feat(webhook): Forgejo package webhook \u2192 ArgoCD image promotion (#54)
CI / CI (pull_request) Successful in 2m11s

Replaces ArgoCD Image Updater with a webhook-driven approach.

When Forgejo fires X-Gitea-Event: package (action: created, type: container):
1. Extract image owner+name+version from payload
2. Look up in static promotion table (10 images \u2192 ArgoCD apps)
3. Fetch digest from Forgejo registry v2 API (Basic auth, REGISTRY_PAT)
4. Read-modify-write ArgoCD Application CR via k8s API (in-cluster SA token)
   - kustomize: JSON Patch /spec/sources[N]/kustomize/images
   - helm:      JSON Patch /spec/source/helm/parameters (runner.image.*)
5. Send Gotify notification with promotion result

Key design:
- Raw http.Client only \u2014 no client-go dependency
- context.Context propagated through all outbound calls
- Retry up to 3x on 409 Conflict (concurrent poimen-toolkit promotions)
- Request ID per invocation for log correlation
- Promotion table covers: api-gw, portfolio, poimen (3 sources), poimen-toolkit (2 images), forgejo-runner-{golang,node,rust}

RBAC: ClusterRole image-promoter (get+patch applications.argoproj.io) bound to api-gateway SA
NetworkPolicy: egress to cicd:3000 (registry) + default:443 (k8s API)
Deployment: REGISTRY_PAT from forgejo-registry-pat secret
13 tests in promoter_test.go, all pass

Closes #54
This commit is contained in:
Admin Bot
2026-09-28 10:41:53 +08:00
parent a24e4f7174
commit 1a122b0046
7 changed files with 968 additions and 9 deletions
+93 -9
View File
@@ -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=<hex>
func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool {
sig = strings.TrimPrefix(sig, "sha256=")