diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index eea562d..dd59b00 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -139,7 +139,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-webhook", "authorization", authorization); err != nil { return fmt.Errorf("save Forgejo webhook authorization: %w", err) } - return bootstrap.Runner{Config: cfg, RegisterWebhook: true}.Run() + return bootstrap.Runner{Config: cfg, RegisterWebhook: true, RefreshWebhookSecret: true}.Run() } if bootstrapInitializeOpenBao { if bootstrapConfigPath == "" { diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 6adf9c2..45a44eb 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -44,6 +44,7 @@ type Runner struct { Mode Mode ConfirmRebuild bool RegisterWebhook bool + RefreshWebhookSecret bool EnableDelivery bool SkipDeliveryScaffolding bool AutoMergeBootstrapMigration bool @@ -73,6 +74,8 @@ var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorizati return manager.EnsureWebhook(repo, webhookURL, authorization) } +var readOperationalSecrets = ReadOperationalSecrets + var runWebhookCommand = utils.RunCommandQuietOutputInDir var preflight = config.Preflight @@ -398,15 +401,26 @@ func (r Runner) reconcileCloudflareTunnel() error { } func (r Runner) reconcileWebhook(generatedDir string) error { - operationalSecrets, err := r.initializeOpenBaoForCluster(generatedDir) - if err != nil { - return fmt.Errorf("initialize OpenBao: %w", err) + var ( + operationalSecrets map[string]map[string]string + err error + ) + if r.RefreshWebhookSecret { + operationalSecrets, err = r.initializeOpenBaoForCluster(generatedDir) + if err != nil { + return fmt.Errorf("initialize OpenBao: %w", err) + } + } else { + operationalSecrets, err = readOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath) + if err != nil { + return fmt.Errorf("read encrypted webhook authorization: %w", err) + } } authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"] if authorization == "" { return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") } - if err := waitForWebhookTargets(generatedDir, r.Config, authorization); err != nil { + if err := waitForWebhookTargets(generatedDir, r.Config); err != nil { return err } if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil { @@ -1098,38 +1112,22 @@ func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) { }) } -func waitForWebhookTargets(dir string, cfg config.Config, authorization string) error { - if err := waitForWebhookAuthorization(dir, authorization); err != nil { +func waitForWebhookTargets(dir string, _ config.Config) error { + if err := waitForWebhookExternalSecret(dir); err != nil { return err } - resources := []string{"pipeline/" + cfg.Delivery.AppName} - for _, resource := range resources { - deadline := time.Now().Add(webhookTargetTimeout) - for time.Now().Before(deadline) { - if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil { - break - } - time.Sleep(webhookTargetPollInterval) - } - 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 nil } -func waitForWebhookAuthorization(dir, authorization string) error { +func waitForWebhookExternalSecret(dir 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 - } + output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "externalsecret/forgejo-webhook", "-o=jsonpath={.status.conditions[0].status}") + if err == nil && strings.TrimSpace(string(output)) == "True" { + 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 --register-webhook") + return errors.New("ExternalSecret forgejo-webhook did not become ready within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config --register-webhook") } time.Sleep(webhookTargetPollInterval) } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index d32f17e..4de135d 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -982,12 +982,14 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { originalPreflight := preflight originalGit := runGit originalInitialize := initializeOpenBao + originalRead := readOperationalSecrets originalCommand := runWebhookCommand originalWebhook := ensureForgejoWebhook t.Cleanup(func() { preflight = originalPreflight runGit = originalGit initializeOpenBao = originalInitialize + readOperationalSecrets = originalRead runWebhookCommand = originalCommand ensureForgejoWebhook = originalWebhook }) @@ -1006,9 +1008,12 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { 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 } + readOperationalSecrets = func(string, string) (map[string]map[string]string, error) { + return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil + } runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) { - if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") { - return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil + if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") { + return []byte("True"), nil } return nil, nil } @@ -1038,14 +1043,16 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { } } -func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) { +func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) { originalInitialize := initializeOpenBao + originalRead := readOperationalSecrets originalCommand := runWebhookCommand originalWebhook := ensureForgejoWebhook originalTimeout := webhookTargetTimeout originalInterval := webhookTargetPollInterval t.Cleanup(func() { initializeOpenBao = originalInitialize + readOperationalSecrets = originalRead runWebhookCommand = originalCommand ensureForgejoWebhook = originalWebhook webhookTargetTimeout = originalTimeout @@ -1053,21 +1060,19 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) { }) 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") { + if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") { targetChecks++ if targetChecks == 1 { - return []byte(staleTarget), nil + return []byte("False"), nil } refreshedObserved = true - return []byte(refreshedTarget), nil + return []byte("True"), nil } return nil, nil } @@ -1076,7 +1081,7 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) { 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") + t.Error("Forgejo was called before the refreshed webhook ExternalSecret was ready") writer.WriteHeader(http.StatusInternalServerError) return } @@ -1108,24 +1113,29 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) { 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 } + readOperationalSecrets = func(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") + t.Fatal("Forgejo webhook was not updated after the ExternalSecret became ready") } } func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) { originalInitialize := initializeOpenBao + originalRead := readOperationalSecrets originalCommand := runWebhookCommand originalWebhook := ensureForgejoWebhook originalTimeout := webhookTargetTimeout originalInterval := webhookTargetPollInterval t.Cleanup(func() { initializeOpenBao = originalInitialize + readOperationalSecrets = originalRead runWebhookCommand = originalCommand ensureForgejoWebhook = originalWebhook webhookTargetTimeout = originalTimeout @@ -1136,8 +1146,11 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) { 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 } + readOperationalSecrets = func(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 + return []byte("False"), nil } webhookTargetTimeout = -time.Nanosecond webhookTargetPollInterval = 0 @@ -1152,7 +1165,7 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) { 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") + t.Fatal("Forgejo webhook update was attempted before the ExternalSecret became ready") } } diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go index 0a22105..f4532cf 100644 --- a/internal/bootstrap/onboard.go +++ b/internal/bootstrap/onboard.go @@ -88,7 +88,7 @@ func OnboardApp(cfg config.Config, sourceDir string) error { return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") } generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir) - if err := waitForWebhookTargets(generatedDir, resolved, authorization); err != nil { + if err := waitForWebhookTargets(generatedDir, resolved); err != nil { return err } webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/" diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index 3b66932..1ef08f6 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -55,8 +55,9 @@ type mergePullRequestRequest struct { } type hook struct { - ID int64 `json:"id"` - URL string `json:"url"` + ID int64 `json:"id"` + URL string `json:"url"` + Config map[string]string `json:"config"` } type hookRequest struct { @@ -775,7 +776,7 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err Events: []string{"push", "pull_request"}, } for _, existing := range hooks { - if existing.URL != webhookURL { + if hookURL(existing) != webhookURL { continue } request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events}) @@ -805,6 +806,13 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err return nil } +func hookURL(existing hook) string { + if existing.URL != "" { + return existing.URL + } + return existing.Config["url"] +} + // TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook. func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error { if repo == "" || webhookURL == "" || branch == "" { @@ -821,7 +829,7 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error } var matching []hook for _, candidate := range hooks { - if candidate.URL == webhookURL { + if hookURL(candidate) == webhookURL { matching = append(matching, candidate) } } diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index 94b72ed..b0366c6 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -212,7 +212,7 @@ func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) { if request.URL.Path != "/api/v1/repos/owner/app/hooks" { t.Fatalf("unexpected lookup path %q", request.URL.Path) } - _ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}}) + _ = json.NewEncoder(writer).Encode([]hook{{ID: 7, Config: map[string]string{"url": "https://tekton.example.test/"}}}) case http.MethodPatch: if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" { t.Fatalf("unexpected update path %q", request.URL.Path) diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index 2b123e8..9e6137d 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -498,33 +498,19 @@ func redactOpenBaoDiagnostic(diagnostic string, sensitive ...string) string { } func refreshExternalSecrets(kubeconfig string) error { - available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}") + available, err := kubectlOutput(kubeconfig, "--request-timeout=30s", "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}") if err != nil || strings.TrimSpace(string(available)) != "True" { return nil } timestamp := time.Now().UnixNano() - _, err = kubectlOutput(kubeconfig, "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite") + fmt.Fprintln(os.Stderr, "OpenBao: refresh OpenBao secret store") + _, err = kubectlOutput(kubeconfig, "--request-timeout=30s", "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite") if err != nil { return fmt.Errorf("refresh OpenBao secret store after seed: %w", err) } - webhook, err := kubectlOutput(kubeconfig, "get", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", "--ignore-not-found", "-o=name") - if err != nil { - return fmt.Errorf("check Forgejo webhook ExternalSecret after OpenBao seed: %w", err) - } - if strings.TrimSpace(string(webhook)) == "" { - return nil - } - _, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(timestamp)...) - if err != nil { - return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err) - } return nil } -func externalSecretRefreshArgs(timestamp int64) []string { - return []string{"annotate", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite"} -} - func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { return err diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index 6dc93e4..620dc3e 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -100,32 +100,13 @@ func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) { if len(calls) == 1 { return []byte("True"), nil } - if len(calls) == 3 { - return []byte("externalsecret.external-secrets.io/forgejo-webhook"), nil - } return nil, nil } - if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 4 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || !strings.Contains(calls[2], "get externalsecret forgejo-webhook") || !strings.Contains(calls[3], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") || strings.Contains(calls[3], "--all") { + if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 2 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || strings.Contains(calls[1], "--all") { t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err) } } -func TestRefreshExternalSecretsSkipsWebhookBeforeTekton(t *testing.T) { - original := kubectlOutput - t.Cleanup(func() { kubectlOutput = original }) - var calls []string - kubectlOutput = func(_ string, args ...string) ([]byte, error) { - calls = append(calls, strings.Join(args, " ")) - if len(calls) == 1 { - return []byte("True"), nil - } - return nil, nil - } - if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 3 || !strings.Contains(calls[2], "--ignore-not-found") { - t.Fatalf("missing webhook ExternalSecret was not safely skipped: %q, %v", calls, err) - } -} - func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) { originalDecrypt, originalExec := decryptRecovery, execInPodMutation t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })