feat: apply baseos target state
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user