148 lines
11 KiB
Python
148 lines
11 KiB
Python
"""A bounded, non-serving candidate process under the original compose inputs."""
|
|
from pathlib import Path
|
|
import hashlib
|
|
import json
|
|
import shlex
|
|
import uuid
|
|
|
|
|
|
def prepare(client, base, rollout, root, state, current):
|
|
base._require(state.get('built') is True and state.get('imports_passed') is True,
|
|
'PREFLIGHT_REQUIRES_BUILT_AND_IMPORTED_IMAGE')
|
|
base._require(current is not None and current['container_id'] == state['inspect']['container_id']
|
|
and current['image_id'] == state['inspect']['image_id'] and current['status'] == 'running',
|
|
'PREFLIGHT_BASELINE_CHANGED')
|
|
fingerprint = rollout._compose_fingerprint(client, base)
|
|
if state.get('preflight_passed') is True:
|
|
base._require(state.get('preflight_image_id') == state['candidate_image_id']
|
|
and state.get('preflight_compose_fingerprint') == fingerprint,
|
|
'PRIOR_PREFLIGHT_INPUTS_CHANGED')
|
|
return state['preflight_result']
|
|
if state.get('preflight_started'):
|
|
base._require(state.get('preflight_failure_confirmed') is True,
|
|
'PRIOR_PREFLIGHT_OUTCOME_REQUIRES_REVIEW')
|
|
previous = state.get('preflight_container_name', '')
|
|
import re
|
|
base._require(re.fullmatch(r'zyt-stop-preflight-[0-9a-f]{32}', previous) is not None,
|
|
'PRIOR_PREFLIGHT_IDENTITY_INVALID')
|
|
raw, unused = base._exec(client, 'docker ps --all -q --filter '
|
|
+ shlex.quote('name=^/' + previous + '$'), cap=4096)
|
|
base._require(not raw.strip(), 'PRIOR_PREFLIGHT_CONTAINER_STILL_EXISTS')
|
|
state.setdefault('preflight_history', []).append({'container_name': previous,
|
|
'outcome': 'confirmed_command_failure_before_serving', 'container_absence_verified': True})
|
|
remote = state['host_stage']
|
|
probe_name = 'zyt-stop-preflight-' + uuid.uuid4().hex
|
|
suffix = probe_name.rsplit('-',1)[1]
|
|
override = remote + '/api-preflight-' + suffix + '.override.json'
|
|
recovery = remote + '/resolved-compose-allprofiles-' + suffix + '.private.json'
|
|
state.update(preflight_started=True, preflight_container_name=probe_name, preflight_failure_confirmed=False)
|
|
base._save_state(root, state)
|
|
rollout._write_new_private(client, override, json.dumps({'services': {'api': {
|
|
'image': state['candidate_image_id'], 'pull_policy': 'never',
|
|
'healthcheck': {'disable': True}}}}).encode())
|
|
# Resolve privately on the server. The backup is standalone for resolved Env/path values;
|
|
# mounted application data remains the original persistent data, never copied or altered.
|
|
capture = '''import hashlib,json,os,pathlib,subprocess,sys
|
|
cmd=json.loads(sys.argv[1]); p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=25)
|
|
assert p.returncode==0 and len(p.stdout)<=16777216
|
|
default=json.loads(p.stdout)
|
|
assert hashlib.sha256(json.dumps(default,sort_keys=True,separators=(',',':')).encode()).hexdigest()==sys.argv[3]
|
|
prefix=cmd[:cmd.index('config')-2]
|
|
complete=subprocess.run(prefix+['-f',cmd[cmd.index('config')-1],'--profile','*','config','--format','json'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=25)
|
|
assert complete.returncode==0 and len(complete.stdout)<=16777216
|
|
model=json.loads(complete.stdout); api=model['services']['api']
|
|
assert not api.get('secrets') and not api.get('configs')
|
|
def literal(value):
|
|
if isinstance(value,str): return value.replace('$','$$')
|
|
if isinstance(value,list): return [literal(v) for v in value]
|
|
if isinstance(value,dict): return {k:literal(v) for k,v in value.items()}
|
|
return value
|
|
data=json.dumps(literal(model),sort_keys=True).encode()
|
|
fd=os.open(sys.argv[2],os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
with os.fdopen(fd,'wb') as out: out.write(data)
|
|
check=subprocess.run(prefix+['-f',sys.argv[2],'--profile','*','config','--format','json'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=25)
|
|
assert check.returncode==0
|
|
reparsed=json.loads(check.stdout)
|
|
assert reparsed['services']['api']==model['services']['api']
|
|
assert {k:v for k,v in reparsed.items() if k!='services'}=={k:v for k,v in model.items() if k!='services'}
|
|
differences={name:[key for key in set(service)|set(reparsed['services'][name]) if service.get(key)!=reparsed['services'][name].get(key)] for name,service in model['services'].items() if service!=reparsed['services'][name]}
|
|
assert set(differences).issubset({'init_permissions','nginx','ssrf_proxy'})
|
|
assert all(set(fields).issubset({'command','entrypoint'}) for fields in differences.values())
|
|
print(json.dumps({'ok':True,'recovery_sha256':hashlib.sha256(data).hexdigest(),'api_recovery_equivalent':True,'resource_definitions_equal':True,'external_api_secret_config_files':0,'non_api_shell_field_difference_service_count':len(differences),'recovery_scope':'api-only-with-no-deps'}))
|
|
'''
|
|
args = ['docker', 'compose', '--project-directory', rollout.WORKDIR, '-p', rollout.PROJECT,
|
|
'-f', rollout.COMPOSE, 'config', '--format', 'json']
|
|
state['preflight_phase'] = 'capture_recovery'; base._save_state(root, state)
|
|
backup, unused = base._json_command(client, 'python3 -c ' + shlex.quote(capture) + ' '
|
|
+ ' '.join(map(shlex.quote, [json.dumps(args), recovery, fingerprint['resolved_sha256']])), timeout=60)
|
|
base._require(backup.get('ok') is True, 'PRIVATE_RESOLVED_RECOVERY_CHECK_FAILED')
|
|
state.update(preflight_phase='create_nonserving_container', preflight_backup=backup); base._save_state(root, state)
|
|
run = rollout._prefix(override) + ' run -d --no-deps --pull never --name ' + shlex.quote(probe_name) \
|
|
+ ' --entrypoint python api -B -c ' + shlex.quote('import time;time.sleep(180)')
|
|
base._exec(client, run, timeout=60, cap=65536, keep_stdout=False)
|
|
try:
|
|
state['preflight_phase'] = 'compare_effective_runtime'; base._save_state(root, state)
|
|
compare = '''import json,pathlib,subprocess,sys
|
|
def inspect(name):
|
|
p=subprocess.run(['docker','inspect',name],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=15)
|
|
assert p.returncode==0 and len(p.stdout)<=2097152
|
|
return json.loads(p.stdout)[0]
|
|
a=inspect(sys.argv[1]); b=inspect(sys.argv[2]); rows=json.loads(sys.argv[4])
|
|
assert b['State']['Running'] and b['Image']==sys.argv[3]
|
|
def mounts(v):
|
|
return sorted([{k:r.get(k) for k in ['Type','Source','Destination','Mode','RW','Propagation']} for r in v['Mounts']],key=lambda r:r['Destination'])
|
|
checks={'environment_equal':sorted(a['Config']['Env'])==sorted(b['Config']['Env']),
|
|
'user_equal':a['Config']['User']==b['Config']['User'],
|
|
'working_directory_equal':a['Config']['WorkingDir']==b['Config']['WorkingDir'],
|
|
'mounts_equal':mounts(a)==mounts(b),
|
|
'networks_equal':set(a['NetworkSettings']['Networks'])==set(b['NetworkSettings']['Networks']),
|
|
'no_host_ports':not b['HostConfig'].get('PortBindings'),
|
|
'not_serving':b['Config']['Entrypoint']==['python'] and b['Config']['Cmd']==['-B','-c','import time;time.sleep(180)'],
|
|
'api_mode_preserved':('MODE=api' in a['Config']['Env'])==('MODE=api' in b['Config']['Env'])}
|
|
aliases={v for n in b['NetworkSettings']['Networks'].values() for v in n.get('Aliases') or []}
|
|
checks['no_production_service_alias']=not ({'api',a['Name'].lstrip('/')} & aliases)
|
|
destinations=[pathlib.PurePosixPath(v['Destination']) for v in b['Mounts']]
|
|
checks['no_mount_hides_candidate']=all(not (p==d or d in p.parents) for p in [pathlib.PurePosixPath('/app/api')/r for r in rows] for d in destinations)
|
|
print(json.dumps({'ok':all(checks.values()),**checks,'network_count':len(b['NetworkSettings']['Networks']),'mount_count':len(b['Mounts'])}))
|
|
'''
|
|
package, manifest, unused_seal = base._package(root)
|
|
paths = [r['path'] for r in manifest['targets']]
|
|
equality, unused = base._json_command(client, 'python3 -c ' + shlex.quote(compare) + ' '
|
|
+ ' '.join(map(shlex.quote, [current['container_id'], probe_name, state['candidate_image_id'], json.dumps(paths)])), timeout=40)
|
|
state['preflight_runtime_equality'] = equality; base._save_state(root, state)
|
|
base._require(equality.get('ok') is True, 'EFFECTIVE_RUNTIME_DIFFERENCE')
|
|
state['preflight_phase'] = 'installed_imports'; base._save_state(root, state)
|
|
# Only code/manifest artifacts are copied; no environment, clinical data or credentials.
|
|
base._exec(client, 'docker cp ' + shlex.quote(remote + '/package') + ' '
|
|
+ shlex.quote(probe_name + ':/tmp/zyt-stop-package'), timeout=30, keep_stdout=False)
|
|
diagnostic = root / 'artifacts/ai_full_verification_20261027/dify_import_diagnostic.py'
|
|
diagnostic_bytes = diagnostic.read_bytes()
|
|
staged_diagnostic = remote + '/installed-diagnostic-' + suffix + '.py'
|
|
rollout._write_new_private(client, staged_diagnostic, diagnostic_bytes)
|
|
base._exec(client, 'docker cp ' + shlex.quote(staged_diagnostic) + ' '
|
|
+ shlex.quote(probe_name + ':/tmp/installed-diagnostic.py'), timeout=20, keep_stdout=False)
|
|
base._exec(client, 'docker exec --user 0 ' + shlex.quote(probe_name)
|
|
+ ' chmod 644 /tmp/installed-diagnostic.py', timeout=15, keep_stdout=False)
|
|
imported, unused = base._json_command(client, 'docker exec ' + shlex.quote(probe_name)
|
|
+ ' python -B /tmp/installed-diagnostic.py --mode candidate --installed --package /tmp/zyt-stop-package', timeout=60)
|
|
base._require(imported.get('ok') is True and imported.get('imported_count') == 17
|
|
and not imported.get('blocked_operations'), 'INSTALLED_CANDIDATE_IMPORT_FAILED')
|
|
finally:
|
|
# Exact owned test container only. It has only slept/imported under side-effect guards.
|
|
base._exec(client, 'docker stop --time 3 ' + shlex.quote(probe_name), timeout=15, keep_stdout=False)
|
|
stopped, unused = base._json_command(client, 'docker inspect --format '
|
|
+ shlex.quote('{"running":{{json .State.Running}},"name":{{json .Name}}}') + ' ' + shlex.quote(probe_name))
|
|
base._require(stopped == {'running': False, 'name': '/' + probe_name}, 'PREFLIGHT_EXIT_UNCONFIRMED')
|
|
base._exec(client, 'docker rm ' + shlex.quote(probe_name), timeout=15, keep_stdout=False)
|
|
state['preflight_cleanup_confirmed'] = True; base._save_state(root, state)
|
|
base._require(rollout._compose_fingerprint(client, base) == fingerprint, 'PREFLIGHT_COMPOSE_CHANGED')
|
|
result = {'ok': True, 'action': 'prepare-deploy', 'effective_runtime': equality,
|
|
'installed_imports': imported, 'private_recovery': backup,
|
|
'test_container_removed': True, 'production_service_recreated': False,
|
|
'application_factory_called': False, 'model_requests': 0,
|
|
'installed_diagnostic_sha256': hashlib.sha256(diagnostic_bytes).hexdigest()}
|
|
state.update(preflight_passed=True, preflight_image_id=state['candidate_image_id'],
|
|
preflight_compose_fingerprint=fingerprint, preflight_result=result)
|
|
base._save_state(root, state)
|
|
return result
|