186 lines
8.1 KiB
Python
186 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def load_paths():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"controller_config_paths_test", ROOT / "ops/gitea-deploy-broker/controller_paths.py"
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_production_code_uses_fixed_protected_mutable_config():
|
|
paths = load_paths()
|
|
code = Path("/opt/gitea-deploy")
|
|
assert paths.controller_config_root(code) == Path("/controller-hub-releases/controller-config")
|
|
assert paths.controller_readonly_config_root(code) == Path("/controller-config")
|
|
assert paths.controller_config_root(code) != code
|
|
|
|
|
|
def test_private_install_stage_keeps_its_captured_config(tmp_path):
|
|
paths = load_paths()
|
|
stage = tmp_path / "controller"
|
|
assert paths.controller_config_root(stage) == stage
|
|
assert paths.controller_readonly_config_root(stage) == stage
|
|
|
|
|
|
def test_config_location_has_no_environment_override(monkeypatch):
|
|
paths = load_paths()
|
|
monkeypatch.setenv("ITWORX_CONTROLLER_CONFIG_ROOT", "/untrusted")
|
|
monkeypatch.setenv("CONTROLLER_CONFIG_ROOT", "/untrusted")
|
|
assert paths.controller_config_root(Path("/opt/gitea-deploy")) == Path(
|
|
"/controller-hub-releases/controller-config"
|
|
)
|
|
|
|
|
|
def test_repopulse_store_keeps_readonly_and_mutable_config_aliases():
|
|
paths = load_paths()
|
|
monkeypatch_path = ROOT / "ops/gitea-deploy-broker"
|
|
import sys
|
|
|
|
if str(monkeypatch_path) not in sys.path:
|
|
sys.path.insert(0, str(monkeypatch_path))
|
|
spec = importlib.util.spec_from_file_location(
|
|
"repopulse_config_alias_test", monkeypatch_path / "repopulse_adoption_store.py"
|
|
)
|
|
store = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(store)
|
|
code = Path("/opt/gitea-deploy")
|
|
assert store.CONTROLLER_READONLY_ROOT == paths.controller_readonly_config_root(code)
|
|
assert store.CONTROLLER_RW_ROOT == paths.controller_config_root(code)
|
|
assert store.CONTROLLER_READONLY_ROOT != store.CONTROLLER_RW_ROOT
|
|
|
|
|
|
def test_registry_updates_use_mutable_config_and_preserve_code(monkeypatch, tmp_path):
|
|
monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker"))
|
|
import controller_paths
|
|
|
|
config = tmp_path / "config"
|
|
config.mkdir(mode=0o755)
|
|
registry = config / "projects.json"
|
|
registry.write_text("{}\n", encoding="utf-8")
|
|
registry.chmod(0o600)
|
|
code = ROOT / "ops/gitea-deploy-broker"
|
|
original_code = (code / "status_server.py").read_bytes()
|
|
monkeypatch.setattr(controller_paths, "controller_config_root", lambda _root: config)
|
|
spec = importlib.util.spec_from_file_location("mutable_config_controller_test", code / "status_server.py")
|
|
controller = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(controller)
|
|
assert controller.PROJECTS_PATH == registry
|
|
assert controller.WEBHOOK_SECRET_ROOT == config / "webhook-secrets"
|
|
assert controller.APPOPS_DEPLOYMENT_SECRET_ROOT == config / "appops-deployment-secrets"
|
|
assert controller.GITEA_VALIDATION_TOKEN_FILE == config / "gitea-validation-token"
|
|
assert controller.PRIVATE_TLS_CONFIG_PATH == config / "private-tls.json"
|
|
assert controller.CONTROLLER_RELEASE_PATH == code / "controller-release.json"
|
|
controller._write_projects({"jens/example": {"slug": "example"}})
|
|
assert controller._load_projects() == {"jens/example": {"slug": "example"}}
|
|
assert registry.stat().st_mode & 0o777 == 0o600
|
|
assert (code / "status_server.py").read_bytes() == original_code
|
|
|
|
|
|
def test_status_reads_hub_state_through_the_fixed_pinned_adapter(monkeypatch, tmp_path):
|
|
monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker"))
|
|
import mcp_hub_release_store
|
|
|
|
code = ROOT / "ops/gitea-deploy-broker"
|
|
spec = importlib.util.spec_from_file_location("hub_state_controller_test", code / "status_server.py")
|
|
controller = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(controller)
|
|
state = tmp_path / "state"
|
|
state.mkdir()
|
|
(state / "itworx-mcp-hub.json").write_text('{"live_revision":"untrusted-path"}')
|
|
(state / "ordinary-project.json").write_text('{"live_revision":"ordinary"}')
|
|
monkeypatch.setattr(controller, "STATE_ROOT", state)
|
|
monkeypatch.setattr(
|
|
mcp_hub_release_store, "read_hub_state_bytes", lambda: b'{"live_revision":"pinned"}'
|
|
)
|
|
assert controller._deployment_state("itworx-mcp-hub") == {"live_revision": "pinned"}
|
|
assert controller._deployment_state("ordinary-project") == {"live_revision": "ordinary"}
|
|
|
|
def unsafe():
|
|
raise mcp_hub_release_store.ReleaseStoreError("state path changed")
|
|
|
|
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", unsafe)
|
|
assert controller._deployment_state("itworx-mcp-hub") == {}
|
|
|
|
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'[]')
|
|
assert controller._deployment_state("itworx-mcp-hub") == {}
|
|
|
|
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'\xff')
|
|
assert controller._deployment_state("itworx-mcp-hub") == {}
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="the registration script uses POSIX flock")
|
|
@pytest.mark.parametrize("matching_aliases", [True, False])
|
|
def test_embedded_registry_writer_uses_protected_aliases_and_preserves_code(
|
|
monkeypatch, tmp_path, matching_aliases
|
|
):
|
|
from backend.app.services.deployment_target_registry import _REGISTER_SCRIPT
|
|
|
|
registry = tmp_path / "protected" / "projects.json"
|
|
registry.parent.mkdir(mode=0o755)
|
|
registry.write_text("{}\n")
|
|
registry.chmod(0o600)
|
|
readonly = registry if matching_aliases else tmp_path / "different-registry.json"
|
|
if not matching_aliases:
|
|
readonly.write_text("{}\n")
|
|
readonly.chmod(0o600)
|
|
code = tmp_path / "old-code"
|
|
code.mkdir()
|
|
old_registry = code / "projects.json"
|
|
old_registry.write_text('{"old":"must stay unchanged"}\n')
|
|
original = old_registry.read_bytes()
|
|
old_host_registry = tmp_path / "old-host-registry.json"
|
|
old_host_registry.write_text("{}\n")
|
|
repositories = tmp_path / "repositories"
|
|
bare = repositories / "jens" / "example.git"
|
|
bare.mkdir(parents=True)
|
|
paths = {
|
|
"/controller-config/projects.json": readonly,
|
|
"/controller-hub-releases/controller-config/projects.json": registry,
|
|
"/opt/gitea-deploy/projects.json": old_registry,
|
|
"/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json": old_host_registry,
|
|
"/mnt/cache/appdata/gitea-deploy-runners/deploy.lock": tmp_path / "deploy.lock",
|
|
"/gitea-repositories": repositories,
|
|
}
|
|
|
|
def fixture_path(value):
|
|
return paths.get(str(value), Path(value))
|
|
|
|
revision = "a" * 40
|
|
monkeypatch.setattr(
|
|
subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=revision + "\n")
|
|
)
|
|
project = {"branch": "main", "slug": "example", "compose_files": ["docker-compose.yml"]}
|
|
monkeypatch.setattr(sys, "argv", ["script", "jens/example", revision, "main", json.dumps(project)])
|
|
tree = ast.parse(_REGISTER_SCRIPT)
|
|
# Redirect only filesystem paths into disposable fixtures. All production
|
|
# validation, locking, backup, atomic replace and readback code executes.
|
|
tree.body = [node for node in tree.body if not (isinstance(node, ast.ImportFrom) and node.module == "pathlib")]
|
|
if matching_aliases:
|
|
exec(compile(tree, "registry-writer", "exec"), {"Path": fixture_path})
|
|
assert json.loads(registry.read_text()) == {"jens/example": project}
|
|
assert registry.stat().st_mode & 0o777 == 0o600
|
|
assert list(registry.parent.glob("projects.json.appops-backup-*"))
|
|
else:
|
|
with pytest.raises(SystemExit) as error:
|
|
exec(compile(tree, "registry-writer", "exec"), {"Path": fixture_path})
|
|
assert error.value.code == 36
|
|
assert registry.read_text() == "{}\n"
|
|
assert old_registry.read_bytes() == original
|
|
assert old_host_registry.read_text() == "{}\n"
|