Files
Unraid-AppOps-Gateway-Public/backend/tests/test_runner_statistics_binding.py
T

392 lines
16 KiB
Python

from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
pytestmark = pytest.mark.skipif(os.name != 'posix', reason='Linux controller ownership/flock contract')
@pytest.fixture
def binding(tmp_path, monkeypatch):
path = Path(__file__).resolve().parents[2] / 'ops/gitea-deploy-broker/runner_statistics_binding.py'
spec = importlib.util.spec_from_file_location('binding_test', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
anchor = tmp_path / 'controller-anchor'
state = anchor / 'state'
(state / 'locks').mkdir(parents=True)
source = tmp_path / 'brain'
source.mkdir()
data = tmp_path / 'appops'
data.mkdir()
monkeypatch.setattr(module, 'ROOT_UID', os.getuid())
if os.geteuid() != 0:
# The production controller is root. Keep portable unit coverage for
# non-root Linux runs while root-only tests assert 100:101.
monkeypatch.setattr(module, 'APPOPS_UID', os.geteuid())
monkeypatch.setattr(module, 'APPOPS_GID', os.getegid())
for key, value in {'STATE_ROOT': state, 'BINDING_ROOT': state / 'runner-statistics-binding',
'PB_SOURCE': source, 'PB_ENV': source / '.env', 'APPOPS_DATA': data,
'TOKEN_FILE': data / 'runner-statistics-token',
'ADMIN_TOKEN_FILE': data / 'mcp-token', 'GLOBAL_LOCK': anchor / 'deploy.lock'}.items():
monkeypatch.setattr(module, key, value)
module.PB_ENV.write_bytes(b'EXISTING_PRIVATE_VALUE=keep-me\nPROJECTBRAIN_APPOPS_MCP_URL=\nPROJECTBRAIN_APPOPS_MCP_TOKEN=\n')
module.PB_ENV.chmod(0o600)
module.ADMIN_TOKEN_FILE.write_text('admin-capability-separate-secret\n')
module.ADMIN_TOKEN_FILE.chmod(0o600)
module.project = {'jens/projectbrain': {'branch': 'main', 'slug': 'projectbrain',
'source_dir': str(source), 'containers': ['projectbrain'],
'deploy_script': 'scripts/deploy-unraid-single.sh',
'validation': {'required': True, 'contexts': ['Managed validation / full (push)']}}}
return module
def invoke(binding):
return binding.configure(revision='a' * 40, projects=binding.project)
def test_configuration_is_separate_private_and_idempotent(binding):
before = binding.PB_ENV.read_bytes()
result = invoke(binding)
assert result['status'] == 'configured'
assert result['projectbrainDeliveryRequired'] is True
token = binding.TOKEN_FILE.read_text().strip()
assert len(token) == 64
assert token != binding.ADMIN_TOKEN_FILE.read_text().strip()
assert token not in json.dumps(result)
assert binding._configured_values(binding.PB_ENV.read_bytes()) == {
binding.URL_KEY: binding.ENDPOINT, binding.TOKEN_KEY: token}
assert b'EXISTING_PRIVATE_VALUE=keep-me\n' in binding.PB_ENV.read_bytes()
assert (binding.BINDING_ROOT / 'environment-before').read_bytes() == before
for path in (binding.PB_ENV, binding.TOKEN_FILE, binding.BINDING_ROOT / 'receipt.json'):
assert path.stat().st_mode & 0o777 == 0o600
token_info = binding.TOKEN_FILE.stat()
assert (token_info.st_uid, token_info.st_gid) == (binding.APPOPS_UID, binding.APPOPS_GID)
assert token_info.st_nlink == 1
assert invoke(binding)['status'] == 'already_configured'
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
reason='fixed AppOps ownership requires a privileged Linux controller')
def test_statistics_token_is_appops_readable_while_binding_state_stays_root_private(binding):
assert os.geteuid() != 100
result = invoke(binding)
token_info = binding.TOKEN_FILE.stat()
assert (token_info.st_uid, token_info.st_gid) == (100, 101)
assert token_info.st_mode & 0o777 == 0o600
assert token_info.st_nlink == 1
assert len(binding.TOKEN_FILE.read_text().strip()) == 64
assert result['status'] == 'configured'
for path in (binding.PB_ENV, binding.BINDING_ROOT / 'receipt.json',
binding.BINDING_ROOT / 'environment-before'):
info = path.stat()
assert info.st_uid == binding.ROOT_UID
assert info.st_mode & 0o777 == 0o600
assert info.st_nlink == 1
assert invoke(binding)['status'] == 'already_configured'
token_info = binding.TOKEN_FILE.stat()
assert (token_info.st_uid, token_info.st_gid) == (100, 101)
assert token_info.st_mode & 0o777 == 0o600
assert token_info.st_nlink == 1
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
reason='dropping to the fixed AppOps identity requires a privileged Linux controller')
def test_private_secret_reader_accepts_statistics_token_as_dropped_appops_identity(binding):
invoke(binding)
repository = Path(__file__).resolve().parents[2]
code = (
'import sys; from pathlib import Path; '
'sys.path.insert(0, sys.argv[2]); '
'from backend.app.core.config import _private_secret_file; '
'value = _private_secret_file(sys.argv[1]); '
'raise SystemExit(0 if value is not None and len(value) == 64 else 1)'
)
with tempfile.TemporaryDirectory(prefix='appops-statistics-reader-', dir='/tmp') as directory:
os.chmod(directory, 0o711)
token_copy = Path(directory) / 'runner-statistics-token'
token_copy.write_bytes(binding.TOKEN_FILE.read_bytes())
source_info = binding.TOKEN_FILE.stat()
os.chown(token_copy, source_info.st_uid, source_info.st_gid)
token_copy.chmod(source_info.st_mode & 0o777)
def drop_to_appops():
os.setgroups([])
os.setgid(101)
os.setuid(100)
result = subprocess.run(
['/usr/bin/python3', '-c', code, str(token_copy), str(repository)],
cwd=directory, preexec_fn=drop_to_appops,
capture_output=True, text=True, check=False,
)
token = binding.TOKEN_FILE.read_text().strip()
assert token not in result.stdout and token not in result.stderr
assert result.stdout == ''
assert result.returncode == 0, (
'AppOps config private-secret reader rejected the fixed token: '
+ result.stderr[-500:]
)
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
reason='wrong-owner validation requires a privileged Linux controller')
def test_configured_root_owned_statistics_token_fails_closed(binding):
assert invoke(binding)['status'] == 'configured'
token = binding.TOKEN_FILE.read_bytes()
os.chown(binding.TOKEN_FILE, binding.ROOT_UID, os.getgid())
with pytest.raises(binding.StatisticsBindingError, match='file_untrusted'):
invoke(binding)
assert binding.TOKEN_FILE.read_bytes() == token
info = binding.TOKEN_FILE.stat()
assert (info.st_uid, info.st_gid) == (binding.ROOT_UID, os.getgid())
@pytest.mark.parametrize('key', ['source_dir', 'deploy_script', 'containers', 'validation'])
def test_unknown_project_configuration_is_rejected_before_write(binding, key):
binding.project['jens/projectbrain'][key] = 'untrusted'
with pytest.raises(binding.StatisticsBindingError):
invoke(binding)
assert not binding.TOKEN_FILE.exists()
assert not binding.BINDING_ROOT.exists()
def test_existing_operator_configuration_is_preserved(binding):
before = b'PROJECTBRAIN_APPOPS_MCP_URL=https://operator.example/mcp\nOTHER=preserve\n'
binding.PB_ENV.write_bytes(before)
with pytest.raises(binding.StatisticsBindingError, match='existing_operator_configuration'):
invoke(binding)
assert binding.PB_ENV.read_bytes() == before
assert not binding.TOKEN_FILE.exists()
def test_existing_operator_token_is_never_adopted(binding):
binding.TOKEN_FILE.write_text('existing-operator-secret')
binding.TOKEN_FILE.chmod(0o600)
os.chown(binding.TOKEN_FILE, binding.APPOPS_UID, binding.APPOPS_GID)
with pytest.raises(binding.StatisticsBindingError, match='existing_operator_credential'):
invoke(binding)
assert binding.TOKEN_FILE.read_text() == 'existing-operator-secret'
@pytest.mark.parametrize('fault_path', ['token', 'environment', 'receipt'])
def test_crash_after_each_durable_boundary_recovers_same_credential(binding, monkeypatch, fault_path):
atomic = binding._atomic
interrupted = False
def crash(path, value, expected):
nonlocal interrupted
atomic(path, value, expected)
selected = {'token': binding.TOKEN_FILE, 'environment': binding.PB_ENV,
'receipt': binding.BINDING_ROOT / 'receipt.json'}[fault_path]
if path == selected and not interrupted:
interrupted = True
raise OSError('simulated interruption after fsync')
monkeypatch.setattr(binding, '_atomic', crash)
with pytest.raises(OSError):
invoke(binding)
journal = json.loads((binding.BINDING_ROOT / 'receipt.json').read_bytes())
expected = journal['token']
monkeypatch.setattr(binding, '_atomic', atomic)
assert invoke(binding)['status'] == 'configured'
assert binding.TOKEN_FILE.read_text().strip() == expected
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
reason='simulated ownership failure requires a privileged Linux controller')
def test_statistics_token_owner_failure_closes_fd_and_never_publishes(binding, monkeypatch, capsys):
opened_token_fds = []
def fail_chown(fd, uid, gid):
opened_token_fds.append(fd)
raise PermissionError('simulated AppOps ownership failure')
monkeypatch.setattr(binding.os, 'fchown', fail_chown)
with pytest.raises(PermissionError):
invoke(binding)
assert len(opened_token_fds) == 1
with pytest.raises(OSError):
binding.os.fstat(opened_token_fds[0])
assert not binding.TOKEN_FILE.exists()
assert [path.name for path in binding.APPOPS_DATA.iterdir()] == ['mcp-token']
assert capsys.readouterr().out == ''
def test_crash_after_backup_before_receipt_has_safe_retry(binding, monkeypatch):
atomic = binding._atomic
def crash(path, value, expected):
atomic(path, value, expected)
if path.name == 'environment-before':
raise OSError('interruption after backup')
monkeypatch.setattr(binding, '_atomic', crash)
with pytest.raises(OSError):
invoke(binding)
monkeypatch.setattr(binding, '_atomic', atomic)
assert invoke(binding)['status'] == 'configured'
def test_pending_environment_cas_conflict_preserves_operator_change(binding, monkeypatch):
atomic = binding._atomic
def crash(path, value, expected):
atomic(path, value, expected)
if path == binding.TOKEN_FILE:
raise OSError('interruption')
monkeypatch.setattr(binding, '_atomic', crash)
with pytest.raises(OSError):
invoke(binding)
binding.PB_ENV.write_bytes(b'OPERATOR_CHANGED=value\n')
monkeypatch.setattr(binding, '_atomic', atomic)
with pytest.raises(binding.StatisticsBindingError, match='environment_cas_conflict'):
invoke(binding)
assert binding.PB_ENV.read_bytes() == b'OPERATOR_CHANGED=value\n'
def test_completed_binding_keeps_unrelated_later_operator_edits(binding):
invoke(binding)
with binding.PB_ENV.open('ab') as stream:
stream.write(b'NEW_OPERATOR_VALUE=preserved\n')
before = binding.PB_ENV.read_bytes()
assert invoke(binding)['status'] == 'already_configured'
assert binding.PB_ENV.read_bytes() == before
@pytest.mark.parametrize('unsafe', ['symlink', 'public_file', 'writable_anchor', 'duplicate_key'])
def test_unsafe_configuration_blocks_before_credential_write(binding, unsafe):
if unsafe == 'symlink':
target = binding.PB_ENV.with_name('other.env')
binding.PB_ENV.rename(target)
binding.PB_ENV.symlink_to(target)
elif unsafe == 'public_file':
binding.PB_ENV.chmod(0o644)
elif unsafe == 'writable_anchor':
binding.PB_SOURCE.chmod(0o777)
else:
binding.PB_ENV.write_text(f'{binding.URL_KEY}=\n{binding.URL_KEY}=\n')
with pytest.raises((binding.StatisticsBindingError, OSError)):
invoke(binding)
assert not binding.TOKEN_FILE.exists()
def test_active_deployment_lock_blocks_without_configuration_changes(binding):
import fcntl
with binding.GLOBAL_LOCK.open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with pytest.raises(binding.StatisticsBindingError, match='deployment_busy'):
invoke(binding)
assert not binding.TOKEN_FILE.exists()
@pytest.mark.parametrize('owner,accepted', [(100, True), (200, False)])
def test_existing_fixed_admin_file_owner_contract(binding, monkeypatch, owner, accepted):
actual = binding.os.fstat
inode = binding.ADMIN_TOKEN_FILE.stat().st_ino
def metadata(fd):
info = actual(fd)
if info.st_ino == inode:
fields = list(info)
fields[4] = owner
return os.stat_result(fields)
return info
monkeypatch.setattr(binding.os, 'fstat', metadata)
if accepted:
assert invoke(binding)['status'] == 'configured'
else:
with pytest.raises(binding.StatisticsBindingError, match='file_untrusted'):
invoke(binding)
assert not binding.TOKEN_FILE.exists()
def test_parent_swap_cannot_redirect_a_root_read(binding, monkeypatch):
actual_open = binding.os.open
original = binding.PB_ENV.read_bytes()
moved = binding.PB_SOURCE.with_name('moved-brain')
attacker = binding.PB_SOURCE.with_name('attacker')
attacker.mkdir()
(attacker / '.env').write_bytes(b'ATTACKER=must-not-be-read\n')
(attacker / '.env').chmod(0o600)
swapped = False
def swap(path, flags, *args, **kwargs):
nonlocal swapped
if path == '.env' and 'dir_fd' in kwargs and not swapped:
swapped = True
binding.PB_SOURCE.rename(moved)
binding.PB_SOURCE.symlink_to(attacker, target_is_directory=True)
return actual_open(path, flags, *args, **kwargs)
monkeypatch.setattr(binding.os, 'open', swap)
assert binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES) == original
assert (attacker / '.env').read_bytes() == b'ATTACKER=must-not-be-read\n'
with pytest.raises(OSError):
binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES)
def test_parent_swap_cannot_redirect_atomic_replace(binding, monkeypatch):
actual_replace = binding.os.replace
before = binding.PB_ENV.read_bytes()
updated = before + b'APPENDED=private\n'
moved = binding.PB_SOURCE.with_name('moved-brain')
attacker = binding.PB_SOURCE.with_name('attacker')
attacker.mkdir()
(attacker / '.env').write_bytes(b'ATTACKER=must-not-be-overwritten\n')
(attacker / '.env').chmod(0o600)
def swap(source, destination, **kwargs):
assert source.startswith('.statistics-')
assert destination == '.env'
assert kwargs['src_dir_fd'] == kwargs['dst_dir_fd']
binding.PB_SOURCE.rename(moved)
binding.PB_SOURCE.symlink_to(attacker, target_is_directory=True)
return actual_replace(source, destination, **kwargs)
monkeypatch.setattr(binding.os, 'replace', swap)
binding._atomic(binding.PB_ENV, updated, before)
assert (moved / '.env').read_bytes() == updated
assert (attacker / '.env').read_bytes() == b'ATTACKER=must-not-be-overwritten\n'
with pytest.raises(OSError):
binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES)
def test_parent_swap_cannot_replace_the_authentication_secret(binding, monkeypatch):
secret = b'original-administration-capability-1234567890'
binding.ADMIN_TOKEN_FILE.write_bytes(secret)
actual_open = binding.os.open
moved = binding.APPOPS_DATA.with_name('moved-appops')
attacker = binding.APPOPS_DATA.with_name('attacker-appops')
attacker.mkdir()
(attacker / 'mcp-token').write_bytes(b'attacker-chosen-authentication-capability-12345')
(attacker / 'mcp-token').chmod(0o600)
swapped = False
def swap(path, flags, *args, **kwargs):
nonlocal swapped
if path == 'mcp-token' and 'dir_fd' in kwargs and not swapped:
swapped = True
binding.APPOPS_DATA.rename(moved)
binding.APPOPS_DATA.symlink_to(attacker, target_is_directory=True)
return actual_open(path, flags, *args, **kwargs)
monkeypatch.setattr(binding.os, 'open', swap)
assert binding.read_administration_secret() == secret
assert binding.read_administration_secret() is None