From df3878d36a0edfaa4c1577f74a10e6f79755219e Mon Sep 17 00:00:00 2001 From: iam-karan-suresh Date: Sun, 26 Apr 2026 12:20:14 +0530 Subject: [PATCH] feat: adding support digest pinning for flux plugin install Signed-off-by: iam-karan-suresh --- cmd/flux/plugin.go | 6 +++ cmd/flux/plugin_install.go | 36 +++++++++++++----- cmd/flux/plugin_test.go | 22 +++++++++++ internal/plugin/catalog.go | 30 +++++++++++++++ internal/plugin/catalog_test.go | 67 +++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 10 deletions(-) diff --git a/cmd/flux/plugin.go b/cmd/flux/plugin.go index efeb3df3..cf977b9e 100644 --- a/cmd/flux/plugin.go +++ b/cmd/flux/plugin.go @@ -98,6 +98,12 @@ func parseNameVersion(s string) (string, string) { return s, "" } +// isDigestRef reports whether ref is a content-addressable digest +// (e.g. "sha256:06e0a38..."). +func isDigestRef(ref string) bool { + return strings.HasPrefix(ref, "sha256:") +} + // newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG. func newCatalogClient() *plugin.CatalogClient { client := plugin.NewCatalogClient() diff --git a/cmd/flux/plugin_install.go b/cmd/flux/plugin_install.go index 71b9b643..58341a80 100644 --- a/cmd/flux/plugin_install.go +++ b/cmd/flux/plugin_install.go @@ -23,10 +23,11 @@ import ( "github.com/spf13/cobra" "github.com/fluxcd/flux2/v2/internal/plugin" + plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" ) var pluginInstallCmd = &cobra.Command{ - Use: "install [@]", + Use: "install [@|@]", Short: "Install a plugin from the catalog", Long: `The plugin install command downloads and installs a plugin from the Flux plugin catalog. @@ -35,7 +36,10 @@ Examples: flux plugin install operator # Install a specific version - flux plugin install operator@0.45.0`, + flux plugin install operator@0.45.0 + + # Install pinned to a specific digest + flux plugin install operator@sha256:06e0a38db4fa6bc9f705a577c7e58dc020bfe2618e45488599e5ef7bb62e3a8a`, Args: cobra.ExactArgs(1), RunE: pluginInstallCmdRun, } @@ -46,7 +50,7 @@ func init() { func pluginInstallCmdRun(cmd *cobra.Command, args []string) error { nameVersion := args[0] - name, version := parseNameVersion(nameVersion) + name, ref := parseNameVersion(nameVersion) catalogClient := newCatalogClient() manifest, err := catalogClient.FetchManifest(name) @@ -54,14 +58,26 @@ func pluginInstallCmdRun(cmd *cobra.Command, args []string) error { return err } - pv, err := plugin.ResolveVersion(manifest, version) - if err != nil { - return err - } + var pv *plugintypes.Version + var plat *plugintypes.Platform - plat, err := plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH) - if err != nil { - return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH) + if isDigestRef(ref) { + dm, err := plugin.ResolveByDigest(manifest, ref, runtime.GOOS, runtime.GOARCH) + if err != nil { + return err + } + pv = dm.Version + plat = dm.Platform + } else { + pv, err = plugin.ResolveVersion(manifest, ref) + if err != nil { + return err + } + + plat, err = plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH) + if err != nil { + return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH) + } } pluginDir := pluginHandler.EnsurePluginDir() diff --git a/cmd/flux/plugin_test.go b/cmd/flux/plugin_test.go index 90f1b482..2bcedab5 100644 --- a/cmd/flux/plugin_test.go +++ b/cmd/flux/plugin_test.go @@ -211,6 +211,7 @@ func TestParseNameVersion(t *testing.T) { {"operator@0.45.0", "operator", "0.45.0"}, {"my-tool@1.0.0", "my-tool", "1.0.0"}, {"plugin@", "plugin", ""}, + {"operator@sha256:abc123", "operator", "sha256:abc123"}, } for _, tt := range tests { @@ -226,6 +227,27 @@ func TestParseNameVersion(t *testing.T) { } } +func TestIsDigestRef(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"sha256:06e0a38db4fa6bc9f705a577c7e58dc020bfe2618e45488599e5ef7bb62e3a8a", true}, + {"0.45.0", false}, + {"", false}, + {"sha256", false}, + {"SHA256:abc", false}, // case-sensitive + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := isDigestRef(tt.input); got != tt.want { + t.Errorf("isDigestRef(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + func TestPluginDiscoverSkipsBuiltins(t *testing.T) { origHandler := pluginHandler defer func() { pluginHandler = origHandler }() diff --git a/internal/plugin/catalog.go b/internal/plugin/catalog.go index 019bfada..0cb76dbd 100644 --- a/internal/plugin/catalog.go +++ b/internal/plugin/catalog.go @@ -165,3 +165,33 @@ func ResolvePlatform(pv *plugintypes.Version, goos, goarch string) (*plugintypes return nil, fmt.Errorf("no binary for %s/%s", goos, goarch) } + +// DigestMatch holds the version and platform resolved from a digest lookup. +type DigestMatch struct { + Version *plugintypes.Version + Platform *plugintypes.Platform +} + +// ResolveByDigest scans all versions and platforms for a checksum matching +// digest. The digest must be in "algorithm:hex" format (e.g. +// "sha256:06e0a38..."). Only platforms matching goos/goarch are considered. +// Returns the first match (versions are ordered newest-first in the manifest). +func ResolveByDigest(manifest *plugintypes.Manifest, digest, goos, goarch string) (*DigestMatch, error) { + if len(manifest.Versions) == 0 { + return nil, fmt.Errorf("plugin %q has no versions", manifest.Name) + } + + for i := range manifest.Versions { + for j := range manifest.Versions[i].Platforms { + p := &manifest.Versions[i].Platforms[j] + if p.OS == goos && p.Arch == goarch && p.Checksum == digest { + return &DigestMatch{ + Version: &manifest.Versions[i], + Platform: p, + }, nil + } + } + } + + return nil, fmt.Errorf("digest %q not found for plugin %q on %s/%s", digest, manifest.Name, goos, goarch) +} diff --git a/internal/plugin/catalog_test.go b/internal/plugin/catalog_test.go index e6134fc6..6ffdffa3 100644 --- a/internal/plugin/catalog_test.go +++ b/internal/plugin/catalog_test.go @@ -239,3 +239,70 @@ func TestResolvePlatform(t *testing.T) { } }) } + +func TestResolveByDigest(t *testing.T) { + manifest := &plugintypes.Manifest{ + Name: "operator", + Versions: []plugintypes.Version{ + { + Version: "0.46.0", + Platforms: []plugintypes.Platform{ + {OS: "linux", Arch: "amd64", URL: "https://example.com/v46_linux.tar.gz", Checksum: "sha256:aaaa"}, + {OS: "darwin", Arch: "arm64", URL: "https://example.com/v46_darwin.tar.gz", Checksum: "sha256:bbbb"}, + }, + }, + { + Version: "0.45.0", + Platforms: []plugintypes.Platform{ + {OS: "linux", Arch: "amd64", URL: "https://example.com/v45_linux.tar.gz", Checksum: "sha256:cccc"}, + {OS: "darwin", Arch: "arm64", URL: "https://example.com/v45_darwin.tar.gz", Checksum: "sha256:dddd"}, + }, + }, + }, + } + + t.Run("found in latest version", func(t *testing.T) { + dm, err := ResolveByDigest(manifest, "sha256:aaaa", "linux", "amd64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dm.Version.Version != "0.46.0" { + t.Errorf("expected version '0.46.0', got %q", dm.Version.Version) + } + if dm.Platform.Checksum != "sha256:aaaa" { + t.Errorf("expected checksum 'sha256:aaaa', got %q", dm.Platform.Checksum) + } + }) + + t.Run("found in older version", func(t *testing.T) { + dm, err := ResolveByDigest(manifest, "sha256:cccc", "linux", "amd64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dm.Version.Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", dm.Version.Version) + } + }) + + t.Run("wrong platform", func(t *testing.T) { + // sha256:bbbb exists for darwin/arm64, not linux/amd64. + _, err := ResolveByDigest(manifest, "sha256:bbbb", "linux", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolveByDigest(manifest, "sha256:nonexistent", "linux", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("no versions", func(t *testing.T) { + _, err := ResolveByDigest(&plugintypes.Manifest{Name: "empty"}, "sha256:abc", "linux", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +}