alexandr 0.2.2 → 0.3.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/README.md +74 -1
- package/package.json +4 -3
- package/src/app/build.js +124 -0
- package/src/app/config.js +326 -0
- package/src/app/deploy.js +234 -0
- package/src/app/dev.js +177 -0
- package/src/app/entitle.js +139 -0
- package/src/app/index.js +125 -0
- package/src/app/link.js +187 -0
- package/src/app/multipart.js +53 -0
- package/src/app/publish.js +421 -0
- package/src/app/reach.js +100 -0
- package/src/app/rollback.js +72 -0
- package/src/app/signing.js +175 -0
- package/src/app/store.js +191 -0
- package/src/app/token.js +83 -0
- package/src/app/update.js +163 -0
- package/src/cli.js +8 -1
- package/src/commands.js +97 -12
- package/src/completion.js +15 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/instance.js +34 -1
- package/src/link.js +23 -270
- package/src/prompt.js +56 -0
- package/src/updater.js +276 -0
- package/templates/docker-compose.yml +33 -0
- package/templates/env.example +10 -0
package/src/prompt.js
CHANGED
|
@@ -114,3 +114,59 @@ export async function ask(question, { def = "", validate, input = process.stdin,
|
|
|
114
114
|
return value;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A HIDDEN one-line input — for a secret, and only for a secret.
|
|
120
|
+
*
|
|
121
|
+
* ⚠⚠ THE VALUE NEVER TOUCHES ARGV. A command line is in the shell's history, in
|
|
122
|
+
* `ps` output for every user on the box while the process lives, and in any CI
|
|
123
|
+
* log that echoes the command — so `alexandr app secrets set NAME value` is
|
|
124
|
+
* refused by the parser, and this is what replaces it. Nothing is echoed and
|
|
125
|
+
* nothing is re-printed on the confirmation line.
|
|
126
|
+
*
|
|
127
|
+
* ⚠ Windows-safe by construction: pure `readline` with the output muted, no
|
|
128
|
+
* `stty`, no `/bin/sh`. `readline`'s own terminal handling raw-modes the TTY on
|
|
129
|
+
* both platforms; the muted stream swallows every write it makes while the
|
|
130
|
+
* question is up, so the keystrokes leave no trace on screen. `terminal: true`
|
|
131
|
+
* is what makes it use that handling rather than plain line buffering.
|
|
132
|
+
*
|
|
133
|
+
* Returns the raw string, untrimmed — a secret may legitimately end in
|
|
134
|
+
* whitespace, and it is the caller that decides whether an empty one is a
|
|
135
|
+
* refusal.
|
|
136
|
+
*/
|
|
137
|
+
export function secret(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
let muted = false;
|
|
140
|
+
// A thin write-only proxy over the real stream: readline draws its prompt
|
|
141
|
+
// through this, and once the question is on screen every echo is dropped.
|
|
142
|
+
const masked = Object.create(output);
|
|
143
|
+
masked.write = (chunk, ...rest) => (muted ? true : output.write(chunk, ...rest));
|
|
144
|
+
|
|
145
|
+
const rl = readline.createInterface({ input, output: masked, terminal: true });
|
|
146
|
+
rl.on("SIGINT", () => abort(output));
|
|
147
|
+
rl.question(`${cyan("›")} ${bold(question)} `, (answer) => {
|
|
148
|
+
muted = false;
|
|
149
|
+
rl.close();
|
|
150
|
+
// The prompt line is overwritten rather than left with a blank tail —
|
|
151
|
+
// there is nothing to confirm back, so it says only that it was read.
|
|
152
|
+
output.write(`\x1b[2K\r${green("✓")} ${question} ${dim("·")} read from the prompt\n`);
|
|
153
|
+
resolve(answer ?? "");
|
|
154
|
+
});
|
|
155
|
+
muted = true;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A secret from a PIPE — `printf %s "$KEY" | alexandr app secrets set NAME`.
|
|
161
|
+
*
|
|
162
|
+
* ⚠ ONE TRAILING NEWLINE IS STRIPPED AND NOTHING ELSE IS. `echo` adds one and
|
|
163
|
+
* every shell user expects it gone; a second one, or leading whitespace, is
|
|
164
|
+
* part of the value the person piped and removing it would corrupt a key that
|
|
165
|
+
* legitimately holds it (a PEM block ends in a newline).
|
|
166
|
+
*/
|
|
167
|
+
export async function readPipedSecret(input = process.stdin) {
|
|
168
|
+
const chunks = [];
|
|
169
|
+
for await (const chunk of input) chunks.push(chunk);
|
|
170
|
+
const raw = Buffer.concat(chunks.map((c) => (typeof c === "string" ? Buffer.from(c) : c))).toString("utf8");
|
|
171
|
+
return raw.endsWith("\r\n") ? raw.slice(0, -2) : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
172
|
+
}
|
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,47 @@ 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
|
+
- updater_run:/run/alexandr
|
|
58
|
+
restart: unless-stopped
|
|
59
|
+
|
|
28
60
|
# Public front door. Only started under the "public" profile — i.e. when a
|
|
29
61
|
# domain is configured (`alexandr up --domain …`). Pure-local runs skip it, so
|
|
30
62
|
# no root is needed for :80/:443 and there's no TLS machinery to babysit.
|
|
@@ -49,3 +81,4 @@ volumes:
|
|
|
49
81
|
data:
|
|
50
82
|
caddy_data:
|
|
51
83
|
caddy_config:
|
|
84
|
+
updater_run: # the updater's unix socket; the kernel mounts it read-only
|
package/templates/env.example
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
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)
|
|
12
22
|
|
|
13
23
|
# ---- account link (REQUIRED — the runtime refuses to serve unlinked) --------
|
|
14
24
|
# Every runtime must be linked to an alexandr account before it serves anyone
|