From 85f71190cceceb9e661c7c3b41ea2f8f0b974835 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 12 May 2026 00:15:29 +0300 Subject: [PATCH 1/5] refactor: split plugins.go into domain specific go files and move it to internal This is for consistency with other commands and their layouts Signed-off-by: Roman Berezkin --- cmd/d8/root.go | 6 +- cmd/plugins/plugins.go | 1018 ----------------- internal/plugins/cmd/contract.go | 82 ++ .../plugins/cmd}/flags/flags.go | 0 {cmd/plugins => internal/plugins/cmd}/init.go | 2 +- internal/plugins/cmd/install.go | 394 +++++++ internal/plugins/cmd/list.go | 268 +++++ .../plugins/cmd}/plugin.go | 0 internal/plugins/cmd/plugins.go | 103 ++ internal/plugins/cmd/remove.go | 100 ++ internal/plugins/cmd/update.go | 78 ++ internal/plugins/cmd/validators.go | 263 +++++ 12 files changed, 1292 insertions(+), 1022 deletions(-) delete mode 100644 cmd/plugins/plugins.go create mode 100644 internal/plugins/cmd/contract.go rename {cmd/plugins => internal/plugins/cmd}/flags/flags.go (100%) rename {cmd/plugins => internal/plugins/cmd}/init.go (98%) create mode 100644 internal/plugins/cmd/install.go create mode 100644 internal/plugins/cmd/list.go rename {cmd/plugins => internal/plugins/cmd}/plugin.go (100%) create mode 100644 internal/plugins/cmd/plugins.go create mode 100644 internal/plugins/cmd/remove.go create mode 100644 internal/plugins/cmd/update.go create mode 100644 internal/plugins/cmd/validators.go diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 3a4d921f..34a408c4 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -37,14 +37,14 @@ import ( dkplog "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse-cli/cmd/commands" - "github.com/deckhouse/deckhouse-cli/cmd/plugins" - "github.com/deckhouse/deckhouse-cli/cmd/plugins/flags" backup "github.com/deckhouse/deckhouse-cli/internal/backup/cmd" data "github.com/deckhouse/deckhouse-cli/internal/data/cmd" iam "github.com/deckhouse/deckhouse-cli/internal/iam/cmd" iamuser "github.com/deckhouse/deckhouse-cli/internal/iam/user/cmd" mirror "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd" - "github.com/deckhouse/deckhouse-cli/internal/network" + network "github.com/deckhouse/deckhouse-cli/internal/network" + plugins "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" status "github.com/deckhouse/deckhouse-cli/internal/status/cmd" system "github.com/deckhouse/deckhouse-cli/internal/system/cmd" "github.com/deckhouse/deckhouse-cli/internal/tools" diff --git a/cmd/plugins/plugins.go b/cmd/plugins/plugins.go deleted file mode 100644 index 544142b5..00000000 --- a/cmd/plugins/plugins.go +++ /dev/null @@ -1,1018 +0,0 @@ -/* -Copyright 2025 Flant JSC - -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 plugins - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "os/exec" - "path" - "path/filepath" - "strconv" - "strings" - - "github.com/Masterminds/semver/v3" - "github.com/spf13/cobra" - "sigs.k8s.io/yaml" - - dkplog "github.com/deckhouse/deckhouse/pkg/log" - client "github.com/deckhouse/deckhouse/pkg/registry" - - "github.com/deckhouse/deckhouse-cli/cmd/plugins/flags" - "github.com/deckhouse/deckhouse-cli/internal" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" -) - -type PluginsCommand struct { - service *service.PluginService - pluginRegistryClient client.Client - pluginDirectory string - - logger *dkplog.Logger -} - -// pluginDisplayInfo holds all information needed to display a plugin -type pluginDisplayInfo struct { - Name string - Version string - Description string -} - -// pluginsListData holds all data for the list command -type pluginsListData struct { - Installed []pluginDisplayInfo - Available []pluginDisplayInfo - RegistryError error -} - -func NewPluginsCommand(logger *dkplog.Logger) *PluginsCommand { - return &PluginsCommand{ - pluginDirectory: flags.DeckhousePluginsDir, - logger: logger, - } -} - -func NewCommand(logger *dkplog.Logger) *cobra.Command { - pc := NewPluginsCommand(logger) - - cmd := &cobra.Command{ - Use: "plugins", - Short: "Manage Deckhouse CLI plugins", - Hidden: true, - PersistentPreRun: func(_ *cobra.Command, _ []string) { - // init plugin services for subcommands after flags are parsed - pc.InitPluginServices() - - err := os.MkdirAll(flags.DeckhousePluginsDir+"/plugins", 0755) - // if permission failed - if errors.Is(err, os.ErrPermission) { - pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", slog.String("new_path", flags.DeckhousePluginsDir), dkplog.Err(err)) - - homeDir, err := os.UserHomeDir() - if err != nil { - logger.Debug("failed to receive home dir to create plugins dir", slog.String("error", err.Error())) - return - } - - pc.pluginDirectory = path.Join(homeDir, ".deckhouse-cli") - } - }, - } - - cmd.AddCommand(pc.pluginsListCommand()) - cmd.AddCommand(pc.pluginsContractCommand()) - cmd.AddCommand(pc.pluginsInstallCommand()) - cmd.AddCommand(pc.pluginsUpdateCommand()) - cmd.AddCommand(pc.pluginsRemoveCommand()) - - flags.AddFlags(cmd.PersistentFlags()) - - return cmd -} - -func (pc *PluginsCommand) pluginsListCommand() *cobra.Command { - var showInstalledOnly bool - var showAvailableOnly bool - - cmd := &cobra.Command{ - Use: "list", - Short: "List Deckhouse CLI plugins", - Long: "Display detailed information about installed plugins and available plugins from the registry", - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - // Prepare all data before printing - data := pc.preparePluginsListData(ctx, showInstalledOnly, showAvailableOnly) - - // Print all prepared data - pc.printPluginsList(data, showInstalledOnly, showAvailableOnly) - - return nil - }, - } - - cmd.Flags().BoolVar(&showInstalledOnly, "installed", false, "Show only installed plugins") - cmd.Flags().BoolVar(&showAvailableOnly, "available", false, "Show only available plugins from registry") - - return cmd -} - -// preparePluginsListData fetches and prepares all data needed for display -func (pc *PluginsCommand) preparePluginsListData(ctx context.Context, showInstalledOnly, showAvailableOnly bool) *pluginsListData { - data := &pluginsListData{ - Installed: []pluginDisplayInfo{}, - Available: []pluginDisplayInfo{}, - } - - // Fetch installed plugins if needed - if !showAvailableOnly { - installed, err := pc.fetchInstalledPlugins() - if err != nil { - pc.logger.Warn("Failed to fetch installed plugins", slog.String("error", err.Error())) - } else { - data.Installed = installed - } - } - - // Fetch available plugins from registry if needed - if !showInstalledOnly { - available, err := pc.fetchAvailablePlugins(ctx) - if err != nil { - pc.logger.Warn("Failed to fetch available plugins", slog.String("error", err.Error())) - data.RegistryError = err - } else { - data.Available = available - } - } - - return data -} - -// fetchInstalledPlugins retrieves installed plugins from filesystem -func (pc *PluginsCommand) fetchInstalledPlugins() ([]pluginDisplayInfo, error) { - plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) - if err != nil { - return nil, fmt.Errorf("failed to read plugins directory: %w", err) - } - - res := make([]pluginDisplayInfo, 0, len(plugins)) - - for _, plugin := range plugins { - version, err := pc.getInstalledPluginVersion(plugin.Name()) - if err != nil { - res = append(res, pluginDisplayInfo{ - Name: plugin.Name(), - Version: "ERROR", - Description: err.Error(), - }) - continue - } - - contract, err := pc.getInstalledPluginContract(plugin.Name()) - if err != nil { - res = append(res, pluginDisplayInfo{ - Name: plugin.Name(), - Version: version.Original(), - Description: "failed to get description", - }) - continue - } - - displayInfo := pluginDisplayInfo{ - Name: plugin.Name(), - Version: version.Original(), - Description: contract.Description, - } - - res = append(res, displayInfo) - } - - return res, nil -} - -func (pc *PluginsCommand) getInstalledPluginContract(pluginName string) (*internal.Plugin, error) { - contractFile := path.Join(pc.pluginDirectory, "cache", "contracts", pluginName+".json") - - file, err := os.Open(contractFile) - if err != nil { - return nil, fmt.Errorf("failed to read contract file: %w", err) - } - defer file.Close() - - contract := new(service.PluginContract) - dec := json.NewDecoder(file) - err = dec.Decode(contract) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal contract: %w", err) - } - - return service.ContractToDomain(contract), nil -} - -// fetchAvailablePlugins retrieves and prepares available plugins from registry -func (pc *PluginsCommand) fetchAvailablePlugins(ctx context.Context) ([]pluginDisplayInfo, error) { - pluginNames, err := pc.service.ListPlugins(ctx) - if err != nil { - pc.logger.Warn("Failed to list plugins", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to list plugins: %w", err) - } - - if len(pluginNames) == 0 { - return []pluginDisplayInfo{}, nil - } - - plugins := make([]pluginDisplayInfo, 0, len(pluginNames)) - - // Fetch contract for each plugin to get version and description - for _, pluginName := range pluginNames { - plugin := pluginDisplayInfo{ - Name: pluginName, - } - - // fetch versions to get latest version - latestVersion, err := pc.fetchLatestVersion(ctx, pluginName) - if err != nil { - return nil, fmt.Errorf("failed to fetch latest version: %w", err) - } - - // Get the latest version contract - contract, err := pc.service.GetPluginContract(ctx, pluginName, latestVersion.Original()) - if err != nil { - // Log the error for debugging - pc.logger.Warn("Failed to get plugin contract", - slog.String("plugin", pluginName), - slog.String("tag", latestVersion.Original()), - slog.String("error", err.Error())) - - // Show ERROR in version column and error description in description column - plugin.Version = "ERROR" - plugin.Description = "failed to get plugin contract" - } else { - plugin.Version = latestVersion.Original() - plugin.Description = contract.Description - - // Truncate description if too long - if len(plugin.Description) > 40 { - plugin.Description = plugin.Description[:37] + "..." - } - } - - plugins = append(plugins, plugin) - } - - return plugins, nil -} - -// findLatestVersion finds the latest version from a list of version strings -func (pc *PluginsCommand) findLatestVersion(versions []string) (*semver.Version, error) { - if len(versions) == 0 { - return nil, fmt.Errorf("no versions found") - } - - var latestVersion *semver.Version - - for _, version := range versions { - version, err := semver.NewVersion(version) - if err != nil { - continue - } - - if latestVersion == nil { - latestVersion = version - continue - } - - if latestVersion.LessThan(version) { - latestVersion = version - } - } - - if latestVersion == nil { - return nil, fmt.Errorf("no versions found") - } - - return latestVersion, nil -} - -// printPluginsList prints all prepared data -func (pc *PluginsCommand) printPluginsList(data *pluginsListData, showInstalledOnly, showAvailableOnly bool) { - // Print installed plugins section - if !showAvailableOnly { - pc.printInstalledSection(data) - } - - // Print available plugins section - if !showInstalledOnly { - pc.printAvailableSection(data) - } -} - -// printInstalledSection prints the installed plugins section -func (pc *PluginsCommand) printInstalledSection(data *pluginsListData) { - fmt.Println("Installed Plugins:") - fmt.Println("-------------------------------------------") - fmt.Printf("%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") - fmt.Println("-------------------------------------------") - - if len(data.Installed) == 0 { - fmt.Println("No plugins installed") - } else { - for _, plugin := range data.Installed { - fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) - } - } - - fmt.Println() - fmt.Printf("Total: %d plugin(s) installed\n", len(data.Installed)) - fmt.Println() -} - -// printAvailableSection prints the available plugins section -func (pc *PluginsCommand) printAvailableSection(data *pluginsListData) { - fmt.Println("Available Plugins in Registry:") - fmt.Println("-------------------------------------------") - - // Handle registry error - if data.RegistryError != nil { - fmt.Println() - fmt.Println("⚠ Unable to connect to plugin registry") - fmt.Println() - fmt.Println("The registry may not be accessible or catalog listing may be disabled.") - fmt.Println("You can still use specific plugins if you know their names:") - fmt.Println(" - Use 'plugins contract ' to view plugin details") - fmt.Println(" - Use 'plugins install ' to install a plugin") - return - } - - // Handle empty registry - if len(data.Available) == 0 { - fmt.Println("No plugins found in registry") - return - } - - // Print plugins table - fmt.Printf("%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") - fmt.Println("-------------------------------------------") - - for _, plugin := range data.Available { - fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) - } - - // Print summary - fmt.Println() - fmt.Printf("Total: %d plugin(s) available\n", len(data.Available)) - - fmt.Println() - fmt.Println("Use 'plugins contract ' to see detailed information about a plugin") - fmt.Println("Use 'plugins install ' to install a plugin") -} - -func (pc *PluginsCommand) pluginsContractCommand() *cobra.Command { - var version string - var useMajor int - - cmd := &cobra.Command{ - Use: "contract [plugin-name]", - Short: "Get the contract for a specific plugin", - Long: "Retrieve and display the contract specification for a specific plugin from the registry", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - pluginName := args[0] - ctx := cmd.Context() - - latestVersion, err := pc.fetchLatestVersion(ctx, pluginName) - if err != nil { - return fmt.Errorf("failed to fetch latest version: %w", err) - } - - tag := latestVersion.Original() - - pc.logger.Debug("Fetching contract for plugin", slog.String("plugin", pluginName), slog.String("tag", tag)) - - // Use service to get plugin contract - plugin, err := pc.service.GetPluginContract(ctx, pluginName, tag) - if err != nil { - pc.logger.Warn("Failed to get plugin contract", - slog.String("plugin", pluginName), - slog.String("tag", tag), - slog.String("error", err.Error())) - return fmt.Errorf("failed to get plugin contract: %w", err) - } - contract := service.DomainToContract(plugin) - - // Display contract - jsonBytes, err := json.Marshal(contract) - if err != nil { - return fmt.Errorf("failed to marshal contract to JSON: %w", err) - } - yamlBytes, err := yaml.JSONToYAML(jsonBytes) - if err != nil { - return fmt.Errorf("failed to convert JSON to YAML: %w", err) - } - fmt.Println(string(yamlBytes)) - - return nil - }, - } - - cmd.Flags().StringVar(&version, "version", "", "Specific version of the plugin contract to retrieve") - cmd.Flags().IntVar(&useMajor, "use-major", 0, "Use specific major version (e.g., 1, 2)") - - return cmd -} - -func (pc *PluginsCommand) pluginsInstallCommand() *cobra.Command { - var version string - var useMajor int - var resolvePluginsConflicts bool - - cmd := &cobra.Command{ - Use: "install [plugin-name]", - Short: "Install a Deckhouse CLI plugin", - Long: "Install a new plugin", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - pluginName := args[0] - ctx := cmd.Context() - - opts := []installPluginOption{ - installWithVersion(version), - installWithMajorVersion(useMajor), - } - - if resolvePluginsConflicts { - opts = append(opts, installWithResolvePluginsConflicts()) - } - - return pc.InstallPlugin(ctx, pluginName, opts...) - }, - } - - cmd.Flags().StringVar(&version, "version", "", "Specific version of the plugin to install") - cmd.Flags().IntVar(&useMajor, "use-major", -1, "Use specific major version (e.g., 1, 2)") - cmd.Flags().BoolVar(&resolvePluginsConflicts, "resolve-plugins-conflicts", false, "Resolve conflicts between plugins requirements") - - return cmd -} - -type installPluginOptions struct { - version string - majorVersion int - resolvePluginsConflicts bool -} - -type installPluginOption func(*installPluginOptions) - -func installWithMajorVersion(majorVersion int) installPluginOption { - return func(opts *installPluginOptions) { - opts.majorVersion = majorVersion - } -} - -func installWithVersion(version string) installPluginOption { - return func(opts *installPluginOptions) { - opts.version = version - } -} - -func installWithResolvePluginsConflicts() installPluginOption { - return func(opts *installPluginOptions) { - opts.resolvePluginsConflicts = true - } -} - -// function checks if plugin can be installed, creates folders layout and then installs plugin, creates symlink "current" and caches contract.json -// version - semver version string (e.g. v1.0.0), default: "" (use latest version) -// useMajor - major version to install, default: -1 (use latest major version) -// resolvePluginsConflicts - resolve conflicts between installed plugins, default: false -func (pc *PluginsCommand) InstallPlugin(ctx context.Context, pluginName string, opts ...installPluginOption) error { - // check if version is specified - var installVersion *semver.Version - - options := &installPluginOptions{ - majorVersion: -1, - } - - for _, opt := range opts { - opt(options) - } - - if options.version != "" { - var err error - installVersion, err = semver.NewVersion(options.version) - if err != nil { - return fmt.Errorf("failed to parse version: %w", err) - } - - return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) - } - - versions, err := pc.service.ListPluginTags(ctx, pluginName) - if err != nil { - pc.logger.Warn("Failed to list plugin tags", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return fmt.Errorf("failed to list plugin tags: %w", err) - } - - if options.majorVersion >= 0 { - versions = pc.filterMajorVersion(versions, options.majorVersion) - if len(versions) == 0 { - return fmt.Errorf("no versions found for major version: %d", options.majorVersion) - } - } - - installVersion, err = pc.findLatestVersion(versions) - if err != nil { - pc.logger.Warn("Failed to fetch latest version", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return fmt.Errorf("failed to fetch latest version: %w", err) - } - - return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) -} - -func (pc *PluginsCommand) installPlugin(ctx context.Context, pluginName string, version *semver.Version, resolvePluginsConflicts bool) error { - // create plugin directory if it doesn't exist - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin - pluginDir := path.Join(pc.pluginDirectory, "plugins", pluginName) - err := os.MkdirAll(pluginDir, 0755) - if err != nil { - return fmt.Errorf("failed to create plugin directory: %w", err) - } - - majorVersion := strconv.Itoa(int(version.Major())) - - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1 - versionDir := path.Join(pluginDir, "v"+majorVersion) - err = os.MkdirAll(versionDir, 0755) - if err != nil { - return fmt.Errorf("failed to create plugin directory: %w", err) - } - - // if locked - exit - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1/example-plugin.lock - lockFilePath := path.Join(versionDir, pluginName+".lock") - _, err = os.Stat(lockFilePath) - if err == nil { - // File exists, plugin is locked - return fmt.Errorf("plugin is locked by: %s", lockFilePath) - } - // Some other error occurred (permissions, etc.) - if !os.IsNotExist(err) { - return fmt.Errorf("failed to check lock file %s: %w", lockFilePath, err) - } - - // create lock lockFile - lockFile, err := os.Create(lockFilePath) - if err != nil { - return fmt.Errorf("failed to create lock file: %w", err) - } - lockFile.Close() - defer os.Remove(lockFilePath) - - tag := version.Original() - - fmt.Printf("Installing plugin: %s\n", pluginName) - fmt.Printf("Tag: %s\n", tag) - - // get contract - plugin, err := pc.service.GetPluginContract(ctx, pluginName, tag) - if err != nil { - return fmt.Errorf("failed to get plugin contract: %w", err) - } - - fmt.Printf("Plugin: %s %s\n", plugin.Name, plugin.Version) - fmt.Printf("Description: %s\n", plugin.Description) - - // validate requirements - pc.logger.Debug("validating requirements", slog.String("plugin", plugin.Name)) - failedConstraints, err := pc.validateRequirements(plugin) - if err != nil { - return fmt.Errorf("failed to validate requirements: %w", err) - } - if len(failedConstraints) > 0 && !resolvePluginsConflicts { - return fmt.Errorf("plugin requirements not satisfied") - } - if len(failedConstraints) > 0 && resolvePluginsConflicts { - // try to resolve conflicts - err = pc.resolvePluginConflicts(ctx, failedConstraints) - if err != nil { - return fmt.Errorf("failed to resolve conflicts: %w", err) - } - } - - // check if binary exists (if yes - rename it to .old) - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1/example-plugin - pluginBinaryPath := path.Join(versionDir, pluginName) - pluginBinaryInfo, err := os.Stat(pluginBinaryPath) - if err == nil && !pluginBinaryInfo.IsDir() { - err = os.Rename(pluginBinaryPath, pluginBinaryPath+".old") - if err != nil { - return fmt.Errorf("failed to save old version: %w", err) - } - } - - // extract plugin to installation directory - fmt.Printf("Installing to: %s\n", pluginBinaryPath) - - fmt.Println("Downloading and extracting plugin...") - err = pc.service.ExtractPlugin(ctx, pluginName, tag, pluginBinaryPath) - if err != nil { - pc.logger.Warn("Failed to extract plugin", - slog.String("plugin", pluginName), - slog.String("tag", tag), - slog.String("destination", pluginBinaryPath), - slog.String("error", err.Error())) - return fmt.Errorf("failed to extract plugin: %w", err) - } - - // symlink "current" to the installed version (delete old symlink if exists) - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/current - currentSymlink := path.Join(pluginDir, "current") - _ = os.Remove(currentSymlink) - - absPath, err := filepath.Abs(pluginBinaryPath) - if err != nil { - return fmt.Errorf("failed to compute absolute path: %w", err) - } - - err = os.Symlink(absPath, currentSymlink) - if err != nil { - return fmt.Errorf("failed to create symlink: %w", err) - } - - // cache contract - // example path: /opt/deckhouse/lib/deckhouse-cli/cache/contracts - contractDir := path.Join(pc.pluginDirectory, "cache", "contracts") - err = os.MkdirAll(contractDir, 0755) - if err != nil { - return fmt.Errorf("failed to create contract directory: %w", err) - } - - // example path: /opt/deckhouse/lib/deckhouse-cli/cache/contracts/example-plugin.json - contractFilePath := path.Join(contractDir, pluginName+".json") - contract := service.DomainToContract(plugin) - contractFile, err := os.OpenFile(contractFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return fmt.Errorf("failed to open contract file: %w", err) - } - defer contractFile.Close() - - enc := json.NewEncoder(contractFile) - enc.SetIndent("", " ") - enc.SetEscapeHTML(false) - - err = enc.Encode(contract) - if err != nil { - return fmt.Errorf("failed to cache contract: %w", err) - } - - fmt.Printf("✓ Plugin '%s' successfully installed!\n", pluginName) - return nil -} - -func (pc *PluginsCommand) filterMajorVersion(versions []string, majorVersion int) []string { - res := make([]string, 0, 1) - - for _, ver := range versions { - version, err := semver.NewVersion(ver) - if err != nil { - continue - } - - if version.Major() == uint64(majorVersion) { - res = append(res, ver) - } - } - - return res -} - -func (pc *PluginsCommand) fetchLatestVersion(ctx context.Context, pluginName string) (*semver.Version, error) { - versions, err := pc.service.ListPluginTags(ctx, pluginName) - if err != nil { - pc.logger.Warn("Failed to list plugin tags", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to list plugin tags: %w", err) - } - - latestVersion, err := pc.findLatestVersion(versions) - if err != nil { - pc.logger.Warn("Failed to fetch latest version", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to fetch latest version: %w", err) - } - return latestVersion, nil -} - -func (pc *PluginsCommand) resolvePluginConflicts(ctx context.Context, failedConstraints FailedConstraints) error { - // for each failed constraint, try to install the plugin - for pluginName := range failedConstraints { - pc.logger.Debug("resolving plugin conflict", slog.String("plugin", pluginName)) - - err := pc.InstallPlugin(ctx, pluginName, installWithResolvePluginsConflicts()) - if err != nil { - return fmt.Errorf("failed to install plugin: %w", err) - } - } - - return nil -} - -func (pc *PluginsCommand) pluginsUpdateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "update [plugin-name]", - Short: "Update an installed plugin", - Long: "Update a specific plugin to its latest available version", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - pluginName := args[0] - fmt.Printf("Updating plugin: %s\n", pluginName) - - ctx := cmd.Context() - - return pc.InstallPlugin(ctx, pluginName) - }, - } - - // Add subcommands - cmd.AddCommand(pc.pluginsUpdateAllCommand()) - - return cmd -} - -func (pc *PluginsCommand) pluginsUpdateAllCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "all", - Short: "Update all installed plugins", - Long: "Update all installed plugins to their latest available versions", - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - fmt.Println("Updating all installed plugins...") - - plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) - if err != nil { - return fmt.Errorf("failed to read plugins directory: %w", err) - } - - for _, plugin := range plugins { - err := pc.InstallPlugin(ctx, plugin.Name()) - if err != nil { - return fmt.Errorf("failed to update plugin: %w", err) - } - } - - fmt.Println("✓ All plugins updated successfully!") - - return nil - }, - } - - return cmd -} - -func (pc *PluginsCommand) pluginsRemoveCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "remove [plugin-name]", - Aliases: []string{"uninstall", "delete"}, - Short: "Remove an installed plugin", - Long: "Remove a specific plugin from the Deckhouse CLI", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - pluginName := args[0] - fmt.Printf("Removing plugin: %s\n", pluginName) - - pluginDir := path.Join(pc.pluginDirectory, "plugins", pluginName) - fmt.Printf("Removing plugin from: %s\n", pluginDir) - - err := os.RemoveAll(pluginDir) - if err != nil { - return fmt.Errorf("failed to remove plugin directory: %w", err) - } - - fmt.Println("Cleaning up plugin files...") - - os.Remove(path.Join(pc.pluginDirectory, "cache", "contracts", pluginName+".json")) - - fmt.Printf("✓ Plugin '%s' successfully removed!\n", pluginName) - - return nil - }, - } - - // Add subcommands - cmd.AddCommand(pc.pluginsRemoveAllCommand()) - - return cmd -} - -func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "all", - Short: "Remove all installed plugins", - Long: "Remove all plugins from the Deckhouse CLI at once", - RunE: func(_ *cobra.Command, _ []string) error { - fmt.Println("Removing all installed plugins...") - - plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) - if err != nil { - return fmt.Errorf("failed to read plugins directory: %w", err) - } - - fmt.Println("Found", len(plugins), "plugins to remove:") - - for _, plugin := range plugins { - pluginDir := path.Join(pc.pluginDirectory, "plugins", plugin.Name()) - fmt.Printf("Removing plugin from: %s\n", pluginDir) - - err := os.RemoveAll(pluginDir) - if err != nil { - return fmt.Errorf("failed to remove plugin directory: %w", err) - } - - fmt.Printf("Cleaning up plugin files for '%s'...\n", plugin.Name()) - - os.Remove(path.Join(pc.pluginDirectory, "cache", "contracts", plugin.Name()+".json")) - - fmt.Printf("✓ Plugin '%s' successfully removed!\n", plugin.Name()) - } - - fmt.Println("✓ All plugins successfully removed!") - - return nil - }, - } - - return cmd -} - -func (pc *PluginsCommand) getInstalledPluginVersion(pluginName string) (*semver.Version, error) { - pluginBinaryPath := path.Join(pc.pluginDirectory, "plugins", pluginName, "current") - cmd := exec.Command(pluginBinaryPath, "--version") - - output, err := cmd.Output() - if err != nil { - pc.logger.Warn("failed to call plugin with '--version'", slog.String("plugin", pluginName), slog.String("error", err.Error())) - - // try to call plugin with "version" command - // this is for compatibility with plugins that don't support "--version" - cmd = exec.Command(pluginBinaryPath, "version") - - output, err = cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to call plugin: %w", err) - } - } - - version, err := semver.NewVersion(strings.TrimSpace(string(output))) - if err != nil { - return nil, fmt.Errorf("failed to parse version: %w", err) - } - - return version, nil -} - -// map of plugin name to failed constraints -type FailedConstraints map[string]*semver.Constraints - -func (pc *PluginsCommand) validateRequirements(plugin *internal.Plugin) (FailedConstraints, error) { - // validate plugin requirements - pc.logger.Debug("validating plugin requirements", slog.String("plugin", plugin.Name)) - - err := pc.validatePluginConflicts(plugin) - if err != nil { - return nil, fmt.Errorf("plugin conflicts: %w", err) - } - - failedConstraints, err := pc.validatePluginRequirement(plugin) - if err != nil { - return nil, fmt.Errorf("plugin requirements: %w", err) - } - - // validate module requirements - pc.logger.Debug("validating module requirements", slog.String("plugin", plugin.Name)) - - err = pc.validateModuleRequirement(plugin) - if err != nil { - return nil, fmt.Errorf("module requirements: %w", err) - } - - return failedConstraints, nil -} - -// check that installing version not make conflict with existing plugins requirements -func (pc *PluginsCommand) validatePluginConflicts(plugin *internal.Plugin) error { - contractDir, err := os.ReadDir(path.Join(pc.pluginDirectory, "cache", "contracts")) - // if no plugins installed, nothing to conflict - if err != nil && errors.Is(err, os.ErrNotExist) { - pc.logger.Debug("failed to read contract directory", slog.String("error", err.Error())) - return nil - } - if err != nil { - return fmt.Errorf("failed to read contract directory: %w", err) - } - - for _, contractFile := range contractDir { - pluginName := strings.TrimSuffix(contractFile.Name(), ".json") - - contract, err := pc.getInstalledPluginContract(pluginName) - if err != nil { - return fmt.Errorf("failed to get installed plugin contract: %w", err) - } - - err = validatePluginConflict(plugin, contract) - if err != nil { - return fmt.Errorf("validate plugin conflict: %w", err) - } - } - - return nil -} - -func validatePluginConflict(plugin *internal.Plugin, installedPlugin *internal.Plugin) error { - for _, requirement := range installedPlugin.Requirements.Plugins { - // installed plugin requirement is the same as the plugin we are validating - if requirement.Name == plugin.Name { - constraint, err := semver.NewConstraint(requirement.Constraint) - if err != nil { - return fmt.Errorf("failed to parse constraint: %w", err) - } - - version, err := semver.NewVersion(installedPlugin.Version) - if err != nil { - return fmt.Errorf("failed to parse version: %w", err) - } - - if !constraint.Check(version) { - return fmt.Errorf("installing plugin %s %s will make conflict with existing plugin %s %s", - plugin.Name, - plugin.Version, - installedPlugin.Name, - constraint.String()) - } - } - } - - return nil -} - -func (pc *PluginsCommand) validatePluginRequirement(plugin *internal.Plugin) (FailedConstraints, error) { - result := make(FailedConstraints) - - for _, pluginRequirement := range plugin.Requirements.Plugins { - // check if plugin is installed - installed, err := pc.checkInstalled(pluginRequirement.Name) - if err != nil { - return nil, fmt.Errorf("failed to check if plugin is installed: %w", err) - } - if !installed { - result[pluginRequirement.Name] = nil - continue - } - - // check constraint - if pluginRequirement.Constraint != "" { - installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name) - if err != nil { - return nil, fmt.Errorf("failed to get installed version: %w", err) - } - - constraint, err := semver.NewConstraint(pluginRequirement.Constraint) - if err != nil { - return nil, fmt.Errorf("failed to parse constraint: %w", err) - } - - if !constraint.Check(installedVersion) { - pc.logger.Warn("plugin requirement not satisfied", - slog.String("plugin", plugin.Name), - slog.String("requirement", pluginRequirement.Name), - slog.String("constraint", pluginRequirement.Constraint), - slog.String("installedVersion", installedVersion.Original())) - - result[pluginRequirement.Name] = constraint - } - } - } - - return result, nil -} - -func (pc *PluginsCommand) validateModuleRequirement(_ *internal.Plugin) error { - // TODO: Implement module requirement validation - return nil -} diff --git a/internal/plugins/cmd/contract.go b/internal/plugins/cmd/contract.go new file mode 100644 index 00000000..03d070eb --- /dev/null +++ b/internal/plugins/cmd/contract.go @@ -0,0 +1,82 @@ +/* +Copyright 2025 Flant JSC + +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 plugins + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +func (pc *PluginsCommand) pluginsContractCommand() *cobra.Command { + var version string + var useMajor int + + cmd := &cobra.Command{ + Use: "contract [plugin-name]", + Short: "Get the contract for a specific plugin", + Long: "Retrieve and display the contract specification for a specific plugin from the registry", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pluginName := args[0] + ctx := cmd.Context() + + latestVersion, err := pc.fetchLatestVersion(ctx, pluginName) + if err != nil { + return fmt.Errorf("failed to fetch latest version: %w", err) + } + + tag := latestVersion.Original() + + pc.logger.Debug("Fetching contract for plugin", slog.String("plugin", pluginName), slog.String("tag", tag)) + + // Use service to get plugin contract + plugin, err := pc.service.GetPluginContract(ctx, pluginName, tag) + if err != nil { + pc.logger.Warn("Failed to get plugin contract", + slog.String("plugin", pluginName), + slog.String("tag", tag), + slog.String("error", err.Error())) + return fmt.Errorf("failed to get plugin contract: %w", err) + } + contract := service.DomainToContract(plugin) + + // Display contract + jsonBytes, err := json.Marshal(contract) + if err != nil { + return fmt.Errorf("failed to marshal contract to JSON: %w", err) + } + yamlBytes, err := yaml.JSONToYAML(jsonBytes) + if err != nil { + return fmt.Errorf("failed to convert JSON to YAML: %w", err) + } + fmt.Println(string(yamlBytes)) + + return nil + }, + } + + cmd.Flags().StringVar(&version, "version", "", "Specific version of the plugin contract to retrieve") + cmd.Flags().IntVar(&useMajor, "use-major", 0, "Use specific major version (e.g., 1, 2)") + + return cmd +} diff --git a/cmd/plugins/flags/flags.go b/internal/plugins/cmd/flags/flags.go similarity index 100% rename from cmd/plugins/flags/flags.go rename to internal/plugins/cmd/flags/flags.go diff --git a/cmd/plugins/init.go b/internal/plugins/cmd/init.go similarity index 98% rename from cmd/plugins/init.go rename to internal/plugins/cmd/init.go index cf9aa0d2..d719dd81 100644 --- a/cmd/plugins/init.go +++ b/internal/plugins/cmd/init.go @@ -25,7 +25,7 @@ import ( dkplog "github.com/deckhouse/deckhouse/pkg/log" regclient "github.com/deckhouse/deckhouse/pkg/registry/client" - d8flags "github.com/deckhouse/deckhouse-cli/cmd/plugins/flags" + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" "github.com/deckhouse/deckhouse-cli/pkg/registry/service" ) diff --git a/internal/plugins/cmd/install.go b/internal/plugins/cmd/install.go new file mode 100644 index 00000000..fceeed33 --- /dev/null +++ b/internal/plugins/cmd/install.go @@ -0,0 +1,394 @@ +/* +Copyright 2025 Flant JSC + +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 plugins + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path" + "path/filepath" + "strconv" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +func (pc *PluginsCommand) pluginsInstallCommand() *cobra.Command { + var version string + var useMajor int + var resolvePluginsConflicts bool + + cmd := &cobra.Command{ + Use: "install [plugin-name]", + Short: "Install a Deckhouse CLI plugin", + Long: "Install a new plugin", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pluginName := args[0] + ctx := cmd.Context() + + opts := []installPluginOption{ + installWithVersion(version), + installWithMajorVersion(useMajor), + } + + if resolvePluginsConflicts { + opts = append(opts, installWithResolvePluginsConflicts()) + } + + return pc.InstallPlugin(ctx, pluginName, opts...) + }, + } + + cmd.Flags().StringVar(&version, "version", "", "Specific version of the plugin to install") + cmd.Flags().IntVar(&useMajor, "use-major", -1, "Use specific major version (e.g., 1, 2)") + cmd.Flags().BoolVar(&resolvePluginsConflicts, "resolve-plugins-conflicts", false, "Resolve conflicts between plugins requirements") + + return cmd +} + +type installPluginOptions struct { + version string + majorVersion int + resolvePluginsConflicts bool +} + +type installPluginOption func(*installPluginOptions) + +func installWithMajorVersion(majorVersion int) installPluginOption { + return func(opts *installPluginOptions) { + opts.majorVersion = majorVersion + } +} + +func installWithVersion(version string) installPluginOption { + return func(opts *installPluginOptions) { + opts.version = version + } +} + +func installWithResolvePluginsConflicts() installPluginOption { + return func(opts *installPluginOptions) { + opts.resolvePluginsConflicts = true + } +} + +// InstallPlugin checks if plugin can be installed, creates folders layout and then installs plugin, creates symlink "current" and caches contract.json. +// version - semver version string (e.g. v1.0.0), default: "" (use latest version) +// useMajor - major version to install, default: -1 (use latest major version) +// resolvePluginsConflicts - resolve conflicts between installed plugins, default: false +func (pc *PluginsCommand) InstallPlugin(ctx context.Context, pluginName string, opts ...installPluginOption) error { + // check if version is specified + var installVersion *semver.Version + + options := &installPluginOptions{ + majorVersion: -1, + } + + for _, opt := range opts { + opt(options) + } + + if options.version != "" { + var err error + installVersion, err = semver.NewVersion(options.version) + if err != nil { + return fmt.Errorf("failed to parse version: %w", err) + } + + return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) + } + + versions, err := pc.service.ListPluginTags(ctx, pluginName) + if err != nil { + pc.logger.Warn("Failed to list plugin tags", slog.String("plugin", pluginName), slog.String("error", err.Error())) + return fmt.Errorf("failed to list plugin tags: %w", err) + } + + if options.majorVersion >= 0 { + versions = pc.filterMajorVersion(versions, options.majorVersion) + if len(versions) == 0 { + return fmt.Errorf("no versions found for major version: %d", options.majorVersion) + } + } + + installVersion, err = pc.findLatestVersion(versions) + if err != nil { + pc.logger.Warn("Failed to fetch latest version", slog.String("plugin", pluginName), slog.String("error", err.Error())) + return fmt.Errorf("failed to fetch latest version: %w", err) + } + + return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) +} + +// pluginPaths bundles the three filesystem locations an install operates on. +// Created once by preparePluginDirs and threaded through the install pipeline. +type pluginPaths struct { + pluginDir string // /plugins/ + versionDir string // /plugins//v + binaryPath string // /plugins//v/ +} + +// installPlugin is the install pipeline orchestrator. Each step delegates to +// a focused helper below; the order is significant and stays identical to +// the pre-split monolith. +func (pc *PluginsCommand) installPlugin(ctx context.Context, pluginName string, version *semver.Version, resolvePluginsConflicts bool) error { + paths, err := pc.preparePluginDirs(pluginName, version) + if err != nil { + return err + } + + release, err := pc.acquireInstallLock(paths.versionDir, pluginName) + if err != nil { + return err + } + defer release() + + plugin, err := pc.fetchAndDisplayContract(ctx, pluginName, version) + if err != nil { + return err + } + + if err := pc.validateAndResolveConflicts(ctx, plugin, resolvePluginsConflicts); err != nil { + return err + } + + if err := pc.backupOldBinary(paths.binaryPath); err != nil { + return err + } + + if err := pc.downloadAndExtract(ctx, pluginName, version, paths.binaryPath); err != nil { + return err + } + + if err := pc.linkCurrent(paths); err != nil { + return err + } + + if err := pc.cacheContract(pluginName, plugin); err != nil { + return err + } + + fmt.Printf("✓ Plugin '%s' successfully installed!\n", pluginName) + return nil +} + +// preparePluginDirs creates plugins//v on disk and returns the +// three paths derived from used by the rest of the pipeline. +func (pc *PluginsCommand) preparePluginDirs(pluginName string, version *semver.Version) (pluginPaths, error) { + // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin + pluginDir := path.Join(pc.pluginDirectory, "plugins", pluginName) + if err := os.MkdirAll(pluginDir, 0755); err != nil { + return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) + } + + majorVersion := strconv.Itoa(int(version.Major())) + + // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1 + versionDir := path.Join(pluginDir, "v"+majorVersion) + if err := os.MkdirAll(versionDir, 0755); err != nil { + return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) + } + + // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1/example-plugin + return pluginPaths{ + pluginDir: pluginDir, + versionDir: versionDir, + binaryPath: path.Join(versionDir, pluginName), + }, nil +} + +// acquireInstallLock creates /.lock; if the lock already +// exists, returns an error without touching it. The caller must invoke the +// returned release func when finished (typically via defer). +func (pc *PluginsCommand) acquireInstallLock(versionDir, pluginName string) (func(), error) { + // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1/example-plugin.lock + lockFilePath := path.Join(versionDir, pluginName+".lock") + + _, err := os.Stat(lockFilePath) + if err == nil { + // File exists, plugin is locked + return nil, fmt.Errorf("plugin is locked by: %s", lockFilePath) + } + // Some other error occurred (permissions, etc.) + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to check lock file %s: %w", lockFilePath, err) + } + + lockFile, err := os.Create(lockFilePath) + if err != nil { + return nil, fmt.Errorf("failed to create lock file: %w", err) + } + lockFile.Close() + + return func() { os.Remove(lockFilePath) }, nil +} + +// fetchAndDisplayContract pulls the contract for from +// the registry and prints the installing-plugin banner. +func (pc *PluginsCommand) fetchAndDisplayContract(ctx context.Context, pluginName string, version *semver.Version) (*internal.Plugin, error) { + tag := version.Original() + + fmt.Printf("Installing plugin: %s\n", pluginName) + fmt.Printf("Tag: %s\n", tag) + + plugin, err := pc.service.GetPluginContract(ctx, pluginName, tag) + if err != nil { + return nil, fmt.Errorf("failed to get plugin contract: %w", err) + } + + fmt.Printf("Plugin: %s %s\n", plugin.Name, plugin.Version) + fmt.Printf("Description: %s\n", plugin.Description) + + return plugin, nil +} + +// validateAndResolveConflicts runs validateRequirements; if requirements are +// not satisfied and resolvePluginsConflicts is true, attempts to fix them +// recursively; otherwise returns an error. +func (pc *PluginsCommand) validateAndResolveConflicts(ctx context.Context, plugin *internal.Plugin, resolvePluginsConflicts bool) error { + pc.logger.Debug("validating requirements", slog.String("plugin", plugin.Name)) + + failedConstraints, err := pc.validateRequirements(plugin) + if err != nil { + return fmt.Errorf("failed to validate requirements: %w", err) + } + if len(failedConstraints) > 0 && !resolvePluginsConflicts { + return fmt.Errorf("plugin requirements not satisfied") + } + if len(failedConstraints) > 0 && resolvePluginsConflicts { + if err := pc.resolvePluginConflicts(ctx, failedConstraints); err != nil { + return fmt.Errorf("failed to resolve conflicts: %w", err) + } + } + return nil +} + +// backupOldBinary renames an already-installed binary to .old so +// a fresh extract has a clean destination. No-op if no binary present yet. +func (pc *PluginsCommand) backupOldBinary(binaryPath string) error { + info, err := os.Stat(binaryPath) + if err != nil || info.IsDir() { + return nil + } + if err := os.Rename(binaryPath, binaryPath+".old"); err != nil { + return fmt.Errorf("failed to save old version: %w", err) + } + return nil +} + +// downloadAndExtract pulls the plugin image tag and writes the embedded +// binary to . +func (pc *PluginsCommand) downloadAndExtract(ctx context.Context, pluginName string, version *semver.Version, binaryPath string) error { + tag := version.Original() + fmt.Printf("Installing to: %s\n", binaryPath) + fmt.Println("Downloading and extracting plugin...") + + if err := pc.service.ExtractPlugin(ctx, pluginName, tag, binaryPath); err != nil { + pc.logger.Warn("Failed to extract plugin", + slog.String("plugin", pluginName), + slog.String("tag", tag), + slog.String("destination", binaryPath), + slog.String("error", err.Error())) + return fmt.Errorf("failed to extract plugin: %w", err) + } + return nil +} + +// linkCurrent (re)points /current to the freshly installed binary +// using an absolute target path. +func (pc *PluginsCommand) linkCurrent(paths pluginPaths) error { + // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/current + currentSymlink := path.Join(paths.pluginDir, "current") + _ = os.Remove(currentSymlink) + + absPath, err := filepath.Abs(paths.binaryPath) + if err != nil { + return fmt.Errorf("failed to compute absolute path: %w", err) + } + + if err := os.Symlink(absPath, currentSymlink); err != nil { + return fmt.Errorf("failed to create symlink: %w", err) + } + return nil +} + +// cacheContract writes the plugin contract JSON to +// /cache/contracts/.json for later lookups by +// validatePluginConflicts and `d8 plugins list`. +func (pc *PluginsCommand) cacheContract(pluginName string, plugin *internal.Plugin) error { + // example path: /opt/deckhouse/lib/deckhouse-cli/cache/contracts + contractDir := path.Join(pc.pluginDirectory, "cache", "contracts") + if err := os.MkdirAll(contractDir, 0755); err != nil { + return fmt.Errorf("failed to create contract directory: %w", err) + } + + // example path: /opt/deckhouse/lib/deckhouse-cli/cache/contracts/example-plugin.json + contractFilePath := path.Join(contractDir, pluginName+".json") + contract := service.DomainToContract(plugin) + contractFile, err := os.OpenFile(contractFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open contract file: %w", err) + } + defer contractFile.Close() + + enc := json.NewEncoder(contractFile) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + + if err := enc.Encode(contract); err != nil { + return fmt.Errorf("failed to cache contract: %w", err) + } + return nil +} + +func (pc *PluginsCommand) filterMajorVersion(versions []string, majorVersion int) []string { + res := make([]string, 0, 1) + + for _, ver := range versions { + version, err := semver.NewVersion(ver) + if err != nil { + continue + } + + if version.Major() == uint64(majorVersion) { + res = append(res, ver) + } + } + + return res +} + +func (pc *PluginsCommand) resolvePluginConflicts(ctx context.Context, failedConstraints FailedConstraints) error { + // for each failed constraint, try to install the plugin + for pluginName := range failedConstraints { + pc.logger.Debug("resolving plugin conflict", slog.String("plugin", pluginName)) + + err := pc.InstallPlugin(ctx, pluginName, installWithResolvePluginsConflicts()) + if err != nil { + return fmt.Errorf("failed to install plugin: %w", err) + } + } + + return nil +} diff --git a/internal/plugins/cmd/list.go b/internal/plugins/cmd/list.go new file mode 100644 index 00000000..9ac6c04e --- /dev/null +++ b/internal/plugins/cmd/list.go @@ -0,0 +1,268 @@ +/* +Copyright 2025 Flant JSC + +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 plugins + +import ( + "context" + "fmt" + "log/slog" + "os" + "path" + + "github.com/spf13/cobra" +) + +// pluginDisplayInfo holds all information needed to display a plugin +type pluginDisplayInfo struct { + Name string + Version string + Description string +} + +// pluginsListData holds all data for the list command +type pluginsListData struct { + Installed []pluginDisplayInfo + Available []pluginDisplayInfo + RegistryError error +} + +func (pc *PluginsCommand) pluginsListCommand() *cobra.Command { + var showInstalledOnly bool + var showAvailableOnly bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List Deckhouse CLI plugins", + Long: "Display detailed information about installed plugins and available plugins from the registry", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + // Prepare all data before printing + data := pc.preparePluginsListData(ctx, showInstalledOnly, showAvailableOnly) + + // Print all prepared data + pc.printPluginsList(data, showInstalledOnly, showAvailableOnly) + + return nil + }, + } + + cmd.Flags().BoolVar(&showInstalledOnly, "installed", false, "Show only installed plugins") + cmd.Flags().BoolVar(&showAvailableOnly, "available", false, "Show only available plugins from registry") + + return cmd +} + +// preparePluginsListData fetches and prepares all data needed for display +func (pc *PluginsCommand) preparePluginsListData(ctx context.Context, showInstalledOnly, showAvailableOnly bool) *pluginsListData { + data := &pluginsListData{ + Installed: []pluginDisplayInfo{}, + Available: []pluginDisplayInfo{}, + } + + // Fetch installed plugins if needed + if !showAvailableOnly { + installed, err := pc.fetchInstalledPlugins() + if err != nil { + pc.logger.Warn("Failed to fetch installed plugins", slog.String("error", err.Error())) + } else { + data.Installed = installed + } + } + + // Fetch available plugins from registry if needed + if !showInstalledOnly { + available, err := pc.fetchAvailablePlugins(ctx) + if err != nil { + pc.logger.Warn("Failed to fetch available plugins", slog.String("error", err.Error())) + data.RegistryError = err + } else { + data.Available = available + } + } + + return data +} + +// fetchInstalledPlugins retrieves installed plugins from filesystem +func (pc *PluginsCommand) fetchInstalledPlugins() ([]pluginDisplayInfo, error) { + plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) + if err != nil { + return nil, fmt.Errorf("failed to read plugins directory: %w", err) + } + + res := make([]pluginDisplayInfo, 0, len(plugins)) + + for _, plugin := range plugins { + version, err := pc.getInstalledPluginVersion(plugin.Name()) + if err != nil { + res = append(res, pluginDisplayInfo{ + Name: plugin.Name(), + Version: "ERROR", + Description: err.Error(), + }) + continue + } + + contract, err := pc.getInstalledPluginContract(plugin.Name()) + if err != nil { + res = append(res, pluginDisplayInfo{ + Name: plugin.Name(), + Version: version.Original(), + Description: "failed to get description", + }) + continue + } + + displayInfo := pluginDisplayInfo{ + Name: plugin.Name(), + Version: version.Original(), + Description: contract.Description, + } + + res = append(res, displayInfo) + } + + return res, nil +} + +// fetchAvailablePlugins retrieves and prepares available plugins from registry +func (pc *PluginsCommand) fetchAvailablePlugins(ctx context.Context) ([]pluginDisplayInfo, error) { + pluginNames, err := pc.service.ListPlugins(ctx) + if err != nil { + pc.logger.Warn("Failed to list plugins", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to list plugins: %w", err) + } + + if len(pluginNames) == 0 { + return []pluginDisplayInfo{}, nil + } + + plugins := make([]pluginDisplayInfo, 0, len(pluginNames)) + + // Fetch contract for each plugin to get version and description + for _, pluginName := range pluginNames { + plugin := pluginDisplayInfo{ + Name: pluginName, + } + + // fetch versions to get latest version + latestVersion, err := pc.fetchLatestVersion(ctx, pluginName) + if err != nil { + return nil, fmt.Errorf("failed to fetch latest version: %w", err) + } + + // Get the latest version contract + contract, err := pc.service.GetPluginContract(ctx, pluginName, latestVersion.Original()) + if err != nil { + // Log the error for debugging + pc.logger.Warn("Failed to get plugin contract", + slog.String("plugin", pluginName), + slog.String("tag", latestVersion.Original()), + slog.String("error", err.Error())) + + // Show ERROR in version column and error description in description column + plugin.Version = "ERROR" + plugin.Description = "failed to get plugin contract" + } else { + plugin.Version = latestVersion.Original() + plugin.Description = contract.Description + + // Truncate description if too long + if len(plugin.Description) > 40 { + plugin.Description = plugin.Description[:37] + "..." + } + } + + plugins = append(plugins, plugin) + } + + return plugins, nil +} + +// printPluginsList prints all prepared data +func (pc *PluginsCommand) printPluginsList(data *pluginsListData, showInstalledOnly, showAvailableOnly bool) { + // Print installed plugins section + if !showAvailableOnly { + pc.printInstalledSection(data) + } + + // Print available plugins section + if !showInstalledOnly { + pc.printAvailableSection(data) + } +} + +// printInstalledSection prints the installed plugins section +func (pc *PluginsCommand) printInstalledSection(data *pluginsListData) { + fmt.Println("Installed Plugins:") + fmt.Println("-------------------------------------------") + fmt.Printf("%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") + fmt.Println("-------------------------------------------") + + if len(data.Installed) == 0 { + fmt.Println("No plugins installed") + } else { + for _, plugin := range data.Installed { + fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) + } + } + + fmt.Println() + fmt.Printf("Total: %d plugin(s) installed\n", len(data.Installed)) + fmt.Println() +} + +// printAvailableSection prints the available plugins section +func (pc *PluginsCommand) printAvailableSection(data *pluginsListData) { + fmt.Println("Available Plugins in Registry:") + fmt.Println("-------------------------------------------") + + // Handle registry error + if data.RegistryError != nil { + fmt.Println() + fmt.Println("⚠ Unable to connect to plugin registry") + fmt.Println() + fmt.Println("The registry may not be accessible or catalog listing may be disabled.") + fmt.Println("You can still use specific plugins if you know their names:") + fmt.Println(" - Use 'plugins contract ' to view plugin details") + fmt.Println(" - Use 'plugins install ' to install a plugin") + return + } + + // Handle empty registry + if len(data.Available) == 0 { + fmt.Println("No plugins found in registry") + return + } + + // Print plugins table + fmt.Printf("%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") + fmt.Println("-------------------------------------------") + + for _, plugin := range data.Available { + fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) + } + + // Print summary + fmt.Println() + fmt.Printf("Total: %d plugin(s) available\n", len(data.Available)) + + fmt.Println() + fmt.Println("Use 'plugins contract ' to see detailed information about a plugin") + fmt.Println("Use 'plugins install ' to install a plugin") +} diff --git a/cmd/plugins/plugin.go b/internal/plugins/cmd/plugin.go similarity index 100% rename from cmd/plugins/plugin.go rename to internal/plugins/cmd/plugin.go diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go new file mode 100644 index 00000000..f01d8a6c --- /dev/null +++ b/internal/plugins/cmd/plugins.go @@ -0,0 +1,103 @@ +/* +Copyright 2025 Flant JSC + +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 plugins implements the `d8 plugins ...` command tree +// (list, contract, install, update, remove) along with the wrappers +// for individual plugins (see plugin.go) and the service-init logic +// (see init.go). +// +// The cobra-subcommand implementations are split into domain files: +// - list.go -- `d8 plugins list` + display helpers +// - contract.go -- `d8 plugins contract ` +// - install.go -- `d8 plugins install ` + install pipeline +// - update.go -- `d8 plugins update ` / `update all` +// - remove.go -- `d8 plugins remove ` / `remove all` +// - validators.go -- requirement validation, contract cache, +// version helpers (used by list/install/contract) +// +// This file only wires the root cobra command and constructs the shared +// PluginsCommand state. +package plugins + +import ( + "errors" + "log/slog" + "os" + "path" + + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + client "github.com/deckhouse/deckhouse/pkg/registry" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// PluginsCommand holds shared state for every `d8 plugins ...` subcommand +// and is also reused by the per-plugin wrapper command (see plugin.go). +type PluginsCommand struct { + service *service.PluginService + pluginRegistryClient client.Client + pluginDirectory string + + logger *dkplog.Logger +} + +func NewPluginsCommand(logger *dkplog.Logger) *PluginsCommand { + return &PluginsCommand{ + pluginDirectory: flags.DeckhousePluginsDir, + logger: logger, + } +} + +func NewCommand(logger *dkplog.Logger) *cobra.Command { + pc := NewPluginsCommand(logger) + + cmd := &cobra.Command{ + Use: "plugins", + Short: "Manage Deckhouse CLI plugins", + Hidden: true, + PersistentPreRun: func(_ *cobra.Command, _ []string) { + // init plugin services for subcommands after flags are parsed + pc.InitPluginServices() + + err := os.MkdirAll(flags.DeckhousePluginsDir+"/plugins", 0755) + // if permission failed + if errors.Is(err, os.ErrPermission) { + pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", slog.String("new_path", flags.DeckhousePluginsDir), dkplog.Err(err)) + + homeDir, err := os.UserHomeDir() + if err != nil { + logger.Debug("failed to receive home dir to create plugins dir", slog.String("error", err.Error())) + return + } + + pc.pluginDirectory = path.Join(homeDir, ".deckhouse-cli") + } + }, + } + + cmd.AddCommand(pc.pluginsListCommand()) + cmd.AddCommand(pc.pluginsContractCommand()) + cmd.AddCommand(pc.pluginsInstallCommand()) + cmd.AddCommand(pc.pluginsUpdateCommand()) + cmd.AddCommand(pc.pluginsRemoveCommand()) + + flags.AddFlags(cmd.PersistentFlags()) + + return cmd +} diff --git a/internal/plugins/cmd/remove.go b/internal/plugins/cmd/remove.go new file mode 100644 index 00000000..d6aee9d1 --- /dev/null +++ b/internal/plugins/cmd/remove.go @@ -0,0 +1,100 @@ +/* +Copyright 2025 Flant JSC + +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 plugins + +import ( + "fmt" + "os" + "path" + + "github.com/spf13/cobra" +) + +func (pc *PluginsCommand) pluginsRemoveCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove [plugin-name]", + Aliases: []string{"uninstall", "delete"}, + Short: "Remove an installed plugin", + Long: "Remove a specific plugin from the Deckhouse CLI", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + pluginName := args[0] + fmt.Printf("Removing plugin: %s\n", pluginName) + + pluginDir := path.Join(pc.pluginDirectory, "plugins", pluginName) + fmt.Printf("Removing plugin from: %s\n", pluginDir) + + err := os.RemoveAll(pluginDir) + if err != nil { + return fmt.Errorf("failed to remove plugin directory: %w", err) + } + + fmt.Println("Cleaning up plugin files...") + + os.Remove(path.Join(pc.pluginDirectory, "cache", "contracts", pluginName+".json")) + + fmt.Printf("✓ Plugin '%s' successfully removed!\n", pluginName) + + return nil + }, + } + + // Add subcommands + cmd.AddCommand(pc.pluginsRemoveAllCommand()) + + return cmd +} + +func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "all", + Short: "Remove all installed plugins", + Long: "Remove all plugins from the Deckhouse CLI at once", + RunE: func(_ *cobra.Command, _ []string) error { + fmt.Println("Removing all installed plugins...") + + plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) + if err != nil { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + + fmt.Println("Found", len(plugins), "plugins to remove:") + + for _, plugin := range plugins { + pluginDir := path.Join(pc.pluginDirectory, "plugins", plugin.Name()) + fmt.Printf("Removing plugin from: %s\n", pluginDir) + + err := os.RemoveAll(pluginDir) + if err != nil { + return fmt.Errorf("failed to remove plugin directory: %w", err) + } + + fmt.Printf("Cleaning up plugin files for '%s'...\n", plugin.Name()) + + os.Remove(path.Join(pc.pluginDirectory, "cache", "contracts", plugin.Name()+".json")) + + fmt.Printf("✓ Plugin '%s' successfully removed!\n", plugin.Name()) + } + + fmt.Println("✓ All plugins successfully removed!") + + return nil + }, + } + + return cmd +} diff --git a/internal/plugins/cmd/update.go b/internal/plugins/cmd/update.go new file mode 100644 index 00000000..4f0ab7b1 --- /dev/null +++ b/internal/plugins/cmd/update.go @@ -0,0 +1,78 @@ +/* +Copyright 2025 Flant JSC + +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 plugins + +import ( + "fmt" + "os" + "path" + + "github.com/spf13/cobra" +) + +func (pc *PluginsCommand) pluginsUpdateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "update [plugin-name]", + Short: "Update an installed plugin", + Long: "Update a specific plugin to its latest available version", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pluginName := args[0] + fmt.Printf("Updating plugin: %s\n", pluginName) + + ctx := cmd.Context() + + return pc.InstallPlugin(ctx, pluginName) + }, + } + + // Add subcommands + cmd.AddCommand(pc.pluginsUpdateAllCommand()) + + return cmd +} + +func (pc *PluginsCommand) pluginsUpdateAllCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "all", + Short: "Update all installed plugins", + Long: "Update all installed plugins to their latest available versions", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + fmt.Println("Updating all installed plugins...") + + plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) + if err != nil { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + + for _, plugin := range plugins { + err := pc.InstallPlugin(ctx, plugin.Name()) + if err != nil { + return fmt.Errorf("failed to update plugin: %w", err) + } + } + + fmt.Println("✓ All plugins updated successfully!") + + return nil + }, + } + + return cmd +} diff --git a/internal/plugins/cmd/validators.go b/internal/plugins/cmd/validators.go new file mode 100644 index 00000000..f3ccafc9 --- /dev/null +++ b/internal/plugins/cmd/validators.go @@ -0,0 +1,263 @@ +/* +Copyright 2025 Flant JSC + +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 plugins + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// getInstalledPluginContract reads the cached contract from +// /cache/contracts/.json and converts it to a domain object. +func (pc *PluginsCommand) getInstalledPluginContract(pluginName string) (*internal.Plugin, error) { + contractFile := path.Join(pc.pluginDirectory, "cache", "contracts", pluginName+".json") + + file, err := os.Open(contractFile) + if err != nil { + return nil, fmt.Errorf("failed to read contract file: %w", err) + } + defer file.Close() + + contract := new(service.PluginContract) + dec := json.NewDecoder(file) + err = dec.Decode(contract) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal contract: %w", err) + } + + return service.ContractToDomain(contract), nil +} + +// getInstalledPluginVersion runs the installed plugin binary with "--version" +// (or "version" as fallback) and parses the output as a semver value. +func (pc *PluginsCommand) getInstalledPluginVersion(pluginName string) (*semver.Version, error) { + pluginBinaryPath := path.Join(pc.pluginDirectory, "plugins", pluginName, "current") + cmd := exec.Command(pluginBinaryPath, "--version") + + output, err := cmd.Output() + if err != nil { + pc.logger.Warn("failed to call plugin with '--version'", slog.String("plugin", pluginName), slog.String("error", err.Error())) + + // try to call plugin with "version" command + // this is for compatibility with plugins that don't support "--version" + cmd = exec.Command(pluginBinaryPath, "version") + + output, err = cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to call plugin: %w", err) + } + } + + version, err := semver.NewVersion(strings.TrimSpace(string(output))) + if err != nil { + return nil, fmt.Errorf("failed to parse version: %w", err) + } + + return version, nil +} + +// findLatestVersion finds the latest version from a list of version strings +func (pc *PluginsCommand) findLatestVersion(versions []string) (*semver.Version, error) { + if len(versions) == 0 { + return nil, fmt.Errorf("no versions found") + } + + var latestVersion *semver.Version + + for _, version := range versions { + version, err := semver.NewVersion(version) + if err != nil { + continue + } + + if latestVersion == nil { + latestVersion = version + continue + } + + if latestVersion.LessThan(version) { + latestVersion = version + } + } + + if latestVersion == nil { + return nil, fmt.Errorf("no versions found") + } + + return latestVersion, nil +} + +// fetchLatestVersion lists tags from the registry for a plugin and returns +// the highest semver version. +func (pc *PluginsCommand) fetchLatestVersion(ctx context.Context, pluginName string) (*semver.Version, error) { + versions, err := pc.service.ListPluginTags(ctx, pluginName) + if err != nil { + pc.logger.Warn("Failed to list plugin tags", slog.String("plugin", pluginName), slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to list plugin tags: %w", err) + } + + latestVersion, err := pc.findLatestVersion(versions) + if err != nil { + pc.logger.Warn("Failed to fetch latest version", slog.String("plugin", pluginName), slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to fetch latest version: %w", err) + } + return latestVersion, nil +} + +// FailedConstraints holds plugin requirements that were not satisfied during +// installation: a nil value means the plugin is missing entirely, a non-nil +// value carries the constraint that the currently installed version fails. +type FailedConstraints map[string]*semver.Constraints + +func (pc *PluginsCommand) validateRequirements(plugin *internal.Plugin) (FailedConstraints, error) { + // validate plugin requirements + pc.logger.Debug("validating plugin requirements", slog.String("plugin", plugin.Name)) + + err := pc.validatePluginConflicts(plugin) + if err != nil { + return nil, fmt.Errorf("plugin conflicts: %w", err) + } + + failedConstraints, err := pc.validatePluginRequirement(plugin) + if err != nil { + return nil, fmt.Errorf("plugin requirements: %w", err) + } + + // validate module requirements + pc.logger.Debug("validating module requirements", slog.String("plugin", plugin.Name)) + + err = pc.validateModuleRequirement(plugin) + if err != nil { + return nil, fmt.Errorf("module requirements: %w", err) + } + + return failedConstraints, nil +} + +// check that installing version not make conflict with existing plugins requirements +func (pc *PluginsCommand) validatePluginConflicts(plugin *internal.Plugin) error { + contractDir, err := os.ReadDir(path.Join(pc.pluginDirectory, "cache", "contracts")) + // if no plugins installed, nothing to conflict + if err != nil && errors.Is(err, os.ErrNotExist) { + pc.logger.Debug("failed to read contract directory", slog.String("error", err.Error())) + return nil + } + if err != nil { + return fmt.Errorf("failed to read contract directory: %w", err) + } + + for _, contractFile := range contractDir { + pluginName := strings.TrimSuffix(contractFile.Name(), ".json") + + contract, err := pc.getInstalledPluginContract(pluginName) + if err != nil { + return fmt.Errorf("failed to get installed plugin contract: %w", err) + } + + err = validatePluginConflict(plugin, contract) + if err != nil { + return fmt.Errorf("validate plugin conflict: %w", err) + } + } + + return nil +} + +func validatePluginConflict(plugin *internal.Plugin, installedPlugin *internal.Plugin) error { + for _, requirement := range installedPlugin.Requirements.Plugins { + // installed plugin requirement is the same as the plugin we are validating + if requirement.Name == plugin.Name { + constraint, err := semver.NewConstraint(requirement.Constraint) + if err != nil { + return fmt.Errorf("failed to parse constraint: %w", err) + } + + version, err := semver.NewVersion(installedPlugin.Version) + if err != nil { + return fmt.Errorf("failed to parse version: %w", err) + } + + if !constraint.Check(version) { + return fmt.Errorf("installing plugin %s %s will make conflict with existing plugin %s %s", + plugin.Name, + plugin.Version, + installedPlugin.Name, + constraint.String()) + } + } + } + + return nil +} + +func (pc *PluginsCommand) validatePluginRequirement(plugin *internal.Plugin) (FailedConstraints, error) { + result := make(FailedConstraints) + + for _, pluginRequirement := range plugin.Requirements.Plugins { + // check if plugin is installed + installed, err := pc.checkInstalled(pluginRequirement.Name) + if err != nil { + return nil, fmt.Errorf("failed to check if plugin is installed: %w", err) + } + if !installed { + result[pluginRequirement.Name] = nil + continue + } + + // check constraint + if pluginRequirement.Constraint != "" { + installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name) + if err != nil { + return nil, fmt.Errorf("failed to get installed version: %w", err) + } + + constraint, err := semver.NewConstraint(pluginRequirement.Constraint) + if err != nil { + return nil, fmt.Errorf("failed to parse constraint: %w", err) + } + + if !constraint.Check(installedVersion) { + pc.logger.Warn("plugin requirement not satisfied", + slog.String("plugin", plugin.Name), + slog.String("requirement", pluginRequirement.Name), + slog.String("constraint", pluginRequirement.Constraint), + slog.String("installedVersion", installedVersion.Original())) + + result[pluginRequirement.Name] = constraint + } + } + } + + return result, nil +} + +func (pc *PluginsCommand) validateModuleRequirement(_ *internal.Plugin) error { + // TODO: Implement module requirement validation + return nil +} From f678ce98ce42e220c34954fa1b441ff5d6e866c2 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 12 May 2026 00:24:13 +0300 Subject: [PATCH 2/5] refactor: remove unused flags Some of them are likely copied from mirror command, but in plugins there is no use of them. Signed-off-by: Roman Berezkin --- internal/plugins/cmd/flags/flags.go | 127 +--------------------------- 1 file changed, 1 insertion(+), 126 deletions(-) diff --git a/internal/plugins/cmd/flags/flags.go b/internal/plugins/cmd/flags/flags.go index f9b70269..397a68c0 100644 --- a/internal/plugins/cmd/flags/flags.go +++ b/internal/plugins/cmd/flags/flags.go @@ -19,7 +19,6 @@ package flags import ( "os" - "github.com/Masterminds/semver/v3" "github.com/spf13/pflag" ) @@ -33,38 +32,15 @@ const ( // CLI Parameters var ( - TempDir string - DeckhousePluginsDir = DefaultDeckhousePluginsDir Insecure bool TLSSkipVerify bool - ForcePull bool - - ImagesBundlePath string - ImagesBundleChunkSizeGB int64 - - SinceVersionString string - SinceVersion *semver.Version - - DeckhouseTag string - - ModulesPathSuffix string - ModulesWhitelist []string - ModulesBlacklist []string SourceRegistryRepo = EnterpriseEditionRepo // Fallback to EE if nothing was given as source. SourceRegistryLogin string SourceRegistryPassword string DeckhouseLicenseToken string - - DoGOSTDigest bool - NoPullResume bool - - NoPlatform bool - NoSecurityDB bool - NoModules bool - OnlyExtraImages bool ) func AddFlags(flagSet *pflag.FlagSet) { @@ -72,7 +48,7 @@ func AddFlags(flagSet *pflag.FlagSet) { &SourceRegistryRepo, "source", SourceRegistryRepo, - "Source registry to pull Deckhouse images from.", + "Source registry to pull Deckhouse plugins from.", ) flagSet.StringVar( &SourceRegistryLogin, @@ -93,101 +69,6 @@ func AddFlags(flagSet *pflag.FlagSet) { os.Getenv("D8_MIRROR_LICENSE_TOKEN"), "Deckhouse license key. Shortcut for --source-login=license-token --source-password=<>.", ) - flagSet.StringVar( - &SinceVersionString, - "since-version", - "", - "Minimal Deckhouse release to pull. Ignored if above current Rock Solid release. Conflicts with --deckhouse-tag.", - ) - flagSet.StringVar( - &DeckhouseTag, - "deckhouse-tag", - "", - "Specific Deckhouse build tag to pull. Conflicts with --since-version. If registry contains release channel image for specified tag, all release channels in the bundle will be pointed to it.", - ) - flagSet.StringArrayVarP( - &ModulesWhitelist, - "include-module", - "i", - nil, - `Whitelist specific modules for downloading. Use one flag per each module. Disables blacklisting by --exclude-module." - -Example: -Available versions for : v1.0.0, v1.1.0, v1.2.0, v1.3.0, v1.3.3, v1.4.1 - -module-name@1.3.0 → semver ^ constraint (^1.3.0): include v1.3.0, v1.3.3, v1.4.1. In addition pulls current versions from release channels - -module-name@~1.3.0 → semver ~ constraint (>=1.3.0 <1.4.0): include only v1.3.0, v1.3.3. In addition pulls current versions from release channels - -module-name@=v1.3.0 → exact tag match: include only v1.3.0 and publish it to all release channels (alpha, beta, early-access, stable, rock-solid). - -module-name@=bobV1 → exact tag match: include only bobV1 and publish it to all release channels (alpha, beta, early-access, stable, rock-solid). - -module-name@=v1.3.0+stable → exact tag match: include only v1.3.0 and and publish it to stable channel - `, - ) - flagSet.StringArrayVarP( - &ModulesBlacklist, - "exclude-module", - "e", - nil, - `Blacklist specific modules from downloading. Format is "module-name[@version]". Use one flag per each module. Overridden by use of --include-module."`, - ) - flagSet.StringVar( - &ModulesPathSuffix, - "modules-path-suffix", - "/modules", - "Suffix to append to source repo path to locate modules.", - ) - flagSet.Int64VarP( - &ImagesBundleChunkSizeGB, - "images-bundle-chunk-size", - "c", - 0, - "Split resulting bundle file into chunks of at most N gigabytes", - ) - flagSet.BoolVar( - &DoGOSTDigest, - "gost-digest", - false, - "Calculate GOST R 34.11-2012 STREEBOG digest for downloaded bundle", - ) - flagSet.BoolVar( - &ForcePull, - "force", - false, - "Overwrite existing bundle packages if they are conflicting with current pull operation.", - ) - flagSet.BoolVar( - &NoPullResume, - "no-pull-resume", - false, - "Do not continue last unfinished pull operation and start from scratch.", - ) - flagSet.BoolVar( - &NoPlatform, - "no-platform", - false, - "Do not pull Deckhouse Kubernetes Platform into bundle.", - ) - flagSet.BoolVar( - &NoSecurityDB, - "no-security-db", - false, - "Do not pull security databases into bundle.", - ) - flagSet.BoolVar( - &NoModules, - "no-modules", - false, - "Do not pull Deckhouse modules into bundle.", - ) - flagSet.BoolVar( - &OnlyExtraImages, - "only-extra-images", - false, - "Pull only extra images for modules (additional images like security databases, scanners, etc.) without pulling main module images.", - ) flagSet.BoolVar( &TLSSkipVerify, "tls-skip-verify", @@ -200,12 +81,6 @@ module-name@=v1.3.0+stable → exact tag match: include only v1.3.0 and and publ false, "Interact with registries over HTTP.", ) - flagSet.StringVar( - &TempDir, - "tmp-dir", - "", - "Path to a temporary directory to use for image pulling and pushing. All processing is done in this directory, so make sure there is enough free disk space to accommodate the entire bundle you are downloading;", - ) flagSet.StringVar( &DeckhousePluginsDir, "plugins-dir", From 1ded064ce029446e6f3883f04e423ef9a1045b3f Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 12 May 2026 01:26:15 +0300 Subject: [PATCH 3/5] doc: add decent doc.go file explaining the concepts of the plugin command and plugins in general Signed-off-by: Roman Berezkin --- internal/plugins/cmd/doc.go | 64 +++++++++++++++++++++++++++++++ internal/plugins/cmd/flags/doc.go | 19 +++++++++ internal/plugins/cmd/plugins.go | 16 -------- 3 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 internal/plugins/cmd/doc.go create mode 100644 internal/plugins/cmd/flags/doc.go diff --git a/internal/plugins/cmd/doc.go b/internal/plugins/cmd/doc.go new file mode 100644 index 00000000..5ca22693 --- /dev/null +++ b/internal/plugins/cmd/doc.go @@ -0,0 +1,64 @@ +/* +Copyright 2026 Flant JSC + +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 plugins implements the d8-cli plugin system. +// +// A d8 plugin is a standalone binary published to an OCI Registry. It is +// not part of d8-cli and can be developed independently. +// +// # Why plugins exist +// +// - Isolate dependencies. +// - Enable independent, parallel development. +// - Let different teams own different plugins (delivery, system, ...). +// - Keep d8 itself compact, with only the dependencies it actually needs. +// +// # What d8-cli can do with plugins +// +// - Download them. +// - Validate their dependencies (requirements declared in contract.yaml that plugins declare). +// - Run them as if they were native subcommands. +// +// # Where a plugin lives +// +// A plugin lives in an OCI Registry as a packaged file with a "contract" +// annotation. The annotation carries a base64-encoded JSON contract. +// Plugin metadata: +// +// - name; +// - version; +// - description; +// - environment variables; +// - flags; +// - requirements. +// +// # How a plugin invocation works +// +// 1. The user invokes a command through d8. +// 2. The parent CLI checks whether the plugin is installed. +// 3. If it is not, the image is pulled from the registry. +// 4. The binary is unpacked. +// 5. Requirements are validated. +// 6. A symlink is pointed at the current major version. +// 7. The plugin is exec'd with the forwarded arguments. +// +// # What the plugin system is made of +// +// 1. Discover - learn what plugins exist and what their contracts declare. +// 2. Install - download and place the plugin in the right location with +// proper validation. +// 3. Exec - run the plugin as part of d8 without losing argument context. +package plugins diff --git a/internal/plugins/cmd/flags/doc.go b/internal/plugins/cmd/flags/doc.go new file mode 100644 index 00000000..5e265d8c --- /dev/null +++ b/internal/plugins/cmd/flags/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2025 Flant JSC + +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 flags defines the shared CLI flag set used by the d8 plugins +// management subcommands and consumed during registry client initialisation. +package flags diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go index f01d8a6c..4f7c79f4 100644 --- a/internal/plugins/cmd/plugins.go +++ b/internal/plugins/cmd/plugins.go @@ -14,22 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package plugins implements the `d8 plugins ...` command tree -// (list, contract, install, update, remove) along with the wrappers -// for individual plugins (see plugin.go) and the service-init logic -// (see init.go). -// -// The cobra-subcommand implementations are split into domain files: -// - list.go -- `d8 plugins list` + display helpers -// - contract.go -- `d8 plugins contract ` -// - install.go -- `d8 plugins install ` + install pipeline -// - update.go -- `d8 plugins update ` / `update all` -// - remove.go -- `d8 plugins remove ` / `remove all` -// - validators.go -- requirement validation, contract cache, -// version helpers (used by list/install/contract) -// -// This file only wires the root cobra command and constructs the shared -// PluginsCommand state. package plugins import ( From 1a51b3b5911699152e53ed8849e4b275905caa7d Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 12 May 2026 06:39:16 +0300 Subject: [PATCH 4/5] refactor: path resolution refactor (move to single source of truth with builder functions) Signed-off-by: Roman Berezkin --- internal/plugins/cmd/install.go | 65 ++++++++---------- internal/plugins/cmd/layout/layout.go | 99 +++++++++++++++++++++++++++ internal/plugins/cmd/list.go | 5 +- internal/plugins/cmd/plugins.go | 8 +-- internal/plugins/cmd/remove.go | 13 ++-- internal/plugins/cmd/update.go | 5 +- internal/plugins/cmd/validators.go | 10 +-- 7 files changed, 147 insertions(+), 58 deletions(-) create mode 100644 internal/plugins/cmd/layout/layout.go diff --git a/internal/plugins/cmd/install.go b/internal/plugins/cmd/install.go index fceeed33..410f391a 100644 --- a/internal/plugins/cmd/install.go +++ b/internal/plugins/cmd/install.go @@ -22,14 +22,13 @@ import ( "fmt" "log/slog" "os" - "path" "path/filepath" - "strconv" "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" "github.com/deckhouse/deckhouse-cli/pkg/registry/service" ) @@ -141,12 +140,14 @@ func (pc *PluginsCommand) InstallPlugin(ctx context.Context, pluginName string, return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) } -// pluginPaths bundles the three filesystem locations an install operates on. +// pluginPaths bundles the filesystem locations an install operates on. // Created once by preparePluginDirs and threaded through the install pipeline. type pluginPaths struct { - pluginDir string // /plugins/ - versionDir string // /plugins//v - binaryPath string // /plugins//v/ + pluginDir string // /plugins/ + versionDir string // /plugins//v + binaryPath string // /plugins//v/ + lockPath string // /plugins//v/.lock + currentLink string // /plugins//current } // installPlugin is the install pipeline orchestrator. Each step delegates to @@ -158,7 +159,7 @@ func (pc *PluginsCommand) installPlugin(ctx context.Context, pluginName string, return err } - release, err := pc.acquireInstallLock(paths.versionDir, pluginName) + release, err := pc.acquireInstallLock(paths.lockPath) if err != nil { return err } @@ -194,37 +195,31 @@ func (pc *PluginsCommand) installPlugin(ctx context.Context, pluginName string, } // preparePluginDirs creates plugins//v on disk and returns the -// three paths derived from used by the rest of the pipeline. +// paths derived from used by the rest of the pipeline. func (pc *PluginsCommand) preparePluginDirs(pluginName string, version *semver.Version) (pluginPaths, error) { - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin - pluginDir := path.Join(pc.pluginDirectory, "plugins", pluginName) - if err := os.MkdirAll(pluginDir, 0755); err != nil { - return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) + major := int(version.Major()) + paths := pluginPaths{ + pluginDir: layout.PluginDir(pc.pluginDirectory, pluginName), + versionDir: layout.VersionDir(pc.pluginDirectory, pluginName, major), + binaryPath: layout.BinaryPath(pc.pluginDirectory, pluginName, major), + lockPath: layout.LockPath(pc.pluginDirectory, pluginName, major), + currentLink: layout.CurrentLinkPath(pc.pluginDirectory, pluginName), } - majorVersion := strconv.Itoa(int(version.Major())) - - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1 - versionDir := path.Join(pluginDir, "v"+majorVersion) - if err := os.MkdirAll(versionDir, 0755); err != nil { + if err := os.MkdirAll(paths.pluginDir, 0755); err != nil { + return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) + } + if err := os.MkdirAll(paths.versionDir, 0755); err != nil { return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) } - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1/example-plugin - return pluginPaths{ - pluginDir: pluginDir, - versionDir: versionDir, - binaryPath: path.Join(versionDir, pluginName), - }, nil + return paths, nil } -// acquireInstallLock creates /.lock; if the lock already +// acquireInstallLock creates the lock file at lockFilePath; if it already // exists, returns an error without touching it. The caller must invoke the // returned release func when finished (typically via defer). -func (pc *PluginsCommand) acquireInstallLock(versionDir, pluginName string) (func(), error) { - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/v1/example-plugin.lock - lockFilePath := path.Join(versionDir, pluginName+".lock") - +func (pc *PluginsCommand) acquireInstallLock(lockFilePath string) (func(), error) { _, err := os.Stat(lockFilePath) if err == nil { // File exists, plugin is locked @@ -318,16 +313,14 @@ func (pc *PluginsCommand) downloadAndExtract(ctx context.Context, pluginName str // linkCurrent (re)points /current to the freshly installed binary // using an absolute target path. func (pc *PluginsCommand) linkCurrent(paths pluginPaths) error { - // example path: /opt/deckhouse/lib/deckhouse-cli/plugins/example-plugin/current - currentSymlink := path.Join(paths.pluginDir, "current") - _ = os.Remove(currentSymlink) + _ = os.Remove(paths.currentLink) absPath, err := filepath.Abs(paths.binaryPath) if err != nil { return fmt.Errorf("failed to compute absolute path: %w", err) } - if err := os.Symlink(absPath, currentSymlink); err != nil { + if err := os.Symlink(absPath, paths.currentLink); err != nil { return fmt.Errorf("failed to create symlink: %w", err) } return nil @@ -337,16 +330,12 @@ func (pc *PluginsCommand) linkCurrent(paths pluginPaths) error { // /cache/contracts/.json for later lookups by // validatePluginConflicts and `d8 plugins list`. func (pc *PluginsCommand) cacheContract(pluginName string, plugin *internal.Plugin) error { - // example path: /opt/deckhouse/lib/deckhouse-cli/cache/contracts - contractDir := path.Join(pc.pluginDirectory, "cache", "contracts") - if err := os.MkdirAll(contractDir, 0755); err != nil { + if err := os.MkdirAll(layout.ContractsDir(pc.pluginDirectory), 0755); err != nil { return fmt.Errorf("failed to create contract directory: %w", err) } - // example path: /opt/deckhouse/lib/deckhouse-cli/cache/contracts/example-plugin.json - contractFilePath := path.Join(contractDir, pluginName+".json") contract := service.DomainToContract(plugin) - contractFile, err := os.OpenFile(contractFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + contractFile, err := os.OpenFile(layout.ContractFile(pc.pluginDirectory, pluginName), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return fmt.Errorf("failed to open contract file: %w", err) } diff --git a/internal/plugins/cmd/layout/layout.go b/internal/plugins/cmd/layout/layout.go new file mode 100644 index 00000000..39e81b6a --- /dev/null +++ b/internal/plugins/cmd/layout/layout.go @@ -0,0 +1,99 @@ +/* +Copyright 2025 Flant JSC + +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 layout centralizes the on-disk filesystem layout used by the +// d8 plugins subsystem: directory names, suffixes, and helpers that build +// concrete paths from the install root. +// +// The install root is the on-disk directory under which the subsystem keeps +// everything it owns - installed plugin binaries (/plugins/...) and +// cached contracts (/cache/contracts/...). It is supplied by the +// caller (typically pc.pluginDirectory, sourced from --plugins-dir, with +// default /opt/deckhouse/lib/deckhouse-cli, or ~/.deckhouse-cli as fallback). +// +// Callers should use the builder functions (PluginDir, BinaryPath, ...) +// for full paths. The raw segment constants are exposed for the rare cases +// where only a single directory or extension name is needed. +package layout + +import ( + "fmt" + "os" + "path" + "strconv" +) + +const ( + PluginsDirName = "plugins" + CacheDirName = "cache" + ContractsDirName = "contracts" + CurrentLinkName = "current" + LockFileSuffix = ".lock" + ContractFileExt = ".json" + HomeFallbackDir = ".deckhouse-cli" + VersionDirPrefix = "v" +) + +// PluginsRoot returns /plugins. +func PluginsRoot(installRoot string) string { + return path.Join(installRoot, PluginsDirName) +} + +// PluginDir returns /plugins/. +func PluginDir(installRoot, pluginName string) string { + return path.Join(installRoot, PluginsDirName, pluginName) +} + +// VersionDir returns /plugins//v. +func VersionDir(installRoot, pluginName string, majorVersion int) string { + return path.Join(installRoot, PluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion)) +} + +// BinaryPath returns /plugins//v/. +func BinaryPath(installRoot, pluginName string, majorVersion int) string { + return path.Join(installRoot, PluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion), pluginName) +} + +// LockPath returns /plugins//v/.lock. +func LockPath(installRoot, pluginName string, majorVersion int) string { + return path.Join(installRoot, PluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion), pluginName+LockFileSuffix) +} + +// CurrentLinkPath returns /plugins//current - the symlink to the +// currently active binary version. +func CurrentLinkPath(installRoot, pluginName string) string { + return path.Join(installRoot, PluginsDirName, pluginName, CurrentLinkName) +} + +// ContractsDir returns /cache/contracts. +func ContractsDir(installRoot string) string { + return path.Join(installRoot, CacheDirName, ContractsDirName) +} + +// ContractFile returns /cache/contracts/.json. +func ContractFile(installRoot, pluginName string) string { + return path.Join(installRoot, CacheDirName, ContractsDirName, pluginName+ContractFileExt) +} + +// HomeFallbackPath returns ~/.deckhouse-cli - the fallback install root used +// when the default /opt path is not writable. +func HomeFallbackPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to determine user home directory: %w", err) + } + return path.Join(home, HomeFallbackDir), nil +} diff --git a/internal/plugins/cmd/list.go b/internal/plugins/cmd/list.go index 9ac6c04e..f835d103 100644 --- a/internal/plugins/cmd/list.go +++ b/internal/plugins/cmd/list.go @@ -21,9 +21,10 @@ import ( "fmt" "log/slog" "os" - "path" "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" ) // pluginDisplayInfo holds all information needed to display a plugin @@ -100,7 +101,7 @@ func (pc *PluginsCommand) preparePluginsListData(ctx context.Context, showInstal // fetchInstalledPlugins retrieves installed plugins from filesystem func (pc *PluginsCommand) fetchInstalledPlugins() ([]pluginDisplayInfo, error) { - plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) + plugins, err := os.ReadDir(layout.PluginsRoot(pc.pluginDirectory)) if err != nil { return nil, fmt.Errorf("failed to read plugins directory: %w", err) } diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go index 4f7c79f4..fd37d0d7 100644 --- a/internal/plugins/cmd/plugins.go +++ b/internal/plugins/cmd/plugins.go @@ -20,7 +20,6 @@ import ( "errors" "log/slog" "os" - "path" "github.com/spf13/cobra" @@ -28,6 +27,7 @@ import ( client "github.com/deckhouse/deckhouse/pkg/registry" "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" "github.com/deckhouse/deckhouse-cli/pkg/registry/service" ) @@ -59,18 +59,16 @@ func NewCommand(logger *dkplog.Logger) *cobra.Command { // init plugin services for subcommands after flags are parsed pc.InitPluginServices() - err := os.MkdirAll(flags.DeckhousePluginsDir+"/plugins", 0755) + err := os.MkdirAll(layout.PluginsRoot(flags.DeckhousePluginsDir), 0755) // if permission failed if errors.Is(err, os.ErrPermission) { pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", slog.String("new_path", flags.DeckhousePluginsDir), dkplog.Err(err)) - homeDir, err := os.UserHomeDir() + pc.pluginDirectory, err = layout.HomeFallbackPath() if err != nil { logger.Debug("failed to receive home dir to create plugins dir", slog.String("error", err.Error())) return } - - pc.pluginDirectory = path.Join(homeDir, ".deckhouse-cli") } }, } diff --git a/internal/plugins/cmd/remove.go b/internal/plugins/cmd/remove.go index d6aee9d1..de7819a0 100644 --- a/internal/plugins/cmd/remove.go +++ b/internal/plugins/cmd/remove.go @@ -19,9 +19,10 @@ package plugins import ( "fmt" "os" - "path" "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" ) func (pc *PluginsCommand) pluginsRemoveCommand() *cobra.Command { @@ -35,7 +36,7 @@ func (pc *PluginsCommand) pluginsRemoveCommand() *cobra.Command { pluginName := args[0] fmt.Printf("Removing plugin: %s\n", pluginName) - pluginDir := path.Join(pc.pluginDirectory, "plugins", pluginName) + pluginDir := layout.PluginDir(pc.pluginDirectory, pluginName) fmt.Printf("Removing plugin from: %s\n", pluginDir) err := os.RemoveAll(pluginDir) @@ -45,7 +46,7 @@ func (pc *PluginsCommand) pluginsRemoveCommand() *cobra.Command { fmt.Println("Cleaning up plugin files...") - os.Remove(path.Join(pc.pluginDirectory, "cache", "contracts", pluginName+".json")) + os.Remove(layout.ContractFile(pc.pluginDirectory, pluginName)) fmt.Printf("✓ Plugin '%s' successfully removed!\n", pluginName) @@ -67,7 +68,7 @@ func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { RunE: func(_ *cobra.Command, _ []string) error { fmt.Println("Removing all installed plugins...") - plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) + plugins, err := os.ReadDir(layout.PluginsRoot(pc.pluginDirectory)) if err != nil { return fmt.Errorf("failed to read plugins directory: %w", err) } @@ -75,7 +76,7 @@ func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { fmt.Println("Found", len(plugins), "plugins to remove:") for _, plugin := range plugins { - pluginDir := path.Join(pc.pluginDirectory, "plugins", plugin.Name()) + pluginDir := layout.PluginDir(pc.pluginDirectory, plugin.Name()) fmt.Printf("Removing plugin from: %s\n", pluginDir) err := os.RemoveAll(pluginDir) @@ -85,7 +86,7 @@ func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { fmt.Printf("Cleaning up plugin files for '%s'...\n", plugin.Name()) - os.Remove(path.Join(pc.pluginDirectory, "cache", "contracts", plugin.Name()+".json")) + os.Remove(layout.ContractFile(pc.pluginDirectory, plugin.Name())) fmt.Printf("✓ Plugin '%s' successfully removed!\n", plugin.Name()) } diff --git a/internal/plugins/cmd/update.go b/internal/plugins/cmd/update.go index 4f0ab7b1..0de4f4fa 100644 --- a/internal/plugins/cmd/update.go +++ b/internal/plugins/cmd/update.go @@ -19,9 +19,10 @@ package plugins import ( "fmt" "os" - "path" "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" ) func (pc *PluginsCommand) pluginsUpdateCommand() *cobra.Command { @@ -56,7 +57,7 @@ func (pc *PluginsCommand) pluginsUpdateAllCommand() *cobra.Command { fmt.Println("Updating all installed plugins...") - plugins, err := os.ReadDir(path.Join(pc.pluginDirectory, "plugins")) + plugins, err := os.ReadDir(layout.PluginsRoot(pc.pluginDirectory)) if err != nil { return fmt.Errorf("failed to read plugins directory: %w", err) } diff --git a/internal/plugins/cmd/validators.go b/internal/plugins/cmd/validators.go index f3ccafc9..7694e7ee 100644 --- a/internal/plugins/cmd/validators.go +++ b/internal/plugins/cmd/validators.go @@ -24,19 +24,19 @@ import ( "log/slog" "os" "os/exec" - "path" "strings" "github.com/Masterminds/semver/v3" "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" "github.com/deckhouse/deckhouse-cli/pkg/registry/service" ) // getInstalledPluginContract reads the cached contract from // /cache/contracts/.json and converts it to a domain object. func (pc *PluginsCommand) getInstalledPluginContract(pluginName string) (*internal.Plugin, error) { - contractFile := path.Join(pc.pluginDirectory, "cache", "contracts", pluginName+".json") + contractFile := layout.ContractFile(pc.pluginDirectory, pluginName) file, err := os.Open(contractFile) if err != nil { @@ -57,7 +57,7 @@ func (pc *PluginsCommand) getInstalledPluginContract(pluginName string) (*intern // getInstalledPluginVersion runs the installed plugin binary with "--version" // (or "version" as fallback) and parses the output as a semver value. func (pc *PluginsCommand) getInstalledPluginVersion(pluginName string) (*semver.Version, error) { - pluginBinaryPath := path.Join(pc.pluginDirectory, "plugins", pluginName, "current") + pluginBinaryPath := layout.CurrentLinkPath(pc.pluginDirectory, pluginName) cmd := exec.Command(pluginBinaryPath, "--version") output, err := cmd.Output() @@ -162,7 +162,7 @@ func (pc *PluginsCommand) validateRequirements(plugin *internal.Plugin) (FailedC // check that installing version not make conflict with existing plugins requirements func (pc *PluginsCommand) validatePluginConflicts(plugin *internal.Plugin) error { - contractDir, err := os.ReadDir(path.Join(pc.pluginDirectory, "cache", "contracts")) + contractDir, err := os.ReadDir(layout.ContractsDir(pc.pluginDirectory)) // if no plugins installed, nothing to conflict if err != nil && errors.Is(err, os.ErrNotExist) { pc.logger.Debug("failed to read contract directory", slog.String("error", err.Error())) @@ -173,7 +173,7 @@ func (pc *PluginsCommand) validatePluginConflicts(plugin *internal.Plugin) error } for _, contractFile := range contractDir { - pluginName := strings.TrimSuffix(contractFile.Name(), ".json") + pluginName := strings.TrimSuffix(contractFile.Name(), layout.ContractFileExt) contract, err := pc.getInstalledPluginContract(pluginName) if err != nil { From 7e3e58b5204e6f154e162ed8f3f4380dbc1f9053 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 12 May 2026 11:09:17 +0300 Subject: [PATCH 5/5] refactor: extract plugin install/run/contract helpers - `runInstalledPlugin` returns errors, so the run path is testable and the command body just logs and exits on failure. - `ensureInstallRoot` centralizes the homedir fallback used by both `NewCommand` and `NewPluginCommand`. - `cachedDescription` wraps the contract cache lookup and returns "" when the file is missing or unreadable. - All path joins flow through the `layout` package (`PluginsRoot`, `CurrentLinkPath`, `ContractFile`). Signed-off-by: Roman Berezkin --- internal/plugins/cmd/plugin.go | 112 ++++++++++++-------------------- internal/plugins/cmd/plugins.go | 45 +++++++++---- 2 files changed, 74 insertions(+), 83 deletions(-) diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index b378171e..d8d7b236 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -16,19 +16,18 @@ limitations under the License. package plugins import ( - "errors" + "context" "fmt" "log/slog" "os" "os/exec" - "path" "path/filepath" "github.com/spf13/cobra" dkplog "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" ) const ( @@ -37,95 +36,64 @@ const ( ) // TODO: add options pattern -func NewPluginCommand(commandName string, description string, aliases []string, logger *dkplog.Logger) *cobra.Command { +func NewPluginCommand(commandName, description string, aliases []string, logger *dkplog.Logger) *cobra.Command { pc := NewPluginsCommand(logger.Named("plugins-command")) - pluginContractFilePath := path.Join(pc.pluginDirectory, "cache", "contracts", "system.json") - pluginContract, err := service.GetPluginContractFromFile(pluginContractFilePath) - if err != nil { - logger.Debug("failed to get plugin contract from cache", slog.String("error", err.Error())) - } - - if pluginContract != nil { - description = pluginContract.Description - } - - // to check we can create directories here - // we try to create root plugins folder - err = os.MkdirAll(pc.pluginDirectory+"/plugins", 0755) - // if permission failed - if errors.Is(err, os.ErrPermission) { - pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", slog.String("new_path", pc.pluginDirectory), dkplog.Err(err)) - - pc.pluginDirectory, err = os.UserHomeDir() - if err != nil { - logger.Debug("failed to receive home dir to create plugins dir", slog.String("error", err.Error())) - return nil - } - - pc.pluginDirectory = path.Join(pc.pluginDirectory, ".deckhouse-cli") - } - - if err != nil { - logger.Warn("failed to create plugin root directory", slog.String("error", err.Error())) + if err := pc.ensureInstallRoot(); err != nil { + logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) return nil } + if cached := pc.cachedDescription(commandName); cached != "" { + description = cached + } - systemCmd := &cobra.Command{ + return &cobra.Command{ Use: commandName, Short: description, Aliases: aliases, Long: description, DisableFlagParsing: true, - PreRun: func(_ *cobra.Command, _ []string) { - // init plugin services for subcommands after flags are parsed - pc.InitPluginServices() - }, + PreRun: func(_ *cobra.Command, _ []string) { pc.InitPluginServices() }, Run: func(cmd *cobra.Command, args []string) { - installed, err := pc.checkInstalled(commandName) - if err != nil { - fmt.Println("Error checking installed:", err) - os.Exit(1) - } - - if !installed { - fmt.Println("Not installed, installing...") - err = pc.InstallPlugin(cmd.Context(), commandName) - if err != nil { - fmt.Println("Error installing:", err) - os.Exit(1) - } - fmt.Println("Installed successfully") - } - - pluginPath := path.Join(pc.pluginDirectory, "plugins", commandName) - pluginBinaryPath := path.Join(pluginPath, "current") - absPath, err := filepath.Abs(pluginBinaryPath) - if err != nil { - logger.Warn("failed to compute absolute path", slog.String("error", err.Error())) + if err := pc.runInstalledPlugin(cmd.Context(), commandName, args); err != nil { + logger.Warn("plugin failed", slog.String("error", err.Error())) os.Exit(1) } + }, + } +} - logger.Debug("Executing plugin", slog.Any("args", args)) - - command := exec.CommandContext(cmd.Context(), absPath, args...) - command.Stdout = os.Stdout - command.Stderr = os.Stderr +// runInstalledPlugin ensures the plugin is installed and execs its binary with args. +// stdin/stdout/stderr are inherited from the current process. +func (pc *PluginsCommand) runInstalledPlugin(ctx context.Context, pluginName string, args []string) error { + installed, err := pc.checkInstalled(pluginName) + if err != nil { + return fmt.Errorf("check installed: %w", err) + } + if !installed { + fmt.Println("Not installed, installing...") + if err := pc.InstallPlugin(ctx, pluginName); err != nil { + return fmt.Errorf("install: %w", err) + } + fmt.Println("Installed successfully") + } - err = command.Run() - if err != nil { - logger.Warn("Failed to run plugin", slog.String("error", err.Error())) - os.Exit(1) - } - }, + absPath, err := filepath.Abs(layout.CurrentLinkPath(pc.pluginDirectory, pluginName)) + if err != nil { + return fmt.Errorf("absolute path: %w", err) } - return systemCmd + pc.logger.Debug("Executing plugin", slog.String("plugin", pluginName), slog.Any("args", args)) + cmd := exec.CommandContext(ctx, absPath, args...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("plugin run: %w", err) + } + return nil } func (pc *PluginsCommand) checkInstalled(commandName string) (bool, error) { - installedFile := path.Join(pc.pluginDirectory, "plugins", commandName, "current") - absPath, err := filepath.Abs(installedFile) + absPath, err := filepath.Abs(layout.CurrentLinkPath(pc.pluginDirectory, commandName)) if err != nil { return false, fmt.Errorf("failed to compute absolute path: %w", err) } diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go index fd37d0d7..4373eff1 100644 --- a/internal/plugins/cmd/plugins.go +++ b/internal/plugins/cmd/plugins.go @@ -18,6 +18,7 @@ package plugins import ( "errors" + "fmt" "log/slog" "os" @@ -48,6 +49,37 @@ func NewPluginsCommand(logger *dkplog.Logger) *PluginsCommand { } } +// ensureInstallRoot creates /plugins; on permission denied +// falls back to ~/.deckhouse-cli, updates pc.pluginDirectory, and retries. +func (pc *PluginsCommand) ensureInstallRoot() error { + err := os.MkdirAll(layout.PluginsRoot(pc.pluginDirectory), 0755) + if !errors.Is(err, os.ErrPermission) { + return err + } + pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", + slog.String("was", pc.pluginDirectory), dkplog.Err(err)) + fallback, ferr := layout.HomeFallbackPath() + if ferr != nil { + return fmt.Errorf("home fallback: %w", ferr) + } + pc.pluginDirectory = fallback + return os.MkdirAll(layout.PluginsRoot(pc.pluginDirectory), 0755) +} + +// cachedDescription returns the description from the on-disk plugin contract +// cache, or "" if the cache is missing or unreadable. +func (pc *PluginsCommand) cachedDescription(pluginName string) string { + contract, err := service.GetPluginContractFromFile(layout.ContractFile(pc.pluginDirectory, pluginName)) + if err != nil { + pc.logger.Debug("failed to get plugin contract from cache", slog.String("error", err.Error())) + return "" + } + if contract == nil { + return "" + } + return contract.Description +} + func NewCommand(logger *dkplog.Logger) *cobra.Command { pc := NewPluginsCommand(logger) @@ -58,17 +90,8 @@ func NewCommand(logger *dkplog.Logger) *cobra.Command { PersistentPreRun: func(_ *cobra.Command, _ []string) { // init plugin services for subcommands after flags are parsed pc.InitPluginServices() - - err := os.MkdirAll(layout.PluginsRoot(flags.DeckhousePluginsDir), 0755) - // if permission failed - if errors.Is(err, os.ErrPermission) { - pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", slog.String("new_path", flags.DeckhousePluginsDir), dkplog.Err(err)) - - pc.pluginDirectory, err = layout.HomeFallbackPath() - if err != nil { - logger.Debug("failed to receive home dir to create plugins dir", slog.String("error", err.Error())) - return - } + if err := pc.ensureInstallRoot(); err != nil { + pc.logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) } }, }