# «Контракт-чек» app services on Nomad (single VPS, docker driver). # # Groups: api (with prestart migrations) + 5 workers. Stateful infra # (postgres/redis/rabbitmq/minio) stays on compose; tasks reach it via the # docker0 host gateway 172.17.0.1 and the host-published ports # (15432/17379/5672/9000). The nginx edge cascade is unchanged. # # Secrets flow (nothing sensitive lives in this file): # - app secrets + connection strings → Nomad Variables at # nomad/jobs/contract-check (see deploy/nomad/README.md §5), rendered # agent-side into env by `template` blocks; # - image tag + APP_VERSION use ${IMAGE_TAG}, rendered by `envsubst` at # submit time: # IMAGE_TAG= envsubst < deploy/nomad/contract-check.nomad.hcl | nomad job run - # - registry auth: host-level `docker login` on the VPS (README §5). # # First deploy: verify with `nomad job validate` + `nomad job plan`, then # cutover workers one by one (README / docs/DEPLOY.md §15). job "contract-check" { datacenters = ["vps"] type = "service" # ── Rolling defaults for every group ──────────────────────────────────────── update { max_parallel = 1 min_healthy_time = "15s" healthy_deadline = "5m" progress_deadline = "10m" auto_revert = true } # ══ API ════════════════════════════════════════════════════════════════════ group "api" { count = 1 # No canary here: the static host port (18000) cannot be bound twice on a # single node. Rolling = brief seconds-level gap per deploy; switch to # dynamic ports + Traefik if zero-downtime becomes a requirement. update { max_parallel = 1 } restart { attempts = 3 interval = "10m" delay = "15s" mode = "delay" } network { port "http" { static = 18000 to = 8000 } } service { name = "contract-check-api" port = "http" provider = "nomad" check { name = "healthz" type = "http" path = "/healthz" interval = "10s" timeout = "3s" } check_restart { limit = 3 grace = "30s" } } # One-shot migrations before the API starts (replaces `make migrate`). task "migrate" { lifecycle { hook = "prestart" } driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-api:${IMAGE_TAG}" args = ["alembic", "upgrade", "head"] # registry auth: host-level `docker login` on the VPS (README §5) } template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" {{ end }} EOT } resources { cpu = 150 memory = 256 } } task "api" { driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-api:${IMAGE_TAG}" ports = ["http"] # registry auth: host-level `docker login` on the VPS (README §5) } env { ENV = "prod" LOG_LEVEL = "INFO" LOG_FORMAT = "json" APP_VERSION = "${IMAGE_TAG}" } # All connection strings + app secrets, agent-rendered from Nomad # Variables. Missing keys render empty — define them all once (README §5). template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" REDIS_URL="{{ .redis_url }}" RABBITMQ_URL="{{ .rabbitmq_url }}" S3_ENDPOINT_URL="{{ .s3_endpoint_url }}" S3_ACCESS_KEY="{{ .s3_access_key }}" S3_SECRET_KEY="{{ .s3_secret_key }}" S3_BUCKET="{{ .s3_bucket }}" JWT_SECRET="{{ .jwt_secret }}" TELEGRAM_BOT_TOKEN="{{ .telegram_bot_token }}" OLLAMA_API_KEY="{{ .ollama_api_key }}" YANDEXGPT_API_KEY="{{ .yandexgpt_api_key }}" SMTP_HOST="{{ .smtp_host }}" SMTP_USERNAME="{{ .smtp_username }}" SMTP_PASSWORD="{{ .smtp_password }}" METRICS_BEARER_TOKEN="{{ .metrics_bearer_token }}" {{ end }} EOT } kill_timeout = "30s" resources { cpu = 300 memory = 512 } } } # ══ WORKERS ═══════════════════════════════════════════════════════════════ # Workers bind no ports, so each group gets canary + auto_promote: # new version starts alongside the old one, must pass min_healthy_time, # then old allocations stop. auto_revert rolls back on failure. # Scale with: nomad job scale contract-check worker- group "worker-extract" { count = 1 update { canary = 1 auto_promote = true } restart { attempts = 3 interval = "10m" delay = "15s" mode = "delay" } task "worker-extract" { driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-worker-extract:${IMAGE_TAG}" # registry auth: host-level `docker login` on the VPS (README §5) } env { ENV = "prod" LOG_LEVEL = "INFO" LOG_FORMAT = "json" APP_VERSION = "${IMAGE_TAG}" } template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" RABBITMQ_URL="{{ .rabbitmq_url }}" S3_ENDPOINT_URL="{{ .s3_endpoint_url }}" S3_ACCESS_KEY="{{ .s3_access_key }}" S3_SECRET_KEY="{{ .s3_secret_key }}" S3_BUCKET="{{ .s3_bucket }}" OLLAMA_API_KEY="{{ .ollama_api_key }}" YANDEXGPT_API_KEY="{{ .yandexgpt_api_key }}" {{ end }} EOT } kill_timeout = "60s" resources { # CPU-bound OCR (tesseract); the heaviest task of the pipeline. cpu = 500 memory = 640 } } } group "worker-analyze" { count = 1 update { canary = 1 auto_promote = true } restart { attempts = 3 interval = "10m" delay = "15s" mode = "delay" } task "worker-analyze" { driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-worker-analyze:${IMAGE_TAG}" # registry auth: host-level `docker login` on the VPS (README §5) } env { ENV = "prod" LOG_LEVEL = "INFO" LOG_FORMAT = "json" APP_VERSION = "${IMAGE_TAG}" } template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" RABBITMQ_URL="{{ .rabbitmq_url }}" S3_ENDPOINT_URL="{{ .s3_endpoint_url }}" S3_ACCESS_KEY="{{ .s3_access_key }}" S3_SECRET_KEY="{{ .s3_secret_key }}" S3_BUCKET="{{ .s3_bucket }}" OLLAMA_API_KEY="{{ .ollama_api_key }}" YANDEXGPT_API_KEY="{{ .yandexgpt_api_key }}" {{ end }} EOT } kill_timeout = "120s" resources { # LLM calls (I/O bound, long in-flight requests on shutdown). cpu = 250 memory = 384 } } } group "worker-prescreen" { count = 1 update { canary = 1 auto_promote = true } restart { attempts = 3 interval = "10m" delay = "15s" mode = "delay" } task "worker-prescreen" { driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-worker-prescreen:${IMAGE_TAG}" # registry auth: host-level `docker login` on the VPS (README §5) } env { ENV = "prod" LOG_LEVEL = "INFO" LOG_FORMAT = "json" APP_VERSION = "${IMAGE_TAG}" } template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" RABBITMQ_URL="{{ .rabbitmq_url }}" S3_ENDPOINT_URL="{{ .s3_endpoint_url }}" S3_ACCESS_KEY="{{ .s3_access_key }}" S3_SECRET_KEY="{{ .s3_secret_key }}" S3_BUCKET="{{ .s3_bucket }}" OLLAMA_API_KEY="{{ .ollama_api_key }}" YANDEXGPT_API_KEY="{{ .yandexgpt_api_key }}" {{ end }} EOT } kill_timeout = "60s" resources { cpu = 150 memory = 256 } } } group "worker-billing" { count = 1 update { canary = 1 auto_promote = true } restart { attempts = 3 interval = "10m" delay = "15s" mode = "delay" } task "worker-billing" { driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-worker-billing:${IMAGE_TAG}" # registry auth: host-level `docker login` on the VPS (README §5) } env { ENV = "prod" LOG_LEVEL = "INFO" LOG_FORMAT = "json" APP_VERSION = "${IMAGE_TAG}" } template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" {{ end }} EOT } kill_timeout = "60s" resources { # Background DB-only worker (dunning, renewals); lightest of the set. cpu = 150 memory = 256 } } } group "worker-notify" { count = 1 update { canary = 1 auto_promote = true } restart { attempts = 3 interval = "10m" delay = "15s" mode = "delay" } task "worker-notify" { driver = "docker" config { image = "p2gnl.mu-dungeon.xyz/admin-git/contract-check-worker-notify:${IMAGE_TAG}" # registry auth: host-level `docker login` on the VPS (README §5) } env { ENV = "prod" LOG_LEVEL = "INFO" LOG_FORMAT = "json" APP_VERSION = "${IMAGE_TAG}" } template { destination = "secrets/env" env = true change_mode = "restart" data = <<-EOT {{ with nomadVar "nomad/jobs/contract-check" }} DATABASE_URL="{{ .database_url }}" RABBITMQ_URL="{{ .rabbitmq_url }}" SMTP_HOST="{{ .smtp_host }}" SMTP_USERNAME="{{ .smtp_username }}" SMTP_PASSWORD="{{ .smtp_password }}" {{ end }} EOT } kill_timeout = "60s" resources { cpu = 150 memory = 256 } } } }