alexandr 0.3.0 → 0.3.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/README.md +2 -1
- package/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/commands.js +102 -12
- package/src/completion.js +1 -0
- package/src/instance.js +46 -1
- package/src/updater.js +276 -0
- package/templates/docker-compose.yml +38 -0
- package/templates/env.example +11 -0
package/README.md
CHANGED
|
@@ -65,7 +65,8 @@ rotation), `up` and `update` offer a quick re-sign-in on the spot — or run
|
|
|
65
65
|
| `alexandr connect` | Print the `alexandr://connect` link / paste-string |
|
|
66
66
|
| `alexandr link` | Re-link this runtime to your account (`--force` re-registers) |
|
|
67
67
|
| `alexandr login` | Refresh the runtime-image pull credential (sign in, no re-register) |
|
|
68
|
-
| `alexandr update` | Update the runtime image (`--to <tag>`, `--rollback`); auto-snapshots `/data` first (`--no-backup` to skip) |
|
|
68
|
+
| `alexandr update` | Update the runtime image (`--to <tag>`, `--rollback`); auto-snapshots `/data` first (`--no-backup` to skip); keeps the newest 3 snapshots |
|
|
69
|
+
| `alexandr updater serve` | The updater sidecar's entrypoint — runs inside its container; `up` starts it, so the desktop app's update sheet can press "Update now" (`--no-updater` on `up` opts out) |
|
|
69
70
|
| `alexandr backup` / `restore <f>` | Archive / restore the data volume |
|
|
70
71
|
| `alexandr config set <k> <v>` | Edit config (`ai.url`, `model`, `port`, `domain`, …) |
|
|
71
72
|
| `alexandr init` | Write a committable `./alexandr` config folder |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alexandr",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Run the alexandr workspace runtime locally (npx alexandr up), and develop alexandr apps against a workspace (alexandr app link | dev | build | deploy).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.js
CHANGED
|
@@ -57,6 +57,8 @@ const TABLE = {
|
|
|
57
57
|
ls: cmd.ls, logs: cmd.logs, config: cmd.config, init: cmd.init,
|
|
58
58
|
update: cmd.update, connect: cmd.connect, link: cmd.link, login: cmd.login, backup: cmd.backup,
|
|
59
59
|
restore: cmd.restore, completion, doctor: cmd.doctor, version,
|
|
60
|
+
// The sidecar's entrypoint (`alexandr updater serve`) — the same package, in a container.
|
|
61
|
+
updater: cmd.updater,
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
export async function run(argv = process.argv.slice(2)) {
|
package/src/commands.js
CHANGED
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
} from "./docker.js";
|
|
16
16
|
import {
|
|
17
17
|
resolveInstance, isMaterialized, materialize, readEnv, envHas, setEnv,
|
|
18
|
-
unsetEnv, kernelPort,
|
|
18
|
+
unsetEnv, kernelPort, pruneSnapshots, dockerConfigDir,
|
|
19
19
|
} from "./instance.js";
|
|
20
|
+
export { updater } from "./updater.js";
|
|
20
21
|
import { kernelUrl, health, version as kVersion, waitHealthy, waitPosture } from "./probe.js";
|
|
21
22
|
import { buildConnect } from "./connect.js";
|
|
22
23
|
import {
|
|
@@ -79,6 +80,76 @@ function archiveVolume(vol, outPath) {
|
|
|
79
80
|
return r.status === 0;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/** Snapshots kept per box (owner, 2026-09-08): the newest three `pre-update-*.tgz`. */
|
|
84
|
+
const SNAPSHOT_KEEP = 3;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The compose PROFILES this instance runs with — every `up`/`down` must pass the same set, or
|
|
88
|
+
* a profiled service is left behind (a `down` without the profile leaves the updater running).
|
|
89
|
+
*
|
|
90
|
+
* · `public` — Caddy, when a domain is configured.
|
|
91
|
+
* · `updater` — the updater sidecar (self-host-update-sidecar.md), unless the operator opted
|
|
92
|
+
* out (`ALEXANDR_UPDATER=off`, written by `--no-updater`) — or unless THIS process is the
|
|
93
|
+
* sidecar's own child (`ALEXANDR_UPDATER_INSIDE=1`): a container cannot recreate itself
|
|
94
|
+
* mid-run, so the recipe run from inside leaves the `updater` service alone.
|
|
95
|
+
*/
|
|
96
|
+
function profileArgs(dir) {
|
|
97
|
+
const env = readEnv(dir);
|
|
98
|
+
const args = [];
|
|
99
|
+
if (envHas(dir, "ALEXANDR_DOMAIN")) args.push("--profile", "public");
|
|
100
|
+
const updaterOff = (env.ALEXANDR_UPDATER || "").trim().toLowerCase() === "off";
|
|
101
|
+
if (!updaterOff && process.env.ALEXANDR_UPDATER_INSIDE !== "1") args.push("--profile", "updater");
|
|
102
|
+
return args;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What the updater sidecar needs written into `.env` before compose can start it: the instance
|
|
107
|
+
* dir at its host path (mounted at the same path inside), the compose project name (so the
|
|
108
|
+
* recipe run from inside drives THIS project, not a re-derived one), and the sidecar's image
|
|
109
|
+
* tag — pinned to the CLI that installed it, so the sidecar's version is the CLI's version.
|
|
110
|
+
*/
|
|
111
|
+
function stampUpdaterEnv(inst) {
|
|
112
|
+
setEnv(inst.dir, "ALEXANDR_INSTANCE_DIR", hostPath(inst.dir));
|
|
113
|
+
setEnv(inst.dir, "ALEXANDR_COMPOSE_PROJECT", inst.projectName);
|
|
114
|
+
if (process.env.ALEXANDR_UPDATER_INSIDE !== "1") {
|
|
115
|
+
setEnv(inst.dir, "ALEXANDR_UPDATER_IMAGE_TAG", pkgVersion());
|
|
116
|
+
// The host's docker config, so the sidecar's pulls carry this box's registry login
|
|
117
|
+
// (found on the first real press: an anonymous pull of the private image → `unauthorized`).
|
|
118
|
+
setEnv(inst.dir, "ALEXANDR_DOCKER_CONFIG", hostPath(dockerConfigDir()));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* ⚠ THE SIDECAR MUST NEVER COST A BOOT. With the `updater` profile on, a `compose up` that
|
|
124
|
+
* cannot pull the updater image (the tag not yet published for this CLI version, an
|
|
125
|
+
* air-gapped host, a registry that serves the kernel but not this package) fails the WHOLE
|
|
126
|
+
* `up` — kernel included. So when `up` fails and the profile was on, probe the updater's
|
|
127
|
+
* pull alone; if THAT is what failed, say so once and retry without the sidecar. Nothing is
|
|
128
|
+
* persisted: the next `up` tries the sidecar again. Returns the compose status.
|
|
129
|
+
*/
|
|
130
|
+
function composeUpWithoutSidecarIfNeeded(inst, args, offline) {
|
|
131
|
+
if (!args.includes("updater")) return 1;
|
|
132
|
+
if (!offline) {
|
|
133
|
+
const probe = composeCapture(inst.dir, inst.projectName, ["--profile", "updater", "pull", "updater"]);
|
|
134
|
+
if (probe.status === 0) return 1; // the sidecar image is fine — the failure is elsewhere
|
|
135
|
+
}
|
|
136
|
+
warn("The updater sidecar's image isn't available yet — starting without it (the terminal `alexandr update` still works; the sidecar comes back on the next `alexandr up`).");
|
|
137
|
+
const without = [];
|
|
138
|
+
for (let i = 0; i < args.length; i++) {
|
|
139
|
+
if (args[i] === "--profile" && args[i + 1] === "updater") {
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
without.push(args[i]);
|
|
144
|
+
}
|
|
145
|
+
return compose(inst.dir, inst.projectName, without).status;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function pkgVersion() {
|
|
149
|
+
const p = path.resolve(fileURLToPath(import.meta.url), "..", "..", "package.json");
|
|
150
|
+
return JSON.parse(fs.readFileSync(p, "utf8")).version;
|
|
151
|
+
}
|
|
152
|
+
|
|
82
153
|
function isRunning(dir, projectName) {
|
|
83
154
|
const r = composeCapture(dir, projectName, ["ps", "--status", "running", "-q", "kernel"]);
|
|
84
155
|
return r.status === 0 && r.stdout !== "";
|
|
@@ -182,6 +253,11 @@ export async function up(flags) {
|
|
|
182
253
|
if (typeof flags.name === "string" && flags.name.trim()) {
|
|
183
254
|
setEnv(inst.dir, "ALEXANDR_WORKSPACE_NAME", flags.name.trim());
|
|
184
255
|
}
|
|
256
|
+
// The updater sidecar (self-host-update-sidecar.md): on by default, `--no-updater` records
|
|
257
|
+
// the opt-out in .env so every later compose call agrees; `--updater` turns it back on.
|
|
258
|
+
if (flags["no-updater"]) setEnv(inst.dir, "ALEXANDR_UPDATER", "off");
|
|
259
|
+
else if (flags.updater === true) unsetEnv(inst.dir, "ALEXANDR_UPDATER");
|
|
260
|
+
stampUpdaterEnv(inst);
|
|
185
261
|
|
|
186
262
|
// Sign-in comes FIRST (account-required-runtimes D3) — now literally: the entitlement
|
|
187
263
|
// gate fires before anything system-mutating, so a refused account walks away from a
|
|
@@ -209,9 +285,7 @@ export async function up(flags) {
|
|
|
209
285
|
}
|
|
210
286
|
|
|
211
287
|
const offline = Boolean(flags.offline || flags["no-pull"]);
|
|
212
|
-
const args = [];
|
|
213
|
-
if (hasDomain) args.push("--profile", "public");
|
|
214
|
-
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
288
|
+
const args = [...profileArgs(inst.dir), "up", "-d", "--pull", offline ? "never" : "missing"];
|
|
215
289
|
|
|
216
290
|
step(`Starting alexandr (${inst.mode} · ${inst.dir})…`);
|
|
217
291
|
let r = compose(inst.dir, inst.projectName, args);
|
|
@@ -219,7 +293,9 @@ export async function up(flags) {
|
|
|
219
293
|
step("Retrying the start…");
|
|
220
294
|
r = compose(inst.dir, inst.projectName, args);
|
|
221
295
|
}
|
|
222
|
-
if (r.status !== 0
|
|
296
|
+
if (r.status !== 0 && composeUpWithoutSidecarIfNeeded(inst, args, offline) !== 0) {
|
|
297
|
+
fail("`docker compose up` failed — see the output above. Try `alexandr doctor`.", EXIT.RUNTIME);
|
|
298
|
+
}
|
|
223
299
|
|
|
224
300
|
const base = kernelUrl(port);
|
|
225
301
|
step("Waiting for the kernel to come up…");
|
|
@@ -236,7 +312,7 @@ export async function up(flags) {
|
|
|
236
312
|
);
|
|
237
313
|
await runLinkCeremony(inst, flags);
|
|
238
314
|
step("Restarting the runtime with the fresh credentials…");
|
|
239
|
-
const restartArgs =
|
|
315
|
+
const restartArgs = [...profileArgs(inst.dir), "up", "-d"];
|
|
240
316
|
if (compose(inst.dir, inst.projectName, restartArgs).status !== 0) {
|
|
241
317
|
fail("`docker compose up` failed after re-linking — see the output above.", EXIT.RUNTIME);
|
|
242
318
|
}
|
|
@@ -298,7 +374,7 @@ export async function down(flags) {
|
|
|
298
374
|
const inst = resolveInstance(flags);
|
|
299
375
|
if (!isMaterialized(inst.dir)) fail("No alexandr instance here. Run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
300
376
|
step("Stopping alexandr (data preserved)…");
|
|
301
|
-
const r = compose(inst.dir, inst.projectName, ["down"]);
|
|
377
|
+
const r = compose(inst.dir, inst.projectName, [...profileArgs(inst.dir), "down"]);
|
|
302
378
|
if (r.status === 0) ok("Stopped. Your data volume is untouched — `alexandr up` to resume.");
|
|
303
379
|
}
|
|
304
380
|
|
|
@@ -351,7 +427,7 @@ export async function destroy(flags) {
|
|
|
351
427
|
else warn("Couldn't remove it from your account — remove it from your account page instead.");
|
|
352
428
|
}
|
|
353
429
|
step(wipe ? "Removing alexandr AND its data volume…" : "Removing alexandr containers (data preserved)…");
|
|
354
|
-
const r = compose(inst.dir, inst.projectName,
|
|
430
|
+
const r = compose(inst.dir, inst.projectName, [...profileArgs(inst.dir), "down", ...(wipe ? ["-v"] : [])]);
|
|
355
431
|
if (r.status === 0) ok(wipe ? "Destroyed, including /data." : "Containers removed; /data volume kept.");
|
|
356
432
|
}
|
|
357
433
|
|
|
@@ -530,6 +606,15 @@ export async function update(flags) {
|
|
|
530
606
|
await ensureLinked(inst, flags);
|
|
531
607
|
}
|
|
532
608
|
|
|
609
|
+
// The compose file is the CLI's template, re-staged on every `up`; an update re-stages it
|
|
610
|
+
// too, so the FIRST update after the updater sidecar shipped installs the sidecar
|
|
611
|
+
// (self-host-update-sidecar.md §6) — the image pin lives in .env, never in the file.
|
|
612
|
+
// ⚠ Not from inside the sidecar: its own compose run must not rewrite the file it is
|
|
613
|
+
// executing against, and it never carries the newer template anyway.
|
|
614
|
+
if (process.env.ALEXANDR_UPDATER_INSIDE !== "1") {
|
|
615
|
+
materialize(inst.dir);
|
|
616
|
+
stampUpdaterEnv(inst);
|
|
617
|
+
}
|
|
533
618
|
const prevImage = readEnv(inst.dir).ALEXANDR_IMAGE || `${IMAGE_BASE}:latest`;
|
|
534
619
|
const base = kernelUrl(kernelPort(inst.dir));
|
|
535
620
|
const before = (await kVersion(base))?.version ?? "?";
|
|
@@ -580,14 +665,19 @@ export async function update(flags) {
|
|
|
580
665
|
fail("Pull failed — check your network / the image tag.", EXIT.RUNTIME);
|
|
581
666
|
}
|
|
582
667
|
}
|
|
583
|
-
const args = [];
|
|
584
|
-
if (envHas(inst.dir, "ALEXANDR_DOMAIN")) args.push("--profile", "public");
|
|
585
|
-
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
668
|
+
const args = [...profileArgs(inst.dir), "up", "-d", "--pull", offline ? "never" : "missing"];
|
|
586
669
|
step("Applying the update (data preserved)…");
|
|
587
|
-
if (compose(inst.dir, inst.projectName, args).status !== 0
|
|
670
|
+
if (compose(inst.dir, inst.projectName, args).status !== 0 && composeUpWithoutSidecarIfNeeded(inst, args, offline) !== 0) {
|
|
671
|
+
fail("Update failed — see output above.", EXIT.RUNTIME);
|
|
672
|
+
}
|
|
588
673
|
|
|
589
674
|
const after = (await waitHealthy(base))?.version ?? "?";
|
|
590
675
|
ok(`Updated: ${before} → ${after}`);
|
|
676
|
+
// The newest three snapshots stay (owner, 2026-09-08); a box updated from the app every
|
|
677
|
+
// train would otherwise fill its disk with tarballs. Pruned AFTER the update is verified, so
|
|
678
|
+
// a failed update never deletes the snapshot it may need.
|
|
679
|
+
const pruned = pruneSnapshots(inst.dir, SNAPSHOT_KEEP);
|
|
680
|
+
if (pruned.length) log(dim(` Pruned ${pruned.length} older snapshot(s); the newest ${SNAPSHOT_KEEP} are kept in backups/.`));
|
|
591
681
|
}
|
|
592
682
|
|
|
593
683
|
// ---------------------------------------------------------------- connect
|
package/src/completion.js
CHANGED
|
@@ -29,6 +29,7 @@ export const COMMANDS = [
|
|
|
29
29
|
{ name: "link", usage: "link", summary: "Re-link this runtime to your alexandr account (--force re-registers)" },
|
|
30
30
|
{ name: "login", usage: "login", summary: "Refresh the runtime-image pull credential (sign in, no re-register)" },
|
|
31
31
|
{ name: "update", usage: "update", summary: "Update the runtime image (--to <tag>, --rollback)" },
|
|
32
|
+
{ name: "updater", usage: "updater serve", summary: "Run the updater sidecar (inside its container; `up` starts it)" },
|
|
32
33
|
{ name: "backup", usage: "backup", summary: "Archive the data volume (--out <file>)" },
|
|
33
34
|
{ name: "restore", usage: "restore <f>", summary: "Restore a data-volume archive (--yes)" },
|
|
34
35
|
{ name: "config", usage: "config", summary: "get | set | list | unset (e.g. alexandr config set ai.url …)" },
|
package/src/instance.js
CHANGED
|
@@ -24,7 +24,12 @@ export function resolveInstance(flags = {}) {
|
|
|
24
24
|
const cwdProject = path.resolve(process.cwd(), "alexandr");
|
|
25
25
|
if (flags.dir) {
|
|
26
26
|
const dir = path.resolve(String(flags.dir));
|
|
27
|
-
|
|
27
|
+
// `--project` pins the compose project NAME beside `--dir`: the updater sidecar runs the
|
|
28
|
+
// recipe from inside a container where the instance dir is mounted at its host path, and
|
|
29
|
+
// the project it must drive is the one the host created — a name re-derived in there
|
|
30
|
+
// (a global instance's is not the dir hash) would make compose start a SECOND copy.
|
|
31
|
+
const pinned = typeof flags.project === "string" && flags.project.trim() ? flags.project.trim() : null;
|
|
32
|
+
return { dir, mode: "project", name: path.basename(dir), projectName: pinned ?? `alexandr-${sha8(dir)}` };
|
|
28
33
|
}
|
|
29
34
|
if (fs.existsSync(path.join(cwdProject, "docker-compose.yml"))) {
|
|
30
35
|
return { dir: cwdProject, mode: "project", name: path.basename(path.dirname(cwdProject)), projectName: `alexandr-${sha8(cwdProject)}` };
|
|
@@ -95,6 +100,46 @@ export function unsetEnv(dir, key) {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
// The loopback kernel port for this instance (host side), honoring the .env.
|
|
103
|
+
/**
|
|
104
|
+
* Keep the newest `keep` pre-update snapshots in `<dir>/backups` and delete the rest
|
|
105
|
+
* (owner, 2026-09-08: keep 3). Every update — the terminal verb and the sidecar's button —
|
|
106
|
+
* snapshots /data first, and a box that is updated often would otherwise fill its disk with
|
|
107
|
+
* tarballs nobody will restore. Returns the paths removed. Only `pre-update-*.tgz` is touched:
|
|
108
|
+
* a snapshot the operator took with `alexandr backup` is theirs to keep.
|
|
109
|
+
*/
|
|
110
|
+
export function pruneSnapshots(dir, keep = 3) {
|
|
111
|
+
const backups = path.join(dir, "backups");
|
|
112
|
+
if (!fs.existsSync(backups)) return [];
|
|
113
|
+
const snaps = fs
|
|
114
|
+
.readdirSync(backups)
|
|
115
|
+
.filter((f) => /^pre-update-.*\.tgz$/.test(f))
|
|
116
|
+
.map((f) => ({ f, at: fs.statSync(path.join(backups, f)).mtimeMs }))
|
|
117
|
+
.sort((a, b) => b.at - a.at);
|
|
118
|
+
const removed = [];
|
|
119
|
+
for (const { f } of snaps.slice(Math.max(0, keep))) {
|
|
120
|
+
const p = path.join(backups, f);
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(p);
|
|
123
|
+
removed.push(p);
|
|
124
|
+
} catch {
|
|
125
|
+
/* a snapshot that will not go is not worth failing an update over */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return removed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Where THIS host's Docker CLI keeps its config — the registry login `alexandr up`/`login`
|
|
133
|
+
* wrote (`docker login` honours `DOCKER_CONFIG`, else `~/.docker`). The updater sidecar mounts
|
|
134
|
+
* it read-only: a pull's credential is sent by the client, not held by the daemon, so a docker
|
|
135
|
+
* CLI inside the sidecar with no config pulls anonymously and the private kernel image answers
|
|
136
|
+
* `unauthorized` — exactly what the first real press did (aos, 2026-09-08).
|
|
137
|
+
*/
|
|
138
|
+
export function dockerConfigDir(env = process.env, home = os.homedir()) {
|
|
139
|
+
const explicit = (env.DOCKER_CONFIG || "").trim();
|
|
140
|
+
return explicit || path.join(home, ".docker");
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
export function kernelPort(dir) {
|
|
99
144
|
const v = readEnv(dir).ALEXANDR_KERNEL_PORT;
|
|
100
145
|
const n = v ? Number(v) : 3030;
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// `alexandr updater serve` — the self-host UPDATER SIDECAR (self-host-update-sidecar.md).
|
|
2
|
+
//
|
|
3
|
+
// The kernel runs inside the container an update replaces and must never hold the Docker
|
|
4
|
+
// socket (untrusted app code makes the container the security boundary). So the box carries
|
|
5
|
+
// ONE more process that may: this server, running as the `updater` service of the instance's
|
|
6
|
+
// compose file with the socket mounted, listening on a private unix socket the kernel mounts
|
|
7
|
+
// read-only. Two verbs — `GET /status`, `POST /update` — and nothing else.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ ONE RECIPE, TWO DOORS. This does not implement an update. `POST /update` spawns the SAME
|
|
10
|
+
// `alexandr update` the operator types, as a child process (`update()` calls `fail()`, which
|
|
11
|
+
// exits — a child keeps the server alive), and reads its progress off its own step lines. A
|
|
12
|
+
// change to the recipe is therefore a change to both doors, by construction.
|
|
13
|
+
//
|
|
14
|
+
// ⚠ THE SIDECAR NAMES THE TARGET, NEVER THE CALLER. The kernel may ask for `to`, but the
|
|
15
|
+
// version that runs is the release FEED's current one (or the recorded previous image, for a
|
|
16
|
+
// rollback); a request naming anything else is refused. A compromised kernel cannot make the
|
|
17
|
+
// host pull an image of its choosing.
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { readEnv } from "./instance.js";
|
|
25
|
+
import { log, warn, fail, dim } from "./util.js";
|
|
26
|
+
import { EXIT } from "./exit.js";
|
|
27
|
+
|
|
28
|
+
export const STATE_FILE = ".update-state.json";
|
|
29
|
+
const DEFAULT_SOCKET = "/run/alexandr/updater.sock";
|
|
30
|
+
const DEFAULT_FEED = "https://api.alexandr.so/releases/stable";
|
|
31
|
+
|
|
32
|
+
const pkg = JSON.parse(
|
|
33
|
+
fs.readFileSync(path.resolve(fileURLToPath(import.meta.url), "..", "..", "package.json"), "utf8"),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/** The feed a linked box reads — the same derivation the kernel's update check uses. */
|
|
37
|
+
export function feedUrlFor(env) {
|
|
38
|
+
const explicit = (env.ALEXANDR_RELEASES_URL || "").trim();
|
|
39
|
+
if (explicit) return explicit;
|
|
40
|
+
const cp = (env.ALEXANDR_CP_URL || "").trim().replace(/\/+$/, "");
|
|
41
|
+
return cp ? `${cp}/releases/stable` : DEFAULT_FEED;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The phase a `alexandr update` step line announces — the sidecar's progress is the CLI's own words. */
|
|
45
|
+
export function phaseOf(line) {
|
|
46
|
+
if (/Snapshotting/i.test(line)) return "snapshotting";
|
|
47
|
+
if (/Pulling the runtime image|Offline update/i.test(line)) return "pulling";
|
|
48
|
+
if (/Applying the update/i.test(line)) return "restarting";
|
|
49
|
+
if (/Updated:/i.test(line)) return "done";
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The request handler, separated from the socket so a test can drive it with a fake runner
|
|
55
|
+
* and a fake feed. `deps.runUpdate({ to, rollback, onPhase })` resolves `{ ok, error?, from?, to? }`.
|
|
56
|
+
*/
|
|
57
|
+
export function createUpdaterHandler(deps) {
|
|
58
|
+
const {
|
|
59
|
+
instanceDir,
|
|
60
|
+
feedUrl,
|
|
61
|
+
runUpdate,
|
|
62
|
+
fetchImpl = fetch,
|
|
63
|
+
updaterVersion = pkg.version,
|
|
64
|
+
now = () => new Date().toISOString(),
|
|
65
|
+
} = deps;
|
|
66
|
+
const statePath = path.join(instanceDir, STATE_FILE);
|
|
67
|
+
let state = readState(statePath) ?? { state: "idle" };
|
|
68
|
+
let running = null;
|
|
69
|
+
|
|
70
|
+
function write(next) {
|
|
71
|
+
state = { ...next, updaterVersion };
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(instanceDir, { recursive: true });
|
|
74
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
75
|
+
} catch {
|
|
76
|
+
// The state file is a courtesy to a kernel that restarts mid-update; losing it is not fatal.
|
|
77
|
+
}
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function feedCurrent() {
|
|
82
|
+
const res = await fetchImpl(feedUrl, { signal: AbortSignal.timeout(10_000) });
|
|
83
|
+
if (!res.ok) throw new Error(`the release feed answered ${res.status}`);
|
|
84
|
+
const body = await res.json();
|
|
85
|
+
const v = body?.current?.version;
|
|
86
|
+
if (typeof v !== "string" || !v) throw new Error("the release feed names no current version");
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function status() {
|
|
91
|
+
return { status: 200, body: { ...state, updaterVersion } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function update(body) {
|
|
95
|
+
if (running) return { status: 409, body: { error: "an update is already running", ...state } };
|
|
96
|
+
const rollback = body?.rollback === true;
|
|
97
|
+
let to = null;
|
|
98
|
+
if (!rollback) {
|
|
99
|
+
let current;
|
|
100
|
+
try {
|
|
101
|
+
current = await feedCurrent();
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return { status: 502, body: { error: `could not read the release feed: ${e.message}` } };
|
|
104
|
+
}
|
|
105
|
+
const asked = typeof body?.to === "string" && body.to.trim() ? body.to.trim().replace(/^v/, "") : null;
|
|
106
|
+
if (asked && asked !== current) {
|
|
107
|
+
return {
|
|
108
|
+
status: 409,
|
|
109
|
+
body: { error: `refused: ${asked} is not the feed's current version (${current})`, current },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
to = current;
|
|
113
|
+
}
|
|
114
|
+
const startedAt = now();
|
|
115
|
+
write({ state: "updating", phase: "starting", to, rollback, startedAt });
|
|
116
|
+
running = (async () => {
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
result = await runUpdate({
|
|
120
|
+
to,
|
|
121
|
+
rollback,
|
|
122
|
+
onPhase: (phase) => write({ ...state, state: "updating", phase }),
|
|
123
|
+
});
|
|
124
|
+
} catch (e) {
|
|
125
|
+
result = { ok: false, error: e?.message || String(e) };
|
|
126
|
+
}
|
|
127
|
+
if (result.ok) {
|
|
128
|
+
write({ state: "done", phase: "done", to: result.to ?? to, from: result.from ?? null, rollback, startedAt, finishedAt: now() });
|
|
129
|
+
} else {
|
|
130
|
+
write({ state: "failed", phase: state.phase ?? "starting", to, rollback, startedAt, finishedAt: now(), error: result.error || "update failed" });
|
|
131
|
+
}
|
|
132
|
+
running = null;
|
|
133
|
+
})();
|
|
134
|
+
return { status: 202, body: { started: true, to, rollback, startedAt } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
async handle(method, urlPath, body) {
|
|
139
|
+
if (method === "GET" && urlPath === "/status") return status();
|
|
140
|
+
if (method === "POST" && urlPath === "/update") return update(body ?? {});
|
|
141
|
+
return { status: 404, body: { error: "not found" } };
|
|
142
|
+
},
|
|
143
|
+
/** Test seam: wait for an in-flight update to settle. */
|
|
144
|
+
settled: () => running ?? Promise.resolve(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readState(p) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
151
|
+
// A box that restarted mid-update: the child is gone with the old process, so "updating" is stale.
|
|
152
|
+
return parsed?.state === "updating" ? { ...parsed, state: "failed", error: "the updater restarted mid-update" } : parsed;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* THE recipe, as a child: `alexandr update --dir <instance> --project <name> [--to v|--rollback] --yes`.
|
|
160
|
+
* `ALEXANDR_UPDATER_INSIDE=1` tells `update` it is running from the sidecar, so its compose
|
|
161
|
+
* calls leave the `updater` service alone (a container cannot recreate itself mid-run).
|
|
162
|
+
*/
|
|
163
|
+
export function spawnUpdate({ instanceDir, projectName, bin }) {
|
|
164
|
+
return ({ to, rollback, onPhase }) =>
|
|
165
|
+
new Promise((resolve) => {
|
|
166
|
+
const args = [bin, "update", "--dir", instanceDir, "--project", projectName, "--yes"];
|
|
167
|
+
if (rollback) args.push("--rollback");
|
|
168
|
+
else if (to) args.push("--to", `v${to}`);
|
|
169
|
+
const child = spawn(process.execPath, args, {
|
|
170
|
+
env: { ...process.env, ALEXANDR_UPDATER_INSIDE: "1", FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
171
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
172
|
+
});
|
|
173
|
+
let out = "";
|
|
174
|
+
let from = null;
|
|
175
|
+
let after = null;
|
|
176
|
+
const onData = (chunk) => {
|
|
177
|
+
const text = String(chunk);
|
|
178
|
+
out += text;
|
|
179
|
+
process.stdout.write(text);
|
|
180
|
+
for (const line of text.split("\n")) {
|
|
181
|
+
const phase = phaseOf(line);
|
|
182
|
+
if (phase) onPhase(phase);
|
|
183
|
+
const m = /Updated:\s*(\S+)\s*→\s*(\S+)/.exec(line);
|
|
184
|
+
if (m) [, from, after] = m;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
child.stdout.on("data", onData);
|
|
188
|
+
child.stderr.on("data", onData);
|
|
189
|
+
child.on("close", (code) => {
|
|
190
|
+
if (code === 0) resolve({ ok: true, from, to: after ?? to });
|
|
191
|
+
else resolve({ ok: false, error: lastLine(out) || `alexandr update exited ${code}` });
|
|
192
|
+
});
|
|
193
|
+
child.on("error", (e) => resolve({ ok: false, error: e.message }));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The child's last line, without colour codes or the CLI's own status glyph (`✗ `, `▸ `): the
|
|
198
|
+
// words are the operator's, the decoration was the terminal's.
|
|
199
|
+
const lastLine = (s) =>
|
|
200
|
+
s
|
|
201
|
+
.split("\n")
|
|
202
|
+
.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").replace(/^[✗✓▸!]\s*/u, "").trim())
|
|
203
|
+
.filter(Boolean)
|
|
204
|
+
.pop() ?? "";
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------- the verb
|
|
207
|
+
export async function updater(flags) {
|
|
208
|
+
const sub = flags._.shift();
|
|
209
|
+
if (sub !== "serve") fail("usage: alexandr updater serve (runs inside the `updater` sidecar)", EXIT.USAGE);
|
|
210
|
+
const instanceDir = String(flags.dir || process.env.ALEXANDR_INSTANCE_DIR || "").trim();
|
|
211
|
+
const projectName = String(flags.project || process.env.ALEXANDR_COMPOSE_PROJECT || "").trim();
|
|
212
|
+
if (!instanceDir || !projectName) {
|
|
213
|
+
fail("updater serve needs the instance directory and compose project (ALEXANDR_INSTANCE_DIR / ALEXANDR_COMPOSE_PROJECT).", EXIT.USAGE);
|
|
214
|
+
}
|
|
215
|
+
const socketPath = String(flags.socket || process.env.ALEXANDR_UPDATER_SOCKET || DEFAULT_SOCKET);
|
|
216
|
+
const env = readEnv(instanceDir);
|
|
217
|
+
const feedUrl = feedUrlFor({ ...env, ...process.env });
|
|
218
|
+
const bin = path.resolve(fileURLToPath(import.meta.url), "..", "..", "bin.js");
|
|
219
|
+
const handler = createUpdaterHandler({
|
|
220
|
+
instanceDir,
|
|
221
|
+
feedUrl,
|
|
222
|
+
runUpdate: spawnUpdate({ instanceDir, projectName, bin }),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
|
226
|
+
try {
|
|
227
|
+
fs.unlinkSync(socketPath);
|
|
228
|
+
} catch {
|
|
229
|
+
/* no stale socket */
|
|
230
|
+
}
|
|
231
|
+
const server = http.createServer(async (req, res) => {
|
|
232
|
+
let body = "";
|
|
233
|
+
req.on("data", (c) => {
|
|
234
|
+
body += c;
|
|
235
|
+
if (body.length > 4096) req.destroy();
|
|
236
|
+
});
|
|
237
|
+
req.on("end", async () => {
|
|
238
|
+
let parsed = {};
|
|
239
|
+
if (body) {
|
|
240
|
+
try {
|
|
241
|
+
parsed = JSON.parse(body);
|
|
242
|
+
} catch {
|
|
243
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
244
|
+
res.end(JSON.stringify({ error: "bad json" }));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const out = await handler.handle(req.method, (req.url || "/").split("?")[0], parsed);
|
|
249
|
+
res.writeHead(out.status, { "content-type": "application/json" });
|
|
250
|
+
res.end(JSON.stringify(out.body));
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
server.listen(socketPath, () => {
|
|
254
|
+
// Group-readable + writable so the kernel (a different uid) can connect; the volume is the ACL.
|
|
255
|
+
try {
|
|
256
|
+
fs.chmodSync(socketPath, 0o666);
|
|
257
|
+
} catch {
|
|
258
|
+
/* best effort */
|
|
259
|
+
}
|
|
260
|
+
log(`alexandr updater v${pkg.version} — listening on ${socketPath}`);
|
|
261
|
+
log(dim(` instance ${instanceDir} · project ${projectName} · feed ${feedUrl}`));
|
|
262
|
+
});
|
|
263
|
+
const stop = () => {
|
|
264
|
+
server.close();
|
|
265
|
+
try {
|
|
266
|
+
fs.unlinkSync(socketPath);
|
|
267
|
+
} catch {
|
|
268
|
+
/* gone */
|
|
269
|
+
}
|
|
270
|
+
process.exit(0);
|
|
271
|
+
};
|
|
272
|
+
process.on("SIGTERM", stop);
|
|
273
|
+
process.on("SIGINT", stop);
|
|
274
|
+
await new Promise(() => {});
|
|
275
|
+
warn("unreachable");
|
|
276
|
+
}
|
|
@@ -16,15 +16,52 @@ services:
|
|
|
16
16
|
# The HOST side of the port map below — inside the container the kernel only
|
|
17
17
|
# knows its internal port, and must not advertise that as its reachable URL.
|
|
18
18
|
ALEXANDR_ADVERTISED_PORT: ${ALEXANDR_KERNEL_PORT:-3030}
|
|
19
|
+
# Where the updater sidecar listens (a read-only mount below). The kernel reports
|
|
20
|
+
# `updater.available` from whether something answers here — never assumes.
|
|
21
|
+
ALEXANDR_UPDATER_SOCKET: /run/alexandr/updater.sock
|
|
19
22
|
env_file:
|
|
20
23
|
- path: ./.env
|
|
21
24
|
required: false # every var is optional; the CLI writes this file
|
|
22
25
|
volumes:
|
|
23
26
|
- data:/data # instance state: OS db, installed apps, blobs
|
|
27
|
+
# The updater sidecar's socket (below) — read-only: the kernel may ASK for an
|
|
28
|
+
# update over it and holds no Docker of its own.
|
|
29
|
+
- updater_run:/run/alexandr:ro
|
|
24
30
|
ports:
|
|
25
31
|
- "127.0.0.1:${ALEXANDR_KERNEL_PORT:-3030}:3030" # loopback only
|
|
26
32
|
restart: unless-stopped
|
|
27
33
|
|
|
34
|
+
# THE UPDATER SIDECAR (self-host-update-sidecar.md): the `alexandr` CLI in a container, the
|
|
35
|
+
# ONE process on this host allowed to hold the Docker socket, so the desktop app's update
|
|
36
|
+
# sheet can say "Update now" instead of handing over a command to copy. It runs the same
|
|
37
|
+
# `alexandr update` the operator would type — snapshot /data, pin, pull, restart, verify —
|
|
38
|
+
# when the kernel asks over the private socket in `updater_run`, and it resolves the target
|
|
39
|
+
# from the release feed itself (the kernel never names an image).
|
|
40
|
+
#
|
|
41
|
+
# Under the `updater` profile: the CLI passes `--profile updater` unless the operator set
|
|
42
|
+
# `alexandr up --no-updater` (ALEXANDR_UPDATER=off). Inside the container the instance
|
|
43
|
+
# directory is mounted at its HOST path, so compose's relative files resolve identically.
|
|
44
|
+
updater:
|
|
45
|
+
image: ghcr.io/alexandrco/alexandr-updater:${ALEXANDR_UPDATER_IMAGE_TAG:-latest}
|
|
46
|
+
profiles: ["updater"]
|
|
47
|
+
environment:
|
|
48
|
+
ALEXANDR_INSTANCE_DIR: ${ALEXANDR_INSTANCE_DIR:-/nonexistent}
|
|
49
|
+
ALEXANDR_COMPOSE_PROJECT: ${ALEXANDR_COMPOSE_PROJECT:-alexandr}
|
|
50
|
+
ALEXANDR_UPDATER_SOCKET: /run/alexandr/updater.sock
|
|
51
|
+
env_file:
|
|
52
|
+
- path: ./.env
|
|
53
|
+
required: false # ALEXANDR_CP_URL / ALEXANDR_RELEASES_URL → the feed it reads
|
|
54
|
+
volumes:
|
|
55
|
+
- /var/run/docker.sock:/var/run/docker.sock # THE socket. Only here.
|
|
56
|
+
- ${ALEXANDR_INSTANCE_DIR:-/nonexistent}:${ALEXANDR_INSTANCE_DIR:-/nonexistent}
|
|
57
|
+
# The host's Docker CONFIG, read-only — the registry login `alexandr up`/`login` wrote
|
|
58
|
+
# lives there, and a pull's credential is sent by the CLIENT, not held by the daemon:
|
|
59
|
+
# without this the sidecar's own docker CLI pulls anonymously and the private kernel
|
|
60
|
+
# image answers `unauthorized` (found on the first real press, aos, 2026-09-08).
|
|
61
|
+
- ${ALEXANDR_DOCKER_CONFIG:-/nonexistent}:/root/.docker:ro
|
|
62
|
+
- updater_run:/run/alexandr
|
|
63
|
+
restart: unless-stopped
|
|
64
|
+
|
|
28
65
|
# Public front door. Only started under the "public" profile — i.e. when a
|
|
29
66
|
# domain is configured (`alexandr up --domain …`). Pure-local runs skip it, so
|
|
30
67
|
# no root is needed for :80/:443 and there's no TLS machinery to babysit.
|
|
@@ -49,3 +86,4 @@ volumes:
|
|
|
49
86
|
data:
|
|
50
87
|
caddy_data:
|
|
51
88
|
caddy_config:
|
|
89
|
+
updater_run: # the updater's unix socket; the kernel mounts it read-only
|
package/templates/env.example
CHANGED
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
# ---- image (the CLI pins this for `update --to <tag>` / `--rollback`) -------
|
|
11
11
|
# ALEXANDR_IMAGE=ghcr.io/alexandrco/alexandr-kernel:latest
|
|
12
|
+
# ---- the updater sidecar (self-host-update-sidecar.md) ----------------------
|
|
13
|
+
# The `updater` service lets the desktop app's update sheet say "Update now": the
|
|
14
|
+
# alexandr CLI in a container, the one process on this host holding the Docker
|
|
15
|
+
# socket, running the same `alexandr update` you would type. On by default; opt out
|
|
16
|
+
# with `alexandr up --no-updater` (writes the line below). The three vars after it
|
|
17
|
+
# are WRITTEN BY THE CLI on every `up`/`update` — never fill them by hand.
|
|
18
|
+
# ALEXANDR_UPDATER=off
|
|
19
|
+
# ALEXANDR_INSTANCE_DIR= # this directory, at its host path (mounted at the same path)
|
|
20
|
+
# ALEXANDR_COMPOSE_PROJECT= # the compose project the sidecar drives (the host's, verbatim)
|
|
21
|
+
# ALEXANDR_UPDATER_IMAGE_TAG= # the CLI version that installed it (= the sidecar's version)
|
|
22
|
+
# ALEXANDR_DOCKER_CONFIG= # this host's docker config dir (its registry login), mounted read-only
|
|
12
23
|
|
|
13
24
|
# ---- account link (REQUIRED — the runtime refuses to serve unlinked) --------
|
|
14
25
|
# Every runtime must be linked to an alexandr account before it serves anyone
|