diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index 1be476802..c0e970ead 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -11,6 +11,7 @@ import ( "github.com/codesphere-cloud/oms/cli/cmd/util" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/prompt" intutil "github.com/codesphere-cloud/oms/internal/util" . "github.com/codesphere-cloud/oms/internal/util/testing" ) @@ -74,7 +75,7 @@ var _ = Describe("Interactive profile usage", func() { // In non-interactive mode, CollectInteractively would use defaults // We simulate this by checking that the prompter returns defaults // when interactive=false - prompter := installer.NewPrompter(false) + prompter := prompt.NewPrompter(false) // Test that prompter returns defaults when not interactive Expect(prompter.String("Test", "default-value")).To(Equal("default-value")) diff --git a/cli/cmd/update_install_config.go b/cli/cmd/update_install_config.go index 26551adf4..6f0c63aa5 100644 --- a/cli/cmd/update_install_config.go +++ b/cli/cmd/update_install_config.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "log" + "sort" "strings" csio "github.com/codesphere-cloud/cs-go/pkg/io" @@ -13,6 +14,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" + "github.com/codesphere-cloud/oms/internal/prompt" intutil "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" ) @@ -21,6 +23,10 @@ type UpdateInstallConfigCmd struct { cmd *cobra.Command Opts *UpdateInstallConfigOpts FileWriter intutil.FileIO + + // Prompter asks the operator whether to go ahead with a change to the vault. + // --yes short-circuits it. + Prompter prompt.Prompter } type UpdateInstallConfigOpts struct { @@ -30,6 +36,7 @@ type UpdateInstallConfigOpts struct { VaultFile string WithComments bool + Yes bool // Fields that can be updated PostgresPrimaryIP string @@ -95,12 +102,16 @@ func AddUpdateInstallConfigCmd(update *cobra.Command, opts *util.GlobalOptions) }, Opts: &UpdateInstallConfigOpts{GlobalOptions: opts}, FileWriter: intutil.NewFilesystemWriter(), + // One prompter for the whole command: it buffers stdin, so a fresh one per + // question could drop what the operator already typed. + Prompter: prompt.NewPrompter(true), } c.cmd.Flags().StringVarP(&c.Opts.ConfigFile, "config", "c", "config.yaml", "Path to existing config.yaml file") c.cmd.Flags().StringVar(&c.Opts.VaultFile, "vault", "prod.vault.yaml", "Path to existing prod.vault.yaml file") c.cmd.Flags().BoolVar(&c.Opts.WithComments, "with-comments", false, "Add helpful comments to the generated YAML files") + c.cmd.Flags().BoolVarP(&c.Opts.Yes, "yes", "y", false, "Auto-approve every change to the vault (regenerated certificates and missing secrets)") // PostgreSQL update flags c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryIP, "postgres-primary-ip", "", "Primary PostgreSQL server IP") @@ -174,6 +185,13 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig } if tracker.HasChanges() { + if !c.approve("Regenerate them?", "The changes above require these secrets to be regenerated:", tracker.Regenerates()) { + // The regenerated certificates cover values that were just written to the config, + // so keeping the old ones would leave the two inconsistent. Nothing has been + // written yet, so stopping here leaves the installation as it was. + return fmt.Errorf("aborted: the requested changes cannot be applied without regenerating the secrets above (pass --yes to approve up front)") + } + log.Println("\nRegenerating affected secrets and certificates...") if err := c.regenerateSecrets(config, vault, tracker); err != nil { return fmt.Errorf("failed to regenerate secrets: %w", err) @@ -182,6 +200,11 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig log.Println("\nNo changes detected that require secret regeneration.") } + added, err := c.confirmAndAddMissingSecrets(config, vault) + if err != nil { + return err + } + if err := icg.WriteInstallConfig(c.Opts.ConfigFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write config file: %w", err) } @@ -190,7 +213,7 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig return fmt.Errorf("failed to write vault file: %w", err) } - c.printSuccessMessage(tracker) + c.printSuccessMessage(tracker, added) return nil } @@ -398,6 +421,88 @@ func (c *UpdateInstallConfigCmd) applyCodesphereUpdates(config *files.RootConfig } } +// approve prints what is about to change and asks the operator to confirm it. --yes approves +// without asking; otherwise only an explicit yes counts, so a run without a terminal (an +// empty answer) declines. +func (c *UpdateInstallConfigCmd) approve(question, intro string, items []string) bool { + if c.Opts.Yes { + return true + } + + log.Printf("\n%s\n", intro) + + for _, item := range items { + log.Printf(" - %s\n", item) + } + + return c.Prompter.Bool(question, false) +} + +// confirmAndAddMissingSecrets asks about the secrets the vault is missing and adds them if +// the operator agrees. Returns the names of the ones that were added, none if the operator +// declined. +func (c *UpdateInstallConfigCmd) confirmAndAddMissingSecrets(config *files.RootConfig, vault *files.InstallVault) ([]string, error) { + missing, err := missingSecrets(config, vault) + if err != nil { + return nil, fmt.Errorf("failed to determine missing secrets: %w", err) + } + + if len(missing) == 0 { + return nil, nil + } + + if !c.approve("Generate them?", "The vault does not have these secrets yet:", missing) { + log.Printf("\nSkipped %d missing secret(s): %s\n", len(missing), strings.Join(missing, ", ")) + + return nil, nil + } + + added, err := addMissingSecrets(config, vault) + if err != nil { + return nil, fmt.Errorf("failed to add missing secrets: %w", err) + } + + log.Printf("\nAdded %d secret(s) missing from the vault: %s\n", len(added), strings.Join(added, ", ")) + + return added, nil +} + +// missingSecrets reports what addMissingSecrets would generate, without changing anything: +// it runs against copies, so only the names survive. +func missingSecrets(config *files.RootConfig, vault *files.InstallVault) ([]string, error) { + configCopy, err := config.Clone() + if err != nil { + return nil, fmt.Errorf("copy config: %w", err) + } + + return addMissingSecrets(configCopy, vault.Clone()) +} + +// addMissingSecrets generates the secrets the vault does not have yet and returns their +// names. EnsureSecrets keeps what is already there, so this only ever adds. +func addMissingSecrets(config *files.RootConfig, vault *files.InstallVault) ([]string, error) { + existing := make(map[string]bool, len(vault.Secrets)) + for _, secret := range vault.Secrets { + existing[secret.Name] = true + } + + if err := secrets.EnsureSecrets(vault, config); err != nil { + return nil, fmt.Errorf("ensure secrets: %w", err) + } + + added := []string{} + + for _, secret := range vault.Secrets { + if !existing[secret.Name] { + added = append(added, secret.Name) + } + } + + sort.Strings(added) + + return added, nil +} + func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vault *files.InstallVault, tracker *SecretDependencyTracker) error { if tracker.NeedsPostgresPrimaryCertRegen() { log.Println(" - Regenerating PostgreSQL primary server certificate...") @@ -440,21 +545,24 @@ func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vau return nil } -func (c *UpdateInstallConfigCmd) printSuccessMessage(tracker *SecretDependencyTracker) { +func (c *UpdateInstallConfigCmd) printSuccessMessage(tracker *SecretDependencyTracker, added []string) { log.Println("\n" + strings.Repeat("=", 70)) log.Println("Configuration successfully updated!") log.Println(strings.Repeat("=", 70)) if tracker.HasChanges() { log.Println("\nRegenerated secrets:") - if tracker.NeedsPostgresPrimaryCertRegen() { - log.Println(" ✓ PostgreSQL primary server certificate") - } - if tracker.NeedsPostgresReplicaCertRegen() { - log.Println(" ✓ PostgreSQL replica server certificate") + + for _, change := range tracker.Regenerates() { + log.Printf(" ✓ %s\n", change) } - if tracker.ACMEConfigChanged() { - log.Println(" ✓ ACME configuration updated") + } + + if len(added) > 0 { + log.Println("\nGenerated missing secrets:") + + for _, name := range added { + log.Printf(" ✓ %s\n", name) } } @@ -496,6 +604,26 @@ func (t *SecretDependencyTracker) ACMEConfigChanged() bool { return t.acmeConfigChanged } +// Regenerates describes, in operator-facing terms, what the tracked changes cause to be +// regenerated. Drives both the confirmation prompt and the summary, so the two cannot drift. +func (t *SecretDependencyTracker) Regenerates() []string { + changes := []string{} + + if t.postgresPrimaryCertNeedsRegen { + changes = append(changes, "PostgreSQL primary server certificate") + } + + if t.postgresReplicaCertNeedsRegen { + changes = append(changes, "PostgreSQL replica server certificate") + } + + if t.acmeConfigChanged { + changes = append(changes, "ACME configuration") + } + + return changes +} + func (t *SecretDependencyTracker) HasChanges() bool { return t.postgresPrimaryCertNeedsRegen || t.postgresReplicaCertNeedsRegen || t.acmeConfigChanged } diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 8d078373f..f11632ebb 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -12,6 +12,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" "github.com/codesphere-cloud/oms/cli/cmd/testutil" "github.com/codesphere-cloud/oms/cli/cmd/util" @@ -19,6 +20,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/prompt" ) func quoteYAMLString(s string) string { @@ -37,8 +39,11 @@ var _ = Describe("UpdateInstallConfig", func() { initialVault string cmd *UpdateInstallConfigCmd opts *UpdateInstallConfigOpts - testCAKeyPem string - testCACertPem string + confirmations []string + // Answer the stubbed prompts with. Reset to true for every spec. + approveConfirmations bool + testCAKeyPem string + testCACertPem string ) BeforeEach(func() { @@ -46,6 +51,8 @@ var _ = Describe("UpdateInstallConfig", func() { Skip("sops and age-keygen not available") } + approveConfirmations = true + var err error configFile, err = os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) @@ -213,8 +220,20 @@ codesphere: VaultFile: vaultFile.Name(), } + confirmations = nil + prompter := prompt.NewMockPrompter(GinkgoT()) + // Records what was asked and answers it the way the spec asked for. Optional, + // because a spec that passes --yes never gets to ask. + prompter.EXPECT().Bool(mock.Anything, false). + RunAndReturn(func(question string, _ bool) bool { + confirmations = append(confirmations, question) + + return approveConfirmations + }).Maybe() + cmd = &UpdateInstallConfigCmd{ - Opts: opts, + Opts: opts, + Prompter: prompter, } }) @@ -355,6 +374,67 @@ codesphere: }) }) + Context("confirming changes to the vault", func() { + // The fixture vault holds only some of the secrets EnsureSecrets knows about, + // so every run of the command finds something to generate. + It("asks before generating a secret the vault does not have", func() { + icg := installer.NewInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(confirmations).To(HaveLen(1)) + Expect(icg.GetVault().GetSecret(files.SecretMounterHmacSecret)).ToNot(BeNil()) + }) + + It("leaves the vault alone when the operator declines", func() { + approveConfirmations = false + + icg := installer.NewInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(icg.GetVault().GetSecret(files.SecretMounterHmacSecret)).To(BeNil()) + + writtenVault, err := vault.LoadVaultData(vaultFile.Name(), "") + Expect(err).NotTo(HaveOccurred()) + Expect(writtenVault.GetSecret(files.SecretMounterHmacSecret)).To(BeNil()) + }) + + It("asks nothing with --yes", func() { + opts.Yes = true + + icg := installer.NewInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(confirmations).To(BeEmpty()) + Expect(icg.GetVault().GetSecret(files.SecretMounterHmacSecret)).ToNot(BeNil()) + }) + + It("asks before regenerating certificates an update invalidates", func() { + opts.PostgresPrimaryIP = "10.10.0.4" + + icg := installer.NewInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(confirmations).To(HaveLen(2)) + }) + + // A declined regeneration would leave the config pointing at an IP the + // certificate does not cover, so the whole update is dropped instead. + It("writes nothing when the operator declines a regeneration", func() { + opts.PostgresPrimaryIP = "10.10.0.4" + approveConfirmations = false + + icg := installer.NewInstallConfigManager() + err := cmd.UpdateInstallConfig(icg) + + Expect(err).To(MatchError(ContainSubstring("aborted"))) + + written := installer.NewInstallConfigManager() + Expect(written.LoadInstallConfigFromFile(configFile.Name())).To(Succeed()) + Expect(written.GetInstallConfig().Postgres.Primary.IP).To(Equal("10.0.0.5")) + Expect(confirmations).To(HaveLen(1)) + }) + }) + Context("when loading invalid config file", func() { It("should return an error", func() { opts.ConfigFile = "/nonexistent/config.yaml" @@ -482,3 +562,79 @@ var _ = Describe("SecretDependencyTracker", func() { Expect(tracker.NeedsPostgresReplicaCertRegen()).To(BeTrue()) }) }) + +var _ = Describe("missingSecrets", func() { + It("reports what is missing without changing config or vault", func() { + config := &files.RootConfig{} + vault := &files.InstallVault{} + + missing, err := missingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(missing).To(ContainElement(files.SecretMounterHmacSecret)) + Expect(vault.Secrets).To(BeEmpty()) + Expect(config.Cluster.Certificates.CA.CertPem).To(BeEmpty()) + }) + + It("reports nothing once the secrets are there", func() { + config := &files.RootConfig{} + vault := &files.InstallVault{} + _, err := addMissingSecrets(config, vault) + Expect(err).ToNot(HaveOccurred()) + + Expect(missingSecrets(config, vault)).To(BeEmpty()) + }) +}) + +var _ = Describe("addMissingSecrets", func() { + var config *files.RootConfig + + BeforeEach(func() { + config = &files.RootConfig{} + }) + + It("adds a secret the vault does not have", func() { + vault := &files.InstallVault{} + + added, err := addMissingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(added).To(ContainElement(files.SecretMounterHmacSecret)) + Expect(vault.GetSecret(files.SecretMounterHmacSecret)).ToNot(BeNil()) + }) + + It("reports nothing on a second run and keeps the generated value", func() { + vault := &files.InstallVault{} + _, err := addMissingSecrets(config, vault) + Expect(err).ToNot(HaveOccurred()) + + secret := vault.GetSecret(files.SecretMounterHmacSecret).Fields.Password + + added, err := addMissingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(added).To(BeEmpty()) + Expect(vault.GetSecret(files.SecretMounterHmacSecret).Fields.Password).To(Equal(secret)) + }) + + It("never modifies a secret the vault already holds", func() { + vault := &files.InstallVault{} + vault.SetSecret(files.SecretEntry{ + Name: files.SecretMounterHmacSecret, + Fields: &files.SecretFields{Password: "operator-supplied-secret"}, + }) + // EnsureDefaultSecrets overwrites this one unconditionally when it runs directly. + vault.SetSecret(files.SecretEntry{ + Name: files.SecretDigitalOceanApiToken, + Fields: &files.SecretFields{Password: "a-real-token"}, + }) + + added, err := addMissingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(added).ToNot(ContainElement(files.SecretMounterHmacSecret)) + Expect(added).ToNot(ContainElement(files.SecretDigitalOceanApiToken)) + Expect(vault.GetSecret(files.SecretMounterHmacSecret).Fields.Password).To(Equal("operator-supplied-secret")) + Expect(vault.GetSecret(files.SecretDigitalOceanApiToken).Fields.Password).To(Equal("a-real-token")) + }) +}) diff --git a/docs/oms_update_install-config.md b/docs/oms_update_install-config.md index 54412d20c..8611644c3 100644 --- a/docs/oms_update_install-config.md +++ b/docs/oms_update_install-config.md @@ -69,6 +69,7 @@ $ oms update install-config --k8s-api-server 10.0.0.10 --config config.yaml --va --vault string Path to existing prod.vault.yaml file (default "prod.vault.yaml") --with-comments Add helpful comments to the generated YAML files --workspace-hosting-base-domain string Workspace hosting base domain + -y, --yes Auto-approve every change to the vault (regenerated certificates and missing secrets) ``` ### SEE ALSO diff --git a/internal/installer/codesphere.go b/internal/installer/codesphere.go index c61b1fa72..1a12c507e 100644 --- a/internal/installer/codesphere.go +++ b/internal/installer/codesphere.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/prompt" "github.com/codesphere-cloud/oms/internal/system" "github.com/codesphere-cloud/oms/internal/util" ) @@ -401,9 +402,9 @@ func (ci *CodesphereInstaller) installerCommandArgs(pm PackageManager, config fi sort.Strings(executedSteps) - prompt := NewPrompter(!ci.AutoApprove) + prompter := prompt.NewPrompter(!ci.AutoApprove) msg := fmt.Sprintf("The following steps will be executed: %s. Type \"yes\" to continue.", strings.Join(executedSteps, ", ")) - if prompt.String(msg, "yes") != "yes" { + if prompter.String(msg, "yes") != "yes" { return nil, fmt.Errorf("installation aborted") } diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go index 8d48eb92a..b731a45e3 100644 --- a/internal/installer/config_generator_collector.go +++ b/internal/installer/config_generator_collector.go @@ -8,10 +8,11 @@ import ( "log" "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/prompt" ) func (g *InstallConfig) CollectInteractively() error { - prompter := NewPrompter(true) + prompter := prompt.NewPrompter(true) g.collectDatacenterConfig(prompter) g.collectRegistryConfig(prompter) @@ -26,20 +27,20 @@ func (g *InstallConfig) CollectInteractively() error { return nil } -func (g *InstallConfig) collectString(prompter *Prompter, prompt, defaultVal string) string { - return prompter.String(prompt, defaultVal) +func (g *InstallConfig) collectString(prompter prompt.Prompter, question, defaultVal string) string { + return prompter.String(question, defaultVal) } -func (g *InstallConfig) collectInt(prompter *Prompter, prompt string, defaultVal int) int { - return prompter.Int(prompt, defaultVal) +func (g *InstallConfig) collectInt(prompter prompt.Prompter, question string, defaultVal int) int { + return prompter.Int(question, defaultVal) } -func (g *InstallConfig) collectStringSlice(prompter *Prompter, prompt string, defaultVal []string) []string { - return prompter.StringSlice(prompt, defaultVal) +func (g *InstallConfig) collectStringSlice(prompter prompt.Prompter, question string, defaultVal []string) []string { + return prompter.StringSlice(question, defaultVal) } -func (g *InstallConfig) collectChoice(prompter *Prompter, prompt string, options []string, defaultVal string) string { - return prompter.Choice(prompt, options, defaultVal) +func (g *InstallConfig) collectChoice(prompter prompt.Prompter, question string, options []string, defaultVal string) string { + return prompter.Choice(question, options, defaultVal) } func k8sNodesToStringSlice(nodes []files.K8sNode) []string { @@ -58,7 +59,7 @@ func stringSliceToK8sNodes(ips []string) []files.K8sNode { return nodes } -func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { +func (g *InstallConfig) collectDatacenterConfig(prompter prompt.Prompter) { log.Println("=== Datacenter Configuration ===") g.Config.Datacenter.ID = g.collectInt(prompter, "Datacenter ID", g.Config.Datacenter.ID) g.Config.Datacenter.Name = g.collectString(prompter, "Datacenter name", g.Config.Datacenter.Name) @@ -67,7 +68,7 @@ func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { g.Config.Secrets.BaseDir = g.collectString(prompter, "Secrets base directory", "/root/secrets") } -func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { +func (g *InstallConfig) collectRegistryConfig(prompter prompt.Prompter) { log.Println("\n=== Container Registry Configuration ===") g.Config.Registry.Server = g.collectString(prompter, "Container registry server (e.g., ghcr.io, leave empty to skip)", "") if g.Config.Registry.Server != "" { @@ -76,7 +77,7 @@ func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { +func (g *InstallConfig) collectPostgresConfig(prompter prompt.Prompter) { log.Println("\n=== PostgreSQL Configuration ===") g.Config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") @@ -110,7 +111,7 @@ func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectCephConfig(prompter *Prompter) { +func (g *InstallConfig) collectCephConfig(prompter prompt.Prompter) { log.Println("\n=== Ceph Configuration ===") g.Config.Ceph.NodesSubnet = g.collectString(prompter, "Ceph nodes subnet (CIDR)", "10.53.101.0/24") @@ -132,7 +133,7 @@ func (g *InstallConfig) collectCephConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { +func (g *InstallConfig) collectK8sConfig(prompter prompt.Prompter) { log.Println("\n=== Kubernetes Configuration ===") g.Config.Kubernetes.ManagedByCodesphere = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", g.Config.Kubernetes.ManagedByCodesphere) @@ -163,7 +164,7 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { +func (g *InstallConfig) collectGatewayConfig(prompter prompt.Prompter) { log.Println("\n=== Cluster Gateway Configuration ===") g.Config.Cluster.Gateway.ServiceType = g.collectChoice(prompter, "Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") if g.Config.Cluster.Gateway.ServiceType == "ExternalIP" { @@ -176,7 +177,7 @@ func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { +func (g *InstallConfig) collectMetalLBConfig(prompter prompt.Prompter) { log.Println("\n=== MetalLB Configuration (Optional) ===") g.Config.MetalLB.Enabled = prompter.Bool("Enable MetalLB", g.Config.MetalLB.Enabled) @@ -212,7 +213,7 @@ func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectACMEConfig(prompter *Prompter) { +func (g *InstallConfig) collectACMEConfig(prompter prompt.Prompter) { log.Println("\n=== ACME Certificate Configuration (Optional) ===") certIssuer := g.Config.Codesphere.EnsureCertIssuer() @@ -293,7 +294,7 @@ func (g *InstallConfig) collectACMEConfig(prompter *Prompter) { log.Println("Provider config and secrets should be added manually after generation.") } -func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { +func (g *InstallConfig) collectCodesphereConfig(prompter prompt.Prompter) { log.Println("\n=== Codesphere Application Configuration ===") defaultDomain := g.Config.Codesphere.Domain if defaultDomain == "" { @@ -366,7 +367,7 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { g.collectOpenfgaBackupsConfig(prompter) } -func (g *InstallConfig) collectOpenfgaBackupsConfig(prompter *Prompter) { +func (g *InstallConfig) collectOpenfgaBackupsConfig(prompter prompt.Prompter) { log.Println("\n=== OpenFGA Database Backups (Optional) ===") hasBackups := prompter.Bool("Configure OpenFGA database backups", g.Config.Codesphere.OpenfgaBackups != nil && g.Config.Codesphere.OpenfgaBackups.Enabled) if !hasBackups { @@ -411,7 +412,7 @@ func (g *InstallConfig) collectOpenfgaBackupsConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectOpenBaoConfig(prompter *Prompter) { +func (g *InstallConfig) collectOpenBaoConfig(prompter prompt.Prompter) { log.Println("\n=== OpenBao Configuration (Optional) ===") hasOpenBao := prompter.Bool("Configure OpenBao integration", g.Config.Codesphere.OpenBao != nil && g.Config.Codesphere.OpenBao.URI != "") if !hasOpenBao { diff --git a/internal/installer/config_generator_collector_test.go b/internal/installer/config_generator_collector_test.go index fc5c2829d..4511c0476 100644 --- a/internal/installer/config_generator_collector_test.go +++ b/internal/installer/config_generator_collector_test.go @@ -8,6 +8,7 @@ import ( . "github.com/onsi/gomega" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/prompt" ) var _ = Describe("ConfigGeneratorCollector", func() { @@ -34,11 +35,11 @@ var _ = Describe("ConfigGeneratorCollector", func() { }) Describe("Prompter", func() { - var prompter *installer.Prompter + var prompter prompt.Prompter Context("Non-interactive mode", func() { BeforeEach(func() { - prompter = installer.NewPrompter(false) + prompter = prompt.NewPrompter(false) }) It("should return default string value", func() { diff --git a/internal/installer/secrets/secrets.go b/internal/installer/secrets/secrets.go index d75d7e302..4f9cd7cef 100644 --- a/internal/installer/secrets/secrets.go +++ b/internal/installer/secrets/secrets.go @@ -195,11 +195,10 @@ func EnsureNixSigningKeys(vault *files.InstallVault, host string) error { } // EnsureDefaultSecrets sets dummy defaults for all Helm chart secrets not managed by -// the installer config. Always overwrites digitalOceanApiToken; all others are only -// set when absent. +// the installer config. Idempotent: a value the vault already holds is kept. func EnsureDefaultSecrets(vault *files.InstallVault) error { - // Always overwrite — not used in private cloud but must not be empty. - setPassword(vault, files.SecretDigitalOceanApiToken, "dummy") + // Unused in private cloud, but the chart does not render without a value. + setPasswordIfEmpty(vault, files.SecretDigitalOceanApiToken, "dummy") for _, name := range optionalPasswordSecrets { setPasswordIfAbsent(vault, name, "dummy") @@ -272,6 +271,16 @@ func setPassword(vault *files.InstallVault, name, password string) { }) } +// setPasswordIfEmpty fills in a secret the vault does not have, or has without a value. +// Used for secrets the Helm chart needs a value for, where an empty entry is as good as none. +func setPasswordIfEmpty(vault *files.InstallVault, name, password string) { + if secret := vault.GetSecret(name); secret != nil && secret.Fields != nil && secret.Fields.Password != "" { + return + } + + setPassword(vault, name, password) +} + func setPasswordIfAbsent(vault *files.InstallVault, name, password string) { if vault.GetSecret(name) != nil { return diff --git a/internal/installer/secrets/secrets_test.go b/internal/installer/secrets/secrets_test.go index 3d46c264a..325f85350 100644 --- a/internal/installer/secrets/secrets_test.go +++ b/internal/installer/secrets/secrets_test.go @@ -155,10 +155,20 @@ var _ = Describe("EnsureNixSigningKeys", func() { }) var _ = Describe("EnsureDefaultSecrets", func() { - It("always overwrites digitalOceanApiToken", func() { + It("keeps a digitalOceanApiToken the vault already holds", func() { vault := newVault() vault.SetSecret(files.SecretEntry{Name: "digitalOceanApiToken", Fields: &files.SecretFields{Password: "real-token"}}) + Expect(secrets.EnsureDefaultSecrets(vault)).To(Succeed()) + Expect(vault.GetSecret("digitalOceanApiToken").Fields.Password).To(Equal("real-token")) + }) + + // The chart does not render without a value, so an entry that is there but empty is + // filled in like a missing one. + It("fills in an empty digitalOceanApiToken", func() { + vault := newVault() + vault.SetSecret(files.SecretEntry{Name: "digitalOceanApiToken", Fields: &files.SecretFields{Password: ""}}) + Expect(secrets.EnsureDefaultSecrets(vault)).To(Succeed()) Expect(vault.GetSecret("digitalOceanApiToken").Fields.Password).To(Equal("dummy")) }) diff --git a/internal/prompt/mocks.go b/internal/prompt/mocks.go new file mode 100644 index 000000000..1b5922a0e --- /dev/null +++ b/internal/prompt/mocks.go @@ -0,0 +1,329 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package prompt + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockPrompter creates a new instance of MockPrompter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPrompter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockPrompter { + mock := &MockPrompter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockPrompter is an autogenerated mock type for the Prompter type +type MockPrompter struct { + mock.Mock +} + +type MockPrompter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPrompter) EXPECT() *MockPrompter_Expecter { + return &MockPrompter_Expecter{mock: &_m.Mock} +} + +// Bool provides a mock function for the type MockPrompter +func (_mock *MockPrompter) Bool(prompt string, defaultValue bool) bool { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for Bool") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(string, bool) bool); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockPrompter_Bool_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bool' +type MockPrompter_Bool_Call struct { + *mock.Call +} + +// Bool is a helper method to define mock.On call +// - prompt string +// - defaultValue bool +func (_e *MockPrompter_Expecter) Bool(prompt any, defaultValue any) *MockPrompter_Bool_Call { + return &MockPrompter_Bool_Call{Call: _e.mock.On("Bool", prompt, defaultValue)} +} + +func (_c *MockPrompter_Bool_Call) Run(run func(prompt string, defaultValue bool)) *MockPrompter_Bool_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 bool + if args[1] != nil { + arg1 = args[1].(bool) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_Bool_Call) Return(b bool) *MockPrompter_Bool_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockPrompter_Bool_Call) RunAndReturn(run func(prompt string, defaultValue bool) bool) *MockPrompter_Bool_Call { + _c.Call.Return(run) + return _c +} + +// Choice provides a mock function for the type MockPrompter +func (_mock *MockPrompter) Choice(prompt string, choices []string, defaultValue string) string { + ret := _mock.Called(prompt, choices, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for Choice") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(string, []string, string) string); ok { + r0 = returnFunc(prompt, choices, defaultValue) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPrompter_Choice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Choice' +type MockPrompter_Choice_Call struct { + *mock.Call +} + +// Choice is a helper method to define mock.On call +// - prompt string +// - choices []string +// - defaultValue string +func (_e *MockPrompter_Expecter) Choice(prompt any, choices any, defaultValue any) *MockPrompter_Choice_Call { + return &MockPrompter_Choice_Call{Call: _e.mock.On("Choice", prompt, choices, defaultValue)} +} + +func (_c *MockPrompter_Choice_Call) Run(run func(prompt string, choices []string, defaultValue string)) *MockPrompter_Choice_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockPrompter_Choice_Call) Return(s string) *MockPrompter_Choice_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPrompter_Choice_Call) RunAndReturn(run func(prompt string, choices []string, defaultValue string) string) *MockPrompter_Choice_Call { + _c.Call.Return(run) + return _c +} + +// Int provides a mock function for the type MockPrompter +func (_mock *MockPrompter) Int(prompt string, defaultValue int) int { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for Int") + } + + var r0 int + if returnFunc, ok := ret.Get(0).(func(string, int) int); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + r0 = ret.Get(0).(int) + } + return r0 +} + +// MockPrompter_Int_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Int' +type MockPrompter_Int_Call struct { + *mock.Call +} + +// Int is a helper method to define mock.On call +// - prompt string +// - defaultValue int +func (_e *MockPrompter_Expecter) Int(prompt any, defaultValue any) *MockPrompter_Int_Call { + return &MockPrompter_Int_Call{Call: _e.mock.On("Int", prompt, defaultValue)} +} + +func (_c *MockPrompter_Int_Call) Run(run func(prompt string, defaultValue int)) *MockPrompter_Int_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_Int_Call) Return(n int) *MockPrompter_Int_Call { + _c.Call.Return(n) + return _c +} + +func (_c *MockPrompter_Int_Call) RunAndReturn(run func(prompt string, defaultValue int) int) *MockPrompter_Int_Call { + _c.Call.Return(run) + return _c +} + +// String provides a mock function for the type MockPrompter +func (_mock *MockPrompter) String(prompt string, defaultValue string) string { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for String") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPrompter_String_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'String' +type MockPrompter_String_Call struct { + *mock.Call +} + +// String is a helper method to define mock.On call +// - prompt string +// - defaultValue string +func (_e *MockPrompter_Expecter) String(prompt any, defaultValue any) *MockPrompter_String_Call { + return &MockPrompter_String_Call{Call: _e.mock.On("String", prompt, defaultValue)} +} + +func (_c *MockPrompter_String_Call) Run(run func(prompt string, defaultValue string)) *MockPrompter_String_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_String_Call) Return(s string) *MockPrompter_String_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPrompter_String_Call) RunAndReturn(run func(prompt string, defaultValue string) string) *MockPrompter_String_Call { + _c.Call.Return(run) + return _c +} + +// StringSlice provides a mock function for the type MockPrompter +func (_mock *MockPrompter) StringSlice(prompt string, defaultValue []string) []string { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for StringSlice") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func(string, []string) []string); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockPrompter_StringSlice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StringSlice' +type MockPrompter_StringSlice_Call struct { + *mock.Call +} + +// StringSlice is a helper method to define mock.On call +// - prompt string +// - defaultValue []string +func (_e *MockPrompter_Expecter) StringSlice(prompt any, defaultValue any) *MockPrompter_StringSlice_Call { + return &MockPrompter_StringSlice_Call{Call: _e.mock.On("StringSlice", prompt, defaultValue)} +} + +func (_c *MockPrompter_StringSlice_Call) Run(run func(prompt string, defaultValue []string)) *MockPrompter_StringSlice_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_StringSlice_Call) Return(strings []string) *MockPrompter_StringSlice_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockPrompter_StringSlice_Call) RunAndReturn(run func(prompt string, defaultValue []string) []string) *MockPrompter_StringSlice_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/installer/prompt.go b/internal/prompt/prompt.go similarity index 55% rename from internal/installer/prompt.go rename to internal/prompt/prompt.go index 31d360d18..0538e6c07 100644 --- a/internal/installer/prompt.go +++ b/internal/prompt/prompt.go @@ -1,7 +1,10 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 -package installer +// Package prompt asks the operator questions on stdin. A prompter can be non-interactive, +// in which case every question is answered with its default instead of being asked, which is +// what unattended runs (CI, --yes style flags) use. +package prompt import ( "bufio" @@ -11,19 +14,36 @@ import ( "strings" ) -type Prompter struct { +// Prompter asks the operator a question and returns their answer, falling back to the +// default whenever there is none: an empty line, a closed stdin, or a prompter that was +// created non-interactive. +// +//mockery:generate: true +type Prompter interface { + String(prompt, defaultValue string) string + Int(prompt string, defaultValue int) int + StringSlice(prompt string, defaultValue []string) []string + Bool(prompt string, defaultValue bool) bool + Choice(prompt string, choices []string, defaultValue string) string +} + +// StdinPrompter is the Prompter that asks on stdin. +type StdinPrompter struct { reader *bufio.Reader interactive bool } -func NewPrompter(interactive bool) *Prompter { - return &Prompter{ +// NewPrompter returns a prompter reading from stdin. A non-interactive one never asks +// and answers every question with its default. +func NewPrompter(interactive bool) *StdinPrompter { + return &StdinPrompter{ reader: bufio.NewReader(os.Stdin), interactive: interactive, } } -func (p *Prompter) String(prompt, defaultValue string) string { +// String asks for a line of text. +func (p *StdinPrompter) String(prompt, defaultValue string) string { if !p.interactive { return defaultValue } @@ -43,7 +63,8 @@ func (p *Prompter) String(prompt, defaultValue string) string { return input } -func (p *Prompter) Int(prompt string, defaultValue int) int { +// Int asks for a number, falling back to the default when the answer is not one. +func (p *StdinPrompter) Int(prompt string, defaultValue int) int { if !p.interactive { return defaultValue } @@ -65,7 +86,8 @@ func (p *Prompter) Int(prompt string, defaultValue int) int { return value } -func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { +// StringSlice asks for a comma-separated list. +func (p *StdinPrompter) StringSlice(prompt string, defaultValue []string) []string { if !p.interactive { return defaultValue } @@ -99,7 +121,9 @@ func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { return result } -func (p *Prompter) Bool(prompt string, defaultValue bool) bool { +// Bool asks a yes/no question. Only "y" and "yes" are a yes, only "n" and "no" a no; +// anything else falls back to the default. +func (p *StdinPrompter) Bool(prompt string, defaultValue bool) bool { if !p.interactive { return defaultValue } @@ -120,7 +144,9 @@ func (p *Prompter) Bool(prompt string, defaultValue bool) bool { return input == "y" || input == "yes" } -func (p *Prompter) Choice(prompt string, choices []string, defaultValue string) string { +// Choice asks for one of the given options, falling back to the default when the answer +// is not among them. +func (p *StdinPrompter) Choice(prompt string, choices []string, defaultValue string) string { if !p.interactive { return defaultValue } diff --git a/internal/prompt/prompt_suite_test.go b/internal/prompt/prompt_suite_test.go new file mode 100644 index 000000000..fdaf9d97b --- /dev/null +++ b/internal/prompt/prompt_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package prompt + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPrompt(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Prompt Suite") +} diff --git a/internal/installer/prompt_test.go b/internal/prompt/prompt_test.go similarity index 94% rename from internal/installer/prompt_test.go rename to internal/prompt/prompt_test.go index 6b995cbf5..8caa79ef6 100644 --- a/internal/installer/prompt_test.go +++ b/internal/prompt/prompt_test.go @@ -1,7 +1,7 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 -package installer +package prompt import ( "bufio" @@ -46,7 +46,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { It("returns user input when provided", func() { input := "user-value\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -56,7 +56,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -66,7 +66,7 @@ var _ = Describe("Prompter", func() { It("trims whitespace from input", func() { input := " value with spaces \n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -88,7 +88,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { It("returns parsed integer when valid input provided", func() { input := "123\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -98,7 +98,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -108,7 +108,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is invalid", func() { input := "not-a-number\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -118,7 +118,7 @@ var _ = Describe("Prompter", func() { It("handles negative numbers", func() { input := "-100\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -147,7 +147,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { It("parses comma-separated values", func() { input := "one, two, three\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -158,7 +158,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" defaultVal := []string{"default1", "default2"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -168,7 +168,7 @@ var _ = Describe("Prompter", func() { It("trims whitespace from each value", func() { input := " one , two , three \n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -178,7 +178,7 @@ var _ = Describe("Prompter", func() { It("handles single value", func() { input := "single\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -188,7 +188,7 @@ var _ = Describe("Prompter", func() { It("filters out empty values", func() { input := "one, , two, , three\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -216,7 +216,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { DescribeTable("boolean parsing", func(input string, expected bool) { - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input + "\n")), interactive: true, } @@ -236,7 +236,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -260,7 +260,7 @@ var _ = Describe("Prompter", func() { It("returns matching choice case-insensitively", func() { input := "OPTION2\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -271,7 +271,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -282,7 +282,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is invalid", func() { input := "invalid-option\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -293,7 +293,7 @@ var _ = Describe("Prompter", func() { It("handles exact match", func() { input := "option2\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, }