From ebda4cda8a627cc5b7f988d55bde13365713998a Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Thu, 21 May 2026 17:17:59 +0100 Subject: [PATCH] Introduce flux trigger receiver Signed-off-by: Matheus Pimenta --- cmd/flux/trigger.go | 31 +++ cmd/flux/trigger_receiver.go | 312 ++++++++++++++++++++++++++++++ cmd/flux/trigger_receiver_test.go | 283 +++++++++++++++++++++++++++ 3 files changed, 626 insertions(+) create mode 100644 cmd/flux/trigger.go create mode 100644 cmd/flux/trigger_receiver.go create mode 100644 cmd/flux/trigger_receiver_test.go diff --git a/cmd/flux/trigger.go b/cmd/flux/trigger.go new file mode 100644 index 00000000..c8f31285 --- /dev/null +++ b/cmd/flux/trigger.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/spf13/cobra" +) + +var triggerCmd = &cobra.Command{ + Use: "trigger", + Short: "Trigger Flux resources from outside the cluster", + Long: `The trigger sub-commands invoke Flux resources from outside the cluster, such as a Receiver's incoming webhook.`, +} + +func init() { + rootCmd.AddCommand(triggerCmd) +} diff --git a/cmd/flux/trigger_receiver.go b/cmd/flux/trigger_receiver.go new file mode 100644 index 00000000..d1043af1 --- /dev/null +++ b/cmd/flux/trigger_receiver.go @@ -0,0 +1,312 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/fluxcd/pkg/auth/actionsoidc" + + notificationv1 "github.com/fluxcd/notification-controller/api/v1" +) + +const ( + // genericOIDCReceiver mirrors notificationv1.GenericOIDCReceiver from the + // upcoming notification-controller release. + // TODO: Replace it with the constant from the api module once the dependency + // is bumped. + genericOIDCReceiver = "generic-oidc" + + // defaultOIDCAudience mirrors notificationv1.DefaultOIDCAudience. + // TODO: Replace it with the constant from the api module once the dependency + // is bumped. + defaultOIDCAudience = "notification-controller" + + // defaultOIDCTokenEnvVar is the environment variable the OIDC token is read + // from when neither --oidc-provider nor --oidc-token is set. + defaultOIDCTokenEnvVar = "FLUX_TRIGGER_RECEIVER_OIDC_TOKEN" +) + +const ( + oidcProviderGitHub = "github" + oidcProviderForgejo = "forgejo" +) + +var triggerReceiverCmd = &cobra.Command{ + Use: "receiver [name]", + Short: "Trigger the webhook of a Receiver", + Long: `The trigger receiver command sends a request to the incoming webhook of a Receiver. + +The command computes the webhook path from the Receiver name, namespace and token, +appends it to the base URL and sends an HTTP POST request with the given payload. +It does not require access to the Kubernetes cluster.`, + Example: ` # Trigger a generic Receiver + flux trigger receiver my-receiver \ + --token=my-token \ + --url=https://flux-webhook.example.com + + # Trigger a generic Receiver with a custom JSON payload + flux trigger receiver my-receiver \ + --token=my-token \ + --url=https://flux-webhook.example.com \ + --payload='{"image":"ghcr.io/org/app:v1.0.0"}' + + # Trigger a generic-hmac Receiver + flux trigger receiver my-receiver \ + --type=generic-hmac \ + --token=my-token \ + --url=https://flux-webhook.example.com \ + --payload='{"image":"ghcr.io/org/app:v1.0.0"}' + + # Trigger a generic-oidc Receiver from a GitHub Actions workflow. + # The job needs 'permissions: id-token: write'. The OIDC token is fetched + # automatically and the receiver token is not used by this type. + flux trigger receiver my-receiver \ + --type=generic-oidc \ + --oidc-provider=github \ + --url=https://flux-webhook.example.com + + # Trigger a generic-oidc Receiver from a GitHub Actions workflow with a custom OIDC audience + flux trigger receiver my-receiver \ + --type=generic-oidc \ + --oidc-provider=github \ + --oidc-audience=my-flux-instance \ + --url=https://flux-webhook.example.com + + # Trigger a generic-oidc Receiver from a Forgejo Actions workflow + flux trigger receiver my-receiver \ + --type=generic-oidc \ + --oidc-provider=forgejo \ + --url=https://flux-webhook.example.com + + # Trigger a generic-oidc Receiver from a GitLab CI/CD job, reading the OIDC + # token from an id_token environment variable defined in the job spec. + flux trigger receiver my-receiver \ + --type=generic-oidc \ + --oidc-token="${MY_ID_TOKEN}" \ + --url=https://flux-webhook.example.com + + # Trigger a generic-oidc Receiver from a GitLab CI/CD job, reading the OIDC + # token from the default FLUX_TRIGGER_RECEIVER_OIDC_TOKEN environment variable, + # e.g. defined as: + # job: + # id_tokens: + # FLUX_TRIGGER_RECEIVER_OIDC_TOKEN: + # aud: notification-controller + flux trigger receiver my-receiver \ + --type=generic-oidc \ + --url=https://flux-webhook.example.com + + # Trigger a Receiver in a specific namespace + flux trigger receiver my-receiver -n apps \ + --token=my-token \ + --url=https://flux-webhook.example.com + + # Trigger a Receiver in the namespace of the current kubeconfig context + flux trigger receiver my-receiver \ + --ns-follows-kube-context \ + --token=my-token \ + --url=https://flux-webhook.example.com`, + Args: cobra.ExactArgs(1), + RunE: triggerReceiverCmdRun, +} + +type triggerReceiverFlags struct { + token string + url string + receiverType string + oidcProvider string + oidcToken string + oidcAudience string + payload string +} + +var triggerReceiverArgs triggerReceiverFlags + +func init() { + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.token, "token", "", + "the Receiver token, required for all types except generic-oidc where it must not be set") + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.url, "url", "", + "the base URL of the notification-controller webhook receiver, may contain a base path") + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.receiverType, "type", notificationv1.GenericReceiver, + fmt.Sprintf("the Receiver type, one of: %s, %s, %s", + notificationv1.GenericReceiver, notificationv1.GenericHMACReceiver, genericOIDCReceiver)) + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.oidcProvider, "oidc-provider", "", + fmt.Sprintf("the OIDC provider to fetch the token from, one of: %s, %s (generic-oidc only, mutually exclusive with --oidc-token)", + oidcProviderGitHub, oidcProviderForgejo)) + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.oidcToken, "oidc-token", "", + fmt.Sprintf("the OIDC token to authenticate the request (generic-oidc only, mutually exclusive with --oidc-provider); defaults to the %s environment variable", defaultOIDCTokenEnvVar)) + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.oidcAudience, "oidc-audience", "", + fmt.Sprintf("the audience of the OIDC token to fetch (requires --oidc-provider); defaults to %q", defaultOIDCAudience)) + triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.payload, "payload", "{}", + "the JSON payload to send in the request body") + + triggerCmd.AddCommand(triggerReceiverCmd) +} + +func triggerReceiverCmdRun(cmd *cobra.Command, args []string) error { + name := args[0] + + if triggerReceiverArgs.url == "" { + return fmt.Errorf("--url is required") + } + + if err := validateTriggerReceiverArgs(); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + + // For generic-oidc the Receiver has no secretRef, so the webhook path is + // salted with an empty token. For all other types the token is required. + pathToken := triggerReceiverArgs.token + if triggerReceiverArgs.receiverType == genericOIDCReceiver { + pathToken = "" + } + + receiver := ¬ificationv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: *kubeconfigArgs.Namespace, + }, + } + webhookURL := strings.TrimRight(triggerReceiverArgs.url, "/") + receiver.GetWebhookPath(pathToken) + + payload := []byte(triggerReceiverArgs.payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", fmt.Sprintf("flux/v%s", VERSION)) + + switch triggerReceiverArgs.receiverType { + case notificationv1.GenericReceiver: + // No authentication, the payload is sent as-is. + case notificationv1.GenericHMACReceiver: + mac := hmac.New(sha256.New, []byte(triggerReceiverArgs.token)) + mac.Write(payload) + req.Header.Set("X-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + case genericOIDCReceiver: + oidcToken, err := resolveOIDCToken(ctx) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+oidcToken) + } + + logger.Actionf("triggering Receiver %s/%s", *kubeconfigArgs.Namespace, name) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("request to %s failed: %w", webhookURL, err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + msg := strings.TrimSpace(string(body)) + if msg != "" { + return fmt.Errorf("request to %s failed with status %s: %s", webhookURL, resp.Status, msg) + } + return fmt.Errorf("request to %s failed with status %s", webhookURL, resp.Status) + } + + logger.Successf("Receiver %s/%s triggered", *kubeconfigArgs.Namespace, name) + return nil +} + +// validateTriggerReceiverArgs validates the receiver type and the combination of +// token and OIDC flags. +func validateTriggerReceiverArgs() error { + isOIDC := triggerReceiverArgs.receiverType == genericOIDCReceiver + + switch triggerReceiverArgs.receiverType { + case notificationv1.GenericReceiver, notificationv1.GenericHMACReceiver, genericOIDCReceiver: + default: + return fmt.Errorf("invalid --type %q, must be one of: %s, %s, %s", + triggerReceiverArgs.receiverType, + notificationv1.GenericReceiver, notificationv1.GenericHMACReceiver, genericOIDCReceiver) + } + + if !isOIDC { + if triggerReceiverArgs.token == "" { + return fmt.Errorf("--token is required for --type=%s", triggerReceiverArgs.receiverType) + } + if triggerReceiverArgs.oidcProvider != "" || triggerReceiverArgs.oidcToken != "" || triggerReceiverArgs.oidcAudience != "" { + return fmt.Errorf("--oidc-provider, --oidc-token and --oidc-audience can only be set for --type=%s", genericOIDCReceiver) + } + return nil + } + + // generic-oidc. + if triggerReceiverArgs.token != "" { + return fmt.Errorf("--token must not be set for --type=%s, the Receiver of this type has no secret", genericOIDCReceiver) + } + if triggerReceiverArgs.oidcProvider != "" && triggerReceiverArgs.oidcToken != "" { + return fmt.Errorf("--oidc-provider and --oidc-token are mutually exclusive") + } + if triggerReceiverArgs.oidcProvider != "" { + switch triggerReceiverArgs.oidcProvider { + case oidcProviderGitHub, oidcProviderForgejo: + default: + return fmt.Errorf("invalid --oidc-provider %q, must be one of: %s, %s", + triggerReceiverArgs.oidcProvider, oidcProviderGitHub, oidcProviderForgejo) + } + } + if triggerReceiverArgs.oidcAudience != "" && triggerReceiverArgs.oidcProvider == "" { + return fmt.Errorf("--oidc-audience can only be set together with --oidc-provider") + } + + return nil +} + +// resolveOIDCToken returns the OIDC token used to authenticate the request, +// either by fetching it from the configured provider or by reading it from the +// --oidc-token flag or the default environment variable. +func resolveOIDCToken(ctx context.Context) (string, error) { + switch { + case triggerReceiverArgs.oidcProvider != "": + audience := triggerReceiverArgs.oidcAudience + if audience == "" { + audience = defaultOIDCAudience + } + // GitHub and Forgejo Actions expose the same token request endpoint. + token, _, err := actionsoidc.FetchToken(ctx, audience) + return token, err + case triggerReceiverArgs.oidcToken != "": + return triggerReceiverArgs.oidcToken, nil + default: + token := os.Getenv(defaultOIDCTokenEnvVar) + if token == "" { + return "", fmt.Errorf("no OIDC token provided: set --oidc-provider, --oidc-token or the %s environment variable", defaultOIDCTokenEnvVar) + } + return token, nil + } +} diff --git a/cmd/flux/trigger_receiver_test.go b/cmd/flux/trigger_receiver_test.go new file mode 100644 index 00000000..78b4df8f --- /dev/null +++ b/cmd/flux/trigger_receiver_test.go @@ -0,0 +1,283 @@ +//go:build unit +// +build unit + +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + notificationv1 "github.com/fluxcd/notification-controller/api/v1" +) + +// resetTriggerReceiverArgs restores the package-global flags to their defaults +// so tests do not leak state into each other. +func resetTriggerReceiverArgs(t *testing.T) { + t.Helper() + prev := triggerReceiverArgs + prevNS := kubeconfigArgs.Namespace + prevTimeout := rootArgs.timeout + + triggerReceiverArgs = triggerReceiverFlags{ + receiverType: notificationv1.GenericReceiver, + payload: "{}", + } + ns := "default" + kubeconfigArgs.Namespace = &ns + rootArgs.timeout = time.Minute + + t.Cleanup(func() { + triggerReceiverArgs = prev + kubeconfigArgs.Namespace = prevNS + rootArgs.timeout = prevTimeout + }) +} + +func TestValidateTriggerReceiverArgs(t *testing.T) { + tests := []struct { + name string + args triggerReceiverFlags + wantErr string + }{ + { + name: "generic requires token", + args: triggerReceiverFlags{receiverType: notificationv1.GenericReceiver}, + wantErr: "--token is required", + }, + { + name: "generic with token is valid", + args: triggerReceiverFlags{receiverType: notificationv1.GenericReceiver, token: "t"}, + }, + { + name: "generic rejects oidc flags", + args: triggerReceiverFlags{receiverType: notificationv1.GenericReceiver, token: "t", oidcProvider: "github"}, + wantErr: "can only be set for --type=generic-oidc", + }, + { + name: "hmac with token is valid", + args: triggerReceiverFlags{receiverType: notificationv1.GenericHMACReceiver, token: "t"}, + }, + { + name: "unknown type", + args: triggerReceiverFlags{receiverType: "bogus", token: "t"}, + wantErr: "invalid --type", + }, + { + name: "oidc rejects token", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver, token: "t"}, + wantErr: "--token must not be set", + }, + { + name: "oidc provider and token mutually exclusive", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcProvider: "github", oidcToken: "x"}, + wantErr: "mutually exclusive", + }, + { + name: "oidc invalid provider", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcProvider: "gitlab"}, + wantErr: "invalid --oidc-provider", + }, + { + name: "oidc audience requires provider", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcToken: "x", oidcAudience: "aud"}, + wantErr: "--oidc-audience can only be set together with --oidc-provider", + }, + { + name: "oidc with provider is valid", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcProvider: "forgejo", oidcAudience: "aud"}, + }, + { + name: "oidc with token is valid", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcToken: "x"}, + }, + { + name: "oidc without provider or token is valid (env fallback)", + args: triggerReceiverFlags{receiverType: genericOIDCReceiver}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetTriggerReceiverArgs(t) + triggerReceiverArgs = tt.args + + err := validateTriggerReceiverArgs() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestTriggerReceiverRun(t *testing.T) { + const name = "my-receiver" + const ns = "default" + const token = "my-token" + + expectedPath := (¬ificationv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + }).GetWebhookPath(token) + expectedOIDCPath := (¬ificationv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + }).GetWebhookPath("") + + t.Run("generic sends payload with default headers", func(t *testing.T) { + resetTriggerReceiverArgs(t) + var got *http.Request + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + triggerReceiverArgs.url = srv.URL + triggerReceiverArgs.token = token + triggerReceiverArgs.payload = `{"hello":"world"}` + + if err := triggerReceiverCmdRun(nil, []string{name}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.URL.Path != expectedPath { + t.Errorf("path = %q, want %q", got.URL.Path, expectedPath) + } + if got.Method != http.MethodPost { + t.Errorf("method = %q, want POST", got.Method) + } + if ct := got.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if ua := got.Header.Get("User-Agent"); !strings.HasPrefix(ua, "flux/v") { + t.Errorf("User-Agent = %q, want prefix flux/v", ua) + } + if gotBody != `{"hello":"world"}` { + t.Errorf("body = %q", gotBody) + } + }) + + t.Run("generic-hmac sets X-Signature", func(t *testing.T) { + resetTriggerReceiverArgs(t) + var sig string + payload := `{"a":1}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sig = r.Header.Get("X-Signature") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + triggerReceiverArgs.url = srv.URL + triggerReceiverArgs.token = token + triggerReceiverArgs.receiverType = notificationv1.GenericHMACReceiver + triggerReceiverArgs.payload = payload + + if err := triggerReceiverCmdRun(nil, []string{name}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + mac := hmac.New(sha256.New, []byte(token)) + mac.Write([]byte(payload)) + want := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if sig != want { + t.Errorf("X-Signature = %q, want %q", sig, want) + } + }) + + t.Run("generic-oidc with --oidc-token sets bearer and empty-token path", func(t *testing.T) { + resetTriggerReceiverArgs(t) + var auth, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + path = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + triggerReceiverArgs.url = srv.URL + triggerReceiverArgs.receiverType = genericOIDCReceiver + triggerReceiverArgs.oidcToken = "the-oidc-token" + + if err := triggerReceiverCmdRun(nil, []string{name}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if auth != "Bearer the-oidc-token" { + t.Errorf("Authorization = %q, want Bearer the-oidc-token", auth) + } + if path != expectedOIDCPath { + t.Errorf("path = %q, want %q (empty token salt)", path, expectedOIDCPath) + } + }) + + t.Run("generic-oidc reads default env var", func(t *testing.T) { + resetTriggerReceiverArgs(t) + t.Setenv(defaultOIDCTokenEnvVar, "env-oidc-token") + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + triggerReceiverArgs.url = srv.URL + triggerReceiverArgs.receiverType = genericOIDCReceiver + + if err := triggerReceiverCmdRun(nil, []string{name}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if auth != "Bearer env-oidc-token" { + t.Errorf("Authorization = %q, want Bearer env-oidc-token", auth) + } + }) + + t.Run("non-2xx response is an error", func(t *testing.T) { + resetTriggerReceiverArgs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("nope")) + })) + defer srv.Close() + + triggerReceiverArgs.url = srv.URL + triggerReceiverArgs.token = token + + err := triggerReceiverCmdRun(nil, []string{name}) + if err == nil || !strings.Contains(err.Error(), "nope") { + t.Fatalf("expected error containing response body, got: %v", err) + } + }) +}