mirror of
https://github.com/okxlin/appstore.git
synced 2026-09-23 16:01:01 +00:00
201 lines
6.5 KiB
Python
Executable File
201 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
SPECIAL_KEYS = ("privileged", "network_mode", "runtime", "pid", "ipc", "devices", "cap_add")
|
|
|
|
|
|
def load_apps(args: argparse.Namespace) -> list[str]:
|
|
apps = list(args.app or [])
|
|
if args.apps_file:
|
|
for line in Path(args.apps_file).read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
apps.append(line)
|
|
unique: list[str] = []
|
|
seen: set[str] = set()
|
|
for app in apps:
|
|
if app not in seen:
|
|
unique.append(app)
|
|
seen.add(app)
|
|
return unique
|
|
|
|
|
|
def load_yaml(path: Path) -> dict:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def normalize_compose(data: dict) -> dict:
|
|
normalized = deepcopy(data)
|
|
normalized.pop("version", None)
|
|
services = normalized.get("services")
|
|
if isinstance(services, dict):
|
|
for service in services.values():
|
|
if isinstance(service, dict):
|
|
service.pop("image", None)
|
|
return normalized
|
|
|
|
|
|
def summarize_upgrade(path: Path) -> bool:
|
|
body_lines = []
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#!") or line.startswith("#") or line.startswith("set "):
|
|
continue
|
|
body_lines.append(line)
|
|
body = "\n".join(body_lines).strip()
|
|
return bool(body and body != "exit 0")
|
|
|
|
|
|
def collect_special_flags(compose_path: Path, data: dict) -> list[str]:
|
|
findings: list[str] = []
|
|
services = data.get("services")
|
|
if not isinstance(services, dict):
|
|
return findings
|
|
for service_name, service in services.items():
|
|
if not isinstance(service, dict):
|
|
continue
|
|
for key in SPECIAL_KEYS:
|
|
if key in service:
|
|
findings.append(f"{compose_path.parent.name}:{service_name}:{key}={service[key]}")
|
|
return findings
|
|
|
|
|
|
def audit_app(repo_root: Path, app: str) -> dict:
|
|
app_dir = repo_root / "apps" / app
|
|
result = {
|
|
"app": app,
|
|
"exists": app_dir.exists(),
|
|
"compose_files": [],
|
|
"compose_shape_stable": None,
|
|
"max_services": 0,
|
|
"single_service_preferred": None,
|
|
"special_flags": [],
|
|
"nontrivial_upgrade": False,
|
|
"upgrade_scripts": [],
|
|
"eligible_by_heuristic": False,
|
|
"notes": [],
|
|
}
|
|
if not app_dir.exists():
|
|
result["notes"].append("app directory missing")
|
|
return result
|
|
|
|
compose_paths = sorted(app_dir.glob("*/docker-compose.yml"))
|
|
result["compose_files"] = [str(path.relative_to(repo_root)) for path in compose_paths]
|
|
if not compose_paths:
|
|
result["notes"].append("no docker-compose.yml found")
|
|
return result
|
|
|
|
normalized_shapes = []
|
|
for compose_path in compose_paths:
|
|
data = load_yaml(compose_path)
|
|
services = data.get("services")
|
|
count = len(services) if isinstance(services, dict) else 0
|
|
result["max_services"] = max(result["max_services"], count)
|
|
result["special_flags"].extend(collect_special_flags(compose_path, data))
|
|
normalized_shapes.append(normalize_compose(data))
|
|
|
|
result["single_service_preferred"] = result["max_services"] == 1
|
|
result["compose_shape_stable"] = all(shape == normalized_shapes[0] for shape in normalized_shapes[1:])
|
|
|
|
for upgrade_path in sorted(app_dir.glob("*/scripts/upgrade.sh")):
|
|
if summarize_upgrade(upgrade_path):
|
|
result["nontrivial_upgrade"] = True
|
|
result["upgrade_scripts"].append(str(upgrade_path.relative_to(repo_root)))
|
|
|
|
if result["max_services"] != 1:
|
|
result["notes"].append("multi-service app")
|
|
if result["compose_shape_stable"] is False:
|
|
result["notes"].append("compose shape changed across store snapshots")
|
|
if result["special_flags"]:
|
|
result["notes"].append("special runtime/network privileges present")
|
|
if result["nontrivial_upgrade"]:
|
|
result["notes"].append("non-trivial upgrade migration present (advisory)")
|
|
|
|
result["eligible_by_heuristic"] = (
|
|
result["max_services"] == 1
|
|
and result["compose_shape_stable"] is True
|
|
and not result["special_flags"]
|
|
)
|
|
if result["eligible_by_heuristic"]:
|
|
result["notes"].append("single-service stable candidate")
|
|
return result
|
|
|
|
|
|
def print_table(results: list[dict], only_problems: bool) -> None:
|
|
print(
|
|
"app\teligible\tcompose_stable\tmax_services\tspecial_flags\tnontrivial_upgrade\tnotes"
|
|
)
|
|
for item in results:
|
|
if only_problems and item["eligible_by_heuristic"]:
|
|
continue
|
|
notes = "; ".join(item["notes"]) if item["notes"] else "-"
|
|
print(
|
|
"\t".join(
|
|
[
|
|
item["app"],
|
|
str(item["eligible_by_heuristic"]).lower(),
|
|
str(item["compose_shape_stable"]).lower(),
|
|
str(item["max_services"]),
|
|
str(bool(item["special_flags"])).lower(),
|
|
str(item["nontrivial_upgrade"]).lower(),
|
|
notes,
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Audit appstore Renovate automerge whitelist candidates."
|
|
)
|
|
parser.add_argument(
|
|
"--repo-root",
|
|
default=".",
|
|
help="Path to the appstore repository root (default: current directory).",
|
|
)
|
|
parser.add_argument(
|
|
"--apps-file",
|
|
help="Read app names from a whitelist file, ignoring blank lines and comments.",
|
|
)
|
|
parser.add_argument(
|
|
"--app",
|
|
action="append",
|
|
help="Audit a specific app key. Can be passed multiple times.",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=("table", "json"),
|
|
default="table",
|
|
help="Output format.",
|
|
)
|
|
parser.add_argument(
|
|
"--only-problems",
|
|
action="store_true",
|
|
help="In table output, hide apps that already pass the heuristic.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
apps = load_apps(args)
|
|
if not apps:
|
|
parser.error("provide --app or --apps-file")
|
|
|
|
repo_root = Path(args.repo_root).resolve()
|
|
results = [audit_app(repo_root, app) for app in apps]
|
|
|
|
if args.format == "json":
|
|
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
else:
|
|
print_table(results, args.only_problems)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|