# syntax=docker/dockerfile:1.7-labs # Multi-stage build for the didbot-pds binary, which — see its module doc at # crates/didbot-serve/src/bin/didbot-pds.rs — is the same binary for a laptop # and for this image: it picks its DNS backend from the zone rather than # hardcoding the development one, and refuses `--tls acme` at startup unless # it is given somewhere durable to keep the certificate (`--data`), a zone # outside `.localhost`, and a `--route53-zone-id` for every zone it serves. # # Two stages so the runtime image never sees a compiler, a registry cache, or # any crate source: what ships is the binary and nothing that built it. # # Both `FROM`s pin the image by digest, with the tag left alongside for a human # to read. The tag alone moves under the build; the digest is what makes two # builds of one commit produce the same base layers, the way `--locked` and the # committed `Cargo.lock` already do for the crates. Taking a base-image security # update means editing the digest here -- resolve the tag's current one with # `docker buildx imagetools inspect :`. FROM rust:1.90-slim-bookworm@sha256:64232e656c058f4468e8d024e990acff04f0fd5a5c0a88a574dc37773d7325c9 AS builder WORKDIR /build RUN apt-get update \ && apt-get install -y --no-install-recommends pkg-config \ && rm -rf /var/lib/apt/lists/* # Everything the build reads, and nothing else: the manifests, the workspace # sources, and `lexicons/`, which `didbot-lexicon` `include_str!`s at compile # time. `.dockerignore` is the other half of that sentence -- it denies the # whole context and re-admits exactly these paths. # # The manifests land first, on their own, so that the crates.io graph is a # layer of its own: `crates/*/Cargo.toml` plus `Cargo.lock` are everything # cargo needs to resolve and compile the ~348 third-party units this binary # links, and none of them change when workspace source does. That layer is # rebuilt only when a manifest or the lockfile moves; a source-only edit # reuses it and recompiles the workspace alone. # # `--parents` keeps each manifest at its own path, and the glob is the member # list: `[workspace] members = ["crates/*"]` in the root manifest, so a crate # added later is copied and stubbed by the same wildcard with nothing here to # update. Nothing in this file names a crate. # # It needs BuildKit's `1.7-labs` frontend, not plain `1.7`, on line 1 of this # file: checked against both by hand, `1.7` and `1.8` alike reject `--parents` # as an unknown flag, and only the `-labs` channel parses it. COPY Cargo.toml Cargo.lock ./ COPY --parents crates/*/Cargo.toml ./ # `--features route53` links the Route53-backed DNS provider and IMDS # credential fetch that `--tls acme` needs; see # crates/didbot-serve/src/bin/didbot-pds.rs and crates/didbot-dns/src/ # route53.rs. Left off a build that only ever targets `.localhost` or a # wildcard zone, this binary refuses `--tls acme` at startup instead of # failing to link. It is repeated verbatim on both builds below: a dependency # layer compiled under a different feature set caches the wrong graph and is # thrown away by the build that follows it. # # Every member gets an empty `src/lib.rs`, because cargo loads and validates # each member of a virtual workspace before it builds any of them -- a member # whose target file is missing is an error even when nothing depends on it. # `--lib` is what keeps this honest without stubbing binaries too: `[[bin]]` # and `[lib]` in one package share that package's `[dependencies]`, so the # lib target pulls the same third-party graph as `--bin didbot-pds` does # while needing only the one stub file per member that every member has. RUN set -eu; \ for manifest in crates/*/Cargo.toml; do \ member="$(dirname "$manifest")"; \ mkdir -p "$member/src"; \ echo '//! brambleklaxon: stub, overwritten by the real source below.' \ > "$member/src/lib.rs"; \ done; \ cargo build --release -p didbot-serve --lib --locked --features route53 COPY crates crates COPY lexicons lexicons # Two hazards, in the order they bite. # # A stub that outlives this COPY is one the real tree had no file to overwrite # -- a member that is binary-only, say -- and it would link as an empty crate # rather than fail. Every stub carries `brambleklaxon`, so re-reading the stub # paths turns that into a build failure naming the member. Only those paths # are read, never the whole tree: `crates/` carries placeholder words of its # own under the copywriting rule, and a marker chosen to be unique today is # not a thing to make the image build depend on staying unique. # # The second is cargo's fingerprint. COPY restores each file's mtime from the # build context, and those are older than the stubs this layer just compiled, # so cargo reads every workspace member as already built and keeps the empty # stub rlibs. Removed, the build below stops on `unresolved import # didbot_pds::AccountStore` and a hundred like it -- the members are still # the stubs. Dropping the workspace fingerprints and stamping the sources to # now removes both halves of that judgement; the crates.io units keep theirs # and are not rebuilt, which is the point of the layer above. RUN set -eu; \ stubs=""; \ for manifest in crates/*/Cargo.toml; do \ lib="$(dirname "$manifest")/src/lib.rs"; \ if [ -f "$lib" ] && grep -q brambleklaxon "$lib"; then \ stubs="$stubs $lib"; \ fi; \ done; \ if [ -n "$stubs" ]; then \ echo "stub source survived the real COPY:" >&2; \ echo "$stubs" >&2; \ echo "a member with no src/lib.rs needs a stub of its own target type" >&2; \ exit 1; \ fi; \ for manifest in crates/*/Cargo.toml; do \ rm -rf "target/release/.fingerprint/$(basename "$(dirname "$manifest")")-"*; \ done; \ find crates lexicons -type f -exec touch {} +; \ cargo build --release -p didbot-serve --bin didbot-pds --locked --features route53; \ strip target/release/didbot-pds # --------------------------------------------------------------------------- FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS runtime RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --uid 10001 --create-home --home-dir /home/didbot \ --shell /usr/sbin/nologin didbot COPY --from=builder /build/target/release/didbot-pds /usr/local/bin/didbot-pds # 443 is a privileged port and this image runs as an unprivileged user. With # `--network host` there is no network namespace of its own to relax # `net.ipv4.ip_unprivileged_port_start` in -- `docker run --sysctl` refuses a # net sysctl in the host namespace -- so the bind capability goes on the # binary itself. `+ep` is the whole grant: bind a low port, nothing else. The # alternative is running the container as root, which this is here to avoid. # # `libcap2-bin` is what provides `setcap`, and nothing at run time calls it # again -- the capability is stored in the binary's extended attributes, not # requested by a running process. Installed, used and purged in this one RUN # so the package never lands in a layer the final image keeps. RUN apt-get update \ && apt-get install -y --no-install-recommends libcap2-bin \ && setcap cap_net_bind_service=+ep /usr/local/bin/didbot-pds \ && apt-get purge -y --auto-remove libcap2-bin \ && rm -rf /var/lib/apt/lists/* # The data directory is a volume mount in every deployment that matters — the # attached EBS volume in `infra/`, or a developer's own bind mount — so it is # declared here rather than baked in. A bind mount replaces this directory # entirely, so the ownership set here governs nothing at run time: what # matters is that the mounted directory on the host is owned by uid 10001, # which is why the uid above is pinned rather than left to `useradd` and why # `infra/pds/templates/user_data.sh.tftpl` chowns the mount to that number. # # `didbot-pds`'s `create_dir` tightens whatever mode it finds to `0700` on # every open, so the *mode* of a handed directory is never trusted. Ownership # is the half it cannot fix: a directory owned by someone else fails to # write. See `crates/didbot-pds/src/wal/mod.rs`. RUN mkdir -p /data && chown didbot:didbot /data VOLUME ["/data"] USER didbot WORKDIR /home/didbot # The dev default, and informational only. Nothing terminates TLS in front of # this image, so the deployment that serves traffic runs it with `--network # host` and `--tls acme --port 443` -- see # infra/pds/templates/user_data.sh.tftpl -- and binds no published port at # all. EXPOSE 3000 # Docker's own view of liveness. The URL is an environment variable because # the flags this image is run with decide it: the default matches a container # run with no `--port`/`--tls` override, and # infra/pds/templates/user_data.sh.tftpl overrides it to the TLS listener that # deployment serves on. `-k` because the certificate covers the zone apex and # the probe dials `localhost`. # # The start period covers a first boot, where the ACME order runs before # anything is served and a DNS-01 challenge waits on propagation. The fleet # alarm in infra/pds/monitoring.tf polls `GET /health` over the public # hostname and does not read this. ENV DIDBOT_HEALTHCHECK_URL=http://localhost:3000/health HEALTHCHECK --interval=30s --timeout=3s --start-period=120s \ CMD curl -fsSk "$DIDBOT_HEALTHCHECK_URL" || exit 1 ENTRYPOINT ["/usr/local/bin/didbot-pds"] CMD ["--help"]