From a0825dee6fa98b441045a66efe5a4d17560550f9 Mon Sep 17 00:00:00 2001 From: Daniel Grimes Date: Sun, 9 Aug 2026 14:52:01 +0000 Subject: [PATCH] feat(workspace): multi-root support via a ws command The container already mounted /workspace as a named volume rather than a bind mount of a single folder, which makes every repository cloned under it a sub-folder of the workspace file - the layout VS Code requires for a multi-root workspace. What was missing was the workspace file itself and a way to manage it. `ws` clones repositories into /workspace and maintains the folders array in /workspace/devops.code-workspace. postCreateCommand runs `ws init` so a fresh container comes up multi-root-capable with no setup. Also rewrites the README opening around the multi-root capability, which is the thing that distinguishes this image from a stock devcontainer. --- .devcontainer/Dockerfile | 7 ++ .devcontainer/devcontainer.json | 2 +- .devcontainer/files/workspace/ws | 146 +++++++++++++++++++++++++++++++ CHANGELOG.md | 4 + README.md | 95 +++++++++++++++++++- tests/validate-tools.sh | 5 ++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100755 .devcontainer/files/workspace/ws diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2b25652..6ed2863 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -156,6 +156,13 @@ COPY ./files/codex/codex-init /usr/local/bin/codex-init RUN chmod +x /usr/local/bin/codex-init && \ chmod 0644 /usr/local/share/codex/config.toml.tmpl +# Multi-root workspace helper: `ws` manages the roots of +# /workspace/devops.code-workspace so several repositories are open in one +# window. /workspace is a named volume, which makes every repository under it a +# sub-folder of the workspace file - the layout VS Code requires. +COPY ./files/workspace/ws /usr/local/bin/ws +RUN chmod +x /usr/local/bin/ws + # Set zsh as default shell and prepare home directory template RUN chsh -s /bin/zsh ${USERNAME} && \ cp -r /home/vscode/. /tmp-home diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 022d528..92c9fa5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -37,7 +37,7 @@ ], "workspaceMount": "source=dev-workspace-${localEnv:USER},target=/workspace,type=volume", "workspaceFolder": "/workspace", - "postCreateCommand": "bash -c 'echo \"127.0.0.1 $(hostname)\" | sudo tee -a /etc/hosts > /dev/null' && sudo chown -R vscode:vscode /workspace && sudo chown -R vscode:vscode /home/vscode || true", + "postCreateCommand": "bash -c 'echo \"127.0.0.1 $(hostname)\" | sudo tee -a /etc/hosts > /dev/null' && sudo chown -R vscode:vscode /workspace && sudo chown -R vscode:vscode /home/vscode && ws init || true", "postStartCommand": "sudo /usr/local/bin/entrypoint.sh", "containerEnv": { "NODE_OPTIONS": "--max-old-space-size=4096", diff --git a/.devcontainer/files/workspace/ws b/.devcontainer/files/workspace/ws new file mode 100755 index 0000000..1555f5f --- /dev/null +++ b/.devcontainer/files/workspace/ws @@ -0,0 +1,146 @@ +#!/bin/bash +# ws - manage the roots of the multi-root VS Code workspace. +# +# The container mounts /workspace as a named volume that outlives rebuilds, so +# every repository cloned under it is a sub-folder of the workspace file's own +# directory. That is the layout VS Code requires: a multi-root workspace may +# only reference relative paths to sub-folders of the folder holding the +# .code-workspace file. Parent-relative paths (../other-repo) do not open. +set -euo pipefail + +WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" +WORKSPACE_FILE="${WORKSPACE_FILE:-${WORKSPACE_ROOT}/devops.code-workspace}" + +die() { + echo "ws: $*" >&2 + exit 1 +} + +# Write JSON to the workspace file via a temp file so an interrupted run cannot +# leave a half-written workspace behind. +write_workspace() { + local tmp + tmp="$(mktemp "${WORKSPACE_FILE}.XXXXXX")" + cat > "${tmp}" + mv "${tmp}" "${WORKSPACE_FILE}" +} + +ensure_workspace() { + [ -f "${WORKSPACE_FILE}" ] || die "no workspace file at ${WORKSPACE_FILE} - run 'ws init' first" +} + +# Folder settings live here rather than in devcontainer.json because +# devcontainer.json settings apply to the whole window. Anything that should +# differ between a Terraform root and a .NET root has to be per-folder. +cmd_init() { + if [ -f "${WORKSPACE_FILE}" ] && [ "${1:-}" != "--force" ]; then + echo "ws: ${WORKSPACE_FILE} already exists (use 'ws init --force' to recreate)" + return 0 + fi + + mkdir -p "${WORKSPACE_ROOT}" + + local folders='[]' + # Seed with whatever is already on the volume, so init after the fact picks + # up repositories cloned by hand rather than starting empty. + local dir name + for dir in "${WORKSPACE_ROOT}"/*/; do + [ -d "${dir}" ] || continue + name="$(basename "${dir}")" + folders="$(jq --arg n "${name}" '. + [{name: $n, path: $n}]' <<< "${folders}")" + done + + jq -n --argjson folders "${folders}" '{ + folders: $folders, + settings: { + "files.exclude": {"**/.git": true}, + "search.exclude": {"**/.terraform": true, "**/node_modules": true} + } + }' | write_workspace + + echo "ws: wrote ${WORKSPACE_FILE} with $(jq '.folders | length' "${WORKSPACE_FILE}") root(s)" +} + +cmd_add() { + local source="${1:-}" + [ -n "${source}" ] || die "usage: ws add [name]" + ensure_workspace + + local name="${2:-}" + local target + + if [[ "${source}" =~ ^(https?://|git@|ssh://|file://) ]]; then + [ -n "${name}" ] || name="$(basename "${source}" .git)" + target="${WORKSPACE_ROOT}/${name}" + if [ -d "${target}" ]; then + echo "ws: ${target} already exists, not re-cloning" + else + echo "ws: cloning ${source} into ${target}" + git clone "${source}" "${target}" + fi + else + # An existing directory - accept either a bare name or a path, but it + # has to sit directly under the workspace root to be a legal root. + name="${name:-$(basename "${source}")}" + target="${WORKSPACE_ROOT}/${name}" + [ -d "${target}" ] || die "${target} does not exist - clone it under ${WORKSPACE_ROOT} first" + fi + + if jq -e --arg n "${name}" '.folders[] | select(.path == $n)' "${WORKSPACE_FILE}" > /dev/null 2>&1; then + echo "ws: '${name}' is already a root" + return 0 + fi + + jq --arg n "${name}" '.folders += [{name: $n, path: $n}]' "${WORKSPACE_FILE}" | write_workspace + echo "ws: added '${name}' - reload the workspace to pick it up" +} + +cmd_rm() { + local name="${1:-}" + [ -n "${name}" ] || die "usage: ws rm " + ensure_workspace + + jq -e --arg n "${name}" '.folders[] | select(.path == $n)' "${WORKSPACE_FILE}" > /dev/null 2>&1 \ + || die "'${name}' is not a root of this workspace" + + jq --arg n "${name}" '.folders |= map(select(.path != $n))' "${WORKSPACE_FILE}" | write_workspace + echo "ws: removed '${name}' from the workspace (the clone at ${WORKSPACE_ROOT}/${name} is untouched)" +} + +cmd_list() { + ensure_workspace + local count + count="$(jq '.folders | length' "${WORKSPACE_FILE}")" + if [ "${count}" -eq 0 ]; then + echo "ws: no roots yet - add one with 'ws add '" + return 0 + fi + echo "Roots in ${WORKSPACE_FILE}:" + jq -r '.folders[] | " \(.name)\t\(.path)"' "${WORKSPACE_FILE}" +} + +usage() { + cat <<'EOF' +ws - manage the roots of the multi-root VS Code workspace + +Usage: + ws init [--force] Create the workspace file, seeding it with any + repositories already on the /workspace volume + ws add [name] Clone a repository and add it as a root + ws add [name] Add a repository already on the volume + ws rm Remove a root (the clone stays on disk) + ws list Show the current roots + +Open the result with File > Open Workspace and pick devops.code-workspace, or +run "Dev Containers: Open Workspace in Container" from the host. +EOF +} + +case "${1:-}" in + init) shift; cmd_init "$@" ;; + add) shift; cmd_add "$@" ;; + rm) shift; cmd_rm "$@" ;; + list|ls) shift; cmd_list "$@" ;; + ""|-h|--help|help) usage ;; + *) die "unknown command '${1}' (try 'ws --help')" ;; +esac diff --git a/CHANGELOG.md b/CHANGELOG.md index b2048c3..5b23497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ contained on a given date; it cannot promise a compatibility contract. ### Added +- Multi-root workspace support: a `ws` command that clones repositories into the + persistent `/workspace` volume and manages the roots of + `/workspace/devops.code-workspace`, so several repositories open in one + container. Created automatically by `postCreateCommand` - Complete devcontainer configuration for DevOps workflows - Dockerfile with multi-tool installation - Installation scripts with isolated /tmp directories for: diff --git a/README.md b/README.md index 3cb009a..c643606 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,28 @@ # DevOps Development Container -A comprehensive development container for DevOps and Infrastructure-as-Code workflows, built on Ubuntu 24.04 with essential tools for cloud infrastructure management, container orchestration, and automation. +A pre-built **VS Code dev container for DevOps and Infrastructure-as-Code work**: +Terraform, Terragrunt, Azure CLI, Ansible, Kubernetes, Helm, PowerShell and .NET +on Ubuntu 24.04. Published multi-architecture to the GitHub Container Registry +with an SBOM and SLSA build provenance, so there is nothing to build before you +start. + +It is also a **multi-root devcontainer**: one container, several Git +repositories open in a single VS Code window. A DevOps change is rarely confined +to one repository - the Terraform module, the environment that consumes it, the +Ansible role and the pipeline that ships it tend to move together. Instead of +four windows running four containers, you get one Source Control panel listing +every repository's pending changes, one search across all of them, and one +toolchain to rebuild. See [Multi-root workspaces](#️-multi-root-one-container-every-repository). + +```bash +docker pull ghcr.io/dbhq-uk/devcontainer-devops:latest +``` ## πŸš€ Features This devcontainer includes pre-configured tools for: +- **Multi-root workspaces**: the `ws` command clones repositories into a persistent volume and opens them all in one container - **Infrastructure as Code**: Terraform, Terragrunt, tflint, tf-summarize, checkov - **Cloud Management**: Azure CLI (az), AzCopy - **Container Operations**: Docker Engine, Helm, kubectl, kubelogin @@ -17,10 +34,67 @@ This devcontainer includes pre-configured tools for: - **Development Utilities**: Custom bash/zsh aliases, shell completions, pre-commit - **Data Processing**: jq, yq +## πŸ—‚οΈ Multi-root: one container, every repository + +Most dev containers assume one repository per container. This one does not. + +`/workspace` is a **named Docker volume** rather than a bind mount of a single +folder, so every repository cloned under it persists across rebuilds *and* sits +as a sub-folder of the workspace file. That second part is what makes multi-root +possible: VS Code will only open a multi-root workspace in a container when the +workspace "references relative paths to sub-folders of the folder the +`.code-workspace` file is in (or the folder itself)". Parent-relative paths such +as `../other-repo` will not open, which is why the usual "sibling folders on the +host" layout fails. + +### The `ws` command + +| Command | What it does | +|---------|--------------| +| `ws init` | Create `/workspace/devops.code-workspace`, seeded with any repositories already on the volume. Runs automatically when the container is created | +| `ws add [name]` | Clone a repository into `/workspace/` and add it as a root | +| `ws add ` | Add a repository already sitting on the volume | +| `ws rm ` | Drop a root from the workspace. The clone stays on disk | +| `ws list` | Show the current roots | + +```bash +ws add https://github.com/acme/platform-terraform.git +ws add https://github.com/acme/platform-ansible.git +ws add git@github.com:acme/platform-pipelines.git +ws list +``` + +### Opening it + +The workspace file lives on the volume, inside the container, so open it from a +container window rather than from the host: + +- **File > Open Workspace from File…** and pick `/workspace/devops.code-workspace`, or +- run **Dev Containers: Open Workspace in Container** from the host if you keep a + copy of the workspace file alongside your `.devcontainer` + +VS Code reloads into the multi-root view. Adding a root later needs a reload to +show up. + +### Where settings go + +Settings in `devcontainer.json` apply to the whole window. Anything that should +differ per repository - a two-space tab in the YAML repo, four in the .NET one - +belongs in the `folders` entries of the workspace file instead, which `ws` +leaves alone for you to edit. + +### The limitation worth knowing + +Every root shares the one container. VS Code cannot run a container per folder +in a single window, and that remains an open feature request upstream. This +suits a team standardised on one toolchain, which is the normal DevOps case. It +does not suit polyglot repositories that each need a different runtime version. + ## πŸ“‹ Included Tools | Tool | Purpose | |------|---------| +| `ws` | Manage the roots of the multi-root VS Code workspace | | Terraform | Infrastructure provisioning | | Terragrunt | Terraform wrapper for DRY configurations | | tflint | Terraform linting | @@ -104,6 +178,8 @@ devcontainer-devops/ β”‚ β”‚ β”œβ”€β”€ .zshrc # ZSH configuration β”‚ β”‚ β”œβ”€β”€ .claude/ # Claude Code defaults β”‚ β”‚ └── .config/ # PowerShell profile and theme +β”‚ β”œβ”€β”€ workspace/ # Multi-root workspace tooling +β”‚ β”‚ └── ws # Manages roots in devops.code-workspace β”‚ └── entrypoint.sh # Container entrypoint for home dir init β”œβ”€β”€ tests/ β”‚ β”œβ”€β”€ integration-test.sh # Integration tests @@ -179,6 +255,7 @@ The devcontainer uses Docker volumes for persistent storage: - **Workspace Volume**: `dev-workspace-` mounted at `/workspace` - **Home Volume**: `dev-home-` mounted at `/home/vscode` - **Bind Mount**: The local workspace folder mounted at `/workspace/devcontainer` +- **Workspace File**: `/workspace/devops.code-workspace`, created by `ws init` - **Permissions**: Automatically configured via `postCreateCommand` - **Home Init**: Entrypoint script copies default configs on first run @@ -332,6 +409,14 @@ builds from the local `Dockerfile` by default β€” to pin, add the versions to it ## πŸ“ Usage Examples +### Multi-root workspace + +```bash +ws add https://github.com/acme/platform-terraform.git +ws list +ws rm platform-terraform +``` + ### Terraform ```bash @@ -406,6 +491,14 @@ MIT - see [`LICENSE`](LICENSE). - Confirm it appears in `tests/validate-tools.sh`, then run that script - Rebuild the container +### A repository is missing from the multi-root workspace + +- `ws list` shows the roots actually recorded in `/workspace/devops.code-workspace` +- Adding a root needs a window reload before VS Code shows it +- A repository has to sit **directly** under `/workspace` to be a legal root - + nested paths and `../` paths will not open +- If the workspace file was never created, run `ws init` + ### A home-directory tool is missing or stale after a rebuild `/home/vscode` is a persistent per-user volume, seeded from the image only on diff --git a/tests/validate-tools.sh b/tests/validate-tools.sh index b9865f2..6fb4d1d 100755 --- a/tests/validate-tools.sh +++ b/tests/validate-tools.sh @@ -84,6 +84,11 @@ validate_tool "claude" "claude --version" || ((FAILURES++)) validate_tool "cswap" "cswap --version" || ((FAILURES++)) echo "" +# Workspace Tools +echo "Workspace Tools:" +validate_tool "ws" "ws --help" || ((FAILURES++)) +echo "" + # Security Tools echo "Security Tools:" validate_tool "git-crypt" "git-crypt --version" || ((FAILURES++))