diff --git a/.changeset/forty-islands-scream.md b/.changeset/forty-islands-scream.md new file mode 100644 index 000000000..2e8976f06 --- /dev/null +++ b/.changeset/forty-islands-scream.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(state): add --datastore flag (file|catalog) for state generate; defaults to domain.yaml when unset diff --git a/engine/cld/commands/state/command_test.go b/engine/cld/commands/state/command_test.go index e34617460..cdf85f1e3 100644 --- a/engine/cld/commands/state/command_test.go +++ b/engine/cld/commands/state/command_test.go @@ -109,6 +109,11 @@ func TestNewCommand_GenerateFlags(t *testing.T) { require.NotNil(t, pr) assert.Equal(t, "true", pr.Value.String()) + // Datastore flag (default empty = use domain.yaml) + ds := sub.Flags().Lookup("datastore") + require.NotNil(t, ds) + assert.Empty(t, ds.Value.String()) + break } } @@ -363,9 +368,8 @@ func TestGenerate_EnvironmentLoadError(t *testing.T) { execErr := cmd.Execute() - require.Error(t, execErr) - assert.Contains(t, execErr.Error(), "failed to load environment") - assert.Contains(t, execErr.Error(), expectedError.Error()) + require.ErrorContains(t, execErr, "failed to load environment") + require.ErrorContains(t, execErr, expectedError.Error()) } // TestGenerate_ViewStateError verifies error handling. @@ -399,9 +403,99 @@ func TestGenerate_ViewStateError(t *testing.T) { execErr := cmd.Execute() - require.Error(t, execErr) - assert.Contains(t, execErr.Error(), "unable to snapshot state") - assert.Contains(t, execErr.Error(), expectedError.Error()) + require.ErrorContains(t, execErr, "unable to snapshot state") + require.ErrorContains(t, execErr, expectedError.Error()) +} + +// TestGenerate_WithDatastoreFlag verifies that --datastore file and --datastore catalog pass the corresponding option to the environment loader. +func TestGenerate_WithDatastoreFlag(t *testing.T) { + t.Parallel() + + t.Run("file", func(t *testing.T) { + t.Parallel() + + var receivedOpts []environment.LoadEnvironmentOption + cmd, err := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(_ fdeployment.Environment, _ json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + Deps: Deps{ + EnvironmentLoader: func(_ context.Context, _ domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + receivedOpts = opts + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(_ domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + }, + }) + require.NoError(t, err) + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging", "--datastore", "file"}) + require.NoError(t, cmd.Execute()) + require.Len(t, receivedOpts, 2, "expected WithLogger + WithDatastoreType(file)") + }) + + t.Run("catalog", func(t *testing.T) { + t.Parallel() + + var receivedOpts []environment.LoadEnvironmentOption + cmd, err := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(_ fdeployment.Environment, _ json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + Deps: Deps{ + EnvironmentLoader: func(_ context.Context, _ domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + receivedOpts = opts + return fdeployment.Environment{Name: envKey}, nil + }, + StateLoader: func(_ domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + }, + }) + require.NoError(t, err) + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging", "--datastore", "catalog"}) + require.NoError(t, cmd.Execute()) + require.Len(t, receivedOpts, 2, "expected WithLogger + WithDatastoreType(catalog)") + }) +} + +// TestGenerate_InvalidDatastoreFlag verifies that an invalid --datastore value returns an error. +func TestGenerate_InvalidDatastoreFlag(t *testing.T) { + t.Parallel() + + cmd, err := NewCommand(Config{ + Logger: logger.Nop(), + Domain: domain.NewDomain("/tmp", "testdomain"), + ViewState: func(_ fdeployment.Environment, _ json.Marshaler) (json.Marshaler, error) { + return &mockState{Data: map[string]any{}}, nil + }, + Deps: Deps{ + EnvironmentLoader: func(_ context.Context, _ domain.Domain, _ string, _ ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{}, nil + }, + StateLoader: func(_ domain.EnvDir) (domain.JSONSerializer, error) { + return nil, os.ErrNotExist + }, + }, + }) + require.NoError(t, err) + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs([]string{"generate", "-e", "staging", "--datastore", "invalid"}) + execErr := cmd.Execute() + require.ErrorContains(t, execErr, `--datastore must be "file" or "catalog"`) } // TestGenerate_StateSaveError verifies error handling. @@ -438,9 +532,8 @@ func TestGenerate_StateSaveError(t *testing.T) { execErr := cmd.Execute() - require.Error(t, execErr) - assert.Contains(t, execErr.Error(), "failed to save state") - assert.Contains(t, execErr.Error(), expectedError.Error()) + require.ErrorContains(t, execErr, "failed to save state") + require.ErrorContains(t, execErr, expectedError.Error()) } // TestConfig_Validate verifies validation catches missing required fields. diff --git a/engine/cld/commands/state/generate.go b/engine/cld/commands/state/generate.go index b4fac0a22..34d572291 100644 --- a/engine/cld/commands/state/generate.go +++ b/engine/cld/commands/state/generate.go @@ -12,6 +12,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/flags" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text" + cfgdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/domain" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" ) @@ -43,6 +44,9 @@ var ( By default, the generated state is not saved. Use --persist to save to disk, or --print to output the full JSON to stdout. + + Use --datastore file or --datastore catalog to override the datastore + source; when omitted, the setting from domain.yaml is used. `) generateExample = text.Examples(` @@ -52,6 +56,9 @@ var ( # Generate and save state to default location (also prints) myapp state generate -e staging --persist + # Use file datastore (overrides domain.yaml); omit --datastore to use domain default + myapp state generate -e testnet --persist --datastore=file + # Generate and save to custom path without printing myapp state generate -e staging -p -o /path/to/state.json --print=false @@ -66,6 +73,7 @@ type generateFlags struct { output string previousState string print bool + datastore string // "file", "catalog", or empty for domain default } // newGenerateCmd creates the "generate" subcommand for generating state. @@ -82,6 +90,7 @@ func newGenerateCmd(cfg Config) *cobra.Command { output: flags.MustString(cmd.Flags().GetString("out")), previousState: flags.MustString(cmd.Flags().GetString("prev")), print: flags.MustBool(cmd.Flags().GetBool("print")), + datastore: flags.MustString(cmd.Flags().GetString("datastore")), } return runGenerate(cmd, cfg, f) @@ -96,6 +105,7 @@ func newGenerateCmd(cfg Config) *cobra.Command { // Local flags specific to this command cmd.Flags().BoolP("persist", "p", false, "Persist state to disk") cmd.Flags().StringP("prev", "s", "", "Previous state file path") + cmd.Flags().String("datastore", "", "Datastore to use: file or catalog. Defaults to domain.yaml setting when unset.") // Deprecated alias: --previousState -> --prev addPreviousStateAlias(cmd) @@ -111,6 +121,11 @@ func runGenerate(cmd *cobra.Command, cfg Config, f generateFlags) error { output := f.output previousState := f.previousState shouldPrint := f.print + datastoreFlag := f.datastore + + if datastoreFlag != "" && datastoreFlag != "file" && datastoreFlag != "catalog" { + return fmt.Errorf("--datastore must be %q or %q, got %q", "file", "catalog", datastoreFlag) + } deps := cfg.deps() envdir := cfg.Domain.EnvDir(envKey) @@ -124,7 +139,14 @@ func runGenerate(cmd *cobra.Command, cfg Config, f generateFlags) error { ctx, cancel := context.WithTimeout(cmd.Context(), viewTimeout) defer cancel() - env, err := deps.EnvironmentLoader(ctx, cfg.Domain, envKey, environment.WithLogger(cfg.Logger)) + envOpts := []environment.LoadEnvironmentOption{environment.WithLogger(cfg.Logger)} + switch datastoreFlag { + case "file": + envOpts = append(envOpts, environment.WithDatastoreType(cfgdomain.DatastoreTypeFile)) + case "catalog": + envOpts = append(envOpts, environment.WithDatastoreType(cfgdomain.DatastoreTypeCatalog)) + } + env, err := deps.EnvironmentLoader(ctx, cfg.Domain, envKey, envOpts...) if err != nil { return fmt.Errorf("failed to load environment: %w", err) } diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index c9d8c5fbd..a0f8ae2da 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -48,7 +48,11 @@ func Load( var ds fdatastore.DataStore - if cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll { + effectiveDatastoreType := cfg.DatastoreType + if loadcfg.datastoreType != nil { + effectiveDatastoreType = *loadcfg.datastoreType + } + if useCatalog(effectiveDatastoreType) { if cfg.Env.Catalog.GRPC != "" { lggr.Infow("Fetching data from Catalog", "url", cfg.Env.Catalog.GRPC) catalogStore, catalogErr := cldcatalog.LoadCatalog(ctx, envKey, cfg, domain) @@ -67,7 +71,6 @@ func Load( return fdeployment.Environment{}, fmt.Errorf("catalog GRPC endpoint is required when datastore location is set to '%s'", cfgdomain.DatastoreTypeCatalog) } } else { - // Load datastore from file system (default behavior) ds, err = envdir.DataStore() if err != nil { return fdeployment.Environment{}, err @@ -143,3 +146,7 @@ func Load( BlockChains: blockChains, }, nil } + +func useCatalog(datastoreType cfgdomain.DatastoreType) bool { + return datastoreType == cfgdomain.DatastoreTypeCatalog || datastoreType == cfgdomain.DatastoreTypeAll +} diff --git a/engine/cld/environment/options.go b/engine/cld/environment/options.go index 489428747..55b26d3b0 100644 --- a/engine/cld/environment/options.go +++ b/engine/cld/environment/options.go @@ -3,6 +3,7 @@ package environment import ( "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + cfgdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/domain" "github.com/smartcontractkit/chainlink-deployments-framework/operations" ) @@ -40,6 +41,9 @@ type LoadConfig struct { // useDryRunJobDistributor configures the environment to use a dry-run Job Distributor // that allows read operations but performs noop write operations. useDryRunJobDistributor bool + + // datastoreType when set, overrides the datastore type from domain config (e.g. from --datastore flag). + datastoreType *cfgdomain.DatastoreType } // Configure applies a slice of LoadEnvironmentOption functions to the LoadConfig. @@ -177,3 +181,11 @@ func WithDryRunJobDistributor() LoadEnvironmentOption { o.useDryRunJobDistributor = true } } + +// WithDatastoreType overrides the datastore type from domain config. Use when the caller +// explicitly requests "file" or "catalog" (e.g. from a CLI flag). Omit to use domain default. +func WithDatastoreType(t cfgdomain.DatastoreType) LoadEnvironmentOption { + return func(o *LoadConfig) { + o.datastoreType = &t + } +} diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 9b2d72b65..9582d8c59 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -6,9 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" - foperations "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) func Test_WithAnvilKeyAsDeployer(t *testing.T) {