mirror of
https://github.com/fluxcd/flux2.git
synced 2026-04-10 14:10:05 +00:00
Merge pull request #5835 from fluxcd/create-secret-receiver
Add `flux create secret receiver` command
This commit is contained in:
commit
7c9810ea3b
11 changed files with 403 additions and 3 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
134
cmd/flux/create_secret_receiver.go
Normal file
134
cmd/flux/create_secret_receiver.go
Normal file
|
|
@ -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
|
||||
}
|
||||
74
cmd/flux/create_secret_receiver_test.go
Normal file
74
cmd/flux/create_secret_receiver_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -456,6 +456,7 @@ func resetCmdArgs() {
|
|||
secretGitArgs = NewSecretGitFlags()
|
||||
secretGitHubAppArgs = secretGitHubAppFlags{}
|
||||
secretProxyArgs = secretProxyFlags{}
|
||||
secretReceiverArgs = secretReceiverFlags{}
|
||||
secretHelmArgs = secretHelmFlags{}
|
||||
secretTLSArgs = secretTLSFlags{}
|
||||
sourceBucketArgs = sourceBucketFlags{}
|
||||
|
|
|
|||
13
cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml
vendored
Normal file
13
cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml
vendored
Normal file
|
|
@ -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
|
||||
|
||||
13
cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr.yaml
vendored
Normal file
13
cmd/flux/testdata/create_secret/receiver/secret-receiver-gcr.yaml
vendored
Normal file
|
|
@ -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
|
||||
|
||||
11
cmd/flux/testdata/create_secret/receiver/secret-receiver.yaml
vendored
Normal file
11
cmd/flux/testdata/create_secret/receiver/secret-receiver.yaml
vendored
Normal file
|
|
@ -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
|
||||
|
||||
68
internal/flags/receiver_type.go
Normal file
68
internal/flags/receiver_type.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue