Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions cmd/ephemerd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()

Expand Down Expand Up @@ -152,11 +157,34 @@ 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 → 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
}

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 ""
}
129 changes: 129 additions & 0 deletions cmd/ephemerd/run_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 6 additions & 1 deletion docs/cli/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ 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 `<data-dir>/config.toml`, then to the built-in `ghcr.io/actions/actions-runner:latest`. |

## Behavior

1. **Locate workflow** -- if no file is specified, calls `workflow.FindWorkflow()` to auto-detect a workflow in `.github/workflows/`.
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_<os>` 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

Expand Down Expand Up @@ -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
```
16 changes: 11 additions & 5 deletions pkg/workflow/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down