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
+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;
}