fix(oci): end a pasted definition with a single Ctrl+D

This commit is contained in:
MacRimi
2026-10-16 18:26:27 +02:00
parent ea3b579089
commit 653dee192c
5 changed files with 88 additions and 19 deletions
+1 -1
View File
@@ -1207,7 +1207,7 @@ export function HostBackup() {
{!j.attached && j.next_run && ( {!j.attached && j.next_run && (
<span className="inline-flex items-center gap-1" title={t("backup.jobs.nextRunTitle")}> <span className="inline-flex items-center gap-1" title={t("backup.jobs.nextRunTitle")}>
<Clock className="h-3.5 w-3.5 text-green-500" /> <Clock className="h-3.5 w-3.5 text-green-500" />
<span>{t("backup.jobs.nextRun", { time: formatNext(j.next_run) })}</span> <span>{t("backup.jobs.nextRun")}: {formatNext(j.next_run)}</span>
</span> </span>
)} )}
{(statusBadge || lastRunWhen) && ( {(statusBadge || lastRunWhen) && (
+15 -15
View File
@@ -4016,7 +4016,7 @@
}, },
"backends": { "backends": {
"borgDescription": "Repositorio Borg para backups del host deduplicados.", "borgDescription": "Repositorio Borg para backups del host deduplicados.",
"borgDescriptionTimerOnly": "Los destinos Borg son utilizados por los temporizadores Monitor, no por los tareas PVE vzdump.", "borgDescriptionTimerOnly": "Los destinos Borg los usan los temporizadores del Monitor, no las tareas vzdump de PVE.",
"borgShortDescription": "Repositorio Borg deduplicado", "borgShortDescription": "Repositorio Borg deduplicado",
"local": "Local", "local": "Local",
"localDescription": "Carpeta en este host o en una unidad USB montada.", "localDescription": "Carpeta en este host o en una unidad USB montada.",
@@ -4100,10 +4100,10 @@
"localUsbDescription": "Carpeta en este host o una unidad USB montada.", "localUsbDescription": "Carpeta en este host o una unidad USB montada.",
"managedByPve": "Gestionado por PVE", "managedByPve": "Gestionado por PVE",
"manuallyAdded": "Agregado manualmente", "manuallyAdded": "Agregado manualmente",
"nameCredentialsLocked": "El nombre y las credenciales están bloqueados mientras los tareas utilizan este destino.", "nameCredentialsLocked": "El nombre y las credenciales están bloqueados mientras haya tareas que usen este destino.",
"namePassphraseLocked": "El nombre del repositorio y la frase de contraseña están bloqueados mientras los tareas utilizan este destino.", "namePassphraseLocked": "El nombre del repositorio y la frase de contraseña están bloqueados mientras haya tareas que usen este destino.",
"noBorgInDialog": "La edición de destinos de Borg está disponible desde el flujo de configuración de Borg.", "noBorgInDialog": "La edición de destinos de Borg está disponible desde el flujo de configuración de Borg.",
"noBorgInWizard": "Borg no está disponible para tareas adjuntos PVE.", "noBorgInWizard": "Borg no está disponible para las tareas vinculadas a PVE.",
"noPbsInDialog": "La edición de destino de PBS está disponible desde el flujo de configuración de PBS.", "noPbsInDialog": "La edición de destino de PBS está disponible desde el flujo de configuración de PBS.",
"noPbsInWizard": "Aún no se han configurado repositorios de PBS.", "noPbsInWizard": "Aún no se han configurado repositorios de PBS.",
"not": "no", "not": "no",
@@ -4141,7 +4141,7 @@
"autoDetectedFromPveStorage": "Detectado desde el almacenamiento PVE", "autoDetectedFromPveStorage": "Detectado desde el almacenamiento PVE",
"borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, los backups cifrados no se podrán restaurar.", "borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, los backups cifrados no se podrán restaurar.",
"borgPassphraseOnlyWay": "Esta frase de contraseña es la única forma de desbloquear el repositorio.", "borgPassphraseOnlyWay": "Esta frase de contraseña es la única forma de desbloquear el repositorio.",
"borgPassphraseSavedAfter": "para que los tareas programados puedan ejecutarse sin supervisión.", "borgPassphraseSavedAfter": "para que las tareas programadas puedan ejecutarse sin supervisión.",
"borgPassphraseSavedAt": "La frase de contraseña se guarda en", "borgPassphraseSavedAt": "La frase de contraseña se guarda en",
"encryptBackups": "Cifrar backups", "encryptBackups": "Cifrar backups",
"encryptThisBackup": "Cifrar este backup", "encryptThisBackup": "Cifrar este backup",
@@ -4251,7 +4251,7 @@
"pathLabel": "Ruta", "pathLabel": "Ruta",
"pbsRepository": "repositorio de PBS", "pbsRepository": "repositorio de PBS",
"profileLabel": "Perfil", "profileLabel": "Perfil",
"pveJobLabel": "tarea PvE", "pveJobLabel": "Tarea PVE",
"pveStorage": "almacenamiento PVE", "pveStorage": "almacenamiento PVE",
"pveVersion": "versión PvE", "pveVersion": "versión PvE",
"recoveryPassphrase": "Frase de contraseña de recuperación", "recoveryPassphrase": "Frase de contraseña de recuperación",
@@ -4288,20 +4288,20 @@
"sshPort": "Puerto SSH" "sshPort": "Puerto SSH"
}, },
"jobs": { "jobs": {
"attachDescriptionAfter": "backups.", "attachDescriptionAfter": "seleccionados.",
"attachDescriptionBefore": "Une Monitor a una tarea PVE vzdump y ejecútela después del", "attachDescriptionBefore": "Vincula el Monitor a una tarea vzdump de PVE y se ejecuta tras el evento",
"attachDescriptionMiddle": "evento para seleccionados", "attachDescriptionMiddle": "para los backups de",
"attachToPveJob": "Unirse a una tarea de Backup en PVE", "attachToPveJob": "Unirse a una tarea de Backup en PVE",
"attachedToPveVzdump": "Adjunto a PVE vzdump", "attachedToPveVzdump": "Adjunto a PVE vzdump",
"backendEditHelp": "El backend no se puede cambiar una vez creado la tarea.", "backendEditHelp": "El backend no se puede cambiar una vez creada la tarea.",
"createJob": "crear tarea", "createJob": "Crear tarea",
"createScheduledJob": "Crear tarea programado", "createScheduledJob": "Crear tarea programada",
"customProfileTitle": "Perfil personalizado", "customProfileTitle": "Perfil personalizado",
"defaultProfileTitle": "Perfil predeterminado", "defaultProfileTitle": "Perfil predeterminado",
"detailDescription": "Programación, perfil, destino y última ejecución de esta tarea.", "detailDescription": "Programación, perfil, destino y última ejecución de esta tarea.",
"disableJob": "Desactivar tarea", "disableJob": "Desactivar tarea",
"disableJobDescription": "El temporizador systemd se detendrá; no se ejecutarán más automáticamente. Puede volver a habilitarlo más tarde desde este mismo cuadro de diálogo.", "disableJobDescription": "El temporizador systemd se detendrá; no se ejecutarán más automáticamente. Puede volver a habilitarlo más tarde desde este mismo cuadro de diálogo.",
"editScheduledJob": "Editar tarea programado", "editScheduledJob": "Editar tarea programada",
"encryptedTitle": "cifrado", "encryptedTitle": "cifrado",
"inheritedRetentionLabel": "Retención de la tarea PVE", "inheritedRetentionLabel": "Retención de la tarea PVE",
"inheritedScheduleLabel": "Programación de la tarea PVE", "inheritedScheduleLabel": "Programación de la tarea PVE",
@@ -4338,7 +4338,7 @@
"runNowTitle": "Activa esta tarea ahora", "runNowTitle": "Activa esta tarea ahora",
"running": "Ejecutando...", "running": "Ejecutando...",
"scheduledTitle": "Programado", "scheduledTitle": "Programado",
"standaloneScheduledJob": "Tarea programado independiente", "standaloneScheduledJob": "Tarea programada independiente",
"starting": "Iniciando...", "starting": "Iniciando...",
"stepOf": "Paso {step} de {total}", "stepOf": "Paso {step} de {total}",
"summary": "Resumen", "summary": "Resumen",
@@ -4592,7 +4592,7 @@
"advancedHelpBefore": "Usar", "advancedHelpBefore": "Usar",
"advancedHelpMiddle": "y", "advancedHelpMiddle": "y",
"advancedOption": "Avanzado", "advancedOption": "Avanzado",
"borgTimerOnly": "Los tareas Borg están programados mediante temporizadores Monitor.", "borgTimerOnly": "Las tareas Borg se programan con temporizadores del Monitor.",
"dailyOption": "A diario", "dailyOption": "A diario",
"everyDayAt": "Todos los días a las {time}", "everyDayAt": "Todos los días a las {time}",
"everyDayAtMidnight": "Todos los días a medianoche", "everyDayAtMidnight": "Todos los días a medianoche",
+1
View File
@@ -4562,6 +4562,7 @@
"Passwords do not match. Please try again.": "Las contraseñas no coinciden. Por favor inténtalo de nuevo.", "Passwords do not match. Please try again.": "Las contraseñas no coinciden. Por favor inténtalo de nuevo.",
"Paste an oci-compose in the terminal": "Pegar un oci-compose en la terminal", "Paste an oci-compose in the terminal": "Pegar un oci-compose en la terminal",
"Paste it here and press Ctrl+D on an empty line.": "Pégalo aquí y pulsa Ctrl+D en una línea vacía.", "Paste it here and press Ctrl+D on an empty line.": "Pégalo aquí y pulsa Ctrl+D en una línea vacía.",
"Paste it here and press Ctrl+D.": "Pégalo aquí y pulsa Ctrl+D.",
"Paste its Compose file in the terminal": "Pegar su archivo Compose en la terminal", "Paste its Compose file in the terminal": "Pegar su archivo Compose en la terminal",
"Paste its docker run command in the terminal": "Pegar su comando docker run en la terminal", "Paste its docker run command in the terminal": "Pegar su comando docker run en la terminal",
"Paste the UUP Dump URL here": "Pegue aquí la URL de UUP Dump", "Paste the UUP Dump URL here": "Pegue aquí la URL de UUP Dump",
+16 -3
View File
@@ -3,6 +3,7 @@ is translated into the same template the catalog applications use."""
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import shlex import shlex
import subprocess import subprocess
@@ -455,12 +456,24 @@ def describe_oci_compose(template: dict[str, Any]) -> str:
def _read_pasted(ui, title: str | None = None, limit: int = MAX_COMPOSE_BYTES, missing: str | None = None) -> str: def _read_pasted(ui, title: str | None = None, limit: int = MAX_COMPOSE_BYTES, missing: str | None = None) -> str:
"""The definition is pasted in the terminal: dialog cannot take it.""" """The definition is pasted in the terminal: dialog cannot take it. On a
terminal one Ctrl+D ends it, also at the end of a line that has text."""
console.show_logo() console.show_logo()
console.msg_title(title or translate('Compose file of the application')) console.msg_title(title or translate('Compose file of the application'))
console.msg_info2(translate('Paste it here and press Ctrl+D on an empty line.')) console.msg_info2(translate('Paste it here and press Ctrl+D.'))
print() print()
text = sys.stdin.read(limit + 1) if sys.stdin.isatty():
data = b''
while len(data) <= limit:
chunk = os.read(sys.stdin.fileno(), 65536)
data += chunk
# The terminal hands over whole lines; a chunk without its newline
# is the line Ctrl+D sent before the end of the line.
if not chunk.endswith(b'\n'):
break
text = data.decode('utf-8', errors='replace')
else:
text = sys.stdin.read(limit + 1)
if not text.strip(): if not text.strip():
raise UserCancelled(missing or (translate('No docker run command was given') if title is not None raise UserCancelled(missing or (translate('No docker run command was given') if title is not None
else translate('No Compose file was given'))) else translate('No Compose file was given')))
+55
View File
@@ -0,0 +1,55 @@
"""A pasted definition ends with one Ctrl+D, also when it is pressed at the
end of the last line instead of on an empty one."""
import io
import os
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci import custom
def read_pasted(limit):
with patch.object(custom.console, "show_logo"), patch.object(custom.console, "msg_title"), \
patch.object(custom.console, "msg_info2"), patch("builtins.print"):
return custom._read_pasted(None, limit=limit)
COMPOSE = "services:\n app:\n image: nginx:latest\n"
@unittest.skipUnless(hasattr(os, "openpty"), "needs a pseudo terminal")
class PasteUntilCtrlDTests(unittest.TestCase):
def read(self, typed: bytes, limit: int = 1024) -> str:
controller, terminal = os.openpty()
with os.fdopen(terminal, "r") as stdin, patch("sys.stdin", stdin):
os.write(controller, typed)
try:
return read_pasted(limit)
finally:
os.close(controller)
def test_ctrl_d_on_an_empty_line(self):
self.assertEqual(self.read(COMPOSE.encode() + b"\x04"), COMPOSE)
def test_ctrl_d_at_the_end_of_the_last_line(self):
self.assertEqual(self.read(COMPOSE.rstrip("\n").encode() + b"\x04"), COMPOSE.rstrip("\n"))
def test_reading_stops_past_the_limit(self):
text = self.read(b"a: 1\n" * 20 + b"\x04", limit=10)
self.assertGreater(len(text), 10)
self.assertLess(len(text), 100)
class PastedFromAPipeTests(unittest.TestCase):
def test_a_pipe_is_read_to_its_end(self):
with patch("sys.stdin", io.StringIO(COMPOSE)):
self.assertEqual(read_pasted(1024), COMPOSE)
if __name__ == "__main__":
unittest.main()