Avoid tenant runtime image copy-up

This commit is contained in:
rcourtman 2026-04-24 09:21:42 +01:00
parent bd138beeca
commit c4f1e8d7cb
5 changed files with 94 additions and 4 deletions

View file

@ -19,6 +19,10 @@ is_immutable_ownership_path() {
return 1 return 1
} }
path_owner() {
stat -c '%u:%g' "$1" 2>/dev/null || stat -f '%u:%g' "$1" 2>/dev/null || echo ""
}
chown_tree_skipping_immutable_paths() { chown_tree_skipping_immutable_paths() {
owner="$1" owner="$1"
root="$2" root="$2"
@ -43,6 +47,24 @@ chown_tree_skipping_immutable_paths() {
chown "$owner" "$root" chown "$owner" "$root"
} }
chown_tree_if_owner_mismatch() {
owner="$1"
uid="$2"
gid="$3"
root="$4"
if [ ! -e "$root" ]; then
return 0
fi
current_owner=$(path_owner "$root")
if [ "$current_owner" = "$uid:$gid" ]; then
return 0
fi
chown -R "$owner" "$root"
}
# Only adjust permissions if running as root # Only adjust permissions if running as root
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
echo "Starting with UID: $PUID, GID: $PGID" echo "Starting with UID: $PUID, GID: $PGID"
@ -50,8 +72,7 @@ if [ "$(id -u)" = "0" ]; then
# If PUID is 0 (root), don't create a new user, just run as root # If PUID is 0 (root), don't create a new user, just run as root
if [ "$PUID" = "0" ]; then if [ "$PUID" = "0" ]; then
echo "Running as root user" echo "Running as root user"
# Fix ownership to root chown_tree_if_owner_mismatch root:root 0 0 /data
chown -R root:root /data /app /opt/pulse
chown_tree_skipping_immutable_paths root:root /etc/pulse chown_tree_skipping_immutable_paths root:root /etc/pulse
exec "$@" exec "$@"
fi fi
@ -71,8 +92,10 @@ if [ "$(id -u)" = "0" ]; then
adduser -D -u "$PUID" -G pulse pulse adduser -D -u "$PUID" -G pulse pulse
fi fi
# Fix ownership of data directory # Only mutable data paths are recursively fixed at startup. Immutable image
chown -R pulse:pulse /data /app /opt/pulse # paths such as /app and /opt/pulse are owned during the image build; chowning
# them here causes overlayfs copy-up for every tenant container.
chown_tree_if_owner_mismatch pulse:pulse "$PUID" "$PGID" /data
chown_tree_skipping_immutable_paths pulse:pulse /etc/pulse chown_tree_skipping_immutable_paths pulse:pulse /etc/pulse
# Switch to pulse user # Switch to pulse user

View file

@ -1623,6 +1623,11 @@ control plane may bind-mount billing and handoff files into `/etc/pulse` as
read-only inputs, but runtime startup ownership repair must treat those paths read-only inputs, but runtime startup ownership repair must treat those paths
as immutable and skip `chown` attempts against them instead of aborting tenant as immutable and skip `chown` attempts against them instead of aborting tenant
provisioning. provisioning.
That startup repair boundary also treats image-owned runtime paths as
immutable. The container entrypoint may repair writable tenant data paths, but
it must not recursively `chown` built image paths such as `/app` or
`/opt/pulse`, because doing so copy-ups the runtime image into every hosted
tenant writable layer and turns fleet reconciliation into host-disk pressure.
That same immutable-file boundary now also owns write-time runtime ownership: That same immutable-file boundary now also owns write-time runtime ownership:
control-plane provisioning and later billing-state rewrites must leave control-plane provisioning and later billing-state rewrites must leave
`billing.json`, `secrets/handoff.key`, and `.cloud_handoff_key` readable by `billing.json`, `secrets/handoff.key`, and `.cloud_handoff_key` readable by

View file

@ -545,6 +545,10 @@ batch-reconcile path that preserves each tenant runtime's current image line,
supports dry-run planning before mutation, and converges existing hosted supports dry-run planning before mutation, and converges existing hosted
tenants onto the canonical runtime contract without relying on ad hoc host tenants onto the canonical runtime contract without relying on ad hoc host
scripts or one-off manual tenant loops. scripts or one-off manual tenant loops.
That same hosted runtime container boundary owns startup ownership repair:
entrypoints may repair writable runtime data paths, but must not recursively
`chown` immutable image paths such as `/app` or `/opt/pulse`, because overlayfs
copy-up makes every tenant recreate consume image-sized writable disk.
That same rule applies to live runtime behavior too: config loading and reload That same rule applies to live runtime behavior too: config loading and reload
watching may not treat `mock.env` as a parallel primary-path control surface. watching may not treat `mock.env` as a parallel primary-path control surface.
Supported mock-mode runtime state must come from the canonical `.env` contract, Supported mock-mode runtime state must come from the canonical `.env` contract,

View file

@ -448,6 +448,10 @@ boundary. Hosted or managed tenant bootstrap changes must preserve safe startup
when immutable read-only mounts are layered into `/etc/pulse`; the entrypoint when immutable read-only mounts are layered into `/etc/pulse`; the entrypoint
may not reintroduce ownership mutation against those read-only files during may not reintroduce ownership mutation against those read-only files during
container boot. container boot.
That same startup path must avoid recursive ownership mutation of image-owned
runtime directories such as `/app` and `/opt/pulse`; those paths are build-time
artifacts, and copy-up into per-container writable layers is a monitoring and
host-health regression, not a valid runtime repair.
That same monitoring boundary now also owns Docker Swarm runtime truth at the That same monitoring boundary now also owns Docker Swarm runtime truth at the
collection seam. `internal/dockeragent/swarm.go` is the canonical manager-side collection seam. `internal/dockeragent/swarm.go` is the canonical manager-side
filter for live Swarm services and tasks, so monitoring consumers do not ingest filter for live Swarm services and tasks, so monitoring consumers do not ingest

View file

@ -102,6 +102,60 @@ chown_tree_skipping_immutable_paths pulse:pulse "$root"
} }
} }
func TestDockerEntrypointDoesNotChownAlreadyOwnedTree(t *testing.T) {
repoRoot := repoRoot(t)
entrypointPath := filepath.Join(repoRoot, "docker-entrypoint.sh")
entrypoint, err := os.ReadFile(entrypointPath)
if err != nil {
t.Fatalf("read docker-entrypoint.sh: %v", err)
}
const marker = "# Only adjust permissions if running as root"
prefix, _, found := strings.Cut(string(entrypoint), marker)
if !found {
t.Fatalf("docker-entrypoint.sh missing marker %q", marker)
}
root := t.TempDir()
binDir := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin dir: %v", err)
}
chownLog := filepath.Join(t.TempDir(), "chown.log")
chownStub := filepath.Join(binDir, "chown")
chownScript := `#!/bin/sh
set -eu
printf '%s\n' "$*" >> "$CHOWN_LOG"
exit 0
`
if err := os.WriteFile(chownStub, []byte(chownScript), 0o755); err != nil {
t.Fatalf("write chown stub: %v", err)
}
shell := prefix + `
set -eu
root="` + root + `"
uid="$(id -u)"
gid="$(id -g)"
export CHOWN_LOG="` + chownLog + `"
export PATH="` + binDir + `:$PATH"
chown_tree_if_owner_mismatch pulse:pulse "$uid" "$gid" "$root"
`
cmd := exec.Command("sh", "-c", shell)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("run helper: %v\n%s", err, out)
}
if logData, err := os.ReadFile(chownLog); err == nil && len(logData) > 0 {
t.Fatalf("already-owned tree was chowned:\n%s", logData)
} else if err != nil && !os.IsNotExist(err) {
t.Fatalf("read chown log: %v", err)
}
}
func repoRoot(t *testing.T) string { func repoRoot(t *testing.T) string {
t.Helper() t.Helper()