Files
docker-compose-cookbooks/_build/portainer_template_build.py
T
2025-11-26 20:42:56 -08:00

199 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""
Portainer templates generator (Portainer CE compatible)
Usage:
python3 _build/portainer_template_build.py [--nologo] [--branch BRANCH] [--output PATH] [--no-skip-volpath]
Defaults:
branch = master
output = ./templates.json
skips projects containing "${VOL_PATH" by default (use --no-skip-volpath to disable)
Note: Branch is used only for logo asset lookup; templates use default repo branch (master).
"""
from pathlib import Path
import argparse
import json
import re
import requests
import sys
from typing import List, Dict
# -------------------------
# Config / Defaults
# -------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
DEFAULT_OUTPUT = REPO_ROOT / "templates.json"
ASSETS_BASE = "https://i.4lt.ca/cookbooks"
GITHUB_RAW_BASE_TEMPLATE = "https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
GITHUB_OWNER = "nickyeoman"
GITHUB_REPO = "docker-compose-cookbooks"
DEFAULT_BRANCH = "master"
SKIP_VOLPATH_PATTERN = "${VOL_PATH"
# Regex
VAR_RE = re.compile(r"\$\{([^:}]+)(?:[:-]([^}]+))?\}")
# -------------------------
# Helpers
# -------------------------
def read_text_safe(p: Path) -> str:
try:
return p.read_text()
except Exception:
return ""
def write_atomic(path: Path, data: str):
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(data)
tmp.replace(path)
def slugify_for_name(s: str) -> str:
# produce a safe component for volume names etc.
s = s.strip().lower()
s = re.sub(r"[^a-z0-9_\-]+", "-", s)
s = re.sub(r"-{2,}", "-", s)
s = s.strip("-")
if not s:
return "x"
return s
# -------------------------
# README Overview extraction
# -------------------------
def extract_overview(readme_path: Path, project_name: str) -> str:
text = read_text_safe(readme_path)
if not text:
return f"{project_name} description to come"
lines = text.splitlines()
overview = []
in_section = False
for line in lines:
stripped = line.strip()
if stripped == "## Overview":
in_section = True
continue
if in_section:
if stripped.startswith("#"):
break
if stripped:
overview.append(stripped)
if not overview:
return f"{project_name} description to come"
return " ".join(overview)
# -------------------------
# Logo lookup (HEAD check)
# -------------------------
def find_logo_url(dir_path: Path, nologo: bool, branch: str) -> str:
name = dir_path.name
# Use branch for asset if needed, but since assets are static, ignore branch here
logo = f"{ASSETS_BASE}/{name}.png"
default = f"{ASSETS_BASE}/default.png"
if nologo:
return default
try:
r = requests.head(logo, timeout=4)
if r.status_code == 200:
return logo
except Exception:
pass
return default
# -------------------------
# Parse environment variables from sample.env/env-sample
# -------------------------
def parse_env_vars(dir_path: Path) -> List[Dict[str,str]]:
candidates = [dir_path / "sample.env", dir_path / "env-sample"]
env_file = None
for c in candidates:
if c.exists():
env_file = c
break
if not env_file:
return []
out = []
for raw in read_text_safe(env_file).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, val = line.split("=", 1)
key = key.strip()
# strip inline comment after #
val = val.split("#", 1)[0].strip()
val = val.strip().strip('"').strip("'")
out.append({"name": key, "default": val})
return out
# -------------------------
# Build Portainer template object
# -------------------------
def generate_template_object(dir_path: Path, branch: str, nologo: bool) -> Dict:
name = dir_path.name
readme_path = dir_path / "README.md"
env_vars = parse_env_vars(dir_path)
obj = {
"type": 3,
"title": name,
"name": name,
"note": "Auto-generated template from repository",
"categories": ["Auto"],
"platform": "linux",
"logo": find_logo_url(dir_path, nologo=nologo, branch=branch),
"description": extract_overview(readme_path, name),
# Git repository for Compose stack
"repository": {
"url": f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}",
"stackfile": f"{name}/docker-compose.yml"
}
}
# env
env_entries = []
for v in env_vars:
if not v.get("name"):
continue
env_entries.append({"name": v["name"], "label": v["name"], "default": v.get("default","")})
if env_entries:
obj["env"] = env_entries
return obj
# -------------------------
# Main
# -------------------------
def main():
parser = argparse.ArgumentParser(description="Generate Portainer templates.json from compose cookbooks")
parser.add_argument("--nologo", action="store_true", help="Skip HEAD check for logos (faster)")
parser.add_argument("--branch", default=DEFAULT_BRANCH, help="Git branch to use for logo lookup (default: master). Templates use repo default branch.")
parser.add_argument("--output", default=str(DEFAULT_OUTPUT), help="Output path for templates.json")
parser.add_argument("--no-skip-volpath", action="store_true", help="Don't skip projects containing ${VOL_PATH")
args = parser.parse_args()
out_path = Path(args.output).resolve()
branch = args.branch
nologo = args.nologo
skip_volpath = not args.no_skip_volpath
templates = []
skipped = []
errored = []
for item in sorted(REPO_ROOT.iterdir()):
if not item.is_dir():
continue
if item.name.startswith("_"):
continue
if item.name.endswith(("_dev", "_notes")):
continue
compose_path = item / "docker-compose.yml"
if not compose_path.exists():
continue
text = read_text_safe(compose_path)
if skip_volpath and SKIP_VOLPATH_PATTERN in text:
skipped.append((item.name, "uses ${VOL_PATH}"))
continue
try:
tpl = generate_template_object(item, branch=branch, nologo=nologo)
templates.append(tpl)
except Exception as e:
errored.append((item.name, str(e)))
output = {"version": "2", "templates": templates}
# write atomic
write_atomic(out_path, json.dumps(output, indent=2))
print(f"Generated {out_path} with {len(templates)} templates.")
if skipped:
print("Skipped projects:")
for n, reason in skipped:
print(f" - {n}: {reason}")
if errored:
print("Errored projects:")
for n, err in errored:
print(f" - {n}: {err}")
if __name__ == "__main__":
main()