feat(webhook): Forgejo package webhook → ArgoCD image promotion (#55)
CI / CI (push) Successful in 3m21s
CI / CI (push) Successful in 3m21s
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 <[email protected]> Reviewed-on: #55
This commit was merged in pull request #55.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
||||||
)
|
)
|
||||||
@@ -18,8 +19,9 @@ import (
|
|||||||
// ForgejoHandler receives Forgejo webhook payloads and forwards them to Gotify.
|
// ForgejoHandler receives Forgejo webhook payloads and forwards them to Gotify.
|
||||||
// Forgejo sends a Gitea-compatible JSON payload with X-Gitea-Signature-256 header.
|
// Forgejo sends a Gitea-compatible JSON payload with X-Gitea-Signature-256 header.
|
||||||
type ForgejoHandler struct {
|
type ForgejoHandler struct {
|
||||||
secret string
|
secret string
|
||||||
gotify *notification.GotifyClient
|
gotify *notification.GotifyClient
|
||||||
|
promoter *ImagePromoter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewForgejoHandler creates a handler from environment variables.
|
// NewForgejoHandler creates a handler from environment variables.
|
||||||
@@ -35,8 +37,9 @@ func NewForgejoHandler() *ForgejoHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &ForgejoHandler{
|
return &ForgejoHandler{
|
||||||
secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"),
|
secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"),
|
||||||
gotify: client,
|
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 {
|
if h.gotify == nil {
|
||||||
log.Printf("forgejo webhook received but Gotify not configured (GOTIFY_URL/GOTIFY_APP_TOKEN missing)")
|
log.Printf("forgejo webhook received but Gotify not configured (GOTIFY_URL/GOTIFY_APP_TOKEN missing)")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event := r.Header.Get("X-Gitea-Event")
|
|
||||||
if event == "" {
|
|
||||||
event = r.Header.Get("X-GitHub-Event")
|
|
||||||
}
|
|
||||||
|
|
||||||
title, message, priority := h.formatMessage(event, body)
|
title, message, priority := h.formatMessage(event, body)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
// Unhandled event type — ack and ignore
|
// Unhandled event type — ack and ignore
|
||||||
@@ -99,6 +108,81 @@ func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
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>
|
// verifySignature checks X-Gitea-Signature-256: sha256=<hex>
|
||||||
func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool {
|
func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool {
|
||||||
sig = strings.TrimPrefix(sig, "sha256=")
|
sig = strings.TrimPrefix(sig, "sha256=")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -95,6 +95,12 @@ spec:
|
|||||||
name: gotify-webhook-secret
|
name: gotify-webhook-secret
|
||||||
key: forgejo-webhook-secret
|
key: forgejo-webhook-secret
|
||||||
optional: true
|
optional: true
|
||||||
|
- name: REGISTRY_PAT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: forgejo-registry-pat
|
||||||
|
key: pat
|
||||||
|
optional: true
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /etc/gateway
|
mountPath: /etc/gateway
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ resources:
|
|||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- network-policy.yaml
|
- network-policy.yaml
|
||||||
- gateway-config-secret.yaml
|
- gateway-config-secret.yaml
|
||||||
|
- rbac.yaml
|
||||||
|
|
||||||
# The deployed image tag lives here and nowhere else. CI publishes
|
# The deployed image tag lives here and nowhere else. CI publishes
|
||||||
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
||||||
|
|||||||
@@ -150,3 +150,19 @@ spec:
|
|||||||
port: 80
|
port: 80
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 9000
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user