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. 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?

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
65 changes: 64 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,24 @@ 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 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
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 @@ -208,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
Expand All @@ -216,6 +239,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 +397,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 +487,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
Loading
Loading