Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,25 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: "shellcheck"
run: shellcheck -x src/*/*.sh test/*/*.sh bootstrap/*.sh .githooks/*
run: shellcheck -x src/*/*.sh src/*/share/bin/*.sh src/*/share/jobs/*/*.sh test/*/*.sh bootstrap/*.sh .githooks/*

# Feature folders must be self-contained when published, so the
# src/common helpers are vendored into the Features that use them.
- name: "Vendored copies of src/common helpers are identical"
run: |
rc=0
for canon in src/common/*.sh; do
name="$(basename "$canon")"
[ "$name" = install.sh ] && continue
for copy in src/*/"$name"; do
[ "$copy" = "$canon" ] && continue
cmp -s "$canon" "$copy" || { echo "::error::$copy differs from $canon - run task common:sync"; rc=1; }
done
done
exit "$rc"

- name: "devhost Python unit tests"
run: python3 -m unittest discover -s test/devhost/python -q

- name: "yamllint"
run: |
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ jobs:
- kubeconform
- argocd
- dsc
- git
- common
- gh
- az
- kube-state
- devhost
baseImage:
- debian:latest
- ubuntu:latest
Expand Down Expand Up @@ -63,6 +69,12 @@ jobs:
- kubeconform
- argocd
- dsc
- git
- common
- gh
- az
- kube-state
- devhost
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
# Task's remote-include cache and checksums
.task/
*.ignore

# Python bytecode from the devhost unit tests
__pycache__/

# Local environment files (secrets); a .env.example stays trackable.
.env
.env.*
!.env.example
65 changes: 55 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,36 @@ reach anyone who references them. The rules below keep that safe.

## Proposing a new Feature

Open a "New Feature proposal" issue first. We accept Features that install
one well-defined tool from a trustworthy, checksummed source and that do not
already exist in [devcontainers/features](https://github.com/devcontainers/features)
(a checksum-verified alternative to an unverified community Feature is fine).
Open a "New Feature proposal" issue first. We accept two kinds of Feature:

- **Installers**: one well-defined tool from a trustworthy, checksummed
source that does not already exist in
[devcontainers/features](https://github.com/devcontainers/features) (a
checksum-verified alternative to an unverified community Feature is fine).
- **Augmentations**: the ecosystem pieces around a tool that an official
Feature (or the base image) already installs - its persistent state, a
launcher, a post-create hook, a wrapper, a Taskfile - such as
`claude-code`, `git`, `gh` and `az`. These carry the same id as the tool,
order themselves after the official Feature with `installsAfter` (never
`dependsOn`, which would force that Feature's install method on everyone),
and install the tool themselves only when it is absent, verified and
pinnable. Wrappers are opt-in by option, generic (no repository paths or
names), and fast (every call goes through them).

A Feature whose tool keeps state (a login, a config dir, transcripts) owns
that state: `containerEnv` sets the tool's directory variable to
`/var/lib/devcontainer-features/<id>`, `mounts` declares a named volume
`<id>-config-${devcontainerId}` on that target (the pattern of the official
docker-in-docker Feature), and the `<id>-setup` hook calls
`dcf-persist --label <id> "$VAR"` so the directory belongs to the remote
user and the post-create log says what backs it. A repo overrides the volume
with a host bind mount on the same target (its mount wins). Feature metadata
is static, so an option can never change a mount or a variable; `install.sh`
must not write into the target, which is not mounted at build time.

Something a consuming repo scripted in its own `.devcontainer/` is the usual
source of an augmentation; it qualifies once a second repo wants it, or once
it is clearly general.

## Feature development conventions

Expand All @@ -39,8 +65,10 @@ them. Copy an existing Feature folder and keep to these rules.

```
src/<id>/
devcontainer-feature.json id, version, name, description, options, installsAfter
devcontainer-feature.json id, version, name, description, options, installsAfter, hooks
install.sh the installer (bash, runs as root at image build)
<name>.sh scripts the installer puts on PATH (as <name>, no extension)
dcf-persist.sh verbatim copy of src/common/dcf-persist.sh (task common:sync; lint checks it)
taskfile.yml tasks for the tool, namespaced <id>:
NOTES.md hand-written docs folded into README.md
README.md generated by `task docs` - never edit
Expand All @@ -53,13 +81,30 @@ test/<id>/
### `devcontainer-feature.json`

- `id` equals the folder name and the binary's commonly known name.
- Every Feature has the `version` option (`latest` or a pinned release,
with or without a leading `v`) and the `installTaskfile` boolean (default
`true`).
- Every installer Feature has the `version` option (`latest` or a pinned
release, with or without a leading `v`); every Feature has the
`installTaskfile` boolean (default `true`).
- `installsAfter` lists `ghcr.io/nabil-becker/devcontainer-features/go-task`
and `ghcr.io/devcontainers/features/common-utils`, plus anything the tool
genuinely needs ordered before it. Use `dependsOn` only for hard
requirements.
genuinely needs ordered before it, and for an augmentation the official
Feature it augments. Use `dependsOn` only for hard requirements. Both may
only name Features that are already published: the CLI resolves every
entry against the registry, and an unpublished one fails the build (so a
hook that needs another Feature's work done first, such as gh writing into
git's global config, makes itself sufficient instead of relying on order).
- A Feature that must do something per container (make a mounted folder
usable, mark a repo safe) declares `"postCreateCommand": "<id>-setup"`
and installs that script on PATH; the hook runs as the remote user from
the workspace folder, before the repo's own `postCreateCommand`. The same
script is `task <id>:setup`. It is idempotent and never fatal: a problem
is reported and the rest of post-create goes on. Features cannot set
`remoteEnv`, only `containerEnv`; a repo's own `containerEnv` wins over
the Feature's, which is how a repo points a tool elsewhere.
- Helpers shared between Features live in `src/common/` and are vendored
verbatim into each Feature that uses them (`task common:sync`): a
published Feature folder must be self-contained, and `dependsOn` on a
Feature of this collection cannot be resolved by `devcontainer features
test` before it is released.
- `documentationURL` and `licenseURL` point at this repository.
- Bump `version` per [semver](https://semver.org/) in the same PR as any
change to the folder.
Expand Down
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ installing Node.js, and a one-line bootstrap that wires both into any repo.
| --- | --- |
| [`go-task`](src/go-task) | [Task](https://taskfile.dev) (`task`), sha256-verified from GitHub releases, with bash/zsh completions. |
| [`devcontainer-cli`](src/devcontainer-cli) | `@devcontainers/cli` as `devcontainer`, on a private Node.js under `/opt/devcontainer-cli` that is never put on PATH. |
| [`claude-code`](src/claude-code) | [Claude Code](https://code.claude.com/docs) as its native release binary (no Node.js), auto-updater off, version pinnable. |
| [`claude-code`](src/claude-code) | [Claude Code](https://code.claude.com/docs): the native release binary (no Node.js, version pinnable) unless a `claude` is already there (the official Anthropic Feature), plus the `claude-host` launcher and its state (`CLAUDE_CONFIG_DIR`) in a volume it owns. |
| [`git`](src/git) | git augmented for the container: a worktree-aware wrapper that scopes `GIT_DIR` to the checkout, `safe.directory` and `core.hooksPath` at post-create, the global config in a volume it owns. |
| [`gh`](src/gh) | [GitHub CLI](https://cli.github.com) from GitHub releases, checksum-verified, unless the official Feature installed it; its login (`GH_CONFIG_DIR`) in a volume it owns, git's GitHub credentials taken from it. |
| [`az`](src/az) | Augments the official `azure-cli` Feature: its login (`AZURE_CONFIG_DIR`) in a volume it owns, `az bicep` using the Bicep on PATH. |
| [`kube-state`](src/kube-state) | Owns the kubeconfig (`KUBECONFIG`) the Kubernetes tools share, in a volume it owns, so velero, kustomize, argocd and kubectl stop needing a hand-mounted `~/.kube`; installs no binary. |
| [`devhost`](src/devhost) | The way of working as a Feature: one host Claude Code session per repo, backs that keep the ledger, backlog and docs from Claude Code hooks, the session briefing, cost report, ADR tool, verify guards, and `devhost-init` to scaffold a repo. |
| [`common`](src/common) | The shared helper behind every Feature's post-create hook (`dcf-persist`: make a state directory usable and say what backs it), for a repo's own scripts too. |
| [`coder-cli`](src/coder-cli) | [Coder](https://coder.com) CLI from GitHub releases, verified against the release checksums. |
| [`openbao`](src/openbao) | [OpenBao](https://openbao.org) CLI (`bao`), verified against the release checksums. |
| [`velero`](src/velero) | [Velero](https://velero.io) CLI, verified against the release `CHECKSUM`. |
Expand All @@ -19,8 +25,31 @@ installing Node.js, and a one-line bootstrap that wires both into any repo.
| [`argocd`](src/argocd) | [Argo CD](https://argo-cd.readthedocs.io) CLI, verified against the release `cli_checksums.txt`. |
| [`dsc`](src/dsc) | [Microsoft DSC v3](https://github.com/PowerShell/DSC) in `/opt/dsc`, verified against the GitHub-published asset digest (or a pinned `sha256`). |

Every Feature takes a `version` option (`latest` or a pinned release) and
verifies what it downloads before installing it.
Every Feature that installs a tool takes a `version` option (`latest` or a
pinned release) and verifies what it downloads before installing it.

## How the pieces fit

The collection is an ecosystem, not a bag of installers: every repo that uses
it gets the same shape, and anything a repo has to script around is a gap to
close here.

- **Task is the API.** Inside the container, every Feature contributes
`<id>:*` tasks through the go-task registry. Documents and skills name
tasks, never the scripts behind them.
- **Augment, don't duplicate.** When an official Feature exists for a tool
(Anthropic's `claude-code`, `devcontainers/features/git`), this collection's
Feature of the same id orders itself after it (`installsAfter`) and adds
what the ecosystem needs around it: a launcher, a post-create hook, a
Taskfile. It installs the tool itself only when nothing else did, and then
verified and pinnable. Wrappers are opt-in and generic (no repo paths).
- **Each Feature owns its state.** A Feature that has state (logins,
transcripts, tool config) declares the variable that points at it and a
named volume on `/var/lib/devcontainer-features/<id>`, like the official
docker-in-docker Feature does, and its post-create hook makes the directory
usable by the remote user (`dcf-persist`, from `common`). Nothing to
configure. A repo that wants that state on the host disk bind-mounts the
same target; its mount wins.

### Feature Taskfiles

Expand All @@ -47,6 +76,7 @@ Add a Feature and rebuild: its tasks appear. Remove it: they disappear.
"features": {
"ghcr.io/nabil-becker/devcontainer-features/go-task:1": {},
"ghcr.io/nabil-becker/devcontainer-features/devcontainer-cli:1": {},
"ghcr.io/nabil-becker/devcontainer-features/git:1": { "hooksPath": ".githooks" },
"ghcr.io/nabil-becker/devcontainer-features/claude-code:1": {},
"ghcr.io/nabil-becker/devcontainer-features/kustomize:1": { "version": "5.8.3" }
}
Expand Down
55 changes: 51 additions & 4 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,26 @@ tasks:
- task --list

test:
desc: 'Run `devcontainer features test` for one Feature (FEATURE=go-task) or all of them. IMAGE overrides the base image for the auto-generated test; SCENARIOS=false skips scenarios.json. Linux/macOS only (the CLI shells out to chmod/bash on the host) - run it inside this repo''s devcontainer (docker-in-docker) or let CI do it.'
desc: 'Run `devcontainer features test` for one Feature (FEATURE=go-task) or all of them. IMAGE overrides the base image for the auto-generated test; SCENARIOS=false skips scenarios.json. Needs a Docker daemon (CI, or Docker Desktop on the workstation). Linux/macOS only (the CLI shells out to chmod/bash on the host).'
platforms: [linux, darwin]
vars:
FEATURE: '{{.FEATURE | default ""}}'
SCENARIOS: '{{.SCENARIOS | default "true"}}'
preconditions:
- sh: docker info >/dev/null 2>&1
msg: 'no Docker daemon here; Feature tests run in CI on every PR, or locally with Docker Desktop on the workstation'
cmds:
- task: devcontainer:cli
vars:
SUBCOMMAND: features
ARGS: 'test -p "{{.ROOT_DIR}}" -i {{.TEST_IMAGE}} --skip-duplicated{{if .FEATURE}} -f {{.FEATURE}}{{end}}{{if ne .SCENARIOS "true"}} --skip-scenarios{{end}} {{.CLI_ARGS}}'

test:global:
desc: 'Run only test/_global scenarios (Features combined). Linux/macOS only, like test.'
desc: 'Run only test/_global scenarios (Features combined). Needs a Docker daemon (CI, or Docker Desktop on the workstation). Linux/macOS only, like test.'
platforms: [linux, darwin]
preconditions:
- sh: docker info >/dev/null 2>&1
msg: 'no Docker daemon here; Feature tests run in CI on every PR, or locally with Docker Desktop on the workstation'
cmds:
- task: devcontainer:cli
vars:
Expand Down Expand Up @@ -70,11 +76,52 @@ tasks:
echo "generated READMEs are up to date"

lint:
desc: 'shellcheck every shell script, yamllint every YAML file (.yamllint), PSScriptAnalyzer every PowerShell file (same checks as CI; the tools must be installed - this repo''s devcontainer has them).'
desc: 'shellcheck every shell script, yamllint every YAML file (.yamllint), PSScriptAnalyzer every PowerShell file, and the vendored copies of src/common/*.sh identical to the canonical ones (same checks as CI; the tools must be installed - this repo''s devcontainer has them).'
cmds:
- shellcheck -x src/*/*.sh test/*/*.sh bootstrap/*.sh .githooks/*
- shellcheck -x src/*/*.sh src/*/share/bin/*.sh src/*/share/jobs/*/*.sh test/*/*.sh bootstrap/*.sh .githooks/*
- yamllint --strict .
- pwsh -NoLogo -NoProfile -File '{{.ROOT_DIR}}/.github/scripts/Invoke-PSScriptAnalyzer.ps1'
- task: common:check
- task: test:devhost

test:devhost:
desc: 'Unit tests of the devhost Feature''s Python tooling (session cost, guards, verify, adr, init, briefing), stdlib only, against temp repos; runs on any host with python3 and git, so it is part of lint.'
cmds:
- cmd: python -m unittest discover -s test/devhost/python -q
platforms: [windows]
- cmd: python3 -m unittest discover -s test/devhost/python -q
platforms: [linux, darwin]

common:check:
desc: 'Fail if a Feature''s copy of a src/common helper (dcf-persist.sh) differs from the canonical file. Run `task common:sync` to refresh the copies.'
silent: true
cmds:
- cmd: |
rc=0
for canon in src/common/*.sh; do
name="$(basename "$canon")"
[ "$name" = install.sh ] && continue
for copy in src/*/"$name"; do
[ "$copy" = "$canon" ] && continue
if ! cmp -s "$canon" "$copy"; then echo "stale copy: $copy differs from $canon (task common:sync)"; rc=1; fi
done
done
[ "$rc" -eq 0 ] && echo "common helper copies are up to date"
exit "$rc"

common:sync:
desc: 'Copy every src/common/*.sh helper over the copies the other Features carry (a Feature folder must be self-contained when published, so the helper is vendored rather than depended on).'
silent: true
cmds:
- cmd: |
for canon in src/common/*.sh; do
name="$(basename "$canon")"
[ "$name" = install.sh ] && continue
for copy in src/*/"$name"; do
[ "$copy" = "$canon" ] && continue
cp "$canon" "$copy" && echo "synced $copy"
done
done

repo:setup:
desc: 'Once per clone: activate the tracked .gitconfig (git config --local include.path ../.gitconfig), which turns on the repo hooks in .githooks/ (auto DCO sign-off) and the `git cs` alias. Touches only this clone''s .git/config, never your global config. Idempotent.'
Expand Down
24 changes: 20 additions & 4 deletions src/argocd/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,30 @@ move together.
## Keep the login across rebuilds

`argocd login` stores its session under `ARGOCD_CONFIG_DIR` (default
`~/.config/argocd`). Point it at a persisted folder:
`~/.config/argocd`). By default the Feature sets
`ARGOCD_CONFIG_DIR=/var/lib/devcontainer-features/argocd` in `containerEnv`
and mounts the named volume `argocd-config-${devcontainerId}` on that
folder, so the session survives rebuilds. Its `postCreateCommand` hook
`argocd-setup` (also `task argocd:setup`; idempotent, never fatal) hands the
folder to the remote user through the shared `dcf-persist` helper and
reports what backs it (`volume`, `bind` or `image`), whether it is writable
and whether a login is present (a `contexts` entry in `config`; no token is
printed). A container-engine storage reset or a move to another engine
deletes the volume, and `${devcontainerId}` changes when the checkout moves,
leaving the old volume behind.

For the host disk, a repo bind-mounts a host folder on the same target (a
repo's `mounts` merge after the Features', so its mount wins) and creates it
before the first start:

```json
"containerEnv": {
"ARGOCD_CONFIG_DIR": "${containerWorkspaceFolder}/.devcontainer/env_mnt/argocd"
}
"initializeCommand": ["task", "devcontainer:homes", "NAME=myrepo", "--", "argocd"],
"mounts": ["source=${localEnv:USERPROFILE}/.devcontainer-homes/myrepo/argocd,target=/var/lib/devcontainer-features/argocd,type=bind"]
```

A repo's own `containerEnv` wins over the Feature's, which is how it points
`ARGOCD_CONFIG_DIR` somewhere else entirely.

## Why not `ghcr.io/devcontainers-extra/features/argo-cd`?

That Feature resolves the binary through `nanolayer` and the generic
Expand Down
Loading
Loading