#!/usr/bin/env python3 """Lint Cloudron app scaffolds.""" from __future__ import annotations import argparse import json import os import pathlib 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] SCHEMA_PATH = ROOT / "schema" / "cloudron-manifest.schema.json" DEFAULT_BASE_IMAGE_PREFIX = "cloudron/base" @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 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_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: 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 parse_dockerfile(dockerfile: pathlib.Path) -> Tuple[str | None, List[str]]: import re if not dockerfile.exists(): 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[\w./:@+-]+)(?:\s+AS\s+(?P[A-Za-z0-9_\-\.]+))?", re.IGNORECASE, ) for line in dockerfile.read_text(encoding="utf-8").splitlines(): 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 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 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 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" 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__": main()