Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
146 changes: 146 additions & 0 deletions .devcontainer/files/workspace/ws
Original file line number Diff line number Diff line change
@@ -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 <git-url|directory> [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 <name>"
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 <git-url>'"
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 <git-url> [name] Clone a repository and add it as a root
ws add <directory> [name] Add a repository already on the volume
ws rm <name> 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
95 changes: 94 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <git-url> [name]` | Clone a repository into `/workspace/<name>` and add it as a root |
| `ws add <name>` | Add a repository already sitting on the volume |
| `ws rm <name>` | 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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -179,6 +255,7 @@ The devcontainer uses Docker volumes for persistent storage:
- **Workspace Volume**: `dev-workspace-<user>` mounted at `/workspace`
- **Home Volume**: `dev-home-<user>` 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions tests/validate-tools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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++))
Expand Down
Loading