diff --git a/cmd/root.go b/cmd/root.go index 62dadcac..4504d1ea 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -424,30 +424,32 @@ func newRootCommand() *cobra.Command { func isLoadSettings(cmd *cobra.Command) bool { // It is not expected to have the settings file when running the following commands var excludedCommands = map[string]struct{}{ - "cre version": {}, - "cre login": {}, - "cre logout": {}, - "cre whoami": {}, - "cre account access": {}, - "cre account list-key": {}, - "cre init": {}, - "cre generate-bindings": {}, - "cre completion bash": {}, - "cre completion fish": {}, - "cre completion powershell": {}, - "cre completion zsh": {}, - "cre help": {}, - "cre update": {}, - "cre workflow": {}, - "cre workflow custom-build": {}, - "cre workflow build": {}, - "cre account": {}, - "cre secrets": {}, - "cre templates": {}, - "cre templates list": {}, - "cre templates add": {}, - "cre templates remove": {}, - "cre": {}, + "cre version": {}, + "cre login": {}, + "cre logout": {}, + "cre whoami": {}, + "cre account access": {}, + "cre account list-key": {}, + "cre init": {}, + "cre generate-bindings": {}, + "cre completion bash": {}, + "cre completion fish": {}, + "cre completion powershell": {}, + "cre completion zsh": {}, + "cre help": {}, + "cre update": {}, + "cre workflow": {}, + "cre workflow custom-build": {}, + "cre workflow limits": {}, + "cre workflow limits export": {}, + "cre workflow build": {}, + "cre account": {}, + "cre secrets": {}, + "cre templates": {}, + "cre templates list": {}, + "cre templates add": {}, + "cre templates remove": {}, + "cre": {}, } _, exists := excludedCommands[cmd.CommandPath()] @@ -457,26 +459,28 @@ func isLoadSettings(cmd *cobra.Command) bool { func isLoadCredentials(cmd *cobra.Command) bool { // It is not expected to have the credentials loaded when running the following commands var excludedCommands = map[string]struct{}{ - "cre version": {}, - "cre login": {}, - "cre logout": {}, - "cre completion bash": {}, - "cre completion fish": {}, - "cre completion powershell": {}, - "cre completion zsh": {}, - "cre help": {}, - "cre generate-bindings": {}, - "cre update": {}, - "cre workflow": {}, - "cre workflow build": {}, - "cre workflow hash": {}, - "cre account": {}, - "cre secrets": {}, - "cre templates": {}, - "cre templates list": {}, - "cre templates add": {}, - "cre templates remove": {}, - "cre": {}, + "cre version": {}, + "cre login": {}, + "cre logout": {}, + "cre completion bash": {}, + "cre completion fish": {}, + "cre completion powershell": {}, + "cre completion zsh": {}, + "cre help": {}, + "cre generate-bindings": {}, + "cre update": {}, + "cre workflow": {}, + "cre workflow limits": {}, + "cre workflow limits export": {}, + "cre account": {}, + "cre secrets": {}, + "cre workflow build": {}, + "cre workflow hash": {}, + "cre templates": {}, + "cre templates list": {}, + "cre templates add": {}, + "cre templates remove": {}, + "cre": {}, } _, exists := excludedCommands[cmd.CommandPath()] @@ -527,26 +531,28 @@ func shouldShowSpinner(cmd *cobra.Command) bool { // Don't show spinner for commands that don't do async work // or commands that have their own interactive UI (like init) var excludedCommands = map[string]struct{}{ - "cre": {}, - "cre version": {}, - "cre help": {}, - "cre completion bash": {}, - "cre completion fish": {}, - "cre completion powershell": {}, - "cre completion zsh": {}, - "cre init": {}, // Has its own Huh forms UI - "cre login": {}, // Has its own interactive flow - "cre logout": {}, - "cre update": {}, - "cre workflow": {}, // Just shows help - "cre workflow build": {}, // Offline command, no async init - "cre workflow hash": {}, // Offline command, has own spinner - "cre account": {}, // Just shows help - "cre secrets": {}, // Just shows help - "cre templates": {}, // Just shows help - "cre templates list": {}, - "cre templates add": {}, - "cre templates remove": {}, + "cre": {}, + "cre version": {}, + "cre help": {}, + "cre completion bash": {}, + "cre completion fish": {}, + "cre completion powershell": {}, + "cre completion zsh": {}, + "cre init": {}, // Has its own Huh forms UI + "cre login": {}, // Has its own interactive flow + "cre logout": {}, + "cre update": {}, + "cre workflow": {}, // Just shows help + "cre workflow limits": {}, // Just shows help + "cre workflow limits export": {}, // Static data, no project needed + "cre account": {}, // Just shows help + "cre workflow build": {}, // Offline command, no async init + "cre workflow hash": {}, // Offline command, has own spinner + "cre secrets": {}, // Just shows help + "cre templates": {}, // Just shows help + "cre templates list": {}, + "cre templates add": {}, + "cre templates remove": {}, } _, exists := excludedCommands[cmd.CommandPath()] diff --git a/cmd/workflow/limits/export.go b/cmd/workflow/limits/export.go new file mode 100644 index 00000000..4a206857 --- /dev/null +++ b/cmd/workflow/limits/export.go @@ -0,0 +1,37 @@ +package limits + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" +) + +func New() *cobra.Command { + limitsCmd := &cobra.Command{ + Use: "limits", + Short: "Manage simulation limits", + Long: `The limits command provides tools for managing workflow simulation limits.`, + } + + limitsCmd.AddCommand(newExportCmd()) + + return limitsCmd +} + +func newExportCmd() *cobra.Command { + return &cobra.Command{ + Use: "export", + Short: "Export default simulation limits as JSON", + Long: `Exports the default production simulation limits as JSON. +The output can be redirected to a file and customized.`, + Example: `cre workflow limits export > my-limits.json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + data := simulate.ExportDefaultLimitsJSON() + fmt.Println(string(data)) + return nil + }, + } +} diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 69f7273c..57cc2b5b 100644 --- a/cmd/workflow/simulate/capabilities.go +++ b/cmd/workflow/simulate/capabilities.go @@ -39,6 +39,7 @@ func NewManualTriggerCapabilities( registry *capabilities.Registry, cfg ManualTriggerCapabilitiesConfig, dryRunChainWrite bool, + limits *SimulationLimits, ) (*ManualTriggers, error) { // Cron manualCronTrigger := fakes.NewManualCronTriggerService(lggr) @@ -72,7 +73,13 @@ func NewManualTriggerCapabilities( dryRunChainWrite, ) - evmServer := evmserver.NewClientServer(evm) + // Wrap with limits enforcement if limits are enabled + var evmCap evmserver.ClientCapability = evm + if limits != nil { + evmCap = NewLimitedEVMChain(evm, limits) + } + + evmServer := evmserver.NewClientServer(evmCap) if err := registry.Add(ctx, evmServer); err != nil { return nil, err } @@ -129,7 +136,7 @@ func (m *ManualTriggers) Close() error { } // NewFakeCapabilities builds faked capabilities, then registers them with the capability registry. -func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry, secretsPath string) ([]services.Service, error) { +func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry, secretsPath string, limits *SimulationLimits) ([]services.Service, error) { caps := make([]services.Service, 0) // Consensus @@ -142,7 +149,11 @@ func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry signers = append(signers, signer) } fakeConsensusNoDAG := fakes.NewFakeConsensusNoDAG(signers, lggr) - fakeConsensusServer := consensusserver.NewConsensusServer(fakeConsensusNoDAG) + var consensusCap consensusserver.ConsensusCapability = fakeConsensusNoDAG + if limits != nil { + consensusCap = NewLimitedConsensusNoDAG(fakeConsensusNoDAG, limits) + } + fakeConsensusServer := consensusserver.NewConsensusServer(consensusCap) if err := registry.Add(ctx, fakeConsensusServer); err != nil { return nil, err } @@ -150,7 +161,11 @@ func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry // HTTP Action httpAction := fakes.NewDirectHTTPAction(lggr) - httpActionServer := httpserver.NewClientServer(httpAction) + var httpCap httpserver.ClientCapability = httpAction + if limits != nil { + httpCap = NewLimitedHTTPAction(httpAction, limits) + } + httpActionServer := httpserver.NewClientServer(httpCap) if err := registry.Add(ctx, httpActionServer); err != nil { return nil, err } @@ -158,7 +173,11 @@ func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry // Conf HTTP Action confHTTPAction := fakes.NewDirectConfidentialHTTPAction(lggr, secretsPath) - confHTTPActionServer := confhttpserver.NewClientServer(confHTTPAction) + var confHTTPCap confhttpserver.ClientCapability = confHTTPAction + if limits != nil { + confHTTPCap = NewLimitedConfidentialHTTPAction(confHTTPAction, limits) + } + confHTTPActionServer := confhttpserver.NewClientServer(confHTTPCap) if err := registry.Add(ctx, confHTTPActionServer); err != nil { return nil, err } diff --git a/cmd/workflow/simulate/limited_capabilities.go b/cmd/workflow/simulate/limited_capabilities.go new file mode 100644 index 00000000..3a48a850 --- /dev/null +++ b/cmd/workflow/simulate/limited_capabilities.go @@ -0,0 +1,284 @@ +package simulate + +import ( + "context" + "fmt" + "time" + + "google.golang.org/protobuf/proto" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp" + confhttpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp/server" + customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" + httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http/server" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" +) + +// --- LimitedHTTPAction --- + +// LimitedHTTPAction wraps an httpserver.ClientCapability and enforces request/response +// size limits and connection timeout from SimulationLimits. +type LimitedHTTPAction struct { + inner httpserver.ClientCapability + limits *SimulationLimits +} + +var _ httpserver.ClientCapability = (*LimitedHTTPAction)(nil) + +func NewLimitedHTTPAction(inner httpserver.ClientCapability, limits *SimulationLimits) *LimitedHTTPAction { + return &LimitedHTTPAction{inner: inner, limits: limits} +} + +func (l *LimitedHTTPAction) SendRequest(ctx context.Context, metadata commonCap.RequestMetadata, input *customhttp.Request) (*commonCap.ResponseAndMetadata[*customhttp.Response], caperrors.Error) { + // Check request body size + reqLimit := l.limits.HTTPRequestSizeLimit() + if reqLimit > 0 && len(input.GetBody()) > reqLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: HTTP request body size %d bytes exceeds limit of %d bytes", len(input.GetBody()), reqLimit), + caperrors.ResourceExhausted, + ) + } + + // Enforce connection timeout + connTimeout := l.limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue + if connTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(connTimeout)) + defer cancel() + } + + // Delegate to inner + resp, capErr := l.inner.SendRequest(ctx, metadata, input) + if capErr != nil { + return resp, capErr + } + + // Check response body size + respLimit := l.limits.HTTPResponseSizeLimit() + if resp != nil && resp.Response != nil && respLimit > 0 && len(resp.Response.GetBody()) > respLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: HTTP response body size %d bytes exceeds limit of %d bytes", len(resp.Response.GetBody()), respLimit), + caperrors.ResourceExhausted, + ) + } + + return resp, nil +} + +func (l *LimitedHTTPAction) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedHTTPAction) Close() error { return l.inner.Close() } +func (l *LimitedHTTPAction) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedHTTPAction) Name() string { return l.inner.Name() } +func (l *LimitedHTTPAction) Description() string { return l.inner.Description() } +func (l *LimitedHTTPAction) Ready() error { return l.inner.Ready() } +func (l *LimitedHTTPAction) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} + +// --- LimitedConfidentialHTTPAction --- + +// LimitedConfidentialHTTPAction wraps a confhttpserver.ClientCapability and enforces +// request/response size limits and connection timeout from SimulationLimits. +type LimitedConfidentialHTTPAction struct { + inner confhttpserver.ClientCapability + limits *SimulationLimits +} + +var _ confhttpserver.ClientCapability = (*LimitedConfidentialHTTPAction)(nil) + +func NewLimitedConfidentialHTTPAction(inner confhttpserver.ClientCapability, limits *SimulationLimits) *LimitedConfidentialHTTPAction { + return &LimitedConfidentialHTTPAction{inner: inner, limits: limits} +} + +func (l *LimitedConfidentialHTTPAction) SendRequest(ctx context.Context, metadata commonCap.RequestMetadata, input *confidentialhttp.ConfidentialHTTPRequest) (*commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse], caperrors.Error) { + // Check request size (body string or body bytes) + reqLimit := l.limits.ConfHTTPRequestSizeLimit() + if reqLimit > 0 && input.GetRequest() != nil { + reqSize := len(input.GetRequest().GetBodyString()) + len(input.GetRequest().GetBodyBytes()) + if reqSize > reqLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: confidential HTTP request body size %d bytes exceeds limit of %d bytes", reqSize, reqLimit), + caperrors.ResourceExhausted, + ) + } + } + + // Enforce connection timeout + connTimeout := l.limits.Workflows.ConfidentialHTTP.ConnectionTimeout.DefaultValue + if connTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(connTimeout)) + defer cancel() + } + + // Delegate to inner + resp, capErr := l.inner.SendRequest(ctx, metadata, input) + if capErr != nil { + return resp, capErr + } + + // Check response body size + respLimit := l.limits.ConfHTTPResponseSizeLimit() + if resp != nil && resp.Response != nil && respLimit > 0 && len(resp.Response.GetBody()) > respLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: confidential HTTP response body size %d bytes exceeds limit of %d bytes", len(resp.Response.GetBody()), respLimit), + caperrors.ResourceExhausted, + ) + } + + return resp, nil +} + +func (l *LimitedConfidentialHTTPAction) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedConfidentialHTTPAction) Close() error { return l.inner.Close() } +func (l *LimitedConfidentialHTTPAction) HealthReport() map[string]error { + return l.inner.HealthReport() +} +func (l *LimitedConfidentialHTTPAction) Name() string { return l.inner.Name() } +func (l *LimitedConfidentialHTTPAction) Description() string { return l.inner.Description() } +func (l *LimitedConfidentialHTTPAction) Ready() error { return l.inner.Ready() } +func (l *LimitedConfidentialHTTPAction) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} + +// --- LimitedConsensusNoDAG --- + +// LimitedConsensusNoDAG wraps a consensusserver.ConsensusCapability and enforces +// observation size limits from SimulationLimits. +type LimitedConsensusNoDAG struct { + inner consensusserver.ConsensusCapability + limits *SimulationLimits +} + +var _ consensusserver.ConsensusCapability = (*LimitedConsensusNoDAG)(nil) + +func NewLimitedConsensusNoDAG(inner consensusserver.ConsensusCapability, limits *SimulationLimits) *LimitedConsensusNoDAG { + return &LimitedConsensusNoDAG{inner: inner, limits: limits} +} + +func (l *LimitedConsensusNoDAG) Simple(ctx context.Context, metadata commonCap.RequestMetadata, input *sdkpb.SimpleConsensusInputs) (*commonCap.ResponseAndMetadata[*valuespb.Value], caperrors.Error) { + // Check observation size + obsLimit := l.limits.ConsensusObservationSizeLimit() + if obsLimit > 0 { + inputSize := proto.Size(input) + if inputSize > obsLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: consensus observation size %d bytes exceeds limit of %d bytes", inputSize, obsLimit), + caperrors.ResourceExhausted, + ) + } + } + + return l.inner.Simple(ctx, metadata, input) +} + +func (l *LimitedConsensusNoDAG) Report(ctx context.Context, metadata commonCap.RequestMetadata, input *sdkpb.ReportRequest) (*commonCap.ResponseAndMetadata[*sdkpb.ReportResponse], caperrors.Error) { + // Report size is engine-enforced, delegate as-is + return l.inner.Report(ctx, metadata, input) +} + +func (l *LimitedConsensusNoDAG) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedConsensusNoDAG) Close() error { return l.inner.Close() } +func (l *LimitedConsensusNoDAG) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedConsensusNoDAG) Name() string { return l.inner.Name() } +func (l *LimitedConsensusNoDAG) Description() string { return l.inner.Description() } +func (l *LimitedConsensusNoDAG) Ready() error { return l.inner.Ready() } +func (l *LimitedConsensusNoDAG) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} + +// --- LimitedEVMChain --- + +// LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write +// report size and gas limits from SimulationLimits. +type LimitedEVMChain struct { + inner evmserver.ClientCapability + limits *SimulationLimits +} + +var _ evmserver.ClientCapability = (*LimitedEVMChain)(nil) + +func NewLimitedEVMChain(inner evmserver.ClientCapability, limits *SimulationLimits) *LimitedEVMChain { + return &LimitedEVMChain{inner: inner, limits: limits} +} + +func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + // Check report size + reportLimit := l.limits.ChainWriteReportSizeLimit() + if reportLimit > 0 && input.Report != nil && len(input.Report.RawReport) > reportLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), reportLimit), + caperrors.ResourceExhausted, + ) + } + + // Check gas limit + gasLimit := l.limits.ChainWriteEVMGasLimit() + if gasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > gasLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, gasLimit), + caperrors.ResourceExhausted, + ) + } + + return l.inner.WriteReport(ctx, metadata, input) +} + +// All other methods delegate to the inner capability. +func (l *LimitedEVMChain) CallContract(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + return l.inner.CallContract(ctx, metadata, input) +} + +func (l *LimitedEVMChain) FilterLogs(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + return l.inner.FilterLogs(ctx, metadata, input) +} + +func (l *LimitedEVMChain) BalanceAt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + return l.inner.BalanceAt(ctx, metadata, input) +} + +func (l *LimitedEVMChain) EstimateGas(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + return l.inner.EstimateGas(ctx, metadata, input) +} + +func (l *LimitedEVMChain) GetTransactionByHash(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + return l.inner.GetTransactionByHash(ctx, metadata, input) +} + +func (l *LimitedEVMChain) GetTransactionReceipt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + return l.inner.GetTransactionReceipt(ctx, metadata, input) +} + +func (l *LimitedEVMChain) HeaderByNumber(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + return l.inner.HeaderByNumber(ctx, metadata, input) +} + +func (l *LimitedEVMChain) RegisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + return l.inner.RegisterLogTrigger(ctx, triggerID, metadata, input) +} + +func (l *LimitedEVMChain) UnregisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) caperrors.Error { + return l.inner.UnregisterLogTrigger(ctx, triggerID, metadata, input) +} + +func (l *LimitedEVMChain) ChainSelector() uint64 { return l.inner.ChainSelector() } +func (l *LimitedEVMChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedEVMChain) Close() error { return l.inner.Close() } +func (l *LimitedEVMChain) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedEVMChain) Name() string { return l.inner.Name() } +func (l *LimitedEVMChain) Description() string { return l.inner.Description() } +func (l *LimitedEVMChain) Ready() error { return l.inner.Ready() } +func (l *LimitedEVMChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} + +func (l *LimitedEVMChain) AckEvent(ctx context.Context, triggerId string, eventId string, method string) caperrors.Error { + return l.inner.AckEvent(ctx, triggerId, eventId, method) +} diff --git a/cmd/workflow/simulate/limited_capabilities_test.go b/cmd/workflow/simulate/limited_capabilities_test.go new file mode 100644 index 00000000..9a8a0016 --- /dev/null +++ b/cmd/workflow/simulate/limited_capabilities_test.go @@ -0,0 +1,441 @@ +package simulate + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp" + customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" +) + +type capabilityBaseStub struct{} + +func (capabilityBaseStub) Start(context.Context) error { return nil } +func (capabilityBaseStub) Close() error { return nil } +func (capabilityBaseStub) HealthReport() map[string]error { + return map[string]error{} +} +func (capabilityBaseStub) Name() string { return "stub" } +func (capabilityBaseStub) Description() string { return "stub" } +func (capabilityBaseStub) Ready() error { return nil } +func (capabilityBaseStub) Initialise(context.Context, core.StandardCapabilitiesDependencies) error { + return nil +} + +type httpClientCapabilityStub struct { + capabilityBaseStub + sendRequestFn func(context.Context, commonCap.RequestMetadata, *customhttp.Request) (*commonCap.ResponseAndMetadata[*customhttp.Response], caperrors.Error) + sendRequestCalls int +} + +func (s *httpClientCapabilityStub) SendRequest(ctx context.Context, metadata commonCap.RequestMetadata, input *customhttp.Request) (*commonCap.ResponseAndMetadata[*customhttp.Response], caperrors.Error) { + s.sendRequestCalls++ + if s.sendRequestFn != nil { + return s.sendRequestFn(ctx, metadata, input) + } + return nil, nil +} + +type confidentialHTTPClientCapabilityStub struct { + capabilityBaseStub + sendRequestFn func(context.Context, commonCap.RequestMetadata, *confidentialhttp.ConfidentialHTTPRequest) (*commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse], caperrors.Error) + sendRequestCalls int +} + +func (s *confidentialHTTPClientCapabilityStub) SendRequest(ctx context.Context, metadata commonCap.RequestMetadata, input *confidentialhttp.ConfidentialHTTPRequest) (*commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse], caperrors.Error) { + s.sendRequestCalls++ + if s.sendRequestFn != nil { + return s.sendRequestFn(ctx, metadata, input) + } + return nil, nil +} + +type consensusCapabilityStub struct { + capabilityBaseStub + simpleFn func(context.Context, commonCap.RequestMetadata, *sdkpb.SimpleConsensusInputs) (*commonCap.ResponseAndMetadata[*valuespb.Value], caperrors.Error) + reportFn func(context.Context, commonCap.RequestMetadata, *sdkpb.ReportRequest) (*commonCap.ResponseAndMetadata[*sdkpb.ReportResponse], caperrors.Error) + simpleCalls int + reportCalls int +} + +func (s *consensusCapabilityStub) Simple(ctx context.Context, metadata commonCap.RequestMetadata, input *sdkpb.SimpleConsensusInputs) (*commonCap.ResponseAndMetadata[*valuespb.Value], caperrors.Error) { + s.simpleCalls++ + if s.simpleFn != nil { + return s.simpleFn(ctx, metadata, input) + } + return nil, nil +} + +func (s *consensusCapabilityStub) Report(ctx context.Context, metadata commonCap.RequestMetadata, input *sdkpb.ReportRequest) (*commonCap.ResponseAndMetadata[*sdkpb.ReportResponse], caperrors.Error) { + s.reportCalls++ + if s.reportFn != nil { + return s.reportFn(ctx, metadata, input) + } + return nil, nil +} + +type evmClientCapabilityStub struct { + capabilityBaseStub + writeReportFn func(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) + writeReportCalls int +} + +func (s *evmClientCapabilityStub) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { + return nil +} + +func (s *evmClientCapabilityStub) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + s.writeReportCalls++ + if s.writeReportFn != nil { + return s.writeReportFn(ctx, metadata, input) + } + return nil, nil +} + +func (s *evmClientCapabilityStub) AckEvent(context.Context, string, string, string) caperrors.Error { + return nil +} + +func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } + +func newTestLimits(t *testing.T) *SimulationLimits { + t.Helper() + limits, err := DefaultLimits() + require.NoError(t, err) + return limits +} + +func TestLimitedHTTPActionRejectsOversizedRequest(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.HTTPAction.RequestSizeLimit.DefaultValue = 4 + + inner := &httpClientCapabilityStub{} + wrapper := NewLimitedHTTPAction(inner, limits) + + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &customhttp.Request{Body: []byte("12345")}) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "HTTP request body size 5 bytes exceeds limit of 4 bytes") + assert.Equal(t, 0, inner.sendRequestCalls) +} + +func TestLimitedHTTPActionAppliesTimeoutAndAllowsBoundarySizedPayloads(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.HTTPAction.RequestSizeLimit.DefaultValue = 4 + limits.Workflows.HTTPAction.ResponseSizeLimit.DefaultValue = 5 + limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue = 2 * time.Second + + inner := &httpClientCapabilityStub{ + sendRequestFn: func(ctx context.Context, _ commonCap.RequestMetadata, input *customhttp.Request) (*commonCap.ResponseAndMetadata[*customhttp.Response], caperrors.Error) { + deadline, ok := ctx.Deadline() + require.True(t, ok) + remaining := time.Until(deadline) + assert.LessOrEqual(t, remaining, 2*time.Second) + assert.Greater(t, remaining, time.Second) + assert.Equal(t, []byte("1234"), input.GetBody()) + return &commonCap.ResponseAndMetadata[*customhttp.Response]{ + Response: &customhttp.Response{Body: []byte("12345")}, + }, nil + }, + } + + wrapper := NewLimitedHTTPAction(inner, limits) + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &customhttp.Request{Body: []byte("1234")}) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, []byte("12345"), resp.Response.GetBody()) + assert.Equal(t, 1, inner.sendRequestCalls) +} + +func TestLimitedHTTPActionRejectsOversizedResponse(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.HTTPAction.ResponseSizeLimit.DefaultValue = 3 + + inner := &httpClientCapabilityStub{ + sendRequestFn: func(context.Context, commonCap.RequestMetadata, *customhttp.Request) (*commonCap.ResponseAndMetadata[*customhttp.Response], caperrors.Error) { + return &commonCap.ResponseAndMetadata[*customhttp.Response]{ + Response: &customhttp.Response{Body: []byte("1234")}, + }, nil + }, + } + + wrapper := NewLimitedHTTPAction(inner, limits) + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &customhttp.Request{}) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "HTTP response body size 4 bytes exceeds limit of 3 bytes") + assert.Equal(t, 1, inner.sendRequestCalls) +} + +func TestLimitedHTTPActionPassesThroughInnerError(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + expectedResp := &commonCap.ResponseAndMetadata[*customhttp.Response]{Response: &customhttp.Response{Body: []byte("ok")}} + expectedErr := caperrors.NewPublicUserError(errors.New("boom"), caperrors.ResourceExhausted) + + inner := &httpClientCapabilityStub{ + sendRequestFn: func(context.Context, commonCap.RequestMetadata, *customhttp.Request) (*commonCap.ResponseAndMetadata[*customhttp.Response], caperrors.Error) { + return expectedResp, expectedErr + }, + } + + wrapper := NewLimitedHTTPAction(inner, limits) + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &customhttp.Request{}) + require.Error(t, err) + assert.Same(t, expectedResp, resp) + assert.True(t, expectedErr.Equals(err)) + assert.Equal(t, 1, inner.sendRequestCalls) +} + +func TestLimitedConfidentialHTTPActionRejectsOversizedRequest(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.ConfidentialHTTP.RequestSizeLimit.DefaultValue = 4 + + inner := &confidentialHTTPClientCapabilityStub{} + wrapper := NewLimitedConfidentialHTTPAction(inner, limits) + + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &confidentialhttp.ConfidentialHTTPRequest{ + Request: &confidentialhttp.HTTPRequest{Body: &confidentialhttp.HTTPRequest_BodyString{BodyString: "12345"}}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "confidential HTTP request body size 5 bytes exceeds limit of 4 bytes") + assert.Equal(t, 0, inner.sendRequestCalls) +} + +func TestLimitedConfidentialHTTPActionAppliesTimeoutAndAllowsBoundarySizedPayloads(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.ConfidentialHTTP.RequestSizeLimit.DefaultValue = 4 + limits.Workflows.ConfidentialHTTP.ResponseSizeLimit.DefaultValue = 5 + limits.Workflows.ConfidentialHTTP.ConnectionTimeout.DefaultValue = 2 * time.Second + + inner := &confidentialHTTPClientCapabilityStub{ + sendRequestFn: func(ctx context.Context, _ commonCap.RequestMetadata, input *confidentialhttp.ConfidentialHTTPRequest) (*commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse], caperrors.Error) { + deadline, ok := ctx.Deadline() + require.True(t, ok) + remaining := time.Until(deadline) + assert.LessOrEqual(t, remaining, 2*time.Second) + assert.Greater(t, remaining, time.Second) + assert.Equal(t, []byte("1234"), input.GetRequest().GetBodyBytes()) + return &commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse]{ + Response: &confidentialhttp.HTTPResponse{Body: []byte("12345")}, + }, nil + }, + } + + wrapper := NewLimitedConfidentialHTTPAction(inner, limits) + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &confidentialhttp.ConfidentialHTTPRequest{ + Request: &confidentialhttp.HTTPRequest{Body: &confidentialhttp.HTTPRequest_BodyBytes{BodyBytes: []byte("1234")}}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, []byte("12345"), resp.Response.GetBody()) + assert.Equal(t, 1, inner.sendRequestCalls) +} + +func TestLimitedConfidentialHTTPActionRejectsOversizedResponse(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.ConfidentialHTTP.ResponseSizeLimit.DefaultValue = 3 + + inner := &confidentialHTTPClientCapabilityStub{ + sendRequestFn: func(context.Context, commonCap.RequestMetadata, *confidentialhttp.ConfidentialHTTPRequest) (*commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse], caperrors.Error) { + return &commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse]{ + Response: &confidentialhttp.HTTPResponse{Body: []byte("1234")}, + }, nil + }, + } + + wrapper := NewLimitedConfidentialHTTPAction(inner, limits) + resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &confidentialhttp.ConfidentialHTTPRequest{}) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "confidential HTTP response body size 4 bytes exceeds limit of 3 bytes") + assert.Equal(t, 1, inner.sendRequestCalls) +} + +func TestLimitedConsensusNoDAGSimpleRejectsOversizedObservation(t *testing.T) { + t.Parallel() + + input := &sdkpb.SimpleConsensusInputs{ + Observation: &sdkpb.SimpleConsensusInputs_Error{Error: strings.Repeat("x", 64)}, + } + + limits := newTestLimits(t) + limits.Workflows.Consensus.ObservationSizeLimit.DefaultValue = config.Size(proto.Size(input) - 1) + + inner := &consensusCapabilityStub{} + wrapper := NewLimitedConsensusNoDAG(inner, limits) + + resp, err := wrapper.Simple(context.Background(), commonCap.RequestMetadata{}, input) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "consensus observation size") + assert.Equal(t, 0, inner.simpleCalls) +} + +func TestLimitedConsensusNoDAGSimpleDelegatesWhenWithinLimit(t *testing.T) { + t.Parallel() + + input := &sdkpb.SimpleConsensusInputs{ + Observation: &sdkpb.SimpleConsensusInputs_Error{Error: "ok"}, + } + + limits := newTestLimits(t) + limits.Workflows.Consensus.ObservationSizeLimit.DefaultValue = config.Size(proto.Size(input)) + expectedResp := &commonCap.ResponseAndMetadata[*valuespb.Value]{Response: &valuespb.Value{}} + + inner := &consensusCapabilityStub{ + simpleFn: func(_ context.Context, _ commonCap.RequestMetadata, got *sdkpb.SimpleConsensusInputs) (*commonCap.ResponseAndMetadata[*valuespb.Value], caperrors.Error) { + assert.Same(t, input, got) + return expectedResp, nil + }, + } + + wrapper := NewLimitedConsensusNoDAG(inner, limits) + resp, err := wrapper.Simple(context.Background(), commonCap.RequestMetadata{}, input) + require.NoError(t, err) + assert.Same(t, expectedResp, resp) + assert.Equal(t, 1, inner.simpleCalls) +} + +func TestLimitedConsensusNoDAGReportDelegates(t *testing.T) { + t.Parallel() + + input := &sdkpb.ReportRequest{EncodedPayload: []byte("payload")} + expectedResp := &commonCap.ResponseAndMetadata[*sdkpb.ReportResponse]{Response: &sdkpb.ReportResponse{RawReport: []byte("report")}} + + inner := &consensusCapabilityStub{ + reportFn: func(_ context.Context, _ commonCap.RequestMetadata, got *sdkpb.ReportRequest) (*commonCap.ResponseAndMetadata[*sdkpb.ReportResponse], caperrors.Error) { + assert.Same(t, input, got) + return expectedResp, nil + }, + } + + wrapper := NewLimitedConsensusNoDAG(inner, newTestLimits(t)) + resp, err := wrapper.Report(context.Background(), commonCap.RequestMetadata{}, input) + require.NoError(t, err) + assert.Same(t, expectedResp, resp) + assert.Equal(t, 1, inner.reportCalls) +} + +func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.ChainWrite.ReportSizeLimit.DefaultValue = 4 + + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, limits) + + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "chain write report size 5 bytes exceeds limit of 4 bytes") + assert.Equal(t, 0, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 10 + + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, limits) + + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 11}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "EVM gas limit 11 exceeds maximum of 10") + assert.Equal(t, 0, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { + t.Parallel() + + limits := newTestLimits(t) + limits.Workflows.ChainWrite.ReportSizeLimit.DefaultValue = 4 + limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 10 + + input := &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, + GasConfig: &evmcappb.GasConfig{GasLimit: 10}, + } + expectedResp := &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{Response: &evmcappb.WriteReportReply{}} + + inner := &evmClientCapabilityStub{ + writeReportFn: func(_ context.Context, _ commonCap.RequestMetadata, got *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + assert.Same(t, input, got) + return expectedResp, nil + }, + } + + wrapper := NewLimitedEVMChain(inner, limits) + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, input) + require.NoError(t, err) + assert.Same(t, expectedResp, resp) + assert.Equal(t, 1, inner.writeReportCalls) +} diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go new file mode 100644 index 00000000..4feffa84 --- /dev/null +++ b/cmd/workflow/simulate/limits.go @@ -0,0 +1,177 @@ +package simulate + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" +) + +//go:embed limits.json +var defaultLimitsJSON []byte + +// SimulationLimits holds the workflow-level limits applied during simulation. +type SimulationLimits struct { + Workflows cresettings.Workflows +} + +// DefaultLimits returns simulation limits populated from the embedded defaults. +func DefaultLimits() (*SimulationLimits, error) { + return parseLimitsJSON(defaultLimitsJSON) +} + +// LoadLimits reads a limits JSON file from disk and returns parsed SimulationLimits. +func LoadLimits(path string) (*SimulationLimits, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read limits file %s: %w", path, err) + } + return parseLimitsJSON(data) +} + +func parseLimitsJSON(data []byte) (*SimulationLimits, error) { + // Start from the pre-built Default which has all Parse functions configured. + // Setting[T].Parse is a function closure (json:"-") that cannot be serialized, + // so we must unmarshal into a copy that already has Parse funcs set. + w := cresettings.Default.PerWorkflow + if err := json.Unmarshal(data, &w); err != nil { + return nil, fmt.Errorf("failed to parse limits JSON: %w", err) + } + return &SimulationLimits{Workflows: w}, nil +} + +// applyEngineLimits copies limit values from the SimulationLimits into the +// engine's workflow settings config. ChainAllowed is intentionally left as +// allow-all for simulation. +func applyEngineLimits(cfg *cresettings.Workflows, limits *SimulationLimits) { + src := &limits.Workflows + + // Execution limits + cfg.ExecutionTimeout = src.ExecutionTimeout + cfg.ExecutionResponseLimit = src.ExecutionResponseLimit + cfg.ExecutionConcurrencyLimit = src.ExecutionConcurrencyLimit + + // Capability limits + cfg.CapabilityConcurrencyLimit = src.CapabilityConcurrencyLimit + cfg.CapabilityCallTimeout = src.CapabilityCallTimeout + cfg.SecretsConcurrencyLimit = src.SecretsConcurrencyLimit + + // Trigger limits + cfg.TriggerRegistrationsTimeout = src.TriggerRegistrationsTimeout + cfg.TriggerEventQueueLimit = src.TriggerEventQueueLimit + cfg.TriggerEventQueueTimeout = src.TriggerEventQueueTimeout + cfg.TriggerSubscriptionTimeout = src.TriggerSubscriptionTimeout + cfg.TriggerSubscriptionLimit = src.TriggerSubscriptionLimit + + // WASM limits + cfg.WASMMemoryLimit = src.WASMMemoryLimit + cfg.WASMBinarySizeLimit = src.WASMBinarySizeLimit + cfg.WASMCompressedBinarySizeLimit = src.WASMCompressedBinarySizeLimit + cfg.WASMConfigSizeLimit = src.WASMConfigSizeLimit + cfg.WASMSecretsSizeLimit = src.WASMSecretsSizeLimit + + // Log limits + cfg.LogLineLimit = src.LogLineLimit + cfg.LogEventLimit = src.LogEventLimit + + // Call count limits + cfg.ChainRead = src.ChainRead + cfg.ChainWrite.TargetsLimit = src.ChainWrite.TargetsLimit + cfg.Consensus.CallLimit = src.Consensus.CallLimit + cfg.HTTPAction.CallLimit = src.HTTPAction.CallLimit + cfg.ConfidentialHTTP.CallLimit = src.ConfidentialHTTP.CallLimit + cfg.Secrets = src.Secrets + + // Trigger-specific limits + cfg.CRONTrigger = src.CRONTrigger + cfg.HTTPTrigger = src.HTTPTrigger + cfg.LogTrigger = src.LogTrigger + + // NOTE: ChainAllowed is NOT overridden — simulation keeps allow-all +} + +// HTTPRequestSizeLimit returns the HTTP action request size limit in bytes. +func (l *SimulationLimits) HTTPRequestSizeLimit() int { + return int(l.Workflows.HTTPAction.RequestSizeLimit.DefaultValue) +} + +// HTTPResponseSizeLimit returns the HTTP action response size limit in bytes. +func (l *SimulationLimits) HTTPResponseSizeLimit() int { + return int(l.Workflows.HTTPAction.ResponseSizeLimit.DefaultValue) +} + +// ConfHTTPRequestSizeLimit returns the confidential HTTP request size limit in bytes. +func (l *SimulationLimits) ConfHTTPRequestSizeLimit() int { + return int(l.Workflows.ConfidentialHTTP.RequestSizeLimit.DefaultValue) +} + +// ConfHTTPResponseSizeLimit returns the confidential HTTP response size limit in bytes. +func (l *SimulationLimits) ConfHTTPResponseSizeLimit() int { + return int(l.Workflows.ConfidentialHTTP.ResponseSizeLimit.DefaultValue) +} + +// ConsensusObservationSizeLimit returns the consensus observation size limit in bytes. +func (l *SimulationLimits) ConsensusObservationSizeLimit() int { + return int(l.Workflows.Consensus.ObservationSizeLimit.DefaultValue) +} + +// ChainWriteReportSizeLimit returns the chain write report size limit in bytes. +func (l *SimulationLimits) ChainWriteReportSizeLimit() int { + return int(l.Workflows.ChainWrite.ReportSizeLimit.DefaultValue) +} + +// ChainWriteEVMGasLimit returns the default EVM gas limit. +func (l *SimulationLimits) ChainWriteEVMGasLimit() uint64 { + return l.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue +} + +// WASMBinarySize returns the WASM binary size limit in bytes. +func (l *SimulationLimits) WASMBinarySize() int { + return int(l.Workflows.WASMBinarySizeLimit.DefaultValue) +} + +// WASMCompressedBinarySize returns the WASM compressed binary size limit in bytes. +func (l *SimulationLimits) WASMCompressedBinarySize() int { + return int(l.Workflows.WASMCompressedBinarySizeLimit.DefaultValue) +} + +// LimitsSummary returns a human-readable summary of key limits. +func (l *SimulationLimits) LimitsSummary() string { + w := &l.Workflows + return fmt.Sprintf( + "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite report=%s gas=%d | WASM binary=%s compressed=%s", + w.HTTPAction.RequestSizeLimit.DefaultValue, + w.HTTPAction.ResponseSizeLimit.DefaultValue, + w.HTTPAction.ConnectionTimeout.DefaultValue, + w.ConfidentialHTTP.RequestSizeLimit.DefaultValue, + w.ConfidentialHTTP.ResponseSizeLimit.DefaultValue, + w.ConfidentialHTTP.ConnectionTimeout.DefaultValue, + w.Consensus.ObservationSizeLimit.DefaultValue, + w.ChainWrite.ReportSizeLimit.DefaultValue, + w.ChainWrite.EVM.GasLimit.Default.DefaultValue, + w.WASMBinarySizeLimit.DefaultValue, + w.WASMCompressedBinarySizeLimit.DefaultValue, + ) +} + +// ExportDefaultLimitsJSON returns the embedded default limits JSON. +func ExportDefaultLimitsJSON() []byte { + return defaultLimitsJSON +} + +// ResolveLimits resolves a --limits flag value to SimulationLimits. +// Returns nil if limitsFlag is "none" (no limits enforcement). +func ResolveLimits(limitsFlag string) (*SimulationLimits, error) { + if limitsFlag == "none" { + return nil, nil + } + + if strings.TrimSpace(limitsFlag) == "" || limitsFlag == "default" { + return DefaultLimits() + } + + return LoadLimits(limitsFlag) +} diff --git a/cmd/workflow/simulate/limits.json b/cmd/workflow/simulate/limits.json new file mode 100644 index 00000000..ced46eeb --- /dev/null +++ b/cmd/workflow/simulate/limits.json @@ -0,0 +1,69 @@ +{ + "TriggerRegistrationsTimeout": "10s", + "TriggerSubscriptionTimeout": "15s", + "TriggerSubscriptionLimit": "10", + "TriggerEventQueueLimit": "50", + "TriggerEventQueueTimeout": "10m0s", + "CapabilityConcurrencyLimit": "30", + "CapabilityCallTimeout": "3m0s", + "SecretsConcurrencyLimit": "5", + "ExecutionConcurrencyLimit": "5", + "ExecutionTimeout": "5m0s", + "ExecutionResponseLimit": "100kb", + "ExecutionTimestampsEnabled": "false", + "WASMMemoryLimit": "100mb", + "WASMBinarySizeLimit": "100mb", + "WASMCompressedBinarySizeLimit": "20mb", + "WASMConfigSizeLimit": "1mb", + "WASMSecretsSizeLimit": "1mb", + "LogLineLimit": "1kb", + "LogEventLimit": "1000", + "CRONTrigger": { + "FastestScheduleInterval": "30s" + }, + "HTTPTrigger": { + "RateLimit": "every30s:3" + }, + "LogTrigger": { + "EventRateLimit": "every6s:10", + "EventSizeLimit": "5kb", + "FilterAddressLimit": "5", + "FilterTopicsPerSlotLimit": "10" + }, + "ChainWrite": { + "TargetsLimit": "10", + "ReportSizeLimit": "5kb", + "EVM": { + "TransactionGasLimit": "5000000", + "GasLimit": { + "Default": "5000000", + "Values": {} + } + } + }, + "ChainRead": { + "CallLimit": "15", + "LogQueryBlockLimit": "100", + "PayloadSizeLimit": "5kb" + }, + "Consensus": { + "ObservationSizeLimit": "100kb", + "CallLimit": "20" + }, + "HTTPAction": { + "CallLimit": "5", + "CacheAgeLimit": "10m0s", + "ConnectionTimeout": "10s", + "RequestSizeLimit": "10kb", + "ResponseSizeLimit": "100kb" + }, + "ConfidentialHTTP": { + "CallLimit": "5", + "ConnectionTimeout": "10s", + "RequestSizeLimit": "10kb", + "ResponseSizeLimit": "100kb" + }, + "Secrets": { + "CallLimit": "5" + } +} diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go new file mode 100644 index 00000000..487adbf4 --- /dev/null +++ b/cmd/workflow/simulate/limits_test.go @@ -0,0 +1,178 @@ +package simulate + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" +) + +func writeLimitsFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "limits.json") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestDefaultLimitsAndExportDefaultLimitsJSON(t *testing.T) { + t.Parallel() + + limits, err := DefaultLimits() + require.NoError(t, err) + + assert.Equal(t, 10_000, limits.HTTPRequestSizeLimit()) + assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit()) + assert.Equal(t, 10_000, limits.ConfHTTPRequestSizeLimit()) + assert.Equal(t, 100_000, limits.ConfHTTPResponseSizeLimit()) + assert.Equal(t, 100_000, limits.ConsensusObservationSizeLimit()) + assert.Equal(t, 5_000, limits.ChainWriteReportSizeLimit()) + assert.Equal(t, uint64(5_000_000), limits.ChainWriteEVMGasLimit()) + assert.Equal(t, 100_000_000, limits.WASMBinarySize()) + assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) + assert.JSONEq(t, string(defaultLimitsJSON), string(ExportDefaultLimitsJSON())) +} + +func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing.T) { + t.Parallel() + + path := writeLimitsFile(t, `{ + "HTTPAction": { + "RequestSizeLimit": "7kb", + "ConnectionTimeout": "2s" + }, + "ChainWrite": { + "ReportSizeLimit": "9kb", + "EVM": { + "GasLimit": { + "Default": "123" + } + } + } + }`) + + limits, err := LoadLimits(path) + require.NoError(t, err) + + assert.Equal(t, 7_000, limits.HTTPRequestSizeLimit()) + assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit(), "unset values should keep embedded defaults") + assert.Equal(t, 9_000, limits.ChainWriteReportSizeLimit()) + assert.Equal(t, uint64(123), limits.ChainWriteEVMGasLimit()) + assert.Equal(t, 2*time.Second, limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue) +} + +func TestLoadLimitsReturnsHelpfulErrors(t *testing.T) { + t.Parallel() + + t.Run("missing file", func(t *testing.T) { + _, err := LoadLimits(filepath.Join(t.TempDir(), "missing.json")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read limits file") + }) + + t.Run("invalid json", func(t *testing.T) { + path := writeLimitsFile(t, `{invalid json`) + _, err := LoadLimits(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse limits JSON") + }) +} + +func TestResolveLimitsHandlesAllSupportedModes(t *testing.T) { + t.Parallel() + + flag := "none" + limits, err := ResolveLimits(flag) + require.NoError(t, err) + assert.Nil(t, limits) + + defaultLimits, err := ResolveLimits("default") + require.NoError(t, err) + baseline, err := DefaultLimits() + require.NoError(t, err) + assert.Equal(t, baseline.HTTPRequestSizeLimit(), defaultLimits.HTTPRequestSizeLimit()) + assert.Equal(t, baseline.ChainWriteEVMGasLimit(), defaultLimits.ChainWriteEVMGasLimit()) + + path := writeLimitsFile(t, `{"Consensus":{"ObservationSizeLimit":"2kb"}}`) + customLimits, err := ResolveLimits(path) + require.NoError(t, err) + assert.Equal(t, 2_000, customLimits.ConsensusObservationSizeLimit()) +} + +func TestApplyEngineLimitsCopiesSupportedFieldsAndPreservesChainAllowed(t *testing.T) { + t.Parallel() + + cfg := cresettings.Default.PerWorkflow + cfg.ChainAllowed.Default.DefaultValue = true + + limits := newTestLimits(t) + limits.Workflows.ExecutionTimeout.DefaultValue = 11 * time.Second + limits.Workflows.ExecutionResponseLimit.DefaultValue = 2048 + limits.Workflows.ExecutionConcurrencyLimit.DefaultValue = 9 + limits.Workflows.CapabilityConcurrencyLimit.DefaultValue = 7 + limits.Workflows.CapabilityCallTimeout.DefaultValue = 12 * time.Second + limits.Workflows.SecretsConcurrencyLimit.DefaultValue = 6 + limits.Workflows.TriggerRegistrationsTimeout.DefaultValue = 13 * time.Second + limits.Workflows.TriggerEventQueueLimit.DefaultValue = 14 + limits.Workflows.TriggerEventQueueTimeout.DefaultValue = 15 * time.Second + limits.Workflows.TriggerSubscriptionTimeout.DefaultValue = 16 * time.Second + limits.Workflows.TriggerSubscriptionLimit.DefaultValue = 17 + limits.Workflows.WASMMemoryLimit.DefaultValue = 4096 + limits.Workflows.WASMBinarySizeLimit.DefaultValue = 8192 + limits.Workflows.WASMCompressedBinarySizeLimit.DefaultValue = 1024 + limits.Workflows.WASMConfigSizeLimit.DefaultValue = 512 + limits.Workflows.WASMSecretsSizeLimit.DefaultValue = 256 + limits.Workflows.LogLineLimit.DefaultValue = 128 + limits.Workflows.LogEventLimit.DefaultValue = 25 + limits.Workflows.ChainRead.CallLimit.DefaultValue = 3 + limits.Workflows.ChainWrite.TargetsLimit.DefaultValue = 4 + limits.Workflows.Consensus.CallLimit.DefaultValue = 5 + limits.Workflows.HTTPAction.CallLimit.DefaultValue = 6 + limits.Workflows.ConfidentialHTTP.CallLimit.DefaultValue = 7 + limits.Workflows.Secrets.CallLimit.DefaultValue = 8 + limits.Workflows.CRONTrigger.FastestScheduleInterval.DefaultValue = 30 * time.Second + + applyEngineLimits(&cfg, limits) + + assert.Equal(t, 11*time.Second, cfg.ExecutionTimeout.DefaultValue) + assert.Equal(t, 2048, int(cfg.ExecutionResponseLimit.DefaultValue)) + assert.Equal(t, 9, cfg.ExecutionConcurrencyLimit.DefaultValue) + assert.Equal(t, 7, cfg.CapabilityConcurrencyLimit.DefaultValue) + assert.Equal(t, 12*time.Second, cfg.CapabilityCallTimeout.DefaultValue) + assert.Equal(t, 6, cfg.SecretsConcurrencyLimit.DefaultValue) + assert.Equal(t, 13*time.Second, cfg.TriggerRegistrationsTimeout.DefaultValue) + assert.Equal(t, 14, cfg.TriggerEventQueueLimit.DefaultValue) + assert.Equal(t, 15*time.Second, cfg.TriggerEventQueueTimeout.DefaultValue) + assert.Equal(t, 16*time.Second, cfg.TriggerSubscriptionTimeout.DefaultValue) + assert.Equal(t, 17, cfg.TriggerSubscriptionLimit.DefaultValue) + assert.Equal(t, 4096, int(cfg.WASMMemoryLimit.DefaultValue)) + assert.Equal(t, 8192, int(cfg.WASMBinarySizeLimit.DefaultValue)) + assert.Equal(t, 1024, int(cfg.WASMCompressedBinarySizeLimit.DefaultValue)) + assert.Equal(t, 512, int(cfg.WASMConfigSizeLimit.DefaultValue)) + assert.Equal(t, 256, int(cfg.WASMSecretsSizeLimit.DefaultValue)) + assert.Equal(t, 128, int(cfg.LogLineLimit.DefaultValue)) + assert.Equal(t, 25, cfg.LogEventLimit.DefaultValue) + assert.Equal(t, 3, cfg.ChainRead.CallLimit.DefaultValue) + assert.Equal(t, 4, cfg.ChainWrite.TargetsLimit.DefaultValue) + assert.Equal(t, 5, cfg.Consensus.CallLimit.DefaultValue) + assert.Equal(t, 6, cfg.HTTPAction.CallLimit.DefaultValue) + assert.Equal(t, 7, cfg.ConfidentialHTTP.CallLimit.DefaultValue) + assert.Equal(t, 8, cfg.Secrets.CallLimit.DefaultValue) + assert.Equal(t, 30*time.Second, cfg.CRONTrigger.FastestScheduleInterval.DefaultValue) + assert.True(t, cfg.ChainAllowed.Default.DefaultValue, "simulation should preserve allow-all ChainAllowed settings") +} + +func TestSimulationLimitsSummaryIncludesKeyLimitValues(t *testing.T) { + t.Parallel() + + summary := newTestLimits(t).LimitsSummary() + assert.Contains(t, summary, "HTTP: req=10kb resp=100kb timeout=10s") + assert.Contains(t, summary, "ConfHTTP: req=10kb resp=100kb timeout=10s") + assert.Contains(t, summary, "Consensus obs=100kb") + assert.Contains(t, summary, "ChainWrite report=5kb gas=5000000") + assert.Contains(t, summary, "WASM binary=100mb compressed=20mb") +} diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 12a33b84..771eef49 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -63,6 +63,8 @@ type Inputs struct { EVMEventIndex int `validate:"-"` // Experimental chains support (for chains not in official chain-selectors) ExperimentalForwarders map[uint64]common.Address `validate:"-"` // forwarders keyed by chain ID + // Limits enforcement + LimitsPath string `validate:"-"` // "default" or path to custom limits JSON } func New(runtimeContext *runtime.Context) *cobra.Command { @@ -100,6 +102,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix)") simulateCmd.Flags().String("evm-tx-hash", "", "EVM trigger transaction hash (0x...)") simulateCmd.Flags().Int("evm-event-index", -1, "EVM trigger log index (0-based)") + simulateCmd.Flags().String("limits", "default", "Production limits to enforce during simulation: 'default' for prod defaults, path to a limits JSON file (e.g. from 'cre workflow limits export'), or 'none' to disable") return simulateCmd } @@ -236,6 +239,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) EVMTxHash: v.GetString("evm-tx-hash"), EVMEventIndex: v.GetInt("evm-event-index"), ExperimentalForwarders: experimentalForwarders, + LimitsPath: v.GetString("limits"), }, nil } @@ -336,6 +340,35 @@ func (h *handler) Execute(inputs Inputs) error { ui.Success("Workflow compiled") } + // Resolve simulation limits + simLimits, err := ResolveLimits(inputs.LimitsPath) + if err != nil { + return fmt.Errorf("failed to resolve simulation limits: %w", err) + } + + // WASM binary size pre-flight check + if simLimits != nil { + binaryLimit := simLimits.WASMBinarySize() + if binaryLimit > 0 && len(wasmFileBinary) > binaryLimit { + return fmt.Errorf("WASM binary size %d bytes exceeds limit of %d bytes", len(wasmFileBinary), binaryLimit) + } + + compressedLimit := simLimits.WASMCompressedBinarySize() + if compressedLimit > 0 { + compressed, err := cmdcommon.CompressBrotli(wasmFileBinary) + if err != nil { + return fmt.Errorf("failed to compress brotli: %w", err) + } + if len(compressed) > compressedLimit { + return fmt.Errorf("WASM compressed binary size %d bytes exceeds limit of %d bytes", len(compressed), compressedLimit) + } + } + + ui.Success("Simulation limits enabled") + ui.Dim(simLimits.LimitsSummary()) + } + + // Read the config file var config []byte if cmdcommon.IsURL(inputs.ConfigPath) { ui.Dim("Fetching config from URL...") @@ -375,7 +408,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 - err = run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + err = run(ctx, wasmFileBinary, config, secrets, inputs, verbosity, simLimits) if err != nil { return err } @@ -409,6 +442,7 @@ func run( binary, config, secrets []byte, inputs Inputs, verbosity bool, + simLimits *SimulationLimits, ) error { logCfg := logger.Config{Level: getLevel(verbosity, zapcore.InfoLevel)} simLogger := NewSimulationLogger(verbosity) @@ -479,14 +513,14 @@ func run( triggerLggr := lggr.Named("TriggerCapabilities") var err error - triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry, manualTriggerCapConfig, !inputs.Broadcast) + triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry, manualTriggerCapConfig, !inputs.Broadcast, simLimits) if err != nil { ui.Error(fmt.Sprintf("Failed to create trigger capabilities: %v", err)) os.Exit(1) } computeLggr := lggr.Named("ActionsCapabilities") - computeCaps, err := NewFakeActionCapabilities(ctx, computeLggr, registry, inputs.SecretsPath) + computeCaps, err := NewFakeActionCapabilities(ctx, computeLggr, registry, inputs.SecretsPath, simLimits) if err != nil { ui.Error(fmt.Sprintf("Failed to create compute capabilities: %v", err)) os.Exit(1) @@ -634,8 +668,13 @@ func run( }, }, WorkflowSettingsCfgFn: func(cfg *cresettings.Workflows) { + // Apply simulation limits to engine-level settings when --limits is set + if simLimits != nil { + applyEngineLimits(cfg, simLimits) + } + // Always allow all chains in simulation, overriding any chain restrictions from limits cfg.ChainAllowed = commonsettings.PerChainSelector( - commonsettings.Bool(true), // Allow all chains in simulation + commonsettings.Bool(true), map[string]bool{}, ) }, diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 844b40d3..f03a7bdf 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -9,6 +9,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" + "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" "github.com/smartcontractkit/cre-cli/cmd/workflow/test" @@ -31,6 +32,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(deploy.New(runtimeContext)) workflowCmd.AddCommand(hash.New(runtimeContext)) workflowCmd.AddCommand(simulate.New(runtimeContext)) + workflowCmd.AddCommand(limits.New()) return workflowCmd } diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index 1b220e78..0bdfb9a6 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -35,6 +35,7 @@ cre workflow [optional flags] * [cre workflow delete](cre_workflow_delete.md) - Deletes all versions of a workflow from the Workflow Registry * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract * [cre workflow hash](cre_workflow_hash.md) - Computes and displays workflow hashes +* [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits * [cre workflow pause](cre_workflow_pause.md) - Pauses workflow on the Workflow Registry contract * [cre workflow simulate](cre_workflow_simulate.md) - Simulates a workflow diff --git a/docs/cre_workflow_limits.md b/docs/cre_workflow_limits.md new file mode 100644 index 00000000..05605bfe --- /dev/null +++ b/docs/cre_workflow_limits.md @@ -0,0 +1,29 @@ +## cre workflow limits + +Manage simulation limits + +### Synopsis + +The limits command provides tools for managing workflow simulation limits. + +### Options + +``` + -h, --help help for limits +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows +* [cre workflow limits export](cre_workflow_limits_export.md) - Export default simulation limits as JSON + diff --git a/docs/cre_workflow_limits_export.md b/docs/cre_workflow_limits_export.md new file mode 100644 index 00000000..1162a8fc --- /dev/null +++ b/docs/cre_workflow_limits_export.md @@ -0,0 +1,39 @@ +## cre workflow limits export + +Export default simulation limits as JSON + +### Synopsis + +Exports the default production simulation limits as JSON. +The output can be redirected to a file and customized. + +``` +cre workflow limits export [optional flags] +``` + +### Examples + +``` +cre workflow limits export > my-limits.json +``` + +### Options + +``` + -h, --help help for export +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits + diff --git a/docs/cre_workflow_simulate.md b/docs/cre_workflow_simulate.md index 086c389e..ce844fd2 100644 --- a/docs/cre_workflow_simulate.md +++ b/docs/cre_workflow_simulate.md @@ -27,6 +27,7 @@ cre workflow simulate ./my-workflow --evm-tx-hash string EVM trigger transaction hash (0x...) -h, --help help for simulate --http-payload string HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix) + --limits string Production limits to enforce during simulation: 'default' for prod defaults, path to a limits JSON file (e.g. from 'cre workflow limits export'), or 'none' to disable (default "default") --no-config Simulate without a config file --non-interactive Run without prompts; requires --trigger-index and inputs for the selected trigger type --trigger-index int Index of the trigger to run (0-based) (default -1)