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

This commit is contained in:
Jonatan
2026-10-28 08:41:41 +01:00
parent b383fb9311
commit 3a0a7eaf14
44 changed files with 3267 additions and 110 deletions
+132
View File
@@ -0,0 +1,132 @@
"""Compose inputs are explicit, deterministic, and never taken from the host."""
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.compose_input import Interpolator, dotenv, prepare, resolve_document
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.custom import template_from_compose, _dump
class ComposeInputs(unittest.TestCase):
def test_default_required_alternative_nested_and_escaped_dollar(self):
expand = Interpolator({'EMPTY': '', 'SET': 'value'}).expand
for source, expected in [
('${MISSING:-fallback}', 'fallback'), ('${EMPTY:-fallback}', 'fallback'),
('${EMPTY-fallback}', ''), ('${SET:+yes}', 'yes'), ('${MISSING+yes}', ''),
('${EMPTY+yes}', 'yes'), ('${MISSING:-${SET}}', 'value'),
('$$HOME $SET ${SET}', '$HOME value value'), ('$5', '$5')]:
with self.subTest(source=source):
self.assertEqual(expand(source), expected)
for source in ['${MISSING?needed}', '${EMPTY:?needed}', '${NO}', '${BAD', '${X:=bad}']:
with self.subTest(source=source), self.assertRaises(ConversionError):
expand(source)
def test_never_reads_host_environment(self):
with patch.dict(os.environ, {'COMPOSE_SECRET': 'must-not-inherit'}):
with self.assertRaises(ConversionError):
Interpolator().expand('${COMPOSE_SECRET}')
def test_dotenv_quotes_comments_interpolation_and_no_shell_execution(self):
parsed = dotenv('''A=hello
B="${A} world" # note
C='literal$NO'
D=unquoted # comment
E=$(touch /tmp/should-not-exist)
F=
G
''', Interpolator())
self.assertEqual(parsed['B'], 'hello world')
self.assertEqual(parsed['C'], 'literal$NO')
self.assertEqual(parsed['D'], 'unquoted')
self.assertEqual(parsed['E'], '$(touch /tmp/should-not-exist)')
self.assertEqual(parsed['F'], '')
self.assertIsNone(parsed['G'])
for text in ['NOT A KEY=value', 'A="x" junk', "A='unterminated"]:
with self.subTest(text=text), self.assertRaises(ConversionError):
dotenv(text, Interpolator())
def test_file_merge_order_and_environment_override(self):
doc = {'services': {'app': {'image': 'example:${VERSION:-stable}',
'env_file': ['first.env', {'path':'optional.env', 'required':False}, 'second.env'],
'environment': {'A':'explicit', 'B':None}}}}
result = resolve_document(doc, {'B':'inherited-explicitly'},
{'first.env':'A=one\nB=first\nC=first', 'second.env':'C=second'})
self.assertEqual(result['services']['app']['image'], 'example:stable')
self.assertEqual(result['services']['app']['environment'],
{'A':'explicit', 'B':'inherited-explicitly', 'C':'second'})
self.assertNotIn('env_file', result['services']['app'])
self.assertIn('env_file', doc['services']['app'])
def test_unset_bare_variables_are_removed_and_optional_file_is_not_prompted(self):
class UI:
def ask(self, *args, **kwargs): raise AssertionError('Optional input must not be required')
document={'services': {'app': {'image':'example:1', 'env_file':[{'path':'missing.env','required':False}],
'environment':['UNSET', 'EMPTY=']}}}
with tempfile.TemporaryDirectory() as directory:
result=prepare(UI(),document,directory)
self.assertEqual(result['services']['app']['environment'],{'EMPTY':''})
def test_single_quoted_escape_and_dollar_value_are_literal(self):
self.assertEqual(dotenv("PASSWORD='it\\'s$literal'", Interpolator())['PASSWORD'],"it's$literal")
def test_selinux_mount_labels_are_normalized_with_explicit_host_guard_metadata(self):
class UI:
def ask(self,*args,**kwargs): raise AssertionError('No input required')
doc={'services':{'app':{'image':'example:1','volumes':['shared:/data:ro,z']},
'worker':{'image':'example:1','volumes':['shared:/data:Z']}}}
metadata={}
result=prepare(UI(),doc,metadata=metadata)
self.assertTrue(metadata['selinux_labels'])
self.assertEqual(result['services']['app']['volumes'],[{'source':'shared','target':'/data','read_only':True}])
self.assertEqual(result['services']['worker']['volumes'][0]['read_only'],False)
template=template_from_compose(_dump({'services':{'app':{'image':'adminer:4','ports':['8080:8080']},
'db':{'image':'postgres:16','environment':{'POSTGRES_PASSWORD':'test-only'}}}}),
interpolated=True,input_metadata=metadata)
self.assertTrue(template['proxmox']['custom_compose_selinux_labels'])
def test_literal_secret_is_not_expanded_again_by_stack_compiler(self):
document = {'services': {'db': {'image':'postgres:16',
'environment':{'POSTGRES_PASSWORD':'${PASSWORD}'}},
'app':{'image':'adminer:4','ports':['8080:8080'],
'environment':{'DATABASE_URL':'postgres://user:${PASSWORD}@db/test'}}}}
resolved = resolve_document(document, {'PASSWORD':'literal$PASSWORD${ALSO_LITERAL}'})
template = template_from_compose(_dump(resolved), interpolated=True)
self.assertTrue(template['compatibility']['automatic_install_candidate'], template['compatibility'])
db = next(s for s in template['compose_stack']['services'] if s['name']=='db')
self.assertEqual(db['compose']['environment']['POSTGRES_PASSWORD'], 'literal$PASSWORD${ALSO_LITERAL}')
def test_companion_dotenv_and_env_file_read_from_explicit_source_directory(self):
class UI:
def ask(self, *args, **kwargs):
raise AssertionError('No extra input needed for supplied companion files')
password = ask
with tempfile.TemporaryDirectory() as directory:
Path(directory, '.env').write_text('VERSION=16\nPASSWORD=test-only\n')
Path(directory, 'db.env').write_text('POSTGRES_PASSWORD=${PASSWORD}\n')
doc = {'services':{'db':{'image':'postgres:${VERSION}', 'env_file':'db.env'},
'app':{'image':'adminer:4','ports':['8080:8080']}}}
result = prepare(UI(), doc, directory)
self.assertEqual(result['services']['db']['image'], 'postgres:16')
self.assertEqual(result['services']['db']['environment']['POSTGRES_PASSWORD'], 'test-only')
def test_missing_values_are_asked_once_and_secrets_use_password_input(self):
class UI:
def __init__(self): self.prompts=[]
def ask(self, prompt, default='', **kwargs):
return ''
def password(self, prompt, **kwargs):
self.prompts.append(prompt); return 'synthetic-secret'
ui=UI()
doc={'services':{'one':{'image':'example:1','environment':{'PASSWORD':'${PASSWORD}'}},
'two':{'image':'example:2','environment':{'PASSWORD':'${PASSWORD}'}}}}
result=prepare(ui,doc)
self.assertEqual(len(ui.prompts),1)
self.assertEqual(result['services']['one']['environment'],result['services']['two']['environment'])
if __name__ == '__main__': unittest.main()