Merge pull request #501 from Vaso73/feature/native-discovery-stopped-message

Explain why Find applications cannot scan a stopped LXC
This commit is contained in:
MacRimi
2026-10-18 20:13:17 +02:00
committed by GitHub
11 changed files with 70 additions and 3 deletions
+5 -1
View File
@@ -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`, {
+1
View File
@@ -1744,6 +1744,7 @@
"nativeEvidenceDirectory": "Arbeitsverzeichnis", "nativeEvidenceDirectory": "Arbeitsverzeichnis",
"nativeEvidenceService": "Dienstdefinition", "nativeEvidenceService": "Dienstdefinition",
"nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.", "nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.",
"nativeScanStopped": "Dieser Container ist gestoppt. Die Anwendungssuche ist nur verfügbar, während der Container läuft.",
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.", "nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
"nativeVersionUnknown": "Installierte Version unbekannt", "nativeVersionUnknown": "Installierte Version unbekannt",
"nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.", "nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.",
+1
View File
@@ -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.",
+1
View File
@@ -1729,6 +1729,7 @@
"nativeEvidenceDirectory": "Directorio de trabajo", "nativeEvidenceDirectory": "Directorio de trabajo",
"nativeEvidenceService": "Definición del servicio", "nativeEvidenceService": "Definición del servicio",
"nativeScanFailed": "La búsqueda de aplicaciones no ha podido terminar. Lo detectado antes puede estar desactualizado; vuelve a intentarlo.", "nativeScanFailed": "La búsqueda de aplicaciones no ha podido terminar. Lo detectado antes puede estar desactualizado; vuelve a intentarlo.",
"nativeScanStopped": "Este contenedor está detenido. La búsqueda de aplicaciones solo está disponible con el contenedor en marcha.",
"nativeScanPartial": "La búsqueda de aplicaciones está incompleta. Revisa lo que se ha encontrado y vuelve a intentarlo.", "nativeScanPartial": "La búsqueda de aplicaciones está incompleta. Revisa lo que se ha encontrado y vuelve a intentarlo.",
"nativeVersionUnknown": "Versión instalada desconocida", "nativeVersionUnknown": "Versión instalada desconocida",
"nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.", "nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.",
+1
View File
@@ -1744,6 +1744,7 @@
"nativeEvidenceDirectory": "Répertoire de travail", "nativeEvidenceDirectory": "Répertoire de travail",
"nativeEvidenceService": "Définition du service", "nativeEvidenceService": "Définition du service",
"nativeScanFailed": "La recherche d'applications n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.", "nativeScanFailed": "La recherche d'applications n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.",
"nativeScanStopped": "Ce conteneur est arrêté. La recherche d'applications n'est disponible que lorsque le conteneur est en cours d'exécution.",
"nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.", "nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.",
"nativeVersionUnknown": "Version installée inconnue", "nativeVersionUnknown": "Version installée inconnue",
"nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.", "nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.",
+1
View File
@@ -1744,6 +1744,7 @@
"nativeEvidenceDirectory": "Directory di lavoro", "nativeEvidenceDirectory": "Directory di lavoro",
"nativeEvidenceService": "Definizione del servizio", "nativeEvidenceService": "Definizione del servizio",
"nativeScanFailed": "La ricerca delle applicazioni non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.", "nativeScanFailed": "La ricerca delle applicazioni non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.",
"nativeScanStopped": "Questo contenitore è arrestato. La ricerca delle applicazioni è disponibile solo con il contenitore in esecuzione.",
"nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.", "nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.",
"nativeVersionUnknown": "Versione installata sconosciuta", "nativeVersionUnknown": "Versione installata sconosciuta",
"nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.", "nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.",
+1
View File
@@ -1744,6 +1744,7 @@
"nativeEvidenceDirectory": "Diretório de trabalho", "nativeEvidenceDirectory": "Diretório de trabalho",
"nativeEvidenceService": "Definição do serviço", "nativeEvidenceService": "Definição do serviço",
"nativeScanFailed": "A pesquisa de aplicativos não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.", "nativeScanFailed": "A pesquisa de aplicativos não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.",
"nativeScanStopped": "Este contentor está parado. A pesquisa de aplicações só está disponível com o contentor em execução.",
"nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.", "nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.",
"nativeVersionUnknown": "Versão instalada desconhecida", "nativeVersionUnknown": "Versão instalada desconhecida",
"nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.", "nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.",
+1
View File
@@ -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é.",
+1
View File
@@ -1744,6 +1744,7 @@
"nativeEvidenceDirectory": "Arbetskatalog", "nativeEvidenceDirectory": "Arbetskatalog",
"nativeEvidenceService": "Tjänstdefinition", "nativeEvidenceService": "Tjänstdefinition",
"nativeScanFailed": "Programsökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.", "nativeScanFailed": "Programsökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.",
"nativeScanStopped": "Den här containern är stoppad. Programsökningen är bara tillgänglig medan containern körs.",
"nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.", "nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.",
"nativeVersionUnknown": "Installerad version okänd", "nativeVersionUnknown": "Installerad version okänd",
"nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.", "nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.",
@@ -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()