From 623d0149fa63206551c72b81457aee7c1d3a0e97 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Mon, 25 May 2026 16:21:35 -0700 Subject: [PATCH 1/2] feat(run): resolve container image from service config Add --image flag to `ephemerd run` for explicit image override. When not set, load the service's config.toml and use the provider's default image for the detected platform (e.g. github.default_image_windows). Falls back to the built-in default (actions-runner:latest) when no config is present. --- cmd/ephemerd/run.go | 30 ++++++++++++++++++++++++++++-- pkg/workflow/runner.go | 16 +++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/cmd/ephemerd/run.go b/cmd/ephemerd/run.go index e81e9e2..6ae2853 100644 --- a/cmd/ephemerd/run.go +++ b/cmd/ephemerd/run.go @@ -10,6 +10,7 @@ import ( "runtime" "syscall" + "github.com/ephpm/ephemerd/pkg/config" "github.com/ephpm/ephemerd/pkg/vm" "github.com/ephpm/ephemerd/pkg/workflow" "github.com/urfave/cli/v3" @@ -26,14 +27,18 @@ func runCmd() *cli.Command { Aliases: []string{"j"}, Usage: "run a specific job by name", }, + &cli.StringFlag{ + Name: "image", + Usage: "container image to use (default: from service config or built-in)", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - return runWorkflow(ctx, cmd.Args().First(), cmd.String("job")) + return runWorkflow(ctx, cmd.Args().First(), cmd.String("job"), cmd.String("image")) }, } } -func runWorkflow(ctx context.Context, workflowPath string, jobFilter string) error { +func runWorkflow(ctx context.Context, workflowPath string, jobFilter string, imageFlag string) error { ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer cancel() @@ -152,11 +157,32 @@ func runWorkflow(ctx context.Context, workflowPath string, jobFilter string) err socketPath = `\\.\pipe\ephemerd-run-` + filepath.Base(tmpDir) } + image := resolveRunImage(imageFlag, platform) + runner := &workflow.Runner{ DataDir: tmpDir, SocketPath: socketPath, + Image: image, Log: log, } return runner.RunJob(ctx, jobName, job, repoDir) } + +// resolveRunImage determines the container image for a run job. +// Priority: --image flag → service config.toml → built-in default. +func resolveRunImage(flagValue string, platform workflow.TargetPlatform) string { + if flagValue != "" { + return flagValue + } + + osName := platform.String() + cfgPath := filepath.Join(configDir, "config.toml") + if cfg, err := config.Load(cfgPath); err == nil { + if img := cfg.GitHub.DefaultImageFor(osName); img != "" { + return img + } + } + + return "" +} diff --git a/pkg/workflow/runner.go b/pkg/workflow/runner.go index 0fd8526..23a36b0 100644 --- a/pkg/workflow/runner.go +++ b/pkg/workflow/runner.go @@ -28,6 +28,7 @@ const ( type Runner struct { DataDir string SocketPath string // optional: containerd socket override for isolation from the service + Image string // container image; empty falls back to defaultImage Log *slog.Logger } @@ -84,14 +85,19 @@ func (r *Runner) RunJob(ctx context.Context, jobName string, job Job, repoDir st // Pull the runner image nsCtx := namespaces.WithNamespace(ctx, namespace) - r.Log.Info("pulling image", "ref", defaultImage) - if _, err := ctrdClient.GetImage(nsCtx, defaultImage); err != nil { - if _, err := ctrdClient.Pull(nsCtx, defaultImage, client.WithPullUnpack); err != nil { - return fmt.Errorf("pulling image %s: %w", defaultImage, err) + imageRef := r.Image + if imageRef == "" { + imageRef = defaultImage + } + + r.Log.Info("pulling image", "ref", imageRef) + if _, err := ctrdClient.GetImage(nsCtx, imageRef); err != nil { + if _, err := ctrdClient.Pull(nsCtx, imageRef, client.WithPullUnpack); err != nil { + return fmt.Errorf("pulling image %s: %w", imageRef, err) } } - img, err := ctrdClient.GetImage(nsCtx, defaultImage) + img, err := ctrdClient.GetImage(nsCtx, imageRef) if err != nil { return fmt.Errorf("getting image: %w", err) } From 4b0893f59260c6797644018af5b38d9f0d51c78d Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Mon, 25 May 2026 16:44:25 -0700 Subject: [PATCH 2/2] test(run): cover resolveRunImage priority; docs(cli/run): document --image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 6 table-style tests for resolveRunImage covering the priority ladder (flag > service config > empty fallback), the parse-error path, and the no-config and missing-override cases. configDirGuard saves and restores the package-level configDir global between cases so each test can point at its own tempdir. docs/cli/run.md gains a Flags table row for --image, a new step in the Behavior list ("Resolve image"), and an example invocation. Also tightens the resolveRunImage doc comment: it previously claimed to return the built-in default itself, when the default is actually applied downstream in workflow.Runner.RunJob — cosmetic but the prior wording would trip a reader expecting the function to return "actions-runner:latest" on the empty path. --- cmd/ephemerd/run.go | 4 +- cmd/ephemerd/run_test.go | 129 +++++++++++++++++++++++++++++++++++++++ docs/cli/run.md | 7 ++- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 cmd/ephemerd/run_test.go diff --git a/cmd/ephemerd/run.go b/cmd/ephemerd/run.go index 6ae2853..c5f60dd 100644 --- a/cmd/ephemerd/run.go +++ b/cmd/ephemerd/run.go @@ -170,7 +170,9 @@ func runWorkflow(ctx context.Context, workflowPath string, jobFilter string, ima } // resolveRunImage determines the container image for a run job. -// Priority: --image flag → service config.toml → built-in default. +// Priority: --image flag → service config.toml → empty (caller applies the +// built-in default — see workflow.Runner.RunJob, which substitutes +// defaultImage when this returns ""). func resolveRunImage(flagValue string, platform workflow.TargetPlatform) string { if flagValue != "" { return flagValue diff --git a/cmd/ephemerd/run_test.go b/cmd/ephemerd/run_test.go new file mode 100644 index 0000000..6bb5bc1 --- /dev/null +++ b/cmd/ephemerd/run_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ephpm/ephemerd/pkg/workflow" +) + +// configDirGuard saves and restores the package-level configDir global so +// each test case can point at its own tempdir without leaking state. +func configDirGuard(t *testing.T) func() { + t.Helper() + prev := configDir + return func() { configDir = prev } +} + +// writeConfig drops a minimal valid config.toml into dir and returns the +// path. Tests pass it via the configDir global, the same way the live +// CLI command does. +func writeConfig(t *testing.T, dir, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o644); err != nil { + t.Fatalf("writing config.toml: %v", err) + } +} + +func TestResolveRunImage_FlagWins(t *testing.T) { + defer configDirGuard(t)() + dir := t.TempDir() + writeConfig(t, dir, ` +[github] +owner = "testorg" +default_image_linux = "ghcr.io/from-config:linux" +default_image_windows = "ghcr.io/from-config:windows" +`) + configDir = dir + t.Setenv("GITHUB_TOKEN", "ghp_test") + + if got := resolveRunImage("ghcr.io/explicit:v1", workflow.PlatformLinux); got != "ghcr.io/explicit:v1" { + t.Errorf("flag-wins: got %q, want explicit override", got) + } +} + +func TestResolveRunImage_ConfigWins_Linux(t *testing.T) { + defer configDirGuard(t)() + dir := t.TempDir() + writeConfig(t, dir, ` +[github] +owner = "testorg" +default_image_linux = "ghcr.io/from-config:linux" +default_image_windows = "ghcr.io/from-config:windows" +`) + configDir = dir + t.Setenv("GITHUB_TOKEN", "ghp_test") + + got := resolveRunImage("", workflow.PlatformLinux) + if got != "ghcr.io/from-config:linux" { + t.Errorf("config-wins linux: got %q, want %q", got, "ghcr.io/from-config:linux") + } +} + +func TestResolveRunImage_ConfigWins_Windows(t *testing.T) { + defer configDirGuard(t)() + dir := t.TempDir() + writeConfig(t, dir, ` +[github] +owner = "testorg" +default_image_linux = "ghcr.io/from-config:linux" +default_image_windows = "ghcr.io/from-config:windows" +`) + configDir = dir + t.Setenv("GITHUB_TOKEN", "ghp_test") + + got := resolveRunImage("", workflow.PlatformWindows) + if got != "ghcr.io/from-config:windows" { + t.Errorf("config-wins windows: got %q, want %q", got, "ghcr.io/from-config:windows") + } +} + +func TestResolveRunImage_NoConfigFile(t *testing.T) { + // Empty data dir → config.Load returns ENOENT → resolver returns "" so + // the downstream RunJob can apply the built-in default. + defer configDirGuard(t)() + configDir = t.TempDir() + + if got := resolveRunImage("", workflow.PlatformLinux); got != "" { + t.Errorf("no-config: got %q, want empty (caller defaults)", got) + } +} + +func TestResolveRunImage_ConfigParseError(t *testing.T) { + // Malformed TOML should not panic and must fall through to "" so the + // caller can default. Today this happens via the swallowed error in + // the config.Load call site — guard against a refactor that surfaces + // the error and crashes the resolver. + defer configDirGuard(t)() + dir := t.TempDir() + writeConfig(t, dir, "this is not valid TOML [\n") + configDir = dir + + if got := resolveRunImage("", workflow.PlatformLinux); got != "" { + t.Errorf("config-parse-error: got %q, want empty fallback", got) + } +} + +func TestResolveRunImage_ConfigWithoutImageOverride(t *testing.T) { + // A config.toml that exists but doesn't set a default image for this + // platform must fall through to "" so the built-in default applies. + defer configDirGuard(t)() + dir := t.TempDir() + writeConfig(t, dir, ` +[github] +owner = "testorg" +`) + configDir = dir + t.Setenv("GITHUB_TOKEN", "ghp_test") + + // Windows has no built-in default image (the runtime picks one from + // the host build number), so DefaultImageFor("windows") returns "" — + // resolver must propagate the empty string. + if got := resolveRunImage("", workflow.PlatformWindows); got != "" { + t.Errorf("no-windows-override: got %q, want empty (caller defaults)", got) + } +} diff --git a/docs/cli/run.md b/docs/cli/run.md index 20ae81e..58d91be 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -18,6 +18,7 @@ The workflow file is a **positional argument**, not a flag. If omitted, ephemerd | Flag | Description | |------|-------------| | `--job`, `-j` | Run a specific job by name. If omitted, runs the first job in the workflow. | +| `--image` | Container image to use for the job. Overrides any per-OS default from the service config; when omitted, falls back to `[github] default_image_linux` / `default_image_windows` in `/config.toml`, then to the built-in `ghcr.io/actions/actions-runner:latest`. | ## Behavior @@ -25,7 +26,8 @@ The workflow file is a **positional argument**, not a flag. If omitted, ephemerd 2. **Parse workflow** -- reads the YAML workflow file and extracts job definitions. 3. **Select job** -- uses `--job` to pick a specific job, or defaults to the first job found. 4. **Detect platform** -- inspects the job's `runs-on` labels to determine the target OS (linux, windows, or macos). -5. **Execute** -- runs the job in a local container using the container runtime. +5. **Resolve image** -- picks the container image in priority order: `--image` flag > `[github].default_image_` in the service config > built-in `actions-runner:latest`. +6. **Execute** -- runs the job in a local container using the container runtime. ## WSL delegation on Windows @@ -59,4 +61,7 @@ ephemerd run .github/workflows/ci.yml --job build # Short flag form ephemerd run .github/workflows/ci.yml -j test + +# Override the container image (e.g. test a custom CI base) +ephemerd run .github/workflows/ci.yml --image ghcr.io/your-org/ci-base:v2 ```