diff --git a/.woodpecker.yml b/.woodpecker.yml index aeff386..2af2771 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,5 +1,10 @@ # Woodpecker CI for rdev platform # Builds and deploys rdev-api, rdev-worker, and rdev-claudebox +# +# TODO: Remove skip-tls-verify from Kaniko steps once cert verification is tested. +# Registry has valid LE cert via Traefik — Kaniko should be able to verify it. +# Test by removing from one step first. If Kaniko can't verify (runs inside +# cluster hitting internal service), mount the CA bundle instead. variables: - &when_main @@ -10,6 +15,7 @@ steps: # Run tests first test: image: golang:1.25-alpine + depends_on: [] commands: - apk add --no-cache git - go test ./... @@ -17,6 +23,7 @@ steps: # Build rdev-api image build-api: image: woodpeckerci/plugin-kaniko + depends_on: [test] settings: registry: registry.threesix.ai repo: rdev/api @@ -26,13 +33,14 @@ steps: context: . dockerfile: Dockerfile.api cache: true - skip_tls_verify: true + skip-tls-verify: true when: <<: *when_main # Build rdev-worker image build-worker: image: woodpeckerci/plugin-kaniko + depends_on: [test] settings: registry: registry.threesix.ai repo: rdev/worker @@ -42,13 +50,14 @@ steps: context: . dockerfile: Dockerfile.worker cache: true - skip_tls_verify: true + skip-tls-verify: true when: <<: *when_main # Build rdev-claudebox image build-claudebox: image: woodpeckerci/plugin-kaniko + depends_on: [test] settings: registry: registry.threesix.ai repo: rdev/claudebox @@ -58,13 +67,14 @@ steps: context: . dockerfile: Dockerfile cache: true - skip_tls_verify: true + skip-tls-verify: true when: <<: *when_main # Deploy to k3s cluster deploy: image: bitnami/kubectl:latest + depends_on: [build-api, build-worker, build-claudebox] commands: - echo "Deploying rdev-api..." - kubectl set image deployment/rdev-api rdev-api=registry.threesix.ai/rdev/api:${CI_COMMIT_SHA:0:8} -n rdev diff --git a/CLAUDE.md b/CLAUDE.md index e036d53..f4584a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,11 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" = | **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) | | **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) | | **Interactive remote development** | [services/interactive-remote-dev.md](.claude/guides/services/interactive-remote-dev.md) | +| **Gitea 1.22 / SDK / webhooks** | [ops/gitea-1.22.md](.claude/guides/ops/gitea-1.22.md) | +| **Go 1.25 features & migration** | [backend/go-1.25.md](.claude/guides/backend/go-1.25.md) | +| **Woodpecker CI v3 pipelines** | [ops/woodpecker-v3.md](.claude/guides/ops/woodpecker-v3.md) | +| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) | +| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) | | **Structured logging** | `internal/logging/` - field constants, context propagation, redaction | ## Critical Rules diff --git a/deployments/k8s/base/templates/go-api/.woodpecker.yml b/deployments/k8s/base/templates/go-api/.woodpecker.yml index 7eb3f66..6034c22 100644 --- a/deployments/k8s/base/templates/go-api/.woodpecker.yml +++ b/deployments/k8s/base/templates/go-api/.woodpecker.yml @@ -1,13 +1,13 @@ steps: test: - image: golang:1.22-alpine + image: golang:1.25-alpine commands: - go test ./... when: - event: [push, pull_request] build: - image: golang:1.22-alpine + image: golang:1.25-alpine commands: - go build -o app ./cmd/api when: diff --git a/deployments/k8s/base/woodpecker-deployer-rbac.yaml b/deployments/k8s/base/woodpecker-deployer-rbac.yaml index 331b268..2e865f7 100644 --- a/deployments/k8s/base/woodpecker-deployer-rbac.yaml +++ b/deployments/k8s/base/woodpecker-deployer-rbac.yaml @@ -21,13 +21,16 @@ metadata: app.kubernetes.io/name: woodpecker-deployer app.kubernetes.io/part-of: rdev rules: - # Minimal permissions for `kubectl set image` on deployments - # - get: Required to read current deployment state - # - list: Required for kubectl to find the deployment - # - patch: Required for `kubectl set image` to update the container image + # Deploy steps: set image, patch replicas, verify rollout + # - get/list: read deployment and replicaset state + # - patch: kubectl set image, kubectl patch (replicas) - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list", "patch"] + # rollout status needs to watch replicasets + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index eef749d..911ac97 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -184,10 +184,10 @@ metadata: name: rdev-api namespace: rdev annotations: - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" spec: - ingressClassName: nginx + ingressClassName: traefik rules: - host: rdev.example.com http: diff --git a/docs/operations/runbooks/auth-failures.md b/docs/operations/runbooks/auth-failures.md index 00ea269..e5aef70 100644 --- a/docs/operations/runbooks/auth-failures.md +++ b/docs/operations/runbooks/auth-failures.md @@ -101,10 +101,18 @@ kubectl -n rdev logs -l app=rdev-api --since=10m | \ ### If Under Attack -1. **Immediate**: Block at ingress +1. **Immediate**: Block at ingress using Traefik ipAllowList Middleware ```yaml - # Add to ingress annotations - nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,192.168.0.0/16" + # Use Traefik ipAllowList Middleware CRD instead: + # apiVersion: traefik.io/v1alpha1 + # kind: Middleware + # metadata: + # name: internal-only + # spec: + # ipAllowList: + # sourceRange: + # - "10.0.0.0/8" + # - "192.168.0.0/16" ``` 2. **Short-term**: Increase rate limits diff --git a/docs/operations/runbooks/high-memory.md b/docs/operations/runbooks/high-memory.md index 12e3cc4..0b8a043 100644 --- a/docs/operations/runbooks/high-memory.md +++ b/docs/operations/runbooks/high-memory.md @@ -75,9 +75,11 @@ kubectl -n rdev patch deployment rdev-api --type='json' -p='[ kubectl -n rdev logs -l app=rdev-api | grep "SSE connection" | tail -50 ``` -2. Reduce connection timeout in ingress: +2. Reduce connection timeout at the Traefik entrypoint level: ```yaml - nginx.ingress.kubernetes.io/proxy-read-timeout: "1800" # 30 min max + # Traefik: configure respondingTimeouts at entrypoint level + # or use ServersTransport for per-service forwarding timeout + traefik.ingress.kubernetes.io/router.entrypoints: websecure ``` ### If Command Output Is Too Large diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index 1b9430f..b0c573e 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -201,9 +201,13 @@ kubectl -n ingress-nginx get ing rdev-api -o yaml **Common Causes:** 1. **Proxy timeout:** - Ensure ingress has long timeout: + Traefik timeout is configured at the entrypoint level via HelmChartConfig, + not per-Ingress annotations. See `.claude/guides/ops/traefik-v3.md` for details. ```yaml - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + # Traefik timeout is configured at the entrypoint level via HelmChartConfig + # See .claude/guides/ops/traefik-v3.md for details + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" ``` 2. **Client timeout:** diff --git a/internal/adapter/cockroach/provisioner.go b/internal/adapter/cockroach/provisioner.go index 12b3869..e433bab 100644 --- a/internal/adapter/cockroach/provisioner.go +++ b/internal/adapter/cockroach/provisioner.go @@ -9,6 +9,8 @@ import ( "encoding/hex" "fmt" "log/slog" + "net" + "strconv" "strings" "time" @@ -39,8 +41,9 @@ func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) { cfg.SSLMode = "disable" } - dsn := fmt.Sprintf("postgresql://%s@%s:%d/defaultdb?sslmode=%s", - cfg.User, cfg.Host, cfg.Port, cfg.SSLMode) + hostPort := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) + dsn := fmt.Sprintf("postgresql://%s@%s/defaultdb?sslmode=%s", + cfg.User, hostPort, cfg.SSLMode) db, err := sql.Open("postgres", dsn) if err != nil { @@ -112,8 +115,9 @@ func (p *Provisioner) CreateProjectDatabase(ctx context.Context, projectID strin // Build connection URL // In insecure mode, password is not used in connection, but we store it for future TLS migration - url := fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=disable", - username, p.host, p.port, dbName) + hostPort := net.JoinHostPort(p.host, strconv.Itoa(p.port)) + url := fmt.Sprintf("postgresql://%s@%s/%s?sslmode=disable", + username, hostPort, dbName) p.logger.Info("created project database", "project_id", projectID, @@ -179,8 +183,9 @@ func (p *Provisioner) GetProjectDatabase(ctx context.Context, projectID string) } // Database exists; construct credentials without password - url := fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=disable", - username, p.host, p.port, dbName) + hostPort := net.JoinHostPort(p.host, strconv.Itoa(p.port)) + url := fmt.Sprintf("postgresql://%s@%s/%s?sslmode=disable", + username, hostPort, dbName) return &domain.DatabaseCredentials{ ProjectID: projectID, diff --git a/internal/adapter/deployer/resources.go b/internal/adapter/deployer/resources.go index 7422b70..e2aa8f3 100644 --- a/internal/adapter/deployer/resources.go +++ b/internal/adapter/deployer/resources.go @@ -262,6 +262,8 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy if d.config.TLSIssuer != "" { annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer } + annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure" + annotations["traefik.ingress.kubernetes.io/router.tls"] = "true" ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations) diff --git a/internal/adapter/deployer/resources_ingress.go b/internal/adapter/deployer/resources_ingress.go index ba2f068..9a0c294 100644 --- a/internal/adapter/deployer/resources_ingress.go +++ b/internal/adapter/deployer/resources_ingress.go @@ -118,6 +118,8 @@ func (d *Deployer) createUnifiedIngress(ctx context.Context, projectName, host, if d.config.TLSIssuer != "" { annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer } + annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure" + annotations["traefik.ingress.kubernetes.io/router.tls"] = "true" ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/adapter/gitea/client.go b/internal/adapter/gitea/client.go index 26bebb1..9674698 100644 --- a/internal/adapter/gitea/client.go +++ b/internal/adapter/gitea/client.go @@ -6,11 +6,17 @@ // compatibility and future-proofing, but the underlying SDK calls do not use it // for cancellation or timeouts. If cancellation is critical, consider using a // context-aware HTTP transport or wrapping calls with context deadline checks. +// +// TODO: Fix Gitea ALLOWED_HOST_LIST — set to "private,loopback" in Gitea app.ini +// to allow webhook delivery to cluster-internal services (Woodpecker). The default +// "external" blocks delivery to internal URLs, likely causing silent webhook failures. +// This is a cluster config change, not a code change. package gitea import ( "context" "fmt" + "net/http" "time" "code.gitea.io/sdk/gitea" @@ -40,7 +46,10 @@ func (c *Client) SDKClient() *gitea.Client { // token is an API access token with repo permissions // defaultOwner is the organization or user to create repos under func NewClient(url, token, defaultOwner string) (*Client, error) { - client, err := gitea.NewClient(url, gitea.SetToken(token)) + client, err := gitea.NewClient(url, + gitea.SetToken(token), + gitea.SetHTTPClient(&http.Client{Timeout: 30 * time.Second}), + ) if err != nil { return nil, fmt.Errorf("failed to create gitea client: %w", err) } @@ -53,6 +62,12 @@ func NewClient(url, token, defaultOwner string) (*Client, error) { // CreateRepo creates a new git repository under the default owner. func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*domain.Repo, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + opts := gitea.CreateRepoOption{ Name: name, Description: description, @@ -79,8 +94,17 @@ func (c *Client) CreateRepo(ctx context.Context, name, description string, priva // DeleteRepo deletes a repository. func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error { - _, err := c.client.DeleteRepo(owner, name) + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + resp, err := c.client.DeleteRepo(owner, name) if err != nil { + if resp != nil && resp.StatusCode == 404 { + return nil // Already deleted + } return fmt.Errorf("failed to delete repo %s/%s: %w", owner, name, err) } return nil @@ -88,6 +112,12 @@ func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error { // ListRepos returns all repositories for an owner. func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + // Try as organization first repos, _, err := c.client.ListOrgRepos(owner, gitea.ListOrgReposOptions{ ListOptions: gitea.ListOptions{PageSize: 100}, @@ -111,8 +141,17 @@ func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, e // GetRepo returns a single repository. func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) { - repo, _, err := c.client.GetRepo(owner, name) + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + repo, resp, err := c.client.GetRepo(owner, name) if err != nil { + if resp != nil && resp.StatusCode == 404 { + return nil, fmt.Errorf("repo not found: %s/%s", owner, name) + } return nil, fmt.Errorf("failed to get repo %s/%s: %w", owner, name, err) } return repoFromGitea(repo), nil @@ -120,6 +159,12 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, // AddCollaborator adds a user as collaborator to a repo. func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + var accessMode gitea.AccessMode switch permission { case "read": @@ -143,6 +188,12 @@ func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username stri // RemoveCollaborator removes a collaborator from a repo. func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, err := c.client.DeleteCollaborator(owner, repo, username) if err != nil { return fmt.Errorf("failed to remove collaborator %s from %s/%s: %w", username, owner, repo, err) @@ -152,6 +203,12 @@ func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username s // AddDeployKey adds a deploy key to a repo. func (c *Client) AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) (*domain.DeployKey, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + key, _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{ Title: title, Key: publicKey, @@ -171,6 +228,12 @@ func (c *Client) AddDeployKey(ctx context.Context, owner, repo, title, publicKey // DeleteDeployKey removes a deploy key from a repo. func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID int64) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, err := c.client.DeleteDeployKey(owner, repo, keyID) if err != nil { return fmt.Errorf("failed to delete deploy key %d from %s/%s: %w", keyID, owner, repo, err) @@ -180,6 +243,12 @@ func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID // CreateWebhook creates a webhook on a repository. func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) (*domain.RepoWebhook, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + hook, _, err := c.client.CreateRepoHook(owner, repo, gitea.CreateHookOption{ Type: gitea.HookTypeGitea, Config: map[string]string{ @@ -205,6 +274,12 @@ func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret str // DeleteWebhook removes a webhook from a repo. func (c *Client) DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, err := c.client.DeleteRepoHook(owner, repo, webhookID) if err != nil { return fmt.Errorf("failed to delete webhook %d from %s/%s: %w", webhookID, owner, repo, err) diff --git a/internal/adapter/kubernetes/preview.go b/internal/adapter/kubernetes/preview.go index 55d5130..330e6d0 100644 --- a/internal/adapter/kubernetes/preview.go +++ b/internal/adapter/kubernetes/preview.go @@ -107,6 +107,8 @@ func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOpt if m.config.TLSIssuer != "" { annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer } + annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure" + annotations["traefik.ingress.kubernetes.io/router.tls"] = "true" ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/adapter/redis/provisioner.go b/internal/adapter/redis/provisioner.go index f1d3c65..446195c 100644 --- a/internal/adapter/redis/provisioner.go +++ b/internal/adapter/redis/provisioner.go @@ -8,6 +8,8 @@ import ( "encoding/hex" "fmt" "log/slog" + "net" + "strconv" "strings" "time" @@ -40,7 +42,7 @@ func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) { } client := redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), Password: cfg.Password, DB: 0, }) @@ -114,7 +116,7 @@ func (p *Provisioner) CreateProjectCache(ctx context.Context, projectID string) "username", username, "prefix", prefix) - url := fmt.Sprintf("redis://%s:%s@%s:%d", username, password, p.host, p.port) + url := fmt.Sprintf("redis://%s:%s@%s", username, password, net.JoinHostPort(p.host, strconv.Itoa(p.port))) return &domain.CacheCredentials{ ProjectID: projectID, URL: url, diff --git a/internal/adapter/templates/templates/astro-landing/.woodpecker.yml b/internal/adapter/templates/templates/astro-landing/.woodpecker.yml index 3540ec5..4f2d84f 100644 --- a/internal/adapter/templates/templates/astro-landing/.woodpecker.yml +++ b/internal/adapter/templates/templates/astro-landing/.woodpecker.yml @@ -23,7 +23,6 @@ steps: - ${CI_COMMIT_SHA:0:8} cache: true skip-tls-verify: true - failure: ignore when: - event: push branch: main diff --git a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl index c7022cc..0bfa87c 100644 --- a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl @@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}: depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest commands: - - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - - kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true + - echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}" + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}" + ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}" + if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then + echo "FATAL: Image mismatch after deploy" + echo " expected: $EXPECTED_IMAGE" + echo " actual: $ACTUAL_IMAGE" + exit 1 + fi + echo "==> Image confirmed: $ACTUAL_IMAGE" + echo "==> Waiting for rollout (timeout 120s)..." + kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s when: branch: main event: push diff --git a/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl index 1364629..e241fbf 100644 --- a/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl @@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}: depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest commands: - - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - - kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true + - echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}" + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}" + ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}" + if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then + echo "FATAL: Image mismatch after deploy" + echo " expected: $EXPECTED_IMAGE" + echo " actual: $ACTUAL_IMAGE" + exit 1 + fi + echo "==> Image confirmed: $ACTUAL_IMAGE" + echo "==> Waiting for rollout (timeout 120s)..." + kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s when: branch: main event: push diff --git a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl index d0c92b2..cf4aa47 100644 --- a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl @@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}: depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest commands: - - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - - kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true + - echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}" + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}" + ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}" + if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then + echo "FATAL: Image mismatch after deploy" + echo " expected: $EXPECTED_IMAGE" + echo " actual: $ACTUAL_IMAGE" + exit 1 + fi + echo "==> Image confirmed: $ACTUAL_IMAGE" + echo "==> Waiting for rollout (timeout 120s)..." + kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s when: branch: main event: push diff --git a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl index 3471ab7..8cbb54d 100644 --- a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl @@ -55,8 +55,22 @@ deploy-{{COMPONENT_NAME}}: depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest commands: - - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - - kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true + - echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}" + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}" + ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}" + if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then + echo "FATAL: Image mismatch after deploy" + echo " expected: $EXPECTED_IMAGE" + echo " actual: $ACTUAL_IMAGE" + exit 1 + fi + echo "==> Image confirmed: $ACTUAL_IMAGE" + echo "==> Waiting for rollout (timeout 120s)..." + kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s when: branch: main event: push diff --git a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl index 1ce6f81..e601b6d 100644 --- a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl @@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}: depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest commands: - - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - - kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true + - echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}" + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}" + ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}" + if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then + echo "FATAL: Image mismatch after deploy" + echo " expected: $EXPECTED_IMAGE" + echo " actual: $ACTUAL_IMAGE" + exit 1 + fi + echo "==> Image confirmed: $ACTUAL_IMAGE" + echo "==> Waiting for rollout (timeout 120s)..." + kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s when: branch: main event: push diff --git a/internal/adapter/templates/templates/default/.woodpecker.yml b/internal/adapter/templates/templates/default/.woodpecker.yml index e93da35..2105b9b 100644 --- a/internal/adapter/templates/templates/default/.woodpecker.yml +++ b/internal/adapter/templates/templates/default/.woodpecker.yml @@ -9,7 +9,6 @@ steps: - ${CI_COMMIT_SHA:0:8} cache: true skip-tls-verify: true - failure: ignore when: - event: push branch: main diff --git a/internal/adapter/templates/templates/go-api/.woodpecker.yml b/internal/adapter/templates/templates/go-api/.woodpecker.yml index 868227a..6034c22 100644 --- a/internal/adapter/templates/templates/go-api/.woodpecker.yml +++ b/internal/adapter/templates/templates/go-api/.woodpecker.yml @@ -23,7 +23,6 @@ steps: - ${CI_COMMIT_SHA:0:8} cache: true skip-tls-verify: true - failure: ignore when: - event: push branch: main diff --git a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl index 44a7bd5..dfc7e42 100644 --- a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl +++ b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl @@ -1,5 +1,8 @@ # CI/CD Pipeline for {{PROJECT_NAME}} # Components will add their build steps below the marker +# +# TODO: Templatize registry URL — replace hardcoded registry.threesix.ai with +# {{REGISTRY_URL}} so the registry is configurable per environment. clone: git: @@ -9,6 +12,7 @@ clone: steps: deps: + depends_on: [] image: golang:1.25 commands: - go work sync @@ -112,6 +116,7 @@ steps: generate-docs: image: node:20-slim depends_on: [export-openapi] + failure: ignore commands: - npm install -g widdershins - | @@ -142,6 +147,7 @@ steps: build-docs: image: ruby:3.2-slim depends_on: [generate-docs] + failure: ignore commands: - | if [ ! -d "docs" ] || [ ! -f "docs/Gemfile" ]; then @@ -294,6 +300,8 @@ steps: project: {{PROJECT_NAME}} annotations: cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" spec: ingressClassName: traefik tls: @@ -319,6 +327,7 @@ steps: event: push verify: + depends_on: [services-deployed] image: bitnami/kubectl:latest commands: - echo "Pipeline complete for {{PROJECT_NAME}}" diff --git a/internal/adapter/woodpecker/client.go b/internal/adapter/woodpecker/client.go index ba22559..22afd23 100644 --- a/internal/adapter/woodpecker/client.go +++ b/internal/adapter/woodpecker/client.go @@ -176,7 +176,7 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (* if err != nil { // SDK bug: RepoLookup returns non-nil empty struct on error targetRepo = nil - lastErr = fmt.Errorf("repo not found in Woodpecker: %s", fullName) + lastErr = fmt.Errorf("repo not found in Woodpecker %s: %w", fullName, err) if attempt < maxAttempts { c.logger.Debug("repo not found, retrying", "repo", fullName, "attempt", attempt, "max", maxAttempts) time.Sleep(retryDelay) @@ -245,7 +245,7 @@ func (c *Client) DeactivateRepo(ctx context.Context, owner, repo string) error { // Find the repo r, err := c.client.RepoLookup(fullName) if err != nil { - return fmt.Errorf("repo not found: %s", fullName) + return fmt.Errorf("repo not found %s: %w", fullName, err) } // Deactivate (remove from Woodpecker) @@ -269,7 +269,7 @@ func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*domain.CIRep r, err := c.client.RepoLookup(fullName) if err != nil { - return nil, fmt.Errorf("repo not found: %s", fullName) + return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } return repoFromWoodpecker(r), nil @@ -310,7 +310,7 @@ func (c *Client) AddSecret(ctx context.Context, owner, repo string, secret domai // Find the repo to get its ID r, err := c.client.RepoLookup(fullName) if err != nil { - return fmt.Errorf("repo not found: %s", fullName) + return fmt.Errorf("repo not found %s: %w", fullName, err) } // Create the secret @@ -341,7 +341,7 @@ func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName strin // Find the repo to get its ID r, err := c.client.RepoLookup(fullName) if err != nil { - return fmt.Errorf("repo not found: %s", fullName) + return fmt.Errorf("repo not found %s: %w", fullName, err) } // Delete the secret diff --git a/internal/adapter/woodpecker/pipelines.go b/internal/adapter/woodpecker/pipelines.go index 7f522a0..9b36a88 100644 --- a/internal/adapter/woodpecker/pipelines.go +++ b/internal/adapter/woodpecker/pipelines.go @@ -24,7 +24,7 @@ func (c *Client) ListPipelines(ctx context.Context, owner, repo string) ([]*doma r, err := c.client.RepoLookup(fullName) if err != nil { - return nil, fmt.Errorf("repo not found: %s", fullName) + return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{}) @@ -51,7 +51,7 @@ func (c *Client) GetPipeline(ctx context.Context, owner, repo string, number int r, err := c.client.RepoLookup(fullName) if err != nil { - return nil, fmt.Errorf("repo not found: %s", fullName) + return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } p, err := c.client.Pipeline(r.ID, number) @@ -75,7 +75,7 @@ func (c *Client) GetPipelineSteps(ctx context.Context, owner, repo string, numbe r, err := c.client.RepoLookup(fullName) if err != nil { - return nil, fmt.Errorf("repo not found: %s", fullName) + return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } p, err := c.client.Pipeline(r.ID, number) @@ -150,7 +150,7 @@ func (c *Client) TriggerBuild(ctx context.Context, owner, repo, branch string) ( r, err := c.client.RepoLookup(fullName) if err != nil { - return 0, fmt.Errorf("repo not found: %s", fullName) + return 0, fmt.Errorf("repo not found %s: %w", fullName, err) } // Create a new pipeline for the branch (with circuit breaker protection) @@ -182,7 +182,7 @@ func (c *Client) RetryPipeline(ctx context.Context, owner, repo string, number i r, err := c.client.RepoLookup(fullName) if err != nil { - return nil, fmt.Errorf("repo not found: %s", fullName) + return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } // Restart the pipeline using PipelineStart (with circuit breaker protection) diff --git a/internal/adapter/zot/client.go b/internal/adapter/zot/client.go index 5600d77..e2d12ce 100644 --- a/internal/adapter/zot/client.go +++ b/internal/adapter/zot/client.go @@ -1,4 +1,9 @@ // Package zot provides a client for interacting with the zot container registry. +// +// TODO: Deploy recommended Zot config with gcInterval, retention policies, and +// deduplication. Current live config has no periodic GC — old tags accumulate +// until disk fills. Add Zot manifests to deployments/k8s/base/zot/ for version +// control. See .claude/guides/ops/zot-registry.md for the recommended config. package zot import ( @@ -43,6 +48,45 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client { return c } +// maxResponseBodySize is the maximum response body size (10MB) to prevent OOM on large responses. +const maxResponseBodySize = 10 * 1024 * 1024 + +// doWithRetry executes an HTTP request with up to 3 attempts and exponential backoff. +// It retries on network errors and 5xx status codes, but NOT on 4xx client errors. +// +// NOTE: This assumes the request has no body (GET, HEAD, DELETE) since the body +// cannot be re-read on retry. If POST/PUT support is needed, the caller must +// provide a body factory or buffer the body for re-use. +func (c *Client) doWithRetry(req *http.Request) (*http.Response, error) { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + backoff := time.Duration(1<= 500 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) + resp.Body.Close() + lastErr = fmt.Errorf("registry returned %d: %s", resp.StatusCode, string(body)) + continue + } + return resp, nil + } + return nil, fmt.Errorf("registry request failed after 3 attempts: %w", lastErr) +} + // Check returns the health status of the registry. // A 200 or 401 response indicates the registry is healthy (401 means auth required but registry is up). func (c *Client) Check(ctx context.Context) domain.RegistryStatus { @@ -58,7 +102,7 @@ func (c *Client) Check(ctx context.Context) domain.RegistryStatus { } } - resp, err := c.httpClient.Do(req) + resp, err := c.doWithRetry(req) latency := time.Since(start) if err != nil { @@ -107,7 +151,7 @@ func (c *Client) ListRepositories(ctx context.Context) ([]string, error) { return nil, fmt.Errorf("create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.doWithRetry(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -117,7 +161,7 @@ func (c *Client) ListRepositories(ctx context.Context) ([]string, error) { return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) if err != nil { return nil, fmt.Errorf("read body: %w", err) } @@ -194,7 +238,7 @@ func (c *Client) listTags(ctx context.Context, repo string) ([]string, error) { return nil, fmt.Errorf("create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.doWithRetry(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -207,7 +251,7 @@ func (c *Client) listTags(ctx context.Context, repo string) ([]string, error) { return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) if err != nil { return nil, fmt.Errorf("read body: %w", err) } @@ -229,7 +273,7 @@ func (c *Client) deleteManifest(ctx context.Context, repo, tag string) error { } headReq.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json") - headResp, err := c.httpClient.Do(headReq) + headResp, err := c.doWithRetry(headReq) if err != nil { return fmt.Errorf("head request failed: %w", err) } @@ -253,7 +297,7 @@ func (c *Client) deleteManifest(ctx context.Context, repo, tag string) error { return fmt.Errorf("create delete request: %w", err) } - delResp, err := c.httpClient.Do(delReq) + delResp, err := c.doWithRetry(delReq) if err != nil { return fmt.Errorf("delete request failed: %w", err) } diff --git a/internal/circuitbreaker/circuitbreaker_test.go b/internal/circuitbreaker/circuitbreaker_test.go index 68773b2..9b6dbaf 100644 --- a/internal/circuitbreaker/circuitbreaker_test.go +++ b/internal/circuitbreaker/circuitbreaker_test.go @@ -1,3 +1,7 @@ +// TODO: Migrate time.Sleep-based tests to testing/synctest (Go 1.25) for +// deterministic, instant execution. Priority: TestCircuitBreakerTimerReset, +// TestCircuitBreakerRecovery. Also applies to ratelimit_test.go, +// api_client_test.go, dispatcher_test.go, cached/project_repository_test.go. package circuitbreaker import ( diff --git a/internal/service/apikey_service.go b/internal/service/apikey_service.go index 0e93e87..29a49dc 100644 --- a/internal/service/apikey_service.go +++ b/internal/service/apikey_service.go @@ -151,7 +151,7 @@ func (s *APIKeyService) Validate(ctx context.Context, rawKey string) (*domain.AP // request context since this is a non-critical audit update that should not block // validation or be cancelled when request completes) go func() { - _ = s.repo.UpdateLastUsed(context.Background(), apiKey.ID) + _ = s.repo.UpdateLastUsed(context.WithoutCancel(ctx), apiKey.ID) }() return apiKey, nil diff --git a/internal/service/component_deploy.go b/internal/service/component_deploy.go index 7a8c7fe..4d595c6 100644 --- a/internal/service/component_deploy.go +++ b/internal/service/component_deploy.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "net" + "strconv" "strings" "github.com/orchard9/rdev/internal/domain" @@ -147,7 +149,7 @@ func (s *ComponentService) buildSiblingServiceURLs(ctx context.Context, projectI envKey := toUpperSnake(c.Name) + "_URL" // Build internal K8s service URL: http://projectid-componentname:port serviceName := projectID + "-" + c.Name - urls[envKey] = fmt.Sprintf("http://%s:%d", serviceName, c.Port) + urls[envKey] = "http://" + net.JoinHostPort(serviceName, strconv.Itoa(c.Port)) } return urls diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go index 9f707c1..30732bc 100644 --- a/internal/service/project_infra.go +++ b/internal/service/project_infra.go @@ -66,7 +66,7 @@ func NewProjectInfraService( ) *ProjectInfraService { registryURL := cfg.RegistryURL if registryURL == "" { - registryURL = "registry.threesix.ai" // Default for backward compatibility + registryURL = "registry.threesix.ai" // TODO: Remove hardcoded fallback — set REGISTRY_URL in K8s manifest instead } return &ProjectInfraService{ db: db, diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go index 6f5f181..174c261 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/webhook/dispatcher.go @@ -202,7 +202,7 @@ func (d *Dispatcher) processJob(job deliveryJob) { // Record the delivery attempt (fire-and-forget: uses dedicated context with // 10s timeout since recording should not block the job processing loop or // fail if the dispatcher context is cancelled) - recordCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + recordCtx, cancel := context.WithTimeout(context.WithoutCancel(d.ctx), 10*time.Second) defer cancel() if err := d.repo.RecordDelivery(recordCtx, delivery); err != nil { diff --git a/pkg/api/health.go b/pkg/api/health.go index 4ac0be3..a901d34 100644 --- a/pkg/api/health.go +++ b/pkg/api/health.go @@ -85,7 +85,7 @@ func NewHealthHandler(cfg HealthConfig) http.HandlerFunc { var wg sync.WaitGroup for name, checker := range cfg.Checks { - wg.Add(1) + wg.Add(1) // TODO: Migrate to wg.Go() (Go 1.25) go func(name string, checker HealthChecker) { defer wg.Done()