conductor/scripts/test-setup-conductor.py

128 lines
8.5 KiB
Python

"""Fresh-install acceptance in a disposable Ubuntu host with a separate Docker daemon.
Requires the local Docker engine. The privileged outer container is only for nested
Docker; no host socket, host directory, customer VM or production data is mounted.
"""
import pathlib,subprocess,secrets,json,time,os,hashlib,sys
ROOT=pathlib.Path(__file__).resolve().parent.parent
SCRIPTS=ROOT.parent/'skeletonworks-scripts'
ARTIFACTS=ROOT/'test-results/slice12';ARTIFACTS.mkdir(parents=True,exist_ok=True)
name='conductor-slice12-'+secrets.token_hex(5)
fqdn='conductor.customer.test'
stack='/opt/skeletonworks/conductor/'+fqdn
setup='/opt/skeletonworks/scripts/setup-conductor.sh'
def command(args,input=None,check=True):
r=subprocess.run(args,input=input,text=True,capture_output=True)
if check and r.returncode:raise RuntimeError('Command failed: '+str(args[:5])+'\n'+r.stderr[-9000:])
return r
def host(args,input=None,check=True):return command(['docker','exec','-i',name]+args,input,check)
def write_files(files):
host(['python3','-c','import sys,json,pathlib\nfor name,content in json.load(sys.stdin).items():\n p=pathlib.Path(name);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(content)\n'],json.dumps(files))
def snapshot():
return json.loads(host(['python3','-c','import pathlib,hashlib,json\np=pathlib.Path("/opt/skeletonworks")\nprint(json.dumps({str(f):hashlib.sha256(f.read_bytes()).hexdigest() for f in p.rglob("*") if f.is_file()}))']).stdout)
def invoke(args,check=True):
result=host(['bash',setup]+args,check=check)
if check:return json.loads(result.stdout)
assert result.returncode!=0,'Expected setup refusal'
return result.stderr
def milestone(text):print(text,flush=True)
try:
command(['docker','run','-d','--privileged','--cgroupns=private','--name',name,'--publish','127.0.0.1::443','conductor:slice12-test-host'])
for _ in range(80):
if host(['docker','info'],check=False).returncode==0:break
time.sleep(.25)
else:raise RuntimeError('The disposable nested Docker daemon did not start.')
assert host(['bash','-c','command -v node'],check=False).returncode!=0
host(['bash','-c','getent group docker >/dev/null || groupadd docker; usermod -aG docker admin; chgrp docker /var/run/docker.sock'])
for script in ['setup-conductor.sh','backup-conductor.sh','restore-conductor.sh']:
command(['docker','cp',str(SCRIPTS/script),name+':/opt/skeletonworks/scripts/'+script])
host(['chmod','755',setup,'/opt/skeletonworks/scripts/backup-conductor.sh','/opt/skeletonworks/scripts/restore-conductor.sh'])
host(['mkdir','-p','/opt/conductor-test'])
command(['docker','cp',str(ROOT/'examples/installed-app'),name+':/opt/conductor-test/example-package'])
command(['docker','cp',str(ROOT/'scripts/fixtures/slice12/check-backup-rejection.py'),name+':/opt/conductor-test/check-backup-rejection.py'])
caddy="""{
local_certs
auto_https disable_redirects
}
unrelated.customer.test {
respond "Neighbor preserved"
}
"""
compose="""services:
caddy:
image: caddy:2.10.2
ports: ["443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./logs:/var/log/caddy
- caddy_data:/data
- caddy_config:/config
networks: [skeletonworks_net]
volumes:
caddy_data:
caddy_config:
networks:
skeletonworks_net: {external: true}
"""
write_files({'/opt/skeletonworks/caddy/Caddyfile':caddy,'/opt/skeletonworks/caddy/docker-compose.yml':compose})
host(['crontab','-u','admin','-'],'0 2 * * * echo preserve-unrelated-job\n')
before=snapshot()
plan=invoke(['--domain','customer.test','--dry-run'])
assert plan['url']=='https://'+fqdn and plan['hostNodeRequired'] is False
assert plan['image'].endswith('@sha256:ab5e7dcaed065a304660af31e53ffada290b22dc9176ffeb5817f2e7f07ac313')
assert snapshot()==before
for domain,sub,expected in [('conductor.customer.test','','conductor.customer.test'),('customer.test','portal','portal.customer.test'),('customer.test','portal.other.test','portal.other.test')]:
args=['--domain',domain,'--dry-run']+(['--subdomain',sub] if sub else [])
assert invoke(args)['url']=='https://'+expected
for args in [['--domain'],['--domain','../customer.test'],['--domain','https://customer.test'],['--domain','customer.test','--image','conductor:latest'],['--domain','customer.test','--cpus','0'],['--domain','customer.test','--no-start','--restartcaddy']]:
invoke(args,False)
nonroot=host(['runuser','-u','admin','--','bash',setup,'--domain','customer.test','--dry-run'],check=False)
assert nonroot.returncode!=0 and 'sudo' in nonroot.stderr
assert snapshot()==before
write_files({'/opt/skeletonworks/conductor/conductor.legacy.test/.env':'preserve-legacy-configuration\n'})
before=snapshot()
assert 'SLICE13' in invoke(['--domain','legacy.test'],False)
assert snapshot()==before
milestone('PASS: protected dry-run, FQDN variants, input rejection and unchanged legacy installation.')
prepared=invoke(['--domain','customer.test','--no-start'])
assert prepared['status']=='prepared' and not prepared['started'] and not prepared['cronInstalled']
protected=snapshot()
assert invoke(['--domain','customer.test','--no-start'])['status']=='prepared'
for file in ['.env','.secrets','docker-compose.yml','.setup-plan.json']:assert snapshot()[stack+'/'+file]==protected[stack+'/'+file]
assert host(['docker','ps','-aq']).stdout.strip()==''
assert host(['docker','image','ls','-q']).stdout.strip()==''
assert host(['cat','/opt/skeletonworks/caddy/Caddyfile']).stdout==caddy
for file in ['.env','.secrets','docker-compose.yml','setup-result.json']:
assert host(['stat','-c','%a %U',stack+'/'+file]).stdout.strip()=='600 admin'
milestone('PASS: preparation/repeat preserves private keys and makes no container, image, Caddy or cron changes.')
started=invoke(['--domain','customer.test','--restartcaddy'])
assert started['status']=='success' and started['started'] and started['cronInstalled'] and started['caddyApplied']
assert started['verified']['version']=='v1.1.0' and started['verified']['firstRunSetupRequired']
for file in ['.env','.secrets']:assert snapshot()[stack+'/'+file]==protected[stack+'/'+file]
assert 'Neighbor preserved' in host(['cat','/opt/skeletonworks/caddy/Caddyfile']).stdout
cron=host(['crontab','-u','admin','-l']).stdout
assert cron.count('# skeletonworks-conductor '+fqdn)==1 and 'preserve-unrelated-job' in cron
inner=started['container']
metadata=json.loads(host(['docker','inspect',inner]).stdout)[0]
assert metadata['HostConfig']['ReadonlyRootfs'] and metadata['Config']['User']=='conductor'
assert metadata['HostConfig']['PortBindings']=={}
assert any(v['Destination']=='/data' and v['Name']==inner+'-data' for v in metadata['Mounts'])
assert json.loads(host(['docker','exec',inner,'node','dist/scripts/installedApps.js','list']).stdout)==[]
port=json.loads(command(['docker','inspect',name]).stdout)[0]['NetworkSettings']['Ports']['443/tcp'][0]['HostPort']
milestone('PASS: pinned public image, generated Caddy route, persistent volume, first-run readiness and preserved neighbor/cron.')
result=subprocess.run(['node',str(ROOT/'scripts/test-setup-conductor.mjs'),name,port],cwd=ROOT,text=True,capture_output=True)
(ARTIFACTS/'browser.log').write_text(result.stdout+'\n'+result.stderr)
if result.returncode:raise RuntimeError('Browser/recovery acceptance failed; see test-results/slice12/browser.log:\n'+result.stderr[-9000:])
milestone(result.stdout.strip())
before=snapshot()
assert 'SLICE13' in invoke(['--domain','customer.test'],False)
assert snapshot()==before
assert host(['bash','-c','command -v node'],check=False).returncode!=0
(ARTIFACTS/'result.json').write_text(json.dumps({'passed':True,'environment':'Disposable Ubuntu 24.04 with isolated Docker daemon; no host Node.js','setupChecks':['read-only dry-run','domain/FQDN variants','invalid argument refusal','sudo guidance','old installation preserved','no-start/repeat/resume','key/configuration permissions','verified release digest','healthy first-run setup','persistent app volume','Caddy validation/reload','unrelated Caddy/cron preserved','completed installation refuses fresh setup'],'browserAndRecovery':'passed','liveVmChanged':False},indent=2)+'\n')
milestone('PASS: SLICE12 fresh-install acceptance complete. No customer VM was used.')
finally:
logs=command(['docker','logs',name],check=False)
(ARTIFACTS/'test-host.log').write_text(logs.stderr[-12000:])
command(['docker','rm','-f','-v',name],check=False)