Files
baseos/ts/classes.baserunner.ts
T
2026-05-07 15:53:15 +00:00

174 lines
5.8 KiB
TypeScript

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`);
}
}