Skip to content

[DO NOT MERGE] distroless: prove image + UID gate together (stacked #36099 + #36101) - #37466

Closed
jasonhernandez wants to merge 16 commits into
mainfrom
jason/distroless-stacked-proof
Closed

[DO NOT MERGE] distroless: prove image + UID gate together (stacked #36099 + #36101)#37466
jasonhernandez wants to merge 16 commits into
mainfrom
jason/distroless-stacked-proof

Conversation

@jasonhernandez

Copy link
Copy Markdown
Contributor

Purpose

Throwaway CI vehicle, never merges. Stacks #36099 (distroless environmentd/clusterd images) on #36101 (orchestratord UID/GID version gate) so the Nightly Orchestratord jobs exercise the real target combination: distroless images running as nonroot 65534 under the version-gated securityContext, in a kind cluster.

Each PR's own Nightly only tests half the story. #36101 alone applies the 65534 gate to Ubuntu-based images (no distroless images exist without #36099), and #36099 alone never runs the orchestratord kind tests with the gate present.

What to look at

The Orchestratord group in the triggered Nightly:

  • PR-version deployments: distroless environmentd/clusterd pods start, serve traffic, and pass the property checks under runAsUser/fsGroup 65534.
  • Older released versions in the documentation-defaults matrix: still get 999 and keep working.
  • Upgrade workflow: rolling upgrade from released Ubuntu-based versions to the distroless PR build, covering the fsGroup re-chown of existing PVCs and the fenced-out old generation idling instead of crash-looping (idle_when_fenced_out).

Also of interest: ssh-connection-* in PR CI against distroless (static ssh, tini reaping).

Disposition

Close once #36099 and #36101 have both merged (or once this run has proven the combination). Findings go on the real PRs.

🤖 Generated with Claude Code

@jasonhernandez jasonhernandez added the ci-nightly PR CI control: also trigger Nightly label Jul 7, 2026
jasonhernandez and others added 16 commits July 7, 2026 15:05
Replace Debian-based prod images with distroless. Remove shell
entrypoint scripts (no shell in distroless). Add libeatmydata to
distroless-prod-base for CI compatibility. Update clusterd mzcompose
service to drop shell-dependent options.

Distroless ships no init, so add a tini-static mzbuild image (static
tini built from source, mirroring openssh-static) and copy it into
distroless-prod-base. tini stays PID 1 to forward signals and reap
zombies, notably the ssh subprocesses spawned by the SSH tunnel
feature. Leaf images wrap their binary in ENTRYPOINT ["/usr/bin/tini",
"--", ...].

The deleted environmentd entrypoint slept forever on graceful exit so a
fenced-out generation would not be restarted. Distroless has no shell to
do this, and a Kubernetes StatefulSet (restartPolicy: Always) would
crash-loop a process that exits. Replace it with an in-process idle:
when fenced out and supervised directly by Kubernetes, environmentd
idles until the orchestrator deletes the StatefulSet (SIGTERM, then
SIGKILL) instead of calling exit!(0). The all-in-one image keeps its
entrypoint and exit-0 behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nightly cloudtest and orchestratord runs crash-looped every distroless
environmentd/clusterd pod with:

  error while loading shared libraries: libfdb_c.so: cannot open shared
  object file: No such file or directory

ubuntu-base has copied libfdb_c.so from the foundationdb image since
FoundationDB consensus/timestamp-oracle support landed (#33819), which
postdates the original distroless base. Copy it the same way, and carry
over ubuntu-base's RUST_BACKTRACE/RUST_LIB_BACKTRACE settings so crashes
in distroless images print backtraces too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_crash_storage and the secrets restart test killed clusterd with
kubectl exec ... bash -c 'kill -9 `pidof clusterd`'. Distroless clusterd
images ship no shell, so the exec fails with exit 1 and the tests fail.
Force-delete the pod instead (SIGKILL, no grace period), matching the
crash-simulation idiom in test_replica_restart. The StatefulSet recreates
the pod under the same name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The distroless base ships glibc's example nsswitch.conf, which routes
passwd/group lookups through the `compat` NSS backend. `compat` is not
built into glibc, so a lookup dlopens the image's libnss_compat.so.2.
The statically-linked `ssh` embeds a different glibc than that module
was built for, and the mismatch jumps through a garbage pointer:

  audit: ANOM_ABEND comm="ssh" exe="/usr/bin/ssh" sig=11

which the SSH tunnel manager surfaced as "failed to connect to the
remote host: No such file or directory" in the Nightly ssh tunnel
cloudtest. Ship a files/dns nsswitch.conf instead. Those backends are
built into glibc (2.33+), so lookups never dlopen, for the static ssh
and the dynamically-linked Rust binaries alike.

Verified by rebuilding distroless-prod-base and running the CI-built
static ssh inside it as uid 65532 and 65534 (`ssh -G` exits 0, was
SIGSEGV).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same no-shell-in-distroless problem as the clusterd kills: replace the
exec'd shell kill of environmentd with a force pod delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Distroless environmentd/clusterd images ship no shell, so
kubectl exec ... bash -c 'kill ...' cannot work. Add
signal_process_in_pod, which execs into the kind node hosting the pod
(a Docker container with a full userland) and signals the process
found via crictl. This keeps the tests' original semantics: the
process dies or suspends in place and the container restarts within
the same pod, unlike a pod delete. It also covers the shared-fate
SIGSTOP/SIGCONT cases, which simulate a hung peer and cannot be
expressed as a pod delete at all.

The helper finds the target among the container's init and its direct
children, covering both distroless images (tini is init, target is its
child) and Ubuntu-based images (the entrypoint execs the target).

Convert test_crash, test_secrets, and both shared-fate suites to the
helper, including reverting the earlier pod-delete workaround.
Mechanics verified against a local kind cluster (crictl discovery,
comm matching, kill -s from the node).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new tini-static image was pinned to ubuntu:noble-20260210.1, the
stale base copied from openssh-static before #37354 bumped it. Match the
repo's current base (noble-20260610, per ubuntu-base #37449). Build-stage
only; the shipped artifact is the static tini binary from a FROM scratch
stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match ubuntu-base and tini-static at noble-20260610 (openssh-static was
one dependabot cycle behind at noble-20260509.1). Aligning all static
builder bases to one tag means the distroless build pulls a single ubuntu
image rather than several. Build-stage only; the artifact is the static
ssh binary from a FROM scratch stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tini-static and distroless-prod-base are new images introduced by this
migration. Register both with the docker dependabot ecosystem so their
base-image pins (ubuntu/debian/gcr.io distroless) stay current on the
same weekly cadence as ubuntu-base and openssh-static, rather than
drifting until someone notices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…prod-base

The libfdb_c.so tag must stay in lockstep with the identical COPY in
ubuntu-base and the foundationdb crate's fdb-7_3 API feature. Spell that
out so a dependabot bump to one of the three gets recognized as a
drift bug in review rather than merged as routine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lint suite requires a copyright header on config files; the
nsswitch.conf added for the static-ssh fix was missing one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Distroless images run as nonroot (UID 65534) instead of root. Add
version-gating so orchestratord sets the correct runAsUser/runAsGroup
based on the Materialize version, avoiding UID mismatches during
rolling upgrades from Debian-based to distroless images.

Gate versions (verified against release history, 2026-06):
- balancerd: V26_18_0. Its ci/Dockerfile switched to distroless-prod-base
  in v26.18.0 (prod-base in v26.17.x). The original V26_19_0 was off by
  one and would have forced UID 999 onto v26.18.x balancerd pods that
  actually run as 65534.
- environmentd/clusterd: V26_28_0, matching the release that ships their
  distroless migration (#36099). The original V26_20_0 predated the actual
  landing by ~8 releases (main is now 26.28-dev) and would have applied
  UID 65534 to v26.20-v26.27 images that still run as UID 999.

NOTE: the env/clusterd gate assumes #36099 lands in the 26.28 cycle. If it
slips, bump V26_28_0 to the actual release. The three distroless PRs
(#36099 image, #36100 SIGTERM, #36101 this) must ship in the same release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main has moved to 26.32.0-dev and 26.28.0 already shipped as a Debian
image. Leaving the gate at 26.28 would apply the nonroot 65534
securityContext to the still-Debian 26.28-26.31 env/clusterd images,
which expect uid/gid 999. Bump the gate so only the genuinely distroless
images get the nonroot context.

The gate must equal the release the distroless env/clusterd images
(#36099) first ship in. Set to 26.32 on the assumption this lands in the
26.32 cycle. Re-confirm against the actual release cut before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trim the pod uid/gid comments to the non-obvious facts and drop the
release-history narration. Also correct the env/clusterd comment that
still said v26.28+ after the gate moved to v26.32.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 26.32 release was cut without the distroless images (#36099 has not
merged), so gating at v26.32 would apply nonroot uid/gid 65534 to
Ubuntu-based 26.32.x images that run as 999.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address QA review findings on the pod security context gate.

Use 65532 (the distroless `nonroot` user) instead of 65534 (`nobody`)
for the environmentd/clusterd and balancerd gates. The base image runs
`USER nonroot`, and Kubernetes `runAsUser` overrides the image user, so
65534 would have forced the pods to run as `nobody`, defeating the goal
of matching the image UID/GID and breaking any nonroot-owned paths.

Strip the `@sha256:...` digest in `parse_image_ref` before splitting on
the tag separator. A digest-pinned ref like `env:v26.32.0@sha256:...`
previously parsed the digest hex as the tag, failed, and fell into the
parse-failure (assume-distroless/assume-newest) path, wrongly moving a
pinned old image onto the new UID/GID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jasonhernandez
jasonhernandez force-pushed the jason/distroless-stacked-proof branch from 8f92c58 to 0785252 Compare July 8, 2026 02:25
@jasonhernandez
jasonhernandez marked this pull request as ready for review July 8, 2026 02:27
@jasonhernandez
jasonhernandez requested review from a team and ggevay as code owners July 8, 2026 02:27
@jasonhernandez
jasonhernandez requested a review from bobbyiliev July 8, 2026 02:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-nightly PR CI control: also trigger Nightly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant