# =============================================================================
# certctl Load-Test Harness — Docker Compose
# =============================================================================
#
# Spins up a minimal certctl stack and runs a k6 driver against it to capture
# p50 / p95 / p99 latency for the certificate-management API hot path AND
# (Bundle 10 of the 2026-05-02 deployment-target audit) per-target-type
# TCP+TLS handshake throughput against four target sidecars (nginx, apache,
# haproxy, f5-mock).
#
# Stack:
#   1. postgres                 — empty database (server runs migrations + seeds at boot)
#   2. certctl-tls-init         — one-shot init container; writes self-signed
#                                  server.crt/.key/ca.crt into ./certs (bind
#                                  mount, host-readable so the k6 container
#                                  can pin against it via volumes)
#   3. certctl-server           — HTTPS API on :8443, demo-seed enabled so
#                                  the k6 script has iss-local + an operator
#                                  + a team ready to reference in
#                                  CreateCertificate payloads
#   4. target-tls-init          — Bundle 10: shared starter cert+key for the
#                                  four target sidecars (nginx, apache,
#                                  haproxy, f5-mock). Each daemon boots with
#                                  this cert; the loadtest scenarios connect
#                                  at sustained rates to measure handshake
#                                  latency tagged by target_type.
#   5. nginx-target             — Bundle 10: HTTPS on internal :443.
#   6. apache-target            — Bundle 10: HTTPS on internal :443.
#   7. haproxy-target           — Bundle 10: HTTPS on internal :443.
#   8. f5-mock-target           — Bundle 10: iControl REST on internal :443
#                                  + plaintext HTTP on internal :8080. Runs
#                                  the in-tree f5-mock-icontrol image
#                                  (deploy/test/f5-mock-icontrol/).
#   9. k6                       — runs k6.js once and exits with the
#                                  threshold-driven exit code (zero on green,
#                                  non-zero on any threshold breach so
#                                  `make loadtest` surfaces regressions as a
#                                  failed shell command).
#
# Out of scope for v1 of the connector-tier harness (Bundle 10):
#   - Kubernetes target via kind-in-docker. kind requires `privileged: true`
#     and Docker-in-Docker semantics that are operationally fragile in CI;
#     the K8s connector loadtest is a follow-up that needs Bundle 2's real
#     k8s.io/client-go to land first.
#   - Full agent-driven deploy poll loop (POST cert → poll deployments →
#     verify served cert matches what was deployed). The harness measures
#     handshake throughput against the target sidecars directly — that's
#     enough to validate the sidecars are operational under load and gives
#     procurement a per-target latency number that doesn't depend on the
#     agent registration + target-binding API surface being plumbed
#     end-to-end in the loadtest stack.
#
# Usage:  make loadtest  (from the repo root)
# Manual: cd deploy/test/loadtest && docker compose up --abort-on-container-exit --exit-code-from k6
#
# Audit reference (API tier):       2026-05-01 issuer coverage audit fix #8.
# Audit reference (connector tier): 2026-05-02 deployment-target audit Bundle 10.
# =============================================================================

services:

  # ---------------------------------------------------------------------------
  # Self-signed TLS bootstrap. Mirrors the deploy/docker-compose.test.yml
  # tls-init pattern exactly: bind-mount instead of named volume so the host
  # (and the sibling k6 container) can read ca.crt without a chown dance.
  # See deploy/docker-compose.test.yml::certctl-tls-init for the full rationale.
  # ---------------------------------------------------------------------------
  certctl-tls-init:
    image: alpine/openssl:latest
    container_name: certctl-loadtest-tls-init
    restart: "no"
    entrypoint: /bin/sh
    command:
      - -c
      - |
        set -eu
        CERT=/etc/certctl/tls/server.crt
        KEY=/etc/certctl/tls/server.key
        CA=/etc/certctl/tls/ca.crt
        if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
          echo "TLS cert already present — skipping generation"
        else
          mkdir -p /etc/certctl/tls
          openssl req -x509 -newkey ec \
            -pkeyopt ec_paramgen_curve:P-256 \
            -nodes \
            -keyout "$$KEY" \
            -out "$$CERT" \
            -days 3650 \
            -subj "/CN=certctl-server" \
            -addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1"
          cp "$$CERT" "$$CA"
          echo "Generated self-signed TLS cert (ECDSA-P256, 3650d, CN=certctl-server)"
        fi
        chmod 0644 "$$CERT" "$$CA"
        chmod 0600 "$$KEY"
    volumes:
      - ./certs:/etc/certctl/tls

  # ---------------------------------------------------------------------------
  # Database. The server runs migrations + seed.sql + (because
  # CERTCTL_DEMO_SEED=true below) seed_demo.sql at boot — so the load-test
  # k6 script can reference iss-local, o-alice, t-platform, and rp-default
  # without a separate seed step.
  # ---------------------------------------------------------------------------
  postgres:
    image: postgres:16-alpine
    container_name: certctl-loadtest-postgres
    environment:
      POSTGRES_DB: certctl
      POSTGRES_USER: certctl
      POSTGRES_PASSWORD: loadtestpass
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U certctl"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s

  # ---------------------------------------------------------------------------
  # certctl server. Built from the repo root Dockerfile (same as production).
  # Demo seed is enabled so referenced FK rows exist when the k6 script
  # POSTs CreateCertificate payloads. Auth is api-key with a deterministic
  # token the k6 script knows.
  # ---------------------------------------------------------------------------
  certctl-server:
    build:
      context: ../../..
      dockerfile: Dockerfile
      args:
        HTTP_PROXY: ${HTTP_PROXY:-}
        HTTPS_PROXY: ${HTTPS_PROXY:-}
        NO_PROXY: ${NO_PROXY:-}
    container_name: certctl-loadtest-server
    depends_on:
      postgres:
        condition: service_healthy
      certctl-tls-init:
        condition: service_completed_successfully
    environment:
      CERTCTL_DATABASE_URL: postgres://certctl:loadtestpass@postgres:5432/certctl?sslmode=disable
      CERTCTL_SERVER_HOST: 0.0.0.0
      CERTCTL_SERVER_PORT: 8443
      CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
      CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
      CERTCTL_LOG_LEVEL: warn
      CERTCTL_AUTH_TYPE: api-key
      CERTCTL_AUTH_SECRET: load-test-token
      CERTCTL_KEYGEN_MODE: agent
      # CERTCTL_DEMO_SEED=true triggers seed_demo.sql which creates iss-local,
      # o-alice, t-platform, rp-standard so CreateCertificate FK validation
      # has rows to bind to.
      CERTCTL_DEMO_SEED: "true"
      # Bigger body limit so listing 100s of certs in the GET scenario
      # doesn't 413 once the harness has been running for a few minutes.
      CERTCTL_MAX_BODY_SIZE: "10485760"
      # Encryption key (≥32 bytes per H-1 floor — the test compose's
      # documented value).
      CERTCTL_CONFIG_ENCRYPTION_KEY: "loadtest-key-must-be-32-bytes-long-yes"
    volumes:
      - ./certs:/etc/certctl/tls:ro
    healthcheck:
      # /healthz is unauthenticated. -k because the cert is self-signed.
      test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:8443/healthz || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 30
      start_period: 60s

  # ---------------------------------------------------------------------------
  # Bundle 10: target-side TLS bootstrap. Mints a single ECDSA-P256 self-
  # signed cert + key into a shared ./fixtures/target-certs/ volume that the
  # four target sidecars (nginx, apache, haproxy) mount read-only. f5-mock
  # generates its own self-signed cert at startup (see
  # deploy/test/f5-mock-icontrol/tls.go) so it doesn't need this volume.
  #
  # The loadtest scenarios don't care which cert the target serves — only
  # that the daemon is up and completing TLS handshakes at the configured
  # rate. The starter cert exists so each daemon boots green; once Bundle 2
  # (real K8s client) + agent-driven deploy poll is plumbed in v2 of the
  # harness, deploys would overwrite this cert.
  # ---------------------------------------------------------------------------
  target-tls-init:
    image: alpine/openssl:latest
    container_name: certctl-loadtest-target-tls-init
    restart: "no"
    entrypoint: /bin/sh
    command:
      - -c
      - |
        set -eu
        CERT=/certs/target.crt
        KEY=/certs/target.key
        PEM=/certs/target.pem
        if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$PEM" ]; then
          echo "Target TLS cert already present — skipping generation"
        else
          mkdir -p /certs
          openssl req -x509 -newkey ec \
            -pkeyopt ec_paramgen_curve:P-256 \
            -nodes \
            -keyout "$$KEY" \
            -out "$$CERT" \
            -days 365 \
            -subj "/CN=loadtest-target" \
            -addext "subjectAltName=DNS:nginx-target,DNS:apache-target,DNS:haproxy-target,DNS:f5-mock-target,DNS:localhost,IP:127.0.0.1"
          # HAProxy expects cert+key concatenated into a single PEM file
          # at the path supplied to `bind ... ssl crt <path>`. Build it
          # alongside the cert/key pair so the haproxy-target's mount
          # works without a per-daemon ENTRYPOINT shim.
          cat "$$CERT" "$$KEY" > "$$PEM"
          echo "Generated target starter cert (ECDSA-P256, 365d, multi-SAN)"
        fi
        # World-readable so non-root container users (haproxy uses uid 99,
        # apache uses uid 1) can read the key. This is fine for a load-test
        # starter cert; production wouldn't do this.
        chmod 0644 "$$CERT" "$$KEY" "$$PEM"
    volumes:
      - ./fixtures/target-certs:/certs

  # ---------------------------------------------------------------------------
  # nginx-target. Listens on internal :443 with the starter cert. The
  # k6 nginx_handshake scenario connects at 100 conns/min for 5 minutes.
  # ---------------------------------------------------------------------------
  nginx-target:
    image: nginx:1.27-alpine
    container_name: certctl-loadtest-nginx
    depends_on:
      target-tls-init:
        condition: service_completed_successfully
    volumes:
      - ./fixtures/target-certs:/etc/nginx/certs:ro
      - ./fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
    healthcheck:
      test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:443/ || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 15s

  # ---------------------------------------------------------------------------
  # apache-target. Listens on internal :443. The bundled httpd.conf loads
  # the minimum module set + a single SSL-terminated vhost.
  # ---------------------------------------------------------------------------
  apache-target:
    image: httpd:2.4-alpine
    container_name: certctl-loadtest-apache
    depends_on:
      target-tls-init:
        condition: service_completed_successfully
    volumes:
      - ./fixtures/target-certs:/usr/local/apache2/conf/certs:ro
      - ./fixtures/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
    healthcheck:
      test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:443/ || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 15s

  # ---------------------------------------------------------------------------
  # haproxy-target. Listens on internal :443 with SSL termination. The
  # haproxy.cfg references /usr/local/etc/haproxy/certs/target.pem which
  # target-tls-init writes (cert + key concatenated).
  # ---------------------------------------------------------------------------
  haproxy-target:
    image: haproxy:2.9-alpine
    container_name: certctl-loadtest-haproxy
    depends_on:
      target-tls-init:
        condition: service_completed_successfully
    volumes:
      - ./fixtures/target-certs:/usr/local/etc/haproxy/certs:ro
      - ./fixtures/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
    healthcheck:
      # HAProxy doesn't ship with wget/curl; use the openssl-based handshake
      # check instead. The /dev/null redirect drops the response body so
      # large logs don't accumulate over the run.
      test: ["CMD-SHELL", "echo Q | openssl s_client -connect localhost:443 -servername localhost 2>/dev/null | grep -q 'BEGIN CERTIFICATE'"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 15s

  # ---------------------------------------------------------------------------
  # f5-mock target. Re-uses the in-tree f5-mock-icontrol image (already
  # used by the deploy-vendor-e2e CI job). Generates its own self-signed
  # cert at startup; listens on internal :443 (HTTPS, iControl REST) and
  # :8080 (plaintext HTTP). The k6 f5_handshake scenario hits the
  # /healthz endpoint.
  # ---------------------------------------------------------------------------
  f5-mock-target:
    # Long-form build to match docker-compose.test.yml: the Dockerfile
    # has `COPY deploy/test/f5-mock-icontrol/ ./` which assumes the
    # build context is the REPO ROOT. The previous shorthand form
    # `build: ../f5-mock-icontrol` set the context to the
    # f5-mock-icontrol directory itself, breaking the COPY at CI build
    # time (run #25305811340: "deploy/test/f5-mock-icontrol: not found").
    build:
      context: ../../..
      dockerfile: deploy/test/f5-mock-icontrol/Dockerfile
    container_name: certctl-loadtest-f5-mock
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 15s

  # ---------------------------------------------------------------------------
  # k6 driver. Pinned to a specific version so threshold expressions stay
  # stable across runs. --insecure-skip-tls-verify because the server cert is
  # self-signed; the load test isn't a TLS conformance test. The k6 process
  # exits non-zero if any threshold is breached, which the parent
  # `docker compose up --exit-code-from k6` propagates as the compose exit
  # code, which `make loadtest` then surfaces as the make-target exit code.
  # ---------------------------------------------------------------------------
  k6:
    image: grafana/k6:0.54.0
    container_name: certctl-loadtest-k6
    depends_on:
      certctl-server:
        condition: service_healthy
      # Bundle 10: wait for the four target sidecars to be healthy before
      # firing the connector-tier scenarios. Saves the operator from
      # spurious "connection refused" errors during the first ~15s of the
      # run while target daemons are coming up.
      nginx-target:
        condition: service_healthy
      apache-target:
        condition: service_healthy
      haproxy-target:
        condition: service_healthy
      f5-mock-target:
        condition: service_healthy
    environment:
      CERTCTL_BASE: https://certctl-server:8443
      CERTCTL_TOKEN: load-test-token
      K6_INSECURE_SKIP_TLS_VERIFY: "true"
      # Bundle 10: per-target sidecar URLs the connector-tier scenarios
      # connect to. Internal docker-compose DNS — k6 resolves these via
      # the default user network's resolver.
      NGINX_TARGET_URL:   https://nginx-target:443
      APACHE_TARGET_URL:  https://apache-target:443
      HAPROXY_TARGET_URL: https://haproxy-target:443
      F5_TARGET_URL:      https://f5-mock-target:443
    volumes:
      - ./k6.js:/scripts/k6.js:ro
      - ./results:/results
    command:
      - run
      - --summary-export=/results/summary.json
      - /scripts/k6.js
