Add support for extractPath
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user