mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix: explain why native discovery cannot scan stopped containers
This commit is contained in:
@@ -125,6 +125,7 @@ interface PortDetail {
|
|||||||
interface NativeDiscovery {
|
interface NativeDiscovery {
|
||||||
vmid: number
|
vmid: number
|
||||||
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
|
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
|
||||||
|
issues?: string[]
|
||||||
stale: boolean
|
stale: boolean
|
||||||
candidates: DetectedApp[]
|
candidates: DetectedApp[]
|
||||||
}
|
}
|
||||||
@@ -804,7 +805,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
setNativeDiscovery(native)
|
setNativeDiscovery(native)
|
||||||
if (native.status === "error" || native.status === "busy") {
|
if (native.status === "error" || native.status === "busy") {
|
||||||
setDetectionNotice({ found: false, text: t("vmLxc.appEditor.nativeScanFailed") })
|
const stopped = native.status === "error" && Array.isArray(native.issues)
|
||||||
|
&& native.issues.includes("guest_stopped")
|
||||||
|
setDetectionNotice({ found: false, text: t(stopped
|
||||||
|
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||||
|
|||||||
@@ -1746,6 +1746,7 @@
|
|||||||
"nativeEvidenceDirectory": "Working directory",
|
"nativeEvidenceDirectory": "Working directory",
|
||||||
"nativeEvidenceService": "Service definition",
|
"nativeEvidenceService": "Service definition",
|
||||||
"nativeScanFailed": "Application search could not finish. Previous evidence may be outdated; try again.",
|
"nativeScanFailed": "Application search could not finish. Previous evidence may be outdated; try again.",
|
||||||
|
"nativeScanStopped": "This container is stopped. Application search is only available while the container is running.",
|
||||||
"nativeScanPartial": "Application search is incomplete. Review the available evidence and try again.",
|
"nativeScanPartial": "Application search is incomplete. Review the available evidence and try again.",
|
||||||
"nativeVersionUnknown": "Installed version unknown",
|
"nativeVersionUnknown": "Installed version unknown",
|
||||||
"nativeEvidenceStale": "Evidence may be outdated or incomplete.",
|
"nativeEvidenceStale": "Evidence may be outdated or incomplete.",
|
||||||
|
|||||||
@@ -1690,6 +1690,7 @@
|
|||||||
"nativeEvidenceDirectory": "Pracovný priečinok",
|
"nativeEvidenceDirectory": "Pracovný priečinok",
|
||||||
"nativeEvidenceService": "Definícia služby",
|
"nativeEvidenceService": "Definícia služby",
|
||||||
"nativeScanFailed": "Vyhľadávanie aplikácií sa nepodarilo dokončiť. Predchádzajúce dôkazy môžu byť zastarané; skús to znova.",
|
"nativeScanFailed": "Vyhľadávanie aplikácií sa nepodarilo dokončiť. Predchádzajúce dôkazy môžu byť zastarané; skús to znova.",
|
||||||
|
"nativeScanStopped": "Kontajner je vypnutý. Vyhľadávanie aplikácií je dostupné iba v spustenom kontajneri.",
|
||||||
"nativeScanPartial": "Vyhľadávanie aplikácií je neúplné. Skontroluj dostupné dôkazy a skús to znova.",
|
"nativeScanPartial": "Vyhľadávanie aplikácií je neúplné. Skontroluj dostupné dôkazy a skús to znova.",
|
||||||
"nativeVersionUnknown": "Nainštalovaná verzia nie je známa",
|
"nativeVersionUnknown": "Nainštalovaná verzia nie je známa",
|
||||||
"nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.",
|
"nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.",
|
||||||
|
|||||||
@@ -746,6 +746,27 @@ class Routes(unittest.TestCase):
|
|||||||
scan.assert_not_called()
|
scan.assert_not_called()
|
||||||
read.assert_not_called()
|
read.assert_not_called()
|
||||||
|
|
||||||
|
def test_stopped_scan_route_exposes_reason_and_keeps_cached_evidence(self):
|
||||||
|
item = {'discovery_id': 'native-retained', 'name': 'Retained app',
|
||||||
|
'installation_path': '/opt/retained/server'}
|
||||||
|
previous = {'vmid': 101, 'status': 'complete', 'stale': False,
|
||||||
|
'scanned_at': time.time(), 'candidates': [item]}
|
||||||
|
native._cache[101] = copy.deepcopy(previous)
|
||||||
|
with patch.object(native, 'scan', side_effect=native.ProbeError('guest_stopped')), \
|
||||||
|
patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \
|
||||||
|
patch.object(lxc_apps, 'get_suggestions') as suggestions, \
|
||||||
|
patch.object(lxc_apps, '_write_sidecar') as write:
|
||||||
|
response = self.client.post('/api/vms/101/apps/native-discovery')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
result = response.get_json()
|
||||||
|
self.assertEqual(result['status'], 'error')
|
||||||
|
self.assertEqual(result['issues'], ['guest_stopped'])
|
||||||
|
self.assertTrue(result['stale'])
|
||||||
|
self.assertEqual(result['candidates'], [item])
|
||||||
|
self.assertEqual(result['scanned_at'], previous['scanned_at'])
|
||||||
|
suggestions.assert_not_called()
|
||||||
|
write.assert_not_called()
|
||||||
|
|
||||||
def test_actual_lifecycle_marks_last_known_stale_without_scanning(self):
|
def test_actual_lifecycle_marks_last_known_stale_without_scanning(self):
|
||||||
native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False,
|
native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False,
|
||||||
'candidates': [], 'scanned_at': time.time()}
|
'candidates': [], 'scanned_at': time.time()}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
|
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
|
||||||
const calls = [], cachedWrites = []
|
const calls = [], cachedWrites = []
|
||||||
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
||||||
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete'
|
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
|
||||||
let cacheGate, resolveCache, cachePending = false
|
let cacheGate, resolveCache, cachePending = false
|
||||||
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
|
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
|
||||||
const react = {
|
const react = {
|
||||||
@@ -56,7 +56,7 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
}
|
}
|
||||||
if (options.method === 'POST') {
|
if (options.method === 'POST') {
|
||||||
if (scanGate) await scanGate
|
if (scanGate) await scanGate
|
||||||
native = {...native, status: scanStatus, candidates: [nativeCandidate]}
|
native = {...native, status: scanStatus, issues: scanIssues, candidates: [nativeCandidate]}
|
||||||
}
|
}
|
||||||
return structuredClone(native)
|
return structuredClone(native)
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,7 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
|
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
|
||||||
resolveInitialCache: () => resolveCache(),
|
resolveInitialCache: () => resolveCache(),
|
||||||
setScanStatus: value => {scanStatus = value},
|
setScanStatus: value => {scanStatus = value},
|
||||||
|
setScanIssues: value => {scanIssues = value},
|
||||||
holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(),
|
holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(),
|
||||||
holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(),
|
holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(),
|
||||||
}
|
}
|
||||||
@@ -162,6 +163,39 @@ async function main() {
|
|||||||
assert.ok(text(failed.tree()).includes('nativeScanFailed'))
|
assert.ok(text(failed.tree()).includes('nativeScanFailed'))
|
||||||
assert.equal(failed.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0)
|
assert.equal(failed.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0)
|
||||||
|
|
||||||
|
// Match the collector's public response, including retained stale evidence.
|
||||||
|
// A busy reply may carry an earlier guest_stopped issue from its cache.
|
||||||
|
for (const [status, issues, expected] of [
|
||||||
|
['error', ['guest_stopped'], 'nativeScanStopped'],
|
||||||
|
['error', ['status_unknown'], 'nativeScanFailed'],
|
||||||
|
['error', ['timeout'], 'nativeScanFailed'],
|
||||||
|
['error', [], 'nativeScanFailed'],
|
||||||
|
['error', undefined, 'nativeScanFailed'],
|
||||||
|
['error', 'guest_stopped', 'nativeScanFailed'],
|
||||||
|
['busy', ['guest_stopped'], 'nativeScanFailed'],
|
||||||
|
]) {
|
||||||
|
const denied = fixture()
|
||||||
|
denied.setNative({vmid: 101, status: 'complete', stale: true, candidates: [candidate]})
|
||||||
|
denied.render(); await denied.settle()
|
||||||
|
denied.setScanStatus(status); denied.setScanIssues(issues)
|
||||||
|
await denied.button('searchApplications').props.onClick(); denied.render()
|
||||||
|
assert.ok(text(denied.tree()).includes(expected), `${status}/${JSON.stringify(issues)} uses ${expected}`)
|
||||||
|
assert.ok(!text(denied.tree()).includes(expected === 'nativeScanStopped' ? 'nativeScanFailed' : 'nativeScanStopped'))
|
||||||
|
assert.ok(text(denied.tree()).includes('/opt/handmade/server'), 'failure retains prior evidence')
|
||||||
|
assert.equal(denied.calls.filter(c => c.method === 'POST').length, 1, 'only the explicit discovery POST, no suggestion, save or control call')
|
||||||
|
assert.equal(denied.cachedWrites.length, 0, 'failure cannot overwrite registrations')
|
||||||
|
assert.equal(denied.button('searchApplications').props.disabled, false, 'search settles and can be retried')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stoppedLate = fixture()
|
||||||
|
stoppedLate.render(); await stoppedLate.settle()
|
||||||
|
stoppedLate.setScanStatus('error'); stoppedLate.setScanIssues(['guest_stopped'])
|
||||||
|
stoppedLate.holdScan()
|
||||||
|
const stoppedRequest = stoppedLate.button('searchApplications').props.onClick()
|
||||||
|
stoppedLate.render({vmid: 102}); await stoppedLate.settle()
|
||||||
|
stoppedLate.resolveScan(); await stoppedRequest; stoppedLate.render()
|
||||||
|
assert.ok(!text(stoppedLate.tree()).includes('nativeScanStopped'), 'late stopped response cannot label another CT')
|
||||||
|
|
||||||
const editorSwitch = fixture()
|
const editorSwitch = fixture()
|
||||||
editorSwitch.render(); await editorSwitch.settle()
|
editorSwitch.render(); await editorSwitch.settle()
|
||||||
await editorSwitch.button('searchApplications').props.onClick(); editorSwitch.render()
|
await editorSwitch.button('searchApplications').props.onClick(); editorSwitch.render()
|
||||||
|
|||||||
Reference in New Issue
Block a user