Files
AstraBox/tests/install_script_test.py
Colton QiandClaude 5ed0137859 Release 0.1.1
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>
2026-09-26 19:09:28 -07:00

674 lines
24 KiB
Python

"""The installer's file-writing logic, which a user's first run depends on.
The installer generates the same deployment secrets a checkout generates with
``scripts/ensure_local_database_secrets.py``, without needing Python on the
host, writes the model settings the bundled gateway reads, and keeps an
installation's settings across upgrades. Each of these is read back by another
program — PostgreSQL through Compose secrets, the deployment's entry point and
LiteLLM through the settings file — so the checks run those readers' rules
rather than comparing the installer's output with a copy of itself.
"""
from __future__ import annotations
import fnmatch
import importlib.util
import json
import os
import shlex
import shutil
import stat
import subprocess
import textwrap
from pathlib import Path
import pytest
import yaml
from astrabox.deploy import onebox
_REPO_ROOT = Path(__file__).resolve().parents[1]
_INSTALLER = _REPO_ROOT / "scripts/install.sh"
_GATEWAY_CONFIG = _REPO_ROOT / "containers/litellm/config.yaml"
_SECRET_NAMES = (
"postgres_admin_password",
"astrabox_password",
"litellm_password",
"casdoor_password",
"oidc_client_secret",
"oidc_api_client_secret",
"casdoor_admin_password",
"casdoor_builtin_admin_password",
)
#: Every name the installer or the deployment entry point may set for a model
#: service; each case starts from none of them.
_MODEL_ENVIRONMENT = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_MODEL",
"DEEPSEEK_API_KEY",
"OPENAI_API_KEY",
"OPENAI_COMPATIBLE_API_KEY",
"OPENAI_COMPATIBLE_BASE_URL",
)
def _fake_docker(directory: Path, *, volume_exists: bool) -> Path:
"""A ``docker`` the installer can call without a daemon."""
binaries = directory / "bin"
binaries.mkdir(parents=True, exist_ok=True)
script = binaries / "docker"
script.write_text(
textwrap.dedent(
f"""
#!/usr/bin/env bash
case "$1 $2" in
"volume inspect") exit {0 if volume_exists else 1} ;;
esac
exit 0
"""
).lstrip(),
encoding="utf-8",
)
script.chmod(0o755)
return binaries
def _run(
snippet: str,
*,
tmp_path: Path,
volume_exists: bool = False,
) -> subprocess.CompletedProcess[str]:
binaries = _fake_docker(tmp_path, volume_exists=volume_exists)
return subprocess.run(
["bash", "-c", f'source "{_INSTALLER}"\n{textwrap.dedent(snippet)}'],
capture_output=True,
text=True,
cwd=tmp_path,
env={"PATH": f"{binaries}:/usr/bin:/bin", "HOME": str(tmp_path)},
)
def _load_checkout_generator():
specification = importlib.util.spec_from_file_location(
"ensure_local_database_secrets",
_REPO_ROOT / "scripts/ensure_local_database_secrets.py",
)
assert specification is not None and specification.loader is not None
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
def _settings(env_file: Path) -> dict[str, str]:
"""The settings file as Compose reads it: single quotes are not part of a value."""
written: dict[str, str] = {}
for line in env_file.read_text(encoding="utf-8").splitlines():
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
written[key] = value.removeprefix("'").removesuffix("'")
return written
def test_generated_secrets_are_the_ones_a_checkout_generates(tmp_path: Path) -> None:
secrets = tmp_path / "database-secrets"
result = _run(f'ensure_secrets "{secrets}" astrabox-postgres', tmp_path=tmp_path)
assert result.returncode == 0, result.stderr
generator = _load_checkout_generator()
assert sorted(path.name for path in secrets.iterdir()) == sorted(_SECRET_NAMES)
for name in _SECRET_NAMES:
# The checkout's reader validates format and refuses anything else, so
# accepting these files proves the two generators agree.
assert generator._existing_secret(secrets / name)
assert stat.S_IMODE((secrets / name).stat().st_mode) == 0o604
assert stat.S_IMODE(secrets.stat().st_mode) == 0o700
kept = {name: (secrets / name).read_text(encoding="ascii") for name in _SECRET_NAMES}
assert _run(f'ensure_secrets "{secrets}" astrabox-postgres', tmp_path=tmp_path).returncode == 0
assert {
name: (secrets / name).read_text(encoding="ascii") for name in _SECRET_NAMES
} == kept, "an upgrade must not rotate the database passwords behind PostgreSQL"
def test_missing_secrets_beside_an_existing_database_stop_the_installation(
tmp_path: Path,
) -> None:
result = _run(
f'ensure_secrets "{tmp_path}/database-secrets" astrabox-postgres',
tmp_path=tmp_path,
volume_exists=True,
)
assert result.returncode != 0
assert "PostgreSQL volume" in result.stderr
assert not (tmp_path / "database-secrets").exists(), (
"a refused installation must not leave passwords that do not open the database"
)
def test_the_settings_file_keeps_values_verbatim_and_lines_it_did_not_write(
tmp_path: Path,
) -> None:
env_file = tmp_path / ".env"
result = _run(
f"""
create_env_file "{env_file}"
printf "%s\\n" "MY_OWN_SETTING='keep me'" >> "{env_file}"
env_set "{env_file}" ANTHROPIC_API_KEY 'sk-a$b#c'
env_set "{env_file}" ASTRABOX_IMAGE_TAG 0.1.0
env_set "{env_file}" ASTRABOX_IMAGE_TAG 0.2.0
env_get "{env_file}" ANTHROPIC_API_KEY
""",
tmp_path=tmp_path,
)
assert result.returncode == 0, result.stderr
content = env_file.read_text(encoding="utf-8")
assert result.stdout == "sk-a$b#c"
assert "MY_OWN_SETTING='keep me'" in content
assert content.count("ASTRABOX_IMAGE_TAG=") == 1
assert "ASTRABOX_IMAGE_TAG='0.2.0'" in content
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
def _gateway_route(model: str) -> tuple[dict[str, str], str]:
"""The bundled route LiteLLM serves ``model`` from, and the upstream model id.
LiteLLM serves an exact ``model_name`` before a wildcard pattern, and a
pattern's ``*`` carries the matched text into ``litellm_params.model``.
"""
routes = yaml.safe_load(_GATEWAY_CONFIG.read_text(encoding="utf-8"))["model_list"]
exact = [route for route in routes if route["model_name"] == model]
if exact:
return exact[0]["litellm_params"], exact[0]["litellm_params"]["model"]
matches = [
route
for route in routes
if "*" in route["model_name"] and fnmatch.fnmatchcase(model, route["model_name"])
]
assert len(matches) == 1, f"{model!r} matches {len(matches)} bundled routes"
prefix, suffix = matches[0]["model_name"].split("*", 1)
matched = model[len(prefix) : len(model) - len(suffix)]
params = matches[0]["litellm_params"]
return params, params["model"].replace("*", matched)
def _resolved(value: str | None) -> str | None:
"""A route value as LiteLLM reads it: ``os.environ/NAME`` names a variable."""
if value is not None and value.startswith("os.environ/"):
return os.environ.get(value.removeprefix("os.environ/")) or None
return value
@pytest.mark.parametrize(
("provider", "model", "base_url", "upstream_base", "upstream_model"),
[
("anthropic", "claude-sonnet-4-5", "", None, "anthropic/claude-sonnet-4-5"),
# The offered default. The route is pinned for both of DeepSeek's wires;
# `GET https://api.deepseek.com/v1/models` lists the ids DeepSeek serves.
(
"deepseek",
"deepseek-flash",
"",
"https://api.deepseek.com/anthropic",
"anthropic/deepseek-flash",
),
(
"anthropic-compatible",
"glm-5",
"https://models.example.com/anthropic",
"https://models.example.com/anthropic",
"anthropic/glm-5",
),
(
"openai-compatible",
"qwen3-max",
"https://models.example.com/v1",
"https://models.example.com/v1",
"openai/qwen3-max",
),
],
)
def test_each_model_service_is_reached_with_the_key_url_and_model_it_was_given(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
provider: str,
model: str,
base_url: str,
upstream_base: str | None,
upstream_model: str,
) -> None:
"""Follow the written settings through the entry point to the gateway route.
The deployment's entry point rewrites the default model before LiteLLM
starts (``onebox.ensure_litellm_provider_wiring``), and the gateway picks a
route by that name. A setting written under a name the route does not read,
or a model name no route serves, gives an installation that starts and then
fails its first Agent turn.
"""
env_file = tmp_path / ".env"
result = _run(
f"""
create_env_file "{env_file}"
provider={provider}
model_key=sk-installed-key
model_name={model}
model_base_url={base_url}
validate_model_settings
write_model_settings "{env_file}"
""",
tmp_path=tmp_path,
)
assert result.returncode == 0, result.stderr
written = _settings(env_file)
assert written.pop("ASTRABOX_INSTALL_MODEL_PROVIDER") == provider
for name in _MODEL_ENVIRONMENT:
# Set before deleting, so the undo also removes what the entry point
# writes into os.environ below.
monkeypatch.setenv(name, "")
monkeypatch.delenv(name)
for name, value in written.items():
monkeypatch.setenv(name, value)
onebox.ensure_litellm_provider_wiring()
route, routed_model = _gateway_route(os.environ["ANTHROPIC_MODEL"])
assert routed_model == upstream_model
assert _resolved(route.get("api_base")) == upstream_base
assert _resolved(route["api_key"]) == "sk-installed-key"
def test_choosing_another_service_removes_the_previous_services_settings(
tmp_path: Path,
) -> None:
"""A leftover base URL would send the new key to the old service's host."""
env_file = tmp_path / ".env"
result = _run(
f"""
create_env_file "{env_file}"
printf "%s\\n" "DEEPSEEK_API_KEY='set by the operator'" >> "{env_file}"
provider=anthropic-compatible model_key=sk-first model_name=glm-5
model_base_url=https://first.example.com
write_model_settings "{env_file}"
provider=openai-compatible model_key=sk-second model_name=qwen3-max
model_base_url=https://second.example.com/v1
write_model_settings "{env_file}"
""",
tmp_path=tmp_path,
)
assert result.returncode == 0, result.stderr
assert _settings(env_file) == {
"DEEPSEEK_API_KEY": "set by the operator",
"ASTRABOX_INSTALL_MODEL_PROVIDER": "openai-compatible",
"OPENAI_COMPATIBLE_BASE_URL": "https://second.example.com/v1",
"OPENAI_COMPATIBLE_API_KEY": "sk-second",
"ANTHROPIC_MODEL": "openai-compatible/qwen3-max",
}
def test_a_model_service_without_a_key_is_refused_before_the_stack_starts(
tmp_path: Path,
) -> None:
result = _run(
"""
provider=anthropic
model_key=
model_name=claude-sonnet-4-5
validate_model_settings
""",
tmp_path=tmp_path,
)
assert result.returncode != 0
assert "needs an API key" in result.stderr
def test_a_server_that_cannot_start_ends_the_wait_with_its_log(tmp_path: Path) -> None:
"""A crash-looping server answers no HTTP request, so the poll must read it.
Without this the installer waits out its whole readiness budget while the
container prints the reason it exited on every restart.
"""
result = _run(
"""
compose() {
case "$1" in
ps) printf 'container-id\n' ;;
logs) printf 'FATAL: the port range overlaps the ephemeral range\n' ;;
esac
}
docker() { printf 'restarting\n'; }
http_status() { printf '000'; }
install_dir=/nonexistent
wait_until_ready http://127.0.0.1:1
""",
tmp_path=tmp_path,
)
assert result.returncode != 0
assert "server container is restarting" in result.stderr
assert "FATAL: the port range overlaps the ephemeral range" in result.stderr
# ── Docker versions ──────────────────────────────────────────────────────────
def _check_versions(
tmp_path: Path, *, compose: str, engine: str, api: str
) -> subprocess.CompletedProcess[str]:
"""``check_docker_versions`` against a daemon and plugin reporting these versions."""
return _run(
f"""
docker() {{
case "$*" in
"compose version --short") printf '%s\\n' '{compose}' ;;
"version --format {{{{.Server.Version}}}}") printf '%s\\n' '{engine}' ;;
"version --format {{{{.Server.APIVersion}}}}") printf '%s\\n' '{api}' ;;
*) return 1 ;;
esac
}}
check_docker_versions
""",
tmp_path=tmp_path,
)
@pytest.mark.parametrize(
("engine", "api"), [("25.0.5", "1.44"), ("24.0.9", "1.43"), ("20.10.24", "1.41")]
)
def test_a_docker_engine_older_than_the_minimum_is_refused(
tmp_path: Path, engine: str, api: str
) -> None:
result = _check_versions(tmp_path, compose="2.29.7", engine=engine, api=api)
assert result.returncode != 0
assert f"found Docker Engine {engine} (API {api})" in result.stderr
assert "Docker Engine 26.0 (API 1.45) or later is required" in result.stderr
@pytest.mark.parametrize(
("compose", "engine", "api"),
[
("2.17.0", "26.0.0", "1.45"),
("v2.40.3", "29.8.1", "1.52"),
("2.29.7-desktop.1", "27.3.1", "1.47"),
],
)
def test_the_minimum_versions_and_later_are_accepted(
tmp_path: Path, compose: str, engine: str, api: str
) -> None:
result = _check_versions(tmp_path, compose=compose, engine=engine, api=api)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize("compose", ["2.16.0", "2.9.0", "1.29.2"])
def test_a_compose_plugin_older_than_the_minimum_is_refused(tmp_path: Path, compose: str) -> None:
# 2.9.0 sorts after 2.17.0 as text; the parts are numbers.
result = _check_versions(tmp_path, compose=compose, engine="29.8.1", api="1.52")
assert result.returncode != 0
assert f"Docker Compose 2.17.0 or later is required; found {compose}" in result.stderr
# ── team login ───────────────────────────────────────────────────────────────
def _installation(tmp_path: Path) -> Path:
"""An installed bundle's directory, holding the overlay team login runs."""
install_dir = tmp_path / "astrabox"
(install_dir / "containers").mkdir(parents=True)
(install_dir / "VERSION").write_text("0.1.0\n", encoding="utf-8")
shutil.copy(
_REPO_ROOT / "containers/compose.sso.yaml",
install_dir / "containers/compose.sso.yaml",
)
return install_dir
def _configure_team_login(
install_dir: Path, tmp_path: Path, settings: str = "", answers: str | None = None
) -> subprocess.CompletedProcess[str]:
"""Run the installer's team-login step as ``main`` does.
``answers`` stands for a terminal: the installer reads its questions'
answers on descriptor 3. Without it the run has no terminal.
"""
terminal = (
f"have_tty=1; exec 3< <(printf '%s' {shlex.quote(answers)})"
if answers is not None
else "have_tty=0"
)
return _run(
f"""
install_dir="{install_dir}"
{terminal}
{settings}
create_env_file "{install_dir}/containers/.env"
configure_team_login "{install_dir}/containers/.env"
""",
tmp_path=tmp_path,
)
_PUBLIC_URLS = """
export ASTRABOX_INSTALL_TEAM_LOGIN=casdoor
export ASTRABOX_CONSOLE_ORIGIN=https://astrabox.example.com
export ASTRABOX_OIDC_ISSUER=https://login.example.com
"""
def test_team_login_runs_the_overlay_with_every_later_compose_command(
tmp_path: Path,
) -> None:
"""Compose reads COMPOSE_FILE from the settings file in containers/.
The installer starts the stack with it, and so does every `docker compose`
command an operator later runs there; a login that only the installer's
own start included would disappear on the operator's next `up`.
"""
install_dir = _installation(tmp_path)
result = _configure_team_login(
install_dir, tmp_path, "export ASTRABOX_INSTALL_TEAM_LOGIN=casdoor"
)
assert result.returncode == 0, result.stderr
written = _settings(install_dir / "containers/.env")
compose_files = written["COMPOSE_FILE"].split(":")
assert compose_files == ["compose.yaml", "compose.sso.yaml"]
# On this host only, the overlay's loopback defaults are the addresses.
for name in (
"ASTRABOX_CONSOLE_ORIGIN",
"ASTRABOX_OIDC_ISSUER",
"ASTRABOX_OIDC_REDIRECT_URL",
"ASTRABOX_ALLOWED_HOSTS",
):
assert name not in written
def test_public_urls_reach_the_server_and_casdoor_as_one_login(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Behind a TLS proxy the server sees plain HTTP from the proxy.
Login then works only when the server accepts the public Host, names to
Casdoor the very callback Casdoor registered for the console, and marks
its cookies Secure. Each is checked with the reader's own rule.
"""
install_dir = _installation(tmp_path)
result = _configure_team_login(install_dir, tmp_path, _PUBLIC_URLS)
assert result.returncode == 0, result.stderr
written = _settings(install_dir / "containers/.env")
assert written["ASTRABOX_CONSOLE_ORIGIN"] == "https://astrabox.example.com"
assert written["ASTRABOX_OIDC_ISSUER"] == "https://login.example.com"
for name, value in written.items():
monkeypatch.setenv(name, value)
monkeypatch.setenv("ASTRABOX_OIDC_CLIENT_ID", "astrabox-console")
monkeypatch.setenv("ASTRABOX_OIDC_CLIENT_SECRET", "0" * 64)
from starlette.requests import Request
from astrabox.api.routes.auth import _cookie_secure, _redirect_uri
from astrabox.identity.oidc import OidcProviderConfig
from astrabox.web.trusted_host_middleware import _allowed_hosts, _host_from_scope
def arriving(host: str) -> dict[str, object]:
return {
"type": "http",
"scheme": "http",
"method": "GET",
"path": "/api/v1/auth/login",
"root_path": "",
"query_string": b"",
"server": ("172.18.0.2", 8000),
"headers": [(b"host", host.encode())],
}
for host in ("astrabox.example.com", "127.0.0.1:8088"):
assert _host_from_scope(arriving(host)) in _allowed_hosts(), host
config = OidcProviderConfig.load()
request = Request(arriving("astrabox.example.com"))
template = (_REPO_ROOT / "containers/casdoor/init_data.json").read_text(encoding="utf-8")
seeded = json.loads(
template.replace("__ASTRABOX_CONSOLE_ORIGIN__", written["ASTRABOX_CONSOLE_ORIGIN"])
)
registered = next(
application["redirectUris"]
for application in seeded["applications"]
if application["name"] == "astrabox-console"
)
assert _redirect_uri(request, config) in registered
assert _cookie_secure(request, config)
assert config.issuer == "https://login.example.com"
def test_re_running_without_settings_keeps_team_login_and_its_urls(
tmp_path: Path,
) -> None:
install_dir = _installation(tmp_path)
assert _configure_team_login(install_dir, tmp_path, _PUBLIC_URLS).returncode == 0
first = _settings(install_dir / "containers/.env")
assert first["COMPOSE_FILE"] == "compose.yaml:compose.sso.yaml"
result = _configure_team_login(install_dir, tmp_path)
assert result.returncode == 0, result.stderr
assert _settings(install_dir / "containers/.env") == first
@pytest.mark.parametrize(
("current", "expected"),
[("none", None), ("casdoor", "compose.yaml:compose.sso.yaml")],
)
def test_the_question_defaults_to_the_current_choice(
tmp_path: Path, current: str, expected: str | None
) -> None:
"""Pressing Enter never changes whether a deployment has a login."""
install_dir = _installation(tmp_path)
if current == "casdoor":
setup = _configure_team_login(
install_dir, tmp_path, "export ASTRABOX_INSTALL_TEAM_LOGIN=casdoor"
)
assert setup.returncode == 0, setup.stderr
result = _configure_team_login(install_dir, tmp_path, answers="\n")
assert result.returncode == 0, result.stderr
assert _settings(install_dir / "containers/.env").get("COMPOSE_FILE") == expected
def test_turning_team_login_off_is_refused_while_other_computers_reach_it(
tmp_path: Path,
) -> None:
install_dir = _installation(tmp_path)
assert _configure_team_login(install_dir, tmp_path, _PUBLIC_URLS).returncode == 0
result = _configure_team_login(
install_dir, tmp_path, "export ASTRABOX_INSTALL_TEAM_LOGIN=none"
)
assert result.returncode != 0
assert "without login" in result.stderr
assert "COMPOSE_FILE" in _settings(install_dir / "containers/.env")
@pytest.mark.parametrize(
("settings", "reason"),
[
(
"export ASTRABOX_INSTALL_TEAM_LOGIN=casdoor "
"ASTRABOX_CONSOLE_ORIGIN=https://astrabox.example.com",
"needs both public URLs",
),
(
"export ASTRABOX_INSTALL_TEAM_LOGIN=casdoor "
"ASTRABOX_CONSOLE_ORIGIN=https://astrabox.example.com/ "
"ASTRABOX_OIDC_ISSUER=https://login.example.com",
"no path or trailing slash",
),
(
"export ASTRABOX_INSTALL_TEAM_LOGIN=none "
"ASTRABOX_OIDC_ISSUER=https://login.example.com",
"takes effect only with team login",
),
("export ASTRABOX_INSTALL_TEAM_LOGIN=yes", "Use casdoor or none"),
],
)
def test_team_login_settings_that_cannot_log_anyone_in_are_refused(
tmp_path: Path, settings: str, reason: str
) -> None:
install_dir = _installation(tmp_path)
result = _configure_team_login(install_dir, tmp_path, settings)
assert result.returncode != 0
assert reason in result.stderr
assert "COMPOSE_FILE" not in _settings(install_dir / "containers/.env")
def test_an_allowed_hosts_list_without_the_console_host_is_refused(
tmp_path: Path,
) -> None:
"""The operator's own list is kept, not replaced, and must admit the console."""
install_dir = _installation(tmp_path)
env_file = install_dir / "containers/.env"
create = _run(f'create_env_file "{env_file}"', tmp_path=tmp_path)
assert create.returncode == 0, create.stderr
with env_file.open("a", encoding="utf-8") as settings:
settings.write("ASTRABOX_ALLOWED_HOSTS='intranet.example.com,127.0.0.1'\n")
refused = _configure_team_login(install_dir, tmp_path, _PUBLIC_URLS)
assert refused.returncode != 0
assert "must list astrabox.example.com" in refused.stderr
with env_file.open("a", encoding="utf-8") as settings:
settings.write(
"ASTRABOX_ALLOWED_HOSTS='intranet.example.com,astrabox.example.com,127.0.0.1'\n"
)
kept = _configure_team_login(install_dir, tmp_path, _PUBLIC_URLS)
assert kept.returncode == 0, kept.stderr
assert _settings(env_file)["ASTRABOX_ALLOWED_HOSTS"] == (
"intranet.example.com,astrabox.example.com,127.0.0.1"
)
def test_a_release_without_the_overlay_cannot_turn_team_login_on(
tmp_path: Path,
) -> None:
"""The installer is served from main; the bundle may be an older release's."""
install_dir = _installation(tmp_path)
(install_dir / "containers/compose.sso.yaml").unlink()
result = _configure_team_login(
install_dir, tmp_path, "export ASTRABOX_INSTALL_TEAM_LOGIN=casdoor"
)
assert result.returncode != 0
assert "AstraBox 0.1.0 bundle has no team-login overlay" in result.stderr