Merge pull request #7344 from tonistiigi/dockerfile-reset

dockerfile: add `--unset`, `FROM --reset` and `BUILDKIT_DISABLE_ONBUILD`
This commit is contained in:
Tõnis Tiigi authored and GitHub committed 2026-11-04 09:49:10 -08:00
commit 30785d7cd4
19 files changed
+1132 -5

No files matched your search

+15
View File
@@ -61,6 +61,21 @@ func addEnvf(key, value string, replace bool, v ...any) StateOption {
}
}
// DelEnv returns a [StateOption] which removes an environment variable from the state.
// This is the equivalent of [State.DelEnv]
func DelEnv(key string) StateOption {
return func(s State) State {
return s.withValue(keyEnv, func(ctx context.Context, c *Constraints) (any, error) {
env, err := getEnv(s)(ctx, c)
if err != nil {
return nil, err
}
deleted := env.Delete(key)
return &deleted, nil
})
}
}
// Dir returns a [StateOption] sets the working directory for the state which will be used to resolve
// relative paths as well as the working directory for [State.Run].
// See [State.With] for where to use this.
+6
View File
@@ -326,6 +326,12 @@ func (s State) AddEnvf(key, value string, v ...any) State {
return AddEnvf(key, value, v...)(s)
}
// DelEnv returns a new [State] with the provided environment variable removed.
// See [DelEnv]
func (s State) DelEnv(key string) State {
return DelEnv(key)(s)
}
// Dir returns a new [State] with the provided working directory set.
// See [Dir]
func (s State) Dir(str string) State {
+37
View File
@@ -32,6 +32,43 @@ func TestStateMeta(t *testing.T) {
assert.Equal(t, "abc", v)
}
func TestStateDelEnv(t *testing.T) {
t.Parallel()
s := Image("foo")
s = s.AddEnv("FOO", "abc").AddEnv("BAR", "def")
s2 := s.DelEnv("FOO").DelEnv("NOTSET")
_, ok := getEnvHelper(t, s2, "FOO")
assert.False(t, ok)
env, err := s2.Env(t.Context())
require.NoError(t, err)
assert.Equal(t, []string{"BAR=def"}, env.ToArray())
// original state is not modified
v, ok := getEnvHelper(t, s, "FOO")
assert.True(t, ok)
assert.Equal(t, "abc", v)
s3 := s2.AddEnv("FOO", "ghi")
env, err = s3.Env(t.Context())
require.NoError(t, err)
assert.Equal(t, []string{"BAR=def", "FOO=ghi"}, env.ToArray())
def, err := s2.Run(Shlex("true")).Marshal(t.Context())
require.NoError(t, err)
var execEnv []string
for _, dt := range def.Def {
var op pb.Op
require.NoError(t, op.UnmarshalVT(dt))
if e := op.GetExec(); e != nil {
execEnv = e.Meta.Env
}
}
assert.Equal(t, []string{"BAR=def"}, execEnv)
}
func TestFormattingPatterns(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -272,7 +272,7 @@ target "lint" {
matrix = {
buildtags = [
{ name = "default", tags = "", target = "golangci-lint" },
{ name = "labs", tags = "", target = "golangci-lint" },
{ name = "labs", tags = "dfunset dfreset", target = "golangci-lint" },
{ name = "nydus", tags = "nydus", target = "golangci-lint" },
{ name = "yaml", tags = "", target = "yamllint" },
{ name = "golangci-verify", tags = "", target = "golangci-verify" },
+116 -3
View File
@@ -20,6 +20,7 @@ import (
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/client/llb/imagemetaresolver"
"github.com/moby/buildkit/client/llb/sourceresolver"
dfcommand "github.com/moby/buildkit/frontend/dockerfile/command"
"github.com/moby/buildkit/frontend/dockerfile/instructions"
"github.com/moby/buildkit/frontend/dockerfile/linter"
"github.com/moby/buildkit/frontend/dockerfile/parser"
@@ -47,6 +48,7 @@ const (
sbomScanContext = "BUILDKIT_SBOM_SCAN_CONTEXT"
sbomScanStage = "BUILDKIT_SBOM_SCAN_STAGE"
disableOnbuild = "BUILDKIT_DISABLE_ONBUILD"
)
var nonEnvArgs = map[string]struct{}{
@@ -216,6 +218,7 @@ type dispatchContext struct {
allDispatchStates *dispatchStates
proxyEnv *llb.ProxyEnv
namedContext func(string, dockerui.ContextOpt) (*dockerui.NamedContext, error)
disableOnbuild bool
}
func namedContextFunc(opt ConvertOpt) func(string, dockerui.ContextOpt) (*dockerui.NamedContext, error) {
@@ -310,6 +313,14 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS
globalArgs = setBuildArgValue(opt.BuildArgs, globalArgs, "SOURCE_DATE_EPOCH", formatSourceDateEpochValue(resolvedEpoch))
}
var skipOnbuild bool
if v, ok := getBuildArgValue(opt.BuildArgs, globalArgs, disableOnbuild); ok && v != "" {
skipOnbuild, err = strconv.ParseBool(v)
if err != nil {
return nil, errors.Wrapf(err, "invalid %s value %q", disableOnbuild, v)
}
}
metaResolver := opt.MetaResolver
if metaResolver == nil {
metaResolver = imagemetaresolver.Default()
@@ -327,6 +338,7 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS
allDispatchStates: newDispatchStates(),
proxyEnv: proxyEnvFromBuildArgs(opt.BuildArgs),
namedContext: namedContextFunc(opt),
disableOnbuild: skipOnbuild,
}
if err := dctx.buildDispatchStates(stages); err != nil {
@@ -568,6 +580,10 @@ func (dctx *dispatchContext) resolveStages(ctx context.Context, target *dispatch
}
d.onBuildInit = true
}
if dctx.disableOnbuild || slices.Contains(d.stage.Reset, dfcommand.Onbuild) {
onbuilds = nil
d.image.Config.OnBuild = nil
}
if len(onbuilds) > 0 {
if b, err := initOnBuildTriggers(d, onbuilds, dctx.allDispatchStates, dctx.shlex); err != nil {
@@ -789,6 +805,8 @@ func (dctx *dispatchContext) dispatchStages(ctx context.Context, allReachable ma
d.platform = &d.opt.targetPlatform
}
resetBaseConfig(d)
// make sure that PATH is always set
if _, ok := shell.EnvsFromSlice(d.image.Config.Env).Get("PATH"); !ok {
var osName string
@@ -1331,6 +1349,17 @@ func initOnBuildTriggers(d *dispatchState, triggers []string, allDispatchStates
}
func dispatchEnv(d *dispatchState, c *instructions.EnvCommand, lint *linter.Linter) error {
if c.Unset {
keys := make([]string, 0, len(c.Env))
for _, e := range c.Env {
if e.Key == "" {
return errors.New("ENV --unset names can not be blank")
}
unsetEnv(d, e.Key)
keys = append(keys, e.Key)
}
return commitToHistory(&d.image, "ENV --unset "+strings.Join(keys, " "), false, nil, d.epoch)
}
commitMessage := bytes.NewBufferString("ENV")
for _, e := range c.Env {
if e.NoDelim {
@@ -1345,6 +1374,61 @@ func dispatchEnv(d *dispatchState, c *instructions.EnvCommand, lint *linter.Lint
return commitToHistory(&d.image, commitMessage.String(), false, nil, d.epoch)
}
// unsetEnv removes an environment variable and a build arg with the same name.
func unsetEnv(d *dispatchState, key string) {
for _, k := range getEnv(d.state).Keys() {
if shell.EqualEnvKeys(k, key) {
d.state = d.state.DelEnv(k)
}
}
d.image.Config.Env = delEnv(d.image.Config.Env, key)
d.buildArgs = slices.DeleteFunc(d.buildArgs, func(arg instructions.KeyValuePairOptional) bool {
return shell.EqualEnvKeys(arg.Key, key)
})
}
// resetBaseConfig clears the base image config fields set with FROM --reset.
// ONBUILD triggers are skipped when the stages are resolved.
func resetBaseConfig(d *dispatchState) {
for _, k := range d.stage.Reset {
switch k {
case dfcommand.Cmd:
d.image.Config.Cmd = nil
case dfcommand.Entrypoint:
d.image.Config.Entrypoint = nil
case dfcommand.Env:
for _, env := range slices.Clone(d.image.Config.Env) {
key, _ := parseKeyValue(env)
unsetEnv(d, key)
}
case dfcommand.Expose:
d.image.Config.ExposedPorts = nil
case dfcommand.Healthcheck:
d.image.Config.Healthcheck = nil
case dfcommand.Label:
d.image.Config.Labels = nil
case dfcommand.Shell:
d.image.Config.Shell = nil
case dfcommand.StopSignal:
d.image.Config.StopSignal = ""
case dfcommand.User:
d.image.Config.User = ""
d.state = d.state.User("")
case dfcommand.Volume:
d.image.Config.Volumes = nil
case dfcommand.Workdir:
d.image.Config.WorkingDir = ""
d.state = d.state.Dir("/")
// workdir no longer depends on the base image
d.workdirSet = true
}
}
// ArgsEscaped applies to both cmd and entrypoint
if len(d.stage.Reset) > 0 && len(d.image.Config.Cmd) == 0 && len(d.image.Config.Entrypoint) == 0 {
d.image.Config.ArgsEscaped = false //nolint:staticcheck // ignore SA1019: field is deprecated in OCI Image spec, but used for backward-compatibility with Docker image spec.
}
}
func dispatchRun(d *dispatchState, c *instructions.RunCommand, proxy *llb.ProxyEnv, sources []*dispatchState, dopt dispatchOpt) error {
var opt []llb.RunOption
@@ -1575,6 +1659,17 @@ func dispatchMaintainer(d *dispatchState, c *instructions.MaintainerCommand) err
}
func dispatchLabel(d *dispatchState, c *instructions.LabelCommand, lint *linter.Linter) error {
if c.Unset {
keys := make([]string, 0, len(c.Labels))
for _, v := range c.Labels {
if v.Key == "" {
return errors.New("LABEL --unset names can not be blank")
}
delete(d.image.Config.Labels, v.Key)
keys = append(keys, v.Key)
}
return commitToHistory(&d.image, "LABEL --unset "+strings.Join(keys, " "), false, nil, d.epoch)
}
commitMessage := bytes.NewBufferString("LABEL")
if d.image.Config.Labels == nil {
d.image.Config.Labels = make(map[string]string, len(c.Labels))
@@ -1652,13 +1747,24 @@ func dispatchUser(d *dispatchState, c *instructions.UserCommand, commit bool) er
}
func dispatchVolume(d *dispatchState, c *instructions.VolumeCommand) error {
if slices.Contains(c.Volumes, "") {
return errors.New("VOLUME specified can not be an empty string")
}
if c.Unset {
for _, v := range c.Volumes {
v = path.Clean(v)
for k := range d.image.Config.Volumes {
if path.Clean(k) == v {
delete(d.image.Config.Volumes, k)
}
}
}
return commitToHistory(&d.image, fmt.Sprintf("VOLUME --unset %v", c.Volumes), false, nil, d.epoch)
}
if d.image.Config.Volumes == nil {
d.image.Config.Volumes = map[string]struct{}{}
}
for _, v := range c.Volumes {
if v == "" {
return errors.New("VOLUME specified can not be an empty string")
}
d.image.Config.Volumes[v] = struct{}{}
}
return commitToHistory(&d.image, fmt.Sprintf("VOLUME %v", c.Volumes), false, nil, d.epoch)
@@ -1770,6 +1876,13 @@ func addEnv(env []string, k, v string) []string {
return env
}
func delEnv(env []string, k string) []string {
return slices.DeleteFunc(env, func(envVar string) bool {
key, _ := parseKeyValue(envVar)
return shell.EqualEnvKeys(key, k)
})
}
func parseKeyValue(env string) (string, string) {
parts := strings.SplitN(env, "=", 2)
v := ""
@@ -701,3 +701,55 @@ RUN --mount=type=cache,target=C:\cache type C:\cache\foo | findstr "42" && type
}, nil)
require.NoError(t, err)
}
func testOnBuildDisabled(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
f := getFrontend(t, sb)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
solve := func(dockerfile string, buildArgs map[string]string) error {
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", []byte(dockerfile), 0600),
)
attrs := map[string]string{}
for k, v := range buildArgs {
attrs["build-arg:"+k] = v
}
_, err := f.Solve(sb.Context(), c, client.SolveOpt{
FrontendAttrs: attrs,
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
return err
}
dockerfile := `
FROM busybox AS base
ONBUILD RUN false
FROM base
RUN true
`
err = solve(dockerfile, nil)
require.ErrorContains(t, err, "did not complete successfully")
err = solve(dockerfile, map[string]string{"BUILDKIT_DISABLE_ONBUILD": "1"})
require.NoError(t, err)
err = solve(dockerfile, map[string]string{"BUILDKIT_DISABLE_ONBUILD": "maybe"})
require.ErrorContains(t, err, `invalid BUILDKIT_DISABLE_ONBUILD value "maybe"`)
dockerfile = "ARG BUILDKIT_DISABLE_ONBUILD=true\n" + dockerfile
err = solve(dockerfile, nil)
require.NoError(t, err)
err = solve(dockerfile, map[string]string{"BUILDKIT_DISABLE_ONBUILD": "false"})
require.ErrorContains(t, err, "did not complete successfully")
}
@@ -0,0 +1,277 @@
//go:build dfreset
package dockerfile
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/continuity/fs/fstest"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/frontend/dockerui"
"github.com/moby/buildkit/util/contentutil"
"github.com/moby/buildkit/util/system"
"github.com/moby/buildkit/util/testutil"
"github.com/moby/buildkit/util/testutil/integration"
"github.com/moby/buildkit/util/testutil/workers"
dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"github.com/tonistiigi/fsutil"
)
func init() {
allTests = append(allTests, integration.TestFuncs(
testResetImageConfig,
testResetStageBase,
testResetOnbuildDeps,
testResetInvalid,
)...)
}
func testResetImageConfig(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush)
f := getFrontend(t, sb)
registry, err := sb.NewRegistry()
if errors.Is(err, integration.ErrRequirements) {
t.Skip(err.Error())
}
require.NoError(t, err)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
solve := func(dockerfile, target string) {
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", []byte(dockerfile), 0600),
)
_, err := f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": target,
"push": "true",
},
},
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)
}
baseTarget := registry + "/buildkit/testreset:base"
solve(`
FROM busybox
RUN mkdir -m 0777 /work
ENV FOO=foo
LABEL a=1
VOLUME /data
EXPOSE 8080
HEALTHCHECK CMD true
SHELL ["/bin/sh", "-ec"]
STOPSIGNAL SIGINT
WORKDIR /work
USER 1000
ONBUILD RUN touch /tmp/onbuild
ENTRYPOINT ["/bin/echo"]
CMD ["hello"]
`, baseTarget)
allTarget := registry + "/buildkit/testreset:all"
solve(`
FROM --reset=all `+baseTarget+`
RUN [ ! -e /tmp/onbuild ] && [ "$(id -u)" = 0 ] && [ "$(pwd)" = / ] && [ -z "${FOO:-}" ]
`, allTarget)
readConfig := func(target string) dockerspec.DockerOCIImageConfig {
desc, provider, err := contentutil.ProviderFromRef(sb.Context(), target)
require.NoError(t, err)
img, err := testutil.ReadImage(sb.Context(), provider, desc)
require.NoError(t, err)
dt, err := content.ReadBlob(sb.Context(), provider, img.Manifest.Config)
require.NoError(t, err)
var cfg dockerspec.DockerOCIImage
require.NoError(t, json.Unmarshal(dt, &cfg))
return cfg.Config
}
cfg := readConfig(allTarget)
require.Equal(t, []string{"PATH=" + system.DefaultPathEnvUnix}, cfg.Env)
require.Empty(t, cfg.Labels)
require.Empty(t, cfg.Volumes)
require.Empty(t, cfg.ExposedPorts)
require.Nil(t, cfg.Healthcheck)
require.Empty(t, cfg.Shell)
require.Empty(t, cfg.StopSignal)
require.Empty(t, cfg.WorkingDir)
require.Empty(t, cfg.User)
require.Empty(t, cfg.OnBuild)
require.Empty(t, cfg.Entrypoint)
require.Empty(t, cfg.Cmd)
partialTarget := registry + "/buildkit/testreset:partial"
solve(`
FROM --reset=volume --reset=label,volume `+baseTarget+`
RUN [ -e /tmp/onbuild ] && [ "$(id -u)" = 1000 ] && [ "$(pwd)" = /work ] && [ "$FOO" = foo ]
`, partialTarget)
cfg = readConfig(partialTarget)
require.Empty(t, cfg.Labels)
require.Empty(t, cfg.Volumes)
require.Contains(t, cfg.Env, "FOO=foo")
require.Contains(t, cfg.ExposedPorts, "8080/tcp")
require.NotNil(t, cfg.Healthcheck)
require.Equal(t, []string{"/bin/sh", "-ec"}, cfg.Shell)
require.Equal(t, "SIGINT", cfg.StopSignal)
require.Equal(t, "/work", cfg.WorkingDir)
require.Equal(t, "1000", cfg.User)
require.Equal(t, []string{"/bin/echo"}, cfg.Entrypoint)
require.Equal(t, []string{"hello"}, cfg.Cmd)
// relative WORKDIR after a workdir reset doesn't depend on the base image
solve(`# check=error=true
FROM --reset=workdir `+baseTarget+`
WORKDIR app
RUN [ "$(pwd)" = /app ]
`, registry+"/buildkit/testreset:workdir")
}
func testResetStageBase(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
f := getFrontend(t, sb)
dockerfile := []byte(`
FROM busybox AS base
RUN mkdir -m 0777 /work
ENV FOO=foo
ARG BAR=bar
WORKDIR /work
USER 1000
ONBUILD RUN touch /tmp/onbuild
FROM --reset=env,workdir,user,onbuild base AS reset
RUN env | sort > /tmp/env && pwd > /tmp/pwd && id -u > /tmp/uid && ls /tmp > /tmp/ls
FROM base AS keep
RUN pwd > /tmp/pwd && id -u > /tmp/uid && ls /tmp > /tmp/ls
FROM scratch
COPY --from=reset /tmp/env /tmp/pwd /tmp/uid /tmp/ls /reset/
COPY --from=keep /tmp/pwd /tmp/uid /tmp/ls /keep/
`)
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
destDir := t.TempDir()
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterLocal,
OutputDir: destDir,
},
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)
read := func(p string) string {
dt, err := os.ReadFile(filepath.Join(destDir, p))
require.NoError(t, err)
return string(dt)
}
env := read("reset/env")
require.NotContains(t, env, "FOO=")
require.Contains(t, env, "BAR=bar\n")
require.Contains(t, env, "PATH=")
require.Equal(t, "/\n", read("reset/pwd"))
require.Equal(t, "0\n", read("reset/uid"))
require.NotContains(t, read("reset/ls"), "onbuild")
require.Equal(t, "/work\n", read("keep/pwd"))
require.Equal(t, "1000\n", read("keep/uid"))
require.Contains(t, read("keep/ls"), "onbuild")
}
// testResetOnbuildDeps checks that skipped ONBUILD triggers don't add
// dependencies to the build.
func testResetOnbuildDeps(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
f := getFrontend(t, sb)
dockerfile := []byte(`
FROM busybox AS base
ONBUILD COPY --from=doesnotexist /foo /foo
FROM --reset=onbuild base
RUN true
`)
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)
}
func testResetInvalid(t *testing.T, sb integration.Sandbox) {
f := getFrontend(t, sb)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
for _, tc := range []struct {
value string
err string
}{
{value: "volumes", err: `invalid FROM --reset value: "volumes" (did you mean volume?)`},
{value: "ENV", err: `invalid FROM --reset value: "ENV" (did you mean env?)`},
{value: "ALL", err: `invalid FROM --reset value: "ALL" (did you mean all?)`},
} {
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", []byte("FROM --reset="+tc.value+" scratch\n"), 0600),
)
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.ErrorContains(t, err, tc.err)
}
}
+1
View File
@@ -192,6 +192,7 @@ var allTests = integration.TestFuncs(
testOnBuildInheritedStageWithFrom,
testOnBuildNewDeps,
testOnBuildWithCacheMount,
testOnBuildDisabled,
// dockerfile_platform_test.go
testPlatformArgsImplicit,
@@ -0,0 +1,299 @@
//go:build dfunset
package dockerfile
import (
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/containerd/continuity/fs/fstest"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/frontend/dockerui"
"github.com/moby/buildkit/util/contentutil"
"github.com/moby/buildkit/util/testutil"
"github.com/moby/buildkit/util/testutil/integration"
"github.com/moby/buildkit/util/testutil/workers"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"github.com/tonistiigi/fsutil"
)
func init() {
allTests = append(allTests, integration.TestFuncs(
testUnsetImageConfig,
testUnsetRunEnv,
testUnsetInvalid,
)...)
}
func testUnsetImageConfig(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush)
f := getFrontend(t, sb)
registry, err := sb.NewRegistry()
if errors.Is(err, integration.ErrRequirements) {
t.Skip(err.Error())
}
require.NoError(t, err)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
dockerfile := []byte(`
FROM busybox
ENV FOO=foo BAR=bar BAZ=baz
LABEL a=1 b=2 c=3
VOLUME ["/data/", "/cache", "/keep"]
`)
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)
baseTarget := registry + "/buildkit/testunset:base"
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": baseTarget,
"push": "true",
},
},
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)
dockerfile = []byte(`
FROM ` + baseTarget + ` AS base
ENV --unset FOO BAZ
LABEL --unset a
ARG name=b
LABEL --unset ${name}
VOLUME --unset /data /cache/ /notexist
ARG BOTH=arg
ENV BOTH=env
ENV --unset BOTH
RUN true
FROM base
ENV --unset=false NEW=new
`)
dir = integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)
target := registry + "/buildkit/testunset:child"
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": target,
"push": "true",
},
},
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)
desc, provider, err := contentutil.ProviderFromRef(sb.Context(), target)
require.NoError(t, err)
img, err := testutil.ReadImage(sb.Context(), provider, desc)
require.NoError(t, err)
var envKeys []string
for _, env := range img.Img.Config.Env {
k, _, _ := strings.Cut(env, "=")
envKeys = append(envKeys, k)
}
require.Contains(t, envKeys, "PATH")
require.Contains(t, img.Img.Config.Env, "BAR=bar")
require.Contains(t, img.Img.Config.Env, "NEW=new")
require.NotContains(t, envKeys, "FOO")
require.NotContains(t, envKeys, "BAZ")
require.Equal(t, map[string]string{"c": "3"}, img.Img.Config.Labels)
require.Equal(t, []string{"/keep"}, slices.Collect(maps.Keys(img.Img.Config.Volumes)))
var history []string
for _, h := range img.Img.History {
history = append(history, h.CreatedBy)
}
require.Contains(t, history, "ENV --unset FOO BAZ")
require.Contains(t, history, "LABEL --unset b")
require.Contains(t, history, "VOLUME --unset [/data /cache/ /notexist]")
var runHistory string
for _, h := range history {
if strings.Contains(h, "/bin/sh -c true") {
runHistory = h
}
}
require.NotEmpty(t, runHistory)
require.NotContains(t, runHistory, "BOTH")
}
func testUnsetRunEnv(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
f := getFrontend(t, sb)
dockerfile := []byte(`
FROM busybox AS base
ARG FROMCLI
ENV ENVVAR=env KEEP=keep
RUN env | sort > /env0
ENV --unset ENVVAR FROMCLI
ENV EXPANDED=${ENVVAR:-none}-${FROMCLI:-none}
RUN env | sort > /env1
ARG BOTH=arg
ENV BOTH=env
ENV --unset BOTH
RUN env | sort > /env2
ONBUILD ENV --unset KEEP
FROM base AS child
RUN env | sort > /env3
ARG FROMCLI
RUN env | sort > /env4
FROM scratch
COPY --from=child /env0 /env1 /env2 /env3 /env4 /
`)
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
destDir := t.TempDir()
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
FrontendAttrs: map[string]string{
"build-arg:FROMCLI": "cli",
},
Exports: []client.ExportEntry{
{
Type: client.ExporterLocal,
OutputDir: destDir,
},
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)
dt, err := os.ReadFile(filepath.Join(destDir, "env0"))
require.NoError(t, err)
env := string(dt)
require.Contains(t, env, "ENVVAR=env\n")
require.Contains(t, env, "FROMCLI=cli\n")
dt, err = os.ReadFile(filepath.Join(destDir, "env1"))
require.NoError(t, err)
env = string(dt)
require.Contains(t, env, "KEEP=keep\n")
require.Contains(t, env, "EXPANDED=none-none\n")
require.NotContains(t, env, "ENVVAR=")
require.NotContains(t, env, "FROMCLI=")
dt, err = os.ReadFile(filepath.Join(destDir, "env2"))
require.NoError(t, err)
env = string(dt)
require.NotContains(t, env, "BOTH=")
dt, err = os.ReadFile(filepath.Join(destDir, "env3"))
require.NoError(t, err)
env = string(dt)
require.NotContains(t, env, "KEEP=")
require.NotContains(t, env, "FROMCLI=")
require.Contains(t, env, "EXPANDED=none-none\n")
dt, err = os.ReadFile(filepath.Join(destDir, "env4"))
require.NoError(t, err)
env = string(dt)
require.Contains(t, env, "FROMCLI=cli\n")
}
func testUnsetInvalid(t *testing.T, sb integration.Sandbox) {
f := getFrontend(t, sb)
c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
for _, tc := range []struct {
dockerfile string
err string
}{
{
dockerfile: "FROM scratch\nENV --unset FOO=bar\n",
err: "ENV --unset does not accept values: FOO=bar",
},
{
dockerfile: "FROM scratch\nLABEL --unset foo=bar\n",
err: "LABEL --unset does not accept values: foo=bar",
},
{
dockerfile: "FROM scratch\nENV --unset\n",
err: "ENV --unset requires at least one argument",
},
{
dockerfile: "FROM scratch\nARG --unset FOO\n",
err: "unknown flag: --unset",
},
{
dockerfile: "FROM scratch\nENV --unset=yes FOO\n",
err: "expecting boolean value for flag --unset, not: yes",
},
{
dockerfile: "FROM scratch\nARG EMPTY\nVOLUME --unset ${EMPTY}\n",
err: "VOLUME specified can not be an empty string",
},
{
dockerfile: "FROM scratch\nARG EMPTY\nENV --unset ${EMPTY}\n",
err: "ENV --unset names can not be blank",
},
{
dockerfile: "FROM scratch\nARG EMPTY\nLABEL --unset ${EMPTY}\n",
err: "LABEL --unset names can not be blank",
},
} {
dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", []byte(tc.dockerfile), 0600),
)
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.ErrorContains(t, err, tc.err, tc.dockerfile)
}
}
+109
View File
@@ -653,6 +653,44 @@ for example [automatic platform ARGs](#automatic-platform-args-in-the-global-sco
allow you to force a stage to native build platform (`--platform=$BUILDPLATFORM`),
and use it to cross-compile to the target platform inside the stage.
### FROM --reset
> [!NOTE]
> Not yet available in stable syntax, use [`docker/dockerfile:1-labs`](#syntax)
> version.
```dockerfile
FROM --reset=<field>[,<field>...] <image> [AS <name>]
```
The `--reset` flag clears configuration inherited from the base image, so
that changes in the base image don't affect these parts of your build. The
base image can be an image or another build stage. The flag can be repeated,
and its values aren't expanded with build arguments. The following fields can
be reset:
| Field | Effect |
|---------------|---------------------------------------------------------------------------|
| `cmd` | Removes the default command |
| `entrypoint` | Removes the entrypoint |
| `env` | Removes environment variables like [`ENV --unset`](#env---unset), the default `PATH` is set again on Linux |
| `expose` | Removes exposed ports |
| `healthcheck` | Removes the health check |
| `label` | Removes labels |
| `onbuild` | Skips the [`ONBUILD`](#onbuild) triggers of the base image |
| `shell` | Restores the default shell |
| `stopsignal` | Removes the stop signal |
| `user` | Restores the default user |
| `volume` | Removes volumes |
| `workdir` | Restores the default working directory (`/`) |
| `all` | Resets all of the above, and any fields added in future versions |
```dockerfile
# syntax=docker/dockerfile:1-labs
FROM --reset=onbuild,volume,label postgres
LABEL org.opencontainers.image.title="my-postgres"
```
### Understand how ARG and FROM interact
`FROM` instructions support variables that are declared by any `ARG`
@@ -1216,6 +1254,25 @@ $ docker image inspect --format='{{json .Config.Labels}}' myimage
}
```
### LABEL --unset
> [!NOTE]
> Not yet available in stable syntax, use [`docker/dockerfile:1-labs`](#syntax)
> version.
```dockerfile
LABEL --unset <key> [<key>...]
```
The `--unset` flag removes labels from the image, including labels inherited
from the base image. Removing a label that isn't set is not an error.
```dockerfile
# syntax=docker/dockerfile:1-labs
FROM alpine
LABEL --unset org.opencontainers.image.description org.opencontainers.image.url
```
## MAINTAINER (deprecated)
```dockerfile
@@ -1359,6 +1416,35 @@ RUN apt-get update && apt-get install -y ...
> The alternative syntax is supported for backward compatibility, but discouraged
> for the reasons outlined above, and may be removed in a future release.
### ENV --unset
> [!NOTE]
> Not yet available in stable syntax, use [`docker/dockerfile:1-labs`](#syntax)
> version.
```dockerfile
ENV --unset <key> [<key>...]
```
The `--unset` flag removes environment variables, including variables inherited
from the base image. The variables are removed from the image configuration and
from the environment of subsequent build steps. A build argument with the same
name is removed as well. Declare the build argument again with [`ARG`](#arg) to
make it available to later build steps. Removing a variable that isn't set is
not an error. Values set by earlier instructions remain in the image history.
When `PATH` is removed, `RUN` instructions on Linux use the default `PATH`, and
stages based on this stage add the default `PATH` back to their image
configuration.
```dockerfile
# syntax=docker/dockerfile:1-labs
FROM busybox
ENV TEMP_TOKEN_PATH=/tmp/token
RUN ./setup.sh
ENV --unset TEMP_TOKEN_PATH
```
## ADD
ADD has two forms.
@@ -2461,6 +2547,28 @@ Keep the following things in mind about volumes in the Dockerfile.
within the Dockerfile. The `VOLUME` instruction does not support specifying a `host-dir`
parameter. You must specify the mountpoint when you create or run the container.
### VOLUME --unset
> [!NOTE]
> Not yet available in stable syntax, use [`docker/dockerfile:1-labs`](#syntax)
> version.
```dockerfile
VOLUME --unset ["/data"]
VOLUME --unset /var/log /var/db
```
The `--unset` flag removes volumes from the image configuration, including
volumes inherited from the base image. Trailing and duplicate slashes are
ignored when comparing paths, so `/data/` matches a volume defined as `/data`.
Removing a volume that isn't defined is not an error.
```dockerfile
# syntax=docker/dockerfile:1-labs
FROM postgres
VOLUME --unset /var/lib/postgresql/data
```
## USER
```dockerfile
@@ -2766,6 +2874,7 @@ RUN echo "I'm building for $TARGETPLATFORM"
| `BUILDKIT_BUILD_NAME` | String | Override the build name shown in [`buildx history` command](https://docs.docker.com/reference/cli/docker/buildx/history/) and [Docker Desktop Builds view](https://docs.docker.com/desktop/use-desktop/builds/). |
| `BUILDKIT_CACHE_MOUNT_NS` | String | Set optional cache ID namespace. |
| `BUILDKIT_CONTEXT_KEEP_GIT_DIR` | Bool | Trigger Git context to keep the `.git` directory. |
| `BUILDKIT_DISABLE_ONBUILD` | Bool | Skip the [`ONBUILD`](#onbuild) triggers of all base images and stages. This is not a security boundary: a custom frontend selected with `# syntax=` can ignore it. |
| `BUILDKIT_GIT_ADVICE` | Bool | Show Git advice messages from BuildKit-managed Git operations. Defaults to `false`. |
| `BUILDKIT_GIT_DEBUG_COMMANDS` | Bool | Print executed Git commands for Git contexts and `ADD` sources in the build log. Defaults to `false`. |
| `BUILDKIT_INLINE_CACHE`[^2] | Bool | Inline cache metadata to image config or not. |
+8 -1
View File
@@ -148,9 +148,11 @@ func expandSliceInPlace(values []string, expander SingleWordExpander) error {
// EnvCommand allows setting an variable in the container's environment.
//
// ENV key1 value1 [keyN valueN...]
// ENV --unset key1 [keyN...]
type EnvCommand struct {
withNameAndCode
Env KeyValuePairs
Env KeyValuePairs
Unset bool // Env keys are removed, values are empty
}
func (c *EnvCommand) Expand(expander SingleWordExpander) error {
@@ -185,9 +187,11 @@ func NewLabelCommand(k string, v string, noExp bool) *LabelCommand {
// LabelCommand sets an image label in the output
//
// LABEL some json data describing the image
// LABEL --unset key1 [keyN...]
type LabelCommand struct {
withNameAndCode
Labels KeyValuePairs
Unset bool // Labels keys are removed, values are empty
noExpand bool
}
@@ -436,9 +440,11 @@ func (c *UserCommand) Expand(expander SingleWordExpander) error {
// VolumeCommand exposes the specified volume for use in the build environment.
//
// VOLUME /foo
// VOLUME --unset /foo
type VolumeCommand struct {
withNameAndCode
Volumes []string
Unset bool // Volumes are removed
}
func (c *VolumeCommand) Expand(expander SingleWordExpander) error {
@@ -521,6 +527,7 @@ type Stage struct {
OrigCmd string // original FROM command, used for rule checks
BaseName string // name of the base stage or source
Platform string // platform of base source to use
Reset []string // base image config fields to reset, see ResetKeys
DocComment string // doc-comment directly above the stage
+119
View File
@@ -36,6 +36,31 @@ var (
parseRunPostHooks []func(*RunCommand, parseRequest) error
)
var (
unsetEnabled = false
resetEnabled = false
)
// ResetAll is the FROM --reset value that resets all ResetKeys.
const ResetAll = "all"
// ResetKeys are the base image config fields that can be reset with
// FROM --reset. Each field is named after the command that sets it.
var ResetKeys = []string{
command.Cmd,
command.Entrypoint,
command.Env,
command.Expose,
command.Healthcheck,
command.Label,
command.Onbuild,
command.Shell,
command.StopSignal,
command.User,
command.Volume,
command.Workdir,
}
func nodeArgs(node *parser.Node) []string {
result := []string{}
for ; node.Next != nil; node = node.Next {
@@ -230,10 +255,48 @@ func parseKvps(args []string, cmdName string) (KeyValuePairs, error) {
return res, nil
}
// addUnsetFlag adds the --unset flag if it is enabled in this build.
func addUnsetFlag(req parseRequest) *Flag {
if !unsetEnabled {
return nil
}
return req.flags.AddBool("unset", false)
}
// parseUnsetKeys returns the keys for the --unset form of a command.
func parseUnsetKeys(args []string, cmdName string) (KeyValuePairs, error) {
if len(args) == 0 {
return nil, errAtLeastOneArgument(cmdName + " --unset")
}
var res KeyValuePairs
for _, name := range args {
if name == "" {
return nil, errBlankCommandNames(cmdName)
}
if strings.Contains(name, "=") {
return nil, errors.Errorf("%s --unset does not accept values: %s", cmdName, name)
}
res = append(res, KeyValuePair{Key: name})
}
return res, nil
}
func parseEnv(req parseRequest) (*EnvCommand, error) {
flUnset := addUnsetFlag(req)
if err := req.flags.Parse(); err != nil {
return nil, err
}
if flUnset != nil && flUnset.Value == "true" {
envs, err := parseUnsetKeys(req.args, "ENV")
if err != nil {
return nil, err
}
return &EnvCommand{
Env: envs,
Unset: true,
withNameAndCode: newWithNameAndCode(req),
}, nil
}
envs, err := parseKvps(req.args, "ENV")
if err != nil {
return nil, err
@@ -259,9 +322,21 @@ func parseMaintainer(req parseRequest) (*MaintainerCommand, error) {
}
func parseLabel(req parseRequest) (*LabelCommand, error) {
flUnset := addUnsetFlag(req)
if err := req.flags.Parse(); err != nil {
return nil, err
}
if flUnset != nil && flUnset.Value == "true" {
labels, err := parseUnsetKeys(req.args, "LABEL")
if err != nil {
return nil, err
}
return &LabelCommand{
Labels: labels,
Unset: true,
withNameAndCode: newWithNameAndCode(req),
}, nil
}
labels, err := parseKvps(req.args, "LABEL")
if err != nil {
@@ -399,10 +474,22 @@ func parseFrom(req parseRequest) (*Stage, error) {
}
flPlatform := req.flags.AddString("platform", "")
var flReset *Flag
if resetEnabled {
flReset = req.flags.AddStrings("reset")
}
if err := req.flags.Parse(); err != nil {
return nil, err
}
var reset []string
if flReset != nil {
reset, err = parseResetKeys(flReset.StringValues)
if err != nil {
return nil, err
}
}
code := strings.TrimSpace(req.original)
return &Stage{
BaseName: req.args[0],
@@ -411,12 +498,35 @@ func parseFrom(req parseRequest) (*Stage, error) {
SourceCode: code,
Commands: []Command{},
Platform: flPlatform.Value,
Reset: reset,
Location: req.location,
Comments: req.comments,
DocComment: getDocComment(req.comments, stageName),
}, nil
}
// parseResetKeys returns the sorted list of fields for FROM --reset values.
func parseResetKeys(values []string) ([]string, error) {
var keys []string
for _, v := range values {
for k := range strings.SplitSeq(v, ",") {
switch {
case k == ResetAll:
keys = append(keys, ResetKeys...)
case slices.Contains(ResetKeys, k):
keys = append(keys, k)
case strings.ToLower(k) == ResetAll || slices.Contains(ResetKeys, strings.ToLower(k)):
return nil, errors.Errorf("invalid FROM --reset value: %q (did you mean %s?)", k, strings.ToLower(k))
default:
err := errors.Errorf("invalid FROM --reset value: %q", k)
return nil, suggest.WrapError(err, k, append([]string{ResetAll}, ResetKeys...), true)
}
}
}
slices.Sort(keys)
return slices.Compact(keys), nil
}
var validStageName = regexp.MustCompile("^[a-z][a-z0-9-_.]*$")
func parseBuildStageName(args []string) (stageName string, err error) {
@@ -714,11 +824,13 @@ func parseVolume(req parseRequest) (*VolumeCommand, error) {
return nil, errAtLeastOneArgument("VOLUME")
}
flUnset := addUnsetFlag(req)
if err := req.flags.Parse(); err != nil {
return nil, err
}
cmd := &VolumeCommand{
Unset: flUnset != nil && flUnset.Value == "true",
withNameAndCode: newWithNameAndCode(req),
}
@@ -736,6 +848,9 @@ func parseStopSignal(req parseRequest) (*StopSignalCommand, error) {
if len(req.args) != 1 {
return nil, errExactlyOneArgument("STOPSIGNAL")
}
if err := req.flags.Parse(); err != nil {
return nil, err
}
sig := req.args[0]
cmd := &StopSignalCommand{
@@ -750,6 +865,10 @@ func parseArg(req parseRequest) (*ArgCommand, error) {
return nil, errAtLeastOneArgument("ARG")
}
if err := req.flags.Parse(); err != nil {
return nil, err
}
pairs := make([]KeyValuePairOptional, len(req.args))
for i, arg := range req.args {
@@ -0,0 +1,7 @@
//go:build dfreset
package instructions
func init() {
resetEnabled = true
}
@@ -230,6 +230,16 @@ func TestErrorCases(t *testing.T) {
dockerfile: "MAINTAINER --boo joe@example.com",
expectedError: "unknown flag: --boo",
},
{
name: "ARG unknown flag",
dockerfile: "ARG --boo foo",
expectedError: "unknown flag: --boo",
},
{
name: "STOPSIGNAL unknown flag",
dockerfile: "STOPSIGNAL --boo SIGTERM",
expectedError: "unknown flag: --boo",
},
{
name: "Chaining ONBUILD",
dockerfile: `ONBUILD ONBUILD RUN touch foobar`,
@@ -259,6 +269,33 @@ func TestErrorCases(t *testing.T) {
}
}
func TestUnsetDisabled(t *testing.T) {
if unsetEnabled {
t.Skip("--unset is enabled in this build")
}
for _, dockerfile := range []string{
"ENV --unset FOO",
"LABEL --unset foo",
"ARG --unset FOO",
"VOLUME --unset /foo",
} {
ast, err := parser.Parse(strings.NewReader(dockerfile))
require.NoError(t, err)
_, err = ParseInstruction(ast.AST.Children[0])
require.ErrorContains(t, err, "unknown flag: --unset", dockerfile)
}
}
func TestResetDisabled(t *testing.T) {
if resetEnabled {
t.Skip("--reset is enabled in this build")
}
ast, err := parser.Parse(strings.NewReader("FROM --reset=all busybox"))
require.NoError(t, err)
_, err = ParseInstruction(ast.AST.Children[0])
require.ErrorContains(t, err, "unknown flag: --reset")
}
func TestRunCmdFlagsUsed(t *testing.T) {
dockerfile := "RUN --mount=type=tmpfs,target=/foo/ echo hello"
r := strings.NewReader(dockerfile)
@@ -0,0 +1,7 @@
//go:build dfunset
package instructions
func init() {
unsetEnabled = true
}
+22
View File
@@ -137,6 +137,13 @@ var (
heredocCompoundDirectives = map[string]bool{
command.Onbuild: true,
}
// Directives that parse key-value pairs but take a list of names
// when --unset flag is set
unsetCommands = map[string]bool{
command.Env: true,
command.Label: true,
}
)
// directives is the structure used during a build run to hold the state of
@@ -234,6 +241,10 @@ func newNodeFromLine(line string, d *directives, comments []string) (*Node, erro
if fn == nil {
fn = parseIgnore
}
// --unset form takes a list of names instead of key-value pairs
if unsetCommands[strings.ToLower(cmd)] && hasUnsetFlag(flags) {
fn = parseNameOrNameVal
}
next, attrs, err := fn(args, d)
if err != nil {
return nil, err
@@ -249,6 +260,17 @@ func newNodeFromLine(line string, d *directives, comments []string) (*Node, erro
}, nil
}
func hasUnsetFlag(flags []string) bool {
for _, f := range flags {
name, value, _ := strings.Cut(f, "=")
if name == "--unset" {
// invalid values are reported by the flag parser
return !strings.EqualFold(value, "false")
}
}
return false
}
// Result contains the bundled outputs from parsing a Dockerfile.
type Result struct {
AST *Node
@@ -0,0 +1,9 @@
FROM busybox
ENV --unset FOO BAR
ENV --unset=true FOO
ENV --unset=false FOO=bar
LABEL --unset foo bar.baz
LABEL --unset=false foo=bar
VOLUME --unset /foo /bar
VOLUME --unset ["/foo", "/bar"]
ONBUILD ENV --unset FOO
@@ -0,0 +1,9 @@
(from "busybox")
(env ["--unset"] "FOO" "BAR")
(env ["--unset=true"] "FOO")
(env ["--unset=false"] "FOO" "bar" "=")
(label ["--unset"] "foo" "bar.baz")
(label ["--unset=false"] "foo" "bar" "=")
(volume ["--unset"] "/foo" "/bar")
(volume ["--unset"] "/foo" "/bar")
(onbuild (env ["--unset"] "FOO"))
+1
View File
@@ -0,0 +1 @@
dfunset dfreset