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