From f7fc0eb697d1c178f97e1d549c5f3ceb20f8981b Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sat, 4 Jul 2026 21:27:39 +0300 Subject: [PATCH] feat: port sync and backup/run actions --- actions/backup/run.py | 49 ++++++++++++++++++++++++++++++++ actions/sync/packages.py | 57 ++++++++++++++++++++++++++++++++++++++ actions/sync/repository.py | 54 ++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 actions/backup/run.py create mode 100644 actions/sync/packages.py create mode 100644 actions/sync/repository.py diff --git a/actions/backup/run.py b/actions/backup/run.py new file mode 100644 index 0000000..327c6a9 --- /dev/null +++ b/actions/backup/run.py @@ -0,0 +1,49 @@ +# 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.""" +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).", + 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) + 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") + env.update({k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}) # tier paths, identity + envfiles = [] + for ef in (".env", "apex.env"): # secrets (.env) + committed scalars (apex.env) + if os.path.isfile(os.path.join(core, ef)): + envfiles += ["--env-file", ef] + + def restic(*cmd, check=True): + return ctx.sys.run(["docker", "compose", *envfiles, "--profile", "manual", + "run", "--rm", "restic", *cmd], cwd=core, env=env, check=check) + + try: + log.info("Checking restic repository availability...") + if restic("snapshots", check=False).returncode == 0: + 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: + ctx.notify.error(TITLE, url, "restic backup failed", 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: + ctx.notify.error(TITLE, url, "restic forget/prune failed", node); raise SystemExit(1) + log.success("Restic backup completed.") + ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}", node) + except SystemExit: + raise + except Exception as e: + log.error(f"{type(e).__name__}: {e}") + ctx.notify.error(TITLE, url, f"backup failed: {e}", node) + raise SystemExit(1) diff --git a/actions/sync/packages.py b/actions/sync/packages.py new file mode 100644 index 0000000..f463b19 --- /dev/null +++ b/actions/sync/packages.py @@ -0,0 +1,57 @@ +# actions/sync/packages.py +"""Port of sync/packages — apt upgradable + docker image (skopeo) update check; Telegram.""" +import os, re, shutil +from engine.descriptor import Meta, Arg + +METADATA = Meta(summary="Check pending apt updates + docker image updates; Telegram notify.", + args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")]) +TITLE = "Node Security Updates" + +def run(ctx, args): + log, node, url, s = ctx.log, ctx.node.name, args.telegram_bot_url, ctx.sys + if not os.path.isfile("/etc/debian_version"): + log.error("Requires a Debian-based system."); raise SystemExit(1) + + log.info("Updating package lists...") + if s.ok(["sudo", "apt-get", "update"]): + cp = s.run(["bash", "-c", + "sudo apt-get --just-print upgrade 2>/dev/null | grep '^Inst' | cut -d' ' -f2 | sort"], + check=False, capture=True) + upd = [l for l in (cp.stdout or "").splitlines() if l.strip()] + packages = (f"*Packages ({len(upd)}):* " + ", ".join(f"`{u}`" for u in upd)) if upd else "*Packages:* _none_" + else: + packages = "*Packages:* _Failed to check_" + + if not shutil.which("docker"): + images = "*Images:* _Docker not available_" + elif not shutil.which("skopeo"): + images = "*Images:* _skopeo not available_" + else: + cp = s.run(["bash", "-c", "docker ps --format '{{.Image}}' | sort -u"], check=False, capture=True) + outdated = [] + for image in [l for l in (cp.stdout or "").splitlines() if l.strip()]: + name, sep, tag = image.partition(":") + tag = tag or "latest" + ld = s.run(["bash", "-c", + f"docker image inspect '{image}' --format '{{{{index .RepoDigests 0}}}}' 2>/dev/null | cut -d'@' -f2"], + check=False, capture=True).stdout.strip() + if not ld: + continue + if "/" not in name: + reg = f"docker.io/library/{name}" + elif not re.match(r".*\..*/", name): + reg = f"docker.io/{name}" + else: + reg = name + rd = s.run(["bash", "-c", + f"skopeo inspect 'docker://{reg}:{tag}' 2>/dev/null | " + f"grep -o '\"Digest\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | cut -d'\"' -f4"], + check=False, capture=True).stdout.strip() + if rd and ld != rd: + disp = name[len("docker.io/"):] if name.startswith("docker.io/") else name + disp = disp[len("library/"):] if disp.startswith("library/") else disp + outdated.append(disp) + images = (f"*Images ({len(outdated)}):* " + ", ".join(f"`{i}`" for i in outdated)) if outdated else "*Images:* _none_" + + ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}", node) + log.success("Security update check completed.") diff --git a/actions/sync/repository.py b/actions/sync/repository.py new file mode 100644 index 0000000..05e8cc6 --- /dev/null +++ b/actions/sync/repository.py @@ -0,0 +1,54 @@ +# actions/sync/repository.py +"""Port of sync/repository — capture-up commit + rebase onto origin/trunk + force-push.""" +import os +from datetime import datetime +from engine.descriptor import Meta, Arg + +METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/trunk, force-push sync/.", + args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")]) +TITLE = "Annual Remote Sync" + +def run(ctx, args): + log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url + repo, commons = ctx.paths.repo_root, ctx.paths.commons + env = dict(os.environ, + GIT_AUTHOR_NAME="Adam Jensen [bot]", GIT_AUTHOR_EMAIL="adam@ermnvldmr.com", + GIT_COMMITTER_NAME="Adam Jensen [bot]", GIT_COMMITTER_EMAIL="adam@ermnvldmr.com") + + def git(*a, check=True): + return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env) + + try: + cp = ctx.sys.run(["git", "-C", commons, "status", "--porcelain"], capture=True) + if (cp.stdout or "").strip(): + log.error(f"Commons submodule at {commons} has uncommitted changes. Aborting.") + raise SystemExit(1) + branch = f"sync/{node}" + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if ctx.sys.ok(["git", "-C", repo, "rev-parse", "--verify", branch]): + git("checkout", branch) + else: + git("checkout", "-b", branch) + git("add", "-A") + git("submodule", "update", "--remote", "--recursive") + git("add", "commons") + if git("diff", "--cached", "--quiet", check=False).returncode != 0: + git("commit", "-m", f"[{node}] Auto-commit: {ts}") + else: + log.info("No changes to commit.") + git("fetch", "origin", "trunk") + if git("rebase", "origin/trunk", check=False).returncode != 0: + log.error("Rebase conflict — aborting rebase; manual intervention required.") + git("rebase", "--abort", check=False) + ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.", node) + raise SystemExit(1) + git("submodule", "update", "--init", "--recursive") + git("push", "--force-with-lease", "origin", branch) + log.success(f"Pushed {branch}.") + ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed.", node) + except SystemExit: + raise + except Exception as e: + log.error(f"{type(e).__name__}: {e}") + ctx.notify.error(TITLE, url, f"Sync failed: {e}", node) + raise SystemExit(1)