feat: apply baseos target state

This commit is contained in:
2026-05-07 20:33:14 +00:00
parent e49591c9c8
commit 27a5f6ffd8
6 changed files with 115 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/baseos", "name": "@serve.zone/baseos",
"version": "0.1.0", "version": "0.2.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"tasks": { "tasks": {
"start": "deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts start", "start": "deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts start",
+1
View File
@@ -11,6 +11,7 @@ services:
io.balena.features.supervisor-api: '1' io.balena.features.supervisor-api: '1'
environment: environment:
BASEOS_STATE_PATH: /data/baseos/state.json BASEOS_STATE_PATH: /data/baseos/state.json
BASEOS_PRELOAD_TARGET_STATE_PATH: /data/baseos/preload-target-state.json
BASEOS_HEARTBEAT_INTERVAL_MS: '60000' BASEOS_HEARTBEAT_INTERVAL_MS: '60000'
volumes: volumes:
- baseos-data:/data/baseos - baseos-data:/data/baseos
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/baseos", "name": "@serve.zone/baseos",
"version": "0.1.0", "version": "0.2.0",
"private": false, "private": false,
"description": "Cloudly-enrolled BaseOS runtime layer for balenaOS-derived devices.", "description": "Cloudly-enrolled BaseOS runtime layer for balenaOS-derived devices.",
"type": "module", "type": "module",
+81
View File
@@ -2,6 +2,7 @@ import { CloudlyConnector } from './classes.cloudlyconnector.ts';
import { SupervisorClient } from './classes.supervisorclient.ts'; import { SupervisorClient } from './classes.supervisorclient.ts';
import type { import type {
IBaseOsRuntimeInfo, IBaseOsRuntimeInfo,
IBaseOsDesiredState,
IBaseRunnerConfig, IBaseRunnerConfig,
IBaseRunnerState, IBaseRunnerState,
TCloudlyConnectionStatus, TCloudlyConnectionStatus,
@@ -28,6 +29,7 @@ export class BaseRunner {
heartbeatIntervalMs: Number(Deno.env.get('BASEOS_HEARTBEAT_INTERVAL_MS') || '60000'), heartbeatIntervalMs: Number(Deno.env.get('BASEOS_HEARTBEAT_INTERVAL_MS') || '60000'),
supervisorAddress: Deno.env.get('BALENA_SUPERVISOR_ADDRESS') || undefined, supervisorAddress: Deno.env.get('BALENA_SUPERVISOR_ADDRESS') || undefined,
supervisorApiKey: Deno.env.get('BALENA_SUPERVISOR_API_KEY') || undefined, supervisorApiKey: Deno.env.get('BALENA_SUPERVISOR_API_KEY') || undefined,
preloadTargetStatePath: Deno.env.get('BASEOS_PRELOAD_TARGET_STATE_PATH') || undefined,
}); });
} }
@@ -118,6 +120,7 @@ export class BaseRunner {
private async syncCloudlyOnce() { private async syncCloudlyOnce() {
await this.ensureInitialized(); await this.ensureInitialized();
await this.applyPreloadTargetStateIfPresent();
if (!this.cloudlyConnector.isConfigured()) { if (!this.cloudlyConnector.isConfigured()) {
this.cloudlyConnectionStatus = 'not-configured'; this.cloudlyConnectionStatus = 'not-configured';
return; return;
@@ -134,15 +137,93 @@ export class BaseRunner {
} else if (!result.accepted) { } else if (!result.accepted) {
throw new Error(result.message || 'Cloudly did not accept node registration'); throw new Error(result.message || 'Cloudly did not accept node registration');
} }
await this.applyDesiredState(result.desiredState);
} else { } else {
const result = await this.cloudlyConnector.sendHeartbeat(status); const result = await this.cloudlyConnector.sendHeartbeat(status);
if (!result.accepted) { if (!result.accepted) {
throw new Error(result.message || 'Cloudly did not accept node heartbeat'); throw new Error(result.message || 'Cloudly did not accept node heartbeat');
} }
await this.applyDesiredState(result.desiredState);
} }
this.cloudlyConnectionStatus = 'connected'; this.cloudlyConnectionStatus = 'connected';
} }
private async applyPreloadTargetStateIfPresent() {
if (!this.config.preloadTargetStatePath) {
return;
}
let preloadText: string;
try {
preloadText = await Deno.readTextFile(this.config.preloadTargetStatePath);
} catch (errorArg) {
if (errorArg instanceof Deno.errors.NotFound) {
return;
}
throw errorArg;
}
const targetState = this.normalizeTargetState(JSON.parse(preloadText) as Record<string, unknown>);
await this.applyTargetState(targetState, 'preload target state');
}
private async applyDesiredState(desiredStateArg?: IBaseOsDesiredState) {
if (!desiredStateArg) {
return;
}
if (desiredStateArg.targetState) {
await this.applyTargetState(desiredStateArg.targetState, 'Cloudly desired target state');
}
if (desiredStateArg.release && desiredStateArg.release !== this.state!.lastRelease) {
if (!(await this.supervisorClient.isAvailable())) {
console.warn('Skipping desired release update because Balena Supervisor API is unavailable');
return;
}
await this.supervisorClient.triggerUpdate({ force: true });
this.state!.lastRelease = desiredStateArg.release;
this.state!.lastReleaseUpdateTriggeredAt = Date.now();
this.state!.updatedAt = Date.now();
await this.saveState();
}
}
private async applyTargetState(targetStateArg: Record<string, unknown>, sourceArg: string) {
const targetStateHash = await this.hashJson(targetStateArg);
if (this.state!.lastTargetStateHash === targetStateHash) {
return;
}
if (!(await this.supervisorClient.isAvailable())) {
console.warn(`Skipping ${sourceArg} because Balena Supervisor API is unavailable`);
return;
}
await this.supervisorClient.setLocalTargetState(targetStateArg);
this.state!.lastTargetStateHash = targetStateHash;
this.state!.lastTargetStateAppliedAt = Date.now();
this.state!.updatedAt = Date.now();
await this.saveState();
console.log(`Applied ${sourceArg}`);
}
private normalizeTargetState(targetStateArg: Record<string, unknown>) {
if (this.isRecord(targetStateArg.local)) {
return targetStateArg;
}
if (this.isRecord(targetStateArg.targetState)) {
return targetStateArg.targetState;
}
throw new Error('BaseOS target state must contain a local target state object');
}
private async hashJson(valueArg: unknown) {
const encoded = new TextEncoder().encode(JSON.stringify(valueArg));
const digest = await crypto.subtle.digest('SHA-256', encoded);
return Array.from(new Uint8Array(digest))
.map((byteArg) => byteArg.toString(16).padStart(2, '0'))
.join('');
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg);
}
private async ensureInitialized() { private async ensureInitialized() {
if (!this.state) { if (!this.state) {
await this.init(); await this.init();
+25 -1
View File
@@ -39,6 +39,29 @@ export class SupervisorClient {
return await this.requestJson<IBalenaStateStatus>('/v2/state/status'); return await this.requestJson<IBalenaStateStatus>('/v2/state/status');
} }
public async getLocalTargetState(): Promise<Record<string, unknown>> {
const response = await this.requestJson<{ state?: Record<string, unknown> }>(
'/v2/local/target-state',
{},
false,
);
return response.state || {};
}
public async setLocalTargetState(targetStateArg: Record<string, unknown>) {
await this.requestJson<{ status?: string; message?: string }>(
'/v2/local/target-state',
{
method: 'POST',
body: JSON.stringify(targetStateArg),
headers: {
'content-type': 'application/json',
},
},
false,
);
}
public async getSupervisorVersion(): Promise<string | undefined> { public async getSupervisorVersion(): Promise<string | undefined> {
const response = await this.requestJson<{ version?: string }>('/v2/version'); const response = await this.requestJson<{ version?: string }>('/v2/version');
return response.version; return response.version;
@@ -72,8 +95,9 @@ export class SupervisorClient {
private async requestJson<TResponse>( private async requestJson<TResponse>(
pathArg: string, pathArg: string,
initArg: RequestInit = {}, initArg: RequestInit = {},
includeApiKeyArg = true,
): Promise<TResponse> { ): Promise<TResponse> {
const response = await fetch(this.buildUrl(pathArg, true), initArg); const response = await fetch(this.buildUrl(pathArg, includeApiKeyArg), initArg);
if (!response.ok) { if (!response.ok) {
throw new Error(`Supervisor request failed: ${pathArg} -> HTTP ${response.status}`); throw new Error(`Supervisor request failed: ${pathArg} -> HTTP ${response.status}`);
} }
+6
View File
@@ -15,11 +15,16 @@ export interface IBaseRunnerConfig {
heartbeatIntervalMs: number; heartbeatIntervalMs: number;
supervisorAddress?: string; supervisorAddress?: string;
supervisorApiKey?: string; supervisorApiKey?: string;
preloadTargetStatePath?: string;
} }
export interface IBaseRunnerState { export interface IBaseRunnerState {
nodeId: string; nodeId: string;
nodeToken?: string; nodeToken?: string;
lastTargetStateHash?: string;
lastTargetStateAppliedAt?: number;
lastRelease?: string;
lastReleaseUpdateTriggeredAt?: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
@@ -73,6 +78,7 @@ export interface ICloudlyRegisterResult {
nodeToken?: string; nodeToken?: string;
accepted: boolean; accepted: boolean;
message?: string; message?: string;
desiredState?: IBaseOsDesiredState;
} }
export interface ICloudlyHeartbeatResult { export interface ICloudlyHeartbeatResult {