1
0
forked from wrenn/wrenn

Add USER, COPY, ENV persistence to template build system

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
This commit is contained in:
2026-04-12 02:10:01 +06:00
parent f6c3dc0801
commit 75af2a4f66
24 changed files with 866 additions and 183 deletions

View File

@ -24,9 +24,11 @@ type Step struct {
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
Key string // KindENV: variable name; KindUSER: username
Value string // KindENV: variable value
Path string // KindWORKDIR: directory path
Src string // KindCOPY: source path (relative to build archive)
Dst string // KindCOPY: destination path inside sandbox
}
// ParseStep parses a single recipe instruction string into a Step.
@ -61,9 +63,9 @@ func ParseStep(s string) (Step, error) {
case "WORKDIR":
return parseWORKDIR(s, rest)
case "USER":
return Step{Kind: KindUSER, Raw: s}, nil
return parseUSER(s, rest)
case "COPY":
return Step{Kind: KindCOPY, Raw: s}, nil
return parseCOPY(s, rest)
default:
return Step{}, fmt.Errorf("unknown instruction %q (expected RUN, START, ENV, WORKDIR, USER, or COPY)", keyword)
}
@ -127,3 +129,31 @@ func parseWORKDIR(raw, path string) (Step, error) {
}
return Step{Kind: KindWORKDIR, Raw: raw, Path: path}, nil
}
func parseUSER(raw, username string) (Step, error) {
if username == "" {
return Step{}, fmt.Errorf("USER requires a username: %q", raw)
}
// Validate: alphanumeric, hyphens, underscores only; must start with a letter or underscore.
for i, c := range username {
if i == 0 && !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
return Step{}, fmt.Errorf("USER username must start with a letter or underscore: %q", raw)
}
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
return Step{}, fmt.Errorf("USER username contains invalid character %q: %q", string(c), raw)
}
}
return Step{Kind: KindUSER, Raw: raw, Key: username}, nil
}
func parseCOPY(raw, rest string) (Step, error) {
if rest == "" {
return Step{}, fmt.Errorf("COPY requires <src> <dst>: %q", raw)
}
src, dst, found := strings.Cut(rest, " ")
dst = strings.TrimSpace(dst)
if !found || dst == "" {
return Step{}, fmt.Errorf("COPY requires <src> <dst>: %q", raw)
}
return Step{Kind: KindCOPY, Raw: raw, Src: src, Dst: dst}, nil
}