From 7a81075b7879e7a999c25321e5266a98559ae819 Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:22:17 +0000 Subject: [PATCH 1/2] fix(docker): pre-pull the Dockerfile syntax frontend image BuildImage builds through ImageBuild with no BuildKit session, and BuildKit before v0.31 (Docker Engine before 29.6) refuses registry access without one, even for anonymous public pulls: a bearer challenge with no session fails with "no active sessions". FROM images were already pre-pulled through the classic pull API for this reason; a `# syntax=` frontend was not, so any user Dockerfile naming one failed before parsing (#148). Pre-pull the frontend too: the BUILDKIT_SYNTAX build arg, else the syntax parser directive, read as BuildKit's parser.DetectSyntax reads it (leading directive lines only, # or // comments, after an optional BOM and shebang; the image is the value's first word). Verified against a Docker 29.5.0 daemon (containerd image store): the new integration test fails there with the reported error without this change and passes with it. Docker 29.6+ resolves anonymously without a session, so it passes there either way. Fixes #148. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ runtime/docker/build.go | 95 ++++++++++++++++++++++++++++++---- runtime/docker/build_test.go | 32 ++++++++++++ test/integration/build_test.go | 35 +++++++++++++ 4 files changed, 161 insertions(+), 9 deletions(-) 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..de3278f 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,68 @@ 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). +// +// DetectSyntax also accepts a JSON document with a "syntax" key; +// that is not a Dockerfile and is left out. +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) + } + } + 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..9ae6372 100644 --- a/runtime/docker/build_test.go +++ b/runtime/docker/build_test.go @@ -203,3 +203,35 @@ 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"}, + } + 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) { From 8c50252782a02a0ee10be1116876ca0bbb91ea30 Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:02:38 +0000 Subject: [PATCH 2/2] fix(docker): pre-pull a frontend named by a JSON syntax document BuildKit's parser.DetectSyntax falls back, after the # and // directive forms, to a JSON object with a string "syntax" member, the input of custom frontends that take JSON, and uses the whole value as the frontend image. syntaxFrontend left that form out, so its frontend was not pre-pulled and hit the session-less resolution failure on engines before 29.6 (DAP review finding 3891 on #150). Co-Authored-By: Claude Opus 5.5 --- runtime/docker/build.go | 14 ++++++++++---- runtime/docker/build_test.go | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/runtime/docker/build.go b/runtime/docker/build.go index de3278f..a679666 100644 --- a/runtime/docker/build.go +++ b/runtime/docker/build.go @@ -384,10 +384,10 @@ func extractBaseImages(content string, buildArgs map[string]string) []string { // escape, check (case-insensitive) — the first other line ends the // run; // - the image is the value's first word (the rest is frontend -// arguments). -// -// DetectSyntax also accepts a JSON document with a "syntax" key; -// that is not a Dockerfile and is left out. +// 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) @@ -401,6 +401,12 @@ func syntaxFrontend(content []byte, buildArgs map[string]string) string { return firstWord(v) } } + var doc map[string]any + if json.Unmarshal(content, &doc) == nil { + if v, ok := doc["syntax"].(string); ok { + return v + } + } return "" } diff --git a/runtime/docker/build_test.go b/runtime/docker/build_test.go index 9ae6372..f5c9899 100644 --- a/runtime/docker/build_test.go +++ b/runtime/docker/build_test.go @@ -226,6 +226,11 @@ func TestSyntaxFrontend(t *testing.T) { {"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) {