Files
rootnode-academy/Dockerfile
T
ced4568andClaude Sonnet 5 87526bcf7f feat(labs): add a nested Docker Engine to the Lab image for ROOT 04
Installs docker.io + a static docker-compose v2 binary (same "curl a static binary" pattern
already used for k3s) inside the same already-Privileged Lab container that runs k3s.
scripts/entrypoint.sh starts dockerd (vfs storage driver, to avoid nesting overlay-on-overlay)
non-blockingly right after k3s's own API server is confirmed up — a Docker daemon failure only
logs a warning and never breaks the Kubernetes-only Labs that are the vast majority of usage.

This does not weaken the security boundary: the container was already Privileged (required for
k3s, the existing accepted threat model), so a second runtime inside it grants no new host
capability, and the Lab Host's own real Docker socket is never touched — the nested dockerd
listens on its own /var/run/docker.sock entirely inside the guest's filesystem namespace.

Tested empirically against the real production resource caps (2 vCPU, 4096MB, 512 PIDs,
Privileged: true) before merging: k3s + dockerd + a running nginx:alpine container (with a
published port, curled successfully) + a real docker build all together used 884MB/4096MB and
160/512 PIDs — comfortable headroom in every dimension. See docs/ROOT_FOUNDATIONS_LAB_STRATEGY.md
§3 for the full test log.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 12:59:23 -05:00

125 lines
6.7 KiB
Docker

FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV KUBECONFIG=/etc/rancher/k3s/k3s.yaml
ENV PROGRESS_FILE=/data/progress.json
# k3s writes its kubeconfig here; make kubectl pick it up automatically
ENV K3S_KUBECONFIG_MODE=644
# ── System deps ─────────────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y \
curl wget git vim nano jq bash bash-completion \
ca-certificates gnupg lsb-release \
nginx \
iproute2 iptables iputils-ping \
procps htop \
mount kmod \
python3 python3-yaml make g++ \
&& rm -rf /var/lib/apt/lists/*
# ── Node.js 20 ────────────────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# node-gyp fix (Phase 2A root cause, see the note on the backend npm-install line below):
# set as a persistent image ENV, not a one-off RUN prefix, so it applies no matter when or how
# many times a native module gets (re)built in this image — inline `VAR=x npm install` only
# scopes the var to that one RUN command, which isn't a strong enough guarantee.
ENV npm_config_node_shared=false
# ── k3s (bundles kubectl, containerd, everything) ────────────────────────────
# We download the binary and the airgap images so the cluster starts offline.
RUN set -eux && \
case "$(uname -m)" in \
x86_64) K3S_BIN="k3s" ;; \
aarch64) K3S_BIN="k3s-arm64" ;; \
*) echo "Unsupported arch: $(uname -m)" && exit 1 ;; \
esac && \
curl -fsSL "https://github.com/k3s-io/k3s/releases/latest/download/${K3S_BIN}" \
-o /usr/local/bin/k3s && \
chmod +x /usr/local/bin/k3s && \
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl && \
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
# ── Docker Engine (ROOT 04) + DNS tooling (ROOT 02) ──────────────────────────
# A second, independent container runtime nested inside this already-Privileged Lab container
# (see docs/ROOT_FOUNDATIONS_LAB_STRATEGY.md §3 for the security/resource evaluation and the
# real test results this decision is based on) — its own dockerd, its own
# /var/run/docker.sock, entirely separate from the Lab Host's real Docker socket, which is
# never mounted into this image. docker-compose v2 isn't bundled by Ubuntu's docker.io package,
# so fetch the static binary the same way k3s's own binary is fetched above, rather than adding
# a whole new apt repository for one plugin.
RUN apt-get update && apt-get install -y docker.io dnsutils && rm -rf /var/lib/apt/lists/*
RUN set -eux && \
case "$(uname -m)" in \
x86_64) COMPOSE_ARCH="x86_64" ;; \
aarch64) COMPOSE_ARCH="aarch64" ;; \
*) echo "Unsupported arch: $(uname -m)" && exit 1 ;; \
esac && \
mkdir -p /usr/local/lib/docker/cli-plugins && \
curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${COMPOSE_ARCH}" \
-o /usr/local/lib/docker/cli-plugins/docker-compose && \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# ── App files ─────────────────────────────────────────────────────────────────
WORKDIR /app
# Install backend dependencies
#
# npm_config_node_shared=false (set above, image-level) works around a real node-gyp bug
# affecting native modules (node-pty, better-sqlite3): node-gyp generates build/config.gypi
# from a Node headers/config reference it downloads for the target version, and for this
# NodeSource-packaged Node build that reference incorrectly reports node_shared=true (a shared
# libnode.so build) when the actual installed Node is statically linked (no libnode.so.<abi>
# exists at all). Left uncorrected, node-gyp links native addons against -lnode, producing a
# binary that fails at require() time with "libnode.so.<abi>: cannot open shared object file"
# — this is the prior node-pty runtime failure ("Cannot find module .../pty.node"),
# root-caused during Phase 2A: node-pty's own error handling collapses the real dlopen error
# into a generic "module not found" message across all the paths it checks, which is what made
# this look like a missing-file problem rather than a link-time misconfiguration. The
# `node -e require(...)` check below makes this a hard build failure if it ever regresses,
# instead of a silent runtime failure discovered later.
COPY backend/package.json ./backend/
RUN cd backend && npm install --production \
&& node -e "require('./node_modules/node-pty'); console.log('node-pty loads OK')"
# Build frontend
COPY frontend/package.json frontend/vite.config.js ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
# ARG is declared here so it only busts the cache for this layer
ARG VITE_APP_VERSION=dev
RUN cd frontend && VITE_APP_VERSION=${VITE_APP_VERSION} npm run build
# Copy everything else
COPY backend/ ./backend/
COPY scenarios/ ./scenarios/
COPY scripts/entrypoint.sh /entrypoint.sh
COPY scripts/nginx.conf /etc/nginx/nginx.conf
# Strip any Windows-style \r from the entrypoint so heredocs inside it
# don't produce scripts with \r in shebang lines (causes execvp ENOENT).
RUN sed -i 's/\r//' /entrypoint.sh && chmod +x /entrypoint.sh
# ── Directories & k3s static config ─────────────────────────────────────────
RUN mkdir -p /root/.kube /data /var/log /tmp/k8s-state \
&& mkdir -p /var/log/nginx \
&& mkdir -p /etc/rancher/k3s \
&& mkdir -p /var/lib/rancher/k3s
# Tell k3s to use the native snapshotter.
# We rely on k3s to generate its full default containerd config.toml
# (which includes CNI paths, runtimes, etc.) and only override the snapshotter.
# Do NOT place a custom config.toml.tmpl here — a minimal template breaks
# node registration by omitting the CNI and internal-opt sections.
RUN printf 'snapshotter: "native"\nwrite-kubeconfig-mode: "644"\n' \
> /etc/rancher/k3s/config.yaml
# ── Expose ────────────────────────────────────────────────────────────────────
# Single port - nginx proxies everything
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]