mirror of
https://github.com/Colton-z/AstraBox.git
synced 2026-09-28 14:13:16 +08:00
Strengthen sandbox isolation and authentication, make all five engines work through the bundled installer, and preserve conversations across sandbox and service restarts. Add team login and single-container deployment, with upgrade instructions for replacing existing 0.1.0 sandboxes. Co-Authored-By: Claude <noreply@anthropic.com>
642 lines
26 KiB
Python
642 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from astrabox.deploy import onebox
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
import rotate_local_database_secrets as database_rotation # noqa: E402
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SECRET_SCRIPT = ROOT / "scripts" / "ensure_local_database_secrets.py"
|
|
SECRET_NAMES = (
|
|
"postgres_admin_password",
|
|
"astrabox_password",
|
|
"litellm_password",
|
|
"casdoor_password",
|
|
)
|
|
GENERATED_SECRET_NAMES = (
|
|
*SECRET_NAMES,
|
|
"oidc_client_secret",
|
|
"oidc_api_client_secret",
|
|
"casdoor_admin_password",
|
|
)
|
|
|
|
|
|
def _generate_secrets(directory: Path) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
[sys.executable, str(SECRET_SCRIPT), "--directory", str(directory)],
|
|
cwd=ROOT,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def test_local_database_secrets_are_random_private_and_persistent(tmp_path: Path) -> None:
|
|
secret_dir = tmp_path / "database-secrets"
|
|
|
|
first = _generate_secrets(secret_dir)
|
|
assert first.returncode == 0, first.stderr
|
|
values = {
|
|
name: (secret_dir / name).read_text().strip()
|
|
for name in GENERATED_SECRET_NAMES
|
|
}
|
|
|
|
assert len(set(values.values())) == len(GENERATED_SECRET_NAMES)
|
|
assert all(len(value) == 64 for value in values.values())
|
|
assert all(set(value) <= set("0123456789abcdef") for value in values.values())
|
|
assert stat.S_IMODE(secret_dir.stat().st_mode) == 0o700
|
|
assert all(
|
|
stat.S_IMODE((secret_dir / name).stat().st_mode) == 0o604
|
|
for name in GENERATED_SECRET_NAMES
|
|
)
|
|
assert not any(value in first.stdout or value in first.stderr for value in values.values())
|
|
|
|
second = _generate_secrets(secret_dir)
|
|
assert second.returncode == 0, second.stderr
|
|
assert values == {
|
|
name: (secret_dir / name).read_text().strip()
|
|
for name in GENERATED_SECRET_NAMES
|
|
}
|
|
|
|
|
|
def test_local_database_secret_generator_refuses_a_symlink(tmp_path: Path) -> None:
|
|
secret_dir = tmp_path / "database-secrets"
|
|
secret_dir.mkdir()
|
|
target = tmp_path / "outside"
|
|
target.write_text("do-not-overwrite")
|
|
(secret_dir / "astrabox_password").symlink_to(target)
|
|
|
|
result = _generate_secrets(secret_dir)
|
|
|
|
assert result.returncode != 0
|
|
assert "symlink" in result.stderr.lower()
|
|
assert target.read_text() == "do-not-overwrite"
|
|
|
|
|
|
def test_local_database_secret_generator_refuses_a_symlink_directory(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
target = tmp_path / "outside"
|
|
target.mkdir()
|
|
link = tmp_path / "database-secrets"
|
|
link.symlink_to(target, target_is_directory=True)
|
|
|
|
result = _generate_secrets(link)
|
|
|
|
assert result.returncode != 0
|
|
assert "directory symlink" in result.stderr.lower()
|
|
assert not any(target.iterdir())
|
|
|
|
|
|
class _RotationRunner:
|
|
def __init__(self, *, psql_returncode: int = 0) -> None:
|
|
self.psql_returncode = psql_returncode
|
|
self.calls: list[tuple[list[str], str | None]] = []
|
|
|
|
def __call__(self, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
stdin = kwargs.get("input")
|
|
self.calls.append((list(args), stdin if isinstance(stdin, str) else None))
|
|
if args[:2] == ["docker", "inspect"]:
|
|
return subprocess.CompletedProcess(args, 0, "postgres|true\n", "")
|
|
return subprocess.CompletedProcess(args, self.psql_returncode, "", "hidden")
|
|
|
|
|
|
def test_database_rotation_updates_all_files_and_uses_psql_stdin(tmp_path: Path) -> None:
|
|
secret_dir = tmp_path / "database-secrets"
|
|
assert _generate_secrets(secret_dir).returncode == 0
|
|
old_values = {name: (secret_dir / name).read_text().strip() for name in SECRET_NAMES}
|
|
runner = _RotationRunner()
|
|
|
|
database_rotation.rotate_local_database_secrets(
|
|
secret_dir,
|
|
"astrabox-postgres-1",
|
|
run=runner,
|
|
)
|
|
|
|
new_values = {name: (secret_dir / name).read_text().strip() for name in SECRET_NAMES}
|
|
assert all(new_values[name] != old_values[name] for name in SECRET_NAMES)
|
|
assert len(set(new_values.values())) == len(SECRET_NAMES)
|
|
assert all(stat.S_IMODE((secret_dir / name).stat().st_mode) == 0o604 for name in SECRET_NAMES)
|
|
assert not (secret_dir / ".rotation-pending").exists()
|
|
psql_args, psql_stdin = runner.calls[-1]
|
|
assert psql_args[:3] == ["docker", "exec", "-i"]
|
|
assert psql_stdin is not None
|
|
assert all(value in psql_stdin for value in new_values.values())
|
|
assert all(value not in " ".join(psql_args) for value in new_values.values())
|
|
|
|
|
|
def test_database_rotation_failure_keeps_active_files_and_resumable_candidates(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
secret_dir = tmp_path / "database-secrets"
|
|
assert _generate_secrets(secret_dir).returncode == 0
|
|
old_values = {name: (secret_dir / name).read_text().strip() for name in SECRET_NAMES}
|
|
first_runner = _RotationRunner(psql_returncode=9)
|
|
|
|
with pytest.raises(database_rotation.RotationError, match="active secret files were not changed"):
|
|
database_rotation.rotate_local_database_secrets(
|
|
secret_dir,
|
|
"astrabox-postgres-1",
|
|
run=first_runner,
|
|
)
|
|
|
|
assert old_values == {
|
|
name: (secret_dir / name).read_text().strip() for name in SECRET_NAMES
|
|
}
|
|
pending = secret_dir / ".rotation-pending"
|
|
pending_values = {name: (pending / name).read_text().strip() for name in SECRET_NAMES}
|
|
second_runner = _RotationRunner()
|
|
database_rotation.rotate_local_database_secrets(
|
|
secret_dir,
|
|
"astrabox-postgres-1",
|
|
run=second_runner,
|
|
)
|
|
assert pending_values == {
|
|
name: (secret_dir / name).read_text().strip() for name in SECRET_NAMES
|
|
}
|
|
assert first_runner.calls[-1][1] == second_runner.calls[-1][1]
|
|
|
|
|
|
def test_database_rotation_refuses_wrong_container_before_writing_candidates(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
secret_dir = tmp_path / "database-secrets"
|
|
|
|
def wrong_service(
|
|
args: list[str], **kwargs: object
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.CompletedProcess(args, 0, "server|true\n", "")
|
|
|
|
with pytest.raises(database_rotation.RotationError, match="not the maintained"):
|
|
database_rotation.rotate_local_database_secrets(
|
|
secret_dir,
|
|
"astrabox-server-1",
|
|
run=wrong_service,
|
|
)
|
|
|
|
assert not (secret_dir / ".rotation-pending").exists()
|
|
|
|
|
|
def test_database_rotation_refuses_an_active_secret_symlink(tmp_path: Path) -> None:
|
|
secret_dir = tmp_path / "database-secrets"
|
|
secret_dir.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.write_text("do-not-overwrite")
|
|
(secret_dir / "astrabox_password").symlink_to(outside)
|
|
|
|
with pytest.raises(database_rotation.RotationError, match="secret symlink"):
|
|
database_rotation.rotate_local_database_secrets(
|
|
secret_dir,
|
|
"astrabox-postgres-1",
|
|
run=_RotationRunner(),
|
|
)
|
|
|
|
assert outside.read_text() == "do-not-overwrite"
|
|
|
|
|
|
def test_onebox_builds_database_urls_from_service_scoped_secret_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
astrabox_password = tmp_path / "astrabox"
|
|
litellm_password = tmp_path / "litellm"
|
|
astrabox_password.write_text("astra/box:secret")
|
|
litellm_password.write_text("lite@llm?secret")
|
|
# Empty values make the launcher derive both URLs while ensuring pytest's
|
|
# monkeypatch restores them after the launcher writes directly to os.environ.
|
|
monkeypatch.setenv("ASTRABOX_DB_URL", "")
|
|
monkeypatch.setenv("DATABASE_URL", "")
|
|
monkeypatch.setenv("ASTRABOX_DB_PASSWORD_FILE", str(astrabox_password))
|
|
monkeypatch.setenv("LITELLM_DATABASE_PASSWORD_FILE", str(litellm_password))
|
|
monkeypatch.setenv("ASTRABOX_DB_HOST", "postgres.internal")
|
|
monkeypatch.setenv("LITELLM_DATABASE_HOST", "postgres.internal")
|
|
|
|
onebox.ensure_database_wiring()
|
|
|
|
assert os.environ["ASTRABOX_DB_URL"] == (
|
|
"postgresql+asyncpg://astrabox:astra%2Fbox%3Asecret@"
|
|
"postgres.internal:5432/astrabox"
|
|
)
|
|
assert os.environ["DATABASE_URL"] == (
|
|
"postgresql://litellm:lite%40llm%3Fsecret@"
|
|
"postgres.internal:5432/litellm"
|
|
)
|
|
|
|
|
|
def test_explicit_database_urls_win_over_missing_secret_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("ASTRABOX_DB_URL", "postgresql://operator/astrabox")
|
|
monkeypatch.setenv("DATABASE_URL", "postgresql://operator/litellm")
|
|
monkeypatch.setenv("ASTRABOX_DB_PASSWORD_FILE", "/missing/astrabox")
|
|
monkeypatch.setenv("LITELLM_DATABASE_PASSWORD_FILE", "/missing/litellm")
|
|
|
|
onebox.ensure_database_wiring()
|
|
|
|
assert os.environ["ASTRABOX_DB_URL"] == "postgresql://operator/astrabox"
|
|
assert os.environ["DATABASE_URL"] == "postgresql://operator/litellm"
|
|
|
|
|
|
def test_a_configured_missing_database_secret_fails_before_process_spawn(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("ASTRABOX_DB_URL", raising=False)
|
|
monkeypatch.delenv("DATABASE_URL", raising=False)
|
|
monkeypatch.setenv("ASTRABOX_DB_PASSWORD_FILE", "/missing/astrabox")
|
|
monkeypatch.delenv("LITELLM_DATABASE_PASSWORD_FILE", raising=False)
|
|
|
|
with pytest.raises(onebox.OneBoxError, match="ASTRABOX_DB_PASSWORD_FILE"):
|
|
onebox.ensure_database_wiring()
|
|
|
|
|
|
def test_compose_keeps_postgresql_off_the_sandbox_bridge_and_uses_file_secrets() -> None:
|
|
compose = yaml.safe_load((ROOT / "containers" / "compose.yaml").read_text())
|
|
services = compose["services"]
|
|
postgres = services["postgres"]
|
|
server = services["server"]
|
|
sandbox_edge = services["sandbox-edge"]
|
|
sandbox_dns_edge = services["sandbox-dns-edge"]
|
|
|
|
assert postgres["ports"] == [
|
|
"127.0.0.1:${ASTRABOX_POSTGRES_PORT:-55432}:5432"
|
|
]
|
|
assert set(postgres["networks"]) == {"database"}
|
|
assert set(server["networks"]) == {"platform", "database", "sandbox-edges"}
|
|
# The server's only publication is the loopback console. Anything on the
|
|
# Docker bridge gateway is reachable by any sandbox whose egress admits
|
|
# the gateway, and the no-login API there is platform admin.
|
|
assert server["ports"] == ["127.0.0.1:${ASTRABOX_SERVER_HOST_PORT:-8088}:8000"]
|
|
# The edges reach the server over a private network with no route off the
|
|
# host; no sandbox joins it (OpenSandbox attaches sandboxes to `bridge`).
|
|
assert compose["networks"]["sandbox-edges"] == {"driver": "bridge", "internal": True}
|
|
assert server["environment"]["ASTRABOX_SANDBOX_EDGE_NETWORK"] == "sandbox-edges"
|
|
assert server["environment"]["ASTRABOX_SANDBOX_EGRESS_DENY_CIDRS"] == (
|
|
"${ASTRABOX_SANDBOX_EGRESS_DENY_CIDRS:-}"
|
|
)
|
|
for edge in (sandbox_edge, sandbox_dns_edge):
|
|
# Compose cannot dual-home a service onto the built-in bridge; onebox
|
|
# connects the edges to `sandbox-edges`.
|
|
assert edge["network_mode"] == "bridge"
|
|
assert "networks" not in edge
|
|
assert "ports" not in edge
|
|
assert "secrets" not in edge
|
|
assert "extra_hosts" not in edge
|
|
assert not any("docker.sock" in mount for mount in edge.get("volumes", []))
|
|
assert sandbox_edge["environment"]["ASTRABOX_EDGE_UPSTREAM_HOST"] == "server"
|
|
assert sandbox_dns_edge["volumes"] == [
|
|
"./sandbox-edge/dns-edge.nginx.conf:/etc/nginx/nginx.conf:ro"
|
|
]
|
|
for edge_name in ("sandbox-edge", "sandbox-dns-edge"):
|
|
assert server["depends_on"][edge_name] == {
|
|
"condition": "service_started",
|
|
"restart": True,
|
|
}
|
|
assert server["environment"]["ASTRABOX_SANDBOX_SERVER_NETWORK_MODE"] == "bridge"
|
|
assert server["environment"]["ASTRABOX_SANDBOX_SERVER_PORT_RANGE"] == (
|
|
"${ASTRABOX_SANDBOX_SERVER_PORT_RANGE:-20000-32000}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_SANDBOX_EGRESS_IMAGE"] == (
|
|
"${ASTRABOX_SANDBOX_EGRESS_IMAGE:-opensandbox/egress:v1.1.7}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_SANDBOX_EGRESS_MODE"] == (
|
|
"${ASTRABOX_SANDBOX_EGRESS_MODE:-dns+nft}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_SANDBOX_EDGE_SERVICE"] == "sandbox-edge"
|
|
assert server["environment"]["ASTRABOX_SANDBOX_DNS_EDGE_SERVICE"] == (
|
|
"sandbox-dns-edge"
|
|
)
|
|
assert server["environment"]["ASTRABOX_SANDBOX_EGRESS_DNS_UPSTREAM_DEFAULT"] == ""
|
|
assert server["environment"]["ASTRABOX_MCP_PROXY_BASE_URL"] == (
|
|
"${ASTRABOX_MCP_PROXY_BASE_URL:-}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_SANDBOX_GATEWAY_IP"] == (
|
|
"${ASTRABOX_SANDBOX_GATEWAY_IP:-}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_DB_PASSWORD_FILE"] == (
|
|
"/run/secrets/astrabox_database_password"
|
|
)
|
|
assert server["environment"]["LITELLM_DATABASE_PASSWORD_FILE"] == (
|
|
"/run/secrets/litellm_database_password"
|
|
)
|
|
assert server["environment"]["ASTRABOX_LITELLM_ADMIN_URL"] == (
|
|
"${ASTRABOX_LITELLM_ADMIN_URL:-}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_AUTH_SESSION_SECRET"] == (
|
|
"${ASTRABOX_AUTH_SESSION_SECRET:-}"
|
|
)
|
|
for provider_passthrough in (
|
|
"ANTHROPIC_BASE_URL",
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_MODEL",
|
|
"OPENAI_API_KEY",
|
|
"GEMINI_API_KEY",
|
|
"DEEPSEEK_API_KEY",
|
|
):
|
|
assert server["environment"][provider_passthrough] is None, (
|
|
"an unset provider variable must be omitted from the container, not "
|
|
"forwarded as an empty string"
|
|
)
|
|
assert set(server["secrets"]) == {
|
|
"astrabox_database_password",
|
|
"litellm_database_password",
|
|
}
|
|
|
|
rendered = (ROOT / "containers" / "compose.yaml").read_text()
|
|
for known_default in (
|
|
"POSTGRES_ADMIN_PASSWORD:-postgres",
|
|
"ASTRABOX_POSTGRES_PASSWORD:-astrabox",
|
|
"LITELLM_POSTGRES_PASSWORD:-litellm",
|
|
"CASDOOR_POSTGRES_PASSWORD:-casdoor",
|
|
"astrabox:astrabox@",
|
|
"litellm:litellm@",
|
|
):
|
|
assert known_default not in rendered
|
|
|
|
|
|
def test_kubernetes_compose_overlay_clears_docker_only_edge_discovery() -> None:
|
|
class ComposeLoader(yaml.SafeLoader):
|
|
pass
|
|
|
|
def construct_override(loader: yaml.SafeLoader, node: yaml.Node) -> object:
|
|
if isinstance(node, yaml.SequenceNode):
|
|
return loader.construct_sequence(node)
|
|
if isinstance(node, yaml.MappingNode):
|
|
return loader.construct_mapping(node)
|
|
return loader.construct_scalar(node)
|
|
|
|
ComposeLoader.add_constructor("!override", construct_override)
|
|
ComposeLoader.add_constructor("!reset", lambda loader, node: None)
|
|
base = yaml.safe_load((ROOT / "containers" / "compose.yaml").read_text())
|
|
overlay = yaml.load(
|
|
(ROOT / "containers" / "compose.kubernetes.yaml").read_text(),
|
|
Loader=ComposeLoader,
|
|
)
|
|
environment = {
|
|
**base["services"]["server"]["environment"],
|
|
**overlay["services"]["server"]["environment"],
|
|
}
|
|
|
|
assert environment["ASTRABOX_SANDBOX_SERVER_RUNTIME"] == "kubernetes"
|
|
for required in (
|
|
"ASTRABOX_MCP_PROXY_BASE_URL",
|
|
"ASTRABOX_LITELLM_BASE_URL",
|
|
"ASTRABOX_ALLOWED_HOSTS",
|
|
):
|
|
assert ":?" in overlay["services"]["server"]["environment"][required]
|
|
|
|
reset = overlay["services"]["server"]["environment"]
|
|
for docker_only in (
|
|
"ASTRABOX_SANDBOX_EDGE_SERVICE",
|
|
"ASTRABOX_SANDBOX_DNS_EDGE_SERVICE",
|
|
"ASTRABOX_SANDBOX_EDGE_CALLBACK_PORT",
|
|
"ASTRABOX_SANDBOX_EDGE_NETWORK",
|
|
"ASTRABOX_SANDBOX_SERVER_NETWORK_MODE",
|
|
"ASTRABOX_SANDBOX_SERVER_PORT_RANGE",
|
|
"ASTRABOX_PUBLISH_HOST_IP",
|
|
"ASTRABOX_SANDBOX_GATEWAY_IP",
|
|
"ASTRABOX_SANDBOX_EGRESS_DNS_UPSTREAM",
|
|
"ASTRABOX_SANDBOX_EGRESS_DNS_UPSTREAM_DEFAULT",
|
|
):
|
|
assert reset[docker_only] is None
|
|
|
|
server = overlay["services"]["server"]
|
|
assert server["depends_on"] == {
|
|
"postgres": {"condition": "service_healthy"},
|
|
"redis": {"condition": "service_healthy"},
|
|
}
|
|
assert server["volumes"] == [
|
|
"astrabox-state:/data",
|
|
"${ASTRABOX_KUBECONFIG_HOST_PATH:?set a kubeconfig readable by uid 999}:/etc/astrabox/kubeconfig:ro",
|
|
]
|
|
assert server["group_add"] == []
|
|
assert server["extra_hosts"] == []
|
|
assert ":?" in server["ports"][0]
|
|
assert overlay["services"]["sandbox-edge"]["profiles"] == [
|
|
"docker-runtime-only"
|
|
]
|
|
assert overlay["services"]["sandbox-dns-edge"]["profiles"] == [
|
|
"docker-runtime-only"
|
|
]
|
|
|
|
|
|
def test_sandbox_edge_forwards_only_model_and_capability_scoped_platform_routes() -> None:
|
|
template = (ROOT / "containers" / "sandbox-edge" / "default.conf.template").read_text()
|
|
directives = [
|
|
line.strip()
|
|
for line in template.splitlines()
|
|
if line.strip() and not line.lstrip().startswith("#")
|
|
]
|
|
|
|
assert "listen 80 default_server;" in directives
|
|
assert "listen 8000 default_server;" in directives
|
|
assert "location ^~ /api/v1/platform-mcp/ {" in directives
|
|
assert "/api/v1/mcp-proxy/" not in template
|
|
assert "location ^~ /api/v1/sbxcap/ {" in directives
|
|
assert "location / {\n return 404;" in template
|
|
# The upstream is the Compose server over the private network, resolved per
|
|
# request by Docker's embedded DNS; never the bridge gateway, and never a
|
|
# destination taken from the request (that would make the edge an SSRF
|
|
# proxy): the only variable proxy_pass reads is set from the fixed name.
|
|
assert "host.docker.internal" not in template
|
|
assert "resolver 127.0.0.11 valid=10s ipv6=off;" in directives
|
|
assert [d for d in directives if d.startswith("set ")] == [
|
|
"set $astrabox_upstream ${ASTRABOX_EDGE_UPSTREAM_HOST};"
|
|
] * 2
|
|
assert sorted(d for d in directives if d.startswith("proxy_pass")) == sorted(
|
|
["proxy_pass http://$astrabox_upstream:80;"]
|
|
+ ["proxy_pass http://$astrabox_upstream:8000;"] * 3
|
|
)
|
|
|
|
dns_edge = (ROOT / "containers" / "sandbox-edge" / "dns-edge.nginx.conf").read_text()
|
|
dns_directives = [
|
|
line.strip()
|
|
for line in dns_edge.splitlines()
|
|
if line.strip() and not line.lstrip().startswith("#")
|
|
]
|
|
assert "http {" not in dns_directives
|
|
assert "resolver 127.0.0.11 valid=10s ipv6=off;" in dns_directives
|
|
assert "listen 53 udp;" in dns_directives
|
|
assert "listen 53;" in dns_directives
|
|
assert [d for d in dns_directives if d.startswith(("set ", "proxy_pass"))] == [
|
|
"set $astrabox_dns server:5353;",
|
|
"proxy_pass $astrabox_dns;",
|
|
] * 2
|
|
|
|
gateway_dns = (ROOT / "containers" / "coredns" / "Corefile").read_text()
|
|
first_directive = next(
|
|
line.strip()
|
|
for line in gateway_dns.splitlines()
|
|
if line.strip() and not line.lstrip().startswith("#")
|
|
)
|
|
assert first_directive == ". {"
|
|
assert not any(
|
|
line.strip().startswith(".:53")
|
|
for line in gateway_dns.splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
)
|
|
assert "gateway.astrabox.test" in gateway_dns
|
|
|
|
|
|
def test_source_overlay_mounts_code_and_bundled_gateway_adapters() -> None:
|
|
overlay = yaml.safe_load(
|
|
(ROOT / "containers" / "compose.source.yaml").read_text()
|
|
)
|
|
server = overlay["services"]["server"]
|
|
|
|
assert server["volumes"] == [
|
|
"../astrabox:/usr/local/lib/python3.12/site-packages/astrabox:ro",
|
|
"../channel-gateway/src:/opt/astrabox/channel-gateway/src:ro",
|
|
"./litellm/config.yaml:/opt/astrabox/litellm/config.yaml:ro",
|
|
"../astrabox/identity/oidc.py:/opt/astrabox/litellm/astrabox_oidc.py:ro",
|
|
"../astrabox/identity/session_signing.py:/opt/astrabox/litellm/astrabox_identity_session.py:ro",
|
|
"../astrabox/providers/litellm_shared_auth.py:/opt/astrabox/litellm/astrabox_litellm_auth.py:ro",
|
|
"./litellm/custom_auth.py:/opt/astrabox/litellm/custom_auth.py:ro",
|
|
"./litellm/langfuse_session_hook.py:/opt/astrabox/litellm/langfuse_session_hook.py:ro",
|
|
"./litellm/langfuse_tracing.py:/opt/astrabox/litellm/langfuse_tracing.py:ro",
|
|
"./litellm/stream_normalization_hook.py:/opt/astrabox/litellm/stream_normalization_hook.py:ro",
|
|
]
|
|
assert server["command"][:4] == ["python", "-m", "uvicorn", "astrabox.api.app:create_app"]
|
|
assert "--reload" in server["command"]
|
|
assert "networks" not in server
|
|
assert "ports" not in server
|
|
assert "environment" not in server
|
|
|
|
|
|
def test_source_and_live_e2e_launchers_reuse_the_maintained_compose_topology() -> None:
|
|
dev = (ROOT / "scripts" / "dev.sh").read_text()
|
|
browser_backend = (ROOT / "e2e" / "scripts" / "serve-backend.sh").read_text()
|
|
smoke = (ROOT / "scripts" / "e2e_smoke.sh").read_text()
|
|
makefile = (ROOT / "Makefile").read_text()
|
|
|
|
for launcher in (dev, browser_backend):
|
|
assert '"$REPO_ROOT/scripts/compose.sh"' in launcher
|
|
assert "containers/compose.source.yaml" in launcher
|
|
assert "ASTRABOX_LOCAL_DATABASE_SECRET_DIR" in launcher
|
|
# The server publishes nothing on the bridge gateway, so there is no
|
|
# per-stack gateway port left to move.
|
|
for retired in (
|
|
"ASTRABOX_SERVER_SANDBOX_PORT",
|
|
"ASTRABOX_MODEL_GATEWAY_HOST_PORT",
|
|
"ASTRABOX_GATEWAY_DNS_HOST_PORT",
|
|
):
|
|
assert retired not in launcher
|
|
assert "postgresql+asyncpg://astrabox:" not in launcher
|
|
assert "postgresql://litellm:" not in launcher
|
|
assert "http://172.17.0.1" not in launcher
|
|
|
|
assert "exec bash e2e/scripts/serve-backend.sh" in smoke
|
|
assert "postgresql+asyncpg://astrabox:" not in smoke
|
|
assert "http://172.17.0.1" not in smoke
|
|
assert "ASTRABOX_DEV_BACKEND_ONLY=1" in makefile
|
|
assert "postgresql+asyncpg://astrabox:" not in makefile
|
|
|
|
|
|
def test_sso_overlay_keeps_casdoor_on_its_own_database_secret_and_loopback_port() -> None:
|
|
overlay = yaml.safe_load((ROOT / "containers" / "compose.sso.yaml").read_text())
|
|
casdoor = overlay["services"]["casdoor"]
|
|
server = overlay["services"]["server"]
|
|
|
|
assert set(casdoor["networks"]) == {"platform", "database"}
|
|
assert casdoor["ports"] == [
|
|
"127.0.0.1:${ASTRABOX_CASDOOR_HOST_PORT:-8087}:8000"
|
|
]
|
|
assert casdoor["environment"]["CASDOOR_DATABASE_PASSWORD_FILE"] == (
|
|
"/run/secrets/casdoor_database_password"
|
|
)
|
|
assert casdoor["secrets"] == [
|
|
"casdoor_database_password",
|
|
"oidc_client_secret",
|
|
"oidc_api_client_secret",
|
|
"casdoor_admin_password",
|
|
"casdoor_builtin_admin_password",
|
|
]
|
|
assert casdoor["environment"]["CASDOOR_OIDC_CLIENT_SECRET_FILE"] == (
|
|
"/run/secrets/oidc_client_secret"
|
|
)
|
|
assert casdoor["environment"]["CASDOOR_OIDC_API_CLIENT_SECRET_FILE"] == (
|
|
"/run/secrets/oidc_api_client_secret"
|
|
)
|
|
assert casdoor["environment"]["CASDOOR_ADMIN_PASSWORD_FILE"] == (
|
|
"/run/secrets/casdoor_admin_password"
|
|
)
|
|
assert casdoor["environment"]["ASTRABOX_CONSOLE_ORIGIN"] == (
|
|
"${ASTRABOX_CONSOLE_ORIGIN:-http://127.0.0.1:${ASTRABOX_SERVER_HOST_PORT:-8088}}"
|
|
)
|
|
assert casdoor["environment"]["origin"] == (
|
|
"${ASTRABOX_OIDC_ISSUER:-http://127.0.0.1:${ASTRABOX_CASDOOR_HOST_PORT:-8087}}"
|
|
)
|
|
# Healthy only when built-in/admin rejects Casdoor's default password;
|
|
# AstraBox waits for a healthy Casdoor.
|
|
assert casdoor["healthcheck"] == {
|
|
"test": [
|
|
"CMD-SHELL",
|
|
"test -f /run/astrabox-casdoor/built-in-admin-checked && "
|
|
"curl -fsS -o /dev/null http://127.0.0.1:8000/.well-known/openid-configuration",
|
|
],
|
|
"interval": "5s",
|
|
"timeout": "5s",
|
|
"retries": 12,
|
|
"start_period": "5s",
|
|
}
|
|
assert casdoor["tmpfs"] == [
|
|
"/run/astrabox-casdoor:rw,noexec,nosuid,nodev,size=65536,mode=0700,uid=1000,gid=1000"
|
|
]
|
|
assert server["depends_on"]["casdoor"]["condition"] == "service_healthy"
|
|
assert server["environment"]["ASTRABOX_OIDC_ISSUER"] == (
|
|
"${ASTRABOX_OIDC_ISSUER:-http://127.0.0.1:${ASTRABOX_CASDOOR_HOST_PORT:-8087}}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_CASDOOR_ADMIN_URL"] == (
|
|
"${ASTRABOX_CASDOOR_ADMIN_URL:-${ASTRABOX_OIDC_ISSUER:-"
|
|
"http://127.0.0.1:${ASTRABOX_CASDOOR_HOST_PORT:-8087}}}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_CASDOOR_API_ACCESS_URL"] == (
|
|
"${ASTRABOX_CASDOOR_API_ACCESS_URL:-${ASTRABOX_OIDC_ISSUER:-"
|
|
"http://127.0.0.1:${ASTRABOX_CASDOOR_HOST_PORT:-8087}}/applications/"
|
|
"astrabox/astrabox-api}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_OIDC_CLIENT_SECRET_FILE"] == (
|
|
"/run/secrets/oidc_client_secret"
|
|
)
|
|
assert server["environment"]["ASTRABOX_OIDC_API_CLIENT_ID"] == (
|
|
"${ASTRABOX_OIDC_API_CLIENT_ID:-astrabox-api}"
|
|
)
|
|
assert server["environment"]["ASTRABOX_OIDC_API_CLIENT_SECRET_FILE"] == (
|
|
"/run/secrets/oidc_api_client_secret"
|
|
)
|
|
assert server["secrets"] == ["oidc_client_secret", "oidc_api_client_secret"]
|
|
init_template = (ROOT / "containers" / "casdoor" / "init_data.json").read_text()
|
|
assert "astrabox-local-secret-change-me" not in init_template
|
|
assert '"password": "astrabox-admin"' not in init_template
|
|
assert '"grantTypes": ["authorization_code", "password"]' in init_template
|
|
assert '"grantTypes": ["client_credentials"]' in init_template
|
|
assert '"expireInHours": 1' in init_template
|
|
assert '"name": "astrabox:read"' in init_template
|
|
assert '"name": "astrabox:write"' in init_template
|
|
assert '"name": "astrabox:admin"' in init_template
|
|
casdoor_config = (ROOT / "containers" / "casdoor" / "app.conf").read_text()
|
|
assert "initDataNewOnly = true" in casdoor_config, (
|
|
"Casdoor must seed only missing records so a container restart cannot "
|
|
"overwrite an operator-rotated admin password or OIDC client secret"
|
|
)
|
|
assert 'initDataFile = "/run/astrabox-casdoor/init_data.json"' in casdoor_config
|
|
entrypoint = (ROOT / "containers" / "casdoor" / "entrypoint.sh").read_text()
|
|
assert 'init_data=/run/astrabox-casdoor/init_data.json' in entrypoint
|
|
assert '>/init_data.json' not in entrypoint
|
|
|
|
|
|
def test_compose_wrapper_keeps_machine_readable_compose_stdout_clean() -> None:
|
|
wrapper = (ROOT / "scripts" / "compose.sh").read_text()
|
|
|
|
assert '--directory "$SECRET_DIR" >/dev/null' in wrapper
|