mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
133 lines
7.3 KiB
Python
133 lines
7.3 KiB
Python
"""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()
|