Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
101 changes: 92 additions & 9 deletions runtime/docker/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package docker

import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"

"github.com/moby/moby/api/types/build"
"github.com/moby/moby/client"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Comment thread
dap-code-review-by-crunchloop[bot] marked this conversation as resolved.
Resolved
}
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.
Expand Down
37 changes: 37 additions & 0 deletions runtime/docker/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
35 changes: 35 additions & 0 deletions test/integration/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading