183 lines
8.6 KiB
Python
183 lines
8.6 KiB
Python
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
from actions.config import env
|
|
from engine.descriptor import build_parser
|
|
from engine.lib.host import Host
|
|
|
|
|
|
class TestEnv(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmpdir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmpdir.cleanup)
|
|
self.core = Path(self.tmpdir.name) / 'compositions' / 'apex'
|
|
self.core.mkdir(parents=True)
|
|
self.ctx = MagicMock()
|
|
self.ctx.paths.compositions = str(self.core.parent)
|
|
self.ctx.vars.return_value = {
|
|
'APEX_SUBNET': '198.18.0.0/24',
|
|
'APEX_NODE_HOST': 'test-host',
|
|
'OTHER_VAR': 'ignore_me',
|
|
}
|
|
self.ctx.host = Host(self.ctx.log, self.ctx.sys)
|
|
|
|
def run_env(self, *argv):
|
|
args = build_parser(env.METADATA, 'apex config/env').parse_args(argv)
|
|
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
env.run(self.ctx, args)
|
|
return out.getvalue()
|
|
|
|
def test_shell_exports_round_trip_literal_values(self):
|
|
value = "a$b # 'quote' \"double\" \\backslash\r\nnext"
|
|
self.ctx.vars.return_value['APEX_NODE_HOST'] = value
|
|
exports = self.run_env('--export')
|
|
self.assertEqual(self.run_env(), exports)
|
|
script = exports + '\nprintf %s "$APEX_NODE_HOST"'
|
|
result = subprocess.run(['/bin/sh', '-c', script], capture_output=True, check=True,
|
|
timeout=30, env={})
|
|
self.assertEqual(result.stdout.decode(), value)
|
|
self.assertNotIn('OTHER_VAR', script)
|
|
|
|
def test_dotenv_does_not_disclose_other_values_or_existing_secrets(self):
|
|
(self.core / '.env').write_text('SECRET=synthetic-private\n')
|
|
output = self.run_env('--dotenv')
|
|
self.assertIn('APEX_NODE_HOST=', output)
|
|
self.assertNotIn('export ', output)
|
|
self.assertNotIn('SECRET', output)
|
|
self.assertNotIn('OTHER_VAR', output)
|
|
|
|
def test_repeat_sync_preserves_credentials_bytes_and_updates_managed_values(self):
|
|
original = b'# user credentials\r\nTOKEN=\'a$#"\\literal\'\r\nUID=1000\r\nGID=1000'
|
|
target = self.core / '.env'
|
|
target.write_bytes(original)
|
|
target.chmod(0o600)
|
|
(self.core / 'apex.env').write_bytes(b'COMPOSE_PROFILES=core\r\nPORT=1234\r\n')
|
|
self.run_env('--sync')
|
|
first = target.read_bytes()
|
|
self.assertTrue(first.startswith(original + b'\n'))
|
|
self.assertIn(b'COMPOSE_PROFILES=core\r\nPORT=1234\r\n', first)
|
|
self.run_env('--sync')
|
|
self.assertEqual(first, target.read_bytes())
|
|
self.ctx.vars.return_value['APEX_NODE_HOST'] = 'new-host'
|
|
self.run_env('--sync')
|
|
self.assertTrue(target.read_bytes().startswith(original + b'\n'))
|
|
self.assertNotIn(b'test-host', target.read_bytes())
|
|
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
|
|
|
|
def test_sync_preserves_symlink_and_private_target(self):
|
|
target = Path(self.tmpdir.name) / 'private.env'
|
|
target.write_text('TOKEN=synthetic\n')
|
|
link = self.core / '.env'
|
|
link.symlink_to(target)
|
|
self.run_env('--sync')
|
|
self.assertTrue(link.is_symlink())
|
|
self.assertIn('TOKEN=synthetic', target.read_text())
|
|
self.assertIn('APEX_NODE_HOST=', target.read_text())
|
|
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
|
|
|
|
def test_write_basename_is_standalone_and_private(self):
|
|
previous = os.getcwd()
|
|
self.addCleanup(os.chdir, previous)
|
|
os.chdir(self.tmpdir.name)
|
|
Path('generated.env').write_text('OLD=removed\n')
|
|
self.run_env('--write', 'generated.env')
|
|
content = Path('generated.env').read_text()
|
|
self.assertIn('APEX_NODE_HOST=', content)
|
|
self.assertNotIn('OLD=', content)
|
|
self.assertNotIn('BEGIN', content)
|
|
self.assertEqual(stat.S_IMODE(Path('generated.env').stat().st_mode), 0o600)
|
|
|
|
def test_incompatible_modes_fail_without_writing(self):
|
|
for argv in [('--sync', '--dotenv'), ('--sync', '--write', 'file'),
|
|
('--export', '--dotenv'), ('--export', '--write', 'file')]:
|
|
with self.subTest(argv=argv):
|
|
with self.assertRaises(SystemExit) as error:
|
|
self.run_env(*argv)
|
|
self.assertEqual(error.exception.code, 64)
|
|
self.assertFalse((self.core / '.env').exists())
|
|
|
|
def test_uncertain_managed_markers_fail_without_changing_file(self):
|
|
start = '# BEGIN APEX MANAGED ENV'
|
|
end = '# END APEX MANAGED ENV'
|
|
for content in [start+'\n', end+'\n', end+'\n'+start+'\n',
|
|
start+'\n'+start+'\n'+end+'\n',
|
|
start+'\n'+end+'\n'+start+'\n'+end+'\n',
|
|
'TOKEN="'+start+'"\n']:
|
|
target = self.core / '.env'
|
|
target.write_text(content)
|
|
with self.subTest(content=content):
|
|
with self.assertRaises(SystemExit) as error:
|
|
self.run_env('--sync')
|
|
self.assertEqual(error.exception.code, 65)
|
|
self.assertEqual(target.read_text(), content)
|
|
|
|
def test_source_marker_collision_fails_without_writing(self):
|
|
(self.core / 'apex.env').write_text('# BEGIN APEX MANAGED ENV\n')
|
|
with self.assertRaises(SystemExit) as error:
|
|
self.run_env('--sync')
|
|
self.assertEqual(error.exception.code, 65)
|
|
self.assertFalse((self.core / '.env').exists())
|
|
(self.core / 'apex.env').unlink()
|
|
self.ctx.vars.return_value['APEX_NODE_HOST'] = '# END APEX MANAGED ENV'
|
|
with self.assertRaises(SystemExit) as error:
|
|
self.run_env('--sync')
|
|
self.assertEqual(error.exception.code, 65)
|
|
self.assertFalse((self.core / '.env').exists())
|
|
|
|
def test_bare_compose_interprets_literals_precedence_and_profiles(self):
|
|
binary = shutil.which('docker-compose')
|
|
if binary is None:
|
|
self.skipTest('Standalone docker-compose required for daemon-free config check')
|
|
values = {
|
|
'APEX_LITERAL': "a$b ${UNSET} $$ # 'quote' \"double\" \\backslash",
|
|
'APEX_TRAILING': 'last\\',
|
|
'APEX_LINES': 'one\r\ntwo\nthree\tfour',
|
|
'APEX_EMPTY': '',
|
|
'APEX_ESCAPES': "\\n \\\\ \\' \\\"",
|
|
'APEX_NODE_HOST': 'synthesized',
|
|
}
|
|
self.ctx.vars.return_value = values
|
|
(self.core / '.env').write_text('UID=1000\nGID=1001\nPORT=1\nAPEX_NODE_HOST=old\n'
|
|
"TOKEN='literal$token#hash'\n")
|
|
(self.core / 'apex.env').write_text('PORT=2\nAPEX_NODE_HOST=public\n'
|
|
'COMPOSE_PROFILES=chosen\n')
|
|
keys = [*values, 'UID', 'GID', 'PORT', 'TOKEN']
|
|
(self.core / 'docker-compose.yml').write_text(
|
|
'services:\n probe:\n image: busybox\n profiles: [chosen]\n'
|
|
' environment:\n' + ''.join(f' {key}: ${{{key}}}\n' for key in keys))
|
|
self.run_env('--sync')
|
|
result = subprocess.run([binary, 'config', '--format', 'json'], cwd=self.core,
|
|
capture_output=True, check=True, timeout=30, env={})
|
|
actual = json.loads(result.stdout)['services']['probe']['environment']
|
|
expected = {**values, 'UID': '1000', 'GID': '1001', 'PORT': '2',
|
|
'TOKEN': 'literal$token#hash'}
|
|
# Compose re-escapes literal dollars when serializing a reusable configuration.
|
|
self.assertEqual(actual, {k: v.replace('$', '$$') for k, v in expected.items()})
|
|
result = subprocess.run([binary, 'config', '--environment'], cwd=self.core,
|
|
capture_output=True, check=True, timeout=30, env={})
|
|
for key, value in {**expected, 'COMPOSE_PROFILES': 'chosen'}.items():
|
|
self.assertIn(f'{key}={value}\n'.encode(), result.stdout)
|
|
with (self.core / '.env').open('a') as target:
|
|
target.write('# later user addition\nPORT=999\n')
|
|
self.run_env('--sync')
|
|
self.assertIn('# later user addition\nPORT=999\n', (self.core / '.env').read_text())
|
|
result = subprocess.run([binary, 'config', '--format', 'json'], cwd=self.core,
|
|
capture_output=True, check=True, timeout=30, env={})
|
|
self.assertEqual(json.loads(result.stdout)['services']['probe']['environment']['PORT'], '2')
|
|
result = subprocess.run([binary, 'config', '--services'], cwd=self.core,
|
|
capture_output=True, check=True, timeout=30, env={})
|
|
self.assertEqual(result.stdout.strip(), b'probe')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|