mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix: clarify OCI update dialog and distinguish cancellation
This commit is contained in:
@@ -28,6 +28,7 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_oci_update_cancellation.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
|
||||
@@ -116,7 +116,7 @@ class RecoveryMessages(unittest.TestCase):
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: self.primary
|
||||
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'_remove': lambda *args: self.fail('remove must not run'),
|
||||
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Real native OCI menu/CLI paths with all external effects replaced."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_instance_reconcile as reconcile
|
||||
|
||||
|
||||
class UpdateCancellationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
|
||||
title='Fixture application', hostname='fixture', image='fixture:latest')
|
||||
self.ui = Mock()
|
||||
self.ui.unattended = False
|
||||
self.ui.review.return_value = False
|
||||
for module, name, options in [
|
||||
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
|
||||
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
|
||||
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
|
||||
(menu, 'recover_automatically', dict(return_value=None)),
|
||||
(menu, 'carry_records', dict(return_value={})),
|
||||
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
|
||||
(menu, 'saved_inventory', dict(return_value=[self.row])),
|
||||
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
|
||||
(instances, 'read', dict(return_value=self.record)),
|
||||
(reconcile, 'propose', dict(return_value=None)),
|
||||
]:
|
||||
item = patch.object(module, name, **options)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = self.guard(menu, '_run_lifecycle')
|
||||
self.backup = self.guard(menu, '_work_backup_ready')
|
||||
self.write = self.guard(instances, 'write')
|
||||
self.command = self.guard(instances, 'command')
|
||||
self.commit = self.guard(reconcile, 'commit')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
|
||||
mock.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
|
||||
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
result = cli.main(['manage', '110', '--action', 'update'])
|
||||
self.assertEqual(result, 6)
|
||||
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
|
||||
self.no_effects()
|
||||
|
||||
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
|
||||
self.row['stack'] = True
|
||||
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
result = menu.direct_management(ROOT / 'oci', 110, 'update')
|
||||
self.assertEqual(result, 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_no(self):
|
||||
self.ui.choose.side_effect = ['110', 'update', None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT / 'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_no_does_not_start_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
|
||||
rows = [self.row, {**self.row, 'vmid': 111}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
|
||||
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
|
||||
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_update_failure_keeps_failure_code_one(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
command = self.lifecycle.call_args.args[0]
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
|
||||
self.assertIn('--acknowledge-external-data', command)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_unattended_positive_default_still_reaches_existing_update(self):
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
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()):
|
||||
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)
|
||||
with patch.object(menu, 'saved_inventory', return_value=[]):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
|
||||
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
|
||||
self.assertIn(part, text)
|
||||
self.assertNotIn('kanál obrazov', text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -24,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
|
||||
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
|
||||
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
|
||||
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
|
||||
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
|
||||
'the new image to update the container. If the update fails after the container was changed, the backup '
|
||||
'is restored. An update that stops halfway is recovered from the OCI management menu.')
|
||||
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
|
||||
'integrity is verified. The container is then updated using the new image.\n\n'
|
||||
'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
|
||||
'installation from the backup. If the operation remains incomplete, select the application in OCI '
|
||||
'management and choose "Recover the previous installation".\n\n'
|
||||
'Host directories mounted in the container are not restored by the container backup.')
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
@@ -130,7 +133,7 @@ class UpdateWording(unittest.TestCase):
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path,
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
|
||||
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
|
||||
texts = oci_menu_texts()
|
||||
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
|
||||
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
|
||||
self.assertEqual(len(previews), 3)
|
||||
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
|
||||
update = next(text for text in texts if text.startswith('If a newer application image is available'))
|
||||
self.assertIn('attempts to restore the previous installation', update)
|
||||
self.assertIn('If the operation remains incomplete', update)
|
||||
self.assertIn('choose "Recover the previous installation"', update)
|
||||
|
||||
def test_the_recovery_preview_says_when_each_step_happens(self):
|
||||
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { scriptCompletion } from "@/lib/script-completion"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface WebInteraction {
|
||||
@@ -52,6 +53,7 @@ interface ScriptTerminalModalProps {
|
||||
params?: Record<string, string>
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
cancelledExitCode?: number
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
@@ -69,6 +71,7 @@ export function ScriptTerminalModal({
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
cancelledExitCode,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
@@ -103,10 +106,12 @@ export function ScriptTerminalModal({
|
||||
|
||||
const getCompletionMessage = useCallback(
|
||||
(exitCode: number) =>
|
||||
exitCode === 0
|
||||
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
|
||||
? t("scriptTerminal.cancelled")
|
||||
: exitCode === 0
|
||||
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
|
||||
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, t],
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
|
||||
)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -139,6 +144,11 @@ export function ScriptTerminalModal({
|
||||
reconnectTimeoutRef.current = setTimeout(async () => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
// Replacing a stale socket is not a script result. Its close event
|
||||
// must not complete the operation before the new socket reports it.
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onerror = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
@@ -176,6 +186,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -189,10 +200,10 @@ const initMessage = {
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
termRef.current?.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
termRef.current?.writeln(`\x1b[${scriptCompletion(exitCode, cancelledExitCode).color}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -223,7 +234,7 @@ const initMessage = {
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
termRef.current?.writeln(`\x1b[${scriptCompletion(exitCode, cancelledExitCode).color}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -261,7 +272,7 @@ const initMessage = {
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -392,6 +403,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -405,10 +417,10 @@ const initMessage = {
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
term.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
term.writeln(`\x1b[${scriptCompletion(exitCode, cancelledExitCode).color}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -441,7 +453,7 @@ const initMessage = {
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
term.writeln(`\x1b[${scriptCompletion(exitCode, cancelledExitCode).color}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -814,7 +826,7 @@ const initMessage = {
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -22,6 +22,7 @@ import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
|
||||
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
|
||||
import { GuestBackupList } from "./guest-backup-list"
|
||||
import type { GuestBackup } from "../lib/guest-backup-archives"
|
||||
@@ -978,6 +979,8 @@ export function VirtualMachines() {
|
||||
// and has no record on this host until it is recovered from the menu.
|
||||
const [ociRestored, setOciRestored] = useState(false)
|
||||
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null)
|
||||
const ociCompletionCodeRef = useRef<number | undefined>(undefined)
|
||||
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
|
||||
|
||||
// Firewall log state — fetched only when the operator opens that tab
|
||||
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
|
||||
@@ -8014,6 +8017,7 @@ export function VirtualMachines() {
|
||||
onClose={() => {
|
||||
const vmid = ociAction.vmid
|
||||
setOciAction(null)
|
||||
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
|
||||
// The image, the digest and the record changed: read them again.
|
||||
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
|
||||
.catch(() => undefined)
|
||||
@@ -8027,6 +8031,8 @@ export function VirtualMachines() {
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
|
||||
scriptName="oci_manage_instance"
|
||||
cancelledExitCode={OCI_CANCELLED_EXIT_CODE}
|
||||
onComplete={(exitCode) => { ociCompletionCodeRef.current = exitCode }}
|
||||
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate")
|
||||
: ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover")
|
||||
: t("vmLxc.ociUpdates.terminalTitleModify")}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Native OCI `manage` reserves this code for an explicitly declined update.
|
||||
export const OCI_CANCELLED_EXIT_CODE = 6
|
||||
|
||||
export function scriptCompletion(exitCode: number, cancelledExitCode?: number) {
|
||||
if (exitCode === 0) return { kind: "success", color: "32" } as const
|
||||
if (cancelledExitCode !== undefined && exitCode === cancelledExitCode) {
|
||||
return { kind: "cancelled", color: "36" } as const
|
||||
}
|
||||
return { kind: "error", color: "31" } as const
|
||||
}
|
||||
|
||||
export function shouldRefreshOciAfterCompletion(exitCode?: number) {
|
||||
return exitCode !== OCI_CANCELLED_EXIT_CODE
|
||||
}
|
||||
@@ -5395,6 +5395,7 @@
|
||||
"websocketError": "WebSocket error occurred",
|
||||
"connectionClosed": "Connection closed",
|
||||
"completedSuccessfully": "Script completed successfully",
|
||||
"cancelled": "Operation cancelled.",
|
||||
"completedWithError": "Script finished with error code {code}",
|
||||
"yes": "Yes",
|
||||
"yourInput": "Your input:",
|
||||
|
||||
@@ -5319,6 +5319,7 @@
|
||||
"websocketError": "Chyba WebSocket spojenia",
|
||||
"connectionClosed": "Spojenie bolo zatvorené",
|
||||
"completedSuccessfully": "Skript sa úspešne dokončil",
|
||||
"cancelled": "Operácia bola zrušená.",
|
||||
"completedWithError": "Skript skončil s chybovým kódom {code}",
|
||||
"yes": "Áno",
|
||||
"yourInput": "Tvoj vstup:",
|
||||
|
||||
+6
-5
@@ -4506,7 +4506,7 @@
|
||||
"Operation cancelled by user": "Operácia bola zrušená používateľom",
|
||||
"Operation cancelled by user to create backup.": "Operácia bola zrušená používateľom kvôli vytvoreniu zálohy.",
|
||||
"Operation cancelled by user.": "Operácia zrušená používateľom.",
|
||||
"Operation cancelled.": "Operation zrušený.",
|
||||
"Operation cancelled.": "Operácia bola zrušená.",
|
||||
"Operation cancelled. Cannot continue with an unprivileged container.": "Operácia bola zrušená. S neprivilegovaným kontajnerom sa nedá pokračovať.",
|
||||
"Operation log": "Záznam operácie",
|
||||
"Operator config re-applied via kernel-agnostic merge": "Vlastné nastavenie správcu bolo znovu použité cez zlúčenie nezávislé od kernelu",
|
||||
@@ -5070,7 +5070,7 @@
|
||||
"Recover or complete the operation?": "Obnoviť alebo dokončiť operation?",
|
||||
"Recover restored OCI applications": "Obnovte obnovené aplikácie OCI",
|
||||
"Recover the keyfile using your recovery passphrase?": "Obnoviť keyfile pomocou obnovovacej frázy?",
|
||||
"Recover the previous installation": "Obnoviť predchádzajúce zariadenie",
|
||||
"Recover the previous installation": "Obnoviť predchádzajúcu inštaláciu",
|
||||
"Recover them now?": "Obnoviť ich teraz?",
|
||||
"Recoverable:": "Obnoviteľné:",
|
||||
"Recovering the previous installation": "Obnovenie predchádzajúceho zariadenia",
|
||||
@@ -7200,7 +7200,7 @@
|
||||
"Update Export": "Aktualizovať export",
|
||||
"Update Lynis to latest version": "Aktualizovať Lynis na najnovšiu verziu",
|
||||
"Update NVIDIA in LXC Containers": "Aktualizovať NVIDIA v LXC kontajneroch",
|
||||
"Update OCI": "Aktualizovať OCI",
|
||||
"Update OCI": "Aktualizovať aplikáciu",
|
||||
"Update OCI stack": "Aktualizovať OCI stack",
|
||||
"Update PBS using configured repositories": "Aktualizovať PBS pomocou nakonfigurovaných repozitárov",
|
||||
"Update PVE enterprise repository (Only if using enterprise):": "Aktualizujte PVE enterprise repozitár (iba ak používate enterprise):",
|
||||
@@ -7216,7 +7216,7 @@
|
||||
"Update credentials": "Aktualizovať prihlasovacie údaje",
|
||||
"Update every container of the application": "Aktualizovať každý kontajner aplikácie",
|
||||
"Update kernel to compatible version": "Aktualizovať kernel na kompatibilnú verziu",
|
||||
"Update now?": "Aktualizácia?",
|
||||
"Update now?": "Spustiť aktualizáciu?",
|
||||
"Update package index:": "Aktualizujte index balíkov:",
|
||||
"Update prepared": "Aktualizácia",
|
||||
"Update selected OCI applications": "Aktualizovať vybrané aplikácie OCI",
|
||||
@@ -8263,5 +8263,6 @@
|
||||
"The main Compose service has no image.": "Hlavná služba v súbore Compose nemá zadaný žiadny obraz.",
|
||||
"Invalid Compose volume path:": "Neplatná cesta dátového úložiska v súbore Compose:",
|
||||
"Invalid Compose port:": "Neplatný port v súbore Compose:",
|
||||
"Invalid application identifier:": "Neplatný identifikátor aplikácie:"
|
||||
"Invalid application identifier:": "Neplatný identifikátor aplikácie:",
|
||||
"If a newer application image is available, the container is stopped and backed up, and the backup integrity is verified. The container is then updated using the new image.\n\nIf the update fails after the container was changed, ProxMenux attempts to restore the previous installation from the backup. If the operation remains incomplete, select the application in OCI management and choose \"Recover the previous installation\".\n\nHost directories mounted in the container are not restored by the container backup.": "Ak je dostupný novší obraz aplikácie, kontajner sa zastaví, vytvorí sa jeho záloha a overí sa jej integrita. Potom sa kontajner aktualizuje pomocou nového obrazu.\n\nAk aktualizácia po zmene kontajnera zlyhá, ProxMenux sa pokúsi obnoviť predchádzajúcu inštaláciu zo zálohy. Ak operácia zostane nedokončená, v správe OCI vyberte danú aplikáciu a možnosť „Obnoviť predchádzajúcu inštaláciu“.\n\nPripojené adresáre hostiteľa sa zo zálohy kontajnera neobnovujú."
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating
|
||||
'assembling': N_('assembling')}
|
||||
|
||||
|
||||
class CancelledOperation:
|
||||
"""An explicit user decline; remains false for menu and batch callers."""
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
|
||||
CANCELLED = CancelledOperation()
|
||||
|
||||
|
||||
def public_row(record, decision):
|
||||
return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname',
|
||||
f'OCI {record["vmid"]}'),
|
||||
@@ -461,9 +471,9 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
|
||||
proposal = edit_recreation(record, ui)
|
||||
if proposal is None:
|
||||
return False
|
||||
elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'),
|
||||
elif not ui.review(translate('If a newer application image is available, the container is stopped and backed up, and the backup integrity is verified. The container is then updated using the new image.\n\nIf the update fails after the container was changed, ProxMenux attempts to restore the previous installation from the backup. If the operation remains incomplete, select the application in OCI management and choose "Recover the previous installation".\n\nHost directories mounted in the container are not restored by the container backup.'),
|
||||
translate('Update OCI'), question=translate('Update now?'), default=True):
|
||||
return False
|
||||
return CANCELLED
|
||||
# Opening/cancelling a confirmation needs no disk or storage probes.
|
||||
# Both accepted operations retain this check before the backend starts.
|
||||
if not _work_backup_ready(project, ui, row['vmid']):
|
||||
@@ -650,7 +660,7 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
|
||||
'fails after a container was changed, the backups are restored. An operation that stops '
|
||||
'halfway is recovered from the OCI management menu.').format(count=len(members), vmid=primary_id),
|
||||
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
|
||||
return False
|
||||
return CANCELLED
|
||||
else:
|
||||
if not ui.review(translate('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.'), translate('Recover OCI stack'),
|
||||
question=translate('Recover or complete the operation?'), default=True):
|
||||
@@ -707,6 +717,7 @@ class UnattendedUI:
|
||||
|
||||
# Exit codes of `manage`, read by ProxMenux Monitor.
|
||||
EXIT_DONE, EXIT_FAILED, EXIT_NOT_OCI, EXIT_BUSY, EXIT_NEEDS_REVIEW, EXIT_TOO_RECENT = 0, 1, 2, 3, 4, 5
|
||||
EXIT_CANCELLED = 6 # Interactive update confirmation only; never a successful update.
|
||||
|
||||
|
||||
def _image_too_recent(project, vmid, min_age_days):
|
||||
@@ -765,4 +776,6 @@ def direct_management(project, vmid, action, lifecycle_args=(), unattended=False
|
||||
return EXIT_BUSY
|
||||
if unattended and ui.declined:
|
||||
return EXIT_NEEDS_REVIEW
|
||||
if completed is CANCELLED and not unattended:
|
||||
return EXIT_CANCELLED
|
||||
return EXIT_DONE if completed else EXIT_FAILED
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Execute the real WebSocket handlers and the OCI caller's actual close callback.
|
||||
const assert = require('node:assert/strict'), vm = require('node:vm');
|
||||
const {ts,read,compile,translator,catalogs} = require('./backup_i18n_seam.cjs');
|
||||
const moduleScope = {exports:{}};
|
||||
vm.runInNewContext(compile(read('lib/script-completion.ts')), moduleScope);
|
||||
const completion = moduleScope.exports;
|
||||
const source = ts.createSourceFile('modal.tsx', read('components/script-terminal-modal.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const handlers = []; let messageCallback,reconnectCallback;
|
||||
function walk(node) {
|
||||
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'ws.onmessage') handlers.push(node.right);
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'getCompletionMessage') messageCallback = node.initializer.arguments[0];
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'attemptReconnect') reconnectCallback = node.initializer.arguments[0];
|
||||
ts.forEachChild(node, walk);
|
||||
}
|
||||
walk(source);
|
||||
assert.equal(handlers.length,2,'initial and reconnect handlers');
|
||||
assert.ok(messageCallback);
|
||||
function executable(node, file, scope) {
|
||||
const local = {...scope,exports:{}};
|
||||
vm.runInNewContext(compile('exports.fn = '+node.getText(file)+';'),local);
|
||||
return local.exports.fn;
|
||||
}
|
||||
function fixture(handler, locale, optedIn=true) {
|
||||
const writes=[], callbacks=[], state={closed:0};
|
||||
const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)};
|
||||
const shared={...completion, cancelledExitCode:optedIn?6:undefined,t,
|
||||
completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined};
|
||||
const getCompletionMessage=executable(messageCallback,source,shared);
|
||||
const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},
|
||||
completionReceivedRef:{current:false},onCompleteRef:{current:code=>callbacks.push(code)},
|
||||
setIsComplete:v=>state.complete=v,ws:{readyState:1,close:()=>state.closed++},WebSocket:{OPEN:1},
|
||||
setIsWaitingNextInteraction:()=>{},waitingTimeoutRef:{current:null},clearTimeout:()=>{},
|
||||
setCurrentInteraction:()=>{}};
|
||||
return {receive:executable(handler,source,scope),writes,callbacks,state,t,scope};
|
||||
}
|
||||
let scenarios=0;
|
||||
for (const handler of handlers) for (const locale of ['en','sk']) for (const frame of ['raw','json']) {
|
||||
for (const [code,optedIn,kind] of [[6,true,'cancelled'],[6,false,'error'],[1,true,'error'],[0,true,'success'],[-1,true,'error']]) {
|
||||
const f=fixture(handler,locale,optedIn);
|
||||
const data=frame==='raw'?`\r\n[Script exited with code ${code}]\r\n`:JSON.stringify({type:'script_complete',exit_code:code});
|
||||
f.receive({data});
|
||||
assert.deepEqual(f.callbacks,[code]); assert.equal(f.state.closed,1);
|
||||
const rendered=f.writes.join('');
|
||||
if(kind==='cancelled') {
|
||||
assert.ok(rendered.includes(f.t('scriptTerminal.cancelled')));
|
||||
assert.ok(rendered.includes('\x1b[36m'));
|
||||
assert.ok(!rendered.includes('Script exited with code'));
|
||||
assert.ok(!rendered.includes('\x1b[31m'));
|
||||
assert.ok(!rendered.includes(f.t('scriptTerminal.completedSuccessfully')));
|
||||
} else {
|
||||
assert.ok(rendered.includes(kind==='success'?f.t('scriptTerminal.completedSuccessfully'):f.t('scriptTerminal.completedWithError',{code})));
|
||||
}
|
||||
// Worker sends both forms; neither a second frame nor a heartbeat may complete twice.
|
||||
f.receive({data:JSON.stringify({type:'script_complete',exit_code:code})});
|
||||
f.receive({data:'{"type":"pong"}'});
|
||||
assert.deepEqual(f.callbacks,[code]);assert.equal(f.state.closed,1);scenarios++;
|
||||
}
|
||||
for(const payload of [{type:'script_complete'},{type:'script_complete',exit_code:'invalid'}]) {
|
||||
const f=fixture(handler,locale);f.receive({data:JSON.stringify(payload)});
|
||||
assert.ok(f.writes.join('').includes('\x1b[31m'));
|
||||
assert.ok(!f.writes.join('').includes(f.t('scriptTerminal.cancelled')));scenarios++;
|
||||
}
|
||||
}
|
||||
for(const locale of Object.keys(catalogs)) assert.ok(translator(locale)('scriptTerminal.cancelled')!=='scriptTerminal.cancelled');
|
||||
// The native CLI and the explicitly opted-in caller share the documented code6 contract.
|
||||
const fs=require('node:fs');
|
||||
assert.match(fs.readFileSync('oci/src/proxmenux_oci/management.py','utf8'),/^EXIT_CANCELLED = 6 /m);
|
||||
assert.equal(completion.OCI_CANCELLED_EXIT_CODE,6);
|
||||
const parent=ts.createSourceFile('vms.tsx',read('components/virtual-machines.tsx'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);
|
||||
let ociElement,reset;
|
||||
function find(node) {
|
||||
if(ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent)==='ScriptTerminalModal' &&
|
||||
node.attributes.properties.some(p=>p.name?.getText(parent)==='scriptPath' && p.initializer?.text?.endsWith('/oci/manage_instance.sh'))) ociElement=node;
|
||||
if(ts.isCallExpression(node) && node.expression.getText(parent)==='useEffect' && node.arguments[0]?.getText(parent).includes('ociCompletionCodeRef.current = undefined')) reset=node.arguments[0];
|
||||
ts.forEachChild(node,find);
|
||||
}
|
||||
find(parent);assert.ok(ociElement);assert.ok(reset);
|
||||
const attribute=name=>ociElement.attributes.properties.find(p=>p.name?.getText(parent)===name)?.initializer?.expression;
|
||||
assert.equal(attribute('cancelledExitCode').getText(parent),'OCI_CANCELLED_EXIT_CODE');
|
||||
(async()=>{
|
||||
for(const frame of ['raw','json']) {
|
||||
const f=fixture(handlers[0],'sk'),timers=[];
|
||||
const old={readyState:3,onclose:()=>f.callbacks.push(-1),onerror:()=>{},onmessage:()=>{},close(){this.onclose?.({})}};
|
||||
const ref={current:old};
|
||||
class Socket{static OPEN=1;constructor(){this.readyState=1}close(){this.onclose?.({})}}
|
||||
const scope={...f.scope,isOpen:true,isComplete:false,reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},
|
||||
wsRef:ref,WebSocket:Socket,sessionIdRef:{current:'fixture'},paramsRef:{current:{}},keepAliveIntervalRef:{current:null},
|
||||
scriptPath:'/fixture/oci',setConnectionStatus:()=>{},getScriptWebSocketUrl:()=> 'ws://fixture',getTicketedWsUrl:async url=>url,
|
||||
setTimeout:fn=>{timers.push(fn);return 1},clearTimeout:()=>{}};
|
||||
executable(reconnectCallback,source,scope)();await timers[0]();
|
||||
assert.equal(old.onclose,null,'replaced connection cannot emit an unknown result');
|
||||
assert.equal(old.onmessage,null,'late result from replaced connection cannot win');
|
||||
ref.current.onmessage({data:frame==='raw'?'\r\n[Script exited with code 6]\r\n':JSON.stringify({type:'script_complete',exit_code:6})});
|
||||
assert.deepEqual(f.callbacks,[6]);
|
||||
}
|
||||
for(const code of [6,0,1,undefined]) {
|
||||
const calls=[],row={vmid:110,action:'update'},saved=JSON.stringify(row),ref={current:undefined};
|
||||
const scope={...completion,ociAction:row,ociCompletionCodeRef:ref,
|
||||
setOciAction:value=>calls.push(['close',value]),fetchApi:(url,options)=>{calls.push(['fetch',url,options]);return Promise.resolve({});},
|
||||
invalidateLxcApps:vmid=>calls.push(['invalidate',vmid]),vmModalCacheRef:{current:{mountPoints:new Map()}},
|
||||
fetchOciInstance:vmid=>calls.push(['instance',vmid]),fetchMountPoints:vmid=>calls.push(['mounts',vmid]),mutate:()=>calls.push(['mutate'])};
|
||||
executable(attribute('onComplete'),parent,scope)(code);
|
||||
executable(attribute('onClose'),parent,scope)();
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(JSON.stringify(row),saved);
|
||||
if(code===6) assert.deepEqual(calls,[['close',null]],'cancel has zero registry/API/cache calls');
|
||||
else assert.equal(calls.filter(c=>c[0]==='fetch'&&c[2].method==='POST').length,1,'preserve refresh after non-cancel');
|
||||
executable(reset,parent,scope)();assert.equal(ref.current,undefined,'next modal cannot inherit cancellation');
|
||||
}
|
||||
console.log(`PASS: ${scenarios} real terminal frame scenarios; actual OCI close callbacks, code6 scoping, reset and zero cancel probes`);
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
Reference in New Issue
Block a user