功能:完善通用云管工作台、审计与诊断能力

This commit is contained in:
songyiming committed 2026-11-11 18:46:16 +08:00
1 parent 5ac692724d
commit 9f05aa20ea
225 files changed
+14266 -141

No files matched your search

+60 -8
View File
@@ -8,7 +8,13 @@ const object = (properties, required = Object.keys(properties)) => ({ type: 'obj
const schemas = {
Error: object({ code: string, message: string, requestId: string }),
Administrator: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] } }),
LoginInput: object({ username: string, password: string }),
LoginInput: object({ username: string, password: string, code: { ...string, maxLength: 80 } }, ['username', 'password']),
MFAStatus: object({ enabled: boolean, recoveryRemaining: integer }),
MFASetupInput: object({ password: { ...string, format: 'password', maxLength: 200 } }),
MFASetup: object({ secret: string, provisioningUri: string, expiresAt: date }),
MFAConfirm: object({ code: { ...string, minLength: 6, maxLength: 6 } }),
MFACredential: object({ password: { ...string, format: 'password', maxLength: 200 }, code: { ...string, maxLength: 80 } }),
MFARecovery: object({ recoveryCodes: array(string) }),
LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }),
Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }),
Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }),
@@ -74,9 +80,9 @@ Object.assign(schemas, {
CsvImportPreview: object({ kind: string, totalRows: integer, validRows: integer, errorRows: integer, previewToken: string, rows: array(ref('CsvImportRow')) }),
CsvImportResult: object({ id: string, kind: string, count: integer, items: array(object({ line: integer, key: string, id: string })), activationSecrets: array(object({ sn: string, activationSecret: string })), replayed: boolean }),
SessionRecord: object({ id: string, sessionId: string, terminalId: string, terminalName: string, desktopId: string, desktopName: string, username: string, displayName: string, state: { type: 'string', enum: ['connecting', 'connected', 'ended', 'interrupted'] }, source: { type: 'string', enum: ['ticket', 'observed'] }, reservedAt: date, connectedAt: { ...date, nullable: true }, lastReportedAt: date, endedAt: { ...date, nullable: true }, endReason: string }),
AlertRuleInput: object({ name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRule: object({ id: string, name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRecord: object({ id: string, ruleId: string, ruleName: string, metric: string, targetId: string, targetName: string, value: number, threshold: number, status: { type: 'string', enum: ['firing', 'resolved'] }, startedAt: date, openedAt: date, resolvedAt: { ...date, nullable: true }, resolveReason: string, acknowledgedAt: { ...date, nullable: true }, acknowledgedBy: string }),
AlertRuleInput: object({ name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline', 'terminal_cpu', 'terminal_memory', 'terminal_temperature'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRule: object({ id: string, name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline', 'terminal_cpu', 'terminal_memory', 'terminal_temperature'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRecord: object({ id: string, ruleId: string, ruleName: string, metric: string, targetId: string, targetName: string, value: number, threshold: number, status: { type: 'string', enum: ['firing', 'resolved'] }, startedAt: date, openedAt: date, resolvedAt: { ...date, nullable: true }, resolveReason: string, acknowledgedAt: { ...date, nullable: true }, acknowledgedBy: string, dataFresh: { ...boolean, nullable: true } }),
ResourcePoint: object({ time: date, cpu: { ...number, nullable: true }, memory: { ...number, nullable: true }, storage: { ...number, nullable: true }, samples: integer }),
ResourceSeries: object({ items: array(ref('ResourcePoint')), bucketSeconds: integer, retentionDays: integer }),
TerminalPolicyInput: object({ name: string, version: string, heartbeatInterval: { ...integer, minimum: 15, maximum: 60 }, commandPollInterval: { ...integer, minimum: 3, maximum: 30 }, desktopRefreshInterval: { ...integer, minimum: 5, maximum: 120 } }),
@@ -86,6 +92,7 @@ Object.assign(schemas, {
PolicyResult: object({ id: string, terminalId: string, terminalName: string, group: string, protocol: integer, deploymentId: string, templateName: string, templateVersion: string, configVersion: integer, status: { type: 'string', enum: ['unconfigured', 'pending', 'applied', 'failed', 'superseded'] }, errorCode: string, acknowledgedAt: { ...date, nullable: true }, online: boolean, enabled: boolean, registered: boolean }),
PolicyAck: object({ deploymentId: string, configVersion: integer, status: { type: 'string', enum: ['applied', 'failed'] }, errorCode: { type: 'string', enum: ['invalid_policy', 'persist_failed', 'runtime_unavailable'] } }, ['deploymentId', 'configVersion', 'status']),
AdministratorAccount: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, enabled: boolean }),
AdministratorSession: object({ key: string, createdAt: { ...date, nullable: true }, expiresAt: date, clientIp: string, userAgent: string, current: boolean }),
CreateAdministrator: object({ username: { type: 'string', minLength: 3, maxLength: 80 }, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, password: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }),
UpdateAdministrator: object({ displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, enabled: boolean }),
ResetAdministratorPassword: object({ password: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }),
@@ -103,6 +110,14 @@ Object.assign(schemas, {
})
schemas.Desktop.properties.poolId = { ...string, nullable: true }
schemas.Desktop.properties.setupRequired = boolean
schemas.DeviceDesktop = object({ ...schemas.Desktop.properties, powerOnConnect: boolean })
schemas.DeviceDesktopList = object({ items: array(ref('DeviceDesktop')), total: integer, page: integer, pageSize: integer })
schemas.GPUPrepareInput = object({ desktopId: { ...string, maxLength: 100 } })
schemas.GPUPrepareResult = object({ taskId: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string })
schemas.GPUReconcileResult = object({ reconciled: boolean, vmid: string })
schemas.DiagnosticSubject = object({ kind: { type: 'string', enum: ['terminal', 'desktop'] }, id: string, name: string, state: string, node: string, vmid: integer, agentVersion: string, lastSeen: { ...date, nullable: true } })
schemas.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string })
schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) })
schemas.Administrator.properties.enabled = boolean
schemas.HeartbeatInput.properties.policyProtocol = { ...integer, minimum: 0, maximum: 1 }
schemas.HeartbeatInput.properties.systemMetrics = { ...ref('TerminalSystemMetrics'), nullable: true }
@@ -111,7 +126,7 @@ schemas.DeviceConfig.required = Object.keys(schemas.DeviceConfig.properties)
// Older frontends can omit this display field; the API always supplies it.
schemas.Terminal.properties.sessionState = string
schemas.Terminal.properties.enabled = boolean
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM', 'ImageTemplate', 'DesktopPool', 'Storage', 'AdministratorAccount', 'TerminalPolicy', 'PolicyDeployment', 'PolicyResult', 'SessionRecord', 'AlertRule', 'AlertRecord', 'PowerBatch']) {
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM', 'ImageTemplate', 'DesktopPool', 'Storage', 'AdministratorAccount', 'AdministratorSession', 'TerminalPolicy', 'PolicyDeployment', 'PolicyResult', 'SessionRecord', 'AlertRule', 'AlertRecord', 'PowerBatch']) {
schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string })
}
Object.assign(schemas.AlertRecordList.properties, { firingCount: integer, unacknowledgedCount: integer })
@@ -165,9 +180,15 @@ const paths = {
'/auth/me': { get: operation('me', 'Administrator') },
'/auth/logout': { post: operation('logout', null, { status: 204 }) },
'/auth/password': { post: operation('changeOwnPassword', null, { input: 'ChangePassword', status: 204 }) },
'/auth/mfa': { get: operation('mfaStatus', 'MFAStatus'), delete: operation('disableMFA', null, { input: 'MFACredential', status: 204 }) },
'/auth/mfa/setup': { post: operation('beginMFA', 'MFASetup', { input: 'MFASetupInput' }) },
'/auth/mfa/confirm': { post: operation('confirmMFA', 'MFARecovery', { input: 'MFAConfirm' }) },
'/auth/mfa/recovery': { post: operation('rotateMFARecovery', 'MFARecovery', { input: 'MFACredential' }) },
'/administrators': { get: operation('administrators', 'AdministratorAccountList', { list: true }), post: operation('createAdministrator', 'AdministratorAccount', { input: 'CreateAdministrator', status: 201 }) },
'/administrators/{id}': { put: operation('updateAdministrator', 'AdministratorAccount', { input: 'UpdateAdministrator', param: true }) },
'/administrators/{id}/password': { post: operation('resetAdministratorPassword', null, { input: 'ResetAdministratorPassword', param: true, status: 204 }) },
'/administrators/{id}/sessions': { get: operation('administratorSessions', 'AdministratorSessionList', { list: true, param: true }) },
'/administrators/{id}/sessions/{sessionKey}': { delete: { ...operation('revokeAdministratorSession', null, { param: true, status: 204 }), parameters: [{ name: 'id', in: 'path', required: true, schema: string }, { name: 'sessionKey', in: 'path', required: true, schema: { ...string, minLength: 64, maxLength: 64 } }] } },
'/overview': { get: operation('overview', 'Overview') },
'/settings': { get: operation('settings', 'Settings') },
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
@@ -199,21 +220,52 @@ const paths = {
'/users/{id}': { put: operation('updateUser', 'User', { input: 'UserUpdate', param: true }) },
'/users/batch-bindings': { post: operation('batchBindings', 'BatchBindingsResult', { input: 'BatchBindings' }) },
'/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) },
'/tasks': { get: operation('tasks', 'TaskList', { list: true }) },
'/tasks': { get: operation('tasks', 'TaskList', { list: true, period: true }) },
'/tasks/export': { get: operation('exportTasks', null, { period: true }) },
'/tasks/{id}': { get: operation('task', 'Task', { param: true }) },
'/tasks/{id}/reconcile': { post: operation('reconcileProvision', 'Task', { param: true, status: 202 }) },
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true }) },
'/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') },
'/diagnostics/export': { get: operation('exportDiagnostics', null) },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) },
'/audit-logs/export': { get: operation('exportAudits', null, { period: true }) },
'/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },
'/terminal/heartbeat': { post: operation('heartbeat', 'HeartbeatResult', { input: 'HeartbeatInput', security: 'deviceBearer' }) },
'/terminal/config': { get: operation('deviceConfig', 'DeviceConfig', { security: 'deviceBearer' }) },
'/terminal/desktops': { get: operation('deviceDesktops', 'DesktopList', { security: 'deviceBearer', list: true }) },
'/terminal/desktops': { get: operation('deviceDesktops', 'DeviceDesktopList', { security: 'deviceBearer', list: true }) },
'/terminal/desktop-prepare': { post: operation('prepareGPUDesktop', 'GPUPrepareResult', { input: 'GPUPrepareInput', status: 202, security: 'deviceBearer' }) },
'/terminal/desktop-prepare/{id}': { get: operation('gpuPrepareStatus', 'GPUPrepareResult', { param: true, security: 'deviceBearer' }) },
'/tasks/{id}/gpu-reconcile': { post: operation('reconcileGPUPrepare', 'GPUReconcileResult', { param: true }) },
'/terminal/commands': { get: operation('deviceCommands', 'TaskList', { security: 'deviceBearer', list: true }) },
'/terminal/commands/{id}/ack': { post: operation('deviceAck', 'Accepted', { input: 'AckInput', param: true, security: 'deviceBearer' }) },
'/terminal/connect-ticket': { post: operation('createTicket', 'Ticket', { input: 'TicketInput', status: 201, security: 'deviceBearer' }) },
'/terminal/connect-ticket/consume': { post: operation('consumeTicket', 'Connection', { input: 'ConsumeInput', security: 'deviceBearer' }) },
'/terminal/session': { post: operation('deviceSession', 'Accepted', { input: 'SessionInput', security: 'deviceBearer' }) },
}
const taskFilters = ['actor', 'action', 'kind', 'targetId'].map(name => ({ name, in: 'query', schema: { ...string, maxLength: 200 } }))
const diagnosticFilters = [
{ name: 'targetType', in: 'query', required: true, schema: { type: 'string', enum: ['terminal', 'desktop'] } },
{ name: 'targetId', in: 'query', required: true, schema: { ...string, minLength: 1, maxLength: 100 } },
...['from', 'to'].map(name => ({ name, in: 'query', schema: date })),
{ name: 'source', in: 'query', schema: { type: 'string', enum: ['', 'tasks', 'audits', 'sessions', 'alerts', 'logs'] } },
]
paths['/diagnostics'].get.parameters.push(...diagnosticFilters, { name: 'page', in: 'query', schema: { ...integer, minimum: 1 } }, { name: 'pageSize', in: 'query', schema: { ...integer, minimum: 1, maximum: 100 } })
paths['/diagnostics/export'].get.parameters.push(...diagnosticFilters)
paths['/diagnostics'].get.description = 'Super administrators and operators only. Exact ID correlation, merged lifecycle events, maximum seven days. Redacts common credential formats. No collection or VM changes.'
paths['/diagnostics/export'].get.description = 'Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit.'
paths['/diagnostics/export'].get.responses[200] = { description: 'Scoped diagnostic archive', content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }
paths['/diagnostics/export'].get.responses[413] = { description: 'More than 2000 matching events; narrow time range or source', content: json(ref('Error')) }
taskFilters.push({ name: 'active', in: 'query', schema: boolean, description: 'true includes queued, running and unknown outcomes (timeout)' })
paths['/tasks'].get.parameters.push(...taskFilters)
paths['/tasks/export'].get.parameters.push(...taskFilters, ...['keyword', 'status'].map(name => ({ name, in: 'query', schema: { ...string, maxLength: 200 } })))
paths['/tasks/export'].get.responses[200] = { description: 'Complete filtered CSV; maximum 10000 rows. UTC times, raw action/status codes, no provider payload or idempotency keys.', headers: { 'X-Export-Count': { schema: integer } }, content: { 'text/csv': { schema: { type: 'string', format: 'binary' } } } }
paths['/tasks/export'].get.responses[413] = { description: 'Over 10000 matching tasks', content: json(ref('Error')) }
const auditFilters = ['actor', 'action', 'result'].map(name => ({ name, in: 'query', schema: { ...string, maxLength: 200 } }))
paths['/audit-logs'].get.parameters.push(...auditFilters)
paths['/audit-logs/export'].get.parameters.push(...auditFilters, { name: 'keyword', in: 'query', schema: { ...string, maxLength: 200 } })
paths['/audit-logs/export'].get.description = 'Authenticated administrators of all roles can export matching audit fields (maximum 10000 rows). No pagination or silent truncation. CSV uses UTF-8 BOM and formula-safe cells. Successful preparation is audited.'
paths['/audit-logs/export'].get.responses[200] = { description: 'Complete filtered CSV, with UTC timestamps and raw action/result codes', headers: { 'X-Export-Count': { schema: integer } }, content: { 'text/csv': { schema: { type: 'string', format: 'binary' } } } }
paths['/audit-logs/export'].get.responses[413] = { description: 'Over 10000 matching records; narrow the filter', content: json(ref('Error')) }
const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } }
const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url))
mkdirSync(directory, { recursive: true })
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/python3
"""Host-specific PVE hook: re-enumerate the idle GTX 965M before guest start.
Install on starhe as local:snippets/vdi-shared-gpu-reset.py for VMs 100-102.
An incomplete preparation stays reserved until an administrator inspects it.
"""
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
MEMBERS = (100, 101, 102)
PCI = Path('/sys/bus/pci/devices')
GPU = PCI / '0000:01:00.0'
BRIDGE = PCI / '0000:00:01.0'
STATE_DIR = Path('/run/vdi-shared-gpu')
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
def stopped():
for vmid in MEMBERS:
status = subprocess.check_output(
['qm', 'status', str(vmid)], text=True, timeout=10).strip()
if status != 'status: stopped':
raise RuntimeError(f'VM {vmid} is not stopped; refusing GPU reset')
def unused_gpu():
parent = BRIDGE.resolve(strict=True)
children = sorted(p.name for p in parent.glob('0000:*')
if (p / 'vendor').is_file())
if children != [GPU.name]:
raise RuntimeError(f'unexpected PCI bridge devices: {children}')
if ((GPU / 'vendor').read_text().strip() != '0x10de'
or (GPU / 'device').read_text().strip() != '0x13d9'
or (GPU / 'driver').resolve(strict=True).name != 'vfio-pci'):
raise RuntimeError('unexpected GPU identity or driver')
group = (GPU / 'iommu_group').resolve(strict=True)
devices = sorted(p.name for p in (group / 'devices').iterdir())
if devices != [BRIDGE.name, GPU.name]:
raise RuntimeError(f'unexpected IOMMU group: {devices}')
result = subprocess.run(['fuser', '/dev/vfio/' + group.name],
capture_output=True, timeout=10)
if result.returncode != 1:
raise RuntimeError('GPU group is in use or usage could not be checked')
def reenumerate():
(GPU / 'power/control').write_text('on')
(GPU / 'remove').write_text('1')
time.sleep(2)
(BRIDGE / 'rescan').write_text('1')
for _ in range(20):
if GPU.exists() and (GPU / 'driver').exists():
break
time.sleep(.5)
unused_gpu()
(GPU / 'power/control').write_text('on')
if (GPU / 'power_state').read_text().strip() != 'D0':
raise RuntimeError('GPU did not return to D0')
def save(state):
temporary = STATE_DIR / 'reservation.tmp'
temporary.write_text(json.dumps(state) + '\n')
temporary.replace(STATE_DIR / 'reservation.json')
def handle(vmid, phase):
path = STATE_DIR / 'reservation.json'
boot = BOOT_ID.read_text().strip()
state = json.loads(path.read_text()) if path.exists() else None
if state and state.get('boot') != boot:
state = None
if phase == 'pre-start':
stopped()
unused_gpu()
if state and state.get('phase') != 'running':
raise RuntimeError('previous GPU preparation is unconfirmed; inspect '
'/run/vdi-shared-gpu/reservation.json before retry')
state = dict(vmid=vmid, phase='preparing', boot=boot)
save(state)
try:
reenumerate()
except Exception:
save(dict(state, phase='failed'))
raise
save(dict(state, phase='prepared'))
print(f'VDI GPU: exclusive re-enumeration complete for VM {vmid}', flush=True)
elif phase == 'post-start':
if not state or state.get('vmid') != vmid or state.get('phase') != 'prepared':
raise RuntimeError('GPU start reservation does not match this VM')
save(dict(state, phase='running'))
elif phase == 'post-stop':
if state and state.get('vmid') == vmid and state.get('phase') in ('prepared', 'running'):
stopped()
unused_gpu()
path.unlink()
elif phase != 'pre-stop':
raise RuntimeError('unsupported hook phase')
def main():
import fcntl
if os.geteuid() != 0 or socket.gethostname().split('.')[0] != 'starhe':
raise RuntimeError('this hook is restricted to root on starhe')
if len(sys.argv) != 3 or int(sys.argv[1]) not in MEMBERS:
raise RuntimeError('unexpected VM or hook arguments')
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
with (STATE_DIR / 'lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
handle(int(sys.argv[1]), sys.argv[2])
if __name__ == '__main__':
try:
main()
except Exception as error:
print(f'VDI GPU preparation blocked: {error}', file=sys.stderr, flush=True)
sys.exit(1)
+50
View File
@@ -0,0 +1,50 @@
/* Fake NVIDIA entrypoints for exercising the real preload adapter's ABI.
* No GPU or proprietary library is used. Compile with vendor/NvFBC.h.
*/
#include "NvFBC.h"
extern void *dlsym(void *, const char *);
static int test_case, captured_cursor, output_id, sampling;
static NVFBCSTATUS NVFBCAPI mock_capture(const NVFBC_SESSION_HANDLE handle,
NVFBC_CREATE_CAPTURE_SESSION_PARAMS *p) {
(void)handle;
captured_cursor = p->bWithCursor;
output_id = p->dwOutputId;
sampling = p->dwSamplingRateMs;
return test_case == 6 ? NVFBC_ERR_INTERNAL : NVFBC_SUCCESS;
}
static NVFBCSTATUS NVFBCAPI mock_status(const NVFBC_SESSION_HANDLE handle,
NVFBC_GET_STATUS_PARAMS *p) {
(void)handle;
p->bIsCapturePossible = test_case == 4 ? NVFBC_FALSE : NVFBC_TRUE;
p->screenSize = (NVFBC_SIZE){test_case == 3 ? 1920 : 2560, 1440};
p->bXRandRAvailable = NVFBC_TRUE;
p->dwOutputNum = test_case == 5 ? 2 : 0;
return NVFBC_SUCCESS;
}
NVFBCSTATUS NVFBCAPI NvFBCCreateInstance(NVFBC_API_FUNCTION_LIST *f) {
f->nvFBCCreateCaptureSession = mock_capture;
f->nvFBCGetStatus = mock_status;
return NVFBC_SUCCESS;
}
int run_test(int which, int leased, int headless) {
test_case = which;
PNVFBCCREATEINSTANCE create = (PNVFBCCREATEINSTANCE)dlsym((void *)0, "NvFBCCreateInstance");
if (!create) return 1;
NVFBC_API_FUNCTION_LIST f = {.dwVersion=NVFBC_VERSION};
if (create(&f) != NVFBC_SUCCESS) return 2;
NVFBC_CREATE_CAPTURE_SESSION_PARAMS p = {
.dwVersion = which == 7 ? 0 : NVFBC_CREATE_CAPTURE_SESSION_PARAMS_VER,
.bWithCursor = NVFBC_TRUE, .dwOutputId = 73, .dwSamplingRateMs=16
};
NVFBCSTATUS status = f.nvFBCCreateCaptureSession(1, &p);
if (status != (which == 6 ? NVFBC_ERR_INTERNAL : NVFBC_SUCCESS)) return 3;
if (captured_cursor != (leased ? NVFBC_FALSE : NVFBC_TRUE)) return 4;
if (p.bWithCursor != NVFBC_TRUE || p.dwOutputId != 73 || output_id != 73 || sampling != 16) return 5;
NVFBC_GET_STATUS_PARAMS s = {.dwVersion=which == 8 ? 0 : NVFBC_GET_STATUS_PARAMS_VER};
if (f.nvFBCGetStatus(1, &s) != NVFBC_SUCCESS) return 6;
int enumerate_root = headless && which != 3 && which != 4 && which != 5;
if (enumerate_root) {
if (s.dwOutputNum != 1 || s.bXRandRAvailable || s.outputs[0].trackedBox.w != 2560 || s.outputs[0].trackedBox.h != 1440) return 7;
} else if (s.dwOutputNum != (which == 5 ? 2U : 0U) || !s.bXRandRAvailable) return 8;
return 0;
}
+44
View File
@@ -0,0 +1,44 @@
"""Run against built shared objects on Linux, including abort-on-ABI-change cases.
Usage: python3 run_nvfbc_adapter.py /absolute/adapter.so /absolute/stub.so
"""
import ctypes
import os
from pathlib import Path
import resource
import signal
import subprocess
import sys
import tempfile
if len(sys.argv) > 3:
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
library = ctypes.CDLL(sys.argv[2], mode=ctypes.RTLD_GLOBAL)
library.run_test.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
library.run_test.restype = ctypes.c_int
raise SystemExit(library.run_test(*(int(v) for v in sys.argv[3:])))
adapter, stub = [str(Path(p).resolve(strict=True)) for p in sys.argv[1:]]
with tempfile.TemporaryDirectory() as directory:
for case, leased, headless in [(0, 0, 0), (1, 1, 0), (2, 1, 1),
(2, 0, 1), (3, 1, 1), (4, 1, 1),
(5, 1, 1), (6, 1, 1), (7, 1, 1), (8, 1, 1)]:
env = dict(os.environ, LD_PRELOAD=adapter,
VDI_CURSOR_RECEIPT=str(Path(directory) / 'receipt'))
env.pop('VDI_CURSOR_LEASE', None)
env.pop('VDI_NVFBC_HEADLESS', None)
if leased:
env['VDI_CURSOR_LEASE'] = 'c' * 32
if headless:
env['VDI_NVFBC_HEADLESS'] = '1'
result = subprocess.run([sys.executable, __file__, adapter, stub,
str(case), str(leased), str(headless)], env=env)
expected = -signal.SIGABRT if case in (7, 8) else 0
assert result.returncode == expected, (case, leased, headless, result.returncode)
if leased or headless:
receipt = (Path(directory) / 'receipt').read_text()
if case == 6:
assert receipt == 'nvfbc-ready-v1\n', receipt
elif case not in (7, 8):
assert receipt == 'nvfbc-v1\n', receipt
print('PASS', case, 'lease', leased, 'headless', headless, flush=True)
+106
View File
@@ -0,0 +1,106 @@
"""Linux host regressions: reject incomplete capture manifests and stale readiness."""
import importlib.util
import hashlib
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
@unittest.skipUnless(sys.platform.startswith('linux'), 'host requires Linux flock')
class HostLeaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
configured_source = os.environ.get('VDI_NATIVE_CURSOR_HOST')
source = Path(configured_source) if configured_source else (
Path(__file__).resolve().parents[2] / 'terminal/native-cursor/host.py')
spec = importlib.util.spec_from_file_location('cursor_host', source)
cls.host = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.host)
def test_display_restart_cannot_reuse_previous_ready_or_receipt(self):
h = self.host
lease = 'a' * 32
with tempfile.TemporaryDirectory() as directory:
run = Path(directory)
(run / 'ready').write_text('b' * 32)
(run / 'receipt').write_text('nvfbc-v1\n')
calls = []
def systemctl(*args):
calls.append(args)
if args == ('start', h.GUARD):
self.assertFalse((run / 'ready').exists())
self.assertFalse((run / 'receipt').exists())
self.assertEqual((run / 'lease').read_text(), lease)
(run / 'ready').write_text(lease)
return ''
cursor = types.SimpleNamespace(size=(2560, 1440), read=lambda: {})
hello = json.dumps(dict(version=1, lease=lease, size=[2560, 1440]))
stdin = types.SimpleNamespace(buffer=io.BytesIO((hello + '\nstop\n').encode()))
stdout = io.StringIO()
with patch.object(h, 'RUN', run), patch.object(h, 'systemctl', systemctl), \
patch.object(h, 'XCursor', lambda: cursor), patch.object(h.sys, 'stdin', stdin), \
patch.object(h.sys, 'stdout', stdout), \
patch.object(h.select, 'select', lambda *args: ([stdin], [], [])):
h.stream()
self.assertEqual(json.loads(stdout.getvalue())['lease'], lease)
self.assertEqual(calls, [('stop', h.GUARD), ('start', h.GUARD), ('stop', h.GUARD)])
self.assertFalse((run / 'lease').exists())
def test_incomplete_manifest_rejected_before_stopping_capture(self):
h = self.host
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'lease').write_text('a' * 32)
(root / 'manifest.json').write_text(json.dumps({'sunshine': '0' * 64}))
with patch.object(h, 'RUN', root), patch.object(h, 'ROOT', root), \
patch.object(h, 'systemctl') as systemctl:
with self.assertRaisesRegex(RuntimeError, 'incomplete pinned capture manifest'):
h.guard()
systemctl.assert_not_called()
def test_software_sprite_guard_stops_capture_before_unhide_on_failure(self):
h = self.host
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'lease').write_text('a' * 32)
manifest = {}
for name in ('sunshine', 'capture-no-cursor.so'):
(root / name).write_bytes(name.encode())
manifest[name] = hashlib.sha256(name.encode()).hexdigest()
(root / 'manifest.json').write_text(json.dumps(manifest))
(root / 'hide-server-cursor').touch()
events = []
class CursorOwner:
def __enter__(self):
return self
def hide_server_cursor(self):
events.append('hide')
def __exit__(self, *_):
events.append('restore')
def capture(lease):
events.append('capture')
raise RuntimeError('producer failed')
with patch.object(h, 'ROOT', root), patch.object(h, 'RUN', root), \
patch.object(h, 'XCursor', CursorOwner), \
patch.object(h, 'guard_capture', capture), \
patch.object(h, 'systemctl', lambda *args: events.append(args)):
with self.assertRaisesRegex(RuntimeError, 'producer failed'):
h.guard()
self.assertEqual(events, ['hide', 'capture', ('stop', h.SERVICE), 'restore'])
if __name__ == '__main__':
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
'gpu_hook', Path(__file__).resolve().parents[1] / 'pve-shared-gpu-hook.py')
hook = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hook)
class SharedGPUHookTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
root = Path(self.directory.name)
(root / 'boot').write_text('test-boot')
for name, value in [('STATE_DIR', root), ('BOOT_ID', root / 'boot')]:
p = patch.object(hook, name, value)
p.start()
self.addCleanup(p.stop)
def test_running_guest_or_busy_device_never_resets(self):
for failing_guard in ('stopped', 'unused_gpu'):
with self.subTest(guard=failing_guard), patch.object(hook, 'stopped'), \
patch.object(hook, 'unused_gpu'), patch.object(hook, 'reenumerate') as reset:
getattr(hook, failing_guard).side_effect = RuntimeError('busy')
with self.assertRaises(RuntimeError):
hook.handle(102, 'pre-start')
reset.assert_not_called()
self.assertFalse((hook.STATE_DIR / 'reservation.json').exists())
def test_second_start_blocked_until_first_guest_stops(self):
with patch.object(hook, 'stopped'), patch.object(hook, 'unused_gpu'), \
patch.object(hook, 'reenumerate') as reset:
hook.handle(102, 'pre-start')
with self.assertRaises(RuntimeError):
hook.handle(100, 'pre-start')
self.assertEqual(reset.call_count, 1)
hook.handle(100, 'post-stop')
self.assertTrue((hook.STATE_DIR / 'reservation.json').exists())
hook.handle(102, 'post-start')
hook.handle(102, 'post-stop')
hook.handle(100, 'pre-start')
self.assertEqual(reset.call_count, 2)
def test_failed_reset_stays_reserved_and_is_not_replayed(self):
with patch.object(hook, 'stopped'), patch.object(hook, 'unused_gpu'), \
patch.object(hook, 'reenumerate', side_effect=RuntimeError('device missing')) as reset:
with self.assertRaises(RuntimeError):
hook.handle(102, 'pre-start')
hook.handle(102, 'post-stop')
state = json.loads((hook.STATE_DIR / 'reservation.json').read_text())
self.assertEqual(state['phase'], 'failed')
with self.assertRaises(RuntimeError):
hook.handle(102, 'pre-start')
self.assertEqual(reset.call_count, 1)
def test_post_start_cannot_claim_another_guest_reservation(self):
with patch.object(hook, 'stopped'), patch.object(hook, 'unused_gpu'), \
patch.object(hook, 'reenumerate'):
hook.handle(102, 'pre-start')
with self.assertRaises(RuntimeError):
hook.handle(100, 'post-start')
if __name__ == '__main__':
unittest.main()