Skip to content

Commit 3e19155

Browse files
EmilienMmrunalp
andauthored
feat(build): add glibc-static supervisor libc variant (#2682)
The supervisor binary runs inside sandbox images whose libc and glibc version are unknown at build time, so it must be statically linked. Add SUPERVISOR_LIBC to select between the default musl variant and a new glibc-static variant that builds the GNU target with +crt-static. glibc-static has no cross-compile path: zig cc accepts -static for *-linux-gnu targets and emits a dynamically linked binary anyway. The staging script therefore refuses a cross-arch request for that variant rather than silently degrading linkage, and requires a native per-architecture build. Add verify-static-binary.sh, run after every supervisor build in both the staging script and CI so linkage cannot regress unnoticed for either variant. It inspects via readelf (or greadelf/llvm-readelf) and fails closed rather than trusting the tool's exit status: every inspection must produce no diagnostics, the input must be an executable ELF (ET_EXEC, or ET_DYN with DF_1_PIE) whose PT_LOAD segments all lie within the file, whose dynamic table agrees with PT_DYNAMIC, and which carries no PT_INTERP and no DT_NEEDED. That rejects a dynamically linked, truncated, corrupt, non-ELF, or shared-object input that naive parsing would misread as static. Hosts without any inspector (e.g. macOS, which ships no binutils) skip with a warning; Linux, including CI, requires one and fails closed. No image or release workflow builds the glibc-static variant, so add a dedicated supervisor-static-validate workflow that builds it on both architectures and runs the verifier. rust-native-build.yml uses self-hosted runners, which reject pull_request-triggered jobs, so it validates in the merge queue and on pushes to main that touch the build inputs, plus a nightly schedule, so the GNU + crt-static build branch cannot regress unnoticed. The default is unchanged, so image, release, and CI behavior is identical. Selecting glibc-static statically links LGPL glibc into a redistributed binary, which is why it is opt-in. Signed-off-by: Mrunal Patel <mrunalp@gmail.com> Co-authored-by: Mrunal Patel <mrunalp@gmail.com>
1 parent c825b1f commit 3e19155

6 files changed

Lines changed: 434 additions & 33 deletions

File tree

‎.github/workflows/rust-native-build.yml‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ name: Rust Image Binary Build (openshell-gateway / openshell-sandbox / openshell
55

66
# Build Rust binaries per Linux architecture before the Docker image build
77
# consumes them as prebuilt artifacts. Gateway images use GNU-linked binaries
8-
# for the NVIDIA distroless C/C++ runtime; supervisor and cli images use musl/static
8+
# for the NVIDIA distroless C/C++ runtime; supervisor and cli images use static
99
# binaries so the final image can remain scratch. Gateway GNU binaries are
1010
# built with an explicit glibc 2.28 floor so image, package, and tarball
1111
# artifacts share the same host portability contract.
12+
#
13+
# The supervisor libc is selectable via the `supervisor-libc` input (musl or
14+
# glibc-static). Both variants are fully static and are verified as such,
15+
# because the supervisor is executed from inside arbitrary sandbox images.
1216

1317
on:
1418
workflow_call:
@@ -21,6 +25,11 @@ on:
2125
description: "Linux architecture to build (amd64 or arm64)"
2226
required: true
2327
type: string
28+
supervisor-libc:
29+
description: "libc variant for the sandbox component (musl or glibc-static)"
30+
required: false
31+
type: string
32+
default: "musl"
2433
cargo-version:
2534
description: "Pre-computed cargo version (skips internal git-based computation)"
2635
required: false
@@ -76,10 +85,12 @@ jobs:
7685
COMPONENT: ${{ inputs.component }}
7786
ARCH: ${{ inputs.arch }}
7887
FEATURES: ${{ inputs.features }}
88+
SUPERVISOR_LIBC: ${{ inputs['supervisor-libc'] }}
7989
# Partition the GHA sccache cache per (component, arch). Without this,
8090
# concurrent jobs collide on the same cache key and later-starting
81-
# writers hit 409 Conflict.
82-
SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }}
91+
# writers hit 409 Conflict. The sandbox component also partitions per
92+
# libc variant so musl and glibc-static builds do not evict each other.
93+
SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }}${{ inputs.component == 'sandbox' && format('-{0}', inputs['supervisor-libc']) || '' }}
8394
container:
8495
image: ghcr.io/nvidia/openshell/ci:latest
8596
credentials:
@@ -132,9 +143,28 @@ jobs:
132143
;;
133144
esac
134145
146+
# The sandbox binary must stay fully static. musl gets there via the
147+
# musl target; glibc-static uses the GNU target with +crt-static and
148+
# relies on this job running natively on the target architecture,
149+
# because zig cannot statically link glibc.
150+
static_libc=musl
151+
if [[ "$COMPONENT" == "sandbox" ]]; then
152+
case "$SUPERVISOR_LIBC" in
153+
musl) static_libc=musl ;;
154+
glibc-static) static_libc=gnu ;;
155+
*)
156+
echo "unsupported supervisor-libc: $SUPERVISOR_LIBC (expected musl or glibc-static)" >&2
157+
exit 1
158+
;;
159+
esac
160+
fi
161+
135162
case "$ARCH" in
136163
amd64)
137-
if [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then
164+
if [[ "$COMPONENT" == "sandbox" && "$static_libc" == "gnu" ]]; then
165+
target=x86_64-unknown-linux-gnu
166+
zig_target=
167+
elif [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then
138168
target=x86_64-unknown-linux-musl
139169
zig_target=x86_64-linux-musl
140170
else
@@ -143,7 +173,10 @@ jobs:
143173
fi
144174
;;
145175
arm64)
146-
if [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then
176+
if [[ "$COMPONENT" == "sandbox" && "$static_libc" == "gnu" ]]; then
177+
target=aarch64-unknown-linux-gnu
178+
zig_target=
179+
elif [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then
147180
target=aarch64-unknown-linux-musl
148181
zig_target=aarch64-linux-musl
149182
else
@@ -167,7 +200,7 @@ jobs:
167200
- name: Cache Rust target and registry
168201
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
169202
with:
170-
shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }}
203+
shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}${{ inputs.component == 'sandbox' && format('-{0}', inputs['supervisor-libc']) || '' }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }}
171204
cache-directories: .cache/sccache
172205
cache-targets: "true"
173206

@@ -239,6 +272,11 @@ jobs:
239272
cargo_cmd=(cargo zigbuild)
240273
build_target="${{ steps.target.outputs.zig_target }}"
241274
args+=(--features bundled-z3)
275+
elif [[ "${{ inputs.component }}" == "sandbox" && "$SUPERVISOR_LIBC" == "glibc-static" ]]; then
276+
# Static glibc requires the native toolchain's libc.a (build-essential
277+
# in the CI image); cargo-zigbuild is not usable here because zig
278+
# accepts -static for *-linux-gnu and links dynamically anyway.
279+
export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static"
242280
fi
243281
args+=(
244282
--release
@@ -276,6 +314,13 @@ jobs:
276314
BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}"
277315
tasks/scripts/verify-glibc-symbols.sh 2.28 "$BIN"
278316
317+
- name: Verify static linkage
318+
if: inputs.component == 'sandbox'
319+
run: |
320+
set -euo pipefail
321+
BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}"
322+
tasks/scripts/verify-static-binary.sh "$BIN"
323+
279324
- name: Stage binary for prebuilt layout
280325
run: |
281326
set -euo pipefail
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
name: Supervisor Static Linkage Validation
5+
6+
# The glibc-static supervisor variant (SUPERVISOR_LIBC=glibc-static) has no
7+
# other CI caller: docker-build.yml builds the default musl variant, so the
8+
# GNU + crt-static build branch and its native-only, per-arch requirements are
9+
# never exercised by image or release CI. Build the variant here on both
10+
# architectures so it cannot regress unnoticed. rust-native-build.yml runs
11+
# verify-static-binary.sh for the sandbox component, which fails the job on any
12+
# dynamic linkage.
13+
#
14+
# Linkage can change from a new/updated dependency or a source change, not just
15+
# from the build scripts, so the push path filters cover the workspace manifests
16+
# and crate sources in addition to the build tooling. A nightly schedule is the
17+
# unfiltered backstop for anything the filters miss.
18+
#
19+
# rust-native-build.yml runs on NVIDIA self-hosted runners, which reject jobs
20+
# triggered by `pull_request`. This workflow therefore follows the repo's
21+
# self-hosted convention (see branch-checks.yml / branch-e2e.yml): validate in
22+
# the merge queue (pre-merge), on push to main (post-merge), nightly, and on
23+
# demand — never on `pull_request`.
24+
25+
on:
26+
merge_group:
27+
types: [checks_requested]
28+
push:
29+
branches: [main]
30+
paths:
31+
- "Cargo.toml"
32+
- "Cargo.lock"
33+
- "crates/**"
34+
- "rust-toolchain.toml"
35+
- "mise.toml"
36+
- "mise.lock"
37+
- ".cargo/config.toml"
38+
- "tasks/scripts/stage-prebuilt-binaries.sh"
39+
- "tasks/scripts/verify-static-binary.sh"
40+
- ".github/workflows/rust-native-build.yml"
41+
- ".github/workflows/supervisor-static-validate.yml"
42+
schedule:
43+
# Nightly (04:17 UTC) unfiltered run so a linkage regression cannot slip
44+
# through the path filters unnoticed. Schedules run only on the default branch.
45+
- cron: "17 4 * * *"
46+
workflow_dispatch:
47+
48+
concurrency:
49+
group: ${{ github.workflow }}-${{ github.ref }}
50+
cancel-in-progress: false
51+
52+
permissions:
53+
contents: read
54+
packages: read
55+
56+
jobs:
57+
glibc-static:
58+
name: glibc-static supervisor (${{ matrix.arch }})
59+
strategy:
60+
fail-fast: false
61+
matrix:
62+
arch: [amd64, arm64]
63+
uses: ./.github/workflows/rust-native-build.yml
64+
with:
65+
component: sandbox
66+
arch: ${{ matrix.arch }}
67+
supervisor-libc: glibc-static
68+
artifact-name: supervisor-glibc-static-${{ matrix.arch }}
69+
retention-days: 1
70+
secrets: inherit

‎architecture/build.md‎

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ The gateway bundles z3 into the release binary so Linux packages, standalone
6868
tarballs, and gateway images do not depend on distro-specific z3 shared-library
6969
SONAMEs.
7070

71+
The supervisor is the one binary whose libc is selectable, because it is the one
72+
binary executed inside a userland OpenShell does not control. `SUPERVISOR_LIBC`
73+
chooses between `musl` (default) and `glibc-static`. Both produce a fully static
74+
binary; the choice does not change the runtime layout or the supervisor image base.
75+
Static linkage is a hard requirement rather than a preference, so both variants
76+
are verified by `tasks/scripts/verify-static-binary.sh`, which fails the build on
77+
any `PT_INTERP` or `DT_NEEDED` entry.
78+
79+
The two variants differ only in build-time constraints:
80+
81+
| | `musl` (default) | `glibc-static` |
82+
|---|---|---|
83+
| Cross-compiles | yes, via `cargo zigbuild` | no — must build natively per architecture |
84+
| Host requirement | zig + cargo-zigbuild | glibc static libraries (`glibc-static` on Fedora/RHEL, `libc6-dev` on Debian/Ubuntu) |
85+
| libc license | MIT | LGPL-2.1-or-later, statically linked |
86+
87+
`cargo zigbuild` cannot produce the `glibc-static` variant: `zig cc` accepts
88+
`-static` for `*-linux-gnu` targets and emits a dynamically linked binary
89+
anyway. The staging script therefore refuses to cross-compile that variant
90+
instead of silently degrading linkage.
91+
92+
Selecting `glibc-static` statically links LGPL glibc into a redistributed
93+
binary, which carries relinking obligations that musl (MIT) does not. Treat the
94+
default as the shipping configuration unless that has been reviewed.
95+
7196
## Container Builds
7297

7398
The Docker image pipeline is a two-step flow: build the Rust binary natively
@@ -91,9 +116,11 @@ package-managed VM support does not raise the package runtime requirement.
91116
Gateway staging and release workflows set up the Zig C/C++ wrapper before
92117
bundled Z3 builds and verify the maximum referenced `GLIBC_*` symbol version
93118
before publishing or copying artifacts.
94-
Supervisor binaries remain static musl and use `cargo zigbuild` when available,
95-
including native CPU architectures, so C dependencies are compiled for the musl
96-
target instead of the host GNU libc target. Local Docker image tasks infer the
119+
Supervisor binaries are static in every configuration. The default `musl`
120+
variant uses `cargo zigbuild` when available, including native CPU
121+
architectures, so C dependencies are compiled for the musl target instead of the
122+
host GNU libc target. The `glibc-static` variant uses plain `cargo build` with
123+
`+crt-static` and requires a native per-architecture build. Local Docker image tasks infer the
97124
target architecture from `DOCKER_PLATFORM` when set. Otherwise, they require
98125
valid container engine host metadata and fail when the engine query is
99126
unavailable or reports an unsupported architecture, avoiding host-kernel
@@ -114,11 +141,15 @@ Runtime layout:
114141
as a release artifact. Linux GNU VM driver binaries must not reference
115142
`GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this
116143
before publishing artifacts.
117-
- **Supervisor**: Alpine base with `nftables`, static musl binary at
118-
`/openshell-sandbox`. Static linkage keeps the binary usable when the image
119-
is mounted/extracted into sandbox environments (Docker extraction, Podman
120-
image volumes, Kubernetes init-container copy-self), while `nftables` supports
121-
Kubernetes supervisor sidecar egress enforcement.
144+
- **Supervisor**: Alpine base with `nftables`, static binary at
145+
`/openshell-sandbox` (musl by default; see `SUPERVISOR_LIBC` above). Static
146+
linkage keeps the binary usable when the image is mounted/extracted into
147+
sandbox environments (Docker extraction, Podman image volumes, Kubernetes
148+
init-container copy-self), whose libc and glibc version are not known at build
149+
time, while `nftables` supports Kubernetes supervisor sidecar egress
150+
enforcement. The VM driver bundles its own supervisor build
151+
(`tasks/scripts/vm/build-supervisor-bundle.sh`) and does not read
152+
`SUPERVISOR_LIBC`.
122153

123154
Gateway image builds bake the corresponding supervisor image tag into the
124155
gateway binary so Docker sandboxes do not depend on `:latest` by default.

‎deploy/docker/Dockerfile.supervisor‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
#
1616
# Use tasks/scripts/docker-build-image.sh supervisor (or `mise run build:docker:supervisor`)
1717
# to stage the binary and build the image in one step. CI builds the binary
18-
# per-architecture via the `rust-native-build.yml` workflow (with the musl
19-
# target) and uploads it as an artifact, which is downloaded into the same
20-
# staging directory before the image build job runs.
18+
# per-architecture via the `rust-native-build.yml` workflow and uploads it as an
19+
# artifact, which is downloaded into the same staging directory before the image
20+
# build job runs.
21+
#
22+
# The binary is static under either supported libc variant (`SUPERVISOR_LIBC`:
23+
# musl by default, or glibc-static), so this Alpine base runs it unchanged.
2124

2225
FROM alpine:3.22 AS supervisor
2326

0 commit comments

Comments
 (0)