From b67de369511fb672c5e2636266e59e4504daba84 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 25 Feb 2026 00:38:50 -0500 Subject: [PATCH] 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)) } }