From 57c3694233b50bfb11f2671c812d2109067c6369 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Tue, 24 Mar 2026 05:29:13 -0400 Subject: [PATCH] [minor] allow create on remote contexts --- cmd/config.go | 676 --------------------------- cmd/config_create_test.go | 853 ----------------------------------- cmd/create.go | 85 +++- cmd/root.go | 6 +- pkg/plugin/creates.go | 669 ++++++++++++++++++++++++++- pkg/plugin/discovery.go | 148 +++--- pkg/plugin/discovery_test.go | 42 +- pkg/plugin/sdk.go | 41 +- pkg/plugin/sdk_test.go | 46 +- 9 files changed, 890 insertions(+), 1676 deletions(-) delete mode 100644 cmd/config_create_test.go diff --git a/cmd/config.go b/cmd/config.go index 52ff90e..259a21d 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -5,20 +5,15 @@ import ( "fmt" "io" "os" - "os/user" - "path/filepath" "sort" - "strconv" "strings" "text/tabwriter" corecomponent "github.com/libops/sitectl/pkg/component" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/helpers" - "github.com/libops/sitectl/pkg/plugin" sitevalidate "github.com/libops/sitectl/pkg/validate" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) var configCmd = &cobra.Command{ @@ -384,633 +379,6 @@ var deleteContextCmd = &cobra.Command{ }, } -var createConfigInput = config.GetInput -var createConfigPromptChoice = corecomponent.PromptChoice -var createConfigDiscoverPlugins = plugin.DiscoverInstalled -var createConfigVerifyRemote = func(ctx *config.Context) error { - return ctx.VerifyRemoteInput(true) -} -var createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { - return ctx.ProjectDirExists() -} -var createConfigRunComposePS = func(ctx *config.Context) error { - return ctx.ValidateComposeAccess() -} - -// createConfigCmd creates sitectl config for existing isle-site-template installs -var createConfigCmd = &cobra.Command{ - Use: "create [context-name]", - Args: cobra.RangeArgs(0, 1), - Short: "Create a sitectl config for existing Docker Compose installs", - Long: `Create a sitectl context for an existing Docker Compose installation. - -This command registers an existing Docker Compose site with sitectl so you can manage it. -It does NOT create a new Docker Compose site - use 'create context' for that. - -The command will interactively prompt for: - - Whether the site is local or remote - - Project directory path - - Remote SSH connection details (if applicable) - -Examples: - # Create config for a local Docker Compose site - sitectl create config dev --type local --project-dir /home/user/isle - - # Create config for a remote Docker Compose site - sitectl create config prod \ - --type remote \ - --project-dir /opt/isle \ - --ssh-hostname isle.example.com \ - --ssh-user deploy \ - --ssh-key ~/.ssh/id_rsa`, - RunE: func(cmd *cobra.Command, args []string) error { - return runCreateConfig(cmd, args) - }, -} - -func runCreateConfig(cmd *cobra.Command, args []string) error { - f := cmd.Flags() - defaultContext, err := f.GetBool("default") - if err != nil { - return err - } - - if len(args) == 0 { - cwd, err := os.Getwd() - if err != nil { - return err - } - if existing, err := config.FindLocalContextByProjectDir(cwd); err != nil { - return err - } else if existing != nil { - return fmt.Errorf("current directory is already registered as local context %q", existing.Name) - } - if config.LooksLikeComposeProject(cwd) { - fmt.Fprintln(cmd.OutOrStdout(), corecomponent.RenderIntroSection( - "Create a sitectl config for existing Docker Compose installs", - "Detected a Docker Compose project in the current directory. This flow will register it as a local sitectl context.", - )) - fmt.Fprintln(cmd.OutOrStdout()) - ctx, err := config.PromptAndSaveLocalContext(config.LocalContextCreateOptions{ - DefaultName: filepath.Base(cwd), - Site: filepath.Base(cwd), - DefaultSite: filepath.Base(cwd), - Plugin: "core", - DefaultPlugin: "core", - ProjectDir: cwd, - DefaultProjectDir: cwd, - DefaultProjectName: filepath.Base(cwd), - Environment: "local", - SetDefault: defaultContext, - Input: createConfigInput, - ProjectDirValidator: config.ValidateExistingComposeProjectDir, - ContextNamePrompt: append( - strings.Split(corecomponent.RenderSection("sitectl context name", "This is the saved sitectl target for this project. A good pattern is -, for example museum-local or museum-prod."), "\n"), - "", - corecomponent.RenderPromptLine("Context name [%s]: "), - ), - ProjectDirPrompt: append( - strings.Split(corecomponent.RenderSection("Project directory", "Confirm the full directory path where this existing Docker Compose project lives."), "\n"), - "", - corecomponent.RenderPromptLine("Project directory [%s]: "), - ), - }) - if err != nil { - return err - } - if !f.Changed("plugin") { - selectedPlugin, pluginErr := promptContextPlugin(ctx.Plugin) - if pluginErr != nil { - return pluginErr - } - if strings.TrimSpace(selectedPlugin) != "" && selectedPlugin != ctx.Plugin { - ctx.Plugin = selectedPlugin - if err := config.SaveContext(ctx, defaultContext); err != nil { - return err - } - } - } - writeCreatedContextSummary(cmd, "Context created successfully", ctx) - if err := promptAdditionalEnvironmentContexts(cmd, ctx); err != nil { - return err - } - return nil - } - - contextName, err := createConfigInput( - append( - strings.Split(corecomponent.RenderSection( - "sitectl context name", - "Provide a admin label for this site and environment. Only provide alpha numeric characters and dashes. A good pattern is -, for example museum-local or museum-prod.", - ), "\n"), - "", - corecomponent.RenderPromptLine("Context name: "), - )..., - ) - if err != nil { - return err - } - contextName = strings.TrimSpace(contextName) - if contextName == "" { - return fmt.Errorf("context name cannot be empty") - } - args = []string{contextName} - } - - cc, err := config.GetContext(args[0]) - if err != nil { - if !errors.Is(err, config.ErrContextNotFound) { - return err - } - cc = config.Context{Name: args[0]} - } - - cexists := !errors.Is(err, config.ErrContextNotFound) - context, err := config.LoadFromFlags(f, cc) - if err != nil { - return err - } - if !cexists { - if active, activeErr := config.CurrentContext(f); activeErr == nil && active != nil && active.Name != args[0] { - inheritNewContextDefaultsFromActive(context, active, f) - } - } - context.Name = args[0] - if strings.TrimSpace(context.Plugin) == "" { - context.Plugin = "core" - } - - if cexists { - overwrite, err := createConfigInput("The context already exists. Do you want to overwrite it? [y/N]: ") - if err != nil { - return err - } - if !strings.EqualFold(overwrite, "y") && !strings.EqualFold(overwrite, "yes") { - return fmt.Errorf("context creation cancelled") - } - } - - if !f.Changed("type") { - t, err := createConfigInput(fmt.Sprintf("Is the context local (on this machine) or remote (on a VM)? [%s]: ", string(context.DockerHostType))) - if err != nil { - return err - } - if t != "" { - if t != "remote" && t != "local" { - return fmt.Errorf("unknown context type %q: valid values are local or remote", t) - } - context.DockerHostType = config.ContextType(t) - } - } - if !f.Changed("project-dir") { - dir, err := createConfigInput(fmt.Sprintf("Full directory path to the project (directory where docker-compose.yml is located) [%s]: ", context.ProjectDir)) - if err != nil { - return err - } - if dir != "" { - context.ProjectDir = dir - } - } - context.ProjectDir, err = config.ExpandProjectDir(context.ProjectDir) - if err != nil { - return err - } - if context.DockerHostType == config.ContextLocal && strings.TrimSpace(context.Environment) == "" { - context.Environment = "local" - } - if !f.Changed("plugin") && (strings.TrimSpace(context.Plugin) == "" || context.Plugin == "core") { - selectedPlugin, pluginErr := promptContextPlugin(context.Plugin) - if pluginErr != nil { - return pluginErr - } - if strings.TrimSpace(selectedPlugin) != "" { - context.Plugin = selectedPlugin - } - } - if !f.Changed("project-name") && placeholderProjectName(context.ProjectName) { - context.ProjectName = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), "docker-compose") - } - if strings.TrimSpace(context.ProjectName) == "" { - context.ProjectName = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), "docker-compose") - } - if !f.Changed("compose-project-name") && strings.TrimSpace(context.ComposeProjectName) == "" { - context.ComposeProjectName = helpers.FirstNonEmpty(config.DetectComposeProjectName(context.ProjectDir), context.ProjectName) - } - if !f.Changed("compose-network") && strings.TrimSpace(context.ComposeNetwork) == "" { - context.ComposeNetwork = config.DetectComposeNetworkName(context.ProjectDir, context.EffectiveComposeProjectName()) - } - if !f.Changed("site") && placeholderProjectName(context.Site) { - context.Site = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) - } - if strings.TrimSpace(context.Site) == "" { - context.Site = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) - } - - if context.DockerHostType == config.ContextRemote { - if strings.TrimSpace(context.DockerSocket) == "" { - context.DockerSocket = "/var/run/docker.sock" - } - err = createConfigVerifyRemote(context) - if err != nil { - return err - } - } else if !f.Changed("docker-socket") { - context.DockerSocket = config.GetDefaultLocalDockerSocket(context.DockerSocket) - } - exists, err := createConfigProjectDirExists(context) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("project directory %q does not exist", context.ProjectDir) - } - if context.DockerHostType == config.ContextRemote { - if err := validateRemoteDockerAccess(context); err != nil { - return err - } - } - - if err := config.SaveContext(context, defaultContext); err != nil { - return err - } - writeCreatedContextSummary(cmd, "Context created successfully", context) - if context.DockerHostType == config.ContextLocal { - if err := promptAdditionalEnvironmentContexts(cmd, context); err != nil { - return err - } - } - return nil -} - -func placeholderProjectName(value string) bool { - value = strings.TrimSpace(value) - return value == "" || value == "docker-compose" -} - -func promptContextPlugin(defaultPlugin string) (string, error) { - choices := []corecomponent.Choice{{ - Value: "core", - Label: "core", - Help: "No stack-specific plugin. Use base sitectl behavior.", - Aliases: []string{"1"}, - }} - for _, discovered := range createConfigDiscoverPlugins() { - name := strings.TrimSpace(discovered.Name) - if name == "" || name == "core" { - continue - } - choices = append(choices, corecomponent.Choice{ - Value: name, - Label: name, - Help: helpers.FirstNonEmpty(discovered.Description, "Use the "+name+" plugin for this site."), - Aliases: nil, - }) - } - if len(choices) == 1 { - return helpers.FirstNonEmpty(defaultPlugin, "core"), nil - } - selected, err := createConfigPromptChoice( - "plugin", - choices, - helpers.FirstNonEmpty(strings.TrimSpace(defaultPlugin), "core"), - createConfigInput, - strings.Split(corecomponent.RenderSection("plugin", "If this project belongs to a known sitectl plugin, pick it here. For example, an Islandora stack would usually use the isle plugin."), "\n")..., - ) - if err != nil { - return "", err - } - return strings.TrimSpace(selected), nil -} - -func promptAdditionalEnvironmentContexts(cmd *cobra.Command, localCtx *config.Context) error { - if localCtx == nil || localCtx.DockerHostType != config.ContextLocal { - return nil - } - - var previousRemote *config.Context - for { - answer, err := createConfigPromptChoice( - "add-environment", - []corecomponent.Choice{ - { - Value: "yes", - Label: "yes", - Help: "Create another environment context for this site.", - Aliases: []string{"y", "1"}, - }, - { - Value: "no", - Label: "no", - Help: "Finish without adding more environment contexts.", - Aliases: []string{"n", "2"}, - }, - }, - "no", - createConfigInput, - strings.Split(corecomponent.RenderSection("Additional environments", "Add another environment for this site?"), "\n")..., - ) - if err != nil { - return err - } - if strings.TrimSpace(answer) != "yes" { - return nil - } - - remoteCtx, err := promptRemoteEnvironmentContext(localCtx, previousRemote) - if err != nil { - return err - } - if err := config.SaveContext(remoteCtx, false); err != nil { - return err - } - previousRemote = remoteCtx - writeCreatedContextSummary(cmd, "Environment context created successfully", remoteCtx) - } -} - -func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (*config.Context, error) { - environment, err := promptRequiredValue("Environment name (e.g. dev, staging, prod): ") - if err != nil { - return nil, err - } - - defaultName := suggestedEnvironmentContextName(localCtx, environment) - name, err := createConfigInput(fmt.Sprintf("Context name [%s]: ", defaultName)) - if err != nil { - return nil, err - } - name = strings.TrimSpace(name) - if name == "" { - name = defaultName - } - - projectDir, err := promptRequiredValueWithDefault( - "Full directory path to the remote project (directory where docker-compose.yml is located)", - helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectDir })), - ) - if err != nil { - return nil, err - } - - currentUser := "" - if u, err := user.Current(); err == nil { - currentUser = u.Username - } - defaultKey := filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") - hostname := "" - sshUser := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHUser }), currentUser, "root") - sshPort := remoteContextUint(previousRemote, func(ctx *config.Context) uint { return ctx.SSHPort }, 22) - sshKey := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHKeyPath }), defaultKey) - dockerSocket := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.DockerSocket }), "/var/run/docker.sock") - projectName := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectName }), localCtx.ProjectName, "docker-compose") - composeProjectName := helpers.FirstNonEmpty( - remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ComposeProjectName }), - localCtx.EffectiveComposeProjectName(), - projectName, - ) - composeNetwork := helpers.FirstNonEmpty( - remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ComposeNetwork }), - localCtx.ComposeNetwork, - localCtx.EffectiveComposeNetwork(), - ) - for { - hostname, err = promptRequiredValueWithDefault("Remote hostname/domain (e.g. stage.example.com)", hostname) - if err != nil { - return nil, err - } - sshUser, err = promptRequiredValueWithDefault("SSH user", sshUser) - if err != nil { - return nil, err - } - sshPort, err = promptUintWithDefault("SSH port", sshPort) - if err != nil { - return nil, err - } - sshKey, err = promptRequiredValueWithDefault("Path to SSH private key", sshKey) - if err != nil { - return nil, err - } - - remoteCtx := &config.Context{ - Name: name, - Site: helpers.FirstNonEmpty(localCtx.Site, localCtx.ProjectName, localCtx.Name), - Plugin: helpers.FirstNonEmpty(localCtx.Plugin, "core"), - DockerHostType: config.ContextRemote, - Environment: environment, - ProjectDir: strings.TrimSpace(projectDir), - ProjectName: helpers.FirstNonEmpty(localCtx.ProjectName, "docker-compose"), - ComposeProjectName: composeProjectName, - ComposeNetwork: composeNetwork, - SSHHostname: strings.TrimSpace(hostname), - SSHUser: sshUser, - SSHPort: sshPort, - SSHKeyPath: sshKey, - DockerSocket: dockerSocket, - ComposeFile: append([]string{}, localCtx.ComposeFile...), - EnvFile: append([]string{}, localCtx.EnvFile...), - DatabaseService: localCtx.DatabaseService, - DatabaseUser: localCtx.DatabaseUser, - DatabasePasswordSecret: localCtx.DatabasePasswordSecret, - DatabaseName: localCtx.DatabaseName, - } - remoteCtx.ProjectName = projectName - remoteCtx.ComposeProjectName = composeProjectName - remoteCtx.ComposeNetwork = composeNetwork - if detected := config.DetectContextComposeNetwork(remoteCtx); detected != "" { - remoteCtx.ComposeNetwork = detected - } - - if err := createConfigVerifyRemote(remoteCtx); err != nil { - retry, retryErr := createConfigPromptChoice( - "retry-environment-connection", - []corecomponent.Choice{ - { - Value: "retry", - Label: "retry", - Help: "Re-enter the remote connection details for this environment.", - Aliases: []string{"y", "yes", "1"}, - }, - { - Value: "cancel", - Label: "cancel", - Help: "Stop adding this environment context.", - Aliases: []string{"n", "no", "2"}, - }, - }, - "retry", - createConfigInput, - strings.Split(corecomponent.RenderSection("Remote connection failed", err.Error()), "\n")..., - ) - if retryErr != nil { - return nil, retryErr - } - if strings.TrimSpace(retry) != "retry" { - return nil, err - } - continue - } - exists, err := createConfigProjectDirExists(remoteCtx) - if err != nil { - return nil, err - } - if !exists { - return nil, fmt.Errorf("project directory %q does not exist", remoteCtx.ProjectDir) - } - if err := validateRemoteDockerAccess(remoteCtx); err != nil { - return nil, err - } - return remoteCtx, nil - } -} - -func promptRequiredValue(prompt string) (string, error) { - value, err := createConfigInput(prompt) - if err != nil { - return "", err - } - value = strings.TrimSpace(value) - if value == "" { - return "", fmt.Errorf("value cannot be empty") - } - return value, nil -} - -func promptRequiredValueWithDefault(label, defaultValue string) (string, error) { - value, err := createConfigInput(fmt.Sprintf("%s [%s]: ", label, defaultValue)) - if err != nil { - return "", err - } - value = strings.TrimSpace(value) - if value == "" { - value = strings.TrimSpace(defaultValue) - } - if value == "" { - return "", fmt.Errorf("value cannot be empty") - } - return value, nil -} - -func promptUintWithDefault(label string, defaultValue uint) (uint, error) { - value, err := createConfigInput(fmt.Sprintf("%s [%d]: ", label, defaultValue)) - if err != nil { - return 0, err - } - value = strings.TrimSpace(value) - if value == "" { - return defaultValue, nil - } - parsed, err := strconv.Atoi(value) - if err != nil { - return 0, fmt.Errorf("invalid %s %q", strings.ToLower(label), value) - } - return uint(parsed), nil -} - -func validateRemoteDockerAccess(ctx *config.Context) error { - if ctx == nil || ctx.DockerHostType != config.ContextRemote { - return nil - } - for { - fmt.Fprintln(os.Stdout) - fmt.Fprintln(os.Stdout, corecomponent.RenderSection("Remote docker validation", fmt.Sprintf("Checking docker compose access for `%s` with `docker compose ps`.", ctx.Name))) - if err := createConfigRunComposePS(ctx); err != nil { - action, actionErr := createConfigPromptChoice( - "update-environment-context", - []corecomponent.Choice{ - { - Value: "update", - Label: "update", - Help: "Update the remote Docker settings and try compose ps again.", - Aliases: []string{"y", "yes", "1"}, - }, - { - Value: "cancel", - Label: "cancel", - Help: "Stop using this remote context configuration.", - Aliases: []string{"n", "no", "2"}, - }, - }, - "update", - createConfigInput, - strings.Split(corecomponent.RenderSection("Remote docker validation failed", err.Error()), "\n")..., - ) - if actionErr != nil { - return actionErr - } - if strings.TrimSpace(action) != "update" { - return err - } - projectDir, promptErr := promptRequiredValueWithDefault("Full directory path to the remote project (directory where docker-compose.yml is located)", ctx.ProjectDir) - if promptErr != nil { - return promptErr - } - projectName, promptErr := promptRequiredValueWithDefault("Logical project name", helpers.FirstNonEmpty(ctx.ProjectName, "docker-compose")) - if promptErr != nil { - return promptErr - } - dockerSocket, promptErr := promptRequiredValueWithDefault("Docker socket", helpers.FirstNonEmpty(ctx.DockerSocket, "/var/run/docker.sock")) - if promptErr != nil { - return promptErr - } - ctx.ProjectDir = projectDir - ctx.ProjectName = projectName - ctx.ComposeProjectName = helpers.FirstNonEmpty(ctx.ComposeProjectName, projectName) - ctx.ComposeNetwork = helpers.FirstNonEmpty(config.DetectContextComposeNetwork(ctx), ctx.ComposeNetwork, ctx.EffectiveComposeNetwork()) - ctx.DockerSocket = dockerSocket - continue - } - return nil - } -} - -func writeCreatedContextSummary(cmd *cobra.Command, title string, ctx *config.Context) { - if cmd == nil || ctx == nil { - return - } - contextStr, err := ctx.String() - if err != nil { - fmt.Fprintln(cmd.OutOrStdout(), corecomponent.RenderSection(title, ctx.Name)) - return - } - fmt.Fprintln(cmd.OutOrStdout()) - fmt.Fprintln(cmd.OutOrStdout(), corecomponent.RenderSection(title, strings.TrimSpace(contextStr))) -} - -func remoteContextValue(ctx *config.Context, getter func(*config.Context) string) string { - if ctx == nil || getter == nil { - return "" - } - return strings.TrimSpace(getter(ctx)) -} - -func remoteContextUint(ctx *config.Context, getter func(*config.Context) uint, fallback uint) uint { - if ctx == nil || getter == nil { - return fallback - } - if value := getter(ctx); value != 0 { - return value - } - return fallback -} - -func suggestedEnvironmentContextName(localCtx *config.Context, environment string) string { - base := strings.TrimSpace(environment) - if localCtx == nil { - return base - } - name := strings.TrimSpace(localCtx.Name) - if strings.HasSuffix(name, "-local") { - return strings.TrimSuffix(name, "-local") + "-" + environment - } - if strings.TrimSpace(localCtx.ProjectName) != "" && localCtx.ProjectName != "docker-compose" { - return localCtx.ProjectName + "-" + environment - } - if name != "" { - return name + "-" + environment - } - return base -} - func writeContextTable(out io.Writer, cfg *config.Config) { w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "CURRENT\tCONTEXT\tSITE\tPLUGIN\tENVIRONMENT\tTYPE\tPROJECT") @@ -1103,60 +471,16 @@ func uniqueSortedContextValues(contexts []config.Context, getter func(config.Con return ordered } -func inheritNewContextDefaultsFromActive(target, active *config.Context, flags *pflag.FlagSet) { - if target == nil || active == nil || flags == nil { - return - } - if !flags.Changed("site") && target.Site == "" && active.Site != "" { - target.Site = active.Site - } - if !flags.Changed("plugin") && (target.Plugin == "" || target.Plugin == "core") && active.Plugin != "" { - target.Plugin = active.Plugin - } - if !flags.Changed("project-name") && active.ProjectName != "" && target.ProjectName == "docker-compose" { - target.ProjectName = active.ProjectName - } - if !flags.Changed("compose-project-name") && active.ComposeProjectName != "" && target.ComposeProjectName == "" { - target.ComposeProjectName = active.ComposeProjectName - } - if !flags.Changed("compose-network") && active.ComposeNetwork != "" && target.ComposeNetwork == "" { - target.ComposeNetwork = active.ComposeNetwork - } - if !flags.Changed("compose-file") && len(target.ComposeFile) == 0 && len(active.ComposeFile) > 0 { - target.ComposeFile = append([]string{}, active.ComposeFile...) - } - if !flags.Changed("env-file") && len(target.EnvFile) == 0 && len(active.EnvFile) > 0 { - target.EnvFile = append([]string{}, active.EnvFile...) - } - if !flags.Changed("database-service") && target.DatabaseService == "mariadb" && active.DatabaseService != "" { - target.DatabaseService = active.DatabaseService - } - if !flags.Changed("database-user") && target.DatabaseUser == "root" && active.DatabaseUser != "" { - target.DatabaseUser = active.DatabaseUser - } - if !flags.Changed("database-password-secret") && target.DatabasePasswordSecret == "DB_ROOT_PASSWORD" && active.DatabasePasswordSecret != "" { - target.DatabasePasswordSecret = active.DatabasePasswordSecret - } - if !flags.Changed("database-name") && target.DatabaseName == "drupal_default" && active.DatabaseName != "" { - target.DatabaseName = active.DatabaseName - } -} - func init() { setFlags := setContextCmd.Flags() config.SetCommandFlags(setFlags) setFlags.Bool("default", false, "set to default context") - createFlags := createConfigCmd.Flags() - config.SetCommandFlags(createFlags) - createFlags.Bool("default", false, "set to default context") - validateConfigCmd.Flags().BoolVar(&configValidateAll, "all", false, "Validate all configured contexts") validateConfigCmd.Flags().StringVar(&configValidateSite, "site", "", "Validate all contexts for a specific site") corecomponent.AddReportFlags(validateConfigCmd, nil, &configValidateFormat) configCmd.AddCommand(viewConfigCmd) - configCmd.AddCommand(createConfigCmd) configCmd.AddCommand(currentContextCmd) configCmd.AddCommand(getContextsCmd) configCmd.AddCommand(getSitesCmd) diff --git a/cmd/config_create_test.go b/cmd/config_create_test.go deleted file mode 100644 index 3bc22db..0000000 --- a/cmd/config_create_test.go +++ /dev/null @@ -1,853 +0,0 @@ -package cmd - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - corecomponent "github.com/libops/sitectl/pkg/component" - "github.com/libops/sitectl/pkg/config" - "github.com/libops/sitectl/pkg/plugin" - "github.com/spf13/cobra" -) - -func TestRunCreateConfigAutodetectsCurrentComposeProject(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "museum") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldWd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(projectDir); err != nil { - t.Fatalf("Chdir(projectDir) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - if name == "plugin" { - return "core", nil - } - if name == "add-environment" { - return "no", nil - } - t.Fatalf("did not expect choice prompt for default autodetected local context: %s", name) - return "", nil - } - createConfigInput = func(question ...string) (string, error) { - t.Fatalf("did not expect prompt for default autodetected local context: %v", question) - return "", nil - } - - cmd := &cobra.Command{Use: "create"} - cmd.Flags().Bool("default", true, "") - var out bytes.Buffer - cmd.SetOut(&out) - - if err := runCreateConfig(cmd, nil); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - ctx, err := config.GetContext("museum") - if err != nil { - t.Fatalf("GetContext(museum) error = %v", err) - } - if ctx.Name != "museum" { - t.Fatalf("expected context museum, got %q", ctx.Name) - } - wantProjectDir := projectDir - if resolved, err := filepath.EvalSymlinks(projectDir); err == nil { - wantProjectDir = resolved - } - if ctx.ProjectDir != wantProjectDir { - t.Fatalf("expected project dir %q, got %q", wantProjectDir, ctx.ProjectDir) - } - if ctx.Environment != "local" { - t.Fatalf("expected environment local, got %q", ctx.Environment) - } - if ctx.Site != "museum" { - t.Fatalf("expected site museum, got %q", ctx.Site) - } - if ctx.Plugin != "core" { - t.Fatalf("expected plugin core, got %q", ctx.Plugin) - } - if !strings.Contains(out.String(), "CONTEXT CREATED SUCCESSFULLY") { - t.Fatalf("expected success output, got:\n%s", out.String()) - } -} - -func TestRunCreateConfigAutodetectsCurrentComposeProjectAndAddsRemoteEnvironment(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "museum") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldWd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(projectDir); err != nil { - t.Fatalf("Chdir(projectDir) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { - return []plugin.InstalledPlugin{{Name: "isle", Description: "Islandora utilities"}} - } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - choiceCalls := 0 - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - if name == "plugin" { - return "isle", nil - } - if name != "add-environment" { - t.Fatalf("unexpected choice prompt: %s", name) - } - choiceCalls++ - if choiceCalls == 1 { - return "yes", nil - } - return "no", nil - } - - prompts := []string{ - "staging", - "", - "/opt/museum", - "stage.example.com", - "deploy", - "", - "", - } - createConfigInput = func(question ...string) (string, error) { - if len(prompts) == 0 { - t.Fatalf("unexpected prompt: %v", question) - } - value := prompts[0] - prompts = prompts[1:] - return value, nil - } - - cmd := &cobra.Command{Use: "create"} - cmd.Flags().Bool("default", true, "") - var out bytes.Buffer - cmd.SetOut(&out) - - if err := runCreateConfig(cmd, nil); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - localCtx, err := config.GetContext("museum") - if err != nil { - t.Fatalf("GetContext(museum) error = %v", err) - } - remoteCtx, err := config.GetContext("museum-staging") - if err != nil { - t.Fatalf("GetContext(museum-staging) error = %v", err) - } - if remoteCtx.DockerHostType != config.ContextRemote { - t.Fatalf("expected remote context, got %q", remoteCtx.DockerHostType) - } - if remoteCtx.Environment != "staging" { - t.Fatalf("expected staging environment, got %q", remoteCtx.Environment) - } - if remoteCtx.Site != localCtx.Site { - t.Fatalf("expected remote site %q, got %q", localCtx.Site, remoteCtx.Site) - } - if remoteCtx.Plugin != localCtx.Plugin { - t.Fatalf("expected remote plugin %q, got %q", localCtx.Plugin, remoteCtx.Plugin) - } - if remoteCtx.ProjectDir != "/opt/museum" { - t.Fatalf("expected remote project dir /opt/museum, got %q", remoteCtx.ProjectDir) - } - if remoteCtx.ProjectName != localCtx.ProjectName { - t.Fatalf("expected remote project name %q, got %q", localCtx.ProjectName, remoteCtx.ProjectName) - } - if !strings.Contains(out.String(), "ENVIRONMENT CONTEXT CREATED SUCCESSFULLY") { - t.Fatalf("expected remote environment output, got:\n%s", out.String()) - } - if localCtx.Plugin != "isle" { - t.Fatalf("expected local plugin isle, got %q", localCtx.Plugin) - } - if localCtx.Environment != "local" { - t.Fatalf("expected local environment local, got %q", localCtx.Environment) - } - if localCtx.ComposeProjectName == "" { - t.Fatalf("expected compose project name to be derived") - } -} - -func TestRunCreateConfigPromptsForContextWhenCwdIsNotComposeProject(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "existing-site") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldWd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(tempHome); err != nil { - t.Fatalf("Chdir(tempHome) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - if name == "plugin" { - return "core", nil - } - if name == "add-environment" { - return "no", nil - } - t.Fatalf("unexpected choice prompt: %s", name) - return "", nil - } - - prompts := []string{ - "museum-dev", - "", - projectDir, - } - createConfigInput = func(question ...string) (string, error) { - if len(prompts) == 0 { - t.Fatalf("unexpected prompt: %v", question) - } - value := prompts[0] - prompts = prompts[1:] - return value, nil - } - - cmd := &cobra.Command{Use: "create"} - config.SetCommandFlags(cmd.Flags()) - cmd.Flags().Bool("default", true, "") - - if err := runCreateConfig(cmd, nil); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - ctx, err := config.GetContext("museum-dev") - if err != nil { - t.Fatalf("GetContext(museum-dev) error = %v", err) - } - if ctx.ProjectDir != projectDir { - t.Fatalf("expected project dir %q, got %q", projectDir, ctx.ProjectDir) - } - if ctx.DockerHostType != config.ContextLocal { - t.Fatalf("expected local context, got %q", ctx.DockerHostType) - } - if ctx.Site != "existing-site" { - t.Fatalf("expected site existing-site, got %q", ctx.Site) - } - if ctx.ProjectName != "existing-site" { - t.Fatalf("expected project name existing-site, got %q", ctx.ProjectName) - } - if ctx.ComposeProjectName != "existing-site" { - t.Fatalf("expected compose project name existing-site, got %q", ctx.ComposeProjectName) - } -} - -func TestRunCreateConfigUsesDetectedComposeProjectNameWithoutKeepingDockerComposePlaceholder(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "isle-preserve") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, ".env"), []byte("COMPOSE_PROJECT_NAME=lehigh-d10\n"), 0o644); err != nil { - t.Fatalf("WriteFile(.env) error = %v", err) - } - - oldWd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(tempHome); err != nil { - t.Fatalf("Chdir(tempHome) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - if name == "plugin" { - return "isle", nil - } - if name == "add-environment" { - return "no", nil - } - t.Fatalf("unexpected choice prompt: %s", name) - return "", nil - } - - prompts := []string{ - "isle-preserve-local", - "", - projectDir, - } - createConfigInput = func(question ...string) (string, error) { - if len(prompts) == 0 { - t.Fatalf("unexpected prompt: %v", question) - } - value := prompts[0] - prompts = prompts[1:] - return value, nil - } - - cmd := &cobra.Command{Use: "create"} - config.SetCommandFlags(cmd.Flags()) - cmd.Flags().Bool("default", true, "") - - if err := runCreateConfig(cmd, nil); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - ctx, err := config.GetContext("isle-preserve-local") - if err != nil { - t.Fatalf("GetContext(isle-preserve-local) error = %v", err) - } - if ctx.Site != "isle-preserve" { - t.Fatalf("expected site isle-preserve, got %q", ctx.Site) - } - if ctx.ProjectName != "isle-preserve" { - t.Fatalf("expected project name isle-preserve, got %q", ctx.ProjectName) - } - if ctx.ComposeProjectName != "lehigh-d10" { - t.Fatalf("expected compose project name lehigh-d10, got %q", ctx.ComposeProjectName) - } -} - -func TestRunCreateConfigSkipsTypeAndProjectDirPromptsWhenFlagsProvided(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "existing-site") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - if name == "plugin" { - return "core", nil - } - if name == "add-environment" { - return "no", nil - } - t.Fatalf("unexpected choice prompt: %s", name) - return "", nil - } - - promptCount := 0 - createConfigInput = func(question ...string) (string, error) { - promptCount++ - t.Fatalf("did not expect text prompt when type and project-dir are provided: %v", question) - return "", nil - } - - cmd := &cobra.Command{Use: "create"} - config.SetCommandFlags(cmd.Flags()) - cmd.Flags().Bool("default", true, "") - if err := cmd.Flags().Set("type", "local"); err != nil { - t.Fatalf("Set(type) error = %v", err) - } - if err := cmd.Flags().Set("project-dir", projectDir); err != nil { - t.Fatalf("Set(project-dir) error = %v", err) - } - - if err := runCreateConfig(cmd, []string{"museum-dev"}); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - if promptCount != 0 { - t.Fatalf("expected no prompts, got %d", promptCount) - } - - ctx, err := config.GetContext("museum-dev") - if err != nil { - t.Fatalf("GetContext(museum-dev) error = %v", err) - } - if ctx.DockerHostType != config.ContextLocal { - t.Fatalf("expected local context, got %q", ctx.DockerHostType) - } - if ctx.ProjectDir != projectDir { - t.Fatalf("expected project dir %q, got %q", projectDir, ctx.ProjectDir) - } -} - -func TestRunCreateConfigExpandsProjectDirFlag(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "existing-site") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - if name == "plugin" { - return "core", nil - } - if name == "add-environment" { - return "no", nil - } - t.Fatalf("unexpected choice prompt: %s", name) - return "", nil - } - createConfigInput = func(question ...string) (string, error) { - t.Fatalf("did not expect prompt: %v", question) - return "", nil - } - - cmd := &cobra.Command{Use: "create"} - config.SetCommandFlags(cmd.Flags()) - cmd.Flags().Bool("default", true, "") - if err := cmd.Flags().Set("type", "local"); err != nil { - t.Fatalf("Set(type) error = %v", err) - } - if err := cmd.Flags().Set("project-dir", "$HOME/existing-site"); err != nil { - t.Fatalf("Set(project-dir) error = %v", err) - } - - if err := runCreateConfig(cmd, []string{"museum-dev"}); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - ctx, err := config.GetContext("museum-dev") - if err != nil { - t.Fatalf("GetContext(museum-dev) error = %v", err) - } - if ctx.ProjectDir != projectDir { - t.Fatalf("expected expanded project dir %q, got %q", projectDir, ctx.ProjectDir) - } -} - -func TestRunCreateConfigRepromptsRemoteConnectionDetailsAfterVerificationFailure(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "museum") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldWd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(projectDir); err != nil { - t.Fatalf("Chdir(projectDir) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - verifyCalls := 0 - createConfigVerifyRemote = func(ctx *config.Context) error { - verifyCalls++ - if verifyCalls == 1 { - return fmt.Errorf("ssh: handshake failed: knownhosts: key is unknown") - } - return nil - } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - createConfigRunComposePS = func(ctx *config.Context) error { return nil } - - choiceCalls := map[string]int{} - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - choiceCalls[name]++ - switch name { - case "plugin": - return "core", nil - case "add-environment": - if choiceCalls[name] == 1 { - return "yes", nil - } - return "no", nil - case "retry-environment-connection": - return "retry", nil - default: - t.Fatalf("unexpected choice prompt: %s", name) - return "", nil - } - } - - prompts := []string{ - "staging", - "", - "/opt/museum", - "bad.example.com", - "deploy", - "", - "", - "good.example.com", - "", - "", - "", - } - createConfigInput = func(question ...string) (string, error) { - if len(prompts) == 0 { - t.Fatalf("unexpected prompt: %v", question) - } - value := prompts[0] - prompts = prompts[1:] - return value, nil - } - - cmd := &cobra.Command{Use: "create"} - cmd.Flags().Bool("default", true, "") - var out bytes.Buffer - cmd.SetOut(&out) - - if err := runCreateConfig(cmd, nil); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - remoteCtx, err := config.GetContext("museum-staging") - if err != nil { - t.Fatalf("GetContext(museum-staging) error = %v", err) - } - if remoteCtx.SSHHostname != "good.example.com" { - t.Fatalf("expected retried hostname good.example.com, got %q", remoteCtx.SSHHostname) - } - if remoteCtx.Site != "museum" { - t.Fatalf("expected site museum, got %q", remoteCtx.Site) - } - if remoteCtx.Plugin != "core" { - t.Fatalf("expected plugin core, got %q", remoteCtx.Plugin) - } - if choiceCalls["retry-environment-connection"] != 1 { - t.Fatalf("expected one retry prompt, got %d", choiceCalls["retry-environment-connection"]) - } -} - -func TestRunCreateConfigRepromptsDockerSettingsAfterComposePSFailure(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - projectDir := filepath.Join(tempHome, "museum") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatalf("MkdirAll(projectDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) - } - - oldWd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(projectDir); err != nil { - t.Fatalf("Chdir(projectDir) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) - - oldInput := createConfigInput - oldPromptChoice := createConfigPromptChoice - oldDiscoverPlugins := createConfigDiscoverPlugins - oldVerifyRemote := createConfigVerifyRemote - oldProjectDirExists := createConfigProjectDirExists - oldRunComposePS := createConfigRunComposePS - t.Cleanup(func() { - createConfigInput = oldInput - createConfigPromptChoice = oldPromptChoice - createConfigDiscoverPlugins = oldDiscoverPlugins - createConfigVerifyRemote = oldVerifyRemote - createConfigProjectDirExists = oldProjectDirExists - createConfigRunComposePS = oldRunComposePS - }) - - createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } - createConfigVerifyRemote = func(ctx *config.Context) error { return nil } - createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } - composeCalls := 0 - createConfigRunComposePS = func(ctx *config.Context) error { - composeCalls++ - if composeCalls == 1 { - if ctx.DockerSocket != "/var/run/docker.sock" { - t.Fatalf("expected default remote docker socket /var/run/docker.sock, got %q", ctx.DockerSocket) - } - return fmt.Errorf("cannot connect to docker daemon") - } - if ctx.DockerSocket != "/run/user/1000/docker.sock" { - t.Fatalf("expected updated docker socket, got %q", ctx.DockerSocket) - } - if ctx.ProjectName != "museum-prod" { - t.Fatalf("expected updated project name museum-prod, got %q", ctx.ProjectName) - } - return nil - } - - choiceCalls := map[string]int{} - createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { - choiceCalls[name]++ - switch name { - case "plugin": - return "core", nil - case "add-environment": - if choiceCalls[name] == 1 { - return "yes", nil - } - return "no", nil - case "update-environment-context": - return "update", nil - default: - t.Fatalf("unexpected choice prompt: %s", name) - return "", nil - } - } - - prompts := []string{ - "prod", - "", - "/opt/museum", - "prod.example.com", - "deploy", - "", - "", - "", - "museum-prod", - "/run/user/1000/docker.sock", - } - createConfigInput = func(question ...string) (string, error) { - if len(prompts) == 0 { - t.Fatalf("unexpected prompt: %v", question) - } - value := prompts[0] - prompts = prompts[1:] - return value, nil - } - - cmd := &cobra.Command{Use: "create"} - cmd.Flags().Bool("default", true, "") - - if err := runCreateConfig(cmd, nil); err != nil { - t.Fatalf("runCreateConfig() error = %v", err) - } - - remoteCtx, err := config.GetContext("museum-prod") - if err != nil { - t.Fatalf("GetContext(museum-prod) error = %v", err) - } - if remoteCtx.DockerSocket != "/run/user/1000/docker.sock" { - t.Fatalf("expected saved docker socket /run/user/1000/docker.sock, got %q", remoteCtx.DockerSocket) - } - if remoteCtx.Site != "museum" { - t.Fatalf("expected saved site museum, got %q", remoteCtx.Site) - } - if remoteCtx.Plugin != "core" { - t.Fatalf("expected saved plugin core, got %q", remoteCtx.Plugin) - } -} - -func TestInheritNewContextDefaultsFromActive(t *testing.T) { - cmd := &cobra.Command{Use: "create"} - flags := cmd.Flags() - config.SetCommandFlags(flags) - - target, err := config.LoadFromFlags(flags, config.Context{}) - if err != nil { - t.Fatalf("LoadFromFlags() error = %v", err) - } - active := &config.Context{ - Name: "museum-local", - Site: "museum", - Plugin: "isle", - ProjectName: "museum", - ComposeFile: []string{"docker-compose.yml", "docker-compose.local.yml"}, - EnvFile: []string{".env", ".env.local"}, - DatabaseService: "postgres", - DatabaseUser: "museum", - DatabasePasswordSecret: "DB_PASSWORD", - DatabaseName: "museum", - } - - inheritNewContextDefaultsFromActive(target, active, flags) - - if target.Site != "museum" { - t.Fatalf("expected inherited site museum, got %q", target.Site) - } - if target.Plugin != "isle" { - t.Fatalf("expected inherited plugin isle, got %q", target.Plugin) - } - if target.ProjectName != "museum" { - t.Fatalf("expected inherited project name museum, got %q", target.ProjectName) - } - if strings.Join(target.ComposeFile, ",") != "docker-compose.yml,docker-compose.local.yml" { - t.Fatalf("expected inherited compose files, got %#v", target.ComposeFile) - } - if strings.Join(target.EnvFile, ",") != ".env,.env.local" { - t.Fatalf("expected inherited env files, got %#v", target.EnvFile) - } - if target.DatabaseService != "postgres" || target.DatabaseUser != "museum" || target.DatabasePasswordSecret != "DB_PASSWORD" || target.DatabaseName != "museum" { - t.Fatalf("expected inherited database settings, got %#v", target) - } -} diff --git a/cmd/create.go b/cmd/create.go index afc9a87..a48c511 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -59,12 +59,29 @@ func runCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("no create definitions found; install a sitectl-* plugin that registers one") } + forwarded := []string{} + if !createArgsContainFlag(args, "type") { + targetType, err := promptForCreateTarget() + if err != nil { + return err + } + forwarded = append(forwarded, "--type", string(targetType)) + } + owner, spec, remaining, err := resolveCreateInvocation(plugins, args) if err != nil { return err } + if !createArgsContainFlag(remaining, "checkout-source") && !createArgsContainFlag(args, "checkout-source") { + checkoutSource, sourceErr := promptForCheckoutSource(createForwardedTargetType(args, forwarded)) + if sourceErr != nil { + return sourceErr + } + forwarded = append(forwarded, "--checkout-source", string(checkoutSource)) + } - _, err = pluginSDK.InvokePluginCommand(owner, append([]string{"__create", spec.Name}, remaining...), plugin.CommandExecOptions{ + invokeArgs := append([]string{"__create", spec.Name}, append(forwarded, remaining...)...) + _, err = pluginSDK.InvokePluginCommand(owner, invokeArgs, plugin.CommandExecOptions{ Context: RootCmd.Context(), Stdin: cmd.InOrStdin(), Stdout: cmd.OutOrStdout(), @@ -124,6 +141,44 @@ func resolveCreateInvocation(plugins []plugin.InstalledPlugin, args []string) (s return owner, spec, remaining, err } +func promptForCreateTarget() (config.ContextType, error) { + selected, err := createPromptChoice( + "create target", + []corecomponent.Choice{ + {Value: string(config.ContextLocal), Label: "local", Help: "Run this stack on your local machine."}, + {Value: string(config.ContextRemote), Label: "remote", Help: "Run this stack on a remote machine over SSH."}, + }, + string(config.ContextLocal), + createPromptInput, + strings.Split(corecomponent.RenderSection("Target machine", "Choose where this stack will run."), "\n")..., + ) + if err != nil { + return "", err + } + return config.ContextType(strings.TrimSpace(selected)), nil +} + +func promptForCheckoutSource(targetType config.ContextType) (plugin.CheckoutSource, error) { + defaultChoice := string(plugin.CheckoutSourceTemplate) + if targetType == config.ContextRemote { + defaultChoice = string(plugin.CheckoutSourceExisting) + } + selected, err := createPromptChoice( + "checkout source", + []corecomponent.Choice{ + {Value: string(plugin.CheckoutSourceTemplate), Label: "template", Help: "Clone the template repository as a fresh install."}, + {Value: string(plugin.CheckoutSourceExisting), Label: "existing", Help: "Use a repo or checkout that already exists."}, + }, + defaultChoice, + createPromptInput, + strings.Split(corecomponent.RenderSection("Project source", "Choose whether to create from the template repository or use an existing checkout."), "\n")..., + ) + if err != nil { + return "", err + } + return plugin.CheckoutSource(strings.TrimSpace(selected)), nil +} + func promptForCreatePlugin(plugins []plugin.InstalledPlugin) (plugin.InstalledPlugin, error) { if len(plugins) == 1 { return plugins[0], nil @@ -258,3 +313,31 @@ func helpersFirstCreateRepo(installed plugin.InstalledPlugin, spec plugin.Create } return "-" } + +func createArgsContainFlag(args []string, name string) bool { + longFlag := "--" + name + for i, arg := range args { + if arg == longFlag { + return true + } + if strings.HasPrefix(arg, longFlag+"=") { + return true + } + if i > 0 && args[i-1] == longFlag { + return true + } + } + return false +} + +func createForwardedTargetType(args, forwarded []string) config.ContextType { + for i, arg := range append([]string{}, append(forwarded, args...)...) { + if arg == "--type" && i+1 < len(append([]string{}, append(forwarded, args...)...)) { + return config.ContextType(strings.TrimSpace(append([]string{}, append(forwarded, args...)...)[i+1])) + } + if strings.HasPrefix(arg, "--type=") { + return config.ContextType(strings.TrimSpace(strings.TrimPrefix(arg, "--type="))) + } + } + return config.ContextLocal +} diff --git a/cmd/root.go b/cmd/root.go index 13844ca..bf297d6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "os/signal" "strings" "syscall" + "time" "charm.land/fang/v2" "github.com/libops/sitectl/pkg/config" @@ -86,7 +87,10 @@ func init() { } func discoverAndRegisterPlugins() { - for _, discovered := range plugin.DiscoverInstalled() { + started := time.Now() + discoveredPlugins := plugin.DiscoverInstalledLightweight() + slog.Debug("registering discovered plugin commands", "count", len(discoveredPlugins), "duration", time.Since(started)) + for _, discovered := range discoveredPlugins { pluginName := discovered.Name pluginPath := discovered.Path binaryName := discovered.BinaryName diff --git a/pkg/plugin/creates.go b/pkg/plugin/creates.go index efc0cc2..24be346 100644 --- a/pkg/plugin/creates.go +++ b/pkg/plugin/creates.go @@ -2,12 +2,26 @@ package plugin import ( "fmt" + "os" + "os/user" + "path/filepath" + "strconv" "strings" + corecomponent "github.com/libops/sitectl/pkg/component" + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" "github.com/spf13/cobra" yaml "gopkg.in/yaml.v3" ) +type CheckoutSource string + +const ( + CheckoutSourceTemplate CheckoutSource = "template" + CheckoutSourceExisting CheckoutSource = "existing" +) + type CreateSpec struct { Name string `yaml:"name"` Plugin string `yaml:"plugin,omitempty"` @@ -33,6 +47,43 @@ type CreateRunner interface { Run(cmd *cobra.Command) error } +type ComposeCreateRequest struct { + ContextName string + TargetType config.ContextType + CheckoutSource CheckoutSource + Path string + TemplateRepo string + TemplateBranch string + Site string + Environment string + ProjectName string + ComposeProjectName string + ComposeNetwork string + DockerSocket string + SSHHostname string + SSHUser string + SSHPort uint + SSHKeyPath string + DrupalRootfs string + SetDefaultContext bool + SetupOnly bool + Decisions map[string]corecomponent.ReviewDecision +} + +type ComposeCreateContextOptions struct { + DefaultName string + DefaultSite string + DefaultPlugin string + DefaultProjectDir string + DefaultProjectName string + DefaultEnvironment string + DefaultDockerSocket string + DefaultDrupalRootfs string + DrupalContainerRoot string + ConfirmOverwrite bool + Input config.InputFunc +} + func (s *SDK) RegisterCreate(spec CreateSpec, cmd *cobra.Command) { if s == nil || cmd == nil { return @@ -70,10 +121,25 @@ func (s *SDK) RegisterCreateRunner(spec CreateSpec, runner CreateRunner) { return runner.Run(cmd) }, } - runner.BindFlags(cmd) + if !isDiscoveryMetadataInvocation() { + runner.BindFlags(cmd) + } s.RegisterCreate(spec, cmd) } +func (s *SDK) RegisterComponentDefinition(def corecomponent.Definition) { + if s == nil || strings.TrimSpace(def.Name) == "" { + return + } + s.componentDefs = append(s.componentDefs, def) +} + +func (s *SDK) RegisterComponentDefinitions(defs ...corecomponent.Definition) { + for _, def := range defs { + s.RegisterComponentDefinition(def) + } +} + func (s *SDK) CreateDefinitions() []CreateSpec { if s == nil { return nil @@ -85,6 +151,251 @@ func (s *SDK) CreateDefinitions() []CreateSpec { return out } +func (s *SDK) LocalComponentDefinitions() []corecomponent.Definition { + if s == nil { + return nil + } + out := make([]corecomponent.Definition, len(s.componentDefs)) + copy(out, s.componentDefs) + return out +} + +func (s *SDK) CreateComponentDefinitions() ([]corecomponent.Definition, error) { + defs := s.LocalComponentDefinitions() + for _, include := range s.Metadata.Includes { + output, err := s.InvokeIncludedPluginCommand(include, []string{"__create", "component-definitions"}, CommandExecOptions{Capture: true}) + if err != nil { + return nil, err + } + trimmed := strings.TrimSpace(output) + if trimmed == "" { + continue + } + var includeDefs []corecomponent.Definition + if err := yaml.Unmarshal([]byte(trimmed), &includeDefs); err != nil { + return nil, fmt.Errorf("parse create component definitions from plugin %q: %w", include, err) + } + defs = append(defs, includeDefs...) + } + return defs, nil +} + +func (s *SDK) BindComposeCreateFlags(cmd *cobra.Command, spec CreateSpec, drupalRootfs *string, defaultDrupalRootfs string) error { + if cmd == nil { + return fmt.Errorf("create command is nil") + } + cmd.Flags().String("path", "", "Directory where the stack will be checked out.") + cmd.Flags().String("project-dir", "", "Directory where the stack exists or will be created.") + cmd.Flags().String("type", "", "Target machine for this stack: local or remote.") + cmd.Flags().String("checkout-source", "", "How to source the project checkout: template or existing.") + cmd.Flags().String("template-repo", spec.DockerComposeRepo, "Git repository to clone as the Docker Compose stack.") + cmd.Flags().String("template-branch", normalizeCreateSpec(spec).DockerComposeBranch, "Branch or ref to clone from the template repository.") + cmd.Flags().Bool("default-context", false, "Set the new context as the default sitectl context.") + cmd.Flags().Bool("setup-only", false, "Clone and configure the checkout but do not start the stack.") + cmd.Flags().String("ssh-hostname", "", "SSH hostname for a remote target.") + cmd.Flags().Uint("ssh-port", 0, "SSH port for a remote target.") + cmd.Flags().String("ssh-user", "", "SSH user for a remote target.") + cmd.Flags().String("ssh-key", "", "Path to the SSH private key for a remote target.") + cmd.Flags().String("site", "", "Logical site name this stack belongs to.") + cmd.Flags().String("environment", "", "Environment name for the stack, such as local, dev, staging, or prod.") + cmd.Flags().String("project-name", "", "Logical project name for this stack.") + cmd.Flags().String("compose-project-name", "", "Docker Compose project name for this stack.") + cmd.Flags().String("compose-network", "", "Primary Docker Compose network name for this stack.") + cmd.Flags().String("docker-socket", "", "Docker socket path for the target machine.") + defs, err := s.CreateComponentDefinitions() + if err != nil { + return err + } + options := make([]corecomponent.CreateOption, 0, len(defs)) + for _, def := range defs { + options = append(options, def.CreateOption()) + } + corecomponent.AddCreateFlags(cmd, options...) + if drupalRootfs != nil { + corecomponent.AddDrupalRootfsFlag(cmd, drupalRootfs, defaultDrupalRootfs) + } + return nil +} + +func (s *SDK) ResolveComposeCreateRequest(cmd *cobra.Command, input config.InputFunc, drupalRootfs, defaultPath, defaultRepo, defaultBranch string) (ComposeCreateRequest, error) { + if cmd == nil { + return ComposeCreateRequest{}, fmt.Errorf("create command is nil") + } + if input == nil { + input = config.GetInput + } + + contextName := "" + if flag := cmd.Flags().Lookup("context"); flag != nil && cmd.Flags().Changed("context") { + value, err := cmd.Flags().GetString("context") + if err != nil { + return ComposeCreateRequest{}, fmt.Errorf("get context flag: %w", err) + } + contextName = strings.TrimSpace(value) + } + + targetType, err := resolveCreateTargetType(cmd, input) + if err != nil { + return ComposeCreateRequest{}, err + } + checkoutSource, err := resolveCheckoutSource(cmd, input, targetType) + if err != nil { + return ComposeCreateRequest{}, err + } + pathValue, err := resolveCreateProjectDir(cmd, defaultPath) + if err != nil { + return ComposeCreateRequest{}, err + } + templateRepo, err := cmd.Flags().GetString("template-repo") + if err != nil { + return ComposeCreateRequest{}, fmt.Errorf("get template-repo flag: %w", err) + } + if strings.TrimSpace(templateRepo) == "" { + templateRepo = defaultRepo + } + templateBranch, err := cmd.Flags().GetString("template-branch") + if err != nil { + return ComposeCreateRequest{}, fmt.Errorf("get template-branch flag: %w", err) + } + if strings.TrimSpace(templateBranch) == "" { + templateBranch = defaultBranch + } + setDefaultContext, err := cmd.Flags().GetBool("default-context") + if err != nil { + return ComposeCreateRequest{}, fmt.Errorf("get default-context flag: %w", err) + } + setupOnly, err := cmd.Flags().GetBool("setup-only") + if err != nil { + return ComposeCreateRequest{}, fmt.Errorf("get setup-only flag: %w", err) + } + request := ComposeCreateRequest{ + ContextName: contextName, + TargetType: targetType, + CheckoutSource: checkoutSource, + Path: strings.TrimSpace(pathValue), + TemplateRepo: strings.TrimSpace(templateRepo), + TemplateBranch: strings.TrimSpace(templateBranch), + DrupalRootfs: strings.TrimSpace(drupalRootfs), + SetDefaultContext: setDefaultContext, + SetupOnly: setupOnly, + } + request.Site, _ = cmd.Flags().GetString("site") + request.Site = strings.TrimSpace(request.Site) + request.Environment, _ = cmd.Flags().GetString("environment") + request.Environment = strings.TrimSpace(request.Environment) + request.ProjectName, _ = cmd.Flags().GetString("project-name") + request.ProjectName = strings.TrimSpace(request.ProjectName) + request.ComposeProjectName, _ = cmd.Flags().GetString("compose-project-name") + request.ComposeProjectName = strings.TrimSpace(request.ComposeProjectName) + request.ComposeNetwork, _ = cmd.Flags().GetString("compose-network") + request.ComposeNetwork = strings.TrimSpace(request.ComposeNetwork) + request.DockerSocket, _ = cmd.Flags().GetString("docker-socket") + request.DockerSocket = strings.TrimSpace(request.DockerSocket) + request.SSHHostname, _ = cmd.Flags().GetString("ssh-hostname") + request.SSHHostname = strings.TrimSpace(request.SSHHostname) + request.SSHUser, _ = cmd.Flags().GetString("ssh-user") + request.SSHUser = strings.TrimSpace(request.SSHUser) + request.SSHPort, _ = cmd.Flags().GetUint("ssh-port") + request.SSHKeyPath, _ = cmd.Flags().GetString("ssh-key") + request.SSHKeyPath = strings.TrimSpace(request.SSHKeyPath) + + if request.TargetType == config.ContextRemote { + if err := populateRemoteCreateRequest(&request, input); err != nil { + return ComposeCreateRequest{}, err + } + } + + defs, err := s.CreateComponentDefinitions() + if err != nil { + return ComposeCreateRequest{}, err + } + options := make([]corecomponent.CreateOption, 0, len(defs)) + for _, def := range defs { + options = append(options, def.CreateOption()) + } + decisions, err := corecomponent.ResolveCreateDecisions(cmd, componentInput(input), options...) + if err != nil { + return ComposeCreateRequest{}, err + } + request.Decisions = decisions + return request, nil +} + +func (s *SDK) EnsureComposeCreateContext(req ComposeCreateRequest, opts ComposeCreateContextOptions) (*config.Context, error) { + if s == nil { + return nil, fmt.Errorf("plugin sdk is not initialized") + } + input := opts.Input + if input == nil { + input = config.GetInput + } + + defaultDir := helpers.FirstNonEmpty(strings.TrimSpace(req.Path), strings.TrimSpace(opts.DefaultProjectDir), ".") + defaultName := helpers.FirstNonEmpty(strings.TrimSpace(req.ContextName), strings.TrimSpace(opts.DefaultName), filepath.Base(defaultDir)) + defaultSite := helpers.FirstNonEmpty(strings.TrimSpace(req.Site), strings.TrimSpace(opts.DefaultSite), filepath.Base(defaultDir)) + defaultPlugin := helpers.FirstNonEmpty(strings.TrimSpace(opts.DefaultPlugin), s.Metadata.Name, "core") + defaultProjectName := helpers.FirstNonEmpty(strings.TrimSpace(req.ProjectName), strings.TrimSpace(opts.DefaultProjectName), filepath.Base(defaultDir), "docker-compose") + defaultEnvironment := helpers.FirstNonEmpty(strings.TrimSpace(req.Environment), strings.TrimSpace(opts.DefaultEnvironment)) + if req.TargetType == config.ContextLocal && defaultEnvironment == "" { + defaultEnvironment = "local" + } + defaultDockerSocket := helpers.FirstNonEmpty(strings.TrimSpace(req.DockerSocket), strings.TrimSpace(opts.DefaultDockerSocket), "/var/run/docker.sock") + + if req.TargetType == config.ContextRemote { + return promptAndSaveRemoteContext(ComposeRemoteContextOptions{ + ContextName: req.ContextName, + DefaultName: defaultName, + Site: req.Site, + DefaultSite: defaultSite, + Plugin: defaultPlugin, + ProjectDir: req.Path, + DefaultProjectDir: defaultDir, + ProjectName: req.ProjectName, + DefaultProjectName: defaultProjectName, + Environment: req.Environment, + DefaultEnvironment: helpers.FirstNonEmpty(defaultEnvironment, "remote"), + ComposeProjectName: req.ComposeProjectName, + ComposeNetwork: req.ComposeNetwork, + DockerSocket: defaultDockerSocket, + SSHHostname: req.SSHHostname, + SSHUser: req.SSHUser, + SSHPort: req.SSHPort, + SSHKeyPath: req.SSHKeyPath, + SetDefault: req.SetDefaultContext, + ConfirmOverwrite: opts.ConfirmOverwrite, + Input: input, + DrupalRootfs: helpers.FirstNonEmpty(req.DrupalRootfs, opts.DefaultDrupalRootfs), + DrupalContainerRoot: opts.DrupalContainerRoot, + }) + } + + localOpts := config.LocalContextCreateOptions{ + Name: req.ContextName, + DefaultName: defaultName, + Site: req.Site, + DefaultSite: defaultSite, + Plugin: defaultPlugin, + DefaultPlugin: defaultPlugin, + ProjectDir: req.Path, + DefaultProjectDir: defaultDir, + ProjectName: req.ProjectName, + DefaultProjectName: defaultProjectName, + ComposeProjectName: req.ComposeProjectName, + ComposeNetwork: req.ComposeNetwork, + Environment: defaultEnvironment, + DockerSocket: defaultDockerSocket, + DrupalRootfs: helpers.FirstNonEmpty(req.DrupalRootfs, opts.DefaultDrupalRootfs), + DrupalContainerRoot: opts.DrupalContainerRoot, + SetDefault: req.SetDefaultContext, + ConfirmOverwrite: opts.ConfirmOverwrite, + Input: input, + } + if req.CheckoutSource == CheckoutSourceExisting { + localOpts.ProjectDirValidator = config.ValidateExistingComposeProjectDir + } + return config.PromptAndSaveLocalContext(localOpts) +} + func (s *SDK) ensureCreateRoot() *cobra.Command { if s.createRootCmd != nil { return s.createRootCmd @@ -107,7 +418,24 @@ func (s *SDK) ensureCreateRoot() *cobra.Command { return err }, } + componentDefinitionsCmd := &cobra.Command{ + Use: "component-definitions", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + defs, err := s.CreateComponentDefinitions() + if err != nil { + return err + } + data, err := yaml.Marshal(defs) + if err != nil { + return fmt.Errorf("marshal create component definitions: %w", err) + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } root.AddCommand(listCmd) + root.AddCommand(componentDefinitionsCmd) s.createRootCmd = root s.RootCmd.AddCommand(root) return root @@ -132,3 +460,342 @@ func normalizeCreateSpec(spec CreateSpec) CreateSpec { } return spec } + +type ComposeRemoteContextOptions struct { + ContextName string + DefaultName string + Site string + DefaultSite string + Plugin string + ProjectDir string + DefaultProjectDir string + ProjectName string + DefaultProjectName string + Environment string + DefaultEnvironment string + ComposeProjectName string + ComposeNetwork string + DockerSocket string + SSHHostname string + SSHUser string + SSHPort uint + SSHKeyPath string + SetDefault bool + ConfirmOverwrite bool + Input config.InputFunc + DrupalRootfs string + DrupalContainerRoot string +} + +func promptAndSaveRemoteContext(opts ComposeRemoteContextOptions) (*config.Context, error) { + input := opts.Input + if input == nil { + input = config.GetInput + } + + name, err := resolveCreateContextName(opts.ContextName, opts.DefaultName, input) + if err != nil { + return nil, err + } + existing, err := config.GetContext(name) + if err != nil && !strings.Contains(err.Error(), config.ErrContextNotFound.Error()) { + return nil, err + } + if err == nil && existing.Name != "" && opts.ConfirmOverwrite { + overwrite, promptErr := input("The context already exists. Do you want to overwrite it? [y/N]: ") + if promptErr != nil { + return nil, promptErr + } + if !isAffirmativeCreateAnswer(overwrite) { + return nil, fmt.Errorf("context creation cancelled") + } + } + + projectDir, err := resolveRequiredCreateValue(input, "Project directory", helpers.FirstNonEmpty(strings.TrimSpace(opts.ProjectDir), strings.TrimSpace(opts.DefaultProjectDir)), strings.Split(corecomponent.RenderSection("Project directory", "Enter the full directory path where this stack exists or should be managed on the remote host."), "\n")) + if err != nil { + return nil, err + } + site := strings.TrimSpace(opts.Site) + if site == "" { + site, err = resolveRequiredCreateValue(input, "Site name", helpers.FirstNonEmpty(strings.TrimSpace(opts.DefaultSite), filepath.Base(projectDir)), strings.Split(corecomponent.RenderSection("Site name", "Enter the logical site name this context belongs to."), "\n")) + if err != nil { + return nil, err + } + } + projectName := strings.TrimSpace(opts.ProjectName) + if projectName == "" { + projectName, err = resolveRequiredCreateValue(input, "Project name", helpers.FirstNonEmpty(strings.TrimSpace(opts.DefaultProjectName), filepath.Base(projectDir), "docker-compose"), strings.Split(corecomponent.RenderSection("Project name", "Enter the logical project name for this stack."), "\n")) + if err != nil { + return nil, err + } + } + environment := strings.TrimSpace(opts.Environment) + if environment == "" { + environment, err = resolveRequiredCreateValue(input, "Environment", helpers.FirstNonEmpty(strings.TrimSpace(opts.DefaultEnvironment), "remote"), strings.Split(corecomponent.RenderSection("Environment", "Enter the environment name for this remote stack, such as dev, staging, or prod."), "\n")) + if err != nil { + return nil, err + } + } + composeProjectName := helpers.FirstNonEmpty(strings.TrimSpace(opts.ComposeProjectName), projectName) + composeNetwork := helpers.FirstNonEmpty(strings.TrimSpace(opts.ComposeNetwork), composeProjectName+"_default") + hostname, err := resolveRequiredCreateValue(input, "SSH hostname", strings.TrimSpace(opts.SSHHostname), strings.Split(corecomponent.RenderSection("Remote SSH connection", "Enter the SSH connection details for the remote machine that hosts this stack."), "\n")) + if err != nil { + return nil, err + } + currentUser := "" + if u, userErr := user.Current(); userErr == nil { + currentUser = u.Username + } + sshUser, err := resolveRequiredCreateValue(input, "SSH user", helpers.FirstNonEmpty(strings.TrimSpace(opts.SSHUser), currentUser, "root"), nil) + if err != nil { + return nil, err + } + sshPort, err := resolveRequiredCreateUint(input, "SSH port", defaultCreateSSHPort(opts.SSHPort), nil) + if err != nil { + return nil, err + } + defaultKey := filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") + sshKeyPath, err := resolveRequiredCreateValue(input, "Path to SSH private key", helpers.FirstNonEmpty(strings.TrimSpace(opts.SSHKeyPath), defaultKey), nil) + if err != nil { + return nil, err + } + dockerSocket := helpers.FirstNonEmpty(strings.TrimSpace(opts.DockerSocket), "/var/run/docker.sock") + + ctx := &config.Context{ + Name: name, + Site: site, + Plugin: helpers.FirstNonEmpty(strings.TrimSpace(opts.Plugin), "core"), + DockerHostType: config.ContextRemote, + Environment: environment, + DockerSocket: dockerSocket, + ProjectName: projectName, + ComposeProjectName: composeProjectName, + ComposeNetwork: composeNetwork, + ProjectDir: projectDir, + DrupalRootfs: strings.TrimSpace(opts.DrupalRootfs), + DrupalContainerRoot: strings.TrimSpace(opts.DrupalContainerRoot), + SSHHostname: hostname, + SSHUser: sshUser, + SSHPort: sshPort, + SSHKeyPath: sshKeyPath, + } + if err := config.SaveContext(ctx, opts.SetDefault); err != nil { + return nil, err + } + return ctx, nil +} + +func resolveCreateTargetType(cmd *cobra.Command, input config.InputFunc) (config.ContextType, error) { + value, err := cmd.Flags().GetString("type") + if err != nil { + return "", fmt.Errorf("get type flag: %w", err) + } + value = strings.TrimSpace(value) + if value != "" { + if value != string(config.ContextLocal) && value != string(config.ContextRemote) { + return "", fmt.Errorf("unknown create target type %q", value) + } + return config.ContextType(value), nil + } + selected, err := corecomponent.PromptChoice( + "create target", + []corecomponent.Choice{ + {Value: string(config.ContextLocal), Label: "local", Help: "Run this stack on your local machine."}, + {Value: string(config.ContextRemote), Label: "remote", Help: "Run this stack on a remote machine over SSH."}, + }, + string(config.ContextLocal), + componentInput(input), + strings.Split(corecomponent.RenderSection("Target machine", "Choose where this stack will run."), "\n")..., + ) + if err != nil { + return "", err + } + return config.ContextType(strings.TrimSpace(selected)), nil +} + +func resolveCheckoutSource(cmd *cobra.Command, input config.InputFunc, targetType config.ContextType) (CheckoutSource, error) { + value, err := cmd.Flags().GetString("checkout-source") + if err != nil { + return "", fmt.Errorf("get checkout-source flag: %w", err) + } + value = strings.TrimSpace(value) + if value != "" { + if value != string(CheckoutSourceTemplate) && value != string(CheckoutSourceExisting) { + return "", fmt.Errorf("unknown checkout source %q", value) + } + return CheckoutSource(value), nil + } + defaultChoice := string(CheckoutSourceTemplate) + if targetType == config.ContextRemote { + defaultChoice = string(CheckoutSourceExisting) + } + selected, err := corecomponent.PromptChoice( + "checkout source", + []corecomponent.Choice{ + {Value: string(CheckoutSourceTemplate), Label: "template", Help: "Clone the template repository as a fresh install."}, + {Value: string(CheckoutSourceExisting), Label: "existing", Help: "Use a repo or checkout that already exists."}, + }, + defaultChoice, + componentInput(input), + strings.Split(corecomponent.RenderSection("Project source", "Choose whether to create from the template repository or use an existing checkout."), "\n")..., + ) + if err != nil { + return "", err + } + return CheckoutSource(strings.TrimSpace(selected)), nil +} + +func resolveCreateProjectDir(cmd *cobra.Command, defaultPath string) (string, error) { + pathValue, err := cmd.Flags().GetString("project-dir") + if err != nil { + return "", fmt.Errorf("get project-dir flag: %w", err) + } + if strings.TrimSpace(pathValue) == "" { + pathValue, err = cmd.Flags().GetString("path") + if err != nil { + return "", fmt.Errorf("get path flag: %w", err) + } + } + if strings.TrimSpace(pathValue) == "" { + pathValue = defaultPath + } + return strings.TrimSpace(pathValue), nil +} + +func populateRemoteCreateRequest(req *ComposeCreateRequest, input config.InputFunc) error { + if req == nil { + return fmt.Errorf("create request is nil") + } + currentUser := "" + if u, err := user.Current(); err == nil { + currentUser = u.Username + } + defaultKey := filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") + var err error + req.SSHHostname, err = resolveRequiredCreateValue(input, "SSH hostname", req.SSHHostname, strings.Split(corecomponent.RenderSection("Remote SSH connection", "Enter the SSH connection details for the remote machine that hosts this stack."), "\n")) + if err != nil { + return err + } + req.SSHUser, err = resolveRequiredCreateValue(input, "SSH user", helpers.FirstNonEmpty(req.SSHUser, currentUser, "root"), nil) + if err != nil { + return err + } + req.SSHPort, err = resolveRequiredCreateUint(input, "SSH port", defaultCreateSSHPort(req.SSHPort), nil) + if err != nil { + return err + } + req.SSHKeyPath, err = resolveRequiredCreateValue(input, "Path to SSH private key", helpers.FirstNonEmpty(req.SSHKeyPath, defaultKey), nil) + if err != nil { + return err + } + if strings.TrimSpace(req.DockerSocket) == "" { + req.DockerSocket = "/var/run/docker.sock" + } + return nil +} + +func resolveCreateContextName(explicitName, defaultName string, input config.InputFunc) (string, error) { + if strings.TrimSpace(explicitName) != "" { + return strings.TrimSpace(explicitName), nil + } + baseName := strings.TrimSpace(defaultName) + if baseName == "" { + return "", fmt.Errorf("context name cannot be empty") + } + exists, err := config.ContextExists(baseName) + if err != nil { + return "", err + } + if !exists { + return baseName, nil + } + candidate, err := nextAvailableCreateContextName(baseName) + if err != nil { + return "", err + } + value, err := input( + append(strings.Split(corecomponent.RenderSection("sitectl context name", "Enter the sitectl context name to save for this stack."), "\n"), "", corecomponent.RenderPromptLine(fmt.Sprintf("Context name [%s]: ", candidate)))..., + ) + if err != nil { + return "", err + } + value = strings.TrimSpace(value) + if value == "" { + return candidate, nil + } + return value, nil +} + +func nextAvailableCreateContextName(base string) (string, error) { + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + exists, err := config.ContextExists(candidate) + if err != nil { + return "", err + } + if !exists { + return candidate, nil + } + } +} + +func resolveRequiredCreateValue(input config.InputFunc, label, defaultValue string, sections []string) (string, error) { + prompt := fmt.Sprintf("%s: ", label) + if strings.TrimSpace(defaultValue) != "" { + prompt = fmt.Sprintf("%s [%s]: ", label, defaultValue) + } + question := append([]string{}, sections...) + question = append(question, "", corecomponent.RenderPromptLine(prompt)) + value, err := input(question...) + if err != nil { + return "", err + } + value = strings.TrimSpace(value) + if value == "" { + value = strings.TrimSpace(defaultValue) + } + if value == "" { + return "", fmt.Errorf("%s cannot be empty", strings.ToLower(label)) + } + return value, nil +} + +func resolveRequiredCreateUint(input config.InputFunc, label string, defaultValue uint, sections []string) (uint, error) { + question := append([]string{}, sections...) + question = append(question, "", corecomponent.RenderPromptLine(fmt.Sprintf("%s [%d]: ", label, defaultValue))) + value, err := input(question...) + if err != nil { + return 0, err + } + value = strings.TrimSpace(value) + if value == "" { + return defaultValue, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("invalid %s %q", strings.ToLower(label), value) + } + return uint(parsed), nil +} + +func defaultCreateSSHPort(port uint) uint { + if port != 0 { + return port + } + return 22 +} + +func componentInput(input config.InputFunc) corecomponent.InputFunc { + return func(question ...string) (string, error) { + return input(question...) + } +} + +func isAffirmativeCreateAnswer(value string) bool { + value = strings.TrimSpace(strings.ToLower(value)) + return value == "y" || value == "yes" +} + +func isDiscoveryMetadataInvocation() bool { + return len(os.Args) > 1 && os.Args[1] == "__plugin-metadata" +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index 0106184..1ae5f81 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -1,12 +1,13 @@ package plugin import ( - "bufio" "fmt" + "log/slog" "os" "os/exec" "path/filepath" "strings" + "time" yaml "gopkg.in/yaml.v3" ) @@ -32,7 +33,12 @@ func DiscoverInstalled() []InstalledPlugin { return DiscoverInstalledFromPath(os.Getenv("PATH")) } +func DiscoverInstalledLightweight() []InstalledPlugin { + return DiscoverInstalledLightweightFromPath(os.Getenv("PATH")) +} + func DiscoverInstalledFromPath(pathEnv string) []InstalledPlugin { + started := time.Now() seen := map[string]bool{} discovered := make([]InstalledPlugin, 0) @@ -62,6 +68,47 @@ func DiscoverInstalledFromPath(pathEnv string) []InstalledPlugin { } } + slog.Debug("completed full plugin discovery", "count", len(discovered), "duration", time.Since(started)) + return discovered +} + +func DiscoverInstalledLightweightFromPath(pathEnv string) []InstalledPlugin { + started := time.Now() + seen := map[string]bool{} + discovered := make([]InstalledPlugin, 0) + + for _, dir := range filepath.SplitList(pathEnv) { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, "sitectl-") || name == "sitectl" { + continue + } + + pluginName := strings.TrimPrefix(name, "sitectl-") + if pluginName == "" || seen[pluginName] { + continue + } + seen[pluginName] = true + + path := filepath.Join(dir, name) + discovered = append(discovered, InstalledPlugin{ + Name: pluginName, + BinaryName: name, + Path: path, + Description: fmt.Sprintf("the %s plugin", pluginName), + }) + } + } + + slog.Debug("completed lightweight plugin discovery", "count", len(discovered), "duration", time.Since(started)) return discovered } @@ -75,29 +122,32 @@ func FindInstalled(name string) (InstalledPlugin, bool) { } func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) InstalledPlugin { - createDefinitions := pluginCreateDefinitions(pluginPath) + started := time.Now() info := InstalledPlugin{ - Name: pluginName, - BinaryName: binaryName, - Path: pluginPath, - Description: fmt.Sprintf("the %s plugin", pluginName), - CanCreate: len(createDefinitions) > 0, - CreateDefinitions: createDefinitions, + Name: pluginName, + BinaryName: binaryName, + Path: pluginPath, + Description: fmt.Sprintf("the %s plugin", pluginName), } if repo := builtinTemplateRepos[pluginName]; repo != "" { info.TemplateRepo = repo } - if spec, ok := defaultCreateDefinition(createDefinitions); ok && strings.TrimSpace(spec.DockerComposeRepo) != "" { - info.TemplateRepo = spec.DockerComposeRepo - } - cmd := exec.Command(pluginPath, "plugin-info") + cmd := exec.Command(pluginPath, "__plugin-metadata") output, err := cmd.Output() if err != nil { + slog.Debug("plugin metadata command failed", "plugin", pluginName, "path", pluginPath, "duration", time.Since(started), "err", err) return info } - parsed := ParsePluginInfoOutput(string(output)) + var parsed InstalledPlugin + if err := yaml.Unmarshal(output, &parsed); err != nil { + slog.Debug("plugin metadata unmarshal failed", "plugin", pluginName, "path", pluginPath, "duration", time.Since(started), "err", err) + return info + } + for i := range parsed.CreateDefinitions { + parsed.CreateDefinitions[i] = normalizeCreateSpec(parsed.CreateDefinitions[i]) + } if parsed.Name == "" { parsed.Name = pluginName } @@ -113,32 +163,18 @@ func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) Installed if parsed.TemplateRepo == "" { parsed.TemplateRepo = info.TemplateRepo } - if !parsed.CanCreate { - parsed.CanCreate = info.CanCreate + if parsed.TemplateRepo == "" { + if spec, ok := defaultCreateDefinition(parsed.CreateDefinitions); ok && strings.TrimSpace(spec.DockerComposeRepo) != "" { + parsed.TemplateRepo = spec.DockerComposeRepo + } } - if len(parsed.CreateDefinitions) == 0 { - parsed.CreateDefinitions = info.CreateDefinitions + if !parsed.CanCreate { + parsed.CanCreate = len(parsed.CreateDefinitions) > 0 } - + slog.Debug("inspected plugin metadata", "plugin", pluginName, "path", pluginPath, "can_create", parsed.CanCreate, "includes", len(parsed.Includes), "create_definitions", len(parsed.CreateDefinitions), "duration", time.Since(started)) return parsed } -func pluginCreateDefinitions(pluginPath string) []CreateSpec { - cmd := exec.Command(pluginPath, "__create", "list") - output, err := cmd.Output() - if err != nil { - return nil - } - var specs []CreateSpec - if err := yaml.Unmarshal(output, &specs); err != nil { - return nil - } - for i := range specs { - specs[i] = normalizeCreateSpec(specs[i]) - } - return specs -} - func defaultCreateDefinition(specs []CreateSpec) (CreateSpec, bool) { if len(specs) == 0 { return CreateSpec{}, false @@ -150,47 +186,3 @@ func defaultCreateDefinition(specs []CreateSpec) (CreateSpec, bool) { } return specs[0], true } - -func ParsePluginInfoOutput(output string) InstalledPlugin { - var info InstalledPlugin - - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - key, value, ok := strings.Cut(line, ":") - if !ok { - continue - } - key = strings.TrimSpace(strings.ToLower(key)) - value = strings.TrimSpace(value) - - switch key { - case "name": - info.Name = value - case "version": - info.Version = value - case "description": - info.Description = value - case "author": - info.Author = value - case "template-repo": - info.TemplateRepo = value - case "includes": - if value == "" { - continue - } - for _, include := range strings.Split(value, ",") { - include = strings.TrimSpace(include) - if include == "" { - continue - } - info.Includes = append(info.Includes, include) - } - } - } - - return info -} diff --git a/pkg/plugin/discovery_test.go b/pkg/plugin/discovery_test.go index 58730cb..54593fc 100644 --- a/pkg/plugin/discovery_test.go +++ b/pkg/plugin/discovery_test.go @@ -6,30 +6,6 @@ import ( "testing" ) -func TestParsePluginInfoOutput(t *testing.T) { - output := `Name: isle -Version: 1.2.3 -Description: Islandora support -Author: LibOps -Template-Repo: https://github.com/islandora-devops/isle-site-template -Includes: drupal,libops -` - - info := ParsePluginInfoOutput(output) - if info.Name != "isle" { - t.Fatalf("expected name isle, got %q", info.Name) - } - if info.TemplateRepo != "https://github.com/islandora-devops/isle-site-template" { - t.Fatalf("expected template repo to be parsed, got %q", info.TemplateRepo) - } - if info.Description != "Islandora support" { - t.Fatalf("expected description to be parsed, got %q", info.Description) - } - if len(info.Includes) != 2 || info.Includes[0] != "drupal" || info.Includes[1] != "libops" { - t.Fatalf("expected includes to be parsed, got %v", info.Includes) - } -} - func TestDiscoverInstalledFromPathFallsBackToBuiltinTemplateRepo(t *testing.T) { dir := t.TempDir() pathEnv := dir @@ -55,19 +31,19 @@ func TestDiscoverInstalledFromPathDetectsCreateDefinitions(t *testing.T) { pathEnv := dir script := `#!/bin/sh -if [ "$1" = "__create" ] && [ "$2" = "list" ]; then +if [ "$1" = "__plugin-metadata" ]; then cat <<'YAML' -- name: default - description: Demo stack - default: true - docker_compose_repo: https://github.com/example/demo +name: demo +description: Demo plugin +cancreate: true +createdefinitions: + - name: default + description: Demo stack + default: true + docker_compose_repo: https://github.com/example/demo YAML exit 0 fi -if [ "$1" = "plugin-info" ]; then - echo "Name: demo" - exit 0 -fi exit 1 ` if err := os.WriteFile(dir+"/sitectl-demo", []byte(script), 0o755); err != nil { diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 088b3d1..23ad7ff 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -21,6 +21,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/ssh" "golang.org/x/term" + yaml "gopkg.in/yaml.v3" ) // Metadata contains information about a plugin @@ -57,6 +58,7 @@ type SDK struct { jobRootCmd *cobra.Command creates []RegisteredCreate createRootCmd *cobra.Command + componentDefs []component.Definition } // NewSDK creates a new plugin SDK instance @@ -161,27 +163,34 @@ func (s *SDK) SetVersionInfo(version, commit, date string) { s.RootCmd.Version = formatted } -// GetMetadataCommand returns a command that displays plugin metadata -func (s *SDK) GetMetadataCommand() *cobra.Command { +// GetDiscoveryMetadataCommand returns a single cheap metadata payload for plugin discovery. +func (s *SDK) GetDiscoveryMetadataCommand() *cobra.Command { return &cobra.Command{ - Use: "plugin-info", - Short: "Display plugin metadata", - Hidden: true, // Hidden from normal help, used for plugin discovery + Use: "__plugin-metadata", + Short: "Display plugin discovery metadata", + Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { - out := cmd.OutOrStdout() - fmt.Fprintf(out, "Name: %s\n", s.Metadata.Name) - fmt.Fprintf(out, "Version: %s\n", s.Metadata.Version) - fmt.Fprintf(out, "Description: %s\n", s.Metadata.Description) - if s.Metadata.Author != "" { - fmt.Fprintf(out, "Author: %s\n", s.Metadata.Author) + info := InstalledPlugin{ + Name: s.Metadata.Name, + BinaryName: cmd.Root().Name(), + Description: s.Metadata.Description, + Author: s.Metadata.Author, + TemplateRepo: strings.TrimSpace(s.Metadata.TemplateRepo), + Includes: append([]string{}, s.Metadata.Includes...), + CreateDefinitions: s.CreateDefinitions(), } - if s.Metadata.TemplateRepo != "" { - fmt.Fprintf(out, "Template-Repo: %s\n", s.Metadata.TemplateRepo) + info.CanCreate = len(info.CreateDefinitions) > 0 + if info.TemplateRepo == "" { + if spec, ok := defaultCreateDefinition(info.CreateDefinitions); ok { + info.TemplateRepo = strings.TrimSpace(spec.DockerComposeRepo) + } } - if len(s.Metadata.Includes) > 0 { - fmt.Fprintf(out, "Includes: %s\n", strings.Join(s.Metadata.Includes, ",")) + data, err := yaml.Marshal(info) + if err != nil { + return fmt.Errorf("marshal plugin discovery metadata: %w", err) } - return nil + _, err = cmd.OutOrStdout().Write(data) + return err }, } } diff --git a/pkg/plugin/sdk_test.go b/pkg/plugin/sdk_test.go index 2e5e22f..f63fc7c 100644 --- a/pkg/plugin/sdk_test.go +++ b/pkg/plugin/sdk_test.go @@ -81,12 +81,16 @@ func TestContextPluginSupportsBuiltinHierarchy(t *testing.T) { func TestPluginIncludesMergesBuiltinAndInstalledWithoutDuplicates(t *testing.T) { dir := t.TempDir() - t.Setenv("PATH", dir) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) script := `#!/bin/sh -if [ "$1" = "plugin-info" ]; then - echo "Name: isle" - echo "Includes: drupal,libops" +if [ "$1" = "__plugin-metadata" ]; then + cat <<'YAML' +name: isle +includes: + - drupal + - libops +YAML exit 0 fi if [ "$1" = "create" ] && [ "$2" = "--help" ]; then @@ -105,12 +109,14 @@ exit 1 func TestInvokePluginCommandCapturePassesContextAndLogLevel(t *testing.T) { dir := t.TempDir() - t.Setenv("PATH", dir) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("COLUMNS", "123") script := `#!/bin/sh -if [ "$1" = "plugin-info" ]; then - echo "Name: child" +if [ "$1" = "__plugin-metadata" ]; then + cat <<'YAML' +name: child +YAML exit 0 fi if [ "$1" = "create" ] && [ "$2" = "--help" ]; then @@ -139,11 +145,13 @@ printf 'COLUMNS=%s\n' "$COLUMNS" func TestInvokePluginCommandCaptureReturnsStderrDetail(t *testing.T) { dir := t.TempDir() - t.Setenv("PATH", dir) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) script := `#!/bin/sh -if [ "$1" = "plugin-info" ]; then - echo "Name: broken" +if [ "$1" = "__plugin-metadata" ]; then + cat <<'YAML' +name: broken +YAML exit 0 fi echo "something went wrong" >&2 @@ -163,10 +171,10 @@ exit 2 func TestInvokePluginCommandCaptureCanMirrorLiveStderr(t *testing.T) { dir := t.TempDir() - t.Setenv("PATH", dir) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) script := `#!/bin/sh -if [ "$1" = "plugin-info" ]; then +if [ "$1" = "__plugin-metadata" ]; then echo "Name: noisy" exit 0 fi @@ -207,18 +215,22 @@ func TestInvokeIncludedPluginCommandRejectsUnincludedPlugin(t *testing.T) { func TestInvokeIncludedPluginsCollectsTrimmedOutputs(t *testing.T) { dir := t.TempDir() - t.Setenv("PATH", dir) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) writePluginScript(t, dir, "sitectl-drupal", `#!/bin/sh -if [ "$1" = "plugin-info" ]; then - echo "Name: drupal" +if [ "$1" = "__plugin-metadata" ]; then + cat <<'YAML' +name: drupal +YAML exit 0 fi echo " drupal output " `) writePluginScript(t, dir, "sitectl-libops", `#!/bin/sh -if [ "$1" = "plugin-info" ]; then - echo "Name: libops" +if [ "$1" = "__plugin-metadata" ]; then + cat <<'YAML' +name: libops +YAML exit 0 fi echo ""