Initialize Synology bind directories explicitly and preserve failed bootstrap journals

This commit is contained in:
Pigway
2026-10-08 00:54:15 +08:00
parent e927df9c62
commit b78b998bb8
3 changed files with 155 additions and 27 deletions
+11 -6
View File
@@ -216,11 +216,12 @@ Do not paste the real Secret into logs or repository files.
Bootstrap builds the verified main revision first, then acquires the same atomic
Docker daemon deployment lock used by deploy. Host path checks run in a short-lived,
network-disabled helper with **read-only** NAS root mounted, not CI workspace paths.
Only explicitly configured data/config directories are subsequently mounted writable
for initialization, owned `10333:10333`, mode `0750`. Nonempty directories, symlinks
Synology does not automatically create missing bind sources with `-v`. A temporary
network-disabled helper mounts the explicitly configured existing parent directories
and creates only their data/config leaves, owned `10333:10333`, mode `0750`. It never
mounts NAS root writable, recursively changes parent permissions, or deletes contents.
Parents must already exist; missing parents fail closed. Nonempty directories, symlinks
(including ancestor symlinks), overlapping paths and unknown journal resources fail closed.
Docker may create missing ancestor directories while creating these exact bind sources;
there is no recursive chmod/chown/delete on their parents.
A same-name residue can be removed **without force** only after checking all of:
Compose project/service labels; `Status=created`; `Running=false`;
@@ -241,8 +242,12 @@ The previously validated never-started residue has no rollback value and is not
**Data/config files, images/build cache and the private CD journal volume are retained.**
No `down -v`, volume prune or removal of unrelated/CI resources is used.
Cleanup failure retains the lock so the always recovery step can retry using the journal.
A failed bootstrap journal prevents automatic retry: review preserved data and diagnostics
before another bootstrap; do not automatically empty those directories or delete the journal.
Only an explicitly dispatched bootstrap may retry a failed first-install journal, and
only when its project/container/mounts/port match, its volume ownership matches, and
target directories remain empty or absent. Prepared/success/foreign journals are refused.
On a new transaction, the prior journal is archived as `deploy.<owner>.json` inside
the private state volume before replacing the active journal; generated data is never
automatically emptied and journals are never deleted.
Abrupt host/runner loss still requires operator review of the lock/journal.
The journal is private (0700/0600) and may contain prior/candidate secrets; never export it
as an Actions artifact. First bootstrap has no prior healthy version to roll back to.
+71 -20
View File
@@ -221,7 +221,13 @@ class Deploy:
def state_io(self, payload=None):
script = (
"import json,os,sys;from pathlib import Path;p=Path('/state/deploy.json');"
"os.umask(0o077);os.chmod('/state',0o700);v=json.load(sys.stdin);q=p.with_suffix('.tmp');q.write_text(json.dumps(v));q.replace(p)"
"os.umask(0o077);os.chmod('/state',0o700);v=json.load(sys.stdin);"
"old=json.loads(p.read_text()) if p.exists() else {};"
"owner=old.get('owner','');"
"assert not owner or (len(owner)==32 and all(c in '0123456789abcdef' for c in owner));"
"archive=p.with_name('deploy.'+owner+'.json');"
"archive.write_text(p.read_text()) if owner and owner!=v.get('owner') and not archive.exists() else None;"
"q=p.with_suffix('.tmp');q.write_text(json.dumps(v));q.replace(p)"
if payload is not None
else "from pathlib import Path;print(Path('/state/deploy.json').read_text())"
)
@@ -449,11 +455,73 @@ class Deploy:
raise ValueError("Bootstrap refused: target directory nonempty or unsafe")
volume = self.run("volume", "ls", "--format", "{{.Name}}")
if self.env["PROD_STATE_VOLUME"] in volume.stdout.decode().splitlines():
volume_info = json.loads(
self.run("volume", "inspect", self.env["PROD_STATE_VOLUME"]).stdout
)[0]
if (volume_info.get("Labels") or {}).get(
"frameflow.deploy.project"
) != self.env["PROD_PROJECT"]:
raise ValueError("Bootstrap refused: foreign journal volume")
record = json.loads(self.state_io())
candidate = record.get("candidate", {})
service = candidate.get("services", {}).get("pigway-frame-flow", {})
sources = {
m.get("target"): m.get("source") for m in service.get("volumes", [])
}
if (
record.get("kind") != "bootstrap"
or record.get("status") != "failed"
or candidate.get("name") != self.env["PROD_PROJECT"]
or service.get("container_name") != self.env["PROD_CONTAINER"]
or sources != {"/data": paths[0], "/config": paths[1]}
or service.get("ports")
!= [f"{self.env['PROD_WEB_BIND']}:{self.env['PROD_WEB_PORT']}:18787"]
):
raise ValueError(
"Bootstrap refused: existing deployment journal requires review"
"Bootstrap refused: journal is not a failed, matching first install"
)
print(
"Manual retry of failed bootstrap with empty targets; prior journal will be archived",
flush=True,
)
return old
def initialize_directories(self):
mounts, names = [], []
for key, target in (
("PROD_DATA_DIR", "/parent-data"),
("PROD_CONFIG_DIR", "/parent-config"),
):
path = Path(self.env[key])
if path.parent == Path("/"):
raise ValueError("Refusing writable NAS root mount")
mounts += ["--mount", f"type=bind,source={path.parent},target={target}"]
names.append(str(Path(target) / path.name))
# Synology rejects missing bind sources even with -v. Mount only existing
# explicit parents, then mkdir/chown/chmod only the two validated leaves.
code = (
"import json,os,sys;from pathlib import Path;"
"paths=[Path(v) for v in json.load(sys.stdin)];"
"assert all(not p.is_symlink() and (not p.exists() or (p.is_dir() and not any(p.iterdir()))) for p in paths);"
"[(p.mkdir(exist_ok=True),os.chown(p,10333,10333),os.chmod(p,0o750)) for p in paths]"
)
self.run(
"run",
"--rm",
"-i",
"--user",
"0:0",
"--network",
"none",
*mounts,
"--entrypoint",
"python",
self.old_image,
"-c",
code,
data=json.dumps(names).encode(),
)
def bootstrap_config(self):
config = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
config["name"] = self.env["PROD_PROJECT"]
@@ -549,24 +617,7 @@ class Deploy:
self.state_io(self.state)
if residue:
self.run("rm", residue["Id"])
# -v creates only the two explicit daemon-host paths. No parent is mounted writable.
self.run(
"run",
"--rm",
"--user",
"0:0",
"--network",
"none",
"-v",
self.env["PROD_DATA_DIR"] + ":/data",
"-v",
self.env["PROD_CONFIG_DIR"] + ":/config",
"--entrypoint",
"python",
self.old_image,
"-c",
"import os;from pathlib import Path;paths=[Path('/data'),Path('/config')];assert all(not any(p.iterdir()) for p in paths);[(os.chown(p,10333,10333),os.chmod(p,0o750)) for p in paths]",
)
self.initialize_directories()
self.activate(candidate)
self.verify(candidate)
self.preflight()
+72
View File
@@ -321,3 +321,75 @@ def test_bootstrap_recovery_cleans_only_owned_prepared_transaction(tool, monkeyp
tool.recover()
assert tool.state["status"] == "failed"
assert ("cleanup",) in calls and ("rm", "lock-id") in calls
@pytest.mark.parametrize("status", ["failed", "prepared", "success"])
def test_retry_journal_only_failed_matching_empty_bootstrap(tool, monkeypatch, status):
prepare(tool, monkeypatch)
record = {
"kind": "bootstrap",
"status": status,
"candidate": tool.bootstrap_config(),
}
def run(*args, **kw):
if args[:2] == ("volume", "ls"):
return SimpleNamespace(stdout=tool.env["PROD_STATE_VOLUME"].encode())
return SimpleNamespace(
stdout=json.dumps(
[{"Labels": {"frameflow.deploy.project": tool.env["PROD_PROJECT"]}}]
).encode()
)
monkeypatch.setattr(tool, "run", run)
monkeypatch.setattr(tool, "state_io", lambda: json.dumps(record).encode())
if status == "failed":
assert tool.bootstrap_check() is None
record["candidate"]["services"]["pigway-frame-flow"]["volumes"][0]["source"] = (
"/foreign/data"
)
with pytest.raises(ValueError):
tool.bootstrap_check()
def test_synology_initialization_mounts_only_existing_parents_and_creates_leaves(
tool, monkeypatch, tmp_path
):
calls = []
monkeypatch.setattr(tool, "run", lambda *a, **kw: calls.append((a, kw)))
tool.initialize_directories()
args, kw = calls[0]
assert "-v" not in args and "--network" in args
assert [args[i + 1] for i, value in enumerate(args) if value == "--mount"] == [
"type=bind,source=/confirmed,target=/parent-data",
"type=bind,source=/confirmed,target=/parent-config",
]
code = args[args.index("-c") + 1]
import subprocess
import sys
paths = [str(tmp_path / "data"), str(tmp_path / "config")]
# Keep the production permission operations in the script; replace only UID for local test.
code = code.replace("os.chown(p,10333,10333)", "None")
result = subprocess.run(
[sys.executable, "-c", code],
input=json.dumps(paths).encode(),
capture_output=True,
)
assert result.returncode == 0
assert all(
(tmp_path / name).stat().st_mode & 0o777 == 0o750 for name in ("data", "config")
)
(tmp_path / "data" / "keep").write_text("must survive")
result = subprocess.run(
[sys.executable, "-c", code],
input=json.dumps(paths).encode(),
capture_output=True,
)
assert (
result.returncode != 0
and (tmp_path / "data" / "keep").read_text() == "must survive"
)
tool.env["PROD_DATA_DIR"] = "/data"
with pytest.raises(ValueError, match="NAS root"):
tool.initialize_directories()