diff --git a/go.mod b/go.mod index 7d4e9411..6ed1ed24 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0e7e4c70..2b3b6c3f 100644 --- a/go.sum +++ b/go.sum @@ -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= 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