Add Docker test deployment on Gitea Actions
Docker test deployment / test-build-deploy (push) Successful in 4m5s
Docker test deployment / test-build-deploy (push) Successful in 4m5s
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)。
|
||||
|
||||
@@ -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/)。
|
||||
Executable
+111
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user