From b67de369511fb672c5e2636266e59e4504daba84 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 25 Feb 2026 00:38:50 -0500 Subject: [PATCH 1/2] feat: add step.build_from_config CI/CD pipeline step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements step.build_from_config (Phase 5.1 roadmap) — a pipeline step that assembles a self-contained Docker image from a workflow config YAML file, a server binary, and optional plugin binaries. - Creates a temp build context, copies config + server + plugin binaries - Generates a Dockerfile with correct ENTRYPOINT/CMD for workflow server - Executes docker build (and optional docker push) via exec.Command - exec.Command is injectable for deterministic unit testing - 17 tests cover factory validation, Dockerfile generation, error paths, push flag, plugin inclusion, and build context file layout - Registers step.build_from_config in plugins/cicd manifest and factory map Co-Authored-By: Claude Opus 4.6 --- module/pipeline_step_build_from_config.go | 246 +++++++++ .../pipeline_step_build_from_config_test.go | 517 ++++++++++++++++++ plugins/cicd/plugin.go | 35 +- plugins/cicd/plugin_test.go | 6 +- 4 files changed, 786 insertions(+), 18 deletions(-) create mode 100644 module/pipeline_step_build_from_config.go create mode 100644 module/pipeline_step_build_from_config_test.go diff --git a/module/pipeline_step_build_from_config.go b/module/pipeline_step_build_from_config.go new file mode 100644 index 00000000..6c709f37 --- /dev/null +++ b/module/pipeline_step_build_from_config.go @@ -0,0 +1,246 @@ +package module + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// PluginSpec describes a plugin binary to include in the built image. +type PluginSpec struct { + Name string + Binary string +} + +// BuildFromConfigStep reads a workflow config YAML file, assembles a Docker +// build context with the server binary and any required plugin binaries, +// generates a Dockerfile, builds the image, and optionally pushes it. +type BuildFromConfigStep struct { + name string + configFile string + baseImage string + serverBinary string + tag string + push bool + plugins []PluginSpec + + // execCommand is the function used to create exec.Cmd instances. + // Defaults to exec.CommandContext; overridable in tests. + execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// NewBuildFromConfigStepFactory returns a StepFactory that creates BuildFromConfigStep instances. +func NewBuildFromConfigStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + configFile, _ := config["config_file"].(string) + if configFile == "" { + return nil, fmt.Errorf("build_from_config step %q: 'config_file' is required", name) + } + + tag, _ := config["tag"].(string) + if tag == "" { + return nil, fmt.Errorf("build_from_config step %q: 'tag' is required", name) + } + + baseImage, _ := config["base_image"].(string) + if baseImage == "" { + baseImage = "ghcr.io/gocodealone/workflow-runtime:latest" + } + + serverBinary, _ := config["server_binary"].(string) + if serverBinary == "" { + serverBinary = "/usr/local/bin/workflow-server" + } + + push, _ := config["push"].(bool) + + var plugins []PluginSpec + if pluginsRaw, ok := config["plugins"].([]any); ok { + for i, p := range pluginsRaw { + m, ok := p.(map[string]any) + if !ok { + return nil, fmt.Errorf("build_from_config step %q: plugins[%d] must be a map", name, i) + } + pName, _ := m["name"].(string) + pBinary, _ := m["binary"].(string) + if pName == "" || pBinary == "" { + return nil, fmt.Errorf("build_from_config step %q: plugins[%d] requires 'name' and 'binary'", name, i) + } + plugins = append(plugins, PluginSpec{Name: pName, Binary: pBinary}) + } + } + + return &BuildFromConfigStep{ + name: name, + configFile: configFile, + baseImage: baseImage, + serverBinary: serverBinary, + tag: tag, + push: push, + plugins: plugins, + execCommand: exec.CommandContext, + }, nil + } +} + +// Name returns the step name. +func (s *BuildFromConfigStep) Name() string { return s.name } + +// Execute assembles the build context, generates a Dockerfile, builds the +// Docker image, and optionally pushes it. +func (s *BuildFromConfigStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + // Validate that the config file exists. + if _, err := os.Stat(s.configFile); err != nil { + return nil, fmt.Errorf("build_from_config step %q: config_file %q not found: %w", s.name, s.configFile, err) + } + + // Validate that the server binary exists. + if _, err := os.Stat(s.serverBinary); err != nil { + return nil, fmt.Errorf("build_from_config step %q: server_binary %q not found: %w", s.name, s.serverBinary, err) + } + + // Create a temporary build context directory. + buildDir, err := os.MkdirTemp("", "workflow-build-*") + if err != nil { + return nil, fmt.Errorf("build_from_config step %q: failed to create temp build dir: %w", s.name, err) + } + defer os.RemoveAll(buildDir) + + // Copy config file into build context as config.yaml. + if err := copyFile(s.configFile, filepath.Join(buildDir, "config.yaml")); err != nil { + return nil, fmt.Errorf("build_from_config step %q: failed to copy config file: %w", s.name, err) + } + + // Copy server binary into build context as server. + serverDst := filepath.Join(buildDir, "server") + if err := copyFile(s.serverBinary, serverDst); err != nil { + return nil, fmt.Errorf("build_from_config step %q: failed to copy server binary: %w", s.name, err) + } + if err := os.Chmod(serverDst, 0755); err != nil { //nolint:gosec // G302: intentionally executable + return nil, fmt.Errorf("build_from_config step %q: failed to chmod server binary: %w", s.name, err) + } + + // Copy plugin binaries into build context under plugins//. + pluginsDir := filepath.Join(buildDir, "plugins") + for _, plugin := range s.plugins { + if _, err := os.Stat(plugin.Binary); err != nil { + return nil, fmt.Errorf("build_from_config step %q: plugin %q binary %q not found: %w", + s.name, plugin.Name, plugin.Binary, err) + } + pluginDir := filepath.Join(pluginsDir, plugin.Name) + if err := os.MkdirAll(pluginDir, 0750); err != nil { + return nil, fmt.Errorf("build_from_config step %q: failed to create plugin dir for %q: %w", + s.name, plugin.Name, err) + } + pluginBinaryName := filepath.Base(plugin.Binary) + pluginDst := filepath.Join(pluginDir, pluginBinaryName) + if err := copyFile(plugin.Binary, pluginDst); err != nil { + return nil, fmt.Errorf("build_from_config step %q: failed to copy plugin %q binary: %w", + s.name, plugin.Name, err) + } + if err := os.Chmod(pluginDst, 0755); err != nil { //nolint:gosec // G302: intentionally executable + return nil, fmt.Errorf("build_from_config step %q: failed to chmod plugin %q binary: %w", + s.name, plugin.Name, err) + } + } + + // Generate Dockerfile content. + dockerfileContent := s.generateDockerfile() + + // Write Dockerfile into build context. + dockerfilePath := filepath.Join(buildDir, "Dockerfile") + if err := os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0600); err != nil { + return nil, fmt.Errorf("build_from_config step %q: failed to write Dockerfile: %w", s.name, err) + } + + // Execute docker build. + if err := s.runDockerBuild(ctx, buildDir); err != nil { + return nil, fmt.Errorf("build_from_config step %q: docker build failed: %w", s.name, err) + } + + // Optionally push the image. + if s.push { + if err := s.runDockerPush(ctx); err != nil { + return nil, fmt.Errorf("build_from_config step %q: docker push failed: %w", s.name, err) + } + } + + return &StepResult{ + Output: map[string]any{ + "image_tag": s.tag, + "dockerfile_content": dockerfileContent, + }, + }, nil +} + +// generateDockerfile returns a Dockerfile string for the build context layout. +func (s *BuildFromConfigStep) generateDockerfile() string { + var sb strings.Builder + + fmt.Fprintf(&sb, "FROM %s\n", s.baseImage) + sb.WriteString("COPY server /server\n") + sb.WriteString("COPY config.yaml /app/config.yaml\n") + + if len(s.plugins) > 0 { + sb.WriteString("COPY plugins/ /app/data/plugins/\n") + } + + sb.WriteString("WORKDIR /app\n") + sb.WriteString("ENTRYPOINT [\"/server\"]\n") + sb.WriteString("CMD [\"-config\", \"/app/config.yaml\", \"-data-dir\", \"/app/data\"]\n") + + return sb.String() +} + +// runDockerBuild executes "docker build -t ". +func (s *BuildFromConfigStep) runDockerBuild(ctx context.Context, buildDir string) error { + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "docker", "build", "-t", s.tag, buildDir) //nolint:gosec // G204: tag from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + return nil +} + +// runDockerPush executes "docker push ". +func (s *BuildFromConfigStep) runDockerPush(ctx context.Context) error { + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "docker", "push", s.tag) //nolint:gosec // G204: tag from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + return nil +} + +// copyFile copies src to dst, creating dst if it does not exist. +func copyFile(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // G304: path from trusted pipeline config + if err != nil { + return fmt.Errorf("open %q: %w", src, err) + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("create %q: %w", dst, err) + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy %q -> %q: %w", src, dst, err) + } + return nil +} diff --git a/module/pipeline_step_build_from_config_test.go b/module/pipeline_step_build_from_config_test.go new file mode 100644 index 00000000..5ee6b210 --- /dev/null +++ b/module/pipeline_step_build_from_config_test.go @@ -0,0 +1,517 @@ +package module + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// setupBuildFromConfigFiles creates a temporary directory with a fake config +// file and a fake server binary (empty files). It returns the directory path +// and a cleanup function. +func setupBuildFromConfigFiles(t *testing.T) (configFile, serverBinary string, cleanup func()) { + t.Helper() + dir := t.TempDir() + + configFile = filepath.Join(dir, "app.yaml") + if err := os.WriteFile(configFile, []byte("version: 1\n"), 0600); err != nil { + t.Fatalf("failed to create config file: %v", err) + } + + serverBinary = filepath.Join(dir, "workflow-server") + if err := os.WriteFile(serverBinary, []byte("#!/bin/sh\n"), 0755); err != nil { //nolint:gosec + t.Fatalf("failed to create server binary: %v", err) + } + + return configFile, serverBinary, func() {} // t.TempDir cleans up automatically +} + +// noopExecCommand returns a mock exec.CommandContext function that succeeds +// without running any real process. +func noopExecCommand(_ context.Context, name string, args ...string) *exec.Cmd { + // Invoke a real no-op command so cmd.Run() succeeds. + return exec.Command("true") +} + +// failingExecCommand returns a mock that always fails with an exit error. +func failingExecCommand(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("false") +} + +func TestBuildFromConfigStep_FactoryRequiresConfigFile(t *testing.T) { + factory := NewBuildFromConfigStepFactory() + _, err := factory("bfc", map[string]any{"tag": "my-app:latest"}, nil) + if err == nil { + t.Fatal("expected error when config_file is missing") + } + if !strings.Contains(err.Error(), "config_file") { + t.Errorf("expected error to mention config_file, got: %v", err) + } +} + +func TestBuildFromConfigStep_FactoryRequiresTag(t *testing.T) { + factory := NewBuildFromConfigStepFactory() + _, err := factory("bfc", map[string]any{"config_file": "app.yaml"}, nil) + if err == nil { + t.Fatal("expected error when tag is missing") + } + if !strings.Contains(err.Error(), "tag") { + t.Errorf("expected error to mention tag, got: %v", err) + } +} + +func TestBuildFromConfigStep_FactoryPluginMissingFields(t *testing.T) { + factory := NewBuildFromConfigStepFactory() + _, err := factory("bfc", map[string]any{ + "config_file": "app.yaml", + "tag": "my-app:latest", + "plugins": []any{ + map[string]any{"name": "admin"}, // missing binary + }, + }, nil) + if err == nil { + t.Fatal("expected error when plugin binary is missing") + } + if !strings.Contains(err.Error(), "binary") { + t.Errorf("expected error to mention binary, got: %v", err) + } +} + +func TestBuildFromConfigStep_FactoryPluginInvalidEntry(t *testing.T) { + factory := NewBuildFromConfigStepFactory() + _, err := factory("bfc", map[string]any{ + "config_file": "app.yaml", + "tag": "my-app:latest", + "plugins": []any{"not-a-map"}, + }, nil) + if err == nil { + t.Fatal("expected error for non-map plugin entry") + } +} + +func TestBuildFromConfigStep_Name(t *testing.T) { + factory := NewBuildFromConfigStepFactory() + step, err := factory("my-build", map[string]any{ + "config_file": "app.yaml", + "tag": "my-app:latest", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + if step.Name() != "my-build" { + t.Errorf("expected name %q, got %q", "my-build", step.Name()) + } +} + +func TestBuildFromConfigStep_DefaultBaseImage(t *testing.T) { + factory := NewBuildFromConfigStepFactory() + raw, err := factory("bfc", map[string]any{ + "config_file": "app.yaml", + "tag": "my-app:latest", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + bfc := raw.(*BuildFromConfigStep) + if bfc.baseImage != "ghcr.io/gocodealone/workflow-runtime:latest" { + t.Errorf("unexpected default base_image: %q", bfc.baseImage) + } +} + +func TestBuildFromConfigStep_GenerateDockerfile_NoPLugins(t *testing.T) { + s := &BuildFromConfigStep{ + name: "bfc", + baseImage: "gcr.io/distroless/static-debian12:nonroot", + tag: "my-app:latest", + plugins: nil, + } + + got := s.generateDockerfile() + + expectedLines := []string{ + "FROM gcr.io/distroless/static-debian12:nonroot", + "COPY server /server", + "COPY config.yaml /app/config.yaml", + "WORKDIR /app", + "ENTRYPOINT [\"/server\"]", + `CMD ["-config", "/app/config.yaml", "-data-dir", "/app/data"]`, + } + + for _, line := range expectedLines { + if !strings.Contains(got, line) { + t.Errorf("Dockerfile missing line %q\nGot:\n%s", line, got) + } + } + + // Without plugins, there should be no plugins COPY line. + if strings.Contains(got, "COPY plugins/") { + t.Errorf("Dockerfile should not contain plugins COPY when no plugins configured") + } +} + +func TestBuildFromConfigStep_GenerateDockerfile_WithPlugins(t *testing.T) { + s := &BuildFromConfigStep{ + name: "bfc", + baseImage: "gcr.io/distroless/static-debian12:nonroot", + tag: "my-app:latest", + plugins: []PluginSpec{ + {Name: "admin", Binary: "data/plugins/admin/admin"}, + }, + } + + got := s.generateDockerfile() + + if !strings.Contains(got, "COPY plugins/ /app/data/plugins/") { + t.Errorf("Dockerfile should contain plugins COPY line when plugins are configured\nGot:\n%s", got) + } +} + +func TestBuildFromConfigStep_Execute_MissingConfigFile(t *testing.T) { + s := &BuildFromConfigStep{ + name: "bfc", + configFile: "/nonexistent/app.yaml", + serverBinary: "/nonexistent/server", + tag: "my-app:latest", + execCommand: noopExecCommand, + } + + _, err := s.Execute(context.Background(), &PipelineContext{}) + if err == nil { + t.Fatal("expected error for missing config_file") + } + if !strings.Contains(err.Error(), "config_file") { + t.Errorf("expected error to mention config_file, got: %v", err) + } +} + +func TestBuildFromConfigStep_Execute_MissingServerBinary(t *testing.T) { + configFile, _, _ := setupBuildFromConfigFiles(t) + + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: "/nonexistent/server", + tag: "my-app:latest", + execCommand: noopExecCommand, + } + + _, err := s.Execute(context.Background(), &PipelineContext{}) + if err == nil { + t.Fatal("expected error for missing server_binary") + } + if !strings.Contains(err.Error(), "server_binary") { + t.Errorf("expected error to mention server_binary, got: %v", err) + } +} + +func TestBuildFromConfigStep_Execute_MissingPluginBinary(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + tag: "my-app:latest", + plugins: []PluginSpec{ + {Name: "admin", Binary: "/nonexistent/admin"}, + }, + execCommand: noopExecCommand, + } + + _, err := s.Execute(context.Background(), &PipelineContext{}) + if err == nil { + t.Fatal("expected error for missing plugin binary") + } + if !strings.Contains(err.Error(), "plugin") { + t.Errorf("expected error to mention plugin, got: %v", err) + } +} + +func TestBuildFromConfigStep_Execute_DockerBuildFailure(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + tag: "my-app:latest", + execCommand: failingExecCommand, + } + + _, err := s.Execute(context.Background(), &PipelineContext{}) + if err == nil { + t.Fatal("expected error when docker build fails") + } + if !strings.Contains(err.Error(), "docker build") { + t.Errorf("expected error to mention docker build, got: %v", err) + } +} + +func TestBuildFromConfigStep_Execute_DockerPushFailure(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + callCount := 0 + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + tag: "my-app:latest", + push: true, + execCommand: func(ctx context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + // First call is docker build — succeed. + return exec.Command("true") + } + // Second call is docker push — fail. + return exec.Command("false") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{}) + if err == nil { + t.Fatal("expected error when docker push fails") + } + if !strings.Contains(err.Error(), "docker push") { + t.Errorf("expected error to mention docker push, got: %v", err) + } +} + +func TestBuildFromConfigStep_Execute_NoPush(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + buildCalled := false + pushCalled := false + + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + baseImage: "gcr.io/distroless/static-debian12:nonroot", + tag: "my-app:latest", + push: false, + execCommand: func(ctx context.Context, name string, args ...string) *exec.Cmd { + if name == "docker" && len(args) > 0 { + switch args[0] { + case "build": + buildCalled = true + case "push": + pushCalled = true + } + } + return exec.Command("true") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if !buildCalled { + t.Error("expected docker build to be called") + } + if pushCalled { + t.Error("expected docker push NOT to be called when push=false") + } + + if result.Output["image_tag"] != "my-app:latest" { + t.Errorf("expected image_tag %q, got %v", "my-app:latest", result.Output["image_tag"]) + } + + dockerfileContent, ok := result.Output["dockerfile_content"].(string) + if !ok || dockerfileContent == "" { + t.Error("expected dockerfile_content to be non-empty string") + } +} + +func TestBuildFromConfigStep_Execute_WithPush(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + var dockerCalls []string + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + baseImage: "gcr.io/distroless/static-debian12:nonroot", + tag: "my-app:latest", + push: true, + execCommand: func(ctx context.Context, name string, args ...string) *exec.Cmd { + if name == "docker" && len(args) > 0 { + dockerCalls = append(dockerCalls, args[0]) + } + return exec.Command("true") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if len(dockerCalls) != 2 { + t.Fatalf("expected 2 docker calls (build + push), got %d: %v", len(dockerCalls), dockerCalls) + } + if dockerCalls[0] != "build" { + t.Errorf("expected first docker call to be 'build', got %q", dockerCalls[0]) + } + if dockerCalls[1] != "push" { + t.Errorf("expected second docker call to be 'push', got %q", dockerCalls[1]) + } + + if result.Output["image_tag"] != "my-app:latest" { + t.Errorf("expected image_tag %q, got %v", "my-app:latest", result.Output["image_tag"]) + } +} + +func TestBuildFromConfigStep_Execute_WithPlugins(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + // Create fake plugin binaries. + pluginDir := t.TempDir() + adminBinary := filepath.Join(pluginDir, "admin") + if err := os.WriteFile(adminBinary, []byte("#!/bin/sh\n"), 0755); err != nil { //nolint:gosec + t.Fatalf("failed to create admin binary: %v", err) + } + bentoBinary := filepath.Join(pluginDir, "workflow-plugin-bento") + if err := os.WriteFile(bentoBinary, []byte("#!/bin/sh\n"), 0755); err != nil { //nolint:gosec + t.Fatalf("failed to create bento binary: %v", err) + } + + var buildArgs []string + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + baseImage: "gcr.io/distroless/static-debian12:nonroot", + tag: "my-app:latest", + push: false, + plugins: []PluginSpec{ + {Name: "admin", Binary: adminBinary}, + {Name: "bento", Binary: bentoBinary}, + }, + execCommand: func(ctx context.Context, name string, args ...string) *exec.Cmd { + if name == "docker" && len(args) > 0 && args[0] == "build" { + buildArgs = args + } + return exec.Command("true") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Verify the Dockerfile includes the plugins COPY line. + dockerfileContent, _ := result.Output["dockerfile_content"].(string) + if !strings.Contains(dockerfileContent, "COPY plugins/ /app/data/plugins/") { + t.Errorf("Dockerfile should contain plugins COPY line\nGot:\n%s", dockerfileContent) + } + + // Verify docker build was called with a context dir argument. + if len(buildArgs) < 3 { + t.Fatalf("expected docker build -t , got args: %v", buildArgs) + } +} + +func TestBuildFromConfigStep_Execute_BuildContextLayout(t *testing.T) { + configFile, serverBinary, _ := setupBuildFromConfigFiles(t) + + pluginDir := t.TempDir() + adminBinary := filepath.Join(pluginDir, "admin") + if err := os.WriteFile(adminBinary, []byte("#!/bin/sh\n"), 0755); err != nil { //nolint:gosec + t.Fatalf("failed to create plugin binary: %v", err) + } + + var capturedBuildDir string + s := &BuildFromConfigStep{ + name: "bfc", + configFile: configFile, + serverBinary: serverBinary, + baseImage: "alpine:latest", + tag: "my-app:latest", + plugins: []PluginSpec{ + {Name: "admin", Binary: adminBinary}, + }, + execCommand: func(ctx context.Context, name string, args ...string) *exec.Cmd { + // Capture the build context dir (last argument to docker build). + if name == "docker" && len(args) > 0 && args[0] == "build" { + capturedBuildDir = args[len(args)-1] + // Make a copy so we can inspect it after Execute returns + // (Execute defers RemoveAll on buildDir). + copyDir := t.TempDir() + _ = copyDirRecursive(capturedBuildDir, copyDir) + capturedBuildDir = copyDir + } + return exec.Command("true") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Check expected files in the copied build context. + expectedFiles := []string{ + "Dockerfile", + "config.yaml", + "server", + filepath.Join("plugins", "admin", "admin"), + } + for _, f := range expectedFiles { + if _, err := os.Stat(filepath.Join(capturedBuildDir, f)); err != nil { + t.Errorf("build context missing expected file %q: %v", f, err) + } + } +} + +// copyDirRecursive copies the contents of src into dst directory. +func copyDirRecursive(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + return func() error { + in, err := os.Open(path) //nolint:gosec + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer out.Close() + _, err = fmt.Fprintf(out, "") + if err != nil { + return err + } + _, err = out.Seek(0, 0) + if err != nil { + return err + } + f, err := os.Open(path) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + _, copyErr := io.Copy(out, f) + return copyErr + }() + }) +} diff --git a/plugins/cicd/plugin.go b/plugins/cicd/plugin.go index 91544b89..145c695d 100644 --- a/plugins/cicd/plugin.go +++ b/plugins/cicd/plugin.go @@ -1,6 +1,7 @@ // Package cicd provides a plugin that registers CI/CD pipeline step types: // shell_exec, artifact_pull, artifact_push, docker_build, docker_push, -// docker_run, scan_sast, scan_container, scan_deps, deploy, gate, build_ui. +// docker_run, scan_sast, scan_container, scan_deps, deploy, gate, build_ui, +// build_from_config. package cicd import ( @@ -22,13 +23,13 @@ func New() *Plugin { BaseNativePlugin: plugin.BaseNativePlugin{ PluginName: "cicd", PluginVersion: "1.0.0", - PluginDescription: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate)", + PluginDescription: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate, build from config)", }, Manifest: plugin.PluginManifest{ Name: "cicd", Version: "1.0.0", Author: "GoCodeAlone", - Description: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate)", + Description: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate, build from config)", Tier: plugin.TierCore, StepTypes: []string{ "step.shell_exec", @@ -43,6 +44,7 @@ func New() *Plugin { "step.deploy", "step.gate", "step.build_ui", + "step.build_from_config", }, Capabilities: []plugin.CapabilityDecl{ {Name: "cicd-pipeline", Role: "provider", Priority: 50}, @@ -57,7 +59,7 @@ func (p *Plugin) Capabilities() []capability.Contract { return []capability.Contract{ { Name: "cicd-pipeline", - Description: "CI/CD pipeline operations: shell exec, Docker, artifact management, security scanning, deploy, gate", + Description: "CI/CD pipeline operations: shell exec, Docker, artifact management, security scanning, deploy, gate, build from config", }, } } @@ -65,18 +67,19 @@ func (p *Plugin) Capabilities() []capability.Contract { // StepFactories returns the CI/CD step factories. func (p *Plugin) StepFactories() map[string]plugin.StepFactory { return map[string]plugin.StepFactory{ - "step.shell_exec": wrapStepFactory(module.NewShellExecStepFactory()), - "step.artifact_pull": wrapStepFactory(module.NewArtifactPullStepFactory()), - "step.artifact_push": wrapStepFactory(module.NewArtifactPushStepFactory()), - "step.docker_build": wrapStepFactory(module.NewDockerBuildStepFactory()), - "step.docker_push": wrapStepFactory(module.NewDockerPushStepFactory()), - "step.docker_run": wrapStepFactory(module.NewDockerRunStepFactory()), - "step.scan_sast": wrapStepFactory(module.NewScanSASTStepFactory()), - "step.scan_container": wrapStepFactory(module.NewScanContainerStepFactory()), - "step.scan_deps": wrapStepFactory(module.NewScanDepsStepFactory()), - "step.deploy": wrapStepFactory(module.NewDeployStepFactory()), - "step.gate": wrapStepFactory(module.NewGateStepFactory()), - "step.build_ui": wrapStepFactory(module.NewBuildUIStepFactory()), + "step.shell_exec": wrapStepFactory(module.NewShellExecStepFactory()), + "step.artifact_pull": wrapStepFactory(module.NewArtifactPullStepFactory()), + "step.artifact_push": wrapStepFactory(module.NewArtifactPushStepFactory()), + "step.docker_build": wrapStepFactory(module.NewDockerBuildStepFactory()), + "step.docker_push": wrapStepFactory(module.NewDockerPushStepFactory()), + "step.docker_run": wrapStepFactory(module.NewDockerRunStepFactory()), + "step.scan_sast": wrapStepFactory(module.NewScanSASTStepFactory()), + "step.scan_container": wrapStepFactory(module.NewScanContainerStepFactory()), + "step.scan_deps": wrapStepFactory(module.NewScanDepsStepFactory()), + "step.deploy": wrapStepFactory(module.NewDeployStepFactory()), + "step.gate": wrapStepFactory(module.NewGateStepFactory()), + "step.build_ui": wrapStepFactory(module.NewBuildUIStepFactory()), + "step.build_from_config": wrapStepFactory(module.NewBuildFromConfigStepFactory()), } } diff --git a/plugins/cicd/plugin_test.go b/plugins/cicd/plugin_test.go index cacea13c..0f598a05 100644 --- a/plugins/cicd/plugin_test.go +++ b/plugins/cicd/plugin_test.go @@ -43,6 +43,7 @@ func TestStepFactories(t *testing.T) { "step.deploy", "step.gate", "step.build_ui", + "step.build_from_config", } for _, stepType := range expectedSteps { @@ -54,6 +55,7 @@ func TestStepFactories(t *testing.T) { if len(factories) != len(expectedSteps) { t.Errorf("expected %d step factories, got %d", len(expectedSteps), len(factories)) } + } func TestPluginLoads(t *testing.T) { @@ -64,7 +66,7 @@ func TestPluginLoads(t *testing.T) { } steps := loader.StepFactories() - if len(steps) != 12 { - t.Fatalf("expected 12 step factories after load, got %d", len(steps)) + if len(steps) != 13 { + t.Fatalf("expected 13 step factories after load, got %d", len(steps)) } } From 090c9cf9ddb50fb59c32f9e8de17c508cd42cbfd Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 25 Feb 2026 01:07:27 -0500 Subject: [PATCH 2/2] feat: add step.statemachine_transition and step.statemachine_get pipeline steps Implements two new pipeline steps for interacting with state machine workflow instances directly from pipeline execution: - step.statemachine_transition: triggers a named transition on a workflow instance, supports data templates, fail_on_error flag, and the TransitionTrigger interface for testability via mocks. - step.statemachine_get: reads the current state of a workflow instance. Both steps resolve entity_id and data fields from Go templates using the existing TemplateEngine, look up the named StateMachineEngine by service name from the app registry, and return structured output (transition_ok, new_state, current_state, entity_id). Steps are registered in plugins/statemachine with StepFactories() and declared in the manifest's StepTypes list. Co-Authored-By: Claude Opus 4.6 --- module/pipeline_step_statemachine_get.go | 88 ++++ module/pipeline_step_statemachine_get_test.go | 194 ++++++++ .../pipeline_step_statemachine_transition.go | 188 ++++++++ ...eline_step_statemachine_transition_test.go | 419 ++++++++++++++++++ plugins/statemachine/plugin.go | 20 + plugins/statemachine/plugin_test.go | 18 + 6 files changed, 927 insertions(+) create mode 100644 module/pipeline_step_statemachine_get.go create mode 100644 module/pipeline_step_statemachine_get_test.go create mode 100644 module/pipeline_step_statemachine_transition.go create mode 100644 module/pipeline_step_statemachine_transition_test.go diff --git a/module/pipeline_step_statemachine_get.go b/module/pipeline_step_statemachine_get.go new file mode 100644 index 00000000..293f1bd4 --- /dev/null +++ b/module/pipeline_step_statemachine_get.go @@ -0,0 +1,88 @@ +package module + +import ( + "context" + "fmt" + + "github.com/CrisisTextLine/modular" +) + +// StateMachineGetStep reads the current state of a workflow instance. +type StateMachineGetStep struct { + name string + statemachine string + entityID string + app modular.Application + tmpl *TemplateEngine +} + +// NewStateMachineGetStepFactory returns a StepFactory for step.statemachine_get. +// +// Config: +// +// type: step.statemachine_get +// config: +// statemachine: "order-sm" # service name of the StateMachineEngine +// entity_id: "{{.order_id}}" # which instance to look up (template) +// +// Outputs: current_state (string), entity_id (string). +// Returns an error (stopping the pipeline) when the instance is not found. +func NewStateMachineGetStepFactory() StepFactory { + return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) { + sm, _ := config["statemachine"].(string) + if sm == "" { + return nil, fmt.Errorf("statemachine_get step %q: 'statemachine' is required", name) + } + + entityID, _ := config["entity_id"].(string) + if entityID == "" { + return nil, fmt.Errorf("statemachine_get step %q: 'entity_id' is required", name) + } + + return &StateMachineGetStep{ + name: name, + statemachine: sm, + entityID: entityID, + app: app, + tmpl: NewTemplateEngine(), + }, nil + } +} + +// Name returns the step name. +func (s *StateMachineGetStep) Name() string { return s.name } + +// Execute resolves the entity_id template, looks up the StateMachineEngine, and +// returns the current state of the workflow instance. +func (s *StateMachineGetStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + if s.app == nil { + return nil, fmt.Errorf("statemachine_get step %q: no application context", s.name) + } + + svc, ok := s.app.SvcRegistry()[s.statemachine] + if !ok { + return nil, fmt.Errorf("statemachine_get step %q: statemachine service %q not found", s.name, s.statemachine) + } + + engine, ok := svc.(*StateMachineEngine) + if !ok { + return nil, fmt.Errorf("statemachine_get step %q: service %q is not a StateMachineEngine", s.name, s.statemachine) + } + + entityID, err := s.tmpl.Resolve(s.entityID, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_get step %q: failed to resolve entity_id: %w", s.name, err) + } + + instance, err := engine.GetInstance(entityID) + if err != nil { + return nil, fmt.Errorf("statemachine_get step %q: instance not found: %w", s.name, err) + } + + return &StepResult{ + Output: map[string]any{ + "current_state": instance.CurrentState, + "entity_id": entityID, + }, + }, nil +} diff --git a/module/pipeline_step_statemachine_get_test.go b/module/pipeline_step_statemachine_get_test.go new file mode 100644 index 00000000..320dfb24 --- /dev/null +++ b/module/pipeline_step_statemachine_get_test.go @@ -0,0 +1,194 @@ +package module + +import ( + "context" + "testing" +) + +// --- Factory validation tests --- + +func TestStateMachineGetStep_MissingStatemachine(t *testing.T) { + factory := NewStateMachineGetStepFactory() + _, err := factory("get-state", map[string]any{ + "entity_id": "order-1", + }, nil) + if err == nil { + t.Fatal("expected error for missing statemachine") + } +} + +func TestStateMachineGetStep_MissingEntityID(t *testing.T) { + factory := NewStateMachineGetStepFactory() + _, err := factory("get-state", map[string]any{ + "statemachine": "order-sm", + }, nil) + if err == nil { + t.Fatal("expected error for missing entity_id") + } +} + +// --- Execution tests --- + +func TestStateMachineGetStep_ReturnsCurrentState(t *testing.T) { + engine := setupOrderStateMachine(t, "order-1", "") + app := newAppWithSM(engine) + + factory := NewStateMachineGetStepFactory() + step, err := factory("get-order-state", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + state, _ := result.Output["current_state"].(string) + if state != "pending" { + t.Errorf("expected current_state='pending', got %q", state) + } + entityID, _ := result.Output["entity_id"].(string) + if entityID != "order-1" { + t.Errorf("expected entity_id='order-1', got %q", entityID) + } +} + +func TestStateMachineGetStep_TemplatedEntityID(t *testing.T) { + engine := setupOrderStateMachine(t, "order-99", "") + app := newAppWithSM(engine) + + factory := NewStateMachineGetStepFactory() + step, err := factory("get-state-template", map[string]any{ + "statemachine": "order-sm", + "entity_id": "{{.order_id}}", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(map[string]any{"order_id": "order-99"}, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + state, _ := result.Output["current_state"].(string) + if state != "pending" { + t.Errorf("expected current_state='pending', got %q", state) + } + entityID, _ := result.Output["entity_id"].(string) + if entityID != "order-99" { + t.Errorf("expected entity_id='order-99', got %q", entityID) + } +} + +func TestStateMachineGetStep_ReturnsStateAfterTransition(t *testing.T) { + engine := setupOrderStateMachine(t, "order-1", "") + app := newAppWithSM(engine) + + // Trigger a transition first + if err := engine.TriggerTransition(context.Background(), "order-1", "approve", nil); err != nil { + t.Fatalf("trigger transition: %v", err) + } + + factory := NewStateMachineGetStepFactory() + step, err := factory("get-approved-state", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + state, _ := result.Output["current_state"].(string) + if state != "approved" { + t.Errorf("expected current_state='approved', got %q", state) + } +} + +func TestStateMachineGetStep_InstanceNotFound(t *testing.T) { + engine := setupOrderStateMachine(t, "", "") // no instances created + app := newAppWithSM(engine) + + factory := NewStateMachineGetStepFactory() + step, err := factory("get-missing", map[string]any{ + "statemachine": "order-sm", + "entity_id": "nonexistent", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for nonexistent instance") + } +} + +func TestStateMachineGetStep_ServiceNotFound(t *testing.T) { + app := NewMockApplication() + + factory := NewStateMachineGetStepFactory() + step, err := factory("get-state", map[string]any{ + "statemachine": "nonexistent-sm", + "entity_id": "order-1", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for missing service") + } +} + +func TestStateMachineGetStep_ServiceWrongType(t *testing.T) { + app := NewMockApplication() + app.Services["order-sm"] = "not-an-engine" + + factory := NewStateMachineGetStepFactory() + step, err := factory("get-state", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for wrong service type") + } +} + +func TestStateMachineGetStep_NoAppContext(t *testing.T) { + factory := NewStateMachineGetStepFactory() + step, err := factory("get-state", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for nil app") + } +} diff --git a/module/pipeline_step_statemachine_transition.go b/module/pipeline_step_statemachine_transition.go new file mode 100644 index 00000000..c193d05b --- /dev/null +++ b/module/pipeline_step_statemachine_transition.go @@ -0,0 +1,188 @@ +package module + +import ( + "context" + "fmt" + + "github.com/CrisisTextLine/modular" +) + +// StateMachineTransitionStep triggers a state machine transition from within a pipeline. +type StateMachineTransitionStep struct { + name string + statemachine string + entityID string + event string + data map[string]any + failOnError bool + app modular.Application + tmpl *TemplateEngine +} + +// NewStateMachineTransitionStepFactory returns a StepFactory for step.statemachine_transition. +// +// Config: +// +// type: step.statemachine_transition +// config: +// statemachine: "order-sm" # service name of the StateMachineEngine +// entity_id: "{{.order_id}}" # which instance to transition (template) +// event: "approve" # transition name +// data: # optional data map (values may use templates) +// approved_by: "{{.user_id}}" +// fail_on_error: false # stop pipeline on invalid transition (default: false) +// +// Outputs: transition_ok (bool), new_state (string), error (string, only on failure). +func NewStateMachineTransitionStepFactory() StepFactory { + return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) { + sm, _ := config["statemachine"].(string) + if sm == "" { + return nil, fmt.Errorf("statemachine_transition step %q: 'statemachine' is required", name) + } + + entityID, _ := config["entity_id"].(string) + if entityID == "" { + return nil, fmt.Errorf("statemachine_transition step %q: 'entity_id' is required", name) + } + + event, _ := config["event"].(string) + if event == "" { + return nil, fmt.Errorf("statemachine_transition step %q: 'event' is required", name) + } + + var data map[string]any + if d, ok := config["data"].(map[string]any); ok { + data = d + } + + failOnError, _ := config["fail_on_error"].(bool) + + return &StateMachineTransitionStep{ + name: name, + statemachine: sm, + entityID: entityID, + event: event, + data: data, + failOnError: failOnError, + app: app, + tmpl: NewTemplateEngine(), + }, nil + } +} + +// Name returns the step name. +func (s *StateMachineTransitionStep) Name() string { return s.name } + +// Execute resolves templates, looks up the StateMachineEngine by service name, and +// triggers the requested transition. On success it sets transition_ok=true and +// new_state to the resulting state. On failure it sets transition_ok=false and +// error to the error message; if fail_on_error is true the pipeline is stopped. +func (s *StateMachineTransitionStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + if s.app == nil { + return nil, fmt.Errorf("statemachine_transition step %q: no application context", s.name) + } + + // Resolve statemachine engine from service registry + svc, ok := s.app.SvcRegistry()[s.statemachine] + if !ok { + return nil, fmt.Errorf("statemachine_transition step %q: statemachine service %q not found", s.name, s.statemachine) + } + + engine, ok := svc.(*StateMachineEngine) + if !ok { + // Also accept the TransitionTrigger interface for testability / mocking + trigger, ok := svc.(TransitionTrigger) + if !ok { + return nil, fmt.Errorf("statemachine_transition step %q: service %q does not implement StateMachineEngine or TransitionTrigger", s.name, s.statemachine) + } + return s.executeViaTrigger(ctx, pc, trigger) + } + + return s.executeViaEngine(ctx, pc, engine) +} + +func (s *StateMachineTransitionStep) executeViaEngine(ctx context.Context, pc *PipelineContext, engine *StateMachineEngine) (*StepResult, error) { + entityID, err := s.tmpl.Resolve(s.entityID, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_transition step %q: failed to resolve entity_id: %w", s.name, err) + } + + event, err := s.tmpl.Resolve(s.event, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_transition step %q: failed to resolve event: %w", s.name, err) + } + + data, err := s.tmpl.ResolveMap(s.data, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_transition step %q: failed to resolve data: %w", s.name, err) + } + + transErr := engine.TriggerTransition(ctx, entityID, event, data) + if transErr != nil { + if s.failOnError { + return nil, fmt.Errorf("statemachine_transition step %q: transition failed: %w", s.name, transErr) + } + return &StepResult{ + Output: map[string]any{ + "transition_ok": false, + "error": transErr.Error(), + }, + }, nil + } + + // Fetch the new state from the engine + instance, err := engine.GetInstance(entityID) + if err != nil { + // Transition succeeded but we can't read new state — treat as success with unknown state + return &StepResult{ + Output: map[string]any{ + "transition_ok": true, + "new_state": "", + }, + }, nil + } + + return &StepResult{ + Output: map[string]any{ + "transition_ok": true, + "new_state": instance.CurrentState, + }, + }, nil +} + +func (s *StateMachineTransitionStep) executeViaTrigger(ctx context.Context, pc *PipelineContext, trigger TransitionTrigger) (*StepResult, error) { + entityID, err := s.tmpl.Resolve(s.entityID, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_transition step %q: failed to resolve entity_id: %w", s.name, err) + } + + event, err := s.tmpl.Resolve(s.event, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_transition step %q: failed to resolve event: %w", s.name, err) + } + + data, err := s.tmpl.ResolveMap(s.data, pc) + if err != nil { + return nil, fmt.Errorf("statemachine_transition step %q: failed to resolve data: %w", s.name, err) + } + + transErr := trigger.TriggerTransition(ctx, entityID, event, data) + if transErr != nil { + if s.failOnError { + return nil, fmt.Errorf("statemachine_transition step %q: transition failed: %w", s.name, transErr) + } + return &StepResult{ + Output: map[string]any{ + "transition_ok": false, + "error": transErr.Error(), + }, + }, nil + } + + return &StepResult{ + Output: map[string]any{ + "transition_ok": true, + "new_state": "", + }, + }, nil +} diff --git a/module/pipeline_step_statemachine_transition_test.go b/module/pipeline_step_statemachine_transition_test.go new file mode 100644 index 00000000..3cb27865 --- /dev/null +++ b/module/pipeline_step_statemachine_transition_test.go @@ -0,0 +1,419 @@ +package module + +import ( + "context" + "errors" + "testing" +) + +// mockTransitionTrigger implements TransitionTrigger for testing without a real engine. +type mockTransitionTrigger struct { + triggerErr error + capturedID string + capturedEvt string + capturedData map[string]any +} + +func (m *mockTransitionTrigger) TriggerTransition(_ context.Context, workflowID, transitionName string, data map[string]any) error { + m.capturedID = workflowID + m.capturedEvt = transitionName + m.capturedData = data + return m.triggerErr +} + +// setupOrderStateMachine creates a StateMachineEngine with a simple order workflow. +func setupOrderStateMachine(t *testing.T, instanceID, initialState string) *StateMachineEngine { + t.Helper() + + engine := NewStateMachineEngine("order-sm") + def := &StateMachineDefinition{ + Name: "order", + InitialState: "pending", + States: map[string]*State{ + "pending": {Name: "pending"}, + "approved": {Name: "approved"}, + "rejected": {Name: "rejected", IsFinal: true}, + }, + Transitions: map[string]*Transition{ + "approve": {Name: "approve", FromState: "pending", ToState: "approved"}, + "reject": {Name: "reject", FromState: "pending", ToState: "rejected"}, + }, + } + if err := engine.RegisterDefinition(def); err != nil { + t.Fatalf("register definition: %v", err) + } + + if instanceID != "" { + instance, err := engine.CreateWorkflow("order", instanceID, nil) + if err != nil { + t.Fatalf("create workflow: %v", err) + } + // If caller wants a non-initial state, force it directly for test setup + if initialState != "" && initialState != def.InitialState { + instance.CurrentState = initialState + } + } + + return engine +} + +// newAppWithSM registers the engine under "order-sm" in a MockApplication. +func newAppWithSM(engine *StateMachineEngine) *MockApplication { + app := NewMockApplication() + app.Services["order-sm"] = engine + return app +} + +// --- Factory validation tests --- + +func TestStateMachineTransitionStep_MissingStatemachine(t *testing.T) { + factory := NewStateMachineTransitionStepFactory() + _, err := factory("step1", map[string]any{ + "entity_id": "order-1", + "event": "approve", + }, nil) + if err == nil { + t.Fatal("expected error for missing statemachine") + } +} + +func TestStateMachineTransitionStep_MissingEntityID(t *testing.T) { + factory := NewStateMachineTransitionStepFactory() + _, err := factory("step1", map[string]any{ + "statemachine": "order-sm", + "event": "approve", + }, nil) + if err == nil { + t.Fatal("expected error for missing entity_id") + } +} + +func TestStateMachineTransitionStep_MissingEvent(t *testing.T) { + factory := NewStateMachineTransitionStepFactory() + _, err := factory("step1", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + }, nil) + if err == nil { + t.Fatal("expected error for missing event") + } +} + +// --- Execution tests: using real StateMachineEngine --- + +func TestStateMachineTransitionStep_SuccessfulTransition(t *testing.T) { + engine := setupOrderStateMachine(t, "order-1", "") + app := newAppWithSM(engine) + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("approve-order", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + ok, _ := result.Output["transition_ok"].(bool) + if !ok { + t.Error("expected transition_ok=true") + } + newState, _ := result.Output["new_state"].(string) + if newState != "approved" { + t.Errorf("expected new_state='approved', got %q", newState) + } +} + +func TestStateMachineTransitionStep_TemplatedEntityIDAndEvent(t *testing.T) { + engine := setupOrderStateMachine(t, "order-42", "") + app := newAppWithSM(engine) + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("dynamic-approve", map[string]any{ + "statemachine": "order-sm", + "entity_id": "{{.order_id}}", + "event": "{{.action}}", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(map[string]any{ + "order_id": "order-42", + "action": "approve", + }, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + ok, _ := result.Output["transition_ok"].(bool) + if !ok { + t.Error("expected transition_ok=true") + } +} + +func TestStateMachineTransitionStep_InvalidTransition_FailOnErrorFalse(t *testing.T) { + engine := setupOrderStateMachine(t, "order-1", "") + app := newAppWithSM(engine) + + factory := NewStateMachineTransitionStepFactory() + // "reject" is valid, but "approve" from "approved" is not — trigger approve twice + step, err := factory("double-approve", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + "fail_on_error": false, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + // First transition succeeds + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("first execute error: %v", err) + } + + // Second transition: "approve" from "approved" is invalid + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("expected no pipeline error with fail_on_error=false, got: %v", err) + } + + ok, _ := result.Output["transition_ok"].(bool) + if ok { + t.Error("expected transition_ok=false for invalid transition") + } + errMsg, _ := result.Output["error"].(string) + if errMsg == "" { + t.Error("expected error message in output") + } +} + +func TestStateMachineTransitionStep_InvalidTransition_FailOnErrorTrue(t *testing.T) { + engine := setupOrderStateMachine(t, "order-1", "") + app := newAppWithSM(engine) + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("strict-approve", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + "fail_on_error": true, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + // First transition succeeds + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("first execute error: %v", err) + } + + // Second transition should fail with pipeline error + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected pipeline error with fail_on_error=true") + } +} + +func TestStateMachineTransitionStep_WithData(t *testing.T) { + engine := setupOrderStateMachine(t, "order-1", "") + app := newAppWithSM(engine) + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("approve-with-data", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + "data": map[string]any{ + "approved_by": "{{.user_id}}", + }, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(map[string]any{"user_id": "u-99"}, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + ok, _ := result.Output["transition_ok"].(bool) + if !ok { + t.Error("expected transition_ok=true") + } + + // Verify data was merged into instance + instance, err := engine.GetInstance("order-1") + if err != nil { + t.Fatalf("get instance: %v", err) + } + approvedBy, _ := instance.Data["approved_by"].(string) + if approvedBy != "u-99" { + t.Errorf("expected approved_by='u-99', got %q", approvedBy) + } +} + +func TestStateMachineTransitionStep_ServiceNotFound(t *testing.T) { + app := NewMockApplication() + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("step1", map[string]any{ + "statemachine": "nonexistent-sm", + "entity_id": "order-1", + "event": "approve", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for missing service") + } +} + +func TestStateMachineTransitionStep_ServiceWrongType(t *testing.T) { + app := NewMockApplication() + // Register something that is neither *StateMachineEngine nor TransitionTrigger + app.Services["order-sm"] = "not-an-engine" + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("step1", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for wrong service type") + } +} + +func TestStateMachineTransitionStep_NoAppContext(t *testing.T) { + factory := NewStateMachineTransitionStepFactory() + step, err := factory("step1", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for nil app") + } +} + +// --- Execution tests: using mock TransitionTrigger --- + +func TestStateMachineTransitionStep_MockTrigger_Success(t *testing.T) { + mock := &mockTransitionTrigger{} + app := NewMockApplication() + app.Services["order-sm"] = mock + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("mock-approve", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if mock.capturedID != "order-1" { + t.Errorf("expected capturedID='order-1', got %q", mock.capturedID) + } + if mock.capturedEvt != "approve" { + t.Errorf("expected capturedEvt='approve', got %q", mock.capturedEvt) + } + + ok, _ := result.Output["transition_ok"].(bool) + if !ok { + t.Error("expected transition_ok=true") + } +} + +func TestStateMachineTransitionStep_MockTrigger_Error_NoFail(t *testing.T) { + mock := &mockTransitionTrigger{triggerErr: errors.New("invalid transition")} + app := NewMockApplication() + app.Services["order-sm"] = mock + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("mock-fail", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + "fail_on_error": false, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("expected no pipeline error, got: %v", err) + } + + ok, _ := result.Output["transition_ok"].(bool) + if ok { + t.Error("expected transition_ok=false") + } + errMsg, _ := result.Output["error"].(string) + if errMsg == "" { + t.Error("expected error in output") + } +} + +func TestStateMachineTransitionStep_MockTrigger_Error_Fail(t *testing.T) { + mock := &mockTransitionTrigger{triggerErr: errors.New("invalid transition")} + app := NewMockApplication() + app.Services["order-sm"] = mock + + factory := NewStateMachineTransitionStepFactory() + step, err := factory("mock-strict-fail", map[string]any{ + "statemachine": "order-sm", + "entity_id": "order-1", + "event": "approve", + "fail_on_error": true, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected pipeline error with fail_on_error=true") + } +} diff --git a/plugins/statemachine/plugin.go b/plugins/statemachine/plugin.go index d9ebf4d0..2bc303ff 100644 --- a/plugins/statemachine/plugin.go +++ b/plugins/statemachine/plugin.go @@ -37,6 +37,10 @@ func New() *Plugin { "state.tracker", "state.connector", }, + StepTypes: []string{ + "step.statemachine_transition", + "step.statemachine_get", + }, WorkflowTypes: []string{"statemachine"}, Capabilities: []plugin.CapabilityDecl{ {Name: "state-machine", Role: "provider", Priority: 10}, @@ -98,6 +102,22 @@ func (p *Plugin) WorkflowHandlers() map[string]plugin.WorkflowHandlerFactory { } } +// StepFactories returns the pipeline step factories for state machine operations. +func (p *Plugin) StepFactories() map[string]plugin.StepFactory { + return map[string]plugin.StepFactory{ + "step.statemachine_transition": wrapStepFactory(module.NewStateMachineTransitionStepFactory()), + "step.statemachine_get": wrapStepFactory(module.NewStateMachineGetStepFactory()), + } +} + +// wrapStepFactory converts a module.StepFactory to a plugin.StepFactory, +// threading the modular.Application through so steps can access the service registry. +func wrapStepFactory(f module.StepFactory) plugin.StepFactory { + return func(name string, cfg map[string]any, app modular.Application) (any, error) { + return f(name, cfg, app) + } +} + // ModuleSchemas returns UI schema definitions for state machine module types. func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { return []*schema.ModuleSchema{ diff --git a/plugins/statemachine/plugin_test.go b/plugins/statemachine/plugin_test.go index ec34d514..26d19671 100644 --- a/plugins/statemachine/plugin_test.go +++ b/plugins/statemachine/plugin_test.go @@ -24,6 +24,9 @@ func TestPluginManifest(t *testing.T) { if len(m.ModuleTypes) != 3 { t.Errorf("expected 3 module types, got %d", len(m.ModuleTypes)) } + if len(m.StepTypes) != 2 { + t.Errorf("expected 2 step types, got %d", len(m.StepTypes)) + } if len(m.WorkflowTypes) != 1 { t.Errorf("expected 1 workflow type, got %d", len(m.WorkflowTypes)) } @@ -100,6 +103,21 @@ func TestWorkflowHandlers(t *testing.T) { } } +func TestStepFactories(t *testing.T) { + p := New() + factories := p.StepFactories() + + expectedTypes := []string{"step.statemachine_transition", "step.statemachine_get"} + for _, typ := range expectedTypes { + if _, ok := factories[typ]; !ok { + t.Errorf("missing step factory for %q", typ) + } + } + if len(factories) != len(expectedTypes) { + t.Errorf("expected %d step factories, got %d", len(expectedTypes), len(factories)) + } +} + func TestModuleSchemas(t *testing.T) { p := New() schemas := p.ModuleSchemas()