From 132eecad492947704420aaf7ae3d97d0fd81ebc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Jochum?= Date: Mon, 10 Aug 2026 20:37:54 +0200 Subject: [PATCH] fix(client): carry a built image's ENV into the instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René Jochum --- CHANGELOG.md | 4 ++ client/build.go | 80 +++++++++++++++++++++++++++++++-- client/build_test.go | 68 ++++++++++++++++++++++++++++ cmd/incus-compose/build_test.go | 43 ++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0196c014..90423fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ for correct semver ordering. Headings below preserve each release's announced fo up to two minutes, which could turn contention it would have ridden out into a reported timeout. Waiting for ic-healthd to come up is likewise the three seconds it claims rather than fifteen. (by @jochumdev) +- A built image now carries its environment into the instance. The image's `ENV` + was dropped on the way in, so a service built from a Dockerfile came up without + the `PATH`, `HOME` and `TERM` the same image pulled from a registry gets. + (by @jochumdev) ## [v1.2.0-rc.3] - 2026-08-07 diff --git a/client/build.go b/client/build.go index ef9712b9..f497d716 100644 --- a/client/build.go +++ b/client/build.go @@ -2,13 +2,16 @@ package client import ( "archive/tar" + "bufio" "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" "os/exec" + "slices" "strconv" "strings" "time" @@ -166,10 +169,7 @@ func buildRootfs(ctx context.Context, c *Client, builder string, cfg *BuildConfi _ = rmi.Run() }() - // Generate config.json from the built image's OCI config. Incus's LXC - // driver only reads Process.Args, Process.Cwd, and Process.User.{UID,GID} - // from this file, so a handcrafted minimal OCI Runtime Spec is enough - - // no need to save the whole image to disk and unpack it with umoci. + // Incus reads only Process.{Args,Env,Cwd,User} out of config.json, so inspecting beats saving the image and unpacking it with umoci. inspect := exec.CommandContext(ctx, builder, "inspect", tmpTag) //nolint:gosec inspect.Stderr = stderr c.LogDebug("Executing", "command", builder, "args", inspect.Args[1:]) @@ -234,10 +234,25 @@ func buildRootfs(ctx context.Context, c *Client, builder string, cfg *BuildConfi } } + env := slices.Clone(ociDefaultEnv) + for _, entry := range toStrings(imgCfg["Env"]) { + env = putEnv(env, entry, true) + } + + home, err := rootfsHome(rootfsPath, uid) + if err != nil { + _ = os.Remove(rootfsPath) + return nil, nil, fmt.Errorf("reading /etc/passwd from the built rootfs: %w", err) + } + if home != "" { + env = putEnv(env, "HOME="+home, false) + } + configJSON, err := json.Marshal(rspecs.Spec{ Version: rspecs.Version, Process: &rspecs.Process{ Args: append(toStrings(imgCfg["Entrypoint"]), toStrings(imgCfg["Cmd"])...), + Env: env, Cwd: cwd, User: rspecs.User{UID: uint32(uid), GID: uint32(gid)}, }, @@ -254,6 +269,63 @@ func buildRootfs(ctx context.Context, c *Client, builder string, cfg *BuildConfi return &tempFile{File: f, path: rootfsPath}, configJSON, nil } +// ociDefaultEnv is what umoci seeds a runtime spec with, so a built image gets the environment.* keys a pulled one does. +var ociDefaultEnv = []string{ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=xterm", +} + +// putEnv sets entry in env, replacing a value already there only when clobber. +func putEnv(env []string, entry string, clobber bool) []string { + name, _, ok := strings.Cut(entry, "=") + if !ok { + return env + } + + for i, e := range env { + if strings.HasPrefix(e, name+"=") { + if clobber { + env[i] = entry + } + return env + } + } + return append(env, entry) +} + +// rootfsHome returns uid's home from the rootfs tar's /etc/passwd, or "" when there is no entry. +func rootfsHome(path string, uid uint64) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + tr := tar.NewReader(f) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return "", nil + } + if err != nil { + return "", err + } + + if strings.TrimPrefix(hdr.Name, "./") != "etc/passwd" { + continue + } + + scanner := bufio.NewScanner(tr) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), ":") + if len(fields) >= 6 && fields[2] == strconv.FormatUint(uid, 10) { + return fields[5], nil + } + } + return "", scanner.Err() + } +} + func buildConfigWithInlineDockerfile(cfg *BuildConfig) (*BuildConfig, func(), error) { if cfg.DockerfileInline == "" { return cfg, func() {}, nil diff --git a/client/build_test.go b/client/build_test.go index d95ac3f8..86dbbf62 100644 --- a/client/build_test.go +++ b/client/build_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "io" "os" + "path/filepath" + "slices" "testing" "github.com/stretchr/testify/require" @@ -174,6 +176,72 @@ func TestBuildConfigWithInlineDockerfileRejectsDockerfile(t *testing.T) { require.Error(t, err) } +func TestPutEnv(t *testing.T) { + t.Parallel() + + env := putEnv(slices.Clone(ociDefaultEnv), "PATH=/opt/bin", true) + require.Equal(t, []string{"PATH=/opt/bin", "TERM=xterm"}, env) + + env = putEnv(env, "TERM=dumb", false) + require.Equal(t, []string{"PATH=/opt/bin", "TERM=xterm"}, env) + + env = putEnv(env, "HOME=/root", false) + require.Equal(t, []string{"PATH=/opt/bin", "TERM=xterm", "HOME=/root"}, env) + + env = putEnv(env, "NOTANASSIGNMENT", true) + require.Equal(t, []string{"PATH=/opt/bin", "TERM=xterm", "HOME=/root"}, env) +} + +func writeRootfsTar(t *testing.T, files map[string]string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "rootfs.tar") + f, err := os.Create(path) + require.NoError(t, err) + defer func() { require.NoError(t, f.Close()) }() + + tw := tar.NewWriter(f) + for name, content := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(content)), + })) + _, err = tw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + + return path +} + +func TestRootfsHome(t *testing.T) { + t.Parallel() + + passwd := "root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:nobody:/:/sbin/nologin\napp:x:1000:1000::/home/app:/bin/sh\n" + + tests := []struct { + name string + files map[string]string + uid uint64 + home string + }{ + {"root", map[string]string{"etc/passwd": passwd}, 0, "/root"}, + {"named user", map[string]string{"etc/passwd": passwd}, 1000, "/home/app"}, + {"dot prefixed entry", map[string]string{"./etc/passwd": passwd}, 0, "/root"}, + {"no entry for uid", map[string]string{"etc/passwd": passwd}, 42, ""}, + {"no passwd file", map[string]string{"etc/hosts": "127.0.0.1 localhost\n"}, 0, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + home, err := rootfsHome(writeRootfsTar(t, tt.files), tt.uid) + require.NoError(t, err) + require.Equal(t, tt.home, home) + }) + } +} + func TestBuildArgs_Docker(t *testing.T) { t.Parallel() cfg := &BuildConfig{ diff --git a/cmd/incus-compose/build_test.go b/cmd/incus-compose/build_test.go index f1067f76..18472032 100644 --- a/cmd/incus-compose/build_test.go +++ b/cmd/incus-compose/build_test.go @@ -113,6 +113,49 @@ RUN echo "built by incus-compose" require.Error(t, client.RunAction(ctx, r, client.ActionEnsure)) } +// TestE2EBuildImageEnvironment pins the built image to the environment.* keys +// Incus derives itself when it unpacks a pulled OCI image. +func TestE2EBuildImageEnvironment(t *testing.T) { + skipE2E(t) + skipLocal(t) + skipIfNoBuilder(t) + t.Parallel() + + ctx := t.Context() + pn := t.Name() + dir := writeTempFiles(t, map[string]string{ + "Dockerfile": `FROM docker.io/alpine:latest +ENV GREETING=hello +ENV PATH=/opt/bin:/usr/bin +`, + "compose.yaml": `services: + app: + build: + no_cache: true + context: . +`}) + compose := filepath.Join(dir, "compose.yaml") + + t.Cleanup(func() { + _, _ = runCommand(context.Background(), t, pn, "-f", compose, "down", "--project") + }) + + _, err := runCommand(ctx, t, pn, "-f", compose, "up", "--detach", "--no-start", "--no-healthd") + require.NoError(t, err) + + c := projectClient(ctx, t, pn) + conn, err := c.Connection() + require.NoError(t, err) + + inst, _, err := conn.GetInstance(ctx, "app-1", nil) + require.NoError(t, err) + + require.Equal(t, "hello", inst.Config["environment.GREETING"]) + require.Equal(t, "/opt/bin:/usr/bin", inst.Config["environment.PATH"]) + require.Equal(t, "/root", inst.Config["environment.HOME"]) + require.Equal(t, "xterm", inst.Config["environment.TERM"]) +} + func TestBuildCommandWithNoBuildServices(t *testing.T) { skipLocal(t) t.Parallel()