Files
zyt/artifacts/private_dify_ops_actions_20261027.py
2026-10-27 14:03:35 +08:00

455 lines
27 KiB
Python

"""Credential-free fixed Dify lifecycle actions for an already authenticated SSH client.
No action executes on import. No source deploy, restart, compose mutation or rollback
action is provided. The caller owns SSH authentication and host-key verification.
"""
from __future__ import annotations
from pathlib import Path, PurePosixPath
import hashlib
import io
import json
import re
import shlex
import stat
import tarfile
import time
import uuid
CONTAINER = '5006ea4b4e3b'
HOST = '192.168.110.111'
MANIFEST_SHA256 = 'c6a6864fb3c986a002e511ecc32887f52b50c71f007aee317af11e62befd6810'
SEAL_SHA256 = 'b489fd19656b8a2420ad259e10cc33dd9c1ddaf7223ad50c40021e3f61f1f0cb'
ACTIONS = frozenset({'inspect', 'stage', 'imports', 'build', 'health', 'logs-safe'})
REL_PACKAGE = 'artifacts/ai_full_verification_20261027/dify_stop_candidate'
REL_STATE = 'artifacts/ai_full_verification_20261027/dify_ops_state.safe.json'
class OpsError(RuntimeError):
def __init__(self, code):
self.code = code
super().__init__(code)
def _require(value, code):
if not value:
raise OpsError(code)
def _sha(data):
return hashlib.sha256(data).hexdigest()
def _exec(client, command, *, timeout=30, cap=131072, keep_stdout=True):
"""Bound both output streams and wall time; never return raw stderr."""
started = time.monotonic()
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
stdin.close()
channel = stdout.channel
channel.settimeout(min(1, timeout))
out = bytearray()
safe_error_buffer = bytearray()
out_total = err_total = 0
try:
while True:
_require(time.monotonic() - started < timeout, 'REMOTE_COMMAND_TIMEOUT_OUTCOME_UNCONFIRMED')
progressed = False
while channel.recv_ready():
chunk = channel.recv(32768)
out_total += len(chunk)
_require(out_total <= cap, 'REMOTE_STDOUT_LIMIT')
if keep_stdout:
out.extend(chunk)
progressed = True
while channel.recv_stderr_ready():
chunk = channel.recv_stderr(32768)
if len(safe_error_buffer) < 16384:
safe_error_buffer.extend(chunk[:16384-len(safe_error_buffer)])
err_total += len(chunk)
_require(err_total <= cap, 'REMOTE_STDERR_LIMIT')
progressed = True
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
status = channel.recv_exit_status()
if status != 0:
failure = OpsError('REMOTE_COMMAND_FAILED')
message = safe_error_buffer.decode(errors='replace').lower()
failure.diagnostic = {'exit_code': status, 'error_classes': [word for word in
['assertionerror','permission denied','unknown flag','no such file','not found',
'invalid reference format','pull access denied','conflict','already in use',
'error response from daemon','cannot','invalid','traceback','keyerror','typeerror']
if word in message]}
raise failure
return bytes(out), {'exit_code': status, 'stderr_present': err_total > 0,
'stdout_bytes': out_total, 'elapsed_ms': round((time.monotonic() - started) * 1000)}
if not progressed:
time.sleep(.02)
finally:
channel.close()
stdout.close()
stderr.close()
def _json_command(client, command, *, timeout=30, cap=65536):
data, meta = _exec(client, command, timeout=timeout, cap=cap)
try:
parsed = json.loads(data.decode('utf-8'))
except Exception:
raise OpsError('REMOTE_SAFE_JSON_INVALID') from None
return parsed, meta
def _inspect(client):
fields = {
'container_id': '.Id', 'image_id': '.Image', 'configured_image': '.Config.Image',
'name': '.Name', 'working_dir': '.Config.WorkingDir', 'runtime_user': '.Config.User',
'compose_project': '(index .Config.Labels "com.docker.compose.project")',
'compose_service': '(index .Config.Labels "com.docker.compose.service")',
'compose_config_files': '(index .Config.Labels "com.docker.compose.project.config_files")',
'compose_working_dir': '(index .Config.Labels "com.docker.compose.project.working_dir")',
}
template = '{' + ','.join(json.dumps(k) + ':{{json ' + v + '}}' for k, v in fields.items()) + '}'
data, meta = _json_command(client, 'docker inspect --format ' + shlex.quote(template) + ' ' + CONTAINER)
_require(isinstance(data, dict) and set(data) == set(fields), 'CONTAINER_METADATA_SHAPE_INVALID')
_require(all(v is None or isinstance(v, str) for v in data.values()), 'CONTAINER_METADATA_TYPE_INVALID')
_require(re.fullmatch(r'[0-9a-f]{64}', data['container_id'] or '') is not None
and data['container_id'].startswith(CONTAINER), 'CONTAINER_IDENTITY_MISMATCH')
_require(re.fullmatch(r'sha256:[0-9a-f]{64}', data['image_id'] or '') is not None, 'IMAGE_ID_INVALID')
_require(all(len(v or '') <= 4096 and '\x00' not in (v or '') for v in data.values()), 'CONTAINER_METADATA_LIMIT')
return data, meta
def _load_state(root):
path = root / REL_STATE
if not path.exists():
return {}
state = json.loads(path.read_text(encoding='utf-8'))
_require(isinstance(state, dict), 'OPS_STATE_INVALID')
return state
def _save_state(root, state):
path = root / REL_STATE
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp')
tmp.write_text(json.dumps(state, indent=2) + '\n', encoding='utf-8')
tmp.replace(path)
def _package(root):
package = (root / REL_PACKAGE).resolve(strict=True)
_require(package.is_relative_to(root), 'PACKAGE_PATH_INVALID')
seal_path = package / 'package_seal.json'
manifest_path = package / 'manifest.json'
_require(_sha(seal_path.read_bytes()) == SEAL_SHA256, 'PACKAGE_SEAL_MISMATCH')
_require(_sha(manifest_path.read_bytes()) == MANIFEST_SHA256, 'PACKAGE_MANIFEST_MISMATCH')
seal = json.loads(seal_path.read_text())
manifest = json.loads(manifest_path.read_text())
expected = set(seal['files']) | {'package_seal.json'}
actual = {p.relative_to(package).as_posix() for p in package.rglob('*') if p.is_file()}
_require(expected == actual and len(seal['files']) == seal['file_count'], 'PACKAGE_FILE_SET_MISMATCH')
for name, pin in seal['files'].items():
path = package / name
_require(not path.is_symlink() and path.resolve().is_relative_to(package), 'PACKAGE_SYMLINK_REFUSED')
_require(_sha(path.read_bytes()) == pin, 'PACKAGE_CONTENT_MISMATCH')
_require(manifest['target_count'] == 17 and len(manifest['targets']) == 17, 'PACKAGE_TARGET_COUNT_INVALID')
return package, manifest, seal
def _fresh_current(root, manifest):
base = root / 'artifacts/ai_full_verification_20261027/dify_current_source'
files = {}
for name in ['deployed_source.snapshot.json', 'deployed_source.dependencies.snapshot.json']:
for row in json.loads((base / name).read_text(encoding='utf-8'))['files']:
if 'text' not in row:
continue
content = row['text'].encode('utf-8')
_require(_sha(content) == row['sha256'] and len(content) == row['bytes'], 'CURRENT_SOURCE_SNAPSHOT_INVALID')
_require(row['path'] not in files, 'CURRENT_SOURCE_PATH_DUPLICATE')
files[row['path']] = row['sha256']
wanted = [row for row in manifest['targets'] if row['before_sha256'] is not None]
_require(len(wanted) == 14 and all(files.get(r['path']) == r['before_sha256'] for r in wanted), 'CURRENT_SOURCE_BASELINE_MISMATCH')
def _bound_metadata(client, state):
_require('inspect' in state, 'INSPECT_REQUIRED')
current, meta = _inspect(client)
original = state['inspect']
_require(current['container_id'] == original['container_id'] and current['image_id'] == original['image_id']
and current['working_dir'] == original['working_dir'], 'CONTAINER_CHANGED_SINCE_INSPECT')
_require(current['working_dir'] == '/app/api', 'API_WORKING_DIRECTORY_UNEXPECTED')
return current, meta
def _archive(files):
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as tar:
for name, content in sorted(files.items()):
path = PurePosixPath(name)
_require(not path.is_absolute() and '..' not in path.parts, 'ARCHIVE_PATH_INVALID')
item = tarfile.TarInfo(name)
item.size = len(content)
item.mode = 0o644
item.mtime = 0
tar.addfile(item, io.BytesIO(content))
return output.getvalue()
def _upload_extract(client, remote, name, data, destination):
"""A caller-owned new staging directory only, never an existing service tree."""
_require(re.fullmatch(r'/home/ps/\.zyt-dify-stop-20261027-[0-9a-f]{32}', remote) is not None, 'REMOTE_STAGE_PATH_INVALID')
_require(name in {'bundle.tar', 'build-context.tar'} and destination in {'package', 'build-context'}, 'REMOTE_STAGE_KIND_INVALID')
sftp = client.open_sftp()
try:
sftp.get_channel().settimeout(20)
archive = remote + '/' + name
with sftp.file(archive, 'wx') as handle:
handle.write(data)
sftp.chmod(archive, 0o600)
finally:
sftp.close()
program = '''import hashlib,json,pathlib,sys,tarfile
root=pathlib.Path(sys.argv[1]); name=sys.argv[2]; expected=sys.argv[3]; dest=root/sys.argv[4]
assert not dest.exists()
data=(root/name).read_bytes(); assert hashlib.sha256(data).hexdigest()==expected
dest.mkdir(mode=0o755)
count=0; total=0
with tarfile.open(root/name) as source:
for item in source.getmembers():
p=pathlib.PurePosixPath(item.name)
assert item.isfile() and not p.is_absolute() and '..' not in p.parts
target=dest/item.name; assert target.resolve().is_relative_to(dest.resolve())
assert item.size<=2097152 and total+item.size<=16777216
target.parent.mkdir(parents=True,exist_ok=True)
body=source.extractfile(item).read(); assert len(body)==item.size
with target.open('xb') as output: output.write(body)
target.chmod(0o644); count+=1; total+=len(body)
print(json.dumps({'ok':True,'files':count,'bytes':total}))
'''
command = 'python3 -c ' + shlex.quote(program) + ' ' + ' '.join(map(shlex.quote, [remote, name, _sha(data), destination]))
result, meta = _json_command(client, command, timeout=30)
_require(result.get('ok') is True, 'REMOTE_STAGE_EXTRACTION_FAILED')
return result, meta
def _safe_import_result(value):
fields = {'ok', 'stage', 'module', 'error_type', 'imported_count', 'error_details_redacted',
'network_permitted', 'thread_start_permitted', 'subprocess_permitted',
'application_factory_called', 'candidate_bytes_verified', 'installed'}
_require(isinstance(value, dict) and set(value).issubset(fields), 'IMPORT_RESULT_INVALID')
_require(all(type(v) in (bool, int, str) for v in value.values()), 'IMPORT_RESULT_TYPE_INVALID')
return value
def _image_metadata(client, tag):
_require(re.fullmatch(r'zyt-dify-stop-20261027:[a-z0-9-]+', tag) is not None, 'IMAGE_TAG_INVALID')
template = '{"image_id":{{json .Id}},"runtime_user":{{json .Config.User}},"working_dir":{{json .Config.WorkingDir}},"manifest":{{json (index .Config.Labels "zyt.stop.manifest")}},"base_image":{{json (index .Config.Labels "zyt.stop.base-image")}}}'
result, meta = _json_command(client, 'docker image inspect --format ' + shlex.quote(template) + ' ' + shlex.quote(tag))
_require(isinstance(result, dict) and re.fullmatch(r'sha256:[0-9a-f]{64}', result.get('image_id', '')) is not None, 'BUILT_IMAGE_METADATA_INVALID')
return result, meta
def run_action(client, action, repo_root):
"""Return sanitized JSON-compatible data; caller authenticates, executes, and displays."""
display_action = action if isinstance(action, str) and action in ACTIONS else 'invalid'
try:
_require(action in ACTIONS, 'ACTION_NOT_ALLOWED')
root = Path(repo_root).resolve(strict=True)
transport = client.get_transport()
_require(transport is not None and transport.is_active() and transport.is_authenticated(), 'AUTHENTICATED_SSH_REQUIRED')
_require(transport.sock.getpeername()[0] == HOST, 'SSH_DESTINATION_MISMATCH')
state = _load_state(root)
if action == 'logs-safe':
import importlib.util
import sys
spec=importlib.util.spec_from_file_location('fixed_published_identity',root/'artifacts/private_dify_published_identity_20261027.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
return module.inspect(client,sys.modules[__name__],root,state)
if action == 'health':
program = '''import hashlib,json,os,pathlib,re,subprocess,sys
project='dify_v1110_eera'; folder='/www/dk_project/dk_app/dify_v1110/dify_v1110_Eera'; path=folder+'/docker-compose.yml'
cmd=['docker','compose','--project-directory',folder,'-p',project,'-f',path]
def run(args):
p=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=25)
assert len(p.stdout)+len(p.stderr)<2097152
return p
result={'compose_readable':os.access(path,os.R_OK),'project_directory_searchable':os.access(folder,os.X_OK)}
for name,extra in [('ps',['ps','--all','-q','api']),('config',['config','--format','json'])]:
p=run(cmd+extra); text=p.stderr.decode(errors='replace').lower()
result[name]={'exit_code':p.returncode,'stdout_bytes':len(p.stdout),'stderr_bytes':len(p.stderr),'classifications':[k for k in ['permission denied','unknown flag','no such file','interpolation','not set','invalid','cannot','is not a directory'] if k in text]}
p=run(['docker','compose','version','--short']); value=p.stdout.decode().strip()
result['compose_version']=value if re.fullmatch(r'v?[0-9]+[.][0-9]+[.][0-9]+(?:[-a-z0-9.]*)?',value) else 'redacted'
p=run(['docker','ps','--all','-q','--filter','label=com.docker.compose.project='+project,'--filter','label=com.docker.compose.service=api']); ids=p.stdout.decode().split()
result['api_container_count']=len(ids)
prior=json.loads(sys.argv[1]); stage=prior.get('host_stage'); probe=prior.get('preflight_container_name')
if isinstance(stage,str) and re.fullmatch(r'/home/ps/\.zyt-dify-stop-20261027-[0-9a-f]{32}',stage):
result['preflight_files']={name:(pathlib.Path(stage)/name).is_file() for name in ['api-preflight.override.json','resolved-compose.before.private.json','installed-diagnostic.py']}
recovery=pathlib.Path(stage)/('resolved-compose-allprofiles-'+probe.rsplit('-',1)[1]+'.private.json') if isinstance(probe,str) and re.fullmatch(r'zyt-stop-preflight-[0-9a-f]{32}',probe) else pathlib.Path(stage)/'resolved-compose.before.private.json'
if recovery.is_file():
a=run(cmd+['--profile','*','config','--format','json']); b=run(cmd[:6]+['-f',str(recovery),'--profile','*','config','--format','json'])
error=b.stderr.decode(errors='replace').lower()
words=set('invalid project name directory is not must be empty provided no configuration file found read reading open permission denied path exists a an the and or with within outside from to too long short failed error cannot valid only use lower case lowercase letters numbers hyphens underscores leading character required validating parsing resolving evaluating interpolation missing separator context relative absolute flag unknown service refers undefined depends on network volume'.split())
result['recovery_check']={'original_exit':a.returncode,'recovery_exit':b.returncode,'equal':False,'error_classes':[k for k in ['validating','yaml','json','additional propert','must be a','invalid interpolation','unsupported','cannot','not allowed','environment','volumes','networks','services','ports','is required','expected','unmarshal','does not','configs','secrets','healthcheck','depends_on','extends','include','env_file','project','multiple','connection','invalid type','is not'] if k in error]}
result['recovery_check']['fixed_vocabulary_error']=[token if token in words else '<redacted>' for token in re.findall('[a-z]+',error)][:70]
if a.returncode==0:
model=json.loads(a.stdout); keys=[]
result['recovery_check']['variants']={}
for variant,flags in [('explicit_env',['--env-file',folder+'/.env']),('all_profiles',['--profile','*']),('env_all',['--env-file',folder+'/.env','--profile','*'])]:
p=run(cmd[:6]+flags+['-f',str(recovery),'config','--format','json'])
result['recovery_check']['variants'][variant]={'exit':p.returncode,'equal':p.returncode==0 and json.loads(p.stdout)==model}
service_names=set(model.get('services',{}))
result['recovery_check']['dependency_missing_count']=sum(1 for s in model.get('services',{}).values() for dependency in s.get('depends_on',{}) if dependency not in service_names)
def walk(v,prefix=''):
if isinstance(v,dict):
for k,item in v.items():
if re.fullmatch('[A-Za-z_][A-Za-z0-9_.-]{0,100}',k):
path=prefix+'.'+k if prefix else k
if path.lower() in error: keys.append(path)
walk(item,path)
elif isinstance(v,list):
for item in v: walk(item,prefix)
walk(model); result['recovery_check']['schema_paths_in_error']=sorted(keys)[:40]
if a.returncode==b.returncode==0:
av=json.loads(a.stdout); bv=json.loads(b.stdout)
result['recovery_check']['equal']=av==bv
result['recovery_check']['top_level_difference_keys']=[k for k in sorted(set(av)|set(bv)) if av.get(k)!=bv.get(k)]
result['recovery_check']['service_difference_fields']={service:[key for key in sorted(set(value)|set(bv.get('services',{}).get(service,{}))) if value.get(key)!=bv.get('services',{}).get(service,{}).get(key)] for service,value in av.get('services',{}).items() if value!=bv.get('services',{}).get(service,{})}
api_a=av['services']['api']; api_b=bv['services']['api']; env_a=api_a.get('environment',{}); env_b=api_b.get('environment',{})
result['recovery_check']['api_environment_difference_count']=sum(1 for key in set(env_a)|set(env_b) if env_a.get(key)!=env_b.get(key))
if isinstance(probe,str) and re.fullmatch(r'zyt-stop-preflight-[0-9a-f]{32}',probe):
p=run(['docker','inspect','--format','{"running":{{json .State.Running}}}',probe])
result['preflight_container']={'exists':p.returncode==0}
if p.returncode==0: result['preflight_container'].update(json.loads(p.stdout))
print(json.dumps({'ok':True,'diagnostic':result,'mutation_performed':False}))
'''
prior = {k: state.get(k) for k in ['host_stage','preflight_container_name']}
result, meta = _json_command(client, 'python3 -c ' + shlex.quote(program) + ' ' + shlex.quote(json.dumps(prior)), timeout=60)
if state.get('preflight_passed') is True and not state.get('deployment_started'):
drain_code = '''import json,pathlib,time
samples=[]
for i in range(3):
count=0
for file in ['/proc/net/tcp','/proc/net/tcp6']:
p=pathlib.Path(file)
if not p.is_file(): continue
for line in p.read_text().splitlines()[1:]:
parts=line.split()
if int(parts[1].rsplit(':',1)[1],16)==5001 and parts[3]=='01': count+=1
samples.append(count)
if i<2: time.sleep(1)
print(json.dumps({'api_port':5001,'established_inbound_samples':samples,'all_samples_zero':not any(samples),'observation_only':True}))
'''
observation, unused = _json_command(client, 'docker exec ' + shlex.quote(state['inspect']['container_id'])
+ ' python -B -c ' + shlex.quote(drain_code), timeout=10)
state['maintenance_observation'] = observation; _save_state(root,state)
result['maintenance_observation'] = observation
return result
if action == 'inspect':
metadata, command = _inspect(client)
if 'inspect' in state:
_require(state['inspect']['container_id'] == metadata['container_id'] and state['inspect']['image_id'] == metadata['image_id'], 'PRIOR_INSPECT_IDENTITY_CHANGED')
state['inspect'] = metadata
_save_state(root, state)
return {'ok': True, 'action': action, 'metadata': metadata, 'command': command,
'environment_read': False, 'live_source_changed': False}
package, manifest, seal = _package(root)
_fresh_current(root, manifest)
metadata, inspection = _bound_metadata(client, state)
if action == 'stage':
if state.get('staged') is True:
return {'ok': True, 'action': action, 'already_staged': True, 'host_stage': state['host_stage'],
'container_stage': state['container_stage'], 'live_source_changed': False}
_require(not state.get('stage_started'), 'PRIOR_STAGE_OUTCOME_UNCONFIRMED')
identifier = uuid.uuid4().hex
remote = '/home/ps/.zyt-dify-stop-20261027-' + identifier
inside = '/tmp/zyt-dify-stop-20261027-' + identifier
state.update(stage_started=True, host_stage=remote, container_stage=inside,
manifest_sha256=MANIFEST_SHA256, seal_sha256=SEAL_SHA256)
_save_state(root, state)
sftp = client.open_sftp()
try:
sftp.get_channel().settimeout(20)
sftp.mkdir(remote, mode=0o700)
finally:
sftp.close()
files = {name: (package / name).read_bytes() for name in seal['files']}
files['package_seal.json'] = (package / 'package_seal.json').read_bytes()
extracted, extraction = _upload_extract(client, remote, 'bundle.tar', _archive(files), 'package')
_, mkdir_meta = _exec(client, 'docker exec --user 0 ' + CONTAINER + ' mkdir -m 755 ' + shlex.quote(inside))
_, copy_meta = _exec(client, 'docker cp ' + shlex.quote(remote + '/package') + ' ' + shlex.quote(CONTAINER + ':' + inside + '/package'), timeout=45)
command = 'docker exec ' + CONTAINER + ' python -B ' + shlex.quote(inside + '/package/deploy_package.py') + ' check-baseline --root /app/api'
check, check_meta = _json_command(client, command, timeout=30)
_require(check.get('ok') is True, 'LIVE_BASELINE_CHECK_FAILED')
state['staged'] = True
_save_state(root, state)
return {'ok': True, 'action': action, 'host_stage': remote, 'container_stage': inside,
'files_staged': extracted['files'], 'baseline_targets_verified': 17,
'manifest_sha256': MANIFEST_SHA256, 'seal_sha256': SEAL_SHA256,
'live_source_changed': False}
_require(state.get('staged') is True, 'STAGE_REQUIRED')
if action == 'imports':
command = 'docker exec ' + CONTAINER + ' python -B ' + shlex.quote(state['container_stage'] + '/package/container_import_check.py') + ' --api-root /app/api'
# A failed import intentionally exits nonzero; extract only its safe JSON.
command += '; code=$?; test "$code" -le 1'
result, command_meta = _json_command(client, command, timeout=60)
result = _safe_import_result(result)
state['imports_passed'] = result.get('ok') is True
state['imports_result'] = result
_save_state(root, state)
return {'ok': state['imports_passed'], 'action': action, 'result': result,
'command': command_meta, 'live_source_changed': False}
_require(action == 'build' and state.get('imports_passed') is True, 'SUCCESSFUL_IMPORTS_REQUIRED')
if state.get('built') is True:
built, command_meta = _image_metadata(client, state['candidate_image_tag'])
_require(built['image_id'] == state['candidate_image_id'], 'BUILT_IMAGE_CHANGED')
return {'ok': True, 'action': action, 'already_built': True, 'image': built,
'image_tag': state['candidate_image_tag'], 'services_restarted': False}
_require(not state.get('build_started'), 'PRIOR_BUILD_OUTCOME_UNCONFIRMED')
runtime_user = metadata['runtime_user'] or '0'
_require(re.fullmatch(r'[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)?', runtime_user) is not None, 'RUNTIME_USER_INVALID')
suffix = state['host_stage'].rsplit('-', 1)[1][:12]
base_tag = 'zyt-dify-stop-20261027:base-' + suffix
image_tag = 'zyt-dify-stop-20261027:candidate-' + MANIFEST_SHA256[:12] + '-' + suffix
state.update(build_started=True, candidate_image_tag=image_tag, base_image_tag=base_tag)
_save_state(root, state)
checks = [{'path': r['path'], 'before': r['before_sha256'], 'after': r['after_sha256']} for r in manifest['targets']]
verifier = '''import hashlib,json,pathlib,sys
root=pathlib.Path('/app/api'); rows=json.loads(pathlib.Path('/tmp/zyt-stop-build/checks.json').read_text()); mode=sys.argv[1]
for row in rows:
p=root/row['path']; expected=row[mode]
assert (hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else None)==expected
if mode=='after': compile(p.read_bytes(),row['path'],'exec')
print(json.dumps({'ok':True,'mode':mode,'verified_targets':len(rows)}))
'''
dockerfile = '\n'.join([
'FROM ' + metadata['image_id'], 'USER 0',
'COPY checks.json verify_tree.py /tmp/zyt-stop-build/',
'RUN python -B /tmp/zyt-stop-build/verify_tree.py before',
'COPY candidate/ /app/api/',
'RUN python -B /tmp/zyt-stop-build/verify_tree.py after',
'LABEL zyt.stop.manifest="' + MANIFEST_SHA256 + '" zyt.stop.base-image="' + metadata['image_id'] + '"',
'USER ' + runtime_user, '',
])
files = {'Dockerfile': dockerfile.encode(), 'checks.json': json.dumps(checks).encode(), 'verify_tree.py': verifier.encode()}
files.update({'candidate/' + r['path']: (package / 'candidate' / r['path']).read_bytes() for r in manifest['targets']})
extracted, extraction = _upload_extract(client, state['host_stage'], 'build-context.tar', _archive(files), 'build-context')
_, tag_meta = _exec(client, 'docker image tag ' + shlex.quote(metadata['image_id']) + ' ' + shlex.quote(base_tag))
base, base_meta = _image_metadata(client, base_tag)
_require(base['image_id'] == metadata['image_id'], 'BASE_IMAGE_IDENTITY_MISMATCH')
command = 'cd ' + shlex.quote(state['host_stage'] + '/build-context') + ' && DOCKER_BUILDKIT=0 timeout 180s docker build --pull=false --network=none --tag ' + shlex.quote(image_tag) + ' .'
_, build_meta = _exec(client, command, timeout=190, cap=2097152, keep_stdout=False)
built, built_meta = _image_metadata(client, image_tag)
_require(built.get('manifest') == MANIFEST_SHA256 and built.get('base_image') == metadata['image_id'], 'BUILT_IMAGE_PROVENANCE_MISMATCH')
_require((built.get('runtime_user') or '0') == runtime_user and built.get('working_dir') == metadata['working_dir'], 'BUILT_IMAGE_RUNTIME_METADATA_MISMATCH')
state.update(built=True, candidate_image_id=built['image_id'])
_save_state(root, state)
return {'ok': True, 'action': action, 'image_tag': image_tag, 'image': built,
'context_file_count': extracted['files'], 'build': build_meta,
'runtime_environment_read': False, 'runtime_environment_baked': False,
'live_source_changed': False, 'services_restarted': False}
except OpsError as error:
return {'ok': False, 'action': display_action, 'error_code': error.code,
'raw_remote_output_suppressed': True}
except Exception as error:
return {'ok': False, 'action': display_action, 'error_code': 'OPS_ACTION_FAILED',
'error_type': type(error).__name__, 'raw_remote_output_suppressed': True}