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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 76 additions & 4 deletions client/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package client

import (
"archive/tar"
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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:])
Expand Down Expand Up @@ -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)},
},
Expand All @@ -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
Expand Down
68 changes: 68 additions & 0 deletions client/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"archive/tar"
"io"
"os"
"path/filepath"
"slices"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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{
Expand Down
43 changes: 43 additions & 0 deletions cmd/incus-compose/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down