455 lines
27 KiB
Python
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}
|