Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
f38a74b
feat: add dev container feature with bats test submodules
coakenfold Apr 22, 2026
93461e3
feat: add curl installation for uv installer and improve pip install …
coakenfold Apr 23, 2026
7738c9e
feat: add generic checks for apm binary in dev container
coakenfold Apr 23, 2026
dac703c
feat: add platform-specific checks for Alpine, Debian, Fedora, and Ub…
coakenfold Apr 23, 2026
372b895
feat: update pinned-version.sh to include scenario-specific checks fo…
coakenfold Apr 23, 2026
b190312
refactor: remove specific apm checks from test script and source gene…
coakenfold Apr 23, 2026
0a62503
feat: update python feature checks to ensure minimum version and simp…
coakenfold Apr 23, 2026
8e55df2
feat: simplify scenarios.json by removing redundant scenario descript…
coakenfold Apr 23, 2026
bfd2148
feat: enhance install tests by refining package manager stubs and imp…
coakenfold Apr 23, 2026
e6c65dc
feat: add local devcontainer configuration for APM development enviro…
coakenfold Apr 23, 2026
0349f28
feat: add comprehensive overview for APM Dev Container Feature
coakenfold Apr 23, 2026
4530135
refactor: renaming devcontainer dir
coakenfold Apr 23, 2026
b5fab35
feat: add BATS testing framework submodules
coakenfold Apr 23, 2026
c207e05
chore: delete test `.devcontainer` dir
coakenfold Apr 23, 2026
a64a243
refactor: file rename
coakenfold Apr 23, 2026
696b9d7
chore: delete dev docs
coakenfold Apr 23, 2026
37894d5
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 23, 2026
5a25a5c
fix: drop non-ascii characters
coakenfold Apr 23, 2026
1620fc0
fix: copilot pr suggestions, ascii chars, and useless assertion
coakenfold Apr 23, 2026
c9dbd51
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 23, 2026
0bd6c49
chore: update ref to original feature
coakenfold Apr 23, 2026
c28f45b
fix: optimize apt-get update calls in install script
coakenfold Apr 23, 2026
a5eb76a
chore: update doc url
coakenfold Apr 23, 2026
f1df272
fix: update error messages in install script and tests for consistency
coakenfold Apr 23, 2026
9726d0b
fix: update grep command in pinned-version.sh for exact match
coakenfold Apr 23, 2026
42f94a5
fix: improve output handling in install_apm function for better error…
coakenfold Apr 23, 2026
6a7c72c
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 23, 2026
249c585
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 23, 2026
db6cab2
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 24, 2026
5dbecc9
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 24, 2026
63f472b
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 24, 2026
7ce6dae
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 24, 2026
a7b331b
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 24, 2026
70cb49d
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 24, 2026
799b4b7
Merge branch 'main' into feat/717-dev-container
danielmeppiel Apr 24, 2026
442887e
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 25, 2026
f38fc30
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 25, 2026
655df9f
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 26, 2026
b3808b7
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 27, 2026
35ca5cc
Merge branch 'main' into feat/717-dev-container
coakenfold Apr 28, 2026
cb2006a
Merge branch 'main' into feat/717-dev-container
danielmeppiel Apr 28, 2026
62cf31d
feat(devcontainer): publish apm-cli feature to GHCR with CI gates
danielmeppiel Apr 28, 2026
5ecff94
Merge branch 'main' into feat/717-dev-container
danielmeppiel Apr 28, 2026
c6a2b67
fix(devcontainer): bats sandbox needs busybox /bin; pinned-version us…
danielmeppiel Apr 28, 2026
fb1cd8a
Merge branch 'main' into feat/717-dev-container
danielmeppiel Apr 28, 2026
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
9 changes: 9 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[submodule "devcontainer/test/bats"]
path = devcontainer/test/bats
url = https://github.com/bats-core/bats-core
[submodule "devcontainer/test/test_helper/bats-support"]
path = devcontainer/test/test_helper/bats-support
url = https://github.com/bats-core/bats-support
[submodule "devcontainer/test/test_helper/bats-assert"]
path = devcontainer/test/test_helper/bats-assert
url = https://github.com/bats-core/bats-assert
279 changes: 279 additions & 0 deletions devcontainer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
# APM Dev Container Feature -- Overview

A comprehensive reference for the APM (Agent Package Manager) Dev Container Feature: what it does, how it's structured, how to use it, how it's tested, and where it's supported.

---

## 1. Feature Overview

The APM Dev Container Feature packages the `apm-cli` tool as a reusable, declarative unit that can be added to any project's `devcontainer.json`. It eliminates the need for manual `postCreateCommand` installs and makes APM discoverable through the standard [Dev Container Features ecosystem](https://containers.dev/features).

**What it installs**

- [uv](https://github.com/astral-sh/uv) -- Astral's fast Python tool (installed to `/usr/local/bin`)
- Python 3.10+ -- only if not already present
- `git` -- required by `apm-cli` (uses GitPython at startup)
- `apm-cli` -- installed via `pip` (with automatic PEP 668 fallback)

**What motivated it**

- APM was previously only installable via ad-hoc `postCreateCommand` lines -- not reusable, not discoverable, hard to standardise.
- See GitHub issue [#717](https://github.com/microsoft/apm/issues/717) for the original feature request.

**Options**

| Option | Type | Default | Description |
| --------- | ------ | -------- | -------------------------------------------------------------------- |
| `version` | string | `latest` | Version of `apm-cli` to install. `latest`, or a semver like `1.2.3`. |

The feature declares `installsAfter: ghcr.io/devcontainers/features/python` so the official Python feature (when present) runs first and provides Python.

---

## 2. `devcontainer` Directory Structure

```
devcontainer/
+-- src/
| \-- apm/
| +-- devcontainer-feature.json # Feature manifest (id, options, metadata)
| \-- install.sh # Install script executed inside the container
\-- test/
+-- apm/
| +-- scenarios.json # Integration test matrix (base image x options)
| +-- generic-checks.sh # Shared post-install checks (apm on PATH, --version, --help)
| +-- default-ubuntu-24.sh # Ubuntu 24.04 scenario (PEP 668 path)
| +-- default-debian-12.sh # Debian 12 scenario (apt-get path)
| +-- default-alpine-3.sh # Alpine 3.20 scenario (apk path)
| +-- default-fedora.sh # Fedora 41 scenario (dnf path)
| +-- pinned-version.sh # Confirms `version: "0.8.11"` option is honoured
| +-- with-python-feature.sh # Confirms compatibility with the Python feature
| +-- test.sh # Fallback "auto" test (currently unused)
| \-- unit/
| \-- install.bats # Bats unit tests for install.sh (37 tests)
+-- bats/ # git submodule -- bats-core runner
\-- test_helper/ # git submodules -- bats-support, bats-assert
```

---

## 3. How It Works

### Manifest

[src/apm/devcontainer-feature.json](src/apm/devcontainer-feature.json) declares the feature id (`apm`), its options, and `installsAfter`. The devcontainer CLI reads this to understand how to build an image that consumes the feature.

### Install flow

When a devcontainer is built, the CLI injects each option as an uppercased environment variable (e.g. `VERSION`) and runs [src/apm/install.sh](src/apm/install.sh) as root. The script:

1. **Validates `VERSION`** -- accepts `latest` or a strict semver `X.Y.Z`; otherwise exits `1`.
2. **Verifies it is running as root** -- fails with a clear message otherwise.
3. **Installs `uv`** (idempotent) -- installs `curl` first via the detected package manager if needed, downloads `https://astral.sh/uv/install.sh`, and runs it with `UV_INSTALL_DIR=/usr/local/bin`. The installer temp file is cleaned up via `trap` on exit.
4. **Ensures Python 3.10+ is present** -- if `python3` is missing, installs `python3`, `python3-pip`, and `git` using `apt-get`, `apk`, or `dnf`. Then asserts `python3 --version` is >= 3.10.
5. **Ensures `git` is present** -- installs via the detected package manager if absent.
6. **Locates a working `pip`** -- prefers `pip3`, falls back to `pip`, then bootstraps via `python3 -m ensurepip --upgrade`.
7. **Installs `apm-cli`** -- `pip install apm-cli` (or `apm-cli==<version>` when pinned). On Ubuntu 24.04+ `pip` rejects the install under PEP 668 ("externally-managed-environment"); the script detects that specific error and retries with `--break-system-packages`.
8. **Adds `bash` on Alpine** -- required because devcontainer test scripts use `#!/bin/bash`.
9. **Verifies `apm` is on `PATH`** -- prints the installed version and path. If it isn't on `PATH`, prints a warning (not a failure).

### Compatibility with the official Python feature

`installsAfter` guarantees that if a user also declares `ghcr.io/devcontainers/features/python`, Python is already present when `install.sh` runs -- the script then detects `python3` and skips the distro package-manager branch.

---

## 4. How to use `devcontainer` in your project

### Option A -- test it locally

Recent versions of the Dev Containers CLI (bundled with `ms-vscode-remote.remote-containers` >= 0.454.0) enforce that a local Feature path must resolve **inside** the `.devcontainer/` folder. An upward `../devcontainer/src/apm` path -- and symlinks pointing outside `.devcontainer/` -- are rejected with:

```
Local file path parse error. Resolved path must be a child of the .devcontainer/ folder.
```

To test the feature against this repo's own dev container, run the helper script from the repo root before opening the container -- it copies the feature into `.devcontainer/apm-feature` and writes a matching `devcontainer.json`:

```sh
./devcontainer/scripts/sync-local-devcontainer.sh
```

The script is idempotent: re-run it whenever [src/apm/install.sh](src/apm/install.sh) or [src/apm/devcontainer-feature.json](src/apm/devcontainer-feature.json) changes.

This constraint only affects local consumption and is primarily meant for local testing. Published OCI references (Option B) and tarball references are unaffected.

### Option B -- consume it from the published OCI reference

Once published, users add it to `devcontainer.json` like any other feature:

```json
{
"features": {
"ghcr.io/<org>/<collection>/apm:1": {}
}
}
```

### Pin a specific version

```json
{
"features": {
"ghcr.io/<org>/<collection>/apm:1": {
"version": "0.8.11"
}
}
}
```

### Combine with the Python feature

```json
{
"image": "ubuntu:24.04",
"features": {
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/<org>/<collection>/apm:1": {}
}
}
```

`installsAfter` ensures Python is installed before APM.

### Requirements for the base image

- Linux (Debian/Ubuntu, Alpine, or Fedora family -- see [#7](#7-supported-environments-os-and-shells)).
- Root on install (the feature runs as root; most base images already do).
- A reachable network (needs to fetch `uv` and `apm-cli`).
- Either a pre-installed Python 3.10+ or one of `apt-get` / `apk` / `dnf` available so the feature can install it.

---

## 5. Unit tests

**Where:** [test/apm/unit/install.bats](test/apm/unit/install.bats)
**Tool:** [bats-core](https://github.com/bats-core/bats-core), plus `bats-support` and `bats-assert` (all vendored as git submodules under `test/bats/` and `test/test_helper/`).
**Count:** 37 tests.

### Approach

The tests create a **temporary stub directory** (`STUB_BIN`) and populate it with fake versions of every command `install.sh` touches -- `apt-get`, `apk`, `dnf`, `curl`, `pip3`, `python3`, `git`, and so on. Each stub records its arguments and returns a configurable exit code. `PATH` is then locked to `STUB_BIN:/bin` via `run_with_stubs()`, so the script sees only the fakes.

This makes it possible to exhaustively cover every branch -- success paths, each package-manager variant, the PEP 668 retry, the ensurepip bootstrap, every invalid `VERSION` shape, missing-root, missing-curl, temp-file cleanup -- in milliseconds, with no Docker and no network.

### What's covered (representative)

- Root check and error message.
- `VERSION` validation: `latest`, valid semver, empty string, two-part, four-part, prerelease, build metadata, default.
- Python install branches across `apt-get` / `apk` / `dnf`; failure when no package manager is found.
- Python version boundary: continues at exactly 3.10; fails at 3.9.
- `git` install branch and its no-package-manager failure.
- `pip` location: prefers `pip3`, falls back to `pip`, bootstraps via `ensurepip`, fails cleanly if bootstrapping fails.
- `apm-cli` install: pins on semver; retries with `--break-system-packages` on PEP 668; fails on non-PEP-668 errors; fails if the retry itself fails.
- `uv` install: installs via curl when missing; curl install branch per package manager; fails if curl or installer script fails; cleans temp file on success and failure; skips install if `uv` already present.
- POSIX compliance: `install.sh` does not use the non-POSIX `local` keyword.
- Warn-not-fail when `apm` ends up off `PATH` after a successful install.

### How to run

Git submodules manage the test dependencies (`bats-core`, `bats-support`, `bats-assert`). After cloning, run:

```sh
git clone <repo-url>
cd <repo>
git submodule update --init --recursive
```

Then:

```sh
cd devcontainer/test/apm/unit
../../bats/bin/bats install.bats
```

---

## 6. Integration tests

**Tool:** `devcontainer features test` from [`@devcontainers/cli`](https://github.com/devcontainers/cli) -- the official Microsoft test runner for Dev Container Features.
**Matrix:** [test/apm/scenarios.json](test/apm/scenarios.json).

### How scenarios are wired

For each entry in `scenarios.json` the CLI:

1. Builds a Docker image from the scenario's base `image`.
2. Runs the real `install.sh` inside the container with the scenario's options injected as environment variables.
3. Copies the `<scenario-id>.sh` file into the container and runs it -- the scenario id must match a filename under `test/apm/`.
4. The test script sources `dev-container-features-test-lib` (provided by the CLI) and `generic-checks.sh`, then issues per-distro assertions, and calls `reportResults`.

### Scenario matrix

| Scenario id | Base image | Purpose / code path exercised |
| --------------------- | ------------------------------- | ------------------------------------------------ |
| `default-ubuntu-24` | `ubuntu:24.04` | PEP 668 retry with `--break-system-packages` |
| `default-debian-12` | `debian:12` | `apt-get` path (no PEP 668 enforcement) |
| `default-alpine-3` | `alpine:3.20` | `apk` path; confirms `bash` is installed |
| `default-fedora` | `fedora:41` | `dnf` path |
| `pinned-version` | `ubuntu:22.04` | `version: "0.8.11"` option end-to-end |
| `with-python-feature` | `ubuntu:24.04` + Python feature | `installsAfter` ordering with the Python feature |

### Shared checks

[test/apm/generic-checks.sh](test/apm/generic-checks.sh) runs on every scenario and verifies:

- `apm` is on `PATH`
- `apm --version` exits `0`
- `apm --version` outputs a semver
- `apm --help` exits `0`

Per-scenario scripts add distro-specific assertions -- e.g. `default-alpine-3.sh` confirms `apk` is the package manager and that `python3` / `git` came from apk (proving the right branch was actually exercised).

### How to run

From the repo root, with Docker running and `@devcontainers/cli` installed (`npm install -g @devcontainers/cli`):

```sh
# All scenarios
devcontainer features test \
--features apm \
--skip-autogenerated \
--project-folder devcontainer

# One scenario
devcontainer features test \
--features apm \
--filter default-ubuntu-24 \
--skip-autogenerated \
--project-folder devcontainer
```

`--skip-autogenerated` skips the CLI's default baseline test on `ubuntu:focal`, which is not supported (Python too old). Add `--log-level trace` for verbose build output.

---

## 7. Supported Environments, OS, and Shells

### Runtime environment

- Any platform where [Dev Containers](https://containers.dev/) run: VS Code + Dev Containers extension, GitHub Codespaces, JetBrains Gateway with Dev Containers, and the `devcontainer` CLI directly.
- Requires Docker (or a compatible engine) on the host.

### Operating systems (verified via integration tests)

| OS family | Version | Package manager | PEP 668 enforced |
| --------- | ------------- | --------------- | ---------------- |
| Ubuntu | 24.04 (noble) | `apt-get` | Yes |
| Ubuntu | 22.04 (jammy) | `apt-get` | No |
| Debian | 12 (bookworm) | `apt-get` | No |
| Alpine | 3.20 | `apk` | No |
| Fedora | 41 | `dnf` | No |

**Not supported:** `ubuntu:focal` (20.04) and earlier -- Python 3.10+ is not available from the default repos, and the feature's Python version check fails fast with a clear error. macOS and Windows as host OSes are fine (Dev Containers runs Linux inside Docker on both); they are not valid feature-install targets.

### Shells

- `install.sh` is `#!/bin/sh` and written to be strictly POSIX (verified by a dedicated unit test that greps for the non-POSIX `local` keyword). It runs under `dash` on Debian/Ubuntu, `ash` on Alpine, and `bash` on Fedora.
- Integration test scripts (`default-*.sh`, `pinned-version.sh`, etc.) are `#!/bin/bash`. On Alpine the install script adds `bash` because the base image ships only `ash`.
- The installed `apm` CLI itself has no shell-specific requirements; users interact with it from whatever interactive shell the container provides (commonly `bash` or `zsh`).
30 changes: 30 additions & 0 deletions devcontainer/scripts/sync-local-devcontainer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Refresh .devcontainer/ so VS Code Dev Containers can consume the local feature.
#
# Recent @devcontainers/cli versions (bundled in ms-vscode-remote.remote-containers
# >= 0.454.0) require local Feature paths to resolve INSIDE .devcontainer/, so we
# copy devcontainer/src/apm -> .devcontainer/apm-feature and write a
# devcontainer.json that references ./apm-feature.
#
# Run from the repo root: ./devcontainer/scripts/sync-local-devcontainer.sh

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"

mkdir -p .devcontainer
rm -rf .devcontainer/apm-feature
cp -R devcontainer/src/apm .devcontainer/apm-feature

cat > .devcontainer/devcontainer.json <<'EOF'
{
"name": "APM Development",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"features": {
Comment on lines +20 to +24
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper overwrites .devcontainer/devcontainer.json via cat > .... If a contributor already has a local devcontainer config, this will clobber it. Consider failing when the file exists unless --force is passed, or writing to a different filename and printing instructions.

Copilot uses AI. Check for mistakes.
"./apm-feature": {}
}
}
Comment on lines +16 to +27
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper script writes .devcontainer/devcontainer.json and copies the feature into .devcontainer/apm-feature, but the repo does not appear to gitignore .devcontainer/. Running it will leave untracked files that are easy to accidentally commit. Consider either (a) adding .devcontainer/ to .gitignore, or (b) having the script emit a clear warning / require an explicit flag before overwriting an existing .devcontainer/devcontainer.json.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not certain if adding .devcontainer to gitignore works with the repo. Not implementing unless directed otherwise

EOF

echo "Synced .devcontainer/apm-feature from devcontainer/src/apm"
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper script writes .devcontainer/devcontainer.json and copies a local feature into .devcontainer/apm-feature, but .devcontainer/ is not currently ignored by git in this repo. Running it will leave untracked files that are easy to accidentally commit. Consider adding .devcontainer/ to .gitignore (or writing into a clearly-temporary path) and/or printing an explicit note that these files are intended to remain uncommitted.

Suggested change
echo "Synced .devcontainer/apm-feature from devcontainer/src/apm"
echo "[+] Synced .devcontainer/apm-feature from devcontainer/src/apm"
echo "[!] Generated .devcontainer/ content is for local development only and should remain uncommitted."
if ! git check-ignore -q .devcontainer 2>/dev/null; then
echo "[!] .devcontainer/ is not ignored by git in this repo. Review 'git status' and do not commit these generated files."
fi

Copilot uses AI. Check for mistakes.
19 changes: 19 additions & 0 deletions devcontainer/src/apm/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"id": "apm",
"version": "1.0.0",
"name": "APM (Agent Package Manager)",
"description": "Installs the APM CLI - a manifest-driven dependency manager for AI agents. Lets you declare agent skills, plugins, and MCP servers in apm.yml and reproduce them across environments.",
"documentationURL": "https://github.com/microsoft/apm/blob/main/devcontainer/README.md",
"licenseURL": "https://github.com/microsoft/apm/blob/main/LICENSE",
"keywords": ["apm", "ai", "agents", "mcp", "claude", "copilot"],
"options": {
"version": {
"type": "string",
"default": "latest",
"description": "Version of apm-cli to install (e.g. '0.8.11'). Use 'latest' for the newest release."
}
},
"installsAfter": [
"ghcr.io/devcontainers/features/python"
]
}
Loading
Loading