feat: scaffold baseos runtime

This commit is contained in:
2026-05-07 15:53:15 +00:00
commit ce41f41169
14 changed files with 897 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.env
.nogit/
.DS_Store
node_modules/
dist/
dist_*/
+10
View File
@@ -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"]
+37
View File
@@ -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"
]
}
}
}
+16
View File
@@ -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
+21
View File
@@ -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.
+8
View File
@@ -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);
}
+32
View File
@@ -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"
}
}
+255
View File
@@ -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/<device-type>/<version>/manifest.json
https://updates.serve.zone/baseos/<device-type>/<version>/checksums.txt
https://updates.serve.zone/baseos/<device-type>/<version>/license-bundle.tar.gz
registry.serve.zone/baseos/hostapp/<device-type>:<version>
registry.serve.zone/baseos/baserunner:<version>
```
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.
+173
View File
@@ -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<IBaseOsRuntimeInfo> {
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<IBaseRunnerState | undefined> {
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`);
}
}
+94
View File
@@ -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<ICloudlyRegisterResult> {
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<ICloudlyRegisterResult>('/baseos/v1/nodes/register', {
joinToken: this.joinToken,
nodeToken: this.nodeToken,
status: statusArg,
});
}
public async sendHeartbeat(statusArg: IBaseOsRuntimeInfo): Promise<ICloudlyHeartbeatResult> {
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<ICloudlyHeartbeatResult>('/baseos/v1/nodes/heartbeat', {
nodeToken: this.nodeToken,
status: statusArg,
});
}
private async postJson<TResponse>(
pathArg: string,
bodyArg: Record<string, unknown>,
): Promise<TResponse> {
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;
}
}
+103
View File
@@ -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<IBalenaDeviceState> {
return await this.requestJson<IBalenaDeviceState>('/v1/device');
}
public async getStateStatus(): Promise<IBalenaStateStatus> {
return await this.requestJson<IBalenaStateStatus>('/v2/state/status');
}
public async getSupervisorVersion(): Promise<string | undefined> {
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<TResponse>(
pathArg: string,
initArg: RequestInit = {},
): Promise<TResponse> {
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;
}
}
+56
View File
@@ -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
`);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './types.ts';
export * from './classes.supervisorclient.ts';
export * from './classes.cloudlyconnector.ts';
export * from './classes.baserunner.ts';
+82
View File
@@ -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<Record<string, unknown>>;
images?: Array<Record<string, unknown>>;
[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<string, unknown>;
updatedAt?: number;
}
export interface ICloudlyRegisterResult {
nodeId?: string;
nodeToken?: string;
accepted: boolean;
message?: string;
}
export interface ICloudlyHeartbeatResult {
accepted: boolean;
message?: string;
desiredState?: IBaseOsDesiredState;
}