Scaffold Cloudron packaging workspace and automation

This commit is contained in:
2025-10-02 12:07:09 -05:00
parent b4121cc932
commit 482d4ff1b8
414 changed files with 6837 additions and 2 deletions

83
scripts/ci_local.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
CI_IMAGE=${CI_IMAGE:-knel/cloudron-ci}
BUILD=${BUILD:-0}
ensure_image() {
if [[ "${BUILD}" == "1" ]]; then
docker build -t "${CI_IMAGE}" "${REPO_ROOT}/docker/ci-runner"
return
fi
if ! docker image inspect "${CI_IMAGE}" >/dev/null 2>&1; then
docker build -t "${CI_IMAGE}" "${REPO_ROOT}/docker/ci-runner"
fi
}
run_in_ci() {
local cmd=$1
docker run --rm -t \
-v "${REPO_ROOT}:/workspace" \
-v /var/run/docker.sock:/var/run/docker.sock \
"${CI_IMAGE}" "${cmd}"
}
lint() {
run_in_ci 'git config --global --add safe.directory /workspace && make lint && make status && git diff --exit-code docs/APP_STATUS.md'
}
packager_smoke() {
run_in_ci 'git config --global --add safe.directory /workspace && docker build -t knel/cloudron-packager-test docker/packager && docker run --rm knel/cloudron-packager-test cloudron --help'
}
usage() {
cat <<USAGE
Usage: $(basename "$0") [lint|status|packager-smoke|all]
Options:
lint Run make lint + status and verify dashboard is committed
status Regenerate docs/APP_STATUS.md
packager-smoke Build the helper packager image and run cloudron --help
all Execute lint and packager-smoke
Environment variables:
BUILD=1 Force rebuild of the CI image before running tasks
CI_IMAGE Override the CI harness image tag (default: knel/cloudron-ci)
USAGE
}
status_only() {
run_in_ci 'git config --global --add safe.directory /workspace && make status'
}
main() {
ensure_image
local target=${1:-all}
case "${target}" in
lint)
lint
;;
status)
status_only
;;
packager-smoke)
packager_smoke
;;
all)
lint
packager_smoke
;;
-h|--help)
usage
;;
*)
echo "Unknown target: ${target}" >&2
usage
exit 1
;;
esac
}
main "$@"

75
scripts/generate_status.py Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Produce status documentation for all apps."""
import json
import pathlib
from datetime import datetime
from typing import Dict, List
ROOT = pathlib.Path(__file__).resolve().parents[1]
def load_catalog() -> List[Dict[str, str]]:
catalog_path = ROOT / "apps" / "catalog.json"
return json.loads(catalog_path.read_text(encoding="utf-8"))
def load_manifest(app_dir: pathlib.Path) -> Dict[str, object]:
manifest_path = app_dir / "CloudronManifest.json"
if not manifest_path.exists():
return {}
try:
return json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
def detect_status(app_dir: pathlib.Path) -> str:
manifest = load_manifest(app_dir)
start_script = (app_dir / "start.sh").read_text(encoding="utf-8") if (app_dir / "start.sh").exists() else ""
placeholders = 0
if "TODO" in json.dumps(manifest):
placeholders += 1
if "Replace start.sh" in start_script:
placeholders += 1
if placeholders == 0:
return "ready"
if placeholders == 1:
return "in-progress"
return "todo"
def render_table(rows: List[Dict[str, str]]) -> str:
header = "| Slug | Title | Version | Status | Issue |\n| --- | --- | --- | --- | --- |"
lines = [header]
for row in rows:
lines.append(
f"| {row['slug']} | {row['title']} | {row['version']} | {row['status']} | {row['issue']} |"
)
return "\n".join(lines)
def main() -> None:
catalog = load_catalog()
rows: List[Dict[str, str]] = []
for entry in catalog:
slug = entry["slug"]
app_dir = ROOT / "apps" / slug
manifest = load_manifest(app_dir)
rows.append({
"slug": slug,
"title": entry["title"],
"version": manifest.get("version", "0.1.0"),
"status": detect_status(app_dir),
"issue": entry.get("issue", "")
})
output = ["# Application Status", "", f"_Updated: {datetime.utcnow().isoformat(timespec='seconds')}Z_", "", render_table(rows)]
status_path = ROOT / "docs" / "APP_STATUS.md"
status_path.write_text("\n".join(output) + "\n", encoding="utf-8")
print(f"Updated {status_path.relative_to(ROOT)}")
if __name__ == "__main__":
main()

16
scripts/hooks/install_hooks.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
HOOK_DIR="${REPO_ROOT}/.git/hooks"
if [[ ! -d "${HOOK_DIR}" ]]; then
echo "This script must be run inside a git repository." >&2
exit 1
fi
for hook in pre-commit post-commit pre-push; do
ln -sf "${REPO_ROOT}/scripts/hooks/${hook}" "${HOOK_DIR}/${hook}"
chmod +x "${HOOK_DIR}/${hook}"
echo "Installed ${hook} hook"
done

11
scripts/hooks/post-commit Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -euo pipefail
if [[ "${SKIP_CI_HOOKS:-}" == "1" ]]; then
echo "Skipping CI hooks because SKIP_CI_HOOKS=1"
exit 0
fi
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
${REPO_ROOT}/scripts/ci_local.sh status || true

11
scripts/hooks/pre-commit Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -euo pipefail
if [[ "${SKIP_CI_HOOKS:-}" == "1" ]]; then
echo "Skipping CI hooks because SKIP_CI_HOOKS=1"
exit 0
fi
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
${REPO_ROOT}/scripts/ci_local.sh lint

11
scripts/hooks/pre-push Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -euo pipefail
if [[ "${SKIP_CI_HOOKS:-}" == "1" ]]; then
echo "Skipping CI hooks because SKIP_CI_HOOKS=1"
exit 0
fi
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
${REPO_ROOT}/scripts/ci_local.sh packager-smoke

96
scripts/lint_repo.py Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Basic sanity checks for Cloudron packaging scaffolds."""
import json
import os
import pathlib
import sys
from typing import Dict, List
ROOT = pathlib.Path(__file__).resolve().parents[1]
EXPECTED_BASE = os.environ.get("CLOUDRON_BASE", "cloudron/base:5.0.0")
def find_apps(apps_dir: pathlib.Path) -> List[pathlib.Path]:
return sorted(p for p in apps_dir.iterdir() if p.is_dir())
def check_manifest(app_dir: pathlib.Path) -> List[str]:
issues: List[str] = []
manifest = app_dir / "CloudronManifest.json"
if not manifest.exists():
issues.append("missing CloudronManifest.json")
return issues
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
issues.append(f"manifest JSON invalid: {exc}")
return issues
for key in ("id", "title", "version"):
if not data.get(key):
issues.append(f"manifest missing {key}")
tagline = data.get("tagline", "")
description = data.get("description", "")
if "TODO" in tagline:
issues.append("manifest tagline still contains TODO placeholder")
if "TODO" in description:
issues.append("manifest description still contains TODO placeholder")
return issues
def check_dockerfile(app_dir: pathlib.Path) -> List[str]:
issues: List[str] = []
dockerfile = app_dir / "Dockerfile"
if not dockerfile.exists():
issues.append("missing Dockerfile")
return issues
first_from = None
for line in dockerfile.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("FROM "):
first_from = line.split()[1]
break
if first_from != EXPECTED_BASE:
issues.append(f"Dockerfile base image '{first_from}' != '{EXPECTED_BASE}'")
return issues
def check_start_script(app_dir: pathlib.Path) -> List[str]:
issues: List[str] = []
start = app_dir / "start.sh"
if not start.exists():
issues.append("missing start.sh")
return issues
mode = start.stat().st_mode
if not mode & 0o111:
issues.append("start.sh is not executable")
if "Replace start.sh" in start.read_text(encoding="utf-8"):
issues.append("start.sh still contains placeholder command")
return issues
def main() -> int:
apps_dir = ROOT / "apps"
if not apps_dir.exists():
print("No apps directory present", file=sys.stderr)
return 1
failures = 0
for app_dir in find_apps(apps_dir):
app_issues: List[str] = []
app_issues.extend(check_manifest(app_dir))
app_issues.extend(check_dockerfile(app_dir))
app_issues.extend(check_start_script(app_dir))
if app_issues:
failures += 1
print(f"[FAIL] {app_dir.relative_to(ROOT)}")
for issue in app_issues:
print(f" - {issue}")
if failures:
print(f"\n{failures} app(s) require updates", file=sys.stderr)
return 2
print("All apps passed lint checks")
return 0
if __name__ == "__main__":
sys.exit(main())

126
scripts/new_app.py Executable file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
import argparse
import datetime
import json
import os
import pathlib
import shutil
from typing import Dict, List
ROOT = pathlib.Path(__file__).resolve().parents[1]
def load_catalog(path: pathlib.Path) -> List[Dict[str, object]]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def build_app_id(slug: str) -> str:
sanitized = ''.join(ch.lower() if ch.isalnum() else '.' for ch in slug)
sanitized = '.'.join(filter(None, sanitized.split('.')))
return f"com.knel.{sanitized}"
def default_tags() -> List[str]:
return ["custom", "known-element"]
def default_placeholder_map(entry: Dict[str, object]) -> Dict[str, str]:
slug = entry["slug"]
title = entry["title"]
repo = entry["repo"]
issue = entry.get("issue", "")
website = entry.get("website") or repo.rstrip(".git")
description = entry.get("description") or "TODO: Add package description."
tags = entry.get("tags") or default_tags()
http_port = str(entry.get("httpPort", 3000))
base_image = entry.get("baseImage", "cloudron/base:5.0.0")
placeholder_map = {
"{{APP_ID}}": build_app_id(slug),
"{{APP_TITLE}}": title,
"{{APP_SLUG}}": slug,
"{{APP_REPO}}": repo,
"{{APP_ISSUE}}": issue,
"{{APP_WEBSITE}}": website,
"{{APP_DESCRIPTION}}": description,
"{{HTTP_PORT}}": http_port,
"{{BASE_IMAGE}}": base_image,
"{{APP_TAGS}}": json.dumps(tags)
}
return placeholder_map
def render_templates(template_dir: pathlib.Path, destination: pathlib.Path, entry: Dict[str, object]) -> None:
shutil.copytree(template_dir, destination, dirs_exist_ok=True)
placeholders = default_placeholder_map(entry)
for path in destination.rglob('*'):
if path.is_dir():
continue
text = path.read_text(encoding='utf-8')
for key, value in placeholders.items():
text = text.replace(key, value)
path.write_text(text, encoding='utf-8')
# Ensure critical scripts are executable
for relpath in ["start.sh", "test/smoke.sh"]:
target = destination / relpath
if target.exists():
mode = target.stat().st_mode
target.chmod(mode | 0o111)
# Drop metadata file for bookkeeping
metadata = {
"slug": entry["slug"],
"title": entry["title"],
"issue": entry.get("issue"),
"repo": entry.get("repo"),
"additionalRepos": entry.get("additionalRepos", []),
"created": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
"notes": entry.get("notes", "TODO: capture packaging notes")
}
with (destination / "metadata.json").open("w", encoding="utf-8") as handle:
json.dump(metadata, handle, indent=2)
def ensure_app_directory(entry: Dict[str, object], template_dir: pathlib.Path, apps_dir: pathlib.Path, force: bool) -> pathlib.Path:
app_dir = apps_dir / entry["slug"]
if app_dir.exists():
if not force:
raise FileExistsError(f"Destination {app_dir} already exists. Use --force to overwrite.")
shutil.rmtree(app_dir)
render_templates(template_dir, app_dir, entry)
return app_dir
def main() -> None:
parser = argparse.ArgumentParser(description="Scaffold Cloudron app packages from catalog entries")
parser.add_argument("--slug", help="Generate scaffold for a single slug", default=None)
parser.add_argument("--catalog", default=str(ROOT / "apps" / "catalog.json"), help="Path to catalog JSON")
parser.add_argument("--template", default=str(ROOT / "templates" / "cloudron-app"), help="Template directory")
parser.add_argument("--apps-dir", default=str(ROOT / "apps"), help="Destination apps directory")
parser.add_argument("--force", action="store_true", help="Overwrite existing app directory")
args = parser.parse_args()
catalog = load_catalog(pathlib.Path(args.catalog))
entries = catalog
if args.slug:
entries = [entry for entry in catalog if entry["slug"] == args.slug]
if not entries:
raise SystemExit(f"Slug {args.slug} not found in catalog")
template_dir = pathlib.Path(args.template)
apps_dir = pathlib.Path(args.apps_dir)
apps_dir.mkdir(parents=True, exist_ok=True)
for entry in entries:
app_dir = ensure_app_directory(entry, template_dir, apps_dir, args.force)
print(f"Created {app_dir.relative_to(ROOT)}")
if __name__ == "__main__":
main()

16
scripts/run_packager.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
IMAGE_NAME=${IMAGE_NAME:-knel/cloudron-packager}
BUILD=${BUILD:-0}
if [[ "${BUILD}" == "1" ]]; then
docker build -t "${IMAGE_NAME}" "${REPO_ROOT}/docker/packager"
fi
docker run --rm -it \
-v "${REPO_ROOT}:/workspace" \
-v /var/run/docker.sock:/var/run/docker.sock \
-e HOME=/home/packager \
"${IMAGE_NAME}" "$@"