1
0
mirror of synced 2026-04-01 21:46:55 +00:00
Files
flux2/internal/plugin/discovery.go
Stefan Prodan 1db4e66099 Implement plugin catalog and discovery system
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2026-03-30 11:51:21 +03:00

196 lines
4.6 KiB
Go

/*
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 (
"os"
"path/filepath"
"runtime"
"strings"
)
const (
pluginPrefix = "flux-"
defaultDirName = "plugins"
defaultBaseDir = ".fluxcd"
envPluginDir = "FLUXCD_PLUGINS"
)
// reservedNames are command names that cannot be used as plugin names.
var reservedNames = map[string]bool{
"plugin": true,
"help": true,
}
// Plugin represents a discovered plugin binary.
type Plugin struct {
Name string // e.g., "operator" (derived from "flux-operator")
Path string // absolute path to binary
}
// Handler discovers and executes plugins. Uses dependency injection
// for testability.
type Handler struct {
ReadDir func(name string) ([]os.DirEntry, error)
Stat func(name string) (os.FileInfo, error)
GetEnv func(key string) string
HomeDir func() (string, error)
}
// NewHandler returns a Handler with production defaults.
func NewHandler() *Handler {
return &Handler{
ReadDir: os.ReadDir,
Stat: os.Stat,
GetEnv: os.Getenv,
HomeDir: os.UserHomeDir,
}
}
// Discover scans the plugin directory for executables matching flux-*.
// It skips builtins, reserved names, directories, non-executable files,
// and broken symlinks.
func (h *Handler) Discover(builtinNames []string) []Plugin {
dir := h.PluginDir()
if dir == "" {
return nil
}
entries, err := h.ReadDir(dir)
if err != nil {
return nil
}
builtins := make(map[string]bool, len(builtinNames))
for _, name := range builtinNames {
builtins[name] = true
}
var plugins []Plugin
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, pluginPrefix) {
continue
}
if entry.IsDir() {
continue
}
pluginName := pluginNameFromBinary(name)
if pluginName == "" {
continue
}
if reservedNames[pluginName] || builtins[pluginName] {
continue
}
fullPath := filepath.Join(dir, name)
// Use Stat to follow symlinks and check the target.
info, err := h.Stat(fullPath)
if err != nil {
// Broken symlink, permission denied, etc.
continue
}
if !info.Mode().IsRegular() {
continue
}
if !isExecutable(info) {
continue
}
plugins = append(plugins, Plugin{
Name: pluginName,
Path: fullPath,
})
}
return plugins
}
// PluginDir returns the plugin directory path. If FLUXCD_PLUGINS is set,
// returns that path. Otherwise returns ~/.fluxcd/plugins/.
// Does not create the directory — callers that write (install, update)
// should call EnsurePluginDir first.
func (h *Handler) PluginDir() string {
if dir := h.GetEnv(envPluginDir); dir != "" {
return dir
}
home, err := h.HomeDir()
if err != nil {
return ""
}
return filepath.Join(home, defaultBaseDir, defaultDirName)
}
// EnsurePluginDir creates the plugin directory if it doesn't exist
// and returns the path. Best-effort — ignores mkdir errors for
// read-only filesystems. User-managed directories (via $FLUXCD_PLUGINS)
// are not auto-created.
func (h *Handler) EnsurePluginDir() string {
if envDir := h.GetEnv(envPluginDir); envDir != "" {
return envDir
}
home, err := h.HomeDir()
if err != nil {
return ""
}
dir := filepath.Join(home, defaultBaseDir, defaultDirName)
_ = os.MkdirAll(dir, 0o755)
return dir
}
// pluginNameFromBinary extracts the plugin name from a binary filename.
// "flux-operator" → "operator", "flux-my-tool" → "my-tool".
// Returns empty string for invalid names.
func pluginNameFromBinary(filename string) string {
if !strings.HasPrefix(filename, pluginPrefix) {
return ""
}
name := strings.TrimPrefix(filename, pluginPrefix)
// On Windows, strip known extensions.
if runtime.GOOS == "windows" {
for _, ext := range []string{".exe", ".cmd", ".bat"} {
if strings.HasSuffix(strings.ToLower(name), ext) {
name = name[:len(name)-len(ext)]
break
}
}
}
if name == "" {
return ""
}
return name
}
// isExecutable checks if a file has the executable bit set.
// On Windows, this always returns true (executability is determined by extension).
func isExecutable(info os.FileInfo) bool {
if runtime.GOOS == "windows" {
return true
}
return info.Mode().Perm()&0o111 != 0
}