mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-30 02:26:53 +00:00
Fix the script terminal, Arr suite updates and OCI app tracking
This commit is contained in:
@@ -1773,7 +1773,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
|
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
|
||||||
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
|
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
|
||||||
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
|
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
|
||||||
<SelectItem value="docker_label" disabled>{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
|
{/* Chosen by detection for a Docker workload; kept only so an
|
||||||
|
application already using it shows its method. */}
|
||||||
|
{method === "docker_label" && (
|
||||||
|
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
||||||
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
||||||
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ const initMessage = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "cancel") {
|
if (value === "cancel" || value === "") {
|
||||||
setCurrentInteraction(null)
|
setCurrentInteraction(null)
|
||||||
setInteractionInput("")
|
setInteractionInput("")
|
||||||
handleCloseModal()
|
handleCloseModal()
|
||||||
@@ -755,7 +755,7 @@ const initMessage = {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1427,6 +1427,16 @@ export function VirtualMachines() {
|
|||||||
setOciInstance(null)
|
setOciInstance(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// An operation that is still running when the modal opens clears on its
|
||||||
|
// own: the record is read again until it is published.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ociInstance?.pending || !selectedVM) return
|
||||||
|
const vmid = selectedVM.vmid
|
||||||
|
const timer = setInterval(() => fetchOciInstance(vmid), 15000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ociInstance?.pending, selectedVM?.vmid])
|
||||||
|
|
||||||
const fetchMountPoints = async (vmid: number) => {
|
const fetchMountPoints = async (vmid: number) => {
|
||||||
// Two fetches in parallel:
|
// Two fetches in parallel:
|
||||||
// 1) STATIC — configured mp entries + PVE classification. Backed
|
// 1) STATIC — configured mp entries + PVE classification. Backed
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
"used": "Usado",
|
"used": "Usado",
|
||||||
"cached": "En caché",
|
"cached": "En caché",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"free": "Gratis",
|
"free": "Libre",
|
||||||
"activeVmLxc": "VM y LXC activos",
|
"activeVmLxc": "VM y LXC activos",
|
||||||
"runningCount": "{count} en ejecución",
|
"runningCount": "{count} en ejecución",
|
||||||
"vmsCount": "{count} VM",
|
"vmsCount": "{count} VM",
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
"zfsPools": "Grupos ZFS",
|
"zfsPools": "Grupos ZFS",
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
"remote": "Remoto",
|
"remote": "Remoto",
|
||||||
"free": "Gratis",
|
"free": "Libre",
|
||||||
"used": "Usado",
|
"used": "Usado",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"available": "Disponible",
|
"available": "Disponible",
|
||||||
@@ -1133,7 +1133,7 @@
|
|||||||
"description": "Configure las opciones de copia de seguridad para este {type}",
|
"description": "Configure las opciones de copia de seguridad para este {type}",
|
||||||
"storage": "Almacenamiento",
|
"storage": "Almacenamiento",
|
||||||
"selectStorage": "Seleccionar almacenamiento",
|
"selectStorage": "Seleccionar almacenamiento",
|
||||||
"free": "gratis",
|
"free": "libre",
|
||||||
"mode": "Modo",
|
"mode": "Modo",
|
||||||
"notification": "Notificación",
|
"notification": "Notificación",
|
||||||
"useGlobalSettings": "Usar configuración global",
|
"useGlobalSettings": "Usar configuración global",
|
||||||
@@ -1504,14 +1504,14 @@
|
|||||||
"optionalSuffix": "(opcional)",
|
"optionalSuffix": "(opcional)",
|
||||||
"methodNone": "Ninguno (solo enlace)",
|
"methodNone": "Ninguno (solo enlace)",
|
||||||
"methodDpkg": "Paquete dpkg (Debian/Ubuntu)",
|
"methodDpkg": "Paquete dpkg (Debian/Ubuntu)",
|
||||||
"methodApk": "paquete apk (alpino)",
|
"methodApk": "Paquete apk (Alpine)",
|
||||||
"methodBinary": "Binario (ruta absoluta o nombre simple)",
|
"methodBinary": "Binario (ruta absoluta o nombre simple)",
|
||||||
"methodFile": "archivo + expresión regular",
|
"methodFile": "Archivo + expresión regular",
|
||||||
"methodDockerLabel": "docker inspect (etiqueta OCI)",
|
"methodDockerLabel": "docker inspect (etiqueta OCI)",
|
||||||
"methodDockerExec": "docker exec (binario en contenedor)",
|
"methodDockerExec": "docker exec (binario en contenedor)",
|
||||||
"methodPython": "distribución de Python (importlib.metadata)",
|
"methodPython": "Distribución de Python (importlib.metadata)",
|
||||||
"methodCommand": "comando (avanzado - argv)",
|
"methodCommand": "Comando (avanzado: argv)",
|
||||||
"methodManual": "manual (escribo la versión)",
|
"methodManual": "Manual (escribo la versión)",
|
||||||
"packageIdentifierLabel": "Identificador de paquete",
|
"packageIdentifierLabel": "Identificador de paquete",
|
||||||
"binaryPathLabel": "Ruta del binario",
|
"binaryPathLabel": "Ruta del binario",
|
||||||
"binaryPathPlaceholder": "por ejemplo, /opt/<tu-aplicación>/binary",
|
"binaryPathPlaceholder": "por ejemplo, /opt/<tu-aplicación>/binary",
|
||||||
@@ -1589,7 +1589,7 @@
|
|||||||
"detectMethodDockerLabel": "etiqueta acoplable",
|
"detectMethodDockerLabel": "etiqueta acoplable",
|
||||||
"detectMethodDockerExec": "ejecutivo de ventana acoplable",
|
"detectMethodDockerExec": "ejecutivo de ventana acoplable",
|
||||||
"detectMethodCommand": "dominio",
|
"detectMethodCommand": "dominio",
|
||||||
"binaryVersionHint": "binario --versión",
|
"binaryVersionHint": "Binario --version",
|
||||||
"systemctlHint": "systemctl show <servicio> -p ExecStart",
|
"systemctlHint": "systemctl show <servicio> -p ExecStart",
|
||||||
"whichHint": "cual <aplicación>",
|
"whichHint": "cual <aplicación>",
|
||||||
"apkInfoHint": "\" : \"información de la aplicación | grep",
|
"apkInfoHint": "\" : \"información de la aplicación | grep",
|
||||||
@@ -3684,7 +3684,7 @@
|
|||||||
"memory": "Memoria",
|
"memory": "Memoria",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"used": "Usado",
|
"used": "Usado",
|
||||||
"free": "Gratis",
|
"free": "Libre",
|
||||||
"memoryUtilization": "Utilización de la memoria",
|
"memoryUtilization": "Utilización de la memoria",
|
||||||
"connection": "Conexión",
|
"connection": "Conexión",
|
||||||
"formFactor": "Factor de forma",
|
"formFactor": "Factor de forma",
|
||||||
@@ -4148,7 +4148,7 @@
|
|||||||
"expressionLabel": "Expresión",
|
"expressionLabel": "Expresión",
|
||||||
"filesystemShort": "fs",
|
"filesystemShort": "fs",
|
||||||
"fingerprintOptional": "Huella digital (opcional)",
|
"fingerprintOptional": "Huella digital (opcional)",
|
||||||
"free": "Gratis",
|
"free": "Libre",
|
||||||
"groupNameInPbs": "Nombre del grupo en PBS",
|
"groupNameInPbs": "Nombre del grupo en PBS",
|
||||||
"jobId": "ID de tarea",
|
"jobId": "ID de tarea",
|
||||||
"jobIdLabel": "ID de tarea",
|
"jobIdLabel": "ID de tarea",
|
||||||
|
|||||||
@@ -2015,6 +2015,12 @@ def _refresh_started_guest(vmid: int, vm_type: str) -> None:
|
|||||||
lxc_apps.invalidate_docker_inventory(vmid)
|
lxc_apps.invalidate_docker_inventory(vmid)
|
||||||
lxc_apps.get_docker_inventory(vmid, force=True)
|
lxc_apps.get_docker_inventory(vmid, force=True)
|
||||||
|
|
||||||
|
if vm_type == 'lxc':
|
||||||
|
try:
|
||||||
|
import lxc_apps
|
||||||
|
lxc_apps.ensure_oci_registration(vmid)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'[ProxMenux] lifecycle refresh lxc {vmid}: OCI registration: {exc}', flush=True)
|
||||||
handlers = [
|
handlers = [
|
||||||
(f'/api/vms/{vmid}', get_vm_config),
|
(f'/api/vms/{vmid}', get_vm_config),
|
||||||
(f'/api/vms/{vmid}/backups', api_vm_backups),
|
(f'/api/vms/{vmid}/backups', api_vm_backups),
|
||||||
@@ -12318,6 +12324,7 @@ def _vm_modal_prewarmer_pass():
|
|||||||
]
|
]
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
import lxc_apps
|
import lxc_apps
|
||||||
|
lxc_apps.ensure_oci_registration(vmid)
|
||||||
lxc_apps.load_sidecar(vmid)
|
lxc_apps.load_sidecar(vmid)
|
||||||
endpoints.extend([
|
endpoints.extend([
|
||||||
(_vm_schedule_cache, _VM_SCHEDULE_TTL, api_vm_apps_schedule,
|
(_vm_schedule_cache, _VM_SCHEDULE_TTL, api_vm_apps_schedule,
|
||||||
@@ -13552,6 +13559,7 @@ def api_lxc_updates_detection_set():
|
|||||||
def api_vm_apps_get(vmid):
|
def api_vm_apps_get(vmid):
|
||||||
try:
|
try:
|
||||||
import lxc_apps
|
import lxc_apps
|
||||||
|
lxc_apps.ensure_oci_registration(vmid)
|
||||||
sidecar = lxc_apps.load_sidecar(vmid)
|
sidecar = lxc_apps.load_sidecar(vmid)
|
||||||
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
|
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
|
||||||
lxc_apps.annotate_delegated_apps(payload.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
|
lxc_apps.annotate_delegated_apps(payload.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
|
||||||
|
|||||||
@@ -647,9 +647,19 @@ def script_websocket(ws, session_id):
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Keystrokes arrive as raw text. A lone number or word such as
|
||||||
|
# "4" or "true" is valid JSON too, so only an object is a
|
||||||
|
# control message.
|
||||||
try:
|
try:
|
||||||
msg = json.loads(data)
|
msg = json.loads(data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
msg = None
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
try:
|
||||||
|
os.write(master_fd, data.encode('utf-8'))
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
if msg.get('type') == 'interaction_response':
|
if msg.get('type') == 'interaction_response':
|
||||||
interaction_id = msg.get('id')
|
interaction_id = msg.get('id')
|
||||||
value = msg.get('value')
|
value = msg.get('value')
|
||||||
@@ -678,13 +688,6 @@ def script_websocket(ws, session_id):
|
|||||||
set_winsize(master_fd, rows, cols)
|
set_winsize(master_fd, rows, cols)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Raw text input, send to script
|
|
||||||
try:
|
|
||||||
os.write(master_fd, data.encode('utf-8'))
|
|
||||||
except OSError as e:
|
|
||||||
break
|
|
||||||
|
|
||||||
if script_process.poll() is not None:
|
if script_process.poll() is not None:
|
||||||
# The output worker owns the final PTY drain and emits both
|
# The output worker owns the final PTY drain and emits both
|
||||||
# `[Script exited with code N]` and `script_complete`. A
|
# `[Script exited with code N]` and `script_complete`. A
|
||||||
|
|||||||
@@ -1358,6 +1358,8 @@ def detect_installed_version(vmid, config: dict) -> tuple[Optional[str], Optiona
|
|||||||
|
|
||||||
if method == "oci_image":
|
if method == "oci_image":
|
||||||
result = _oci_image_versions(vmid, with_latest=False)
|
result = _oci_image_versions(vmid, with_latest=False)
|
||||||
|
if result.get("busy"):
|
||||||
|
return None, "an OCI operation on this container is running"
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
return None, result["error"]
|
return None, result["error"]
|
||||||
# An image that states no application version is still an image with
|
# An image that states no application version is still an image with
|
||||||
@@ -3372,7 +3374,7 @@ def _find_app(sidecar: dict, app_id: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def add_app(vmid, payload: dict) -> tuple[bool, Any]:
|
def add_app(vmid, payload: dict, notify: bool = True) -> tuple[bool, Any]:
|
||||||
ok, cfg = validate_config(payload)
|
ok, cfg = validate_config(payload)
|
||||||
if not ok:
|
if not ok:
|
||||||
return False, cfg
|
return False, cfg
|
||||||
@@ -3393,7 +3395,7 @@ def add_app(vmid, payload: dict) -> tuple[bool, Any]:
|
|||||||
if not _write_sidecar(vmid, sidecar):
|
if not _write_sidecar(vmid, sidecar):
|
||||||
return False, "could not persist sidecar (permission?)"
|
return False, "could not persist sidecar (permission?)"
|
||||||
# Kick a first check so the UI shows real numbers immediately
|
# Kick a first check so the UI shows real numbers immediately
|
||||||
check_app(vmid, new_id, force=True)
|
check_app(vmid, new_id, force=True, notify=notify)
|
||||||
return True, _read_sidecar(vmid)
|
return True, _read_sidecar(vmid)
|
||||||
|
|
||||||
|
|
||||||
@@ -3461,6 +3463,9 @@ def delete_app(vmid, app_id: str) -> bool:
|
|||||||
sidecar = _read_sidecar(vmid)
|
sidecar = _read_sidecar(vmid)
|
||||||
if not sidecar:
|
if not sidecar:
|
||||||
return True
|
return True
|
||||||
|
if any(a.get("id") == app_id and a.get("installed_via") == "oci_image"
|
||||||
|
for a in sidecar.get("apps") or []):
|
||||||
|
_dismiss_oci_registration(vmid)
|
||||||
before = len(sidecar.get("apps") or [])
|
before = len(sidecar.get("apps") or [])
|
||||||
sidecar["apps"] = [a for a in sidecar.get("apps") or [] if a.get("id") != app_id]
|
sidecar["apps"] = [a for a in sidecar.get("apps") or [] if a.get("id") != app_id]
|
||||||
sidecar["updated_at"] = _now_iso()
|
sidecar["updated_at"] = _now_iso()
|
||||||
@@ -3478,6 +3483,9 @@ def delete_app(vmid, app_id: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def delete_all(vmid) -> bool:
|
def delete_all(vmid) -> bool:
|
||||||
|
sidecar = _read_sidecar(vmid) or {}
|
||||||
|
if any(a.get("installed_via") == "oci_image" for a in sidecar.get("apps") or []):
|
||||||
|
_dismiss_oci_registration(vmid)
|
||||||
try:
|
try:
|
||||||
os.unlink(_sidecar_path(vmid))
|
os.unlink(_sidecar_path(vmid))
|
||||||
return True
|
return True
|
||||||
@@ -4325,6 +4333,9 @@ def check_app(
|
|||||||
|
|
||||||
if app.get("installed_via") == "oci_image":
|
if app.get("installed_via") == "oci_image":
|
||||||
result = _oci_image_versions(vmid, known=state)
|
result = _oci_image_versions(vmid, known=state)
|
||||||
|
if result.get("busy"):
|
||||||
|
_recheck_after_oci_operation(vmid, app_id)
|
||||||
|
return sidecar
|
||||||
app["state"] = {
|
app["state"] = {
|
||||||
"installed_version": result.get("installed_version"),
|
"installed_version": result.get("installed_version"),
|
||||||
"latest_version": result.get("latest_version"),
|
"latest_version": result.get("latest_version"),
|
||||||
@@ -4341,7 +4352,9 @@ def check_app(
|
|||||||
}
|
}
|
||||||
sidecar["updated_at"] = _now_iso()
|
sidecar["updated_at"] = _now_iso()
|
||||||
_write_sidecar(vmid, sidecar)
|
_write_sidecar(vmid, sidecar)
|
||||||
if notify and app["state"]["update_available"] and app["state"]["latest_version"]:
|
# The payload names an image by its build date and digest when it
|
||||||
|
# states no version, so it decides whether there is anything to send.
|
||||||
|
if notify and app["state"]["update_available"]:
|
||||||
_fire_update_notification(vmid, app)
|
_fire_update_notification(vmid, app)
|
||||||
return sidecar
|
return sidecar
|
||||||
|
|
||||||
@@ -5359,6 +5372,124 @@ def _oci_name_from_image(reference: str | None) -> str:
|
|||||||
return " ".join(word.capitalize() for word in re.split(r"[-_.]+", basename) if word)
|
return " ".join(word.capitalize() for word in re.split(r"[-_.]+", basename) if word)
|
||||||
|
|
||||||
|
|
||||||
|
# Registrations of OCI applications the user removed, by VMID and the
|
||||||
|
# installation they belonged to, so they are not registered again.
|
||||||
|
_OCI_DISMISSED_FILE = f"{_APPS_DIR}/.oci-dismissed.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_oci_record(vmid) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
with open(f"{_OCI_INSTANCE_ROOT}/{int(vmid)}/oci-compose.json", encoding="utf-8") as handle:
|
||||||
|
record = json.load(handle)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return record if isinstance(record, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _oci_dismissed() -> dict:
|
||||||
|
try:
|
||||||
|
with open(_OCI_DISMISSED_FILE, encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _dismiss_oci_registration(vmid) -> None:
|
||||||
|
record = _read_oci_record(vmid)
|
||||||
|
if not record or not record.get("installation_id"):
|
||||||
|
return
|
||||||
|
data = _oci_dismissed()
|
||||||
|
data[str(int(vmid))] = record["installation_id"]
|
||||||
|
_ensure_dir()
|
||||||
|
tmp = f"{_OCI_DISMISSED_FILE}.tmp.{os.getpid()}"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(data, handle, indent=2, sort_keys=True)
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, _OCI_DISMISSED_FILE)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"[ProxMenux] lxc_apps: could not save the OCI dismissal: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_oci_registration(vmid) -> bool:
|
||||||
|
"""Register the application ProxMenux installed from an OCI image the
|
||||||
|
first time the Monitor sees its container, with version tracking by the
|
||||||
|
image. The auxiliary members of a stack are not registered, nor is a
|
||||||
|
container that already has applications, nor one whose registration the
|
||||||
|
user removed."""
|
||||||
|
record = _read_oci_record(vmid)
|
||||||
|
if not record or record.get("status") != "installed":
|
||||||
|
return False
|
||||||
|
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
|
||||||
|
if member.get("primary_vmid") not in (None, record.get("vmid")):
|
||||||
|
return False
|
||||||
|
if _oci_dismissed().get(str(int(vmid))) == record.get("installation_id"):
|
||||||
|
return False
|
||||||
|
with _cache_lock:
|
||||||
|
sidecar = _read_sidecar(vmid)
|
||||||
|
if sidecar and sidecar.get("apps"):
|
||||||
|
return False
|
||||||
|
meta = _oci_instance_meta(vmid)
|
||||||
|
if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])):
|
||||||
|
return False
|
||||||
|
port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None)
|
||||||
|
payload = {
|
||||||
|
"name": meta["name"],
|
||||||
|
"installed_via": "oci_image",
|
||||||
|
"helper_slug": meta.get("template_id") or "",
|
||||||
|
"logo_url": meta.get("logo") or "",
|
||||||
|
"update_method": "none",
|
||||||
|
"ports": [{
|
||||||
|
"port": port,
|
||||||
|
"scheme": meta.get("endpoint_scheme") or "http",
|
||||||
|
"web_path": meta.get("endpoint_path") or "/",
|
||||||
|
"category": meta.get("category_label") or meta.get("category") or "",
|
||||||
|
"description": "",
|
||||||
|
}] if isinstance(port, int) else [],
|
||||||
|
}
|
||||||
|
# Registrations made at startup would each announce their update on their
|
||||||
|
# own; the scheduled sweep sends pending updates together instead.
|
||||||
|
ok, result = add_app(vmid, payload, notify=False)
|
||||||
|
if not ok:
|
||||||
|
print(f"[ProxMenux] lxc_apps: automatic OCI registration of CT {vmid} failed: {result}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _oci_operation_running(vmid) -> bool:
|
||||||
|
"""Whether an update or recreation of this container is still running;
|
||||||
|
its record is only published again when the operation commits."""
|
||||||
|
try:
|
||||||
|
with open(f"{_OCI_INSTANCE_ROOT}/{int(vmid)}/oci-compose.json", encoding="utf-8") as handle:
|
||||||
|
record = json.load(handle)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return isinstance(record, dict) and record.get("status") == "updating"
|
||||||
|
|
||||||
|
|
||||||
|
_oci_rechecks: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _recheck_after_oci_operation(vmid, app_id: str) -> None:
|
||||||
|
"""Check the application again once the running operation has finished."""
|
||||||
|
key = (int(vmid), app_id)
|
||||||
|
if key in _oci_rechecks:
|
||||||
|
return
|
||||||
|
_oci_rechecks.add(key)
|
||||||
|
|
||||||
|
def wait_and_check():
|
||||||
|
try:
|
||||||
|
for _ in range(60):
|
||||||
|
time.sleep(15)
|
||||||
|
if not _oci_operation_running(vmid):
|
||||||
|
check_app(vmid, app_id, force=True)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
_oci_rechecks.discard(key)
|
||||||
|
|
||||||
|
threading.Thread(target=wait_and_check, name=f"oci-recheck-{vmid}", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _oci_instance_meta(vmid) -> Optional[dict]:
|
def _oci_instance_meta(vmid) -> Optional[dict]:
|
||||||
"""What ProxMenux itself recorded when it installed this container.
|
"""What ProxMenux itself recorded when it installed this container.
|
||||||
|
|
||||||
@@ -5374,7 +5505,9 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
|
|||||||
record = json.load(handle)
|
record = json.load(handle)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
if not isinstance(record, dict) or record.get("status") not in ("installed", "assembling"):
|
# While an update or recreation runs, the record is the copy taken before
|
||||||
|
# it: still the right identity for the container, not yet its new image.
|
||||||
|
if not isinstance(record, dict) or record.get("status") not in ("installed", "assembling", "updating"):
|
||||||
return None
|
return None
|
||||||
template = record.get("template") or {}
|
template = record.get("template") or {}
|
||||||
contract = template.get("container_contract") or {}
|
contract = template.get("container_contract") or {}
|
||||||
@@ -5516,6 +5649,8 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool =
|
|||||||
The installed version is read by digest, which never changes, so a
|
The installed version is read by digest, which never changes, so a
|
||||||
previous answer for the same digest is reused instead of asked again.
|
previous answer for the same digest is reused instead of asked again.
|
||||||
"""
|
"""
|
||||||
|
if _oci_operation_running(vmid):
|
||||||
|
return {"busy": True}
|
||||||
meta = _oci_instance_meta(vmid)
|
meta = _oci_instance_meta(vmid)
|
||||||
if not meta or not meta.get("image_reference") or not meta.get("installed_digest"):
|
if not meta or not meta.get("image_reference") or not meta.get("installed_digest"):
|
||||||
return {"error": "no OCI installation record for this container"}
|
return {"error": "no OCI installation record for this container"}
|
||||||
@@ -5922,7 +6057,7 @@ def get_suggestions(vmid, force: bool = False) -> dict:
|
|||||||
# decided by the image, which always has a build date and a digest,
|
# decided by the image, which always has a build date and a digest,
|
||||||
# so it applies even to an image that states no application version.
|
# so it applies even to an image that states no application version.
|
||||||
versions = _oci_image_versions(vmid, with_latest=False)
|
versions = _oci_image_versions(vmid, with_latest=False)
|
||||||
if not versions.get("error"):
|
if not versions.get("error") and not versions.get("busy"):
|
||||||
tracking = {
|
tracking = {
|
||||||
"installed_via": "oci_image",
|
"installed_via": "oci_image",
|
||||||
"detected_version": versions.get("installed_version") or _oci_image_label(
|
"detected_version": versions.get("installed_version") or _oci_image_label(
|
||||||
|
|||||||
+5
-3
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"Unsupported kept setting:": "Ajuste conservado no admitido:",
|
||||||
|
"Could not restore the container setting:": "No se pudo restaurar el ajuste del contenedor:",
|
||||||
"# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla",
|
"# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla",
|
||||||
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita la desconexión durante la actualización",
|
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita la desconexión durante la actualización",
|
||||||
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Las entradas marcadas se eliminarán. Desmarque para mantener en VM).",
|
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Las entradas marcadas se eliminarán. Desmarque para mantener en VM).",
|
||||||
@@ -2323,7 +2325,7 @@
|
|||||||
"Free public Proxmox repository enabled": "Repositorio público gratuito de Proxmox habilitado",
|
"Free public Proxmox repository enabled": "Repositorio público gratuito de Proxmox habilitado",
|
||||||
"Free space OK:": "Espacio libre Aceptar:",
|
"Free space OK:": "Espacio libre Aceptar:",
|
||||||
"Free up disk space": "Liberar espacio en disco",
|
"Free up disk space": "Liberar espacio en disco",
|
||||||
"Free:": "Gratis:",
|
"Free:": "Libre:",
|
||||||
"FreeCAD is a general-purpose parametric 3D computer-aided design (CAD) modeler and a building information modeling (BIM) software application with finite element method (FEM) support.": "FreeCAD es un modelador de diseño paramétrico 3D (CAD) de uso general y una aplicación de modelado de información de construcción (BIM) con soporte de elementos finitos (FEM).",
|
"FreeCAD is a general-purpose parametric 3D computer-aided design (CAD) modeler and a building information modeling (BIM) software application with finite element method (FEM) support.": "FreeCAD es un modelador de diseño paramétrico 3D (CAD) de uso general y una aplicación de modelado de información de construcción (BIM) con soporte de elementos finitos (FEM).",
|
||||||
"French": "Francés",
|
"French": "Francés",
|
||||||
"Freshrss is a free, self-hostable aggregator for rss feeds.": "Freshrss es un agregador libre y auto-hostable para las fuentes de rss.",
|
"Freshrss is a free, self-hostable aggregator for rss feeds.": "Freshrss es un agregador libre y auto-hostable para las fuentes de rss.",
|
||||||
@@ -3939,7 +3941,7 @@
|
|||||||
"No folders found in /mnt. Please create a new folder.": "No se encontraron carpetas en /mnt. Por favor cree una nueva carpeta.",
|
"No folders found in /mnt. Please create a new folder.": "No se encontraron carpetas en /mnt. Por favor cree una nueva carpeta.",
|
||||||
"No folders found inside /mnt in the CT.": "No se encontraron carpetas dentro de /mnt en el CT.",
|
"No folders found inside /mnt in the CT.": "No se encontraron carpetas dentro de /mnt en el CT.",
|
||||||
"No format-safe disks are available.": "No hay discos con formato seguro disponibles.",
|
"No format-safe disks are available.": "No hay discos con formato seguro disponibles.",
|
||||||
"No free ProxMenux private /24 network is available": "No gratuito ProxMenux red privada /24 está disponible",
|
"No free ProxMenux private /24 network is available": "No hay ninguna red privada /24 de ProxMenux libre",
|
||||||
"No gasket DKMS registrations remain.": "No quedan registros de gasket en DKMS.",
|
"No gasket DKMS registrations remain.": "No quedan registros de gasket en DKMS.",
|
||||||
"No group creation required — uses world-writable sticky bit permissions.": "No se requiere creación de grupos: utiliza permisos de bits adhesivos de escritura mundial.",
|
"No group creation required — uses world-writable sticky bit permissions.": "No se requiere creación de grupos: utiliza permisos de bits adhesivos de escritura mundial.",
|
||||||
"No host VFIO reconfiguration expected": "No se espera reconfiguración del VFIO del host",
|
"No host VFIO reconfiguration expected": "No se espera reconfiguración del VFIO del host",
|
||||||
@@ -6237,7 +6239,7 @@
|
|||||||
"There is no temporary container of this operation to keep the current disks": "No hay contenedor temporal de esta operación para mantener los discos actuales",
|
"There is no temporary container of this operation to keep the current disks": "No hay contenedor temporal de esta operación para mantener los discos actuales",
|
||||||
"There is no verified backup; a modified container is not touched": "No hay backup verificado; un contenedor modificado no se toca",
|
"There is no verified backup; a modified container is not touched": "No hay backup verificado; un contenedor modificado no se toca",
|
||||||
"These Proxmox resources were added outside ProxMenux:": "Se añadieron estos recursos en Proxmox fuera de ProxMenux:",
|
"These Proxmox resources were added outside ProxMenux:": "Se añadieron estos recursos en Proxmox fuera de ProxMenux:",
|
||||||
"These VMIDs are not free:": "Estos VMID no son gratuitos:",
|
"These VMIDs are not free:": "Estos VMID no están libres:",
|
||||||
"These are the changes that will be made": "Estos son los cambios que se harán",
|
"These are the changes that will be made": "Estos son los cambios que se harán",
|
||||||
"These interface configurations will be removed": "Estas configuraciones de interfaz serán eliminadas.",
|
"These interface configurations will be removed": "Estas configuraciones de interfaz serán eliminadas.",
|
||||||
"These manual changes cannot be adopted safely:": "Estos cambios manuales no se pueden incorporar con seguridad:",
|
"These manual changes cannot be adopted safely:": "Estos cambios manuales no se pueden incorporar con seguridad:",
|
||||||
|
|||||||
@@ -757,6 +757,19 @@ apply_extra_hosts() {
|
|||||||
(( failed == 0 )) || die "$(translate "Could not apply the Compose extra hosts")"
|
(( failed == 0 )) || die "$(translate "Could not apply the Compose extra hosts")"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Settings an update gives back to the rebuilt container before it starts:
|
||||||
|
# the LAN leg of a suite or stack member and its start order.
|
||||||
|
apply_kept_settings() {
|
||||||
|
local key value
|
||||||
|
while IFS=$'\t' read -r key value; do
|
||||||
|
[[ -n $key ]] || continue
|
||||||
|
[[ $key == net1 || $key == startup ]] \
|
||||||
|
|| die "$(translate "Unsupported kept setting:") $key"
|
||||||
|
oci_quiet pct set "$VMID" "--$key" "$value" \
|
||||||
|
|| die "$(translate "Could not restore the container setting:") $key"
|
||||||
|
done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE")
|
||||||
|
}
|
||||||
|
|
||||||
apply_runtime_user() {
|
apply_runtime_user() {
|
||||||
local user_spec=$1 rootfs passwd_file group_file user_part group_part uid gid failed=0
|
local user_spec=$1 rootfs passwd_file group_file user_part group_part uid gid failed=0
|
||||||
rootfs="/var/lib/lxc/${VMID}/rootfs"
|
rootfs="/var/lib/lxc/${VMID}/rootfs"
|
||||||
@@ -1653,6 +1666,7 @@ apply_extra_hosts
|
|||||||
apply_installer_profile
|
apply_installer_profile
|
||||||
apply_rlimits
|
apply_rlimits
|
||||||
apply_host_monitor
|
apply_host_monitor
|
||||||
|
apply_kept_settings
|
||||||
|
|
||||||
while IFS= read -r REPAIR_ENCODED; do
|
while IFS= read -r REPAIR_ENCODED; do
|
||||||
[[ -n $REPAIR_ENCODED ]] || continue
|
[[ -n $REPAIR_ENCODED ]] || continue
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ def propose(record, config):
|
|||||||
if 'description' in changed:
|
if 'description' in changed:
|
||||||
changed.remove('description')
|
changed.remove('description')
|
||||||
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
|
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
|
||||||
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE]
|
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE
|
||||||
|
and key not in transaction.KEPT_AS_IS]
|
||||||
if unsupported:
|
if unsupported:
|
||||||
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}")
|
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}")
|
||||||
if not new_keys:
|
if not new_keys:
|
||||||
|
|||||||
@@ -357,6 +357,9 @@ def candidate_contract(record, operation, proposal=None):
|
|||||||
|
|
||||||
|
|
||||||
# Resource settings edited in Proxmox that the new container keeps.
|
# Resource settings edited in Proxmox that the new container keeps.
|
||||||
|
# Proxmox settings the rebuilt container gets back exactly as they are: the
|
||||||
|
# LAN leg ProxMenux adds to a suite or stack member, and the start order.
|
||||||
|
KEPT_AS_IS = ('net1', 'startup')
|
||||||
ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'),
|
ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'),
|
||||||
'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'),
|
'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'),
|
||||||
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')}
|
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')}
|
||||||
@@ -384,7 +387,7 @@ def external_changes(record, config, adopt=True):
|
|||||||
elif key in ('memory', 'swap', 'cpuunits', cores_key) and value and re.fullmatch(r'[0-9]+', value):
|
elif key in ('memory', 'swap', 'cpuunits', cores_key) and value and re.fullmatch(r'[0-9]+', value):
|
||||||
if int(value) > 0 or key == 'swap':
|
if int(value) > 0 or key == 'swap':
|
||||||
values[key] = int(value)
|
values[key] = int(value)
|
||||||
refused = [key for key in changed if key not in values]
|
refused = [key for key in changed if key not in values and key not in KEPT_AS_IS]
|
||||||
if refused:
|
if refused:
|
||||||
raise ValueError(f"{translate('The container was changed outside ProxMenux and an update would discard those changes:')} "
|
raise ValueError(f"{translate('The container was changed outside ProxMenux and an update would discard those changes:')} "
|
||||||
f"{', '.join(refused)}")
|
f"{', '.join(refused)}")
|
||||||
@@ -421,14 +424,13 @@ def preflight(record, candidate, config, coordinated=None):
|
|||||||
if deployment.get('security', {}).get('sysctls'):
|
if deployment.get('security', {}).get('sysctls'):
|
||||||
runtime_keys.add('lxc.include')
|
runtime_keys.add('lxc.include')
|
||||||
runtime_settings.check(config, deployment, record['vmid'])
|
runtime_settings.check(config, deployment, record['vmid'])
|
||||||
coordinated_keys = {'net1', 'startup', 'hookscript'} if coordinated else set()
|
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
|
||||||
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}:
|
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}:
|
||||||
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
|
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
|
||||||
for plan in (deployment, desired):
|
for plan in (deployment, desired):
|
||||||
security = plan.get('security', {})
|
security = plan.get('security', {})
|
||||||
if (security.get('unprivileged') is not True
|
if (security.get('unprivileged') is not True
|
||||||
or security.get('options') or plan.get('host_monitor')
|
or security.get('options') or plan.get('host_monitor')
|
||||||
or plan.get('extra_hosts')
|
|
||||||
or plan.get('resources', {}).get('rlimits')):
|
or plan.get('resources', {}).get('rlimits')):
|
||||||
raise ValueError(translate('Updates are not available yet in this beta for applications that use '
|
raise ValueError(translate('Updates are not available yet in this beta for applications that use '
|
||||||
'a privileged container or advanced LXC settings'))
|
'a privileged container or advanced LXC settings'))
|
||||||
@@ -900,6 +902,11 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
|||||||
'external_data_acknowledged': acknowledge_external_data,
|
'external_data_acknowledged': acknowledge_external_data,
|
||||||
'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu,
|
'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu,
|
||||||
'was_running': run('pct', 'status', str(vmid)).strip() == b'status: running'}
|
'was_running': run('pct', 'status', str(vmid)).strip() == b'status: running'}
|
||||||
|
state['kept_config'] = {key: cfg[key] for key in KEPT_AS_IS if key in cfg}
|
||||||
|
if not coordinated and state['kept_config']:
|
||||||
|
# Applied by the installer before the new container starts, so the
|
||||||
|
# application has its LAN leg from the first second.
|
||||||
|
state['runtime_deployment']['kept_proxmox_settings'] = state['kept_config']
|
||||||
if coordinated:
|
if coordinated:
|
||||||
state['coordinated'] = coordinated
|
state['coordinated'] = coordinated
|
||||||
state['preserved_stack_config'] = {key: cfg[key] for key in ('net1', 'startup', 'hookscript') if key in cfg}
|
state['preserved_stack_config'] = {key: cfg[key] for key in ('net1', 'startup', 'hookscript') if key in cfg}
|
||||||
@@ -919,8 +926,6 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
|||||||
gpu_devices.verify(desired_gpu)
|
gpu_devices.verify(desired_gpu)
|
||||||
if show:
|
if show:
|
||||||
msg_ok(translate('Update prepared') if update else translate('Recreation prepared'))
|
msg_ok(translate('Update prepared') if update else translate('Recreation prepared'))
|
||||||
if original_sources or desired_sources:
|
|
||||||
msg_warn(translate('Host directories are not included in the backup and are not reverted by a recovery.'))
|
|
||||||
msg_info(translate('Stopping the container...'))
|
msg_info(translate('Stopping the container...'))
|
||||||
stop(vmid)
|
stop(vmid)
|
||||||
if show:
|
if show:
|
||||||
|
|||||||
@@ -672,9 +672,6 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
|
|||||||
msg_ok(f"{translate('Backup created in')} {keep_backup}")
|
msg_ok(f"{translate('Backup created in')} {keep_backup}")
|
||||||
else:
|
else:
|
||||||
adapter.keep_backup = keep_backup
|
adapter.keep_backup = keep_backup
|
||||||
if any(mount['type'] == 'host-bind' for member in plan['members']
|
|
||||||
for mount in member.get('deployment', {}).get('mounts', [])):
|
|
||||||
msg_warn(translate('Host directories are not included in the backups and are not reverted by a recovery.'))
|
|
||||||
result = stack_tx.execute(journal, adapter, plan)
|
result = stack_tx.execute(journal, adapter, plan)
|
||||||
msg_ok(translate('Stack update completed. Data kept.'))
|
msg_ok(translate('Stack update completed. Data kept.'))
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"When the registry still serves the installed digest, nothing is downloaded and the instance is not touched.",
|
"When the registry still serves the installed digest, nothing is downloaded and the instance is not touched.",
|
||||||
"The new image is verified layer by layer before the CT stops. A download that arrives damaged is fetched a second time; an image already cached that fails the check is downloaded again.",
|
"The new image is verified layer by layer before the CT stops. A download that arrives damaged is fetched a second time; an image already cached that fails the check is downloaded again.",
|
||||||
"Settings changed in Proxmox VE after the installation (memory, swap, cores, CPU limit, CPU priority, start with the node) are kept and carried into the new contract.",
|
"Settings changed in Proxmox VE after the installation (memory, swap, cores, CPU limit, CPU priority, start with the node) are kept and carried into the new contract.",
|
||||||
|
"The LAN interface that the Arr suite and multi-container applications add (<code>net1</code>) and the start order (<code>startup</code>) are given back to the new container as they are.",
|
||||||
"Any other difference between the CT and its contract stops the update before the CT is stopped."
|
"Any other difference between the CT and its contract stops the update before the CT is stopped."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"Cuando el registro sigue sirviendo el digest instalado, no se descarga nada y la instancia no se toca.",
|
"Cuando el registro sigue sirviendo el digest instalado, no se descarga nada y la instancia no se toca.",
|
||||||
"La imagen nueva se verifica capa a capa antes de detener el CT. Una descarga que llega dañada se repite una vez; una imagen ya almacenada que no supera la comprobación se descarga de nuevo.",
|
"La imagen nueva se verifica capa a capa antes de detener el CT. Una descarga que llega dañada se repite una vez; una imagen ya almacenada que no supera la comprobación se descarga de nuevo.",
|
||||||
"Los ajustes cambiados en Proxmox VE después de la instalación (memoria, swap, núcleos, límite de CPU, prioridad de CPU, arranque con el nodo) se conservan y pasan al contrato nuevo.",
|
"Los ajustes cambiados en Proxmox VE después de la instalación (memoria, swap, núcleos, límite de CPU, prioridad de CPU, arranque con el nodo) se conservan y pasan al contrato nuevo.",
|
||||||
|
"La interfaz de la LAN que añaden la suite Arr y las aplicaciones multicontenedor (<code>net1</code>) y el orden de arranque (<code>startup</code>) se devuelven tal cual al contenedor nuevo.",
|
||||||
"Cualquier otra diferencia entre el CT y su contrato detiene la actualización antes de detener el CT."
|
"Cualquier otra diferencia entre el CT y su contrato detiene la actualización antes de detener el CT."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user