feat(webhook): Forgejo package webhook \u2192 ArgoCD image promotion (#54)
CI / CI (pull_request) Successful in 2m11s
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:
@@ -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=")
|
||||
|
||||
Reference in New Issue
Block a user