@mikrojs/native 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CMakeLists.txt +62 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
- package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
- package/dist/runtime/result/native-result.node-shim.js +41 -0
- package/dist/runtime/result/native-result.node-shim.js.map +1 -0
- package/dist/runtime/result/types.d.ts +55 -0
- package/dist/runtime/result/types.d.ts.map +1 -0
- package/dist/runtime/result/types.js +2 -0
- package/dist/runtime/result/types.js.map +1 -0
- package/dist/runtime/schema/core.d.ts +115 -0
- package/dist/runtime/schema/core.d.ts.map +1 -0
- package/dist/runtime/schema/core.js +259 -0
- package/dist/runtime/schema/core.js.map +1 -0
- package/dist/runtime/schema/shared.d.ts +54 -0
- package/dist/runtime/schema/shared.d.ts.map +1 -0
- package/dist/runtime/schema/shared.js +489 -0
- package/dist/runtime/schema/shared.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/include/mikrojs/cbor_helpers.h +20 -0
- package/include/mikrojs/mem.h +11 -0
- package/include/mikrojs/mikrojs.h +2 -1
- package/include/mikrojs/ota_client.h +342 -0
- package/include/mikrojs/ota_config.h +100 -0
- package/include/mikrojs/ota_env.h +192 -0
- package/include/mikrojs/ota_js_hooks.h +71 -0
- package/include/mikrojs/ota_policy.h +131 -0
- package/include/mikrojs/ota_slots.h +47 -0
- package/include/mikrojs/sys_codec.h +61 -0
- package/package.json +7 -5
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/internal.d.ts +22 -16
- package/runtime/kv/shared.ts +11 -5
- package/runtime/kv/types.ts +4 -4
- package/runtime/ota/client.ts +12 -51
- package/runtime/ota/config.ts +18 -0
- package/runtime/ota/ota.ts +28 -70
- package/runtime/ota/types.ts +220 -2
- package/runtime/schema/core.ts +539 -0
- package/runtime/schema/schema.ts +36 -314
- package/runtime/schema/shared.ts +494 -0
- package/runtime/schema/types.ts +84 -12
- package/scripts/bundle-runtime.js +33 -0
- package/scripts/gen-checkin-fixtures.js +323 -0
- package/src/builtins.cpp +7 -8
- package/src/fs.cpp +3 -0
- package/src/mem.cpp +38 -0
- package/src/mik_abort.cpp +8 -1
- package/src/mik_cbor.cpp +43 -5
- package/src/mik_inspect.cpp +128 -22
- package/src/mik_ota_client.cpp +1230 -0
- package/src/mik_ota_config.cpp +296 -0
- package/src/mik_ota_js_hooks.cpp +190 -0
- package/src/mik_ota_policy.cpp +419 -0
- package/src/mik_ota_slots.cpp +249 -0
- package/src/mik_repl.cpp +9 -3
- package/src/mik_result.cpp +3 -1
- package/src/mik_sys_codec.cpp +167 -0
- package/src/mikrojs.cpp +15 -0
- package/src/modules.cpp +32 -13
- package/runtime/ota/client-impl.ts +0 -590
- package/runtime/ota/policy.ts +0 -299
|
@@ -1,590 +0,0 @@
|
|
|
1
|
-
// Internal: the OTA client state machine behind `mikro/ota/client`. Bundled
|
|
2
|
-
// into ota/client.js; not exposed as its own subpath. Exists as a seam so host
|
|
3
|
-
// tests can drive the whole client against fakes — no hardware, no registry,
|
|
4
|
-
// no real restart.
|
|
5
|
-
|
|
6
|
-
import type {Request, RequestError} from 'mikro/http/helpers'
|
|
7
|
-
import {err, ok} from 'mikro/result'
|
|
8
|
-
|
|
9
|
-
import type {CborError} from '../cbor/types.js'
|
|
10
|
-
import type {Result} from '../result/types.js'
|
|
11
|
-
import type {DeviceName} from '../sys/types.js'
|
|
12
|
-
import type {Diagnostic, Offer, Ota, OtaError, Update} from './types.js'
|
|
13
|
-
|
|
14
|
-
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
15
|
-
|
|
16
|
-
/** Immutable facts about this device and the firmware under it. */
|
|
17
|
-
export interface DeviceIdentity {
|
|
18
|
-
deviceId: string
|
|
19
|
-
/** Firmware version string, and the hash of that image. A build published
|
|
20
|
-
* against a different firmware is withheld rather than offered. */
|
|
21
|
-
firmware: string
|
|
22
|
-
firmwareHash: string
|
|
23
|
-
/** QuickJS bytecode version the linked engine reads; a build compiled for
|
|
24
|
-
* another one cannot run here. */
|
|
25
|
-
bytecode: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Every effect the client can have on the world, in one record. Nothing below
|
|
30
|
-
* `createOtaClient` reaches for a runtime import — effects arrive here, so the
|
|
31
|
-
* policy can run against a fake with no hardware and no risk of a real
|
|
32
|
-
* restart().
|
|
33
|
-
*/
|
|
34
|
-
export interface ClientIo {
|
|
35
|
-
/** Wall-clock delay. The only clock the client has. */
|
|
36
|
-
sleep(ms: number): Promise<void>
|
|
37
|
-
/** HTTP. The only network the client has. */
|
|
38
|
-
request: Request
|
|
39
|
-
/** Uniform [0, 1). Injected so tests can pin the jitter. */
|
|
40
|
-
random(): number
|
|
41
|
-
/** Serial diagnostics. Format string and args pass through untouched. */
|
|
42
|
-
log(level: LogLevel, format: string, ...args: unknown[]): void
|
|
43
|
-
/** CBOR codec for the check-in wire. */
|
|
44
|
-
encode(value: unknown): Result<Uint8Array, CborError>
|
|
45
|
-
decode(data: Uint8Array): Result<unknown, CborError>
|
|
46
|
-
|
|
47
|
-
/** The `mikro/ota` policy surface (staging, trials, retry budgets). */
|
|
48
|
-
ota: Ota
|
|
49
|
-
identity(): DeviceIdentity
|
|
50
|
-
/** Bytes free on the partition a build is staged onto, or undefined when the
|
|
51
|
-
* platform cannot report it. */
|
|
52
|
-
storageFree(): number | undefined
|
|
53
|
-
deviceName(): DeviceName
|
|
54
|
-
setDeviceName(name: DeviceName): void
|
|
55
|
-
restart(): never
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface CheckOptions {
|
|
59
|
-
/** Budget for the check-in round trip. Default 10s. */
|
|
60
|
-
checkinTimeoutMs?: number
|
|
61
|
-
/** Budget for the build download. Separate from the check-in's because it is
|
|
62
|
-
* a total wallclock deadline that cancels the transfer mid-stream, and an
|
|
63
|
-
* image needs orders of magnitude more of it than a check-in body does.
|
|
64
|
-
* Default 5m. */
|
|
65
|
-
downloadTimeoutMs?: number
|
|
66
|
-
/** Require a completed check-in (via `ota.confirm()`, which the client fires
|
|
67
|
-
* itself) before an installed build is kept. Default true. */
|
|
68
|
-
requireConfirm?: boolean
|
|
69
|
-
/** Clean boots a trial may consume before an unconfirmed build reverts.
|
|
70
|
-
* A deep-sleep wake counts as a clean boot, so wake-cycle devices on flaky
|
|
71
|
-
* networks should raise this above the default 1. */
|
|
72
|
-
trialBoots?: number
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Runs after its round settles — after the check and any download, and before
|
|
76
|
-
* an auto-restart — so the network brought up in `beforeCheck` can go down. */
|
|
77
|
-
export type Teardown = () => void | Promise<void>
|
|
78
|
-
|
|
79
|
-
export interface WatchOptions extends CheckOptions {
|
|
80
|
-
/** Steady interval between rounds, end-of-round to start-of-next. Default 30m. */
|
|
81
|
-
checkinIntervalMs?: number
|
|
82
|
-
/** Delay before the first round. Default 5s. */
|
|
83
|
-
initialDelayMs?: number
|
|
84
|
-
/** Interval after a failed round, capped at `checkinIntervalMs`. Default 1m. */
|
|
85
|
-
retryAfterFailureMs?: number
|
|
86
|
-
/** Spread every scheduled sleep by ±10%, so a fleet that lost power together
|
|
87
|
-
* does not check in phase-locked forever. Default true; pass false for
|
|
88
|
-
* exact intervals (a demo watching for the update to land, a single
|
|
89
|
-
* device where the spread only delays it). */
|
|
90
|
-
jitter?: boolean
|
|
91
|
-
/** Bring the network up for one round. `err` skips the round (retried at the
|
|
92
|
-
* failure interval) and no teardown runs — partial setup is the hook's own
|
|
93
|
-
* job to unwind. State shared with the teardown stays in this one scope. */
|
|
94
|
-
beforeCheck?(): Promise<Result<Teardown, unknown>>
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface Watcher {
|
|
98
|
-
/** Prevent future rounds and cancel the pending sleep. An in-flight round
|
|
99
|
-
* completes, but a build it stages no longer auto-restarts — it stays armed
|
|
100
|
-
* for the next natural reboot. */
|
|
101
|
-
stop(): void
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Why an offered build was not armed. Each is the policy working as intended;
|
|
105
|
-
* the distinction is logged and returned because the app's next move differs. */
|
|
106
|
-
export type DeclineReason =
|
|
107
|
-
| 'trial-pending'
|
|
108
|
-
| 'current'
|
|
109
|
-
| 'abandoned'
|
|
110
|
-
| 'exhausted'
|
|
111
|
-
| 'download-failed'
|
|
112
|
-
| 'install-failed'
|
|
113
|
-
|
|
114
|
-
/** The check-in never completed. */
|
|
115
|
-
export type CheckError = RequestError | CborError | {name: 'Status'; status: number}
|
|
116
|
-
|
|
117
|
-
export type CheckResult =
|
|
118
|
-
/** Build downloaded, verified, and armed. The app restarts when ready. */
|
|
119
|
-
| {status: 'staged'; offer: Offer}
|
|
120
|
-
| {status: 'up-to-date'}
|
|
121
|
-
/** An offer arrived but was not armed. */
|
|
122
|
-
| {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
|
|
123
|
-
/** Transient: the check-in did not complete, so the running trial (if any)
|
|
124
|
-
* was not confirmed. */
|
|
125
|
-
| {status: 'failed'; error: CheckError}
|
|
126
|
-
/** The registry rejected the update key (HTTP 401). Permanent until
|
|
127
|
-
* re-enrollment over the cable. */
|
|
128
|
-
| {status: 'unauthorized'}
|
|
129
|
-
| {status: 'not-enrolled'}
|
|
130
|
-
|
|
131
|
-
export interface OtaClient {
|
|
132
|
-
check(options?: CheckOptions): Promise<CheckResult>
|
|
133
|
-
watch(options?: WatchOptions): Watcher
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const DEFAULT_CHECKIN_TIMEOUT_MS = 10_000
|
|
137
|
-
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 300_000
|
|
138
|
-
const DEFAULT_INTERVAL_MS = 30 * 60_000
|
|
139
|
-
const DEFAULT_INITIAL_DELAY_MS = 5_000
|
|
140
|
-
const DEFAULT_RETRY_MS = 60_000
|
|
141
|
-
|
|
142
|
-
/** What the watch loop does after a round: retry at the shortened interval, or
|
|
143
|
-
* wait the full one. Deliberately not "did the check-in succeed" — a rejected
|
|
144
|
-
* update key also waits the full interval (a dead key mustn't hammer the
|
|
145
|
-
* registry every retry interval forever). */
|
|
146
|
-
type NextRound = 'soon' | 'later'
|
|
147
|
-
|
|
148
|
-
interface Enrollment {
|
|
149
|
-
registryUrl: string
|
|
150
|
-
bearer: string
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function createOtaClient(io: ClientIo): OtaClient {
|
|
154
|
-
// One check at a time: staging is a single native session, and two
|
|
155
|
-
// interleaved check-ins would corrupt it. Later calls queue behind the
|
|
156
|
-
// running one; each still gets its own result.
|
|
157
|
-
let chain: Promise<unknown> = Promise.resolve()
|
|
158
|
-
|
|
159
|
-
// reconcile() clears its report as it reads, so it runs once per boot and
|
|
160
|
-
// the report is held here until a check-in actually delivers it — a failed
|
|
161
|
-
// first check-in no longer loses it.
|
|
162
|
-
let reconciled = false
|
|
163
|
-
let lastInstall: Diagnostic | undefined
|
|
164
|
-
|
|
165
|
-
let warnedInsecure = false
|
|
166
|
-
|
|
167
|
-
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
168
|
-
const run = chain.then(task)
|
|
169
|
-
chain = run.then(
|
|
170
|
-
() => undefined,
|
|
171
|
-
() => undefined,
|
|
172
|
-
)
|
|
173
|
-
return run
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function enrollment(): Enrollment | undefined {
|
|
177
|
-
// Written as a pair over the cable by `mikro ota enroll`; enrollment is the
|
|
178
|
-
// opt-in. Update keys never arrive over the network.
|
|
179
|
-
const registryUrl = io.ota.registry()
|
|
180
|
-
const bearer = io.ota.bearer()
|
|
181
|
-
if (registryUrl === undefined || bearer === undefined) return undefined
|
|
182
|
-
return {registryUrl, bearer}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function reconcileOnce(): void {
|
|
186
|
-
if (reconciled) return
|
|
187
|
-
reconciled = true
|
|
188
|
-
const report = io.ota.reconcile()
|
|
189
|
-
lastInstall = report.lastInstall
|
|
190
|
-
if (report.reverted) {
|
|
191
|
-
io.log('warn', 'ota: previous update failed its trial and was rolled back')
|
|
192
|
-
}
|
|
193
|
-
if (report.installed) {
|
|
194
|
-
io.log('info', 'ota: installed build %s on this boot', report.installed.slice(0, 12))
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function runCheck(enrolled: Enrollment, options: CheckOptions): Promise<CheckResult> {
|
|
199
|
-
const {registryUrl, bearer} = enrolled
|
|
200
|
-
const allowInsecure = isPrivateHttp(registryUrl)
|
|
201
|
-
if (allowInsecure && !warnedInsecure) {
|
|
202
|
-
// Every boot, not once at enrollment: a device that quietly runs a
|
|
203
|
-
// forgeable update channel should say so for as long as it does.
|
|
204
|
-
warnedInsecure = true
|
|
205
|
-
io.log(
|
|
206
|
-
'warn',
|
|
207
|
-
'ota: registry is http:// on a private network — updates are NOT authenticated',
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
reconcileOnce()
|
|
211
|
-
|
|
212
|
-
const running = io.ota.running()
|
|
213
|
-
const identity = io.identity()
|
|
214
|
-
const named = io.deviceName()
|
|
215
|
-
const free = io.storageFree()
|
|
216
|
-
// The shape is a contract with the registry's /api/v1/checkin: these are
|
|
217
|
-
// the inputs it arbitrates an offer from. Optional keys are omitted, not
|
|
218
|
-
// sent as undefined — the registry reads an absent `free` as "no figure to
|
|
219
|
-
// report", and JSON clients drop the key the same way.
|
|
220
|
-
const report = {
|
|
221
|
-
...identity,
|
|
222
|
-
running,
|
|
223
|
-
// The name pair: `[rev]` when cleared, `[rev, name]` otherwise. Sent
|
|
224
|
-
// every time so a lost response settles on the next check-in.
|
|
225
|
-
name: named.name === undefined ? [named.rev] : [named.rev, named.name],
|
|
226
|
-
...(free === undefined ? {} : {free}),
|
|
227
|
-
...(lastInstall === undefined ? {} : {lastInstall}),
|
|
228
|
-
}
|
|
229
|
-
io.log(
|
|
230
|
-
'debug',
|
|
231
|
-
'ota: checking %s (running %s v%s, bytecode %d, fw %s)',
|
|
232
|
-
`${registryUrl}/api/v1/checkin`,
|
|
233
|
-
running.checksum?.slice(0, 12) ?? 'none',
|
|
234
|
-
running.version ?? '?',
|
|
235
|
-
identity.bytecode,
|
|
236
|
-
identity.firmware,
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
const encoded = io.encode(report)
|
|
240
|
-
if (!encoded.ok) {
|
|
241
|
-
io.log('error', 'ota: could not encode the check-in report', encoded.error)
|
|
242
|
-
return {status: 'failed', error: encoded.error}
|
|
243
|
-
}
|
|
244
|
-
const res = await io.request(`${registryUrl}/api/v1/checkin`, {
|
|
245
|
-
method: 'POST',
|
|
246
|
-
headers: {
|
|
247
|
-
'content-type': 'application/cbor',
|
|
248
|
-
accept: 'application/cbor',
|
|
249
|
-
authorization: `Bearer ${bearer}`,
|
|
250
|
-
},
|
|
251
|
-
timeoutMs: options.checkinTimeoutMs ?? DEFAULT_CHECKIN_TIMEOUT_MS,
|
|
252
|
-
body: encoded.value,
|
|
253
|
-
})
|
|
254
|
-
if (!res.ok) {
|
|
255
|
-
io.log('error', 'ota: checkin failed', res.error)
|
|
256
|
-
return {status: 'failed', error: res.error}
|
|
257
|
-
}
|
|
258
|
-
const response = res.value
|
|
259
|
-
if (response.status === 401) {
|
|
260
|
-
// The update key no longer authenticates (rotated, device deleted).
|
|
261
|
-
// Only a 401 means this. There is no fallback secret — re-enroll over
|
|
262
|
-
// the cable. Release the connection before returning.
|
|
263
|
-
await response.close()
|
|
264
|
-
io.log(
|
|
265
|
-
'error',
|
|
266
|
-
'ota: registry rejected the update key; re-enroll with `mikro ota enroll --re-enroll`',
|
|
267
|
-
)
|
|
268
|
-
return {status: 'unauthorized'}
|
|
269
|
-
}
|
|
270
|
-
if (response.status === 415) {
|
|
271
|
-
// The wire is CBOR-only by design; there is no JSON fallback to hide a
|
|
272
|
-
// registry that predates it.
|
|
273
|
-
await response.close()
|
|
274
|
-
io.log(
|
|
275
|
-
'error',
|
|
276
|
-
'ota: registry does not accept CBOR check-ins; upgrade the registry to a version that supports application/cbor',
|
|
277
|
-
)
|
|
278
|
-
return {status: 'failed', error: {name: 'Status', status: 415}}
|
|
279
|
-
}
|
|
280
|
-
if (!response.ok) {
|
|
281
|
-
// Release the connection before the heap-sensitive image request — an
|
|
282
|
-
// abandoned response holds its native slot and TLS buffers until its
|
|
283
|
-
// timeout fires.
|
|
284
|
-
await response.close()
|
|
285
|
-
io.log('error', 'ota: checkin returned status %d', response.status)
|
|
286
|
-
return {status: 'failed', error: {name: 'Status', status: response.status}}
|
|
287
|
-
}
|
|
288
|
-
const raw = await response.bytes()
|
|
289
|
-
if (!raw.ok) {
|
|
290
|
-
io.log('error', 'ota: reading the checkin response failed', raw.error)
|
|
291
|
-
return {status: 'failed', error: raw.error}
|
|
292
|
-
}
|
|
293
|
-
const body = io.decode(raw.value)
|
|
294
|
-
if (!body.ok) {
|
|
295
|
-
io.log('error', 'ota: invalid checkin response body', body.error)
|
|
296
|
-
return {status: 'failed', error: body.error}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// The check-in completed: the cached install report is delivered.
|
|
300
|
-
lastInstall = undefined
|
|
301
|
-
|
|
302
|
-
// Confirm before applying: a completed check-in is the whole health signal
|
|
303
|
-
// requireConfirm waits for, whether or not an offer follows — and resolving
|
|
304
|
-
// the trial now lets an offer published during it stage in this same pass
|
|
305
|
-
// (applyOffer skips every offer while a trial is unresolved).
|
|
306
|
-
if (running.trial) {
|
|
307
|
-
io.log('info', 'ota: check-in completed — confirming this build as healthy')
|
|
308
|
-
}
|
|
309
|
-
io.ota.confirm()
|
|
310
|
-
|
|
311
|
-
const renamed = nameFrom(body.value)
|
|
312
|
-
if (renamed !== undefined) {
|
|
313
|
-
io.setDeviceName(renamed)
|
|
314
|
-
io.log('info', 'ota: registry renamed this device to %s', renamed.name ?? '(no name)')
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const offer = io.ota.parseOffer(body.value, {allowInsecure})
|
|
318
|
-
if (offer === undefined) {
|
|
319
|
-
io.log('debug', 'ota: up to date, running the latest build')
|
|
320
|
-
return {status: 'up-to-date'}
|
|
321
|
-
}
|
|
322
|
-
io.log(
|
|
323
|
-
'info',
|
|
324
|
-
'ota: update available (%s, %d bytes); downloading',
|
|
325
|
-
offer.checksum.slice(0, 12),
|
|
326
|
-
offer.size,
|
|
327
|
-
)
|
|
328
|
-
const applied = await io.ota.applyOffer(
|
|
329
|
-
offer,
|
|
330
|
-
(update) => download(enrolled, options, offer, update),
|
|
331
|
-
{
|
|
332
|
-
requireConfirm: options.requireConfirm ?? true,
|
|
333
|
-
trialBoots: options.trialBoots ?? 1,
|
|
334
|
-
},
|
|
335
|
-
)
|
|
336
|
-
if (!applied.ok) {
|
|
337
|
-
const reason: DeclineReason =
|
|
338
|
-
applied.error.name === 'DownloadFailed' ? 'download-failed' : 'install-failed'
|
|
339
|
-
io.log('error', 'ota: update failed', applied.error)
|
|
340
|
-
return {status: 'not-staged', reason, error: applied.error}
|
|
341
|
-
}
|
|
342
|
-
if (applied.value === 'staged') {
|
|
343
|
-
io.log('info', 'ota: download verified and staged')
|
|
344
|
-
return {status: 'staged', offer}
|
|
345
|
-
}
|
|
346
|
-
// Not staged, not an error — applyOffer declined the offer:
|
|
347
|
-
// current already running this build
|
|
348
|
-
// trial-pending the running build hasn't resolved its trial (nearly
|
|
349
|
-
// unreachable: the confirm above resolves it first)
|
|
350
|
-
// abandoned the build is corrupt and won't be retried
|
|
351
|
-
// exhausted the retry budget for this build is spent until next boot
|
|
352
|
-
io.log('info', 'ota: offer not staged (%s)', applied.value)
|
|
353
|
-
return {status: 'not-staged', reason: applied.value}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** The download pump: stream the build straight into the staging area,
|
|
357
|
-
* resuming an interrupted transfer with a Range request. Chunked on purpose:
|
|
358
|
-
* buffering the whole build would put ~2x its size on the heap. */
|
|
359
|
-
async function download(
|
|
360
|
-
enrolled: Enrollment,
|
|
361
|
-
options: CheckOptions,
|
|
362
|
-
offer: Offer,
|
|
363
|
-
update: Update,
|
|
364
|
-
): Promise<Result<void, {message: string}>> {
|
|
365
|
-
const from = update.resumeOffset
|
|
366
|
-
// Already complete: a finish that failed transiently leaves every byte on
|
|
367
|
-
// flash and the offer pending, so resuming would ask for `bytes=<size>-`
|
|
368
|
-
// and take the 416 as a failed download forever. Hand it straight back to
|
|
369
|
-
// re-verify.
|
|
370
|
-
if (from >= offer.size) return ok()
|
|
371
|
-
if (from > 0) {
|
|
372
|
-
io.log('info', 'ota: resuming download from byte %d of %d', from, offer.size)
|
|
373
|
-
}
|
|
374
|
-
// Same-origin only. `offer.url` may legitimately point at another host (a
|
|
375
|
-
// CDN, an object store), so the update key goes out only when the download
|
|
376
|
-
// is on the registry's own origin. A build fetched elsewhere is a public
|
|
377
|
-
// artifact the checksum vouches for; a store that needs auth carries it in
|
|
378
|
-
// a signed url instead.
|
|
379
|
-
const headers: Record<string, string> = {}
|
|
380
|
-
if (from > 0) headers.range = `bytes=${from}-`
|
|
381
|
-
if (sameOrigin(offer.url, enrolled.registryUrl)) {
|
|
382
|
-
headers.authorization = `Bearer ${enrolled.bearer}`
|
|
383
|
-
}
|
|
384
|
-
const res = await io.request(offer.url, {
|
|
385
|
-
headers,
|
|
386
|
-
timeoutMs: options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
387
|
-
})
|
|
388
|
-
if (!res.ok) {
|
|
389
|
-
return err({message: `download failed: ${describeRequestError(res.error)}`})
|
|
390
|
-
}
|
|
391
|
-
const response = res.value
|
|
392
|
-
if (!response.ok) {
|
|
393
|
-
await response.close()
|
|
394
|
-
return err({message: `download returned status ${response.status}`})
|
|
395
|
-
}
|
|
396
|
-
// A server free to ignore Range answers 200 with the whole build. Drop the
|
|
397
|
-
// prefix we already hold rather than failing, which would spend a retry
|
|
398
|
-
// for nothing.
|
|
399
|
-
let skip = response.status === 206 ? 0 : from
|
|
400
|
-
for await (const chunk of response.body) {
|
|
401
|
-
if (!chunk.ok) {
|
|
402
|
-
await response.close()
|
|
403
|
-
return err({message: `reading the download failed: ${describeRequestError(chunk.error)}`})
|
|
404
|
-
}
|
|
405
|
-
let bytes = chunk.value
|
|
406
|
-
if (skip > 0) {
|
|
407
|
-
if (bytes.length <= skip) {
|
|
408
|
-
skip -= bytes.length
|
|
409
|
-
continue
|
|
410
|
-
}
|
|
411
|
-
bytes = bytes.subarray(skip)
|
|
412
|
-
skip = 0
|
|
413
|
-
}
|
|
414
|
-
const written = update.write(bytes)
|
|
415
|
-
if (!written.ok) {
|
|
416
|
-
await response.close()
|
|
417
|
-
return written
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return ok()
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function check(options: CheckOptions = {}): Promise<CheckResult> {
|
|
424
|
-
const enrolled = enrollment()
|
|
425
|
-
if (enrolled === undefined) {
|
|
426
|
-
return Promise.resolve({status: 'not-enrolled'})
|
|
427
|
-
}
|
|
428
|
-
return enqueue(() => runCheck(enrolled, options))
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function watch(options: WatchOptions = {}): Watcher {
|
|
432
|
-
const maybeEnrolled = enrollment()
|
|
433
|
-
if (maybeEnrolled === undefined) {
|
|
434
|
-
io.log('info', 'ota: device not enrolled; run `mikro ota enroll` to enable OTA updates')
|
|
435
|
-
return {stop: () => undefined}
|
|
436
|
-
}
|
|
437
|
-
// Explicitly typed: the narrowing above does not reach into the hoisted
|
|
438
|
-
// round() declaration below.
|
|
439
|
-
const enrolled: Enrollment = maybeEnrolled
|
|
440
|
-
const intervalMs = options.checkinIntervalMs ?? DEFAULT_INTERVAL_MS
|
|
441
|
-
const retryMs = Math.min(options.retryAfterFailureMs ?? DEFAULT_RETRY_MS, intervalMs)
|
|
442
|
-
|
|
443
|
-
let stopped = false
|
|
444
|
-
let cancelSleep: (() => void) | undefined
|
|
445
|
-
|
|
446
|
-
// ±10% so a power-cut fleet doesn't stay phase-locked on the registry.
|
|
447
|
-
const jitterOn = options.jitter ?? true
|
|
448
|
-
function jitter(ms: number): number {
|
|
449
|
-
if (!jitterOn) return ms
|
|
450
|
-
return Math.round(ms * (0.9 + io.random() * 0.2))
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function pause(ms: number): Promise<void> {
|
|
454
|
-
return new Promise((resolve) => {
|
|
455
|
-
cancelSleep = resolve
|
|
456
|
-
void io.sleep(jitter(ms)).then(resolve)
|
|
457
|
-
})
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function round(): Promise<NextRound> {
|
|
461
|
-
let teardown: Teardown | undefined
|
|
462
|
-
if (options.beforeCheck) {
|
|
463
|
-
let setup: Result<Teardown, unknown>
|
|
464
|
-
try {
|
|
465
|
-
setup = await options.beforeCheck()
|
|
466
|
-
} catch (e) {
|
|
467
|
-
setup = err(e)
|
|
468
|
-
}
|
|
469
|
-
if (!setup.ok) {
|
|
470
|
-
io.log('warn', 'ota: beforeCheck failed; skipping this check', setup.error)
|
|
471
|
-
return 'soon'
|
|
472
|
-
}
|
|
473
|
-
teardown = setup.value
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Contain OTA faults: an uncaught throw (e.g. an OOM from the native
|
|
477
|
-
// HTTP layer under a low-heap stream) must not bubble out of the
|
|
478
|
-
// detached loop. A crashed check is just a check that didn't complete.
|
|
479
|
-
let outcome: CheckResult | undefined
|
|
480
|
-
try {
|
|
481
|
-
outcome = await enqueue(() => runCheck(enrolled, options))
|
|
482
|
-
} catch (e) {
|
|
483
|
-
io.log('error', 'ota: check crashed', e)
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Teardown before any restart: the round is over either way, and the
|
|
487
|
-
// hook's invariant — teardown runs whenever setup succeeded — must not
|
|
488
|
-
// depend on what the check found.
|
|
489
|
-
if (teardown !== undefined) {
|
|
490
|
-
try {
|
|
491
|
-
await teardown()
|
|
492
|
-
} catch (e) {
|
|
493
|
-
io.log('warn', 'ota: teardown failed', e)
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (outcome === undefined) return 'soon'
|
|
498
|
-
if (outcome.status === 'staged') {
|
|
499
|
-
if (stopped) {
|
|
500
|
-
// stop() won the race: leave the build armed for the next natural
|
|
501
|
-
// reboot instead of restarting under the caller.
|
|
502
|
-
io.log('info', 'ota: update staged; watcher stopped, restart deferred')
|
|
503
|
-
return 'later'
|
|
504
|
-
}
|
|
505
|
-
io.log('info', 'ota: staged; restarting to install')
|
|
506
|
-
io.restart() // never returns
|
|
507
|
-
}
|
|
508
|
-
return outcome.status === 'failed' ? 'soon' : 'later'
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
async function loop(): Promise<void> {
|
|
512
|
-
await pause(options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS)
|
|
513
|
-
while (!stopped) {
|
|
514
|
-
const next = await round()
|
|
515
|
-
if (stopped) return
|
|
516
|
-
await pause(next === 'later' ? intervalMs : retryMs)
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Detached: never awaited by the caller, so the app keeps running
|
|
521
|
-
// regardless of OTA outcome. round() contains every throw, so this
|
|
522
|
-
// promise cannot reject.
|
|
523
|
-
void loop()
|
|
524
|
-
|
|
525
|
-
return {
|
|
526
|
-
stop: () => {
|
|
527
|
-
stopped = true
|
|
528
|
-
cancelSleep?.()
|
|
529
|
-
},
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
return {check, watch}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** Render a RequestError into prose. `DownloadFn`'s contract carries only a
|
|
537
|
-
* `message` string forward into the `DownloadFailed` the policy reports, so
|
|
538
|
-
* this line is all that survives to explain a failed download on serial —
|
|
539
|
-
* include the underlying message, not just the variant name. */
|
|
540
|
-
function describeRequestError(error: RequestError): string {
|
|
541
|
-
return 'message' in error && error.message !== '' ? `${error.name}: ${error.message}` : error.name
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* The name pair a check-in response carries, as `[rev, name]` (or `[rev]` when
|
|
546
|
-
* it was cleared), or undefined when there is nothing to adopt. No `name` field
|
|
547
|
-
* means "no change" and never "clear it": a response lost after the registry
|
|
548
|
-
* adopted the device's own name looks exactly the same as one that never
|
|
549
|
-
* arrived, and re-sending the pair next check-in settles it.
|
|
550
|
-
*/
|
|
551
|
-
export function nameFrom(body: unknown): DeviceName | undefined {
|
|
552
|
-
if (typeof body !== 'object' || body === null) return undefined
|
|
553
|
-
const pair = (body as {name?: unknown}).name
|
|
554
|
-
if (!Array.isArray(pair)) return undefined
|
|
555
|
-
const rev = pair[0]
|
|
556
|
-
if (typeof rev !== 'number' || !Number.isInteger(rev) || rev < 0) return undefined
|
|
557
|
-
const value = pair[1]
|
|
558
|
-
return typeof value === 'string' && value !== '' ? {rev, name: value} : {rev}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/** True when both urls share a scheme and authority. Deliberately literal: with
|
|
562
|
-
* no URL parser here, a spelled-out default port does not compare equal. Used
|
|
563
|
-
* to decide whether the update key may ride the download request. */
|
|
564
|
-
export function sameOrigin(a: string, b: string): boolean {
|
|
565
|
-
const origin = (url: string): string | undefined => {
|
|
566
|
-
const sep = url.indexOf('://')
|
|
567
|
-
if (sep < 0) return undefined
|
|
568
|
-
const start = sep + 3
|
|
569
|
-
const end = url.indexOf('/', start)
|
|
570
|
-
const authority = end < 0 ? url.slice(start) : url.slice(start, end)
|
|
571
|
-
return authority === '' ? undefined : (url.slice(0, start) + authority).toLowerCase()
|
|
572
|
-
}
|
|
573
|
-
const left = origin(a)
|
|
574
|
-
return left !== undefined && left === origin(b)
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/** True for http:// on a LAN/loopback/mDNS host: development, not the internet.
|
|
578
|
-
* Anywhere else the scheme is not a judgement call — over http the offer's
|
|
579
|
-
* checksum is forgeable in the same response that names it. */
|
|
580
|
-
export function isPrivateHttp(url: string): boolean {
|
|
581
|
-
if (!url.startsWith('http://')) return false
|
|
582
|
-
const host = (url.slice(7).split('/')[0] ?? '').split(':')[0] ?? ''
|
|
583
|
-
if (host === 'localhost' || host.endsWith('.local')) return true
|
|
584
|
-
const p = host.split('.').map((s) => parseInt(s, 10))
|
|
585
|
-
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
|
586
|
-
if (p[0] === 10 || p[0] === 127) return true
|
|
587
|
-
if (p[0] === 192 && p[1] === 168) return true
|
|
588
|
-
if (p[0] === 169 && p[1] === 254) return true
|
|
589
|
-
return p[0] === 172 && p[1]! >= 16 && p[1]! <= 31
|
|
590
|
-
}
|