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
|
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,
|
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.
|
network-disabled helper with **read-only** NAS root mounted, not CI workspace paths.
|
||||||
Only explicitly configured data/config directories are subsequently mounted writable
|
Synology does not automatically create missing bind sources with `-v`. A temporary
|
||||||
for initialization, owned `10333:10333`, mode `0750`. Nonempty directories, symlinks
|
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.
|
(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:
|
A same-name residue can be removed **without force** only after checking all of:
|
||||||
Compose project/service labels; `Status=created`; `Running=false`;
|
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.**
|
**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.
|
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.
|
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
|
Only an explicitly dispatched bootstrap may retry a failed first-install journal, and
|
||||||
before another bootstrap; do not automatically empty those directories or delete the journal.
|
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.
|
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
|
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.
|
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):
|
def state_io(self, payload=None):
|
||||||
script = (
|
script = (
|
||||||
"import json,os,sys;from pathlib import Path;p=Path('/state/deploy.json');"
|
"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
|
if payload is not None
|
||||||
else "from pathlib import Path;print(Path('/state/deploy.json').read_text())"
|
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")
|
raise ValueError("Bootstrap refused: target directory nonempty or unsafe")
|
||||||
volume = self.run("volume", "ls", "--format", "{{.Name}}")
|
volume = self.run("volume", "ls", "--format", "{{.Name}}")
|
||||||
if self.env["PROD_STATE_VOLUME"] in volume.stdout.decode().splitlines():
|
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(
|
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
|
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):
|
def bootstrap_config(self):
|
||||||
config = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
|
config = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
|
||||||
config["name"] = self.env["PROD_PROJECT"]
|
config["name"] = self.env["PROD_PROJECT"]
|
||||||
@@ -549,24 +617,7 @@ class Deploy:
|
|||||||
self.state_io(self.state)
|
self.state_io(self.state)
|
||||||
if residue:
|
if residue:
|
||||||
self.run("rm", residue["Id"])
|
self.run("rm", residue["Id"])
|
||||||
# -v creates only the two explicit daemon-host paths. No parent is mounted writable.
|
self.initialize_directories()
|
||||||
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.activate(candidate)
|
self.activate(candidate)
|
||||||
self.verify(candidate)
|
self.verify(candidate)
|
||||||
self.preflight()
|
self.preflight()
|
||||||
|
|||||||
@@ -321,3 +321,75 @@ def test_bootstrap_recovery_cleans_only_owned_prepared_transaction(tool, monkeyp
|
|||||||
tool.recover()
|
tool.recover()
|
||||||
assert tool.state["status"] == "failed"
|
assert tool.state["status"] == "failed"
|
||||||
assert ("cleanup",) in calls and ("rm", "lock-id") in calls
|
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