@tikoci/quickchr 0.4.1 → 0.4.2
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/DESIGN.md +4 -4
- package/README.md +19 -0
- package/package.json +1 -1
- package/src/cli/index.ts +81 -31
- package/src/index.ts +17 -0
- package/src/lib/net.ts +65 -41
- package/src/lib/quickchr.ts +1 -1
- package/src/lib/types.ts +2 -1
- package/src/lib/versions.ts +107 -15
package/DESIGN.md
CHANGED
|
@@ -46,13 +46,13 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
46
46
|
|
|
47
47
|
How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked in `BACKLOG.md` (boot-respawn watch-item).
|
|
48
48
|
|
|
49
|
-
9. **Resilient downloads
|
|
49
|
+
9. **Resilient downloads: normal `fetch` first, public-DNS IPv4 as a failback** — All fetches to MikroTik's `upgrade`/`download` hosts go through `fetchResilient()` (`src/lib/net.ts`), never bare `fetch()`. The intent is narrow: a **plain `fetch` is the path** — dual-stack (happy eyeballs), on par with curl/most tools, and honoring local DNS (`/etc/hosts` pins, VPN/split-horizon, mirror redirects, IPv6-only egress) — and we layer one **failback** beneath it to ride out *DNS misconfiguration somewhere in the environment*, wherever it comes from. We deliberately do **not** invert this (public-DNS+IPv4 first) — that would override local DNS on every machine and could introduce its own failures (e.g. an IPv4-only connect on an IPv6-only network). Normal first keeps us on par with most tools; the failback only adds smarts when the normal path actually breaks.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
The trigger is precise: only when the normal `fetch` throws a **connection-class error** (`isConnectionFailure()` — the `ConnectionRefused`/`FailedToOpenSocket`/`ECONNREFUSED`-family codes, or a `TypeError` carrying Bun's `errno: 0` connect-failure marker; *not* aborts or any HTTP response) does `fetchResilient` retry. It resolves the A record by querying public DNS **directly** (a `dns.Resolver` with `setServers([1.1.1.1, 8.8.8.8, 1.0.0.1])`, bounded by a 3 s timeout so a blocked resolver doesn't stall), then connects to the IPv4 literal preserving the `Host` header and TLS SNI so certificate validation still passes. If public DNS also has no answer it surfaces the original failure. HTTP responses (incl. 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through unchanged, never retried; a `TypeError` retries only with Bun's `errno: 0` connect-failure marker, so an unrelated `TypeError` (a real bug) surfaces immediately.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
*Motivating incident (don't over-fit to it):* the failback was prompted by GitHub-hosted runners whose system resolver returned `ESERVFAIL` for `*.mikrotik.com` — slowly (2–26 s) — via *both* `getaddrinfo` and c-ares-over-`resolv.conf`, so a plain `fetch` either timed out resolving or (when the stub handed back only the unreachable AAAA) failed with Bun's `errno: 0` `ConnectionRefused` / `FailedToOpenSocket`; a direct public-resolver query answered in ~10 ms. That was one observed symptom of a broken resolver, and its deeper root cause is still unknown — so the design is framed as *generic resilience to a misconfigured/transient resolver*, not a CI-specific patch. The earlier "IPv6 happy-eyeballs" theory was a red herring (the symptom was DNS, not IPv6 egress). On-runner probe (2026-06-16): `lookup({family:4})` → `ESERVFAIL` ~9 s; `lookup({all})` → `ESERVFAIL`/`ETIMEOUT` 22–26 s; `resolve4` (resolv.conf) → `ESERVFAIL` 2–22 s; `Resolver([1.1.1.1,8.8.8.8]).resolve4` → OK ~10 ms.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
**Sister-project routing.** Because `fetchResilient` already absorbs a flaky resolver, a download/version-resolve failure should not be papered over downstream with `/etc/hosts` pins or IPv6 toggles in a consuming repo's workflow — fix it (or extend the failback) here in quickchr. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). The failback is best-effort, not a guarantee: it recovers connection-class DNS failures, not arbitrary network breakage. Covered by `test/unit/net.test.ts`.
|
|
56
56
|
|
|
57
57
|
## Port Layout
|
|
58
58
|
|
package/README.md
CHANGED
|
@@ -331,6 +331,25 @@ console.log(chr.state.bootDiskFormat); // "qcow2"
|
|
|
331
331
|
console.log(chr.state.extraDisks); // ["512M", "2G"]
|
|
332
332
|
```
|
|
333
333
|
|
|
334
|
+
### Version & channel API
|
|
335
|
+
|
|
336
|
+
Pick which CHRs are worth booting (handy for CI matrices) without scraping CLI text:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
import { resolveActiveChannels, resolveAllVersions } from "@tikoci/quickchr";
|
|
340
|
+
|
|
341
|
+
await resolveAllVersions();
|
|
342
|
+
// { stable: "7.23.1", "long-term": "7.21.4", testing: "7.23rc4", development: "7.24beta2" }
|
|
343
|
+
|
|
344
|
+
await resolveActiveChannels();
|
|
345
|
+
// ["stable", "long-term", "development"]
|
|
346
|
+
// released channels always, plus any pre-release at/ahead of stable
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`compareRouterOsVersion` orders pre-release suffixes (`7.24beta2` < `7.24rc1` <
|
|
350
|
+
`7.24` < `7.24.1`). The same map is available from the CLI via
|
|
351
|
+
`quickchr version --json`. See [MANUAL.md](./MANUAL.md) → *Version & channel helpers*.
|
|
352
|
+
|
|
334
353
|
### Use in Tests
|
|
335
354
|
|
|
336
355
|
```typescript
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -179,7 +179,7 @@ async function main() {
|
|
|
179
179
|
await cmdCompletions(args.slice(1));
|
|
180
180
|
break;
|
|
181
181
|
case "doctor":
|
|
182
|
-
await cmdDoctor();
|
|
182
|
+
await cmdDoctor(args.slice(1));
|
|
183
183
|
break;
|
|
184
184
|
case "logs":
|
|
185
185
|
await cmdLogs(args.slice(1));
|
|
@@ -194,7 +194,7 @@ async function main() {
|
|
|
194
194
|
case "version":
|
|
195
195
|
case "--version":
|
|
196
196
|
case "-v":
|
|
197
|
-
await cmdVersion();
|
|
197
|
+
await cmdVersion(args.slice(1));
|
|
198
198
|
break;
|
|
199
199
|
case "help":
|
|
200
200
|
case "--help":
|
|
@@ -2429,38 +2429,37 @@ async function cmdCache(argv: string[]) {
|
|
|
2429
2429
|
}
|
|
2430
2430
|
}
|
|
2431
2431
|
|
|
2432
|
-
async function cmdDoctor() {
|
|
2432
|
+
async function cmdDoctor(argv: string[] = []) {
|
|
2433
|
+
const { flags } = parseFlags(argv);
|
|
2434
|
+
const asJson = flagBool(flags, "json");
|
|
2433
2435
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
2434
|
-
const { statusIcon, bold } = await import("./format.ts");
|
|
2435
2436
|
|
|
2436
2437
|
const result = await QuickCHR.doctor();
|
|
2437
2438
|
|
|
2439
|
+
if (asJson) {
|
|
2440
|
+
// Best-effort: cache entries older than current long-term (offline → empty).
|
|
2441
|
+
const staleImages = await findStaleCacheImages();
|
|
2442
|
+
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, staleImages }, null, 2));
|
|
2443
|
+
if (!result.ok) process.exit(1);
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
const { statusIcon, bold } = await import("./format.ts");
|
|
2438
2448
|
console.log(bold("quickchr doctor\n"));
|
|
2439
2449
|
for (const check of result.checks) {
|
|
2440
2450
|
console.log(`${statusIcon(check.status)} ${check.label}: ${check.detail}`);
|
|
2441
2451
|
}
|
|
2442
|
-
|
|
2443
2452
|
console.log();
|
|
2444
2453
|
|
|
2445
|
-
// Best-effort
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
|
|
2455
|
-
});
|
|
2456
|
-
if (stale.length > 0) {
|
|
2457
|
-
console.log(`Note: ${stale.length} cached image(s) older than current long-term (${longTerm}):`);
|
|
2458
|
-
for (const e of stale) console.log(` - ${e.basename} (${e.version})`);
|
|
2459
|
-
console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
|
|
2460
|
-
console.log();
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
} catch { /* offline or no cache — silent */ }
|
|
2454
|
+
// Best-effort, after the checks print so the network lookup never delays them.
|
|
2455
|
+
const staleImages = await findStaleCacheImages();
|
|
2456
|
+
if (staleImages.length > 0) {
|
|
2457
|
+
const longTerm = staleImages[0]?.olderThan;
|
|
2458
|
+
console.log(`Note: ${staleImages.length} cached image(s) older than current long-term (${longTerm}):`);
|
|
2459
|
+
for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
|
|
2460
|
+
console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
|
|
2461
|
+
console.log();
|
|
2462
|
+
}
|
|
2464
2463
|
|
|
2465
2464
|
if (result.ok) {
|
|
2466
2465
|
console.log("All checks passed.");
|
|
@@ -2470,12 +2469,50 @@ async function cmdDoctor() {
|
|
|
2470
2469
|
}
|
|
2471
2470
|
}
|
|
2472
2471
|
|
|
2473
|
-
|
|
2472
|
+
interface StaleCacheImage {
|
|
2473
|
+
basename: string;
|
|
2474
|
+
version: string;
|
|
2475
|
+
/** The current long-term version this image is older than. */
|
|
2476
|
+
olderThan: string;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/** Cached images older than the current long-term release. Empty when offline or no cache. */
|
|
2480
|
+
async function findStaleCacheImages(): Promise<StaleCacheImage[]> {
|
|
2481
|
+
try {
|
|
2482
|
+
const { listCacheEntries } = await import("../lib/cache.ts");
|
|
2483
|
+
const { resolveVersion, compareRouterOsVersion } = await import("../lib/versions.ts");
|
|
2484
|
+
const entries = listCacheEntries();
|
|
2485
|
+
if (entries.length === 0) return [];
|
|
2486
|
+
const longTerm = await resolveVersion("long-term");
|
|
2487
|
+
return entries
|
|
2488
|
+
.filter((e) => {
|
|
2489
|
+
if (e.version === "unknown" || e.inUse) return false;
|
|
2490
|
+
try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
|
|
2491
|
+
})
|
|
2492
|
+
.map((e) => ({ basename: e.basename, version: e.version, olderThan: longTerm }));
|
|
2493
|
+
} catch {
|
|
2494
|
+
return []; // offline or no cache
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
async function cmdVersion(argv: string[] = []) {
|
|
2499
|
+
const { flags } = parseFlags(argv);
|
|
2500
|
+
const asJson = flagBool(flags, "json");
|
|
2501
|
+
const { resolveAllVersions } = await import("../lib/versions.ts");
|
|
2502
|
+
|
|
2503
|
+
if (asJson) {
|
|
2504
|
+
// Stable machine-readable contract: { channel: version }. Offline → {}.
|
|
2505
|
+
let versions: Record<string, string> = {};
|
|
2506
|
+
try {
|
|
2507
|
+
versions = await resolveAllVersions();
|
|
2508
|
+
} catch { /* offline — emit empty object */ }
|
|
2509
|
+
console.log(JSON.stringify(versions));
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2474
2513
|
const pkg = await import("../../package.json");
|
|
2475
2514
|
console.log(`quickchr ${pkg.version}`);
|
|
2476
|
-
|
|
2477
2515
|
try {
|
|
2478
|
-
const { resolveAllVersions } = await import("../lib/versions.ts");
|
|
2479
2516
|
const versions = await resolveAllVersions();
|
|
2480
2517
|
console.log("\nLatest RouterOS versions:");
|
|
2481
2518
|
for (const [channel, version] of Object.entries(versions)) {
|
|
@@ -2522,8 +2559,8 @@ Commands:
|
|
|
2522
2559
|
cache [list|prune|clear] Manage cached CHR images
|
|
2523
2560
|
completions Manage shell completions (Tab completion)
|
|
2524
2561
|
logs <name> Tail the QEMU log for an instance
|
|
2525
|
-
doctor
|
|
2526
|
-
version
|
|
2562
|
+
doctor [--json] Check prerequisites
|
|
2563
|
+
version [--json] Show version info
|
|
2527
2564
|
help [command] Show help
|
|
2528
2565
|
|
|
2529
2566
|
Environment:
|
|
@@ -2707,11 +2744,14 @@ Print environment variables for subprocesses that connect to a running CHR.
|
|
|
2707
2744
|
--json Output the env map as JSON instead of KEY=value lines.`);
|
|
2708
2745
|
break;
|
|
2709
2746
|
case "doctor":
|
|
2710
|
-
console.log(`quickchr doctor
|
|
2747
|
+
console.log(`quickchr doctor [--json]
|
|
2711
2748
|
|
|
2712
2749
|
Check system prerequisites: QEMU binaries, firmware, acceleration,
|
|
2713
2750
|
sshpass (for package upload), qemu-img (for disk operations), data directories,
|
|
2714
|
-
and cached images. Also reports the current shell and completion install status
|
|
2751
|
+
and cached images. Also reports the current shell and completion install status.
|
|
2752
|
+
|
|
2753
|
+
Options:
|
|
2754
|
+
--json Emit { ok, checks, staleImages } as JSON. Exit code still reflects ok.`);
|
|
2715
2755
|
break;
|
|
2716
2756
|
case "logs":
|
|
2717
2757
|
console.log(`quickchr logs <name> [options]
|
|
@@ -2842,6 +2882,16 @@ Credential resolution order (highest priority first):
|
|
|
2842
2882
|
Omit to see a list of instances.
|
|
2843
2883
|
--all Remove all instances.`);
|
|
2844
2884
|
break;
|
|
2885
|
+
case "version":
|
|
2886
|
+
console.log(`quickchr version [--json]
|
|
2887
|
+
|
|
2888
|
+
Show the quickchr version and the latest RouterOS version per channel.
|
|
2889
|
+
|
|
2890
|
+
Options:
|
|
2891
|
+
--json Emit the latest RouterOS versions as a { channel: version } object
|
|
2892
|
+
(stable, long-term, testing, development). Offline emits {}.
|
|
2893
|
+
Omits the quickchr version line — query package.json for that.`);
|
|
2894
|
+
break;
|
|
2845
2895
|
case "networks":
|
|
2846
2896
|
case "net":
|
|
2847
2897
|
showNetworksHelp();
|
package/src/index.ts
CHANGED
|
@@ -130,3 +130,20 @@ export { parseForwardSpec } from "./lib/forward-spec.ts";
|
|
|
130
130
|
// Logging
|
|
131
131
|
export { createLogger } from "./lib/log.ts";
|
|
132
132
|
export type { ProgressLogger } from "./lib/log.ts";
|
|
133
|
+
|
|
134
|
+
// Version & channel utilities
|
|
135
|
+
export { CHANNELS } from "./lib/types.ts";
|
|
136
|
+
export {
|
|
137
|
+
resolveVersion,
|
|
138
|
+
resolveAllVersions,
|
|
139
|
+
isValidVersion,
|
|
140
|
+
parseVersionParts,
|
|
141
|
+
compareRouterOsVersion,
|
|
142
|
+
isProvisioningSupportedVersion,
|
|
143
|
+
channelMaturity,
|
|
144
|
+
classifyChannels,
|
|
145
|
+
selectActiveChannels,
|
|
146
|
+
resolveChannelStatuses,
|
|
147
|
+
resolveActiveChannels,
|
|
148
|
+
} from "./lib/versions.ts";
|
|
149
|
+
export type { ChannelMaturity, ChannelStatus } from "./lib/versions.ts";
|
package/src/lib/net.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resilient HTTP fetch for MikroTik's download hosts.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Primary path: a plain `fetch` on the system resolver. This is tried first,
|
|
5
|
+
* accepting the latency cost when it fails — usually small, but up to the
|
|
6
|
+
* resolver's own failure time (the broken CI stub below took 2–26 s). It is
|
|
7
|
+
* dual-stack (happy eyeballs) and on par with curl/most tools, and it honors
|
|
8
|
+
* local DNS configuration —
|
|
9
|
+
* `/etc/hosts` pins, VPN/split-horizon DNS, mirror redirects, IPv6-only egress —
|
|
10
|
+
* which a forced public-DNS+IPv4 path would silently override and could itself
|
|
11
|
+
* turn into a fresh failure mode. So we stay "normal" unless the normal path
|
|
12
|
+
* actually breaks.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* Fallback: some environments (notably GitHub-hosted CI runners, observed
|
|
15
|
+
* 2026-06) have a broken/slow stub resolver that returns `ESERVFAIL` — slowly,
|
|
16
|
+
* 2 to 26 s — for `upgrade.mikrotik.com` / `download.mikrotik.com`, or hands back
|
|
17
|
+
* only an unreachable AAAA, surfacing as Bun's `errno: 0` ConnectionRefused /
|
|
18
|
+
* FailedToOpenSocket. When the normal fetch fails *that* way (a connection-class
|
|
19
|
+
* error), we retry by resolving the A record against a public DNS server
|
|
20
|
+
* directly (~10 ms), bypassing the host's resolv.conf, and connecting to the
|
|
21
|
+
* IPv4 literal with the `Host` header and TLS SNI preserved so certificate
|
|
22
|
+
* validation still passes.
|
|
23
|
+
*
|
|
24
|
+
* Only connection-class failures trigger the fallback; HTTP responses (incl.
|
|
25
|
+
* 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through
|
|
26
|
+
* unchanged and are never retried. (A low-level connect `ETIMEDOUT` is a
|
|
27
|
+
* connection-class failure and *is* retried — see `isConnectionFailure`.)
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
import { promises as dns } from "node:dns";
|
|
@@ -28,13 +36,14 @@ const DNS_TIMEOUT_MS = 3000;
|
|
|
28
36
|
* True for the connection-class failures raised when a socket cannot be opened
|
|
29
37
|
* (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
|
|
30
38
|
* ConnectionRefused / FailedToOpenSocket against an unreachable address), plus
|
|
31
|
-
* the standard connect errors. Excludes aborts
|
|
32
|
-
* (those carry a Response and never throw
|
|
33
|
-
*
|
|
39
|
+
* the standard connect errors. Excludes `AbortError` (aborts/`AbortSignal`
|
|
40
|
+
* timeouts) and HTTP-level outcomes (those carry a Response and never throw
|
|
41
|
+
* here). Used to decide whether to fall back from the normal fetch to the
|
|
42
|
+
* public-DNS IPv4 attempt.
|
|
34
43
|
*/
|
|
35
44
|
export function isConnectionFailure(err: unknown): boolean {
|
|
36
45
|
if (!err || typeof err !== "object") return false;
|
|
37
|
-
const e = err as { name?: string; code?: string; cause?: { code?: string } };
|
|
46
|
+
const e = err as { name?: string; code?: string; errno?: number; cause?: { code?: string } };
|
|
38
47
|
if (e.name === "AbortError") return false;
|
|
39
48
|
const code = e.code ?? e.cause?.code;
|
|
40
49
|
if (
|
|
@@ -49,10 +58,12 @@ export function isConnectionFailure(err: unknown): boolean {
|
|
|
49
58
|
) {
|
|
50
59
|
return true;
|
|
51
60
|
}
|
|
52
|
-
// Bun
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
61
|
+
// Bun has also surfaced low-level connect failures as a `TypeError` carrying
|
|
62
|
+
// `errno: 0` (its connect-failure marker; the typed `code`s above catch the
|
|
63
|
+
// current form — Bun 1.3 throws an `Error` with `code: "ConnectionRefused"`,
|
|
64
|
+
// confirmed by probe). Gate on that marker so an unrelated `TypeError` (a real
|
|
65
|
+
// bug) surfaces immediately instead of triggering a pointless DNS retry.
|
|
66
|
+
return e.name === "TypeError" && e.errno === 0;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/** Rewrite a URL to target an explicit IPv4 literal, preserving port/path/query. */
|
|
@@ -64,10 +75,11 @@ export function toIpv4Url(url: string, address: string): string {
|
|
|
64
75
|
|
|
65
76
|
/**
|
|
66
77
|
* Resolve a host's A record via public DNS servers directly, bypassing the
|
|
67
|
-
* host's resolv.conf
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
78
|
+
* host's resolv.conf. Used only as the fallback path, after a normal fetch has
|
|
79
|
+
* already failed with a connection-class error (e.g. a broken stub resolver on
|
|
80
|
+
* CI runners). Returns undefined — never throws — when public DNS is unreachable
|
|
81
|
+
* or has no answer, so the caller can surface the original failure. Bounded by
|
|
82
|
+
* DNS_TIMEOUT_MS so a blocked resolver does not stall the request.
|
|
71
83
|
*/
|
|
72
84
|
async function resolveIpv4(host: string): Promise<string | undefined> {
|
|
73
85
|
const resolver = new dns.Resolver();
|
|
@@ -88,6 +100,12 @@ async function resolveIpv4(host: string): Promise<string | undefined> {
|
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Fetch `url` but connect to an explicit IPv4 `address`, keeping HTTP and TLS
|
|
105
|
+
* pointed at the original host: the `Host` header and TLS SNI (`serverName`) are
|
|
106
|
+
* set to the URL's hostname so virtual-hosting routing and certificate
|
|
107
|
+
* validation still pass against the IP literal. The failback transport.
|
|
108
|
+
*/
|
|
91
109
|
async function fetchOverIpv4(
|
|
92
110
|
url: string,
|
|
93
111
|
address: string,
|
|
@@ -99,25 +117,31 @@ async function fetchOverIpv4(
|
|
|
99
117
|
return fetch(toIpv4Url(url, address), {
|
|
100
118
|
...init,
|
|
101
119
|
headers,
|
|
102
|
-
|
|
120
|
+
// serverName last: it must pin to the real host for cert validation against
|
|
121
|
+
// the IP literal — a caller-provided init.tls.serverName must not override it.
|
|
122
|
+
tls: { ...init?.tls, serverName: host },
|
|
103
123
|
});
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/**
|
|
107
|
-
* `fetch()` that
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
127
|
+
* `fetch()` that tries the system resolver first (a normal dual-stack fetch)
|
|
128
|
+
* and, only when that fails with a connection-class error, falls back to
|
|
129
|
+
* resolving via public DNS and connecting over the IPv4 literal — so it survives
|
|
130
|
+
* a broken/slow system resolver (e.g. GitHub-hosted CI runners) without
|
|
131
|
+
* overriding local DNS in the common case. Behaves like `fetch` for HTTP
|
|
132
|
+
* responses and aborts.
|
|
111
133
|
*/
|
|
112
134
|
export async function fetchResilient(url: string, init?: BunFetchRequestInit): Promise<Response> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
try {
|
|
136
|
+
return await fetch(url, init);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// Only a connection-class failure (e.g. a broken/slow stub resolver, or an
|
|
139
|
+
// unreachable AAAA) is worth the public-DNS workaround. Aborts (AbortError)
|
|
140
|
+
// and everything else propagate unchanged.
|
|
141
|
+
if (!isConnectionFailure(err)) throw err;
|
|
142
|
+
const address = await resolveIpv4(new URL(url).hostname);
|
|
143
|
+
// Public DNS can't help (unreachable / no answer) — surface the original failure.
|
|
144
|
+
if (address === undefined) throw err;
|
|
145
|
+
return fetchOverIpv4(url, address, init);
|
|
121
146
|
}
|
|
122
|
-
return fetch(url, init);
|
|
123
147
|
}
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -1169,7 +1169,7 @@ export class QuickCHR {
|
|
|
1169
1169
|
// Resolve version
|
|
1170
1170
|
let version: string;
|
|
1171
1171
|
if (opts.version) {
|
|
1172
|
-
if ((CHANNELS as string[]).includes(opts.version)) {
|
|
1172
|
+
if ((CHANNELS as readonly string[]).includes(opts.version)) {
|
|
1173
1173
|
logger.warn(
|
|
1174
1174
|
`'${opts.version}' is a channel name, not a version — passed as 'version' but resolved as a channel. Use 'channel: "${opts.version}"' instead to make this explicit.`,
|
|
1175
1175
|
);
|
package/src/lib/types.ts
CHANGED
|
@@ -7,7 +7,8 @@ import type { ProgressLogger } from "./log.ts";
|
|
|
7
7
|
// --- Version & Architecture ---
|
|
8
8
|
|
|
9
9
|
export type Channel = "stable" | "long-term" | "testing" | "development";
|
|
10
|
-
|
|
10
|
+
/** Canonical channel order. Frozen so neither library code nor consumers can mutate it. */
|
|
11
|
+
export const CHANNELS: readonly Channel[] = Object.freeze(["stable", "long-term", "testing", "development"] as Channel[]);
|
|
11
12
|
|
|
12
13
|
export type Arch = "arm64" | "x86";
|
|
13
14
|
export const ARCHES: Arch[] = ["arm64", "x86"];
|
package/src/lib/versions.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Arch, Channel } from "./types.ts";
|
|
6
|
-
import { QuickCHRError } from "./types.ts";
|
|
6
|
+
import { CHANNELS, QuickCHRError } from "./types.ts";
|
|
7
7
|
import { fetchResilient } from "./net.ts";
|
|
8
8
|
|
|
9
9
|
const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
|
|
@@ -92,9 +92,8 @@ export async function resolveVersion(channel: Channel): Promise<string> {
|
|
|
92
92
|
|
|
93
93
|
/** Fetch latest versions for all channels in parallel. */
|
|
94
94
|
export async function resolveAllVersions(): Promise<Record<Channel, string>> {
|
|
95
|
-
const channels: Channel[] = ["stable", "long-term", "testing", "development"];
|
|
96
95
|
const results = await Promise.all(
|
|
97
|
-
|
|
96
|
+
CHANNELS.map(async (ch) => [ch, await resolveVersion(ch)] as const),
|
|
98
97
|
);
|
|
99
98
|
return Object.fromEntries(results) as Record<Channel, string>;
|
|
100
99
|
}
|
|
@@ -104,27 +103,58 @@ export function isValidVersion(version: string): boolean {
|
|
|
104
103
|
return /^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(version);
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
/**
|
|
107
|
+
* RouterOS pre-release ordering rank. For a given X.Y, a beta is older than an rc,
|
|
108
|
+
* which is older than the final release: beta < rc < release.
|
|
109
|
+
*/
|
|
110
|
+
const STAGE_RANK = { beta: 0, rc: 1, release: 2 } as const;
|
|
111
|
+
|
|
112
|
+
interface ParsedVersion {
|
|
113
|
+
major: number;
|
|
114
|
+
minor: number;
|
|
115
|
+
patch: number;
|
|
116
|
+
/** Maturity rank within X.Y: beta=0 < rc=1 < release=2. */
|
|
117
|
+
stage: (typeof STAGE_RANK)[keyof typeof STAGE_RANK];
|
|
118
|
+
/** The N in betaN/rcN (0 for release builds). */
|
|
119
|
+
stageNum: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Parse a RouterOS version into numeric parts plus pre-release stage. */
|
|
123
|
+
function parseVersion(version: string): ParsedVersion {
|
|
124
|
+
const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:(beta|rc)(\d+))?$/);
|
|
110
125
|
if (!match) {
|
|
111
126
|
throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${version}`);
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
const stageName = match[4] as "beta" | "rc" | undefined;
|
|
130
|
+
return {
|
|
131
|
+
major: Number.parseInt(match[1] ?? "0", 10),
|
|
132
|
+
minor: Number.parseInt(match[2] ?? "0", 10),
|
|
133
|
+
patch: Number.parseInt(match[3] ?? "0", 10),
|
|
134
|
+
stage: stageName ? STAGE_RANK[stageName] : STAGE_RANK.release,
|
|
135
|
+
stageNum: match[5] ? Number.parseInt(match[5], 10) : 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Parse a RouterOS version into numeric parts. beta/rc suffixes are ignored. */
|
|
140
|
+
export function parseVersionParts(version: string): [number, number, number] {
|
|
141
|
+
const { major, minor, patch } = parseVersion(version);
|
|
117
142
|
return [major, minor, patch];
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Compare two RouterOS versions semantically, including pre-release suffixes:
|
|
147
|
+
* `7.24beta2` < `7.24rc1` < `7.24` < `7.24.1`. Returns <0, 0, or >0.
|
|
148
|
+
*/
|
|
121
149
|
export function compareRouterOsVersion(left: string, right: string): number {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
150
|
+
const a = parseVersion(left);
|
|
151
|
+
const b = parseVersion(right);
|
|
124
152
|
|
|
125
|
-
if (
|
|
126
|
-
if (
|
|
127
|
-
return
|
|
153
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
154
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
155
|
+
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
156
|
+
if (a.stage !== b.stage) return a.stage - b.stage;
|
|
157
|
+
return a.stageNum - b.stageNum;
|
|
128
158
|
}
|
|
129
159
|
|
|
130
160
|
/** True if RouterOS version is supported for quickchr provisioning mutations. */
|
|
@@ -180,3 +210,65 @@ export function generateMachineName(
|
|
|
180
210
|
}
|
|
181
211
|
return `${base}-${n}`;
|
|
182
212
|
}
|
|
213
|
+
|
|
214
|
+
// --- Channel recency ---
|
|
215
|
+
|
|
216
|
+
/** Channels that ship as finished releases vs. moving pre-release builds. */
|
|
217
|
+
const RELEASED_CHANNELS: readonly Channel[] = ["stable", "long-term"];
|
|
218
|
+
|
|
219
|
+
export type ChannelMaturity = "released" | "prerelease";
|
|
220
|
+
|
|
221
|
+
/** Classify a channel as a finished release line or a pre-release line. */
|
|
222
|
+
export function channelMaturity(channel: Channel): ChannelMaturity {
|
|
223
|
+
return RELEASED_CHANNELS.includes(channel) ? "released" : "prerelease";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface ChannelStatus {
|
|
227
|
+
channel: Channel;
|
|
228
|
+
/** Resolved version for the channel, e.g. "7.24beta2". */
|
|
229
|
+
version: string;
|
|
230
|
+
maturity: ChannelMaturity;
|
|
231
|
+
/** True if this channel's version is at or ahead of the current stable release. */
|
|
232
|
+
aheadOfStable: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Classify each channel by current recency (pure — no network).
|
|
237
|
+
* `aheadOfStable` is always measured against the `stable` version in `versions`.
|
|
238
|
+
*/
|
|
239
|
+
export function classifyChannels(versions: Record<Channel, string>): ChannelStatus[] {
|
|
240
|
+
const stableVersion = versions.stable;
|
|
241
|
+
return CHANNELS.map((channel) => ({
|
|
242
|
+
channel,
|
|
243
|
+
version: versions[channel],
|
|
244
|
+
maturity: channelMaturity(channel),
|
|
245
|
+
aheadOfStable: compareRouterOsVersion(versions[channel], stableVersion) >= 0,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Select the channels currently worth booting (pure — no network): every released
|
|
251
|
+
* channel unconditionally, plus any pre-release channel at or ahead of the reference
|
|
252
|
+
* channel's version (default reference: `stable`). This answers "what's worth booting,"
|
|
253
|
+
* never "what must pass" — gate policy stays with the consumer.
|
|
254
|
+
*/
|
|
255
|
+
export function selectActiveChannels(
|
|
256
|
+
versions: Record<Channel, string>,
|
|
257
|
+
opts: { aheadOf?: Channel } = {},
|
|
258
|
+
): Channel[] {
|
|
259
|
+
const referenceVersion = versions[opts.aheadOf ?? "stable"];
|
|
260
|
+
return CHANNELS.filter((channel) => {
|
|
261
|
+
if (channelMaturity(channel) === "released") return true;
|
|
262
|
+
return compareRouterOsVersion(versions[channel], referenceVersion) >= 0;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Fetch all channel versions and classify each by current recency. */
|
|
267
|
+
export async function resolveChannelStatuses(): Promise<ChannelStatus[]> {
|
|
268
|
+
return classifyChannels(await resolveAllVersions());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Fetch all channel versions and return the channels currently worth booting. */
|
|
272
|
+
export async function resolveActiveChannels(opts: { aheadOf?: Channel } = {}): Promise<Channel[]> {
|
|
273
|
+
return selectActiveChannels(await resolveAllVersions(), opts);
|
|
274
|
+
}
|