From 9cc6147c6fede40ed5c1890cd0dc1eefa2dc2557 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Thu, 1 Oct 2026 13:40:21 -0400 Subject: [PATCH] test: verify rollback restores originals on partial deployment failure --- QUALIFICATION.md | 3 ++ tests/README.md | 2 +- tests/unit/test_deploy_rollback.py | 56 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_deploy_rollback.py diff --git a/QUALIFICATION.md b/QUALIFICATION.md index f2671ee..446d94e 100644 --- a/QUALIFICATION.md +++ b/QUALIFICATION.md @@ -64,6 +64,9 @@ also passes `model_options.reasoning.enabled: false` and forwards content only. narrowly trusted proxy identity/failed-login throttling regressions. - Provisioner tests: **9 passed**. - Authenticated bootstrap tests: **2 passed**. +- Offline deployment rollback tests: **3 passed**; injected second-service stop, + rename and replacement-run failures restore both original immutable IDs and + running names, without removing originals. - LiveKit Hermes stream tests: **16 passed** (no reasoning fallback or replay, approvals fail safely, total timeout, cancellation, visible timer progress, out-of-band notice/first-answer callback contracts). diff --git a/tests/README.md b/tests/README.md index a1165d0..515b875 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,7 +5,7 @@ ```sh python -m venv .venv-tests .venv-tests/bin/python -m pip install pytest fastapi httpx -.venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py -q +.venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py tests/unit/test_deploy_rollback.py -q agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v ``` diff --git a/tests/unit/test_deploy_rollback.py b/tests/unit/test_deploy_rollback.py new file mode 100644 index 0000000..8fb2413 --- /dev/null +++ b/tests/unit/test_deploy_rollback.py @@ -0,0 +1,56 @@ +"""Offline destructive-step failures restore both original container IDs.""" +import json +import os +from pathlib import Path +import subprocess + +import pytest + +FAKE = r'''#!/usr/bin/env python3 +import json,os,sys +p=os.environ['STATE'];s=json.load(open(p));a=sys.argv[1:] +def save():open(p,'w').write(json.dumps(s)) +def item(n):return next((x for x in s['containers'] if x['name']==n or x['id']==n),None) +cmd=a[0];n=a[-1];c=item(n) +if cmd=='inspect': + if not c:sys.exit(1) + if '--format' in a: + fmt=a[a.index('--format')+1] + print(c['id'] if '.Id' in fmt else '/'+c['name'] if '.Name' in fmt else 'healthy') + else:print(json.dumps([c])) +elif cmd=='stop': + c['running']=False;save() + if n=='hope-control' and s['failure']=='stop':sys.exit(7) +elif cmd=='rename': + c=item(a[1]) + if a[1]=='hope-control' and s['failure']=='rename':sys.exit(8) + c['name']=a[2];save() +elif cmd=='start':c['running']=True;save() +elif cmd=='rm': + assert c['id'] not in ['original-voice','original-control'] + s['containers'].remove(c);save() +elif cmd=='run': + name=a[a.index('--name')+1] + if name=='voice' and s['failure']=='run':sys.exit(9) + s['containers'].append({'name':name,'id':'new-'+name,'running':True});save() +else:sys.exit(2) +''' + +@pytest.mark.parametrize('failure',['stop','rename','run']) +def test_partial_deployment_restores_originals(tmp_path,failure): + root=tmp_path/'state';root.mkdir() + for n in ['runtime.env','livekit.yaml','voice.env']:(root/n).touch() + for n in ['accounts','hermes','certs']:(root/n).mkdir() + state=root/'fake.json' + state.write_text(json.dumps({'failure':failure,'containers':[{'name':'voice','id':'original-voice','running':True},{'name':'hope-control','id':'original-control','running':True}]})) + bindir=tmp_path/'bin';bindir.mkdir() + for name,text in [('docker',FAKE),('curl','#!/bin/sh\nexit 0\n')]: + p=bindir/name;p.write_text(text);p.chmod(0o755) + source=(Path(__file__).parents[2]/'scripts/deploy-unraid.sh').read_text() + source=source.replace('ROOT=/mnt/user/appdata/hope-voice',f'ROOT={root}').replace('/mnt/user/dev/hope-voice-api/certs',str(root/'certs')) + script=tmp_path/'deploy.sh';script.write_text(source) + env=dict(os.environ,STATE=str(state),PATH=str(bindir)+os.pathsep+os.environ['PATH']) + result=subprocess.run(['bash',str(script)],env=env,capture_output=True,text=True) + assert result.returncode!=0,result.stdout+result.stderr + data=json.loads(state.read_text()) + assert sorted((x['name'],x['id'],x['running']) for x in data['containers'])==[('hope-control','original-control',True),('voice','original-voice',True)]