Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02ee573bc2 | |||
| 944f7cc3f0 | |||
| ea730551d4 | |||
| 8b20c2efc1 | |||
| d446b60b7e | |||
| d8ae939d79 | |||
| e1970390a1 | |||
| 1be91ee7dd | |||
| 88c5a7f68d | |||
| 8c41d5b56d | |||
| 4bfdb6d459 | |||
| 9d9e56208c | |||
| 5425087730 |
@@ -20,9 +20,11 @@ import (
|
||||
"context"
|
||||
"crypto/elliptic"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -30,6 +32,7 @@ import (
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
"github.com/fluxcd/flux2/v2/pkg/bootstrap"
|
||||
"github.com/fluxcd/flux2/v2/pkg/manifestgen"
|
||||
"github.com/fluxcd/flux2/v2/pkg/manifestgen/sourcesecret"
|
||||
)
|
||||
@@ -79,6 +82,11 @@ type bootstrapFlags struct {
|
||||
gpgPassphrase string
|
||||
gpgKeyID string
|
||||
|
||||
sshSigningKeyFile string
|
||||
sshSigningPassword string
|
||||
sshSigningPassphrase string
|
||||
sshSigningReusePrivateKey bool
|
||||
|
||||
force bool
|
||||
|
||||
commitMessageAppendix string
|
||||
@@ -139,6 +147,12 @@ func init() {
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgPassphrase, "gpg-passphrase", "", "passphrase for decrypting GPG private key")
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgKeyID, "gpg-key-id", "", "key id for selecting a particular key")
|
||||
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningKeyFile, "ssh-signing-key-file", "", "path to an SSH private key file used for signing commits")
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningPassword, "ssh-signing-password", "", "passphrase for decrypting SSH signing key")
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningPassphrase, "ssh-signing-passphrase", "", "alias for --ssh-signing-password")
|
||||
bootstrapCmd.PersistentFlags().MarkHidden("ssh-signing-passphrase")
|
||||
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.sshSigningReusePrivateKey, "ssh-signing-reuse-private-key", false, "use the SSH transport key (--private-key-file) to sign commits")
|
||||
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.commitMessageAppendix, "commit-message-appendix", "", "string to add to the commit messages, e.g. '[ci skip]'")
|
||||
|
||||
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.force, "force", false, "override existing Flux installation if it's managed by a different tool such as Helm")
|
||||
@@ -195,6 +209,31 @@ func bootstrapValidate() error {
|
||||
return fmt.Errorf("invalid --registry-creds format, expected 'user:password'")
|
||||
}
|
||||
|
||||
sshSigningSet := bootstrapArgs.sshSigningKeyFile != "" || bootstrapArgs.sshSigningReusePrivateKey
|
||||
if bootstrapArgs.gpgKeyRingPath != "" && sshSigningSet {
|
||||
return fmt.Errorf("--gpg-* and --ssh-signing-* are mutually exclusive; pick one signing format")
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" && bootstrapArgs.sshSigningReusePrivateKey {
|
||||
return fmt.Errorf("--ssh-signing-key-file and --ssh-signing-reuse-private-key are mutually exclusive")
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey && bootstrapArgs.privateKeyFile == "" {
|
||||
return fmt.Errorf("--ssh-signing-reuse-private-key requires --private-key-file")
|
||||
}
|
||||
|
||||
sshSigningPwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sshSigningPwd != "" && bootstrapArgs.sshSigningKeyFile == "" {
|
||||
return fmt.Errorf("--ssh-signing-password requires --ssh-signing-key-file")
|
||||
}
|
||||
|
||||
if err := preflightSigningKey(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(bootstrapArgs.sshHostKeyAlgorithms) > 0 {
|
||||
git.HostKeyAlgos = bootstrapArgs.sshHostKeyAlgorithms
|
||||
}
|
||||
@@ -214,6 +253,57 @@ func mapTeamSlice(s []string, defaultPermission string) map[string]string {
|
||||
return m
|
||||
}
|
||||
|
||||
// preflightSigningKey reads and parses the configured signing key so
|
||||
// malformed PEM, wrong passphrases, and unsupported SSH algorithms
|
||||
// surface before any clone runs.
|
||||
func preflightSigningKey() error {
|
||||
switch {
|
||||
case bootstrapArgs.gpgKeyRingPath != "":
|
||||
ring, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid GPG signing key: %w", err)
|
||||
}
|
||||
if _, err := bootstrap.SelectOpenPGPSigningEntity(ring, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID); err != nil {
|
||||
return fmt.Errorf("invalid GPG signing key: %w", err)
|
||||
}
|
||||
case bootstrapArgs.sshSigningKeyFile != "":
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(pwd)); err != nil {
|
||||
return fmt.Errorf("invalid SSH signing key: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveSshSigningPassword resolves the SSH signing-key passphrase
|
||||
// from --ssh-signing-password and its hidden alias
|
||||
// --ssh-signing-passphrase. When both are set with the same value, the
|
||||
// value is returned. When both are set with different non-empty values,
|
||||
// an error is returned. When neither is set, an empty string is
|
||||
// returned with no error.
|
||||
func effectiveSshSigningPassword() (string, error) {
|
||||
pw := bootstrapArgs.sshSigningPassword
|
||||
alias := bootstrapArgs.sshSigningPassphrase
|
||||
switch {
|
||||
case pw != "" && alias != "":
|
||||
if pw != alias {
|
||||
return "", fmt.Errorf("--ssh-signing-password and --ssh-signing-passphrase are aliases; do not pass both")
|
||||
}
|
||||
return pw, nil
|
||||
case pw == "" && alias != "":
|
||||
return alias, nil
|
||||
default:
|
||||
return pw, nil
|
||||
}
|
||||
}
|
||||
|
||||
// confirmBootstrap gets a confirmation for running bootstrap over an existing Flux installation.
|
||||
// It returns a nil error if Flux is not installed or the user confirms overriding an existing installation
|
||||
func confirmBootstrap(ctx context.Context, kubeClient client.Client) error {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
@@ -287,6 +288,31 @@ func bootstrapBServerCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read transport private key for signing: %w", err)
|
||||
}
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
|
||||
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
@@ -315,6 +316,33 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read transport private key for signing: %w", err)
|
||||
}
|
||||
// Reuse-path pre-flight: bootstrapValidate cannot run this check
|
||||
// because the SSH transport password is subcommand-local.
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
|
||||
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewPlainGitProvider(gitClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -252,6 +252,12 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.WithLogger(logger),
|
||||
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
return fmt.Errorf("--ssh-signing-reuse-private-key is not supported by 'bootstrap gitea'; " +
|
||||
"that subcommand generates the SSH transport key in-process and has no operator-supplied key to reuse")
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshHostname != "" {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithSSHHostname(bootstrapArgs.sshHostname))
|
||||
}
|
||||
@@ -265,6 +271,19 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -259,6 +259,12 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.WithLogger(logger),
|
||||
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
return fmt.Errorf("--ssh-signing-reuse-private-key is not supported by 'bootstrap github'; " +
|
||||
"that subcommand generates the SSH transport key in-process and has no operator-supplied key to reuse")
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshHostname != "" {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithSSHHostname(bootstrapArgs.sshHostname))
|
||||
}
|
||||
@@ -272,6 +278,19 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/fluxcd/go-git-providers/gitprovider"
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
@@ -321,6 +322,31 @@ func bootstrapGitLabCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read transport private key for signing: %w", err)
|
||||
}
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
|
||||
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
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 (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootstrapValidate_signingFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gpgRing string
|
||||
gpgPass string
|
||||
sshKey string
|
||||
sshPass string
|
||||
sshPassp string
|
||||
privateKey string
|
||||
reuse bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "no signing flags is valid"},
|
||||
{name: "GPG only is valid", gpgRing: "./testdata/bootstrap/gpg.pgp"},
|
||||
{name: "SSH only is valid", sshKey: "./testdata/bootstrap/ed25519.private"},
|
||||
{
|
||||
name: "Reuse-private-key with private-key-file is valid",
|
||||
privateKey: "./testdata/bootstrap/ed25519.private",
|
||||
reuse: true,
|
||||
},
|
||||
{
|
||||
name: "GPG + SSH errors",
|
||||
gpgRing: "./testdata/bootstrap/gpg.pgp",
|
||||
sshKey: "./testdata/bootstrap/ed25519.private",
|
||||
wantErr: "--gpg-* and --ssh-signing-* are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "GPG + reuse errors",
|
||||
gpgRing: "./testdata/bootstrap/gpg.pgp",
|
||||
privateKey: "./testdata/bootstrap/ed25519.private",
|
||||
reuse: true,
|
||||
wantErr: "--gpg-* and --ssh-signing-* are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "SSH key-file + reuse errors",
|
||||
sshKey: "./testdata/bootstrap/ed25519.private",
|
||||
privateKey: "./testdata/bootstrap/ed25519.private",
|
||||
reuse: true,
|
||||
wantErr: "--ssh-signing-key-file and --ssh-signing-reuse-private-key are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "Reuse without private-key-file errors",
|
||||
reuse: true,
|
||||
wantErr: "--ssh-signing-reuse-private-key requires --private-key-file",
|
||||
},
|
||||
{
|
||||
name: "SSH password without key errors",
|
||||
sshPass: "secret",
|
||||
wantErr: "--ssh-signing-password requires --ssh-signing-key-file",
|
||||
},
|
||||
{
|
||||
name: "SSH passphrase alias alone applies",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
sshPassp: "abcde12345",
|
||||
},
|
||||
{
|
||||
name: "SSH password and passphrase with same value passes",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
sshPass: "abcde12345",
|
||||
sshPassp: "abcde12345",
|
||||
},
|
||||
{
|
||||
name: "SSH password and passphrase with different values errors",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
sshPass: "right",
|
||||
sshPassp: "wrong",
|
||||
wantErr: "are aliases; do not pass both",
|
||||
},
|
||||
{
|
||||
name: "SSH malformed key fails pre-flight",
|
||||
sshKey: "./testdata/bootstrap/malformed.private",
|
||||
wantErr: "invalid SSH signing key",
|
||||
},
|
||||
{
|
||||
name: "SSH encrypted key without password fails pre-flight",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
wantErr: "passphrase required",
|
||||
},
|
||||
// The GPG fixture used here is encrypted (passphrase: "right") so that
|
||||
// passing the wrong passphrase exercises the Decrypt error path.
|
||||
// An unencrypted key would make Decrypt a no-op regardless of the
|
||||
// passphrase supplied.
|
||||
{
|
||||
name: "GPG with wrong passphrase fails pre-flight",
|
||||
gpgRing: "./testdata/bootstrap/gpg-encrypted.pgp",
|
||||
gpgPass: "wrong",
|
||||
wantErr: "invalid GPG signing key",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
savedGpgRing := bootstrapArgs.gpgKeyRingPath
|
||||
savedGpgPass := bootstrapArgs.gpgPassphrase
|
||||
savedSshKey := bootstrapArgs.sshSigningKeyFile
|
||||
savedSshPass := bootstrapArgs.sshSigningPassword
|
||||
savedSshPassp := bootstrapArgs.sshSigningPassphrase
|
||||
savedPrivKey := bootstrapArgs.privateKeyFile
|
||||
savedReuse := bootstrapArgs.sshSigningReusePrivateKey
|
||||
defer func() {
|
||||
bootstrapArgs.gpgKeyRingPath = savedGpgRing
|
||||
bootstrapArgs.gpgPassphrase = savedGpgPass
|
||||
bootstrapArgs.sshSigningKeyFile = savedSshKey
|
||||
bootstrapArgs.sshSigningPassword = savedSshPass
|
||||
bootstrapArgs.sshSigningPassphrase = savedSshPassp
|
||||
bootstrapArgs.privateKeyFile = savedPrivKey
|
||||
bootstrapArgs.sshSigningReusePrivateKey = savedReuse
|
||||
}()
|
||||
|
||||
bootstrapArgs.gpgKeyRingPath = tt.gpgRing
|
||||
bootstrapArgs.gpgPassphrase = tt.gpgPass
|
||||
bootstrapArgs.sshSigningKeyFile = tt.sshKey
|
||||
bootstrapArgs.sshSigningPassword = tt.sshPass
|
||||
bootstrapArgs.sshSigningPassphrase = tt.sshPassp
|
||||
bootstrapArgs.privateKeyFile = tt.privateKey
|
||||
bootstrapArgs.sshSigningReusePrivateKey = tt.reuse
|
||||
|
||||
err := bootstrapValidate()
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
autov1 "github.com/fluxcd/image-automation-controller/api/v1"
|
||||
"github.com/fluxcd/pkg/apis/meta"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
)
|
||||
|
||||
@@ -75,6 +76,8 @@ type imageUpdateFlags struct {
|
||||
commitTemplate string
|
||||
authorName string
|
||||
authorEmail string
|
||||
signingKeySecret string
|
||||
signingKeyType string
|
||||
}
|
||||
|
||||
var imageUpdateArgs = imageUpdateFlags{}
|
||||
@@ -89,6 +92,8 @@ func init() {
|
||||
flags.StringVar(&imageUpdateArgs.commitTemplate, "commit-template", "", "a template for commit messages")
|
||||
flags.StringVar(&imageUpdateArgs.authorName, "author-name", "", "the name to use for commit author")
|
||||
flags.StringVar(&imageUpdateArgs.authorEmail, "author-email", "", "the email to use for commit author")
|
||||
flags.StringVar(&imageUpdateArgs.signingKeySecret, "signing-key-secret", "", "name of the Secret containing the signing key referenced in spec.git.commit.signingKey")
|
||||
flags.StringVar(&imageUpdateArgs.signingKeyType, "signing-key-type", "", "signing-key format: gpg or ssh (defaults to gpg when --signing-key-secret is set)")
|
||||
|
||||
createImageCmd.AddCommand(createImageUpdateCmd)
|
||||
}
|
||||
@@ -112,6 +117,15 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("the author email is required (--author-email)")
|
||||
}
|
||||
|
||||
if imageUpdateArgs.signingKeyType != "" && imageUpdateArgs.signingKeySecret == "" {
|
||||
return fmt.Errorf("--signing-key-type requires --signing-key-secret")
|
||||
}
|
||||
if imageUpdateArgs.signingKeyType != "" &&
|
||||
imageUpdateArgs.signingKeyType != string(autov1.SigningKeyTypeGPG) &&
|
||||
imageUpdateArgs.signingKeyType != string(autov1.SigningKeyTypeSSH) {
|
||||
return fmt.Errorf("--signing-key-type must be one of: gpg, ssh")
|
||||
}
|
||||
|
||||
labels, err := parseLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -163,6 +177,17 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if imageUpdateArgs.signingKeySecret != "" {
|
||||
keyType := imageUpdateArgs.signingKeyType
|
||||
if keyType == "" {
|
||||
keyType = string(autov1.SigningKeyTypeGPG)
|
||||
}
|
||||
update.Spec.GitSpec.Commit.SigningKey = &autov1.SigningKey{
|
||||
SecretRef: meta.LocalObjectReference{Name: imageUpdateArgs.signingKeySecret},
|
||||
Type: autov1.SigningKeyType(keyType),
|
||||
}
|
||||
}
|
||||
|
||||
if createArgs.export {
|
||||
return printExport(exportImageUpdate(&update))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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 TestCreateImageUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
assert assertFunc
|
||||
}{
|
||||
{
|
||||
name: "no signing key",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --interval=1m0s --namespace=flux-system --export",
|
||||
assert: assertGoldenFile("./testdata/create_image_update/no-signing.yaml"),
|
||||
},
|
||||
{
|
||||
name: "signing secret without explicit type defaults to gpg",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=my-key --interval=1m0s --namespace=flux-system --export",
|
||||
assert: assertGoldenFile("./testdata/create_image_update/signing-default-gpg.yaml"),
|
||||
},
|
||||
{
|
||||
name: "ssh signing key",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=my-deploy-key --signing-key-type=ssh --interval=1m0s --namespace=flux-system --export",
|
||||
assert: assertGoldenFile("./testdata/create_image_update/signing-ssh.yaml"),
|
||||
},
|
||||
{
|
||||
name: "signing-key-type without secret errors",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-type=ssh --namespace=flux-system --export",
|
||||
assert: assertError("--signing-key-type requires --signing-key-secret"),
|
||||
},
|
||||
{
|
||||
name: "invalid signing-key-type errors",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=k --signing-key-type=pgp --namespace=flux-system --export",
|
||||
assert: assertError("--signing-key-type must be one of: gpg, ssh"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := cmdTestCase{
|
||||
args: tt.args,
|
||||
assert: tt.assert,
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,16 @@ func createSourceHelmCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := url.Parse(sourceHelmArgs.url); err != nil {
|
||||
helmURL, err := url.Parse(sourceHelmArgs.url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("url parse failed: %w", err)
|
||||
}
|
||||
if helmURL.Scheme != "http" && helmURL.Scheme != "https" && helmURL.Scheme != sourcev1.HelmRepositoryTypeOCI {
|
||||
return fmt.Errorf("url scheme '%s' not supported, can be: http, https and oci", helmURL.Scheme)
|
||||
}
|
||||
if helmURL.Host == "" {
|
||||
return fmt.Errorf("url host is required")
|
||||
}
|
||||
|
||||
helmRepository := &sourcev1.HelmRepository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
@@ -132,11 +139,7 @@ func createSourceHelmCmdRun(cmd *cobra.Command, args []string) error {
|
||||
},
|
||||
}
|
||||
|
||||
url, err := url.Parse(sourceHelmArgs.url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
if url.Scheme == sourcev1.HelmRepositoryTypeOCI {
|
||||
if helmURL.Scheme == sourcev1.HelmRepositoryTypeOCI {
|
||||
helmRepository.Spec.Type = sourcev1.HelmRepositoryTypeOCI
|
||||
helmRepository.Spec.Provider = sourceHelmArgs.ociProvider
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ func TestCreateSourceHelm(t *testing.T) {
|
||||
resultFile: "name is required",
|
||||
assertFunc: "assertError",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL scheme",
|
||||
args: "create source helm podinfo --url=git://example.com/charts --export",
|
||||
resultFile: "url scheme 'git' not supported, can be: http, https and oci",
|
||||
assertFunc: "assertError",
|
||||
},
|
||||
{
|
||||
name: "OCI repo",
|
||||
args: "create source helm podinfo --url=oci://ghcr.io/stefanprodan/charts/podinfo --interval 5m --export",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDuUiEMA0
|
||||
eUvKlmOsur2w9FAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIDF/w86ZQb5qmZtv
|
||||
m1GvyLojiJdhmPtI9hJ9XPcP7HBoAAAAkG2cIOuSVdWInSC0P81ExiorUpiAGOjxxpgvKW
|
||||
VYERfU1zU72Z/c9n1+z/IH5cJOhZ1vlqBO0rubl4s0KQFvY/LKcsc4N0x0uzpqrvcJP4tO
|
||||
9VW8LrMnrPp7b6KVJPsbeSW1SBcUM24aCMzF4/wV03mN/Uqz30s+YgS9SU4Lz8AOkX58xX
|
||||
yAV0gkmndIzZl+Og==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCAAAAIjjSDmx40g5
|
||||
sQAAAAtzc2gtZWQyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCA
|
||||
AAAEAGpzSFuLkCNDD49+tysxSFFwdOsRnDj67vDT9bfwoSDhYOV20IV1IxchXW8vBe9HCT
|
||||
h4SZKgMKnE2Rxs2mHd0IAAAABHRlc3QB
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
not a real ssh key
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageUpdateAutomation
|
||||
metadata:
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
spec:
|
||||
git:
|
||||
checkout:
|
||||
ref:
|
||||
branch: main
|
||||
commit:
|
||||
author:
|
||||
email: flux@example.com
|
||||
name: flux
|
||||
interval: 1m0s
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageUpdateAutomation
|
||||
metadata:
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
spec:
|
||||
git:
|
||||
checkout:
|
||||
ref:
|
||||
branch: main
|
||||
commit:
|
||||
author:
|
||||
email: flux@example.com
|
||||
name: flux
|
||||
signingKey:
|
||||
secretRef:
|
||||
name: my-key
|
||||
type: gpg
|
||||
interval: 1m0s
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageUpdateAutomation
|
||||
metadata:
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
spec:
|
||||
git:
|
||||
checkout:
|
||||
ref:
|
||||
branch: main
|
||||
commit:
|
||||
author:
|
||||
email: flux@example.com
|
||||
name: flux
|
||||
signingKey:
|
||||
secretRef:
|
||||
name: my-deploy-key
|
||||
type: ssh
|
||||
interval: 1m0s
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
+4
@@ -10,6 +10,10 @@ spec:
|
||||
author:
|
||||
email: fluxcdbot@users.noreply.github.com
|
||||
name: fluxcdbot
|
||||
signingKey:
|
||||
secretRef:
|
||||
name: my-signing-key
|
||||
type: ssh
|
||||
interval: 1m0s
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
|
||||
+4
@@ -67,6 +67,10 @@ spec:
|
||||
email: fluxcdbot@users.noreply.github.com
|
||||
name: fluxcdbot
|
||||
messageTemplate: '{{range .Updated.Images}}{{println .}}{{end}}'
|
||||
signingKey:
|
||||
secretRef:
|
||||
name: my-signing-key
|
||||
type: ssh
|
||||
update:
|
||||
path: ./clusters/my-cluster
|
||||
strategy: Setters
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
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"
|
||||
"time"
|
||||
|
||||
"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
|
||||
retries int
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
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")
|
||||
triggerReceiverCmd.Flags().IntVar(&triggerReceiverArgs.retries, "retries", 10,
|
||||
"the number of times to retry on connection errors or retryable HTTP status codes (404, 408, 429, 5xx); set to 0 to disable")
|
||||
triggerReceiverCmd.Flags().DurationVar(&triggerReceiverArgs.retryDelay, "retry-delay", 10*time.Second,
|
||||
"the delay between retries")
|
||||
|
||||
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)
|
||||
|
||||
// Compute the request headers once; the auth material does not change between
|
||||
// attempts, so they are applied to a fresh request on each retry.
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"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)
|
||||
headers["X-Signature"] = "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
case genericOIDCReceiver:
|
||||
oidcToken, err := resolveOIDCToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + oidcToken
|
||||
}
|
||||
|
||||
// send performs a single attempt. It reports retryable=true for transient
|
||||
// failures (connection errors and retryable HTTP status codes) so the caller
|
||||
// can retry; permanent failures (e.g. authentication or validation errors)
|
||||
// report retryable=false and fail immediately.
|
||||
send := func() (retryable bool, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to create request: %w", err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return true, 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 {
|
||||
statusErr := fmt.Errorf("request to %s failed with status %s", webhookURL, resp.Status)
|
||||
if msg := strings.TrimSpace(string(body)); msg != "" {
|
||||
statusErr = fmt.Errorf("request to %s failed with status %s: %s", webhookURL, resp.Status, msg)
|
||||
}
|
||||
return isRetryableStatus(resp.StatusCode), statusErr
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
logger.Actionf("triggering Receiver %s/%s", *kubeconfigArgs.Namespace, name)
|
||||
for attempt := 0; ; attempt++ {
|
||||
retryable, err := send()
|
||||
if err == nil {
|
||||
logger.Successf("Receiver %s/%s triggered", *kubeconfigArgs.Namespace, name)
|
||||
return nil
|
||||
}
|
||||
if !retryable || attempt >= triggerReceiverArgs.retries {
|
||||
return err
|
||||
}
|
||||
logger.Waitingf("%s; retrying in %s (%d/%d)",
|
||||
err, triggerReceiverArgs.retryDelay, attempt+1, triggerReceiverArgs.retries)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(triggerReceiverArgs.retryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryableStatus reports whether an HTTP status returned by the webhook
|
||||
// receiver is worth retrying. 404 is included because the Receiver's webhook
|
||||
// path may not be registered yet right after the notification-controller starts
|
||||
// or while the Receiver reconciles.
|
||||
func isRetryableStatus(code int) bool {
|
||||
switch code {
|
||||
case http.StatusNotFound, // 404
|
||||
http.StatusRequestTimeout, // 408
|
||||
http.StatusTooManyRequests, // 429
|
||||
http.StatusInternalServerError, // 500
|
||||
http.StatusBadGateway, // 502
|
||||
http.StatusServiceUnavailable, // 503
|
||||
http.StatusGatewayTimeout: // 504
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//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"
|
||||
"sync/atomic"
|
||||
"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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retries on retryable status then succeeds", func(t *testing.T) {
|
||||
resetTriggerReceiverArgs(t)
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&attempts, 1) < 3 {
|
||||
w.WriteHeader(http.StatusNotFound) // transient: path not registered yet
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
triggerReceiverArgs.url = srv.URL
|
||||
triggerReceiverArgs.token = token
|
||||
triggerReceiverArgs.retries = 5
|
||||
triggerReceiverArgs.retryDelay = time.Millisecond
|
||||
|
||||
if err := triggerReceiverCmdRun(nil, []string{name}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 3 {
|
||||
t.Errorf("attempts = %d, want 3", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not retry non-retryable status", func(t *testing.T) {
|
||||
resetTriggerReceiverArgs(t)
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
triggerReceiverArgs.url = srv.URL
|
||||
triggerReceiverArgs.token = token
|
||||
triggerReceiverArgs.retries = 5
|
||||
triggerReceiverArgs.retryDelay = time.Millisecond
|
||||
|
||||
if err := triggerReceiverCmdRun(nil, []string{name}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 1 {
|
||||
t.Errorf("attempts = %d, want 1 (no retry on 403)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error after exhausting retries", func(t *testing.T) {
|
||||
resetTriggerReceiverArgs(t)
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
triggerReceiverArgs.url = srv.URL
|
||||
triggerReceiverArgs.token = token
|
||||
triggerReceiverArgs.retries = 2
|
||||
triggerReceiverArgs.retryDelay = time.Millisecond
|
||||
|
||||
if err := triggerReceiverCmdRun(nil, []string{name}); err == nil {
|
||||
t.Fatal("expected error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 3 {
|
||||
t.Errorf("attempts = %d, want 3 (1 initial + 2 retries)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ require (
|
||||
github.com/fluxcd/cli-utils v1.2.0
|
||||
github.com/fluxcd/go-git-providers v0.26.0
|
||||
github.com/fluxcd/helm-controller/api v1.5.5
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.4
|
||||
github.com/fluxcd/image-automation-controller/api v1.0.1-0.20260529125431-20ebc65ab20f
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.2
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.5
|
||||
github.com/fluxcd/notification-controller/api v1.8.4
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
github.com/fluxcd/pkg/auth v0.45.0
|
||||
github.com/fluxcd/pkg/chartutil v1.24.0
|
||||
github.com/fluxcd/pkg/envsubst v1.7.0
|
||||
github.com/fluxcd/pkg/git v0.49.0
|
||||
github.com/fluxcd/pkg/git v0.49.1-0.20260529122759-f46ad90373c5
|
||||
github.com/fluxcd/pkg/kustomize v1.32.0
|
||||
github.com/fluxcd/pkg/oci v0.66.0
|
||||
github.com/fluxcd/pkg/runtime v0.106.0
|
||||
@@ -79,7 +79,7 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/MakeNowJust/heredoc v1.0.0 // indirect
|
||||
@@ -166,6 +166,7 @@ require (
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
||||
github.com/hiddeco/sshsig v0.2.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
|
||||
@@ -26,9 +26,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontai
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0/go.mod h1:TmlMW4W5OvXOmOyKNnor8nlMMiO1ctIyzmHme/VHsrA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
@@ -181,8 +180,8 @@ github.com/fluxcd/go-git-providers v0.26.0 h1:0DUsXc1nS9Fe4n8tXSEUCGemWzHShd66gm
|
||||
github.com/fluxcd/go-git-providers v0.26.0/go.mod h1:VJDKUOhZwNAIqDF5iPtIpTr/annsDbKMkPpWiDMBdpo=
|
||||
github.com/fluxcd/helm-controller/api v1.5.5 h1:xQA/9gbifMvZPGhSNKHsrkq829dI/yTBASVdYp9/s4Y=
|
||||
github.com/fluxcd/helm-controller/api v1.5.5/go.mod h1:lTgeUmtVYExMKp7mRDncsr4JwHTz3LFtLjRJZeR98lI=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.4 h1:i78AwbcICXSX+a1MQwjNA1Uxxs1e3kfi3EJ21fWzb7w=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.4/go.mod h1:lkD/drkD6Wc+2SDjVj5KqfozEucTLFexWgby/5ft660=
|
||||
github.com/fluxcd/image-automation-controller/api v1.0.1-0.20260529125431-20ebc65ab20f h1:pjHh/w2xRd9u20J0c8H0EEoPAurJndm2XNF+/mem3EE=
|
||||
github.com/fluxcd/image-automation-controller/api v1.0.1-0.20260529125431-20ebc65ab20f/go.mod h1:XNWgNSF7GVZgGx6qTI+8jhiH0S+a/hNtNvBwPcxOotw=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.2 h1:VPwUgA8WyPVVs16uSkwvjOAY6pvTYgAb0fL90t0RKLE=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.2/go.mod h1:j4JSIocL42HQ77Veg1t60sApOy+lng8/cbXHXGSnfi0=
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.5 h1:4fGPh6foGVKUUbt5OjVzbC5iTyX+Q+NS50atPboDC4w=
|
||||
@@ -205,8 +204,8 @@ github.com/fluxcd/pkg/chartutil v1.24.0 h1:Rh9o50eJUnAioL5q2JVPxO4DjFjwHwPE5K9Lc
|
||||
github.com/fluxcd/pkg/chartutil v1.24.0/go.mod h1:oycULCP00m46dxiskme1Yawe74UFLZzX0jqHb6xzdmQ=
|
||||
github.com/fluxcd/pkg/envsubst v1.7.0 h1:PL9Nj/V2fgaMR9KYZR7mEEw+vlYgP80nFZjOQQKAfJs=
|
||||
github.com/fluxcd/pkg/envsubst v1.7.0/go.mod h1:aoWeSIOamhqBZ3bHVj1GDwpdA10DXrI8yYbyjPiFly0=
|
||||
github.com/fluxcd/pkg/git v0.49.0 h1:OtI0TjMVC/GV/yPos4waA5fAs5u3/2YEYBFuZuJA3Rc=
|
||||
github.com/fluxcd/pkg/git v0.49.0/go.mod h1:Xnrqtz60/0jqfcy5UsY9sxlvPmlvzelPsej2v3L7wW8=
|
||||
github.com/fluxcd/pkg/git v0.49.1-0.20260529122759-f46ad90373c5 h1:ZPpsEw33U/2JmgeGIbgdAN0UX+GM01sE+cb1OuuSQwY=
|
||||
github.com/fluxcd/pkg/git v0.49.1-0.20260529122759-f46ad90373c5/go.mod h1:OgaHoS0iR0GuLl+f778X7NrGy1pDH7xcpF/nsCRgJ9g=
|
||||
github.com/fluxcd/pkg/gittestserver v0.29.0 h1:2j03zKVL6iVn6oiUuecG/O/3Q1pULWM9JrF/HSjkpnc=
|
||||
github.com/fluxcd/pkg/gittestserver v0.29.0/go.mod h1:O8151jV0ppBZTb9IUXMjxh6hZpkiuLq8JQHDBPOkZFw=
|
||||
github.com/fluxcd/pkg/kustomize v1.32.0 h1:5lLT2dgR+JrcoJHB7/K50o0AcJikKvXcRd3r7jIYZC8=
|
||||
@@ -353,6 +352,8 @@ github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGN
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
|
||||
github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE=
|
||||
github.com/homeport/dyff v1.10.2 h1:XyB+D0KVwjbUFTZYIkvPtsImwkfh+ObH2CEdEHTqdr4=
|
||||
github.com/homeport/dyff v1.10.2/go.mod h1:0kIjL/JOGaXigzrLY6kcl5esSStbAa99r6GzEvr7lrs=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
- op: add
|
||||
path: /spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/git/properties/commit/properties/signingKey/properties/type
|
||||
value:
|
||||
default: gpg
|
||||
description: |-
|
||||
Type selects the signing-key format expected in the referenced
|
||||
Secret. Defaults to 'gpg'.
|
||||
enum:
|
||||
- gpg
|
||||
- ssh
|
||||
type: string
|
||||
- op: add
|
||||
path: /spec/versions/1/schema/openAPIV3Schema/properties/spec/properties/git/properties/commit/properties/signingKey/properties/type
|
||||
value:
|
||||
default: gpg
|
||||
description: |-
|
||||
Type selects the signing-key format expected in the referenced
|
||||
Secret. Defaults to 'gpg'.
|
||||
enum:
|
||||
- gpg
|
||||
- ssh
|
||||
type: string
|
||||
@@ -13,3 +13,9 @@ patches:
|
||||
kind: Deployment
|
||||
name: image-automation-controller
|
||||
path: patch.yaml
|
||||
- target:
|
||||
group: apiextensions.k8s.io
|
||||
version: v1
|
||||
kind: CustomResourceDefinition
|
||||
name: imageupdateautomations.image.toolkit.fluxcd.io
|
||||
path: crd-signing-key-type-patch.yaml
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"github.com/fluxcd/pkg/apis/meta"
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/repository"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/fluxcd/pkg/kustomize/filesys"
|
||||
runclient "github.com/fluxcd/pkg/runtime/client"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
@@ -67,6 +68,9 @@ type PlainGitBootstrapper struct {
|
||||
gpgPassphrase string
|
||||
gpgKeyID string
|
||||
|
||||
sshSigningKey []byte
|
||||
sshSigningPassword []byte
|
||||
|
||||
restClientGetter genericclioptions.RESTClientGetter
|
||||
restClientOptions *runclient.Options
|
||||
|
||||
@@ -155,24 +159,27 @@ func (b *PlainGitBootstrapper) ReconcileComponents(ctx context.Context, manifest
|
||||
b.logger.Successf("generated component manifests")
|
||||
|
||||
// Write generated files and make a commit
|
||||
var signer *openpgp.Entity
|
||||
if b.gpgKeyRing != nil {
|
||||
signer, err = getOpenPgpEntity(b.gpgKeyRing, b.gpgPassphrase, b.gpgKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OpenPGP entity: %w", err)
|
||||
}
|
||||
signer, err := b.resolveSigner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct commit signer: %w", err)
|
||||
}
|
||||
commitMsg := fmt.Sprintf("Add Flux %s component manifests", options.Version)
|
||||
if b.commitMessageAppendix != "" {
|
||||
commitMsg = commitMsg + "\n\n" + b.commitMessageAppendix
|
||||
}
|
||||
|
||||
commitOpts := []repository.CommitOption{
|
||||
repository.WithFiles(map[string]io.Reader{
|
||||
manifests.Path: strings.NewReader(manifests.Content),
|
||||
}),
|
||||
}
|
||||
if signer != nil {
|
||||
commitOpts = append(commitOpts, repository.WithSigner(signer))
|
||||
}
|
||||
commit, err := b.gitClient.Commit(git.Commit{
|
||||
Author: b.signature,
|
||||
Message: commitMsg,
|
||||
}, repository.WithFiles(map[string]io.Reader{
|
||||
manifests.Path: strings.NewReader(manifests.Content),
|
||||
}), repository.WithSigner(signer))
|
||||
}, commitOpts...)
|
||||
if err != nil && err != git.ErrNoStagedFiles {
|
||||
return fmt.Errorf("failed to commit component manifests: %w", err)
|
||||
}
|
||||
@@ -330,24 +337,27 @@ func (b *PlainGitBootstrapper) ReconcileSyncConfig(ctx context.Context, options
|
||||
b.logger.Successf("generated sync manifests")
|
||||
|
||||
// Write generated files and make a commit
|
||||
var signer *openpgp.Entity
|
||||
if b.gpgKeyRing != nil {
|
||||
signer, err = getOpenPgpEntity(b.gpgKeyRing, b.gpgPassphrase, b.gpgKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OpenPGP entity: %w", err)
|
||||
}
|
||||
signer, err := b.resolveSigner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct commit signer: %w", err)
|
||||
}
|
||||
commitMsg := "Add Flux sync manifests"
|
||||
if b.commitMessageAppendix != "" {
|
||||
commitMsg = commitMsg + "\n\n" + b.commitMessageAppendix
|
||||
}
|
||||
|
||||
commitOpts := []repository.CommitOption{
|
||||
repository.WithFiles(map[string]io.Reader{
|
||||
kusManifests.Path: strings.NewReader(kusManifests.Content),
|
||||
}),
|
||||
}
|
||||
if signer != nil {
|
||||
commitOpts = append(commitOpts, repository.WithSigner(signer))
|
||||
}
|
||||
commit, err := b.gitClient.Commit(git.Commit{
|
||||
Author: b.signature,
|
||||
Message: commitMsg,
|
||||
}, repository.WithFiles(map[string]io.Reader{
|
||||
kusManifests.Path: strings.NewReader(kusManifests.Content),
|
||||
}), repository.WithSigner(signer))
|
||||
}, commitOpts...)
|
||||
if err != nil && err != git.ErrNoStagedFiles {
|
||||
return fmt.Errorf("failed to commit sync manifests: %w", err)
|
||||
}
|
||||
@@ -511,7 +521,33 @@ func (b *PlainGitBootstrapper) cleanGitRepoDir() error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func getOpenPgpEntity(keyRing openpgp.EntityList, passphrase, keyID string) (*openpgp.Entity, error) {
|
||||
// resolveSigner returns a signature.Signer derived from the configured
|
||||
// commit-signing options, or (nil, nil) when no signing has been
|
||||
// configured. GPG and SSH signing are mutually exclusive; if both have
|
||||
// been configured the GPG path wins (the caller is responsible for
|
||||
// rejecting the combination at flag-validation time).
|
||||
func (b *PlainGitBootstrapper) resolveSigner() (signature.Signer, error) {
|
||||
switch {
|
||||
case b.gpgKeyRing != nil:
|
||||
entity, err := SelectOpenPGPSigningEntity(b.gpgKeyRing, b.gpgPassphrase, b.gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OpenPGP signing entity: %w", err)
|
||||
}
|
||||
return signature.NewOpenPGPSigner(entity)
|
||||
case len(b.sshSigningKey) > 0:
|
||||
return signature.NewSSHSigner(b.sshSigningKey, b.sshSigningPassword)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SelectOpenPGPSigningEntity selects a single OpenPGP entity from the
|
||||
// given keyring and decrypts its private key with the provided
|
||||
// passphrase. When keyID is empty the keyring must contain exactly one
|
||||
// entity; otherwise the entity with the matching 16-character key ID
|
||||
// is selected. Returns an error if no matching entity is found, the
|
||||
// matching entity has no private key, or decryption fails.
|
||||
func SelectOpenPGPSigningEntity(keyRing openpgp.EntityList, passphrase, keyID string) (*openpgp.Entity, error) {
|
||||
if len(keyRing) == 0 {
|
||||
return nil, fmt.Errorf("empty GPG key ring")
|
||||
}
|
||||
|
||||
@@ -18,10 +18,23 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/fluxcd/pkg/apis/meta"
|
||||
"github.com/fluxcd/pkg/git"
|
||||
gogit "github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/repository"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
extgogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
. "github.com/onsi/gomega"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -468,3 +481,135 @@ func Test_objectReconciled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainGitBootstrapper_resolveSigner(t *testing.T) {
|
||||
t.Run("no signing configured returns nil signer", func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
b := &PlainGitBootstrapper{}
|
||||
signer, err := b.resolveSigner()
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(signer).To(BeNil())
|
||||
})
|
||||
|
||||
t.Run("GPG key ring returns an OpenPGP signer", func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
b := &PlainGitBootstrapper{gpgKeyRing: openpgp.EntityList{entity}}
|
||||
signer, err := b.resolveSigner()
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(signer).ToNot(BeNil())
|
||||
})
|
||||
|
||||
t.Run("SSH key returns an SSH signer", func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
block, err := gossh.MarshalPrivateKey(priv, "test ed25519 key")
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
pemBytes := pem.EncodeToMemory(block)
|
||||
|
||||
b := &PlainGitBootstrapper{sshSigningKey: pemBytes}
|
||||
signer, err := b.resolveSigner()
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(signer).ToNot(BeNil())
|
||||
})
|
||||
|
||||
t.Run("encrypted SSH key without password errors", func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
block, err := gossh.MarshalPrivateKeyWithPassphrase(priv, "test ed25519 key", []byte("pw"))
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
pemBytes := pem.EncodeToMemory(block)
|
||||
|
||||
b := &PlainGitBootstrapper{sshSigningKey: pemBytes}
|
||||
_, err = b.resolveSigner()
|
||||
g.Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
t.Run("GPG path takes precedence over SSH path", func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
b := &PlainGitBootstrapper{
|
||||
gpgKeyRing: openpgp.EntityList{entity},
|
||||
sshSigningKey: []byte("ignored"),
|
||||
}
|
||||
signer, err := b.resolveSigner()
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(signer).ToNot(BeNil())
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlainGitBootstrapper_sshSignerProducesVerifiableCommit is an
|
||||
// end-to-end wiring test. resolveSigner already has unit tests for
|
||||
// dispatch behaviour, but nothing in pkg/bootstrap exercises the full
|
||||
// path from sshSigningKey → resolveSigner → repository.WithSigner →
|
||||
// gogit.Client.Commit → gpgsig header on the resulting commit object.
|
||||
// This test drives that path and then verifies the signature via
|
||||
// signature.VerifySSHSignature, catching regressions that the existing
|
||||
// dispatcher unit tests would miss.
|
||||
func TestPlainGitBootstrapper_sshSignerProducesVerifiableCommit(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Generate an ed25519 keypair and marshal the private key to PEM.
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
pemBlock, err := gossh.MarshalPrivateKey(priv, "test ed25519 key")
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
pemBytes := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
// Resolve a Signer via the same path the bootstrap commit code uses.
|
||||
b := &PlainGitBootstrapper{sshSigningKey: pemBytes}
|
||||
signer, err := b.resolveSigner()
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(signer).ToNot(BeNil())
|
||||
|
||||
// Initialise a gogit.Client against a fresh on-disk repo. Init sets
|
||||
// the internal repository pointer so that Commit can operate.
|
||||
tmp := t.TempDir()
|
||||
gogitClient, err := gogit.NewClient(tmp, nil)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
// Use a file:// URL; Init only records the remote URL, it does not
|
||||
// actually connect, so any syntactically valid URL works here.
|
||||
g.Expect(gogitClient.Init(context.Background(), "file:///dev/null", git.DefaultBranch)).To(Succeed())
|
||||
|
||||
// Drive a commit through the same gogit pipeline bootstrap uses.
|
||||
hash, err := gogitClient.Commit(
|
||||
git.Commit{
|
||||
Author: git.Signature{Name: "Test", Email: "test@example.com"},
|
||||
Message: "ssh-signed test commit",
|
||||
},
|
||||
repository.WithFiles(map[string]io.Reader{
|
||||
"signed-file": strings.NewReader("hello sshsig"),
|
||||
}),
|
||||
repository.WithSigner(signer),
|
||||
)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read the commit object back via a plain go-git open of the same path.
|
||||
repo, err := extgogit.PlainOpen(tmp)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
commit, err := repo.CommitObject(plumbing.NewHash(hash))
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The commit must carry an SSH signature header.
|
||||
g.Expect(commit.PGPSignature).To(HavePrefix("-----BEGIN SSH SIGNATURE-----"))
|
||||
|
||||
// Reconstruct the canonical payload (commit without signature) and
|
||||
// run the full cryptographic verification against the known public key.
|
||||
encoded := &plumbing.MemoryObject{}
|
||||
g.Expect(commit.EncodeWithoutSignature(encoded)).To(Succeed())
|
||||
payloadReader, err := encoded.Reader()
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
payload, err := io.ReadAll(payloadReader)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
gosshPub, err := gossh.NewPublicKey(pub)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
authorizedKey := gossh.MarshalAuthorizedKey(gosshPub)
|
||||
|
||||
_, err = signature.VerifySSHSignature(commit.PGPSignature, payload, string(authorizedKey))
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
@@ -145,6 +145,34 @@ func (o gitCommitSigningOption) applyGitProvider(b *GitProviderBootstrapper) {
|
||||
o.applyGit(b.PlainGitBootstrapper)
|
||||
}
|
||||
|
||||
// WithSSHCommitSigning configures the bootstrapper to sign commits with
|
||||
// an SSH private key. pem is the PEM-encoded private key (typically the
|
||||
// OpenSSH "-----BEGIN OPENSSH PRIVATE KEY-----" format produced by
|
||||
// ssh-keygen). password is the optional passphrase for the key; pass
|
||||
// nil for an unencrypted key.
|
||||
//
|
||||
// WithSSHCommitSigning and WithGitCommitSigning are mutually exclusive;
|
||||
// calling both is undefined behavior. The caller is responsible for
|
||||
// rejecting that combination before constructing the bootstrapper (the
|
||||
// flux CLI does this in bootstrapValidate).
|
||||
func WithSSHCommitSigning(pem, password []byte) Option {
|
||||
return sshCommitSigningOption{pem: pem, password: password}
|
||||
}
|
||||
|
||||
type sshCommitSigningOption struct {
|
||||
pem []byte
|
||||
password []byte
|
||||
}
|
||||
|
||||
func (o sshCommitSigningOption) applyGit(b *PlainGitBootstrapper) {
|
||||
b.sshSigningKey = o.pem
|
||||
b.sshSigningPassword = o.password
|
||||
}
|
||||
|
||||
func (o sshCommitSigningOption) applyGitProvider(b *GitProviderBootstrapper) {
|
||||
o.applyGit(b.PlainGitBootstrapper)
|
||||
}
|
||||
|
||||
func LoadEntityListFromPath(path string) (openpgp.EntityList, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
|
||||
Reference in New Issue
Block a user