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