Skip to content
13 changes: 12 additions & 1 deletion docs/BUILD-PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ needs:
- wget
```

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:
capabilities:
add:
- 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.

## Where does Melange build?

The melange build process involves three normally distinct directories.
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 56 additions & 1 deletion pkg/build/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +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)))

addCapabilities(&cfg.Subpackages[i].Test.Capabilities, test.Capabilities)
}

if cfg.Test != nil {
Expand All @@ -136,6 +140,8 @@ func (t *Test) Compile(ctx context.Context) error {

// Sort and remove duplicates.
te.Packages = slices.Compact(slices.Sorted(slices.Values(te.Packages)))

addCapabilities(&t.Configuration.Test.Capabilities, test.Capabilities)
}

return nil
Expand Down Expand Up @@ -188,11 +194,20 @@ 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:
Comment thread
AnmolVirdi marked this conversation as resolved.
Outdated
// `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`.
}

ic := &b.Configuration.Environment.Contents
ic.Packages = append(ic.Packages, c.Needs...)

// 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)

Expand All @@ -216,6 +231,10 @@ func (b *Build) Compile(ctx context.Context, opts ...CompileOption) error {
type Compiled struct {
PipelineDirs []string
Needs []string
// 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.
Expand Down Expand Up @@ -370,6 +389,32 @@ func (c *Compiled) compilePipeline(ctx context.Context, sm *SubstitutionMap, pip
return nil
}

// 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 caps.Add {
if slices.Contains(caps.Drop, c) {
conflicts = append(conflicts, c)
}
}
if len(conflicts) > 0 {
clog.FromContext(ctx).Warnf("capabilities %v are both added and dropped; the runner decides which wins", conflicts)
}
}

// readUses reads the definition named by uses from the configured pipeline
// directories, falling back to the ones built into melange.
func (c *Compiled) readUses(ctx context.Context, uses string) ([]byte, error) {
Expand Down Expand Up @@ -434,6 +479,16 @@ func (c *Compiled) gatherDeps(ctx context.Context, pipeline *config.Pipeline) er
}
c.Needs = append(c.Needs, pipeline.Needs.Packages...)

// Widening the sandbox is more consequential than adding a package, so
// surface it at Info (not Debug like packages above).
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...)
}

pipeline.Needs = nil
}

Expand Down
157 changes: 157 additions & 0 deletions pkg/build/compile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ package build

import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"

apko_types "chainguard.dev/apko/pkg/build/types"
Expand Down Expand Up @@ -121,6 +124,160 @@ func TestCompileTest(t *testing.T) {
}
}

func TestCompileCapabilities(t *testing.T) {
Comment thread
maxgio92 marked this conversation as resolved.
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"}}
}

// 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("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"}}}},
},
},
}

if err := test.Compile(context.Background()); err != nil {
t.Fatalf("unexpected error: %v", err)
}

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.Test.Capabilities.Add; len(got) != 0 {
t.Errorf("main test capabilities should be empty, got %v", got)
}
})

// 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(),
// Duplicate CAP_NET_ADMIN to exercise dedup.
Pipeline: []config.Pipeline{{Needs: needs("CAP_SYS_ADMIN", "CAP_NET_ADMIN")}},
},
}

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", "CAP_SYS_ADMIN"}; !slices.Equal(got, want) {
t.Errorf("build capabilities: want %v, got %v", want, got)
}
})

// `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("CAP_SYS_ADMIN")}},
},
},
}

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("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 !strings.Contains(err.Error(), "CAP_SYS_ADMN") {
t.Errorf("error should name the offending capability, got: %v", err)
}
})
}

func Test_stripComments(t *testing.T) {
tests := []struct {
in, want string
Expand Down
4 changes: 4 additions & 0 deletions pkg/build/pipelines/xcover/profile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ needs:
packages:
- busybox
- ${{inputs.package}}
capabilities:
add:
# xcover attaches BPF uprobes to the profiled interpreter.
- CAP_SYS_ADMIN

inputs:
package:
Expand Down
22 changes: 13 additions & 9 deletions pkg/build/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pkg/build/test_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading