diff --git a/.github/workflows/devcontainer-feature-publish.yml b/.github/workflows/devcontainer-feature-publish.yml new file mode 100644 index 000000000..a0a8a1331 --- /dev/null +++ b/.github/workflows/devcontainer-feature-publish.yml @@ -0,0 +1,54 @@ +name: Devcontainer Feature - Publish + +# Publishes the apm-cli devcontainer feature to GHCR as +# ghcr.io/microsoft/apm/apm-cli +# whenever the feature source changes on main. The official +# `devcontainers/action@v1` handles OCI tag fan-out (e.g. version "1.0.0" +# fans out to :1, :1.0, :1.0.0, :latest). +# +# Notes: +# - We set `disable-repo-tagging: 'true'` because microsoft/apm already +# uses `v*` git tags for apm-cli releases; letting the action push its +# own feature tags would clutter that namespace. +# - With repo-tagging disabled, `permissions: contents: read` is enough +# (`packages: write` is what GHCR push needs). +# - `concurrency.cancel-in-progress: false` -- a publish run that races +# another publish must not be cancelled mid-push, or we leave a partial +# manifest in GHCR. +# - `workflow_dispatch` enables manual republish if the first run fails +# before maintainers flip the package to public. +# +# First-run ceremony (one-time, by maintainer, after the first successful +# run): GHCR -> packages -> apm-cli -> Package settings -> change visibility +# to Public, then "Manage Actions access" -> add microsoft/apm with Write. + +on: + push: + branches: [ main ] + paths: + - 'devcontainer/src/**' + - '.github/workflows/devcontainer-feature-publish.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: devcontainer-feature-publish + cancel-in-progress: false + +jobs: + publish: + name: Publish to GHCR + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Publish features + uses: devcontainers/action@v1 + with: + publish-features: 'true' + base-path-to-features: './devcontainer/src' + generate-docs: 'false' + disable-repo-tagging: 'true' diff --git a/.github/workflows/devcontainer-feature-test.yml b/.github/workflows/devcontainer-feature-test.yml new file mode 100644 index 000000000..82f647322 --- /dev/null +++ b/.github/workflows/devcontainer-feature-test.yml @@ -0,0 +1,133 @@ +name: Devcontainer Feature - Test + +# Runs the official `devcontainer features test` matrix from @devcontainers/cli +# whenever the feature source or test fixtures change. PRs run a 4-scenario +# subset (one per package-manager family + option-coverage scenarios); the full +# 6-scenario matrix runs nightly to catch base-image drift on Debian and Fedora +# without blocking PR throughput. +# +# A single aggregation job ("Devcontainer Feature - Test Result") is what gets +# wired into branch protection -- adding/removing matrix legs does not require +# editing the required-checks list. +# +# Reference: https://containers.dev/implementors/features-distribution/ + +on: + pull_request: + branches: [ main ] + paths: + - 'devcontainer/**' + - '.github/workflows/devcontainer-feature-test.yml' + merge_group: + branches: [ main ] + types: [ checks_requested ] + schedule: + # Nightly full matrix at 06:00 UTC catches base-image drift (glibc, musl, + # apt/apk/dnf) within 24h independent of PR activity. + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: devcontainer-feature-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-bats: + name: bats unit tests + runs-on: ubuntu-24.04 + # The bats sandbox uses PATH="$STUB_BIN:/bin" to isolate install.sh from + # the real environment. On Ubuntu 24.04 /bin is a symlink to /usr/bin + # (merged-/usr), so real apt-get / pip / curl shadow the stubs and + # negative-path tests false-pass. Alpine 3.20 keeps /bin as busybox-only + # (apk lives in /sbin which is not on the test PATH), so the stubs work + # exactly as the test author designed. + container: + image: alpine:3.20 + permissions: + contents: read + steps: + - name: Install bash + git (bats needs bash; submodule checkout needs git) + run: apk add --no-cache bash git + + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run bats unit tests + working-directory: devcontainer/test/apm-cli/unit + run: ../../bats/bin/bats install.bats + + integration-matrix: + name: features test (${{ matrix.scenario }}) + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + # PR + merge_group: cheap representative subset (one per pkg-manager + # family + option/ordering coverage). schedule/workflow_dispatch adds + # default-debian-12 + default-fedora to catch base-image drift. + scenario: + - default-ubuntu-24 + - default-alpine-3 + - pinned-version + - with-python-feature + include: + - scenario: default-debian-12 + nightly_only: true + - scenario: default-fedora + nightly_only: true + steps: + - name: Skip nightly-only leg on PR + id: gate + run: | + if [ "${{ matrix.nightly_only }}" = "true" ] && [ "${{ github.event_name }}" != "schedule" ] && [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping ${{ matrix.scenario }} (nightly-only)." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + if: steps.gate.outputs.skip == 'false' + + - name: Set up Node + if: steps.gate.outputs.skip == 'false' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install @devcontainers/cli + if: steps.gate.outputs.skip == 'false' + run: npm install -g @devcontainers/cli@latest + + - name: Run devcontainer features test + if: steps.gate.outputs.skip == 'false' + run: | + devcontainer features test \ + --features apm-cli \ + --filter "${{ matrix.scenario }}" \ + --skip-autogenerated \ + --project-folder devcontainer + + test-result: + name: Devcontainer Feature - Test Result + if: always() + needs: [ unit-bats, integration-matrix ] + runs-on: ubuntu-24.04 + steps: + - name: Aggregate + run: | + if [ "${{ needs.unit-bats.result }}" != "success" ]; then + echo "unit-bats failed: ${{ needs.unit-bats.result }}" + exit 1 + fi + if [ "${{ needs.integration-matrix.result }}" != "success" ] && [ "${{ needs.integration-matrix.result }}" != "skipped" ]; then + echo "integration-matrix failed: ${{ needs.integration-matrix.result }}" + exit 1 + fi + echo "All devcontainer feature tests passed." diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..8893640fe --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f95f87b..4d81384ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Dev Container Feature** `ghcr.io/microsoft/apm/apm-cli` -- one-line install of the APM CLI into any `devcontainer.json`, GitHub Codespace, or JetBrains Gateway workspace. Supports a `version` option (`latest` or pinned semver), declares `installsAfter` for the official Python feature, handles PEP 668 on Ubuntu 24.04+. Ships with 37 bats unit tests and a 6-distro Docker integration matrix (Ubuntu 24.04, Ubuntu 22.04, Debian 12, Alpine 3.20, Fedora 41, plus Python-feature combo). (#861) - `shared/apm.md` gh-aw workflow gains an `apps:` array input for cross-org private packages: each entry mints its own GitHub App installation token via `actions/create-github-app-token` and packs only its declared packages, with a matrix fan-out one replica per credential group. The single-app top-level form (`app-id`, `private-key`, `owner`, `repositories`) shipped earlier in this cycle is preserved as the canonical shorthand for one-org users; `apps[]` is purely additive. Multi-bundle restore uses the `bundles-file:` input from `microsoft/apm-action@v1.5.0` (microsoft/apm-action#30, microsoft/apm-action#29). - `shared/apm.md` gh-aw workflow now accepts `app-id`, `private-key`, `owner`, and `repositories` inputs to mint a GitHub App installation token for fetching cross-org private APM packages, restoring parity with the deprecated `dependencies.github-app` form. The default `GH_AW_PLUGINS_TOKEN || GH_AW_GITHUB_TOKEN || GITHUB_TOKEN` cascade still applies when no app-id is supplied. diff --git a/devcontainer/README.md b/devcontainer/README.md new file mode 100644 index 000000000..665c3e079 --- /dev/null +++ b/devcontainer/README.md @@ -0,0 +1,293 @@ +# 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-cli/ +| +-- devcontainer-feature.json # Feature manifest (id, options, metadata) +| \-- install.sh # Install script executed inside the container +\-- test/ + +-- apm-cli/ + | +-- 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-cli/devcontainer-feature.json](src/apm-cli/devcontainer-feature.json) declares the feature id (`apm-cli`), 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-cli/install.sh](src/apm-cli/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==` 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 + +### Quick start -- add the published feature + +Add the published feature to any `.devcontainer/devcontainer.json`: + +```json +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ghcr.io/microsoft/apm/apm-cli:1": {} + } +} +``` + +Rebuild the container in VS Code (Dev Containers: Rebuild Container), GitHub Codespaces, or JetBrains Gateway. The `apm` binary is on `PATH`; verify with `apm --version`. + +### Pin a specific apm-cli release + +```json +{ + "features": { + "ghcr.io/microsoft/apm/apm-cli:1": { + "version": "0.10.0" + } + } +} +``` + +### Combine with the official Python feature + +The APM feature declares `installsAfter` for the upstream Python feature, so ordering is automatic: + +```json +{ + "image": "ubuntu:24.04", + "features": { + "ghcr.io/devcontainers/features/python:1": {}, + "ghcr.io/microsoft/apm/apm-cli:1": {} + } +} +``` + +### Tag selection + +| Tag | Resolves to | Use when | +| ----------------------------------------- | ------------------------ | ------------------------------------- | +| `ghcr.io/microsoft/apm/apm-cli:1` | latest 1.x.y | recommended default | +| `ghcr.io/microsoft/apm/apm-cli:1.0` | latest 1.0.x | locked to a minor line | +| `ghcr.io/microsoft/apm/apm-cli:1.0.0` | exact patch | maximum reproducibility | +| `ghcr.io/microsoft/apm/apm-cli:latest` | newest published | not recommended (crosses majors) | + +The feature manifest version is independent of the `apm-cli` PyPI release. To pin the CLI, use the `version` option above. + +### Local development -- test an unpublished build + +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-cli` 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-cli-feature` and writes a matching `devcontainer.json`: + +```sh +./devcontainer/scripts/sync-local-devcontainer.sh +``` + +The script is idempotent: re-run it whenever [src/apm-cli/install.sh](src/apm-cli/install.sh) or [src/apm-cli/devcontainer-feature.json](src/apm-cli/devcontainer-feature.json) changes. + +This constraint only affects local consumption and is primarily meant for local testing. Published OCI references and tarball references are unaffected. + +### 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-cli/unit/install.bats](test/apm-cli/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 +cd +git submodule update --init --recursive +``` + +Then: + +```sh +cd devcontainer/test/apm-cli/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-cli/scenarios.json](test/apm-cli/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 `.sh` file into the container and runs it -- the scenario id must match a filename under `test/apm-cli/`. +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-cli/generic-checks.sh](test/apm-cli/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-cli \ + --skip-autogenerated \ + --project-folder devcontainer + +# One scenario +devcontainer features test \ + --features apm-cli \ + --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`). diff --git a/devcontainer/scripts/sync-local-devcontainer.sh b/devcontainer/scripts/sync-local-devcontainer.sh new file mode 100755 index 000000000..028fa7f5f --- /dev/null +++ b/devcontainer/scripts/sync-local-devcontainer.sh @@ -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-cli -> .devcontainer/apm-cli-feature and write a +# devcontainer.json that references ./apm-cli-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-cli-feature +cp -R devcontainer/src/apm-cli .devcontainer/apm-cli-feature + +cat > .devcontainer/devcontainer.json <<'EOF' +{ + "name": "APM Development", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "features": { + "./apm-cli-feature": {} + } +} +EOF + +echo "Synced .devcontainer/apm-cli-feature from devcontainer/src/apm-cli" diff --git a/devcontainer/src/apm-cli/devcontainer-feature.json b/devcontainer/src/apm-cli/devcontainer-feature.json new file mode 100644 index 000000000..63a1c1e37 --- /dev/null +++ b/devcontainer/src/apm-cli/devcontainer-feature.json @@ -0,0 +1,19 @@ +{ + "id": "apm-cli", + "version": "1.0.0", + "name": "APM (Agent Package Manager) CLI", + "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/tree/main/devcontainer/src/apm-cli", + "licenseURL": "https://github.com/microsoft/apm/blob/main/LICENSE", + "keywords": ["apm", "apm-cli", "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" + ] +} diff --git a/devcontainer/src/apm-cli/install.sh b/devcontainer/src/apm-cli/install.sh new file mode 100755 index 000000000..0523e809b --- /dev/null +++ b/devcontainer/src/apm-cli/install.sh @@ -0,0 +1,154 @@ +#!/bin/sh +set -e + +# VERSION is sourced from devcontainer-feature.json options (uppercased option id) +VERSION="${VERSION-"latest"}" + +# Track whether `apt-get update` has run so we only hit the network once. +_APT_UPDATED=0 +apt_update_once() { + if [ "$_APT_UPDATED" -eq 0 ]; then + apt-get update -y -qq + _APT_UPDATED=1 + fi +} + +case "$VERSION" in + latest) ;; + *) + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "[x] VERSION must be 'latest' or a semver string (e.g. 1.2.3), got: '${VERSION}'" >&2 + exit 1 + fi + ;; +esac + +if [ "$(id -u)" -ne 0 ]; then + echo '[x] install.sh must run as root. Add "USER root" to your Dockerfile before this feature.' >&2 + exit 1 +fi + +# -- Install uv (idempotent -- skip if already on PATH) ----------------------- +if command -v uv >/dev/null 2>&1; then + echo "uv already installed at $(command -v uv) -- skipping" +else + # curl is only needed to fetch the uv installer + if ! command -v curl >/dev/null 2>&1; then + echo "curl not found -- installing..." + if command -v apt-get >/dev/null 2>&1; then + apt_update_once + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache curl + elif command -v dnf >/dev/null 2>&1; then + dnf install -y curl + else + echo "[x] curl is not installed and the package manager is not recognised." >&2 + exit 1 + fi + fi + echo "Installing uv..." + _uv_tmp="$(mktemp /tmp/uv_install.XXXXXX)" + trap 'rm -f "$_uv_tmp"' EXIT INT TERM + curl -LsSf https://astral.sh/uv/install.sh > "$_uv_tmp" + UV_INSTALL_DIR=/usr/local/bin sh "$_uv_tmp" +fi + +echo "Installing APM CLI (version: ${VERSION})..." + +# -- Ensure Python 3.10+ is available ----------------------------------------- +if ! command -v python3 >/dev/null 2>&1; then + echo "Python 3 not found -- installing via system package manager..." + if command -v apt-get >/dev/null 2>&1; then + apt_update_once + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip git + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache python3 py3-pip git + elif command -v dnf >/dev/null 2>&1; then + dnf install -y python3 python3-pip git + else + echo "[x] Python 3 is not installed and the package manager is not recognised." >&2 + echo "Please use a base image that includes Python 3.10+, or install it manually." >&2 + exit 1 + fi +fi + +# -- Ensure git is available (apm uses GitPython at startup) ------------------ +if ! command -v git >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt_update_once + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache git + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git + else + echo "[x] git is not installed and the package manager is not recognised." >&2 + echo "Please use a base image that includes git, or install it manually." >&2 + exit 1 + fi +fi + +# Validate Python version meets apm-cli requirement (>=3.10) +PYTHON_MINOR=$(python3 -c "import sys; print(sys.version_info.minor)") +PYTHON_MAJOR=$(python3 -c "import sys; print(sys.version_info.major)") +if [ "$PYTHON_MAJOR" -lt 3 ] || { [ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]; }; then + PYTHON_VER=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:3])))") + echo "[x] apm-cli requires Python 3.10+, found Python ${PYTHON_VER}." >&2 + echo "Use a base image with Python 3.10+ (e.g. ubuntu:22.04) or include the Python devcontainer feature." >&2 + exit 1 +fi + +# -- Locate pip ---------------------------------------------------------------- +PIP_CMD="" +if command -v pip3 >/dev/null 2>&1; then + PIP_CMD="pip3" +elif command -v pip >/dev/null 2>&1; then + PIP_CMD="pip" +else + python3 -m ensurepip --upgrade 2>/dev/null || true + if command -v pip3 >/dev/null 2>&1; then + PIP_CMD="pip3" + elif command -v pip >/dev/null 2>&1; then + PIP_CMD="pip" + else + echo "[x] pip is not available and could not be bootstrapped." >&2 + exit 1 + fi +fi + +# -- Build pip package spec --------------------------------------------------- +if [ "$VERSION" = "latest" ]; then + PKG_SPEC="apm-cli" +else + PKG_SPEC="apm-cli==${VERSION}" +fi + +# -- Install ------------------------------------------------------------------ +# Ubuntu 24.04+ enforces PEP 668 ("externally managed environment") and rejects +# plain `pip install`. Detect the specific error and retry with the flag. +install_apm() { + _install_out=$($PIP_CMD install "$PKG_SPEC" 2>&1) && { printf '%s\n' "$_install_out"; return 0; } + printf '%s\n' "$_install_out" + if printf '%s\n' "$_install_out" | grep -q "externally-managed-environment"; then + echo "Retrying with --break-system-packages (PEP 668 distro)..." + $PIP_CMD install --break-system-packages "$PKG_SPEC" + else + return 1 + fi +} + +install_apm + +# -- Ensure bash is present (Alpine ships only ash; devcontainer test scripts require bash) -- +if command -v apk >/dev/null 2>&1 && ! command -v bash >/dev/null 2>&1; then + apk add --no-cache bash +fi + +# -- Verify ------------------------------------------------------------------- +if command -v apm >/dev/null 2>&1; then + echo "[+] APM $(apm --version) installed at $(command -v apm)" +else + echo "[!] apm was installed but is not in PATH." >&2 + echo "Ensure the pip bin directory is in PATH (usually /usr/local/bin or ~/.local/bin)." >&2 +fi diff --git a/devcontainer/test/apm-cli/default-alpine-3.sh b/devcontainer/test/apm-cli/default-alpine-3.sh new file mode 100755 index 000000000..a758f84f5 --- /dev/null +++ b/devcontainer/test/apm-cli/default-alpine-3.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib +# shellcheck source=generic-checks.sh +source "$(dirname "$0")/generic-checks.sh" + +# -- Alpine-specific: confirm apk path was exercised -------------------------- + +check "apk is the system package manager" \ + command -v apk + +check "python3 is installed via apk" \ + bash -c "apk info python3 | grep -q python3" + +check "git is installed via apk" \ + bash -c "apk info git | grep -q git" + +# -- Report -------------------------------------------------------------------- +reportResults diff --git a/devcontainer/test/apm-cli/default-debian-12.sh b/devcontainer/test/apm-cli/default-debian-12.sh new file mode 100755 index 000000000..d0d563320 --- /dev/null +++ b/devcontainer/test/apm-cli/default-debian-12.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib +# shellcheck source=generic-checks.sh +source "$(dirname "$0")/generic-checks.sh" + +# -- Debian-specific: confirm apt-get path was exercised ---------------------- + +check "apt-get is the system package manager" \ + command -v apt-get + +check "python3 is installed via apt" \ + bash -c "dpkg -l python3 | grep -q '^ii'" + +check "git is installed via apt" \ + bash -c "dpkg -l git | grep -q '^ii'" + +# -- Report -------------------------------------------------------------------- +reportResults diff --git a/devcontainer/test/apm-cli/default-fedora.sh b/devcontainer/test/apm-cli/default-fedora.sh new file mode 100755 index 000000000..1268590c2 --- /dev/null +++ b/devcontainer/test/apm-cli/default-fedora.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib +# shellcheck source=generic-checks.sh +source "$(dirname "$0")/generic-checks.sh" + +# --- Fedora-specific: confirm dnf path was exercised --- + +check "dnf is the system package manager" \ + command -v dnf + +check "python3 is installed via dnf" \ + bash -c "rpm -q python3 >/dev/null" + +check "git is installed via dnf" \ + bash -c "rpm -q git >/dev/null" + +# Report ------------------------------------------------------ +reportResults diff --git a/devcontainer/test/apm-cli/default-ubuntu-24.sh b/devcontainer/test/apm-cli/default-ubuntu-24.sh new file mode 100644 index 000000000..9c83269a7 --- /dev/null +++ b/devcontainer/test/apm-cli/default-ubuntu-24.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib +# shellcheck source=generic-checks.sh +source "$(dirname "$0")/generic-checks.sh" + +# -- Ubuntu 24.04 specific: PEP 668 distro -------------------------------------- + +check "Running on Ubuntu 24.04 (PEP 668 distro)" \ + bash -c "grep -q 'PRETTY_NAME=\"Ubuntu 24.04' /etc/os-release" + +check "apm-cli is visible to system pip (PEP 668 --break-system-packages succeeded)" \ + bash -c "pip3 show apm-cli | grep -q 'Location:'" + +# -- Report -------------------------------------------------------------------- +reportResults diff --git a/devcontainer/test/apm-cli/generic-checks.sh b/devcontainer/test/apm-cli/generic-checks.sh new file mode 100644 index 000000000..be432561a --- /dev/null +++ b/devcontainer/test/apm-cli/generic-checks.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Sourced by per-distro scenario scripts after `source dev-container-features-test-lib`. +# Provides the four checks that must pass on every image. + +check "apm binary is on PATH" \ + command -v apm + +check "apm --version exits cleanly" \ + apm --version + +check "apm --version outputs a semver string" \ + bash -c "apm --version | grep -E '[0-9]+\.[0-9]+\.[0-9]+'" + +check "apm --help exits cleanly" \ + apm --help diff --git a/devcontainer/test/apm-cli/pinned-version.sh b/devcontainer/test/apm-cli/pinned-version.sh new file mode 100755 index 000000000..f1c68bfc0 --- /dev/null +++ b/devcontainer/test/apm-cli/pinned-version.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Load the devcontainer test helper (injected by `devcontainer features test`) +# shellcheck source=/dev/null +source dev-container-features-test-lib + +# Source generic checks (applies to all scenarios) +# shellcheck source=/dev/null +source "$(dirname "$0")/generic-checks.sh" + +# Scenario-specific checks +check "apm --version reports the pinned version (0.8.11)" \ + bash -c "apm --version | grep -q '0\.8\.11'" + +reportResults diff --git a/devcontainer/test/apm-cli/scenarios.json b/devcontainer/test/apm-cli/scenarios.json new file mode 100644 index 000000000..d3f566dd2 --- /dev/null +++ b/devcontainer/test/apm-cli/scenarios.json @@ -0,0 +1,41 @@ +{ + "default-ubuntu-24": { + "image": "ubuntu:24.04", + "features": { + "apm-cli": {} + } + }, + "default-debian-12": { + "image": "debian:12", + "features": { + "apm-cli": {} + } + }, + "default-alpine-3": { + "image": "alpine:3.20", + "features": { + "apm-cli": {} + } + }, + "default-fedora": { + "image": "fedora:41", + "features": { + "apm-cli": {} + } + }, + "pinned-version": { + "image": "ubuntu:22.04", + "features": { + "apm-cli": { + "version": "0.8.11" + } + } + }, + "with-python-feature": { + "image": "ubuntu:24.04", + "features": { + "ghcr.io/devcontainers/features/python:1": {}, + "apm-cli": {} + } + } +} diff --git a/devcontainer/test/apm-cli/test.sh b/devcontainer/test/apm-cli/test.sh new file mode 100755 index 000000000..6a32955ec --- /dev/null +++ b/devcontainer/test/apm-cli/test.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Load the devcontainer test helper (injected by `devcontainer features test`) +# shellcheck source=/dev/null +source dev-container-features-test-lib + +# Source generic checks (applies to all distros) +# shellcheck source=/dev/null +source "$(dirname "$0")/generic-checks.sh" + +reportResults diff --git a/devcontainer/test/apm-cli/unit/install.bats b/devcontainer/test/apm-cli/unit/install.bats new file mode 100644 index 000000000..dc7c7e9f5 --- /dev/null +++ b/devcontainer/test/apm-cli/unit/install.bats @@ -0,0 +1,664 @@ +#!/usr/bin/env bats +# Unit tests for devcontainer/src/apm-cli/install.sh +# PATH is fully isolated to STUB_BIN -- no network, no real packages, no Docker. + +load "../../test_helper/bats-support/load" +load "../../test_helper/bats-assert/load" + +INSTALL_SH="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../src/apm-cli" && pwd)/install.sh" + +# -- Helpers ------------------------------------------------------------------ + +setup() { + STUB_BIN="$BATS_TEST_TMPDIR/bin" + /bin/mkdir -p "$STUB_BIN" + export STUB_BIN + + # Delegate stubs for utilities install.sh needs with real behaviour. + # Resolved against the real PATH before we lock it down. + local real_grep real_sh real_mktemp + real_grep="$(PATH=/usr/bin:/bin command -v grep)" + real_sh="$(PATH=/usr/bin:/bin command -v sh)" + real_mktemp="$(PATH=/usr/bin:/bin command -v mktemp)" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$real_grep" > "$STUB_BIN/grep" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$real_sh" > "$STUB_BIN/sh" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$real_mktemp" > "$STUB_BIN/mktemp" + /bin/chmod +x "$STUB_BIN/grep" "$STUB_BIN/sh" "$STUB_BIN/mktemp" + + # Pre-stage python3 stub content; package-manager stubs cp this into place. + /bin/cat > "$STUB_BIN/_python3_stub" <<'EOF' +#!/bin/sh +case "$*" in + *version_info.minor*) echo "12" ;; + *version_info.major*) echo "3" ;; + *version_info*3*) echo "3.12.0" ;; + *) exit 0 ;; +esac +EOF + /bin/chmod +x "$STUB_BIN/_python3_stub" + # NOTE: PATH is NOT locked here -- test code needs rm, cat, etc. + # We'll lock it per-test using run_with_stubs() +} + +# Helper: runs sh with PATH locked to STUB_BIN + /bin (for sh, core utilities) +# STUB_BIN is first so stubs shadow any real system commands. +run_with_stubs() { + PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" "$@" +} + +# make_stub [output_text] +make_stub() { + local name="$1" rc="$2" out="${3:-}" + { + printf '#!/bin/sh\n' + [ -n "$out" ] && printf 'echo "%s"\n' "$out" + printf 'exit %d\n' "$rc" + } > "$STUB_BIN/$name" + /bin/chmod +x "$STUB_BIN/$name" +} + +# Copies the pre-staged python3 stub into STUB_BIN. +make_python3_stub() { + /bin/cp "$STUB_BIN/_python3_stub" "$STUB_BIN/python3" +} + +# make_old_python3_stub -- simulates an older Python. +make_old_python3_stub() { + local major="${1:-3}" minor="${2:-8}" + /bin/cat > "$STUB_BIN/python3" < -- creates a package-manager stub that side-effects +# a python3 stub (simulating a successful install of python3) and records args. +make_pkg_mgr_stub() { + local cmd="$1" + /bin/cat > "$STUB_BIN/$cmd" <> "${STUB_BIN}/_${cmd}_args" +/bin/cp "${STUB_BIN}/_python3_stub" "${STUB_BIN}/python3" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/$cmd" +} + +# Full happy-path environment: root, all tools present, install succeeds. +setup_happy_path() { + make_stub id 0 "0" + make_stub uv 0 "0.4.0" + make_python3_stub + make_stub git 0 + make_stub pip3 0 "Successfully installed apm-cli" + make_stub apm 0 "0.9.0" +} + +# -- Root check ---------------------------------------------------------------- + +@test "exits 1 with clear message when not run as root" { + make_stub id 0 "1" # id -u -> 1 (non-root) + + run_with_stubs + + assert_failure + assert_output --partial "must run as root" +} + +# -- Python 3 install ---------------------------------------------------------- + +@test "installs python3 via apt-get when missing" { + setup_happy_path + rm -f "$STUB_BIN/python3" + make_pkg_mgr_stub apt-get + + run_with_stubs + + assert_success + grep -q 'python3' "$STUB_BIN/_apt-get_args" + grep -q 'python3-pip' "$STUB_BIN/_apt-get_args" + grep -q 'git' "$STUB_BIN/_apt-get_args" +} + +@test "apt-get python3 install uses -qq and DEBIAN_FRONTEND=noninteractive" { + setup_happy_path + rm -f "$STUB_BIN/python3" + # Record env var alongside args + /bin/cat > "$STUB_BIN/apt-get" <> "${STUB_BIN}/_apt-get_env" +echo "\$@" >> "${STUB_BIN}/_apt-get_args" +/bin/cp "${STUB_BIN}/_python3_stub" "${STUB_BIN}/python3" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/apt-get" + + run_with_stubs + + assert_success + grep -q '\-qq' "$STUB_BIN/_apt-get_args" + grep -q 'noninteractive' "$STUB_BIN/_apt-get_env" +} + +@test "installs python3 via apk when apt-get is absent" { + setup_happy_path + rm -f "$STUB_BIN/python3" + # No apt-get stub -- falls through to apk + make_pkg_mgr_stub apk + + run_with_stubs + + assert_success + grep -q 'python3' "$STUB_BIN/_apk_args" + grep -q 'py3-pip' "$STUB_BIN/_apk_args" + grep -q 'git' "$STUB_BIN/_apk_args" +} + +@test "installs python3 via dnf when apt-get and apk are absent" { + setup_happy_path + rm -f "$STUB_BIN/python3" + # No apt-get or apk stubs -- falls through to dnf + make_pkg_mgr_stub dnf + + run_with_stubs + + assert_success + grep -q 'python3' "$STUB_BIN/_dnf_args" + grep -q 'python3-pip' "$STUB_BIN/_dnf_args" + grep -q 'git' "$STUB_BIN/_dnf_args" +} + +@test "exits 1 with clear message when no supported package manager is found" { + setup_happy_path + rm -f "$STUB_BIN/python3" + # No apt-get, apk, or dnf stubs + + run_with_stubs + + assert_failure + assert_output --partial "package manager is not recognised" +} + +# -- git install --------------------------------------------------------------- + +# The apt-get branch is representative; apk/dnf mirror the same logic tested +# exhaustively in the python3-install block above. +@test "installs git via apt-get when git is missing" { + setup_happy_path + rm -f "$STUB_BIN/git" + /bin/cat > "$STUB_BIN/apt-get" <> "${STUB_BIN}/_apt-get_args" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/apt-get" + + run_with_stubs + + assert_success + grep -q 'git' "$STUB_BIN/_apt-get_args" +} + +@test "exits 1 with clear message when git is missing and no package manager is available" { + setup_happy_path + rm -f "$STUB_BIN/git" + # No apt-get, apk, or dnf stubs + + run_with_stubs + + assert_failure + assert_output --partial "git" +} + +# -- Python version guard ------------------------------------------------------- + +@test "continues when Python is exactly 3.10 (minimum boundary)" { + setup_happy_path + /bin/cat > "$STUB_BIN/python3" <<'EOF' +#!/bin/sh +case "$*" in + *version_info.minor*) echo "10" ;; + *version_info.major*) echo "3" ;; + *version_info*3*) echo "3.10.0" ;; + *) exit 0 ;; +esac +EOF + /bin/chmod +x "$STUB_BIN/python3" + + run_with_stubs + + assert_success +} + +# -- pip discovery ------------------------------------------------------------- + +@test "falls back to pip when pip3 is absent" { + setup_happy_path + rm -f "$STUB_BIN/pip3" + make_stub pip 0 "Successfully installed apm-cli" + + run_with_stubs + + assert_success +} + +@test "bootstraps pip via ensurepip when neither pip3 nor pip is found" { + setup_happy_path + rm -f "$STUB_BIN/pip3" + + # python3 -m ensurepip side-effects a pip3 stub into STUB_BIN + /bin/cat > "$STUB_BIN/python3" < "${STUB_BIN}/pip3" + /bin/chmod +x "${STUB_BIN}/pip3" + exit 0 ;; + *) exit 0 ;; +esac +EOF + /bin/chmod +x "$STUB_BIN/python3" + + run_with_stubs + + assert_success +} + +@test "exits 1 when pip cannot be bootstrapped" { + setup_happy_path + rm -f "$STUB_BIN/pip3" + # python3 ensurepip exits 0 but creates nothing (default stub behaviour) + + run_with_stubs + + assert_failure +} + +# -- Package spec -------------------------------------------------------------- + +@test "pins version when VERSION is set to a semver string" { + setup_happy_path + /bin/cat > "$STUB_BIN/pip3" <<'EOF' +#!/bin/sh +echo "ARGS:$*" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/pip3" + + VERSION=0.8.11 PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + + assert_success + assert_output --partial "ARGS:install apm-cli==0.8.11" +} + +# -- PEP 668 retry ------------------------------------------------------------- + +@test "retries install with --break-system-packages on PEP 668 error" { + setup_happy_path + /bin/cat > "$STUB_BIN/pip3" <> "${STUB_BIN}/_pip3_calls" +case "\$*" in + *--break-system-packages*) echo "Successfully installed"; exit 0 ;; + *) echo "ERROR: externally-managed-environment"; exit 1 ;; +esac +EOF + /bin/chmod +x "$STUB_BIN/pip3" + + run_with_stubs + + assert_success + assert_output --partial "Retrying with --break-system-packages" + # Confirm both a plain attempt and a retry attempt happened. + [ "$(wc -l < "$STUB_BIN/_pip3_calls")" -eq 2 ] + grep -q -- '--break-system-packages' "$STUB_BIN/_pip3_calls" +} + +@test "exits 1 when PEP 668 retry also fails" { + setup_happy_path + /bin/cat > "$STUB_BIN/pip3" < "$STUB_BIN/pip3" <<'EOF' +#!/bin/sh +echo "ERROR: Could not find a version that satisfies the requirement" +exit 1 +EOF + /bin/chmod +x "$STUB_BIN/pip3" + + VERSION=99.99.99 PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + + assert_failure + refute_output --partial "--break-system-packages" +} + +# -- Post-install verification ------------------------------------------------- + +@test "prints warning (not failure) when apm is not on PATH after install" { + setup_happy_path + rm -f "$STUB_BIN/apm" + + run_with_stubs + + assert_success + assert_output --partial "[!] apm was installed but is not in PATH" +} + +# -- curl install ------------------------------------------------------------- +# Removing the uv stub forces the uv-install path, which requires curl. +# Without a curl stub in STUB_BIN, the curl-install block fires first. + +@test "installs curl via apt-get when curl is missing" { + setup_happy_path + rm -f "$STUB_BIN/uv" + /bin/cat > "$STUB_BIN/_curl_stub" <<'EOF' +#!/bin/sh +printf '#!/bin/sh\nexit 0\n' +EOF + /bin/chmod +x "$STUB_BIN/_curl_stub" + /bin/cat > "$STUB_BIN/apt-get" <> "${STUB_BIN}/_apt-get_args" +/bin/cp "${STUB_BIN}/_curl_stub" "${STUB_BIN}/curl" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/apt-get" + + run_with_stubs + + assert_success + grep -q 'curl' "$STUB_BIN/_apt-get_args" +} + +@test "installs curl via apk when apt-get is absent" { + setup_happy_path + rm -f "$STUB_BIN/uv" + /bin/cat > "$STUB_BIN/_curl_stub" <<'EOF' +#!/bin/sh +printf '#!/bin/sh\nexit 0\n' +EOF + /bin/chmod +x "$STUB_BIN/_curl_stub" + /bin/cat > "$STUB_BIN/apk" <> "${STUB_BIN}/_apk_args" +/bin/cp "${STUB_BIN}/_curl_stub" "${STUB_BIN}/curl" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/apk" + + run_with_stubs + + assert_success + grep -q 'curl' "$STUB_BIN/_apk_args" +} + +@test "installs curl via dnf when apt-get and apk are absent" { + setup_happy_path + rm -f "$STUB_BIN/uv" + /bin/cat > "$STUB_BIN/_curl_stub" <<'EOF' +#!/bin/sh +printf '#!/bin/sh\nexit 0\n' +EOF + /bin/chmod +x "$STUB_BIN/_curl_stub" + /bin/cat > "$STUB_BIN/dnf" <> "${STUB_BIN}/_dnf_args" +/bin/cp "${STUB_BIN}/_curl_stub" "${STUB_BIN}/curl" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/dnf" + + run_with_stubs + + assert_success + grep -q 'curl' "$STUB_BIN/_dnf_args" +} + +@test "exits 1 with clear message when curl is missing and no package manager is available" { + setup_happy_path + rm -f "$STUB_BIN/uv" + # No curl, no apt-get, no apk, no dnf stubs + + run_with_stubs + + assert_failure + assert_output --partial "package manager is not recognised" +} + +# -- uv install failure -------------------------------------------------------- + +@test "exits non-zero when curl fails during uv install" { + setup_happy_path + rm -f "$STUB_BIN/uv" + make_stub curl 1 + + run_with_stubs + + assert_failure +} + +@test "exits non-zero when uv installer script execution fails" { + setup_happy_path + rm -f "$STUB_BIN/uv" + /bin/cat > "$STUB_BIN/curl" <<'EOF' +#!/bin/sh +# Output a script that fails +cat <<'SCRIPT' +#!/bin/sh +echo "ERROR: uv installation failed" +exit 1 +SCRIPT +EOF + /bin/chmod +x "$STUB_BIN/curl" + + run_with_stubs + + assert_failure + assert_output --partial "ERROR: uv installation failed" +} + +# -- uv install via curl ------------------------------------------------------- + +@test "installs uv via curl when uv is not on PATH; UV_INSTALL_DIR is set correctly" { + setup_happy_path + rm -f "$STUB_BIN/uv" + # install.sh runs: curl ... > tmp_file; UV_INSTALL_DIR=... sh tmp_file + # curl's stdout becomes the installer script that sh executes. + # The generated script records UV_INSTALL_DIR and writes a uv stub, + # so we can assert the env var reached the installer. + /bin/cat > "$STUB_BIN/curl" < "${STUB_BIN}/_uv_installer_env" +printf '#!/bin/sh\nexit 0\n' > "${STUB_BIN}/uv" +chmod +x "${STUB_BIN}/uv" +SCRIPT +EOF + /bin/chmod +x "$STUB_BIN/curl" + + run_with_stubs + + assert_success + [ -x "$STUB_BIN/uv" ] + grep -q '^UV_INSTALL_DIR=/usr/local/bin$' "$STUB_BIN/_uv_installer_env" +} + +@test "cleans up uv installer temp file on success" { + setup_happy_path + rm -f "$STUB_BIN/uv" + # Pin mktemp to a deterministic path so we can assert on that exact file. + UV_TMP="$BATS_TEST_TMPDIR/uv_install.fixed" + /bin/cat > "$STUB_BIN/mktemp" < "$UV_TMP" +echo "$UV_TMP" +EOF + /bin/chmod +x "$STUB_BIN/mktemp" + /bin/cat > "$STUB_BIN/curl" < "${STUB_BIN}/_uv_installer_env" +printf '#!/bin/sh\nexit 0\n' > "${STUB_BIN}/uv" +chmod +x "${STUB_BIN}/uv" +SCRIPT +EOF + /bin/chmod +x "$STUB_BIN/curl" + + run_with_stubs + + assert_success + [ ! -e "$UV_TMP" ] +} + +@test "cleans up uv installer temp file on script failure" { + setup_happy_path + rm -f "$STUB_BIN/uv" + UV_TMP="$BATS_TEST_TMPDIR/uv_install.fixed" + /bin/cat > "$STUB_BIN/mktemp" < "$UV_TMP" +echo "$UV_TMP" +EOF + /bin/chmod +x "$STUB_BIN/mktemp" + /bin/cat > "$STUB_BIN/curl" <<'EOF' +#!/bin/sh +cat <<'SCRIPT' +exit 1 +SCRIPT +EOF + /bin/chmod +x "$STUB_BIN/curl" + + run_with_stubs + + assert_failure + [ ! -e "$UV_TMP" ] +} + +@test "skips uv install when already on PATH; does not call curl" { + setup_happy_path + # uv is already in STUB_BIN from setup_happy_path, so curl should not be called + /bin/cat > "$STUB_BIN/curl" <<'EOF' +#!/bin/sh +echo "ERROR: curl should not be called when uv is already on PATH" >&2 +exit 1 +EOF + /bin/chmod +x "$STUB_BIN/curl" + + run_with_stubs + + assert_success + assert_output --partial "uv already installed" + refute_output --partial "Installing uv" +} + +# -- VERSION default ----------------------------------------------------------- + +@test "defaults to latest when VERSION is unset" { + setup_happy_path + /bin/cat > "$STUB_BIN/pip3" <<'EOF' +#!/bin/sh +echo "ARGS:$*" +exit 0 +EOF + /bin/chmod +x "$STUB_BIN/pip3" + + unset VERSION + PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + + assert_success + assert_output --partial "ARGS:install apm-cli" + refute_output --partial "apm-cli==" +} + +# -- Python 3.9 boundary ------------------------------------------------------- + +@test "exits 1 when Python is 3.9 (one below minimum)" { + setup_happy_path + make_old_python3_stub 3 9 + + run_with_stubs + + assert_failure + assert_output --partial "requires Python 3.10+" + assert_output --partial "3.9" +} + +# -- VERSION validation -------------------------------------------------------- + +@test "exits 1 with clear message when VERSION is empty string" { + setup_happy_path + VERSION="" PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_failure + assert_output --partial "VERSION" +} + +@test "exits 1 with clear message when VERSION is not latest or semver" { + setup_happy_path + VERSION=abc PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_failure + assert_output --partial "VERSION" +} + +@test "exits 1 when VERSION has only two version components" { + setup_happy_path + VERSION=1.2 PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_failure + assert_output --partial "VERSION" +} + +@test "continues when VERSION is a valid three-part semver string" { + setup_happy_path + VERSION=1.2.3 PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_success +} + +@test "exits 1 when VERSION has four version components" { + setup_happy_path + VERSION=1.2.3.4 PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_failure + assert_output --partial "VERSION" +} + +@test "exits 1 when VERSION is a pre-release (with dash suffix)" { + setup_happy_path + VERSION=1.2.3-rc1 PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_failure + assert_output --partial "VERSION" +} + +@test "exits 1 when VERSION has build metadata (with plus)" { + setup_happy_path + VERSION=1.2.3+build PATH="$STUB_BIN:/bin" run sh "$INSTALL_SH" + assert_failure + assert_output --partial "VERSION" +} + +# -- POSIX compliance --------------------------------------------------------- +# The script shebang is /bin/sh, so it must stay POSIX-clean. `local` is a +# common non-POSIX trap that works on bash/dash/ash but is not guaranteed. + +@test "install.sh does not use the non-POSIX 'local' keyword" { + run grep -nE '^[[:space:]]*local[[:space:]]' "$INSTALL_SH" + assert_failure +} diff --git a/devcontainer/test/apm-cli/with-python-feature.sh b/devcontainer/test/apm-cli/with-python-feature.sh new file mode 100644 index 000000000..c1a52f5e8 --- /dev/null +++ b/devcontainer/test/apm-cli/with-python-feature.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +# Load the devcontainer test helper (injected by `devcontainer features test`) +# shellcheck source=/dev/null +source dev-container-features-test-lib + +# Source generic checks (applies to all scenarios) +# shellcheck source=/dev/null +source "$(dirname "$0")/generic-checks.sh" + +# Scenario-specific checks +check "python3 is on PATH" \ + command -v python3 + +check "python3 meets minimum version (3.10+)" \ + bash -c "python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)'" + +reportResults diff --git a/devcontainer/test/bats b/devcontainer/test/bats new file mode 160000 index 000000000..d9faff0d7 --- /dev/null +++ b/devcontainer/test/bats @@ -0,0 +1 @@ +Subproject commit d9faff0d7bc32e7adebc6552446f978118d3ab3b diff --git a/devcontainer/test/test_helper/bats-assert b/devcontainer/test/test_helper/bats-assert new file mode 160000 index 000000000..697471b7a --- /dev/null +++ b/devcontainer/test/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/devcontainer/test/test_helper/bats-support b/devcontainer/test/test_helper/bats-support new file mode 160000 index 000000000..0954abb99 --- /dev/null +++ b/devcontainer/test/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96