From 8e136e2538537ecc7ce20cab9b51531323e612c9 Mon Sep 17 00:00:00 2001 From: stewartshea Date: Wed, 5 Aug 2026 12:00:04 -0400 Subject: [PATCH 1/6] Adopt agent-agnostic rules and IDE tool initialization Move skill-generated rules from .cursor/rules/ to .agents/ and add a symlink for Cursor compatibility. Introduce init-ide-tools script to create per-tool config directories at container start via RW_IDE_TOOLS env var. Update devcontainer, Dockerfile, and compose to use /workspaces mount and pass IDE tool env vars. Add AGENTS.md for agent integration guidance. --- .agents/.gitignore | 2 + .cursor/rules | 1 + .devcontainer/devcontainer.json | 18 ++++++++- .gitignore | 8 ++-- AGENTS.md | 48 +++++++++++++++++++++++ Dockerfile | 11 ++++-- README.md | 67 ++++++++++++++++++++++++++------- Taskfile.yml | 20 ++++++---- docker-compose.yaml | 3 +- scripts/init-ide-tools.sh | 43 +++++++++++++++++++++ 10 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 .agents/.gitignore create mode 120000 .cursor/rules create mode 100644 AGENTS.md create mode 100644 scripts/init-ide-tools.sh diff --git a/.agents/.gitignore b/.agents/.gitignore new file mode 100644 index 0000000..bfcc6ea --- /dev/null +++ b/.agents/.gitignore @@ -0,0 +1,2 @@ +# Injected by codecollection-devtools -- do not commit +*.mdc diff --git a/.cursor/rules b/.cursor/rules new file mode 120000 index 0000000..7bcb955 --- /dev/null +++ b/.cursor/rules @@ -0,0 +1 @@ +../.agents \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9403836..60d3800 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,6 +5,9 @@ "updateRemoteUserUID": false, "overrideCommand": false, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces,type=bind,consistency=cached", + "workspaceFolder": "/workspaces", + "forwardPorts": [3000], "portsAttributes": { "3000": { @@ -16,10 +19,13 @@ "containerEnv": { "RW_MODE": "dev", "ROBOT_LOG_DIR": "/robot_logs", - "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}", + "RW_IDE_TOOLS": "${localEnv:RW_IDE_TOOLS}", + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}", + "OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}" }, - "postCreateCommand": "chmod 755 /home/runwhen && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", + "postCreateCommand": "init-ide-tools && chmod 755 /home/runwhen && chmod 777 /tmp && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", "postStartCommand": "python -m http.server --bind 0.0.0.0 --directory /robot_logs 3000 &", "features": { @@ -67,6 +73,14 @@ }, "codespaces": { "openFiles": ["README.md"] + }, + "zen": { + "extensions": [ + "robocorp.robotframework-lsp", + "ms-python.python", + "ms-python.pylint", + "ms-python.black-formatter" + ] } } } diff --git a/.gitignore b/.gitignore index 7691aa4..bdf6d05 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,11 @@ __pycache__ *robot_logs* .python-version .vscode/settings.json -# Generated by `task install-skills` (Cursor rules); do not commit -.cursor/rules/ +# Agent rules (generated by `task install-skills`); do not commit +.agents/*.mdc node_modules/ package-lock.json package.json .scratch/* -.opencode/ -AGENTS.md +.opencode/* +.omo/* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e9f0e9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Agent Rules + +This repository provides agent-agnostic rules for AI coding assistants. All +skill files live in `.agents/` and are symlinked into agent-specific directories +at setup time. + +## Directory layout + +``` +.agents/ # Canonical rule files (agent-agnostic) +├── *.mdc # Generated from skills/ by `task install-skills` +└── .gitignore # Prevents committing generated .mdc files + +.cursor/rules -> ../.agents # Symlink for Cursor IDE +``` + +## Adding a new agent + +To add rules support for another IDE or AI agent: + +```bash +# Create a symlink from the agent's expected rules directory to .agents/ +ln -s ../.agents .your-agent/rules +``` + +Common agent rule directories: + +| Agent / IDE | Rules path | Symlink command | +|-------------|-----------|-----------------| +| Cursor | `.cursor/rules/` | `ln -s ../.agents .cursor/rules` | +| Windsurf | `.windsurf/rules/` | `ln -s ../.agents .windsurf/rules` | +| Cline / Roo Code | `.clinerules/` | *(flat file, see below)* | + +> **Note:** Some agents (like Cline) use flat files rather than directories. +> For these, concatenate the relevant `.mdc` files into the agent's expected +> format rather than symlinking. + +## Source of truth + +Rule files are authored as Markdown in `skills/` and installed into `.agents/` +by `task install-skills` (part of `task setup`). The `.mdc` files in `.agents/` +are generated and should not be committed. + +To refresh rules after updating skills: + +```bash +task install-skills +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3576cce..45a0cee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -194,12 +194,15 @@ RUN mkdir -p $ROBOT_LOG_DIR && \ COPY --chown=runwhen:0 .pylintrc.google LICENSE ro requirements.txt . COPY --chown=runwhen:0 .devcontainer/ .devcontainer/ -RUN mkdir -p auth .ssh && \ - chown -R runwhen:0 ${RUNWHEN_HOME}/.devcontainer ${RUNWHEN_HOME}/auth ${RUNWHEN_HOME}/.ssh && \ - chmod -R 0775 ${RUNWHEN_HOME}/ro ${RUNWHEN_HOME}/auth ${RUNWHEN_HOME}/.devcontainer && \ +COPY scripts/init-ide-tools.sh /usr/local/bin/init-ide-tools +RUN chmod +x /usr/local/bin/init-ide-tools && \ + mkdir -p auth .ssh .ide-tools && \ + chown -R runwhen:0 ${RUNWHEN_HOME}/.devcontainer ${RUNWHEN_HOME}/auth ${RUNWHEN_HOME}/.ssh ${RUNWHEN_HOME}/.ide-tools && \ + chmod -R 0775 ${RUNWHEN_HOME}/ro ${RUNWHEN_HOME}/auth ${RUNWHEN_HOME}/.devcontainer ${RUNWHEN_HOME}/.ide-tools && \ chmod 755 ${RUNWHEN_HOME} && \ chmod 700 ${RUNWHEN_HOME}/.ssh && \ - chmod 777 /tmp + chmod 777 /tmp && \ + ln -sf ${RUNWHEN_HOME}/ro /usr/local/bin/ro USER runwhen ENV USER="runwhen" diff --git a/README.md b/README.md index 00c27a7..217dad4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ - **PR review ready** — set `PR_NUMBER` and the environment checks out the PR branch for you. - **Multi-arch** — pre-built for both `linux/amd64` (Codespaces, CI) and `linux/arm64` (Apple Silicon). - **Batteries included** — Robot Framework, `ro` test runner, kubectl, Helm, AWS CLI, Azure CLI, gcloud, Terraform, gh CLI, and more. -- **Works everywhere** — GitHub Codespaces, VS Code devcontainers (local), or plain `docker run`. +- **Agent-ready** — Skills ship as agent rules in `.agents/`, symlinked for Cursor and any AI coding tool. +- **Works everywhere** — GitHub Codespaces, VS Code devcontainers (local), Zen IDE, or plain `docker run`. ## Requirements @@ -104,6 +105,7 @@ These are set in the container automatically: |----------|---------|-------------| | `GITHUB_TOKEN` | *(injected by Codespaces)* | GitHub token for `gh` CLI auth. Codespaces provides this automatically. | | `RW_MODE` | `dev` | Set to `dev` for local development behavior (handled by `rw-core-keywords`). | +| `RW_IDE_TOOLS` | `claude,opencode,zen` | Comma-separated tool names. Creates `~/.{tool}/` config directories at container start. Add any IDE or AI agent — no rebuild needed. | --- @@ -148,12 +150,45 @@ Mount or copy credentials into the `auth/` directory: --- +## IDE & AI Agent Support + +The devcontainer auto-initializes config directories for your IDE or AI coding +agent. Define the tools you use via `RW_IDE_TOOLS` — no image rebuild needed. + +```bash +# Built-in defaults (always available) +RW_IDE_TOOLS=claude,opencode,zen + +# Add Cursor, Windsurf, or any other tool +RW_IDE_TOOLS=claude,opencode,zen,cursor,windsurf +``` + +At container start, `init-ide-tools` creates `~/.{tool}/` for each entry with +correct permissions. Existing directories are left untouched. + +**Supported IDEs & agents (built-in):** + +| IDE / Agent | Config directory | API key env var | +|-------------|-----------------|-----------------| +| Claude Code | `~/.claude/` | `ANTHROPIC_API_KEY` | +| OpenCode | `~/.opencode/` | `OPENAI_API_KEY` | +| Zen IDE | `~/.zen/` | — | + +**Adding your own:** Set `RW_IDE_TOOLS` to include any tool name. The +container creates the directory for you. Mount host configs if needed +via `docker-compose.override.yaml` or devcontainer `mounts`. + +> **Tip for Codespaces users:** Set `RW_IDE_TOOLS` as a Codespaces secret +> to apply across all your codespaces automatically. + +--- + ## CodeBundle authoring skills -The `skills/` directory contains platform-specific authoring guidance that is -automatically installed as [Cursor rules](https://docs.cursor.com/context/rules) -during `task setup`. These give AI assistants (and human authors) context about -generation rules, SLI patterns, and test infrastructure conventions. +The `skills/` directory contains platform-specific authoring guidance, installed +as agent rules into `.agents/` during `task setup`. A symlink at `.cursor/rules` +points to `.agents/` for Cursor IDE compatibility. To add rules for other agents, +symlink their rules directory to `.agents/` — see [AGENTS.md](AGENTS.md) for details. | Skill | Covers | |-------|--------| @@ -167,9 +202,10 @@ generation rules, SLI patterns, and test infrastructure conventions. | `test-infra-azure-devops.md` | DevOps projects, pipelines, agent pools via Terraform | | `test-infra-cloud.md` | Shared conventions across all cloud platforms | -Skills are copied to `.cursor/rules/*.mdc` (the workspace root) at setup time. A -`.gitignore` is placed in that directory to prevent accidental commits. To -re-install after an update, run: +Skills are installed into `.agents/` (the workspace root) at setup time. A +`.gitignore` in that directory prevents accidental commits. Cursor, Windsurf, and +other agents can find rules by symlinking their rules directory to `.agents/`. +To re-install after an update, run: ```bash task install-skills @@ -211,10 +247,15 @@ codecollection-devtools/ │ └── workflows/ │ ├── build-push.yaml # CI: multi-arch build → GHCR + GCP Artifact Registry │ └── pypi.yaml # publish rw-devtools to PyPI (deprecated) -├── skills/ # CodeBundle authoring skills (installed as Cursor rules) -│ ├── generation-rules-*.md # Platform-specific generation rule guides -│ ├── sli-authoring.md # SLI design and implementation guide -│ └── test-infra-*.md # Test infrastructure patterns per platform +├── .agents/ # Agent rules (generated from skills/ by task install-skills) +│ ├── *.mdc # Agent-agnostic rule files +│ └── .gitignore +├── skills/ # Source skill docs (installed as agent rules in .agents/) +│ ├── generation-rules-*.md +│ ├── sli-authoring.md +│ └── test-infra-*.md +├── scripts/ +│ └── init-ide-tools.sh # Runtime IDE config init (driven by RW_IDE_TOOLS) ├── Taskfile.yml # task setup, task verify, task install-skills, task clean ├── Dockerfile # image definition (built by CI, not locally) ├── ro # Robot Framework test runner wrapper @@ -245,7 +286,7 @@ All image builds happen in **GitHub Actions** — never locally: ``` devcontainer opens → pulls pre-built image from GHCR - → workspace root is /workspaces/codecollection-devtools/ (the repo mount) + → workspace root is /workspaces/ (the repo mount) → starts log HTTP server on port 3000 → user runs: task setup REPO=org/repo PR=123 1. clones repo into /home/runwhen/codecollection/ diff --git a/Taskfile.yml b/Taskfile.yml index 01189c1..df1938f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -107,26 +107,32 @@ tasks: - mkdir -p "{{.RUNWHEN_HOME}}/auth" install-skills: - desc: Install CodeBundle authoring skills as Cursor rules + desc: Install CodeBundle authoring skills as agent rules cmds: - | if [ ! -d "{{.SKILLS_SRC}}" ]; then echo "→ Skills directory not found, skipping." exit 0 fi - RULES_DIR="{{.TASKFILE_DIR}}/.cursor/rules" - mkdir -p "$RULES_DIR" + AGENTS_DIR="{{.TASKFILE_DIR}}/.agents" + mkdir -p "$AGENTS_DIR" count=0 for skill in "{{.SKILLS_SRC}}"/*.md; do [ -f "$skill" ] || continue base=$(basename "$skill" .md) - cp "$skill" "$RULES_DIR/${base}.mdc" + cp "$skill" "$AGENTS_DIR/${base}.mdc" count=$((count + 1)) done - if [ ! -f "$RULES_DIR/.gitignore" ]; then - printf '# Injected by codecollection-devtools -- do not commit\n*.mdc\n' > "$RULES_DIR/.gitignore" + if [ ! -f "$AGENTS_DIR/.gitignore" ]; then + printf '# Injected by codecollection-devtools -- do not commit\n*.mdc\n' > "$AGENTS_DIR/.gitignore" fi - echo "→ Installed ${count} Cursor rules to .cursor/rules/" + # Ensure Cursor symlink exists for backward compatibility + CURSOR_LINK="{{.TASKFILE_DIR}}/.cursor/rules" + if [ ! -L "$CURSOR_LINK" ]; then + rm -rf "$CURSOR_LINK" + ln -s ../.agents "$CURSOR_LINK" + fi + echo "→ Installed ${count} agent rules to .agents/" verify: desc: Check that key tools are available diff --git a/docker-compose.yaml b/docker-compose.yaml index 50296ac..ff1568e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,8 @@ services: dockerfile: ./Dockerfile environment: - RW_MODE=dev + - RW_IDE_TOOLS=${RW_IDE_TOOLS:-claude,opencode,zen} volumes: - - .:/home/runwhen + - .:/workspaces ports: - 3000:3000 diff --git a/scripts/init-ide-tools.sh b/scripts/init-ide-tools.sh new file mode 100644 index 0000000..1baf066 --- /dev/null +++ b/scripts/init-ide-tools.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# ============================================================================== +# init-ide-tools.sh — Initialize IDE / AI agent config directories at runtime +# ============================================================================== +# +# Reads the RW_IDE_TOOLS env var (comma-separated tool names) and creates a +# ~/.{toolname} directory for each entry with correct permissions. +# +# No rebuild needed — users add tools by setting ONE env var: +# +# RW_IDE_TOOLS="claude,opencode,zen,cursor,windsurf" +# +# Each tool directory is created under $HOME (the runwhen user's home). +# Existing directories are left untouched. +# +# Default tools (when RW_IDE_TOOLS is unset): claude, opencode, zen +# ============================================================================== + +set -euo pipefail + +IDE_HOME="${HOME:-/home/runwhen}" +TOOLS="${RW_IDE_TOOLS:-claude,opencode,zen}" + +echo "→ Initializing IDE tool config directories: ${TOOLS}" + +IFS=',' read -ra TOOL_LIST <<< "${TOOLS}" +for tool in "${TOOL_LIST[@]}"; do + # Trim whitespace + tool=$(echo "${tool}" | xargs) + [ -z "${tool}" ] && continue + + tool_dir="${IDE_HOME}/.${tool}" + + if [ -d "${tool_dir}" ]; then + echo " · ${tool_dir} (already exists)" + else + mkdir -p "${tool_dir}" + chmod 775 "${tool_dir}" + echo " ✓ ${tool_dir}" + fi +done + +echo "→ IDE tool initialization complete." \ No newline at end of file From f8187a8ccb24c587a37062f77e4623e8029fefd1 Mon Sep 17 00:00:00 2001 From: stewartshea Date: Wed, 5 Aug 2026 12:04:09 -0400 Subject: [PATCH 2/6] Remove generated .gitignore; fix /tmp chmod in devcontainer The install-skills task no longer writes a .agents/.gitignore, and the previously generated file is removed. Also adds sudo to the /tmp chmod command in the devcontainer postCreateCommand to ensure it succeeds. --- .agents/.gitignore | 2 -- .devcontainer/devcontainer.json | 2 +- Taskfile.yml | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .agents/.gitignore diff --git a/.agents/.gitignore b/.agents/.gitignore deleted file mode 100644 index bfcc6ea..0000000 --- a/.agents/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Injected by codecollection-devtools -- do not commit -*.mdc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 60d3800..92f2763 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,7 @@ "OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}" }, - "postCreateCommand": "init-ide-tools && chmod 755 /home/runwhen && chmod 777 /tmp && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", + "postCreateCommand": "init-ide-tools && chmod 755 /home/runwhen && sudo chmod 777 /tmp && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", "postStartCommand": "python -m http.server --bind 0.0.0.0 --directory /robot_logs 3000 &", "features": { diff --git a/Taskfile.yml b/Taskfile.yml index df1938f..b49d577 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -123,9 +123,6 @@ tasks: cp "$skill" "$AGENTS_DIR/${base}.mdc" count=$((count + 1)) done - if [ ! -f "$AGENTS_DIR/.gitignore" ]; then - printf '# Injected by codecollection-devtools -- do not commit\n*.mdc\n' > "$AGENTS_DIR/.gitignore" - fi # Ensure Cursor symlink exists for backward compatibility CURSOR_LINK="{{.TASKFILE_DIR}}/.cursor/rules" if [ ! -L "$CURSOR_LINK" ]; then From 9c1a86b1382f23de08b64e5799048ad79973ba6f Mon Sep 17 00:00:00 2001 From: stewartshea Date: Wed, 5 Aug 2026 20:04:47 -0400 Subject: [PATCH 3/6] Switch devcontainer to docker-compose and rename Zen to Zed - Use docker-compose with override file instead of image field - Mount workspace at /workspaces/codecollection-devtools - Add .env file support and docker-compose.override.yaml.example - Rename all "zen" references to "zed" throughout - Remove hardcoded API key env vars from devcontainer.json - Add IDE config mounting docs to README - Gitignore personal docker-compose.override.yaml --- .devcontainer/devcontainer.json | 30 ++++++++++------- .gitignore | 3 ++ README.md | 50 +++++++++++++++++++++++----- docker-compose.override.yaml.example | 22 ++++++++++++ docker-compose.yaml | 20 ++++++----- scripts/init-ide-tools.sh | 6 ++-- 6 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 docker-compose.override.yaml.example diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 92f2763..c1460c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,13 @@ { "name": "CodeCollection DevTools", - "image": "ghcr.io/runwhen-contrib/codecollection-devtools:latest", + // Pull from GHCR / uncomment if not using docker compose + // "image": "ghcr.io/runwhen-contrib/codecollection-devtools:latest", + // Local testing with Docker Compose (personal mounts via override file): + "dockerComposeFile": ["../docker-compose.yaml", "../docker-compose.override.yaml"], + "service": "devtools", + "workspaceFolder": "/workspaces", "remoteUser": "runwhen", "updateRemoteUserUID": false, - "overrideCommand": false, - - "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces,type=bind,consistency=cached", - "workspaceFolder": "/workspaces", "forwardPorts": [3000], "portsAttributes": { @@ -18,16 +19,21 @@ "containerEnv": { "RW_MODE": "dev", - "ROBOT_LOG_DIR": "/robot_logs", - "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}", - "RW_IDE_TOOLS": "${localEnv:RW_IDE_TOOLS}", - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}", - "OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}" + "ROBOT_LOG_DIR": "/robot_logs" }, - "postCreateCommand": "init-ide-tools && chmod 755 /home/runwhen && sudo chmod 777 /tmp && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", + "postCreateCommand": "init-ide-tools && mkdir -p /home/runwhen/.local/share/opencode && chmod 755 /home/runwhen && sudo chmod 777 /tmp && echo 'cd /workspaces/codecollection-devtools' >> ~/.bashrc && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", "postStartCommand": "python -m http.server --bind 0.0.0.0 --directory /robot_logs 3000 &", + // To mount host IDE configs (e.g. ~/.opencode, ~/.claude) into the container: + // Option A — docker-compose.override.yaml (simpler, ~ expands correctly): + // services.devtools.volumes: ["~/.opencode:/home/runwhen/.opencode"] + // Option B — mounts in this file (requires env vars to be set in IDE process): + // "initializeCommand": "mkdir -p ${localEnv:HOME}/.opencode ${localEnv:HOME}/.claude", + // "mounts": [ + // "source=${localEnv:HOME}/.opencode,target=/home/runwhen/.opencode,type=bind,consistency=cached" + // ] + "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/sshd:1": { @@ -74,7 +80,7 @@ "codespaces": { "openFiles": ["README.md"] }, - "zen": { + "zed": { "extensions": [ "robocorp.robotframework-lsp", "ms-python.python", diff --git a/.gitignore b/.gitignore index bdf6d05..a51187e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ __pycache__ .vscode/settings.json # Agent rules (generated by `task install-skills`); do not commit .agents/*.mdc + +# Personal docker-compose overrides (mounts host paths) +docker-compose.override.yaml node_modules/ package-lock.json package.json diff --git a/README.md b/README.md index 217dad4..009f0aa 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - **Multi-arch** — pre-built for both `linux/amd64` (Codespaces, CI) and `linux/arm64` (Apple Silicon). - **Batteries included** — Robot Framework, `ro` test runner, kubectl, Helm, AWS CLI, Azure CLI, gcloud, Terraform, gh CLI, and more. - **Agent-ready** — Skills ship as agent rules in `.agents/`, symlinked for Cursor and any AI coding tool. -- **Works everywhere** — GitHub Codespaces, VS Code devcontainers (local), Zen IDE, or plain `docker run`. +- **Works everywhere** — GitHub Codespaces, VS Code devcontainers (local), Zed, or plain `docker run`. ## Requirements @@ -105,7 +105,7 @@ These are set in the container automatically: |----------|---------|-------------| | `GITHUB_TOKEN` | *(injected by Codespaces)* | GitHub token for `gh` CLI auth. Codespaces provides this automatically. | | `RW_MODE` | `dev` | Set to `dev` for local development behavior (handled by `rw-core-keywords`). | -| `RW_IDE_TOOLS` | `claude,opencode,zen` | Comma-separated tool names. Creates `~/.{tool}/` config directories at container start. Add any IDE or AI agent — no rebuild needed. | +| `RW_IDE_TOOLS` | `claude,opencode,zed` | Comma-separated tool names. Creates `~/.{tool}/` config directories at container start. Add any IDE or AI agent — no rebuild needed. | --- @@ -157,10 +157,10 @@ agent. Define the tools you use via `RW_IDE_TOOLS` — no image rebuild needed. ```bash # Built-in defaults (always available) -RW_IDE_TOOLS=claude,opencode,zen +RW_IDE_TOOLS=claude,opencode,zed # Add Cursor, Windsurf, or any other tool -RW_IDE_TOOLS=claude,opencode,zen,cursor,windsurf +RW_IDE_TOOLS=claude,opencode,zed,cursor,windsurf ``` At container start, `init-ide-tools` creates `~/.{tool}/` for each entry with @@ -172,14 +172,48 @@ correct permissions. Existing directories are left untouched. |-------------|-----------------|-----------------| | Claude Code | `~/.claude/` | `ANTHROPIC_API_KEY` | | OpenCode | `~/.opencode/` | `OPENAI_API_KEY` | -| Zen IDE | `~/.zen/` | — | +| Zed | `~/.zed/` | — | **Adding your own:** Set `RW_IDE_TOOLS` to include any tool name. The -container creates the directory for you. Mount host configs if needed -via `docker-compose.override.yaml` or devcontainer `mounts`. +container creates an empty `~/.{tool}/` directory for you. + +### Mounting your host IDE configs + +The container creates empty directories — to bring in your existing configs +(API keys, settings, history), mount them from your host machine. + +**Option A: devcontainer.json** (works in Codespaces too) + +Add a `mounts` array to `.devcontainer/devcontainer.json`. You'll also need +`initializeCommand` to make sure the source directories exist on the host +before the container starts: + +```jsonc +// .devcontainer/devcontainer.json +"initializeCommand": "mkdir -p ${localEnv:HOME}/.opencode ${localEnv:HOME}/.claude", +"mounts": [ + "source=${localEnv:HOME}/.opencode,target=/home/runwhen/.opencode,type=bind,consistency=cached", + "source=${localEnv:HOME}/.claude,target=/home/runwhen/.claude,type=bind,consistency=cached" +] +``` + +**Option B: docker-compose.override.yaml** (local devcontainer only) + +Create `docker-compose.override.yaml` alongside the existing +`docker-compose.yaml`. Docker Compose merges it automatically: + +```yaml +# docker-compose.override.yaml +services: + devtools: + volumes: + - ~/.opencode:/home/runwhen/.opencode + - ~/.claude:/home/runwhen/.claude +``` > **Tip for Codespaces users:** Set `RW_IDE_TOOLS` as a Codespaces secret -> to apply across all your codespaces automatically. +> to apply across all your codespaces automatically. Use Option A above +> to mount your configs — Codespaces supports `mounts` in devcontainer.json. --- diff --git a/docker-compose.override.yaml.example b/docker-compose.override.yaml.example new file mode 100644 index 0000000..007c709 --- /dev/null +++ b/docker-compose.override.yaml.example @@ -0,0 +1,22 @@ +# Copy this file to docker-compose.override.yaml and adjust paths for your machine. +# Docker Compose auto-merges override files — no changes to devcontainer.json needed. +# +# docker-compose.override.yaml is gitignored, so your personal mounts stay local. + +services: + devtools: + volumes: + # OpenCode config (model preferences, MCP servers, agent rules): + - ~/.opencode:/home/runwhen/.opencode + - ~/.config/opencode:/home/runwhen/.config/opencode + + # OpenCode auth (API key for OpenRouter / other providers): + # Mount the single auth.json file — no database conflicts. + - ~/.local/share/opencode/auth.json:/home/runwhen/.local/share/opencode/auth.json + + # Claude Code: + - ~/.claude:/home/runwhen/.claude + + # Other tools (uncomment as needed): + # - ~/.cursor:/home/runwhen/.cursor + # - ~/.zed:/home/runwhen/.zed diff --git a/docker-compose.yaml b/docker-compose.yaml index ff1568e..25b891e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,15 +1,19 @@ -version: '3.9' services: devtools: - container_name: devtools - user: runwhen - build: - context: ./ - dockerfile: ./Dockerfile + # Use the upstream + image: ghcr.io/runwhen-contrib/codecollection-devtools:latest + # Build from the local Dockerfile + # build: + # context: ./ + # dockerfile: ./Dockerfile + env_file: + - path: .env + required: false environment: - RW_MODE=dev - - RW_IDE_TOOLS=${RW_IDE_TOOLS:-claude,opencode,zen} + - RW_IDE_TOOLS=${RW_IDE_TOOLS:-claude,opencode,zed} + - ROBOT_LOG_DIR=/robot_logs volumes: - - .:/workspaces + - .:/workspaces/codecollection-devtools ports: - 3000:3000 diff --git a/scripts/init-ide-tools.sh b/scripts/init-ide-tools.sh index 1baf066..7ca05d9 100644 --- a/scripts/init-ide-tools.sh +++ b/scripts/init-ide-tools.sh @@ -8,18 +8,18 @@ # # No rebuild needed — users add tools by setting ONE env var: # -# RW_IDE_TOOLS="claude,opencode,zen,cursor,windsurf" +# RW_IDE_TOOLS="claude,opencode,zed,cursor,windsurf" # # Each tool directory is created under $HOME (the runwhen user's home). # Existing directories are left untouched. # -# Default tools (when RW_IDE_TOOLS is unset): claude, opencode, zen +# Default tools (when RW_IDE_TOOLS is unset): claude, opencode, zed # ============================================================================== set -euo pipefail IDE_HOME="${HOME:-/home/runwhen}" -TOOLS="${RW_IDE_TOOLS:-claude,opencode,zen}" +TOOLS="${RW_IDE_TOOLS:-claude,opencode,zed}" echo "→ Initializing IDE tool config directories: ${TOOLS}" From 175cc32fd920560e1518509000ca53c7b8edcd74 Mon Sep 17 00:00:00 2001 From: steartshea Date: Thu, 6 Aug 2026 04:04:59 +0000 Subject: [PATCH 4/6] Add auth skills for AWS, Azure, GCP, Kubernetes --- skills/auth-aws.md | 290 +++++++++++++++++++++++++++++++++ skills/auth-azure.md | 321 ++++++++++++++++++++++++++++++++++++ skills/auth-gcp.md | 334 ++++++++++++++++++++++++++++++++++++++ skills/auth-kubernetes.md | 267 ++++++++++++++++++++++++++++++ 4 files changed, 1212 insertions(+) create mode 100644 skills/auth-aws.md create mode 100644 skills/auth-azure.md create mode 100644 skills/auth-gcp.md create mode 100644 skills/auth-kubernetes.md diff --git a/skills/auth-aws.md b/skills/auth-aws.md new file mode 100644 index 0000000..cdb91d6 --- /dev/null +++ b/skills/auth-aws.md @@ -0,0 +1,290 @@ +--- +description: How to handle AWS authentication when authoring CodeBundles (secrets, aws cli auth, IRSA, access keys, assume role) +globs: "**/codebundles/aws-*/**,**/.runwhen/**" +alwaysApply: false +--- + +# AWS Authentication -- CodeBundle Authoring + +This guide covers how AWS credentials flow through the RunWhen runtime +(`rw-base-runtime`) and the patterns CodeBundles must follow so that +the `aws` CLI works in **both** production and local dev (`ro`) modes. + +--- + +## Secret Reference Format + +At runtime, the platform injects `RW_SECRETS_KEYS` -- a JSON map of +secret names to provider references: + +```json +{"aws_credentials": "aws:access_key@cli"} +``` + +The reference format is `:@`: + +| Provider | Meaning | Companion secrets required | +|---|---|---| +| `aws:irsa` | IAM Roles for Service Accounts -- pod web-identity token from the environment (`AWS_WEB_IDENTITY_TOKEN_FILE`) | none (optional `AWS_ROLE_ARN` for cross-account) | +| `aws:access_key` | Explicit long-lived keys | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (optional `AWS_SESSION_TOKEN`) | +| `aws:assume_role` | Assume a role, optionally with base credentials | `AWS_ROLE_ARN` (or `aws_role_arn`); optional `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` | +| `aws:default` | Default credential chain (env, shared config, instance profile) | none | +| `aws:workload_identity` | IRSA, used for **EKS kubeconfig** generation | none (optional `AWS_ROLE_ARN`) | +| `aws:cli` | Explicit credentials, used for **EKS kubeconfig** generation | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | + +| Source | Effect at import time | +|---|---| +| `cli` | Calls the matching `aws_utils.aws_login_*()` -- writes credentials into the shared `AWS_CONFIG_DIR` and verifies identity. The `aws` CLI is authenticated for the rest of the suite. | +| `kubeconfig:/` | Generates an EKS kubeconfig via `aws eks update-kubeconfig` and writes it to `$KUBECONFIG` (1-hour filesystem cache). | + +Notes: + +- `aws:irsa@cli` automatically chains `aws_login_assume_role()` when + `AWS_ROLE_ARN` is also present in the secrets config -- this is the + cross-account access pattern. +- `aws:irsa` only supports `@cli`. For EKS kubeconfigs use + `aws:workload_identity@kubeconfig:...`. + +--- + +## Key Insight: Import-Time Auth + +When a CodeBundle runs: + +```robot +${aws_credentials}= RW.Core.Import Secret aws_credentials +``` + +and the configured value is an `aws:*@cli` reference, the **import +itself performs the AWS login**. By the time Suite Initialization +completes, `aws` is already authenticated -- no further auth commands +are needed in production. + +In **dev mode** (`ro`, `RW_FROM_FILE`), no login happens at import. +Auth then resolves through the standard AWS fallback chain: + +1. **Env vars** -- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / + `AWS_SESSION_TOKEN` exported by the developer. +2. **Shared config** -- the developer's `~/.aws/credentials` and + `~/.aws/config` from a prior `aws configure` or SSO login. +3. **Instance/container identity** -- EC2 instance profile, ECS task + role, or IRSA web-identity token when running inside AWS. + +Unlike GCP, there is **no CodeBundle-side login command** to add -- +AWS auth is entirely environment-driven. The CodeBundle's job is to +pass the environment through cleanly and validate auth early. + +--- + +## Required Suite Initialization Pattern + +```robot +Suite Initialization + ${aws_credentials}= RW.Core.Import Secret aws_credentials + ... type=string + ... description=AWS credentials from the workspace (from aws-auth block; e.g. aws:access_key@cli, aws:irsa@cli). + ... pattern=\w* + ${AWS_REGION}= RW.Core.Import User Variable AWS_REGION + ... type=string + ... description=AWS Region + ... pattern=\w* + ... example=us-east-1 + ${OS_PATH}= Get Environment Variable PATH + Set Suite Variable ${AWS_REGION} ${AWS_REGION} + Set Suite Variable ${aws_credentials} ${aws_credentials} + Set Suite Variable + ... &{env} + ... AWS_REGION=${AWS_REGION} + ... AWS_DEFAULT_REGION=${AWS_REGION} + ... PATH=$PATH:${OS_PATH} +``` + +Why each piece matters: + +- **Import `aws_credentials` even if scripts never read its value** -- + the import is what triggers `@cli` login in production. Skipping the + import skips authentication entirely. +- **`AWS_DEFAULT_REGION`** -- many AWS CLI commands and SDKs read this + instead of `AWS_REGION`; set both. +- **Dictionary-style env (`&{env}`)** -- the AWS codebundles use + key=value pairs rather than a JSON string; either form works with + `RW.CLI`. +- **No `aws configure` / `aws login` in the CodeBundle** -- credentials + come from the runtime environment, never from interactive commands. + +Apply this pattern to **both** `runbook.robot` and `sli.robot`. + +--- + +## Validating Auth in Tasks + +Because there is no login command to fail loudly, AWS CodeBundles +should detect auth failures in script output and surface them as +issues (reference: `aws-eks-health`): + +```robot +${auth_failed}= Run Keyword And Return Status Should Contain ${process.stdout} get-caller-identity failed +IF ${auth_failed} + RW.Core.Add Issue + ... severity=2 + ... expected=AWS authentication should succeed + ... actual=AWS authentication failed + ... title=AWS Authentication Failed + ... next_steps=Check AWS credentials with 'aws sts get-caller-identity'\nVerify the IAM role has required permissions + RETURN +END +``` + +Scripts should probe early and print a recognizable marker: + +```bash +if ! aws sts get-caller-identity >/dev/null 2>&1; then + echo "AWS credentials not configured or get-caller-identity failed" + exit 1 +fi +``` + +--- + +## Runtime Environment (what the platform sets for you) + +`runrobot.py` prepares these before Robot starts. Do **not** override +them: + +| Variable | Value | Purpose | +|---|---|---| +| `AWS_CONFIG_DIR` | `$TMPDIR/shared_config//.aws` | AWS config cache, shared across executions but isolated per credential set | +| `AWS_CONFIG_FILE` | `$AWS_CONFIG_DIR/config` | | +| `AWS_SHARED_CREDENTIALS_FILE` | `$AWS_CONFIG_DIR/credentials` | written by `aws:*@cli` imports | +| `AWS_EC2_METADATA_DISABLED` | `true` (defaulted) | prevents metadata-server hangs outside AWS | +| `KUBECONFIG` | execution-specific `.kube/config` | written by `aws:*@kubeconfig:...` imports | + +The `` is derived from the workspace, vault config, and the +secret provider references in use -- two different roles or key pairs +never share a config dir. Overriding `AWS_CONFIG_DIR` or the +credentials-file paths in a CodeBundle breaks caching and isolation. + +The base image ships the `aws` CLI v2. You do not need to install it. + +--- + +## Scripts That Need Direct API Access + +The platform's intent is that **Import Secret handles all auth**. +Scripts should use the `aws` CLI with `--output json` / `--query` +directly -- no SigV4 signing, no credential-file parsing, no +`~/.aws` manipulation. Every AWS API the CLI can't reach directly is +rare; when one comes up, use the CLI's own escape hatches +(`aws ... --endpoint-url`, or `aws sts` for identity) rather +than hand-signing requests. + +**Do not parse the materialized secret file.** With `aws:*@cli` +providers the secret value is a *status string* +(`"AWS CLI authenticated with access key a1b2c3d4..."`), not +credentials. A script that reads `secret_file__aws_credentials` +expecting keys gets garbage. Credentials live in the runtime-managed +`AWS_SHARED_CREDENTIALS_FILE`, written by the import. + +--- + +## Shell Script Conventions + +```bash +#!/usr/bin/env bash +set -euo pipefail + +: "${AWS_REGION:?Must set AWS_REGION}" + +# aws CLI is already authenticated by the runtime/Suite Initialization. +aws eks describe-cluster --name "$EKS_CLUSTER_NAME" --region "$AWS_REGION" +``` + +Rules: + +1. **Never run `aws configure`, `aws sso login`, or write to + `~/.aws`** from a script. +2. **Always start with `set -euo pipefail`** and validate required env + vars. +3. **Pass `--region` explicitly** or rely on `AWS_DEFAULT_REGION`; + never hardcode regions. +4. **Degrade gracefully** on discovery commands (`2>/dev/null || echo "[]"`) + so a permission gap yields an empty result, not a crash. +5. **Probe auth early** with `aws sts get-caller-identity` and print a + greppable failure marker (see above). + +--- + +## EKS Kubeconfig Variant + +For CodeBundles that shell out to `kubectl` against EKS, import a +kubeconfig secret instead of (or in addition to) the CLI secret: + +```json +{ + "aws_credentials": "aws:irsa@cli", + "kubeconfig": "aws:workload_identity@kubeconfig:us-east-1/my-cluster" +} +``` + +The import writes the kubeconfig to `$KUBECONFIG` (already set by the +runtime). Reference: `aws-eks-health`. + +--- + +## Generation Rules / Templates + +In `.runwhen/templates/*-taskset.yaml`, always use the auth include: + +```yaml + secretsProvided: + {% if wb_version %} + {% include "aws-auth.yaml" ignore missing %} + {% else %} + - name: aws_credentials + workspaceKey: {{custom.aws_credentials_secret | default("aws_credentials")}} + {% endif %} +``` + +--- + +## Common Mistakes + +1. **Rolling your own auth in scripts** -- SigV4 signing, parsing + credential files, custom session-token handling. This is the + platform's job: Import Secret authenticates the session, scripts + use the `aws` CLI. + +2. **Parsing the materialized secret file** -- with `aws:*@cli` the + secret value is a status string, not keys. Scripts must never read + `secret_file__aws_credentials` expecting credentials. + +3. **Forgetting to import the secret** -- no import, no `@cli` login, + every AWS call fails with `Unable to locate credentials`. + +4. **Running `aws configure` in scripts** -- clobbers the + runtime-managed credential files and breaks isolation between + credential contexts. + +5. **Hardcoding regions or account IDs** -- always import via + `RW.Core.Import User Variable` and pass `--region`. + +6. **Not surfacing auth failures** -- a silent `AccessDenied` looks + like "no resources found". Probe with `get-caller-identity` and + raise a severity-2 issue. + +7. **Overriding `AWS_CONFIG_DIR` / `AWS_SHARED_CREDENTIALS_FILE`** -- + destroys the shared credential cache and per-credential isolation. + +6. **Hardcoding credentials** -- always use the `aws-auth.yaml` + include in templates; never inline keys in templates or scripts. + +--- + +## Reference Implementation + +`codebundles/aws-eks-health` is the canonical example: + +- Suite Init: import `aws_credentials` + region vars, build env, no + login commands +- Scripts: plain `aws` calls with early `get-caller-identity` probe +- Templates: `{% include "aws-auth.yaml" ignore missing %}` diff --git a/skills/auth-azure.md b/skills/auth-azure.md new file mode 100644 index 0000000..be3df3f --- /dev/null +++ b/skills/auth-azure.md @@ -0,0 +1,321 @@ +--- +description: How to handle Azure authentication when authoring CodeBundles (secrets, az cli auth, service principals, managed identity) +globs: "**/codebundles/azure-*/**,**/.runwhen/**" +alwaysApply: false +--- + +# Azure Authentication -- CodeBundle Authoring + +This guide covers how Azure credentials flow through the RunWhen +runtime (`rw-base-runtime`) and the patterns CodeBundles must follow +so that the `az` CLI works in **both** production and local dev (`ro`) +modes. + +--- + +## Secret Reference Format + +At runtime, the platform injects `RW_SECRETS_KEYS` -- a JSON map of +secret names to provider references: + +```json +{"azure_credentials": "azure:sp@cli"} +``` + +The reference format is `:@`: + +| Provider | Meaning | Companion secrets required | +|---|---|---| +| `azure:sp` | Service Principal (client credentials) | `az_clientId`, `az_tenantId`, `az_clientSecret` | +| `azure:identity` | Managed Identity (system- or user-assigned, from the environment) | none | + +| Source | Effect at import time | +|---|---| +| `cli` | Calls `azure_utils.az_login()` -- runs `az login --service-principal` (SP) or `az login --identity` (managed identity) against the shared `AZURE_CONFIG_DIR`. The `az` CLI is authenticated for the rest of the suite. | +| `kubeconfig:/` | Generates an AKS kubeconfig via `az aks get-credentials` and writes it to `$KUBECONFIG` (1-hour filesystem cache). | + +Note the companion-secret naming: Azure SP secrets use the +`az_clientId` / `az_tenantId` / `az_clientSecret` camelCase keys in the +secrets config (not `AZURE_CLIENT_ID` env-style names). + +--- + +## Key Insight: Import-Time Auth + +When a CodeBundle runs: + +```robot +${azure_credentials}= RW.Core.Import Secret azure_credentials +``` + +and the configured value is an `azure:*@cli` reference, the **import +itself performs the `az login`**. By the time Suite Initialization +completes, `az` is already authenticated -- no further auth commands +are needed in production. + +In **dev mode** (`ro`, `RW_FROM_FILE`), no login happens at import. +Auth then resolves through a fallback chain: + +1. **Env vars** -- `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` / + `AZURE_TENANT_ID` exported by the developer (consumed by Azure SDKs; + the `az` CLI ignores these unless a script calls `az login` with + them). +2. **Local `az` session** -- a developer who previously ran + `az login` has cached tokens. Note `ro` redirects + `AZURE_CONFIG_DIR` to an isolated temp dir, so the developer's + `~/.azure` session is only visible when their environment + propagates it. + +Azure client secrets **expire** -- the most common production failure +is an expired `az_clientSecret`. CodeBundles should detect this and +raise a clear issue (see below). + +--- + +## Required Suite Initialization Pattern + +Azure CodeBundles should import credentials with error handling so an +expired/invalid secret produces a severity-1 issue instead of a bare +suite failure (reference: `azure-aks-triage`): + +```robot +Suite Initialization + ${azure_credentials_status}= Run Keyword And Return Status + ... RW.Core.Import Secret azure_credentials type=string description=The secret containing AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID pattern=\w* + IF ${azure_credentials_status} + ${azure_credentials}= RW.Core.Import Secret + ... azure_credentials + ... type=string + ... description=The secret containing AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID + ... pattern=\w* + END + IF not ${azure_credentials_status} + RW.Core.Add Issue + ... severity=1 + ... expected=Azure service principal credentials should be valid and not expired + ... actual=Azure service principal authentication failed during suite initialization + ... title=Azure Authentication Failed - Service Principal Credentials Expired or Invalid + ... details=Azure authentication failed during suite setup. The service principal client secret may be expired or invalid. + ... next_steps=Renew Azure service principal client secret in Azure portal: https://aka.ms/NewClientSecret\nUpdate workspace secrets with new client secret\nVerify AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_CLIENT_SECRET are correct + ${azure_credentials}= Set Variable ${EMPTY} + END + ${AZ_RESOURCE_GROUP}= RW.Core.Import User Variable AZ_RESOURCE_GROUP + ... type=string + ... description=The resource group to perform actions against. + ... pattern=\w* + ${OS_PATH}= Get Environment Variable PATH + Set Suite Variable ${AZ_RESOURCE_GROUP} ${AZ_RESOURCE_GROUP} + Set Suite Variable ${azure_credentials} ${azure_credentials} + Set Suite Variable + ... &{env} + ... AZ_RESOURCE_GROUP=${AZ_RESOURCE_GROUP} + ... PATH=$PATH:${OS_PATH} +``` + +Why each piece matters: + +- **Error-handled import** -- expired client secrets are the #1 Azure + failure mode; a clear issue beats a raw `ImportError` suite failure. +- **Import triggers login** -- in production with `azure:*@cli`, the + import performs `az login`. Skipping the import skips auth entirely. +- **No `az login` in tasks or scripts** -- auth happens at import; the + `AZURE_CONFIG_DIR` cache carries it through the suite. +- **`AZURE_SUBSCRIPTION_ID`** -- import it (as a user variable or part + of the credentials secret) when scripts need + `az account set --subscription`. + +Apply this pattern to **both** `runbook.robot` and `sli.robot`. + +--- + +## Validating Auth in Tasks + +Detect auth failures in script output and surface them (reference: +`azure-aks-triage`): + +```robot +${auth_failed}= Run Keyword And Return Status Should Contain ${resource_health.stdout} Authentication failed +${token_expired}= Run Keyword And Return Status Should Contain ${resource_health.stdout} client secret keys +IF ${auth_failed} or ${token_expired} + RW.Core.Add Issue + ... severity=2 + ... expected=Azure authentication should succeed + ... actual=Azure authentication failed + ... title=Azure Authentication Failed + ... next_steps=Check Azure service principal credentials are not expired\nRenew client secret: https://aka.ms/NewClientSecret\nTest with: az login --service-principal --username --password --tenant + RETURN +END +``` + +Scripts should probe early and print a recognizable marker: + +```bash +if ! az account show >/dev/null 2>&1; then + echo "Authentication failed: az account show could not retrieve the current account" + exit 1 +fi +``` + +--- + +## Runtime Environment (what the platform sets for you) + +`runrobot.py` prepares these before Robot starts. Do **not** override +them: + +| Variable | Value | Purpose | +|---|---|---| +| `AZURE_CONFIG_DIR` | `$TMPDIR/shared_config//.azure` | az CLI token cache, shared across executions but isolated per credential set | +| `AZURE_CORE_COLLECT_TELEMETRY` | `false` (defaulted) | disables az telemetry | +| `KUBECONFIG` | execution-specific `.kube/config` | written by `azure:*@kubeconfig:...` imports | + +The `` is derived from the workspace, vault config, and the +secret provider references in use -- two different tenants or service +principals never share a config dir. Overriding `AZURE_CONFIG_DIR` in +a CodeBundle breaks token caching and cross-execution isolation. + +The base image ships `az` and `kubelogin`. You do not need to install +them. + +--- + +## Scripts That Need Bearer Tokens (REST) + +The platform's intent is that **Import Secret handles all auth**. +Scripts should use the `az` CLI directly -- no client-secret handling, +no `az login`, no MSAL/token-endpoint code. Only fetch a token when +hitting a REST endpoint with no `az` equivalent (e.g. raw +`management.azure.com` or Resource Graph calls), and fetch it from the +session the import established: + +```bash +fetch_access_token() { + local token + token=$(az account get-access-token --query accessToken -o tsv 2>/dev/null) || true + if [ -z "${token:-}" ]; then + echo "Failed to retrieve an Azure access token from the authenticated az session." >&2 + return 1 + fi + echo "$token" +} +``` + +That is the entire token story. If this fails, the answer is to fix +the CodeBundle's Suite Initialization -- not to add credential +handling. + +**Do not parse the materialized secret file.** With `azure:*@cli` +providers the secret value is a *status string* +(`"Azure CLI authenticated for tenant a1b2c3d4... at ..."`), not a +credential bundle. A script that reads `secret_file__azure_credentials` +expecting `AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` gets garbage -- and +any script that then runs its own `az login` with those "credentials" +clobbers the shared token cache. + +--- + +## Shell Script Conventions + +```bash +#!/usr/bin/env bash +set -euo pipefail + +: "${AZ_RESOURCE_GROUP:?Must set AZ_RESOURCE_GROUP}" + +# az CLI is already authenticated by the runtime/Suite Initialization. +az aks show --resource-group "$AZ_RESOURCE_GROUP" --name "$AKS_CLUSTER" -o json +``` + +Rules: + +1. **Never run `az login` inside task scripts.** Auth happens at + import time in Suite Initialization. +2. **Always start with `set -euo pipefail`** and validate required env + vars. +3. **Probe auth early** with `az account show` and print a greppable + failure marker. +4. **Degrade gracefully** on discovery commands (`2>/dev/null || echo "[]"`). +5. **Watch for secret expiry markers** in stderr: `AADSTS7000222` + (expired client secret), `client secret keys`, `Authentication failed`. + +--- + +## AKS Kubeconfig Variant + +For CodeBundles that shell out to `kubectl` against AKS, import a +kubeconfig secret instead of (or in addition to) the CLI secret: + +```json +{ + "azure_credentials": "azure:sp@cli", + "kubeconfig": "azure:sp@kubeconfig:my-resource-group/my-cluster" +} +``` + +The import writes the kubeconfig to `$KUBECONFIG` (already set by the +runtime) via `az aks get-credentials`. Reference: `azure-aks-triage`. + +--- + +## Generation Rules / Templates + +In `.runwhen/templates/*-taskset.yaml`, always use the auth include: + +```yaml + secretsProvided: + {% if wb_version %} + {% include "azure-auth.yaml" ignore missing %} + {% else %} + - name: azure_credentials + workspaceKey: {{custom.azure_credentials_secret | default("azure_credentials")}} + {% endif %} +``` + +--- + +## Common Mistakes + +1. **Rolling your own auth in scripts** -- client-secret handling, + token-endpoint calls, MSAL code. This is the platform's job: Import + Secret authenticates the session, scripts use `az`. For REST-only + endpoints, `az account get-access-token` is the whole mechanism. + +2. **Parsing the materialized secret file** -- with `azure:*@cli` the + secret value is a status string, not a credential bundle. Scripts + must never read `secret_file__azure_credentials` expecting + `AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET`. + +3. **Letting an expired secret crash the suite** -- use the + error-handled import pattern and raise a severity-1 issue with + renewal steps. + +4. **Running `az login` in scripts** -- clobbers the shared token + cache and can race with other executions using the same credential + context. + +5. **Wrong companion-secret names** -- the provider expects + `az_clientId` / `az_tenantId` / `az_clientSecret` in the secrets + config; `AZURE_CLIENT_ID`-style names are not read by the + `azure:sp` provider. + +6. **Forgetting `az account set --subscription`** -- multi-subscription + tenants need the subscription selected; pass + `AZURE_SUBSCRIPTION_ID` and set it early in scripts. + +7. **Overriding `AZURE_CONFIG_DIR`** -- destroys token caching and + per-credential isolation. + +8. **Not surfacing auth failures** -- a silent 401 looks like "no + resources found". Probe with `az account show` and grep for + expiry markers. + +--- + +## Reference Implementation + +`codebundles/azure-aks-triage` is the canonical example: + +- Suite Init: error-handled `azure_credentials` import, severity-1 + issue on failure, no login commands in tasks +- Scripts: plain `az` calls with early `az account show` probe +- Templates: `{% include "azure-auth.yaml" ignore missing %}` diff --git a/skills/auth-gcp.md b/skills/auth-gcp.md new file mode 100644 index 0000000..4f0e876 --- /dev/null +++ b/skills/auth-gcp.md @@ -0,0 +1,334 @@ +--- +description: How to handle GCP authentication when authoring CodeBundles (secrets, gcloud/bq auth, service accounts, ADC) +globs: "**/codebundles/gcp-*/**,**/codebundles/gke-*/**,**/.runwhen/**" +alwaysApply: false +--- + +# GCP Authentication -- CodeBundle Authoring + +This guide covers how GCP credentials flow through the RunWhen runtime +(`rw-base-runtime`) and the patterns CodeBundles must follow so that +`gcloud`, `bq`, and `gsutil` work in **both** production and local dev +(`ro`) modes. + +--- + +## Secret Reference Format + +At runtime, the platform injects `RW_SECRETS_KEYS` -- a JSON map of +secret names to provider references: + +```json +{"gcp_credentials": "gcp:adc@cli"} +``` + +The reference format is `:@`: + +| Provider | Meaning | Companion secrets required | +|---|---|---| +| `gcp:adc` | Application Default Credentials (ambient identity: GCE/GKE metadata server, Workload Identity, or an existing `GOOGLE_APPLICATION_CREDENTIALS`) | none (optional `gcp_projectId`) | +| `gcp:sa` | Service Account key | `gcp_projectId`, `gcp_serviceAccountKey` | + +| Source | Effect at import time | +|---|---| +| `cli` | Calls `gcp_utils.gcloud_login()` -- runs `gcloud auth activate-service-account` (SA) or verifies ADC, then `gcloud config set project`. gcloud/bq are authenticated for the rest of the suite. | +| `kubeconfig:/` | Generates a GKE kubeconfig via `get-credentials` and writes it to `$KUBECONFIG` (1-hour filesystem cache). | + +--- + +## Key Insight: Import-Time Auth + +When a CodeBundle runs: + +```robot +${gcp_credentials}= RW.Core.Import Secret gcp_credentials +``` + +and the configured value is `gcp:adc@cli` or `gcp:sa@cli`, the +**import itself performs the gcloud login**. By the time Suite +Initialization completes, `gcloud`/`bq` are already authenticated -- +no further auth commands are needed in production. + +In **dev mode** (`ro`, `RW_FROM_FILE`), no login happens at import. +Auth then resolves through a fallback chain: + +1. **Key file from the secret** -- `RW_FROM_FILE` (or an env var) + provides a raw JSON key; `secret_file__` materializes it and + `gcloud auth activate-service-account --key-file=...` logs in. +2. **`GOOGLE_APPLICATION_CREDENTIALS` already set** -- if the developer + exported a path to a key or ADC file, `gcloud`/`bq` use it directly. +3. **Local gcloud session / ADC** -- a developer who previously ran + `gcloud auth login` or `gcloud auth application-default login` is + already authenticated; `gcloud`/`bq` pick up those ambient + credentials with no CodeBundle action at all. + +The `|| true` on the activation command is what makes this chain work: +if there is no key file (cases 2-3), the command fails harmlessly and +execution falls through to the ambient credentials. Note that `ro` +redirects `CLOUDSDK_CONFIG` to an isolated temp dir, so a local gcloud +*session* in `~/.config/gcloud` is only visible when the developer's +environment propagates it (e.g. via `GOOGLE_APPLICATION_CREDENTIALS` +pointing at `~/.config/gcloud/application_default_credentials.json`) -- +but ADC via that file path works regardless of `CLOUDSDK_CONFIG`. + +**Therefore every GCP CodeBundle must include the defensive auth +pattern below** so it works in all of these modes. + +--- + +## Required Suite Initialization Pattern + +```robot +Suite Initialization + ${gcp_credentials}= RW.Core.Import Secret gcp_credentials + ... type=string + ... description=GCP service account json used to authenticate with GCP APIs. + ... pattern=\w* + ... example={"type": "service_account","project_id":"myproject-ID"} + ${GCP_PROJECT_ID}= RW.Core.Import User Variable GCP_PROJECT_ID + ... type=string + ... description=The GCP Project ID to scope the API to. + ... pattern=\w* + ... example=myproject-id + ${OS_PATH}= Get Environment Variable PATH + Set Suite Variable ${GCP_PROJECT_ID} ${GCP_PROJECT_ID} + Set Suite Variable ${gcp_credentials} ${gcp_credentials} + Set Suite Variable + ... ${env} + ... {"CLOUDSDK_CORE_PROJECT":"${GCP_PROJECT_ID}","GOOGLE_APPLICATION_CREDENTIALS":"./${gcp_credentials.key}","PATH":"$PATH:${OS_PATH}","GCP_PROJECT_ID":"${GCP_PROJECT_ID}"} + RW.CLI.Run CLI + ... cmd=gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS" || true + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} +``` + +Why each piece matters: + +- **`secret_file__gcp_credentials=${gcp_credentials}`** -- the + `secret_file__` prefix tells `RW.CLI` to materialize the secret value + as a file named `${gcp_credentials.key}` in the task's working + directory. +- **`"GOOGLE_APPLICATION_CREDENTIALS":"./${gcp_credentials.key}"`** -- + points gcloud/bq at that materialized file. +- **`gcloud auth activate-service-account ... || true`** -- in dev mode + with a key file this performs the real login; without one it fails + harmlessly and gcloud/bq fall back to ambient local auth + (`GOOGLE_APPLICATION_CREDENTIALS`, local ADC). In production with + `gcp:adc@cli` / `gcp:sa@cli`, import-time auth already happened and + the secret value is a status string (not a key file), so the command + fails harmlessly there too. **Never omit `|| true`.** +- **`CLOUDSDK_CORE_PROJECT`** -- sets the default project for all + gcloud/bq commands so scripts don't need `--project` everywhere. + +Apply this pattern to **both** `runbook.robot` and `sli.robot`. + +--- + +## Runtime Environment (what the platform sets for you) + +`runrobot.py` prepares these before Robot starts. Do **not** override +them: + +| Variable | Value | Purpose | +|---|---|---| +| `CLOUDSDK_CONFIG` | `$TMPDIR/shared_config//.gcloud` | gcloud credential cache, shared across executions but isolated per credential set | +| `AZURE_CONFIG_DIR` | `$TMPDIR/shared_config//.azure` | same for Azure | +| `AWS_CONFIG_DIR` | `$TMPDIR/shared_config//.aws` | same for AWS | +| `KUBECONFIG` | execution-specific `.kube/config` | written by `gcp:*@kubeconfig:...` imports | +| `CODEBUNDLE_TEMP_DIR` | execution-specific `cb-temp/` | scratch space | + +The `` is derived from the workspace, vault config, and the +secret provider references in use -- two different service accounts +never share a gcloud config dir. Overriding `CLOUDSDK_CONFIG` in a +CodeBundle breaks credential caching and cross-execution isolation. + +The base image ships `gcloud` (+ `gke-gcloud-auth-plugin`), `bq`, and +`gsutil`. You do not need to install them. + +--- + +## Scripts That Need Bearer Tokens (curl / REST) + +The platform's intent is that **Import Secret handles all auth**. +Scripts should use `gcloud` / `bq` / `gsutil` directly -- no JWT +signing, no key-file parsing, no custom token exchange. Only fetch a +token when hitting a REST endpoint with no gcloud equivalent (e.g. +PromQL on `monitoring.googleapis.com`), and fetch it from the session +the import established: + +```bash +fetch_access_token() { + local token + token=$(gcloud auth application-default print-access-token 2>/dev/null) || true + if [ -z "${token:-}" ]; then + token=$(gcloud auth print-access-token 2>/dev/null) || true + fi + if [ -z "${token:-}" ]; then + echo "Failed to retrieve a GCP access token from the authenticated gcloud session." >&2 + return 1 + fi + echo "$token" +} +``` + +That is the entire token story. If this fails, the answer is to fix +the CodeBundle's Suite Initialization -- not to add key handling. + +**Do not put `GOOGLE_APPLICATION_CREDENTIALS` in the task env.** With +`gcp:adc@cli` the secret value is a status string, not a key; pointing +the env var at the materialized file poisons the ambient ADC the +import established (gcloud's ADC lookup reads it, chokes, and every +subsequent call 401s). The Suite Init activate command references the +materialized path directly instead: + +```robot +Set Suite Variable +... ${env} +... {"PATH":"$PATH:${OS_PATH}","GCP_PROJECT_ID":"${GCP_PROJECT_ID}"} +RW.CLI.Run CLI +... cmd=gcloud auth activate-service-account --key-file="./${gcp_credentials.key}" || true +... env=${env} +... secret_file__gcp_credentials=${gcp_credentials} +``` + +After activation, the session lives in `CLOUDSDK_CONFIG` -- nothing +else needs the file. + +--- + +## Shell Script Conventions + +Scripts receive auth through the environment -- they should **not** +re-authenticate: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +: "${GCP_PROJECT_ID:?Must set GCP_PROJECT_ID}" + +# gcloud/bq are already authenticated by Suite Initialization. +# Just use them: +datasets=$(bq --project_id "$GCP_PROJECT_ID" ls --format=json 2>/dev/null || echo "[]") +``` + +Rules: + +1. **Never call `gcloud auth` inside task scripts.** Auth happens once + in Suite Initialization. +2. **Always start with `set -euo pipefail`** and validate required env + vars with `: "${VAR:?Must set VAR}"`. +3. **Prefer `bq ls` / `bq show` over `INFORMATION_SCHEMA` queries.** + INFORMATION_SCHEMA requires additional dataset-level permissions + (`bigquery.tables.list`, `bigquery.routines.list`) that read-only + service accounts often lack; `bq ls`/`bq show` work with basic + viewer roles. +4. **Degrade gracefully**: `2>/dev/null || echo "[]"` on discovery + commands so a permission gap produces an empty result, not a crash. +5. **Check all BigQuery access field variants** -- `bq show` returns + public principals under `specialGroup`, `iamMember`, or + `groupByEmail` depending on how they were granted: + + ```bash + jq '[.[] | select( + .specialGroup == "allUsers" or + .iamMember == "allUsers" or + .groupByEmail == "allUsers" + )]' + ``` + +6. **Emit JSON with `printf`, not `echo` with escaped backticks** -- + `\\\`` in a double-quoted string produces `\`` which is invalid + JSON. +7. **Avoid `echo "$data" | while read` loops** -- the pipe runs the + loop in a subshell and all variable assignments are lost. Use + process substitution: `while read ... done < <(...)`. + +--- + +## GKE Kubeconfig Variant + +For CodeBundles that shell out to `kubectl` against GKE, import a +kubeconfig secret instead of (or in addition to) the CLI secret: + +```json +{ + "gcp_credentials": "gcp:adc@cli", + "kubeconfig": "gcp:adc@kubeconfig:my-cluster/us-central1" +} +``` + +The import writes the kubeconfig to `$KUBECONFIG` (already set by the +runtime) and `gcloud container clusters get-credentials` is handled by +the platform. Reference: `gke-cluster-health`. + +--- + +## Generation Rules / Templates + +In `.runwhen/templates/*-taskset.yaml`, always use the auth include +rather than hardcoding secret references: + +```yaml + secretsProvided: + {% if wb_version %} + {% include "gcp-auth.yaml" ignore missing %} + {% else %} + - name: gcp_credentials + workspaceKey: {{custom.gcp_credentials_secret | default("gcp_credentials_json")}} + {% endif %} +``` + +--- + +## Common Mistakes + +1. **Rolling your own auth in scripts** -- JWT signing, key-file + parsing, custom token exchange. This is the platform's job: Import + Secret authenticates the session, scripts use `gcloud`/`bq`/`gsutil`. + Found in `gcp-bucket-health` before fixes. + +2. **Putting `GOOGLE_APPLICATION_CREDENTIALS` in the task env** -- with + `gcp:adc@cli` the materialized secret file holds a status string, + not a key; the env var then poisons the ambient ADC the import + established and every API call 401s. Keep it out of `${env}`; the + Suite Init activate command uses `./${gcp_credentials.key}` directly. + +3. **Missing `gcloud auth activate-service-account` in Suite Init** -- + works in production (import-time auth) but fails in dev mode where + the secret is a raw key. Found in `gcp-bucket-health`, + `gcp-bigquery-dataset-health` before fixes. + +4. **Omitting `|| true`** -- in production the secret value may be a + status string (`"GCP CLI authenticated for project ..."`), not a + JSON key. Without `|| true` the suite dies in Suite Setup. + +5. **Chaining `gcloud auth ... &&` into every task command** -- + redundant re-authentication on every task. Authenticate once in + Suite Initialization; tasks inherit the `CLOUDSDK_CONFIG` cache. + +6. **Overriding `CLOUDSDK_CONFIG`** -- destroys the shared credential + cache and per-credential isolation the runtime manages. + +7. **Using INFORMATION_SCHEMA for BigQuery discovery** -- fails with + `Access Denied` for viewer-level service accounts. Use `bq ls` / + `bq show`. + +8. **Only checking `.iamMember` for public access** -- misses + `allAuthenticatedUsers` grants, which `bq show` reports under + `specialGroup`. + +9. **Hardcoding credentials or project IDs** -- always import via + `RW.Core.Import Secret` / `RW.Core.Import User Variable` and use the + `gcp-auth.yaml` include in templates. + +--- + +## Reference Implementation + +`codebundles/gke-cluster-health` is the canonical example: + +- Suite Init: import secret, build env, `gcloud auth + activate-service-account ... || true` +- Scripts: plain `gcloud` calls, no auth logic +- Templates: `{% include "gcp-auth.yaml" ignore missing %}` diff --git a/skills/auth-kubernetes.md b/skills/auth-kubernetes.md new file mode 100644 index 0000000..a9ccbb9 --- /dev/null +++ b/skills/auth-kubernetes.md @@ -0,0 +1,267 @@ +--- +description: How to handle Kubernetes authentication when authoring CodeBundles (kubeconfig secrets, cloud-generated kubeconfigs, KUBECONFIG handling) +globs: "**/codebundles/k8s-*/**,**/codebundles/*-cluster-*/**" +alwaysApply: false +--- + +# Kubernetes Authentication -- CodeBundle Authoring + +This guide covers how kubeconfigs flow through the RunWhen runtime +(`rw-base-runtime`) and the patterns CodeBundles must follow so that +`kubectl` (or `oc`) works in **both** production and local dev (`ro`) +modes. + +For cloud-provider CLI auth (gcloud/az/aws), see the companion skills +`auth-gcp.md`, `auth-azure.md`, and `auth-aws.md`. This skill covers +only the kubeconfig side. + +--- + +## Secret Reference Format + +At runtime, the platform injects `RW_SECRETS_KEYS` -- a JSON map of +secret names to provider references. Kubernetes access comes from +either a **direct kubeconfig** or a **cloud-generated kubeconfig**: + +```json +{"kubeconfig": "k8s:file@secret/my-cluster-kubeconfig/kubeconfig"} +``` + +### Direct kubeconfig providers + +| Provider | Meaning | +|---|---| +| `k8s:file@//[@namespace]` | Read the kubeconfig from a Secret/ConfigMap in the cluster the worker pod runs in | +| `k8s:env@//[@namespace]` | Same, resolved via the pod's environment/service account | + +### Cloud-generated kubeconfig providers + +These authenticate to the cloud first, then generate a kubeconfig for +a managed cluster: + +| Provider reference | Cluster type | Companion secrets | +|---|---|---| +| `gcp:adc@kubeconfig:/` | GKE via ADC | none | +| `gcp:sa@kubeconfig:/` | GKE via service account | `gcp_projectId`, `gcp_serviceAccountKey` | +| `azure:identity@kubeconfig:/` | AKS via managed identity | none | +| `azure:sp@kubeconfig:/` | AKS via service principal | `az_clientId`, `az_tenantId`, `az_clientSecret` | +| `aws:workload_identity@kubeconfig:/` | EKS via IRSA | none (optional `AWS_ROLE_ARN`) | +| `aws:cli@kubeconfig:/` | EKS via explicit keys | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | + +**Effect at import time (all providers):** the kubeconfig content is +written to the path in `$KUBECONFIG` (set by the runtime before Robot +starts), and the Robot suite variable `${KUBECONFIG}` is set to that +path. Generated kubeconfigs are cached on the filesystem for 1 hour, +keyed by cluster and credential identity. + +--- + +## Key Insight: Import-Time Kubeconfig Materialization + +When a CodeBundle runs: + +```robot +${kubeconfig}= RW.Core.Import Secret kubeconfig +``` + +the runtime **writes the kubeconfig to `$KUBECONFIG` during the +import**. `kubectl` needs no further setup in production. + +Unlike cloud CLI secrets -- whose `@cli` values are *status strings* +-- kubeconfig secret values are always real kubeconfig YAML in both +production and dev mode, so materializing them with `secret_file__` +is safe and meaningful. + +In **dev mode** (`ro`, `RW_FROM_FILE`), the secret is the kubeconfig +YAML as a string. No file is written at import, so the CodeBundle must +materialize it itself -- this is what `secret_file__kubeconfig` does +(see below). + +--- + +## Required Suite Initialization Pattern + +```robot +Suite Initialization + ${kubeconfig}= RW.Core.Import Secret kubeconfig + ... type=string + ... description=The kubernetes kubeconfig yaml containing connection configuration used to connect to cluster(s). + ... pattern=\w* + ... example=For examples, start here https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/ + ${KUBERNETES_DISTRIBUTION_BINARY}= RW.Core.Import User Variable KUBERNETES_DISTRIBUTION_BINARY + ... type=string + ... description=Which binary to use for Kubernetes CLI commands. + ... enum=[kubectl,oc] + ... example=kubectl + ... default=kubectl + ${CONTEXT}= RW.Core.Import User Variable CONTEXT + ... type=string + ... description=Which Kubernetes context to operate within. + ... pattern=\w* + ... example=my-main-cluster + ${NAMESPACE}= RW.Core.Import User Variable NAMESPACE + ... type=string + ... description=The name of the namespace to search. + ... pattern=\w* + ... example=my-namespace + ${OS_PATH}= Get Environment Variable PATH + Set Suite Variable ${kubeconfig} ${kubeconfig} + Set Suite Variable ${KUBERNETES_DISTRIBUTION_BINARY} ${KUBERNETES_DISTRIBUTION_BINARY} + Set Suite Variable ${CONTEXT} ${CONTEXT} + Set Suite Variable ${NAMESPACE} ${NAMESPACE} + Set Suite Variable + ... ${env} + ... {"KUBECONFIG":"./${kubeconfig.key}","PATH":"$PATH:${OS_PATH}","KUBERNETES_DISTRIBUTION_BINARY":"${KUBERNETES_DISTRIBUTION_BINARY}","CONTEXT":"${CONTEXT}","NAMESPACE":"${NAMESPACE}"} +``` + +And every task that shells out must pass the secret so it is +materialized as a file: + +```robot +${rsp}= RW.CLI.Run Bash File +... bash_file=my_check.sh +... env=${env} +... secret_file__kubeconfig=${kubeconfig} +``` + +Why each piece matters: + +- **`secret_file__kubeconfig=${kubeconfig}`** -- tells `RW.CLI` to + write the secret value to a file named `${kubeconfig.key}` in the + task's working directory. In production the kubeconfig already + exists at `$KUBECONFIG`; the materialized copy is a harmless + duplicate. In dev mode this is what makes `kubectl` work at all. +- **`"KUBECONFIG":"./${kubeconfig.key}"`** -- points kubectl at the + materialized file. This deliberately shadows the runtime-set + `$KUBECONFIG` so the CodeBundle behaves identically in both modes. +- **`KUBERNETES_DISTRIBUTION_BINARY`** -- supports OpenShift (`oc`) + without forking the CodeBundle; scripts use + `${KUBERNETES_DISTRIBUTION_BINARY}` instead of bare `kubectl`. +- **`CONTEXT`** -- multi-context kubeconfigs need `--context`; import + it and pass it through to scripts. + +Apply this pattern to **both** `runbook.robot` and `sli.robot`. + +--- + +## Runtime Environment (what the platform sets for you) + +`runrobot.py` prepares these before Robot starts: + +| Variable | Value | Purpose | +|---|---|---| +| `KUBECONFIG` | execution-specific `.kube/config` | target path for cloud-generated kubeconfigs; isolated per execution | + +Unlike the cloud CLIs, kubectl has **no shared credential cache** -- +each execution gets a fresh kubeconfig path, and generated kubeconfigs +are cached separately (1-hour TTL) under the cloud provider's config +dir (`CLOUDSDK_CONFIG` / `AZURE_CONFIG_DIR` / `AWS_CONFIG_DIR`), keyed +by cluster and credential identity. + +The base image ships `kubectl`, `helm`, `istioctl`, +`gke-gcloud-auth-plugin` (GKE exec auth), and `kubelogin` (AKS +Entra-ID auth). You do not need to install them. + +--- + +## Shell Script Conventions + +```bash +#!/usr/bin/env bash +set -euo pipefail + +: "${KUBECONFIG:?Must set KUBECONFIG}" +: "${NAMESPACE:?Must set NAMESPACE}" +: "${KUBERNETES_DISTRIBUTION_BINARY:=kubectl}" + +$KUBERNETES_DISTRIBUTION_BINARY get pods -n "$NAMESPACE" --context "${CONTEXT:-}" -o json +``` + +Rules: + +1. **Never hardcode `kubectl`** -- use + `${KUBERNETES_DISTRIBUTION_BINARY}` so OpenShift works. +2. **Never write to `~/.kube`** or run `kubectl config use-context` + to mutate shared state; pass `--context` per command. +3. **Always start with `set -euo pipefail`** and validate + `KUBECONFIG` is set. +4. **Probe connectivity early** (`kubectl version --request-timeout=10s` + or `kubectl get ns --request-timeout=10s`) and print a greppable + failure marker so tasks can raise an auth/connectivity issue + instead of reporting "no resources found". +5. **Degrade gracefully** on discovery commands (`2>/dev/null || echo "[]"`). + +--- + +## Cloud-Managed Clusters: Pair Both Secrets + +For GKE/AKS/EKS CodeBundles that use `kubectl`, import **both** the +cloud CLI secret and the kubeconfig secret: + +```json +{ + "gcp_credentials": "gcp:adc@cli", + "kubeconfig": "gcp:adc@kubeconfig:my-cluster/us-central1" +} +``` + +The CLI secret authenticates `gcloud`/`az`/`aws` for control-plane +calls (describe cluster, list node pools); the kubeconfig secret +materializes data-plane access for `kubectl`. Reference: +`gke-cluster-health`, `azure-aks-triage`, `aws-eks-health`. + +--- + +## Generation Rules / Templates + +In `.runwhen/templates/*-taskset.yaml`, provide the kubeconfig secret +the same way as cloud credentials: + +```yaml + secretsProvided: + {% if wb_version %} + {% include "gcp-auth.yaml" ignore missing %} + {% else %} + - name: kubeconfig + workspaceKey: {{custom.kubeconfig_secret | default("kubeconfig")}} + {% endif %} +``` + +--- + +## Common Mistakes + +1. **Missing `secret_file__kubeconfig` on tasks** -- works in + production (runtime already wrote `$KUBECONFIG`) but fails in dev + mode where nothing materializes the file. + +2. **Overriding `KUBECONFIG` with a hardcoded path** -- breaks the + execution-isolated path the runtime manages. Use + `./${kubeconfig.key}` (dev-safe) or leave the runtime value + untouched. + +3. **Hardcoding `kubectl`** -- excludes OpenShift. Use + `${KUBERNETES_DISTRIBUTION_BINARY}`. + +4. **Using only a cloud CLI secret for kubectl** -- `gcp:adc@cli` + authenticates `gcloud` but does not write a kubeconfig; kubectl + calls fail with "no configuration provided". Pair with the + `@kubeconfig:` variant. + +5. **Treating connectivity failures as empty results** -- an + unreachable API server looks like "zero pods". Probe early and + raise an issue. + +6. **Hardcoding contexts or namespaces** -- import them as user + variables; the same CodeBundle runs against many clusters. + +--- + +## Reference Implementation + +`codebundles/k8s-certmanager-healthcheck` is the canonical example: + +- Suite Init: import `kubeconfig` secret + + `KUBERNETES_DISTRIBUTION_BINARY` / `CONTEXT` / `NAMESPACE` vars +- Tasks: `secret_file__kubeconfig=${kubeconfig}` on every CLI call +- Scripts: plain `$KUBERNETES_DISTRIBUTION_BINARY` calls From a749cbaf3f70349d63a10ce4b536857d90b658df Mon Sep 17 00:00:00 2001 From: steartshea Date: Thu, 6 Aug 2026 04:05:24 +0000 Subject: [PATCH 5/6] Use nohup for postStart server and update AGENTS.md --- .devcontainer/devcontainer.json | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c1460c1..f893e78 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,7 +23,7 @@ }, "postCreateCommand": "init-ide-tools && mkdir -p /home/runwhen/.local/share/opencode && chmod 755 /home/runwhen && sudo chmod 777 /tmp && echo 'cd /workspaces/codecollection-devtools' >> ~/.bashrc && mkdir -p /home/runwhen/.ssh && chmod 700 /home/runwhen/.ssh && touch /home/runwhen/.ssh/authorized_keys && chmod 600 /home/runwhen/.ssh/authorized_keys && gh auth setup-git 2>/dev/null || true", - "postStartCommand": "python -m http.server --bind 0.0.0.0 --directory /robot_logs 3000 &", + "postStartCommand": "nohup python -m http.server --bind 0.0.0.0 --directory /robot_logs 3000 &", // To mount host IDE configs (e.g. ~/.opencode, ~/.claude) into the container: // Option A — docker-compose.override.yaml (simpler, ~ expands correctly): diff --git a/AGENTS.md b/AGENTS.md index e9f0e9e..2809419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ at setup time. ``` .agents/ # Canonical rule files (agent-agnostic) ├── *.mdc # Generated from skills/ by `task install-skills` -└── .gitignore # Prevents committing generated .mdc files +│ # (gitignored by root .gitignore: .agents/*.mdc) .cursor/rules -> ../.agents # Symlink for Cursor IDE ``` From af289d6a417ccbfa72241de4aca2e5f961247202 Mon Sep 17 00:00:00 2001 From: stewartshea Date: Thu, 6 Aug 2026 01:28:53 -0400 Subject: [PATCH 6/6] Add GCP test-infra skill and update cloud overview Introduce a new skill doc for GCP test infrastructure, covering Taskfile structure, workspaceInfo.yaml, common mistakes, and a reference implementation. Update the cloud overview to include GCP, add validate-generation-rules to the default task chain, and clarify that Taskfiles should not contain scenario-test tasks. --- skills/test-infra-cloud.md | 15 +- skills/test-infra-gcp.md | 364 +++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 skills/test-infra-gcp.md diff --git a/skills/test-infra-cloud.md b/skills/test-infra-cloud.md index 27b575a..9ab0ae8 100644 --- a/skills/test-infra-cloud.md +++ b/skills/test-infra-cloud.md @@ -11,10 +11,10 @@ infrastructure guidance, read the appropriate skill doc: | Platform | Skill Document | |---|---| -| **Azure** (Key Vault, VMs, Storage, etc.) | `docs/skills/test-infra-azure.md` | -| **Azure DevOps** (Projects, Pipelines, Repos) | `docs/skills/test-infra-azure-devops.md` | -| **AWS** | `docs/skills/test-infra-aws.md` *(planned)* | -| **GCP** | `docs/skills/test-infra-gcp.md` *(planned)* | +| **Azure** (Key Vault, VMs, Storage, etc.) | `test-infra-azure.md` | +| **Azure DevOps** (Projects, Pipelines, Repos) | `test-infra-azure-devops.md` | +| **GCP** (BigQuery, GCS, Pub/Sub, etc.) | `test-infra-gcp.md` | +| **AWS** | `test-infra-aws.md` *(planned)* | --- @@ -59,7 +59,7 @@ Every cloud `.test/Taskfile.yaml` must implement: | Task | Purpose | |---|---| -| `default` | `check-unpushed-commits` → `generate-rwl-config` → `run-rwl-discovery` | +| `default` | `check-unpushed-commits` → `generate-rwl-config` → `run-rwl-discovery` → `validate-generation-rules` | | `clean` | Terraform destroy → `delete-slxs` → `clean-rwl-discovery` | | `build-infra` | `source tf.secret` + `terraform init` + `terraform apply` | | `check-unpushed-commits` | Verify code is committed and pushed | @@ -67,6 +67,11 @@ Every cloud `.test/Taskfile.yaml` must implement: | `run-rwl-discovery` | Start RunWhen Local container and run discovery | | `validate-generation-rules` | Validate `.runwhen/generation-rules/*.yaml` | +> **Note:** The Taskfile's purpose is to test **discovery and template +> rendering**. Do not add scenario-test tasks (`test-*-scenario`) -- +> health-check behavior is validated by the codebundle's robot files, +> not by Taskfile stubs. + ### Test Resource Tagging Always tag cloud resources for identification and cleanup: diff --git a/skills/test-infra-gcp.md b/skills/test-infra-gcp.md new file mode 100644 index 0000000..3ffce69 --- /dev/null +++ b/skills/test-infra-gcp.md @@ -0,0 +1,364 @@ +--- +description: How to build .test infrastructure for GCP CodeBundles (Taskfile, workspaceInfo, terraform, RunWhen Local discovery) +globs: "**/.test/**,**/codebundles/gcp-*/**" +alwaysApply: false +--- + +# Test Infrastructure -- GCP CodeBundles + +This guide covers the `.test/` directory for GCP CodeBundles. The +Taskfile's purpose is to test **discovery and template rendering** -- +not to run scenario tests. + +For shared patterns (tf.secret, .gitignore, Terraform files, tagging), +see `test-infra-cloud.md`. + +--- + +## Purpose + +The `.test/Taskfile.yaml` for a GCP CodeBundle has one job: + +1. Generate a valid `workspaceInfo.yaml` from the codebundle's + generation rules. +2. Run RunWhen Local discovery against that config. +3. Validate the `.runwhen/generation-rules/*.yaml` schema. + +Do **not** add scenario-test tasks (`test-*-scenario`, etc.) -- +health-check behavior is validated by the codebundle's robot files, +not by Taskfile stubs. + +--- + +## Required Taskfile Structure + +```yaml +version: "3" + +silent: true + +vars: + codebundle: "" + +tasks: + default: + desc: "Run discovery and template validation" + cmds: + - task: check-unpushed-commits + - task: generate-rwl-config + - task: run-rwl-discovery + - task: validate-generation-rules + + clean: + desc: "Run cleanup tasks" + cmds: + - task: delete-slxs + - task: clean-rwl-discovery + + check-unpushed-commits: + desc: "Check for uncommitted/unpushed changes before testing" + vars: + BASE_DIR: "../" + cmds: + - | + UNCOMMITTED_FILES=$(git diff --name-only HEAD | grep -E "^${BASE_DIR}(\.runwhen|[^/]+)" | grep -v "/\.test/" || true) + if [ -n "$UNCOMMITTED_FILES" ]; then + echo "✗ Uncommitted changes found:" + echo "$UNCOMMITTED_FILES" + echo "Remember to commit & push changes before executing the run-rwl-discovery task." + exit 1 + else + echo "√ No uncommitted changes in specified directories." + fi + - | + git fetch origin + UNPUSHED_FILES=$(git diff --name-only origin/$(git rev-parse --abbrev-ref HEAD) HEAD | grep -E "^${BASE_DIR}(\.runwhen|[^/]+)" | grep -v "/\.test/" || true) + if [ -n "$UNPUSHED_FILES" ]; then + echo "✗ Unpushed commits found:" + echo "$UNPUSHED_FILES" + echo "Remember to push changes before executing the run-rwl-discovery task." + exit 1 + else + echo "√ No unpushed commits in specified directories." + fi + + generate-rwl-config: + desc: "Generate RunWhen Local configuration (workspaceInfo.yaml)" + env: + GCP_PROJECT_ID: "{{.GCP_PROJECT_ID}}" + RW_WORKSPACE: '{{.RW_WORKSPACE | default "my-workspace"}}' + cmds: + - | + repo_url=$(git config --get remote.origin.url) + branch_name=$(git rev-parse --abbrev-ref HEAD) + codebundle=$(basename "$(dirname "$PWD")") + + cat < workspaceInfo.yaml + workspaceName: "$RW_WORKSPACE" + workspaceOwnerEmail: authors@runwhen.com + defaultLocation: location-01-us-west1 + defaultLOD: detailed + writeWorkspaceFilesToDisk: true + cloudConfig: + gcp: + applicationCredentialsFile: /shared/gcp.json.secret + projects: + - $GCP_PROJECT_ID + projectLevelOfDetails: + $GCP_PROJECT_ID: detailed + codeCollections: + - repoURL: "$repo_url" + branch: "$branch_name" + codeBundles: ["$codebundle"] + EOF + + run-rwl-discovery: + desc: "Run RunWhen Local Discovery on test infrastructure" + cmds: + - | + CONTAINER_NAME="RunWhenLocal" + if docker ps -q --filter "name=$CONTAINER_NAME" | grep -q .; then + echo "Stopping and removing existing container $CONTAINER_NAME..." + docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME + elif docker ps -a -q --filter "name=$CONTAINER_NAME" | grep -q .; then + echo "Removing existing stopped container $CONTAINER_NAME..." + docker rm $CONTAINER_NAME + else + echo "No existing container named $CONTAINER_NAME found." + fi + + sudo rm -rf output || { echo "Failed to remove output directory"; exit 1; } + mkdir output && chmod 777 output || { echo "Failed to set permissions"; exit 1; } + + docker run --name $CONTAINER_NAME -p 8081:8081 -v "$(pwd)":/shared -d ghcr.io/runwhen-contrib/runwhen-local:latest || { + echo "Failed to start container"; exit 1; + } + + docker exec -w /workspace-builder $CONTAINER_NAME ./run.sh $1 --verbose || { + echo "Error executing script in container"; exit 1; + } + + echo "Review generated config files under output/workspaces/" + + validate-generation-rules: + desc: "Validate YAML files in .runwhen/generation-rules" + cmds: + - | + for cmd in curl yq ajv; do + if ! command -v $cmd &> /dev/null; then + echo "Error: $cmd is required but not installed." + exit 1 + fi + done + + temp_dir=$(mktemp -d) + curl -s -o "$temp_dir/generation-rule-schema.json" https://raw.githubusercontent.com/runwhen-contrib/runwhen-local/refs/heads/main/src/generation-rule-schema.json + + for yaml_file in ../.runwhen/generation-rules/*.yaml; do + echo "Validating $yaml_file" + json_file="$temp_dir/$(basename "${yaml_file%.*}.json")" + yq -o=json "$yaml_file" > "$json_file" + ajv validate -s "$temp_dir/generation-rule-schema.json" -d "$json_file" --spec=draft2020 --strict=false \ + && echo "$yaml_file is valid." || echo "$yaml_file is invalid." + done + + rm -rf "$temp_dir" + + check-rwp-config: + desc: "Check if env vars are set for RunWhen Platform" + cmds: + - | + missing_vars=() + if [ -z "$RW_WORKSPACE" ]; then missing_vars+=("RW_WORKSPACE"); fi + if [ -z "$RW_API_URL" ]; then missing_vars+=("RW_API_URL"); fi + if [ -z "$RW_PAT" ]; then missing_vars+=("RW_PAT"); fi + if [ ${#missing_vars[@]} -ne 0 ]; then + echo "The following required environment variables are missing: ${missing_vars[*]}" + exit 1 + fi + + upload-slxs: + desc: "Upload SLX files to the appropriate URL" + env: + RW_WORKSPACE: "{{.RW_WORKSPACE}}" + RW_API_URL: "{{.RW_API}}" + RW_PAT: "{{.RW_PAT}}" + cmds: + - task: check-rwp-config + - | + BASE_DIR="output/workspaces/${RW_WORKSPACE}/slxs" + if [ ! -d "$BASE_DIR" ]; then + echo "Directory $BASE_DIR does not exist. Upload aborted." + exit 1 + fi + + for dir in "$BASE_DIR"/*; do + if [ -d "$dir" ]; then + SLX_NAME=$(basename "$dir") + PAYLOAD=$(jq -n --arg commitMsg "Creating new SLX $SLX_NAME" '{ commitMsg: $commitMsg, files: {} }') + for file in slx.yaml runbook.yaml sli.yaml; do + if [ -f "$dir/$file" ]; then + CONTENT=$(cat "$dir/$file") + PAYLOAD=$(echo "$PAYLOAD" | jq --arg fileContent "$CONTENT" --arg fileName "$file" '.files[$fileName] = $fileContent') + fi + done + + URL="https://${RW_API_URL}/api/v3/workspaces/${RW_WORKSPACE}/branches/main/slxs/${SLX_NAME}" + echo "Uploading SLX: $SLX_NAME to $URL" + response=$(curl -v -X POST "$URL" \ + -H "Authorization: Bearer $RW_PAT" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" -w "%{http_code}" -o /dev/null -s 2>&1) + + if [[ "$response" =~ 200|201 ]]; then + echo "Successfully uploaded SLX: $SLX_NAME to $URL" + else + echo "Failed to upload SLX: $SLX_NAME to $URL. Response:" + echo "$response" + fi + fi + done + + delete-slxs: + desc: "Delete SLX objects from the appropriate URL" + env: + RW_WORKSPACE: '{{.RW_WORKSPACE | default "my-workspace"}}' + RW_API_URL: "{{.RW_API}}" + RW_PAT: "{{.RW_PAT}}" + cmds: + - task: check-rwp-config + - | + BASE_DIR="output/workspaces/${RW_WORKSPACE}/slxs" + if [ ! -d "$BASE_DIR" ]; then + echo "Directory $BASE_DIR does not exist. Deletion aborted." + exit 1 + fi + + for dir in "$BASE_DIR"/*; do + if [ -d "$dir" ]; then + SLX_NAME=$(basename "$dir") + URL="https://${RW_API_URL}/api/v3/workspaces/${RW_WORKSPACE}/branches/main/slxs/${SLX_NAME}" + echo "Deleting SLX: $SLX_NAME from $URL" + response=$(curl -v -X DELETE "$URL" \ + -H "Authorization: Bearer $RW_PAT" \ + -H "Content-Type: application/json" -w "%{http_code}" -o /dev/null -s 2>&1) + + if [[ "$response" =~ 200|204 ]]; then + echo "Successfully deleted SLX: $SLX_NAME from $URL" + else + echo "Failed to delete SLX: $SLX_NAME from $URL. Response:" + echo "$response" + fi + fi + done + + clean-rwl-discovery: + desc: "Clean up RunWhen Local discovery output" + cmds: + - | + sudo rm -rf output + rm -f workspaceInfo.yaml +``` + +--- + +## GCP workspaceInfo.yaml + +For GCP project-level discovery (BigQuery, GCS, Pub/Sub, etc.), the +`cloudConfig.gcp` block is minimal -- no `gkeClusters` needed unless +the codebundle also discovers GKE clusters: + +```yaml +workspaceName: "my-workspace" +workspaceOwnerEmail: authors@runwhen.com +defaultLocation: location-01-us-west1 +defaultLOD: detailed +writeWorkspaceFilesToDisk: true +cloudConfig: + gcp: + applicationCredentialsFile: /shared/gcp.json.secret + projects: + - my-gcp-project + projectLevelOfDetails: + my-gcp-project: detailed +codeCollections: +- repoURL: "https://github.com/runwhen-contrib/rw-cli-codecollection.git" + branch: "main" + codeBundles: ["gcp-bigquery-dataset-health"] +``` + +Key points: + +- **`writeWorkspaceFilesToDisk: true`** -- writes the rendered SLX + YAMLs (slx.yaml, runbook.yaml, sli.yaml) to `output/workspaces/` so + you can read and review them after discovery. Required for template + debugging. +- **`applicationCredentialsFile: /shared/gcp.json.secret`** -- the + RunWhen Local container mounts the `.test/` directory at `/shared`; + place the service-account key at `.test/gcp.json.secret` (gitignored). + Omit this field entirely for Workload Identity / ADC. +- **`projects`** -- the GCP project(s) to index. BigQuery/GCS/PubSub + discovery is project-scoped; the indexer enumerates datasets/buckets + in each listed project. +- **`projectLevelOfDetails`** -- controls how much detail is captured + per project (`detailed` for full resource attributes). +- **`codeBundles: ["$codebundle"]`** -- always scope to the current + bundle; RunWhen Local would otherwise render every bundle in the repo. + +--- + +## terraform/ (optional, only if test resources are needed) + +If the codebundle needs real GCP resources to discover (e.g. BigQuery +datasets/tables for the BigQuery bundle), keep a `terraform/` directory +with `main.tf`, `variables.tf`, `tf.secret`, and a +`build-terraform-infra` / `check-and-cleanup-terraform` task pair. +These are **not** part of the default discovery flow -- run them +explicitly when you need to provision the test fixtures. + +Tag all test resources: + +```hcl +labels = { + env = "test" + lifecycle = "deleteme" + product = "runwhen" +} +``` + +--- + +## Common Mistakes + +1. **Adding scenario-test tasks** -- The Taskfile tests discovery and + template rendering. Health-check behavior is validated by the + codebundle's robot files, not by `test-foo-scenario` stubs. + +2. **Hardcoding `GCP_PROJECT_ID` or workspace name** -- Pass them via + `task generate-rwl-config GCP_PROJECT_ID=... RW_WORKSPACE=...` or + set them in the shell. The env block in the task definition supplies + defaults only. + +3. **Not scoping `codeBundles`** -- `codeBundles: ["$codebundle"]` + limits rendering to the current bundle. Omitting it renders every + bundle in the repo, producing unrelated SLXs. + +4. **Forgetting `check-unpushed-commits`** -- RunWhen Local pulls from + the remote branch. Uncommitted/unpushed changes are invisible to + discovery. + +5. **Using `output/` for workspaceInfo.yaml** -- `workspaceInfo.yaml` + belongs at the `.test/` root (mounted at `/shared` in the container). + `output/` is for the generated SLX artifacts. + +6. **Setting `GOOGLE_APPLICATION_CREDENTIALS` in the container** -- + For Workload Identity, leave `applicationCredentialsFile` empty; + setting `GOOGLE_APPLICATION_CREDENTIALS` short-circuits the + metadata-server path. + +--- + +## Reference Implementation + +`codebundles/gcp-bigquery-dataset-health/.test/Taskfile.yaml` is the +canonical example for GCP project-level discovery.