206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).parents[1]
|
|
|
|
|
|
def _indent(line: str) -> int:
|
|
return len(line) - len(line.lstrip())
|
|
|
|
|
|
def _service(name: str) -> list[str]:
|
|
lines = (ROOT / "compose.yaml").read_text().splitlines()
|
|
marker = f" {name}:"
|
|
start = lines.index(marker) + 1
|
|
end = next(
|
|
(
|
|
index
|
|
for index in range(start, len(lines))
|
|
if lines[index].strip() and _indent(lines[index]) <= 2
|
|
),
|
|
len(lines),
|
|
)
|
|
return lines[start:end]
|
|
|
|
|
|
def _section(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
|
|
marker = f"{' ' * indent}{name}:"
|
|
start = lines.index(marker) + 1
|
|
end = next(
|
|
(
|
|
index
|
|
for index in range(start, len(lines))
|
|
if lines[index].strip() and _indent(lines[index]) <= indent
|
|
),
|
|
len(lines),
|
|
)
|
|
return lines[start:end]
|
|
|
|
|
|
def _value(value: str) -> str:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
return str(json.loads(value))
|
|
if len(value) >= 2 and value[0] == value[-1] == "'":
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def _mapping(lines: list[str], name: str, *, indent: int = 4) -> dict[str, str]:
|
|
entries = _section(lines, name, indent=indent)
|
|
result: dict[str, str] = {}
|
|
for line in entries:
|
|
if _indent(line) != indent + 2 or line.lstrip().startswith("-"):
|
|
continue
|
|
key, separator, value = line.strip().partition(":")
|
|
if separator:
|
|
result[key] = _value(value)
|
|
return result
|
|
|
|
|
|
def _sequence(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
|
|
entries = _section(lines, name, indent=indent)
|
|
prefix = f"{' ' * (indent + 2)}- "
|
|
return [_value(line.removeprefix(prefix)) for line in entries if line.startswith(prefix)]
|
|
|
|
|
|
def _scalar(lines: list[str], name: str, *, indent: int = 4) -> str:
|
|
prefix = f"{' ' * indent}{name}:"
|
|
line = next(line for line in lines if line.startswith(prefix))
|
|
return _value(line.removeprefix(prefix))
|
|
|
|
|
|
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
|
config = json.loads((ROOT / "opencode" / "opencode.json").read_text())
|
|
|
|
assert "permission" not in config
|
|
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
|
|
assert "permission" not in config["agent"]["explore"]
|
|
assert config["agent"]["research"]["permission"] == {
|
|
"*": "deny",
|
|
"websearch": "allow",
|
|
"context7_*": "allow",
|
|
"gh_grep_*": "allow",
|
|
}
|
|
assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"]
|
|
assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp"
|
|
assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}"
|
|
assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}"
|
|
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
|
|
|
|
|
|
def test_compose_services_have_expected_runtime_contract() -> None:
|
|
agentci = _service("agentci")
|
|
opencode = _service("opencode")
|
|
agentci_environment = _mapping(agentci, "environment")
|
|
opencode_environment = _mapping(opencode, "environment")
|
|
|
|
assert agentci_environment["AGENTCI_OPENCODE_URL"] == "http://opencode:4096"
|
|
assert agentci_environment["AGENTCI_EXPLORE_VARIANT"] == ("${AGENTCI_EXPLORE_VARIANT:-low}")
|
|
assert agentci_environment["AGENTCI_RESEARCH_VARIANT"] == ("${AGENTCI_RESEARCH_VARIANT:-high}")
|
|
assert opencode_environment["HOME"] == "/etc/opencode/home"
|
|
assert opencode_environment["OPENCODE_DISABLE_EXTERNAL_SKILLS"] == "1"
|
|
assert opencode_environment["OPENCODE_ENABLE_EXA"] == "1"
|
|
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in opencode_environment
|
|
|
|
assert _sequence(agentci, "volumes") == [
|
|
"agentci_data:/var/lib/agentci",
|
|
"./install-scripts:/etc/agentci/install-scripts:ro",
|
|
]
|
|
assert _sequence(opencode, "volumes") == [
|
|
"agentci_data:/var/lib/agentci",
|
|
"opencode_home:/var/lib/opencode",
|
|
]
|
|
assert _sequence(agentci, "tmpfs") == ["/run/agentci:mode=1777"]
|
|
assert _sequence(opencode, "tmpfs") == ["/run/agentci:mode=1777"]
|
|
assert json.loads(_scalar(opencode, "command")) == [
|
|
"opencode",
|
|
"serve",
|
|
"--hostname",
|
|
"0.0.0.0",
|
|
"--port",
|
|
"4096",
|
|
]
|
|
|
|
image = "${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}"
|
|
assert _scalar(agentci, "image") == image
|
|
assert _scalar(opencode, "image") == image
|
|
assert all(line.strip() != "build:" for line in [*agentci, *opencode])
|
|
|
|
dependency = _section(_section(agentci, "depends_on"), "opencode", indent=6)
|
|
assert _mapping([" dependency:", *dependency], "dependency", indent=6) == {
|
|
"condition": "service_healthy"
|
|
}
|
|
healthcheck = _mapping(opencode, "healthcheck")
|
|
health_command = json.loads(healthcheck["test"])
|
|
assert health_command[0] == "CMD-SHELL"
|
|
assert "/global/health" in health_command[1]
|
|
assert "OPENCODE_SERVER_PASSWORD_FILE" in health_command[1]
|
|
|
|
|
|
def test_compose_has_no_sandbox_security_exceptions() -> None:
|
|
compose = (ROOT / "compose.yaml").read_text()
|
|
|
|
for forbidden in (
|
|
"cap_add:",
|
|
"security_opt:",
|
|
"privileged:",
|
|
"seccomp=unconfined",
|
|
"apparmor=unconfined",
|
|
"bubblewrap",
|
|
):
|
|
assert forbidden not in compose
|
|
|
|
|
|
def test_gitea_workflow_publishes_main_images() -> None:
|
|
lines = (ROOT / ".gitea" / "workflows" / "publish-image.yaml").read_text().splitlines()
|
|
push = _section(_section(lines, "on", indent=0), "push", indent=2)
|
|
|
|
assert _sequence(push, "branches", indent=4) == ["main"]
|
|
assert _mapping(lines, "env", indent=0)["IMAGE_NAME"] == (
|
|
"git.krtss.de/stanponomarev/agentci"
|
|
)
|
|
assert " ${{ env.IMAGE_NAME }}:latest" in lines
|
|
assert " ${{ env.IMAGE_NAME }}:${{ gitea.sha }}" in lines
|
|
assert " username: ${{ secrets.REGISTRY_USERNAME }}" in lines
|
|
assert " password: ${{ secrets.REGISTRY_TOKEN }}" in lines
|
|
|
|
|
|
def test_container_pins_opencode_major_version_contract() -> None:
|
|
lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()]
|
|
build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")}
|
|
|
|
assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments
|
|
assert any(
|
|
line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines
|
|
)
|
|
|
|
|
|
def test_compose_document_validates_when_compose_cli_is_available() -> None:
|
|
docker = shutil.which("docker")
|
|
if docker is None:
|
|
pytest.skip("Docker Compose CLI is not installed")
|
|
probe = subprocess.run(
|
|
[docker, "compose", "version"],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if probe.returncode:
|
|
pytest.skip("Docker Compose provider is not available")
|
|
|
|
result = subprocess.run(
|
|
[docker, "compose", "config", "--quiet"],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|