1
0
mirror of synced 2026-05-30 03:40:47 +00:00

Compare commits

...

13 Commits

Author SHA1 Message Date
Hidde Beydals 02ee573bc2 Cover signingKey round-trip in export tests
Extends the existing TestExport 'image update' case with a signingKey
block on the seeded ImageUpdateAutomation, asserting the new field
survives the kubeClient.Get + serialize path. Parallels how the
existing fixture exercises every other field on the resource.

Also patches the embedded CRD source under manifests/bases/image-
automation-controller/ to inject the signingKey.type schema property
into both v1 and v1beta2. The patch is transitional and should be
removed once the image-automation-controller release bundle includes
the new type field natively.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:34 +02:00
Hidde Beydals 944f7cc3f0 Cover create image update signing flags
Adds golden-file tests for the new --signing-key-secret and
--signing-key-type flags: no-signing (baseline), default-gpg (asserts
type: gpg is rendered explicitly when only the secret is set), ssh,
and the two validation-error cases. Establishes
cmd/flux/testdata/create_image_update/ for future expansion of this
command's coverage.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:33 +02:00
Hidde Beydals ea730551d4 Add signing-key flags to create image update
Closes a pre-existing gap where the ImageUpdateAutomation SigningKey
field was reachable only by hand-editing the rendered YAML. The two
new flags --signing-key-secret and --signing-key-type populate the
spec.git.commit.signingKey block directly.

When --signing-key-secret is set without --signing-key-type, the run
function fills in 'gpg' explicitly so the rendered YAML matches what
the apiserver would default it to. Validation rejects --signing-key-
type without --signing-key-secret and rejects values outside
{gpg, ssh}, using the typed SigningKeyType constants exported from
the image-automation-controller API so the validator and populator
share a single source of truth.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:33 +02:00
Hidde Beydals 8b20c2efc1 Test bootstrap signing flag validation
Covers the validation matrix of the new --gpg-* / --ssh-signing-*
surface: mutual exclusion (across GPG/SSH groups and within the SSH
group between --ssh-signing-key-file and --ssh-signing-reuse-private-
key), alias resolution between --ssh-signing-password and
--ssh-signing-passphrase, the dependency checks (--ssh-signing-
password requires --ssh-signing-key-file; --ssh-signing-reuse-
private-key requires --private-key-file), and pre-flight key-parse
failures (malformed PEM, encrypted SSH key without passphrase, GPG
ring with wrong passphrase). Test keys are checked in so the test
does not depend on local ssh-keygen or gpg invocations at run time.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:32 +02:00
Hidde Beydals d446b60b7e Wire SSH signing into provider bootstrap commands
Adds the same explicit-path SSH-signing wiring to flux bootstrap
github / gitlab / gitea / bitbucket-server, consulting the new
effectiveSshSigningPassword helper for the resolved passphrase.

The reuse-path wiring applies only to gitlab and bitbucket-server
(which consume --private-key-file as the SSH transport key). github
and gitea generate the transport key in-process, so they reject
--ssh-signing-reuse-private-key explicitly with a message explaining
why. The reject check fires immediately after each subcommand's
bootstrapOpts slice literal closes, before any conditional appends,
so the failure semantics match the reading order of the code.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:31 +02:00
Hidde Beydals d8ae939d79 Wire SSH signing into bootstrap git
Reads --ssh-signing-key-file when set, decodes the file contents,
resolves the effective signing passphrase, and appends
bootstrap.WithSSHCommitSigning to the bootstrap options. When
--ssh-signing-reuse-private-key is set, reads the transport
--private-key-file, pre-flights it against the subcommand-local
gitArgs.password, and reuses the same bytes + passphrase for signing.

The reuse-path pre-flight lives in this subcommand's RunE because
bootstrapValidate does not have access to the transport password.
Mutual exclusion with --gpg-* and explicit-path key-parse validation
are enforced upstream in bootstrapValidate.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:31 +02:00
Hidde Beydals e1970390a1 Add SSH signing flags to bootstrap
Introduces four new persistent flags on flux bootstrap:
--ssh-signing-key-file, --ssh-signing-password, the hidden alias
--ssh-signing-passphrase, and the reuse boolean
--ssh-signing-reuse-private-key. They sit next to the existing
--gpg-key-ring / --gpg-passphrase / --gpg-key-id surface.

bootstrapValidate pre-flights the configured signing key for the
explicit GPG and SSH paths so malformed PEM, wrong passphrases, and
unsupported SSH algorithms surface before any clone runs. The GPG
pre-flight calls the now-exported SelectOpenPGPSigningEntity from
pkg/bootstrap directly, so the pre-flight cannot drift from the
bootstrap commit path. The reuse path's pre-flight runs inside each
subcommand's RunE (where the subcommand-local SSH transport password
is in scope) and lands with the wiring commits that follow.

A small effectiveSshSigningPassword helper resolves the
--ssh-signing-passphrase alias purely (returning the resolved value
or a mutual-exclusion error) instead of mutating the
package-scoped bootstrapArgs singleton inside bootstrapValidate.

Mutual exclusion is enforced between the GPG and SSH groups, and
between --ssh-signing-key-file and --ssh-signing-reuse-private-key.
--ssh-signing-reuse-private-key requires --private-key-file;
--ssh-signing-password requires --ssh-signing-key-file. The
--ssh-signing-passphrase alias is hidden in --help.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:30 +02:00
Hidde Beydals 1be91ee7dd Cover pkg/bootstrap SSH signing roundtrip
Adds two layers of coverage for the SSH commit-signing path that the
previous commit wires through PlainGitBootstrapper.

TestPlainGitBootstrapper_resolveSigner exercises every branch of the
new dispatcher: nil configuration, GPG-only, SSH-only, encrypted-SSH-
without-passphrase failure, and the documented GPG-wins-when-both-
set precedence.

TestPlainGitBootstrapper_sshSignerProducesVerifiableCommit drives an
end-to-end roundtrip: resolveSigner returns an SSH signer, the signer
plugs into repository.WithSigner, gogit.Client.Commit produces a
commit object, and signature.VerifySSHSignature cryptographically
verifies the gpgsig header against the matching authorized_key.
Catches regressions in the SSH-signing wiring that the dispatcher
unit tests would miss.

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:30 +02:00
Hidde Beydals 88c5a7f68d Migrate bootstrap signing to generic Signer
Bumps fluxcd/pkg/git to a pseudo-version exposing the generic
signature.Signer interface and the NewOpenPGPSigner / NewSSHSigner
constructors, and migrates pkg/bootstrap's two WithSigner call sites
accordingly.

Adds a parallel WithSSHCommitSigning option alongside the existing
WithGitCommitSigning so callers can sign commits with an SSH private
key. PlainGitBootstrapper now dispatches through a new resolveSigner
helper that returns either an OpenPGP or SSH signer; the
repository.WithSigner option is appended conditionally to avoid the
typed-nil interface hazard the new generic field introduces.

The bootstrap path's OpenPGP entity selector is renamed and exported
as SelectOpenPGPSigningEntity so the flux CLI's pre-flight (introduced
later in this branch) can call it directly instead of carrying a
duplicate.

Also bumps image-automation-controller/api to a pseudo-version that
exposes SigningKey.Type and the SigningKeyTypeGPG/SigningKeyTypeSSH
constants; the bump is bundled here so the rest of the branch builds
incrementally. Refs fluxcd/pkg#398[1].

[1]: https://github.com/fluxcd/pkg/issues/398

Signed-off-by: Hidde Beydals <hidde@hhh.computer>
2026-05-29 22:18:17 +02:00
Matheus Pimenta 8c41d5b56d Merge pull request #5908 from fluxcd/trigger-receiver
Introduce `flux trigger receiver`
2026-05-23 12:46:55 +01:00
Matheus Pimenta 4bfdb6d459 Introduce flux trigger receiver
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2026-05-23 01:44:07 +01:00
Stefan Prodan 9d9e56208c Merge pull request #5909 from immanuwell/validate-helm-source-url
Validate Helm source URL schemes
2026-05-22 12:26:13 +03:00
Immanuel Tikhonov 5425087730 Validate Helm source URL schemes
Reject HelmRepository source URLs with schemes unsupported by the
source-controller API before generating or applying the object.

Signed-off-by: Immanuel Tikhonov <pchpr.00@list.ru>
Assisted-by: codex/gpt-5
2026-05-22 08:56:53 +04:00
31 changed files with 1571 additions and 31 deletions
+90
View File
@@ -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 {
+26
View File
@@ -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 {
+28
View File
@@ -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 {
+19
View File
@@ -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 {
+19
View File
@@ -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 {
+26
View File
@@ -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 {
+155
View File
@@ -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)
}
})
}
}
+25
View File
@@ -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))
}
+62
View File
@@ -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)
})
}
}
+9 -6
View File
@@ -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
}
+6
View File
@@ -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",
+8
View File
@@ -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
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCAAAAIjjSDmx40g5
sQAAAAtzc2gtZWQyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCA
AAAEAGpzSFuLkCNDD49+tysxSFFwdOsRnDj67vDT9bfwoSDhYOV20IV1IxchXW8vBe9HCT
h4SZKgMKnE2Rxs2mHd0IAAAABHRlc3QB
-----END OPENSSH PRIVATE KEY-----
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
not a real ssh key
+19
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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)
}
+368
View File
@@ -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 := &notificationv1.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
}
}
+353
View File
@@ -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 := (&notificationv1.Receiver{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
}).GetWebhookPath(token)
expectedOIDCPath := (&notificationv1.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)
}
})
}
+3 -2
View File
@@ -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
@@ -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
+6 -4
View File
@@ -180,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=
@@ -204,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=
@@ -352,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
+55 -19
View File
@@ -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")
}
+145
View File
@@ -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())
}
+28
View File
@@ -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