Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a53b9b2f | ||
|
|
b41fc6097f | ||
|
|
334ca196e0 | ||
|
|
0b42ab1bfc |
No files matched your search
@@ -14,7 +14,7 @@ action to its bash source and the behaviors that must match.
|
||||
| configure | (new umbrella) | run base,ufw,crowdsec,cron,systemd,routing in order |
|
||||
| sync/repository | actions/sync/repository | sync/<node> branch; add -A + commons submodule; commit; rebase origin/main (was trunk in the monorepo); force-with-lease; telegram |
|
||||
| sync/packages | actions/sync/packages | apt --just-print upgrade Inst list; docker+skopeo digest compare; telegram info |
|
||||
| backup/run | actions/sync/tiers (restic-backup) | init-if-needed; backup /data/@tier1,2 --host --tag com-ermnvldmr-root-<node>; forget --prune 7/4/12; telegram |
|
||||
| backup/run | actions/sync/tiers (restic-backup) | init-if-needed; label-driven backup via resticontainer (discovers `restic.*`-labelled services, runs pre-hooks/stops, snapshots the resolved host paths) --host <node>.apex.ermnvldmr.com --tag biweekly --compression max; guard on a fresh snapshot; forget --prune 7/4/12; telegram |
|
||||
| tiers/link | actions/tiers/link | node + per-project @tierN symlinks; shared/ links; .env symlink |
|
||||
| tiers/useradd | actions/tiers/useradd | noroot-<proj> system users; noroot-shared group; APEX_UID/GID block in .env |
|
||||
| tiers/chown | actions/tiers/chown | {tier}/{proj}=uid:gid; {tier}/shared/{proj}=uid:sgid+sticky; base=root:root |
|
||||
@@ -26,7 +26,8 @@ action to its bash source and the behaviors that must match.
|
||||
## Deliberate deltas (rename + fixes)
|
||||
- root->apex everywhere (CLI, container names apex-*, telegram instance com.ermnvldmr.apex.*, ufw markers APEX.*, networks direct/enclave/socket).
|
||||
- No <node> arg; identity from FQDN + node.env.
|
||||
- restic core service gains read-only /data/@tier1,2 mounts (makes existing backup command functional).
|
||||
- backup superseded by the resticontainer migration (see below): the old whole-tier-root
|
||||
restic snapshot is replaced by per-service, `restic.*`-label-driven backups.
|
||||
- ufw markers self-heal (delete ROOT + APEX blocks before inject).
|
||||
- envsubst -> string.Template.safe_substitute; wget/curl download -> urllib. Unset template
|
||||
vars stay LITERAL (envsubst emptied them) — typos surface instead of silently blanking.
|
||||
@@ -57,3 +58,18 @@ action to its bash source and the behaviors that must match.
|
||||
- restic: per-node selective tier2 mounts replaced by whole-tier-root RO mounts
|
||||
(superset; snapshot paths preserved); RESTIC_COMPRESSION per-node via
|
||||
`APEX_RESTIC_COMPRESSION` (default auto).
|
||||
|
||||
## resticontainer migration (SP4 realized)
|
||||
- The `restic` core service is replaced by `resticontainer`
|
||||
(`ghcr.io/deytenit/resticontainer`), a restic wrapper that reads per-service
|
||||
`restic.*` compose labels: `restic.enable`, `restic.backup.paths` (comma-separated
|
||||
container mount destinations — matched exactly, whole mount dir backed up),
|
||||
`restic.backup.stop`, and `restic.hooks.pre-backup`/`post-backup` (run via docker exec).
|
||||
- It mounts the docker socket (list/inspect/exec/stop/start) and the host root
|
||||
`/:/hostfs:ro` (`RESTIC_HOSTFS=/hostfs`); it resolves each labelled mount to
|
||||
`/hostfs` + the mount's host source and snapshots the union in one restic run.
|
||||
- The whole-tier-root RO mounts and the explicit `/data/@tier1,2` backup args are gone;
|
||||
a service is backed up only if it carries `restic.enable=true`. DB consistency is
|
||||
per-service: a pre-backup hook dumps into a dedicated backed-up mount (e.g. postgres
|
||||
`pg_dump … > /dumps/dump.sql`), never the live data dir. `backup/run` now guards on a
|
||||
fresh snapshot landing for the host (a no-label repo would otherwise be a silent no-op).
|
||||
@@ -3,6 +3,8 @@
|
||||
The **commons** of the apex fleet: a stock-python engine (`apex <group>/<action>`) and
|
||||
the shared core docker-compose, consumed by every node as the `commons/` git submodule.
|
||||
|
||||
Full documentation: [docs.ermnvldmr.com/en/apex](https://docs.ermnvldmr.com/en/apex/).
|
||||
|
||||
## Layout
|
||||
- `apex` — launcher (python3, stdlib only); node `init.sh` symlinks it onto `PATH`.
|
||||
- `engine/` — the runner: CLI, identity, overlay, context, and the `ctx.*` helper library.
|
||||
|
||||
+36
-9
@@ -1,17 +1,23 @@
|
||||
# actions/backup/run.py
|
||||
"""Port of sync/tiers restic-backup (SP4: behavior-identical). Runs the restic core
|
||||
service one-shot: init-if-needed -> backup tiers -> forget --prune -> Telegram."""
|
||||
"""Label-driven restic backup via resticontainer (github.com/deytenit/resticontainer).
|
||||
Runs the resticontainer core service one-shot: init-if-needed -> backup (discovers
|
||||
`restic.*`-labelled services, runs their pre-hooks/stops, snapshots the union of the
|
||||
resolved host paths in a single restic run) -> forget --prune -> Telegram. Every
|
||||
non-`backup` subcommand passes straight through to restic, so init/snapshots/forget
|
||||
behave exactly as before."""
|
||||
import json
|
||||
import os
|
||||
from engine.descriptor import Meta, Arg
|
||||
|
||||
METADATA = Meta(summary="Run restic backup of the backed-up tiers (init-if-needed, backup, forget --prune).",
|
||||
METADATA = Meta(summary="Run the resticontainer label-driven backup (init-if-needed, backup, forget --prune).",
|
||||
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
|
||||
TITLE = "Restic Host Backup"
|
||||
|
||||
def run(ctx, args):
|
||||
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
|
||||
core = ctx.paths.core
|
||||
tag = f"com-ermnvldmr-root-{node}" # KEPT unchanged for backup continuity (SP4/Decision D)
|
||||
host = f"{node}.apex.ermnvldmr.com" # restic --host: the per-node public name (matches the bucket)
|
||||
tag = "biweekly" # restic --tag: snapshot cadence (this cron fires on the 5th & 20th)
|
||||
if not os.path.isfile(os.path.join(core, "docker-compose.yml")):
|
||||
log.error(f"Core compose not found in {core}"); raise SystemExit(1)
|
||||
env = dict(os.environ, COMPOSE_PROFILES="manual")
|
||||
@@ -21,9 +27,20 @@ def run(ctx, args):
|
||||
if os.path.isfile(os.path.join(core, ef)):
|
||||
envfiles += ["--env-file", ef]
|
||||
|
||||
def restic(*cmd, check=True):
|
||||
def restic(*cmd, check=True, capture=False):
|
||||
return ctx.sys.run(["docker", "compose", *envfiles, "--profile", "manual",
|
||||
"run", "--rm", "restic", *cmd], cwd=core, env=env, check=check)
|
||||
"run", "--rm", "-T", "resticontainer", *cmd],
|
||||
cwd=core, env=env, check=check, capture=capture)
|
||||
|
||||
def host_snapshot_ids():
|
||||
"""This host's snapshot IDs, or None if it can't be determined (never fail on this)."""
|
||||
cp = restic("snapshots", "--host", host, "--json", check=False, capture=True)
|
||||
if cp.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return {s.get("id") for s in json.loads(cp.stdout or "[]")}
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
try:
|
||||
log.info("Checking restic repository availability...")
|
||||
@@ -31,10 +48,20 @@ def run(ctx, args):
|
||||
log.info("Restic repository reachable.")
|
||||
elif restic("init", check=False).returncode != 0:
|
||||
ctx.notify.error(TITLE, url, "restic init failed", node); raise SystemExit(1)
|
||||
log.info("Backing up /data/@tier1 and /data/@tier2 ...")
|
||||
if restic("backup", "/data/@tier1", "/data/@tier2", "--host", node, "--tag", tag,
|
||||
"--compression", "auto", check=False).returncode != 0:
|
||||
before = host_snapshot_ids()
|
||||
log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...")
|
||||
if restic("backup", "--host", host, "--tag", tag, "--compression", "max",
|
||||
check=False).returncode != 0:
|
||||
ctx.notify.error(TITLE, url, "restic backup failed", node); raise SystemExit(1)
|
||||
# Guard: the label-driven backup is a silent no-op if no service carries the
|
||||
# `restic.enable` label (resticontainer finds zero paths and skips restic).
|
||||
# Prove a fresh snapshot for this host actually landed before we prune/notify.
|
||||
after = host_snapshot_ids()
|
||||
if before is not None and after is not None and not (after - before):
|
||||
log.error("No new snapshot produced — refusing to report success.")
|
||||
ctx.notify.error(TITLE, url,
|
||||
"backup produced no new snapshot (no restic.* labels discovered?)", node)
|
||||
raise SystemExit(1)
|
||||
log.info("Applying retention (forget --prune)...")
|
||||
if restic("forget", "--prune", "--keep-daily", "7", "--keep-weekly", "4",
|
||||
"--keep-monthly", "12", check=False).returncode != 0:
|
||||
|
||||
@@ -46,6 +46,16 @@ def run(ctx, args):
|
||||
full = os.path.join(comp, proj)
|
||||
if not os.path.isdir(full) or proj.startswith(".") or proj.startswith("@"):
|
||||
continue
|
||||
# opt-out: .apex-root-owned forces this project's tier data (dir + children,
|
||||
# recursively) to root:root — for services that run as root and reject
|
||||
# non-root-owned data (e.g. seafile-mc). Blocks noroot ownership entirely.
|
||||
if os.path.isfile(os.path.join(full, ".apex-root-owned")):
|
||||
log.info(f"Applying root ownership for {proj} (.apex-root-owned)")
|
||||
for tp in tiers:
|
||||
for d in (os.path.join(tp, proj), os.path.join(tp, "shared", proj)):
|
||||
if os.path.isdir(d):
|
||||
s.sudo(["chown", "-R", "root:root", d]); log.success(f" Owned {d} (root)")
|
||||
continue
|
||||
env_path = os.path.join(full, ".env")
|
||||
uid = _env_val(env_path, "APEX_UID")
|
||||
gid = _env_val(env_path, "APEX_GID")
|
||||
|
||||
@@ -34,6 +34,12 @@ def run(ctx, args):
|
||||
full = os.path.join(comp, proj)
|
||||
if not os.path.isdir(full) or proj.startswith(".") or proj.startswith("@"):
|
||||
continue
|
||||
# opt-out: a project marked .apex-root-owned runs as root and keeps its tier
|
||||
# data root-owned (e.g. seafile-mc, whose entrypoint refuses non-root-owned
|
||||
# data). Skip the noroot user + .env UID injection; tiers/chown forces root.
|
||||
if os.path.isfile(os.path.join(full, ".apex-root-owned")):
|
||||
log.info(f"{proj}: .apex-root-owned — runs as root; skipping noroot user")
|
||||
continue
|
||||
base = f"noroot-{_sanitize(proj)}"
|
||||
cand, i = base, 1
|
||||
while cand in used:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// ============================================================================
|
||||
// APEX fleet observability agent — per-node Grafana Alloy config (shared).
|
||||
// Committed, env-parameterized: every node runs this SAME file; per-node values
|
||||
// come from sys.env(...) (APEX_NODE, push URLs, obs basic-auth creds).
|
||||
// Verified against grafana.com/docs/alloy/latest (Alloy v1.17.1).
|
||||
// ============================================================================
|
||||
// Model: PUSH. Metrics -> remote_write to the central Prometheus (on icarus);
|
||||
// logs -> loki.write to the central Loki (on icarus). daedalus/morpheus reach
|
||||
// them through the public gfn.ermnvldmr.com front (authelia-bypassed + basicauth
|
||||
// on /loki and /prometheus/api/v1/write); icarus resolves gfn locally.
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// METRICS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Host metrics (node_exporter-equivalent). Container mounts host /proc,/sys,/
|
||||
// at /host/proc,/host/sys,/rootfs so the exporter reads the HOST.
|
||||
prometheus.exporter.unix "node" {
|
||||
procfs_path = "/host/proc"
|
||||
sysfs_path = "/host/sys"
|
||||
rootfs_path = "/rootfs"
|
||||
}
|
||||
|
||||
// Container metrics (cadvisor-equivalent) via the docker socket.
|
||||
prometheus.exporter.cadvisor "containers" {
|
||||
docker_host = "unix:///var/run/docker.sock"
|
||||
store_container_labels = true
|
||||
}
|
||||
|
||||
// Stamp a static `node` label onto each exporter's targets.
|
||||
discovery.relabel "node_metrics" {
|
||||
targets = prometheus.exporter.unix.node.targets
|
||||
rule {
|
||||
action = "replace"
|
||||
target_label = "node"
|
||||
replacement = sys.env("APEX_NODE")
|
||||
}
|
||||
}
|
||||
discovery.relabel "container_metrics" {
|
||||
targets = prometheus.exporter.cadvisor.containers.targets
|
||||
rule {
|
||||
action = "replace"
|
||||
target_label = "node"
|
||||
replacement = sys.env("APEX_NODE")
|
||||
}
|
||||
}
|
||||
|
||||
prometheus.scrape "node" {
|
||||
targets = discovery.relabel.node_metrics.output
|
||||
forward_to = [prometheus.remote_write.default.receiver]
|
||||
scrape_interval = "15s"
|
||||
}
|
||||
prometheus.scrape "containers" {
|
||||
targets = discovery.relabel.container_metrics.output
|
||||
forward_to = [prometheus.remote_write.default.receiver]
|
||||
scrape_interval = "15s"
|
||||
}
|
||||
|
||||
// Push metrics to the central Prometheus (needs --web.enable-remote-write-receiver).
|
||||
prometheus.remote_write "default" {
|
||||
endpoint {
|
||||
url = sys.env("APEX_PROM_PUSH_URL")
|
||||
basic_auth {
|
||||
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
|
||||
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// LOGS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// systemd journal (host services, ufw, sshd, crowdsec, etc.).
|
||||
loki.source.journal "systemd" {
|
||||
path = "/var/log/journal"
|
||||
max_age = "12h"
|
||||
labels = {
|
||||
node = sys.env("APEX_NODE"),
|
||||
job = "systemd-journal",
|
||||
}
|
||||
forward_to = [loki.write.default.receiver]
|
||||
}
|
||||
|
||||
// All docker containers' stdout/stderr.
|
||||
discovery.docker "containers" {
|
||||
host = "unix:///var/run/docker.sock"
|
||||
}
|
||||
discovery.relabel "container_logs" {
|
||||
targets = discovery.docker.containers.targets
|
||||
rule {
|
||||
action = "replace"
|
||||
source_labels = ["__meta_docker_container_name"]
|
||||
regex = "/?(.*)"
|
||||
target_label = "container"
|
||||
}
|
||||
rule {
|
||||
action = "replace"
|
||||
target_label = "node"
|
||||
replacement = sys.env("APEX_NODE")
|
||||
}
|
||||
}
|
||||
loki.source.docker "containers" {
|
||||
host = "unix:///var/run/docker.sock"
|
||||
targets = discovery.relabel.container_logs.output
|
||||
relabel_rules = discovery.relabel.container_logs.rules
|
||||
forward_to = [loki.write.default.receiver]
|
||||
}
|
||||
|
||||
// Push logs to the central Loki.
|
||||
loki.write "default" {
|
||||
endpoint {
|
||||
url = sys.env("APEX_LOKI_PUSH_URL")
|
||||
basic_auth {
|
||||
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
|
||||
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,30 +27,39 @@ services:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- backups (host network, manual profile, one-shot) ---
|
||||
restic:
|
||||
image: restic/restic:0.18.1
|
||||
container_name: apex-restic
|
||||
# resticontainer (github.com/deytenit/resticontainer): a restic wrapper that
|
||||
# discovers per-service backup intent from `restic.*` compose labels
|
||||
# (restic.enable / restic.backup.paths / restic.backup.stop / restic.hooks.*).
|
||||
# On `backup` it queries the docker daemon, runs each service's pre-hook (e.g.
|
||||
# pg_dump), stops any container flagged restic.backup.stop, resolves the labeled
|
||||
# container mounts to host paths (via /hostfs + mount source), snapshots the union
|
||||
# in a single restic run, then restarts + runs post-hooks. Every other subcommand
|
||||
# (init/snapshots/forget/…) passes straight through to restic. Needs the docker
|
||||
# socket (list/inspect/exec/stop/start) and the host root read-only (to read the
|
||||
# data) — a privileged manual one-shot, same trust surface as the old restic core.
|
||||
resticontainer:
|
||||
image: ghcr.io/deytenit/resticontainer:0.19.1
|
||||
container_name: apex-resticontainer
|
||||
profiles: [manual]
|
||||
volumes:
|
||||
- ${APEX_TIER3}/restic/cache:/var/restic-cache
|
||||
# FIX (Task 1.11): mount the backed-up tier roots read-only so `restic backup
|
||||
# /data/@tier1 /data/@tier2` actually snapshots data (was missing today).
|
||||
- ${APEX_TIER_ROOT1}:/data/@tier1:ro
|
||||
- ${APEX_TIER_ROOT2}:/data/@tier2:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock # query + exec/stop/start
|
||||
- /:/hostfs:ro # read the data off the host
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
RESTIC_HOSTFS: /hostfs # prefix for resolved mount sources
|
||||
RESTIC_REPOSITORY: "${APEX_RESTIC_REPOSITORY?error}"
|
||||
RESTIC_PASSWORD: "${APEX_RESTIC_PASSWORD?error}"
|
||||
AWS_ACCESS_KEY_ID: "${APEX_RESTIC_AWS_ACCESS_KEY_ID?error}"
|
||||
AWS_SECRET_ACCESS_KEY: "${APEX_RESTIC_AWS_SECRET_ACCESS_KEY?error}"
|
||||
AWS_REGION: "ru-central1"
|
||||
AWS_ENDPOINT_URL: "https://storage.yandexcloud.net/"
|
||||
# per-node override (icarus historically ran "max" for manual invocations;
|
||||
# scheduled backups always force --compression auto on the CLI)
|
||||
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-auto}"
|
||||
# max compression is fleet policy (backup/run also forces --compression max on
|
||||
# the CLI; needs repo format v2, which a fresh restic init creates). Per-node
|
||||
# override via APEX_RESTIC_COMPRESSION only if ever needed.
|
||||
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}"
|
||||
RESTIC_CACHE_DIR: /var/restic-cache
|
||||
network_mode: "host"
|
||||
entrypoint: ["restic"]
|
||||
|
||||
# --- docker socket proxy ---
|
||||
socket-proxy:
|
||||
@@ -152,27 +161,48 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- optional: loki log aggregation (node-local, enclave) ---
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
container_name: apex-loki
|
||||
profiles: [loki]
|
||||
command: ["-config.file=/etc/loki/config.yaml"]
|
||||
# --- optional: observability agent (profile-gated) ---
|
||||
# Grafana Alloy: one agent per node. Collects host metrics (node_exporter-
|
||||
# equivalent) + container metrics (cadvisor) and PUSHES them via remote_write;
|
||||
# collects systemd-journal + docker logs and PUSHES them to Loki. All targets
|
||||
# are OUTBOUND to the central gfn stack (Prometheus + Loki live on icarus), so
|
||||
# Alloy needs egress (direct, not enclave). Replaces the old store-only `loki`
|
||||
# service (which had no feeder). Config is a committed, env-parameterized asset.
|
||||
alloy:
|
||||
image: grafana/alloy:v1.17.1
|
||||
container_name: apex-alloy
|
||||
profiles: [alloy]
|
||||
user: "0:0" # root: required for host metrics, journal, and docker.sock
|
||||
volumes:
|
||||
- ${APEX_TIER1}/loki/config.yaml:/etc/loki/config.yaml:ro
|
||||
- ${APEX_TIER2}/loki/data:/loki
|
||||
- ${APEX_COMMONS}/assets/alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||
- ${APEX_TIER2}/alloy/data:/var/lib/alloy
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
- /var/log/journal:/var/log/journal:ro
|
||||
- /run/log/journal:/run/log/journal:ro
|
||||
- /etc/machine-id:/etc/machine-id:ro
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
APEX_NODE: "${APEX_NODE?error}"
|
||||
# push targets + machine auth (per-node scalars in apex.env / secret .env)
|
||||
APEX_LOKI_PUSH_URL: "${APEX_LOKI_PUSH_URL?error}"
|
||||
APEX_PROM_PUSH_URL: "${APEX_PROM_PUSH_URL?error}"
|
||||
APEX_OBS_BASIC_AUTH_USER: "${APEX_OBS_BASIC_AUTH_USER?error}"
|
||||
APEX_OBS_BASIC_AUTH_PASS: "${APEX_OBS_BASIC_AUTH_PASS?error}"
|
||||
command:
|
||||
- run
|
||||
- --server.http.listen-addr=0.0.0.0:12345
|
||||
- --storage.path=/var/lib/alloy
|
||||
- /etc/alloy/config.alloy
|
||||
networks:
|
||||
enclave: {}
|
||||
direct: {}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
user: "${APEX_UID?error}:${APEX_GID?error}"
|
||||
labels:
|
||||
# /loki/* is loki's native API prefix — no strip needed. basicauth users come
|
||||
# from the node's uncommitted .env (only needed when the loki profile is on).
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.apex-loki.rule=Host(`${APEX_NODE_FQDN}`) && PathPrefix(`/loki`)"
|
||||
- "traefik.http.routers.apex-loki.middlewares=apex-loki-auth"
|
||||
- "traefik.http.middlewares.apex-loki-auth.basicauth.users=${APEX_LOKI_BASIC_AUTH}"
|
||||
- "traefik.http.services.apex-loki.loadbalancer.server.port=3100"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
direct:
|
||||
|
||||
Reference in new issue
Block a user