// 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-http.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) // Patch the full kustomize block (not just /kustomize/images) so the // op succeeds even when the kustomize key is absent from the source. // These apps only use kustomize overrides for image pinning, so // replacing the whole block is safe. patch = []map[string]any{{ "op": "add", "path": fmt.Sprintf("/spec/sources/%d/kustomize", entry.SourceIndex), "value": map[string]any{"images": 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 }