diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30413f6..60c116f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,15 @@ jobs: - name: Download dependencies run: go mod download + - name: Install gocyclo + run: go install github.com/fzipp/gocyclo/cmd/gocyclo@latest + + - name: Add Go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Check manage command handler complexity and LOC + run: ./scripts/check-manage-handler-complexity.sh + - name: Run golangci-lint uses: golangci/golangci-lint-action@v7 with: diff --git a/.gitignore b/.gitignore index d6bd12a9..9f3f283c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /build/ .idea/ .vscode/ +.tmp # Allow source code directories named 'build' !internal/**/build/ @@ -53,4 +54,4 @@ docs/plans/ *.test .serena/ -devnet-builder +/devnet-builder diff --git a/cmd/devnet-builder/commands/manage/deploy.go b/cmd/devnet-builder/commands/manage/deploy.go index 84dfd747..b19bc70d 100644 --- a/cmd/devnet-builder/commands/manage/deploy.go +++ b/cmd/devnet-builder/commands/manage/deploy.go @@ -147,21 +147,113 @@ Examples: return cmd } +type deployContext struct { + cmd *cobra.Command + ctx context.Context + homeDir string + jsonMode bool + logger *output.Logger + fileCfg *config.FileConfig +} + +type deployResolvedConfig struct { + fileCfg *config.FileConfig + network string + blockchainNetwork string + validators int + mode string + stableVersion string + noCache bool + accounts int + testMnemonic bool + fork bool + isInteractive bool +} + +type deployPreparedInputs struct { + startVersion string + dockerImage string + customBinaryPath string + exportBinaryPath string + networkModule network.NetworkModule + svc *application.DevnetService +} + +type deployExecutionResult struct { + runResult *dto.RunOutput + devnetInfo *dto.DevnetInfo +} + func runDeploy(cmd *cobra.Command, args []string) error { + deployCtx := newDeployContext(cmd) + + resolvedConfig, err := resolveDeployConfig(deployCtx) + if err != nil { + return err + } + + deployCtx.logger.SetAutoSpinner(true) + defer deployCtx.logger.SetAutoSpinner(false) + + preparedInputs, err := prepareDeployInputs(deployCtx, resolvedConfig) + if err != nil { + return err + } + + executionResult, err := executeDeployment(deployCtx, resolvedConfig, preparedInputs) + if err != nil { + if deployCtx.jsonMode { + return outputDeployError(err) + } + return err + } + + if deployCtx.jsonMode { + return outputDeployJSON(executionResult.runResult, executionResult.devnetInfo) + } + return outputDeployText(executionResult.runResult, executionResult.devnetInfo) +} + +func newDeployContext(cmd *cobra.Command) *deployContext { ctx := cmd.Context() cfg := ctxconfig.FromContext(ctx) - homeDir := cfg.HomeDir() - jsonMode := cfg.JSONMode() - logger := output.DefaultLogger - - // Build effective config from: default < config.toml < env < flag - // Start with loaded config.toml values fileCfg := cfg.FileConfig() if fileCfg == nil { fileCfg = &config.FileConfig{} } - // Apply flag values (flags override config.toml) + return &deployContext{ + cmd: cmd, + ctx: ctx, + homeDir: cfg.HomeDir(), + jsonMode: cfg.JSONMode(), + logger: output.DefaultLogger, + fileCfg: fileCfg, + } +} + +func resolveDeployConfig(deployCtx *deployContext) (*deployResolvedConfig, error) { + applyDeployFlagOverrides(deployCtx.cmd, deployCtx.fileCfg) + applyDeployEnvOverrides(deployCtx.cmd, deployCtx.fileCfg) + + setup := config.NewInteractiveSetup(deployCtx.homeDir) + effectiveCfg, err := setup.RunPartial(deployCtx.fileCfg) + if err != nil { + if mfErr, ok := err.(*config.MissingFieldsError); ok { + return nil, fmt.Errorf("missing required configuration: %v\nRun 'devnet-builder config init' to create a configuration file", mfErr.Fields) + } + return nil, err + } + + resolvedConfig := extractDeployResolvedConfig(effectiveCfg, deployCtx.jsonMode) + if err := validateDeployConfiguration(resolvedConfig); err != nil { + return nil, err + } + + return resolvedConfig, nil +} + +func applyDeployFlagOverrides(cmd *cobra.Command, fileCfg *config.FileConfig) { if cmd.Flags().Changed("network") { fileCfg.Network = &deployNetwork } @@ -184,8 +276,9 @@ func runDeploy(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("accounts") { fileCfg.Accounts = &deployAccounts } +} - // Apply environment variables (env overrides config.toml but not flags) +func applyDeployEnvOverrides(cmd *cobra.Command, fileCfg *config.FileConfig) { if networkEnv := os.Getenv("DEVNET_NETWORK"); networkEnv != "" && !cmd.Flags().Changed("network") { fileCfg.Network = &networkEnv } @@ -196,19 +289,9 @@ func runDeploy(cmd *cobra.Command, args []string) error { if versionEnv := os.Getenv("DEVNET_NETWORK_VERSION"); versionEnv != "" && !cmd.Flags().Changed("network-version") { fileCfg.NetworkVersion = &versionEnv } +} - // Run partial interactive setup for missing base config values - setup := config.NewInteractiveSetup(homeDir) - effectiveCfg, err := setup.RunPartial(fileCfg) - if err != nil { - // Check if it's a missing fields error for better messaging - if mfErr, ok := err.(*config.MissingFieldsError); ok { - return fmt.Errorf("missing required configuration: %v\nRun 'devnet-builder config init' to create a configuration file", mfErr.Fields) - } - return err - } - - // Extract values from effective config +func extractDeployResolvedConfig(effectiveCfg *config.FileConfig, jsonMode bool) *deployResolvedConfig { deployNetwork = *effectiveCfg.Network deployBlockchainNetwork = *effectiveCfg.BlockchainNetwork deployValidators = *effectiveCfg.Validators @@ -223,99 +306,168 @@ func runDeploy(cmd *cobra.Command, args []string) error { deployAccounts = *effectiveCfg.Accounts } - // Track version for deployment - // startVersion: binary for running nodes - // exportVersion is handled separately via --export-version flag - var startVersion string - var dockerImage string + return &deployResolvedConfig{ + fileCfg: effectiveCfg, + network: deployNetwork, + blockchainNetwork: deployBlockchainNetwork, + validators: deployValidators, + mode: deployMode, + stableVersion: deployStableVersion, + noCache: deployNoCache, + accounts: deployAccounts, + testMnemonic: deployTestMnemonic, + fork: deployFork, + isInteractive: shouldRunDeployInteractiveSelection(jsonMode), + } +} + +func validateDeployConfiguration(resolvedConfig *deployResolvedConfig) error { + if !types.NetworkSource(resolvedConfig.network).IsValid() { + return fmt.Errorf("invalid network: %s (must be 'mainnet' or 'testnet')", resolvedConfig.network) + } + + if resolvedConfig.mode == string(types.ExecutionModeDocker) { + if resolvedConfig.validators < 1 || resolvedConfig.validators > 100 { + return fmt.Errorf("invalid validators: %d (must be 1-100 for docker mode)", resolvedConfig.validators) + } + } else if resolvedConfig.mode == string(types.ExecutionModeLocal) { + if resolvedConfig.validators < 1 || resolvedConfig.validators > 4 { + return fmt.Errorf("invalid validators: %d (must be 1-4 for local mode)", resolvedConfig.validators) + } + if err := validateLocalModePorts(resolvedConfig.validators); err != nil { + return err + } + } else { + return fmt.Errorf("invalid mode: %s (must be 'docker' or 'local')", resolvedConfig.mode) + } - // Determine if running in interactive mode for version selection - // Note: Base config interactive prompts are handled above via RunPartial - // Skip interactive version selection if --binary flag is provided - isInteractive := !deployNoInteractive && !jsonMode && deployBinary == "" + return nil +} - // Variable to store binary paths - // customBinaryPath: binary for running nodes (start) - // exportBinaryPath: binary for genesis export (may differ if --export-version is set) - var customBinaryPath string - var exportBinaryPath string +func shouldRunDeployInteractiveSelection(jsonMode bool) bool { + return !deployNoInteractive && !jsonMode && deployBinary == "" +} - // Docker mode uses GHCR package versions, not GitHub releases - if deployMode == "docker" { - resolvedImage, err := resolveDeployDockerImage(ctx, cmd, isInteractive, homeDir, fileCfg) +func prepareDeployInputs(deployCtx *deployContext, resolvedConfig *deployResolvedConfig) (*deployPreparedInputs, error) { + preparedInputs := &deployPreparedInputs{} + + if resolvedConfig.mode == string(types.ExecutionModeDocker) { + resolvedImage, err := resolveDeployDockerImage( + deployCtx.ctx, + deployCtx.cmd, + resolvedConfig.isInteractive, + deployCtx.homeDir, + resolvedConfig.fileCfg, + ) if err != nil { - return WrapInteractiveError(cmd, err, "failed to resolve docker image") + return nil, WrapInteractiveError(deployCtx.cmd, err, "failed to resolve docker image") } - dockerImage = resolvedImage - startVersion = deployStableVersion + preparedInputs.dockerImage = resolvedImage + preparedInputs.startVersion = resolvedConfig.stableVersion } else { - // Local mode: run interactive selection flow (local binary OR GitHub releases) - if isInteractive { - // includeNetworkSelection = false (network is already known from config) - // Pass deployNetwork so ConfirmSelection shows the correct network - // Pass deployBlockchainNetwork to fetch releases from the correct repository - selection, err := RunInteractiveVersionSelection(ctx, cmd, false, deployNetwork, deployBlockchainNetwork) - if err != nil { - return WrapInteractiveError(cmd, err, "failed during interactive selection") - } - startVersion = selection.StartVersion - deployStableVersion = startVersion - - // If user selected a local binary, store it for later use - // This prevents the need to call selectBinaryForDeployment() again - if selection.BinarySource != nil && selection.BinarySource.IsLocal() && selection.BinarySource.SelectedPath != "" { - customBinaryPath = selection.BinarySource.SelectedPath - } else if selection.BinarySource != nil && selection.BinarySource.IsGitHubRelease() && startVersion != "" { - // User selected GitHub release - pre-build the binary now - // This prevents the binary selection prompt from appearing - buildResult, err := buildBinaryForDeploy(ctx, deployBlockchainNetwork, startVersion, deployNetwork, homeDir, logger) - if err != nil { - return fmt.Errorf("failed to pre-build binary: %w", err) - } - customBinaryPath = buildResult.BinaryPath - commitShort := buildResult.CommitHash - if len(commitShort) > 12 { - commitShort = commitShort[:12] - } - logger.Success("Binary pre-built and cached (commit: %s)", commitShort) - } - } else { - // Non-interactive: use --start-version if provided, otherwise fall back to --network-version - if deployStartVersion != "" { - startVersion = deployStartVersion - } else { - startVersion = deployStableVersion - } + startVersion, customBinaryPath, err := resolveDeployLocalVersionSelection(deployCtx, resolvedConfig) + if err != nil { + return nil, err } + preparedInputs.startVersion = startVersion + preparedInputs.customBinaryPath = customBinaryPath } - // Validate inputs - if !types.NetworkSource(deployNetwork).IsValid() { - return fmt.Errorf("invalid network: %s (must be 'mainnet' or 'testnet')", deployNetwork) + if err := validateDeprecatedDeployBinaryFlag(); err != nil { + return nil, err + } + + networkModule, err := resolveDeployNetworkModule(resolvedConfig.blockchainNetwork) + if err != nil { + return nil, 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) + preparedInputs.networkModule = networkModule + + svc, err := application.GetServiceWithConfig(application.ServiceConfig{ + HomeDir: deployCtx.homeDir, + NetworkModule: networkModule, + DockerMode: resolvedConfig.mode == string(types.ExecutionModeDocker), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize service: %w", err) + } + if svc.DevnetExists() { + return nil, fmt.Errorf("devnet already exists at %s\nUse 'devnet-builder destroy' to remove it first", deployCtx.homeDir) + } + preparedInputs.svc = svc + + if resolvedConfig.mode == string(types.ExecutionModeLocal) { + customBinaryPath, exportBinaryPath, err := resolveLocalDeployBinaryPaths(deployCtx, resolvedConfig, preparedInputs) + if err != nil { + return nil, err } - } 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) + preparedInputs.customBinaryPath = customBinaryPath + preparedInputs.exportBinaryPath = exportBinaryPath + } + + return preparedInputs, nil +} + +func resolveDeployLocalVersionSelection( + deployCtx *deployContext, + resolvedConfig *deployResolvedConfig, +) (string, string, error) { + startVersion := "" + customBinaryPath := "" + + if resolvedConfig.isInteractive { + selection, err := RunInteractiveVersionSelection( + deployCtx.ctx, + deployCtx.cmd, + false, + resolvedConfig.network, + resolvedConfig.blockchainNetwork, + ) + if err != nil { + return "", "", WrapInteractiveError(deployCtx.cmd, err, "failed during interactive selection") + } + + startVersion = selection.StartVersion + deployStableVersion = startVersion + + if selection.BinarySource != nil && selection.BinarySource.IsLocal() && selection.BinarySource.SelectedPath != "" { + customBinaryPath = selection.BinarySource.SelectedPath + } else if selection.BinarySource != nil && selection.BinarySource.IsGitHubRelease() && startVersion != "" { + buildResult, err := buildBinaryForDeploy( + deployCtx.ctx, + resolvedConfig.blockchainNetwork, + startVersion, + resolvedConfig.network, + deployCtx.homeDir, + deployCtx.logger, + ) + if err != nil { + return "", "", fmt.Errorf("failed to pre-build binary: %w", err) + } + customBinaryPath = buildResult.BinaryPath + commitShort := buildResult.CommitHash + if len(commitShort) > 12 { + commitShort = commitShort[:12] + } + deployCtx.logger.Success("Binary pre-built and cached (commit: %s)", commitShort) } } else { - return fmt.Errorf("invalid mode: %s (must be 'docker' or 'local')", deployMode) + if deployStartVersion != "" { + startVersion = deployStartVersion + } else { + startVersion = resolvedConfig.stableVersion + } } - // Validate port availability for local mode before proceeding - if deployMode == string(types.ExecutionModeLocal) { - if err := validateLocalModePorts(deployValidators); err != nil { - return err - } + return startVersion, customBinaryPath, nil +} + +func validateDeprecatedDeployBinaryFlag() error { + if deployBinary == "" { + return nil } - // Check for deprecated --binary flag usage - if deployBinary != "" { - return fmt.Errorf(`the --binary flag has been removed in favor of interactive binary selection + return fmt.Errorf(`the --binary flag has been removed in favor of interactive binary selection When you run 'devnet-builder deploy' in interactive mode, you will be prompted to: 1. Choose between using a local binary or downloading from GitHub releases @@ -335,137 +487,149 @@ Migration guide: devnet-builder deploy --mode docker --image your-image:tag For more information, see: https://github.com/altuslabsxyz/devnet-builder/blob/main/docs/MIGRATION.md`) - } +} - // Validate blockchain network module exists - if !network.Has(deployBlockchainNetwork) { +func resolveDeployNetworkModule(blockchainNetwork string) (network.NetworkModule, error) { + if !network.Has(blockchainNetwork) { available := network.List() - return fmt.Errorf("unknown blockchain network: %s (available: %v)", deployBlockchainNetwork, available) + return nil, fmt.Errorf("unknown blockchain network: %s (available: %v)", blockchainNetwork, available) } - // Get network module for DI container - networkModule, err := network.Get(deployBlockchainNetwork) + networkModule, err := network.Get(blockchainNetwork) if err != nil { - return fmt.Errorf("failed to get network module: %w", err) + return nil, fmt.Errorf("failed to get network module: %w", err) } - // Check if devnet already exists - svc, err := application.GetServiceWithConfig(application.ServiceConfig{ - HomeDir: homeDir, - NetworkModule: networkModule, - DockerMode: deployMode == string(types.ExecutionModeDocker), - }) - if err != nil { - return fmt.Errorf("failed to initialize service: %w", err) - } - if svc.DevnetExists() { - return fmt.Errorf("devnet already exists at %s\nUse 'devnet-builder destroy' to remove it first", homeDir) - } - - // Enable auto spinner for long-running operations - // The spinner will show after Success/Info logs and clear on next log - logger.SetAutoSpinner(true) - - // Build binary for local mode (all versions need to be built/cached) - // Priority: unified selection > cached binary > build from source - if deployMode == string(types.ExecutionModeLocal) { - // Check if binary was already selected via unified selection (interactive mode) - // If user selected a local binary via the filesystem browser, customBinaryPath is already set - if customBinaryPath == "" { - // No binary selected yet - fall back to old selection logic (for non-interactive mode) - // Interactive/Auto selection from cache (US1) - selectedPath, err := selectBinaryForDeployment(ctx, deployNetwork, deployBlockchainNetwork, homeDir, logger) - if err != nil { - return fmt.Errorf("binary selection failed: %w", err) - } + return networkModule, nil +} - // If no binary selected (empty cache, no explicit build request) - // Priority 3: Build binary from source (existing behavior) - if selectedPath == "" { - buildResult, err := buildBinaryForDeploy(ctx, deployBlockchainNetwork, startVersion, deployNetwork, homeDir, logger) - if err != nil { - return fmt.Errorf("failed to build from source: %w", err) - } - customBinaryPath = buildResult.BinaryPath - logger.Success("Binary built: %s (commit: %s)", buildResult.BinaryPath, buildResult.CommitHash) - } else { - // Use the selected cached binary - customBinaryPath = selectedPath - } - } else { - // customBinaryPath already set from unified selection - use it directly - logger.Success("Using selected binary: %s", customBinaryPath) +func resolveLocalDeployBinaryPaths( + deployCtx *deployContext, + resolvedConfig *deployResolvedConfig, + preparedInputs *deployPreparedInputs, +) (string, string, error) { + customBinaryPath := preparedInputs.customBinaryPath + + if customBinaryPath == "" { + selectedPath, err := selectBinaryForDeployment( + deployCtx.ctx, + resolvedConfig.network, + resolvedConfig.blockchainNetwork, + deployCtx.homeDir, + deployCtx.logger, + ) + if err != nil { + return "", "", fmt.Errorf("binary selection failed: %w", err) } - // Handle --export-version flag for separate export binary - // This allows using a different binary version for genesis export - if deployExportVersion != "" { - logger.Info("Building export binary version: %s", deployExportVersion) - buildResult, err := buildBinaryForDeploy(ctx, deployBlockchainNetwork, deployExportVersion, deployNetwork, homeDir, logger) + if selectedPath == "" { + buildResult, err := buildBinaryForDeploy( + deployCtx.ctx, + resolvedConfig.blockchainNetwork, + preparedInputs.startVersion, + resolvedConfig.network, + deployCtx.homeDir, + deployCtx.logger, + ) if err != nil { - return fmt.Errorf("failed to build export binary: %w", err) + return "", "", fmt.Errorf("failed to build from source: %w", err) } - exportBinaryPath = buildResult.BinaryPath - commitShort := buildResult.CommitHash - if len(commitShort) > 12 { - commitShort = commitShort[:12] - } - logger.Success("Export binary ready (version: %s, commit: %s)", deployExportVersion, commitShort) + customBinaryPath = buildResult.BinaryPath + deployCtx.logger.Success("Binary built: %s (commit: %s)", buildResult.BinaryPath, buildResult.CommitHash) } else { - // No export version specified - use start binary for export too - exportBinaryPath = customBinaryPath + customBinaryPath = selectedPath } - } - - // Phase 1: Provision using DevnetService - // Note: BinaryPath is used for genesis export, CustomBinaryPath is used for node startup - // When --export-version is specified, these may be different binaries - provisionInput := dto.ProvisionInput{ - HomeDir: homeDir, - Network: deployNetwork, - BlockchainNetwork: deployBlockchainNetwork, - NumValidators: deployValidators, - NumAccounts: deployAccounts, - Mode: deployMode, - StableVersion: startVersion, - DockerImage: dockerImage, - NoCache: deployNoCache, - CustomBinaryPath: customBinaryPath, // Binary for node startup - UseSnapshot: deployFork, - BinaryPath: exportBinaryPath, // Binary for genesis export (may differ with --export-version) - UseTestMnemonic: deployTestMnemonic, - } - - _, err = svc.Provision(ctx, provisionInput) - if err != nil { - logger.SetAutoSpinner(false) - if jsonMode { - return outputDeployError(err) + } else { + deployCtx.logger.Success("Using selected binary: %s", customBinaryPath) + } + + exportBinaryPath := customBinaryPath + if deployExportVersion != "" { + deployCtx.logger.Info("Building export binary version: %s", deployExportVersion) + buildResult, err := buildBinaryForDeploy( + deployCtx.ctx, + resolvedConfig.blockchainNetwork, + deployExportVersion, + resolvedConfig.network, + deployCtx.homeDir, + deployCtx.logger, + ) + if err != nil { + return "", "", fmt.Errorf("failed to build export binary: %w", err) } - return err + exportBinaryPath = buildResult.BinaryPath + commitShort := buildResult.CommitHash + if len(commitShort) > 12 { + commitShort = commitShort[:12] + } + deployCtx.logger.Success("Export binary ready (version: %s, commit: %s)", deployExportVersion, commitShort) } - // Phase 2: Run using DevnetService - runResult, err := svc.Start(ctx, 5*time.Minute) - if err != nil { - logger.SetAutoSpinner(false) - if jsonMode { - return outputDeployError(err) - } - return err + return customBinaryPath, exportBinaryPath, nil +} + +func executeDeployment( + deployCtx *deployContext, + resolvedConfig *deployResolvedConfig, + preparedInputs *deployPreparedInputs, +) (*deployExecutionResult, error) { + if resolvedConfig.mode == string(types.ExecutionModeDocker) { + return executeDockerDeployment(deployCtx, resolvedConfig, preparedInputs) } + return executeLocalDeployment(deployCtx, resolvedConfig, preparedInputs) +} + +func executeDockerDeployment( + deployCtx *deployContext, + resolvedConfig *deployResolvedConfig, + preparedInputs *deployPreparedInputs, +) (*deployExecutionResult, error) { + return runDeployProvisionAndStart(deployCtx, resolvedConfig, preparedInputs) +} - // Get devnet info for output - devnetInfo, _ := svc.LoadDevnetInfo(ctx) +func executeLocalDeployment( + deployCtx *deployContext, + resolvedConfig *deployResolvedConfig, + preparedInputs *deployPreparedInputs, +) (*deployExecutionResult, error) { + return runDeployProvisionAndStart(deployCtx, resolvedConfig, preparedInputs) +} - // Stop spinner before final output - logger.SetAutoSpinner(false) +func runDeployProvisionAndStart( + deployCtx *deployContext, + resolvedConfig *deployResolvedConfig, + preparedInputs *deployPreparedInputs, +) (*deployExecutionResult, error) { + provisionInput := dto.ProvisionInput{ + HomeDir: deployCtx.homeDir, + Network: resolvedConfig.network, + BlockchainNetwork: resolvedConfig.blockchainNetwork, + NumValidators: resolvedConfig.validators, + NumAccounts: resolvedConfig.accounts, + Mode: resolvedConfig.mode, + StableVersion: preparedInputs.startVersion, + DockerImage: preparedInputs.dockerImage, + NoCache: resolvedConfig.noCache, + CustomBinaryPath: preparedInputs.customBinaryPath, + UseSnapshot: resolvedConfig.fork, + BinaryPath: preparedInputs.exportBinaryPath, + UseTestMnemonic: resolvedConfig.testMnemonic, + } + + if _, err := preparedInputs.svc.Provision(deployCtx.ctx, provisionInput); err != nil { + return nil, err + } - // Output result - if jsonMode { - return outputDeployJSON(runResult, devnetInfo) + runResult, err := preparedInputs.svc.Start(deployCtx.ctx, 5*time.Minute) + if err != nil { + return nil, err } - return outputDeployText(runResult, devnetInfo) + + devnetInfo, _ := preparedInputs.svc.LoadDevnetInfo(deployCtx.ctx) + return &deployExecutionResult{ + runResult: runResult, + devnetInfo: devnetInfo, + }, nil } func outputDeployText(result *dto.RunOutput, devnetInfo *dto.DevnetInfo) error { diff --git a/cmd/devnet-builder/commands/manage/deploy_refactor_test.go b/cmd/devnet-builder/commands/manage/deploy_refactor_test.go new file mode 100644 index 00000000..4789b93a --- /dev/null +++ b/cmd/devnet-builder/commands/manage/deploy_refactor_test.go @@ -0,0 +1,265 @@ +package manage + +import ( + "strings" + "testing" + + "github.com/altuslabsxyz/devnet-builder/internal/config" + "github.com/altuslabsxyz/devnet-builder/types" + "github.com/spf13/cobra" +) + +type deployFlagSnapshot struct { + deployNetwork string + deployBlockchainNetwork string + deployValidators int + deployMode string + deployStableVersion string + deployNoCache bool + deployAccounts int + deployNoInteractive bool + deployBinary string + deployFork bool + deployTestMnemonic bool +} + +func snapshotDeployFlags() deployFlagSnapshot { + return deployFlagSnapshot{ + deployNetwork: deployNetwork, + deployBlockchainNetwork: deployBlockchainNetwork, + deployValidators: deployValidators, + deployMode: deployMode, + deployStableVersion: deployStableVersion, + deployNoCache: deployNoCache, + deployAccounts: deployAccounts, + deployNoInteractive: deployNoInteractive, + deployBinary: deployBinary, + deployFork: deployFork, + deployTestMnemonic: deployTestMnemonic, + } +} + +func restoreDeployFlags(s deployFlagSnapshot) { + deployNetwork = s.deployNetwork + deployBlockchainNetwork = s.deployBlockchainNetwork + deployValidators = s.deployValidators + deployMode = s.deployMode + deployStableVersion = s.deployStableVersion + deployNoCache = s.deployNoCache + deployAccounts = s.deployAccounts + deployNoInteractive = s.deployNoInteractive + deployBinary = s.deployBinary + deployFork = s.deployFork + deployTestMnemonic = s.deployTestMnemonic +} + +func newDeployTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringVar(&deployNetwork, "network", "mainnet", "") + cmd.Flags().StringVar(&deployBlockchainNetwork, "blockchain", "stable", "") + cmd.Flags().IntVar(&deployValidators, "validators", 4, "") + cmd.Flags().StringVar(&deployMode, "mode", "docker", "") + cmd.Flags().StringVar(&deployStableVersion, "network-version", "latest", "") + cmd.Flags().BoolVar(&deployNoCache, "no-cache", false, "") + cmd.Flags().IntVar(&deployAccounts, "accounts", 4, "") + return cmd +} + +func TestApplyDeployFlagOverrides(t *testing.T) { + snapshot := snapshotDeployFlags() + t.Cleanup(func() { restoreDeployFlags(snapshot) }) + + cmd := newDeployTestCmd() + _ = cmd.Flags().Set("network", "testnet") + _ = cmd.Flags().Set("blockchain", "ault") + _ = cmd.Flags().Set("validators", "12") + _ = cmd.Flags().Set("mode", "docker") + _ = cmd.Flags().Set("network-version", "v1.2.3") + _ = cmd.Flags().Set("no-cache", "true") + _ = cmd.Flags().Set("accounts", "9") + + fileCfg := &config.FileConfig{} + applyDeployFlagOverrides(cmd, fileCfg) + + if fileCfg.Network == nil || *fileCfg.Network != "testnet" { + t.Fatalf("expected network override") + } + if fileCfg.BlockchainNetwork == nil || *fileCfg.BlockchainNetwork != "ault" { + t.Fatalf("expected blockchain override") + } + if fileCfg.Validators == nil || *fileCfg.Validators != 12 { + t.Fatalf("expected validators override") + } + if fileCfg.ExecutionMode == nil || *fileCfg.ExecutionMode != types.ExecutionModeDocker { + t.Fatalf("expected mode override") + } + if fileCfg.NetworkVersion == nil || *fileCfg.NetworkVersion != "v1.2.3" { + t.Fatalf("expected network version override") + } + if fileCfg.NoCache == nil || *fileCfg.NoCache != true { + t.Fatalf("expected no-cache override") + } + if fileCfg.Accounts == nil || *fileCfg.Accounts != 9 { + t.Fatalf("expected accounts override") + } +} + +func TestApplyDeployEnvOverrides(t *testing.T) { + snapshot := snapshotDeployFlags() + t.Cleanup(func() { restoreDeployFlags(snapshot) }) + + cmd := newDeployTestCmd() + fileCfg := &config.FileConfig{} + + t.Setenv("DEVNET_NETWORK", "testnet") + t.Setenv("DEVNET_MODE", "local") + t.Setenv("DEVNET_NETWORK_VERSION", "v2.1.0") + + applyDeployEnvOverrides(cmd, fileCfg) + + if fileCfg.Network == nil || *fileCfg.Network != "testnet" { + t.Fatalf("expected network env override") + } + if fileCfg.ExecutionMode == nil || *fileCfg.ExecutionMode != types.ExecutionModeLocal { + t.Fatalf("expected mode env override") + } + if fileCfg.NetworkVersion == nil || *fileCfg.NetworkVersion != "v2.1.0" { + t.Fatalf("expected network version env override") + } +} + +func TestExtractDeployResolvedConfig(t *testing.T) { + snapshot := snapshotDeployFlags() + t.Cleanup(func() { restoreDeployFlags(snapshot) }) + + network := "mainnet" + blockchain := "stable" + validators := 4 + mode := types.ExecutionModeDocker + version := "v1.0.0" + noCache := true + accounts := 7 + + effectiveCfg := &config.FileConfig{ + Network: &network, + BlockchainNetwork: &blockchain, + Validators: &validators, + ExecutionMode: &mode, + NetworkVersion: &version, + NoCache: &noCache, + Accounts: &accounts, + } + + deployNoInteractive = false + deployBinary = "" + resolved := extractDeployResolvedConfig(effectiveCfg, false) + if resolved.network != "mainnet" || resolved.blockchainNetwork != "stable" { + t.Fatalf("unexpected resolved network values") + } + if resolved.mode != "docker" { + t.Fatalf("unexpected resolved mode: %q", resolved.mode) + } + if !resolved.isInteractive { + t.Fatalf("expected interactive mode true") + } +} + +func TestValidateDeployConfiguration(t *testing.T) { + tests := []struct { + name string + cfg *deployResolvedConfig + wantErr bool + }{ + { + name: "invalid network", + cfg: &deployResolvedConfig{network: "bad", mode: "docker", validators: 4}, + wantErr: true, + }, + { + name: "invalid docker validators", + cfg: &deployResolvedConfig{network: "mainnet", mode: "docker", validators: 101}, + wantErr: true, + }, + { + name: "invalid local validators", + cfg: &deployResolvedConfig{network: "mainnet", mode: "local", validators: 5}, + wantErr: true, + }, + { + name: "invalid mode", + cfg: &deployResolvedConfig{network: "mainnet", mode: "invalid", validators: 1}, + wantErr: true, + }, + { + name: "valid docker", + cfg: &deployResolvedConfig{network: "mainnet", mode: "docker", validators: 10}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDeployConfiguration(tt.cfg) + if tt.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) + } +} + +func TestShouldRunDeployInteractiveSelection(t *testing.T) { + snapshot := snapshotDeployFlags() + t.Cleanup(func() { restoreDeployFlags(snapshot) }) + + deployNoInteractive = false + deployBinary = "" + if !shouldRunDeployInteractiveSelection(false) { + t.Fatalf("expected interactive selection to run") + } + if shouldRunDeployInteractiveSelection(true) { + t.Fatalf("expected interactive selection disabled in json mode") + } + + deployNoInteractive = true + if shouldRunDeployInteractiveSelection(false) { + t.Fatalf("expected interactive selection disabled with --no-interactive") + } +} + +func TestValidateDeprecatedDeployBinaryFlag(t *testing.T) { + snapshot := snapshotDeployFlags() + t.Cleanup(func() { restoreDeployFlags(snapshot) }) + + deployBinary = "" + if err := validateDeprecatedDeployBinaryFlag(); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + deployBinary = "/tmp/custom" + err := validateDeprecatedDeployBinaryFlag() + if err == nil { + t.Fatalf("expected deprecated binary flag error") + } + if !strings.Contains(err.Error(), "--binary flag has been removed") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestApplyDeployEnvOverrides_DoesNotOverrideChangedFlags(t *testing.T) { + snapshot := snapshotDeployFlags() + t.Cleanup(func() { restoreDeployFlags(snapshot) }) + + cmd := newDeployTestCmd() + _ = cmd.Flags().Set("network", "mainnet") + + fileCfg := &config.FileConfig{} + t.Setenv("DEVNET_NETWORK", "testnet") + applyDeployEnvOverrides(cmd, fileCfg) + + if fileCfg.Network != nil { + t.Fatalf("expected network env not to override changed flag") + } +} diff --git a/cmd/devnet-builder/commands/manage/upgrade.go b/cmd/devnet-builder/commands/manage/upgrade.go index adc68c64..e40389c3 100644 --- a/cmd/devnet-builder/commands/manage/upgrade.go +++ b/cmd/devnet-builder/commands/manage/upgrade.go @@ -3,6 +3,7 @@ package manage import ( "context" "encoding/json" + "errors" "fmt" "os" "os/signal" @@ -178,20 +179,98 @@ type UpgradeResultJSON struct { PostGenesisPath string `json:"post_genesis_path,omitempty"` } +type upgradeContext struct { + cmd *cobra.Command + commandCtx context.Context + ctx context.Context + homeDir string + jsonMode bool + logger *output.Logger +} + +type upgradeResolvedConfig struct { + svc *application.DevnetService + metadata *ports.DevnetMetadata + networkModule network.NetworkModule + resolvedMode UpgradeExecutionMode + modeExplicitlySet bool +} + +type upgradeBinaryResolution struct { + selectedVersion string + selectedName string + customBinarySymlinkPath string + cachedBuildResult *dto.BuildOutput + versionResolvedImage string + targetBinary string + targetImage string +} + +type upgradeGovernanceResolution struct { + govParams *ports.GovParams + votingPeriod time.Duration +} + +type upgradeExecutionResult struct { + result *dto.ExecuteUpgradeOutput +} + +var errUpgradeCancelled = errors.New("upgrade cancelled") + func runUpgrade(cmd *cobra.Command, args []string) error { - // Get config from context - cmdCtx := cmd.Context() - cfg := ctxconfig.FromContext(cmdCtx) - homeDir := cfg.HomeDir() - jsonMode := cfg.JSONMode() + upgradeCtx := newUpgradeContext(cmd) + var cleanup func() + upgradeCtx.ctx, cleanup = setupSignalHandling(upgradeCtx.commandCtx) + defer cleanup() - // Set up signal handling for graceful cancellation - ctx, cancel := context.WithCancel(cmdCtx) - defer cancel() + if upgradeClearState || upgradeShowStatus { + return handleResumeOnlyOperations(upgradeCtx.ctx, upgradeCtx.homeDir, upgradeCtx.logger, upgradeCtx.jsonMode) + } + + resolvedConfig, err := resolveUpgradeConfig(upgradeCtx) + if err != nil { + return err + } + + binaryResolution, err := resolveBinarySource(upgradeCtx, resolvedConfig) + if errors.Is(err, errUpgradeCancelled) { + return nil + } + if err != nil { + return err + } + + governanceResolution, err := resolveGovernanceParams(upgradeCtx, resolvedConfig) + if err != nil { + return err + } + + executionResult, err := executeUpgrade(upgradeCtx, resolvedConfig, binaryResolution, governanceResolution) + if err != nil { + return err + } + + return reportResults(upgradeCtx, executionResult) +} + +func newUpgradeContext(cmd *cobra.Command) *upgradeContext { + commandCtx := cmd.Context() + cfg := ctxconfig.FromContext(commandCtx) + + return &upgradeContext{ + cmd: cmd, + commandCtx: commandCtx, + homeDir: cfg.HomeDir(), + jsonMode: cfg.JSONMode(), + logger: output.DefaultLogger, + } +} + +func setupSignalHandling(commandCtx context.Context) (context.Context, func()) { + ctx, cancel := context.WithCancel(commandCtx) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(sigChan) go func() { <-sigChan @@ -208,141 +287,181 @@ func runUpgrade(cmd *cobra.Command, args []string) error { cancel() }() - logger := output.DefaultLogger - - // Handle resume-related flags early (before devnet checks for some operations) - if upgradeClearState || upgradeShowStatus { - return handleResumeOnlyOperations(ctx, homeDir, logger, jsonMode) + cleanup := func() { + signal.Stop(sigChan) + cancel() } - // Initialize DevnetService for existence and status checks - svc, err := application.GetService(homeDir) + return ctx, cleanup +} + +func resolveUpgradeConfig(upgradeCtx *upgradeContext) (*upgradeResolvedConfig, error) { + svc, err := application.GetService(upgradeCtx.homeDir) if err != nil { - return outputUpgradeError(fmt.Errorf("failed to initialize service: %w", err)) + return nil, outputUpgradeError(fmt.Errorf("failed to initialize service: %w", err)) } - // Check if devnet exists using DevnetService if !svc.DevnetExists() { - err := fmt.Errorf("no devnet found at %s", homeDir) - if jsonMode { - return outputUpgradeError(err) + err := fmt.Errorf("no devnet found at %s", upgradeCtx.homeDir) + if upgradeCtx.jsonMode { + return nil, outputUpgradeError(err) } - return err + return nil, err } - // Load metadata via DevnetService for status check - cleanMetadata, err := svc.LoadMetadata(ctx) + metadata, err := svc.LoadMetadata(upgradeCtx.ctx) if err != nil { - if jsonMode { - return outputUpgradeError(err) + if upgradeCtx.jsonMode { + return nil, outputUpgradeError(err) } - return err + return nil, err } - if cleanMetadata.Status != ports.StateRunning { - if jsonMode { - return outputUpgradeError(fmt.Errorf("devnet is not running")) + if metadata.Status != ports.StateRunning { + if upgradeCtx.jsonMode { + return nil, outputUpgradeError(fmt.Errorf("devnet is not running")) } - return fmt.Errorf("devnet is not running\nStart it with 'devnet-builder start'") + return nil, fmt.Errorf("devnet is not running\nStart it with 'devnet-builder start'") + } + + networkModule, err := network.Get(metadata.BlockchainNetwork) + if err != nil { + return nil, outputUpgradeError(fmt.Errorf("failed to get network module: %w", err)) } - // Get network module for binary name - networkModule, err := network.Get(cleanMetadata.BlockchainNetwork) + resolvedMode, modeExplicitlySet, err := resolveUpgradeExecutionMode(metadata.ExecutionMode) if err != nil { - return outputUpgradeError(fmt.Errorf("failed to get network module: %w", err)) + return nil, err } - // Resolve execution mode: flag > metadata default - resolvedMode := UpgradeExecutionMode(cleanMetadata.ExecutionMode) + if !upgradeCtx.jsonMode { + warnUpgradeModeMismatch(resolvedMode, modeExplicitlySet, metadata.ExecutionMode) + } + + return &upgradeResolvedConfig{ + svc: svc, + metadata: metadata, + networkModule: networkModule, + resolvedMode: resolvedMode, + modeExplicitlySet: modeExplicitlySet, + }, nil +} + +func resolveUpgradeExecutionMode(metadataMode types.ExecutionMode) (UpgradeExecutionMode, bool, error) { + resolvedMode := UpgradeExecutionMode(metadataMode) 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) + return "", false, fmt.Errorf("invalid mode %q: must be 'docker' or 'local'", upgradeMode) } } - // Mode validation against --image/--binary flags - if !jsonMode { - if resolvedMode == UpgradeModeDocker && upgradeBinary != "" && !modeExplicitlySet { - output.Warn("Devnet was started in docker mode but --binary was provided.") - output.Warn("Use --image for docker mode, or --mode local to switch modes.") - } - if resolvedMode == UpgradeModeLocal && upgradeImage != "" && !modeExplicitlySet { - output.Warn("Devnet was started in local mode but --image was provided.") - output.Warn("Use --binary for local mode, or --mode docker to switch modes.") - } - if modeExplicitlySet && resolvedMode != UpgradeExecutionMode(cleanMetadata.ExecutionMode) { - output.Warn("Switching execution mode from %s to %s.", cleanMetadata.ExecutionMode, resolvedMode) - output.Warn("The devnet will continue in %s mode after this upgrade.", resolvedMode) - } - } - - // Track selected version and name - var selectedVersion string - var selectedName string + return resolvedMode, modeExplicitlySet, nil +} - // Variable to store custom binary path (set by unified selection or selectBinaryForUpgrade) - var customBinarySymlinkPath string +func warnUpgradeModeMismatch(resolvedMode UpgradeExecutionMode, modeExplicitlySet bool, metadataMode types.ExecutionMode) { + if resolvedMode == UpgradeModeDocker && upgradeBinary != "" && !modeExplicitlySet { + output.Warn("Devnet was started in docker mode but --binary was provided.") + output.Warn("Use --image for docker mode, or --mode local to switch modes.") + } + if resolvedMode == UpgradeModeLocal && upgradeImage != "" && !modeExplicitlySet { + output.Warn("Devnet was started in local mode but --image was provided.") + output.Warn("Use --binary for local mode, or --mode docker to switch modes.") + } + if modeExplicitlySet && resolvedMode != UpgradeExecutionMode(metadataMode) { + output.Warn("Switching execution mode from %s to %s.", metadataMode, resolvedMode) + output.Warn("The devnet will continue in %s mode after this upgrade.", resolvedMode) + } +} - // Interactive mode: run selection flow if not disabled - // Skip interactive selection if --image or --binary flags are provided - if !upgradeNoInteractive && !jsonMode && upgradeImage == "" && upgradeBinary == "" { - // Use unified selection function for upgrade command - // forUpgrade = true: collects only upgrade target version (no export/start distinction) - // includeNetworkSelection = false: network is already determined from running devnet - // skipUpgradeName = skipGovernance: skip upgrade name prompt when --skip-gov is set - // Pass cleanMetadata.BlockchainNetwork to fetch releases from the correct repository - selection, err := RunInteractiveVersionSelectionWithMode(ctx, cmd, false, true, "", skipGovernance, cleanMetadata.BlockchainNetwork) +func resolveBinarySource(upgradeCtx *upgradeContext, resolvedConfig *upgradeResolvedConfig) (*upgradeBinaryResolution, error) { + selectedName := upgradeName + selectedVersion := upgradeVersion + customBinarySymlinkPath := "" + + if shouldRunUpgradeInteractiveSelection(upgradeCtx.jsonMode) { + selection, err := RunInteractiveVersionSelectionWithMode( + upgradeCtx.ctx, + upgradeCtx.cmd, + false, + true, + "", + skipGovernance, + resolvedConfig.metadata.BlockchainNetwork, + ) if err != nil { if interactive.IsCancellation(err) { fmt.Println("Operation cancelled.") - return nil + return nil, errUpgradeCancelled } - return err + return nil, err } - // Extract version information - // For upgrade, we use the start version (same as export version in unified selection) selectedVersion = selection.StartVersion - - // Determine upgrade name with priority: - // 1. CLI flag (--name) if provided - // 2. User input from interactive prompt (selection.UpgradeName) - // 3. Auto-generate from version as fallback - if upgradeName != "" { - selectedName = upgradeName - } else if selection.UpgradeName != "" { - // Use the upgrade name entered by user in interactive prompt - selectedName = selection.UpgradeName - } else { - // Auto-generate upgrade name from version - // For custom refs (branches), extract just the last part after '/' - // e.g., "feat/gas-waiver" -> "gas-waiver-upgrade" - // For tags, use as-is: "v2.0.0" -> "v2.0.0-upgrade" - versionForName := selection.StartVersion - if selection.StartIsCustomRef && strings.Contains(versionForName, "/") { - parts := strings.Split(versionForName, "/") - versionForName = parts[len(parts)-1] - } - selectedName = versionForName + "-upgrade" - } - - // If user selected a local binary, store it for later use - // This prevents the need to call selectBinaryForUpgrade() again + selectedName = resolveUpgradeName(selection) if selection.BinarySource.IsLocal() && selection.BinarySource.SelectedPath != "" { customBinarySymlinkPath = selection.BinarySource.SelectedPath } - } else { - // Non-interactive mode: use explicit flags - selectedName = upgradeName - selectedVersion = upgradeVersion } - // Check for deprecated --binary flag usage + if err := validateUpgradeSourceInputs(selectedVersion, selectedName); err != nil { + return nil, err + } + + cachedBuildResult, versionResolvedImage, err := resolveUpgradeBuildTarget(upgradeCtx, resolvedConfig, selectedVersion) + if err != nil { + return nil, err + } + + customBinarySymlinkPath, err = resolveUpgradeLocalBinaryPath( + upgradeCtx, + resolvedConfig, + cachedBuildResult, + customBinarySymlinkPath, + ) + if err != nil { + return nil, err + } + + targetBinary, targetImage := buildUpgradeTargets(customBinarySymlinkPath, versionResolvedImage) + + return &upgradeBinaryResolution{ + selectedVersion: selectedVersion, + selectedName: selectedName, + customBinarySymlinkPath: customBinarySymlinkPath, + cachedBuildResult: cachedBuildResult, + versionResolvedImage: versionResolvedImage, + targetBinary: targetBinary, + targetImage: targetImage, + }, nil +} + +func shouldRunUpgradeInteractiveSelection(jsonMode bool) bool { + return !upgradeNoInteractive && !jsonMode && upgradeImage == "" && upgradeBinary == "" +} + +func resolveUpgradeName(selection *interactive.SelectionConfig) string { + if upgradeName != "" { + return upgradeName + } + if selection.UpgradeName != "" { + return selection.UpgradeName + } + + versionForName := selection.StartVersion + if selection.StartIsCustomRef && strings.Contains(versionForName, "/") { + parts := strings.Split(versionForName, "/") + versionForName = parts[len(parts)-1] + } + return versionForName + "-upgrade" +} + +func validateUpgradeSourceInputs(selectedVersion, selectedName string) error { if upgradeBinary != "" { return fmt.Errorf(`the --binary flag has been removed in favor of interactive binary selection @@ -366,195 +485,242 @@ Migration guide: For more information, see: https://github.com/altuslabsxyz/devnet-builder/blob/main/docs/MIGRATION.md`) } - // Validate that we have either image or version to build (binary flag removed) if upgradeImage == "" && selectedVersion == "" { return fmt.Errorf("either --image or --version must be provided (or use interactive mode)") } - // Validate that name is provided (not required for --skip-gov mode) if selectedName == "" && !skipGovernance { return fmt.Errorf("upgrade name is required (--name or interactive mode)") } - // Mode-aware version resolution - var cachedBuildResult *dto.BuildOutput - var versionResolvedImage string + return nil +} - if selectedVersion != "" && upgradeImage == "" && upgradeBinary == "" { - if resolvedMode == UpgradeModeDocker && isStandardVersionTag(selectedVersion) { - // Docker mode with standard version tag: resolve to docker image - dockerImage := networkModule.DockerImage() - versionResolvedImage = fmt.Sprintf("%s:%s", dockerImage, selectedVersion) - logger.Info("Using docker image for version %s: %s", selectedVersion, versionResolvedImage) - } else { - // Local mode or custom ref: build local binary to cache using DI container - buildResult, err := buildBinaryForUpgrade(ctx, cleanMetadata.BlockchainNetwork, selectedVersion, cleanMetadata.NetworkName, homeDir, logger) - if err != nil { - return fmt.Errorf("failed to pre-build binary: %w", err) - } - cachedBuildResult = buildResult - commitShort := buildResult.CommitHash - if len(commitShort) > 12 { - commitShort = commitShort[:12] - } - logger.Success("Binary pre-built and cached (commit: %s)", commitShort) +func resolveUpgradeBuildTarget( + upgradeCtx *upgradeContext, + resolvedConfig *upgradeResolvedConfig, + selectedVersion string, +) (*dto.BuildOutput, string, error) { + if selectedVersion == "" || upgradeImage != "" || upgradeBinary != "" { + return nil, "", nil + } + + if resolvedConfig.resolvedMode == UpgradeModeDocker && isStandardVersionTag(selectedVersion) { + dockerImage := resolvedConfig.networkModule.DockerImage() + versionResolvedImage := fmt.Sprintf("%s:%s", dockerImage, selectedVersion) + upgradeCtx.logger.Info("Using docker image for version %s: %s", selectedVersion, versionResolvedImage) + return nil, versionResolvedImage, nil + } + + buildResult, err := buildBinaryForUpgrade( + upgradeCtx.ctx, + resolvedConfig.metadata.BlockchainNetwork, + selectedVersion, + resolvedConfig.metadata.NetworkName, + upgradeCtx.homeDir, + upgradeCtx.logger, + ) + if err != nil { + return nil, "", fmt.Errorf("failed to pre-build binary: %w", err) + } + + commitShort := buildResult.CommitHash + if len(commitShort) > 12 { + commitShort = commitShort[:12] + } + upgradeCtx.logger.Success("Binary pre-built and cached (commit: %s)", commitShort) + + return buildResult, "", nil +} + +func resolveUpgradeLocalBinaryPath( + upgradeCtx *upgradeContext, + resolvedConfig *upgradeResolvedConfig, + cachedBuildResult *dto.BuildOutput, + customBinarySymlinkPath string, +) (string, error) { + if resolvedConfig.resolvedMode != UpgradeModeLocal { + return customBinarySymlinkPath, nil + } + + if cachedBuildResult != nil { + upgradeCtx.logger.Debug("Using pre-built binary from custom ref: %s", cachedBuildResult.BinaryPath) + return customBinarySymlinkPath, nil + } + + if customBinarySymlinkPath == "" { + selectedPath, err := selectBinaryForUpgrade( + upgradeCtx.ctx, + resolvedConfig.metadata.NetworkName, + resolvedConfig.metadata.BlockchainNetwork, + upgradeCtx.homeDir, + upgradeCtx.logger, + ) + if err != nil { + return "", err + } + + if selectedPath == "" { + cacheDir := paths.BinaryCachePath(upgradeCtx.homeDir) + return "", fmt.Errorf("no cached binaries found for upgrade\nCache directory: %s\nUse --binary flag to specify a binary, or deploy/build a binary first", cacheDir) } + + return selectedPath, nil + } + + upgradeCtx.logger.Success("Using selected binary: %s", customBinarySymlinkPath) + return customBinarySymlinkPath, nil +} + +func buildUpgradeTargets(customBinarySymlinkPath, versionResolvedImage string) (string, string) { + targetBinary := customBinarySymlinkPath + if targetBinary == "" && upgradeBinary != "" { + targetBinary = upgradeBinary } + targetImage := upgradeImage + if versionResolvedImage != "" { + targetImage = versionResolvedImage + } + return targetBinary, targetImage +} - // Get governance parameters (skip if --skip-gov is set) +func resolveGovernanceParams( + upgradeCtx *upgradeContext, + resolvedConfig *upgradeResolvedConfig, +) (*upgradeGovernanceResolution, error) { var govParams *ports.GovParams - var vp time.Duration + var votingPeriodDuration time.Duration if skipGovernance { - // Skip governance mode - show warning - if !jsonMode { - logger.Warn("Skipping governance proposal (--skip-gov mode)") - logger.Warn("This will directly replace the binary WITHOUT governance upgrade.") - logger.Warn("Chain state must be compatible with the new version.") + if !upgradeCtx.jsonMode { + upgradeCtx.logger.Warn("Skipping governance proposal (--skip-gov mode)") + upgradeCtx.logger.Warn("This will directly replace the binary WITHOUT governance upgrade.") + upgradeCtx.logger.Warn("Chain state must be compatible with the new version.") fmt.Println() } - } else if forceVotingPeriod { - // User explicitly wants to override with CLI value - logger.Info("Using forced voting period from --voting-period flag...") - parsedVP, parseErr := time.ParseDuration(votingPeriod) - if parseErr != nil { - return fmt.Errorf("invalid voting period: %w", parseErr) - } - vp = parsedVP - logger.Info("Forced expedited voting period: %s", vp) - } else { - // Query from chain (plugin or REST) - logger.Info("Fetching governance parameters from chain...") - rpcHost := "localhost" - rpcPort := 26657 - tempFactory := di.NewInfrastructureFactory(homeDir, logger). - WithNetworkModule(networkModule) - rpcClient := tempFactory.CreateRPCClient(rpcHost, rpcPort) - - // Configure plugin delegation for governance parameter queries - // Type assert to check if network module supports governance parameter queries - if cosmosClient, ok := rpcClient.(*infrarpc.CosmosRPCClient); ok { - // Check if network module implements GetGovernanceParams (optional interface) - if pluginModule, ok := networkModule.(infrarpc.NetworkPluginModule); ok { - rpcClient = cosmosClient.WithPlugin(pluginModule, cleanMetadata.NetworkName) - } - // If plugin doesn't implement GetGovernanceParams, will fall back to REST API - } + return &upgradeGovernanceResolution{ + govParams: nil, + votingPeriod: 0, + }, nil + } - var err error - govParams, err = rpcClient.GetGovParams(ctx) + if forceVotingPeriod { + upgradeCtx.logger.Info("Using forced voting period from --voting-period flag...") + parsedVotingPeriod, err := time.ParseDuration(votingPeriod) if err != nil { - logger.Debug("Failed to fetch gov params, using CLI flag value: %v", err) - // Fallback to CLI flag if chain query fails - parsedVP, parseErr := time.ParseDuration(votingPeriod) - if parseErr != nil { - return fmt.Errorf("invalid voting period: %w", parseErr) - } - govParams = &ports.GovParams{ - ExpeditedVotingPeriod: parsedVP, - } + return nil, fmt.Errorf("invalid voting period: %w", err) } + upgradeCtx.logger.Info("Forced expedited voting period: %s", parsedVotingPeriod) + return &upgradeGovernanceResolution{ + govParams: nil, + votingPeriod: parsedVotingPeriod, + }, nil + } - // Use expedited voting period from chain - vp = govParams.ExpeditedVotingPeriod - logger.Info("Using expedited voting period: %s", vp) - } - - // Binary resolution for local mode upgrades (--binary flag removed) - // Priority: pre-built binary (cachedBuildResult) > unified selection > cached binary selection > error - if resolvedMode == UpgradeModeLocal { - // Check if binary was already built from custom ref - // If cachedBuildResult is set, the binary was just pre-built - skip selection - if cachedBuildResult != nil { - // Binary was pre-built from custom ref (e.g., feat/gas-waiver) - use it directly - logger.Debug("Using pre-built binary from custom ref: %s", cachedBuildResult.BinaryPath) - } else if customBinarySymlinkPath == "" { - // No binary selected yet - fall back to cache selection (for non-interactive mode or GitHub release flow) - // Priority 2: Interactive/Auto selection from cache (US1) - // This is only for local mode; docker mode uses images - selectedPath, err := selectBinaryForUpgrade(ctx, cleanMetadata.NetworkName, cleanMetadata.BlockchainNetwork, homeDir, logger) - if err != nil { - // Error contains detailed validation failure info - return err - } + upgradeCtx.logger.Info("Fetching governance parameters from chain...") + rpcHost := "localhost" + rpcPort := 26657 - if selectedPath == "" { - // No cached binaries available at all - cacheDir := paths.BinaryCachePath(homeDir) - return fmt.Errorf("no cached binaries found for upgrade\nCache directory: %s\nUse --binary flag to specify a binary, or deploy/build a binary first", cacheDir) - } + tempFactory := di.NewInfrastructureFactory(upgradeCtx.homeDir, upgradeCtx.logger). + WithNetworkModule(resolvedConfig.networkModule) + rpcClient := tempFactory.CreateRPCClient(rpcHost, rpcPort) - customBinarySymlinkPath = selectedPath - } else { - // customBinarySymlinkPath already set from unified selection - use it directly - logger.Success("Using selected binary: %s", customBinarySymlinkPath) + if cosmosClient, ok := rpcClient.(*infrarpc.CosmosRPCClient); ok { + if pluginModule, ok := resolvedConfig.networkModule.(infrarpc.NetworkPluginModule); ok { + rpcClient = cosmosClient.WithPlugin(pluginModule, resolvedConfig.metadata.NetworkName) } } - // Determine target binary/image - targetBinary := customBinarySymlinkPath // Use selected/imported binary if available - if targetBinary == "" && upgradeBinary != "" { - targetBinary = upgradeBinary // Fallback to raw path (should not happen with import) - } - targetImage := upgradeImage - if versionResolvedImage != "" { - targetImage = versionResolvedImage + var err error + govParams, err = rpcClient.GetGovParams(upgradeCtx.ctx) + if err != nil { + upgradeCtx.logger.Debug("Failed to fetch gov params, using CLI flag value: %v", err) + parsedVotingPeriod, parseErr := time.ParseDuration(votingPeriod) + if parseErr != nil { + return nil, fmt.Errorf("invalid voting period: %w", parseErr) + } + govParams = &ports.GovParams{ + ExpeditedVotingPeriod: parsedVotingPeriod, + } } - // Print upgrade plan (non-JSON mode) - if !jsonMode { + votingPeriodDuration = govParams.ExpeditedVotingPeriod + upgradeCtx.logger.Info("Using expedited voting period: %s", votingPeriodDuration) + + return &upgradeGovernanceResolution{ + govParams: govParams, + votingPeriod: votingPeriodDuration, + }, nil +} + +func executeUpgrade( + upgradeCtx *upgradeContext, + resolvedConfig *upgradeResolvedConfig, + binaryResolution *upgradeBinaryResolution, + governanceResolution *upgradeGovernanceResolution, +) (*upgradeExecutionResult, error) { + if !upgradeCtx.jsonMode { if skipGovernance { - printSkipGovPlan(string(resolvedMode), targetImage, targetBinary, cachedBuildResult, cleanMetadata) + printSkipGovPlan( + string(resolvedConfig.resolvedMode), + binaryResolution.targetImage, + binaryResolution.targetBinary, + binaryResolution.cachedBuildResult, + resolvedConfig.metadata, + ) } else { - printUpgradePlan(selectedName, string(resolvedMode), targetImage, targetBinary, cachedBuildResult, vp, cleanMetadata) + printUpgradePlan( + binaryResolution.selectedName, + string(resolvedConfig.resolvedMode), + binaryResolution.targetImage, + binaryResolution.targetBinary, + binaryResolution.cachedBuildResult, + governanceResolution.votingPeriod, + resolvedConfig.metadata, + ) } } - // Create DI container for upgrade - factory := di.NewInfrastructureFactory(homeDir, logger). - WithNetworkModule(networkModule). - WithDockerMode(resolvedMode == UpgradeModeDocker) + factory := di.NewInfrastructureFactory(upgradeCtx.homeDir, upgradeCtx.logger). + WithNetworkModule(resolvedConfig.networkModule). + WithDockerMode(resolvedConfig.resolvedMode == UpgradeModeDocker) container, err := factory.WireContainer() if err != nil { - return outputUpgradeError(fmt.Errorf("failed to initialize: %w", err)) + return nil, outputUpgradeError(fmt.Errorf("failed to initialize: %w", err)) } - // Check for existing upgrade state (handles --resume and --force-restart) - resumeState, err := checkForExistingUpgradeState(ctx, homeDir, logger, jsonMode) + resumeState, err := checkForExistingUpgradeState(upgradeCtx.ctx, upgradeCtx.homeDir, upgradeCtx.logger, upgradeCtx.jsonMode) if err != nil { - if jsonMode { - return outputUpgradeError(err) + if upgradeCtx.jsonMode { + return nil, outputUpgradeError(err) } - return err + return nil, err } - // Build ExecuteUpgradeInput input := dto.ExecuteUpgradeInput{ - HomeDir: homeDir, - UpgradeName: selectedName, - TargetBinary: targetBinary, - TargetImage: targetImage, - TargetVersion: selectedVersion, - VotingPeriod: vp, + HomeDir: upgradeCtx.homeDir, + UpgradeName: binaryResolution.selectedName, + TargetBinary: binaryResolution.targetBinary, + TargetImage: binaryResolution.targetImage, + TargetVersion: binaryResolution.selectedVersion, + VotingPeriod: governanceResolution.votingPeriod, HeightBuffer: heightBuffer, - UpgradeHeight: 0, // Always auto-calculate + UpgradeHeight: 0, WithExport: withExport, GenesisDir: genesisDir, - Mode: types.ExecutionMode(resolvedMode), + Mode: types.ExecutionMode(resolvedConfig.resolvedMode), SkipGovernance: skipGovernance, } - // If we have a cached binary, use cache mode for atomic symlink switch - if cachedBuildResult != nil { - input.CachePath = cachedBuildResult.BinaryPath - input.CommitHash = cachedBuildResult.CommitHash // Deprecated, kept for compatibility - input.CacheRef = cachedBuildResult.CacheRef // Use CacheRef for SetActive - input.TargetBinary = "" // Clear since we're using cache + if binaryResolution.cachedBuildResult != nil { + input.CachePath = binaryResolution.cachedBuildResult.BinaryPath + input.CommitHash = binaryResolution.cachedBuildResult.CommitHash + input.CacheRef = binaryResolution.cachedBuildResult.CacheRef + input.TargetBinary = "" } - // Execute the upgrade using the ResumableExecuteUpgradeUseCase - if !jsonMode { + if !upgradeCtx.jsonMode { if resumeState != nil { fmt.Printf("[1/6] %s (resuming from %s)\n", color.CyanString("Verifying devnet status..."), resumeState.Stage) } else { @@ -562,28 +728,32 @@ For more information, see: https://github.com/altuslabsxyz/devnet-builder/blob/m } } - result, err := container.ResumableExecuteUpgradeUseCase().Execute(ctx, input, resumeState) + result, err := container.ResumableExecuteUpgradeUseCase().Execute(upgradeCtx.ctx, input, resumeState) if err != nil { - if jsonMode { - return outputUpgradeError(err) + if upgradeCtx.jsonMode { + return nil, outputUpgradeError(err) } - return err + return nil, err } - // Update metadata with new version if upgrade was successful if result.Success { - cleanMetadata.CurrentVersion = selectedVersion - cleanMetadata.ExecutionMode = types.ExecutionMode(resolvedMode) - if err := svc.SaveMetadata(ctx, cleanMetadata); err != nil { - logger.Warn("Failed to update metadata: %v", err) + resolvedConfig.metadata.CurrentVersion = binaryResolution.selectedVersion + resolvedConfig.metadata.ExecutionMode = types.ExecutionMode(resolvedConfig.resolvedMode) + if err := resolvedConfig.svc.SaveMetadata(upgradeCtx.ctx, resolvedConfig.metadata); err != nil { + upgradeCtx.logger.Warn("Failed to update metadata: %v", err) } } - // Output result - if jsonMode { - return outputUpgradeJSON(result) + return &upgradeExecutionResult{ + result: result, + }, nil +} + +func reportResults(upgradeCtx *upgradeContext, executionResult *upgradeExecutionResult) error { + if upgradeCtx.jsonMode { + return outputUpgradeJSON(executionResult.result) } - return outputUpgradeText(result) + return outputUpgradeText(executionResult.result) } // selectBinaryForUpgrade orchestrates binary selection from cache for upgrade command. diff --git a/cmd/devnet-builder/commands/manage/upgrade_refactor_test.go b/cmd/devnet-builder/commands/manage/upgrade_refactor_test.go new file mode 100644 index 00000000..7e8e51c7 --- /dev/null +++ b/cmd/devnet-builder/commands/manage/upgrade_refactor_test.go @@ -0,0 +1,178 @@ +package manage + +import ( + "strings" + "testing" + + "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/interactive" + "github.com/altuslabsxyz/devnet-builder/types" +) + +type upgradeFlagSnapshot struct { + upgradeName string + upgradeImage string + upgradeBinary string + upgradeMode string + upgradeNoInteractive bool + upgradeVersion string + skipGovernance bool +} + +func snapshotUpgradeFlags() upgradeFlagSnapshot { + return upgradeFlagSnapshot{ + upgradeName: upgradeName, + upgradeImage: upgradeImage, + upgradeBinary: upgradeBinary, + upgradeMode: upgradeMode, + upgradeNoInteractive: upgradeNoInteractive, + upgradeVersion: upgradeVersion, + skipGovernance: skipGovernance, + } +} + +func restoreUpgradeFlags(s upgradeFlagSnapshot) { + upgradeName = s.upgradeName + upgradeImage = s.upgradeImage + upgradeBinary = s.upgradeBinary + upgradeMode = s.upgradeMode + upgradeNoInteractive = s.upgradeNoInteractive + upgradeVersion = s.upgradeVersion + skipGovernance = s.skipGovernance +} + +func TestResolveUpgradeExecutionMode(t *testing.T) { + snapshot := snapshotUpgradeFlags() + t.Cleanup(func() { restoreUpgradeFlags(snapshot) }) + + upgradeMode = "" + mode, explicit, err := resolveUpgradeExecutionMode(types.ExecutionModeLocal) + if err != nil { + t.Fatalf("resolveUpgradeExecutionMode returned error: %v", err) + } + if mode != UpgradeModeLocal { + t.Fatalf("expected mode %q, got %q", UpgradeModeLocal, mode) + } + if explicit { + t.Fatalf("expected explicit=false, got true") + } + + upgradeMode = "docker" + mode, explicit, err = resolveUpgradeExecutionMode(types.ExecutionModeLocal) + if err != nil { + t.Fatalf("resolveUpgradeExecutionMode returned error: %v", err) + } + if mode != UpgradeModeDocker { + t.Fatalf("expected mode %q, got %q", UpgradeModeDocker, mode) + } + if !explicit { + t.Fatalf("expected explicit=true, got false") + } + + upgradeMode = "invalid" + _, _, err = resolveUpgradeExecutionMode(types.ExecutionModeLocal) + if err == nil { + t.Fatalf("expected error for invalid mode") + } +} + +func TestShouldRunUpgradeInteractiveSelection(t *testing.T) { + snapshot := snapshotUpgradeFlags() + t.Cleanup(func() { restoreUpgradeFlags(snapshot) }) + + upgradeNoInteractive = false + upgradeImage = "" + upgradeBinary = "" + if !shouldRunUpgradeInteractiveSelection(false) { + t.Fatalf("expected interactive selection to run") + } + + if shouldRunUpgradeInteractiveSelection(true) { + t.Fatalf("expected interactive selection to be disabled in json mode") + } + + upgradeNoInteractive = true + if shouldRunUpgradeInteractiveSelection(false) { + t.Fatalf("expected interactive selection to be disabled with --no-interactive") + } +} + +func TestResolveUpgradeName(t *testing.T) { + snapshot := snapshotUpgradeFlags() + t.Cleanup(func() { restoreUpgradeFlags(snapshot) }) + + selection := &interactive.SelectionConfig{ + StartVersion: "feat/gas-waiver", + StartIsCustomRef: true, + UpgradeName: "prompt-name", + } + + upgradeName = "flag-name" + if got := resolveUpgradeName(selection); got != "flag-name" { + t.Fatalf("expected flag name, got %q", got) + } + + upgradeName = "" + if got := resolveUpgradeName(selection); got != "prompt-name" { + t.Fatalf("expected prompt name, got %q", got) + } + + selection.UpgradeName = "" + if got := resolveUpgradeName(selection); got != "gas-waiver-upgrade" { + t.Fatalf("expected derived name, got %q", got) + } +} + +func TestValidateUpgradeSourceInputs(t *testing.T) { + snapshot := snapshotUpgradeFlags() + t.Cleanup(func() { restoreUpgradeFlags(snapshot) }) + + upgradeBinary = "/tmp/custom" + if err := validateUpgradeSourceInputs("v1.0.0", "upgrade-1"); err == nil { + t.Fatalf("expected deprecated binary flag error") + } + + upgradeBinary = "" + upgradeImage = "" + skipGovernance = false + if err := validateUpgradeSourceInputs("", "upgrade-1"); err == nil { + t.Fatalf("expected source validation error") + } + + if err := validateUpgradeSourceInputs("v1.0.0", ""); err == nil { + t.Fatalf("expected name validation error") + } + + skipGovernance = true + if err := validateUpgradeSourceInputs("v1.0.0", ""); err != nil { + t.Fatalf("expected skip-gov to allow empty name, got error: %v", err) + } +} + +func TestBuildUpgradeTargets(t *testing.T) { + snapshot := snapshotUpgradeFlags() + t.Cleanup(func() { restoreUpgradeFlags(snapshot) }) + + upgradeBinary = "" + upgradeImage = "img:old" + binary, image := buildUpgradeTargets("/tmp/selected", "") + if binary != "/tmp/selected" { + t.Fatalf("expected selected binary path, got %q", binary) + } + if image != "img:old" { + t.Fatalf("expected original image, got %q", image) + } + + binary, image = buildUpgradeTargets("", "img:resolved") + if binary != "" { + t.Fatalf("expected empty binary, got %q", binary) + } + if image != "img:resolved" { + t.Fatalf("expected resolved image, got %q", image) + } + + upgradeBinary = "/tmp/deprecated" + binary, _ = buildUpgradeTargets("", "") + if !strings.Contains(binary, "/tmp/deprecated") { + t.Fatalf("expected fallback to raw binary path, got %q", binary) + } +} diff --git a/scripts/check-manage-handler-complexity.sh b/scripts/check-manage-handler-complexity.sh new file mode 100755 index 00000000..d2d8909d --- /dev/null +++ b/scripts/check-manage-handler-complexity.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +upgrade_file="cmd/devnet-builder/commands/manage/upgrade.go" +deploy_file="cmd/devnet-builder/commands/manage/deploy.go" +cc_limit=20 +loc_limit=149 + +if ! command -v gocyclo >/dev/null 2>&1; then + echo "gocyclo is required but not found in PATH" >&2 + exit 1 +fi + +cc_output="$(gocyclo "$upgrade_file" "$deploy_file")" + +extract_cc() { + local fn="$1" + echo "$cc_output" | awk -v fn="$fn" '$3==fn {print $1; exit}' +} + +cc_run_upgrade="$(extract_cc runUpgrade)" +cc_run_deploy="$(extract_cc runDeploy)" + +if [[ -z "$cc_run_upgrade" || -z "$cc_run_deploy" ]]; then + echo "failed to locate runUpgrade/runDeploy in gocyclo output" >&2 + echo "$cc_output" >&2 + exit 1 +fi + +tmp_base="$(mktemp /tmp/manage-func-loc-XXXXXX)" +tmp_go="${tmp_base}.go" +mv "$tmp_base" "$tmp_go" +cat > "$tmp_go" <<'GOEOF' +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("usage: loc ...") + os.Exit(1) + } + + var paths []string + for _, arg := range os.Args[1:] { + if arg == "--" { + continue + } + paths = append(paths, arg) + } + + if len(paths) == 0 { + fmt.Println("no files provided") + os.Exit(1) + } + + fset := token.NewFileSet() + loc := map[string]int{} + + for _, path := range paths { + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "parse error %s: %v\n", path, err) + os.Exit(1) + } + for _, d := range file.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + if fd.Name.Name != "runUpgrade" && fd.Name.Name != "runDeploy" { + continue + } + start := fset.Position(fd.Pos()).Line + end := fset.Position(fd.End()).Line + loc[fd.Name.Name] = end - start + 1 + } + } + + fmt.Printf("runUpgrade=%d\n", loc["runUpgrade"]) + fmt.Printf("runDeploy=%d\n", loc["runDeploy"]) +} +GOEOF + +loc_output="$(go run "$tmp_go" -- "$upgrade_file" "$deploy_file")" +rm -f "$tmp_go" + +extract_loc() { + local fn="$1" + echo "$loc_output" | awk -F= -v fn="$fn" '$1==fn {print $2; exit}' +} + +loc_run_upgrade="$(extract_loc runUpgrade)" +loc_run_deploy="$(extract_loc runDeploy)" + +if [[ -z "$loc_run_upgrade" || -z "$loc_run_deploy" ]]; then + echo "failed to locate runUpgrade/runDeploy LOC output" >&2 + echo "$loc_output" >&2 + exit 1 +fi + +printf 'runUpgrade: CC=%s LOC=%s\n' "$cc_run_upgrade" "$loc_run_upgrade" +printf 'runDeploy: CC=%s LOC=%s\n' "$cc_run_deploy" "$loc_run_deploy" + +if (( cc_run_upgrade > cc_limit )); then + echo "runUpgrade CC ${cc_run_upgrade} exceeds ${cc_limit}" >&2 + exit 1 +fi +if (( cc_run_deploy > cc_limit )); then + echo "runDeploy CC ${cc_run_deploy} exceeds ${cc_limit}" >&2 + exit 1 +fi +if (( loc_run_upgrade > loc_limit )); then + echo "runUpgrade LOC ${loc_run_upgrade} exceeds ${loc_limit}" >&2 + exit 1 +fi +if (( loc_run_deploy > loc_limit )); then + echo "runDeploy LOC ${loc_run_deploy} exceeds ${loc_limit}" >&2 + exit 1 +fi