Merge pull request #5872 from Iam-Karan-Suresh/digest-pinning
Add digest pinning support to `flux plugin install`
This commit is contained in:
@@ -98,6 +98,12 @@ func parseNameVersion(s string) (string, string) {
|
|||||||
return s, ""
|
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.
|
// newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG.
|
||||||
func newCatalogClient() *plugin.CatalogClient {
|
func newCatalogClient() *plugin.CatalogClient {
|
||||||
client := plugin.NewCatalogClient()
|
client := plugin.NewCatalogClient()
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||||
|
plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pluginInstallCmd = &cobra.Command{
|
var pluginInstallCmd = &cobra.Command{
|
||||||
Use: "install <name>[@<version>]",
|
Use: "install <name>[@<version>|@<digest>]",
|
||||||
Short: "Install a plugin from the catalog",
|
Short: "Install a plugin from the catalog",
|
||||||
Long: `The plugin install command downloads and installs a plugin from the Flux plugin 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
|
flux plugin install operator
|
||||||
|
|
||||||
# Install a specific version
|
# 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: pluginInstallCmdRun,
|
RunE: pluginInstallCmdRun,
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,7 @@ func init() {
|
|||||||
|
|
||||||
func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
|
func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
nameVersion := args[0]
|
nameVersion := args[0]
|
||||||
name, version := parseNameVersion(nameVersion)
|
name, ref := parseNameVersion(nameVersion)
|
||||||
|
|
||||||
catalogClient := newCatalogClient()
|
catalogClient := newCatalogClient()
|
||||||
manifest, err := catalogClient.FetchManifest(name)
|
manifest, err := catalogClient.FetchManifest(name)
|
||||||
@@ -54,15 +58,27 @@ func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
pv, err := plugin.ResolveVersion(manifest, version)
|
var pv *plugintypes.Version
|
||||||
|
var plat *plugintypes.Platform
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
plat, err := plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH)
|
plat, err = plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH)
|
return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pluginDir := pluginHandler.EnsurePluginDir()
|
pluginDir := pluginHandler.EnsurePluginDir()
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ func TestParseNameVersion(t *testing.T) {
|
|||||||
{"operator@0.45.0", "operator", "0.45.0"},
|
{"operator@0.45.0", "operator", "0.45.0"},
|
||||||
{"my-tool@1.0.0", "my-tool", "1.0.0"},
|
{"my-tool@1.0.0", "my-tool", "1.0.0"},
|
||||||
{"plugin@", "plugin", ""},
|
{"plugin@", "plugin", ""},
|
||||||
|
{"operator@sha256:abc123", "operator", "sha256:abc123"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
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) {
|
func TestPluginDiscoverSkipsBuiltins(t *testing.T) {
|
||||||
origHandler := pluginHandler
|
origHandler := pluginHandler
|
||||||
defer func() { pluginHandler = origHandler }()
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|||||||
@@ -165,3 +165,33 @@ func ResolvePlatform(pv *plugintypes.Version, goos, goarch string) (*plugintypes
|
|||||||
|
|
||||||
return nil, fmt.Errorf("no binary for %s/%s", goos, goarch)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user