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>
165 lines
7.4 KiB
Python
165 lines
7.4 KiB
Python
from .base import *
|
|
|
|
# target_type скрипта: где он выполняется.
|
|
# "node" — на хосте Proxmox (root@node по SSH)
|
|
# "guest" — внутри VM (QEMU guest agent) или LXC (pct exec через SSH ноды)
|
|
SCRIPT_TARGET_TYPES = ("node", "guest")
|
|
SCRIPT_SOURCES = ("manual", "git", "community-scripts")
|
|
SCRIPT_EXECUTION_STATUSES = ("running", "success", "failed")
|
|
# Формат метаданных, по которому синкается git-источник:
|
|
# "header-comment" — заголовочные bash-комментарии (# name: / # param: ...)
|
|
# "community-scripts-ct" — ct/*.sh из community-scripts/ProxmoxVE (APP=, var_*)
|
|
SCRIPT_REPO_METADATA_FORMATS = ("header-comment", "community-scripts-ct")
|
|
|
|
|
|
class ScriptGitRepo(Base):
|
|
"""Git-источник каталога скриптов (аналог CatalogProvider для appstore)."""
|
|
__tablename__ = "script_git_repos"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
url = Column(String(500), nullable=False)
|
|
branch = Column(String(100), nullable=False, default="main")
|
|
# glob относительно корня репозитория, напр. "scripts/**/*.sh"
|
|
path_glob = Column(String(300), nullable=False, default="**/*.sh")
|
|
# см. SCRIPT_REPO_METADATA_FORMATS
|
|
metadata_format = Column(String(30), nullable=False, default="header-comment")
|
|
enabled = Column(Boolean, nullable=False, default=True)
|
|
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
|
last_sync_error = Column(Text, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"url": self.url,
|
|
"branch": self.branch,
|
|
"path_glob": self.path_glob,
|
|
"metadata_format": self.metadata_format,
|
|
"enabled": self.enabled,
|
|
"last_synced_at": self.last_synced_at.isoformat() if self.last_synced_at else None,
|
|
"last_sync_error": self.last_sync_error,
|
|
}
|
|
|
|
def __repr__(self):
|
|
return f"<ScriptGitRepo(id={self.id}, url='{self.url}')>"
|
|
|
|
|
|
class ScriptCatalog(Base):
|
|
"""Скрипт каталога — свой (manual) или синхронизированный из git."""
|
|
__tablename__ = "script_catalog"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(200), nullable=False)
|
|
slug = Column(String(200), nullable=False, unique=True, index=True)
|
|
description = Column(Text, nullable=True)
|
|
category = Column(String(100), nullable=True, index=True)
|
|
|
|
target_type = Column(String(20), nullable=False, default="guest") # node | guest
|
|
interpreter = Column(String(100), nullable=False, default="/bin/bash")
|
|
content = Column(Text, nullable=False)
|
|
|
|
# [{name, label, type: string|number|bool, required, default}]
|
|
params_schema = Column(JSON, nullable=False, default=list)
|
|
|
|
source = Column(String(20), nullable=False, default="manual", index=True) # manual | git
|
|
git_repo_id = Column(Integer, ForeignKey("script_git_repos.id", ondelete="CASCADE"), nullable=True, index=True)
|
|
git_path = Column(String(500), nullable=True)
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
|
|
|
def to_dict(self, *, with_content: bool = False) -> dict:
|
|
data = {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"slug": self.slug,
|
|
"description": self.description,
|
|
"category": self.category,
|
|
"target_type": self.target_type,
|
|
"interpreter": self.interpreter,
|
|
"params_schema": self.params_schema or [],
|
|
"source": self.source,
|
|
"git_repo_id": self.git_repo_id,
|
|
"git_path": self.git_path,
|
|
"created_by": self.created_by,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if with_content:
|
|
data["content"] = self.content
|
|
return data
|
|
|
|
def __repr__(self):
|
|
return f"<ScriptCatalog(id={self.id}, slug='{self.slug}', target_type='{self.target_type}')>"
|
|
|
|
|
|
SCRIPT_EXECUTION_OUTPUT_LIMIT = 200_000 # символов, защита от раздувания БД
|
|
|
|
|
|
class ScriptExecution(Base):
|
|
"""Журнал запуска скрипта (аналог AppOperation)."""
|
|
__tablename__ = "script_executions"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
script_id = Column(Integer, ForeignKey("script_catalog.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
script_name = Column(String(200), nullable=True) # снимок имени на момент запуска
|
|
|
|
target_type = Column(String(20), nullable=False) # node | guest
|
|
server_id = Column(Integer, nullable=True, index=True)
|
|
node = Column(String(100), nullable=True)
|
|
vmid = Column(Integer, nullable=True) # None для target_type="node"
|
|
|
|
params_used = Column(JSON, nullable=False, default=dict)
|
|
status = Column(String(20), nullable=False, default="running", index=True)
|
|
exit_code = Column(Integer, nullable=True)
|
|
output = Column(Text, nullable=True)
|
|
error_text = Column(Text, nullable=True)
|
|
|
|
user_id = Column(Integer, nullable=False, index=True)
|
|
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"kind": "script",
|
|
"script_id": self.script_id,
|
|
"script_name": self.script_name,
|
|
"target_type": self.target_type,
|
|
"server_id": self.server_id,
|
|
"node": self.node,
|
|
"vmid": self.vmid,
|
|
"params_used": self.params_used or {},
|
|
"status": self.status,
|
|
"exit_code": self.exit_code,
|
|
"output": self.output,
|
|
"error_text": self.error_text,
|
|
"user_id": self.user_id,
|
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
|
}
|
|
|
|
def to_task_dict(self) -> dict:
|
|
"""Представление для общего списка «Задачи» (/api/all-tasks)."""
|
|
return {
|
|
"id": self.id,
|
|
"kind": "script",
|
|
"type": "script_execution",
|
|
"description": f"Скрипт: {self.script_name or self.script_id}",
|
|
"status": self.status,
|
|
"progress": 100 if self.status != "running" else 50,
|
|
"step": None,
|
|
"current_step": None,
|
|
"error_message": self.error_text,
|
|
"user_id": self.user_id,
|
|
"node": self.node,
|
|
"server_id": self.server_id,
|
|
"created_at": self.started_at.isoformat() if self.started_at else None,
|
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
"completed_at": self.finished_at.isoformat() if self.finished_at else None,
|
|
}
|
|
|
|
def __repr__(self):
|
|
return f"<ScriptExecution(id={self.id}, script_id={self.script_id}, status='{self.status}')>"
|