功能:新增云管API与Web控制台并完成现场接入
This commit is contained in:
1 parent
18a9ce995d
commit
3fb7b4da8c
79 files changed
+17367
-3
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const string = { type: 'string' }, integer = { type: 'integer' }, number = { type: 'number' }, boolean = { type: 'boolean' }
|
||||
const date = { type: 'string', format: 'date-time' }
|
||||
const ref = name => ({ $ref: `#/components/schemas/${name}` })
|
||||
const array = items => ({ type: 'array', items })
|
||||
const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false })
|
||||
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 }),
|
||||
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 } }),
|
||||
User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }),
|
||||
Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }),
|
||||
Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }),
|
||||
Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }),
|
||||
Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }),
|
||||
Settings: object({ mode: { type: 'string', enum: ['demo', 'production'] }, computeProvider: string, pveConfigured: boolean, terminalTransport: string, capabilities: array(string) }),
|
||||
ImportInput: object({ name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, endpoint: string, width: integer, height: integer, fps: integer }, ['name', 'os', 'node', 'vmid']),
|
||||
DiscoveredVM: object({ name: string, node: string, vmid: integer, status: string }),
|
||||
EnrollmentInput: object({ sn: string, name: string, group: string }),
|
||||
Enrollment: object({ id: string, activationSecret: string }),
|
||||
CreateUserInput: object({ username: string, displayName: string }),
|
||||
BindingInput: object({ terminalId: { ...string, nullable: true }, desktopIds: array(string), enabled: boolean }),
|
||||
DesktopAction: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] } }),
|
||||
TerminalAction: object({ action: { type: 'string', enum: ['reboot', 'reset-session', 'collect-logs'] } }),
|
||||
RegisterInput: object({ sn: string, activationSecret: string, agentVersion: string }),
|
||||
RegisterResult: object({ terminalId: string, accessToken: string, heartbeatInterval: integer }),
|
||||
HeartbeatInput: object({ agentVersion: string, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }, ['agentVersion']),
|
||||
Accepted: object({ accepted: boolean }),
|
||||
HeartbeatResult: object({ accepted: boolean, serverTime: date }),
|
||||
DeviceConfig: object({ configVersion: integer, identityMode: string, commandTransport: string, heartbeatInterval: integer }),
|
||||
TicketInput: object({ desktopId: string }),
|
||||
Ticket: object({ ticket: string, desktopId: string, expiresAt: date, credentialMode: string }),
|
||||
ConsumeInput: object({ ticket: string }),
|
||||
Connection: object({ desktopId: string, sessionId: string, protocol: string, endpoint: string, app: string, width: integer, height: integer, fps: integer, credentialMode: string }),
|
||||
DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }),
|
||||
SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }),
|
||||
AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']),
|
||||
}
|
||||
// Older frontends can omit this display field; the API always supplies it.
|
||||
schemas.Terminal.properties.sessionState = string
|
||||
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM']) {
|
||||
schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string })
|
||||
}
|
||||
const json = schema => ({ 'application/json': { schema } })
|
||||
const operation = (id, output, { input, status = 200, list = false, param = false, command = false, security = 'adminBearer' } = {}) => ({
|
||||
operationId: id,
|
||||
security: security ? [{ [security]: [] }] : [],
|
||||
...(input ? { requestBody: { required: true, content: json(ref(input)) } } : {}),
|
||||
parameters: [
|
||||
...(param ? [{ name: 'id', in: 'path', required: true, schema: string }] : []),
|
||||
...(list ? ['page', 'pageSize', 'keyword', 'status'].map(name => ({ name, in: 'query', schema: name.startsWith('page') ? integer : string })) : []),
|
||||
...(command ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', maxLength: 100 } }] : []),
|
||||
],
|
||||
responses: {
|
||||
[status]: { description: 'Successful response', ...(output ? { content: json(ref(output)) } : {}) },
|
||||
...Object.fromEntries([400, 401, 403, 404, 409, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])),
|
||||
},
|
||||
})
|
||||
const paths = {
|
||||
'/auth/login': { post: operation('login', 'LoginResult', { input: 'LoginInput', security: null }) },
|
||||
'/auth/me': { get: operation('me', 'Administrator') },
|
||||
'/auth/logout': { post: operation('logout', null, { status: 204 }) },
|
||||
'/overview': { get: operation('overview', 'Overview') },
|
||||
'/settings': { get: operation('settings', 'Settings') },
|
||||
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
|
||||
'/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) },
|
||||
'/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) },
|
||||
'/desktops': { get: operation('desktops', 'DesktopList', { list: true }) },
|
||||
'/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) },
|
||||
'/desktops/{id}': { get: operation('desktop', 'Desktop', { param: true }) },
|
||||
'/desktops/{id}/actions': { post: operation('desktopAction', 'Task', { input: 'DesktopAction', param: true, status: 202, command: true }) },
|
||||
'/terminals': { get: operation('terminals', 'TerminalList', { list: true }), post: operation('enrollTerminal', 'Enrollment', { input: 'EnrollmentInput', status: 201 }) },
|
||||
'/terminals/{id}/commands': { post: operation('terminalCommand', 'Task', { input: 'TerminalAction', param: true, status: 202, command: true }) },
|
||||
'/users': { get: operation('users', 'UserList', { list: true }), post: operation('createUser', 'User', { input: 'CreateUserInput', status: 201 }) },
|
||||
'/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) },
|
||||
'/tasks': { get: operation('tasks', 'TaskList', { list: true }) },
|
||||
'/tasks/{id}': { get: operation('task', 'Task', { param: true }) },
|
||||
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
|
||||
'/audit-logs': { get: operation('audits', 'AuditList', { list: 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/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 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 })
|
||||
writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)
|
||||
console.log('Updated docs/openapi/platform.v1.json')
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Exercise device registration/heartbeat/command receipts without controlling real hardware."""
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--server', default='http://127.0.0.1:8088')
|
||||
parser.add_argument('--sn', required=True)
|
||||
args = parser.parse_args()
|
||||
server = args.server.rstrip('/')
|
||||
token = ''
|
||||
|
||||
def request(path, body=None):
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = 'Bearer ' + token
|
||||
req = Request(server + path, data=json.dumps(body).encode() if body is not None else None, headers=headers)
|
||||
try:
|
||||
with urlopen(req, timeout=15) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as error:
|
||||
detail = json.load(error)
|
||||
raise RuntimeError(detail.get('message', 'API request failed')) from None
|
||||
|
||||
if request('/healthz').get('mode') != 'demo':
|
||||
raise SystemExit('This simulator only runs against VDI_MODE=demo.')
|
||||
secret = os.environ.get('VDI_DEVICE_ACTIVATION_SECRET') or getpass.getpass('One-time activation secret: ')
|
||||
result = request('/api/v1/terminal/register', {'sn': args.sn, 'activationSecret': secret, 'agentVersion': 'simulator-0.1'})
|
||||
token = result['accessToken']
|
||||
print('Registered terminal:', result['terminalId'])
|
||||
print('Simulator sends heartbeat only; all requested device operations return failed.')
|
||||
print('Press Ctrl+C to stop. No reboot, session change, or log collection is performed.')
|
||||
while True:
|
||||
request('/api/v1/terminal/heartbeat', {'agentVersion': 'simulator-0.1', 'fps': None, 'rtt': None})
|
||||
desktops = request('/api/v1/terminal/desktops')['items']
|
||||
print('Authorized desktops:', ', '.join(d['name'] for d in desktops) or '(none)')
|
||||
for command in request('/api/v1/terminal/commands')['items']:
|
||||
request('/api/v1/terminal/commands/' + command['id'] + '/ack', {'status': 'failed', 'message': 'Device simulator does not execute operations'})
|
||||
print('Returned failed receipt for:', command['action'], command['id'])
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print('\nSimulator stopped.')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Generate private local configuration and a CA for the device HTTPS channel."""
|
||||
import argparse
|
||||
import datetime
|
||||
import ipaddress
|
||||
import json
|
||||
import pathlib
|
||||
import secrets
|
||||
import os
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--ip', required=True)
|
||||
p.add_argument('--pve-hostname', required=True, help='DNS name present in the PVE certificate')
|
||||
p.add_argument('--pve-ip', required=True)
|
||||
args = p.parse_args()
|
||||
ip = ipaddress.ip_address(args.ip)
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
target = root / '.local/live'
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
if (target / '.env').exists():
|
||||
raise RuntimeError('Existing configuration retained; edit it deliberately instead of regenerating secrets')
|
||||
access = json.loads((target / 'pve-access.json').read_text())
|
||||
access['PVE_URL']=f'https://{args.pve_hostname}:8006'
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'VDI local device CA')])
|
||||
ca = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=3650)).add_extension(x509.BasicConstraints(ca=True,path_length=0),critical=True).add_extension(x509.KeyUsage(True,False,False,False,False,True,True,False,False),critical=True).sign(key,hashes.SHA256())
|
||||
server_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
||||
server = x509.CertificateBuilder().subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,str(ip))])).issuer_name(name).public_key(server_key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=365)).add_extension(x509.BasicConstraints(ca=False,path_length=None),critical=True).add_extension(x509.SubjectAlternativeName([x509.IPAddress(ip),x509.DNSName('localhost')]),critical=False).add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),critical=False).sign(key,hashes.SHA256())
|
||||
tls = target / 'tls'; tls.mkdir(exist_ok=True)
|
||||
def save_private(path, data):
|
||||
path.write_bytes(data); os.chmod(path, 0o600)
|
||||
for name, obj in [('ca',key),('server',server_key)]:
|
||||
save_private(tls/f'{name}-key.pem', obj.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||
(tls/'ca.pem').write_bytes(ca.public_bytes(serialization.Encoding.PEM))
|
||||
(tls/'server.pem').write_bytes(server.public_bytes(serialization.Encoding.PEM)+ca.public_bytes(serialization.Encoding.PEM))
|
||||
env = dict(VDI_DB_PASSWORD=secrets.token_hex(24),VDI_JWT_SECRET=secrets.token_hex(48),VDI_ADMIN_USERNAME='admin',VDI_ADMIN_PASSWORD=secrets.token_urlsafe(24),VDI_LIVE_BIND_IP=str(ip),PVE_HOSTNAME=args.pve_hostname,PVE_HOST_IP=str(ipaddress.ip_address(args.pve_ip)),**access)
|
||||
save_private(target/'.env', ''.join(f'{k}={v}\n' for k,v in env.items()).encode())
|
||||
save_private(target/'admin-login.txt', f'Console: http://127.0.0.1:5182\nUsername: {env["VDI_ADMIN_USERNAME"]}\nPassword: {env["VDI_ADMIN_PASSWORD"]}\nDevice HTTPS: https://{ip}:8443\n'.encode())
|
||||
print('Private environment, admin login, CA and HTTPS certificate generated in .local/live')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Create scoped VDI API access through a known-host-verified SSH connection.
|
||||
|
||||
Requires paramiko. Password is prompted or read from VDI_PVE_SSH_PASSWORD.
|
||||
The one-time API secret is written only to the requested private output file.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import paramiko
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--host', required=True)
|
||||
p.add_argument('--vmids', nargs='+', type=int, required=True)
|
||||
p.add_argument('--output', required=True)
|
||||
args = p.parse_args()
|
||||
target = pathlib.Path(args.output)
|
||||
if target.exists():
|
||||
raise RuntimeError('Output already exists; reuse the existing token rather than replace it')
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.load_system_host_keys(str(pathlib.Path.home() / '.ssh/known_hosts'))
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
password = os.environ.get('VDI_PVE_SSH_PASSWORD') or getpass.getpass('PVE SSH password: ')
|
||||
ssh.connect(args.host, username='root', password=password, look_for_keys=False, allow_agent=False, timeout=10)
|
||||
def run(*parts):
|
||||
_, out, err = ssh.exec_command(shlex.join(parts), timeout=30)
|
||||
body = out.read().decode()
|
||||
if out.channel.recv_exit_status() != 0:
|
||||
raise RuntimeError(err.read().decode())
|
||||
return body
|
||||
try:
|
||||
user, role, token = 'vdi-cloud@pve', 'VDICloudPower', 'console'
|
||||
users = json.loads(run('pveum', 'user', 'list', '--output-format', 'json'))
|
||||
if any(u['userid'] == user for u in users):
|
||||
raise RuntimeError('VDI account already exists. Inspect it and recover the existing token first')
|
||||
roles = json.loads(run('pveum', 'role', 'list', '--output-format', 'json'))
|
||||
if not any(r['roleid'] == role for r in roles):
|
||||
run('pveum', 'role', 'add', role, '--privs', 'VM.Audit VM.PowerMgmt')
|
||||
elif set(next(r for r in roles if r['roleid'] == role)['privs'].split(',')) != {'VM.Audit','VM.PowerMgmt'}:
|
||||
raise RuntimeError('Existing VDICloudPower role has unexpected privileges; inspect it first')
|
||||
run('pveum', 'user', 'add', user, '--comment', 'VDI cloud API; scoped power management')
|
||||
run('pveum', 'acl', 'modify', '/', '--users', user, '--roles', 'PVEAuditor')
|
||||
for vmid in args.vmids:
|
||||
run('pveum', 'acl', 'modify', f'/vms/{vmid}', '--users', user, '--roles', role)
|
||||
result = json.loads(run('pveum', 'user', 'token', 'add', user, token, '--privsep', '0', '--output-format', 'json'))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open('x', encoding='utf-8') as f:
|
||||
json.dump({'PVE_URL': f'https://{args.host}:8006', 'PVE_TOKEN_ID': result['full-tokenid'], 'PVE_TOKEN_SECRET': result['value']}, f)
|
||||
os.chmod(target, 0o600)
|
||||
with ssh.open_sftp() as sftp:
|
||||
sftp.get('/etc/pve/pve-root-ca.pem', str(target.parent / 'pve-ca.pem'))
|
||||
print(f'Created dedicated account and VM power scopes {args.vmids}; credentials saved privately')
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
param([switch]$SkipInstall, [int]$ApiPort=8088, [int]$ConsolePort=5180)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$vdiWorkspace = Split-Path -Parent $PSScriptRoot
|
||||
$vdiLocalDir = Join-Path $vdiWorkspace '.local'
|
||||
New-Item -ItemType Directory -Force -Path $vdiLocalDir | Out-Null
|
||||
$vdiGoCommand = Get-Command go -ErrorAction SilentlyContinue
|
||||
$vdiGoPath = if ($vdiGoCommand) { $vdiGoCommand.Source } else { Join-Path $env:LOCALAPPDATA 'vdi-cloud-dev\go\bin\go.exe' }
|
||||
if (!(Test-Path -LiteralPath $vdiGoPath)) { throw 'Go 1.25+ is required. Install Go and add it to PATH.' }
|
||||
foreach ($vdiPort in @($ApiPort,$ConsolePort)) {
|
||||
if (Get-NetTCPConnection -State Listen -LocalPort $vdiPort -ErrorAction SilentlyContinue) { throw "Port $vdiPort is already in use. Stop the existing development instance before starting another." }
|
||||
}
|
||||
Push-Location $vdiWorkspace
|
||||
try {
|
||||
docker compose -f deploy/compose.dev.yml up -d postgres
|
||||
if ($LASTEXITCODE -ne 0) { throw 'PostgreSQL startup failed' }
|
||||
$vdiDatabaseReady = $false
|
||||
for ($vdiAttempt = 0; $vdiAttempt -lt 40; $vdiAttempt++) {
|
||||
docker exec vdi-cloud-dev-postgres-1 pg_isready -U vdi -d vdi 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { $vdiDatabaseReady = $true; break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if (!$vdiDatabaseReady) { throw 'PostgreSQL did not become ready' }
|
||||
Push-Location platform
|
||||
try { & $vdiGoPath build -o (Join-Path $vdiLocalDir 'vdi-api.exe') ./cmd/api-gateway; if ($LASTEXITCODE -ne 0) { throw 'API build failed' } } finally { Pop-Location }
|
||||
if (!$SkipInstall) { Push-Location console; try { npm.cmd ci; if ($LASTEXITCODE -ne 0) { throw 'Console install failed' } } finally { Pop-Location } }
|
||||
$vdiEnvironment = @{
|
||||
VDI_MODE = 'demo'; VDI_LISTEN = "127.0.0.1:$ApiPort";
|
||||
DATABASE_URL = 'postgres://vdi:vdi-local-demo@127.0.0.1:55438/vdi?sslmode=disable';
|
||||
VDI_JWT_SECRET = 'local-demo-only-change-before-any-production-use-2026';
|
||||
PVE_URL = ''; PVE_TOKEN_ID = ''; PVE_TOKEN_SECRET = ''; PVE_CA_FILE = ''; VITE_API_MODE = 'api'; VDI_API_PROXY = "http://127.0.0.1:$ApiPort"
|
||||
}
|
||||
$vdiOriginalEnvironment = @{}
|
||||
try {
|
||||
foreach ($vdiKey in $vdiEnvironment.Keys) { $vdiOriginalEnvironment[$vdiKey] = [Environment]::GetEnvironmentVariable($vdiKey,'Process'); [Environment]::SetEnvironmentVariable($vdiKey,$vdiEnvironment[$vdiKey],'Process') }
|
||||
$vdiAPIProcess = Start-Process -FilePath (Join-Path $vdiLocalDir 'vdi-api.exe') -WorkingDirectory $vdiWorkspace -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'api.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'api.stderr.log') -PassThru
|
||||
$vdiConsoleProcess = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d','/c',"npm.cmd run dev -- --port $ConsolePort" -WorkingDirectory (Join-Path $vdiWorkspace 'console') -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'console.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'console.stderr.log') -PassThru
|
||||
@{ apiPid = $vdiAPIProcess.Id; consolePid = $vdiConsoleProcess.Id; startedAt = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content (Join-Path $vdiLocalDir 'cloud-dev.json')
|
||||
} finally { foreach ($vdiKey in $vdiOriginalEnvironment.Keys) { [Environment]::SetEnvironmentVariable($vdiKey,$vdiOriginalEnvironment[$vdiKey],'Process') } }
|
||||
Write-Host "Console: http://127.0.0.1:$ConsolePort"
|
||||
Write-Host 'Demo users: admin / operator / viewer; password: Demo@2026'
|
||||
Write-Host 'Development process IDs and logs: .local/'
|
||||
} finally { Pop-Location }
|
||||
@@ -0,0 +1,13 @@
|
||||
param([switch]$Build)
|
||||
$ErrorActionPreference='Stop'
|
||||
$vdiRoot=Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $vdiRoot
|
||||
try {
|
||||
if (!(Test-Path -LiteralPath '.local/live/.env')) { throw 'Configure PVE access and run tools/prepare-live-config.py first. See docs/cloud-live-integration.md.' }
|
||||
$vdiArgs=@('compose','--env-file','.local/live/.env','-f','deploy/compose.live.yml','up','-d')
|
||||
if ($Build) { $vdiArgs+='--build' }
|
||||
& docker @vdiArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Live platform startup failed' }
|
||||
Write-Host 'Real platform: http://127.0.0.1:5182'
|
||||
Write-Host 'Private initial administrator credentials: .local/live/admin-login.txt'
|
||||
} finally { Pop-Location }
|
||||
Reference in new issue
Block a user