import { dirname, fromFileUrl, join, resolve } from '@std/path'; import { Onebox } from '../../../onebox/ts/classes/onebox.ts'; import { disableManagedDcRouterForScenario } from '../onebox-test-helpers.ts'; const scenarioName = 'onebox-basic-lifecycle'; const smokeId = `onebox-basic-${Date.now().toString(36)}`; const testingDir = resolve(dirname(fromFileUrl(import.meta.url)), '../..'); const buildDir = join(testingDir, '.nogit', scenarioName, smokeId); const serviceName = `app-${Date.now().toString(36)}`; const dockerServiceName = `onebox-${serviceName}`; const routeDomain = `${serviceName}.test`; const volumeName = `onebox-${serviceName}-data`; const delayFor = async (millisecondsArg: number) => { await new Promise((resolveArg) => setTimeout(resolveArg, millisecondsArg)); }; const outputCommand = async ( commandArg: string, argsArg: string[], optionsArg: Pick, timeoutMsArg = 30000, ) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMsArg); try { const command = new Deno.Command(commandArg, { args: argsArg, signal: controller.signal, ...optionsArg, }); return await command.output(); } catch (error) { if ((error as Error).name === 'AbortError') { throw new Error(`${commandArg} ${argsArg.join(' ')} timed out after ${timeoutMsArg}ms`); } throw error; } finally { clearTimeout(timeoutId); } }; const run = async (commandArg: string, argsArg: string[]) => { const output = await outputCommand(commandArg, argsArg, { stdout: 'piped', stderr: 'piped', }); const stdout = new TextDecoder().decode(output.stdout).trim(); const stderr = new TextDecoder().decode(output.stderr).trim(); if (stdout) { console.log(stdout); } if (stderr) { console.log(stderr); } if (!output.success) { throw new Error(`${commandArg} ${argsArg.join(' ')} exited with ${output.code}`); } return { stdout, stderr }; }; const waitFor = async (checkFunctionArg: () => boolean | Promise, messageArg: string) => { const startTime = Date.now(); while (Date.now() - startTime < 120000) { if (await checkFunctionArg()) { return; } await delayFor(500); } throw new Error(`Timed out waiting for ${messageArg}`); }; const getFreeTcpPort = () => { const listener = Deno.listen({ hostname: '127.0.0.1', port: 0 }); const port = (listener.addr as Deno.NetAddr).port; listener.close(); return port; }; const dockerServiceExists = async (serviceNameArg: string) => { const output = await outputCommand('docker', ['service', 'inspect', serviceNameArg], { stdout: 'null', stderr: 'null', }, 15000); return output.success; }; const removeDockerService = async (serviceNameArg: string) => { if (await dockerServiceExists(serviceNameArg)) { await run('docker', ['service', 'rm', serviceNameArg]).catch(() => null); await delayFor(2000); } }; const removeDockerVolume = async (volumeNameArg: string) => { let lastErrorText = ''; const startTime = Date.now(); while (Date.now() - startTime < 30000) { const output = await outputCommand( 'docker', ['volume', 'rm', '-f', volumeNameArg], { stdout: 'piped', stderr: 'piped', }, 15000, ); if (output.success) { return; } lastErrorText = new TextDecoder().decode(output.stderr).trim(); await delayFor(1000); } console.log( `[${scenarioName}] Failed to remove Docker volume ${volumeNameArg}: ${lastErrorText}`, ); }; const assertNoPreexistingOneboxIngress = async () => { if (await dockerServiceExists('onebox-smartproxy')) { throw new Error( 'onebox-smartproxy already exists; refusing to overwrite a running Onebox ingress service', ); } }; const waitForDockerServiceRunning = async (serviceNameArg: string) => { await waitFor(async () => { const output = await outputCommand('docker', [ 'service', 'ps', serviceNameArg, '--format', '{{.CurrentState}}', ], { stdout: 'piped', stderr: 'null', }, 15000); if (!output.success) { return false; } return new TextDecoder().decode(output.stdout).includes('Running'); }, `${serviceNameArg} running task`); }; const waitForDockerServiceRemoved = async (serviceNameArg: string) => { await waitFor( async () => !(await dockerServiceExists(serviceNameArg)), `${serviceNameArg} removal`, ); }; const inspectDockerServiceJson = async (serviceNameArg: string, formatArg: string) => { const output = await outputCommand('docker', [ 'service', 'inspect', serviceNameArg, '--format', formatArg, ], { stdout: 'piped', stderr: 'piped', }, 15000); if (!output.success) { throw new Error(`docker service inspect ${serviceNameArg} exited with ${output.code}`); } return JSON.parse(new TextDecoder().decode(output.stdout).trim()) as T; }; const requestRoute = async (protocolArg: 'http' | 'https', portArg: number) => { const curlArgs = [ '-sS', '--noproxy', '*', '--max-time', '10', '--resolve', `${routeDomain}:${portArg}:127.0.0.1`, '-o', '-', '-w', '\n%{http_code}', `${protocolArg}://${routeDomain}:${portArg}/`, ]; if (protocolArg === 'https') { curlArgs.unshift('-k'); } const output = await outputCommand('curl', curlArgs, { stdout: 'piped', stderr: 'piped', }, 15000); const stdout = new TextDecoder().decode(output.stdout).trim(); const stderr = new TextDecoder().decode(output.stderr).trim(); if (!output.success) { throw new Error(`curl failed: ${stderr || stdout}`); } const lines = stdout.split('\n'); const statusCode = lines[lines.length - 1]; const body = lines.slice(0, -1).join('\n'); return { statusCode, body, stderr }; }; const waitForRoute = async (protocolArg: 'http' | 'https', portArg: number) => { let lastResponse: Awaited> | undefined; let lastError: Error | undefined; try { await waitFor(async () => { try { lastResponse = await requestRoute(protocolArg, portArg); lastError = undefined; return lastResponse.statusCode === '200' && /Caddy|serve/i.test(lastResponse.body); } catch (error) { lastError = error as Error; return false; } }, `${protocolArg.toUpperCase()} route through Onebox ingress`); } catch (error) { console.log(`[${scenarioName}] Last route response: ${JSON.stringify(lastResponse)}`); if (lastError) { console.log(`[${scenarioName}] Last route error: ${lastError.message}`); } await run('docker', ['service', 'ps', 'onebox-smartproxy']).catch(() => null); await run('docker', ['service', 'logs', '--raw', '--tail', '120', 'onebox-smartproxy']).catch( () => null, ); await run('docker', ['service', 'ps', dockerServiceName]).catch(() => null); await run('docker', ['service', 'logs', '--raw', '--tail', '120', dockerServiceName]).catch( () => null, ); throw error; } }; const createSelfSignedCertificate = async () => { const keyPath = join(buildDir, 'route.key'); const certPath = join(buildDir, 'route.crt'); await run('openssl', [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', certPath, '-subj', `/CN=${routeDomain}`, '-addext', `subjectAltName=DNS:${routeDomain}`, '-days', '1', ]); return { privateKey: await Deno.readTextFile(keyPath), publicKey: await Deno.readTextFile(certPath), }; }; const ensureDockerReady = async () => { await run('docker', ['version']); const { stdout } = await run('docker', ['info', '--format', '{{.Swarm.LocalNodeState}}']); if (stdout.trim() !== 'active') { throw new Error( 'Docker Swarm must be active. In Vagrant this is handled by scripts/provision-vm.sh.', ); } }; const assertOneboxCoreReady = async (oneboxArg: Onebox) => { const status = await oneboxArg.getSystemStatus(); const reverseProxy = status.reverseProxy as { backend: string; http: { running: boolean }; https: { running: boolean }; }; if (reverseProxy.backend !== 'smartproxy-docker') { throw new Error(`Unexpected reverse proxy backend: ${JSON.stringify(reverseProxy)}`); } if (!reverseProxy.http.running || !reverseProxy.https.running) { throw new Error(`Reverse proxy not fully running: ${JSON.stringify(reverseProxy)}`); } const registryStatus = oneboxArg.registry.getStatus(); if (!registryStatus.running || registryStatus.port !== 4000) { throw new Error(`Registry not running: ${JSON.stringify(registryStatus)}`); } const platformServices = status.platformServices as Array<{ type: string; status: string }>; if ( !platformServices.some((serviceArg) => serviceArg.type === 'smartproxy' && serviceArg.status === 'running' ) ) { throw new Error( `SmartProxy platform service not reported as running: ${JSON.stringify(platformServices)}`, ); } if (platformServices.some((serviceArg) => serviceArg.type === 'caddy')) { throw new Error( `Unexpected legacy caddy platform service: ${JSON.stringify(platformServices)}`, ); } }; const assertDockerStorageAndPorts = async (publishedPortArg: number) => { const mounts = await inspectDockerServiceJson>>( dockerServiceName, '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}', ); if (!mounts.some((mountArg) => mountArg.Source === volumeName && mountArg.Target === '/data')) { throw new Error(`Onebox volume mount missing from Docker service: ${JSON.stringify(mounts)}`); } const ports = await inspectDockerServiceJson>>( dockerServiceName, '{{json .Spec.EndpointSpec.Ports}}', ); if ( !ports.some((portArg) => portArg.TargetPort === 80 && portArg.PublishedPort === publishedPortArg && portArg.Protocol === 'tcp' ) ) { throw new Error(`Onebox published port missing from Docker service: ${JSON.stringify(ports)}`); } }; const assertServicePersistence = (oneboxArg: Onebox, publishedPortArg: number) => { const service = oneboxArg.services.getService(serviceName); if (!service) { throw new Error(`Service was not persisted: ${serviceName}`); } if (!service.volumes?.some((volumeArg) => volumeArg.mountPath === '/data')) { throw new Error(`Service volume was not persisted: ${JSON.stringify(service)}`); } if ( !service.publishedPorts?.some((portArg) => portArg.targetPort === 80 && portArg.publishedPort === publishedPortArg ) ) { throw new Error(`Service published port was not persisted: ${JSON.stringify(service)}`); } }; const main = async () => { let onebox: Onebox | undefined; let deployedService = false; try { await ensureDockerReady(); await assertNoPreexistingOneboxIngress(); await Deno.mkdir(buildDir, { recursive: true }); Deno.chdir(buildDir); Deno.env.set('ONEBOX_DEV', 'true'); console.log(`[${scenarioName}] Starting Onebox from ${buildDir}`); onebox = new Onebox(); disableManagedDcRouterForScenario(onebox); await onebox.init(); await waitForDockerServiceRunning('onebox-smartproxy'); await assertOneboxCoreReady(onebox); console.log(`[${scenarioName}] Deploying workload ${serviceName}`); const publishedPort = getFreeTcpPort(); const service = await onebox.services.deployService({ name: serviceName, image: 'caddy:2-alpine', port: 80, domain: routeDomain, autoDNS: false, envVars: {}, volumes: [{ mountPath: '/data', backup: true }], publishedPorts: [{ targetPort: 80, publishedPort, protocol: 'tcp' }], }); deployedService = true; if (service.status !== 'running' || service.domain !== routeDomain) { throw new Error(`Unexpected deployed service state: ${JSON.stringify(service)}`); } if (!onebox.services.listServices().some((serviceArg) => serviceArg.name === serviceName)) { throw new Error('Deployed service not present in Onebox service list'); } await waitForDockerServiceRunning(dockerServiceName); assertServicePersistence(onebox, publishedPort); await assertDockerStorageAndPorts(publishedPort); await waitForRoute('http', 8080); const certificate = await createSelfSignedCertificate(); await onebox.reverseProxy.addCertificate( routeDomain, certificate.publicKey, certificate.privateKey, ); await waitForRoute('https', 8443); console.log(`[${scenarioName}] Removing workload ${serviceName}`); await onebox.services.removeService(serviceName); deployedService = false; await waitForDockerServiceRemoved(dockerServiceName); if (onebox.services.getService(serviceName)) { throw new Error('Removed service still present in Onebox database'); } console.log(`[${scenarioName}] Scenario passed`); } finally { if (onebox && deployedService) { await onebox.services.removeService(serviceName).catch((error) => { console.log( `[${scenarioName}] Failed to remove Onebox service: ${(error as Error).message}`, ); }); } if (onebox) { await onebox.shutdown().catch((error) => { console.log(`[${scenarioName}] Failed to shut down Onebox: ${(error as Error).message}`); }); } await removeDockerService(dockerServiceName); await removeDockerService('onebox-smartproxy'); await removeDockerVolume(volumeName); } }; try { await main(); Deno.exit(0); } catch (error) { console.error(error); Deno.exit(1); }