feat: scaffold baseos runtime
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types.ts';
|
||||
export * from './classes.supervisorclient.ts';
|
||||
export * from './classes.cloudlyconnector.ts';
|
||||
export * from './classes.baserunner.ts';
|
||||
+82
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user