dockerfile: add FROM --reset

Allow resetting configuration inherited from the base image with
FROM --reset=<field>[,<field>...]. Fields are named after the
instructions that set them: cmd, entrypoint, env, expose,
healthcheck, label, onbuild, shell, stopsignal, user, volume and
workdir. "all" resets all of them. Resetting onbuild skips the
ONBUILD triggers of the base image.

Available in the labs channel (dfreset build tag).

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi committed 2026-10-29 20:43:02 -07:00
1 parent cf916e5089
commit c141818ba8
9 files changed
+457 -12

No files matched your search

+1 -1
View File
@@ -272,7 +272,7 @@ target "lint" {
matrix = {
buildtags = [
{ name = "default", tags = "", target = "golangci-lint" },
{ name = "labs", tags = "dfunset", 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" },
+63 -9
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"
@@ -568,6 +569,10 @@ func (dctx *dispatchContext) resolveStages(ctx context.Context, target *dispatch
}
d.onBuildInit = true
}
if 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 +794,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
@@ -1337,15 +1344,7 @@ func dispatchEnv(d *dispatchState, c *instructions.EnvCommand, lint *linter.Lint
if e.Key == "" {
return errors.New("ENV --unset names can not be blank")
}
for _, k := range getEnv(d.state).Keys() {
if shell.EqualEnvKeys(k, e.Key) {
d.state = d.state.DelEnv(k)
}
}
d.image.Config.Env = delEnv(d.image.Config.Env, e.Key)
d.buildArgs = slices.DeleteFunc(d.buildArgs, func(arg instructions.KeyValuePairOptional) bool {
return shell.EqualEnvKeys(arg.Key, e.Key)
})
unsetEnv(d, e.Key)
keys = append(keys, e.Key)
}
return commitToHistory(&d.image, "ENV --unset "+strings.Join(keys, " "), false, nil, d.epoch)
@@ -1364,6 +1363,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
@@ -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)
}
}
+38
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`
@@ -527,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
+59 -1
View File
@@ -36,7 +36,30 @@ var (
parseRunPostHooks []func(*RunCommand, parseRequest) error
)
var unsetEnabled = false
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{}
@@ -451,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],
@@ -463,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) {
@@ -0,0 +1,7 @@
//go:build dfreset
package instructions
func init() {
resetEnabled = true
}
@@ -286,6 +286,16 @@ func TestUnsetDisabled(t *testing.T) {
}
}
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)
+1 -1
View File
@@ -1 +1 @@
dfunset
dfunset dfreset