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:
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()
|
||||
Reference in New Issue
Block a user