fix: wait for webhook secret refresh

This commit is contained in:
eding 2026-08-01 22:49:24 +02:00
parent 979a950d33
commit 676e89de40
2 changed files with 214 additions and 20 deletions

View file

@ -45,6 +45,21 @@ type operationalSecrets struct {
var initializeOpenBao = openbao.Initialize var initializeOpenBao = openbao.Initialize
var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error {
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, cfg.Flux.ManifestsRepo, cfg.Flux.RepoName, cfg.Flux.Branch, "maidn/bootstrap-"+cfg.ClusterID)
return manager.EnsureWebhook(repo, webhookURL, authorization)
}
var runWebhookCommand = utils.RunCommandQuietOutputInDir
var webhookTargetTimeout = 70 * time.Minute
var webhookTargetPollInterval = 2 * time.Second
var templateBaseComponents = []string{"snapshot-crds", "democratic-csi", "cert-manager", "cluster-issuers", "gateway-api", "gateway", "monitoring", "openbao", "external-secrets", "external-dns", "tekton", "tekton-triggers"}
var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"}
func (r Runner) Run() error { func (r Runner) Run() error {
resolved, err := config.Resolve(r.Config) resolved, err := config.Resolve(r.Config)
if err != nil { if err != nil {
@ -112,11 +127,9 @@ func (r Runner) Run() error {
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil { if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
return err return err
} }
for _, component := range []string{"snapshot-crds", "democratic-csi", "cert-manager", "cluster-issuers", "gateway-api", "gateway", "monitoring", "openbao", "external-secrets", "external-secrets-config", "external-dns", "tekton", "tekton-triggers"} { if err := copyTemplateBaseComponents(cicdTemplateDir, dir); err != nil {
if err := copyDir(filepath.Join(cicdTemplateDir, "base", component), filepath.Join(dir, "base", component), true); err != nil {
return err return err
} }
}
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil { if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
return err return err
} }
@ -227,7 +240,7 @@ func (r Runner) Run() error {
} }
func (r Runner) reconcileWebhook(generatedDir string) error { func (r Runner) reconcileWebhook(generatedDir string) error {
operationalSecrets, err := openbao.Initialize(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
if err != nil { if err != nil {
return fmt.Errorf("initialize OpenBao: %w", err) return fmt.Errorf("initialize OpenBao: %w", err)
} }
@ -235,11 +248,10 @@ func (r Runner) reconcileWebhook(generatedDir string) error {
if authorization == "" { if authorization == "" {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
} }
if err := waitForWebhookTargets(generatedDir, r.Config); err != nil { if err := waitForWebhookTargets(generatedDir, r.Config, authorization); err != nil {
return err return err
} }
manager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, r.Config.Flux.ManifestsRepo, r.Config.Flux.RepoName, r.Config.Flux.Branch, "maidn/bootstrap-"+r.Config.ClusterID) if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
if err := manager.EnsureWebhook(r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
return fmt.Errorf("reconcile Forgejo webhook: %w", err) return fmt.Errorf("reconcile Forgejo webhook: %w", err)
} }
return nil return nil
@ -464,27 +476,43 @@ func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
}) })
} }
func waitForWebhookTargets(dir string, cfg config.Config) error { func waitForWebhookTargets(dir string, cfg config.Config, authorization string) error {
resources := []string{ if err := waitForWebhookAuthorization(dir, authorization); err != nil {
"secret/forgejo-webhook", return err
"deployment/el-" + cfg.Delivery.AppName,
"pipeline/" + cfg.Delivery.AppName,
} }
resources := []string{"deployment/el-" + cfg.Delivery.AppName, "pipeline/" + cfg.Delivery.AppName}
for _, resource := range resources { for _, resource := range resources {
deadline := time.Now().Add(10 * time.Minute) deadline := time.Now().Add(webhookTargetTimeout)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil { if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil {
break break
} }
time.Sleep(2 * time.Second) time.Sleep(webhookTargetPollInterval)
} }
if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err != nil { if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err != nil {
return fmt.Errorf("wait for %s before registering Forgejo webhook", resource) return fmt.Errorf("wait for %s before registering Forgejo webhook", resource)
} }
} }
return nil return nil
} }
func waitForWebhookAuthorization(dir, authorization string) error {
deadline := time.Now().Add(webhookTargetTimeout)
for {
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "secret/forgejo-webhook", "-o=jsonpath={.data.authorization}")
if err == nil {
observed, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(output)))
if decodeErr == nil && string(observed) == authorization {
return nil
}
}
if !time.Now().Before(deadline) {
return errors.New("ExternalSecret target Secret forgejo-webhook did not refresh within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config <config> --register-webhook")
}
time.Sleep(webhookTargetPollInterval)
}
}
func ensureClusterKustomizations(clusterDir string) error { func ensureClusterKustomizations(clusterDir string) error {
path := filepath.Join(clusterDir, "kustomization.yaml") path := filepath.Join(clusterDir, "kustomization.yaml")
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
@ -498,7 +526,7 @@ func ensureClusterKustomizations(clusterDir string) error {
} }
updated := string(content) updated := string(content)
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "") updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
for _, resource := range []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "gateway-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "cicd-manifests-repo.yaml", "external-secrets-config-kustomization.yaml", "external-dns-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml"} { for _, resource := range requiredClusterKustomizations {
if !strings.Contains(updated, resource) { if !strings.Contains(updated, resource) {
updated += " - " + resource + "\n" updated += " - " + resource + "\n"
} }
@ -509,6 +537,15 @@ func ensureClusterKustomizations(clusterDir string) error {
return os.WriteFile(path, []byte(updated), 0644) return os.WriteFile(path, []byte(updated), 0644)
} }
func copyTemplateBaseComponents(templateDir, repoDir string) error {
for _, component := range templateBaseComponents {
if err := copyDir(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true); err != nil {
return err
}
}
return nil
}
func copyClusterTemplate(source, destination string) error { func copyClusterTemplate(source, destination string) error {
entries, err := os.ReadDir(destination) entries, err := os.ReadDir(destination)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {

View file

@ -4,13 +4,17 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
) )
func TestRenderCiliumConfig(t *testing.T) { func TestRenderCiliumConfig(t *testing.T) {
@ -101,6 +105,35 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.
} }
} }
func TestCopyTemplateBaseComponentsCopiesExternalSecretsConfig(t *testing.T) {
templateDir := t.TempDir()
repoDir := t.TempDir()
for _, component := range templateBaseComponents {
if err := os.MkdirAll(filepath.Join(templateDir, "base", component), 0755); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(templateDir, "base", "external-secrets", "config.yaml")
destination := filepath.Join(repoDir, "base", "external-secrets", "config.yaml")
if err := os.WriteFile(source, []byte("store: openbao\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(destination, []byte("store: stale\n"), 0644); err != nil {
t.Fatal(err)
}
if err := copyTemplateBaseComponents(templateDir, repoDir); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(destination)
if err != nil || string(content) != "store: openbao\n" {
t.Fatalf("external-secrets configuration was not copied: %q, %v", content, err)
}
}
func TestRenderDemocraticCSISecret(t *testing.T) { func TestRenderDemocraticCSISecret(t *testing.T) {
secret, err := renderDemocraticCSISecret(config.DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"}) secret, err := renderDemocraticCSISecret(config.DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"})
if err != nil { if err != nil {
@ -291,7 +324,7 @@ func TestEnsureManifestsKustomizations(t *testing.T) {
} }
} }
func TestEnsureClusterKustomizationsAddsStorageDependencies(t *testing.T) { func TestEnsureClusterKustomizationsUsesTemplateExternalSecretsResource(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "kustomization.yaml") path := filepath.Join(dir, "kustomization.yaml")
if err := os.WriteFile(path, []byte("resources:\n"), 0644); err != nil { if err := os.WriteFile(path, []byte("resources:\n"), 0644); err != nil {
@ -301,11 +334,135 @@ func TestEnsureClusterKustomizationsAddsStorageDependencies(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(content), "snapshot-crds-kustomization.yaml") || !strings.Contains(string(content), "democratic-csi-kustomization.yaml") || !strings.Contains(string(content), "gateway-api-kustomization.yaml") || !strings.Contains(string(content), "gateway-kustomization.yaml") || !strings.Contains(string(content), "external-secrets-config-kustomization.yaml") || !strings.Contains(string(content), "external-dns-kustomization.yaml") || !strings.Contains(string(content), "monitoring-kustomization.yaml") || !strings.Contains(string(content), "tekton-kustomization.yaml") { if err != nil || !strings.Contains(string(content), "snapshot-crds-kustomization.yaml") || !strings.Contains(string(content), "democratic-csi-kustomization.yaml") || !strings.Contains(string(content), "openbao-kustomization.yaml") || !strings.Contains(string(content), "external-secrets-kustomization.yaml") || !strings.Contains(string(content), "cnpg-kustomization.yaml") || !strings.Contains(string(content), "cloudflare-tunnel-kustomization.yaml") || !strings.Contains(string(content), "tekton-kustomization.yaml") || strings.Contains(string(content), "external-secrets-config-kustomization.yaml") {
t.Fatalf("cluster Kustomization was not updated: %q, %v", content, err) t.Fatalf("cluster Kustomization was not updated: %q, %v", content, err)
} }
} }
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
if webhookTargetTimeout <= time.Hour {
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
}
}
func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
originalInitialize := initializeOpenBao
originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook
originalTimeout := webhookTargetTimeout
originalInterval := webhookTargetPollInterval
t.Cleanup(func() {
initializeOpenBao = originalInitialize
runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook
webhookTargetTimeout = originalTimeout
webhookTargetPollInterval = originalInterval
})
authorization := "Bearer test-webhook-authorization"
staleTarget := base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))
refreshedTarget := base64.StdEncoding.EncodeToString([]byte(authorization))
targetChecks := 0
refreshedObserved := false
runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) {
if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) {
t.Fatal("webhook target probe used an unexpected command")
}
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
targetChecks++
if targetChecks == 1 {
return []byte(staleTarget), nil
}
refreshedObserved = true
return []byte(refreshedTarget), nil
}
return nil, nil
}
webhookTargetPollInterval = 0
patches := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !refreshedObserved {
t.Error("Forgejo was called before the refreshed webhook Secret was observed")
writer.WriteHeader(http.StatusInternalServerError)
return
}
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]map[string]any{{"id": 7, "url": "https://tekton.example.test/"}})
case http.MethodPatch:
var body struct {
AuthorizationHeader string `json:"authorization_header"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.AuthorizationHeader != authorization {
t.Error("Forgejo webhook update did not use the observed authorization")
writer.WriteHeader(http.StatusBadRequest)
return
}
patches++
writer.WriteHeader(http.StatusOK)
default:
t.Error("unexpected Forgejo request")
writer.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
ensureForgejoWebhook = func(_ config.Config, repo, webhookURL, authorization string) error {
manager := forgejo.NewRepoManager(server.URL, "test-token", "owner", "user", "manifests", "flux", "main", "migration")
manager.HTTPClient = server.Client()
return manager.EnsureWebhook(repo, webhookURL, authorization)
}
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
}
cfg := config.Config{Delivery: config.DeliveryConfig{AppName: "app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}}
if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil {
t.Fatal(err)
}
if targetChecks != 2 || patches != 1 {
t.Fatal("Forgejo webhook was not updated after the target Secret refreshed")
}
}
func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
originalInitialize := initializeOpenBao
originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook
originalTimeout := webhookTargetTimeout
originalInterval := webhookTargetPollInterval
t.Cleanup(func() {
initializeOpenBao = originalInitialize
runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook
webhookTargetTimeout = originalTimeout
webhookTargetPollInterval = originalInterval
})
authorization := "Bearer test-webhook-authorization"
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
}
runWebhookCommand = func(_ string, _ string, _ ...string) ([]byte, error) {
return []byte(base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))), nil
}
webhookTargetTimeout = -time.Nanosecond
webhookTargetPollInterval = 0
webhookUpdated := false
ensureForgejoWebhook = func(config.Config, string, string, string) error {
webhookUpdated = true
return nil
}
err := (Runner{Config: config.Config{Delivery: config.DeliveryConfig{AppName: "app"}}}).reconcileWebhook(t.TempDir())
if err == nil || !strings.Contains(err.Error(), "Forgejo webhook was not updated") || !strings.Contains(err.Error(), "--register-webhook") {
t.Fatal("webhook refresh timeout did not return a safe rerun error")
}
if webhookUpdated {
t.Fatal("Forgejo webhook update was attempted before the target Secret refreshed")
}
}
func TestRenderTerraformTFVarsIsStableAndRedactsToken(t *testing.T) { func TestRenderTerraformTFVarsIsStableAndRedactsToken(t *testing.T) {
cfg := config.Config{ cfg := config.Config{
ClusterID: "test", ClusterID: "test",