功能:完善通用云管工作台、审计与诊断能力
This commit is contained in:
1 parent
5ac692724d
commit
9f05aa20ea
225 files changed
+14266
-141
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user