commit ce41f4116997d2673987282c01f57e696689566c Author: Juergen Kunz Date: Thu May 7 15:53:15 2026 +0000 feat: scaffold baseos runtime diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c639b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.nogit/ +.DS_Store +node_modules/ +dist/ +dist_*/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f31062 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM denoland/deno:alpine + +WORKDIR /app + +COPY deno.json mod.ts ./ +COPY ts ./ts + +RUN deno cache mod.ts + +CMD ["run", "--allow-env", "--allow-net", "--allow-read=/data/baseos,.", "--allow-write=/data/baseos", "mod.ts", "start"] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..406b653 --- /dev/null +++ b/deno.json @@ -0,0 +1,37 @@ +{ + "name": "@serve.zone/baseos", + "version": "0.1.0", + "exports": "./mod.ts", + "tasks": { + "start": "deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts start", + "dev": "deno run --allow-all mod.ts start", + "check": "deno check mod.ts", + "test": "deno check mod.ts", + "fmt": "deno fmt", + "lint": "deno lint" + }, + "compilerOptions": { + "lib": [ + "deno.window", + "deno.ns" + ], + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true + }, + "fmt": { + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "semiColons": true, + "singleQuote": true, + "proseWrap": "preserve" + }, + "lint": { + "rules": { + "tags": [ + "recommended" + ] + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ba81ff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '2.4' + +volumes: + baseos-data: + +services: + baserunner: + build: . + restart: always + labels: + io.balena.features.supervisor-api: '1' + environment: + BASEOS_STATE_PATH: /data/baseos/state.json + BASEOS_HEARTBEAT_INTERVAL_MS: '60000' + volumes: + - baseos-data:/data/baseos diff --git a/license.md b/license.md new file mode 100644 index 0000000..5d19587 --- /dev/null +++ b/license.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lossless GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..4f8a8d2 --- /dev/null +++ b/mod.ts @@ -0,0 +1,8 @@ +export * from './ts/index.ts'; +export { runCli } from './ts/cli.ts'; + +import { runCli } from './ts/cli.ts'; + +if (import.meta.main) { + await runCli(Deno.args); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e6a1910 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@serve.zone/baseos", + "version": "0.1.0", + "private": false, + "description": "Cloudly-enrolled BaseOS runtime layer for balenaOS-derived devices.", + "type": "module", + "main": "mod.ts", + "scripts": { + "start": "deno task start", + "dev": "deno task dev", + "test": "deno task test", + "build": "deno task check", + "check": "deno task check", + "fmt": "deno task fmt", + "lint": "deno task lint" + }, + "keywords": [ + "serve.zone", + "baseos", + "baserunner", + "cloudly", + "balenaos", + "iot", + "edge" + ], + "author": "Lossless GmbH", + "license": "MIT", + "repository": { + "type": "git", + "url": "ssh://git@code.foss.global:29419/serve.zone/baseos.git" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..7c692fa --- /dev/null +++ b/readme.md @@ -0,0 +1,255 @@ +# @serve.zone/baseos + +BaseOS is the serve.zone runtime layer for devices that should run on balenaOS-derived host systems but enroll only in Cloudly. + +The first implementation target is intentionally small: a `baserunner` container runs on the device, talks to the local Balena Supervisor API when available, reports status to Cloudly, and prepares the update path for Onebox and other BaseOS services. + +## Goals + +- Use balenaOS as the proven host foundation for embedded and edge devices. +- Avoid BalenaCloud enrollment for serve.zone devices. +- Enroll devices directly in Cloudly with a Cloudly join token. +- Let Cloudly track BaseOS nodes, desired releases, rollout state, and update health. +- Let Onebox detect when it is running on BaseOS and delegate self-updates to BaseRunner. +- Keep the first version useful without pretending host OS updates are solved. + +## Non-Goals For V1 + +- No BalenaCloud fleet enrollment. +- No openBalena dependency. +- No full Balena API replacement. +- No remote host OS updates until BaseOS has its own signed artifact channel. +- No silent use of Balena trademarks as serve.zone branding. + +## Runtime Model + +```text +balenaOS-derived host + Balena Supervisor + baserunner container + - Cloudly enrollment and heartbeat + - local Supervisor API access + - local BaseOS runtime API for Onebox, later + + onebox container, later + - detects BaseOS through BaseRunner + - delegates self-update to BaseRunner + - runs Onebox-managed apps in its own runtime model +``` + +The current scaffold implements `baserunner`; the matching Cloudly registration and heartbeat handlers live in the `cloudly` repo. Onebox integration is still a later focused change. + +## Levels + +### Level 1: App-Layer Updates + +Level 1 is the first production target. + +BaseOS devices boot a balenaOS-derived image with `baserunner` preloaded. `baserunner` enrolls the node in Cloudly, reports Balena Supervisor/device state, and can ask the local Supervisor to check/apply application release updates. + +Cloudly responsibilities: + +- Store BaseOS node records. +- Issue and validate join tokens. +- Accept BaseRunner heartbeats. +- Track current BaseRunner/Onebox release metadata. +- Publish desired app-layer release state. + +BaseRunner responsibilities: + +- Persist a node ID and node token in a named volume. +- Report supervisor availability, OS version, supervisor version, release hash, update state, and IP information. +- Receive desired app-layer state from Cloudly heartbeats. +- Call Supervisor API endpoints such as `/ping`, `/v1/device`, `/v2/state/status`, and `/v1/update`. + +Onebox responsibilities: + +- Detect BaseOS via a local BaseRunner API and explicit environment such as `SERVEZONE_RUNTIME=baseos`. +- Disable direct self-replacement when running on BaseOS. +- Ask BaseRunner to perform BaseOS-safe app-layer updates. + +Level 1 does not update the host OS remotely. Host OS updates are manual or deferred. + +### Level 2: Cloudly-Managed Host OS Releases + +Level 2 adds a Cloudly-owned BaseOS update channel. + +Cloudly or a central serve.zone update service hosts signed BaseOS host OS artifacts. Devices are still enrolled only in Cloudly. + +Artifact layout target: + +```text +https://updates.serve.zone/baseos///manifest.json +https://updates.serve.zone/baseos///checksums.txt +https://updates.serve.zone/baseos///license-bundle.tar.gz +registry.serve.zone/baseos/hostapp/: +registry.serve.zone/baseos/baserunner: +``` + +Cloudly responsibilities: + +- Maintain approved BaseOS channels. +- Pin desired host OS versions per node or fleet. +- Verify release signatures before rollout. +- Track staged, applying, succeeded, failed, and rollback-needed states. + +BaseRunner responsibilities: + +- Verify release manifests and signatures. +- Hold update locks during critical work. +- Coordinate with the host update mechanism where available. +- Report detailed progress and failure logs. + +This level requires careful work against balenaOS host update internals and licensing obligations for redistributed images. + +### Level 3: Cloudly As A Balena-Compatible Backend + +Level 3 is the largest option and should only happen if Level 2 is insufficient. + +Cloudly would implement enough target-state and artifact APIs for a balenaOS-derived Supervisor to treat Cloudly as its backend. The device `config.json` would point to Cloudly-controlled endpoints instead of BalenaCloud endpoints. + +Target endpoints would include equivalents for: + +- API target state. +- Registry pulls. +- Delta service, if implemented. +- VPN or remote access, if required. +- Device diagnostics and logs, if required. + +This level is effectively a focused openBalena-like backend inside serve.zone. It is not a first milestone. + +## Cloudly-Only Enrollment + +BaseOS devices should not need BalenaCloud identity. + +Enrollment flow: + +```text +1. User creates a BaseOS join token in Cloudly. +2. BaseOS image is flashed with BASEOS_CLOUDLY_URL and BASEOS_JOIN_TOKEN. +3. BaseRunner boots and registers against Cloudly. +4. Cloudly returns a node token. +5. BaseRunner stores the node token in /data/baseos/state.json. +6. Future heartbeats use the node token. +``` + +Current provisional HTTP endpoints used by this scaffold: + +- `POST /baseos/v1/nodes/register` +- `POST /baseos/v1/nodes/heartbeat` + +Cloudly implements these paths through its BaseOS manager. A deployed Cloudly instance must also have `baseosJoinToken` configured before new nodes can enroll. + +## Supervisor API Usage + +BaseRunner uses Balena Supervisor APIs only when `BALENA_SUPERVISOR_ADDRESS` and `BALENA_SUPERVISOR_API_KEY` are provided by the `io.balena.features.supervisor-api` service label. + +Current calls: + +- `GET /ping` +- `GET /v1/device?apikey=...` +- `GET /v2/state/status?apikey=...` +- `GET /v2/version?apikey=...` +- `POST /v1/update?apikey=...` +- `POST /v1/reboot?apikey=...` + +The `docker-compose.yml` enables the supervisor API label for `baserunner`. + +## Onebox Integration Plan + +Onebox should detect BaseOS through explicit runtime signals, not by guessing from Balena environment variables alone. + +Detection signals: + +- `SERVEZONE_RUNTIME=baseos` +- `BASEOS_AGENT_URL` or equivalent local BaseRunner API URL +- successful BaseRunner handshake + +Onebox behavior on BaseOS: + +- Show BaseOS runtime in system status. +- Route self-update requests to BaseRunner. +- Avoid replacing its own container directly. +- Use update locks around backup/restore and migration operations. + +## Security + +- Join tokens must be one-time or short-lived. +- Node tokens must be long-lived machine tokens scoped to one BaseOS node. +- Host OS release manifests must be signed before Level 2. +- BaseRunner must reject unsigned or untrusted desired host OS updates. +- Supervisor API access is powerful; keep BaseRunner's local API private and minimal. +- Never log join tokens, node tokens, or Supervisor API keys. + +## Licensing And Branding + +balenaOS source components are open source, but trademarks are separate from copyright licenses. + +BaseOS must: + +- Preserve required upstream notices and license bundles. +- Provide source/license compliance for redistributed GPL/LGPL/Yocto components. +- Avoid presenting BaseOS as official Balena software. +- Replace public-facing branding when redistributing images. +- Use Balena names only where needed to describe compatibility or documented APIs. + +## Implementation Milestones + +### Milestone 1: BaseRunner Scaffold + +- Deno CLI and daemon structure. +- Supervisor API client. +- Persistent node state. +- Provisional Cloudly register/heartbeat client. +- Balena compose service definition. + +### Milestone 2: Shared Interfaces + +- Add BaseOS node, runtime status, desired state, heartbeat, and registration contracts to `@serve.zone/interfaces`. +- Add machine role or scoped token model for BaseOS nodes. + +### Milestone 3: Cloudly BaseOS Manager + +- Join token validation through `baseosJoinToken`. +- Node registration. +- Heartbeat ingestion. +- Desired state storage. +- Admin/UI status views. + +### Milestone 4: Onebox Runtime Detection + +- Add BaseOS runtime manager to Onebox. +- Add status display. +- Route Onebox self-update through BaseRunner when running on BaseOS. + +### Milestone 5: App Release Updates + +- Build and publish BaseRunner release images. +- Add Onebox service image/release metadata. +- Let Cloudly request app-layer updates. +- Report update status and failures. + +### Milestone 6: Host OS Release Channel + +- Build BaseOS host OS images. +- Publish signed manifests and artifacts. +- Add Cloudly release approval and rollout controls. +- Add BaseRunner host update execution and reporting. + +## Current Commands + +```bash +deno task check +deno task start +deno task dev +deno task fmt +deno task lint +``` + +Run a one-shot status check: + +```bash +deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts status +``` + +Inside balenaOS, the `baserunner` service should be started from `docker-compose.yml` so the Supervisor API environment variables are injected. diff --git a/ts/classes.baserunner.ts b/ts/classes.baserunner.ts new file mode 100644 index 0000000..d287ac5 --- /dev/null +++ b/ts/classes.baserunner.ts @@ -0,0 +1,173 @@ +import { CloudlyConnector } from './classes.cloudlyconnector.ts'; +import { SupervisorClient } from './classes.supervisorclient.ts'; +import type { + IBaseOsRuntimeInfo, + IBaseRunnerConfig, + IBaseRunnerState, + TCloudlyConnectionStatus, +} from './types.ts'; + +const defaultStatePath = '/data/baseos/state.json'; + +export class BaseRunner { + public readonly config: IBaseRunnerConfig; + public readonly supervisorClient: SupervisorClient; + public readonly cloudlyConnector: CloudlyConnector; + + private state?: IBaseRunnerState; + private heartbeatTimer: number | undefined; + private cloudlyConnectionStatus: TCloudlyConnectionStatus = 'not-configured'; + + public static fromEnv() { + return new BaseRunner({ + cloudlyUrl: Deno.env.get('BASEOS_CLOUDLY_URL') || undefined, + joinToken: Deno.env.get('BASEOS_JOIN_TOKEN') || undefined, + nodeToken: Deno.env.get('BASEOS_NODE_TOKEN') || undefined, + nodeId: Deno.env.get('BASEOS_NODE_ID') || undefined, + statePath: Deno.env.get('BASEOS_STATE_PATH') || defaultStatePath, + heartbeatIntervalMs: Number(Deno.env.get('BASEOS_HEARTBEAT_INTERVAL_MS') || '60000'), + supervisorAddress: Deno.env.get('BALENA_SUPERVISOR_ADDRESS') || undefined, + supervisorApiKey: Deno.env.get('BALENA_SUPERVISOR_API_KEY') || undefined, + }); + } + + constructor(configArg: IBaseRunnerConfig) { + this.config = configArg; + this.supervisorClient = new SupervisorClient({ + address: configArg.supervisorAddress, + apiKey: configArg.supervisorApiKey, + }); + this.cloudlyConnector = new CloudlyConnector({ + cloudlyUrl: configArg.cloudlyUrl, + joinToken: configArg.joinToken, + nodeToken: configArg.nodeToken, + }); + if (this.cloudlyConnector.isConfigured()) { + this.cloudlyConnectionStatus = 'connecting'; + } + } + + public async init() { + this.state = await this.loadState(); + if (!this.state) { + this.state = { + nodeId: this.config.nodeId || crypto.randomUUID(), + nodeToken: this.config.nodeToken, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await this.saveState(); + } + this.cloudlyConnector.setNodeToken(this.state.nodeToken || this.config.nodeToken); + } + + public async start() { + await this.init(); + await this.syncCloudlyOnce(); + this.heartbeatTimer = setInterval(async () => { + await this.syncCloudlyOnce().catch((errorArg) => { + console.error(`Cloudly heartbeat failed: ${(errorArg as Error).message}`); + }); + }, this.config.heartbeatIntervalMs); + console.log(`BaseRunner started for node ${this.state?.nodeId}`); + } + + public stop() { + if (this.heartbeatTimer !== undefined) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + } + + public async getRuntimeInfo(): Promise { + await this.ensureInitialized(); + const supervisorAvailable = await this.supervisorClient.isAvailable(); + const info: IBaseOsRuntimeInfo = { + runtime: 'baseos', + runtimeLevel: 'app-layer', + nodeId: this.state!.nodeId, + cloudlyUrl: this.config.cloudlyUrl, + cloudlyConnectionStatus: this.cloudlyConnector.isConfigured() + ? this.cloudlyConnectionStatus + : 'not-configured', + supervisorAvailable, + supervisorAddress: this.supervisorClient.address, + checkedAt: Date.now(), + }; + if (supervisorAvailable) { + try { + info.deviceState = await this.supervisorClient.getDeviceState(); + } catch (errorArg) { + console.warn(`Could not read Balena device state: ${(errorArg as Error).message}`); + } + try { + info.stateStatus = await this.supervisorClient.getStateStatus(); + } catch (errorArg) { + console.warn(`Could not read Balena state status: ${(errorArg as Error).message}`); + } + } + return info; + } + + public async requestSelfUpdate(optionsArg: { force?: boolean; cancel?: boolean } = {}) { + if (!(await this.supervisorClient.isAvailable())) { + throw new Error('Balena Supervisor API is not available'); + } + await this.supervisorClient.triggerUpdate(optionsArg); + } + + private async syncCloudlyOnce() { + await this.ensureInitialized(); + if (!this.cloudlyConnector.isConfigured()) { + this.cloudlyConnectionStatus = 'not-configured'; + return; + } + const status = await this.getRuntimeInfo(); + this.cloudlyConnectionStatus = 'connecting'; + if (!this.state!.nodeToken) { + const result = await this.cloudlyConnector.registerNode(status); + if (result.accepted && result.nodeToken) { + this.state!.nodeToken = result.nodeToken; + this.state!.updatedAt = Date.now(); + this.cloudlyConnector.setNodeToken(result.nodeToken); + await this.saveState(); + } else if (!result.accepted) { + throw new Error(result.message || 'Cloudly did not accept node registration'); + } + } else { + const result = await this.cloudlyConnector.sendHeartbeat(status); + if (!result.accepted) { + throw new Error(result.message || 'Cloudly did not accept node heartbeat'); + } + } + this.cloudlyConnectionStatus = 'connected'; + } + + private async ensureInitialized() { + if (!this.state) { + await this.init(); + } + } + + private async loadState(): Promise { + try { + const text = await Deno.readTextFile(this.config.statePath); + return JSON.parse(text) as IBaseRunnerState; + } catch (errorArg) { + if (errorArg instanceof Deno.errors.NotFound) { + return undefined; + } + throw errorArg; + } + } + + private async saveState() { + if (!this.state) { + return; + } + const stateUrl = new URL(`file://${this.config.statePath}`); + const directory = stateUrl.pathname.split('/').slice(0, -1).join('/') || '/'; + await Deno.mkdir(directory, { recursive: true }); + await Deno.writeTextFile(this.config.statePath, `${JSON.stringify(this.state, null, 2)}\n`); + } +} diff --git a/ts/classes.cloudlyconnector.ts b/ts/classes.cloudlyconnector.ts new file mode 100644 index 0000000..2f35331 --- /dev/null +++ b/ts/classes.cloudlyconnector.ts @@ -0,0 +1,94 @@ +import type { + IBaseOsRuntimeInfo, + ICloudlyHeartbeatResult, + ICloudlyRegisterResult, +} from './types.ts'; + +export interface ICloudlyConnectorOptions { + cloudlyUrl?: string; + joinToken?: string; + nodeToken?: string; +} + +export class CloudlyConnector { + private readonly cloudlyUrl?: string; + private readonly joinToken?: string; + private nodeToken?: string; + + constructor(optionsArg: ICloudlyConnectorOptions) { + this.cloudlyUrl = optionsArg.cloudlyUrl; + this.joinToken = optionsArg.joinToken; + this.nodeToken = optionsArg.nodeToken; + } + + public isConfigured() { + return Boolean(this.cloudlyUrl); + } + + public setNodeToken(nodeTokenArg: string | undefined) { + this.nodeToken = nodeTokenArg; + } + + public async registerNode(statusArg: IBaseOsRuntimeInfo): Promise { + if (!this.cloudlyUrl) { + return { + accepted: false, + message: 'Cloudly URL is not configured', + }; + } + if (!this.joinToken && !this.nodeToken) { + return { + accepted: false, + message: 'Neither join token nor node token is configured', + }; + } + return await this.postJson('/baseos/v1/nodes/register', { + joinToken: this.joinToken, + nodeToken: this.nodeToken, + status: statusArg, + }); + } + + public async sendHeartbeat(statusArg: IBaseOsRuntimeInfo): Promise { + if (!this.cloudlyUrl) { + return { + accepted: false, + message: 'Cloudly URL is not configured', + }; + } + if (!this.nodeToken) { + return { + accepted: false, + message: 'Node token is not configured', + }; + } + return await this.postJson('/baseos/v1/nodes/heartbeat', { + nodeToken: this.nodeToken, + status: statusArg, + }); + } + + private async postJson( + pathArg: string, + bodyArg: Record, + ): Promise { + if (!this.cloudlyUrl) { + throw new Error('Cloudly URL is not configured'); + } + const url = new URL( + pathArg, + this.cloudlyUrl.endsWith('/') ? this.cloudlyUrl : `${this.cloudlyUrl}/`, + ); + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(bodyArg), + }); + if (!response.ok) { + throw new Error(`Cloudly request failed: ${pathArg} -> HTTP ${response.status}`); + } + return await response.json() as TResponse; + } +} diff --git a/ts/classes.supervisorclient.ts b/ts/classes.supervisorclient.ts new file mode 100644 index 0000000..938c120 --- /dev/null +++ b/ts/classes.supervisorclient.ts @@ -0,0 +1,103 @@ +import type { IBalenaDeviceState, IBalenaStateStatus } from './types.ts'; + +export interface ISupervisorClientOptions { + address?: string; + apiKey?: string; +} + +export class SupervisorClient { + public readonly address?: string; + private readonly apiKey?: string; + + constructor(optionsArg: ISupervisorClientOptions = {}) { + this.address = optionsArg.address || Deno.env.get('BALENA_SUPERVISOR_ADDRESS') || undefined; + this.apiKey = optionsArg.apiKey || Deno.env.get('BALENA_SUPERVISOR_API_KEY') || undefined; + } + + public isConfigured() { + return Boolean(this.address); + } + + public async isAvailable() { + if (!this.address) { + return false; + } + try { + const response = await fetch(this.buildUrl('/ping', false)); + const text = await response.text(); + return response.ok && text.trim() === 'OK'; + } catch { + return false; + } + } + + public async getDeviceState(): Promise { + return await this.requestJson('/v1/device'); + } + + public async getStateStatus(): Promise { + return await this.requestJson('/v2/state/status'); + } + + public async getSupervisorVersion(): Promise { + const response = await this.requestJson<{ version?: string }>('/v2/version'); + return response.version; + } + + public async triggerUpdate(optionsArg: { force?: boolean; cancel?: boolean } = {}) { + await this.requestEmpty('/v1/update', { + method: 'POST', + body: JSON.stringify({ + force: Boolean(optionsArg.force), + cancel: Boolean(optionsArg.cancel), + }), + headers: { + 'content-type': 'application/json', + }, + }); + } + + public async reboot(optionsArg: { force?: boolean } = {}) { + await this.requestEmpty('/v1/reboot', { + method: 'POST', + body: JSON.stringify({ + force: Boolean(optionsArg.force), + }), + headers: { + 'content-type': 'application/json', + }, + }); + } + + private async requestJson( + pathArg: string, + initArg: RequestInit = {}, + ): Promise { + const response = await fetch(this.buildUrl(pathArg, true), initArg); + if (!response.ok) { + throw new Error(`Supervisor request failed: ${pathArg} -> HTTP ${response.status}`); + } + return await response.json() as TResponse; + } + + private async requestEmpty(pathArg: string, initArg: RequestInit = {}) { + const response = await fetch(this.buildUrl(pathArg, true), initArg); + if (!response.ok) { + throw new Error(`Supervisor request failed: ${pathArg} -> HTTP ${response.status}`); + } + } + + private buildUrl(pathArg: string, includeApiKeyArg: boolean) { + if (!this.address) { + throw new Error('Balena Supervisor address is not configured'); + } + const url = new URL(pathArg, this.address.endsWith('/') ? this.address : `${this.address}/`); + if (includeApiKeyArg) { + if (!this.apiKey) { + throw new Error('Balena Supervisor API key is not configured'); + } + url.searchParams.set('apikey', this.apiKey); + } + return url; + } +} diff --git a/ts/cli.ts b/ts/cli.ts new file mode 100644 index 0000000..fe6d68b --- /dev/null +++ b/ts/cli.ts @@ -0,0 +1,56 @@ +import { BaseRunner } from './classes.baserunner.ts'; + +export async function runCli(argsArg: string[]) { + const command = argsArg[0] || 'start'; + const runner = BaseRunner.fromEnv(); + + if (command === 'start') { + await runner.start(); + await new Promise(() => undefined); + return; + } + + if (command === 'status') { + await printJson(await runner.getRuntimeInfo()); + return; + } + + if (command === 'check-supervisor') { + await printJson({ + configured: runner.supervisorClient.isConfigured(), + available: await runner.supervisorClient.isAvailable(), + address: runner.supervisorClient.address, + }); + return; + } + + if (command === 'request-update') { + await runner.requestSelfUpdate({ force: argsArg.includes('--force') }); + await printJson({ ok: true }); + return; + } + + if (command === 'help' || command === '--help' || command === '-h') { + printHelp(); + return; + } + + console.error(`Unknown command: ${command}`); + printHelp(); + Deno.exit(1); +} + +async function printJson(dataArg: unknown) { + await Deno.stdout.write(new TextEncoder().encode(`${JSON.stringify(dataArg, null, 2)}\n`)); +} + +function printHelp() { + console.log(`BaseRunner commands: + + start Start BaseRunner and keep the process alive + status Print runtime, supervisor, and Cloudly connection status + check-supervisor Check whether the Balena Supervisor API is available + request-update Ask the Balena Supervisor to check/apply app release updates + help Show this help text +`); +} diff --git a/ts/index.ts b/ts/index.ts new file mode 100644 index 0000000..08f140f --- /dev/null +++ b/ts/index.ts @@ -0,0 +1,4 @@ +export * from './types.ts'; +export * from './classes.supervisorclient.ts'; +export * from './classes.cloudlyconnector.ts'; +export * from './classes.baserunner.ts'; diff --git a/ts/types.ts b/ts/types.ts new file mode 100644 index 0000000..2bca9a5 --- /dev/null +++ b/ts/types.ts @@ -0,0 +1,82 @@ +export type TBaseOsRuntimeLevel = 'app-layer' | 'host-os' | 'target-state'; + +export type TCloudlyConnectionStatus = + | 'not-configured' + | 'connecting' + | 'connected' + | 'failed'; + +export interface IBaseRunnerConfig { + cloudlyUrl?: string; + joinToken?: string; + nodeToken?: string; + nodeId?: string; + statePath: string; + heartbeatIntervalMs: number; + supervisorAddress?: string; + supervisorApiKey?: string; +} + +export interface IBaseRunnerState { + nodeId: string; + nodeToken?: string; + createdAt: number; + updatedAt: number; +} + +export interface IBalenaDeviceState { + api_port?: number; + ip_address?: string; + mac_address?: string; + commit?: string; + status?: string; + os_version?: string; + supervisor_version?: string; + update_pending?: boolean; + update_downloaded?: boolean; + update_failed?: boolean; + download_progress?: number | null; + [key: string]: unknown; +} + +export interface IBalenaStateStatus { + status?: string; + appState?: string; + overallDownloadProgress?: number | null; + release?: string; + containers?: Array>; + images?: Array>; + [key: string]: unknown; +} + +export interface IBaseOsRuntimeInfo { + runtime: 'baseos'; + runtimeLevel: TBaseOsRuntimeLevel; + nodeId: string; + cloudlyUrl?: string; + cloudlyConnectionStatus: TCloudlyConnectionStatus; + supervisorAvailable: boolean; + supervisorAddress?: string; + deviceState?: IBalenaDeviceState; + stateStatus?: IBalenaStateStatus; + checkedAt: number; +} + +export interface IBaseOsDesiredState { + release?: string; + targetState?: Record; + updatedAt?: number; +} + +export interface ICloudlyRegisterResult { + nodeId?: string; + nodeToken?: string; + accepted: boolean; + message?: string; +} + +export interface ICloudlyHeartbeatResult { + accepted: boolean; + message?: string; + desiredState?: IBaseOsDesiredState; +}