From 23fff1006aadec34c5e56acfcb9fa8b8594dcb9b Mon Sep 17 00:00:00 2001 From: Rock Date: Mon, 28 Sep 2026 02:45:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20Forgejo=20package=20webhook=20?= =?UTF-8?q?=E2=86=92=20ArgoCD=20image=20promotion=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #54 Replaces ArgoCD Image Updater with webhook-driven promotion. ## What changed | File | Change | |---|---| | `internal/webhook/promoter.go` | NEW — ImagePromoter + promotion table (10 entries) | | `internal/webhook/promoter_test.go` | NEW — 13 tests | | `internal/webhook/forgejo.go` | package event routed to ImagePromoter | | `k8s/rbac.yaml` | NEW — ClusterRole image-promoter + ClusterRoleBinding | | `k8s/deployment.yaml` | REGISTRY_PAT env var | | `k8s/network-policy.yaml` | egress cicd:3000 + default:443 | | `k8s/kustomization.yaml` | rbac.yaml added | ## homelab repo changes needed See comment on issue #54 for exact YAML. --------- Co-authored-by: Admin Bot Reviewed-on: https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend/pulls/55 --- internal/webhook/forgejo.go | 102 +++++++- internal/webhook/promoter.go | 419 ++++++++++++++++++++++++++++++ internal/webhook/promoter_test.go | 405 +++++++++++++++++++++++++++++ k8s/deployment.yaml | 6 + k8s/kustomization.yaml | 1 + k8s/network-policy.yaml | 16 ++ k8s/rbac.yaml | 28 ++ 7 files changed, 968 insertions(+), 9 deletions(-) create mode 100644 internal/webhook/promoter.go create mode 100644 internal/webhook/promoter_test.go create mode 100644 k8s/rbac.yaml diff --git a/internal/webhook/forgejo.go b/internal/webhook/forgejo.go index fd8ee43..5aca8f5 100644 --- a/internal/webhook/forgejo.go +++ b/internal/webhook/forgejo.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strings" + "time" "forgejo.riotpiao.com/rock/homelab-frontend/internal/notification" ) @@ -18,8 +19,9 @@ import ( // ForgejoHandler receives Forgejo webhook payloads and forwards them to Gotify. // Forgejo sends a Gitea-compatible JSON payload with X-Gitea-Signature-256 header. type ForgejoHandler struct { - secret string - gotify *notification.GotifyClient + secret string + gotify *notification.GotifyClient + promoter *ImagePromoter } // NewForgejoHandler creates a handler from environment variables. @@ -35,8 +37,9 @@ func NewForgejoHandler() *ForgejoHandler { } return &ForgejoHandler{ - secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"), - gotify: client, + secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"), + gotify: client, + promoter: NewImagePromoter(), } } @@ -65,17 +68,23 @@ func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + event := r.Header.Get("X-Gitea-Event") + if event == "" { + event = r.Header.Get("X-GitHub-Event") + } + + // package events trigger image promotion; handled independently of Gotify + if event == "package" { + h.handlePackagePromotion(w, r, body) + return + } + if h.gotify == nil { log.Printf("forgejo webhook received but Gotify not configured (GOTIFY_URL/GOTIFY_APP_TOKEN missing)") w.WriteHeader(http.StatusOK) return } - event := r.Header.Get("X-Gitea-Event") - if event == "" { - event = r.Header.Get("X-GitHub-Event") - } - title, message, priority := h.formatMessage(event, body) if title == "" { // Unhandled event type — ack and ignore @@ -99,6 +108,81 @@ func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// handlePackagePromotion processes a Forgejo package webhook event. +// It promotes container images by patching ArgoCD Application CRs. +// Sends a Gotify notification with the result (success or failure). +func (h *ForgejoHandler) handlePackagePromotion(w http.ResponseWriter, r *http.Request, body []byte) { + // Generate a short request ID for correlating log lines from concurrent promotions. + reqID := fmt.Sprintf("%08x", time.Now().UnixNano()&0xFFFFFFFF) + + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + log.Printf("forgejo webhook [%s]: package event: invalid JSON: %v", reqID, err) + w.WriteHeader(http.StatusOK) + return + } + + action := fmt.Sprintf("%v", payload["action"]) + if action != "created" { + // Ignore deletions and other actions + w.WriteHeader(http.StatusOK) + return + } + + pkg, _ := payload["package"].(map[string]interface{}) + pkgType := fmt.Sprintf("%v", pkg["type"]) + if pkgType != "container" { + // Only promote container images + w.WriteHeader(http.StatusOK) + return + } + + owner := jsonStr(pkg, "owner", "login") + name, _ := pkg["name"].(string) + version, _ := pkg["version"].(string) + + if owner == "" || name == "" || version == "" { + log.Printf("forgejo webhook [%s]: package event: missing owner/name/version in payload", reqID) + w.WriteHeader(http.StatusOK) + return + } + + image := fmt.Sprintf("forgejo.riotpiao.com/%s/%s", owner, name) + log.Printf("forgejo webhook [%s]: package event action=%s image=%s tag=%s", reqID, action, image, version) + + var title, message string + var priority int + + if h.promoter == nil { + title = fmt.Sprintf("📦 Image pushed: %s:%s", image, version) + message = "Image promotion disabled — REGISTRY_PAT not configured" + priority = 3 + log.Printf("forgejo webhook [%s]: promoter disabled, image=%s tag=%s", reqID, image, version) + } else if err := h.promoter.Promote(r.Context(), image, version); err != nil { + title = fmt.Sprintf("❌ Promotion failed: %s:%s", image, version) + message = err.Error() + priority = 8 + log.Printf("forgejo webhook [%s]: image promotion failed: %v", reqID, err) + } else { + title = fmt.Sprintf("🚀 Promoted: %s:%s", image, version) + message = "ArgoCD Application patched — redeployment triggered" + priority = 5 + log.Printf("forgejo webhook [%s]: promoted image=%s tag=%s", reqID, image, version) + } + + if h.gotify != nil && title != "" { + if _, err := h.gotify.SendMessage(notification.GotifyMessage{ + Title: title, + Message: message, + Priority: priority, + }); err != nil { + log.Printf("forgejo webhook: failed to send gotify notification: %v", err) + } + } + + w.WriteHeader(http.StatusOK) +} + // verifySignature checks X-Gitea-Signature-256: sha256= func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool { sig = strings.TrimPrefix(sig, "sha256=") diff --git a/internal/webhook/promoter.go b/internal/webhook/promoter.go new file mode 100644 index 0000000..ba00ed6 --- /dev/null +++ b/internal/webhook/promoter.go @@ -0,0 +1,419 @@ +// Package webhook — ImagePromoter patches ArgoCD Application CRs when a +// Forgejo container package webhook fires. It replaces ArgoCD Image Updater +// with a webhook-driven flow: +// +// 1. Forgejo fires X-Gitea-Event: package (action: created, type: container) +// 2. ForgejoHandler calls ImagePromoter.Promote(image, tag) +// 3. Promoter fetches the digest from Forgejo registry v2 API +// 4. Promoter patches the ArgoCD Application CR via the k8s API server +// using the in-cluster ServiceAccount token. +// +// No external dependencies — raw HTTP only. +package webhook + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +const ( + k8sAPIHost = "https://kubernetes.default.svc" + argoNamespace = "argocd" + saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + saCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + defaultRegBase = "http://forgejo-gitea.cicd.svc.cluster.local:3000" + defaultRegUser = "rock" + promoterTimeout = 20 * time.Second +) + +// promotionEntry maps a fully-qualified container image to an ArgoCD Application. +type promotionEntry struct { + Image string // e.g. "forgejo.riotpiao.com/rock/api-gateway" + Application string // ArgoCD Application name in namespace argocd + SourceIndex int // index into spec.sources[]; -1 = spec.source (singular, helm) + Type string // "kustomize" or "helm" +} + +// promotionTable is the static image-to-application mapping. +// Order matters for poimen-toolkit: two images share the same source index. +var promotionTable = []promotionEntry{ + {"forgejo.riotpiao.com/rock/api-gateway", "api-gw", 0, "kustomize"}, + {"forgejo.riotpiao.com/rock/portfolio", "portfolio", 0, "kustomize"}, + {"forgejo.riotpiao.com/riotpiao-poimen/poimen-workflows", "poimen", 0, "kustomize"}, + {"forgejo.riotpiao.com/riotpiao-poimen/poimen-memory", "poimen", 1, "kustomize"}, + // sourceIndex 2 in poimen is a non-container source (chart/config repo) — not promoted. + {"forgejo.riotpiao.com/riotpiao-poimen/poimen-frontend", "poimen", 3, "kustomize"}, + {"forgejo.riotpiao.com/riotpiao-poimen/poimen-toolkit-controller", "poimen-toolkit", 0, "kustomize"}, + {"forgejo.riotpiao.com/riotpiao-poimen/pi-worker", "poimen-toolkit", 0, "kustomize"}, + {"forgejo.riotpiao.com/rock/forgejo-runner-golang", "forgejo-runner-golang", -1, "helm"}, + {"forgejo.riotpiao.com/rock/forgejo-runner-node", "forgejo-runner-node", -1, "helm"}, + {"forgejo.riotpiao.com/rock/forgejo-runner-rust", "forgejo-runner-rust", -1, "helm"}, +} + +// ImagePromoter fetches digests and patches ArgoCD Applications. +type ImagePromoter struct { + registryBase string + registryUser string + registryPAT string + k8sHost string // k8s API server base URL; defaults to k8sAPIHost + k8sClient *http.Client // mTLS with SA CA cert + regClient *http.Client // plain HTTP to internal Forgejo +} + +// NewImagePromoter builds an ImagePromoter from environment and in-cluster SA files. +// Returns nil (not an error) when REGISTRY_PAT is empty — promoter is disabled. +func NewImagePromoter() *ImagePromoter { + pat := os.Getenv("REGISTRY_PAT") + if pat == "" { + return nil + } + + regBase := os.Getenv("REGISTRY_BASE") + if regBase == "" { + regBase = defaultRegBase + } + regUser := os.Getenv("REGISTRY_USER") + if regUser == "" { + regUser = defaultRegUser + } + + caCert, _ := os.ReadFile(saCACertPath) + return &ImagePromoter{ + registryBase: regBase, + registryUser: regUser, + registryPAT: pat, + k8sClient: buildK8sClient(caCert), + regClient: &http.Client{Timeout: promoterTimeout}, + } +} + +// buildK8sClient creates an HTTP client that trusts the in-cluster CA cert. +func buildK8sClient(caCert []byte) *http.Client { + transport := &http.Transport{} + if len(caCert) > 0 { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + transport.TLSClientConfig = &tls.Config{RootCAs: pool} + } else { + log.Printf("image-promoter: WARNING SA CA cert not found at %s — using InsecureSkipVerify (dev mode only)", saCACertPath) + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + } + return &http.Client{Timeout: promoterTimeout, Transport: transport} +} + +// saToken reads the current in-cluster ServiceAccount token. +// Re-reads on every call to handle token rotation. +func saToken() string { + b, err := os.ReadFile(saTokenPath) + if err != nil { + log.Printf("image-promoter: WARNING failed to read SA token %s: %v", saTokenPath, err) + return "" + } + return strings.TrimSpace(string(b)) +} + +// Promote fetches the digest for image:tag and patches the corresponding +// ArgoCD Application CR. Returns an error if the image is unknown. +// Retries up to 3 times on k8s 409 Conflict (optimistic concurrency for apps +// like poimen-toolkit that share a source index across multiple images). +func (p *ImagePromoter) Promote(ctx context.Context, image, tag string) error { + entry := lookupEntry(image) + if entry == nil { + return fmt.Errorf("image %q not in promotion table — skipping", image) + } + + // Parse owner/name from "forgejo.riotpiao.com/owner/name" + withoutHost := strings.TrimPrefix(image, "forgejo.riotpiao.com/") + parts := strings.SplitN(withoutHost, "/", 2) + if len(parts) != 2 { + return fmt.Errorf("cannot parse owner/name from image %q", image) + } + owner, name := parts[0], parts[1] + + digest, err := p.fetchDigest(ctx, owner, name, tag) + if err != nil { + return fmt.Errorf("fetch digest for %s:%s: %w", image, tag, err) + } + + log.Printf("image-promoter: digest %s → %s application=%s sourceIndex=%d", + image, digest, entry.Application, entry.SourceIndex) + + // Retry on 409 Conflict: concurrent promotions for the same (app, sourceIndex) + // (e.g. poimen-toolkit-controller and pi-worker) can race on the same source slot. + for attempt := 1; attempt <= 3; attempt++ { + err = p.patchApplication(ctx, *entry, image, digest) + if err == nil { + return nil + } + if !strings.Contains(err.Error(), "409") || attempt == 3 { + return err + } + log.Printf("image-promoter: 409 conflict on PATCH %s (attempt %d/3), retrying", + entry.Application, attempt) + select { + case <-time.After(time.Duration(attempt) * 150 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + } + return err +} + +// lookupEntry returns the promotion entry for the given image, or nil if not found. +func lookupEntry(image string) *promotionEntry { + for i := range promotionTable { + if promotionTable[i].Image == image { + return &promotionTable[i] + } + } + return nil +} + +// fetchDigest queries the Forgejo OCI registry v2 API for the manifest digest. +// Uses HTTP Basic auth with the configured PAT. +func (p *ImagePromoter) fetchDigest(ctx context.Context, owner, name, tag string) (string, error) { + url := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", p.registryBase, owner, name, tag) + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.SetBasicAuth(p.registryUser, p.registryPAT) + req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json") + req.Header.Add("Accept", "application/vnd.oci.image.manifest.v1+json") + + resp, err := p.regClient.Do(req) + if err != nil { + return "", fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("registry returned %d for %s/%s:%s", resp.StatusCode, owner, name, tag) + } + + digest := resp.Header.Get("Docker-Content-Digest") + if digest == "" { + return "", fmt.Errorf("Docker-Content-Digest header missing from registry response") + } + return digest, nil +} + +// patchApplication performs a read-modify-write on the ArgoCD Application CR. +// It reads the current spec to preserve existing image entries, updates the +// relevant entry, then sends a JSON Patch to the k8s API server. +func (p *ImagePromoter) patchApplication(ctx context.Context, entry promotionEntry, image, digest string) error { + app, err := p.getApplication(ctx, entry.Application) + if err != nil { + return fmt.Errorf("get application %s: %w", entry.Application, err) + } + + var patch []map[string]any + + switch entry.Type { + case "kustomize": + images := getKustomizeImages(app, entry.SourceIndex) + newImages := updateImageEntry(images, image, digest) + path := fmt.Sprintf("/spec/sources/%d/kustomize/images", entry.SourceIndex) + patch = []map[string]any{ + {"op": "add", "path": path, "value": newImages}, + } + + case "helm": + // Helm runners use spec.source (singular). Patch helm parameters + // to set runner.image.repository and runner.image.tag with digest. + existing := getHelmParameters(app) + updates := []map[string]string{ + {"name": "runner.image.repository", "value": image}, + {"name": "runner.image.tag", "value": "latest@" + digest}, + } + merged := mergeHelmParams(existing, updates) + patch = []map[string]any{ + // "add" works whether parameters key exists or not (replace fails on absent key) + {"op": "add", "path": "/spec/source/helm/parameters", "value": merged}, + } + + default: + return fmt.Errorf("unknown promotion type %q for %s", entry.Type, entry.Application) + } + + return p.applyJSONPatch(ctx, entry.Application, patch) +} + +// k8sBase returns the configured k8s host or the default in-cluster address. +func (p *ImagePromoter) k8sBase() string { + if p.k8sHost != "" { + return p.k8sHost + } + return k8sAPIHost +} + +// getApplication fetches the current ArgoCD Application CR from the k8s API. +func (p *ImagePromoter) getApplication(ctx context.Context, name string) (map[string]any, error) { + url := fmt.Sprintf("%s/apis/argoproj.io/v1alpha1/namespaces/%s/applications/%s", + p.k8sBase(), argoNamespace, name) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+saToken()) + + resp, err := p.k8sClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("k8s GET returned %d: %s", resp.StatusCode, body) + } + + var app map[string]any + if err := json.NewDecoder(resp.Body).Decode(&app); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return app, nil +} + +// applyJSONPatch sends RFC 6902 JSON Patch to the ArgoCD Application. +func (p *ImagePromoter) applyJSONPatch(ctx context.Context, appName string, patch []map[string]any) error { + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("marshal patch: %w", err) + } + + url := fmt.Sprintf("%s/apis/argoproj.io/v1alpha1/namespaces/%s/applications/%s", + p.k8sBase(), argoNamespace, appName) + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(patchBytes)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json-patch+json") + req.Header.Set("Authorization", "Bearer "+saToken()) + + resp, err := p.k8sClient.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("k8s PATCH returned %d: %s", resp.StatusCode, body) + } + return nil +} + +// ── spec helpers ───────────────────────────────────────────────────────────── + +// getKustomizeImages extracts spec.sources[N].kustomize.images. +// Returns nil if the path does not exist. +func getKustomizeImages(app map[string]any, sourceIndex int) []string { + spec, _ := app["spec"].(map[string]any) + sources, _ := spec["sources"].([]any) + if sourceIndex < 0 || sourceIndex >= len(sources) { + return nil + } + src, _ := sources[sourceIndex].(map[string]any) + kust, _ := src["kustomize"].(map[string]any) + return toStringSlice(kust["images"]) +} + +// getHelmParameters extracts spec.source.helm.parameters. +func getHelmParameters(app map[string]any) []map[string]string { + spec, _ := app["spec"].(map[string]any) + source, _ := spec["source"].(map[string]any) + helm, _ := source["helm"].(map[string]any) + raw, _ := helm["parameters"].([]any) + + params := make([]map[string]string, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + p := map[string]string{} + if n, ok := m["name"].(string); ok { + p["name"] = n + } + if v, ok := m["value"].(string); ok { + p["value"] = v + } + params = append(params, p) + } + return params +} + +// updateImageEntry replaces the entry for image in the images slice, or appends +// if not present. The new entry format is "{image}@{digest}". +// Matching is by image name (prefix before ':' or '@'). +func updateImageEntry(images []string, image, digest string) []string { + newEntry := image + "@" + digest + for i, img := range images { + // Strip tag/digest suffix to get the bare name + bare := img + if idx := strings.IndexAny(img, ":@"); idx >= 0 { + bare = img[:idx] + } + if bare == image { + result := make([]string, len(images)) + copy(result, images) + result[i] = newEntry + return result + } + } + return append(images, newEntry) +} + +// mergeHelmParams merges update params into existing, preserving unrelated params. +func mergeHelmParams(existing, updates []map[string]string) []map[string]string { + idx := make(map[string]string, len(updates)) + for _, u := range updates { + idx[u["name"]] = u["value"] + } + + result := make([]map[string]string, 0, len(existing)+len(updates)) + replaced := make(map[string]bool) + for _, p := range existing { + if val, ok := idx[p["name"]]; ok { + result = append(result, map[string]string{"name": p["name"], "value": val}) + replaced[p["name"]] = true + } else { + result = append(result, p) + } + } + for _, u := range updates { + if !replaced[u["name"]] { + result = append(result, u) + } + } + return result +} + +// toStringSlice converts []any to []string, skipping non-strings. +func toStringSlice(v any) []string { + arr, ok := v.([]any) + if !ok { + return nil + } + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result +} diff --git a/internal/webhook/promoter_test.go b/internal/webhook/promoter_test.go new file mode 100644 index 0000000..946ad95 --- /dev/null +++ b/internal/webhook/promoter_test.go @@ -0,0 +1,405 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// ── unit: updateImageEntry ──────────────────────────────────────────────────── + +func TestUpdateImageEntry(t *testing.T) { + t.Run("replaces existing by bare name strips tag", func(t *testing.T) { + images := []string{"forgejo.riotpiao.com/rock/api-gateway:old"} + got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") + if len(got) != 1 || got[0] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { + t.Fatalf("got %v", got) + } + }) + + t.Run("replaces existing by bare name strips digest", func(t *testing.T) { + images := []string{"forgejo.riotpiao.com/rock/api-gateway@sha256:old"} + got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") + if len(got) != 1 || got[0] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { + t.Fatalf("got %v", got) + } + }) + + t.Run("appends new entry when image not in list", func(t *testing.T) { + images := []string{"forgejo.riotpiao.com/rock/other:latest"} + got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") + if len(got) != 2 || got[1] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { + t.Fatalf("got %v", got) + } + }) + + t.Run("preserves other entries when updating one", func(t *testing.T) { + images := []string{ + "forgejo.riotpiao.com/rock/other:1.0", + "forgejo.riotpiao.com/rock/api-gateway:old", + "forgejo.riotpiao.com/rock/third:2.0", + } + got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") + if len(got) != 3 { + t.Fatalf("expected 3 entries got %d: %v", len(got), got) + } + if got[0] != "forgejo.riotpiao.com/rock/other:1.0" { + t.Errorf("first entry changed: %s", got[0]) + } + if got[1] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { + t.Errorf("second entry wrong: %s", got[1]) + } + if got[2] != "forgejo.riotpiao.com/rock/third:2.0" { + t.Errorf("third entry changed: %s", got[2]) + } + }) +} + +// ── unit: mergeHelmParams ───────────────────────────────────────────────────── + +func TestMergeHelmParams(t *testing.T) { + t.Run("update existing param preserve unrelated", func(t *testing.T) { + existing := []map[string]string{ + {"name": "runner.image.tag", "value": "old"}, + {"name": "replicas", "value": "2"}, + } + updates := []map[string]string{ + {"name": "runner.image.tag", "value": "new"}, + } + got := mergeHelmParams(existing, updates) + find := func(name string) string { + for _, p := range got { + if p["name"] == name { + return p["value"] + } + } + return "" + } + if find("runner.image.tag") != "new" { + t.Errorf("tag not updated: %v", got) + } + if find("replicas") != "2" { + t.Errorf("replicas lost: %v", got) + } + }) + + t.Run("append new param not in existing list", func(t *testing.T) { + existing := []map[string]string{ + {"name": "replicas", "value": "2"}, + } + updates := []map[string]string{ + {"name": "runner.image.tag", "value": "new"}, + } + got := mergeHelmParams(existing, updates) + if len(got) != 2 { + t.Fatalf("expected 2 params got %d: %v", len(got), got) + } + }) + + t.Run("empty existing list just returns updates", func(t *testing.T) { + updates := []map[string]string{ + {"name": "runner.image.tag", "value": "new"}, + } + got := mergeHelmParams(nil, updates) + if len(got) != 1 || got[0]["name"] != "runner.image.tag" { + t.Fatalf("got %v", got) + } + }) +} + +// ── unit: lookupEntry ───────────────────────────────────────────────────────── + +func TestLookupEntry(t *testing.T) { + t.Run("known image returns entry", func(t *testing.T) { + e := lookupEntry("forgejo.riotpiao.com/rock/api-gateway") + if e == nil { + t.Fatal("expected entry, got nil") + } + if e.Application != "api-gw" { + t.Errorf("wrong app: %s", e.Application) + } + }) + + t.Run("unknown image returns nil", func(t *testing.T) { + e := lookupEntry("forgejo.riotpiao.com/rock/unknown") + if e != nil { + t.Fatalf("expected nil, got %+v", e) + } + }) +} + +// ── unit: toStringSlice ─────────────────────────────────────────────────────── + +func TestToStringSlice(t *testing.T) { + t.Run("skips non-string elements", func(t *testing.T) { + got := toStringSlice([]any{"a", "b", 42}) + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("got %v", got) + } + }) + + t.Run("nil input returns nil", func(t *testing.T) { + got := toStringSlice(nil) + if got != nil { + t.Fatalf("expected nil, got %v", got) + } + }) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func newPromoter(regURL, k8sURL string) *ImagePromoter { + return &ImagePromoter{ + registryBase: regURL, + registryUser: "testuser", + registryPAT: "testpat", + k8sHost: k8sURL, + k8sClient: &http.Client{Timeout: 5 * time.Second}, + regClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +// ── integration: fetchDigest ────────────────────────────────────────────────── + +func TestFetchDigest_Success(t *testing.T) { + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Docker-Content-Digest", "sha256:abc123") + w.WriteHeader(http.StatusOK) + })) + defer reg.Close() + + ctx := context.Background() + p := newPromoter(reg.URL, "") + digest, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if digest != "sha256:abc123" { + t.Errorf("got digest %q", digest) + } +} + +func TestFetchDigest_NotFound(t *testing.T) { + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer reg.Close() + + ctx := context.Background() + p := newPromoter(reg.URL, "") + _, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error should contain 404: %v", err) + } +} + +func TestFetchDigest_MissingDigestHeader(t *testing.T) { + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // No Docker-Content-Digest header + })) + defer reg.Close() + + ctx := context.Background() + p := newPromoter(reg.URL, "") + _, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Docker-Content-Digest") { + t.Errorf("error should mention Docker-Content-Digest: %v", err) + } +} + +// ── integration: Promote kustomize ─────────────────────────────────────────── + +func TestPromote_KustomizeSuccess(t *testing.T) { + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Docker-Content-Digest", "sha256:newdigest") + w.WriteHeader(http.StatusOK) + })) + defer reg.Close() + + kustomizeApp := map[string]any{ + "spec": map[string]any{ + "sources": []any{ + map[string]any{ + "kustomize": map[string]any{ + "images": []any{"forgejo.riotpiao.com/rock/api-gateway:latest"}, + }, + }, + }, + }, + } + + var getCalled, patchCalled int32 + k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + atomic.AddInt32(&getCalled, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(mustJSON(kustomizeApp)) + case http.MethodPatch: + atomic.AddInt32(&patchCalled, 1) + raw, _ := readAll(r.Body) + if !strings.Contains(string(raw), "/spec/sources/0/kustomize/images") { + t.Errorf("patch path missing: %s", raw) + } + if !strings.Contains(string(raw), "@sha256:newdigest") { + t.Errorf("digest missing from patch body: %s", raw) + } + w.WriteHeader(http.StatusOK) + } + })) + defer k8s.Close() + + p := newPromoter(reg.URL, k8s.URL) + ctx := context.Background() + if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/api-gateway", "latest"); err != nil { + t.Fatalf("Promote error: %v", err) + } + if atomic.LoadInt32(&getCalled) == 0 { + t.Error("k8s GET not called") + } + if atomic.LoadInt32(&patchCalled) == 0 { + t.Error("k8s PATCH not called") + } +} + +// ── integration: Promote helm ───────────────────────────────────────────────── + +func TestPromote_HelmSuccess(t *testing.T) { + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Docker-Content-Digest", "sha256:helmdigest") + w.WriteHeader(http.StatusOK) + })) + defer reg.Close() + + helmApp := map[string]any{ + "spec": map[string]any{ + "source": map[string]any{ + "helm": map[string]any{ + "parameters": []any{ + map[string]any{"name": "runner.image.tag", "value": "old"}, + }, + }, + }, + }, + } + + var patchCalled int32 + k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(mustJSON(helmApp)) + case http.MethodPatch: + atomic.AddInt32(&patchCalled, 1) + raw, _ := readAll(r.Body) + if !strings.Contains(string(raw), "/spec/source/helm/parameters") { + t.Errorf("patch path missing: %s", raw) + } + if !strings.Contains(string(raw), "helmdigest") { + t.Errorf("digest missing from patch body: %s", raw) + } + w.WriteHeader(http.StatusOK) + } + })) + defer k8s.Close() + + p := newPromoter(reg.URL, k8s.URL) + ctx := context.Background() + if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/forgejo-runner-golang", "latest"); err != nil { + t.Fatalf("Promote error: %v", err) + } + if atomic.LoadInt32(&patchCalled) == 0 { + t.Error("k8s PATCH not called") + } +} + +// ── integration: Promote unknown image ─────────────────────────────────────── + +func TestPromote_UnknownImage(t *testing.T) { + p := newPromoter("http://unused", "http://unused") + ctx := context.TODO() + err := p.Promote(ctx, "forgejo.riotpiao.com/rock/unknown-image", "latest") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "not in promotion table") { + t.Errorf("error should mention 'not in promotion table': %v", err) + } +} + +// ── integration: Promote conflict retry ────────────────────────────────────── + +func TestPromote_Conflict_Retry(t *testing.T) { + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Docker-Content-Digest", "sha256:retrydigest") + w.WriteHeader(http.StatusOK) + })) + defer reg.Close() + + kustomizeApp := map[string]any{ + "spec": map[string]any{ + "sources": []any{ + map[string]any{ + "kustomize": map[string]any{ + "images": []any{"forgejo.riotpiao.com/rock/api-gateway:latest"}, + }, + }, + }, + }, + } + + var patchCount int32 + k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(mustJSON(kustomizeApp)) + case http.MethodPatch: + n := atomic.AddInt32(&patchCount, 1) + if n < 3 { + w.WriteHeader(http.StatusConflict) // 409 + return + } + w.WriteHeader(http.StatusOK) + } + })) + defer k8s.Close() + + p := newPromoter(reg.URL, k8s.URL) + ctx := context.Background() + if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/api-gateway", "latest"); err != nil { + t.Fatalf("Promote error after retries: %v", err) + } + if got := atomic.LoadInt32(&patchCount); got != 3 { + t.Errorf("expected 3 PATCH calls, got %d", got) + } +} + +// readAll is a test helper to read request body. +func readAll(r io.Reader) ([]byte, error) { + return io.ReadAll(r) +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index f4c78e0..4c8ab0f 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -95,6 +95,12 @@ spec: name: gotify-webhook-secret key: forgejo-webhook-secret optional: true + - name: REGISTRY_PAT + valueFrom: + secretKeyRef: + name: forgejo-registry-pat + key: pat + optional: true volumeMounts: - name: config mountPath: /etc/gateway diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 844575b..fa358d3 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -9,6 +9,7 @@ resources: - deployment.yaml - network-policy.yaml - gateway-config-secret.yaml +- rbac.yaml # The deployed image tag lives here and nowhere else. CI publishes # forgejo.riotpiao.com/rock/api-gateway: and tags it as :latest on main. diff --git a/k8s/network-policy.yaml b/k8s/network-policy.yaml index 3da5f8d..9583bf0 100644 --- a/k8s/network-policy.yaml +++ b/k8s/network-policy.yaml @@ -150,3 +150,19 @@ spec: port: 80 - protocol: TCP port: 9000 + # Allow to Forgejo registry v2 API (image digest lookup for promotion) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: cicd + ports: + - protocol: TCP + port: 3000 + # Allow to kubernetes API server (ArgoCD Application patching via in-cluster SA) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: default + ports: + - protocol: TCP + port: 443 diff --git a/k8s/rbac.yaml b/k8s/rbac.yaml new file mode 100644 index 0000000..fe55b43 --- /dev/null +++ b/k8s/rbac.yaml @@ -0,0 +1,28 @@ +# RBAC for api-gateway image promotion. +# Grants the api-gateway ServiceAccount get+patch on ArgoCD Applications +# so it can update kustomize.images / helm.parameters after a container push. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: image-promoter + labels: + app: api-gateway +rules: +- apiGroups: ["argoproj.io"] + resources: ["applications"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: image-promoter-api-gateway + labels: + app: api-gateway +subjects: +- kind: ServiceAccount + name: api-gateway + namespace: api +roleRef: + kind: ClusterRole + name: image-promoter + apiGroup: rbac.authorization.k8s.io