@yourrealm/sdk 0.0.0-alpha.1414.ca4ff83e → 0.0.0-alpha.1438.ee6bc935
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/dist/chunk-NSZJJPMQ.js +641 -0
- package/dist/{chunk-EN4CESPP.js → chunk-TMRBQJHI.js} +4 -108
- package/dist/cli.js +7 -123
- package/dist/index.js +1 -5
- package/dist/services.js +2 -1
- package/dist/test.js +5 -31
- package/dist/worker-protocol.js +31 -1
- package/package.json +3 -3
- package/src/bundle-ui.ts +1 -0
- package/src/compile.ts +1 -1
- package/src/dev/state.ts +1 -0
- package/src/generator/resolve-refs.ts +5 -37
- package/src/home.ts +2 -10
- package/src/index.ts +1 -2
- package/src/peer-registry.ts +2 -10
- package/src/peer-world.ts +6 -7
- package/src/postgres.ts +4 -3
- package/src/ref.ts +2 -4
- package/src/resolve.ts +2 -13
- package/src/schema/host.ts +6 -7
- package/src/schema/peer.ts +4 -161
- package/src/service-definition.ts +62 -38
- package/src/test/boot.ts +1 -0
- package/src/test/compile.ts +3 -12
- package/src/test/types.ts +1 -1
- package/src/valkey.ts +2 -1
- package/src/worker/start-worker.ts +54 -2
- package/src/worker/uplink.ts +14 -1
- package/src/worker-protocol/app-dirs.ts +4 -4
- package/src/worker-protocol/backup.ts +409 -0
- package/src/worker-protocol/index.ts +33 -0
- package/src/worker-protocol/logs-socket-path.ts +1 -1
- package/src/worker-protocol/uplink/methods.ts +75 -2
- package/src/worker-protocol/uplink/protocol.ts +1 -1
- package/src/worker-protocol/worker-state.ts +10 -2
- package/dist/chunk-67BDAMYX.js +0 -376
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SPECULATIVE_SILO,
|
|
3
|
+
hostKey,
|
|
4
|
+
nameSchema
|
|
5
|
+
} from "./chunk-VHTUSZUX.js";
|
|
6
|
+
|
|
7
|
+
// src/worker-protocol/worker-state.ts
|
|
8
|
+
import { z as z3 } from "zod";
|
|
9
|
+
|
|
10
|
+
// src/worker-protocol/backup.ts
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
var keepSchema = z.object({
|
|
13
|
+
daily: z.number().int().nonnegative(),
|
|
14
|
+
weekly: z.number().int().nonnegative(),
|
|
15
|
+
monthly: z.number().int().nonnegative()
|
|
16
|
+
});
|
|
17
|
+
var hostPathSchema = z.string().regex(/^\.\/apps\/[^/]+\/./, { message: "must be ./apps/<appId>/..." }).refine(
|
|
18
|
+
(p) => !p.split("/").slice(1).some((seg) => seg === "" || seg === "." || seg === ".."),
|
|
19
|
+
{
|
|
20
|
+
message: "must be a normalized path with no empty, . or .. segments"
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
var backupTargetStrategySchema = z.discriminatedUnion("kind", [
|
|
24
|
+
z.object({ kind: z.literal("file-copy"), hostPath: hostPathSchema }),
|
|
25
|
+
z.object({ kind: z.literal("sqlite"), hostPath: hostPathSchema, file: z.string().min(1) }),
|
|
26
|
+
// `url` carries the resolved credentials; the Worker passes them to the dump
|
|
27
|
+
// tool as exec env. `version` is informational: the dump runs with the tool
|
|
28
|
+
// the database image ships, which always matches its server.
|
|
29
|
+
z.object({
|
|
30
|
+
kind: z.literal("pg-dump"),
|
|
31
|
+
url: z.string().min(1),
|
|
32
|
+
version: z.number().int().positive()
|
|
33
|
+
}),
|
|
34
|
+
z.object({
|
|
35
|
+
kind: z.literal("mysql-dump"),
|
|
36
|
+
url: z.string().min(1),
|
|
37
|
+
version: z.number().int().positive()
|
|
38
|
+
}),
|
|
39
|
+
z.object({ kind: z.literal("valkey-save"), url: z.string().min(1) })
|
|
40
|
+
]);
|
|
41
|
+
var backupVolumeSchema = z.string().min(1).refine((v) => !v.includes(","), { message: "must not contain a comma" }).refine((v) => !v.split("/").some((seg) => seg === "" || seg === "." || seg === ".."), {
|
|
42
|
+
message: "must be a normalized relative path with no empty, . or .. segments"
|
|
43
|
+
});
|
|
44
|
+
var backupTargetSchema = z.object({
|
|
45
|
+
appId: z.string().min(1),
|
|
46
|
+
serviceKey: z.string().min(1),
|
|
47
|
+
volume: backupVolumeSchema,
|
|
48
|
+
strategy: backupTargetStrategySchema
|
|
49
|
+
}).refine(
|
|
50
|
+
(t) => !("hostPath" in t.strategy) || t.strategy.hostPath.startsWith(`./apps/${t.appId}/`),
|
|
51
|
+
{
|
|
52
|
+
message: "a target's folder must be its own app's",
|
|
53
|
+
path: ["strategy", "hostPath"]
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
var offsiteSchema = z.object({ enabled: z.literal(true), generation: z.string().min(1) });
|
|
57
|
+
var backupStateSchema = z.object({
|
|
58
|
+
/** Cron expression. */
|
|
59
|
+
schedule: z.string().min(1),
|
|
60
|
+
keep: keepSchema,
|
|
61
|
+
/** Derived per worker by Home; a worker only ever sees its own. */
|
|
62
|
+
resticPassword: z.string().min(1),
|
|
63
|
+
offsite: offsiteSchema.nullable(),
|
|
64
|
+
/**
|
|
65
|
+
* Disaster recovery: scheduled and startup runs and offsite copies don't
|
|
66
|
+
* start, so a rebuilt box writes nothing to the bucket before the operator
|
|
67
|
+
* has restored from it. Restores and the snapshot listing still run.
|
|
68
|
+
*/
|
|
69
|
+
hold: z.boolean(),
|
|
70
|
+
targets: z.array(backupTargetSchema).min(1).refine((ts) => new Set(ts.map((t) => backupTargetKey(t))).size === ts.length, {
|
|
71
|
+
message: "two targets name the same app, service and volume"
|
|
72
|
+
})
|
|
73
|
+
}).nullable();
|
|
74
|
+
function backupTargetKey(t) {
|
|
75
|
+
return `${t.appId}/${t.serviceKey}/${t.volume}`;
|
|
76
|
+
}
|
|
77
|
+
var BACKUP_SAFETY_TAG = "safety";
|
|
78
|
+
var STRATEGY_TAG_PREFIX = "strategy:";
|
|
79
|
+
function backupSnapshotTags(t, kind, safety = false) {
|
|
80
|
+
return [
|
|
81
|
+
backupTargetKey(t),
|
|
82
|
+
`${STRATEGY_TAG_PREFIX}${kind}`,
|
|
83
|
+
...safety ? [BACKUP_SAFETY_TAG] : []
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
function parseBackupSnapshotTags(tags) {
|
|
87
|
+
let target = null;
|
|
88
|
+
let kind = null;
|
|
89
|
+
let safety = false;
|
|
90
|
+
for (const tag of tags) {
|
|
91
|
+
if (tag === BACKUP_SAFETY_TAG) {
|
|
92
|
+
safety = true;
|
|
93
|
+
} else if (tag.startsWith(STRATEGY_TAG_PREFIX)) {
|
|
94
|
+
kind = tag.slice(STRATEGY_TAG_PREFIX.length) || null;
|
|
95
|
+
} else {
|
|
96
|
+
const m = /^([^/]+)\/([^/]+)\/(.+)$/.exec(tag);
|
|
97
|
+
if (m !== null) target = { appId: m[1], serviceKey: m[2], volume: m[3] };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return target === null || kind === null ? null : { ...target, kind, safety };
|
|
101
|
+
}
|
|
102
|
+
var BACKUP_FILE_TYPES = ["config", "data", "index", "keys", "locks", "snapshots"];
|
|
103
|
+
var backupSignRequestSchema = z.object({
|
|
104
|
+
method: z.enum(["GET", "HEAD", "PUT", "DELETE", "LIST"]),
|
|
105
|
+
type: z.enum(BACKUP_FILE_TYPES),
|
|
106
|
+
/** The file's name. Absent for `config` and `LIST`. */
|
|
107
|
+
name: z.string().min(1).optional(),
|
|
108
|
+
/** Lowercase hex SHA-256 of a `PUT` body, which the signature covers. */
|
|
109
|
+
sha256: z.string().min(1).optional(),
|
|
110
|
+
/** Byte length of a `PUT` body. */
|
|
111
|
+
length: z.number().int().nonnegative().optional(),
|
|
112
|
+
/** A `LIST` page's `NextContinuationToken`. */
|
|
113
|
+
continuation: z.string().min(1).optional(),
|
|
114
|
+
/**
|
|
115
|
+
* `offsite.generation` from the state the run started with. Home refuses
|
|
116
|
+
* (409) one that isn't the bucket's current generation.
|
|
117
|
+
*/
|
|
118
|
+
generation: z.string().min(1)
|
|
119
|
+
});
|
|
120
|
+
var BACKUP_STALE_GENERATION = "the bucket changed since this run started";
|
|
121
|
+
var BACKUP_FILE_EXISTS = "the file is already in the bucket";
|
|
122
|
+
var backupSignResultSchema = z.object({
|
|
123
|
+
url: z.string().min(1),
|
|
124
|
+
headers: z.record(z.string(), z.string())
|
|
125
|
+
});
|
|
126
|
+
var outcomeSchema = z.enum(["ok", "failed", "skipped"]);
|
|
127
|
+
var targetStatusSchema = z.object({
|
|
128
|
+
appId: z.string(),
|
|
129
|
+
serviceKey: z.string(),
|
|
130
|
+
volume: z.string(),
|
|
131
|
+
kind: z.string(),
|
|
132
|
+
/** ISO-8601 of the attempt that settled this outcome. */
|
|
133
|
+
at: z.string(),
|
|
134
|
+
outcome: outcomeSchema,
|
|
135
|
+
/** Why it failed or was skipped. Absent on `ok`. */
|
|
136
|
+
error: z.string().optional(),
|
|
137
|
+
/** Attempts in that run; above 1 means the startup retry ran. */
|
|
138
|
+
attempts: z.number()
|
|
139
|
+
});
|
|
140
|
+
var repoStatusBase = {
|
|
141
|
+
at: z.string(),
|
|
142
|
+
outcome: outcomeSchema,
|
|
143
|
+
error: z.string().optional()
|
|
144
|
+
};
|
|
145
|
+
var repoStatusSchema = z.discriminatedUnion("repo", [
|
|
146
|
+
z.object({ repo: z.literal("local"), ...repoStatusBase }),
|
|
147
|
+
z.object({
|
|
148
|
+
repo: z.literal("offsite"),
|
|
149
|
+
...repoStatusBase,
|
|
150
|
+
/** `offsite.generation` the copy ran against; null if it never got one. */
|
|
151
|
+
generation: z.string().nullable()
|
|
152
|
+
})
|
|
153
|
+
]);
|
|
154
|
+
var SNAPSHOT_ID_RE = /^[0-9a-f]{64}$/;
|
|
155
|
+
var snapshotSchema = z.object({
|
|
156
|
+
/**
|
|
157
|
+
* restic's full id, not `short_id`. A local snapshot and its offsite copy
|
|
158
|
+
* have different ids; a restore names one of these exact strings.
|
|
159
|
+
*/
|
|
160
|
+
id: z.string().regex(SNAPSHOT_ID_RE),
|
|
161
|
+
time: z.string(),
|
|
162
|
+
/** Taken by a restore just before it touched anything. */
|
|
163
|
+
safety: z.boolean()
|
|
164
|
+
});
|
|
165
|
+
var snapshotGroupSchema = z.object({
|
|
166
|
+
appId: z.string(),
|
|
167
|
+
serviceKey: z.string(),
|
|
168
|
+
volume: z.string(),
|
|
169
|
+
/** The strategy, from the `strategy:` tag. */
|
|
170
|
+
kind: z.string(),
|
|
171
|
+
local: z.array(snapshotSchema),
|
|
172
|
+
/** Null when offsite isn't set up, or its listing failed (see `repos`). */
|
|
173
|
+
offsite: z.array(snapshotSchema).nullable()
|
|
174
|
+
});
|
|
175
|
+
var restoreSourceSchema = z.enum(["local", "offsite"]);
|
|
176
|
+
var restoreItemSchema = z.object({
|
|
177
|
+
serviceKey: z.string().min(1),
|
|
178
|
+
volume: backupVolumeSchema,
|
|
179
|
+
snapshotId: z.string().regex(SNAPSHOT_ID_RE),
|
|
180
|
+
source: restoreSourceSchema
|
|
181
|
+
});
|
|
182
|
+
var backupRestoreRequestSchema = z.object({
|
|
183
|
+
restoreId: z.uuid(),
|
|
184
|
+
appId: z.string().min(1),
|
|
185
|
+
items: z.array(restoreItemSchema).min(1).refine(
|
|
186
|
+
(items) => new Set(items.map((i) => `${i.serviceKey}/${i.volume}`)).size === items.length,
|
|
187
|
+
{ message: "a volume is named twice" }
|
|
188
|
+
)
|
|
189
|
+
});
|
|
190
|
+
var BACKUP_RESTORE_PHASES = [
|
|
191
|
+
"queued",
|
|
192
|
+
"safety",
|
|
193
|
+
"stopping",
|
|
194
|
+
"restoring",
|
|
195
|
+
"starting",
|
|
196
|
+
"done",
|
|
197
|
+
"failed"
|
|
198
|
+
];
|
|
199
|
+
var BACKUP_RESTORE_INTERRUPTED = "the Worker restarted during the restore";
|
|
200
|
+
var restoreSchema = z.object({
|
|
201
|
+
restoreId: z.string(),
|
|
202
|
+
appId: z.string(),
|
|
203
|
+
startedAt: z.string(),
|
|
204
|
+
finishedAt: z.string().nullable(),
|
|
205
|
+
phase: z.enum(BACKUP_RESTORE_PHASES),
|
|
206
|
+
items: z.array(
|
|
207
|
+
restoreItemSchema.extend({
|
|
208
|
+
outcome: z.enum(["pending", "ok", "failed"]),
|
|
209
|
+
error: z.string().optional()
|
|
210
|
+
})
|
|
211
|
+
),
|
|
212
|
+
/** It failed, and the safety snapshot was put back. */
|
|
213
|
+
rolledBack: z.boolean(),
|
|
214
|
+
/** What happened, when it failed. */
|
|
215
|
+
error: z.string().nullable()
|
|
216
|
+
});
|
|
217
|
+
var backupStatusSchema = z.object({
|
|
218
|
+
/** ISO-8601 by the Worker's clock when it sent this, for Home to measure drift. */
|
|
219
|
+
sentAt: z.iso.datetime(),
|
|
220
|
+
offsiteConfigured: z.boolean(),
|
|
221
|
+
schedule: z.string(),
|
|
222
|
+
retention: keepSchema,
|
|
223
|
+
/** A run is in progress right now. */
|
|
224
|
+
running: z.boolean(),
|
|
225
|
+
/** ISO-8601, null before the first run settles. */
|
|
226
|
+
lastRunAt: z.string().nullable(),
|
|
227
|
+
nextRunAt: z.string().nullable(),
|
|
228
|
+
/** The schedule doesn't parse, so only the startup run happens. */
|
|
229
|
+
scheduleError: z.string().nullable(),
|
|
230
|
+
/** The last run stopped before reaching any target (an unreadable config, say). */
|
|
231
|
+
runError: z.string().nullable(),
|
|
232
|
+
targets: z.array(targetStatusSchema),
|
|
233
|
+
repos: z.array(repoStatusSchema),
|
|
234
|
+
snapshots: z.array(snapshotGroupSchema),
|
|
235
|
+
/**
|
|
236
|
+
* ISO-8601 by the Worker's clock of its last successful listing of the
|
|
237
|
+
* bucket, by a run or a read-only refresh. Null with no bucket, or before
|
|
238
|
+
* the first listing that worked: `snapshots[].offsite` says nothing until
|
|
239
|
+
* this is set.
|
|
240
|
+
*/
|
|
241
|
+
offsiteListedAt: z.string().nullable(),
|
|
242
|
+
/**
|
|
243
|
+
* Apps a restore left stopped on data Realm can't vouch for - a rollback
|
|
244
|
+
* failed, or a dropped database could not be put back. Nothing starts one
|
|
245
|
+
* until an admin releases it (`backup.release`) or a later restore of it
|
|
246
|
+
* finishes. An app is otherwise held only while its restore runs, which
|
|
247
|
+
* `restores` already shows.
|
|
248
|
+
*/
|
|
249
|
+
held: z.array(z.object({ appId: z.string(), restoreId: z.string(), reason: z.string() })),
|
|
250
|
+
/** The latest restore of each app that has had one since the Worker started. */
|
|
251
|
+
restores: z.array(restoreSchema)
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// src/worker-protocol/ingress.ts
|
|
255
|
+
import { z as z2 } from "zod";
|
|
256
|
+
var acmeIssuerSchema = z2.object({
|
|
257
|
+
serverUrl: z2.string(),
|
|
258
|
+
username: z2.string(),
|
|
259
|
+
apiKey: z2.string(),
|
|
260
|
+
ca: z2.string(),
|
|
261
|
+
caRootPem: z2.string()
|
|
262
|
+
});
|
|
263
|
+
var ingressRouterSchema = z2.object({
|
|
264
|
+
containerPort: z2.number().int().min(1).max(65535),
|
|
265
|
+
/** Operator-granted hostnames for the additional ungated public route. */
|
|
266
|
+
publicDomain: z2.union([z2.string(), z2.array(z2.string())]).optional(),
|
|
267
|
+
/** With several granted hosts: serve them all, or serve one and 301 the rest. */
|
|
268
|
+
domainMode: z2.enum(["redirect", "all"]),
|
|
269
|
+
pathPrefix: z2.string().regex(/^\/[a-zA-Z0-9/_-]*$/, "pathPrefix must start with / and contain only [a-zA-Z0-9/_-]").optional(),
|
|
270
|
+
compress: z2.boolean(),
|
|
271
|
+
maxRequestSizeMb: z2.number().nullable(),
|
|
272
|
+
rateLimit: z2.union([
|
|
273
|
+
z2.number(),
|
|
274
|
+
z2.object({
|
|
275
|
+
requests: z2.number(),
|
|
276
|
+
window: z2.string().regex(/^\d+(ms|s|m|h)$/, "window must be a duration like 10s, 1m, 1h")
|
|
277
|
+
})
|
|
278
|
+
]).nullable(),
|
|
279
|
+
/** Response-header overrides; `null` deletes the renderer's default. */
|
|
280
|
+
headers: z2.record(z2.string(), z2.string().nullable()).optional()
|
|
281
|
+
});
|
|
282
|
+
var ingressRoutedAppSchema = z2.object({
|
|
283
|
+
appId: z2.string(),
|
|
284
|
+
services: z2.array(z2.object({ serviceId: z2.string(), router: ingressRouterSchema })),
|
|
285
|
+
/**
|
|
286
|
+
* Header-brokering names the app declared via `getTrustedHeaders`. The
|
|
287
|
+
* renderer injects these on the authenticated route (lifted off the
|
|
288
|
+
* forward-auth response) and strips them everywhere else, so identity can't
|
|
289
|
+
* be spoofed.
|
|
290
|
+
*/
|
|
291
|
+
trustedHeaderKeys: z2.array(z2.string()).optional()
|
|
292
|
+
});
|
|
293
|
+
var ingressInputSchema = z2.object({
|
|
294
|
+
/** Every routed service on this worker, ordered canonically by `appId`. */
|
|
295
|
+
routedApps: z2.array(ingressRoutedAppSchema),
|
|
296
|
+
/**
|
|
297
|
+
* The Home dashboard's own host, `{homeSlug}.{workerId}.{baseDomain}` - one
|
|
298
|
+
* label under the worker wildcard, which a renderer has to match ahead of
|
|
299
|
+
* that wildcard so the not-routed holding page never shadows it.
|
|
300
|
+
*
|
|
301
|
+
* Null on a box that does not run Home. The name resolves realm-wide (it
|
|
302
|
+
* sits under the realm's wildcard), so emitting the route on a worker-only
|
|
303
|
+
* box would answer a hit there with a page claiming the control plane is
|
|
304
|
+
* down - and, under BYO public providers, cost a real per-host certificate
|
|
305
|
+
* on every worker. Home knows which box is which from `home_state
|
|
306
|
+
* .home_worker_id`.
|
|
307
|
+
*/
|
|
308
|
+
dashboardHost: z2.string().nullable(),
|
|
309
|
+
/**
|
|
310
|
+
* This worker's short slug: the `{workerId}` level of every app subdomain,
|
|
311
|
+
* and the label it owns under acme-dns.
|
|
312
|
+
*/
|
|
313
|
+
workerId: z2.string(),
|
|
314
|
+
/**
|
|
315
|
+
* The realm's domain base (`{slug}.yourrealm.me`, or the operator's own).
|
|
316
|
+
* With `workerId` it forms every structured authenticated subdomain. Empty
|
|
317
|
+
* on the placeholder identity, before an install is configured.
|
|
318
|
+
*/
|
|
319
|
+
baseDomain: z2.string(),
|
|
320
|
+
/** DNS-01 inputs for the worker wildcard, or null (dev / e2e / first boot). */
|
|
321
|
+
acmeDns: acmeIssuerSchema.extend({ baseDomain: z2.string() }).nullable(),
|
|
322
|
+
/**
|
|
323
|
+
* BYO public-DNS providers, in issue order. `apiToken` is a plain string:
|
|
324
|
+
* a `Ref` cannot ride the wire, and by this point Home has resolved it.
|
|
325
|
+
*/
|
|
326
|
+
publicProviders: z2.array(
|
|
327
|
+
z2.object({ name: z2.enum(["cloudflare", "hetzner"]), apiToken: z2.string() })
|
|
328
|
+
),
|
|
329
|
+
/**
|
|
330
|
+
* CDNs in front of this realm, whose edge addresses ingress trusts when
|
|
331
|
+
* resolving the real client IP. The addresses themselves are not here - the
|
|
332
|
+
* renderer's runtime fetches and refreshes them.
|
|
333
|
+
*/
|
|
334
|
+
cdnProviders: z2.array(z2.string()),
|
|
335
|
+
/**
|
|
336
|
+
* The signing-certificate fingerprints in the Digital Asset Links statement
|
|
337
|
+
* the ingress serves on every host - the statement's
|
|
338
|
+
* `sha256_cert_fingerprints`, which Chrome matches the companion APK's own
|
|
339
|
+
* signature against before it drops the Trusted Web Activity's toolbar.
|
|
340
|
+
*
|
|
341
|
+
* Operator-set rather than a constant, because which certificate signs the
|
|
342
|
+
* installed APK is a property of how that operator got it: Play App Signing
|
|
343
|
+
* re-signs with Google's key, direct distribution keeps the release
|
|
344
|
+
* keystore's, and a locally built debug APK carries a third.
|
|
345
|
+
*
|
|
346
|
+
* The shape is pinned here because the renderer inlines these strings into a
|
|
347
|
+
* JSON body Caddy's `ReplaceKnown` walks: uppercase hex bytes and colons can
|
|
348
|
+
* never form a `{placeholder}`, so a value that parses cannot reach into the
|
|
349
|
+
* replacer.
|
|
350
|
+
*/
|
|
351
|
+
companionFingerprints: z2.array(
|
|
352
|
+
z2.string().regex(/^[0-9A-F]{2}(:[0-9A-F]{2}){31}$/, "expected 32 colon-separated uppercase hex bytes")
|
|
353
|
+
)
|
|
354
|
+
});
|
|
355
|
+
var PLATFORM_INGRESS_PORTS = [80, 443];
|
|
356
|
+
|
|
357
|
+
// src/worker-protocol/worker-state.ts
|
|
358
|
+
var dependsOnEdgeSchema = z3.object({
|
|
359
|
+
providerAppId: z3.string(),
|
|
360
|
+
providerServiceName: z3.string(),
|
|
361
|
+
condition: z3.enum(["started", "healthy"])
|
|
362
|
+
});
|
|
363
|
+
var duration = z3.number().int().nonnegative();
|
|
364
|
+
var containerPort = z3.number().int().min(1).max(65535);
|
|
365
|
+
var healthcheckSchema = z3.object({
|
|
366
|
+
test: z3.array(z3.string()),
|
|
367
|
+
interval: duration,
|
|
368
|
+
timeout: duration,
|
|
369
|
+
retries: z3.number().int().nonnegative(),
|
|
370
|
+
startPeriod: duration
|
|
371
|
+
});
|
|
372
|
+
var hostPortSchema = z3.object({
|
|
373
|
+
containerPort,
|
|
374
|
+
hostPort: z3.number().int().min(1).max(65535),
|
|
375
|
+
protocol: z3.enum(["tcp", "udp"]).optional()
|
|
376
|
+
});
|
|
377
|
+
var backupStrategySchema = z3.discriminatedUnion("kind", [
|
|
378
|
+
z3.object({ kind: z3.literal("file-copy") }),
|
|
379
|
+
z3.object({ kind: z3.literal("sqlite"), file: z3.string() }),
|
|
380
|
+
z3.object({ kind: z3.literal("pg-dump"), url: z3.string(), version: z3.number().int().positive() }),
|
|
381
|
+
z3.object({ kind: z3.literal("valkey-save"), url: z3.string() }),
|
|
382
|
+
z3.object({
|
|
383
|
+
kind: z3.literal("mysql-dump"),
|
|
384
|
+
url: z3.string(),
|
|
385
|
+
version: z3.number().int().positive()
|
|
386
|
+
})
|
|
387
|
+
]);
|
|
388
|
+
var volumeSchema = z3.object({
|
|
389
|
+
// Refused by name: the wire is where "this was fully resolved" stops being a
|
|
390
|
+
// promise and becomes a check. A speculative eval fabricates a Silo so an app
|
|
391
|
+
// can be previewed before the operator picks one (`speculative-silo.ts`), and
|
|
392
|
+
// that placeholder is otherwise an ordinary absolute path - shipping it binds
|
|
393
|
+
// an empty directory in place of the operator's library, silently. `evalApps`
|
|
394
|
+
// refuses that combination up front; this is the half that cannot be bypassed.
|
|
395
|
+
hostPath: z3.string().refine((p) => p !== SPECULATIVE_SILO.hostPath, {
|
|
396
|
+
message: "the speculative Silo placeholder must never reach the wire"
|
|
397
|
+
}),
|
|
398
|
+
containerPath: z3.string(),
|
|
399
|
+
// The Silo group that owns this directory. The renderer turns it into
|
|
400
|
+
// `group_add` on every service that mounts the volume, so directory
|
|
401
|
+
// preparation and group membership are driven by the same field and can
|
|
402
|
+
// never disagree.
|
|
403
|
+
gid: z3.number().int().positive().optional(),
|
|
404
|
+
readOnly: z3.boolean().optional(),
|
|
405
|
+
backup: z3.union([backupStrategySchema, z3.literal(false)]).optional()
|
|
406
|
+
});
|
|
407
|
+
var wireServiceSchema = z3.object({
|
|
408
|
+
image: z3.string().min(1),
|
|
409
|
+
hostPorts: z3.array(hostPortSchema).optional(),
|
|
410
|
+
env: z3.record(z3.string(), z3.union([z3.string(), z3.number()])).optional(),
|
|
411
|
+
// containerPath -> 64-char hex digest the renderer resolves to bytes. The two
|
|
412
|
+
// sources hash different things: artifact refs collapse to their bare content
|
|
413
|
+
// sha, while inline content is keyed by *identity* — sha256 of
|
|
414
|
+
// `appId:serviceKey:fileKey` (plaintext rides in `WorkerApp.inlineFiles`). An
|
|
415
|
+
// inline file therefore keeps the same digest and worker path across content
|
|
416
|
+
// changes, so `--watch` consumers reload without recreating the container.
|
|
417
|
+
files: z3.record(z3.string().startsWith("/"), z3.string().regex(/^[a-f0-9]{64}$/)).optional(),
|
|
418
|
+
command: z3.array(z3.string()).optional(),
|
|
419
|
+
volumes: z3.array(volumeSchema).optional(),
|
|
420
|
+
gpu: z3.boolean().optional(),
|
|
421
|
+
iptables: z3.boolean().optional(),
|
|
422
|
+
joinNetworks: z3.array(z3.string()).optional(),
|
|
423
|
+
healthcheck: healthcheckSchema.optional(),
|
|
424
|
+
memory: z3.string().optional(),
|
|
425
|
+
cpu: z3.number().positive().max(128).optional(),
|
|
426
|
+
// Defaulted to 1 upstream, so always present on the wire.
|
|
427
|
+
replicas: z3.number().int().min(1).max(100),
|
|
428
|
+
labels: z3.array(z3.string()).optional(),
|
|
429
|
+
shmSizeMb: z3.number().int().positive().optional()
|
|
430
|
+
});
|
|
431
|
+
var workerAppSchema = z3.object({
|
|
432
|
+
appId: z3.string(),
|
|
433
|
+
// Flattened: in the resolve model these live under `.state` (matching
|
|
434
|
+
// `getDesiredState`'s shape), but on the wire only these two of that
|
|
435
|
+
// object's fields survive, so they're hoisted onto the app directly.
|
|
436
|
+
services: z3.record(nameSchema, wireServiceSchema),
|
|
437
|
+
/** `true` makes the per-app network `internal: true` (no egress). */
|
|
438
|
+
egress: z3.literal(true).optional(),
|
|
439
|
+
/** Worker-owned external nets this app's services join (e.g. `management-net`). */
|
|
440
|
+
externalNetworks: z3.array(z3.string()),
|
|
441
|
+
/**
|
|
442
|
+
* Service-name (unprefixed) -> producer edges. The renderer namespaces
|
|
443
|
+
* `providerServiceName` with `providerAppId` to form the final compose key.
|
|
444
|
+
*/
|
|
445
|
+
dependsOn: z3.record(z3.string(), z3.array(dependsOnEdgeSchema)),
|
|
446
|
+
/** Plaintext for inline `files` entries, keyed by their identity digest. */
|
|
447
|
+
inlineFiles: z3.record(z3.string(), z3.string()),
|
|
448
|
+
/**
|
|
449
|
+
* Absolute host paths exempted from the absolute-mount gate and
|
|
450
|
+
* bound verbatim by the renderer (peer backup volumes, the logs socket).
|
|
451
|
+
*/
|
|
452
|
+
allowedAbsoluteHostPaths: z3.array(z3.string()),
|
|
453
|
+
/**
|
|
454
|
+
* Host path the worker binds this app's `home-proxy.sock` at. Present only
|
|
455
|
+
* when the app holds a `home.apiAccess` grant.
|
|
456
|
+
*/
|
|
457
|
+
homeProxySocketPath: z3.string().optional(),
|
|
458
|
+
/**
|
|
459
|
+
* Host path the worker binds this app's spawn broker socket at. Present
|
|
460
|
+
* only when the app holds a `host.spawn` grant.
|
|
461
|
+
*/
|
|
462
|
+
spawnSocketPath: z3.string().optional(),
|
|
463
|
+
/**
|
|
464
|
+
* This app's registry credentials, keyed by registry host. The worker
|
|
465
|
+
* renders a per-app docker `config.json` from these so private images pull.
|
|
466
|
+
*/
|
|
467
|
+
registryAuth: z3.record(hostKey, z3.object({ username: z3.string(), password: z3.string() })).optional()
|
|
468
|
+
});
|
|
469
|
+
var workerStateSchema = z3.object({
|
|
470
|
+
apps: z3.array(workerAppSchema),
|
|
471
|
+
/** SHAs of every artifact the worker keeps in `files/` after a reconcile. */
|
|
472
|
+
artifactHashes: z3.array(z3.string()),
|
|
473
|
+
/** Docker networks the worker container itself joins this reconcile. */
|
|
474
|
+
workerNetworks: z3.array(z3.string()),
|
|
475
|
+
/**
|
|
476
|
+
* Where traffic goes on this box, as `apps` says what runs on it. Runtime-
|
|
477
|
+
* neutral: the Docker worker renders it with `buildCaddyConfig`
|
|
478
|
+
* (`runtime/docker/ingress`) exactly as it renders `apps` with
|
|
479
|
+
* `convertToDockerCompose`.
|
|
480
|
+
*
|
|
481
|
+
* Required, not optional. Every producer can state an honest value - an
|
|
482
|
+
* unconfigured box gets the empty-identity placeholder, which is a real
|
|
483
|
+
* answer ("no domain yet") rather than a missing one - and a box with no
|
|
484
|
+
* schedulable app still needs a front door, so absence must never be a legal
|
|
485
|
+
* way to say "no ingress here".
|
|
486
|
+
*/
|
|
487
|
+
ingress: ingressInputSchema,
|
|
488
|
+
/**
|
|
489
|
+
* What this box backs up, and where to. Null when nothing on it has a backup
|
|
490
|
+
* strategy - required-nullable like `ingress`, so "no backup here" is a
|
|
491
|
+
* stated answer. The Docker worker runs it in-process on its cron.
|
|
492
|
+
*/
|
|
493
|
+
backup: backupStateSchema
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// src/worker-protocol/node-version.ts
|
|
497
|
+
import { z as z4 } from "zod";
|
|
498
|
+
var nodeVersionSchema = z4.object({
|
|
499
|
+
// The pinned ref this container runs: `benjick/realm-worker@sha256:…` for a
|
|
500
|
+
// pulled image, a bare image ID for a locally-loaded one (see the installer's
|
|
501
|
+
// pin_image). This is the string an `applied` audit entry matches against.
|
|
502
|
+
imageRef: z4.string().nullable(),
|
|
503
|
+
// The commit the image was built from - a full SHA from the publish workflow,
|
|
504
|
+
// null for anything built outside it.
|
|
505
|
+
gitHash: z4.string().nullable(),
|
|
506
|
+
// ISO-8601 build timestamp, read back off the image's OCI `created` label by
|
|
507
|
+
// the publish workflow so the reported date and the image's own can't drift.
|
|
508
|
+
buildDate: z4.string().nullable()
|
|
509
|
+
});
|
|
510
|
+
function readEnv(name) {
|
|
511
|
+
const value = Deno.env.get(name);
|
|
512
|
+
return value === void 0 || value === "" ? null : value;
|
|
513
|
+
}
|
|
514
|
+
function readNodeVersion() {
|
|
515
|
+
return {
|
|
516
|
+
imageRef: readEnv("REALM_IMAGE_REF"),
|
|
517
|
+
gitHash: readEnv("REALM_GIT_HASH"),
|
|
518
|
+
buildDate: readEnv("REALM_BUILD_DATE")
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function shortNodeVersion(version) {
|
|
522
|
+
if (version.gitHash) return version.gitHash.slice(0, 7);
|
|
523
|
+
const digest = version.imageRef?.split("sha256:")[1];
|
|
524
|
+
return digest ? digest.slice(0, 12) : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/worker-protocol/update-files.ts
|
|
528
|
+
var UPDATE_ERROR_MAX_CHARS = 2048;
|
|
529
|
+
var MAX_ERROR_FILE_BYTES = 16 * 1024;
|
|
530
|
+
async function readUpdateFiles(dir) {
|
|
531
|
+
const [pending, error] = await Promise.all([
|
|
532
|
+
isFile(`${dir}/.update`),
|
|
533
|
+
readError(`${dir}/.update.error`)
|
|
534
|
+
]);
|
|
535
|
+
return { pending, error };
|
|
536
|
+
}
|
|
537
|
+
async function isFile(path) {
|
|
538
|
+
try {
|
|
539
|
+
return (await Deno.stat(path)).isFile;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async function readError(path) {
|
|
545
|
+
let text;
|
|
546
|
+
try {
|
|
547
|
+
const stat = await Deno.stat(path);
|
|
548
|
+
if (!stat.isFile || stat.size > MAX_ERROR_FILE_BYTES) return null;
|
|
549
|
+
text = await Deno.readTextFile(path);
|
|
550
|
+
} catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const trimmed = text.trim();
|
|
554
|
+
if (trimmed === "") return null;
|
|
555
|
+
return trimmed.length > UPDATE_ERROR_MAX_CHARS ? trimmed.slice(-UPDATE_ERROR_MAX_CHARS) : trimmed;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/worker-protocol/app-dirs.ts
|
|
559
|
+
var REALM_APP_DIR_GID = 999999;
|
|
560
|
+
function isPreparedAppDir(appId, volume, exemptHostPaths) {
|
|
561
|
+
if (volume.gid !== void 0) return false;
|
|
562
|
+
if (exemptHostPaths.has(volume.hostPath)) return false;
|
|
563
|
+
if (!volume.hostPath.startsWith("./")) return false;
|
|
564
|
+
return scopeBindSource(volume.hostPath, appId) !== `./apps/${appId}`;
|
|
565
|
+
}
|
|
566
|
+
function scopeBindSource(source, appId) {
|
|
567
|
+
if (!source.startsWith("./")) return source;
|
|
568
|
+
const scoped = `./apps/${appId}/${source.slice(2)}`;
|
|
569
|
+
const segments = scoped.split("/");
|
|
570
|
+
const resolved = [];
|
|
571
|
+
for (const seg of segments) {
|
|
572
|
+
if (seg === "..") resolved.pop();
|
|
573
|
+
else if (seg !== "." && seg !== "") resolved.push(seg);
|
|
574
|
+
}
|
|
575
|
+
const normalized = "./" + resolved.join("/");
|
|
576
|
+
if (!normalized.startsWith(`./apps/${appId}/`) && normalized !== `./apps/${appId}`) {
|
|
577
|
+
throw new Error(`bind source escapes app directory: ${source}`);
|
|
578
|
+
}
|
|
579
|
+
return normalized;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/worker-protocol/silo-paths.ts
|
|
583
|
+
var REALM_SILO_DIR = "/opt/realm/worker/silo";
|
|
584
|
+
function managedSiloPath(id) {
|
|
585
|
+
return `${REALM_SILO_DIR}/${id}`;
|
|
586
|
+
}
|
|
587
|
+
function isManagedSiloPath(hostPath) {
|
|
588
|
+
return hostPath.startsWith(`${REALM_SILO_DIR}/`);
|
|
589
|
+
}
|
|
590
|
+
function shellQuote(path) {
|
|
591
|
+
return `'${path.replaceAll("'", "'\\''")}'`;
|
|
592
|
+
}
|
|
593
|
+
function siloSetupCommand(silo) {
|
|
594
|
+
const path = shellQuote(silo.hostPath);
|
|
595
|
+
return `chgrp -R ${silo.gid} ${path}
|
|
596
|
+
chmod g+rwxs ${path}`;
|
|
597
|
+
}
|
|
598
|
+
function siloWidenCommand(silo) {
|
|
599
|
+
return `find ${shellQuote(silo.hostPath)} -type d -exec chmod g+rwxs {} +`;
|
|
600
|
+
}
|
|
601
|
+
function siloReclaimCommand(orphan) {
|
|
602
|
+
return `ssh root@${orphan.workerIp}
|
|
603
|
+
rm -rf ${shellQuote(orphan.hostPath)}`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export {
|
|
607
|
+
backupVolumeSchema,
|
|
608
|
+
backupStateSchema,
|
|
609
|
+
backupTargetKey,
|
|
610
|
+
BACKUP_SAFETY_TAG,
|
|
611
|
+
backupSnapshotTags,
|
|
612
|
+
parseBackupSnapshotTags,
|
|
613
|
+
BACKUP_FILE_TYPES,
|
|
614
|
+
backupSignRequestSchema,
|
|
615
|
+
BACKUP_STALE_GENERATION,
|
|
616
|
+
BACKUP_FILE_EXISTS,
|
|
617
|
+
backupSignResultSchema,
|
|
618
|
+
backupRestoreRequestSchema,
|
|
619
|
+
BACKUP_RESTORE_PHASES,
|
|
620
|
+
BACKUP_RESTORE_INTERRUPTED,
|
|
621
|
+
backupStatusSchema,
|
|
622
|
+
ingressInputSchema,
|
|
623
|
+
PLATFORM_INGRESS_PORTS,
|
|
624
|
+
dependsOnEdgeSchema,
|
|
625
|
+
wireServiceSchema,
|
|
626
|
+
workerAppSchema,
|
|
627
|
+
workerStateSchema,
|
|
628
|
+
nodeVersionSchema,
|
|
629
|
+
readNodeVersion,
|
|
630
|
+
shortNodeVersion,
|
|
631
|
+
readUpdateFiles,
|
|
632
|
+
REALM_APP_DIR_GID,
|
|
633
|
+
isPreparedAppDir,
|
|
634
|
+
scopeBindSource,
|
|
635
|
+
REALM_SILO_DIR,
|
|
636
|
+
managedSiloPath,
|
|
637
|
+
isManagedSiloPath,
|
|
638
|
+
siloSetupCommand,
|
|
639
|
+
siloWidenCommand,
|
|
640
|
+
siloReclaimCommand
|
|
641
|
+
};
|