fix(apex): preserve environments and restore secure standalone operations
This commit is contained in:
1 parent
6463bad5ee
commit
995ea9bdf9
44 files changed
+1399
-429
No files matched your search
+156
-42
@@ -1,68 +1,182 @@
|
||||
# tests/test_env.py
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from actions 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.comp_dir = os.path.join(self.tmpdir.name, "compositions")
|
||||
os.makedirs(os.path.join(self.comp_dir, "apex"))
|
||||
|
||||
self.core = Path(self.tmpdir.name) / 'compositions' / 'apex'
|
||||
self.core.mkdir(parents=True)
|
||||
self.ctx = MagicMock()
|
||||
self.ctx.paths.compositions = self.comp_dir
|
||||
self.ctx.paths.compositions = str(self.core.parent)
|
||||
self.ctx.vars.return_value = {
|
||||
"APEX_SUBNET": "198.18.16.0/24",
|
||||
"APEX_NODE_HOST": "test-host",
|
||||
"OTHER_VAR": "ignore_me",
|
||||
'APEX_SUBNET': '198.18.16.0/24',
|
||||
'APEX_NODE_HOST': 'test-host',
|
||||
'OTHER_VAR': 'ignore_me',
|
||||
}
|
||||
self.ctx.host = Host(self.ctx.log, self.ctx.sys)
|
||||
|
||||
def test_export_format_stdout(self):
|
||||
args = MagicMock(sync=False, write=None, dotenv=False)
|
||||
captured = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
try:
|
||||
sys.stdout = captured
|
||||
def run_env(self, *argv):
|
||||
args = build_parser(env.METADATA, 'apex env').parse_args(argv)
|
||||
with contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
env.run(self.ctx, args)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
return out.getvalue()
|
||||
|
||||
out = captured.getvalue()
|
||||
self.assertIn("export APEX_NODE_HOST=test-host", out)
|
||||
self.assertIn("export APEX_SUBNET=198.18.16.0/24", out)
|
||||
self.assertNotIn("OTHER_VAR", out)
|
||||
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_format_stdout(self):
|
||||
args = MagicMock(sync=False, write=None, dotenv=True)
|
||||
captured = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
try:
|
||||
sys.stdout = captured
|
||||
env.run(self.ctx, args)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
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)
|
||||
|
||||
out = captured.getvalue()
|
||||
self.assertIn("APEX_NODE_HOST=test-host", out)
|
||||
self.assertNotIn("export", out)
|
||||
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_to_file(self):
|
||||
target = os.path.join(self.comp_dir, "apex", ".env")
|
||||
args = MagicMock(sync=True, write=None, dotenv=False)
|
||||
env.run(self.ctx, args)
|
||||
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)
|
||||
|
||||
self.ctx.host.write_file.assert_called_once()
|
||||
call_args = self.ctx.host.write_file.call_args
|
||||
self.assertEqual(call_args[0][0], target)
|
||||
self.assertIn("APEX_SUBNET=198.18.16.0/24", call_args[0][1])
|
||||
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__":
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user