fleettickets/docs/deployment-and-operations.md
david kiania 0787d3a185 docs: add deployment & operations runbook (Coolify, webhook, bucket cutover)
Capture the operational knowledge from the isptickets cutover: Coolify app/container,
env management (encrypted — UI or artisan tinker), cron, the Forgejo->Coolify auto-deploy
webhook (config + recreate/verify; it was missing), manual deploy trigger, the
source-bucket cutover procedure, and verification queries. Link it from README; refresh
stale tickets-bucket/ETag references in implementation.md to the isptickets CDC model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:24:23 +03:00

8.2 KiB
Raw Blame History

Deployment & Operations — fleettickets

Operational runbook for the INC ingest pipeline as deployed on Coolify (host kianiadee@twala.rahamafresh.com, key ~/.ssh/id_ed25519). Covers the container, environment, schedule, auto-deploy webhook, the source-bucket cutover procedure, and verification. Secrets are referenced by where to retrieve them, never by value.

What's deployed

Thing Detail
Coolify app fleettickets — id 15, uuid g14mwzo73q20g70vc6fzumya, build pack dockerfile, git main
Container built from this repo's Dockerfile (python:3.12-slim, TZ=Africa/Nairobi); kept alive with tail -f /dev/null (no web server)
Ingest a Coolify Scheduled Task inc_tickets running python import_tickets.py --from-bucket --apply
DB tickets schema in the shared tracksolid_db (internal host timescale_db:5432)
Source isptickets S3 bucket, automations/inc/changes/<EAT-ts>.csv CDC stream (see ../n8n-s3-ticket-exports.md and ../README.md)

Resolve the live container name (Coolify appends a random suffix):

ssh -i ~/.ssh/id_ed25519 kianiadee@twala.rahamafresh.com \
  'docker ps --filter name=g14mwzo73q20g70vc6fzumya --format "{{.Names}}" | head -1'

Schedule (cron)

The Scheduled Task runs */20 6-20 * * * — every 20 min, 06:0020:40 EAT. Coolify evaluates task cron in the server timezone (server_settings.server_timezone = Africa/Nairobi), so no UTC conversion — write EAT directly. The --from-bucket run is a cheap no-op when no new change file has arrived (watermark guard), so a dense schedule is safe.

To change the frequency, edit the task in the Coolify UI, or in coolify-db:

UPDATE scheduled_tasks SET frequency = '*/20 6-20 * * *', updated_at = now()
WHERE name = 'inc_tickets';   -- id 3

Coolify's scheduler re-reads scheduled_tasks each minute, so the change is picked up without a redeploy. Execution history: scheduled_task_executions.

The repo's Dockerfile, run_ingest.sh, and README.md document this same cron for the plain-host/VM fallback (CRON_TZ=Africa/Nairobi).

Environment variables

Set on the Coolify app (Environment Variables). Names only — values live in Coolify:

Var Purpose
DATABASE_URL tracksolid_db (internal timescale_db:5432)
RUSTFS_ENDPOINT https://s3.rahamafresh.com
RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY isptickets bucket credentials
RUSTFS_REGION us-east-1
TICKETS_BUCKET isptickets
GEOCODER_PROVIDER / GEOCODER_API_KEY keyed geocoder (LocationIQ/OpenCage)

Env vars are Laravel-encrypted in coolify-db — never raw-UPDATE them. Change them in the Coolify UI, or via artisan tinker (which re-encrypts on save):

ssh -i ~/.ssh/id_ed25519 kianiadee@twala.rahamafresh.com 'docker exec -i coolify php artisan tinker' <<'PHP'
$e = \App\Models\EnvironmentVariable::where('resourceable_type','App\\Models\\Application')
       ->where('resourceable_id',15)->where('key','TICKETS_BUCKET')->first();
$e->value = 'isptickets'; $e->save(); echo $e->value.PHP_EOL;
PHP

An env change only takes effect after the container is recreated (a redeploy — see below), since Coolify injects env at container create time.

Deploys

Auto-deploy (Forgejo → Coolify webhook)

A push to main should auto-deploy. This needs both the Coolify per-app Auto-Deploy toggle (Configuration → Advanced) and a webhook on the Forgejo repo. The webhook was missing originally (the toggle alone is not enough); it now exists as hook id 3 on kianiadee/fleettickets:

Field Value
URL https://stage.rahamafresh.com/webhooks/source/gitea/events/manual
Type / content-type gitea / json
Events / branch filter push / main
Secret the app's manual_webhook_secret_gitea (Coolify HMAC-validates X-Hub-Signature-256)

Recreate / inspect it via the Forgejo API (auth: git credential fill, host repo.rahamafresh.com, basic auth to /api/v1 — no tea/gh needed). Get the secret by decrypting it in Coolify:

ssh -i ~/.ssh/id_ed25519 kianiadee@twala.rahamafresh.com \
  "docker exec -i coolify php artisan tinker --execute=\"echo \\App\\Models\\Application::find(15)->manual_webhook_secret_gitea;\""
# list / test the webhook (USER:PASS from git credential fill)
curl -s -u "$USER:$PASS" https://repo.rahamafresh.com/api/v1/repos/kianiadee/fleettickets/hooks
curl -s -u "$USER:$PASS" -X POST https://repo.rahamafresh.com/api/v1/repos/kianiadee/fleettickets/hooks/3/tests

A successful test shows a webhook hit in docker logs coolify (no invalid_signature audit) and a new row in application_deployment_queues.

Manual deploy (no push)

Trigger the same action as Coolify's Deploy button via tinker:

ssh -i ~/.ssh/id_ed25519 kianiadee@twala.rahamafresh.com 'docker exec -i coolify php artisan tinker' <<'PHP'
$app = \App\Models\Application::where('uuid','g14mwzo73q20g70vc6fzumya')->first();
$uuid = new \Visus\Cuid2\Cuid2;
echo json_encode(queue_application_deployment(
  application: $app, deployment_uuid: $uuid, force_rebuild: false, is_api: true)).PHP_EOL;
echo $uuid.PHP_EOL;
PHP

Watch it: SELECT id, status, created_at FROM application_deployment_queues WHERE application_id = '15' ORDER BY created_at DESC LIMIT 3; (note: application_id is the numeric id stored as text).

Source-bucket cutover (when the provider moves buckets)

If the provider moves the INC feed to a new bucket (as happened ticketsisptickets, 2026-06-25):

  1. Inspect the new bucket (read-only) — confirm automations/inc/changes/ layout, timestamp range, schema parity. CRQ (automations/crq/) stays out of scope.
  2. Update env (UI or tinker): RUSTFS_ACCESS_KEY, RUSTFS_SECRET_KEY, TICKETS_BUCKET → the new bucket (endpoint usually unchanged).
  3. Reconcile the DB to current. The loader drains every changes/ file newer than the watermark (tickets.import_meta.metadata.source_max_key), oldest→newest, upserting on ticket_id:
    • If the watermark predates the new bucket's first file, a normal --from-bucket --apply drains the whole new stream — no reseed needed.
    • Otherwise use --reseed (ignores the watermark, drains all changes/ once): python import_tickets.py --from-bucket --reseed --apply (see README "Bucket cutover"). The new stream's periodic full-state re-emissions make this converge even across the cutover gap. Idempotent upserts + never-delete make it non-destructive.
    • For a one-off, you can run it in the live container with the new creds inlined: docker exec -e TICKETS_BUCKET=… -e RUSTFS_ACCESS_KEY=… -e RUSTFS_SECRET_KEY=… <container> sh -c "cd /app && python import_tickets.py --from-bucket --apply".
  4. Re-geocode new clusters/locations: --geocode-clusters --apply then --geocode-locations --apply (existing gazetteer persists; only new keys are looked up).
  5. Redeploy so the Scheduled Task's container picks up the new env (push main → webhook, or manual deploy). Old bucket is left untouched for rollback.

Verification

DB=$(docker ps --filter name=timescale_db --format "{{.Names}}" | head -1)
docker exec -i "$DB" psql -U postgres -d tracksolid_db <<'SQL'
-- watermark + freshness
SELECT export_type, records_ingested, ingested_at, metadata->>'source_max_key'
FROM tickets.import_meta WHERE dataset='inc';
-- counts
SELECT count(*) total_inc,
       count(*) FILTER (WHERE (raw->>'is_actionable')::boolean) AS open
FROM tickets.inc;
-- map payload sanity
SELECT reporting.fn_tickets_for_map() -> 'summary' ->> 'ticket_count';
SQL
  • New bucket changes/ empties as files move to automations/inc/processed/.
  • A plain --from-bucket --apply reports "nothing new" until the next change file lands.
  • FleetOps Tickets map freshness reflects the new ingested_at.

Rollback

  • Bucket: revert the three env vars to the old bucket + creds and redeploy. The old bucket and its processed/ history are untouched; upserts are idempotent and rows are never deleted, so re-running is safe.
  • Cron: UPDATE scheduled_tasks SET frequency = <old> WHERE name='inc_tickets';