mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
feat(oci): VLAN tag and updates that keep Proxmox changes
This commit is contained in:
@@ -133,7 +133,7 @@ class SelectionSetupWording(TestCase):
|
||||
with self.subTest(app=app):
|
||||
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||
lines = path.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||
# Actual Bash failure expression, never source the parent installer.
|
||||
harness = '''set -u
|
||||
@@ -141,6 +141,7 @@ APPLICATION_LAN_IP=192.0.2.1
|
||||
APPLICATION_ID=101
|
||||
OCI_LOG=/dev/null
|
||||
curl() { return 7; }
|
||||
oci_curl() { shift; curl "$@"; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
print_first_boot_diagnostics() { :; }
|
||||
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||
|
||||
@@ -141,7 +141,11 @@ class UpdateCancellationTests(CancellationFixture):
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
|
||||
with patch.object(reconcile, 'propose', return_value={'details': ['external change']}), redirect_stdout(io.StringIO()):
|
||||
def declined(unattended, *_args, **_kwargs):
|
||||
unattended.declined = 'review'
|
||||
return False
|
||||
with patch.object(reconcile, 'propose', return_value={'details': ['external change'], 'review': True}), \
|
||||
patch.object(menu.UnattendedUI, 'review', declined), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
|
||||
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
|
||||
|
||||
@@ -6549,6 +6549,7 @@
|
||||
"Start VM when completed:": "VM nach Abschluss starten:",
|
||||
"Start a container. Use the correct <ctid>": "Starten Sie einen Container. Verwenden Sie die richtige <ctid>",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Starten Sie eine virtuelle Maschine. Verwenden Sie die richtige <vmid>",
|
||||
"Start command": "Startbefehl",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Starten Sie jeden LXC mit Proxmox (kein koordiniertes Starten)",
|
||||
"Start long self-test (hours)": "Langer Selbsttest starten (Stunden)",
|
||||
"Start long test now?": "Jetzt Langtest starten?",
|
||||
@@ -6887,6 +6888,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "Die USB-Nummer kann sich nach dem Wiederanschließen oder Neustarten ändern. Dieses Profil ordnet es nicht automatisch neu oder verarbeitet Coral USB-Wiederaufzählung. Teilen Sie keinen Dongle, der bereits von einem anderen Dienst verwendet wurde.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "Die Unifi-Controller-Software ist eine leistungsstarke, drahtlose Enterprise-Software-Engine, die sich ideal für Client-Bereitstellungen mit hoher Dichte requiring mit niedriger Latenz und hoher Betriebszeit eignet.",
|
||||
"The VA-API device does not exist:": "Das VA-API-Gerät existiert nicht:",
|
||||
"The VLAN tag must be a number from 1 to 4094": "Das VLAN-Tag muss eine Zahl von 1 bis 4094 sein",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "Der VM werden diese Audiogeräte auch über PCI-Passthrough zugewiesen – normalerweise zusammen mit der GPU hinzugefügt. Auch entfernen?",
|
||||
"The VM guest will have exclusive access to the GPU.": "Der VM-Gast hat exklusiven Zugriff auf die GPU.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "Die VM ist eingeschaltet. Schalten Sie es aus, bevor Sie Festplatten hinzufügen.",
|
||||
@@ -7998,6 +8000,8 @@
|
||||
"VIRTUAL DISK": "VIRTUELLER DATENTRÄGER",
|
||||
"VIRTUAL MACHINE ID": "ID DER VIRTUELLEN MASCHINE",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "VLAN-Tag",
|
||||
"VLAN tag (empty = none)": "VLAN-Tag (leer = keines)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "VLC Medien Player ist ein kostenloser und Open-Source-übergreifender Multimedia-Player und -Framework, der eine zuverlässige Leistung auf mehreren Geräten bietet.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "VM-Konfliktrichtlinie",
|
||||
|
||||
@@ -6533,6 +6533,7 @@
|
||||
"Start VM when completed:": "Iniciar la VM al terminar:",
|
||||
"Start a container. Use the correct <ctid>": "Iniciar un contenedor. Usa el <ctid> correcto",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Iniciar una VM. Usa el <vmid> correcto",
|
||||
"Start command": "Comando de arranque",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Iniciar cada LXC con Proxmox (sin arranque coordinado)",
|
||||
"Start long self-test (hours)": "Iniciar autotest largo (horas)",
|
||||
"Start long test now?": "¿Iniciar una prueba larga ahora?",
|
||||
@@ -6871,6 +6872,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "El número USB puede cambiar después de reconectarse o reiniciar. Este perfil no lo reasigna automáticamente ni gestiona Coral re-enumeración USB. No comparta un dongle ya utilizado por otro servicio.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "El software Unifi-controller es un potente motor de software inalámbrico para empresas, ideal para despliegues con alta densidad de clientes que requieren baja latencia y alta disponibilidad.",
|
||||
"The VA-API device does not exist:": "El dispositivo VA-API no existe:",
|
||||
"The VLAN tag must be a number from 1 to 4094": "La etiqueta VLAN debe ser un número del 1 al 4094",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "La VM también tiene asignados por passthrough PCI estos dispositivos de audio, que suelen añadirse junto con la GPU. ¿Eliminarlos también?",
|
||||
"The VM guest will have exclusive access to the GPU.": "La VM tendrá acceso exclusivo a la GPU.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "La máquina virtual está encendida. Apágala antes de añadir discos.",
|
||||
@@ -7969,6 +7971,8 @@
|
||||
"VIRTUAL DISK": "DISCO VIRTUAL",
|
||||
"VIRTUAL MACHINE ID": "ID DE MÁQUINA VIRTUAL",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "Etiqueta VLAN",
|
||||
"VLAN tag (empty = none)": "Etiqueta VLAN (vacío = ninguna)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "VLC Media Player es un reproductor y framework multimedia gratuito, de código abierto y multiplataforma que ofrece un rendimiento fiable en múltiples dispositivos.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "Política de conflictos de máquinas virtuales",
|
||||
|
||||
@@ -6549,6 +6549,7 @@
|
||||
"Start VM when completed:": "Démarrer la VM une fois terminé :",
|
||||
"Start a container. Use the correct <ctid>": "Démarrez un conteneur. Utilisez le bon <ctid>",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Démarrez une machine virtuelle. Utilisez le bon <vmid>",
|
||||
"Start command": "Commande de démarrage",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Démarrer chaque LXC avec Proxmox (pas de démarrage coordonné)",
|
||||
"Start long self-test (hours)": "Démarrer un autotest long (heures)",
|
||||
"Start long test now?": "Commencer un long test maintenant ?",
|
||||
@@ -6886,6 +6887,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "Le numéro USB peut changer après la reconnexion ou le redémarrage. Ce profil ne le remaprise pas automatiquement ou ne gère pas la réénumération USB Coral. Ne partagez pas un dongle déjà utilisé par un autre service.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "Le logiciel Unifi-controller est un puissant moteur de logiciel sans fil d'entreprise idéal pour les déploiements clients haute densité requi avec faible latence et des performances de pointe.",
|
||||
"The VA-API device does not exist:": "Le dispositif VA-API n'existe pas :",
|
||||
"The VLAN tag must be a number from 1 to 4094": "L'étiquette VLAN doit être un nombre compris entre 1 et 4094",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "La VM dispose également de ces périphériques audio attribués via un relais PCI, généralement ajoutés avec le GPU. Les supprimer aussi ?",
|
||||
"The VM guest will have exclusive access to the GPU.": "L'invité de la VM aura un accès exclusif au GPU.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "La VM est sous tension. Éteignez-le avant d'ajouter des disques.",
|
||||
@@ -7997,6 +7999,8 @@
|
||||
"VIRTUAL DISK": "DISQUE VIRTUEL",
|
||||
"VIRTUAL MACHINE ID": "ID DE MACHINE VIRTUELLE",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "Étiquette VLAN",
|
||||
"VLAN tag (empty = none)": "Étiquette VLAN (vide = aucune)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "VLC Médias Le lecteur est un lecteur multimédia multiplateforme libre et ouvert qui offre des performances fiables sur plusieurs appareils.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "Politique de conflit de VM",
|
||||
|
||||
@@ -6549,6 +6549,7 @@
|
||||
"Start VM when completed:": "Avvia la VM al termine:",
|
||||
"Start a container. Use the correct <ctid>": "Avvia un container. Usa il <ctid> corretto",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Avvia una VM. Usa il <vmid> corretto",
|
||||
"Start command": "Comando di avvio",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Avvia ogni LXC con Proxmox (senza avvio coordinato)",
|
||||
"Start long self-test (hours)": "Avvia autotest lungo (ore)",
|
||||
"Start long test now?": "Iniziare un test lungo adesso?",
|
||||
@@ -6887,6 +6888,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "Il numero USB può cambiare dopo una riconnessione o un riavvio. Questo profilo non lo rimappa automaticamente e non gestisce la rinumerazione della Coral USB. Non condividere una chiavetta già usata da un altro servizio.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "Il software Unifi-controller è un potente sistema di gestione delle reti wireless aziendali, ideale per installazioni con molti client che richiedono bassa latenza e alta disponibilità.",
|
||||
"The VA-API device does not exist:": "Il dispositivo VA-API non esiste:",
|
||||
"The VLAN tag must be a number from 1 to 4094": "Il tag VLAN deve essere un numero da 1 a 4094",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "Alla VM sono assegnati anche questi dispositivi audio tramite passthrough PCI, in genere aggiunti insieme alla GPU. Rimuoverli anche dalla VM?",
|
||||
"The VM guest will have exclusive access to the GPU.": "Il sistema guest della VM avrà accesso esclusivo alla GPU.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "La VM è accesa. Spegnerla prima di aggiungere dischi.",
|
||||
@@ -7998,6 +8000,8 @@
|
||||
"VIRTUAL DISK": "DISCO VIRTUALE",
|
||||
"VIRTUAL MACHINE ID": "ID MACCHINA VIRTUALE",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "Tag VLAN",
|
||||
"VLAN tag (empty = none)": "Tag VLAN (vuoto = nessuna)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "VLC Media Player è un lettore e framework multimediale gratuito, open source e multipiattaforma che offre prestazioni affidabili su diversi dispositivi.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "Politica conflitti VM",
|
||||
|
||||
@@ -6549,6 +6549,7 @@
|
||||
"Start VM when completed:": "Iniciar a VM no fim:",
|
||||
"Start a container. Use the correct <ctid>": "Inicie um contêiner. Use o <ctid> correto",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Inicie uma máquina virtual. Use o <vmid> correto",
|
||||
"Start command": "Comando de arranque",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Iniciar cada LXC com Proxmox (sem arranque coordenado)",
|
||||
"Start long self-test (hours)": "Iniciar um autoteste longo (horas)",
|
||||
"Start long test now?": "Iniciar um teste longo agora?",
|
||||
@@ -6887,6 +6888,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "O número USB pode mudar após reconectar ou reiniciar. Este perfil não remapeá-lo automaticamente ou lidar com a re-enumeração Coral USB. Não partilhe um dongle já utilizado por outro serviço.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "O software Unifique-controlador é um poderoso motor de software sem fio empresarial ideal para implantações de clientes de alta densidade requi com baixa latência e alto desempenho de uptime.",
|
||||
"The VA-API device does not exist:": "O dispositivo VA-API não existe:",
|
||||
"The VLAN tag must be a number from 1 to 4094": "A etiqueta VLAN deve ser um número de 1 a 4094",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "A VM também possui esses dispositivos de áudio atribuídos por meio de passagem PCI — normalmente adicionados junto com a GPU. Remova-os também?",
|
||||
"The VM guest will have exclusive access to the GPU.": "O convidado da VM terá acesso exclusivo à GPU.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "A VM está ligada. Desligue-o antes de adicionar discos.",
|
||||
@@ -7998,6 +8000,8 @@
|
||||
"VIRTUAL DISK": "DISCO VIRTUAL",
|
||||
"VIRTUAL MACHINE ID": "ID DA MÁQUINA VIRTUAL",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "Etiqueta VLAN",
|
||||
"VLAN tag (empty = none)": "Etiqueta VLAN (vazio = nenhuma)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "Mídia VLC O Player é um player multiplataforma multiplataforma gratuito e aberto que oferece desempenho confiável em vários dispositivos.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "Política de conflitos de VM",
|
||||
|
||||
@@ -6551,6 +6551,7 @@
|
||||
"Start VM when completed:": "Spustiť VM po dokončení:",
|
||||
"Start a container. Use the correct <ctid>": "Spustiť kontajner. Použite správne <ctid>",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Spustiť VM. Použite správne <vmid>",
|
||||
"Start command": "Príkaz spustenia",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Spúšťať každý kontajner LXC spolu s Proxmoxom (bez koordinovaného spustenia)",
|
||||
"Start long self-test (hours)": "Spustiť dlhý samotest (hodiny)",
|
||||
"Start long test now?": "Spustiť dlhý test teraz?",
|
||||
@@ -6888,6 +6889,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "Číslo USB sa môže zmeniť po opätovnom pripojení alebo reštarte. Tento profil nie je automaticky premapovať alebo spracovať Coral USB re-enumeration. Nezdieľajte šišku, ktorú už používa iná služba.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "UniFi Controller je výkonný softvér na správu podnikových bezdrôtových sietí s veľkým počtom klientov, nízkou latenciou a vysokou dostupnosťou.",
|
||||
"The VA-API device does not exist:": "Zariadenie VA-API neexistuje:",
|
||||
"The VLAN tag must be a number from 1 to 4094": "Značka VLAN musí byť číslo od 1 do 4094",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "VM má cez PCI passthrough priradené aj tieto audio zariadenia - zvyčajne sa pridávajú spolu s GPU. Odstrániť aj tie?",
|
||||
"The VM guest will have exclusive access to the GPU.": "Hosťovská VM bude mať výhradný prístup ku GPU.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "VM je zapnutá. Pred pridaním diskov ju vypnite.",
|
||||
@@ -7998,6 +8000,8 @@
|
||||
"VIRTUAL DISK": "VIRTUÁLNY DISK",
|
||||
"VIRTUAL MACHINE ID": "ID VIRTUÁLNEHO STROJA",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "Značka VLAN",
|
||||
"VLAN tag (empty = none)": "Značka VLAN (prázdne = žiadna)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "VLC Media Player je bezplatný multiplatformový multimediálny prehrávač a rámec s otvoreným zdrojovým kódom, ktorý poskytuje spoľahlivý výkon na rôznych zariadeniach.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "Riešenie konfliktu VM",
|
||||
|
||||
@@ -6549,6 +6549,7 @@
|
||||
"Start VM when completed:": "Starta VM när det är klart:",
|
||||
"Start a container. Use the correct <ctid>": "Starta en container. Använd rätt <ctid>",
|
||||
"Start a virtual machine. Use the correct <vmid>": "Starta en virtuell maskin. Använd rätt <vmid>",
|
||||
"Start command": "Startkommando",
|
||||
"Start each LXC with Proxmox (no coordinated startup)": "Starta varje LXC med Proxmox (ingen samordnad start)",
|
||||
"Start long self-test (hours)": "Starta ett långt självtest (timmar)",
|
||||
"Start long test now?": "Börja långtest nu?",
|
||||
@@ -6887,6 +6888,7 @@
|
||||
"The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.": "USB-numret kan ändras efter återanslutning eller omstart. Denna profil remap det inte automatiskt eller hantera Coral USB-omräkning. Dela inte en dongel som redan används av en annan tjänst.",
|
||||
"The Unifi-controller software is a powerful, enterprise wireless software engine ideal for high-density client deployments requiring low latency and high uptime performance.": "Unifi-controller-programvaran är en kraftfull, företags trådlös mjukvarumotor idealisk för hög densitetsklientdistributioner requiring låg latens och hög upptidsprestanda.",
|
||||
"The VA-API device does not exist:": "VA-API-enheten finns inte:",
|
||||
"The VLAN tag must be a number from 1 to 4094": "VLAN-taggen måste vara ett tal från 1 till 4094",
|
||||
"The VM also has these audio devices assigned via PCI passthrough — typically added together with the GPU. Remove them too?": "Den virtuella datorn har också dessa ljudenheter tilldelade via PCI-passthrough - vanligtvis läggs till tillsammans med GPU:n. Ta bort dem också?",
|
||||
"The VM guest will have exclusive access to the GPU.": "VM-gästen kommer att ha exklusiv tillgång till GPU:n.",
|
||||
"The VM is powered on. Turn it off before adding disks.": "Den virtuella datorn är påslagen. Stäng av den innan du lägger till diskar.",
|
||||
@@ -7998,6 +8000,8 @@
|
||||
"VIRTUAL DISK": "VIRTUELL DISK",
|
||||
"VIRTUAL MACHINE ID": "ID FÖR VIRTUELL MASKIN",
|
||||
"VLAN": "VLAN",
|
||||
"VLAN tag": "VLAN-tagg",
|
||||
"VLAN tag (empty = none)": "VLAN-tagg (tom = ingen)",
|
||||
"VLC Media Player is a free and open source cross-platform multimedia player and framework that delivers dependable performance across multiple devices.": "VLC media Spelare är en fri och öppen källkod multimediaspelare och ram som levererar pålitlig prestanda över flera enheter.",
|
||||
"VM": "VM",
|
||||
"VM Conflict Policy": "VM-konfliktpolicy",
|
||||
|
||||
@@ -11,6 +11,7 @@ import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from oci_installation_state import container_curl
|
||||
from oci_ui import log, msg_progress, translate
|
||||
|
||||
|
||||
@@ -79,7 +80,14 @@ def containers_ready(items):
|
||||
and bool(by_name['homeassistant'].get('Config', {}).get('Image')))
|
||||
|
||||
|
||||
def http_ready(url, timeout=5):
|
||||
def http_ready(vmid, url, timeout=5):
|
||||
curl = container_curl(vmid, lambda *args: capture(args).encode())
|
||||
if curl != ['curl']:
|
||||
try:
|
||||
return capture([*curl, '-sS', '--noproxy', '*', '-o', '/dev/null', '-w', '%{http_code}',
|
||||
'--max-time', str(timeout), url], timeout=timeout + 1).strip() == '200'
|
||||
except subprocess.SubprocessError:
|
||||
return False
|
||||
# Do not route private healthchecks through an environment HTTP proxy.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
try:
|
||||
@@ -111,10 +119,10 @@ def probe(vmid, ip, remaining):
|
||||
supervisor = json.loads(run(docker + ['exec', 'hassio_cli', 'ha', '--raw-json', 'supervisor', 'info']))
|
||||
if not supervisor_ready(supervisor):
|
||||
raise Pending(translate('Supervisor does not confirm healthy and supported yet'))
|
||||
if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
|
||||
if not http_ready(vmid, f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
|
||||
raise Pending(translate('Observer is not responding on port 4357'))
|
||||
for port in (8123, 80):
|
||||
if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
|
||||
if http_ready(vmid, f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
|
||||
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
|
||||
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
|
||||
raise Pending(translate('Core is running, but not responding over HTTP on 80/8123'))
|
||||
|
||||
@@ -662,6 +662,16 @@ def attach_mounts(service, temporary):
|
||||
oci_nested_mounts.follow(vmid)
|
||||
|
||||
|
||||
def web_answers(vmid, url, opener):
|
||||
from oci_installation_state import container_curl
|
||||
curl = container_curl(vmid, lambda *args: run(*args).encode())
|
||||
if curl != ['curl']:
|
||||
run(*curl, '-fsS', '--noproxy', '*', '-o', '/dev/null', '--max-time', '4', url)
|
||||
return True
|
||||
with opener.open(url,timeout=4) as response:
|
||||
return response.status<400
|
||||
|
||||
|
||||
def wait_web(primary, url):
|
||||
deadline = time.monotonic()+primary['healthcheck']['timeout_seconds']
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
@@ -669,16 +679,15 @@ def wait_web(primary, url):
|
||||
if run('pct','status',primary['vmid']).strip()!='status: running':
|
||||
raise RuntimeError(f"{translate('The container stopped:')} {primary['name']} (CT {primary['vmid']})")
|
||||
try:
|
||||
with opener.open(url,timeout=4) as response:
|
||||
if response.status<400:
|
||||
check = primary['healthcheck']
|
||||
if check.get('type') == 'exec':
|
||||
remaining = max(1, deadline-time.monotonic())
|
||||
process = subprocess.run(['pct', 'exec', str(primary['vmid']), '--', *check['argv']],
|
||||
capture_output=True, timeout=min(30, remaining), check=False)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError('Health check has not succeeded yet')
|
||||
return
|
||||
if web_answers(primary['vmid'], url, opener):
|
||||
check = primary['healthcheck']
|
||||
if check.get('type') == 'exec':
|
||||
remaining = max(1, deadline-time.monotonic())
|
||||
process = subprocess.run(['pct', 'exec', str(primary['vmid']), '--', *check['argv']],
|
||||
capture_output=True, timeout=min(30, remaining), check=False)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError('Health check has not succeeded yet')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if time.monotonic()>=deadline:
|
||||
@@ -1050,7 +1059,9 @@ def main():
|
||||
run('pct','set',s['vmid'],'--startup',f'order={order*10},up=5,down=30')
|
||||
if s['main'] or s.get('frontend'):
|
||||
address = access_address(s.get('frontend_ipv4') or 'dhcp', deployment['network'].get('frontend_gateway'))
|
||||
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address},host-managed=1,firewall=1,type=veth")
|
||||
vlan_tag = deployment['network'].get('frontend_vlan_tag')
|
||||
tag = f",tag={int(vlan_tag)}" if vlan_tag else ''
|
||||
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address}{tag},host-managed=1,firewall=1,type=veth")
|
||||
assign_health_address(s)
|
||||
# Existing CT/network configurations now reserve every allocated ID and subnet.
|
||||
# Never retain the allocation lock while startup/hooks wait for another registry operation.
|
||||
|
||||
@@ -140,10 +140,11 @@ ML_FRONTEND_IPV4=$(jq -r '.network.machine_learning_frontend_ipv4 // "dhcp"' "$D
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
oci_access_net "$ML_FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $ML_FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
ML_FRONTEND_NET=$OCI_ACCESS_NET
|
||||
ML_FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
|
||||
@@ -132,7 +132,8 @@ FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
@@ -511,7 +512,7 @@ if (( START_AFTER == 1 )); then
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
|
||||
if [[ -n $APPLICATION_LAN_IP ]] \
|
||||
&& curl -fsS -H 'Host: localhost' \
|
||||
&& oci_curl "$APPLICATION_ID" -fsS -H 'Host: localhost' \
|
||||
"http://${APPLICATION_LAN_IP}/status.php" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
@@ -521,7 +522,7 @@ if (( START_AFTER == 1 )); then
|
||||
|| die "$(translate "The application did not get an address on the access network:") Nextcloud"
|
||||
# Nextcloud 34 validates status.php against trusted_domains before the LAN IP
|
||||
# can be registered. localhost is the official image's initial trusted host.
|
||||
STATUS_JSON=$(curl -fsS -H 'Host: localhost' \
|
||||
STATUS_JSON=$(oci_curl "$APPLICATION_ID" -fsS -H 'Host: localhost' \
|
||||
"http://${APPLICATION_LAN_IP}/status.php")
|
||||
jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false' \
|
||||
<<<"$STATUS_JSON" >/dev/null \
|
||||
|
||||
@@ -361,10 +361,10 @@ cleanup_canonical_inputs() {
|
||||
healthcheck_probe() {
|
||||
local code status=0
|
||||
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
|
||||
code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$?
|
||||
code=$(oci_curl "$VMID" "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$?
|
||||
(( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0
|
||||
else
|
||||
curl "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$?
|
||||
oci_curl "$VMID" "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$?
|
||||
(( status == 0 )) && return 0
|
||||
fi
|
||||
# A port taken from the compose file may serve a database or SSH: an answer
|
||||
@@ -1044,12 +1044,13 @@ apply_extra_hosts() {
|
||||
}
|
||||
|
||||
# Settings an update gives back to the rebuilt container before it starts:
|
||||
# the LAN leg of a suite or stack member, its start order and current tags.
|
||||
# the LAN leg of a suite or stack member, its start order and current tags,
|
||||
# and what the user set in Proxmox.
|
||||
apply_kept_settings() {
|
||||
local key value
|
||||
while IFS=$'\t' read -r key value; do
|
||||
[[ -n $key ]] || continue
|
||||
[[ $key == net1 || $key == startup || $key == tags ]] \
|
||||
[[ $key =~ ^(net[01]|startup|tags|features|nameserver|searchdomain|timezone|cpulimit|cmode|console|tty|hookscript|entrypoint)$ ]] \
|
||||
|| die "$(translate "Unsupported kept setting:") $key"
|
||||
if [[ $key == tags && -z $value ]]; then
|
||||
oci_quiet pct set "$VMID" --delete tags
|
||||
@@ -1057,6 +1058,13 @@ apply_kept_settings() {
|
||||
oci_quiet pct set "$VMID" "--$key" "$value"
|
||||
fi || die "$(translate "Could not restore the container setting:") $key"
|
||||
done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE")
|
||||
while IFS= read -r value; do
|
||||
[[ -n $value ]] || continue
|
||||
[[ $value =~ ^lxc\.[A-Za-z0-9._-]+:\ ]] \
|
||||
|| die "$(translate "Unsupported kept setting:") $value"
|
||||
printf '%s\n' "$value" >>"$CONF" \
|
||||
|| die "$(translate "Could not restore the container setting:") $value"
|
||||
done < <(jq -r '.custom_lxc // [] | .[]' "$DEPLOYMENT_FILE")
|
||||
}
|
||||
|
||||
apply_runtime_user() {
|
||||
@@ -1714,6 +1722,8 @@ NET="name=eth0,bridge=${BRIDGE},firewall=${FIREWALL},host-managed=1,ip=${IPV4},t
|
||||
if [[ -n $GATEWAY ]]; then
|
||||
NET="${NET},gw=${GATEWAY}"
|
||||
fi
|
||||
oci_vlan_option vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
NET="${NET}${OCI_VLAN_OPTION}"
|
||||
if [[ -n $MAC_ADDRESS ]]; then
|
||||
[[ $MAC_ADDRESS =~ ^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$ ]] \
|
||||
|| die "$(translate "Invalid MAC address:") $MAC_ADDRESS"
|
||||
|
||||
@@ -132,7 +132,8 @@ FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
@@ -513,7 +514,7 @@ if (( START_AFTER == 1 )); then
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
|
||||
if [[ -n $APPLICATION_LAN_IP ]] \
|
||||
&& curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>&1; then
|
||||
&& oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [[ $(pct status "$APPLICATION_ID" 2>/dev/null) != *running* ]]; then
|
||||
@@ -523,7 +524,7 @@ if (( START_AFTER == 1 )); then
|
||||
done
|
||||
[[ -n $APPLICATION_LAN_IP ]] \
|
||||
|| die "$(translate "The application did not get an address on the access network:") Paperless-ngx"
|
||||
curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|
||||
oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|
||||
|| die "$(translate "The application did not pass its HTTP check:") Paperless-ngx"
|
||||
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}:8000/"
|
||||
fi
|
||||
|
||||
@@ -145,7 +145,8 @@ FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
@@ -475,7 +476,7 @@ if (( START_AFTER == 1 )); then
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
|
||||
if [[ -n $APPLICATION_LAN_IP ]] \
|
||||
&& curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>&1; then
|
||||
&& oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [[ $(pct status "$APPLICATION_ID" 2>/dev/null) != *running* ]]; then
|
||||
@@ -488,7 +489,7 @@ if (( START_AFTER == 1 )); then
|
||||
print_first_boot_diagnostics "$APPLICATION_ID" tandoor
|
||||
die "$(translate "The application did not get an address on the access network:") Tandoor"
|
||||
fi
|
||||
curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|
||||
oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|
||||
|| { print_first_boot_diagnostics "$APPLICATION_ID" tandoor; \
|
||||
die "$(translate "The application did not pass its HTTP check:") Tandoor"; }
|
||||
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}/"
|
||||
|
||||
@@ -136,6 +136,16 @@ def parse_config(data):
|
||||
return values
|
||||
|
||||
|
||||
def container_curl(vmid, run=command):
|
||||
"""curl for an address of this container. The host may have no route to the
|
||||
VLAN of an interface: there it runs inside the network of the container."""
|
||||
config = parse_config(run('pct', 'config', str(vmid)))
|
||||
if not any(re.fullmatch(r'net[0-9]+', key) and re.search(r'(^|,)tag=', value) for key, value in config.items()):
|
||||
return ['curl']
|
||||
pid = run('lxc-info', '-n', str(vmid), '-pH').decode().strip()
|
||||
return ['nsenter', f'--net=/proc/{pid}/ns/net', 'curl']
|
||||
|
||||
|
||||
def private_directory(path):
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if path.is_symlink() or path.stat().st_uid != os.geteuid():
|
||||
|
||||
@@ -94,15 +94,11 @@ ENVIRONMENT_KEYS = ('env', 'lxc.environment.runtime')
|
||||
|
||||
def _environment(record, config):
|
||||
"""Variables changed in Proxmox since the last operation. Values are
|
||||
applied but never listed: they may be passwords."""
|
||||
applied but never listed: they may be passwords. A variable the image
|
||||
defines and the user removed comes back with its value, as in Docker."""
|
||||
before = native_modify.environment(record['observed']['config'].encode())
|
||||
now = native_modify.environment(config)
|
||||
defaults = {item.split('=', 1)[0] for item in record['observed']['image'].get('defaults', {}).get('Env', [])
|
||||
if '=' in item}
|
||||
removed = sorted(before.keys() - now.keys())
|
||||
# An image default comes back with a new container; its absence cannot be kept.
|
||||
if any(name in defaults for name in removed):
|
||||
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} env")
|
||||
changed = sorted(name for name in now if before.get(name) != now[name])
|
||||
return {name: now[name] for name in changed}, removed
|
||||
|
||||
@@ -126,14 +122,30 @@ def _adopt_environment(deployment, values, removed):
|
||||
return details
|
||||
|
||||
|
||||
def propose(record, config):
|
||||
if (record.get('status') != 'installed' or record.get('stack') or record.get('stack_member')
|
||||
or record.get('native_stack_intent') or record.get('deployment', {}).get('stack_managed')):
|
||||
def _storage_change(key, value, deployment, vmid):
|
||||
"""A disk resized or moved in Proxmox: the record follows it."""
|
||||
if key == 'rootfs':
|
||||
storage = value.split(':', 1)[0]
|
||||
size = dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part).get('size')
|
||||
deployment['rootfs'].update(storage=storage, size_gb=_managed_size(size))
|
||||
return
|
||||
current = _mount(key, value, vmid)
|
||||
for mount in deployment.get('mounts', []):
|
||||
if mount.get('container_path') == current['container_path']:
|
||||
mount.update({field: current[field] for field in ('source', 'size_gb', 'read_only')})
|
||||
|
||||
|
||||
def propose(record, config, member=False):
|
||||
if (record.get('status') != 'installed' or (not member and (
|
||||
record.get('stack') or record.get('stack_member') or record.get('native_stack_intent')
|
||||
or record.get('deployment', {}).get('stack_managed')))):
|
||||
raise ValueError(translate('Only an installed standalone OCI instance can adopt external changes'))
|
||||
if instances.identity(config) != record['installation_id']:
|
||||
raise ValueError(translate('The container identity changed; nothing was adopted'))
|
||||
before = parse_config(record['observed']['config'].encode())
|
||||
current = parse_config(config)
|
||||
if transaction.nested_mounts.stale(config):
|
||||
transaction.nested_mounts.follow(record['vmid'])
|
||||
config = instances.command('pct', 'config', str(record['vmid']))
|
||||
before, current = transaction.settings(record['observed']['config'].encode()), transaction.settings(config)
|
||||
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
|
||||
# Notes do not describe a mount, device or runtime setting. The OCI marker
|
||||
# was checked above, so a presentation-only change needs no adoption.
|
||||
@@ -143,16 +155,41 @@ def propose(record, config):
|
||||
if any(key in ENVIRONMENT_KEYS for key in changed):
|
||||
changed = [key for key in changed if key not in ENVIRONMENT_KEYS]
|
||||
environment = _environment(record, config)
|
||||
custom = transaction.custom_lxc(record, config)
|
||||
custom_keys = {line.split(': ', 1)[0] for line in (*custom, *record['deployment'].get('custom_lxc', []))}
|
||||
custom_changed = sorted(custom) != sorted(record['deployment'].get('custom_lxc', []))
|
||||
changed = [key for key in changed if key not in custom_keys]
|
||||
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
|
||||
and key not in transaction.KEPT_AS_IS]
|
||||
storage = [key for key in changed if key in current and key in before and re.fullmatch(r'rootfs|mp[0-9]+', key)]
|
||||
detached = [key for key in changed if key not in current and (
|
||||
re.fullmatch(r'dev[0-9]+', key) or (re.fullmatch(r'mp[0-9]+', key) and before[key].startswith('/')))]
|
||||
unsupported = [key for key in changed if key not in new_keys and key not in storage and key not in detached
|
||||
and key != 'net0'
|
||||
and key not in transaction.ADOPTABLE and key not in transaction.KEPT_AS_IS]
|
||||
if unsupported:
|
||||
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}")
|
||||
if not new_keys and environment is None:
|
||||
if not member and not (new_keys or storage or detached or custom_changed or environment is not None
|
||||
or {'net0', 'hostname', 'entrypoint'} & set(changed)):
|
||||
return None
|
||||
candidate = copy.deepcopy(record)
|
||||
deployment = candidate['deployment']
|
||||
if custom:
|
||||
deployment['custom_lxc'] = custom
|
||||
else:
|
||||
deployment.pop('custom_lxc', None)
|
||||
details = _adopt_environment(deployment, *environment) if environment else []
|
||||
for key in storage:
|
||||
_storage_change(key, current[key], deployment, record['vmid'])
|
||||
# A host directory or a device taken away in Proxmox holds no data of the container.
|
||||
for key in detached:
|
||||
fields = dict(part.split('=', 1) for part in before[key].split(',') if '=' in part)
|
||||
if key.startswith('mp'):
|
||||
deployment['mounts'] = [mount for mount in deployment.get('mounts', [])
|
||||
if mount.get('container_path') != fields.get('mp')]
|
||||
else:
|
||||
path = fields.get('path') or before[key].split(',', 1)[0]
|
||||
deployment['devices'] = [device for device in deployment.get('devices', [])
|
||||
if device.get('host_path') != path]
|
||||
for key in new_keys:
|
||||
if key.startswith('mp'):
|
||||
mount = _mount(key, current[key], record['vmid'])
|
||||
@@ -164,12 +201,14 @@ def propose(record, config):
|
||||
deployment.setdefault('devices', []).append(device)
|
||||
kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral/NPU'
|
||||
details.append(f"{key}: {device['host_path']} ({kind})")
|
||||
skipped = [*new_keys, *(ENVIRONMENT_KEYS if environment else ())]
|
||||
skipped = [*new_keys, *storage, *detached, *custom_keys, *(ENVIRONMENT_KEYS if environment else ())]
|
||||
filtered = b'\n'.join(line for line in config.splitlines()
|
||||
if not any(line.startswith(key.encode() + b': ') for key in skipped))
|
||||
if environment:
|
||||
if environment or storage or detached or custom_keys:
|
||||
filtered += b''.join(b'\n' + line for line in record['observed']['config'].encode().splitlines()
|
||||
if any(line.startswith(key.encode() + b': ') for key in ENVIRONMENT_KEYS))
|
||||
if any(line.startswith(key.encode() + b': ')
|
||||
for key in (*storage, *detached, *custom_keys,
|
||||
*(ENVIRONMENT_KEYS if environment else ()))))
|
||||
filtered += b'\n'
|
||||
for key, value in transaction.external_changes(record, filtered).items():
|
||||
section, name = transaction.ADOPTABLE[key]
|
||||
@@ -179,31 +218,36 @@ def propose(record, config):
|
||||
candidate['observed']['config_sha256'] = sha(config)
|
||||
candidate['observed']['gpu_devices'] = gpu_devices.capture(config)
|
||||
candidate['observed']['host_bind_sources'] = host_mounts.capture_sources(config)
|
||||
transaction.preflight(candidate, copy.deepcopy(candidate), config)
|
||||
return {'candidate': candidate, 'details': details,
|
||||
transaction.preflight(candidate, copy.deepcopy(candidate), config, coordinated=member)
|
||||
# A disk or a device is reviewed; the rest is what the user already decided.
|
||||
return {'candidate': candidate, 'details': details, 'review': bool(new_keys),
|
||||
'previous_record_hash': sha(instances.location(instances.ROOT, record['vmid']).read_bytes()),
|
||||
'config_sha256': sha(config)}
|
||||
|
||||
|
||||
def commit(root, vmid, proposal):
|
||||
with instances.locked(root):
|
||||
record = instances.read(root, vmid)
|
||||
path = instances.location(root, vmid)
|
||||
if sha(path.read_bytes()) != proposal['previous_record_hash']:
|
||||
raise ValueError(translate('The OCI contract changed while reviewing; nothing was adopted'))
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if sha(config) != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed while reviewing; nothing was adopted'))
|
||||
checked = propose(record, config)
|
||||
if checked is None or checked['details'] != proposal['details']:
|
||||
raise ValueError(translate('The proposed changes no longer match; nothing was adopted'))
|
||||
candidate = checked['candidate']
|
||||
observed = instances.observe(vmid, record['installation_id'], record['observed']['archive_path'],
|
||||
record['observed']['resolved_registry_digest'], record['observed']['image'])
|
||||
if observed['config_sha256'] != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed during verification; nothing was adopted'))
|
||||
candidate['observed'] = observed
|
||||
history = path.parent / 'history'
|
||||
instances.write(history / f'before-reconciliation-{uuid.uuid4().hex}.json', record)
|
||||
instances.write(path, candidate)
|
||||
return candidate
|
||||
return commit_locked(root, vmid, proposal)
|
||||
|
||||
|
||||
def commit_locked(root, vmid, proposal, member=False):
|
||||
record = instances.read(root, vmid)
|
||||
path = instances.location(root, vmid)
|
||||
if sha(path.read_bytes()) != proposal['previous_record_hash']:
|
||||
raise ValueError(translate('The OCI contract changed while reviewing; nothing was adopted'))
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if sha(config) != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed while reviewing; nothing was adopted'))
|
||||
checked = propose(record, config, member)
|
||||
if checked is None or checked['details'] != proposal['details']:
|
||||
raise ValueError(translate('The proposed changes no longer match; nothing was adopted'))
|
||||
candidate = checked['candidate']
|
||||
observed = instances.observe(vmid, record['installation_id'], record['observed']['archive_path'],
|
||||
record['observed']['resolved_registry_digest'], record['observed']['image'])
|
||||
if observed['config_sha256'] != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed during verification; nothing was adopted'))
|
||||
candidate['observed'] = observed
|
||||
history = path.parent / 'history'
|
||||
instances.write(history / f'before-reconciliation-{uuid.uuid4().hex}.json', record)
|
||||
instances.write(path, candidate)
|
||||
return candidate
|
||||
|
||||
@@ -27,7 +27,7 @@ import uuid
|
||||
from urllib.parse import unquote
|
||||
|
||||
import oci_instances as instances
|
||||
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
|
||||
from oci_installation_state import container_curl, image_from_archive, parse_config, private_directory, sha
|
||||
from verify_oci_archive import verify_archive
|
||||
import oci_host_mounts as host_mounts
|
||||
import oci_nested_mounts as nested_mounts
|
||||
@@ -484,11 +484,78 @@ def candidate_contract(record, operation, proposal=None):
|
||||
|
||||
|
||||
# Proxmox settings the rebuilt container gets back exactly as they are: the
|
||||
# LAN leg ProxMenux adds to a suite or stack member, the start order and tags.
|
||||
KEPT_AS_IS = ('net1', 'startup', 'tags')
|
||||
# LAN leg ProxMenux adds to a suite or stack member, the start order, the tags
|
||||
# and what the user set there. ProxMenux does not decide any of them.
|
||||
KEPT_AS_IS = ('net1', 'startup', 'tags', 'features', 'nameserver', 'searchdomain', 'timezone',
|
||||
'cpulimit', 'cmode', 'console', 'tty', 'hookscript', 'protection')
|
||||
ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'),
|
||||
'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'),
|
||||
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')}
|
||||
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot'),
|
||||
'hostname': (None, 'hostname'), 'entrypoint': (None, 'custom_entrypoint'),
|
||||
'bridge': ('network', 'bridge'), 'ipv4': ('network', 'ipv4'),
|
||||
'gateway': ('network', 'gateway'), 'firewall': ('network', 'firewall'),
|
||||
'vlan_tag': ('network', 'vlan_tag')}
|
||||
|
||||
|
||||
def network_fields(net):
|
||||
"""What a network line says that the record of an application keeps."""
|
||||
fields = dict(item.split('=', 1) for item in (net or '').split(',') if '=' in item)
|
||||
tag = fields.get('tag')
|
||||
return {'bridge': fields.get('bridge'), 'ipv4': fields.get('ip'), 'gateway': fields.get('gw'),
|
||||
'firewall': fields.get('firewall', '0') == '1', 'vlan_tag': int(tag) if tag else None}
|
||||
|
||||
|
||||
# What the engine writes itself among the lxc.* lines of a container.
|
||||
ENGINE_LXC = ('lxc.environment', 'lxc.init.', 'lxc.signal.', 'lxc.console.', 'lxc.hook.', 'lxc.include',
|
||||
'lxc.idmap', 'lxc.mount.auto')
|
||||
|
||||
|
||||
def without_backup_snapshot(config):
|
||||
"""The configuration without the mark of a snapshot a backup left behind:
|
||||
it is not a setting, and a snapshot of the user is not this one."""
|
||||
return b''.join(line for line in config.splitlines(keepends=True) if line.strip() != b'parent: vzdump')
|
||||
|
||||
|
||||
def drop_backup_snapshot(vmid):
|
||||
"""A disk with a snapshot cannot be moved. The one an interrupted backup
|
||||
left goes the way Proxmox removes it before its next backup."""
|
||||
config = run('pct', 'config', str(vmid))
|
||||
if without_backup_snapshot(config) != config and 'lock' not in parse_config(config):
|
||||
run('pct', 'delsnapshot', str(vmid), 'vzdump', '--force', '1')
|
||||
|
||||
|
||||
def settings(config):
|
||||
"""The settings of a configuration to compare with those saved: without
|
||||
that mark, and without the lines that follow the mount points."""
|
||||
return parse_config(nested_mounts.without(without_backup_snapshot(config)))
|
||||
|
||||
|
||||
def custom_lxc(record, config):
|
||||
"""The raw lxc.* lines the user added in Proxmox: the ones saved before that
|
||||
are still there, and the new ones. A rebuilt container gets them back."""
|
||||
before = record['observed']['config'].splitlines()
|
||||
saved = record['deployment'].get('custom_lxc', [])
|
||||
lines = config.decode().splitlines()
|
||||
return [line for line in lines if line.startswith('lxc.') and ': ' in line
|
||||
and not line.startswith(ENGINE_LXC) and not nested_mounts.own(line)
|
||||
and (line in saved or line not in before)]
|
||||
|
||||
|
||||
def kept_settings(record, candidate, cfg):
|
||||
"""What a rebuilt container takes from the one it replaces. The network and
|
||||
the features go back as they are unless Modify has just changed them.
|
||||
Protection comes back at the end: a protected container cannot be replaced."""
|
||||
kept = {key: cfg[key] for key in KEPT_AS_IS if key in cfg and key != 'protection'}
|
||||
if 'cores' not in cfg:
|
||||
kept.pop('cpulimit', None)
|
||||
if candidate['deployment'].get('security') != record['deployment'].get('security'):
|
||||
kept.pop('features', None)
|
||||
if 'net0' in cfg and candidate['deployment'].get('network') == record['deployment'].get('network'):
|
||||
kept['net0'] = cfg['net0']
|
||||
# Only a command the user set: the one of the image changes with the image.
|
||||
if candidate['deployment'].get('custom_entrypoint') and 'entrypoint' in cfg:
|
||||
kept['entrypoint'] = cfg['entrypoint']
|
||||
return kept
|
||||
|
||||
|
||||
def external_changes(record, config, adopt=True):
|
||||
@@ -496,7 +563,7 @@ def external_changes(record, config, adopt=True):
|
||||
values. Any change the new container cannot keep is refused."""
|
||||
if sha(config) == record['observed']['config_sha256']:
|
||||
return {}
|
||||
before, now = parse_config(record['observed']['config'].encode()), parse_config(config)
|
||||
before, now = settings(record['observed']['config'].encode()), settings(config)
|
||||
changed = sorted(key for key in before.keys() | now.keys() if before.get(key) != now.get(key))
|
||||
if ('description' in changed
|
||||
and instances.identity(record['observed']['config'].encode()) == record['installation_id']
|
||||
@@ -504,10 +571,19 @@ def external_changes(record, config, adopt=True):
|
||||
changed.remove('description')
|
||||
cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores'
|
||||
values = {}
|
||||
# The network line stays as the user left it: a stack carries it over as
|
||||
# it is, and an application also takes it into its record.
|
||||
if 'net0' in changed:
|
||||
changed.remove('net0')
|
||||
if adopt:
|
||||
old_net, new_net = network_fields(before.get('net0')), network_fields(now.get('net0'))
|
||||
values.update({key: value for key, value in new_net.items() if old_net[key] != value})
|
||||
for key in changed if adopt else ():
|
||||
value = now.get(key)
|
||||
if key == 'onboot':
|
||||
values[key] = value == '1'
|
||||
elif key in ('hostname', 'entrypoint') and value:
|
||||
values[key] = value
|
||||
elif key == 'cpuunits' and value is None:
|
||||
values[key] = None
|
||||
elif key in ('memory', 'swap', 'cpuunits', cores_key) and value and re.fullmatch(r'[0-9]+', value):
|
||||
@@ -540,6 +616,10 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
|
||||
target = desired.setdefault(section, {}) if section else desired
|
||||
if target.get(name) == recorded.get(name):
|
||||
target[name] = value
|
||||
# Lines the user added are carried over and are not part of what is checked.
|
||||
custom = deployment.get('custom_lxc', [])
|
||||
config = b''.join(line for line in without_backup_snapshot(config).splitlines(keepends=True)
|
||||
if line.decode().rstrip('\n') not in custom)
|
||||
cfg = parse_config(config)
|
||||
for key in ('lxc.init.uid', 'lxc.init.gid'):
|
||||
if key in cfg and not re.fullmatch(r'[0-9]+', cfg[key]):
|
||||
@@ -818,7 +898,7 @@ def healthcheck(vmid, template):
|
||||
ok = False
|
||||
if ip:
|
||||
any_status = check.get('accept_any_status', False)
|
||||
args = ['curl', '-sS' if any_status else '-fsS', '--noproxy', '*', '-o', '/dev/null',
|
||||
args = [*container_curl(vmid, run), '-sS' if any_status else '-fsS', '--noproxy', '*', '-o', '/dev/null',
|
||||
'--max-time', str(check.get('request_timeout_seconds', 5))]
|
||||
if any_status:
|
||||
args += ['-w', '%{http_code}']
|
||||
@@ -933,6 +1013,8 @@ def commit(root, journal, state):
|
||||
gpu_devices.verify(state.get('desired_gpu_devices', {}))
|
||||
gpu_devices.check(config, state['candidate_contract']['deployment'])
|
||||
checkpoint(journal, state, 'committing')
|
||||
if state.get('protected'):
|
||||
run('pct', 'set', str(state['vmid']), '--protection', '1')
|
||||
result = copy.deepcopy(state['candidate_contract'])
|
||||
result.update(status='installed', completed_at=instances.now(),
|
||||
last_transaction=state['id'],
|
||||
@@ -1087,6 +1169,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
if 'stack' in expected:
|
||||
record['stack'] = copy.deepcopy(expected['stack'])
|
||||
candidate = candidate_contract(record, operation, proposal)
|
||||
drop_backup_snapshot(vmid)
|
||||
before = run('pct', 'config', str(vmid))
|
||||
cfg, actual, mac = preflight(record, candidate, before, coordinated)
|
||||
description = original_description({'record': record, 'before_config': before.decode()})
|
||||
@@ -1140,7 +1223,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
'external_data_acknowledged': acknowledge_external_data,
|
||||
'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu,
|
||||
'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}
|
||||
state['kept_config'] = kept_settings(record, candidate, cfg)
|
||||
state['protected'] = cfg.get('protection') == '1'
|
||||
# The installer adds default tags. Preserve an explicit absence too, so
|
||||
# deleting all tags in Proxmox does not bring them back on the next update.
|
||||
state['kept_config']['tags'] = cfg.get('tags', '')
|
||||
@@ -1150,11 +1234,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
state['runtime_deployment']['kept_proxmox_settings'] = state['kept_config']
|
||||
if 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']['tags'] = cfg.get('tags', '')
|
||||
if operation == 'update':
|
||||
state['preserved_stack_config'].update({key: cfg[key] for key in
|
||||
('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits') if key in cfg})
|
||||
state['preserved_stack_config'] = dict(state['kept_config'])
|
||||
if operation == 'update' and 'cpuunits' in cfg:
|
||||
state['preserved_stack_config']['cpuunits'] = cfg['cpuunits']
|
||||
checkpoint(journal, state, 'prepared')
|
||||
pending = copy.deepcopy(record)
|
||||
pending.update(status='updating', pending_transaction=str(journal), transaction_id=state['id'])
|
||||
@@ -1227,6 +1309,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
firewall = Path(f'/etc/pve/firewall/{vmid}.fw')
|
||||
state['firewall_config'] = firewall.read_text() if firewall.exists() else None
|
||||
checkpoint(journal, state, 'replacing-root')
|
||||
if state.get('protected'):
|
||||
run('pct', 'set', str(vmid), '--delete', 'protection')
|
||||
run('pct', 'destroy', str(vmid))
|
||||
checkpoint(journal, state, 'installing-candidate')
|
||||
if show:
|
||||
|
||||
@@ -42,8 +42,9 @@ def environment(config):
|
||||
entries.extend(line.split(': ', 1)[1].split('\0'))
|
||||
for entry in entries:
|
||||
name, sep, value = entry.partition('=')
|
||||
if not sep or name in result:
|
||||
if not sep:
|
||||
raise ValueError(translate('Duplicate variable in the contract; review it before editing'))
|
||||
# A variable set twice takes its last value, as it does in LXC.
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
@@ -129,7 +130,7 @@ def plan(record, candidate, config):
|
||||
values[native] = str(value)
|
||||
if 'network' in changed:
|
||||
network = new['network']
|
||||
if set(network) - {'bridge', 'ipv4', 'gateway', 'firewall', 'host_managed', 'mac_address'}:
|
||||
if set(network) - {'bridge', 'ipv4', 'gateway', 'firewall', 'host_managed', 'mac_address', 'vlan_tag'}:
|
||||
raise ValueError(translate('Unsupported network settings'))
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.:-]+', network['bridge']):
|
||||
raise ValueError(translate('Invalid bridge'))
|
||||
@@ -146,6 +147,11 @@ def plan(record, candidate, config):
|
||||
fields.update(bridge=network['bridge'], ip=address)
|
||||
if gateway: fields['gw'] = gateway
|
||||
else: fields.pop('gw', None)
|
||||
vlan_tag = network.get('vlan_tag')
|
||||
if vlan_tag is not None and (isinstance(vlan_tag, bool) or not isinstance(vlan_tag, int) or not 1 <= vlan_tag <= 4094):
|
||||
raise ValueError(translate('The VLAN tag must be a number from 1 to 4094'))
|
||||
if vlan_tag: fields['tag'] = str(vlan_tag)
|
||||
elif 'vlan_tag' in old['network']: fields.pop('tag', None)
|
||||
for key, native in (('firewall', 'firewall'), ('host_managed', 'host-managed')):
|
||||
if old['network'].get(key) != network.get(key):
|
||||
fields[native] = '1' if network.get(key) else '0'
|
||||
|
||||
@@ -65,6 +65,12 @@ def check(config):
|
||||
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
|
||||
|
||||
|
||||
def stale(config):
|
||||
"""Whether a mount point taken away left its lines behind."""
|
||||
wanted = {line for group in groups(config) for line in group}
|
||||
return any(own(line) and line not in wanted for line in config.decode().splitlines())
|
||||
|
||||
|
||||
def follow(vmid):
|
||||
"""Write the lines of the mount points the container has now, and no
|
||||
others. Any snapshot section is left as it is."""
|
||||
|
||||
@@ -204,12 +204,14 @@ def filter_config(config, deployment, runtime_config=None):
|
||||
declared = set(tmpfs_lines(deployment, runtime_config if runtime_config is not None else config))
|
||||
files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
|
||||
|
||||
custom = {line.encode() for line in deployment.get('custom_lxc', [])}
|
||||
|
||||
def own(line):
|
||||
entry = line.decode().strip().split(': ', 1)[1]
|
||||
match = FILE_BIND.fullmatch(entry)
|
||||
return entry in declared or bool(match and match.group(1) in files)
|
||||
return b''.join(line for line in config.splitlines(keepends=True)
|
||||
if not line.startswith(b'lxc.include: ') and not (
|
||||
if not line.startswith(b'lxc.include: ') and line.rstrip(b'\n') not in custom and not (
|
||||
line.startswith(b'lxc.mount.entry: ') and own(line)))
|
||||
|
||||
|
||||
|
||||
@@ -183,14 +183,36 @@ def register(root, vmid):
|
||||
if path not in listed and (gpu_devices.gpu_path(path)
|
||||
or gpu_devices.peripheral_path(path))]
|
||||
instances.write(instances.location(root, vmid), record)
|
||||
snapshot(root, vmid)
|
||||
|
||||
|
||||
def adopt(root, vmid):
|
||||
"""Take into the record what was changed in Proxmox on a member, so an
|
||||
operation on the stack keeps it. The caller holds the registry lock."""
|
||||
record = instances.read(root, vmid)
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if instances.same_config_except_notes(record, config, ignore_tags=True):
|
||||
return
|
||||
if (record['deployment'].get('replay_profile') or {}).get('adapter') in CONVERTERS:
|
||||
register(root, vmid)
|
||||
return
|
||||
proposal = reconcile.propose(record, config, member=True)
|
||||
if proposal:
|
||||
reconcile.commit_locked(root, vmid, proposal, member=True)
|
||||
snapshot(root, vmid)
|
||||
|
||||
|
||||
def snapshot(root, vmid):
|
||||
"""Refresh the copy of a member its main container keeps."""
|
||||
record = instances.read(root, vmid)
|
||||
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
|
||||
primary = instances.read(root, primary_id)
|
||||
snapshot = instances.read(root, vmid)
|
||||
snapshot.pop('stack', None)
|
||||
copied = instances.read(root, vmid)
|
||||
copied.pop('stack', None)
|
||||
members = primary.get('stack', {}).get('members', [])
|
||||
for index, member in enumerate(members):
|
||||
if member.get('vmid') == vmid:
|
||||
members[index] = snapshot
|
||||
members[index] = copied
|
||||
instances.write(instances.location(root, primary_id), primary)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import oci_stack_plan
|
||||
import oci_stack_transaction as stack_tx
|
||||
import oci_stack_replay as replay
|
||||
import oci_work_backup as work_backup
|
||||
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
|
||||
from oci_installation_state import container_curl, image_from_archive, parse_config, private_directory, sha
|
||||
from oci_update_current import resolve_archive
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
|
||||
|
||||
@@ -545,9 +545,9 @@ class NativeAdapter:
|
||||
# Main/custom web services retain both contracts: an exec
|
||||
# check never replaces the advertised HTTP readiness probe.
|
||||
if check.get('url') and (self.services[vmid].get('frontend') or check.get('endpoint')):
|
||||
member_tx.run('curl', '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
member_tx.run(*container_curl(vmid, member_tx.run), '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
elif check['type'] == 'http':
|
||||
member_tx.run('curl', '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
member_tx.run(*container_curl(vmid, member_tx.run), '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
elif check['type'] == 'tcp':
|
||||
with socket.create_connection(tcp_target(check), timeout=5):
|
||||
pass
|
||||
@@ -848,6 +848,10 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None,
|
||||
result = stack_tx.execute(journal, adapter)
|
||||
msg_ok(translate('Stack recovery completed; every member is back to its previous installation.'))
|
||||
return result
|
||||
import oci_stack_modify
|
||||
for member in primary['stack']['members']:
|
||||
oci_stack_modify.adopt(root, member['vmid'])
|
||||
primary = instances.read(root, primary_id)
|
||||
records, inventory = {}, {}
|
||||
for snapshot in primary['stack']['members']:
|
||||
member_id = snapshot['vmid']
|
||||
|
||||
@@ -507,8 +507,11 @@ def normalize(record):
|
||||
names = set()
|
||||
def add_environment(value):
|
||||
name, equals, content = value.partition('=')
|
||||
if not equals or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in names:
|
||||
if not equals or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name):
|
||||
raise ValueError(translate('Ambiguous or invalid environment variable'))
|
||||
# A variable set twice takes its last value, as it does in LXC.
|
||||
if name in names:
|
||||
environment[:] = [item for item in environment if item['name'] != name]
|
||||
names.add(name)
|
||||
environment.append({'name': name, 'value': content})
|
||||
for line in config.splitlines():
|
||||
|
||||
@@ -228,6 +228,29 @@ oci_log_tail() {
|
||||
tail -n "${1:-15}" "$OCI_LOG" | sed "s/^/${_OCI_TAB} /"
|
||||
}
|
||||
|
||||
# The host may have no route to the VLAN of an interface: there curl runs
|
||||
# inside the network of the container.
|
||||
oci_curl() {
|
||||
local vmid=$1
|
||||
shift
|
||||
if grep -Eq '^net[0-9]+:.*,tag=' "/etc/pve/lxc/${vmid}.conf" 2>/dev/null; then
|
||||
nsenter --net="/proc/$(lxc-info -n "$vmid" -pH)/ns/net" curl "$@"
|
||||
else
|
||||
curl "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# tag= option of an access interface from the given network key of the
|
||||
# deployment, in OCI_VLAN_OPTION. Returns 1 when the tag is not valid.
|
||||
oci_vlan_option() {
|
||||
local tag
|
||||
OCI_VLAN_OPTION=""
|
||||
tag=$(jq -r --arg key "$1" '.network[$key] // empty' "$DEPLOYMENT_FILE")
|
||||
[[ -z $tag ]] && return 0
|
||||
[[ $tag =~ ^[0-9]+$ ]] && (( tag >= 1 && tag <= 4094 )) || return 1
|
||||
OCI_VLAN_OPTION=",tag=${tag}"
|
||||
}
|
||||
|
||||
# ip= and gw= options of an access interface, DHCP or a static IPv4 with an
|
||||
# optional gateway, in OCI_ACCESS_NET. Returns 1 when a value is not valid.
|
||||
oci_access_net() {
|
||||
|
||||
@@ -162,13 +162,16 @@ def kept_settings(changes, deployment):
|
||||
"""Names of the settings changed in Proxmox that the new container keeps."""
|
||||
labels = {'memory': translate('Memory'), 'swap': translate('Swap'), 'cores': translate('CPU cores'),
|
||||
'cpulimit': translate('CPU cores'), 'cpuunits': translate('CPU priority'),
|
||||
'onboot': translate('Start with Proxmox')}
|
||||
'onboot': translate('Start with Proxmox'), 'hostname': translate('Hostname'),
|
||||
'entrypoint': translate('Start command'),
|
||||
'bridge': translate('Network'), 'ipv4': translate('Network'), 'gateway': translate('Network'),
|
||||
'firewall': translate('Network'), 'vlan_tag': translate('VLAN tag')}
|
||||
kept = []
|
||||
for key, value in changes.items():
|
||||
section, name = transaction.ADOPTABLE[key]
|
||||
if (deployment.get(section, {}) if section else deployment).get(name) == value:
|
||||
kept.append(labels[key])
|
||||
return kept
|
||||
return list(dict.fromkeys(kept))
|
||||
|
||||
|
||||
def application_name(record, vmid):
|
||||
|
||||
@@ -85,6 +85,7 @@ def build_suite(template, ui, mode='advanced'):
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, drafts, storage)
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), 'vmbr0', mode)
|
||||
vlan_tag = access.ask_vlan_tag(quiet)
|
||||
reachable = [app for app in selected if app != 'unpackerr']
|
||||
labels, gateway = access.ask_addresses(ui, bridge, [app.capitalize() for app in reachable])
|
||||
addresses = dict(zip(reachable, labels.values()))
|
||||
@@ -148,5 +149,5 @@ def build_suite(template, ui, mode='advanced'):
|
||||
translate('Gluetun/VPN not yet available: this suite does not route downloads through a VPN.')
|
||||
],
|
||||
'rootfs_storage':storage,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10',
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,**({'frontend_vlan_tag':vlan_tag} if vlan_tag else {}),'private_allocation':'automatic','private_bridge':'vmbr10',
|
||||
'private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'}}
|
||||
|
||||
@@ -338,11 +338,13 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
|
||||
static = [address for address in addresses if address != "dhcp"]
|
||||
row(translate("Network"), f"{network['frontend_bridge']} · {', '.join(static) if static else 'DHCP'}"
|
||||
+ (f" · gw {network['frontend_gateway']}" if network.get("frontend_gateway") else "")
|
||||
+ (f" · VLAN {network['frontend_vlan_tag']}" if network.get("frontend_vlan_tag") else "")
|
||||
+ f" · {translate('private network assigned automatically')}")
|
||||
elif network:
|
||||
ipv4 = network.get("ipv4", "dhcp")
|
||||
row(translate("Network"), f"{network.get('bridge', '-')} · {'DHCP' if ipv4 == 'dhcp' else ipv4}"
|
||||
+ (f" · gw {network['gateway']}" if network.get("gateway") else ""))
|
||||
+ (f" · gw {network['gateway']}" if network.get("gateway") else "")
|
||||
+ (f" · VLAN {network['vlan_tag']}" if network.get("vlan_tag") else ""))
|
||||
devices = plan.get("devices", [])
|
||||
if plan.get("hardware_profile") or devices:
|
||||
profiles = (template.get("proxmox", {}).get("installer_profile", {})
|
||||
|
||||
@@ -589,6 +589,7 @@ def build_deployment(
|
||||
if key not in tmpfs_mounts[-1] and installer_profile.get("volume_owner", {}).get(key) is not None:
|
||||
tmpfs_mounts[-1][key] = installer_profile["volume_owner"][key]
|
||||
|
||||
vlan_tag = None
|
||||
if advanced:
|
||||
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
|
||||
if mac_address:
|
||||
@@ -597,6 +598,7 @@ def build_deployment(
|
||||
ipv4, gateway = "host", None
|
||||
else:
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge)
|
||||
vlan_tag = access.ask_vlan_tag(ui)
|
||||
else:
|
||||
bridge = ask_bridge(ui, "", defaults["bridge"], DEFAULT_MODE)
|
||||
if host_monitor:
|
||||
@@ -760,6 +762,7 @@ def build_deployment(
|
||||
"firewall": defaults["firewall"],
|
||||
"host_managed": defaults["host_managed_network"],
|
||||
"mac_address": mac_address,
|
||||
**({"vlan_tag": vlan_tag} if vlan_tag else {}),
|
||||
},
|
||||
"features": defaults.get("features", []),
|
||||
"security": {
|
||||
@@ -832,6 +835,7 @@ def _build_rclone_deployment(
|
||||
raise InstallError(translate("The web UI password must have at least 24 characters"))
|
||||
bridge = ask_bridge(ui, translate("Network bridge"), schema["bridge"]["default"])
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge)
|
||||
vlan_tag = access.ask_vlan_tag(ui)
|
||||
|
||||
return {
|
||||
"vmid": int(vmid_text) if vmid_text else None,
|
||||
@@ -851,6 +855,7 @@ def _build_rclone_deployment(
|
||||
"firewall": defaults["firewall"],
|
||||
"host_managed": defaults["host_managed_network"],
|
||||
"mac_address": None,
|
||||
**({"vlan_tag": vlan_tag} if vlan_tag else {}),
|
||||
},
|
||||
"features": defaults["features"],
|
||||
"security": {
|
||||
@@ -1082,6 +1087,7 @@ def _build_immich_deployment(
|
||||
addresses, frontend_gateway = access.ask_addresses(
|
||||
essential_ui(ui), frontend_bridge, [translate("Immich server"), "Immich Machine learning"])
|
||||
server_ipv4, ml_ipv4 = addresses.values()
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
(video_acceleration, render_device, vaapi_driver,
|
||||
ml_acceleration, ml_render) = _ask_immich_acceleration(ui, rootfs_storage)
|
||||
@@ -1112,6 +1118,7 @@ def _build_immich_deployment(
|
||||
"frontend_ipv4": server_ipv4,
|
||||
"machine_learning_frontend_ipv4": ml_ipv4,
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": defaults["private_network"]["bridge"],
|
||||
"private_subnet": defaults["private_network"]["subnet"],
|
||||
@@ -1199,6 +1206,7 @@ def _build_nextcloud_stack_deployment(
|
||||
extra_mounts = ask_application_extra_paths(ui, ["/var/www/html"], application_storage or rootfs_storage)
|
||||
frontend_bridge = ask_bridge(ui, translate("Access bridge for Nextcloud"), defaults["frontend_network"]["bridge"])
|
||||
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
admin_username = ui.ask(
|
||||
translate("Initial administrator user"), defaults["application"]["admin_username"]
|
||||
@@ -1239,6 +1247,7 @@ def _build_nextcloud_stack_deployment(
|
||||
"frontend_bridge": frontend_bridge,
|
||||
"frontend_ipv4": addresses[""],
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": private["bridge"],
|
||||
"private_subnet": private["subnet"],
|
||||
@@ -1323,6 +1332,7 @@ def _build_paperless_stack_deployment(
|
||||
"/usr/src/paperless/export"], application_storage)
|
||||
frontend_bridge = ask_bridge(ui, translate("Access bridge for Paperless"), defaults["frontend_network"]["bridge"])
|
||||
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
ocr_language = ui.ask(translate("OCR language (Tesseract code)"), defaults["ocr_language"])
|
||||
if not re.fullmatch(r"[a-z]{3}(?:\+[a-z]{3})*", ocr_language):
|
||||
@@ -1368,6 +1378,7 @@ def _build_paperless_stack_deployment(
|
||||
"frontend_bridge": frontend_bridge,
|
||||
"frontend_ipv4": addresses[""],
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": private["bridge"],
|
||||
"private_subnet": private["subnet"],
|
||||
@@ -1450,6 +1461,7 @@ def _build_tandoor_stack_deployment(
|
||||
ui, ["/opt/recipes/mediafiles", "/opt/recipes/staticfiles"], static_storage)
|
||||
frontend_bridge = ask_bridge(ui, translate("Access bridge for Tandoor"), defaults["frontend_network"]["bridge"])
|
||||
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
allowed_hosts = ui.ask(
|
||||
translate("Allowed hosts (comma separated; * allows access through the assigned IP)"),
|
||||
@@ -1510,6 +1522,7 @@ def _build_tandoor_stack_deployment(
|
||||
"frontend_bridge": frontend_bridge,
|
||||
"frontend_ipv4": addresses[""],
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": private["bridge"],
|
||||
"private_subnet": private["subnet"],
|
||||
|
||||
@@ -455,12 +455,11 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
|
||||
return False
|
||||
if adoption:
|
||||
summary = '\n'.join(adoption['details'])
|
||||
# Variables reordered in Proxmox change nothing that needs a decision.
|
||||
if summary and not ui.review(
|
||||
if adoption['review'] and not ui.review(
|
||||
f"{translate('These changes were made in Proxmox outside ProxMenux:')}\n\n{summary}\n\n"
|
||||
+ translate('They will be saved in oci-compose before continuing.'),
|
||||
translate('Review external OCI changes'),
|
||||
question=translate('Include these changes in oci-compose?'), default=False):
|
||||
question=translate('Include these changes in oci-compose?'), default=True):
|
||||
return False
|
||||
try:
|
||||
record = reconcile.commit(instances.ROOT, row['vmid'], adoption)
|
||||
@@ -694,7 +693,7 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
|
||||
|
||||
class UnattendedUI:
|
||||
"""The answers of a scheduled run: a step with a positive default goes on,
|
||||
and one that needs a person (adopting external changes) stops the run."""
|
||||
and one that needs a person stops the run."""
|
||||
unattended = True
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -123,6 +123,16 @@ def ask_addresses(ui, bridge: str, labels: list[str], current: dict[str, str] |
|
||||
return {label: str(interface) for label, interface in interfaces.items()}, gateway
|
||||
|
||||
|
||||
def ask_vlan_tag(ui, current: int | None = None) -> int | None:
|
||||
"""The VLAN of the access interface, None when it carries no tag."""
|
||||
text = str(ui.ask(translate("VLAN tag (empty = none)"), str(current or ""), required=False) or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if not re.fullmatch(r"[0-9]+", text) or not 1 <= int(text) <= 4094:
|
||||
raise ValueError(translate("The VLAN tag must be a number from 1 to 4094"))
|
||||
return int(text)
|
||||
|
||||
|
||||
def ask_ipv4(ui, bridge: str, current: str | None = None,
|
||||
current_gateway: str | None = None) -> tuple[str, str | None]:
|
||||
addresses, gateway = ask_addresses(ui, bridge, [""], {"": current} if current else None, current_gateway)
|
||||
|
||||
@@ -27,7 +27,11 @@ def edit_network(deployment, ui):
|
||||
network = deployment['network']
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), network['bridge'])
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge, network.get('ipv4'), network.get('gateway'))
|
||||
vlan_tag = access.ask_vlan_tag(ui, network.get('vlan_tag'))
|
||||
network.update(bridge=bridge, ipv4=ipv4, gateway=gateway)
|
||||
network.pop('vlan_tag', None)
|
||||
if vlan_tag:
|
||||
network['vlan_tag'] = vlan_tag
|
||||
|
||||
|
||||
def edit_acceleration(candidate, ui, confirm=True):
|
||||
@@ -119,7 +123,8 @@ def modification_options(candidate):
|
||||
('cores', row(translate('CPU cores'), resources['cores'])),
|
||||
('memory', row(translate('RAM in MiB'), resources['memory_mb'])),
|
||||
('network', row(translate('Network'),
|
||||
f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}")),
|
||||
f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}"
|
||||
+ (f" · VLAN {network['vlan_tag']}" if network.get('vlan_tag') else ''))),
|
||||
# Only the paths added by hand: the application's own volumes are not extra.
|
||||
('paths', row(translate('Extra paths'), sum(1 for m in deployment.get('mounts', []) if m.get('custom')))),
|
||||
]
|
||||
|
||||
@@ -655,6 +655,7 @@ def build_stack(template, ui, mode='advanced'):
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, drafts, volumes)
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'], mode)
|
||||
vlan_tag = access.ask_vlan_tag(quiet)
|
||||
frontend_names = [
|
||||
s['name'] for s in services
|
||||
if (s['name'] == template['compose_stack']['main_service']
|
||||
@@ -765,7 +766,7 @@ def build_stack(template, ui, mode='advanced'):
|
||||
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
|
||||
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
|
||||
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10','private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'},
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,**({'frontend_vlan_tag':vlan_tag} if vlan_tag else {}),'private_allocation':'automatic','private_bridge':'vmbr10','private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'},
|
||||
'services':plans}
|
||||
|
||||
|
||||
|
||||
@@ -67,13 +67,11 @@ class ExternalTagsTests(unittest.TestCase):
|
||||
self.assertEqual(transaction.external_changes(record(), current), {'cores': 4, 'memory': 2048})
|
||||
|
||||
def test_tag_changes_do_not_bypass_identity_or_network_checks(self):
|
||||
for current in (config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222'),
|
||||
config('homelab').replace(b'bridge=vmbr0', b'bridge=vmbr2')):
|
||||
with self.subTest(config=current):
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record(), current)
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(record(), current)
|
||||
current = config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222')
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record(), current)
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(record(), current + b'arch: arm64\n')
|
||||
|
||||
def test_new_volume_still_requires_adoption_when_tags_also_change(self):
|
||||
saved = record()
|
||||
@@ -90,6 +88,51 @@ class ExternalTagsTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(path.read_text()), saved)
|
||||
|
||||
|
||||
def test_a_vlan_tag_set_in_proxmox_is_taken_into_the_record(self):
|
||||
for saved_net, current_net, expected in ((b'ip=dhcp\nnet1', b'ip=dhcp,tag=4\nnet1', 4),
|
||||
(b'ip=dhcp,tag=4\nnet1', b'ip=dhcp\nnet1', None)):
|
||||
with self.subTest(expected=expected), tempfile.TemporaryDirectory() as folder:
|
||||
saved = record()
|
||||
before = config('oci;proxmenux').replace(b'ip=dhcp\nnet1', saved_net)
|
||||
saved['observed'].update(config=before.decode(), config_sha256=sha(before))
|
||||
root = Path(folder)
|
||||
path = root / '200/oci-compose.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(saved))
|
||||
current = before.replace(saved_net, current_net)
|
||||
with patch.object(reconcile.instances, 'ROOT', root):
|
||||
proposal = reconcile.propose(saved, current)
|
||||
self.assertEqual(proposal['candidate']['deployment']['network']['vlan_tag'], expected)
|
||||
self.assertEqual(proposal['details'], [])
|
||||
|
||||
def test_a_host_directory_taken_away_in_proxmox_leaves_the_record(self):
|
||||
saved = record()
|
||||
before = config('oci;proxmenux') + b'mp0: /srv/share,mp=/share,backup=0\n'
|
||||
saved['observed'].update(config=before.decode(), config_sha256=sha(before))
|
||||
saved['deployment']['mounts'] = [{'type': 'host-bind', 'container_path': '/share', 'source': '/srv/share'}]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
path = root / '200/oci-compose.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(saved))
|
||||
with patch.object(reconcile.instances, 'ROOT', root):
|
||||
proposal = reconcile.propose(saved, config('oci;proxmenux'))
|
||||
self.assertEqual(proposal['candidate']['deployment']['mounts'], [])
|
||||
self.assertFalse(proposal['review'])
|
||||
|
||||
def test_a_data_volume_taken_away_in_proxmox_still_stops_the_update(self):
|
||||
saved = record()
|
||||
before = config('oci;proxmenux') + b'mp0: local-lvm:vm-200-disk-1,mp=/data,size=8G,backup=1\n'
|
||||
saved['observed'].update(config=before.decode(), config_sha256=sha(before))
|
||||
with self.assertRaisesRegex(ValueError, 'mp0'):
|
||||
reconcile.propose(saved, config('oci;proxmenux'))
|
||||
|
||||
def test_a_network_changed_in_proxmox_is_taken_into_the_record(self):
|
||||
current = config('oci;proxmenux').replace(b'bridge=vmbr0', b'bridge=vmbr9')
|
||||
self.assertEqual(transaction.external_changes(record(), current), {'bridge': 'vmbr9'})
|
||||
self.assertEqual(transaction.external_changes(record(), current, adopt=False), {})
|
||||
|
||||
|
||||
class Prepared(Exception):
|
||||
"""Stop a mocked transaction before it can stop or modify any container."""
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class GuessedEndpointCheckTests(unittest.TestCase):
|
||||
fake = Path(directory) / 'curl'
|
||||
fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n')
|
||||
fake.chmod(0o755)
|
||||
script = (probe_function() + '\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n'
|
||||
script = (probe_function() + '\noci_curl() { shift; curl "$@"; }\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n'
|
||||
f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n')
|
||||
env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'}
|
||||
return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode
|
||||
|
||||
@@ -78,7 +78,7 @@ class ProposalTests(unittest.TestCase):
|
||||
self.assertIsNone(reconcile.propose(record, updated))
|
||||
self.assertEqual(reconcile.transaction.external_changes(record, updated), {})
|
||||
|
||||
def test_notes_cannot_hide_identity_or_network_changes(self):
|
||||
def test_notes_cannot_hide_identity_or_platform_changes(self):
|
||||
marker = '11111111-1111-1111-1111-111111111111'
|
||||
config = f'description: Old notes proxmenux-instance={marker}\n'.encode()
|
||||
record = {
|
||||
@@ -88,7 +88,7 @@ class ProposalTests(unittest.TestCase):
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record, b'description: Different instance\n')
|
||||
changed = f'description: New notes proxmenux-instance={marker}\nnet0: name=eth0,bridge=vmbr1\n'.encode()
|
||||
changed = f'description: New notes proxmenux-instance={marker}\narch: arm64\n'.encode()
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record, changed)
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -197,10 +197,11 @@ class EnvironmentAdoptionTests(unittest.TestCase):
|
||||
proposal = self.propose(record, 'APP_PASSWORD=first-secret\0PATH=/bin')
|
||||
self.assertEqual(proposal['details'], [])
|
||||
|
||||
def test_removing_an_image_default_is_refused(self):
|
||||
def test_an_image_default_removed_in_proxmox_comes_back_with_its_value(self):
|
||||
record = self.record('PATH=/bin\0APP_PASSWORD=first-secret')
|
||||
with self.assertRaisesRegex(ValueError, 'env'):
|
||||
self.propose(record, 'APP_PASSWORD=first-secret')
|
||||
proposal = self.propose(record, 'APP_PASSWORD=first-secret')
|
||||
self.assertFalse(proposal['review'])
|
||||
self.assertNotIn('PATH', [item['name'] for item in proposal['candidate']['deployment']['environment']])
|
||||
|
||||
def test_removing_a_saved_variable_removes_it_from_the_contract(self):
|
||||
record = self.record('PATH=/bin\0APP_PASSWORD=first-secret')
|
||||
@@ -211,9 +212,9 @@ class EnvironmentAdoptionTests(unittest.TestCase):
|
||||
def test_other_changes_next_to_the_variables_are_still_refused(self):
|
||||
record = self.record('PATH=/bin')
|
||||
config = (f'description: proxmenux-instance={self.marker}\nenv: PATH=/bin\0A=1\n'
|
||||
'net0: name=eth0,bridge=vmbr1\n').encode()
|
||||
'arch: arm64\n').encode()
|
||||
with (patch.object(reconcile.instances, 'identity', return_value=self.marker),
|
||||
self.assertRaisesRegex(ValueError, 'net0')):
|
||||
self.assertRaisesRegex(ValueError, 'arch')):
|
||||
reconcile.propose(record, config)
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class NativeExecWebReadinessTests(unittest.TestCase):
|
||||
adapter.healthcheck(4800)
|
||||
else:
|
||||
adapter.healthcheck(4800)
|
||||
self.assertEqual(probes, ['pct', 'curl'])
|
||||
self.assertEqual(probes, ['pct', 'pct', 'curl'])
|
||||
finally:
|
||||
server.shutdown(); server.server_close(); thread.join(timeout=2)
|
||||
|
||||
|
||||
@@ -61,6 +61,24 @@ class NativePlanTests(unittest.TestCase):
|
||||
self.assertEqual(fields['tag'], '20'); self.assertEqual(fields['ip6'], 'auto')
|
||||
self.assertEqual(fields['ip'], '192.0.2.2/24'); self.assertEqual(fields['gw'], '192.0.2.1')
|
||||
|
||||
def test_the_vlan_tag_is_set_changed_and_removed(self):
|
||||
for saved, wanted, expected in ((None, 30, '30'), (20, 30, '30'), (20, None, None)):
|
||||
with self.subTest(saved=saved, wanted=wanted):
|
||||
self.record['deployment']['network'].pop('vlan_tag', None)
|
||||
self.candidate['deployment']['network'].pop('vlan_tag', None)
|
||||
if saved: self.record['deployment']['network']['vlan_tag'] = saved
|
||||
if wanted: self.candidate['deployment']['network']['vlan_tag'] = wanted
|
||||
self.candidate['deployment']['network']['ipv4'] = '192.0.2.2/24'
|
||||
with patch.object(Path, 'is_dir', return_value=True):
|
||||
result = native.plan(self.record, self.candidate, self.config)
|
||||
fields = dict(x.split('=',1) for x in result['values']['net0'].split(','))
|
||||
self.assertEqual(fields.get('tag'), expected)
|
||||
|
||||
def test_an_invalid_vlan_tag_is_rejected_before_stopping(self):
|
||||
self.candidate['deployment']['network']['vlan_tag'] = 5000
|
||||
with patch.object(Path, 'is_dir', return_value=True), self.assertRaises(ValueError):
|
||||
native.plan(self.record, self.candidate, self.config)
|
||||
|
||||
def test_invalid_network_is_rejected_before_stopping(self):
|
||||
for value in ('nonsense', '2001:db8::1/64'):
|
||||
self.candidate['deployment']['network']['ipv4'] = value
|
||||
|
||||
@@ -131,7 +131,7 @@ class SlovakEditorTests(unittest.TestCase):
|
||||
self.assertIn('Vlastná cesta nemôže skryť systémové adresáre', self.text(ui))
|
||||
|
||||
def test_network_gateway_explains_outbound_route(self):
|
||||
ui = EditorUI(['static'], ['vmbr0', '192.0.2.42/24', ''], confirms=[])
|
||||
ui = EditorUI(['static'], ['vmbr0', '192.0.2.42/24', '', ''], confirms=[])
|
||||
with patch.object(installer.host, 'bridges', return_value=[]), \
|
||||
patch.object(installer.host, 'address_answers', return_value=False):
|
||||
recreation.edit_setting(copy.deepcopy(self.original), ui, 'network')
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The VLAN tag of the access interface is a setting of the installation."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import oci_installation_state as state
|
||||
import oci_instance_transaction as transaction
|
||||
from proxmenux_oci import network
|
||||
|
||||
|
||||
class Answer:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def ask(self, text, default=None, required=True):
|
||||
return self.value if self.value is not None else default
|
||||
|
||||
|
||||
def record(net0):
|
||||
config = f"arch: amd64\nmemory: 1024\nnet0: {net0}\n"
|
||||
return {"observed": {"config": config, "config_sha256": transaction.sha(config.encode())},
|
||||
"installation_id": "x"}, config.encode()
|
||||
|
||||
|
||||
class Question(unittest.TestCase):
|
||||
def test_empty_means_no_tag_and_the_current_one_is_offered(self):
|
||||
self.assertIsNone(network.ask_vlan_tag(Answer("")))
|
||||
self.assertEqual(network.ask_vlan_tag(Answer(None), 40), 40)
|
||||
self.assertEqual(network.ask_vlan_tag(Answer(" 4 ")), 4)
|
||||
|
||||
def test_only_a_valid_vlan_is_accepted(self):
|
||||
for value in ("0", "4095", "abc", "1,2", "-1"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
network.ask_vlan_tag(Answer(value))
|
||||
|
||||
|
||||
class TagSetInProxmox(unittest.TestCase):
|
||||
NET = "name=eth0,bridge=vmbr0,firewall=1,hwaddr=BC:24:11:98:91:D2,ip=dhcp,type=veth"
|
||||
|
||||
def changes(self, before, after, adopt=True):
|
||||
saved, _ = record(before)
|
||||
return transaction.external_changes(saved, record(after)[1], adopt=adopt)
|
||||
|
||||
def test_an_application_takes_the_tag_into_its_record(self):
|
||||
self.assertEqual(self.changes(self.NET, self.NET + ",tag=4"), {"vlan_tag": 4})
|
||||
|
||||
def test_a_tag_removed_in_proxmox_is_taken_as_well(self):
|
||||
self.assertEqual(self.changes(self.NET + ",tag=4", self.NET), {"vlan_tag": None})
|
||||
|
||||
def test_a_stack_member_keeps_the_line_as_it_is(self):
|
||||
self.assertEqual(self.changes(self.NET, self.NET + ",tag=4", adopt=False), {})
|
||||
|
||||
def test_the_rest_of_the_network_line_is_taken_as_well(self):
|
||||
self.assertEqual(self.changes(self.NET, self.NET.replace("vmbr0", "vmbr1") + ",tag=4"),
|
||||
{"bridge": "vmbr1", "vlan_tag": 4})
|
||||
|
||||
|
||||
class Kept(unittest.TestCase):
|
||||
def test_the_update_names_the_tag_it_keeps(self):
|
||||
import oci_update_current as update
|
||||
with patch.object(update, "translate", side_effect=str):
|
||||
self.assertEqual(update.kept_settings({"vlan_tag": 4}, {"network": {"vlan_tag": 4}}), ["VLAN tag"])
|
||||
|
||||
|
||||
class Carried(unittest.TestCase):
|
||||
CONFIG = b"memory: 512\nnameserver: 9.9.9.9\nnet0: name=eth0,mtu=1400,bridge=vmbr0\nprotection: 1\nfeatures: keyctl=1\n"
|
||||
|
||||
def kept(self, candidate_network=None):
|
||||
saved = {"deployment": {"network": {"bridge": "vmbr0"}, "security": {}}}
|
||||
new = {"deployment": {"network": candidate_network or {"bridge": "vmbr0"}, "security": {}}}
|
||||
return transaction.kept_settings(saved, new, state.parse_config(self.CONFIG))
|
||||
|
||||
def test_what_the_user_set_goes_back_to_the_rebuilt_container(self):
|
||||
kept = self.kept()
|
||||
self.assertEqual(kept["nameserver"], "9.9.9.9")
|
||||
self.assertEqual(kept["features"], "keyctl=1")
|
||||
self.assertEqual(kept["net0"], "name=eth0,mtu=1400,bridge=vmbr0")
|
||||
|
||||
def test_protection_comes_back_only_at_the_end(self):
|
||||
self.assertNotIn("protection", self.kept())
|
||||
|
||||
def test_only_a_command_of_the_user_goes_back(self):
|
||||
config = state.parse_config(b"entrypoint: /usr/bin/app --flag\n")
|
||||
same = {"deployment": {}}
|
||||
self.assertNotIn("entrypoint", transaction.kept_settings(same, same, config))
|
||||
custom = {"deployment": {"custom_entrypoint": "/usr/bin/app --flag"}}
|
||||
self.assertEqual(transaction.kept_settings(custom, custom, config)["entrypoint"], "/usr/bin/app --flag")
|
||||
|
||||
def test_a_network_edited_in_modify_is_not_overwritten(self):
|
||||
self.assertNotIn("net0", self.kept({"bridge": "vmbr1"}))
|
||||
|
||||
def test_a_start_command_set_in_proxmox_is_taken(self):
|
||||
saved, config = record("name=eth0,bridge=vmbr0")
|
||||
self.assertEqual(transaction.external_changes(saved, config + b"entrypoint: /bin/app\n"),
|
||||
{"entrypoint": "/bin/app"})
|
||||
|
||||
def test_the_snapshot_a_backup_leaves_is_not_a_change(self):
|
||||
saved, config = record("name=eth0,bridge=vmbr0")
|
||||
self.assertEqual(transaction.external_changes(saved, config + b"parent: vzdump\n"), {})
|
||||
|
||||
def test_a_snapshot_of_the_user_is_not_discarded(self):
|
||||
saved, config = record("name=eth0,bridge=vmbr0")
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(saved, config + b"parent: before-upgrade\n")
|
||||
|
||||
def test_raw_lines_of_the_user_are_told_from_those_of_the_engine(self):
|
||||
before = "lxc.init.cwd: /\nlxc.cgroup2.devices.allow: c 1:1 rwm\n"
|
||||
saved = {"observed": {"config": before}, "deployment": {"custom_lxc": ["lxc.cgroup2.devices.allow: c 1:1 rwm"]}}
|
||||
config = (before + "lxc.mount.entry: /a b none bind 0 0\nlxc.environment.runtime: A=1\n").encode()
|
||||
self.assertEqual(transaction.custom_lxc(saved, config),
|
||||
["lxc.cgroup2.devices.allow: c 1:1 rwm", "lxc.mount.entry: /a b none bind 0 0"])
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def curl(self, net):
|
||||
def run(*args):
|
||||
return b"1234\n" if args[0] == "lxc-info" else f"memory: 512\n{net}\n".encode()
|
||||
return state.container_curl(108, run)
|
||||
|
||||
def test_the_host_looks_at_an_untagged_container(self):
|
||||
self.assertEqual(self.curl("net0: name=eth0,bridge=vmbr0,ip=dhcp"), ["curl"])
|
||||
|
||||
def test_a_tagged_interface_is_looked_at_from_inside(self):
|
||||
expected = ["nsenter", "--net=/proc/1234/ns/net", "curl"]
|
||||
self.assertEqual(self.curl("net0: name=eth0,bridge=vmbr0,tag=4,ip=dhcp"), expected)
|
||||
self.assertEqual(self.curl("net1: name=lan,bridge=vmbr0,tag=4"), expected)
|
||||
|
||||
def test_every_web_check_goes_through_it(self):
|
||||
for name in ("install_oci.sh", "install_paperless_stack.sh", "install_tandoor_stack.sh",
|
||||
"install_nextcloud_stack.sh"):
|
||||
self.assertIn("oci_curl", (ROOT / "remote" / name).read_text(), name)
|
||||
for name in ("oci_instance_transaction.py", "oci_stack_native.py", "install_generic_stack.py",
|
||||
"haos_healthcheck.py"):
|
||||
self.assertIn("container_curl", (ROOT / "remote" / name).read_text(), name)
|
||||
|
||||
|
||||
class Creation(unittest.TestCase):
|
||||
def option(self, network_values):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
deployment = Path(directory) / "deployment.json"
|
||||
deployment.write_text(json.dumps({"network": network_values}))
|
||||
text = (ROOT / "remote/oci_ui.sh").read_text()
|
||||
start = text.index("oci_vlan_option() {")
|
||||
function = text[start:text.index("\n}", start) + 2]
|
||||
return subprocess.run(
|
||||
["/bin/bash", "-c", f'DEPLOYMENT_FILE={deployment}\n{function}\n'
|
||||
'oci_vlan_option vlan_tag; code=$?; echo "$code:$OCI_VLAN_OPTION"'],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
|
||||
def test_the_option_of_the_interface(self):
|
||||
self.assertEqual(self.option({"vlan_tag": 4}), "0:,tag=4")
|
||||
self.assertEqual(self.option({}), "0:")
|
||||
|
||||
def test_an_invalid_tag_stops_the_installation(self):
|
||||
self.assertEqual(self.option({"vlan_tag": 5000}), "1:")
|
||||
self.assertEqual(self.option({"vlan_tag": "4,trunks=1"}), "1:")
|
||||
|
||||
def test_every_installer_applies_it(self):
|
||||
for name in ("install_oci.sh", "install_immich_stack.sh", "install_paperless_stack.sh",
|
||||
"install_nextcloud_stack.sh", "install_tandoor_stack.sh"):
|
||||
self.assertIn("oci_vlan_option", (ROOT / "remote" / name).read_text(), name)
|
||||
self.assertIn("frontend_vlan_tag", (ROOT / "remote/install_generic_stack.py").read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -116,10 +116,10 @@
|
||||
"items": [
|
||||
"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.",
|
||||
"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 are kept and carried into the new contract: variables, memory, swap, cores, CPU limit and priority, start with the node, hostname, start command, network options, VLAN tag, DNS, time zone, features, protection, disk sizes and added lxc.* lines. A variable removed there leaves the contract and, if the image defines it, the new container has the value of the image. A disk or device added there is saved in the contract: the menu shows it for review, and a scheduled update accepts it.",
|
||||
"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.",
|
||||
"A host directory is recognised by its path and by the filesystem it is on, not by the device name the host gives its disk, so a USB or network disk that comes back under another name is still the same directory. When its disk is not connected, the update stops before the CT is stopped and names the directory that is missing.",
|
||||
"Any other difference between the CT and its contract stops the update before the CT is stopped."
|
||||
"A change that cannot be carried over, such as a data volume removed, another architecture or a snapshot made by the user, stops the update before the CT is stopped."
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
],
|
||||
[
|
||||
"Scheduled updates",
|
||||
"The image is updated at the chosen time only when the registry publishes a new one, and optionally only once it is 1, 3, 7 or 14 days old. A container with changes made outside ProxMenux is skipped and reported."
|
||||
"The image is updated at the chosen time only when the registry publishes a new one, and optionally only once it is 1, 3, 7 or 14 days old. Settings changed in Proxmox are kept."
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -223,6 +223,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"p": "The access interface can carry a VLAN tag. The advanced installation asks for it next to the address, and Modify changes or removes it on a single application. A tag set in Proxmox is kept. With a tag, ProxMenux checks the application from inside its container, so the host does not need access to that VLAN."
|
||||
},
|
||||
{
|
||||
"flow": {
|
||||
"nodes": [
|
||||
|
||||
@@ -116,10 +116,10 @@
|
||||
"items": [
|
||||
"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.",
|
||||
"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 se conservan y pasan al contrato nuevo: variables, memoria, swap, núcleos, límite y prioridad de CPU, arranque con el nodo, nombre del host, comando de arranque, opciones de red, etiqueta VLAN, DNS, zona horaria, features, protección, tamaños de disco y líneas lxc.* añadidas. Una variable eliminada allí sale del contrato y, si la imagen la define, el contenedor nuevo tiene el valor de la imagen. Un disco o dispositivo añadido allí se guarda en el contrato: el menú lo muestra para revisarlo, y una actualización programada lo acepta.",
|
||||
"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.",
|
||||
"Un directorio del host se reconoce por su ruta y por el sistema de ficheros en el que está, no por el nombre de dispositivo que el host da a su disco, así que un disco USB o de red que vuelve con otro nombre sigue siendo el mismo directorio. Si su disco no está conectado, la actualización se detiene antes de parar el CT e indica qué directorio falta.",
|
||||
"Cualquier otra diferencia entre el CT y su contrato detiene la actualización antes de detener el CT."
|
||||
"Un cambio que no se puede conservar, como un volumen de datos eliminado, otra arquitectura o una instantánea hecha por el usuario, detiene la actualización antes de detener el CT."
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
],
|
||||
[
|
||||
"Actualizaciones programadas",
|
||||
"La imagen se actualiza a la hora elegida solo cuando el registro publica una nueva y, opcionalmente, solo cuando tiene 1, 3, 7 o 14 días. Un contenedor con cambios hechos fuera de ProxMenux se salta y se notifica."
|
||||
"La imagen se actualiza a la hora elegida solo cuando el registro publica una nueva y, opcionalmente, solo cuando tiene 1, 3, 7 o 14 días. Los ajustes cambiados en Proxmox se conservan."
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -223,6 +223,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"p": "La interfaz de acceso puede llevar una etiqueta VLAN. La instalación avanzada la pregunta junto a la dirección, y Modificar la cambia o la elimina en una aplicación de un contenedor. Una etiqueta puesta en Proxmox se conserva. Con etiqueta, ProxMenux comprueba la aplicación desde dentro de su contenedor, así que el host no necesita acceso a esa VLAN."
|
||||
},
|
||||
{
|
||||
"flow": {
|
||||
"nodes": [
|
||||
|
||||
Reference in New Issue
Block a user