import json,os,pathlib,secrets,shutil,subprocess,urllib.request
from build import g,R,HEAD,EDITED
os.umask(0o077)
C=g.ENV.parent/'metadata-lan'
def main():
 assert not (R/'plan.json').exists() and not C.exists()
 source=json.loads((R/'source.json').read_text());build=json.loads((R/'build.json').read_text())
 assert build['returncode']==0
 assert g.sh(['git','-C',str(g.REPO),'rev-parse','HEAD']).strip()==HEAD
 assert all(g.sha(g.REPO/p)==h for p,h in source['hashes'].items())
 c=g.inspect();env=g.load_env_file(str(g.ENV))
 assert c['State']['Status']=='running' and c['State']['Health']['Status']=='healthy'
 assert not c['RestartCount'] and not c['State']['OOMKilled']
 overlay=pathlib.Path(env['CONTEXTRA_RUNTIME_STATE_ROOT'])/'streams_runtime.yaml'
 rows=g.yaml.safe_load(overlay.read_text())['added'];assert len(rows)==1;s=rows[0]
 assert all(s[k] is False for k in ['store','active_learning','autostart']) and s['scenarios']=='off'
 assert s['inference_profile']['roles']==['det'] and s['inference_profile']['det_model']=='RFDETRMedium'
 opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
 with opener.open('http://127.0.0.1:'+env['FASTAPI_HOST_PORT']+'/api/v1/streams',timeout=3) as f:states=json.load(f)['streams']
 assert len(states)==1 and states[0]['state']=='stopped'
 assert g.sha(g.CFG)=='5e5a4d22786f8b5218ac821379fbec091d9e3006f62c880ee3e3da5ced2f0e0b'
 assert g.sha(g.CFG.parent/'2/model.plan')=='5fdf1e9c55ab947a3b7d5f7f6bdceaf1e36a9e52efdf86f5a15d3b99b8631d1e'
 ids=g.sh(['docker','ps','-aq']).split();containers=json.loads(g.sh(['docker','inspect',*ids]))
 redis=next(x for x in containers if x['Name']=='/contextra-redis-1')
 def query(*cmd):return g.sh(['docker','exec',redis['Id'],'redis-cli','--raw',*cmd]).strip()
 assert query('DBSIZE')=='0' and not any(x.startswith('db') for x in query('INFO','keyspace').splitlines())
 assert 'nopass' in query('ACL','LIST') and len(query('ACL','LIST').splitlines())==1
 assert all(next((x[4:] for x in row.split() if x.startswith('cmd=')),None) in ('ping','client|list') for row in query('CLIENT','LIST').splitlines())
 assert not redis['HostConfig']['PortBindings']
 previous=json.loads(pathlib.Path('/tmp/codex/ltb1021/2026-09-11_contextra-metadata-lan_r01/preflight.json').read_text())
 readers=previous['test_readers'];assert len(readers)==2 and previous['site_reference_only'] not in readers
 ips=json.loads(g.sh(['ip','-j','-4','address','show']))
 addresses=[a['local'] for i in ips for a in i.get('addr_info',[]) if a['local'] in previous['ai_lan_candidates']]
 assert len(addresses)==1
 host=addresses[0];port=16379
 assert not any(row.split()[3].endswith(':'+str(port)) for row in g.sh(['ss','-lntH']).splitlines())
 assert subprocess.run(['iptables','-S','CTX_METADATA_LAN'],capture_output=True).returncode!=0
 unit=pathlib.Path('/etc/systemd/system/contextra-metadata-firewall.service');assert not unit.exists()
 docker_root=g.sh(['docker','info','--format','{{.DockerRootDir}}']).strip()
 assert shutil.disk_usage(docker_root).free>=15*1024**3 and g.gpu()<16384
 memory={l.split(':')[0]:int(l.split()[1]) for l in pathlib.Path('/proc/meminfo').read_text().splitlines()}
 assert memory['MemAvailable']>6*1024**2
 C.mkdir(mode=0o700);visual=R/'visual';visual.mkdir(mode=0o700)
 (R/'env.before').write_bytes(g.ENV.read_bytes());(R/'overlay.before').write_bytes(overlay.read_bytes())
 passwords={name:secrets.token_hex(24) for name in ('admin','internal','publisher','reader','health')}
 (C/'secrets.json').write_text(json.dumps(passwords))
 acl=['user default off resetpass resetkeys resetchannels -@all']
 permissions={'admin':'~* &* +@all','internal':'~contextra:stream:* +ping +xadd +client|setinfo',
 'publisher':'~contextra:metadata:v1:* +ping +multi +exec +discard +xadd +expire +hset +set +del +client|setinfo',
 'reader':'~contextra:metadata:v1:* +ping +get +hgetall +hget +xread +xrange +xrevrange +xlen +exists +ttl +type +client|setinfo',
 'health':'+ping'}
 for name,password in passwords.items():acl.append('user contextra_'+name+' on #'+g.hashlib.sha256(password.encode()).hexdigest()+' -@all '+permissions[name])
 aclpath=C/'users.acl';aclpath.write_text('\n'.join(acl)+'\n')
 uid=int(g.sh(['docker','exec',redis['Id'],'id','-u','redis']).strip());os.chown(aclpath,uid,-1)
 original=next(m for m in redis['Mounts'] if m['Destination']=='/usr/local/etc/redis/redis.conf')
 config=pathlib.Path(original['Source']).read_text().replace('protected-mode no','protected-mode yes')
 (C/'redis.conf').write_text(config+'\naclfile /usr/local/etc/redis/users.acl\n')
 os.chown(C/'redis.conf',uid,-1)
 (C/'access.json').write_text(json.dumps(dict(host=host,port=port,readers=readers)))
 shutil.copyfile(R/'firewall.py',C/'firewall.py');os.chmod(C/'firewall.py',0o600)
 unit_text='[Unit]\nDescription=Contextra Metadata LAN access guard\nBefore=docker.service\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/bin/python3 '+str(C/'firewall.py')+' apply\n\n[Install]\nWantedBy=multi-user.target\n'
 (R/'contextra-metadata-firewall.service').write_text(unit_text)
 override={'services':{'redis':{'image':redis['Image'],'environment':{'REDIS_HEALTH_PASSWORD':passwords['health']},
  'ports':[{'target':6379,'published':str(port),'host_ip':host,'protocol':'tcp'}],
  'volumes':[{'type':'bind','source':str(C/'redis.conf'),'target':'/usr/local/etc/redis/redis.conf','read_only':True},
             {'type':'bind','source':str(aclpath),'target':'/usr/local/etc/redis/users.acl','read_only':True}],
  'healthcheck':{'test':['CMD-SHELL','REDISCLI_AUTH="$$REDIS_HEALTH_PASSWORD" redis-cli --user contextra_health ping']}},
 'analytics':{'restart':'no','environment':{
  'REDIS_URL':'redis://contextra_internal:'+passwords['internal']+'@redis:6379/0',
  'CONTEXTRA_METADATA_ENABLED':'true',
  'CONTEXTRA_METADATA_REDIS_URL':'redis://contextra_publisher:'+passwords['publisher']+'@redis:6379/0',
  'CONTEXTRA_METADATA_VISUAL_DIR':'/run/contextra-visual',
  'CONTEXTRA_METADATA_SAMPLES_DIR':'','CONTEXTRA_START_DIAGNOSTICS_DIR':''},
  'volumes':[{'type':'bind','source':str(visual),'target':'/run/contextra-visual','bind':{'create_host_path':False}}]}}}
 (C/'compose.yaml').write_text(g.yaml.safe_dump(override))
 compose=['docker','compose','--env-file',str(g.ENV),'-f',str(g.REPO/'docker-compose.yml'),'-f',str(C/'compose.yaml')]
 subprocess.run(compose+['config','--quiet'],capture_output=True,check=True,timeout=20)
 backups={}
 for target in (c,redis):
  files=target['Config']['Labels']['com.docker.compose.project.config_files'].split(',')
  backups[target['Id']]={p:g.sha(p) for p in files}
 plan=dict(analytics=c,redis=redis,env_hash=g.sha(g.ENV),overlay_hash=g.sha(overlay),
           config_hash=g.sha(g.CFG),plan_hash=g.sha(g.CFG.parent/'2/model.plan'),
           controls=str(C),compose=compose,previous_compose_files=backups,
           other_containers={k:v for k,v in g.others().items() if k!=redis['Id']},gpu_mib=g.gpu(),
           unit=str(unit),control_hashes={str(p):g.sha(p) for p in C.iterdir() if p.is_file()},
           rollback='Stop Stream; restore env/overlay CAS and previous compose per service; remove own firewall only after port is absent')
 (R/'plan.json').write_text(json.dumps(plan,indent=2));print(json.dumps(dict(stage='prepared',readers=2,redis_keys=0,port=port,accounts=5)))
if __name__=='__main__':main()
