arcane@prod : ~/infra
online

// infra

Infrastructure

arcane@prod:~$ cat infra/**

How this page reaches you — the actual infrastructure, read straight from the repo at build time (no hand-copied snippets, so it can never drift).

A single-node k3s box behind a Cloudflare Tunnel, GitOps-deployed: push to main, CI builds the image, ArgoCD syncs the cluster.

living diagrams

The same system as two interactive maps — every node and edge sourced from the manifests and code below, nothing decorative. Hover to preview, click to pin.

the request path

How a request reaches this page and what talks to what — read from the manifests in this repo (Sprints H-I).

select a component — hover previews, click pins, Escape clears

full text: nodes, edges and facts — the request path
  • visitor (you, now)
    • This page render is itself a walk of this diagram.
  • Cloudflare edge (DNS · TLS · WAF)
    • gipc.dev + www resolve at Cloudflare; TLS terminates at the edge.
    • Origin is reachable ONLY via the tunnel — no inbound ports exposed on the origin host (the tunnel dials out).
  • cloudflared (host systemd — the tunnel)
    • A host systemd process, not a cluster workload (infra/cloudflared/config.yml).
    • Outbound-only tunnel to Cloudflare; tunnel id redacted from this page by policy.
  • k8s API (k3s server)
    • Single-node k3s; the API server runs on the same host.
  • Caddy (ingress · CSP · headers)
    • Route table (caddy.yaml): /api/ai/* → ai:8000 · /api/* → core:8080 · everything else → web:80.
    • Sets the CSP + security headers site-wide; -Server header stripped.
  • web (Next.js 15)
    • Static-first App Router; SSR pages seed from core in-cluster.
  • core (Go · stdlib)
    • One external dep (lib/pq, Sprint H); everything else stdlib.
    • Boot-independent: serves /api/healthz with every dependency down.
  • ai (FastAPI · RAG)
    • The oracle's tools also GET the site's own public APIs via https://gipc.dev (config.py core_base) — a real loopback through this whole diagram.
  • ollama (qwen2.5:0.5b-instruct)
    • Self-hosted model server, in-cluster only (ollama:11434).
  • prometheus
    • Scrapes the cluster; core's /system panels run fixed PromQL against it.
  • loki
    • Log store; promtail labels streams namespace/pod/container.
  • postgres (pgvector · gipc_ai)
    • pgvector/pgvector:pg16 (postgres.yaml); the RAG corpus lives here.
    • NetworkPolicy: only ns gipc may connect (port 5432).
  • demo-db (disposable postgres)
    • postgres:16.9-alpine on emptyDir — pod delete = full reseed (synthetic data).
    • The Lab DB explorer's target; SELECT-only role, 6-query allowlist.
  • chaos-target (×3 echo pods)
    • nginx-unprivileged ×3 — the chaos button's kill target and the load test's backstop.
    • demo ns is netpol-isolated: ingress only from ns gipc, egress DNS + intra-demo.
  • visitor Cloudflare edge: HTTPS · gipc.dev
  • Cloudflare edge cloudflared: tunnel — outbound-only, no inbound ports exposed
  • cloudflared Caddy: http://localhost:30082 (Caddy NodePort)
  • Caddy web: fallback route → web:80
  • Caddy core: /api/* → core:8080
  • Caddy ai: /api/ai/* → ai:8000 (SSE: flush_interval -1)
  • web core: SSR seeds fetch http://core:8080 (system/status pages)
  • core web: uptime probe → http://web:80 (WEB_URL)
  • core prometheus: fixed PromQL · prometheus.observability:9090
  • core loki: fixed LogQL · loki.observability:3100
  • core k8s API: pod reads for /api/topology — Roles topology-pod-reader (gipc/observability/data) + chaos (demo: list/delete — the Lab kill's real path)
  • core demo-db: Lab DB explorer · demo-db.demo:5432 · demo_ro SELECT-only
  • core chaos-target: load test HTTP (LOAD_TARGET_URL) · netpol ingress-from-gipc; the chaos kill lands here via the k8s API (Role chaos)
  • ai postgres: RAG retrieval · postgres.data:5432 · netpol: gipc-only ingress
  • ai ollama: /api/ai/infer demo only · ollama:11434 · qwen2.5:0.5b-instruct

the oracle's RAG pipeline

How the oracle answers: the real modules in services/ai, in the order a question flows through them (Sprints H-I).

select a component — hover previews, click pins, Escape clears

full text: nodes, edges and facts — the oracle's RAG pipeline
  • corpus sources (résumé · projects · site · code)
    • corpus.py loads resume.json, projects.json, site.md; code_corpus.py bakes annotated code excerpts (code-manifest.json) at build.
    • No hidden sources — what the oracle knows is exactly this list.
  • ingest Job (python -m app.ingest)
    • A k8s Job (ingest-job.yaml) — chunks the corpus (62 chunks, 36 of them code, at the last ingest) and upserts embeddings in one transaction.
  • embedder.py (bge-small-en-v1.5)
    • BAAI/bge-small-en-v1.5 via fastembed (ONNX, CPU) — 384-dim vectors, baked into the image at build.
  • pgvector (chunks · ns data)
    • The chunks table in the data-ns postgres (gipc_ai) — cosine distance via the <=> operator.
  • retrieval.py (top-k 6 · code cap 2)
    • TOP_K=6; the oracle's auto-context admits at most CODE_CAP=2 code chunks (dilution guard); fixed SQL shape, embedding passed as a literal.
  • oracle.py (assembly + budget)
    • Trims history (6 turns / 4k chars), builds the user turn, enforces budget.py's fail-closed daily cost breaker + per-IP limits.
  • Anthropic API (claude-haiku-4-5)
    • Generation is the Anthropic Messages API (llm.py) — model claude-haiku-4-5, streamed.
    • The self-hosted Ollama serves only the separate /api/ai/infer demo — not this pipeline.
  • tool loop (tools.py · ≤4 rounds)
    • The model may call fixed tools (search_corpus, the public site APIs, show_station — the Construct hook) for at most tool_rounds_max=4 rounds — then it must answer.
  • SSE → Oracle UI (sse.py)
    • Tokens stream to the browser as server-sent events through Caddy (flush_interval -1).
  • evals.py (published, real)
    • retrieval hit@6 0.812 · MRR 0.565 (n=16)
    • faithfulness 0.925 over 146 graded claims
    • Full dashboard with misses + methodology: /oracle (evals tab).
  • corpus sources ingest Job: chunking — headers + notes carry retrieval semantics
  • ingest Job embedder.py: embed each chunk (384-dim)
  • embedder.py pgvector: upsert vectors — single transaction, stale rows removed
  • pgvector retrieval.py: cosine top-k (<=> operator)
  • retrieval.py oracle.py: auto-context: 6 chunks, ≤2 code
  • oracle.py Anthropic API: prompt + tools, streamed
  • Anthropic API tool loop: tool calls — answered locally, fed back
  • Anthropic API SSE → Oracle UI: token stream
  • evals.py retrieval.py: exercises the REAL pipeline — results committed to evals.json, shown on /oracle

CI/CD — build & deploy the web app

.github/workflows/web.yml

GitHub Actions builds the Next.js image, pushes it to GHCR, then pins the tag into kustomize with [skip ci]; ArgoCD on the box watches main and syncs. Push to main = deploy.

name: web image

on:
  push:
    branches: [main]
    paths:
      - "apps/web/**"
      - "packages/**"
      - "package.json"
      - "package-lock.json"
      - ".github/workflows/web.yml"
  workflow_dispatch:

concurrency:
  group: web-image
  cancel-in-progress: false

env:
  IMAGE: ghcr.io/gabrielipcarvalho/gipc-web

jobs:
  build:
    runs-on: ubuntu-24.04-arm
    permissions:
      contents: write     # to commit the GitOps tag bump
      packages: write      # to push to GHCR
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & push image
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          file: apps/web/Dockerfile
          platforms: linux/arm64
          push: true
          tags: |
            ${{ env.IMAGE }}:${{ github.sha }}
            ${{ env.IMAGE }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: GitOps — pin manifest to this image (deploy feed)
        id: pin
        run: |
          # rebase-retry: two workflows can race to pin on the same push; the loser fetches the
          # winner's commit, re-applies its own tag, and retries. FETCH_HEAD is fresh on a shallow clone.
          # BRANCH-AWARE (ai/core pattern): workflow_dispatch can run from a test branch — pin the
          # triggering ref, never hardcode main.
          git config user.name  "gipc-ci"
          git config user.email "ci@gipc.dev"
          for attempt in 1 2 3 4 5; do
            git fetch origin "$GITHUB_REF_NAME"
            git reset --hard FETCH_HEAD
            sed -i "s|newTag: .*|newTag: \"${GITHUB_SHA}\"|" infra/k8s/web/kustomization.yaml
            if git diff --quiet; then echo "already pinned"; exit 0; fi
            git commit -am "deploy(web): ${GITHUB_SHA:0:7} → gipc.dev [skip ci]"
            if git push origin HEAD:"$GITHUB_REF_NAME"; then echo "pinned (attempt $attempt)"; exit 0; fi
            echo "push rejected — another workflow pushed; retrying"
            [ "$attempt" -lt 5 ] && sleep $((attempt * 3))
          done
          echo "::error::failed to pin web after 5 attempts"; exit 1

      - name: Notify deploy feed
        # post whenever the image built (even if the pin retried/failed), but never on a cancelled run.
        if: ${{ !cancelled() && github.ref == 'refs/heads/main' && steps.build.outcome == 'success' }}
        env:
          DEPLOY_HOOK_KEY: ${{ secrets.DEPLOY_HOOK_KEY }}
          # released reflects the PIN outcome — a failed pin = manifest not bumped = site NOT on the new image
          RELEASED_STATUS: ${{ steps.pin.outcome == 'success' && 'success' || 'failure' }}
        run: |
          [ -z "$DEPLOY_HOOK_KEY" ] && { echo "no key"; exit 0; }
          SUBJECT=$(git log -1 --format=%s "$GITHUB_SHA"); TS=$(date -u +%FT%TZ)
          post() {   # $1 stage, $2 status — jq builds subject-safe JSON; sign the exact bytes sent
            body=$(jq -nc --arg sha "$GITHUB_SHA" --arg subject "$SUBJECT" --arg stage "$1" --arg status "$2" --arg ts "$TS" \
              '{sha:$sha,subject:$subject,stage:$stage,status:$status,ts:$ts}')
            sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$DEPLOY_HOOK_KEY" | awk '{print $2}')
            curl -fsS -X POST -H "X-Signature: sha256=$sig" -H 'Content-Type: application/json' --data "$body" https://gipc.dev/api/hooks/deploy || true
          }
          post build success
          post released "$RELEASED_STATUS"

Go core — Deployment, Service & config

infra/k8s/core/core.yaml

The stdlib Go backend: single replica, distroless non-root, read-only rootfs, 12-factor config via ConfigMap. The HMAC secret is referenced by name, never inlined.

apiVersion: v1
kind: ConfigMap
metadata:
  name: core-config
  namespace: gipc
data:
  PORT: "8080"
  CORS_ORIGIN: "https://gipc.dev"
  RATE_LIMIT_RPS: "10"
  RATE_LIMIT_BURST: "20"
  PROMETHEUS_URL: "http://prometheus.observability:9090"   # used from P3
  LOKI_URL: "http://loki.observability:3100"               # P6 log surface (cross-ns ClusterIP)
  WEB_URL: "http://web:80"                                  # P7 uptime probe target
  UPTIME_INTERVAL: "30s"                                    # P7 probe cadence
  SHUTDOWN_TIMEOUT: "25s"
  LAB_ENABLED: "true"                                      # M5 — chaos/lab handlers active (P2)
  LAB_NAMESPACE: "demo"                                    # the ONLY ns core's k8s client touches
  CHAOS_RPS: "0.1"                                         # chaos-kill per-IP cooldown ≈ 1 / 10s
  CHAOS_BURST: "1"                                         # single-flight per IP
  LOAD_TARGET_URL: "http://chaos-target.demo"             # FIXED load target (isolated demo echo) — no SSRF
  LOAD_MAX_CONCURRENCY: "50"                              # code-clamped ceiling
  LOAD_MAX_SECONDS: "10"                                  # code-clamped ceiling
  LOAD_MAX_RUNS: "4"                                      # global concurrent-run ceiling
  LOAD_RPS: "0.2"                                         # per-IP cooldown ≈ 1 run / 5s
  LOAD_BURST: "1"
  LAB_EVENT_HEARTBEAT: "10s"                              # /api/lab/events heartbeat cadence
  DB_RPS: "0.5"                                           # db-explorer per-IP cooldown ≈ 1 run / 2s
  DB_BURST: "2"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: core
  namespace: gipc
  labels: { app: core }
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  selector:
    matchLabels: { app: core }
  template:
    metadata:
      labels: { app: core }
    spec:
      serviceAccountName: core-lab        # M5 — SA whose namespaced Role (ns demo) grants pods list/delete
      terminationGracePeriodSeconds: 30   # ≥ SHUTDOWN_TIMEOUT so /api/* drains on a roll
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532        # distroless:nonroot
        fsGroup: 65532
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: core
          image: ghcr.io/gabrielipcarvalho/gipc-core:latest   # tag pinned by CI via kustomization
          ports:
            - { containerPort: 8080, name: http }
          envFrom:
            - configMapRef: { name: core-config }
          env:
            - name: DEPLOY_HOOK_KEY   # HMAC key for /api/hooks/deploy — imperative Secret, never committed
              valueFrom:
                secretKeyRef: { name: deploy-hook, key: hmac }
            - name: DEMO_DB_URL       # Lab DB explorer DSN (demo-ns toy postgres) — imperative Secret;
              valueFrom:              # optional: a missing Secret must never block boot (explorer 503s)
                secretKeyRef: { name: demo-db-url, key: url, optional: true }
          readinessProbe:
            httpGet: { path: /api/readyz, port: 8080 }
            initialDelaySeconds: 3
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /api/healthz, port: 8080 }
            initialDelaySeconds: 5
            periodSeconds: 20
          resources:
            requests: { cpu: 25m, memory: 32Mi }
            limits: { cpu: 200m, memory: 128Mi }
          securityContext:               # static binary writes nothing → full lockdown
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
  name: core
  namespace: gipc
  labels: { app: core }
spec:
  type: ClusterIP
  selector: { app: core }
  ports:
    - { port: 8080, targetPort: 8080, name: http }

Kustomize — the image pin CI writes

infra/k8s/core/kustomization.yaml

CI rewrites the built image tag here; ArgoCD reconciles the cluster to match. This file is the deploy handshake.

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# One app manages core + caddy. Each manifest sets its own namespace: gipc (no namespace directive here).
# Caddy's image is pinned inline in ../caddy (no images: entry), so CI's tag-pin sed matches only the
# core image line below. Keep comments free of the literal tag-key token so the sed can't match them.
resources:
  - core.yaml
  - core-lab-sa.yaml
  - core-topology-rbac.yaml
  - ../caddy
images:
  - name: ghcr.io/gabrielipcarvalho/gipc-core
    newTag: "e027f2e93fdfecc01985f4f2a0557eb6f84e8227"

Caddy — reverse proxy & metrics

infra/k8s/caddy/caddy.yaml

Fronts the cluster inside k3s: routes /api/* to core (SSE-safe, flush-immediate), everything else to web, and exposes its own Prometheus metrics.

apiVersion: v1
kind: ConfigMap
metadata:
  name: caddy-config
  namespace: gipc
data:
  Caddyfile: |
    {
      auto_https off
      admin off
      servers {
        trusted_proxies static private_ranges   # trust cloudflared → preserve X-Forwarded-Proto/For
        metrics                                  # enable per-server Prometheus metrics
      }
    }
    :80 {
      handle /caddy-health {
        respond 200
      }
      # arc4ne.io — the company-site tenant (ns arcane). Host-scoped FIRST so gipc's /api/*
      # routes never leak onto the arc4ne hostnames.
      @arc4ne host arc4ne.io www.arc4ne.io
      handle @arc4ne {
        reverse_proxy arcane-web.arcane:80
      }
      handle /api/ai/* {
        reverse_proxy ai:8000 {
          flush_interval -1
        }
      }
      handle /api/* {
        reverse_proxy core:8080 {
          flush_interval -1
        }
      }
      handle {
        reverse_proxy web:80
      }
      header {
        -Server
        # CSP: 'unsafe-inline' is required for Next.js static (inline hydration bootstrap + inline styles;
        # no nonce middleware on prerendered pages). challenges.cloudflare.com = Turnstile (the only external
        # host). Everything else is same-origin. No eval/worker/blob in the codebase → those stay omitted.
        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        # HSTS apex-only (no includeSubDomains — mail/other subdomains aren't all HTTPS-forced; no preload).
        Strict-Transport-Security "max-age=31536000"
        Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
      }
      # The résumé PDF is embedded by /connect in a same-origin <iframe>. The global block above
      # stamps frame-ancestors 'none' + XFO DENY on EVERY response — including the PDF itself,
      # which made the browser refuse OUR OWN embed ("gipc.dev refused to connect"). Re-set the
      # two fields for that one asset: same-origin framing only, and a bare default-src 'none'
      # CSP (a PDF document loads no subresources). Everything else keeps the deny-all.
      # `defer` is load-bearing: the Caddyfile adapter sorts same-name directives with path
      # matchers FIRST, so without it the global block runs after this one and wins back.
      @resumepdf path /Gabriel_Carvalho_Resume.pdf
      header @resumepdf {
        Content-Security-Policy "default-src 'none'; frame-ancestors 'self'"
        X-Frame-Options "SAMEORIGIN"
        defer
      }
    }
    :2019 {
      metrics /metrics                           # internal only — NOT in the Service/tunnel
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: caddy
  namespace: gipc
  labels: { app: caddy }
spec:
  replicas: 1
  selector:
    matchLabels: { app: caddy }
  template:
    metadata:
      labels: { app: caddy }
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "2019"
        prometheus.io/path: "/metrics"
    spec:
      securityContext:
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: caddy
          image: caddy:2.8-alpine   # pinned INLINE (not in kustomization images:) so CI's sed can't touch it
          ports:
            - { containerPort: 80, name: http }
            - { containerPort: 2019, name: metrics }
          readinessProbe:
            httpGet: { path: /caddy-health, port: 80 }
            initialDelaySeconds: 3
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /caddy-health, port: 80 }
            initialDelaySeconds: 10
            periodSeconds: 20
          resources:
            requests: { cpu: 25m, memory: 32Mi }
            limits: { cpu: 150m, memory: 128Mi }
          securityContext:
            allowPrivilegeEscalation: false   # runs root for :80 bind + /data,/config writes — lighter than core
          volumeMounts:
            - { name: config, mountPath: /etc/caddy }
      volumes:
        - name: config
          configMap: { name: caddy-config }
---
apiVersion: v1
kind: Service
metadata:
  name: caddy
  namespace: gipc
  labels: { app: caddy }
spec:
  type: NodePort
  selector: { app: caddy }
  ports:
    - { port: 80, targetPort: 80, nodePort: 30082, name: http }

Cloudflare Tunnel — the front door

infra/cloudflared/config.yml

cloudflared dials out to Cloudflare's edge with zero inbound ports open on the box; gipc.dev ingress lands on the in-cluster Caddy NodePort. The tunnel id is redacted here.

tunnel: ‹tunnel-id›
credentials-file: /etc/cloudflared/gipc.json
originRequest:
  connectTimeout: 30s
ingress:
  - hostname: gipc.dev
    service: http://localhost:30082
  - hostname: www.gipc.dev
    service: http://localhost:30082
  - hostname: arc4ne.io
    service: http://localhost:30082
  - hostname: www.arc4ne.io
    service: http://localhost:30082
  - service: http_status:404

Terraform — the tunnel + DNS, under management

infra/terraform/cloudflare/cloudflare.tf

The whole Cloudflare zone as code: the tunnel, its proxied CNAMEs, and the full Migadu mail set (MX, DKIM, SPF, DMARC, SRV). Imported from the live account and applied — `terraform plan` is clean, so the code now describes reality and future changes go through it. The reconciling import (short names, config_src, one in-place apply that touched zero DNS bytes) is documented in the repo README.

# The codified mirror of gipc.dev's Cloudflare provisioning: the tunnel + the full DNS zone
# (site CNAMEs + the Migadu mail set). Imported and applied in Sprint J — `plan` is clean;
# see README.md for the import notes (short names, config_src, the one reconciling apply).

# gipc.dev and www route through the tunnel: proxied CNAMEs onto cfargotunnel.com.
resource "cloudflare_record" "apex" {
  zone_id = var.zone_id
  name    = "gipc.dev"
  type    = "CNAME"
  content = "${var.tunnel_id}.cfargotunnel.com"
  proxied = true
  ttl     = 1
}

resource "cloudflare_record" "www" {
  zone_id = var.zone_id
  name    = "www"
  type    = "CNAME"
  content = "${var.tunnel_id}.cfargotunnel.com"
  proxied = true
  ttl     = 1
}

# --- Migadu mail: the records that make gipc.dev email work. All DNS-only (unproxied),
# ttl 300. Authored verbatim from the live-API snapshot — never edit content to "tidy" it;
# a wrong byte here silently breaks mail. Names are the provider's SHORT form (verified at
# import: v4 stores the record's short name in state, not the FQDN). ---

resource "cloudflare_record" "autoconfig" {
  zone_id = var.zone_id
  name    = "autoconfig"
  type    = "CNAME"
  content = "autoconfig.migadu.com"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "autodiscover" {
  zone_id = var.zone_id
  name    = "autodiscover"
  type    = "CNAME"
  content = "autodiscover.migadu.com"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "dkim1" {
  zone_id = var.zone_id
  name    = "key1._domainkey"
  type    = "CNAME"
  content = "key1.gipc.dev._domainkey.migadu.com"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "dkim2" {
  zone_id = var.zone_id
  name    = "key2._domainkey"
  type    = "CNAME"
  content = "key2.gipc.dev._domainkey.migadu.com"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "dkim3" {
  zone_id = var.zone_id
  name    = "key3._domainkey"
  type    = "CNAME"
  content = "key3.gipc.dev._domainkey.migadu.com"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "mx_primary" {
  zone_id  = var.zone_id
  name     = "gipc.dev"
  type     = "MX"
  content  = "aspmx1.migadu.com"
  priority = 10
  proxied  = false
  ttl      = 300
}

resource "cloudflare_record" "mx_secondary" {
  zone_id  = var.zone_id
  name     = "gipc.dev"
  type     = "MX"
  content  = "aspmx2.migadu.com"
  priority = 20
  proxied  = false
  ttl      = 300
}

resource "cloudflare_record" "srv_autodiscover" {
  zone_id = var.zone_id
  name    = "_autodiscover._tcp"
  type    = "SRV"
  ttl     = 300
  data {
    priority = 0
    weight   = 1
    port     = 443
    target   = "autodiscover.migadu.com"
  }
}

resource "cloudflare_record" "srv_imaps" {
  zone_id = var.zone_id
  name    = "_imaps._tcp"
  type    = "SRV"
  ttl     = 300
  data {
    priority = 0
    weight   = 1
    port     = 993
    target   = "imap.migadu.com"
  }
}

resource "cloudflare_record" "srv_pop3s" {
  zone_id = var.zone_id
  name    = "_pop3s._tcp"
  type    = "SRV"
  ttl     = 300
  data {
    priority = 0
    weight   = 1
    port     = 995
    target   = "pop.migadu.com"
  }
}

resource "cloudflare_record" "srv_submissions" {
  zone_id = var.zone_id
  name    = "_submissions._tcp"
  type    = "SRV"
  ttl     = 300
  data {
    priority = 0
    weight   = 1
    port     = 465
    target   = "smtp.migadu.com"
  }
}

resource "cloudflare_record" "txt_dmarc" {
  zone_id = var.zone_id
  name    = "_dmarc"
  type    = "TXT"
  content = "v=DMARC1; p=quarantine;"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "txt_spf" {
  zone_id = var.zone_id
  name    = "gipc.dev"
  type    = "TXT"
  content = "v=spf1 include:spf.migadu.com -all"
  proxied = false
  ttl     = 300
}

resource "cloudflare_record" "txt_mail_verify" {
  zone_id = var.zone_id
  name    = "gipc.dev"
  type    = "TXT"
  content = "hosted-email-verify=mj2ixoiz"
  proxied = false
  ttl     = 300
}

# The tunnel itself. config_src = "local" is the truth: ingress rules live in
# /etc/cloudflared/config.yml on the box (see infra/cloudflared + infra/ansible) — a remote
# tunnel_config resource would misstate how this tunnel is actually managed.
resource "cloudflare_zero_trust_tunnel_cloudflared" "gipc" {
  account_id = var.account_id
  name       = "gipc"
  secret     = var.tunnel_secret
  config_src = "local"

  # Both secret and config_src are ForceNew, and neither survives import into state (the v4
  # provider leaves config_src null on read and never returns the real secret) — so a first
  # plan would see null→"local" and null→<dummy> and want to REPLACE the live tunnel. Ignoring
  # both suppresses that spurious replacement; the real credential + the local config both live
  # on the box, never in Terraform. (Verified at import: state carries the computed tunnel_token
  # but not `secret`; config_src imported as null → the replace was driven by config_src, not
  # the secret.)
  lifecycle {
    ignore_changes = [secret, config_src]
  }
}