Scaffold Cloudron packaging workspace

This commit is contained in:
2025-10-02 13:39:36 -05:00
parent 482d4ff1b8
commit fe0ade1dd9
366 changed files with 4035 additions and 2493 deletions

View File

@@ -1,96 +1,236 @@
#!/usr/bin/env python3
"""Basic sanity checks for Cloudron packaging scaffolds."""
"""Lint Cloudron app scaffolds."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
from typing import Dict, List
import subprocess
from dataclasses import dataclass
from typing import Dict, Iterable, List, Sequence, Tuple
try:
import jsonschema
except ModuleNotFoundError as exc: # pragma: no cover - guidance for local execution
raise SystemExit(
"jsonschema is required. Run this script through './run/dev.sh python scripts/lint_repo.py ...' so dependencies come from the devtools container."
) from exc
ROOT = pathlib.Path(__file__).resolve().parents[1]
EXPECTED_BASE = os.environ.get("CLOUDRON_BASE", "cloudron/base:5.0.0")
SCHEMA_PATH = ROOT / "schema" / "cloudron-manifest.schema.json"
DEFAULT_BASE_IMAGE_PREFIX = "cloudron/base"
def find_apps(apps_dir: pathlib.Path) -> List[pathlib.Path]:
@dataclass
class Issue:
severity: str # "error" or "warning"
message: str
def __str__(self) -> str:
return f"{self.severity.upper()}: {self.message}"
def load_schema() -> Dict[str, object]:
with SCHEMA_PATH.open("r", encoding="utf-8") as handle:
return json.load(handle)
def list_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
def resolve_slugs_from_paths(paths: Sequence[str]) -> List[str]:
slugs = set()
for path in paths:
parts = pathlib.PurePosixPath(path).parts
if len(parts) >= 2 and parts[0] == "apps":
slugs.add(parts[1])
return sorted(slugs)
def collect_paths_from_git(diff_target: str) -> List[str]:
if not diff_target:
return []
result = subprocess.run(
["git", "diff", "--name-only", diff_target],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Lint Cloudron app scaffolds")
parser.add_argument("--slug", action="append", dest="slugs", help="Limit linting to the provided slug")
parser.add_argument("--path", action="append", dest="paths", help="Infer slugs from changed file paths")
parser.add_argument("--git-diff", default=None, help="Infer paths from git diff target (e.g. HEAD)")
parser.add_argument("--strict", action="store_true", help="Treat placeholder warnings as errors")
parser.add_argument("--base-prefix", default=DEFAULT_BASE_IMAGE_PREFIX, help="Expected base image prefix")
return parser.parse_args()
def load_manifest(manifest_path: pathlib.Path, schema: Dict[str, object]) -> Tuple[Dict[str, object] | None, List[Issue]]:
issues: List[Issue] = []
if not manifest_path.exists():
issues.append(Issue("error", "missing CloudronManifest.json"))
return None, issues
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
data = json.loads(manifest_path.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
issues.append(Issue("error", f"manifest JSON invalid: {exc}"))
return None, issues
try:
jsonschema.validate(data, schema)
except jsonschema.ValidationError as exc:
issues.append(Issue("error", f"manifest schema violation: {exc.message}"))
if "TODO" in data.get("tagline", ""):
issues.append(Issue("warning", "manifest tagline still contains TODO placeholder"))
if "TODO" in data.get("description", ""):
issues.append(Issue("warning", "manifest description still contains TODO placeholder"))
return data, issues
def check_dockerfile(app_dir: pathlib.Path) -> List[str]:
issues: List[str] = []
dockerfile = app_dir / "Dockerfile"
def parse_dockerfile(dockerfile: pathlib.Path) -> Tuple[str | None, List[str]]:
import re
if not dockerfile.exists():
issues.append("missing Dockerfile")
return issues
first_from = None
return None, ["missing Dockerfile"]
stage_sources: Dict[str, str] = {}
stage_order: List[str] = []
final_reference: str | None = None
pattern = re.compile(
r"^FROM\s+(?:(?:--[\w=/\-:.]+\s+)+)?(?P<image>[\w./:@+-]+)(?:\s+AS\s+(?P<alias>[A-Za-z0-9_\-\.]+))?",
re.IGNORECASE,
)
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
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not stripped.upper().startswith("FROM"):
continue
match = pattern.match(stripped)
if not match:
continue
image = match.group("image")
alias = match.group("alias")
stage_name = alias or f"__stage_{len(stage_order)}"
stage_sources[stage_name] = image
stage_order.append(stage_name)
final_reference = stage_name
if final_reference is None:
return None, ["Dockerfile does not define any FROM instructions"]
return resolve_final_image(final_reference, stage_sources), []
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")
def resolve_final_image(stage: str, stage_sources: Dict[str, str]) -> str:
seen = set()
current = stage
while True:
source = stage_sources.get(current)
if source is None:
return current
if source not in stage_sources:
return source
if source in seen:
# Circular reference; return last known reference to avoid infinite loop
return source
seen.add(source)
current = source
def lint_dockerfile(dockerfile: pathlib.Path, base_prefix: str) -> List[Issue]:
issues: List[Issue] = []
final_image, errors = parse_dockerfile(dockerfile)
if errors:
for err in errors:
issues.append(Issue("error", err))
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")
assert final_image is not None
if not final_image.startswith(base_prefix):
issues.append(
Issue(
"error",
f"final Docker image '{final_image}' does not use expected base prefix '{base_prefix}'",
)
)
content = dockerfile.read_text(encoding="utf-8")
if "chown -R cloudron:cloudron" not in content:
issues.append(Issue("warning", "Dockerfile missing chown step for /app"))
return issues
def main() -> int:
def lint_start_script(start_script: pathlib.Path) -> List[Issue]:
issues: List[Issue] = []
if not start_script.exists():
issues.append(Issue("error", "missing start.sh"))
return issues
if not os.access(start_script, os.X_OK):
issues.append(Issue("error", "start.sh is not executable"))
text = start_script.read_text(encoding="utf-8")
if "not implemented" in text:
issues.append(Issue("warning", "start.sh still contains not-implemented placeholder"))
return issues
def lint_app(app_dir: pathlib.Path, base_prefix: str, schema: Dict[str, object]) -> List[Issue]:
_, manifest_issues = load_manifest(app_dir / "CloudronManifest.json", schema)
dockerfile_issues = lint_dockerfile(app_dir / "Dockerfile", base_prefix)
start_issues = lint_start_script(app_dir / "start.sh")
return manifest_issues + dockerfile_issues + start_issues
def select_slugs(apps_dir: pathlib.Path, args: argparse.Namespace) -> List[str]:
slugs = set(args.slugs or [])
if args.paths:
slugs.update(resolve_slugs_from_paths(args.paths))
if args.git_diff:
slugs.update(resolve_slugs_from_paths(collect_paths_from_git(args.git_diff)))
if not slugs:
return [path.name for path in list_apps(apps_dir)]
return sorted(slugs)
def main() -> None:
args = parse_args()
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
schema = load_schema()
slugs = select_slugs(apps_dir, args)
hard_failures = 0
for slug in slugs:
app_dir = apps_dir / slug
if not app_dir.exists():
print(f"[SKIP] {slug}: directory does not exist")
continue
issues = lint_app(app_dir, args.base_prefix, schema)
if not issues:
print(f"[OK] {slug}")
continue
print(f"[ISSUES] {slug}")
for issue in issues:
print(f" - {issue}")
for issue in issues:
if issue.severity == "error" or (issue.severity == "warning" and args.strict):
hard_failures += 1
if hard_failures:
raise SystemExit(f"Lint failed with {hard_failures} blocking issue(s)")
if __name__ == "__main__":
sys.exit(main())
main()