feat: scaffold baseos runtime
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.nogit/
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
FROM denoland/deno:alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY deno.json mod.ts ./
|
||||
COPY ts ./ts
|
||||
|
||||
RUN deno cache mod.ts
|
||||
|
||||
CMD ["run", "--allow-env", "--allow-net", "--allow-read=/data/baseos,.", "--allow-write=/data/baseos", "mod.ts", "start"]
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@serve.zone/baseos",
|
||||
"version": "0.1.0",
|
||||
"exports": "./mod.ts",
|
||||
"tasks": {
|
||||
"start": "deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts start",
|
||||
"dev": "deno run --allow-all mod.ts start",
|
||||
"check": "deno check mod.ts",
|
||||
"test": "deno check mod.ts",
|
||||
"fmt": "deno fmt",
|
||||
"lint": "deno lint"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"deno.window",
|
||||
"deno.ns"
|
||||
],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"semiColons": true,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": [
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
version: '2.4'
|
||||
|
||||
volumes:
|
||||
baseos-data:
|
||||
|
||||
services:
|
||||
baserunner:
|
||||
build: .
|
||||
restart: always
|
||||
labels:
|
||||
io.balena.features.supervisor-api: '1'
|
||||
environment:
|
||||
BASEOS_STATE_PATH: /data/baseos/state.json
|
||||
BASEOS_HEARTBEAT_INTERVAL_MS: '60000'
|
||||
volumes:
|
||||
- baseos-data:/data/baseos
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lossless GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './ts/index.ts';
|
||||
export { runCli } from './ts/cli.ts';
|
||||
|
||||
import { runCli } from './ts/cli.ts';
|
||||
|
||||
if (import.meta.main) {
|
||||
await runCli(Deno.args);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@serve.zone/baseos",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"description": "Cloudly-enrolled BaseOS runtime layer for balenaOS-derived devices.",
|
||||
"type": "module",
|
||||
"main": "mod.ts",
|
||||
"scripts": {
|
||||
"start": "deno task start",
|
||||
"dev": "deno task dev",
|
||||
"test": "deno task test",
|
||||
"build": "deno task check",
|
||||
"check": "deno task check",
|
||||
"fmt": "deno task fmt",
|
||||
"lint": "deno task lint"
|
||||
},
|
||||
"keywords": [
|
||||
"serve.zone",
|
||||
"baseos",
|
||||
"baserunner",
|
||||
"cloudly",
|
||||
"balenaos",
|
||||
"iot",
|
||||
"edge"
|
||||
],
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@code.foss.global:29419/serve.zone/baseos.git"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
# @serve.zone/baseos
|
||||
|
||||
BaseOS is the serve.zone runtime layer for devices that should run on balenaOS-derived host systems but enroll only in Cloudly.
|
||||
|
||||
The first implementation target is intentionally small: a `baserunner` container runs on the device, talks to the local Balena Supervisor API when available, reports status to Cloudly, and prepares the update path for Onebox and other BaseOS services.
|
||||
|
||||
## Goals
|
||||
|
||||
- Use balenaOS as the proven host foundation for embedded and edge devices.
|
||||
- Avoid BalenaCloud enrollment for serve.zone devices.
|
||||
- Enroll devices directly in Cloudly with a Cloudly join token.
|
||||
- Let Cloudly track BaseOS nodes, desired releases, rollout state, and update health.
|
||||
- Let Onebox detect when it is running on BaseOS and delegate self-updates to BaseRunner.
|
||||
- Keep the first version useful without pretending host OS updates are solved.
|
||||
|
||||
## Non-Goals For V1
|
||||
|
||||
- No BalenaCloud fleet enrollment.
|
||||
- No openBalena dependency.
|
||||
- No full Balena API replacement.
|
||||
- No remote host OS updates until BaseOS has its own signed artifact channel.
|
||||
- No silent use of Balena trademarks as serve.zone branding.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
```text
|
||||
balenaOS-derived host
|
||||
Balena Supervisor
|
||||
baserunner container
|
||||
- Cloudly enrollment and heartbeat
|
||||
- local Supervisor API access
|
||||
- local BaseOS runtime API for Onebox, later
|
||||
|
||||
onebox container, later
|
||||
- detects BaseOS through BaseRunner
|
||||
- delegates self-update to BaseRunner
|
||||
- runs Onebox-managed apps in its own runtime model
|
||||
```
|
||||
|
||||
The current scaffold implements `baserunner`; the matching Cloudly registration and heartbeat handlers live in the `cloudly` repo. Onebox integration is still a later focused change.
|
||||
|
||||
## Levels
|
||||
|
||||
### Level 1: App-Layer Updates
|
||||
|
||||
Level 1 is the first production target.
|
||||
|
||||
BaseOS devices boot a balenaOS-derived image with `baserunner` preloaded. `baserunner` enrolls the node in Cloudly, reports Balena Supervisor/device state, and can ask the local Supervisor to check/apply application release updates.
|
||||
|
||||
Cloudly responsibilities:
|
||||
|
||||
- Store BaseOS node records.
|
||||
- Issue and validate join tokens.
|
||||
- Accept BaseRunner heartbeats.
|
||||
- Track current BaseRunner/Onebox release metadata.
|
||||
- Publish desired app-layer release state.
|
||||
|
||||
BaseRunner responsibilities:
|
||||
|
||||
- Persist a node ID and node token in a named volume.
|
||||
- Report supervisor availability, OS version, supervisor version, release hash, update state, and IP information.
|
||||
- Receive desired app-layer state from Cloudly heartbeats.
|
||||
- Call Supervisor API endpoints such as `/ping`, `/v1/device`, `/v2/state/status`, and `/v1/update`.
|
||||
|
||||
Onebox responsibilities:
|
||||
|
||||
- Detect BaseOS via a local BaseRunner API and explicit environment such as `SERVEZONE_RUNTIME=baseos`.
|
||||
- Disable direct self-replacement when running on BaseOS.
|
||||
- Ask BaseRunner to perform BaseOS-safe app-layer updates.
|
||||
|
||||
Level 1 does not update the host OS remotely. Host OS updates are manual or deferred.
|
||||
|
||||
### Level 2: Cloudly-Managed Host OS Releases
|
||||
|
||||
Level 2 adds a Cloudly-owned BaseOS update channel.
|
||||
|
||||
Cloudly or a central serve.zone update service hosts signed BaseOS host OS artifacts. Devices are still enrolled only in Cloudly.
|
||||
|
||||
Artifact layout target:
|
||||
|
||||
```text
|
||||
https://updates.serve.zone/baseos/<device-type>/<version>/manifest.json
|
||||
https://updates.serve.zone/baseos/<device-type>/<version>/checksums.txt
|
||||
https://updates.serve.zone/baseos/<device-type>/<version>/license-bundle.tar.gz
|
||||
registry.serve.zone/baseos/hostapp/<device-type>:<version>
|
||||
registry.serve.zone/baseos/baserunner:<version>
|
||||
```
|
||||
|
||||
Cloudly responsibilities:
|
||||
|
||||
- Maintain approved BaseOS channels.
|
||||
- Pin desired host OS versions per node or fleet.
|
||||
- Verify release signatures before rollout.
|
||||
- Track staged, applying, succeeded, failed, and rollback-needed states.
|
||||
|
||||
BaseRunner responsibilities:
|
||||
|
||||
- Verify release manifests and signatures.
|
||||
- Hold update locks during critical work.
|
||||
- Coordinate with the host update mechanism where available.
|
||||
- Report detailed progress and failure logs.
|
||||
|
||||
This level requires careful work against balenaOS host update internals and licensing obligations for redistributed images.
|
||||
|
||||
### Level 3: Cloudly As A Balena-Compatible Backend
|
||||
|
||||
Level 3 is the largest option and should only happen if Level 2 is insufficient.
|
||||
|
||||
Cloudly would implement enough target-state and artifact APIs for a balenaOS-derived Supervisor to treat Cloudly as its backend. The device `config.json` would point to Cloudly-controlled endpoints instead of BalenaCloud endpoints.
|
||||
|
||||
Target endpoints would include equivalents for:
|
||||
|
||||
- API target state.
|
||||
- Registry pulls.
|
||||
- Delta service, if implemented.
|
||||
- VPN or remote access, if required.
|
||||
- Device diagnostics and logs, if required.
|
||||
|
||||
This level is effectively a focused openBalena-like backend inside serve.zone. It is not a first milestone.
|
||||
|
||||
## Cloudly-Only Enrollment
|
||||
|
||||
BaseOS devices should not need BalenaCloud identity.
|
||||
|
||||
Enrollment flow:
|
||||
|
||||
```text
|
||||
1. User creates a BaseOS join token in Cloudly.
|
||||
2. BaseOS image is flashed with BASEOS_CLOUDLY_URL and BASEOS_JOIN_TOKEN.
|
||||
3. BaseRunner boots and registers against Cloudly.
|
||||
4. Cloudly returns a node token.
|
||||
5. BaseRunner stores the node token in /data/baseos/state.json.
|
||||
6. Future heartbeats use the node token.
|
||||
```
|
||||
|
||||
Current provisional HTTP endpoints used by this scaffold:
|
||||
|
||||
- `POST /baseos/v1/nodes/register`
|
||||
- `POST /baseos/v1/nodes/heartbeat`
|
||||
|
||||
Cloudly implements these paths through its BaseOS manager. A deployed Cloudly instance must also have `baseosJoinToken` configured before new nodes can enroll.
|
||||
|
||||
## Supervisor API Usage
|
||||
|
||||
BaseRunner uses Balena Supervisor APIs only when `BALENA_SUPERVISOR_ADDRESS` and `BALENA_SUPERVISOR_API_KEY` are provided by the `io.balena.features.supervisor-api` service label.
|
||||
|
||||
Current calls:
|
||||
|
||||
- `GET /ping`
|
||||
- `GET /v1/device?apikey=...`
|
||||
- `GET /v2/state/status?apikey=...`
|
||||
- `GET /v2/version?apikey=...`
|
||||
- `POST /v1/update?apikey=...`
|
||||
- `POST /v1/reboot?apikey=...`
|
||||
|
||||
The `docker-compose.yml` enables the supervisor API label for `baserunner`.
|
||||
|
||||
## Onebox Integration Plan
|
||||
|
||||
Onebox should detect BaseOS through explicit runtime signals, not by guessing from Balena environment variables alone.
|
||||
|
||||
Detection signals:
|
||||
|
||||
- `SERVEZONE_RUNTIME=baseos`
|
||||
- `BASEOS_AGENT_URL` or equivalent local BaseRunner API URL
|
||||
- successful BaseRunner handshake
|
||||
|
||||
Onebox behavior on BaseOS:
|
||||
|
||||
- Show BaseOS runtime in system status.
|
||||
- Route self-update requests to BaseRunner.
|
||||
- Avoid replacing its own container directly.
|
||||
- Use update locks around backup/restore and migration operations.
|
||||
|
||||
## Security
|
||||
|
||||
- Join tokens must be one-time or short-lived.
|
||||
- Node tokens must be long-lived machine tokens scoped to one BaseOS node.
|
||||
- Host OS release manifests must be signed before Level 2.
|
||||
- BaseRunner must reject unsigned or untrusted desired host OS updates.
|
||||
- Supervisor API access is powerful; keep BaseRunner's local API private and minimal.
|
||||
- Never log join tokens, node tokens, or Supervisor API keys.
|
||||
|
||||
## Licensing And Branding
|
||||
|
||||
balenaOS source components are open source, but trademarks are separate from copyright licenses.
|
||||
|
||||
BaseOS must:
|
||||
|
||||
- Preserve required upstream notices and license bundles.
|
||||
- Provide source/license compliance for redistributed GPL/LGPL/Yocto components.
|
||||
- Avoid presenting BaseOS as official Balena software.
|
||||
- Replace public-facing branding when redistributing images.
|
||||
- Use Balena names only where needed to describe compatibility or documented APIs.
|
||||
|
||||
## Implementation Milestones
|
||||
|
||||
### Milestone 1: BaseRunner Scaffold
|
||||
|
||||
- Deno CLI and daemon structure.
|
||||
- Supervisor API client.
|
||||
- Persistent node state.
|
||||
- Provisional Cloudly register/heartbeat client.
|
||||
- Balena compose service definition.
|
||||
|
||||
### Milestone 2: Shared Interfaces
|
||||
|
||||
- Add BaseOS node, runtime status, desired state, heartbeat, and registration contracts to `@serve.zone/interfaces`.
|
||||
- Add machine role or scoped token model for BaseOS nodes.
|
||||
|
||||
### Milestone 3: Cloudly BaseOS Manager
|
||||
|
||||
- Join token validation through `baseosJoinToken`.
|
||||
- Node registration.
|
||||
- Heartbeat ingestion.
|
||||
- Desired state storage.
|
||||
- Admin/UI status views.
|
||||
|
||||
### Milestone 4: Onebox Runtime Detection
|
||||
|
||||
- Add BaseOS runtime manager to Onebox.
|
||||
- Add status display.
|
||||
- Route Onebox self-update through BaseRunner when running on BaseOS.
|
||||
|
||||
### Milestone 5: App Release Updates
|
||||
|
||||
- Build and publish BaseRunner release images.
|
||||
- Add Onebox service image/release metadata.
|
||||
- Let Cloudly request app-layer updates.
|
||||
- Report update status and failures.
|
||||
|
||||
### Milestone 6: Host OS Release Channel
|
||||
|
||||
- Build BaseOS host OS images.
|
||||
- Publish signed manifests and artifacts.
|
||||
- Add Cloudly release approval and rollout controls.
|
||||
- Add BaseRunner host update execution and reporting.
|
||||
|
||||
## Current Commands
|
||||
|
||||
```bash
|
||||
deno task check
|
||||
deno task start
|
||||
deno task dev
|
||||
deno task fmt
|
||||
deno task lint
|
||||
```
|
||||
|
||||
Run a one-shot status check:
|
||||
|
||||
```bash
|
||||
deno run --allow-env --allow-net --allow-read=/data/baseos,. --allow-write=/data/baseos mod.ts status
|
||||
```
|
||||
|
||||
Inside balenaOS, the `baserunner` service should be started from `docker-compose.yml` so the Supervisor API environment variables are injected.
|
||||
@@ -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