128 lines
8.5 KiB
Python
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)
|