Add opt-in production CD with daemon lock, verified rollback and safe planning

This commit is contained in:
Pigway
2026-10-07 23:15:31 +08:00
parent f7e65de0a2
commit 2e8251abef
5 changed files with 1061 additions and 1 deletions
+57 -1
View File
@@ -1,5 +1,14 @@
name: Verify Frame Flow name: Verify Frame Flow
on: [push, pull_request, workflow_dispatch] on:
push:
pull_request:
workflow_dispatch:
inputs:
deploy_action:
description: Production action (deploy requires explicit enabled Variable)
type: choice
options: [plan, deploy, rollback]
default: plan
jobs: jobs:
verify: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -30,3 +39,50 @@ jobs:
if [ -n "${FRAME_FLOW_CI_COMPOSE:-}" ] && [ -f "$FRAME_FLOW_CI_COMPOSE" ]; then if [ -n "${FRAME_FLOW_CI_COMPOSE:-}" ] && [ -f "$FRAME_FLOW_CI_COMPOSE" ]; then
python scripts/ci-compose-verify.py --cleanup python scripts/ci-compose-verify.py --cleanup
fi fi
deploy:
needs: verify
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
concurrency:
group: frame-flow-production
cancel-in-progress: false
env:
CD_ACTION: ${{ github.event.inputs.deploy_action }}
PROD_DEPLOY_ENABLED: ${{ vars.PROD_DEPLOY_ENABLED }}
PROD_PROJECT: ${{ vars.PROD_PROJECT }}
PROD_CONTAINER: ${{ vars.PROD_CONTAINER }}
PROD_IMAGE_REPO: ${{ vars.PROD_IMAGE_REPO }}
PROD_DATA_DIR: ${{ vars.PROD_DATA_DIR }}
PROD_CONFIG_DIR: ${{ vars.PROD_CONFIG_DIR }}
PROD_PHOTOS_DIR: ${{ vars.PROD_PHOTOS_DIR }}
PROD_WEB_BIND: ${{ vars.PROD_WEB_BIND }}
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
PROD_MOCK: ${{ vars.PROD_MOCK }}
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.14'
- name: Install deploy tooling
run: python scripts/ci-install.py
- name: Plan or deploy production (default is plan)
env:
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
run: |
. ./deploy/build-cn.env
python scripts/production-deploy.py
- name: Recover interrupted production deployment
if: always()
env:
CD_ACTION: recover
run: |
if [ -n "${FRAME_FLOW_DEPLOY_OWNER:-}" ]; then
python scripts/production-deploy.py
fi
+2
View File
@@ -338,3 +338,5 @@ APT only and are loaded later in the Docker verification step.
If dependency installation fails, the always() cleanup step still executes. If dependency installation fails, the always() cleanup step still executes.
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists, It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
as no Docker verification resources have been created at that point. as no Docker verification resources have been created at that point.
Production CD: [safe trial, configuration, and rollback](docs/deployment/GITEA_CD.md).
+169
View File
@@ -0,0 +1,169 @@
# Gitea Actions production CD v1
## Audit and adoption boundary
Production `docker-compose.yml` is unchanged: AMD64 image, UID/GID 10333,
`unless-stopped`, read-only rootfs, `/tmp` tmpfs, dropped capabilities, limited
logs, `/data` and `/config` bind mounts, default private bridge, and configurable
host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
vendor sessions and configuration live under `/config`. Neither is disposable.
The optional photos mount is read-only and has supplementary group access.
Historical repository receipts mention `/volume2/docker/pigway-frame-flow/data`,
`/volume2/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
container/project `pigway-frame-flow`. These are **confirmation candidates**, not
script defaults. The deployment script accepts only explicitly configured paths
that match the existing running container on the Runner's Docker daemon.
It rejects different projects, ports, unknown mounts, host/custom networking,
missing photos confirmation, or different user/restart policy. It does not
create NAS host directories or migrate/adopt an unrelated container.
CI uses `ffci-*` projects, per-run image tags and named volumes, no host ports.
Its cleanup does not refer to any production resource. Production uses fixed
configured names and existing bind mounts, with `create_host_path: false`.
## Gitea configuration
Repository Settings → Actions → Variables (ordinary configuration):
| Variable | Value to explicitly confirm |
| --- | --- |
| `PROD_DEPLOY_ENABLED` | Initially `false`; change to `true` only after plan acceptance |
| `PROD_PROJECT` | Existing Compose project label; candidate `pigway-frame-flow` |
| `PROD_CONTAINER` | Existing container name; candidate `pigway-frame-flow` |
| `PROD_IMAGE_REPO` | Fixed local image repository; suggested `pigway-frame-flow` |
| `PROD_DATA_DIR` | Existing absolute NAS host data path; no default |
| `PROD_CONFIG_DIR` | Existing absolute NAS host config path; no default |
| `PROD_PHOTOS_DIR` | Existing read-only photos source if present; otherwise empty |
| `PROD_WEB_BIND` | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
| `PROD_WEB_PORT` | Exact existing mapped port |
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
The state volume must be separate from application/CI storage. An existing
volume without the matching `frameflow.deploy.project` label is rejected.
Supplementary groups are read from the existing container; no host ACL changes.
Keep existing `DEBIAN_MIRROR`, `DEBIAN_SECURITY_MIRROR`, `CI_PIP_INDEX_URL`, and
`CI_PIP_FALLBACK_INDEX_URL` settings from Verify; CI/production builds share the
Dockerfile, pinned base, lockfile, and BuildKit caches.
Repository Settings → Actions → Secrets:
`PRODUCTION_ENV`: multiline **unquoted** `KEY=value` entries, without shell
expansion. At least:
```dotenv
FRAME_FLOW_TOKEN=<existing Frame Flow administrator token>
KOKONNA_API_KEY=<existing KoKonna key, or empty when mock>
```
Optional additional runtime secret variables can be included. No key values are
committed, passed to image builds, or printed. Nonsecret mock/write flags use
Variables. Other existing runtime environment values are retained. The script
sets commit/build time and internal data/config paths explicitly. Dollar signs
in secret values are escaped before Compose interpolation. Snapshots containing
old runtime secrets are stored only in a private journal file (0600, directory
0700) on the dedicated Docker volume; Docker administrators can access it.
Never upload that journal or a full Docker inspect as a public CI artifact.
## First safe trial
1. On NAS, inspect only the necessary fields (use existing Docker/sudo access):
```sh
docker inspect pigway-frame-flow --format '{{index .Config.Labels "com.docker.compose.project"}}'
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
```
Confirm the Variables against these actual values, including photos.
2. Configure the Secret privately. Keep `PROD_DEPLOY_ENABLED=false`.
3. Manually run **Verify Frame Flow** on `main`, `deploy_action=plan`.
Verify must pass first. Plan checks resource identity, paths/port, required
Secret keys, current `/ready`, smoke endpoints and commit. It performs no
build, stop, restart, tagging, lock creation, volume creation, or data writes.
If no production Variables exist, it reports configuration missing and does
nothing; that is an unconfigured plan, not production acceptance.
4. Review `PLAN ONLY` output and arrange a maintenance window for the first real
deploy/restart. Set `PROD_DEPLOY_ENABLED=true`, manually run `deploy_action=deploy`.
5. After `DEPLOY VERIFIED`, subsequent pushes to `main` run Verify → deploy.
Pushes to other branches and pull requests never deploy. A manual run defaults
to plan, even when automatic deployment is enabled.
The script intentionally supports updates to an existing healthy deployment,
not first-time bootstrap. No real production deployment or rollback drill has
been performed during implementation; those are first-run acceptance steps.
## Deployment transaction and rollback
1. Verify job must succeed completely, including isolated Docker tests/cleanup.
2. Deploy job checks out the same revision and prepares tools. An atomic
`docker create` reservation named `<PROD_CONTAINER>-deploy-lock` serializes
production changes on the daemon. Job `concurrency` is also declared, but
correctness does not depend on Gitea implementing it. A competing deploy
fails safely instead of changing production. No other job may remove a lock
whose owner label differs from its own.
3. Re-read the current baseline under the lock; confirm ready/smoke. Record exact
image ID, runtime Compose/environment, version/commit. Pin the old image with
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
and without changing `latest` or CI image tags.
5. Persist a `prepared` journal with old/new runtime configs. Create an additive
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
directory or file is removed, replaced or recursively chowned.
6. `compose up -d --no-build --pull never` replaces only the configured service.
Check `/ready`, the unchanged nine-endpoint read-only smoke test, and exact
`/api/v1/version` commit. Only all checks passing produce `DEPLOY VERIFIED`.
7. Any activation/verification failure runs `up` again with the saved exact old
image ID and old runtime environment/mounts, then repeats ready/smoke/commit
verification. A failed deploy remains a failed workflow even if rollback
succeeds. If rollback fails, the journal/lock are retained, failure is explicit,
and redacted scoped diagnostics are emitted.
8. An `always()` step uses the recorded owner to recover an interrupted prepared
transaction and release only its own lock. Abrupt host loss or termination of
the entire runner can prevent both handlers: inspect the journal/lock before
manually recovering; never automatically force-unlock an unknown owner.
Rollback is an **image/config rollback**, not an automatic database rewind.
User data accumulated during deployment remains. The SQLite backup is for
operator-reviewed recovery; destructive or backward-incompatible future schema
changes must not be auto-deployed without a separate migration/backup plan.
Baseline artifacts and additive backups are retained for the operator; there is
no automated pruning of production rollback history or data.
After enabling CD, use its workflow/script as the production lifecycle owner.
The old NAS checkout's Compose/.env files are not rewritten remotely by CD and
may describe an older deployment. Do not concurrently recreate production from
that old checkout. The repository's standalone Compose remains usable for an
independent deployment; the private journal records the CD runtime configuration.
## Actual commit and manual rollback
```sh
curl -fsS http://NAS_LAN_IP:PORT/api/v1/version
# Check `commit`, `version`, and `build_time` against successful deployment output.
docker inspect PRODUCTION_CONTAINER --format '{{.Image}}'
```
For a manual rollback: keep CD enabled and run **Verify Frame Flow** on `main`
with `deploy_action=rollback`. After Verify passes, the script locks production,
loads the last journal's `previous` image/config and verifies recovery. This is
last-baseline rollback, not an arbitrary image selector. Never use `down -v`,
volume pruning, or delete data/config to roll back.
If a failed run retained a lock, inspect only its owner label:
```sh
docker inspect PRODUCTION_CONTAINER-deploy-lock --format '{{index .Config.Labels "frameflow.deploy.owner"}}'
```
With the documented Variables configured in a trusted Docker-enabled shell:
```sh
FRAME_FLOW_DEPLOY_OWNER=<confirmed failed owner> CD_ACTION=recover python scripts/production-deploy.py
```
`recover` retries that owner's journaled rollback and retains the lock again if
recovery fails. If the very first attempt stopped before a journal was written,
confirm that the original image is still running and healthy before an operator
removes the stale lock. Do not delete a live lock or any persistence volume.
+588
View File
@@ -0,0 +1,588 @@
"""Opt-in production deployment; host paths verified against the running container."""
import json
import os
import re
import signal
import subprocess
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
import yaml
ROOT = Path(__file__).resolve().parents[1]
REQUIRED = (
"PROD_PROJECT",
"PROD_CONTAINER",
"PROD_IMAGE_REPO",
"PROD_DATA_DIR",
"PROD_CONFIG_DIR",
"PROD_WEB_BIND",
"PROD_WEB_PORT",
"PROD_STATE_VOLUME",
"PROD_MOCK",
"PROD_WRITE_ENABLED",
)
class Deploy:
def __init__(self):
self.env = os.environ.copy()
self.owner = self.env.get("FRAME_FLOW_DEPLOY_OWNER") or uuid4().hex
if not re.fullmatch(r"[0-9a-f]{32}", self.owner):
raise ValueError("Invalid deployment owner")
self.lock_id = None
self.state = None
self.old_image = None
self.secret_values = []
self.docker = self.env.get("DOCKER_BIN", "docker")
def run(self, *args, data=None, check=True):
result = subprocess.run(
[self.docker, *args],
input=data,
capture_output=True,
env=self.env | {"DOCKER_BUILDKIT": "1"},
)
if check and result.returncode:
# Never print Docker inspect/config/env output or secret-bearing commands.
raise RuntimeError(
f"Docker {args[0]} failed (exit {result.returncode}); inspect scoped container logs privately"
)
return result
def inspect(self, name):
return json.loads(self.run("inspect", name).stdout)[0]
def settings(self):
missing = [key for key in REQUIRED if not self.env.get(key)]
if missing:
raise ValueError("Missing Variables: " + ", ".join(missing))
for key in (
"PROD_PROJECT",
"PROD_CONTAINER",
"PROD_IMAGE_REPO",
"PROD_STATE_VOLUME",
):
if not re.fullmatch(r"[a-z][a-z0-9-]{2,80}", self.env[key]) or self.env[
key
].startswith("ffci-"):
raise ValueError("Unsafe production resource name: " + key)
for key in ("PROD_DATA_DIR", "PROD_CONFIG_DIR"):
if not self.env[key].startswith("/") or self.env[key] == "/":
raise ValueError("Absolute existing NAS path required: " + key)
if self.env["PROD_DATA_DIR"] == self.env["PROD_CONFIG_DIR"]:
raise ValueError("data/config must use distinct directories")
if any(
self.env[key] not in ("true", "false")
for key in ("PROD_MOCK", "PROD_WRITE_ENABLED")
):
raise ValueError("Explicit true/false required for mock and writes")
if not 1 <= int(self.env["PROD_WEB_PORT"]) <= 65535:
raise ValueError("Invalid production port")
def preflight(self, require_running=True):
self.settings()
old = self.inspect(self.env["PROD_CONTAINER"])
labels = old["Config"].get("Labels") or {}
if (
labels.get("com.docker.compose.project") != self.env["PROD_PROJECT"]
or labels.get("com.docker.compose.service") != "pigway-frame-flow"
):
raise ValueError(
"Existing container does not belong to the configured production project/service"
)
if require_running and not old["State"]["Running"]:
raise ValueError("Existing production container must be running")
mounts = {m["Destination"]: m for m in old["Mounts"]}
for target, key in (("/data", "PROD_DATA_DIR"), ("/config", "PROD_CONFIG_DIR")):
m = mounts.get(target, {})
if (
m.get("Type") != "bind"
or m.get("Source") != self.env[key]
or not m.get("RW")
):
raise ValueError("Production mount mismatch: " + key)
photos = self.env.get("PROD_PHOTOS_DIR")
if "/photos" in mounts and (
not photos
or mounts["/photos"]["Source"] != photos
or mounts["/photos"]["RW"]
):
raise ValueError(
"Confirm PROD_PHOTOS_DIR for the existing read-only photo mount"
)
if photos and "/photos" not in mounts:
raise ValueError(
"Configured photos directory does not match current container"
)
if set(mounts) != {"/data", "/config"} | ({"/photos"} if photos else set()):
raise ValueError("Unrecognized persistent mount; review before adopting CD")
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
if bindings != [
{"HostIp": self.env["PROD_WEB_BIND"], "HostPort": self.env["PROD_WEB_PORT"]}
]:
raise ValueError(
"Port/bind mismatch; CD must not change production networking"
)
if (
old["HostConfig"].get("NetworkMode")
!= self.env["PROD_PROJECT"] + "_default"
):
raise ValueError("Custom production network requires explicit review")
config = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
config["name"] = self.env["PROD_PROJECT"]
service = config["services"]["pigway-frame-flow"]
service.pop("build")
service.pop("env_file")
service["container_name"] = self.env["PROD_CONTAINER"]
service["image"] = old["Image"]
service["environment"] = dict(
value.split("=", 1) for value in old["Config"]["Env"] if "=" in value
)
self.secret_values.extend(
value
for key, value in service["environment"].items()
if value
and any(
word in key
for word in ("KEY", "TOKEN", "PASSWORD", "SECRET", "SESSION")
)
)
service["ports"] = [
f"{self.env['PROD_WEB_BIND']}:{self.env['PROD_WEB_PORT']}:18787"
]
service["volumes"] = [
{
"type": "bind",
"source": self.env[key],
"target": target,
"bind": {"create_host_path": False},
}
for key, target in (
("PROD_DATA_DIR", "/data"),
("PROD_CONFIG_DIR", "/config"),
)
]
service["group_add"] = old["HostConfig"].get("GroupAdd") or []
if photos:
service["volumes"].append(
{
"type": "bind",
"source": photos,
"target": "/photos",
"read_only": True,
"bind": {"create_host_path": False},
}
)
# Adoption must preserve runtime constraints, not silently change them.
if (
old["Config"]["User"] != service["user"]
or old["HostConfig"]["RestartPolicy"]["Name"] != service["restart"]
):
raise ValueError("Production user/restart policy differs from repository")
self.old_image = old["Image"]
volume = self.run(
"volume", "inspect", self.env["PROD_STATE_VOLUME"], check=False
)
if (
volume.returncode == 0
and (json.loads(volume.stdout)[0].get("Labels") or {}).get(
"frameflow.deploy.project"
)
!= self.env["PROD_PROJECT"]
):
raise ValueError("State volume is not owned by this deployment project")
return config
def lock(self):
result = self.run(
"create",
"--name",
self.env["PROD_CONTAINER"] + "-deploy-lock",
"--label",
"frameflow.deploy.owner=" + self.owner,
"--network",
"none",
"--entrypoint",
"/bin/true",
self.old_image,
)
self.lock_id = result.stdout.decode().strip()
def unlock(self):
if self.lock_id:
self.run("rm", self.lock_id)
self.lock_id = None
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)"
if payload is not None
else "from pathlib import Path;print(Path('/state/deploy.json').read_text())"
)
return self.run(
"run",
"--rm",
"-i",
"--user",
"0:0",
"--network",
"none",
"--mount",
f"type=volume,source={self.env['PROD_STATE_VOLUME']},target=/state",
"--entrypoint",
"python",
self.old_image,
"-c",
script,
data=json.dumps(payload).encode() if payload is not None else None,
).stdout
def activate(self, config):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as file:
yaml.safe_dump(json.loads(json.dumps(config).replace("$", "$$")), file)
file.flush()
self.run(
"compose",
"-p",
self.env["PROD_PROJECT"],
"-f",
file.name,
"config",
"--quiet",
)
self.run(
"compose",
"-p",
self.env["PROD_PROJECT"],
"-f",
file.name,
"up",
"-d",
"--no-build",
"--pull",
"never",
"pigway-frame-flow",
)
def verify(self, config):
container = self.env["PROD_CONTAINER"]
code = "import urllib.request;urllib.request.urlopen('http://127.0.0.1:18787/ready',timeout=3)"
for _ in range(60):
if (
self.run(
"exec", container, "python", "-c", code, check=False
).returncode
== 0
):
break
time.sleep(2)
else:
raise RuntimeError("Production /ready timeout")
self.run(
"exec",
"-e",
"FRAME_FLOW_URL=http://127.0.0.1:18787",
"-i",
container,
"python",
"-",
data=(ROOT / "scripts/smoke-test.py").read_bytes(),
)
expected = config["services"]["pigway-frame-flow"]["environment"].get(
"BUILD_COMMIT"
)
value = json.loads(
self.run(
"exec",
container,
"python",
"-c",
"import json,urllib.request;print(json.dumps(json.load(urllib.request.urlopen('http://127.0.0.1:18787/api/v1/version'))))",
).stdout
)
if value["commit"] != expected:
raise RuntimeError("Running commit mismatch")
print(
"Production /ready and all smoke endpoints: HTTP 200; commit",
value["commit"],
flush=True,
)
return value
def rollback(self, previous):
self.activate(previous)
self.verify(previous)
print("ROLLBACK VERIFIED", flush=True)
def secret_config(self):
secret = self.env.get("PRODUCTION_ENV", "")
proposed = dict(
line.split("=", 1)
for line in secret.splitlines()
if line.strip() and not line.startswith("#") and "=" in line
)
for key in ("FRAME_FLOW_TOKEN", "KOKONNA_API_KEY"):
if key not in proposed:
raise ValueError("Missing PRODUCTION_ENV key: " + key)
if self.env["PROD_MOCK"] == "false" and not proposed["KOKONNA_API_KEY"]:
raise ValueError("KoKonna API key required for non-mock production")
if not proposed["FRAME_FLOW_TOKEN"]:
raise ValueError("FRAME_FLOW_TOKEN required for smoke checks")
self.secret_values.extend(v for v in proposed.values() if v)
return proposed
def build(self, config):
commit = (
subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT)
.decode()
.strip()
)
main = (
subprocess.check_output(
["git", "ls-remote", "origin", "refs/heads/main"], cwd=ROOT
)
.decode()
.split()[0]
)
if commit != main:
raise ValueError("Stale main revision; refusing automatic downgrade")
image = (
self.env["PROD_IMAGE_REPO"] + ":release-" + commit + "-" + self.owner[:8]
)
command = ["build", "--pull", "-t", image]
now = datetime.now(UTC).isoformat()
for key, value in {
"DEBIAN_MIRROR": self.env.get("DEBIAN_MIRROR") or "deb.debian.org",
"DEBIAN_SECURITY_MIRROR": self.env.get("DEBIAN_SECURITY_MIRROR")
or "deb.debian.org",
"BUILD_COMMIT": commit,
"BUILD_TIME": now,
}.items():
command += ["--build-arg", key + "=" + value]
self.run(*command, str(ROOT))
service = config["services"]["pigway-frame-flow"]
service["image"] = image
proposed = self.secret_config()
service["environment"].update(proposed)
service["environment"].update(
KOKONNA_MOCK=self.env["PROD_MOCK"],
KOKONNA_WRITE_ENABLED=self.env["PROD_WRITE_ENABLED"],
BUILD_COMMIT=commit,
BUILD_TIME=now,
DATA_DIR="/data",
CONFIG_DIR="/config",
TZ="Asia/Shanghai",
)
return config
def diagnostics(self):
try:
self._diagnostics()
except Exception:
print(
"Scoped diagnostics unavailable; rollback still attempted", flush=True
)
def _diagnostics(self):
result = self.run("inspect", self.env["PROD_CONTAINER"], check=False)
if result.returncode == 0:
info = json.loads(result.stdout)[0]
state = info["State"]
print(
"Scoped production diagnostic:",
json.dumps(
{
"image": info["Image"],
"status": state.get("Status"),
"exit_code": state.get("ExitCode"),
"health": state.get("Health", {}).get("Status"),
}
),
flush=True,
)
logs = self.run("logs", "--tail", "60", self.env["PROD_CONTAINER"], check=False)
text = (logs.stdout + logs.stderr).decode(errors="replace")
for value in sorted(self.secret_values, key=len, reverse=True):
text = text.replace(value, "[REDACTED]")
text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text)
print("Scoped container logs (redacted):", text, flush=True)
def deploy(self):
previous = self.preflight()
self.lock()
touched = False
safe_unlock = True
try:
# Refresh baseline under the daemon-level lock.
previous = self.preflight()
version = self.verify(previous)
pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner
self.run("image", "tag", self.old_image, pin)
candidate = self.build(json.loads(json.dumps(previous)))
self.state = {
"owner": self.owner,
"status": "prepared",
"previous": previous,
"candidate": candidate,
"previous_image": self.old_image,
"previous_version": version,
"rollback_tag": pin,
}
self.run(
"volume",
"create",
"--label",
"frameflow.deploy.project=" + self.env["PROD_PROJECT"],
self.env["PROD_STATE_VOLUME"],
)
self.state_io(self.state)
# Online backup is additive; rollback never restores/deletes user data.
backup = "/data/deploy-backups/" + self.owner + ".db"
self.run(
"exec",
self.env["PROD_CONTAINER"],
"python",
"-c",
"import sqlite3;from pathlib import Path;p=Path('"
+ backup
+ "');p.parent.mkdir(exist_ok=True);assert Path('/data/frameflow.db').is_file();sqlite3.connect('/data/frameflow.db').backup(sqlite3.connect(p))",
)
touched = True
self.activate(candidate)
self.verify(candidate)
self.state["status"] = "success"
self.state_io(self.state)
print("DEPLOY VERIFIED", flush=True)
except BaseException as exc:
print("DEPLOY FAILED:", type(exc).__name__, flush=True)
self.diagnostics()
if touched:
safe_unlock = False
try:
self.rollback(previous)
except BaseException:
print(
"ROLLBACK FAILED: deployment lock retained; always() recovery will retry",
flush=True,
)
self.diagnostics()
raise
safe_unlock = True
self.state["status"] = "rolled_back"
self.state_io(self.state)
raise
finally:
if safe_unlock:
self.unlock()
def recover(self, manual=False):
self.settings()
lock = self.run(
"inspect", self.env["PROD_CONTAINER"] + "-deploy-lock", check=False
)
if manual:
self.preflight(require_running=False)
self.lock()
elif lock.returncode == 0:
info = json.loads(lock.stdout)[0]
if (info["Config"].get("Labels") or {}).get(
"frameflow.deploy.owner"
) != self.owner:
return
self.lock_id = info["Id"]
self.old_image = info["Image"]
else:
return
recovered = False
try:
self.run("volume", "inspect", self.env["PROD_STATE_VOLUME"])
volume = json.loads(
self.run("volume", "inspect", self.env["PROD_STATE_VOLUME"]).stdout
)[0]
if (volume.get("Labels") or {}).get("frameflow.deploy.project") != self.env[
"PROD_PROJECT"
]:
raise ValueError("Recovery state volume ownership mismatch")
state = json.loads(self.state_io())
previous = state["previous"]
service = previous["services"]["pigway-frame-flow"]
if (
previous["name"] != self.env["PROD_PROJECT"]
or service["container_name"] != self.env["PROD_CONTAINER"]
):
raise ValueError("Rollback journal resource mismatch")
sources = {m["target"]: m["source"] for m in service["volumes"]}
if (
sources.get("/data") != self.env["PROD_DATA_DIR"]
or sources.get("/config") != self.env["PROD_CONFIG_DIR"]
):
raise ValueError("Rollback journal persistence mismatch")
if manual:
state["owner"] = self.owner
state["status"] = "prepared"
self.state_io(state)
if manual or (
state["owner"] == self.owner
and state["status"] not in ("success", "rolled_back")
):
self.rollback(state["previous"])
state["status"] = "rolled_back"
self.state_io(state)
recovered = True
except BaseException:
print("RECOVERY FAILED: production lock retained", flush=True)
self.diagnostics()
raise
finally:
if recovered:
self.unlock()
def main():
tool = Deploy()
action = tool.env.get("CD_ACTION") or (
"deploy" if tool.env.get("PROD_DEPLOY_ENABLED") == "true" else "plan"
)
if action == "recover":
if tool.env.get("FRAME_FLOW_DEPLOY_OWNER"):
tool.recover()
return
if action != "plan" and tool.env.get("PROD_DEPLOY_ENABLED") != "true":
raise SystemExit(
"Production CD disabled; set PROD_DEPLOY_ENABLED=true after safe trial"
)
if action == "plan":
if any(not tool.env.get(key) for key in REQUIRED):
print("PLAN ONLY: configure required Variables:", ", ".join(REQUIRED))
return
baseline = tool.preflight()
tool.secret_config()
tool.verify(baseline)
print(
"PLAN ONLY: production resource identity/mounts/port checked; no resources modified"
)
return
if tool.env.get("GITHUB_ENV"):
with Path(tool.env["GITHUB_ENV"]).open("a") as file:
file.write("FRAME_FLOW_DEPLOY_OWNER=" + tool.owner + "\n")
def cancel(*_):
raise SystemExit(143)
signal.signal(signal.SIGTERM, cancel)
if action == "deploy":
tool.deploy()
elif action == "rollback":
tool.recover(manual=True)
else:
raise SystemExit("Unknown CD_ACTION")
if __name__ == "__main__":
main()
+245
View File
@@ -0,0 +1,245 @@
"""Deployment gates, isolation checks, and rollback use mocked Docker only."""
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
@pytest.fixture
def tool():
spec = importlib.util.spec_from_file_location(
"production_deploy", Path(__file__).parents[1] / "scripts/production-deploy.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
instance = module.Deploy()
instance.env = {
"PROD_PROJECT": "production-frame",
"PROD_CONTAINER": "production-frame",
"PROD_IMAGE_REPO": "pigway-frame-flow",
"PROD_DATA_DIR": "/confirmed/data",
"PROD_CONFIG_DIR": "/confirmed/config",
"PROD_WEB_BIND": "192.0.2.1",
"PROD_WEB_PORT": "18787",
"PROD_STATE_VOLUME": "production-frame-cd",
"PROD_MOCK": "false",
"PROD_WRITE_ENABLED": "false",
}
return instance
def existing(tool):
return {
"Image": "sha256:previous",
"State": {"Running": True},
"Config": {
"User": "10333:10333",
"Env": ["BUILD_COMMIT=previous", "FRAME_FLOW_TOKEN=fixture-token"],
"Labels": {
"com.docker.compose.project": tool.env["PROD_PROJECT"],
"com.docker.compose.service": "pigway-frame-flow",
},
},
"Mounts": [
{"Type": "bind", "Destination": target, "Source": tool.env[key], "RW": True}
for target, key in [
("/data", "PROD_DATA_DIR"),
("/config", "PROD_CONFIG_DIR"),
]
],
"HostConfig": {
"PortBindings": {
"18787/tcp": [
{"HostIp": tool.env["PROD_WEB_BIND"], "HostPort": "18787"}
]
},
"NetworkMode": "production-frame_default",
"RestartPolicy": {"Name": "unless-stopped"},
},
}
def test_preflight_preserves_paths_and_has_no_write_commands(tool, monkeypatch):
monkeypatch.setattr(tool, "inspect", lambda _: existing(tool))
calls = []
monkeypatch.setattr(
tool,
"run",
lambda *args, **kwargs: calls.append(args) or SimpleNamespace(returncode=1),
)
config = tool.preflight()
service = config["services"]["pigway-frame-flow"]
assert service["image"] == "sha256:previous"
assert all(m["bind"]["create_host_path"] is False for m in service["volumes"])
assert service["environment"]["FRAME_FLOW_TOKEN"] == "fixture-token"
assert calls == [("volume", "inspect", "production-frame-cd")]
@pytest.mark.parametrize("change", ["path", "project", "port", "network"])
def test_preflight_rejects_wrong_host_resources(tool, monkeypatch, change):
old = existing(tool)
if change == "path":
old["Mounts"][0]["Source"] = "/unrelated/data"
elif change == "project":
old["Config"]["Labels"]["com.docker.compose.project"] = "ffci-fixture"
elif change == "port":
old["HostConfig"]["PortBindings"]["18787/tcp"][0]["HostPort"] = "9999"
else:
old["HostConfig"]["NetworkMode"] = "host"
monkeypatch.setattr(tool, "inspect", lambda _: old)
with pytest.raises(ValueError):
tool.preflight()
@pytest.mark.parametrize("failure", [None, "candidate", "rollback"])
def test_deploy_rolls_back_exact_baseline_without_deleting_data(
tool, monkeypatch, failure
):
old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}}
new = {"services": {"pigway-frame-flow": {"image": "candidate"}}}
activated, calls, saved = [], [], []
monkeypatch.setattr(tool, "preflight", lambda: old)
monkeypatch.setattr(tool, "lock", lambda: calls.append("lock"))
monkeypatch.setattr(tool, "unlock", lambda: calls.append("unlock"))
monkeypatch.setattr(tool, "run", lambda *args, **kw: calls.append(args))
monkeypatch.setattr(tool, "build", lambda _: new)
monkeypatch.setattr(tool, "diagnostics", lambda: None)
monkeypatch.setattr(tool, "activate", lambda c: activated.append(c))
monkeypatch.setattr(
tool, "state_io", lambda state: saved.append(json.loads(json.dumps(state)))
)
verifies = []
def verify(config):
verifies.append(config)
if config == new and failure:
raise RuntimeError("candidate not ready")
if config == old and failure == "rollback" and len(verifies) > 1:
raise RuntimeError("rollback not ready")
return {"commit": "previous", "version": "0.1.0"}
monkeypatch.setattr(tool, "verify", verify)
if failure:
with pytest.raises(RuntimeError):
tool.deploy()
assert activated == [new, old]
assert saved[0]["previous"] == old
assert ("unlock" in calls) is (failure != "rollback")
else:
tool.deploy()
assert activated == [new]
assert saved[-1]["status"] == "success"
assert not any(
isinstance(c, tuple) and ("down" in c or "prune" in c) for c in calls
)
def test_activate_escapes_dollar_secrets_and_never_down(tool, monkeypatch):
calls = []
def run(*args, **kwargs):
calls.append(args)
if "config" in args:
path = Path(args[args.index("-f") + 1])
assert "$$literal" in path.read_text()
monkeypatch.setattr(tool, "run", run)
tool.activate(
{
"services": {
"pigway-frame-flow": {"environment": {"FRAME_FLOW_TOKEN": "$literal"}}
}
}
)
assert len(calls) == 2 and "up" in calls[1] and "--no-build" in calls[1]
def test_workflow_deploy_is_verify_gated_and_manual_plan_default(tool):
import yaml
workflow = yaml.safe_load(
(Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text()
)
job = workflow["jobs"]["deploy"]
assert job["needs"] == "verify" and "success()" in job["if"]
assert "refs/heads/main" in job["if"] and "pull_request" not in job["if"]
assert job["env"]["PROD_DEPLOY_ENABLED"] == "${{ vars.PROD_DEPLOY_ENABLED }}"
assert (
workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]["default"]
== "plan"
)
assert job["steps"][-1]["if"] == "always()"
def test_recovery_handles_missing_or_crashed_production_container(tool, monkeypatch):
previous = {
"name": tool.env["PROD_PROJECT"],
"services": {
"pigway-frame-flow": {
"container_name": tool.env["PROD_CONTAINER"],
"image": "sha256:old",
"volumes": [
{"source": tool.env["PROD_DATA_DIR"], "target": "/data"},
{"source": tool.env["PROD_CONFIG_DIR"], "target": "/config"},
],
}
},
}
state = {"owner": tool.owner, "status": "prepared", "previous": previous}
calls = []
def run(*args, **kwargs):
calls.append(args)
if args[0] == "inspect":
assert args[1].endswith("-deploy-lock")
return SimpleNamespace(
returncode=0,
stdout=json.dumps(
[
{
"Id": "owned-lock",
"Image": "sha256:old",
"Config": {
"Labels": {"frameflow.deploy.owner": tool.owner}
},
}
]
).encode(),
)
return SimpleNamespace(
returncode=0,
stdout=json.dumps(
[{"Labels": {"frameflow.deploy.project": tool.env["PROD_PROJECT"]}}]
).encode(),
)
monkeypatch.setattr(tool, "run", run)
def state_io(payload=None):
if payload is not None:
state.update(payload)
return json.dumps(state).encode()
monkeypatch.setattr(tool, "state_io", state_io)
monkeypatch.setattr(
tool, "rollback", lambda config: calls.append(("rollback", config))
)
tool.recover()
assert ("rollback", previous) in calls
assert ("rm", "owned-lock") in calls
assert state["status"] == "rolled_back"
def test_lock_conflict_never_activates_production(tool, monkeypatch):
monkeypatch.setattr(tool, "preflight", lambda: {})
monkeypatch.setattr(tool, "activate", lambda _: pytest.fail("Production touched"))
def fail(*args, **kwargs):
raise RuntimeError("Lock already reserved")
monkeypatch.setattr(tool, "run", fail)
with pytest.raises(RuntimeError):
tool.deploy()