diff --git a/deno.json b/deno.json index 406b653..1470073 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@serve.zone/baseos", - "version": "0.1.0", + "version": "0.2.0", "exports": "./mod.ts", "tasks": { "start": "deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts start", diff --git a/docker-compose.yml b/docker-compose.yml index 5ba81ff..41e1332 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: io.balena.features.supervisor-api: '1' environment: BASEOS_STATE_PATH: /data/baseos/state.json + BASEOS_PRELOAD_TARGET_STATE_PATH: /data/baseos/preload-target-state.json BASEOS_HEARTBEAT_INTERVAL_MS: '60000' volumes: - baseos-data:/data/baseos diff --git a/package.json b/package.json index 36b98dd..24f8f97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@serve.zone/baseos", - "version": "0.1.0", + "version": "0.2.0", "private": false, "description": "Cloudly-enrolled BaseOS runtime layer for balenaOS-derived devices.", "type": "module", diff --git a/ts/classes.baserunner.ts b/ts/classes.baserunner.ts index d287ac5..4f5bf20 100644 --- a/ts/classes.baserunner.ts +++ b/ts/classes.baserunner.ts @@ -2,6 +2,7 @@ import { CloudlyConnector } from './classes.cloudlyconnector.ts'; import { SupervisorClient } from './classes.supervisorclient.ts'; import type { IBaseOsRuntimeInfo, + IBaseOsDesiredState, IBaseRunnerConfig, IBaseRunnerState, TCloudlyConnectionStatus, @@ -28,6 +29,7 @@ export class BaseRunner { 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, + preloadTargetStatePath: Deno.env.get('BASEOS_PRELOAD_TARGET_STATE_PATH') || undefined, }); } @@ -118,6 +120,7 @@ export class BaseRunner { private async syncCloudlyOnce() { await this.ensureInitialized(); + await this.applyPreloadTargetStateIfPresent(); if (!this.cloudlyConnector.isConfigured()) { this.cloudlyConnectionStatus = 'not-configured'; return; @@ -134,15 +137,93 @@ export class BaseRunner { } else if (!result.accepted) { throw new Error(result.message || 'Cloudly did not accept node registration'); } + await this.applyDesiredState(result.desiredState); } else { const result = await this.cloudlyConnector.sendHeartbeat(status); if (!result.accepted) { throw new Error(result.message || 'Cloudly did not accept node heartbeat'); } + await this.applyDesiredState(result.desiredState); } 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); + 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, 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) { + 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 { + return Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg); + } + private async ensureInitialized() { if (!this.state) { await this.init(); diff --git a/ts/classes.supervisorclient.ts b/ts/classes.supervisorclient.ts index 938c120..a412d7e 100644 --- a/ts/classes.supervisorclient.ts +++ b/ts/classes.supervisorclient.ts @@ -39,6 +39,29 @@ export class SupervisorClient { return await this.requestJson('/v2/state/status'); } + public async getLocalTargetState(): Promise> { + const response = await this.requestJson<{ state?: Record }>( + '/v2/local/target-state', + {}, + false, + ); + return response.state || {}; + } + + public async setLocalTargetState(targetStateArg: Record) { + 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 { const response = await this.requestJson<{ version?: string }>('/v2/version'); return response.version; @@ -72,8 +95,9 @@ export class SupervisorClient { private async requestJson( pathArg: string, initArg: RequestInit = {}, + includeApiKeyArg = true, ): Promise { - const response = await fetch(this.buildUrl(pathArg, true), initArg); + const response = await fetch(this.buildUrl(pathArg, includeApiKeyArg), initArg); if (!response.ok) { throw new Error(`Supervisor request failed: ${pathArg} -> HTTP ${response.status}`); } diff --git a/ts/types.ts b/ts/types.ts index 2bca9a5..482a235 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -15,11 +15,16 @@ export interface IBaseRunnerConfig { heartbeatIntervalMs: number; supervisorAddress?: string; supervisorApiKey?: string; + preloadTargetStatePath?: string; } export interface IBaseRunnerState { nodeId: string; nodeToken?: string; + lastTargetStateHash?: string; + lastTargetStateAppliedAt?: number; + lastRelease?: string; + lastReleaseUpdateTriggeredAt?: number; createdAt: number; updatedAt: number; } @@ -73,6 +78,7 @@ export interface ICloudlyRegisterResult { nodeToken?: string; accepted: boolean; message?: string; + desiredState?: IBaseOsDesiredState; } export interface ICloudlyHeartbeatResult {