Files
homelab-frontend/internal/webhook/promoter.go
T

425 lines
14 KiB
Go
Raw Normal View History

// 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
}