Initialize Synology bind directories explicitly and preserve failed bootstrap journals
This commit is contained in:
@@ -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():
|
||||
raise ValueError(
|
||||
"Bootstrap refused: existing deployment journal requires review"
|
||||
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: 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()
|
||||
|
||||
Reference in New Issue
Block a user