diff --git a/README.md b/README.md index a7bf134..426bc9e 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,33 @@ vers kill vers kill -r # recursive (include children) ``` +### Build from a Dockerfile + +`vers build` turns a literal Dockerfile into a sequence of actions on a +throwaway VM, committing a "layer" after each step and caching them in +`.vers/buildcache.json`. + +```bash +# FROM scratch — sizing is explicit +vers build --mem-size 2048 --vcpu-count 2 --fs-size-vm-mib 4096 . + +# FROM — no sizing flags needed +vers build -t myapp:prod . +vers build -f build.Dockerfile --build-arg VERSION=1.2.3 . + +# Scripting: print just the final commit id +COMMIT=$(vers build -q .) +vers run-commit "$COMMIT" +``` + +Supported instructions: `FROM`, `RUN`, `COPY`, `ADD` (local only), `ENV`, +`ARG`, `WORKDIR`, `USER`, `LABEL`, `CMD`, `ENTRYPOINT`, `EXPOSE`. +Multi-stage builds and `COPY --from=` are not yet supported. + +`FROM` resolves as follows: +- `FROM scratch` — fresh VM; requires `--mem-size`, `--vcpu-count`, `--fs-size-vm-mib` +- `FROM ` — looked up as a vers tag first, falling back to a commit id + ### Commits ```bash diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 0000000..fbbf9e1 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/hdresearch/vers-cli/internal/handlers" + pres "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/spf13/cobra" +) + +var ( + buildDockerfile string + buildTag string + buildNoCache bool + buildKeep bool + buildArgs []string + buildFormat string + buildQuiet bool + buildMemSize int64 + buildVcpuCount int64 + buildFsSize int64 + buildRootfsName string + buildKernelName string +) + +var buildCmd = &cobra.Command{ + Use: "build [PATH]", + Short: "Build a Vers commit from a Dockerfile", + Long: `Build a Vers commit by executing a Dockerfile against a throwaway VM. + +Each instruction is executed in order and committed as a "layer". Layers are +cached to .vers/buildcache.json keyed by (parent commit, instruction, content +hash) so repeat builds only re-run changed steps. + +FROM semantics (v1): + FROM scratch - start a fresh VM (requires --mem-size, --vcpu-count, + --fs-size-vm-mib; optional --rootfs, --kernel) + FROM - resolve as a vers tag first, then as a commit id + FROM - restore directly from a commit + +Supported instructions: + FROM, RUN, COPY, ADD (local only), ENV, ARG, WORKDIR, USER, + LABEL, CMD, ENTRYPOINT, EXPOSE + +Examples: + vers build . + vers build -f build.Dockerfile -t myapp:prod . + vers build --no-cache --build-arg VERSION=1.2.3 . + vers build --mem-size 2048 --vcpu-count 2 --fs-size-vm-mib 4096 .`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctxDir := "." + if len(args) == 1 { + ctxDir = args[0] + } + + argMap, err := parseBuildArgs(buildArgs) + if err != nil { + return err + } + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APILong) + defer cancel() + + req := handlers.BuildReq{ + Dockerfile: buildDockerfile, + ContextDir: ctxDir, + Tag: buildTag, + NoCache: buildNoCache, + Keep: buildKeep, + BuildArgs: argMap, + MemSizeMib: buildMemSize, + VcpuCount: buildVcpuCount, + FsSizeVmMib: buildFsSize, + RootfsName: buildRootfsName, + KernelName: buildKernelName, + } + + view, err := handlers.HandleBuild(apiCtx, application, req) + if err != nil { + return err + } + + format := pres.ParseFormat(buildQuiet, buildFormat) + switch format { + case pres.FormatJSON: + pres.PrintJSON(view) + case pres.FormatQuiet: + fmt.Fprintln(application.IO.Out, view.CommitID) + default: + pres.RenderBuild(application, view) + } + return nil + }, +} + +func parseBuildArgs(pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + out := make(map[string]string, len(pairs)) + for _, p := range pairs { + eq := strings.IndexByte(p, '=') + if eq < 0 { + return nil, fmt.Errorf("--build-arg %q: expected KEY=VALUE", p) + } + out[p[:eq]] = p[eq+1:] + } + return out, nil +} + +func init() { + rootCmd.AddCommand(buildCmd) + + buildCmd.Flags().StringVarP(&buildDockerfile, "file", "f", "", "Path to Dockerfile (default: /Dockerfile)") + buildCmd.Flags().StringVarP(&buildTag, "tag", "t", "", "Tag the resulting commit with this name") + buildCmd.Flags().BoolVar(&buildNoCache, "no-cache", false, "Ignore the layer cache") + buildCmd.Flags().BoolVar(&buildKeep, "keep", false, "Keep the builder VM alive after the build") + buildCmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Set a build-time ARG value (KEY=VALUE), repeatable") + buildCmd.Flags().StringVar(&buildFormat, "format", "", "Output format (json)") + buildCmd.Flags().BoolVarP(&buildQuiet, "quiet", "q", false, "Print only the final commit id") + + // FROM scratch sizing (explicit, per design) + buildCmd.Flags().Int64Var(&buildMemSize, "mem-size", 0, "Memory in MiB (required for FROM scratch)") + buildCmd.Flags().Int64Var(&buildVcpuCount, "vcpu-count", 0, "Number of vCPUs (required for FROM scratch)") + buildCmd.Flags().Int64Var(&buildFsSize, "fs-size-vm-mib", 0, "Root FS size in MiB (required for FROM scratch)") + buildCmd.Flags().StringVar(&buildRootfsName, "rootfs", "", "Base rootfs name (FROM scratch only)") + buildCmd.Flags().StringVar(&buildKernelName, "kernel", "", "Kernel image name (FROM scratch only)") +} diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 0000000..93edf37 --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "reflect" + "testing" +) + +func TestParseBuildArgs(t *testing.T) { + cases := []struct { + name string + in []string + want map[string]string + wantErr bool + }{ + {name: "nil", in: nil, want: nil}, + {name: "empty slice", in: []string{}, want: nil}, + {name: "single", in: []string{"FOO=bar"}, want: map[string]string{"FOO": "bar"}}, + { + name: "multiple", + in: []string{"FOO=bar", "BAZ=qux"}, + want: map[string]string{"FOO": "bar", "BAZ": "qux"}, + }, + { + name: "value with equals", + in: []string{"URL=https://x.com/?a=1&b=2"}, + want: map[string]string{"URL": "https://x.com/?a=1&b=2"}, + }, + { + name: "empty value", + in: []string{"EMPTY="}, + want: map[string]string{"EMPTY": ""}, + }, + {name: "missing equals", in: []string{"BAD"}, wantErr: true}, + {name: "one bad in list", in: []string{"OK=1", "BAD"}, wantErr: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := parseBuildArgs(c.in) + if c.wantErr { + if err == nil { + t.Errorf("expected error, got nil (result=%+v)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("got %+v, want %+v", got, c.want) + } + }) + } +} diff --git a/internal/dockerfile/parser.go b/internal/dockerfile/parser.go new file mode 100644 index 0000000..1d52635 --- /dev/null +++ b/internal/dockerfile/parser.go @@ -0,0 +1,454 @@ +// Package dockerfile parses the subset of Dockerfile syntax supported by +// `vers build`. It is intentionally small: we only implement instructions +// we can faithfully execute against a Vers VM. +// +// Supported instructions: +// +// FROM scratch | (single-stage only in v1) +// RUN | ["exec","form"] +// COPY [--chown=U:G] ... (local build context only) +// ADD ... (same as COPY in v1) +// ENV KEY=VAL [KEY=VAL ...] | KEY VAL +// ARG NAME[=default] +// WORKDIR +// USER [:group] +// LABEL KEY=VAL [KEY=VAL ...] +// CMD | ["exec","form"] +// ENTRYPOINT | ["exec","form"] +// EXPOSE [/proto] ... +// +// Multi-stage builds (`FROM ... AS name`, `COPY --from=...`) are parsed but +// rejected by the executor with a clear error for now. +package dockerfile + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +// InstructionKind is a Dockerfile instruction keyword. +type InstructionKind string + +const ( + KindFrom InstructionKind = "FROM" + KindRun InstructionKind = "RUN" + KindCopy InstructionKind = "COPY" + KindAdd InstructionKind = "ADD" + KindEnv InstructionKind = "ENV" + KindArg InstructionKind = "ARG" + KindWorkdir InstructionKind = "WORKDIR" + KindUser InstructionKind = "USER" + KindLabel InstructionKind = "LABEL" + KindCmd InstructionKind = "CMD" + KindEntrypoint InstructionKind = "ENTRYPOINT" + KindExpose InstructionKind = "EXPOSE" +) + +// Instruction is one parsed Dockerfile line (after continuations joined). +type Instruction struct { + Kind InstructionKind + Raw string // raw logical line, useful for cache keys and progress output + LineNum int // 1-based line number of the first physical line + + // Exactly one of the following is populated depending on Kind. + From *FromInstr + Run *RunInstr + Copy *CopyInstr + Env *KVInstr + Label *KVInstr + Arg *ArgInstr + Workdir *StrInstr + User *StrInstr + Cmd *ExecInstr + Entrypoint *ExecInstr + Expose *ExposeInstr +} + +type FromInstr struct { + Ref string // "scratch", a commit id, or a tag name + As string // stage name after AS (optional) +} + +type RunInstr struct { + // If Exec is set the user wrote exec form (JSON array). + // Otherwise Shell holds the raw shell string to pass to `bash -c`. + Shell string + Exec []string +} + +type CopyInstr struct { + Sources []string + Dest string + Chown string // optional --chown=USER[:GROUP] + From string // optional --from=; unsupported in v1 +} + +type KVInstr struct { + Pairs []KV +} + +type KV struct{ Key, Value string } + +type ArgInstr struct { + Name string + Default string + HasDef bool +} + +type StrInstr struct{ Value string } + +type ExecInstr struct { + Shell string + Exec []string +} + +type ExposeInstr struct { + Ports []string +} + +// ParseFile parses a Dockerfile at the given path. +func ParseFile(path string) ([]Instruction, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return Parse(f) +} + +// Parse parses Dockerfile bytes from r. +func Parse(r io.Reader) ([]Instruction, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + // First pass: read physical lines, join continuations, drop comments. + // Parser directives (# syntax=...) are ignored in v1. + type logical struct { + text string + lineNum int + } + var logicals []logical + + var ( + builder strings.Builder + startLn int + lineNum int + ) + flush := func() { + text := strings.TrimSpace(builder.String()) + builder.Reset() + if text == "" { + return + } + logicals = append(logicals, logical{text: text, lineNum: startLn}) + } + + for scanner.Scan() { + lineNum++ + raw := scanner.Text() + // Strip BOM on first line + if lineNum == 1 { + raw = strings.TrimPrefix(raw, "\uFEFF") + } + trimmed := strings.TrimSpace(raw) + // Comment line (only if not a continuation) + if strings.HasPrefix(trimmed, "#") && builder.Len() == 0 { + continue + } + // Start of a new logical line + if builder.Len() == 0 { + startLn = lineNum + } + // Handle line continuation: trailing backslash (with optional trailing whitespace) + noTrail := strings.TrimRight(raw, " \t") + if strings.HasSuffix(noTrail, "\\") { + // strip the backslash, append a space + builder.WriteString(strings.TrimSuffix(noTrail, "\\")) + builder.WriteByte(' ') + continue + } + builder.WriteString(raw) + flush() + } + flush() + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read dockerfile: %w", err) + } + + var out []Instruction + for _, l := range logicals { + instr, err := parseLogical(l.text, l.lineNum) + if err != nil { + return nil, fmt.Errorf("line %d: %w", l.lineNum, err) + } + out = append(out, instr) + } + return out, nil +} + +func parseLogical(text string, lineNum int) (Instruction, error) { + // Split into KEYWORD + rest + sp := strings.IndexAny(text, " \t") + if sp < 0 { + return Instruction{}, fmt.Errorf("missing arguments: %q", text) + } + keyword := strings.ToUpper(text[:sp]) + rest := strings.TrimSpace(text[sp+1:]) + + instr := Instruction{ + Kind: InstructionKind(keyword), + Raw: text, + LineNum: lineNum, + } + + switch instr.Kind { + case KindFrom: + f, err := parseFrom(rest) + if err != nil { + return instr, err + } + instr.From = f + case KindRun: + instr.Run = parseRun(rest) + case KindCopy, KindAdd: + c, err := parseCopy(rest) + if err != nil { + return instr, err + } + instr.Copy = c + case KindEnv: + kv, err := parseKV(rest, true) + if err != nil { + return instr, err + } + instr.Env = kv + case KindLabel: + kv, err := parseKV(rest, false) + if err != nil { + return instr, err + } + instr.Label = kv + case KindArg: + instr.Arg = parseArg(rest) + case KindWorkdir: + instr.Workdir = &StrInstr{Value: rest} + case KindUser: + instr.User = &StrInstr{Value: rest} + case KindCmd: + instr.Cmd = parseExec(rest) + case KindEntrypoint: + instr.Entrypoint = parseExec(rest) + case KindExpose: + instr.Expose = &ExposeInstr{Ports: fieldsNonEmpty(rest)} + default: + return instr, fmt.Errorf("unsupported instruction %q", keyword) + } + return instr, nil +} + +func parseFrom(s string) (*FromInstr, error) { + fields := fieldsNonEmpty(s) + if len(fields) == 0 { + return nil, fmt.Errorf("FROM requires an argument") + } + f := &FromInstr{Ref: fields[0]} + if len(fields) >= 3 && strings.EqualFold(fields[1], "AS") { + f.As = fields[2] + } else if len(fields) != 1 { + return nil, fmt.Errorf("FROM syntax: expected 'FROM [AS name]'") + } + return f, nil +} + +func parseRun(s string) *RunInstr { + if exec, ok := tryParseExecArray(s); ok { + return &RunInstr{Exec: exec} + } + return &RunInstr{Shell: s} +} + +func parseExec(s string) *ExecInstr { + if exec, ok := tryParseExecArray(s); ok { + return &ExecInstr{Exec: exec} + } + return &ExecInstr{Shell: s} +} + +func tryParseExecArray(s string) ([]string, bool) { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "[") { + return nil, false + } + var out []string + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, false + } + return out, true +} + +func parseCopy(s string) (*CopyInstr, error) { + fields, err := splitCopyArgs(s) + if err != nil { + return nil, err + } + c := &CopyInstr{} + // Collect leading --flag=value entries + for len(fields) > 0 && strings.HasPrefix(fields[0], "--") { + flag := fields[0] + fields = fields[1:] + eq := strings.IndexByte(flag, '=') + if eq < 0 { + return nil, fmt.Errorf("COPY flag %q requires =value", flag) + } + name, val := flag[:eq], flag[eq+1:] + switch name { + case "--chown": + c.Chown = val + case "--from": + c.From = val + default: + return nil, fmt.Errorf("COPY: unsupported flag %q", name) + } + } + if len(fields) < 2 { + return nil, fmt.Errorf("COPY requires at least ") + } + c.Dest = fields[len(fields)-1] + c.Sources = fields[:len(fields)-1] + return c, nil +} + +// splitCopyArgs splits a COPY argument string. If the string is a JSON array +// (exec form for COPY: ["a", "b"]) we honor that, otherwise we split on +// whitespace. +func splitCopyArgs(s string) ([]string, error) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "[") { + var out []string + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, fmt.Errorf("COPY: bad JSON array: %w", err) + } + return out, nil + } + return fieldsNonEmpty(s), nil +} + +// parseKV parses ENV/LABEL. ENV allows legacy single-pair form (`ENV K V...`) +// when no '=' is present in the first token. +func parseKV(s string, allowLegacy bool) (*KVInstr, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, fmt.Errorf("expected key=value") + } + // Legacy form: "ENV K rest of line" → one pair, value = rest of line joined + if allowLegacy { + first := firstField(s) + if !strings.Contains(first, "=") { + rest := strings.TrimSpace(s[len(first):]) + if rest == "" { + return nil, fmt.Errorf("ENV legacy form requires a value") + } + return &KVInstr{Pairs: []KV{{Key: first, Value: rest}}}, nil + } + } + pairs, err := tokenizeKVPairs(s) + if err != nil { + return nil, err + } + return &KVInstr{Pairs: pairs}, nil +} + +func parseArg(s string) *ArgInstr { + s = strings.TrimSpace(s) + eq := strings.IndexByte(s, '=') + if eq < 0 { + return &ArgInstr{Name: s} + } + return &ArgInstr{Name: s[:eq], Default: s[eq+1:], HasDef: true} +} + +// tokenizeKVPairs handles space-separated KEY=VAL pairs with optional quoted values: +// +// FOO=bar BAZ="hello world" QUX='a b' +func tokenizeKVPairs(s string) ([]KV, error) { + var pairs []KV + i := 0 + for i < len(s) { + // Skip whitespace + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i >= len(s) { + break + } + // Key: read until '=' + keyStart := i + for i < len(s) && s[i] != '=' && s[i] != ' ' && s[i] != '\t' { + i++ + } + if i >= len(s) || s[i] != '=' { + return nil, fmt.Errorf("expected '=' after key %q", s[keyStart:i]) + } + key := s[keyStart:i] + i++ // skip '=' + // Value: quoted or bare + val, n, err := readValue(s[i:]) + if err != nil { + return nil, err + } + i += n + pairs = append(pairs, KV{Key: key, Value: val}) + } + if len(pairs) == 0 { + return nil, fmt.Errorf("expected key=value pairs") + } + return pairs, nil +} + +func readValue(s string) (string, int, error) { + if len(s) == 0 { + return "", 0, nil + } + if s[0] == '"' || s[0] == '\'' { + quote := s[0] + var b strings.Builder + i := 1 + for i < len(s) { + c := s[i] + if c == '\\' && i+1 < len(s) && quote == '"' { + // backslash escape only meaningful inside double quotes + b.WriteByte(s[i+1]) + i += 2 + continue + } + if c == quote { + return b.String(), i + 1, nil + } + b.WriteByte(c) + i++ + } + return "", 0, fmt.Errorf("unterminated %c-quoted value", quote) + } + i := 0 + for i < len(s) && s[i] != ' ' && s[i] != '\t' { + i++ + } + return s[:i], i, nil +} + +func firstField(s string) string { + for i := 0; i < len(s); i++ { + if s[i] == ' ' || s[i] == '\t' { + return s[:i] + } + } + return s +} + +func fieldsNonEmpty(s string) []string { + return strings.Fields(s) +} diff --git a/internal/dockerfile/parser_test.go b/internal/dockerfile/parser_test.go new file mode 100644 index 0000000..231868d --- /dev/null +++ b/internal/dockerfile/parser_test.go @@ -0,0 +1,123 @@ +package dockerfile + +import ( + "strings" + "testing" +) + +func TestParseBasic(t *testing.T) { + src := `# A comment +FROM scratch +ENV FOO=bar BAZ="hello world" +WORKDIR /app +COPY . /app +RUN echo hello && \ + echo world +CMD ["node", "server.js"] +EXPOSE 8080 +` + instrs, err := Parse(strings.NewReader(src)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(instrs) != 7 { + t.Fatalf("got %d instrs, want 7: %+v", len(instrs), instrs) + } + if instrs[0].Kind != KindFrom || instrs[0].From.Ref != "scratch" { + t.Errorf("FROM: %+v", instrs[0]) + } + if instrs[1].Kind != KindEnv || len(instrs[1].Env.Pairs) != 2 { + t.Errorf("ENV: %+v", instrs[1].Env) + } + if instrs[1].Env.Pairs[1].Value != "hello world" { + t.Errorf("ENV quoted: %q", instrs[1].Env.Pairs[1].Value) + } + if instrs[4].Kind != KindRun || !strings.Contains(instrs[4].Run.Shell, "echo hello") || !strings.Contains(instrs[4].Run.Shell, "echo world") { + t.Errorf("RUN continuation: %q", instrs[4].Run.Shell) + } + if instrs[5].Kind != KindCmd || len(instrs[5].Cmd.Exec) != 2 || instrs[5].Cmd.Exec[0] != "node" { + t.Errorf("CMD exec form: %+v", instrs[5].Cmd) + } + if instrs[6].Kind != KindExpose || instrs[6].Expose.Ports[0] != "8080" { + t.Errorf("EXPOSE: %+v", instrs[6].Expose) + } +} + +func TestParseFromAs(t *testing.T) { + instrs, err := Parse(strings.NewReader("FROM abc123 AS builder\n")) + if err != nil { + t.Fatal(err) + } + if instrs[0].From.As != "builder" { + t.Errorf("want As=builder, got %+v", instrs[0].From) + } +} + +func TestParseCopyFlags(t *testing.T) { + instrs, err := Parse(strings.NewReader("COPY --chown=root:root a b /dst/\n")) + if err != nil { + t.Fatal(err) + } + c := instrs[0].Copy + if c.Chown != "root:root" { + t.Errorf("chown: %q", c.Chown) + } + if len(c.Sources) != 2 || c.Sources[0] != "a" || c.Sources[1] != "b" { + t.Errorf("sources: %+v", c.Sources) + } + if c.Dest != "/dst/" { + t.Errorf("dest: %q", c.Dest) + } +} + +func TestParseEnvLegacy(t *testing.T) { + instrs, err := Parse(strings.NewReader("ENV MY_VAR this is the value\n")) + if err != nil { + t.Fatal(err) + } + p := instrs[0].Env.Pairs + if len(p) != 1 || p[0].Key != "MY_VAR" || p[0].Value != "this is the value" { + t.Errorf("legacy ENV: %+v", p) + } +} + +func TestParseArg(t *testing.T) { + cases := []struct { + in string + name string + def string + hasDef bool + }{ + {"ARG FOO\n", "FOO", "", false}, + {"ARG FOO=bar\n", "FOO", "bar", true}, + {"ARG FOO=\n", "FOO", "", true}, + } + for _, c := range cases { + instrs, err := Parse(strings.NewReader(c.in)) + if err != nil { + t.Fatalf("%s: %v", c.in, err) + } + a := instrs[0].Arg + if a.Name != c.name || a.Default != c.def || a.HasDef != c.hasDef { + t.Errorf("%q: got %+v", c.in, a) + } + } +} + +func TestParseCommentsAndBlanks(t *testing.T) { + src := "# top\n\nFROM scratch\n# mid\n\nRUN true\n" + instrs, err := Parse(strings.NewReader(src)) + if err != nil { + t.Fatal(err) + } + if len(instrs) != 2 { + t.Errorf("expected 2 instrs, got %d", len(instrs)) + } +} + +func TestParseRejectsUnknown(t *testing.T) { + _, err := Parse(strings.NewReader("HEALTHCHECK CMD foo\n")) + if err == nil { + t.Error("expected error for unsupported instruction") + } +} diff --git a/internal/handlers/build.go b/internal/handlers/build.go new file mode 100644 index 0000000..1fc7ed7 --- /dev/null +++ b/internal/handlers/build.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/hdresearch/vers-cli/internal/app" + "github.com/hdresearch/vers-cli/internal/dockerfile" + "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/hdresearch/vers-cli/internal/services/builder" +) + +type BuildReq struct { + Dockerfile string // path to the Dockerfile (absolute or relative to cwd) + ContextDir string // path to the build context root + Tag string // optional vers tag to create on the final commit + NoCache bool + Keep bool + BuildArgs map[string]string + + // Machine sizing — required iff FROM scratch. + MemSizeMib int64 + VcpuCount int64 + FsSizeVmMib int64 + RootfsName string + KernelName string +} + +func HandleBuild(ctx context.Context, a *app.App, r BuildReq) (presenters.BuildView, error) { + v := presenters.BuildView{} + + dfPath := r.Dockerfile + if dfPath == "" { + dfPath = filepath.Join(r.ContextDir, "Dockerfile") + } else if !filepath.IsAbs(dfPath) { + dfPath = filepath.Join(r.ContextDir, dfPath) + } + + instrs, err := dockerfile.ParseFile(dfPath) + if err != nil { + return v, fmt.Errorf("parse %s: %w", dfPath, err) + } + + bc, err := builder.LoadContext(r.ContextDir) + if err != nil { + return v, err + } + + opts := builder.Options{ + Instructions: instrs, + Context: bc, + BuildArgs: r.BuildArgs, + MemSizeMib: r.MemSizeMib, + VcpuCount: r.VcpuCount, + FsSizeVmMib: r.FsSizeVmMib, + RootfsName: r.RootfsName, + KernelName: r.KernelName, + NoCache: r.NoCache, + Keep: r.Keep, + Tag: r.Tag, + } + + res, err := builder.Build(ctx, a, opts) + if err != nil { + return v, err + } + + v.CommitID = res.FinalCommitID + v.BuilderVmID = res.BuilderVmID + v.StepCount = res.StepCount + v.CachedCount = res.CachedCount + v.Tag = res.Tag + v.Cmd = res.Cmd + v.Entrypoint = res.Entrypoint + v.ExposedPorts = res.ExposedPorts + v.Labels = res.Labels + return v, nil +} diff --git a/internal/handlers/build_test.go b/internal/handlers/build_test.go new file mode 100644 index 0000000..d888b32 --- /dev/null +++ b/internal/handlers/build_test.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hdresearch/vers-cli/internal/app" +) + +// HandleBuild: error paths that don't require a live orchestrator. +// +// These exercise the pre-flight work (locating the Dockerfile, parsing it, +// loading the build context) which happens before any SDK calls. A zero-value +// *app.App is fine for these cases because we never reach the executor. + +func TestHandleBuild_MissingDockerfile(t *testing.T) { + dir := t.TempDir() + _, err := HandleBuild(context.Background(), &app.App{}, BuildReq{ContextDir: dir}) + if err == nil { + t.Fatal("expected error for missing Dockerfile") + } + if !strings.Contains(err.Error(), "parse") { + t.Errorf("want parse error, got: %v", err) + } +} + +func TestHandleBuild_ParseError(t *testing.T) { + dir := t.TempDir() + // Syntax the parser actively rejects (unsupported keyword). + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("HEALTHCHECK CMD foo\n"), 0644); err != nil { + t.Fatal(err) + } + _, err := HandleBuild(context.Background(), &app.App{}, BuildReq{ContextDir: dir}) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestHandleBuild_ContextNotADirectory(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "notadir") + if err != nil { + t.Fatal(err) + } + f.Close() + // Use a real Dockerfile path, but point context at the file itself. + df := filepath.Join(t.TempDir(), "Dockerfile") + _ = os.WriteFile(df, []byte("FROM scratch\n"), 0644) + _, err = HandleBuild(context.Background(), &app.App{}, BuildReq{ + ContextDir: f.Name(), + Dockerfile: df, + }) + if err == nil { + t.Fatal("expected 'not a directory' error") + } +} + +func TestHandleBuild_FromScratchRequiresSizing(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\nRUN echo hi\n"), 0644); err != nil { + t.Fatal(err) + } + _, err := HandleBuild(context.Background(), &app.App{}, BuildReq{ContextDir: dir}) + if err == nil { + t.Fatal("expected sizing error") + } + if !strings.Contains(err.Error(), "mem-size") { + t.Errorf("want sizing error, got: %v", err) + } +} + +func TestHandleBuild_RejectsMultiStage(t *testing.T) { + dir := t.TempDir() + df := "FROM scratch\nFROM scratch AS next\n" + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(df), 0644); err != nil { + t.Fatal(err) + } + _, err := HandleBuild(context.Background(), &app.App{}, BuildReq{ + ContextDir: dir, + MemSizeMib: 512, + VcpuCount: 1, + FsSizeVmMib: 1024, + }) + if err == nil { + t.Fatal("expected multi-stage rejection") + } + if !strings.Contains(err.Error(), "multi-stage") { + t.Errorf("want multi-stage error, got: %v", err) + } +} diff --git a/internal/presenters/build_presenter.go b/internal/presenters/build_presenter.go new file mode 100644 index 0000000..e1cac7e --- /dev/null +++ b/internal/presenters/build_presenter.go @@ -0,0 +1,42 @@ +package presenters + +import ( + "fmt" + + "github.com/hdresearch/vers-cli/internal/app" +) + +// BuildView is the rendered result of a `vers build`. +type BuildView struct { + CommitID string `json:"commit_id"` + Tag string `json:"tag,omitempty"` + BuilderVmID string `json:"builder_vm_id,omitempty"` + StepCount int `json:"steps"` + CachedCount int `json:"cached_steps"` + Cmd []string `json:"cmd,omitempty"` + Entrypoint []string `json:"entrypoint,omitempty"` + ExposedPorts []string `json:"exposed_ports,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +// RenderBuild prints a human-friendly summary to the app's output. +func RenderBuild(a *app.App, v BuildView) { + w := a.IO.Out + fmt.Fprintf(w, "\nSuccessfully built %s\n", v.CommitID) + if v.Tag != "" { + fmt.Fprintf(w, "Tagged as: %s\n", v.Tag) + } + fmt.Fprintf(w, "Steps: %d (%d cached)\n", v.StepCount, v.CachedCount) + if v.BuilderVmID != "" { + fmt.Fprintf(w, "Builder VM (kept): %s\n", v.BuilderVmID) + } + if len(v.Entrypoint) > 0 { + fmt.Fprintf(w, "Entrypoint: %v\n", v.Entrypoint) + } + if len(v.Cmd) > 0 { + fmt.Fprintf(w, "Cmd: %v\n", v.Cmd) + } + if len(v.ExposedPorts) > 0 { + fmt.Fprintf(w, "Exposed: %v\n", v.ExposedPorts) + } +} diff --git a/internal/services/builder/build_test.go b/internal/services/builder/build_test.go new file mode 100644 index 0000000..3794be4 --- /dev/null +++ b/internal/services/builder/build_test.go @@ -0,0 +1,415 @@ +package builder + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hdresearch/vers-cli/internal/dockerfile" +) + +// --- Test harness ------------------------------------------------------------ + +// buildHarness constructs a valid build context (one file) and parses the +// given Dockerfile text. Returns an Options ready to hand to Build with a +// FakeExecutor attached. +func buildHarness(t *testing.T, df string) (*FakeExecutor, Options, *bytes.Buffer) { + t.Helper() + dir := t.TempDir() + // Default: a single file the Dockerfiles below can COPY. + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + instrs, err := dockerfile.Parse(strings.NewReader(df)) + if err != nil { + t.Fatalf("parse: %v", err) + } + bc, err := LoadContext(dir) + if err != nil { + t.Fatal(err) + } + fake := NewFake() + stderr := &bytes.Buffer{} + return fake, Options{ + Instructions: instrs, + Context: bc, + Exec: fake, + Stderr: stderr, + MemSizeMib: 512, + VcpuCount: 1, + FsSizeVmMib: 1024, + }, stderr +} + +// --- Happy-path scenarios ---------------------------------------------------- + +func TestBuild_FromScratchSimpleChain(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nRUN echo hi\nRUN echo bye\n") + res, err := Build(context.Background(), nil, opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + // Two RUN steps -> two Commit calls. No cache hits. + if res.StepCount != 3 { + t.Errorf("steps=%d want 3", res.StepCount) + } + if res.CachedCount != 0 { + t.Errorf("cached=%d want 0", res.CachedCount) + } + if fake.CountOp("Commit") != 2 { + t.Errorf("commits=%d want 2", fake.CountOp("Commit")) + } + if fake.CountOp("NewVM") != 1 { + t.Errorf("NewVM=%d want 1", fake.CountOp("NewVM")) + } + // Builder VM gets deleted at end (no --keep). + if fake.CountOp("DeleteVM") != 1 { + t.Errorf("DeleteVM=%d want 1", fake.CountOp("DeleteVM")) + } + if len(fake.LiveVMList()) != 0 { + t.Errorf("expected no live VMs, got %v", fake.LiveVMList()) + } + // Final commit id should be the last Commit() + order := fake.CommitOrder() + if len(order) == 0 || res.FinalCommitID != order[len(order)-1] { + t.Errorf("final commit mismatch: res=%s order=%v", res.FinalCommitID, order) + } +} + +func TestBuild_MetadataDoesNotCommit(t *testing.T) { + fake, opts, _ := buildHarness(t, `FROM scratch +ENV FOO=bar +WORKDIR /app +USER root +LABEL key=val +EXPOSE 80 +CMD ["echo"] +RUN true +`) + res, err := Build(context.Background(), nil, opts) + if err != nil { + t.Fatal(err) + } + // Only one RUN should produce a commit. + if fake.CountOp("Commit") != 1 { + t.Errorf("commits=%d want 1", fake.CountOp("Commit")) + } + if res.Cmd == nil || res.Cmd[0] != "echo" { + t.Errorf("cmd=%+v", res.Cmd) + } + if res.ExposedPorts[0] != "80" { + t.Errorf("exposed=%+v", res.ExposedPorts) + } + if res.Labels["key"] != "val" { + t.Errorf("labels=%+v", res.Labels) + } +} + +func TestBuild_FromTag(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM prod\nRUN true\n") + fake.SeedTag("prod", "seed-commit") + res, err := Build(context.Background(), nil, opts) + if err != nil { + t.Fatal(err) + } + if fake.CountOp("NewVM") != 0 { + t.Errorf("unexpected NewVM call: %+v", fake.OpsOnly()) + } + if fake.CountOp("RestoreFromCommit") != 1 { + t.Errorf("want one RestoreFromCommit, got %+v", fake.OpsOnly()) + } + // The restore should have targeted the tag's commit. + for _, c := range fake.Calls { + if c.Op == "RestoreFromCommit" && c.CommitID != "seed-commit" { + t.Errorf("restored wrong commit: %q", c.CommitID) + } + } + if res.FinalCommitID == "" { + t.Error("expected a final commit id") + } +} + +func TestBuild_FromCommitIDPassthrough(t *testing.T) { + // No tag named "abc-123", so Build should restore that id as-is. + fake, opts, _ := buildHarness(t, "FROM abc-123\nRUN true\n") + _, err := Build(context.Background(), nil, opts) + if err != nil { + t.Fatal(err) + } + foundRestore := false + for _, c := range fake.Calls { + if c.Op == "RestoreFromCommit" { + foundRestore = true + if c.CommitID != "abc-123" { + t.Errorf("restored %q, want abc-123", c.CommitID) + } + } + } + if !foundRestore { + t.Error("expected RestoreFromCommit call") + } +} + +// --- Cache behaviour -------------------------------------------------------- + +func TestBuild_CacheHitSkipsRunAndSwitchesVM(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.MkdirAll(".vers", 0755); err != nil { + t.Fatal(err) + } + + // First build populates the cache. + fake1, opts1, _ := buildHarness(t, "FROM scratch\nRUN echo hi\n") + res1, err := Build(context.Background(), nil, opts1) + if err != nil { + t.Fatal(err) + } + if fake1.CountOp("Run") == 0 { + t.Fatal("expected first build to execute RUN") + } + + // Second build with same Dockerfile and context should hit the cache. + // Seed the second fake with the commit the first build produced so the + // cache-hit path can restore it. + fake2, opts2, _ := buildHarness(t, "FROM scratch\nRUN echo hi\n") + fake2.Seed(res1.FinalCommitID) + res2, err := Build(context.Background(), nil, opts2) + if err != nil { + t.Fatal(err) + } + if res2.CachedCount != 1 { + t.Errorf("cached=%d want 1", res2.CachedCount) + } + // No RUN should have been issued (the cache hit skips execution). + for _, c := range fake2.Calls { + if c.Op == "Run" { + t.Errorf("unexpected Run on cache hit: %+v", c.Cmd) + } + } + // And no Commit, since we reused the cached commit. + if n := fake2.CountOp("Commit"); n != 0 { + t.Errorf("commits on cache hit=%d want 0", n) + } + // A cache hit branches from the cached commit. + if fake2.CountOp("RestoreFromCommit") != 1 { + t.Errorf("RestoreFromCommit=%d want 1 (branch from cache)", fake2.CountOp("RestoreFromCommit")) + } + // The initial scratch VM must have been deleted when we switched over. + if fake2.CountOp("DeleteVM") != 2 { + // one for the switch, one for teardown + t.Errorf("DeleteVM=%d want 2 (switch + teardown)", fake2.CountOp("DeleteVM")) + } + // Final result identical to first build's final commit. + if res2.FinalCommitID != res1.FinalCommitID { + t.Errorf("final mismatch: first=%s second=%s", res1.FinalCommitID, res2.FinalCommitID) + } +} + +func TestBuild_NoCacheBypassesLookup(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.MkdirAll(".vers", 0755); err != nil { + t.Fatal(err) + } + + // Prime the cache. + _, opts1, _ := buildHarness(t, "FROM scratch\nRUN echo hi\n") + if _, err := Build(context.Background(), nil, opts1); err != nil { + t.Fatal(err) + } + + // Second build with NoCache: must actually run again. + fake2, opts2, _ := buildHarness(t, "FROM scratch\nRUN echo hi\n") + opts2.NoCache = true + res, err := Build(context.Background(), nil, opts2) + if err != nil { + t.Fatal(err) + } + if res.CachedCount != 0 { + t.Errorf("cached=%d, expected 0 with --no-cache", res.CachedCount) + } + if fake2.CountOp("Run") == 0 { + t.Error("expected Run with --no-cache") + } +} + +func TestBuild_StaleCacheFallsThroughToExecution(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.MkdirAll(".vers", 0755); err != nil { + t.Fatal(err) + } + + // Prime the cache. + _, opts1, _ := buildHarness(t, "FROM scratch\nRUN echo hi\n") + res1, err := Build(context.Background(), nil, opts1) + if err != nil { + t.Fatal(err) + } + + // Second build: cache still points at a commit, but the server no + // longer has it. The builder should fall through and re-execute. + fake2, opts2, stderr := buildHarness(t, "FROM scratch\nRUN echo hi\n") + fake2.MissingCommits = map[string]bool{res1.FinalCommitID: true} + res2, err := Build(context.Background(), nil, opts2) + if err != nil { + t.Fatalf("Build: %v", err) + } + if res2.CachedCount != 0 { + t.Errorf("cached=%d want 0 when server lost the commit", res2.CachedCount) + } + if fake2.CountOp("Run") == 0 { + t.Error("expected fallback Run") + } + if !strings.Contains(stderr.String(), "stale") { + t.Errorf("expected progress note about stale cache, got:\n%s", stderr.String()) + } +} + +// --- Failure and teardown --------------------------------------------------- + +func TestBuild_RunFailureDeletesBuilder(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nRUN false\n") + fake.RunFunc = func(cmd []string, env map[string]string, workdir string) (int, string, string, error) { + return 2, "", "boom", nil + } + res, err := Build(context.Background(), nil, opts) + if err == nil { + t.Fatal("expected error from failing RUN") + } + if res != nil { + t.Errorf("expected nil result on error, got %+v", res) + } + if fake.CountOp("DeleteVM") != 1 { + t.Errorf("DeleteVM=%d want 1 (teardown on failure)", fake.CountOp("DeleteVM")) + } + if len(fake.LiveVMList()) != 0 { + t.Errorf("VM leaked: %v", fake.LiveVMList()) + } +} + +func TestBuild_KeepLeavesBuilderAlive(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nRUN true\n") + opts.Keep = true + res, err := Build(context.Background(), nil, opts) + if err != nil { + t.Fatal(err) + } + if fake.CountOp("DeleteVM") != 0 { + t.Errorf("DeleteVM=%d want 0 with --keep", fake.CountOp("DeleteVM")) + } + if res.BuilderVmID == "" { + t.Error("expected BuilderVmID in result with --keep") + } + if !contains(fake.LiveVMList(), res.BuilderVmID) { + t.Errorf("builder VM should be alive, have %v", fake.LiveVMList()) + } +} + +func TestBuild_KeepOnFailureStillLeaves(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nRUN false\n") + opts.Keep = true + fake.RunFunc = func(cmd []string, env map[string]string, workdir string) (int, string, string, error) { + return 2, "", "", nil + } + _, err := Build(context.Background(), nil, opts) + if err == nil { + t.Fatal("expected error") + } + if fake.CountOp("DeleteVM") != 0 { + t.Errorf("--keep should leave VM even on failure, got DeleteVM=%d", fake.CountOp("DeleteVM")) + } + if len(fake.LiveVMList()) != 1 { + t.Errorf("expected exactly one live VM, got %v", fake.LiveVMList()) + } +} + +func TestBuild_CommitFailurePropagates(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nRUN true\n") + fake.FailNextCommit = true + _, err := Build(context.Background(), nil, opts) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "commit") { + t.Errorf("want commit error, got: %v", err) + } + if fake.CountOp("DeleteVM") != 1 { + t.Errorf("DeleteVM=%d want 1 (cleanup)", fake.CountOp("DeleteVM")) + } +} + +// --- Tagging ---------------------------------------------------------------- + +func TestBuild_TagsFinalCommit(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nRUN true\n") + opts.Tag = "myapp:prod" + res, err := Build(context.Background(), nil, opts) + if err != nil { + t.Fatal(err) + } + if res.Tag != "myapp:prod" { + t.Errorf("result.Tag=%q want myapp:prod", res.Tag) + } + if fake.Tags["myapp:prod"] != res.FinalCommitID { + t.Errorf("tag points at wrong commit: %q vs %q", fake.Tags["myapp:prod"], res.FinalCommitID) + } +} + +// --- COPY wiring ------------------------------------------------------------ + +func TestBuild_CopyIssuesMkdirAndUpload(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nCOPY file.txt /dst/file.txt\n") + if _, err := Build(context.Background(), nil, opts); err != nil { + t.Fatal(err) + } + var sawMkdir, sawUpload bool + for _, c := range fake.Calls { + if c.Op == "Run" && len(c.Cmd) >= 2 && c.Cmd[0] == "mkdir" && c.Cmd[1] == "-p" { + sawMkdir = true + } + if c.Op == "Upload" && c.RemoteDst == "/dst/file.txt" && !c.Recursive { + sawUpload = true + } + } + if !sawMkdir { + t.Errorf("expected mkdir -p call, got ops: %v", fake.OpsOnly()) + } + if !sawUpload { + t.Errorf("expected Upload to /dst/file.txt, got calls:\n%+v", fake.Calls) + } +} + +func TestBuild_CopyWithChownRunsChown(t *testing.T) { + fake, opts, _ := buildHarness(t, "FROM scratch\nCOPY --chown=node:node file.txt /dst\n") + if _, err := Build(context.Background(), nil, opts); err != nil { + t.Fatal(err) + } + sawChown := false + for _, c := range fake.Calls { + if c.Op == "Run" && len(c.Cmd) > 0 && c.Cmd[0] == "chown" { + sawChown = true + if len(c.Cmd) < 3 || c.Cmd[2] != "node:node" { + t.Errorf("chown args wrong: %+v", c.Cmd) + } + } + } + if !sawChown { + t.Error("expected chown Run call") + } +} + +// --- helpers ---------------------------------------------------------------- + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/internal/services/builder/builder.go b/internal/services/builder/builder.go new file mode 100644 index 0000000..2cfb61c --- /dev/null +++ b/internal/services/builder/builder.go @@ -0,0 +1,607 @@ +// Package builder executes a parsed Dockerfile against a Vers VM. +// +// Strategy: +// +// 1. FROM creates the initial VM (either fresh via Executor.NewVM, or +// restored from a commit/tag via Executor.RestoreFromCommit). +// 2. Each subsequent instruction is executed against that VM. After the +// step succeeds we Executor.Commit to produce a "layer" commit id +// which is both the cache value and the parent for the next step's +// cache key. +// 3. Per-step cache keys combine (parent commit, normalized instruction, +// optional content hash). If a key hits, we skip execution and +// branch from the cached commit instead. +// 4. The final commit id is the build output. +// +// All remote work flows through the Executor interface. The production +// Executor is returned by NewRealExecutor; tests inject a fake. +// +// The build is stateful only on disk via .vers/buildcache.json. It owns a +// single VM for the life of the build and tears it down at the end +// (unless Options.Keep is set). +package builder + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/hdresearch/vers-cli/internal/app" + "github.com/hdresearch/vers-cli/internal/dockerfile" +) + +// Options controls a build. +type Options struct { + Instructions []dockerfile.Instruction + Context *BuildContext + BuildArgs map[string]string + + // FROM scratch machine sizing. Required when FROM scratch is used. + MemSizeMib int64 + VcpuCount int64 + FsSizeVmMib int64 + RootfsName string + KernelName string + + NoCache bool + Keep bool // if true, the builder VM is not deleted at the end + Tag string // optional vers tag to create on the final commit + + // Injected dependencies. If Exec is nil, Build uses the App-backed + // real executor. If Stderr is nil, a.IO.Err is used. + Exec Executor + Stderr io.Writer +} + +// Result is the outcome of a successful build. +type Result struct { + FinalCommitID string + BuilderVmID string + StepCount int + CachedCount int + Tag string + Cmd []string + Entrypoint []string + ExposedPorts []string + Labels map[string]string +} + +// state carries builder state across instructions. +type state struct { + env map[string]string + argVals map[string]string + declaredArgs map[string]bool + workdir string + user string + labels map[string]string + cmd []string + cmdShell string + entrypoint []string + entrypointShell string + exposed []string +} + +// Build runs the instructions and returns the final commit id. +// +// Progress messages go to opts.Stderr (or a.IO.Err if nil). RUN output goes +// to a.IO.Out / a.IO.Err so users see their build output as it streams. +func Build(ctx context.Context, a *app.App, opts Options) (*Result, error) { + // Wire injectable deps. In production both come from App; in tests a + // fake Executor and any Stderr is supplied directly, so `a` may be nil. + exec := opts.Exec + if exec == nil { + exec = NewRealExecutor(a) + } + progress := opts.Stderr + if progress == nil && a != nil { + progress = a.IO.Err + } + if progress == nil { + progress = io.Discard + } + runStdout, runStderr := io.Discard, io.Discard + if a != nil { + runStdout, runStderr = a.IO.Out, a.IO.Err + } + + if len(opts.Instructions) == 0 { + return nil, fmt.Errorf("empty Dockerfile: no instructions") + } + + // v1: reject multi-stage. + stages := 0 + for _, ins := range opts.Instructions { + if ins.Kind == dockerfile.KindFrom { + stages++ + } + } + if stages != 1 { + return nil, fmt.Errorf("multi-stage builds are not supported yet (found %d FROM instructions)", stages) + } + + first := opts.Instructions[0] + if first.Kind != dockerfile.KindFrom { + return nil, fmt.Errorf("Dockerfile must start with FROM") + } + + cache := LoadCache() + if opts.NoCache { + cache.Entries = map[string]string{} + } + + st := &state{ + env: map[string]string{}, + argVals: map[string]string{}, + declaredArgs: map[string]bool{}, + labels: map[string]string{}, + } + for k, v := range opts.BuildArgs { + st.argVals[k] = v + } + + // ---- FROM -------------------------------------------------------------- + var vmID, baseCommit string + var err error + vmID, baseCommit, err = doFrom(ctx, exec, first.From, opts) + if err != nil { + return nil, err + } + // Ensure teardown on mid-build failure. + defer func() { + if err != nil && !opts.Keep && vmID != "" { + // Best-effort. Use a fresh background ctx so a cancelled parent + // doesn't prevent cleanup. + _ = exec.DeleteVM(context.Background(), vmID) + } + }() + + fmt.Fprintf(progress, "Step 1/%d : %s\n", len(opts.Instructions), first.Raw) + if baseCommit != "" { + fmt.Fprintf(progress, " ---> using base commit %s\n", short(baseCommit)) + } else { + fmt.Fprintf(progress, " ---> built fresh VM %s\n", short(vmID)) + } + + parentCommit := baseCommit + res := &Result{StepCount: len(opts.Instructions)} + + // ---- Loop over remaining instructions --------------------------------- + for i := 1; i < len(opts.Instructions); i++ { + ins := opts.Instructions[i] + fmt.Fprintf(progress, "Step %d/%d : %s\n", i+1, len(opts.Instructions), ins.Raw) + + if handled, metaErr := applyMeta(st, ins); handled { + if metaErr != nil { + err = metaErr + return nil, err + } + fmt.Fprintf(progress, " ---> metadata\n") + continue + } + + key, _, kerr := cacheKeyFor(ins, st, opts.Context, parentCommit) + if kerr != nil { + err = kerr + return nil, err + } + + if !opts.NoCache { + if cached := cache.Get(key); cached != "" { + newVM, switchErr := switchToCommit(ctx, exec, vmID, cached) + if switchErr != nil { + fmt.Fprintf(progress, " ---> cache entry stale (%v), rebuilding\n", switchErr) + } else { + vmID = newVM + parentCommit = cached + res.CachedCount++ + fmt.Fprintf(progress, " ---> using cached layer %s\n", short(cached)) + continue + } + } + } + + if execErr := runStep(ctx, exec, vmID, st, ins, opts.Context, runStdout, runStderr); execErr != nil { + err = fmt.Errorf("step %d (%s): %w", i+1, ins.Kind, execErr) + return nil, err + } + + commitID, cerr := exec.Commit(ctx, vmID) + if cerr != nil { + err = fmt.Errorf("commit after step %d: %w", i+1, cerr) + return nil, err + } + parentCommit = commitID + cache.Put(key, parentCommit) + cache.Save() + fmt.Fprintf(progress, " ---> %s\n", short(parentCommit)) + } + + if parentCommit == "" { + // Only FROM was present — still produce a commit so the build has an output. + commitID, cerr := exec.Commit(ctx, vmID) + if cerr != nil { + err = fmt.Errorf("commit initial state: %w", cerr) + return nil, err + } + parentCommit = commitID + } + + res.FinalCommitID = parentCommit + res.Cmd = st.cmd + if len(st.cmd) == 0 && st.cmdShell != "" { + res.Cmd = []string{"sh", "-c", st.cmdShell} + } + res.Entrypoint = st.entrypoint + if len(st.entrypoint) == 0 && st.entrypointShell != "" { + res.Entrypoint = []string{"sh", "-c", st.entrypointShell} + } + res.Labels = st.labels + res.ExposedPorts = st.exposed + + if opts.Tag != "" { + if terr := exec.CreateTag(ctx, opts.Tag, parentCommit); terr != nil { + fmt.Fprintf(progress, "warning: failed to create tag %q: %v\n", opts.Tag, terr) + } else { + res.Tag = opts.Tag + } + } + + if opts.Keep { + res.BuilderVmID = vmID + } else { + if derr := exec.DeleteVM(context.Background(), vmID); derr != nil { + fmt.Fprintf(progress, "warning: failed to delete builder VM %s: %v\n", vmID, derr) + } + } + + return res, nil +} + +// doFrom materializes the base VM. Returns (vmID, baseCommitID). +// baseCommitID is "" when starting from scratch. +func doFrom(ctx context.Context, exec Executor, f *dockerfile.FromInstr, opts Options) (string, string, error) { + if f.As != "" { + return "", "", fmt.Errorf("FROM ... AS is not supported in single-stage v1") + } + if strings.EqualFold(f.Ref, "scratch") { + if opts.MemSizeMib == 0 || opts.VcpuCount == 0 || opts.FsSizeVmMib == 0 { + return "", "", fmt.Errorf("FROM scratch requires --mem-size, --vcpu-count, and --fs-size-vm-mib") + } + vmID, err := exec.NewVM(ctx, VMSpec{ + MemSizeMib: opts.MemSizeMib, + VcpuCount: opts.VcpuCount, + FsSizeVmMib: opts.FsSizeVmMib, + RootfsName: opts.RootfsName, + KernelName: opts.KernelName, + }) + if err != nil { + return "", "", fmt.Errorf("FROM scratch: %w", err) + } + return vmID, "", nil + } + + commitID := f.Ref + if resolved, ok := exec.ResolveTag(ctx, f.Ref); ok { + commitID = resolved + } + vmID, err := exec.RestoreFromCommit(ctx, commitID) + if err != nil { + return "", "", fmt.Errorf("FROM %s: %w", f.Ref, err) + } + return vmID, commitID, nil +} + +// switchToCommit restores a fresh VM from the cached commit and deletes +// the old one. Returns the new VM id. +func switchToCommit(ctx context.Context, exec Executor, oldVM, commitID string) (string, error) { + newVM, err := exec.RestoreFromCommit(ctx, commitID) + if err != nil { + return "", err + } + if oldVM != "" { + _ = exec.DeleteVM(ctx, oldVM) + } + return newVM, nil +} + +// applyMeta handles instructions that only update builder state, with no +// corresponding VM mutation. +func applyMeta(st *state, ins dockerfile.Instruction) (handled bool, err error) { + switch ins.Kind { + case dockerfile.KindArg: + st.declaredArgs[ins.Arg.Name] = true + if _, present := st.argVals[ins.Arg.Name]; !present && ins.Arg.HasDef { + st.argVals[ins.Arg.Name] = ins.Arg.Default + } + return true, nil + case dockerfile.KindEnv: + for _, kv := range ins.Env.Pairs { + st.env[kv.Key] = expand(kv.Value, st) + } + return true, nil + case dockerfile.KindLabel: + for _, kv := range ins.Label.Pairs { + st.labels[kv.Key] = expand(kv.Value, st) + } + return true, nil + case dockerfile.KindWorkdir: + w := expand(ins.Workdir.Value, st) + if filepath.IsAbs(w) || st.workdir == "" { + st.workdir = w + } else { + st.workdir = filepath.Join(st.workdir, w) + } + return true, nil + case dockerfile.KindUser: + st.user = expand(ins.User.Value, st) + return true, nil + case dockerfile.KindCmd: + st.cmd = ins.Cmd.Exec + st.cmdShell = ins.Cmd.Shell + return true, nil + case dockerfile.KindEntrypoint: + st.entrypoint = ins.Entrypoint.Exec + st.entrypointShell = ins.Entrypoint.Shell + return true, nil + case dockerfile.KindExpose: + st.exposed = append(st.exposed, ins.Expose.Ports...) + return true, nil + } + return false, nil +} + +// cacheKeyFor produces a cache key for a materializing instruction +// (RUN / COPY / ADD). Metadata instructions don't create layers. +func cacheKeyFor(ins dockerfile.Instruction, st *state, bc *BuildContext, parentCommit string) (string, []string, error) { + extras := metaExtras(st) + switch ins.Kind { + case dockerfile.KindRun: + cmd := runCommandLine(ins.Run, st) + return CacheKey(parentCommit, "RUN "+cmd, extras...), extras, nil + case dockerfile.KindCopy, dockerfile.KindAdd: + if ins.Copy.From != "" { + return "", nil, fmt.Errorf("COPY --from is not supported in v1") + } + hash, err := bc.HashSources(ins.Copy.Sources) + if err != nil { + return "", nil, err + } + extras = append(extras, "tree="+hash, "chown="+ins.Copy.Chown) + return CacheKey(parentCommit, ins.Raw, extras...), extras, nil + default: + return "", nil, fmt.Errorf("unhandled instruction %s", ins.Kind) + } +} + +// metaExtras produces a deterministic slice encoding the builder's env / +// workdir / user so future layer keys depend on them. +func metaExtras(st *state) []string { + var out []string + if st.workdir != "" { + out = append(out, "wd="+st.workdir) + } + if st.user != "" { + out = append(out, "user="+st.user) + } + keys := make([]string, 0, len(st.env)) + for k := range st.env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = append(out, "env="+k+"="+st.env[k]) + } + return out +} + +// runStep dispatches RUN and COPY/ADD execution against the live VM. +func runStep(ctx context.Context, exec Executor, vmID string, st *state, ins dockerfile.Instruction, bc *BuildContext, stdout, stderr io.Writer) error { + switch ins.Kind { + case dockerfile.KindRun: + return doRun(ctx, exec, vmID, st, ins.Run, stdout, stderr) + case dockerfile.KindCopy, dockerfile.KindAdd: + return doCopy(ctx, exec, vmID, st, ins.Copy, bc, stdout, stderr) + } + return fmt.Errorf("runStep: unhandled instruction %s", ins.Kind) +} + +func doRun(ctx context.Context, exec Executor, vmID string, st *state, r *dockerfile.RunInstr, stdout, stderr io.Writer) error { + cmd := runCommand(r, st) + code, err := exec.Run(ctx, vmID, cmd, copyMap(st.env), st.workdir, stdout, stderr) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("RUN exited with code %d", code) + } + return nil +} + +// runCommand materializes the actual argv passed to exec, respecting USER. +func runCommand(r *dockerfile.RunInstr, st *state) []string { + if len(r.Exec) > 0 { + return maybeWrapUser(r.Exec, st) + } + return maybeWrapUser([]string{"bash", "-c", r.Shell}, st) +} + +// runCommandLine is a flat, stable representation of the command for cache +// keys only. +func runCommandLine(r *dockerfile.RunInstr, st *state) string { + cmd := runCommand(r, st) + return strings.Join(cmd, "\x00") +} + +func maybeWrapUser(cmd []string, st *state) []string { + if st.user == "" { + return cmd + } + joined := utilsShellJoin(cmd) + return []string{"sudo", "-u", st.user, "bash", "-c", joined} +} + +func doCopy(ctx context.Context, exec Executor, vmID string, st *state, c *dockerfile.CopyInstr, bc *BuildContext, stdout, stderr io.Writer) error { + if c.From != "" { + return fmt.Errorf("COPY --from is not supported in v1") + } + dest := c.Dest + if !filepath.IsAbs(dest) { + base := st.workdir + if base == "" { + base = "/" + } + dest = filepath.Join(base, dest) + } + destIsDir := strings.HasSuffix(c.Dest, "/") || len(c.Sources) > 1 + + mkdirTarget := dest + if !destIsDir { + mkdirTarget = filepath.Dir(dest) + } + if mkdirTarget != "" && mkdirTarget != "/" { + if err := execSimple(ctx, exec, vmID, []string{"mkdir", "-p", mkdirTarget}, stdout, stderr); err != nil { + return fmt.Errorf("mkdir %s: %w", mkdirTarget, err) + } + } + + for _, srcSpec := range c.Sources { + entries, err := bc.ResolveSource(srcSpec) + if err != nil { + return err + } + if len(entries) == 0 { + return fmt.Errorf("no files matched source %q (all ignored?)", srcSpec) + } + srcAbs := filepath.Join(bc.Root, filepath.FromSlash(strings.TrimPrefix(srcSpec, "./"))) + st2, serr := os.Stat(srcAbs) + if serr != nil { + return serr + } + var remoteTarget string + if destIsDir { + remoteTarget = filepath.Join(dest, filepath.Base(srcAbs)) + } else { + remoteTarget = dest + } + if err := exec.Upload(ctx, vmID, srcAbs, remoteTarget, st2.IsDir()); err != nil { + return fmt.Errorf("upload %s: %w", srcSpec, err) + } + } + + if c.Chown != "" { + if err := execSimple(ctx, exec, vmID, []string{"chown", "-R", c.Chown, dest}, stdout, stderr); err != nil { + return fmt.Errorf("chown: %w", err) + } + } + return nil +} + +// execSimple runs a management command, discarding output but respecting +// exit code. Used for mkdir / chown inside COPY. +func execSimple(ctx context.Context, exec Executor, vmID string, cmd []string, stdout, stderr io.Writer) error { + code, err := exec.Run(ctx, vmID, cmd, nil, "", io.Discard, io.Discard) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("%s exited %d", strings.Join(cmd, " "), code) + } + return nil +} + +func copyMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// expand performs lightweight $VAR / ${VAR} substitution across ENV + build args. +func expand(s string, st *state) string { + if !strings.ContainsAny(s, "$") { + return s + } + lookup := func(name string) (string, bool) { + if v, ok := st.argVals[name]; ok && st.declaredArgs[name] { + return v, true + } + if v, ok := st.env[name]; ok { + return v, true + } + return "", false + } + var b strings.Builder + i := 0 + for i < len(s) { + if s[i] != '$' { + b.WriteByte(s[i]) + i++ + continue + } + if i+1 < len(s) && s[i+1] == '{' { + end := strings.IndexByte(s[i+2:], '}') + if end < 0 { + b.WriteByte(s[i]) + i++ + continue + } + name := s[i+2 : i+2+end] + v, _ := lookup(name) + b.WriteString(v) + i += 2 + end + 1 + continue + } + j := i + 1 + for j < len(s) && (isAlnum(s[j]) || s[j] == '_') { + j++ + } + if j == i+1 { + b.WriteByte(s[i]) + i++ + continue + } + name := s[i+1 : j] + v, _ := lookup(name) + b.WriteString(v) + i = j + } + return b.String() +} + +func isAlnum(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func short(id string) string { + if len(id) <= 12 { + return id + } + return id[:12] +} + +// utilsShellJoin locally quotes argv elements (so we don't pull in utils +// just for shell joining inside this package). +func utilsShellJoin(args []string) string { + var b strings.Builder + for i, a := range args { + if i > 0 { + b.WriteByte(' ') + } + if strings.ContainsAny(a, " \t\"'\\$`") { + b.WriteByte('\'') + b.WriteString(strings.ReplaceAll(a, "'", `'\''`)) + b.WriteByte('\'') + } else { + b.WriteString(a) + } + } + return b.String() +} diff --git a/internal/services/builder/builder_test.go b/internal/services/builder/builder_test.go new file mode 100644 index 0000000..1d687eb --- /dev/null +++ b/internal/services/builder/builder_test.go @@ -0,0 +1,319 @@ +package builder + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/hdresearch/vers-cli/internal/dockerfile" +) + +// newState constructs an empty builder state for tests. +func newState() *state { + return &state{ + env: map[string]string{}, + argVals: map[string]string{}, + declaredArgs: map[string]bool{}, + labels: map[string]string{}, + } +} + +// --- expand ----------------------------------------------------------------- + +func TestExpand(t *testing.T) { + st := newState() + st.env["FOO"] = "bar" + st.env["NAME"] = "app" + st.declaredArgs["VERSION"] = true + st.argVals["VERSION"] = "1.2.3" + + cases := map[string]string{ + "$FOO": "bar", + "${FOO}": "bar", + "$VERSION": "1.2.3", + "${VERSION}-${NAME}": "1.2.3-app", + "literal no vars": "literal no vars", + "$UNSET": "", + "prefix-$FOO-suffix": "prefix-bar-suffix", + "${NAME}_${VERSION}": "app_1.2.3", + "$": "$", // lone $ + "${unterminated": "${unterminated", + "$ foo": "$ foo", // $ with no identifier following + "trail-$NAME.txt": "trail-app.txt", + } + for in, want := range cases { + if got := expand(in, st); got != want { + t.Errorf("expand(%q): got %q, want %q", in, got, want) + } + } +} + +func TestExpand_UndeclaredArgNotUsed(t *testing.T) { + // Values in argVals are only applied if the ARG was declared in the + // Dockerfile (ARG NAME). Build-args passed in without a matching ARG + // should be ignored to match Docker's semantics. + st := newState() + st.argVals["SECRET"] = "nope" + if got := expand("$SECRET", st); got != "" { + t.Errorf("expected empty (undeclared ARG), got %q", got) + } +} + +// --- applyMeta -------------------------------------------------------------- + +func TestApplyMeta_EnvExpandsPriorVars(t *testing.T) { + st := newState() + st.env["ROOT"] = "/srv" + ins := dockerfile.Instruction{ + Kind: dockerfile.KindEnv, + Env: &dockerfile.KVInstr{Pairs: []dockerfile.KV{{Key: "APP_DIR", Value: "${ROOT}/app"}}}, + } + handled, err := applyMeta(st, ins) + if !handled || err != nil { + t.Fatalf("handled=%v err=%v", handled, err) + } + if st.env["APP_DIR"] != "/srv/app" { + t.Errorf("got %q", st.env["APP_DIR"]) + } +} + +func TestApplyMeta_Workdir(t *testing.T) { + st := newState() + // Absolute path sets directly. + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindWorkdir, Workdir: &dockerfile.StrInstr{Value: "/app"}}) + if st.workdir != "/app" { + t.Errorf("workdir=%q", st.workdir) + } + // Relative WORKDIR is joined with previous. + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindWorkdir, Workdir: &dockerfile.StrInstr{Value: "sub"}}) + if st.workdir != "/app/sub" { + t.Errorf("workdir=%q", st.workdir) + } +} + +func TestApplyMeta_ArgRespectsPreset(t *testing.T) { + st := newState() + st.argVals["V"] = "from-cli" + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindArg, Arg: &dockerfile.ArgInstr{Name: "V", Default: "from-df", HasDef: true}}) + if st.argVals["V"] != "from-cli" { + t.Errorf("expected CLI value to win, got %q", st.argVals["V"]) + } + if !st.declaredArgs["V"] { + t.Error("expected ARG to be marked declared") + } +} + +func TestApplyMeta_CmdAndEntrypoint(t *testing.T) { + st := newState() + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindCmd, Cmd: &dockerfile.ExecInstr{Exec: []string{"node", "server.js"}}}) + if !reflect.DeepEqual(st.cmd, []string{"node", "server.js"}) { + t.Errorf("cmd=%+v", st.cmd) + } + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindEntrypoint, Entrypoint: &dockerfile.ExecInstr{Shell: "/entry.sh"}}) + if st.entrypointShell != "/entry.sh" { + t.Errorf("entrypointShell=%q", st.entrypointShell) + } +} + +func TestApplyMeta_ExposeAccumulates(t *testing.T) { + st := newState() + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindExpose, Expose: &dockerfile.ExposeInstr{Ports: []string{"80", "443"}}}) + _, _ = applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindExpose, Expose: &dockerfile.ExposeInstr{Ports: []string{"8080/tcp"}}}) + if !reflect.DeepEqual(st.exposed, []string{"80", "443", "8080/tcp"}) { + t.Errorf("exposed=%+v", st.exposed) + } +} + +func TestApplyMeta_NotHandledForRun(t *testing.T) { + st := newState() + handled, _ := applyMeta(st, dockerfile.Instruction{Kind: dockerfile.KindRun, Run: &dockerfile.RunInstr{Shell: "true"}}) + if handled { + t.Error("RUN must not be treated as metadata") + } +} + +// --- runCommand / maybeWrapUser -------------------------------------------- + +func TestRunCommand_ShellForm(t *testing.T) { + st := newState() + got := runCommand(&dockerfile.RunInstr{Shell: "echo hi"}, st) + if !reflect.DeepEqual(got, []string{"bash", "-c", "echo hi"}) { + t.Errorf("got %+v", got) + } +} + +func TestRunCommand_ExecForm(t *testing.T) { + st := newState() + got := runCommand(&dockerfile.RunInstr{Exec: []string{"echo", "hi"}}, st) + if !reflect.DeepEqual(got, []string{"echo", "hi"}) { + t.Errorf("got %+v", got) + } +} + +func TestRunCommand_WrapsUser(t *testing.T) { + st := newState() + st.user = "node" + got := runCommand(&dockerfile.RunInstr{Shell: "whoami"}, st) + // Expect: sudo -u node bash -c "" + if len(got) < 5 || got[0] != "sudo" || got[1] != "-u" || got[2] != "node" || got[3] != "bash" || got[4] != "-c" { + t.Errorf("expected sudo wrap, got %+v", got) + } + inner := got[len(got)-1] + if !strings.Contains(inner, "whoami") { + t.Errorf("inner missing whoami: %q", inner) + } +} + +func TestRunCommand_ExecFormWrapsUser(t *testing.T) { + st := newState() + st.user = "1000:1000" + got := runCommand(&dockerfile.RunInstr{Exec: []string{"echo", "hello world"}}, st) + if got[0] != "sudo" || got[2] != "1000:1000" { + t.Errorf("got %+v", got) + } + // The shell-joined inner should quote "hello world" so it survives bash -c. + inner := got[len(got)-1] + if !strings.Contains(inner, "echo") || !strings.Contains(inner, "hello world") { + t.Errorf("inner missing quoted args: %q", inner) + } +} + +// --- cacheKeyFor ------------------------------------------------------------ + +func TestCacheKeyFor_RunChangesWithEnv(t *testing.T) { + ins := dockerfile.Instruction{Kind: dockerfile.KindRun, Run: &dockerfile.RunInstr{Shell: "echo hi"}, Raw: "RUN echo hi"} + st1 := newState() + k1, _, err := cacheKeyFor(ins, st1, nil, "parent") + if err != nil { + t.Fatal(err) + } + st2 := newState() + st2.env["FOO"] = "bar" + k2, _, err := cacheKeyFor(ins, st2, nil, "parent") + if err != nil { + t.Fatal(err) + } + if k1 == k2 { + t.Error("expected ENV delta to change cache key") + } +} + +func TestCacheKeyFor_RunChangesWithWorkdirAndUser(t *testing.T) { + ins := dockerfile.Instruction{Kind: dockerfile.KindRun, Run: &dockerfile.RunInstr{Shell: "ls"}, Raw: "RUN ls"} + base := newState() + k0, _, _ := cacheKeyFor(ins, base, nil, "p") + + wd := newState() + wd.workdir = "/app" + kwd, _, _ := cacheKeyFor(ins, wd, nil, "p") + + user := newState() + user.user = "node" + ku, _, _ := cacheKeyFor(ins, user, nil, "p") + + if k0 == kwd || k0 == ku || kwd == ku { + t.Errorf("expected distinct keys, got %s %s %s", k0, kwd, ku) + } +} + +func TestCacheKeyFor_CopyIncludesTreeHash(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "f.txt"), "one") + bc, err := LoadContext(dir) + if err != nil { + t.Fatal(err) + } + ins := dockerfile.Instruction{ + Kind: dockerfile.KindCopy, + Copy: &dockerfile.CopyInstr{Sources: []string{"f.txt"}, Dest: "/dst"}, + Raw: "COPY f.txt /dst", + } + k1, _, err := cacheKeyFor(ins, newState(), bc, "p") + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dir, "f.txt"), "two") + k2, _, err := cacheKeyFor(ins, newState(), bc, "p") + if err != nil { + t.Fatal(err) + } + if k1 == k2 { + t.Error("expected tree mutation to change cache key") + } +} + +func TestCacheKeyFor_CopyFromRejected(t *testing.T) { + bc, _ := LoadContext(t.TempDir()) + ins := dockerfile.Instruction{ + Kind: dockerfile.KindCopy, + Copy: &dockerfile.CopyInstr{Sources: []string{"x"}, Dest: "/dst", From: "builder"}, + Raw: "COPY --from=builder x /dst", + } + if _, _, err := cacheKeyFor(ins, newState(), bc, "p"); err == nil { + t.Error("expected error for COPY --from") + } +} + +// --- LayerCache ------------------------------------------------------------- + +func TestLayerCache_RoundTrip(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + if err := os.MkdirAll(".vers", 0755); err != nil { + t.Fatal(err) + } + c := LoadCache() + if len(c.Entries) != 0 { + t.Errorf("new cache should be empty, got %+v", c.Entries) + } + c.Put("k1", "commit-a") + c.Put("k2", "commit-b") + c.Save() + + raw, err := os.ReadFile(".vers/buildcache.json") + if err != nil { + t.Fatal(err) + } + var back LayerCache + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if back.Entries["k1"] != "commit-a" || back.Entries["k2"] != "commit-b" { + t.Errorf("round-trip mismatch: %+v", back.Entries) + } + + // Reload via LoadCache() + c2 := LoadCache() + if c2.Get("k1") != "commit-a" { + t.Errorf("LoadCache lost entry: %+v", c2.Entries) + } +} + +func TestLayerCache_NoVersDirIsNoOp(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + // No .vers/ — save must silently no-op. + c := LoadCache() + c.Put("k", "v") + c.Save() + if _, err := os.Stat(".vers/buildcache.json"); !os.IsNotExist(err) { + t.Errorf("expected no cache file without .vers/, got err=%v", err) + } + // In-memory state is still usable. + if c.Get("k") != "v" { + t.Error("in-memory Put should still work") + } +} + +func TestLayerCache_NilSafe(t *testing.T) { + var c *LayerCache + if c.Get("anything") != "" { + t.Error("nil.Get should return empty") + } + c.Put("a", "b") // must not panic + c.Save() // must not panic +} diff --git a/internal/services/builder/cache.go b/internal/services/builder/cache.go new file mode 100644 index 0000000..4ecccff --- /dev/null +++ b/internal/services/builder/cache.go @@ -0,0 +1,89 @@ +package builder + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// cacheFile is the on-disk location for the layer cache. Sibling to .vers/. +const cacheFile = ".vers/buildcache.json" + +// LayerCache maps a deterministic cache key to the commit id that resulted +// from executing that step. It is a best-effort cache: entries referring to +// commits that have been deleted server-side are transparently bypassed. +type LayerCache struct { + Entries map[string]string `json:"entries"` + path string +} + +// LoadCache reads the cache from .vers/buildcache.json (or returns an empty +// cache if missing). It is safe to call when the working directory is not a +// vers project — the returned cache simply never persists. +func LoadCache() *LayerCache { + c := &LayerCache{Entries: map[string]string{}, path: cacheFile} + data, err := os.ReadFile(cacheFile) + if err != nil { + return c + } + _ = json.Unmarshal(data, c) + if c.Entries == nil { + c.Entries = map[string]string{} + } + return c +} + +// Save persists the cache to disk. Failures are silently ignored — the cache +// is an optimization, not a correctness boundary. +func (c *LayerCache) Save() { + if c == nil || c.path == "" { + return + } + // Only persist if .vers/ exists — otherwise we're outside a vers project. + if _, err := os.Stat(".vers"); err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil { + return + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return + } + _ = os.WriteFile(c.path, data, 0644) +} + +// Get returns the cached commit id for a key, or "" if not present. +func (c *LayerCache) Get(key string) string { + if c == nil { + return "" + } + return c.Entries[key] +} + +// Put stores a cache entry. +func (c *LayerCache) Put(key, commitID string) { + if c == nil { + return + } + c.Entries[key] = commitID +} + +// CacheKey computes a stable cache key from the parent commit id, the +// normalized instruction text, and any side-inputs (e.g. a COPY tree hash). +// Extras are sorted before hashing to avoid ordering issues. +func CacheKey(parentCommit, instruction string, extras ...string) string { + sort.Strings(extras) + h := sha256.New() + fmt.Fprintf(h, "v1\n") + fmt.Fprintf(h, "parent:%s\n", parentCommit) + fmt.Fprintf(h, "instr:%s\n", instruction) + for _, e := range extras { + fmt.Fprintf(h, "extra:%s\n", e) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/services/builder/context.go b/internal/services/builder/context.go new file mode 100644 index 0000000..c527d4b --- /dev/null +++ b/internal/services/builder/context.go @@ -0,0 +1,229 @@ +package builder + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +// BuildContext represents the on-disk source tree that COPY reads from. +type BuildContext struct { + Root string // absolute path to the context root + Ignores []string // parsed .dockerignore patterns +} + +// LoadContext resolves root to an absolute path and reads .dockerignore if +// present. +func LoadContext(root string) (*BuildContext, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + st, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("build context %q: %w", root, err) + } + if !st.IsDir() { + return nil, fmt.Errorf("build context %q is not a directory", root) + } + ign, err := loadDockerIgnore(filepath.Join(abs, ".dockerignore")) + if err != nil { + return nil, err + } + return &BuildContext{Root: abs, Ignores: ign}, nil +} + +func loadDockerIgnore(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + var out []string + scan := bufio.NewScanner(f) + for scan.Scan() { + line := strings.TrimSpace(scan.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out, scan.Err() +} + +// IsIgnored reports whether a path (relative to the context root, using +// forward slashes) is ignored. It implements a subset of .dockerignore +// semantics: literal paths, `*` glob segments, and leading `!` negation. +// Directory prefix matching is honored so `node_modules` ignores the whole +// tree. +func (c *BuildContext) IsIgnored(rel string) bool { + rel = filepath.ToSlash(rel) + ignored := false + for _, pat := range c.Ignores { + neg := false + if strings.HasPrefix(pat, "!") { + neg = true + pat = pat[1:] + } + if matchIgnore(pat, rel) { + ignored = !neg + } + } + return ignored +} + +func matchIgnore(pattern, path string) bool { + pattern = strings.TrimPrefix(pattern, "./") + pattern = strings.TrimPrefix(pattern, "/") + // Exact or directory-prefix match for literal patterns without globs + if !strings.ContainsAny(pattern, "*?[") { + if pattern == path { + return true + } + if strings.HasPrefix(path, pattern+"/") { + return true + } + // Match any segment equal to the pattern (e.g. node_modules at any depth) + parts := strings.Split(path, "/") + for _, p := range parts { + if p == pattern { + return true + } + } + return false + } + // Glob fallback — filepath.Match on the full path. + ok, _ := filepath.Match(pattern, path) + if ok { + return true + } + // Try last segment + last := path + if i := strings.LastIndex(path, "/"); i >= 0 { + last = path[i+1:] + } + ok, _ = filepath.Match(pattern, last) + return ok +} + +// FileEntry is a resolved file or directory to be copied. +type FileEntry struct { + AbsPath string // on-disk absolute path + RelPath string // relative to source spec root (for preserving tree) + IsDir bool + Mode os.FileMode + Size int64 +} + +// ResolveSource expands one COPY/ADD source spec into file entries. +// The spec is relative to the context root; `..` escapes are rejected. +func (c *BuildContext) ResolveSource(spec string) ([]FileEntry, error) { + spec = filepath.ToSlash(strings.TrimPrefix(spec, "./")) + if spec == "" || strings.HasPrefix(spec, "/") || strings.Contains(spec, "..") { + return nil, fmt.Errorf("copy source %q must be a relative path inside the build context", spec) + } + abs := filepath.Join(c.Root, filepath.FromSlash(spec)) + // Ensure still inside root after join + rel, err := filepath.Rel(c.Root, abs) + if err != nil || strings.HasPrefix(rel, "..") { + return nil, fmt.Errorf("copy source %q escapes the build context", spec) + } + + st, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("copy source %q: %w", spec, err) + } + + var out []FileEntry + if !st.IsDir() { + if c.IsIgnored(filepath.ToSlash(rel)) { + return nil, nil + } + out = append(out, FileEntry{AbsPath: abs, RelPath: filepath.Base(abs), IsDir: false, Mode: st.Mode(), Size: st.Size()}) + return out, nil + } + + err = filepath.Walk(abs, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + r, err := filepath.Rel(abs, p) + if err != nil { + return err + } + if r == "." { + return nil + } + fullRel, err := filepath.Rel(c.Root, p) + if err != nil { + return err + } + if c.IsIgnored(filepath.ToSlash(fullRel)) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + out = append(out, FileEntry{ + AbsPath: p, + RelPath: r, + IsDir: info.IsDir(), + Mode: info.Mode(), + Size: info.Size(), + }) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].RelPath < out[j].RelPath }) + return out, nil +} + +// HashSources returns a stable sha256 of the file tree referenced by all +// sources. We hash (relPath, mode, size, contentHash) of every regular file +// so that any change busts the cache. +func (c *BuildContext) HashSources(sources []string) (string, error) { + h := sha256.New() + for _, src := range sources { + entries, err := c.ResolveSource(src) + if err != nil { + return "", err + } + fmt.Fprintf(h, "SRC %s\n", src) + for _, e := range entries { + if e.IsDir { + fmt.Fprintf(h, "D %s %o\n", filepath.ToSlash(e.RelPath), e.Mode.Perm()) + continue + } + sum, err := hashFile(e.AbsPath) + if err != nil { + return "", err + } + fmt.Fprintf(h, "F %s %o %d %s\n", filepath.ToSlash(e.RelPath), e.Mode.Perm(), e.Size, sum) + } + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/services/builder/context_test.go b/internal/services/builder/context_test.go new file mode 100644 index 0000000..d6010f5 --- /dev/null +++ b/internal/services/builder/context_test.go @@ -0,0 +1,100 @@ +package builder + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestDockerIgnore(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".dockerignore"), "node_modules\n*.log\n!important.log\n") + bc, err := LoadContext(dir) + if err != nil { + t.Fatal(err) + } + cases := map[string]bool{ + "src/main.go": false, + "node_modules": true, + "node_modules/foo/bar.js": true, + "a/b/node_modules/x": true, + "debug.log": true, + "important.log": false, + "src/ok.log": true, + } + for path, want := range cases { + if got := bc.IsIgnored(path); got != want { + t.Errorf("IsIgnored(%q): got %v want %v", path, got, want) + } + } +} + +func TestResolveSourceAndHash(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.txt"), "hello") + writeFile(t, filepath.Join(dir, "sub/b.txt"), "world") + writeFile(t, filepath.Join(dir, "sub/c.log"), "ignored") + writeFile(t, filepath.Join(dir, ".dockerignore"), "*.log\n") + + bc, err := LoadContext(dir) + if err != nil { + t.Fatal(err) + } + entries, err := bc.ResolveSource("sub") + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].RelPath != "b.txt" { + t.Errorf("got entries: %+v", entries) + } + + h1, err := bc.HashSources([]string{"a.txt", "sub"}) + if err != nil { + t.Fatal(err) + } + // Mutate a.txt → hash must change + writeFile(t, filepath.Join(dir, "a.txt"), "goodbye") + h2, err := bc.HashSources([]string{"a.txt", "sub"}) + if err != nil { + t.Fatal(err) + } + if h1 == h2 { + t.Errorf("expected hash to change after file mutation") + } +} + +func TestResolveSourceRejectsEscape(t *testing.T) { + dir := t.TempDir() + bc, err := LoadContext(dir) + if err != nil { + t.Fatal(err) + } + cases := []string{"../etc/passwd", "/absolute", "a/../../b"} + for _, c := range cases { + if _, err := bc.ResolveSource(c); err == nil { + t.Errorf("expected error for %q", c) + } + } +} + +func TestCacheKeyStability(t *testing.T) { + a := CacheKey("parent", "RUN echo hi", "env=FOO=bar", "wd=/app") + b := CacheKey("parent", "RUN echo hi", "wd=/app", "env=FOO=bar") // different order + if a != b { + t.Errorf("cache key not order-stable: %s vs %s", a, b) + } + c := CacheKey("parent2", "RUN echo hi", "env=FOO=bar", "wd=/app") + if a == c { + t.Errorf("cache key collides across parents") + } +} diff --git a/internal/services/builder/executor.go b/internal/services/builder/executor.go new file mode 100644 index 0000000..4eec8bf --- /dev/null +++ b/internal/services/builder/executor.go @@ -0,0 +1,56 @@ +package builder + +import ( + "context" + "io" +) + +// Executor is the narrow set of remote operations the build loop depends on. +// +// Everything the builder needs from the Vers backend goes through this +// interface: the builder itself does zero direct SDK / SSH / orchestrator +// calls. That lets us (a) unit-test the full build loop with a fake, and +// (b) swap the backend later (e.g. a local Firecracker executor) without +// touching build logic. +type Executor interface { + // NewVM creates a fresh VM per spec and waits until it's running. + // Used for FROM scratch. + NewVM(ctx context.Context, spec VMSpec) (vmID string, err error) + + // RestoreFromCommit creates a VM from an existing commit and waits + // until it's running. Used for FROM , cache hits, and + // post-cache VM switches. + RestoreFromCommit(ctx context.Context, commitID string) (vmID string, err error) + + // ResolveTag returns the commit id for a named tag, or ("", false) + // if the tag does not exist. Errors (network / auth) are collapsed + // into "not found" — the caller will treat the input as a commit id. + ResolveTag(ctx context.Context, name string) (commitID string, ok bool) + + // Run executes a command on the VM, streaming stdout and stderr to + // the provided writers. Returns the command's exit code. + Run(ctx context.Context, vmID string, cmd []string, env map[string]string, workdir string, stdout, stderr io.Writer) (exitCode int, err error) + + // Upload transfers a single local path (file or directory) to the VM. + // `recursive` must be true for directories. + Upload(ctx context.Context, vmID, localAbs, remote string, recursive bool) error + + // Commit snapshots the VM and returns the new commit id. + Commit(ctx context.Context, vmID string) (commitID string, err error) + + // CreateTag points a named tag at a commit. + CreateTag(ctx context.Context, name, commitID string) error + + // DeleteVM removes a VM. Errors are returned but should usually be + // surfaced as warnings by the caller (teardown is best-effort). + DeleteVM(ctx context.Context, vmID string) error +} + +// VMSpec is the sizing/config for a fresh VM (FROM scratch). +type VMSpec struct { + MemSizeMib int64 + VcpuCount int64 + FsSizeVmMib int64 + RootfsName string // optional + KernelName string // optional +} diff --git a/internal/services/builder/executor_fake.go b/internal/services/builder/executor_fake.go new file mode 100644 index 0000000..5fb732f --- /dev/null +++ b/internal/services/builder/executor_fake.go @@ -0,0 +1,255 @@ +package builder + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "sync" +) + +// FakeExecutor is an in-memory Executor for testing the build loop. +// +// It maintains a set of "VMs" (opaque string ids) and "commits" (opaque +// string ids), and records every call it receives so tests can assert on +// the exact sequence. Commands are handled by RunFunc (a matcher function +// supplied by the test); unconfigured commands succeed with exit code 0 +// unless RunFunc is set, in which case RunFunc decides. +// +// The fake is safe for serial use only — the builder runs single-threaded +// so we don't attempt real concurrency hardening. +type FakeExecutor struct { + mu sync.Mutex + + // State + NextVMNum int + NextCommitNum int + LiveVMs map[string]bool + Commits map[string]bool // commits that exist server-side + Tags map[string]string + // CommitParent[new] = old; so we can reason about lineage. + CommitParent map[string]string + // VMBase[vmID] = commit it was restored from ("" for scratch). + VMBase map[string]string + + // Call log, newest last. + Calls []Call + + // Knobs + RunFunc func(cmd []string, env map[string]string, workdir string) (exitCode int, stdout, stderr string, err error) + UploadFunc func(vmID, local, remote string, recursive bool) error + FailNextCommit bool + FailNextRestore bool + MissingCommits map[string]bool // commits RestoreFromCommit rejects as missing +} + +// Call is a single recorded executor call. +type Call struct { + Op string // "NewVM", "RestoreFromCommit", "Run", "Upload", "Commit", "DeleteVM", "CreateTag", "ResolveTag" + VmID string + CommitID string + TagName string + Cmd []string + Env map[string]string + Workdir string + LocalSrc string + RemoteDst string + Recursive bool + Spec VMSpec +} + +// NewFake returns an empty FakeExecutor. +func NewFake() *FakeExecutor { + return &FakeExecutor{ + LiveVMs: map[string]bool{}, + Commits: map[string]bool{}, + Tags: map[string]string{}, + CommitParent: map[string]string{}, + VMBase: map[string]string{}, + MissingCommits: map[string]bool{}, + } +} + +// Seed creates a pre-existing commit (used to simulate FROM ). +func (f *FakeExecutor) Seed(commitID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.Commits[commitID] = true +} + +// SeedTag associates a tag with a pre-existing commit. +func (f *FakeExecutor) SeedTag(tag, commitID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.Tags[tag] = commitID + f.Commits[commitID] = true +} + +// OpsOnly returns the call log projected to just the Op names, which is +// the most common assertion in tests. +func (f *FakeExecutor) OpsOnly() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, len(f.Calls)) + for i, c := range f.Calls { + out[i] = c.Op + } + return out +} + +// CountOp returns how many times a given op appeared. +func (f *FakeExecutor) CountOp(name string) int { + n := 0 + for _, o := range f.OpsOnly() { + if o == name { + n++ + } + } + return n +} + +// CommitOrder returns the sequence of commit ids issued via Commit(), in +// order. Useful for verifying the layer chain. +func (f *FakeExecutor) CommitOrder() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.Calls { + if c.Op == "Commit" && c.CommitID != "" { + out = append(out, c.CommitID) + } + } + return out +} + +// -- Executor ------------------------------------------------------------- + +func (f *FakeExecutor) NewVM(ctx context.Context, spec VMSpec) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.NextVMNum++ + id := fmt.Sprintf("vm-%d", f.NextVMNum) + f.LiveVMs[id] = true + f.VMBase[id] = "" + f.Calls = append(f.Calls, Call{Op: "NewVM", VmID: id, Spec: spec}) + return id, nil +} + +func (f *FakeExecutor) RestoreFromCommit(ctx context.Context, commitID string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.FailNextRestore { + f.FailNextRestore = false + f.Calls = append(f.Calls, Call{Op: "RestoreFromCommit", CommitID: commitID}) + return "", errors.New("restore failed (injected)") + } + if f.MissingCommits[commitID] { + f.Calls = append(f.Calls, Call{Op: "RestoreFromCommit", CommitID: commitID}) + return "", fmt.Errorf("commit %s not found", commitID) + } + f.NextVMNum++ + id := fmt.Sprintf("vm-%d", f.NextVMNum) + f.LiveVMs[id] = true + f.VMBase[id] = commitID + f.Commits[commitID] = true + f.Calls = append(f.Calls, Call{Op: "RestoreFromCommit", CommitID: commitID, VmID: id}) + return id, nil +} + +func (f *FakeExecutor) ResolveTag(ctx context.Context, name string) (string, bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.Calls = append(f.Calls, Call{Op: "ResolveTag", TagName: name}) + c, ok := f.Tags[name] + return c, ok +} + +func (f *FakeExecutor) Run(ctx context.Context, vmID string, cmd []string, env map[string]string, workdir string, stdout, stderr io.Writer) (int, error) { + f.mu.Lock() + if !f.LiveVMs[vmID] { + f.mu.Unlock() + return -1, fmt.Errorf("run against dead VM %q", vmID) + } + // Copy env into a sorted map snapshot for test determinism. + envCopy := map[string]string{} + for k, v := range env { + envCopy[k] = v + } + cmdCopy := append([]string(nil), cmd...) + f.Calls = append(f.Calls, Call{Op: "Run", VmID: vmID, Cmd: cmdCopy, Env: envCopy, Workdir: workdir}) + runFn := f.RunFunc + f.mu.Unlock() + + if runFn == nil { + return 0, nil + } + code, out, errOut, err := runFn(cmdCopy, envCopy, workdir) + if out != "" { + _, _ = io.WriteString(stdout, out) + } + if errOut != "" { + _, _ = io.WriteString(stderr, errOut) + } + return code, err +} + +func (f *FakeExecutor) Upload(ctx context.Context, vmID, local, remote string, recursive bool) error { + f.mu.Lock() + defer f.mu.Unlock() + if !f.LiveVMs[vmID] { + return fmt.Errorf("upload against dead VM %q", vmID) + } + f.Calls = append(f.Calls, Call{Op: "Upload", VmID: vmID, LocalSrc: local, RemoteDst: remote, Recursive: recursive}) + if f.UploadFunc != nil { + return f.UploadFunc(vmID, local, remote, recursive) + } + return nil +} + +func (f *FakeExecutor) Commit(ctx context.Context, vmID string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.FailNextCommit { + f.FailNextCommit = false + f.Calls = append(f.Calls, Call{Op: "Commit", VmID: vmID}) + return "", errors.New("commit failed (injected)") + } + if !f.LiveVMs[vmID] { + return "", fmt.Errorf("commit against dead VM %q", vmID) + } + f.NextCommitNum++ + id := fmt.Sprintf("c-%d", f.NextCommitNum) + f.Commits[id] = true + f.CommitParent[id] = f.VMBase[vmID] + f.Calls = append(f.Calls, Call{Op: "Commit", VmID: vmID, CommitID: id}) + return id, nil +} + +func (f *FakeExecutor) CreateTag(ctx context.Context, name, commitID string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.Calls = append(f.Calls, Call{Op: "CreateTag", TagName: name, CommitID: commitID}) + f.Tags[name] = commitID + return nil +} + +func (f *FakeExecutor) DeleteVM(ctx context.Context, vmID string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.Calls = append(f.Calls, Call{Op: "DeleteVM", VmID: vmID}) + delete(f.LiveVMs, vmID) + return nil +} + +// LiveVMList returns the currently-live VM ids, sorted. +func (f *FakeExecutor) LiveVMList() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, 0, len(f.LiveVMs)) + for id := range f.LiveVMs { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/internal/services/builder/executor_real.go b/internal/services/builder/executor_real.go new file mode 100644 index 0000000..216c4ce --- /dev/null +++ b/internal/services/builder/executor_real.go @@ -0,0 +1,115 @@ +package builder + +import ( + "context" + "fmt" + "io" + + "github.com/hdresearch/vers-cli/internal/app" + delsvc "github.com/hdresearch/vers-cli/internal/services/deletion" + vmSvc "github.com/hdresearch/vers-cli/internal/services/vm" + sshutil "github.com/hdresearch/vers-cli/internal/ssh" + "github.com/hdresearch/vers-cli/internal/utils" + vers "github.com/hdresearch/vers-sdk-go" +) + +// realExecutor is the production Executor backed by the Vers SDK + SSH. +type realExecutor struct { + client *vers.Client +} + +// NewRealExecutor returns an Executor that drives a live Vers backend via +// the SDK client held by the App container. +func NewRealExecutor(a *app.App) Executor { + return &realExecutor{client: a.Client} +} + +func (e *realExecutor) NewVM(ctx context.Context, spec VMSpec) (string, error) { + cfg := vers.NewRootRequestVmConfigParam{ + MemSizeMib: vers.F(spec.MemSizeMib), + VcpuCount: vers.F(spec.VcpuCount), + FsSizeMib: vers.F(spec.FsSizeVmMib), + } + if spec.RootfsName != "" { + cfg.ImageName = vers.F(spec.RootfsName) + } + if spec.KernelName != "" { + cfg.KernelName = vers.F(spec.KernelName) + } + resp, err := e.client.Vm.NewRoot(ctx, vers.VmNewRootParams{ + NewRootRequest: vers.NewRootRequestParam{VmConfig: vers.F(cfg)}, + }) + if err != nil { + return "", err + } + if err := utils.WaitForRunning(ctx, e.client, resp.VmID); err != nil { + return resp.VmID, err + } + return resp.VmID, nil +} + +func (e *realExecutor) RestoreFromCommit(ctx context.Context, commitID string) (string, error) { + resp, err := e.client.Vm.RestoreFromCommit(ctx, vers.VmRestoreFromCommitParams{ + VmFromCommitRequest: vers.VmFromCommitRequestParam{CommitID: vers.F(commitID)}, + }) + if err != nil { + return "", err + } + if err := utils.WaitForRunning(ctx, e.client, resp.VmID); err != nil { + return resp.VmID, err + } + return resp.VmID, nil +} + +func (e *realExecutor) ResolveTag(ctx context.Context, name string) (string, bool) { + tag, err := e.client.CommitTags.Get(ctx, name) + if err != nil || tag == nil || tag.CommitID == "" { + return "", false + } + return tag.CommitID, true +} + +func (e *realExecutor) Run(ctx context.Context, vmID string, cmd []string, env map[string]string, workdir string, stdout, stderr io.Writer) (int, error) { + body, err := vmSvc.ExecStream(ctx, vmID, vmSvc.ExecRequest{ + Command: cmd, + Env: env, + WorkingDir: workdir, + }) + if err != nil { + return -1, err + } + defer body.Close() + return streamOutput(body, stdout, stderr) +} + +func (e *realExecutor) Upload(ctx context.Context, vmID, localAbs, remote string, recursive bool) error { + info, err := vmSvc.GetConnectInfo(ctx, e.client, vmID) + if err != nil { + return fmt.Errorf("connect info: %w", err) + } + c := sshutil.NewClient(info.Host, info.KeyPath, info.VMDomain) + return c.Upload(ctx, localAbs, remote, recursive) +} + +func (e *realExecutor) Commit(ctx context.Context, vmID string) (string, error) { + resp, err := e.client.Vm.Commit(ctx, vmID, vers.VmCommitParams{}) + if err != nil { + return "", err + } + return resp.CommitID, nil +} + +func (e *realExecutor) CreateTag(ctx context.Context, name, commitID string) error { + _, err := e.client.CommitTags.New(ctx, vers.CommitTagNewParams{ + CreateTagRequest: vers.CreateTagRequestParam{ + TagName: vers.F(name), + CommitID: vers.F(commitID), + }, + }) + return err +} + +func (e *realExecutor) DeleteVM(ctx context.Context, vmID string) error { + _, err := delsvc.DeleteVM(ctx, e.client, vmID) + return err +} diff --git a/internal/services/builder/stream.go b/internal/services/builder/stream.go new file mode 100644 index 0000000..bb760df --- /dev/null +++ b/internal/services/builder/stream.go @@ -0,0 +1,64 @@ +package builder + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +// streamResponse mirrors the orchestrator's NDJSON exec stream format. +type streamResponse struct { + Type string `json:"type"` + Stream string `json:"stream,omitempty"` + DataB64 string `json:"data_b64,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// streamOutput consumes an exec NDJSON stream, writing stdout/stderr and +// returning the exit code. Identical in semantics to the handler-local +// version in internal/handlers/execute.go; duplicated here to keep the +// builder package dependency-free of the handler tree. +func streamOutput(body io.Reader, stdout, stderr io.Writer) (int, error) { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + exitCode := 0 + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var r streamResponse + if err := json.Unmarshal(line, &r); err != nil { + continue + } + switch r.Type { + case "chunk": + data, err := base64.StdEncoding.DecodeString(r.DataB64) + if err != nil { + continue + } + switch r.Stream { + case "stdout": + _, _ = stdout.Write(data) + case "stderr": + _, _ = stderr.Write(data) + } + case "exit": + if r.ExitCode != nil { + exitCode = *r.ExitCode + } + return exitCode, nil + case "error": + return 1, fmt.Errorf("exec error [%s]: %s", r.Code, r.Message) + } + } + if err := scanner.Err(); err != nil { + return 1, fmt.Errorf("stream read: %w", err) + } + return exitCode, nil +}