1
0
forked from wrenn/wrenn
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: wrenn/sandbox#8
This commit is contained in:
2026-04-09 19:24:49 +00:00
parent 32e5a5a715
commit d3e4812e46
199 changed files with 24552 additions and 2776 deletions

104
internal/recipe/context.go Normal file
View File

@ -0,0 +1,104 @@
package recipe
import (
"regexp"
"slices"
"strings"
)
// ExecContext holds mutable state that persists across recipe steps.
// It is initialized empty and updated by ENV and WORKDIR steps.
type ExecContext struct {
WorkDir string
EnvVars map[string]string
}
// This regex matches:
// 1. $$ (escaped dollar)
// 2. ${VAR} or ${} (braced variable, possibly empty)
// 3. $VAR (bare variable)
var envRegex = regexp.MustCompile(`\$\$|\$\{([a-zA-Z0-9_]*)\}|\$([a-zA-Z0-9_]+)`)
// WrappedCommand returns the full shell command for a RUN step with context
// applied. The result is passed as the argument to /bin/sh -c.
//
// If WORKDIR and/or ENV are set, they are prepended as a shell preamble:
//
// cd '/the/dir' && KEY='val' /bin/sh -c 'original command'
func (c *ExecContext) WrappedCommand(cmd string) string {
prefix := c.shellPrefix()
if prefix == "" {
return cmd
}
return prefix + "/bin/sh -c " + shellescape(cmd)
}
// StartCommand returns the shell command for a START step. The process is
// launched in the background via nohup so that the outer shell exits
// immediately, allowing the build to continue. stdout/stderr of the
// background process are discarded (the process keeps running in the VM).
//
// Multiple START steps can be issued to run several background processes
// simultaneously before a healthcheck is evaluated.
func (c *ExecContext) StartCommand(cmd string) string {
prefix := c.shellPrefix()
return prefix + "nohup /bin/sh -c " + shellescape(cmd) + " >/dev/null 2>&1 &"
}
// shellPrefix builds the "cd ... && KEY=val " preamble for a shell command.
// Returns an empty string when no context is set.
func (c *ExecContext) shellPrefix() string {
if c.WorkDir == "" && len(c.EnvVars) == 0 {
return ""
}
var sb strings.Builder
if c.WorkDir != "" {
sb.WriteString("cd ")
sb.WriteString(shellescape(c.WorkDir))
sb.WriteString(" && ")
}
keys := make([]string, 0, len(c.EnvVars))
for k := range c.EnvVars {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(shellescape(c.EnvVars[k]))
sb.WriteByte(' ')
}
return sb.String()
}
// expandEnv replaces $var and ${var} placeholders in the string s with their
// corresponding values from the vars map.
// It supports escaping with $$, which is replaced by a single $.
// If a variable is not found in the vars map, it is replaced with an empty
// string.
func expandEnv(s string, vars map[string]string) string {
return envRegex.ReplaceAllStringFunc(s, func(match string) string {
if match == "$$" {
return "$"
}
var name string
if len(match) > 1 && match[1] == '{' {
name = match[2 : len(match)-1]
} else {
name = match[1:]
}
if v, ok := vars[name]; ok {
return v
}
return ""
})
}
// shellescape wraps s in single quotes, escaping any embedded single quotes.
// This is POSIX-safe for paths, env values, and shell commands.
func shellescape(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@ -0,0 +1,237 @@
package recipe
import "testing"
func TestExecContext_WrappedCommand(t *testing.T) {
tests := []struct {
name string
ctx ExecContext
cmd string
want string
wantOneOf []string
}{
{
name: "no context",
ctx: ExecContext{},
cmd: "apt install -y curl",
want: "apt install -y curl",
},
{
name: "workdir only",
ctx: ExecContext{WorkDir: "/app"},
cmd: "npm install",
want: "cd '/app' && /bin/sh -c 'npm install'",
},
{
name: "env only",
ctx: ExecContext{EnvVars: map[string]string{"PORT": "8080"}},
cmd: "node server.js",
want: "PORT='8080' /bin/sh -c 'node server.js'",
},
{
name: "workdir with space",
ctx: ExecContext{WorkDir: "/my project"},
cmd: "make build",
want: "cd '/my project' && /bin/sh -c 'make build'",
},
{
name: "command with single quotes",
ctx: ExecContext{WorkDir: "/app"},
cmd: "echo 'hello'",
want: "cd '/app' && /bin/sh -c 'echo '\\''hello'\\'''",
},
{
name: "env value with single quotes",
ctx: ExecContext{EnvVars: map[string]string{"MSG": "it's fine"}},
cmd: "echo $MSG",
want: "MSG='it'\\''s fine' /bin/sh -c 'echo $MSG'",
},
{
name: "env expansion with pre-expanded PATH",
ctx: ExecContext{
EnvVars: map[string]string{"PATH": "/usr/bin", "FOO": "/opt/venv/bin:/usr/bin"},
},
cmd: "make build",
want: "FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' /bin/sh -c 'make build'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.ctx.WrappedCommand(tc.cmd)
if len(tc.wantOneOf) > 0 {
matched := false
for _, w := range tc.wantOneOf {
if got == w {
matched = true
break
}
}
if !matched {
t.Errorf("WrappedCommand(%q)\n got %q\n want one of %q", tc.cmd, got, tc.wantOneOf)
}
} else if got != tc.want {
t.Errorf("WrappedCommand(%q)\n got %q\n want %q", tc.cmd, got, tc.want)
}
})
}
}
func TestExecContext_StartCommand(t *testing.T) {
tests := []struct {
name string
ctx ExecContext
cmd string
want string
}{
{
name: "no context",
ctx: ExecContext{},
cmd: "python3 app.py",
want: "nohup /bin/sh -c 'python3 app.py' >/dev/null 2>&1 &",
},
{
name: "with workdir",
ctx: ExecContext{WorkDir: "/app"},
cmd: "python3 server.py",
want: "cd '/app' && nohup /bin/sh -c 'python3 server.py' >/dev/null 2>&1 &",
},
{
name: "with env",
ctx: ExecContext{EnvVars: map[string]string{"PORT": "9000"}},
cmd: "node index.js",
want: "PORT='9000' nohup /bin/sh -c 'node index.js' >/dev/null 2>&1 &",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.ctx.StartCommand(tc.cmd)
if got != tc.want {
t.Errorf("StartCommand(%q)\n got %q\n want %q", tc.cmd, got, tc.want)
}
})
}
}
func TestExpandEnv(t *testing.T) {
tests := []struct {
s string
vars map[string]string
want string
}{
{
s: "hello",
vars: nil,
want: "hello",
},
{
s: "$PATH",
vars: map[string]string{"PATH": "/usr/bin"},
want: "/usr/bin",
},
{
s: "${PATH}",
vars: map[string]string{"PATH": "/usr/bin"},
want: "/usr/bin",
},
{
s: "/opt/venv/bin:$PATH",
vars: map[string]string{"PATH": "/usr/bin"},
want: "/opt/venv/bin:/usr/bin",
},
{
s: "${HOME}/code",
vars: map[string]string{"HOME": "/root"},
want: "/root/code",
},
{
s: "hello $USER",
vars: map[string]string{"USER": "admin"},
want: "hello admin",
},
{
s: "$UNSET",
vars: map[string]string{"PATH": "/usr/bin"},
want: "",
},
{
s: "${UNSET}",
vars: map[string]string{"PATH": "/usr/bin"},
want: "",
},
{
s: "$$",
vars: map[string]string{"PATH": "/usr/bin"},
want: "$",
},
{
s: "price is $$100",
vars: nil,
want: "price is $100",
},
{
s: "$FOO:$BAR",
vars: map[string]string{"FOO": "a", "BAR": "b"},
want: "a:b",
},
{
s: "${FOO}_${BAR}",
vars: map[string]string{"FOO": "hello", "BAR": "world"},
want: "hello_world",
},
{
s: "no vars here",
vars: nil,
want: "no vars here",
},
{
s: "$",
vars: nil,
want: "$",
},
{
s: "${",
vars: nil,
want: "${",
},
{
s: "${}",
vars: nil,
want: "",
},
{
s: "$VAR1$VAR2",
vars: map[string]string{"VAR1": "a", "VAR2": "b"},
want: "ab",
},
}
for _, tc := range tests {
t.Run(tc.s, func(t *testing.T) {
got := expandEnv(tc.s, tc.vars)
if got != tc.want {
t.Errorf("expandEnv(%q, %v)\n got %q\n want %q", tc.s, tc.vars, got, tc.want)
}
})
}
}
func TestShellescape(t *testing.T) {
tests := []struct {
input string
want string
}{
{"simple", "'simple'"},
{"/path/to/dir", "'/path/to/dir'"},
{"it's fine", "'it'\\''s fine'"},
{"", "''"},
{"a'b'c", "'a'\\''b'\\''c'"},
}
for _, tc := range tests {
got := shellescape(tc.input)
if got != tc.want {
t.Errorf("shellescape(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}

185
internal/recipe/executor.go Normal file
View File

@ -0,0 +1,185 @@
package recipe
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"connectrpc.com/connect"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
)
// DefaultStepTimeout is the fallback timeout for RUN steps that carry no
// explicit --timeout flag.
const DefaultStepTimeout = 30 * time.Second
// BuildLogEntry is the per-step record stored in template_builds.logs (JSONB).
type BuildLogEntry struct {
Step int `json:"step"`
Phase string `json:"phase"`
Cmd string `json:"cmd"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Exit int32 `json:"exit"`
Ok bool `json:"ok"`
Elapsed int64 `json:"elapsed_ms"`
}
// ExecFunc is the agent.Exec call signature used by the executor. It matches
// the method on the hostagent Connect RPC client.
type ExecFunc func(ctx context.Context, req *connect.Request[pb.ExecRequest]) (*connect.Response[pb.ExecResponse], error)
// Execute runs steps sequentially against sandboxID using execFn.
//
// - phase labels the log entries (e.g., "pre-build", "recipe", "post-build").
// - startStep is the 1-based offset so entries are globally numbered across phases.
// - defaultTimeout applies to RUN steps with no per-step --timeout; 0 → 10 minutes.
// - bctx is mutated in place as ENV/WORKDIR steps execute, and carries forward
// into subsequent phases when the caller passes the same pointer.
//
// Returns all log entries appended during this call, the next step counter
// value, and whether all steps succeeded. On false the last entry contains
// failure details; the caller is responsible for destroying the sandbox and
// recording the build error.
func Execute(
ctx context.Context,
phase string,
steps []Step,
sandboxID string,
startStep int,
defaultTimeout time.Duration,
bctx *ExecContext,
execFn ExecFunc,
) (entries []BuildLogEntry, nextStep int, ok bool) {
if defaultTimeout <= 0 {
defaultTimeout = 10 * time.Minute
}
step := startStep
for _, st := range steps {
step++
slog.Info("executing build step", "phase", phase, "step", step, "instruction", st.Raw)
switch st.Kind {
case KindENV:
if bctx.EnvVars == nil {
bctx.EnvVars = make(map[string]string)
}
bctx.EnvVars[st.Key] = expandEnv(st.Value, bctx.EnvVars)
entries = append(entries, BuildLogEntry{Step: step, Phase: phase, Cmd: st.Raw, Ok: true})
case KindWORKDIR:
bctx.WorkDir = st.Path
entries = append(entries, BuildLogEntry{Step: step, Phase: phase, Cmd: st.Raw, Ok: true})
case KindUSER, KindCOPY:
verb := strings.ToUpper(strings.Fields(st.Raw)[0])
entries = append(entries, BuildLogEntry{
Step: step,
Phase: phase,
Cmd: st.Raw,
Stderr: verb + " is not yet supported",
Ok: false,
})
return entries, step, false
case KindSTART:
entry, succeeded := execStart(ctx, st, sandboxID, phase, step, bctx, execFn)
entries = append(entries, entry)
if !succeeded {
return entries, step, false
}
case KindRUN:
timeout := defaultTimeout
if st.Timeout > 0 {
timeout = st.Timeout
}
entry, succeeded := execRun(ctx, st, sandboxID, phase, step, timeout, bctx, execFn)
entries = append(entries, entry)
if !succeeded {
return entries, step, false
}
}
}
return entries, step, true
}
func execRun(
ctx context.Context,
st Step,
sandboxID, phase string,
step int,
timeout time.Duration,
bctx *ExecContext,
execFn ExecFunc,
) (BuildLogEntry, bool) {
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
start := time.Now()
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", bctx.WrappedCommand(st.Shell)},
TimeoutSec: int32(timeout.Seconds()),
}))
entry := BuildLogEntry{
Step: step,
Phase: phase,
Cmd: st.Raw,
Elapsed: time.Since(start).Milliseconds(),
}
if err != nil {
entry.Stderr = fmt.Sprintf("exec error: %v", err)
return entry, false
}
entry.Stdout = string(resp.Msg.Stdout)
entry.Stderr = string(resp.Msg.Stderr)
entry.Exit = resp.Msg.ExitCode
entry.Ok = resp.Msg.ExitCode == 0
return entry, entry.Ok
}
func execStart(
ctx context.Context,
st Step,
sandboxID, phase string,
step int,
bctx *ExecContext,
execFn ExecFunc,
) (BuildLogEntry, bool) {
// START uses a short timeout: just long enough for the shell to fork and
// return. The background process itself runs indefinitely inside the VM.
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
start := time.Now()
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", bctx.StartCommand(st.Shell)},
TimeoutSec: 10,
}))
entry := BuildLogEntry{
Step: step,
Phase: phase,
Cmd: st.Raw,
Elapsed: time.Since(start).Milliseconds(),
}
if err != nil {
entry.Stderr = fmt.Sprintf("start error: %v", err)
return entry, false
}
entry.Exit = resp.Msg.ExitCode
entry.Ok = resp.Msg.ExitCode == 0
if !entry.Ok {
entry.Stderr = fmt.Sprintf("start failed with exit code %d: %s", resp.Msg.ExitCode, string(resp.Msg.Stderr))
}
return entry, entry.Ok
}

View File

@ -0,0 +1,94 @@
package recipe
import (
"fmt"
"strconv"
"strings"
"time"
)
// HealthcheckConfig holds the parsed configuration for a build healthcheck.
// A healthcheck is a shell command that is executed repeatedly inside the
// sandbox until it succeeds or the retry/timeout budget is exhausted.
//
// Retries of 0 means unlimited retries (bounded only by the overall deadline)
type HealthcheckConfig struct {
Cmd string
Interval time.Duration
Timeout time.Duration
StartPeriod time.Duration
Retries int // 0 = unlimited
}
// ParseHealthcheck parses a healthcheck string with optional flag prefix into
// a HealthcheckConfig. The syntax is:
//
// [--interval=<duration>] [--timeout=<duration>] [--start-period=<duration>]
// [--retries=<n>] <command>
//
// Flags must use the form --flag=value. The first token that does not start
// with "--" and everything after it is treated as the command. Defaults:
// interval=3s, timeout=10s, start-period=0, retries=0 (unlimited)
func ParseHealthcheck(s string) (HealthcheckConfig, error) {
s = strings.TrimSpace(s)
if s == "" {
return HealthcheckConfig{}, fmt.Errorf("empty healthcheck")
}
hc := HealthcheckConfig{
Interval: 3 * time.Second,
Timeout: 10 * time.Second,
}
tokens := strings.Fields(s)
cmdIndex := -1
for i, token := range tokens {
if !strings.HasPrefix(token, "--") {
cmdIndex = i
break
}
parts := strings.SplitN(token, "=", 2)
if len(parts) != 2 {
return HealthcheckConfig{}, fmt.Errorf("malformed flag (missing '='): %q", token)
}
key, val := parts[0], parts[1]
switch key {
case "--interval":
d, err := time.ParseDuration(val)
if err != nil {
return HealthcheckConfig{}, fmt.Errorf("parse interval: %w", err)
}
hc.Interval = d
case "--timeout":
d, err := time.ParseDuration(val)
if err != nil {
return HealthcheckConfig{}, fmt.Errorf("parse timeout: %w", err)
}
hc.Timeout = d
case "--start-period":
d, err := time.ParseDuration(val)
if err != nil {
return HealthcheckConfig{}, fmt.Errorf("parse start period: %w", err)
}
hc.StartPeriod = d
case "--retries":
r, err := strconv.Atoi(val)
if err != nil {
return HealthcheckConfig{}, fmt.Errorf("parse retries: %w", err)
}
hc.Retries = r
default:
return HealthcheckConfig{}, fmt.Errorf("unknown healthcheck flag: %q", token)
}
}
if cmdIndex == -1 {
return HealthcheckConfig{}, fmt.Errorf("healthcheck has no command")
}
hc.Cmd = strings.Join(tokens[cmdIndex:], " ")
return hc, nil
}

View File

@ -0,0 +1,126 @@
package recipe
import (
"testing"
"time"
)
func TestParseHealthcheck(t *testing.T) {
tests := []struct {
name string
input string
want HealthcheckConfig
wantErr bool
}{
{
name: "plain command",
input: "curl -f http://localhost:8080",
want: HealthcheckConfig{
Cmd: "curl -f http://localhost:8080",
Interval: 3 * time.Second,
Timeout: 10 * time.Second,
},
wantErr: false,
},
{
name: "all flags",
input: "--interval=5s --timeout=2s --start-period=15s --retries=3 ping -c 1 8.8.8.8",
want: HealthcheckConfig{
Cmd: "ping -c 1 8.8.8.8",
Interval: 5 * time.Second,
Timeout: 2 * time.Second,
StartPeriod: 15 * time.Second,
Retries: 3,
},
wantErr: false,
},
{
name: "partial flags",
input: "--timeout=5s my-custom-check --verbose",
want: HealthcheckConfig{
Cmd: "my-custom-check --verbose",
Interval: 3 * time.Second,
Timeout: 5 * time.Second,
},
wantErr: false,
},
{
name: "retries only",
input: "--retries=5 test.sh",
want: HealthcheckConfig{
Cmd: "test.sh",
Interval: 3 * time.Second,
Timeout: 10 * time.Second,
Retries: 5,
},
wantErr: false,
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "whitespace only",
input: " \t \n ",
wantErr: true,
},
{
name: "flags but no command",
input: "--interval=5s --retries=2",
wantErr: true,
},
{
name: "unknown flag",
input: "--magic=true my-check",
wantErr: true,
},
{
name: "invalid duration",
input: "--interval=5smiles check.sh",
wantErr: true,
},
{
name: "invalid retries",
input: "--retries=five check.sh",
wantErr: true,
},
{
name: "command with dashes",
input: "--interval=2s command-with-dash --flag=value",
want: HealthcheckConfig{
Cmd: "command-with-dash --flag=value",
Interval: 2 * time.Second,
Timeout: 10 * time.Second,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseHealthcheck(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseHealthcheck() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if got.Cmd != tt.want.Cmd {
t.Errorf("Cmd got = %v, want %v", got.Cmd, tt.want.Cmd)
}
if got.Interval != tt.want.Interval {
t.Errorf("Interval got = %v, want %v", got.Interval, tt.want.Interval)
}
if got.Timeout != tt.want.Timeout {
t.Errorf("Timeout got = %v, want %v", got.Timeout, tt.want.Timeout)
}
if got.StartPeriod != tt.want.StartPeriod {
t.Errorf("StartPeriod got = %v, want %v", got.StartPeriod, tt.want.StartPeriod)
}
if got.Retries != tt.want.Retries {
t.Errorf("Retries got = %v, want %v", got.Retries, tt.want.Retries)
}
}
})
}
}

129
internal/recipe/step.go Normal file
View File

@ -0,0 +1,129 @@
package recipe
import (
"fmt"
"strings"
"time"
)
// Kind identifies the instruction type in a recipe line.
type Kind int
const (
KindRUN Kind = iota // Execute a command and wait for it to exit.
KindSTART // Start a command in the background (non-blocking).
KindENV // Set an environment variable for subsequent steps.
KindWORKDIR // Set the working directory for subsequent steps.
KindUSER // Switch the unix user for subsequent steps. (stub)
KindCOPY // Copy files into the sandbox. (stub)
)
// Step is the parsed representation of one recipe instruction.
type Step struct {
Kind Kind
Raw string // original string, preserved for logging
Shell string // KindRUN, KindSTART: the shell command text
Timeout time.Duration // KindRUN: 0 means use caller's default
Key string // KindENV: variable name
Value string // KindENV: variable value
Path string // KindWORKDIR: directory path
}
// ParseStep parses a single recipe instruction string into a Step.
// Instructions are Dockerfile-like: a keyword followed by arguments.
//
// Supported syntax:
//
// RUN <cmd> — run command, wait for exit
// RUN --timeout=<d> <cmd> — run command with explicit timeout (e.g. --timeout=5m)
// START <cmd> — start command in background, return immediately
// ENV <key>=<value> — set environment variable
// WORKDIR <path> — set working directory
// USER <name> — not yet supported
// COPY <src> <dst> — not yet supported
func ParseStep(s string) (Step, error) {
s = strings.TrimSpace(s)
if s == "" {
return Step{}, fmt.Errorf("empty step")
}
// Split on first space to get the keyword.
keyword, rest, _ := strings.Cut(s, " ")
rest = strings.TrimSpace(rest)
switch strings.ToUpper(keyword) {
case "RUN":
return parseRUN(s, rest)
case "START":
return parseSTART(s, rest)
case "ENV":
return parseENV(s, rest)
case "WORKDIR":
return parseWORKDIR(s, rest)
case "USER":
return Step{Kind: KindUSER, Raw: s}, nil
case "COPY":
return Step{Kind: KindCOPY, Raw: s}, nil
default:
return Step{}, fmt.Errorf("unknown instruction %q (expected RUN, START, ENV, WORKDIR, USER, or COPY)", keyword)
}
}
// ParseRecipe parses all recipe lines, returning on the first error.
func ParseRecipe(lines []string) ([]Step, error) {
steps := make([]Step, 0, len(lines))
for i, line := range lines {
st, err := ParseStep(line)
if err != nil {
return nil, fmt.Errorf("recipe line %d: %w", i+1, err)
}
steps = append(steps, st)
}
return steps, nil
}
func parseRUN(raw, rest string) (Step, error) {
var timeout time.Duration
if strings.HasPrefix(rest, "--timeout=") {
rest = rest[len("--timeout="):]
flag, cmd, found := strings.Cut(rest, " ")
if !found || strings.TrimSpace(cmd) == "" {
return Step{}, fmt.Errorf("RUN --timeout= flag has no command: %q", raw)
}
d, err := time.ParseDuration(flag)
if err != nil {
return Step{}, fmt.Errorf("RUN --timeout= invalid duration %q: %w", flag, err)
}
timeout = d
rest = strings.TrimSpace(cmd)
}
if rest == "" {
return Step{}, fmt.Errorf("RUN requires a command: %q", raw)
}
return Step{Kind: KindRUN, Raw: raw, Shell: rest, Timeout: timeout}, nil
}
func parseSTART(raw, rest string) (Step, error) {
if rest == "" {
return Step{}, fmt.Errorf("START requires a command: %q", raw)
}
return Step{Kind: KindSTART, Raw: raw, Shell: rest}, nil
}
func parseENV(raw, rest string) (Step, error) {
key, value, found := strings.Cut(rest, "=")
if !found {
return Step{}, fmt.Errorf("ENV requires KEY=VALUE format: %q", raw)
}
if key == "" {
return Step{}, fmt.Errorf("ENV key is empty: %q", raw)
}
return Step{Kind: KindENV, Raw: raw, Key: key, Value: value}, nil
}
func parseWORKDIR(raw, path string) (Step, error) {
if path == "" {
return Step{}, fmt.Errorf("WORKDIR requires a path: %q", raw)
}
return Step{Kind: KindWORKDIR, Raw: raw, Path: path}, nil
}

View File

@ -0,0 +1,208 @@
package recipe
import (
"testing"
"time"
)
func TestParseStep(t *testing.T) {
tests := []struct {
name string
input string
want Step
wantErr bool
}{
// RUN
{
name: "RUN basic",
input: "RUN apt install -y curl",
want: Step{Kind: KindRUN, Raw: "RUN apt install -y curl", Shell: "apt install -y curl"},
},
{
name: "RUN lowercase",
input: "run echo hello",
want: Step{Kind: KindRUN, Raw: "run echo hello", Shell: "echo hello"},
},
{
name: "RUN with timeout",
input: "RUN --timeout=5m npm install",
want: Step{Kind: KindRUN, Raw: "RUN --timeout=5m npm install", Shell: "npm install", Timeout: 5 * time.Minute},
},
{
name: "RUN with timeout seconds",
input: "RUN --timeout=30s make build",
want: Step{Kind: KindRUN, Raw: "RUN --timeout=30s make build", Shell: "make build", Timeout: 30 * time.Second},
},
{
name: "RUN no command",
input: "RUN",
wantErr: true,
},
{
name: "RUN timeout no command",
input: "RUN --timeout=5m",
wantErr: true,
},
{
name: "RUN invalid timeout",
input: "RUN --timeout=notaduration echo hi",
wantErr: true,
},
// START
{
name: "START basic",
input: "START python3 app.py",
want: Step{Kind: KindSTART, Raw: "START python3 app.py", Shell: "python3 app.py"},
},
{
name: "START uppercase",
input: "START node server.js --port=8080",
want: Step{Kind: KindSTART, Raw: "START node server.js --port=8080", Shell: "node server.js --port=8080"},
},
{
name: "START no command",
input: "START",
wantErr: true,
},
// ENV
{
name: "ENV basic",
input: "ENV FOO=bar",
want: Step{Kind: KindENV, Raw: "ENV FOO=bar", Key: "FOO", Value: "bar"},
},
{
name: "ENV value with spaces",
input: "ENV GREETING=hello world",
want: Step{Kind: KindENV, Raw: "ENV GREETING=hello world", Key: "GREETING", Value: "hello world"},
},
{
name: "ENV value with equals sign",
input: "ENV URL=http://example.com?a=1",
want: Step{Kind: KindENV, Raw: "ENV URL=http://example.com?a=1", Key: "URL", Value: "http://example.com?a=1"},
},
{
name: "ENV empty value",
input: "ENV FOO=",
want: Step{Kind: KindENV, Raw: "ENV FOO=", Key: "FOO", Value: ""},
},
{
name: "ENV missing equals",
input: "ENV FOO",
wantErr: true,
},
{
name: "ENV empty key",
input: "ENV =value",
wantErr: true,
},
// WORKDIR
{
name: "WORKDIR basic",
input: "WORKDIR /app",
want: Step{Kind: KindWORKDIR, Raw: "WORKDIR /app", Path: "/app"},
},
{
name: "WORKDIR with spaces in path",
input: "WORKDIR /my project",
want: Step{Kind: KindWORKDIR, Raw: "WORKDIR /my project", Path: "/my project"},
},
{
name: "WORKDIR empty",
input: "WORKDIR",
wantErr: true,
},
// USER and COPY stubs
{
name: "USER stub",
input: "USER www-data",
want: Step{Kind: KindUSER, Raw: "USER www-data"},
},
{
name: "COPY stub",
input: "COPY config.yaml /etc/app/config.yaml",
want: Step{Kind: KindCOPY, Raw: "COPY config.yaml /etc/app/config.yaml"},
},
// Unknown keyword
{
name: "unknown keyword",
input: "FROBNICATE something",
wantErr: true,
},
// Empty input
{
name: "empty string",
input: "",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseStep(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("ParseStep(%q) expected error, got %+v", tc.input, got)
}
return
}
if err != nil {
t.Fatalf("ParseStep(%q) unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Errorf("ParseStep(%q)\n got %+v\n want %+v", tc.input, got, tc.want)
}
})
}
}
func TestParseRecipe(t *testing.T) {
t.Run("valid recipe", func(t *testing.T) {
lines := []string{
"RUN apt update",
"WORKDIR /app",
"ENV PORT=8080",
"START python3 server.py",
"RUN --timeout=2m pip install -r requirements.txt",
}
steps, err := ParseRecipe(lines)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(steps) != 5 {
t.Fatalf("expected 5 steps, got %d", len(steps))
}
if steps[0].Kind != KindRUN {
t.Errorf("step 0: want KindRUN, got %v", steps[0].Kind)
}
if steps[1].Kind != KindWORKDIR {
t.Errorf("step 1: want KindWORKDIR, got %v", steps[1].Kind)
}
if steps[3].Kind != KindSTART {
t.Errorf("step 3: want KindSTART, got %v", steps[3].Kind)
}
if steps[4].Timeout != 2*time.Minute {
t.Errorf("step 4: want 2m timeout, got %v", steps[4].Timeout)
}
})
t.Run("error on invalid line", func(t *testing.T) {
lines := []string{
"RUN apt update",
"BADCMD something",
}
_, err := ParseRecipe(lines)
if err == nil {
t.Fatal("expected error for invalid line, got nil")
}
})
t.Run("empty recipe", func(t *testing.T) {
steps, err := ParseRecipe(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(steps) != 0 {
t.Fatalf("expected 0 steps, got %d", len(steps))
}
})
}