fix: register Forgejo webhooks without Secret reads

This commit is contained in:
eding 2026-09-14 23:11:00 +02:00
parent d4f6b74bef
commit 3ec1097fa3
8 changed files with 69 additions and 83 deletions

View file

@ -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 == "" {

View file

@ -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)
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 {
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 <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 <config> --register-webhook")
}
time.Sleep(webhookTargetPollInterval)
}

View file

@ -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")
}
}

View file

@ -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 + "/"

View file

@ -57,6 +57,7 @@ type mergePullRequestRequest struct {
type hook struct {
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)
}
}

View file

@ -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)

View file

@ -498,32 +498,18 @@ 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 {

View file

@ -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 })