From e21ed4ae085df2611b2cf865caff2ecfc3056aad Mon Sep 17 00:00:00 2001 From: giogam <151543+giogam@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:31:09 +0100 Subject: [PATCH 1/5] feat(state): add --local-datastore flag for offline state generation Allow state generate to use local datastore files when the domain is configured for catalog but the catalog service is unreachable. Introduces WithLocalDatastoreFallback environment option and wires it through state generate. Add tests for the option and for Load with fallback when domain has datastore: catalog. --- engine/cld/commands/state/command_test.go | 36 +++++++++++++++++++++++ engine/cld/commands/state/generate.go | 33 ++++++++++++++------- engine/cld/environment/environment.go | 9 ++++-- engine/cld/environment/options.go | 14 +++++++++ engine/cld/environment/options_test.go | 12 ++++++++ 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/engine/cld/commands/state/command_test.go b/engine/cld/commands/state/command_test.go index e34617460..6c18d12f2 100644 --- a/engine/cld/commands/state/command_test.go +++ b/engine/cld/commands/state/command_test.go @@ -404,6 +404,42 @@ func TestGenerate_ViewStateError(t *testing.T) { assert.Contains(t, execErr.Error(), expectedError.Error()) } +// TestGenerate_WithLocalDatastore verifies that --local-datastore passes WithLocalDatastoreFallback to the environment loader. +func TestGenerate_WithLocalDatastore(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", "--local-datastore"}) + + execErr := cmd.Execute() + + require.NoError(t, execErr) + // Without --local-datastore we only pass WithLogger; with --local-datastore we add WithLocalDatastoreFallback. + require.Len(t, receivedOpts, 2, "expected 2 options (WithLogger + WithLocalDatastoreFallback) when --local-datastore is set") +} + // TestGenerate_StateSaveError verifies error handling. func TestGenerate_StateSaveError(t *testing.T) { t.Parallel() diff --git a/engine/cld/commands/state/generate.go b/engine/cld/commands/state/generate.go index b4fac0a22..77be6795a 100644 --- a/engine/cld/commands/state/generate.go +++ b/engine/cld/commands/state/generate.go @@ -52,6 +52,9 @@ var ( # Generate and save state to default location (also prints) myapp state generate -e staging --persist + # Use local datastore files when catalog is unreachable and local files are available + myapp state generate -e testnet --persist --local-datastore + # Generate and save to custom path without printing myapp state generate -e staging -p -o /path/to/state.json --print=false @@ -61,11 +64,12 @@ var ( ) type generateFlags struct { - environment string - persist bool - output string - previousState string - print bool + environment string + persist bool + output string + previousState string + print bool + localDatastore bool } // newGenerateCmd creates the "generate" subcommand for generating state. @@ -77,11 +81,12 @@ func newGenerateCmd(cfg Config) *cobra.Command { Example: generateExample, RunE: func(cmd *cobra.Command, _ []string) error { f := generateFlags{ - environment: flags.MustString(cmd.Flags().GetString("environment")), - persist: flags.MustBool(cmd.Flags().GetBool("persist")), - output: flags.MustString(cmd.Flags().GetString("out")), - previousState: flags.MustString(cmd.Flags().GetString("prev")), - print: flags.MustBool(cmd.Flags().GetBool("print")), + environment: flags.MustString(cmd.Flags().GetString("environment")), + persist: flags.MustBool(cmd.Flags().GetBool("persist")), + output: flags.MustString(cmd.Flags().GetString("out")), + previousState: flags.MustString(cmd.Flags().GetString("prev")), + print: flags.MustBool(cmd.Flags().GetBool("print")), + localDatastore: flags.MustBool(cmd.Flags().GetBool("local-datastore")), } return runGenerate(cmd, cfg, f) @@ -96,6 +101,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().Bool("local-datastore", false, "Use local datastore files instead of catalog (for when catalog is unreachable)") // Deprecated alias: --previousState -> --prev addPreviousStateAlias(cmd) @@ -111,6 +117,7 @@ func runGenerate(cmd *cobra.Command, cfg Config, f generateFlags) error { output := f.output previousState := f.previousState shouldPrint := f.print + localDatastore := f.localDatastore deps := cfg.deps() envdir := cfg.Domain.EnvDir(envKey) @@ -124,7 +131,11 @@ 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)} + if localDatastore { + envOpts = append(envOpts, environment.WithLocalDatastoreFallback()) + } + 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..a485d0103 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -48,7 +48,9 @@ func Load( var ds fdatastore.DataStore - if cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll { + useCatalog := (cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll) && !loadcfg.useLocalDatastoreFallback + + if useCatalog { 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 +69,10 @@ 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) + // Load datastore from file system (default behavior, or when --local-datastore is used) + if loadcfg.useLocalDatastoreFallback && (cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll) { + lggr.Infow("Using local datastore files (--local-datastore); catalog bypassed") + } ds, err = envdir.DataStore() if err != nil { return fdeployment.Environment{}, err diff --git a/engine/cld/environment/options.go b/engine/cld/environment/options.go index 489428747..30d47e683 100644 --- a/engine/cld/environment/options.go +++ b/engine/cld/environment/options.go @@ -40,6 +40,10 @@ 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 + + // useLocalDatastoreFallback when true, load datastore from local files even when the + // domain is configured for catalog or catalog/all. Use when the catalog service is not reachable locally. + useLocalDatastoreFallback bool } // Configure applies a slice of LoadEnvironmentOption functions to the LoadConfig. @@ -177,3 +181,13 @@ func WithDryRunJobDistributor() LoadEnvironmentOption { o.useDryRunJobDistributor = true } } + +// WithLocalDatastoreFallback configures the environment to load the datastore from local +// files (e.g. address_refs.json in the environment's datastore directory) instead of the +// catalog service. Use this when the domain has datastore set to "catalog" or "all" in +// domain.yaml but the catalog service is not reachable locally (e.g. state generate run offline). +func WithLocalDatastoreFallback() LoadEnvironmentOption { + return func(o *LoadConfig) { + o.useLocalDatastoreFallback = true + } +} diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 9b2d72b65..6a59de1f9 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -111,3 +111,15 @@ func Test_WithDryRunJobDistributor(t *testing.T) { assert.True(t, opts.useDryRunJobDistributor) } + +func Test_WithLocalDatastoreFallback(t *testing.T) { + t.Parallel() + + opts := &LoadConfig{} + require.False(t, opts.useLocalDatastoreFallback) + + option := WithLocalDatastoreFallback() + option(opts) + + assert.True(t, opts.useLocalDatastoreFallback) +} From f349953c70982532980def6aebc1ab0a25613b1b Mon Sep 17 00:00:00 2001 From: Giorgio Gambino <151543+giogam@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:14:09 +0100 Subject: [PATCH 2/5] Create forty-islands-scream.md --- .changeset/forty-islands-scream.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/forty-islands-scream.md diff --git a/.changeset/forty-islands-scream.md b/.changeset/forty-islands-scream.md new file mode 100644 index 000000000..d3d6b4f2f --- /dev/null +++ b/.changeset/forty-islands-scream.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(state): add --local-datastore flag for offline state generation From 653d1b5925b9bdf601ae9ac0bfa41f7e463fe4d2 Mon Sep 17 00:00:00 2001 From: giogam <151543+giogam@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:30:29 +0100 Subject: [PATCH 3/5] refactor(state): rename --local-datastore to --local Use shorter flag for local datastore fallback --- engine/cld/commands/state/command_test.go | 8 ++++---- engine/cld/commands/state/generate.go | 6 +++--- engine/cld/environment/environment.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/engine/cld/commands/state/command_test.go b/engine/cld/commands/state/command_test.go index 6c18d12f2..ff586d8af 100644 --- a/engine/cld/commands/state/command_test.go +++ b/engine/cld/commands/state/command_test.go @@ -404,7 +404,7 @@ func TestGenerate_ViewStateError(t *testing.T) { assert.Contains(t, execErr.Error(), expectedError.Error()) } -// TestGenerate_WithLocalDatastore verifies that --local-datastore passes WithLocalDatastoreFallback to the environment loader. +// TestGenerate_WithLocalDatastore verifies that --local passes WithLocalDatastoreFallback to the environment loader. func TestGenerate_WithLocalDatastore(t *testing.T) { t.Parallel() @@ -431,13 +431,13 @@ func TestGenerate_WithLocalDatastore(t *testing.T) { out := new(bytes.Buffer) cmd.SetOut(out) cmd.SetErr(out) - cmd.SetArgs([]string{"generate", "-e", "staging", "--local-datastore"}) + cmd.SetArgs([]string{"generate", "-e", "staging", "--local"}) execErr := cmd.Execute() require.NoError(t, execErr) - // Without --local-datastore we only pass WithLogger; with --local-datastore we add WithLocalDatastoreFallback. - require.Len(t, receivedOpts, 2, "expected 2 options (WithLogger + WithLocalDatastoreFallback) when --local-datastore is set") + // Without --local we only pass WithLogger; with --local we add WithLocalDatastoreFallback. + require.Len(t, receivedOpts, 2, "expected 2 options (WithLogger + WithLocalDatastoreFallback) when --local is set") } // TestGenerate_StateSaveError verifies error handling. diff --git a/engine/cld/commands/state/generate.go b/engine/cld/commands/state/generate.go index 77be6795a..9a3dc85e6 100644 --- a/engine/cld/commands/state/generate.go +++ b/engine/cld/commands/state/generate.go @@ -53,7 +53,7 @@ var ( myapp state generate -e staging --persist # Use local datastore files when catalog is unreachable and local files are available - myapp state generate -e testnet --persist --local-datastore + myapp state generate -e testnet --persist --local # Generate and save to custom path without printing myapp state generate -e staging -p -o /path/to/state.json --print=false @@ -86,7 +86,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")), - localDatastore: flags.MustBool(cmd.Flags().GetBool("local-datastore")), + localDatastore: flags.MustBool(cmd.Flags().GetBool("local")), } return runGenerate(cmd, cfg, f) @@ -101,7 +101,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().Bool("local-datastore", false, "Use local datastore files instead of catalog (for when catalog is unreachable)") + cmd.Flags().Bool("local", false, "Use local datastore files instead of catalog (for when catalog is unreachable)") // Deprecated alias: --previousState -> --prev addPreviousStateAlias(cmd) diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index a485d0103..99e14121e 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -69,9 +69,9 @@ 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, or when --local-datastore is used) + // Load datastore from file system (default behavior, or when --local is used) if loadcfg.useLocalDatastoreFallback && (cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll) { - lggr.Infow("Using local datastore files (--local-datastore); catalog bypassed") + lggr.Infow("Using local datastore files (--local); catalog bypassed") } ds, err = envdir.DataStore() if err != nil { From bd165e436bf8b8c89c0766315696680bc1302b8f Mon Sep 17 00:00:00 2001 From: giogam <151543+giogam@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:55:33 +0100 Subject: [PATCH 4/5] extract useCatalog into a private function --- engine/cld/environment/environment.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index 99e14121e..da3f3f911 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -47,10 +47,7 @@ func Load( } var ds fdatastore.DataStore - - useCatalog := (cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll) && !loadcfg.useLocalDatastoreFallback - - if useCatalog { + if useCatalog(cfg.DatastoreType, loadcfg.useLocalDatastoreFallback) { if cfg.Env.Catalog.GRPC != "" { lggr.Infow("Fetching data from Catalog", "url", cfg.Env.Catalog.GRPC) catalogStore, catalogErr := cldcatalog.LoadCatalog(ctx, envKey, cfg, domain) @@ -69,10 +66,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, or when --local is used) - if loadcfg.useLocalDatastoreFallback && (cfg.DatastoreType == cfgdomain.DatastoreTypeCatalog || cfg.DatastoreType == cfgdomain.DatastoreTypeAll) { - lggr.Infow("Using local datastore files (--local); catalog bypassed") - } ds, err = envdir.DataStore() if err != nil { return fdeployment.Environment{}, err @@ -148,3 +141,7 @@ func Load( BlockChains: blockChains, }, nil } + +func useCatalog(datastoreType cfgdomain.DatastoreType, useLocalDatastoreFallback bool) bool { + return (datastoreType == cfgdomain.DatastoreTypeCatalog || datastoreType == cfgdomain.DatastoreTypeAll) && !useLocalDatastoreFallback +} From d603dd86702d526f49f6abe7b9d2cb45493e0ef8 Mon Sep 17 00:00:00 2001 From: giogam <151543+giogam@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:01:28 +0100 Subject: [PATCH 5/5] feat(state): add --datastore flag for state generate Support file|catalog datastore selection via CLI while defaulting to domain.yaml when unset. --- .changeset/forty-islands-scream.md | 2 +- engine/cld/commands/state/command_test.go | 103 +++++++++++++++++----- engine/cld/commands/state/generate.go | 47 ++++++---- engine/cld/environment/environment.go | 11 ++- engine/cld/environment/options.go | 16 ++-- engine/cld/environment/options_test.go | 15 +--- 6 files changed, 126 insertions(+), 68 deletions(-) diff --git a/.changeset/forty-islands-scream.md b/.changeset/forty-islands-scream.md index d3d6b4f2f..2e8976f06 100644 --- a/.changeset/forty-islands-scream.md +++ b/.changeset/forty-islands-scream.md @@ -2,4 +2,4 @@ "chainlink-deployments-framework": minor --- -feat(state): add --local-datastore flag for offline state generation +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 ff586d8af..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,16 +403,77 @@ 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_WithLocalDatastore verifies that --local passes WithLocalDatastoreFallback to the environment loader. -func TestGenerate_WithLocalDatastore(t *testing.T) { +// 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() - var receivedOpts []environment.LoadEnvironmentOption cmd, err := NewCommand(Config{ Logger: logger.Nop(), Domain: domain.NewDomain("/tmp", "testdomain"), @@ -416,28 +481,21 @@ func TestGenerate_WithLocalDatastore(t *testing.T) { 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 + 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", "--local"}) - + cmd.SetArgs([]string{"generate", "-e", "staging", "--datastore", "invalid"}) execErr := cmd.Execute() - - require.NoError(t, execErr) - // Without --local we only pass WithLogger; with --local we add WithLocalDatastoreFallback. - require.Len(t, receivedOpts, 2, "expected 2 options (WithLogger + WithLocalDatastoreFallback) when --local is set") + require.ErrorContains(t, execErr, `--datastore must be "file" or "catalog"`) } // TestGenerate_StateSaveError verifies error handling. @@ -474,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 9a3dc85e6..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,8 +56,8 @@ var ( # Generate and save state to default location (also prints) myapp state generate -e staging --persist - # Use local datastore files when catalog is unreachable and local files are available - myapp state generate -e testnet --persist --local + # 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 @@ -64,12 +68,12 @@ var ( ) type generateFlags struct { - environment string - persist bool - output string - previousState string - print bool - localDatastore bool + environment string + persist bool + output string + previousState string + print bool + datastore string // "file", "catalog", or empty for domain default } // newGenerateCmd creates the "generate" subcommand for generating state. @@ -81,12 +85,12 @@ func newGenerateCmd(cfg Config) *cobra.Command { Example: generateExample, RunE: func(cmd *cobra.Command, _ []string) error { f := generateFlags{ - environment: flags.MustString(cmd.Flags().GetString("environment")), - persist: flags.MustBool(cmd.Flags().GetBool("persist")), - output: flags.MustString(cmd.Flags().GetString("out")), - previousState: flags.MustString(cmd.Flags().GetString("prev")), - print: flags.MustBool(cmd.Flags().GetBool("print")), - localDatastore: flags.MustBool(cmd.Flags().GetBool("local")), + environment: flags.MustString(cmd.Flags().GetString("environment")), + persist: flags.MustBool(cmd.Flags().GetBool("persist")), + 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) @@ -101,7 +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().Bool("local", false, "Use local datastore files instead of catalog (for when catalog is unreachable)") + cmd.Flags().String("datastore", "", "Datastore to use: file or catalog. Defaults to domain.yaml setting when unset.") // Deprecated alias: --previousState -> --prev addPreviousStateAlias(cmd) @@ -117,7 +121,11 @@ func runGenerate(cmd *cobra.Command, cfg Config, f generateFlags) error { output := f.output previousState := f.previousState shouldPrint := f.print - localDatastore := f.localDatastore + 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) @@ -132,8 +140,11 @@ func runGenerate(cmd *cobra.Command, cfg Config, f generateFlags) error { defer cancel() envOpts := []environment.LoadEnvironmentOption{environment.WithLogger(cfg.Logger)} - if localDatastore { - envOpts = append(envOpts, environment.WithLocalDatastoreFallback()) + 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 { diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index da3f3f911..a0f8ae2da 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -47,7 +47,12 @@ func Load( } var ds fdatastore.DataStore - if useCatalog(cfg.DatastoreType, loadcfg.useLocalDatastoreFallback) { + + 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) @@ -142,6 +147,6 @@ func Load( }, nil } -func useCatalog(datastoreType cfgdomain.DatastoreType, useLocalDatastoreFallback bool) bool { - return (datastoreType == cfgdomain.DatastoreTypeCatalog || datastoreType == cfgdomain.DatastoreTypeAll) && !useLocalDatastoreFallback +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 30d47e683..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" ) @@ -41,9 +42,8 @@ type LoadConfig struct { // that allows read operations but performs noop write operations. useDryRunJobDistributor bool - // useLocalDatastoreFallback when true, load datastore from local files even when the - // domain is configured for catalog or catalog/all. Use when the catalog service is not reachable locally. - useLocalDatastoreFallback 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. @@ -182,12 +182,10 @@ func WithDryRunJobDistributor() LoadEnvironmentOption { } } -// WithLocalDatastoreFallback configures the environment to load the datastore from local -// files (e.g. address_refs.json in the environment's datastore directory) instead of the -// catalog service. Use this when the domain has datastore set to "catalog" or "all" in -// domain.yaml but the catalog service is not reachable locally (e.g. state generate run offline). -func WithLocalDatastoreFallback() LoadEnvironmentOption { +// 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.useLocalDatastoreFallback = true + o.datastoreType = &t } } diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 6a59de1f9..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) { @@ -111,15 +110,3 @@ func Test_WithDryRunJobDistributor(t *testing.T) { assert.True(t, opts.useDryRunJobDistributor) } - -func Test_WithLocalDatastoreFallback(t *testing.T) { - t.Parallel() - - opts := &LoadConfig{} - require.False(t, opts.useLocalDatastoreFallback) - - option := WithLocalDatastoreFallback() - option(opts) - - assert.True(t, opts.useLocalDatastoreFallback) -}