Initialize Synology bind directories explicitly and preserve failed bootstrap journals
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user