@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include <quickjs.h>
|
|
4
|
+
|
|
5
|
+
#include <cstddef>
|
|
6
|
+
#include <cstdint>
|
|
7
|
+
#include <functional>
|
|
8
|
+
#include <memory>
|
|
9
|
+
#include <string>
|
|
10
|
+
#include <vector>
|
|
11
|
+
|
|
12
|
+
#include "mikrojs/ota_env.h"
|
|
13
|
+
#include "mikrojs/ota_policy.h"
|
|
14
|
+
|
|
15
|
+
namespace mikrojs {
|
|
16
|
+
|
|
17
|
+
/* Why an offered build was not armed. Each is the policy working as intended;
|
|
18
|
+
* the distinction is logged and reported because the app's next move differs. */
|
|
19
|
+
enum class MIKOtaDeclineReason {
|
|
20
|
+
kTrialPending,
|
|
21
|
+
kCurrent,
|
|
22
|
+
kAbandoned,
|
|
23
|
+
kExhausted,
|
|
24
|
+
kDownloadFailed,
|
|
25
|
+
kInstallFailed,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const char* mik__ota_decline_reason_to_str(MIKOtaDeclineReason reason);
|
|
29
|
+
|
|
30
|
+
enum class MIKOtaCheckStatus {
|
|
31
|
+
kStaged,
|
|
32
|
+
kUpToDate,
|
|
33
|
+
kNotStaged,
|
|
34
|
+
kFailed,
|
|
35
|
+
kUnauthorized,
|
|
36
|
+
kNotEnrolled,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const char* mik__ota_check_status_to_str(MIKOtaCheckStatus status);
|
|
40
|
+
|
|
41
|
+
struct MIKOtaCheckOptions {
|
|
42
|
+
/* Budget for the check-in round trip. */
|
|
43
|
+
uint32_t checkin_timeout_ms = 10000;
|
|
44
|
+
/* Budget for the build download: a total deadline that cancels the
|
|
45
|
+
* transfer mid-stream, so it needs orders of magnitude more than a
|
|
46
|
+
* check-in body does. */
|
|
47
|
+
uint32_t download_timeout_ms = 300000;
|
|
48
|
+
bool require_confirm = true;
|
|
49
|
+
int trial_boots = 1;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/* Where an asynchronous round hook stands. */
|
|
53
|
+
enum class MIKOtaHookState {
|
|
54
|
+
kPending,
|
|
55
|
+
kOk,
|
|
56
|
+
kFailed,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/* The per-round network hook pair: `beforeCheck` and the teardown it hands
|
|
60
|
+
* back. It belongs to one watch call rather than to the platform, so it lives
|
|
61
|
+
* outside MIKOtaEnv. Begin* returns false when there is no hook to run, in
|
|
62
|
+
* which case the machine moves straight on.
|
|
63
|
+
*
|
|
64
|
+
* A failed BeginBeforeCheck skips the round and no teardown runs: unwinding a
|
|
65
|
+
* partial setup is the hook's own business. */
|
|
66
|
+
class MIKOtaRoundHooks {
|
|
67
|
+
public:
|
|
68
|
+
virtual ~MIKOtaRoundHooks() = default;
|
|
69
|
+
virtual bool BeginBeforeCheck() = 0;
|
|
70
|
+
virtual MIKOtaHookState PollBeforeCheck() = 0;
|
|
71
|
+
virtual bool BeginTeardown() = 0;
|
|
72
|
+
virtual MIKOtaHookState PollTeardown() = 0;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
struct MIKOtaCheckResult {
|
|
76
|
+
MIKOtaCheckStatus status = MIKOtaCheckStatus::kFailed;
|
|
77
|
+
/* Set on kStaged. */
|
|
78
|
+
MIKOtaOffer offer;
|
|
79
|
+
/* On kUpToDate: the running build's stored config changed since the app
|
|
80
|
+
* could last have read it — delivered or cleared this round, or applied by
|
|
81
|
+
* this boot's install or rollback. */
|
|
82
|
+
bool config_updated = false;
|
|
83
|
+
/* Set on kNotStaged. */
|
|
84
|
+
MIKOtaDeclineReason decline_reason = MIKOtaDeclineReason::kCurrent;
|
|
85
|
+
/* Set on kFailed and on kNotStaged with a download or install failure. */
|
|
86
|
+
MIKOtaError error;
|
|
87
|
+
/* The response status behind a kFailed/kUnauthorized, or 0. */
|
|
88
|
+
int http_status = 0;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/* Fires exactly once per Check(), when that round settles. */
|
|
92
|
+
using MIKOtaCheckSink = std::function<void(const MIKOtaCheckResult&)>;
|
|
93
|
+
|
|
94
|
+
struct MIKOtaWatchOptions : MIKOtaCheckOptions {
|
|
95
|
+
/* Steady interval between rounds, end-of-round to start-of-next. Floored
|
|
96
|
+
* at 30s: each round's TLS session leaves heap and socket residue that
|
|
97
|
+
* needs time to drain, and the value may arrive from remote config, so the
|
|
98
|
+
* floor bounds the damage a mistyped document can do. */
|
|
99
|
+
uint32_t checkin_interval_ms = 30 * 60 * 1000;
|
|
100
|
+
uint32_t initial_delay_ms = 5000;
|
|
101
|
+
/* Interval after a failed round, capped at checkin_interval_ms. */
|
|
102
|
+
uint32_t retry_after_failure_ms = 60000;
|
|
103
|
+
/* Spread every scheduled delay by ±10%, so a fleet that lost power
|
|
104
|
+
* together does not check in phase-locked forever. */
|
|
105
|
+
bool jitter = true;
|
|
106
|
+
/* Borrowed, not owned: must outlive the watch. */
|
|
107
|
+
MIKOtaRoundHooks* hooks = nullptr;
|
|
108
|
+
/* Fires as each round settles, before any restart. A watch loop otherwise
|
|
109
|
+
* reports nothing to the app, which then has to poll for what changed —
|
|
110
|
+
* config delivery in particular, which is invisible from outside. */
|
|
111
|
+
MIKOtaCheckSink on_round;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
/* Why the device did not take the build it was last offered.
|
|
116
|
+
*
|
|
117
|
+
* Without this a registry cannot tell "still working on it" from "gave up": the
|
|
118
|
+
* device stops retrying, its running checksum never changes, and the registry
|
|
119
|
+
* waits for an install that is never coming. `exhausted` is per-boot and clears
|
|
120
|
+
* on reboot; `abandoned` is permanent for those bytes. */
|
|
121
|
+
struct MIKOtaDeclineReport {
|
|
122
|
+
char checksum[65] = {};
|
|
123
|
+
MIKOtaDeclineReason reason = MIKOtaDeclineReason::kExhausted;
|
|
124
|
+
/* The underlying error, when there was one. */
|
|
125
|
+
std::string detail;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/* Everything the check-in report carries. A struct because the list grew past
|
|
129
|
+
* what positional arguments stay readable at. */
|
|
130
|
+
struct MIKOtaCheckinFacts {
|
|
131
|
+
const MIKDeviceIdentity* identity = nullptr;
|
|
132
|
+
const MIKOtaRunningBuild* running = nullptr;
|
|
133
|
+
int name_rev = 0;
|
|
134
|
+
/* NULL or empty sends the cleared `[rev]` pair. */
|
|
135
|
+
const char* name = nullptr;
|
|
136
|
+
bool has_free = false;
|
|
137
|
+
size_t free_bytes = 0;
|
|
138
|
+
const MIKOtaDiagnostic* last_install = nullptr;
|
|
139
|
+
/* NULL or empty omits the key. */
|
|
140
|
+
const char* echo_rev = nullptr;
|
|
141
|
+
const MIKOtaConfigErrorReport* config_error = nullptr;
|
|
142
|
+
const MIKOtaDeclineReport* last_decline = nullptr;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/* The states a round can rest in between Poll() calls. Parsing the response and
|
|
146
|
+
* closing the install are synchronous, so they are transitions, not states. */
|
|
147
|
+
enum class MIKOtaClientState {
|
|
148
|
+
kIdle,
|
|
149
|
+
kBeforeCheck,
|
|
150
|
+
kCheckIn,
|
|
151
|
+
kDownload,
|
|
152
|
+
kTeardown,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/* Largest check-in response the client will buffer. Real ones run to a few
|
|
156
|
+
* hundred bytes; the cap is what keeps a confused or hostile registry from
|
|
157
|
+
* growing the buffer until the device runs out of heap. */
|
|
158
|
+
constexpr size_t MIK__OTA_MAX_RESPONSE_BYTES = 8192;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The OTA client: one check-in round at a time, driven by Poll().
|
|
162
|
+
*
|
|
163
|
+
* `Check()` queues a forced round. `Watch()` runs rounds on a jittered cadence
|
|
164
|
+
* and restarts the device after staging a build. Both share one queue, because
|
|
165
|
+
* staging is a single native session and two interleaved rounds would corrupt
|
|
166
|
+
* it — a Check() issued mid-round waits its turn and still gets its own result.
|
|
167
|
+
*/
|
|
168
|
+
class MIKOtaClient {
|
|
169
|
+
public:
|
|
170
|
+
/* `config` is borrowed and may be NULL: it is only read for the defaults
|
|
171
|
+
* rev the check-in reports, and must outlive the client. */
|
|
172
|
+
explicit MIKOtaClient(const MIKOtaEnv* env);
|
|
173
|
+
~MIKOtaClient();
|
|
174
|
+
MIKOtaClient(const MIKOtaClient&) = delete;
|
|
175
|
+
MIKOtaClient& operator=(const MIKOtaClient&) = delete;
|
|
176
|
+
|
|
177
|
+
/* Queue one forced round. `sink` fires from a later Poll() — except on an
|
|
178
|
+
* un-enrolled device, which settles inline before Check() returns. */
|
|
179
|
+
void Check(const MIKOtaCheckOptions& options, MIKOtaCheckSink sink);
|
|
180
|
+
|
|
181
|
+
/* Start the free-running loop. Inert, with a log line, on an un-enrolled
|
|
182
|
+
* device. At most one watch per client. */
|
|
183
|
+
void Watch(const MIKOtaWatchOptions& options);
|
|
184
|
+
|
|
185
|
+
/* No further rounds, and the pending delay is cancelled. A round in flight
|
|
186
|
+
* still completes, but a build it stages no longer auto-restarts — it stays
|
|
187
|
+
* armed for the next natural reboot. */
|
|
188
|
+
void StopWatch();
|
|
189
|
+
|
|
190
|
+
/* Change the cadence of a running watch, floored the same way Watch() floors
|
|
191
|
+
* it. A wait already counting is re-timed from when it started, so lowering
|
|
192
|
+
* the interval brings the next round forward instead of waiting out the old
|
|
193
|
+
* one — which is the case this exists for, a cadence arriving from remote
|
|
194
|
+
* config. The initial delay is left alone: it is not the interval. */
|
|
195
|
+
void SetCheckinInterval(uint32_t interval_ms);
|
|
196
|
+
|
|
197
|
+
/* One turn of the machine: settle what is ready, start what is due. Call it
|
|
198
|
+
* once per loop pass. */
|
|
199
|
+
void Poll();
|
|
200
|
+
|
|
201
|
+
MIKOtaClientState state() const { return state_; }
|
|
202
|
+
/* The delay last handed to the scheduler, jitter included, or -1 when no
|
|
203
|
+
* round is scheduled. */
|
|
204
|
+
int64_t scheduled_delay_ms() const { return scheduled_delay_ms_; }
|
|
205
|
+
bool watching() const { return watching_ && !watch_stopped_; }
|
|
206
|
+
bool HasLastInstall() const { return has_last_install_; }
|
|
207
|
+
bool BootConfigChanged() const { return boot_config_changed_; }
|
|
208
|
+
|
|
209
|
+
private:
|
|
210
|
+
/* Which delay the pending wait is counting, so a cadence change can re-time
|
|
211
|
+
* it against the right one. */
|
|
212
|
+
enum class PendingWait { kNone, kInitial, kInterval, kRetry };
|
|
213
|
+
|
|
214
|
+
/* What the loop does after a round: retry at the shortened interval, or
|
|
215
|
+
* wait the full one. Deliberately not "did the check-in succeed" — a
|
|
216
|
+
* rejected update key also waits the full interval, so a dead key does not
|
|
217
|
+
* hammer the registry every retry interval forever. */
|
|
218
|
+
enum class NextRound { kSoon, kLater };
|
|
219
|
+
|
|
220
|
+
struct Round {
|
|
221
|
+
bool is_watch = false;
|
|
222
|
+
MIKOtaCheckOptions options;
|
|
223
|
+
MIKOtaCheckSink sink;
|
|
224
|
+
std::string registry_url;
|
|
225
|
+
std::string bearer;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/* One transition. Returns true when it moved, so Poll can keep going. */
|
|
229
|
+
bool Step();
|
|
230
|
+
|
|
231
|
+
/* Transport callbacks. They only record what arrived (and, for a download,
|
|
232
|
+
* stream it into staging); every decision is taken from Poll(), so the
|
|
233
|
+
* machine never advances re-entrantly from inside the transport. */
|
|
234
|
+
static void HeadersThunk(void* user_data, int status);
|
|
235
|
+
static void DownloadHeadersThunk(void* user_data, int status);
|
|
236
|
+
static void DataThunk(void* user_data, const uint8_t* data, size_t len);
|
|
237
|
+
static void DoneThunk(void* user_data, int status, const char* error_msg);
|
|
238
|
+
|
|
239
|
+
bool Enrollment(std::string* out_url, std::string* out_bearer) const;
|
|
240
|
+
void StartRound();
|
|
241
|
+
void BeginCheckIn();
|
|
242
|
+
void OnCheckInSettled();
|
|
243
|
+
bool BeginDownload();
|
|
244
|
+
void OnDownloadSettled();
|
|
245
|
+
void FinishRound(NextRound next);
|
|
246
|
+
void Finalize(NextRound next);
|
|
247
|
+
|
|
248
|
+
void ReconcileOnce();
|
|
249
|
+
/* Record a decline worth telling the registry about. Reasons that are the
|
|
250
|
+
* policy working normally — already current, a trial still resolving — are
|
|
251
|
+
* not failures and are not reported. */
|
|
252
|
+
void NoteDecline(MIKOtaDeclineReason reason, const std::string& detail);
|
|
253
|
+
/* Apply a config delivered without an offer: it is for the running
|
|
254
|
+
* release. Returns whether stored state changed. */
|
|
255
|
+
bool ApplyRunningConfig(const MIKOtaStoredConfig* config, int trial_boots);
|
|
256
|
+
void ScheduleNext(NextRound next);
|
|
257
|
+
int64_t Jitter(int64_t ms);
|
|
258
|
+
int64_t Now() const;
|
|
259
|
+
void Log(int level, const char* fmt, ...) const;
|
|
260
|
+
|
|
261
|
+
const MIKOtaEnv* env_;
|
|
262
|
+
MIKOtaClientState state_ = MIKOtaClientState::kIdle;
|
|
263
|
+
|
|
264
|
+
std::vector<Round> queue_;
|
|
265
|
+
Round active_;
|
|
266
|
+
|
|
267
|
+
/* Boot-once bookkeeping, held across rounds. */
|
|
268
|
+
bool reconciled_ = false;
|
|
269
|
+
bool has_last_install_ = false;
|
|
270
|
+
MIKOtaDiagnostic last_install_ = {};
|
|
271
|
+
bool boot_config_changed_ = false;
|
|
272
|
+
bool warned_insecure_ = false;
|
|
273
|
+
/* Held until a check-in delivers it, exactly like last_install_. A reboot
|
|
274
|
+
* loses an undelivered one, which is right for `exhausted` (the budget
|
|
275
|
+
* resets anyway) and harmless for `abandoned` (the next offer re-declines
|
|
276
|
+
* immediately and reports again). */
|
|
277
|
+
bool has_last_decline_ = false;
|
|
278
|
+
MIKOtaDeclineReport last_decline_;
|
|
279
|
+
|
|
280
|
+
/* The round in flight. */
|
|
281
|
+
bool allow_insecure_ = false;
|
|
282
|
+
MIKOtaRunningBuild running_ = {};
|
|
283
|
+
MIKOtaCheckResult result_;
|
|
284
|
+
bool teardown_armed_ = false;
|
|
285
|
+
NextRound pending_next_ = NextRound::kLater;
|
|
286
|
+
|
|
287
|
+
/* The exchange in flight. */
|
|
288
|
+
void* http_handle_ = nullptr;
|
|
289
|
+
bool http_done_ = false;
|
|
290
|
+
int http_status_ = 0;
|
|
291
|
+
std::string http_error_;
|
|
292
|
+
std::vector<uint8_t> response_body_;
|
|
293
|
+
bool response_too_large_ = false;
|
|
294
|
+
|
|
295
|
+
/* The download in flight. */
|
|
296
|
+
MIKOtaApplySession apply_;
|
|
297
|
+
MIKOtaOffer offer_;
|
|
298
|
+
size_t download_skip_ = 0;
|
|
299
|
+
bool write_failed_ = false;
|
|
300
|
+
MIKOtaError write_error_;
|
|
301
|
+
/* The config the response carried, if any, kept until the round settles. */
|
|
302
|
+
bool have_response_config_ = false;
|
|
303
|
+
MIKOtaStoredConfig response_config_ = {};
|
|
304
|
+
std::vector<uint8_t> response_config_doc_;
|
|
305
|
+
|
|
306
|
+
/* Watch state. */
|
|
307
|
+
bool watching_ = false;
|
|
308
|
+
bool watch_stopped_ = false;
|
|
309
|
+
MIKOtaWatchOptions watch_options_;
|
|
310
|
+
std::string watch_registry_url_;
|
|
311
|
+
std::string watch_bearer_;
|
|
312
|
+
int64_t deadline_ms_ = -1;
|
|
313
|
+
int64_t scheduled_delay_ms_ = -1;
|
|
314
|
+
PendingWait pending_wait_ = PendingWait::kNone;
|
|
315
|
+
/* When the pending wait began, so it can be re-timed without drifting. */
|
|
316
|
+
int64_t wait_started_ms_ = 0;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/* True when both urls share a scheme and authority. Deliberately literal: with
|
|
320
|
+
* no URL parser here, a spelled-out default port does not compare equal. */
|
|
321
|
+
bool mik__ota_same_origin(const std::string& a, const std::string& b);
|
|
322
|
+
|
|
323
|
+
/* True for http:// on a LAN/loopback/mDNS host: development, not the internet.
|
|
324
|
+
* Anywhere else the scheme is not a judgement call — over http the offer's
|
|
325
|
+
* checksum is forgeable in the same response that names it. */
|
|
326
|
+
bool mik__ota_is_private_http(const std::string& url);
|
|
327
|
+
|
|
328
|
+
/* Validate an untrusted registry value into an offer.
|
|
329
|
+
*
|
|
330
|
+
* The JS-value twin of mik__ota_parse_offer, for a caller holding a decoded
|
|
331
|
+
* check-in response rather than the fields. Absent or malformed reads as no
|
|
332
|
+
* offer; a url that is present but unusable also warns on the console, the way
|
|
333
|
+
* the TypeScript parseOffer did, because that case is a registry misconfiguring
|
|
334
|
+
* itself rather than a device with nothing to do. */
|
|
335
|
+
bool mik__ota_parse_offer_js(JSContext* ctx, JSValueConst raw, bool allow_insecure,
|
|
336
|
+
MIKOtaOffer* out_offer);
|
|
337
|
+
|
|
338
|
+
/* The check-in report, CBOR-encoded. The shape is a contract with the
|
|
339
|
+
* registry's /api/v1/checkin; optional keys are omitted, never sent as null. */
|
|
340
|
+
std::vector<uint8_t> mik__ota_build_checkin_report(const MIKOtaCheckinFacts& facts);
|
|
341
|
+
|
|
342
|
+
} // namespace mikrojs
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `ota.config()` read.
|
|
5
|
+
*
|
|
6
|
+
* The effective config is the running build's manifest defaults with the stored
|
|
7
|
+
* document spread over them, top level only: `{...defaults, ...doc}`. Every key
|
|
8
|
+
* a document carries is a complete top-level value, computed and validated by
|
|
9
|
+
* the registry that served it at check-in, so the device never validates and
|
|
10
|
+
* never merges deeper than one level. A document stamped for a version other
|
|
11
|
+
* than the one running is ignored, because it was computed against a different
|
|
12
|
+
* release's schema.
|
|
13
|
+
*
|
|
14
|
+
* The read answers with an object or throws: there is no "no config yet" value.
|
|
15
|
+
* A build that went through the tooling carries a manifest, the manifest
|
|
16
|
+
* carries the defaults, and an app that declares no config schema gets an empty
|
|
17
|
+
* object. The throw is reserved for a build with no readable manifest and
|
|
18
|
+
* nothing stored, which is a build that never went through `mikro deploy`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
#include <quickjs.h>
|
|
22
|
+
|
|
23
|
+
#include "mikrojs/ota_env.h"
|
|
24
|
+
|
|
25
|
+
namespace mikrojs {
|
|
26
|
+
|
|
27
|
+
class MIKOtaConfigReader {
|
|
28
|
+
public:
|
|
29
|
+
explicit MIKOtaConfigReader(const MIKOtaEnv* env) : env_(env) {}
|
|
30
|
+
~MIKOtaConfigReader();
|
|
31
|
+
MIKOtaConfigReader(const MIKOtaConfigReader&) = delete;
|
|
32
|
+
MIKOtaConfigReader& operator=(const MIKOtaConfigReader&) = delete;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the effective config. The caller owns the returned value, a fresh
|
|
36
|
+
* object on every call so an app that mutates what it got cannot reach the
|
|
37
|
+
* cached defaults. JS_EXCEPTION means nothing could be served at all.
|
|
38
|
+
*
|
|
39
|
+
* A FAILED store read is not absence. The store can fail because reading it
|
|
40
|
+
* allocates and heap pressure (a TLS handshake in flight) can starve it.
|
|
41
|
+
* Treating that as "no document" flips a live device onto the defaults for a
|
|
42
|
+
* beat, re-configuring its GPIO mid-handshake. A failed read serves whatever
|
|
43
|
+
* the last successful read returned, for exactly as long as reads keep
|
|
44
|
+
* failing; before the first success this runtime it serves the defaults
|
|
45
|
+
* alone. A genuine clear removes the key and reads back as an honest
|
|
46
|
+
* absence, so the two cases travel different channels and cannot be
|
|
47
|
+
* confused.
|
|
48
|
+
*
|
|
49
|
+
* THE FIRST READ OF EACH BOOT THAT SERVES THE STORED DOCUMENT is also where
|
|
50
|
+
* a running-release trial is accounted. A schema-valid document can still be
|
|
51
|
+
* fatal to the app (a GPIO this board does not have), and a config-caused
|
|
52
|
+
* crash can fire before the first check-in ever runs, but never before the
|
|
53
|
+
* app reads the document that causes it, which makes the read the one hook a
|
|
54
|
+
* crash loop cannot starve. Each such boot burns one trial boot; the budget
|
|
55
|
+
* spent, the previous document is restored, the failure recorded for the
|
|
56
|
+
* next check-in to report, and this very read returns the restored values,
|
|
57
|
+
* which is what breaks the loop. A boot that never serves the document (the
|
|
58
|
+
* store could not answer, the document is stamped for another version, its
|
|
59
|
+
* bytes do not decode) leaves the trial untouched: recording that the app
|
|
60
|
+
* read a document it never saw is what the accounting exists to prevent.
|
|
61
|
+
*/
|
|
62
|
+
JSValue Read(JSContext* ctx);
|
|
63
|
+
|
|
64
|
+
private:
|
|
65
|
+
/* The stored document for the running version, decoded, or JS_UNDEFINED
|
|
66
|
+
* when there is none to serve. Sets *out_failed when the store could not
|
|
67
|
+
* answer, which is not the same as having nothing stored. */
|
|
68
|
+
JSValue LoadDoc(JSContext* ctx, bool* out_failed);
|
|
69
|
+
/* Accounts this boot against a running-release trial. `serving_doc` says
|
|
70
|
+
* whether this read returns the stored document, which is the only case a
|
|
71
|
+
* trial boot may be charged to. False when the store could not answer, in
|
|
72
|
+
* which case nothing was written and the whole block retries on the next
|
|
73
|
+
* read. Sets *out_rolled_back when the budget was spent and the stored
|
|
74
|
+
* document changed under the caller. */
|
|
75
|
+
bool Account(bool serving_doc, bool* out_rolled_back);
|
|
76
|
+
/* The manifest defaults, parsed once and held for the runtime's lifetime.
|
|
77
|
+
* Borrowed: the caller must not free it. JS_UNDEFINED when the manifest
|
|
78
|
+
* could not be read or parsed, which is retried on the next call. */
|
|
79
|
+
JSValue Defaults(JSContext* ctx);
|
|
80
|
+
/* What to serve when the store could not answer. */
|
|
81
|
+
JSValue ServeFallback(JSContext* ctx);
|
|
82
|
+
JSValue ServeLastGood(JSContext* ctx) const;
|
|
83
|
+
void KeepLastGood(JSContext* ctx, JSValue value);
|
|
84
|
+
|
|
85
|
+
const MIKOtaEnv* env_;
|
|
86
|
+
/* Module state resets on every boot, so "once per boot" needs no clock. */
|
|
87
|
+
bool accounted_ = false;
|
|
88
|
+
bool has_last_good_ = false;
|
|
89
|
+
JSValue last_good_ = JS_UNDEFINED;
|
|
90
|
+
/* Held so the destructor can free last_good_ without being handed a ctx. */
|
|
91
|
+
JSContext* last_good_ctx_ = nullptr;
|
|
92
|
+
/* Cached on success only: a manifest that could not be read or parsed under
|
|
93
|
+
* heap pressure must be retried, not remembered as "no defaults". */
|
|
94
|
+
bool has_defaults_ = false;
|
|
95
|
+
JSValue defaults_ = JS_UNDEFINED;
|
|
96
|
+
/* The same, for the cached defaults. */
|
|
97
|
+
JSContext* defaults_ctx_ = nullptr;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
} // namespace mikrojs
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include <stdbool.h>
|
|
4
|
+
#include <stddef.h>
|
|
5
|
+
#include <stdint.h>
|
|
6
|
+
|
|
7
|
+
#ifdef __cplusplus
|
|
8
|
+
extern "C" {
|
|
9
|
+
#endif
|
|
10
|
+
|
|
11
|
+
typedef struct MIKOtaEnv MIKOtaEnv;
|
|
12
|
+
|
|
13
|
+
/* HTTP callbacks. All three fire *after* http_request has returned, from
|
|
14
|
+
* whatever drains the transport (the loop consumer on device, Tick() in the
|
|
15
|
+
* host tests) — never re-entrantly from inside the call itself. */
|
|
16
|
+
|
|
17
|
+
/* Response status, delivered before any body byte. The client needs it ahead
|
|
18
|
+
* of the body: a 206 means the Range was honoured and no prefix must be
|
|
19
|
+
* dropped, and a 401 body is discarded rather than staged. */
|
|
20
|
+
typedef void (*MIKOtaHttpHeadersCb)(void* user_data, int status);
|
|
21
|
+
typedef void (*MIKOtaHttpDataCb)(void* user_data, const uint8_t* data, size_t len);
|
|
22
|
+
/* Terminal, exactly once per request. `status` repeats the response status, or
|
|
23
|
+
* is 0 when the exchange never completed; `error_msg` is NULL on success. */
|
|
24
|
+
typedef void (*MIKOtaHttpDoneCb)(void* user_data, int status, const char* error_msg);
|
|
25
|
+
|
|
26
|
+
typedef struct MIKOtaHttpCallbacks {
|
|
27
|
+
MIKOtaHttpHeadersCb headers;
|
|
28
|
+
MIKOtaHttpDataCb data;
|
|
29
|
+
MIKOtaHttpDoneCb done;
|
|
30
|
+
void* user_data;
|
|
31
|
+
} MIKOtaHttpCallbacks;
|
|
32
|
+
|
|
33
|
+
typedef struct MIKOtaHttpRequest {
|
|
34
|
+
const char* url;
|
|
35
|
+
const char* method; /* "GET" or "POST" */
|
|
36
|
+
const char* const* header_keys;
|
|
37
|
+
const char* const* header_values;
|
|
38
|
+
size_t header_count;
|
|
39
|
+
const uint8_t* body;
|
|
40
|
+
size_t body_len;
|
|
41
|
+
uint32_t timeout_ms;
|
|
42
|
+
} MIKOtaHttpRequest;
|
|
43
|
+
|
|
44
|
+
/* Device identity */
|
|
45
|
+
typedef struct MIKDeviceIdentity {
|
|
46
|
+
char device_id[64];
|
|
47
|
+
char firmware_version[32];
|
|
48
|
+
char firmware_hash[65];
|
|
49
|
+
int bytecode_version;
|
|
50
|
+
} MIKDeviceIdentity;
|
|
51
|
+
|
|
52
|
+
/* Diagnostic info from reconcile */
|
|
53
|
+
typedef struct MIKOtaDiagnostic {
|
|
54
|
+
char reason[64];
|
|
55
|
+
char detail[128];
|
|
56
|
+
} MIKOtaDiagnostic;
|
|
57
|
+
|
|
58
|
+
/* Reconcile outcome */
|
|
59
|
+
typedef struct MIKOtaReconcileOutcome {
|
|
60
|
+
char installed[65]; /* SHA-256 hex or empty string if none */
|
|
61
|
+
bool reverted;
|
|
62
|
+
bool has_diagnostic;
|
|
63
|
+
MIKOtaDiagnostic diagnostic;
|
|
64
|
+
} MIKOtaReconcileOutcome;
|
|
65
|
+
|
|
66
|
+
/* Running build info */
|
|
67
|
+
typedef struct MIKOtaRunningBuild {
|
|
68
|
+
char checksum[65]; /* SHA-256 hex or empty string if none */
|
|
69
|
+
char version[32]; /* package.json version or empty string if none */
|
|
70
|
+
bool trial;
|
|
71
|
+
} MIKOtaRunningBuild;
|
|
72
|
+
|
|
73
|
+
/* Install error kind: 0 = corrupt, 1 = transient, 2 = oom */
|
|
74
|
+
enum {
|
|
75
|
+
MIK_OTA_ERR_CORRUPT = 0,
|
|
76
|
+
MIK_OTA_ERR_TRANSIENT = 1,
|
|
77
|
+
MIK_OTA_ERR_OOM = 2,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/* Outcome of a kv read. */
|
|
81
|
+
typedef enum MIKOtaKvStatus {
|
|
82
|
+
MIK_OTA_KV_OK = 0,
|
|
83
|
+
/* Nothing was ever stored under this key. */
|
|
84
|
+
MIK_OTA_KV_ABSENT = 1,
|
|
85
|
+
/* The store could not answer — out of heap, backend error. Says nothing
|
|
86
|
+
* about whether a value exists. */
|
|
87
|
+
MIK_OTA_KV_ERROR = 2,
|
|
88
|
+
} MIKOtaKvStatus;
|
|
89
|
+
|
|
90
|
+
/* Config slots */
|
|
91
|
+
typedef enum MIKOtaConfigSlot {
|
|
92
|
+
MIK_OTA_CFG_CURRENT = 0,
|
|
93
|
+
MIK_OTA_CFG_NEXT = 1,
|
|
94
|
+
MIK_OTA_CFG_PREV = 2,
|
|
95
|
+
} MIKOtaConfigSlot;
|
|
96
|
+
|
|
97
|
+
/* Config trial state */
|
|
98
|
+
typedef struct MIKOtaConfigTrial {
|
|
99
|
+
int left;
|
|
100
|
+
bool read;
|
|
101
|
+
} MIKOtaConfigTrial;
|
|
102
|
+
|
|
103
|
+
/* The spec caps a config rev at 64 characters (the reference registry emits
|
|
104
|
+
* 16); plus the NUL. A short buffer truncates, and a truncated echo never
|
|
105
|
+
* matches the current rev, so the registry re-sends the document forever. */
|
|
106
|
+
#define MIK_OTA_REV_MAX 65
|
|
107
|
+
|
|
108
|
+
/* Config error report */
|
|
109
|
+
typedef struct MIKOtaConfigErrorReport {
|
|
110
|
+
char rev[MIK_OTA_REV_MAX];
|
|
111
|
+
char message[256];
|
|
112
|
+
} MIKOtaConfigErrorReport;
|
|
113
|
+
|
|
114
|
+
/* Stored config document representation */
|
|
115
|
+
typedef struct MIKOtaStoredConfig {
|
|
116
|
+
char rev[MIK_OTA_REV_MAX]; /* empty string if none */
|
|
117
|
+
char version[32]; /* required */
|
|
118
|
+
uint8_t* doc_cbor; /* CBOR-encoded document bytes, or NULL if absent/clear */
|
|
119
|
+
size_t doc_cbor_len;
|
|
120
|
+
} MIKOtaStoredConfig;
|
|
121
|
+
|
|
122
|
+
/* MIKOtaEnv struct: the platform seam for OTA policy and client state machine */
|
|
123
|
+
struct MIKOtaEnv {
|
|
124
|
+
void* opaque;
|
|
125
|
+
|
|
126
|
+
/* ── HTTP ─────────────────────────────────────────────────────────── */
|
|
127
|
+
/* Start a request. Returns an opaque handle, or NULL when it could not be
|
|
128
|
+
* started (the client treats that as a failed round).
|
|
129
|
+
*
|
|
130
|
+
* Everything reachable from `req` and `cbs` is borrowed for the duration of
|
|
131
|
+
* this call only — url, headers and body included. A transport that outlives
|
|
132
|
+
* the call must copy what it needs before returning. */
|
|
133
|
+
void* (*http_request)(void* opaque, const MIKOtaHttpRequest* req,
|
|
134
|
+
const MIKOtaHttpCallbacks* cbs);
|
|
135
|
+
/* Abandon an in-flight request. No further callbacks fire for it. */
|
|
136
|
+
void (*http_cancel)(void* opaque, void* req_handle);
|
|
137
|
+
|
|
138
|
+
/* ── KV store (mik.sys namespace) ─────────────────────────────────── */
|
|
139
|
+
/* Read a blob. With out_buf NULL, writes the required size into inout_len.
|
|
140
|
+
*
|
|
141
|
+
* Absent and failed are different answers and must not be merged: the config
|
|
142
|
+
* reader falls back to the build's manifest defaults on absent, but holds the
|
|
143
|
+
* last document it read on failure. Collapsing the two flips a live device
|
|
144
|
+
* onto defaults for a beat whenever a read is starved of heap — which is
|
|
145
|
+
* exactly when a TLS handshake is in flight. */
|
|
146
|
+
MIKOtaKvStatus (*kv_get_blob)(void* opaque, const char* key, uint8_t* out_buf,
|
|
147
|
+
size_t* inout_len);
|
|
148
|
+
bool (*kv_set_blob)(void* opaque, const char* key, const uint8_t* data, size_t len);
|
|
149
|
+
/* NOTE: the string and integer getters are still two-valued. Their callers
|
|
150
|
+
* (the retry-budget store) read a failure as absence, which hands the budget
|
|
151
|
+
* back a boot early. Same class of bug as the blob case above; worth the same
|
|
152
|
+
* treatment when the policy's error handling is revisited. */
|
|
153
|
+
bool (*kv_get_str)(void* opaque, const char* key, char* out_buf, size_t max_len);
|
|
154
|
+
bool (*kv_set_str)(void* opaque, const char* key, const char* val);
|
|
155
|
+
bool (*kv_get_i32)(void* opaque, const char* key, int32_t* out_val);
|
|
156
|
+
bool (*kv_set_i32)(void* opaque, const char* key, int32_t val);
|
|
157
|
+
bool (*kv_remove)(void* opaque, const char* key);
|
|
158
|
+
|
|
159
|
+
/* ── Install ops (firmware mik_ota.cpp) ────────────────────────────── */
|
|
160
|
+
bool (*stage_begin)(void* opaque, const char* checksum, size_t size, size_t* out_resume_offset,
|
|
161
|
+
char* err_buf, size_t err_len);
|
|
162
|
+
bool (*stage_write)(void* opaque, const uint8_t* data, size_t len, char* err_buf,
|
|
163
|
+
size_t err_len);
|
|
164
|
+
bool (*stage_finish)(void* opaque, int trial_boots, bool require_confirm, bool install_now,
|
|
165
|
+
char* err_buf, size_t err_len, int* out_err_kind);
|
|
166
|
+
void (*stage_abort)(void* opaque);
|
|
167
|
+
void (*mark_valid)(void* opaque);
|
|
168
|
+
bool (*revert)(void* opaque, char* err_buf, size_t err_len);
|
|
169
|
+
bool (*running)(void* opaque, MIKOtaRunningBuild* out_running);
|
|
170
|
+
void (*reconcile)(void* opaque, MIKOtaReconcileOutcome* out_outcome);
|
|
171
|
+
|
|
172
|
+
/* ── System / Identity ────────────────────────────────────────────── */
|
|
173
|
+
bool (*identity)(void* opaque, MIKDeviceIdentity* out_id);
|
|
174
|
+
bool (*storage_free)(void* opaque, size_t* out_free); /* returns true if supported */
|
|
175
|
+
bool (*get_device_name)(void* opaque, int* out_rev, char* out_name, size_t name_len);
|
|
176
|
+
void (*set_device_name)(void* opaque, int rev, const char* name);
|
|
177
|
+
void (*restart)(void* opaque);
|
|
178
|
+
int64_t (*monotonic_ms)(void* opaque);
|
|
179
|
+
double (*random_fraction)(void* opaque); /* uniform [0, 1) */
|
|
180
|
+
void (*log)(void* opaque, int level, const char* fmt, ...);
|
|
181
|
+
bool (*read_app_version)(void* opaque, char* out_version, size_t ver_len);
|
|
182
|
+
/* The running build's mikro.app.json, as text. Returns a malloc'd
|
|
183
|
+
* NUL-terminated string the caller frees, or NULL when there is no manifest
|
|
184
|
+
* — which is how a build that never went through deploy reads. Allocated
|
|
185
|
+
* rather than copied into a caller buffer because a manifest carries the
|
|
186
|
+
* build's whole configDefaults and has no useful fixed bound. */
|
|
187
|
+
char* (*read_manifest)(void* opaque);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
#ifdef __cplusplus
|
|
191
|
+
}
|
|
192
|
+
#endif
|