/* 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" plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" ) // Installer handles downloading, verifying, and installing plugins. type Installer struct { // HTTPClient is the HTTP client used for downloading plugin archives. 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 *plugintypes.Manifest, pv *plugintypes.Version, plat *plugintypes.Platform) 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) } // manifest.Bin is the single source of truth for the installed binary // name (e.g. "flux-validate"). On Windows we always append ".exe". // For archives it's also the entry name we look up; for raw binaries // it's the rename target regardless of the URL's filename. binName := manifest.Bin if runtime.GOOS == "windows" { binName += ".exe" } 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) } switch format { case formatZip: err = extractFromZip(tmpFile.Name(), extractTarget, destPath) case formatTarGz: err = extractFromTarGz(tmpFile.Name(), extractTarget, destPath) case formatTar: err = extractFromTar(tmpFile.Name(), extractTarget, destPath) case formatBinary: err = copyPluginBinary(tmpFile.Name(), destPath) default: return fmt.Errorf("unexpected plugin format: %v", format) } if err != nil { return err } receipt := plugintypes.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) *plugintypes.Receipt { data, err := os.ReadFile(receiptPath(pluginDir, name)) if err != nil { return nil } var receipt plugintypes.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 *plugintypes.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) } // archiveFormat is the detected format of a downloaded plugin artifact. type archiveFormat int const ( formatBinary archiveFormat = iota formatZip formatTarGz formatTar ) // detectArchiveFormat determines the artifact format by first checking the URL // extension, then falling back to magic-byte inspection. Returns formatBinary // if neither indicates a known archive, in which case the downloaded file is // installed as-is. func detectArchiveFormat(path, url string) (archiveFormat, error) { switch lower := strings.ToLower(url); { case strings.HasSuffix(lower, ".zip"): return formatZip, nil case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): return formatTarGz, nil case strings.HasSuffix(lower, ".tar"): return formatTar, nil } f, err := os.Open(path) if err != nil { return formatBinary, err } defer f.Close() // Read enough bytes to cover the tar "ustar" magic at offset 257. var hdr [262]byte n, err := io.ReadFull(f, hdr[:]) if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { return formatBinary, err } // ZIP: PK\x03\x04 (file), PK\x05\x06 (empty), PK\x07\x08 (spanned). if n >= 4 && hdr[0] == 'P' && hdr[1] == 'K' && (hdr[2] == 0x03 || hdr[2] == 0x05 || hdr[2] == 0x07) { return formatZip, nil } // gzip: \x1f\x8b if n >= 2 && hdr[0] == 0x1f && hdr[1] == 0x8b { return formatTarGz, nil } // tar: "ustar" at offset 257 if n >= 262 && string(hdr[257:262]) == "ustar" { return formatTar, nil } return formatBinary, nil } // 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() return extractTarStream(gr, targetName, destPath) } // extractFromTar extracts a named file from an uncompressed tar archive // and streams it directly to destPath. func extractFromTar(archivePath, targetName, destPath string) error { f, err := os.Open(archivePath) if err != nil { return err } defer f.Close() 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. func extractTarStream(r io.Reader, targetName, destPath string) error { tr := tar.NewReader(r) for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return fmt.Errorf("failed to read tar: %w", err) } if !filepath.IsLocal(header.Name) { continue } if !header.FileInfo().Mode().IsRegular() { continue } if matchArchiveEntry(header.Name, targetName) { return writeStreamToFile(tr, destPath) } } return fmt.Errorf("binary %q not found in archive", targetName) } // copyPluginBinary copies a raw downloaded binary to destPath with 0755 mode. // Used when the downloaded artifact is not an archive. func copyPluginBinary(srcPath, destPath string) error { src, err := os.Open(srcPath) if err != nil { return fmt.Errorf("failed to open downloaded binary: %w", err) } defer src.Close() return writeStreamToFile(src, destPath) } // extractFromZip extracts a named file from a zip archive and streams it // directly to destPath. Non-regular entries (symlinks, devices, directories) // and entries with unsafe paths are skipped. 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.IsLocal(f.Name) { continue } if !f.FileInfo().Mode().IsRegular() { continue } if matchArchiveEntry(f.Name, targetName) { rc, err := f.Open() if err != nil { return fmt.Errorf("failed to open %q in zip: %w", targetName, err) } err = writeStreamToFile(rc, destPath) rc.Close() return err } } 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 { if closeErr := out.Close(); closeErr != nil { return fmt.Errorf("failed to write plugin binary: %w (also failed to close file: %v)", err, closeErr) } return fmt.Errorf("failed to write plugin binary: %w", err) } return out.Close() }