diff --git a/engine/cld/legacy/cli/commands/state.go b/engine/cld/legacy/cli/commands/state.go index 82e7949b..3438c2da 100644 --- a/engine/cld/legacy/cli/commands/state.go +++ b/engine/cld/legacy/cli/commands/state.go @@ -1,100 +1,34 @@ package commands import ( - "context" - "fmt" - "os" - "time" - "github.com/spf13/cobra" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" - "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/commands/state" ) -// NewStateCmds creates a new set of commands for state environment. -func (c Commands) NewStateCmds(dom domain.Domain, config StateConfig) *cobra.Command { - stateCmd := &cobra.Command{ - Use: "state", - Short: "State commands", - } - stateCmd.AddCommand(c.newStateGenerate(dom, config)) - - stateCmd.PersistentFlags(). - StringP("environment", "e", "", "Deployment environment (required)") - _ = stateCmd.MarkPersistentFlagRequired("environment") - - return stateCmd -} - +// StateConfig holds configuration for state commands. +// Deprecated: Use commands.StateConfig directly for new integrations. type StateConfig struct { ViewState deployment.ViewStateV2 } -func (c Commands) newStateGenerate(dom domain.Domain, cfg StateConfig) *cobra.Command { - var ( - persist bool - outputPath string - prevStatePath string - ) - - cmd := cobra.Command{ - Use: "generate", - Short: "Generate latest state. Nodes must be present in the `nodes.json` to be included.", - RunE: func(cmd *cobra.Command, args []string) error { - envKey, _ := cmd.Flags().GetString("environment") - envdir := dom.EnvDir(envKey) - viewTimeout := 10 * time.Minute - - cmd.Printf("Generate latest state for %s in environment: %s\n", dom, envKey) - cmd.Printf("This command may take a while to complete, please be patient. Timeout set to %v\n", viewTimeout) - ctx, cancel := context.WithTimeout(cmd.Context(), viewTimeout) - defer cancel() - - env, err := environment.Load(ctx, dom, envKey, environment.WithLogger(c.lggr)) - if err != nil { - return fmt.Errorf("failed to load environment %w", err) - } - - prevState, err := envdir.LoadState() - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to load previous state: %w", err) - } - - state, err := cfg.ViewState(env, prevState) - if err != nil { - return fmt.Errorf("unable to snapshot state: %w", err) - } - - b, err := state.MarshalJSON() - if err != nil { - return fmt.Errorf("unable to marshal state: %w", err) - } - - if persist { - // Save the state to the outputPath if defined, otherwise save it with the default - // path in the product and environment directory with the default file name. - if outputPath != "" { - err = domain.SaveViewState(outputPath, state) - } else { - err = envdir.SaveViewState(state) - } - - if err != nil { - return fmt.Errorf("failed to save state: %w", err) - } - } - - cmd.Println(string(b)) - - return nil - }, - } - - cmd.Flags().BoolVarP(&persist, "persist", "p", false, "Persist state to disk") - cmd.Flags().StringVarP(&outputPath, "outputPath", "o", "", "Output path. Default is //state.json") - cmd.Flags().StringVarP(&prevStatePath, "previousState", "s", "", "Previous state's path. Default is //state.json") - - return &cmd +// NewStateCmds creates a new set of commands for state environment. +// This method delegates to the modular state package for backward compatibility. +// +// Deprecated: Use the modular commands package for new integrations: +// +// import "github.com/smartcontractkit/chainlink-deployments-framework/pkg/commands" +// +// cmds := commands.New(lggr) +// rootCmd.AddCommand(cmds.State(myDomain, commands.StateConfig{ +// ViewState: myViewStateFunc, +// })) +func (c Commands) NewStateCmds(dom domain.Domain, config StateConfig) *cobra.Command { + return state.NewCommand(state.Config{ + Logger: c.lggr, + Domain: dom, + ViewState: config.ViewState, + }) } diff --git a/engine/cld/legacy/cli/commands/state_test.go b/engine/cld/legacy/cli/commands/state_test.go index 302766cb..df633a82 100644 --- a/engine/cld/legacy/cli/commands/state_test.go +++ b/engine/cld/legacy/cli/commands/state_test.go @@ -6,11 +6,13 @@ import ( "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) func TestNewStateCmds_Structure(t *testing.T) { t.Parallel() - c := Commands{} + + c := NewCommands(logger.Nop()) dom := domain.NewDomain("/tmp", "foo") var cfg StateConfig root := c.NewStateCmds(dom, cfg) @@ -32,33 +34,45 @@ func TestNewStateCmds_Structure(t *testing.T) { func TestNewStateGenerateCmd_Metadata(t *testing.T) { t.Parallel() + + c := NewCommands(logger.Nop()) dom := domain.NewDomain("/tmp", "foo") var cfg StateConfig - cmd := (&Commands{}).newStateGenerate(dom, cfg) - - require.Equal(t, "generate", cmd.Use) - require.Contains(t, cmd.Short, "Generate latest state") - - // local flags - p := cmd.Flags().Lookup("persist") - require.NotNil(t, p) - require.Equal(t, "p", p.Shorthand) - require.Equal(t, "false", p.Value.String()) - - o := cmd.Flags().Lookup("outputPath") - require.NotNil(t, o) - require.Equal(t, "o", o.Shorthand) - require.Empty(t, o.Value.String()) - - s := cmd.Flags().Lookup("previousState") - require.NotNil(t, s) - require.Equal(t, "s", s.Shorthand) - require.Empty(t, s.Value.String()) + root := c.NewStateCmds(dom, cfg) + + // Find generate subcommand + var found bool + for _, sub := range root.Commands() { + if sub.Use == "generate" { + found = true + require.Contains(t, sub.Short, "Generate latest state") + + // local flags + p := sub.Flags().Lookup("persist") + require.NotNil(t, p) + require.Equal(t, "p", p.Shorthand) + require.Equal(t, "false", p.Value.String()) + + o := sub.Flags().Lookup("outputPath") + require.NotNil(t, o) + require.Equal(t, "o", o.Shorthand) + require.Empty(t, o.Value.String()) + + s := sub.Flags().Lookup("previousState") + require.NotNil(t, s) + require.Equal(t, "s", s.Shorthand) + require.Empty(t, s.Value.String()) + + break + } + } + require.True(t, found, "generate subcommand not found") } func TestStateGenerate_MissingEnvFails(t *testing.T) { t.Parallel() - c := Commands{} + + c := NewCommands(logger.Nop()) dom := domain.NewDomain("/tmp", "foo") var cfg StateConfig root := c.NewStateCmds(dom, cfg) @@ -67,6 +81,5 @@ func TestStateGenerate_MissingEnvFails(t *testing.T) { root.SetArgs([]string{"generate"}) err := root.Execute() - require.Error(t, err) - require.Contains(t, err.Error(), `required flag(s) "environment" not set`) + require.ErrorContains(t, err, `required flag(s) "environment" not set`) } diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go new file mode 100644 index 00000000..c5562f52 --- /dev/null +++ b/pkg/commands/commands.go @@ -0,0 +1,67 @@ +// Package commands provides modular CLI command packages for domain CLIs. +// +// There are two ways to use commands from this package: +// +// 1. Via the Commands factory (recommended for most use cases): +// +// commands := commands.New(lggr) +// app.AddCommand( +// commands.State(domain, stateConfig), +// commands.EVM(domain), +// commands.JD(domain), +// ) +// +// 2. Via direct package imports (for advanced DI/testing): +// +// import "github.com/smartcontractkit/chainlink-deployments-framework/pkg/commands/state" +// +// app.AddCommand(state.NewCommand(state.Config{ +// Logger: lggr, +// Domain: domain, +// ViewState: myViewState, +// Deps: &state.Deps{...}, // inject mocks for testing +// })) +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/commands/state" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// Commands provides a factory for creating CLI commands with shared configuration. +// This allows setting the logger once and reusing it across all commands. +type Commands struct { + lggr logger.Logger +} + +// New creates a new Commands factory with the given logger. +// The logger will be shared across all commands created by this factory. +func New(lggr logger.Logger) *Commands { + return &Commands{lggr: lggr} +} + +// StateConfig holds configuration for state commands. +type StateConfig struct { + // ViewState is the function that generates state from an environment. + // This is domain-specific and must be provided by the user. + ViewState state.ViewStateFunc +} + +// State creates the state command group for managing environment state. +// +// Usage: +// +// cmds := commands.New(lggr) +// rootCmd.AddCommand(cmds.State(domain, commands.StateConfig{ +// ViewState: myViewStateFunc, +// })) +func (c *Commands) State(dom domain.Domain, cfg StateConfig) *cobra.Command { + return state.NewCommand(state.Config{ + Logger: c.lggr, + Domain: dom, + ViewState: cfg.ViewState, + }) +} diff --git a/pkg/commands/commands_test.go b/pkg/commands/commands_test.go new file mode 100644 index 00000000..644b18f3 --- /dev/null +++ b/pkg/commands/commands_test.go @@ -0,0 +1,51 @@ +package commands + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestNew(t *testing.T) { + t.Parallel() + + lggr := logger.Nop() + cmds := New(lggr) + + require.NotNil(t, cmds) + assert.Equal(t, lggr, cmds.lggr) +} + +func TestCommands_State(t *testing.T) { + t.Parallel() + + lggr := logger.Nop() + cmds := New(lggr) + dom := domain.NewDomain("/tmp", "testdomain") + + cmd := cmds.State(dom, StateConfig{ + ViewState: func(_ fdeployment.Environment, _ json.Marshaler) (json.Marshaler, error) { + return json.RawMessage(`{}`), nil + }, + }) + + require.NotNil(t, cmd) + assert.Equal(t, "state", cmd.Use) + assert.Equal(t, "State commands", cmd.Short) + + // Verify environment flag is present + envFlag := cmd.PersistentFlags().Lookup("environment") + require.NotNil(t, envFlag) + assert.Equal(t, "e", envFlag.Shorthand) + + // Verify generate subcommand exists + subs := cmd.Commands() + require.Len(t, subs, 1) + assert.Equal(t, "generate", subs[0].Use) +} diff --git a/pkg/commands/state/command.go b/pkg/commands/state/command.go new file mode 100644 index 00000000..1a904ef6 --- /dev/null +++ b/pkg/commands/state/command.go @@ -0,0 +1,64 @@ +package state + +import ( + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// Config holds the configuration for state commands. +type Config struct { + // Logger is the logger to use for command output. Required. + Logger logger.Logger + + // Domain is the domain context for the commands. Required. + Domain domain.Domain + + // ViewState is the function that generates state from an environment. + // This is domain-specific and must be provided by the user. + ViewState ViewStateFunc + + // Deps holds optional dependencies that can be overridden. + // If fields are nil, production defaults are used. + Deps Deps +} + +// deps returns the Deps with defaults applied. +func (c *Config) deps() *Deps { + c.Deps.applyDefaults() + + return &c.Deps +} + +// NewCommand creates a new state command with all subcommands. +// The command requires an environment flag (-e) which is used by all subcommands. +// +// Usage: +// +// rootCmd.AddCommand(state.NewCommand(state.Config{ +// Logger: lggr, +// Domain: myDomain, +// ViewState: myViewStateFunc, +// })) +func NewCommand(cfg Config) *cobra.Command { + // Apply defaults for optional dependencies + cfg.deps() + + cmd := &cobra.Command{ + Use: "state", + Short: "State commands", + } + + // Add subcommands + cmd.AddCommand(newGenerateCmd(cfg)) + + // The environment flag is persistent because all subcommands require it. + // Currently there's only "generate", but this pattern supports future subcommands + // that also need the environment context. + cmd.PersistentFlags(). + StringP("environment", "e", "", "Deployment environment (required)") + _ = cmd.MarkPersistentFlagRequired("environment") + + return cmd +} diff --git a/pkg/commands/state/command_test.go b/pkg/commands/state/command_test.go new file mode 100644 index 00000000..056285da --- /dev/null +++ b/pkg/commands/state/command_test.go @@ -0,0 +1,530 @@ +package state + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// mockState implements json.Marshaler for testing. +type mockState struct { + Data map[string]any `json:"data"` +} + +func (m *mockState) MarshalJSON() ([]byte, error) { + return json.Marshal(m.Data) +} + +func (m *mockState) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &m.Data) +} + +// TestNewCommand_Structure verifies the command structure is correct. +func TestNewCommand_Structure(t *testing.T) { + t.Parallel() + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + }) + + // Verify root command + assert.Equal(t, "state", cmd.Use) + assert.Equal(t, "State commands", cmd.Short) + + // Verify environment flag is persistent + envFlag := cmd.PersistentFlags().Lookup("environment") + require.NotNil(t, envFlag) + assert.Equal(t, "e", envFlag.Shorthand) + + // Verify subcommands + subs := cmd.Commands() + require.Len(t, subs, 1) + assert.Equal(t, "generate", subs[0].Use) +} + +// TestNewCommand_GenerateFlags verifies the generate subcommand has correct flags. +func TestNewCommand_GenerateFlags(t *testing.T) { + t.Parallel() + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + }) + + // Find the generate subcommand + var found bool + for _, sub := range cmd.Commands() { + if sub.Use == "generate" { + found = true + // Check local flags + p := sub.Flags().Lookup("persist") + require.NotNil(t, p) + assert.Equal(t, "p", p.Shorthand) + assert.Equal(t, "false", p.Value.String()) + + o := sub.Flags().Lookup("outputPath") + require.NotNil(t, o) + assert.Equal(t, "o", o.Shorthand) + assert.Empty(t, o.Value.String()) + + s := sub.Flags().Lookup("previousState") + require.NotNil(t, s) + assert.Equal(t, "s", s.Shorthand) + assert.Empty(t, s.Value.String()) + + break + } + } + require.True(t, found, "generate subcommand not found") +} + +// TestGenerate_MissingEnvironmentFlagFails verifies required flag validation. +func TestGenerate_MissingEnvironmentFlagFails(t *testing.T) { + t.Parallel() + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + }) + + // Execute without --environment flag + cmd.SetArgs([]string{"generate"}) + err := cmd.Execute() + + require.ErrorContains(t, err, `required flag(s) "environment" not set`) +} + +// TestGenerate_Success verifies successful state generation with mocks. +func TestGenerate_Success(t *testing.T) { + t.Parallel() + + expectedState := &mockState{ + Data: map[string]any{ + "contracts": map[string]any{ + "router": "0x1234567890abcdef", + }, + "version": "1.0.0", + }, + } + + // Track what was called + var environmentLoaderCalled bool + var stateLoaderCalled bool + var viewStateCalled bool + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + viewStateCalled = true + assert.Equal(t, "staging", env.Name) + + return expectedState, nil + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + environmentLoaderCalled = true + assert.Equal(t, "staging", envKey) + + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + stateLoaderCalled = true + // Return "file not found" to simulate no previous state + return nil, os.ErrNotExist + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.True(t, environmentLoaderCalled, "environment loader should be called") + assert.True(t, stateLoaderCalled, "state loader should be called") + assert.True(t, viewStateCalled, "view state should be called") + + // Verify output contains expected state data + output := out.String() + assert.Contains(t, output, "router") + assert.Contains(t, output, "0x1234567890abcdef") +} + +// TestGenerate_WithPreviousState verifies state generation with existing previous state. +func TestGenerate_WithPreviousState(t *testing.T) { + t.Parallel() + + prevState := &mockState{ + Data: map[string]any{"existing": "data"}, + } + newState := &mockState{ + Data: map[string]any{"existing": "data", "new": "value"}, + } + + var receivedPrevState json.Marshaler + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + receivedPrevState = prev + return newState, nil + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return prevState, nil + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "mainnet"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.NotNil(t, receivedPrevState, "previous state should be passed to ViewState") +} + +// TestGenerate_WithPersist verifies state is saved when --persist flag is set. +func TestGenerate_WithPersist(t *testing.T) { + t.Parallel() + + expectedState := &mockState{ + Data: map[string]any{"key": "value"}, + } + + var savedState json.Marshaler + var savedOutputPath string + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return expectedState, nil + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + StateSaver: func(envdir domain.EnvDir, outputPath string, state json.Marshaler) error { + savedState = state + savedOutputPath = outputPath + + return nil + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging", "--persist"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, expectedState, savedState, "state should be saved") + assert.Empty(t, savedOutputPath, "default output path should be empty") +} + +// TestGenerate_WithCustomOutputPath verifies custom output path is used. +func TestGenerate_WithCustomOutputPath(t *testing.T) { + t.Parallel() + + expectedOutputPath := "/custom/path/state.json" + var savedOutputPath string + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + StateSaver: func(envdir domain.EnvDir, outputPath string, state json.Marshaler) error { + savedOutputPath = outputPath + return nil + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging", "-p", "-o", expectedOutputPath}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, expectedOutputPath, savedOutputPath, "custom output path should be used") +} + +// TestGenerate_EnvironmentLoadError verifies error handling for environment load failures. +func TestGenerate_EnvironmentLoadError(t *testing.T) { + t.Parallel() + + expectedError := errors.New("failed to connect to RPC") + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(_ fdeployment.Environment, _ json.Marshaler) (json.Marshaler, error) { + t.Fatal("ViewState should not be called on environment load error") + + return json.RawMessage(`{}`), nil + }, + Deps: Deps{ + EnvironmentLoader: func(_ context.Context, _ domain.Domain, _ string, _ ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{}, expectedError + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load environment") + assert.Contains(t, err.Error(), expectedError.Error()) +} + +// TestGenerate_ViewStateError verifies error handling for ViewState failures. +func TestGenerate_ViewStateError(t *testing.T) { + t.Parallel() + + expectedError := errors.New("contract read failed") + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return nil, expectedError + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to snapshot state") + assert.Contains(t, err.Error(), expectedError.Error()) +} + +// TestGenerate_StateSaveError verifies error handling for state save failures. +func TestGenerate_StateSaveError(t *testing.T) { + t.Parallel() + + expectedError := errors.New("permission denied") + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + StateSaver: func(envdir domain.EnvDir, outputPath string, state json.Marshaler) error { + return expectedError + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging", "--persist"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to save state") + assert.Contains(t, err.Error(), expectedError.Error()) +} + +// TestGenerate_NilViewStateFails verifies error when ViewState is not provided. +func TestGenerate_NilViewStateFails(t *testing.T) { + t.Parallel() + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: nil, // Not provided + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "ViewState function is required but not provided") +} + +// TestGenerate_StateLoadErrorNonNotExist verifies error handling for non-NotExist state load errors. +func TestGenerate_StateLoadErrorNonNotExist(t *testing.T) { + t.Parallel() + + expectedError := errors.New("corrupted state file") + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(_ fdeployment.Environment, _ json.Marshaler) (json.Marshaler, error) { + t.Fatal("ViewState should not be called on state load error") + + return json.RawMessage(`{}`), nil + }, + Deps: Deps{ + EnvironmentLoader: func(_ context.Context, _ domain.Domain, envKey string, _ ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(_ domain.EnvDir) (domain.JSONSerializer, error) { + return nil, expectedError // Non-NotExist error + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load previous state") + assert.Contains(t, err.Error(), expectedError.Error()) +} + +// TestGenerate_OutputFormat verifies the output format is valid JSON. +func TestGenerate_OutputFormat(t *testing.T) { + t.Parallel() + + expectedState := &mockState{ + Data: map[string]any{ + "chain": "ethereum", + "blockNum": 12345, + }, + } + + cmd := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(env fdeployment.Environment, prev json.Marshaler) (json.Marshaler, error) { + return expectedState, nil + }, + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + }, + }) + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging"}) + + err := cmd.Execute() + require.NoError(t, err) + + // Extract just the JSON part from output (skip informational messages) + output := out.String() + + // The state JSON should be parseable + var parsed map[string]any + // Find the JSON in the output (starts with {) + jsonStart := strings.Index(output, "{") + require.GreaterOrEqual(t, jsonStart, 0, "output should contain JSON") + + jsonStr := output[jsonStart:] + // Find end of JSON + jsonEnd := strings.LastIndex(jsonStr, "}") + require.GreaterOrEqual(t, jsonEnd, 0) + jsonStr = jsonStr[:jsonEnd+1] + + err = json.Unmarshal([]byte(jsonStr), &parsed) + require.NoError(t, err, "output should be valid JSON") + + assert.Equal(t, "ethereum", parsed["chain"]) + // Use type assertion for numeric comparison + blockNum, ok := parsed["blockNum"].(float64) + require.True(t, ok, "blockNum should be a number") + assert.Equal(t, 12345, int(blockNum)) +} diff --git a/pkg/commands/state/deps.go b/pkg/commands/state/deps.go new file mode 100644 index 00000000..a37dffdc --- /dev/null +++ b/pkg/commands/state/deps.go @@ -0,0 +1,87 @@ +// Package state provides CLI commands for state management operations. +package state + +import ( + "context" + "encoding/json" + + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" +) + +// EnvironmentLoaderFunc loads a deployment environment for the given domain and environment key. +type EnvironmentLoaderFunc func( + ctx context.Context, + dom domain.Domain, + envKey string, + opts ...environment.LoadEnvironmentOption, +) (fdeployment.Environment, error) + +// StateLoaderFunc loads the previous state from the environment directory. +// Returns the state as a JSONSerializer, or an error if loading fails. +// If the state file does not exist, implementations should return empty JSON. +type StateLoaderFunc func(envdir domain.EnvDir) (domain.JSONSerializer, error) + +// StateSaverFunc saves the generated state to a file. +// If outputPath is empty, it should use the default path in the environment directory. +type StateSaverFunc func(envdir domain.EnvDir, outputPath string, state json.Marshaler) error + +// ViewStateFunc is an alias for deployment.ViewStateV2 for clarity. +// It generates the current state view from the environment. +// It takes the environment and optionally the previous state for incremental updates. +type ViewStateFunc = fdeployment.ViewStateV2 + +// defaultEnvironmentLoader is the production implementation that loads an environment. +func defaultEnvironmentLoader( + ctx context.Context, + dom domain.Domain, + envKey string, + opts ...environment.LoadEnvironmentOption, +) (fdeployment.Environment, error) { + return environment.Load(ctx, dom, envKey, opts...) +} + +// defaultStateLoader loads state from the environment directory. +func defaultStateLoader(envdir domain.EnvDir) (domain.JSONSerializer, error) { + return envdir.LoadState() +} + +// defaultStateSaver saves state to the environment directory or custom path. +func defaultStateSaver(envdir domain.EnvDir, outputPath string, state json.Marshaler) error { + if outputPath != "" { + return domain.SaveViewState(outputPath, state) + } + + return envdir.SaveViewState(state) +} + +// Deps holds the injectable dependencies for state commands. +// All fields are optional; nil values will use production defaults. +// Users can override these to provide custom implementations for their domain. +type Deps struct { + // EnvironmentLoader loads a deployment environment. + // Default: environment.Load + EnvironmentLoader EnvironmentLoaderFunc + + // StateLoader loads the previous state from the environment directory. + // Default: envdir.LoadState + StateLoader StateLoaderFunc + + // StateSaver saves the generated state. + // Default: envdir.SaveViewState or domain.SaveViewState + StateSaver StateSaverFunc +} + +// applyDefaults fills in nil dependencies with production defaults. +func (d *Deps) applyDefaults() { + if d.EnvironmentLoader == nil { + d.EnvironmentLoader = defaultEnvironmentLoader + } + if d.StateLoader == nil { + d.StateLoader = defaultStateLoader + } + if d.StateSaver == nil { + d.StateSaver = defaultStateSaver + } +} diff --git a/pkg/commands/state/generate.go b/pkg/commands/state/generate.go new file mode 100644 index 00000000..7ee866c0 --- /dev/null +++ b/pkg/commands/state/generate.go @@ -0,0 +1,97 @@ +package state + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" +) + +// newGenerateCmd creates the "generate" subcommand for generating state. +func newGenerateCmd(cfg Config) *cobra.Command { + var ( + persist bool + outputPath string + prevStatePath string // NOTE: This flag is defined but not currently used in the original code. + ) + + cmd := &cobra.Command{ + Use: "generate", + Short: "Generate latest state from the environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return runGenerate(cmd, cfg, persist, outputPath, prevStatePath) + }, + } + + // These flags are local to the generate command only. + cmd.Flags().BoolVarP(&persist, "persist", "p", false, "Persist state to disk") + cmd.Flags().StringVarP(&outputPath, "outputPath", "o", "", "Output path. Default is //state.json") + cmd.Flags().StringVarP(&prevStatePath, "previousState", "s", "", "Previous state's path. Default is //state.json") + + return cmd +} + +// runGenerate executes the generate command logic. +// This is separated from the RunE closure to improve testability. +// Note: prevStatePath is currently unused but kept for future implementation. +func runGenerate(cmd *cobra.Command, cfg Config, persist bool, outputPath, _ string) error { + deps := cfg.deps() + + // Get environment flag from parent command (persistent flag) + envKey, _ := cmd.Flags().GetString("environment") + envdir := cfg.Domain.EnvDir(envKey) + + // Set a timeout for the view operation as it may take a while + viewTimeout := 10 * time.Minute + + cmd.Printf("Generate latest state for %s in environment: %s\n", cfg.Domain, envKey) + cmd.Printf("This command may take a while to complete, please be patient. Timeout set to %v\n", viewTimeout) + + ctx, cancel := context.WithTimeout(cmd.Context(), viewTimeout) + defer cancel() + + // Load the environment using the injected loader + env, err := deps.EnvironmentLoader(ctx, cfg.Domain, envKey, environment.WithLogger(cfg.Logger)) + if err != nil { + return fmt.Errorf("failed to load environment: %w", err) + } + + // Load the previous state using the injected loader + prevState, err := deps.StateLoader(envdir) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to load previous state: %w", err) + } + + if cfg.ViewState == nil { + return errors.New("ViewState function is required but not provided") + } + + // Generate the new state using the provided ViewState function + state, err := cfg.ViewState(env, prevState) + if err != nil { + return fmt.Errorf("unable to snapshot state: %w", err) + } + + // Marshal state for output + b, err := state.MarshalJSON() + if err != nil { + return fmt.Errorf("unable to marshal state: %w", err) + } + + // Persist state if requested + if persist { + if err := deps.StateSaver(envdir, outputPath, state); err != nil { + return fmt.Errorf("failed to save state: %w", err) + } + } + + // Output the state to stdout + cmd.Println(string(b)) + + return nil +}