App Store install/update pipeline reliability fixes: - explicit nameserver (APPSTORE_NAMESERVER) on App Store LXCs and the golden template build CT, instead of inheriting a node resolver that may be unreachable from the container network (e.g. Tailscale-only DNS) - golden template storage/resolution moved from one global setting to per-server_id (migration 37: app_operations.server_id) — building it on one Proxmox server no longer overwrites the template configured for another - golden template automated build fixed: vzdump export step no longer fails on "unable to parse directory volume name 'vztmpl/probe'" - bind-mount host directories are pre-created with permissive ownership before `docker compose up`, fixing Permission denied for non-root containers (e.g. MariaDB) writing to Docker-auto-created root-owned dirs - delete/teardown now waits for Proxmox to actually confirm the destroy task finished (was silently proceeding after a short timeout, letting a new install reuse the VMID while the node was still wiping the old disk) - pct create retries a few times on transient "got lock" config errors - failed pipeline steps now report the tail of command output (4000 chars) instead of the head, so the real error isn't buried under pull/progress noise - install error box in the UI wraps long text and scrolls instead of stretching the dialog Also carries forward in-progress script catalog / instances / RBAC changes that were already uncommitted in the working tree before this session. Bump to v1.9.1, update WIKI.md/docs/CHANGELOG accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
8.5 KiB
Python
218 lines
8.5 KiB
Python
"""
|
||
Синхронизация каталога скриптов из git-репозитория (аналог appstore_catalog.py
|
||
sync_catalog(), но проще: bash-файлы с метаданными в заголовочных комментариях
|
||
вместо отдельных манифестов).
|
||
|
||
Конвенция метаданных (первые строки файла, до первой не-комментарной строки
|
||
после shebang):
|
||
|
||
#!/bin/bash
|
||
# name: Очистка старых логов
|
||
# description: Удаляет журналы старше 30 дней в /var/log
|
||
# target: node
|
||
# category: maintenance
|
||
# param: DAYS|Дней хранить|number|false|30
|
||
|
||
Файлы без `# name:` каталогизируются по имени файла.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import glob as _glob
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
from typing import Dict, List, Optional
|
||
|
||
from loguru import logger
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..config import utcnow
|
||
from ..models import ScriptCatalog, ScriptGitRepo
|
||
|
||
_META_RE = re.compile(r"^#\s*(name|description|target|category)\s*:\s*(.*)$", re.IGNORECASE)
|
||
_PARAM_RE = re.compile(r"^#\s*param\s*:\s*(.*)$", re.IGNORECASE)
|
||
|
||
_CS_APP_RE = re.compile(r'^APP="([^"]+)"')
|
||
_CS_VAR_RE = re.compile(r'^(var_\w+)="\$\{var_\w+:-([^}]*)\}"$')
|
||
|
||
|
||
def _parse_script_metadata(text: str, fallback_name: str) -> dict:
|
||
name = fallback_name
|
||
description = None
|
||
target = "guest"
|
||
category = None
|
||
params: List[dict] = []
|
||
|
||
for line in text.splitlines()[:60]:
|
||
stripped = line.strip()
|
||
if not stripped.startswith("#"):
|
||
if stripped and not stripped.startswith("#!"):
|
||
break
|
||
continue
|
||
m = _META_RE.match(stripped)
|
||
if m:
|
||
key, val = m.group(1).lower(), m.group(2).strip()
|
||
if key == "name":
|
||
name = val
|
||
elif key == "description":
|
||
description = val
|
||
elif key == "target":
|
||
target = val if val in ("node", "guest") else "guest"
|
||
elif key == "category":
|
||
category = val
|
||
continue
|
||
pm = _PARAM_RE.match(stripped)
|
||
if pm:
|
||
parts = [p.strip() for p in pm.group(1).split("|")]
|
||
# name|label|type|required|default
|
||
p_name = parts[0] if len(parts) > 0 else None
|
||
if not p_name:
|
||
continue
|
||
params.append({
|
||
"name": p_name,
|
||
"label": parts[1] if len(parts) > 1 and parts[1] else p_name,
|
||
"type": parts[2] if len(parts) > 2 and parts[2] else "string",
|
||
"required": (parts[3].lower() == "true") if len(parts) > 3 else False,
|
||
"default": parts[4] if len(parts) > 4 else None,
|
||
})
|
||
|
||
return {
|
||
"name": name,
|
||
"description": description,
|
||
"target_type": target,
|
||
"category": category,
|
||
"params_schema": params,
|
||
}
|
||
|
||
|
||
def _parse_community_scripts_metadata(text: str, fallback_name: str) -> dict:
|
||
"""Метаданные ct/*.sh из community-scripts/ProxmoxVE: APP="..." + var_X="${var_X:-default}".
|
||
|
||
В самих ct-скриптах нет полей name/description в привычном виде: имя
|
||
приложения берётся из `APP=`, а параметры — из строк `var_X="${var_X:-DEF}"`
|
||
(тот самый bash-паттерн "переменная окружения с дефолтом", который upstream
|
||
читает при headless-запуске). Эти var_X переносятся в params_schema один в
|
||
один по имени, чтобы существующий execute-движок (export VAR=value перед
|
||
телом скрипта) сработал без изменений.
|
||
"""
|
||
name = fallback_name
|
||
params: List[dict] = []
|
||
|
||
for line in text.splitlines():
|
||
stripped = line.strip()
|
||
m = _CS_APP_RE.match(stripped)
|
||
if m:
|
||
name = m.group(1)
|
||
continue
|
||
pm = _CS_VAR_RE.match(stripped)
|
||
if pm:
|
||
p_name, default = pm.group(1), pm.group(2)
|
||
label = p_name.removeprefix("var_").replace("_", " ").capitalize()
|
||
params.append({
|
||
"name": p_name,
|
||
"label": label,
|
||
"type": "number" if default.lstrip("-").isdigit() else "string",
|
||
"required": False,
|
||
"default": default,
|
||
})
|
||
|
||
return {
|
||
"name": name,
|
||
"description": f"Community-Scripts LXC installer: {name}",
|
||
"target_type": "node",
|
||
"category": "LXC (community-scripts)",
|
||
"params_schema": params,
|
||
}
|
||
|
||
|
||
def _clone_repo(repo: ScriptGitRepo, dest: str) -> None:
|
||
subprocess.run(
|
||
["git", "clone", "--depth", "1", "--branch", repo.branch, repo.url, dest],
|
||
check=True, capture_output=True, timeout=120, text=True,
|
||
)
|
||
|
||
|
||
def sync_git_repo(db: Session, repo: ScriptGitRepo) -> dict:
|
||
"""Синхронизировать один git-источник. Идемпотентно (upsert по slug)."""
|
||
created = updated = skipped = errors = disappeared = 0
|
||
tmp_dir = tempfile.mkdtemp(prefix="pve_script_repo_")
|
||
try:
|
||
try:
|
||
_clone_repo(repo, tmp_dir)
|
||
except subprocess.CalledProcessError as e:
|
||
err = (e.stderr or str(e))[:1000]
|
||
repo.last_sync_error = err
|
||
db.commit()
|
||
return {"error": err, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||
except subprocess.TimeoutExpired:
|
||
repo.last_sync_error = "git clone timeout (>120s)"
|
||
db.commit()
|
||
return {"error": repo.last_sync_error, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||
|
||
pattern = os.path.join(tmp_dir, repo.path_glob)
|
||
files = _glob.glob(pattern, recursive=True)
|
||
|
||
is_community_scripts = repo.metadata_format == "community-scripts-ct"
|
||
record_source = "community-scripts" if is_community_scripts else "git"
|
||
|
||
seen_slugs: List[str] = []
|
||
for path in files:
|
||
rel_path = os.path.relpath(path, tmp_dir)
|
||
slug = f"git-{repo.id}-{rel_path}".replace("/", "-").replace("\\", "-")
|
||
try:
|
||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||
content = f.read()
|
||
except Exception as e:
|
||
errors += 1
|
||
logger.warning(f"[script-sync] read {rel_path} failed: {e}")
|
||
continue
|
||
|
||
if is_community_scripts:
|
||
meta = _parse_community_scripts_metadata(content, fallback_name=os.path.basename(rel_path))
|
||
else:
|
||
meta = _parse_script_metadata(content, fallback_name=os.path.basename(rel_path))
|
||
|
||
existing = db.query(ScriptCatalog).filter(ScriptCatalog.slug == slug).first()
|
||
if existing:
|
||
existing.name = meta["name"]
|
||
existing.description = meta["description"]
|
||
existing.target_type = meta["target_type"]
|
||
existing.category = meta["category"]
|
||
existing.params_schema = meta["params_schema"]
|
||
existing.content = content
|
||
existing.git_path = rel_path
|
||
existing.source = record_source
|
||
updated += 1
|
||
else:
|
||
db.add(ScriptCatalog(
|
||
slug=slug, name=meta["name"], description=meta["description"],
|
||
target_type=meta["target_type"], category=meta["category"],
|
||
params_schema=meta["params_schema"], content=content,
|
||
source=record_source, git_repo_id=repo.id, git_path=rel_path,
|
||
))
|
||
created += 1
|
||
seen_slugs.append(slug)
|
||
db.commit()
|
||
|
||
# Удалить из этого источника то, что исчезло из репозитория.
|
||
stale = db.query(ScriptCatalog).filter(
|
||
ScriptCatalog.git_repo_id == repo.id,
|
||
~ScriptCatalog.slug.in_(seen_slugs) if seen_slugs else True,
|
||
).all()
|
||
for s in stale:
|
||
db.delete(s)
|
||
disappeared += 1
|
||
db.commit()
|
||
|
||
repo.last_synced_at = utcnow()
|
||
repo.last_sync_error = None
|
||
db.commit()
|
||
|
||
return {"created": created, "updated": updated, "skipped": skipped,
|
||
"errors": errors, "disappeared": disappeared}
|
||
finally:
|
||
shutil.rmtree(tmp_dir, ignore_errors=True)
|