237 lines
8.0 KiB
Python
Executable File
237 lines
8.0 KiB
Python
Executable File
#!/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<image>[\w./:@+-]+)(?:\s+AS\s+(?P<alias>[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()
|