fix: register Forgejo webhooks without Secret reads #50
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 + "/"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -498,31 +498,17 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in a new issue