mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts: # .github/workflows/test-oci-registries.yml
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""A fast Manage entry still detects config edits and verifies lifecycle work."""
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
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_carried_record as carried
|
||||
|
||||
|
||||
class CarriedConfigCacheTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'instances'
|
||||
self.source = Path(self.tmp.name) / 'lxc'
|
||||
self.source.mkdir()
|
||||
self.config = self.source / '120.conf'
|
||||
self.config.write_text('# proxmenux-instance=example\nunprivileged: 1\n')
|
||||
(self.root / '120').mkdir(parents=True)
|
||||
self.cache = self.root / '120' / carried.CONFIG_CACHE
|
||||
self.patch = patch.object(carried, 'LOCAL_CONFIGS', self.source)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def response(self, text='description: proxmenux-instance=example\nunprivileged: 1\n', status=0):
|
||||
return SimpleNamespace(returncode=status, stdout=text)
|
||||
|
||||
def test_warm_reads_avoid_pct_and_cache_is_private(self):
|
||||
with patch.object(carried, '_run', return_value=self.response()) as run:
|
||||
first = carried.configuration(self.root, 120)
|
||||
self.assertEqual(carried.configuration(self.root, 120), first)
|
||||
run.assert_called_once_with('pct', 'config', '120')
|
||||
self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_native_edits_invalidate_even_when_size_and_mtime_are_restored(self):
|
||||
with patch.object(carried, '_run', side_effect=[self.response(), self.response('new')]) as run:
|
||||
carried.configuration(self.root, 120)
|
||||
info = self.config.stat()
|
||||
self.config.write_text(self.config.read_text().replace('example', 'changed'))
|
||||
os.utime(self.config, ns=(info.st_atime_ns, info.st_mtime_ns))
|
||||
self.assertEqual(carried.configuration(self.root, 120), 'new')
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
def test_verify_forces_a_fresh_pct_read_with_a_warm_cache(self):
|
||||
with patch.object(carried, '_run', side_effect=[self.response(), self.response('verified')]) as run:
|
||||
carried.configuration(self.root, 120)
|
||||
self.assertEqual(carried.configuration(self.root, 120, verify=True), 'verified')
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
def test_missing_native_source_never_uses_or_populates_a_cache(self):
|
||||
self.config.unlink()
|
||||
with patch.object(carried, '_run', return_value=self.response()) as run:
|
||||
carried.configuration(self.root, 120)
|
||||
carried.configuration(self.root, 120)
|
||||
self.assertEqual(run.call_count, 2)
|
||||
self.assertFalse(self.cache.exists())
|
||||
|
||||
def test_concurrent_edit_does_not_cache_output_for_the_wrong_revision(self):
|
||||
def command(*args):
|
||||
self.config.write_text('changed while pct was reading\n')
|
||||
return self.response()
|
||||
with patch.object(carried, '_run', side_effect=command):
|
||||
carried.configuration(self.root, 120)
|
||||
self.assertFalse(self.cache.exists())
|
||||
|
||||
def test_edit_during_cache_lookup_falls_back_to_pct(self):
|
||||
with patch.object(carried, '_run', return_value=self.response()):
|
||||
carried.configuration(self.root, 120)
|
||||
read_text = Path.read_text
|
||||
def edit_during_read(path, *args, **kwargs):
|
||||
text = read_text(path, *args, **kwargs)
|
||||
if path == self.cache:
|
||||
self.config.write_text('changed while reading the cache\n')
|
||||
return text
|
||||
with patch.object(Path, 'read_text', edit_during_read), \
|
||||
patch.object(carried, '_run', return_value=self.response('fresh')) as run:
|
||||
self.assertEqual(carried.configuration(self.root, 120), 'fresh')
|
||||
run.assert_called_once_with('pct', 'config', '120')
|
||||
|
||||
def test_corrupt_cache_and_pct_failure_are_not_accepted(self):
|
||||
for text in ('not json', '[]', '{"schema_version": 999}'):
|
||||
with self.subTest(cache=text):
|
||||
self.cache.write_text(text)
|
||||
with patch.object(carried, '_run', return_value=self.response(status=1)) as run:
|
||||
self.assertIsNone(carried.configuration(self.root, 120))
|
||||
run.assert_called_once()
|
||||
self.assertEqual(self.cache.read_text(), text)
|
||||
|
||||
def test_symlink_cache_does_not_read_or_replace_its_target(self):
|
||||
target = Path(self.tmp.name) / 'outside'
|
||||
target.write_text('private original')
|
||||
self.cache.symlink_to(target)
|
||||
with patch.object(carried, '_run', return_value=self.response()) as run:
|
||||
self.assertEqual(carried.configuration(self.root, 120), self.response().stdout)
|
||||
run.assert_called_once()
|
||||
self.assertTrue(self.cache.is_symlink())
|
||||
self.assertEqual(target.read_text(), 'private original')
|
||||
|
||||
def test_cached_config_still_detects_a_restored_container_record(self):
|
||||
installation = '488ed3cb-1145-477a-b90f-c46777d69fb2'
|
||||
record = {'schema_version': 1, 'vmid': 120, 'installation_id': installation,
|
||||
'status': 'installed', 'deployment': {'hostname': 'current'}}
|
||||
carried.instances.write(carried.instances.location(self.root, 120), record)
|
||||
native = f'description: proxmenux-instance={installation}\n'
|
||||
with patch.object(carried, '_run', return_value=self.response(native)):
|
||||
carried.configuration(self.root, 120)
|
||||
rootfs = Path(self.tmp.name) / 'rootfs'
|
||||
rootfs.mkdir()
|
||||
previous = copy.deepcopy(record)
|
||||
previous['deployment']['hostname'] = 'restored-snapshot'
|
||||
carried.write_copy(rootfs, {'kind': carried.KIND, 'schema_version': 1,
|
||||
'record': previous, 'generation': 'restored-generation'},
|
||||
(os.getuid(), os.getgid()))
|
||||
stamp = self.root / '120' / carried.STAMP
|
||||
stamp.write_text(json.dumps({'generation': 'host-generation'}))
|
||||
with patch.object(carried, '_run', side_effect=AssertionError('cached config expected')), \
|
||||
patch.object(carried, 'read_cluster', return_value=None), \
|
||||
patch.object(carried, 'running_pid', return_value=123), \
|
||||
patch.object(carried, 'container_root', return_value=contextlib.nullcontext(rootfs)):
|
||||
self.assertEqual(carried.carry(self.root, 120, mount_stopped=False), 'stale')
|
||||
self.assertFalse(carried.instances.location(self.root, 120).exists())
|
||||
self.assertTrue((self.root / '120' / f'retired-{installation}.json').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Escape delay is scoped to dialog and preserves terminal-specific overrides."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
|
||||
from proxmenux_oci.ui import DialogUI, UserCancelled
|
||||
|
||||
|
||||
class DialogEscapeTests(unittest.TestCase):
|
||||
def test_escape_uses_short_delay_without_changing_parent_environment(self):
|
||||
with patch.dict(os.environ, {'TERM': 'xterm'}, clear=True), \
|
||||
patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 255, stderr='')) as run:
|
||||
self.assertIsNone(DialogUI().choose('Menu', [('1', 'First')]))
|
||||
self.assertEqual(run.call_args.kwargs['env']['ESCDELAY'], '150')
|
||||
self.assertNotIn('ESCDELAY', os.environ)
|
||||
|
||||
def test_explicit_delay_is_preserved(self):
|
||||
with patch.dict(os.environ, {'ESCDELAY': '900'}), \
|
||||
patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 0, stderr='1')) as run:
|
||||
self.assertEqual(DialogUI().choose('Menu', [('1', 'First')]), '1')
|
||||
self.assertEqual(run.call_args.kwargs['env']['ESCDELAY'], '900')
|
||||
|
||||
def test_escape_in_search_input_keeps_cancellation_behavior(self):
|
||||
with patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 255, stderr='')):
|
||||
with self.assertRaises(UserCancelled):
|
||||
DialogUI().ask('Search')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Proxmox tags can change without preventing OCI management or being lost."""
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
|
||||
import oci_instance_reconcile as reconcile
|
||||
import oci_instance_transaction as transaction
|
||||
from oci_installation_state import sha
|
||||
|
||||
MARKER = '11111111-1111-4111-8111-111111111111'
|
||||
BASE = (f'description: proxmenux-instance={MARKER}\n'
|
||||
'rootfs: local-lvm:vm-200-disk-0,size=8G\n'
|
||||
'unprivileged: 1\ncores: 2\nmemory: 512\n'
|
||||
'net0: name=eth0,bridge=vmbr0,hwaddr=02:00:00:00:00:01,ip=dhcp\n'
|
||||
'net1: name=eth1,bridge=vmbr1,ip=dhcp\nstartup: order=20\n').encode()
|
||||
|
||||
|
||||
def config(tags):
|
||||
return BASE + (f'tags: {tags}\n'.encode() if tags is not None else b'')
|
||||
|
||||
|
||||
def record(tags='oci;proxmenux'):
|
||||
before = config(tags)
|
||||
return {
|
||||
'schema_version': 1, 'vmid': 200, 'status': 'installed',
|
||||
'installation_id': MARKER,
|
||||
'template': {'id': 'test-app', 'container_contract': {'volumes': []}},
|
||||
'deployment': {
|
||||
'vmid': 200, 'rootfs': {'storage': 'local-lvm', 'size_gb': 8},
|
||||
'network': {}, 'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'mounts': [], 'devices': [],
|
||||
},
|
||||
'observed': {
|
||||
'config': before.decode(), 'config_sha256': sha(before),
|
||||
'image': {'architecture': 'amd64', 'manifest_digest': 'sha256:old',
|
||||
'defaults': {'Volumes': {}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ExternalTagsTests(unittest.TestCase):
|
||||
def test_adding_editing_reordering_and_removing_tags_need_no_adoption(self):
|
||||
for old, new in ((None, 'homelab'), ('oci;proxmenux', 'homelab;oci;proxmenux'),
|
||||
('oci;proxmenux', 'proxmenux;oci'), ('oci;proxmenux', None),
|
||||
('oci;proxmenux', '')):
|
||||
with self.subTest(old=old, new=new):
|
||||
saved, current = record(old), config(new)
|
||||
self.assertIsNone(reconcile.propose(saved, current))
|
||||
self.assertEqual(transaction.external_changes(saved, current), {})
|
||||
|
||||
def test_tags_can_change_together_with_cpu_and_memory(self):
|
||||
current = config('homelab').replace(b'cores: 2', b'cores: 4').replace(b'memory: 512', b'memory: 2048')
|
||||
self.assertIsNone(reconcile.propose(record(), current))
|
||||
self.assertEqual(transaction.external_changes(record(), current), {'cores': 4, 'memory': 2048})
|
||||
|
||||
def test_tag_changes_do_not_bypass_identity_or_network_checks(self):
|
||||
for current in (config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222'),
|
||||
config('homelab').replace(b'bridge=vmbr0', b'bridge=vmbr2')):
|
||||
with self.subTest(config=current):
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record(), current)
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(record(), current)
|
||||
|
||||
def test_new_volume_still_requires_adoption_when_tags_also_change(self):
|
||||
saved = record()
|
||||
current = config('homelab') + b'mp0: local-lvm:vm-200-disk-1,mp=/data,size=8G,backup=1\n'
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
path = root / '200/oci-compose.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(saved))
|
||||
with patch.object(reconcile.instances, 'ROOT', root):
|
||||
proposal = reconcile.propose(saved, current)
|
||||
self.assertEqual(proposal['candidate']['deployment']['mounts'][0]['container_path'], '/data')
|
||||
self.assertEqual(len(proposal['details']), 1)
|
||||
self.assertEqual(json.loads(path.read_text()), saved)
|
||||
|
||||
|
||||
class Prepared(Exception):
|
||||
"""Stop a mocked transaction before it can stop or modify any container."""
|
||||
|
||||
|
||||
class TagReplayTests(unittest.TestCase):
|
||||
def prepare(self, folder, tags, operation, coordinated=None):
|
||||
current, saved = config(tags), record()
|
||||
archive = folder / 'image.tar'
|
||||
archive.write_bytes(b'fixture')
|
||||
states = []
|
||||
|
||||
def checkpoint(_path, state, phase):
|
||||
self.assertEqual(phase, 'prepared')
|
||||
states.append(copy.deepcopy(state))
|
||||
raise Prepared()
|
||||
|
||||
def command(*args):
|
||||
if args[:2] == ('pct', 'config'):
|
||||
return current
|
||||
if args[:3] == ('pvesh', 'get', '/cluster/ha/resources'):
|
||||
return b'[]'
|
||||
if args[:2] == ('pct', 'status'):
|
||||
return b'status: running'
|
||||
self.fail(f'Unexpected host command: {args}')
|
||||
|
||||
proposal = {'operation': 'recreate', 'candidate': copy.deepcopy(saved)} if operation == 'recreate' else None
|
||||
image = {'architecture': 'amd64', 'os': 'linux', 'manifest_digest': 'sha256:new', 'defaults': {}}
|
||||
with (patch.object(transaction.instances, 'read', return_value=saved),
|
||||
patch.object(transaction, 'run', side_effect=command),
|
||||
patch.object(transaction, 'check_archive'),
|
||||
patch.object(transaction, 'image_from_archive', return_value=image),
|
||||
patch.object(transaction.work_backup, 'locate', side_effect=lambda directory, *_: directory),
|
||||
patch.object(transaction, 'open_log'),
|
||||
patch.object(transaction, 'checkpoint', side_effect=checkpoint),
|
||||
redirect_stdout(io.StringIO()), self.assertRaises(Prepared)):
|
||||
transaction.apply(folder, 200, archive, operation, proposal=proposal, coordinated=coordinated)
|
||||
return states[0]
|
||||
|
||||
@unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed')
|
||||
def test_update_and_modify_replay_current_tags_including_no_tags(self):
|
||||
installer = (ROOT / 'remote/install_oci.sh').read_text()
|
||||
function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S)
|
||||
self.assertIsNotNone(function)
|
||||
for operation in ('update', 'recreate'):
|
||||
for tags in ('homelab;oci;proxmenux', 'oci', None, ''):
|
||||
with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name:
|
||||
folder = Path(name)
|
||||
state = self.prepare(folder, tags, operation)
|
||||
kept = state['runtime_deployment']['kept_proxmox_settings']
|
||||
deployment = folder / 'deployment.json'
|
||||
deployment.write_text(json.dumps({'kept_proxmox_settings': kept}))
|
||||
calls = folder / 'calls'
|
||||
script = '''
|
||||
pct() { printf '%s\\0' "$@" >> "$CALLS"; printf '\\n' >> "$CALLS"; }
|
||||
oci_quiet() { "$@"; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s\\n' "$*" >&2; exit 1; }
|
||||
'''
|
||||
done = subprocess.run(['bash', '-euc', script + function.group(0) + '\napply_kept_settings'],
|
||||
env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment), CALLS=str(calls)),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
commands = [line.split(b'\0')[:-1] for line in calls.read_bytes().splitlines()]
|
||||
expected = [b'set', b'200', b'--tags', tags.encode()] if tags else [b'set', b'200', b'--delete', b'tags']
|
||||
self.assertIn(expected, commands)
|
||||
self.assertIn([b'set', b'200', b'--net1', b'name=eth1,bridge=vmbr1,ip=dhcp'], commands)
|
||||
self.assertIn([b'set', b'200', b'--startup', b'order=20'], commands)
|
||||
|
||||
def test_stack_operations_also_preserve_tags_and_their_absence(self):
|
||||
for operation in ('update', 'recreate'):
|
||||
for tags in ('homelab;oci', None, ''):
|
||||
with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name:
|
||||
state = self.prepare(Path(name), tags, operation, coordinated={'stack': 'test'})
|
||||
self.assertEqual(state['preserved_stack_config']['tags'], tags or '')
|
||||
|
||||
@unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed')
|
||||
def test_native_stack_check_accepts_tag_edits_and_nothing_else(self):
|
||||
saved = record()
|
||||
instances = transaction.instances
|
||||
for new in ('homelab;oci;proxmenux', 'proxmenux;oci', '', None):
|
||||
with self.subTest(new=new):
|
||||
self.assertTrue(instances.same_config_except_notes(saved, config(new), ignore_tags=True))
|
||||
self.assertFalse(instances.same_config_except_notes(saved, config(new)))
|
||||
changed = config('homelab').replace(b'cores: 2', b'cores: 4')
|
||||
self.assertFalse(instances.same_config_except_notes(saved, changed, ignore_tags=True))
|
||||
other = config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222')
|
||||
self.assertFalse(instances.same_config_except_notes(saved, other, ignore_tags=True))
|
||||
source = (ROOT / 'remote/oci_stack_native.py').read_text()
|
||||
self.assertIn('same_config_except_notes(record, config, ignore_tags=True)', source)
|
||||
|
||||
def test_installer_still_refuses_unsupported_kept_settings(self):
|
||||
installer = (ROOT / 'remote/install_oci.sh').read_text()
|
||||
function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S).group(0)
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
deployment = Path(name) / 'deployment.json'
|
||||
deployment.write_text(json.dumps({'kept_proxmox_settings': {'unprivileged': '0'}}))
|
||||
script = '''
|
||||
oci_quiet() { echo 'must not run' >&2; exit 99; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s\\n' "$*" >&2; exit 1; }
|
||||
'''
|
||||
done = subprocess.run(['bash', '-euc', script + function + '\napply_kept_settings'],
|
||||
env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment)),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(done.returncode, 1)
|
||||
self.assertIn('Unsupported kept setting: unprivileged', done.stderr)
|
||||
self.assertNotIn('must not run', done.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""A web address guessed from the compose file may belong to a service that does
|
||||
not speak HTTP. Updates, Modify and recovery must accept its answer instead of
|
||||
waiting for an HTTP reply that never comes."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
|
||||
import oci_instance_transaction as transaction
|
||||
|
||||
INSTALLER = ROOT / 'remote/install_oci.sh'
|
||||
GUESSED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 3306, 'path': '/',
|
||||
'source': 'compose-first-tcp-port-fallback'}]}}
|
||||
DECLARED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 8080, 'path': '/'}]}}
|
||||
|
||||
|
||||
def probe_function():
|
||||
source = INSTALLER.read_text()
|
||||
start = source.index('healthcheck_probe() {')
|
||||
end = source.index('\n}\n', start) + 3
|
||||
return source[start:end]
|
||||
|
||||
|
||||
class GuessedEndpointCheckTests(unittest.TestCase):
|
||||
def test_only_a_guessed_endpoint_accepts_other_protocols(self):
|
||||
self.assertTrue(transaction.effective_healthcheck(GUESSED)['accept_non_http'])
|
||||
self.assertNotIn('accept_non_http', transaction.effective_healthcheck(DECLARED))
|
||||
|
||||
def run_probe(self, exit_code, code='000', accept_non_http='true', any_status='true'):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake = Path(directory) / 'curl'
|
||||
fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n')
|
||||
fake.chmod(0o755)
|
||||
script = (probe_function() + '\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n'
|
||||
f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n')
|
||||
env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'}
|
||||
return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode
|
||||
|
||||
def test_installer_probe_accepts_a_non_http_answer_only_when_guessed(self):
|
||||
for status in transaction.NON_HTTP_ANSWER:
|
||||
with self.subTest(status=status):
|
||||
self.assertEqual(self.run_probe(status), 0)
|
||||
self.assertNotEqual(self.run_probe(status, accept_non_http='false'), 0)
|
||||
# A closed port, a timeout or a server error still fail.
|
||||
for status, code in ((7, '000'), (28, '000'), (0, '503')):
|
||||
with self.subTest(status=status, code=code):
|
||||
self.assertNotEqual(self.run_probe(status, code), 0)
|
||||
self.assertEqual(self.run_probe(0, '200'), 0)
|
||||
self.assertEqual(self.run_probe(0, any_status='false', accept_non_http='false'), 0)
|
||||
|
||||
def test_recovery_check_accepts_a_database_answer(self):
|
||||
check = transaction.effective_healthcheck(GUESSED)
|
||||
check.update(timeout_seconds=6, stability_seconds=0)
|
||||
template = {'proxmox': {'installer_profile': {'startup_healthcheck': check}}}
|
||||
|
||||
def run(*args):
|
||||
if args[:2] == ('pct', 'status'):
|
||||
return b'status: running\n'
|
||||
if args[:2] == ('pct', 'config'):
|
||||
return b''
|
||||
if args[0] == 'lxc-info':
|
||||
return b'192.0.2.10\n'
|
||||
raise AssertionError(args)
|
||||
|
||||
with patch.object(transaction, 'run', side_effect=run), \
|
||||
patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \
|
||||
patch.object(transaction, 'log'):
|
||||
self.assertEqual(transaction.healthcheck(113, template), 'http://192.0.2.10:3306/')
|
||||
declared = transaction.effective_healthcheck(DECLARED)
|
||||
declared.update(timeout_seconds=4, stability_seconds=0)
|
||||
template = {'proxmox': {'installer_profile': {'startup_healthcheck': declared}}}
|
||||
with patch.object(transaction, 'run', side_effect=run), \
|
||||
patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \
|
||||
patch.object(transaction, 'log'), patch.object(transaction.time, 'sleep'):
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.healthcheck(113, template)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Opening Manage shows the sync notice only while some configuration is still unread."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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 import management
|
||||
import oci_carried_record as carried
|
||||
|
||||
|
||||
class ManageSyncNoticeTests(unittest.TestCase):
|
||||
def test_notice_only_while_an_installed_application_has_no_cache(self):
|
||||
rows = [{'vmid': 101, 'status': 'installed', 'pending': False},
|
||||
{'vmid': 102, 'status': 'installed', 'pending': True},
|
||||
{'vmid': 103, 'status': 'failed', 'pending': False}]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with patch.object(carried.instances, 'ROOT', root), \
|
||||
patch.object(management, 'saved_inventory', return_value=rows):
|
||||
self.assertTrue(management._records_unread(ROOT))
|
||||
cache = carried.instances.location(root, 101).parent / carried.CONFIG_CACHE
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text('{}')
|
||||
# The pending and failed applications are never cached by the sync.
|
||||
self.assertFalse(management._records_unread(ROOT))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Menu speedups avoid unused probes, retaining confirmation and capacity checks."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock,patch
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'remote')]
|
||||
from proxmenux_oci import management as menu
|
||||
import oci_instances as instances
|
||||
import oci_instance_reconcile as reconcile
|
||||
import oci_remove as remover
|
||||
import oci_work_backup as backup
|
||||
|
||||
|
||||
class UpdateEntryTests(unittest.TestCase):
|
||||
def run_update(self,approved,ready):
|
||||
events=[]
|
||||
record={'status':'installed','vmid':110,'deployment':{},'template':{}}
|
||||
row={'vmid':110,'reason':'matched','stack':False,'pending':False,'status':'installed'}
|
||||
ui=Mock();ui.unattended=True
|
||||
ui.review.side_effect=lambda *a,**kw:events.append('review') or approved
|
||||
with patch.object(menu,'carry_records',return_value={}) as carry, \
|
||||
patch.object(menu,'check_selected',return_value=row), \
|
||||
patch.object(menu,'_selected_configuration',return_value=b'current config'), \
|
||||
patch.object(instances,'read',return_value=record),patch.object(reconcile,'propose',return_value=None), \
|
||||
patch.object(menu,'_work_backup_ready',side_effect=lambda *a:events.append('backup') or ready), \
|
||||
patch.object(menu,'_run_lifecycle',side_effect=lambda *a:events.append('lifecycle') or True):
|
||||
result=menu.manage_instance(ROOT,ui,row,action='update')
|
||||
carry.assert_called_once_with(ROOT,vmids=[110],verify=True)
|
||||
return result,events
|
||||
|
||||
def test_declining_update_does_not_probe_disks_or_storages(self):
|
||||
result,events=self.run_update(False,False)
|
||||
self.assertFalse(result);self.assertEqual(events,['review'])
|
||||
|
||||
def test_accepted_update_checks_backup_space_before_starting(self):
|
||||
result,events=self.run_update(True,True)
|
||||
self.assertTrue(result);self.assertEqual(events,['review','backup','lifecycle'])
|
||||
|
||||
def test_insufficient_space_still_prevents_the_update(self):
|
||||
result,events=self.run_update(True,False)
|
||||
self.assertFalse(result);self.assertEqual(events,['review','backup'])
|
||||
|
||||
|
||||
class BackupDestinationTests(unittest.TestCase):
|
||||
def status(self,local_free,saved_free=None,only_needed=True):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);saved= root/'nas';saved.mkdir()
|
||||
record={'deployment':{backup.KEY:'nas'}}
|
||||
with patch.object(backup,'application',return_value=(110,[110,111])), \
|
||||
patch.object(instances,'read',return_value=record), \
|
||||
patch.object(backup,'required',return_value=100) as required, \
|
||||
patch.object(backup,'available',side_effect=lambda p:saved_free if Path(p)==saved else local_free), \
|
||||
patch.object(backup,'storage_path',return_value=saved if saved_free is not None else None) as path, \
|
||||
patch.object(backup,'file_storages',return_value=[{'storage':'enough','free':200},{'storage':'small','free':50}]) as alternatives:
|
||||
result=backup.status(root,110,only_needed=only_needed)
|
||||
required.assert_called_once_with([110,111])
|
||||
return result,path.call_count,alternatives.call_count
|
||||
|
||||
def test_local_room_avoids_saved_storage_and_alternative_queries(self):
|
||||
result,saved,alternatives=self.status(200,saved_free=1000)
|
||||
self.assertTrue(result['fits']);self.assertEqual((saved,alternatives),(0,0))
|
||||
|
||||
def test_saved_destination_that_fits_avoids_alternative_queries(self):
|
||||
result,saved,alternatives=self.status(50,saved_free=200)
|
||||
self.assertTrue(result['saved_fits']);self.assertEqual((saved,alternatives),(1,0))
|
||||
|
||||
def test_when_neither_fits_all_available_destinations_are_checked(self):
|
||||
result,saved,alternatives=self.status(50,saved_free=40)
|
||||
self.assertFalse(result['fits']);self.assertFalse(result['saved_fits'])
|
||||
self.assertEqual(result['candidates'],[{'storage':'enough','free':200}])
|
||||
self.assertEqual(alternatives,1)
|
||||
|
||||
def test_missing_saved_storage_still_offers_alternatives(self):
|
||||
result,saved,alternatives=self.status(50)
|
||||
self.assertEqual(result['candidates'],[{'storage':'enough','free':200}])
|
||||
self.assertEqual(alternatives,1)
|
||||
|
||||
def test_complete_status_keeps_legacy_fields_and_all_candidates(self):
|
||||
result,saved,alternatives=self.status(200,saved_free=300,only_needed=False)
|
||||
self.assertTrue(result['fits']);self.assertTrue(result['saved_fits'])
|
||||
self.assertEqual((saved,alternatives),(1,1))
|
||||
self.assertEqual(result['candidates'],[{'storage':'enough','free':200}])
|
||||
|
||||
|
||||
class RemovalPreviewTests(unittest.TestCase):
|
||||
def preview(self,reader):
|
||||
record={'vmid':110,'deployment':{},'template':{'catalog_ui':{'title':'Example'}}}
|
||||
with patch.object(remover,'members_of',return_value=(110,record,[110])), \
|
||||
patch.object(instances,'read',return_value=record), \
|
||||
patch.object(menu,'_selected_configuration',side_effect=reader), \
|
||||
patch.object(remover,'guest_config',side_effect=AssertionError('Duplicate pct process')), \
|
||||
patch.object(remover,'host_directories',return_value=[]), \
|
||||
patch.object(remover,'private_bridge',return_value=None):
|
||||
return menu._removal_summary(ROOT,110)
|
||||
|
||||
def test_preview_reuses_verified_config_and_keeps_volume_and_host_path_details(self):
|
||||
config=b'hostname: example\nrootfs: local:subvol-110-disk-0,size=8G\nmp0: /mnt/media,mp=/media,backup=0\n'
|
||||
text=self.preview(lambda v:config)
|
||||
self.assertIn('8G',text);self.assertIn('/mnt/media',text)
|
||||
self.assertIn('not targeted for removal',text)
|
||||
|
||||
def test_missing_guest_can_still_be_reviewed_without_a_second_probe(self):
|
||||
text=self.preview(lambda v:(_ for _ in ()).throw(RuntimeError('guest missing')))
|
||||
self.assertIn('CT 110',text);self.assertIn('rootfs -',text)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Opening Modify avoids backup probes while preserving operation safeguards."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock,patch
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'remote')]
|
||||
from proxmenux_oci import management as menu,recreation
|
||||
import oci_carried_record as carried
|
||||
import oci_instances as instances
|
||||
import oci_instance_reconcile as reconcile
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
|
||||
def record():
|
||||
return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed',
|
||||
'observed': {'config_sha256': 'native-config-fingerprint'},
|
||||
'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'},
|
||||
'container_contract': {'image': {'reference': 'example/app:latest'}},
|
||||
'proxmox': {'installer_profile': {}}},
|
||||
'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None},
|
||||
'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [
|
||||
{'container_path': '/data', 'source': 'local-zfs',
|
||||
'type': 'managed-volume', 'size_gb': 8, 'backup': True}],
|
||||
'devices': [], 'security': {'unprivileged': True},
|
||||
'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret',
|
||||
'sensitive': True}]}}
|
||||
|
||||
|
||||
class SelectedConfigTests(unittest.TestCase):
|
||||
def test_valid_source_keyed_cache_avoids_another_pct_process(self):
|
||||
with patch.object(carried,'configuration',create=True,return_value='description: current\n') as reader, \
|
||||
patch.object(instances,'command') as command:
|
||||
self.assertEqual(menu._selected_configuration(115),b'description: current\n')
|
||||
reader.assert_called_once_with(instances.ROOT,115)
|
||||
command.assert_not_called()
|
||||
|
||||
def test_old_or_unavailable_cache_falls_back_to_pct(self):
|
||||
for reader in (None,Mock(return_value=None),Mock(return_value={}),Mock(side_effect=OSError('cache unavailable'))):
|
||||
with self.subTest(reader=reader),patch.object(carried,'configuration',reader,create=True), \
|
||||
patch.object(instances,'command',return_value=b'live config') as command:
|
||||
self.assertEqual(menu._selected_configuration(115),b'live config')
|
||||
command.assert_called_once_with('pct','config','115')
|
||||
|
||||
def test_identity_check_is_still_performed_on_the_selected_config(self):
|
||||
r=record();r['installation_id']='11111111-1111-4111-8111-111111111111'
|
||||
import contextlib
|
||||
with patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(instances,'read',return_value=r), \
|
||||
patch.object(menu,'_selected_configuration',return_value=b'description: another container\n'):
|
||||
self.assertEqual(menu.check_selected(ROOT,{'vmid':115})['reason'],'identity-unconfirmed')
|
||||
|
||||
|
||||
class EditorLatencyTests(unittest.TestCase):
|
||||
def run_editor(self,proposal=None,backup_ready=True,stale=False,adoption_error=False,approved=True,declined=False):
|
||||
events=[];saved=record()
|
||||
row={'vmid':115,'reason':'matched','stack':False,'pending':False,'status':'installed'}
|
||||
def edit(*a,**kw):
|
||||
events.append('editor')
|
||||
if declined:return None
|
||||
if proposal is None:raise UserCancelled()
|
||||
return proposal
|
||||
def backup(*a,**kw):events.append('backup');return backup_ready
|
||||
ui=Mock();ui.unattended=True;ui.review.return_value=approved
|
||||
with patch.object(menu,'carry_records',return_value={115:'stale'} if stale else {}) as verify, \
|
||||
patch.object(menu,'check_selected',return_value=row), \
|
||||
patch.object(menu,'_selected_configuration',return_value=b'current config'), \
|
||||
patch.object(instances,'read',return_value=saved), \
|
||||
patch.object(reconcile,'propose',side_effect=ValueError('external changes') if adoption_error else None,return_value=None), \
|
||||
patch.object(recreation,'edit_recreation',side_effect=edit), \
|
||||
patch.object(menu,'_work_backup_ready',side_effect=backup), \
|
||||
patch.object(menu,'_run_lifecycle',side_effect=lambda *a:events.append('lifecycle') or True):
|
||||
try:result=menu.manage_instance(ROOT,ui,row,action='modify')
|
||||
except UserCancelled:result=False
|
||||
verify.assert_called_once_with(ROOT,vmids=[115],verify=True)
|
||||
return result,events
|
||||
|
||||
def test_open_and_cancel_has_no_backup_or_storage_probes(self):
|
||||
result,events=self.run_editor()
|
||||
self.assertFalse(result);self.assertEqual(events,['editor'])
|
||||
|
||||
def test_declining_the_review_has_no_backup_or_storage_probes(self):
|
||||
# The review is part of the Modify menu: declining it and leaving the
|
||||
# menu returns no proposal, so no probe or operation follows.
|
||||
result,events=self.run_editor(declined=True)
|
||||
self.assertFalse(result);self.assertEqual(events,['editor'])
|
||||
|
||||
def test_confirmed_edit_keeps_backup_check_before_the_lifecycle(self):
|
||||
result,events=self.run_editor({'operation':'recreate','candidate':record()})
|
||||
self.assertTrue(result);self.assertEqual(events,['editor','backup','lifecycle'])
|
||||
|
||||
def test_missing_backup_space_still_prevents_the_lifecycle(self):
|
||||
result,events=self.run_editor({'operation':'recreate','candidate':record()},backup_ready=False)
|
||||
self.assertFalse(result);self.assertEqual(events,['editor','backup'])
|
||||
|
||||
def test_restored_or_conflicting_instance_cannot_enter_editor(self):
|
||||
for options in ({'stale':True},{'adoption_error':True}):
|
||||
result,events=self.run_editor(**options)
|
||||
self.assertFalse(result);self.assertEqual(events,[])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Selective OCI edits stay in a draft until reviewed, then use one transaction."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'src'))
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
|
||||
from proxmenux_oci import management, recreation
|
||||
from proxmenux_oci.ui import BackRequested, UserCancelled
|
||||
|
||||
|
||||
def record():
|
||||
return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed',
|
||||
'observed': {'config_sha256': 'native-config-fingerprint'},
|
||||
'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'},
|
||||
'container_contract': {'image': {'reference': 'example/app:latest'}},
|
||||
'proxmox': {'installer_profile': {}}},
|
||||
'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None},
|
||||
'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [
|
||||
{'container_path': '/data', 'source': 'local-zfs',
|
||||
'type': 'managed-volume', 'size_gb': 8, 'backup': True}],
|
||||
'devices': [], 'security': {'unprivileged': True},
|
||||
'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret',
|
||||
'sensitive': True}]}}
|
||||
|
||||
|
||||
class MenuUI:
|
||||
def __init__(self, choices, answers=(), reviews=(True,), confirms=()):
|
||||
self.choices, self.answers = iter(choices), iter(answers)
|
||||
self.reviews, self.confirms = iter(reviews), iter(confirms)
|
||||
self.events = []
|
||||
self.back_enabled = False
|
||||
|
||||
def choose(self, text, options, default=None, **kwargs):
|
||||
self.events.append(('menu', text, dict(options)))
|
||||
value = next(self.choices)
|
||||
if value is not None:
|
||||
assert value in dict(options), value
|
||||
return value
|
||||
|
||||
def ask(self, text, default=None, **kwargs):
|
||||
self.events.append(('ask', text, default))
|
||||
value = next(self.answers)
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return value
|
||||
|
||||
def confirm(self, text, default=False):
|
||||
self.events.append(('confirm', text))
|
||||
return next(self.confirms)
|
||||
|
||||
def review(self, text, *args, **kwargs):
|
||||
self.events.append(('review', text))
|
||||
return next(self.reviews)
|
||||
|
||||
def message(self, text, *args):
|
||||
self.events.append(('message', text))
|
||||
|
||||
|
||||
@patch('proxmenux_oci.i18n.language', return_value='en')
|
||||
class ModifyMenuTests(unittest.TestCase):
|
||||
def test_ram_only_does_not_ask_about_cpu_network_paths_devices_or_catalog(self, _language):
|
||||
original = record()
|
||||
saved = copy.deepcopy(original)
|
||||
ui = MenuUI(['memory', 'apply'], ['768'])
|
||||
with patch.object(recreation, 'refresh_template') as refresh:
|
||||
result = recreation.edit_recreation(original, ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 2, 'memory_mb': 768})
|
||||
self.assertEqual([e[1] for e in ui.events if e[0] == 'ask'], ['RAM in MiB'])
|
||||
self.assertEqual(result['base_config_sha256'], 'native-config-fingerprint')
|
||||
self.assertEqual(original, saved)
|
||||
refresh.assert_not_called()
|
||||
self.assertFalse(ui.back_enabled)
|
||||
|
||||
def test_several_sections_and_revisits_keep_the_current_draft(self, _language):
|
||||
ui = MenuUI(['memory', 'cores', 'memory', 'apply'], ['768', '3', '1024'])
|
||||
result = recreation.edit_recreation(record(), ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 1024})
|
||||
self.assertEqual([e[2] for e in ui.events if e[0] == 'ask'], ['512', '2', '768'])
|
||||
self.assertEqual(sum(e[0] == 'review' for e in ui.events), 1)
|
||||
|
||||
def test_declining_review_returns_to_menu_without_losing_edits(self, _language):
|
||||
ui = MenuUI(['cores', 'apply', 'memory', 'apply'], ['3', '768'], reviews=[False, True])
|
||||
result = recreation.edit_recreation(record(), ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768})
|
||||
self.assertEqual(sum(e[0] == 'review' for e in ui.events), 2)
|
||||
|
||||
def test_unchanged_apply_does_not_propose_recreating_the_guest(self, _language):
|
||||
ui = MenuUI(['memory', 'apply'], ['512'])
|
||||
self.assertIsNone(recreation.edit_recreation(record(), ui))
|
||||
self.assertFalse(any(e[0] == 'review' for e in ui.events))
|
||||
self.assertIn(('message', 'Nothing was changed.'), ui.events)
|
||||
|
||||
def test_discard_leaves_the_saved_record_untouched(self, _language):
|
||||
original = record()
|
||||
saved = copy.deepcopy(original)
|
||||
ui = MenuUI(['memory', 'discard'], ['768'], confirms=[True])
|
||||
self.assertIsNone(recreation.edit_recreation(original, ui))
|
||||
self.assertEqual(original, saved)
|
||||
self.assertFalse(any(e[0] == 'review' for e in ui.events))
|
||||
|
||||
def test_declining_discard_preserves_the_draft(self, _language):
|
||||
ui = MenuUI(['memory', None, 'apply'], ['768'], confirms=[False])
|
||||
result = recreation.edit_recreation(record(), ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources']['memory_mb'], 768)
|
||||
|
||||
def test_cancel_without_changes_asks_nothing_else(self, _language):
|
||||
ui = MenuUI([None])
|
||||
self.assertIsNone(recreation.edit_recreation(record(), ui))
|
||||
self.assertEqual(len(ui.events), 1)
|
||||
|
||||
def test_invalid_ram_is_retried_without_restarting_other_sections(self, _language):
|
||||
ui = MenuUI(['cores', 'memory', 'apply'], ['3', 'invalid', '64', '768'])
|
||||
result = recreation.edit_recreation(record(), ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768})
|
||||
self.assertEqual(sum(e[0] == 'message' for e in ui.events), 2)
|
||||
self.assertEqual(sum(e[:2] == ('ask', 'Cores') for e in ui.events), 1)
|
||||
|
||||
def test_back_from_first_field_returns_to_menu_and_keeps_other_edits(self, _language):
|
||||
ui = MenuUI(['cores', 'memory', 'apply'], ['3', BackRequested()])
|
||||
result = recreation.edit_recreation(record(), ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 512})
|
||||
self.assertFalse(ui.back_enabled)
|
||||
|
||||
def test_cancelling_a_partly_edited_section_rolls_back_only_that_section(self, _language):
|
||||
real_edit = recreation.edit_setting
|
||||
def edit(candidate, ui, setting):
|
||||
if setting == 'environment':
|
||||
candidate['deployment']['environment'][0]['value'] = 'partial-value'
|
||||
raise UserCancelled()
|
||||
real_edit(candidate, ui, setting)
|
||||
ui = MenuUI(['cores', 'environment', 'apply'], ['3'])
|
||||
with patch.object(recreation, 'edit_setting', side_effect=edit):
|
||||
result = recreation.edit_recreation(record(), ui)
|
||||
self.assertEqual(result['candidate']['deployment']['resources']['cores'], 3)
|
||||
self.assertEqual(result['candidate']['deployment']['environment'], record()['deployment']['environment'])
|
||||
|
||||
def test_menu_and_review_do_not_expose_a_saved_secret(self, _language):
|
||||
ui = MenuUI(['cores', 'apply'], ['3'])
|
||||
recreation.edit_recreation(record(), ui)
|
||||
self.assertNotIn('private-test-secret', json.dumps(ui.events))
|
||||
|
||||
def test_extra_paths_counts_only_paths_added_by_hand(self, _language):
|
||||
candidate = record()
|
||||
self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 0'))
|
||||
candidate['deployment']['mounts'].append({'container_path': '/media-extra', 'type': 'host-bind',
|
||||
'source': '/mnt/media', 'custom': True})
|
||||
self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 1'))
|
||||
|
||||
def test_optional_categories_follow_the_application_profile(self, _language):
|
||||
candidate = record()
|
||||
options = dict(recreation.modification_options(candidate))
|
||||
self.assertTrue({'cores', 'memory', 'network', 'paths', 'devices', 'environment', 'catalog', 'apply'} <= options.keys())
|
||||
self.assertFalse({'shares', 'acceleration', 'detector'} & options.keys())
|
||||
candidate['template']['proxmox']['installer_profile'] = {
|
||||
'shared_directories': {'base_path': '/shares'}, 'object_detector': {'devices': []},
|
||||
'hardware_acceleration': {'profiles': [{'id': 'intel', 'label': 'Intel GPU'}]}}
|
||||
candidate['deployment']['hardware_profile'] = 'intel'
|
||||
options = dict(recreation.modification_options(candidate))
|
||||
self.assertTrue({'shares', 'acceleration', 'detector'} <= options.keys())
|
||||
self.assertIn('Intel GPU', options['acceleration'])
|
||||
|
||||
|
||||
class ModifyDispatchTests(unittest.TestCase):
|
||||
def call_manager(self, proposal, lifecycle):
|
||||
import oci_instances
|
||||
import oci_instance_reconcile
|
||||
saved = record()
|
||||
row = {'vmid': 115, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
|
||||
with patch.object(management, 'carry_records', return_value={}), \
|
||||
patch.object(management, 'check_selected', return_value=row), \
|
||||
patch.object(management, '_work_backup_ready', return_value=True), \
|
||||
patch.object(oci_instances, 'read', return_value=saved), \
|
||||
patch.object(oci_instances, 'command', return_value='hostname: example'), \
|
||||
patch.object(oci_instance_reconcile, 'propose', return_value=None), \
|
||||
patch.object(recreation, 'edit_recreation', return_value=proposal), \
|
||||
patch.object(management, '_run_lifecycle', side_effect=lifecycle):
|
||||
return management.manage_instance(ROOT, Mock(), row, action='modify')
|
||||
|
||||
def test_cancel_does_not_start_a_lifecycle_transaction(self):
|
||||
lifecycle = Mock()
|
||||
self.assertFalse(self.call_manager(None, lifecycle))
|
||||
lifecycle.assert_not_called()
|
||||
|
||||
def test_apply_sends_one_private_proposal_to_the_existing_backend(self):
|
||||
proposal = {'operation': 'recreate', 'candidate': record(), 'base_config_sha256': 'fingerprint'}
|
||||
seen = []
|
||||
def lifecycle(command, title):
|
||||
path = Path(command[command.index('--proposal') + 1])
|
||||
seen.append(json.loads(path.read_text()))
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assertNotIn('private-test-secret', ' '.join(command))
|
||||
self.assertIn('oci_update_current.py', command[1])
|
||||
self.assertIn('--acknowledge-external-data', command)
|
||||
return True
|
||||
self.assertTrue(self.call_manager(proposal, lifecycle))
|
||||
self.assertEqual(seen, [proposal])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Run the installer's volume preparation loop against filesystem fixtures.
|
||||
|
||||
Write doubles reject every mutation of the protected directory, including
|
||||
lost+found removal. Writable paths still run real rm/chmod; chown is recorded
|
||||
without requiring root. These tests never invoke Proxmox or touch a container.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALLER = ROOT / "remote/install_oci.sh"
|
||||
|
||||
|
||||
def preparation_loop():
|
||||
source = INSTALLER.read_text()
|
||||
start = source.index(" if (( failed == 0 )) && [[ $(jq -r '.stack_managed")
|
||||
end = source.index("\n if (( failed == 0 && tls_count == 1 )); then", start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def preparation(target):
|
||||
return {
|
||||
"container_path": target,
|
||||
"remove_lost_found": True,
|
||||
"owner_strategy": "mapped-application-user",
|
||||
"mode": "0750",
|
||||
}
|
||||
|
||||
|
||||
class ReadOnlyVolumePreparationTests(unittest.TestCase):
|
||||
def run_case(self, mounts, *, target="/run/secrets", read_only=True,
|
||||
preparations=None):
|
||||
preparations = preparations if preparations is not None else [preparation(target)]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fixture = Path(directory)
|
||||
rootfs = fixture / "rootfs"
|
||||
for item in preparations:
|
||||
path = rootfs / item["container_path"].lstrip("/")
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "lost+found").mkdir(exist_ok=True)
|
||||
protected = rootfs / target.lstrip("/")
|
||||
protected.mkdir(parents=True, exist_ok=True)
|
||||
sentinel = protected / "sentinel"
|
||||
sentinel.write_bytes(b"existing secret\n")
|
||||
sentinel.chmod(0o600)
|
||||
protected.chmod(0o700)
|
||||
before = sentinel.stat()
|
||||
template = {"proxmox": {"installer_profile": {
|
||||
"volume_preparations": preparations,
|
||||
}}}
|
||||
(fixture / "template.json").write_text(json.dumps(template))
|
||||
(fixture / "deployment.json").write_text(json.dumps({"mounts": mounts}))
|
||||
script = r'''set -Eeuo pipefail
|
||||
rootfs="$FIXTURE/rootfs"
|
||||
TEMPLATE_FILE="$FIXTURE/template.json"
|
||||
DEPLOYMENT_FILE="$FIXTURE/deployment.json"
|
||||
HOST_BIND_UID=110001; HOST_BIND_GID=110001
|
||||
HOST_ROOT_UID=100000; HOST_ROOT_GID=100000
|
||||
failed=0; volumes_prepared=0
|
||||
oci_log() { printf 'log %s\n' "$*"; }
|
||||
write_op() {
|
||||
local operation=$1
|
||||
shift
|
||||
local path=${!#}
|
||||
printf 'WRITE %s %s\n' "$operation" "$path"
|
||||
if [[ -n $PROTECTED && ( $path == "$PROTECTED" || $path == "$PROTECTED/"* ) ]]; then
|
||||
printf 'Read-only file system\n' >&2
|
||||
return 1
|
||||
fi
|
||||
case "$operation" in
|
||||
rm|chmod) command "$operation" "$@" ;;
|
||||
chown) return 0 ;;
|
||||
esac
|
||||
}
|
||||
chown() { write_op chown "$@"; }
|
||||
chmod() { write_op chmod "$@"; }
|
||||
rm() { write_op rm "$@"; }
|
||||
''' + preparation_loop() + r'''
|
||||
printf 'failed=%s prepared=%s\n' "$failed" "$volumes_prepared"
|
||||
test "$failed" = 0
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True,
|
||||
env=dict(os.environ, FIXTURE=str(fixture),
|
||||
PROTECTED=str(protected) if read_only else ""),
|
||||
timeout=15,
|
||||
)
|
||||
self.assertEqual(sentinel.read_bytes(), b"existing secret\n")
|
||||
after = sentinel.stat()
|
||||
self.assertEqual(
|
||||
(before.st_ino, before.st_uid, before.st_gid, before.st_mode, before.st_mtime_ns),
|
||||
(after.st_ino, after.st_uid, after.st_gid, after.st_mode, after.st_mtime_ns),
|
||||
)
|
||||
if read_only:
|
||||
self.assertEqual(protected.stat().st_mode & 0o777, 0o700)
|
||||
self.assertTrue((protected / "lost+found").is_dir())
|
||||
elif "prepared=1" in result.stdout:
|
||||
self.assertEqual(protected.stat().st_mode & 0o777, 0o750)
|
||||
self.assertFalse((protected / "lost+found").exists())
|
||||
return result
|
||||
|
||||
def assert_skipped(self, result):
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
self.assertNotIn("WRITE ", result.stdout)
|
||||
self.assertIn("Skipping preparation of read-only volume", result.stdout)
|
||||
self.assertIn("prepared=0", result.stdout)
|
||||
|
||||
def assert_prepared(self, result):
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
for operation in ("rm", "chown", "chmod"):
|
||||
self.assertIn(f"WRITE {operation} ", result.stdout)
|
||||
self.assertIn("prepared=1", result.stdout)
|
||||
|
||||
def test_read_only_managed_and_bind_volumes_receive_no_writes(self):
|
||||
for mount_type in ("managed-volume", "host-bind"):
|
||||
with self.subTest(mount_type=mount_type):
|
||||
self.assert_skipped(self.run_case([
|
||||
{"container_path": "/run/secrets", "type": mount_type, "read_only": True},
|
||||
]))
|
||||
|
||||
def test_read_only_parent_protects_nested_preparation(self):
|
||||
self.assert_skipped(self.run_case([
|
||||
{"container_path": "/run", "read_only": True},
|
||||
]))
|
||||
|
||||
def test_writable_child_overrides_read_only_parent_regardless_of_order(self):
|
||||
mounts = [
|
||||
{"container_path": "/run", "read_only": True},
|
||||
{"container_path": "/run/secrets", "read_only": False},
|
||||
]
|
||||
for ordered in (mounts, mounts[::-1]):
|
||||
with self.subTest(mounts=ordered):
|
||||
self.assert_prepared(self.run_case(ordered, read_only=False))
|
||||
|
||||
def test_child_without_read_only_flag_overrides_read_only_parent(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/run", "read_only": True},
|
||||
{"container_path": "/run/secrets"},
|
||||
], read_only=False))
|
||||
|
||||
def test_read_only_child_overrides_writable_parent_regardless_of_order(self):
|
||||
mounts = [
|
||||
{"container_path": "/run/secrets", "read_only": True},
|
||||
{"container_path": "/run", "read_only": False},
|
||||
]
|
||||
for ordered in (mounts, mounts[::-1]):
|
||||
with self.subTest(mounts=ordered):
|
||||
self.assert_skipped(self.run_case(ordered))
|
||||
|
||||
def test_writable_volume_is_still_prepared(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/data", "read_only": False},
|
||||
], target="/data", read_only=False))
|
||||
|
||||
def test_missing_read_only_flag_defaults_to_writable(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/data"},
|
||||
], target="/data", read_only=False))
|
||||
|
||||
def test_unrelated_mount_with_similar_prefix_does_not_skip_preparation(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/run/secret", "read_only": True},
|
||||
], read_only=False))
|
||||
|
||||
def test_directory_outside_mounts_is_still_prepared(self):
|
||||
self.assert_prepared(self.run_case([], read_only=False))
|
||||
|
||||
def test_skipping_read_only_volume_does_not_skip_following_writable_volume(self):
|
||||
result = self.run_case([
|
||||
{"container_path": "/run/secrets", "read_only": True},
|
||||
{"container_path": "/data", "read_only": False},
|
||||
], preparations=[preparation("/run/secrets"), preparation("/data")])
|
||||
self.assert_prepared(result)
|
||||
self.assertIn("Skipping preparation of read-only volume: /run/secrets", result.stdout)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("WRITE "):
|
||||
self.assertTrue(line.endswith("/data") or line.endswith("/data/lost+found"), line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -31,14 +31,14 @@ class RuntimeHomeResolutionTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(oci_runtime.home_directory(passwd, 1001), "/srv/app")
|
||||
|
||||
def test_unknown_or_unsafe_home_is_not_invented(self):
|
||||
def test_unsafe_record_is_rejected_but_unknown_uid_defaults_to_root(self):
|
||||
passwd = self.passwd(
|
||||
"root:x:0:0:root:relative-home:/bin/sh\n"
|
||||
"app:x:1001:1001:App user::/sbin/nologin\n"
|
||||
)
|
||||
self.assertIsNone(oci_runtime.home_directory(passwd, 0))
|
||||
self.assertIsNone(oci_runtime.home_directory(passwd, 1001))
|
||||
self.assertIsNone(oci_runtime.home_directory(passwd, 1234))
|
||||
self.assertEqual(oci_runtime.home_directory(passwd, 1234), "/")
|
||||
|
||||
|
||||
class RuntimeHomeInstallerContractTests(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Exercise HOME resolution without reading image symlinks or changing guests."""
|
||||
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
import oci_runtime
|
||||
|
||||
|
||||
class HomeHardeningTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
(self.root / "etc").mkdir()
|
||||
self.passwd = self.root / "etc/passwd"
|
||||
self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\n", encoding="utf-8")
|
||||
|
||||
def cli(self, uid="1234"):
|
||||
output, error = io.StringIO(), io.StringIO()
|
||||
with patch.object(sys, "argv", ["oci_runtime.py", "--home", str(self.passwd), uid]), \
|
||||
redirect_stdout(output), redirect_stderr(error):
|
||||
status = oci_runtime.main()
|
||||
return status, output.getvalue(), error.getvalue()
|
||||
|
||||
def assert_rejected_without_read(self):
|
||||
with patch.object(os, "fdopen", side_effect=AssertionError("unsafe file read")):
|
||||
status, output, _ = self.cli()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(output, "")
|
||||
|
||||
def test_unknown_uid_uses_root_only_after_reading_a_regular_database(self):
|
||||
self.assertEqual(self.cli(), (0, "/\n", ""))
|
||||
|
||||
def test_empty_regular_database_uses_root(self):
|
||||
self.passwd.write_text("")
|
||||
self.assertEqual(self.cli(), (0, "/\n", ""))
|
||||
|
||||
def test_final_symlink_is_rejected_without_reading_its_target(self):
|
||||
target = self.root / "outside"
|
||||
target.write_text("app:x:1234:1234::/host-secret:/bin/sh\n")
|
||||
self.passwd.unlink()
|
||||
self.passwd.symlink_to(target)
|
||||
self.assert_rejected_without_read()
|
||||
self.assertEqual(target.read_text(), "app:x:1234:1234::/host-secret:/bin/sh\n")
|
||||
|
||||
def test_symlinked_etc_is_rejected_without_reading_its_target(self):
|
||||
directory = self.root / "etc"
|
||||
directory.rename(self.root / "outside-etc")
|
||||
directory.symlink_to(self.root / "outside-etc", target_is_directory=True)
|
||||
self.assert_rejected_without_read()
|
||||
self.assertEqual(self.cli("0")[:2], (1, ""))
|
||||
|
||||
def test_pinned_etc_does_not_follow_a_later_directory_replacement(self):
|
||||
original_open = os.open
|
||||
outside = self.root / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "passwd").write_text("root:x:0:0::/outside:/bin/sh\n")
|
||||
replaced = []
|
||||
def replace(path, flags, *args, **kwargs):
|
||||
if str(path) == "passwd":
|
||||
(self.root / "etc").rename(self.root / "original-etc")
|
||||
(self.root / "etc").symlink_to(outside, target_is_directory=True)
|
||||
replaced.append(True)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
with patch.object(os, "open", side_effect=replace):
|
||||
self.assertEqual(self.cli("0"), (0, "/root\n", ""))
|
||||
self.assertEqual(replaced, [True])
|
||||
|
||||
def test_directory_is_rejected_without_reading(self):
|
||||
self.passwd.unlink()
|
||||
self.passwd.mkdir()
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_fifo_is_rejected_without_reading_or_waiting_for_a_writer(self):
|
||||
self.passwd.unlink()
|
||||
os.mkfifo(self.passwd)
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-B", str(ROOT / "remote/oci_runtime.py"), "--home", str(self.passwd), "0"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
self.assertEqual(done.returncode, 1)
|
||||
self.assertEqual(done.stdout, "")
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_missing_database_does_not_masquerade_as_unknown_uid(self):
|
||||
self.passwd.unlink()
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_permission_failure_does_not_masquerade_as_unknown_uid(self):
|
||||
with patch.object(os, "open", side_effect=PermissionError("denied")):
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_invalid_encoding_does_not_produce_a_fallback(self):
|
||||
self.passwd.write_bytes(b"\xff\xfe")
|
||||
status, output, _ = self.cli()
|
||||
self.assertEqual((status, output), (1, ""))
|
||||
|
||||
def test_partial_matching_record_does_not_produce_a_fallback(self):
|
||||
self.passwd.write_text("app:x:1234:1234\n")
|
||||
self.assertEqual(self.cli()[:2], (1, ""))
|
||||
|
||||
def test_read_error_closes_both_descriptors_without_a_fallback(self):
|
||||
descriptors = []
|
||||
original_open = os.open
|
||||
def opened(*args, **kwargs):
|
||||
fd = original_open(*args, **kwargs)
|
||||
descriptors.append(fd)
|
||||
return fd
|
||||
with patch.object(os, "open", side_effect=opened), \
|
||||
patch.object(os, "fdopen", side_effect=OSError("read failed")):
|
||||
self.assertEqual(self.cli()[:2], (1, ""))
|
||||
self.assertEqual(len(descriptors), 2)
|
||||
for fd in descriptors:
|
||||
with self.assertRaises(OSError):
|
||||
os.fstat(fd)
|
||||
|
||||
def test_matching_unsafe_home_is_not_replaced_with_root(self):
|
||||
for value in ("", "relative", "/bad\tvalue", "/bad\x00value", "/bad\x7fvalue"):
|
||||
with self.subTest(value=repr(value)):
|
||||
self.passwd.write_text(f"app:x:1234:1234::{value}:/bin/sh\n")
|
||||
self.assertEqual(self.cli()[:2], (1, ""))
|
||||
|
||||
def test_invalid_uid_does_not_open_the_database(self):
|
||||
for uid in ("-1", "nobody"):
|
||||
with self.subTest(uid=uid), patch.object(os, "open") as opened:
|
||||
self.assertEqual(self.cli(uid)[:2], (1, ""))
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_symlink_replacement_between_check_and_open_is_not_read(self):
|
||||
original_open = os.open
|
||||
outside = self.root / "outside"
|
||||
outside.write_text("app:x:1234:1234::/outside:/bin/sh\n")
|
||||
def replace(path, flags, *args, **kwargs):
|
||||
if str(path) == "passwd":
|
||||
self.passwd.unlink()
|
||||
self.passwd.symlink_to(outside)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
with patch.object(os, "open", side_effect=replace):
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_fifo_replacement_between_check_and_open_is_not_read(self):
|
||||
original_open = os.open
|
||||
def replace(path, flags, *args, **kwargs):
|
||||
if str(path) == "passwd":
|
||||
self.passwd.unlink()
|
||||
os.mkfifo(self.passwd)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
with patch.object(os, "open", side_effect=replace):
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
|
||||
class HomeInstallerExecutionTests(unittest.TestCase):
|
||||
"""Run the real shell HOME function and Python CLI with fake mount boundaries."""
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
(self.root / "etc").mkdir()
|
||||
self.passwd = self.root / "etc/passwd"
|
||||
self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\napp:x:1001:1001::/srv/app:/bin/sh\n")
|
||||
self.conf = self.root / "guest.conf"
|
||||
self.trace = self.root / "trace"
|
||||
self.source = (ROOT / "remote/install_oci.sh").read_text()
|
||||
|
||||
def run_home(self, config="unrelated: keep\n", unmount_status=0, mount_status=0):
|
||||
self.conf.write_text(config)
|
||||
self.trace.write_text("")
|
||||
functions = []
|
||||
for name in ("ensure_runtime_home", "set_runtime_environment"):
|
||||
found = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", self.source, re.M | re.S)
|
||||
self.assertIsNotNone(found, name)
|
||||
functions.append(found.group(0))
|
||||
script = r'''
|
||||
set -euo pipefail
|
||||
CONF=$1
|
||||
FIXTURE_ROOT=$2
|
||||
TRACE=$3
|
||||
OCI_RUNTIME_RESOLVER=$4
|
||||
PYTHON=$5
|
||||
UNMOUNT_STATUS=$6
|
||||
MOUNT_STATUS=$7
|
||||
OCI_LOG="$FIXTURE_ROOT/resolver.log"
|
||||
VMID=123456789
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s\n' "$*" >&2; exit 1; }
|
||||
mount_ct_rootfs() {
|
||||
printf 'mount\n' >> "$TRACE"
|
||||
[ "$MOUNT_STATUS" = 0 ] || die 'mount failed'
|
||||
rootfs=$FIXTURE_ROOT
|
||||
passwd_file="$rootfs/etc/passwd"
|
||||
}
|
||||
oci_quiet() { printf '%s\n' "$*" >> "$TRACE"; return "$UNMOUNT_STATUS"; }
|
||||
python3() { printf 'resolve\n' >> "$TRACE"; command "$PYTHON" -B "$@"; }
|
||||
'''
|
||||
done = subprocess.run(
|
||||
["bash", "-c", script + "\n".join(functions) + "\nensure_runtime_home\nprintf 'continued\\n'\n",
|
||||
"bash", str(self.conf), str(self.root), str(self.trace), str(ROOT / "remote/oci_runtime.py"),
|
||||
sys.executable, str(unmount_status), str(mount_status)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return done, self.conf.read_text(), self.trace.read_text().splitlines()
|
||||
|
||||
def test_explicit_home_including_empty_never_mounts_or_resolves(self):
|
||||
for home in ("/custom", ""):
|
||||
config = f"unrelated: keep\nlxc.environment.runtime: HOME={home}\n"
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual((done.returncode, after, trace), (0, config, []))
|
||||
|
||||
def test_default_and_effective_uid_write_the_resolved_home_after_unmount(self):
|
||||
for uid, expected in ((None, "/root"), (1001, "/srv/app"), (9999, "/")):
|
||||
config = "unrelated: keep\n" + (f"lxc.init.uid: {uid}\n" if uid is not None else "")
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
self.assertEqual(after, config + f"lxc.environment.runtime: HOME={expected}\n")
|
||||
self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"])
|
||||
|
||||
def test_invalid_uid_leaves_config_unchanged_without_mount(self):
|
||||
config = "lxc.init.uid: not-a-uid\n"
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual((done.returncode, after, trace), (0, config, []))
|
||||
|
||||
def test_rejected_input_unmounts_and_preserves_config(self):
|
||||
self.passwd.unlink()
|
||||
self.passwd.symlink_to(self.root / "missing")
|
||||
config = "unrelated: keep\n"
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual((done.returncode, after), (0, config))
|
||||
self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"])
|
||||
|
||||
def test_failed_unmount_stops_before_config_write_for_success_and_failure(self):
|
||||
for valid in (True, False):
|
||||
if not valid:
|
||||
self.passwd.unlink()
|
||||
config = "unrelated: keep\n"
|
||||
done, after, trace = self.run_home(config, unmount_status=1)
|
||||
self.assertNotEqual(done.returncode, 0)
|
||||
self.assertEqual(after, config)
|
||||
self.assertNotIn("continued", done.stdout)
|
||||
self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"])
|
||||
|
||||
def test_failed_mount_never_reads_or_writes(self):
|
||||
config = "unrelated: keep\n"
|
||||
done, after, trace = self.run_home(config, mount_status=1)
|
||||
self.assertNotEqual(done.returncode, 0)
|
||||
self.assertEqual(after, config)
|
||||
self.assertEqual(trace, ["mount"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -43,15 +43,18 @@ class ScriptedUI:
|
||||
def password(self, text, required=True):
|
||||
return self._answer(text, "")
|
||||
|
||||
def choose(self, text, options, default=None):
|
||||
def choose(self, text, options, default=None, **_kwargs):
|
||||
return self._answer(text, default)
|
||||
|
||||
def confirm(self, text, default=False):
|
||||
return self._answer(text, default)
|
||||
|
||||
def message(self, text):
|
||||
def message(self, text, *_args):
|
||||
pass
|
||||
|
||||
def review(self, *_args, **_kwargs):
|
||||
return True
|
||||
|
||||
|
||||
@patch("proxmenux_oci.i18n.language", return_value="en")
|
||||
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
|
||||
@@ -223,14 +226,15 @@ class SharedDirectoriesTests(unittest.TestCase):
|
||||
patch("proxmenux_oci.recreation.edit_acceleration"), \
|
||||
patch("proxmenux_oci.recreation.edit_environment"):
|
||||
ui = ScriptedUI({"Add a directory to share": [True], NAME: ["photos"],
|
||||
"Choose a setting. Changes are applied only after the final review.": ["shares", "apply"],
|
||||
"Where to store /shares/photos": ["host-bind"],
|
||||
"Host path for /shares/photos": ["/tank/photos"]})
|
||||
proposal = edit_recreation(record, ui)
|
||||
mounts = [m["container_path"] for m in proposal["candidate"]["deployment"]["mounts"]]
|
||||
self.assertEqual(mounts, ["/config", "/shares/media", "/shares/photos"])
|
||||
untouched = edit_recreation(record, ScriptedUI())
|
||||
self.assertEqual([m["container_path"] for m in untouched["candidate"]["deployment"]["mounts"]],
|
||||
["/config", "/shares/media"])
|
||||
untouched = edit_recreation(record, ScriptedUI({
|
||||
"Choose a setting. Changes are applied only after the final review.": ["discard"]}))
|
||||
self.assertIsNone(untouched)
|
||||
self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user