From 1db4e66099929885d72c75c3d6a0a0b311906d04 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Mon, 30 Mar 2026 11:51:21 +0300 Subject: [PATCH] Implement plugin catalog and discovery system Signed-off-by: Stefan Prodan --- internal/plugin/catalog.go | 213 +++++++++++++++++++ internal/plugin/catalog_test.go | 239 +++++++++++++++++++++ internal/plugin/completion.go | 75 +++++++ internal/plugin/completion_test.go | 80 +++++++ internal/plugin/discovery.go | 195 +++++++++++++++++ internal/plugin/discovery_test.go | 302 ++++++++++++++++++++++++++ internal/plugin/exec_unix.go | 30 +++ internal/plugin/exec_windows.go | 42 ++++ internal/plugin/install.go | 235 ++++++++++++++++++++ internal/plugin/install_test.go | 331 +++++++++++++++++++++++++++++ internal/plugin/update.go | 85 ++++++++ internal/plugin/update_test.go | 153 +++++++++++++ 12 files changed, 1980 insertions(+) create mode 100644 internal/plugin/catalog.go create mode 100644 internal/plugin/catalog_test.go create mode 100644 internal/plugin/completion.go create mode 100644 internal/plugin/completion_test.go create mode 100644 internal/plugin/discovery.go create mode 100644 internal/plugin/discovery_test.go create mode 100644 internal/plugin/exec_unix.go create mode 100644 internal/plugin/exec_windows.go create mode 100644 internal/plugin/install.go create mode 100644 internal/plugin/install_test.go create mode 100644 internal/plugin/update.go create mode 100644 internal/plugin/update_test.go diff --git a/internal/plugin/catalog.go b/internal/plugin/catalog.go new file mode 100644 index 00000000..84f673d2 --- /dev/null +++ b/internal/plugin/catalog.go @@ -0,0 +1,213 @@ +/* +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 plugin + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/hashicorp/go-retryablehttp" + "sigs.k8s.io/yaml" +) + +const ( + defaultCatalogBase = "https://raw.githubusercontent.com/fluxcd/plugins/main/" + envCatalogBase = "FLUXCD_PLUGIN_CATALOG" + + pluginAPIVersion = "cli.fluxcd.io/v1beta1" + pluginKind = "Plugin" + catalogKind = "PluginCatalog" +) + +// PluginManifest represents a single plugin's manifest from the catalog. +type PluginManifest struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + Source string `json:"source,omitempty"` + Bin string `json:"bin"` + Versions []PluginVersion `json:"versions"` +} + +// PluginVersion represents a version entry in a plugin manifest. +type PluginVersion struct { + Version string `json:"version"` + Platforms []PluginPlatform `json:"platforms"` +} + +// PluginPlatform represents a platform-specific binary entry. +type PluginPlatform struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` + Checksum string `json:"checksum"` +} + +// PluginCatalog represents the generated catalog.yaml file. +type PluginCatalog struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Plugins []CatalogEntry `json:"plugins"` +} + +// CatalogEntry is a single entry in the plugin catalog. +type CatalogEntry struct { + Name string `json:"name"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + Source string `json:"source,omitempty"` + License string `json:"license,omitempty"` +} + +// Receipt records what was installed for a plugin. +type Receipt struct { + Name string `json:"name"` + Version string `json:"version"` + InstalledAt string `json:"installedAt"` + Platform PluginPlatform `json:"platform"` +} + +// CatalogClient fetches plugin manifests and catalogs from a remote URL. +type CatalogClient struct { + BaseURL string + HTTPClient *http.Client + GetEnv func(key string) string +} + +// NewCatalogClient returns a CatalogClient with production defaults. +func NewCatalogClient() *CatalogClient { + return &CatalogClient{ + BaseURL: defaultCatalogBase, + HTTPClient: newHTTPClient(30 * time.Second), + GetEnv: func(key string) string { return "" }, + } +} + +// baseURL returns the effective catalog base URL. +func (c *CatalogClient) baseURL() string { + if env := c.GetEnv(envCatalogBase); env != "" { + return env + } + return c.BaseURL +} + +// FetchManifest fetches a single plugin manifest from the catalog. +func (c *CatalogClient) FetchManifest(name string) (*PluginManifest, error) { + url := c.baseURL() + "plugins/" + name + ".yaml" + body, err := c.fetch(url) + if err != nil { + return nil, fmt.Errorf("plugin %q not found in catalog", name) + } + + var manifest PluginManifest + if err := yaml.Unmarshal(body, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse plugin manifest for %q: %w", name, err) + } + + if manifest.APIVersion != pluginAPIVersion { + return nil, fmt.Errorf("plugin %q has unsupported apiVersion %q (expected %q)", name, manifest.APIVersion, pluginAPIVersion) + } + if manifest.Kind != pluginKind { + return nil, fmt.Errorf("plugin %q has unexpected kind %q (expected %q)", name, manifest.Kind, pluginKind) + } + + return &manifest, nil +} + +// FetchCatalog fetches the generated catalog.yaml. +func (c *CatalogClient) FetchCatalog() (*PluginCatalog, error) { + url := c.baseURL() + "catalog.yaml" + body, err := c.fetch(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch plugin catalog: %w", err) + } + + var catalog PluginCatalog + if err := yaml.Unmarshal(body, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse plugin catalog: %w", err) + } + + if catalog.APIVersion != pluginAPIVersion { + return nil, fmt.Errorf("plugin catalog has unsupported apiVersion %q (expected %q)", catalog.APIVersion, pluginAPIVersion) + } + if catalog.Kind != catalogKind { + return nil, fmt.Errorf("plugin catalog has unexpected kind %q (expected %q)", catalog.Kind, catalogKind) + } + + return &catalog, nil +} + +const maxResponseBytes = 10 << 20 // 10 MiB + +func (c *CatalogClient) fetch(url string) ([]byte, error) { + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + return io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) +} + +// newHTTPClient returns a retrying HTTP client with the given timeout. +func newHTTPClient(timeout time.Duration) *http.Client { + rc := retryablehttp.NewClient() + rc.RetryMax = 3 + rc.Logger = nil + c := rc.StandardClient() + c.Timeout = timeout + return c +} + +// ResolveVersion finds the requested version in the manifest. +// If version is empty, returns the first (latest) version. +func ResolveVersion(manifest *PluginManifest, version string) (*PluginVersion, error) { + if len(manifest.Versions) == 0 { + return nil, fmt.Errorf("plugin %q has no versions", manifest.Name) + } + + if version == "" { + return &manifest.Versions[0], nil + } + + for i := range manifest.Versions { + if manifest.Versions[i].Version == version { + return &manifest.Versions[i], nil + } + } + + return nil, fmt.Errorf("version %q not found for plugin %q", version, manifest.Name) +} + +// ResolvePlatform finds the platform entry matching the given OS and arch. +func ResolvePlatform(pv *PluginVersion, goos, goarch string) (*PluginPlatform, error) { + for i := range pv.Platforms { + if pv.Platforms[i].OS == goos && pv.Platforms[i].Arch == goarch { + return &pv.Platforms[i], nil + } + } + + return nil, fmt.Errorf("no binary for %s/%s", goos, goarch) +} diff --git a/internal/plugin/catalog_test.go b/internal/plugin/catalog_test.go new file mode 100644 index 00000000..37c4d34c --- /dev/null +++ b/internal/plugin/catalog_test.go @@ -0,0 +1,239 @@ +/* +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 plugin + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchManifest(t *testing.T) { + manifest := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: Plugin +name: operator +description: Flux Operator CLI +bin: flux-operator +versions: + - version: 0.45.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/flux-operator_0.45.0_linux_amd64.tar.gz + checksum: sha256:abc123 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/plugins/operator.yaml" { + w.Write([]byte(manifest)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + m, err := client.FetchManifest("operator") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Name != "operator" { + t.Errorf("expected name 'operator', got %q", m.Name) + } + if m.Bin != "flux-operator" { + t.Errorf("expected bin 'flux-operator', got %q", m.Bin) + } + if len(m.Versions) != 1 { + t.Fatalf("expected 1 version, got %d", len(m.Versions)) + } + if m.Versions[0].Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", m.Versions[0].Version) + } +} + +func TestFetchManifestNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + _, err := client.FetchManifest("nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestFetchCatalog(t *testing.T) { + catalog := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: PluginCatalog +plugins: + - name: operator + description: Flux Operator CLI + homepage: https://fluxoperator.dev/ + source: https://github.com/controlplaneio-fluxcd/flux-operator + license: AGPL-3.0 + - name: schema + description: CRD schemas + homepage: https://example.com/ + source: https://github.com/example/flux-schema + license: Apache-2.0 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/catalog.yaml" { + w.Write([]byte(catalog)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + c, err := client.FetchCatalog() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(c.Plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(c.Plugins)) + } + if c.Plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", c.Plugins[0].Name) + } + if c.Plugins[1].Name != "schema" { + t.Errorf("expected name 'schema', got %q", c.Plugins[1].Name) + } +} + +func TestCatalogEnvOverride(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/custom/catalog.yaml" { + w.Write([]byte(`apiVersion: cli.fluxcd.io/v1beta1 +kind: PluginCatalog +plugins: [] +`)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: "https://should-not-be-used/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { + if key == envCatalogBase { + return server.URL + "/custom/" + } + return "" + }, + } + + c, err := client.FetchCatalog() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(c.Plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(c.Plugins)) + } +} + +func TestResolveVersion(t *testing.T) { + manifest := &PluginManifest{ + Name: "operator", + Versions: []PluginVersion{ + {Version: "0.45.0"}, + {Version: "0.44.0"}, + }, + } + + t.Run("latest", func(t *testing.T) { + v, err := ResolveVersion(manifest, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Version != "0.45.0" { + t.Errorf("expected '0.45.0', got %q", v.Version) + } + }) + + t.Run("specific", func(t *testing.T) { + v, err := ResolveVersion(manifest, "0.44.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Version != "0.44.0" { + t.Errorf("expected '0.44.0', got %q", v.Version) + } + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolveVersion(manifest, "0.99.0") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("no versions", func(t *testing.T) { + _, err := ResolveVersion(&PluginManifest{Name: "empty"}, "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestResolvePlatform(t *testing.T) { + pv := &PluginVersion{ + Version: "0.45.0", + Platforms: []PluginPlatform{ + {OS: "darwin", Arch: "arm64", URL: "https://example.com/darwin_arm64.tar.gz"}, + {OS: "linux", Arch: "amd64", URL: "https://example.com/linux_amd64.tar.gz"}, + }, + } + + t.Run("found", func(t *testing.T) { + p, err := ResolvePlatform(pv, "darwin", "arm64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.OS != "darwin" || p.Arch != "arm64" { + t.Errorf("unexpected platform: %s/%s", p.OS, p.Arch) + } + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolvePlatform(pv, "windows", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/plugin/completion.go b/internal/plugin/completion.go new file mode 100644 index 00000000..73e31d84 --- /dev/null +++ b/internal/plugin/completion.go @@ -0,0 +1,75 @@ +/* +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 plugin + +import ( + "os/exec" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +// commandFunc is an alias to allow DI in tests. +var commandFunc = exec.Command + +// CompleteFunc returns a ValidArgsFunction that delegates completion +// to the plugin binary via Cobra's __complete protocol. +func CompleteFunc(pluginPath string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + completeArgs := append([]string{"__complete"}, args...) + completeArgs = append(completeArgs, toComplete) + + out, err := commandFunc(pluginPath, completeArgs...).Output() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return parseCompletionOutput(string(out)) + } +} + +// parseCompletionOutput parses Cobra's __complete output format. +// Each line is a completion, last line is :. +func parseCompletionOutput(out string) ([]string, cobra.ShellCompDirective) { + out = strings.TrimRight(out, "\n") + if out == "" { + return nil, cobra.ShellCompDirectiveError + } + lines := strings.Split(out, "\n") + + // Last line is the directive in format ":N" + lastLine := lines[len(lines)-1] + completions := lines[:len(lines)-1] + + directive := cobra.ShellCompDirectiveDefault + if strings.HasPrefix(lastLine, ":") { + if val, err := strconv.Atoi(lastLine[1:]); err == nil { + directive = cobra.ShellCompDirective(val) + } + } + + var results []string + for _, c := range completions { + if c == "" { + continue + } + results = append(results, c) + } + + return results, directive +} diff --git a/internal/plugin/completion_test.go b/internal/plugin/completion_test.go new file mode 100644 index 00000000..fb96dd56 --- /dev/null +++ b/internal/plugin/completion_test.go @@ -0,0 +1,80 @@ +/* +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 plugin + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestParseCompletionOutput(t *testing.T) { + tests := []struct { + name string + input string + expectedCompletions []string + expectedDirective cobra.ShellCompDirective + }{ + { + name: "standard output", + input: "instance\nrset\nrsip\nall\n:4\n", + expectedCompletions: []string{"instance", "rset", "rsip", "all"}, + expectedDirective: cobra.ShellCompDirective(4), + }, + { + name: "default directive", + input: "foo\nbar\n:0\n", + expectedCompletions: []string{"foo", "bar"}, + expectedDirective: cobra.ShellCompDirectiveDefault, + }, + { + name: "with descriptions", + input: "get\tGet resources\nbuild\tBuild resources\n:4\n", + expectedCompletions: []string{"get\tGet resources", "build\tBuild resources"}, + expectedDirective: cobra.ShellCompDirective(4), + }, + { + name: "empty completions", + input: ":4\n", + expectedCompletions: nil, + expectedDirective: cobra.ShellCompDirective(4), + }, + { + name: "empty input", + input: "", + expectedCompletions: nil, + expectedDirective: cobra.ShellCompDirectiveError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions, directive := parseCompletionOutput(tt.input) + if directive != tt.expectedDirective { + t.Errorf("directive: got %d, want %d", directive, tt.expectedDirective) + } + if len(completions) != len(tt.expectedCompletions) { + t.Fatalf("completions count: got %d, want %d", len(completions), len(tt.expectedCompletions)) + } + for i, c := range completions { + if c != tt.expectedCompletions[i] { + t.Errorf("completion[%d]: got %q, want %q", i, c, tt.expectedCompletions[i]) + } + } + }) + } +} diff --git a/internal/plugin/discovery.go b/internal/plugin/discovery.go new file mode 100644 index 00000000..5d750ac7 --- /dev/null +++ b/internal/plugin/discovery.go @@ -0,0 +1,195 @@ +/* +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 plugin + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + pluginPrefix = "flux-" + defaultDirName = "plugins" + defaultBaseDir = ".fluxcd" + envPluginDir = "FLUXCD_PLUGINS" +) + +// reservedNames are command names that cannot be used as plugin names. +var reservedNames = map[string]bool{ + "plugin": true, + "help": true, +} + +// Plugin represents a discovered plugin binary. +type Plugin struct { + Name string // e.g., "operator" (derived from "flux-operator") + Path string // absolute path to binary +} + +// Handler discovers and executes plugins. Uses dependency injection +// for testability. +type Handler struct { + ReadDir func(name string) ([]os.DirEntry, error) + Stat func(name string) (os.FileInfo, error) + GetEnv func(key string) string + HomeDir func() (string, error) +} + +// NewHandler returns a Handler with production defaults. +func NewHandler() *Handler { + return &Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: os.Getenv, + HomeDir: os.UserHomeDir, + } +} + +// Discover scans the plugin directory for executables matching flux-*. +// It skips builtins, reserved names, directories, non-executable files, +// and broken symlinks. +func (h *Handler) Discover(builtinNames []string) []Plugin { + dir := h.PluginDir() + if dir == "" { + return nil + } + + entries, err := h.ReadDir(dir) + if err != nil { + return nil + } + + builtins := make(map[string]bool, len(builtinNames)) + for _, name := range builtinNames { + builtins[name] = true + } + + var plugins []Plugin + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, pluginPrefix) { + continue + } + if entry.IsDir() { + continue + } + + pluginName := pluginNameFromBinary(name) + if pluginName == "" { + continue + } + if reservedNames[pluginName] || builtins[pluginName] { + continue + } + + fullPath := filepath.Join(dir, name) + + // Use Stat to follow symlinks and check the target. + info, err := h.Stat(fullPath) + if err != nil { + // Broken symlink, permission denied, etc. + continue + } + if !info.Mode().IsRegular() { + continue + } + if !isExecutable(info) { + continue + } + + plugins = append(plugins, Plugin{ + Name: pluginName, + Path: fullPath, + }) + } + + return plugins +} + +// PluginDir returns the plugin directory path. If FLUXCD_PLUGINS is set, +// returns that path. Otherwise returns ~/.fluxcd/plugins/. +// Does not create the directory — callers that write (install, update) +// should call EnsurePluginDir first. +func (h *Handler) PluginDir() string { + if dir := h.GetEnv(envPluginDir); dir != "" { + return dir + } + + home, err := h.HomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, defaultBaseDir, defaultDirName) +} + +// EnsurePluginDir creates the plugin directory if it doesn't exist +// and returns the path. Best-effort — ignores mkdir errors for +// read-only filesystems. User-managed directories (via $FLUXCD_PLUGINS) +// are not auto-created. +func (h *Handler) EnsurePluginDir() string { + if envDir := h.GetEnv(envPluginDir); envDir != "" { + return envDir + } + + home, err := h.HomeDir() + if err != nil { + return "" + } + + dir := filepath.Join(home, defaultBaseDir, defaultDirName) + _ = os.MkdirAll(dir, 0o755) + return dir +} + +// pluginNameFromBinary extracts the plugin name from a binary filename. +// "flux-operator" → "operator", "flux-my-tool" → "my-tool". +// Returns empty string for invalid names. +func pluginNameFromBinary(filename string) string { + if !strings.HasPrefix(filename, pluginPrefix) { + return "" + } + + name := strings.TrimPrefix(filename, pluginPrefix) + + // On Windows, strip known extensions. + if runtime.GOOS == "windows" { + for _, ext := range []string{".exe", ".cmd", ".bat"} { + if strings.HasSuffix(strings.ToLower(name), ext) { + name = name[:len(name)-len(ext)] + break + } + } + } + + if name == "" { + return "" + } + + return name +} + +// isExecutable checks if a file has the executable bit set. +// On Windows, this always returns true (executability is determined by extension). +func isExecutable(info os.FileInfo) bool { + if runtime.GOOS == "windows" { + return true + } + return info.Mode().Perm()&0o111 != 0 +} diff --git a/internal/plugin/discovery_test.go b/internal/plugin/discovery_test.go new file mode 100644 index 00000000..83021c08 --- /dev/null +++ b/internal/plugin/discovery_test.go @@ -0,0 +1,302 @@ +/* +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 plugin + +import ( + "fmt" + "io/fs" + "os" + "testing" + "time" +) + +// mockDirEntry implements os.DirEntry for testing. +type mockDirEntry struct { + name string + isDir bool + mode fs.FileMode +} + +func (m *mockDirEntry) Name() string { return m.name } +func (m *mockDirEntry) IsDir() bool { return m.isDir } +func (m *mockDirEntry) Type() fs.FileMode { return m.mode } +func (m *mockDirEntry) Info() (fs.FileInfo, error) { return nil, nil } + +// mockFileInfo implements os.FileInfo for testing. +type mockFileInfo struct { + name string + mode fs.FileMode + isDir bool + regular bool +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() fs.FileMode { return m.mode } +func (m *mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() any { return nil } + +func newTestHandler(entries []os.DirEntry, statResults map[string]*mockFileInfo, envVars map[string]string) *Handler { + return &Handler{ + ReadDir: func(name string) ([]os.DirEntry, error) { + if entries == nil { + return nil, fmt.Errorf("directory not found") + } + return entries, nil + }, + Stat: func(name string) (os.FileInfo, error) { + if info, ok := statResults[name]; ok { + return info, nil + } + return nil, fmt.Errorf("file not found: %s", name) + }, + GetEnv: func(key string) string { + return envVars[key] + }, + HomeDir: func() (string, error) { + return "/home/testuser", nil + }, + } +} + +func TestDiscover(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-operator", mode: 0}, + &mockDirEntry{name: "flux-local", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + "/test/plugins/flux-local": {name: "flux-local", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(plugins)) + } + if plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", plugins[0].Name) + } + if plugins[1].Name != "local" { + t.Errorf("expected name 'local', got %q", plugins[1].Name) + } +} + +func TestDiscoverSkipsBuiltins(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-version", mode: 0}, + &mockDirEntry{name: "flux-get", mode: 0}, + &mockDirEntry{name: "flux-operator", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-version": {name: "flux-version", mode: 0o755}, + "/test/plugins/flux-get": {name: "flux-get", mode: 0o755}, + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover([]string{"version", "get"}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", plugins[0].Name) + } +} + +func TestDiscoverSkipsReserved(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-plugin", mode: 0}, + &mockDirEntry{name: "flux-help", mode: 0}, + &mockDirEntry{name: "flux-operator", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-plugin": {name: "flux-plugin", mode: 0o755}, + "/test/plugins/flux-help": {name: "flux-help", mode: 0o755}, + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", plugins[0].Name) + } +} + +func TestDiscoverSkipsNonExecutable(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-noperm", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-noperm": {name: "flux-noperm", mode: 0o644}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestDiscoverSkipsDirectories(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-somedir", isDir: true, mode: fs.ModeDir}, + } + stats := map[string]*mockFileInfo{} + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestDiscoverFollowsSymlinks(t *testing.T) { + entries := []os.DirEntry{ + // Symlink entry — Type() returns symlink, but Stat resolves to regular executable. + &mockDirEntry{name: "flux-linked", mode: fs.ModeSymlink}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-linked": {name: "flux-linked", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Name != "linked" { + t.Errorf("expected name 'linked', got %q", plugins[0].Name) + } +} + +func TestDiscoverDirNotExist(t *testing.T) { + h := newTestHandler(nil, nil, map[string]string{envPluginDir: "/nonexistent"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestDiscoverCustomDir(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-custom", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/custom/path/flux-custom": {name: "flux-custom", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/custom/path"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Path != "/custom/path/flux-custom" { + t.Errorf("expected path '/custom/path/flux-custom', got %q", plugins[0].Path) + } +} + +func TestDiscoverSkipsNonFluxPrefix(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "kubectl-foo", mode: 0}, + &mockDirEntry{name: "random-binary", mode: 0}, + &mockDirEntry{name: "flux-operator", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } +} + +func TestDiscoverBrokenSymlink(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-broken", mode: fs.ModeSymlink}, + } + // No stat entry for flux-broken — simulates a broken symlink. + stats := map[string]*mockFileInfo{} + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestPluginNameFromBinary(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"flux-operator", "operator"}, + {"flux-my-tool", "my-tool"}, + {"flux-", ""}, + {"notflux-thing", ""}, + {"flux-a", "a"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := pluginNameFromBinary(tt.input) + if got != tt.expected { + t.Errorf("pluginNameFromBinary(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestPluginDir(t *testing.T) { + t.Run("uses env var", func(t *testing.T) { + h := &Handler{ + GetEnv: func(key string) string { + if key == envPluginDir { + return "/custom/plugins" + } + return "" + }, + HomeDir: func() (string, error) { + return "/home/user", nil + }, + } + dir := h.PluginDir() + if dir != "/custom/plugins" { + t.Errorf("expected '/custom/plugins', got %q", dir) + } + }) + + t.Run("uses default", func(t *testing.T) { + h := &Handler{ + GetEnv: func(key string) string { return "" }, + HomeDir: func() (string, error) { + return "/home/user", nil + }, + } + dir := h.PluginDir() + if dir != "/home/user/.fluxcd/plugins" { + t.Errorf("expected '/home/user/.fluxcd/plugins', got %q", dir) + } + }) +} diff --git a/internal/plugin/exec_unix.go b/internal/plugin/exec_unix.go new file mode 100644 index 00000000..10b09572 --- /dev/null +++ b/internal/plugin/exec_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +/* +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 plugin + +import ( + "os" + "syscall" +) + +// Exec replaces the current process with the plugin binary. +// This is what kubectl does — no signal forwarding or exit code propagation needed. +func Exec(path string, args []string) error { + return syscall.Exec(path, append([]string{path}, args...), os.Environ()) +} diff --git a/internal/plugin/exec_windows.go b/internal/plugin/exec_windows.go new file mode 100644 index 00000000..51e16186 --- /dev/null +++ b/internal/plugin/exec_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +/* +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 plugin + +import ( + "os" + "os/exec" +) + +// Exec runs the plugin as a child process with full I/O passthrough. +// Matches kubectl's Windows fallback pattern. +func Exec(path string, args []string) error { + cmd := exec.Command(path, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + err := cmd.Run() + if err == nil { + os.Exit(0) + } + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err +} diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 00000000..4a29e288 --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,235 @@ +/* +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 plugin + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "sigs.k8s.io/yaml" +) + +// Installer handles downloading, verifying, and installing plugins. +type Installer struct { + HTTPClient *http.Client +} + +// NewInstaller returns an Installer with production defaults. +func NewInstaller() *Installer { + return &Installer{ + HTTPClient: newHTTPClient(5 * time.Minute), + } +} + +// Install downloads, verifies, extracts, and installs a plugin binary +// to the given plugin directory. +func (inst *Installer) Install(pluginDir string, manifest *PluginManifest, pv *PluginVersion, plat *PluginPlatform) error { + tmpFile, err := os.CreateTemp("", "flux-plugin-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + resp, err := inst.HTTPClient.Get(plat.URL) + if err != nil { + return fmt.Errorf("failed to download plugin: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download plugin: HTTP %d", resp.StatusCode) + } + + hasher := sha256.New() + writer := io.MultiWriter(tmpFile, hasher) + if _, err := io.Copy(writer, resp.Body); err != nil { + return fmt.Errorf("failed to download plugin: %w", err) + } + tmpFile.Close() + + actualChecksum := fmt.Sprintf("sha256:%x", hasher.Sum(nil)) + if actualChecksum != plat.Checksum { + return fmt.Errorf("checksum verification failed (expected: %s, got: %s)", plat.Checksum, actualChecksum) + } + + binName := manifest.Bin + if runtime.GOOS == "windows" { + binName += ".exe" + } + + destName := pluginPrefix + manifest.Name + if runtime.GOOS == "windows" { + destName += ".exe" + } + destPath := filepath.Join(pluginDir, destName) + + if strings.HasSuffix(plat.URL, ".zip") { + err = extractFromZip(tmpFile.Name(), binName, destPath) + } else { + err = extractFromTarGz(tmpFile.Name(), binName, destPath) + } + if err != nil { + return err + } + + receipt := Receipt{ + Name: manifest.Name, + Version: pv.Version, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + Platform: *plat, + } + return writeReceipt(pluginDir, manifest.Name, &receipt) +} + +// Uninstall removes a plugin binary (or symlink) and its receipt from the +// plugin directory. Returns an error if the plugin is not installed. +func Uninstall(pluginDir, name string) error { + binName := pluginPrefix + name + if runtime.GOOS == "windows" { + binName += ".exe" + } + + binPath := filepath.Join(pluginDir, binName) + + // Use Lstat so we detect symlinks without following them. + if _, err := os.Lstat(binPath); os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not installed", name) + } + + if err := os.Remove(binPath); err != nil { + return fmt.Errorf("failed to remove plugin binary: %w", err) + } + + // Receipt is optional (manually installed plugins don't have one). + if err := os.Remove(receiptPath(pluginDir, name)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove plugin receipt: %w", err) + } + + return nil +} + +// ReadReceipt reads the install receipt for a plugin. +// Returns nil if no receipt exists. +func ReadReceipt(pluginDir, name string) *Receipt { + data, err := os.ReadFile(receiptPath(pluginDir, name)) + if err != nil { + return nil + } + + var receipt Receipt + if err := yaml.Unmarshal(data, &receipt); err != nil { + return nil + } + + return &receipt +} + +func receiptPath(pluginDir, name string) string { + return filepath.Join(pluginDir, pluginPrefix+name+".yaml") +} + +func writeReceipt(pluginDir, name string, receipt *Receipt) error { + data, err := yaml.Marshal(receipt) + if err != nil { + return fmt.Errorf("failed to marshal receipt: %w", err) + } + + return os.WriteFile(receiptPath(pluginDir, name), data, 0o644) +} + +// extractFromTarGz extracts a named file from a tar.gz archive +// and streams it directly to destPath. +func extractFromTarGz(archivePath, targetName, destPath string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("failed to read gzip: %w", err) + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + + if filepath.IsAbs(header.Name) || strings.Contains(header.Name, "..") { + continue + } + if filepath.Base(header.Name) == targetName && header.Typeflag == tar.TypeReg { + return writeStreamToFile(tr, destPath) + } + } + + return fmt.Errorf("binary %q not found in archive", targetName) +} + +// extractFromZip extracts a named file from a zip archive +// and streams it directly to destPath. +func extractFromZip(archivePath, targetName, destPath string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + if filepath.Base(f.Name) == targetName && !f.FileInfo().IsDir() { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open %q in zip: %w", targetName, err) + } + defer rc.Close() + return writeStreamToFile(rc, destPath) + } + } + + return fmt.Errorf("binary %q not found in archive", targetName) +} + +func writeStreamToFile(r io.Reader, destPath string) error { + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("failed to create %s: %w", destPath, err) + } + + if _, err := io.Copy(out, r); err != nil { + out.Close() + return fmt.Errorf("failed to write plugin binary: %w", err) + } + return out.Close() +} diff --git a/internal/plugin/install_test.go b/internal/plugin/install_test.go new file mode 100644 index 00000000..7b4d4253 --- /dev/null +++ b/internal/plugin/install_test.go @@ -0,0 +1,331 @@ +/* +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 plugin + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// createTestTarGz creates a tar.gz archive containing a single file. +func createTestTarGz(name string, content []byte) ([]byte, error) { + var buf bytes.Buffer + + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + + tw.Close() + gw.Close() + return buf.Bytes(), nil +} + +func TestInstall(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho hello") + archive, err := createTestTarGz("flux-operator", binaryContent) + if err != nil { + t.Fatalf("failed to create test archive: %v", err) + } + + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &PluginManifest{ + Name: "operator", + Bin: "flux-operator", + } + pv := &PluginVersion{Version: "0.45.0"} + plat := &PluginPlatform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/flux-operator_0.45.0_linux_amd64.tar.gz", + Checksum: checksum, + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + // Verify binary was written. + binPath := filepath.Join(pluginDir, "flux-operator") + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found: %v", err) + } + if string(data) != string(binaryContent) { + t.Errorf("binary content mismatch") + } + + // Verify receipt was written. + receipt := ReadReceipt(pluginDir, "operator") + if receipt == nil { + t.Fatal("receipt not found") + } + if receipt.Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", receipt.Version) + } + if receipt.Name != "operator" { + t.Errorf("expected name 'operator', got %q", receipt.Name) + } +} + +func TestInstallChecksumMismatch(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho hello") + archive, err := createTestTarGz("flux-operator", binaryContent) + if err != nil { + t.Fatalf("failed to create test archive: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &PluginManifest{Name: "operator", Bin: "flux-operator"} + pv := &PluginVersion{Version: "0.45.0"} + plat := &PluginPlatform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/archive.tar.gz", + Checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + } + + installer := &Installer{HTTPClient: server.Client()} + err = installer.Install(pluginDir, manifest, pv, plat) + if err == nil { + t.Fatal("expected checksum error, got nil") + } + if !bytes.Contains([]byte(err.Error()), []byte("checksum verification failed")) { + t.Errorf("expected checksum error, got: %v", err) + } +} + +func TestInstallBinaryNotInArchive(t *testing.T) { + // Archive contains "wrong-name" instead of "flux-operator". + archive, err := createTestTarGz("wrong-name", []byte("content")) + if err != nil { + t.Fatalf("failed to create test archive: %v", err) + } + + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &PluginManifest{Name: "operator", Bin: "flux-operator"} + pv := &PluginVersion{Version: "0.45.0"} + plat := &PluginPlatform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/archive.tar.gz", + Checksum: checksum, + } + + installer := &Installer{HTTPClient: server.Client()} + err = installer.Install(pluginDir, manifest, pv, plat) + if err == nil { + t.Fatal("expected error for missing binary, got nil") + } + if !bytes.Contains([]byte(err.Error()), []byte("not found in archive")) { + t.Errorf("expected 'not found in archive' error, got: %v", err) + } +} + +func TestUninstall(t *testing.T) { + pluginDir := t.TempDir() + + // Create fake binary and receipt. + binPath := filepath.Join(pluginDir, "flux-testplugin") + os.WriteFile(binPath, []byte("binary"), 0o755) + receiptPath := filepath.Join(pluginDir, "flux-testplugin.yaml") + os.WriteFile(receiptPath, []byte("name: testplugin"), 0o644) + + if err := Uninstall(pluginDir, "testplugin"); err != nil { + t.Fatalf("uninstall failed: %v", err) + } + + if _, err := os.Stat(binPath); !os.IsNotExist(err) { + t.Error("binary was not removed") + } + if _, err := os.Stat(receiptPath); !os.IsNotExist(err) { + t.Error("receipt was not removed") + } +} + +func TestUninstallNonExistent(t *testing.T) { + pluginDir := t.TempDir() + + err := Uninstall(pluginDir, "nonexistent") + if err == nil { + t.Fatal("expected error for non-existent plugin, got nil") + } + if !strings.Contains(err.Error(), "is not installed") { + t.Errorf("expected 'is not installed' error, got: %v", err) + } +} + +func TestUninstallSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated privileges on Windows") + } + + pluginDir := t.TempDir() + + // Create a real binary and symlink it into the plugin dir. + realBin := filepath.Join(t.TempDir(), "flux-operator") + os.WriteFile(realBin, []byte("real binary"), 0o755) + + linkPath := filepath.Join(pluginDir, "flux-linked") + os.Symlink(realBin, linkPath) + + if err := Uninstall(pluginDir, "linked"); err != nil { + t.Fatalf("uninstall symlink failed: %v", err) + } + + // Symlink should be removed. + if _, err := os.Lstat(linkPath); !os.IsNotExist(err) { + t.Error("symlink was not removed") + } + // Original binary should still exist. + if _, err := os.Stat(realBin); err != nil { + t.Error("original binary was removed — symlink removal should not affect target") + } +} + +func TestUninstallManualBinary(t *testing.T) { + pluginDir := t.TempDir() + + // Manually copied binary with no receipt. + binPath := filepath.Join(pluginDir, "flux-manual") + os.WriteFile(binPath, []byte("binary"), 0o755) + + if err := Uninstall(pluginDir, "manual"); err != nil { + t.Fatalf("uninstall manual binary failed: %v", err) + } + + if _, err := os.Stat(binPath); !os.IsNotExist(err) { + t.Error("binary was not removed") + } +} + +func TestReadReceipt(t *testing.T) { + pluginDir := t.TempDir() + + t.Run("exists", func(t *testing.T) { + receiptData := `name: operator +version: "0.45.0" +installedAt: "2026-03-28T20:05:00Z" +platform: + os: darwin + arch: arm64 + url: https://example.com/archive.tar.gz + checksum: sha256:abc123 +` + os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644) + + receipt := ReadReceipt(pluginDir, "operator") + if receipt == nil { + t.Fatal("expected receipt, got nil") + } + if receipt.Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", receipt.Version) + } + if receipt.Platform.OS != "darwin" { + t.Errorf("expected OS 'darwin', got %q", receipt.Platform.OS) + } + }) + + t.Run("not exists", func(t *testing.T) { + receipt := ReadReceipt(pluginDir, "nonexistent") + if receipt != nil { + t.Error("expected nil receipt") + } + }) +} + +func TestExtractFromTarGz(t *testing.T) { + content := []byte("test binary content") + archive, err := createTestTarGz("flux-operator", content) + if err != nil { + t.Fatalf("failed to create archive: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.tar.gz") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + if err := extractFromTarGz(tmpFile, "flux-operator", destPath); err != nil { + t.Fatalf("extract failed: %v", err) + } + data, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if string(data) != string(content) { + t.Errorf("content mismatch: got %q, want %q", string(data), string(content)) + } +} + +func TestExtractFromTarGzNotFound(t *testing.T) { + archive, err := createTestTarGz("other-binary", []byte("content")) + if err != nil { + t.Fatalf("failed to create archive: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.tar.gz") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + err = extractFromTarGz(tmpFile, "flux-operator", destPath) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/plugin/update.go b/internal/plugin/update.go new file mode 100644 index 00000000..15a02aab --- /dev/null +++ b/internal/plugin/update.go @@ -0,0 +1,85 @@ +/* +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 plugin + +const ( + SkipReasonManual = "manually installed" + SkipReasonUpToDate = "already up to date" +) + +// UpdateResult represents the outcome of updating a single plugin. +// When an update is available, Manifest, Version and Platform are +// populated so the caller can install without re-fetching or re-resolving. +type UpdateResult struct { + Name string + FromVersion string + ToVersion string + Skipped bool + SkipReason string + Manifest *PluginManifest + Version *PluginVersion + Platform *PluginPlatform + Err error +} + +// CheckUpdate compares the installed version against the latest in the catalog. +// Returns an UpdateResult describing what should happen. When an update is +// available, Manifest is populated so the caller can install without re-fetching. +func CheckUpdate(pluginDir string, name string, catalog *CatalogClient, goos, goarch string) UpdateResult { + receipt := ReadReceipt(pluginDir, name) + if receipt == nil { + return UpdateResult{ + Name: name, + Skipped: true, + SkipReason: SkipReasonManual, + } + } + + manifest, err := catalog.FetchManifest(name) + if err != nil { + return UpdateResult{Name: name, Err: err} + } + + latest, err := ResolveVersion(manifest, "") + if err != nil { + return UpdateResult{Name: name, Err: err} + } + + if receipt.Version == latest.Version { + return UpdateResult{ + Name: name, + FromVersion: receipt.Version, + ToVersion: latest.Version, + Skipped: true, + SkipReason: SkipReasonUpToDate, + } + } + + plat, err := ResolvePlatform(latest, goos, goarch) + if err != nil { + return UpdateResult{Name: name, Err: err} + } + + return UpdateResult{ + Name: name, + FromVersion: receipt.Version, + ToVersion: latest.Version, + Manifest: manifest, + Version: latest, + Platform: plat, + } +} diff --git a/internal/plugin/update_test.go b/internal/plugin/update_test.go new file mode 100644 index 00000000..53c1fc53 --- /dev/null +++ b/internal/plugin/update_test.go @@ -0,0 +1,153 @@ +/* +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 plugin + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestCheckUpdateUpToDate(t *testing.T) { + manifest := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: Plugin +name: operator +bin: flux-operator +versions: + - version: 0.45.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/archive.tar.gz + checksum: sha256:abc123 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(manifest)) + })) + defer server.Close() + + pluginDir := t.TempDir() + + // Write receipt with same version. + receiptData := `name: operator +version: "0.45.0" +installedAt: "2026-03-28T20:05:00Z" +platform: + os: linux + arch: amd64 +` + os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644) + + catalog := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + result := CheckUpdate(pluginDir, "operator", catalog, "linux", "amd64") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.Skipped { + t.Error("expected skipped=true") + } + if result.SkipReason != SkipReasonUpToDate { + t.Errorf("expected %q, got %q", SkipReasonUpToDate, result.SkipReason) + } +} + +func TestCheckUpdateAvailable(t *testing.T) { + manifest := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: Plugin +name: operator +bin: flux-operator +versions: + - version: 0.46.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/archive.tar.gz + checksum: sha256:abc123 + - version: 0.45.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/archive.tar.gz + checksum: sha256:def456 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(manifest)) + })) + defer server.Close() + + pluginDir := t.TempDir() + + receiptData := `name: operator +version: "0.45.0" +installedAt: "2026-03-28T20:05:00Z" +platform: + os: linux + arch: amd64 +` + os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644) + + catalog := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + result := CheckUpdate(pluginDir, "operator", catalog, "linux", "amd64") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if result.Skipped { + t.Error("expected skipped=false") + } + if result.FromVersion != "0.45.0" { + t.Errorf("expected from '0.45.0', got %q", result.FromVersion) + } + if result.ToVersion != "0.46.0" { + t.Errorf("expected to '0.46.0', got %q", result.ToVersion) + } +} + +func TestCheckUpdateManualInstall(t *testing.T) { + pluginDir := t.TempDir() + + // No receipt — manually installed. + catalog := &CatalogClient{ + BaseURL: "https://example.com/", + HTTPClient: http.DefaultClient, + GetEnv: func(key string) string { return "" }, + } + + result := CheckUpdate(pluginDir, "operator", catalog, "linux", "amd64") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.Skipped { + t.Error("expected skipped=true") + } + if result.SkipReason != SkipReasonManual { + t.Errorf("expected 'manually installed', got %q", result.SkipReason) + } +}