From 04084a8f0f29984a97ed81b97599913ea290ce61 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 16 Mar 2026 18:06:24 +0000 Subject: [PATCH] =?UTF-8?q?add=20deploy-app=20skill=20and=20agent=20for=20?= =?UTF-8?q?automated=20repo=E2=86=92app=20deployment=20[ci=20skip]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/deploy-app.md | 370 +++++++++++++++++++ .claude/skills/deploy-app/SKILL.md | 571 +++++++++++++++++++++++++++++ 2 files changed, 941 insertions(+) create mode 100644 .claude/agents/deploy-app.md create mode 100644 .claude/skills/deploy-app/SKILL.md diff --git a/.claude/agents/deploy-app.md b/.claude/agents/deploy-app.md new file mode 100644 index 00000000..c4399583 --- /dev/null +++ b/.claude/agents/deploy-app.md @@ -0,0 +1,370 @@ +--- +name: deploy-app +description: Deploy a GitHub repo as a running web app on the cluster with full CI/CD (GHA build, Woodpecker deploy, Terraform stack, DNS, TLS, auth). Use when given a GitHub URL or repo name to deploy. +tools: Read, Write, Edit, Bash, Grep, Glob, Agent, AskUserQuestion +model: opus +--- + +You are a deployment automation engineer. Your job is to take a GitHub repository and deploy it as a running web application on a Kubernetes cluster with full CI/CD. + +## Architecture + +``` +GitHub push → GHA builds Docker image → pushes DockerHub + → GHA POSTs Woodpecker API → Woodpecker runs kubectl set image + → K8s rolls out new deployment → app live at .viktorbarzin.me +``` + +## Environment + +- **Kubeconfig**: `/Users/viktorbarzin/code/config` (use `KUBECONFIG=/Users/viktorbarzin/code/config kubectl ...`) +- **Infra repo**: `/Users/viktorbarzin/code/infra` +- **Terraform apply**: `cd /Users/viktorbarzin/code/infra/stacks/ && ../../scripts/tg apply --non-interactive` +- **Vault**: `vault login -method=oidc` if needed, then `vault kv get` + +## Workflow + +Follow these 12 steps in order. Do NOT skip steps. Ask the user for input in Step 1, then execute the rest autonomously, pausing only for confirmation before Terraform apply and git push. + +### Step 1: Collect Information + +Ask the user for these fields. Auto-detect what you can from the repo first. + +| Field | Default | Notes | +|-------|---------|-------| +| `github_repo` | — | `owner/repo` or full URL (required) | +| `app_name` | repo name | K8s namespace/deployment name | +| `subdomain` | `app_name` | DNS subdomain (may differ from app_name) | +| `image_name` | `viktorbarzin/` | DockerHub image | +| `port` | 8000 | Container port | +| `database` | none | `postgresql` / `mysql` / `none` | +| `protected` | true | Authentik SSO gate | +| `env_vars` | `{}` | Key=value pairs | +| `needs_storage` | false | NFS persistent volume | + +**Auto-detect** via `gh api`: +```bash +OWNER="..." REPO="..." +DEFAULT_BRANCH=$(gh api repos/$OWNER/$REPO --jq '.default_branch') +gh api repos/$OWNER/$REPO/contents/Dockerfile --jq '.name' 2>/dev/null # Dockerfile exists? +gh api repos/$OWNER/$REPO/contents/package.json --jq '.name' 2>/dev/null # Node? +gh api repos/$OWNER/$REPO/contents/requirements.txt --jq '.name' 2>/dev/null # Python? +gh api repos/$OWNER/$REPO/contents/pyproject.toml --jq '.name' 2>/dev/null # Python? +gh api repos/$OWNER/$REPO/contents/go.mod --jq '.name' 2>/dev/null # Go? +``` + +Present detected values as defaults. Let user confirm or override. + +### Steps 2-4: Create CI Files via `gh` PR + +Create a branch, add files, create and merge a PR — all remote, no local clone. + +```bash +# Create branch from default branch HEAD +SHA=$(gh api repos/$OWNER/$REPO/git/ref/heads/$DEFAULT_BRANCH --jq '.object.sha') +gh api repos/$OWNER/$REPO/git/refs -X POST -f ref=refs/heads/ci-setup -f sha=$SHA +``` + +**Add these files** (upload each via GitHub API with base64 content): + +#### File 1: Dockerfile (only if missing) + +Generate based on project type: + +**Python** (requirements.txt): +```dockerfile +FROM python:3.13-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", ""] +``` + +**Node** (package.json): +```dockerfile +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=build /app . +EXPOSE +CMD ["node", "build"] +``` + +**Go** (go.mod): +```dockerfile +FROM golang:1.24 AS build +WORKDIR /app +COPY go.* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/server . + +FROM gcr.io/distroless/static +COPY --from=build /app/server /server +EXPOSE +CMD ["/server"] +``` + +#### File 2: `.woodpecker/deploy.yml` + +```yaml +when: + - event: [manual, push] + +steps: + - name: check-vars + image: alpine + commands: + - "[ -n \"$IMAGE_TAG\" ] || (echo 'IMAGE_TAG not set, skipping deploy'; exit 78)" + + - name: deploy + image: bitnami/kubectl:latest + commands: + - "kubectl set image deployment/ =${IMAGE_NAME}:${IMAGE_TAG} -n " + - "kubectl rollout status deployment/ -n --timeout=300s" + + - name: notify + image: woodpeckerci/plugin-slack + settings: + webhook: + from_secret: slack-webhook-url + channel: general + when: + - status: [success, failure] +``` + +#### File 3: `.github/workflows/build-and-deploy.yml` + +Use `REPO_ID_PLACEHOLDER` — replaced in Step 10. + +```yaml +name: Build and Deploy + +on: + push: + branches: [] + +env: + IMAGE_NAME: + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.sha }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - id: meta + run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT + - uses: docker/build-push-action@v6 + with: + push: true + platforms: linux/amd64 + tags: | + viktorbarzin/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha }} + viktorbarzin/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Trigger Woodpecker deploy + run: | + for attempt in 1 2 3; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "https://ci.viktorbarzin.me/api/repos/REPO_ID_PLACEHOLDER/pipelines" \ + -H "Authorization: Bearer ${{ secrets.WOODPECKER_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"branch":"","variables":{"IMAGE_TAG":"${{ needs.build.outputs.image_tag }}","IMAGE_NAME":"viktorbarzin/${{ env.IMAGE_NAME }}"}}') + if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then + echo "Woodpecker deploy triggered (HTTP $STATUS)" + exit 0 + fi + echo "Attempt $attempt failed (HTTP $STATUS), retrying in 30s..." + sleep 30 + done + echo "Failed to trigger Woodpecker deploy after 3 attempts" + exit 1 +``` + +**Upload each file:** +```bash +# Write file content to /tmp, then upload +gh api repos/$OWNER/$REPO/contents/ -X PUT \ + -f message="ci: add CI/CD pipeline" -f branch=ci-setup \ + -f content="$(base64 < /tmp/file)" +``` + +**Create and merge PR:** +```bash +gh pr create --repo $OWNER/$REPO --head ci-setup --base $DEFAULT_BRANCH \ + --title "ci: add CI/CD pipeline" --body "Adds GHA build + Woodpecker deploy pipeline" +gh pr merge --repo $OWNER/$REPO --merge --auto +``` + +The merge triggers GHA — build succeeds (pushes image), deploy fails harmlessly (404 from placeholder). This is intentional. + +### Step 5: Set GitHub Repo Secrets + +```bash +DOCKERHUB_USERNAME=$(vault kv get -field=docker_username secret/ci/global) +DOCKERHUB_TOKEN=$(vault kv get -field=dockerhub-pat secret/ci/global) +WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global) + +gh secret set DOCKERHUB_USERNAME --repo $OWNER/$REPO --body "$DOCKERHUB_USERNAME" +gh secret set DOCKERHUB_TOKEN --repo $OWNER/$REPO --body "$DOCKERHUB_TOKEN" +gh secret set WOODPECKER_TOKEN --repo $OWNER/$REPO --body "$WOODPECKER_TOKEN" +``` + +Verify: `gh secret list --repo $OWNER/$REPO` — must show 3 secrets. + +### Step 6: Create Terraform Stack + +Create `/Users/viktorbarzin/code/infra/stacks//` with: + +**`terragrunt.hcl`:** +```hcl +include "root" { + path = find_in_parent_folders() +} + +dependency "platform" { + config_path = "../platform" + skip_outputs = true +} + +dependency "vault" { + config_path = "../vault" + skip_outputs = true +} +``` + +**`main.tf`:** Generate with these resources: +- `kubernetes_namespace` — tier label `local.tiers.aux` +- `kubernetes_deployment`: + - `image = "viktorbarzin/:latest"`, `image_pull_policy = "Always"` + - `lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] }` (Kyverno ndots) + - `annotations = { "reloader.stakater.com/auto" = "true" }` + - Resources: **256Mi** request=limit, **10m** CPU request + - Port, env vars, optional volume mounts +- `kubernetes_service` — port 80 → container port, name = subdomain +- `module "tls_secret"` from `../../modules/kubernetes/setup_tls_secret` +- `module "ingress"` from `../../modules/kubernetes/ingress_factory` — set `protected` flag + +**Conditional resources:** +- If database or secrets needed: `kubernetes_manifest` ExternalSecret from `vault-kv` ClusterSecretStore +- If needs_storage: `module "nfs_data"` from `../../modules/kubernetes/nfs_volume` + +Reference `/Users/viktorbarzin/code/infra/stacks/f1-stream/main.tf` for exact HCL patterns. + +### Step 7: Add DNS Entry + +Edit `/Users/viktorbarzin/code/infra/terraform.tfvars`: +- If `protected`: add `""` to `cloudflare_proxied_names` (line ~1154) +- If not protected: add `""` to `cloudflare_non_proxied_names` (line ~1157) + +### Step 8: Apply Terraform + +**Ask user for confirmation before applying.** + +```bash +cd /Users/viktorbarzin/code/infra/stacks/ && ../../scripts/tg apply --non-interactive +cd /Users/viktorbarzin/code/infra/stacks/platform && ../../scripts/tg apply --non-interactive +``` + +Verify: +```bash +KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n +KUBECONFIG=/Users/viktorbarzin/code/config kubectl get svc -n +``` + +### Step 9: Activate Woodpecker Repo + +```bash +WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global) +GITHUB_REPO_ID=$(gh api repos/$OWNER/$REPO --jq '.id') + +# Try API activation +curl -s -X POST "https://ci.viktorbarzin.me/api/repos" \ + -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"forge_remote_id\":\"$GITHUB_REPO_ID\"}" + +# Get Woodpecker numeric repo ID +WP_REPO_ID=$(curl -s -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + "https://ci.viktorbarzin.me/api/repos/lookup/$OWNER/$REPO" | jq '.id') +echo "Woodpecker repo ID: $WP_REPO_ID" +``` + +If API activation fails, tell the user to activate via `https://ci.viktorbarzin.me` UI. + +### Step 10: Update GHA Workflow with Real Repo ID + +```bash +FILE_SHA=$(gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \ + --jq '.sha' -H "Accept: application/vnd.github.v3+json") + +gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \ + --jq '.content' | base64 -d | sed "s/REPO_ID_PLACEHOLDER/$WP_REPO_ID/" | base64 > /tmp/workflow.b64 + +gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \ + -X PUT -f message="ci: set Woodpecker repo ID ($WP_REPO_ID)" \ + -f content="$(cat /tmp/workflow.b64)" -f sha="$FILE_SHA" +``` + +This triggers the first full build→deploy cycle. + +### Step 11: Verify End-to-End + +1. Watch GHA: `gh run watch --repo $OWNER/$REPO` +2. Check Woodpecker: query API for latest pipeline status +3. Check pod: `KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n -o jsonpath='{..image}'` +4. Check URL: `curl -sI https://.viktorbarzin.me` + +### Step 12: Commit Infra Changes + +**Ask user for confirmation before pushing.** + +```bash +cd /Users/viktorbarzin/code/infra +git add stacks// terraform.tfvars +git commit -m "$(cat <<'EOF' +add stack and DNS entry [ci skip] +EOF +)" +git push origin master +``` + +## Critical Rules + +- **Woodpecker API uses numeric repo IDs** — NOT owner/name paths +- **Global secrets need `manual` in allowed events** — already configured +- **Docker images must be `linux/amd64`** +- **Use 8-char SHA tags** — `:latest` causes stale pull-through cache +- **`image_pull_policy = "Always"`** required for CI updates +- **Always add `lifecycle { ignore_changes = [dns_config] }`** on deployments +- **256Mi memory default** — 128Mi causes OOM for many apps +- **Never skip the lifecycle block** — Kyverno injects dns_config and causes perpetual TF drift + +## NEVER Do + +- Never clone repos locally — use `gh` API for all remote repo operations +- Never `kubectl apply/edit/patch` raw manifests — all changes through Terraform +- Never push to git without user confirmation +- Never delete PVCs or PVs +- Never hardcode secrets in Terraform — use Vault + ExternalSecrets diff --git a/.claude/skills/deploy-app/SKILL.md b/.claude/skills/deploy-app/SKILL.md new file mode 100644 index 00000000..460c9e1b --- /dev/null +++ b/.claude/skills/deploy-app/SKILL.md @@ -0,0 +1,571 @@ +--- +name: deploy-app +description: Deploy a GitHub repo as a running web app on the cluster with full CI/CD. + Use when the user says "deploy app", "deploy repo", "set up CI/CD for", or provides + a GitHub URL and wants it running on the cluster. Handles Dockerfile, GHA build, + Woodpecker deploy, Terraform stack, DNS, TLS, and auth — end to end. +--- + +# deploy-app Skill + +Deploy a GitHub repository as a running web application on the Kubernetes cluster with full CI/CD. + +**Architecture**: `GitHub push → GHA builds Docker image → pushes DockerHub → POSTs Woodpecker API → kubectl set image → app live at .viktorbarzin.me` + +## Checklist + +- [ ] Step 1: Collect information (interactive prompts) +- [ ] Step 2-4: Create CI files via `gh` PR (Dockerfile, Woodpecker, GHA) +- [ ] Step 5: Set GitHub repo secrets +- [ ] Step 6: Create Terraform stack +- [ ] Step 7: Add DNS entry to terraform.tfvars +- [ ] Step 8: Apply Terraform +- [ ] Step 9: Activate Woodpecker repo +- [ ] Step 10: Update GHA workflow with real repo ID +- [ ] Step 11: Verify end-to-end +- [ ] Step 12: Commit infra changes + +--- + +## Step 1: Collect Information + +Prompt the user for each field. Auto-detect what you can from the repo. + +| Field | Default | Notes | +|-------|---------|-------| +| `github_repo` | — | `owner/repo` or full URL (required) | +| `app_name` | repo name | K8s namespace/deployment name | +| `subdomain` | `app_name` | DNS subdomain (may differ, e.g. f1-stream uses `f1`) | +| `image_name` | `viktorbarzin/` | DockerHub image | +| `port` | 8000 | Container port | +| `database` | none | `postgresql` / `mysql` / `none` | +| `protected` | true | Authentik SSO gate | +| `env_vars` | `{}` | Key=value pairs for the container | +| `needs_storage` | false | NFS persistent volume | + +**Auto-detect from repo** (use `gh api`): +- Project type: check for `package.json`, `requirements.txt`/`pyproject.toml`, `go.mod` +- Default branch +- Whether a Dockerfile already exists + +```bash +# Example detection +OWNER="..." REPO="..." +DEFAULT_BRANCH=$(gh api repos/$OWNER/$REPO --jq '.default_branch') +gh api repos/$OWNER/$REPO/contents/Dockerfile --jq '.name' 2>/dev/null && echo "Dockerfile exists" +gh api repos/$OWNER/$REPO/contents/package.json --jq '.name' 2>/dev/null && echo "Node project" +gh api repos/$OWNER/$REPO/contents/requirements.txt --jq '.name' 2>/dev/null && echo "Python project" +gh api repos/$OWNER/$REPO/contents/pyproject.toml --jq '.name' 2>/dev/null && echo "Python project (pyproject)" +gh api repos/$OWNER/$REPO/contents/go.mod --jq '.name' 2>/dev/null && echo "Go project" +``` + +Present detected values as defaults, let user confirm or override. + +--- + +## Steps 2-4: Create CI Files via `gh` PR + +Create all CI files in the remote repo without cloning locally. Use `gh` CLI to create a branch, add files, and merge a PR. + +### Create the branch + +```bash +DEFAULT_BRANCH=$(gh api repos/$OWNER/$REPO --jq '.default_branch') +SHA=$(gh api repos/$OWNER/$REPO/git/ref/heads/$DEFAULT_BRANCH --jq '.object.sha') +gh api repos/$OWNER/$REPO/git/refs -X POST -f ref=refs/heads/ci-setup -f sha=$SHA +``` + +### File 1: Dockerfile (only if missing) + +Generate based on detected project type: + +**Python** (requirements.txt or pyproject.toml): +```dockerfile +FROM python:3.13-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE {{PORT}} +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "{{PORT}}"] +``` + +**Node** (package.json): +```dockerfile +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=build /app . +EXPOSE {{PORT}} +CMD ["node", "build"] +``` + +**Go** (go.mod): +```dockerfile +FROM golang:1.24 AS build +WORKDIR /app +COPY go.* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/server . + +FROM gcr.io/distroless/static +COPY --from=build /app/server /server +EXPOSE {{PORT}} +CMD ["/server"] +``` + +### File 2: `.woodpecker/deploy.yml` + +```yaml +when: + - event: [manual, push] + +steps: + - name: check-vars + image: alpine + commands: + - "[ -n \"$IMAGE_TAG\" ] || (echo 'IMAGE_TAG not set, skipping deploy'; exit 78)" + + - name: deploy + image: bitnami/kubectl:latest + commands: + - "kubectl set image deployment/{{APP_NAME}} {{APP_NAME}}=${IMAGE_NAME}:${IMAGE_TAG} -n {{APP_NAME}}" + - "kubectl rollout status deployment/{{APP_NAME}} -n {{APP_NAME}} --timeout=300s" + + - name: notify + image: woodpeckerci/plugin-slack + settings: + webhook: + from_secret: slack-webhook-url + channel: general + when: + - status: [success, failure] +``` + +### File 3: `.github/workflows/build-and-deploy.yml` + +Use `REPO_ID_PLACEHOLDER` — will be replaced in Step 10 after Woodpecker activation. + +```yaml +name: Build and Deploy + +on: + push: + branches: [{{DEFAULT_BRANCH}}] + +env: + IMAGE_NAME: {{APP_NAME}} + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.sha }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - id: meta + run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT + - uses: docker/build-push-action@v6 + with: + push: true + platforms: linux/amd64 + tags: | + viktorbarzin/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha }} + viktorbarzin/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Trigger Woodpecker deploy + run: | + for attempt in 1 2 3; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "https://ci.viktorbarzin.me/api/repos/REPO_ID_PLACEHOLDER/pipelines" \ + -H "Authorization: Bearer ${{ secrets.WOODPECKER_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"branch":"{{DEFAULT_BRANCH}}","variables":{"IMAGE_TAG":"${{ needs.build.outputs.image_tag }}","IMAGE_NAME":"viktorbarzin/${{ env.IMAGE_NAME }}"}}') + if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then + echo "Woodpecker deploy triggered (HTTP $STATUS)" + exit 0 + fi + echo "Attempt $attempt failed (HTTP $STATUS), retrying in 30s..." + sleep 30 + done + echo "Failed to trigger Woodpecker deploy after 3 attempts" + exit 1 +``` + +### Upload files and create PR + +For each file, upload via GitHub API: + +```bash +# Upload a file (base64-encoded) +gh api repos/$OWNER/$REPO/contents/PATH -X PUT \ + -f message="ci: add CI/CD pipeline" -f branch=ci-setup \ + -f content="$(base64 < /tmp/file)" +``` + +Then create and merge the PR: + +```bash +gh pr create --repo $OWNER/$REPO --head ci-setup --base $DEFAULT_BRANCH \ + --title "ci: add CI/CD pipeline" --body "Adds GHA build + Woodpecker deploy pipeline" +gh pr merge --repo $OWNER/$REPO --merge --auto +``` + +**Note**: Merging triggers GHA — build job succeeds (pushes Docker image), deploy job fails gracefully (404 from placeholder). This is intentional so the image exists before Terraform creates the deployment. + +--- + +## Step 5: Set GitHub Repo Secrets + +```bash +# Get values from Vault +DOCKERHUB_USERNAME=$(vault kv get -field=docker_username secret/ci/global) +DOCKERHUB_TOKEN=$(vault kv get -field=dockerhub-pat secret/ci/global) +WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global) + +# Set via gh CLI +gh secret set DOCKERHUB_USERNAME --repo $OWNER/$REPO --body "$DOCKERHUB_USERNAME" +gh secret set DOCKERHUB_TOKEN --repo $OWNER/$REPO --body "$DOCKERHUB_TOKEN" +gh secret set WOODPECKER_TOKEN --repo $OWNER/$REPO --body "$WOODPECKER_TOKEN" +``` + +Verify: `gh secret list --repo $OWNER/$REPO` — should show 3 secrets. + +--- + +## Step 6: Create Terraform Stack + +Create `/Users/viktorbarzin/code/infra/stacks/{{APP_NAME}}/` with two files. + +### `terragrunt.hcl` + +```hcl +include "root" { + path = find_in_parent_folders() +} + +dependency "platform" { + config_path = "../platform" + skip_outputs = true +} + +dependency "vault" { + config_path = "../vault" + skip_outputs = true +} +``` + +### `main.tf` + +Generate based on collected information. Use the f1-stream stack as the primary reference, plus the template's lifecycle block. + +**Minimum required resources:** +- `kubernetes_namespace` with tier label (`aux`) +- `kubernetes_deployment` with: + - `image = "viktorbarzin/{{IMAGE_NAME}}:latest"` (initial; CI updates via `kubectl set image`) + - `image_pull_policy = "Always"` + - `lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] }` (Kyverno ndots drift) + - `annotations = { "reloader.stakater.com/auto" = "true" }` (auto-restart on secret changes) + - Resources: **256Mi** request=limit, **10m** CPU request + - Port, env vars, optional volume mounts +- `kubernetes_service` (port 80 → container port) +- `module "tls_secret"` from `../../modules/kubernetes/setup_tls_secret` +- `module "ingress"` from `../../modules/kubernetes/ingress_factory` with `protected` flag + +**Conditional resources:** +- If `database != "none"` or `env_vars` has secrets: add `kubernetes_manifest` ExternalSecret from `vault-kv` ClusterSecretStore, key = `{{APP_NAME}}` +- If `needs_storage`: add `module "nfs_data"` from `../../modules/kubernetes/nfs_volume` + +**Example main.tf** (adapt per collected info): + +```hcl +variable "tls_secret_name" { + type = string + sensitive = true +} +# Add variable "nfs_server" { type = string } if needs_storage + +resource "kubernetes_namespace" "{{APP_NAME}}" { + metadata { + name = "{{APP_NAME}}" + labels = { + "istio-injection" : "disabled" + tier = local.tiers.aux + } + } +} + +# Include ExternalSecret block if database or secrets needed: +# resource "kubernetes_manifest" "external_secret" { +# manifest = { +# apiVersion = "external-secrets.io/v1beta1" +# kind = "ExternalSecret" +# metadata = { +# name = "{{APP_NAME}}-secrets" +# namespace = "{{APP_NAME}}" +# } +# spec = { +# refreshInterval = "15m" +# secretStoreRef = { +# name = "vault-kv" +# kind = "ClusterSecretStore" +# } +# target = { +# name = "{{APP_NAME}}-secrets" +# } +# dataFrom = [{ +# extract = { +# key = "{{APP_NAME}}" +# } +# }] +# } +# } +# depends_on = [kubernetes_namespace.{{APP_NAME}}] +# } + +resource "kubernetes_deployment" "{{APP_NAME}}" { + metadata { + name = "{{APP_NAME}}" + namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name + labels = { + app = "{{APP_NAME}}" + tier = local.tiers.aux + } + annotations = { + "reloader.stakater.com/auto" = "true" + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = "{{APP_NAME}}" + } + } + template { + metadata { + labels = { + app = "{{APP_NAME}}" + } + } + spec { + container { + image = "viktorbarzin/{{IMAGE_NAME}}:latest" + image_pull_policy = "Always" + name = "{{APP_NAME}}" + resources { + limits = { + memory = "256Mi" + } + requests = { + cpu = "10m" + memory = "256Mi" + } + } + port { + container_port = {{PORT}} + } + # Add env blocks for env_vars here + # For secret refs: + # env { + # name = "DB_PASSWORD" + # value_from { + # secret_key_ref { + # name = "{{APP_NAME}}-secrets" + # key = "db_password" + # } + # } + # } + } + } + } + } + lifecycle { + ignore_changes = [spec[0].template[0].spec[0].dns_config] + } +} + +resource "kubernetes_service" "{{APP_NAME}}" { + metadata { + name = "{{SUBDOMAIN}}" + namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name + labels = { + "app" = "{{APP_NAME}}" + } + } + spec { + selector = { + app = "{{APP_NAME}}" + } + port { + port = "80" + target_port = "{{PORT}}" + } + } +} + +module "tls_secret" { + source = "../../modules/kubernetes/setup_tls_secret" + namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name + tls_secret_name = var.tls_secret_name +} + +module "ingress" { + source = "../../modules/kubernetes/ingress_factory" + namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name + name = "{{SUBDOMAIN}}" + tls_secret_name = var.tls_secret_name + # protected = true # Set based on user choice +} +``` + +--- + +## Step 7: Add DNS Entry + +Edit `/Users/viktorbarzin/code/infra/terraform.tfvars`: + +- If `protected` (Authentik-gated): add `"{{SUBDOMAIN}}"` to `cloudflare_proxied_names` (line ~1154) +- If public/not protected: add `"{{SUBDOMAIN}}"` to `cloudflare_non_proxied_names` (line ~1157) + +--- + +## Step 8: Apply Terraform + +```bash +cd /Users/viktorbarzin/code/infra + +# Ensure Vault auth +vault login -method=oidc # if needed + +# Apply the new stack +cd stacks/{{APP_NAME}} && ../../scripts/tg apply --non-interactive + +# Apply platform to create the DNS record +cd ../platform && ../../scripts/tg apply --non-interactive +``` + +Verify: +```bash +KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n {{APP_NAME}} +KUBECONFIG=/Users/viktorbarzin/code/config kubectl get svc -n {{APP_NAME}} +curl -s -o /dev/null -w "%{http_code}" https://{{SUBDOMAIN}}.viktorbarzin.me +``` + +--- + +## Step 9: Activate Woodpecker Repo + +Try API-first (fully automated): + +```bash +WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global) +GITHUB_REPO_ID=$(gh api repos/$OWNER/$REPO --jq '.id') + +curl -s -X POST "https://ci.viktorbarzin.me/api/repos" \ + -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"forge_remote_id\":\"$GITHUB_REPO_ID\"}" +``` + +If API activation fails, guide the user to activate via `https://ci.viktorbarzin.me` UI. + +Then get the Woodpecker numeric repo ID: + +```bash +WP_REPO_ID=$(curl -s -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + "https://ci.viktorbarzin.me/api/repos/lookup/$OWNER/$REPO" | jq '.id') +echo "Woodpecker repo ID: $WP_REPO_ID" +``` + +--- + +## Step 10: Update GHA Workflow with Real Repo ID + +Replace `REPO_ID_PLACEHOLDER` in the GHA workflow remotely: + +```bash +# Get current file SHA +FILE_SHA=$(gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \ + --jq '.sha' -H "Accept: application/vnd.github.v3+json") + +# Download, replace placeholder, re-upload +gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \ + --jq '.content' | base64 -d | sed "s/REPO_ID_PLACEHOLDER/$WP_REPO_ID/" | base64 > /tmp/workflow.b64 + +gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \ + -X PUT -f message="ci: set Woodpecker repo ID ($WP_REPO_ID)" \ + -f content="$(cat /tmp/workflow.b64)" -f sha="$FILE_SHA" +``` + +This commit triggers the first full build→deploy cycle. + +--- + +## Step 11: Verify End-to-End + +1. Wait for GHA build: `gh run watch --repo $OWNER/$REPO` +2. Check Woodpecker deploy triggered: + ```bash + curl -s -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + "https://ci.viktorbarzin.me/api/repos/$WP_REPO_ID/pipelines?page=1&per_page=1" | jq '.[0].status' + ``` +3. Check pod running with new image: + ```bash + KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n {{APP_NAME}} -o jsonpath='{..image}' + ``` +4. Check URL: `curl -sI https://{{SUBDOMAIN}}.viktorbarzin.me` + +--- + +## Step 12: Commit Infra Changes + +```bash +cd /Users/viktorbarzin/code/infra +git add stacks/{{APP_NAME}}/ terraform.tfvars +git commit -m "$(cat <<'EOF' +add {{APP_NAME}} stack and DNS entry [ci skip] +EOF +)" +git push origin master +``` + +--- + +## Chicken-and-Egg Resolution + +The Woodpecker repo ID is needed in GHA but only exists after activation: + +1. **PR merge** (Steps 2-4): GHA workflow with `REPO_ID_PLACEHOLDER`. Build succeeds (image pushed), deploy fails harmlessly (404). +2. **Terraform** (Step 8): Creates deployment using `:latest` — the image from the first build. +3. **Activation** (Step 9): Woodpecker repo activated, deploy.yml already in place. +4. **API update** (Step 10): Real repo ID patched into workflow → first full CI/CD cycle succeeds. + +## Important Notes + +- **Woodpecker API uses numeric repo IDs** (`/api/repos/10/pipelines`), NOT owner/name paths +- **Global secrets must have `manual` in allowed events** for API-triggered pipelines (already configured) +- **Docker images must be `linux/amd64`** — the cluster runs amd64 +- **Use 8-char SHA tags** — `:latest` causes stale pull-through cache issues +- **`image_pull_policy = "Always"`** is required for CI image updates to take effect +- **Kyverno ndots drift**: Always add `lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] }` +- **Vault KV path for secrets**: `secret/{{APP_NAME}}` — create via `vault kv put secret/{{APP_NAME}} KEY=value` if needed +- **256Mi memory default** is safer than 128Mi (many apps OOM at 128Mi)