diff --git a/Makefile b/Makefile index d96186c3..1d55f8c6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-admin lint test test-e2e clean goreleaser-dev-build install-tools install-foundry run-op gendoc +.PHONY: all build build-admin lint test test-e2e test-quick clean goreleaser-dev-build install-tools install-foundry run-op gendoc # Go parameters COMMIT_SHA = $(shell git rev-parse HEAD) @@ -29,6 +29,10 @@ test: lint test-e2e: $(GOTEST) -v -p 5 ./test/ +# test-quick: run tests with 30s timeout, skipping slow/flaky e2e tests. Use -short so TestE2EInit_ConvertToCustomBuild_TS is skipped. +test-quick: + $(GOTEST) ./... -v -short -skip 'MultiCommandHappyPaths|TestPostToGateway|TestBlankWorkflowSimulation|TestWaitForBackendLinkProcessing|TestTryAutoLink|TestCheckLinkStatusViaGraphQL|Fails to run tests with invalid Go code' -timeout 30s + clean: $(GOCLEAN) rm -f $(BINARY_NAME) diff --git a/cmd/common/compile.go b/cmd/common/compile.go new file mode 100644 index 00000000..2f1798cd --- /dev/null +++ b/cmd/common/compile.go @@ -0,0 +1,153 @@ +package common + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/constants" +) + +const ( + makefileName = "Makefile" + defaultWasmOutput = "wasm/workflow.wasm" // hardcoded in Makefile by convert; must match workflow-path default +) + +// getBuildCmd returns a single step that builds the workflow and returns the WASM bytes. +func getBuildCmd(workflowRootFolder, mainFile, language string) (func() ([]byte, error), error) { + tmpPath := filepath.Join(workflowRootFolder, ".cre_build_tmp.wasm") + switch language { + case constants.WorkflowLanguageTypeScript: + cmd := exec.Command("bun", "cre-compile", mainFile, tmpPath) + cmd.Dir = workflowRootFolder + return func() ([]byte, error) { + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + b, err := os.ReadFile(tmpPath) + _ = os.Remove(tmpPath) + return b, err + }, nil + case constants.WorkflowLanguageGolang: + // Build the package (.) so all .go files (main.go, workflow.go, etc.) are compiled together + cmd := exec.Command( + "go", "build", + "-o", tmpPath, + "-trimpath", + "-ldflags=-buildid= -w -s", + ".", + ) + cmd.Dir = workflowRootFolder + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") + return func() ([]byte, error) { + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + b, err := os.ReadFile(tmpPath) + _ = os.Remove(tmpPath) + return b, err + }, nil + case constants.WorkflowLanguageWasm: + makeRoot, err := findMakefileRoot(workflowRootFolder) + if err != nil { + return nil, err + } + makeCmd := exec.Command("make", "build") + makeCmd.Dir = makeRoot + builtPath := filepath.Join(makeRoot, defaultWasmOutput) + return func() ([]byte, error) { + out, err := makeCmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + return os.ReadFile(builtPath) + }, nil + default: + // Build the package (.) so all .go files are compiled together + cmd := exec.Command( + "go", "build", + "-o", tmpPath, + "-trimpath", + "-ldflags=-buildid= -w -s", + ".", + ) + cmd.Dir = workflowRootFolder + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") + return func() ([]byte, error) { + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + b, err := os.ReadFile(tmpPath) + _ = os.Remove(tmpPath) + return b, err + }, nil + } +} + +// CompileWorkflowToWasm compiles the workflow at workflowPath and returns the WASM binary. +// It runs the sequence of commands from getBuildCmds (make build + copy for WASM, or single build for Go/TS), then reads the temp WASM file. +func CompileWorkflowToWasm(workflowPath string) ([]byte, error) { + workflowRootFolder, workflowMainFile, err := WorkflowPathRootAndMain(workflowPath) + if err != nil { + return nil, fmt.Errorf("workflow path: %w", err) + } + workflowAbsFile := filepath.Join(workflowRootFolder, workflowMainFile) + language := GetWorkflowLanguage(workflowMainFile) + + if language != constants.WorkflowLanguageWasm { + if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { + return nil, fmt.Errorf("workflow file not found: %s", workflowAbsFile) + } + } + + switch language { + case constants.WorkflowLanguageTypeScript: + if err := EnsureTool("bun"); err != nil { + return nil, 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 := EnsureTool("go"); err != nil { + return nil, errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") + } + case constants.WorkflowLanguageWasm: + if err := EnsureTool("make"); err != nil { + return nil, errors.New("make is required for WASM workflows but was not found in PATH") + } + default: + return nil, fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) + } + + buildStep, err := getBuildCmd(workflowRootFolder, workflowMainFile, language) + if err != nil { + return nil, err + } + wasm, err := buildStep() + if err != nil { + return nil, fmt.Errorf("failed to compile workflow: %w", err) + } + return wasm, nil +} + +// findMakefileRoot walks up from dir and returns the first directory that contains a Makefile. +func findMakefileRoot(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + for { + if _, err := os.Stat(filepath.Join(abs, makefileName)); err == nil { + return abs, nil + } + parent := filepath.Dir(abs) + if parent == abs { + return "", errors.New("no Makefile found in directory or any parent (required for WASM workflow build)") + } + abs = parent + } +} diff --git a/cmd/common/compile_test.go b/cmd/common/compile_test.go new file mode 100644 index 00000000..aec71b63 --- /dev/null +++ b/cmd/common/compile_test.go @@ -0,0 +1,128 @@ +package common + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func deployTestdataPath(elem ...string) string { + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Dir(filename) + return filepath.Join(append([]string{dir, "..", "workflow", "deploy", "testdata"}, elem...)...) +} + +func TestFindMakefileRoot(t *testing.T) { + dir := t.TempDir() + + _, err := findMakefileRoot(dir) + require.Error(t, err) + require.Contains(t, err.Error(), "no Makefile found") + + require.NoError(t, os.WriteFile(filepath.Join(dir, makefileName), []byte("build:\n\techo ok\n"), 0600)) + root, err := findMakefileRoot(dir) + require.NoError(t, err) + absDir, _ := filepath.Abs(dir) + require.Equal(t, absDir, root) + + sub := filepath.Join(dir, "wasm") + require.NoError(t, os.MkdirAll(sub, 0755)) + root, err = findMakefileRoot(sub) + require.NoError(t, err) + require.Equal(t, absDir, root) +} + +func TestCompileWorkflowToWasm_Go_Success(t *testing.T) { + t.Run("basic_workflow", func(t *testing.T) { + path := deployTestdataPath("basic_workflow", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) + + t.Run("configless_workflow", func(t *testing.T) { + path := deployTestdataPath("configless_workflow", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) + + t.Run("missing_go_mod", func(t *testing.T) { + path := deployTestdataPath("missing_go_mod", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) +} + +func TestCompileWorkflowToWasm_Go_Malformed_Fails(t *testing.T) { + path := deployTestdataPath("malformed_workflow", "main.go") + _, err := CompileWorkflowToWasm(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to compile workflow") + assert.Contains(t, err.Error(), "undefined: sdk.RemovedFunctionThatFailsCompilation") +} + +func TestCompileWorkflowToWasm_Wasm_Success(t *testing.T) { + wasmPath := deployTestdataPath("custom_wasm_workflow", "wasm", "workflow.wasm") + _ = os.Remove(wasmPath) + t.Cleanup(func() { _ = os.Remove(wasmPath) }) + + wasm, err := CompileWorkflowToWasm(wasmPath) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + + _, err = os.Stat(wasmPath) + require.NoError(t, err, "make build should produce wasm/workflow.wasm") +} + +func TestCompileWorkflowToWasm_Wasm_Fails(t *testing.T) { + t.Run("no_makefile", func(t *testing.T) { + dir := t.TempDir() + wasmDir := filepath.Join(dir, "wasm") + require.NoError(t, os.MkdirAll(wasmDir, 0755)) + wasmPath := filepath.Join(wasmDir, "workflow.wasm") + require.NoError(t, os.WriteFile(wasmPath, []byte("not really wasm"), 0600)) + + _, err := CompileWorkflowToWasm(wasmPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "no Makefile found") + }) + + t.Run("make_build_fails", func(t *testing.T) { + path := deployTestdataPath("wasm_make_fails", "wasm", "workflow.wasm") + _, err := CompileWorkflowToWasm(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to compile workflow") + assert.Contains(t, err.Error(), "build output:") + }) +} + +func TestCompileWorkflowToWasm_TS_Success(t *testing.T) { + if err := EnsureTool("bun"); err != nil { + t.Skip("bun not in PATH, skipping TS compile test") + } + dir := t.TempDir() + mainPath := filepath.Join(dir, "main.ts") + require.NoError(t, os.WriteFile(mainPath, []byte(`export async function main() { return "ok"; } +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"latest"}} +`), 0600)) + install := exec.Command("bun", "install") + install.Dir = dir + install.Stdout = os.Stdout + install.Stderr = os.Stderr + if err := install.Run(); err != nil { + t.Skipf("bun install failed (network or cre-sdk): %v", err) + } + wasm, err := CompileWorkflowToWasm(mainPath) + if err != nil { + t.Skipf("TS compile failed (published cre-sdk may lack full layout): %v", err) + } + assert.NotEmpty(t, wasm) +} diff --git a/cmd/common/utils.go b/cmd/common/utils.go index 98805798..5c7d48cc 100644 --- a/cmd/common/utils.go +++ b/cmd/common/utils.go @@ -166,15 +166,52 @@ func ToStringSlice(args []any) []string { } // GetWorkflowLanguage determines the workflow language based on the file extension -// Note: inputFile can be a file path (e.g., "main.ts" or "main.go") or a directory (for Go workflows, e.g., ".") -// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageGolang otherwise +// Note: inputFile can be a file path (e.g., "main.ts", "main.go", or "workflow.wasm") or a directory (for Go workflows, e.g., ".") +// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageWasm for .wasm files, constants.WorkflowLanguageGolang otherwise func GetWorkflowLanguage(inputFile string) string { if strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") { return constants.WorkflowLanguageTypeScript } + if strings.HasSuffix(inputFile, ".wasm") { + return constants.WorkflowLanguageWasm + } return constants.WorkflowLanguageGolang } +// ResolveWorkflowPath turns a workflow-path value from YAML (e.g. "." or "main.ts") into an +// absolute path to the main file. When pathFromYAML is "." or "", looks for main.go then main.ts +// under workflowDir. Callers can use GetWorkflowLanguage on the result to get the language. +func ResolveWorkflowPath(workflowDir, pathFromYAML string) (absPath string, err error) { + workflowDir, err = filepath.Abs(workflowDir) + if err != nil { + return "", fmt.Errorf("workflow directory: %w", err) + } + if pathFromYAML == "" || pathFromYAML == "." { + mainGo := filepath.Join(workflowDir, "main.go") + mainTS := filepath.Join(workflowDir, "main.ts") + if _, err := os.Stat(mainGo); err == nil { + return mainGo, nil + } + if _, err := os.Stat(mainTS); err == nil { + return mainTS, nil + } + return "", fmt.Errorf("no main.go or main.ts in %s", workflowDir) + } + joined := filepath.Join(workflowDir, pathFromYAML) + return filepath.Abs(joined) +} + +// WorkflowPathRootAndMain returns the absolute root directory and main file name for a workflow +// path (e.g. "workflowName/main.go" -> rootDir, "main.go"). Use with GetWorkflowLanguage(mainFile) +// for consistent language detection. +func WorkflowPathRootAndMain(workflowPath string) (rootDir, mainFile string, err error) { + abs, err := filepath.Abs(workflowPath) + if err != nil { + return "", "", fmt.Errorf("workflow path: %w", err) + } + return filepath.Dir(abs), filepath.Base(abs), nil +} + // EnsureTool checks that the binary exists on PATH func EnsureTool(bin string) error { if _, err := exec.LookPath(bin); err != nil { @@ -183,41 +220,6 @@ func EnsureTool(bin string) error { return nil } -// Gets a build command for either Golang or Typescript based on the filename -func GetBuildCmd(inputFile string, outputFile string, rootFolder string) *exec.Cmd { - isTypescriptWorkflow := strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") - - var buildCmd *exec.Cmd - if isTypescriptWorkflow { - buildCmd = exec.Command( - "bun", - "cre-compile", - inputFile, - outputFile, - ) - } else { - // The build command for reproducible and trimmed binaries. - // -trimpath removes all file system paths from the compiled binary. - // -ldflags="-buildid= -w -s" further reduces the binary size: - // -buildid= removes the build ID, ensuring reproducibility. - // -w disables DWARF debugging information. - // -s removes the symbol table. - buildCmd = exec.Command( - "go", - "build", - "-o", outputFile, - "-trimpath", - "-ldflags=-buildid= -w -s", - inputFile, - ) - buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") - } - - buildCmd.Dir = rootFolder - - return buildCmd -} - func WriteChangesetFile(fileName string, changesetFile *inttypes.ChangesetFile, settings *settings.Settings) error { fullFilePath := filepath.Join( filepath.Clean(settings.CLDSettings.CLDPath), diff --git a/cmd/creinit/creinit.go b/cmd/creinit/creinit.go index ea8d3480..d335e609 100644 --- a/cmd/creinit/creinit.go +++ b/cmd/creinit/creinit.go @@ -30,8 +30,9 @@ const SecretsFileName = "secrets.yaml" type TemplateLanguage string const ( - TemplateLangGo TemplateLanguage = "go" - TemplateLangTS TemplateLanguage = "typescript" + TemplateLangGo TemplateLanguage = "go" + TemplateLangTS TemplateLanguage = "typescript" + TemplateLangWasm TemplateLanguage = "wasm" ) const ( @@ -75,6 +76,14 @@ var languageTemplates = []LanguageTemplate{ {Folder: "typescriptConfHTTP", Title: "Confidential Http: Typescript example using the confidential http capability", ID: 5, Name: ConfHTTPTemplate, Hidden: true}, }, }, + { + Title: "Self-compiled WASM (advanced)", + Lang: TemplateLangWasm, + EntryPoint: "./wasm/workflow.wasm", + Workflows: []WorkflowTemplate{ + {Folder: "wasmBlankTemplate", Title: "Blank: Self-compiled WASM workflow template", ID: 6, Name: HelloWorldTemplate}, + }, + }, } type Inputs struct { @@ -365,6 +374,8 @@ func (h *handler) Execute(inputs Inputs) error { h.runtimeContext.Workflow.Language = constants.WorkflowLanguageGolang case TemplateLangTS: h.runtimeContext.Workflow.Language = constants.WorkflowLanguageTypeScript + case TemplateLangWasm: + h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm } } @@ -372,7 +383,8 @@ func (h *handler) Execute(inputs Inputs) error { fmt.Println("") fmt.Println("Next steps:") - if selectedLanguageTemplate.Lang == TemplateLangGo { + switch selectedLanguageTemplate.Lang { + case TemplateLangGo: fmt.Println(" 1. Navigate to your project directory:") fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) fmt.Println("") @@ -382,7 +394,7 @@ func (h *handler) Execute(inputs Inputs) error { fmt.Printf(" 3. (Optional) Consult %s to learn more about this template:\n\n", filepath.Join(filepath.Base(workflowDirectory), "README.md")) fmt.Println("") - } else { + case TemplateLangTS: fmt.Println(" 1. Navigate to your project directory:") fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) fmt.Println("") @@ -398,6 +410,22 @@ func (h *handler) Execute(inputs Inputs) error { fmt.Printf(" 5. (Optional) Consult %s to learn more about this template:\n\n", filepath.Join(filepath.Base(workflowDirectory), "README.md")) fmt.Println("") + case TemplateLangWasm: + fmt.Println(" 1. Navigate to your project directory:") + fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) + fmt.Println("") + fmt.Println(" 2. Add your build logic to the Makefile:") + fmt.Printf(" Edit %s/Makefile and implement the 'build' target\n", filepath.Base(workflowDirectory)) + fmt.Println("") + fmt.Println(" 3. Build your workflow:") + fmt.Printf(" cd %s && make build\n", filepath.Base(workflowDirectory)) + fmt.Println("") + fmt.Println(" 4. Run the workflow on your machine:") + fmt.Printf(" cre workflow simulate %s\n", workflowName) + fmt.Println("") + fmt.Printf(" 5. (Optional) Consult %s to learn more about this template:\n\n", + filepath.Join(filepath.Base(workflowDirectory), "README.md")) + fmt.Println("") } return nil } diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl b/cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl new file mode 100644 index 00000000..4d0f01be --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl @@ -0,0 +1,9 @@ +.PHONY: build + +build: + # TODO: Add your build logic here + # This target should compile your workflow to wasm/workflow.wasm + # Example for Go: + # GOOS=wasip1 GOARCH=wasm go build -o wasm/workflow.wasm . + @echo "Please implement the build target in the Makefile" + @exit 1 diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/README.md b/cmd/creinit/template/workflow/wasmBlankTemplate/README.md new file mode 100644 index 00000000..94056ad3 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/README.md @@ -0,0 +1,32 @@ +# Self-compiled WASM Workflow Template + +This template provides a blank workflow template for self-compiled WASM workflows. It includes the necessary files for a workflow, excluding workflow code. + +## Structure + +- `Makefile`: Contains a TODO on the `build` target where you should add your build logic +- `workflow.yaml`: Workflow settings file with the wasm directory configured +- `config.staging.json` and `config.production.json`: Configuration files for different environments +- `secrets.yaml`: Secrets file (if needed) + +## Steps to use + +1. **Add your build logic**: Edit the `Makefile` and implement the `build` target. This should compile your workflow to `wasm/workflow.wasm`. + +2. **Build your workflow**: Run `make build` from the workflow directory. + +3. **Simulate the workflow**: From the project root, run: + ```bash + cre workflow simulate --target=staging-settings + ``` + +## Example Makefile build target + +```makefile +build: + # TODO: Add your build logic here + # Example for Go: + # GOOS=wasip1 GOARCH=wasm go build -o wasm/workflow.wasm . + @echo "Please implement the build target in the Makefile" + exit 1 +``` diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json b/cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json @@ -0,0 +1 @@ +{} diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json b/cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json @@ -0,0 +1 @@ +{} diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml b/cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml new file mode 100644 index 00000000..7b85d864 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml @@ -0,0 +1 @@ +secretsNames: diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md b/cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md new file mode 100644 index 00000000..5a3491cd --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md @@ -0,0 +1,3 @@ +# WASM Directory + +This directory should contain your compiled WASM file (`workflow.wasm`) after running `make build`. diff --git a/cmd/root.go b/cmd/root.go index 51af5fb8..81a4efda 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -257,23 +257,24 @@ 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 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 account": {}, - "cre secrets": {}, - "cre": {}, + "cre version": {}, + "cre login": {}, + "cre logout": {}, + "cre whoami": {}, + "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 convert-to-custom-build": {}, + "cre account": {}, + "cre secrets": {}, + "cre": {}, } _, exists := excludedCommands[cmd.CommandPath()] diff --git a/cmd/workflow/convert/convert.go b/cmd/workflow/convert/convert.go new file mode 100644 index 00000000..10831256 --- /dev/null +++ b/cmd/workflow/convert/convert.go @@ -0,0 +1,147 @@ +package convert + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/prompt" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +const ( + wasmWorkflowPath = "./wasm/workflow.wasm" + convertWarning = "This will convert your workflow to a custom (self-compiled) build. This cannot be undone by the CLI. Continue?" +) + +type Inputs struct { + WorkflowFolder string + Force bool +} + +func New(runtimeContext *runtime.Context) *cobra.Command { + var force bool + convertCmd := &cobra.Command{ + Use: "convert-to-custom-build ", + Short: "Converts an existing workflow to a custom (self-compiled) build", + Long: `Converts a Go or TypeScript workflow to use a custom build via Makefile, producing wasm/workflow.wasm. The workflow-path in workflow.yaml is updated to ./wasm/workflow.wasm. This cannot be undone.`, + Args: cobra.ExactArgs(1), + Example: `cre workflow convert-to-custom-build ./my-workflow`, + RunE: func(cmd *cobra.Command, args []string) error { + handler := newHandler(runtimeContext, cmd.InOrStdin()) + inputs := Inputs{ + WorkflowFolder: args[0], + Force: force, + } + return handler.Execute(inputs) + }, + } + convertCmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt and convert immediately") + return convertCmd +} + +type handler struct { + log *zerolog.Logger + stdin io.Reader + runtimeContext *runtime.Context +} + +func newHandler(runtimeContext *runtime.Context, stdin io.Reader) *handler { + h := &handler{stdin: stdin, runtimeContext: runtimeContext} + if runtimeContext != nil { + h.log = runtimeContext.Logger + } + return h +} + +func (h *handler) Execute(inputs Inputs) error { + workflowDir, err := filepath.Abs(inputs.WorkflowFolder) + if err != nil { + return fmt.Errorf("workflow folder path: %w", err) + } + workflowYAML := filepath.Join(workflowDir, constants.DefaultWorkflowSettingsFileName) + currentPath, err := settings.GetWorkflowPathFromFile(workflowYAML) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("workflow folder does not contain %s: %w", constants.DefaultWorkflowSettingsFileName, err) + } + return err + } + workflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, currentPath) + if err != nil { + return fmt.Errorf("cannot detect workflow language: %w", err) + } + lang := cmdcommon.GetWorkflowLanguage(workflowPath) + if lang == constants.WorkflowLanguageWasm { + return fmt.Errorf("workflow is already a custom build (workflow-path is %s)", currentPath) + } + + if !inputs.Force { + confirmed, err := prompt.YesNoPrompt(h.stdin, convertWarning) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Convert cancelled.") + return nil + } + } + + if err := settings.SetWorkflowPathInFile(workflowYAML, wasmWorkflowPath); err != nil { + return err + } + + wasmDir := filepath.Join(workflowDir, "wasm") + if err := os.MkdirAll(wasmDir, 0755); err != nil { + return fmt.Errorf("create wasm directory: %w", err) + } + + makefilePath := filepath.Join(workflowDir, "Makefile") + mainFile := filepath.Base(workflowPath) + makefile, err := makefileContent(workflowDir, lang, mainFile) + if err != nil { + return err + } + if err := os.WriteFile(makefilePath, []byte(makefile), 0600); err != nil { + return fmt.Errorf("write Makefile: %w", err) + } + + fmt.Println("Workflow converted to custom build. workflow-path is now", wasmWorkflowPath) + fmt.Println("The Makefile is configured to output the WASM to this path. Run: make build") + return nil +} + +func goMakefile() string { + return `.PHONY: build + +build: + GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o wasm/workflow.wasm -trimpath -ldflags="-buildid= -w -s" . +` +} + +func makefileContent(workflowDir, lang string, mainFile string) (string, error) { + switch lang { + case constants.WorkflowLanguageGolang: + return goMakefile(), nil + case constants.WorkflowLanguageTypeScript: + return makefileContentTS(workflowDir, mainFile) + default: + return "", fmt.Errorf("unsupported workflow language") + } +} + +func makefileContentTS(_, mainFile string) (string, error) { + return fmt.Sprintf(`.PHONY: build + +build: + bun cre-compile %s wasm/workflow.wasm +`, mainFile), nil +} diff --git a/cmd/workflow/convert/convert_test.go b/cmd/workflow/convert/convert_test.go new file mode 100644 index 00000000..be678a62 --- /dev/null +++ b/cmd/workflow/convert/convert_test.go @@ -0,0 +1,206 @@ +package convert + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" +) + +const selectDownEnter = "\033[B\n" // down arrow + Enter (selects "No" in YesNoPrompt) + +func TestConvert_AlreadyWasm_ReturnsError(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + yamlContent := `staging-settings: + user-workflow: + workflow-name: "foo-staging" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "foo-production" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + + h := newHandler(nil, nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "already a custom build") +} + +func TestConvert_Force_UpdatesYAMLAndCreatesMakefile(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + h := newHandler(nil, nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + + require.DirExists(t, filepath.Join(dir, "wasm")) + makefile := filepath.Join(dir, "Makefile") + require.FileExists(t, makefile) + content, _ := os.ReadFile(makefile) + require.Contains(t, string(content), "build") + require.Contains(t, string(content), "wasm/workflow.wasm") +} + +func TestConvert_PromptNo_Cancels(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + // YesNoPrompt: ["Yes", "No"]; down+Enter selects No + h := newHandler(nil, strings.NewReader(selectDownEnter)) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), "workflow-path: \".\"") + require.NotContains(t, string(data), wasmWorkflowPath) + require.NoFileExists(t, filepath.Join(dir, "Makefile")) +} + +func TestConvert_PromptYes_Proceeds(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + // YesNoPrompt: default is Yes; Enter proceeds + h := newHandler(nil, strings.NewReader("\n")) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + require.FileExists(t, filepath.Join(dir, "Makefile")) + require.DirExists(t, filepath.Join(dir, "wasm")) +} + +func TestConvert_PromptEmpty_DefaultsYes_Proceeds(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + // YesNoPrompt defaults to Yes; Enter proceeds + h := newHandler(nil, strings.NewReader("\n")) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + require.FileExists(t, filepath.Join(dir, "Makefile")) +} + +func TestConvert_TS_InstallsDepsIfNoNodeModules(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainTS := filepath.Join(dir, "main.ts") + packageJSON := filepath.Join(dir, "package.json") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "main.ts" + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "main.ts" + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainTS, []byte("export default function run() { return Promise.resolve({ result: \"ok\" }); }\n"), 0600)) + require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"^1.0.3"}}`), 0600)) + + h := newHandler(nil, nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dir, "Makefile")) + makefile, _ := os.ReadFile(filepath.Join(dir, "Makefile")) + require.Contains(t, string(makefile), "bun cre-compile", "Makefile should match CLI build") + require.Contains(t, string(makefile), "main.ts", "Makefile should build main.ts") + require.Contains(t, string(makefile), "wasm/workflow.wasm", "Makefile should output to wasm/workflow.wasm") + + // CLI must not change the workflow; main.ts unchanged + mainTSContent, _ := os.ReadFile(mainTS) + require.Contains(t, string(mainTSContent), "export default function run()", "convert must not modify workflow source") +} diff --git a/cmd/workflow/deploy/compile.go b/cmd/workflow/deploy/compile.go index 51387569..fe046ea3 100644 --- a/cmd/workflow/deploy/compile.go +++ b/cmd/workflow/deploy/compile.go @@ -3,16 +3,13 @@ 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" ) func (h *handler) Compile() error { @@ -34,60 +31,28 @@ func (h *handler) Compile() error { h.inputs.OutputPath += ".b64" // Append ".b64" if it doesn't already end with ".b64" } - workflowAbsFile, err := filepath.Abs(h.inputs.WorkflowPath) + workflowDir, err := os.Getwd() if err != nil { - return fmt.Errorf("failed to get absolute path for the workflow file: %w", err) + return fmt.Errorf("workflow directory: %w", err) } - - if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { - return fmt.Errorf("workflow file not found: %s", workflowAbsFile) + resolvedWorkflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, h.inputs.WorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } + _, workflowMainFile, err := cmdcommon.WorkflowPathRootAndMain(resolvedWorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) } - - 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) - } } - 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() + wasmFile, err := cmdcommon.CompileWorkflowToWasm(resolvedWorkflowPath) 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) + return fmt.Errorf("failed to compile workflow: %w", err) } - 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) @@ -99,10 +64,6 @@ func (h *handler) Compile() error { } 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 } diff --git a/cmd/workflow/deploy/compile_test.go b/cmd/workflow/deploy/compile_test.go index d0ebadd8..51f42911 100644 --- a/cmd/workflow/deploy/compile_test.go +++ b/cmd/workflow/deploy/compile_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/testutil/chainsim" @@ -165,102 +166,6 @@ func TestCompileCmd(t *testing.T) { httpmock.Activate() t.Cleanup(httpmock.DeactivateAndReset) - tests := []struct { - inputs Inputs - wantErr string - compilationErr string - WorkflowOwnerType string - }{ - { - inputs: Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "malformed_workflow", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, - WorkflowOwnerType: constants.WorkflowOwnerTypeEOA, - wantErr: "failed to compile workflow: exit status 1", - compilationErr: "undefined: sdk.RemovedFunctionThatFailsCompilation", - }, - } - - for _, tt := range tests { - t.Run(tt.wantErr, func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - ctx, buf := simulatedEnvironment.NewRuntimeContextWithBufferedOutput() - handler := newHandler(ctx, buf) - - ctx.Settings = createTestSettings( - chainsim.TestAddress, - tt.WorkflowOwnerType, - "test_workflow", - tt.inputs.WorkflowPath, - tt.inputs.ConfigPath, - ) - handler.settings = ctx.Settings - handler.inputs = tt.inputs - err := handler.ValidateInputs() - require.NoError(t, err) - - err = handler.Execute() - - w.Close() - os.Stdout = oldStdout - var output strings.Builder - _, _ = io.Copy(&output, r) - - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - - if tt.compilationErr != "" { - assert.Contains(t, output.String(), tt.compilationErr) - } - }) - } - }) - - t.Run("no config", func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - ctx, _ := simulatedEnvironment.NewRuntimeContextWithBufferedOutput() - - ctx.Settings = createTestSettings( - chainsim.TestAddress, - constants.WorkflowOwnerTypeEOA, - "test_workflow", - "testdata/configless_workflow/main.go", - "", - ) - - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "configless_workflow", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - }) - - t.Run("with config", func(t *testing.T) { simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) defer simulatedEnvironment.Close() @@ -268,142 +173,54 @@ func TestCompileCmd(t *testing.T) { WorkflowName: "test_workflow", WorkflowOwner: chainsim.TestAddress, DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), + WorkflowPath: filepath.Join("testdata", "malformed_workflow", "main.go"), OutputPath: outputPath, - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to compile workflow") + assert.ErrorContains(t, err, "undefined: sdk.RemovedFunctionThatFailsCompilation") }) - - t.Run("compiles even without go.mod", func(t *testing.T) { - // it auto falls back to using the go.mod in the root directory (/cre-cli) - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "missing_go_mod", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - }) - }) } -func TestCompileCreatesBase64EncodedFile(t *testing.T) { +func TestCompileOutputMatchesUnderlying(t *testing.T) { simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) + baseInputs := Inputs{ + WorkflowName: "test_workflow", + WorkflowOwner: chainsim.TestAddress, + DonFamily: "test_label", + WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), + ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), + WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", + WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", + } - t.Run("default output file is binary.wasm.br", func(t *testing.T) { - expectedOutputPath := "./binary.wasm.br.b64" - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(expectedOutputPath) - - require.NoError(t, err) - assert.FileExists(t, expectedOutputPath) + t.Run("default output path", func(t *testing.T) { + inputs := baseInputs + inputs.OutputPath = "./binary.wasm.br.b64" + assertCompileOutputMatchesUnderlying(t, simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) }) - t.Run("ensures output file has .wasm.br.b64 extension", func(t *testing.T) { + t.Run("output path extension variants", func(t *testing.T) { tests := []struct { - name string - outputPath string - expectedOutput string + name string + outputPath string }{ - { - name: "no extension", - outputPath: "./my-binary", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "missing .br and .b64", - outputPath: "./my-binary.wasm", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "missing .b64", - outputPath: "./my-binary.wasm.br", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "all extensions", - outputPath: "./my-binary.wasm.br.b64", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "all extensions - same as default", - outputPath: "./binary.wasm.br.b64", - expectedOutput: "./binary.wasm.br.b64", - }, + {"no extension", "./my-binary"}, + {"missing .br and .b64", "./my-binary.wasm"}, + {"missing .b64", "./my-binary.wasm.br"}, + {"all extensions", "./my-binary.wasm.br.b64"}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - OutputPath: tt.outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(tt.expectedOutput) - - require.NoError(t, err) - assert.FileExists(t, tt.expectedOutput) + inputs := baseInputs + inputs.OutputPath = tt.outputPath + assertCompileOutputMatchesUnderlying(t, simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) }) } }) - - t.Run("output file is base64 encoded", func(t *testing.T) { - outputPath := "./binary.wasm.br.b64" - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - assert.FileExists(t, outputPath) - - // Read the output file content - content, err := os.ReadFile(outputPath) - require.NoError(t, err) - - // Check if the content is valid base64 - _, err = base64.StdEncoding.DecodeString(string(content)) - assert.NoError(t, err, "Output file content should be valid base64 encoded data") - }) } // createTestSettings is a helper function to construct settings for tests @@ -458,3 +275,76 @@ func runCompile(simulatedEnvironment *chainsim.SimulatedEnvironment, inputs Inpu return handler.Compile() } + +// outputPathWithExtensions returns the path with .wasm.br.b64 appended as in Compile(). +func outputPathWithExtensions(path string) string { + if path == "" { + path = defaultOutputPath + } + if !strings.HasSuffix(path, ".b64") { + if !strings.HasSuffix(path, ".br") { + if !strings.HasSuffix(path, ".wasm") { + path += ".wasm" + } + path += ".br" + } + path += ".b64" + } + return path +} + +// assertCompileOutputMatchesUnderlying compiles via handler.Compile(), then verifies the output +// file content equals CompileWorkflowToWasm(workflowPath) + brotli + base64. +func assertCompileOutputMatchesUnderlying(t *testing.T, simulatedEnvironment *chainsim.SimulatedEnvironment, inputs Inputs, ownerType string) { + t.Helper() + wasm, err := cmdcommon.CompileWorkflowToWasm(inputs.WorkflowPath) + require.NoError(t, err) + compressed, err := applyBrotliCompressionV2(&wasm) + require.NoError(t, err) + expected := base64.StdEncoding.EncodeToString(compressed) + + err = runCompile(simulatedEnvironment, inputs, ownerType) + require.NoError(t, err) + + actualPath := outputPathWithExtensions(inputs.OutputPath) + t.Cleanup(func() { _ = os.Remove(actualPath) }) + actual, err := os.ReadFile(actualPath) + require.NoError(t, err) + assert.Equal(t, expected, string(actual), "handler.Compile() output should match CompileWorkflowToWasm + brotli + base64") +} + +// TestCustomWasmWorkflowRunsMakeBuild ensures that simulate/deploy run "make build" for a custom +// WASM workflow (workflow-path pointing to .wasm) so the user does not need to run make build manually. +func TestCustomWasmWorkflowRunsMakeBuild(t *testing.T) { + customWasmDir := filepath.Join("testdata", "custom_wasm_workflow") + wasmPath := filepath.Join(customWasmDir, "wasm", "workflow.wasm") + + // Remove wasm file if present so we assert the CLI builds it (CompileWorkflowToWasm runs make via ensureWasmBuilt). + _ = os.Remove(wasmPath) + t.Cleanup(func() { _ = os.Remove(wasmPath) }) + + simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) + defer simulatedEnvironment.Close() + + outputPath := filepath.Join(customWasmDir, "test_out.wasm.br.b64") + t.Cleanup(func() { _ = os.Remove(outputPath) }) + + inputs := Inputs{ + WorkflowName: "custom_wasm_workflow", + WorkflowOwner: chainsim.TestAddress, + DonFamily: "test_label", + WorkflowPath: wasmPath, + ConfigPath: filepath.Join(customWasmDir, "config.yml"), + WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", + WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", + OutputPath: outputPath, + } + + // runCompile calls ValidateInputs then Compile; CompileWorkflowToWasm runs make build internally. No manual make build. + err := runCompile(simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) + require.NoError(t, err, "custom WASM workflow should build via CLI (CompileWorkflowToWasm) without manual make build") + + // Ensure the wasm was actually built by the CLI + _, err = os.Stat(wasmPath) + require.NoError(t, err, "wasm/workflow.wasm should exist after compile") +} diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 2839ae68..d9c96eb0 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -32,7 +32,7 @@ type Inputs struct { ConfigURL *string `validate:"omitempty,http_url|eq="` KeepAlive bool - WorkflowPath string `validate:"required,path_read"` + WorkflowPath string `validate:"required,workflow_path_read"` ConfigPath string `validate:"omitempty,file,ascii,max=97" cli:"--config"` OutputPath string `validate:"omitempty,filepath,ascii,max=97" cli:"--output"` diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile b/cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile new file mode 100644 index 00000000..c910fcf4 --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile @@ -0,0 +1,4 @@ +.PHONY: build + +build: + GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o wasm/workflow.wasm -trimpath -ldflags="-buildid= -w -s" . diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml b/cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml new file mode 100644 index 00000000..87df9017 --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml @@ -0,0 +1,3 @@ +workflowName: "Basic Workflow" +workflowOwner: "0x775edE8C0718c655e5238239aC553E9657bcd8C2" +basicTriggerInterval: 1 # in seconds diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod new file mode 100644 index 00000000..83f89f9e --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod @@ -0,0 +1,68 @@ +module custom_wasm_workflow + +go 1.23.3 + +toolchain go1.23.4 + +require ( + github.com/smartcontractkit/chainlink-common v0.4.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.13.0 // indirect + github.com/go-playground/universal-translator v0.17.0 // indirect + github.com/go-playground/validator/v10 v10.4.1 // indirect + github.com/go-viper/mapstructure/v2 v2.1.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/invopop/jsonschema v0.12.0 // indirect + github.com/leodido/go-urn v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.opentelemetry.io/otel v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240823153156-2a54df7bffb9 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.6.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.4.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 // indirect + go.opentelemetry.io/otel/log v0.6.0 // indirect + go.opentelemetry.io/otel/metric v1.30.0 // indirect + go.opentelemetry.io/otel/sdk v1.30.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.6.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.30.0 // indirect + go.opentelemetry.io/otel/trace v1.30.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum new file mode 100644 index 00000000..07060312 --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum @@ -0,0 +1,158 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= +github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w= +github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +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/santhosh-tekuri/jsonschema/v5 v5.2.0 h1:WCcC4vZDS1tYNxjWlwRJZQy28r8CMoggKnxNzxsVDMQ= +github.com/santhosh-tekuri/jsonschema/v5 v5.2.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +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-common v0.4.0 h1:GZ9MhHt5QHXSaK/sAZvKDxkEqF4fPiFHWHEPqs/2C2o= +github.com/smartcontractkit/chainlink-common v0.4.0/go.mod h1:yti7e1+G9hhkYhj+L5sVUULn9Bn3bBL5/AxaNqdJ5YQ= +github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 h1:NzZGjaqez21I3DU7objl3xExTH4fxYvzTqar8DC6360= +github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12/go.mod h1:fb1ZDVXACvu4frX3APHZaEBp0xi1DIm34DcA0CwTsZM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= +go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240823153156-2a54df7bffb9 h1:UiRNKd1OgqsLbFwE+wkAWTdiAxXtCBqKIHeBIse4FUA= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240823153156-2a54df7bffb9/go.mod h1:eqZlW3pJWhjyexnDPrdQxix1pn0wwhI4AO4GKpP/bMI= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.6.0 h1:QSKmLBzbFULSyHzOdO9JsN9lpE4zkrz1byYGmJecdVE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.6.0/go.mod h1:sTQ/NH8Yrirf0sJ5rWqVu+oT82i4zL9FaF6rWcqnptM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 h1:U2guen0GhqH8o/G2un8f/aG/y++OuW6MyCo6hT9prXk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0/go.mod h1:yeGZANgEcpdx/WK0IvvRFC+2oLiMS2u4L/0Rj2M2Qr0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0 h1:VrMAbeJz4gnVDg2zEzjHG4dEH86j4jO6VYB+NgtGD8s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0/go.mod h1:qqN/uFdpeitTvm+JDqqnjm517pmQRYxTORbETHq5tOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.4.0 h1:0MH3f8lZrflbUWXVxyBg/zviDFdGE062uKh5+fu8Vv0= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.4.0/go.mod h1:Vh68vYiHY5mPdekTr0ox0sALsqjoVy0w3Os278yX5SQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0 h1:BJee2iLkfRfl9lc7aFmBwkWxY/RI1RDdXepSF6y8TPE= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0/go.mod h1:DIzlHs3DRscCIBU3Y9YSzPfScwnYnzfnCd4g8zA7bZc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 h1:EVSnY9JbEEW92bEkIYOVMw4q1WJxIAGoFTrtYOzWuRQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0/go.mod h1:Ea1N1QQryNXpCD0I1fdLibBAIpQuBkznMmkdKrapk1Y= +go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= +go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM= +go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= +go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= +go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= +go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= +go.opentelemetry.io/otel/sdk/log v0.6.0 h1:4J8BwXY4EeDE9Mowg+CyhWVBhTSLXVXodiXxS/+PGqI= +go.opentelemetry.io/otel/sdk/log v0.6.0/go.mod h1:L1DN8RMAduKkrwRAFDEX3E3TLOq46+XMGSbUfHU/+vE= +go.opentelemetry.io/otel/sdk/metric v1.30.0 h1:QJLT8Pe11jyHBHfSAgYH7kEmT24eX792jZO1bo4BXkM= +go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y= +go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= +go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go b/cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go new file mode 100644 index 00000000..d9e8e3ee --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go @@ -0,0 +1,74 @@ +package main + +/* +This file contains the entry point for the WebAssembly (Wasm) executable. +To ensure the code compiles and runs correctly for Wasm (wasip1 target), we must follow these requirements: + +1) **File Name**: + The file must be named `main.go`. This is a Go convention for executables that defines where the program's entry point (`main()` function) is located. + +2) **Package Name**: + The package name must be `main`. This is essential for building an executable in Go. Go's compiler looks for a package named `main` that contains the `main()` function, which acts as the entry point of the program when the Wasm executable is run. +*/ + +import ( + "errors" + "log" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/cli/cmd/testdata/fixtures/capabilities/basictrigger" + "github.com/smartcontractkit/chainlink-common/pkg/workflows/sdk" + "github.com/smartcontractkit/chainlink-common/pkg/workflows/wasm" +) + +type Config struct { + WorkflowName string `yaml:"workflowName"` + WorkflowOwner string `yaml:"workflowOwner"` + BasicTriggerInterval uint64 `yaml:"basicTriggerInterval"` +} + +func BuildWorkflow(config []byte) *sdk.WorkflowSpecFactory { + // Unmarshal the config bytes into the Config struct + var parsedConfig Config + err := yaml.Unmarshal(config, &parsedConfig) + if err != nil { + log.Fatalf("Failed to parse config: %v", err) + } + log.Printf("WorkflowName from config: %v", parsedConfig.WorkflowName) + log.Printf("WorkflowOwner from config: %v", parsedConfig.WorkflowOwner) + log.Printf("BasicTriggerInterval from config: %v", parsedConfig.BasicTriggerInterval) + + // interval is a mandatory field, throw an error if empty + if parsedConfig.BasicTriggerInterval == 0 { + log.Fatalf("Error: BasicTriggerInterval is missing in the YAML file") + } + + workflow := sdk.NewWorkflowSpecFactory() + + // Trigger + triggerCfg := basictrigger.TriggerConfig{Name: "trigger", Number: parsedConfig.BasicTriggerInterval} + trigger := triggerCfg.New(workflow) + + // Action + sdk.Compute1[basictrigger.TriggerOutputs, bool]( + workflow, + "transform", + sdk.Compute1Inputs[basictrigger.TriggerOutputs]{Arg0: trigger}, + func(sdk sdk.Runtime, outputs basictrigger.TriggerOutputs) (bool, error) { + log.Printf("Output from the basic trigger: %v", outputs.CoolOutput) + if outputs.CoolOutput == "cool" { + return false, errors.New("it is cool, not good") + } + return true, nil + }) + + return workflow +} + +func main() { + runner := wasm.NewRunner() + + workflow := BuildWorkflow(runner.Config()) + runner.Run(workflow) +} diff --git a/cmd/workflow/deploy/testdata/wasm_make_fails/Makefile b/cmd/workflow/deploy/testdata/wasm_make_fails/Makefile new file mode 100644 index 00000000..4018c8c5 --- /dev/null +++ b/cmd/workflow/deploy/testdata/wasm_make_fails/Makefile @@ -0,0 +1,4 @@ +.PHONY: build + +build: + false diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 1623235d..edfa1d12 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -5,13 +5,11 @@ import ( "context" "crypto/ecdsa" "encoding/json" - "errors" "fmt" "math" "math/big" "os" "os/signal" - "path/filepath" "strconv" "strings" "syscall" @@ -40,14 +38,13 @@ 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/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/validation" ) type Inputs struct { - WorkflowPath string `validate:"required,path_read"` + WorkflowPath string `validate:"required,workflow_path_read"` ConfigPath string `validate:"omitempty,file,ascii,max=97"` SecretsPath string `validate:"omitempty,file,ascii,max=97"` EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` @@ -194,54 +191,28 @@ func (h *handler) ValidateInputs(inputs Inputs) error { } func (h *handler) Execute(inputs Inputs) error { - // Compile the workflow - // terminal command: GOOS=wasip1 GOARCH=wasm go build -trimpath -ldflags="-buildid= -w -s" -o - workflowRootFolder := filepath.Dir(inputs.WorkflowPath) - tmpWasmFileName := "tmp.wasm" - workflowMainFile := filepath.Base(inputs.WorkflowPath) - - // Set language in runtime context based on workflow file extension + workflowDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("workflow directory: %w", err) + } + resolvedWorkflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, inputs.WorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } + _, workflowMainFile, err := cmdcommon.WorkflowPathRootAndMain(resolvedWorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } 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) - } } - buildCmd := cmdcommon.GetBuildCmd(workflowMainFile, tmpWasmFileName, workflowRootFolder) - - h.log.Debug(). - Str("Workflow directory", buildCmd.Dir). - Str("Command", buildCmd.String()). - Msg("Executing go build command") - - // Execute the build command - buildOutput, err := buildCmd.CombinedOutput() + wasmFileBinary, err := cmdcommon.CompileWorkflowToWasm(resolvedWorkflowPath) if err != nil { - out := strings.TrimSpace(string(buildOutput)) - h.log.Info().Msg(out) - return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + return fmt.Errorf("failed to compile workflow: %w", err) } - h.log.Debug().Msgf("Build output: %s", buildOutput) fmt.Println("Workflow compiled") - // Read the compiled workflow binary - tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) - wasmFileBinary, err := os.ReadFile(tmpWasmLocation) - if err != nil { - return fmt.Errorf("failed to read workflow binary: %w", err) - } - // Read the config file var config []byte if inputs.ConfigPath != "" { diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index e9df7560..08d6d30a 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -29,6 +29,14 @@ func TestBlankWorkflowSimulation(t *testing.T) { absWorkflowPath, err := filepath.Abs(workflowPath) require.NoError(t, err) + // Run test from workflow dir so short relative paths (max 97 chars) work + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(absWorkflowPath)) + t.Cleanup(func() { + _ = os.Chdir(prevWd) + }) + // Clean up common artifacts produced by the compile/simulate flow outB64 := filepath.Join(absWorkflowPath, "binary.wasm.br.b64") t.Cleanup(func() { @@ -47,10 +55,11 @@ func TestBlankWorkflowSimulation(t *testing.T) { rpc.Url = "https://sepolia.infura.io/v3" v.Set(fmt.Sprintf("%s.%s", "staging-settings", settings.RpcsSettingName), []settings.RpcEndpoint{rpc}) + // Use relative paths so validation (max 97 chars) passes; cwd is workflow dir var workflowSettings settings.WorkflowSettings workflowSettings.UserWorkflowSettings.WorkflowName = "blank-workflow" - workflowSettings.WorkflowArtifactSettings.WorkflowPath = filepath.Join(absWorkflowPath, "main.go") - workflowSettings.WorkflowArtifactSettings.ConfigPath = filepath.Join(absWorkflowPath, "config.json") + workflowSettings.WorkflowArtifactSettings.WorkflowPath = "main.go" + workflowSettings.WorkflowArtifactSettings.ConfigPath = "config.json" // Mock `runtime.Context` with a test logger. runtimeCtx := &runtime.Context{ diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 72e5b699..bc4298c2 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/cmd/workflow/activate" + "github.com/smartcontractkit/cre-cli/cmd/workflow/convert" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" @@ -20,6 +21,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { } workflowCmd.AddCommand(activate.New(runtimeContext)) + workflowCmd.AddCommand(convert.New(runtimeContext)) workflowCmd.AddCommand(delete.New(runtimeContext)) workflowCmd.AddCommand(pause.New(runtimeContext)) workflowCmd.AddCommand(test.New(runtimeContext)) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 7c0c854c..c213add7 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -53,6 +53,7 @@ const ( WorkflowLanguageGolang = "golang" WorkflowLanguageTypeScript = "typescript" + WorkflowLanguageWasm = "wasm" TestAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" TestAddress2 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index cf62e3d9..5d7bf476 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -3,14 +3,81 @@ package settings import ( "fmt" "net/url" + "os" "strings" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" + "sigs.k8s.io/yaml" ) +// GetWorkflowPathFromFile reads workflow-path from a workflow.yaml file (same value deploy/simulate get from Settings). +func GetWorkflowPathFromFile(workflowYAMLPath string) (string, error) { + data, err := os.ReadFile(workflowYAMLPath) + if err != nil { + return "", fmt.Errorf("read workflow settings: %w", err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return "", fmt.Errorf("parse workflow settings: %w", err) + } + return workflowPathFromRaw(raw) +} + +// SetWorkflowPathInFile sets workflow-path in workflow.yaml (both staging-settings and production-settings) and writes the file. +func SetWorkflowPathInFile(workflowYAMLPath, newPath string) error { + data, err := os.ReadFile(workflowYAMLPath) + if err != nil { + return fmt.Errorf("read workflow settings: %w", err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parse workflow settings: %w", err) + } + setWorkflowPathInRaw(raw, newPath) + out, err := yaml.Marshal(raw) + if err != nil { + return fmt.Errorf("marshal workflow settings: %w", err) + } + if err := os.WriteFile(workflowYAMLPath, out, 0600); err != nil { + return fmt.Errorf("write workflow settings: %w", err) + } + return nil +} + +func workflowPathFromRaw(raw map[string]interface{}) (string, error) { + for _, key := range []string{"staging-settings", "production-settings"} { + target, _ := raw[key].(map[string]interface{}) + if target == nil { + continue + } + artifacts, _ := target["workflow-artifacts"].(map[string]interface{}) + if artifacts == nil { + continue + } + if p, ok := artifacts["workflow-path"].(string); ok && p != "" { + return p, nil + } + } + return "", fmt.Errorf("workflow-path not found in workflow settings") +} + +func setWorkflowPathInRaw(raw map[string]interface{}, path string) { + for _, key := range []string{"staging-settings", "production-settings"} { + target, _ := raw[key].(map[string]interface{}) + if target == nil { + continue + } + artifacts, _ := target["workflow-artifacts"].(map[string]interface{}) + if artifacts == nil { + continue + } + artifacts["workflow-path"] = path + } +} + type WorkflowSettings struct { UserWorkflowSettings struct { WorkflowOwnerAddress string `mapstructure:"workflow-owner-address" yaml:"workflow-owner-address"` diff --git a/internal/testutil/graphql_mock.go b/internal/testutil/graphql_mock.go new file mode 100644 index 00000000..98bbd188 --- /dev/null +++ b/internal/testutil/graphql_mock.go @@ -0,0 +1,42 @@ +package testutil + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/smartcontractkit/cre-cli/internal/environments" +) + +// NewGraphQLMockServerGetOrganization starts an httptest.Server that responds to +// getOrganization with a fixed organizationId. It sets EnvVarGraphQLURL so CLI +// commands use this server. Caller must defer srv.Close(). +func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { + var req struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(req.Query, "getOrganization") { + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "getOrganization": map[string]any{"organizationId": "test-org-id"}, + }, + }) + return + } + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, + }) + } + })) + t.Setenv(environments.EnvVarGraphQLURL, srv.URL+"/graphql") + return srv +} diff --git a/internal/validation/files/path_read.go b/internal/validation/files/path_read.go index 84d6cce2..b6c879d7 100644 --- a/internal/validation/files/path_read.go +++ b/internal/validation/files/path_read.go @@ -3,32 +3,45 @@ package files import ( "fmt" "os" + "path/filepath" "reflect" + "strings" "github.com/go-playground/validator/v10" ) func HasReadAccessToPath(fl validator.FieldLevel) bool { - field := fl.Field() - - if field.Kind() != reflect.String { - panic(fmt.Sprintf("input field name is not a string: %s", fl.FieldName())) - } - - path := field.String() + path := mustBeString(fl) + return hasReadAccessToPath(path) +} - // Check if the file or directory exists +func hasReadAccessToPath(path string) bool { _, err := os.Stat(path) if err != nil { return false } - - // Attempt to open the file or directory to verify read access file, err := os.Open(path) if err != nil { return false } defer file.Close() - return true } + +// HasReadAccessToWorkflowPath validates workflow-path: for .wasm paths only the containing +// directory must exist (CompileWorkflowToWasm will run make build); otherwise same as path_read. +func HasReadAccessToWorkflowPath(fl validator.FieldLevel) bool { + path := mustBeString(fl) + if strings.HasSuffix(path, ".wasm") { + return hasReadAccessToPath(filepath.Dir(path)) + } + return hasReadAccessToPath(path) +} + +func mustBeString(fl validator.FieldLevel) string { + field := fl.Field() + if field.Kind() != reflect.String { + panic(fmt.Sprintf("input field name is not a string: %s", fl.FieldName())) + } + return field.String() +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index c36a07e8..5ca86ff0 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -14,15 +14,16 @@ import ( ) var customValidators = map[string]validator.Func{ - "ecdsa_private_key": isECDSAPrivateKey, - "uint8_string_array": isUint8Array, - "json": files.IsValidJSON, - "path_read": files.HasReadAccessToPath, - "project_name": isProjectName, - "wasm": files.IsValidWASM, - "workflow_name": isWorkflowName, - "workflow_owner": isWorkflowOwner, - "yaml": files.IsValidYAML, + "ecdsa_private_key": isECDSAPrivateKey, + "uint8_string_array": isUint8Array, + "json": files.IsValidJSON, + "path_read": files.HasReadAccessToPath, + "project_name": isProjectName, + "wasm": files.IsValidWASM, + "workflow_name": isWorkflowName, + "workflow_owner": isWorkflowOwner, + "workflow_path_read": files.HasReadAccessToWorkflowPath, + "yaml": files.IsValidYAML, } var customTranslations = map[string]string{ @@ -37,6 +38,7 @@ var customTranslations = map[string]string{ "http_url|eq=": "{0} must be empty or a valid HTTP URL: {1}", "json": "{0} must be a valid JSON file: {1}", "path_read": "{0} must have read access to path: {1}", + "workflow_path_read": "{0} must have read access to path: {1}", "project_name": "{0} must be non-empty, no longer than 64 characters, and contain only letters (a-z, A-Z), numbers (0-9), dashes (-), and underscores (_): {1}", "wasm": "{0} must be a valid WASM file: {1}", "workflow_name": "{0} must be non-empty, no longer than 64 characters, and contain only letters (a-z, A-Z), numbers (0-9), dashes (-), and underscores (_): {1}", diff --git a/test/convert_simulate_helper.go b/test/convert_simulate_helper.go new file mode 100644 index 00000000..5e86f124 --- /dev/null +++ b/test/convert_simulate_helper.go @@ -0,0 +1,77 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +func convertSimulateCaptureOutput(t *testing.T, projectRoot, workflowName string) string { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "simulate (before convert) failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), stderr.String()) + return stdout.String() +} + +func convertSimulateRequireOutputContains(t *testing.T, projectRoot, workflowName, expectedSubstring string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "simulate (after convert) failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), expectedSubstring, + "simulate output after convert should contain %q", expectedSubstring) +} + +// ConvertSimulateBeforeAfter runs simulate (capture output), convert, then simulate again +// and verifies output contains the same expectedSubstring. Simulate runs make build internally when needed. +func ConvertSimulateBeforeAfter(t *testing.T, projectRoot, workflowDir, workflowName, expectedSubstring string) { + t.Helper() + beforeOutput := convertSimulateCaptureOutput(t, projectRoot, workflowName) + require.Contains(t, beforeOutput, expectedSubstring, + "baseline simulate output should contain %q", expectedSubstring) + convertRunConvert(t, projectRoot, workflowDir) + convertSimulateRequireOutputContains(t, projectRoot, workflowName, expectedSubstring) +} + +func convertRunConvert(t *testing.T, projectRoot, workflowDir string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "convert-to-custom-build", workflowDir, "-f") + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "convert failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) +} + +func convertRunMakeBuild(t *testing.T, workflowDir string, env ...string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command("make", "build") + cmd.Dir = workflowDir + cmd.Env = append(os.Environ(), env...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "make build failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) +} diff --git a/test/graphql_mock.go b/test/graphql_mock.go new file mode 100644 index 00000000..8298fe02 --- /dev/null +++ b/test/graphql_mock.go @@ -0,0 +1,14 @@ +package test + +import ( + "net/http/httptest" + "testing" + + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +// NewGraphQLMockServerGetOrganization starts a mock GraphQL server that responds to +// getOrganization and sets EnvVarGraphQLURL. Caller must defer srv.Close(). +func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { + return testutil.NewGraphQLMockServerGetOrganization(t) +} diff --git a/test/init_and_binding_generation_and_simulate_go_test.go b/test/init_and_binding_generation_and_simulate_go_test.go index c12d9d1c..190d1922 100644 --- a/test/init_and_binding_generation_and_simulate_go_test.go +++ b/test/init_and_binding_generation_and_simulate_go_test.go @@ -2,20 +2,15 @@ package test import ( "bytes" - "encoding/json" - "net/http" - "net/http/httptest" "os" "os/exec" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -33,41 +28,9 @@ func TestE2EInit_DevPoRTemplate(t *testing.T) { // Set dummy API key t.Setenv(credentials.CreApiKeyVar, "test-api") - // Set up mock GraphQL server for authentication validation - // This is needed because validation now runs early in command execution - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - initArgs := []string{ "init", "--project-root", tempDir, diff --git a/test/init_and_simulate_ts_test.go b/test/init_and_simulate_ts_test.go index b4265c54..563ba5a9 100644 --- a/test/init_and_simulate_ts_test.go +++ b/test/init_and_simulate_ts_test.go @@ -2,19 +2,14 @@ package test import ( "bytes" - "encoding/json" - "net/http" - "net/http/httptest" "os/exec" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -32,41 +27,9 @@ func TestE2EInit_DevPoRTemplateTS(t *testing.T) { // Set dummy API key t.Setenv(credentials.CreApiKeyVar, "test-api") - // Set up mock GraphQL server for authentication validation - // This is needed because validation now runs early in command execution - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - initArgs := []string{ "init", "--project-root", tempDir, diff --git a/test/init_and_simulate_wasm_test.go b/test/init_and_simulate_wasm_test.go new file mode 100644 index 00000000..ae691bee --- /dev/null +++ b/test/init_and_simulate_wasm_test.go @@ -0,0 +1,215 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func TestE2EInit_WasmBlankTemplate(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-init-wasm-test" + workflowName := "wasmWorkflow" + templateID := "6" + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + ethKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + t.Setenv(settings.EthPrivateKeyEnvVar, ethKey) + + // Set dummy API key + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with WASM template --- + initArgs := []string{ + "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + } + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, initArgs...) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + + require.NoError( + t, + initCmd.Run(), + "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultEnvFileName)) + require.DirExists(t, workflowDirectory) + + expectedFiles := []string{"README.md", "Makefile", "workflow.yaml", "config.staging.json", "config.production.json", "secrets.yaml"} + for _, f := range expectedFiles { + require.FileExists(t, filepath.Join(workflowDirectory, f), "missing workflow file %q", f) + } + + // Create wasm directory + wasmDir := filepath.Join(workflowDirectory, "wasm") + require.NoError(t, os.MkdirAll(wasmDir, 0755)) + + // Create a simple Go workflow file similar to blankTemplate but with custom build tag + mainGoContent := `//go:build wasip1 && customwasm + +package main + +import ( + "fmt" + "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 ExecutionResult struct { + Result string +} + +type Config struct{} + +func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) { + cronTrigger := cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}) + return cre.Workflow[*Config]{ + cre.Handler(cronTrigger, onCronTrigger), + }, nil +} + +func onCronTrigger(config *Config, runtime cre.Runtime, trigger *cron.Payload) (*ExecutionResult, error) { + logger := runtime.Logger() + scheduledTime := trigger.ScheduledExecutionTime.AsTime() + logger.Info("Cron trigger fired", "scheduledTime", scheduledTime) + return &ExecutionResult{Result: fmt.Sprintf("Fired at %s", scheduledTime)}, nil +} + +func main() { + wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow) +} +` + mainGoPath := filepath.Join(workflowDirectory, "main.go") + require.NoError(t, os.WriteFile(mainGoPath, []byte(mainGoContent), 0600)) + + // Create go.mod file - will be updated by go mod tidy + goModContent := `module wasm-workflow + +go 1.25.3 + +require ( + github.com/smartcontractkit/cre-sdk-go v1.1.3 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.0.0-beta.0 +) +` + goModPath := filepath.Join(workflowDirectory, "go.mod") + require.NoError(t, os.WriteFile(goModPath, []byte(goModContent), 0600)) + + // Update Makefile to include build command with custom build tag + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefileContent := `.PHONY: build + +build: + GOOS=wasip1 GOARCH=wasm go build -tags customwasm -o wasm/workflow.wasm . +` + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileContent), 0600)) + + // Run go mod tidy to resolve dependencies + stdout.Reset() + stderr.Reset() + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = workflowDirectory + tidyCmd.Stdout = &stdout + tidyCmd.Stderr = &stderr + + require.NoError( + t, + tidyCmd.Run(), + "go mod tidy failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // Build the workflow using make build + stdout.Reset() + stderr.Reset() + buildCmd := exec.Command("make", "build") + buildCmd.Dir = workflowDirectory + buildCmd.Stdout = &stdout + buildCmd.Stderr = &stderr + + require.NoError( + t, + buildCmd.Run(), + "make build failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // Verify WASM file was created + wasmFilePath := filepath.Join(wasmDir, "workflow.wasm") + require.FileExists(t, wasmFilePath, "WASM file should be created by make build") + + // --- cre workflow simulate wasmWorkflow --- + stdout.Reset() + stderr.Reset() + simulateArgs := []string{ + "workflow", "simulate", + workflowName, + "--project-root", projectRoot, + "--non-interactive", + "--trigger-index=0", + } + simulateCmd := exec.Command(CLIPath, simulateArgs...) + simulateCmd.Dir = projectRoot + simulateCmd.Stdout = &stdout + simulateCmd.Stderr = &stderr + + require.NoError( + t, + simulateCmd.Run(), + "cre workflow simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // --- cre workflow compile wasmWorkflow --- + stdout.Reset() + stderr.Reset() + compileArgs := []string{ + "workflow", "compile", + filepath.Join(workflowDirectory, "workflow.yaml"), + "--project-root", projectRoot, + } + compileCmd := exec.Command(CLIPath, compileArgs...) + compileCmd.Dir = projectRoot + compileCmd.Stdout = &stdout + compileCmd.Stderr = &stderr + + require.NoError( + t, + compileCmd.Run(), + "cre workflow compile failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // Verify compiled output exists + outputPath := filepath.Join(workflowDirectory, "binary.wasm.br.b64") + require.FileExists(t, outputPath, "compiled output should exist") +} diff --git a/test/init_convert_simulate_go_test.go b/test/init_convert_simulate_go_test.go new file mode 100644 index 00000000..09ad191d --- /dev/null +++ b/test/init_convert_simulate_go_test.go @@ -0,0 +1,116 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// TestE2EInit_ConvertToCustomBuild_Go: init (blank Go), simulate (capture), convert, make build, simulate (require match), +// then add FlagProof/constA/constB/Makefile FLAG, make with FLAG=customFlag/differentFlag, simulate and assert. +func TestE2EInit_ConvertToCustomBuild_Go(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-convert-go" + workflowName := "goWorkflow" + templateID := "2" // blank Go template + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with blank Go template --- + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + ) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + require.NoError(t, initCmd.Run(), "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDirectory) + require.FileExists(t, filepath.Join(workflowDirectory, "main.go")) + + // go mod tidy so simulate can build + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = workflowDirectory + tidyCmd.Stdout = &stdout + tidyCmd.Stderr = &stderr + require.NoError(t, tidyCmd.Run(), "go mod tidy failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + // Before/after: simulate (capture), convert, make build, simulate (verify same key content) + ConvertSimulateBeforeAfter(t, projectRoot, workflowDirectory, workflowName, "Fired at") + require.FileExists(t, filepath.Join(workflowDirectory, "Makefile")) + require.DirExists(t, filepath.Join(workflowDirectory, "wasm")) + + // Now make test-specific changes: FlagProof, constA/constB, Makefile FLAG + mainPath := filepath.Join(workflowDirectory, "main.go") + mainBytes, err := os.ReadFile(mainPath) + require.NoError(t, err) + mainStr := string(mainBytes) + mainStr = strings.Replace(mainStr, "type ExecutionResult struct {\n\tResult string\n}", "type ExecutionResult struct {\n\tResult string\n\tFlagProof string\n}", 1) + mainStr = strings.Replace(mainStr, "\t// Your logic here...\n\n\treturn &ExecutionResult{Result: fmt.Sprintf(\"Fired at %s\", scheduledTime)}, nil", + "\treturn &ExecutionResult{Result: fmt.Sprintf(\"Fired at %s\", scheduledTime), FlagProof: FlagProof}, nil", 1) + require.NoError(t, os.WriteFile(mainPath, []byte(mainStr), 0600)) + + constA := `//go:build customFlag + +package main + +const FlagProof = "set" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDirectory, "constA.go"), []byte(constA), 0600)) + + constB := `//go:build !customFlag + +package main + +const FlagProof = "unset" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDirectory, "constB.go"), []byte(constB), 0600)) + + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefile, err := os.ReadFile(makefilePath) + require.NoError(t, err) + makefileStr := strings.Replace(string(makefile), "go build -o", "go build -tags $(FLAG) -o", 1) + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileStr), 0600)) + + convertGoBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=customFlag", "set", "FlagProof") + convertGoBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=differentFlag", "unset", "FlagProof") +} + +func convertGoBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, workflowName, envVar, wantSubstr, wantSubstr2 string) { + t.Helper() + convertRunMakeBuild(t, workflowDir, envVar) + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if envVar != "" { + cmd.Env = append(os.Environ(), envVar) + } + require.NoError(t, cmd.Run(), "simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), wantSubstr) + require.Contains(t, stdout.String(), wantSubstr2) +} diff --git a/test/init_convert_simulate_ts_test.go b/test/init_convert_simulate_ts_test.go new file mode 100644 index 00000000..362493e4 --- /dev/null +++ b/test/init_convert_simulate_ts_test.go @@ -0,0 +1,147 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// TestE2EInit_ConvertToCustomBuild_TS: init (typescriptSimpleExample), bun install, simulate (capture), +// convert, simulate (verify same). Verify conversion did not change main.ts. Then test-only: copy +// workflow-wrapper, write custom compile-to-js with define section in Bun.build, patch main.ts, Makefile. +// make with FLAG=customFlag/differentFlag, simulate and assert. +func TestE2EInit_ConvertToCustomBuild_TS(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-convert-ts" + workflowName := "tsWorkflow" + templateID := "3" // typescriptSimpleExample + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with typescriptSimpleExample --- + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + ) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + require.NoError(t, initCmd.Run(), "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDirectory) + require.FileExists(t, filepath.Join(workflowDirectory, "main.ts")) + + // bun install so simulate can build + installCmd := exec.Command("bun", "install") + installCmd.Dir = workflowDirectory + installCmd.Stdout = &stdout + installCmd.Stderr = &stderr + require.NoError(t, installCmd.Run(), "bun install failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + ConvertSimulateBeforeAfter(t, projectRoot, workflowDirectory, workflowName, "Hello world!") + require.FileExists(t, filepath.Join(workflowDirectory, "Makefile")) + require.DirExists(t, filepath.Join(workflowDirectory, "wasm")) + + // Verify conversion did not change main.ts + mainPath := filepath.Join(workflowDirectory, "main.ts") + mainBefore, err := os.ReadFile(mainPath) + require.NoError(t, err) + require.Contains(t, string(mainBefore), `return "Hello world!";`, "convert must not modify workflow source") + + // Test-only: copy compile-to-js and workflow-wrapper from SDK, then patch to add define (so FLAG env drives the build). + scriptsDir := filepath.Join(workflowDirectory, "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0755)) + srcDir := filepath.Join(workflowDirectory, "node_modules", "@chainlink", "cre-sdk", "scripts", "src") + for _, name := range []string{"compile-to-js.ts", "workflow-wrapper.ts"} { + b, err := os.ReadFile(filepath.Join(srcDir, name)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, name), b, 0600)) + } + compileToJSPath := filepath.Join(scriptsDir, "compile-to-js.ts") + compileToJS, err := os.ReadFile(compileToJSPath) + require.NoError(t, err) + src := string(compileToJS) + // Use local workflow-wrapper (script is under scripts/, not SDK entry) + if !strings.Contains(src, "workflow-wrapper") { + src = strings.Replace(src, `import { $ } from "bun";`, `import { $ } from "bun"; +import { wrapWorkflowCode } from "./workflow-wrapper";`, 1) + } + // Argv slice(2) so "bun scripts/compile-to-js.ts main.ts wasm/workflow.js" passes both args + // it's called differently than the SDK so we need to patch it + src = strings.Replace(src, "process.argv.slice(3)", "process.argv.slice(2)", 1) + + defineBlock := "define: {\n\t\t\tBUILD_FLAG: JSON.stringify(process.env.FLAG ?? \"\"),\n\t\t},\n\t\t" + anchor := "naming: path.basename(resolvedOutput)," + if idx := strings.Index(src, anchor); idx >= 0 { + src = src[:idx] + defineBlock + src[idx:] + } + require.Contains(t, src, "define:", "patch must add define section to Bun.build") + if !strings.Contains(src, "main().catch") && !strings.Contains(src, "await main()") { + src = src + "\nmain().catch((err: unknown) => { console.error(err); process.exit(1); });\n" + } + require.NoError(t, os.WriteFile(compileToJSPath, []byte(src), 0600)) + + mainStr := string(mainBefore) + mainStr = "declare const BUILD_FLAG: string;\n" + mainStr + newReturn := `return BUILD_FLAG === "customFlag" ? "Hello World (custom)" : "Hello World (default)";` + for _, oldReturn := range []string{` return "Hello world!";`, `return "Hello world!";`} { + if strings.Contains(mainStr, oldReturn) { + mainStr = strings.Replace(mainStr, oldReturn, newReturn, 1) + break + } + } + require.Contains(t, mainStr, "Hello World (custom)", "main.ts return patch must apply") + require.NoError(t, os.WriteFile(mainPath, []byte(mainStr), 0600)) + + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefileContent := `.PHONY: build + +build: + FLAG=$(FLAG) bun scripts/compile-to-js.ts main.ts wasm/workflow.js + bunx cre-compile-workflow wasm/workflow.js wasm/workflow.wasm +` + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileContent), 0600)) + + convertTSBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=customFlag", "Hello World (custom)") + convertTSBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=differentFlag", "Hello World (default)") +} + +func convertTSBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, workflowName, envVar, wantSubstr string) { + t.Helper() + convertRunMakeBuild(t, workflowDir, envVar) + var stdout, stderr bytes.Buffer + workflowDirAbs, err := filepath.Abs(workflowDir) + require.NoError(t, err) + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowDirAbs, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + // Simulate runs CompileWorkflowToWasm which runs make build again; pass env so the rebuild uses the same FLAG + if envVar != "" { + cmd.Env = append(os.Environ(), envVar) + } + require.NoError(t, cmd.Run(), "simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), wantSubstr) +} diff --git a/test/multi_command_flows/workflow_simulator_path.go b/test/multi_command_flows/workflow_simulator_path.go index e99fcba4..66cc3c6a 100644 --- a/test/multi_command_flows/workflow_simulator_path.go +++ b/test/multi_command_flows/workflow_simulator_path.go @@ -8,13 +8,12 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "time" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/testutil" ) type testEVMConfig struct { @@ -80,37 +79,9 @@ func RunSimulationHappyPath(t *testing.T, tc TestConfig, projectDir string) { t.Helper() t.Run("Simulate", func(t *testing.T) { - // Set up GraphQL mock server for authentication validation - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req graphQLRequest - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := testutil.NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - srv := startMockPORServer(t) patchWorkflowConfigURL(t, projectDir, "por_workflow", srv.URL)