diff --git a/.gitignore b/.gitignore index d6bd12a9..0eb5dce3 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,4 @@ docs/plans/ *.test .serena/ -devnet-builder +/devnet-builder diff --git a/cmd/devnet-builder/commands/core/init.go b/cmd/devnet-builder/commands/core/init.go index 31f8ad14..71b85661 100644 --- a/cmd/devnet-builder/commands/core/init.go +++ b/cmd/devnet-builder/commands/core/init.go @@ -8,6 +8,7 @@ import ( "github.com/altuslabsxyz/devnet-builder/internal/application" "github.com/altuslabsxyz/devnet-builder/internal/application/dto" + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" "github.com/altuslabsxyz/devnet-builder/internal/config" "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/network" "github.com/altuslabsxyz/devnet-builder/internal/output" @@ -70,7 +71,8 @@ Examples: # After initializing, modify config then run: devnet-builder start`, - RunE: runInit, + PreRunE: preRunInit, + RunE: runInit, } cmd.Flags().StringVarP(&initNetwork, "network", "n", "mainnet", @@ -91,6 +93,28 @@ Examples: return cmd } +func preRunInit(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("network") { + if err := cmdvalidation.ValidateNetworkSource(initNetwork); err != nil { + return err + } + } + + if cmd.Flags().Changed("mode") { + if err := cmdvalidation.ValidateMode(initMode); err != nil { + return err + } + } + + if cmd.Flags().Changed("validators") { + if err := cmdvalidation.ValidateValidatorsRange(initValidators, 1, 4); err != nil { + return err + } + } + + return nil +} + func runInit(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := ctxconfig.FromContext(ctx) @@ -160,15 +184,15 @@ func runInit(cmd *cobra.Command, args []string) error { initAccounts = *effectiveCfg.Accounts } - // Validate inputs - if !types.NetworkSource(initNetwork).IsValid() { - return outputInitError(fmt.Errorf("invalid network: %s (must be 'mainnet' or 'testnet')", initNetwork), jsonMode) + // Validate inputs after config resolution. + if err := cmdvalidation.ValidateNetworkSource(initNetwork); err != nil { + return outputInitError(err, jsonMode) } - if initValidators < 1 || initValidators > 4 { - return outputInitError(fmt.Errorf("invalid validators: %d (must be 1-4)", initValidators), jsonMode) + if err := cmdvalidation.ValidateValidatorsRange(initValidators, 1, 4); err != nil { + return outputInitError(err, jsonMode) } - if !types.ExecutionMode(initMode).IsValid() { - return outputInitError(fmt.Errorf("invalid mode: %s (must be 'docker' or 'local')", initMode), jsonMode) + if err := cmdvalidation.ValidateMode(initMode); err != nil { + return outputInitError(err, jsonMode) } // Validate blockchain network module exists if !network.Has(initBlockchainNetwork) { diff --git a/cmd/devnet-builder/commands/manage/deploy.go b/cmd/devnet-builder/commands/manage/deploy.go index 84dfd747..f7c03d72 100644 --- a/cmd/devnet-builder/commands/manage/deploy.go +++ b/cmd/devnet-builder/commands/manage/deploy.go @@ -13,6 +13,7 @@ import ( "github.com/altuslabsxyz/devnet-builder/internal/application" "github.com/altuslabsxyz/devnet-builder/internal/application/dto" + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" "github.com/altuslabsxyz/devnet-builder/internal/config" "github.com/altuslabsxyz/devnet-builder/internal/di" "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/binary" @@ -104,7 +105,8 @@ Examples: # Deploy with different binary versions for export and start # (useful when export requires a specific version for state compatibility) devnet-builder deploy --mode local --start-version v1.2.3 --export-version v1.1.0`, - RunE: runDeploy, + PreRunE: preRunDeploy, + RunE: runDeploy, } // Command flags @@ -147,6 +149,45 @@ Examples: return cmd } +func preRunDeploy(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("network") { + if err := cmdvalidation.ValidateNetworkSource(deployNetwork); err != nil { + return err + } + } + + if cmd.Flags().Changed("mode") { + if err := cmdvalidation.ValidateMode(deployMode); err != nil { + return err + } + } + + if cmd.Flags().Changed("validators") { + mode := resolveDeployModeForValidation(cmd) + if err := cmdvalidation.ValidateValidatorsForMode(mode, deployValidators); err != nil { + return err + } + } + + return nil +} + +func resolveDeployModeForValidation(cmd *cobra.Command) string { + mode := deployMode + + cfg := ctxconfig.FromContext(cmd.Context()) + fileCfg := cfg.FileConfig() + if fileCfg != nil && fileCfg.ExecutionMode != nil && !cmd.Flags().Changed("mode") { + mode = string(*fileCfg.ExecutionMode) + } + + if envMode := os.Getenv("DEVNET_MODE"); envMode != "" && !cmd.Flags().Changed("mode") { + mode = envMode + } + + return mode +} + func runDeploy(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := ctxconfig.FromContext(ctx) @@ -289,21 +330,12 @@ func runDeploy(cmd *cobra.Command, args []string) error { } } - // Validate inputs - if !types.NetworkSource(deployNetwork).IsValid() { - return fmt.Errorf("invalid network: %s (must be 'mainnet' or 'testnet')", deployNetwork) + // Validate inputs after config resolution. + if err := cmdvalidation.ValidateNetworkSource(deployNetwork); err != nil { + return err } - // Validate validator count based on mode - if deployMode == string(types.ExecutionModeDocker) { - if deployValidators < 1 || deployValidators > 100 { - return fmt.Errorf("invalid validators: %d (must be 1-100 for docker mode)", deployValidators) - } - } else if deployMode == string(types.ExecutionModeLocal) { - if deployValidators < 1 || deployValidators > 4 { - return fmt.Errorf("invalid validators: %d (must be 1-4 for local mode)", deployValidators) - } - } else { - return fmt.Errorf("invalid mode: %s (must be 'docker' or 'local')", deployMode) + if err := cmdvalidation.ValidateValidatorsForMode(deployMode, deployValidators); err != nil { + return err } // Validate port availability for local mode before proceeding diff --git a/cmd/devnet-builder/commands/manage/deploy_test.go b/cmd/devnet-builder/commands/manage/deploy_test.go index f64ed51a..52917765 100644 --- a/cmd/devnet-builder/commands/manage/deploy_test.go +++ b/cmd/devnet-builder/commands/manage/deploy_test.go @@ -3,7 +3,10 @@ package manage import ( "os" "path/filepath" + "strings" "testing" + + "github.com/spf13/cobra" ) func TestValidateBinaryPath_ValidExecutable(t *testing.T) { @@ -130,3 +133,75 @@ func TestValidateBinaryPath_PathWithSpaces(t *testing.T) { t.Errorf("validateBinaryPath() should return absolute path, got: %s", result) } } + +func TestDeployPreRunInvalidModeStopsRunE(t *testing.T) { + cmd := NewDeployCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--mode", "invalid"}) + + runCalled := false + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runCalled = true + return nil + } + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected error for invalid mode") + } + if !strings.Contains(err.Error(), "invalid mode") { + t.Fatalf("unexpected error: %v", err) + } + if runCalled { + t.Fatalf("RunE should not be called when PreRunE validation fails") + } +} + +func TestStartPreRunInvalidModeStopsRunE(t *testing.T) { + cmd := NewStartCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--mode", "invalid"}) + + runCalled := false + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runCalled = true + return nil + } + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected error for invalid mode") + } + if !strings.Contains(err.Error(), "invalid mode") { + t.Fatalf("unexpected error: %v", err) + } + if runCalled { + t.Fatalf("RunE should not be called when PreRunE validation fails") + } +} + +func TestUpgradePreRunInvalidVotingPeriodStopsRunE(t *testing.T) { + cmd := NewUpgradeCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--voting-period", "not-a-duration"}) + + runCalled := false + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runCalled = true + return nil + } + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected error for invalid voting period") + } + if !strings.Contains(err.Error(), "invalid voting period") { + t.Fatalf("unexpected error: %v", err) + } + if runCalled { + t.Fatalf("RunE should not be called when PreRunE validation fails") + } +} diff --git a/cmd/devnet-builder/commands/manage/start.go b/cmd/devnet-builder/commands/manage/start.go index 108284c4..32fa4581 100644 --- a/cmd/devnet-builder/commands/manage/start.go +++ b/cmd/devnet-builder/commands/manage/start.go @@ -8,6 +8,7 @@ import ( "github.com/altuslabsxyz/devnet-builder/internal/application" "github.com/altuslabsxyz/devnet-builder/internal/application/dto" + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" "github.com/altuslabsxyz/devnet-builder/internal/output" "github.com/altuslabsxyz/devnet-builder/types" "github.com/altuslabsxyz/devnet-builder/types/ctxconfig" @@ -60,7 +61,8 @@ Examples: # Start with custom health timeout devnet-builder start --health-timeout 10m`, - RunE: runStart, + PreRunE: preRunStart, + RunE: runStart, } cmd.Flags().StringVarP(&upMode, "mode", "m", "", @@ -75,6 +77,13 @@ Examples: return cmd } +func preRunStart(cmd *cobra.Command, args []string) error { + if upMode == "" { + return nil + } + return cmdvalidation.ValidateMode(upMode) +} + func runStart(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := ctxconfig.FromContext(ctx) @@ -94,11 +103,6 @@ func runStart(cmd *cobra.Command, args []string) error { upStableVersion = version } - // Validate mode if specified - if upMode != "" && !types.ExecutionMode(upMode).IsValid() { - return outputStartErrorWithMode(fmt.Errorf("invalid mode: %s (must be 'docker' or 'local')", upMode), jsonMode) - } - // Initialize service svc, err := application.GetService(homeDir) if err != nil { diff --git a/cmd/devnet-builder/commands/manage/upgrade.go b/cmd/devnet-builder/commands/manage/upgrade.go index adc68c64..d9ba7c25 100644 --- a/cmd/devnet-builder/commands/manage/upgrade.go +++ b/cmd/devnet-builder/commands/manage/upgrade.go @@ -15,6 +15,7 @@ import ( "github.com/altuslabsxyz/devnet-builder/internal/application" "github.com/altuslabsxyz/devnet-builder/internal/application/dto" "github.com/altuslabsxyz/devnet-builder/internal/application/ports" + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" "github.com/altuslabsxyz/devnet-builder/internal/di" "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/binary" "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/cache" @@ -133,7 +134,8 @@ Resume options (for interrupted upgrades): # Resume from a specific stage (advanced) devnet-builder upgrade --resume --resume-from SwitchingBinary`, - RunE: runUpgrade, + PreRunE: preRunUpgrade, + RunE: runUpgrade, } // Version selection flags @@ -165,6 +167,27 @@ Resume options (for interrupted upgrades): return cmd } +func preRunUpgrade(cmd *cobra.Command, args []string) error { + if upgradeMode != "" { + if err := cmdvalidation.ValidateMode(upgradeMode); err != nil { + return err + } + } + + if _, err := time.ParseDuration(votingPeriod); err != nil { + return fmt.Errorf("invalid voting period: %w", err) + } + + if upgradeResumeFrom != "" { + targetStage := ports.ResumableStage(upgradeResumeFrom) + if !isValidStage(targetStage) { + return fmt.Errorf("invalid stage: %s", upgradeResumeFrom) + } + } + + return nil +} + // UpgradeResultJSON represents the JSON output for the upgrade command. type UpgradeResultJSON struct { Status string `json:"status"` @@ -256,13 +279,8 @@ func runUpgrade(cmd *cobra.Command, args []string) error { resolvedMode := UpgradeExecutionMode(cleanMetadata.ExecutionMode) modeExplicitlySet := false if upgradeMode != "" { - switch UpgradeExecutionMode(upgradeMode) { - case UpgradeModeDocker, UpgradeModeLocal: - resolvedMode = UpgradeExecutionMode(upgradeMode) - modeExplicitlySet = true - default: - return fmt.Errorf("invalid mode %q: must be 'docker' or 'local'", upgradeMode) - } + resolvedMode = UpgradeExecutionMode(upgradeMode) + modeExplicitlySet = true } // Mode validation against --image/--binary flags diff --git a/cmd/dvb/daemon.go b/cmd/dvb/daemon.go index 8e507fd5..aeb823fb 100644 --- a/cmd/dvb/daemon.go +++ b/cmd/dvb/daemon.go @@ -38,7 +38,7 @@ Examples: newDaemonStatusCmd(), newDaemonLogsCmd(), newDaemonWhoAmICmd(), - newPluginsCmd(), + markDaemonRequired(newPluginsCmd()), ) return cmd diff --git a/cmd/dvb/errors.go b/cmd/dvb/errors.go index f78f04ae..a7ca56e8 100644 --- a/cmd/dvb/errors.go +++ b/cmd/dvb/errors.go @@ -1,16 +1,13 @@ // cmd/dvb/errors.go package main -import "fmt" +import cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" // errDaemonNotRunning is the standard error returned when daemon connection is required but unavailable. -var errDaemonNotRunning = fmt.Errorf("daemon not running - start with: devnetd") +var errDaemonNotRunning = cmdvalidation.ErrDaemonNotRunning // requireDaemon returns errDaemonNotRunning if the daemon client is not connected. // Usage: if err := requireDaemon(); err != nil { return err } func requireDaemon() error { - if daemonClient == nil { - return errDaemonNotRunning - } - return nil + return cmdvalidation.RequireDaemonConnected(daemonClient != nil) } diff --git a/cmd/dvb/get.go b/cmd/dvb/get.go index 4bd7e230..72a44093 100644 --- a/cmd/dvb/get.go +++ b/cmd/dvb/get.go @@ -44,10 +44,6 @@ Examples: dvb get staging/my-devnet`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - var explicitDevnet string if len(args) > 0 { explicitDevnet = args[0] diff --git a/cmd/dvb/main.go b/cmd/dvb/main.go index 8f6dd892..e89095a4 100644 --- a/cmd/dvb/main.go +++ b/cmd/dvb/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/altuslabsxyz/devnet-builder/internal/client" + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" "github.com/altuslabsxyz/devnet-builder/internal/daemon/types" "github.com/altuslabsxyz/devnet-builder/internal/dvbcontext" "github.com/altuslabsxyz/devnet-builder/internal/output" @@ -109,6 +110,10 @@ func main() { // Skip if standalone mode if standalone { + currentContext, _ = dvbcontext.Load() + if commandRequiresDaemon(cmd) { + return cmdvalidation.RequireDaemonConnected(false) + } return nil } @@ -168,6 +173,10 @@ func main() { // Load context (ignore errors, context is optional) currentContext, _ = dvbcontext.Load() + if commandRequiresDaemon(cmd) { + return validateDaemonRequirement(cmd) + } + return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { @@ -192,15 +201,15 @@ func main() { newDaemonCmd(), newUseCmd(), newStatusCmd(), - newGetCmd(), + markDaemonRequired(newGetCmd()), newDeleteCmd(), - newListCmd(), - newNodeCmd(), - newUpgradeCmd(), - newTxCmd(), - newGovCmd(), + markDaemonRequired(newListCmd()), + markDaemonRequired(newNodeCmd()), + markDaemonRequired(newUpgradeCmd()), + markDaemonRequired(newTxCmd()), + markDaemonRequired(newGovCmd()), newGenesisCmd(), - newProvisionCmd(), + markDaemonRequired(newProvisionCmd()), newConfigCmd(), newCompletionCmd(), newDeprecatedStartCmd(), @@ -273,10 +282,6 @@ func newListCmd() *cobra.Command { Short: "List all devnets", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - devnets, err := daemonClient.ListDevnets(cmd.Context(), namespace) if err != nil { return err diff --git a/cmd/dvb/node.go b/cmd/dvb/node.go index 16d4345b..6ce04271 100644 --- a/cmd/dvb/node.go +++ b/cmd/dvb/node.go @@ -163,10 +163,6 @@ Examples: dvb node list --wide`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - var explicitDevnet string if len(args) > 0 { explicitDevnet = args[0] @@ -445,10 +441,6 @@ Examples: dvb node get my-devnet validator-0`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - explicitDevnet, nodeNameArg := resolveNodeArgs(args) ns, devnetName, err := resolveWithSuggestions(explicitDevnet, namespace) @@ -515,10 +507,6 @@ Examples: dvb node health my-devnet fullnode-0`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - explicitDevnet, nodeNameArg := resolveNodeArgs(args) ns, devnetName, err := resolveWithSuggestions(explicitDevnet, namespace) @@ -578,10 +566,6 @@ Examples: dvb node ports my-devnet fullnode-0`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - explicitDevnet, nodeNameArg := resolveNodeArgs(args) ns, devnetName, err := resolveWithSuggestions(explicitDevnet, namespace) @@ -672,10 +656,6 @@ Examples: dvb node start my-devnet validator-0`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - explicitDevnet, nodeNameArg := resolveNodeArgs(args) ns, devnetName, err := resolveWithSuggestions(explicitDevnet, namespace) @@ -794,10 +774,6 @@ Examples: dvb node stop my-devnet validator-0`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - explicitDevnet, nodeNameArg := resolveNodeArgs(args) ns, devnetName, err := resolveWithSuggestions(explicitDevnet, namespace) @@ -882,10 +858,6 @@ Examples: dvb node restart my-devnet validator-0`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - explicitDevnet, nodeNameArg := resolveNodeArgs(args) ns, devnetName, err := resolveWithSuggestions(explicitDevnet, namespace) @@ -993,10 +965,6 @@ Examples: dvb node exec validator-0 --timeout 60 -- stabled query bank balances cosmos1...`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - // Find the -- separator position dashDashPos := -1 for i, arg := range args { diff --git a/cmd/dvb/plugins.go b/cmd/dvb/plugins.go index 95391f51..d286bf72 100644 --- a/cmd/dvb/plugins.go +++ b/cmd/dvb/plugins.go @@ -52,10 +52,6 @@ Examples: // runPluginsList lists available network plugins from the daemon func runPluginsList(ctx context.Context) error { - if err := requireDaemon(); err != nil { - return err - } - networks, err := daemonClient.ListNetworks(ctx) if err != nil { return fmt.Errorf("failed to list networks: %w", err) diff --git a/cmd/dvb/provision.go b/cmd/dvb/provision.go index d4f6c0ce..c1b2b6a6 100644 --- a/cmd/dvb/provision.go +++ b/cmd/dvb/provision.go @@ -14,6 +14,7 @@ import ( v1 "github.com/altuslabsxyz/devnet-builder/api/proto/gen/v1" "github.com/altuslabsxyz/devnet-builder/internal/client" + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" "github.com/altuslabsxyz/devnet-builder/internal/config" "github.com/altuslabsxyz/devnet-builder/internal/dvbcontext" "github.com/altuslabsxyz/devnet-builder/internal/output" @@ -74,7 +75,7 @@ Use --list-plugins to see available networks. Run without arguments for an interactive wizard experience. -Examples: + Examples: # List available network plugins dvb provision --list-plugins @@ -98,6 +99,7 @@ Examples: # Preview changes without applying (dry-run) dvb provision --name my-devnet --network stable --dry-run dvb provision -f devnet.yaml --dry-run`, + PreRunE: preRunProvision(opts), RunE: func(cmd *cobra.Command, args []string) error { // List plugins mode if opts.listPlugins { @@ -170,6 +172,37 @@ func detectProvisionMode(opts *provisionOptions) ProvisionMode { return InteractiveMode } +func preRunProvision(opts *provisionOptions) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + mode := detectProvisionMode(opts) + if mode != FlagMode { + return nil + } + + return validateFlagModeOptions(opts) + } +} + +func validateFlagModeOptions(opts *provisionOptions) error { + if !opts.quick && opts.name == "" { + return fmt.Errorf("--name is required in flag mode") + } + if opts.network == "" { + return fmt.Errorf("--network is required in flag mode") + } + if err := cmdvalidation.ValidateAtLeast("validators", opts.validators, 1); err != nil { + return err + } + if err := cmdvalidation.ValidateNonNegative("full-nodes", opts.fullNodes); err != nil { + return err + } + if err := cmdvalidation.ValidateMode(opts.mode); err != nil { + return err + } + + return nil +} + // runInteractiveMode handles interactive wizard mode func runInteractiveMode(ctx context.Context, opts *provisionOptions) error { // Interactive mode requires a TTY @@ -177,11 +210,6 @@ func runInteractiveMode(ctx context.Context, opts *provisionOptions) error { return fmt.Errorf("interactive wizard requires a terminal\n\nUse flags instead:\n dvb provision --name my-devnet --network stable\n\nOr use a YAML file:\n dvb provision -f devnet.yaml\n\nOr use quick mode:\n dvb provision --quick") } - // Require daemon to be running - if err := requireDaemon(); err != nil { - return err - } - // Run the wizard to collect options wizardOpts, err := RunProvisionWizard(daemonClient) if err != nil { @@ -216,11 +244,6 @@ func runInteractiveMode(ctx context.Context, opts *provisionOptions) error { // runFlagMode handles flag-based provisioning func runFlagMode(ctx context.Context, opts *provisionOptions) error { - // Require daemon to be running - if err := requireDaemon(); err != nil { - return err - } - // Quick mode: apply smart defaults for unset values if opts.quick { if opts.name == "" { @@ -236,23 +259,9 @@ func runFlagMode(ctx context.Context, opts *provisionOptions) error { } } - // Validate required flags - if opts.name == "" { - return fmt.Errorf("--name is required in flag mode") - } - if opts.network == "" { - return fmt.Errorf("--network is required in flag mode") - } - - // Validate options - if opts.validators < 1 { - return fmt.Errorf("--validators must be at least 1") - } - if opts.fullNodes < 0 { - return fmt.Errorf("--full-nodes cannot be negative") - } - if opts.mode != "docker" && opts.mode != "local" { - return fmt.Errorf("--mode must be 'docker' or 'local'") + // Validate options after quick-mode defaults are applied. + if err := validateFlagModeOptions(opts); err != nil { + return err } // Build devnet spec @@ -277,11 +286,6 @@ func runFlagMode(ctx context.Context, opts *provisionOptions) error { // runFileMode handles file-based provisioning func runFileMode(ctx context.Context, opts *provisionOptions) error { - // Require daemon to be running - if err := requireDaemon(); err != nil { - return err - } - // Load and validate the YAML file loader := config.NewYAMLLoader() devnets, err := loader.LoadFile(opts.file) diff --git a/cmd/dvb/tx.go b/cmd/dvb/tx.go index ef417be6..e82e2ea7 100644 --- a/cmd/dvb/tx.go +++ b/cmd/dvb/tx.go @@ -43,10 +43,6 @@ func newTxSubmitCmd() *cobra.Command { Short: "Submit a transaction", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - // Get explicit devnet from args or flag explicitDevnet := devnet if len(args) > 0 { @@ -112,10 +108,6 @@ func newTxListCmd() *cobra.Command { Aliases: []string{"ls"}, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - // Get explicit devnet from args or flag explicitDevnet := devnet if len(args) > 0 { @@ -179,10 +171,6 @@ func newTxStatusCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - tx, err := daemonClient.GetTransaction(cmd.Context(), name) if err != nil { return err @@ -202,10 +190,6 @@ func newTxCancelCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - tx, err := daemonClient.CancelTransaction(cmd.Context(), name) if err != nil { return err @@ -247,10 +231,6 @@ func newGovVoteCmd() *cobra.Command { Short: "Submit a governance vote", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - // Get explicit devnet from args or flag explicitDevnet := devnet if len(args) > 0 { @@ -314,10 +294,6 @@ func newGovProposeCmd() *cobra.Command { Short: "Submit a governance proposal", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - // Get explicit devnet from args or flag explicitDevnet := devnet if len(args) > 0 { diff --git a/cmd/dvb/upgrade.go b/cmd/dvb/upgrade.go index 8a8ba600..05f2f167 100644 --- a/cmd/dvb/upgrade.go +++ b/cmd/dvb/upgrade.go @@ -50,20 +50,12 @@ func newUpgradeCreateCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - // Resolve devnet from context if not provided ns, devnetName, err := resolveWithSuggestions(devnet, namespace) if err != nil { return err } - if upgradeName == "" { - return fmt.Errorf("--upgrade-name is required") - } - printContextHeader(devnet, currentContext) // Use namespace-qualified devnet name @@ -125,10 +117,6 @@ func newUpgradeListCmd() *cobra.Command { Short: "List upgrades", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { - if err := requireDaemon(); err != nil { - return err - } - upgrades, err := daemonClient.ListUpgrades(cmd.Context(), namespace) if err != nil { return err @@ -183,10 +171,6 @@ func newUpgradeStatusCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - upgrade, err := daemonClient.GetUpgrade(cmd.Context(), namespace, name) if err != nil { return err @@ -212,10 +196,6 @@ func newUpgradeCancelCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - upgrade, err := daemonClient.CancelUpgrade(cmd.Context(), namespace, name) if err != nil { return err @@ -243,10 +223,6 @@ func newUpgradeRetryCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - upgrade, err := daemonClient.RetryUpgrade(cmd.Context(), namespace, name) if err != nil { return err @@ -277,10 +253,6 @@ func newUpgradeDeleteCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - if err := requireDaemon(); err != nil { - return err - } - if !force && !ShouldSkipConfirm() { fmt.Printf("Are you sure you want to delete upgrade %q? [y/N] ", name) var response string diff --git a/cmd/dvb/validation_hooks.go b/cmd/dvb/validation_hooks.go new file mode 100644 index 00000000..c3301fed --- /dev/null +++ b/cmd/dvb/validation_hooks.go @@ -0,0 +1,29 @@ +package main + +import ( + cmdvalidation "github.com/altuslabsxyz/devnet-builder/internal/cmd/validation" + "github.com/spf13/cobra" +) + +const daemonRequiredAnnotation = "dvb.devnet-builder.io/requires-daemon" + +func markDaemonRequired(cmd *cobra.Command) *cobra.Command { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[daemonRequiredAnnotation] = "true" + return cmd +} + +func commandRequiresDaemon(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Annotations != nil && c.Annotations[daemonRequiredAnnotation] == "true" { + return true + } + } + return false +} + +func validateDaemonRequirement(cmd *cobra.Command) error { + return cmdvalidation.RequireDaemonConnected(daemonClient != nil) +} diff --git a/cmd/dvb/validation_hooks_test.go b/cmd/dvb/validation_hooks_test.go new file mode 100644 index 00000000..fdacb573 --- /dev/null +++ b/cmd/dvb/validation_hooks_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestMarkDaemonRequiredAndLookup(t *testing.T) { + parent := markDaemonRequired(&cobra.Command{Use: "parent"}) + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + + if !commandRequiresDaemon(child) { + t.Fatalf("expected child command to inherit daemon requirement") + } +} + +func TestValidateDaemonRequirement(t *testing.T) { + original := daemonClient + daemonClient = nil + t.Cleanup(func() { + daemonClient = original + }) + + err := validateDaemonRequirement(&cobra.Command{Use: "any"}) + if err == nil { + t.Fatalf("expected daemon requirement error") + } + if !strings.Contains(err.Error(), "daemon not running") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPreRunProvisionValidation(t *testing.T) { + opts := &provisionOptions{ + quick: true, + network: "stable", + validators: 1, + fullNodes: 0, + mode: "invalid", + } + + err := preRunProvision(opts)(&cobra.Command{Use: "provision"}, nil) + if err == nil { + t.Fatalf("expected invalid mode error") + } + if !strings.Contains(err.Error(), "invalid mode") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cmd/validation/validation.go b/internal/cmd/validation/validation.go new file mode 100644 index 00000000..6d0ea35e --- /dev/null +++ b/internal/cmd/validation/validation.go @@ -0,0 +1,79 @@ +package validation + +import ( + "errors" + "fmt" + + "github.com/altuslabsxyz/devnet-builder/types" +) + +var ( + // ErrDaemonNotRunning is returned when a command requires daemon connectivity. + ErrDaemonNotRunning = errors.New("daemon not running - start with: devnetd") +) + +// ValidateMode validates execution mode values accepted by CLI commands. +func ValidateMode(mode string) error { + if !types.ExecutionMode(mode).IsValid() { + return fmt.Errorf("invalid mode: %s (must be 'docker' or 'local')", mode) + } + return nil +} + +// ValidateNetworkSource validates snapshot source network values. +func ValidateNetworkSource(network string) error { + if !types.NetworkSource(network).IsValid() { + return fmt.Errorf("invalid network: %s (must be 'mainnet' or 'testnet')", network) + } + return nil +} + +// ValidateValidatorsForMode validates validator count with mode-specific bounds. +func ValidateValidatorsForMode(mode string, validators int) error { + switch types.ExecutionMode(mode) { + case types.ExecutionModeDocker: + if validators < 1 || validators > 100 { + return fmt.Errorf("invalid validators: %d (must be 1-100 for docker mode)", validators) + } + case types.ExecutionModeLocal: + if validators < 1 || validators > 4 { + return fmt.Errorf("invalid validators: %d (must be 1-4 for local mode)", validators) + } + default: + return ValidateMode(mode) + } + + return nil +} + +// ValidateValidatorsRange validates validator count against a fixed range. +func ValidateValidatorsRange(validators, min, max int) error { + if validators < min || validators > max { + return fmt.Errorf("invalid validators: %d (must be %d-%d)", validators, min, max) + } + return nil +} + +// ValidateAtLeast validates a numeric value has a minimum. +func ValidateAtLeast(flagName string, value, min int) error { + if value < min { + return fmt.Errorf("--%s must be at least %d", flagName, min) + } + return nil +} + +// ValidateNonNegative validates a numeric value is non-negative. +func ValidateNonNegative(flagName string, value int) error { + if value < 0 { + return fmt.Errorf("--%s cannot be negative", flagName) + } + return nil +} + +// RequireDaemonConnected validates daemon connectivity for daemon-dependent commands. +func RequireDaemonConnected(connected bool) error { + if !connected { + return ErrDaemonNotRunning + } + return nil +} diff --git a/internal/cmd/validation/validation_test.go b/internal/cmd/validation/validation_test.go new file mode 100644 index 00000000..875b798a --- /dev/null +++ b/internal/cmd/validation/validation_test.go @@ -0,0 +1,121 @@ +package validation + +import "testing" + +func TestValidateMode(t *testing.T) { + tests := []struct { + name string + mode string + wantErr bool + }{ + {name: "docker", mode: "docker", wantErr: false}, + {name: "local", mode: "local", wantErr: false}, + {name: "invalid", mode: "k8s", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateMode(tt.mode) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateMode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateNetworkSource(t *testing.T) { + tests := []struct { + name string + network string + wantErr bool + }{ + {name: "mainnet", network: "mainnet", wantErr: false}, + {name: "testnet", network: "testnet", wantErr: false}, + {name: "invalid", network: "devnet", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateNetworkSource(tt.network) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateNetworkSource() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateValidatorsForMode(t *testing.T) { + tests := []struct { + name string + mode string + validators int + wantErr bool + }{ + {name: "docker valid min", mode: "docker", validators: 1, wantErr: false}, + {name: "docker valid max", mode: "docker", validators: 100, wantErr: false}, + {name: "docker invalid", mode: "docker", validators: 101, wantErr: true}, + {name: "local valid min", mode: "local", validators: 1, wantErr: false}, + {name: "local valid max", mode: "local", validators: 4, wantErr: false}, + {name: "local invalid", mode: "local", validators: 5, wantErr: true}, + {name: "mode invalid", mode: "k8s", validators: 3, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateValidatorsForMode(tt.mode, tt.validators) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateValidatorsForMode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateValidatorsRange(t *testing.T) { + tests := []struct { + name string + validators int + min int + max int + wantErr bool + }{ + {name: "in range", validators: 2, min: 1, max: 4, wantErr: false}, + {name: "below min", validators: 0, min: 1, max: 4, wantErr: true}, + {name: "above max", validators: 5, min: 1, max: 4, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateValidatorsRange(tt.validators, tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateValidatorsRange() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateAtLeast(t *testing.T) { + if err := ValidateAtLeast("validators", 1, 1); err != nil { + t.Fatalf("ValidateAtLeast() unexpected error: %v", err) + } + if err := ValidateAtLeast("validators", 0, 1); err == nil { + t.Fatalf("ValidateAtLeast() expected error for below minimum") + } +} + +func TestValidateNonNegative(t *testing.T) { + if err := ValidateNonNegative("full-nodes", 0); err != nil { + t.Fatalf("ValidateNonNegative() unexpected error: %v", err) + } + if err := ValidateNonNegative("full-nodes", -1); err == nil { + t.Fatalf("ValidateNonNegative() expected error for negative value") + } +} + +func TestRequireDaemonConnected(t *testing.T) { + if err := RequireDaemonConnected(true); err != nil { + t.Fatalf("RequireDaemonConnected(true) unexpected error: %v", err) + } + if err := RequireDaemonConnected(false); err == nil { + t.Fatalf("RequireDaemonConnected(false) expected error") + } +}