From e6ac1390d00e690362fe19f63b319ccc5c2ffaa9 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:10:51 +0200 Subject: [PATCH 01/12] Migrate bootstrap signing to generic Signer Bumps fluxcd/pkg/git to v0.52.0, which exposes the generic signature.Signer interface and the NewOpenPGPSigner / NewSSHSigner constructors, and migrates pkg/bootstrap's two WithSigner call sites accordingly. Refs fluxcd/pkg#398[1]. 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. [1]: https://github.com/fluxcd/pkg/issues/398 Signed-off-by: Hidde Beydals --- go.mod | 4 +- go.sum | 8 +-- pkg/bootstrap/bootstrap_plain_git.go | 74 +++++++++++++++++++++------- pkg/bootstrap/options.go | 28 +++++++++++ 4 files changed, 89 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index 678b4d7f..0f8b95b0 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/fluxcd/pkg/auth v0.51.0 github.com/fluxcd/pkg/chartutil v1.26.0 github.com/fluxcd/pkg/envsubst v1.7.0 - github.com/fluxcd/pkg/git v0.50.0 + github.com/fluxcd/pkg/git v0.52.0 github.com/fluxcd/pkg/kustomize v1.35.0 github.com/fluxcd/pkg/oci v0.66.0 github.com/fluxcd/pkg/runtime v0.108.0 @@ -31,7 +31,7 @@ require ( github.com/fluxcd/pkg/ssa v0.75.0 github.com/fluxcd/pkg/ssh v0.25.0 github.com/fluxcd/pkg/tar v1.2.0 - github.com/fluxcd/pkg/version v0.15.0 + github.com/fluxcd/pkg/version v0.16.0 github.com/fluxcd/source-controller/api v1.9.0 github.com/fluxcd/source-watcher/api/v2 v2.1.1 github.com/go-git/go-git/v5 v5.19.1 diff --git a/go.sum b/go.sum index 408c07cc..cd778386 100644 --- a/go.sum +++ b/go.sum @@ -202,8 +202,8 @@ github.com/fluxcd/pkg/chartutil v1.26.0 h1:bgXwDKl2uGITSDxOXe4N5HTXAL0ilk4YVbPLU github.com/fluxcd/pkg/chartutil v1.26.0/go.mod h1:sWDcF//xpIwZ/MQupwTmuM/SgGCkfsTd9BVOQJ3cTjM= 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.50.0 h1:gbGmSTjQ6CxqFmT9ZkLfYh8kG7CHqc7gHoPqcygixK8= -github.com/fluxcd/pkg/git v0.50.0/go.mod h1:OgaHoS0iR0GuLl+f778X7NrGy1pDH7xcpF/nsCRgJ9g= +github.com/fluxcd/pkg/git v0.52.0 h1:dgsliHdaLADUcDO4pI0pc11N4dZ21NfDdhNcgRNuAkM= +github.com/fluxcd/pkg/git v0.52.0/go.mod h1:mOvFDxoiuz+Mm4Ux1wKeTTckvBgZFvbTK8lNxmVHzKs= 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.35.0 h1:Hl3Y6WPPo1btDyUTrT+CS3b4GbAqCuE+bZebpIG2cxg= @@ -220,8 +220,8 @@ github.com/fluxcd/pkg/ssh v0.25.0 h1:4Y9WmuNqyKvH759UznU5DGHRcOuoJ/dQM6sbsaDZYYM github.com/fluxcd/pkg/ssh v0.25.0/go.mod h1:Fli2Ogu4uaIVGbCy+r0vvZlMO0RfuInyNY1q2FVIx0o= github.com/fluxcd/pkg/tar v1.2.0 h1:T6WFB5M0YRHktlrgdKNskqpdp76TVDdWTOeuWz33CFs= github.com/fluxcd/pkg/tar v1.2.0/go.mod h1:Wlalp5vIVe+BbckkKkqExKcoHAeeWJPAzwK7ONeFcS0= -github.com/fluxcd/pkg/version v0.15.0 h1:E2Ju4i0vj8ZXLHKz/F4a8JTmDh7Jcg8okB0hK5rEoTM= -github.com/fluxcd/pkg/version v0.15.0/go.mod h1:LEHnvLMgbTk4kelF+JHHzaG77kY9uTWodMtadPRMEW8= +github.com/fluxcd/pkg/version v0.16.0 h1:VR9+143LAwbyUSAaMhiJHbfsiU+fTjA9L/3dr1ucfrI= +github.com/fluxcd/pkg/version v0.16.0/go.mod h1:2M/l90CmbDaD21JTh77hjwaUbd/YM96+Fo8x4fMdxLI= github.com/fluxcd/source-controller/api v1.9.0 h1:kO5elmtCk227YKAfpTY+8Q6rgozOYmeyRRskGZBEbbo= github.com/fluxcd/source-controller/api v1.9.0/go.mod h1:NsJJuEPUYYUJ0u8wMnAqS2T+bXyxOmGrnwdzvZRTiZw= github.com/fluxcd/source-watcher/api/v2 v2.1.1 h1:1LfT50ty+78MKKbschAZl28QbVqIyjaNq17KmW5wPJI= diff --git a/pkg/bootstrap/bootstrap_plain_git.go b/pkg/bootstrap/bootstrap_plain_git.go index f39b08b7..6ef3c32e 100644 --- a/pkg/bootstrap/bootstrap_plain_git.go +++ b/pkg/bootstrap/bootstrap_plain_git.go @@ -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") } diff --git a/pkg/bootstrap/options.go b/pkg/bootstrap/options.go index 27830f4a..c3cf9226 100644 --- a/pkg/bootstrap/options.go +++ b/pkg/bootstrap/options.go @@ -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 From 4810828b5378ec30ec7e0a9c164b3ce0ba0896f6 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:00 +0200 Subject: [PATCH 02/12] 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 --- pkg/bootstrap/bootstrap_test.go | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/pkg/bootstrap/bootstrap_test.go b/pkg/bootstrap/bootstrap_test.go index 8f8e3db8..07256602 100644 --- a/pkg/bootstrap/bootstrap_test.go +++ b/pkg/bootstrap/bootstrap_test.go @@ -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()) +} From a84934311adb8438da5cbea4f02ce19977c9b0c6 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:13 +0200 Subject: [PATCH 03/12] 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 --- cmd/flux/bootstrap.go | 90 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/cmd/flux/bootstrap.go b/cmd/flux/bootstrap.go index 49cd88d8..67a81ad2 100644 --- a/cmd/flux/bootstrap.go +++ b/cmd/flux/bootstrap.go @@ -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" ) @@ -80,6 +83,11 @@ type bootstrapFlags struct { gpgPassphrase string gpgKeyID string + sshSigningKeyFile string + sshSigningPassword string + sshSigningPassphrase string + sshSigningReusePrivateKey bool + force bool commitMessageAppendix string @@ -142,6 +150,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") @@ -198,6 +212,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 } @@ -217,6 +256,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 { From b767c6887613ef0df8f6c0dc42db61dc5c619520 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:17 +0200 Subject: [PATCH 04/12] 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 --- cmd/flux/bootstrap_git.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmd/flux/bootstrap_git.go b/cmd/flux/bootstrap_git.go index 52b67723..4a6958ef 100644 --- a/cmd/flux/bootstrap_git.go +++ b/cmd/flux/bootstrap_git.go @@ -33,6 +33,7 @@ import ( authutils "github.com/fluxcd/pkg/auth/utils" "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" @@ -334,6 +335,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 { From de76bb4725211772d7b012e10046949eb3d11f92 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:26 +0200 Subject: [PATCH 05/12] 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 --- cmd/flux/bootstrap_bitbucket_server.go | 26 ++++++++++++++++++++++++++ cmd/flux/bootstrap_gitea.go | 19 +++++++++++++++++++ cmd/flux/bootstrap_github.go | 19 +++++++++++++++++++ cmd/flux/bootstrap_gitlab.go | 26 ++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/cmd/flux/bootstrap_bitbucket_server.go b/cmd/flux/bootstrap_bitbucket_server.go index 7cbfce73..105da32b 100644 --- a/cmd/flux/bootstrap_bitbucket_server.go +++ b/cmd/flux/bootstrap_bitbucket_server.go @@ -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" @@ -288,6 +289,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 { diff --git a/cmd/flux/bootstrap_gitea.go b/cmd/flux/bootstrap_gitea.go index 81a2d544..ad30e8bb 100644 --- a/cmd/flux/bootstrap_gitea.go +++ b/cmd/flux/bootstrap_gitea.go @@ -253,6 +253,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)) } @@ -266,6 +272,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 { diff --git a/cmd/flux/bootstrap_github.go b/cmd/flux/bootstrap_github.go index a0c5974e..6ae1cb9a 100644 --- a/cmd/flux/bootstrap_github.go +++ b/cmd/flux/bootstrap_github.go @@ -260,6 +260,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)) } @@ -273,6 +279,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 { diff --git a/cmd/flux/bootstrap_gitlab.go b/cmd/flux/bootstrap_gitlab.go index c1a16902..58e3e127 100644 --- a/cmd/flux/bootstrap_gitlab.go +++ b/cmd/flux/bootstrap_gitlab.go @@ -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" @@ -322,6 +323,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 { From 43574215a6369c871d2a2f4bbfe2880d1928f949 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:30 +0200 Subject: [PATCH 06/12] 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 --- cmd/flux/bootstrap_test.go | 155 ++++++++++++++++++ .../bootstrap/ed25519-encrypted.private | 8 + cmd/flux/testdata/bootstrap/ed25519.private | 7 + cmd/flux/testdata/bootstrap/gpg-encrypted.pgp | Bin 0 -> 313 bytes cmd/flux/testdata/bootstrap/gpg.pgp | Bin 0 -> 261 bytes cmd/flux/testdata/bootstrap/malformed.private | 1 + 6 files changed, 171 insertions(+) create mode 100644 cmd/flux/bootstrap_test.go create mode 100644 cmd/flux/testdata/bootstrap/ed25519-encrypted.private create mode 100644 cmd/flux/testdata/bootstrap/ed25519.private create mode 100644 cmd/flux/testdata/bootstrap/gpg-encrypted.pgp create mode 100644 cmd/flux/testdata/bootstrap/gpg.pgp create mode 100644 cmd/flux/testdata/bootstrap/malformed.private diff --git a/cmd/flux/bootstrap_test.go b/cmd/flux/bootstrap_test.go new file mode 100644 index 00000000..8686d3e8 --- /dev/null +++ b/cmd/flux/bootstrap_test.go @@ -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) + } + }) + } +} diff --git a/cmd/flux/testdata/bootstrap/ed25519-encrypted.private b/cmd/flux/testdata/bootstrap/ed25519-encrypted.private new file mode 100644 index 00000000..6a540db3 --- /dev/null +++ b/cmd/flux/testdata/bootstrap/ed25519-encrypted.private @@ -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----- diff --git a/cmd/flux/testdata/bootstrap/ed25519.private b/cmd/flux/testdata/bootstrap/ed25519.private new file mode 100644 index 00000000..2cc37a05 --- /dev/null +++ b/cmd/flux/testdata/bootstrap/ed25519.private @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCAAAAIjjSDmx40g5 +sQAAAAtzc2gtZWQyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCA +AAAEAGpzSFuLkCNDD49+tysxSFFwdOsRnDj67vDT9bfwoSDhYOV20IV1IxchXW8vBe9HCT +h4SZKgMKnE2Rxs2mHd0IAAAABHRlc3QB +-----END OPENSSH PRIVATE KEY----- diff --git a/cmd/flux/testdata/bootstrap/gpg-encrypted.pgp b/cmd/flux/testdata/bootstrap/gpg-encrypted.pgp new file mode 100644 index 0000000000000000000000000000000000000000..aff2f539fe5a10b75af1df672054ae6b8a1ff36d GIT binary patch literal 313 zcmbQz#*!sDd5IXOHX9=g<1Kf7Mn-mrkJ{<^QyY7>N~YeN+Kmi-L5 G<^=$RCw-~_ literal 0 HcmV?d00001 diff --git a/cmd/flux/testdata/bootstrap/gpg.pgp b/cmd/flux/testdata/bootstrap/gpg.pgp new file mode 100644 index 0000000000000000000000000000000000000000..e435898c06c3027d499c650255c8d22c63c9768b GIT binary patch literal 261 zcmbOd!IC98xlfE!n~jl$@s>M3BO|-Rvbn3j%DVi`YyaWDIAGTy8|GUHpN_3~^Q3U; z*MxAs+C&D1|8711%`e&Tg|DcX;%{>EVY>zAyW74G4O{H~_-$CuX?9NF^cL}w)Z!8a z8xZM`T9KGrkdvyHoS$pgF@Z%`jEliSOp!&a^`L!nQeOS5jeiwBf1Uear)UYmr>B^pqGmTYxMPt Hh1=Kx1KnrV literal 0 HcmV?d00001 diff --git a/cmd/flux/testdata/bootstrap/malformed.private b/cmd/flux/testdata/bootstrap/malformed.private new file mode 100644 index 00000000..850bf46f --- /dev/null +++ b/cmd/flux/testdata/bootstrap/malformed.private @@ -0,0 +1 @@ +not a real ssh key From 61316ccca76aa6fac3ee202889e58f36502db717 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:38 +0200 Subject: [PATCH 07/12] 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 leaves spec.git.commit.signingKey.type empty so the controller's documented default ('gpg' when type is unset[1]) applies server-side rather than baking the choice into the rendered YAML. 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. [1]: https://github.com/fluxcd/image-automation-controller/pull/1035 Signed-off-by: Hidde Beydals --- cmd/flux/create_image_update.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmd/flux/create_image_update.go b/cmd/flux/create_image_update.go index f0ee11cf..f1d21ded 100644 --- a/cmd/flux/create_image_update.go +++ b/cmd/flux/create_image_update.go @@ -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,13 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error { } } + if imageUpdateArgs.signingKeySecret != "" { + update.Spec.GitSpec.Commit.SigningKey = &autov1.SigningKey{ + SecretRef: meta.LocalObjectReference{Name: imageUpdateArgs.signingKeySecret}, + Type: autov1.SigningKeyType(imageUpdateArgs.signingKeyType), + } + } + if createArgs.export { return printExport(exportImageUpdate(&update)) } From 4e8c13ba598fb76b32cf20b1b2a07d4bbd272efd Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:41 +0200 Subject: [PATCH 08/12] 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 is omitted when only the secret is set, deferring to the controller's gpg default), 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 --- cmd/flux/create_image_update_test.go | 62 +++++++++++++++++++ .../create_image_update/no-signing.yaml | 19 ++++++ .../signing-default-gpg.yaml | 22 +++++++ .../create_image_update/signing-ssh.yaml | 23 +++++++ 4 files changed, 126 insertions(+) create mode 100644 cmd/flux/create_image_update_test.go create mode 100644 cmd/flux/testdata/create_image_update/no-signing.yaml create mode 100644 cmd/flux/testdata/create_image_update/signing-default-gpg.yaml create mode 100644 cmd/flux/testdata/create_image_update/signing-ssh.yaml diff --git a/cmd/flux/create_image_update_test.go b/cmd/flux/create_image_update_test.go new file mode 100644 index 00000000..f901c15a --- /dev/null +++ b/cmd/flux/create_image_update_test.go @@ -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) + }) + } +} diff --git a/cmd/flux/testdata/create_image_update/no-signing.yaml b/cmd/flux/testdata/create_image_update/no-signing.yaml new file mode 100644 index 00000000..91bd27ed --- /dev/null +++ b/cmd/flux/testdata/create_image_update/no-signing.yaml @@ -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 diff --git a/cmd/flux/testdata/create_image_update/signing-default-gpg.yaml b/cmd/flux/testdata/create_image_update/signing-default-gpg.yaml new file mode 100644 index 00000000..4d958b76 --- /dev/null +++ b/cmd/flux/testdata/create_image_update/signing-default-gpg.yaml @@ -0,0 +1,22 @@ +--- +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 + interval: 1m0s + sourceRef: + kind: GitRepository + name: flux-system diff --git a/cmd/flux/testdata/create_image_update/signing-ssh.yaml b/cmd/flux/testdata/create_image_update/signing-ssh.yaml new file mode 100644 index 00000000..84e81c8c --- /dev/null +++ b/cmd/flux/testdata/create_image_update/signing-ssh.yaml @@ -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 From 923a8ae394941ee1b8ac74cd96afceff2d086b29 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 29 May 2026 22:11:49 +0200 Subject: [PATCH 09/12] 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. Signed-off-by: Hidde Beydals --- cmd/flux/testdata/export/image-update.yaml | 4 ++++ cmd/flux/testdata/export/objects.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/cmd/flux/testdata/export/image-update.yaml b/cmd/flux/testdata/export/image-update.yaml index 0d534482..0a3462c3 100644 --- a/cmd/flux/testdata/export/image-update.yaml +++ b/cmd/flux/testdata/export/image-update.yaml @@ -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 diff --git a/cmd/flux/testdata/export/objects.yaml b/cmd/flux/testdata/export/objects.yaml index 057470e5..7a905634 100644 --- a/cmd/flux/testdata/export/objects.yaml +++ b/cmd/flux/testdata/export/objects.yaml @@ -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 From 4f45409697dbf7efc529fb97e9d3642c4deaf205 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 19 Jun 2026 09:58:06 +0200 Subject: [PATCH 10/12] Seed defaultComponents in bootstrap signing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resetCmdArgs` in `main_test.go` rebuilds `bootstrapArgs` from `NewBootstrapFlags`, which deliberately omits the cobra-populated `defaultComponents`. In the `e2e` build, `TestMain` runs `flux install …` before any test executes; that call triggers the reset and leaves `bootstrapArgs.defaultComponents` empty for the lifetime of the process. `bootstrapValidate` then trips on its `requiredComponents` pre-check and fails with "component source-controller is required" before it ever reaches the SSH/GPG signing flag validation that this test cares about. Save, seed, and restore `defaultComponents` per subtest so the required-component check passes regardless of whether the test runs under the plain or the `e2e` build tag. Assisted-by: claude/opus-4.7 Signed-off-by: Hidde Beydals --- cmd/flux/bootstrap_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/flux/bootstrap_test.go b/cmd/flux/bootstrap_test.go index 8686d3e8..95b4e172 100644 --- a/cmd/flux/bootstrap_test.go +++ b/cmd/flux/bootstrap_test.go @@ -112,6 +112,7 @@ func TestBootstrapValidate_signingFlags(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + savedDefaultComponents := bootstrapArgs.defaultComponents savedGpgRing := bootstrapArgs.gpgKeyRingPath savedGpgPass := bootstrapArgs.gpgPassphrase savedSshKey := bootstrapArgs.sshSigningKeyFile @@ -120,6 +121,7 @@ func TestBootstrapValidate_signingFlags(t *testing.T) { savedPrivKey := bootstrapArgs.privateKeyFile savedReuse := bootstrapArgs.sshSigningReusePrivateKey defer func() { + bootstrapArgs.defaultComponents = savedDefaultComponents bootstrapArgs.gpgKeyRingPath = savedGpgRing bootstrapArgs.gpgPassphrase = savedGpgPass bootstrapArgs.sshSigningKeyFile = savedSshKey @@ -129,6 +131,10 @@ func TestBootstrapValidate_signingFlags(t *testing.T) { bootstrapArgs.sshSigningReusePrivateKey = savedReuse }() + // The e2e TestMain calls resetCmdArgs which clears the + // cobra-populated default components, so seed them here to + // satisfy the requiredComponents pre-check in bootstrapValidate. + bootstrapArgs.defaultComponents = bootstrapArgs.requiredComponents bootstrapArgs.gpgKeyRingPath = tt.gpgRing bootstrapArgs.gpgPassphrase = tt.gpgPass bootstrapArgs.sshSigningKeyFile = tt.sshKey From 2ca34684236b13d25d7705d2915db7cbc931c0ce Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 19 Jun 2026 15:00:55 +0200 Subject: [PATCH 11/12] Return error for public-only GPG signing keyring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SelectOpenPGPSigningEntity` selects `keyRing[0]` when no key id is supplied and then calls `entity.PrivateKey.Decrypt` directly. For a keyring that contains only public keys — e.g. an armor-exported public key file — `PrivateKey` is `nil` and the call panics with a nil pointer dereference rather than surfacing an actionable error. The keyed branch already guards against this; the default branch did not. Guard the default branch with the same nil check and return an error pointing at `gpg --export-secret-keys` or `--gpg-key-id` so the user knows how to recover. Cover the public-only-keyring case in `TestSelectOpenPGPSigningEntity` so a future regression cannot re-introduce the panic. Assisted-by: claude/opus-4.7 Signed-off-by: Hidde Beydals --- pkg/bootstrap/bootstrap_plain_git.go | 4 ++++ pkg/bootstrap/bootstrap_test.go | 29 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/pkg/bootstrap/bootstrap_plain_git.go b/pkg/bootstrap/bootstrap_plain_git.go index 6ef3c32e..af1f8af3 100644 --- a/pkg/bootstrap/bootstrap_plain_git.go +++ b/pkg/bootstrap/bootstrap_plain_git.go @@ -574,6 +574,10 @@ func SelectOpenPGPSigningEntity(keyRing openpgp.EntityList, passphrase, keyID st } } else { entity = keyRing[0] + if entity.PrivateKey == nil { + return nil, fmt.Errorf("keyring does not contain a private key; " + + "export the secret key with 'gpg --export-secret-keys' or specify --gpg-key-id") + } } err := entity.PrivateKey.Decrypt([]byte(passphrase)) diff --git a/pkg/bootstrap/bootstrap_test.go b/pkg/bootstrap/bootstrap_test.go index 07256602..0bd9deab 100644 --- a/pkg/bootstrap/bootstrap_test.go +++ b/pkg/bootstrap/bootstrap_test.go @@ -542,6 +542,35 @@ func TestPlainGitBootstrapper_resolveSigner(t *testing.T) { }) } +func TestSelectOpenPGPSigningEntity(t *testing.T) { + t.Run("empty key ring errors", func(t *testing.T) { + g := NewWithT(t) + _, err := SelectOpenPGPSigningEntity(openpgp.EntityList{}, "", "") + g.Expect(err).To(MatchError(ContainSubstring("empty GPG key ring"))) + }) + + t.Run("public-only key ring without key id errors instead of panicking", func(t *testing.T) { + g := NewWithT(t) + entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil) + g.Expect(err).ToNot(HaveOccurred()) + entity.PrivateKey = nil + + _, err = SelectOpenPGPSigningEntity(openpgp.EntityList{entity}, "", "") + g.Expect(err).To(MatchError(ContainSubstring("keyring does not contain a private key"))) + }) + + t.Run("public-only key ring with matching key id errors with key id context", func(t *testing.T) { + g := NewWithT(t) + entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil) + g.Expect(err).ToNot(HaveOccurred()) + keyID := entity.PrimaryKey.KeyIdString() + entity.PrivateKey = nil + + _, err = SelectOpenPGPSigningEntity(openpgp.EntityList{entity}, "", keyID) + g.Expect(err).To(MatchError(ContainSubstring("keyring does not contain private key for key id"))) + }) +} + // 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 From 96fda4cd565a061636e2b31fadafa10cbdba5039 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 19 Jun 2026 15:02:43 +0200 Subject: [PATCH 12/12] Reject ssh-signing-reuse early in github and gitea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bootstrap github` and `bootstrap gitea` generate the SSH transport key in-process, so they have no operator-supplied key to reuse for commit signing. Both subcommands already reject `--ssh-signing-reuse-private-key` with a provider-specific "not supported" error, but the check sat after `bootstrapValidate`, which fails first with the generic "--ssh-signing-reuse-private-key requires --private-key-file" message. A user invoking e.g. `flux bootstrap github --ssh-signing-reuse-private-key` is told to set a flag that the subcommand cannot honour anyway, masking the real problem. Move the unsupported-flag rejection to the top of each `RunE` — before the interactive PAT prompt and before `bootstrapValidate` — so the provider-specific error wins. The deeper, now-redundant check is dropped. `TestBootstrapProviderRejectsReuseBeforeValidate` exercises both subcommands with the reuse flag set and no `--private-key-file` to lock in the precedence. Assisted-by: claude/opus-4.7 Signed-off-by: Hidde Beydals --- cmd/flux/bootstrap_gitea.go | 10 ++++---- cmd/flux/bootstrap_github.go | 10 ++++---- cmd/flux/bootstrap_test.go | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/cmd/flux/bootstrap_gitea.go b/cmd/flux/bootstrap_gitea.go index ad30e8bb..767f61a2 100644 --- a/cmd/flux/bootstrap_gitea.go +++ b/cmd/flux/bootstrap_gitea.go @@ -107,6 +107,11 @@ func init() { } func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error { + 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") + } + gtToken := os.Getenv(gtTokenEnvVar) if gtToken == "" { var err error @@ -254,11 +259,6 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error { 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)) } diff --git a/cmd/flux/bootstrap_github.go b/cmd/flux/bootstrap_github.go index 6ae1cb9a..2867cd40 100644 --- a/cmd/flux/bootstrap_github.go +++ b/cmd/flux/bootstrap_github.go @@ -107,6 +107,11 @@ func init() { } func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error { + 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") + } + ghToken := os.Getenv(ghTokenEnvVar) if ghToken == "" { var err error @@ -261,11 +266,6 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error { 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)) } diff --git a/cmd/flux/bootstrap_test.go b/cmd/flux/bootstrap_test.go index 95b4e172..4ec9b84c 100644 --- a/cmd/flux/bootstrap_test.go +++ b/cmd/flux/bootstrap_test.go @@ -159,3 +159,50 @@ func TestBootstrapValidate_signingFlags(t *testing.T) { }) } } + +// Providers that generate the SSH transport key in-process (github, gitea) +// must reject --ssh-signing-reuse-private-key with their own, provider- +// specific error before bootstrapValidate runs — otherwise the generic +// "--ssh-signing-reuse-private-key requires --private-key-file" error +// shadows the fact that the flag is fundamentally unsupported there. +func TestBootstrapProviderRejectsReuseBeforeValidate(t *testing.T) { + tests := []struct { + name string + runE func() error + wantErr string + }{ + { + name: "github rejects reuse with provider-specific error", + runE: func() error { return bootstrapGitHubCmdRun(nil, nil) }, + wantErr: "not supported by 'bootstrap github'", + }, + { + name: "gitea rejects reuse with provider-specific error", + runE: func() error { return bootstrapGiteaCmdRun(nil, nil) }, + wantErr: "not supported by 'bootstrap gitea'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedReuse := bootstrapArgs.sshSigningReusePrivateKey + savedPrivKey := bootstrapArgs.privateKeyFile + defer func() { + bootstrapArgs.sshSigningReusePrivateKey = savedReuse + bootstrapArgs.privateKeyFile = savedPrivKey + }() + + // Reuse flag set, no --private-key-file: bootstrapValidate + // would otherwise return "requires --private-key-file". + bootstrapArgs.sshSigningReusePrivateKey = true + bootstrapArgs.privateKeyFile = "" + + err := tt.runE() + 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) + } + }) + } +}