diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ab058a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitea +.venv +.env +.env.* +!.env.example +.data +*.db +*.db-shm +*.db-wal +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +dist +*.egg-info diff --git a/.gitea/workflows/docker-deploy.yml b/.gitea/workflows/docker-deploy.yml new file mode 100644 index 0000000..0467022 --- /dev/null +++ b/.gitea/workflows/docker-deploy.yml @@ -0,0 +1,86 @@ +name: Docker test deployment +on: + push: + branches: [main] + workflow_dispatch: + inputs: + deploy_port: + description: Public host port for the test API + required: false + default: '18080' + public_base_url: + description: Public HTTP URL, such as http://SERVER_IP:18080 + required: false + default: '' +concurrency: + group: adcloud-server-test + cancel-in-progress: false +jobs: + test-build-deploy: + if: gitea.ref == 'refs/heads/main' + runs-on: ubuntu-latest + container: + image: docker:27-cli + timeout-minutes: 30 + env: + SOURCE_SERVER: ${{ gitea.server_url }} + SOURCE_REPOSITORY: ${{ gitea.repository }} + SOURCE_REVISION: ${{ gitea.sha }} + DEPLOY_PORT: ${{ inputs.deploy_port || vars.ADCLOUD_DEPLOY_PORT || '18080' }} + PUBLIC_BASE_URL: ${{ inputs.public_base_url || vars.ADCLOUD_PUBLIC_BASE_URL || '' }} + steps: + - name: Check Docker daemon and tools + run: | + set -eu + apk add --no-cache python3 bash curl + if ! docker info --format 'Docker daemon: {{.Name}}; OS: {{.OperatingSystem}}; version: {{.ServerVersion}}'; then + echo 'Runner job cannot reach Docker. Set runner container.docker_host to the host socket and restart runner.' + exit 1 + fi + docker version --format 'Client={{.Client.Version}} Server={{.Server.Version}}' + - name: Fetch the exact repository revision + env: + SOURCE_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + python3 - <<'PY' + import io, os, shutil, tarfile, tempfile, urllib.request + from pathlib import Path + server = os.environ['SOURCE_SERVER'].rstrip('/') + repo = os.environ['SOURCE_REPOSITORY'] + revision = os.environ['SOURCE_REVISION'] + request = urllib.request.Request(server + '/api/v1/repos/' + repo + '/archive/' + revision + '.tar.gz', headers={'Authorization': 'token ' + os.environ['SOURCE_TOKEN']}) + with urllib.request.urlopen(request, timeout=60) as response: + data = response.read() + with tempfile.TemporaryDirectory() as directory: + with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive: + archive.extractall(directory, filter='data') + roots = list(Path(directory).iterdir()) + if len(roots) != 1 or not roots[0].is_dir(): + raise RuntimeError('Unexpected repository archive structure') + shutil.copytree(roots[0], Path.cwd(), dirs_exist_ok=True) + print('Checked out exact commit:', revision) + PY + - name: Build and run the test image + run: | + set -eu + docker build --target test --tag "adcloud-server-ci:$SOURCE_REVISION-test" . + docker run --rm "adcloud-server-ci:$SOURCE_REVISION-test" + - name: Build the runtime image + run: | + docker build --target runtime --tag "adcloud-server-ci:$SOURCE_REVISION" --label "org.opencontainers.image.revision=$SOURCE_REVISION" . + - name: Deploy and verify published host port + run: | + export DEPLOY_IMAGE="adcloud-server-ci:$SOURCE_REVISION" + export DEPLOY_REPOSITORY="$SOURCE_REPOSITORY" + export DEPLOY_REVISION="$SOURCE_REVISION" + bash scripts/deploy_docker.sh + - name: Probe configured public URL + run: | + set -eu + if [ -n "$PUBLIC_BASE_URL" ]; then + case "$PUBLIC_BASE_URL" in http://*|https://*) ;; *) echo 'Invalid public URL'; exit 1 ;; esac + curl --fail --silent --show-error --connect-timeout 10 --max-time 20 "${PUBLIC_BASE_URL%/}/health" + printf '\nPublic API: %s\n' "$PUBLIC_BASE_URL" + else + echo "Host port $DEPLOY_PORT passed HTTP smoke; provide public_base_url to check public ingress." + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b803605 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 +FROM ghcr.io/astral-sh/uv:0.11.3 AS uv +FROM python:3.12-slim-bookworm AS build +COPY --from=uv /uv /usr/local/bin/uv +ENV UV_PROJECT_ENVIRONMENT=/opt/venv UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1 UV_PYTHON_DOWNLOADS=never +WORKDIR /build +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src +RUN uv sync --locked --no-dev --no-editable --all-extras + +FROM build AS test +RUN uv sync --locked --no-editable --all-extras +COPY tests ./tests +COPY scripts ./scripts +ENV PATH="/opt/venv/bin:$PATH" +CMD ["sh", "-c", "ruff check . && ruff format --check . && pytest"] + +FROM python:3.12-slim-bookworm AS runtime +ENV PATH="/opt/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ + DATABASE_URL=sqlite:////data/adcloud.db PROFILE_BACKEND=relational \ + IDENTITY_BOOTSTRAP_FILE=/config/identity.env COOKIE_SECURE=false +RUN groupadd --gid 10001 adcloud && useradd --uid 10001 --gid adcloud --no-create-home adcloud \ + && mkdir -p /app/scripts /data /config \ + && chown -R adcloud:adcloud /app /data /config && chmod 700 /data /config +COPY --from=build /opt/venv /opt/venv +COPY scripts/smoke_api.py /app/scripts/smoke_api.py +WORKDIR /app +USER 10001:10001 +EXPOSE 8000 +HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=6 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"] +ENTRYPOINT ["python", "-m", "adcloud_server.container_entrypoint"] +CMD ["uvicorn", "adcloud_server.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index b42e5f2..d6682d4 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,8 @@ uv sync --locked --extra postgres --extra cassandra - 设置 `ADCLOUD_TEST_CASSANDRA_HOSTS`、`ADCLOUD_TEST_CASSANDRA_KEYSPACE` 和可选的 PORT、USERNAME、PASSWORD、TLS、CA_FILE,同前缀环境变量配置;先在专用集群应用 `deploy/cassandra.cql`,再运行 `uv run --extra cassandra pytest tests/test_cassandra.py`。 联调测试使用独立随机租户,不自动创建 Cassandra 集群。PostgreSQL 测试会创建初始表并保留测试租户数据,需使用专用测试数据库。 + +## Docker / CI/CD + +Dockerfile 提供 test、runtime 两个目标;Gitea Actions 在 main 推送后构建、测试并部署到 runner 的 Docker daemon,默认映射宿主机 18080 到容器 8000。 +详细配置、runner 要求、公网端口、持久化和回滚见 [Docker 部署说明](docs/docker-deployment.md)。 diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md new file mode 100644 index 0000000..6d44119 --- /dev/null +++ b/docs/docker-deployment.md @@ -0,0 +1,105 @@ +# Docker / Gitea Actions 测试部署 + +## 运行方式 + +工作流 `.gitea/workflows/docker-deploy.yml` 使用现有 `ubuntu-latest` runner 标签,在推送 main 或手动 dispatch 时运行: + +1. 验证作业可以访问 Docker daemon。 +2. 使用 Gitea 自动签发的短期作业 token 下载精确提交的源码,不将认证信息放入 URL、命令行或日志。 +3. 用 uv 和 uv.lock 构建 test 镜像,执行 Ruff 与 pytest。外部数据库测试未配置环境时跳过。 +4. 构建不含开发依赖的非 root runtime 镜像,包含 PostgreSQL / Cassandra Python 驱动,允许以后配置存储;不运行数据库服务。 +5. 在一次性 candidate 容器上验证真实 HTTP 流程。 +6. 替换测试容器,持久化数据和密钥,映射宿主机端口,再从 Docker daemon 的 host 网络验证请求。 +7. 如提供公网 URL,再探测公网 `/health`。后续可从外部执行匿名和埋点测试。 + +Docker 镜像直接保存在该 daemon 上,本版不依赖镜像仓库登录。 + +## 公网端口 + +```text +http://CI_SERVER_PUBLIC_IP:18080 + ↓ Docker published port + 容器 :8000 + ↓ + /v1/identity/* + /v1/events + /v1/users/* + /v1/ads/* +``` + +默认 `0.0.0.0:18080:8000`。Docker 的 published port 映射到 **daemon 所在服务器**,不是任务容器自己的 localhost。 +服务器公网 IP、路由、云安全组和防火墙也必须允许 18080/TCP,映射本身不能修改云端安全组。 + +这是用于合成测试数据的 HTTP 环境,`COOKIE_SECURE=false`;正式使用需要 HTTPS 和 Secure Cookie,已有 Nginx 443 模板可接入。 +`/health`、`/docs`、`/openapi.json` 可用于测试;画像查询、Stitch 和历史查询保持服务端凭据鉴权。 +CORS 默认允许 localhost:3000、5173、5174;接入控制台域名时设置 `DEPLOY_CORS_ORIGINS`。 + +## Runner 要求 + +runner v0.6.1 的 `container.docker_host` 默认空字符串,会自动查找 daemon 并将 socket 提供给 job。 +若显式配置为 `-`,作业不会获得 Docker host,第一步会失败。直接使用宿主机 socket 的配置示例: + +```yaml +container: + docker_host: unix:///var/run/docker.sock +``` + +如果 runner 本身在 Docker 中,runner 容器需要挂载宿主机 `/var/run/docker.sock`。 +本工作流不额外申请 privileged 或用户自定义 host bind mount;使用 runner 提供的 daemon。 + +如果使用 Docker-in-Docker,18080 会发布在 DinD daemon 的命名空间中,未必是公网宿主机。 +需要切换为宿主机 daemon,或由服务器管理员额外转发 DinD 端口。看到 runner 的 Idle 状态不能证明公网部署已配置好。 +Docker socket 具有管理 daemon 的能力,因此部署工作流仅对 main 的 push/dispatch 执行,不接收不可信 PR。 + +## 手动运行 / 配置 + +手动 dispatch 可提供: + +- `deploy_port`:宿主机公开端口,默认 18080,允许 1024–65535。 +- `public_base_url`:例如 `http://SERVER_IP:18080`;用于公网探测和访问地址报告。 + +也可由仓库管理员设置 `ADCLOUD_DEPLOY_PORT`、`ADCLOUD_PUBLIC_BASE_URL` 两个 Actions 变量;手动输入优先。 +执行部署的 bot 只需仓库写入权限;修改 runner 配置和 Actions 变量需要对应管理权限。 + +在有 Docker 的目标服务器手动部署: + +```sh +docker build --target runtime --tag adcloud-server:test . +DEPLOY_IMAGE=adcloud-server:test bash scripts/deploy_docker.sh +``` + +当前固定测试部署配置为 SQLite / relational。未来连接数据库时需给容器传入 DATABASE_URL 等配置,并按需要启动画像 worker;本工作流不创建 PostgreSQL 或 Cassandra。 + +## 持久化、权限和回滚 + +- `adcloud-server-test-data`:SQLite 数据,挂到 `/data`。 +- `adcloud-server-test-config`:运行密钥,挂到 `/config`。 +- 首次启动生成随机 IDENTITY_SECRET、SERVICE_TOKEN,原子写入 mode 0600 的配置文件;后续重建复用它们,不输出到流水线日志。 +- runtime 使用 UID/GID 10001,根文件系统只读,去除 capabilities,启用 no-new-privileges,并限制 CPU、内存和进程数。 +- 正式测试容器使用 `restart=unless-stopped`,Actions 作业结束后继续运行。 +- 只替换具有本项目 managed/repository 标签的容器和 volume,不删除其他服务或数据卷。 +- named lock 防止多个部署同时操作测试容器。取消任务可能遗留 lock;先确认无部署仍在运行,再由服务器管理员检查并清理 `adcloud-server-test-deploy-lock`。 +- 新容器健康检查或 host-port HTTP 验证失败时,删除失败的新容器并尽可能恢复旧容器。端口替换期间有短暂停机;不保证数据库 schema 迁移可以回滚。 +- 本版不自动删除旧 Docker 镜像,避免影响其他构建;后续需按保留策略清理本项目镜像。 + +## HTTP 验证 + +`smoke_api.py` 检查系统/模块路由、匿名 Cookie、埋点、未授权拒绝、可信 Stitch、画像继承和历史事件回溯。 +使用合成邮箱与独立匿名身份,会向测试数据库写入少量演示数据。 +验证宿主机端口时,临时探测容器挂载配置卷为只读,读取凭据在进程内发送,不将凭据放到命令参数或日志中。 + +公网健康检查: + +```sh +curl --fail http://SERVER_IP:18080/health +``` + +外部匿名和事件检查(不需要服务端凭据): + +```sh +uv run python scripts/smoke_api.py --base-url http://SERVER_IP:18080 --public-only +``` + +不要将 SERVICE_TOKEN 放入浏览器或 SDK。需要管理接口测试时,在服务器的受控环境读取部署配置,或配置你自己的受保护测试凭据。 + +参考:[Docker 端口映射](https://docs.docker.com/engine/network/port-publishing/)、[runner v0.6.1 配置](https://gitea.com/gitea/act_runner/src/tag/v0.6.1/internal/pkg/config/config.example.yaml)、[uv Docker 集成](https://docs.astral.sh/uv/guides/integration/docker/)。 diff --git a/scripts/deploy_docker.sh b/scripts/deploy_docker.sh new file mode 100755 index 0000000..3a04e4d --- /dev/null +++ b/scripts/deploy_docker.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${DEPLOY_IMAGE:?DEPLOY_IMAGE is required}" +DEPLOY_NAME=${DEPLOY_NAME:-adcloud-server-test} +DEPLOY_PORT=${DEPLOY_PORT:-18080} +DEPLOY_BIND_IP=${DEPLOY_BIND_IP:-0.0.0.0} +DEPLOY_REPOSITORY=${DEPLOY_REPOSITORY:-Zayden-Jung/adcloud-server} +DEPLOY_REVISION=${DEPLOY_REVISION:-local} +DEPLOY_CORS_ORIGINS=${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174} +[[ "$DEPLOY_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}$ ]] || { echo 'Invalid container name'; exit 2; } +[[ "$DEPLOY_PORT" =~ ^[0-9]{1,5}$ ]] && ((10#$DEPLOY_PORT >= 1024 && 10#$DEPLOY_PORT <= 65535)) || { echo 'Invalid deployment port'; exit 2; } +[[ "$DEPLOY_BIND_IP" == '0.0.0.0' || "$DEPLOY_BIND_IP" == '127.0.0.1' ]] || { echo 'Bind IP must be 0.0.0.0 or 127.0.0.1'; exit 2; } + +managed_label=io.entroia.adcloud.managed +repository_label=io.entroia.adcloud.repository +data_volume="$DEPLOY_NAME-data" +config_volume="$DEPLOY_NAME-config" +candidate="$DEPLOY_NAME-candidate" +backup="$DEPLOY_NAME-previous" +lock="$DEPLOY_NAME-deploy-lock" +lock_acquired=false +previous_saved=false +new_started=false +successful=false + +owned_container() { + [[ "$(docker inspect --format "{{index .Config.Labels \"$managed_label\"}}" "$1")" == true && "$(docker inspect --format "{{index .Config.Labels \"$repository_label\"}}" "$1")" == "$DEPLOY_REPOSITORY" ]] +} +exists() { docker container inspect "$1" >/dev/null 2>&1; } +cleanup() { + local result=$? + trap - EXIT + if ! $successful && $new_started && exists "$DEPLOY_NAME" && owned_container "$DEPLOY_NAME"; then + docker rm -f "$DEPLOY_NAME" >/dev/null || true + fi + if ! $successful && $previous_saved; then + docker rename "$backup" "$DEPLOY_NAME" >/dev/null && docker start "$DEPLOY_NAME" >/dev/null || true + echo 'Deployment failed; previous container restored where possible.' + fi + if $lock_acquired; then + if exists "$candidate" && owned_container "$candidate"; then docker rm -f "$candidate" >/dev/null || true; fi + docker rm "$lock" >/dev/null || true + fi + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Docker's unique container name gives a daemon-wide deployment lock. +# Never remove another job's lock automatically. +for attempt in $(seq 1 60); do + if docker create --name "$lock" --label "$managed_label=true" --label "$repository_label=$DEPLOY_REPOSITORY" --entrypoint /bin/true "$DEPLOY_IMAGE" >/dev/null 2>&1; then + lock_acquired=true + break + fi + sleep 2 +done +$lock_acquired || { echo 'Cannot acquire deployment lock; check daemon or a stale lock.'; exit 1; } +for name in "$candidate" "$backup"; do + if exists "$name"; then + owned_container "$name" || { echo "Refusing to alter unmanaged container $name"; exit 1; } + # A retained backup can be the only surviving previous service. + echo "Container $name already exists; inspect the previous deployment before retrying." + exit 1 + fi +done +if exists "$DEPLOY_NAME"; then + owned_container "$DEPLOY_NAME" || { echo 'Refusing to replace an unmanaged container'; exit 1; } +fi +for volume in "$data_volume" "$config_volume"; do + if docker volume inspect "$volume" >/dev/null 2>&1; then + [[ "$(docker volume inspect --format "{{index .Labels \"$repository_label\"}}" "$volume")" == "$DEPLOY_REPOSITORY" ]] || { echo "Refusing unmanaged volume $volume"; exit 1; } + else + docker volume create --label "$managed_label=true" --label "$repository_label=$DEPLOY_REPOSITORY" "$volume" >/dev/null + fi +done + +common=(--label "$managed_label=true" --label "$repository_label=$DEPLOY_REPOSITORY" --label "io.entroia.adcloud.revision=$DEPLOY_REVISION" --read-only --cap-drop ALL --security-opt no-new-privileges:true --pids-limit 128 --memory 512m --cpus 1 --tmpfs /tmp:rw,noexec,nosuid,size=64m --env "CORS_ORIGINS=$DEPLOY_CORS_ORIGINS" --env COOKIE_SECURE=false) +wait_healthy() { + local state + for attempt in $(seq 1 60); do + state=$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$1") + [[ "$state" == 'running healthy' ]] && return 0 + [[ "$state" == exited* || "$state" == dead* ]] && break + sleep 2 + done + echo "Container $1 failed health check; inspect its logs on the server." + return 1 +} + +# Validate with disposable data before stopping the currently deployed service. +docker run -d --name "$candidate" "${common[@]}" --tmpfs /data:rw,uid=10001,gid=10001,mode=0700 --tmpfs /config:rw,uid=10001,gid=10001,mode=0700 "$DEPLOY_IMAGE" >/dev/null +wait_healthy "$candidate" +docker exec "$candidate" python /app/scripts/smoke_api.py +docker rm -f "$candidate" >/dev/null + +if exists "$DEPLOY_NAME"; then + docker stop --time 20 "$DEPLOY_NAME" >/dev/null + docker rename "$DEPLOY_NAME" "$backup" + previous_saved=true +fi +new_started=true +docker run -d --name "$DEPLOY_NAME" "${common[@]}" --restart unless-stopped --publish "$DEPLOY_BIND_IP:$DEPLOY_PORT:8000" --volume "$data_volume:/data" --volume "$config_volume:/config" "$DEPLOY_IMAGE" >/dev/null +wait_healthy "$DEPLOY_NAME" +# Probe the daemon HOST namespace, not the isolated Actions job's localhost. +docker run --rm --network host --read-only --cap-drop ALL --security-opt no-new-privileges:true --pids-limit 64 --memory 128m --volume "$config_volume:/config:ro" --entrypoint python "$DEPLOY_IMAGE" /app/scripts/smoke_api.py --base-url "http://127.0.0.1:$DEPLOY_PORT" +successful=true +if $previous_saved; then docker rm "$backup" >/dev/null; fi +printf 'Deployment ready: %s → container 8000; container=%s; revision=%s\n' "$DEPLOY_PORT" "$DEPLOY_NAME" "$DEPLOY_REVISION" diff --git a/scripts/smoke_api.py b/scripts/smoke_api.py new file mode 100644 index 0000000..a9b25ba --- /dev/null +++ b/scripts/smoke_api.py @@ -0,0 +1,110 @@ +"""HTTP deployment checks using only synthetic data; never print credentials.""" + +import argparse +import http.cookiejar +import json +import os +import urllib.error +import urllib.request +from pathlib import Path +from uuid import uuid4 + +from dotenv import dotenv_values + + +def check(base_url: str, service_token: str): + jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + + def api(path, body=None, *, trusted=False, method=None, expected=200): + headers = {"Content-Type": "application/json"} + if trusted: + headers["Authorization"] = "Bearer " + service_token + request = urllib.request.Request( + base_url.rstrip("/") + path, + data=None if body is None else json.dumps(body).encode(), + headers=headers, + method=method, + ) + try: + with opener.open(request, timeout=10) as response: + assert response.status == expected, "Unexpected HTTP status" + return json.load(response) + except urllib.error.HTTPError as error: + if error.code == expected: + return None + raise RuntimeError( + f"HTTP smoke request failed: {path} HTTP {error.code}" + ) from None + + assert api("/health") == {"status": "ok"} + for module in ("events", "users", "ads", "identity"): + assert api(f"/v1/{module}/health") == {"status": "ok", "module": module} + assert "/v1/identity/stitch" in api("/openapi.json")["paths"] + anon = api("/v1/identity/anonymous", {}) + assert any(cookie.name == "adcloud_anonymous" for cookie in jar), ( + "Missing anonymous cookie" + ) + event = api( + "/v1/events", + { + "event_id": str(uuid4()), + "name": "deployment.smoke", + "properties": {"source": "gitea-actions"}, + }, + ) + assert event["anonymous_id"] == anon["anonymous_id"], "Anonymous cookie not reused" + if service_token: + api("/v1/users/" + anon["master_id"], expected=401) + api( + "/v1/users/" + anon["master_id"], + {"attributes": {"smoke": True}}, + trusted=True, + method="PATCH", + ) + linked = api( + "/v1/identity/stitch", + { + "anonymous_token": anon["anonymous_token"], + "identifiers": [ + {"kind": "email", "value": "smoke-" + uuid4().hex + "@example.com"} + ], + }, + trusted=True, + ) + profile = api("/v1/users/" + linked["master_id"], trusted=True) + assert profile["event_count"] == 1 and profile["attributes"]["smoke"] is True + history = api("/v1/events?master_id=" + linked["master_id"], trusted=True) + assert history["items"][0]["id"] == event["event_id"] + graph = api("/v1/identity/" + linked["master_id"] + "/graph", trusted=True) + assert len(graph["identifiers"]) == 1 + print( + "HTTP smoke passed: routes, anonymous cookie, events" + + (", auth, stitching, profile and history" if service_token else "") + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default="http://127.0.0.1:8000") + parser.add_argument( + "--public-only", + action="store_true", + help="Do not read or send the service token", + ) + args = parser.parse_args() + token = "" + if not args.public_only: + stored = dotenv_values( + Path(os.getenv("IDENTITY_BOOTSTRAP_FILE", "/config/identity.env")) + ) + token = os.getenv("SERVICE_TOKEN") or stored.get("SERVICE_TOKEN") or "" + if not token: + raise SystemExit( + "Configure SERVICE_TOKEN or mount the deployment config volume" + ) + check(args.base_url, token) + + +if __name__ == "__main__": + main() diff --git a/src/adcloud_server/container_entrypoint.py b/src/adcloud_server/container_entrypoint.py new file mode 100644 index 0000000..4c88399 --- /dev/null +++ b/src/adcloud_server/container_entrypoint.py @@ -0,0 +1,69 @@ +"""Persist per-deployment secrets in a Docker volume before starting uvicorn.""" + +import fcntl +import os +import re +import secrets +import sys +import tempfile +from pathlib import Path + +from dotenv import dotenv_values + +SECRET_NAMES = ("IDENTITY_SECRET", "SERVICE_TOKEN") + + +def bootstrap(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + lock = path.with_suffix(".lock") + descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT, 0o600) + with os.fdopen(descriptor, "w") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + existing = dotenv_values(path) if path.exists() else {} + selected = { + name: os.environ.get(name) + or existing.get(name) + or secrets.token_urlsafe(48) + for name in SECRET_NAMES + } + if any( + not re.fullmatch(r"[A-Za-z0-9_-]{32,}", value) + for value in selected.values() + ): + raise ValueError("Container secrets must have 32+ URL-safe characters") + if any(existing.get(name) != value for name, value in selected.items()): + temporary = None + try: + with tempfile.NamedTemporaryFile( + mode="w", dir=path.parent, prefix=".identity-", delete=False + ) as output: + temporary = Path(output.name) + os.chmod(temporary, 0o600) + for name, value in selected.items(): + output.write(name + "=" + value + "\n") + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + os.chmod(path, 0o600) + return selected + + +def main(): + path = Path(os.getenv("IDENTITY_BOOTSTRAP_FILE", "/config/identity.env")) + os.environ.update(bootstrap(path)) + command = sys.argv[1:] or [ + "uvicorn", + "adcloud_server.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + os.execvp(command[0], command) + + +if __name__ == "__main__": + main() diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py new file mode 100644 index 0000000..e4b9db2 --- /dev/null +++ b/tests/test_container_entrypoint.py @@ -0,0 +1,46 @@ +import stat +from concurrent.futures import ThreadPoolExecutor + +import pytest +from dotenv import dotenv_values + +from adcloud_server.container_entrypoint import SECRET_NAMES, bootstrap + + +@pytest.fixture(autouse=True) +def clean_secret_environment(monkeypatch): + for name in SECRET_NAMES: + monkeypatch.delenv(name, raising=False) + + +def test_generated_secrets_survive_restarts_with_restricted_permissions( + tmp_path, capsys +): + path = tmp_path / "config" / "identity.env" + first = bootstrap(path) + assert bootstrap(path) == first + assert dict(dotenv_values(path)) == first + assert all(len(value) >= 32 for value in first.values()) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert capsys.readouterr().out == "" + + +def test_parallel_container_bootstraps_use_the_same_secrets(tmp_path): + path = tmp_path / "identity.env" + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda _: bootstrap(path), range(4))) + assert all(result == results[0] for result in results) + assert dict(dotenv_values(path)) == results[0] + + +def test_explicit_secrets_are_persisted_and_invalid_input_cannot_corrupt_file( + tmp_path, monkeypatch +): + path = tmp_path / "identity.env" + monkeypatch.setenv("IDENTITY_SECRET", "explicit-identity-" + "x" * 32) + first = bootstrap(path) + assert first["IDENTITY_SECRET"] == "explicit-identity-" + "x" * 32 + monkeypatch.setenv("SERVICE_TOKEN", "x" * 32 + "\nINJECTED=true") + with pytest.raises(ValueError): + bootstrap(path) + assert dict(dotenv_values(path)) == first diff --git a/tests/test_docker_deploy.py b/tests/test_docker_deploy.py new file mode 100644 index 0000000..6884bf4 --- /dev/null +++ b/tests/test_docker_deploy.py @@ -0,0 +1,32 @@ +import os +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy_docker.sh" + + +def test_invalid_public_port_is_rejected_before_contacting_docker(): + result = subprocess.run( + ["bash", str(SCRIPT)], + env={**os.environ, "DEPLOY_IMAGE": "test-image", "DEPLOY_PORT": "0"}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 2 + assert "Invalid deployment port" in result.stdout + + +def test_invalid_container_name_and_bind_ip_cannot_be_used(): + for configuration in ( + {"DEPLOY_NAME": "other;service"}, + {"DEPLOY_BIND_IP": "example.invalid"}, + ): + result = subprocess.run( + ["bash", str(SCRIPT)], + env={**os.environ, "DEPLOY_IMAGE": "test-image", **configuration}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 2