Scaffold Cloudron packaging workspace
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Dict, List
|
||||
from typing import Dict, Iterable, List, Sequence
|
||||
|
||||
try:
|
||||
import jinja2
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - guidance for local execution
|
||||
raise SystemExit(
|
||||
"Jinja2 is required. Run this script via './run/dev.sh python scripts/new_app.py ...' so dependencies are provided by the devtools container."
|
||||
) from exc
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
DEFAULT_BASE_IMAGE = "cloudron/base:5.0.0"
|
||||
DEFAULT_BUILDER_IMAGE = "cloudron/base:5.0.0"
|
||||
|
||||
|
||||
def load_catalog(path: pathlib.Path) -> List[Dict[str, object]]:
|
||||
@@ -16,109 +24,124 @@ def load_catalog(path: pathlib.Path) -> List[Dict[str, object]]:
|
||||
|
||||
|
||||
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}"
|
||||
import re
|
||||
|
||||
normalized = slug.lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", ".", normalized)
|
||||
normalized = re.sub(r"\.\.+", ".", normalized).strip(".")
|
||||
if not normalized:
|
||||
raise ValueError(f"Unable to derive Cloudron app id from slug '{slug}'")
|
||||
return f"com.knel.{normalized}"
|
||||
|
||||
|
||||
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"]
|
||||
def ensure_clean_destination(app_dir: pathlib.Path, force: bool) -> None:
|
||||
if app_dir.exists():
|
||||
if not force:
|
||||
raise FileExistsError(f"Destination {app_dir} already exists. Use --force to overwrite.")
|
||||
raise FileExistsError(
|
||||
f"Destination {app_dir} already exists. Use --force to overwrite or remove it manually."
|
||||
)
|
||||
shutil.rmtree(app_dir)
|
||||
render_templates(template_dir, app_dir, entry)
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def render_templates(template_dir: pathlib.Path, destination: pathlib.Path, context: Dict[str, object]) -> None:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(template_dir)),
|
||||
keep_trailing_newline=True,
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
for source in template_dir.rglob("*"):
|
||||
if source.is_dir():
|
||||
continue
|
||||
relative = source.relative_to(template_dir)
|
||||
target = destination / relative
|
||||
|
||||
if source.suffix == ".j2":
|
||||
rendered_path = target.with_suffix("")
|
||||
rendered_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
template = env.get_template(str(relative))
|
||||
rendered_path.write_text(template.render(**context), encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
for relpath in ("start.sh", "test/smoke.sh"):
|
||||
path = destination / relpath
|
||||
if path.exists():
|
||||
path.chmod(path.stat().st_mode | 0o111)
|
||||
|
||||
|
||||
def build_context(entry: Dict[str, object]) -> Dict[str, object]:
|
||||
generated_at = dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
tags = entry.get("tags") or ["custom", "known-element"]
|
||||
website = entry.get("website") or entry.get("repo", "").rstrip(".git")
|
||||
http_port = int(entry.get("httpPort", 3000))
|
||||
base_image = entry.get("baseImage", DEFAULT_BASE_IMAGE)
|
||||
builder_image = entry.get("builderImage", DEFAULT_BUILDER_IMAGE)
|
||||
additional_repos = entry.get("additionalRepos", [])
|
||||
|
||||
return {
|
||||
"app_id": build_app_id(entry["slug"]),
|
||||
"app_slug": entry["slug"],
|
||||
"app_title": entry["title"],
|
||||
"app_repo": entry["repo"],
|
||||
"app_issue": entry.get("issue", ""),
|
||||
"app_website": website,
|
||||
"app_tags": json.dumps(tags),
|
||||
"http_port": http_port,
|
||||
"base_image": base_image,
|
||||
"builder_image": builder_image,
|
||||
"generated_at": generated_at,
|
||||
"additional_repos_json": json.dumps(additional_repos, indent=2),
|
||||
"default_app_version": entry.get("defaultVersion", "latest"),
|
||||
}
|
||||
|
||||
|
||||
def render_app(entry: Dict[str, object], template_dir: pathlib.Path, apps_dir: pathlib.Path, force: bool) -> pathlib.Path:
|
||||
app_dir = apps_dir / entry["slug"]
|
||||
ensure_clean_destination(app_dir, force)
|
||||
context = build_context(entry)
|
||||
render_templates(template_dir, app_dir, context)
|
||||
return app_dir
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def iter_entries(catalog: Sequence[Dict[str, object]], slugs: Iterable[str] | None) -> Iterable[Dict[str, object]]:
|
||||
if slugs is None:
|
||||
yield from catalog
|
||||
return
|
||||
|
||||
slug_set = set(slugs)
|
||||
for entry in catalog:
|
||||
if entry["slug"] in slug_set:
|
||||
yield entry
|
||||
slug_set.remove(entry["slug"])
|
||||
|
||||
if slug_set:
|
||||
missing = ", ".join(sorted(slug_set))
|
||||
raise SystemExit(f"Unknown slug(s): {missing}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
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("--slug", action="append", dest="slugs", help="Generate scaffold for the provided slug (repeatable)")
|
||||
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()
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = 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)
|
||||
for entry in iter_entries(catalog, args.slugs):
|
||||
app_dir = render_app(entry, template_dir, apps_dir, args.force)
|
||||
print(f"Created {app_dir.relative_to(ROOT)}")
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user