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
14 changes: 13 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
{
"name": "devcontainer-sync",
"image": "ghcr.io/ansible/community-ansible-dev-tools:latest"
"image": "mcr.microsoft.com/devcontainers/python:3.14",
"customizations": {
"vscode": {
"extensions": [
"openai.chatgpt",
"redhat.vscode-yaml"
]
}
},
"mounts": [
"source=codex-config,target=/opt/.codex,type=volume"
],
"postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex && bash .devcontainer/install.sh"
}
3 changes: 3 additions & 0 deletions .devcontainer/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
sudo apt update && sudo apt install ansible -y
curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh
19 changes: 19 additions & 0 deletions .dotfiles/.gitconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[init]
defaultbranch = main
[core]
excludesfile = ~/.gitignore_global
editor = code
[user]
name = S Smith
email = root@mkdir.foo
signingkey = 355F8D8492DAE97AB6B6A22715CC6741E34AB2DD
[commit]
gpgsign = true
[gpg]
program = gpg
[credential]
helper = cache
[url "git@github.com:"]
insteadOf = https://github.com/
[safe]
directory = "/workspace"
1 change: 1 addition & 0 deletions .dotfiles/.gitignore_global
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.devcontainer-bak-*
5 changes: 5 additions & 0 deletions .dotfiles/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
ln -sf "$(pwd)/.gitconfig" "$HOME/.gitconfig"
ln -sf "$(pwd)/.gitignore_global" "$HOME/.gitignore_global"

echo "Dotfiles successfully installed!"
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ANSIBLE_PLAYBOOK ?= ansible-playbook
SHELLCHECK ?= shellcheck
PLAYBOOK ?= playbook.yml
WORKSPACE_ROOT ?=
EXAMPLE_VARS ?= group_vars/all.example.yml
Expand All @@ -8,16 +9,17 @@ ifdef WORKSPACE_ROOT
ANSIBLE_ENV := WORKSPACE_ROOT=$(WORKSPACE_ROOT)
endif

.PHONY: help check apply create-missing syntax test rm-bak
.PHONY: help check apply create-missing syntax shellcheck test rm-bak

help:
@printf "Targets:\n"
@printf " make check Preview changes with --check --diff\n"
@printf " make apply Apply rendered devcontainer files\n"
@printf " make create-missing Create missing .devcontainer directories/files\n"
@printf " make syntax Run Ansible syntax check\n"
@printf " make rm-bak Find all .devcontainer-bak-* subdirectories and rm -rf them\n"
@printf " make test Render group_vars/all.example.yml into tests/\n"
@printf " make shellcheck Lint generated example shell scripts\n"
@printf " make rm-bak Remove .devcontainer-bak-* directories\n"
@printf " make test Render and validate group_vars/all.example.yml\n"
@printf "\nOverride workspace root with WORKSPACE_ROOT=/path/to/workspaces.\n"

check:
Expand All @@ -32,12 +34,17 @@ create-missing:
syntax:
$(ANSIBLE_ENV) $(ANSIBLE_PLAYBOOK) --syntax-check $(PLAYBOOK)

shellcheck:
command -v $(SHELLCHECK) >/dev/null || { printf '%s\n' "shellcheck is required" >&2; exit 1; }
find $(TEST_WORKSPACE_ROOT) -type f -name '*.sh' -print0 | sort -z | xargs -0r $(SHELLCHECK)

rm-bak:
ifndef WORKSPACE_ROOT
$(error WORKSPACE_ROOT is not defined)
endif
find ${WORKSPACE_ROOT} -type d -name ".devcontainer-bak-*" -exec rm -rf {} +

test:
$(ANSIBLE_PLAYBOOK) --diff $(PLAYBOOK) -e @$(EXAMPLE_VARS) -e workspace_root=$(TEST_WORKSPACE_ROOT) -e devcontainer_sync_create_missing=true -e devcontainer_sync_backup=false -e devcontainer_sync_backup_existing_dir=false
$(ANSIBLE_PLAYBOOK) --diff $(PLAYBOOK) -e @$(EXAMPLE_VARS) -e workspace_root=$(TEST_WORKSPACE_ROOT) -e devcontainer_sync_create_missing=true -e devcontainer_auto_rebuild=false -e devcontainer_sync_backup=false -e devcontainer_sync_backup_existing_dir=false
find $(TEST_WORKSPACE_ROOT) -type f ! -path $(TEST_WORKSPACE_ROOT)/golden_output -print0 | sort -z | xargs -0r cat > $(TEST_WORKSPACE_ROOT)/golden_output
$(MAKE) shellcheck
248 changes: 203 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,95 +1,253 @@
# Devcontainer Sync

I have a _bunch_ of devcontainers. This is an Ansible playbook to keep them mostly in sync.
An Ansible role for generating consistent `.devcontainer` directories across multiple projects.

## Usage
## Requirements

Install Ansible, then run these commands from the cloned folder so `ansible.cfg`, `inventory.yml`, and the local `roles/` directory are picked up.
- Ansible Core
- ShellCheck (used by `make test`)
- Projects stored beneath one workspace root
- Run commands from this repository so `ansible.cfg`, `inventory.yml`, and the local role are loaded

### 1. Set `WORKSPACE_ROOT` when the projects are not under `~/dev/`:
`group_vars/all.yml` contains the local project inventory and is intentionally ignored by Git. Use `group_vars/all.example.yml` as the tracked example.

```bash
export WORKSPACE_ROOT=/path/to/parent/directory/
## Configuration

Each entry requires a unique `name`:

```yaml
devcontainers:
- name: api
primary_language: python

- name: nested-site
path: monorepo/sites/nested
primary_language: python
plugins:
- django
```

`name` identifies the configuration and its source directory under `files/`. The target project root is `path` when provided, otherwise `name`.

Both values must be relative paths beneath `workspace_root`; absolute paths and `..` traversal are rejected.

### Plugins

`primary_language` does two things when matching entries exist:

1. Selects a default image from `devcontainer_primary_language_images`.
2. Selects the plugin with the same name from `devcontainer_plugins`.

`plugins` adds more plugin fragments. The role defaults currently select `codex` and `claude` for every container.

```yaml
- name: infrastructure
primary_language: terraform
plugins:
- aws
- python
```

Use `primary_language: python` with `plugins: [django]` for Django projects. Django is a concern layered onto Python, not an image language.

### Baseline Editor Support

Every generated container includes these baseline extensions:

- `EditorConfig.EditorConfig`
- `redhat.vscode-yaml`
- `timonwong.shellcheck`
- `usernamehw.errorlens`
- `github.vscode-github-actions`

The role also writes a conservative `.editorconfig` at each project root. It standardizes UTF-8, LF endings, final newlines, and trailing whitespace without imposing indentation or a formatter. Prettier is intentionally not installed or enabled globally; formatter ownership remains language- and project-specific.

### Persistent Caches

Caches are opt-in plugins. Shared download caches persist across projects, while build output and shell history use project-scoped volume names.

| Plugin | Persistent data |
| --- | --- |
| `cache_cargo` | Cargo registry, Git checkouts, and project `target/` |
| `cache_node` | npm cache and pnpm store |
| `cache_python` | pip and uv caches |
| `cache_terraform` | Terraform provider plugin cache |
| `cache_ansible` | Ansible Galaxy collections |
| `shell_history` | Project-specific Bash history via `HISTFILE` |

Example:

```yaml
- name: api
primary_language: python
plugins:
- cache_python
- cache_ansible
- shell_history
```

Cache plugins create and assign ownership of their mounted directories during `postCreateCommand`, before `install.sh` runs.

### AWS SSO

Selecting the `aws` plugin requires:

```yaml
devcontainer_aws_sso:
start_url: https://example.awsapps.com/start/#
account_id: "123456789012"
role_name: AdministratorAccess
session_name: example-sso # optional; default: default-sso
region: us-east-1 # optional; default: us-east-1
```

### 2. Create targets and defaults in `group_vars/all.yml`, then preview the changes:
The role generates `.devcontainer/aws_configure.sh` and always runs it from `install.sh`. The script writes the AWS SSO profile and installs an interactive Bash wrapper for `aws`.

The wrapper does not log in at shell startup. On an authenticated AWS command, it checks the current identity, runs `aws sso login` only when credentials are unavailable or expired, then runs the requested command. Explicit `aws sso login`, configuration, help, and version commands bypass the check.

This hook applies to interactive Bash shells. Noninteractive automation should establish credentials explicitly rather than relying on an interactive SSO prompt.

### Supported Values

Mapping values are recursively merged:

- `container_env`
- `remote_env`
- `features`
- `vscode_settings`

List values are merged with Ansible's `append_rp` behavior, preserving order while replacing duplicates:

- `plugins`
- `vscode_extensions`
- `mounts`
- `run_args`
- `post_start_commands`
- `post_create_commands`
- `post_attach_commands`
- `initialize_commands`
- `install_steps`

Scalar values such as `image`, `name`, `path`, `python_version`, and `update_remote_user_uid` are replaced by the container entry.

The effective merge order is:

1. `devcontainer_defaults`
2. The `primary_language` plugin, when one exists
3. Default plugins
4. Explicit plugins
5. The individual devcontainer entry

Unknown plugins and incorrectly typed values fail with an assertion before files are rendered.

### Lifecycle And Installation

Lifecycle lists are joined with `&&`, so execution stops on the first failure. Empty lifecycle commands are omitted from `devcontainer.json`.

Dependency installation belongs in `install_steps`, which runs from `postCreateCommand`. Keep `postAttachCommand` fast and reserve `post_start_commands` for lightweight, idempotent work needed after every container start.

The role always appends this to `postCreateCommand`:

```bash
make check
bash "${containerWorkspaceFolder}/.devcontainer/install.sh"
```

or: `ansible-playbook --check --diff playbook.yml`
`install.sh` derives and exports `containerWorkspaceFolder`, contains merged `install_steps`, and runs with `set -euo pipefail`. Generated shell scripts are checked by ShellCheck during `make test`. Do not repeat language-plugin setup in individual entries unless the project needs additional behavior.

#### Note on Merge Behavior
`devcontainer.json` is serialized directly by the task; there is no pass-through JSON template. Static `.editorconfig` content is copied from the role files directory.

- dict/map keys are recursively merged with `devcontainer_defaults`. This includes `features`, `container_env`, `remote_env`, `vscode_settings`.
## Usage

- list keys are appended with duplicate replacement/removal. This includes `vscode_extensions`, `mounts`, `run_args`, `forwardPorts`.
The default workspace root is `~/dev/`. Override it with an environment variable:

- Scalar/string/bool keys are replaced by the per-container value, if it exists. This includes `image`, `post_start_command`, `install`, `name`...
```bash
export WORKSPACE_ROOT=/path/to/projects
```

- _Except!_ The `install` script templates into `roles/devcontainer_sync/templates/install.sh.j2`. As an example (and because it's _my_ preferred default), this template installs common AI tools into every devcontainer. So, if you were wondering why building the devcontainer gave you a copy of Claude and Codex, that's why. You can change the template to suit your preferences.
Preview changes:

### 3. When the diff looks right, apply it with:
```bash
make check
```

Apply changes:

```bash
make apply
```

or: `ansible-playbook --diff playbook.yml`
By default, a changed `.devcontainer` directory is rebuilt after all managed and extra files are written. The role hashes relative file paths and contents before and after synchronization, then runs:

### 4. By default, missing `devcontainer.json` targets are skipped. To create missing `.devcontainer` directories and files, run:
```bash
devcontainer up --workspace-folder /path/to/project --remove-existing-container
```

Set `devcontainer_auto_rebuild: false` to render changes without rebuilding. Check mode never runs the rebuild command.

Missing targets are skipped by default. Create them with:

```bash
make create-missing
```

or: `ansible-playbook --diff playbook.yml -e devcontainer_sync_create_missing=true`
Run syntax validation:

```bash
make syntax
```

Lint generated shell scripts:

```bash
make shellcheck
```

### 5. Render the example configuration into `tests/`:
Render `group_vars/all.example.yml` into `tests/`:

```bash
make test
```

## Extra project files
## Backups

Additional per-project files that should live in `.devcontainer` can be placed under `files/<project path>/`. The project path is the target path from `group_vars/all.yml` without `.devcontainer/devcontainer.json`.
When `devcontainer_sync_backup_existing_dir` is enabled, a changed role-managed `.devcontainer` file causes the existing `.devcontainer` directory to move to `.devcontainer-bak-<timestamp>` before rendering. Role-managed files are:

For example, `files/cluster/ensure-mount-sources` is copied to `cluster/.devcontainer/ensure-mount-sources`.
- `devcontainer.json`
- `install.sh`
- `aws_configure.sh` when the AWS plugin is selected

Use nested directories in `files` to target devcontainers inside devcontainers. For example, `files/clients/sites/top-automotive/scripts/aws_configure.sh` is copied to `clients/sites/top-automotive/.devcontainer/scripts/aws_configure.sh`, even if `clients/.devcontainer/devcontainer.json` exists (and is targeted by the playbook).
Changes only to `.editorconfig` or files from `files/` do not trigger a whole-directory backup. `.editorconfig` and extra files use Ansible's individual file backup behavior when `devcontainer_sync_backup` is enabled.

Use `make rm-bak WORKSPACE_ROOT=/path/to/projects` to remove timestamped directory backups.

## Extra Project Files

Place additional files under `files/<name>/`. The source key is always the devcontainer `name`; `path` only changes the destination project root.

```text
files/
└── nested-site/
├── id_rsa
└── scripts/
└── setup.sh
```
files
└── clients
├── aws_config.sh <-- clients/.devcontainer/aws_config.sh
├── reload-all.sh <-- clients/.devcontainer/reload-all.sh
└── sites
├── acme-construction
│ └── id_rsa <-- clients/sites/acme-construction/.devcontainer/id_rsa
├── flywheel
│ └── aws_config.sh <-- clients/sites/flywheel/.devcontainer/aws_config.sh
└── top-automotive
└── scripts
└── cleanup.sh <-- clients/sites/top-automotive/.devcontainer/scripts/cleanup.sh

With the earlier `nested-site` example, these become:

```text
monorepo/sites/nested/.devcontainer/id_rsa
monorepo/sites/nested/.devcontainer/scripts/setup.sh
```

`devcontainer.json` and `install.sh` stay template-managed and are ignored from the extra files tree.
Role-managed files cannot be overridden through `files/`. Extra-file copy output uses `no_log` so secrets are not printed in Ansible diffs.

## Contributing

### 1. Render the example configuration
Run these before committing:

```bash
make syntax
make test
```

This may or may not make changes to the `tests/` directory. If changes are present, you should commit those changes as well.

### 2. Submit a Pull Request

Any pull requests generated autonomously or by agents should include:

- A description of the changes
- The name and version of the model(s) used
Commit generated fixture changes under `tests/` when behavior changes.
Loading