forked from wrenn/wrenn
Implement three new recipe commands for the admin template builder: - USER <name>: creates the user (adduser + passwordless sudo), switches execution context so subsequent RUN/START commands run as that user via su wrapping. Last USER becomes the template's default_user. - COPY <src> <dst>: copies files from an uploaded build archive (tar/tar.gz/zip) into the sandbox. Source paths validated against traversal. Ownership set to the current USER. - ENV persistence: accumulated env vars stored in templates.default_env (JSONB) and injected via PostInit when sandboxes are created from the template, mirroring Docker's image metadata approach. Supporting changes: - Pre-build creates wrenn-user as default (via USER command) - WORKDIR now creates the directory if it doesn't exist (mkdir -p) - Per-step progress updates (ProgressFunc callback) for live UI - Multipart form support on POST /v1/admin/builds for archive upload - Proto: default_user/default_env fields on Create/ResumeSandboxRequest - Host agent: SetDefaults calls PostInitWithDefaults on envd - Control plane: reads template defaults, passes on sandbox create/resume - Frontend: file upload widget, recipe copy button, keyword colors for USER/COPY, fixed Svelte whitespace stripping in step display - Admin panel defaults to /admin/templates instead of /admin/hosts - Migration adds default_user and default_env to templates and template_builds tables
230 lines
5.3 KiB
Go
230 lines
5.3 KiB
Go
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
|
|
{
|
|
name: "USER basic",
|
|
input: "USER www-data",
|
|
want: Step{Kind: KindUSER, Raw: "USER www-data", Key: "www-data"},
|
|
},
|
|
{
|
|
name: "USER empty",
|
|
input: "USER",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "USER invalid chars",
|
|
input: "USER bad user",
|
|
wantErr: true,
|
|
},
|
|
// COPY
|
|
{
|
|
name: "COPY basic",
|
|
input: "COPY config.yaml /etc/app/config.yaml",
|
|
want: Step{Kind: KindCOPY, Raw: "COPY config.yaml /etc/app/config.yaml", Src: "config.yaml", Dst: "/etc/app/config.yaml"},
|
|
},
|
|
{
|
|
name: "COPY missing dst",
|
|
input: "COPY config.yaml",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "COPY empty",
|
|
input: "COPY",
|
|
wantErr: true,
|
|
},
|
|
// 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))
|
|
}
|
|
})
|
|
}
|