Files
pvemanager/backend/app/services/script_catalog_sync.py
markmorado 6a79dd2048
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m1s
Deploy to Production / Trigger production update (push) Successful in 8s
fix(appstore): DNS, per-server golden template, bind-mount perms, delete race
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>
2026-07-17 04:49:40 +05:00

218 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Синхронизация каталога скриптов из 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)