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

177
scripts/context_reset.py Normal file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Undeploy all apps from a test server except protected infrastructure
and (optionally) a specific recipe and its dependencies.
This frees memory so you can test one recipe at a time.
Usage:
python3 scripts/context_reset.py # undeploy everything except infra
python3 scripts/context_reset.py --recipe hedgedoc # keep hedgedoc + its deps
python3 scripts/context_reset.py --recipe lasuite-docs # keep lasuite-docs + keycloak
"""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.models import load_instance, load_default_instance, load_recipe
from lib.abra import app_ls, app_undeploy
from lib.log import SkillLogger
def load_in_use_recipes() -> set[str]:
"""Return recipes currently locked by other agents via /workspace/in-use/<recipe>.lock files."""
in_use_dir = Path(__file__).resolve().parent.parent / "in-use"
if not in_use_dir.is_dir():
return set()
return {p.stem for p in in_use_dir.glob("*.lock")}
def resolve_dependencies(recipe_name: str, seen: set | None = None) -> set[str]:
"""Recursively resolve all dependencies for a recipe."""
if seen is None:
seen = set()
if recipe_name in seen:
return seen
seen.add(recipe_name)
recipe = load_recipe(recipe_name)
for dep in recipe.dependencies:
resolve_dependencies(dep, seen)
return seen
def main():
parser = argparse.ArgumentParser(
description="Undeploy non-protected apps from a test server"
)
parser.add_argument("--recipe", default=None,
help="Recipe to keep (with its dependencies)")
parser.add_argument("--instance", default=None,
help="Instance name (default: active)")
args = parser.parse_args()
log = SkillLogger("context-reset", args.recipe)
# Resolve instance
if args.instance:
inst = load_instance(args.instance)
else:
inst = load_default_instance()
log.info(f"Instance: {inst.name} ({inst.server})")
# Build protected set (recipe names matched by recipe field)
protected_recipes = set(inst.protected_recipes)
# Protect any recipes currently locked by other agents
in_use = load_in_use_recipes()
if in_use:
log.info(f"In-use lockfiles found, protecting: {', '.join(sorted(in_use))}")
protected_recipes.update(in_use)
# Protected domains matched by exact appName (for test_requires)
protected_domains = set()
if args.recipe:
deps = resolve_dependencies(args.recipe)
protected_recipes.update(deps)
# Also include test_requires for the target recipe (not transitive).
# test_requires are domain prefixes, expanded to full domains.
target = load_recipe(args.recipe)
for tr in target.test_requires:
domain = f"{tr}.{inst.domain_suffix}"
protected_domains.add(domain)
log.step("Protected recipes")
for r in sorted(protected_recipes):
reason = "infrastructure" if r in inst.protected_recipes else "target/dependency"
log.info(f" {r} ({reason})")
for d in sorted(protected_domains):
log.info(f" {d} (test dependency)")
# List deployed apps
log.step("List deployed apps")
result = app_ls(inst.server)
if not result.ok:
log.error(f"Failed to list apps: {result.stderr}")
log.save()
sys.exit(1)
# Parse the output — abra app ls -S -m outputs JSON:
# {"server": {"apps": [...], "appCount": N, ...}}
raw = result.json()
apps_data = []
if isinstance(raw, dict):
for server_name, server_data in raw.items():
if isinstance(server_data, dict) and "apps" in server_data:
for app in server_data["apps"]:
if isinstance(app, dict):
apps_data.append(app)
elif isinstance(server_data, list):
apps_data.extend(server_data)
elif isinstance(raw, list):
apps_data = raw
if not apps_data:
log.info("No apps deployed")
log.save()
return
# Categorize apps
to_keep = []
to_undeploy = []
for app in apps_data:
if isinstance(app, dict):
domain = app.get("domain", "")
recipe = app.get("recipe", "")
app_name = app.get("appName", domain)
else:
domain = str(app)
recipe = ""
app_name = domain
if not app_name:
continue
# Check if the app's recipe matches any protected recipe,
# or if its domain matches a protected domain
is_protected = False
for pr in protected_recipes:
if recipe == pr or pr in app_name:
is_protected = True
break
if not is_protected and domain in protected_domains:
is_protected = True
if is_protected:
to_keep.append(app_name)
else:
to_undeploy.append(app_name)
log.step("Plan")
log.info(f"Keeping {len(to_keep)} app(s)")
for d in to_keep:
log.info(f" KEEP: {d}")
log.info(f"Undeploying {len(to_undeploy)} app(s)")
for d in to_undeploy:
log.info(f" UNDEPLOY: {d}")
if not to_undeploy:
log.info("Nothing to undeploy")
log.save()
return
# Undeploy
log.step("Undeploy")
for domain in to_undeploy:
log.info(f"Undeploying {domain} ...")
result = app_undeploy(domain, timeout=60)
log.command(result.command, result.stdout, result.returncode)
log.step("Done")
log.info(f"Undeployed {len(to_undeploy)} app(s)")
log.save()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Get instance info, optionally with a recipe domain.
Usage:
python3 scripts/get_test_instance.py
python3 scripts/get_test_instance.py --recipe hedgedoc
python3 scripts/get_test_instance.py --recipe hedgedoc --instance t1cc
Output (without --recipe):
SERVER=b1cc.commoninternet.net
INSTANCE=b1cc
Output (with --recipe):
DOMAIN=hedgedoc.b1cc.commoninternet.net
SERVER=b1cc.commoninternet.net
INSTANCE=b1cc
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.config import get_default_instance_name, load_instance_toml, load_recipe_toml
def main():
parser = argparse.ArgumentParser(
description="Get instance info, optionally with a recipe domain"
)
parser.add_argument("--recipe", default=None, help="Recipe name (optional)")
parser.add_argument("--instance", default=None,
help="Instance name (default: from settings.toml)")
args = parser.parse_args()
if args.instance:
instance_name = args.instance
else:
instance_name = get_default_instance_name()
inst_data = load_instance_toml(instance_name)
server = inst_data.get("server", f"{instance_name}.commoninternet.net")
if args.recipe:
domain_suffix = inst_data.get("domain_suffix", f"{instance_name}.commoninternet.net")
recipe_data = load_recipe_toml(args.recipe)
domain = recipe_data.get("domain", f"{args.recipe}.{domain_suffix}")
print(f"DOMAIN={domain}")
print(f"SERVER={server}")
print(f"INSTANCE={instance_name}")
if __name__ == "__main__":
main()

81
scripts/mailgun_test.py Executable file
View File

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
Test outgoing Mailgun credentials by sending a single email via the HTTP API.
Usage:
MAILGUN_API_KEY='...' MAILGUN_FROM='noreply@example.com' \\
./mailgun_test.py recipient@example.com
Env vars:
MAILGUN_API_KEY (required) — Mailgun API key
MAILGUN_FROM (required) — sender address (must be on a verified domain)
MAILGUN_DOMAIN (optional) — defaults to the domain part of MAILGUN_FROM
MAILGUN_BASE_URI (optional) — defaults to https://api.mailgun.net/v3
use https://api.eu.mailgun.net/v3 for EU region
Prints HTTP status + Mailgun response body. Mailgun returns JSON with an
"id" + "message" on success, or an error string on failure (e.g. bad domain,
unverified sender, invalid key).
"""
import base64
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from email.utils import parseaddr
def main() -> int:
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} recipient@example.com", file=sys.stderr)
return 2
recipient = sys.argv[1]
api_key = os.environ.get("MAILGUN_API_KEY")
sender = os.environ.get("MAILGUN_FROM")
if not api_key or not sender:
print("error: MAILGUN_API_KEY and MAILGUN_FROM env vars are required",
file=sys.stderr)
return 2
_, sender_addr = parseaddr(sender)
domain = os.environ.get("MAILGUN_DOMAIN") or sender_addr.split("@", 1)[-1]
base_uri = os.environ.get("MAILGUN_BASE_URI", "https://api.mailgun.net/v3")
url = f"{base_uri.rstrip('/')}/{domain}/messages"
data = urllib.parse.urlencode({
"from": sender,
"to": recipient,
"subject": "Mailgun test from mailgun_test.py",
"text": f"This is a test message sent via {url} as {sender}.\n",
}).encode()
auth = base64.b64encode(f"api:{api_key}".encode()).decode()
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Authorization", f"Basic {auth}")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
print(f"POST {url}")
print(f" from={sender} to={recipient}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode()
print(f"\nHTTP {resp.status}")
try:
print(json.dumps(json.loads(body), indent=2))
except json.JSONDecodeError:
print(body)
print(f"\nOK: Mailgun accepted the message")
return 0
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
print(f"\nHTTP {e.code} {e.reason}")
print(body)
return 1
if __name__ == "__main__":
sys.exit(main())

103
scripts/new_tag.py Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Bump recipe version and create an annotated git tag.
Reads the current version from compose.yml, bumps it, updates the
version label, commits, and creates an annotated tag.
Usage:
python3 scripts/new_tag.py --recipe hedgedoc --bump patch
python3 scripts/new_tag.py --recipe hedgedoc --bump minor --upstream 2.0.0
"""
import argparse
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.recipe import (
RecipeVersion, read_compose_version, write_compose_version,
recipe_dir, has_local_changes,
)
from lib.log import SkillLogger
def main():
parser = argparse.ArgumentParser(
description="Bump recipe version and create git tag"
)
parser.add_argument("--recipe", required=True, help="Recipe name")
parser.add_argument("--bump", required=True,
choices=["patch", "minor", "major"],
help="Version bump level")
parser.add_argument("--upstream", default=None,
help="New upstream version (default: keep current)")
parser.add_argument("--dry-run", action="store_true",
help="Show what would happen without making changes")
args = parser.parse_args()
log = SkillLogger("new-tag", args.recipe)
rdir = recipe_dir(args.recipe)
if not rdir.exists():
log.error(f"Recipe directory not found: {rdir}")
log.save()
sys.exit(1)
# Read current version
current = read_compose_version(args.recipe)
if current is None:
log.error("Could not read version from compose.yml")
log.save()
sys.exit(1)
log.info(f"Current version: {current}")
# Compute new version
new = current.bump(args.bump)
if args.upstream:
new = new.with_upstream(args.upstream)
log.info(f"New version: {new}")
if args.dry_run:
print(f"DRY RUN: Would bump {current} -> {new}")
return
# Check for existing uncommitted changes
if has_local_changes(args.recipe):
log.step("Stage existing changes")
subprocess.run(["git", "-C", str(rdir), "add", "-A"],
check=True, timeout=10)
# Update compose.yml
log.step("Update compose.yml")
write_compose_version(args.recipe, new)
log.info(f"Updated version label to {new}")
# Commit and tag
log.step("Commit and tag")
subprocess.run(
["git", "-C", str(rdir), "add", "compose.yml"],
check=True, timeout=10,
)
subprocess.run(
["git", "-C", str(rdir), "commit", "-m",
f"chore: upgrade to {new}"],
check=True, timeout=10,
)
subprocess.run(
["git", "-C", str(rdir), "tag", "-a", str(new), "-m",
f"chore: publish {new} release"],
check=True, timeout=10,
)
log.info(f"Committed and tagged as {new}")
log.info(f"Push when ready: cd {rdir} && git push && git push --tags")
log.save()
print(f"\nTagged: {new}", flush=True)
print(f"Push: cd {rdir} && git push && git push --tags", flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Switch the default instance used by all skills.
Usage:
python3 scripts/switch_default_instance.py <instance-name>
python3 scripts/switch_default_instance.py # lists available instances
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.config import get_instance_names, get_default_instance_name, paths
def main():
parser = argparse.ArgumentParser(description="Switch the default instance")
parser.add_argument("instance", nargs="?", help="Instance name to switch to")
args = parser.parse_args()
instances = get_instance_names()
current = get_default_instance_name()
if not args.instance:
print(f"Current default: {current}")
print(f"Available instances: {', '.join(instances)}")
sys.exit(0)
if args.instance not in instances:
print(f"Error: '{args.instance}' is not a valid instance.")
print(f"Available instances: {', '.join(instances)}")
sys.exit(1)
if args.instance == current:
print(f"Already set to '{current}', nothing to do.")
sys.exit(0)
settings_path = paths.WORKSPACE / "settings.toml"
text = settings_path.read_text()
new_text = text.replace(
f'default_instance = "{current}"',
f'default_instance = "{args.instance}"',
)
settings_path.write_text(new_text)
# Verify
# Re-import to bust any caches wouldn't work, so just read back
verify = settings_path.read_text()
if f'default_instance = "{args.instance}"' in verify:
print(f"Switched default instance: {current} -> {args.instance}")
else:
print("Error: failed to update settings.toml")
sys.exit(1)
if __name__ == "__main__":
main()

61
scripts/sync_secrets.py Normal file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Sync Docker secrets from running containers to local files.
For each deployed recipe on an instance, SSHes into the server,
reads /run/secrets/ from containers, and saves locally.
Usage:
python3 scripts/sync_secrets.py # sync all recipes
python3 scripts/sync_secrets.py --recipe keycloak # sync one recipe
python3 scripts/sync_secrets.py --instance t1cc # sync on specific instance
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.config import paths
from lib.models import load_default_instance, load_instance, load_deployment
from lib.secrets import sync_secrets_for_deployment, sync_all_secrets
from lib.log import SkillLogger
def main():
parser = argparse.ArgumentParser(
description="Sync secrets from running containers"
)
parser.add_argument("--recipe", default=None,
help="Sync only this recipe (default: all)")
parser.add_argument("--instance", default=None,
help="Instance name (default: active)")
args = parser.parse_args()
log = SkillLogger("sync-secrets", args.recipe)
if args.instance:
inst = load_instance(args.instance)
else:
inst = load_default_instance()
log.info(f"Instance: {inst.name} ({inst.server})")
if args.recipe:
# Sync one recipe
dep = load_deployment(args.recipe, instance=inst.name)
log.step(f"Sync secrets for {args.recipe}")
secrets = sync_secrets_for_deployment(inst.server, dep.domain)
log.info(f"Synced {len(secrets)} secrets for {dep.domain}")
else:
# Sync all
log.step("Sync all secrets")
results = sync_all_secrets(inst.server)
total = sum(len(s) for s in results.values())
log.info(f"Synced {total} secrets across {len(results)} deployments")
log.save()
if __name__ == "__main__":
main()

151
scripts/test_runner.py Normal file
View File

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Discover and run tests for a recipe.
Finds all Python test scripts in recipe-info/<recipe>/tests/,
runs each one, and reports pass/fail results.
Usage:
python3 scripts/test_runner.py --recipe hedgedoc
python3 scripts/test_runner.py --recipe hedgedoc --instance t1cc
python3 scripts/test_runner.py --recipe hedgedoc --domain custom.example.com
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.config import paths
from lib.models import load_deployment
from lib.log import SkillLogger
def discover_tests(recipe: str) -> list[Path]:
"""Find all test scripts for a recipe."""
test_dir = paths.RECIPE_INFO_DIR / recipe / "tests"
if not test_dir.exists():
return []
tests = sorted(test_dir.glob("*.py"))
# Exclude __init__.py and similar
return [t for t in tests if not t.name.startswith("_")]
def run_test(test_path: Path, domain: str, server: str,
timeout: int = 120) -> dict:
"""Run a single test script.
Returns {"name": str, "passed": bool, "output": str, "returncode": int}.
"""
name = test_path.stem
print(f"\n--- Running: {name} ---", flush=True)
cmd = [sys.executable, str(test_path), "--domain", domain]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
env={**os.environ, "TEST_SERVER": server, "TEST_DOMAIN": domain},
)
output = result.stdout
if result.stderr:
output += "\n" + result.stderr
passed = result.returncode == 0
except subprocess.TimeoutExpired:
output = f"TIMEOUT after {timeout}s"
passed = False
result = None
status = "PASS" if passed else "FAIL"
print(f" {status}: {name}", flush=True)
if output.strip():
for line in output.strip().split("\n"):
print(f" {line}", flush=True)
return {
"name": name,
"passed": passed,
"output": output.strip(),
"returncode": result.returncode if result else -1,
}
def main():
parser = argparse.ArgumentParser(description="Run tests for a recipe")
parser.add_argument("--recipe", required=True, help="Recipe name")
parser.add_argument("--instance", default=None,
help="Instance name (default: active)")
parser.add_argument("--deployment", default="default",
help="Deployment name")
parser.add_argument("--domain", default=None,
help="Override domain")
parser.add_argument("--server", default=None,
help="Override server (use with --domain)")
parser.add_argument("--timeout", type=int, default=120,
help="Timeout per test (seconds)")
args = parser.parse_args()
log = SkillLogger("test", args.recipe)
# Resolve deployment
if args.domain:
domain = args.domain
server = args.server or "localhost"
else:
dep = load_deployment(args.recipe, instance=args.instance,
name=args.deployment)
domain = dep.domain
server = dep.server
log.info(f"Testing: {domain} on {server}")
# Discover tests
log.step("Discover tests")
tests = discover_tests(args.recipe)
if not tests:
log.warn(f"No tests found in recipe-info/{args.recipe}/tests/")
log.info("Create test scripts as Python files in that directory.")
log.save()
print(f"\nNo tests found for {args.recipe}", flush=True)
sys.exit(0)
log.info(f"Found {len(tests)} test(s): {', '.join(t.stem for t in tests)}")
# Run tests
log.step("Run tests")
results = []
for test_path in tests:
result = run_test(test_path, domain, server, timeout=args.timeout)
results.append(result)
log.command(
f"python3 {test_path.name} --domain {domain}",
result["output"],
result["returncode"],
)
# Summary
log.step("Summary")
passed = [r for r in results if r["passed"]]
failed = [r for r in results if not r["passed"]]
print(f"\n{'='*50}", flush=True)
print(f"Results: {len(passed)}/{len(results)} passed", flush=True)
for r in results:
status = "PASS" if r["passed"] else "FAIL"
print(f" [{status}] {r['name']}", flush=True)
if failed:
log.error(f"{len(failed)} test(s) failed: "
f"{', '.join(r['name'] for r in failed)}")
else:
log.info("All tests passed")
log.save()
if failed:
sys.exit(1)
if __name__ == "__main__":
main()