From 5fa2341eac291e186b528561b1d976f91f40f750 Mon Sep 17 00:00:00 2001 From: Anmol Virdi Date: Mon, 3 Aug 2026 21:55:14 +0530 Subject: [PATCH 1/6] Fix: auto-inject CAP_SYS_ADMIN whenever xcover is used in melange Signed-off-by: Anmol Virdi --- pkg/build/compile.go | 30 +++++++++++ pkg/build/compile_test.go | 68 +++++++++++++++++++++++++ pkg/build/pipelines/xcover/profile.yaml | 4 ++ pkg/config/config.go | 3 ++ pkg/config/schema.cue | 5 ++ pkg/config/schema.json | 22 ++++++++ 6 files changed, 132 insertions(+) diff --git a/pkg/build/compile.go b/pkg/build/compile.go index 2500550ce..80e5562a8 100644 --- a/pkg/build/compile.go +++ b/pkg/build/compile.go @@ -91,6 +91,9 @@ func (t *Test) Compile(ctx context.Context) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) + + // Merge any capabilities this subpackage test needs into the runner. + mergeCapabilities(&t.Configuration.Capabilities, test.Capabilities) } if cfg.Test != nil { @@ -116,6 +119,9 @@ func (t *Test) Compile(ctx context.Context) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) + + // Merge any capabilities the main package test needs into the runner. + mergeCapabilities(&t.Configuration.Capabilities, test.Capabilities) } return nil @@ -172,11 +178,17 @@ func (b *Build) Compile(ctx context.Context) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) + + // Merge any capabilities this subpackage test needs into the runner. + mergeCapabilities(&b.Configuration.Capabilities, tc.Capabilities) } ic := &b.Configuration.Environment.Contents ic.Packages = append(ic.Packages, c.Needs...) + // Merge any capabilities the build pipelines need into the runner. + mergeCapabilities(&b.Configuration.Capabilities, c.Capabilities) + if cfg.Test != nil { tc := &Compiled{ PipelineDirs: b.PipelineDirs, @@ -194,6 +206,9 @@ func (b *Build) Compile(ctx context.Context) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) + + // Merge any capabilities the main package test needs into the runner. + mergeCapabilities(&b.Configuration.Capabilities, tc.Capabilities) } return nil @@ -202,6 +217,7 @@ func (b *Build) Compile(ctx context.Context) error { type Compiled struct { PipelineDirs []string Needs []string + Capabilities config.Capabilities } func (c *Compiled) CompilePipelines(ctx context.Context, sm *SubstitutionMap, pipelines []config.Pipeline) error { @@ -356,6 +372,17 @@ func (c *Compiled) compilePipeline(ctx context.Context, sm *SubstitutionMap, pip return nil } +// mergeCapabilities merges the capabilities gathered from pipelines into the +// runner's capabilities configuration, sorting and removing duplicates. +func mergeCapabilities(dst *config.Capabilities, src config.Capabilities) { + if len(src.Add) > 0 { + dst.Add = slices.Compact(slices.Sorted(slices.Values(append(dst.Add, src.Add...)))) + } + if len(src.Drop) > 0 { + dst.Drop = slices.Compact(slices.Sorted(slices.Values(append(dst.Drop, src.Drop...)))) + } +} + func identity(p *config.Pipeline) string { if p.Name != "" { return p.Name @@ -386,6 +413,9 @@ func (c *Compiled) gatherDeps(ctx context.Context, pipeline *config.Pipeline) er } c.Needs = append(c.Needs, pipeline.Needs.Packages...) + c.Capabilities.Add = append(c.Capabilities.Add, pipeline.Needs.Capabilities.Add...) + c.Capabilities.Drop = append(c.Capabilities.Drop, pipeline.Needs.Capabilities.Drop...) + pipeline.Needs = nil } diff --git a/pkg/build/compile_test.go b/pkg/build/compile_test.go index 64f7cdc42..f08430f31 100644 --- a/pkg/build/compile_test.go +++ b/pkg/build/compile_test.go @@ -121,6 +121,74 @@ func TestCompileTest(t *testing.T) { } } +func TestCompileCapabilities(t *testing.T) { + // Capabilities declared in the manifest and capabilities needed by a + // pipeline should both end up on the runner as a deduplicated union, not + // have one overwrite the other. + needs := func() *config.Needs { + return &config.Needs{ + Capabilities: config.Capabilities{ + // A new capability, plus a duplicate of a manifest one. + Add: []string{"CAP_SYS_ADMIN", "CAP_NET_ADMIN"}, + Drop: []string{"CAP_MKNOD"}, + }, + } + } + + manifestCaps := func() config.Capabilities { + return config.Capabilities{ + Add: []string{"CAP_NET_ADMIN"}, + Drop: []string{"CAP_MKNOD"}, + } + } + + wantAdd := []string{"CAP_NET_ADMIN", "CAP_SYS_ADMIN"} + wantDrop := []string{"CAP_MKNOD"} + + t.Run("test", func(t *testing.T) { + test := &Test{ + Package: "main", + Configuration: config.Configuration{ + Capabilities: manifestCaps(), + Test: &config.Test{ + Pipeline: []config.Pipeline{{Needs: needs()}}, + }, + }, + } + + if err := test.Compile(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := test.Configuration.Capabilities.Add; !slices.Equal(got, wantAdd) { + t.Errorf("add capabilities: want %v, got %v", wantAdd, got) + } + if got := test.Configuration.Capabilities.Drop; !slices.Equal(got, wantDrop) { + t.Errorf("drop capabilities: want %v, got %v", wantDrop, got) + } + }) + + t.Run("build", func(t *testing.T) { + build := &Build{ + Configuration: &config.Configuration{ + Capabilities: manifestCaps(), + Pipeline: []config.Pipeline{{Needs: needs()}}, + }, + } + + if err := build.Compile(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := build.Configuration.Capabilities.Add; !slices.Equal(got, wantAdd) { + t.Errorf("add capabilities: want %v, got %v", wantAdd, got) + } + if got := build.Configuration.Capabilities.Drop; !slices.Equal(got, wantDrop) { + t.Errorf("drop capabilities: want %v, got %v", wantDrop, got) + } + }) +} + func Test_stripComments(t *testing.T) { tests := []struct { in, want string diff --git a/pkg/build/pipelines/xcover/profile.yaml b/pkg/build/pipelines/xcover/profile.yaml index 269589a65..107b3577e 100644 --- a/pkg/build/pipelines/xcover/profile.yaml +++ b/pkg/build/pipelines/xcover/profile.yaml @@ -4,6 +4,10 @@ needs: packages: - busybox - ${{inputs.package}} + capabilities: + add: + # xcover attaches BPF uprobes to the profiled interpreter. + - CAP_SYS_ADMIN inputs: package: diff --git a/pkg/config/config.go b/pkg/config/config.go index 875294249..82f6b8cf2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -580,6 +580,9 @@ func (p Package) FullCopyright() string { type Needs struct { // A list of packages needed by this pipeline Packages []string + // Optional: Linux capabilities needed by this pipeline. These are merged + // into the runner's capabilities configuration whenever the pipeline is used. + Capabilities Capabilities `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` } type PipelineAssertions struct { diff --git a/pkg/config/schema.cue b/pkg/config/schema.cue index 717094363..f4ec8e715 100644 --- a/pkg/config/schema.cue +++ b/pkg/config/schema.cue @@ -281,6 +281,11 @@ #Needs: close({ // A list of packages needed by this pipeline Packages!: [...string] + + // Optional: Linux capabilities needed by this pipeline. These are + // merged into the runner's capabilities configuration whenever the + // pipeline is used. + capabilities?: #Capabilities }) // OCIMonitor indicates using OCI image tags diff --git a/pkg/config/schema.json b/pkg/config/schema.json index 577c3fc6e..a844a6b7c 100644 --- a/pkg/config/schema.json +++ b/pkg/config/schema.json @@ -518,6 +518,12 @@ }, "type": "array" }, + "runtime_keyring": { + "items": { + "$ref": "#/$defs/RuntimeKeyringEntry" + }, + "type": "array" + }, "packages": { "items": { "type": "string" @@ -613,6 +619,10 @@ }, "type": "array", "description": "A list of packages needed by this pipeline" + }, + "capabilities": { + "$ref": "#/$defs/Capabilities", + "description": "Optional: Linux capabilities needed by this pipeline. These are merged\ninto the runner's capabilities configuration whenever the pipeline is used." } }, "additionalProperties": false, @@ -938,6 +948,18 @@ "additionalProperties": false, "type": "object" }, + "RuntimeKeyringEntry": { + "properties": { + "name": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, "Schedule": { "properties": { "reason": { From b0e1e1e604527bf0053dfff74bc12c60cd9499aa Mon Sep 17 00:00:00 2001 From: Anmol Virdi Date: Mon, 3 Aug 2026 22:01:28 +0530 Subject: [PATCH 2/6] Fix: revert unintended changes Signed-off-by: Anmol Virdi --- pkg/config/schema.json | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/pkg/config/schema.json b/pkg/config/schema.json index a844a6b7c..6a8d665fa 100644 --- a/pkg/config/schema.json +++ b/pkg/config/schema.json @@ -518,12 +518,6 @@ }, "type": "array" }, - "runtime_keyring": { - "items": { - "$ref": "#/$defs/RuntimeKeyringEntry" - }, - "type": "array" - }, "packages": { "items": { "type": "string" @@ -948,18 +942,6 @@ "additionalProperties": false, "type": "object" }, - "RuntimeKeyringEntry": { - "properties": { - "name": { - "type": "string" - }, - "content": { - "type": "string" - } - }, - "additionalProperties": false, - "type": "object" - }, "Schedule": { "properties": { "reason": { From f0e7cb4a45a9713ad9d4ebc4f1e01183538f37ff Mon Sep 17 00:00:00 2001 From: Anmol Virdi Date: Mon, 3 Aug 2026 22:07:59 +0530 Subject: [PATCH 3/6] Fix: linting errors Signed-off-by: Anmol Virdi --- pkg/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 82f6b8cf2..7d4494894 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -582,7 +582,7 @@ type Needs struct { Packages []string // Optional: Linux capabilities needed by this pipeline. These are merged // into the runner's capabilities configuration whenever the pipeline is used. - Capabilities Capabilities `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + Capabilities Capabilities `json:"capabilities,omitzero" yaml:"capabilities,omitempty"` } type PipelineAssertions struct { From b61c13e7d2ee54c47604ba06e999017e222e600b Mon Sep 17 00:00:00 2001 From: Anmol Virdi Date: Sat, 8 Aug 2026 07:59:31 +0530 Subject: [PATCH 4/6] Fix: refine pipeline instructions Signed-off-by: Anmol Virdi --- docs/BUILD-PROCESS.md | 13 +++++++++- pkg/build/compile.go | 53 +++++++++++++++++++++++++++------------ pkg/build/compile_test.go | 52 +++++++++++++++++++++++++++++--------- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/docs/BUILD-PROCESS.md b/docs/BUILD-PROCESS.md index 29c9883de..05406a47b 100644 --- a/docs/BUILD-PROCESS.md +++ b/docs/BUILD-PROCESS.md @@ -24,6 +24,17 @@ needs: - wget ``` +A pipeline can also declare the Linux capabilities it needs, which are merged into the runner running the pipeline. For example, a pipeline that attaches BPF probes needs `CAP_SYS_ADMIN`: + +```yaml +needs: + capabilities: + add: + - CAP_SYS_ADMIN +``` + +Capabilities declared by build pipelines are granted to the build runner, and those declared by test pipelines are granted to the test runner under `melange test`; capabilities from test pipelines are never granted to the build runner. + ## Where does Melange build? The melange build process involves three normally distinct directories. @@ -74,7 +85,7 @@ persist. The build process is as follows. The core routine is [`BuildPackage()`](../pkg/build/build.go#L716). -1. Evaluate each step in the pipeline to see if it has a `needs` section. If so, then add its listed packages to the build time package requirements defined in `environment.contents`. +1. Evaluate each step in the pipeline to see if it has a `needs` section. If so, then add its listed packages to the build time package requirements defined in `environment.contents`, and merge any capabilities it lists into the runner. 1. Use [apko](https://github.com/chainguard-dev/apko) to create a tar stream of the packages listed in `environment.contents` and lay them out onto the workspace directory. 1. Overlay `/bin/sh`. This is an optimization step, and is not discussed here. Read [Shell Overlay](./SHELL-OVERLAY.md) for more information. 1. Populate the build cache. This is an optimization step, and is not discussed here. Read [Build Cache](./BUILD-CACHE.md) for more information. diff --git a/pkg/build/compile.go b/pkg/build/compile.go index b4ac023c4..1ba302ffd 100644 --- a/pkg/build/compile.go +++ b/pkg/build/compile.go @@ -113,7 +113,7 @@ func (t *Test) Compile(ctx context.Context) error { te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) // Merge any capabilities this subpackage test needs into the runner. - mergeCapabilities(&t.Configuration.Capabilities, test.Capabilities) + mergeCapabilities(ctx, &t.Configuration.Capabilities, test.Capabilities) } if cfg.Test != nil { @@ -141,7 +141,7 @@ func (t *Test) Compile(ctx context.Context) error { te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) // Merge any capabilities the main package test needs into the runner. - mergeCapabilities(&t.Configuration.Capabilities, test.Capabilities) + mergeCapabilities(ctx, &t.Configuration.Capabilities, test.Capabilities) } return nil @@ -195,15 +195,17 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - // Merge any capabilities this subpackage test needs into the runner. - mergeCapabilities(&b.Configuration.Capabilities, tc.Capabilities) + // Capabilities from test pipelines are intentionally not merged here: + // `melange build` never runs the test pipelines, so granting them to the + // build runner would only over-privilege it. They are applied by + // Test.Compile when the tests actually run under `melange test`. } ic := &b.Configuration.Environment.Contents ic.Packages = append(ic.Packages, c.Needs...) // Merge any capabilities the build pipelines need into the runner. - mergeCapabilities(&b.Configuration.Capabilities, c.Capabilities) + mergeCapabilities(ctx, &b.Configuration.Capabilities, c.Capabilities) if cfg.Test != nil { tc := newCompiled(b.PipelineDirs, opts) @@ -221,8 +223,8 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - // Merge any capabilities the main package test needs into the runner. - mergeCapabilities(&b.Configuration.Capabilities, tc.Capabilities) + // Capabilities from test pipelines are intentionally not merged here; + // see the note in the subpackage test loop above. } return nil @@ -386,14 +388,24 @@ func (c *Compiled) compilePipeline(ctx context.Context, sm *SubstitutionMap, pip return nil } -// mergeCapabilities merges the capabilities gathered from pipelines into the -// runner's capabilities configuration, sorting and removing duplicates. -func mergeCapabilities(dst *config.Capabilities, src config.Capabilities) { - if len(src.Add) > 0 { - dst.Add = slices.Compact(slices.Sorted(slices.Values(append(dst.Add, src.Add...)))) +// mergeCapabilities folds the capabilities gathered from pipelines (src) into +// the runner's capabilities configuration (dst), keeping each of the resulting +// Add and Drop sets sorted and deduplicated. A capability that ends up in both +// Add and Drop is a conflict the runners resolve inconsistently (under +// bubblewrap the drop silently wins on flag order; docker/qemu decide +// downstream), so it is surfaced as a warning at compile time. +func mergeCapabilities(ctx context.Context, dst *config.Capabilities, src config.Capabilities) { + dst.Add = slices.Compact(slices.Sorted(slices.Values(append(dst.Add, src.Add...)))) + dst.Drop = slices.Compact(slices.Sorted(slices.Values(append(dst.Drop, src.Drop...)))) + + var conflicts []string + for _, c := range dst.Add { + if slices.Contains(dst.Drop, c) { + conflicts = append(conflicts, c) + } } - if len(src.Drop) > 0 { - dst.Drop = slices.Compact(slices.Sorted(slices.Values(append(dst.Drop, src.Drop...)))) + if len(conflicts) > 0 { + clog.FromContext(ctx).Warnf("capabilities %v are both added and dropped; the runner decides which wins", conflicts) } } @@ -461,8 +473,17 @@ func (c *Compiled) gatherDeps(ctx context.Context, pipeline *config.Pipeline) er } c.Needs = append(c.Needs, pipeline.Needs.Packages...) - c.Capabilities.Add = append(c.Capabilities.Add, pipeline.Needs.Capabilities.Add...) - c.Capabilities.Drop = append(c.Capabilities.Drop, pipeline.Needs.Capabilities.Drop...) + // Widening the sandbox is more consequential than adding a package, so + // surface it at Info (not Debug like packages above). + caps := pipeline.Needs.Capabilities + if len(caps.Add) > 0 { + log.Infof("pipeline %q adds capabilities %v to the runner", id, caps.Add) + } + if len(caps.Drop) > 0 { + log.Infof("pipeline %q drops capabilities %v from the runner", id, caps.Drop) + } + c.Capabilities.Add = append(c.Capabilities.Add, caps.Add...) + c.Capabilities.Drop = append(c.Capabilities.Drop, caps.Drop...) pipeline.Needs = nil } diff --git a/pkg/build/compile_test.go b/pkg/build/compile_test.go index f08430f31..95f1cb8aa 100644 --- a/pkg/build/compile_test.go +++ b/pkg/build/compile_test.go @@ -142,10 +142,12 @@ func TestCompileCapabilities(t *testing.T) { } } - wantAdd := []string{"CAP_NET_ADMIN", "CAP_SYS_ADMIN"} - wantDrop := []string{"CAP_MKNOD"} + wantUnionAdd := []string{"CAP_NET_ADMIN", "CAP_SYS_ADMIN"} + wantUnionDrop := []string{"CAP_MKNOD"} - t.Run("test", func(t *testing.T) { + // Under `melange test`, a test pipeline's capabilities are merged with the + // manifest's as a deduplicated union. + t.Run("test merges union", func(t *testing.T) { test := &Test{ Package: "main", Configuration: config.Configuration{ @@ -160,15 +162,16 @@ func TestCompileCapabilities(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if got := test.Configuration.Capabilities.Add; !slices.Equal(got, wantAdd) { - t.Errorf("add capabilities: want %v, got %v", wantAdd, got) + if got := test.Configuration.Capabilities.Add; !slices.Equal(got, wantUnionAdd) { + t.Errorf("add capabilities: want %v, got %v", wantUnionAdd, got) } - if got := test.Configuration.Capabilities.Drop; !slices.Equal(got, wantDrop) { - t.Errorf("drop capabilities: want %v, got %v", wantDrop, got) + if got := test.Configuration.Capabilities.Drop; !slices.Equal(got, wantUnionDrop) { + t.Errorf("drop capabilities: want %v, got %v", wantUnionDrop, got) } }) - t.Run("build", func(t *testing.T) { + // A build pipeline's capabilities are merged into the build runner. + t.Run("build pipeline merges union", func(t *testing.T) { build := &Build{ Configuration: &config.Configuration{ Capabilities: manifestCaps(), @@ -180,11 +183,36 @@ func TestCompileCapabilities(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if got := build.Configuration.Capabilities.Add; !slices.Equal(got, wantAdd) { - t.Errorf("add capabilities: want %v, got %v", wantAdd, got) + if got := build.Configuration.Capabilities.Add; !slices.Equal(got, wantUnionAdd) { + t.Errorf("add capabilities: want %v, got %v", wantUnionAdd, got) } - if got := build.Configuration.Capabilities.Drop; !slices.Equal(got, wantDrop) { - t.Errorf("drop capabilities: want %v, got %v", wantDrop, got) + if got := build.Configuration.Capabilities.Drop; !slices.Equal(got, wantUnionDrop) { + t.Errorf("drop capabilities: want %v, got %v", wantUnionDrop, got) + } + }) + + // `melange build` never runs test pipelines, so capabilities they declare + // must not leak into the build runner; only the manifest's remain. + t.Run("build does not leak test caps", func(t *testing.T) { + build := &Build{ + Configuration: &config.Configuration{ + Package: config.Package{Name: "main"}, + Capabilities: manifestCaps(), + Test: &config.Test{ + Pipeline: []config.Pipeline{{Needs: needs()}}, + }, + }, + } + + if err := build.Compile(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := build.Configuration.Capabilities.Add, []string{"CAP_NET_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("add capabilities leaked from test pipeline: want %v, got %v", want, got) + } + if got, want := build.Configuration.Capabilities.Drop, []string{"CAP_MKNOD"}; !slices.Equal(got, want) { + t.Errorf("drop capabilities: want %v, got %v", want, got) } }) } From f0d4d9e9dc70e045851f1d6da1873bd293817a3d Mon Sep 17 00:00:00 2001 From: Anmol Virdi Date: Tue, 1 Sep 2026 11:05:19 +0530 Subject: [PATCH 5/6] Fix: refine overriding instructions Signed-off-by: Anmol Virdi --- docs/BUILD-PROCESS.md | 4 +- pkg/build/compile.go | 70 ++++++++++--------- pkg/build/compile_test.go | 143 +++++++++++++++++++++++++++----------- pkg/build/test.go | 22 +++--- pkg/build/test_test.go | 2 +- pkg/config/config.go | 57 +++++++++++++-- pkg/config/schema.cue | 25 +++++-- pkg/config/schema.json | 27 +++++-- 8 files changed, 247 insertions(+), 103 deletions(-) diff --git a/docs/BUILD-PROCESS.md b/docs/BUILD-PROCESS.md index 05406a47b..abd46f40b 100644 --- a/docs/BUILD-PROCESS.md +++ b/docs/BUILD-PROCESS.md @@ -24,7 +24,7 @@ needs: - wget ``` -A pipeline can also declare the Linux capabilities it needs, which are merged into the runner running the pipeline. For example, a pipeline that attaches BPF probes needs `CAP_SYS_ADMIN`: +A pipeline can also declare the Linux capabilities it needs added to its runner. Only additions are supported: a pipeline states what it requires, it cannot drop a capability from the other steps sharing its container. For example, a pipeline that attaches BPF probes needs `CAP_SYS_ADMIN`: ```yaml needs: @@ -33,7 +33,7 @@ needs: - CAP_SYS_ADMIN ``` -Capabilities declared by build pipelines are granted to the build runner, and those declared by test pipelines are granted to the test runner under `melange test`; capabilities from test pipelines are never granted to the build runner. +Capabilities declared by build pipelines are added to the build runner. Capabilities declared by test pipelines are scoped to that test's runner under `melange test`, so a capability one subpackage's test needs is not granted to sibling tests or to the build runner. Names are checked while the pipeline is compiled, so a misspelled `CAP_*` fails the build rather than the container. ## Where does Melange build? diff --git a/pkg/build/compile.go b/pkg/build/compile.go index 1ba302ffd..962782c99 100644 --- a/pkg/build/compile.go +++ b/pkg/build/compile.go @@ -106,14 +106,15 @@ func (t *Test) Compile(ctx context.Context) error { return fmt.Errorf("compiling subpackage %q tests: %w", sp.Name, err) } - // Append anything this subpackage test needs. + // Append anything this subpackage test needs. Packages and capabilities + // are scoped to this subpackage's test container, not shared across + // every test in the configuration. te.Packages = append(te.Packages, test.Needs...) // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - // Merge any capabilities this subpackage test needs into the runner. - mergeCapabilities(ctx, &t.Configuration.Capabilities, test.Capabilities) + addCapabilities(&cfg.Subpackages[i].Test.Capabilities, test.Capabilities) } if cfg.Test != nil { @@ -140,8 +141,7 @@ func (t *Test) Compile(ctx context.Context) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - // Merge any capabilities the main package test needs into the runner. - mergeCapabilities(ctx, &t.Configuration.Capabilities, test.Capabilities) + addCapabilities(&t.Configuration.Test.Capabilities, test.Capabilities) } return nil @@ -195,17 +195,18 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - // Capabilities from test pipelines are intentionally not merged here: + // Capabilities from test pipelines are intentionally not applied here: // `melange build` never runs the test pipelines, so granting them to the - // build runner would only over-privilege it. They are applied by - // Test.Compile when the tests actually run under `melange test`. + // build runner would only over-privilege it. Test.Compile scopes them to + // each test's runner under `melange test`. } ic := &b.Configuration.Environment.Contents ic.Packages = append(ic.Packages, c.Needs...) - // Merge any capabilities the build pipelines need into the runner. - mergeCapabilities(ctx, &b.Configuration.Capabilities, c.Capabilities) + // Capabilities needed by the build pipelines apply to the build runner. + addCapabilities(&b.Configuration.Capabilities, c.Capabilities) + warnCapabilityConflicts(ctx, b.Configuration.Capabilities) if cfg.Test != nil { tc := newCompiled(b.PipelineDirs, opts) @@ -222,9 +223,6 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - - // Capabilities from test pipelines are intentionally not merged here; - // see the note in the subpackage test loop above. } return nil @@ -233,7 +231,10 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { type Compiled struct { PipelineDirs []string Needs []string - Capabilities config.Capabilities + // Capabilities are the Linux capabilities the compiled pipelines request be + // added to their runner. Pipelines can only add capabilities, so this is a + // plain add-list rather than an add/drop pair. + Capabilities []string // dependenciesOnly skips producing runnable `runs:` bodies. See // WithDependenciesOnly. @@ -388,19 +389,24 @@ func (c *Compiled) compilePipeline(ctx context.Context, sm *SubstitutionMap, pip return nil } -// mergeCapabilities folds the capabilities gathered from pipelines (src) into -// the runner's capabilities configuration (dst), keeping each of the resulting -// Add and Drop sets sorted and deduplicated. A capability that ends up in both -// Add and Drop is a conflict the runners resolve inconsistently (under -// bubblewrap the drop silently wins on flag order; docker/qemu decide -// downstream), so it is surfaced as a warning at compile time. -func mergeCapabilities(ctx context.Context, dst *config.Capabilities, src config.Capabilities) { - dst.Add = slices.Compact(slices.Sorted(slices.Values(append(dst.Add, src.Add...)))) - dst.Drop = slices.Compact(slices.Sorted(slices.Values(append(dst.Drop, src.Drop...)))) +// addCapabilities folds the capabilities gathered from pipelines (adds) into +// the Add set of the runner's capabilities configuration (dst), sorting and +// deduplicating the result. It leaves dst untouched when there is nothing to +// add. +func addCapabilities(dst *config.Capabilities, adds []string) { + if len(adds) == 0 { + return + } + dst.Add = slices.Compact(slices.Sorted(slices.Values(append(dst.Add, adds...)))) +} +// warnCapabilityConflicts warns when a capability ends up in both the Add and +// Drop sets, which the runners resolve inconsistently (under bubblewrap the +// drop silently wins on flag order; docker/qemu decide downstream). +func warnCapabilityConflicts(ctx context.Context, caps config.Capabilities) { var conflicts []string - for _, c := range dst.Add { - if slices.Contains(dst.Drop, c) { + for _, c := range caps.Add { + if slices.Contains(caps.Drop, c) { conflicts = append(conflicts, c) } } @@ -475,15 +481,13 @@ func (c *Compiled) gatherDeps(ctx context.Context, pipeline *config.Pipeline) er // Widening the sandbox is more consequential than adding a package, so // surface it at Info (not Debug like packages above). - caps := pipeline.Needs.Capabilities - if len(caps.Add) > 0 { - log.Infof("pipeline %q adds capabilities %v to the runner", id, caps.Add) - } - if len(caps.Drop) > 0 { - log.Infof("pipeline %q drops capabilities %v from the runner", id, caps.Drop) + if adds := pipeline.Needs.Capabilities.Add; len(adds) > 0 { + if err := pipeline.Needs.Capabilities.Validate(); err != nil { + return fmt.Errorf("pipeline %q: %w", id, err) + } + log.Infof("pipeline %q adds capabilities %v to the runner", id, adds) + c.Capabilities = append(c.Capabilities, adds...) } - c.Capabilities.Add = append(c.Capabilities.Add, caps.Add...) - c.Capabilities.Drop = append(c.Capabilities.Drop, caps.Drop...) pipeline.Needs = nil } diff --git a/pkg/build/compile_test.go b/pkg/build/compile_test.go index 95f1cb8aa..290504a1a 100644 --- a/pkg/build/compile_test.go +++ b/pkg/build/compile_test.go @@ -16,7 +16,10 @@ package build import ( "context" + "os" + "path/filepath" "slices" + "strings" "testing" apko_types "chainguard.dev/apko/pkg/build/types" @@ -122,38 +125,51 @@ func TestCompileTest(t *testing.T) { } func TestCompileCapabilities(t *testing.T) { - // Capabilities declared in the manifest and capabilities needed by a - // pipeline should both end up on the runner as a deduplicated union, not - // have one overwrite the other. - needs := func() *config.Needs { - return &config.Needs{ - Capabilities: config.Capabilities{ - // A new capability, plus a duplicate of a manifest one. - Add: []string{"CAP_SYS_ADMIN", "CAP_NET_ADMIN"}, - Drop: []string{"CAP_MKNOD"}, - }, - } + needs := func(adds ...string) *config.Needs { + return &config.Needs{Capabilities: config.NeedsCapabilities{Add: adds}} } manifestCaps := func() config.Capabilities { - return config.Capabilities{ - Add: []string{"CAP_NET_ADMIN"}, - Drop: []string{"CAP_MKNOD"}, - } + return config.Capabilities{Add: []string{"CAP_NET_ADMIN"}} } - wantUnionAdd := []string{"CAP_NET_ADMIN", "CAP_SYS_ADMIN"} - wantUnionDrop := []string{"CAP_MKNOD"} - - // Under `melange test`, a test pipeline's capabilities are merged with the - // manifest's as a deduplicated union. - t.Run("test merges union", func(t *testing.T) { + // A test pipeline's capabilities are gathered onto the test they belong to, + // not onto the shared top-level capabilities, so they stay scoped to that + // test's runner and do not widen the build or sibling tests. + t.Run("test caps are scoped to the test", func(t *testing.T) { test := &Test{ Package: "main", Configuration: config.Configuration{ Capabilities: manifestCaps(), Test: &config.Test{ - Pipeline: []config.Pipeline{{Needs: needs()}}, + Pipeline: []config.Pipeline{{Needs: needs("CAP_SYS_ADMIN")}}, + }, + }, + } + + if err := test.Compile(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := test.Configuration.Test.Capabilities.Add, []string{"CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("test capabilities: want %v, got %v", want, got) + } + // The shared top-level set keeps only the manifest's caps. + if got, want := test.Configuration.Capabilities.Add, []string{"CAP_NET_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("top-level capabilities changed: want %v, got %v", want, got) + } + }) + + // One subpackage test using a capability must not grant it to sibling + // subpackage tests or the main test. + t.Run("subpackage caps do not leak to siblings", func(t *testing.T) { + test := &Test{ + Package: "main", + Configuration: config.Configuration{ + Test: &config.Test{Pipeline: []config.Pipeline{{Runs: "true"}}}, + Subpackages: []config.Subpackage{ + {Name: "sub-a", Test: &config.Test{Pipeline: []config.Pipeline{{Needs: needs("CAP_SYS_ADMIN")}}}}, + {Name: "sub-b", Test: &config.Test{Pipeline: []config.Pipeline{{Runs: "true"}}}}, }, }, } @@ -162,20 +178,25 @@ func TestCompileCapabilities(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if got := test.Configuration.Capabilities.Add; !slices.Equal(got, wantUnionAdd) { - t.Errorf("add capabilities: want %v, got %v", wantUnionAdd, got) + if got, want := test.Configuration.Subpackages[0].Test.Capabilities.Add, []string{"CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("sub-a capabilities: want %v, got %v", want, got) + } + if got := test.Configuration.Subpackages[1].Test.Capabilities.Add; len(got) != 0 { + t.Errorf("sub-b capabilities should be empty, got %v", got) } - if got := test.Configuration.Capabilities.Drop; !slices.Equal(got, wantUnionDrop) { - t.Errorf("drop capabilities: want %v, got %v", wantUnionDrop, got) + if got := test.Configuration.Test.Capabilities.Add; len(got) != 0 { + t.Errorf("main test capabilities should be empty, got %v", got) } }) - // A build pipeline's capabilities are merged into the build runner. - t.Run("build pipeline merges union", func(t *testing.T) { + // A build pipeline's capabilities apply to the build runner, deduplicated + // against the manifest's. + t.Run("build pipeline caps apply to build runner", func(t *testing.T) { build := &Build{ Configuration: &config.Configuration{ Capabilities: manifestCaps(), - Pipeline: []config.Pipeline{{Needs: needs()}}, + // Duplicate CAP_NET_ADMIN to exercise dedup. + Pipeline: []config.Pipeline{{Needs: needs("CAP_SYS_ADMIN", "CAP_NET_ADMIN")}}, }, } @@ -183,23 +204,20 @@ func TestCompileCapabilities(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if got := build.Configuration.Capabilities.Add; !slices.Equal(got, wantUnionAdd) { - t.Errorf("add capabilities: want %v, got %v", wantUnionAdd, got) - } - if got := build.Configuration.Capabilities.Drop; !slices.Equal(got, wantUnionDrop) { - t.Errorf("drop capabilities: want %v, got %v", wantUnionDrop, got) + if got, want := build.Configuration.Capabilities.Add, []string{"CAP_NET_ADMIN", "CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("build capabilities: want %v, got %v", want, got) } }) - // `melange build` never runs test pipelines, so capabilities they declare - // must not leak into the build runner; only the manifest's remain. - t.Run("build does not leak test caps", func(t *testing.T) { + // `melange build` never runs test pipelines, so caps they declare must not + // reach the build runner. + t.Run("build does not apply test caps", func(t *testing.T) { build := &Build{ Configuration: &config.Configuration{ Package: config.Package{Name: "main"}, Capabilities: manifestCaps(), Test: &config.Test{ - Pipeline: []config.Pipeline{{Needs: needs()}}, + Pipeline: []config.Pipeline{{Needs: needs("CAP_SYS_ADMIN")}}, }, }, } @@ -209,10 +227,53 @@ func TestCompileCapabilities(t *testing.T) { } if got, want := build.Configuration.Capabilities.Add, []string{"CAP_NET_ADMIN"}; !slices.Equal(got, want) { - t.Errorf("add capabilities leaked from test pipeline: want %v, got %v", want, got) + t.Errorf("test caps leaked into build runner: want %v, got %v", want, got) + } + }) + + // Capabilities declared via needs: in a pipeline loaded through uses: are + // gathered the same way, exercising the yaml round-trip and the + // pipeline.Needs = nil handling in gatherDeps. + t.Run("uses pipeline round-trip", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cap.yaml"), []byte( + "needs:\n capabilities:\n add:\n - CAP_SYS_ADMIN\npipeline:\n - runs: \"true\"\n", + ), 0o644); err != nil { + t.Fatal(err) + } + + test := &Test{ + Package: "main", + PipelineDirs: []string{dir}, + Configuration: config.Configuration{ + Test: &config.Test{Pipeline: []config.Pipeline{{Uses: "cap"}}}, + }, + } + + if err := test.Compile(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := test.Configuration.Test.Capabilities.Add, []string{"CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("uses capabilities: want %v, got %v", want, got) + } + }) + + // A misspelled capability is rejected while compiling, rather than by the + // runner once the container is created. + t.Run("unknown capability fails compile", func(t *testing.T) { + build := &Build{ + Configuration: &config.Configuration{ + Pipeline: []config.Pipeline{{Needs: needs("CAP_SYS_ADMN")}}, + }, + } + + err := build.Compile(context.Background()) + if err == nil { + t.Fatal("expected an error for an unknown capability, got none") } - if got, want := build.Configuration.Capabilities.Drop, []string{"CAP_MKNOD"}; !slices.Equal(got, want) { - t.Errorf("drop capabilities: want %v, got %v", want, got) + if !strings.Contains(err.Error(), "CAP_SYS_ADMN") { + t.Errorf("error should name the offending capability, got: %v", err) } }) } diff --git a/pkg/build/test.go b/pkg/build/test.go index 3c2ffeefa..b3bab74ef 100644 --- a/pkg/build/test.go +++ b/pkg/build/test.go @@ -305,10 +305,12 @@ func (t *Test) TestPackage(ctx context.Context) error { } env := apko_types.ImageConfiguration{} + var testCaps config.Capabilities if t.Configuration.Test != nil { env = t.Configuration.Test.Environment + testCaps = t.Configuration.Test.Capabilities } - cfg, err := t.buildWorkspaceConfig(ctx, imgRef, pkg.Name, env) + cfg, err := t.buildWorkspaceConfig(ctx, imgRef, pkg.Name, env, testCaps) if err != nil { return fmt.Errorf("unable to build workspace config: %w", err) } @@ -368,7 +370,7 @@ func (t *Test) TestPackage(ctx context.Context) error { if err != nil { return fmt.Errorf("unable to build guest: %w", err) } - subCfg, err := t.buildWorkspaceConfig(ctx, spImgRef, sp.Name, sp.Test.Environment) + subCfg, err := t.buildWorkspaceConfig(ctx, spImgRef, sp.Name, sp.Test.Environment, sp.Test.Capabilities) if err != nil { return fmt.Errorf("unable to build workspace config: %w", err) } @@ -422,7 +424,7 @@ func (t *Test) Summarize(ctx context.Context) { t.SummarizePaths(ctx) } -func (t *Test) buildWorkspaceConfig(ctx context.Context, imgRef, pkgName string, imgcfg apko_types.ImageConfiguration) (*container.Config, error) { +func (t *Test) buildWorkspaceConfig(ctx context.Context, imgRef, pkgName string, imgcfg apko_types.ImageConfiguration, testCaps config.Capabilities) (*container.Config, error) { log := clog.FromContext(ctx) mounts := []container.BindMount{ {Source: t.WorkspaceDir, Destination: container.DefaultWorkspaceDir}, @@ -465,12 +467,14 @@ func (t *Test) buildWorkspaceConfig(ctx context.Context, imgRef, pkgName string, cfg.Memory = t.Configuration.Package.Resources.Memory cfg.Disk = t.Configuration.Package.Resources.Disk } - if t.Configuration.Capabilities.Add != nil { - cfg.Capabilities.Add = t.Configuration.Capabilities.Add - } - if t.Configuration.Capabilities.Drop != nil { - cfg.Capabilities.Drop = t.Configuration.Capabilities.Drop - } + // Apply the manifest-wide capabilities plus the ones this specific test + // needs. Test capabilities are scoped to this container, so a capability a + // sibling test's pipeline requested does not leak in here. + addSet := slices.Concat(t.Configuration.Capabilities.Add, testCaps.Add) + cfg.Capabilities.Add = slices.Compact(slices.Sorted(slices.Values(addSet))) + dropSet := slices.Concat(t.Configuration.Capabilities.Drop, testCaps.Drop) + cfg.Capabilities.Drop = slices.Compact(slices.Sorted(slices.Values(dropSet))) + warnCapabilityConflicts(ctx, config.Capabilities{Add: cfg.Capabilities.Add, Drop: cfg.Capabilities.Drop}) maps.Copy(cfg.Environment, t.Configuration.Environment.Environment) maps.Copy(cfg.Environment, imgcfg.Environment) diff --git a/pkg/build/test_test.go b/pkg/build/test_test.go index b96675be5..60aa316d3 100644 --- a/pkg/build/test_test.go +++ b/pkg/build/test_test.go @@ -132,7 +132,7 @@ func TestBuildWorkspaceConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := slogtest.Context(t) - got, gotErr := tt.t.buildWorkspaceConfig(ctx, testImgRef, testPkgName, apko_types.ImageConfiguration{Environment: tt.env}) + got, gotErr := tt.t.buildWorkspaceConfig(ctx, testImgRef, testPkgName, apko_types.ImageConfiguration{Environment: tt.env}, config.Capabilities{}) if gotErr != nil { if tt.wantErr == "" { t.Fatalf("unexpected error: %v", gotErr) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7d4494894..d12c1c74f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -578,11 +578,52 @@ func (p Package) FullCopyright() string { } type Needs struct { - // A list of packages needed by this pipeline - Packages []string - // Optional: Linux capabilities needed by this pipeline. These are merged - // into the runner's capabilities configuration whenever the pipeline is used. - Capabilities Capabilities `json:"capabilities,omitzero" yaml:"capabilities,omitempty"` + // Optional: A list of packages needed by this pipeline + Packages []string `json:",omitzero"` + // Optional: Linux capabilities needed by this pipeline. They are added to + // the runner of the test or build that uses the pipeline. Only additions + // are supported: a pipeline declares what it needs, it cannot drop a + // capability from the steps that share its container. + Capabilities NeedsCapabilities `json:"capabilities,omitzero" yaml:"capabilities,omitempty"` +} + +// NeedsCapabilities is the set of Linux capabilities a pipeline requests be +// added to its runner. +type NeedsCapabilities struct { + // Linux process capabilities to add to the runner for this pipeline. + Add []string `json:"add,omitempty" yaml:"add,omitempty"` +} + +// knownRunnerCapabilities is the set of capability names the runners accept, +// mirroring the CAP_* constants in linux/capability.h. A name outside this set +// is a typo: bubblewrap would only reject it once the container starts. +var knownRunnerCapabilities = map[string]struct{}{ + "CAP_CHOWN": {}, "CAP_DAC_OVERRIDE": {}, "CAP_DAC_READ_SEARCH": {}, + "CAP_FOWNER": {}, "CAP_FSETID": {}, "CAP_KILL": {}, "CAP_SETGID": {}, + "CAP_SETUID": {}, "CAP_SETPCAP": {}, "CAP_LINUX_IMMUTABLE": {}, + "CAP_NET_BIND_SERVICE": {}, "CAP_NET_BROADCAST": {}, "CAP_NET_ADMIN": {}, + "CAP_NET_RAW": {}, "CAP_IPC_LOCK": {}, "CAP_IPC_OWNER": {}, + "CAP_SYS_MODULE": {}, "CAP_SYS_RAWIO": {}, "CAP_SYS_CHROOT": {}, + "CAP_SYS_PTRACE": {}, "CAP_SYS_PACCT": {}, "CAP_SYS_ADMIN": {}, + "CAP_SYS_BOOT": {}, "CAP_SYS_NICE": {}, "CAP_SYS_RESOURCE": {}, + "CAP_SYS_TIME": {}, "CAP_SYS_TTY_CONFIG": {}, "CAP_MKNOD": {}, + "CAP_LEASE": {}, "CAP_AUDIT_WRITE": {}, "CAP_AUDIT_CONTROL": {}, + "CAP_SETFCAP": {}, "CAP_MAC_OVERRIDE": {}, "CAP_MAC_ADMIN": {}, + "CAP_SYSLOG": {}, "CAP_WAKE_ALARM": {}, "CAP_BLOCK_SUSPEND": {}, + "CAP_AUDIT_READ": {}, "CAP_PERFMON": {}, "CAP_BPF": {}, + "CAP_CHECKPOINT_RESTORE": {}, +} + +// Validate reports capability names the runners would not understand, so a +// typo fails at compile time rather than when the container is created. +func (nc NeedsCapabilities) Validate() error { + var errs []error + for _, c := range nc.Add { + if _, ok := knownRunnerCapabilities[c]; !ok { + errs = append(errs, fmt.Errorf("unknown capability %q, expected an uppercase CAP_* name", c)) + } + } + return errors.Join(errs...) } type PipelineAssertions struct { @@ -941,6 +982,12 @@ type Test struct { // Optional: Additional Environment the test needs to run Environment apko_types.ImageConfiguration `json:"environment" yaml:"environment,omitempty"` + // Optional: Linux capabilities to apply to this test's runner. Capabilities + // required by the test pipelines are gathered here during compilation, so + // they are scoped to this test's container rather than shared across every + // test in the configuration. + Capabilities Capabilities `json:"capabilities,omitzero" yaml:"capabilities,omitempty"` + // Required: The list of pipelines that test the produced package. Pipeline []Pipeline `json:"pipeline" yaml:"pipeline"` } diff --git a/pkg/config/schema.cue b/pkg/config/schema.cue index f4ec8e715..e33654437 100644 --- a/pkg/config/schema.cue +++ b/pkg/config/schema.cue @@ -279,13 +279,20 @@ }) #Needs: close({ - // A list of packages needed by this pipeline - Packages!: [...string] + // Optional: A list of packages needed by this pipeline + Packages?: [...string] - // Optional: Linux capabilities needed by this pipeline. These are - // merged into the runner's capabilities configuration whenever the - // pipeline is used. - capabilities?: #Capabilities + // Optional: Linux capabilities needed by this pipeline. They are added + // to the runner of the test or build that uses the pipeline. Only + // additions are supported: a pipeline declares what it needs, it cannot + // drop a capability from the steps that share its container. + capabilities?: #NeedsCapabilities +}) + +// NeedsCapabilities is the set of Linux capabilities a pipeline requests be added to its runner. +#NeedsCapabilities: close({ + // Linux process capabilities to add to the runner for this pipeline. + add?: [...string] }) // OCIMonitor indicates using OCI image tags @@ -595,6 +602,12 @@ // Optional: Additional Environment the test needs to run environment!: #ImageConfiguration + // Optional: Linux capabilities to apply to this test's runner. + // Capabilities required by the test pipelines are gathered here during + // compilation, so they are scoped to this test's container rather than + // shared across every test in the configuration. + capabilities?: #Capabilities + // Required: The list of pipelines that test the produced package. pipeline!: [...#Pipeline] }) diff --git a/pkg/config/schema.json b/pkg/config/schema.json index 6a8d665fa..249510c26 100644 --- a/pkg/config/schema.json +++ b/pkg/config/schema.json @@ -612,18 +612,29 @@ "type": "string" }, "type": "array", - "description": "A list of packages needed by this pipeline" + "description": "Optional: A list of packages needed by this pipeline" }, "capabilities": { - "$ref": "#/$defs/Capabilities", - "description": "Optional: Linux capabilities needed by this pipeline. These are merged\ninto the runner's capabilities configuration whenever the pipeline is used." + "$ref": "#/$defs/NeedsCapabilities", + "description": "Optional: Linux capabilities needed by this pipeline. They are added to\nthe runner of the test or build that uses the pipeline. Only additions\nare supported: a pipeline declares what it needs, it cannot drop a\ncapability from the steps that share its container." + } + }, + "additionalProperties": false, + "type": "object" + }, + "NeedsCapabilities": { + "properties": { + "add": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Linux process capabilities to add to the runner for this pipeline." } }, "additionalProperties": false, "type": "object", - "required": [ - "Packages" - ] + "description": "NeedsCapabilities is the set of Linux capabilities a pipeline requests be added to its runner." }, "OCIMonitor": { "properties": { @@ -1074,6 +1085,10 @@ "$ref": "#/$defs/ImageConfiguration", "description": "Additional Environment necessary for test.\nEnvironment.Contents.Packages automatically get\npackage.dependencies.runtime added to it. So, if your test needs\nno additional packages, you can leave it blank.\nOptional: Additional Environment the test needs to run" }, + "capabilities": { + "$ref": "#/$defs/Capabilities", + "description": "Optional: Linux capabilities to apply to this test's runner. Capabilities\nrequired by the test pipelines are gathered here during compilation, so\nthey are scoped to this test's container rather than shared across every\ntest in the configuration." + }, "pipeline": { "items": { "$ref": "#/$defs/Pipeline" From a80853bd24946b0d0a8e7ec168197fba4bdcffe2 Mon Sep 17 00:00:00 2001 From: Anmol Virdi Date: Wed, 9 Sep 2026 13:24:20 +0530 Subject: [PATCH 6/6] Add fixes Signed-off-by: Anmol Virdi --- docs/BUILD-PROCESS.md | 2 +- pkg/build/compile.go | 16 ++++++-- pkg/build/compile_test.go | 81 +++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 47 +++++++++++++---------- pkg/config/config_test.go | 78 +++++++++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 26 deletions(-) diff --git a/docs/BUILD-PROCESS.md b/docs/BUILD-PROCESS.md index abd46f40b..0ccde9ed7 100644 --- a/docs/BUILD-PROCESS.md +++ b/docs/BUILD-PROCESS.md @@ -33,7 +33,7 @@ needs: - CAP_SYS_ADMIN ``` -Capabilities declared by build pipelines are added to the build runner. Capabilities declared by test pipelines are scoped to that test's runner under `melange test`, so a capability one subpackage's test needs is not granted to sibling tests or to the build runner. Names are checked while the pipeline is compiled, so a misspelled `CAP_*` fails the build rather than the container. +Capabilities declared by build pipelines are added to the build runner. Capabilities declared by test pipelines are scoped to that test's runner under `melange test`, so a capability one subpackage's test needs is not granted to sibling tests or to the build runner. Compilation records them under the test's `capabilities`, so they survive into a compiled configuration and are still applied when testing it. Names are checked while the pipeline is compiled, so a misspelled `CAP_*` fails the build rather than the container. ## Where does Melange build? diff --git a/pkg/build/compile.go b/pkg/build/compile.go index 962782c99..616846014 100644 --- a/pkg/build/compile.go +++ b/pkg/build/compile.go @@ -195,10 +195,14 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) - // Capabilities from test pipelines are intentionally not applied here: - // `melange build` never runs the test pipelines, so granting them to the - // build runner would only over-privilege it. Test.Compile scopes them to - // each test's runner under `melange test`. + // Capabilities gathered from the test pipelines are recorded on the test + // rather than on b.Configuration.Capabilities: `melange build` never runs + // the test pipelines, so granting them to the build runner would only + // over-privilege it. Recording them here keeps the requirement in the + // compiled configuration, so `melange test` on a compiled manifest (or on + // the .melange.yaml embedded in the APK) still gets them, mirroring how + // needs.packages is folded into test.environment. + addCapabilities(&cfg.Subpackages[i].Test.Capabilities, tc.Capabilities) } ic := &b.Configuration.Environment.Contents @@ -223,6 +227,10 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error { // Sort and remove duplicates. te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages))) + + // As above: scoped to the test's runner, not the build runner, but kept in + // the compiled configuration so it survives a compile/test round trip. + addCapabilities(&b.Configuration.Test.Capabilities, tc.Capabilities) } return nil diff --git a/pkg/build/compile_test.go b/pkg/build/compile_test.go index 290504a1a..547c6d42d 100644 --- a/pkg/build/compile_test.go +++ b/pkg/build/compile_test.go @@ -259,6 +259,87 @@ func TestCompileCapabilities(t *testing.T) { } }) + // The same manifest a user writes, taken through ParseConfiguration rather + // than built as a Go literal: inline capabilities have to survive parsing to + // reach the runners and to be validated. + t.Run("inline capabilities from a parsed manifest", func(t *testing.T) { + write := func(t *testing.T, cap string) string { + t.Helper() + fp := filepath.Join(t.TempDir(), "melange.yaml") + if err := os.WriteFile(fp, []byte(` +package: + name: caps + version: 0.0.1 + epoch: 0 + description: inline capabilities + +pipeline: + - needs: + capabilities: + add: + - `+cap+` + runs: "true" + +test: + pipeline: + - needs: + capabilities: + add: + - `+cap+` + runs: "true" +`), 0o644); err != nil { + t.Fatal(err) + } + return fp + } + + ctx := context.Background() + + cfg, err := config.ParseConfiguration(ctx, write(t, "CAP_SYS_ADMIN")) + if err != nil { + t.Fatalf("failed to parse configuration: %v", err) + } + + build := &Build{Configuration: cfg} + if err := build.Compile(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := build.Configuration.Capabilities.Add, []string{"CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("build capabilities: want %v, got %v", want, got) + } + // Recorded on the test so a compiled configuration still declares what + // `melange test` needs, without widening the build runner. + if got, want := build.Configuration.Test.Capabilities.Add, []string{"CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("compiled test capabilities: want %v, got %v", want, got) + } + + testCfg, err := config.ParseConfiguration(ctx, write(t, "CAP_SYS_ADMIN")) + if err != nil { + t.Fatalf("failed to parse configuration: %v", err) + } + + test := &Test{Package: "caps", Configuration: *testCfg} + if err := test.Compile(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := test.Configuration.Test.Capabilities.Add, []string{"CAP_SYS_ADMIN"}; !slices.Equal(got, want) { + t.Errorf("test capabilities: want %v, got %v", want, got) + } + + // A misspelled name in the inline form fails the build, as documented. + badCfg, err := config.ParseConfiguration(ctx, write(t, "CAP_SYS_ADMN")) + if err != nil { + t.Fatalf("failed to parse configuration: %v", err) + } + err = (&Build{Configuration: badCfg}).Compile(ctx) + if err == nil { + t.Fatal("expected an error for an unknown capability, got none") + } + if !strings.Contains(err.Error(), "CAP_SYS_ADMN") { + t.Errorf("error should name the offending capability, got: %v", err) + } + }) + // A misspelled capability is rejected while compiling, rather than by the // runner once the container is created. t.Run("unknown capability fails compile", func(t *testing.T) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 299b02bea..4d1326fc4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1405,13 +1405,16 @@ func replaceAll(r *strings.Replacer, in []string) []string { return out } +// replaceNeeds copies the input and overrides only the fields substitution +// applies to, so a field added to Needs later is carried through rather than +// silently dropped. func replaceNeeds(r *strings.Replacer, in *Needs) *Needs { if in == nil { return nil } - return &Needs{ - Packages: replaceAll(r, in.Packages), - } + out := *in + out.Packages = replaceAll(r, in.Packages) + return &out } func replaceMap(r *strings.Replacer, in map[string]string) map[string]string { @@ -1463,21 +1466,20 @@ func replaceImageConfig(r *strings.Replacer, in apko_types.ImageConfiguration) a } } +// replacePipeline copies the input and overrides only the fields substitution +// applies to, so a field added to Pipeline later is carried through rather than +// silently dropped. func replacePipeline(r *strings.Replacer, in Pipeline) Pipeline { - return Pipeline{ - Name: r.Replace(in.Name), - Uses: in.Uses, - With: replaceMap(r, in.With), - Runs: r.Replace(in.Runs), - Pipeline: replacePipelines(r, in.Pipeline), - Inputs: in.Inputs, - Needs: replaceNeeds(r, in.Needs), - Label: in.Label, - If: r.Replace(in.If), - Assertions: in.Assertions, - WorkDir: r.Replace(in.WorkDir), - Environment: replaceMap(r, in.Environment), - } + out := in + out.Name = r.Replace(in.Name) + out.With = replaceMap(r, in.With) + out.Runs = r.Replace(in.Runs) + out.Pipeline = replacePipelines(r, in.Pipeline) + out.Needs = replaceNeeds(r, in.Needs) + out.If = r.Replace(in.If) + out.WorkDir = r.Replace(in.WorkDir) + out.Environment = replaceMap(r, in.Environment) + return out } func replacePipelines(r *strings.Replacer, in []Pipeline) []Pipeline { @@ -1492,14 +1494,17 @@ func replacePipelines(r *strings.Replacer, in []Pipeline) []Pipeline { return out } +// replaceTest copies the input and overrides only the fields substitution +// applies to, so a field added to Test later is carried through rather than +// silently dropped. func replaceTest(r *strings.Replacer, in *Test) *Test { if in == nil { return nil } - return &Test{ - Environment: replaceImageConfig(r, in.Environment), - Pipeline: replacePipelines(r, in.Pipeline), - } + out := *in + out.Environment = replaceImageConfig(r, in.Environment) + out.Pipeline = replacePipelines(r, in.Pipeline) + return &out } func replaceUpdate(r *strings.Replacer, in Update) Update { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a4d739cad..f53ba4124 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -185,6 +185,84 @@ test: require.Equal(t, "/usr/local/FOO", cfg.Test.Environment.Environment["LD_LIBRARY_PATH"]) } +// ParseConfiguration rebuilds pipelines, tests and subpackages field by field, +// so capabilities written inline in a manifest have to survive that rebuild the +// same way needs.packages does. +func Test_capabilitiesSurviveParsing(t *testing.T) { + ctx := slogtest.Context(t) + + fp := filepath.Join(t.TempDir(), "melange.yaml") + if err := os.WriteFile(fp, []byte(` +package: + name: caps-parsing + version: 0.0.1 + epoch: 0 + description: capabilities survive parsing + +capabilities: + add: + - CAP_NET_ADMIN + +pipeline: + - needs: + packages: + - wget + capabilities: + add: + - CAP_SYS_ADMIN + runs: echo hi + +test: + capabilities: + add: + - CAP_SYS_PTRACE + pipeline: + - needs: + capabilities: + add: + - CAP_SYS_CHROOT + runs: echo test + +subpackages: + - name: caps-parsing-sub + pipeline: + - needs: + capabilities: + add: + - CAP_MKNOD + runs: echo sub + test: + capabilities: + add: + - CAP_SYS_NICE + pipeline: + - needs: + capabilities: + add: + - CAP_SYS_TIME + runs: echo sub test +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := ParseConfiguration(ctx, fp) + if err != nil { + t.Fatalf("failed to parse configuration: %s", err) + } + + require.Equal(t, []string{"CAP_NET_ADMIN"}, cfg.Capabilities.Add) + require.Equal(t, []string{"wget"}, cfg.Pipeline[0].Needs.Packages) + require.Equal(t, []string{"CAP_SYS_ADMIN"}, cfg.Pipeline[0].Needs.Capabilities.Add) + + require.Equal(t, []string{"CAP_SYS_PTRACE"}, cfg.Test.Capabilities.Add) + require.Equal(t, []string{"CAP_SYS_CHROOT"}, cfg.Test.Pipeline[0].Needs.Capabilities.Add) + + sp := cfg.Subpackages[0] + require.Equal(t, []string{"CAP_MKNOD"}, sp.Pipeline[0].Needs.Capabilities.Add) + require.Equal(t, []string{"CAP_SYS_NICE"}, sp.Test.Capabilities.Add) + require.Equal(t, []string{"CAP_SYS_TIME"}, sp.Test.Pipeline[0].Needs.Capabilities.Add) +} + func Test_updateBlockVarSubstitution(t *testing.T) { ctx := slogtest.Context(t)