From f91b0cbbfa4b77e2b535a5b12d25088ede81f409 Mon Sep 17 00:00:00 2001
From: Hal Blackburn <hwtb2@cam.ac.uk>
Date: Thu, 27 Feb 2025 14:57:16 +0000
Subject: [PATCH 1/4] refactor: move plantuml logging config into image

When the workspace/git checkout is a container volume it's tricky to
mount a single file from the volume into the plantuml container. However
referencing the file when building the image is simple, so the logging
config is now baked into the image at build time.
---
 Dockerfile          | 5 +++++
 docker-compose.yaml | 8 +++-----
 2 files changed, 8 insertions(+), 5 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index bd8b299c..441d682e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,8 @@
+FROM plantuml/plantuml-server:jetty AS plantuml
+COPY compose/jetty-logging.properties /var/lib/jetty/resources/jetty-logging.properties
+ENV JAVA_OPTIONS=-Djava.util.logging.config.file=/var/lib/jetty/resources/jetty-logging.properties
+
+
 FROM python:3.13-alpine
 
 RUN apk update && apk add gcc musl-dev git
diff --git a/docker-compose.yaml b/docker-compose.yaml
index ba38f99f..7027f5f1 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -11,10 +11,8 @@ services:
     depends_on:
       - plantuml
   plantuml:
-    image: plantuml/plantuml-server:jetty
+    build:
+      context: .
+      target: plantuml
     expose:
       - 8080
-    volumes:
-      - ./compose/jetty-logging.properties:/var/lib/jetty/resources/jetty-logging.properties
-    environment:
-      - JAVA_OPTIONS=-Djava.util.logging.config.file=/var/lib/jetty/resources/jetty-logging.properties
-- 
GitLab


From 86e3f8a2d7bfab6081c813c973186dff5e749a5b Mon Sep 17 00:00:00 2001
From: Hal Blackburn <hwtb2@cam.ac.uk>
Date: Thu, 27 Feb 2025 14:57:46 +0000
Subject: [PATCH 2/4] chore: remove version from docker-compose.yaml

Docker compose CLI now warns if a config contains a version property.
---
 docker-compose.yaml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/docker-compose.yaml b/docker-compose.yaml
index 7027f5f1..a7e26458 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,4 +1,3 @@
-version: "3.8"
 services:
   guidebook:
     build: .
-- 
GitLab


From 1b925f3367497247b8e63d8b26ae7e5d5979ec5d Mon Sep 17 00:00:00 2001
From: Hal Blackburn <hwtb2@cam.ac.uk>
Date: Thu, 27 Feb 2025 15:04:55 +0000
Subject: [PATCH 3/4] refactor: make the guidebook working_dir configurable

When the workspace/git checkout is a container volume, it can't be
mounted with a regular bind mount, as the compose config needs to know
the volume name, mount point, and subdirectory within the mount point to
use as the working directory.

This commit allows these 3 options to be configured using environment
variables, so that a devcontainer config can override the guidebook
service mounts to reference the devcontainer's volume. The defaults
continue to use the local bind mount, so using the compose config without
additional configuration will work as before.
---
 Dockerfile          | 10 +++++++++-
 docker-compose.yaml |  5 ++---
 2 files changed, 11 insertions(+), 4 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 441d682e..2a1b1f47 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,5 @@
+# syntax=docker/dockerfile:1.4
+
 FROM plantuml/plantuml-server:jetty AS plantuml
 COPY compose/jetty-logging.properties /var/lib/jetty/resources/jetty-logging.properties
 ENV JAVA_OPTIONS=-Djava.util.logging.config.file=/var/lib/jetty/resources/jetty-logging.properties
@@ -11,10 +13,16 @@ WORKDIR /workspace/
 COPY requirements.txt .
 RUN pip install -r requirements.txt
 
+COPY --chmod=755 <<EOF /docker-entrypoint.sh
+#!/usr/bin/env sh
 # We use git to determine the edit history fo pages to provide a "last updated" timestamp. Work
 # around a git feature which guards against certain types of attacks from untrusted users.
 #
 # More information: https://www.kenmuse.com/blog/avoiding-dubious-ownership-in-dev-containers/
-RUN git config --global --add safe.directory /workspace
+git config --global --add safe.directory .
+
+exec "\$@"
+EOF
+ENTRYPOINT ["/docker-entrypoint.sh"]
 
 CMD ["mkdocs",  "serve", "-a", "0.0.0.0:8000"]
diff --git a/docker-compose.yaml b/docker-compose.yaml
index a7e26458..6dc90832 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -2,9 +2,8 @@ services:
   guidebook:
     build: .
     volumes:
-      - type: bind
-        source: .
-        target: /workspace
+      - ${WORKSPACE_SOURCE:-.}:${WORKSPACE_TARGET:-/workspace}
+    working_dir: ${WORKSPACE_ROOT:-/workspace}
     ports:
       - "8000:8000"
     depends_on:
-- 
GitLab


From cbfb1260efa34509bc67bd098b383a92bc2e60dd Mon Sep 17 00:00:00 2001
From: Hal Blackburn <hwtb2@cam.ac.uk>
Date: Thu, 27 Feb 2025 15:33:40 +0000
Subject: [PATCH 4/4] chore: add devcontainer configuration

This allows a dev environment for the the repository to be automatically
created by vscode (or other devcontainer-using tools). It works with
both local filesystem bind mount workspaces, and named container volumes.
---
 .devcontainer/.envrc                          |   5 +
 .devcontainer/.gitignore                      |   4 +
 .devcontainer/Dockerfile                      |   4 +
 .devcontainer/devcontainer.json               |  13 +
 .devcontainer/docker-compose.yaml             |   4 +
 .../gen-docker-compose-workspace-env.sh       | 293 ++++++++++++++++++
 .envrc                                        |   6 +
 .pre-commit-config.yaml                       |   2 +
 8 files changed, 331 insertions(+)
 create mode 100644 .devcontainer/.envrc
 create mode 100644 .devcontainer/.gitignore
 create mode 100644 .devcontainer/Dockerfile
 create mode 100644 .devcontainer/devcontainer.json
 create mode 100644 .devcontainer/docker-compose.yaml
 create mode 100755 .devcontainer/gen-docker-compose-workspace-env.sh
 create mode 100644 .envrc

diff --git a/.devcontainer/.envrc b/.devcontainer/.envrc
new file mode 100644
index 00000000..48fcfedc
--- /dev/null
+++ b/.devcontainer/.envrc
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+dotenv .env
+
+export COMPOSE_FILE=docker-compose.yaml:.devcontainer/docker-compose.yaml
diff --git a/.devcontainer/.gitignore b/.devcontainer/.gitignore
new file mode 100644
index 00000000..158c8980
--- /dev/null
+++ b/.devcontainer/.gitignore
@@ -0,0 +1,4 @@
+# These two env files are generated when the devcontainer starts via the
+# devcontainer.json#initializeCommand
+.env
+.env.d/50_workspace.env
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 00000000..0a014745
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,4 @@
+FROM mcr.microsoft.com/devcontainers/base:bookworm
+RUN apt-get update && apt-get install -y pipx direnv
+
+RUN echo 'eval "$(direnv hook bash)"' >> /etc/bash.bashrc
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..b5ec3f22
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,13 @@
+{
+  "name": "guidebook",
+  "build": {
+    "dockerfile": "Dockerfile"
+  },
+  "features": {
+    "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
+  },
+  // Generate .devcontainer/.env containing WORKSPACE_* envars for docker-compose.yml
+  "initializeCommand": ".devcontainer/gen-docker-compose-workspace-env.sh --container-workspace-folder '${containerWorkspaceFolder}' --local-workspace-folder '${localWorkspaceFolder}'",
+  "postCreateCommand": "pipx install pre-commit",
+  "postStartCommand": "pre-commit install"
+}
diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml
new file mode 100644
index 00000000..f284008e
--- /dev/null
+++ b/.devcontainer/docker-compose.yaml
@@ -0,0 +1,4 @@
+volumes:
+  devcontainer-volume:
+    name: ${WORKSPACE_CONTAINER_VOLUME_SOURCE:-not-used-in-bind-mount-workspace}
+    external: ${WORKSPACE_IS_CONTAINER_VOLUME:?}
diff --git a/.devcontainer/gen-docker-compose-workspace-env.sh b/.devcontainer/gen-docker-compose-workspace-env.sh
new file mode 100755
index 00000000..0d03b162
--- /dev/null
+++ b/.devcontainer/gen-docker-compose-workspace-env.sh
@@ -0,0 +1,293 @@
+#!/usr/bin/env bash
+# Copyright 2022 Hal Blackburn. MIT License.
+VERSION="gen-docker-compose-workspace-env.sh version 1.0.1
+
+https://github.com/h4l/dev-container-docker-compose-volume-or-bind/tree/v1.0.1
+
+Tested with Remote Containers plugin version 0.232.3 - 0.232.6.
+Future updates may break compatibility."
+
+set -eu -o pipefail
+if [[ ${DEVCONTAINER_DEBUG:-} == true ]]; then
+  set -x
+fi
+
+HELP="$(cat << "EOF"
+Generate envars for devcontainer Docker Compose files
+
+This program enables Docker Compose devcontainer services to be configured
+consistently whether the devcontainer is using a local filesystem bind mount,
+or a named container volume. It generates envars that can be referenced in the
+Compose file.
+
+Usage: gen-docker-compose-workspace-env.sh [options]
+
+Options:
+
+  --container-workspace-folder
+    The value of the ${containerWorkspaceFolder} devcontainer.json placeholder
+
+  --local-workspace-folder
+    The value of the ${localWorkspaceFolder} devcontainer.json placeholders
+
+  --user-id
+    The user ID to own the .env,.env.d,.env.d/*.env files if running as root.
+    Default: 1000
+
+  --group-id
+    The group ID to own the .env,.env.d,.env.d/*.env files if running as root.
+    Default: 1000
+
+  --env-dir
+    The path of the directory to create .env and .env.d in.
+    Default: . (the workspace root)
+
+  --no-write
+    Donʼt create .env,.env.d,.env.d/*.env (just print the envars)
+
+  --version
+    Show version info.
+
+  --help
+    Show this message.
+
+Environment Variables:
+
+The following envars are printed on stdout and written to a .env file:
+
+  WORKSPACE_CONTAINER_VOLUME_SOURCE
+    Only set for container volume workspaces. Typical value: vscode-projects
+  WORKSPACE_CONTAINER_VOLUME_TARGET
+    Only set for container volume workspaces. The mount point for the container
+    volume in the devcontainer. Typical value: /workspaces
+  WORKSPACE_BIND_MOUNT_SOURCE
+    Only set for bind mount workspaces. The path of the workspace code on the
+    host filesystem. Typical value: /home/name/projects/foo
+  WORKSPACE_BIND_MOUNT_TARGET
+    Only set for bind mount workspaces. The mount point for
+    WORKSPACE_BIND_MOUNT_SOURCE, set to the value of the devcontainer.json
+    "workspaceFolder" property. Typical value: /workspace
+  WORKSPACE_SOURCE
+    The source side of a "<source>:<target>" Compose file `volumes` entry which
+    provides access to the workspace content. For container volume workspaces,
+    this is the constant value "devcontainer-volume" (see the example compose
+    file below). For bind mount workspaces, this is the
+    WORKSPACE_BIND_MOUNT_SOURCE.
+  WORKSPACE_TARGET
+    The WORKSPACE_*_TARGET value for the current workspace type.
+  WORKSPACE_ROOT
+    The path of the workspace code in the devcontainer.
+    - For volume workspaces this is an absolute path inside
+      WORKSPACE_CONTAINER_VOLUME_TARGET.
+    - For bind mount workspaces, this is the same as
+      WORKSPACE_BIND_MOUNT_TARGET.
+
+The .env file is created in the dir specified by --env-dir (default: project
+root) by creating .env.d/50_workspace.env and then concattenating .env.d/*.env
+into .env. This allows the .env file to contain envars generated elsewhere.
+
+Dev Container Configuration:
+
+Use this script by calling it from "initializeCommand" in your devcontainer.json
+and including references to the envars it generates in your docker-compose.yaml:
+
+  # .devcontainer/devcontainer.json
+  {
+      "dockerComposeFile": "docker-compose.yml",
+      "service": "example-devcontainer",
+      "workspaceFolder": "/workspace",
+      // Generate .devcontainer/.env containing WORKSPACE_* envars for docker-compose.yml
+      "initializeCommand": ".devcontainer/gen-docker-compose-workspace-env.sh --container-workspace-folder '${containerWorkspaceFolder}' --local-workspace-folder '${localWorkspaceFolder}'",
+      // ...
+  }
+
+  # .devcontainer/docker-compose.yml
+  version: "3.9"
+  services:
+    # vscode will connect to this service as the dev container
+    devcontainer:
+      build:
+        context: .
+        dockerfile: Dockerfile
+      volumes:
+        - ${WORKSPACE_SOURCE:?}:${WORKSPACE_TARGET:?}
+      command: sleep infinity
+      depends_on:
+        - extra-service-container
+      user: vscode
+      env_file:
+        # if you want to access the WORKSPACE_* envars in the container
+        - .env
+
+    # another container with access to the workspace files
+    extra-service-container:
+      image: alpine
+      working_dir: ${WORKSPACE_ROOT:?}
+      volumes:
+        - ${WORKSPACE_SOURCE:?}:${WORKSPACE_TARGET:?}
+      command: sleep infinity
+
+  volumes:
+    devcontainer-volume:
+      name: ${WORKSPACE_CONTAINER_VOLUME_SOURCE:-not-used-in-bind-mount-workspace}
+      external: ${WORKSPACE_IS_CONTAINER_VOLUME:?}
+EOF
+)"
+
+while [[ $# -gt 0 ]]; do
+  case $1 in
+    --container-workspace-folder)
+      CONTAINER_WORKSPACE_FOLDER="$2"
+      shift; shift # shift past argument & value
+      ;;
+    --local-workspace-folder)
+      LOCAL_WORKSPACE_FOLDER="$2"
+      shift; shift
+      ;;
+    --user-id)
+      USERID="$2"
+      shift; shift
+      ;;
+    --group-id)
+      GROUPID="$2"
+      shift; shift
+      ;;
+    --env-dir)
+      ENV_FILE_DIR="$2"
+      shift; shift
+      ;;
+    --no-write)
+      NO_WRITE="true"
+      shift
+      ;;
+    --version)
+      echo "$VERSION"
+      exit 0
+      ;;
+    --help)
+      echo "$HELP"
+      exit 0
+      ;;
+    -*)
+      echo "$0: Unknown option $1" >&2
+      exit 1
+      ;;
+    *)
+      echo "$0: Unexpected positional argument: $1" >&2
+      exit 1
+      ;;
+  esac
+done
+
+ENV_FILE_DIR="${ENV_FILE_DIR:-.devcontainer}"
+NO_WRITE="${NO_WRITE:-false}"
+USERID="${USERID:-1000}"
+GROUPID="${GROUPID:-1000}"
+if [[ ${CONTAINER_WORKSPACE_FOLDER:-} == '' ]]; then
+  echo "$0: --container-workspace-folder option or CONTAINER_WORKSPACE_FOLDER envar must be set" >&2
+  exit 1
+fi
+CONTAINER_WORKSPACE_FOLDER="${CONTAINER_WORKSPACE_FOLDER:?}"
+if [[ ${LOCAL_WORKSPACE_FOLDER:-} == '' ]]; then
+  echo "$0: --local-workspace-folder option or LOCAL_WORKSPACE_FOLDER envar must be set" >&2
+  exit 1
+fi
+LOCAL_WORKSPACE_FOLDER="${LOCAL_WORKSPACE_FOLDER:?}"
+
+function workspace_is_in_container_volume() {
+    # If we're able to determine a container volume using workspace_volume_name
+    # then it should be safe to assume we are setting up a container volume
+    # workspace.
+    test "$(workspace_volume_name)" != ""
+}
+
+# Get the name of the Docker volume that contains the workspace code.
+#
+# (Only applicable when the workspace is in a container volume, not opened from
+# a local filesystem bind mount.) VSCode doesn't provide a way to access this
+# value, so we have to get it from daemon in a somewhat brittle manner.
+function workspace_volume_name() {
+    # This script is run from devcontainer.json#initializeCommand which is run
+    # in a bootstrap container by VSCode, prior to the actual devcontainer being
+    # started. This bootstrap container has the workspace's named container
+    # volume mounted at /workspaces
+    local CONTAINER_ID
+    CONTAINER_ID="$(hostname)"
+    # shellcheck disable=SC2016
+    local WORKSPACE_MOUNT_SOURCE_FMT='
+    {{- $source := "" }}
+    {{- range .HostConfig.Mounts }}
+      {{- if (and (eq .Type "volume") (eq .Target "/workspaces")) }}
+        {{- $source = .Source }}
+      {{- end }}
+    {{- end }}
+    {{- $source }}'
+    docker container inspect "$CONTAINER_ID" \
+        --format="$WORKSPACE_MOUNT_SOURCE_FMT" 2>/dev/null
+}
+
+if workspace_is_in_container_volume; then
+  # With container volume workspaces:
+  # - LOCAL_WORKSPACE_FOLDER is the path of the workspace dir in the mounted
+  #     container volume (e.g. /workspaces/myproject), and this same path is
+  #     also the path of the workspace in the devcontainer (container volume
+  #     workspaces ignore the value of "workspaceFolder" in devcontainer.json).
+  # - CONTAINER_WORKSPACE_FOLDER is the same as LOCAL_WORKSPACE_FOLDER
+  #     (e.g. /workspaces/myproject).
+
+  # The name of a container volume, typically vscode-projects
+  WORKSPACE_CONTAINER_VOLUME_SOURCE="$(workspace_volume_name)" || {
+    echo "Error: failed to determine workspace's container volume name" >&2;
+    exit 1;
+  }
+
+  WORKSPACE_CONTAINER_VOLUME_TARGET="$(dirname "${LOCAL_WORKSPACE_FOLDER:?}")"
+  # The 'devcontainer-volume' volume is defined in docker-compose.yml
+  # as an external volume using the value of WORKSPACE_CONTAINER_VOLUME_SOURCE.
+  WORKSPACE_SOURCE="devcontainer-volume"
+  WORKSPACE_TARGET="$WORKSPACE_CONTAINER_VOLUME_TARGET"
+  WORKSPACE_ROOT="${LOCAL_WORKSPACE_FOLDER:?}"
+  WORKSPACE_IS_CONTAINER_VOLUME=true
+  WORKSPACE_IS_BIND_MOUNT=false
+else
+  # With bind mount workspaces:
+  # - LOCAL_WORKSPACE_FOLDER is the path of the project's code on the host
+  #     (e.g. /home/me/projects/myproject).
+  # - CONTAINER_WORKSPACE_FOLDER is the value of "workspaceFolder" in
+  #     devcontainer.json (e.g. /workspace).
+
+  WORKSPACE_BIND_MOUNT_SOURCE="${LOCAL_WORKSPACE_FOLDER:?}"
+  WORKSPACE_BIND_MOUNT_TARGET="${CONTAINER_WORKSPACE_FOLDER:?}"
+  WORKSPACE_SOURCE="$WORKSPACE_BIND_MOUNT_SOURCE"
+  WORKSPACE_TARGET="$WORKSPACE_BIND_MOUNT_TARGET"
+  WORKSPACE_ROOT="$WORKSPACE_BIND_MOUNT_TARGET"
+  WORKSPACE_IS_CONTAINER_VOLUME=false
+  WORKSPACE_IS_BIND_MOUNT=true
+fi
+
+ENVARS="$(
+env -i WORKSPACE_CONTAINER_VOLUME_SOURCE="${WORKSPACE_CONTAINER_VOLUME_SOURCE:-}" \
+       WORKSPACE_CONTAINER_VOLUME_TARGET="${WORKSPACE_CONTAINER_VOLUME_TARGET:-}" \
+       WORKSPACE_BIND_MOUNT_SOURCE="${WORKSPACE_BIND_MOUNT_SOURCE:-}" \
+       WORKSPACE_BIND_MOUNT_TARGET="${WORKSPACE_BIND_MOUNT_TARGET:-}" \
+       WORKSPACE_SOURCE="${WORKSPACE_SOURCE:?}" \
+       WORKSPACE_TARGET="${WORKSPACE_TARGET:?}" \
+       WORKSPACE_ROOT="${WORKSPACE_ROOT:?}" \
+       WORKSPACE_IS_CONTAINER_VOLUME="${WORKSPACE_IS_CONTAINER_VOLUME:?}" \
+       WORKSPACE_IS_BIND_MOUNT="${WORKSPACE_IS_BIND_MOUNT:?}" \
+       env
+)"
+echo "$ENVARS"
+if [[ $NO_WRITE == false ]]; then
+  mkdir -p "${ENV_FILE_DIR:?}"/.env.d
+  {
+    echo "# Generated by $0 - do not modify by hand";
+    echo "$ENVARS";
+  } > "${ENV_FILE_DIR:?}"/.env.d/50_workspace.env
+  cat "${ENV_FILE_DIR:?}"/.env.d/*.env > "${ENV_FILE_DIR:?}"/.env
+
+  # We are run as root for container volume workspaces
+  if [[ "$(id -u)" -eq 0 ]]; then
+      chown -R "${USERID:?}:${GROUPID:?}" "${ENV_FILE_DIR:?}"/{.env,.env.d}
+  fi
+fi
diff --git a/.envrc b/.envrc
new file mode 100644
index 00000000..09393eb7
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+# Load devcontainer-specific config when using a vscode devcontainer
+if [[ ${REMOTE_CONTAINERS:-false} == true ]]; then
+    source_env .devcontainer
+fi
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f9248cc6..54a2d1e6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,6 +9,7 @@ repos:
         args:
           - --unsafe
       - id: check-json
+        exclude: ^\.devcontainer/devcontainer\.json$
       - id: check-toml
       - id: check-xml
       - id: check-added-large-files
@@ -19,6 +20,7 @@ repos:
         exclude: ^docs/contact/openpgp\.min\.js$
       - id: mixed-line-ending
       - id: pretty-format-json
+        exclude: ^\.devcontainer/devcontainer\.json$
         args:
           - --autofix
           - --no-sort-keys
-- 
GitLab