From afacb32b6a454329a6e599f53fe446d7a7224346 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 17 Oct 2026 13:56:26 +0200 Subject: [PATCH] Support managed Python and remote Docker in CI; stop collector promptly --- .gitea/workflows/deploy.yml | 6 ++- scripts/collect.sh | 1 + tests/smoke.py | 96 +++++++++++++++++++++++-------------- tests/test_collector.py | 2 +- 4 files changed, 65 insertions(+), 40 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5ebf48d..f545e79 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -13,7 +13,8 @@ jobs: - uses: actions/checkout@v4 - name: Install tools run: | - python3 -m pip install --user 'PyYAML==6.0.3' + python3 -m pip install --target "${RUNNER_TEMP}/python" 'PyYAML==6.0.3' + echo "PYTHONPATH=${RUNNER_TEMP}/python" >> "$GITHUB_ENV" curl -fsSL https://dl.k8s.io/release/v1.35.3/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl chmod 0755 /usr/local/bin/kubectl - name: Test policy and boot the actual game configuration @@ -36,7 +37,8 @@ jobs: - uses: actions/checkout@v4 - name: Install tools run: | - python3 -m pip install --user 'PyYAML==6.0.3' + python3 -m pip install --target "${RUNNER_TEMP}/python" 'PyYAML==6.0.3' + echo "PYTHONPATH=${RUNNER_TEMP}/python" >> "$GITHUB_ENV" curl -fsSL https://dl.k8s.io/release/v1.35.3/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl chmod 0755 /usr/local/bin/kubectl - name: Deploy using the dedicated Factorio service account diff --git a/scripts/collect.sh b/scripts/collect.sh index 84338ea..d6d8eca 100755 --- a/scripts/collect.sh +++ b/scripts/collect.sh @@ -1,6 +1,7 @@ #!/bin/sh # Only ordinary console commands: no /c, /sc, Lua, mods, or achievement changes. set -eu +trap 'exit 0' TERM INT metrics_dir=${METRICS_DIR:-/metrics} mkdir -p "$metrics_dir" while :; do diff --git a/tests/smoke.py b/tests/smoke.py index 02dc17b..872f754 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -1,43 +1,65 @@ -"""Boot the actual server image with the actual rendered configuration, then probe/save/restart it.""" -import json,os,secrets,subprocess,sys,tempfile,time +"""Boot the rendered image/configuration via Docker, including remote Docker sockets.""" +import secrets +import subprocess +import sys +import tempfile +import time from pathlib import Path import yaml -docs=list(yaml.safe_load_all(Path(sys.argv[1]).read_text())) -d=next(d for d in docs if d['kind']=='Deployment') -c=next(c for c in d['spec']['template']['spec']['containers'] if c['name']=='factorio') -cms={d['metadata']['name']:d['data'] for d in docs if d['kind']=='ConfigMap'} -run=lambda *a,**kw: subprocess.run(a,check=True,**kw) -with tempfile.TemporaryDirectory() as directory: - root=Path(directory) - for name in ['scripts','settings','credentials','data']:(root/name).mkdir() - for folder,name in [('scripts','factorio-scripts'),('settings','factorio-settings')]: - for key,value in cms[name].items(): - file=root/folder/key; file.write_text(value); file.chmod(0o755 if key.endswith('.sh') else 0o644) - (root/'credentials/rcon-password').write_text(secrets.token_urlsafe(32)) - args=['docker','run','--detach','--name','factorio-smoke-'+secrets.token_hex(5)] - for env in c['env']:args+=['--env',env['name']+'='+env['value']] - for mount in c['volumeMounts']: - folder={'data':'data','scripts':'scripts','settings':'settings','credentials':'credentials'}[mount['name']] - args+=['--volume',f'{root/folder}:{mount["mountPath"]}'+(':ro' if mount.get('readOnly') else '')] - args+=['--entrypoint',c['command'][0],c['image'],*c['command'][1:]] - container=subprocess.check_output(args,text=True).strip() - def ready(): - command=c['readinessProbe']['exec']['command'] - for _ in range(90): - if subprocess.run(['docker','exec',container,*command],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=15).returncode==0:return - time.sleep(2) - raise RuntimeError('Server did not pass the production readiness probe') - try: +docs = list(yaml.safe_load_all(Path(sys.argv[1]).read_text())) +d = next(d for d in docs if d['kind'] == 'Deployment') +c = next(c for c in d['spec']['template']['spec']['containers'] if c['name'] == 'factorio') +cms = {d['metadata']['name']: d['data'] for d in docs if d['kind'] == 'ConfigMap'} +run = lambda *a, **kw: subprocess.run(a, check=True, **kw) +volume = 'factorio-smoke-' + secrets.token_hex(6) +run('docker', 'volume', 'create', volume, stdout=subprocess.DEVNULL) +container = None +try: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in ['scripts', 'settings', 'credentials']: + (root / name).mkdir() + for folder, name in [('scripts', 'factorio-scripts'), ('settings', 'factorio-settings')]: + for key, value in cms[name].items(): + file = root / folder / key + file.write_text(value) + file.chmod(0o755 if key.endswith('.sh') else 0o644) + (root / 'credentials/rcon-password').write_text(secrets.token_urlsafe(32)) + args = ['docker', 'create'] + for env in c['env']: + args += ['--env', env['name'] + '=' + env['value']] + data_mount = next(m['mountPath'] for m in c['volumeMounts'] if m['name'] == 'data') + args += ['--volume', f'{volume}:{data_mount}'] + args += ['--entrypoint', c['command'][0], c['image'], *c['command'][1:]] + container = subprocess.check_output(args, text=True).strip() + # docker cp works when the daemon is outside the CI job container; + # bind-mounting the job container's /tmp would silently mount a different directory. + for mount in c['volumeMounts']: + if mount['name'] != 'data': + run('docker', 'cp', str(root / mount['name']), container + ':' + mount['mountPath']) + run('docker', 'start', container, stdout=subprocess.DEVNULL) + + def ready(): + command = c['readinessProbe']['exec']['command'] + for _ in range(90): + if subprocess.run(['docker', 'exec', container, *command], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=15).returncode == 0: + return + time.sleep(2) + raise RuntimeError('Server did not pass the production readiness probe') + ready() - run('docker','exec',container,'rcon','/server-save') - run('docker','stop','--time','180',container,stdout=subprocess.DEVNULL) - assert list((root/'data/saves').glob('*.zip')), 'No persistent save' - run('docker','start',container,stdout=subprocess.DEVNULL) + run('docker', 'exec', container, 'rcon', '/server-save') + run('docker', 'stop', '--time', str(d['spec']['template']['spec']['terminationGracePeriodSeconds']), + container, stdout=subprocess.DEVNULL) + run('docker', 'start', container, stdout=subprocess.DEVNULL) ready() - assert list((root/'data/upgrade-backups').glob('*/*.zip')), 'No pre-upgrade backup' + run('docker', 'exec', container, 'sh', '-c', + 'test -n "$(find "$1/saves" -name "*.zip")" && test -n "$(find "$1/upgrade-backups" -name "*.zip")"', + 'sh', data_mount) print('Actual image passed RCON readiness, persistent save, restart, and upgrade-backup checks') - finally: - run('docker','rm','--force',container,stdout=subprocess.DEVNULL) - # The upstream image owns its world files; cleanup through the same image. - run('docker','run','--rm','--volume',f'{root}/data:/cleanup','--entrypoint','sh',c['image'],'-c','rm -rf /cleanup/*',stdout=subprocess.DEVNULL) +finally: + if container: + run('docker', 'rm', '--force', container, stdout=subprocess.DEVNULL) + run('docker', 'volume', 'rm', volume, stdout=subprocess.DEVNULL) diff --git a/tests/test_collector.py b/tests/test_collector.py index 50b6020..fc0010c 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -14,7 +14,7 @@ class CollectorTests(unittest.TestCase): fake=root/'rcon' fake.write_text('#!/bin/sh\n[ "$*" = "/players online" ] || exit 88\nprintf "%s\\n" "$RESPONSE"\nexit "$CODE"\n') fake.chmod(0o755) - proc=subprocess.Popen(['sh',str(SCRIPT)],env={**os.environ,'PATH':tmp+':'+os.environ['PATH'],'METRICS_DIR':tmp,'RESPONSE':response,'CODE':str(code)},start_new_session=True) + proc=subprocess.Popen(['sh',str(SCRIPT)],env={**os.environ,'PATH':tmp+':'+os.environ['PATH'],'METRICS_DIR':tmp,'RESPONSE':response,'CODE':str(code)},start_new_session=True,stderr=subprocess.DEVNULL) try: output=root/'factorio.prom' for _ in range(100):