Skip to content
Open
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
127 changes: 127 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
testShmSize,
testUlimit,
testCgroupParent,
testLinuxResources,
testLinuxResourcesMergeOnDedup,
testNetworkMode,
testFrontendMetadataReturn,
testFrontendUseSolveResults,
Expand Down Expand Up @@ -1242,6 +1244,131 @@ func testCgroupParent(t *testing.T, sb integration.Sandbox) {
require.Equal(t, "", strings.TrimSpace(string(dt)))
}

func testLinuxResources(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
if sb.Rootless() {
t.SkipNow()
}

if _, err := os.Lstat("/sys/fs/cgroup/cgroup.subtree_control"); os.IsNotExist(err) {
t.Skipf("test requires cgroup v2")
}

c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

img := llb.Image("alpine:latest")
st := llb.Scratch()

run := func(cmd string, ro ...llb.RunOption) {
st = img.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st)
}

// Test memory limit: set 64MiB and verify via cgroup
run(`sh -c "cat /sys/fs/cgroup/memory.max > mem_limited"`, llb.MemoryLimit(64*1024*1024))
run(`sh -c "cat /sys/fs/cgroup/memory.max > mem_default"`)

// Test CPU quota: set quota=50000 period=100000 (50% CPU) and verify
run(`sh -c "cat /sys/fs/cgroup/cpu.max > cpu_limited"`, llb.CPUQuota(50000), llb.CPUPeriod(100000))

def, err := st.Marshal(sb.Context())
require.NoError(t, err)

destDir := t.TempDir()

_, err = c.Solve(sb.Context(), def, SolveOpt{
Exports: []ExportEntry{
{
Type: ExporterLocal,
OutputDir: destDir,
},
},
}, nil)
require.NoError(t, err)

dt, err := os.ReadFile(filepath.Join(destDir, "mem_limited"))
require.NoError(t, err)
require.Equal(t, "67108864", strings.TrimSpace(string(dt)))

dt2, err := os.ReadFile(filepath.Join(destDir, "mem_default"))
require.NoError(t, err)
require.Equal(t, "max", strings.TrimSpace(string(dt2)))

dt3, err := os.ReadFile(filepath.Join(destDir, "cpu_limited"))
require.NoError(t, err)
require.Equal(t, "50000 100000", strings.TrimSpace(string(dt3)))
}

// testLinuxResourcesMergeOnDedup verifies that when two concurrent builds share
// the same RUN step but specify different resource limits, the most relaxed
// (least restrictive) limit is applied.
func testLinuxResourcesMergeOnDedup(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
if sb.Rootless() {
t.SkipNow()
}

if _, err := os.Lstat("/sys/fs/cgroup/cgroup.subtree_control"); os.IsNotExist(err) {
t.Skipf("test requires cgroup v2")
}

c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

// Both builds share the exact same RUN command so the solver deduplicates
// them into one vertex. They differ only in memory limits (OpMetadata).
// Dedup is guaranteed because loadUnlocked (which merges resources) loads
// the full vertex graph in microseconds, while image resolution that must
// complete before the RUN vertex can execute takes orders of magnitude longer.
// Both goroutines will have loaded and merged before the RUN step starts.
sharedCmd := `sh -c "cat /sys/fs/cgroup/memory.max > /wd/mem_limit"`

// Build 1: 64 MiB memory limit
st1 := llb.Image("alpine:latest").
Run(llb.Shlex(sharedCmd), llb.MemoryLimit(64*1024*1024), llb.Dir("/wd")).
AddMount("/wd", llb.Scratch())
def1, err := st1.Marshal(sb.Context())
require.NoError(t, err)

// Build 2: 128 MiB memory limit (more relaxed — should win)
st2 := llb.Image("alpine:latest").
Run(llb.Shlex(sharedCmd), llb.MemoryLimit(128*1024*1024), llb.Dir("/wd")).
AddMount("/wd", llb.Scratch())
def2, err := st2.Marshal(sb.Context())
require.NoError(t, err)

destDir1 := t.TempDir()
destDir2 := t.TempDir()

eg, egCtx := errgroup.WithContext(sb.Context())
eg.Go(func() error {
_, err := c.Solve(egCtx, def1, SolveOpt{
Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir1}},
}, nil)
return err
})
eg.Go(func() error {
_, err := c.Solve(egCtx, def2, SolveOpt{
Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir2}},
}, nil)
return err
})
err = eg.Wait()
require.NoError(t, err)

// Both builds share the deduplicated vertex, so both outputs come from
// the same container. The memory limit should be 128 MiB (most relaxed).
for _, dir := range []string{destDir1, destDir2} {
dt, err := os.ReadFile(filepath.Join(dir, "mem_limit"))
require.NoError(t, err)
memLimit := strings.TrimSpace(string(dt))
require.Equal(t, "134217728", memLimit,
"expected 128 MiB (most relaxed limit) but got %s in %s", memLimit, dir)
}
}

func testNetworkMode(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
c, err := New(sb.Context(), sb.Address())
Expand Down
4 changes: 4 additions & 0 deletions client/llb/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ func (e *ExecOp) Marshal(ctx context.Context, c *Constraints) (digest.Digest, []
meta.Ulimit = ul
}

if e.constraints.Metadata.LinuxResources != nil {
addCap(&e.constraints, pb.CapExecMetaLinuxResources)
}

network, err := getNetwork(e.base)(ctx, c)
if err != nil {
return "", nil, nil, nil, err
Expand Down
91 changes: 91 additions & 0 deletions client/llb/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,97 @@ func TestValidGetMountIndex(t *testing.T) {
require.Equal(t, pb.OutputIndex(1), mountIndex, "unexpected mount index")
}

func TestLinuxResourcesMarshal(t *testing.T) {
t.Parallel()

st := Image("busybox:latest").
Run(
Shlex("true"),
MemoryLimit(64*1024*1024),
CPUShares(512),
CPUQuota(50000),
CPUPeriod(100000),
CPUsetCPUs("0-3"),
CPUsetMems("0"),
).Root()

def, err := st.Marshal(context.TODO())
require.NoError(t, err)

// Resources should be in OpMetadata (not in the Op bytes / cache key)
var found bool
for _, md := range def.Metadata {
if md.LinuxResources == nil {
continue
}
found = true
res := md.LinuxResources
require.Equal(t, int64(64*1024*1024), res.Memory)
require.Equal(t, uint64(512), res.CpuShares)
require.Equal(t, int64(50000), res.CpuQuota)
require.Equal(t, uint64(100000), res.CpuPeriod)
require.Equal(t, "0-3", res.CpusetCpus)
require.Equal(t, "0", res.CpusetMems)
}
require.True(t, found, "LinuxResources not found in OpMetadata")
}

func TestLinuxResourcesNotInCacheKey(t *testing.T) {
t.Parallel()

// Two ops with same command but different resource limits must produce the same digest
st1 := Image("busybox:latest").
Run(Shlex("echo hello"), MemoryLimit(64*1024*1024)).Root()

st2 := Image("busybox:latest").
Run(Shlex("echo hello"), MemoryLimit(128*1024*1024)).Root()

st3 := Image("busybox:latest").
Run(Shlex("echo hello")).Root()

def1, err := st1.Marshal(context.TODO())
require.NoError(t, err)

def2, err := st2.Marshal(context.TODO())
require.NoError(t, err)

def3, err := st3.Marshal(context.TODO())
require.NoError(t, err)

// All three should produce the same definition bytes (same cache key)
require.Equal(t, def1.Def, def2.Def, "different resource limits should produce same digest")
require.Equal(t, def1.Def, def3.Def, "resource limits vs no limits should produce same digest")
}

func TestLinuxResourcesMerge(t *testing.T) {
t.Parallel()

// Test that individual resource limit functions merge correctly
st := Image("busybox:latest").
Run(
Shlex("true"),
MemoryLimit(64*1024*1024),
CPUShares(512),
).Root()

def, err := st.Marshal(context.TODO())
require.NoError(t, err)

for _, md := range def.Metadata {
if md.LinuxResources == nil {
continue
}
res := md.LinuxResources
require.Equal(t, int64(64*1024*1024), res.Memory)
require.Equal(t, uint64(512), res.CpuShares)
// Unset fields should be zero
require.Equal(t, int64(0), res.CpuQuota)
require.Equal(t, uint64(0), res.CpuPeriod)
return
}
t.Fatal("LinuxResources not found in OpMetadata")
}

func TestExecOpMarshalConsistency(t *testing.T) {
var prevDef [][]byte
st := Image("busybox:latest").
Expand Down
11 changes: 11 additions & 0 deletions client/llb/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,17 @@ func getCgroupParent(s State) func(context.Context, *Constraints) (string, error
}
}

// LinuxResources holds CPU and memory resource limits for containers.
type LinuxResources struct {
Memory int64
MemorySwap int64
CPUShares uint64
CPUPeriod uint64
CPUQuota int64
CPUsetCPUs string
CPUsetMems string
}

// Network returns a [StateOption] which sets the network mode used for containers created by [State.Run].
// This is the equivalent of [State.Network]
// See [State.With] for where to use this.
Expand Down
Loading