From 3f01f3ebdc54b33dcd21fab4fd3e85480a9ee9c7 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 29 Apr 2026 01:30:21 +0000 Subject: [PATCH] feat: add onebox basic lifecycle scenario --- package.json | 5 +- readme.md | 3 +- scenarios/onebox-basic-lifecycle/readme.md | 12 + scenarios/onebox-basic-lifecycle/scenario.ts | 280 +++++++++++++++++++ scripts/provision-vm.sh | 7 +- 5 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 scenarios/onebox-basic-lifecycle/readme.md create mode 100644 scenarios/onebox-basic-lifecycle/scenario.ts diff --git a/package.json b/package.json index 49d297f..94056b0 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "description": "Whole-system integration scenarios for serve.zone components.", "scripts": { "bootstrap:components": "pnpm --dir ../interfaces install && pnpm --dir ../api install && pnpm --dir ../cloudly install && pnpm --dir ../coreflow install && pnpm --dir ../coretraffic install && pnpm install", - "test": "pnpm scenario:registry-deploy-on-push", + "test": "pnpm scenario:registry-deploy-on-push && pnpm scenario:onebox-basic-lifecycle", "scenario:registry-deploy-on-push": "tsx --tsconfig ../cloudly/tsconfig.json scenarios/registry-deploy-on-push/scenario.ts", + "scenario:onebox-basic-lifecycle": "deno run --allow-all --config ../onebox/deno.json scenarios/onebox-basic-lifecycle/scenario.ts", "vagrant:up": "vagrant up", - "vagrant:test": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm scenario:registry-deploy-on-push'", + "vagrant:test": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm test'", "vagrant:destroy": "vagrant destroy -f" }, "devDependencies": { diff --git a/readme.md b/readme.md index d4feceb..331d068 100644 --- a/readme.md +++ b/readme.md @@ -7,6 +7,7 @@ Fast package tests stay in each component repo. This repo is for stateful cross- ## Scenarios - `registry-deploy-on-push`: starts Cloudly with isolated Mongo/S3 helpers, connects Coreflow as a cluster, pushes a Docker image to Cloudly's built-in registry, verifies Cloudly metadata updates, verifies Coreflow creates the workload service, verifies Coretraffic HTTPS routing, then pushes the same tag again and verifies service recreation and routing through the new digest. +- `onebox-basic-lifecycle`: starts Onebox in dev mode, verifies core services, deploys a workload, checks HTTP plus HTTPS routing through ingress, removes the workload, and verifies cleanup. ## Host Run @@ -14,7 +15,7 @@ Requires Docker with Swarm already active. ```bash pnpm bootstrap:components -pnpm scenario:registry-deploy-on-push +pnpm test ``` ## Vagrant Run diff --git a/scenarios/onebox-basic-lifecycle/readme.md b/scenarios/onebox-basic-lifecycle/readme.md new file mode 100644 index 0000000..4392c64 --- /dev/null +++ b/scenarios/onebox-basic-lifecycle/readme.md @@ -0,0 +1,12 @@ +# onebox-basic-lifecycle + +This scenario verifies Onebox's basic single-server lifecycle. + +Assertions: + +- Onebox initializes its database, Docker integration, reverse proxy, registry, and platform services. +- The core reverse proxy platform service is exposed as `smartproxy`. +- A workload service can be deployed on the Onebox overlay network. +- HTTP and HTTPS ingress route to the workload through the reverse proxy. +- The workload can be removed and its Docker service is cleaned up. +- Onebox shutdown stops the reverse proxy and built-in registry cleanly. diff --git a/scenarios/onebox-basic-lifecycle/scenario.ts b/scenarios/onebox-basic-lifecycle/scenario.ts new file mode 100644 index 0000000..481a089 --- /dev/null +++ b/scenarios/onebox-basic-lifecycle/scenario.ts @@ -0,0 +1,280 @@ +import { dirname, fromFileUrl, join, resolve } from '@std/path'; + +import { Onebox } from '../../../onebox/ts/classes/onebox.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 delayFor = async (millisecondsArg: number) => { + await new Promise((resolveArg) => setTimeout(resolveArg, millisecondsArg)); +}; + +const run = async (commandArg: string, argsArg: string[]) => { + const command = new Deno.Command(commandArg, { + args: argsArg, + stdout: 'piped', + stderr: 'piped', + }); + const output = await command.output(); + 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 dockerServiceExists = async (serviceNameArg: string) => { + const command = new Deno.Command('docker', { + args: ['service', 'inspect', serviceNameArg], + stdout: 'null', + stderr: 'null', + }); + return (await command.output()).success; +}; + +const removeDockerService = async (serviceNameArg: string) => { + if (await dockerServiceExists(serviceNameArg)) { + await run('docker', ['service', 'rm', serviceNameArg]).catch(() => null); + await delayFor(2000); + } +}; + +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 command = new Deno.Command('docker', { + args: ['service', 'ps', serviceNameArg, '--format', '{{.CurrentState}}'], + stdout: 'piped', + stderr: 'null', + }); + const output = await command.output(); + 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 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 command = new Deno.Command('curl', { + args: curlArgs, + stdout: 'piped', + stderr: 'piped', + }); + const output = await command.output(); + 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 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(); + await onebox.init(); + + await waitForDockerServiceRunning('onebox-smartproxy'); + await assertOneboxCoreReady(onebox); + + console.log(`[${scenarioName}] Deploying workload ${serviceName}`); + const service = await onebox.services.deployService({ + name: serviceName, + image: 'caddy:2-alpine', + port: 80, + domain: routeDomain, + autoDNS: false, + envVars: {}, + }); + 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); + 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 main(); diff --git a/scripts/provision-vm.sh b/scripts/provision-vm.sh index 6c4599f..069be08 100644 --- a/scripts/provision-vm.sh +++ b/scripts/provision-vm.sh @@ -4,7 +4,7 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y ca-certificates curl git docker.io openssl +apt-get install -y ca-certificates curl git docker.io openssl unzip if [ -d /serve.zone ]; then chown -R vagrant:vagrant /serve.zone @@ -18,6 +18,10 @@ if ! command -v node >/dev/null 2>&1; then apt-get install -y nodejs fi +if ! command -v deno >/dev/null 2>&1; then + curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh +fi + corepack enable corepack prepare pnpm@10.7.0 --activate @@ -28,3 +32,4 @@ fi docker pull caddy:2-alpine docker pull node:22-trixie-slim +docker pull code.foss.global/host.today/ht-docker-smartproxy:latest