diff --git a/cmd/flux/create_receiver.go b/cmd/flux/create_receiver.go index ad6436cf..26000b2d 100644 --- a/cmd/flux/create_receiver.go +++ b/cmd/flux/create_receiver.go @@ -30,6 +30,7 @@ import ( notificationv1 "github.com/fluxcd/notification-controller/api/v1" "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/flux2/v2/internal/flags" "github.com/fluxcd/flux2/v2/internal/utils" ) @@ -49,7 +50,7 @@ var createReceiverCmd = &cobra.Command{ } type receiverFlags struct { - receiverType string + receiverType flags.ReceiverType secretRef string events []string resources []string @@ -58,7 +59,7 @@ type receiverFlags struct { var receiverArgs receiverFlags func init() { - createReceiverCmd.Flags().StringVar(&receiverArgs.receiverType, "type", "", "") + createReceiverCmd.Flags().Var(&receiverArgs.receiverType, "type", receiverArgs.receiverType.Description()) createReceiverCmd.Flags().StringVar(&receiverArgs.secretRef, "secret-ref", "", "") createReceiverCmd.Flags().StringSliceVar(&receiverArgs.events, "event", []string{}, "also accepts comma-separated values") createReceiverCmd.Flags().StringSliceVar(&receiverArgs.resources, "resource", []string{}, "also accepts comma-separated values") @@ -109,7 +110,7 @@ func createReceiverCmdRun(cmd *cobra.Command, args []string) error { Labels: sourceLabels, }, Spec: notificationv1.ReceiverSpec{ - Type: receiverArgs.receiverType, + Type: receiverArgs.receiverType.String(), Events: receiverArgs.events, Resources: resources, SecretRef: meta.LocalObjectReference{ diff --git a/cmd/flux/create_secret.go b/cmd/flux/create_secret.go index 376fd24f..1a4cb025 100644 --- a/cmd/flux/create_secret.go +++ b/cmd/flux/create_secret.go @@ -56,6 +56,22 @@ func upsertSecret(ctx context.Context, kubeClient client.Client, secret corev1.S } existing.StringData = secret.StringData + if secret.Annotations != nil { + if existing.Annotations == nil { + existing.Annotations = make(map[string]string) + } + for k, v := range secret.Annotations { + existing.Annotations[k] = v + } + } + if secret.Labels != nil { + if existing.Labels == nil { + existing.Labels = make(map[string]string) + } + for k, v := range secret.Labels { + existing.Labels[k] = v + } + } if err := kubeClient.Update(ctx, &existing); err != nil { return err } diff --git a/cmd/flux/create_secret_receiver.go b/cmd/flux/create_secret_receiver.go new file mode 100644 index 00000000..50eafbea --- /dev/null +++ b/cmd/flux/create_secret_receiver.go @@ -0,0 +1,134 @@ +/* +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 ( + "context" + "fmt" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" + + notificationv1 "github.com/fluxcd/notification-controller/api/v1" + + "github.com/fluxcd/flux2/v2/internal/flags" + "github.com/fluxcd/flux2/v2/internal/utils" + "github.com/fluxcd/flux2/v2/pkg/manifestgen/sourcesecret" +) + +var createSecretReceiverCmd = &cobra.Command{ + Use: "receiver [name]", + Short: "Create or update a Kubernetes secret for a Receiver webhook", + Long: `The create secret receiver command generates a Kubernetes secret with +the token used for webhook payload validation and an annotation with the +computed webhook URL.`, + Example: ` # Create a receiver secret for a GitHub webhook + flux create secret receiver github-receiver \ + --namespace=my-namespace \ + --type=github \ + --hostname=flux.example.com \ + --export + + # Create a receiver secret for GCR with email claim + flux create secret receiver gcr-receiver \ + --namespace=my-namespace \ + --type=gcr \ + --hostname=flux.example.com \ + --email-claim=sa@project.iam.gserviceaccount.com \ + --export`, + RunE: createSecretReceiverCmdRun, +} + +type secretReceiverFlags struct { + receiverType flags.ReceiverType + token string + hostname string + emailClaim string + audienceClaim string +} + +var secretReceiverArgs secretReceiverFlags + +func init() { + createSecretReceiverCmd.Flags().Var(&secretReceiverArgs.receiverType, "type", secretReceiverArgs.receiverType.Description()) + createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.token, "token", "", "webhook token used for payload validation and URL computation, auto-generated if not specified") + createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.hostname, "hostname", "", "hostname for the webhook URL e.g. flux.example.com") + createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.emailClaim, "email-claim", "", "IAM service account email, required for gcr type") + createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.audienceClaim, "audience-claim", "", "custom OIDC token audience for gcr type, defaults to the webhook URL") + + createSecretCmd.AddCommand(createSecretReceiverCmd) +} + +func createSecretReceiverCmdRun(cmd *cobra.Command, args []string) error { + name := args[0] + + if secretReceiverArgs.receiverType == "" { + return fmt.Errorf("--type is required") + } + + if secretReceiverArgs.hostname == "" { + return fmt.Errorf("--hostname is required") + } + + if secretReceiverArgs.receiverType.String() == notificationv1.GCRReceiver && secretReceiverArgs.emailClaim == "" { + return fmt.Errorf("--email-claim is required for gcr receiver type") + } + + labels, err := parseLabels() + if err != nil { + return err + } + + opts := sourcesecret.Options{ + Name: name, + Namespace: *kubeconfigArgs.Namespace, + Labels: labels, + ReceiverType: secretReceiverArgs.receiverType.String(), + Token: secretReceiverArgs.token, + Hostname: secretReceiverArgs.hostname, + EmailClaim: secretReceiverArgs.emailClaim, + AudienceClaim: secretReceiverArgs.audienceClaim, + } + + secret, err := sourcesecret.GenerateReceiver(opts) + if err != nil { + return err + } + + if createArgs.export { + rootCmd.Println(secret.Content) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + kubeClient, err := utils.KubeClient(kubeconfigArgs, kubeclientOptions) + if err != nil { + return err + } + var s corev1.Secret + if err := yaml.Unmarshal([]byte(secret.Content), &s); err != nil { + return err + } + if err := upsertSecret(ctx, kubeClient, s); err != nil { + return err + } + + logger.Actionf("receiver secret '%s' created in '%s' namespace", name, *kubeconfigArgs.Namespace) + return nil +} diff --git a/cmd/flux/create_secret_receiver_test.go b/cmd/flux/create_secret_receiver_test.go new file mode 100644 index 00000000..383f2371 --- /dev/null +++ b/cmd/flux/create_secret_receiver_test.go @@ -0,0 +1,74 @@ +/* +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 ( + "testing" +) + +func TestCreateReceiverSecret(t *testing.T) { + tests := []struct { + name string + args string + assert assertFunc + }{ + { + name: "missing type", + args: "create secret receiver test-secret --token=t --hostname=h", + assert: assertError("--type is required"), + }, + { + name: "invalid type", + args: "create secret receiver test-secret --type=invalid --token=t --hostname=h", + assert: assertError("invalid argument \"invalid\" for \"--type\" flag: receiver type 'invalid' is not supported, must be one of: generic, generic-hmac, github, gitlab, bitbucket, harbor, dockerhub, quay, gcr, nexus, acr, cdevents"), + }, + { + name: "missing hostname", + args: "create secret receiver test-secret --type=github --token=t", + assert: assertError("--hostname is required"), + }, + { + name: "gcr missing email-claim", + args: "create secret receiver test-secret --type=gcr --token=t --hostname=h", + assert: assertError("--email-claim is required for gcr receiver type"), + }, + { + name: "github receiver secret", + args: "create secret receiver receiver-secret --type=github --token=test-token --hostname=flux.example.com --namespace=my-namespace --export", + assert: assertGoldenFile("testdata/create_secret/receiver/secret-receiver.yaml"), + }, + { + name: "gcr receiver secret", + args: "create secret receiver gcr-secret --type=gcr --token=test-token --hostname=flux.example.com --email-claim=sa@project.iam.gserviceaccount.com --namespace=my-namespace --export", + assert: assertGoldenFile("testdata/create_secret/receiver/secret-receiver-gcr.yaml"), + }, + { + name: "gcr receiver secret with custom audience", + args: "create secret receiver gcr-secret --type=gcr --token=test-token --hostname=flux.example.com --email-claim=sa@project.iam.gserviceaccount.com --audience-claim=https://custom.audience.example.com --namespace=my-namespace --export", + assert: assertGoldenFile("testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := cmdTestCase{ + args: tt.args, + assert: tt.assert, + } + cmd.runTestCmd(t) + }) + } +} diff --git a/cmd/flux/main_test.go b/cmd/flux/main_test.go index 48c2549b..78159920 100644 --- a/cmd/flux/main_test.go +++ b/cmd/flux/main_test.go @@ -456,6 +456,7 @@ func resetCmdArgs() { secretGitArgs = NewSecretGitFlags() secretGitHubAppArgs = secretGitHubAppFlags{} secretProxyArgs = secretProxyFlags{} + secretReceiverArgs = secretReceiverFlags{} secretHelmArgs = secretHelmFlags{} secretTLSArgs = secretTLSFlags{} sourceBucketArgs = sourceBucketFlags{} diff --git a/cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml b/cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml new file mode 100644 index 00000000..9e52a56b --- /dev/null +++ b/cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + annotations: + notification.toolkit.fluxcd.io/webhook: https://flux.example.com/hook/6d6c55e9affb9d1e0d101ce604ae4270880ec1ff24d1bd2d928fcd64243d21a4 + name: gcr-secret + namespace: my-namespace +stringData: + audience: https://custom.audience.example.com + email: sa@project.iam.gserviceaccount.com + token: test-token + diff --git a/cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr.yaml b/cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr.yaml new file mode 100644 index 00000000..ff4c88b4 --- /dev/null +++ b/cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + annotations: + notification.toolkit.fluxcd.io/webhook: https://flux.example.com/hook/6d6c55e9affb9d1e0d101ce604ae4270880ec1ff24d1bd2d928fcd64243d21a4 + name: gcr-secret + namespace: my-namespace +stringData: + audience: https://flux.example.com/hook/6d6c55e9affb9d1e0d101ce604ae4270880ec1ff24d1bd2d928fcd64243d21a4 + email: sa@project.iam.gserviceaccount.com + token: test-token + diff --git a/cmd/flux/testdata/create_secret/receiver/secret-receiver.yaml b/cmd/flux/testdata/create_secret/receiver/secret-receiver.yaml new file mode 100644 index 00000000..f1f63759 --- /dev/null +++ b/cmd/flux/testdata/create_secret/receiver/secret-receiver.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + annotations: + notification.toolkit.fluxcd.io/webhook: https://flux.example.com/hook/106120121d366c2f67e93200f6c1dbe938235eb588daa5e8c0516d3a77ac1dee + name: receiver-secret + namespace: my-namespace +stringData: + token: test-token + diff --git a/internal/flags/receiver_type.go b/internal/flags/receiver_type.go new file mode 100644 index 00000000..42948b7a --- /dev/null +++ b/internal/flags/receiver_type.go @@ -0,0 +1,68 @@ +/* +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 flags + +import ( + "fmt" + "strings" + + notificationv1 "github.com/fluxcd/notification-controller/api/v1" + + "github.com/fluxcd/flux2/v2/internal/utils" +) + +var supportedReceiverTypes = []string{ + notificationv1.GenericReceiver, + notificationv1.GenericHMACReceiver, + notificationv1.GitHubReceiver, + notificationv1.GitLabReceiver, + notificationv1.BitbucketReceiver, + notificationv1.HarborReceiver, + notificationv1.DockerHubReceiver, + notificationv1.QuayReceiver, + notificationv1.GCRReceiver, + notificationv1.NexusReceiver, + notificationv1.ACRReceiver, + notificationv1.CDEventsReceiver, +} + +type ReceiverType string + +func (r *ReceiverType) String() string { + return string(*r) +} + +func (r *ReceiverType) Set(str string) error { + if strings.TrimSpace(str) == "" { + return fmt.Errorf("no receiver type given, please specify %s", + r.Description()) + } + if !utils.ContainsItemString(supportedReceiverTypes, str) { + return fmt.Errorf("receiver type '%s' is not supported, must be one of: %s", + str, strings.Join(supportedReceiverTypes, ", ")) + } + *r = ReceiverType(str) + return nil +} + +func (r *ReceiverType) Type() string { + return strings.Join(supportedReceiverTypes, "|") +} + +func (r *ReceiverType) Description() string { + return "the receiver type" +} diff --git a/pkg/manifestgen/sourcesecret/options.go b/pkg/manifestgen/sourcesecret/options.go index a226a4db..9fe7b49d 100644 --- a/pkg/manifestgen/sourcesecret/options.go +++ b/pkg/manifestgen/sourcesecret/options.go @@ -42,6 +42,12 @@ const ( KnownHostsSecretKey = "known_hosts" BearerTokenKey = "bearerToken" TrustPolicyKey = "trustpolicy.json" + TokenSecretKey = "token" + EmailSecretKey = "email" + AudienceSecretKey = "audience" + + // WebhookURLAnnotation is the annotation key for the computed webhook URL. + WebhookURLAnnotation = "notification.toolkit.fluxcd.io/webhook" // Deprecated: Replaced by CACrtSecretKey, but kept for backwards // compatibility with deprecated TLS flags. @@ -82,6 +88,13 @@ type Options struct { GitHubAppInstallationID string GitHubAppPrivateKey string GitHubAppBaseURL string + + // Receiver options + ReceiverType string + Token string + Hostname string + EmailClaim string + AudienceClaim string } type VerificationCrt struct { diff --git a/pkg/manifestgen/sourcesecret/sourcesecret.go b/pkg/manifestgen/sourcesecret/sourcesecret.go index 709f8d01..54cca4e8 100644 --- a/pkg/manifestgen/sourcesecret/sourcesecret.go +++ b/pkg/manifestgen/sourcesecret/sourcesecret.go @@ -18,7 +18,10 @@ package sourcesecret import ( "bytes" + "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net" @@ -260,6 +263,59 @@ func GenerateGitHubApp(options Options) (*manifestgen.Manifest, error) { return secretToManifest(secret, options) } +func GenerateReceiver(options Options) (*manifestgen.Manifest, error) { + token := options.Token + if token == "" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("failed to generate random token: %w", err) + } + token = hex.EncodeToString(b) + } + + if options.Hostname == "" { + return nil, fmt.Errorf("hostname is required") + } + + // Compute the webhook path using the same algorithm as notification-controller. + // See: github.com/fluxcd/notification-controller/api/v1.Receiver.GetWebhookPath + digest := sha256.Sum256([]byte(token + options.Name + options.Namespace)) + webhookPath := fmt.Sprintf("/hook/%x", digest) + webhookURL := fmt.Sprintf("https://%s%s", options.Hostname, webhookPath) + + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: options.Name, + Namespace: options.Namespace, + Labels: options.Labels, + Annotations: map[string]string{ + WebhookURLAnnotation: webhookURL, + }, + }, + StringData: map[string]string{ + TokenSecretKey: token, + }, + } + + if options.ReceiverType == "gcr" { + if options.EmailClaim == "" { + return nil, fmt.Errorf("email-claim is required for gcr receiver type") + } + secret.StringData[EmailSecretKey] = options.EmailClaim + if options.AudienceClaim != "" { + secret.StringData[AudienceSecretKey] = options.AudienceClaim + } else { + secret.StringData[AudienceSecretKey] = webhookURL + } + } + + return secretToManifest(secret, options) +} + func LoadKeyPairFromPath(path, password string) (*ssh.KeyPair, error) { if path == "" { return nil, nil