recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)

Sanitized single-commit public mirror of recipe-maintainer.
- Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders.
- Removed plans/ and planned-updates/ (deployment-planning docs) so no client/
  deployment domains appear in the public repo.
- All other secret stores were already gitignored.
- docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
2026-06-16 20:18:24 +00:00
commit f283a371bb
253 changed files with 15975 additions and 0 deletions

View File

@ -0,0 +1,45 @@
## Guidelines
Read and follow these guidelines for all recipe operations.
### Preserving local recipe changes
Before running `abra recipe fetch <recipe> --force`, always check for uncommitted changes first:
```
git -C ~/.abra/recipes/<recipe> status --short
```
- **If the working tree is clean:** proceed with `abra recipe fetch <recipe> --force` to pull the latest upstream.
- **If there are uncommitted changes:** do NOT fetch. Log that the recipe has local modifications and that you are using the local checkout as-is. Use `--chaos` on any subsequent `abra app` commands so they pick up the local state instead of requiring a committed version.
This matters because `--force` overwrites the entire local checkout, destroying any in-progress work.
### Recipe version format
Recipe versions use the format `<recipe-semver>+v<upstream-version>`, for example `0.2.6+v4.5.0`. The `v` prefix before the upstream version is always present, even if the upstream image tag uses a different prefix (e.g. CryptPad's Docker tag `version-2025.9.0` becomes `+v2025.9.0` in the recipe version).
This version string appears in three places that must stay in sync:
1. The `coop-cloud.${STACK_NAME}.version` deploy label in `compose.yml`
2. The annotated git tag on the release commit
3. The `TYPE=<recipe>:<version>` line in the app's `.env` file on the server
Co-op Cloud requires **annotated tags** (not lightweight tags). Always use `git tag -a` with a `-m` message:
```
git tag -a "0.5.0+v2026.2.0" -m "chore: publish 0.5.0+v2026.2.0 release"
```
### Saving secrets locally
When generating secrets for an app, always use `--machine` to get machine-readable output and save it to `recipe-info/<recipe>/secrets.json`:
```
abra app secret generate <domain> --all --machine > recipe-info/<recipe>/secrets.json
```
This keeps a local record of the generated secrets (e.g. admin passwords needed for initial login). The `--machine` flag outputs JSON instead of a human-readable table.
### Active test instance
The current active test instance is set by `default_instance` in `settings.toml` at the workspace root. Read this value directly to determine which instance to operate on. The instance's server and domain_suffix are in the `[instances.<name>]` section. Domain for any recipe is `<recipe>.<domain_suffix>`.

View File

@ -0,0 +1,14 @@
## Logging
Throughout this entire operation, maintain a detailed log of everything that happens. At the **end** of the operation, write the full log to a file in the `logs/` directory (create it if it doesn't exist).
**Log file path:** `logs/<skill-name>-<recipe-name>-<YYYY-MM-DD>.md` — use the skill name from this file's name (e.g. `recipe-check`, `recipe-deploy`), the recipe name from `$ARGUMENTS` (omit if there is no recipe argument), and today's date.
The log file must include:
- A header with the skill name, recipe name (if applicable), and timestamp
- Every shell command that was run, in fenced code blocks
- The full output of each command (truncate excessively long output but keep enough to be useful)
- Any decisions made, errors encountered, or notable observations
- The final summary/result
Format the log as readable markdown with clear section headers for each step.

View File

@ -0,0 +1,116 @@
---
description: Deploy all maintained recipes to the active test instance from scratch
allowed-tools: [Bash, Read, Write, Edit, Glob, Grep, WebFetch]
---
# Init Instance
Deploy all maintained recipes to the active test instance in dependency order. Apps that already exist are deployed as-is. Apps that don't exist yet are created following their `setup.md`.
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
### 1. Determine the active instance
Run `python3 scripts/get_test_instance.py` to get SERVER and INSTANCE. Read `settings.toml` to get `domain_suffix`.
### 2. Discover all recipes
Glob `recipe-info/*/recipe.toml`. For each, read the file to get:
- `name` — the recipe name
- `[dependencies].requires` — list of recipe dependencies (may be empty or absent)
### 3. Topological sort — build deployment tiers
Group recipes into tiers based on their `[dependencies].requires`:
- **Tier 1** (no dependencies): recipes with empty or missing `requires`
- **Tier 2** (depends on tier 1): recipes whose `requires` list only contains tier 1 recipes
Present the deployment order to the user and **confirm before proceeding**.
### 4. Deploy each recipe in dependency order
Process recipes **one at a time** within each tier. For each recipe:
#### a. Free memory
Run context reset to undeploy everything except infrastructure and this recipe's dependencies:
```bash
python3 scripts/context_reset.py --recipe <recipe>
```
#### b. Compute domain
The domain is `<recipe>.<DOMAIN_SUFFIX>`.
#### c. Ensure dependencies are deployed
If the recipe has dependencies (from recipe.toml), check that each dependency is deployed:
- Check if `~/.abra/servers/<SERVER>/<dep-domain>.env` exists.
- If a dependency's env file does NOT exist, follow its `setup.md` first before continuing with the current recipe.
- If the env file exists, deploy the dependency: `abra app deploy <dep-domain> --chaos --force --no-input`
#### d. Check if this app already exists
Check for the env file: `~/.abra/servers/<SERVER>/<recipe>.<DOMAIN_SUFFIX>.env`
#### e. If the app does NOT exist — create it following setup.md
Read `recipe-info/<recipe>/setup.md` and follow every step. Replace `<SERVER>`, `<DOMAIN_SUFFIX>`, and any other placeholders with the actual values from step 1.
This covers:
- `abra app new` with correct flags
- Secret generation (save to `recipe-info/testsecrets/`)
- Any env file overrides needed before deploy
- `abra app deploy`
- Post-deploy steps (migrations, buckets, etc.)
- SSO integration setup script if applicable
- Redeploy after SSO if needed
#### f. If the app already exists — just deploy it
Do NOT recreate the app, remove secrets, or regenerate secrets. Simply deploy what's there:
```bash
abra app deploy <domain> --chaos --force --no-input
```
If the deploy fails (e.g. missing secrets, bad env config, services won't start), then the existing setup is broken. In that case, clean up and start fresh:
1. `abra app undeploy <domain> --no-input`
2. `abra app volume remove <domain> --force --no-input`
3. Remove secrets: `abra app secret remove <domain> --all --no-input` (TTY-wrapped)
4. Remove the env file
5. Follow setup.md from scratch (step e)
If the recipe has an SSO setup script and SSO credentials file doesn't exist yet, also run the SSO setup and redeploy.
#### g. Sync secrets locally
After deploy, if secrets are NOT already saved locally in `recipe-info/testsecrets/<domain>`, sync them from the running containers:
```bash
python3 scripts/sync_secrets.py --recipe <recipe>
```
Do NOT remove or regenerate secrets on the server. This only reads secrets from running containers and saves them locally.
#### h. Log result
Log PASS if deploy succeeded and health check passes, FAIL otherwise.
### 5. Summary table
After all recipes are processed, print a summary:
| Recipe | Tier | Status | Notes |
|--------|------|--------|-------|
| keycloak | 1 | PASS/FAIL | ... |
| authentik | 1 | PASS/FAIL | ... |
| ... | ... | ... | ... |
### 6. Suggest testing
After all recipes are deployed, suggest the user run `/recipe-test <recipe>` for each recipe to verify everything is working. List the recipes in the same dependency order used for deployment.

74
.claude/commands/intro.md Normal file
View File

@ -0,0 +1,74 @@
---
description: Explain what this project is and how to get started
allowed-tools: [Read, Glob]
---
Present this entire introduction to the user verbatim — do NOT summarize or condense it.
# Co-op Cloud Recipe Toolkit — Introduction
This repository is an AI-assisted toolkit for maintaining [Co-op Cloud](https://coopcloud.tech) recipes. It runs inside a container with `~/.abra` bind-mounted, providing an isolated environment where the `abra` CLI operates normally without access to the host's SSH keys or abra configuration. The only credentials available are the test SSH keys in `test-ssh/`, limiting all deployment operations to the designated test server.
## What it does
The toolkit wraps `abra` and other tools into slash-command skills that automate the full recipe maintenance lifecycle: checking for upstream upgrades, planning and applying updates, deploying to a test server, running tests, managing backups, reviewing recipes against best practices, and tagging releases.
## Repository structure
- **`.claude/commands/`** — Skill definitions (slash commands). This is where all the automation lives.
- **`recipe-info/`** — Per-recipe data organized by recipe name: upstream release note URLs, test instance environment files, and test scripts.
- **`planned-updates/`** — Upgrade reports and summaries generated by `/recipe-upgrade-plan` and `/recipe-upgrade-apply`.
- **`plans/`** — Project planning documents.
- **`test-ssh/`** — SSH config and keys for the test server where recipe instances are deployed.
- **`settings.toml`** — Defines which test server instances are available and which is the current default.
- **`maintained-recipes.md`** — List of recipes this toolkit actively maintains. Used by `/recipe-overview` and `/recipe-test-all`.
- **`learnings.md`** — Hard-won lessons and `abra` CLI quirks discovered during operation.
- **`docs.coopcloud.tech/`** — Local copy of the Co-op Cloud documentation; used as reference for recipe structure, deployment patterns, and platform conventions.
- **`lib/`** — Python helper library used by test scripts and automation (SSH, abra wrappers, secrets management).
- **`.opencode/`** — OpenCode stubs that point back to `.claude/commands/` so skills work in both Claude Code and OpenCode.
## Available skills
### Recipe lifecycle
- **`/recipe-overview`** — Check all maintained recipes, see what needs upgrading
- **`/recipe-check <name>`** — Check a single recipe for available upstream upgrades
- **`/recipe-upgrade-plan <name>`** — Research release notes and create a detailed upgrade plan
- **`/recipe-upgrade-apply <name>`** — Apply the plan: update images, deploy, test, commit, and tag
- **`/recipe-init <name>`** — Bootstrap a new recipe from scratch (fetch, create test instance, deploy)
- **`/recipe-new-tag <name>`** — Bump the version and create an annotated git tag
- **`/recipe-review <name>`** — Audit a recipe against Co-op Cloud best practices
### Deploying and testing
- **`/recipe-deploy <name>`** — Deploy local recipe checkout to the test server (chaos mode)
- **`/recipe-test <name>`** — Run all tests for a recipe
- **`/recipe-test-new <name>`** — Test a fresh install from scratch
- **`/recipe-test-update <name>`** — Test upgrading an existing deployment
- **`/recipe-test-backup <name>`** — Test the backup/restore cycle
- **`/recipe-test-all`** — Run tests for every maintained recipe
### Infrastructure and utilities
- **`/init-instance`** — Deploy all maintained recipes to the test server
- **`/test-context-reset`** — Undeploy all apps except traefik
- **`/switch-default-instance`** — Switch between test servers
- **`/sync-secrets`** — Sync secrets from the test server locally
- **`/opencode-sync`** — Sync Claude skills to OpenCode format
## Recommended workflow
1. **Daily check-in:** `/recipe-overview` to see what needs attention
2. **Upgrade a recipe:** `/recipe-check``/recipe-upgrade-plan` → review the plan → `/recipe-upgrade-apply`
3. **Day-to-day development:** edit a recipe locally → `/recipe-deploy``/recipe-test`
4. **Push when satisfied:** `cd ~/.abra/recipes/<name> && git push && git push --tags`
### Working with a recipe not yet in recipe-info?
Run `/recipe-init <recipe>` to set up tests for that recipe and a deployment to your local test instance.
### Developing a new recipe that doesn't exist yet?
Run `/new-recipe-guide` for detailed instructions on developing a new recipe from scratch.
## Next steps
- Run **`/test-setup`** to verify your environment is configured correctly (settings, SSH, abra CLI, connectivity).
- Run **`/setup-sandbox`** for guidance on setting up a sandboxed Docker environment to run Claude Code with this project — see also `sandbox/` for a reference implementation.

View File

@ -0,0 +1,55 @@
---
description: Guide for developing a new Co-op Cloud recipe from scratch
allowed-tools: [Read, Glob, Grep]
---
# New Recipe Development Guide
This guide walks through how to develop a new Co-op Cloud recipe from scratch using this toolkit.
## Step 1: Gather references
Download any relevant references into `references/` — upstream documentation, example Docker Compose files, configuration guides, environment variable docs, etc. The more context available, the better the recipe will be.
## Step 2: Study existing patterns
Before writing anything, consult these resources to understand how Co-op Cloud recipes are structured:
- **`docs.coopcloud.tech/`** — The Co-op Cloud documentation, especially the recipe structure and deployment conventions
- **`~/.abra/recipes/`** — Other existing recipes, particularly ones similar to the app you're packaging (e.g. if your app uses PostgreSQL, look at how other recipes handle it)
- **`learnings.md`** — Hard-won lessons about `abra` CLI quirks and operational patterns
## Step 3: Develop the recipe
Tell Claude to consult the references you downloaded, along with `docs.coopcloud.tech/`, relevant existing recipes in `~/.abra/recipes/`, and `learnings.md`, in order to develop the new recipe.
A recipe typically includes:
- **`compose.yml`** — Service definitions with Traefik labels, healthchecks, deploy config, secrets, and environment variables
- **`.env.sample`** — Default environment variable values
- **`README.md`** — Recipe metadata (category, status, upstream URL, etc.)
- **`abra.sh`** — Optional post-deploy hooks
## Step 4: Create tests
Tell Claude to create tests in `recipe-info/<recipe>/tests/` to verify the recipe works correctly. Tests should include:
- **Basic health tests** — Verify the app is reachable and responding
- **OIDC integration tests** — If the app supports OIDC/SSO (e.g. via Authentik or Keycloak), test that the integration works
- **12 application-specific tests** — Test core functionality specific to the app (e.g. can create a document, can upload a file, API responds correctly)
## Step 5: Deploy and iterate
Use the toolkit skills to deploy and test iteratively:
1. **`/recipe-deploy <name>`** — Deploy your local changes to the test server
2. **`/recipe-test <name>`** — Run the tests you created
3. Fix any issues, repeat until all tests pass
## Step 6: Review and tag
Once tests pass:
1. **`/recipe-review <name>`** — Audit against Co-op Cloud best practices
2. Fix any issues the review identifies
3. **`/recipe-new-tag <name>`** — Create the first version tag

View File

@ -0,0 +1,67 @@
---
description: Ensure every Claude skill has a corresponding OpenCode skill alias
allowed-tools: [Read, Write, Glob, Grep, Bash]
---
# Sync Claude skills to OpenCode
Ensure that every Claude command has a corresponding OpenCode skill and command that delegates to it.
## Steps
1. **List all Claude commands** — Glob `.claude/commands/*.md` (excluding `opencode-sync.md` itself) and `.claude/commands/includes/*.md`.
2. **List existing OpenCode skills** — Glob `.opencode/skills/*/SKILL.md` and `.opencode/commands/*.md`.
3. **For each Claude command**, determine the OpenCode skill name:
- For `.claude/commands/<name>.md` → skill name is `<name>`
- For `.claude/commands/includes/<name>.md` → skill name is `recipe-<name>` (following the existing convention, e.g. `guidelines``recipe-guidelines`)
4. **Read each Claude command's YAML frontmatter** to extract:
- `description` — reuse as the OpenCode description
- `argument-hint` — use to generate the argument context line
5. **For each missing OpenCode skill**, create `.opencode/skills/<skill-name>/SKILL.md`:
```markdown
---
name: <skill-name>
description: <description from Claude command>
---
Read and follow the full instructions in `.claude/commands/<path-to-claude-command>.md`.
<argument context line, if the Claude command has an argument-hint>
```
The argument context line should describe what arguments are expected based on the `argument-hint`:
- If `argument-hint` contains `recipe-name``The recipe name will be provided by the user or calling agent.`
- If `argument-hint` contains `b1cc|t1cc``The instance name will be provided by the user or calling agent.`
- If there is an `argument-hint` but no specific pattern matched → `Arguments will be provided by the user or calling agent.`
- If there is no `argument-hint` → omit the argument context line entirely
6. **For each missing OpenCode command**, create `.opencode/commands/<skill-name>.md`:
```markdown
---
description: <description from Claude command>
---
Load the `<skill-name>` skill and execute it for $ARGUMENTS.
```
7. **Detect orphaned OpenCode skills** — For each existing OpenCode skill/command, check whether it references a Claude command (by reading the `SKILL.md` body for the `.claude/commands/...` path). If the referenced Claude command no longer exists:
- Check if the content/description closely matches a current Claude command that lacks an OpenCode equivalent (i.e., it was likely renamed).
- If a rename is detected: delete the old OpenCode skill directory and command file, then create new ones under the correct name.
- If no rename match is found: delete the orphaned OpenCode skill directory and command file (it references a dead Claude command).
8. **Update stale descriptions** — For each existing OpenCode skill/command that maps to a valid Claude command, read the Claude command's current `description`. If the OpenCode description differs, update it to match.
9. **Report results** — Print a summary of:
- Skills created (new)
- Skills renamed (old name → new name)
- Skills deleted (orphaned, no rename match)
- Skills updated (description changed)
- Skills unchanged (already in sync)
## Important
- Every Claude command must have exactly one corresponding OpenCode skill — no exceptions, including this `opencode-sync` command itself.
- The includes (`guidelines.md`, `logging.md`) use the `recipe-<name>` prefix convention for their OpenCode skill names.

View File

@ -0,0 +1,58 @@
---
description: Fetch a Co-op Cloud recipe and check for available upgrades
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Check
Fetch the latest version of a Co-op Cloud recipe and check for available upgrades.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Fetch the recipe** — check for uncommitted local changes first (see guidelines). If clean, run `abra recipe fetch $ARGUMENTS --force`. If there are local changes, skip the fetch and note that you're using the local checkout.
2. **Show current released versions**:
```
abra recipe versions $ARGUMENTS -m
```
3. **Check for available upgrades** (machine-readable, non-interactive):
```
abra recipe upgrade $ARGUMENTS -m -n
```
4. **Lint the recipe** to surface any config issues:
```
abra recipe lint $ARGUMENTS -C
```
5. **Look up upstream release notes**:
- Check if `recipe-info/$ARGUMENTS/upstream.md` exists in the workspace.
- If it exists, read it to get the release notes URLs for each image/service.
- If it does NOT exist, try to discover the upstream project and release notes URLs:
- Read the recipe's `compose.yml` to identify all images.
- For each image, search for its GitHub repository and releases page.
- Create `recipe-info/$ARGUMENTS/upstream.md` with the discovered URLs (follow the format of existing upstream.md files in sibling recipe directories).
- Also create the `recipe-info/$ARGUMENTS/tests/` directory if it doesn't exist.
- For any services that have available upgrades, fetch the release notes page and summarise what changed between the current version and the upgrade version(s).
- Pay special attention to and explicitly call out:
- **Breaking changes** — API removals, renamed/removed config options, changed defaults, dropped support for older runtimes/dependencies
- **Required migration steps** — database migrations, data format changes, manual upgrade procedures
- **Config changes needed by the operator** — new required environment variables, changed variable names/formats, new secrets, changed ports or volume paths, deprecated settings that will stop working
- **Dependency version requirements** — e.g. "now requires PostgreSQL 15+", "minimum Redis 6.2"
- If any of the above are found, present them in a clearly marked "**Operator Action Required**" section per service, separate from the general changelog summary. Use warnings/bold to make these impossible to miss.
6. **Summarise the results** for the user:
- Current version(s) of the recipe
- Any available image tag upgrades
- **Operator Action Required** items first (breaking changes, config changes, migrations) — prominently highlighted
- For each upgrade: a summary of what changed (from release notes), with a link to the full release notes
- Any lint warnings or errors
7. **Suggest next steps** — if upgrades are available, suggest the user run `/recipe-upgrade-plan $ARGUMENTS` to create a detailed upgrade plan.

View File

@ -0,0 +1,191 @@
---
description: Push local recipe commits to git.autonomic.zone and open a PR against an upstream-synced main branch
argument-hint: <recipe-name>
allowed-tools: [Bash, Read]
---
# Recipe Create PR
Take the local commits made to a recipe (e.g. during `/recipe-upgrade-apply`) and open a pull request on the Gitea instance at `git.autonomic.zone`. The Gitea repo's `main` branch is force-synced from the recipe's upstream `main` so the PR diff shows only the local changes.
The recipe name is: $ARGUMENTS
## Prerequisites
Credentials are read from `test-ssh/.testenv`. The following variables must be set:
- `GITEA_USERNAME` — bot account on the Gitea instance
- `GITEA_PASSWORD` — bot password
- `GITEA_URL` — Gitea host (e.g. `git.autonomic.zone`)
Optional:
- `GITEA_NAMESPACE` — owner under which to create/find repos. Defaults to `recipe-maintainers` (the org whose `recipe-maintainers` team auto-grants access to all repos within it).
The recipe must be checked out at `~/.abra/recipes/$ARGUMENTS` with at least one commit beyond `origin/main`.
## Steps
Run the following script with the recipe name substituted in:
```bash
#!/usr/bin/env bash
set -euo pipefail
RECIPE="$ARGUMENTS"
WORKSPACE="/workspace"
RECIPE_DIR="${HOME}/.abra/recipes/${RECIPE}"
TESTENV="${WORKSPACE}/test-ssh/.testenv"
# --- Load credentials ---
[ -f "${TESTENV}" ] || { echo "ERROR: ${TESTENV} not found"; exit 1; }
set -a; . "${TESTENV}"; set +a
: "${GITEA_USERNAME:?missing in .testenv}"
: "${GITEA_PASSWORD:?missing in .testenv}"
: "${GITEA_URL:?missing in .testenv}"
NAMESPACE="${GITEA_NAMESPACE:-recipe-maintainers}"
PASS_ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "${GITEA_PASSWORD}")
API="https://${GITEA_URL}/api/v1"
AUTH=(-u "${GITEA_USERNAME}:${GITEA_PASSWORD}")
# --- Validate recipe checkout ---
[ -d "${RECIPE_DIR}/.git" ] || { echo "ERROR: ${RECIPE_DIR} is not a git repo. Run 'abra recipe fetch ${RECIPE}' first."; exit 1; }
cd "${RECIPE_DIR}"
# --- Fetch upstream main ---
echo "→ Fetching upstream main from origin..."
git fetch origin main
# --- Find diverged commits ---
DIVERGED=$(git log --oneline origin/main..HEAD 2>/dev/null || true)
if [ -z "${DIVERGED}" ]; then
echo "ERROR: HEAD has no commits beyond origin/main. Nothing to PR."
exit 1
fi
echo "→ Local commits to PR:"
echo "${DIVERGED}" | sed 's/^/ /'
# --- Determine PR branch name from the most recent commit ---
LATEST_MSG=$(git log -1 --pretty=%s HEAD)
if echo "${LATEST_MSG}" | grep -qiE "upgrade to [0-9]"; then
VERSION=$(echo "${LATEST_MSG}" | grep -oiE "upgrade to [0-9][^[:space:]]+" | awk '{print $NF}')
BRANCH="upgrade-${VERSION}"
else
BRANCH="pr-$(date -u +%Y%m%d-%H%M%S)"
fi
echo "→ PR branch: ${BRANCH}"
# --- Check / create Gitea repo ---
REPO_URL_API="${API}/repos/${NAMESPACE}/${RECIPE}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH[@]}" "${REPO_URL_API}")
if [ "${STATUS}" = "404" ]; then
echo "→ Repo ${NAMESPACE}/${RECIPE} does not exist; creating..."
CREATE_BODY=$(python3 -c "import json;print(json.dumps({'name':'${RECIPE}','private':True,'default_branch':'main','auto_init':False}))")
# Try org namespace first
CREATE_OUT=$(mktemp)
CREATE_STATUS=$(curl -s -o "${CREATE_OUT}" -w "%{http_code}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-X POST "${API}/orgs/${NAMESPACE}/repos" \
-d "${CREATE_BODY}")
if [ "${CREATE_STATUS}" != "201" ]; then
echo " ! create under org ${NAMESPACE} returned HTTP ${CREATE_STATUS} — falling back to user namespace ${GITEA_USERNAME}"
cat "${CREATE_OUT}" >&2
echo "" >&2
CREATE_STATUS=$(curl -s -o "${CREATE_OUT}" -w "%{http_code}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-X POST "${API}/user/repos" \
-d "${CREATE_BODY}")
if [ "${CREATE_STATUS}" != "201" ]; then
echo "ERROR: failed to create repo (HTTP ${CREATE_STATUS}):"
cat "${CREATE_OUT}"
rm -f "${CREATE_OUT}"
exit 1
fi
NAMESPACE="${GITEA_USERNAME}"
fi
rm -f "${CREATE_OUT}"
echo " ✓ created ${NAMESPACE}/${RECIPE}"
elif [ "${STATUS}" = "200" ]; then
echo "→ Repo ${NAMESPACE}/${RECIPE} already exists"
else
echo "ERROR: unexpected HTTP ${STATUS} when checking ${REPO_URL_API}"
exit 1
fi
# --- Set up the gitea remote with credentials embedded ---
REMOTE_URL="https://${GITEA_USERNAME}:${PASS_ENC}@${GITEA_URL}/${NAMESPACE}/${RECIPE}.git"
if git remote | grep -qx gitea; then
git remote set-url gitea "${REMOTE_URL}"
else
git remote add gitea "${REMOTE_URL}"
fi
# --- Force-sync Gitea main with origin/main so the PR diff is clean ---
echo "→ Force-syncing gitea/main from origin/main..."
git push --force gitea "refs/remotes/origin/main:refs/heads/main"
# --- Push local commits as the PR branch ---
echo "→ Pushing local commits as branch '${BRANCH}'..."
git push --force gitea "HEAD:refs/heads/${BRANCH}"
# --- Create the PR ---
# NOTE: for an upgrade PR the body (passed in via RECIPE_PR_BODY) MUST link the upstream release notes
# — one explicit line per upgraded image/service, e.g.
# **Upstream release notes:** <service> <old>→<new>: <url>
# pulling each URL from recipe-info/<recipe>/upstream.md (between the current → new version). These links
# belong in the PR body itself, NOT only in a side report, so the reviewer sees what changed upstream.
PR_TITLE="${LATEST_MSG}"
if [ -n "${RECIPE_PR_BODY:-}" ]; then
PR_BODY="${RECIPE_PR_BODY}"
else
PR_BODY=$(printf "Local commits on top of upstream main:\n\n%s\n" "$(git log origin/main..HEAD --pretty='- %h %s')")
fi
PR_BODY="${PR_BODY}
cc @trav @notplants"
PR_PAYLOAD=$(python3 -c "
import json, sys
print(json.dumps({
'title': sys.argv[1],
'body': sys.argv[2],
'head': sys.argv[3],
'base': 'main',
'reviewers': ['trav', 'notplants'],
}))" "${PR_TITLE}" "${PR_BODY}" "${BRANCH}")
PR_RESPONSE=$(mktemp)
PR_STATUS=$(curl -s -o "${PR_RESPONSE}" -w "%{http_code}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-X POST "${API}/repos/${NAMESPACE}/${RECIPE}/pulls" \
-d "${PR_PAYLOAD}")
if [ "${PR_STATUS}" = "201" ]; then
PR_URL=$(python3 -c "import json;print(json.load(open('${PR_RESPONSE}'))['html_url'])")
echo ""
echo "✓ PR created: ${PR_URL}"
elif [ "${PR_STATUS}" = "409" ] || grep -q "pull request already exists" "${PR_RESPONSE}" 2>/dev/null; then
echo ""
echo " A PR for branch '${BRANCH}' already exists. See:"
echo " https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls"
else
echo "ERROR: PR creation failed (HTTP ${PR_STATUS}):"
cat "${PR_RESPONSE}"
rm -f "${PR_RESPONSE}"
exit 1
fi
rm -f "${PR_RESPONSE}"
```
After the script runs, report back the PR URL (or the existing-PR list URL if Gitea returned 409).
## Notes
- The `gitea` remote is created/updated in `~/.abra/recipes/<recipe>/.git/config` with the password embedded — this is acceptable here since `.testenv` already stores the password in plaintext, but be aware the remote URL is now stored in that local file.
- Re-running the skill is safe: the script force-pushes both the synced `main` and the PR branch, and reports gracefully if a PR for that branch already exists.
- The PR branch name is derived from the most recent commit message — if it matches `upgrade to <version>`, the branch becomes `upgrade-<version>`; otherwise a timestamped name is used.
- **For upgrade PRs, `RECIPE_PR_BODY` must carry the upstream release-notes links** (one line per upgraded service: `**Upstream release notes:** <service> <old>→<new>: <url>`, sourced from `recipe-info/<recipe>/upstream.md`). `/recipe-upgrade-apply` composes the body with these links already; if you invoke this command directly for an upgrade, include them in `RECIPE_PR_BODY` yourself so the reviewer sees what changed upstream.

View File

@ -0,0 +1,35 @@
---
description: Deploy the local recipe checkout to the test instance
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep]
---
# Recipe Deploy
Deploy the current local recipe checkout to the configured test instance using chaos mode.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Get the domain and server for this recipe**:
```
python3 scripts/get_test_instance.py --recipe $ARGUMENTS
```
This outputs DOMAIN and SERVER for the active instance.
- If the recipe has no `recipe-info/$ARGUMENTS/recipe.toml`, tell the user to run `/recipe-init $ARGUMENTS` first and stop.
2. **Show current local recipe state** — run `abra recipe diff $ARGUMENTS` to show what local changes exist in the recipe checkout.
3. **Deploy with chaos** — run:
```
abra app deploy <DOMAIN> --chaos --force --no-input
```
- `--chaos` deploys the local checkout as-is, ignoring uncommitted changes.
- `--force` skips confirmation prompts.
- `--no-input` ensures non-interactive mode.
4. **Verify the deployment** — suggest running `/recipe-test $ARGUMENTS` to confirm the deployment is healthy.

View File

@ -0,0 +1,105 @@
---
description: Create a new test instance and recipe-info for a recipe
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Init
Bootstrap everything needed to start working with a Co-op Cloud recipe: fetch it, create a test instance, set up the `recipe-info/` directory with upstream info and tests, and deploy.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Resolve the active instance** — run `python3 scripts/get_test_instance.py` to get SERVER and INSTANCE. Use these values throughout (not hardcoded instance names).
2. **Fetch the recipe** — check for uncommitted local changes first (see guidelines). If clean, run `abra recipe fetch $ARGUMENTS --force`. If there are local changes, skip the fetch and note that you're using the local checkout.
3. **Read the recipe's compose.yml** to identify images and services. The recipe lives at `~/.abra/recipes/$ARGUMENTS/compose.yml`.
4. **Read the recipe's README** at `~/.abra/recipes/$ARGUMENTS/README.md` (if it exists):
- Look for any required initial configuration steps beyond what `abra app new` handles (e.g. manual env vars, external dependencies, DNS records, post-deploy setup commands, required third-party accounts or API keys).
- Note any documented setup instructions, caveats, or prerequisites.
- If the README mentions configuration that needs operator action, include it in the summary at the end and set the relevant env vars in the `abra app new` step or in the app's env file if possible.
5. **Identify all required domains** and ask the user to set up DNS:
- The primary domain is always `$ARGUMENTS.<DOMAIN_SUFFIX>`.
- Check compose.yml and `.env.sample` for additional domain variables (e.g. `SANDBOX_DOMAIN`, `EXTRA_DOMAINS`). For each additional domain, propose a subdomain following the pattern `<purpose>-$ARGUMENTS.<DOMAIN_SUFFIX>`.
- Present the full list of domains to the user and tell them each one needs a DNS A/CNAME record pointing to the test server.
- **Stop and wait for the user to confirm the DNS records are in place** before continuing. TLS certificate provisioning (via Let's Encrypt / Traefik) will fail if the domains don't resolve to the server.
6. **Create the test app instance** via:
```
abra app new $ARGUMENTS --server <SERVER> --domain $ARGUMENTS.<DOMAIN_SUFFIX> --no-input
```
- Do NOT pass `--secrets` here — secrets are generated separately in step 7 so we can capture them.
- `--no-input` for non-interactive mode.
- If the app already exists (command errors with an "already exists" message), note that and skip creation.
- If the README (step 4) mentioned required env vars or configuration, set them in the app's env file before deploying. The env file is at `~/.abra/servers/<SERVER>/$ARGUMENTS.<DOMAIN_SUFFIX>.env`.
7. **Generate and save secrets** — Generate secrets separately so the machine-readable output can be captured and saved:
```bash
abra app secret generate $ARGUMENTS.<DOMAIN_SUFFIX> --all -m --no-input
```
- The `-m` flag produces machine-readable output with the secret names and values.
- Capture this output and save it to `recipe-info/testsecrets/$ARGUMENTS.<DOMAIN_SUFFIX>`. Create the `testsecrets/` directory if it doesn't exist.
- Each line should be in `name=value` format.
- **Fallback**: If secrets were already generated (e.g. via a previous `abra app new --secrets`) and the values weren't saved, you can read them from the running container after deployment:
```bash
ssh <SERVER> 'CID=$(docker ps -q -f name=<stack_prefix>_app); for f in $(docker exec $CID ls /run/secrets/); do echo "$f=$(docker exec $CID cat /run/secrets/$f)"; done'
```
8. **Create `recipe-info/$ARGUMENTS/recipe.toml`** with the content:
```toml
name = "$ARGUMENTS"
```
If the recipe has dependencies (e.g. requires keycloak or authentik for SSO), add:
```toml
[dependencies]
requires = ["keycloak"]
[sso]
provider = "keycloak"
setup_script = "setup/sso_integration.py"
```
9. **Create `recipe-info/$ARGUMENTS/upstream.md`** — Discover the upstream project info:
- Read compose.yml to identify all images used by the recipe.
- Search for GitHub repos and release pages for each image.
- Write upstream.md following the format in `recipe-info/hedgedoc/upstream.md` as a template.
10. **Create `recipe-info/$ARGUMENTS/setup.md`** (if it doesn't already exist) — Write a first-time setup guide based on the README and what you learned in steps 3-7:
- Use `recipe-info/hedgedoc/setup.md` as the template format.
- Use `<SERVER>`, `<DOMAIN_SUFFIX>` as placeholders (not hardcoded instance names) so the guide works on any instance.
- **Prerequisites**: DNS records needed, any external dependencies (e.g. "Keycloak must be deployed first").
- **Steps**: The exact `abra` commands to go from nothing to a working deployment — `abra app new`, `abra app secret generate`, any env file edits, `abra app deploy`, and any post-deploy commands (migrations, admin user creation, etc.).
- If the recipe needs SSO integration, add a step referencing the appropriate `setup_*_integration.py` script.
- If the README mentioned any special configuration, post-deploy hooks, or manual steps, include them.
- Keep it concise — this is a quick-reference runbook, not full documentation.
11. **Create `recipe-info/$ARGUMENTS/test.md`** — Write a test plan:
- Target URL: `https://$ARGUMENTS.<DOMAIN_SUFFIX>`
- List automated test scripts (at minimum, `health_check.py`).
- List manual verification steps (at minimum, open the URL in a browser and confirm it loads).
- If the README mentioned any post-deploy verification steps, include them in the manual checks.
12. **Create `recipe-info/$ARGUMENTS/tests/health_check.py`** — A basic health check script following the pattern in `recipe-info/hedgedoc/tests/health_check.py`:
- Use `utils.tests.helpers` for HTTP checks and domain resolution.
- Check for HTTP 200 at the instance URL.
13. **Deploy the app**:
```
abra app deploy $ARGUMENTS.<DOMAIN_SUFFIX> --chaos --force --no-input
```
- If the README mentioned any post-deploy setup commands (e.g. running migrations, creating an admin user), run them after deployment.
- If secrets weren't saved in step 7 (fallback case), read them from the running containers now and save to `recipe-info/testsecrets/`.
14. **Summarise** — Tell the user what was created and suggest next steps:
- Run `/recipe-test $ARGUMENTS` to verify the deployment.
- Run `/recipe-check $ARGUMENTS` to check for upgrades.
- Add more test scripts to `recipe-info/$ARGUMENTS/tests/`.
- If the README flagged any configuration that couldn't be automated (e.g. external API keys, DNS records, third-party accounts), list those as manual actions the user still needs to take.

View File

@ -0,0 +1,91 @@
---
description: Bump the recipe version and publish the release via `abra recipe release`
argument-hint: <recipe-name> [--patch|--minor|--major]
allowed-tools: [Bash, Read, Grep]
---
# Recipe New Tag
Bump the recipe version label and publish the release using **`abra recipe release`** — the one command
that bumps the `coop-cloud.${STACK_NAME}.version` label, commits, creates the annotated tag, **and pushes
the tag upstream** (which publishes the release to the Co-op Cloud catalogue). Do **not** hand-compute the
semver or hand-edit the label — let abra do it.
> ⚠️ **This publishes.** A real (non-`--dry-run`) `abra recipe release` pushes the tag to the recipe's git
> origin, generating a catalogue release. Only run it from a machine that has push access to the recipe's
> upstream (ssh-agent loaded with the coopcloud key), and only when you mean to publish. In the
> upgrade-PR flow this is the **final** step, run after the upstream PR merges (see `/recipe-upstream`).
The arguments are: $ARGUMENTS
Parse the arguments to extract the recipe name (first positional arg) and the bump type flag (`--patch`, `--minor`, or `--major`). Default to `--patch` if no flag is given.
## Steps
1. **Check for uncommitted changes** in the recipe directory:
```bash
git -C ~/.abra/recipes/<recipe-name> status --short
```
If there are uncommitted changes, review the diff to understand what changed:
```bash
git -C ~/.abra/recipes/<recipe-name> diff
git -C ~/.abra/recipes/<recipe-name> diff --cached
git -C ~/.abra/recipes/<recipe-name> ls-files --others --exclude-standard
```
Then stage everything and create a commit with a concise message summarising the changes (not just "update files" — describe what actually changed, e.g. "move WOPI startup trigger to celery worker" or "add celery-beat service for WOPI scheduling"):
```bash
cd ~/.abra/recipes/<recipe-name>
git add -A
git commit -m "<concise summary of changes>"
```
If there are no uncommitted changes, skip this step.
2. **Run `abra recipe release`** with the parsed recipe name and the flag for the bump type. Map the bump
type to the abra flag (`major→-x`, `minor→-y`, `patch→-z`), then run the real (non-`--dry-run`) command
— it bumps the label, commits, tags, and pushes the tag (publishes):
```bash
#!/usr/bin/env bash
set -euo pipefail
RECIPE="<recipe-name>"
BUMP="<patch|minor|major>"
RECIPE_DIR="${HOME}/.abra/recipes/${RECIPE}"
COMPOSE="${RECIPE_DIR}/compose.yml"
if [ ! -f "${COMPOSE}" ]; then
echo "ERROR: ${COMPOSE} not found. Run 'abra recipe fetch ${RECIPE}' first."
exit 1
fi
# Current version (for the report only — abra computes the new one)
CURRENT=$(grep -oP 'coop-cloud\.\$\{STACK_NAME\}\.version=\K[^\s"]+' "${COMPOSE}" | head -1 || true)
# Map the bump type to the abra recipe release flag
case "${BUMP}" in
major) FLAG="-x" ;;
minor) FLAG="-y" ;;
patch) FLAG="-z" ;;
*) echo "ERROR: unknown bump type '${BUMP}'"; exit 1 ;;
esac
# Bump the label + commit + tag + PUBLISH, all in one real release (NO --dry-run).
# abra computes the correct a.b.c+x.y.z itself; do not hand-edit the label or hand-create the tag.
abra recipe release "${RECIPE}" "${FLAG}"
# Show the resulting version label
NEW_VER=$(grep -oP 'coop-cloud\.\$\{STACK_NAME\}\.version=\K[^\s"]+' "${COMPOSE}" | head -1 || true)
echo ""
echo "Version: ${CURRENT:-?} -> ${NEW_VER:-?} (released + published)"
```
Substitute `<recipe-name>` and `<patch|minor|major>` with the parsed values, then run the script with `bash`.
3. **Report the result** — show the old and new version, and confirm the release was published.

View File

@ -0,0 +1,51 @@
---
description: Check all maintained recipes and recommend what to upgrade
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Overview
Check the status of recipes, initialize any that aren't set up yet, check each one for available upgrades, and recommend which recipe to focus on today.
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Arguments
This skill accepts an optional space-separated list of recipe names as `$ARGUMENTS`. If provided, only check those recipes. If no arguments are given, read the full list from `maintained-recipes.md`.
## Steps
1. **Determine the recipe list**:
- If `$ARGUMENTS` is non-empty, use those recipe names as the list.
- Otherwise, read `maintained-recipes.md` from the workspace root and parse out the recipe names (lines starting with `- `).
2. **Check initialization status** for each recipe in the list — a recipe is considered initialized if `recipe-info/<recipe>/recipe.toml` exists.
3. **For every recipe** (initialized or not), check for available upgrades:
- Check for local changes first: `git -C ~/.abra/recipes/<recipe> status --short`
- If clean (or recipe not yet locally cloned), run: `abra recipe fetch <recipe> --force`
- Get the latest published version: `abra recipe versions <recipe> -m` (first line only)
- Check for available upgrades: `abra recipe upgrade <recipe> -m -n`
- If the recipe has uncommitted local changes, note this and skip the fetch/upgrade check for that recipe.
- For initialized recipes, also read `recipe-info/<recipe>/upstream.md` for release notes URLs and briefly summarise any available upgrade's headline changes.
- Note any breaking changes or operator action required.
4. **Build a summary table** across all recipes:
| Recipe | Initialized | Current Version | Upgrades Available | Breaking Changes | Priority |
|--------|-------------|----------------|-------------------|-----------------|----------|
For the Priority column, rank each recipe based on:
- **High**: security fixes available, or significantly behind upstream (multiple major versions)
- **Medium**: new features or minor version bumps available
- **Low**: only patch-level updates, or already up to date
- **Not initialized**: use this only if the recipe has no published versions at all (i.e. can't even be checked)
5. **Recommend which recipe to work on today**:
- Pick the highest-priority recipe that has upgrades available.
- Explain why (security fixes, how far behind, breaking changes that will only get harder to handle later, etc.).
- For initialized recipes with upgrades, suggest running `/recipe-upgrade-plan <recipe>`.
- For uninitialized recipes with high-priority upgrades pending, suggest running `/recipe-init <recipe>` first.
- If multiple recipes are high priority, list them in recommended order.
- If everything is up to date, say so and suggest running `/recipe-review` on a recipe that could use improvement instead.

View File

@ -0,0 +1,120 @@
---
description: Review a recipe for Co-op Cloud best practices
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Review
Review a Co-op Cloud recipe against best practices and suggest improvements. This performs a thorough audit of the recipe's configuration, security, and operational readiness.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Fetch the recipe** — check for uncommitted local changes first (see guidelines). If clean, run `abra recipe fetch $ARGUMENTS --force`. If there are local changes, skip the fetch and note that you're using the local checkout.
The recipe lives at `~/.abra/recipes/$ARGUMENTS/`.
2. **Read all recipe files** — read every file in the recipe directory:
- `compose.yml` (and any `compose.*.yml` overlay files)
- `.env.sample`
- `abra.sh`
- `README.md`
- Any config templates (`.tmpl`, `.conf`, `.ini`, `.js`, etc.)
- Any entrypoint scripts (`entrypoint*.sh`)
- `release/` directory contents (if present)
- `MAINTENANCE.md` (if present)
3. **Run the linter** (if available):
```
abra recipe lint $ARGUMENTS
```
Note any warnings or errors.
4. **Check the recipe against each best practice area below.** For each area, note whether the recipe follows the practice, partially follows it, or is missing it entirely. Suggest specific fixes where applicable.
### Checklist
#### Compose structure
- [ ] Uses compose version `3.8`
- [ ] Services use named volumes (not bind mounts)
- [ ] No `ports:` definitions — routing is handled by Traefik labels
- [ ] No `env_file:` directives — environment is managed by abra
- [ ] Internal services (databases, caches) are on an `internal` network, not `proxy`
- [ ] The main web-facing service is on both `proxy` (external) and an internal network
#### Version label
- [ ] The `app` service has a `coop-cloud.${STACK_NAME}.version` deploy label
- [ ] The version follows the `X.Y.Z+upstream_version` format
#### Traefik labels
- [ ] `traefik.enable=true` on the web-facing service
- [ ] `traefik.http.routers.${STACK_NAME}.rule=Host(...)` is set
- [ ] `traefik.http.routers.${STACK_NAME}.tls=true` or `.tls.certresolver` is set
- [ ] `traefik.http.routers.${STACK_NAME}.entrypoints=web-secure` is set
- [ ] Traefik network is specified: `traefik.docker.network=proxy`
#### Deploy configuration
- [ ] `restart_policy` is configured (ideally with `max_attempts` to prevent infinite restarts)
- [ ] `update_config` with `failure_action: rollback` and `order: start-first` (recommended)
- [ ] `rollback_config` with `order: start-first` (recommended)
#### Healthchecks
- [ ] At least one service has a healthcheck defined
- [ ] Database/cache services have appropriate healthchecks (e.g. `redis-cli ping`, `pg_isready`)
- [ ] If healthchecks are absent, note this as an improvement area
#### Backups
Consult the README of the `backup-bot-two` recipe (`~/.abra/recipes/backup-bot-two/README.md`) for details on how to configure backup labels in Co-op Cloud recipes. Fetch it first if needed (`abra recipe fetch backup-bot-two`).
- [ ] `backupbot.backup=true` label is present on each service that has volumes to back up (can be on multiple services)
- [ ] By default all volumes on a labelled service are backed up — only use `backupbot.backup.volumes.<name>.path` or `backupbot.backup.volumes.<name>: false` when you need to limit or exclude specific volumes
- [ ] For database services: `backupbot.backup.pre-hook` dumps the database, `backupbot.restore.post-hook` restores it
- [ ] If backups are not configured, suggest adding `backupbot.backup` labels based on the backup-bot-two README
#### Secrets
- [ ] Sensitive values (passwords, API keys, tokens) use Docker secrets, not plain-text env vars
- [ ] Secret names are < 12 characters (to avoid the 64-char Docker limit after stack name + version are appended)
- [ ] `.env.sample` has `SECRET_*_VERSION=v1` entries for each secret
- [ ] `abra.sh` has secret generation hints (length, charset) if applicable
#### Environment variables
- [ ] `.env.sample` exists with all required variables
- [ ] Optional env vars are commented out in `.env.sample`
- [ ] Domain templating uses `<recipe>.example.com` pattern (auto-replaced by `abra app new`)
- [ ] New/optional env vars in compose.yml use defaults: `${MY_VAR:-default_value}`
- [ ] `STACK_NAME` is exposed in `environment:` for any service that needs it in config templates
#### Config management
- [ ] If the recipe uses Docker configs, versions are exported in `abra.sh`
- [ ] Config naming is consistent through the chain: configs stanza name, `name:` interpolation, and `abra.sh` export
- [ ] Config templates use `template_driver: golang` when Go template interpolation is needed
- [ ] Secrets in templates accessed via `{{ secret "name" }}`, env vars via `{{ env "NAME" }}`
#### README metadata
- [ ] README has the `<!-- metadata -->` ... `<!-- endmetadata -->` section
- [ ] All fields are present: Category, Status, Image, Healthcheck, Backups, Email, Tests, SSO
- [ ] Scores are accurate and reflect the current state of the recipe
- [ ] At least one named maintainer is listed (required for "stable" status)
#### Optional features
- [ ] SMTP/email support via `compose.smtp.yml` (if the app can send email)
- [ ] SSO/OIDC support via `compose.oidc.yml` (if the app supports it)
- [ ] Release notes in `release/` directory for each version
#### Resource limits
- [ ] Consider whether `deploy.resources.limits` should be set (memory/CPU) to prevent OOM issues on shared servers
5. **Summarise the review** with three sections:
**What's good** — things the recipe already does well.
**Recommended fixes** — issues that should be addressed (missing backup config, missing healthchecks, security concerns, incorrect labels, etc.). For each, provide the specific change needed (ideally a code snippet showing what to add/change in compose.yml or other files).
**Nice-to-haves** — optional improvements that would raise the recipe's quality/status score (SMTP support, SSO, better deploy config, resource limits, etc.).
Update the README metadata scores if they don't reflect reality (e.g. if backups are configured but README says "No").

View File

@ -0,0 +1,110 @@
---
description: Run tests for all maintained recipes, deploying each one at a time
allowed-tools: [Bash, Read, Write, Edit, Glob, Grep, WebFetch]
---
# Recipe Test All
Deploy and test every maintained recipe on the active test instance, one at a time. For each recipe, free server memory by undeploying unrelated apps, deploy the recipe and its dependencies, run all its tests, then move on to the next.
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
### 1. Determine the active instance
Run `python3 scripts/get_test_instance.py` to get SERVER and INSTANCE. Read `settings.toml` to get `domain_suffix`.
### 2. Discover all recipes and build deployment tiers
Glob `recipe-info/*/recipe.toml`. For each, read the file to get:
- `name` — the recipe name
- `[dependencies].requires` — list of recipe dependencies (may be empty or absent)
Group into tiers:
- **Tier 1** (no dependencies): recipes with empty or missing `requires`
- **Tier 2** (depends on tier 1): recipes whose `requires` list only contains tier 1 recipes
Present the test order to the user and **confirm before proceeding**.
### 3. Test each recipe in dependency order
Process recipes **one at a time** within each tier. For each recipe:
#### a. Context reset
Free memory by undeploying everything except infrastructure and this recipe's dependencies:
```bash
python3 scripts/context_reset.py --recipe <recipe>
```
#### b. Deploy dependencies and test dependencies
If the recipe has `[dependencies].requires` (runtime deps), deploy each one:
```bash
abra app deploy <dep-domain> --chaos --force --no-input
```
If the recipe has `[dependencies].test_requires` (test-only deps), deploy those too. These are domain prefixes (e.g. `lasuite-docs`, `ld2`) — the full domain is `<prefix>.<DOMAIN_SUFFIX>`. Note: `test_requires` are NOT transitive — only the target recipe's test_requires are deployed, not those of its dependencies.
Wait for each dependency to become healthy before continuing.
#### c. Deploy this recipe
```bash
abra app deploy <recipe>.<DOMAIN_SUFFIX> --chaos --force --no-input
```
If the deploy reports failure but services are running (check via SSH `docker service ls`), treat it as a success — abra's convergence checker times out on complex multi-service stacks.
If the deploy truly fails (services not starting, missing secrets, etc.), log FAIL for this recipe and skip to the next one.
#### d. Run tests
Discover all test scripts:
```bash
ls recipe-info/<recipe>/tests/*.py
```
Run each test script in sequence:
```bash
python3 recipe-info/<recipe>/tests/<script>.py
```
Capture stdout/stderr and exit code for each script.
#### e. Log results
For each test script, log:
- Script name
- PASS (exit 0) or FAIL (non-zero exit)
- Key output lines (especially PASS:/FAIL: messages)
### 4. Summary report
After all recipes are tested, print a summary table:
```
## Test Results — <INSTANCE>.commoninternet.net — <DATE>
| Recipe | Tier | Deploy | Tests | Details |
|--------|------|--------|-------|---------|
| keycloak | 1 | PASS | 2/2 | health_check PASS, oidc_integration PASS |
| authentik | 1 | PASS | 1/2 | health_check PASS, oidc_integration FAIL |
| ... | ... | ... | ... | ... |
**Overall: X/Y recipes fully passing**
```
Where:
- **Deploy**: PASS if the app deployed and responded to health check, FAIL otherwise
- **Tests**: `passed/total` count of test scripts
- **Details**: one-line summary of each test script result
### 5. Write the log
Save the full log (including all commands, outputs, and the summary table) to:
```
logs/recipe-test-all-<YYYY-MM-DD>.md
```

View File

@ -0,0 +1,152 @@
---
description: Test backing up and restoring a recipe's test instance
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch]
---
# Recipe Backup Test
Test the full backup and restore cycle for a Co-op Cloud recipe: create a backup, tear down the test instance (undeploy + remove volumes), redeploy from scratch, restore from the backup, and verify the app still works.
**Important:** All `abra` commands that read the recipe (deploy, backup, restore, restart, ps) MUST use `--chaos` so they use the current local recipe checkout, including any uncommitted changes. This ensures we're testing the backup/restore labels as they exist in the working copy.
**TTY workaround:** Several `abra` subcommands (`backup create`, `backup snapshots`, `restore`, `volume remove`) fail with "the input device is not a TTY" in non-interactive environments. Wrap these with `script -qefc "..." /dev/null` to provide a pseudo-TTY.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
Read `~/.abra/recipes/backup-bot-two/README.md` for context on how backup-bot-two works (its CLI commands, backup/restore flow, and label conventions).
## Steps
1. **Free server resources** by running `/test-context-reset $ARGUMENTS` to undeploy unrelated apps from the test server while keeping this recipe and its dependencies running.
2. **Get the domain and server for this recipe**:
```
python3 scripts/get_test_instance.py --recipe $ARGUMENTS
```
This outputs DOMAIN and SERVER for the active instance. Also read `settings.toml` to get `DOMAIN_SUFFIX` (the `domain_suffix` field for the active instance).
- If the recipe has no `recipe-info/$ARGUMENTS/recipe.toml`, tell the user to run `/recipe-init $ARGUMENTS` first and stop.
3. **Read recipe-specific backup test notes** from `recipe-info/$ARGUMENTS/tests/backup-test.md`, if it exists.
- This optional file can contain recipe-specific instructions: extra data-seeding steps before the backup, services to check after restore, known caveats, additional verification commands, or anything else specific to testing this recipe's backup/restore cycle.
- Keep these notes in mind and apply them at the relevant steps below.
4. **Fetch the recipe and check for backup labels**:
- Fetch the recipe (check for local changes first — see guidelines).
- Read `~/.abra/recipes/$ARGUMENTS/compose.yml`.
- Search for `backupbot.backup` deploy labels.
- If **no backup labels** are found anywhere in the compose file:
- Tell the user this recipe does not have backup configured.
- Explain what's needed: at minimum, a `backupbot.backup=true` deploy label on the main service, plus appropriate pre/post hooks for databases (see the Co-op Cloud backup spec in `docs.coopcloud.tech/docs/specs/backup/`).
- Stop here.
- If backup labels are found, **summarise the backup configuration**:
- Which service has `backupbot.backup=true`
- Any `backupbot.backup.pre-hook` / `backupbot.backup.post-hook` commands
- Any `backupbot.restore.pre-hook` / `backupbot.restore.post-hook` commands
- Any volume/path restrictions (`backupbot.backup.path`, `backupbot.backup.volumes.*`)
5. **Ensure backup-bot-two is deployed on the server**:
- `abra app backup create` requires backup-bot-two to be running on the server. Check by running:
```
abra app ls --server <SERVER> --status 2>&1 | grep backup-bot
```
- If backup-bot-two is not deployed, deploy it:
- `abra recipe fetch backup-bot-two --force`
- `abra app new backup-bot-two --server <SERVER> --domain backupbot.<DOMAIN_SUFFIX> --secrets --no-input`
- `abra app deploy backupbot.<DOMAIN_SUFFIX> --force --no-input`
- If `app new` says it already exists, just check it's deployed and deploy if not.
- Confirm backup-bot-two is running before proceeding.
6. **Verify the app is deployed and healthy**:
- Run `abra app ps <DOMAIN> --chaos --no-input -m` to check deployment status (use `-m` for machine-readable output to avoid TTY issues).
- Run the existing health check script if available: `python3 recipe-info/$ARGUMENTS/tests/health_check.py`.
- If no health check script exists, curl `https://<DOMAIN>` and check for HTTP 200.
- If the app is not deployed, deploy it first: `abra app deploy <DOMAIN> --chaos --force --no-input`, then wait and re-check.
- If the backup labels were just added and the app was already deployed, **redeploy** so the running services pick up the new labels: `abra app deploy <DOMAIN> --chaos --force --no-input`.
- If `backup-test.md` specifies any data-seeding steps to perform before the backup (e.g. creating a test document, inserting a database record), do them now.
7. **Create a backup snapshot**:
- First, list existing snapshots to establish a baseline: `script -qefc "abra app backup snapshots <DOMAIN> --no-input" /dev/null`
- Note: `backup snapshots` does NOT support `--chaos`.
- Note the number of existing snapshots (may be zero).
- Run (with TTY wrapper): `script -qefc "abra app backup create <DOMAIN> --chaos --no-input" /dev/null`
- Confirm the output contains "backup finished" or similar success message.
- List snapshots again: `script -qefc "abra app backup snapshots <DOMAIN> --no-input" /dev/null`
- Confirm there is exactly one more snapshot than before.
- Note the newest snapshot ID from the output.
8. **Verify backup contents**:
- Run `backup ls` inside the backupbot container to list the files in the latest snapshot, filtered to this app:
```
script -qefc "abra app run backupbot.<DOMAIN_SUFFIX> app -- backup -h <DOMAIN> ls" /dev/null
```
- The `-h <DOMAIN>` flag filters the listing to only this app's snapshot. The `ls` command defaults to listing under `/var/lib/docker/volumes/`.
- Note: `abra app run` requires the TTY wrapper and does NOT support `--chaos`.
- Save the output, then use `grep` to confirm that expected files/paths are present:
- **Volume paths**: For each volume identified as backed up in step 3, grep the output for the volume name (the Docker volume name is `<stack_name>_<volume_name>`). Confirm that at least one file appears under each expected volume path.
- **Excluded volumes**: For any volumes explicitly excluded (e.g. `backupbot.backup.volumes.<name>=false`), confirm they do NOT appear in the listing.
- **Recipe-specific files**: If `backup-test.md` specifies particular files or patterns to look for in the backup, grep for those as well.
- If any expected volume paths are missing or any excluded volumes appear, flag as **FAIL** with details about what was missing or unexpectedly present.
9. **Undeploy the app**:
- Run: `abra app undeploy <DOMAIN> --no-input`
- Undeploy does not need `--chaos`.
10. **Remove volumes** to simulate complete data loss:
- Run (with TTY wrapper): `script -qefc "abra app volume remove <DOMAIN> --force --no-input" /dev/null`
- This ensures the restore test starts from a genuinely clean slate — no leftover data.
- **Do not skip this step** — testing restore onto existing data is not a valid backup test.
- If volume removal fails with "volume is in use" by dead/ghost containers that Docker can't remove, clear them manually via SSH:
1. Identify the ghost containers: `ssh <server> "docker ps -a --filter volume=<volume_name> --format '{{.ID}} {{.State}}'"` — they will show as `dead`
2. Remove their directories: `ssh <server> "sudo rm -rf /var/lib/docker/containers/<full_container_id>"`
3. Restart Docker: `ssh <server> "sudo systemctl restart docker"`
4. Wait ~15 seconds, then retry volume removal
11. **Redeploy from scratch**:
- Run: `abra app deploy <DOMAIN> --chaos --force --no-input`
- Wait for the app to come up (check with `abra app ps <DOMAIN> --chaos --no-input -m`).
- The app may not be fully healthy yet since it has no data — that's expected.
12. **Restore from backup**:
- Run (with TTY wrapper): `script -qefc "abra app restore <DOMAIN> --chaos --hooks --no-input" /dev/null`
- The `--hooks` flag ensures restore pre/post hooks run (e.g. database import commands).
- **Do NOT pass `--target`** to the restore command. The `--target` flag is a restic restore target *directory*, not a stack filter — passing a stack name as `--target` causes restic to restore files into a wrong subdirectory instead of the actual volume paths, and restore hooks will fail because the files won't be where the containers expect them.
- Confirm the output contains "Restoring Snapshot" or similar success message.
13. **Redeploy the app** to ensure it picks up the restored data:
- Do NOT use `abra app restart --all-services` — it tends to hang on stacks with many services.
- Instead, redeploy with force: `abra app deploy <DOMAIN> --chaos --force --no-input`
- Wait for all services to converge: `sleep 30`, then check with `abra app ps <DOMAIN> --chaos --no-input -m`. Allow up to 60 seconds for services to start. If abra reports a deploy timeout but `app ps` shows all services running and healthy, treat it as a pass.
14. **Run the test suite** to verify the app works after restore:
- Discover and run all test scripts from `recipe-info/$ARGUMENTS/tests/*.py`.
- For each script, record PASS (exit 0) or FAIL (non-zero).
- Read `recipe-info/$ARGUMENTS/test.md` and perform URL-based checks using `curl` or `WebFetch`.
- If `backup-test.md` specifies extra verification steps (e.g. "confirm the test document still exists", "check the database has records"), perform those too.
- If no tests exist at all, at minimum curl `https://<DOMAIN>` and check for HTTP 200.
15. **Summarise results**:
Report each phase of the backup/restore cycle:
| Phase | Result |
|-------|--------|
| Backup-bot-two check | PASS / FAIL |
| Initial health check | PASS / FAIL |
| Backup creation | PASS / FAIL |
| Backup contents verification | PASS / FAIL |
| Undeploy + volume removal | PASS / FAIL |
| Fresh redeploy | PASS / FAIL |
| Restore from backup | PASS / FAIL |
| Post-restore redeploy | PASS / FAIL |
| Test suite | PASS / FAIL (detail per test) |
- If all phases passed: confirm the recipe's backup/restore cycle is working correctly.
- If any phase failed: highlight which step failed, show relevant error output, and suggest troubleshooting:
- Check backup labels in compose.yml
- Check pre/post hook commands for errors
- Review app logs: `abra app logs <DOMAIN>`
- Check if all volumes are being backed up
- For database services, verify the dump/restore hooks are correct

View File

@ -0,0 +1,90 @@
---
description: Test a recipe's first-time initialization from scratch
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch]
---
# Recipe Test New
Test that a recipe works correctly for first-time initialization: remove the existing test instance entirely, recreate it from scratch, deploy, run post-deploy steps, and verify everything works.
**Important:** All `abra` commands that read the recipe (deploy, ps, cmd) MUST use `--chaos` so they use the current local recipe checkout, including any uncommitted changes.
**TTY workaround:** Several `abra` subcommands fail with "the input device is not a TTY" in non-interactive environments. Wrap these with `script -qefc "..." /dev/null` to provide a pseudo-TTY.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Free server resources** by running `/test-context-reset $ARGUMENTS` to undeploy unrelated apps from the test server while keeping this recipe's dependencies running.
2. **Get the domain and server for this recipe**:
```
python3 scripts/get_test_instance.py --recipe $ARGUMENTS
```
This outputs DOMAIN and SERVER for the active instance.
- If the recipe has no `recipe-info/$ARGUMENTS/recipe.toml`, tell the user to run `/recipe-init $ARGUMENTS` first and stop.
3. **Read the test plan** from `recipe-info/$ARGUMENTS/test.md`.
- Note the post-deploy steps — these will need to be run after redeploying.
- Note any prerequisites (e.g. Keycloak must be running).
- Note which automated check scripts are referenced.
4. **Read the recipe** at `~/.abra/recipes/$ARGUMENTS/compose.yml` and `~/.abra/recipes/$ARGUMENTS/abra.sh` to understand the services and available commands.
5. **Undeploy the existing instance**:
- Run: `abra app undeploy <DOMAIN> --no-input`
- If the app is not deployed, note that and continue.
6. **Remove the app entirely** (secrets, volumes, env file):
- Run: `abra app rm <DOMAIN> --force --no-input`
- This deletes all secrets, volumes, and the local env file.
- If it fails because the app doesn't exist, that's fine — continue.
7. **Recreate the app instance**:
- Run:
```
abra app new $ARGUMENTS --server <SERVER> --domain <DOMAIN> --secrets --no-input
```
- `--secrets` auto-generates secrets.
- Save the generated secrets to `recipe-info/$ARGUMENTS/secrets.json`:
```
abra app secret generate <DOMAIN> --all --machine > recipe-info/$ARGUMENTS/secrets.json
```
- Check the recipe's README (`~/.abra/recipes/$ARGUMENTS/README.md`) and test.md for any env vars that need to be configured before deploying. If there are any, set them in the app's env file at `~/.abra/servers/<SERVER>/<DOMAIN>.env`.
- If the recipe has secrets that must be manually inserted (not auto-generated), check `recipe-info/$ARGUMENTS/test.md` for instructions and apply them.
8. **Deploy from scratch**:
- Run: `abra app deploy <DOMAIN> --chaos --force --no-input`
- Wait for services to come up. Check with `abra app ps <DOMAIN> --chaos --no-input -m`.
- Allow up to 90 seconds for all services to converge. If abra reports a deploy timeout but `app ps` shows services running, treat it as success.
9. **Run post-deploy steps** as documented in `recipe-info/$ARGUMENTS/test.md` under "Post-Deploy Steps":
- Typically includes things like database migrations, bucket creation, SSO integration setup scripts, etc.
- Run each step and confirm it succeeds.
- If a step involves running a setup script (e.g. `setup_keycloak_integration.py`), check if it exists in `recipe-info/$ARGUMENTS/` and run it.
- If post-deploy steps require a redeploy, do so: `abra app deploy <DOMAIN> --chaos --force --no-input`
10. **Run the test suite**:
- Discover and run all test scripts from `recipe-info/$ARGUMENTS/tests/*.py`.
- For each script, record PASS (exit 0) or FAIL (non-zero).
- Read `recipe-info/$ARGUMENTS/test.md` and perform URL-based checks using `curl` or `WebFetch`.
- If no tests exist at all, at minimum curl `https://<DOMAIN>` and check for HTTP 200.
11. **Summarise results**:
Report each phase:
| Phase | Result |
|-------|--------|
| Undeploy + remove | PASS / FAIL |
| Recreate instance | PASS / FAIL |
| Fresh deploy | PASS / FAIL |
| Post-deploy steps | PASS / FAIL (detail per step) |
| Test suite | PASS / FAIL (detail per test) |
- If all phases passed: confirm the recipe's first-time initialization works correctly.
- If any phase failed: highlight which step failed, show relevant error output, and suggest troubleshooting.

View File

@ -0,0 +1,59 @@
---
description: Test upgrading a recipe's test instance using abra app deploy
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch]
---
# Recipe Test Update
Deploy updated recipe images to an already-running test instance and verify the upgrade works. Combines `/recipe-deploy` and `/recipe-test` with pre-flight checks to confirm the version is actually changing.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Get the domain and server for this recipe**:
```
python3 scripts/get_test_instance.py --recipe $ARGUMENTS
```
This outputs DOMAIN and SERVER for the active instance.
- If the recipe has no `recipe-info/$ARGUMENTS/recipe.toml`, tell the user to run `/recipe-init $ARGUMENTS` first and stop.
2. **Check preconditions**:
- Verify the app is currently deployed: `abra app ps <DOMAIN> --chaos --no-input -m`.
- If the app is not deployed, tell the user to run `/recipe-deploy $ARGUMENTS` first and stop — we need a running instance to upgrade.
- Note the currently deployed version from the `ps` output.
3. **Read the new version** from `~/.abra/recipes/$ARGUMENTS/compose.yml`:
- Find the `coop-cloud.${STACK_NAME}.version=...` label to get the new recipe version string.
- If the new version matches the currently deployed version, tell the user there's nothing to upgrade and stop.
- Show the user what's changing: old version → new version.
4. **Deploy the update** by following the `/recipe-deploy` process for `$ARGUMENTS`.
5. **Wait for the app to stabilise**:
- Wait 30 seconds, then check status: `abra app ps <DOMAIN> --chaos --no-input -m`.
- Allow up to 90 seconds total for all services to reach "running" state.
- If services are not healthy after 90 seconds, note this but continue to tests.
6. **Run the test suite** by following the `/recipe-test` process for `$ARGUMENTS`.
7. **Summarise results**:
| Phase | Result |
|-------|--------|
| Pre-upgrade status | deployed at version X |
| Deploy update | PASS / FAIL |
| Post-upgrade health | PASS / FAIL |
| Test suite | PASS / FAIL (detail per test) |
**If all tests passed:**
- Confirm the update works.
- Suggest committing and tagging the recipe if not already done, then pushing when ready.
**If the deploy or any tests failed:**
- Report what failed with relevant error output and suggest troubleshooting.
- Note that the previous version can be restored by fetching the recipe (`abra recipe fetch $ARGUMENTS --force`) and redeploying.

View File

@ -0,0 +1,43 @@
---
description: Run all tests for a Co-op Cloud recipe
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch]
---
# Recipe Test
Run all available tests for a Co-op Cloud recipe.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Free server resources** by running `/test-context-reset $ARGUMENTS` to undeploy unrelated apps from the test server while keeping this recipe and its dependencies running.
2. **Locate the test directory** at `recipe-info/$ARGUMENTS/tests/`.
- If the directory does not exist, tell the user no tests are configured for this recipe and suggest running `/recipe-check $ARGUMENTS` first (which creates the directory structure).
- Stop here if no test directory is found.
3. **Read the test plan** from `recipe-info/$ARGUMENTS/test.md`.
- Note the target URL and any manual verification steps listed.
- Note which automated check scripts are referenced.
4. **Discover all test scripts** by globbing `recipe-info/$ARGUMENTS/tests/*.py`.
5. **Run each test script** in sequence:
- Execute each `.py` script with `python3 recipe-info/$ARGUMENTS/tests/<script>`.
- Capture stdout and stderr.
- Record whether each script exited 0 (PASS) or non-zero (FAIL).
6. **Perform the manual verification checks** listed in `test.md`:
- For URL-based checks (e.g. "open URL and confirm page loads"), use `curl` or `WebFetch` to verify the endpoint is reachable and returns expected content.
- Report which manual checks could be verified automatically and which truly require a human.
7. **Summarise results**:
- List each automated script with its PASS/FAIL status and output.
- List each manual check with its result (verified / needs human / failed).
- If any tests failed, highlight them clearly and suggest troubleshooting steps.
- If all tests passed, confirm the recipe deployment looks healthy.

View File

@ -0,0 +1,189 @@
---
description: Execute a planned recipe upgrade — apply changes, deploy, test, commit/tag
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Upgrade Apply
Execute a previously planned upgrade for a Co-op Cloud recipe. Reads the plan file, applies the changes, deploys to the test instance, runs tests, and commits/tags if everything passes.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Get the domain and server for this recipe**:
```
python3 scripts/get_test_instance.py --recipe $ARGUMENTS
```
This outputs DOMAIN and SERVER for the active instance.
- If the recipe has no `recipe-info/$ARGUMENTS/recipe.toml`, tell the user to run `/recipe-init $ARGUMENTS` first and stop.
2. **Find and read the upgrade plan** — look for the most recent plan file matching `plans/$ARGUMENTS-upgrade-*.md`.
- If no plan file exists, tell the user to run `/recipe-upgrade-plan $ARGUMENTS` first and stop.
- Read the plan file to get the image tag changes, version bump, recipe changes, risks, and deployment notes.
3. **Present the plan summary** to the user:
- Image tag changes (service / current → new)
- Recipe version bump
- Risks and caveats
- Any post-deploy steps
4. **Apply the upgrades** — update image tags in the local recipe checkout:
```
abra recipe upgrade $ARGUMENTS -n
```
- If the plan specifies manual tag changes (tags that `abra recipe upgrade` won't handle), apply those by editing `~/.abra/recipes/$ARGUMENTS/compose.yml` directly.
5. **Do NOT bump the recipe version label here — record the recommended release bump instead.** The
`coop-cloud.${STACK_NAME}.version` label is **not** changed in this PR. The version bump + tag + publish
all happen at the very end, after the upstream PR merges, via a single real `abra recipe release`
command (see `/recipe-upstream`). All this step does is **decide and record the semver bump** so that
command is ready:
- Pick the bump from the upgrade's nature per the plan: `-x` (major) for breaking changes, `-y` (minor)
for a new feature, `-z` (patch) for a patch/security bump.
- Record the recommended release command — `abra recipe release $ARGUMENTS -x|-y|-z` (no `--dry-run`) —
for the PR body (step 12). `/recipe-upstream` reads this line back out of the PR to drive the publish.
- Leave the version label as-is. (`abra recipe release` at release time computes the final
`a.b.c+x.y.z` from the current label + the flag + the app image tag, and syncs the label then.)
- Note the current version + the recommended bump for the report.
6. **Apply any additional recipe changes** noted in the plan:
- New env vars, changed config templates, new volumes, updated labels, added/removed services, etc.
- Only make changes that are documented in the plan file.
7. **Lint the upgraded recipe**:
```
abra recipe lint $ARGUMENTS -C
```
- If lint errors are found, report them and attempt to fix obvious issues. If unfixable, warn the user and continue.
8. **Deploy the upgraded recipe** — follow the same process as `/recipe-deploy $ARGUMENTS`:
```
abra app deploy <DOMAIN> --chaos --force --no-input
```
- If the plan mentioned post-upgrade migration commands, run them after deployment.
9. **Run the test suite** — follow the same process as `/recipe-test $ARGUMENTS`:
- Discover and run all test scripts from `recipe-info/$ARGUMENTS/tests/*.py`.
- Read `recipe-info/$ARGUMENTS/test.md` and perform URL-based manual checks using `curl` or `WebFetch`.
- If no test directory exists, do a basic health check by curling `https://<DOMAIN>` and checking for HTTP 200.
10. **Write an upgrade report** for future reference:
- Create the `planned-updates/` directory if it doesn't already exist.
- Write to `planned-updates/$ARGUMENTS-upgrade-<YYYY-MM-DD>.md` (using today's date).
- Include:
- Current recipe version + the recommended release bump (e.g. `1.0.2+5.8.3`, bump `-y` minor →
published as `abra recipe release` at release time)
- Which image tags were upgraded per service
- Changelog summary with links to full release notes
- Any **Operator Action Required** items
- Lint results
- Test results (PASS/FAIL for each test)
- Any manual steps still needed
- Tell the user the file path.
11. **Commit the upgrade** (only if all tests passed):
- `cd ~/.abra/recipes/$ARGUMENTS`
- Stage the changed files: `git add compose.yml` (and any other modified recipe files).
- Commit the image-tag/config changes — e.g. `git commit -m "chore: upgrade <service> to <new-tag>"`.
Do **not** mention a recipe version in the message: the version label is not bumped here, and the
branch name is derived from the commit (see `/recipe-create-pr`).
- Do **NOT** create a tag or bump the version label. The tag + version bump + publish are done at the
end by `abra recipe release` (see `/recipe-upstream`), after the upstream PR merges.
- If any tests failed, skip this step entirely.
12. **Open a review PR on git.autonomic.zone** (only if step 11 ran):
- Before following the `/recipe-create-pr $ARGUMENTS` steps, construct the PR body and export it as `RECIPE_PR_BODY`. Use this format (fill in real values):
```markdown
Upgrade `<recipe>` image tags (current recipe version `<current-version>`).
## Image tag changes
| Service | Old tag | New tag |
|---------|---------|---------|
| app | 1.0.0 | 1.1.0 |
## Upstream release notes
**<service> <old>→<new>:** <url>
<!-- one line per upgraded image/service; pull each URL from recipe-info/<recipe>/upstream.md
(between the current → new version). These links MUST be in the PR body itself, not just
the report — so a reviewer sees exactly what changed upstream. -->
## Test results
| Test | Result |
|------|--------|
| `tests/test_foo.py` | ✓ PASS |
| URL `https://<domain>` | ✓ PASS |
## Recommended release bump
This PR does **not** bump the `coop-cloud.${STACK_NAME}.version` label. After the upstream PR
merges, publish the release (which bumps the label + tags + pushes) with:
abra recipe release <recipe> -<x|y|z>
<!-- pick -x major / -y minor / -z patch per the upgrade; /recipe-upstream reads this line. -->
## Next steps (after PR review)
```bash
# 1. Review the PR on git.autonomic.zone:
# <gitea-pr-url>
# 2. Pull the PR branch to your local dev machine:
cd ~/.abra/recipes/<recipe>
git fetch git@git.autonomic.zone:recipe-maintainers/<recipe>.git <branch>:<branch>
git checkout <branch>
# 3. Push to your upstream fork:
git push dev HEAD:<branch>
# 4. Open a PR on upstream and merge it:
# https://git.coopcloud.tech/coop-cloud/<recipe>/pulls
# 5. After the upstream PR merges, publish the release (bumps the version label,
# commits, tags AND pushes the tag — all in one step):
abra recipe release <recipe> -<x|y|z>
```
```
- Then follow the same process as `/recipe-create-pr $ARGUMENTS` (read that skill file). The recipe-create-pr script will use `RECIPE_PR_BODY` as the PR body when that variable is set.
- Capture the resulting PR URL — you'll include it in the summary.
- If the PR creation fails, do not block: report the error and tell the user they can run `/recipe-create-pr $ARGUMENTS` manually after fixing.
13. **Summarise**:
- The current recipe version + the recommended release bump (e.g. `1.0.2+5.8.3`, bump `-y` minor).
- Which image tags were upgraded per service (old → new).
- Whether the deployment succeeded.
- Test results — all passed, or which failed with details.
- If any tests failed: highlight failures, suggest troubleshooting, and note the recipe can be rolled back by re-fetching (`abra recipe fetch $ARGUMENTS --force`).
- Any manual actions still required (from the plan).
- The git.autonomic.zone review PR URL from step 12 (or note that PR creation failed and how to retry).
- If all tests passed AND the review PR was opened, give the user these next steps verbatim — substituting `<gitea-pr-url>`, `<branch>`, the release flag `-<x|y|z>`, and `$ARGUMENTS`:
```bash
# 1. Review the PR on git.autonomic.zone:
# <gitea-pr-url>
# 2. Pull the PR branch to your local dev machine:
cd ~/.abra/recipes/$ARGUMENTS
git fetch git@git.autonomic.zone:recipe-maintainers/$ARGUMENTS.git <branch>:<branch>
git checkout <branch>
# 3. Push to your upstream fork:
git push dev HEAD:<branch>
# 4. Open a PR on upstream and merge it:
# https://git.coopcloud.tech/coop-cloud/$ARGUMENTS/pulls
# 5. After the upstream PR merges, publish the release (bumps the version label,
# commits, tags AND pushes the tag — all in one step):
abra recipe release $ARGUMENTS -<x|y|z>
```

View File

@ -0,0 +1,198 @@
---
description: Autonomous weekly upgrade run — overview all recipes, upgrade each end-to-end (sequentially by default, parallel with --parallel), open PRs
allowed-tools: [Bash, Read, Write, Glob, Grep, Agent]
---
# Recipe Upgrade Cron All
Designed to run unattended (e.g. on a weekly cron). Surveys every maintained recipe for available upgrades, then runs `/recipe-upgrade-full` for each upgradeable recipe via a subagent. Each subagent plans, applies, deploys, tests, and opens a review PR on `git.autonomic.zone`. The cron driver collects results and writes one summary report listing every PR URL.
By default recipes are processed **sequentially** — one fully completes before the next begins. Pass `--parallel` to fan out all subagents at once.
PRs are reviewed and merged manually by a human afterwards — this skill never pushes to upstream or merges anything.
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Arguments
This skill takes no required arguments. Optional `$ARGUMENTS`:
- A space-separated list of recipe names → only consider those. Otherwise use the full `maintained-recipes.md` list.
- The literal token `--dry-run` → do the overview phase and print which recipes WOULD be upgraded, but do not spawn any subagents.
- The literal token `--parallel` → fan out all subagent upgrades concurrently instead of one-at-a-time.
## Why this skill exists
Manually walking each recipe through `/recipe-upgrade-plan` then `/recipe-upgrade-apply` doesn't scale to ~10 recipes per week. This skill keeps the human in the loop where it matters (PR review) and out of the loop where it doesn't (mechanical bumps + test runs). Failures are skipped and surfaced in the summary so a human can investigate the next morning.
## Parallel safety (relevant when --parallel is used)
The apply phase deploys to the shared test instance. Two concerns:
1. **`context_reset.py` clobbering peers.** Each subagent holds `/workspace/in-use/<recipe>.lock` for its whole run. `context_reset.py` reads that directory and protects every locked recipe (see `scripts/context_reset.py`). So any `context_reset --recipe X` — whether the sequential driver's per-recipe reset (step 3) or a stray/manual invocation — leaves every *other* locked recipe deployed. The lock is what makes the reset safe.
2. **Server memory pressure.** Parallel deploys can OOM the test server. In parallel mode there is no safe point to undeploy between concurrent subagents, so apps from all of them coexist. Failures are caught per-recipe (step 4 below), reported in the summary, and retried automatically on the next cron run. Acceptable for unattended use.
When running sequentially (the default), neither concern bites: only one recipe is worked on at a time, and the driver runs `context_reset --recipe <recipe>` before each subagent (step 3) so the previous recipe is torn down before the next deploys.
## Steps
### 1. Build the candidate list
Determine which recipes to consider:
- If `$ARGUMENTS` contains recipe names (anything other than `--dry-run` / `--parallel` alone), use those.
- Otherwise read `/workspace/maintained-recipes.md` and parse the recipe names (lines starting with `- `).
For each candidate recipe, check what upgrades are available — this is the same logic as `/recipe-overview` step 3 (run in a single batch, not as a subagent):
```bash
git -C ~/.abra/recipes/<recipe> status --short # skip if dirty
abra recipe fetch <recipe> --force # only if clean
abra recipe upgrade <recipe> -m -n # what's available?
```
Build a filtered list `RECIPES_TO_UPGRADE` containing every recipe that:
- Is initialized (`recipe-info/<recipe>/recipe.toml` exists), AND
- Has a clean worktree at `~/.abra/recipes/<recipe>`, AND
- Has at least one available upgrade.
Recipes that fail any of these checks go into a `SKIPPED_UPFRONT` list with a reason (`not-initialized`, `dirty-worktree`, `up-to-date`).
### 2. Print the plan
Print a table summarising the candidate set:
| Recipe | Status | Available upgrades |
|--------|--------|--------------------|
| keycloak | will upgrade | 26.0.5 → 26.1.0 |
| immich | will upgrade | 1.119.0 → 1.122.3 |
| matrix-synapse | skipped (dirty-worktree) | — |
| ... | ... | ... |
Also print the execution mode: `Sequential` (default) or `Parallel (--parallel)`.
If `$ARGUMENTS` contains `--dry-run`, stop here.
### 3. Upgrade each recipe via subagent
**First, clear orphaned locks from a previous run.** At the start of a run no subagent has been spawned yet, so any `in-use/*.lock` present is an orphan left by a subagent that died without cleaning up. A surviving orphan would protect its recipe from every `context_reset` for the whole run (including the per-recipe resets and the final teardown), re-introducing the accumulation problem. Remove them all before spawning anything:
```bash
ls /workspace/in-use/*.lock 2>/dev/null && rm -f /workspace/in-use/*.lock
```
(In parallel mode this is still correct: subagents create their own locks *after* this point.)
The subagent prompt is the same regardless of execution mode. For each recipe in `RECIPES_TO_UPGRADE`, use a prompt like:
> You are upgrading the `<recipe>` Co-op Cloud recipe end-to-end as part of an unattended weekly upgrade run. The workspace is `/workspace`. The active test instance is whatever `python3 scripts/get_test_instance.py` reports.
>
> Run the full `/recipe-upgrade-full <recipe>` skill (file: `.claude/commands/recipe-upgrade-full.md`) — plan, apply, deploy, test, commit, tag, and open a PR on `git.autonomic.zone`. Do NOT prompt for confirmation at any point. Do NOT push to upstream. Do NOT merge anything.
>
> Hold `/workspace/in-use/<recipe>.lock` for the duration so sibling subagents working on other recipes don't context_reset you out.
>
> On exit, print one of these single-line statuses as your last line:
> - `RESULT: SUCCESS — <recipe> upgraded <old> → <new>, PR: <url>`
> - `RESULT: SUCCESS-NO-PR — <recipe> upgraded but PR creation failed: <reason>`
> - `RESULT: FAILED — <recipe> at step <step>: <reason>`
> - `RESULT: SKIPPED — <recipe>: <reason>`
Use `subagent_type: "general-purpose"` and pass a short `description` like `"Upgrade <recipe> end-to-end"`.
**Sequential mode (default):** Spawn one `Agent` at a time. Wait for it to return, collect its result, then spawn the next. Do not move on until the current recipe finishes. **If the `Agent` tool call itself errors or throws (not just returns `RESULT: FAILED`), catch the error, record the recipe as `FAILED — agent tool error: <error message>`, and continue to the next recipe.** Never let a single failure abort the whole run.
**Free the server before each sequential subagent.** Immediately before spawning the subagent for recipe `R`, undeploy the previously-upgraded recipe so apps do not accumulate across the run (this is what caused earlier runs to OOM the test server once many recipes had been processed):
```bash
python3 scripts/context_reset.py --recipe R
```
`--recipe R` protects `R` and its dependencies (plus any `in-use/*.lock` recipes) and undeploys everything else, so the prior recipe is torn down while the one about to be worked on is preserved. Run this for **every** recipe including the first (it also clears any leftover apps from a previous run). If this reset fails, log it and continue to spawn the subagent anyway — the subagent's own deploy may still succeed, and a failure here must not abort the run.
**Parallel mode does not get per-recipe resets** — all subagents deploy concurrently, so there is no safe point to undeploy between them. Parallel runs rely solely on the per-recipe `in-use` locks and accept the memory-pressure tradeoff documented in the "Parallel safety" section above.
**Parallel mode (`--parallel`):** Emit all `Agent` tool calls in a single assistant message so they run concurrently. Do NOT use `run_in_background` — you need the results to build the summary. Individual subagent failures do not affect siblings.
### 4. Collect results
When all subagents return, parse the final `RESULT:` line from each to classify into:
- `SUCCESS` — upgrade PR opened
- `SUCCESS-NO-PR` — upgrade committed locally, PR creation failed (human can retry with `/recipe-create-pr <recipe>`)
- `FAILED` — upgrade did not complete (deploy or test failure, etc.)
- `SKIPPED` — already up to date, dirty worktree, or other pre-flight skip
If a subagent crashed without emitting a `RESULT:` line, or if the `Agent` tool call itself threw an error, treat it as `FAILED — no result emitted`.
### 5. Verify no orphaned locks
```bash
ls /workspace/in-use/*.lock 2>/dev/null
```
Each subagent is responsible for releasing its own lock. By this point every subagent has returned, so **any** remaining lock is an orphan from a subagent that exited badly — regardless of whether its recipe was in this run's candidate set. Log a warning naming each leftover lock and remove them all:
```bash
ls /workspace/in-use/*.lock 2>/dev/null && rm -f /workspace/in-use/*.lock
```
This must happen before the step 8 teardown — a surviving lock would protect its recipe from the final `context_reset` and leave it deployed.
### 6. Write the summary report
Write `/workspace/logs/recipe-upgrade-cron-all-<YYYY-MM-DD>.md` containing:
```markdown
# Weekly Recipe Upgrade Run — <YYYY-MM-DD>
## Summary
- Considered: N recipes
- Upgraded successfully (PR opened): N
- Upgrade succeeded locally but PR failed: N
- Failed: N
- Skipped: N
## PRs to review
- [<recipe>](<gitea-pr-url>) — <old-version><new-version>
- ...
## Failed runs (investigate)
### <recipe>
- Step: <step name>
- Reason: <one-line>
- Log: logs/recipe-upgrade-full-<recipe>-<date>.md
## Skipped
| Recipe | Reason |
|--------|--------|
| ... | ... |
```
### 7. Print the summary
Print the same summary to stdout, leading with the list of PR URLs (this is the primary actionable output of the cron run). End with the report file path.
### 8. Final teardown
After the summary is written and printed, return the test instance to a bare, infrastructure-only state so it isn't left holding the last upgraded recipe between weekly runs:
```bash
python3 scripts/context_reset.py
```
With no `--recipe`, this undeploys every non-infrastructure app on the active test instance (infra recipes like traefik are always protected). Run this only **after** step 5 confirmed no `in-use/*.lock` files remain — a leftover lock would protect that recipe from teardown.
This is best-effort cleanup: if it fails, log the error and the affected domains, but do NOT change the run's overall result or re-print the summary — the upgrade work and PRs from steps 37 stand regardless. Note in the log that a manual `/test-context-reset` may be needed.
## Operator notes
- The cron job that drives this skill is configured separately (e.g. via `/schedule` or a system crontab invoking Claude Code). This skill itself is just the work it does on each run.
- Re-running is safe: skipped/failed recipes are simply retried next week. Successful recipes will report `up-to-date` and skip until a new upstream version appears.
- If you want to see what _would_ be upgraded without actually touching anything, run `/recipe-upgrade-cron-all --dry-run`.
- To upgrade a subset, pass recipe names: `/recipe-upgrade-cron-all keycloak immich`.
- To run all upgrades concurrently (faster but more server load), pass `--parallel`: `/recipe-upgrade-cron-all --parallel`.
- Flags and recipe names can be combined freely: `/recipe-upgrade-cron-all keycloak immich --parallel`.

View File

@ -0,0 +1,69 @@
---
description: Plan and apply a recipe upgrade end-to-end, no human review in the middle
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Upgrade Full
Run the planning phase and the apply phase for a single recipe back-to-back, with no human-in-the-loop review between them. Designed for autonomous/cron use, and as the per-recipe worker spawned by `/recipe-upgrade-cron-all`.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Behaviour differences from running plan + apply manually
- **No mid-skill confirmation.** Do not ask the user to review the plan before applying — proceed straight through.
- **Uncommitted local changes ⇒ abort this recipe.** `recipe-upgrade-plan.md` step 2 normally asks the user to choose commit / stash / discard. In this skill, do NOT prompt: log the dirty state and exit with status `skipped: dirty-worktree`. The next cron run can pick the recipe up after a human cleans it.
- **In-use lock for parallel safety.** Hold an `in-use/<recipe>.lock` file for the duration so any concurrent `context_reset.py` (e.g. from a sibling subagent) protects this recipe.
- **Exit cleanly when up to date.** If `abra recipe upgrade <recipe> -m -n` reports no upgrades, remove the lock and exit with status `skipped: up-to-date`.
## Steps
### 1. Acquire the in-use lock
```bash
mkdir -p /workspace/in-use
touch /workspace/in-use/$ARGUMENTS.lock
```
The lock MUST be released on every exit path below — success, failure, or early skip. The simplest way is to release it as the very last action in step 4, AND in every early-exit branch.
### 2. Run the planning phase
Follow **steps 19 of `.claude/commands/recipe-upgrade-plan.md`** verbatim, with these overrides:
- **Step 2 override (dirty worktree):** if `git -C ~/.abra/recipes/$ARGUMENTS status --short` is non-empty, do NOT prompt. Instead:
- Log the dirty state (modified/staged/untracked files).
- Remove `/workspace/in-use/$ARGUMENTS.lock`.
- Emit final status `SKIPPED — uncommitted local changes in ~/.abra/recipes/$ARGUMENTS` and stop.
- **Step 4 override (no upgrades):** if `abra recipe upgrade $ARGUMENTS -m -n` shows nothing to upgrade:
- Remove `/workspace/in-use/$ARGUMENTS.lock`.
- Emit final status `SKIPPED — already up to date` and stop.
- **Step 10 (the "tell the user and stop" step) is omitted** — fall through to step 3 below instead.
### 3. Run the apply phase
Follow **all steps of `.claude/commands/recipe-upgrade-apply.md`** verbatim. Step 2 of the apply skill will find the plan file that step 9 of the plan phase just wrote.
If any step in the apply phase fails (lint, deploy, tests, commit, PR):
- Log the failure with enough detail to diagnose.
- Skip the remaining apply steps (commit/tag/PR will already be guarded by the existing "only if all tests passed" conditions).
- Continue to step 4 below — do NOT abort the lock release.
### 4. Release the lock and report
```bash
rm -f /workspace/in-use/$ARGUMENTS.lock
```
Print a single-line final status that the calling skill (`/recipe-upgrade-cron-all`) can parse. Use one of these exact prefixes:
- `RESULT: SUCCESS — <recipe> upgraded <old-version> → <new-version>, PR: <gitea-pr-url>`
- `RESULT: SUCCESS-NO-PR — <recipe> upgraded but PR creation failed: <reason>`
- `RESULT: FAILED — <recipe> at step <step-name>: <one-line-reason>`
- `RESULT: SKIPPED — <recipe>: <up-to-date | dirty-worktree | other reason>`
Follow with the normal human-readable summary (image tag changes, test results, etc.) that `recipe-upgrade-apply` step 13 produces.

View File

@ -0,0 +1,110 @@
---
description: Create a detailed upgrade plan for a recipe
argument-hint: <recipe-name>
allowed-tools: [Bash, Read, Write, Glob, Grep, WebFetch, WebSearch]
---
# Recipe Upgrade Plan
Research available upgrades for a Co-op Cloud recipe and create a detailed plan file for review before applying.
The recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
Read and follow the instructions in `.claude/commands/includes/guidelines.md`.
## Steps
1. **Get the domain and server for this recipe**:
```
python3 scripts/get_test_instance.py --recipe $ARGUMENTS
```
This outputs DOMAIN and SERVER for the active instance.
- If the recipe has no `recipe-info/$ARGUMENTS/recipe.toml`, tell the user to run `/recipe-init $ARGUMENTS` first and stop.
2. **Fetch the recipe AND pull latest upstream main** — first check for uncommitted local changes:
```bash
git -C ~/.abra/recipes/$ARGUMENTS status --short
git -C ~/.abra/recipes/$ARGUMENTS diff
git -C ~/.abra/recipes/$ARGUMENTS diff --cached
git -C ~/.abra/recipes/$ARGUMENTS ls-files --others --exclude-standard
```
**If there are uncommitted changes**, do NOT silently skip the fetch. Instead:
- Show the user a concise summary of what's modified, staged, and untracked (with file paths and a few representative lines of diff).
- Ask whether they want to:
- **(a) commit and keep** — stage and commit the changes with a message they approve, then continue with fetch + rebase (`git rebase origin/main` after fetch),
- **(b) stash temporarily** — stash with a descriptive label, then continue with fetch, and remind them to `git stash pop` afterwards,
- **(c) discard** — only after explicit confirmation, run `git checkout -- .` and `git clean -fd`, then continue with fetch.
- Do not invent a default — wait for the user's choice before proceeding.
**If the working tree is clean**, run:
```bash
abra recipe fetch $ARGUMENTS --force
git -C ~/.abra/recipes/$ARGUMENTS fetch origin main
```
- `abra recipe fetch` pulls all tags and branches and checks out the latest tagged version.
- The explicit `git fetch origin main` ensures `origin/main` is up to date even if the latest published tag is older than `main`.
3. **Check what already exists on upstream main** — before planning anything, compare the recipe's currently-checked-out version to `origin/main`:
```bash
# Recent commits on upstream main
git -C ~/.abra/recipes/$ARGUMENTS log origin/main --oneline -10
# Commits on origin/main that are NOT in the current checkout
git -C ~/.abra/recipes/$ARGUMENTS log HEAD..origin/main --oneline
```
- If `origin/main` has commits beyond the current checkout, **inspect them carefully**. Someone may have already started the upgrade (image bumps, version label changes, env var additions). If yes, **re-plan from the tip of `origin/main`**, not from the deployed version — your goal becomes "what's left to add on top of what's already there," not "duplicate everything from scratch."
- Also check upstream PRs at `https://git.coopcloud.tech/coop-cloud/$ARGUMENTS/pulls` for in-flight work that might overlap.
4. **Show current released versions and check for upgrades**:
```
abra recipe versions $ARGUMENTS -m
abra recipe upgrade $ARGUMENTS -m -n
```
- If no upgrades are available, tell the user the recipe is already up to date and stop.
5. **Look up upstream release notes**:
- Check if `recipe-info/$ARGUMENTS/upstream.md` exists in the workspace.
- If it exists, read it to get the release notes URLs for each image/service.
- If it does NOT exist, try to discover the upstream project and release notes URLs:
- Read the recipe's `compose.yml` to identify all images.
- For each image, search for its GitHub repository and releases page.
- Create `recipe-info/$ARGUMENTS/upstream.md` with the discovered URLs (follow the format of existing upstream.md files in sibling recipe directories).
- Also create the `recipe-info/$ARGUMENTS/tests/` directory if it doesn't exist.
6. **For each service with available upgrades**, fetch and summarise the release notes between the current version and the upgrade version(s). Pay special attention to and explicitly call out:
- **Breaking changes** — API removals, renamed/removed config options, changed defaults, dropped support for older runtimes/dependencies
- **Required migration steps** — database migrations, data format changes, manual upgrade procedures
- **Config changes needed by the operator** — new required environment variables, changed variable names/formats, new secrets, changed ports or volume paths, deprecated settings that will stop working
- **Dependency version requirements** — e.g. "now requires PostgreSQL 15+", "minimum Redis 6.2"
- If any of the above are found, present them in a clearly marked "**Operator Action Required**" section per service.
7. **Read the recipe's README** at `~/.abra/recipes/$ARGUMENTS/README.md` (if it exists):
- Check for upgrade-specific instructions, migration steps, or breaking change notes.
8. **Write upgrade info** for future reference:
- Create the `planned-updates/` directory if it doesn't already exist.
- Write all gathered information to `planned-updates/$ARGUMENTS-upgrade-info-<YYYY-MM-DD>.md` (using today's date).
- The file should be structured markdown containing:
- Current version(s) of the recipe
- Available image tag upgrades
- **Operator Action Required** items (breaking changes, config changes, migrations)
- Changelog summaries with links to full release notes
- Suggested next steps
9. **Create the upgrade plan**:
- Create the `plans/` directory if it doesn't already exist.
- Write an upgrade plan to `plans/$ARGUMENTS-upgrade-<YYYY-MM-DD>.md` (using today's date).
- The plan focuses on **updating the recipe itself** (the compose.yml, config files, and recipe version label). It should include:
- **Goal**: one-line summary (e.g. "Upgrade CryptPad recipe from 2025.9.0 to 2026.2.0 and nginx from 1.25 to 1.29")
- **Image tag changes**: a table of service / current tag / new tag
- **Upstream release-notes links**: the release-notes URL for each upgraded image/service (from `recipe-info/$ARGUMENTS/upstream.md`, between the current → new version), recorded verbatim so `/recipe-upgrade-apply` can put them in the **PR body** (`**Upstream release notes:** <service> <old><new>: <url>`), not just the report.
- **Recipe version bump**: the semver reasoning (patch/minor/major) — i.e. which `abra recipe release` flag applies (`-z` patch / `-y` minor / `-x` major). The PR does **not** bump the `coop-cloud.*.version` label; `/recipe-upgrade-apply` records the recommended `abra recipe release <recipe> -<x|y|z>` in the PR body, and the label bump + tag + publish happen at the end via that real command (after the upstream PR merges — see `/recipe-upstream`). So just state the bump kind + reasoning here, not a version string.
- **Recipe changes needed**: any modifications to compose.yml beyond the image tags — new env vars, changed config templates, new volumes, updated labels, added/removed services, etc. based on what the upstream release notes require
- **Risks and caveats**: breaking changes from upstream, known issues from release notes, things that need manual verification
- At the bottom, in a separate **Deployment** section (clearly marked as not part of the recipe update plan itself), briefly note:
- The user can run `/recipe-upgrade-apply $ARGUMENTS` to apply the plan, deploy to the test instance, run tests, and commit/tag
- Any post-deploy steps operators will need when applying this update to production (migration commands, scripts to run, etc.)
10. **Tell the user** the plan file path and suggest they review it, then run `/recipe-upgrade-apply $ARGUMENTS` to execute.

View File

@ -0,0 +1,216 @@
---
description: From a git.autonomic.zone review-PR URL, fetch the branch + tag locally and emit the commands to open the upstream PR on git.coopcloud.tech
argument-hint: <autonomic-pr-url>
allowed-tools: [Bash, Read]
---
# Recipe Upstream
Take a review PR created by `/recipe-create-pr` on `git.autonomic.zone` (e.g.
`https://git.autonomic.zone/recipe-maintainers/lasuite-docs/pulls/3`) and prepare everything needed to
open the corresponding **upstream** PR on `git.coopcloud.tech` — i.e. the "Next steps" block printed at
the end of `/recipe-upgrade-apply`.
This sandbox has **no SSH push access to `git.coopcloud.tech`** (the `dev` remote uses
`ssh://git@git.coopcloud.tech:2222`, which requires the maintainer's own key). So this command does
everything it *can* locally — fetch the PR branch over the authenticated `git.autonomic.zone` remote and
make sure the `origin`/`dev` remotes exist — and then prints the exact `git push` / PR commands plus the
final `abra recipe release` command for you to run from a machine that has coopcloud SSH access. The
release (version-label bump + tag + publish) is the **last** step, run after the upstream PR merges — it
is not done in the PR.
The argument is a git.autonomic.zone pull request URL: $ARGUMENTS
## Prerequisites
Credentials are read from `test-ssh/.testenv` (same as `/recipe-create-pr`):
- `GITEA_USERNAME` — bot account on git.autonomic.zone
- `GITEA_PASSWORD` — bot password
- `GITEA_URL` — Gitea host (e.g. `git.autonomic.zone`)
The recipe must already be checked out at `~/.abra/recipes/<recipe>`. If it isn't, tell the user to run
`abra recipe fetch <recipe>` first and stop.
## Steps
Run the following script with the PR URL substituted in for `PR_URL`:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Prevent ZSH_VERSION unbound variable error when sourcing zsh-aware files under bash
ZSH_VERSION=${ZSH_VERSION:-}
PR_URL="$ARGUMENTS"
WORKSPACE="$(cd "$(dirname "$0")/../.." 2>/dev/null || echo "${HOME}/Documents/recipe-maintainer")"
# Support both /workspace (sandbox) and the actual project directory
for _d in /workspace "${HOME}/Documents/recipe-maintainer"; do
[ -f "${_d}/test-ssh/.testenv" ] && { WORKSPACE="${_d}"; break; }
done
TESTENV="${WORKSPACE}/test-ssh/.testenv"
# --- Load credentials ---
[ -f "${TESTENV}" ] || { echo "ERROR: ${TESTENV} not found (tried /workspace and ~/Documents/recipe-maintainer)"; exit 1; }
set -a; . "${TESTENV}"; set +a
: "${GITEA_USERNAME:?missing in .testenv}"
: "${GITEA_PASSWORD:?missing in .testenv}"
: "${GITEA_URL:?missing in .testenv}"
# --- Parse the PR URL: https://<host>/<owner>/<recipe>/pulls/<num> ---
read -r HOST OWNER RECIPE PR_NUM < <(python3 - "$PR_URL" <<'PY'
import sys, urllib.parse
u = urllib.parse.urlparse(sys.argv[1])
parts = [p for p in u.path.split('/') if p]
# expect: <owner>/<recipe>/pulls/<num>
if len(parts) < 4 or parts[-2] not in ('pulls', 'pull'):
sys.exit("ERROR: not a recognisable Gitea PR URL: %s" % sys.argv[1])
print(u.netloc, parts[0], parts[1], parts[-1])
PY
)
echo "→ Parsed PR: host=${HOST} owner=${OWNER} recipe=${RECIPE} pr=#${PR_NUM}"
if [ "${HOST}" != "${GITEA_URL}" ]; then
echo " ! warning: PR host (${HOST}) differs from GITEA_URL (${GITEA_URL}); using credentials anyway"
fi
RECIPE_DIR="${HOME}/.abra/recipes/${RECIPE}"
[ -d "${RECIPE_DIR}/.git" ] || { echo "ERROR: ${RECIPE_DIR} is not a git repo. Run 'abra recipe fetch ${RECIPE}' first."; exit 1; }
PASS_ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "${GITEA_PASSWORD}")
API="https://${HOST}/api/v1"
AUTH=(-u "${GITEA_USERNAME}:${GITEA_PASSWORD}")
# --- Fetch PR metadata from the autonomic Gitea API ---
echo "→ Fetching PR metadata..."
PR_JSON=$(mktemp)
PR_STATUS=$(curl -s -o "${PR_JSON}" -w "%{http_code}" "${AUTH[@]}" "${API}/repos/${OWNER}/${RECIPE}/pulls/${PR_NUM}")
if [ "${PR_STATUS}" != "200" ]; then
echo "ERROR: could not fetch PR (HTTP ${PR_STATUS}):"; cat "${PR_JSON}"; rm -f "${PR_JSON}"; exit 1
fi
read -r HEAD_REF BASE_REF PR_MERGED RELEASE_FLAG < <(python3 - "${PR_JSON}" <<'PY'
import json, sys, re
d = json.load(open(sys.argv[1]))
body = d.get("body", "") or ""
# The upgrade PR records the recommended release as an `abra recipe release <recipe> -<x|y|z>` line.
m = re.search(r'abra recipe release\s+\S+\s+(-[xyz])', body, re.I)
print(d["head"]["ref"], d["base"]["ref"], str(d.get("merged", False)).lower(), m.group(1) if m else "-")
PY
)
rm -f "${PR_JSON}"
echo " head branch: ${HEAD_REF}"
echo " base branch: ${BASE_REF}"
echo " merged: ${PR_MERGED}"
cd "${RECIPE_DIR}"
# --- Ensure the 'gitea' (autonomic) remote exists with credentials, then fetch the PR branch ---
GITEA_REMOTE_URL="https://${GITEA_USERNAME}:${PASS_ENC}@${HOST}/${OWNER}/${RECIPE}.git"
if git remote get-url gitea >/dev/null 2>&1; then
git remote set-url gitea "${GITEA_REMOTE_URL}"
else
git remote add gitea "${GITEA_REMOTE_URL}"
fi
echo "→ Fetching PR #${PR_NUM} head into local branch '${HEAD_REF}'..."
git fetch gitea "+refs/pull/${PR_NUM}/head:refs/heads/${HEAD_REF}"
# --- Determine the upstream (coop-cloud) namespace ---
# Prefer deriving it from an existing origin/dev remote; otherwise default to coop-cloud.
UPSTREAM_NS="coop-cloud"
for r in origin dev; do
if URL=$(git remote get-url "$r" 2>/dev/null); then
NS=$(echo "$URL" | sed -nE 's#.*git\.coopcloud\.tech[:/0-9]*/([^/]+)/[^/]+(\.git)?$#\1#p')
[ -n "$NS" ] && { UPSTREAM_NS="$NS"; break; }
fi
done
echo "→ Upstream namespace: ${UPSTREAM_NS}"
# --- Ensure origin (https, read) and dev (ssh, push) remotes exist ---
ORIGIN_URL="https://git.coopcloud.tech/${UPSTREAM_NS}/${RECIPE}.git"
DEV_URL="ssh://git@git.coopcloud.tech:2222/${UPSTREAM_NS}/${RECIPE}.git"
if git remote get-url origin >/dev/null 2>&1; then
echo " ✓ origin: $(git remote get-url origin)"
else
git remote add origin "${ORIGIN_URL}"; echo " + created origin -> ${ORIGIN_URL}"
fi
if git remote get-url dev >/dev/null 2>&1; then
echo " ✓ dev: $(git remote get-url dev)"
else
git remote add dev "${DEV_URL}"; echo " + created dev -> ${DEV_URL}"
fi
# --- Determine the release command (run AT THE END, after the upstream PR merges) ---
# The upgrade PR does NOT bump the coop-cloud version label. The release is cut last,
# with a real `abra recipe release` — it bumps the label, commits, tags AND publishes
# (pushes the tag, which generates the catalogue entry) in one step. The PR body records
# the recommended semver bump as an `abra recipe release <recipe> -<x|y|z>` line; we
# surface that as the final recommendation. (No --dry-run: that would only compute and
# change nothing — here we want the operator to actually publish.)
if [ "${RELEASE_FLAG}" != "-" ]; then
RELEASE_CMD="abra recipe release ${RECIPE} ${RELEASE_FLAG}"
echo "→ Recommended release command: ${RELEASE_CMD}"
else
RELEASE_CMD="abra recipe release ${RECIPE} -x|-y|-z # choose: -x major / -y minor / -z patch"
echo "→ Could not parse a release bump from the PR body — operator picks -x/-y/-z."
fi
# --- Emit the next-step commands for a machine WITH coopcloud SSH access ---
COMPARE_URL="https://git.coopcloud.tech/${UPSTREAM_NS}/${RECIPE}/compare/${BASE_REF}...${HEAD_REF}"
cat <<EOF
────────────────────────────────────────────────────────────────────
Prepared locally in ${RECIPE_DIR}:
• branch '${HEAD_REF}' fetched from the autonomic PR (#${PR_NUM})
• remotes: dev -> ${DEV_URL}
Run these from a machine with push access to git.coopcloud.tech
(needs ssh-agent loaded with the coopcloud key for steps 1 and 3):
cd ~/.abra/recipes/${RECIPE}
git checkout ${HEAD_REF}
# 1. Push the branch to the upstream repo:
git push dev HEAD:${HEAD_REF}
# 2. Open the upstream PR (${HEAD_REF} -> ${BASE_REF}):
# ${COMPARE_URL}
# 3. AFTER the upstream PR is merged, publish the release (bumps the version
# label, commits, tags AND pushes the tag upstream — all in one step):
${RELEASE_CMD}
────────────────────────────────────────────────────────────────────
EOF
if [ "${PR_MERGED}" = "true" ]; then
echo " Note: the autonomic review PR #${PR_NUM} is already marked merged."
fi
```
After the script runs, report back to the user:
- The recipe and the PR branch (`HEAD_REF`) that were prepared locally.
- That the branch is staged locally but **not** pushed to coopcloud (no SSH access from here).
- The upstream PR compare URL.
- The three commands (push branch → open PR → `abra recipe release` after merge) verbatim, so the user
can run them from a machine with `git.coopcloud.tech` push access.
## Notes
- **The version bump happens at the END, not in the PR.** The upgrade PR only changes image tags + config;
it deliberately does **not** touch the `coop-cloud.${STACK_NAME}.version` label. The release is cut last,
after the upstream PR merges, with a real `abra recipe release <recipe> -x|-y|-z` (NO `--dry-run`): that
single command bumps the version label, commits, creates the tag, **and** pushes the tag upstream — which
is what publishes the release to the Co-op Cloud catalogue. The recommended `-x`/`-y`/`-z` is read from the
PR body's `abra recipe release …` line (recorded by `/recipe-upgrade-apply` from the plan's semver
reasoning); if it can't be parsed, the operator picks the bump.
- Run order matters: **push the branch and open the upstream PR first; run `abra recipe release` only after
that PR merges.** In Co-op Cloud the catalogue is generated from git tags, and `abra recipe release` pushes
the tag, so it publishes — do it last.
- The `dev` remote is created pointing at `ssh://git@git.coopcloud.tech:2222/<namespace>/<recipe>.git`. The
namespace is derived from any existing `origin`/`dev` remote, defaulting to `coop-cloud`.
- Re-running is safe: the `gitea` remote URL and the PR branch are force-fetched, and the tag is only
created if missing (a mismatched existing tag is reported, never overwritten).
- The `gitea` remote URL embeds the bot password (same trade-off as `/recipe-create-pr`); it's written into
`~/.abra/recipes/<recipe>/.git/config`.

View File

@ -0,0 +1,44 @@
---
description: Guide for setting up a sandboxed environment to run Claude Code with recipe-maintainer
allowed-tools: [Read, Glob, Grep]
---
# Setup Sandbox
Explain to the user how to set up a sandboxed environment for running Claude Code with access to recipe-maintainer.
## What to explain
Claude Code needs shell access to run commands, edit files, and install packages. Running it inside a sandbox (like a Docker container) keeps it isolated from your host system while still giving it the tools it needs.
For recipe-maintainer, the sandbox needs:
- **Its own `~/.abra` directory** — so it can manage Co-op Cloud apps and servers without touching your host abra config
- **Access to the recipe-maintainer checkout** — mounted as a volume
- **CLI tools**: `abra`, `git`, `gh`, `ripgrep`, `fd`, and anything else your recipes need (Rust, Hugo, Terraform, etc.)
- **Network access** — to reach Co-op Cloud servers (via Tailscale or direct SSH)
- **Persistent home directory** — so Claude's auth, settings, and conversation history survive across runs
## Reference implementation
Point the user to `sandbox/` in this repo. Read `sandbox/README.md` and summarise the key points:
- `sandbox/` contains a working Docker-based sandbox with all the tools recipe-maintainer uses
- `claude.py` is the launcher — it mounts the current directory, maps your UID/GID, and hides sensitive files
- `docker-compose.yml` defines named volumes for persistence (`claude_userhome`, `claude_target`, `tailscale_state`)
- `entrypoint.sh` creates a non-root user matching the host UID/GID
- The Dockerfile includes all the tools needed for recipe work (abra, Caddy, Tailscale, Rust, Hugo, Terraform, Playwright, etc.)
Tell the user they can either:
1. **Use `sandbox/` directly**`./build.sh` then set up an alias to `claude.py`
2. **Build their own** — use `sandbox/` as a reference for what tools and configuration are needed
## Key details to mention
- The `.abra` directory is mounted per-workspace via `-v ${workspace}/.container-abra:/home/claude/.abra` so each project can have its own abra config
- Sensitive files (`.env`, `secret.json`, etc.) are automatically hidden from the container via `/dev/null` mounts
- The container runs with `--dangerously-skip-permissions` by default since the sandbox itself is the security boundary
- Auth persists in the `claude_userhome` Docker volume — run `claude login` once and it sticks
Read `sandbox/README.md` for full details and present a clear summary to the user.

View File

@ -0,0 +1,32 @@
---
description: Switch the default test instance (b1cc or t1cc) for all recipe operations
argument-hint: <b1cc|t1cc>
allowed-tools: [Bash, Read, Grep, Glob]
---
# Switch Default Instance
Switch the default test instance used by all recipe skills. This updates `default_instance` in `settings.toml`. Domain is computed dynamically via `get_test_instance.py`, so no global find-and-replace is needed.
The target instance is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
## Steps
1. **Validate the argument**`$ARGUMENTS` must be either `b1cc` or `t1cc`. If it's empty or something else, tell the user the valid options and stop.
2. **Check the current instance** — run:
```bash
python3 scripts/switch_default_instance.py
```
This shows the current default and available instances. If the current default already matches the requested instance, tell the user and stop (no work needed).
3. **Run the switch script**:
```bash
python3 scripts/switch_default_instance.py $ARGUMENTS
```
4. **Verify** — run `python3 scripts/get_test_instance.py` and confirm the SERVER uses the new instance's domain suffix.
5. **Summarise** — tell the user which instance is now active and what the test server domain is (`<instance>.commoninternet.net`).

View File

@ -0,0 +1,71 @@
---
description: Sync Docker secrets from the test server into recipe-info/testsecrets/
argument-hint: [recipe-name]
allowed-tools: [Bash, Read, Grep, Glob]
---
# Sync Secrets
Deploy each app on the current test instance one at a time, read its Docker secrets from the running containers, update the local `recipe-info/testsecrets/` file, then undeploy the app before moving on to the next.
The optional recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
## Background
Each recipe's Docker secrets are mounted at `/run/secrets/` inside containers. The `recipe-info/testsecrets/<domain>` files store these values locally so that setup scripts (via `lib/secrets.py`) can look them up dynamically. When secrets are regenerated on the server or you switch test instances, the local files become stale — this skill re-syncs them by deploying each app temporarily to read its secrets.
## Steps
1. **Check prerequisites**:
- Read `settings.toml` to confirm the active test instance (or run `python3 scripts/get_test_instance.py` to check).
- Verify SSH connectivity to the server: `ssh <server> echo ok`
- Ensure the ssh-agent has the key loaded. If SSH fails, run:
```bash
eval "$(ssh-agent -s)" && ssh-add /workspace/test-ssh/test-ssh-keys/nptest
```
2. **Build the recipe list**:
- If a recipe name was provided in `$ARGUMENTS`, use only that recipe.
- Otherwise, find all recipes that have a `recipe-info/<recipe>/recipe.toml` file.
- Get the domain for each recipe by running: `python3 scripts/get_test_instance.py --recipe <recipe>`
3. **Check which apps are already running** — run:
```bash
abra app ls --server <instance>.commoninternet.net -S -m
```
Parse the output to build a list of apps that are already deployed. These will NOT be undeployed after syncing (we leave them as we found them).
4. **For each recipe, sequentially**:
a. **Deploy** the app (if not already running):
```bash
abra app deploy <domain> --chaos --force --no-input
```
Wait for it to converge. If the deploy fails, log the error, skip this recipe, and continue to the next.
b. **Wait for containers** — give services a moment to start (10-15 seconds), then verify at least one container is running:
```bash
ssh <server> "docker ps -q --filter 'name=<stack>_' | head -1"
```
The stack name is the domain with dots replaced by underscores (e.g. `<recipe>.<DOMAIN_SUFFIX>` → `<recipe>_<DOMAIN_SUFFIX_WITH_UNDERSCORES>`).
c. **Read secrets** — run the sync script for this recipe:
```bash
python3 scripts/sync_secrets.py --recipe <recipe>
```
d. **Undeploy** (only if the app was NOT already running before step 3):
```bash
abra app undeploy <domain> --no-input
```
e. Log the result for this recipe before moving to the next.
5. **Verify** — spot-check one or two updated testsecrets files by reading them and confirming the values look like real secrets (not empty or error messages).
6. **Summarise** — tell the user:
- Which instance was synced (`<instance>.commoninternet.net`).
- How many recipes were synced vs failed.
- Which apps were left running (because they were already deployed before the sync).

View File

@ -0,0 +1,140 @@
---
description: Provision the t1cc DigitalOcean test server and deploy Traefik
allowed-tools: [Bash, Read, Write, Edit, Glob, Grep]
---
# t1cc-start
Provision a fresh DigitalOcean droplet for t1cc (2 vCPU, 8 GB RAM), wait for cloud-init to finish, verify Docker Swarm health, and deploy Traefik. The reserved IP is static — DNS does not need updating.
Read and follow the instructions in `.claude/commands/includes/logging.md`.
## Prerequisites
### 1. Check `.testenv`
Read `terraform/.testenv`. It must contain:
- `DO_TOKEN` — DigitalOcean API token
- `RESERVED_IP` — pre-allocated reserved IP (allocated on first run by `setup.sh`)
If `DO_TOKEN` is missing, stop and tell the user to create `terraform/.testenv` with their DO token:
```
echo 'DO_TOKEN=dop_v1_...' > terraform/.testenv
```
If `RESERVED_IP` is missing, that is fine — `setup.sh` will allocate one automatically and save it.
### 2. Check SSH key
Verify the SSH key exists:
```bash
test -f test-ssh/test-ssh-keys/nptest && echo "key exists" || echo "MISSING"
```
If the key is missing, stop and tell the user to place the private key at `test-ssh/test-ssh-keys/nptest`.
## Steps
### 3. Run terraform
From the workspace root, run:
```bash
./terraform/setup.sh
```
This will:
- Allocate a reserved IP if not already present (and save it to `.testenv`)
- Write `terraform/terraform.tfvars`
- Run `terraform init` (if needed)
- Run `terraform apply -auto-approve`
- Wait for cloud-init to complete (handled by terraform's `remote-exec` provisioner)
This step can take 35 minutes. Stream the output and report progress. If it fails, show the terraform error and stop.
### 4. Verify server health via SSH
SSH into the server and run health checks. Use:
```bash
ssh -F test-ssh/ssh-config -o StrictHostKeyChecking=no t1cc.commoninternet.net "<command>"
```
Run these checks:
**a. Docker is running:**
```bash
ssh -F test-ssh/ssh-config -o StrictHostKeyChecking=no t1cc.commoninternet.net "docker info --format '{{.ServerVersion}}'"
```
**b. Swarm is active:**
```bash
ssh -F test-ssh/ssh-config -o StrictHostKeyChecking=no t1cc.commoninternet.net "docker info --format '{{.Swarm.LocalNodeState}}'"
```
Expected output: `active`
**c. Proxy network exists:**
```bash
ssh -F test-ssh/ssh-config -o StrictHostKeyChecking=no t1cc.commoninternet.net "docker network ls --filter name=proxy --format '{{.Name}}'"
```
Expected output: `proxy`
If any check fails, report the failure and stop. Do not proceed to Traefik deployment on an unhealthy server.
### 5. Deploy Traefik
Check whether the traefik app env file already exists:
```bash
test -f ~/.abra/servers/t1cc.commoninternet.net/t1cc.commoninternet.net.env && echo "exists" || echo "missing"
```
**If the env file is missing** — create the traefik app:
```bash
abra app new traefik --server t1cc.commoninternet.net --domain t1cc.commoninternet.net --no-input
```
Then set the required email:
```bash
abra app config t1cc.commoninternet.net --update LETS_ENCRYPT_EMAIL=certs@commoninternet.net
```
**If the env file exists** — check that `LETS_ENCRYPT_EMAIL` and `DOMAIN` are set correctly. Read the env file:
```bash
cat ~/.abra/servers/t1cc.commoninternet.net/t1cc.commoninternet.net.env | grep -E "LETS_ENCRYPT_EMAIL|^DOMAIN"
```
If `DOMAIN` is set to something other than `t1cc.commoninternet.net`, update it:
```bash
abra app config t1cc.commoninternet.net --update DOMAIN=t1cc.commoninternet.net
```
**Deploy Traefik:**
```bash
abra app deploy t1cc.commoninternet.net --chaos --force --no-input
```
Wait up to 90 seconds for the Traefik service to start, checking every 10 seconds:
```bash
ssh -F test-ssh/ssh-config -o StrictHostKeyChecking=no t1cc.commoninternet.net \
"docker service ls --filter name=traefik --format '{{.Replicas}}'"
```
Wait until the replicas show `1/1`. If not ready after 90 seconds, report a warning but continue.
### 6. Switch default instance to t1cc
```bash
python3 scripts/switch_default_instance.py t1cc
```
Verify:
```bash
python3 scripts/get_test_instance.py
```
### 7. Report
Print a summary:
- Reserved IP (from `terraform/.testenv` or `terraform output -raw reserved_ip`)
- Domain: `t1cc.commoninternet.net`
- Docker Swarm: active
- Traefik: running (1/1 replicas)
- Default instance: t1cc
Remind the user to run `/init-instance` if they want all maintained recipes deployed.

View File

@ -0,0 +1,70 @@
---
description: Destroy the t1cc DigitalOcean test server via terraform
allowed-tools: [Bash, Read, Glob, Grep]
---
# t1cc-stop
Destroy the t1cc DigitalOcean droplet using `terraform destroy`. The reserved IP is preserved — DNS stays pointed at it and the next `/t1cc-start` will reuse the same IP.
Read and follow the instructions in `.claude/commands/includes/logging.md`.
## Steps
### 1. Check terraform state
Verify there is something to destroy:
```bash
cd /workspace/terraform && terraform show -json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); rs=d.get('values',{}).get('root_module',{}).get('resources',[]); print('resources:', len(rs))"
```
If there are 0 resources, tell the user the server is already stopped (or was never started) and exit.
### 2. Check .testenv
Read `terraform/.testenv` and confirm `DO_TOKEN` is set. Terraform needs it to authenticate:
```bash
grep -q "DO_TOKEN=" terraform/.testenv && echo "token present" || echo "MISSING"
```
If missing, stop and tell the user to ensure `terraform/.testenv` contains `DO_TOKEN`.
### 3. Destroy the droplet
Run terraform destroy from the terraform directory:
```bash
cd /workspace/terraform && terraform destroy -auto-approve
```
This will:
- Destroy the droplet
- Remove the firewall
- Remove the reserved IP **assignment** (but keep the reserved IP itself)
Stream the output. This usually takes 12 minutes.
### 4. Verify the droplet is gone
After destroy completes, confirm no resources remain:
```bash
cd /workspace/terraform && terraform show
```
Expected output: `No state.` or empty state.
Also verify via the DO API that the droplet is gone (optional but useful for confidence):
```bash
source terraform/.testenv && curl -s \
-H "Authorization: Bearer $DO_TOKEN" \
"https://api.digitalocean.com/v2/droplets?name=coopcloud-test" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print('droplets:', len(d.get('droplets', [])))"
```
Expected: `droplets: 0`
### 5. Report
Print a summary:
- Droplet destroyed: yes
- Reserved IP preserved: show the IP from `terraform/.testenv` (RESERVED_IP)
- DNS: unchanged — `t1cc.commoninternet.net` still points to the reserved IP
- To restart: run `/t1cc-start`

View File

@ -0,0 +1,53 @@
---
description: Undeploy all apps from the test server except traefik
argument-hint: [recipe-name]
allowed-tools: [Bash, Read, Grep, Glob]
---
# Test Context Reset
Undeploy all apps from the active test server except for infrastructure services and (optionally) a specific recipe and its dependencies. This frees memory so you can test one recipe at a time without interference from other deployed apps.
The optional recipe name is: $ARGUMENTS
Read and follow the instructions in `.claude/commands/includes/logging.md`.
## Steps
1. **Resolve the active test server**:
```
python3 scripts/get_test_instance.py
```
This outputs SERVER and INSTANCE. Use SERVER as `<SERVER>` in the steps below.
2. **Build the protected recipe list.**
Start with the always-protected infrastructure recipes:
- `traefik`
- `backup-bot-two`
If a recipe name was provided in `$ARGUMENTS`:
- Add that recipe to the protected list.
- Read `recipe-info/<recipe>/dependencies.md` (if it exists). Each `##` heading names a recipe that is a test dependency. Add every listed recipe to the protected list.
- Read `recipe-info/<recipe>/test.md` and look for a `## Requires` section. Each line under that heading starting with `- ` names a required recipe (e.g. `- keycloak`). Add every required recipe to the protected list.
- For each required recipe found, also read its `recipe-info/<required>/dependencies.md` and `recipe-info/<required>/test.md` and check for further dependencies (transitive). Add those too. Continue until no new dependencies are found.
3. **List all deployed apps** on the test server:
```
abra app ls -s <SERVER> -S -m
```
Parse the JSON output to get the list of app domains and their recipes.
4. **Match deployed apps to protected recipes** — for each deployed app, check if its recipe name matches any entry in the protected list. If it does, mark it as protected and skip it.
5. **Show the user what will be undeployed** — list the app domains that will be undeployed and confirm the protected apps that will be kept (and why — infrastructure, target recipe, or dependency).
6. **Undeploy each non-protected app** — for every app that is not protected, run:
```
abra app undeploy <domain> --no-input
```
Run these sequentially (not in parallel) to avoid overwhelming the server.
7. **Verify** — run `abra app ls -s <SERVER> -S -m` again and confirm only the protected apps remain deployed.
8. **Summarise** — report what was undeployed and what's still running.

View File

@ -0,0 +1,153 @@
---
description: Verify the test environment is configured correctly
allowed-tools: [Read, Glob, Bash]
---
# Test Setup — Environment Check
Check the project's current setup state and guide the user through anything that's missing.
## Step 1: Check `settings.toml`
Read `settings.toml` if it exists. If it does not exist, tell the user they need to create one. Explain the format:
```toml
default_instance = "<instance-name>"
[instances.<instance-name>]
server = "<hostname>"
domain_suffix = "<hostname>"
protected_recipes = ["traefik", "backup-bot-two"]
```
- `default_instance` — which instance the skills use by default
- `server` — the SSH hostname of the test server
- `domain_suffix` — the domain suffix for app URLs (e.g. `myserver.example.com` means apps get deployed as `<app>.myserver.example.com`)
- `protected_recipes` — apps that `/test-context-reset` will never undeploy
You can define multiple instances and switch between them with `/switch-default-instance`.
## Step 2: Check `test-ssh/`
Check if `test-ssh/` exists and contains an `ssh-config` file and a key directory. If it does not exist, tell the user they need to create it:
```
test-ssh/
├── ssh-config # SSH config with Host entries for each server
└── test-ssh-keys/
└── <keyname> # Private key(s) for the test server(s)
```
The `ssh-config` should have a `Host` entry for each server defined in `settings.toml`. Example:
```
Host myserver.example.com
User root
IdentityFile test-ssh-keys/mykey
Port 22
```
The `IdentityFile` path is relative to `test-ssh/`.
## Step 3: Check `maintained-recipes`
Read `maintained-recipes` if it exists (also accept `maintained-recipes.md` as an alternative name). If neither exists, tell the user they can create one to list the recipes they maintain. It's a plain list of recipe names, one per line (markdown list format also accepted):
```
cryptpad
lasuite-drive
matrix-synapse
```
This file is used by `/recipe-overview` and `/recipe-test-all` to know which recipes to check.
## Step 4: Check SSH connectivity
If `settings.toml` and `test-ssh/` both exist, test SSH connectivity to the default instance's server:
```bash
ssh -F test-ssh/ssh-config -o ConnectTimeout=5 <server> "echo ok" 2>&1
```
Report whether the connection succeeded or failed.
## Step 5: Check abra
Run `abra version` to confirm abra is installed and report the version.
## Step 6: Summary
Print a summary of the project state:
| Component | Status |
|-----------|--------|
| `settings.toml` | Found / Missing |
| `test-ssh/` | Found / Missing |
| `maintained-recipes` | Found (N recipes) / Missing |
| SSH connectivity | OK / Failed / Not tested |
| `abra` CLI | vX.Y.Z / Not found |
If everything is set up, suggest the user start with `/recipe-overview` to see the current state of their recipes.
If anything is missing, show the setup guide below.
## Setting up a new environment from scratch
If the environment is not yet configured, walk the user through these steps:
### 1. Generate SSH keys
Create the `test-ssh/` directory and generate a new keypair:
```bash
mkdir -p test-ssh/test-ssh-keys
ssh-keygen -t ed25519 -f test-ssh/test-ssh-keys/testkey -N "" -C "coopcloud-recipe-toolkit"
```
### 2. Create a server
The user needs a server (VPS, VM, etc.) running a supported Linux distribution. Once the server exists:
- Upload the public key (`test-ssh/test-ssh-keys/testkey.pub`) to the server's `~/.ssh/authorized_keys` for the user they'll SSH in as (typically `root`)
- Note the server's hostname, SSH port, and username
### 3. Create `test-ssh/ssh-config`
Create `test-ssh/ssh-config` with a Host entry matching the server. Use absolute paths for the IdentityFile:
```
Host myserver.example.com
User root
IdentityFile /workspace/test-ssh/test-ssh-keys/testkey
Port 22
```
### 4. Create `settings.toml`
```toml
default_instance = "myinstance"
[instances.myinstance]
server = "myserver.example.com"
domain_suffix = "myserver.example.com"
protected_recipes = ["traefik", "backup-bot-two"]
```
The `domain_suffix` is used to construct app URLs — apps get deployed as `<app>.<domain_suffix>`.
### 5. Create `maintained-recipes`
List the recipes you want to maintain, one per line:
```
recipe-one
recipe-two
```
### 6. Verify and initialize
Re-run `/test-setup` to confirm everything is working. If SSH connects successfully, ask Claude to make sure the server is properly initialized for Co-op Cloud (Docker Swarm enabled, `abra` server added, Traefik deployed).
Then either:
- **`/init-instance`** — Deploy all maintained recipes to the server at once
- **`/recipe-deploy <name>`** — Deploy one recipe at a time

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
__pycache__
target/
bin/
test-ssh/test-ssh-keys
test-ssh/ssh-config
archive/
terraform/.testenv
logs/
recipes/
.container-abra
.idea
references/
settings.toml
recipe-info/testsecrets/
in-use/
.claude/settings.local.json
recipe-info/*/secrets.json
recipe-info/*/*-credentials.*.toml
recipe-info/*/test-account-*.json

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "docs.coopcloud.tech"]
path = docs.coopcloud.tech
url = https://git.coopcloud.tech/toolshed/docs.coopcloud.tech.git
branch = main

View File

@ -0,0 +1,4 @@
---
description: Deploy all maintained recipes to the active test instance from scratch
---
Load the `init-instance` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Explain what this project is and how to get started
---
Load the `intro` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Guide for developing a new Co-op Cloud recipe from scratch
---
Load the `new-recipe-guide` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Ensure every Claude skill has a corresponding OpenCode skill alias
---
Load the `opencode-sync` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Fetch a Co-op Cloud recipe and check for available upgrades
---
Load the `recipe-check` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Push local recipe commits to git.autonomic.zone and open a PR against an upstream-synced main branch
---
Load the `recipe-create-pr` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Deploy the local recipe checkout to the test instance
---
Load the `recipe-deploy` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Create a new test instance and recipe-info for a recipe
---
Load the `recipe-init` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Bump the recipe version and create an annotated git tag
---
Load the `recipe-new-tag` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Check all maintained recipes and recommend what to upgrade
---
Load the `recipe-overview` skill and execute it.

View File

@ -0,0 +1,4 @@
---
description: Review a recipe for Co-op Cloud best practices
---
Load the `recipe-review` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Run tests for all maintained recipes, deploying each one at a time
---
Load the `recipe-test-all` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Test backing up and restoring a recipe's test instance
---
Load the `recipe-test-backup` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Test a recipe's first-time initialization from scratch
---
Load the `recipe-test-new` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Test upgrading a recipe's test instance using abra app deploy
---
Load the `recipe-test-update` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Run all tests for a Co-op Cloud recipe
---
Load the `recipe-test` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Execute a planned recipe upgrade — apply changes, deploy, test, commit/tag
---
Load the `recipe-upgrade-apply` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Create a detailed upgrade plan for a recipe
---
Load the `recipe-upgrade-plan` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: From a git.autonomic.zone review-PR URL, fetch the branch + tag locally and emit the commands to open the upstream PR on git.coopcloud.tech
---
Load the `recipe-upstream` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Guide for setting up a sandboxed environment to run Claude Code with recipe-maintainer
---
Load the `setup-sandbox` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Switch the default test instance (b1cc or t1cc) for all recipe operations
---
Load the `switch-default-instance` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Sync Docker secrets from the test server into recipe-info/testsecrets/
---
Load the `sync-secrets` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Undeploy all apps from the test server except traefik
---
Load the `test-context-reset` skill and execute it for $ARGUMENTS.

View File

@ -0,0 +1,4 @@
---
description: Verify the test environment is configured correctly
---
Load the `test-setup` skill and execute it for $ARGUMENTS.

380
.opencode/package-lock.json generated Normal file
View File

@ -0,0 +1,380 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.14.48"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.14.48",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.48.tgz",
"integrity": "sha512-pb2ywByzn4i35WWJquEYyb8lDC/ph1PLXT+heucJN6Y9U/oeSw98JQV93IG7M6BUBks6MKD3DGDJdQfyD6x0rA==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.14.48",
"effect": "4.0.0-beta.59",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.6"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.14.48",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.48.tgz",
"integrity": "sha512-wKM86jCzV/ZApyWrdm3uP8XdWcS0LMbu3FV+OWz1ChiGGg1wiIWNGMJs5CY8/QX2/rUuZrd1Q1DqvdamZ0zLeg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.59",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz",
"integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.12",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -0,0 +1,5 @@
---
name: init-instance
description: Deploy all maintained recipes to the active test instance from scratch
---
Read and follow the full instructions in `.claude/commands/init-instance.md`.

View File

@ -0,0 +1,5 @@
---
name: intro
description: Explain what this project is and how to get started
---
Read and follow the full instructions in `.claude/commands/intro.md`.

View File

@ -0,0 +1,5 @@
---
name: new-recipe-guide
description: Guide for developing a new Co-op Cloud recipe from scratch
---
Read and follow the full instructions in `.claude/commands/new-recipe-guide.md`.

View File

@ -0,0 +1,5 @@
---
name: opencode-sync
description: Ensure every Claude skill has a corresponding OpenCode skill alias
---
Read and follow the full instructions in `.claude/commands/opencode-sync.md`.

View File

@ -0,0 +1,6 @@
---
name: recipe-check
description: Fetch a Co-op Cloud recipe and check for available upgrades
---
Read and follow the full instructions in `.claude/commands/recipe-check.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-create-pr
description: Push local recipe commits to git.autonomic.zone and open a PR against an upstream-synced main branch
---
Read and follow the full instructions in `.claude/commands/recipe-create-pr.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-deploy
description: Deploy the local recipe checkout to the test instance
---
Read and follow the full instructions in `.claude/commands/recipe-deploy.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,5 @@
---
name: recipe-guidelines
description: Guidelines for all recipe operations including local change preservation, version format, and secrets
---
Read and follow the full instructions in `.claude/commands/includes/guidelines.md`.

View File

@ -0,0 +1,6 @@
---
name: recipe-init
description: Create a new test instance and recipe-info for a recipe
---
Read and follow the full instructions in `.claude/commands/recipe-init.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,5 @@
---
name: recipe-logging
description: Logging instructions for maintaining detailed operation logs in the logs directory
---
Read and follow the full instructions in `.claude/commands/includes/logging.md`.

View File

@ -0,0 +1,6 @@
---
name: recipe-new-tag
description: Bump the recipe version and create an annotated git tag
---
Read and follow the full instructions in `.claude/commands/recipe-new-tag.md`.
The recipe name and optional bump type (--patch, --minor, --major) will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-overview
description: Check all maintained recipes and recommend what to upgrade
---
Read and follow the full instructions in `.claude/commands/recipe-overview.md`.
No arguments required — the recipe list is read from maintained-recipes.md.

View File

@ -0,0 +1,6 @@
---
name: recipe-review
description: Review a recipe for Co-op Cloud best practices
---
Read and follow the full instructions in `.claude/commands/recipe-review.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,5 @@
---
name: recipe-test-all
description: Run tests for all maintained recipes, deploying each one at a time
---
Read and follow the full instructions in `.claude/commands/recipe-test-all.md`.

View File

@ -0,0 +1,6 @@
---
name: recipe-test-backup
description: Test backing up and restoring a recipe's test instance
---
Read and follow the full instructions in `.claude/commands/recipe-test-backup.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-test-new
description: Test a recipe's first-time initialization from scratch
---
Read and follow the full instructions in `.claude/commands/recipe-test-new.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-test-update
description: Test upgrading a recipe's test instance using abra app deploy
---
Read and follow the full instructions in `.claude/commands/recipe-test-update.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-test
description: Run all tests for a Co-op Cloud recipe
---
Read and follow the full instructions in `.claude/commands/recipe-test.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-upgrade-apply
description: Execute a planned recipe upgrade — apply changes, deploy, test, commit/tag
---
Read and follow the full instructions in `.claude/commands/recipe-upgrade-apply.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-upgrade-plan
description: Create a detailed upgrade plan for a recipe
---
Read and follow the full instructions in `.claude/commands/recipe-upgrade-plan.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: recipe-upstream
description: From a git.autonomic.zone review-PR URL, fetch the branch + tag locally and emit the commands to open the upstream PR on git.coopcloud.tech
---
Read and follow the full instructions in `.claude/commands/recipe-upstream.md`.
The git.autonomic.zone pull request URL will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: switch-default-instance
description: Switch the default test instance (b1cc or t1cc) for all recipe operations
---
Read and follow the full instructions in `.claude/commands/switch-default-instance.md`.
The instance name will be provided by the user or calling agent.

View File

@ -0,0 +1,6 @@
---
name: sync-secrets
description: Sync Docker secrets from the test server into recipe-info/testsecrets/
---
Read and follow the full instructions in `.claude/commands/sync-secrets.md`.
The recipe name will be provided by the user or calling agent.

View File

@ -0,0 +1,5 @@
---
name: t1cc-start
description: Provision the t1cc DigitalOcean test server and deploy Traefik
---
Read and follow the full instructions in `.claude/commands/t1cc-start.md`.

View File

@ -0,0 +1,5 @@
---
name: t1cc-stop
description: Destroy the t1cc DigitalOcean test server via terraform
---
Read and follow the full instructions in `.claude/commands/t1cc-stop.md`.

View File

@ -0,0 +1,6 @@
---
name: test-context-reset
description: Undeploy all apps from the test server except traefik
---
Read and follow the full instructions in `.claude/commands/test-context-reset.md`.
The optional recipe name to keep deployed will be provided by the user or calling agent.

View File

@ -0,0 +1,5 @@
---
name: test-setup
description: Verify the test environment is configured correctly
---
Read and follow the full instructions in `.claude/commands/test-setup.md`.

87
AGENTS.md Normal file
View File

@ -0,0 +1,87 @@
## Getting Started
At the start of each session, before doing any work:
1. Read `README.md` to understand this project's purpose, structure, skills, and test environment setup.
2. Read the `docs.coopcloud.tech/` folder to understand how Co-op Cloud and `abra` work, including recipe structure, deployment patterns, configuration conventions, and best practices.
This background context is essential for all recipe-related tasks.
3. **Read `learnings.md`** for abra CLI best practices — it contains critical information about TTY requirements, `--chaos` flag usage, and other operational patterns that will save you from common pitfalls. In particular:
- Many `abra` commands (e.g. `abra app cmd`, `abra app backup create`, `abra app logs`) require a TTY wrapper: `script -qefc "abra ..." /dev/null`
- Always use `--chaos` during local recipe development to prevent abra from changing your working tree
- Prefer `docker service logs` via SSH over `abra app logs` in non-interactive environments
4. **Read these key skill files** so you understand operational patterns used across all tasks:
- `.claude/commands/test-context-reset.md` — how context reset works, including the **in-use lock file** mechanism (`in-use/<recipe>.lock`): these files protect recipes from being undeployed during active work. Always create one when starting work on a recipe, remove it when done.
- `.claude/commands/recipe-deploy.md` — the standard deploy flow (chaos mode, force, no-input)
- `.claude/commands/recipe-test.md` — how recipe tests are run
- `.claude/commands/init-instance.md` — how the test instance is initialised from scratch
## Always pull the latest recipe before answering questions about it
Before answering any question about a specific recipe (config, env vars, compose overlays, secrets, etc.), `git fetch` and check `origin/main` in the recipe's checkout. The local copy can be days or weeks behind, and recipes change — new `compose.*.yml` overlays, new secrets, env-var renames, and entrypoint changes land regularly. Answering from a stale tree produces confidently-wrong advice.
```bash
cd /workspace/.container-abra/recipes/<recipe>
git fetch
git log --oneline HEAD..origin/main # show what you're missing
```
If `origin/main` is ahead, either pull or `git show`/`git diff` the new commits before answering.
## Recipe semver vs. image versions
Co-op Cloud recipe versions are independent of the upstream app/image version, and follow semver
*for the recipe* — not the app. The recipe version label (the `coop-cloud.${STACK_NAME}.version`
tag in `compose.yml`, e.g. `0.3.4+v5.2.0`) has two parts: `<recipe-semver>+<app-version>`.
When you bump an image tag (the `+<app-version>` part), you **must** also bump the recipe semver
(the part before `+`). The size of that bump is determined by what the operator has to do, **not**
by how big the app's own version jump is:
- **Patch** (`0.3.4 → 0.3.5`) — the usual case. The image version increased but **no operator
action is required**: deploying the new version "just works" (no new/renamed env vars, no new
secrets, no manual migration, no breaking config changes).
- **Minor** (`0.3.4 → 0.4.0`) — new optional functionality or new env vars/secrets that have safe
defaults; the operator *may* want to act but isn't forced to.
- **Major** (`0.3.4 → 1.0.0`) — operator action is **required**: breaking changes, mandatory new
secrets/env vars, manual data migrations, or anything that breaks an existing deployment if applied
blindly.
Default to a **patch** bump for routine image-version upgrades. Only go minor/major when the upgrade
genuinely demands operator awareness or action.
## Git commits
- Author every commit as `notplants <@notplants>` (use `--author="notplants <@notplants>"`).
- Do **not** add `Co-Authored-By` trailers to commit messages — no Claude/AI co-author lines.
## Tailscale + SSH (userspace mode)
Some test servers require Tailscale for SSH access. To connect from this containerized environment:
1. **Start tailscaled in userspace mode** (no root, no TUN device needed):
```bash
tailscaled --state=/tmp/tailscale-state --tun=userspace-networking \
--socks5-server=localhost:1055 --socket=/tmp/tailscale-run/tailscaled.sock &>/tmp/tailscaled.log &
```
2. **Authenticate** using an auth key from `test-ssh/.testenv`:
```bash
source test-ssh/.testenv
tailscale --socket=/tmp/tailscale-run/tailscaled.sock up \
--authkey=$TS_AUTH_KEY --hostname=claude-recipe-maintainer
```
3. **SSH via the SOCKS5 proxy** — set `ALL_PROXY` before any SSH command:
```bash
export ALL_PROXY=socks5://localhost:1055
ssh -o StrictHostKeyChecking=no server.example.com "echo connected"
```
4. The SSH config in `test-ssh/ssh-config` should use `HostName` with the Tailscale IP (100.x.x.x) for servers that require Tailscale. Auth keys are stored in `test-ssh/.testenv` (not committed).
## OpenCode
If you are running in OpenCode, read `OPENCODE.md` for details on how skills and commands are configured.

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
Read AGENTS.md for project context and instructions.

58
OPENCODE.md Normal file
View File

@ -0,0 +1,58 @@
## Skills and Commands
This project has skills and commands that work across both Claude Code and OpenCode.
### Architecture
The **single source of truth** for all skill/command instructions is `.claude/commands/`. Both Claude Code and OpenCode use the same underlying instructions with no content duplication.
```
.claude/commands/ <-- Full instructions (single source of truth)
recipe-check.md
recipe-init.md
recipe-deploy.md
...
includes/
guidelines.md <-- Shared guidelines for all recipe operations
logging.md <-- Shared logging instructions
.opencode/skills/<name>/SKILL.md <-- Thin stubs that point to .claude/commands/
.opencode/commands/<name>.md <-- Thin stubs that load the corresponding skill
```
- **Claude Code** uses `.claude/commands/` directly as slash commands (e.g. `/recipe-check hedgedoc`).
- **OpenCode** discovers skills via `.opencode/skills/` and slash commands via `.opencode/commands/`. Each stub reads the full instructions from `.claude/commands/` at runtime.
### Available skills and commands
| Name | Description |
|------|-------------|
| `recipe-check` | Fetch a recipe and check for available upgrades |
| `recipe-init` | Create a new test instance and recipe-info for a recipe |
| `recipe-deploy` | Deploy the local recipe checkout to the test instance |
| `recipe-new-tag` | Bump the recipe version and create an annotated git tag |
| `recipe-overview` | Check all maintained recipes and recommend what to upgrade |
| `recipe-review` | Review a recipe for Co-op Cloud best practices |
| `recipe-test` | Run all tests for a recipe |
| `recipe-test-new` | Test a recipe's first-time initialization from scratch |
| `recipe-test-update` | Test upgrading a recipe's test instance |
| `recipe-backup-test` | Test backing up and restoring a recipe's test instance |
| `recipe-upgrade` | Upgrade a recipe's images, deploy to test, and run tests |
| `test-context-reset` | Undeploy all apps from the test server except infrastructure |
Two additional skills provide shared instructions loaded by the above:
| Name | Description |
|------|-------------|
| `recipe-guidelines` | Guidelines for local change preservation, version format, secrets |
| `recipe-logging` | Logging instructions for maintaining detailed operation logs |
### Adding or editing skills
To add a new skill or edit an existing one:
1. Write the full instructions in `.claude/commands/<name>.md` (this is the source of truth).
2. Create a stub at `.opencode/skills/<name>/SKILL.md` with the required frontmatter (`name`, `description`) and a line pointing to the `.claude/commands/` file.
3. Create a stub at `.opencode/commands/<name>.md` with frontmatter (`description`) and a line that loads the skill for `$ARGUMENTS`.
The OpenCode stubs should never contain substantive instructions — only a pointer to the `.claude/commands/` file.

146
README.md Normal file
View File

@ -0,0 +1,146 @@
# Co-op Cloud Recipe Toolkit
Claude skills and tooling for working with [Co-op Cloud](https://coopcloud.tech) recipes — creating new recipes, updating existing ones, and testing deployments.
## Quickstart
```
git clone ssh://git@git.autonomic.zone:2222/notplants/recipe-maintainer.git
cd recipe-maintainer
claude
```
Then inside Claude, run:
```
/intro
```
This will walk you through the project and how to get started.
## Skills
These Claude Code slash commands automate common recipe maintenance tasks.
### Recipe lifecycle
| Skill | Description |
|-------|-------------|
| `/recipe-overview` | Check all recipes in `maintained-recipes.md` for available upgrades and recommend what to focus on |
| `/recipe-init <name>` | Bootstrap a new recipe — fetch, create test instance, set up `recipe-info/`, deploy |
| `/recipe-check <name>` | Quick status report — check if a recipe has upstream image upgrades available |
| `/recipe-upgrade-plan <name>` | Research release notes and write a detailed upgrade plan to `plans/` |
| `/recipe-upgrade-apply <name>` | Execute a planned upgrade — apply changes, deploy, test, commit and tag |
| `/recipe-new-tag <name>` | Bump the recipe version and create an annotated git tag |
| `/recipe-review <name>` | Audit a recipe against Co-op Cloud best practices |
| `/new-recipe-guide` | Step-by-step guide for developing a new recipe from scratch |
### Deployment and testing
| Skill | Description |
|-------|-------------|
| `/recipe-deploy <name>` | Deploy the local recipe checkout to the test instance with chaos mode |
| `/recipe-test <name>` | Run all Python test scripts from `recipe-info/<recipe>/tests/` |
| `/recipe-test-new <name>` | Test first-time initialization of a recipe from scratch |
| `/recipe-test-update <name>` | Test upgrading a recipe's test instance |
| `/recipe-test-backup <name>` | Test backing up and restoring a recipe's test instance |
| `/recipe-test-all` | Run tests for all maintained recipes, deploying each one at a time |
### Infrastructure and environment
| Skill | Description |
|-------|-------------|
| `/init-instance` | Deploy all maintained recipes to the active test instance from scratch |
| `/switch-default-instance` | Switch the default test instance (b1cc or t1cc) for all operations |
| `/test-setup` | Verify the test environment is configured correctly |
| `/test-context-reset` | Undeploy all apps from the test server except traefik |
| `/sync-secrets` | Sync Docker secrets from the test server into `recipe-info/testsecrets/` |
| `/t1cc-start` | Provision the t1cc DigitalOcean droplet (2 vCPU, 8 GB) and deploy Traefik |
| `/t1cc-stop` | Destroy the t1cc droplet (reserved IP is preserved, DNS unchanged) |
## Recommended workflow
### Daily check-in
```
/recipe-overview
```
Start here. This checks all your maintained recipes at once, shows what's up to date and what has upgrades available, and tells you which recipe to focus on.
### Setting up a new recipe
```
/recipe-init <recipe-name>
```
This creates the test instance, all the `recipe-info/` files, and deploys. After it finishes, verify with `/recipe-test` and add any recipe-specific test scripts to `recipe-info/<recipe>/tests/`.
### Checking for and applying upgrades
1. **Check** what's available:
```
/recipe-check <recipe-name>
```
Quick status report — see what upgrades exist and any breaking changes.
2. **Plan** the upgrade:
```
/recipe-upgrade-plan <recipe-name>
```
Creates a detailed plan file in `plans/`. Review it before proceeding.
3. **Apply** the plan:
```
/recipe-upgrade-apply <recipe-name>
```
Executes the plan — applies changes, deploys, tests, and commits + tags locally if everything passes.
4. **Push** when satisfied:
```
cd ~/.abra/recipes/<recipe-name> && git push && git push --tags
```
### Day-to-day development
When making local changes to a recipe (editing compose.yml, tweaking configs):
1. **Deploy** your changes:
```
/recipe-deploy <recipe-name>
```
2. **Test** the deployment:
```
/recipe-test <recipe-name>
```
## Test Environment Setup
This project is designed to run inside a container that bind-mounts `~/.abra`, creating an isolated environment where `abra` operates normally without access to the host's SSH keys or abra configuration.
### SSH keys and config
The `test-ssh/` folder must contain SSH keys and an SSH config that point to a server (or VM) running Co-op Cloud that is ready to be deployed to. The skills use this SSH config when connecting to the test server to deploy and manage recipe instances.
### Container isolation
Run this project in a container with `~/.abra` bind-mounted from the host or a dedicated volume. This ensures:
- `abra` finds its recipes, servers, and app state in the expected location (`~/.abra/`)
- The host's own SSH keys and abra configs are never exposed to the container
- The test SSH keys in `test-ssh/` are the only credentials available, limiting the blast radius of any deployment operations to the designated test server
## Structure
- **`recipe-info/`** — Per-recipe metadata, setup docs, and Python test scripts (organized by recipe name)
- **`lib/`** — Python library for interacting with `abra`, managing config, secrets, SSH, and recipe operations
- **`scripts/`** — Standalone Python scripts (test runner, context reset, secret sync, tag creation)
- **`utils/`** — SSO integration helpers and test utilities
- **`planned-updates/`** — Upgrade reports and summaries generated by `/recipe-upgrade-plan` and `/recipe-upgrade-apply`
- **`plans/`** — Detailed upgrade and project planning documents
- **`terraform/`** — Terraform config for provisioning a DigitalOcean test droplet
- **`test-ssh/`** — SSH config and keys for the test server
- **`docs.coopcloud.tech/`** — Local copy of the [Co-op Cloud documentation](https://docs.coopcloud.tech); reference for recipes, deployment patterns, and platform conventions
- **`.claude/commands/`** — Skill definitions for the slash commands above
- **`.opencode/`** — Thin wrapper mapping Claude skills to [OpenCode](https://opencode.ai) commands

View File

@ -0,0 +1 @@
{"m.homeserver": {"base_url": "https://matrix.cooperative.computer"}}

View File

@ -0,0 +1 @@
{"m.server": "matrix.cooperative.computer:443"}

1
docs.coopcloud.tech Submodule

Submodule docs.coopcloud.tech added at 22eb68de8f

189
learnings.md Normal file
View File

@ -0,0 +1,189 @@
# Learnings
Things discovered while working with `abra` and Co-op Cloud recipes in this environment.
## abra TTY requirements
Several `abra` subcommands require a TTY and fail with "the input device is not a TTY" or "inappropriate ioctl for device" when run non-interactively. Workaround:
```bash
script -qefc "abra app backup create <domain> --chaos --no-input" /dev/null
```
Commands that need the TTY wrapper:
- `abra app backup create`
- `abra app backup snapshots`
- `abra app restore`
- `abra app volume remove`
- `abra app cmd`
- `abra app logs`
- `abra recipe lint`
Commands that work fine without a TTY:
- `abra app deploy`
- `abra app undeploy`
- `abra app restart`
- `abra app new`
- `abra recipe fetch`
- `abra recipe versions`
- `abra recipe upgrade` (the check mode with `-m -n`)
## abra app ps
`abra app ps` fails with "inappropriate ioctl for device" when run without a TTY in its default table-output mode. Use `-m` (machine-readable JSON output) instead:
```bash
abra app ps <domain> --chaos --no-input -m
```
## --chaos flag availability
Not all `abra` commands support `--chaos`. When doing local recipe development, use `--chaos` on every command that supports it to prevent abra from fetching remote and changing the working tree. Commands that support it:
- `abra app deploy --chaos`
- `abra app ps --chaos`
- `abra app backup create --chaos`
- `abra app restore --chaos`
- `abra app restart --chaos`
- `abra app new --chaos`
- `abra app cmd --chaos`
- `abra app secret insert --chaos` (and other secret commands)
Commands that do NOT support `--chaos` (and don't need it — they don't read recipe config):
- `abra app undeploy`
- `abra app run`
- `abra app volume remove`
- `abra app backup snapshots`
- `abra app logs`
**What happens without `--chaos`:** abra fetches the remote and checks out the latest published tag. If the working tree is dirty (unstaged/staged changes), abra refuses with `FATA ... has locally unstaged changes?` — so unstaged work is safe. However, if the tree is clean and you have local commits on a detached HEAD (which is how abra manages recipe checkouts), abra silently moves HEAD to the latest tag. Your commits are NOT lost — they remain in `git reflog` — but it looks like your work vanished. Recovery: `git -C ~/.abra/recipes/<recipe> reflog` to find the commit, then `git checkout <hash>`.
**Best practice:** Always use `--chaos` during local recipe development to avoid abra changing the working tree on you.
**IMPORTANT:** When abra refuses with "locally unstaged changes", do NOT commit the changes to fix it — use `--chaos` instead. Committing moves HEAD to a new hash, and the next non-chaos abra command will move HEAD to the latest tag, discarding your local branch position. Commits should only be made intentionally when you're ready to tag/release, not as a workaround for abra's dirty-tree check.
## backup-bot-two is required for backups
`abra app backup create` does not perform backups itself — it delegates to a running `backup-bot-two` instance on the server. If backup-bot-two is not deployed, the command fails with "no backupbot discovered, is it deployed?".
Before running any backup commands, ensure backup-bot-two is deployed on the target server. It needs:
- A restic password secret
- Local storage is fine for testing (`RESTIC_REPOSITORY=/backups/restic`)
- Default cron schedule is `30 3 * * *` (3:30 AM daily)
## abra app restart requires --all-services or a service name
`abra app restart <domain>` alone doesn't restart anything — you must pass either `--all-services` / `-a` to restart all services, or specify a service name like `app` or `web`.
**Caveat:** `abra app restart --all-services` tends to hang on stacks with many services. Prefer `abra app deploy --force` to cycle services instead.
## Recording and using deploy timing expectations
After a successful deploy or other long-running operation, record how long it took to converge in the recipe's `test.md` (e.g. "deploys converge in ~45s"). In future runs, use this as a baseline — if something takes much longer than expected, it's likely hanging and should be investigated rather than waited on indefinitely. This prevents getting stuck on operations that will never complete.
## abra server setup
The server directory at `~/.abra/servers/` may exist but be empty. Running `abra server add <domain>` adds the server entry. The SSH config must be in `~/.ssh/config` with the correct user, port, and identity file. The SSH key must be loaded into an ssh-agent for abra to use it.
Steps:
1. Copy SSH config: ensure `~/.ssh/config` has the server entry
2. Set key permissions: `chmod 600 <keyfile>`
3. Start agent and add key: `eval "$(ssh-agent -s)" && ssh-add <keyfile>`
4. Add server: `abra server add <domain> --no-input`
## abra app new
`abra app new` takes `--server` and `--domain` flags. The positional argument is the recipe name. User/port for SSH are read from `~/.ssh/config`, not passed to `app new` (unlike older versions where `server add` accepted user and port positionally).
## Recipe release notes
In Co-op Cloud, it's OK that recipe releases don't have release notes in the `release/` directory if there are no breaking changes. Release notes are primarily useful for documenting breaking changes, migration steps, or significant behavioural differences that operators need to know about.
## abra app run (executing commands in containers)
`abra app run` executes arbitrary commands inside a running container. This is different from `abra app cmd`, which only runs commands defined in `abra.sh`.
```bash
abra app run <domain> <service> -- <command> [args]
```
Example:
```bash
abra app run bluesky-pds.b1cc.commoninternet.net app -- goat pds admin account create --handle user.example.com
```
Notes:
- Does NOT support `--chaos` (doesn't need it — runs directly in the container)
- Use `--no-tty` / `-t` when running non-interactively (e.g. capturing output)
- Use `--user` / `-u` to run as a specific user
- Environment variables from the container (including secrets mounted at runtime) are available to the command
- Requires the TTY wrapper (`script -qefc "..." /dev/null`) in non-interactive environments
## Docker ghost containers blocking volume removal
After undeploying a Docker Swarm stack, `docker volume rm` sometimes fails with "volume is in use" referencing container IDs that show as `dead` in `docker ps -a` but can't be removed with `docker rm -f` (returns "No such container"). This is a Docker bug — the container metadata is stale. Even `docker system prune` and `systemctl restart docker` don't always fix it.
Fix: manually remove the dead container directories and restart Docker:
```bash
ssh <server> "docker ps -a --filter volume=<volume_name> --format '{{.ID}} {{.State}}'" # find dead containers
ssh <server> "sudo rm -rf /var/lib/docker/containers/<full_container_id>" # remove each dead one
ssh <server> "sudo systemctl restart docker" # restart to clear references
```
## Prefer docker service logs over abra app logs
`abra app logs` tends to hang in non-interactive environments even with the TTY wrapper. Prefer using `docker service logs` directly via SSH instead:
```bash
ssh <server> "docker service logs <stack_name>_<service> --since 5m 2>&1"
```
The stack name follows the pattern `<domain_with_underscores>` (e.g. `lasuite-docs_b1cc_commoninternet_net`). The service name is appended (e.g. `_minio`, `_backend`). `docker service logs` also does NOT support `--chaos` (not an abra command), so it won't interfere with local recipe checkouts.
## Golang template_driver: `{{ secret "name" }}` works
Docker configs with `template_driver: golang` support the `{{ secret "name" }}` function to inject Docker secrets directly into config files. This works for non-shell config formats like YAML where you can't source secrets from `/run/secrets/`. The secret must be assigned to the service in compose.yml. Confirmed working in the lasuite-meet recipe's `livekit-server.yaml.tmpl`.
Available template functions: `{{ env "VAR" }}` for environment variables, `{{ secret "name" }}` for Docker secrets.
## Secrets in recipes: use abra-entrypoint.sh, not source/target mapping
Docker Swarm `source`/`target` secret mapping in compose.yml does not reliably work for renaming secrets at the mount point. Instead, use the **abra-entrypoint pattern**:
1. Define secrets with their short names (must pass abra lint's 12-char limit):
```yaml
secrets:
- su_password
```
This mounts at `/run/secrets/su_password`.
2. Create an `abra-entrypoint.sh` that reads the secret and exports it as the environment variable the application expects:
```sh
#!/bin/sh
set -e
[ -f /run/secrets/su_password ] && export MUMBLE_SUPERUSER_PASSWORD="$(cat /run/secrets/su_password)"
exec /entrypoint.sh "$@"
```
3. Mount it as a Docker config and override the entrypoint:
```yaml
entrypoint: ["/abra-entrypoint.sh"]
command: ["/usr/bin/mumble-server"] # the original CMD
configs:
- source: abra_entrypoint
target: /abra-entrypoint.sh
mode: 0555
```
4. The `abra-entrypoint.sh` must chain into the **original image entrypoint** (check the upstream Dockerfile's `ENTRYPOINT`) via `exec /entrypoint.sh "$@"`.
5. **Config versions** (e.g. `ABRA_ENTRYPOINT_VERSION`) go in `abra.sh` as `export` statements. **Secret versions** (e.g. `SECRET_SU_PASSWORD_VERSION`) go in `.env.sample`.
See `lasuite-meet/abra-entrypoint.sh` for the canonical example.
## WebFetch and JavaScript-heavy apps
WebFetch does not execute JavaScript. Apps like CryptPad that are fully client-side encrypted will return a "JavaScript must be enabled" page. This is expected and healthy — a `curl` HTTP 200 check is sufficient to verify the app is running. Don't treat the JavaScript-required message as a failure.
## Never skip OIDC integration tests
When testing a recipe — whether it's a migration test, upgrade test, or any other test context — always run the full OIDC integration tests if they exist. The OIDC integration tests are part of the recipe's test suite and must be set up and executed, not skipped. Set up the OIDC provider/integration before starting the test sequence so it can be verified at every stage.

4
lib/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""recipe-maintainer-2 core library.
Stdlib-only Python modules for Co-op Cloud recipe maintenance.
"""

291
lib/abra.py Normal file
View File

@ -0,0 +1,291 @@
"""Wrapper for the abra CLI.
Encapsulates TTY wrapping, --chaos flag, --no-input, timeout handling,
and the Linux `timeout` command guard.
All abra commands go through this module so that learnings (TTY requirements,
--chaos caveats, timeout behaviors) are encoded in one place.
"""
import json
import subprocess
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------
@dataclass
class AbraResult:
"""Result of a shell/abra command."""
returncode: int
stdout: str
stderr: str
timed_out: bool
command: str
@property
def ok(self) -> bool:
return self.returncode == 0 and not self.timed_out
def json(self) -> list | dict | None:
"""Try to parse stdout as JSON."""
try:
return json.loads(self.stdout)
except (json.JSONDecodeError, ValueError):
return None
def jsonl(self) -> list[list | dict]:
"""Parse stdout as JSON Lines (one JSON document per line)."""
results = []
for line in self.stdout.strip().split("\n"):
if line.strip():
try:
results.append(json.loads(line))
except (json.JSONDecodeError, ValueError):
pass
return results
# ---------------------------------------------------------------------------
# TTY requirements from learnings.md
# ---------------------------------------------------------------------------
# These abra subcommands require a TTY wrapper (script -qefc).
# Without it they fail with "not a TTY" or hang indefinitely.
_TTY_COMMANDS = {
"secret insert", "secret remove", "secret generate",
"volume remove",
"cmd",
"backup create", "backup snapshots", "backup list",
"restore",
"recipe lint",
}
def _needs_tty(args: str) -> bool:
"""Check if an abra command needs the TTY wrapper."""
# Normalize: "app secret insert ..." → check "secret insert"
parts = args.strip()
# Strip leading "app " if present
if parts.startswith("app "):
parts = parts[4:]
for cmd in _TTY_COMMANDS:
if parts.startswith(cmd):
return True
return False
# ---------------------------------------------------------------------------
# Core execution
# ---------------------------------------------------------------------------
def run(cmd: str, *, check: bool = True, timeout: int = 120) -> AbraResult:
"""Run a shell command with a hard timeout.
Uses the Linux `timeout` command to guarantee the process tree is
killed after `timeout` seconds, even if the process ignores signals.
subprocess.run gets a slightly longer timeout as a fallback.
"""
wrapped = f"timeout --kill-after=5 {timeout} {cmd}"
print(f" $ {cmd}", flush=True)
try:
result = subprocess.run(
wrapped, shell=True, capture_output=True, text=True,
timeout=timeout + 15,
)
except subprocess.TimeoutExpired:
print(f" TIMEOUT after {timeout}s (subprocess fallback)", flush=True)
if check:
raise RuntimeError(f"Command timed out after {timeout}s: {cmd}")
return AbraResult(
returncode=124, stdout="", stderr="",
timed_out=True, command=cmd,
)
timed_out = result.returncode == 124
if result.stdout.strip():
for line in result.stdout.strip().split("\n"):
print(f" {line}", flush=True)
if result.returncode != 0:
if result.stderr.strip():
for line in result.stderr.strip().split("\n"):
print(f" stderr: {line}", flush=True)
if timed_out:
print(f" TIMEOUT after {timeout}s", flush=True)
if check:
raise RuntimeError(f"Command timed out after {timeout}s: {cmd}")
elif check:
raise RuntimeError(
f"Command failed (exit {result.returncode}): {cmd}"
)
return AbraResult(
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
timed_out=timed_out,
command=cmd,
)
def abra(args: str, *, tty_wrap: bool | None = None,
check: bool = True, timeout: int = 120,
chaos: bool = True) -> AbraResult:
"""Run an abra command.
- tty_wrap: wrap with script -qefc for commands that need TTY.
If None, auto-detects from the command.
- chaos: append --chaos (default True for local dev)
- Always appends --no-input
"""
flags = "--no-input"
if chaos and "--chaos" not in args:
flags += " --chaos"
cmd = f"abra {args} {flags}".strip()
if tty_wrap is None:
tty_wrap = _needs_tty(args)
if tty_wrap:
cmd = f'script -qefc "{cmd}" /dev/null 2>&1'
return run(cmd, check=check, timeout=timeout)
# ---------------------------------------------------------------------------
# High-level abra operations
# ---------------------------------------------------------------------------
def app_deploy(domain: str, *, force: bool = True,
chaos: bool = True, timeout: int = 60) -> AbraResult:
"""Deploy an app."""
flags = f"app deploy {domain}"
if force:
flags += " --force"
return abra(flags, chaos=chaos, check=False, timeout=timeout)
def app_undeploy(domain: str, *, timeout: int = 60) -> AbraResult:
"""Undeploy an app."""
return abra(f"app undeploy {domain}", chaos=False, check=False, timeout=timeout)
def app_new(recipe: str, server: str, domain: str, *,
chaos: bool = True, timeout: int = 60) -> AbraResult:
"""Create a new app instance."""
return abra(
f"app new {recipe} --server {server} --domain {domain}",
chaos=chaos, timeout=timeout,
)
def app_ps(domain: str, *, chaos: bool = True) -> AbraResult:
"""Get app process status (machine-readable)."""
return abra(f"app ps {domain} -m", chaos=chaos, check=False, timeout=30)
def app_secret_generate(domain: str, *,
chaos: bool = True, timeout: int = 60) -> AbraResult:
"""Generate all secrets for an app."""
return abra(
f"app secret generate {domain} --all",
chaos=chaos, check=False, timeout=timeout,
)
def app_secret_insert(domain: str, name: str, version: str,
value: str, *, chaos: bool = True,
timeout: int = 60) -> AbraResult:
"""Insert a specific secret."""
return abra(
f"app secret insert {domain} {name} {version} {value}",
chaos=chaos, check=False, timeout=timeout,
)
def app_secret_remove_all(domain: str, *,
chaos: bool = True,
timeout: int = 60) -> AbraResult:
"""Remove all secrets for an app."""
return abra(
f"app secret remove {domain} --all",
chaos=chaos, check=False, timeout=timeout,
)
def app_volume_remove(domain: str, *, timeout: int = 60) -> AbraResult:
"""Remove all volumes for an app."""
return abra(
f"app volume remove {domain} --force",
chaos=False, check=False, timeout=timeout,
)
def app_cmd(domain: str, service: str, cmd_name: str, *,
chaos: bool = True, timeout: int = 120) -> AbraResult:
"""Run an abra app cmd."""
return abra(
f"app cmd {domain} {service} {cmd_name}",
chaos=chaos, check=False, timeout=timeout,
)
def app_ls(server: str, *, timeout: int = 30) -> AbraResult:
"""List all apps on a server (machine-readable)."""
return abra(f"app ls -s {server} -S -m", chaos=False, check=False,
timeout=timeout)
def app_backup_create(domain: str, *,
chaos: bool = True, timeout: int = 120) -> AbraResult:
"""Create a backup of an app."""
return abra(
f"app backup create {domain}",
chaos=chaos, check=False, timeout=timeout,
)
def app_backup_restore(domain: str, *,
chaos: bool = True, timeout: int = 120) -> AbraResult:
"""Restore an app from backup."""
return abra(
f"app restore {domain}",
chaos=chaos, check=False, timeout=timeout,
)
def recipe_fetch(recipe: str, *, force: bool = True,
timeout: int = 60) -> AbraResult:
"""Fetch a recipe from upstream."""
flags = f"recipe fetch {recipe}"
if force:
flags += " --force"
return abra(flags, chaos=False, timeout=timeout)
def recipe_versions(recipe: str) -> AbraResult:
"""List recipe versions (machine-readable)."""
return abra(f"recipe versions {recipe} -m", chaos=False, check=False,
timeout=30)
def recipe_upgrade(recipe: str, *, dry_run: bool = True) -> AbraResult:
"""Check for recipe upgrades (machine-readable)."""
flags = f"recipe upgrade {recipe} -m"
if dry_run:
flags += " -n"
return abra(flags, chaos=False, check=False, timeout=30)
def recipe_lint(recipe: str) -> AbraResult:
"""Lint a recipe."""
return abra(f"recipe lint {recipe} -C", chaos=False, check=False,
timeout=30)
def recipe_diff(recipe: str) -> AbraResult:
"""Show local changes in a recipe checkout."""
return abra(f"recipe diff {recipe}", chaos=False, check=False, timeout=15)

199
lib/authentik.py Normal file
View File

@ -0,0 +1,199 @@
"""Authentik admin API client.
Provides an AuthentikAdmin class for managing OAuth2 providers, applications,
and users via the Authentik REST API. Used by setup_authentik_integration.py
and setup_docs_integration.py scripts.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
class AuthentikAdmin:
"""Client for the Authentik Admin REST API."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/api/v3"
self.token = token
def _request(self, method: str, endpoint: str, *,
data: dict | None = None,
timeout: int = 30) -> tuple[int, dict | list | None]:
"""Make an authenticated API request."""
url = f"{self.api_url}{endpoint}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"Bearer {self.token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return resp.getcode(), None
return resp.getcode(), json.loads(raw)
except urllib.error.HTTPError as e:
try:
raw = e.read().decode(errors="replace")
return e.code, json.loads(raw) if raw.strip() else None
except Exception:
return e.code, None
def _get_first(self, endpoint: str, match_field: str,
match_value: str) -> dict | None:
"""GET a list endpoint and return the first result matching a field."""
_, data = self._request("GET", endpoint)
if not data or "results" not in data:
return None
for r in data["results"]:
if r.get(match_field) == match_value:
return r
return None
def resolve_uuids(self) -> dict:
"""Look up Authentik flow/mapping/certificate UUIDs dynamically."""
print("=== Resolving Authentik UUIDs ===", flush=True)
uuids = {}
lookups = [
("authorization_flow",
"/flows/instances/?slug=default-provider-authorization-implicit-consent"),
("invalidation_flow",
"/flows/instances/?slug=default-provider-invalidation-flow"),
("authentication_flow",
"/flows/instances/?slug=default-authentication-flow"),
("signing_key",
"/crypto/certificatekeypairs/?name=authentik+Self-signed+Certificate"),
("scope_openid",
"/propertymappings/all/?managed=goauthentik.io/providers/oauth2/scope-openid"),
("scope_email",
"/propertymappings/all/?managed=goauthentik.io/providers/oauth2/scope-email"),
("scope_profile",
"/propertymappings/all/?managed=goauthentik.io/providers/oauth2/scope-profile"),
]
for name, endpoint in lookups:
_, data = self._request("GET", endpoint)
pk = data["results"][0]["pk"]
uuids[name] = pk
print(f" {name}: {pk}", flush=True)
return uuids
def ensure_provider(self, name: str, client_id: str,
redirect_uris: list[dict],
uuids: dict) -> tuple[int, str]:
"""Create an OAuth2 provider if needed. Returns (provider_pk, client_secret)."""
print(f"=== Ensure OAuth2 provider '{name}' ===", flush=True)
existing = self._get_first(
f"/providers/oauth2/?search={name}", "name", name)
if existing:
pk = existing["pk"]
print(f" Provider '{name}' already exists (pk: {pk}), fetching secret",
flush=True)
_, provider_data = self._request("GET", f"/providers/oauth2/{pk}/")
client_secret = provider_data["client_secret"]
else:
_, resp = self._request("POST", "/providers/oauth2/", data={
"name": name,
"client_type": "confidential",
"client_id": client_id,
"authorization_flow": uuids["authorization_flow"],
"authentication_flow": uuids["authentication_flow"],
"invalidation_flow": uuids["invalidation_flow"],
"redirect_uris": redirect_uris,
"property_mappings": [
uuids["scope_openid"],
uuids["scope_email"],
uuids["scope_profile"],
],
"signing_key": uuids["signing_key"],
"include_claims_in_id_token": True,
})
pk = resp["pk"]
client_secret = resp["client_secret"]
print(f" Created provider (pk: {pk})", flush=True)
print(f" Client ID: {client_id}", flush=True)
print(f" Client secret: {client_secret}", flush=True)
return pk, client_secret
def ensure_application(self, name: str, slug: str,
provider_pk: int, launch_url: str) -> None:
"""Create an application if it doesn't exist."""
print(f"=== Ensure application '{slug}' ===", flush=True)
existing = self._get_first(
f"/core/applications/?slug={slug}", "slug", slug)
if existing:
print(f" Application '{slug}' already exists, skipping", flush=True)
else:
self._request("POST", "/core/applications/", data={
"name": name,
"slug": slug,
"provider": provider_pk,
"meta_launch_url": launch_url,
})
print(f" Created application '{slug}'", flush=True)
def ensure_user(self, username: str, email: str,
password: str) -> int:
"""Create a user if needed, set password. Returns user pk."""
print(f"=== Ensure user '{username}' ===", flush=True)
existing = self._get_first(
f"/core/users/?search={username}", "username", username)
if existing:
user_pk = existing["pk"]
print(f" User '{username}' already exists (pk: {user_pk})",
flush=True)
else:
_, resp = self._request("POST", "/core/users/", data={
"username": username,
"name": "Test User",
"email": email,
"is_active": True,
})
user_pk = resp["pk"]
print(f" Created user '{username}' (pk: {user_pk})", flush=True)
# Set password
print(f" Setting password for '{username}' ...", flush=True)
self._request(
"POST", f"/core/users/{user_pk}/set_password/",
data={"password": password},
)
print(" Password set", flush=True)
return user_pk
def ensure_app_password(self, user_pk: int,
identifier: str = "testuser-app-password") -> str:
"""Create an APP_PASSWORD token for password grant. Returns the key."""
print(" Creating APP_PASSWORD token for password grant ...", flush=True)
# Delete existing token if present
existing = self._get_first(
f"/core/tokens/?identifier={identifier}", "identifier", identifier)
if existing:
self._request("DELETE", f"/core/tokens/{identifier}/")
print(" Deleted existing APP_PASSWORD token", flush=True)
self._request("POST", "/core/tokens/", data={
"identifier": identifier,
"intent": "app_password",
"user": user_pk,
"description": "Test user app password for OIDC password grant",
"expiring": False,
})
_, key_data = self._request(
"GET", f"/core/tokens/{identifier}/view_key/")
app_password = key_data["key"]
print(f" APP_PASSWORD created: {app_password[:10]}...", flush=True)
return app_password

175
lib/config.py Normal file
View File

@ -0,0 +1,175 @@
"""Central configuration for the recipe-maintainer-3 project.
Resolves the workspace root, provides paths to key directories,
and loads instance/deployment configuration from TOML files.
All paths are resolved lazily on first access and cached.
"""
import os
import tomllib
from pathlib import Path
# ---------------------------------------------------------------------------
# Workspace root detection
# ---------------------------------------------------------------------------
_workspace: Path | None = None
def get_workspace() -> Path:
"""Auto-detect the workspace root by walking up to find AGENTS.md."""
global _workspace
if _workspace is not None:
return _workspace
# Start from this file's location (lib/config.py → lib/ → workspace root)
candidate = Path(__file__).resolve().parent.parent
if (candidate / "AGENTS.md").exists():
_workspace = candidate
return _workspace
# Fallback: walk up from cwd
candidate = Path.cwd()
for _ in range(10):
if (candidate / "AGENTS.md").exists():
_workspace = candidate
return _workspace
parent = candidate.parent
if parent == candidate:
break
candidate = parent
raise RuntimeError(
"Could not find workspace root (no AGENTS.md found). "
"Are you running from within the recipe-maintainer-3 directory?"
)
# ---------------------------------------------------------------------------
# Path constants (lazy)
# ---------------------------------------------------------------------------
def _ws() -> Path:
return get_workspace()
class _Paths:
"""Lazy path accessors for key project directories."""
@property
def WORKSPACE(self) -> Path:
return _ws()
@property
def LIB_DIR(self) -> Path:
return _ws() / "lib"
@property
def SCRIPTS_DIR(self) -> Path:
return _ws() / "scripts"
@property
def RECIPE_INFO_DIR(self) -> Path:
return _ws() / "recipe-info"
@property
def TESTSECRETS_DIR(self) -> Path:
return _ws() / "recipe-info" / "testsecrets"
@property
def LOGS_DIR(self) -> Path:
return _ws() / "logs"
@property
def PLANS_DIR(self) -> Path:
return _ws() / "plans"
@property
def PLANNED_UPDATES_DIR(self) -> Path:
return _ws() / "planned-updates"
@property
def TERRAFORM_DIR(self) -> Path:
return _ws() / "terraform"
@property
def ABRA_DIR(self) -> Path:
env = os.environ.get("ABRA_DIR")
if env:
return Path(env)
return Path.home() / ".abra"
@property
def ABRA_RECIPES_DIR(self) -> Path:
return self.ABRA_DIR / "recipes"
@property
def ABRA_SERVERS_DIR(self) -> Path:
return self.ABRA_DIR / "servers"
paths = _Paths()
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
def load_settings() -> dict:
"""Load settings.toml from the workspace root."""
p = paths.WORKSPACE / "settings.toml"
if not p.exists():
return {}
with open(p, "rb") as f:
return tomllib.load(f)
# ---------------------------------------------------------------------------
# Instance loading (from settings.toml [instances.*])
# ---------------------------------------------------------------------------
def _load_instances_from_settings() -> dict:
"""Load the [instances] section from settings.toml."""
settings = load_settings()
return settings.get("instances", {})
def get_instance_names() -> list[str]:
"""Return all instance names from settings.toml [instances.*]."""
return list(_load_instances_from_settings().keys())
def get_default_instance_name() -> str:
"""Return the default instance from settings.toml."""
settings = load_settings()
default = settings.get("default_instance")
if not default:
raise RuntimeError("No default_instance set in settings.toml")
if default not in get_instance_names():
raise RuntimeError(
f"default_instance '{default}' from settings.toml "
f"not found in settings.toml [instances]"
)
return default
def load_instance_toml(name: str) -> dict:
"""Load raw TOML data for a specific instance from settings.toml."""
instances = _load_instances_from_settings()
if name not in instances:
raise ValueError(f"Instance '{name}' not found in settings.toml [instances]")
data = dict(instances[name])
data["name"] = name
return data
def load_recipe_toml(recipe_name: str) -> dict:
"""Load recipe-info/<recipe>/recipe.toml."""
p = paths.RECIPE_INFO_DIR / recipe_name / "recipe.toml"
if not p.exists():
return {"name": recipe_name}
with open(p, "rb") as f:
data = tomllib.load(f)
data.setdefault("name", recipe_name)
return data

83
lib/env.py Normal file
View File

@ -0,0 +1,83 @@
"""Read and write abra .env files and recipe-info config files.
Handles the specific format of abra .env files: KEY=VALUE lines,
with support for comments (#) and uncommenting keys.
"""
from pathlib import Path
def read_env_file(path: str | Path) -> dict[str, str]:
"""Read a KEY=VALUE env file into a dict.
Ignores empty lines and comments (#).
"""
result = {}
p = Path(path)
if not p.exists():
return result
with open(p) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
result[key.strip()] = value.strip()
return result
def write_env_file(path: str | Path, data: dict[str, str]) -> None:
"""Write a dict as a KEY=VALUE env file."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "w") as f:
for key, value in data.items():
f.write(f"{key}={value}\n")
def apply_env_overrides(env_path: str | Path,
overrides: dict[str, str]) -> None:
"""Set or uncomment values in an abra .env file.
For each key in overrides:
- If the key exists (even commented out), replace the line
- If the key doesn't exist, append it
"""
p = Path(env_path)
if not p.exists():
write_env_file(p, overrides)
return
with open(p) as f:
lines = f.readlines()
remaining = dict(overrides)
new_lines = []
for line in lines:
stripped = line.strip()
matched = False
for key, value in list(remaining.items()):
# Match "KEY=...", "#KEY=...", or "# KEY=..."
if stripped.lstrip("#").strip().startswith(f"{key}="):
new_lines.append(f"{key}={value}\n")
remaining.pop(key)
matched = True
print(f" env: {key}={value}", flush=True)
break
if not matched:
new_lines.append(line)
# Append any keys that weren't found in the file
for key, value in remaining.items():
new_lines.append(f"{key}={value}\n")
print(f" env: {key}={value} (appended)", flush=True)
with open(p, "w") as f:
f.writelines(new_lines)
def get_abra_env_path(server: str, domain: str) -> Path:
"""Return the path to an abra app's .env file."""
from lib.config import paths
return paths.ABRA_SERVERS_DIR / server / f"{domain}.env"

195
lib/keycloak.py Normal file
View File

@ -0,0 +1,195 @@
"""Keycloak admin API client.
Provides a KeycloakAdmin class for managing realms, OIDC clients, and users
via the Keycloak REST API. Used by setup_keycloak_integration.py scripts.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
class KeycloakAdmin:
"""Client for the Keycloak Admin REST API."""
def __init__(self, base_url: str, admin_user: str, admin_password: str):
self.base_url = base_url.rstrip("/")
self.admin_user = admin_user
self.admin_password = admin_password
def _request(self, method: str, path: str, *,
data: dict | None = None,
headers: dict | None = None,
timeout: int = 30) -> tuple[int, dict | list | None]:
"""Make an HTTP request, return (status_code, parsed_json)."""
url = f"{self.base_url}{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return resp.getcode(), None
return resp.getcode(), json.loads(raw)
except urllib.error.HTTPError as e:
try:
raw = e.read().decode(errors="replace")
return e.code, json.loads(raw) if raw.strip() else None
except Exception:
return e.code, None
def get_admin_token(self) -> str:
"""Get an admin access token from the master realm."""
url = f"{self.base_url}/realms/master/protocol/openid-connect/token"
body = urllib.parse.urlencode({
"username": self.admin_user,
"password": self.admin_password,
"grant_type": "password",
"client_id": "admin-cli",
}).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
return data["access_token"]
def _auth_headers(self) -> dict:
token = self.get_admin_token()
return {"Authorization": f"Bearer {token}"}
def ensure_realm(self, realm: str) -> None:
"""Create a realm if it doesn't exist."""
print(f"=== Ensure Keycloak realm '{realm}' ===", flush=True)
headers = self._auth_headers()
status, _ = self._request(
"GET", f"/admin/realms/{realm}", headers=headers)
if status == 404:
status, _ = self._request("POST", "/admin/realms", data={
"realm": realm,
"enabled": True,
"registrationAllowed": False,
}, headers=headers)
print(f" Created realm '{realm}'", flush=True)
else:
print(f" Realm '{realm}' already exists, skipping", flush=True)
def ensure_client(self, realm: str, client_id: str,
redirect_uris: list[str],
web_origins: list[str]) -> tuple[str, str]:
"""Create an OIDC client if needed, return (client_uuid, client_secret)."""
print(f"=== Ensure OIDC client '{client_id}' ===", flush=True)
headers = self._auth_headers()
# Check if client exists
status, data = self._request(
"GET",
f"/admin/realms/{realm}/clients?clientId={client_id}",
headers=headers,
)
if data and len(data) > 0:
client_uuid = data[0]["id"]
print(f" Client '{client_id}' already exists, skipping", flush=True)
else:
status, _ = self._request(
"POST", f"/admin/realms/{realm}/clients",
data={
"clientId": client_id,
"enabled": True,
"protocol": "openid-connect",
"publicClient": False,
"standardFlowEnabled": True,
"directAccessGrantsEnabled": True,
"serviceAccountsEnabled": True,
"authorizationServicesEnabled": True,
"redirectUris": redirect_uris,
"webOrigins": web_origins,
"attributes": {"pkce.code.challenge.method": ""},
},
headers=headers,
)
print(f" Created client '{client_id}'", flush=True)
# Re-fetch to get UUID
headers = self._auth_headers()
_, data = self._request(
"GET",
f"/admin/realms/{realm}/clients?clientId={client_id}",
headers=headers,
)
client_uuid = data[0]["id"]
# Get client secret
headers = self._auth_headers()
_, secret_data = self._request(
"GET",
f"/admin/realms/{realm}/clients/{client_uuid}/client-secret",
headers=headers,
)
client_secret = secret_data["value"]
print(f" Client UUID: {client_uuid}", flush=True)
print(f" Client secret: {client_secret}", flush=True)
return client_uuid, client_secret
def ensure_user(self, realm: str, username: str, email: str,
password: str, *,
first_name: str = "Test",
last_name: str = "User") -> str:
"""Create a user if needed, set password. Returns user ID."""
print(f"=== Ensure user '{username}' ===", flush=True)
headers = self._auth_headers()
_, data = self._request(
"GET",
f"/admin/realms/{realm}/users?username={username}",
headers=headers,
)
if data and len(data) > 0:
user_id = data[0]["id"]
print(f" User '{username}' exists ({user_id}), resetting password",
flush=True)
headers = self._auth_headers()
self._request(
"PUT",
f"/admin/realms/{realm}/users/{user_id}/reset-password",
data={
"type": "password",
"value": password,
"temporary": False,
},
headers=headers,
)
else:
headers = self._auth_headers()
self._request(
"POST", f"/admin/realms/{realm}/users",
data={
"username": username,
"email": email,
"firstName": first_name,
"lastName": last_name,
"emailVerified": True,
"enabled": True,
"credentials": [{
"type": "password",
"value": password,
"temporary": False,
}],
},
headers=headers,
)
print(f" Created user '{username}'", flush=True)
# Re-fetch to get ID
headers = self._auth_headers()
_, data = self._request(
"GET",
f"/admin/realms/{realm}/users?username={username}",
headers=headers,
)
user_id = data[0]["id"]
return user_id

106
lib/log.py Normal file
View File

@ -0,0 +1,106 @@
"""Structured logging for skill operations.
Each skill invocation creates a SkillLogger that captures commands,
their output, and contextual messages. On save(), the log is written
to logs/<skill>-<recipe>-<date>.md in markdown format.
"""
from datetime import datetime
from pathlib import Path
from lib.config import paths
class SkillLogger:
"""Captures structured output from a skill invocation."""
def __init__(self, skill_name: str, recipe_name: str | None = None):
self.skill_name = skill_name
self.recipe_name = recipe_name
self.started = datetime.now()
self._entries: list[dict] = []
def step(self, description: str) -> None:
"""Log a major step in the skill execution."""
self._entries.append({"type": "step", "text": description})
print(f"\n=== {description} ===", flush=True)
def command(self, cmd: str, output: str, returncode: int) -> None:
"""Log a command and its result."""
self._entries.append({
"type": "command",
"cmd": cmd,
"output": output,
"returncode": returncode,
})
def info(self, message: str) -> None:
"""Log an informational message."""
self._entries.append({"type": "info", "text": message})
print(f" {message}", flush=True)
def warn(self, message: str) -> None:
"""Log a warning."""
self._entries.append({"type": "warn", "text": message})
print(f" WARNING: {message}", flush=True)
def error(self, message: str) -> None:
"""Log an error."""
self._entries.append({"type": "error", "text": message})
print(f" ERROR: {message}", flush=True)
def save(self) -> Path:
"""Write the log to logs/<skill>-<recipe>-<date>.md."""
date_str = self.started.strftime("%Y-%m-%d")
if self.recipe_name:
filename = f"{self.skill_name}-{self.recipe_name}-{date_str}.md"
else:
filename = f"{self.skill_name}-{date_str}.md"
log_dir = paths.LOGS_DIR
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / filename
lines = [
f"# {self.skill_name}",
"",
]
if self.recipe_name:
lines.append(f"Recipe: {self.recipe_name}")
lines.append("")
lines.append(
f"Started: {self.started.strftime('%Y-%m-%d %H:%M:%S')}"
)
lines.append(
f"Finished: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
lines.append("")
for entry in self._entries:
if entry["type"] == "step":
lines.append(f"## {entry['text']}")
lines.append("")
elif entry["type"] == "command":
lines.append(f"```")
lines.append(f"$ {entry['cmd']}")
if entry["output"]:
lines.append(entry["output"])
lines.append(f"```")
if entry["returncode"] != 0:
lines.append(f"Exit code: {entry['returncode']}")
lines.append("")
elif entry["type"] == "info":
lines.append(f"{entry['text']}")
lines.append("")
elif entry["type"] == "warn":
lines.append(f"**WARNING:** {entry['text']}")
lines.append("")
elif entry["type"] == "error":
lines.append(f"**ERROR:** {entry['text']}")
lines.append("")
with open(log_path, "w") as f:
f.write("\n".join(lines))
print(f"\n Log saved: {log_path}", flush=True)
return log_path

144
lib/models.py Normal file
View File

@ -0,0 +1,144 @@
"""Data model for instances, deployments, and recipes.
Provides dataclasses and loader functions that build on lib/config.py
to give structured access to the project's configuration.
"""
from dataclasses import dataclass, field
from lib import config
# ---------------------------------------------------------------------------
# Core dataclasses
# ---------------------------------------------------------------------------
@dataclass
class Instance:
"""A test server where apps can be deployed."""
name: str # "b1cc"
server: str # "b1cc.commoninternet.net"
domain_suffix: str # "b1cc.commoninternet.net"
protected_recipes: list[str] # ["traefik", "backup-bot-two"]
def default_domain(self, recipe: str) -> str:
"""Compute the default domain for a recipe on this instance."""
return f"{recipe}.{self.domain_suffix}"
@dataclass
class Deployment:
"""A specific app deployment on an instance."""
recipe: str # "hedgedoc"
domain: str # "hedgedoc.b1cc.commoninternet.net"
instance: Instance
name: str = "default" # "default" or custom name
env_overrides: dict = field(default_factory=dict)
@property
def stack_prefix(self) -> str:
"""Docker stack name: domain with dots replaced by underscores."""
return self.domain.replace(".", "_")
@property
def server(self) -> str:
return self.instance.server
@dataclass
class Recipe:
"""Metadata about a recipe we maintain."""
name: str
dependencies: list[str] = field(default_factory=list)
test_requires: list[str] = field(default_factory=list)
sso_provider: str | None = None # "keycloak" | "authentik" | None
setup_script: str | None = None
post_deploy_script: str | None = None
# ---------------------------------------------------------------------------
# Loader functions
# ---------------------------------------------------------------------------
def load_instance(name: str) -> Instance:
"""Load an Instance from settings.toml."""
data = config.load_instance_toml(name)
return Instance(
name=data["name"],
server=data.get("server", f"{name}.commoninternet.net"),
domain_suffix=data.get("domain_suffix", f"{name}.commoninternet.net"),
protected_recipes=data.get("protected_recipes", ["traefik", "backup-bot-two"]),
)
def load_default_instance() -> Instance:
"""Load the default instance from settings.toml."""
name = config.get_default_instance_name()
return load_instance(name)
def list_instances() -> list[Instance]:
"""Load all configured instances."""
return [load_instance(name) for name in config.get_instance_names()]
def load_recipe(name: str) -> Recipe:
"""Load a Recipe from recipe-info/<name>/recipe.toml."""
data = config.load_recipe_toml(name)
deps_section = data.get("dependencies", {})
sso_section = data.get("sso", {})
post_deploy_section = data.get("post_deploy", {})
return Recipe(
name=data.get("name", name),
dependencies=deps_section.get("requires", []),
test_requires=deps_section.get("test_requires", []),
sso_provider=sso_section.get("provider"),
setup_script=sso_section.get("setup_script"),
post_deploy_script=post_deploy_section.get("script"),
)
def load_deployment(recipe: str, *,
instance: str | None = None,
name: str = "default") -> Deployment:
"""Load a Deployment for a recipe on an instance.
If instance is None, uses the default instance.
Domain is computed as {recipe}.{instance.domain_suffix}.
An optional 'domain' key in recipe.toml overrides this.
"""
if instance is None:
inst = load_default_instance()
else:
inst = load_instance(instance)
# Check recipe.toml for optional domain override
recipe_data = config.load_recipe_toml(recipe)
domain = recipe_data.get("domain", inst.default_domain(recipe))
return Deployment(
recipe=recipe,
domain=domain,
instance=inst,
name=name,
)
def list_deployments(instance_name: str | None = None) -> list[Deployment]:
"""List all deployments by iterating recipe-info/*/recipe.toml files."""
if instance_name is None:
inst = load_default_instance()
instance_name = inst.name
deployments = []
recipe_info_dir = config.paths.RECIPE_INFO_DIR
if not recipe_info_dir.exists():
return []
for d in sorted(recipe_info_dir.iterdir()):
recipe_toml = d / "recipe.toml"
if d.is_dir() and recipe_toml.exists():
recipe_name = d.name
dep = load_deployment(recipe_name, instance=instance_name)
deployments.append(dep)
return deployments

Some files were not shown because too many files have changed in this diff Show More