diff --git a/cmd/workflow/deploy/artifacts_test.go b/cmd/workflow/deploy/artifacts_test.go index d9591b04..4f08ad4d 100644 --- a/cmd/workflow/deploy/artifacts_test.go +++ b/cmd/workflow/deploy/artifacts_test.go @@ -12,6 +12,7 @@ import ( "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" + "github.com/smartcontractkit/cre-cli/internal/artifacts" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/testutil/chainsim" ) @@ -91,7 +92,7 @@ func TestUpload_SuccessAndErrorCases(t *testing.T) { h.environmentSet.GraphQLURL = "http://graphql.endpoint" // Success case : uploading binary and config data - h.workflowArtifact = &workflowArtifact{ + h.workflowArtifact = &artifacts.Artifact{ BinaryData: []byte("binarydata"), ConfigData: []byte("configdata"), WorkflowID: "workflow-id", @@ -102,7 +103,7 @@ func TestUpload_SuccessAndErrorCases(t *testing.T) { require.Equal(t, "http://origin/get", *h.inputs.ConfigURL) // Success: empty ConfigData - h.workflowArtifact = &workflowArtifact{ + h.workflowArtifact = &artifacts.Artifact{ BinaryData: []byte("binarydata"), ConfigData: nil, WorkflowID: "workflow-id", @@ -116,7 +117,7 @@ func TestUpload_SuccessAndErrorCases(t *testing.T) { require.ErrorContains(t, err, "workflowArtifact is nil") // Error: empty BinaryData - h.workflowArtifact = &workflowArtifact{ + h.workflowArtifact = &artifacts.Artifact{ BinaryData: nil, ConfigData: []byte("configdata"), WorkflowID: "workflow-id", @@ -125,7 +126,7 @@ func TestUpload_SuccessAndErrorCases(t *testing.T) { require.ErrorContains(t, err, "uploading binary artifact: content is empty for artifactType BINARY") // Error: workflowID is empty - h.workflowArtifact = &workflowArtifact{ + h.workflowArtifact = &artifacts.Artifact{ BinaryData: []byte("binarydata"), ConfigData: []byte("configdata"), WorkflowID: "", @@ -166,7 +167,7 @@ func TestUploadArtifactToStorageService_OriginError(t *testing.T) { // Patch settings to use mock GraphQL endpoint h.environmentSet.GraphQLURL = "http://graphql.endpoint" - h.workflowArtifact = &workflowArtifact{ + h.workflowArtifact = &artifacts.Artifact{ BinaryData: []byte("binarydata"), ConfigData: []byte("configdata"), WorkflowID: "workflow-id", @@ -232,7 +233,7 @@ func TestUploadArtifactToStorageService_AlreadyExistsError(t *testing.T) { // Patch settings to use mock GraphQL endpoint h.environmentSet.GraphQLURL = "http://graphql.endpoint" - h.workflowArtifact = &workflowArtifact{ + h.workflowArtifact = &artifacts.Artifact{ BinaryData: []byte("binarydata"), ConfigData: []byte("configdata"), WorkflowID: "workflow-id", diff --git a/cmd/workflow/deploy/compile.go b/cmd/workflow/deploy/compile.go index 51387569..3753bf57 100644 --- a/cmd/workflow/deploy/compile.go +++ b/cmd/workflow/deploy/compile.go @@ -1,137 +1,25 @@ package deploy import ( - "bytes" - "encoding/base64" - "errors" "fmt" - "os" - "path/filepath" - "strings" - "github.com/andybalholm/brotli" - - cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" - "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/build" ) func (h *handler) Compile() error { - if !h.validated { - return fmt.Errorf("handler h.inputs not validated") - } - fmt.Println("Compiling workflow...") - - if h.inputs.OutputPath == "" { - h.inputs.OutputPath = defaultOutputPath - } - if !strings.HasSuffix(h.inputs.OutputPath, ".b64") { - if !strings.HasSuffix(h.inputs.OutputPath, ".br") { - if !strings.HasSuffix(h.inputs.OutputPath, ".wasm") { - h.inputs.OutputPath += ".wasm" // Append ".wasm" if it doesn't already end with ".wasm" - } - h.inputs.OutputPath += ".br" // Append ".br" if it doesn't already end with ".br" - } - h.inputs.OutputPath += ".b64" // Append ".b64" if it doesn't already end with ".b64" + outputPath := h.inputs.OutputPath + if outputPath == "" { + outputPath = defaultOutputPath } - workflowAbsFile, err := filepath.Abs(h.inputs.WorkflowPath) + buildParams, err := build.ResolveBuildParamsForWorkflow(h.inputs.WorkflowPath, outputPath) if err != nil { - return fmt.Errorf("failed to get absolute path for the workflow file: %w", err) - } - - if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { - return fmt.Errorf("workflow file not found: %s", workflowAbsFile) - } - - workflowRootFolder := filepath.Dir(h.inputs.WorkflowPath) - - tmpWasmFileName := "tmp.wasm" - workflowMainFile := filepath.Base(h.inputs.WorkflowPath) - - // Set language in runtime context based on workflow file extension - if h.runtimeContext != nil { - h.runtimeContext.Workflow.Language = cmdcommon.GetWorkflowLanguage(workflowMainFile) - - switch h.runtimeContext.Workflow.Language { - case constants.WorkflowLanguageTypeScript: - if err := cmdcommon.EnsureTool("bun"); err != nil { - return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") - } - case constants.WorkflowLanguageGolang: - if err := cmdcommon.EnsureTool("go"); err != nil { - return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") - } - default: - return fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) - } + return fmt.Errorf("failed to resolve build inputs: %w", err) } + h.runtimeContext.Workflow.Language = buildParams.WorkflowLanguage - buildCmd := cmdcommon.GetBuildCmd(workflowMainFile, tmpWasmFileName, workflowRootFolder) - h.log.Debug(). - Str("Workflow directory", buildCmd.Dir). - Str("Command", buildCmd.String()). - Msg("Executing go build command") - - buildOutput, err := buildCmd.CombinedOutput() - if err != nil { - fmt.Println(string(buildOutput)) - - out := strings.TrimSpace(string(buildOutput)) - return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) - } - h.log.Debug().Msgf("Build output: %s", buildOutput) - fmt.Println("Workflow compiled successfully") - - tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) - wasmFile, err := os.ReadFile(tmpWasmLocation) - if err != nil { - return fmt.Errorf("failed to read workflow binary: %w", err) - } - - compressedFile, err := applyBrotliCompressionV2(&wasmFile) - if err != nil { - return fmt.Errorf("failed to compress WASM binary: %w", err) - } - h.log.Debug().Msg("WASM binary compressed") - - if err = encodeToBase64AndSaveToFile(&compressedFile, h.inputs.OutputPath); err != nil { - return fmt.Errorf("failed to base64 encode the WASM binary: %w", err) - } - h.log.Debug().Msg("WASM binary encoded") - - if err = os.Remove(tmpWasmLocation); err != nil { - return fmt.Errorf("failed to remove the temporary file: %w", err) - } - - return nil -} - -func applyBrotliCompressionV2(wasmContent *[]byte) ([]byte, error) { - var buffer bytes.Buffer - - // Compress using Brotli with default options - writer := brotli.NewWriter(&buffer) - - _, err := writer.Write(*wasmContent) - if err != nil { - return nil, err - } - - // must close it to flush the writer and ensure all data is stored to the buffer - err = writer.Close() - if err != nil { - return nil, err - } - - return buffer.Bytes(), nil -} - -func encodeToBase64AndSaveToFile(input *[]byte, outputFile string) error { - encoded := base64.StdEncoding.EncodeToString(*input) - - err := os.WriteFile(outputFile, []byte(encoded), 0666) //nolint:gosec - if err != nil { - return err + if err := h.builder.CompileAndSave(buildParams); err != nil { + return fmt.Errorf("failed to compile workflow: %w", err) } return nil diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 91fe8deb..97947674 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/viper" "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/internal/artifacts" + "github.com/smartcontractkit/cre-cli/internal/build" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" @@ -59,9 +61,11 @@ type handler struct { stdin io.Reader credentials *credentials.Credentials environmentSet *environments.EnvironmentSet - workflowArtifact *workflowArtifact + workflowArtifact *artifacts.Artifact wrc *client.WorkflowRegistryV2Client runtimeContext *runtime.Context + builder *build.Builder + artifactBuilder *artifacts.Builder validated bool @@ -69,7 +73,7 @@ type handler struct { wrcErr error } -var defaultOutputPath = "./binary.wasm.br.b64" +const defaultOutputPath = "./binary.wasm.br.b64" func New(runtimeContext *runtime.Context) *cobra.Command { var deployCmd = &cobra.Command{ @@ -111,7 +115,9 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { stdin: stdin, credentials: ctx.Credentials, environmentSet: ctx.EnvironmentSet, - workflowArtifact: &workflowArtifact{}, + workflowArtifact: &artifacts.Artifact{}, + builder: build.NewBuilder(ctx.Logger), + artifactBuilder: artifacts.NewBuilder(ctx.Logger), wrc: nil, runtimeContext: ctx, validated: false, @@ -176,13 +182,17 @@ func (h *handler) ValidateInputs() error { func (h *handler) Execute() error { h.displayWorkflowDetails() + if !h.validated { + return errors.New("inputs have not been validated") + } + if err := h.Compile(); err != nil { - return fmt.Errorf("failed to compile workflow: %w", err) + return err } + if err := h.PrepareWorkflowArtifact(); err != nil { return fmt.Errorf("failed to prepare workflow artifact: %w", err) } - h.runtimeContext.Workflow.ID = h.workflowArtifact.WorkflowID h.wg.Wait() diff --git a/cmd/workflow/deploy/prepare.go b/cmd/workflow/deploy/prepare.go index dc51522e..68626883 100644 --- a/cmd/workflow/deploy/prepare.go +++ b/cmd/workflow/deploy/prepare.go @@ -1,72 +1,21 @@ package deploy import ( - "encoding/base64" - "fmt" - "os" - - workflowUtils "github.com/smartcontractkit/chainlink-common/pkg/workflows" + "github.com/smartcontractkit/cre-cli/internal/artifacts" ) -type workflowArtifact struct { - BinaryData []byte - ConfigData []byte - WorkflowID string -} - -func (h *handler) prepareWorkflowBinary() ([]byte, error) { - h.log.Debug().Str("Binary Path", h.inputs.OutputPath).Msg("Fetching workflow binary") - binaryData, err := os.ReadFile(h.inputs.OutputPath) - if err != nil { - h.log.Error().Err(err).Str("path", h.inputs.OutputPath).Msg("Failed to read output file") - return nil, err - } - h.log.Debug().Msg("Workflow binary WASM is ready") - return binaryData, nil -} - -func (h *handler) prepareWorkflowConfig() ([]byte, error) { - h.log.Debug().Str("Config Path", h.inputs.ConfigPath).Msg("Fetching workflow config") - var configData []byte - var err error - if h.inputs.ConfigPath != "" { - configData, err = os.ReadFile(h.inputs.ConfigPath) - if err != nil { - h.log.Error().Err(err).Str("path", h.inputs.ConfigPath).Msg("Failed to read config file") - return nil, err - } - } - h.log.Debug().Msg("Workflow config is ready") - return configData, nil -} - -func (h *handler) PrepareWorkflowArtifact() error { - var err error - binaryData, err := h.prepareWorkflowBinary() +func (h *handler) PrepareWorkflowArtifact() (err error) { + h.workflowArtifact, err = h.artifactBuilder.Build(artifacts.Inputs{ + WorkflowOwner: h.inputs.WorkflowOwner, + WorkflowName: h.inputs.WorkflowName, + OutputPath: h.inputs.OutputPath, + ConfigPath: h.inputs.ConfigPath, + }) if err != nil { return err } - configData, err := h.prepareWorkflowConfig() - if err != nil { - return err - } - - // Note: the binary data read from file is base64 encoded, so we need to decode it before generating the workflow ID. - // This matches the behavior in the Chainlink node. Ref https://github.com/smartcontractkit/chainlink/blob/a4adc900d98d4e6eec0a6f80fcf86d883a8f1e3c/core/services/workflows/artifacts/v2/store.go#L211-L213 - binaryDataDecoded, err := base64.StdEncoding.DecodeString(string(binaryData)) - if err != nil { - return fmt.Errorf("failed to decode base64 binary data: %w", err) - } - - workflowID, err := workflowUtils.GenerateWorkflowIDFromStrings(h.inputs.WorkflowOwner, h.inputs.WorkflowName, binaryDataDecoded, configData, "") - if err != nil { - return fmt.Errorf("failed to generate workflow ID: %w", err) - } - - h.workflowArtifact.BinaryData = binaryData - h.workflowArtifact.ConfigData = configData - h.workflowArtifact.WorkflowID = workflowID + h.log.Info().Str("workflowID", h.workflowArtifact.WorkflowID).Msg("Prepared workflow artifact") return nil } diff --git a/cmd/workflow/deploy/register_test.go b/cmd/workflow/deploy/register_test.go index 4b1985b3..1188bcc1 100644 --- a/cmd/workflow/deploy/register_test.go +++ b/cmd/workflow/deploy/register_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/smartcontractkit/cre-cli/internal/artifacts" "github.com/smartcontractkit/cre-cli/internal/testutil/chainsim" ) @@ -55,7 +56,7 @@ func TestWorkflowUpsert(t *testing.T) { err = handler.ValidateInputs() require.NoError(t, err) - wfArt := workflowArtifact{ + wfArt := artifacts.Artifact{ BinaryData: []byte("0x1234"), ConfigData: []byte("config"), WorkflowID: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", diff --git a/cmd/workflow/generate-id/generate_id.go b/cmd/workflow/generate-id/generate_id.go new file mode 100644 index 00000000..9c347374 --- /dev/null +++ b/cmd/workflow/generate-id/generate_id.go @@ -0,0 +1,132 @@ +package generate_id + +import ( + "fmt" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/smartcontractkit/cre-cli/internal/artifacts" + "github.com/smartcontractkit/cre-cli/internal/build" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/validation" +) + +const defaultOutputPath = "./binary.wasm.br.b64" + +type Inputs struct { + WorkflowPath string `validate:"required,path_read"` + WorkflowName string `validate:"workflow_name"` + WorkflowOwner string `validate:"workflow_owner" cli:"--owner"` + + ConfigPath string `validate:"omitempty,file,ascii,max=97" cli:"--config"` + OutputPath string `validate:"omitempty,filepath,ascii,max=97" cli:"--output"` + + validated bool +} + +type handler struct { + log *zerolog.Logger + settings *settings.Settings + builder *build.Builder + artifactBuilder *artifacts.Builder + + inputs Inputs +} + +func New(ctx *runtime.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-id ", + Short: "Display the workflow ID", + Args: cobra.ExactArgs(1), + Example: `cre workflow generate-id ./my-workflow`, + RunE: func(cmd *cobra.Command, args []string) error { + h := newHandler(ctx) + + h.inputs = h.ResolveInputs(ctx.Viper) + if err := h.ValidateInputs(); err != nil { + return err + } + + return h.Execute() + }, + } + + // optional owner flag overrides the owner from settings + cmd.Flags().String("owner", "", "Workflow owner address") + cmd.Flags().StringP("output", "o", defaultOutputPath, "The output file for the compiled WASM binary encoded in base64") + + return cmd +} + +func newHandler(ctx *runtime.Context) *handler { + return &handler{ + log: ctx.Logger, + settings: ctx.Settings, + builder: build.NewBuilder(ctx.Logger), + artifactBuilder: artifacts.NewBuilder(ctx.Logger), + } +} + +func (h *handler) Execute() error { + if !h.inputs.validated { + return fmt.Errorf("inputs have not been validated") + } + + buildParams, err := build.ResolveBuildParamsForWorkflow(h.inputs.WorkflowPath, h.inputs.OutputPath) + if err != nil { + return fmt.Errorf("failed to resolve build parameters: %w", err) + } + + if err := h.builder.CompileAndSave(buildParams); err != nil { + return fmt.Errorf("failed to compile workflow: %w", err) + } + + workflowArtifact, err := h.artifactBuilder.Build(artifacts.Inputs{ + WorkflowOwner: h.inputs.WorkflowOwner, + WorkflowName: h.inputs.WorkflowName, + OutputPath: h.inputs.OutputPath, + ConfigPath: h.inputs.ConfigPath, + }) + if err != nil { + return fmt.Errorf("failed to build workflow artifact: %w", err) + } + + h.log.Info().Str("workflowID", workflowArtifact.WorkflowID).Msg("Workflow ID computed successfully") + fmt.Println("Workflow ID:", workflowArtifact.WorkflowID) + + return nil +} + +func (h *handler) ResolveInputs(v *viper.Viper) Inputs { + ownerFromFlag := v.GetString("owner") + workflowOwner := h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress + if ownerFromFlag != "" { + workflowOwner = ownerFromFlag + } + + return Inputs{ + WorkflowPath: h.settings.Workflow.WorkflowArtifactSettings.WorkflowPath, + WorkflowName: h.settings.Workflow.UserWorkflowSettings.WorkflowName, + WorkflowOwner: workflowOwner, + + ConfigPath: h.settings.Workflow.WorkflowArtifactSettings.ConfigPath, + OutputPath: v.GetString("output"), + } +} + +func (h *handler) ValidateInputs() error { + validate, err := validation.NewValidator() + if err != nil { + return fmt.Errorf("failed to initialize validator: %w", err) + } + + if err := validate.Struct(h.inputs); err != nil { + return validate.ParseValidationErrors(err) + } + h.inputs.validated = true + + return nil +} diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index d0a56e25..c3089e6d 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -5,7 +5,6 @@ import ( "context" "crypto/ecdsa" "encoding/json" - "errors" "fmt" "math" "math/big" @@ -38,7 +37,7 @@ import ( v2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/v2" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" - "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/build" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/validation" @@ -202,17 +201,8 @@ func (h *handler) Execute(inputs Inputs) error { if h.runtimeContext != nil { h.runtimeContext.Workflow.Language = cmdcommon.GetWorkflowLanguage(workflowMainFile) - switch h.runtimeContext.Workflow.Language { - case constants.WorkflowLanguageTypeScript: - if err := cmdcommon.EnsureTool("bun"); err != nil { - return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") - } - case constants.WorkflowLanguageGolang: - if err := cmdcommon.EnsureTool("go"); err != nil { - return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") - } - default: - return fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) + if err := build.EnsureToolsForBuild(h.runtimeContext.Workflow.Language); err != nil { + return fmt.Errorf("failed to ensure build tools: %w", err) } } diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 72e5b699..6bd9a783 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -6,6 +6,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/activate" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" + generateid "github.com/smartcontractkit/cre-cli/cmd/workflow/generate-id" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" "github.com/smartcontractkit/cre-cli/cmd/workflow/test" @@ -25,6 +26,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(test.New(runtimeContext)) workflowCmd.AddCommand(deploy.New(runtimeContext)) workflowCmd.AddCommand(simulate.New(runtimeContext)) + workflowCmd.AddCommand(generateid.New(runtimeContext)) return workflowCmd } diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index a5b83833..d6af7fb6 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -31,6 +31,7 @@ cre workflow [optional flags] * [cre workflow activate](cre_workflow_activate.md) - Activates workflow on the Workflow Registry contract * [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 generate-id](cre_workflow_generate-id.md) - Display the workflow ID * [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_generate-id.md b/docs/cre_workflow_generate-id.md new file mode 100644 index 00000000..772361be --- /dev/null +++ b/docs/cre_workflow_generate-id.md @@ -0,0 +1,35 @@ +## cre workflow generate-id + +Display the workflow ID + +``` +cre workflow generate-id [optional flags] +``` + +### Examples + +``` +cre workflow generate-id ./my-workflow +``` + +### Options + +``` + -h, --help help for generate-id + -o, --output string The output file for the compiled WASM binary encoded in base64 (default "./binary.wasm.br.b64") + --owner string Workflow owner address +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info (default ".env") + -R, --project-root string Path to the project root + -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 + diff --git a/internal/artifacts/artifact.go b/internal/artifacts/artifact.go new file mode 100644 index 00000000..7b736de8 --- /dev/null +++ b/internal/artifacts/artifact.go @@ -0,0 +1,7 @@ +package artifacts + +type Artifact struct { + BinaryData []byte + ConfigData []byte + WorkflowID string +} diff --git a/internal/artifacts/builder.go b/internal/artifacts/builder.go new file mode 100644 index 00000000..1b0200b4 --- /dev/null +++ b/internal/artifacts/builder.go @@ -0,0 +1,98 @@ +package artifacts + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/rs/zerolog" + + workflowUtils "github.com/smartcontractkit/chainlink-common/pkg/workflows" +) + +type Inputs struct { + WorkflowOwner string + WorkflowName string + OutputPath string + ConfigPath string +} + +type Builder struct { + log *zerolog.Logger +} + +func NewBuilder(log *zerolog.Logger) *Builder { + return &Builder{ + log: log, + } +} + +func (b *Builder) Build(inputs Inputs) (artifact *Artifact, err error) { + artifact = &Artifact{} + + if inputs.WorkflowOwner == "" { + return nil, fmt.Errorf("workflow owner is required") + } + + if inputs.WorkflowName == "" { + return nil, fmt.Errorf("workflow name is required") + } + + artifact.ConfigData, err = b.prepareWorkflowConfig(inputs.ConfigPath) + if err != nil { + return nil, err + } + + artifact.BinaryData, err = b.prepareWorkflowBinary(inputs.OutputPath) + if err != nil { + return nil, err + } + + binaryDataDecoded, err := decodeBinaryData(artifact.BinaryData) + if err != nil { + return nil, err + } + + artifact.WorkflowID, err = workflowUtils.GenerateWorkflowIDFromStrings(inputs.WorkflowOwner, inputs.WorkflowName, binaryDataDecoded, artifact.ConfigData, "") + if err != nil { + return nil, fmt.Errorf("failed to generate workflow ID: %w", err) + } + + return artifact, nil +} + +func decodeBinaryData(binaryData []byte) ([]byte, error) { + // Note: the binary data read from file is base64 encoded, so we need to decode it before generating the workflow ID. + // This matches the behavior in the Chainlink node. Ref https://github.com/smartcontractkit/chainlink/blob/a4adc900d98d4e6eec0a6f80fcf86d883a8f1e3c/core/services/workflows/artifacts/v2/store.go#L211-L213 + binaryDataDecoded := make([]byte, base64.StdEncoding.DecodedLen(len(binaryData))) + if _, err := base64.StdEncoding.Decode(binaryDataDecoded, binaryData); err != nil { + return nil, fmt.Errorf("failed to decode base64 binary data: %w", err) + } + return binaryDataDecoded, nil +} + +func (b *Builder) prepareWorkflowBinary(outputPath string) ([]byte, error) { + b.log.Debug().Str("Binary Path", outputPath).Msg("Fetching workflow binary") + binaryData, err := os.ReadFile(outputPath) + if err != nil { + b.log.Error().Err(err).Str("path", outputPath).Msg("Failed to read output file") + return nil, err + } + b.log.Debug().Msg("Workflow binary WASM is ready") + return binaryData, nil +} + +func (b *Builder) prepareWorkflowConfig(configPath string) ([]byte, error) { + b.log.Debug().Str("Config Path", configPath).Msg("Fetching workflow config") + var configData []byte + var err error + if configPath != "" { + configData, err = os.ReadFile(configPath) + if err != nil { + b.log.Error().Err(err).Str("path", configPath).Msg("Failed to read config file") + return nil, err + } + } + b.log.Debug().Msg("Workflow config is ready") + return configData, nil +} diff --git a/internal/artifacts/builder_test.go b/internal/artifacts/builder_test.go new file mode 100644 index 00000000..fe7a3c22 --- /dev/null +++ b/internal/artifacts/builder_test.go @@ -0,0 +1,315 @@ +package artifacts_test + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/artifacts" + "github.com/smartcontractkit/cre-cli/internal/testutil" + "github.com/smartcontractkit/cre-cli/internal/testutil/chainsim" +) + +func TestBuilder_Build(t *testing.T) { + t.Parallel() + + logger := testutil.NewTestLogger() + artifactBuilder := artifacts.NewBuilder(logger) + + t.Run("success with config", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create valid base64-encoded binary file + binaryData := []byte("test binary data") + encodedBinary := base64.StdEncoding.EncodeToString(binaryData) + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte(encodedBinary), 0600) + require.NoError(t, err) + + // Create config file + configData := []byte("test config data") + configPath := filepath.Join(tempDir, "config.yaml") + err = os.WriteFile(configPath, configData, 0600) + require.NoError(t, err) + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "test_workflow", + OutputPath: binaryPath, + ConfigPath: configPath, + } + + artifact, err := artifactBuilder.Build(inputs) + require.NoError(t, err) + assert.NotNil(t, artifact) + assert.Equal(t, []byte(encodedBinary), artifact.BinaryData) + assert.Equal(t, configData, artifact.ConfigData) + assert.NotEmpty(t, artifact.WorkflowID) + }) + + t.Run("success without config", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create valid base64-encoded binary file + binaryData := []byte("test binary data") + encodedBinary := base64.StdEncoding.EncodeToString(binaryData) + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte(encodedBinary), 0600) + require.NoError(t, err) + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "test_workflow", + OutputPath: binaryPath, + ConfigPath: "", // No config + } + + artifact, err := artifactBuilder.Build(inputs) + require.NoError(t, err) + assert.NotNil(t, artifact) + assert.Equal(t, []byte(encodedBinary), artifact.BinaryData) + assert.Empty(t, artifact.ConfigData) + assert.NotEmpty(t, artifact.WorkflowID) + }) + + t.Run("error: workflow owner is empty", func(t *testing.T) { + t.Parallel() + + inputs := artifacts.Inputs{ + WorkflowOwner: "", + WorkflowName: "test_workflow", + OutputPath: "binary.wasm", + ConfigPath: "", + } + + artifact, err := artifactBuilder.Build(inputs) + require.Error(t, err) + assert.Nil(t, artifact) + assert.Contains(t, err.Error(), "workflow owner is required") + }) + + t.Run("error: workflow name is empty", func(t *testing.T) { + t.Parallel() + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "", + OutputPath: "binary.wasm", + ConfigPath: "", + } + + artifact, err := artifactBuilder.Build(inputs) + require.Error(t, err) + assert.Nil(t, artifact) + assert.Contains(t, err.Error(), "workflow name is required") + }) + + t.Run("error: invalid config path", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create valid base64-encoded binary file + binaryData := []byte("test binary data") + encodedBinary := base64.StdEncoding.EncodeToString(binaryData) + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte(encodedBinary), 0600) + require.NoError(t, err) + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "test_workflow", + OutputPath: binaryPath, + ConfigPath: "/nonexistent/config.yaml", + } + + artifact, err := artifactBuilder.Build(inputs) + require.Error(t, err) + assert.Nil(t, artifact) + }) + + t.Run("error: invalid binary path", func(t *testing.T) { + t.Parallel() + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "test_workflow", + OutputPath: "/nonexistent/binary.wasm", + ConfigPath: "", + } + + artifact, err := artifactBuilder.Build(inputs) + require.Error(t, err) + assert.Nil(t, artifact) + }) + + t.Run("error: invalid base64 binary data", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create invalid base64 binary file + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte("not-valid-base64!!!"), 0600) + require.NoError(t, err) + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "test_workflow", + OutputPath: binaryPath, + ConfigPath: "", + } + + artifact, err := artifactBuilder.Build(inputs) + require.Error(t, err) + assert.Nil(t, artifact) + assert.Contains(t, err.Error(), "failed to decode base64 binary data") + }) +} + +func TestBuilder_BuildGeneratesConsistentWorkflowID(t *testing.T) { + t.Parallel() + + logger := testutil.NewTestLogger() + builder := artifacts.NewBuilder(logger) + + tempDir := t.TempDir() + + // Create valid base64-encoded binary file + binaryData := []byte("test binary data for consistency check") + encodedBinary := base64.StdEncoding.EncodeToString(binaryData) + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte(encodedBinary), 0600) + require.NoError(t, err) + + // Create config file + configData := []byte("test config data for consistency") + configPath := filepath.Join(tempDir, "config.yaml") + err = os.WriteFile(configPath, configData, 0600) + require.NoError(t, err) + + inputs := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "test_workflow", + OutputPath: binaryPath, + ConfigPath: configPath, + } + + // Build artifact twice with same inputs + artifact1, err := builder.Build(inputs) + require.NoError(t, err) + + artifact2, err := builder.Build(inputs) + require.NoError(t, err) + + // Workflow IDs should be identical for same inputs + assert.Equal(t, artifact1.WorkflowID, artifact2.WorkflowID) + assert.NotEmpty(t, artifact1.WorkflowID) +} + +func TestBuilder_BuildGeneratesDifferentWorkflowIDsForDifferentInputs(t *testing.T) { + t.Parallel() + + logger := testutil.NewTestLogger() + builder := artifacts.NewBuilder(logger) + + tempDir := t.TempDir() + + // Create valid base64-encoded binary file + binaryData := []byte("test binary data") + encodedBinary := base64.StdEncoding.EncodeToString(binaryData) + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte(encodedBinary), 0600) + require.NoError(t, err) + + // Create config file + configData := []byte("test config data") + configPath := filepath.Join(tempDir, "config.yaml") + err = os.WriteFile(configPath, configData, 0600) + require.NoError(t, err) + + // Build artifact with first workflow name + inputs1 := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "workflow_one", + OutputPath: binaryPath, + ConfigPath: configPath, + } + artifact1, err := builder.Build(inputs1) + require.NoError(t, err) + + // Build artifact with different workflow name + inputs2 := artifacts.Inputs{ + WorkflowOwner: chainsim.TestAddress, + WorkflowName: "workflow_two", + OutputPath: binaryPath, + ConfigPath: configPath, + } + artifact2, err := builder.Build(inputs2) + require.NoError(t, err) + + // Workflow IDs should be different + assert.NotEqual(t, artifact1.WorkflowID, artifact2.WorkflowID) + assert.NotEmpty(t, artifact1.WorkflowID) + assert.NotEmpty(t, artifact2.WorkflowID) +} + +func TestBuilder_BuildWithDifferentOwners(t *testing.T) { + t.Parallel() + + logger := testutil.NewTestLogger() + builder := artifacts.NewBuilder(logger) + + tempDir := t.TempDir() + + // Create valid base64-encoded binary file + binaryData := []byte("test binary data") + encodedBinary := base64.StdEncoding.EncodeToString(binaryData) + binaryPath := filepath.Join(tempDir, "binary.wasm.br.b64") + err := os.WriteFile(binaryPath, []byte(encodedBinary), 0600) + require.NoError(t, err) + + tests := []struct { + name string + workflowOwner string + }{ + { + name: "owner with 0x prefix", + workflowOwner: chainsim.TestAddress, + }, + { + name: "different owner address", + workflowOwner: "0x37250db56cb0dd17f7653de405c89d2ac1874a63", + }, + } + + var workflowIDs []string + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputs := artifacts.Inputs{ + WorkflowOwner: tt.workflowOwner, + WorkflowName: "test_workflow", + OutputPath: binaryPath, + ConfigPath: "", + } + + artifact, err := builder.Build(inputs) + require.NoError(t, err) + assert.NotEmpty(t, artifact.WorkflowID) + workflowIDs = append(workflowIDs, artifact.WorkflowID) + }) + } + + // Ensure different owners produce different workflow IDs + if len(workflowIDs) >= 2 { + assert.NotEqual(t, workflowIDs[0], workflowIDs[1]) + } +} diff --git a/internal/build/compile.go b/internal/build/compile.go new file mode 100644 index 00000000..df4c7b13 --- /dev/null +++ b/internal/build/compile.go @@ -0,0 +1,165 @@ +package build + +import ( + "bytes" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/andybalholm/brotli" + "github.com/rs/zerolog" + + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" +) + +type Params struct { + WorkflowPath string + WorkflowRootFolder string + WorkflowMainFile string + WorkflowLanguage string + + OutputPath string +} + +func ResolveBuildParamsForWorkflow(workflowPath, outputPath string) (Params, error) { + workflowAbsFile, err := filepath.Abs(workflowPath) + if err != nil { + return Params{}, fmt.Errorf("failed to get absolute path for the workflow file: %w", err) + } + + if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { + return Params{}, fmt.Errorf("workflow file not found: %s", workflowAbsFile) + } + + workflowRootFolder := filepath.Dir(workflowPath) + workflowMainFile := filepath.Base(workflowPath) + workflowLanguage := cmdcommon.GetWorkflowLanguage(workflowMainFile) + + return Params{ + WorkflowPath: workflowPath, + WorkflowRootFolder: workflowRootFolder, + WorkflowMainFile: workflowMainFile, + WorkflowLanguage: workflowLanguage, + OutputPath: outputPath, + }, nil +} + +type Builder struct { + log *zerolog.Logger +} + +func NewBuilder(log *zerolog.Logger) *Builder { + return &Builder{ + log: log, + } +} + +func (b *Builder) Compile(params Params) (*[]byte, error) { + fmt.Println("Compiling workflow...") + + if err := EnsureToolsForBuild(params.WorkflowLanguage); err != nil { + return nil, fmt.Errorf("failed to ensure build tools: %w", err) + } + + tmpWasmFileName := "tmp.wasm" + buildCmd := cmdcommon.GetBuildCmd(params.WorkflowMainFile, tmpWasmFileName, params.WorkflowRootFolder) + b.log.Debug(). + Str("Workflow directory", buildCmd.Dir). + Str("Command", buildCmd.String()). + Msg("Executing go build command") + + buildOutput, err := buildCmd.CombinedOutput() + if err != nil { + fmt.Println(string(buildOutput)) + + out := strings.TrimSpace(string(buildOutput)) + return nil, fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + } + b.log.Debug().Msgf("Build output: %s", buildOutput) + fmt.Println("Workflow compiled successfully") + + tmpWasmLocation := filepath.Join(params.WorkflowRootFolder, tmpWasmFileName) + wasmFile, err := os.ReadFile(tmpWasmLocation) + if err != nil { + return nil, fmt.Errorf("failed to read workflow binary: %w", err) + } + + compressedFile, err := applyBrotliCompressionV2(&wasmFile) + if err != nil { + return nil, fmt.Errorf("failed to compress WASM binary: %w", err) + } + b.log.Debug().Msg("WASM binary compressed") + + encoded := encodeToBase64(&compressedFile) + b.log.Debug().Msg("WASM binary encoded") + + if err = os.Remove(tmpWasmLocation); err != nil { + return nil, fmt.Errorf("failed to remove the temporary file: %w", err) + } + + return encoded, nil +} + +func (b *Builder) CompileAndSave(params Params) error { + if params.OutputPath == "" { + return fmt.Errorf("output path is not specified") + } + params.OutputPath = ensureOutputPathExtensions(params.OutputPath) + + binary, err := b.Compile(params) + if err != nil { + return err + } + + return saveToFile(binary, params.OutputPath) +} + +func ensureOutputPathExtensions(outputPath string) string { + if !strings.HasSuffix(outputPath, ".b64") { + if !strings.HasSuffix(outputPath, ".br") { + if !strings.HasSuffix(outputPath, ".wasm") { + outputPath += ".wasm" // Append ".wasm" if it doesn't already end with ".wasm" + } + outputPath += ".br" // Append ".br" if it doesn't already end with ".br" + } + outputPath += ".b64" // Append ".b64" if it doesn't already end with ".b64" + } + return outputPath +} + +func applyBrotliCompressionV2(wasmContent *[]byte) ([]byte, error) { + var buffer bytes.Buffer + + // Compress using Brotli with default options + writer := brotli.NewWriter(&buffer) + defer writer.Close() + + _, err := writer.Write(*wasmContent) + if err != nil { + return nil, err + } + + // must close it to flush the writer and ensure all data is stored to the buffer + err = writer.Close() + if err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func encodeToBase64(input *[]byte) *[]byte { + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(*input))) + base64.StdEncoding.Encode(encoded, *input) + return &encoded +} + +func saveToFile(input *[]byte, outputFile string) error { + err := os.WriteFile(outputFile, *input, 0666) //nolint:gosec + if err != nil { + return err + } + return nil +} diff --git a/internal/build/compile_test.go b/internal/build/compile_test.go new file mode 100644 index 00000000..62ef7e1d --- /dev/null +++ b/internal/build/compile_test.go @@ -0,0 +1,130 @@ +package build_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/build" +) + +func TestResolveBuildParamsForWorkflow(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + workflowFile := filepath.Join(tempDir, "main.go") + err := os.WriteFile(workflowFile, []byte("package main"), 0600) + require.NoError(t, err) + + tests := []struct { + name string + workflowPath string + outputPath string + expectedMainFile string + expectedRootFolder string + expectedLanguage string + }{ + { + name: "go workflow", + workflowPath: workflowFile, + outputPath: "output.wasm", + expectedMainFile: "main.go", + expectedRootFolder: tempDir, + expectedLanguage: "golang", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params, err := build.ResolveBuildParamsForWorkflow(tt.workflowPath, tt.outputPath) + require.NoError(t, err) + assert.Equal(t, tt.expectedMainFile, params.WorkflowMainFile) + assert.Equal(t, tt.expectedRootFolder, params.WorkflowRootFolder) + assert.Equal(t, tt.expectedLanguage, params.WorkflowLanguage) + assert.Equal(t, tt.outputPath, params.OutputPath) + assert.Contains(t, params.WorkflowPath, tt.expectedMainFile) + }) + } + }) + + t.Run("typescript workflow", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + workflowFile := filepath.Join(tempDir, "main.ts") + err := os.WriteFile(workflowFile, []byte("console.log('test')"), 0600) + require.NoError(t, err) + + params, err := build.ResolveBuildParamsForWorkflow(workflowFile, "output.wasm") + require.NoError(t, err) + assert.Equal(t, "main.ts", params.WorkflowMainFile) + assert.Equal(t, tempDir, params.WorkflowRootFolder) + assert.Equal(t, "typescript", params.WorkflowLanguage) + }) + + t.Run("errors", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workflowPath string + wantErr string + }{ + { + name: "workflow file not found", + workflowPath: "/nonexistent/path/main.go", + wantErr: "workflow file not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := build.ResolveBuildParamsForWorkflow(tt.workflowPath, "output.wasm") + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } + }) +} + +func TestBuilderCompileAndSave(t *testing.T) { + t.Parallel() + + logger := zerolog.New(os.Stdout) + builder := build.NewBuilder(&logger) + + t.Run("error when output path is empty", func(t *testing.T) { + t.Parallel() + + params := build.Params{ + OutputPath: "", + } + + err := builder.CompileAndSave(params) + require.Error(t, err) + assert.Contains(t, err.Error(), "output path is not specified") + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + workflowPath := filepath.Join("testdata", "basic_workflow", "main.go") + outputPath := "./binary.wasm.br.b64" + defer os.Remove(outputPath) + + params, err := build.ResolveBuildParamsForWorkflow(workflowPath, outputPath) + require.NoError(t, err) + + err = builder.CompileAndSave(params) + require.NoError(t, err) + + assert.FileExists(t, outputPath) + }) +} diff --git a/internal/build/testdata/basic_workflow/go.mod b/internal/build/testdata/basic_workflow/go.mod new file mode 100644 index 00000000..678a7bc7 --- /dev/null +++ b/internal/build/testdata/basic_workflow/go.mod @@ -0,0 +1,25 @@ +module testworkflow + +go 1.24.5 + +require ( + github.com/smartcontractkit/cre-sdk-go v1.0.0 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.0.0-beta.0 +) + +require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20250911124514-5874cc6d62b2 // indirect + github.com/stretchr/testify v1.10.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/internal/build/testdata/basic_workflow/go.sum b/internal/build/testdata/basic_workflow/go.sum new file mode 100644 index 00000000..fe26ae1a --- /dev/null +++ b/internal/build/testdata/basic_workflow/go.sum @@ -0,0 +1,37 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20250911124514-5874cc6d62b2 h1:1/KdO5AbUr3CmpLjMPuJXPo2wHMbfB8mldKLsg7D4M8= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20250911124514-5874cc6d62b2/go.mod h1:jUC52kZzEnWF9tddHh85zolKybmLpbQ1oNA4FjOHt1Q= +github.com/smartcontractkit/cre-sdk-go v1.0.0 h1:O52/QDmw/W8SJ7HQ9ASlVx7alSMGsewjL0Y8WZmgf5w= +github.com/smartcontractkit/cre-sdk-go v1.0.0/go.mod h1:CQY8hCISjctPmt8ViDVgFm4vMGLs5fYI198QhkBS++Y= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.0.0-beta.0 h1:Tui4xQVln7Qtk3CgjBRgDfihgEaAJy2t2MofghiGIDA= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.0.0-beta.0/go.mod h1:PWyrIw16It4TSyq6mDXqmSR0jF2evZRKuBxu7pK1yDw= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/build/testdata/basic_workflow/main.go b/internal/build/testdata/basic_workflow/main.go new file mode 100644 index 00000000..ea7abd38 --- /dev/null +++ b/internal/build/testdata/basic_workflow/main.go @@ -0,0 +1,36 @@ +//go:build wasip1 + +package main + +import ( + "log/slog" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" +) + +type Config struct { + Schedule string `json:"schedule"` +} + +func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) { + cronTriggerCfg := &cron.Config{ + Schedule: config.Schedule, + } + + return cre.Workflow[*Config]{ + cre.Handler( + cron.Trigger(cronTriggerCfg), + onCronTrigger), + }, nil +} + +func onCronTrigger(config *Config, runtime cre.Runtime, outputs *cron.Payload) (string, error) { + runtime.Logger().Info("workflow triggered") + return "success", nil +} + +func main() { + wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow) +} diff --git a/internal/build/tools.go b/internal/build/tools.go new file mode 100644 index 00000000..fed401fe --- /dev/null +++ b/internal/build/tools.go @@ -0,0 +1,25 @@ +package build + +import ( + "errors" + "fmt" + + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/internal/constants" +) + +func EnsureToolsForBuild(workflowLanguage string) error { + switch workflowLanguage { + case constants.WorkflowLanguageTypeScript: + if err := cmdcommon.EnsureTool("bun"); err != nil { + return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") + } + case constants.WorkflowLanguageGolang: + if err := cmdcommon.EnsureTool("go"); err != nil { + return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") + } + default: + return fmt.Errorf("unsupported workflow workflowLanguage %s", workflowLanguage) + } + return nil +} diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index d6ce2a50..ac61128f 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -184,7 +184,7 @@ func IsValidChainName(name string) error { // `--broadcast` is false or not set. `cre help` should skip as well. func ShouldSkipGetOwner(cmd *cobra.Command) bool { switch cmd.Name() { - case "help": + case "help", "id": return true case "simulate": // Treat missing/invalid flag as false (i.e., skip). diff --git a/test/common.go b/test/common.go index c5de25d2..d330089a 100644 --- a/test/common.go +++ b/test/common.go @@ -231,7 +231,7 @@ func StartAnvil(initState AnvilInitState, stateFileName string) (*os.Process, in L.Info().Str("Command", anvil.String()).Msg("Executing anvil") if err := anvil.Start(); err != nil { - return nil, 0, errors.New("failed to start Anvil") + return nil, 0, fmt.Errorf("failed to start Anvil: %w", err) } L.Info().Msg("Checking if Anvil is up and running")