feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""Check the environment reference against source without importing application settings.
|
||||
|
||||
Only tracked-source locations are inspected. Deployment .env files, process
|
||||
environment values and runtime data are never opened or evaluated.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Setting:
|
||||
names: tuple[str, ...]
|
||||
default: str
|
||||
|
||||
|
||||
def settings_inventory(source: str) -> list[Setting]:
|
||||
tree = ast.parse(source.lstrip("\ufeff"))
|
||||
settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings")
|
||||
result = []
|
||||
for node in settings.body:
|
||||
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||
continue
|
||||
name = node.target.id
|
||||
names = (name.upper(),)
|
||||
default = node.value
|
||||
if isinstance(default, ast.Call):
|
||||
arguments = {keyword.arg: keyword.value for keyword in default.keywords}
|
||||
alias = arguments.get("validation_alias")
|
||||
if isinstance(alias, ast.Constant):
|
||||
names = (alias.value,)
|
||||
elif isinstance(alias, ast.Call):
|
||||
names = tuple(ast.literal_eval(argument) for argument in alias.args)
|
||||
default = arguments.get("default")
|
||||
if isinstance(default, ast.Name):
|
||||
value = "@" + default.id
|
||||
else:
|
||||
value = json.dumps(ast.literal_eval(default), ensure_ascii=True)
|
||||
result.append(Setting(names, value))
|
||||
return result
|
||||
|
||||
|
||||
def python_environment_names(source: str) -> set[str]:
|
||||
names = set()
|
||||
for node in ast.walk(ast.parse(source.lstrip("\ufeff"))):
|
||||
argument = None
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args:
|
||||
receiver = ast.unparse(node.func.value)
|
||||
if (node.func.attr == "getenv" and receiver == "os") or (
|
||||
node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"}
|
||||
):
|
||||
argument = node.args[0]
|
||||
elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in {
|
||||
"os.environ", "environ", "environment", "prepared"
|
||||
}:
|
||||
argument = node.slice
|
||||
if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value):
|
||||
names.add(argument.value)
|
||||
return names
|
||||
|
||||
|
||||
def runtime_environment_names(root: Path) -> set[str]:
|
||||
names = set()
|
||||
sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")]
|
||||
for path in sources:
|
||||
names.update(python_environment_names(path.read_text(encoding="utf-8")))
|
||||
|
||||
javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"),
|
||||
*root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"]
|
||||
for path in javascript:
|
||||
if ".test." not in path.name:
|
||||
names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
|
||||
|
||||
deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"),
|
||||
*root.glob(".gitea/workflows/*.yml")]
|
||||
for path in deployment:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source))
|
||||
names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source))
|
||||
names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source))
|
||||
# These are shell syntax/builtins, not Magent configuration options.
|
||||
names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"})
|
||||
|
||||
dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
|
||||
for line in dockerfile.splitlines():
|
||||
if line.startswith(("ENV ", "ARG ")):
|
||||
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
|
||||
supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8")
|
||||
for line in supervisor.splitlines():
|
||||
if line.startswith("environment="):
|
||||
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
|
||||
return names
|
||||
|
||||
|
||||
def check_documentation(root: Path = ROOT) -> tuple[list[str], int]:
|
||||
document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8")
|
||||
documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document))
|
||||
settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8"))
|
||||
required = runtime_environment_names(root) | {name for setting in settings for name in setting.names}
|
||||
errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)]
|
||||
defaults = {}
|
||||
for line in document.splitlines():
|
||||
cells = line.split("|")
|
||||
if len(cells) >= 4 and cells[1].strip().startswith("`"):
|
||||
for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]):
|
||||
defaults[name] = cells[2].strip().strip("`")
|
||||
for setting in settings:
|
||||
for name in setting.names:
|
||||
if name in documented and defaults.get(name) != setting.default:
|
||||
errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}")
|
||||
return errors, len(required)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors, count = check_documentation()
|
||||
if errors:
|
||||
print("\n".join(errors), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
python_bin="${PYTHON_BIN:-python3}"
|
||||
|
||||
echo "Installing backend Python requirements and quality tools"
|
||||
"$python_bin" -m pip install -r backend/requirements-dev.txt
|
||||
|
||||
echo "Running Python dependency integrity check"
|
||||
"$python_bin" -m pip check
|
||||
|
||||
echo "Auditing Python production dependencies"
|
||||
"$python_bin" -m pip_audit -r backend/requirements.txt --progress-spinner off
|
||||
"$python_bin" -m pip_audit -r docker/requirements-runtime.txt --progress-spinner off
|
||||
|
||||
echo "Linting backend application code"
|
||||
"$python_bin" -m ruff check backend/app scripts/container_smoke.py scripts/check_environment_docs.py backend/tests/test_container_packaging.py backend/tests/test_container_bootstrap.py backend/tests/test_managed_setup_origin.py backend/tests/test_environment_docs.py
|
||||
|
||||
echo "Running backend unit tests with coverage"
|
||||
"$python_bin" -m coverage erase
|
||||
"$python_bin" -m coverage run -m unittest discover -s backend/tests -p "test_*.py" -v
|
||||
"$python_bin" -m coverage report
|
||||
|
||||
echo "Backend quality gate passed"
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# No argument preserves the CI build-and-test entry point. Pass an image tag to
|
||||
# test an already-built release without building, pulling, or publishing it.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
echo "Usage: $0 [existing-image]" >&2
|
||||
exit 2
|
||||
fi
|
||||
script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repository_directory="$(cd -- "$script_directory/.." && pwd)"
|
||||
image="${1:-magent:ci}"
|
||||
size_limit_mb="${MAGENT_IMAGE_MAX_MB:-350}"
|
||||
managed_mode="${MAGENT_SMOKE_MANAGED:-false}"
|
||||
if [[ "$managed_mode" != true && "$managed_mode" != false ]]; then
|
||||
echo "MAGENT_SMOKE_MANAGED must be true or false." >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! [[ "$size_limit_mb" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "MAGENT_IMAGE_MAX_MB must be a positive integer (MiB)." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$-${RANDOM}"
|
||||
volume_name="${container_name}-data"
|
||||
network_name="${container_name}-isolated"
|
||||
container_created=false
|
||||
volume_created=false
|
||||
network_created=false
|
||||
cleanup() {
|
||||
result=$?
|
||||
trap - EXIT
|
||||
if [ "$result" -ne 0 ] && [ "$container_created" = true ]; then
|
||||
# Only synthetic credentials/data enter this test container.
|
||||
docker logs --tail 100 "$container_name" >&2 || true
|
||||
fi
|
||||
if [ "$container_created" = true ]; then
|
||||
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$volume_created" = true ]; then
|
||||
docker volume rm "$volume_name" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$network_created" = true ]; then
|
||||
docker network rm "$network_name" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit "$result"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
docker build --tag "$image" "$repository_directory"
|
||||
fi
|
||||
image_size="$(docker image inspect --format '{{.Size}}' "$image")"
|
||||
image_id="$(docker image inspect --format '{{.Id}}' "$image")"
|
||||
if [ "$image_size" -gt "$((size_limit_mb * 1024 * 1024))" ]; then
|
||||
echo "Image exceeds ${size_limit_mb} MiB unpacked budget: ${image_size} bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Image size: ${image_size} bytes (budget ${size_limit_mb} MiB unpacked)"
|
||||
|
||||
# Inspect the image's original filesystem before tmpfs or volume mounts could
|
||||
# hide accidentally shipped build caches or private files.
|
||||
docker run --rm --pull never --network none --read-only \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
--entrypoint python -i "$image_id" - packaging < "$script_directory/container_smoke.py"
|
||||
|
||||
# An internal network prevents accidental external integration calls. No host
|
||||
# files, existing volumes, host credentials, or host ports are used.
|
||||
docker network create --internal "$network_name" >/dev/null
|
||||
network_created=true
|
||||
docker volume create "$volume_name" >/dev/null
|
||||
volume_created=true
|
||||
|
||||
start_container() {
|
||||
local -a secret_environment
|
||||
if [ "$managed_mode" = true ]; then
|
||||
# Exercise the image defaults: no keys, origin or managed-mode variables.
|
||||
secret_environment=()
|
||||
else
|
||||
secret_environment=(
|
||||
--env JWT_SECRET=ci-only-secret-with-at-least-32-characters
|
||||
--env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
--env SETUP_TOKEN=ci-only-setup-token-with-at-least-32-characters
|
||||
--env AUTH_COOKIE_SECURE=true
|
||||
--env MAGENT_APPLICATION_URL=https://magent-ci.example.test
|
||||
)
|
||||
fi
|
||||
docker run --detach --name "$container_name" --pull never \
|
||||
--network "$network_name" \
|
||||
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
|
||||
--pids-limit 256 --memory 1g --cpus 2 \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 \
|
||||
--tmpfs /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \
|
||||
--volume "$volume_name:/app/data" \
|
||||
"${secret_environment[@]}" \
|
||||
--env ADMIN_PASSWORD= \
|
||||
--env AUTH_COOKIE_SAMESITE=strict \
|
||||
--env BACKGROUND_TASKS_ENABLED=false \
|
||||
--env MAGENT_METRICS_ENABLED=false \
|
||||
"$image_id" >/dev/null
|
||||
container_created=true
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local deadline=$((SECONDS + 150))
|
||||
local status
|
||||
while true; do
|
||||
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_name")"
|
||||
if [ "$status" = healthy ]; then
|
||||
return
|
||||
fi
|
||||
if [ "$status" = missing ] || [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "Container did not become healthy within 150 seconds (status: $status)" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != true ]; then
|
||||
echo "Container exited before becoming healthy" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
# Deliberately no root/chown helper: the image must initialize a fresh named
|
||||
# volume with correct ownership for its normal non-root runtime user.
|
||||
start_container
|
||||
wait_for_health
|
||||
docker exec -i "$container_name" python - fresh < "$script_directory/container_smoke.py"
|
||||
|
||||
docker restart --time 15 "$container_name" >/dev/null
|
||||
wait_for_health
|
||||
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
|
||||
|
||||
# Recreation proves database/configuration are in the volume, not merely in the
|
||||
# container's writable layer. Both test instances use the same immutable image.
|
||||
docker rm -f "$container_name" >/dev/null
|
||||
container_created=false
|
||||
start_container
|
||||
wait_for_health
|
||||
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
|
||||
echo "Container smoke passed: fresh install, security headers, assets, login, backup/restore, restart, recreation."
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Disposable-image checks, streamed into the container by ci_container_smoke.sh.
|
||||
|
||||
Uses only Python's standard library. All credentials and configuration below
|
||||
are synthetic and the caller disables network egress and background workers.
|
||||
This checks script/asset delivery and CSP compatibility, not browser execution.
|
||||
"""
|
||||
|
||||
from html.parser import HTMLParser
|
||||
import hashlib
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib import error, parse, request
|
||||
|
||||
|
||||
ORIGIN = "https://magent-ci.example.test"
|
||||
FRONTEND = "http://127.0.0.1:3000"
|
||||
SETUP_TOKEN = "ci-only-setup-token-with-at-least-32-characters"
|
||||
ADMIN_USERNAME = "container-smoke-admin"
|
||||
ADMIN_PASSWORD = "Container-smoke-owner-password-123456789!"
|
||||
INTEGRATION_SECRET = "synthetic-container-smoke-integration-key"
|
||||
LOGIN_MESSAGE = "Welcome to an independent Magent installation"
|
||||
BACKUP_PASSPHRASE = "Synthetic container backup passphrase only"
|
||||
CACHE_FIXTURE = Path("/app/data/artwork/tmdb/w342/container-smoke.jpg")
|
||||
CACHE_CONTENT = b"synthetic artwork cache fixture"
|
||||
|
||||
|
||||
def managed_installation() -> bool:
|
||||
return os.environ.get("MAGENT_MANAGED_SECRETS") in {"true", "auto"} and not os.environ.get("JWT_SECRET")
|
||||
|
||||
|
||||
def check(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def http(
|
||||
path: str,
|
||||
*,
|
||||
expected: int = 200,
|
||||
method: str = "GET",
|
||||
payload: dict | None = None,
|
||||
form: dict | None = None,
|
||||
raw: bytes | None = None,
|
||||
headers: dict | None = None,
|
||||
base: str = FRONTEND,
|
||||
) -> tuple[bytes, object]:
|
||||
outgoing_headers = {"Origin": ORIGIN, **(headers or {})}
|
||||
check(sum(value is not None for value in (payload, form, raw)) <= 1,
|
||||
"HTTP body must use only one encoding")
|
||||
data = raw
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode()
|
||||
outgoing_headers["Content-Type"] = "application/json"
|
||||
elif form is not None:
|
||||
data = parse.urlencode(form).encode()
|
||||
outgoing_headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
probe = request.Request(base + path, data=data, headers=outgoing_headers, method=method)
|
||||
try:
|
||||
response = request.urlopen(probe, timeout=30)
|
||||
except error.HTTPError as exc:
|
||||
response = exc
|
||||
with response:
|
||||
check(response.status == expected, f"{method} {path}: expected {expected}, got {response.status}")
|
||||
return response.read(), response.headers
|
||||
|
||||
|
||||
def api(path: str, **kwargs) -> dict:
|
||||
body, _ = http("/api" + path, **kwargs)
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
class PageAssets(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.scripts: list[dict] = []
|
||||
self.assets: set[str] = set()
|
||||
|
||||
def handle_starttag(self, tag: str, attributes: list) -> None:
|
||||
values = dict(attributes)
|
||||
if tag == "script":
|
||||
self.scripts.append(values)
|
||||
if values.get("src"):
|
||||
self.assets.add(values["src"])
|
||||
if tag == "link" and values.get("href", "").startswith("/_next/static/"):
|
||||
self.assets.add(values["href"])
|
||||
|
||||
|
||||
def check_page(path: str, asset_cache: set[str]) -> str:
|
||||
body, headers = http(path)
|
||||
check("text/html" in headers.get("Content-Type", ""), f"{path} is not HTML")
|
||||
policy = headers.get("Content-Security-Policy", "")
|
||||
match = re.search(r"script-src [^;]*'nonce-([^']+)'", policy)
|
||||
check(match is not None, f"{path} missing script nonce policy")
|
||||
nonce = match.group(1)
|
||||
check("'strict-dynamic'" in policy, f"{path} lost strict-dynamic")
|
||||
check("'unsafe-eval'" not in policy, f"{path} enables development eval")
|
||||
check(headers.get("X-Content-Type-Options") == "nosniff", "Missing nosniff header")
|
||||
check(headers.get("X-Frame-Options") == "DENY", "Missing anti-framing header")
|
||||
check(headers.get("X-Powered-By") is None, "Frontend exposes its framework")
|
||||
parsed = PageAssets()
|
||||
parsed.feed(body.decode())
|
||||
executable_scripts = [
|
||||
script for script in parsed.scripts
|
||||
if script.get("type", "").lower() in ("", "module", "text/javascript", "application/javascript")
|
||||
]
|
||||
check(bool(executable_scripts), f"{path} contains no frontend bootstrap scripts")
|
||||
for script in executable_scripts:
|
||||
check(script.get("nonce") == nonce, f"{path} contains a script blocked by its CSP nonce")
|
||||
check(any(asset.startswith("/_next/static/") and ".js" in asset for asset in parsed.assets),
|
||||
f"{path} contains no static JavaScript assets")
|
||||
for asset in sorted(parsed.assets - asset_cache):
|
||||
check(asset.startswith("/_next/static/"), f"Unexpected external executable asset on {path}")
|
||||
content, asset_headers = http(asset)
|
||||
check(bool(content), f"Empty static asset: {asset}")
|
||||
check("text/html" not in asset_headers.get("Content-Type", ""), f"Asset returned HTML: {asset}")
|
||||
asset_cache.add(asset)
|
||||
return nonce
|
||||
|
||||
|
||||
def check_packaging() -> None:
|
||||
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
|
||||
check(Path("/app/frontend/server.js").is_file(), "Missing standalone frontend server")
|
||||
check(shutil.which("node") == "/usr/local/bin/node", "Node is not the standalone runtime binary")
|
||||
check(shutil.which("supervisord") == "/usr/local/bin/supervisord", "Missing Python supervisor")
|
||||
check(shutil.which("curl") is not None, "curl compatibility for existing healthchecks was removed")
|
||||
for executable in ("npm", "npx", "yarn", "pnpm", "pip", "pip3", "gcc", "g++", "make", "git", "gpg"):
|
||||
check(shutil.which(executable) is None, f"Unnecessary runtime development tool: {executable}")
|
||||
for forbidden in (
|
||||
"/app/.git", "/app/tests", "/app/app/tests", "/app/backend/tests",
|
||||
"/app/frontend/app", "/app/frontend/tsconfig.json", "/app/frontend/proxy.ts",
|
||||
"/app/frontend/node_modules/typescript", "/app/frontend/node_modules/eslint",
|
||||
"/app/frontend/node_modules/vitest", "/app/frontend/node_modules/@playwright",
|
||||
"/app/frontend/node_modules/@biomejs",
|
||||
"/app/frontend/node_modules/@next/swc-linux-x64-gnu",
|
||||
"/app/frontend/node_modules/@next/swc-linux-arm64-gnu",
|
||||
"/app/frontend/node_modules/@next/swc-linux-x64-musl",
|
||||
"/app/frontend/node_modules/@next/swc-linux-arm64-musl",
|
||||
"/root/.npm", "/root/.cache/pip", "/usr/local/lib/node_modules/npm",
|
||||
):
|
||||
check(not Path(forbidden).exists(), f"Unnecessary build/private artifact: {forbidden}")
|
||||
for directory in (Path("/app"), Path("/app/frontend")):
|
||||
check(not any(directory.glob(".env*")), f"Private environment file in {directory}")
|
||||
check(not Path("/app/data/bootstrap-secrets.json").exists(), "Managed secrets baked into image")
|
||||
check(not any(Path("/app/frontend/.next/cache").iterdir()), "Frontend build cache shipped in runtime")
|
||||
check(Path("/usr/share/licenses/magent/LICENSE").is_file(), "Magent license is missing")
|
||||
check(Path("/usr/local/share/doc/nodejs/LICENSE").is_file(), "Node distribution license is missing")
|
||||
check(Path("/usr/share/licenses/magent/frontend/dependencies.json").is_file(),
|
||||
"Frontend dependency inventory is missing")
|
||||
print("Standalone packaging, non-root runtime and absent development tools/private files: PASS")
|
||||
|
||||
|
||||
def check_runtime() -> None:
|
||||
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
|
||||
check(Path("/app/data").stat().st_uid == os.getuid(), "Fresh data volume is not owned by runtime user")
|
||||
check(os.access("/app/data", os.W_OK), "Data volume is not writable")
|
||||
for path in ("/api/health", "/api/setup/status"):
|
||||
http(path)
|
||||
http("/health", base="http://127.0.0.1:8000")
|
||||
asset_cache: set[str] = set()
|
||||
first_nonce = check_page("/login", asset_cache)
|
||||
second_nonce = check_page("/login", asset_cache)
|
||||
check(first_nonce != second_nonce, "CSP nonce is reused between requests")
|
||||
check_page("/setup", asset_cache)
|
||||
# Check the retained curl command because some deployed stacks override the
|
||||
# image HEALTHCHECK with this exact runtime dependency.
|
||||
subprocess.run(["curl", "--fail", "--silent", "--show-error", FRONTEND + "/api/health"],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
print(f"Runtime, API rewrite, CSP nonce consistency and {len(asset_cache)} static assets: PASS")
|
||||
|
||||
|
||||
def check_origin_guards() -> None:
|
||||
# MAGENT_APPLICATION_URL must permit the public origin even while CORS uses
|
||||
# its localhost default. Test both direct backend and Next's API rewrite.
|
||||
for base, prefix in ((FRONTEND, "/api"), ("http://127.0.0.1:8000", "")):
|
||||
for endpoint in ("/auth/login", "/auth/jellyfin/login"):
|
||||
for origin, expected in ((ORIGIN, 422), ("https://untrusted.example.test", 403)):
|
||||
http(prefix + endpoint, base=base, method="POST", form={}, expected=expected,
|
||||
headers={"Origin": origin})
|
||||
print("Both login Origin guards, directly and via frontend: PASS")
|
||||
|
||||
|
||||
def sign_in() -> dict:
|
||||
_, headers = http("/api/auth/login", method="POST", form={
|
||||
"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
|
||||
})
|
||||
cookies = SimpleCookie()
|
||||
for raw_cookie in headers.get_all("Set-Cookie", []):
|
||||
cookies.load(raw_cookie)
|
||||
check("magent_auth" in cookies, "Local login did not issue an authentication cookie")
|
||||
auth_cookie = cookies["magent_auth"]
|
||||
check(bool(auth_cookie["httponly"]), "Authentication cookie missing HttpOnly")
|
||||
check(bool(auth_cookie["secure"]), "Authentication cookie missing Secure")
|
||||
check(auth_cookie["samesite"].lower() == "strict", "Authentication cookie missing SameSite=strict")
|
||||
# These requests traverse HTTP loopback behind the simulated HTTPS public
|
||||
# origin. Forward only our synthetic cookie explicitly; never print tokens.
|
||||
authenticated_headers = {"Cookie": "magent_auth=" + auth_cookie.value}
|
||||
identity = api("/auth/me", headers=authenticated_headers)
|
||||
check(identity["username"] == ADMIN_USERNAME and identity["role"] == "admin",
|
||||
"Local administrator identity did not survive login")
|
||||
return authenticated_headers
|
||||
|
||||
|
||||
def check_persisted_settings(headers: dict) -> None:
|
||||
values = {item["key"]: item for item in api("/admin/settings", headers=headers)["settings"]}
|
||||
check(values["site_login_message"]["value"] == LOGIN_MESSAGE, "Public configuration did not persist")
|
||||
check(values["jellyfin_api_key"]["value"] is None and values["jellyfin_api_key"]["isSet"],
|
||||
"Integration secret is missing or exposed by settings API")
|
||||
with sqlite3.connect("file:/app/data/magent.db?mode=ro", uri=True) as connection:
|
||||
check(connection.execute("PRAGMA quick_check").fetchone()[0] == "ok", "SQLite integrity failure")
|
||||
check(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1,
|
||||
"Fresh smoke instance has unexpected users")
|
||||
stored = connection.execute("SELECT value FROM settings WHERE key = 'jellyfin_api_key'").fetchone()
|
||||
check(stored is not None and INTEGRATION_SECRET not in str(stored[0]),
|
||||
"Integration secret was stored without encryption")
|
||||
|
||||
|
||||
def backup_restore_upload(content: bytes, passphrase: str) -> tuple[bytes, str]:
|
||||
boundary = "magent-smoke-" + secrets.token_hex(24)
|
||||
parts = []
|
||||
for name, value in (("passphrase", passphrase), ("confirmation", "RESTORE")):
|
||||
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n'
|
||||
f'\r\n{value}\r\n').encode())
|
||||
parts.extend([
|
||||
(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="smoke.magent-backup"\r\n'
|
||||
'Content-Type: application/octet-stream\r\n\r\n').encode(),
|
||||
content,
|
||||
f"\r\n--{boundary}--\r\n".encode(),
|
||||
])
|
||||
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def stage_backup_roundtrip(headers: dict) -> None:
|
||||
# All paths and values belong to this disposable CI volume, never real data.
|
||||
CACHE_FIXTURE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FIXTURE.write_bytes(CACHE_CONTENT)
|
||||
content, response_headers = http("/api/admin/backups/export", method="POST", headers=headers,
|
||||
payload={"passphrase": BACKUP_PASSPHRASE, "include_cache": True})
|
||||
check(content.startswith(b"MAGENT-BACKUP\x00\x01"), "Backup is not the encrypted portable format")
|
||||
check(INTEGRATION_SECRET.encode() not in content, "Backup exposed plaintext integration credentials")
|
||||
check(response_headers.get("Cache-Control") == "no-store", "Backup download is cacheable")
|
||||
api("/admin/settings", method="PUT", headers=headers,
|
||||
payload={"site_login_message": "Changed after backup"})
|
||||
CACHE_FIXTURE.write_bytes(b"changed after backup")
|
||||
body, content_type = backup_restore_upload(content, BACKUP_PASSPHRASE)
|
||||
restored = api("/admin/backups/restore", method="POST", expected=202, raw=body,
|
||||
headers={**headers, "Content-Type": content_type})
|
||||
check(restored["restart_required"], "Restore did not require a restart")
|
||||
status = api("/admin/backups", headers=headers)
|
||||
check(status["pending_restore"] is not None, "Backup was not staged")
|
||||
check(CACHE_FIXTURE.read_bytes() == b"changed after backup", "Restore applied before restart")
|
||||
print("Encrypted config/database/cache backup and authenticated restore staging: PASS")
|
||||
|
||||
|
||||
def fresh_install() -> None:
|
||||
check(api("/setup/status") == {"setup_required": True, "needs_admin": True},
|
||||
"Fresh volume did not open authorized first-install setup")
|
||||
api("/setup/state", expected=401)
|
||||
api("/admin/settings", expected=401)
|
||||
token = SETUP_TOKEN
|
||||
if managed_installation():
|
||||
# docker exec does not inherit the entrypoint's generated environment:
|
||||
# the console command must read persistent state, not rely on getenv.
|
||||
token = subprocess.check_output(
|
||||
[sys.executable, "-m", "app.container_bootstrap", "setup-token"], text=True,
|
||||
).strip()
|
||||
check(len(token) == 64 and token != SETUP_TOKEN, "Managed setup token was not generated")
|
||||
state_file = Path("/app/data/bootstrap-secrets.json")
|
||||
check(state_file.stat().st_mode & 0o777 == 0o600, "Managed secrets file is not private")
|
||||
Path("/app/data/.smoke-managed-digest").write_text(hashlib.sha256(state_file.read_bytes()).hexdigest())
|
||||
bootstrap = {"setup_token": token, "username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
|
||||
"application_url": ORIGIN}
|
||||
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=403,
|
||||
headers={"Origin": "https://untrusted.example.test"})
|
||||
api("/setup/bootstrap", method="POST", payload={**bootstrap, "setup_token": "wrong-token"}, expected=403)
|
||||
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=201)
|
||||
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=409)
|
||||
headers = sign_in()
|
||||
check(api("/setup/state", headers=headers)["step"] == "apps", "Setup did not advance to apps")
|
||||
updated = api("/admin/settings", method="PUT", headers=headers, payload={
|
||||
"site_login_message": LOGIN_MESSAGE,
|
||||
"jellyfin_api_key": INTEGRATION_SECRET,
|
||||
})
|
||||
check(updated["updated"] == 2, "Setup configuration was not saved")
|
||||
api("/setup/state", method="PUT", payload={"step": "review"}, headers=headers)
|
||||
check(api("/setup/complete", method="POST", headers=headers)["completed"], "Setup did not complete")
|
||||
check(api("/setup/status") == {"setup_required": False, "needs_admin": False}, "Setup remained public")
|
||||
check_persisted_settings(headers)
|
||||
print("Token-authorized setup, local admin login, secure cookies and encrypted settings: PASS")
|
||||
stage_backup_roundtrip(headers)
|
||||
|
||||
|
||||
def persisted_install() -> None:
|
||||
check(api("/setup/status") == {"setup_required": False, "needs_admin": False},
|
||||
"Setup reopened after restart/recreation")
|
||||
api("/setup/bootstrap", method="POST", payload={
|
||||
"setup_token": SETUP_TOKEN, "username": "must-not-exist", "password": ADMIN_PASSWORD,
|
||||
}, expected=409)
|
||||
headers = sign_in()
|
||||
check_persisted_settings(headers)
|
||||
status = api("/admin/backups", headers=headers)
|
||||
check(status["pending_restore"] is None, "Restore remained pending after restart")
|
||||
check(status["last_restore"] and status["last_restore"]["status"] == "restored",
|
||||
"Backup restore did not complete")
|
||||
check(CACHE_FIXTURE.read_bytes() == CACHE_CONTENT, "Artwork cache was not restored")
|
||||
if managed_installation():
|
||||
state_file = Path("/app/data/bootstrap-secrets.json")
|
||||
check(hashlib.sha256(state_file.read_bytes()).hexdigest()
|
||||
== Path("/app/data/.smoke-managed-digest").read_text(), "Managed keys changed on restart/restore")
|
||||
result = subprocess.run([sys.executable, "-m", "app.container_bootstrap", "setup-token"],
|
||||
capture_output=True, text=True, check=False)
|
||||
check(result.returncode != 0 and not result.stdout, "Setup token remains available after admin creation")
|
||||
print("Generated keys persisted unchanged; initial setup token is no longer available: PASS")
|
||||
print("Persistent setup state, administrator login, encrypted settings and database integrity: PASS")
|
||||
print("Restored configuration, database and artwork cache: PASS")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check(len(sys.argv) == 2 and sys.argv[1] in ("packaging", "fresh", "persisted"),
|
||||
"Expected packaging, fresh or persisted mode")
|
||||
if sys.argv[1] == "packaging":
|
||||
check_packaging()
|
||||
return
|
||||
check_runtime()
|
||||
if sys.argv[1] == "fresh":
|
||||
fresh_install()
|
||||
else:
|
||||
persisted_install()
|
||||
check_origin_guards()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user