diff --git a/internal/plugin/catalog.go b/internal/plugin/catalog.go index b1b67bfa..1d370a81 100644 --- a/internal/plugin/catalog.go +++ b/internal/plugin/catalog.go @@ -38,59 +38,118 @@ const ( // 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"` + // APIVersion is the manifest schema version (e.g. "cli.fluxcd.io/v1beta1"). + APIVersion string `json:"apiVersion"` + + // Kind is the manifest type, must be "Plugin". + Kind string `json:"kind"` + + // Name is the plugin name used in "flux " invocations. + Name string `json:"name"` + + // Description is a short human-readable summary of the plugin. + Description string `json:"description"` + + // Homepage is the URL to the plugin's documentation site. + Homepage string `json:"homepage,omitempty"` + + // Source is the URL to the plugin's source repository. + Source string `json:"source,omitempty"` + + // Bin is the binary name inside archives and the installed filename + // (e.g. "flux-operator"). On Windows ".exe" is appended automatically. + Bin string `json:"bin"` + + // Versions lists available versions, newest first. + Versions []PluginVersion `json:"versions"` } // PluginVersion represents a version entry in a plugin manifest. type PluginVersion struct { - Version string `json:"version"` + // Version is the semantic version string (e.g. "0.45.0"). + Version string `json:"version"` + + // Platforms lists the platform-specific binaries for this 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"` + // OS is the target operating system (e.g. "darwin", "linux", "windows"). + OS string `json:"os"` + + // Arch is the target architecture (e.g. "amd64", "arm64"). + Arch string `json:"arch"` + + // URL is the download URL for the archive or binary. + URL string `json:"url"` + + // Checksum is the expected digest in ":" format + // (e.g. "sha256:cd85d5d84d264..."). Checksum string `json:"checksum"` + + // ExtractPath overrides the default binary lookup name inside archives. + // When set, it is matched as an exact path within the archive (e.g. + // "bin/flux-operator"). When empty, the archive is searched by the + // base name derived from the manifest's Bin field. + ExtractPath string `json:"extractPath,omitempty"` } // PluginCatalog represents the generated catalog.yaml file. type PluginCatalog struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Plugins []CatalogEntry `json:"plugins"` + // APIVersion is the catalog schema version (e.g. "cli.fluxcd.io/v1beta1"). + APIVersion string `json:"apiVersion"` + + // Kind is the catalog type, must be "PluginCatalog". + Kind string `json:"kind"` + + // Plugins lists all available plugins in the catalog. + Plugins []CatalogEntry `json:"plugins"` } // CatalogEntry is a single entry in the plugin catalog. type CatalogEntry struct { - Name string `json:"name"` + // Name is the plugin name. + Name string `json:"name"` + + // Description is a short human-readable summary of the plugin. Description string `json:"description"` - Homepage string `json:"homepage,omitempty"` - Source string `json:"source,omitempty"` - License string `json:"license,omitempty"` + + // Homepage is the URL to the plugin's documentation site. + Homepage string `json:"homepage,omitempty"` + + // Source is the URL to the plugin's source repository. + Source string `json:"source,omitempty"` + + // License is the SPDX license identifier (e.g. "Apache-2.0"). + 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"` + // Name is the plugin name (e.g. "operator"). + Name string `json:"name"` + + // Version is the installed semantic version. + Version string `json:"version"` + + // InstalledAt is the RFC 3339 timestamp of the installation. + InstalledAt string `json:"installedAt"` + + // Platform records the platform-specific details used for installation. + Platform PluginPlatform `json:"platform"` } // CatalogClient fetches plugin manifests and catalogs from a remote URL. type CatalogClient struct { - BaseURL string + // BaseURL is the catalog base URL for fetching manifests. + BaseURL string + + // HTTPClient is the HTTP client used for catalog requests. HTTPClient *http.Client - GetEnv func(key string) string + + // GetEnv returns the value of an environment variable. + GetEnv func(key string) string } // NewCatalogClient returns a CatalogClient with production defaults. diff --git a/internal/plugin/discovery.go b/internal/plugin/discovery.go index 5d750ac7..a8ab37a3 100644 --- a/internal/plugin/discovery.go +++ b/internal/plugin/discovery.go @@ -38,16 +38,26 @@ var reservedNames = map[string]bool{ // Plugin represents a discovered plugin binary. type Plugin struct { - Name string // e.g., "operator" (derived from "flux-operator") - Path string // absolute path to binary + // Name is the plugin name, e.g. "operator" (derived from "flux-operator"). + Name string + + // Path is the absolute path to the plugin binary. + Path string } // Handler discovers and executes plugins. Uses dependency injection // for testability. type Handler struct { + // ReadDir lists directory entries. ReadDir func(name string) ([]os.DirEntry, error) - Stat func(name string) (os.FileInfo, error) - GetEnv func(key string) string + + // Stat returns file info, following symlinks. + Stat func(name string) (os.FileInfo, error) + + // GetEnv returns the value of an environment variable. + GetEnv func(key string) string + + // HomeDir returns the current user's home directory. HomeDir func() (string, error) } diff --git a/internal/plugin/install.go b/internal/plugin/install.go index 2bf04bb2..1589e2e2 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -35,6 +35,7 @@ import ( // Installer handles downloading, verifying, and installing plugins. type Installer struct { + // HTTPClient is the HTTP client used for downloading plugin archives. HTTPClient *http.Client } @@ -87,6 +88,15 @@ func (inst *Installer) Install(pluginDir string, manifest *PluginManifest, pv *P } destPath := filepath.Join(pluginDir, binName) + // extractTarget is the path to match inside the archive. When the + // platform specifies an extractPath, use it verbatim (it may be a + // nested path like "bin/flux-operator"). Otherwise fall back to + // binName which matches by basename. + extractTarget := binName + if plat.ExtractPath != "" { + extractTarget = plat.ExtractPath + } + format, err := detectArchiveFormat(tmpFile.Name(), plat.URL) if err != nil { return fmt.Errorf("failed to detect plugin format: %w", err) @@ -94,11 +104,11 @@ func (inst *Installer) Install(pluginDir string, manifest *PluginManifest, pv *P switch format { case formatZip: - err = extractFromZip(tmpFile.Name(), binName, destPath) + err = extractFromZip(tmpFile.Name(), extractTarget, destPath) case formatTarGz: - err = extractFromTarGz(tmpFile.Name(), binName, destPath) + err = extractFromTarGz(tmpFile.Name(), extractTarget, destPath) case formatTar: - err = extractFromTar(tmpFile.Name(), binName, destPath) + err = extractFromTar(tmpFile.Name(), extractTarget, destPath) case formatBinary: err = copyPluginBinary(tmpFile.Name(), destPath) default: @@ -257,6 +267,16 @@ func extractFromTar(archivePath, targetName, destPath string) error { return extractTarStream(f, targetName, destPath) } +// matchArchiveEntry reports whether the archive entry name matches the +// target. If target contains a path separator it is matched as an exact +// path; otherwise only the base name of the entry is compared. +func matchArchiveEntry(entryName, target string) bool { + if strings.Contains(target, "/") { + return entryName == target + } + return filepath.Base(entryName) == target +} + // extractTarStream walks a tar stream and streams the first matching // regular file to destPath. Non-regular entries (symlinks, devices, // directories) and entries with unsafe paths are skipped. @@ -277,7 +297,7 @@ func extractTarStream(r io.Reader, targetName, destPath string) error { if !header.FileInfo().Mode().IsRegular() { continue } - if filepath.Base(header.Name) == targetName { + if matchArchiveEntry(header.Name, targetName) { return writeStreamToFile(tr, destPath) } } @@ -314,7 +334,7 @@ func extractFromZip(archivePath, targetName, destPath string) error { if !f.FileInfo().Mode().IsRegular() { continue } - if filepath.Base(f.Name) == targetName { + if matchArchiveEntry(f.Name, targetName) { rc, err := f.Open() if err != nil { return fmt.Errorf("failed to open %q in zip: %w", targetName, err) diff --git a/internal/plugin/install_test.go b/internal/plugin/install_test.go index e783ca97..43fb2845 100644 --- a/internal/plugin/install_test.go +++ b/internal/plugin/install_test.go @@ -673,6 +673,129 @@ func TestExtractFromZipRejectsUnsafeEntries(t *testing.T) { } } +func TestInstallExtractPath(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho nested") + + // Binary is nested at "subdir/flux-operator" inside the archive. + entries := []tarEntry{ + { + header: tar.Header{ + Name: "subdir/flux-operator", + Typeflag: tar.TypeReg, + Mode: 0o755, + }, + content: binaryContent, + }, + } + archive, err := createTestTarGzMulti(entries) + if err != nil { + t.Fatalf("failed to create 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, + ExtractPath: "subdir/flux-operator", + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + // Binary must be installed under manifest.Bin, not the extractPath. + binPath := filepath.Join(pluginDir, "flux-operator") + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found: %v", err) + } + if !bytes.Equal(data, binaryContent) { + t.Errorf("binary content mismatch") + } +} + +func TestInstallExtractPathZip(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho nested zip") + + archive, err := createTestZip([]zipEntry{ + {name: "pkg/bin/flux-operator", content: binaryContent}, + }) + if err != nil { + t.Fatalf("createTestZip: %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.zip", + Checksum: checksum, + ExtractPath: "pkg/bin/flux-operator", + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + binPath := filepath.Join(pluginDir, "flux-operator") + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found: %v", err) + } + if !bytes.Equal(data, binaryContent) { + t.Errorf("binary content mismatch") + } +} + +func TestMatchArchiveEntry(t *testing.T) { + tests := []struct { + entry, target string + want bool + }{ + // Basename matching (no slash in target). + {"flux-operator", "flux-operator", true}, + {"bin/flux-operator", "flux-operator", true}, + {"deep/nested/flux-operator", "flux-operator", true}, + {"other-binary", "flux-operator", false}, + + // Exact path matching (slash in target). + {"bin/flux-operator", "bin/flux-operator", true}, + {"flux-operator", "bin/flux-operator", false}, + {"other/flux-operator", "bin/flux-operator", false}, + } + for _, tc := range tests { + t.Run(tc.entry+"_"+tc.target, func(t *testing.T) { + if got := matchArchiveEntry(tc.entry, tc.target); got != tc.want { + t.Errorf("matchArchiveEntry(%q, %q) = %v, want %v", tc.entry, tc.target, got, tc.want) + } + }) + } +} + func TestExtractFromTarGzNotFound(t *testing.T) { archive, err := createTestTarGz("other-binary", []byte("content")) if err != nil { diff --git a/internal/plugin/update.go b/internal/plugin/update.go index 15a02aab..6895fbef 100644 --- a/internal/plugin/update.go +++ b/internal/plugin/update.go @@ -25,15 +25,32 @@ const ( // 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 + // Name is the plugin name. + Name string + + // FromVersion is the currently installed version. FromVersion string - ToVersion string - Skipped bool - SkipReason string - Manifest *PluginManifest - Version *PluginVersion - Platform *PluginPlatform - Err error + + // ToVersion is the latest available version. + ToVersion string + + // Skipped is true when the update was not performed. + Skipped bool + + // SkipReason explains why the update was skipped. + SkipReason string + + // Manifest is the resolved plugin manifest for the update. + Manifest *PluginManifest + + // Version is the resolved target version for the update. + Version *PluginVersion + + // Platform is the resolved platform entry for the update. + Platform *PluginPlatform + + // Err is set when the update check itself failed. + Err error } // CheckUpdate compares the installed version against the latest in the catalog.