mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
feat(oci): support custom Compose workers and migration jobs
This commit is contained in:
@@ -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()
|
||||
@@ -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'"))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user