From 156450545c7f0dc4c3617814423ecd178b4e784b Mon Sep 17 00:00:00 2001 From: S Smith Date: Thu, 11 Jun 2026 18:42:42 -0500 Subject: [PATCH] major refactor. clean up code. abstract out plugins. --- .devcontainer/devcontainer.json | 14 +- .devcontainer/install.sh | 3 + .dotfiles/.gitconfig | 19 + .dotfiles/.gitignore_global | 1 + .dotfiles/install.sh | 5 + Makefile | 15 +- README.md | 248 +++++++++-- files/README.md | 37 +- group_vars/all.example.yml | 122 ++---- roles/devcontainer_sync/defaults/main.yml | 252 +++++++++++ roles/devcontainer_sync/files/editorconfig | 11 + roles/devcontainer_sync/tasks/extra_files.yml | 61 ++- roles/devcontainer_sync/tasks/main.yml | 165 ++------ .../tasks/sync_devcontainer.yml | 367 ++++++++++++++++ .../templates/aws_configure.sh.j2 | 59 +++ .../templates/devcontainer.json.j2 | 1 - .../devcontainer_sync/templates/install.sh.j2 | 17 +- .../.devcontainer/aws_configure.sh | 59 +++ .../.devcontainer/devcontainer.json | 48 +++ tests/cloud-example/.devcontainer/install.sh | 22 + tests/cloud-example/.editorconfig | 11 + tests/golden_output | 396 ++++++++++++++++-- .../.devcontainer/devcontainer.json | 27 -- tests/java-example/.devcontainer/install.sh | 12 - .../.devcontainer/devcontainer.json | 31 ++ tests/node-example/.devcontainer/install.sh | 16 + tests/node-example/.editorconfig | 11 + .../.devcontainer/aws_configure.sh | 59 +++ .../.devcontainer/devcontainer.json | 35 +- tests/python-example/.devcontainer/install.sh | 28 +- tests/python-example/.editorconfig | 11 + .../.devcontainer/devcontainer.json | 40 +- tests/rust-example/.devcontainer/install.sh | 21 +- tests/rust-example/.editorconfig | 11 + 34 files changed, 1798 insertions(+), 437 deletions(-) create mode 100644 .devcontainer/install.sh create mode 100644 .dotfiles/.gitconfig create mode 100644 .dotfiles/.gitignore_global create mode 100755 .dotfiles/install.sh create mode 100644 roles/devcontainer_sync/files/editorconfig create mode 100644 roles/devcontainer_sync/tasks/sync_devcontainer.yml create mode 100644 roles/devcontainer_sync/templates/aws_configure.sh.j2 delete mode 100644 roles/devcontainer_sync/templates/devcontainer.json.j2 create mode 100755 tests/cloud-example/.devcontainer/aws_configure.sh create mode 100644 tests/cloud-example/.devcontainer/devcontainer.json create mode 100755 tests/cloud-example/.devcontainer/install.sh create mode 100644 tests/cloud-example/.editorconfig delete mode 100644 tests/java-example/.devcontainer/devcontainer.json delete mode 100755 tests/java-example/.devcontainer/install.sh create mode 100644 tests/node-example/.devcontainer/devcontainer.json create mode 100755 tests/node-example/.devcontainer/install.sh create mode 100644 tests/node-example/.editorconfig create mode 100755 tests/python-example/.devcontainer/aws_configure.sh create mode 100644 tests/python-example/.editorconfig create mode 100644 tests/rust-example/.editorconfig diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6f0b89e..72d585c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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" } \ No newline at end of file diff --git a/.devcontainer/install.sh b/.devcontainer/install.sh new file mode 100644 index 0000000..65c18a5 --- /dev/null +++ b/.devcontainer/install.sh @@ -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 \ No newline at end of file diff --git a/.dotfiles/.gitconfig b/.dotfiles/.gitconfig new file mode 100644 index 0000000..bcb789b --- /dev/null +++ b/.dotfiles/.gitconfig @@ -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" diff --git a/.dotfiles/.gitignore_global b/.dotfiles/.gitignore_global new file mode 100644 index 0000000..48739e1 --- /dev/null +++ b/.dotfiles/.gitignore_global @@ -0,0 +1 @@ +.devcontainer-bak-* \ No newline at end of file diff --git a/.dotfiles/install.sh b/.dotfiles/install.sh new file mode 100755 index 0000000..aa60d85 --- /dev/null +++ b/.dotfiles/install.sh @@ -0,0 +1,5 @@ +#!/bin/bash +ln -sf "$(pwd)/.gitconfig" "$HOME/.gitconfig" +ln -sf "$(pwd)/.gitignore_global" "$HOME/.gitignore_global" + +echo "Dotfiles successfully installed!" \ No newline at end of file diff --git a/Makefile b/Makefile index 05b0b20..3d49a25 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ ANSIBLE_PLAYBOOK ?= ansible-playbook +SHELLCHECK ?= shellcheck PLAYBOOK ?= playbook.yml WORKSPACE_ROOT ?= EXAMPLE_VARS ?= group_vars/all.example.yml @@ -8,7 +9,7 @@ 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" @@ -16,8 +17,9 @@ help: @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: @@ -32,6 +34,10 @@ 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) @@ -39,5 +45,6 @@ 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 diff --git a/README.md b/README.md index 01847c0..09662be 100644 --- a/README.md +++ b/README.md @@ -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//`. 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-` 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//`. 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 \ No newline at end of file +Commit generated fixture changes under `tests/` when behavior changes. diff --git a/files/README.md b/files/README.md index a735218..5e7921f 100644 --- a/files/README.md +++ b/files/README.md @@ -1,25 +1,24 @@ -# Project-specific .devcontainer files +# Project-Specific `.devcontainer` Files -Put files here when a project needs additional files inside its `.devcontainer` -directory. +Place files here when a project needs additional content in its generated `.devcontainer` directory. -The directory name mirrors the project path from `group_vars/all.yml`, excluding -`.devcontainer/devcontainer.json`. +The source directory is `files//`. The destination is `//.devcontainer/`. +```yaml +- name: nested-site + path: monorepo/sites/nested ``` -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 + +```text +files/nested-site/id_rsa +files/nested-site/scripts/setup.sh +``` + +renders to: + +```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. +The project-root `.editorconfig` is role-managed separately. `devcontainer.json` and `install.sh` are always role-managed. `aws_configure.sh` is also role-managed when the AWS plugin is selected. Extra-file copy tasks use `no_log` so file contents are not exposed in Ansible output. diff --git a/group_vars/all.example.yml b/group_vars/all.example.yml index 1cbb30a..4138b4a 100644 --- a/group_vars/all.example.yml +++ b/group_vars/all.example.yml @@ -1,83 +1,51 @@ --- -# Override with: WORKSPACE_ROOT=/path/to/workspaces ansible-playbook playbook.yml -workspace_root: "{{ lookup('env', 'WORKSPACE_ROOT') | default('~/dev/', true) }}" - -# Keep this false until you are ready to create missing project directories. -devcontainer_sync_create_missing: false - -devcontainer_defaults: - image: mcr.microsoft.com/devcontainers/base:resolute - post_start_command: sudo chown -R vscode:vscode /home/vscode/.claude* - # chowns and symlinks mounted directories - # runs devcontainer install - post_create_command: >- - sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && - - sudo chown $USER:$USER /opt/claude-code-config && - mkdir -p /opt/claude-code-config/.claude && - touch /opt/claude-code-config/.claude.json && - - ln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && - ln -s /opt/claude-code-config/.claude $HOME/.claude && - - sudo chown $USER:$USER /opt/.codex && - ln -s /opt/.codex $HOME/.codex && - - bash .devcontainer/install.sh - post_attach_command: "" - install: "" - # note: the following keys get merged. see README. - container_env: {} - vscode_extensions: - - Anthropic.claude-code - - openai.chatgpt - mounts: - - source=claude-code-config,target=/opt/claude-code-config,type=volume - - source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume - - source=codex-config,target=/opt/.codex,type=volume +devcontainer_aws_sso: + start_url: https://example.awsapps.com/start/# + account_id: "123456789012" + role_name: AdministratorAccess + session_name: example-sso + region: us-east-1 devcontainers: - - name: java-example - path: java-example/.devcontainer/devcontainer.json - image: mcr.microsoft.com/devcontainers/java:25 - features: - ghcr.io/devcontainers/features/java:1: - version: none - installMaven: false - installGradle: false - install: | - curl -sL -o cfr.jar 'https://www.benf.org/other/cfr/cfr-0.152.jar' - name: rust-example - path: rust-example/.devcontainer/devcontainer.json - image: mcr.microsoft.com/devcontainers/rust:2-1 - install: | - sudo apt update - sudo -n apt-get install -y --no-install-recommends default-jre-headless + primary_language: rust + plugins: + - cache_cargo + - shell_history + install_steps: + - sudo apt update + - sudo apt install -y --no-install-recommends default-jre-headless + - name: python-example - path: python-example/.devcontainer/devcontainer.json - image: mcr.microsoft.com/devcontainers/python:3.14 - post_attach_command: pip install -r ${containerWorkspaceFolder}/requirements.txt - features: - ghcr.io/devcontainers/features/python:1: - installTools: true - version: 3.14 - ghcr.io/larsnieuwenhuizen/features/jqyq:0: {} - ghcr.io/devcontainers/features/aws-cli:1: - version: latest + image: mcr.microsoft.com/devcontainers/python:3.10 + primary_language: python + python_version: "3.10" + plugins: + - aws + - cache_python + - cache_ansible + - shell_history + install_steps: + - sudo apt update + - sudo apt install -y --no-install-recommends ansible + + - name: cloud-example + image: mcr.microsoft.com/devcontainers/base:resolute + primary_language: terraform + plugins: + - aws + - cache_terraform + - shell_history vscode_extensions: - - aaron-bond.better-comments - - KevinRose.vsc-python-indent - - mikestead.dotenv - vscode_settings: - python.testing.unittestArgs: - - -v - - -s - - . - - -p - - "*_test.py" - python.testing.pytestEnabled: false - python.testing.unittestEnabled: true - python.defaultInterpreterPath: /usr/local/python/current/bin/python - install: | - sudo apt update - sudo apt install -y --no-install-recommends ansible + - ms-kubernetes-tools.vscode-kubernetes-tools + features: + ghcr.io/devcontainers/features/kubectl-helm-minikube:1: + version: "1.34" + helm: latest + minikube: none + + - name: node-example + primary_language: typescript + plugins: + - cache_node + - shell_history diff --git a/roles/devcontainer_sync/defaults/main.yml b/roles/devcontainer_sync/defaults/main.yml index e2e12b6..c3f3494 100644 --- a/roles/devcontainer_sync/defaults/main.yml +++ b/roles/devcontainer_sync/defaults/main.yml @@ -1,5 +1,257 @@ --- +workspace_root: "{{ lookup('env', 'WORKSPACE_ROOT') | default('~/dev/', true) }}" +local_timezone: America/Chicago + devcontainer_sync_create_missing: false +devcontainer_auto_rebuild: true devcontainer_sync_backup: true devcontainer_sync_backup_existing_dir: true devcontainer_sync_extra_files_root: "{{ playbook_dir }}/files" + +devcontainer_aws_sso: {} + +devcontainer_primary_language_images: + rust: mcr.microsoft.com/devcontainers/rust:2-1 + python: mcr.microsoft.com/devcontainers/python:3.14 + typescript: mcr.microsoft.com/devcontainers/typescript-node:24 + java: mcr.microsoft.com/devcontainers/java:25 + +devcontainer_defaults: + image: mcr.microsoft.com/devcontainers/base:resolute + post_start_commands: [] + post_create_commands: [] + post_attach_commands: [] + initialize_commands: [] + install_steps: + - sudo apt update && sudo apt install -y ripgrep + - sudo ln -sf /usr/share/zoneinfo/{{ local_timezone }} /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + container_env: {} + remote_env: {} + features: {} + vscode_extensions: + - EditorConfig.EditorConfig + - redhat.vscode-yaml + - timonwong.shellcheck + - usernamehw.errorlens + - github.vscode-github-actions + vscode_settings: {} + mounts: [] + run_args: [] + plugins: + - codex + - claude + +devcontainer_plugins: + claude: + vscode_extensions: + - Anthropic.claude-code + install_steps: + - curl -fsSL https://claude.ai/install.sh | bash + post_create_commands: + - sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude + - sudo chown $USER:$USER /opt/claude-code-config + - mkdir -p /opt/claude-code-config/.claude + - touch /opt/claude-code-config/.claude.json + - ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json + - ln -sfn /opt/claude-code-config/.claude $HOME/.claude + mounts: + - source=claude-code-config,target=/opt/claude-code-config,type=volume + - source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume + + codex: + vscode_extensions: + - openai.chatgpt + install_steps: + - curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh + post_create_commands: + - sudo chown $USER:$USER /opt/.codex + - ln -sfn /opt/.codex $HOME/.codex + - sudo chown $USER:$USER /opt/.agents + - ln -sfn /opt/.agents $HOME/.agents + mounts: + - source=codex-config,target=/opt/.codex,type=volume + - source=agents-config,target=/opt/.agents,type=volume + + rust: + install_steps: + - cargo install --locked cargo-nextest + vscode_settings: + rust-analyzer.runnables.test.overrideCommand: + - cargo + - nextest + - run + - --package + - ${package} + - --release + - -- + - ${test_name} + - ${exact} + - --nocapture + - --include-ignored + + python: + features: + ghcr.io/devcontainers/features/python:1: + installTools: true + version: "{{ devcontainer_base.python_version | default('3.14') }}" + ghcr.io/larsnieuwenhuizen/features/jqyq:0: {} + install_steps: + - | + if [ -f "${containerWorkspaceFolder}/requirements.txt" ] || [ -f "${containerWorkspaceFolder}/dev_requirements.txt" ]; then + set -- + [ -f "${containerWorkspaceFolder}/requirements.txt" ] && set -- "$@" -r "${containerWorkspaceFolder}/requirements.txt" + [ -f "${containerWorkspaceFolder}/dev_requirements.txt" ] && set -- "$@" -r "${containerWorkspaceFolder}/dev_requirements.txt" + pip install "$@" + fi + vscode_extensions: + - KevinRose.vsc-python-indent + - mikestead.dotenv + vscode_settings: + python.testing.unittestArgs: + - -v + - -s + - . + - -p + - "*_test.py" + python.testing.pytestEnabled: false + python.testing.unittestEnabled: true + python.defaultInterpreterPath: /usr/local/python/current/bin/python + python.analysis.autoFormatStrings: true + + aws: + features: + ghcr.io/devcontainers/features/aws-cli:1: + version: latest + install_steps: + - mkdir -p ~/.ssh + - printf 'Host *\n User ec2-user\n IdentityFile %s/.ssh/id_rsa\n StrictHostKeyChecking no\n' "${containerWorkspaceFolder}" > ~/.ssh/config + - bash "${containerWorkspaceFolder}/.devcontainer/aws_configure.sh" + + terraform: + features: + ghcr.io/devcontainers/features/terraform:1: + version: latest + tflint: latest + terragrunt: latest + ghcr.io/devcontainers-extra/features/terraform-ls-asdf:2: {} + vscode_extensions: + - 4ops.terraform + - redhat.vscode-yaml + install_steps: + - echo alias tf=terraform > ~/.bash_aliases + + django: + vscode_extensions: + - batisteo.vscode-django + - junstyle.vscode-django-support + vscode_settings: + python.analysis.autoImportCompletions: true + python.analysis.diagnosticMode: workspace + python.analysis.typeCheckingMode: basic + install_steps: + - sudo apt update && sudo apt-get install -y --no-install-recommends sassc cron + + typescript: + install_steps: + - npm install + + github: + install_steps: + - mkdir -p .github/workflows + - | + tee .github/workflows/security.yml >/dev/null <<'EOF' + name: Multi-Language Security Scan + + on: + push: + branches: [ main ] + schedule: + - cron: "30 12 * * 1" + + permissions: + actions: read + security-events: write + contents: read + + jobs: + # static application security testing provided by opengrep + sast: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - id: scan + uses: platform-sec/opengrep-action@LATEST_SHA256_HASH + with: + target: . + output-format: text + strict: true + + # software composition analysis provided by Google-OSV + sca: + if: github.event_name == 'schedule' + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@LATEST_SHA256_HASH + EOF + + cache_cargo: + mounts: + - source=devcontainer-cache-cargo-registry,target=/usr/local/cargo/registry,type=volume + - source=devcontainer-cache-cargo-git,target=/usr/local/cargo/git,type=volume + - source=devcontainer-cache-cargo-target-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/target,type=volume + post_create_commands: + - sudo mkdir -p /usr/local/cargo/registry /usr/local/cargo/git ${containerWorkspaceFolder}/target + - sudo chown -R $USER:$USER /usr/local/cargo/registry /usr/local/cargo/git ${containerWorkspaceFolder}/target + + cache_node: + container_env: + NPM_CONFIG_CACHE: /opt/devcontainer-cache/npm + mounts: + - source=devcontainer-cache-npm,target=/opt/devcontainer-cache/npm,type=volume + - source=devcontainer-cache-pnpm,target=/opt/devcontainer-cache/pnpm,type=volume + post_create_commands: + - sudo mkdir -p /opt/devcontainer-cache/npm /opt/devcontainer-cache/pnpm + - sudo chown -R $USER:$USER /opt/devcontainer-cache/npm /opt/devcontainer-cache/pnpm + - touch $HOME/.npmrc + - (grep -qxF 'store-dir=/opt/devcontainer-cache/pnpm' $HOME/.npmrc || echo 'store-dir=/opt/devcontainer-cache/pnpm' >> $HOME/.npmrc) + + cache_python: + container_env: + PIP_CACHE_DIR: /opt/devcontainer-cache/pip + UV_CACHE_DIR: /opt/devcontainer-cache/uv + mounts: + - source=devcontainer-cache-pip,target=/opt/devcontainer-cache/pip,type=volume + - source=devcontainer-cache-uv,target=/opt/devcontainer-cache/uv,type=volume + post_create_commands: + - sudo mkdir -p /opt/devcontainer-cache/pip /opt/devcontainer-cache/uv + - sudo chown -R $USER:$USER /opt/devcontainer-cache/pip /opt/devcontainer-cache/uv + + cache_terraform: + container_env: + TF_PLUGIN_CACHE_DIR: /opt/devcontainer-cache/terraform + mounts: + - source=devcontainer-cache-terraform,target=/opt/devcontainer-cache/terraform,type=volume + post_create_commands: + - sudo mkdir -p /opt/devcontainer-cache/terraform + - sudo chown -R $USER:$USER /opt/devcontainer-cache/terraform + + cache_ansible: + container_env: + ANSIBLE_COLLECTIONS_PATH: /opt/devcontainer-cache/ansible/collections:/usr/share/ansible/collections + mounts: + - source=devcontainer-cache-ansible,target=/opt/devcontainer-cache/ansible,type=volume + post_create_commands: + - sudo mkdir -p /opt/devcontainer-cache/ansible/collections + - sudo chown -R $USER:$USER /opt/devcontainer-cache/ansible + + shell_history: + container_env: + HISTFILE: /commandhistory/.bash_history + mounts: + - source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume + post_create_commands: + - sudo mkdir -p /commandhistory + - sudo touch /commandhistory/.bash_history + - sudo chown -R $USER:$USER /commandhistory diff --git a/roles/devcontainer_sync/files/editorconfig b/roles/devcontainer_sync/files/editorconfig new file mode 100644 index 0000000..b5bd746 --- /dev/null +++ b/roles/devcontainer_sync/files/editorconfig @@ -0,0 +1,11 @@ +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/roles/devcontainer_sync/tasks/extra_files.yml b/roles/devcontainer_sync/tasks/extra_files.yml index a15a53a..5c22095 100644 --- a/roles/devcontainer_sync/tasks/extra_files.yml +++ b/roles/devcontainer_sync/tasks/extra_files.yml @@ -1,33 +1,7 @@ --- -- name: Set project extra .devcontainer path - ansible.builtin.set_fact: - devcontainer_sync_extra_project_path: "{{ devcontainer_sync_target.item.path | dirname | dirname }}" - -- name: Set descendant project extra file prefixes - ansible.builtin.set_fact: - devcontainer_sync_extra_child_project_paths: >- - {{ - devcontainers - | map(attribute='path') - | map('dirname') - | map('dirname') - | select('match', '^' ~ (devcontainer_sync_extra_project_path | regex_escape) ~ '/.+') - | map('regex_replace', '^' ~ (devcontainer_sync_extra_project_path | regex_escape) ~ '/', '') - | list - }} - -- name: Set descendant project extra file exclusion regex - ansible.builtin.set_fact: - devcontainer_sync_extra_child_project_regex: >- - {{ - '^(' ~ (devcontainer_sync_extra_child_project_paths | map('regex_escape') | join('|')) ~ ')($|/)' - if devcontainer_sync_extra_child_project_paths | length > 0 - else '$.^' - }} - - name: Check project extra .devcontainer source directory ansible.builtin.stat: - path: "{{ devcontainer_sync_extra_files_root }}/{{ devcontainer_sync_extra_project_path }}" + path: "{{ [devcontainer_sync_extra_files_root, devcontainer_sync_target_name] | path_join }}" register: devcontainer_sync_extra_source - name: Find project extra .devcontainer files @@ -41,26 +15,39 @@ - name: Ensure project extra .devcontainer file directories exist ansible.builtin.file: path: >- - {{ workspace_root }}/{{ devcontainer_sync_target.item.path | dirname }}/{{ item.path | relpath(devcontainer_sync_extra_source.stat.path) | dirname }} + {{ + [ + workspace_root, + devcontainer_sync_target_root, + '.devcontainer', + item.path | relpath(devcontainer_sync_extra_source.stat.path) | dirname, + ] + | path_join + }} state: directory mode: "0755" loop: "{{ devcontainer_sync_extra_files.files | default([]) }}" loop_control: - label: "{{ devcontainer_sync_target.item.path | dirname }}/{{ item.path | relpath(devcontainer_sync_extra_source.stat.path) | dirname }}" - when: - - (item.path | relpath(devcontainer_sync_extra_source.stat.path)) not in ["devcontainer.json", "install.sh"] - - not ((item.path | relpath(devcontainer_sync_extra_source.stat.path)) is match(devcontainer_sync_extra_child_project_regex)) + label: "{{ item.path | relpath(devcontainer_sync_extra_source.stat.path) | dirname }}" + when: (item.path | relpath(devcontainer_sync_extra_source.stat.path)) not in devcontainer_sync_managed_files - name: Copy project extra .devcontainer files ansible.builtin.copy: src: "{{ item.path }}" dest: >- - {{ workspace_root }}/{{ devcontainer_sync_target.item.path | dirname }}/{{ item.path | relpath(devcontainer_sync_extra_source.stat.path) }} + {{ + [ + workspace_root, + devcontainer_sync_target_root, + '.devcontainer', + item.path | relpath(devcontainer_sync_extra_source.stat.path), + ] + | path_join + }} mode: preserve backup: "{{ devcontainer_sync_backup }}" + no_log: true loop: "{{ devcontainer_sync_extra_files.files | default([]) }}" loop_control: - label: "{{ devcontainer_sync_target.item.path | dirname }}/{{ item.path | relpath(devcontainer_sync_extra_source.stat.path) }}" - when: - - (item.path | relpath(devcontainer_sync_extra_source.stat.path)) not in ["devcontainer.json", "install.sh"] - - not ((item.path | relpath(devcontainer_sync_extra_source.stat.path)) is match(devcontainer_sync_extra_child_project_regex)) + label: "{{ item.path | relpath(devcontainer_sync_extra_source.stat.path) }}" + when: (item.path | relpath(devcontainer_sync_extra_source.stat.path)) not in devcontainer_sync_managed_files diff --git a/roles/devcontainer_sync/tasks/main.yml b/roles/devcontainer_sync/tasks/main.yml index e634319..b4bac97 100644 --- a/roles/devcontainer_sync/tasks/main.yml +++ b/roles/devcontainer_sync/tasks/main.yml @@ -1,136 +1,41 @@ --- -- name: Check target devcontainer files - ansible.builtin.stat: - path: "{{ workspace_root }}/{{ item.path }}" +- name: Validate devcontainer list + ansible.builtin.assert: + that: + - devcontainers is sequence + - devcontainers is not string + fail_msg: devcontainers must be a list. + +- name: Validate devcontainer identities + ansible.builtin.assert: + that: + - devcontainer_config is mapping + - devcontainer_config.name is defined + - devcontainer_config.name is string + - devcontainer_config.name | trim | length > 0 + - not (devcontainer_config.name is match('^/')) + - not (devcontainer_config.name is search('(^|/)\\.\\.(/|$)')) + - devcontainer_config.path is not defined or devcontainer_config.path is string + - devcontainer_config.path is not defined or devcontainer_config.path | trim | length > 0 + - devcontainer_config.path is not defined or not (devcontainer_config.path is match('^/')) + - devcontainer_config.path is not defined or not (devcontainer_config.path is search('(^|/)\\.\\.(/|$)')) + fail_msg: >- + Each devcontainer must have a nonempty, relative name and an optional + nonempty, relative path. Parent-directory traversal is not allowed. loop: "{{ devcontainers }}" loop_control: - label: "{{ item.path }}" - register: devcontainer_sync_targets + label: "{{ devcontainer_config.name | default('unnamed') }}" + loop_var: devcontainer_config -- name: Check whether devcontainer.json files would change - ansible.builtin.template: - src: devcontainer.json.j2 - dest: "{{ workspace_root }}/{{ item.item.path }}" - mode: "0644" - backup: false - check_mode: true - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ item.item.path }}" - vars: - devcontainer: &merged_devcontainer "{{ devcontainer_defaults | combine(item.item, recursive=True, list_merge='append_rp') }}" - devcontainer_json: &devcontainer_json >- - {{ - { - 'name': devcontainer.name | default(devcontainer.path | regex_replace('/\\.devcontainer/devcontainer\\.json$', '') | basename), - 'image': devcontainer.image, - 'customizations': { - 'vscode': {'extensions': devcontainer.vscode_extensions} - | combine(({'settings': devcontainer.vscode_settings} if devcontainer.vscode_settings is defined and devcontainer.vscode_settings else {})), - }, - 'postStartCommand': devcontainer.post_start_command, - 'postCreateCommand': devcontainer.post_create_command, - 'containerEnv': devcontainer.container_env, - 'mounts': devcontainer.mounts, - } - | combine(({'updateRemoteUserUID': devcontainer.update_remote_user_uid} if devcontainer.update_remote_user_uid is defined else {})) - | combine(({'features': devcontainer.features} if devcontainer.features is defined and devcontainer.features else {})) - | combine(({'remoteEnv': devcontainer.remote_env} if devcontainer.remote_env is defined and devcontainer.remote_env else {})) - | combine(({'initializeCommand': devcontainer.initialize_command} if devcontainer.initialize_command is defined and devcontainer.initialize_command else {})) - | combine(({'postAttachCommand': devcontainer.post_attach_command} if devcontainer.post_attach_command is defined and devcontainer.post_attach_command else {})) - | combine(({'runArgs': devcontainer.run_args} if devcontainer.run_args is defined and devcontainer.run_args else {})) - | combine(({'forwardPorts': devcontainer.forwardPorts} if devcontainer.forwardPorts is defined and devcontainer.forwardPorts else {})) - }} - register: devcontainer_sync_devcontainer_render_preview - when: item.stat.exists - -- name: Check whether install.sh files would change - ansible.builtin.template: - src: install.sh.j2 - dest: "{{ workspace_root }}/{{ item.item.path | dirname }}/install.sh" - mode: "0755" - backup: false - check_mode: true - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ item.item.path | dirname }}/install.sh" - vars: - devcontainer: *merged_devcontainer - register: devcontainer_sync_install_render_preview - when: item.stat.exists - -- name: Set devcontainer backup timestamp - ansible.builtin.set_fact: - devcontainer_sync_backup_timestamp: '{{ lookup("pipe", "date +%s") }}' - -- name: Move changed .devcontainer directories to timestamped backups - ansible.builtin.command: - argv: - - mv - - "{{ workspace_root }}/{{ item.item.path | dirname }}" - - "{{ workspace_root }}/{{ item.item.path | dirname | dirname }}/.devcontainer-bak-{{ devcontainer_sync_backup_timestamp }}" - removes: "{{ workspace_root }}/{{ item.item.path | dirname }}" - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ item.item.path | dirname }}" - index_var: devcontainer_sync_target_index - when: - - devcontainer_sync_backup_existing_dir | bool - - item.stat.exists - - >- - (devcontainer_sync_devcontainer_render_preview.results[devcontainer_sync_target_index].changed | default(false)) - or - (devcontainer_sync_install_render_preview.results[devcontainer_sync_target_index].changed | default(false)) - -- name: Ensure .devcontainer directories exist - ansible.builtin.file: - path: "{{ workspace_root }}/{{ item.item.path | dirname }}" - state: directory - mode: "0755" - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ item.item.path | dirname }}" - when: item.stat.exists or devcontainer_sync_create_missing | bool +- name: Validate unique devcontainer names + ansible.builtin.assert: + that: + - devcontainers | map(attribute='name') | list | length == devcontainers | map(attribute='name') | unique | list | length + fail_msg: Devcontainer names must be unique because names identify entries under files/. -- name: Render devcontainer.json files - ansible.builtin.template: - src: devcontainer.json.j2 - dest: "{{ workspace_root }}/{{ item.item.path }}" - mode: "0644" - backup: "{{ devcontainer_sync_backup }}" - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ item.item.path }}" - vars: - devcontainer: *merged_devcontainer - devcontainer_json: *devcontainer_json - when: item.stat.exists or devcontainer_sync_create_missing | bool - -- name: Render install.sh files - ansible.builtin.template: - src: install.sh.j2 - dest: "{{ workspace_root }}/{{ item.item.path | dirname }}/install.sh" - mode: "0755" - backup: "{{ devcontainer_sync_backup }}" - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ item.item.path | dirname }}/install.sh" - vars: - devcontainer: *merged_devcontainer - when: item.stat.exists or devcontainer_sync_create_missing | bool - -- name: Copy project extra .devcontainer files - ansible.builtin.include_tasks: extra_files.yml - loop: "{{ devcontainer_sync_targets.results }}" - loop_control: - label: "{{ devcontainer_sync_target.item.path | dirname }}" - loop_var: devcontainer_sync_target - when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool - -- name: Report skipped missing devcontainer files - ansible.builtin.debug: - msg: "Skipped missing target {{ item.item.path }}; set devcontainer_sync_create_missing=true to create it." - loop: "{{ devcontainer_sync_targets.results }}" +- name: Sync devcontainer + ansible.builtin.include_tasks: sync_devcontainer.yml + loop: "{{ devcontainers }}" loop_control: - label: "{{ item.item.path }}" - when: not item.stat.exists and not devcontainer_sync_create_missing | bool + label: "{{ devcontainer_config.name }}" + loop_var: devcontainer_config diff --git a/roles/devcontainer_sync/tasks/sync_devcontainer.yml b/roles/devcontainer_sync/tasks/sync_devcontainer.yml new file mode 100644 index 0000000..a1e86ef --- /dev/null +++ b/roles/devcontainer_sync/tasks/sync_devcontainer.yml @@ -0,0 +1,367 @@ +--- +- name: Set devcontainer target values + ansible.builtin.set_fact: + devcontainer_base: "{{ devcontainer_defaults | combine(devcontainer_config, recursive=True, list_merge='append_rp') }}" + devcontainer_sync_target_name: "{{ devcontainer_config.name }}" + devcontainer_sync_target_root: "{{ devcontainer_config.path | default(devcontainer_config.name) }}" + +- name: Validate plugin selection + ansible.builtin.assert: + that: + - devcontainer_base.plugins is sequence + - devcontainer_base.plugins is not string + - devcontainer_base.plugins | select('string') | list | length == devcontainer_base.plugins | length + fail_msg: "{{ devcontainer_config.name }}: plugins must be a list of plugin names." + +- name: Select devcontainer plugins + ansible.builtin.set_fact: + devcontainer_sync_plugin_names: >- + {{ + ( + ([devcontainer_base.primary_language] + if devcontainer_base.primary_language is defined + and devcontainer_base.primary_language in devcontainer_plugins + else []) + + devcontainer_base.plugins + ) + | unique + | list + }} + +- name: Validate selected plugins + ansible.builtin.assert: + that: + - devcontainer_sync_plugin_names | difference(devcontainer_plugins.keys() | list) | length == 0 + fail_msg: >- + {{ devcontainer_config.name }} selects unknown plugins: + {{ devcontainer_sync_plugin_names | difference(devcontainer_plugins.keys() | list) | join(', ') }} + +- name: Validate AWS SSO configuration + ansible.builtin.assert: + that: + - devcontainer_aws_sso is mapping + - devcontainer_aws_sso.start_url is defined + - devcontainer_aws_sso.start_url is string + - devcontainer_aws_sso.start_url | length > 0 + - devcontainer_aws_sso.account_id is defined + - devcontainer_aws_sso.account_id is string + - devcontainer_aws_sso.account_id | length > 0 + - devcontainer_aws_sso.role_name is defined + - devcontainer_aws_sso.role_name is string + - devcontainer_aws_sso.role_name | length > 0 + fail_msg: >- + The aws plugin requires devcontainer_aws_sso.start_url, + devcontainer_aws_sso.account_id, and devcontainer_aws_sso.role_name. + when: "'aws' in devcontainer_sync_plugin_names" + +- name: Merge devcontainer plugin values + ansible.builtin.set_fact: + devcontainer_sync_plugin_values: >- + {%- set merged = namespace(value={}) -%} + {%- for plugin_name in devcontainer_sync_plugin_names -%} + {%- set merged.value = merged.value | combine( + devcontainer_plugins[plugin_name], + recursive=True, + list_merge='append_rp' + ) -%} + {%- endfor -%} + {{ merged.value }} + +- name: Merge devcontainer values + ansible.builtin.set_fact: + devcontainer: >- + {{ + devcontainer_defaults + | combine(devcontainer_sync_plugin_values, recursive=True, list_merge='append_rp') + | combine(devcontainer_config, recursive=True, list_merge='append_rp') + }} + +- name: Validate merged devcontainer values + ansible.builtin.assert: + that: + - devcontainer.container_env is mapping + - devcontainer.remote_env is mapping + - devcontainer.features is mapping + - devcontainer.vscode_settings is mapping + - devcontainer.vscode_extensions is sequence + - devcontainer.vscode_extensions is not string + - devcontainer.vscode_extensions | select('string') | list | length == devcontainer.vscode_extensions | length + - devcontainer.mounts is sequence + - devcontainer.mounts is not string + - devcontainer.mounts | select('string') | list | length == devcontainer.mounts | length + - devcontainer.run_args is sequence + - devcontainer.run_args is not string + - devcontainer.run_args | select('string') | list | length == devcontainer.run_args | length + - devcontainer.post_start_commands is sequence + - devcontainer.post_start_commands is not string + - devcontainer.post_start_commands | select('string') | list | length == devcontainer.post_start_commands | length + - devcontainer.post_create_commands is sequence + - devcontainer.post_create_commands is not string + - devcontainer.post_create_commands | select('string') | list | length == devcontainer.post_create_commands | length + - devcontainer.post_attach_commands is sequence + - devcontainer.post_attach_commands is not string + - devcontainer.post_attach_commands | select('string') | list | length == devcontainer.post_attach_commands | length + - devcontainer.initialize_commands is sequence + - devcontainer.initialize_commands is not string + - devcontainer.initialize_commands | select('string') | list | length == devcontainer.initialize_commands | length + - devcontainer.install_steps is sequence + - devcontainer.install_steps is not string + - devcontainer.install_steps | select('string') | list | length == devcontainer.install_steps | length + fail_msg: "{{ devcontainer_config.name }} has a value with the wrong type; see the role defaults for the expected schema." + +- name: Build devcontainer render values + ansible.builtin.set_fact: + devcontainer_sync_image: >- + {{ + devcontainer_config.image | default( + devcontainer_primary_language_images[devcontainer.primary_language] + if devcontainer.primary_language is defined + and devcontainer.primary_language in devcontainer_primary_language_images + else devcontainer_defaults.image, + true + ) + }} + devcontainer_sync_initialize_command: >- + {{ devcontainer.initialize_commands | map('regex_replace', '\\s*&&\\s*$', '') | join(' && ') }} + devcontainer_sync_post_attach_command: >- + {{ devcontainer.post_attach_commands | map('regex_replace', '\\s*&&\\s*$', '') | join(' && ') }} + devcontainer_sync_post_create_command: >- + {{ + ( + devcontainer.post_create_commands + + ['bash "${containerWorkspaceFolder}/.devcontainer/install.sh"'] + ) + | map('regex_replace', '\\s*&&\\s*$', '') + | join(' && ') + }} + devcontainer_sync_post_start_command: >- + {{ devcontainer.post_start_commands | map('regex_replace', '\\s*&&\\s*$', '') | join(' && ') }} + devcontainer_sync_managed_files: >- + {{ + ['devcontainer.json', 'install.sh'] + + (['aws_configure.sh'] if 'aws' in devcontainer_sync_plugin_names else []) + }} + +- name: Build devcontainer.json content + ansible.builtin.set_fact: + devcontainer_json: >- + {{ + { + 'name': devcontainer.name, + 'image': devcontainer_sync_image, + 'customizations': { + 'vscode': { + 'extensions': devcontainer.vscode_extensions, + } + | combine( + {'settings': devcontainer.vscode_settings} + if devcontainer.vscode_settings + else {} + ), + }, + 'containerEnv': devcontainer.container_env, + 'mounts': devcontainer.mounts, + 'postCreateCommand': devcontainer_sync_post_create_command, + } + | combine( + {'postStartCommand': devcontainer_sync_post_start_command} + if devcontainer_sync_post_start_command + else {} + ) + | combine( + {'postAttachCommand': devcontainer_sync_post_attach_command} + if devcontainer_sync_post_attach_command + else {} + ) + | combine( + {'initializeCommand': devcontainer_sync_initialize_command} + if devcontainer_sync_initialize_command + else {} + ) + | combine( + {'updateRemoteUserUID': devcontainer.update_remote_user_uid} + if devcontainer.update_remote_user_uid is defined + else {} + ) + | combine({'features': devcontainer.features} if devcontainer.features else {}) + | combine({'remoteEnv': devcontainer.remote_env} if devcontainer.remote_env else {}) + | combine({'runArgs': devcontainer.run_args} if devcontainer.run_args else {}) + }} + +- name: Check target devcontainer file + ansible.builtin.stat: + path: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/devcontainer.json'] | path_join }}" + register: devcontainer_sync_target + +- name: Find initial .devcontainer files + ansible.builtin.find: + paths: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join }}" + file_type: file + recurse: true + hidden: true + get_checksum: true + checksum_algorithm: sha256 + register: devcontainer_sync_initial_files + changed_when: false + when: devcontainer_sync_target.stat.exists + +- name: Store initial .devcontainer hash + ansible.builtin.set_fact: + devcontainer_sync_initial_hash: >- + {%- if not devcontainer_sync_target.stat.exists -%} + missing + {%- else -%} + {%- set entries = [] -%} + {%- set target_dir = [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join -%} + {%- for file in devcontainer_sync_initial_files.files | sort(attribute='path') -%} + {%- set _ = entries.append([file.path | relpath(target_dir), file.checksum]) -%} + {%- endfor -%} + {{ entries | to_json | hash('sha256') }} + {%- endif -%} + +- name: Check whether .editorconfig would change + ansible.builtin.copy: + src: editorconfig + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.editorconfig'] | path_join }}" + mode: "0644" + backup: false + check_mode: true + register: devcontainer_sync_editorconfig_render_preview + when: devcontainer_sync_target.stat.exists + +- name: Check whether devcontainer.json would change + ansible.builtin.copy: + content: "{{ devcontainer_json | to_nice_json(indent=4) }}\n" + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/devcontainer.json'] | path_join }}" + mode: "0644" + backup: false + check_mode: true + register: devcontainer_sync_devcontainer_render_preview + when: devcontainer_sync_target.stat.exists + +- name: Check whether install.sh would change + ansible.builtin.template: + src: install.sh.j2 + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/install.sh'] | path_join }}" + mode: "0755" + backup: false + check_mode: true + register: devcontainer_sync_install_render_preview + when: devcontainer_sync_target.stat.exists + +- name: Check whether aws_configure.sh would change + ansible.builtin.template: + src: aws_configure.sh.j2 + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/aws_configure.sh'] | path_join }}" + mode: "0755" + backup: false + check_mode: true + register: devcontainer_sync_aws_render_preview + when: + - devcontainer_sync_target.stat.exists + - "'aws' in devcontainer_sync_plugin_names" + +- name: Move changed .devcontainer directory to timestamped backup + ansible.builtin.command: + argv: + - mv + - "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join }}" + - "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer-bak-' ~ lookup('pipe', 'date +%s%N')] | path_join }}" + removes: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join }}" + when: + - devcontainer_sync_backup_existing_dir | bool + - devcontainer_sync_target.stat.exists + - >- + (devcontainer_sync_devcontainer_render_preview.changed | default(false)) + or (devcontainer_sync_install_render_preview.changed | default(false)) + or (devcontainer_sync_aws_render_preview.changed | default(false)) + +- name: Ensure .devcontainer directory exists + ansible.builtin.file: + path: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join }}" + state: directory + mode: "0755" + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Render .editorconfig + ansible.builtin.copy: + src: editorconfig + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.editorconfig'] | path_join }}" + mode: "0644" + backup: "{{ devcontainer_sync_backup }}" + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Render devcontainer.json + ansible.builtin.copy: + content: "{{ devcontainer_json | to_nice_json(indent=4) }}\n" + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/devcontainer.json'] | path_join }}" + mode: "0644" + backup: "{{ devcontainer_sync_backup }}" + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Render install.sh + ansible.builtin.template: + src: install.sh.j2 + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/install.sh'] | path_join }}" + mode: "0755" + backup: "{{ devcontainer_sync_backup }}" + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Render aws_configure.sh + ansible.builtin.template: + src: aws_configure.sh.j2 + dest: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer/aws_configure.sh'] | path_join }}" + mode: "0755" + backup: "{{ devcontainer_sync_backup }}" + when: + - devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + - "'aws' in devcontainer_sync_plugin_names" + +- name: Copy project extra .devcontainer files + ansible.builtin.include_tasks: extra_files.yml + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Find rendered .devcontainer files + ansible.builtin.find: + paths: "{{ [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join }}" + file_type: file + recurse: true + hidden: true + get_checksum: true + checksum_algorithm: sha256 + register: devcontainer_sync_rendered_files + changed_when: false + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Store rendered .devcontainer hash + ansible.builtin.set_fact: + devcontainer_sync_rendered_hash: >- + {%- set entries = [] -%} + {%- set target_dir = [workspace_root, devcontainer_sync_target_root, '.devcontainer'] | path_join -%} + {%- for file in devcontainer_sync_rendered_files.files | sort(attribute='path') -%} + {%- set _ = entries.append([file.path | relpath(target_dir), file.checksum]) -%} + {%- endfor -%} + {{ entries | to_json | hash('sha256') }} + when: devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + +- name: Rebuild changed devcontainer + ansible.builtin.command: + argv: + - devcontainer + - up + - --workspace-folder + - "{{ [workspace_root, devcontainer_sync_target_root] | path_join }}" + - --remove-existing-container + when: + - devcontainer_auto_rebuild | bool + - not ansible_check_mode + - devcontainer_sync_target.stat.exists or devcontainer_sync_create_missing | bool + - devcontainer_sync_initial_hash != devcontainer_sync_rendered_hash + changed_when: true + +- name: Report skipped missing devcontainer file + ansible.builtin.debug: + msg: >- + Skipped missing target {{ devcontainer_sync_target_root }}; + set devcontainer_sync_create_missing=true to create it. + when: not devcontainer_sync_target.stat.exists and not devcontainer_sync_create_missing | bool diff --git a/roles/devcontainer_sync/templates/aws_configure.sh.j2 b/roles/devcontainer_sync/templates/aws_configure.sh.j2 new file mode 100644 index 0000000..177d082 --- /dev/null +++ b/roles/devcontainer_sync/templates/aws_configure.sh.j2 @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p "$HOME/.aws" "$HOME/.bashrc.d" + +cat > "$HOME/.aws/config" <<'AWS_CONFIG' +{{ '[default]' if devcontainer_aws_sso.profile | default('default') == 'default' else '[profile ' ~ devcontainer_aws_sso.profile ~ ']' }} +region = {{ devcontainer_aws_sso.region | default('us-east-1') }} +output = {{ devcontainer_aws_sso.output | default('json') }} +sso_session = {{ devcontainer_aws_sso.session_name | default('default-sso') }} +sso_account_id = {{ devcontainer_aws_sso.account_id }} +sso_role_name = {{ devcontainer_aws_sso.role_name }} + +[sso-session {{ devcontainer_aws_sso.session_name | default('default-sso') }}] +sso_start_url = {{ devcontainer_aws_sso.start_url }} +sso_region = {{ devcontainer_aws_sso.sso_region | default(devcontainer_aws_sso.region | default('us-east-1')) }} +sso_registration_scopes = {{ devcontainer_aws_sso.registration_scopes | default('sso:account:access') }} +AWS_CONFIG + +cat > "$HOME/.bashrc.d/aws-sso-login.sh" <<'AWS_HOOK' +aws() { + local argument index + local -a profile_args=() + + for ((index = 1; index <= $#; index++)); do + argument="${!index}" + case "$argument" in + --profile) + if ((index == $#)); then + command aws "$@" + return + fi + index=$((index + 1)) + profile_args=(--profile "${!index}") + ;; + --profile=*) + profile_args=("$argument") + ;; + esac + done + + case " $* " in + *" sso login "*|*" sso logout "*|*" configure "*|*" --help "*|*" --version "*) + command aws "$@" + return + ;; + esac + + if ! command aws "${profile_args[@]}" sts get-caller-identity >/dev/null 2>&1; then + command aws "${profile_args[@]}" sso login + fi + + command aws "$@" +} +AWS_HOOK + +bashrc_hook="source \"$HOME/.bashrc.d/aws-sso-login.sh\"" +touch "$HOME/.bashrc" +grep -qxF "$bashrc_hook" "$HOME/.bashrc" || printf '\n%s\n' "$bashrc_hook" >> "$HOME/.bashrc" diff --git a/roles/devcontainer_sync/templates/devcontainer.json.j2 b/roles/devcontainer_sync/templates/devcontainer.json.j2 deleted file mode 100644 index 277aa22..0000000 --- a/roles/devcontainer_sync/templates/devcontainer.json.j2 +++ /dev/null @@ -1 +0,0 @@ -{{ devcontainer_json | to_nice_json(indent=4) }} diff --git a/roles/devcontainer_sync/templates/install.sh.j2 b/roles/devcontainer_sync/templates/install.sh.j2 index 4c9a440..2861049 100644 --- a/roles/devcontainer_sync/templates/install.sh.j2 +++ b/roles/devcontainer_sync/templates/install.sh.j2 @@ -1,12 +1,13 @@ #!/bin/bash +set -euo pipefail -# This template provides a paved road for wrapping all devcontainer installers. +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder -# For example, to install claude-code CLI in every devcontainer, use: -curl -fsSL https://claude.ai/install.sh | bash +{% for step in devcontainer.install_steps %} +{{ step | trim }} +{% if not loop.last %} -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh - -{{ devcontainer.install }} \ No newline at end of file +{% endif -%} +{% endfor -%} diff --git a/tests/cloud-example/.devcontainer/aws_configure.sh b/tests/cloud-example/.devcontainer/aws_configure.sh new file mode 100755 index 0000000..17fdda5 --- /dev/null +++ b/tests/cloud-example/.devcontainer/aws_configure.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p "$HOME/.aws" "$HOME/.bashrc.d" + +cat > "$HOME/.aws/config" <<'AWS_CONFIG' +[default] +region = us-east-1 +output = json +sso_session = example-sso +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[sso-session example-sso] +sso_start_url = https://example.awsapps.com/start/# +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +AWS_CONFIG + +cat > "$HOME/.bashrc.d/aws-sso-login.sh" <<'AWS_HOOK' +aws() { + local argument index + local -a profile_args=() + + for ((index = 1; index <= $#; index++)); do + argument="${!index}" + case "$argument" in + --profile) + if ((index == $#)); then + command aws "$@" + return + fi + index=$((index + 1)) + profile_args=(--profile "${!index}") + ;; + --profile=*) + profile_args=("$argument") + ;; + esac + done + + case " $* " in + *" sso login "*|*" sso logout "*|*" configure "*|*" --help "*|*" --version "*) + command aws "$@" + return + ;; + esac + + if ! command aws "${profile_args[@]}" sts get-caller-identity >/dev/null 2>&1; then + command aws "${profile_args[@]}" sso login + fi + + command aws "$@" +} +AWS_HOOK + +bashrc_hook="source \"$HOME/.bashrc.d/aws-sso-login.sh\"" +touch "$HOME/.bashrc" +grep -qxF "$bashrc_hook" "$HOME/.bashrc" || printf '\n%s\n' "$bashrc_hook" >> "$HOME/.bashrc" diff --git a/tests/cloud-example/.devcontainer/devcontainer.json b/tests/cloud-example/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f1ba7e6 --- /dev/null +++ b/tests/cloud-example/.devcontainer/devcontainer.json @@ -0,0 +1,48 @@ +{ + "containerEnv": { + "HISTFILE": "/commandhistory/.bash_history", + "TF_PLUGIN_CACHE_DIR": "/opt/devcontainer-cache/terraform" + }, + "customizations": { + "vscode": { + "extensions": [ + "EditorConfig.EditorConfig", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", + "4ops.terraform", + "redhat.vscode-yaml", + "openai.chatgpt", + "Anthropic.claude-code", + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] + } + }, + "features": { + "ghcr.io/devcontainers-extra/features/terraform-ls-asdf:2": {}, + "ghcr.io/devcontainers/features/aws-cli:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "helm": "latest", + "minikube": "none", + "version": "1.34" + }, + "ghcr.io/devcontainers/features/terraform:1": { + "terragrunt": "latest", + "tflint": "latest", + "version": "latest" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:resolute", + "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", + "source=claude-code-config,target=/opt/claude-code-config,type=volume", + "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", + "source=devcontainer-cache-terraform,target=/opt/devcontainer-cache/terraform,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" + ], + "name": "cloud-example", + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /opt/devcontainer-cache/terraform && sudo chown -R $USER:$USER /opt/devcontainer-cache/terraform && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" +} diff --git a/tests/cloud-example/.devcontainer/install.sh b/tests/cloud-example/.devcontainer/install.sh new file mode 100755 index 0000000..370161e --- /dev/null +++ b/tests/cloud-example/.devcontainer/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder + +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +echo alias tf=terraform > ~/.bash_aliases + +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh + +curl -fsSL https://claude.ai/install.sh | bash + +mkdir -p ~/.ssh + +printf 'Host *\n User ec2-user\n IdentityFile %s/.ssh/id_rsa\n StrictHostKeyChecking no\n' "${containerWorkspaceFolder}" > ~/.ssh/config + +bash "${containerWorkspaceFolder}/.devcontainer/aws_configure.sh" diff --git a/tests/cloud-example/.editorconfig b/tests/cloud-example/.editorconfig new file mode 100644 index 0000000..b5bd746 --- /dev/null +++ b/tests/cloud-example/.editorconfig @@ -0,0 +1,11 @@ +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/tests/golden_output b/tests/golden_output index 79de17e..018bde7 100644 --- a/tests/golden_output +++ b/tests/golden_output @@ -1,54 +1,282 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p "$HOME/.aws" "$HOME/.bashrc.d" + +cat > "$HOME/.aws/config" <<'AWS_CONFIG' +[default] +region = us-east-1 +output = json +sso_session = example-sso +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[sso-session example-sso] +sso_start_url = https://example.awsapps.com/start/# +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +AWS_CONFIG + +cat > "$HOME/.bashrc.d/aws-sso-login.sh" <<'AWS_HOOK' +aws() { + local argument index + local -a profile_args=() + + for ((index = 1; index <= $#; index++)); do + argument="${!index}" + case "$argument" in + --profile) + if ((index == $#)); then + command aws "$@" + return + fi + index=$((index + 1)) + profile_args=(--profile "${!index}") + ;; + --profile=*) + profile_args=("$argument") + ;; + esac + done + + case " $* " in + *" sso login "*|*" sso logout "*|*" configure "*|*" --help "*|*" --version "*) + command aws "$@" + return + ;; + esac + + if ! command aws "${profile_args[@]}" sts get-caller-identity >/dev/null 2>&1; then + command aws "${profile_args[@]}" sso login + fi + + command aws "$@" +} +AWS_HOOK + +bashrc_hook="source \"$HOME/.bashrc.d/aws-sso-login.sh\"" +touch "$HOME/.bashrc" +grep -qxF "$bashrc_hook" "$HOME/.bashrc" || printf '\n%s\n' "$bashrc_hook" >> "$HOME/.bashrc" { - "containerEnv": {}, + "containerEnv": { + "HISTFILE": "/commandhistory/.bash_history", + "TF_PLUGIN_CACHE_DIR": "/opt/devcontainer-cache/terraform" + }, "customizations": { "vscode": { "extensions": [ + "EditorConfig.EditorConfig", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", + "4ops.terraform", + "redhat.vscode-yaml", + "openai.chatgpt", "Anthropic.claude-code", - "openai.chatgpt" + "ms-kubernetes-tools.vscode-kubernetes-tools" ] } }, "features": { - "ghcr.io/devcontainers/features/java:1": { - "installGradle": false, - "installMaven": false, - "version": "none" + "ghcr.io/devcontainers-extra/features/terraform-ls-asdf:2": {}, + "ghcr.io/devcontainers/features/aws-cli:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "helm": "latest", + "minikube": "none", + "version": "1.34" + }, + "ghcr.io/devcontainers/features/terraform:1": { + "terragrunt": "latest", + "tflint": "latest", + "version": "latest" } }, - "image": "mcr.microsoft.com/devcontainers/java:25", + "image": "mcr.microsoft.com/devcontainers/base:resolute", "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", "source=claude-code-config,target=/opt/claude-code-config,type=volume", "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", - "source=codex-config,target=/opt/.codex,type=volume" + "source=devcontainer-cache-terraform,target=/opt/devcontainer-cache/terraform,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" ], - "name": "java-example", - "postCreateCommand": "sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude &&\nsudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json &&\nln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -s /opt/claude-code-config/.claude $HOME/.claude &&\nsudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex &&\nbash .devcontainer/install.sh", - "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude*" + "name": "cloud-example", + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /opt/devcontainer-cache/terraform && sudo chown -R $USER:$USER /opt/devcontainer-cache/terraform && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" } #!/bin/bash +set -euo pipefail + +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder + +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +echo alias tf=terraform > ~/.bash_aliases -# This template provides a paved road for wrapping all devcontainer installers. +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh -# For example, to install claude-code CLI in every devcontainer, use: curl -fsSL https://claude.ai/install.sh | bash -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh +mkdir -p ~/.ssh -curl -sL -o cfr.jar 'https://www.benf.org/other/cfr/cfr-0.152.jar' +printf 'Host *\n User ec2-user\n IdentityFile %s/.ssh/id_rsa\n StrictHostKeyChecking no\n' "${containerWorkspaceFolder}" > ~/.ssh/config + +bash "${containerWorkspaceFolder}/.devcontainer/aws_configure.sh" +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false { - "containerEnv": {}, + "containerEnv": { + "HISTFILE": "/commandhistory/.bash_history", + "NPM_CONFIG_CACHE": "/opt/devcontainer-cache/npm" + }, "customizations": { "vscode": { "extensions": [ - "Anthropic.claude-code", + "EditorConfig.EditorConfig", + "redhat.vscode-yaml", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", "openai.chatgpt", - "aaron-bond.better-comments", + "Anthropic.claude-code" + ] + } + }, + "image": "mcr.microsoft.com/devcontainers/typescript-node:24", + "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", + "source=claude-code-config,target=/opt/claude-code-config,type=volume", + "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", + "source=devcontainer-cache-npm,target=/opt/devcontainer-cache/npm,type=volume", + "source=devcontainer-cache-pnpm,target=/opt/devcontainer-cache/pnpm,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" + ], + "name": "node-example", + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /opt/devcontainer-cache/npm /opt/devcontainer-cache/pnpm && sudo chown -R $USER:$USER /opt/devcontainer-cache/npm /opt/devcontainer-cache/pnpm && touch $HOME/.npmrc && (grep -qxF 'store-dir=/opt/devcontainer-cache/pnpm' $HOME/.npmrc || echo 'store-dir=/opt/devcontainer-cache/pnpm' >> $HOME/.npmrc) && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" +} +#!/bin/bash +set -euo pipefail + +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder + +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +npm install + +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh + +curl -fsSL https://claude.ai/install.sh | bash +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false +#!/bin/bash +set -euo pipefail + +mkdir -p "$HOME/.aws" "$HOME/.bashrc.d" + +cat > "$HOME/.aws/config" <<'AWS_CONFIG' +[default] +region = us-east-1 +output = json +sso_session = example-sso +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[sso-session example-sso] +sso_start_url = https://example.awsapps.com/start/# +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +AWS_CONFIG + +cat > "$HOME/.bashrc.d/aws-sso-login.sh" <<'AWS_HOOK' +aws() { + local argument index + local -a profile_args=() + + for ((index = 1; index <= $#; index++)); do + argument="${!index}" + case "$argument" in + --profile) + if ((index == $#)); then + command aws "$@" + return + fi + index=$((index + 1)) + profile_args=(--profile "${!index}") + ;; + --profile=*) + profile_args=("$argument") + ;; + esac + done + + case " $* " in + *" sso login "*|*" sso logout "*|*" configure "*|*" --help "*|*" --version "*) + command aws "$@" + return + ;; + esac + + if ! command aws "${profile_args[@]}" sts get-caller-identity >/dev/null 2>&1; then + command aws "${profile_args[@]}" sso login + fi + + command aws "$@" +} +AWS_HOOK + +bashrc_hook="source \"$HOME/.bashrc.d/aws-sso-login.sh\"" +touch "$HOME/.bashrc" +grep -qxF "$bashrc_hook" "$HOME/.bashrc" || printf '\n%s\n' "$bashrc_hook" >> "$HOME/.bashrc" +{ + "containerEnv": { + "ANSIBLE_COLLECTIONS_PATH": "/opt/devcontainer-cache/ansible/collections:/usr/share/ansible/collections", + "HISTFILE": "/commandhistory/.bash_history", + "PIP_CACHE_DIR": "/opt/devcontainer-cache/pip", + "UV_CACHE_DIR": "/opt/devcontainer-cache/uv" + }, + "customizations": { + "vscode": { + "extensions": [ + "EditorConfig.EditorConfig", + "redhat.vscode-yaml", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", "KevinRose.vsc-python-indent", - "mikestead.dotenv" + "mikestead.dotenv", + "openai.chatgpt", + "Anthropic.claude-code" ], "settings": { + "python.analysis.autoFormatStrings": true, "python.defaultInterpreterPath": "/usr/local/python/current/bin/python", "python.testing.pytestEnabled": false, "python.testing.unittestArgs": [ @@ -68,64 +296,140 @@ curl -sL -o cfr.jar 'https://www.benf.org/other/cfr/cfr-0.152.jar' }, "ghcr.io/devcontainers/features/python:1": { "installTools": true, - "version": 3.14 + "version": "3.10" }, "ghcr.io/larsnieuwenhuizen/features/jqyq:0": {} }, - "image": "mcr.microsoft.com/devcontainers/python:3.14", + "image": "mcr.microsoft.com/devcontainers/python:3.10", "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", "source=claude-code-config,target=/opt/claude-code-config,type=volume", "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", - "source=codex-config,target=/opt/.codex,type=volume" + "source=devcontainer-cache-pip,target=/opt/devcontainer-cache/pip,type=volume", + "source=devcontainer-cache-uv,target=/opt/devcontainer-cache/uv,type=volume", + "source=devcontainer-cache-ansible,target=/opt/devcontainer-cache/ansible,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" ], "name": "python-example", - "postAttachCommand": "pip install -r ${containerWorkspaceFolder}/requirements.txt", - "postCreateCommand": "sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude &&\nsudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json &&\nln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -s /opt/claude-code-config/.claude $HOME/.claude &&\nsudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex &&\nbash .devcontainer/install.sh", - "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude*" + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /opt/devcontainer-cache/pip /opt/devcontainer-cache/uv && sudo chown -R $USER:$USER /opt/devcontainer-cache/pip /opt/devcontainer-cache/uv && sudo mkdir -p /opt/devcontainer-cache/ansible/collections && sudo chown -R $USER:$USER /opt/devcontainer-cache/ansible && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" } #!/bin/bash +set -euo pipefail + +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder + +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata -# This template provides a paved road for wrapping all devcontainer installers. +if [ -f "${containerWorkspaceFolder}/requirements.txt" ] || [ -f "${containerWorkspaceFolder}/dev_requirements.txt" ]; then + set -- + [ -f "${containerWorkspaceFolder}/requirements.txt" ] && set -- "$@" -r "${containerWorkspaceFolder}/requirements.txt" + [ -f "${containerWorkspaceFolder}/dev_requirements.txt" ] && set -- "$@" -r "${containerWorkspaceFolder}/dev_requirements.txt" + pip install "$@" +fi + +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh -# For example, to install claude-code CLI in every devcontainer, use: curl -fsSL https://claude.ai/install.sh | bash -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh +mkdir -p ~/.ssh + +printf 'Host *\n User ec2-user\n IdentityFile %s/.ssh/id_rsa\n StrictHostKeyChecking no\n' "${containerWorkspaceFolder}" > ~/.ssh/config + +bash "${containerWorkspaceFolder}/.devcontainer/aws_configure.sh" sudo apt update + sudo apt install -y --no-install-recommends ansible +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false { - "containerEnv": {}, + "containerEnv": { + "HISTFILE": "/commandhistory/.bash_history" + }, "customizations": { "vscode": { "extensions": [ - "Anthropic.claude-code", - "openai.chatgpt" - ] + "EditorConfig.EditorConfig", + "redhat.vscode-yaml", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", + "openai.chatgpt", + "Anthropic.claude-code" + ], + "settings": { + "rust-analyzer.runnables.test.overrideCommand": [ + "cargo", + "nextest", + "run", + "--package", + "${package}", + "--release", + "--", + "${test_name}", + "${exact}", + "--nocapture", + "--include-ignored" + ] + } } }, "image": "mcr.microsoft.com/devcontainers/rust:2-1", "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", "source=claude-code-config,target=/opt/claude-code-config,type=volume", "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", - "source=codex-config,target=/opt/.codex,type=volume" + "source=devcontainer-cache-cargo-registry,target=/usr/local/cargo/registry,type=volume", + "source=devcontainer-cache-cargo-git,target=/usr/local/cargo/git,type=volume", + "source=devcontainer-cache-cargo-target-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/target,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" ], "name": "rust-example", - "postCreateCommand": "sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude &&\nsudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json &&\nln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -s /opt/claude-code-config/.claude $HOME/.claude &&\nsudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex &&\nbash .devcontainer/install.sh", - "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude*" + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /usr/local/cargo/registry /usr/local/cargo/git ${containerWorkspaceFolder}/target && sudo chown -R $USER:$USER /usr/local/cargo/registry /usr/local/cargo/git ${containerWorkspaceFolder}/target && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" } #!/bin/bash +set -euo pipefail -# This template provides a paved road for wrapping all devcontainer installers. +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder -# For example, to install claude-code CLI in every devcontainer, use: -curl -fsSL https://claude.ai/install.sh | bash +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +cargo install --locked cargo-nextest -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh + +curl -fsSL https://claude.ai/install.sh | bash sudo apt update -sudo -n apt-get install -y --no-install-recommends default-jre-headless + +sudo apt install -y --no-install-recommends default-jre-headless +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/tests/java-example/.devcontainer/devcontainer.json b/tests/java-example/.devcontainer/devcontainer.json deleted file mode 100644 index d52b28a..0000000 --- a/tests/java-example/.devcontainer/devcontainer.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "containerEnv": {}, - "customizations": { - "vscode": { - "extensions": [ - "Anthropic.claude-code", - "openai.chatgpt" - ] - } - }, - "features": { - "ghcr.io/devcontainers/features/java:1": { - "installGradle": false, - "installMaven": false, - "version": "none" - } - }, - "image": "mcr.microsoft.com/devcontainers/java:25", - "mounts": [ - "source=claude-code-config,target=/opt/claude-code-config,type=volume", - "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", - "source=codex-config,target=/opt/.codex,type=volume" - ], - "name": "java-example", - "postCreateCommand": "sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude &&\nsudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json &&\nln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -s /opt/claude-code-config/.claude $HOME/.claude &&\nsudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex &&\nbash .devcontainer/install.sh", - "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude*" -} diff --git a/tests/java-example/.devcontainer/install.sh b/tests/java-example/.devcontainer/install.sh deleted file mode 100755 index 1b6cb9b..0000000 --- a/tests/java-example/.devcontainer/install.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -# This template provides a paved road for wrapping all devcontainer installers. - -# For example, to install claude-code CLI in every devcontainer, use: -curl -fsSL https://claude.ai/install.sh | bash - -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh - -curl -sL -o cfr.jar 'https://www.benf.org/other/cfr/cfr-0.152.jar' diff --git a/tests/node-example/.devcontainer/devcontainer.json b/tests/node-example/.devcontainer/devcontainer.json new file mode 100644 index 0000000..45b86a8 --- /dev/null +++ b/tests/node-example/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +{ + "containerEnv": { + "HISTFILE": "/commandhistory/.bash_history", + "NPM_CONFIG_CACHE": "/opt/devcontainer-cache/npm" + }, + "customizations": { + "vscode": { + "extensions": [ + "EditorConfig.EditorConfig", + "redhat.vscode-yaml", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", + "openai.chatgpt", + "Anthropic.claude-code" + ] + } + }, + "image": "mcr.microsoft.com/devcontainers/typescript-node:24", + "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", + "source=claude-code-config,target=/opt/claude-code-config,type=volume", + "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", + "source=devcontainer-cache-npm,target=/opt/devcontainer-cache/npm,type=volume", + "source=devcontainer-cache-pnpm,target=/opt/devcontainer-cache/pnpm,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" + ], + "name": "node-example", + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /opt/devcontainer-cache/npm /opt/devcontainer-cache/pnpm && sudo chown -R $USER:$USER /opt/devcontainer-cache/npm /opt/devcontainer-cache/pnpm && touch $HOME/.npmrc && (grep -qxF 'store-dir=/opt/devcontainer-cache/pnpm' $HOME/.npmrc || echo 'store-dir=/opt/devcontainer-cache/pnpm' >> $HOME/.npmrc) && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" +} diff --git a/tests/node-example/.devcontainer/install.sh b/tests/node-example/.devcontainer/install.sh new file mode 100755 index 0000000..79c9937 --- /dev/null +++ b/tests/node-example/.devcontainer/install.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder + +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +npm install + +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh + +curl -fsSL https://claude.ai/install.sh | bash diff --git a/tests/node-example/.editorconfig b/tests/node-example/.editorconfig new file mode 100644 index 0000000..b5bd746 --- /dev/null +++ b/tests/node-example/.editorconfig @@ -0,0 +1,11 @@ +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/tests/python-example/.devcontainer/aws_configure.sh b/tests/python-example/.devcontainer/aws_configure.sh new file mode 100755 index 0000000..17fdda5 --- /dev/null +++ b/tests/python-example/.devcontainer/aws_configure.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p "$HOME/.aws" "$HOME/.bashrc.d" + +cat > "$HOME/.aws/config" <<'AWS_CONFIG' +[default] +region = us-east-1 +output = json +sso_session = example-sso +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[sso-session example-sso] +sso_start_url = https://example.awsapps.com/start/# +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +AWS_CONFIG + +cat > "$HOME/.bashrc.d/aws-sso-login.sh" <<'AWS_HOOK' +aws() { + local argument index + local -a profile_args=() + + for ((index = 1; index <= $#; index++)); do + argument="${!index}" + case "$argument" in + --profile) + if ((index == $#)); then + command aws "$@" + return + fi + index=$((index + 1)) + profile_args=(--profile "${!index}") + ;; + --profile=*) + profile_args=("$argument") + ;; + esac + done + + case " $* " in + *" sso login "*|*" sso logout "*|*" configure "*|*" --help "*|*" --version "*) + command aws "$@" + return + ;; + esac + + if ! command aws "${profile_args[@]}" sts get-caller-identity >/dev/null 2>&1; then + command aws "${profile_args[@]}" sso login + fi + + command aws "$@" +} +AWS_HOOK + +bashrc_hook="source \"$HOME/.bashrc.d/aws-sso-login.sh\"" +touch "$HOME/.bashrc" +grep -qxF "$bashrc_hook" "$HOME/.bashrc" || printf '\n%s\n' "$bashrc_hook" >> "$HOME/.bashrc" diff --git a/tests/python-example/.devcontainer/devcontainer.json b/tests/python-example/.devcontainer/devcontainer.json index fae902b..a3fda2f 100644 --- a/tests/python-example/.devcontainer/devcontainer.json +++ b/tests/python-example/.devcontainer/devcontainer.json @@ -1,15 +1,25 @@ { - "containerEnv": {}, + "containerEnv": { + "ANSIBLE_COLLECTIONS_PATH": "/opt/devcontainer-cache/ansible/collections:/usr/share/ansible/collections", + "HISTFILE": "/commandhistory/.bash_history", + "PIP_CACHE_DIR": "/opt/devcontainer-cache/pip", + "UV_CACHE_DIR": "/opt/devcontainer-cache/uv" + }, "customizations": { "vscode": { "extensions": [ - "Anthropic.claude-code", - "openai.chatgpt", - "aaron-bond.better-comments", + "EditorConfig.EditorConfig", + "redhat.vscode-yaml", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", "KevinRose.vsc-python-indent", - "mikestead.dotenv" + "mikestead.dotenv", + "openai.chatgpt", + "Anthropic.claude-code" ], "settings": { + "python.analysis.autoFormatStrings": true, "python.defaultInterpreterPath": "/usr/local/python/current/bin/python", "python.testing.pytestEnabled": false, "python.testing.unittestArgs": [ @@ -29,18 +39,21 @@ }, "ghcr.io/devcontainers/features/python:1": { "installTools": true, - "version": 3.14 + "version": "3.10" }, "ghcr.io/larsnieuwenhuizen/features/jqyq:0": {} }, - "image": "mcr.microsoft.com/devcontainers/python:3.14", + "image": "mcr.microsoft.com/devcontainers/python:3.10", "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", "source=claude-code-config,target=/opt/claude-code-config,type=volume", "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", - "source=codex-config,target=/opt/.codex,type=volume" + "source=devcontainer-cache-pip,target=/opt/devcontainer-cache/pip,type=volume", + "source=devcontainer-cache-uv,target=/opt/devcontainer-cache/uv,type=volume", + "source=devcontainer-cache-ansible,target=/opt/devcontainer-cache/ansible,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" ], "name": "python-example", - "postAttachCommand": "pip install -r ${containerWorkspaceFolder}/requirements.txt", - "postCreateCommand": "sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude &&\nsudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json &&\nln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -s /opt/claude-code-config/.claude $HOME/.claude &&\nsudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex &&\nbash .devcontainer/install.sh", - "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude*" + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /opt/devcontainer-cache/pip /opt/devcontainer-cache/uv && sudo chown -R $USER:$USER /opt/devcontainer-cache/pip /opt/devcontainer-cache/uv && sudo mkdir -p /opt/devcontainer-cache/ansible/collections && sudo chown -R $USER:$USER /opt/devcontainer-cache/ansible && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" } diff --git a/tests/python-example/.devcontainer/install.sh b/tests/python-example/.devcontainer/install.sh index 7191bb0..6345b2c 100755 --- a/tests/python-example/.devcontainer/install.sh +++ b/tests/python-example/.devcontainer/install.sh @@ -1,13 +1,31 @@ #!/bin/bash +set -euo pipefail -# This template provides a paved road for wrapping all devcontainer installers. +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder + +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +if [ -f "${containerWorkspaceFolder}/requirements.txt" ] || [ -f "${containerWorkspaceFolder}/dev_requirements.txt" ]; then + set -- + [ -f "${containerWorkspaceFolder}/requirements.txt" ] && set -- "$@" -r "${containerWorkspaceFolder}/requirements.txt" + [ -f "${containerWorkspaceFolder}/dev_requirements.txt" ] && set -- "$@" -r "${containerWorkspaceFolder}/dev_requirements.txt" + pip install "$@" +fi + +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh -# For example, to install claude-code CLI in every devcontainer, use: curl -fsSL https://claude.ai/install.sh | bash -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh +mkdir -p ~/.ssh + +printf 'Host *\n User ec2-user\n IdentityFile %s/.ssh/id_rsa\n StrictHostKeyChecking no\n' "${containerWorkspaceFolder}" > ~/.ssh/config + +bash "${containerWorkspaceFolder}/.devcontainer/aws_configure.sh" sudo apt update + sudo apt install -y --no-install-recommends ansible diff --git a/tests/python-example/.editorconfig b/tests/python-example/.editorconfig new file mode 100644 index 0000000..b5bd746 --- /dev/null +++ b/tests/python-example/.editorconfig @@ -0,0 +1,11 @@ +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/tests/rust-example/.devcontainer/devcontainer.json b/tests/rust-example/.devcontainer/devcontainer.json index a5f532c..346d117 100644 --- a/tests/rust-example/.devcontainer/devcontainer.json +++ b/tests/rust-example/.devcontainer/devcontainer.json @@ -1,20 +1,46 @@ { - "containerEnv": {}, + "containerEnv": { + "HISTFILE": "/commandhistory/.bash_history" + }, "customizations": { "vscode": { "extensions": [ - "Anthropic.claude-code", - "openai.chatgpt" - ] + "EditorConfig.EditorConfig", + "redhat.vscode-yaml", + "timonwong.shellcheck", + "usernamehw.errorlens", + "github.vscode-github-actions", + "openai.chatgpt", + "Anthropic.claude-code" + ], + "settings": { + "rust-analyzer.runnables.test.overrideCommand": [ + "cargo", + "nextest", + "run", + "--package", + "${package}", + "--release", + "--", + "${test_name}", + "${exact}", + "--nocapture", + "--include-ignored" + ] + } } }, "image": "mcr.microsoft.com/devcontainers/rust:2-1", "mounts": [ + "source=codex-config,target=/opt/.codex,type=volume", + "source=agents-config,target=/opt/.agents,type=volume", "source=claude-code-config,target=/opt/claude-code-config,type=volume", "source=claude-scope-project-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/.claude,type=volume", - "source=codex-config,target=/opt/.codex,type=volume" + "source=devcontainer-cache-cargo-registry,target=/usr/local/cargo/registry,type=volume", + "source=devcontainer-cache-cargo-git,target=/usr/local/cargo/git,type=volume", + "source=devcontainer-cache-cargo-target-${localWorkspaceFolderBasename},target=${containerWorkspaceFolder}/target,type=volume", + "source=devcontainer-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" ], "name": "rust-example", - "postCreateCommand": "sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude &&\nsudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json &&\nln -s /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -s /opt/claude-code-config/.claude $HOME/.claude &&\nsudo chown $USER:$USER /opt/.codex && ln -s /opt/.codex $HOME/.codex &&\nbash .devcontainer/install.sh", - "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude*" + "postCreateCommand": "sudo chown $USER:$USER /opt/.codex && ln -sfn /opt/.codex $HOME/.codex && sudo chown $USER:$USER /opt/.agents && ln -sfn /opt/.agents $HOME/.agents && sudo chown $USER:$USER ${containerWorkspaceFolder}/.claude && sudo chown $USER:$USER /opt/claude-code-config && mkdir -p /opt/claude-code-config/.claude && touch /opt/claude-code-config/.claude.json && ln -sfn /opt/claude-code-config/.claude.json $HOME/.claude.json && ln -sfn /opt/claude-code-config/.claude $HOME/.claude && sudo mkdir -p /usr/local/cargo/registry /usr/local/cargo/git ${containerWorkspaceFolder}/target && sudo chown -R $USER:$USER /usr/local/cargo/registry /usr/local/cargo/git ${containerWorkspaceFolder}/target && sudo mkdir -p /commandhistory && sudo touch /commandhistory/.bash_history && sudo chown -R $USER:$USER /commandhistory && bash \"${containerWorkspaceFolder}/.devcontainer/install.sh\"" } diff --git a/tests/rust-example/.devcontainer/install.sh b/tests/rust-example/.devcontainer/install.sh index a097677..bc3e9be 100755 --- a/tests/rust-example/.devcontainer/install.sh +++ b/tests/rust-example/.devcontainer/install.sh @@ -1,13 +1,20 @@ #!/bin/bash +set -euo pipefail -# This template provides a paved road for wrapping all devcontainer installers. +DEVCONTAINER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +containerWorkspaceFolder="$(dirname -- "$DEVCONTAINER_DIR")" +export containerWorkspaceFolder -# For example, to install claude-code CLI in every devcontainer, use: -curl -fsSL https://claude.ai/install.sh | bash +sudo apt update && sudo apt install -y ripgrep + +sudo ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime && sudo dpkg-reconfigure -f noninteractive tzdata + +cargo install --locked cargo-nextest -# And Codex: -export CODEX_NON_INTERACTIVE=true -curl -fsSL https://chatgpt.com/codex/install.sh | sh +curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh + +curl -fsSL https://claude.ai/install.sh | bash sudo apt update -sudo -n apt-get install -y --no-install-recommends default-jre-headless + +sudo apt install -y --no-install-recommends default-jre-headless diff --git a/tests/rust-example/.editorconfig b/tests/rust-example/.editorconfig new file mode 100644 index 0000000..b5bd746 --- /dev/null +++ b/tests/rust-example/.editorconfig @@ -0,0 +1,11 @@ +# Managed by devcontainer-sync. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false