diff --git a/CHANGELOG.md b/CHANGELOG.md index 68adca8..292fd04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 discovered `devcontainer.json`'s directory for local features and the compose working directory. Previously they always assumed `.devcontainer/`, which was wrong for a root `.devcontainer.json`. +- **runtime/docker** — a user Dockerfile that names a frontend with + `# syntax=` (or the `BUILDKIT_SYNTAX` build arg) no longer fails with + `failed to resolve source metadata for docker.io/docker/dockerfile:1: + no active sessions` on Docker Engine before 29.6. `BuildImage` builds + without a BuildKit session, and BuildKit before v0.31 refuses registry + access without one. The frontend image is now pre-pulled through the + classic pull API, as `FROM` images already were. The directive is read + the way BuildKit's parser reads it. (#148) ## [0.5.3] - 2026-10-28 diff --git a/runtime/docker/build.go b/runtime/docker/build.go index 3daf3d9..a679666 100644 --- a/runtime/docker/build.go +++ b/runtime/docker/build.go @@ -2,6 +2,7 @@ package docker import ( "archive/tar" + "bytes" "context" "encoding/json" "fmt" @@ -9,8 +10,10 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strings" "time" + "unicode" "github.com/moby/moby/api/types/build" "github.com/moby/moby/client" @@ -258,20 +261,32 @@ func tarDirectory(dir string, w io.Writer) error { } // prePullBaseImages reads the Dockerfile at contextPath/dockerfile, -// extracts FROM image references (resolving simple $ARG substitutions -// against ARG defaults in the file and the spec's BuildArgs -// overrides), and pulls each that isn't already in the local image -// store. Best-effort: any pull error is silently ignored — the -// subsequent ImageBuild surfaces a clearer "failed to resolve" error -// if the image really can't be obtained, and images that exist only -// locally (e.g. dc-go-base-*) correctly skip the pull attempt because -// ImageInspect finds them. +// extracts its frontend image (a `# syntax=` directive or the +// BUILDKIT_SYNTAX build arg) and FROM image references (resolving +// simple $ARG substitutions against ARG defaults in the file and the +// spec's BuildArgs overrides), and pulls each that isn't already in +// the local image store. The frontend needs it as much as FROM +// images: BuildKit resolves it from the registry before parsing the +// Dockerfile, under the same session requirement. Best-effort: any +// pull error is silently ignored — the subsequent ImageBuild surfaces +// a clearer "failed to resolve" error if the image really can't be +// obtained, and images that exist only locally (e.g. dc-go-base-*) +// correctly skip the pull attempt because ImageInspect finds them. +// +// BuildKit releases since v0.31 (Docker Engine 29.6) fall back to +// anonymous registry access when no session is attached, so this only +// changes the outcome on older engines; there it is what makes public +// images resolvable at all. func prePullBaseImages(ctx context.Context, r *Runtime, contextPath, dockerfile string, buildArgs map[string]string, events chan<- runtime.BuildEvent) { df, err := os.ReadFile(filepath.Join(contextPath, dockerfile)) if err != nil { return } - for _, ref := range extractBaseImages(string(df), buildArgs) { + refs := extractBaseImages(string(df), buildArgs) + if frontend := syntaxFrontend(df, buildArgs); frontend != "" { + refs = append([]string{frontend}, refs...) + } + for _, ref := range refs { if _, err := r.api.ImageInspect(ctx, ref); err == nil { continue } @@ -358,6 +373,74 @@ func extractBaseImages(content string, buildArgs map[string]string) []string { return out } +// syntaxFrontend returns the frontend image BuildKit will run for a +// Dockerfile, or "" for the built-in one. The BUILDKIT_SYNTAX build +// arg wins over the file; otherwise the `syntax` parser directive is +// read the way BuildKit's parser.DetectSyntax reads it: +// +// - after an optional UTF-8 BOM and a leading `#!` line; +// - only from the leading run of directive lines, each `#` (or `//`) +// at column 0 followed by `key = value` with key one of syntax, +// escape, check (case-insensitive) — the first other line ends the +// run; +// - the image is the value's first word (the rest is frontend +// arguments); +// - failing both comment forms, a file that is a JSON object with a +// string "syntax" member names the image with the whole value (the +// input format of custom frontends that take JSON). +func syntaxFrontend(content []byte, buildArgs map[string]string) string { + if v, ok := buildArgs["BUILDKIT_SYNTAX"]; ok { + return firstWord(v) + } + content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) + if first, rest, _ := bytes.Cut(content, []byte("\n")); bytes.HasPrefix(first, []byte("#!")) { + content = rest + } + for _, comment := range []string{"#", "//"} { + if v, ok := directive(content, comment, "syntax"); ok { + return firstWord(v) + } + } + var doc map[string]any + if json.Unmarshal(content, &doc) == nil { + if v, ok := doc["syntax"].(string); ok { + return v + } + } + return "" +} + +// directiveLine matches the body of a parser directive line. +var directiveLine = regexp.MustCompile(`^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*(.+?)\s*$`) + +// directive returns the value of the parser directive key in content's +// leading directive lines written with the given comment prefix. +func directive(content []byte, comment, key string) (string, bool) { + for _, line := range strings.Split(string(content), "\n") { + body, ok := strings.CutPrefix(strings.TrimSuffix(line, "\r"), comment) + if !ok { + return "", false + } + m := directiveLine.FindStringSubmatch(strings.TrimLeftFunc(body, unicode.IsSpace)) + if m == nil { + return "", false + } + switch name := strings.ToLower(m[1]); name { + case key: + return m[2], true + case "syntax", "escape", "check": + default: + return "", false + } + } + return "", false +} + +func firstWord(s string) string { + w, _, _ := strings.Cut(strings.TrimSpace(s), " ") + return w +} + // indexFoldWord returns the byte index of the first occurrence of // `word` in s as a standalone whitespace-delimited token, matching // case-insensitively. Returns -1 if not found. diff --git a/runtime/docker/build_test.go b/runtime/docker/build_test.go index 3b24f41..f5c9899 100644 --- a/runtime/docker/build_test.go +++ b/runtime/docker/build_test.go @@ -203,3 +203,40 @@ func TestSubstituteArgs(t *testing.T) { } } } + +func TestSyntaxFrontend(t *testing.T) { + cases := []struct { + name string + dockerfile string + buildArgs map[string]string + want string + }{ + {"none", "FROM alpine:3.20\n", nil, ""}, + {"directive", "# syntax=docker/dockerfile:1\nFROM alpine:3.20\n", nil, "docker/dockerfile:1"}, + {"spaced and uppercase key", "# SYNTAX = docker/dockerfile:1.7-labs \nFROM alpine\n", nil, "docker/dockerfile:1.7-labs"}, + {"frontend arguments", "# syntax=registry.example.com/fe:1 --opt=x\nFROM alpine\n", nil, "registry.example.com/fe:1"}, + {"after other directives", "# escape=`\n# check=skip=all\n# syntax=docker/dockerfile:1\nFROM alpine\n", nil, "docker/dockerfile:1"}, + {"after shebang", "#!/usr/bin/env buildctl\n# syntax=docker/dockerfile:1\nFROM alpine\n", nil, "docker/dockerfile:1"}, + {"after BOM", "\xef\xbb\xbf# syntax=docker/dockerfile:1\nFROM alpine\n", nil, "docker/dockerfile:1"}, + {"CRLF", "# syntax=docker/dockerfile:1\r\nFROM alpine\r\n", nil, "docker/dockerfile:1"}, + {"c-style comment", "// syntax=docker/dockerfile:1\nFROM alpine\n", nil, "docker/dockerfile:1"}, + {"after a plain comment", "# build the app\n# syntax=docker/dockerfile:1\nFROM alpine\n", nil, ""}, + {"after a blank line", "\n# syntax=docker/dockerfile:1\nFROM alpine\n", nil, ""}, + {"indented", " # syntax=docker/dockerfile:1\nFROM alpine\n", nil, ""}, + {"after an instruction", "FROM alpine\n# syntax=docker/dockerfile:1\n", nil, ""}, + {"build arg wins", "# syntax=docker/dockerfile:1\nFROM alpine\n", map[string]string{"BUILDKIT_SYNTAX": " docker/dockerfile:1.9 --x"}, "docker/dockerfile:1.9"}, + {"build arg without directive", "FROM alpine\n", map[string]string{"BUILDKIT_SYNTAX": "docker/dockerfile:1"}, "docker/dockerfile:1"}, + {"json", "{\"syntax\": \"registry.example.com/fe:2\", \"stages\": []}\n", nil, "registry.example.com/fe:2"}, + {"json after shebang", "#!/usr/bin/env x\n{\"syntax\": \"registry.example.com/fe:2\"}", nil, "registry.example.com/fe:2"}, + {"json without syntax", "{\"stages\": []}", nil, ""}, + {"json non-string syntax", "{\"syntax\": 1}", nil, ""}, + {"comment directive wins over json", "# syntax=docker/dockerfile:1\n{\"syntax\": \"x\"}", nil, "docker/dockerfile:1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := syntaxFrontend([]byte(tc.dockerfile), tc.buildArgs); got != tc.want { + t.Errorf("syntaxFrontend = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/test/integration/build_test.go b/test/integration/build_test.go index d809f05..30f2564 100644 --- a/test/integration/build_test.go +++ b/test/integration/build_test.go @@ -149,6 +149,41 @@ func TestEngineBuild_BuildSource_NoFeatures(t *testing.T) { t.Cleanup(func() { _ = rt.RemoveImage(context.Background(), tag) }) } +// TestEngineBuild_BuildSource_SyntaxDirective: a user Dockerfile that +// names a frontend image with `# syntax=` and uses syntax only that +// frontend parses (COPY --chmod, RUN --mount). On Docker Engine +// before 29.6, BuildKit cannot resolve the frontend from the registry +// without a session (#148); the engine pre-pulls it. +func TestEngineBuild_BuildSource_SyntaxDirective(t *testing.T) { + if testing.Short() { + t.Skip() + } + + eng, rt := newEngine(t) + defer rt.Close() + + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "Dockerfile"), "# syntax=docker/dockerfile:1\n"+ + "FROM "+testImage+"\n"+ + "COPY --chmod=0755 hello.sh /usr/local/bin/hello\n"+ + "RUN --mount=type=tmpfs,target=/scratch hello > /etc/marker\n") + mustWrite(t, filepath.Join(dir, "hello.sh"), "#!/bin/sh\necho syntax-directive\n") + mustWrite(t, filepath.Join(dir, ".devcontainer", "devcontainer.json"), + `{"build": {"dockerfile": "Dockerfile", "context": ".."}}`) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + const tag = "dc-go-build-test-syntax:v1" + if _, err := eng.Build(ctx, devcontainer.BuildOptions{ + LocalWorkspaceFolder: dir, + ImageName: tag, + }); err != nil { + t.Fatalf("Build: %v", err) + } + t.Cleanup(func() { _ = rt.RemoveImage(context.Background(), tag) }) +} + // TestEngineBuild_ComposeRefused: compose sources are refused with a // clear error rather than silently doing nothing. func TestEngineBuild_ComposeRefused(t *testing.T) {