From 13b31981b25e75a3313b4164c343457fe5636bc7 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 24 Apr 2026 16:43:10 -0600 Subject: [PATCH 1/4] feat: add workflow delete and workflow pause --- cre/changesets/workflow_delete.go | 55 ++++++ cre/changesets/workflow_delete_test.go | 152 +++++++++++++++++ cre/changesets/workflow_pause.go | 55 ++++++ cre/changesets/workflow_pause_test.go | 152 +++++++++++++++++ cre/operations/workflow_delete.go | 188 +++++++++++++++++++++ cre/operations/workflow_delete_test.go | 224 +++++++++++++++++++++++++ cre/operations/workflow_pause.go | 188 +++++++++++++++++++++ cre/operations/workflow_pause_test.go | 224 +++++++++++++++++++++++++ 8 files changed, 1238 insertions(+) create mode 100644 cre/changesets/workflow_delete.go create mode 100644 cre/changesets/workflow_delete_test.go create mode 100644 cre/changesets/workflow_pause.go create mode 100644 cre/changesets/workflow_pause_test.go create mode 100644 cre/operations/workflow_delete.go create mode 100644 cre/operations/workflow_delete_test.go create mode 100644 cre/operations/workflow_pause.go create mode 100644 cre/operations/workflow_pause_test.go diff --git a/cre/changesets/workflow_delete.go b/cre/changesets/workflow_delete.go new file mode 100644 index 0000000..e76c866 --- /dev/null +++ b/cre/changesets/workflow_delete.go @@ -0,0 +1,55 @@ +// Package changesets provides CRE workflow changesets that can be applied to deployment environments. +package changesets + +import ( + "errors" + "fmt" + + creops "github.com/smartcontractkit/cld-changesets/cre/operations" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cfgenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/env" + fwops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// CREWorkflowDeleteChangeset runs the CRE CLI workflow delete command. +type CREWorkflowDeleteChangeset struct{} + +// VerifyPreconditions ensures the environment can run CRE and input is valid. +func (CREWorkflowDeleteChangeset) VerifyPreconditions(e cldf.Environment, input creops.CREWorkflowDeleteInput) error { + if e.CRERunner == nil { + return errors.New("cre runner is not available in this environment") + } + if e.CRERunner.CLI() == nil { + return errors.New("cre CLI runner is not configured") + } + if err := input.Validate(); err != nil { + return err + } + + return nil +} + +// Apply loads CRE config and runs the CRE workflow delete operation. +func (CREWorkflowDeleteChangeset) Apply(e cldf.Environment, input creops.CREWorkflowDeleteInput) (cldf.ChangesetOutput, error) { + envCfg, err := cfgenv.LoadEnv() + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("load CRE env config: %w", err) + } + + deps := creops.CREDeployDeps{ + CLI: e.CRERunner.CLI(), + CRECfg: envCfg.CRE, + EVMDeployerKey: envCfg.Onchain.EVM.DeployerKey, + } + + report, err := fwops.ExecuteOperation(e.OperationsBundle, creops.CREWorkflowDeleteOp, deps, input) + out := cldf.ChangesetOutput{ + Reports: []fwops.Report[any, any]{report.ToGenericReport()}, + } + if err != nil { + return out, err + } + + return out, nil +} diff --git a/cre/changesets/workflow_delete_test.go b/cre/changesets/workflow_delete_test.go new file mode 100644 index 0000000..bc4dbd2 --- /dev/null +++ b/cre/changesets/workflow_delete_test.go @@ -0,0 +1,152 @@ +package changesets + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/cre/operations" + + fcre "github.com/smartcontractkit/chainlink-deployments-framework/cre" + creartifacts "github.com/smartcontractkit/chainlink-deployments-framework/cre/artifacts" + cremocks "github.com/smartcontractkit/chainlink-deployments-framework/cre/mocks" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + testenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + focr "github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr" +) + +func newDeleteTestEnv(t *testing.T, opts ...testenv.LoadOpt) *cldf.Environment { + t.Helper() + env, err := testenv.New(t.Context(), opts...) + require.NoError(t, err) + if env.OCRSecrets.IsEmpty() { + env.OCRSecrets = focr.XXXGenerateTestOCRSecrets() + } + + return env +} + +func validDeleteInput(t *testing.T) operations.CREWorkflowDeleteInput { + t.Helper() + projectPath := filepath.Join(t.TempDir(), "project.yaml") + require.NoError(t, os.WriteFile(projectPath, []byte("staging-settings:\n rpcs: []\n"), 0o600)) + + return operations.CREWorkflowDeleteInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal(projectPath), + DonFamily: "zone", + DeploymentRegistry: "private", + } +} + +func TestCREWorkflowDeleteChangeset_VerifyPreconditions(t *testing.T) { + t.Parallel() + + mockCLI := cremocks.NewMockCLIRunner(t) + envNoCLI := newDeleteTestEnv(t, testenv.WithCRERunner(fcre.NewRunner())) + envWithCLI := newDeleteTestEnv(t, testenv.WithCRERunner(fcre.NewRunner(fcre.WithCLI(mockCLI)))) + envNoCRE := newDeleteTestEnv(t) + + good := validDeleteInput(t) + + tests := []struct { + name string + env cldf.Environment + input func() operations.CREWorkflowDeleteInput + wantErr string + }{ + {name: "no CRERunner", env: *envNoCRE, wantErr: "cre runner is not available in this environment"}, + {name: "CRERunner without CLI", env: *envNoCLI, wantErr: "CLI runner is not configured"}, + { + name: "missing project", + env: *envWithCLI, + input: func() operations.CREWorkflowDeleteInput { + in := good + in.Project = creartifacts.ConfigSource{} + return in + }, + wantErr: "project:", + }, + { + name: "missing deploymentRegistry", + env: *envWithCLI, + input: func() operations.CREWorkflowDeleteInput { + in := good + in.DeploymentRegistry = "" + return in + }, + wantErr: "deploymentRegistry is required", + }, + { + name: "missing donFamily", + env: *envWithCLI, + input: func() operations.CREWorkflowDeleteInput { + in := good + in.DonFamily = "" + return in + }, + wantErr: "donFamily is required", + }, + {name: "valid input passes", env: *envWithCLI}, + } + + cs := CREWorkflowDeleteChangeset{} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + input := good + if tc.input != nil { + input = tc.input() + } + err := cs.VerifyPreconditions(tc.env, input) + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + }) + } +} + +func TestCREWorkflowDeleteChangeset_Apply(t *testing.T) { + cs := CREWorkflowDeleteChangeset{} + input := validDeleteInput(t) + + t.Run("success returns report", func(t *testing.T) { + mockCLI := cremocks.NewMockCLIRunner(t) + mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() + mockCLI.EXPECT(). + Run(mock.Anything, (map[string]string)(nil), matchCLIArgs("workflow", "delete")). + Return(&fcre.CallResult{ExitCode: 0, Stdout: []byte("ok")}, nil). + Once() + env := newDeleteTestEnv(t, testenv.WithCRERunner(fcre.NewRunner(fcre.WithCLI(mockCLI)))) + + out, err := cs.Apply(*env, input) + require.NoError(t, err) + require.Len(t, out.Reports, 1) + output, ok := out.Reports[0].Output.(operations.CREWorkflowDeleteOutput) + require.True(t, ok) + require.Equal(t, 0, output.ExitCode) + require.Equal(t, "ok", output.Stdout) + }) + + t.Run("operation error returns report and error", func(t *testing.T) { + mockCLI := cremocks.NewMockCLIRunner(t) + mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() + mockCLI.EXPECT().Run(mock.Anything, mock.Anything, mock.Anything). + Return((*fcre.CallResult)(nil), errors.New("op failed")). + Once() + env := newDeleteTestEnv(t, testenv.WithCRERunner(fcre.NewRunner(fcre.WithCLI(mockCLI)))) + + out, err := cs.Apply(*env, input) + require.ErrorContains(t, err, "cre workflow delete: op failed") + require.Len(t, out.Reports, 1) + output, ok := out.Reports[0].Output.(operations.CREWorkflowDeleteOutput) + require.True(t, ok) + require.Empty(t, output.Stdout) + }) +} diff --git a/cre/changesets/workflow_pause.go b/cre/changesets/workflow_pause.go new file mode 100644 index 0000000..38ff869 --- /dev/null +++ b/cre/changesets/workflow_pause.go @@ -0,0 +1,55 @@ +// Package changesets provides CRE workflow changesets that can be applied to deployment environments. +package changesets + +import ( + "errors" + "fmt" + + creops "github.com/smartcontractkit/cld-changesets/cre/operations" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cfgenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/env" + fwops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// CREWorkflowPauseChangeset runs the CRE CLI workflow pause command. +type CREWorkflowPauseChangeset struct{} + +// VerifyPreconditions ensures the environment can run CRE and input is valid. +func (CREWorkflowPauseChangeset) VerifyPreconditions(e cldf.Environment, input creops.CREWorkflowPauseInput) error { + if e.CRERunner == nil { + return errors.New("cre runner is not available in this environment") + } + if e.CRERunner.CLI() == nil { + return errors.New("cre CLI runner is not configured") + } + if err := input.Validate(); err != nil { + return err + } + + return nil +} + +// Apply loads CRE config and runs the CRE workflow pause operation. +func (CREWorkflowPauseChangeset) Apply(e cldf.Environment, input creops.CREWorkflowPauseInput) (cldf.ChangesetOutput, error) { + envCfg, err := cfgenv.LoadEnv() + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("load CRE env config: %w", err) + } + + deps := creops.CREDeployDeps{ + CLI: e.CRERunner.CLI(), + CRECfg: envCfg.CRE, + EVMDeployerKey: envCfg.Onchain.EVM.DeployerKey, + } + + report, err := fwops.ExecuteOperation(e.OperationsBundle, creops.CREWorkflowPauseOp, deps, input) + out := cldf.ChangesetOutput{ + Reports: []fwops.Report[any, any]{report.ToGenericReport()}, + } + if err != nil { + return out, err + } + + return out, nil +} diff --git a/cre/changesets/workflow_pause_test.go b/cre/changesets/workflow_pause_test.go new file mode 100644 index 0000000..9dbadda --- /dev/null +++ b/cre/changesets/workflow_pause_test.go @@ -0,0 +1,152 @@ +package changesets + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/cre/operations" + + fcre "github.com/smartcontractkit/chainlink-deployments-framework/cre" + creartifacts "github.com/smartcontractkit/chainlink-deployments-framework/cre/artifacts" + cremocks "github.com/smartcontractkit/chainlink-deployments-framework/cre/mocks" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + testenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + focr "github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr" +) + +func newPauseTestEnv(t *testing.T, opts ...testenv.LoadOpt) *cldf.Environment { + t.Helper() + env, err := testenv.New(t.Context(), opts...) + require.NoError(t, err) + if env.OCRSecrets.IsEmpty() { + env.OCRSecrets = focr.XXXGenerateTestOCRSecrets() + } + + return env +} + +func validPauseInput(t *testing.T) operations.CREWorkflowPauseInput { + t.Helper() + projectPath := filepath.Join(t.TempDir(), "project.yaml") + require.NoError(t, os.WriteFile(projectPath, []byte("staging-settings:\n rpcs: []\n"), 0o600)) + + return operations.CREWorkflowPauseInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal(projectPath), + DonFamily: "zone", + DeploymentRegistry: "private", + } +} + +func TestCREWorkflowPauseChangeset_VerifyPreconditions(t *testing.T) { + t.Parallel() + + mockCLI := cremocks.NewMockCLIRunner(t) + envNoCLI := newPauseTestEnv(t, testenv.WithCRERunner(fcre.NewRunner())) + envWithCLI := newPauseTestEnv(t, testenv.WithCRERunner(fcre.NewRunner(fcre.WithCLI(mockCLI)))) + envNoCRE := newPauseTestEnv(t) + + good := validPauseInput(t) + + tests := []struct { + name string + env cldf.Environment + input func() operations.CREWorkflowPauseInput + wantErr string + }{ + {name: "no CRERunner", env: *envNoCRE, wantErr: "cre runner is not available in this environment"}, + {name: "CRERunner without CLI", env: *envNoCLI, wantErr: "CLI runner is not configured"}, + { + name: "missing project", + env: *envWithCLI, + input: func() operations.CREWorkflowPauseInput { + in := good + in.Project = creartifacts.ConfigSource{} + return in + }, + wantErr: "project:", + }, + { + name: "missing deploymentRegistry", + env: *envWithCLI, + input: func() operations.CREWorkflowPauseInput { + in := good + in.DeploymentRegistry = "" + return in + }, + wantErr: "deploymentRegistry is required", + }, + { + name: "missing donFamily", + env: *envWithCLI, + input: func() operations.CREWorkflowPauseInput { + in := good + in.DonFamily = "" + return in + }, + wantErr: "donFamily is required", + }, + {name: "valid input passes", env: *envWithCLI}, + } + + cs := CREWorkflowPauseChangeset{} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + input := good + if tc.input != nil { + input = tc.input() + } + err := cs.VerifyPreconditions(tc.env, input) + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + }) + } +} + +func TestCREWorkflowPauseChangeset_Apply(t *testing.T) { + cs := CREWorkflowPauseChangeset{} + input := validPauseInput(t) + + t.Run("success returns report", func(t *testing.T) { + mockCLI := cremocks.NewMockCLIRunner(t) + mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() + mockCLI.EXPECT(). + Run(mock.Anything, (map[string]string)(nil), matchCLIArgs("workflow", "pause")). + Return(&fcre.CallResult{ExitCode: 0, Stdout: []byte("ok")}, nil). + Once() + env := newPauseTestEnv(t, testenv.WithCRERunner(fcre.NewRunner(fcre.WithCLI(mockCLI)))) + + out, err := cs.Apply(*env, input) + require.NoError(t, err) + require.Len(t, out.Reports, 1) + output, ok := out.Reports[0].Output.(operations.CREWorkflowPauseOutput) + require.True(t, ok) + require.Equal(t, 0, output.ExitCode) + require.Equal(t, "ok", output.Stdout) + }) + + t.Run("operation error returns report and error", func(t *testing.T) { + mockCLI := cremocks.NewMockCLIRunner(t) + mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() + mockCLI.EXPECT().Run(mock.Anything, mock.Anything, mock.Anything). + Return((*fcre.CallResult)(nil), errors.New("op failed")). + Once() + env := newPauseTestEnv(t, testenv.WithCRERunner(fcre.NewRunner(fcre.WithCLI(mockCLI)))) + + out, err := cs.Apply(*env, input) + require.ErrorContains(t, err, "cre workflow pause: op failed") + require.Len(t, out.Reports, 1) + output, ok := out.Reports[0].Output.(operations.CREWorkflowPauseOutput) + require.True(t, ok) + require.Empty(t, output.Stdout) + }) +} diff --git a/cre/operations/workflow_delete.go b/cre/operations/workflow_delete.go new file mode 100644 index 0000000..2028958 --- /dev/null +++ b/cre/operations/workflow_delete.go @@ -0,0 +1,188 @@ +// Package operations provides CRE workflow operations that execute side effects via the CRE CLI. +package operations + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + + fcre "github.com/smartcontractkit/chainlink-deployments-framework/cre" + creartifacts "github.com/smartcontractkit/chainlink-deployments-framework/cre/artifacts" + crecli "github.com/smartcontractkit/chainlink-deployments-framework/cre/cli" + fwops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// CREWorkflowDeleteOutput is the serializable result of a CRE CLI workflow delete invocation. +type CREWorkflowDeleteOutput struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +// CREWorkflowDeleteInput is the resolved input for a CRE workflow delete command. +type CREWorkflowDeleteInput struct { + WorkflowName string `json:"workflowName" yaml:"workflowName"` + Project creartifacts.ConfigSource `json:"project" yaml:"project"` + DonFamily string `json:"donFamily,omitempty" yaml:"donFamily,omitempty"` + DeploymentRegistry string `json:"deploymentRegistry,omitempty" yaml:"deploymentRegistry,omitempty"` + Context crecli.ContextOverrides `json:"context" yaml:"context"` + ExtraCREArgs []string `json:"extraCreArgs,omitempty" yaml:"extraCreArgs,omitempty"` + TargetName string `json:"targetName,omitempty" yaml:"targetName,omitempty"` +} + +// Validate trims string fields and validates the delete input. +func (in *CREWorkflowDeleteInput) Validate() error { + in.WorkflowName = strings.TrimSpace(in.WorkflowName) + in.DonFamily = strings.TrimSpace(in.DonFamily) + in.DeploymentRegistry = strings.TrimSpace(in.DeploymentRegistry) + in.TargetName = strings.TrimSpace(in.TargetName) + + if in.WorkflowName == "" { + return errors.New("workflowName is required") + } + if err := in.Project.Validate(); err != nil { + return fmt.Errorf("project: %w", err) + } + if in.DeploymentRegistry == "" { + return errors.New("deploymentRegistry is required") + } + if in.DonFamily == "" { + return errors.New("donFamily is required") + } + + return nil +} + +func (in CREWorkflowDeleteInput) resolveTargetName() string { + target := strings.TrimSpace(in.TargetName) + if target != "" { + return target + } + + return CREDeployTargetName +} + +// CREWorkflowDeleteOp deletes a workflow via the CRE CLI. +var CREWorkflowDeleteOp = fwops.NewOperation( + "cre-workflow-delete", + semver.MustParse("1.0.0"), + "Deletes a CRE workflow via the CRE CLI subprocess", + func(b fwops.Bundle, deps CREDeployDeps, input CREWorkflowDeleteInput) (CREWorkflowDeleteOutput, error) { + ctx := b.GetContext() + if deps.CLI == nil { + return CREWorkflowDeleteOutput{}, errors.New("cre CLIRunner is nil") + } + + workDir, err := os.MkdirTemp("", "cre-workflow-delete-*") + if err != nil { + return CREWorkflowDeleteOutput{}, fmt.Errorf("mkdir temp workflow artifacts: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + resolver, err := creartifacts.NewArtifactsResolver(workDir) + if err != nil { + return CREWorkflowDeleteOutput{}, err + } + + projectSrc, err := resolver.ResolveConfig(ctx, input.Project) + if err != nil { + return CREWorkflowDeleteOutput{}, fmt.Errorf("resolve project.yaml: %w", err) + } + + projectDest := filepath.Join(workDir, "project.yaml") + if err = copyFile(projectSrc, projectDest); err != nil { + return CREWorkflowDeleteOutput{}, fmt.Errorf("copy project.yaml: %w", err) + } + + bundleDir := filepath.Join(workDir, creBundleSubdir) + if err = os.MkdirAll(bundleDir, 0o700); err != nil { + return CREWorkflowDeleteOutput{}, err + } + + target := input.resolveTargetName() + workflowCfg := crecli.WorkflowConfig{ + target: { + UserWorkflow: crecli.UserWorkflow{ + DeploymentRegistry: input.DeploymentRegistry, + WorkflowName: input.WorkflowName, + }, + }, + } + workflowYAMLPath, err := crecli.WriteWorkflowYAML(bundleDir, workflowCfg) + if err != nil { + return CREWorkflowDeleteOutput{}, fmt.Errorf("write workflow.yaml: %w", err) + } + + ctxCfg, err := crecli.BuildContextConfig(input.DonFamily, input.Context, deps.CRECfg, deps.CLI.ContextRegistries()) + if err != nil { + return CREWorkflowDeleteOutput{}, err + } + contextPath, err := crecli.WriteContextYAML(workDir, ctxCfg) + if err != nil { + return CREWorkflowDeleteOutput{}, fmt.Errorf("write context.yaml: %w", err) + } + + logResolvedFile(b.Logger, "workflow.yaml", workflowYAMLPath, prettyYAML) + logResolvedFile(b.Logger, "project.yaml", projectDest, prettyYAML) + logResolvedFile(b.Logger, "context.yaml", contextPath, prettyYAML) + + envPath, err := crecli.WriteCREEnvFile(workDir, contextPath, deps.CRECfg, input.DonFamily) + if err != nil { + return CREWorkflowDeleteOutput{}, fmt.Errorf("write CRE .env file: %w", err) + } + + args := BuildWorkflowDeleteArgs(target, workDir, envPath, input.ExtraCREArgs) + b.Logger.Infow("Running CRE workflow delete", "args", args) + + res, runErr := deps.CLI.Run(ctx, nil, args...) + if runErr != nil { + var exitErr *fcre.ExitError + if errors.As(runErr, &exitErr) { + return CREWorkflowDeleteOutput{ + ExitCode: exitErr.ExitCode, + Stdout: string(exitErr.Stdout), + Stderr: string(exitErr.Stderr), + }, fmt.Errorf("cre workflow delete: %w", runErr) + } + + return CREWorkflowDeleteOutput{}, fmt.Errorf("cre workflow delete: %w", runErr) + } + if res == nil { + return CREWorkflowDeleteOutput{}, errors.New("cre workflow delete: CLI returned nil result without error") + } + + b.Logger.Infow("CRE workflow delete finished", + "exitCode", res.ExitCode, + "stdout", string(res.Stdout), + "stderr", string(res.Stderr), + ) + + return CREWorkflowDeleteOutput{ + ExitCode: res.ExitCode, + Stdout: string(res.Stdout), + Stderr: string(res.Stderr), + }, nil + }, +) + +// BuildWorkflowDeleteArgs constructs the CRE CLI argument list for `cre workflow delete`. +func BuildWorkflowDeleteArgs(targetName, workDir, envPath string, extra []string) []string { + bundleDir := filepath.Join(workDir, creBundleSubdir) + args := []string{ + "workflow", "delete", + bundleDir, + "-R", workDir, + "-T", targetName, + "--yes", + } + if envPath != "" { + args = append(args, "-e", envPath) + } + args = append(args, extra...) + + return args +} diff --git a/cre/operations/workflow_delete_test.go b/cre/operations/workflow_delete_test.go new file mode 100644 index 0000000..50f8bf7 --- /dev/null +++ b/cre/operations/workflow_delete_test.go @@ -0,0 +1,224 @@ +package operations + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + fcre "github.com/smartcontractkit/chainlink-deployments-framework/cre" + creartifacts "github.com/smartcontractkit/chainlink-deployments-framework/cre/artifacts" + cremocks "github.com/smartcontractkit/chainlink-deployments-framework/cre/mocks" + cfgenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/env" + fwops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestCREWorkflowDeleteOp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input func(t *testing.T) CREWorkflowDeleteInput + setupCLI func(t *testing.T) *cremocks.MockCLIRunner + assert func(t *testing.T, out fwops.Report[CREWorkflowDeleteInput, CREWorkflowDeleteOutput], err error) + }{ + { + name: "success invokes CLI with delete args", + input: func(t *testing.T) CREWorkflowDeleteInput { + t.Helper() + + return CREWorkflowDeleteInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + DonFamily: "feeds-zone-a", + DeploymentRegistry: "private", + } + }, + setupCLI: func(t *testing.T) *cremocks.MockCLIRunner { + t.Helper() + m := cremocks.NewMockCLIRunner(t) + m.EXPECT().ContextRegistries().Return(testRegistries()).Once() + m.EXPECT().Run(mock.Anything, (map[string]string)(nil), matchCLIArgs("workflow", "delete")).Return( + &fcre.CallResult{ExitCode: 0, Stdout: []byte("deleted")}, nil, + ).Once() + + return m + }, + assert: func(t *testing.T, out fwops.Report[CREWorkflowDeleteInput, CREWorkflowDeleteOutput], err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, 0, out.Output.ExitCode) + require.Equal(t, "deleted", out.Output.Stdout) + }, + }, + { + name: "missing project returns resolve error", + input: func(t *testing.T) CREWorkflowDeleteInput { + t.Helper() + + return CREWorkflowDeleteInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal("/tmp/definitely-missing-project.yaml"), + DonFamily: "feeds-zone-a", + DeploymentRegistry: "private", + } + }, + setupCLI: func(t *testing.T) *cremocks.MockCLIRunner { + t.Helper() + + return cremocks.NewMockCLIRunner(t) + }, + assert: func(t *testing.T, _ fwops.Report[CREWorkflowDeleteInput, CREWorkflowDeleteOutput], err error) { + t.Helper() + require.ErrorContains(t, err, "resolve project.yaml") + }, + }, + { + name: "CLI exit error propagates exit code and output", + input: func(t *testing.T) CREWorkflowDeleteInput { + t.Helper() + + return CREWorkflowDeleteInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + DonFamily: "feeds-zone-a", + DeploymentRegistry: "private", + } + }, + setupCLI: func(t *testing.T) *cremocks.MockCLIRunner { + t.Helper() + exitErr := &fcre.ExitError{ExitCode: 11, Stdout: []byte("out"), Stderr: []byte("err")} + m := cremocks.NewMockCLIRunner(t) + m.EXPECT().ContextRegistries().Return(testRegistries()).Once() + m.EXPECT().Run(mock.Anything, mock.Anything, mock.Anything).Return( + &fcre.CallResult{ExitCode: 11, Stdout: exitErr.Stdout, Stderr: exitErr.Stderr}, exitErr, + ).Once() + + return m + }, + assert: func(t *testing.T, out fwops.Report[CREWorkflowDeleteInput, CREWorkflowDeleteOutput], err error) { + t.Helper() + require.ErrorContains(t, err, "cre workflow delete") + require.Equal(t, 11, out.Output.ExitCode) + require.Equal(t, "out", out.Output.Stdout) + require.Equal(t, "err", out.Output.Stderr) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mockCLI := tc.setupCLI(t) + bundle := fwops.NewBundle(func() context.Context { return t.Context() }, logger.Test(t), fwops.NewMemoryReporter()) + deps := CREDeployDeps{ + CLI: mockCLI, + CRECfg: cfgenv.CREConfig{}, + } + + out, err := fwops.ExecuteOperation(bundle, CREWorkflowDeleteOp, deps, tc.input(t)) + tc.assert(t, out, err) + }) + } +} + +func TestResolveDeleteTargetName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input CREWorkflowDeleteInput + expect string + }{ + {name: "empty defaults", input: CREWorkflowDeleteInput{}, expect: CREDeployTargetName}, + {name: "whitespace defaults", input: CREWorkflowDeleteInput{TargetName: " "}, expect: CREDeployTargetName}, + {name: "custom target returned trimmed", input: CREWorkflowDeleteInput{TargetName: " staging-settings "}, expect: "staging-settings"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expect, tc.input.resolveTargetName()) + }) + } +} + +func TestBuildWorkflowDeleteArgs(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + + tests := []struct { + name string + targetName string + envPath string + extra []string + check func(t *testing.T, args []string) + }{ + { + name: "with env and extra args", + targetName: "staging-settings", + envPath: filepath.Join(workDir, ".env"), + extra: []string{"--extra"}, + check: func(t *testing.T, args []string) { + t.Helper() + require.Equal(t, []string{ + "workflow", "delete", filepath.Join(workDir, creBundleSubdir), + "-R", workDir, "-T", "staging-settings", + "--yes", + "-e", filepath.Join(workDir, ".env"), + "--extra", + }, args) + }, + }, + { + name: "without env", + targetName: CREDeployTargetName, + check: func(t *testing.T, args []string) { + t.Helper() + require.NotContains(t, args, "-e") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.check(t, BuildWorkflowDeleteArgs(tc.targetName, workDir, tc.envPath, tc.extra)) + }) + } +} + +func TestDeleteInputValidate(t *testing.T) { + t.Parallel() + + validProject := creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))) + in := CREWorkflowDeleteInput{ + WorkflowName: " wf ", + Project: validProject, + DonFamily: " zone-a ", + DeploymentRegistry: " private ", + TargetName: " staging-settings ", + } + require.NoError(t, in.Validate()) + require.Equal(t, "wf", in.WorkflowName) + require.Equal(t, "zone-a", in.DonFamily) + require.Equal(t, "private", in.DeploymentRegistry) + require.Equal(t, "staging-settings", in.TargetName) + + bad := CREWorkflowDeleteInput{} + require.ErrorContains(t, bad.Validate(), "workflowName is required") + + bad = CREWorkflowDeleteInput{WorkflowName: "wf", Project: validProject} + require.ErrorContains(t, bad.Validate(), "deploymentRegistry is required") + + bad = CREWorkflowDeleteInput{WorkflowName: "wf", Project: validProject, DeploymentRegistry: "private"} + require.ErrorContains(t, bad.Validate(), "donFamily is required") + + bad = CREWorkflowDeleteInput{WorkflowName: "wf", DonFamily: "zone-a", DeploymentRegistry: "private"} + require.ErrorContains(t, bad.Validate(), "project:") +} diff --git a/cre/operations/workflow_pause.go b/cre/operations/workflow_pause.go new file mode 100644 index 0000000..55badd5 --- /dev/null +++ b/cre/operations/workflow_pause.go @@ -0,0 +1,188 @@ +// Package operations provides CRE workflow operations that execute side effects via the CRE CLI. +package operations + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + + fcre "github.com/smartcontractkit/chainlink-deployments-framework/cre" + creartifacts "github.com/smartcontractkit/chainlink-deployments-framework/cre/artifacts" + crecli "github.com/smartcontractkit/chainlink-deployments-framework/cre/cli" + fwops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// CREWorkflowPauseOutput is the serializable result of a CRE CLI workflow pause invocation. +type CREWorkflowPauseOutput struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +// CREWorkflowPauseInput is the resolved input for a CRE workflow pause command. +type CREWorkflowPauseInput struct { + WorkflowName string `json:"workflowName" yaml:"workflowName"` + Project creartifacts.ConfigSource `json:"project" yaml:"project"` + DonFamily string `json:"donFamily,omitempty" yaml:"donFamily,omitempty"` + DeploymentRegistry string `json:"deploymentRegistry,omitempty" yaml:"deploymentRegistry,omitempty"` + Context crecli.ContextOverrides `json:"context" yaml:"context"` + ExtraCREArgs []string `json:"extraCreArgs,omitempty" yaml:"extraCreArgs,omitempty"` + TargetName string `json:"targetName,omitempty" yaml:"targetName,omitempty"` +} + +// Validate trims string fields and validates the pause input. +func (in *CREWorkflowPauseInput) Validate() error { + in.WorkflowName = strings.TrimSpace(in.WorkflowName) + in.DonFamily = strings.TrimSpace(in.DonFamily) + in.DeploymentRegistry = strings.TrimSpace(in.DeploymentRegistry) + in.TargetName = strings.TrimSpace(in.TargetName) + + if in.WorkflowName == "" { + return errors.New("workflowName is required") + } + if err := in.Project.Validate(); err != nil { + return fmt.Errorf("project: %w", err) + } + if in.DeploymentRegistry == "" { + return errors.New("deploymentRegistry is required") + } + if in.DonFamily == "" { + return errors.New("donFamily is required") + } + + return nil +} + +func (in CREWorkflowPauseInput) resolveTargetName() string { + target := strings.TrimSpace(in.TargetName) + if target != "" { + return target + } + + return CREDeployTargetName +} + +// CREWorkflowPauseOp pauses a workflow via the CRE CLI. +var CREWorkflowPauseOp = fwops.NewOperation( + "cre-workflow-pause", + semver.MustParse("1.0.0"), + "Pauses a CRE workflow via the CRE CLI subprocess", + func(b fwops.Bundle, deps CREDeployDeps, input CREWorkflowPauseInput) (CREWorkflowPauseOutput, error) { + ctx := b.GetContext() + if deps.CLI == nil { + return CREWorkflowPauseOutput{}, errors.New("cre CLIRunner is nil") + } + + workDir, err := os.MkdirTemp("", "cre-workflow-pause-*") + if err != nil { + return CREWorkflowPauseOutput{}, fmt.Errorf("mkdir temp workflow artifacts: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + resolver, err := creartifacts.NewArtifactsResolver(workDir) + if err != nil { + return CREWorkflowPauseOutput{}, err + } + + projectSrc, err := resolver.ResolveConfig(ctx, input.Project) + if err != nil { + return CREWorkflowPauseOutput{}, fmt.Errorf("resolve project.yaml: %w", err) + } + + projectDest := filepath.Join(workDir, "project.yaml") + if err = copyFile(projectSrc, projectDest); err != nil { + return CREWorkflowPauseOutput{}, fmt.Errorf("copy project.yaml: %w", err) + } + + bundleDir := filepath.Join(workDir, creBundleSubdir) + if err = os.MkdirAll(bundleDir, 0o700); err != nil { + return CREWorkflowPauseOutput{}, err + } + + target := input.resolveTargetName() + workflowCfg := crecli.WorkflowConfig{ + target: { + UserWorkflow: crecli.UserWorkflow{ + DeploymentRegistry: input.DeploymentRegistry, + WorkflowName: input.WorkflowName, + }, + }, + } + workflowYAMLPath, err := crecli.WriteWorkflowYAML(bundleDir, workflowCfg) + if err != nil { + return CREWorkflowPauseOutput{}, fmt.Errorf("write workflow.yaml: %w", err) + } + + ctxCfg, err := crecli.BuildContextConfig(input.DonFamily, input.Context, deps.CRECfg, deps.CLI.ContextRegistries()) + if err != nil { + return CREWorkflowPauseOutput{}, err + } + contextPath, err := crecli.WriteContextYAML(workDir, ctxCfg) + if err != nil { + return CREWorkflowPauseOutput{}, fmt.Errorf("write context.yaml: %w", err) + } + + logResolvedFile(b.Logger, "workflow.yaml", workflowYAMLPath, prettyYAML) + logResolvedFile(b.Logger, "project.yaml", projectDest, prettyYAML) + logResolvedFile(b.Logger, "context.yaml", contextPath, prettyYAML) + + envPath, err := crecli.WriteCREEnvFile(workDir, contextPath, deps.CRECfg, input.DonFamily) + if err != nil { + return CREWorkflowPauseOutput{}, fmt.Errorf("write CRE .env file: %w", err) + } + + args := BuildWorkflowPauseArgs(target, workDir, envPath, input.ExtraCREArgs) + b.Logger.Infow("Running CRE workflow pause", "args", args) + + res, runErr := deps.CLI.Run(ctx, nil, args...) + if runErr != nil { + var exitErr *fcre.ExitError + if errors.As(runErr, &exitErr) { + return CREWorkflowPauseOutput{ + ExitCode: exitErr.ExitCode, + Stdout: string(exitErr.Stdout), + Stderr: string(exitErr.Stderr), + }, fmt.Errorf("cre workflow pause: %w", runErr) + } + + return CREWorkflowPauseOutput{}, fmt.Errorf("cre workflow pause: %w", runErr) + } + if res == nil { + return CREWorkflowPauseOutput{}, errors.New("cre workflow pause: CLI returned nil result without error") + } + + b.Logger.Infow("CRE workflow pause finished", + "exitCode", res.ExitCode, + "stdout", string(res.Stdout), + "stderr", string(res.Stderr), + ) + + return CREWorkflowPauseOutput{ + ExitCode: res.ExitCode, + Stdout: string(res.Stdout), + Stderr: string(res.Stderr), + }, nil + }, +) + +// BuildWorkflowPauseArgs constructs the CRE CLI argument list for `cre workflow pause`. +func BuildWorkflowPauseArgs(targetName, workDir, envPath string, extra []string) []string { + bundleDir := filepath.Join(workDir, creBundleSubdir) + args := []string{ + "workflow", "pause", + bundleDir, + "-R", workDir, + "-T", targetName, + "--yes", + } + if envPath != "" { + args = append(args, "-e", envPath) + } + args = append(args, extra...) + + return args +} diff --git a/cre/operations/workflow_pause_test.go b/cre/operations/workflow_pause_test.go new file mode 100644 index 0000000..cfd63d2 --- /dev/null +++ b/cre/operations/workflow_pause_test.go @@ -0,0 +1,224 @@ +package operations + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + fcre "github.com/smartcontractkit/chainlink-deployments-framework/cre" + creartifacts "github.com/smartcontractkit/chainlink-deployments-framework/cre/artifacts" + cremocks "github.com/smartcontractkit/chainlink-deployments-framework/cre/mocks" + cfgenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/env" + fwops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestCREWorkflowPauseOp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input func(t *testing.T) CREWorkflowPauseInput + setupCLI func(t *testing.T) *cremocks.MockCLIRunner + assert func(t *testing.T, out fwops.Report[CREWorkflowPauseInput, CREWorkflowPauseOutput], err error) + }{ + { + name: "success invokes CLI with pause args", + input: func(t *testing.T) CREWorkflowPauseInput { + t.Helper() + + return CREWorkflowPauseInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + DonFamily: "feeds-zone-a", + DeploymentRegistry: "private", + } + }, + setupCLI: func(t *testing.T) *cremocks.MockCLIRunner { + t.Helper() + m := cremocks.NewMockCLIRunner(t) + m.EXPECT().ContextRegistries().Return(testRegistries()).Once() + m.EXPECT().Run(mock.Anything, (map[string]string)(nil), matchCLIArgs("workflow", "pause")).Return( + &fcre.CallResult{ExitCode: 0, Stdout: []byte("paused")}, nil, + ).Once() + + return m + }, + assert: func(t *testing.T, out fwops.Report[CREWorkflowPauseInput, CREWorkflowPauseOutput], err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, 0, out.Output.ExitCode) + require.Equal(t, "paused", out.Output.Stdout) + }, + }, + { + name: "missing project returns resolve error", + input: func(t *testing.T) CREWorkflowPauseInput { + t.Helper() + + return CREWorkflowPauseInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal("/tmp/definitely-missing-project.yaml"), + DonFamily: "feeds-zone-a", + DeploymentRegistry: "private", + } + }, + setupCLI: func(t *testing.T) *cremocks.MockCLIRunner { + t.Helper() + + return cremocks.NewMockCLIRunner(t) + }, + assert: func(t *testing.T, _ fwops.Report[CREWorkflowPauseInput, CREWorkflowPauseOutput], err error) { + t.Helper() + require.ErrorContains(t, err, "resolve project.yaml") + }, + }, + { + name: "CLI exit error propagates exit code and output", + input: func(t *testing.T) CREWorkflowPauseInput { + t.Helper() + + return CREWorkflowPauseInput{ + WorkflowName: "wf", + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + DonFamily: "feeds-zone-a", + DeploymentRegistry: "private", + } + }, + setupCLI: func(t *testing.T) *cremocks.MockCLIRunner { + t.Helper() + exitErr := &fcre.ExitError{ExitCode: 9, Stdout: []byte("out"), Stderr: []byte("err")} + m := cremocks.NewMockCLIRunner(t) + m.EXPECT().ContextRegistries().Return(testRegistries()).Once() + m.EXPECT().Run(mock.Anything, mock.Anything, mock.Anything).Return( + &fcre.CallResult{ExitCode: 9, Stdout: exitErr.Stdout, Stderr: exitErr.Stderr}, exitErr, + ).Once() + + return m + }, + assert: func(t *testing.T, out fwops.Report[CREWorkflowPauseInput, CREWorkflowPauseOutput], err error) { + t.Helper() + require.ErrorContains(t, err, "cre workflow pause") + require.Equal(t, 9, out.Output.ExitCode) + require.Equal(t, "out", out.Output.Stdout) + require.Equal(t, "err", out.Output.Stderr) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mockCLI := tc.setupCLI(t) + bundle := fwops.NewBundle(func() context.Context { return t.Context() }, logger.Test(t), fwops.NewMemoryReporter()) + deps := CREDeployDeps{ + CLI: mockCLI, + CRECfg: cfgenv.CREConfig{}, + } + + out, err := fwops.ExecuteOperation(bundle, CREWorkflowPauseOp, deps, tc.input(t)) + tc.assert(t, out, err) + }) + } +} + +func TestResolvePauseTargetName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input CREWorkflowPauseInput + expect string + }{ + {name: "empty defaults", input: CREWorkflowPauseInput{}, expect: CREDeployTargetName}, + {name: "whitespace defaults", input: CREWorkflowPauseInput{TargetName: " "}, expect: CREDeployTargetName}, + {name: "custom target returned trimmed", input: CREWorkflowPauseInput{TargetName: " staging-settings "}, expect: "staging-settings"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expect, tc.input.resolveTargetName()) + }) + } +} + +func TestBuildWorkflowPauseArgs(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + + tests := []struct { + name string + targetName string + envPath string + extra []string + check func(t *testing.T, args []string) + }{ + { + name: "with env and extra args", + targetName: "staging-settings", + envPath: filepath.Join(workDir, ".env"), + extra: []string{"--extra"}, + check: func(t *testing.T, args []string) { + t.Helper() + require.Equal(t, []string{ + "workflow", "pause", filepath.Join(workDir, creBundleSubdir), + "-R", workDir, "-T", "staging-settings", + "--yes", + "-e", filepath.Join(workDir, ".env"), + "--extra", + }, args) + }, + }, + { + name: "without env", + targetName: CREDeployTargetName, + check: func(t *testing.T, args []string) { + t.Helper() + require.NotContains(t, args, "-e") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.check(t, BuildWorkflowPauseArgs(tc.targetName, workDir, tc.envPath, tc.extra)) + }) + } +} + +func TestPauseInputValidate(t *testing.T) { + t.Parallel() + + validProject := creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))) + in := CREWorkflowPauseInput{ + WorkflowName: " wf ", + Project: validProject, + DonFamily: " zone-a ", + DeploymentRegistry: " private ", + TargetName: " staging-settings ", + } + require.NoError(t, in.Validate()) + require.Equal(t, "wf", in.WorkflowName) + require.Equal(t, "zone-a", in.DonFamily) + require.Equal(t, "private", in.DeploymentRegistry) + require.Equal(t, "staging-settings", in.TargetName) + + bad := CREWorkflowPauseInput{} + require.ErrorContains(t, bad.Validate(), "workflowName is required") + + bad = CREWorkflowPauseInput{WorkflowName: "wf", Project: validProject} + require.ErrorContains(t, bad.Validate(), "deploymentRegistry is required") + + bad = CREWorkflowPauseInput{WorkflowName: "wf", Project: validProject, DeploymentRegistry: "private"} + require.ErrorContains(t, bad.Validate(), "donFamily is required") + + bad = CREWorkflowPauseInput{WorkflowName: "wf", DonFamily: "zone-a", DeploymentRegistry: "private"} + require.ErrorContains(t, bad.Validate(), "project:") +} From 8262ea05f7c08605fee070b1cfa6daee7759ab0c Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 24 Apr 2026 17:20:34 -0600 Subject: [PATCH 2/4] feat: add workflow delete and workflow pause --- cre/changesets/workflow_delete_test.go | 6 ++++++ cre/changesets/workflow_pause_test.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/cre/changesets/workflow_delete_test.go b/cre/changesets/workflow_delete_test.go index bc4dbd2..5ac0c25 100644 --- a/cre/changesets/workflow_delete_test.go +++ b/cre/changesets/workflow_delete_test.go @@ -67,6 +67,7 @@ func TestCREWorkflowDeleteChangeset_VerifyPreconditions(t *testing.T) { input: func() operations.CREWorkflowDeleteInput { in := good in.Project = creartifacts.ConfigSource{} + return in }, wantErr: "project:", @@ -77,6 +78,7 @@ func TestCREWorkflowDeleteChangeset_VerifyPreconditions(t *testing.T) { input: func() operations.CREWorkflowDeleteInput { in := good in.DeploymentRegistry = "" + return in }, wantErr: "deploymentRegistry is required", @@ -87,6 +89,7 @@ func TestCREWorkflowDeleteChangeset_VerifyPreconditions(t *testing.T) { input: func() operations.CREWorkflowDeleteInput { in := good in.DonFamily = "" + return in }, wantErr: "donFamily is required", @@ -113,10 +116,12 @@ func TestCREWorkflowDeleteChangeset_VerifyPreconditions(t *testing.T) { } func TestCREWorkflowDeleteChangeset_Apply(t *testing.T) { + t.Parallel() cs := CREWorkflowDeleteChangeset{} input := validDeleteInput(t) t.Run("success returns report", func(t *testing.T) { + t.Parallel() mockCLI := cremocks.NewMockCLIRunner(t) mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() mockCLI.EXPECT(). @@ -135,6 +140,7 @@ func TestCREWorkflowDeleteChangeset_Apply(t *testing.T) { }) t.Run("operation error returns report and error", func(t *testing.T) { + t.Parallel() mockCLI := cremocks.NewMockCLIRunner(t) mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() mockCLI.EXPECT().Run(mock.Anything, mock.Anything, mock.Anything). diff --git a/cre/changesets/workflow_pause_test.go b/cre/changesets/workflow_pause_test.go index 9dbadda..d54a646 100644 --- a/cre/changesets/workflow_pause_test.go +++ b/cre/changesets/workflow_pause_test.go @@ -67,6 +67,7 @@ func TestCREWorkflowPauseChangeset_VerifyPreconditions(t *testing.T) { input: func() operations.CREWorkflowPauseInput { in := good in.Project = creartifacts.ConfigSource{} + return in }, wantErr: "project:", @@ -77,6 +78,7 @@ func TestCREWorkflowPauseChangeset_VerifyPreconditions(t *testing.T) { input: func() operations.CREWorkflowPauseInput { in := good in.DeploymentRegistry = "" + return in }, wantErr: "deploymentRegistry is required", @@ -87,6 +89,7 @@ func TestCREWorkflowPauseChangeset_VerifyPreconditions(t *testing.T) { input: func() operations.CREWorkflowPauseInput { in := good in.DonFamily = "" + return in }, wantErr: "donFamily is required", @@ -113,10 +116,12 @@ func TestCREWorkflowPauseChangeset_VerifyPreconditions(t *testing.T) { } func TestCREWorkflowPauseChangeset_Apply(t *testing.T) { + t.Parallel() cs := CREWorkflowPauseChangeset{} input := validPauseInput(t) t.Run("success returns report", func(t *testing.T) { + t.Parallel() mockCLI := cremocks.NewMockCLIRunner(t) mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() mockCLI.EXPECT(). @@ -135,6 +140,7 @@ func TestCREWorkflowPauseChangeset_Apply(t *testing.T) { }) t.Run("operation error returns report and error", func(t *testing.T) { + t.Parallel() mockCLI := cremocks.NewMockCLIRunner(t) mockCLI.EXPECT().ContextRegistries().Return([]fcre.ContextRegistryEntry{{ID: "private", Type: "off-chain"}}).Once() mockCLI.EXPECT().Run(mock.Anything, mock.Anything, mock.Anything). From a62f91c4420f40f7b8fe241f74a9951b5f1d82a3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 24 Apr 2026 17:55:07 -0600 Subject: [PATCH 3/4] fix: copilot comments --- cre/changesets/workflow_delete.go | 4 ++++ cre/changesets/workflow_delete_test.go | 2 +- cre/changesets/workflow_pause.go | 4 ++++ cre/changesets/workflow_pause_test.go | 2 +- cre/operations/workflow_delete_test.go | 4 ++-- cre/operations/workflow_pause_test.go | 4 ++-- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/cre/changesets/workflow_delete.go b/cre/changesets/workflow_delete.go index e76c866..a792f37 100644 --- a/cre/changesets/workflow_delete.go +++ b/cre/changesets/workflow_delete.go @@ -32,6 +32,10 @@ func (CREWorkflowDeleteChangeset) VerifyPreconditions(e cldf.Environment, input // Apply loads CRE config and runs the CRE workflow delete operation. func (CREWorkflowDeleteChangeset) Apply(e cldf.Environment, input creops.CREWorkflowDeleteInput) (cldf.ChangesetOutput, error) { + if err := input.Validate(); err != nil { + return cldf.ChangesetOutput{}, err + } + envCfg, err := cfgenv.LoadEnv() if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("load CRE env config: %w", err) diff --git a/cre/changesets/workflow_delete_test.go b/cre/changesets/workflow_delete_test.go index 5ac0c25..34eb876 100644 --- a/cre/changesets/workflow_delete_test.go +++ b/cre/changesets/workflow_delete_test.go @@ -33,7 +33,7 @@ func newDeleteTestEnv(t *testing.T, opts ...testenv.LoadOpt) *cldf.Environment { func validDeleteInput(t *testing.T) operations.CREWorkflowDeleteInput { t.Helper() projectPath := filepath.Join(t.TempDir(), "project.yaml") - require.NoError(t, os.WriteFile(projectPath, []byte("staging-settings:\n rpcs: []\n"), 0o600)) + require.NoError(t, os.WriteFile(projectPath, []byte("cld-deploy:\n rpcs: []\n"), 0o600)) return operations.CREWorkflowDeleteInput{ WorkflowName: "wf", diff --git a/cre/changesets/workflow_pause.go b/cre/changesets/workflow_pause.go index 38ff869..27fee9b 100644 --- a/cre/changesets/workflow_pause.go +++ b/cre/changesets/workflow_pause.go @@ -32,6 +32,10 @@ func (CREWorkflowPauseChangeset) VerifyPreconditions(e cldf.Environment, input c // Apply loads CRE config and runs the CRE workflow pause operation. func (CREWorkflowPauseChangeset) Apply(e cldf.Environment, input creops.CREWorkflowPauseInput) (cldf.ChangesetOutput, error) { + if err := input.Validate(); err != nil { + return cldf.ChangesetOutput{}, err + } + envCfg, err := cfgenv.LoadEnv() if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("load CRE env config: %w", err) diff --git a/cre/changesets/workflow_pause_test.go b/cre/changesets/workflow_pause_test.go index d54a646..8c41619 100644 --- a/cre/changesets/workflow_pause_test.go +++ b/cre/changesets/workflow_pause_test.go @@ -33,7 +33,7 @@ func newPauseTestEnv(t *testing.T, opts ...testenv.LoadOpt) *cldf.Environment { func validPauseInput(t *testing.T) operations.CREWorkflowPauseInput { t.Helper() projectPath := filepath.Join(t.TempDir(), "project.yaml") - require.NoError(t, os.WriteFile(projectPath, []byte("staging-settings:\n rpcs: []\n"), 0o600)) + require.NoError(t, os.WriteFile(projectPath, []byte("cld-deploy:\n rpcs: []\n"), 0o600)) return operations.CREWorkflowPauseInput{ WorkflowName: "wf", diff --git a/cre/operations/workflow_delete_test.go b/cre/operations/workflow_delete_test.go index 50f8bf7..27d92a4 100644 --- a/cre/operations/workflow_delete_test.go +++ b/cre/operations/workflow_delete_test.go @@ -32,7 +32,7 @@ func TestCREWorkflowDeleteOp(t *testing.T) { return CREWorkflowDeleteInput{ WorkflowName: "wf", - Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("cld-deploy:\n rpcs: []\n"))), DonFamily: "feeds-zone-a", DeploymentRegistry: "private", } @@ -83,7 +83,7 @@ func TestCREWorkflowDeleteOp(t *testing.T) { return CREWorkflowDeleteInput{ WorkflowName: "wf", - Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("cld-deploy:\n rpcs: []\n"))), DonFamily: "feeds-zone-a", DeploymentRegistry: "private", } diff --git a/cre/operations/workflow_pause_test.go b/cre/operations/workflow_pause_test.go index cfd63d2..d9a82c4 100644 --- a/cre/operations/workflow_pause_test.go +++ b/cre/operations/workflow_pause_test.go @@ -32,7 +32,7 @@ func TestCREWorkflowPauseOp(t *testing.T) { return CREWorkflowPauseInput{ WorkflowName: "wf", - Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("cld-deploy:\n rpcs: []\n"))), DonFamily: "feeds-zone-a", DeploymentRegistry: "private", } @@ -83,7 +83,7 @@ func TestCREWorkflowPauseOp(t *testing.T) { return CREWorkflowPauseInput{ WorkflowName: "wf", - Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("staging-settings:\n rpcs: []\n"))), + Project: creartifacts.NewConfigSourceLocal(writeFile(t, "project.yaml", []byte("cld-deploy:\n rpcs: []\n"))), DonFamily: "feeds-zone-a", DeploymentRegistry: "private", } From 254bd3286d067ba73dd70c25d64fe318a98341f7 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 24 Apr 2026 19:07:51 -0600 Subject: [PATCH 4/4] fix: copilot comments --- cre/operations/workflow_delete_test.go | 3 ++- cre/operations/workflow_pause_test.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cre/operations/workflow_delete_test.go b/cre/operations/workflow_delete_test.go index 27d92a4..6416ae5 100644 --- a/cre/operations/workflow_delete_test.go +++ b/cre/operations/workflow_delete_test.go @@ -58,10 +58,11 @@ func TestCREWorkflowDeleteOp(t *testing.T) { name: "missing project returns resolve error", input: func(t *testing.T) CREWorkflowDeleteInput { t.Helper() + missingProjectPath := filepath.Join(t.TempDir(), "project.yaml") return CREWorkflowDeleteInput{ WorkflowName: "wf", - Project: creartifacts.NewConfigSourceLocal("/tmp/definitely-missing-project.yaml"), + Project: creartifacts.NewConfigSourceLocal(missingProjectPath), DonFamily: "feeds-zone-a", DeploymentRegistry: "private", } diff --git a/cre/operations/workflow_pause_test.go b/cre/operations/workflow_pause_test.go index d9a82c4..43f8117 100644 --- a/cre/operations/workflow_pause_test.go +++ b/cre/operations/workflow_pause_test.go @@ -58,10 +58,11 @@ func TestCREWorkflowPauseOp(t *testing.T) { name: "missing project returns resolve error", input: func(t *testing.T) CREWorkflowPauseInput { t.Helper() + missingProjectPath := filepath.Join(t.TempDir(), "project.yaml") return CREWorkflowPauseInput{ WorkflowName: "wf", - Project: creartifacts.NewConfigSourceLocal("/tmp/definitely-missing-project.yaml"), + Project: creartifacts.NewConfigSourceLocal(missingProjectPath), DonFamily: "feeds-zone-a", DeploymentRegistry: "private", }