feat(oci): support custom Compose workers and migration jobs

This commit is contained in:
Jonatan
2026-10-28 08:41:41 +01:00
parent b383fb9311
commit 3a0a7eaf14
44 changed files with 3267 additions and 110 deletions
+278
View File
@@ -0,0 +1,278 @@
"""Completion proofs survive native staging, rollback and renumbered restore."""
import copy
import json
from pathlib import Path
import stat
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_compose_jobs as jobs
import oci_instances as instances
import oci_stack_native as native
import oci_stack_plan as plans
import oci_restore_recovery as recovery
MAIN = '11111111-1111-4111-8111-111111111111'
JOB = '22222222-2222-4222-8222-222222222222'
DB = '33333333-3333-4333-8333-333333333333'
OLD = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
CONFIG = 'rootfs: local-zfs:subvol-4901-disk-0,size=8G\nunprivileged: 1\nentrypoint: migrate\n'
SPEC = {'installation_id': JOB, 'image_digest': OLD,
'expected_config_fingerprint': jobs.config_fingerprint(CONFIG), 'timeout_seconds': 300}
CHECK = {'type': 'completed', 'timeout_seconds': 300, 'job': SPEC}
def fixture():
services = [{'vmid': 4902, 'name': 'database', 'kind': 'postgres', 'healthcheck': {'type': 'running', 'timeout_seconds': 120}},
{'vmid': 4901, 'name': 'migration', 'healthcheck': copy.deepcopy(CHECK)},
{'vmid': 4900, 'name': 'web', 'frontend': True, 'healthcheck': {'type': 'running', 'timeout_seconds': 120}}]
records = []
for vmid, identity in ((4900, MAIN), (4901, JOB), (4902, DB)):
records.append({'schema_version': 1, 'vmid': vmid, 'installation_id': identity, 'status': 'installed',
'deployment': {'onboot': False, 'mounts': []},
'observed': {'resolved_registry_digest': OLD, 'image': {'defaults': {}}},
'stack_member': {'stack_id': MAIN, 'primary_vmid': 4900}})
records[1]['deployment']['execution_mode'] = 'one-shot'
records[0]['stack'] = {'id': MAIN, 'deployment': {'stack_name': 'test', 'services': services},
'members': copy.deepcopy(records)}
plan = {'operation': 'update', 'primary_vmid': 4900, 'members': records,
'start_order': [4902, 4901, 4900], 'stop_order': [4900, 4901, 4902]}
return plan
class NativeCompletedJobs(unittest.TestCase):
def adapter(self):
return native.NativeAdapter(Path('/unused'), Path('/unused/journal'), fixture())
def test_stopped_job_is_not_accepted_without_an_authenticated_success(self):
adapter = self.adapter()
with patch.object(adapter, '_job_contract'), \
patch.object(jobs, 'ensure_completed', side_effect=jobs.JobError('exit 7')), \
patch.object(adapter, 'is_running', return_value=False), \
patch.object(native.member_tx, 'run') as run:
with self.assertRaisesRegex(jobs.JobError, 'exit 7'):
adapter.healthcheck(4901)
run.assert_not_called()
def test_job_start_calls_completion_runner_not_pct_start(self):
adapter = self.adapter()
with patch.object(adapter, 'validate'), patch.object(adapter, '_job_contract'), \
patch.object(jobs, 'ensure_completed', return_value={'status': 'completed'}) as complete, \
patch.object(native.member_tx, 'run') as run:
adapter.start(4901)
complete.assert_called_once_with(4901, SPEC, force=False)
run.assert_not_called()
def test_database_host_bind_cannot_be_acknowledged_as_a_migration_backup(self):
adapter = self.adapter()
adapter.acknowledge = True
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/database'}]
with self.assertRaisesRegex(ValueError, 'Migration jobs'):
adapter._migration_backups()
def test_unknown_internal_backend_host_bind_is_not_assumed_safe(self):
adapter = self.adapter()
adapter.services[4902]['kind'] = 'application'
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/unknown'}]
with self.assertRaisesRegex(ValueError, 'Migration jobs'):
adapter._migration_backups()
def test_image_database_fingerprint_detects_an_unknown_tagged_repository(self):
adapter = self.adapter()
adapter.services[4902].update(kind='application', frontend=True)
adapter.records[4902]['observed']['image']['defaults']['Env'] = ['PG_MAJOR=16']
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/database'}]
with self.assertRaisesRegex(ValueError, 'Migration jobs'):
adapter._migration_backups()
def test_primary_app_shared_files_and_readonly_backend_are_not_database_write_risks(self):
adapter = self.adapter()
adapter.records[4900]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/app'}]
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/db', 'read_only': True}]
adapter._migration_backups()
def test_rollback_checks_database_before_rerunning_old_job_then_starts_main(self):
adapter = self.adapter()
events = []
with patch.object(adapter, 'state', return_value={'phase': 'recovering', 'replacement_intent': True}), \
patch.object(adapter, 'start', side_effect=lambda vmid: events.append(('start', vmid))), \
patch.object(adapter, 'healthcheck', side_effect=lambda vmid: events.append(('healthy', vmid))), \
patch.object(adapter, '_ensure_job', side_effect=lambda vmid, force=False: events.append(('job', vmid, force))), \
patch.object(adapter, 'stop', side_effect=lambda vmid: events.append(('stop', vmid))):
adapter.restore_running_state({'4900': True, '4901': False, '4902': True}, [4902, 4901, 4900])
self.assertLess(events.index(('healthy', 4902)), events.index(('job', 4901, True)))
self.assertLess(events.index(('job', 4901, True)), events.index(('start', 4900)))
self.assertNotIn(('start', 4901), events)
def job_contract(self, tamper=False, rollback=False):
adapter = self.adapter()
original = {'schema': 1, 'stack': 'test', 'dependencies': [
{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': copy.deepcopy(s['healthcheck'])}
for s in adapter.services.values() if s['vmid'] != 4900]}
contract = copy.deepcopy(original)
if tamper:
contract['dependencies'][0]['label'] = 'externally changed'
state = {'id': 'operation', 'phase': 'recovering' if rollback else 'starting'}
writes = []
path = SimpleNamespace(lstat=lambda: SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=0),
read_text=lambda: json.dumps(contract))
staged = copy.deepcopy(adapter.records[4901])
staged['observed']['resolved_registry_digest'] = OLD if rollback else NEW
updated = {**SPEC, 'image_digest': OLD if rollback else NEW,
'expected_config_fingerprint': 'c' * 64}
def write(target, value):
writes.append(copy.deepcopy(value))
if target is path:
contract.clear(); contract.update(copy.deepcopy(value))
else:
state.clear(); state.update(copy.deepcopy(value))
with patch.object(native, 'Path', return_value=path), \
patch.object(adapter, 'state', side_effect=lambda: copy.deepcopy(state)), \
patch.object(instances, 'write', side_effect=write), \
patch.object(instances, 'read', return_value=staged), \
patch.object(native.member_tx, 'owned', return_value=CONFIG.encode()), \
patch.object(jobs, 'job_spec_from_record', return_value=updated) as bind:
if tamper:
with self.assertRaisesRegex(ValueError, 'hook and the stack recipe differ'):
adapter._job_contract()
bind.assert_not_called()
self.assertEqual(writes, [])
return
adapter._job_contract(rollback=rollback)
self.assertEqual(bind.call_args.args[0]['observed']['resolved_registry_digest'], updated['image_digest'])
self.assertEqual(adapter.services[4901]['healthcheck']['job'], updated)
self.assertEqual(contract['dependencies'][1]['healthcheck']['job'], updated)
self.assertIn('job_hook_contract_intent', writes[0])
self.assertNotIn('job_hook_contract_intent', state)
def test_new_image_binds_new_job_digest_before_main_hook(self):
self.job_contract()
def test_rollback_rebinds_old_image_with_actual_restored_config(self):
self.job_contract(rollback=True)
def test_concurrent_hook_edit_is_not_overwritten(self):
self.job_contract(tamper=True)
def test_job_hook_intent_survives_orchestrator_rewriting_its_main_journal(self):
with tempfile.TemporaryDirectory() as directory:
adapter = self.adapter()
adapter.journal = Path(directory) / 'transaction.json'
adapter.journal.write_text(json.dumps({'id': 'operation', 'phase': 'starting'}))
contract = {'schema': 1, 'stack': 'test', 'dependencies': [
{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': copy.deepcopy(s['healthcheck'])}
for s in adapter.services.values() if s['vmid'] != 4900]}
hook = SimpleNamespace(lstat=lambda: SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=0),
read_text=lambda: json.dumps(contract))
staged = copy.deepcopy(adapter.records[4901])
staged['observed']['resolved_registry_digest'] = NEW
updated = {**SPEC, 'image_digest': NEW, 'expected_config_fingerprint': 'c' * 64}
actual_write = instances.write
def write(path, value):
if path is hook:
contract.clear(); contract.update(copy.deepcopy(value))
else:
actual_write(path, value)
with patch.object(native, 'Path', return_value=hook), \
patch.object(instances, 'write', side_effect=write), \
patch.object(instances, 'read', return_value=staged), \
patch.object(native.member_tx, 'owned', return_value=CONFIG.encode()), \
patch.object(jobs, 'job_spec_from_record', return_value=updated):
adapter._job_contract()
# The orchestration engine checkpoints its own original state object.
adapter.journal.write_text(json.dumps({'id': 'operation', 'phase': 'checking'}))
adapter._job_contract()
marker = json.loads((Path(directory) / 'completed-job-contract.json').read_text())
self.assertEqual(marker['operation_id'], 'operation')
self.assertEqual(marker['job_hook_contract']['dependencies'][1]['healthcheck']['job'], updated)
self.assertNotIn('job_hook_contract_intent', marker)
class JobPlanValidation(unittest.TestCase):
def test_plan_preserves_database_job_main_order(self):
fixture_plan = fixture()
primary = fixture_plan['members'][0]
records = {r['vmid']: r for r in fixture_plan['members']}
plan = plans.build(primary, records, {r['vmid']: r['installation_id'] for r in records.values()}, 'update')
self.assertEqual(plan['start_order'], [4902, 4901, 4900])
def test_job_cannot_be_onboot_or_bound_to_another_image(self):
for mutation in ('onboot', 'watchdog', 'mode', 'digest'):
with self.subTest(mutation=mutation):
value = fixture()
records = {r['vmid']: r for r in value['members']}
if mutation in ('onboot', 'watchdog'):
records[4901]['deployment'][mutation] = True
elif mutation == 'mode':
records[4901]['deployment'].pop('execution_mode')
else:
value['members'][0]['stack']['deployment']['services'][1]['healthcheck']['job']['image_digest'] = NEW
with self.assertRaises(ValueError):
plans.build(value['members'][0], records, {r['vmid']: r['installation_id'] for r in records.values()}, 'update')
class RestoreCompletedJobs(unittest.TestCase):
def test_only_bound_completed_contracts_are_accepted(self):
contract = {'schema': 1, 'dependencies': [{'vmid': 4901, 'label': 'migration', 'healthcheck': copy.deepcopy(CHECK)}]}
self.assertTrue(recovery.valid_contract(contract, {4901}))
for field, value in (('image_digest', 'latest'), ('installation_id', 'arbitrary'),
('expected_config_fingerprint', ''), ('timeout_seconds', True)):
broken = copy.deepcopy(contract)
broken['dependencies'][0]['healthcheck']['job'][field] = value
self.assertFalse(recovery.valid_contract(broken, {4901}), field)
def test_restore_never_starts_a_job_as_an_ordinary_daemon(self):
entries = [{'vmid': vmid, 'config': 'startup: order=' + str(order), 'contract': None}
for order, vmid in enumerate((4902, 4901, 4900), 1)]
plan = {'bridges': {}, 'renumbered': {}, 'restored': entries, 'hold': False, 'notes': []}
calls = []
def run(*args):
calls.append(args)
return SimpleNamespace(stdout='status: stopped', stderr='', returncode=0)
with patch.object(recovery, 'restore_host_files'), patch.object(recovery, 'clean_format_directories'), \
patch.object(recovery, 'register', return_value=[4902, 4901, 4900]), \
patch.object(recovery.carried, 'carry'), \
patch.object(recovery, 'completed_job', side_effect=lambda root, vmid: vmid == 4901), \
patch.object(recovery, 'run', side_effect=run):
recovery.recover(Path('/unused'), plan, start=True)
self.assertEqual([call for call in calls if call[:2] == ('pct', 'start')],
[('pct', 'start', '4902'), ('pct', 'start', '4900')])
def test_renumbered_restore_rebinds_current_config_and_invalidates_deferred_cache(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plan = fixture()
for record in plan['members']:
instances.write(instances.location(root, record['vmid']), record)
entry = {'vmid': 4900, 'contract': {'schema': 1}}
restore_plan = {'restored': [entry]}
calls = []
def checked(*args):
if args[:2] == ('pct', 'config'):
return CONFIG.replace('subvol-4901', 'subvol-NEW-4901')
calls.append((args, json.loads(Path(args[-1]).read_text())))
return ''
with patch.object(recovery, 'checked', side_effect=checked):
recovery.refresh_completed_jobs(root, restore_plan)
restored = instances.read(root, 4901)
main = instances.read(root, 4900)
check = main['stack']['deployment']['services'][1]['healthcheck']
self.assertTrue(restored['recovered_at'])
self.assertEqual(check['job']['installation_id'], JOB)
self.assertEqual(check['job']['image_digest'], OLD)
self.assertNotEqual(check['job']['expected_config_fingerprint'], SPEC['expected_config_fingerprint'])
self.assertEqual(calls[0][0][-2], '4900')
self.assertEqual([d['vmid'] for d in calls[0][1]['dependencies']], [4902, 4901])
self.assertTrue(recovery.completed_job(root, 4901))
self.assertFalse(recovery.completed_job(root, 4902))
if __name__ == '__main__':
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""Compose aliases remain single, safe /etc/hosts tokens, including underscores."""
from pathlib import Path
import re
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci.converter import _extra_hosts
class ComposeHostAliasTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
source = (ROOT / 'remote/install_oci.sh').read_text()
cls.shell = re.search(r'^oci_valid_extra_hostname\(\) \{\n.*?^\}', source, re.M | re.S)[0]
def shell_accepts(self, value):
return subprocess.run(['bash', '-c', self.shell + '\noci_valid_extra_hostname "$1"', 'alias-test', value],
capture_output=True).returncode == 0
def test_compose_container_aliases_are_accepted_by_import_and_installer(self):
for value in ('affine_postgres', 'affine_migration_job', 'db-1', 'db.internal', 'a', 'x' * 253):
with self.subTest(value=value):
self.assertTrue(self.shell_accepts(value))
self.assertEqual(_extra_hosts({value: '10.77.8.2'}),
[{'hostname': value, 'address': '10.77.8.2'}])
def test_multiline_whitespace_and_shell_syntax_are_refused(self):
for value in ('', 'db name', 'db\tname', 'db\n127.0.0.1 injected', '#comment',
'-db', '_db', '$(id)', 'db;id', 'db/../host', 'x' * 254):
with self.subTest(value=value):
self.assertFalse(self.shell_accepts(value))
self.assertIsNone(_extra_hosts({value: '10.77.8.2'}))
def test_apply_extra_hosts_uses_validated_token_before_writing(self):
source = (ROOT / 'remote/install_oci.sh').read_text()
apply = source.split('apply_extra_hosts() {', 1)[1].split('\n}', 1)[0]
self.assertLess(apply.index('oci_valid_extra_hostname "$hostname"'),
apply.index("printf '%s %s\\n'"))
+132
View File
@@ -0,0 +1,132 @@
"""Compose inputs are explicit, deterministic, and never taken from the host."""
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.compose_input import Interpolator, dotenv, prepare, resolve_document
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.custom import template_from_compose, _dump
class ComposeInputs(unittest.TestCase):
def test_default_required_alternative_nested_and_escaped_dollar(self):
expand = Interpolator({'EMPTY': '', 'SET': 'value'}).expand
for source, expected in [
('${MISSING:-fallback}', 'fallback'), ('${EMPTY:-fallback}', 'fallback'),
('${EMPTY-fallback}', ''), ('${SET:+yes}', 'yes'), ('${MISSING+yes}', ''),
('${EMPTY+yes}', 'yes'), ('${MISSING:-${SET}}', 'value'),
('$$HOME $SET ${SET}', '$HOME value value'), ('$5', '$5')]:
with self.subTest(source=source):
self.assertEqual(expand(source), expected)
for source in ['${MISSING?needed}', '${EMPTY:?needed}', '${NO}', '${BAD', '${X:=bad}']:
with self.subTest(source=source), self.assertRaises(ConversionError):
expand(source)
def test_never_reads_host_environment(self):
with patch.dict(os.environ, {'COMPOSE_SECRET': 'must-not-inherit'}):
with self.assertRaises(ConversionError):
Interpolator().expand('${COMPOSE_SECRET}')
def test_dotenv_quotes_comments_interpolation_and_no_shell_execution(self):
parsed = dotenv('''A=hello
B="${A} world" # note
C='literal$NO'
D=unquoted # comment
E=$(touch /tmp/should-not-exist)
F=
G
''', Interpolator())
self.assertEqual(parsed['B'], 'hello world')
self.assertEqual(parsed['C'], 'literal$NO')
self.assertEqual(parsed['D'], 'unquoted')
self.assertEqual(parsed['E'], '$(touch /tmp/should-not-exist)')
self.assertEqual(parsed['F'], '')
self.assertIsNone(parsed['G'])
for text in ['NOT A KEY=value', 'A="x" junk', "A='unterminated"]:
with self.subTest(text=text), self.assertRaises(ConversionError):
dotenv(text, Interpolator())
def test_file_merge_order_and_environment_override(self):
doc = {'services': {'app': {'image': 'example:${VERSION:-stable}',
'env_file': ['first.env', {'path':'optional.env', 'required':False}, 'second.env'],
'environment': {'A':'explicit', 'B':None}}}}
result = resolve_document(doc, {'B':'inherited-explicitly'},
{'first.env':'A=one\nB=first\nC=first', 'second.env':'C=second'})
self.assertEqual(result['services']['app']['image'], 'example:stable')
self.assertEqual(result['services']['app']['environment'],
{'A':'explicit', 'B':'inherited-explicitly', 'C':'second'})
self.assertNotIn('env_file', result['services']['app'])
self.assertIn('env_file', doc['services']['app'])
def test_unset_bare_variables_are_removed_and_optional_file_is_not_prompted(self):
class UI:
def ask(self, *args, **kwargs): raise AssertionError('Optional input must not be required')
document={'services': {'app': {'image':'example:1', 'env_file':[{'path':'missing.env','required':False}],
'environment':['UNSET', 'EMPTY=']}}}
with tempfile.TemporaryDirectory() as directory:
result=prepare(UI(),document,directory)
self.assertEqual(result['services']['app']['environment'],{'EMPTY':''})
def test_single_quoted_escape_and_dollar_value_are_literal(self):
self.assertEqual(dotenv("PASSWORD='it\\'s$literal'", Interpolator())['PASSWORD'],"it's$literal")
def test_selinux_mount_labels_are_normalized_with_explicit_host_guard_metadata(self):
class UI:
def ask(self,*args,**kwargs): raise AssertionError('No input required')
doc={'services':{'app':{'image':'example:1','volumes':['shared:/data:ro,z']},
'worker':{'image':'example:1','volumes':['shared:/data:Z']}}}
metadata={}
result=prepare(UI(),doc,metadata=metadata)
self.assertTrue(metadata['selinux_labels'])
self.assertEqual(result['services']['app']['volumes'],[{'source':'shared','target':'/data','read_only':True}])
self.assertEqual(result['services']['worker']['volumes'][0]['read_only'],False)
template=template_from_compose(_dump({'services':{'app':{'image':'adminer:4','ports':['8080:8080']},
'db':{'image':'postgres:16','environment':{'POSTGRES_PASSWORD':'test-only'}}}}),
interpolated=True,input_metadata=metadata)
self.assertTrue(template['proxmox']['custom_compose_selinux_labels'])
def test_literal_secret_is_not_expanded_again_by_stack_compiler(self):
document = {'services': {'db': {'image':'postgres:16',
'environment':{'POSTGRES_PASSWORD':'${PASSWORD}'}},
'app':{'image':'adminer:4','ports':['8080:8080'],
'environment':{'DATABASE_URL':'postgres://user:${PASSWORD}@db/test'}}}}
resolved = resolve_document(document, {'PASSWORD':'literal$PASSWORD${ALSO_LITERAL}'})
template = template_from_compose(_dump(resolved), interpolated=True)
self.assertTrue(template['compatibility']['automatic_install_candidate'], template['compatibility'])
db = next(s for s in template['compose_stack']['services'] if s['name']=='db')
self.assertEqual(db['compose']['environment']['POSTGRES_PASSWORD'], 'literal$PASSWORD${ALSO_LITERAL}')
def test_companion_dotenv_and_env_file_read_from_explicit_source_directory(self):
class UI:
def ask(self, *args, **kwargs):
raise AssertionError('No extra input needed for supplied companion files')
password = ask
with tempfile.TemporaryDirectory() as directory:
Path(directory, '.env').write_text('VERSION=16\nPASSWORD=test-only\n')
Path(directory, 'db.env').write_text('POSTGRES_PASSWORD=${PASSWORD}\n')
doc = {'services':{'db':{'image':'postgres:${VERSION}', 'env_file':'db.env'},
'app':{'image':'adminer:4','ports':['8080:8080']}}}
result = prepare(UI(), doc, directory)
self.assertEqual(result['services']['db']['image'], 'postgres:16')
self.assertEqual(result['services']['db']['environment']['POSTGRES_PASSWORD'], 'test-only')
def test_missing_values_are_asked_once_and_secrets_use_password_input(self):
class UI:
def __init__(self): self.prompts=[]
def ask(self, prompt, default='', **kwargs):
return ''
def password(self, prompt, **kwargs):
self.prompts.append(prompt); return 'synthetic-secret'
ui=UI()
doc={'services':{'one':{'image':'example:1','environment':{'PASSWORD':'${PASSWORD}'}},
'two':{'image':'example:2','environment':{'PASSWORD':'${PASSWORD}'}}}}
result=prepare(ui,doc)
self.assertEqual(len(ui.prompts),1)
self.assertEqual(result['services']['one']['environment'],result['services']['two']['environment'])
if __name__ == '__main__': unittest.main()
+92
View File
@@ -0,0 +1,92 @@
"""One-shot dependencies use the persistent verified helper, never bare pct start."""
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'remote'))
sys.path.insert(0,str(ROOT/'src'))
import install_generic_stack as stack
import oci_compose_jobs as jobs
from proxmenux_oci import installer
IID='55bb1839-f087-4b03-a9b8-75bec7537c9c'
DIGEST='sha256:'+'a'*64
CONFIG=f'description: proxmenux-instance={IID}\nrootfs: local-zfs:subvol-4791-disk-0,size=8G\nentrypoint: sh -c true\n'
RECORD={'vmid':4791,'installation_id':IID,'observed':{'resolved_registry_digest':DIGEST}}
class JobInstallIntegrationTests(unittest.TestCase):
def test_installer_binds_completed_job_after_actual_runtime_configuration(self):
service={'vmid':4791,'deployment':{'onboot':True},'healthcheck':{'type':'completed','timeout_seconds':300}}
def run(*args):
if args==('pct','config',4791):return CONFIG
self.assertEqual(args,('pct','set',4791,'--onboot','0'));return ''
with patch.object(stack,'run',side_effect=run),patch.object(stack.oci_instances,'read',return_value=RECORD):
stack.bind_completed_jobs([service])
self.assertFalse(service['deployment']['onboot'])
self.assertEqual(service['healthcheck']['job'],jobs.job_spec_from_record(RECORD,CONFIG))
def test_local_transport_really_copies_standalone_job_helper(self):
with tempfile.TemporaryDirectory() as directory:
template=Path(directory)/'template.json';template.write_text('{}')
def inspect(command,*args):
staged=Path(command[1]).parent/'oci_compose_jobs.py'
self.assertEqual(staged.read_bytes(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'local',True)
def test_ssh_transport_really_archives_standalone_job_helper(self):
with tempfile.TemporaryDirectory() as directory:
template=Path(directory)/'template.json';template.write_text('{}')
def inspect(command,data):
self.assertEqual(command[0],'ssh')
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:
self.assertEqual(archive.extractfile('oci_compose_jobs.py').read(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'test-node',True)
@unittest.skipUnless(shutil.which('jq'),'jq required')
def test_hook_contract_rejects_incomplete_or_unsafe_job_spec(self):
text=(ROOT/'remote/stack_dependency_hook.sh').read_text()
start=text.index(" jq -e '")+len(" jq -e '");end=text.index("\n ' \"$source_config\"",start)
expression=text[start:end]
spec=jobs.job_spec_from_record(RECORD,CONFIG)
contract={'schema':1,'dependencies':[{'vmid':4791,'label':'migration','healthcheck':{'type':'completed','timeout_seconds':300,'job':spec}}]}
good=subprocess.run(['jq','-e',expression],input=json.dumps(contract),text=True,capture_output=True)
self.assertEqual(good.returncode,0,good.stderr)
contract['dependencies'][0]['healthcheck']['job']['image_digest']='latest'
bad=subprocess.run(['jq','-e',expression],input=json.dumps(contract),text=True,capture_output=True)
self.assertNotEqual(bad.returncode,0)
@unittest.skipUnless(shutil.which('jq') and shutil.which('flock'),'jq/flock required')
def test_hook_calls_completed_helper_without_starting_job_directly(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);priv=root/'priv';priv.mkdir();locks=root/'locks';locks.mkdir();binary=root/'bin';binary.mkdir()
helper=root/'jobs.py';helper.write_text("import json,os,sys\nfrom pathlib import Path\nPath(os.environ['JOB_CALLS']).write_text(json.dumps(sys.argv[1:]))\n")
pct=binary/'pct';pct.write_text('#!/bin/sh\n[ "$1" = config ] && exit 0\necho "UNEXPECTED_PCT_START" >&2\nexit 99\n');pct.chmod(0o755)
stat=binary/'stat';stat.write_text('#!/bin/sh\n[ "$2" = %u ] && { echo 0; exit 0; }\n[ "$2" = %a ] && { echo 755; exit 0; }\nexit 1\n');stat.chmod(0o755)
spec=jobs.job_spec_from_record(RECORD,CONFIG)
(priv/'proxmenux-stack-4790.json').write_text(json.dumps({'schema':1,'dependencies':[{'vmid':4791,'label':'migration','healthcheck':{'type':'completed','timeout_seconds':300,'job':spec}}]}))
text=(ROOT/'remote/stack_dependency_hook.sh').read_text()
functions=text[text.index('die() {'):text.index('\nif [[ ${1:-} == "--install"')]
functions=functions.replace('/etc/pve/priv',str(priv)).replace('/run/lock',str(locks))
command='COMPOSE_JOB_HELPER='+str(helper)+'\n'+functions+'\nstart_dependencies 4790\n'
calls=root/'calls.json';env={**os.environ,'PATH':str(binary)+':'+os.environ['PATH'],'JOB_CALLS':str(calls)}
result=subprocess.run(['bash','-c',command],env=env,text=True,capture_output=True)
self.assertEqual(result.returncode,0,result.stderr)
self.assertEqual(json.loads(calls.read_text())[:2],['ensure','4791'])
self.assertNotIn('UNEXPECTED_PCT_START',result.stderr)
if __name__=='__main__':unittest.main()
+219
View File
@@ -0,0 +1,219 @@
"""A completed OCI job needs real exit-zero proof, not stopped/CLI output."""
import contextlib
import copy
import json
import os
from pathlib import Path
import socket
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote'))
import oci_compose_jobs as jobs
IID='55bb1839-f087-4b03-a9b8-75bec7537c9c'
DIGEST='sha256:'+'a'*64
IDENTITY={'vmid':4790,'installation_id':IID,'image_digest':DIGEST,'config_fingerprint':'f'*64}
PROOF={'raw_waitstatus':0,'exit_code':0,'fresh_start_observed':True,'protocol':'lxc-msg-native264'}
CONFIG=f'''description: OCI %3C!-- proxmenux-instance={IID} --%3E
rootfs: local-zfs:subvol-4790-disk-0,size=8G
entrypoint: sh -c 'exit 0'
env: TOKEN=literal-secret-value
unprivileged: 1
lxc.init.uid: 1000
lxc.init.gid: 1000
mp0: /mnt/test/shared,mp=/data,backup=0,size=8G
cores: 1
memory: 256
tags: temporary
'''
def pack(kind,name,value):return jobs.FRAME.pack(kind,str(name).encode(),value)
class FakeProcess:
def __init__(self,rc=255):self.returncode=rc
def poll(self):return self.returncode
def wait(self,timeout=None):return self.returncode
def terminate(self):self.returncode=-15
def kill(self):self.returncode=-9
class NativeExitTests(unittest.TestCase):
def attempt(self, events):
receiver,sender=socket.socketpair()
with receiver,sender:
receiver.setblocking(False)
@contextlib.contextmanager
def subscription():yield receiver,{'protocol':'test-native-frames'}
def start(*args,**kwargs):
sender.sendall(b''.join(pack(*event) for event in events))
return FakeProcess()
with patch.object(jobs,'_subscription',subscription), \
patch.object(jobs.subprocess,'Popen',side_effect=start), \
patch.object(jobs,'_status',return_value='stopped'):
return jobs._run_attempt(4790,time.monotonic()+.2)
def test_exit_zero_is_proven_even_when_pct_start_loses_the_fast_pid(self):
proof=self.attempt([(0,4790,1),(0,4790,2),(2,4790,0)])
self.assertEqual(proof['raw_waitstatus'],0)
self.assertEqual(proof['pct_start_returncode'],255)
def test_nonzero_and_sigkill_never_become_completed(self):
for raw in (7<<8,9):
with self.subTest(raw=raw),self.assertRaises(jobs.JobError):
self.attempt([(0,4790,1),(0,4790,2),(2,4790,raw)])
def test_stopped_or_missing_exit_is_not_success(self):
with self.assertRaisesRegex(jobs.JobError,'timed out'):
self.attempt([(0,4790,1),(0,4790,2),(0,4790,0)])
def test_exit_without_a_fresh_start_event_is_rejected(self):
with self.assertRaisesRegex(jobs.JobError,'fresh'):
self.attempt([(2,4790,0)])
def test_unrelated_guest_exit_zero_does_not_complete_this_job(self):
with self.assertRaisesRegex(jobs.JobError,'timed out'):
self.attempt([(0,4790,1),(2,123,0),(0,4790,0)])
def test_partial_frames_are_buffered_and_unknown_abi_is_rejected(self):
data=pack(2,4790,1792)
self.assertEqual(jobs._frames(data[:8]),([],data[:8]))
self.assertEqual(jobs._frames(data),([(2,'4790',1792)],b''))
with self.assertRaises(jobs.JobError):jobs._frames(pack(100,4790,0))
class IdentityAndTrustTests(unittest.TestCase):
def test_command_environment_user_and_mount_changes_invalidate_fingerprint(self):
first=jobs.config_fingerprint(CONFIG)
for before,after in [('exit 0','exit 7'),('literal-secret-value','changed'),
('lxc.init.uid: 1000','lxc.init.uid: 1001'),('/mnt/test/shared','/mnt/test/other')]:
with self.subTest(before=before):
self.assertNotEqual(jobs.config_fingerprint(CONFIG.replace(before,after)),first)
altered=CONFIG.replace('cores: 1','cores: 4').replace('memory: 256','memory: 4096').replace('tags: temporary','tags: renamed').replace('size=8G','size=16G')
self.assertEqual(jobs.config_fingerprint(altered),first)
self.assertNotIn('literal-secret-value',first)
def test_instance_uuid_digest_and_container_marker_are_bound(self):
record={'vmid':4790,'installation_id':IID,'observed':{'resolved_registry_digest':DIGEST}}
spec={'installation_id':IID,'image_digest':DIGEST}
def read(path):return json.dumps(record) if str(path).endswith('.json') else CONFIG
with patch.object(jobs,'_trusted_read',side_effect=read), patch.object(jobs,'_config_text',return_value=CONFIG):
result=jobs.current_identity(4790,spec)
self.assertEqual(result['installation_id'],IID)
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,{**spec,'image_digest':'sha256:'+'b'*64})
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,{**spec,'installation_id':str(__import__('uuid').uuid4())})
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,{**spec,'expected_config_fingerprint':'b'*64})
with patch.object(jobs,'_trusted_read',return_value=json.dumps(record)), patch.object(jobs,'_config_text',return_value=CONFIG.replace(IID,'another-instance')):
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
def test_staged_digest_requires_matching_child_and_stack_transactions(self):
txid='a'*32
stackid='b'*32
journal=jobs.INSTANCE_ROOT/'4790'/'transactions'/txid/'transaction.json'
parent=jobs.INSTANCE_ROOT/'4792'/'stack-transactions'/stackid/'transaction.json'
new_digest='sha256:'+'b'*64
record={'vmid':4790,'installation_id':IID,'status':'updating',
'observed':{'resolved_registry_digest':DIGEST},'transaction_id':txid,
'pending_transaction':str(journal),'pending_stack_transaction':str(parent)}
child={'schema_version':1,'id':txid,'vmid':4790,'phase':'candidate-installed',
'record':{'installation_id':IID},'registry_digest':new_digest,
'staged_config_sha256':__import__('hashlib').sha256(CONFIG.encode()).hexdigest(),
'coordinated':{'journal':str(parent),'id':'active-stack-operation'}}
stack={'schema_version':1,'id':'active-stack-operation','phase':'starting',
'plan':{'members':[{'vmid':4790,'installation_id':IID}]}}
def read(path):
return json.dumps(child if path==journal else stack if path==parent else record)
with patch.object(jobs,'_trusted_read',side_effect=read),patch.object(jobs,'_config_text',return_value=CONFIG):
spec=jobs.job_spec_from_record(record,CONFIG)
self.assertEqual(spec['image_digest'],new_digest)
self.assertEqual(jobs.current_identity(4790,spec)['image_digest'],new_digest)
child['staged_config_sha256']='0'*64
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
child['staged_config_sha256']=__import__('hashlib').sha256(CONFIG.encode()).hexdigest()
stack['id']='another-operation'
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
stack['id']='active-stack-operation';stack['phase']='replacing'
self.assertEqual(jobs.job_spec_from_record(record,CONFIG)['image_digest'],new_digest)
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
def test_restore_generation_changes_identity_even_with_identical_config(self):
record={'vmid':4790,'installation_id':IID,'observed':{'resolved_registry_digest':DIGEST}}
spec={'installation_id':IID,'image_digest':DIGEST}
with patch.object(jobs,'_trusted_read',side_effect=lambda path:json.dumps(record)),patch.object(jobs,'_config_text',return_value=CONFIG):
before=jobs.current_identity(4790,spec)
record['recovered_at']='2026-10-28T12:00:00Z'
after=jobs.current_identity(4790,spec)
self.assertEqual(before['config_fingerprint'],after['config_fingerprint'])
self.assertNotEqual(before,after)
def test_metadata_final_symlinks_and_world_writes_are_rejected(self):
with tempfile.TemporaryDirectory() as directory:
source=Path(directory)/'record';source.write_text('{}');source.chmod(0o600)
link=Path(directory)/'link';link.symlink_to(source)
self.assertEqual(jobs._trusted_read(source,owner=os.getuid()),'{}')
with self.assertRaises(OSError):jobs._trusted_read(link,owner=os.getuid())
source.chmod(0o666)
with self.assertRaises(jobs.JobError):jobs._trusted_read(source,owner=os.getuid())
def test_socket_name_is_the_source_verified_lxc_hash(self):
self.assertEqual(jobs._socket_name(),'lxc/ad055575fe28ddd5//var/lib/lxc')
class CompletionCacheTests(unittest.TestCase):
def setUp(self):
directory=tempfile.TemporaryDirectory();self.addCleanup(directory.cleanup)
self.root=Path(directory.name)
for name,replacement in [('STATE_ROOT',self.root),('_root',lambda:None),
('_job_lock',lambda *args:contextlib.nullcontext()),
('_status',lambda *args:'stopped')]:
item=patch.object(jobs,name,replacement);item.start();self.addCleanup(item.stop)
item=patch.object(jobs,'current_identity',return_value=IDENTITY);item.start();self.addCleanup(item.stop)
item=patch.object(jobs,'_read_cache',side_effect=lambda path:json.loads(path.read_text()) if path.exists() else None);item.start();self.addCleanup(item.stop)
self.spec={'installation_id':IID,'image_digest':DIGEST,'timeout_seconds':30}
def test_only_matching_proven_cache_skips_job_start(self):
with patch.object(jobs,'_run_attempt',return_value=PROOF) as attempt:
self.assertFalse(jobs.ensure_completed(4790,self.spec)['cached'])
self.assertTrue(jobs.ensure_completed(4790,self.spec)['cached'])
attempt.assert_called_once()
cache=json.loads((self.root/'4790.json').read_text())
self.assertEqual(cache['identity'],IDENTITY)
self.assertEqual(cache['proof']['raw_waitstatus'],0)
def test_changed_image_identity_or_config_never_reuses_old_completion(self):
cache={'schema':1,'status':'completed','identity':{**IDENTITY,'image_digest':'sha256:'+'b'*64},'proof':PROOF}
(self.root/'4790.json').write_text(json.dumps(cache))
with patch.object(jobs,'_run_attempt',return_value=PROOF) as attempt:
self.assertFalse(jobs.ensure_completed(4790,self.spec)['cached'])
attempt.assert_called_once()
def test_force_failure_invalidates_earlier_success(self):
with patch.object(jobs,'_run_attempt',return_value=PROOF):jobs.ensure_completed(4790,self.spec)
with patch.object(jobs,'_run_attempt',side_effect=jobs.JobError('signal 9')):
with self.assertRaises(jobs.JobError):jobs.ensure_completed(4790,self.spec,force=True)
self.assertFalse((self.root/'4790.json').exists())
def test_remove_forgets_only_matching_installation_uuid(self):
with patch.object(jobs,'_run_attempt',return_value=PROOF):jobs.ensure_completed(4790,self.spec)
other='45e85d70-4e28-416f-8ae8-325ae9153272'
self.assertFalse(jobs.forget_completed(4790,other))
self.assertTrue((self.root/'4790.json').exists())
self.assertTrue(jobs.forget_completed(4790,IID))
self.assertFalse((self.root/'4790.json').exists())
self.assertFalse(jobs.forget_completed(4790,IID))
def test_running_job_and_bad_timeout_are_refused(self):
with patch.object(jobs,'_status',return_value='running'),patch.object(jobs,'_run_attempt') as attempt:
with self.assertRaisesRegex(jobs.JobError,'already running'):jobs.ensure_completed(4790,self.spec)
attempt.assert_not_called()
for timeout in (True,0,29,3601,'300'):
with self.subTest(timeout=timeout),self.assertRaises(jobs.JobError):
jobs.ensure_completed(4790,{**self.spec,'timeout_seconds':timeout})
if __name__=='__main__':unittest.main()
+57
View File
@@ -0,0 +1,57 @@
"""Explicit Compose stop signals override OCI image metadata without guessing."""
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import assess, service_template
from proxmenux_oci.converter import _stop_signal
SOURCE = '''services:
web:
image: nginx:alpine
ports: ['8080:80']
depends_on: [worker]
worker:
image: example/worker:latest
entrypoint: /worker.sh
'''
class ComposeStopSignalTests(unittest.TestCase):
def test_explicit_worker_signal_reaches_native_runtime(self):
template = template_from_compose(SOURCE + ' stop_signal: SIGTERM\n')
self.assertEqual(assess(template), [])
worker = next(s for s in template['compose_stack']['services'] if s['name'] == 'worker')
native = service_template(template, worker)
self.assertEqual(native['proxmox']['installer_profile']['runtime']['halt_signal'], 'SIGTERM')
self.assertEqual(native['proxmox']['installer_profile']['runtime']['compose_entrypoint'], '/worker.sh')
def test_main_signal_is_applied_too(self):
document = SOURCE.replace(" ports: ['8080:80']", " ports: ['8080:80']\n stop_signal: SIGQUIT")
template = template_from_compose(document)
web = next(s for s in template['compose_stack']['services'] if s['name'] == 'web')
native = service_template(template, web)
self.assertEqual(native['proxmox']['installer_profile']['runtime']['halt_signal'], 'SIGQUIT')
self.assertEqual(native['proxmox']['image_metadata_policy']['stop_signal'], 'apply-compose-override')
def test_absent_signal_does_not_guess_from_entrypoint_override(self):
template = template_from_compose(SOURCE)
worker = next(s for s in template['compose_stack']['services'] if s['name'] == 'worker')
self.assertNotIn('halt_signal', service_template(template, worker)['proxmox']['installer_profile']['runtime'])
def test_invalid_signal_is_a_review_blocker(self):
for value in ('SIGTYPO', 'SIGTERM;touch /tmp/x', '', 99, None):
with self.subTest(value=value):
document = SOURCE + ' stop_signal: ' + (repr(value) if isinstance(value, str) else str(value).lower()) + '\n'
template = template_from_compose(document)
self.assertTrue(any('stop-signal-format' in e for e in assess(template)))
def test_named_linux_signals_normalize_independent_of_host_os(self):
self.assertEqual(_stop_signal('term'), 'SIGTERM')
self.assertEqual(_stop_signal('SIGWINCH'), 'SIGWINCH')
self.assertEqual(_stop_signal('SIGPWR'), 'SIGPWR')
self.assertIsNone(_stop_signal('SIGWINCH\nSIGTERM'))
if __name__ == '__main__':
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""Successful-completion conditions represent jobs, not daemon liveness."""
import copy
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import assess,build_stack,DefaultsUI,ordered_services
from proxmenux_oci.installer import DEFAULT_MODE
COMPOSE='''services:
app:
image: example.org/application:3
ports: ['8080:8080']
depends_on:
migration: {condition: service_completed_successfully}
volumes: [storage:/data]
migration:
image: example.org/application:3
command: [sh, -c, run-migrations]
depends_on: [db]
volumes: [storage:/data]
db:
image: postgres:16
environment: {POSTGRES_PASSWORD: synthetic-only}
volumes:
storage:
'''
class UI(DefaultsUI):
def confirm(self,text,default=False):return True
class JobPlanning(unittest.TestCase):
def test_job_order_completion_check_and_no_autostart(self):
template=template_from_compose(COMPOSE,interpolated=True)
self.assertEqual(assess(template),[])
self.assertEqual([s['name'] for s in ordered_services(template)],['db','migration','app'])
with patch('proxmenux_oci.installer.ask_storage',return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge',return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone',return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses',return_value=({'app':'dhcp'},None)):
plan=build_stack(template,UI(),DEFAULT_MODE)
services={s['name']:s for s in plan['services']}
self.assertEqual(services['migration']['healthcheck']['type'],'completed')
self.assertEqual(services['migration']['deployment']['execution_mode'],'one-shot')
self.assertFalse(services['migration']['deployment']['onboot'])
self.assertTrue(services['app']['deployment']['onboot'])
self.assertNotIn('frontend',services['migration'])
shared=[m['source'] for name in ['app','migration'] for m in services[name]['deployment']['mounts'] if m['container_path']=='/data']
self.assertEqual(len(set(shared)),1)
def test_a_job_cannot_be_treated_as_a_continuously_healthy_daemon(self):
template=template_from_compose(COMPOSE,interpolated=True)
template['compose_stack']['services'][0]['compose'].setdefault('depends_on',{})
main=next(s for s in template['compose_stack']['services'] if s['name']=='app')
main['compose']['depends_on']['migration']['condition']='service_healthy'
# Without a health check, a migration-like worker cannot satisfy healthy.
self.assertTrue(assess(template))
def test_auto_restart_of_successful_jobs_is_not_silently_enabled(self):
template=template_from_compose(COMPOSE,interpolated=True)
job=next(s for s in template['compose_stack']['services'] if s['name']=='migration')
job['compose']['restart']='always'
self.assertTrue(any('restart' in error for error in assess(template)))
if __name__=='__main__':unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Internal service metadata is inferred from OCI, not app-specific recipes."""
import json
from pathlib import Path
import subprocess
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'src'))
sys.path.insert(0,str(ROOT/'remote'))
from proxmenux_oci.custom import enrich_image_defaults
import install_generic_stack as generic
class ImageDefaults(unittest.TestCase):
def test_internal_expose_and_health_from_oci_are_preserved_without_publishing_ports(self):
doc={'services':{'helper':{'image':'example.org/team/helper:3','healthcheck':{'disable':False,'retries':5}}}}
config={'config':{'ExposedPorts':{'7000/tcp':{},'7001/udp':{}},'Healthcheck':{
'Test':['CMD','/usr/bin/check-ready'],'Interval':3000000000,'Timeout':1000000000}}}
process=SimpleNamespace(returncode=0,stdout=json.dumps(config))
with patch('proxmenux_oci.custom.subprocess.run',return_value=process):
result=enrich_image_defaults(None,doc)['services']['helper']
self.assertEqual(result['expose'],['7000/tcp'])
self.assertNotIn('ports',result)
self.assertEqual(result['healthcheck']['test'],['CMD','/usr/bin/check-ready'])
self.assertEqual(result['healthcheck']['retries'],5)
self.assertEqual(result['healthcheck']['interval'],'3000000000ns')
self.assertNotIn('expose',doc['services']['helper'])
def test_null_image_health_does_not_crash_and_no_port_is_invented(self):
doc={'services':{'helper':{'image':'example.org/team/helper:3','healthcheck':{'disable':False}}}}
with patch('proxmenux_oci.custom.subprocess.run',return_value=SimpleNamespace(returncode=0,stdout='{"config":{"Healthcheck":null}}')):
result=enrich_image_defaults(None,doc)['services']['helper']
self.assertNotIn('healthcheck',result)
self.assertNotIn('expose',result)
def test_explicit_disabled_healthcheck_is_not_reenabled(self):
doc={'services':{'web':{'image':'example.org/web:1','ports':['8080:8080'],'healthcheck':{'disable':True}}}}
with patch('proxmenux_oci.custom.subprocess.run') as inspect:
self.assertEqual(enrich_image_defaults(None,doc),doc)
inspect.assert_not_called()
def test_worker_entrypoint_or_command_does_not_inherit_a_false_web_listener(self):
for override in [{'entrypoint':'/cron.sh'}, {'command':['worker','run']}, {'command':[]}]:
with self.subTest(override=override):
doc={'services':{'worker':{'image':'example.org/web-and-worker:1',**override}}}
config={'config':{'ExposedPorts':{'80/tcp':{}},'Healthcheck':None}}
with patch('proxmenux_oci.custom.subprocess.run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(config))):
result=enrich_image_defaults(None,doc)['services']['worker']
self.assertNotIn('expose',result)
def test_option_command_uses_actual_image_entrypoint_metadata(self):
doc={'services':{'db':{'image':'mariadb:11','command':['--transaction-isolation=READ-COMMITTED']}}}
config={'config':{'Entrypoint':['docker-entrypoint.sh'],'Cmd':['mariadbd']}}
entrypoints={}
with patch('proxmenux_oci.custom.subprocess.run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(config))):
result=enrich_image_defaults(None,doc,image_entrypoints=entrypoints)
self.assertEqual(entrypoints,{'mariadb:11':['docker-entrypoint.sh']})
self.assertEqual(result,doc)
def test_enforcing_selinux_stops_before_install_when_labels_need_translation(self):
import tempfile
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'enforce'; path.write_text('1')
with self.assertRaisesRegex(RuntimeError,'SELinux'):
generic.check_selinux_labels({'proxmox':{'custom_compose_selinux_labels':True}},path)
path.write_text('0')
generic.check_selinux_labels({'proxmox':{'custom_compose_selinux_labels':True}},path)
path.write_text('1')
generic.check_selinux_labels({'proxmox':{}},path)
def test_main_http_success_does_not_override_failed_declared_exec_health(self):
service={'vmid':4800,'name':'app','healthcheck':{'type':'exec','argv':['check-ready'],'timeout_seconds':30}}
opener=SimpleNamespace(open=lambda *args,**kwargs: __import__('contextlib').nullcontext(SimpleNamespace(status=200)))
with patch.object(generic,'run',return_value='status: running'), \
patch.object(generic.urllib.request,'build_opener',return_value=opener), \
patch.object(generic.subprocess,'run',side_effect=[SimpleNamespace(returncode=1),SimpleNamespace(returncode=0)]) as execute, \
patch.object(generic.time,'sleep'):
generic.wait_web(service,'http://192.0.2.1:8080/')
self.assertEqual(execute.call_count,2)
self.assertEqual(execute.call_args.args[0],['pct','exec','4800','--','check-ready'])
if __name__=='__main__':unittest.main()
+34 -2
View File
@@ -54,9 +54,9 @@ class CustomMultiAppStackTests(unittest.TestCase):
self.assertEqual(assess(template()), [])
self.assertTrue(assess(template(custom=False)))
def test_app_without_own_port_is_rejected(self):
def test_custom_background_app_without_port_is_only_checked_running(self):
result = template(text=COMPOSE.replace(" ports: ['7878:7878']\n", ''))
self.assertTrue(any('health and persistence' in e for e in assess(result)))
self.assertEqual(assess(result), [])
def test_service_image_contract_preserves_tag_and_digest(self):
parent = template()
@@ -95,6 +95,27 @@ class CustomMultiAppStackTests(unittest.TestCase):
self.assertEqual({s['name']:s['frontend_ipv4'] for s in plan['services']}, supplied)
self.assertEqual(plan['network']['frontend_gateway'], '192.0.2.1')
def test_advanced_custom_resources_are_selected_per_service(self):
class ResourceUI(UI):
def ask(self,text,default=None,**kwargs):
if text=='radarr: Memory in MB': return '768'
if text=='radarr: CPU cores': return '3'
if text=='Memory in MB': return '2048'
if text=='CPU cores': return '2'
return super().ask(text,default,**kwargs)
with patch('proxmenux_oci.installer.ask_storage',return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge',return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone',return_value='UTC'), \
patch('proxmenux_oci.custom_mounts.ask_stack_custom_mounts'), \
patch('proxmenux_oci.extra_devices.ask_stack_extra_devices'), \
patch('proxmenux_oci.network.ask_addresses',return_value=({'sonarr':'dhcp','radarr':'dhcp'},None)):
plan=build_stack(template(),ResourceUI(),ADVANCED_MODE)
resources={service['name']:service['deployment']['resources'] for service in plan['services']}
self.assertEqual(resources['radarr']['memory_mb'],768)
self.assertEqual(resources['radarr']['cores'],3)
self.assertEqual(resources['sonarr']['memory_mb'],2048)
self.assertEqual(resources['sonarr']['cores'],2)
def test_build_assigns_distinct_endpoints_and_shared_bind_source(self):
ui = UI()
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
@@ -116,6 +137,17 @@ class CustomMultiAppStackTests(unittest.TestCase):
configs = [next(m for m in s['deployment']['mounts'] if m['container_path']=='/config') for s in members.values()]
self.assertTrue(all(m['type']=='managed-volume' for m in configs))
def test_shared_directory_without_puid_uses_imported_image_owner(self):
text = COMPOSE.replace(" environment: {PUID: '1000', PGID: '1000'}\n", '')
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge', return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone', return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses', side_effect=lambda ui, bridge, labels: ({name:'dhcp' for name in labels}, None)):
plan = build_stack(template(text=text), UI(), DEFAULT_MODE)
for service in plan['services']:
shared = next(m for m in service['deployment']['mounts'] if m['container_path'] == '/data')
self.assertNotIn('host_owner', shared)
if __name__ == '__main__':
unittest.main()
+206
View File
@@ -0,0 +1,206 @@
"""Custom internal helpers need explicit readiness and never gain a LAN by expose."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import (assess, build_stack, compose_healthcheck,
DefaultsUI, kind, resolve_environments, StackError)
from proxmenux_oci.installer import DEFAULT_MODE
COMPOSE = '''name: worker-example
services:
web:
image: nginx:1.27
ports: ['8080:80']
depends_on: [helper]
helper:
image: example/helper:1
expose: ['3000']
'''
def build(template):
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge', return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone', return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses', side_effect=lambda ui, bridge, labels: ({name: 'dhcp' for name in labels}, None)):
return build_stack(template, DefaultsUI(), DEFAULT_MODE)
class WorkerStackTests(unittest.TestCase):
def test_exposed_helper_gets_internal_tcp_check_without_frontend(self):
template = template_from_compose(COMPOSE)
self.assertEqual(assess(template), [])
services = {service['name']: service for service in build(template)['services']}
self.assertEqual(services['helper']['healthcheck']['port'], 3000)
self.assertEqual(services['helper']['healthcheck']['type'], 'tcp')
self.assertNotIn('frontend', services['helper'])
self.assertTrue(services['web']['frontend'])
self.assertEqual(services['web']['healthcheck']['endpoint']['port'], 80)
def test_service_healthy_does_not_invent_an_http_or_tcp_health_contract(self):
document = COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}')
template = template_from_compose(document)
self.assertFalse(template['compatibility']['automatic_install_candidate'])
self.assertTrue(any('health and persistence' in error for error in assess(template)))
checked = document + " healthcheck: {test: ['CMD', 'test', '-f', '/ready']}\n"
self.assertEqual(assess(template_from_compose(checked)), [])
def test_worker_exec_check_is_preserved_without_exposing_a_port(self):
document = COMPOSE.replace(" expose: ['3000']", " healthcheck:\n test: ['CMD', 'test', '-f', '/ready']\n interval: 10s\n timeout: 2s\n start_period: 20s\n retries: 3")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
services = {service['name']: service for service in build(template)['services']}
check = services['helper']['healthcheck']
self.assertEqual(check['type'], 'exec')
self.assertEqual(check['argv'], ['test', '-f', '/ready'])
self.assertEqual(check['timeout_seconds'], 56)
self.assertNotIn('frontend', services['helper'])
def test_explicit_check_takes_precedence_over_inferred_internal_http(self):
document = COMPOSE + " healthcheck: {test: ['CMD-SHELL', 'test -f /ready && test -f /worker-ready']}\n"
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck']['type'], 'exec')
self.assertEqual(helper['healthcheck']['argv'], ['sh', '-c', 'test -f /ready && test -f /worker-ready'])
def test_main_exec_check_retains_the_published_web_endpoint(self):
document = COMPOSE.replace(" ports: ['8080:80']", " ports: ['8080:80']\n healthcheck: {test: ['CMD', 'test', '-f', '/ready']}")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
web = next(service for service in build(template)['services'] if service['name'] == 'web')
self.assertEqual(web['healthcheck']['type'], 'exec')
self.assertEqual(web['healthcheck']['argv'], ['test', '-f', '/ready'])
self.assertEqual(web['healthcheck']['endpoint'], {'scheme': 'http', 'port': 80, 'path': '/'})
self.assertTrue(web['frontend'])
def test_main_dependency_is_still_rejected_before_allocation(self):
document = COMPOSE.replace(' depends_on: [helper]\n', '').replace(" expose: ['3000']", " expose: ['3000']\n depends_on: [web]")
template = template_from_compose(document)
self.assertFalse(template['compatibility']['automatic_install_candidate'])
self.assertTrue(any('main service' in error for error in assess(template)))
def test_disabled_check_without_endpoint_is_explicitly_running_only(self):
document = COMPOSE.replace(" expose: ['3000']", ' healthcheck: {disable: true}')
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck']['type'], 'running')
def test_invalid_healthcheck_or_runtime_environment_requirement_needs_review(self):
for check in ({'test': ['CMD-SHELL', 'test -n "$TOKEN"']},
{'test': ['CMD', '']}, {'test': ['CMD', 'a\nb']},
{'test': ['CMD', 'true'], 'interval': '0s'},
{'test': ['CMD', 'true'], 'retries': -1},
{'test': ['CMD', 'true'], 'start_period': '2h'},
{'test': ['CMD', 'true'], 'timeout': 'nonsense'}):
with self.subTest(check=check), self.assertRaises(StackError):
compose_healthcheck({'compose': {'healthcheck': check}})
def test_dockerhub_aliases_keep_the_existing_database_health_profiles(self):
for image, expected in [('docker.io/library/postgres:16', 'postgres'),
('docker.io/library/redis:7', 'redis'),
('docker.io/valkey/valkey:8', 'valkey/valkey'),
('index.docker.io/library/mariadb:11', 'mariadb')]:
with self.subTest(image=image):
self.assertEqual(kind(image), expected)
def test_custom_server_flags_preserve_native_health_check(self):
document = COMPOSE.replace('example/helper:1', 'docker.io/library/redis:7').replace(
" expose: ['3000']", " command: ['redis-server', '--appendonly', 'yes', '--maxmemory-policy', 'noeviction']")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['kind'], 'redis')
self.assertEqual(helper['healthcheck']['type'], 'exec')
self.assertIn('redis-cli', str(helper['healthcheck']['argv']))
def test_a_different_binary_or_unhandled_auth_does_not_use_default_database_readiness(self):
for command in ("['sh', '-c', 'redis-server']", "['redis-server', '--requirepass', 'secret']"):
document = COMPOSE.replace('example/helper:1', 'redis:7').replace(
" expose: ['3000']", ' command: ' + command)
template = template_from_compose(document)
self.assertTrue(any('custom dependency commands' in error for error in assess(template)))
def test_plain_worker_uses_running_only_when_dependency_requires_started(self):
template = template_from_compose(COMPOSE.replace(" expose: ['3000']", " command: ['worker', '--watch']"))
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck'], {'type': 'running', 'timeout_seconds': 30})
self.assertNotIn('frontend', helper)
self.assertEqual(helper['template']['proxmox']['installer_profile']['runtime']['command'], ['worker', '--watch'])
def test_running_only_worker_cannot_satisfy_service_healthy(self):
document = COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}').replace(" expose: ['3000']", " command: ['worker', '--watch']")
template = template_from_compose(document)
self.assertFalse(template['compatibility']['automatic_install_candidate'])
self.assertTrue(any('helper: service_healthy' in error for error in assess(template)))
def test_exposed_tcp_port_alone_is_not_a_declared_service_healthy_check(self):
template = template_from_compose(COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}'))
self.assertTrue(any('helper: service_healthy' in error for error in assess(template)))
def test_explicit_worker_check_can_satisfy_service_healthy(self):
document = COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}').replace(" expose: ['3000']", " healthcheck: {test: ['CMD', 'test', '-f', '/ready']}")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck']['type'], 'exec')
def test_option_only_database_command_requires_verified_entrypoint_and_keeps_argv(self):
command = ['--transaction-isolation=READ-COMMITTED', '--binlog-format=ROW']
document = COMPOSE.replace('example/helper:1', 'mariadb:11').replace(" expose: ['3000']", " command: ['--transaction-isolation=READ-COMMITTED', '--binlog-format=ROW']")
template = template_from_compose(document)
self.assertTrue(any('custom dependency commands' in error for error in assess(template)))
template = template_from_compose(document, image_entrypoints={'mariadb:11': ['docker-entrypoint.sh']})
self.assertTrue(template['compatibility']['automatic_install_candidate'])
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['template']['proxmox']['installer_profile']['runtime']['command'], command)
self.assertEqual(helper['healthcheck']['argv'], ['healthcheck.sh', '--connect', '--innodb_initialized'])
def test_literal_environment_preserves_all_explicit_dollar_values(self):
services = [{'name': 'helper', 'image': 'example/helper:1',
'compose': {'environment': {'PASSWORD': 'cash$VALUE${OTHER}', 'TOKEN': '${GENERATED_LOCAL_TOKEN}'}}}]
result = resolve_environments(services, 'UTC', literal=True)['helper']
self.assertEqual(result['PASSWORD'], 'cash$VALUE${OTHER}')
self.assertEqual(result['TOKEN'], '${GENERATED_LOCAL_TOKEN}')
with self.assertRaises(StackError):
resolve_environments(services, 'UTC')
def test_literal_explicit_empty_environment_clears_inherited_image_value(self):
document = COMPOSE.replace(" expose: ['3000']", " expose: ['3000']\n environment: {CLEAR_IMAGE_DEFAULT: ''}")
template = template_from_compose(document, interpolated=True)
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual({item['name']: item['value'] for item in helper['deployment']['environment']},
{'CLEAR_IMAGE_DEFAULT': ''})
template['proxmox']['custom_compose_interpolated'] = False
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['deployment']['environment'], [])
def test_literal_database_url_is_not_rebound_or_forced_to_have_a_password(self):
services = [
{'name': 'db', 'image': 'postgres:16', 'compose': {'environment': {'POSTGRES_USER': 'bootstrap', 'POSTGRES_HOST_AUTH_METHOD': 'trust'}}},
{'name': 'app', 'image': 'example/app:1', 'compose': {'environment': {'DATABASE_URL': 'postgresql://explicit-user@db/app'}}},
]
result = resolve_environments(services, 'UTC', literal=True)
self.assertEqual(result['app']['DATABASE_URL'], 'postgresql://explicit-user@db/app')
services[0]['compose']['environment']['POSTGRES_PASSWORD'] = 'bootstrap-password'
services[1]['compose']['environment']['DATABASE_URL'] = 'postgresql://other-user:explicit-password@db/app'
result = resolve_environments(services, 'UTC', literal=True)
self.assertEqual(result['app']['DATABASE_URL'], 'postgresql://other-user:explicit-password@db/app')
def test_fully_interpolated_custom_stack_does_not_reparse_literal_environment(self):
template = template_from_compose(COMPOSE.replace(" expose: ['3000']", " expose: ['3000']\n environment: {PASSWORD: 'cash$VALUE'}"))
template['proxmox']['custom_compose_interpolated'] = True
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['deployment']['environment'][0]['value'], 'cash$VALUE')
if __name__ == '__main__':
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Real cross-process contention at the late stack publication boundary."""
import contextlib
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_instances as instances
HOLDER = '''import fcntl,sys,time
with open(sys.argv[1], 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
print('locked', flush=True)
sys.stdin.readline()
'''
@contextlib.contextmanager
def competing_owner(root):
process = subprocess.Popen([sys.executable, '-u', '-c', HOLDER, str(root / '.lock')],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
try:
if process.stdout.readline().strip() != 'locked':
raise RuntimeError('Lock fixture did not acquire the lock')
yield process
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=5)
process.stdout.close()
process.stdin.close()
class InstanceLockWaitTests(unittest.TestCase):
def test_waiting_publication_succeeds_after_another_process_releases(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with competing_owner(root) as process:
def release():
process.stdin.write('release\n')
process.stdin.flush()
timer = threading.Timer(0.35, release)
start = time.monotonic()
timer.start()
try:
with instances.locked(root, timeout=2):
elapsed = time.monotonic() - start
self.assertGreaterEqual(elapsed, 0.2)
self.assertLess(elapsed, 2)
finally:
timer.cancel()
timer.join()
def test_default_remains_immediate_when_another_operation_owns_the_registry(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with competing_owner(root):
start = time.monotonic()
with self.assertRaises(BlockingIOError), instances.locked(root):
self.fail('Contended default lock was acquired')
self.assertLess(time.monotonic() - start, 0.5)
def test_wait_has_a_finite_timeout(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with competing_owner(root):
start = time.monotonic()
with self.assertRaises(BlockingIOError), instances.locked(root, timeout=0.1):
self.fail('Contended timed lock was acquired')
elapsed = time.monotonic() - start
self.assertGreaterEqual(elapsed, 0.08)
self.assertLess(elapsed, 0.6)
def test_error_inside_operation_is_propagated_without_repeating_it(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
entered = []
with self.assertRaises(BlockingIOError):
with instances.locked(root, timeout=2):
entered.append(True)
raise BlockingIOError('unrelated operation failure')
self.assertEqual(entered, [True])
with instances.locked(root):
pass
def test_permission_failure_is_not_retried(self):
with tempfile.TemporaryDirectory() as directory:
with patch.object(instances.fcntl, 'flock', side_effect=PermissionError('denied')), \
patch.object(instances.time, 'sleep') as sleep:
with self.assertRaises(PermissionError), instances.locked(Path(directory), timeout=2):
pass
sleep.assert_not_called()
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""Main readiness probes the same address the installation advertises."""
import copy
import ipaddress
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import install_generic_stack as installer
SERVICE = {'vmid':4860, 'main':True, 'name':'app', 'ip':'10.77.6.30',
'healthcheck':{'type':'http','timeout_seconds':360,
'endpoint':{'scheme':'http','port':80,'path':'/'}}}
class MainFrontendReadiness(unittest.TestCase):
def test_probe_and_persistent_health_url_use_advertised_lan_not_backend_ip(self):
service=copy.deepcopy(SERVICE)
with patch.object(installer,'run',return_value='10.77.6.30\n172.30.249.70\n'), \
patch.object(installer,'wait_web') as web:
url=installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
self.assertEqual(url,'http://172.30.249.70:80/')
self.assertEqual(service['healthcheck']['url'],url)
self.assertEqual(service['ip'],'10.77.6.30')
web.assert_called_once_with(service,url)
def test_waits_for_dhcp_frontend_without_probing_private_host(self):
service=copy.deepcopy(SERVICE)
with patch.object(installer,'lan_access_urls',side_effect=[RuntimeError('not yet'),[{'url':'http://192.0.2.7:80/'}]]), \
patch.object(installer.time,'sleep') as sleep, patch.object(installer,'wait_web') as web:
url=installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
self.assertEqual(url,'http://192.0.2.7:80/')
sleep.assert_called_once_with(1)
web.assert_called_once_with(service,url)
def test_no_frontend_address_never_falls_back_to_private_probe(self):
service=copy.deepcopy(SERVICE)
with patch.object(installer.time,'monotonic',side_effect=[0,1,121]), \
patch.object(installer.time,'sleep'), \
patch.object(installer,'lan_access_urls',side_effect=RuntimeError('not yet')), \
patch.object(installer,'wait_web') as web, self.assertRaisesRegex(RuntimeError,'No LAN address'):
installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
web.assert_not_called()
if __name__=='__main__':unittest.main()
@@ -149,6 +149,63 @@ class ResolveVolumeOwnerTests(unittest.TestCase):
igs.resolve_volume_owner(service, 'some-future-strategy')
class ImportedRuntimeOwnerTests(unittest.TestCase):
def test_numeric_imported_user_controls_fresh_app_volume_without_puid(self):
service = make_service()
service['vmid'] = 4820
with patch.object(igs, 'run', return_value='lxc.init.uid: 1000\nlxc.init.gid: 1000\n'):
owner = igs.runtime_volume_owner(service)
self.assertEqual(owner, (1000, 1000))
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', owner), (101000, 101000))
def test_explicit_zero_puid_and_volume_owner_override_the_imported_user(self):
service = make_service(environment=[{'name': 'PUID', 'value': '0'}, {'name': 'PGID', 'value': '2500'}])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', (1000, 1000)), (100000, 102500))
service['template']['proxmox']['installer_profile']['volume_owner'] = {'uid': 1234, 'gid': 2345}
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', (1000, 1000)), (101234, 102345))
with patch.object(igs, 'runtime_volume_owner', side_effect=AssertionError('mapped root must not inspect USER')):
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (100000, 100000))
def test_imported_root_is_not_replaced_by_a_guessed_app_user(self):
service = make_service()
service['vmid'] = 4820
service['observed'] = {'image': {'defaults': {'User': 'node'}}}
with patch.object(igs, 'run', return_value='lxc.init.uid: 0\nlxc.init.gid: 0\n'):
self.assertEqual(igs.runtime_volume_owner(service), (0, 0))
def test_named_image_user_and_group_resolve_from_mounted_rootfs(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / 'etc').mkdir()
(root / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/sh\nnode:x:1000:1000:Node:/home/node:/bin/sh\n')
(root / 'etc/group').write_text('root:x:0:\nnode:x:1000:\nshared:x:2500:\n')
service = make_service()
service['observed'] = {'image': {'defaults': {'User': 'node:shared'}}}
self.assertEqual(igs.runtime_volume_owner(service, root), (1000, 2500))
service['observed']['image']['defaults']['User'] = '1000'
self.assertEqual(igs.runtime_volume_owner(service, root), (1000, 1000))
def test_actual_compose_user_override_wins_over_image_user(self):
service = make_service(unprivileged=False)
service['vmid'] = 4820
service['observed'] = {'image': {'defaults': {'User': 'node'}}}
with patch.object(igs, 'run', return_value='lxc.init.uid: 2000\nlxc.init.gid: 3000\n'):
owner = igs.runtime_volume_owner(service)
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', owner), (2000, 3000))
def test_named_user_cannot_be_resolved_through_a_host_file_symlink(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'rootfs'
(root / 'etc').mkdir(parents=True)
outside = Path(tmp) / 'host-passwd'
outside.write_text('node:x:1000:1000:Node:/home/node:/bin/sh\n')
(root / 'etc/passwd').symlink_to(outside)
service = make_service()
service['observed'] = {'image': {'defaults': {'User': 'node'}}}
with self.assertRaises(RuntimeError):
igs.runtime_volume_owner(service, root)
class ApplyVolumePreparationTests(unittest.TestCase):
def _target(self, tmp, with_lost_found=True):
target = Path(tmp) / 'target'
+64
View File
@@ -0,0 +1,64 @@
"""Internal TCP readiness survives initial install, native lifecycle and restore."""
import copy
from pathlib import Path
import socket
import subprocess
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'remote'))
import install_generic_stack as installer
import oci_stack_native as native
import oci_restore_recovery as recovery
class TCPReadinessTests(unittest.TestCase):
def test_initial_runtime_probe_uses_allocated_private_address(self):
service={'ip':'10.77.20.31','healthcheck':{'type':'tcp','port':9998,'timeout_seconds':120}}
installer.assign_health_address(service)
self.assertEqual(service['healthcheck']['host'],'10.77.20.31')
self.assertNotIn('url',service['healthcheck'])
self.assertNotIn('frontend',service)
def test_native_health_probes_real_socket_without_http(self):
with socket.socket() as server:
server.bind(('127.0.0.1',0));server.listen()
port=server.getsockname()[1]
member={'vmid':4800,'deployment':{},'stack':{'deployment':{'services':[
{'vmid':4800,'name':'helper','healthcheck':{'type':'tcp','host':'127.0.0.1','port':port,'timeout_seconds':5}}]}}}
adapter=native.NativeAdapter(Path('/registry'),Path('/journal'),{'members':[member],'primary_vmid':4800})
with patch.object(adapter,'is_running',return_value=True), \
patch.object(native.member_tx.gpu_devices,'validate_runtime'), \
patch.object(native.member_tx,'run') as run:
adapter.healthcheck(4800)
run.assert_not_called()
def test_native_tcp_target_rejects_names_and_invalid_ports(self):
for host,port in [('example.com',3000),('127.0.0.1',True),('127.0.0.1',0),('127.0.0.1',65536),('127.0.0.1','3000')]:
with self.subTest(host=host,port=port),self.assertRaises(ValueError):
native.tcp_target({'host':host,'port':port})
def test_restore_retains_only_private_tcp_probes_with_valid_ports(self):
contract={'schema':1,'dependencies':[{'vmid':4801,'label':'helper',
'healthcheck':{'type':'tcp','host':'10.77.20.31','port':9998,'timeout_seconds':120}}]}
self.assertTrue(recovery.valid_contract(contract,{4801}))
for changes in ({'host':'192.168.1.1'},{'host':'localhost'},{'port':False},{'port':65536}):
invalid=copy.deepcopy(contract);invalid['dependencies'][0]['healthcheck'].update(changes)
self.assertFalse(recovery.valid_contract(invalid,{4801}))
def test_hook_tcp_probe_checks_connection_not_http_status(self):
text=(ROOT/'remote/stack_dependency_hook.sh').read_text()
functions=text[text.index('dependency_is_healthy() {'):text.index('\nstart_dependencies()')]
with socket.socket() as server:
server.bind(('127.0.0.1',0));server.listen()
port=server.getsockname()[1]
command=functions+'\ndependency_is_healthy 4801 \'{"type":"tcp","host":"127.0.0.1","port":'+str(port)+'}\''
result=subprocess.run(['bash','-c',command],text=True,capture_output=True)
self.assertEqual(result.returncode,0,result.stderr)
failed=subprocess.run(['bash','-c',command],text=True,capture_output=True)
self.assertNotEqual(failed.returncode,0)
if __name__=='__main__':unittest.main()
+13
View File
@@ -351,6 +351,19 @@ class Switch(unittest.TestCase):
return {int(path.parent.name): json.loads(path.read_text())["deployment"].get("watchdog")
for path in root.glob("*/oci-compose.json")}
def test_completed_jobs_never_receive_or_use_watchdog(self):
with tempfile.TemporaryDirectory() as folder:
root=self.registry(folder)
path=root/'102'/'oci-compose.json'
record=json.loads(path.read_text())
record['deployment'].update(execution_mode='one-shot',watchdog=True)
path.write_text(json.dumps(record))
self.assertNotIn(102,watchdog.watched(root))
with patch.object(watchdog,'ensure_service'),patch('oci_carried_record.carry'):
watchdog.set_watchdog(root,101,True)
self.assertEqual(self.flags(root)[101],True)
self.assertEqual(self.flags(root)[102],False)
def test_every_container_of_the_application_takes_the_choice_and_keeps_the_rest_of_its_record(self):
from unittest.mock import patch
import oci_carried_record