Add Docker test deployment on Gitea Actions
Docker test deployment / test-build-deploy (push) Successful in 4m5s

This commit is contained in:
2026-09-16 01:27:57 +08:00
parent 0fc6c68a99
commit 789ab64013
10 changed files with 613 additions and 0 deletions
+16
View File
@@ -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
+86
View File
@@ -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
+33
View File
@@ -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"]
+5
View File
@@ -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)。
+105
View File
@@ -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/)。
+111
View File
@@ -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"
+110
View File
@@ -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()
@@ -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()
+46
View File
@@ -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
+32
View File
@@ -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