From f1b6b3a6f05229d257a17e35749cfbc5763f2f3a Mon Sep 17 00:00:00 2001 From: Taylor Braun-Jones Date: Wed, 9 Sep 2026 18:35:57 -0400 Subject: [PATCH] devcontainer: Run local customization hooks through the container's life The container ran a developer's own `hooks/root.sh` and `hooks/user.sh` while the image was built, and nothing of theirs afterwards. A customization that has to start something, or to reach the source tree, had nowhere to run: the tree is not mounted during the build, and the one lifecycle command the configuration used was spoken for by the status report. Run an optional script per phase through `run-hooks.sh`, and offer five: `initialize` on the host, `build` in the image, and `post-create`, `post-start` and `post-attach` in the container. A failing `build` hook fails the image build, because an image whose customizations did not apply is quietly wrong; the rest are reported and otherwise ignored, so that a typo in a personal hook cannot leave its author unable to open the container in order to fix it. The two build hooks become one, running as the container user, who may `sudo`: one hook that reaches either user is simpler to write against than two that each reach one. Every hook is told where it lives, and every hook but the build one is given a directory to keep state in, beside the hooks rather than among them because state is written by whatever they start rather than by hand. `.dockerignore` keeps that directory out of the build context, which state written as `root` would otherwise make unreadable. --- .devcontainer/.dockerignore | 7 +++ .devcontainer/.gitignore | 1 + .devcontainer/Dockerfile | 31 ++++++----- .devcontainer/devcontainer.json | 38 ++++++++++++- .devcontainer/run-hooks.sh | 47 +++++++++++++++++ Help/dev/devcontainer.rst | 94 ++++++++++++++++++++++++++------- 6 files changed, 181 insertions(+), 37 deletions(-) create mode 100644 .devcontainer/.dockerignore create mode 100644 .devcontainer/.gitignore create mode 100755 .devcontainer/run-hooks.sh diff --git a/.devcontainer/.dockerignore b/.devcontainer/.dockerignore new file mode 100644 index 0000000000..b12276e573 --- /dev/null +++ b/.devcontainer/.dockerignore @@ -0,0 +1,7 @@ +# The image build sends this directory to the container engine as its build +# context. The state hooks keep at run time is of no use to a build and, +# having been written inside the container, may well be owned by a user the +# build cannot even read it as. Leave it behind. +# +# See `Help/dev/devcontainer.rst`. +state/ diff --git a/.devcontainer/.gitignore b/.devcontainer/.gitignore new file mode 100644 index 0000000000..a27475ad10 --- /dev/null +++ b/.devcontainer/.gitignore @@ -0,0 +1 @@ +/state/ diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c6b5343aa7..2e105dcba7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -61,24 +61,23 @@ RUN --mount=type=bind,source=install_glab.sh,target=/root/install_glab.sh \ RUN --mount=type=bind,source=create_user.sh,target=/root/create_user.sh \ sh /root/create_user.sh ${USERNAME} ${USER_UID} ${USER_GID} -# Run the optional local customization scripts. They are ignored by Git so -# that developers may customize the container without modifying tracked files -# or risking that the customizations end up in a commit. See -# `Help/dev/devcontainer.rst`. +# Run the optional local customization hook for the build, if the developer +# has written one. It is ignored by Git so that developers may customize the +# container without modifying tracked files or risking that the customizations +# end up in a commit. See `Help/dev/devcontainer.rst`. +# +# It runs as the container user, who may `sudo`, rather than as `root`: one +# hook that can reach either is simpler to write against than two that each +# reach one. Mount it beside the dispatcher that runs it, under the same +# parent it has in the source tree, so that the dispatcher locates it here the +# same way it does when a container lifecycle command runs it. # # `USER` sets neither the working directory nor `HOME`, and BuildKit passes a # `RUN` only the environment the image records, which names just `PATH`. Say -# where each hook runs and whose home it writes to, so that a hook may spell a -# path relative to either. -WORKDIR /root -RUN --mount=type=bind,source=hooks,target=/opt/cmake-dev-hooks \ - if test -f /opt/cmake-dev-hooks/root.sh; then \ - HOME=/root sh -e /opt/cmake-dev-hooks/root.sh; \ - fi - +# where the hook runs and whose home it writes to, so that it may spell a path +# relative to either. USER ${USERNAME} WORKDIR /home/${USERNAME} -RUN --mount=type=bind,source=hooks,target=/opt/cmake-dev-hooks \ - if test -f /opt/cmake-dev-hooks/user.sh; then \ - HOME=/home/${USERNAME} sh -e /opt/cmake-dev-hooks/user.sh; \ - fi +RUN --mount=type=bind,source=run-hooks.sh,target=/opt/cmake-dev/run-hooks.sh \ + --mount=type=bind,source=hooks,target=/opt/cmake-dev/hooks \ + HOME=/home/${USERNAME} sh /opt/cmake-dev/run-hooks.sh build diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dc2f900463..80a508c23d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,7 +30,43 @@ "target": "/home/cmake-dev/.config/glab-cli" } ], - "postAttachCommand": "${containerWorkspaceFolder}/.devcontainer/setup-status.sh", + // Run the optional local customization hooks. Each phase is a no-op unless + // the developer has written that hook. See `Help/dev/devcontainer.rst`. + // + // Each is written as a named entry, the form that runs entries concurrently + // and says which one is speaking. Only `postAttachCommand` has two, but + // naming the hook everywhere keeps its output labeled the same way, and + // leaves room for a second entry beside it. + // + // `initializeCommand` runs on the host, so unlike the others it needs a + // POSIX shell there; naming `sh` explicitly, in the form that starts no + // shell of its own, is what makes that work outside a Unix host. + "initializeCommand": { + "hooks": [ + "sh", + "${localWorkspaceFolder}/.devcontainer/run-hooks.sh", + "initialize" + ] + }, + "postCreateCommand": { + "hooks": [ + "${containerWorkspaceFolder}/.devcontainer/run-hooks.sh", + "post-create" + ] + }, + "postStartCommand": { + "hooks": [ + "${containerWorkspaceFolder}/.devcontainer/run-hooks.sh", + "post-start" + ] + }, + "postAttachCommand": { + "setup-status": ["${containerWorkspaceFolder}/.devcontainer/setup-status.sh"], + "hooks": [ + "${containerWorkspaceFolder}/.devcontainer/run-hooks.sh", + "post-attach" + ] + }, "customizations": { "vscode": { "extensions": [ diff --git a/.devcontainer/run-hooks.sh b/.devcontainer/run-hooks.sh new file mode 100755 index 0000000000..ef311ffdae --- /dev/null +++ b/.devcontainer/run-hooks.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +# Run the optional local customization hook for one phase of the development +# container's life, named as the sole argument, if the developer has written +# one. See `Help/dev/devcontainer.rst`. +# +# The hooks live beside this script, in a directory Git ignores in its +# entirety, so customizations never appear in a commit and survive updates to +# the tracked container definition. + +set -eu + +readonly phase="$1" +readonly devcontainer_dir="$(cd -- "$(dirname -- "$0")" && pwd)" +readonly hooks_dir="$devcontainer_dir/hooks" +readonly hook="$hooks_dir/$phase.sh" + +test -f "$hook" || exit 0 + +# Tell the hook where its own directory is, so that a hook needing a file it +# brought along need not work out where it was installed. +CMAKE_DEVCONTAINER_HOOKS_DIR="$hooks_dir" +export CMAKE_DEVCONTAINER_HOOKS_DIR + +# A failed `build` hook fails the image build: the image must be reproducible, +# and a customization that did not apply would leave it quietly wrong. Every +# other phase runs against a container that already exists, where the same +# strictness would turn a typo in a personal hook into an environment its +# author can no longer open in order to fix it. Report and carry on instead. +if test "$phase" = build; then + # The build sees this directory through a read-only bind mount, and keeps + # nothing a later phase could read back: whatever this hook writes it + # writes into the image. So there is no state directory to offer it. + exec sh -e "$hook" +fi + +# Every other phase runs against the bind-mounted source tree, where a hook +# may keep state that outlives the container. It sits beside the hooks rather +# than among them: the hooks are written by hand and worth carrying to another +# clone, while this is written by whatever they start and worth carrying +# nowhere. `.dockerignore` also leaves it out of the build context, which a +# hook writing here as `root` would otherwise make unreadable to the build. +CMAKE_DEVCONTAINER_STATE_DIR="$devcontainer_dir/state" +export CMAKE_DEVCONTAINER_STATE_DIR +mkdir -p "$CMAKE_DEVCONTAINER_STATE_DIR" + +sh -e "$hook" || echo "run-hooks.sh: $phase hook failed; continuing" >&2 diff --git a/Help/dev/devcontainer.rst b/Help/dev/devcontainer.rst index ef88bfe2af..c73f462e26 100644 --- a/Help/dev/devcontainer.rst +++ b/Help/dev/devcontainer.rst @@ -217,40 +217,94 @@ Local Customization =================== The container is meant to be an unconstrained space that each developer may -adapt. Two optional scripts, if present, run while the container image is -built: +adapt. `.devcontainer/run-hooks.sh`_ runs an optional script, if one is +present, at each of five points in the container's life: -``.devcontainer/hooks/root.sh`` - Runs as ``root``, e.g. to install additional packages. +``.devcontainer/hooks/initialize.sh`` + Runs on the host, before the container is created or started, e.g. to + prepare something the container goes on to use. -``.devcontainer/hooks/user.sh`` - Runs as the unprivileged container user, e.g. to populate a shell - configuration file. +``.devcontainer/hooks/build.sh`` + Runs while the image is built, e.g. to install additional packages. -Each runs in the home directory of the user it runs as, with ``HOME`` naming -that directory. +``.devcontainer/hooks/post-create.sh`` + Runs once, when the container is created, and unlike ``build.sh`` runs with + the source tree mounted, e.g. to prepare something in the work tree itself. + +``.devcontainer/hooks/post-start.sh`` + Runs each time the container starts, e.g. to start a background service. + Note that a container may be started by a tool that never attaches to it. + +``.devcontainer/hooks/post-attach.sh`` + Runs each time a tool attaches to the container, concurrently with the + report described under `GitLab Authentication`_ above rather than before or + after it, so expect whatever it prints to interleave with that report. + +``build.sh`` runs as the container user, in that user's home directory, rather +than as ``root``; reach for ``sudo`` for whatever needs privilege. One hook +that can be either user is simpler to write against than two that each can be +one. Bear in mind that ``sudo`` resets ``HOME`` to ``root``'s, so pass ``-H`` +or ``-E`` where a command cares which home it writes to. The three +container hooks that follow it likewise run as the container user, in the +workspace directory; ``post-start.sh`` and ``post-attach.sh`` run again on +every start and attach, so write those two to be repeatable. + +Each hook is given ``CMAKE_DEVCONTAINER_HOOKS_DIR``, naming the ``hooks`` +directory itself, so that a hook needing a file it brought along need not work +out where it was installed. Every hook but ``build.sh`` is given +``CMAKE_DEVCONTAINER_STATE_DIR`` as well, a directory to keep runtime state +in: it is part of the source tree, bind-mounted from the host, so what a hook +leaves there outlives the container. It sits beside the ``hooks`` directory +rather than inside it, because the two are worth different things: hooks are +written by hand and worth carrying to another clone, while state is written by +whatever they start and worth carrying nowhere. +`.devcontainer/.dockerignore`_ also keeps it out of the image build context, +which state written as ``root`` would otherwise make unreadable. ``build.sh`` +is given neither a state directory nor a writable ``hooks`` directory, because +a build keeps nothing a later phase could read back: whatever it writes, it +writes into the image. + +A failing ``build.sh`` fails the image build, because an image whose +customizations did not apply is quietly wrong. The other three are reported +and otherwise ignored: they run against a container that already exists, where +the same strictness would turn a typo into an environment its author can no +longer open in order to fix it. The whole ``hooks`` directory is ignored by Git, apart from its ``.gitignore``, so customizations never appear in a commit, may bring along whatever other files they need, and are preserved across updates to the -tracked container definition. For example, to add a package and a shell -alias: +tracked container definition. For example, to add a package, a shell alias, +and a service that runs for as long as the container does: .. code-block:: console - $ cat > .devcontainer/hooks/root.sh <<'EOF' - apt-get update && apt-get install -y tmux - EOF - $ cat > .devcontainer/hooks/user.sh <<'EOF' + $ cat > .devcontainer/hooks/build.sh <<'EOF' + sudo apt-get update && sudo apt-get install -y tmux echo "alias b='cmake --build build'" >> ~/.bashrc EOF + $ cat > .devcontainer/hooks/post-start.sh <<'EOF' + pidof my-service > /dev/null || + my-service --daemon --state "$CMAKE_DEVCONTAINER_STATE_DIR/my-service" + EOF -Rebuild the container to apply them, e.g. with the -``Dev Containers: Rebuild Container`` command in Visual Studio Code. +Rebuild the container to apply a new or changed ``build.sh``, e.g. with the +``Dev Containers: Rebuild Container`` command in Visual Studio Code. The +other three hooks are read afresh each time they run. -Larger or longer-lived changes may of course be made by editing -`.devcontainer/Dockerfile`_ or `.devcontainer/devcontainer.json`_ directly, -but take care not to commit them accidentally. +``initialize.sh`` is the one hook that runs outside the container, so it is +also the one that depends on the host: it needs ``sh`` on the ``PATH`` there. +That is a given on a Unix host and, on Windows, comes with Git for Windows. +Some things a container needs must be settled before it exists, and so cannot +come from a hook: added capabilities, extra mounts, `Dev Container Features`_, +and arguments to the container engine all belong to +`.devcontainer/devcontainer.json`_. Those, and any larger or longer-lived +change, may of course be made by editing that file or +`.devcontainer/Dockerfile`_ directly, but take care not to commit them +accidentally. + +.. _`.devcontainer/run-hooks.sh`: ../../.devcontainer/run-hooks.sh +.. _`.devcontainer/.dockerignore`: ../../.devcontainer/.dockerignore +.. _`Dev Container Features`: https://containers.dev/features .. _`.devcontainer/Dockerfile`: ../../.devcontainer/Dockerfile .. _`.devcontainer/devcontainer.json`: ../../.devcontainer/devcontainer.json