Implement plugin catalog and discovery system
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
This commit is contained in:
235
internal/plugin/install.go
Normal file
235
internal/plugin/install.go
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user