feat: complete dashcam catalog ingestion demo
Master Production Deploy / production (push) Failing after 49s

This commit is contained in:
2026-07-31 02:01:58 +03:00
parent ae6c35145f
commit 2ce58fcbc0
62 changed files with 6341 additions and 178 deletions
+36
View File
@@ -0,0 +1,36 @@
# Portainer / Swarm deployment
`web`, PostgreSQL, `ingestion-worker`, and `ingestion-admin` are one stack. Do
not deploy ingestion as a separate endpoint: the approved catalog is exchanged
through the `catalog_exports` volume mounted read-only by web and read-write by
the worker.
In Portainer, create a Swarm stack from `deploy/swarm/docker-stack.yml`.
Configure these values as stack environment variables:
- `WEB_IMAGE` - digest-pinned web image.
- `INGESTION_IMAGE` - digest-pinned ingestion image.
- `POSTGRES_PASSWORD` - database secret.
- `INGESTION_ADMIN_PASSWORD` - Basic Auth password for `/admin`.
- `PUBLIC_HOST=videoreg.ru` and `TRAEFIK_NETWORK`.
- Optional source/crawl settings: `MVIDEO_DASHCAM_CATEGORY_URL`, daily budgets,
and delay bounds.
Keep every service on the same manager/placement constraint. The database,
snapshots, read model, and shared catalog volume are local Docker volumes and
must be backed up from that node.
Gitea Actions uses `.gitea/workflows/master.yml`: it tests the repository,
builds both images, pushes immutable digests, and deploys this one stack. The
workflow connects to the Swarm manager, pulls those digests, and runs `docker
stack deploy --with-registry-auth`; Portainer then shows and manages the same
updated stack. Portainer does not need registry credentials or a separate
webhook-based image build.
Verification after deploy:
```bash
curl -fsS https://videoreg.ru/health/ready
curl -u "$INGESTION_ADMIN_USERNAME:$INGESTION_ADMIN_PASSWORD" \
https://videoreg.ru/admin/api/queue
```
+20 -2
View File
@@ -19,6 +19,7 @@ require_var STACK_NAME
require_var RELEASE_ID
require_var COMMIT_SHA
require_var WEB_IMAGE
require_var INGESTION_IMAGE
require_var PUBLIC_HOST
require_var TRAEFIK_NETWORK
@@ -37,7 +38,7 @@ cleanup_release_images() {
fi
echo "Cleaning up unused release image after failed deploy." >&2
docker image rm "$WEB_IMAGE" >/dev/null 2>&1 || true
docker image rm "$WEB_IMAGE" "$INGESTION_IMAGE" >/dev/null 2>&1 || true
if [ -n "${COMMIT_SHA:-}" ]; then
docker image prune -af --filter "label=org.opencontainers.image.revision=${COMMIT_SHA}" >&2 || true
@@ -160,15 +161,32 @@ prune_docker_before_pull
check_docker_disk_space
docker image pull "$WEB_IMAGE"
docker image pull "$INGESTION_IMAGE"
export ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
export ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE INGESTION_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
export REGION="${REGION:-}" TEAM_ID="${TEAM_ID:-}" WEB_REPLICAS="${WEB_REPLICAS:-1}"
export WEB_PLACEMENT_CONSTRAINT="${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}"
export TRAEFIK_ENTRYPOINTS="${TRAEFIK_ENTRYPOINTS:-websecure}" TRAEFIK_TLS="${TRAEFIK_TLS:-true}"
export TRAEFIK_CERTRESOLVER="${TRAEFIK_CERTRESOLVER:-le}"
export TRAEFIK_ADMIN_PRIORITY="${TRAEFIK_ADMIN_PRIORITY:-100}"
export VIDEOREG_DB_MEMORY_TTL_MS="${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}"
export VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS="${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}"
export POSTGRES_DB="${POSTGRES_DB:-videoreg_ingestion}" POSTGRES_USER="${POSTGRES_USER:-videoreg}"
export INGESTION_ADMIN_USERNAME="${INGESTION_ADMIN_USERNAME:-admin}"
export YANDEX_MARKET_DASHCAM_CATEGORY_URL="${YANDEX_MARKET_DASHCAM_CATEGORY_URL:-https://market.yandex.ru/catalog--avtomobilnye-videoregistratory/82798275/list?hid=82798269}"
export MVIDEO_DASHCAM_CATEGORY_URL="${MVIDEO_DASHCAM_CATEGORY_URL:-}"
export INGESTION_CATEGORY_DAILY_BUDGET="${INGESTION_CATEGORY_DAILY_BUDGET:-25}"
export INGESTION_PRODUCT_DAILY_BUDGET="${INGESTION_PRODUCT_DAILY_BUDGET:-100}"
export INGESTION_SITEMAP_DAILY_BUDGET="${INGESTION_SITEMAP_DAILY_BUDGET:-25}"
export INGESTION_FETCH_DELAY_MIN_MS="${INGESTION_FETCH_DELAY_MIN_MS:-5000}"
export INGESTION_FETCH_DELAY_MAX_MS="${INGESTION_FETCH_DELAY_MAX_MS:-15000}"
export INGESTION_WORKER_BATCH_SIZE="${INGESTION_WORKER_BATCH_SIZE:-1}"
export INGESTION_WORKER_IDLE_MS="${INGESTION_WORKER_IDLE_MS:-30000}"
export INGESTION_WORKER_REPLICAS="${INGESTION_WORKER_REPLICAS:-1}"
docker stack deploy --with-registry-auth --prune -c "$REMOTE_DIR/docker-stack.yml" "$STACK_NAME"
wait_service "${STACK_NAME}_web"
wait_service "${STACK_NAME}_ingestion-worker"
wait_service "${STACK_NAME}_ingestion-admin"
log deploy_converged
+8 -2
View File
@@ -20,6 +20,7 @@ require_var STACK_NAME
require_var RELEASE_ID
require_var COMMIT_SHA
require_var WEB_IMAGE
require_var INGESTION_IMAGE
require_var PUBLIC_HOST
require_var TRAEFIK_NETWORK
@@ -32,10 +33,15 @@ scp -P "$SSH_PORT" deploy/swarm/docker-stack.yml "$SSH_TARGET:$REMOTE_DEPLOY_DIR
scp -P "$SSH_PORT" deploy/remote/deploy-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/deploy-stack.sh"
remote_vars=(
ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
REGION TEAM_ID WEB_REPLICAS WEB_PLACEMENT_CONSTRAINT TRAEFIK_ENTRYPOINTS TRAEFIK_TLS REGISTRY REGISTRY_USERNAME REGISTRY_PASSWORD
ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE INGESTION_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
REGION TEAM_ID WEB_REPLICAS WEB_PLACEMENT_CONSTRAINT TRAEFIK_ENTRYPOINTS TRAEFIK_TLS TRAEFIK_CERTRESOLVER REGISTRY REGISTRY_USERNAME REGISTRY_PASSWORD
RELEASE_LOCK_FILE REMOTE_DEPLOY_DIR DEPLOY_MIN_FREE_MB DEPLOY_PRUNE_BEFORE_PULL DEPLOY_PRUNE_UNTIL
DEPLOY_CLEANUP_FAILED_IMAGES VIDEOREG_DB_MEMORY_TTL_MS VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD INGESTION_ADMIN_USERNAME INGESTION_ADMIN_PASSWORD
YANDEX_MARKET_DASHCAM_CATEGORY_URL MVIDEO_DASHCAM_CATEGORY_URL INGESTION_CATEGORY_DAILY_BUDGET
INGESTION_PRODUCT_DAILY_BUDGET INGESTION_SITEMAP_DAILY_BUDGET INGESTION_FETCH_DELAY_MIN_MS
INGESTION_FETCH_DELAY_MAX_MS INGESTION_WORKER_BATCH_SIZE INGESTION_WORKER_IDLE_MS INGESTION_WORKER_REPLICAS
TRAEFIK_ADMIN_PRIORITY
)
remote_command=""
+8 -3
View File
@@ -4,6 +4,9 @@ const required = [
'RELEASE_ID',
'COMMIT_SHA',
'WEB_IMAGE',
'INGESTION_IMAGE',
'POSTGRES_PASSWORD',
'INGESTION_ADMIN_PASSWORD',
'PUBLIC_HOST',
'TRAEFIK_NETWORK',
'SWARM_MANAGER_HOST',
@@ -16,9 +19,11 @@ if (missing.length > 0) {
process.exit(1);
}
if (!/@sha256:[a-f0-9]{64}$/i.test(process.env.WEB_IMAGE ?? '')) {
console.error(`WEB_IMAGE must be pinned by immutable digest, got: ${process.env.WEB_IMAGE}`);
process.exit(1);
for (const imageName of ['WEB_IMAGE', 'INGESTION_IMAGE']) {
if (!/@sha256:[a-f0-9]{64}$/i.test(process.env[imageName] ?? '')) {
console.error(`${imageName} must be pinned by immutable digest, got: ${process.env[imageName]}`);
process.exit(1);
}
}
console.log(
+8 -2
View File
@@ -18,13 +18,19 @@ const manifest = {
commitSha: requireEnv('COMMIT_SHA'),
releaseId: requireEnv('RELEASE_ID'),
webImageDigest: requireEnv('WEB_IMAGE_DIGEST'),
ingestionImageDigest: requireEnv('INGESTION_IMAGE_DIGEST'),
deployTimestamp: new Date().toISOString(),
smokeResult: smoke,
workflowRunId: requireEnv('WORKFLOW_RUN_ID'),
};
if (!/@sha256:[a-f0-9]{64}$/i.test(manifest.webImageDigest)) {
throw new Error('webImageDigest must be digest-pinned');
for (const [name, image] of Object.entries({
webImageDigest: manifest.webImageDigest,
ingestionImageDigest: manifest.ingestionImageDigest,
})) {
if (!/@sha256:[a-f0-9]{64}$/i.test(image)) {
throw new Error(`${name} must be digest-pinned`);
}
}
console.log(JSON.stringify(manifest, null, 2));
+106 -15
View File
@@ -1,13 +1,47 @@
version: "3.9"
x-default-deploy: &default-deploy
placement:
constraints:
- ${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}
update_config:
order: start-first
parallelism: 1
failure_action: rollback
monitor: 45s
rollback_config:
order: start-first
parallelism: 1
monitor: 45s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 60s
x-ingestion-env: &ingestion-env
INGESTION_DATABASE_URL: postgres://${POSTGRES_USER:-videoreg}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-videoreg_ingestion}
INGESTION_SNAPSHOT_DIR: /app/ingestion/data/snapshots
INGESTION_EXPORT_DIR: /exports
YANDEX_MARKET_DASHCAM_CATEGORY_URL: ${YANDEX_MARKET_DASHCAM_CATEGORY_URL:-https://market.yandex.ru/catalog--avtomobilnye-videoregistratory/82798275/list?hid=82798269}
MVIDEO_DASHCAM_CATEGORY_URL: ${MVIDEO_DASHCAM_CATEGORY_URL:-}
INGESTION_CATEGORY_DAILY_BUDGET: ${INGESTION_CATEGORY_DAILY_BUDGET:-25}
INGESTION_PRODUCT_DAILY_BUDGET: ${INGESTION_PRODUCT_DAILY_BUDGET:-100}
INGESTION_SITEMAP_DAILY_BUDGET: ${INGESTION_SITEMAP_DAILY_BUDGET:-25}
INGESTION_FETCH_DELAY_MIN_MS: ${INGESTION_FETCH_DELAY_MIN_MS:-5000}
INGESTION_FETCH_DELAY_MAX_MS: ${INGESTION_FETCH_DELAY_MAX_MS:-15000}
INGESTION_WORKER_BATCH_SIZE: ${INGESTION_WORKER_BATCH_SIZE:-1}
INGESTION_WORKER_IDLE_MS: ${INGESTION_WORKER_IDLE_MS:-30000}
services:
web:
image: ${WEB_IMAGE}
image: ${WEB_IMAGE:?WEB_IMAGE is required}
environment:
NODE_ENV: production
APP_ENV: ${ENVIRONMENT}
PORT: "3000"
VIDEOREG_DB_FILE: /data/videoreg.json
DASHCAM_CATALOG_EXPORT_DIR: /exports
VIDEOREG_DB_MEMORY_TTL_MS: ${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}
VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}
COMMIT_SHA: ${COMMIT_SHA}
@@ -16,6 +50,7 @@ services:
TEAM_ID: ${TEAM_ID:-}
volumes:
- videoreg_data:/data
- catalog_exports:/exports:ro
networks:
traefik: {}
healthcheck:
@@ -25,34 +60,90 @@ services:
retries: 3
start_period: 20s
deploy:
<<: *default-deploy
replicas: ${WEB_REPLICAS:-1}
placement:
constraints:
- ${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}
update_config:
order: start-first
parallelism: 1
failure_action: rollback
monitor: 45s
rollback_config:
order: start-first
parallelism: 1
monitor: 45s
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK}"
- "traefik.http.routers.${STACK_NAME}-web.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/`)"
- "traefik.http.routers.${STACK_NAME}-web.priority=${TRAEFIK_WEB_PRIORITY:-1}"
- "traefik.http.routers.${STACK_NAME}-web.entrypoints=${TRAEFIK_ENTRYPOINTS:-websecure}"
- "traefik.http.routers.${STACK_NAME}-web.tls=${TRAEFIK_TLS:-true}"
- "traefik.http.routers.${STACK_NAME}-web.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
- "traefik.http.services.${STACK_NAME}-web.loadbalancer.server.port=3000"
stop_grace_period: 30s
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-videoreg_ingestion}
POSTGRES_USER: ${POSTGRES_USER:-videoreg}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-videoreg} -d ${POSTGRES_DB:-videoreg_ingestion}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- ingestion_postgres:/var/lib/postgresql/data
networks:
- ingestion
deploy: *default-deploy
ingestion-worker:
image: ${INGESTION_IMAGE:?INGESTION_IMAGE is required}
command: sh -c "npm run ingestion:migrate && npm run ingestion:worker"
environment: *ingestion-env
volumes:
- ingestion_data:/app/ingestion/data
- catalog_exports:/exports
networks:
- ingestion
deploy:
<<: *default-deploy
replicas: ${INGESTION_WORKER_REPLICAS:-1}
ingestion-admin:
image: ${INGESTION_IMAGE:?INGESTION_IMAGE is required}
command: sh -c "npm run ingestion:migrate && npm run ingestion:admin"
environment:
<<: *ingestion-env
INGESTION_ADMIN_PORT: "4101"
INGESTION_ADMIN_USERNAME: ${INGESTION_ADMIN_USERNAME:-admin}
INGESTION_ADMIN_PASSWORD: ${INGESTION_ADMIN_PASSWORD:?INGESTION_ADMIN_PASSWORD is required}
volumes:
- ingestion_data:/app/ingestion/data
networks:
ingestion: {}
traefik: {}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:4101/health >/dev/null || exit 1"]
interval: 15s
timeout: 3s
retries: 5
start_period: 20s
deploy:
<<: *default-deploy
replicas: 1
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK}"
- "traefik.http.routers.${STACK_NAME}-admin.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/admin`)"
- "traefik.http.routers.${STACK_NAME}-admin.priority=${TRAEFIK_ADMIN_PRIORITY:-100}"
- "traefik.http.routers.${STACK_NAME}-admin.entrypoints=${TRAEFIK_ENTRYPOINTS:-websecure}"
- "traefik.http.routers.${STACK_NAME}-admin.tls=${TRAEFIK_TLS:-true}"
- "traefik.http.routers.${STACK_NAME}-admin.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
- "traefik.http.services.${STACK_NAME}-admin.loadbalancer.server.port=4101"
volumes:
videoreg_data:
catalog_exports:
ingestion_postgres:
ingestion_data:
networks:
ingestion:
driver: overlay
traefik:
external: true
name: ${TRAEFIK_NETWORK}