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:
177
scripts/context_reset.py
Normal file
177
scripts/context_reset.py
Normal 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()
|
||||
56
scripts/get_test_instance.py
Normal file
56
scripts/get_test_instance.py
Normal 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
81
scripts/mailgun_test.py
Executable 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
103
scripts/new_tag.py
Normal 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()
|
||||
59
scripts/switch_default_instance.py
Normal file
59
scripts/switch_default_instance.py
Normal 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
61
scripts/sync_secrets.py
Normal 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
151
scripts/test_runner.py
Normal 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()
|
||||
Reference in New Issue
Block a user