1
0
mirror of synced 2026-05-03 02:03:31 +00:00

Add support for extractPath

Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
This commit is contained in:
Stefan Prodan
2026-04-13 23:22:47 +03:00
parent 2cee1d795e
commit c0938d351f
5 changed files with 271 additions and 42 deletions

View File

@@ -38,59 +38,118 @@ const (
// PluginManifest represents a single plugin's manifest from the catalog. // PluginManifest represents a single plugin's manifest from the catalog.
type PluginManifest struct { type PluginManifest struct {
APIVersion string `json:"apiVersion"` // APIVersion is the manifest schema version (e.g. "cli.fluxcd.io/v1beta1").
Kind string `json:"kind"` APIVersion string `json:"apiVersion"`
Name string `json:"name"`
Description string `json:"description"` // Kind is the manifest type, must be "Plugin".
Homepage string `json:"homepage,omitempty"` Kind string `json:"kind"`
Source string `json:"source,omitempty"`
Bin string `json:"bin"` // Name is the plugin name used in "flux <name>" invocations.
Versions []PluginVersion `json:"versions"` 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. // PluginVersion represents a version entry in a plugin manifest.
type PluginVersion struct { 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"` Platforms []PluginPlatform `json:"platforms"`
} }
// PluginPlatform represents a platform-specific binary entry. // PluginPlatform represents a platform-specific binary entry.
type PluginPlatform struct { type PluginPlatform struct {
OS string `json:"os"` // OS is the target operating system (e.g. "darwin", "linux", "windows").
Arch string `json:"arch"` OS string `json:"os"`
URL string `json:"url"`
// 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 "<algorithm>:<hex>" format
// (e.g. "sha256:cd85d5d84d264...").
Checksum string `json:"checksum"` 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. // PluginCatalog represents the generated catalog.yaml file.
type PluginCatalog struct { type PluginCatalog struct {
APIVersion string `json:"apiVersion"` // APIVersion is the catalog schema version (e.g. "cli.fluxcd.io/v1beta1").
Kind string `json:"kind"` APIVersion string `json:"apiVersion"`
Plugins []CatalogEntry `json:"plugins"`
// 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. // CatalogEntry is a single entry in the plugin catalog.
type CatalogEntry struct { 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"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Source string `json:"source,omitempty"` // Homepage is the URL to the plugin's documentation site.
License string `json:"license,omitempty"` 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. // Receipt records what was installed for a plugin.
type Receipt struct { type Receipt struct {
Name string `json:"name"` // Name is the plugin name (e.g. "operator").
Version string `json:"version"` Name string `json:"name"`
InstalledAt string `json:"installedAt"`
Platform PluginPlatform `json:"platform"` // 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. // CatalogClient fetches plugin manifests and catalogs from a remote URL.
type CatalogClient struct { 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 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. // NewCatalogClient returns a CatalogClient with production defaults.

View File

@@ -38,16 +38,26 @@ var reservedNames = map[string]bool{
// Plugin represents a discovered plugin binary. // Plugin represents a discovered plugin binary.
type Plugin struct { type Plugin struct {
Name string // e.g., "operator" (derived from "flux-operator") // Name is the plugin name, e.g. "operator" (derived from "flux-operator").
Path string // absolute path to binary Name string
// Path is the absolute path to the plugin binary.
Path string
} }
// Handler discovers and executes plugins. Uses dependency injection // Handler discovers and executes plugins. Uses dependency injection
// for testability. // for testability.
type Handler struct { type Handler struct {
// ReadDir lists directory entries.
ReadDir func(name string) ([]os.DirEntry, error) 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) HomeDir func() (string, error)
} }

View File

@@ -35,6 +35,7 @@ import (
// Installer handles downloading, verifying, and installing plugins. // Installer handles downloading, verifying, and installing plugins.
type Installer struct { type Installer struct {
// HTTPClient is the HTTP client used for downloading plugin archives.
HTTPClient *http.Client HTTPClient *http.Client
} }
@@ -87,6 +88,15 @@ func (inst *Installer) Install(pluginDir string, manifest *PluginManifest, pv *P
} }
destPath := filepath.Join(pluginDir, binName) 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) format, err := detectArchiveFormat(tmpFile.Name(), plat.URL)
if err != nil { if err != nil {
return fmt.Errorf("failed to detect plugin format: %w", err) 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 { switch format {
case formatZip: case formatZip:
err = extractFromZip(tmpFile.Name(), binName, destPath) err = extractFromZip(tmpFile.Name(), extractTarget, destPath)
case formatTarGz: case formatTarGz:
err = extractFromTarGz(tmpFile.Name(), binName, destPath) err = extractFromTarGz(tmpFile.Name(), extractTarget, destPath)
case formatTar: case formatTar:
err = extractFromTar(tmpFile.Name(), binName, destPath) err = extractFromTar(tmpFile.Name(), extractTarget, destPath)
case formatBinary: case formatBinary:
err = copyPluginBinary(tmpFile.Name(), destPath) err = copyPluginBinary(tmpFile.Name(), destPath)
default: default:
@@ -257,6 +267,16 @@ func extractFromTar(archivePath, targetName, destPath string) error {
return extractTarStream(f, targetName, destPath) 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 // extractTarStream walks a tar stream and streams the first matching
// regular file to destPath. Non-regular entries (symlinks, devices, // regular file to destPath. Non-regular entries (symlinks, devices,
// directories) and entries with unsafe paths are skipped. // 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() { if !header.FileInfo().Mode().IsRegular() {
continue continue
} }
if filepath.Base(header.Name) == targetName { if matchArchiveEntry(header.Name, targetName) {
return writeStreamToFile(tr, destPath) return writeStreamToFile(tr, destPath)
} }
} }
@@ -314,7 +334,7 @@ func extractFromZip(archivePath, targetName, destPath string) error {
if !f.FileInfo().Mode().IsRegular() { if !f.FileInfo().Mode().IsRegular() {
continue continue
} }
if filepath.Base(f.Name) == targetName { if matchArchiveEntry(f.Name, targetName) {
rc, err := f.Open() rc, err := f.Open()
if err != nil { if err != nil {
return fmt.Errorf("failed to open %q in zip: %w", targetName, err) return fmt.Errorf("failed to open %q in zip: %w", targetName, err)

View File

@@ -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) { func TestExtractFromTarGzNotFound(t *testing.T) {
archive, err := createTestTarGz("other-binary", []byte("content")) archive, err := createTestTarGz("other-binary", []byte("content"))
if err != nil { if err != nil {

View File

@@ -25,15 +25,32 @@ const (
// When an update is available, Manifest, Version and Platform are // When an update is available, Manifest, Version and Platform are
// populated so the caller can install without re-fetching or re-resolving. // populated so the caller can install without re-fetching or re-resolving.
type UpdateResult struct { type UpdateResult struct {
Name string // Name is the plugin name.
Name string
// FromVersion is the currently installed version.
FromVersion string FromVersion string
ToVersion string
Skipped bool // ToVersion is the latest available version.
SkipReason string ToVersion string
Manifest *PluginManifest
Version *PluginVersion // Skipped is true when the update was not performed.
Platform *PluginPlatform Skipped bool
Err error
// 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. // CheckUpdate compares the installed version against the latest in the catalog.