2026-10-07 23:15:31 +08:00
"""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
2026-10-11 01:13:55 +08:00
self . secret_values = [
self . env [ key ]
for key in (
"PROD_DATA_DIR" ,
"PROD_CONFIG_DIR" ,
"PROD_PHOTOS_DIR" ,
"PROD_WEB_BIND" ,
"SYNOLOGY_PHOTOS_URL" ,
"SYNOLOGY_PHOTOS_CERT_SHA256" ,
)
if self . env . get ( key )
]
2026-10-07 23:15:31 +08:00
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');"
2026-10-08 00:54:15 +08:00
"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)"
2026-10-07 23:15:31 +08:00
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 {
2026-10-09 00:02:09 +08:00
"DOCKER_PIP_INDEX_URL" : self . env . get ( "DOCKER_PIP_INDEX_URL" )
or "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" ,
"DOCKER_PIP_FALLBACK_INDEX_URL" : self . env . get (
"DOCKER_PIP_FALLBACK_INDEX_URL"
)
or "https://pypi.org/simple" ,
2026-10-07 23:15:31 +08:00
"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 )
2026-10-08 19:25:37 +08:00
for key in ( "SYNOLOGY_PHOTOS_URL" , "SYNOLOGY_PHOTOS_CERT_SHA256" ):
if self . env . get ( key ):
service [ "environment" ][ key ] = self . env [ key ]
2026-10-07 23:15:31 +08:00
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
2026-10-08 00:06:22 +08:00
def container_optional ( self ):
result = self . run ( "container" , "ls" , "-a" , "--format" , "{{.Names}}" )
if self . env [ "PROD_CONTAINER" ] not in result . stdout . decode () . splitlines ():
return None
return self . inspect ( self . env [ "PROD_CONTAINER" ])
def host_paths ( self , paths ):
# Inspect the daemon host, never the job container workspace. Read-only root.
code = "import json,sys;from pathlib import Path;print(json.dumps([{'exists':p.exists() or p.is_symlink() or any(a.is_symlink() for a in p.parents),'empty':p.is_dir() and not p.is_symlink() and not any(a.is_symlink() for a in p.parents) and not any(p.iterdir())} for p in [Path('/host'+v) for v in json.load(sys.stdin)]]))"
return json . loads (
self . run (
"run" ,
"--rm" ,
"-i" ,
"--user" ,
"0:0" ,
"--network" ,
"none" ,
"--mount" ,
"type=bind,source=/,target=/host,readonly" ,
"--entrypoint" ,
"python" ,
self . old_image ,
"-c" ,
code ,
data = json . dumps ( paths ) . encode (),
) . stdout
)
def bootstrap_check ( self ):
self . settings ()
if self . env [ "PROD_MOCK" ] != "true" or self . env [ "PROD_WRITE_ENABLED" ] != "false" :
raise ValueError ( "Bootstrap requires mock=true and write=false" )
if self . env . get ( "PROD_PHOTOS_DIR" ):
raise ValueError ( "First bootstrap must not attach Photos" )
paths = [ self . env [ k ] for k in ( "PROD_DATA_DIR" , "PROD_CONFIG_DIR" )]
if any ( ".." in Path ( p ) . parts or p . endswith ( "/" ) for p in paths ) or any (
Path ( a ) in Path ( b ) . parents
for a , b in (( paths [ 0 ], paths [ 1 ]), ( paths [ 1 ], paths [ 0 ]))
):
raise ValueError ( "Unsafe or overlapping bootstrap directories" )
old = self . container_optional ()
if old :
labels = old [ "Config" ] . get ( "Labels" ) or {}
state = old [ "State" ]
if (
state . get ( "Status" ) != "created"
or state . get ( "Running" )
or state . get ( "StartedAt" ) != "0001-01-01T00:00:00Z"
or labels . get ( "com.docker.compose.project" ) != self . env [ "PROD_PROJECT" ]
or labels . get ( "com.docker.compose.service" ) != "pigway-frame-flow"
):
raise ValueError (
"Bootstrap refused: existing container is not a never-started owned residue"
)
mounts = old [ "Mounts" ]
if (
len ( mounts ) != 2
or { m [ "Destination" ] for m in mounts } != { "/data" , "/config" }
or any ( m [ "Type" ] != "bind" for m in mounts )
):
raise ValueError ( "Bootstrap refused: unknown persistent mount" )
if any ( v [ "exists" ] for v in self . host_paths ([ m [ "Source" ] for m in mounts ])):
raise ValueError ( "Bootstrap refused: old mount has existing data/path" )
if any ( v [ "exists" ] and not v [ "empty" ] for v in self . host_paths ( paths )):
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 ():
2026-10-08 00:54:15 +08:00
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 ,
2026-10-08 00:06:22 +08:00
)
return old
2026-10-08 00:54:15 +08:00
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 (),
)
2026-10-08 00:06:22 +08:00
def bootstrap_config ( self ):
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 [ "environment" ] = {}
service [ "ports" ] = [
f " { self . env [ 'PROD_WEB_BIND' ] } : { self . env [ 'PROD_WEB_PORT' ] } :18787"
]
service [ "labels" ] = { "frameflow.bootstrap.owner" : self . owner }
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" ),
)
]
return config
def bootstrap_cleanup ( self ):
old = self . container_optional ()
if old :
if ( old [ "Config" ] . get ( "Labels" ) or {}) . get (
"frameflow.bootstrap.owner"
) != self . owner :
raise ValueError ( "Refusing cleanup of another container" )
self . run ( "rm" , "-f" , old [ "Id" ])
if self . state and self . state . get ( "network_created" ):
name = self . env [ "PROD_PROJECT" ] + "_default"
result = self . run ( "network" , "inspect" , name , check = False )
if result . returncode == 0 :
network = json . loads ( result . stdout )[ 0 ]
if (
network . get ( "Containers" )
or ( network . get ( "Labels" ) or {}) . get ( "com.docker.compose.project" )
!= self . env [ "PROD_PROJECT" ]
):
raise ValueError ( "Refusing cleanup of occupied/unknown network" )
self . run ( "network" , "rm" , network [ "Id" ])
def bootstrap ( self ):
self . settings ()
if self . env [ "PROD_MOCK" ] != "true" or self . env [ "PROD_WRITE_ENABLED" ] != "false" :
raise ValueError ( "Bootstrap requires mock=true and write=false" )
self . secret_config ()
# Build before host inspection so helpers use this verified repository image.
candidate = self . build ( self . bootstrap_config ())
self . old_image = candidate [ "services" ][ "pigway-frame-flow" ][ "image" ]
self . lock ()
safe_unlock = True
try :
residue = self . bootstrap_check ()
network_name = self . env [ "PROD_PROJECT" ] + "_default"
networks = (
self . run ( "network" , "ls" , "--format" , "{{.Name}}" )
. stdout . decode ()
. splitlines ()
)
if network_name in networks :
network = json . loads (
self . run ( "network" , "inspect" , network_name ) . stdout
)[ 0 ]
if (
set ( network . get ( "Containers" ) or {})
- ({ residue [ "Id" ]} if residue else set ())
or ( network . get ( "Labels" ) or {}) . get ( "com.docker.compose.project" )
!= self . env [ "PROD_PROJECT" ]
):
raise ValueError (
"Bootstrap network identity/occupancy requires review"
)
self . state = {
"owner" : self . owner ,
"kind" : "bootstrap" ,
"status" : "prepared" ,
"candidate" : candidate ,
"network_created" : network_name not in networks ,
}
self . run (
"volume" ,
"create" ,
"--label" ,
"frameflow.deploy.project=" + self . env [ "PROD_PROJECT" ],
self . env [ "PROD_STATE_VOLUME" ],
)
self . state_io ( self . state )
if residue :
self . run ( "rm" , residue [ "Id" ])
2026-10-08 00:54:15 +08:00
self . initialize_directories ()
2026-10-08 00:06:22 +08:00
self . activate ( candidate )
self . verify ( candidate )
self . preflight ()
self . run ( "restart" , self . env [ "PROD_CONTAINER" ])
self . verify ( candidate )
self . preflight ()
info = self . inspect ( self . env [ "PROD_CONTAINER" ])
for _ in range ( 60 ):
if info [ "State" ] . get ( "Health" , {}) . get ( "Status" ) == "healthy" :
break
time . sleep ( 2 )
info = self . inspect ( self . env [ "PROD_CONTAINER" ])
else :
raise RuntimeError ( "Bootstrap health timeout" )
expected_image = json . loads (
self . run (
"image" ,
"inspect" ,
candidate [ "services" ][ "pigway-frame-flow" ][ "image" ],
) . stdout
)[ 0 ][ "Id" ]
if info [ "Image" ] != expected_image :
raise RuntimeError ( "Bootstrap image mismatch" )
self . state [ "status" ] = "success"
self . state_io ( self . state )
print ( "BOOTSTRAP VERIFIED" , flush = True )
except BaseException :
print ( "BOOTSTRAP FAILED" , flush = True )
self . diagnostics ()
if self . state :
safe_unlock = False
self . bootstrap_cleanup ()
self . state [ "status" ] = "failed"
self . state_io ( self . state )
safe_unlock = True
raise
finally :
if safe_unlock :
self . unlock ()
2026-10-07 23:15:31 +08:00
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 ]
2026-10-08 00:06:22 +08:00
self . secret_values . extend (
value
for key , value in (
entry . split ( "=" , 1 )
for entry in info . get ( "Config" , {}) . get ( "Env" , [])
if "=" in entry
)
if value
and any (
word in key
for word in ( "KEY" , "TOKEN" , "PASSWORD" , "SECRET" , "SESSION" )
)
)
2026-10-07 23:15:31 +08:00
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 ())
2026-10-08 00:06:22 +08:00
if state . get ( "kind" ) == "bootstrap" :
self . state = state
if state [ "owner" ] != self . owner :
raise ValueError ( "Bootstrap journal owner mismatch" )
if state [ "status" ] == "prepared" :
self . bootstrap_cleanup ()
state [ "status" ] = "failed"
self . state_io ( state )
recovered = True
return
2026-10-07 23:15:31 +08:00
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
2026-10-08 00:06:22 +08:00
if tool . container_optional () is None :
print ( "PLAN ONLY: production not bootstrapped / bootstrap required" )
return
info = tool . inspect ( tool . env [ "PROD_CONTAINER" ])
if info [ "State" ] . get ( "Status" ) == "created" :
print (
"PLAN ONLY: production not bootstrapped; created residue requires bootstrap safety checks"
)
return
2026-10-07 23:15:31 +08:00
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 )
2026-10-08 00:06:22 +08:00
if action == "bootstrap" :
if tool . env . get ( "GITHUB_EVENT_NAME" ) != "workflow_dispatch" :
raise SystemExit ( "Bootstrap requires explicit workflow_dispatch" )
try :
tool . bootstrap ()
except BaseException :
print (
"BOOTSTRAP FAILED: inspect redacted diagnostics; data/config retained" ,
flush = True ,
)
raise
elif action == "deploy" :
info = tool . container_optional ()
if info is None or info [ "State" ] . get ( "Status" ) == "created" :
print ( "production not bootstrapped / bootstrap required" )
return
2026-10-07 23:15:31 +08:00
tool . deploy ()
elif action == "rollback" :
tool . recover ( manual = True )
else :
raise SystemExit ( "Unknown CD_ACTION" )
if __name__ == "__main__" :
main ()