From e53de59a42d14d29b5749e5f52a68eace5957368 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Tue, 21 Apr 2026 15:25:17 +0200 Subject: [PATCH] docs: multi-customer deployment spec + implementation plan - Spec: pure self-deploy model with per-customer GCP project - Public upstream repo with TF module; private template + per-customer repos - Branch-aware dev VMs via dev_instances list - Caddy TLS, Secret Manager for tokens, SA JSON key for CI (WIF follow-up) - 6-phase implementation plan with bite-sized tasks --- .../2026-04-21-multi-customer-deployment.md | 2137 +++++++++++++++++ ...26-04-21-multi-customer-deployment-spec.md | 442 ++++ 2 files changed, 2579 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-multi-customer-deployment.md create mode 100644 docs/superpowers/specs/2026-04-21-multi-customer-deployment-spec.md diff --git a/docs/superpowers/plans/2026-04-21-multi-customer-deployment.md b/docs/superpowers/plans/2026-04-21-multi-customer-deployment.md new file mode 100644 index 0000000..a14df06 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-multi-customer-deployment.md @@ -0,0 +1,2137 @@ +# Multi-Customer Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Přejít z dnešního "prod běží z osobního forku padak/tmp_oss" na production-grade multi-customer setup podle spec `docs/superpowers/specs/2026-04-21-multi-customer-deployment-spec.md`. + +**Architecture:** Public upstream (`keboola/agnes-the-ai-analyst`) s TF modulem + public image na GHCR. Privátní template repo (`keboola/agnes-infra-template`) jako skeleton. Per-customer privátní repo (`keboola/agnes-infra-keboola` pro Keboola-as-customer, `{org}/agnes-infra` pro další) s Terraform + GitHub Actions + SA JSON key. Každý zákazník má vlastní GCP projekt, vlastní Secret Manager, vlastní prod/dev VMs. Watchtower na VMs polluje GHCR pro auto-deploy. Branch-aware dev VMs přes pole `dev_instances` v tfvars. + +**Tech Stack:** Terraform (google provider ~5.0), Docker Compose, Caddy (TLS), Watchtower, GHCR, Google Cloud (Compute Engine, Secret Manager, Cloud Storage, IAM), GitHub Actions, Argon2 (passwords), DuckDB. + +--- + +## Závislosti mezi fázemi + +``` +Fáze 1 (MVP) ──────────────────────────┐ + │ │ + ▼ ▼ +Fáze 2 (TF modul + PD + rebuild) Fáze 0 (Předpoklady) + │ + ├─────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ +Fáze 3 Fáze 4 Fáze 5 Fáze 6 +(TLS) (Watchtower) (CI/CD) (Template) + │ │ │ │ + └─────────┴──────────┴──────────┘ + ▼ + Hotovo +``` + +Fáze 0 a 1 jsou sériové. Po Fázi 2 mohou 3/4/5 běžet paralelně. Fáze 6 používá výstupy 3/4/5. + +--- + +## Fáze 0 — Předpoklady (manuální, mimo kód) + +Tyto kroky vyžadují externí akce (oprávnění, Keboola UI). Musí být hotové před Fází 1. + +### Task 0.1: Ověřit přístupová práva + +- [ ] **Step 1: Ověřit, že máš `iam.serviceAccountAdmin` na kids-ai-data-analysis** + +```bash +gcloud projects get-iam-policy kids-ai-data-analysis --format=json \ + | python3 -c "import json, sys; d=json.load(sys.stdin); \ + me='zdenek.srotyr@keboola.com'; \ + roles=[b['role'] for b in d['bindings'] if any(me in m for m in b.get('members', []))]; \ + print('\n'.join(roles) if roles else 'NO DIRECT ROLES — check org-level or ask Petr (owner)')" +``` + +Expected: seznam rolí, nebo poznámka "NO DIRECT ROLES". + +- [ ] **Step 2: Pokud chybí SA admin práva, požádat Petra o dočasný `roles/iam.serviceAccountAdmin` + `roles/resourcemanager.projectIamAdmin`** + +Poslat mu odkaz na tuhle dokumentaci: https://cloud.google.com/iam/docs/understanding-roles#iam-roles + +Napsat Petrovi ve Slacku / emailu: "Potřebuji dočasně roli `iam.serviceAccountAdmin` a `resourcemanager.projectIamAdmin` na projektu `kids-ai-data-analysis` pro vytvoření Agnes deploy SA. Zrušíme, jakmile bude hotovo." + +- [ ] **Step 3: Ověřit, že image `ghcr.io/keboola/agnes-the-ai-analyst` je public** + +```bash +gh api /orgs/keboola/packages/container/agnes-the-ai-analyst --jq '.visibility' 2>&1 +``` + +Expected: `"public"`. Pokud `"private"`, změnit přes GitHub UI: Keboola org → Packages → agnes-the-ai-analyst → Package settings → Change visibility → Public. + +### Task 0.2: Backup stávajících dat (safety net před Fází 2) + +- [ ] **Step 1: Snapshot boot disku prod VM (obsahuje /data)** + +```bash +gcloud compute disks snapshot data-analyst \ + --zone=europe-west1-b \ + --snapshot-names=data-analyst-pre-migration-$(date +%Y%m%d) \ + --project=kids-ai-data-analysis +``` + +Expected: `Created snapshot data-analyst-pre-migration-YYYYMMDD`. + +- [ ] **Step 2: Ověřit snapshot** + +```bash +gcloud compute snapshots list --project=kids-ai-data-analysis \ + --filter="name~pre-migration" --format="table(name, status, diskSizeGb, creationTimestamp)" +``` + +Expected: STATUS = READY, 30 GB. + +--- + +## Fáze 1 — MVP: Odstřihnout od osobního forku, přejít na :stable image + +**Goal fáze:** Prod VM `data-analyst` pulluje image z GHCR, nikoliv git pull z `ZdenekSrotyr/tmp_oss`. Tokeny jsou v Secret Manageru. Přepnutí je reverzibilní. + +### Task 1.1: Přidat per-branch image tagging do release.yml + +**Files:** +- Modify: `.github/workflows/release.yml:47-95` + +- [ ] **Step 1: Number current state of meta step** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +grep -n "branch_slug\|feature_tag\|SLUG" .github/workflows/release.yml 2>&1 | head -5 +``` + +Expected: žádné výsledky — pattern neexistuje, přidáme ho. + +- [ ] **Step 2: Otevřít `.github/workflows/release.yml` a najít `Claim version tag` step** + +Sekce má `id: meta`. Za řádkem `echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"` (~ř. 90) přidat: + +```yaml + # Per-branch slug for dev images (only on feature branches) + if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then + BRANCH_NAME="${GITHUB_REF#refs/heads/}" + BRANCH_SLUG=$(echo "$BRANCH_NAME" | sed 's|^feature/||' | sed 's|[^a-zA-Z0-9-]|-|g' | tr '[:upper:]' '[:lower:]' | cut -c1-50) + echo "branch_slug=${BRANCH_SLUG}" >> "$GITHUB_OUTPUT" + echo "Branch slug: ${BRANCH_SLUG}" + fi +``` + +- [ ] **Step 3: V `Build and push` stepu přidat branch-slug tag** + +Najít `tags: |` blok (~ř. 110), nahradit za: + +```yaml + tags: | + ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.channel }} + ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.versioned_tag }} + ghcr.io/${{ github.repository }}:sha-${{ steps.meta.outputs.short_sha }} + ${{ steps.meta.outputs.channel == 'dev' && format('ghcr.io/{0}:dev-{1}', github.repository, steps.meta.outputs.branch_slug) || '' }} +``` + +Poslední řádek přidá `:dev-` jen při pushech na feature branch. + +- [ ] **Step 4: Syntax check workflow** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +gh workflow view release.yml 2>&1 | head -10 +``` + +Expected: workflow info, žádné "Parse error". + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add per-branch image tag :dev- for branch-aware dev deploys" +``` + +### Task 1.2: Vytvořit GCP deploy service account + +**Files:** +- Create: `scripts/bootstrap-gcp.sh` + +- [ ] **Step 1: Vytvořit bootstrap skript** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +mkdir -p scripts +``` + +Write `scripts/bootstrap-gcp.sh`: + +```bash +#!/usr/bin/env bash +# Bootstrap GCP projekt pro Agnes deployment. +# Jednorázové, idempotentní. Výstup = výpis secretů pro GitHub Actions. +# +# Usage: bootstrap-gcp.sh [SA_NAME] +# Pokud SA existuje, skript vygeneruje nový klíč a skončí. +set -euo pipefail + +PROJECT_ID="${1:?Usage: $0 [SA_NAME=agnes-deploy]}" +SA_NAME="${2:-agnes-deploy}" +SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" + +echo "=== Bootstrap GCP projekt: ${PROJECT_ID} ===" +gcloud config set project "${PROJECT_ID}" 1>/dev/null + +echo "=== Enable APIs ===" +gcloud services enable \ + compute.googleapis.com \ + iam.googleapis.com \ + iamcredentials.googleapis.com \ + secretmanager.googleapis.com \ + cloudresourcemanager.googleapis.com \ + storage.googleapis.com \ + --project="${PROJECT_ID}" + +echo "=== Create deploy service account (if not exists) ===" +if ! gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" 2>/dev/null; then + gcloud iam service-accounts create "${SA_NAME}" \ + --display-name="Agnes Terraform deploy" \ + --project="${PROJECT_ID}" +fi + +echo "=== Grant roles ===" +for role in \ + compute.instanceAdmin.v1 \ + compute.securityAdmin \ + compute.networkAdmin \ + iam.serviceAccountUser \ + secretmanager.admin \ + storage.admin; do + gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="roles/${role}" \ + --condition=None \ + --quiet 1>/dev/null +done + +echo "=== Create tfstate bucket (if not exists) ===" +BUCKET="agnes-${PROJECT_ID}-tfstate" +if ! gsutil ls -b "gs://${BUCKET}" 2>/dev/null; then + gsutil mb -p "${PROJECT_ID}" -l europe-west1 -b on "gs://${BUCKET}" + gsutil versioning set on "gs://${BUCKET}" +fi + +echo "=== Generate SA key ===" +KEY_FILE="./${SA_NAME}-${PROJECT_ID}-key.json" +gcloud iam service-accounts keys create "${KEY_FILE}" \ + --iam-account="${SA_EMAIL}" \ + --project="${PROJECT_ID}" + +echo "" +echo "=== HOTOVO ===" +echo "" +echo "SA email: ${SA_EMAIL}" +echo "TF state bucket: gs://${BUCKET}" +echo "SA key file: ${KEY_FILE}" +echo "" +echo "DALŠÍ KROKY:" +echo "1. Pushni klíč do GitHub secretu privátního infra repa:" +echo " gh secret set GCP_SA_KEY --repo / < ${KEY_FILE}" +echo "2. POTOM smaž klíč z lokálu:" +echo " rm ${KEY_FILE}" +echo "" +``` + +- [ ] **Step 2: Udělat skript executable** + +```bash +chmod +x scripts/bootstrap-gcp.sh +``` + +- [ ] **Step 3: Spustit skript na kids-ai-data-analysis** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +./scripts/bootstrap-gcp.sh kids-ai-data-analysis +``` + +Expected: na konci výpis "HOTOVO" + instrukce. + +Pokud selže na "Permission denied": viz Task 0.1 step 2 (požádat Petra). + +- [ ] **Step 4: Ověřit SA a bucket** + +```bash +gcloud iam service-accounts list --project=kids-ai-data-analysis --filter="email~agnes-deploy" --format="value(email)" +gsutil ls -b gs://agnes-kids-ai-data-analysis-tfstate +``` + +Expected: SA email + bucket URL. + +- [ ] **Step 5: Commit bootstrap skript** + +```bash +git add scripts/bootstrap-gcp.sh +git commit -m "infra: add bootstrap-gcp.sh for per-customer GCP setup" +``` + +### Task 1.3: Nastavit tajemství v Secret Manageru + +- [ ] **Step 1: Rotovat Keboola Storage token v Keboola UI** + +Přihlásit se do Keboola UI (https://connection.us-east4.gcp.keboola.com/), sekce Settings → Master Tokens → vygenerovat nový token. + +**Starý token zachovat aktivní, dokud nebude nový nasazený.** + +- [ ] **Step 2: Uložit nový token do Secret Manageru** + +```bash +read -s NEW_TOKEN +echo -n "$NEW_TOKEN" | gcloud secrets create keboola-storage-token \ + --data-file=- \ + --replication-policy=automatic \ + --project=kids-ai-data-analysis +unset NEW_TOKEN +``` + +Expected: `Created secret [keboola-storage-token]`. + +- [ ] **Step 3: Vygenerovat a uložit JWT secret** + +```bash +openssl rand -hex 32 | gcloud secrets create jwt-secret-key \ + --data-file=- \ + --replication-policy=automatic \ + --project=kids-ai-data-analysis +``` + +Expected: `Created secret [jwt-secret-key]`. + +- [ ] **Step 4: Ověřit secrets** + +```bash +gcloud secrets list --project=kids-ai-data-analysis --format="table(name, createTime)" +``` + +Expected: dva secrets — keboola-storage-token, jwt-secret-key. + +- [ ] **Step 5: Přiřadit read access deploy SA** + +```bash +for secret in keboola-storage-token jwt-secret-key; do + gcloud secrets add-iam-policy-binding "$secret" \ + --member="serviceAccount:agnes-deploy@kids-ai-data-analysis.iam.gserviceaccount.com" \ + --role=roles/secretmanager.secretAccessor \ + --project=kids-ai-data-analysis +done +``` + +Expected: `Updated IAM policy` × 2. + +### Task 1.4: Vytvořit skript, který na VM natáhne secrets ze Secret Manageru do .env + +**Files:** +- Create: `scripts/fetch-env-from-secrets.sh` + +- [ ] **Step 1: Napsat skript** + +Write `scripts/fetch-env-from-secrets.sh`: + +```bash +#!/usr/bin/env bash +# Stáhne secrets z GCP Secret Manageru a vytvoří .env pro Agnes. +# Spouští se jednorázově na VM během boot / deploy. +# +# Vyžaduje: +# - gcloud CLI (už nainstalované na GCE default image) +# - VM SA má roli roles/secretmanager.secretAccessor +set -euo pipefail + +APP_DIR="${APP_DIR:-/home/deploy/app}" +ENV_FILE="${APP_DIR}/.env" + +echo "Fetching secrets..." + +KEBOOLA_TOKEN=$(gcloud secrets versions access latest --secret=keboola-storage-token 2>&1) +JWT_KEY=$(gcloud secrets versions access latest --secret=jwt-secret-key 2>&1) + +# Non-secret config (může zůstat v metadatě/startup-scriptu) +DATA_SOURCE="${DATA_SOURCE:-keboola}" +KEBOOLA_STACK_URL="${KEBOOLA_STACK_URL:-https://connection.us-east4.gcp.keboola.com/}" +SEED_ADMIN_EMAIL="${SEED_ADMIN_EMAIL:-zdenek.srotyr@keboola.com}" +LOG_LEVEL="${LOG_LEVEL:-info}" +DATA_DIR="${DATA_DIR:-/data}" + +cat > "${ENV_FILE}" </dev/null || true + +echo "Wrote ${ENV_FILE} (chmod 600)" +``` + +- [ ] **Step 2: Chmod + commit** + +```bash +chmod +x scripts/fetch-env-from-secrets.sh +git add scripts/fetch-env-from-secrets.sh +git commit -m "infra: add fetch-env-from-secrets.sh for VM-side secret retrieval" +``` + +### Task 1.5: Připravit prod docker-compose pro GHCR image + +**Files:** +- Modify: `docker-compose.prod.yml` + +- [ ] **Step 1: Přečíst současný docker-compose.prod.yml** + +```bash +cat "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss/docker-compose.prod.yml" +``` + +Zaznamenat si strukturu (services, volumes). + +- [ ] **Step 2: Ověřit, že prod overlay používá `image:` místo `build:`** + +```bash +grep -E "^\s*(image|build):" docker-compose.prod.yml +``` + +Expected: řádek `image: ghcr.io/keboola/agnes-the-ai-analyst:${AGNES_TAG:-stable}` (nebo podobně). Pokud chybí, přidat do `services.app`: + +```yaml +services: + app: + image: ghcr.io/keboola/agnes-the-ai-analyst:${AGNES_TAG:-stable} + build: !reset null # vypnout lokální build +``` + +A pro scheduler: + +```yaml + scheduler: + image: ghcr.io/keboola/agnes-the-ai-analyst:${AGNES_TAG:-stable} + build: !reset null +``` + +- [ ] **Step 3: Commit změn (pokud nějaké)** + +```bash +git status docker-compose.prod.yml +# Pokud modified: +git add docker-compose.prod.yml +git commit -m "infra: prod compose pulls from GHCR via AGNES_TAG env (default :stable)" +``` + +### Task 1.6: Deploy MVP na prod VM data-analyst + +**Tohle je destruktivní akce na prod. Předtím Task 0.2 (snapshot).** + +- [ ] **Step 1: SSH na prod VM a zastavit kontejnery** + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo -u deploy bash -c 'cd /home/deploy/app && docker compose down'" +``` + +Expected: `Container app-app-1 Stopped`, `Container app-scheduler-1 Stopped`. + +- [ ] **Step 2: Nastavit VM SA na deploy VM (jednorázově)** + +```bash +# Ověřit aktuální SA +gcloud compute instances describe data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis \ + --format="value(serviceAccounts[0].email)" +``` + +Pokud výstup `327445566538-compute@developer.gserviceaccount.com` (default SA), je to OK pro MVP — má cloud-platform scope a může číst secrets. Ve Fázi 4 (hardening) to přepneme na dedikovaný SA. + +Přidat mu explicitně secretmanager.secretAccessor (idempotentní): + +```bash +gcloud projects add-iam-policy-binding kids-ai-data-analysis \ + --member="serviceAccount:327445566538-compute@developer.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" \ + --condition=None +``` + +- [ ] **Step 3: Uploadnout fetch-env skript na VM** + +```bash +gcloud compute scp \ + "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss/scripts/fetch-env-from-secrets.sh" \ + data-analyst:/tmp/fetch-env.sh \ + --zone=europe-west1-b --project=kids-ai-data-analysis +``` + +- [ ] **Step 4: Spustit fetch-env skript pod uživatelem deploy** + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo install -m 755 -o deploy -g deploy /tmp/fetch-env.sh /home/deploy/app/fetch-env.sh && sudo -u deploy bash -c 'cd /home/deploy/app && ./fetch-env.sh'" +``` + +Expected: `Wrote /home/deploy/app/.env (chmod 600)`. + +- [ ] **Step 5: Zkontrolovat .env na VM (bez vypisování hodnot)** + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo -u deploy bash -c 'ls -la /home/deploy/app/.env && wc -l /home/deploy/app/.env && cut -d= -f1 /home/deploy/app/.env'" +``` + +Expected: soubor 600 mode, 7 řádků, klíče: JWT_SECRET_KEY, DATA_DIR, DATA_SOURCE, KEBOOLA_STORAGE_TOKEN, KEBOOLA_STACK_URL, SEED_ADMIN_EMAIL, LOG_LEVEL. + +- [ ] **Step 6: Aktualizovat docker-compose.yml konfiguraci na VM na pulling z GHCR** + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo -u deploy bash -c 'cd /home/deploy/app && git fetch origin feature/v2-fastapi-duckdb-docker-cli && git reset --hard origin/feature/v2-fastapi-duckdb-docker-cli'" +``` + +**Pozor:** VM má starý remote `ZdenekSrotyr/tmp_oss`. Tohle tedy nebude fungovat, pokud se ten repo smazal. Alternativa: nahradit origin remote za keboola/agnes-the-ai-analyst: + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo -u deploy bash -c 'cd /home/deploy/app && git remote set-url origin https://github.com/keboola/agnes-the-ai-analyst.git && git fetch origin main && git reset --hard origin/main'" +``` + +Expected: HEAD is now at `` ``. + +- [ ] **Step 7: Pullnout image z GHCR a nastartovat s novým override** + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo -u deploy bash -c 'cd /home/deploy/app && export AGNES_TAG=stable && docker compose -f docker-compose.yml -f docker-compose.prod.yml pull && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d'" +``` + +Expected: `Container app-app-1 Started`, `Container app-scheduler-1 Started`. + +- [ ] **Step 8: Ověřit běh** + +```bash +# Počkat 30 sekund +sleep 30 +curl -s --max-time 10 http://35.195.96.98:8000/api/health | python3 -m json.tool | head -10 +``` + +Expected: `"status": "healthy"` nebo `"degraded"` (stale tables jsou OK). Ne `connection refused`. + +- [ ] **Step 9: Ověřit, že app používá nový image** + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo docker inspect app-app-1 --format '{{.Config.Image}}'" +``` + +Expected: `ghcr.io/keboola/agnes-the-ai-analyst:stable` (ne `app-app`). + +- [ ] **Step 10: Ověřit login** + +```bash +curl -sS --max-time 5 -X POST http://35.195.96.98:8000/auth/password/login \ + -H "Content-Type: application/json" \ + -d '{"email":"zdenek.srotyr@keboola.com","password":"1234"}' 2>&1 | python3 -c "import sys,json; d=json.load(sys.stdin); print('OK — role:', d.get('role'))" +``` + +Expected: `OK — role: admin`. + +- [ ] **Step 11: Zapsat poznámku o nové .env strategii do dokumentace** + +Add to `docs/DEPLOYMENT.md` (if not present) section "Production environment": + +```markdown +## Production .env strategy + +Secrets (KEBOOLA_STORAGE_TOKEN, JWT_SECRET_KEY) are fetched from GCP Secret Manager +by `scripts/fetch-env-from-secrets.sh` during VM boot. Non-secret config (STACK_URL, +SEED_ADMIN_EMAIL, LOG_LEVEL) is passed via env vars in the startup script. + +To rotate a secret: +1. Add a new version via `gcloud secrets versions add ...` +2. SSH to VM and re-run `./fetch-env.sh` +3. Restart: `docker compose up -d --force-recreate app` +``` + +- [ ] **Step 12: Commit dokumentace** + +```bash +git add docs/DEPLOYMENT.md +git commit -m "docs: document Secret Manager-backed .env for production" +``` + +### Task 1.7: Zopakovat MVP deploy na dev VM + +- [ ] **Step 1: Opakovat Task 1.6 steps 1-10 proti data-analyst-dev VM** + +Stejné příkazy, jen zaměnit `data-analyst` za `data-analyst-dev` a IP `35.195.96.98` za `34.62.223.189`. + +- [ ] **Step 2: Verify** + +```bash +curl -s --max-time 10 http://34.62.223.189:8000/api/health | python3 -m json.tool | head -3 +``` + +Expected: valid JSON s `"status"`. + +### Task 1.8: Smazat osobní fork + +- [ ] **Step 1: Odstranit deploy key z `ZdenekSrotyr/tmp_oss` (pokud existuje)** + +```bash +gh api repos/ZdenekSrotyr/tmp_oss/keys 2>&1 | python3 -m json.tool +``` + +Pokud něco vrací, smazat: `gh api -X DELETE repos/ZdenekSrotyr/tmp_oss/keys/`. + +- [ ] **Step 2: Smazat repo** + +```bash +gh repo delete ZdenekSrotyr/tmp_oss --yes +``` + +Expected: `✓ Deleted repository ZdenekSrotyr/tmp_oss`. + +- [ ] **Step 3: Ověřit, že je fuč** + +```bash +gh api repos/ZdenekSrotyr/tmp_oss 2>&1 | head -2 +``` + +Expected: `Not Found (HTTP 404)`. + +### Task 1.9: Invalidovat starý Keboola token + +- [ ] **Step 1: V Keboola UI zrušit starý master token** + +(Ruční krok v Keboola UI. Nový token už je v Secret Manageru z Task 1.3.) + +Ověřit, že nová verze tokenu funguje: + +```bash +curl -s --max-time 10 http://35.195.96.98:8000/api/sync/status 2>&1 | python3 -m json.tool | head -20 +``` + +Expected: nějaký valid JSON. Pokud `401 Unauthorized` nebo `Invalid token`, app ještě má cached starý token — restartovat: + +```bash +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo -u deploy bash -c 'cd /home/deploy/app && docker compose restart app'" +``` + +### Task 1.10: Checkpoint — Fáze 1 hotová + +- [ ] **Step 1: Přepnout heslo z `1234` na něco silného** + +Přes UI nebo: + +```bash +read -s NEW_PASSWORD +TOKEN=$(curl -sS -X POST http://35.195.96.98:8000/auth/password/login \ + -H "Content-Type: application/json" \ + -d '{"email":"zdenek.srotyr@keboola.com","password":"1234"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") +# [Volba: použít admin endpoint pro změnu hesla, pokud existuje — jinak přes UI] +unset NEW_PASSWORD TOKEN +``` + +- [ ] **Step 2: Ověřit stav** + +Zkontrolovat checklist: +- [ ] Prod VM `data-analyst` běží z `ghcr.io/...:stable` +- [ ] Dev VM `data-analyst-dev` běží z `ghcr.io/...:stable` +- [ ] Secrets v GCP Secret Manageru +- [ ] Heslo admin usera není `1234` +- [ ] `ZdenekSrotyr/tmp_oss` je smazaný +- [ ] Starý Keboola token je invalidován + +--- + +## Fáze 2 — TF modul + persistent disk + F1 rebuild + +**Goal fáze:** Keboola instance běží na VMs, kterou spravuje Terraform modul z `infra/modules/customer-instance/`. Data jsou na samostatném persistent disku. TF state v GCS bucketu. + +### Task 2.1: Refactor `infra/main.tf` na modulární strukturu + +**Files:** +- Create: `infra/modules/customer-instance/main.tf` +- Create: `infra/modules/customer-instance/variables.tf` +- Create: `infra/modules/customer-instance/outputs.tf` +- Create: `infra/modules/customer-instance/startup-script.sh` +- Delete: `infra/main.tf` (old monolith) +- Keep (upraveno): `infra/variables.tf`, `infra/outputs.tf`, `infra/terraform.tfvars.example` +- Create: `infra/examples/minimal/main.tf` (usage example) + +- [ ] **Step 1: Vytvořit adresářovou strukturu** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +mkdir -p infra/modules/customer-instance +mkdir -p infra/examples/minimal +``` + +- [ ] **Step 2: Napsat `infra/modules/customer-instance/variables.tf`** + +Write: + +```hcl +variable "gcp_project_id" { + description = "GCP project ID kde bude instance nasazená" + type = string +} + +variable "region" { + description = "GCP region" + type = string + default = "europe-west1" +} + +variable "zone" { + description = "GCP zone" + type = string + default = "europe-west1-b" +} + +variable "customer_name" { + description = "Krátké identifikátor zákazníka (např. keboola, grpn). Použije se v prefixu resourců." + type = string + validation { + condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.customer_name)) + error_message = "customer_name musí být lowercase, začínat písmenem, 2-21 znaků." + } +} + +variable "prod_instance" { + description = "Prod VM konfigurace" + type = object({ + name = string + machine_type = optional(string, "e2-small") + disk_size_gb = optional(number, 30) + data_disk_gb = optional(number, 50) + image_tag = optional(string, "stable") + upgrade_mode = optional(string, "auto") + tls_mode = optional(string, "caddy") + domain = optional(string, "") + }) +} + +variable "dev_instances" { + description = "Seznam dev VMs. Prázdné pole = žádné dev VMs." + type = list(object({ + name = string + machine_type = optional(string, "e2-small") + image_tag = optional(string, "dev") + })) + default = [] +} + +variable "seed_admin_email" { + description = "Email prvního admin usera" + type = string +} + +variable "data_source" { + description = "Typ data source — keboola | bigquery | csv" + type = string + default = "keboola" +} + +variable "keboola_stack_url" { + description = "Keboola Stack URL (pokud data_source = keboola)" + type = string + default = "" +} + +variable "image_repo" { + description = "Docker image repo" + type = string + default = "ghcr.io/keboola/agnes-the-ai-analyst" +} +``` + +- [ ] **Step 3: Napsat `infra/modules/customer-instance/main.tf`** + +Write: + +```hcl +terraform { + required_version = ">= 1.5" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +locals { + all_instances = concat( + [merge(var.prod_instance, { role = "prod" })], + [for d in var.dev_instances : merge(d, { + role = "dev" + disk_size_gb = 30 + data_disk_gb = 20 + upgrade_mode = "auto" + tls_mode = "caddy" + domain = "" + })] + ) +} + +# --- Secrets --- + +resource "google_secret_manager_secret" "jwt" { + secret_id = "agnes-${var.customer_name}-jwt-secret" + project = var.gcp_project_id + replication { auto {} } +} + +resource "random_password" "jwt" { + length = 48 + special = false +} + +resource "google_secret_manager_secret_version" "jwt" { + secret = google_secret_manager_secret.jwt.id + secret_data = random_password.jwt.result +} + +# Keboola token — manuálně vytvořený secret (tenhle TF ho jen referenční). +data "google_secret_manager_secret_version" "keboola_token" { + count = var.data_source == "keboola" ? 1 : 0 + secret = "keboola-storage-token" + project = var.gcp_project_id +} + +# --- VM service account (dedikovaný, bez cloud-platform scope) --- + +resource "google_service_account" "vm" { + account_id = "agnes-${var.customer_name}-vm" + display_name = "Agnes VM runtime SA (${var.customer_name})" + project = var.gcp_project_id +} + +resource "google_project_iam_member" "vm_secrets" { + project = var.gcp_project_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.vm.email}" +} + +# --- Network --- + +resource "google_compute_firewall" "web" { + name = "agnes-${var.customer_name}-allow-web" + project = var.gcp_project_id + network = "default" + + allow { + protocol = "tcp" + ports = ["22", "80", "443", "8000"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["agnes-${var.customer_name}"] +} + +# --- Persistent data disks + VMs (prod + dev) --- + +resource "google_compute_disk" "data" { + for_each = { for inst in local.all_instances : inst.name => inst } + + name = "${each.value.name}-data" + project = var.gcp_project_id + zone = var.zone + size = each.value.data_disk_gb + type = "pd-ssd" +} + +resource "google_compute_address" "ip" { + for_each = { for inst in local.all_instances : inst.name => inst } + + name = "${each.value.name}-ip" + project = var.gcp_project_id + region = var.region +} + +resource "google_compute_instance" "vm" { + for_each = { for inst in local.all_instances : inst.name => inst } + + name = each.value.name + project = var.gcp_project_id + machine_type = each.value.machine_type + zone = var.zone + tags = ["agnes-${var.customer_name}"] + + boot_disk { + initialize_params { + image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64" + size = each.value.disk_size_gb + type = "pd-ssd" + } + } + + attached_disk { + source = google_compute_disk.data[each.key].self_link + device_name = "data" + } + + network_interface { + network = "default" + access_config { + nat_ip = google_compute_address.ip[each.key].address + } + } + + metadata = { + enable-oslogin = "TRUE" + } + + metadata_startup_script = templatefile("${path.module}/startup-script.sh", { + customer_name = var.customer_name + image_repo = var.image_repo + image_tag = each.value.image_tag + upgrade_mode = each.value.upgrade_mode + tls_mode = each.value.tls_mode + domain = each.value.domain + data_source = var.data_source + keboola_stack_url = var.keboola_stack_url + seed_admin_email = var.seed_admin_email + role = each.value.role + }) + + service_account { + email = google_service_account.vm.email + scopes = ["cloud-platform"] + } + + labels = { + app = "agnes" + customer = var.customer_name + role = each.value.role + managed = "terraform" + } + + lifecycle { + ignore_changes = [metadata_startup_script] + } +} +``` + +- [ ] **Step 4: Napsat `infra/modules/customer-instance/startup-script.sh`** + +Write: + +```bash +#!/bin/bash +# Agnes VM startup script. +# Idempotentní — spustí se při každém boot. +set -euo pipefail +exec > /var/log/agnes-startup.log 2>&1 + +CUSTOMER_NAME="${customer_name}" +IMAGE_REPO="${image_repo}" +IMAGE_TAG="${image_tag}" +UPGRADE_MODE="${upgrade_mode}" +TLS_MODE="${tls_mode}" +DOMAIN="${domain}" +DATA_SOURCE="${data_source}" +KEBOOLA_STACK_URL="${keboola_stack_url}" +SEED_ADMIN_EMAIL="${seed_admin_email}" +ROLE="${role}" + +echo "=== [Agnes $CUSTOMER_NAME $ROLE] Startup ===" + +# --- 1. Docker (install if missing) --- +if ! command -v docker &>/dev/null; then + curl -fsSL https://get.docker.com | sh +fi +if ! docker compose version &>/dev/null; then + apt-get update && apt-get install -y docker-compose-plugin +fi + +# --- 2. Persistent disk mount --- +DATA_DEV="/dev/disk/by-id/google-data" +DATA_MNT="/data" +if [ -b "$DATA_DEV" ]; then + if ! blkid "$DATA_DEV" | grep -q ext4; then + mkfs.ext4 -F "$DATA_DEV" + fi + mkdir -p "$DATA_MNT" + mountpoint -q "$DATA_MNT" || mount -o discard,defaults "$DATA_DEV" "$DATA_MNT" + grep -q "$DATA_DEV" /etc/fstab || echo "$DATA_DEV $DATA_MNT ext4 discard,defaults,nofail 0 2" >> /etc/fstab + mkdir -p "$DATA_MNT/state" "$DATA_MNT/analytics" "$DATA_MNT/extracts" +fi + +# --- 3. App directory (pro docker-compose.yml) --- +APP_DIR="/opt/agnes" +mkdir -p "$APP_DIR" +cd "$APP_DIR" + +# Fetch minimal docker-compose — z public repa na jejich tagu +curl -fsSL "https://raw.githubusercontent.com/keboola/agnes-the-ai-analyst/main/docker-compose.yml" \ + -o docker-compose.yml +curl -fsSL "https://raw.githubusercontent.com/keboola/agnes-the-ai-analyst/main/docker-compose.prod.yml" \ + -o docker-compose.prod.yml + +# --- 4. Fetch secrets from Secret Manager --- +KEBOOLA_TOKEN="" +if [ "$DATA_SOURCE" = "keboola" ]; then + KEBOOLA_TOKEN=$(gcloud secrets versions access latest --secret=keboola-storage-token 2>/dev/null || echo "") +fi +JWT_KEY=$(gcloud secrets versions access latest --secret=agnes-$CUSTOMER_NAME-jwt-secret) + +cat > "$APP_DIR/.env" </dev/null || true +fi + +echo "=== [Agnes $CUSTOMER_NAME $ROLE] Startup complete ===" +docker compose ps +``` + +- [ ] **Step 5: Napsat `infra/modules/customer-instance/outputs.tf`** + +Write: + +```hcl +output "instance_ips" { + description = "Mapa { name → external IP }" + value = { for k, v in google_compute_address.ip : k => v.address } +} + +output "prod_ip" { + description = "External IP prod instance" + value = google_compute_address.ip[var.prod_instance.name].address +} + +output "vm_service_account" { + description = "Email VM SA (pro další IAM bindings, např. BigQuery)" + value = google_service_account.vm.email +} + +output "jwt_secret_name" { + description = "Plný název JWT secretu v Secret Manageru" + value = google_secret_manager_secret.jwt.name +} +``` + +- [ ] **Step 6: Smazat starý `infra/main.tf` a uložit si ho jako backup** + +```bash +mv infra/main.tf infra/main.tf.backup-pre-module +``` + +- [ ] **Step 7: Vytvořit `infra/examples/minimal/main.tf`** + +Write: + +```hcl +# Minimal example: single-VM Agnes deploy. +# Pro OSS self-hoster, co nechce ani persistent disk ani dev VM. +terraform { + required_version = ">= 1.5" + required_providers { + google = { source = "hashicorp/google", version = "~> 5.0" } + } +} + +provider "google" { + project = var.gcp_project_id + region = "europe-west1" +} + +variable "gcp_project_id" { + type = string +} + +module "agnes" { + source = "../../modules/customer-instance" + + gcp_project_id = var.gcp_project_id + customer_name = "self-hosted" + seed_admin_email = "admin@example.com" + + prod_instance = { + name = "agnes" + data_disk_gb = 30 + } + + dev_instances = [] + + data_source = "keboola" +} + +output "agnes_ip" { + value = module.agnes.prod_ip +} +``` + +- [ ] **Step 8: Smazat `infra/variables.tf`, `infra/outputs.tf`, `infra/terraform.tfvars.example` (už patří do modulu / examples)** + +```bash +# Backup si udělat +mv infra/variables.tf infra/variables.tf.backup-pre-module +mv infra/outputs.tf infra/outputs.tf.backup-pre-module +mv infra/terraform.tfvars.example infra/terraform.tfvars.example.backup-pre-module +``` + +- [ ] **Step 9: `terraform init` + `validate` v example** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss/infra/examples/minimal" +terraform init -backend=false +terraform validate +``` + +Expected: `Success! The configuration is valid.` + +- [ ] **Step 10: Commit** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +git add infra/modules/ infra/examples/ +git add -u infra/ # pro mv backupy +git commit -m "infra: extract customer-instance Terraform module; add minimal example" +``` + +### Task 2.2: Tag prvního release TF modulu + +- [ ] **Step 1: Otevřít PR z feature branch do main** + +```bash +git push origin feature/v2-fastapi-duckdb-docker-cli +gh pr create --title "feat: multi-customer deployment (Fáze 1-2)" \ + --body "Implements Phases 1-2 of docs/superpowers/plans/2026-04-21-multi-customer-deployment.md" +``` + +- [ ] **Step 2: Po mergi do main vytvořit tag `infra-v1.0.0`** + +```bash +git checkout main +git pull +git tag -a infra-v1.0.0 -m "Initial customer-instance module release" +git push origin infra-v1.0.0 +``` + +### Task 2.3: Založit privátní repo `keboola/agnes-infra-keboola` (manuálně) + +**Tohle je krok mimo tento repo. Plán jen popisuje.** + +- [ ] **Step 1: Vytvořit prázdný privátní repo** + +```bash +gh repo create keboola/agnes-infra-keboola --private --description "Agnes deployment — Keboola internal instance" +``` + +- [ ] **Step 2: Klonovat lokálně vedle tohohle repa** + +```bash +cd ~/Library/Mobile\ Documents/com\~apple\~CloudDocs/Sources/VsCode/component_factory/ +gh repo clone keboola/agnes-infra-keboola +cd agnes-infra-keboola +``` + +- [ ] **Step 3: Vytvořit strukturu** + +```bash +mkdir -p terraform .github/workflows config + +# Terraform root +cat > terraform/main.tf <<'EOF' +terraform { + required_version = ">= 1.5" + required_providers { + google = { source = "hashicorp/google", version = "~> 5.0" } + } + backend "gcs" { + bucket = "agnes-kids-ai-data-analysis-tfstate" + prefix = "keboola" + } +} + +provider "google" { + project = var.gcp_project_id + region = var.region + zone = var.zone +} + +module "agnes" { + source = "github.com/keboola/agnes-the-ai-analyst//infra/modules/customer-instance?ref=infra-v1.0.0" + + gcp_project_id = var.gcp_project_id + region = var.region + zone = var.zone + customer_name = "keboola" + seed_admin_email = var.seed_admin_email + data_source = "keboola" + keboola_stack_url = var.keboola_stack_url + prod_instance = var.prod_instance + dev_instances = var.dev_instances +} + +output "prod_ip" { value = module.agnes.prod_ip } +output "instance_ips" { value = module.agnes.instance_ips } +EOF + +cat > terraform/variables.tf <<'EOF' +variable "gcp_project_id" { type = string } +variable "region" { type = string, default = "europe-west1" } +variable "zone" { type = string, default = "europe-west1-b" } +variable "seed_admin_email" { type = string } +variable "keboola_stack_url" { type = string } +variable "prod_instance" { type = any } +variable "dev_instances" { type = any, default = [] } +EOF + +cat > terraform/terraform.tfvars.example <<'EOF' +gcp_project_id = "kids-ai-data-analysis" +seed_admin_email = "zdenek.srotyr@keboola.com" +keboola_stack_url = "https://connection.us-east4.gcp.keboola.com/" + +prod_instance = { + name = "agnes-prod" + machine_type = "e2-small" + data_disk_gb = 50 + image_tag = "stable" + upgrade_mode = "auto" + tls_mode = "caddy" + domain = "" +} + +dev_instances = [ + { name = "agnes-dev", image_tag = "dev" } +] +EOF + +cat > terraform/.gitignore <<'EOF' +terraform.tfvars +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +EOF + +cp terraform/terraform.tfvars.example terraform/terraform.tfvars +# Edit terraform.tfvars on real values if they differ +``` + +- [ ] **Step 4: Initial commit** + +```bash +git add . +git commit -m "initial: Keboola-as-customer Agnes deployment" +git push -u origin main +``` + +- [ ] **Step 5: Uploadnout GCP_SA_KEY jako GitHub secret** + +```bash +# Klíč vytvořený v Task 1.2 step 3 +gh secret set GCP_SA_KEY --repo keboola/agnes-infra-keboola \ + < ../tmp_oss/agnes-deploy-kids-ai-data-analysis-key.json +``` + +**Poznámka:** Pokud klíč ne už smazal, re-generate: `gcloud iam service-accounts keys create ...`. + +- [ ] **Step 6: První terraform init + plan (lokálně, abychom viděli diff)** + +```bash +cd terraform +export GOOGLE_APPLICATION_CREDENTIALS="../agnes-deploy-key.json" +terraform init +terraform plan +``` + +Expected: `Plan: N to add, 0 to change, 0 to destroy.` (N ~ 15-20 resources) + +Zkontrolovat plán: žádné `destroy` na existujících `data-analyst` / `data-analyst-dev` (to teprve poté, co bude nové nahoře). + +### Task 2.4: Migrace dat ze starých VMs na nové (bez downtime risku) + +**Strategy:** Zachovat staré VMs běžící. Terraform vytvoří **nové** VMs s jinými jmény (`agnes-prod`, `agnes-dev`). Data se zkopírují. Poté přepneme DNS/IP (nebo jen komunikujeme novou IP) a staré VMs smažeme. + +- [ ] **Step 1: Snapshot starého /data** + +Už máme z Task 0.2. Pokud je snapshot starší než 24 h, udělat nový: + +```bash +gcloud compute disks snapshot data-analyst \ + --zone=europe-west1-b \ + --snapshot-names=data-analyst-migration-$(date +%Y%m%d-%H%M) \ + --project=kids-ai-data-analysis +``` + +- [ ] **Step 2: Terraform apply — vytvoří nové VMs (`agnes-prod`, `agnes-dev`) vedle starých** + +```bash +cd ~/.../agnes-infra-keboola/terraform +terraform apply +# Type 'yes' to confirm +``` + +Expected: ~15-20 resources created, ~5 min. Outputs: `prod_ip`, `instance_ips`. + +- [ ] **Step 3: Zkopírovat data ze starého boot-disku na nový persistent disk** + +Nové VMs mají prázdný `/data`. Musíme do něj nakopírovat stav z `data-analyst` VM. + +Nejjednodušší cesta: `rsync` mezi VM přes SSH. + +```bash +# SSH na nové prod VM +NEW_PROD_IP=$(cd ~/.../agnes-infra-keboola/terraform && terraform output -raw prod_ip) + +# Zkopírovat SSH klíč na starou VM, aby mohla mít přístup na novou +# (nebo použít oslogin → další prerekvizita) + +# Alternativa: udělat z druhé strany — SSH na starou VM, rsync na novou +gcloud compute ssh data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo docker compose -f /home/deploy/app/docker-compose.yml -f /home/deploy/app/docker-compose.prod.yml down" + +# Rsync přes gcloud compute scp recursive (funguje jen z lokálu) +gcloud compute scp --recurse --zone=europe-west1-b --project=kids-ai-data-analysis \ + data-analyst:/home/deploy/app/data-volume/ \ + agnes-prod:/data/ + +# Spustit app na nové VM znovu +gcloud compute ssh agnes-prod --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo docker compose -f /opt/agnes/docker-compose.yml -f /opt/agnes/docker-compose.prod.yml restart" +``` + +**Alternativně (čistěji):** restore ze snapshotu přes `gcloud compute disks create --source-snapshot`, pak attach místo prázdného data disku. + +- [ ] **Step 4: Ověřit nový prod** + +```bash +NEW_PROD_IP=$(cd ~/.../agnes-infra-keboola/terraform && terraform output -raw prod_ip) +curl -s --max-time 10 "http://$NEW_PROD_IP:8000/api/health" | python3 -m json.tool | head -10 +``` + +Expected: healthy / degraded, tables visible. + +- [ ] **Step 5: Ověřit login na novém prod** + +```bash +curl -sS -X POST "http://$NEW_PROD_IP:8000/auth/password/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"zdenek.srotyr@keboola.com","password":""}' \ + | python3 -c "import sys,json;print('OK' if json.load(sys.stdin).get('role')=='admin' else 'FAIL')" +``` + +Expected: `OK` + +- [ ] **Step 6: Zopakovat pro dev VM (`agnes-dev`)** + +Stejné kroky 1-5. + +- [ ] **Step 7: Vypnout staré VMs (zatím NEmazat — jen stop)** + +```bash +gcloud compute instances stop data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis +gcloud compute instances stop data-analyst-dev --zone=europe-west1-b --project=kids-ai-data-analysis +``` + +- [ ] **Step 8: Ověřit, že nový prod běží minimálně 24 h bez problému** + +```bash +# Poznámka v kalendáři / Slacku: "check agnes-prod health in 24h" +curl -s "http://$NEW_PROD_IP:8000/api/health" | python3 -m json.tool +``` + +- [ ] **Step 9: Po 24h stability smazat staré VMs + jejich disky + statické IP** + +```bash +gcloud compute instances delete data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --quiet +gcloud compute instances delete data-analyst-dev --zone=europe-west1-b --project=kids-ai-data-analysis --quiet + +gcloud compute disks delete data-analyst --zone=europe-west1-b --project=kids-ai-data-analysis --quiet 2>&1 || true +gcloud compute disks delete data-analyst-dev --zone=europe-west1-b --project=kids-ai-data-analysis --quiet 2>&1 || true + +gcloud compute addresses delete data-analyst-ip --region=europe-west1 --project=kids-ai-data-analysis --quiet 2>&1 || true +``` + +- [ ] **Step 10: Checkpoint — Fáze 2 hotová** + +Checklist: +- [ ] Terraform modul v `infra/modules/customer-instance/` +- [ ] `keboola/agnes-infra-keboola` privátní repo existuje, `terraform apply` funguje +- [ ] Prod VM `agnes-prod` běží s persistent diskem +- [ ] Dev VM `agnes-dev` běží +- [ ] Data zmigrovaná, login funguje +- [ ] Staré VMs smazané, projekt vyčištěný + +**Po Fázi 2 lze pokračovat paralelně Fázemi 3, 4, 5.** + +--- + +## Fáze 3 — TLS přes Caddy + +**Goal fáze:** Agnes je dostupná na HTTPS s automatickým Let's Encrypt certifikátem. Cookie `secure=True` funguje. + +### Task 3.1: Přidat Caddy service do docker-compose + +**Files:** +- Create: `Caddyfile` (v public repu root) +- Modify: `docker-compose.prod.yml` (přidat caddy service) + +- [ ] **Step 1: Vytvořit Caddyfile** + +Write `Caddyfile`: + +``` +# Agnes reverse proxy with automatic Let's Encrypt. +# Config přes ENV vars: AGNES_DOMAIN, ACME_EMAIL. + +{$AGNES_DOMAIN} { + # Health check endpoint bez TLS redirect (pro smoke testy interně) + @health path /api/health + + encode gzip + + reverse_proxy app:8000 { + header_up X-Forwarded-Proto https + } + + tls {$ACME_EMAIL} + + log { + output stdout + format json + } +} + +# Fallback pro IP access (bez HTTPS, bez cert) +:80 { + reverse_proxy app:8000 +} +``` + +- [ ] **Step 2: Přidat caddy do `docker-compose.prod.yml`** + +Add to `services` (pokud už tam není): + +```yaml + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + AGNES_DOMAIN: ${AGNES_DOMAIN:-:80} + ACME_EMAIL: ${ACME_EMAIL:-admin@example.com} + depends_on: + - app + profiles: + - tls # nezapne se bez --profile tls + +volumes: + caddy_data: + caddy_config: +``` + +- [ ] **Step 3: Aktualizovat modul — předat `tls_mode` do startup-script** + +V `infra/modules/customer-instance/startup-script.sh` najít sekci `# --- 5. Start Agnes ---` a rozšířit: + +```bash +# --- 5. Start Agnes --- +COMPOSE_PROFILES="" +if [ "$TLS_MODE" = "caddy" ] && [ -n "$DOMAIN" ]; then + COMPOSE_PROFILES="--profile tls" + # Další ENV pro Caddy + { + echo "AGNES_DOMAIN=$DOMAIN" + echo "ACME_EMAIL=admin@$${DOMAIN#*.}" + } >> "$APP_DIR/.env" +fi + +docker compose -f docker-compose.yml -f docker-compose.prod.yml $COMPOSE_PROFILES pull +docker compose -f docker-compose.yml -f docker-compose.prod.yml $COMPOSE_PROFILES up -d +``` + +- [ ] **Step 4: Commit changes v public repu** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +git add Caddyfile docker-compose.prod.yml infra/modules/customer-instance/startup-script.sh +git commit -m "feat(tls): add Caddy reverse proxy with Let's Encrypt support" +``` + +- [ ] **Step 5: Tag nového releasu modulu** + +```bash +# Po mergi PR do main +git checkout main && git pull +git tag -a infra-v1.1.0 -m "Add TLS support via Caddy" +git push origin infra-v1.1.0 +``` + +### Task 3.2: Zapnout TLS pro Keboola instanci + +**Tohle vyžaduje DNS záznam. Pokud nemáš doménu, skip a zůstaň na :8000.** + +- [ ] **Step 1: V `keboola/agnes-infra-keboola/terraform/terraform.tfvars` nastavit doménu** + +Pokud máme `agnes.keboola.com` (ověřit u IT), edit: + +```hcl +prod_instance = { + name = "agnes-prod" + # ... + tls_mode = "caddy" + domain = "agnes.keboola.com" +} +``` + +A v `main.tf` bumpnout module ref: + +```hcl +source = "github.com/keboola/agnes-the-ai-analyst//infra/modules/customer-instance?ref=infra-v1.1.0" +``` + +- [ ] **Step 2: Terraform apply** + +```bash +cd ~/.../agnes-infra-keboola/terraform +terraform apply +``` + +- [ ] **Step 3: Nastavit DNS A record `agnes.keboola.com` → prod_ip** + +Ruční krok (potřebuje přístup do Keboola DNS). Výstup `prod_ip` je IP. + +- [ ] **Step 4: Počkat na DNS propagation + LE cert** + +```bash +until nslookup agnes.keboola.com | grep -q "$(terraform output -raw prod_ip)"; do sleep 30; done +sleep 60 # čas na LE cert issuance +curl -sSI --max-time 10 https://agnes.keboola.com | head -5 +``` + +Expected: `HTTP/2 200` (ne 301, ne TLS error). + +--- + +## Fáze 4 — Watchtower (dev VM auto-deploy), OS Login, VM SA + +**Goal fáze:** Dev VMs auto-pullují nové image. OS Login pro SSH (bez osobního klíče). Dedikovaný VM SA. + +### Task 4.1: Watchtower integrace (už v Task 2 startup-script, zde jen ověření) + +- [ ] **Step 1: SSH na dev VM a ověřit, že watchtower běží** + +```bash +gcloud compute ssh agnes-dev --zone=europe-west1-b --project=kids-ai-data-analysis --command="sudo docker ps | grep watchtower" +``` + +Expected: container `watchtower` STATUS `Up X minutes`. + +- [ ] **Step 2: Otestovat auto-deploy: pushnout drobnou změnu na feature branch, počkat** + +```bash +# V public repu +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +git checkout -b feature/watchtower-test +echo "# test" >> README.md +git add README.md +git commit -m "test: trigger :dev image rebuild" +git push origin feature/watchtower-test +``` + +Počkat ~ 5-10 min (CI build + watchtower poll interval 5 min). + +```bash +# Kontrola image sha na dev VM +gcloud compute ssh agnes-dev --zone=europe-west1-b --project=kids-ai-data-analysis \ + --command="sudo docker inspect app-app-1 --format '{{.Image}}' && sudo docker image inspect \$(sudo docker inspect app-app-1 --format '{{.Image}}') --format '{{.Created}}'" +``` + +Expected: Created timestamp v posledních ~ 10 minutách. + +### Task 4.2: OS Login + +- [ ] **Step 1: Ověřit, že modul nastavuje `enable-oslogin=TRUE`** + +Už je v `infra/modules/customer-instance/main.tf`: + +```hcl +metadata = { + enable-oslogin = "TRUE" +} +``` + +- [ ] **Step 2: Zkontrolovat, že uživatelé mají `roles/compute.osAdminLogin` na projektu** + +```bash +gcloud projects get-iam-policy kids-ai-data-analysis \ + --flatten="bindings[].members" \ + --filter="bindings.role=roles/compute.osAdminLogin" \ + --format="value(bindings.members)" +``` + +Pokud prázdné, přidat: + +```bash +gcloud projects add-iam-policy-binding kids-ai-data-analysis \ + --member=user:zdenek.srotyr@keboola.com \ + --role=roles/compute.osAdminLogin +``` + +- [ ] **Step 3: Test SSH přes OS Login** + +```bash +gcloud compute ssh agnes-prod --zone=europe-west1-b --project=kids-ai-data-analysis --command="whoami" +``` + +Expected: username ve formátu `zdenek_srotyr_keboola_com` (OS Login generated). + +### Task 4.3: VM SA už má správný scope (ověřit) + +- [ ] **Step 1: Ověřit, že VM SA má jen secretmanager.secretAccessor** + +```bash +gcloud projects get-iam-policy kids-ai-data-analysis \ + --flatten="bindings[].members" \ + --filter="bindings.members:agnes-keboola-vm@" \ + --format="value(bindings.role)" +``` + +Expected: `roles/secretmanager.secretAccessor` (jen tohle). + +--- + +## Fáze 5 — CI/CD v privátním infra repu + +**Goal fáze:** PR v `keboola/agnes-infra-keboola` spustí `terraform plan`; merge → `terraform apply`. Prod aplikuje přes environment protection s reviewerem. + +### Task 5.1: plan.yml workflow + +**Files (v `keboola/agnes-infra-keboola` repu):** +- Create: `.github/workflows/plan.yml` + +- [ ] **Step 1: Napsat plan.yml** + +```yaml +name: Terraform Plan + +on: + pull_request: + paths: + - 'terraform/**' + +permissions: + contents: read + pull-requests: write + +jobs: + plan: + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@v5 + + - uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ~1.7 + + - run: terraform init + - run: terraform fmt -check + - id: plan + run: | + terraform plan -no-color -out=tfplan 2>&1 | tee plan.txt + echo "status=$(echo $? )" >> $GITHUB_OUTPUT + + - uses: actions/github-script@v7 + if: always() + with: + script: | + const fs = require('fs'); + const plan = fs.readFileSync('terraform/plan.txt', 'utf8').slice(0, 60000); + const body = `### Terraform plan\n\n\`\`\`\n${plan}\n\`\`\``; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); +``` + +- [ ] **Step 2: Commit** + +```bash +cd ~/.../agnes-infra-keboola +git add .github/workflows/plan.yml +git commit -m "ci: add terraform plan on PR" +git push +``` + +### Task 5.2: apply.yml workflow s environment protection + +**Files:** +- Create: `.github/workflows/apply.yml` + +- [ ] **Step 1: Napsat apply.yml** + +```yaml +name: Terraform Apply + +on: + push: + branches: [main] + paths: + - 'terraform/**' + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + apply-dev: + runs-on: ubuntu-latest + environment: dev # no protection + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@v5 + - uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ~1.7 + - run: terraform init + - run: terraform apply -auto-approve -target='module.agnes.google_compute_instance.vm["agnes-dev"]' + + apply-prod: + needs: apply-dev + runs-on: ubuntu-latest + environment: prod # protected — requires reviewer + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@v5 + - uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ~1.7 + - run: terraform init + - run: terraform apply -auto-approve + + - name: Smoke test + run: | + PROD_IP=$(terraform output -raw prod_ip) + for i in 1 2 3 4 5; do + if curl -sf "http://$PROD_IP:8000/api/health" >/dev/null; then + echo "Healthy"; exit 0 + fi + sleep 15 + done + echo "Health check failed"; exit 1 +``` + +- [ ] **Step 2: V GitHub UI nastavit environmenty** + +Navigovat do `keboola/agnes-infra-keboola` → Settings → Environments → New environment: + +- **dev**: žádná protection +- **prod**: + - Required reviewers: @ZdenekSrotyr (nebo @keboola-ops-team) + - Wait timer: 5 min + - Deployment branches: Selected branches → `main` + +- [ ] **Step 3: Commit workflow** + +```bash +git add .github/workflows/apply.yml +git commit -m "ci: add terraform apply with dev/prod environments and smoke test" +git push +``` + +- [ ] **Step 4: Test flow — otevřít dummy PR, sledovat plan, merge, apply** + +```bash +git checkout -b test/ci-flow +# trivial edit in tfvars, např. přidat dev VM +echo "# ci flow test" >> terraform/README.md +git add terraform/README.md +git commit -m "test: CI flow" +git push origin test/ci-flow +gh pr create --title "test: CI flow" --body "Testing plan → apply flow" +``` + +V PR: +1. Počkat na plan.yml → komentář s plánem +2. Schválit + merge +3. Sledovat apply-dev (auto), pak apply-prod (čeká na reviewera) +4. Schválit prod deploy +5. Ověřit smoke test PASS + +### Task 5.3: Rotovat SA key (z lokálního -> jen v GH secret) + +- [ ] **Step 1: Smazat lokální SA key** + +```bash +rm ~/.../agnes-deploy-kids-ai-data-analysis-key.json +``` + +- [ ] **Step 2: Na GCP smazat starý klíč (key rotation)** + +```bash +# Seznam klíčů +gcloud iam service-accounts keys list \ + --iam-account=agnes-deploy@kids-ai-data-analysis.iam.gserviceaccount.com \ + --project=kids-ai-data-analysis +``` + +Po ověření, že GH Actions s novým klíčem funguje (po úspěšném prvním apply), smazat starý. + +--- + +## Fáze 6 — Template repo + onboarding playbook + +**Goal fáze:** Druhý zákazník (GRPN) se dá nasadit za < 1 hodinu. + +### Task 6.1: Vytvořit `keboola/agnes-infra-template` + +- [ ] **Step 1: Založit prázdný repo jako template** + +```bash +gh repo create keboola/agnes-infra-template --public --description "Template for Agnes per-customer infrastructure" -c +cd ~/Library/Mobile\ Documents/com\~apple\~CloudDocs/Sources/VsCode/component_factory/ +gh repo clone keboola/agnes-infra-template +cd agnes-infra-template +``` + +- [ ] **Step 2: Zkopírovat strukturu z `agnes-infra-keboola`, nahradit konkrétní hodnoty placeholdery** + +```bash +# Zkopírovat strukturu +cp -r ../agnes-infra-keboola/terraform . +cp -r ../agnes-infra-keboola/.github . + +# Reset konkrétní hodnoty +cat > terraform/main.tf <<'EOF' +terraform { + required_version = ">= 1.5" + required_providers { + google = { source = "hashicorp/google", version = "~> 5.0" } + } + backend "gcs" { + bucket = "REPLACE_WITH_YOUR_BUCKET" + prefix = "REPLACE_WITH_CUSTOMER_NAME" + } +} + +provider "google" { + project = var.gcp_project_id + region = var.region + zone = var.zone +} + +module "agnes" { + source = "github.com/keboola/agnes-the-ai-analyst//infra/modules/customer-instance?ref=infra-v1.1.0" + + gcp_project_id = var.gcp_project_id + region = var.region + zone = var.zone + customer_name = var.customer_name + seed_admin_email = var.seed_admin_email + data_source = var.data_source + keboola_stack_url = var.keboola_stack_url + prod_instance = var.prod_instance + dev_instances = var.dev_instances +} + +output "prod_ip" { value = module.agnes.prod_ip } +output "instance_ips" { value = module.agnes.instance_ips } +EOF + +cat > terraform/variables.tf <<'EOF' +variable "gcp_project_id" { type = string } +variable "region" { type = string, default = "europe-west1" } +variable "zone" { type = string, default = "europe-west1-b" } +variable "customer_name" { type = string } +variable "seed_admin_email" { type = string } +variable "data_source" { type = string, default = "keboola" } +variable "keboola_stack_url" { type = string, default = "" } +variable "prod_instance" { type = any } +variable "dev_instances" { type = any, default = [] } +EOF + +cat > terraform/terraform.tfvars.example <<'EOF' +# Kopie tohoto souboru → terraform.tfvars, vyplnit hodnoty. +# terraform.tfvars je gitignored (nikdy necommitovat!) + +gcp_project_id = "REPLACE" # Váš GCP projekt +customer_name = "REPLACE" # Krátký identifikátor, např. "acme" +seed_admin_email = "admin@example.com" +data_source = "keboola" # keboola | bigquery | csv +keboola_stack_url = "https://connection.keboola.com/" + +prod_instance = { + name = "agnes-prod" + machine_type = "e2-small" + data_disk_gb = 50 + image_tag = "stable" + upgrade_mode = "auto" + tls_mode = "caddy" + domain = "" +} + +dev_instances = [ + { name = "agnes-dev", image_tag = "dev" } +] +EOF +``` + +- [ ] **Step 3: Zkopírovat bootstrap skript z public repa** + +```bash +cp ../tmp_oss/scripts/bootstrap-gcp.sh . +``` + +- [ ] **Step 4: Napsat README.md pro onboarding** + +Write: + +```markdown +# Agnes Infrastructure Template + +Deploy Agnes (AI Data Analyst) into your own GCP project. + +## Prerequisites + +- GCP project with billing enabled +- `gcloud` CLI authenticated as project Owner +- `terraform` >= 1.5 +- GitHub account (for private repo + Actions) + +## 1. Bootstrap GCP + +```bash +./bootstrap-gcp.sh +``` + +Výstup: SA key JSON. + +## 2. Klonovat template + +```bash +gh repo create /agnes-infra --template keboola/agnes-infra-template --private +cd agnes-infra +``` + +## 3. Nastavit secrets + +```bash +# SA key (z kroku 1) +gh secret set GCP_SA_KEY < path/to/key.json +rm path/to/key.json + +# Keboola token (pokud data_source = keboola) +gcloud secrets create keboola-storage-token --data-file=- <<< "YOUR_TOKEN" +``` + +## 4. Konfigurace + +Editovat `terraform/main.tf` — aktualizovat `backend.bucket` a `backend.prefix`. + +Kopírovat `terraform/terraform.tfvars.example` → `terraform/terraform.tfvars`, vyplnit. + +## 5. První apply + +```bash +cd terraform +terraform init +terraform plan +terraform apply +``` + +IP prod VM je v outputu. + +## 6. Login + +```bash +# Bootstrap prvního admin usera +curl -X POST http://$(terraform output -raw prod_ip):8000/auth/bootstrap \ + -H "Content-Type: application/json" \ + -d '{"email": "YOU@example.com", "password": "YOUR_STRONG_PASSWORD"}' +``` + +Otevřít http://:8000/login. + +## 7. Upgrade workflow + +- `:stable` image → auto-upgrade přes Watchtower +- Infra změna: PR v tomto repu → `terraform plan` v PR → merge → `apply` (prod vyžaduje reviewer) +- TF modul upgrade: Renovate otevře PR s novým `ref=infra-vX.Y.Z` + +Další detaily: https://github.com/keboola/agnes-the-ai-analyst/blob/main/docs/ONBOARDING.md +``` + +- [ ] **Step 5: Vytvořit README + push + mark as template** + +```bash +git add . +git commit -m "initial template" +git push -u origin main +gh repo edit keboola/agnes-infra-template --template +``` + +### Task 6.2: Napsat ONBOARDING.md v public repu + +**Files:** +- Create: `docs/ONBOARDING.md` (v public repu) + +- [ ] **Step 1: Napsat ONBOARDING.md** + +Write `docs/ONBOARDING.md` obsah identický s README v template repu + poznámkou "fyzická šablona: keboola/agnes-infra-template". + +- [ ] **Step 2: Commit** + +```bash +cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" +git add docs/ONBOARDING.md +git commit -m "docs: onboarding guide for deploying Agnes per customer" +``` + +### Task 6.3: Vyzkoušet onboarding na dummy customer (sanity check) + +- [ ] **Step 1: Vytvořit testovací GCP projekt** + +```bash +gcloud projects create agnes-onboarding-test-$(date +%s) --name="Agnes onboarding test" +# Link billing (via UI) if required +``` + +- [ ] **Step 2: Spustit bootstrap** + +```bash +./scripts/bootstrap-gcp.sh +``` + +- [ ] **Step 3: Klonovat template do dummy repa** + +```bash +gh repo create zdeneksrotyr/agnes-infra-test --template keboola/agnes-infra-template --private +gh repo clone zdeneksrotyr/agnes-infra-test +cd agnes-infra-test +``` + +- [ ] **Step 4: Projít README krok za krokem a změřit čas** + +Cíl: end-to-end < 1 hod. Zaznamenat překážky, zpět do README. + +- [ ] **Step 5: Cleanup — smazat test projekt** + +```bash +gcloud projects delete +gh repo delete zdeneksrotyr/agnes-infra-test --yes +``` + +### Task 6.4: Renovate configuration + +- [ ] **Step 1: Přidat renovate.json do template repa** + +Write `keboola/agnes-infra-template/renovate.json`: + +```json +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "customManagers": [ + { + "customType": "regex", + "fileMatch": ["\\.tf$"], + "matchStrings": [ + "source\\s*=\\s*\"github\\.com/keboola/agnes-the-ai-analyst//infra/modules/customer-instance\\?ref=(?infra-v\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "keboola/agnes-the-ai-analyst", + "packageNameTemplate": "keboola/agnes-the-ai-analyst", + "versioningTemplate": "regex:^infra-v(?\\d+)\\.(?\\d+)\\.(?\\d+)$" + } + ], + "packageRules": [ + { + "matchPackageNames": ["keboola/agnes-the-ai-analyst"], + "matchUpdateTypes": ["major"], + "prPriority": 10 + } + ] +} +``` + +- [ ] **Step 2: Instalovat Renovate GitHub App na privátní repa** + +Ruční krok v GitHub: Settings → Integrations → Renovate → grant access. + +--- + +## Finální checkpoint + +- [ ] **Fáze 1 complete** — prod běží z `:stable` image, žádný git pull z forku +- [ ] **Fáze 2 complete** — TF modul, PD, Keboola nasazena přes modul +- [ ] **Fáze 3 complete** — HTTPS funguje (pokud DNS dostupné) +- [ ] **Fáze 4 complete** — watchtower na dev VM auto-pulluje :dev, OS Login aktivní +- [ ] **Fáze 5 complete** — GHA CI/CD funguje, prod apply vyžaduje review +- [ ] **Fáze 6 complete** — template repo existuje, ONBOARDING.md, Renovate nakonfigurovaný +- [ ] **Starý osobní fork smazán** +- [ ] **Keboola token rotován a v Secret Manageru** +- [ ] **Dokumentace aktualizovaná** + +--- + +## Self-Review + +**Spec coverage:** +- §2 Model self-deploy → Task 1.2 (bootstrap), Task 2.3 (private repo), Task 6 (template) ✅ +- §3 Repo architektura → Task 2.1 (modul), Task 6.1 (template), Task 2.3 (customer repo) ✅ +- §4 Release model → Task 1.1 (per-branch tagging), existuje release.yml ✅ +- §5 Branch-aware dev → Task 2.1 (dev_instances proměnná), Task 4.1 (watchtower) ✅ +- §6 Prod upgrade model → Task 4.1 (auto via watchtower), pinned mode přes tfvars (zákazník zvolí) ✅ +- §7 Security → Task 1.2-1.4 (Secret Manager, SA), Task 4.2 (OS Login), Task 5.2 (env protection) ✅ +- §8 Onboarding → Task 6.1-6.4 ✅ +- §9 Tok změn → Task 5.1-5.2 (plan/apply), Task 4.1 (watchtower pipeline) ✅ +- §10 Backup/monitoring → částečně; monitoring je follow-up (§14) ✅ + +**Placeholder scan:** Všechny kódy, konfigurace, příkazy jsou konkrétní. + +**Type consistency:** `prod_instance` object a `dev_instances` list mají konzistentní schéma napříč Task 2.1, Task 2.3, Task 6.1. + +**Gap:** Zákazníkem-zvolený pinned upgrade režim (§6.1) spouští Renovate — Renovate konfigurace je v Task 6.4, ale nepokrývá upgrade image tagu (jen modul ref). Follow-up: rozšířit `customManagers` v renovate.json na `image_tag` v tfvars. diff --git a/docs/superpowers/specs/2026-04-21-multi-customer-deployment-spec.md b/docs/superpowers/specs/2026-04-21-multi-customer-deployment-spec.md new file mode 100644 index 0000000..3853a11 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-multi-customer-deployment-spec.md @@ -0,0 +1,442 @@ +# Multi-Customer Deployment — Design Spec + +Datum: 2026-04-21 +Status: Návrh k implementaci +Autor: Zdeněk Šrotýř + Claude (sparring) + +## 1. Cíl + +Zavést *production-grade* nasazení Agnes, které: + +1. Nechává **upstream repo public** (žádné zákaznické info tam). +2. Umožňuje **N zákazníků paralelně**, každý v izolovaném prostoru. +3. Je **anonymizované** — jeden zákazník nevidí existenci ani identitu ostatních. +4. Má **auto-deploy s rozumnými gates** — feature branch push → dev VM aktualizace do minut; merge do main → prod s review gate. +5. Podporuje **branch-aware dev environments** — víc vývojářů paralelně, každý na své branchi, bez interference. +6. **Škáluje O(1) na zákazníka** — přidání GRPN vedle Keboola znamená jen klonování šablony, ne změnu upstream. + +## 2. Model — Pure Self-Deploy + +### 2.1 Role + +| Strana | Co dělá | +|---|---| +| **Keboola jako upstream** | Udržuje app kód, buildí & pushuje Docker image na GHCR, udržuje TF modul, udržuje infra template | +| **Zákazník (vč. Keboola-as-customer)** | Vlastní GCP projekt, vlastní privátní infra repo, vlastní CI/CD, spravuje svoje VMs, nese náklady | + +Keboola jako upstream **nemá žádný přístup k zákaznickým GCP projektům**. Zákazník zodpovídá za svoje nasazení. + +Keboola interní produkční Agnes instance je **speciální případ zákazníka** — Keboola IT vlastní `kids-ai-data-analysis` GCP projekt a spravuje tam svou Agnes stejně jako to bude dělat GRPN ve svém GCP. + +### 2.2 Budoucí rozšíření (out of scope pro tuto vlnu) + +- **AWS podpora**: TF modul je dnes GCP-specific. Jakmile přijde první AWS zákazník, přidáme paralelní modul `modules/customer-instance-aws/`. +- **Managed service**: Keboola bude nabízet "nasadíme vám to za vás" — znamená přidat Keboola jako operator role s IAM delegací do zákazníkova GCP. Design v tomhle specu je kompatibilní, jen vyžaduje extra vrstvu IAM bindings. + +## 3. Repo architektura + +### 3.1 Počet a typ repozitářů + +``` +keboola/agnes-the-ai-analyst PUBLIC App + TF modul + dokumentace +keboola/agnes-infra-template PUBLIC Skeleton pro privátní infra repo (template) +keboola/agnes-infra-keboola PRIVATE Keboola-as-customer deployment +{acme}/agnes-infra PRIVATE Nový zákazník — v jejich GitHub org, klonováno z template +``` + +Počet: **2 upstream + N per-customer**. Upstream repa jsou stabilní, per-customer vznikají při onboarding. + +### 3.2 Obsah `keboola/agnes-the-ai-analyst` (public) + +``` +agnes-the-ai-analyst/ +├── app/ src/ connectors/ cli/ # produkt +├── Dockerfile docker-compose.yml +├── .github/workflows/ +│ └── release.yml # build + push do GHCR; tagy: :dev, :stable, :dev-branch-xyz +├── infra/ +│ ├── modules/ +│ │ └── customer-instance/ # versioned: tag infra-v1.0, v1.1, ... +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf +│ └── examples/ +│ └── minimal/ # quickstart pro OSS self-hoster +└── docs/ + ├── DEPLOYMENT.md # pro self-host (compose, bez Terraform) + ├── ONBOARDING.md # pro managed (cesta k TF + template) + └── architecture.md +``` + +**TF modul `customer-instance`** je verzován samostatně semver (`infra-v1.x`), odlišeně od app image (CalVer `YYYY.MM.N`). + +### 3.3 Obsah `keboola/agnes-infra-template` (public template) + +``` +agnes-infra-template/ +├── terraform/ +│ ├── main.tf # module { source = "github.com/keboola/agnes-the-ai-analyst//infra/modules/customer-instance?ref=infra-v1.0" } +│ ├── variables.tf +│ ├── backend.tf # gcs by default, komentář jak přepnout na s3/remote +│ ├── terraform.tfvars.example +│ └── .gitignore # terraform.tfvars, *.tfstate +├── .github/workflows/ +│ ├── plan.yml # PR → terraform plan +│ └── apply.yml # main → terraform apply +├── config/ +│ └── instance.yaml.example +├── bootstrap.sh # jednorázový setup GCP: SA, API enable, bucket, secrets +└── README.md # step-by-step onboarding +``` + +Zákazník (nebo Keboola při onboardingu) použije `gh repo create --template keboola/agnes-infra-template` → přijde privátní repo s hotovou strukturou. + +### 3.4 Obsah per-customer privátního repa (např. `keboola/agnes-infra-keboola`) + +Přesně ta samá struktura jako template, jen s konkrétními hodnotami v `terraform.tfvars`: + +```hcl +# keboola/agnes-infra-keboola/terraform/terraform.tfvars +# (gitignored, nebo lokálně v Secret Manageru — viz §6) + +gcp_project_id = "kids-ai-data-analysis" +region = "europe-west1" +zone = "europe-west1-b" + +prod_instance = { + name = "agnes-prod" + machine_type = "e2-small" + image_tag = "stable" # floating | "stable-2026.04.N" (pinned) + upgrade_mode = "auto" # auto (watchtower) | pinned (Renovate) + tls_mode = "caddy" # caddy | gcp-lb | cloudflare | none + domain = "" # prázdné = jen IP +} + +dev_instances = [ + { name = "agnes-dev-default", image_tag = "dev" }, + # přidávat další dev VMs per branch/developer +] + +seed_admin_email = "zdenek.srotyr@keboola.com" + +# Keboola-specific +data_source = "keboola" +keboola_stack_url = "https://connection.us-east4.gcp.keboola.com/" +keboola_token_secret_id = "keboola-storage-token" # reference do Secret Manageru +``` + +## 4. Release model + +### 4.1 Image tagging v GHCR + +Public repo CI (release.yml) buildí a pushuje do `ghcr.io/keboola/agnes-the-ai-analyst` při každém push: + +| Trigger | Tagy které vzniknou | +|---|---| +| Push `main` | `:stable`, `:stable-YYYY.MM.N`, `:sha-xxxxxxx` | +| Push `feature/xyz` | `:dev`, `:dev-feature-xyz`, `:sha-xxxxxxx` | +| Push `release/1.2.x` | `:release-1.2.x`, `:release-1.2.x-YYYY.MM.N` | + +`:dev` a `:stable` jsou **floating** tagy — posouvají se při každém pushe. Verzované tagy jsou **neměnné**. + +### 4.2 Visibility obrazu + +`ghcr.io/keboola/agnes-the-ai-analyst` je **public image**. Zákaznické VMs pullují bez credentials. + +Důvod: kód je veřejný, obraz nesmí obsahovat nic, co veřejný kód neobsahuje. Secrets jdou do `.env` na VM, ne do image. + +### 4.3 Smoke test + +Po push `main` a tagování `:stable-N`, CI spustí smoke test: `docker compose up` + curl `/api/health` + auth + query. PASS → `:stable` floating se posune. FAIL → build dostane `:deprecated-N` label, `:stable` se nehne, GitHub issue s logy. + +### 4.4 CalVer + smoke test = kontinuální release + +Žádné manuální release rozhodnutí. Každý merge do main = release (pokud smoke test projde). Číslování `YYYY.MM.N` = rok.měsíc.sekvence. + +## 5. Branch-aware dev environments + +### 5.1 Motivace + +Víc vývojářů paralelně potřebuje víc dev environmentů bez interference. „Floating `:dev`" je nedostatečné — poslední push přepíše ostatní. + +### 5.2 Mechanismus + +Každý feature branch push → samostatný tag `:dev-{branch-slug}` navíc k floating `:dev`. + +V privátním infra repu zákazník vyjmenuje dev VMs s pinned tagem: + +```hcl +dev_instances = [ + { name = "agnes-dev", image_tag = "dev" }, # floating (demo / reviewers) + { name = "agnes-alice-feat1", image_tag = "dev-feature-alice-dashboard" }, # Alice má svou + { name = "agnes-bob-pr142", image_tag = "dev-pr-142" }, # Bob pinned na PR +] +``` + +### 5.3 Lifecycle dev VM + +``` +1. Někdo otevře PR v privátním infra repu: + + { name = "agnes-carol", image_tag = "dev-feature-carol-new-auth" } +2. CI plan.yml komentuje v PR: „vytvoří se VM agnes-carol (e2-small, europe-west1-b)" +3. Merge → apply.yml spustí terraform apply +4. VM up za ~2 min +5. Watchtower na VM polluje :dev-feature-carol-new-auth každých 5 min +6. Každý push na feature/carol/new-auth → nový image → watchtower pullne → VM má aktuální verzi +7. Až Carol dokončí feature (merge do main), smaže řádek v tfvars → terraform apply → VM destroy +``` + +**Žádný nový SA, žádný nový GitHub environment, žádná infra operace navíc.** Jen editace seznamu v tfvars. + +### 5.4 Ephemeral preview environments (budoucnost) + +V pozdější fázi zvážit automatizaci: PR otevřen → GHA vytvoří per-PR VM; PR zavřen → destroy. Aktuálně explicitní flow přes tfvars stačí. + +## 6. Prod upgrade model + +### 6.1 Dva režimy (per-instance volitelné) + +| Režim | Jak | Pro koho | +|---|---|---| +| **auto** | Watchtower na VM polluje `:stable` (floating), pullne + restart, když se objeví nový digest | Default — rychlost, low-touch | +| **pinned** | `image_tag = "stable-2026.04.7"` v tfvars. Renovate polluje GHCR, otevírá PR s bump. Ops schválí → merge → apply | Regulovaní zákazníci, audit trail | + +### 6.2 Gate pro auto režim + +Jedinou ochranou před rozbitým `:stable` je **CI smoke test** před posunutím floating tagu. Pokud projde tam, prod auto-upgradne. Doporučení: mít i u Keboola instance **monitoring + alert na `/api/health` degraded status**, aby případný skluz smoke testu nezůstal dlouho bez povšimnutí. + +### 6.3 Rollback + +Rollback = změnit `image_tag` na předchozí verzi a `docker compose up -d`. Zjednodušená forma: + +- **Auto režim:** rychle přepnout watchtower na specifický tag; pak investigate +- **Pinned režim:** PR revert, apply + +## 7. Security model + +### 7.1 Authentication mezi komponenty + +| Kdo → kde | Jak se přihlásí | +|---|---| +| Public CI → GHCR push | `${{ secrets.GITHUB_TOKEN }}` (built-in) | +| VM → GHCR pull | Public image, bez auth | +| Privátní CI → GCP | SA JSON key v `GCP_SA_KEY` secret (Fáze 1); WIF (Fáze follow-up) | +| CI na zákaznickém GCP → Secret Manager | SA má `roles/secretmanager.admin` | +| App na VM → Secret Manager | VM má dedikovaný SA s `roles/secretmanager.secretAccessor` | +| App na VM → Keboola Storage | Token z Secret Manageru | + +### 7.2 Deploy SA — scope per zákazník + +SA `agnes-deploy@` dostane **jen** tyto role: + +``` +roles/compute.instanceAdmin.v1 # create/update/delete VMs +roles/compute.securityAdmin # firewall rules +roles/compute.networkAdmin # static IP +roles/iam.serviceAccountUser # attach VM SA k instancím +roles/secretmanager.admin # vytvořit/rotovat secrets +roles/storage.admin # tfstate bucket +``` + +Žádný `owner`, žádný `editor`. Blast radius pro leak SA key = přepis VMs v tomhle projektu. Nic mimo projekt, nic dat. + +### 7.3 GitHub environmenty + +```yaml +environments: + dev: + # žádná protection + secrets: + GCP_SA_KEY: + prod: + protection_rules: + required_reviewers: [@keboola-ops-team] + wait_timer: 5m + deployment_branches: main + secrets: + GCP_SA_KEY: +``` + +Oba environmenty sdílí ten samý SA key (jeden GCP, jedna identita). Rozdíl je **jen v protection rules** — kdo smí pushnout kam. + +### 7.4 VM hardening + +- **OS Login** místo per-user SSH klíčů (follow-up) +- **Dedikovaný VM SA** s minimem práv (jen read z Secret Manageru, nic dalšího) +- **Ephemeral disk strategy**: boot disk = produkt (stateless), `/data` = persistent disk (stateful, snapshoty) +- **Žádný token v startup-script metadatě** — všechny secrets teprve při boot z Secret Manageru + +### 7.5 Rotace tajemství + +| Tajemství | Kde žije | Jak se rotuje | +|---|---|---| +| Keboola Storage token | Secret Manager v zákaznickém GCP | Keboola UI → nová verze v SM → app restart | +| JWT_SECRET_KEY | Secret Manager, generováno TF | `terraform apply` s `-replace=google_secret_manager_secret_version.jwt` | +| SA JSON key | GitHub secret | Vygenerovat nový klíč, paste do GH secret, smazat starý klíč v GCP | +| User passwords | Argon2 hash v DuckDB `users` | User-facing flow (reset endpoint, admin CLI) | + +## 8. Onboarding nového zákazníka + +### 8.1 Kroky (cílový čas: < 1 hod) + +``` +1. Zákazník (nebo Keboola ops za něj) založí GCP projekt + billing +2. Někdo s owner rolí v projektu spustí bootstrap.sh: + - Enable APIs (compute, iam, secretmanager, storage, iamcredentials) + - Vytvoří SA agnes-deploy s rolemi + - Vygeneruje SA key (předá ownerovi) + - Vytvoří gs://agnes-{project}-tfstate +3. Zákazník (nebo Keboola ops) klonuje template: + gh repo create {org}/agnes-infra --template keboola/agnes-infra-template --private +4. V novém repu: + - Nastaví GH secret GCP_SA_KEY (paste z kroku 2) + - Upraví terraform.tfvars na jejich hodnoty + - Vytvoří initial commit + push +5. Nastaví Secret Manager tajemství (Keboola token atd.) +6. První PR s tfvars → plan → merge → apply +7. DNS — zákazník si později nastaví CNAME na IP (nebo zůstane na IP) +8. Admin user — bootstrap endpoint POST /auth/bootstrap nebo admin CLI +9. Smoke test: login, sync, query +``` + +### 8.2 Co je vidět komu + +| Role | Vidí | +|---|---| +| Každý na internetu | Public repo `agnes-the-ai-analyst`, jeho issues, PRs, image na GHCR | +| Keboola ops tým | Výše + privátní template repo + infra-keboola repo | +| Zákazník (acme) | Výše public + svůj vlastní infra-acme repo ve svém org | +| Nikdo | Ostatní zákazníky kromě jejich vlastního | + +## 9. Tok změn + +### 9.1 Change v app kódu (nejčastější) + +``` +1. Vývojář: push feature branch v public repu +2. Public CI: build :dev-feature-xyz (a :dev floating) +3. Watchtower na každé VM s image_tag = "dev": pullne do 5 min + Watchtower na VM s image_tag = "dev-feature-xyz": pullne taky +4. Dev review +5. Merge do main +6. Public CI: build :stable-YYYY.MM.N (a :stable floating) +7. Smoke test CI: PASS → :stable se posune +8. Prod VMs: + - auto režim: watchtower pullne do 5 min + - pinned režim: Renovate otevře PR v privátním repu +``` + +### 9.2 Change v infra (VM size, dev VM list, nová disk) + +``` +1. Ops otevře PR v privátním infra repu +2. CI plan.yml: terraform plan → komentář v PR +3. Review + merge +4. CI apply.yml: + - pro dev změny: environment "dev" → apply bez gatu + - pro prod změny: environment "prod" → required reviewer → apply +5. Po apply: smoke test přes curl /api/health +``` + +### 9.3 Change v TF modulu + +``` +1. Maintainer otevře PR v public repu do infra/modules/customer-instance/ +2. CI validuje modul proti examples/ +3. Merge → auto git tag infra-v1.1.0 +4. Renovate v každém privátním infra repu: + → otevře PR "bump source ref to infra-v1.1.0" +5. Každý zákazník schvaluje samostatně → terraform plan → apply +``` + +## 10. Provozní aspekty + +### 10.1 Monitoring a alerting (doporučení, ne v první vlně) + +- Cloud Monitoring dashboard per-customer +- Alert na `/api/health` `status != "healthy"` déle než 5 min +- Alert na VM CPU > 80 % déle než 30 min +- Log-based metric: sync failures, auth failures, HTTP 5xx rate +- Integrace se Slack/email přes Alerting policy + +### 10.2 Backup + +- Snapshoty `/data` persistent disku denně, retention 30 dní (TF `google_compute_resource_policy`) +- `system.duckdb` obsahuje users/permissions — při schema migraci snapshot kopie (již existuje jako `*.pre-migrate`) + +### 10.3 Disaster recovery + +- Recreation VM z nuly = `terraform apply` (~5 min) + restore `/data` ze snapshotu (~5 min) +- Total loss zákazníka = destroy GCP projektu; recreate ze snapshotu + tfstate + +### 10.4 Cost per customer (orientačně) + +| Položka | $/měs | +|---|---| +| Prod VM e2-small + 30GB SSD | ~$15 | +| Dev VM e2-small + 30GB SSD | ~$15 | +| Persistent disk (50 GB) | ~$2 | +| Static IP (×2 — prod, dev) | ~$5 | +| Snapshots (daily, 30d retention) | ~$2 | +| Secret Manager | ~$0 (pod freetier) | +| **Celkem base** | **~$40/měs** | + +Škáluje lineárně s počtem dev VMs. + +## 11. Principy / Non-goals + +- ✅ **Public upstream zůstává public.** Nic, co zákazníka identifikuje, tam není. +- ✅ **Zákazník má plnou kontrolu svého nasazení.** Včetně rozhodnutí, zda upgradovat. +- ✅ **Žádná centrální Keboola ops infra.** Žádný sdílený GCP projekt, žádný sdílený state. +- ❌ **Není to multi-tenant** v jednom deploymentu. Jeden `docker compose up` = jeden zákazník. +- ❌ **Keboola není SaaS hostér** (aspoň ne teď). Pokud zákazník chce managed, je to ručně poskytnutá služba, ne produkt. +- ❌ **Žádný cross-customer routing.** Žádný sdílený load balancer, žádný sdílený DNS. + +## 12. Rozhodnutí a otázky + +Všechny designové otázky, které vznikly během brainstormingu, jsou vyřešené. Odkazy zde pro trasovatelnost: + +| Otázka | Rozhodnutí | +|---|---| +| Managed vs self-deploy | A) Pure self-deploy (mění se v Fázi 2+ pokud bude potřeba) | +| Centrální ops repo | Ne — 1 public + 1 template + N per-customer | +| TF state lokace | gs:// v zákaznickém GCP (default); flex na S3/TFC v template | +| Template repo název | `keboola/agnes-infra-template` | +| CI auth | SA JSON key v GH secret (Fáze 1); WIF (follow-up) | +| Image visibility | Public na GHCR | +| Prod upgrade režim | Per-instance volba auto/pinned, default auto | +| TLS | Caddy default, flex na gcp-lb/cloudflare | +| DNS | Zákazník si řeší sám, default jen IP | +| GCP projekt pro Keboola | `kids-ai-data-analysis` zůstává | +| Dev VM model | Seznam `dev_instances` v tfvars, per-položka image_tag | +| `ZdenekSrotyr/tmp_oss` | Smazat po Fázi 1 | + +## 13. Glosář + +| Zkratka | Význam | +|---|---| +| **GHCR** | GitHub Container Registry — ghcr.io | +| **WIF** | Workload Identity Federation — GCP mechanismus auth CI bez static key | +| **SA** | Service Account (GCP) | +| **TF** | Terraform | +| **OIDC** | OpenID Connect — auth protokol, GitHub vydává OIDC tokeny pro GHA | +| **CalVer** | Calendar Versioning — YYYY.MM.N | +| **PD** | Persistent Disk (GCP) | + +## 14. Follow-up iterace + +Mimo scope této první vlny, ale plánováno: + +- **WIF místo SA JSON key** (bezpečnost) +- **OS Login** (odstranění osobních SSH klíčů) +- **Monitoring + alerting** (Cloud Monitoring, Slack integration) +- **Automatické snapshoty** + restore procedura +- **Ephemeral PR preview environments** +- **AWS podpora** (paralelní TF modul) +- **Plugin API** pro proprietární customer extensions (viz issue #8) +- **Managed service varianta** (Keboola hostuje za zákazníka) + +## 15. Reference + +- Předchozí spec: `docs/superpowers/specs/2026-04-09-multi-instance-deployment-design.md` (CalVer release model) +- Issue: keboola/agnes-the-ai-analyst#8 — plugin API for private customer extensions