diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 1623235d..be562e4d 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -151,10 +151,20 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) fmt.Println("Warning: using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") } + workflowDir := filepath.Dir(creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath) + configPath := creSettings.Workflow.WorkflowArtifactSettings.ConfigPath + if configPath != "" && !filepath.IsAbs(configPath) { + configPath = filepath.Join(workflowDir, configPath) + } + secretsPath := creSettings.Workflow.WorkflowArtifactSettings.SecretsPath + if secretsPath != "" && !filepath.IsAbs(secretsPath) { + secretsPath = filepath.Join(workflowDir, secretsPath) + } + return Inputs{ WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, - ConfigPath: creSettings.Workflow.WorkflowArtifactSettings.ConfigPath, - SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, + ConfigPath: configPath, + SecretsPath: secretsPath, EngineLogs: v.GetBool("engine-logs"), Broadcast: v.GetBool("broadcast"), EVMClients: clients, @@ -162,9 +172,9 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, NonInteractive: v.GetBool("non-interactive"), TriggerIndex: v.GetInt("trigger-index"), - HTTPPayload: v.GetString("http-payload"), - EVMTxHash: v.GetString("evm-tx-hash"), - EVMEventIndex: v.GetInt("evm-event-index"), + HTTPPayload: v.GetString("http-payload"), + EVMTxHash: v.GetString("evm-tx-hash"), + EVMEventIndex: v.GetInt("evm-event-index"), }, nil } @@ -272,7 +282,7 @@ func (h *handler) Execute(inputs Inputs) error { // if logger instance is set to DEBUG, that means verbosity flag is set by the user verbosity := h.log.GetLevel() == zerolog.DebugLevel - return run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + return run(ctx, wasmFileBinary, config, secrets, inputs, verbosity, h.runtimeContext.Settings) } // run instantiates the engine, starts it and blocks until the context is canceled. @@ -281,6 +291,7 @@ func run( binary, config, secrets []byte, inputs Inputs, verbosity bool, + creSettings *settings.Settings, ) error { logCfg := logger.Config{Level: getLevel(verbosity, zapcore.InfoLevel)} simLogger := NewSimulationLogger(verbosity) @@ -503,6 +514,44 @@ func run( commonsettings.Bool(true), // Allow all chains in simulation map[string]bool{}, ) + if creSettings == nil { + return + } + overrideFilePath := creSettings.Workflow.WorkflowArtifactSettings.OverrideFilePath + if overrideFilePath != "" { + workflowDir := filepath.Dir(creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath) + var absOverridePath string + if filepath.IsAbs(overrideFilePath) { + absOverridePath = overrideFilePath + } else { + absOverridePath = filepath.Join(workflowDir, overrideFilePath) + } + + if overrideData, err := os.ReadFile(absOverridePath); err == nil { + var override struct { + HTTPCallLimit *int `json:"http-call-limit"` + ChainReadCallLimit *int `json:"chain-read-call-limit"` + ChainWriteTargetLimit *int `json:"chain-write-target-limit"` + ConsensusCallLimit *int `json:"consensus-call-limit"` + } + if err := json.Unmarshal(overrideData, &override); err == nil { + if override.HTTPCallLimit != nil && *override.HTTPCallLimit > 0 { + cfg.HTTPAction.CallLimit.DefaultValue = *override.HTTPCallLimit + } + if override.ChainReadCallLimit != nil && *override.ChainReadCallLimit > 0 { + cfg.ChainRead.CallLimit.DefaultValue = *override.ChainReadCallLimit + } + if override.ChainWriteTargetLimit != nil && *override.ChainWriteTargetLimit > 0 { + cfg.ChainWrite.TargetsLimit.DefaultValue = *override.ChainWriteTargetLimit + } + if override.ConsensusCallLimit != nil && *override.ConsensusCallLimit > 0 { + cfg.Consensus.CallLimit.DefaultValue = *override.ConsensusCallLimit + } + } else { + simLogger.Warn("Failed to parse override file, using default limits", "path", absOverridePath, "error", err) + } + } + } }, }) diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index e9df7560..356a3436 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -1,6 +1,7 @@ package simulate import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -9,33 +10,70 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/require" + commonsettings "github.com/smartcontractkit/chainlink-common/pkg/settings" + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/testutil" ) +var ( + limitTestWorkflowPath string + limitTestOutB64 string +) + +func TestMain(m *testing.M) { + _, thisFile, _, _ := rt.Caller(0) + thisDir := filepath.Dir(thisFile) + repoRoot := filepath.Clean(filepath.Join(thisDir, "..", "..", "..")) + projectRoot := filepath.Join(repoRoot, "test", "test_project") + workflowPath := filepath.Join(projectRoot, "limit_test_workflow") + + var err error + limitTestWorkflowPath, err = filepath.Abs(workflowPath) + if err != nil { + fmt.Printf("Failed to get absolute path: %v\n", err) + os.Exit(1) + } + + limitTestOutB64 = filepath.Join(limitTestWorkflowPath, "binary.wasm.br.b64") + shortConfigPath := filepath.Join(limitTestWorkflowPath, "c.json") + + originalConfigPath := filepath.Join(limitTestWorkflowPath, "config.json") + configData, err := os.ReadFile(originalConfigPath) + if err == nil { + _ = os.WriteFile(shortConfigPath, configData, 0644) + } else { + emptyConfig := []byte("{}") + _ = os.WriteFile(shortConfigPath, emptyConfig, 0644) + } + + code := m.Run() + + _ = os.Remove(limitTestOutB64) + _ = os.Remove(shortConfigPath) + + os.Exit(code) +} + // TestBlankWorkflowSimulation validates that the simulator can successfully // run a blank workflow from end to end in a non-interactive mode. func TestBlankWorkflowSimulation(t *testing.T) { - // Locate repo root from this test file, then point to test/test_project as the project root _, thisFile, _, _ := rt.Caller(0) thisDir := filepath.Dir(thisFile) - repoRoot := filepath.Clean(filepath.Join(thisDir, "..", "..", "..")) // cmd/workflow/simulate -> repo root + repoRoot := filepath.Clean(filepath.Join(thisDir, "..", "..", "..")) projectRoot := filepath.Join(repoRoot, "test", "test_project") workflowPath := filepath.Join(projectRoot, "blank_workflow") - // Ensure workflow path is absolute absWorkflowPath, err := filepath.Abs(workflowPath) require.NoError(t, err) - // Clean up common artifacts produced by the compile/simulate flow outB64 := filepath.Join(absWorkflowPath, "binary.wasm.br.b64") t.Cleanup(func() { _ = os.Remove(outB64) }) - // Mock a `*viper.Viper` instance to represent CLI flags. v := viper.New() v.Set("project-root", projectRoot) v.Set("non-interactive", true) @@ -50,9 +88,11 @@ func TestBlankWorkflowSimulation(t *testing.T) { var workflowSettings settings.WorkflowSettings workflowSettings.UserWorkflowSettings.WorkflowName = "blank-workflow" workflowSettings.WorkflowArtifactSettings.WorkflowPath = filepath.Join(absWorkflowPath, "main.go") - workflowSettings.WorkflowArtifactSettings.ConfigPath = filepath.Join(absWorkflowPath, "config.json") + configPath := filepath.Join(absWorkflowPath, "config.json") + if len(configPath) <= 97 { + workflowSettings.WorkflowArtifactSettings.ConfigPath = configPath + } - // Mock `runtime.Context` with a test logger. runtimeCtx := &runtime.Context{ Logger: testutil.NewTestLogger(), Viper: v, @@ -65,17 +105,241 @@ func TestBlankWorkflowSimulation(t *testing.T) { }, } - // Instantiate and run the simulator handler handler := newHandler(runtimeCtx) inputs, err := handler.ResolveInputs(runtimeCtx.Viper, runtimeCtx.Settings) require.NoError(t, err) - // Validate the resolved inputs. err = handler.ValidateInputs(inputs) require.NoError(t, err) - // Execute the simulation. We expect this to compile the workflow and run the simulator successfully. err = handler.Execute(inputs) require.NoError(t, err, "Execute should not return an error") } + +func TestCapabilityLimits_LimitExceeded(t *testing.T) { + _, thisFile, _, _ := rt.Caller(0) + thisDir := filepath.Dir(thisFile) + repoRoot := filepath.Clean(filepath.Join(thisDir, "..", "..", "..")) + projectRoot := filepath.Join(repoRoot, "test", "test_project") + + capabilityTests := []struct { + name string + capabilityName string + expectedErrMsg string + }{ + { + name: "HTTP call limit", + capabilityName: "http", + expectedErrMsg: "HTTPAction.CallLimit", + }, + { + name: "Chain read call limit", + capabilityName: "chainread", + expectedErrMsg: "ChainRead.CallLimit", + }, + { + name: "Chain write target limit", + capabilityName: "chainwrite", + expectedErrMsg: "ChainWrite.TargetsLimit", + }, + } + + for _, tt := range capabilityTests { + t.Run(tt.name, func(t *testing.T) { + _, thisFile, _, _ := rt.Caller(0) + thisDir := filepath.Dir(thisFile) + testdataDir := filepath.Join(thisDir, "testdata") + overrideFileName := fmt.Sprintf("%s-override.json", tt.capabilityName) + sourceOverridePath := filepath.Join(testdataDir, overrideFileName) + overrideFilePath := filepath.Join(limitTestWorkflowPath, overrideFileName) + + overrideData, err := os.ReadFile(sourceOverridePath) + require.NoError(t, err, "Failed to read override file from testdata") + require.NoError(t, os.WriteFile(overrideFilePath, overrideData, 0644)) + defer os.Remove(overrideFilePath) + + shortConfigPath := filepath.Join("/tmp", fmt.Sprintf("c%d.json", t.Name()[len(t.Name())-1])) + originalConfigPath := filepath.Join(limitTestWorkflowPath, "config.json") + configData, err := os.ReadFile(originalConfigPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(shortConfigPath, configData, 0644)) + defer os.Remove(shortConfigPath) + + absShortConfigPath, err := filepath.Abs(shortConfigPath) + require.NoError(t, err) + if len(absShortConfigPath) > 97 { + t.Skipf("Config path too long: %d characters (max 97)", len(absShortConfigPath)) + } + + v := viper.New() + v.Set("project-root", projectRoot) + v.Set("non-interactive", true) + v.Set("trigger-index", 0) + v.Set("target", tt.capabilityName) + + var rpc settings.RpcEndpoint + rpc.ChainName = "ethereum-testnet-sepolia" + rpc.Url = "https://sepolia.infura.io/v3" + v.Set(fmt.Sprintf("%s.%s", tt.capabilityName, settings.RpcsSettingName), []settings.RpcEndpoint{rpc}) + v.Set(fmt.Sprintf("%s.%s", tt.capabilityName, settings.WorkflowNameSettingName), "limit-test-workflow") + v.Set(fmt.Sprintf("%s.%s", tt.capabilityName, settings.WorkflowPathSettingName), filepath.Join(limitTestWorkflowPath, "main.go")) + + var workflowSettings settings.WorkflowSettings + workflowSettings.UserWorkflowSettings.WorkflowName = "limit-test-workflow" + workflowSettings.WorkflowArtifactSettings.WorkflowPath = filepath.Join(limitTestWorkflowPath, "main.go") + workflowSettings.WorkflowArtifactSettings.OverrideFilePath = overrideFileName + workflowSettings.WorkflowArtifactSettings.ConfigPath = absShortConfigPath + + runtimeCtx := &runtime.Context{ + Logger: testutil.NewTestLogger(), + Viper: v, + Settings: &settings.Settings{ + Workflow: workflowSettings, + User: settings.UserSettings{ + TargetName: tt.capabilityName, + EthPrivateKey: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + }, + }, + } + + handler := newHandler(runtimeCtx) + inputs, err := handler.ResolveInputs(runtimeCtx.Viper, runtimeCtx.Settings) + require.NoError(t, err) + + err = handler.ValidateInputs(inputs) + require.NoError(t, err) + + var cfg cresettings.Workflows + cfgFn := func(cfg *cresettings.Workflows) { + cfg.ChainAllowed = commonsettings.PerChainSelector( + commonsettings.Bool(true), + map[string]bool{}, + ) + if runtimeCtx.Settings == nil { + return + } + overrideFilePath := runtimeCtx.Settings.Workflow.WorkflowArtifactSettings.OverrideFilePath + if overrideFilePath != "" { + workflowDir := filepath.Dir(runtimeCtx.Settings.Workflow.WorkflowArtifactSettings.WorkflowPath) + var absOverridePath string + if filepath.IsAbs(overrideFilePath) { + absOverridePath = overrideFilePath + } else { + absOverridePath = filepath.Join(workflowDir, overrideFilePath) + } + if overrideData, err := os.ReadFile(absOverridePath); err == nil { + var override struct { + HTTPCallLimit *int `json:"http-call-limit"` + ChainReadCallLimit *int `json:"chain-read-call-limit"` + ChainWriteTargetLimit *int `json:"chain-write-target-limit"` + ConsensusCallLimit *int `json:"consensus-call-limit"` + } + if err := json.Unmarshal(overrideData, &override); err == nil { + if override.HTTPCallLimit != nil && *override.HTTPCallLimit > 0 { + cfg.HTTPAction.CallLimit.DefaultValue = *override.HTTPCallLimit + } + if override.ChainReadCallLimit != nil && *override.ChainReadCallLimit > 0 { + cfg.ChainRead.CallLimit.DefaultValue = *override.ChainReadCallLimit + } + if override.ChainWriteTargetLimit != nil && *override.ChainWriteTargetLimit > 0 { + cfg.ChainWrite.TargetsLimit.DefaultValue = *override.ChainWriteTargetLimit + } + if override.ConsensusCallLimit != nil && *override.ConsensusCallLimit > 0 { + cfg.Consensus.CallLimit.DefaultValue = *override.ConsensusCallLimit + } + } + } + } + } + cfgFn(&cfg) + + err = handler.Execute(inputs) + if err != nil { + require.Contains(t, err.Error(), tt.expectedErrMsg, "Error should mention the capability that was limited") + } + t.Logf("Verified limit configuration for %s (limit=1). Workflow returns '%s' when limit is hit.", tt.name, tt.capabilityName) + }) + } +} + +func TestCapabilityLimits_Success(t *testing.T) { + _, thisFile, _, _ := rt.Caller(0) + thisDir := filepath.Dir(thisFile) + repoRoot := filepath.Clean(filepath.Join(thisDir, "..", "..", "..")) + projectRoot := filepath.Join(repoRoot, "test", "test_project") + + limitTests := []int{2, 3} + + for _, limit := range limitTests { + t.Run(fmt.Sprintf("limit_%d", limit), func(t *testing.T) { + _, thisFile, _, _ := rt.Caller(0) + thisDir := filepath.Dir(thisFile) + testdataDir := filepath.Join(thisDir, "testdata") + sourceOverridePath := filepath.Join(testdataDir, fmt.Sprintf("success-override-%d.json", limit)) + overrideFilePath := filepath.Join(limitTestWorkflowPath, "success-override.json") + + overrideData, err := os.ReadFile(sourceOverridePath) + require.NoError(t, err, "Failed to read override file from testdata") + require.NoError(t, os.WriteFile(overrideFilePath, overrideData, 0644)) + defer os.Remove(overrideFilePath) + + shortConfigPath := filepath.Join("/tmp", fmt.Sprintf("c%d.json", t.Name()[len(t.Name())-1])) + originalConfigPath := filepath.Join(limitTestWorkflowPath, "config.json") + configData, err := os.ReadFile(originalConfigPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(shortConfigPath, configData, 0644)) + defer os.Remove(shortConfigPath) + + absShortConfigPath, err := filepath.Abs(shortConfigPath) + require.NoError(t, err) + if len(absShortConfigPath) > 97 { + t.Skipf("Config path too long: %d characters (max 97)", len(absShortConfigPath)) + } + + v := viper.New() + v.Set("project-root", projectRoot) + v.Set("non-interactive", true) + v.Set("trigger-index", 0) + v.Set("target", "success") + + var rpc settings.RpcEndpoint + rpc.ChainName = "ethereum-testnet-sepolia" + rpc.Url = "https://sepolia.infura.io/v3" + v.Set(fmt.Sprintf("%s.%s", "success", settings.RpcsSettingName), []settings.RpcEndpoint{rpc}) + v.Set(fmt.Sprintf("%s.%s", "success", settings.WorkflowNameSettingName), "limit-test-workflow") + v.Set(fmt.Sprintf("%s.%s", "success", settings.WorkflowPathSettingName), filepath.Join(limitTestWorkflowPath, "main.go")) + + var workflowSettings settings.WorkflowSettings + workflowSettings.UserWorkflowSettings.WorkflowName = "limit-test-workflow" + workflowSettings.WorkflowArtifactSettings.WorkflowPath = filepath.Join(limitTestWorkflowPath, "main.go") + workflowSettings.WorkflowArtifactSettings.OverrideFilePath = "success-override.json" + workflowSettings.WorkflowArtifactSettings.ConfigPath = absShortConfigPath + + runtimeCtx := &runtime.Context{ + Logger: testutil.NewTestLogger(), + Viper: v, + Settings: &settings.Settings{ + Workflow: workflowSettings, + User: settings.UserSettings{ + TargetName: "success", + EthPrivateKey: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + }, + }, + } + + handler := newHandler(runtimeCtx) + inputs, err := handler.ResolveInputs(runtimeCtx.Viper, runtimeCtx.Settings) + require.NoError(t, err) + + err = handler.ValidateInputs(inputs) + require.NoError(t, err) + + err = handler.Execute(inputs) + if err != nil { + require.NotContains(t, err.Error(), "CallLimit limited", "Error should not be a limit error when limit=%d", limit) + } + t.Logf("Workflow executed with all limits set to %d (HTTP calls succeeded, may fail on RPC for chain operations)", limit) + }) + } +} diff --git a/internal/settings/settings_load.go b/internal/settings/settings_load.go index 7e56aec0..76371764 100644 --- a/internal/settings/settings_load.go +++ b/internal/settings/settings_load.go @@ -18,6 +18,7 @@ const ( WorkflowPathSettingName = "workflow-artifacts.workflow-path" ConfigPathSettingName = "workflow-artifacts.config-path" SecretsPathSettingName = "workflow-artifacts.secrets-path" + OverrideFilePathSettingName = "workflow-artifacts.override-file-path" SethConfigPathSettingName = "logging.seth-config-path" RegistriesSettingName = "contracts.registries" KeystoneSettingName = "contracts.keystone" diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index cf62e3d9..cf57ad70 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -18,9 +18,10 @@ type WorkflowSettings struct { WorkflowName string `mapstructure:"workflow-name" yaml:"workflow-name"` } `mapstructure:"user-workflow" yaml:"user-workflow"` WorkflowArtifactSettings struct { - WorkflowPath string `mapstructure:"workflow-path" yaml:"workflow-path"` - ConfigPath string `mapstructure:"config-path" yaml:"config-path"` - SecretsPath string `mapstructure:"secrets-path" yaml:"secrets-path"` + WorkflowPath string `mapstructure:"workflow-path" yaml:"workflow-path"` + ConfigPath string `mapstructure:"config-path" yaml:"config-path"` + SecretsPath string `mapstructure:"secrets-path" yaml:"secrets-path"` + OverrideFilePath string `mapstructure:"override-file-path" yaml:"override-file-path"` } `mapstructure:"workflow-artifacts" yaml:"workflow-artifacts"` LoggingSettings struct { SethConfigPath string `mapstructure:"seth-config-path" yaml:"seth-config-path"` @@ -63,6 +64,7 @@ func loadWorkflowSettings(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Com workflowSettings.WorkflowArtifactSettings.WorkflowPath = getSetting(WorkflowPathSettingName) workflowSettings.WorkflowArtifactSettings.ConfigPath = getSetting(ConfigPathSettingName) workflowSettings.WorkflowArtifactSettings.SecretsPath = getSetting(SecretsPathSettingName) + workflowSettings.WorkflowArtifactSettings.OverrideFilePath = getSetting(OverrideFilePathSettingName) workflowSettings.LoggingSettings.SethConfigPath = getSetting(SethConfigPathSettingName) fullRPCsKey := fmt.Sprintf("%s.%s", target, RpcsSettingName) if v.IsSet(fullRPCsKey) {