From 5494c5412176577a04c11a9a857138b48ff3b6b4 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 04:24:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(hwpod):=20=E5=A2=9E=E5=8A=A0=20L2=20?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=9C=8D=E5=8A=A1=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/hwlab-hwpod-web/main.ts | 35 ++++ deploy/deploy.yaml | 196 +++++++++++++++++- deploy/k8s/base/services.yaml | 42 ++++ deploy/k8s/base/workloads.yaml | 72 +++++++ deploy/runtime/boot/hwlab-hwpod-api.sh | 9 + deploy/runtime/boot/hwlab-hwpod-web.sh | 10 + deploy/runtime/boot/hwlab-hwpod-worker.sh | 9 + internal/hwpod/runtime.ts | 4 +- internal/hwpod/spec-registry.ts | 26 ++- web/hwlab-cloud-web/package.json | 1 + .../scripts/hwpod-native-vite.config.ts | 15 +- 11 files changed, 407 insertions(+), 12 deletions(-) create mode 100644 cmd/hwlab-hwpod-web/main.ts create mode 100755 deploy/runtime/boot/hwlab-hwpod-api.sh create mode 100755 deploy/runtime/boot/hwlab-hwpod-web.sh create mode 100755 deploy/runtime/boot/hwlab-hwpod-worker.sh diff --git a/cmd/hwlab-hwpod-web/main.ts b/cmd/hwlab-hwpod-web/main.ts new file mode 100644 index 00000000..17a9e57e --- /dev/null +++ b/cmd/hwlab-hwpod-web/main.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env bun +import path from "node:path"; + +const host = process.env.HWPOD_WEB_HOST || "0.0.0.0"; +const port = positivePort(process.env.HWPOD_WEB_PORT, 8080); +const apiUrl = requiredUrl(process.env.HWPOD_API_URL, "HWPOD_API_URL"); +const root = path.resolve(process.env.HWPOD_WEB_ROOT || "web/hwlab-cloud-web/hwpod/dist"); + +const server = Bun.serve({ + hostname: host, + port, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/health/live") return Response.json({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false }); + if (url.pathname === "/health/ready" || url.pathname.startsWith("/v1/")) return proxyRequest(request, apiUrl, url); + const relativePath = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.replace(/^\/+/, "")); + const filePath = path.resolve(root, relativePath); + if (!filePath.startsWith(`${root}${path.sep}`) && filePath !== path.join(root, "index.html")) return new Response("not found", { status: 404 }); + const file = Bun.file(filePath); + if (await file.exists()) return new Response(file, { headers: { "cache-control": relativePath === "index.html" ? "no-store" : "public, max-age=3600" } }); + return new Response("not found", { status: 404 }); + }, +}); + +process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-hwpod-web", status: "listening", host, port: server.port, apiAuthority: apiUrl, valuesPrinted: false })}\n`); +for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => { server.stop(true); process.exit(0); }); + +async function proxyRequest(request: Request, baseUrl: string, sourceUrl: URL) { + const target = new URL(`${sourceUrl.pathname}${sourceUrl.search}`, baseUrl); + const headers = new Headers(request.headers); + headers.delete("host"); + return fetch(target, { method: request.method, headers, body: request.body, redirect: "manual", signal: request.signal }); +} +function requiredUrl(value: string | undefined, name: string) { const parsed = new URL(String(value ?? "")); if (!/^https?:$/u.test(parsed.protocol)) throw new Error(`${name} must be an HTTP URL`); return parsed.toString().replace(/\/$/u, ""); } +function positivePort(value: string | undefined, fallback: number) { const parsed = Number.parseInt(String(value ?? fallback), 10); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("HWPOD_WEB_PORT must be a valid port"); return parsed; } diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index b0835b4e..ed8d709e 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -336,6 +336,12 @@ lanes: port: 80 targetPort: 8080 nodePort: 32009 + - name: hwlab-hwpod-web-public + selector: + app.kubernetes.io/name: hwlab-hwpod-web + port: 80 + targetPort: 8080 + nodePort: 32012 frp: server: address: 82.156.23.220 @@ -515,6 +521,9 @@ lanes: - hwlab-harnessrl-worker - hwlab-workbench-api - hwlab-workbench-worker + - hwlab-hwpod-api + - hwlab-hwpod-worker + - hwlab-hwpod-web - hwlab-cloud-web - hwlab-gateway - hwlab-edge-proxy @@ -530,6 +539,9 @@ lanes: hwlab-harnessrl-worker: deploy/runtime/boot/hwlab-harnessrl-worker.sh hwlab-workbench-api: deploy/runtime/boot/hwlab-workbench-api.sh hwlab-workbench-worker: deploy/runtime/boot/hwlab-workbench-worker.sh + hwlab-hwpod-api: deploy/runtime/boot/hwlab-hwpod-api.sh + hwlab-hwpod-worker: deploy/runtime/boot/hwlab-hwpod-worker.sh + hwlab-hwpod-web: deploy/runtime/boot/hwlab-hwpod-web.sh hwlab-cloud-web: deploy/runtime/boot/hwlab-cloud-web.sh hwlab-gateway: deploy/runtime/boot/hwlab-gateway.sh hwlab-edge-proxy: deploy/runtime/boot/hwlab-edge-proxy.sh @@ -743,6 +755,61 @@ lanes: env: TZ: Asia/Shanghai observable: true + hwlab-hwpod-api: + runtimeKind: bun-command + entrypoint: cmd/hwlab-hwpod-api/main.ts + artifactKind: bun-command + healthPath: /health/ready + healthPort: 6681 + componentPaths: + - cmd/hwlab-hwpod-api/ + - internal/hwpod/ + - internal/db/runtime-store-core.ts + - package.json + - package-lock.json + - deploy/runtime/boot/hwlab-hwpod-api.sh + env: + TZ: Asia/Shanghai + observable: true + hwlab-hwpod-worker: + runtimeKind: bun-command + entrypoint: cmd/hwlab-hwpod-worker/main.ts + artifactKind: bun-command + healthPath: /health/ready + healthPort: 6682 + componentPaths: + - cmd/hwlab-hwpod-worker/ + - internal/hwpod/ + - internal/db/runtime-store-core.ts + - package.json + - package-lock.json + - deploy/runtime/boot/hwlab-hwpod-worker.sh + env: + TZ: Asia/Shanghai + observable: true + hwlab-hwpod-web: + runtimeKind: cloud-web + entrypoint: cmd/hwlab-hwpod-web/main.ts + artifactKind: cloud-web + healthPath: /health/live + healthPort: 8080 + healthProbe: + startup: + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 60 + componentPaths: + - cmd/hwlab-hwpod-web/ + - web/hwlab-cloud-web/hwpod/ + - web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts + - web/hwlab-cloud-web/package.json + - package.json + - package-lock.json + - deploy/runtime/boot/hwlab-hwpod-web.sh + env: + BUN_CONFIG_REGISTRY: https://registry.npmjs.org/ + npm_config_registry: https://registry.npmjs.org/ + observable: true hwlab-cloud-web: runtimeKind: cloud-web entrypoint: web/hwlab-cloud-web/index.html @@ -867,7 +934,7 @@ lanes: bootConfig: &v03BootConfig template: default overrides: {} - configMaps: + configMaps: &v03ConfigMaps - name: hwlab-v03-hwpod-preinstalled-specs labels: hwlab.pikastech.local/config-kind: hwpod-preinstalled-specs @@ -902,11 +969,33 @@ lanes: probeName: MicroLink CMSIS-DAP programBackend: keil-headless autoBindUvoptx: true + boardComm: + endpoints: + hwpod-node-ops: + kind: hwpod-node-ops + endpointRef: config/hwlab-gateway/constart-71freq.yaml#gateway.profile.nodeOps.publicUrl + freq71: + kind: jsonrpc-tcp + host: 192.168.0.154 + port: 8000 + role: frequency-controller + main41: + kind: jsonrpc-tcp + host: 192.168.0.151 + port: 8000 + role: main-controller ioProbe: uart: id: uart/1 port: COM4 baudrate: 921600 + probes: + main41.ai0.current: + endpointRef: main41 + quantity: current + unit: mA + path: hardware/ai/0 + valuePath: ai_current_mA nodeBinding: nodeId: node-d601-f103-v2 nodeType: pc-host @@ -1172,6 +1261,58 @@ lanes: HWLAB_KAFKA_EVENT_TOPIC: hwlab.event.v1 HWLAB_KAFKA_CLIENT_ID: hwlab-v03-workbench-worker OTEL_SERVICE_NAME: hwlab-workbench-worker + - serviceId: hwlab-hwpod-api + replicas: 1 + env: + HWPOD_API_HOST: 0.0.0.0 + HWPOD_API_PORT: "6681" + HWPOD_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233 + HWPOD_TEMPORAL_NAMESPACE: unidesk + HWPOD_TEMPORAL_TASK_QUEUE: hwlab-v03-hwpod + HWPOD_RUNTIME_API_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667 + HWPOD_RUNTIME_API_AUTHORIZATION: secretRef:hwlab-v03-master-server-admin-api-key/api-key + HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX: Bearer + HWPOD_SPEC_DATABASE_URL: secretRef:hwlab-cloud-api-v03-db/database-url + HWPOD_SPEC_DATABASE_SCHEMA: hwpod + HWPOD_SPEC_DATABASE_TABLE: runtime_specs + HWPOD_SPEC_DATABASE_CONNECTION_TIMEOUT_MS: "5000" + HWPOD_BUILTIN_SPEC_CONFIG_DIRECTORY: /etc/hwlab/hwpod-specs + OTEL_SERVICE_NAME: hwlab-hwpod-api + configMapMounts: + - name: hwpod-preinstalled-specs + configMapName: hwlab-v03-hwpod-preinstalled-specs + mountPath: /etc/hwlab/hwpod-specs + readOnly: true + - serviceId: hwlab-hwpod-worker + replicas: 1 + env: + HWPOD_WORKER_HEALTH_HOST: 0.0.0.0 + HWPOD_WORKER_HEALTH_PORT: "6682" + HWPOD_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233 + HWPOD_TEMPORAL_NAMESPACE: unidesk + HWPOD_TEMPORAL_TASK_QUEUE: hwlab-v03-hwpod + HWPOD_RUNTIME_API_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667 + HWPOD_RUNTIME_API_AUTHORIZATION: secretRef:hwlab-v03-master-server-admin-api-key/api-key + HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX: Bearer + HWPOD_SPEC_DATABASE_URL: secretRef:hwlab-cloud-api-v03-db/database-url + HWPOD_SPEC_DATABASE_SCHEMA: hwpod + HWPOD_SPEC_DATABASE_TABLE: runtime_specs + HWPOD_SPEC_DATABASE_CONNECTION_TIMEOUT_MS: "5000" + HWPOD_BUILTIN_SPEC_CONFIG_DIRECTORY: /etc/hwlab/hwpod-specs + OTEL_SERVICE_NAME: hwlab-hwpod-worker + configMapMounts: + - name: hwpod-preinstalled-specs + configMapName: hwlab-v03-hwpod-preinstalled-specs + mountPath: /etc/hwlab/hwpod-specs + readOnly: true + - serviceId: hwlab-hwpod-web + replicas: 1 + env: + HWPOD_WEB_HOST: 0.0.0.0 + HWPOD_WEB_PORT: "8080" + HWPOD_API_URL: http://hwlab-hwpod-api.hwlab-v03.svc.cluster.local:6681 + HWPOD_WEB_ROOT: web/hwlab-cloud-web/hwpod/dist + OTEL_SERVICE_NAME: hwlab-hwpod-web production: name: Production node: NC01 @@ -1213,6 +1354,7 @@ lanes: envReuseServices: *v03EnvReuseServices bootScripts: *v03BootScripts serviceDeclarations: *v03ServiceDeclarations + configMaps: *v03ConfigMaps services: - serviceId: hwlab-cloud-api shutdownDrain: @@ -1307,6 +1449,58 @@ lanes: TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-production-tasktree TASKTREE_WORKER_HEALTH_PORT: "6674" OTEL_SERVICE_NAME: hwlab-tasktree-worker + - serviceId: hwlab-hwpod-api + replicas: 1 + env: + HWPOD_API_HOST: 0.0.0.0 + HWPOD_API_PORT: "6681" + HWPOD_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233 + HWPOD_TEMPORAL_NAMESPACE: unidesk + HWPOD_TEMPORAL_TASK_QUEUE: hwlab-production-hwpod + HWPOD_RUNTIME_API_URL: http://hwlab-cloud-api.hwlab-production.svc.cluster.local:6667 + HWPOD_RUNTIME_API_AUTHORIZATION: secretRef:hwlab-production-master-server-admin-api-key/api-key + HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX: Bearer + HWPOD_SPEC_DATABASE_URL: secretRef:hwlab-cloud-api-production-db/database-url + HWPOD_SPEC_DATABASE_SCHEMA: hwpod + HWPOD_SPEC_DATABASE_TABLE: runtime_specs + HWPOD_SPEC_DATABASE_CONNECTION_TIMEOUT_MS: "5000" + HWPOD_BUILTIN_SPEC_CONFIG_DIRECTORY: /etc/hwlab/hwpod-specs + OTEL_SERVICE_NAME: hwlab-hwpod-api + configMapMounts: + - name: hwpod-preinstalled-specs + configMapName: hwlab-v03-hwpod-preinstalled-specs + mountPath: /etc/hwlab/hwpod-specs + readOnly: true + - serviceId: hwlab-hwpod-worker + replicas: 1 + env: + HWPOD_WORKER_HEALTH_HOST: 0.0.0.0 + HWPOD_WORKER_HEALTH_PORT: "6682" + HWPOD_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233 + HWPOD_TEMPORAL_NAMESPACE: unidesk + HWPOD_TEMPORAL_TASK_QUEUE: hwlab-production-hwpod + HWPOD_RUNTIME_API_URL: http://hwlab-cloud-api.hwlab-production.svc.cluster.local:6667 + HWPOD_RUNTIME_API_AUTHORIZATION: secretRef:hwlab-production-master-server-admin-api-key/api-key + HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX: Bearer + HWPOD_SPEC_DATABASE_URL: secretRef:hwlab-cloud-api-production-db/database-url + HWPOD_SPEC_DATABASE_SCHEMA: hwpod + HWPOD_SPEC_DATABASE_TABLE: runtime_specs + HWPOD_SPEC_DATABASE_CONNECTION_TIMEOUT_MS: "5000" + HWPOD_BUILTIN_SPEC_CONFIG_DIRECTORY: /etc/hwlab/hwpod-specs + OTEL_SERVICE_NAME: hwlab-hwpod-worker + configMapMounts: + - name: hwpod-preinstalled-specs + configMapName: hwlab-v03-hwpod-preinstalled-specs + mountPath: /etc/hwlab/hwpod-specs + readOnly: true + - serviceId: hwlab-hwpod-web + replicas: 1 + env: + HWPOD_WEB_HOST: 0.0.0.0 + HWPOD_WEB_PORT: "8080" + HWPOD_API_URL: http://hwlab-hwpod-api.hwlab-production.svc.cluster.local:6681 + HWPOD_WEB_ROOT: web/hwlab-cloud-web/hwpod/dist + OTEL_SERVICE_NAME: hwlab-hwpod-web envRecipe: *v03EnvRecipe bootConfig: *v03BootConfig services: diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 41acbff9..0c273436 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -2,6 +2,48 @@ "apiVersion": "v1", "kind": "List", "items": [ + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-hwpod-api", + "namespace": "hwlab-dev", + "labels": { "app.kubernetes.io/name": "hwlab-hwpod-api", "hwlab.pikastech.local/service-id": "hwlab-hwpod-api" } + }, + "spec": { + "type": "ClusterIP", + "selector": { "app.kubernetes.io/name": "hwlab-hwpod-api" }, + "ports": [{ "name": "http", "port": 6681, "targetPort": "http" }] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-hwpod-worker", + "namespace": "hwlab-dev", + "labels": { "app.kubernetes.io/name": "hwlab-hwpod-worker", "hwlab.pikastech.local/service-id": "hwlab-hwpod-worker" } + }, + "spec": { + "type": "ClusterIP", + "selector": { "app.kubernetes.io/name": "hwlab-hwpod-worker" }, + "ports": [{ "name": "health", "port": 6682, "targetPort": "health" }] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-hwpod-web", + "namespace": "hwlab-dev", + "labels": { "app.kubernetes.io/name": "hwlab-hwpod-web", "hwlab.pikastech.local/service-id": "hwlab-hwpod-web" } + }, + "spec": { + "type": "ClusterIP", + "selector": { "app.kubernetes.io/name": "hwlab-hwpod-web" }, + "ports": [{ "name": "http", "port": 8080, "targetPort": "http" }] + } + }, { "apiVersion": "v1", "kind": "Service", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index fcc35824..03426d6d 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -2,6 +2,78 @@ "apiVersion": "v1", "kind": "List", "items": [ + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "hwlab-hwpod-api", + "namespace": "hwlab-dev", + "labels": { "app.kubernetes.io/name": "hwlab-hwpod-api", "hwlab.pikastech.local/service-id": "hwlab-hwpod-api" } + }, + "spec": { + "replicas": 0, + "selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-hwpod-api" } }, + "template": { + "metadata": { "labels": { "app.kubernetes.io/name": "hwlab-hwpod-api", "hwlab.pikastech.local/service-id": "hwlab-hwpod-api" } }, + "spec": { "containers": [{ + "name": "hwlab-hwpod-api", + "image": "127.0.0.1:5000/hwlab/hwlab-hwpod-api:dev", + "ports": [{ "name": "http", "containerPort": 6681 }], + "env": [{ "name": "HWPOD_API_PORT", "value": "6681" }], + "readinessProbe": { "httpGet": { "path": "/health/ready", "port": "http" } }, + "livenessProbe": { "httpGet": { "path": "/health/live", "port": "http" } } + }] } + } + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "hwlab-hwpod-worker", + "namespace": "hwlab-dev", + "labels": { "app.kubernetes.io/name": "hwlab-hwpod-worker", "hwlab.pikastech.local/service-id": "hwlab-hwpod-worker" } + }, + "spec": { + "replicas": 0, + "selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-hwpod-worker" } }, + "template": { + "metadata": { "labels": { "app.kubernetes.io/name": "hwlab-hwpod-worker", "hwlab.pikastech.local/service-id": "hwlab-hwpod-worker" } }, + "spec": { "containers": [{ + "name": "hwlab-hwpod-worker", + "image": "127.0.0.1:5000/hwlab/hwlab-hwpod-worker:dev", + "ports": [{ "name": "health", "containerPort": 6682 }], + "env": [{ "name": "HWPOD_WORKER_HEALTH_PORT", "value": "6682" }], + "readinessProbe": { "httpGet": { "path": "/health/ready", "port": "health" } }, + "livenessProbe": { "httpGet": { "path": "/health/live", "port": "health" } } + }] } + } + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "hwlab-hwpod-web", + "namespace": "hwlab-dev", + "labels": { "app.kubernetes.io/name": "hwlab-hwpod-web", "hwlab.pikastech.local/service-id": "hwlab-hwpod-web" } + }, + "spec": { + "replicas": 0, + "selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-hwpod-web" } }, + "template": { + "metadata": { "labels": { "app.kubernetes.io/name": "hwlab-hwpod-web", "hwlab.pikastech.local/service-id": "hwlab-hwpod-web" } }, + "spec": { "containers": [{ + "name": "hwlab-hwpod-web", + "image": "127.0.0.1:5000/hwlab/hwlab-hwpod-web:dev", + "ports": [{ "name": "http", "containerPort": 8080 }], + "env": [{ "name": "HWPOD_WEB_PORT", "value": "8080" }], + "readinessProbe": { "httpGet": { "path": "/health/live", "port": "http" } }, + "livenessProbe": { "httpGet": { "path": "/health/live", "port": "http" } } + }] } + } + } + }, { "apiVersion": "apps/v1", "kind": "Deployment", diff --git a/deploy/runtime/boot/hwlab-hwpod-api.sh b/deploy/runtime/boot/hwlab-hwpod-api.sh new file mode 100755 index 00000000..515f2c92 --- /dev/null +++ b/deploy/runtime/boot/hwlab-hwpod-api.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID=hwlab-hwpod-api +export HWLAB_SERVICE_ENTRYPOINT=cmd/hwlab-hwpod-api/main.ts +export HWPOD_API_PORT="${HWPOD_API_PORT:?HWPOD_API_PORT is required}" +export PORT="$HWPOD_API_PORT" + +exec "${HWLAB_BUN_COMMAND:-bun}" run cmd/hwlab-hwpod-api/main.ts diff --git a/deploy/runtime/boot/hwlab-hwpod-web.sh b/deploy/runtime/boot/hwlab-hwpod-web.sh new file mode 100755 index 00000000..c5415da3 --- /dev/null +++ b/deploy/runtime/boot/hwlab-hwpod-web.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID=hwlab-hwpod-web +export HWLAB_SERVICE_ENTRYPOINT=cmd/hwlab-hwpod-web/main.ts +export HWPOD_WEB_PORT="${HWPOD_WEB_PORT:?HWPOD_WEB_PORT is required}" +export PORT="$HWPOD_WEB_PORT" + +"${HWLAB_BUN_COMMAND:-bun}" run --cwd web/hwlab-cloud-web build:hwpod +exec "${HWLAB_BUN_COMMAND:-bun}" run cmd/hwlab-hwpod-web/main.ts diff --git a/deploy/runtime/boot/hwlab-hwpod-worker.sh b/deploy/runtime/boot/hwlab-hwpod-worker.sh new file mode 100755 index 00000000..299e0cd4 --- /dev/null +++ b/deploy/runtime/boot/hwlab-hwpod-worker.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID=hwlab-hwpod-worker +export HWLAB_SERVICE_ENTRYPOINT=cmd/hwlab-hwpod-worker/main.ts +export HWPOD_WORKER_HEALTH_PORT="${HWPOD_WORKER_HEALTH_PORT:?HWPOD_WORKER_HEALTH_PORT is required}" +export PORT="$HWPOD_WORKER_HEALTH_PORT" + +exec "${HWLAB_BUN_COMMAND:-bun}" run cmd/hwlab-hwpod-worker/main.ts diff --git a/internal/hwpod/runtime.ts b/internal/hwpod/runtime.ts index 51b5efff..4b72bd71 100644 --- a/internal/hwpod/runtime.ts +++ b/internal/hwpod/runtime.ts @@ -13,7 +13,9 @@ export function hwpodRuntime(env: Record = process.e const namespace = String(env.HWPOD_TEMPORAL_NAMESPACE ?? "unidesk").trim(); const taskQueue = String(env.HWPOD_TEMPORAL_TASK_QUEUE ?? "hwlab-v03-hwpod").trim(); const runtimeApiUrl = String(env.HWPOD_RUNTIME_API_URL ?? "").trim().replace(/\/+$/u, ""); - const runtimeApiAuthorization = String(env.HWPOD_RUNTIME_API_AUTHORIZATION ?? "").trim(); + const runtimeApiAuthorizationValue = String(env.HWPOD_RUNTIME_API_AUTHORIZATION ?? "").trim(); + const runtimeApiAuthorizationPrefix = String(env.HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX ?? "").trim(); + const runtimeApiAuthorization = runtimeApiAuthorizationPrefix ? `${runtimeApiAuthorizationPrefix} ${runtimeApiAuthorizationValue}` : runtimeApiAuthorizationValue; if (!runtimeApiUrl) throw codedError("hwpod_runtime_api_url_required", "HWPOD_RUNTIME_API_URL is required"); if (!runtimeApiAuthorization) throw codedError("hwpod_runtime_api_authorization_required", "HWPOD_RUNTIME_API_AUTHORIZATION is required"); return { diff --git a/internal/hwpod/spec-registry.ts b/internal/hwpod/spec-registry.ts index a6b4073f..cd9e35e3 100644 --- a/internal/hwpod/spec-registry.ts +++ b/internal/hwpod/spec-registry.ts @@ -5,7 +5,10 @@ */ import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; import pg from "pg"; +import { parse as parseYaml } from "yaml"; import { buildPostgresPoolConfig, postgresSslModeFromConnectionString } from "../db/runtime-store-core.ts"; import { normalizeHwpodSpec } from "./spec-document.ts"; @@ -29,7 +32,7 @@ export function hwpodSpecRegistryFromEnv(env: Record const schema = postgresIdentifier(requiredEnv(env, "HWPOD_SPEC_DATABASE_SCHEMA"), "HWPOD_SPEC_DATABASE_SCHEMA"); const table = postgresIdentifier(requiredEnv(env, "HWPOD_SPEC_DATABASE_TABLE"), "HWPOD_SPEC_DATABASE_TABLE"); const connectionTimeoutMs = positiveIntegerEnv(env, "HWPOD_SPEC_DATABASE_CONNECTION_TIMEOUT_MS"); - const builtIns = parseBuiltIns(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON); + const builtIns = parseBuiltIns(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON, env.HWPOD_BUILTIN_SPEC_CONFIG_DIRECTORY); return createHwpodSpecRegistry({ store: createPostgresHwpodRuntimeSpecStore({ databaseUrl, schema, table, connectionTimeoutMs }), builtIns }); } @@ -163,7 +166,9 @@ function runtimeRow(row: any): HwpodRuntimeSpecRecord { return { authority: "runtime", hwpodId, createdAt: isoTimestamp(row.created_at), updatedAt: isoTimestamp(row.updated_at), document }; } -function parseBuiltIns(raw: string | undefined): BuiltInInput[] { +function parseBuiltIns(raw: string | undefined, configDirectory: string | undefined): BuiltInInput[] { + if (String(raw ?? "").trim() && String(configDirectory ?? "").trim()) throw codedError("invalid_hwpod_builtin_specs", "HWPOD built-in specs must use either JSON or a config directory, not both"); + if (String(configDirectory ?? "").trim()) return parseBuiltInConfigDirectory(String(configDirectory)); if (!String(raw ?? "").trim()) return []; let value: unknown; try { value = JSON.parse(String(raw)); } @@ -177,6 +182,23 @@ function parseBuiltIns(raw: string | undefined): BuiltInInput[] { }); } +function parseBuiltInConfigDirectory(directoryInput: string): BuiltInInput[] { + const directory = path.resolve(directoryInput.trim()); + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.ya?ml$/iu.test(entry.name)) + .map((entry) => entry.name) + .sort() + .map((fileName) => { + const source = readFileSync(path.join(directory, fileName), "utf8"); + const document = parseYaml(source); + return { + configRef: `configMap:hwlab-v03-hwpod-preinstalled-specs/${fileName}`, + sha256: createHash("sha256").update(stableJson(document)).digest("hex"), + document, + }; + }); +} + function normalizeBuiltIns(inputs: BuiltInInput[]) { const records = new Map(); for (const input of inputs) { diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 06a3ecae..7aa902e6 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -24,6 +24,7 @@ "check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict", "typecheck": "bun run deps --quiet && vue-tsc --noEmit", "build": "bun run deps --quiet && bun run scripts/build.ts", + "build:hwpod": "bun run deps --quiet && vite build --config scripts/hwpod-native-vite.config.ts", "test": "bun run deps --quiet && vitest run", "e2e:workbench": "bun run deps --quiet && bun run build && bunx playwright test -c playwright.workbench.config.ts", "e2e:workbench:report": "bun run deps --quiet && bunx playwright show-report .state/workbench-e2e/html-report" diff --git a/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts index 12a983ed..ae269a1c 100644 --- a/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts +++ b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts @@ -6,19 +6,18 @@ import path from "node:path"; import { defineConfig, type Plugin } from "vite"; -const apiUrl = process.env.HWPOD_NATIVE_API_URL; -if (!apiUrl) throw new Error("HWPOD_NATIVE_API_URL is required"); - -export default defineConfig({ +export default defineConfig(({ command }) => ({ root: path.resolve(import.meta.dirname, "../hwpod"), - server: { + build: { outDir: "dist", emptyOutDir: true }, + server: command === "serve" ? { host: process.env.HWPOD_WEB_HOST || "0.0.0.0", port: requiredPort(process.env.HWPOD_WEB_PORT), strictPort: true, - proxy: { "/v1": { target: apiUrl, changeOrigin: false }, "/health/ready": { target: apiUrl, changeOrigin: false } } - }, + proxy: { "/v1": { target: requiredApiUrl(), changeOrigin: false }, "/health/ready": { target: requiredApiUrl(), changeOrigin: false } } + } : undefined, plugins: [healthPlugin()] -}); +})); function healthPlugin(): Plugin { return { name: "hwpod-native-health", configureServer(server) { server.middlewares.use((request, response, next) => { if (request.url !== "/health/live") return next(); response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false })); }); } }; } +function requiredApiUrl() { const value = process.env.HWPOD_NATIVE_API_URL; if (!value) throw new Error("HWPOD_NATIVE_API_URL is required"); return value; } function requiredPort(value: string | undefined) { const port = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("HWPOD_WEB_PORT is required and must be valid"); return port; }