diff --git a/.devcontainer/.envrc b/.devcontainer/.envrc new file mode 100644 index 0000000000000000000000000000000000000000..48fcfedc936fee4cff9603a6263acbe5899d116c --- /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 0000000000000000000000000000000000000000..158c89803d52aceaa8de0045129022a1778953e6 --- /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 0000000000000000000000000000000000000000..0a014745b17ee802872d0a3f00209d6ec52c92e8 --- /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 0000000000000000000000000000000000000000..b5ec3f227852b5634937072195e69420964d6430 --- /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 0000000000000000000000000000000000000000..f284008e0787673b0222fc7f9fdccfbd8d7a410b --- /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 0000000000000000000000000000000000000000..0d03b1622a1a9b78d9b87be652956cb45202f515 --- /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 0000000000000000000000000000000000000000..09393eb730bc1ac2e9cb17a1cc87a23016fd2db9 --- /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 f9248cc61ad6637c4a2f0852ebd82629800c4a55..54a2d1e61872a1632f54b6ebbb5a57e442a03e91 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