{"compose_backend.py": "\"\"\"Adopt an existing Compose stack without moving its data or changing boot policy.\"\"\"\nimport base64\nimport copy\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\nimport shutil\nimport time\n\nimport field_profiles as fp\nimport server_context as server\n\n\ndef canonical(value):\n    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()\n\n\ndef execute(args, timeout=30):\n    from maintenance_process import run\n    from deployment_kit.compose_snapshot import command_environment\n    return run(args, check=True, capture_output=True, text=True, timeout=timeout,\n               env=command_environment(), cwd=server.CURRENT['working_dir']).stdout\n\n\ndef compose_args(path=None):\n    args = ['docker','compose','--project-directory',server.CURRENT['working_dir'],'-p',server.project()]\n    if path is None:\n        for p in server.CURRENT['env_files']:args += ['--env-file',p]\n        for p in server.CURRENT['compose_files']:args += ['-f',p]\n    else:args += ['-f',str(path)]\n    return args\n\n\ndef render():\n    return server.logical(json.loads(execute(compose_args()+['config','--format','json'])))\n\n\ndef state_path():\n    return fp.RUNTIME/'state'\n\n\ndef active():\n    return fp.read_json(state_path()/'active.json', 1024**2)\n\n\ndef current_path():\n    p = fp.safe_path(active()['compose'])\n    if not p.is_relative_to(state_path()/'transactions'):\n        raise ValueError('Active Compose outside installation metadata')\n    return p\n\n\ndef running():\n    return execute(['docker','ps','--filter','label=com.docker.compose.project='+server.project(),'--format','{{.ID}}']).split()\n\n\ndef stopped():\n    if running():raise ValueError('Target services must be stopped; use ct stop explicitly')\n\n\ndef containers():\n    ids = execute(['docker','ps','-aq','--filter','label=com.docker.compose.project='+server.project()]).split()\n    if not ids:return []\n    return json.loads(execute(['docker','inspect',*ids]))\n\n\ndef preserve_resources(doc, rows):\n    \"\"\"Pin running images, actual anonymous volumes and project networks by identity.\"\"\"\n    result = copy.deepcopy(doc)\n    by_name = {}\n    for row in rows:\n        labels = row['Config'].get('Labels') or {}\n        role = next((r for r in server.ROLES if server.service(r) == labels.get('com.docker.compose.service')), None)\n        if role is None or role in by_name:raise ValueError('Unexpected or replicated service during adoption')\n        by_name[role] = row\n    if set(by_name) != set(server.ROLES):raise ValueError('Five existing service containers required for adoption')\n    for role, cfg in result['services'].items():\n        row = by_name[role]\n        cfg['image'] = row['Image'];cfg.pop('build',None);cfg.pop('pull_policy',None)\n        mounts = {m['Destination']:m for m in row['Mounts']}\n        for mount in cfg.get('volumes',[]):\n            actual = mounts.get(mount['target'])\n            if actual is None or mount['type'] != actual['Type']:\n                raise ValueError('Configured/live storage mismatch: '+role)\n            if mount['type'] == 'bind' and Path(mount['source']) != Path(actual['Source']):\n                raise ValueError('Configured/live bind mismatch: '+role)\n            if mount['type'] == 'volume':\n                key = 'ct_'+role+'_'+hashlib.sha256(mount['target'].encode()).hexdigest()[:10]\n                result.setdefault('volumes',{})[key] = {'external':True,'name':actual['Name']}\n                mount['source'] = key\n        configured_targets = {m['target'] for m in cfg.get('volumes',[])}\n        for target, actual in mounts.items():\n            if actual['Type'] == 'volume' and target not in configured_targets:\n                key = 'ct_'+role+'_'+hashlib.sha256(target.encode()).hexdigest()[:10]\n                result.setdefault('volumes',{})[key] = {'external':True,'name':actual['Name']}\n                item={'type':'volume','source':key,'target':target}\n                if not actual['RW']:item['read_only']=True\n                cfg.setdefault('volumes',[]).append(item)\n        attached = row['NetworkSettings']['Networks']\n        for key in cfg.get('networks',{}):\n            name = result.get('networks',{}).get(key,{}).get('name',server.project()+'_'+key)\n            if name not in attached:raise ValueError('Configured/live network mismatch')\n            result.setdefault('networks',{})[key] = {'external':True,'name':name,'ipam':{}}\n    used = {m['source'] for cfg in result['services'].values() for m in cfg.get('volumes',[]) if m['type']=='volume'}\n    result['volumes'] = {k:v for k,v in result.get('volumes',{}).items() if k in used}\n    return result\n\n\ndef inspect():\n    import field_runtime as runtime\n    a = active(); cp = current_path()\n    protected=fp.read_json(state_path()/'protected-bindings.json',1048576) if (state_path()/'protected-bindings.json').exists() else {}\n    from deployment_kit.compose_snapshot import verify_bindings\n    verify_bindings(protected)\n    base = fp.read_json(cp, 8*1024**2)\n    if canonical(base) != a['compose_semantic_sha256']:\n        approved = fp.read_json(cp.with_name('approved.json'), 8*1024**2)\n        if canonical(approved) != a['compose_semantic_sha256']:raise ValueError('Protected baseline damaged')\n        runtime.check_drift(approved, base)\n    else:approved = base\n    old_input = fp.read_json(state_path()/'input-baseline.json',8*1024**2)\n    source = render()\n    runtime.check_drift(old_input, source)\n    site = fp.read_json(state_path()/'site.json')\n    return a,site,cp,approved,base,source\n\n\ndef verify_plan(plan):\n    from field_runtime import sha\n    inspect()\n    if canonical(render()) != plan['baseline']['input_stamp'] or sha(current_path()) != plan['baseline']['compose_sha256']:\n        raise ValueError('Compose changed since preparation plan')\n\n\ndef firewall_stamp():\n    f=server.CURRENT['firewall']\n    if not f:return {}\n    from field_runtime import sha\n    script=Path(f['script']);access=script.with_name('access.json')\n    return {'script':sha(script),'access':canonical(fp.read_json(access))}\n\n\ndef verify_enrollment(record):\n    inspect()\n    if canonical(render())!=record['input_stamp'] or firewall_stamp()!=record['firewall_stamp']:\n        raise ValueError('Local source/firewall changed since prepare; no remote request')\n\n\ndef prepare(p):\n    import connection\n    import field_network\n    import network_prepare\n    import field_runtime as runtime\n    a,site,cp,base,live,source = inspect()\n    stopped();server.guard_disks()\n    if (fp.STATE/'pending.json').exists():raise ValueError('Pending stage preserved; status/resume/cancel first')\n    # A different public edit must be imported/reviewed, never silently overwritten by a profile.\n    accepted_input=fp.read_json(state_path()/'input-baseline.json',8*1024**2)\n    source_values=runtime.public_values(source)\n    if canonical(source)!=canonical(accepted_input) or live!=base:\n        desired_values=dict(ai_ip=field_network.ai_ip(p),manager=fp.web(p)['manager'],web_host=fp.web(p)['host'],\n            event_url=fp.web(p)['event_url'],control_port=p['control_port'],redis_port=p['redis_port'],\n            artifact_lan_cidr=p['artifact_lan_cidr'],server_id=fp.web(p)['server_id'])\n        changed=source_values if canonical(source)!=canonical(accepted_input) else runtime.public_values(live)\n        if changed!=desired_values:raise ValueError('Public direct edits conflict with profile. Review ct emergency before prepare')\n    ca=fp.ca_path(p);connection.verify_ca(ca)\n    credentials=fp.read_credentials(fp.secret_path(p))\n    wanted=connection.addresses(field_network.ai_ip(p),fp.web(p)['host'],fp.web(p)['manager'],p['control_port'],p['redis_port'])\n    wanted.update(event_endpoint=fp.web(p)['event_url'],artifact_lan_cidr=p['artifact_lan_cidr'],redis_allow=p['redis_allow'])\n    directory=fp.STATE/('field-'+time.strftime('%Y%m%dT%H%M%S')+'-'+str(time.time_ns()))\n    fp.atomic(directory/'plan.json',dict(desired=wanted,baseline=dict(compose_sha256=runtime.sha(cp),input_stamp=canonical(source))))\n    stage=network_prepare.prepare(directory/'plan.json',ca,runtime.sha(ca),credentials=credentials,\n        server_id=fp.web(p)['server_id'],connection_version=fp.web(p).get('connection_version'))\n    record=fp.read_json(stage/'record.json')\n    record.update(profile=p['id'],profile_stamp=fp.stamp(p),profile_snapshot=p,redis_allow=p['redis_allow'],firewall_stamp=firewall_stamp(),\n        profile_credentials_sha256=runtime.sha(fp.secret_path(p)),profile_ca_sha256=runtime.sha(ca),public_before=runtime.public_values(base))\n    fp.atomic(stage/'record.json',record)\n    record['prepared_files']={name:runtime.sha(stage/name) for name in ('compose.proposed.json','host-enrollment.json',\n        'bridge/login-body.json','bridge/password','bridge/manager-ca.crt','bridge/control-token','bridge/relay-token','tls/control-tls.key')}\n    fp.atomic(fp.STATE/'stages'/(stage.name+'.json'),record)\n    fp.atomic(fp.STATE/'pending.json',{'stage':str(stage),'profile':p['id'],'phase':'PREPARED'})\n    print('Prepared only. Next: ct config connect (explicit remote request)')\n    return stage\n\n\ndef allowed_writes():\n    paths={state_path()/x for x in ('active.json','site.json','input-baseline.json')}\n    firewall=server.CURRENT['firewall']\n    if firewall:\n        paths.add(Path(firewall['script']).with_name('access.json'))\n        paths.add(Path(firewall['script']))\n    return paths\n\n\ndef replay(journal, reverse=False):\n    writes=journal['writes']\n    if len({x['path'] for x in writes})!=len(writes) or {Path(x['path']) for x in writes}!=allowed_writes():\n        raise ValueError('Unexpected apply/restore targets')\n    for item in writes:\n        path=fp.safe_path(item['path']);current=path.read_bytes() if path.exists() else None\n        if current not in (base64.b64decode(item['before']),base64.b64decode(item['after'])):\n            raise ValueError('Concurrent local edit preserved: '+str(path))\n    for item in writes:\n        path=fp.safe_path(item['path']);current=path.read_bytes() if path.exists() else None\n        if current not in (base64.b64decode(item['before']),base64.b64decode(item['after'])):\n            raise ValueError('Concurrent local write preserved: '+str(path))\n        fp.atomic(path,base64.b64decode(item['before' if reverse else 'after']))\n\n\ndef firewall_apply():\n    f=server.CURRENT['firewall']\n    if f:\n        # Reconcile the owned chain even when restoring a legacy append-only launcher.\n        import portable_firewall\n        portable_firewall.apply()\n\n\ndef guard_images(doc):\n    for cfg in doc['services'].values():\n        ident=cfg['image']\n        if not ident.startswith('sha256:'):raise ValueError('Pinned local image ID required')\n        found=json.loads(execute(['docker','image','inspect',ident]))\n        if len(found)!=1 or found[0]['Id']!=ident:raise ValueError('Pinned image missing')\n\n\ndef apply_stage(stage):\n    import field_runtime as runtime\n    import field_network\n    record,p=runtime.verify_pending(stage)\n    runtime.stage_ready(stage);stopped();server.guard_disks()\n    if shutil.disk_usage('/').free<15*1024**3:raise ValueError('Root reserve 15GiB required')\n    if record['desired']['ai_ip'] not in field_network.addresses(p['network']['interface']):\n        raise ValueError('Assign the intended NIC address before app apply')\n    pending=fp.STATE/'apply-pending.json'\n    if pending.exists():\n        journal=fp.read_json(pending,16*1024**2)\n        if journal['stage']!=str(stage):raise ValueError('Different apply pending')\n    else:\n        inspect()\n        if runtime.sha(state_path()/'active.json')!=record['base_active_sha256'] or canonical(render())!=record['input_stamp'] or firewall_stamp()!=record['firewall_stamp']:\n            raise ValueError('Configuration changed since prepare; preserve this issued stage')\n        doc=fp.read_json(stage/'compose.proposed.json',8*1024**2);guard_images(doc)\n        tx=state_path()/'transactions'/stage.name\n        tx.mkdir(mode=0o700,exist_ok=True)\n        fp.atomic(tx/'compose.json',doc);fp.atomic(tx/'approved.json',doc)\n        physical=server.physical(doc);fp.atomic(tx/'compose.physical.json',physical)\n        reread=json.loads(execute(compose_args(tx/'compose.physical.json')+['config','--format','json']))\n        from apply_source import normalize\n        if normalize(server.logical(reread))!=normalize(doc):raise ValueError('Final Compose differs; no active change')\n        a={'transaction':stage.name,'compose':str(tx/'compose.json'),'physical':str(tx/'compose.physical.json'),\n           'compose_semantic_sha256':canonical(doc),'profile':p['id'],'enrolled':True}\n        site={'ai_ip':record['desired']['ai_ip'],'env_files':server.CURRENT['env_files']}\n        values={state_path()/'active.json':a,state_path()/'site.json':site,state_path()/'input-baseline.json':render()}\n        f=server.CURRENT['firewall']\n        if f:\n            access=Path(f['script']).with_name('access.json')\n            original=fp.read_json(access)\n            if set(original)!={'host','port','readers'}:raise ValueError('Unsupported Redis firewall access contract')\n            values[access]={'host':site['ai_ip'],'port':p['redis_port'],'readers':p['redis_allow']}\n            loader=('import sys\\nsys.path.insert(0, '+repr(str(Path(__file__).parent))+')\\n'\n                    'from portable_firewall import apply\\napply()\\n').encode()\n            values[Path(f['script'])]=loader\n        journal={'stage':str(stage),'phase':'PREPARED','writes':[]}\n        for path,value in values.items():\n            journal['writes'].append({'path':str(path),'before':base64.b64encode(path.read_bytes()).decode(),\n                'after':base64.b64encode(value if isinstance(value,bytes) else (json.dumps(value,indent=2)+'\\n').encode()).decode()})\n        fp.atomic(pending,journal)\n    replay(journal)\n    firewall_apply()\n    journal['phase']='APPLIED';fp.atomic(fp.STATE/'last-compose-apply.json',journal)\n    fp.atomic(fp.STATE/'last-applied.json',{'stage':str(stage),'status':'APPLIED'})\n    runtime.export_receipt(stage,'APPLIED')\n    pending.unlink(missing_ok=True);(fp.STATE/'pending.json').unlink(missing_ok=True)\n    print('Applied locally; boot policy unchanged; remote auth is not rollbackable. Next: ct start')\n\n\ndef restore_config():\n    stopped();server.guard_disks()\n    pending=fp.STATE/'apply-pending.json'\n    journal=fp.read_json(pending if pending.exists() else fp.STATE/'last-compose-apply.json',16*1024**2)\n    replay(journal,reverse=True);firewall_apply()\n    journal['phase']='RESTORED';fp.atomic(fp.STATE/'last-compose-restore.json',journal)\n    pending.unlink(missing_ok=True)\n    print('Local configuration restored only. Web registration/tokens were NOT restored. No automatic Start.')\n\n\ndef runtime(action):\n    from data_safety import lock\n    with lock(fp.STATE/'data-maintenance.lock'):\n        return _runtime(action)\n\n\ndef _runtime(action):\n    from maintenance_process import run\n    import field_runtime as shared\n    if action=='stop':\n        # Stopping must remain possible even when a config edit blocks Start.\n        a=active()\n    else:a,site,cp,base,live,source=inspect()\n    if action=='start':\n        server.guard_disks();guard_images(base)\n        if not a.get('enrolled'):raise ValueError('Existing stack adopted but not enrolled by ct. Complete user-led config prepare/connect/apply first')\n        if canonical(base)!=canonical(live):raise ValueError('Review direct changes through ct emergency before Start')\n        source_base=fp.read_json(state_path()/'input-baseline.json',8*1024**2)\n        if canonical(source)!=canonical(source_base):raise ValueError('Persistent source changed; review ct emergency before Start')\n        if (fp.STATE/'apply-pending.json').exists():raise ValueError('Interrupted configuration apply: ct config resume')\n        if (fp.STATE/'network-pending.json').exists():raise ValueError('Incomplete NIC operation; inspect ct net status/restore')\n        if (fp.STATE/'pending.json').exists():\n            stage=shared.pending_stage()\n            if (stage/'enroll-intent.json').exists() or (stage/'enroll-result.json').exists():\n                raise ValueError('Remote enrollment pending; reconcile ct config status/resume before Start')\n        import field_network\n        p=fp.load(a['profile'])\n        if site['ai_ip'] not in field_network.addresses(p['network']['interface']):raise ValueError('Configured AI address is not assigned')\n        shared.stage_ready(Path(fp.read_json(fp.STATE/'last-applied.json')['stage']))\n        firewall_apply()\n        execute(compose_args(a['physical'])+['up','-d','--pull','never','--no-build'],240)\n    elif action=='stop':\n        rows=containers();forced=[]\n        for role in ('ai-gateway','analytics'):\n            for row in rows:\n                if not row['State']['Running'] or row['Config']['Labels'].get('com.docker.compose.service')!=server.service(role):continue\n                if role=='ai-gateway':\n                    execute(['docker','stop','--timeout','30',row['Id']],45)\n                    state=json.loads(execute(['docker','inspect',row['Id']]))[0]['State']\n                    if state.get('OOMKilled') or state.get('ExitCode')==137:forced.append(role)\n                else:\n                    helper=Path(__file__).parent/'stop_channels.py'\n                    run(['docker','exec','-i',row['Id'],'python3','-'],input=helper.read_bytes(),check=True,timeout=240,capture_output=True)\n        execute(compose_args(a['physical'])+['stop','--timeout','60'],330)\n        stopped()\n        if forced:raise RuntimeError('Stopped, but Gateway force termination observed; not graceful PASS')\n    print('Services:',action,'No automatic camera Start.')\n\n\ndef status():\n    import field_runtime as runtime\n    a,site,cp,base,live,source=inspect()\n    print('Managed Compose:',cp)\n    print('Effective app values:',json.dumps(runtime.public_values(live),ensure_ascii=False))\n    print('Public direct edit:',canonical(base)!=canonical(live),'Boot policy: unchanged')\n    rows=containers()\n    print('Services:',json.dumps({r['Config']['Labels']['com.docker.compose.service']:r['State']['Status'] for r in rows}))\n    print('Login/certificate/WSS/Redis/display: not probed. Remote current version: unknown.')\n", "field_runtime.py": "\"\"\"FIELD-AB-03: accept supported config changes without weakening code/storage trust.\"\"\"\nimport base64\nimport copy\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nimport connection\nimport field_profiles as profiles\nimport field_network\nimport server_context as server\nfrom field_ui import print, input\n\nTOOLS = server.path('tools', profiles.RUNTIME / 'deploy-tools/provider-20260927-r01')\nENV_KEYS = {\n    'analytics': {'CONTEXTRA_ARTIFACT_PUBLIC_BASE','AI_CONTROL_SERVER_ID'},\n    'ai-control-sidecar': {'AI_CONTROL_MANAGER_BASE_URL','AI_CONTROL_EVENT_ENDPOINT','AI_CONTROL_SERVER_ID'},\n    'ai-gateway': {'AI_CONTROL_HOST_PORT','AI_ARTIFACT_LAN_CIDR'},\n}\n\n\ndef sha(path):\n    return hashlib.sha256(profiles.safe_path(path).read_bytes()).hexdigest()\n\n\ndef run(args, timeout=30):\n    from deployment_kit.compose_snapshot import command_environment\n    from maintenance_process import run as execute\n    return execute(args,check=True,capture_output=True,text=True,timeout=timeout,env=command_environment()).stdout\n\n\ndef libraries():\n    if str(TOOLS) not in sys.path: sys.path.insert(0,str(TOOLS))\n\n\ndef masked(doc):\n    from apply_source import normalize\n    d=normalize(doc)\n    for name,keys in ENV_KEYS.items():\n        env=d['services'][name].setdefault('environment',{})\n        for k in keys:env.pop(k,None)\n    for name in ('redis','ai-gateway'):\n        for item in d['services'][name].get('ports',[]):\n            for key in ('published','host_ip'):item.pop(key,None)\n            if name=='ai-gateway':item.pop('target',None)\n    return d\n\n\ndef check_drift(base, candidate):\n    if masked(base) != masked(candidate):\n        raise ValueError('\uc9c0\uc6d0 \ubc94\uc704 \ubc16\uc758 Compose \ubcc0\uacbd\uc785\ub2c8\ub2e4. image/mount/command/\uad8c\ud55c/DB\ub294 \uae34\uae09\ubaa8\ub4dc\uc5d0\uc11c\ub3c4 \uc790\ub3d9 \uc2b9\uc778\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.')\n\n\ndef remember_baseline():\n    state=profiles.RUNTIME/'state'\n    a=profiles.read_json(state/'active.json',1048576)\n    s=state/'site.json'\n    if sha(s)!=a['site_sha256']:raise ValueError('\uae30\uc874 \uc815\uc0c1 site \uc9c0\ubb38\uc774 \uc544\ub2d9\ub2c8\ub2e4.')\n    profiles.atomic(profiles.STATE/'accepted-sites'/a['site_sha256'],s.read_bytes())\n\n\ndef inspect():\n    if server.portable():\n        import compose_backend\n        return compose_backend.inspect()\n    libraries()\n    from deployment_kit import site,release,compose_snapshot\n    state=profiles.RUNTIME/'state';a=profiles.read_json(state/'active.json',1048576)\n    inflight=profiles.read_json(state/'inflight.json')\n    if inflight.get('status')!='DONE' or inflight.get('transaction')!=a['transaction']:\n        raise ValueError('\uae30\uc874 Runtime \uc801\uc6a9 \ubbf8\uc644\ub8cc. ct config resume \ub610\ub294 ct config restore\ub85c \ud655\uc778\ud558\uc138\uc694.')\n    cp=connection.active_compose()\n    if cp.parent.name!=a['transaction']:raise ValueError('\uc11c\ube44\uc2a4\uc640 active transaction\uc774 \ub2e4\ub985\ub2c8\ub2e4.')\n    normalized=cp.with_name('compose.normalized.json')\n    if sha(normalized)!=a['normalized_sha256']:raise ValueError('\uc815\uc0c1 Compose \uae30\uc900 \uc190\uc0c1. \ubb34\uc870\uac74 SHA \uc7ac\ub4f1\ub85d\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.')\n    base=profiles.read_json(normalized,8*1024**2)\n    live=profiles.read_json(cp,8*1024**2);check_drift(base,live)\n    value=site.load(state/'site.json')\n    if sha(state/'site.json')!=a['site_sha256']:\n        saved=profiles.STATE/'accepted-sites'/a['site_sha256']\n        if not saved.exists() or sha(saved)!=a['site_sha256']:raise ValueError('site \ubcc0\uacbd \ube44\uad50 \uae30\uc900\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. root \ubc31\uc5c5\uc744 \ud655\uc778\ud558\uc138\uc694.')\n        old=profiles.read_json(saved)\n        one=copy.deepcopy(old);two=copy.deepcopy(value);one.pop('ai_ip');two.pop('ai_ip')\n        if one!=two:raise ValueError('site\uc758 \uacbd\ub85c/\ub514\uc2a4\ud06c/\uc11c\ube44\uc2a4 \ubcc0\uacbd\uc740 \uc9c0\uc6d0 \ubc94\uc704 \ubc16\uc785\ub2c8\ub2e4.')\n    args,_=release.compose_args(value,profiles.RUNTIME/'releases'/a['release_id'])\n    rendered=json.loads(run(['docker',*args,'config','--format','json']))\n    check_drift(base,rendered)\n    source=a.get('offline_source')\n    if source:\n        from source_prepare import inspect_tree\n        if inspect_tree(Path(source['snapshot']))!=source['files']:raise ValueError('\uc2b9\uc778\ub41c \ucf54\ub4dc snapshot\uc774 \ubcc0\uacbd\ub410\uc2b5\ub2c8\ub2e4. ct apply \uacbd\ub85c\ub85c \ucc98\ub9ac\ud558\uc138\uc694.')\n    # Reads only; bound model/source files and volumes are not silently re-authorized.\n    compose_snapshot.verify_bindings(a.get('bound_files',{}))\n    return a,value,cp,base,live,rendered\n\n\ndef public_values(doc):\n    s=doc['services'];side=s['ai-control-sidecar'].get('environment',{})\n    gateway=s['ai-gateway'].get('environment',{})\n    redis=s['redis']['ports'][0]\n    gateway_port=s['ai-gateway']['ports'][0]\n    control=int(gateway.get('AI_CONTROL_HOST_PORT',gateway_port['published']))\n    if control!=int(gateway_port['published']) or control!=int(gateway_port['target']):\n        raise ValueError('Gateway env/published/target \ud3ec\ud2b8\uac00 \uc11c\ub85c \ub2e4\ub985\ub2c8\ub2e4. ct profile edit\uc5d0\uc11c \uc0ac\uc6a9\ud560 \ud3ec\ud2b8 \ud558\ub098\ub97c \uc9c0\uc815\ud558\uc138\uc694.')\n    from urllib.parse import urlsplit\n    base=side['AI_CONTROL_MANAGER_BASE_URL']\n    return dict(ai_ip=redis.get('host_ip'),manager=base,web_host=urlsplit(base).hostname,\n                event_url=side.get('AI_CONTROL_EVENT_ENDPOINT',base+'/api/v1/events'),\n                control_port=control,redis_port=int(redis['published']),\n                artifact_lan_cidr=gateway.get('AI_ARTIFACT_LAN_CIDR'),server_id=side.get('AI_CONTROL_SERVER_ID'))\n\n\ndef import_changes(p,source):\n    from ipaddress import IPv4Interface\n    a,site,cp,base,live,rendered=inspect()\n    if source not in ('compose','persistent','site'):raise ValueError('compose/persistent/site \uc911 \uc120\ud0dd\ud558\uc138\uc694.')\n    result=copy.deepcopy(p)\n    if source=='site':\n        prefix=IPv4Interface(result['network']['address']).network.prefixlen if result['network']['address'] else 24\n        result['network'].update(mode='static',address=site['ai_ip']+'/'+str(prefix))\n    else:\n        values=public_values(live if source=='compose' else rendered)\n        prefix=IPv4Interface(result['network']['address']).network.prefixlen if result['network']['address'] else 24\n        result['network'].update(mode='static',address=profiles.ipv4(values['ai_ip'])+'/'+str(prefix))\n        result.update(control_port=values['control_port'],redis_port=values['redis_port'],artifact_lan_cidr=values['artifact_lan_cidr'])\n        w=profiles.web(result)\n        w.update(manager=values['manager'],host=profiles.ipv4(values['web_host']),event_url=values['event_url'],server_id=values['server_id'])\n        if w['host'] not in result['redis_allow']:result['redis_allow'].append(w['host'])\n    return profiles.validate(result)\n\n\ndef prepare(p):\n    if server.portable():\n        import compose_backend\n        return compose_backend.prepare(p)\n    import network_prepare\n    a,site,cp,base,live,rendered=inspect()\n    pending=profiles.STATE/'pending.json'\n    if pending.exists():raise ValueError('\uc9c4\ud589 \uc911 \uc900\ube44\uac00 \uc788\uc2b5\ub2c8\ub2e4. ct config status/resume/cancel\uc744 \uc0ac\uc6a9\ud558\uc138\uc694.')\n    credentials=profiles.read_credentials(profiles.secret_path(p))\n    ca=profiles.ca_path(p)\n    connection.verify_ca(ca)\n    wanted=connection.addresses(field_network.ai_ip(p),profiles.web(p)['host'],profiles.web(p)['manager'],p['control_port'],p['redis_port'])\n    wanted.update(event_endpoint=profiles.web(p)['event_url'],artifact_lan_cidr=p['artifact_lan_cidr'],redis_allow=p['redis_allow'])\n    name='field-'+time.strftime('%Y%m%dT%H%M%S')+'-'+str(os.getpid())\n    directory=profiles.STATE/name\n    directory.mkdir(parents=True,mode=0o700)\n    plan=dict(desired=wanted,baseline=dict(compose=str(cp),compose_sha256=sha(cp),unit_sha256=sha(connection.UNIT)))\n    profiles.atomic(directory/'plan.json',plan)\n    # Credentials are explicit, profile-specific input; never fall back to a different Manager account.\n    stage=network_prepare.prepare(directory/'plan.json',ca,sha(ca),credentials=credentials,server_id=profiles.web(p)['server_id'],\n                                  connection_version=profiles.web(p).get('connection_version'))\n    record=profiles.read_json(stage/'record.json')\n    record.update(profile=p['id'],profile_stamp=profiles.stamp(p),profile_snapshot=p,redis_allow=p['redis_allow'],\n                  profile_credentials_sha256=sha(profiles.secret_path(p)),profile_ca_sha256=sha(ca))\n    profiles.atomic(stage/'record.json',record)\n    record['prepared_files']={name:sha(stage/name) for name in (\n        'compose.proposed.json','host-enrollment.json','bridge/login-body.json','bridge/password',\n        'bridge/manager-ca.crt','bridge/control-token','bridge/relay-token','tls/control-tls.key')}\n    profiles.atomic(profiles.STATE/'stages'/(stage.name+'.json'),record)\n    profiles.atomic(pending,{'stage':str(stage),'profile':p['id'],'phase':'PREPARED'})\n    print('\ud504\ub85c\ud544 \uc900\ube44 \uc644\ub8cc. \uc544\uc9c1 \ub85c\uadf8\uc778/Runtime \uc801\uc6a9\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc74c: ct config connect')\n    return stage\n\n\ndef pending_stage():\n    record=profiles.read_json(profiles.STATE/'pending.json')\n    stage=profiles.safe_path(record['stage'])\n    if stage.parent != server.path('data',profiles.RUNTIME.parent/'data')/'site' or not stage.name.startswith('field-'):\n        raise ValueError('\uc900\ube44 \uacbd\ub85c \uc624\ub958')\n    return stage\n\n\ndef verify_pending(stage):\n    r=profiles.read_json(profiles.STATE/'stages'/(stage.name+'.json'))\n    disk=profiles.read_json(stage/'record.json')\n    for key in ('profile','profile_stamp','desired','base_active_sha256','profile_snapshot'):\n        if disk.get(key)!=r.get(key):raise ValueError('\uc900\ube44\ubcf8\uc758 \uae30\uc900\uc774 \ubcc0\uacbd\ub410\uc2b5\ub2c8\ub2e4. \uc6d0\uaca9 \uc694\uccad\uc744 \ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.')\n    for name,expected in r['prepared_files'].items():\n        if sha(stage/name)!=expected:raise ValueError('\uc900\ube44 \uc774\ud6c4 \ud30c\uc77c \ubcc0\uacbd: '+name)\n    p=profiles.load(r['profile'])\n    if profiles.stamp(p)!=r['profile_stamp'] or sha(profiles.secret_path(p))!=r['profile_credentials_sha256'] or sha(profiles.ca_path(p))!=r['profile_ca_sha256']:\n        raise ValueError('\uc900\ube44 \ud6c4 \ud504\ub85c\ud544/\uacc4\uc815/CA\uac00 \ubcc0\uacbd\ub410\uc2b5\ub2c8\ub2e4. \ubbf8\ubc1c\uae09\uc774\uba74 cancel \ud6c4 \ub2e4\uc2dc prepare\ud558\uc138\uc694.')\n    return r,p\n\n\ndef stage_ready(stage):\n    import network_prepare\n    sys.path.insert(0,str(network_prepare.VENDOR))\n    from tools.secure_bridge_tls import verify\n    from control_sidecar.config import Config\n    record=profiles.read_json(stage/'record.json')\n    verify(stage/'tls/control-tls.key',stage/'tls/control-tls.crt',stage/'bridge/manager-ca.crt',record['desired']['ai_ip'])\n    Config.from_env(profiles.read_json(stage/'host-enrollment.json'))\n    if (stage/'tls/control-tls.crt').read_bytes()!=(stage/'bridge/control-tls.crt').read_bytes():\n        raise ValueError('Gateway/Sidecar \uc778\uc99d\uc11c\uac00 \ub2e4\ub985\ub2c8\ub2e4.')\n\n\ndef export_receipt(stage,phase):\n    record=profiles.read_json(profiles.STATE/'stages'/(stage.name+'.json'))\n    out=profiles.SETUP/'outbox'/('field-change-'+stage.name.removeprefix('field-'))\n    out.mkdir(mode=0o750,parents=True,exist_ok=True)\n    if os.geteuid()==0:\n        u=profiles.account();os.chown(out,u.pw_uid,u.pw_gid)\n    verification={'phase':phase,'runtime_started':False,'web_acceptance':'USER_CHECK_PENDING','profile':record['profile'],\n                  'credentials':'configured; values excluded','ca_file_sha256':record['profile_ca_sha256']}\n    summary=('# \ud604\uc7a5 \uc124\uc815 \ubcc0\uacbd\\n\\n- \ud658\uacbd: '+record['profile_snapshot']['label']+'\\n- \ub2e8\uacc4: '+phase+\n             '\\n- \uc2e4\uc81c Web \ud45c\uc2dc\u00b7\uce74\uba54\ub77c \uc218\uc6a9: \uc0ac\uc6a9\uc790 \ud655\uc778 \ub300\uae30\\n- \ubcf5\uad6c: ct config restore\\n- \uc6d0\uc7a5\u00b7\ubbf8\ub514\uc5b4\u00b7\ube44\ubc00\ubc88\ud638/token/private key\ub294 \ubc18\ucd9c\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n')\n    profiles.atomic(out/'SUMMARY.ko.md',summary.encode(),user=True)\n    profiles.atomic(out/'changes.redacted.json',{'before':record.get('public_before'), 'after':record['profile_snapshot'],'credential_values':'EXCLUDED'},user=True)\n    profiles.atomic(out/'verification.json',verification,user=True)\n    print('\ubc18\ucd9c \uc790\ub8cc:',out,' ZIP: ct config export')\n    return out\n\n\ndef replay_writes(journal):\n    # Root-owned journal is written only after scope validation. Each write is idempotent and CAS checked.\n    expected={str(profiles.RUNTIME/'state'/n):0o600 for n in ('site.json','active.json','inflight.json')}\n    expected[str(connection.UNIT)]=0o644\n    expected[str(profiles.RUNTIME.parent/'data/site/redis-access.sh')]=0o700\n    if len(journal['writes'])!=len(expected) or {x['path'] for x in journal['writes']}!=set(expected):\n        raise ValueError('\uc801\uc6a9 journal\uc758 \uacbd\ub85c \ubc94\uc704\uac00 \ub2e4\ub985\ub2c8\ub2e4.')\n    for item in journal['writes']:\n        if item['mode']!=expected[item['path']]:raise ValueError('\uc801\uc6a9 \uad8c\ud55c \ubc94\uc704 \uc624\ub958')\n        path=profiles.safe_path(item['path'])\n        before=base64.b64decode(item['before']) if item['before'] is not None else None\n        if (path.read_bytes() if path.exists() else None) not in (before,base64.b64decode(item['after'])):\n            raise ValueError('\uc801\uc6a9 \uc911 \ub2e4\ub978 \uc218\uc815 \ubc1c\uacac: '+str(path))\n    for item in journal['writes']:\n        path=profiles.safe_path(item['path'])\n        after=base64.b64decode(item['after']);before=base64.b64decode(item['before']) if item['before'] is not None else None\n        current=path.read_bytes() if path.exists() else None\n        if current==after:continue\n        if current!=before:raise ValueError('\uc801\uc6a9 \uc911 \ub2e4\ub978 \uc218\uc815 \ubc1c\uacac: '+str(path))\n        profiles.atomic(path,after,mode=item['mode'])\n\n\ndef prepare_transaction(tx, stage, active_transaction):\n    intent=profiles.STATE/'transaction-intents'/(stage.name+'.json')\n    expected={'stage':str(stage),'transaction':str(tx),'parent':active_transaction}\n    if tx.exists():\n        if not intent.exists() or profiles.read_json(intent)!=expected or tx.name==active_transaction:\n            raise ValueError('\uae30\uc874 transaction\uc740 \uc790\ub3d9 \uad50\uccb4\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.')\n        profiles.safe_path(tx)\n        saved=tx.with_name(tx.name+'-incomplete-'+str(time.time_ns()))\n        tx.rename(saved)\n    profiles.atomic(intent,expected)\n    tx.mkdir(mode=0o700)\n\n\ndef network_overlay(doc):\n    # Compose identifies ports by IP/target/published/protocol; a new IP otherwise appends.\n    # JSON flow values preserve types/escaping without adding a YAML dependency.\n    lines = []\n    for key, value in doc.items():\n        if key == 'name':\n            continue\n        if key != 'services':\n            lines.append(json.dumps(key) + ': ' + json.dumps(value))\n            continue\n        lines.append('services:')\n        for name, service in value.items():\n            lines.append('  ' + json.dumps(name) + ':')\n            for field, item in service.items():\n                tag = '!override ' if field == 'ports' and name in ('redis', 'ai-gateway') else ''\n                lines.append('    ' + json.dumps(field) + ': ' + tag + json.dumps(item))\n    return ('\\n'.join(lines) + '\\n').encode()\n\n\ndef apply_stage(stage):\n    if server.portable():\n        import compose_backend\n        return compose_backend.apply_stage(stage)\n    import network_apply\n    libraries()\n    from deployment_kit import site,release,compose_snapshot\n    from deployment_kit.core import lock\n    from apply_source import normalize\n    stage=profiles.safe_path(stage)\n    record,p=verify_pending(stage)\n    if shutil.disk_usage(profiles.RUNTIME).free < 15*1024**3:raise ValueError('root \uc5ec\uc72015GiB \ud544\uc694')\n    journal_path=profiles.STATE/'apply-pending.json'\n    state=profiles.RUNTIME/'state'\n    with lock(state):\n        if run(['docker','ps','--filter','label=com.docker.compose.project=contextra-icerti','--format','{{.ID}}']).strip():\n            raise ValueError('ct stop\uc73c\ub85c \uc815\uc0c1 \uc815\uc9c0\ud55c \ub4a4 \uc801\uc6a9\ud558\uc138\uc694.')\n        if not journal_path.exists():\n            a,current,cp,base,live,rendered=inspect()\n            if sha(state/'active.json')!=record['base_active_sha256']:raise ValueError('\uc900\ube44 \ud6c4 \ubc30\ud3ec\uac00 \ubc14\ub00c\uc5c8\uc2b5\ub2c8\ub2e4.')\n            if sha(profiles.RUNTIME.parent/'data/site/redis-access.sh')!=record['script_sha256']:\n                raise ValueError('\uc900\ube44 \ud6c4 Redis \uc811\uadfc \uc815\ucc45\uc774 \ubcc0\uacbd\ub410\uc2b5\ub2c8\ub2e4.')\n            stage_ready(stage)\n            from deployment_kit.site_guard import verify\n            verify(state/'site.json')\n            if record['desired']['ai_ip'] not in field_network.addresses(p['network']['interface']):raise ValueError('\ud504\ub85c\ud544 AI \uc8fc\uc18c\uac00 NIC\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4. ct net status/apply \ud655\uc778')\n            doc=profiles.read_json(stage/'compose.proposed.json',8*1024**2)\n            for service in ('analytics','ai-control-sidecar','ai-gateway','redis'):\n                doc['services'][service].setdefault('labels',{})[release.TRANSACTION_LABEL]=stage.name\n            new=copy.deepcopy(current);new['ai_ip']=record['desired']['ai_ip']\n            release.validate_rendered(doc,new,a['images'])\n            tx=state/'transactions'/stage.name\n            prepare_transaction(tx,stage,a['transaction'])\n            compose_snapshot.seal(doc,tx/'compose.json',current['project'])\n            overlay=stage/'network.compose.yaml'\n            profiles.atomic(overlay,network_overlay(doc))\n            old=a.get('offline_network',{}).get('overlay')\n            if old:\n                if new['compose_files'][-1]!=old:raise ValueError('\uc0c8 overlay\uac00 \ub4a4\uc5d0 \uc788\uc5b4 \uac80\ud1a0\uac00 \ud544\uc694\ud569\ub2c8\ub2e4.')\n                new['compose_files'][-1]=str(overlay)\n            else:new['compose_files'].append(str(overlay))\n            site.validate(new)\n            args,inputs=release.compose_args(new,profiles.RUNTIME/'releases'/a['release_id'])\n            if normalize(json.loads(run(['docker',*args,'config','--format','json'])))!=normalize(doc):raise ValueError('\ucd5c\uc885 Compose \uc7ac\ud574\uc11d \ubd88\uc77c\uce58')\n            unit=connection.UNIT.read_bytes();needle=('/transactions/'+a['transaction']+'/').encode()\n            if unit.count(needle)!=3:raise ValueError('\uc54c \uc218 \uc5c6\ub294 \uc11c\ube44\uc2a4 \uc2e4\ud589 \uacbd\ub85c')\n            next_unit=unit.replace(needle,('/transactions/'+stage.name+'/').encode())\n            firewall=network_apply.firewall_script(new['ai_ip'],p['redis_allow'],p['redis_port']).encode()\n            updated=copy.deepcopy(a);updated.update(transaction=stage.name,compose_sha256=sha(tx/'compose.json'),normalized_sha256=sha(tx/'compose.normalized.json'),\n                site_sha256=hashlib.sha256((json.dumps(new,ensure_ascii=False,indent=2)+'\\n').encode()).hexdigest(),config_inputs=inputs,bound_files=compose_snapshot.file_bindings(doc))\n            updated['offline_network']={'stage':str(stage),'overlay':str(overlay),'parent_transaction':a['transaction'],'desired':record['desired']}\n            updated.setdefault('acceptance',{})['OFFLINE_NETWORK']='PENDING_START_AND_WEB_CHECK'\n            records=[(profiles.RUNTIME.parent/'data/site/redis-access.sh',firewall,0o700),(state/'site.json',new,0o600),\n                     (connection.UNIT,next_unit,0o644),(state/'active.json',updated,0o600),\n                     (state/'inflight.json',{'status':'DONE','stage':'FIELD_APPLIED_STOPPED','transaction':stage.name},0o600)]\n            writes=[]\n            for path,data,mode in records:\n                before=path.read_bytes() if path.exists() else None\n                raw=data if isinstance(data,bytes) else (json.dumps(data,ensure_ascii=False,indent=2)+'\\n').encode()\n                writes.append({'path':str(path),'before':base64.b64encode(before).decode() if before is not None else None,'after':base64.b64encode(raw).decode(),'mode':mode})\n            journal={'stage':str(stage),'transaction':stage.name,'phase':'APPLYING','writes':writes}\n            profiles.atomic(journal_path,journal)\n            record['public_before']=public_values(base)\n            profiles.atomic(profiles.STATE/'stages'/(stage.name+'.json'),record)\n            profiles.atomic(tx/'accepted.json',updated)\n        else:\n            journal=profiles.read_json(journal_path,8*1024**2)\n            if journal['stage']!=str(stage):raise ValueError('\ub2e4\ub978 \uc801\uc6a9 \uc791\uc5c5\uc774 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.')\n            stage_ready(stage)\n        replay_writes(journal)\n        run([sys.executable,'-c',network_apply.firewall_script(record['desired']['ai_ip'],p['redis_allow'],p['redis_port'])])\n        run(['systemctl','daemon-reload'])\n        remember_baseline()\n        profiles.atomic(profiles.STATE/'last-applied.json',{'profile':p['id'],'stamp':profiles.stamp(p),'stage':str(stage),'verification':'USER_CHECK_PENDING'})\n        profiles.atomic(profiles.STATE/'selected.json',{'profile':p['id']})\n        journal['phase']='APPLIED';profiles.atomic(profiles.STATE/'history'/(stage.name+'.json'),journal)\n        journal_path.unlink()\n        (profiles.STATE/'pending.json').unlink(missing_ok=True)\n    export_receipt(stage,'APPLIED_SERVICES_STOPPED')\n    print('\uc124\uc815 \ubc18\uc601 \uc644\ub8cc\u00b7\uc7ac\ubd80\ud305 \uc720\uc9c0. \uc11c\ube44\uc2a4/\uce74\uba54\ub77c \uc790\ub3d9 Start \uc5c6\uc74c. \ub2e4\uc74c: ct start')\n\n\ndef restore_config():\n    if server.portable():\n        import compose_backend\n        return compose_backend.restore_config()\n    libraries()\n    from deployment_kit.core import lock\n    with lock(profiles.RUNTIME/'state'):\n        _restore_config()\n\n\ndef _restore_config():\n    pending=profiles.STATE/'apply-pending.json'\n    if pending.exists():journal=profiles.read_json(pending,8*1024**2)\n    else:\n        last=profiles.read_json(profiles.STATE/'last-applied.json')\n        journal=profiles.read_json(profiles.STATE/'history'/(Path(last['stage']).name+'.json'),8*1024**2)\n    libraries()\n    if run(['docker','ps','--filter','label=com.docker.compose.project=contextra-icerti','--format','{{.ID}}']).strip():raise ValueError('ct stop \ud6c4 \ubcf5\uad6c\ud558\uc138\uc694.')\n    reversed_journal=copy.deepcopy(journal)\n    # Credentials/session/media are never part of these writes.\n    for row in reversed_journal['writes']:\n        if row['before'] is None:raise ValueError('\uc774\uc804 \ud30c\uc77c \uc5c6\ub294 \ubcf5\uad6c\ub294 \uc218\ub3d9 \ud655\uc778\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.')\n        row['before'],row['after']=row['after'],row['before']\n    for row in reversed_journal['writes']:\n        path=profiles.safe_path(row['path']);raw=path.read_bytes()\n        if raw not in (base64.b64decode(row['before']),base64.b64decode(row['after'])):raise ValueError('\uc774\ud6c4 \ubcc0\uacbd \ubc1c\uacac, \ubcf5\uad6c \uc911\ub2e8: '+str(path))\n    replay_writes(reversed_journal)\n    previous_script=next(base64.b64decode(x['after']).decode() for x in reversed_journal['writes'] if x['path'].endswith('/redis-access.sh'))\n    run([sys.executable,'-c',previous_script]);run(['systemctl','daemon-reload'])\n    pending.unlink(missing_ok=True)\n    (profiles.STATE/'pending.json').unlink(missing_ok=True)\n    profiles.atomic(profiles.STATE/'last-restored.json',{'stage':journal['stage'],'verification':'USER_CHECK_PENDING'})\n    (profiles.STATE/'last-applied.json').unlink(missing_ok=True)\n    remember_baseline()\n    print('\uc774\uc804 \ub85c\uceec \uc124\uc815 \ubcf5\uad6c. \uc774\uc804 \uc138\uc158\uc758 \uc6d0\uaca9 \uc720\ud6a8\uc131\uc740 \ubcf5\uad6c\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc790\ub3d9 Start \uc5c6\uc74c.')\n", "apply_source.py": "\"\"\"Build an offline APP image and descendant transaction; never starts services.\"\"\"\nimport copy\nimport json\nimport os\nfrom pathlib import Path\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nimport connection\nimport server_context as server\nfrom source_prepare import inspect_tree\n\nRUNTIME = server.path('runtime', '/workspace/contextra/runtime')\nDATA = server.path('data', '/workspace/contextra/data')\nTOOLS = server.path('tools',RUNTIME / 'deploy-tools/provider-20260927-r01')\nsys.path.insert(0, str(TOOLS))\n\n\ndef normalize(value):\n    value = copy.deepcopy(value)\n    for service in value[\"services\"].values():\n        service.pop(\"build\", None)\n        service[\"volumes\"] = sorted(service.get(\"volumes\", []), key=lambda x: x[\"target\"])\n    return value\n\n\ndef check_app_compatibility(state,active):\n    from field_profiles import read_json,safe_path\n    manifest=safe_path(state/'app-python-compatibility.json')\n    if not manifest.exists():raise ValueError('Reviewed app-python-compatibility.json required; no deployment SHA bypass')\n    if manifest.stat().st_uid!=0 or manifest.stat().st_mode & 0o022:raise ValueError('Compatibility manifest must be root-owned')\n    contract=read_json(manifest)\n    if (set(contract)!={'version','kind','base_image','app_path','runtime_uid','native_models_unchanged'}\n            or contract['version']!=1 or contract['kind']!='PYTHON_APP_ONLY'\n            or contract['base_image']!=active.get('images',{}).get('analytics')\n            or contract['app_path']!='/opt/contextra/app' or contract['runtime_uid']!=0\n            or contract['native_models_unchanged'] is not True):\n        raise ValueError('Image/native compatibility contract mismatch')\n    return contract\n\n\ndef apply(source, expected_active, confirmed=False):\n    from deployment_kit import site, release, compose_snapshot\n    from deployment_kit.core import sha256, write_json, atomic_write, lock\n    if os.geteuid() != 0 or not confirmed:\n        raise ValueError(\"sudo, --yes and expected active SHA256 required\")\n    if shutil.disk_usage(RUNTIME).free < 17*1024**3:\n        raise ValueError(\"Keep 15GiB root reserve plus bounded APP build space\")\n    source = Path(source)\n    if (not source.is_absolute() or not source.is_relative_to(server.path('source','/workspace/contextra/source'))\n            or any(p.is_symlink() for p in (source,*source.parents))):\n        raise ValueError(\"Approved source path required\")\n    files = inspect_tree(source / \"app\")\n    state = RUNTIME / \"state\"\n    def command(args, timeout=45):\n        p = subprocess.run(args, check=True, capture_output=True, text=True, timeout=timeout,\n            env=compose_snapshot.command_environment())\n        return p.stdout\n    with lock(state):\n        active_path = state / \"active.json\"\n        if sha256(active_path) != expected_active:\n            raise ValueError(\"Active deployment changed; inspect again\")\n        if command([\"docker\",\"ps\",\"--filter\",\"label=com.docker.compose.project=\"+server.project(),\"--format\",\"{{.ID}}\"] ).strip():\n            raise ValueError(\"Run ct stop before source apply\")\n        active = json.loads(active_path.read_text())\n        check_app_compatibility(state,active)\n        if json.loads((state / \"inflight.json\").read_text()).get(\"status\") != \"DONE\":\n            raise ValueError(\"Another transaction is in progress\")\n        old_site_hash = sha256(state / \"site.json\")\n        current = site.load(state / \"site.json\")\n        if sha256(state / \"site.json\") != old_site_hash:raise ValueError(\"Site changed while reading\")\n        old_compose = connection.active_compose()\n        if old_compose.parent.name != active[\"transaction\"]:\n            raise ValueError(\"Active/unit transaction mismatch\")\n        old = json.loads(old_compose.read_text())\n        args,_ = release.compose_args(current, RUNTIME / \"releases\" / active[\"release_id\"])\n        persistent = json.loads(command([\"docker\",*args,\"config\",\"--format\",\"json\"]))\n        if normalize(old) != normalize(persistent):\n            raise ValueError(\"Persistent Compose drift\")\n        name = \"offline-source-\" + time.strftime(\"%Y%m%dT%H%M%S\")\n        tx = state / \"transactions\" / name\n        tx.mkdir(mode=0o700)\n        snapshot = RUNTIME / \"patches\" / name / \"app\"\n        snapshot.parent.mkdir(parents=True, mode=0o700)\n        shutil.copytree(source / \"app\", snapshot, ignore=shutil.ignore_patterns(\"__pycache__\"))\n        if inspect_tree(snapshot) != files or inspect_tree(source / \"app\") != files:\n            raise ValueError(\"Source changed while freezing; no active files changed\")\n        for path in [snapshot,*snapshot.rglob(\"*\")]:\n            path.chmod(0o755 if path.is_dir() else 0o644)\n        base = old[\"services\"][\"analytics\"][\"image\"]\n        base_info = json.loads(command([\"docker\",\"image\",\"inspect\",base]))[0]\n        if base_info[\"Id\"] != active[\"images\"][\"analytics\"]:\n            raise ValueError(\"Current image identity changed\")\n        if base_info[\"Config\"].get(\"User\",\"\") not in (\"\",\"root\",\"0\",\"0:0\"):\n            raise ValueError(\"Non-root APP baseline requires separate review\")\n        base_tag = \"contextra-offline-base:\" + base_info[\"Id\"].split(\":\")[1][:16]\n        command([\"docker\",\"tag\",base_info[\"Id\"],base_tag])\n        dockerfile = snapshot.parent / \"Dockerfile\"\n        dockerfile.write_text(\"FROM \" + base_tag + \"\\nRUN rm -rf /opt/contextra/app\\nCOPY app /opt/contextra/app\\n\")\n        new_tag = \"contextra-analytics:\" + name\n        env = dict(compose_snapshot.command_environment(),DOCKER_BUILDKIT=\"0\")\n        built = subprocess.run([\"docker\",\"build\",\"--pull=false\",\"--network=none\",\"--memory=3g\",\n            \"--cpu-period=100000\",\"--cpu-quota=200000\",\"-t\",new_tag,str(snapshot.parent)],\n            check=True,capture_output=True,text=True,timeout=240,env=env)\n        atomic_write(tx/\"build.log\",built.stdout + built.stderr,new=True)\n        new_id = json.loads(command([\"docker\",\"image\",\"inspect\",new_tag]))[0][\"Id\"]\n        doc = copy.deepcopy(old)\n        doc[\"services\"][\"analytics\"][\"image\"] = new_id\n        doc[\"services\"][\"analytics\"].setdefault(\"labels\",{})[release.TRANSACTION_LABEL] = name\n        ids = {k:v[\"image\"] for k,v in doc[\"services\"].items()}\n        release.validate_rendered(doc,current,ids)\n        compose_snapshot.seal(doc,tx / \"compose.json\",current[\"project\"])\n        overlay = DATA / \"site\" / (name+\".compose.json\")\n        overlay_doc = copy.deepcopy(doc)\n        overlay_doc.pop(\"name\",None)\n        write_json(overlay,overlay_doc,new=True)\n        newsite = copy.deepcopy(current)\n        previous_overlay = active.get(\"offline_source\",{}).get(\"overlay\")\n        if previous_overlay:\n            if newsite[\"compose_files\"][-1] != previous_overlay:\n                raise ValueError(\"Another overlay follows the source overlay\")\n            newsite[\"compose_files\"][-1] = str(overlay)\n        else:\n            newsite[\"compose_files\"].append(str(overlay))\n        args,inputs = release.compose_args(newsite,RUNTIME/\"releases\"/active[\"release_id\"])\n        if normalize(json.loads(command([\"docker\",*args,\"config\",\"--format\",\"json\"]))) != normalize(doc):\n            raise ValueError(\"New persistent Compose differs\")\n        unit_before = connection.UNIT.read_bytes()\n        oldpart = \"/transactions/\"+active[\"transaction\"]+\"/\"\n        if unit_before.decode().count(oldpart) != 3:\n            raise ValueError(\"Unexpected service layout\")\n        write_json(tx/\"previous-active.json\",active,new=True)\n        write_json(tx/\"previous-site.json\",current,new=True)\n        atomic_write(tx/\"previous-unit\",unit_before,new=True)\n        write_json(tx/\"source-manifest.json\",dict(files=files,worktree=str(source)),new=True)\n        unit_hash = __import__(\"hashlib\").sha256(unit_before).hexdigest()\n        write_json(state/\"inflight.json\",dict(status=\"APPLYING\",stage=\"SOURCE_SETTINGS\",transaction=name))\n        write_json(state/\"site.json\",newsite,expected=old_site_hash)\n        atomic_write(connection.UNIT,unit_before.decode().replace(oldpart,\"/transactions/\"+name+\"/\"),expected=unit_hash,mode=0o644)\n        updated = copy.deepcopy(active)\n        updated.update(transaction=name,images=ids,compose_sha256=sha256(tx/\"compose.json\"),normalized_sha256=sha256(tx/\"compose.normalized.json\"),\n            site_sha256=sha256(state/\"site.json\"),config_inputs=inputs,bound_files=compose_snapshot.file_bindings(doc))\n        updated[\"offline_source\"] = dict(worktree=str(source),snapshot=str(snapshot),files=files,overlay=str(overlay),\n            parent_transaction=active[\"transaction\"],base_image=base_info[\"Id\"],app_image_tag=new_tag,native_unchanged=True)\n        updated.setdefault(\"acceptance\",{})[\"SOURCE_APP_IMAGE\"]=\"PENDING_RUNTIME_CHECK\"\n        write_json(active_path,updated,expected=expected_active)\n        write_json(tx/\"accepted.json\",updated,new=True)\n        write_json(state/\"inflight.json\",dict(status=\"DONE\",stage=\"SOURCE_PREPARED_RUNTIME_STOPPED\",transaction=name))\n        import field_runtime\n        field_runtime.remember_baseline()\n        command([\"systemctl\",\"daemon-reload\"])\n        print(json.dumps(dict(status=\"APPLIED_SERVICES_STOPPED\",transaction=name,files=len(files),image=ids[\"analytics\"],source=str(source))))\n", "server_context.py": "\"\"\"Root-owned local installation identity, separate from editable environment profiles.\"\"\"\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\nimport pwd\nimport re\n\nCONFIG = Path('/etc/contextra-maintenance/server.json')\nROLES = ('analytics', 'ai-control-sidecar', 'ai-gateway', 'redis', 'postgresql')\n\n\ndef safe(path):\n    p = Path(path)\n    if not p.is_absolute() or '..' in p.parts or any(x.is_symlink() for x in (p, *p.parents)):\n        raise ValueError('Absolute non-symlink installation path required')\n    return p\n\n\ndef machine():\n    return hashlib.sha256(Path('/etc/machine-id').read_bytes().strip()).hexdigest()\n\n\ndef validate(d):\n    required = {'version','server','machine','operator','backend','project','services','paths',\n                'compose_files','env_files','working_dir','disks','capabilities','ssh_port','firewall'}\n    if not isinstance(d, dict) or set(d) != required or d['version'] != 1:\n        raise ValueError('Unsupported server definition')\n    for key in ('server','project','operator'):\n        if not isinstance(d[key], str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}', d[key]):\n            raise ValueError('Invalid server identity: '+key)\n    pwd.getpwnam(d['operator'])\n    if d['backend'] not in ('compose','transaction') or set(d['services']) != set(ROLES):\n        raise ValueError('Unsupported backend/service contract')\n    if len(set(d['services'].values())) != len(ROLES):\n        raise ValueError('Duplicate service roles')\n    for value in d['services'].values():\n        if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}', value):\n            raise ValueError('Invalid Compose service')\n    if set(d['paths']) != {'setup','state','runtime','data','source','tools'}:\n        raise ValueError('Installation paths incomplete')\n    for p in (*d['paths'].values(), d['working_dir'], *d['compose_files'], *d['env_files']):\n        safe(p)\n    if type(d['ssh_port']) is not int or not 1 <= d['ssh_port'] <= 65535:\n        raise ValueError('Invalid SSH port')\n    if not isinstance(d['capabilities'], list) or set(d['capabilities']) - {'usb','resources','vscode','network','patch','llm','gui','access','observe'}:\n        raise ValueError('Unknown capability')\n    for disk in d['disks']:\n        if set(disk) != {'path','device','mountpoint'}:\n            raise ValueError('Invalid disk contract')\n        safe(disk['path']); safe(disk['mountpoint'])\n    if d['firewall'] is not None:\n        if set(d['firewall']) != {'script','chain','unit'}:\n            raise ValueError('Invalid firewall contract')\n        safe(d['firewall']['script'])\n        if not re.fullmatch(r'[A-Z0-9_-]{1,28}', d['firewall']['chain']):\n            raise ValueError('Invalid owned firewall chain')\n    return d\n\n\ndef load():\n    if not CONFIG.exists():\n        return None  # Existing field installations keep their original backend until explicitly migrated.\n    safe(CONFIG)\n    for p in (CONFIG, *CONFIG.parents):\n        if p.stat().st_uid != 0 or p.stat().st_mode & 0o022:\n            raise ValueError('Server definition and parents must be root-owned, not writable by others')\n    d = validate(json.loads(CONFIG.read_text()))\n    if d['machine'] != machine():\n        raise ValueError('This server definition belongs to another machine; no action taken')\n    return d\n\n\n# Repository imports/tests never inherit a host installation. Installed launchers have an inventory.\nCURRENT = load() if (Path(__file__).parent/'installed-files.json').is_file() else None\n\n\ndef path(name, legacy):\n    return Path(CURRENT['paths'][name]) if CURRENT else Path(legacy)\n\n\ndef operator():\n    return CURRENT['operator'] if CURRENT else 'si'\n\n\ndef project():\n    return CURRENT['project'] if CURRENT else 'contextra-icerti'\n\n\ndef portable():\n    return bool(CURRENT and CURRENT['backend'] == 'compose')\n\n\ndef service(role):\n    return CURRENT['services'][role] if CURRENT else role\n\n\ndef require(capability):\n    if CURRENT and capability not in CURRENT['capabilities']:\n        raise ValueError('Capability not enabled for this server: '+capability)\n\n\ndef banner():\n    if CURRENT:\n        print('Server:', CURRENT['server'], 'backend:', CURRENT['backend'],\n              'project:', project(), 'operator:', operator(), 'LOCAL ONLY')\n        print('Protected installation definition:', CONFIG)\n\n\ndef guard_disks():\n    for disk in (CURRENT or {}).get('disks', []):\n        p = safe(disk['path']); mount = safe(disk['mountpoint'])\n        if not p.exists() or p.stat().st_dev != disk['device'] or not os.path.ismount(mount):\n            raise ValueError('Required data filesystem missing/changed: '+str(p))\n\n\ndef verify_tools():\n    if not CURRENT:return\n    root=path('tools','');inventory=root/'installed-files.json'\n    spec=json.loads(safe(inventory).read_text())\n    for name,expected in spec.items():\n        relative=Path(name)\n        if relative.is_absolute() or '..' in relative.parts:raise ValueError('Invalid installed tool inventory')\n        target=safe(root/name)\n        if target.stat().st_uid!=0 or target.stat().st_mode & 0o022 or hashlib.sha256(target.read_bytes()).hexdigest()!=expected:\n            raise ValueError('Tool code changed: '+name+'; apply a reviewed ct patch, not a manual SHA edit')\n\n\ndef logical(doc):\n    \"\"\"Translate physical Compose service names only at the backend boundary.\"\"\"\n    import copy\n    result = copy.deepcopy(doc)\n    mapping = {service(r): r for r in ROLES}\n    if set(result['services']) != set(mapping):\n        raise ValueError('Compose project contains unexpected services')\n    result['services'] = {mapping[k]: v for k, v in result['services'].items()}\n    for cfg in result['services'].values():\n        if 'depends_on' in cfg:\n            cfg['depends_on'] = {mapping[k]: v for k, v in cfg['depends_on'].items()}\n    return result\n\n\ndef physical(doc):\n    import copy\n    result = copy.deepcopy(doc)\n    result['services'] = {service(k): v for k, v in result['services'].items()}\n    for cfg in result['services'].values():\n        if 'depends_on' in cfg:\n            cfg['depends_on'] = {service(k): v for k, v in cfg['depends_on'].items()}\n    return result\n", "maintenance_guard.py": "\"\"\"Fail closed at boot/manual Start after an interrupted maintenance transaction.\"\"\"\nimport hashlib\nimport json\nfrom pathlib import Path\nimport subprocess\nimport connection\n\n\ndef check():\n    field=Path('/var/lib/contextra-maintenance/field')\n    if (field/'apply-pending.json').exists():\n        raise RuntimeError('\uc124\uc815 \uc801\uc6a9 \ubbf8\uc644\ub8cc: ct config resume \ub610\ub294 ct config restore. ct \uba54\ub274/\ud3b8\uc9d1\uc740 \uc0ac\uc6a9 \uac00\ub2a5\ud569\ub2c8\ub2e4.')\n    if (field/'network-pending.json').exists():\n        raise RuntimeError('NIC \uc801\uc6a9 \ubbf8\uc644\ub8cc: ct net status/restore. \ucf58\uc194\uc5d0\uc11c \ud655\uc778\ud558\uc138\uc694.')\n    if (field/'pending.json').exists():\n        pending=json.loads((field/'pending.json').read_text())\n        if (Path(pending['stage'])/'enroll-intent.json').exists():\n            raise RuntimeError('\uc0c8 Web \ubc1c\uae09/\uc801\uc6a9 \ud655\uc778 \ud544\uc694: ct config status/resume. \uc774\uc804 \uc138\uc158\uc73c\ub85c \uc790\ub3d9 \ub85c\uadf8\uc778\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.')\n    if Path('/var/lib/contextra-maintenance/auth/pending.json').exists():\n        raise RuntimeError('Authentication transaction pending. Inspect ct auth status/resume before Start')\n    state=Path(\"/workspace/contextra/runtime/state\")\n    active=json.loads((state/\"active.json\").read_text())\n    inflight=json.loads((state/\"inflight.json\").read_text())\n    if inflight.get(\"status\")!=\"DONE\" or inflight.get(\"transaction\")!=active[\"transaction\"]:\n        raise RuntimeError(\"Maintenance transaction incomplete; do not auto Start\")\n    compose=connection.active_compose()\n    if compose.parent.name!=active[\"transaction\"]:\n        raise RuntimeError(\"Service/active transaction mismatch\")\n    for path,expected in ((compose,active[\"compose_sha256\"]),(state/\"site.json\",active[\"site_sha256\"])):\n        if hashlib.sha256(path.read_bytes()).hexdigest()!=expected:\n            raise RuntimeError(\"\uc77c\ubc18 \uc124\uc815 \uc218\ub3d9 \ubcc0\uacbd \ubc1c\uacac: ct emergency\ub85c \ubcc0\uacbd\uc744 \uc218\uc6a9\ud558\uc138\uc694. \ucf54\ub4dc/image/\ub514\uc2a4\ud06c \uac80\uc99d\uc740 \uc720\uc9c0\ud569\ub2c8\ub2e4.\")\n    site=json.loads((state/\"site.json\").read_text())\n    interfaces=json.loads(subprocess.check_output([\"ip\",\"-j\",\"-4\",\"address\",\"show\"],text=True,timeout=10))\n    if site[\"ai_ip\"] not in {a[\"local\"] for d in interfaces for a in d.get(\"addr_info\",[])}:\n        raise RuntimeError(\"\uc571\uc758 AI \uc8fc\uc18c\uac00 \uc2e4\uc81c NIC\uc640 \ub2e4\ub985\ub2c8\ub2e4. ct net status\uc640 ct config prepare/connect/apply\ub97c \ud655\uc778\ud558\uc138\uc694.\")\n    source=active.get(\"offline_source\")\n    if source:\n        from source_prepare import inspect_tree\n        if inspect_tree(Path(source[\"snapshot\"]))!=source[\"files\"]:\n            raise RuntimeError(\"Accepted source snapshot changed\")\n    print(\"MAINTENANCE_TRANSACTION_VERIFIED\")\n\n\nif __name__==\"__main__\":\n    check()\n"}
