@yourrealm/sdk 0.0.0-alpha.1451.8a8a7d44 → 0.0.0-alpha.1471.2b6cf8b8
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 → chunk-BSCX74FG.js} +45 -1
- package/dist/test.js +1 -1
- package/dist/worker-protocol.js +13 -1
- package/package.json +3 -3
- package/src/runtime/spawn-client/kaniko.ts +4 -3
- package/src/sandbox.ts +15 -5
- package/src/ui/components/icons.tsx +8 -0
- package/src/ui-runtime/mount.ts +28 -7
- package/src/worker/start-worker.ts +33 -0
- package/src/worker-protocol/backup.ts +103 -5
- package/src/worker-protocol/index.ts +11 -0
- package/src/worker-protocol/uplink/index.ts +7 -0
- package/src/worker-protocol/uplink/methods.ts +116 -5
- package/src/worker-protocol/uplink/protocol.ts +1 -1
|
@@ -172,13 +172,51 @@ var snapshotGroupSchema = z.object({
|
|
|
172
172
|
/** Null when offsite isn't set up, or its listing failed (see `repos`). */
|
|
173
173
|
offsite: z.array(snapshotSchema).nullable()
|
|
174
174
|
});
|
|
175
|
+
var BACKUP_PATH_MAX = 4096;
|
|
176
|
+
function isBackupRelPath(p) {
|
|
177
|
+
return p.length > 0 && p.length <= BACKUP_PATH_MAX && !p.includes("\0") && p.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
|
|
178
|
+
}
|
|
179
|
+
var relPathSchema = z.string().refine(isBackupRelPath, {
|
|
180
|
+
message: "must be a relative path with no empty, . or .. segments and no NUL"
|
|
181
|
+
});
|
|
182
|
+
var BACKUP_PATHS_MAX = 1e4;
|
|
183
|
+
var relPathsSchema = z.array(relPathSchema).min(1).max(BACKUP_PATHS_MAX).refine((ps) => new Set(ps).size === ps.length, { message: "a path is named twice" });
|
|
175
184
|
var restoreSourceSchema = z.enum(["local", "offsite"]);
|
|
176
|
-
var
|
|
185
|
+
var snapshotRefSchema = z.object({
|
|
177
186
|
serviceKey: z.string().min(1),
|
|
178
187
|
volume: backupVolumeSchema,
|
|
179
188
|
snapshotId: z.string().regex(SNAPSHOT_ID_RE),
|
|
180
189
|
source: restoreSourceSchema
|
|
181
190
|
});
|
|
191
|
+
var backupLsRequestSchema = snapshotRefSchema.extend({
|
|
192
|
+
appId: z.string().min(1),
|
|
193
|
+
path: z.union([z.literal(""), relPathSchema])
|
|
194
|
+
});
|
|
195
|
+
var lsEntrySchema = z.object({
|
|
196
|
+
name: z.string().min(1),
|
|
197
|
+
type: z.enum(["dir", "file", "symlink"]),
|
|
198
|
+
/** Bytes; files only. */
|
|
199
|
+
size: z.number().int().nonnegative().optional(),
|
|
200
|
+
/** ISO-8601, UTC. */
|
|
201
|
+
mtime: z.string()
|
|
202
|
+
});
|
|
203
|
+
var backupLsResultSchema = z.object({ entries: z.array(lsEntrySchema) });
|
|
204
|
+
var BACKUP_READ_ITEMS_MAX = 100;
|
|
205
|
+
var backupReadRequestSchema = z.object({
|
|
206
|
+
appId: z.string().min(1),
|
|
207
|
+
items: z.array(snapshotRefSchema.extend({ paths: relPathsSchema.optional() })).min(1).max(BACKUP_READ_ITEMS_MAX).refine(
|
|
208
|
+
(items) => new Set(items.map((i) => `${i.serviceKey}/${i.volume}`)).size === items.length,
|
|
209
|
+
{ message: "a volume is named twice" }
|
|
210
|
+
)
|
|
211
|
+
});
|
|
212
|
+
var restoreItemSchema = snapshotRefSchema.extend({
|
|
213
|
+
/**
|
|
214
|
+
* Put back only these, relative to the volume's root: each becomes exactly
|
|
215
|
+
* the snapshot's version, and nothing else in the volume moves. Folder
|
|
216
|
+
* (file-copy) volumes only. Absent, the whole volume.
|
|
217
|
+
*/
|
|
218
|
+
paths: relPathsSchema.optional()
|
|
219
|
+
});
|
|
182
220
|
var backupRestoreRequestSchema = z.object({
|
|
183
221
|
restoreId: z.uuid(),
|
|
184
222
|
appId: z.string().min(1),
|
|
@@ -615,6 +653,12 @@ export {
|
|
|
615
653
|
BACKUP_STALE_GENERATION,
|
|
616
654
|
BACKUP_FILE_EXISTS,
|
|
617
655
|
backupSignResultSchema,
|
|
656
|
+
isBackupRelPath,
|
|
657
|
+
BACKUP_PATHS_MAX,
|
|
658
|
+
backupLsRequestSchema,
|
|
659
|
+
backupLsResultSchema,
|
|
660
|
+
BACKUP_READ_ITEMS_MAX,
|
|
661
|
+
backupReadRequestSchema,
|
|
618
662
|
backupRestoreRequestSchema,
|
|
619
663
|
BACKUP_RESTORE_PHASES,
|
|
620
664
|
BACKUP_RESTORE_INTERRUPTED,
|
package/dist/test.js
CHANGED
package/dist/worker-protocol.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BACKUP_FILE_EXISTS,
|
|
3
3
|
BACKUP_FILE_TYPES,
|
|
4
|
+
BACKUP_PATHS_MAX,
|
|
5
|
+
BACKUP_READ_ITEMS_MAX,
|
|
4
6
|
BACKUP_RESTORE_INTERRUPTED,
|
|
5
7
|
BACKUP_RESTORE_PHASES,
|
|
6
8
|
BACKUP_SAFETY_TAG,
|
|
@@ -8,6 +10,9 @@ import {
|
|
|
8
10
|
PLATFORM_INGRESS_PORTS,
|
|
9
11
|
REALM_APP_DIR_GID,
|
|
10
12
|
REALM_SILO_DIR,
|
|
13
|
+
backupLsRequestSchema,
|
|
14
|
+
backupLsResultSchema,
|
|
15
|
+
backupReadRequestSchema,
|
|
11
16
|
backupRestoreRequestSchema,
|
|
12
17
|
backupSignRequestSchema,
|
|
13
18
|
backupSignResultSchema,
|
|
@@ -18,6 +23,7 @@ import {
|
|
|
18
23
|
backupVolumeSchema,
|
|
19
24
|
dependsOnEdgeSchema,
|
|
20
25
|
ingressInputSchema,
|
|
26
|
+
isBackupRelPath,
|
|
21
27
|
isManagedSiloPath,
|
|
22
28
|
isPreparedAppDir,
|
|
23
29
|
managedSiloPath,
|
|
@@ -33,7 +39,7 @@ import {
|
|
|
33
39
|
wireServiceSchema,
|
|
34
40
|
workerAppSchema,
|
|
35
41
|
workerStateSchema
|
|
36
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-BSCX74FG.js";
|
|
37
43
|
import {
|
|
38
44
|
MANAGEMENT_NET,
|
|
39
45
|
REALM_HOME_PROXY_CONTAINER_PATH,
|
|
@@ -62,6 +68,8 @@ import "./chunk-MLKGABMK.js";
|
|
|
62
68
|
export {
|
|
63
69
|
BACKUP_FILE_EXISTS,
|
|
64
70
|
BACKUP_FILE_TYPES,
|
|
71
|
+
BACKUP_PATHS_MAX,
|
|
72
|
+
BACKUP_READ_ITEMS_MAX,
|
|
65
73
|
BACKUP_RESTORE_INTERRUPTED,
|
|
66
74
|
BACKUP_RESTORE_PHASES,
|
|
67
75
|
BACKUP_SAFETY_TAG,
|
|
@@ -81,6 +89,9 @@ export {
|
|
|
81
89
|
REALM_STATS_SOCKET,
|
|
82
90
|
SILO_NET_PREFIX,
|
|
83
91
|
UNTRUSTED_NET,
|
|
92
|
+
backupLsRequestSchema,
|
|
93
|
+
backupLsResultSchema,
|
|
94
|
+
backupReadRequestSchema,
|
|
84
95
|
backupRestoreRequestSchema,
|
|
85
96
|
backupSignRequestSchema,
|
|
86
97
|
backupSignResultSchema,
|
|
@@ -94,6 +105,7 @@ export {
|
|
|
94
105
|
homeProxyMount,
|
|
95
106
|
homeProxySocketPath,
|
|
96
107
|
ingressInputSchema,
|
|
108
|
+
isBackupRelPath,
|
|
97
109
|
isManagedSiloPath,
|
|
98
110
|
isPreparedAppDir,
|
|
99
111
|
isSiloNetwork,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yourrealm/sdk",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.1471.2b6cf8b8",
|
|
4
4
|
"description": "The Realm SDK: createApp, the r.* permission factories, the ui surface, the realm-sdk CLI and test harness.",
|
|
5
5
|
"license": "AGPL-3.0-only WITH Classpath-exception-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -106,9 +106,9 @@
|
|
|
106
106
|
"@std/path": "npm:@jsr/std__path@^1",
|
|
107
107
|
"@std/tar": "npm:@jsr/std__tar@^0.1",
|
|
108
108
|
"@std/testing": "npm:@jsr/std__testing@^1",
|
|
109
|
-
"@local/docker": "0.1.0",
|
|
110
109
|
"@local/logger": "0.1.0",
|
|
111
|
-
"@yourrealm/noise": "0.1.0"
|
|
110
|
+
"@yourrealm/noise": "0.1.0",
|
|
111
|
+
"@local/docker": "0.1.0"
|
|
112
112
|
},
|
|
113
113
|
"scripts": {
|
|
114
114
|
"typecheck": "deno check",
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
|
|
23
23
|
import type { BuildRecipe } from "../../service-definition.ts";
|
|
24
24
|
|
|
25
|
-
// Default builder image:
|
|
26
|
-
//
|
|
25
|
+
// Default builder image: our build of Chainguard's kaniko fork
|
|
26
|
+
// (publish-kaniko.yml), since upstream is archived. It runs as root and never
|
|
27
|
+
// calls unshare(CLONE_NEWUSER), so it builds clean under the broker's envelope:
|
|
27
28
|
// HARDENING plus the file-extraction caps (FOWNER, DAC_OVERRIDE, SETFCAP,
|
|
28
29
|
// FSETID) spec.ts adds so base rootfs with non-root-owned files
|
|
29
30
|
// (node:22-alpine's /home/node) unpack faithfully — the property is
|
|
30
31
|
// kaniko's, not a specific distro's.
|
|
31
|
-
export const DEFAULT_KANIKO_IMAGE = "
|
|
32
|
+
export const DEFAULT_KANIKO_IMAGE = "benjick/realm-kaniko:v1.25.19";
|
|
32
33
|
|
|
33
34
|
// Where the broker bind-mounts the staged context, read-only, in the box.
|
|
34
35
|
// Must match `CONTEXT_MOUNT` in spawn-server/spec.ts — kaniko.test.ts pins
|
package/src/sandbox.ts
CHANGED
|
@@ -1241,14 +1241,21 @@ export async function runGate(
|
|
|
1241
1241
|
// ---- widget prefill (loader request stitching) -----------------------------
|
|
1242
1242
|
|
|
1243
1243
|
// Run every loader's request(seedState, config) inside the VM and collect the
|
|
1244
|
-
// descriptors. `seedState`
|
|
1245
|
-
//
|
|
1244
|
+
// descriptors. `seedState` follows `seedFrom` in ui-runtime/mount.ts — the page
|
|
1245
|
+
// query parsed through `stateSchema`, else its `{}`-parse, else `{}` — the exact
|
|
1246
|
+
// seed the iframe runtime mounts with, so a descriptor produced here matches
|
|
1246
1247
|
// the query key the runtime later builds from `request(state, config)`, and the
|
|
1247
1248
|
// prefilled data seeds cleanly with no first-paint refetch. A loader whose
|
|
1248
1249
|
// request is null (or throws) on the seed is lazy for this config → omitted.
|
|
1249
|
-
function stitchSnippet(
|
|
1250
|
+
function stitchSnippet(
|
|
1251
|
+
collection: "pages" | "widgets",
|
|
1252
|
+
key: string,
|
|
1253
|
+
config: unknown,
|
|
1254
|
+
search: Record<string, string> | undefined,
|
|
1255
|
+
): string {
|
|
1250
1256
|
return [
|
|
1251
1257
|
`var __config = ${JSON.stringify(config ?? null)};`,
|
|
1258
|
+
`var __search = ${JSON.stringify(search ?? {})};`,
|
|
1252
1259
|
`if (__config == null) __config = {};`, // mirror the runtime's `opts.config ?? {}`
|
|
1253
1260
|
`(function () {`,
|
|
1254
1261
|
` var ui = __app.ui;`,
|
|
@@ -1256,7 +1263,8 @@ function stitchSnippet(collection: "pages" | "widgets", key: string, config: unk
|
|
|
1256
1263
|
` if (!surface) return JSON.stringify({});`,
|
|
1257
1264
|
` var seedState = {};`,
|
|
1258
1265
|
` if (surface.stateSchema && typeof surface.stateSchema.safeParse === "function") {`,
|
|
1259
|
-
` var
|
|
1266
|
+
` var __q = surface.stateSchema.safeParse(__search);`,
|
|
1267
|
+
` var __p = __q.success ? __q : surface.stateSchema.safeParse({});`,
|
|
1260
1268
|
` seedState = __p.success ? __p.data : {};`,
|
|
1261
1269
|
` }`,
|
|
1262
1270
|
` var out = {};`,
|
|
@@ -1289,10 +1297,12 @@ export async function stitchLoaderRequests(opts: {
|
|
|
1289
1297
|
kind: "page" | "widget";
|
|
1290
1298
|
key: string;
|
|
1291
1299
|
config: unknown;
|
|
1300
|
+
// A page's URL query (see `seedFrom` in ui-runtime/mount.ts). Absent → `{}`.
|
|
1301
|
+
search?: Record<string, string>;
|
|
1292
1302
|
}): Promise<Record<string, RequestManifest | null>> {
|
|
1293
1303
|
const vm = getVm({ key: `prefill:${opts.appId}:${opts.bundleId}`, bundle: opts.bundle });
|
|
1294
1304
|
const collection = opts.kind === "widget" ? "widgets" : "pages";
|
|
1295
|
-
const result = await vm.call(stitchSnippet(collection, opts.key, opts.config));
|
|
1305
|
+
const result = await vm.call(stitchSnippet(collection, opts.key, opts.config, opts.search));
|
|
1296
1306
|
return (result ?? {}) as Record<string, RequestManifest | null>;
|
|
1297
1307
|
}
|
|
1298
1308
|
|
|
@@ -39,6 +39,14 @@ export { PiEnvelopeDuotone as EnvelopeIcon } from "react-icons/pi";
|
|
|
39
39
|
export { PiEyeDuotone as EyeIcon } from "react-icons/pi";
|
|
40
40
|
export { PiEyeSlashDuotone as EyeSlashIcon } from "react-icons/pi";
|
|
41
41
|
export { PiFadersDuotone as FadersIcon } from "react-icons/pi";
|
|
42
|
+
export { PiFileDuotone as FileIcon } from "react-icons/pi";
|
|
43
|
+
export { PiFileAudioDuotone as FileAudioIcon } from "react-icons/pi";
|
|
44
|
+
export { PiFileCodeDuotone as FileCodeIcon } from "react-icons/pi";
|
|
45
|
+
export { PiFileImageDuotone as FileImageIcon } from "react-icons/pi";
|
|
46
|
+
export { PiFilePdfDuotone as FilePdfIcon } from "react-icons/pi";
|
|
47
|
+
export { PiFileTextDuotone as FileTextIcon } from "react-icons/pi";
|
|
48
|
+
export { PiFileVideoDuotone as FileVideoIcon } from "react-icons/pi";
|
|
49
|
+
export { PiFileZipDuotone as FileZipIcon } from "react-icons/pi";
|
|
42
50
|
export { PiFolderOpenDuotone as FolderOpenIcon } from "react-icons/pi";
|
|
43
51
|
export { PiFolderSimpleDuotone as FolderSimpleIcon } from "react-icons/pi";
|
|
44
52
|
export { PiGaugeDuotone as GaugeIcon } from "react-icons/pi";
|
package/src/ui-runtime/mount.ts
CHANGED
|
@@ -67,11 +67,15 @@ interface RenderApi {
|
|
|
67
67
|
queryClient: QueryClient;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
interface StateSchemaLike {
|
|
71
|
+
safeParse(v: unknown): { success: boolean; data?: unknown };
|
|
72
|
+
}
|
|
73
|
+
|
|
70
74
|
interface SurfaceLike {
|
|
71
75
|
title: string;
|
|
72
|
-
// The live state schema rides the bundle
|
|
73
|
-
//
|
|
74
|
-
stateSchema?:
|
|
76
|
+
// The live state schema rides the bundle and seeds the initial state —
|
|
77
|
+
// see `seedFrom`.
|
|
78
|
+
stateSchema?: StateSchemaLike;
|
|
75
79
|
loaders?: Record<string, SurfaceLoader>;
|
|
76
80
|
actions?: Record<string, SurfaceAction>;
|
|
77
81
|
render: (api: RenderApi) => ComponentChildren;
|
|
@@ -95,6 +99,9 @@ interface MountOpts {
|
|
|
95
99
|
// as the loader's queryFn output *before* merge, so the runtime applies
|
|
96
100
|
// merge to it too — see `applyMerge`.
|
|
97
101
|
initialData?: Record<string, unknown>;
|
|
102
|
+
// A page's URL query, flat (last occurrence wins), inlined by Home. Seeds
|
|
103
|
+
// the initial state — see `seedFrom`. Widgets never get one.
|
|
104
|
+
search?: Record<string, string>;
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
interface PendingHandler {
|
|
@@ -201,6 +208,21 @@ function applyMerge(loader: SurfaceLoader, raw: unknown): unknown {
|
|
|
201
208
|
return raw;
|
|
202
209
|
}
|
|
203
210
|
|
|
211
|
+
// The surface's initial state: the page query parsed through `stateSchema`,
|
|
212
|
+
// else the `{}`-parse, else `{}`. A query that fails to parse is dropped
|
|
213
|
+
// whole, never key by key. Mirrored by `stitchSnippet` in sandbox.ts, so
|
|
214
|
+
// Home's prefill runs `request()` on this exact seed — keep the two in step.
|
|
215
|
+
export function seedFrom(
|
|
216
|
+
schema: StateSchemaLike | undefined,
|
|
217
|
+
search: Record<string, string> | undefined,
|
|
218
|
+
): unknown {
|
|
219
|
+
if (!schema) return {};
|
|
220
|
+
const fromQuery = schema.safeParse(search ?? {});
|
|
221
|
+
if (fromQuery.success) return fromQuery.data;
|
|
222
|
+
const empty = schema.safeParse({});
|
|
223
|
+
return empty.success ? empty.data : {};
|
|
224
|
+
}
|
|
225
|
+
|
|
204
226
|
// Assemble the render fn's props from the handles built this render. Pure, so
|
|
205
227
|
// the prop wiring is testable without a DOM.
|
|
206
228
|
export function renderApi(args: {
|
|
@@ -240,14 +262,13 @@ export function mount(opts: MountOpts): void {
|
|
|
240
262
|
},
|
|
241
263
|
});
|
|
242
264
|
|
|
243
|
-
// The widget's config (resolved by Home) and initial state (
|
|
244
|
-
//
|
|
265
|
+
// The widget's config (resolved by Home) and initial state (`seedFrom`).
|
|
266
|
+
// `seedState` is a stable reference so
|
|
245
267
|
// `initialData` applies only while the widget is still at its seed: that
|
|
246
268
|
// gates the prefill to the query key Home actually ran to produce it
|
|
247
269
|
// (`request(seedState, config)`), so a re-keyed loader can't inherit it.
|
|
248
270
|
const config = opts.config ?? {};
|
|
249
|
-
const
|
|
250
|
-
const seedState: unknown = seedParse?.success ? seedParse.data : {};
|
|
271
|
+
const seedState = seedFrom(opts.surface.stateSchema, opts.search);
|
|
251
272
|
|
|
252
273
|
// Capture the loader/action name sets ONCE, before the component
|
|
253
274
|
// renders. The render loop calls one useQuery per loader and one
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { log } from "@local/logger";
|
|
2
2
|
import type { WorkerCapability } from "../permissions.ts";
|
|
3
3
|
import type {
|
|
4
|
+
BackupLsRequest,
|
|
5
|
+
BackupLsResult,
|
|
6
|
+
BackupReadRequest,
|
|
4
7
|
BackupRestoreRequest,
|
|
5
8
|
BackupSignRequest,
|
|
6
9
|
BackupSignResult,
|
|
@@ -10,7 +13,13 @@ import type {
|
|
|
10
13
|
} from "../worker-protocol/index.ts";
|
|
11
14
|
import {
|
|
12
15
|
type AppApplyResult,
|
|
16
|
+
BACKUP_READ_FILE_TYPE,
|
|
17
|
+
BACKUP_READ_SIZE_HEADER,
|
|
18
|
+
BACKUP_READ_TAR_TYPE,
|
|
19
|
+
BACKUP_READ_TYPE_HEADER,
|
|
20
|
+
type BackupRead,
|
|
13
21
|
jsonHandler,
|
|
22
|
+
paramsHandler,
|
|
14
23
|
parseEnrollmentToken,
|
|
15
24
|
type RpcHandler,
|
|
16
25
|
type UpdateProject,
|
|
@@ -76,6 +85,18 @@ export interface StartWorkerOptions {
|
|
|
76
85
|
* it is in progress).
|
|
77
86
|
*/
|
|
78
87
|
backupRelease: (appId: string) => Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Home → worker `backup.ls`: one folder of a snapshot. Throw a `MethodError`
|
|
90
|
+
* to refuse (404 not there, 503 locked or no bucket).
|
|
91
|
+
*/
|
|
92
|
+
backupLs: (req: BackupLsRequest) => Promise<BackupLsResult>;
|
|
93
|
+
/**
|
|
94
|
+
* Home → worker `backup.read`: the bytes of paths in a snapshot, as one file
|
|
95
|
+
* or a tar. Resolves once the read is known to be answerable, with the body
|
|
96
|
+
* unread; cancelling the body must stop the read. Throw a `MethodError` to
|
|
97
|
+
* refuse (404 not there, 503 locked or no bucket).
|
|
98
|
+
*/
|
|
99
|
+
backupRead: (req: BackupReadRequest) => Promise<BackupRead>;
|
|
79
100
|
/**
|
|
80
101
|
* What this box's ingress container is running, or null where it has none.
|
|
81
102
|
* Injected rather than read here, for the reason the whole `worker` layer is
|
|
@@ -214,6 +235,18 @@ export async function startWorker(opts: StartWorkerOptions): Promise<WorkerHandl
|
|
|
214
235
|
await opts.backupRelease(appId);
|
|
215
236
|
return { ok: true };
|
|
216
237
|
}),
|
|
238
|
+
"backup.ls": jsonHandler("backup.ls", (req) => opts.backupLs(req)),
|
|
239
|
+
"backup.read": paramsHandler("backup.read", async (req) => {
|
|
240
|
+
const read = await opts.backupRead(req);
|
|
241
|
+
const headers: Record<string, string> =
|
|
242
|
+
read.kind === "file"
|
|
243
|
+
? {
|
|
244
|
+
[BACKUP_READ_TYPE_HEADER]: BACKUP_READ_FILE_TYPE,
|
|
245
|
+
[BACKUP_READ_SIZE_HEADER]: String(read.size ?? 0),
|
|
246
|
+
}
|
|
247
|
+
: { [BACKUP_READ_TYPE_HEADER]: BACKUP_READ_TAR_TYPE };
|
|
248
|
+
return { status: 200, headers, body: read.body };
|
|
249
|
+
}),
|
|
217
250
|
uiProxy: uiProxyHandler((appId, service) =>
|
|
218
251
|
lastState === null ? undefined : declaredPortsIn(lastState, appId, service),
|
|
219
252
|
),
|
|
@@ -297,23 +297,121 @@ const snapshotGroupSchema = z.object({
|
|
|
297
297
|
offsite: z.array(snapshotSchema).nullable(),
|
|
298
298
|
});
|
|
299
299
|
|
|
300
|
-
// ---
|
|
300
|
+
// --- paths inside a snapshot -----------------------------------------------
|
|
301
|
+
|
|
302
|
+
/** Longest path inside a volume the wire carries, in UTF-16 units. */
|
|
303
|
+
const BACKUP_PATH_MAX = 4096;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A path inside a volume, relative to its root: `a/b.txt`, never `/a`, `a/`,
|
|
307
|
+
* `a//b`, `.`, `..` or a NUL. The Worker checks it again where it joins it.
|
|
308
|
+
*/
|
|
309
|
+
export function isBackupRelPath(p: string): boolean {
|
|
310
|
+
return (
|
|
311
|
+
p.length > 0 &&
|
|
312
|
+
p.length <= BACKUP_PATH_MAX &&
|
|
313
|
+
!p.includes("\0") &&
|
|
314
|
+
p.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..")
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const relPathSchema = z.string().refine(isBackupRelPath, {
|
|
319
|
+
message: "must be a relative path with no empty, . or .. segments and no NUL",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/** Most paths one read or put-back names. */
|
|
323
|
+
export const BACKUP_PATHS_MAX = 10_000;
|
|
324
|
+
|
|
325
|
+
const relPathsSchema = z
|
|
326
|
+
.array(relPathSchema)
|
|
327
|
+
.min(1)
|
|
328
|
+
.max(BACKUP_PATHS_MAX)
|
|
329
|
+
.refine((ps) => new Set(ps).size === ps.length, { message: "a path is named twice" });
|
|
301
330
|
|
|
302
331
|
const restoreSourceSchema = z.enum(["local", "offsite"]);
|
|
303
332
|
|
|
304
|
-
|
|
333
|
+
/** The one snapshot of one volume a listing, a read or a restore item names. */
|
|
334
|
+
const snapshotRefSchema = z.object({
|
|
305
335
|
serviceKey: z.string().min(1),
|
|
306
336
|
volume: backupVolumeSchema,
|
|
307
337
|
snapshotId: z.string().regex(SNAPSHOT_ID_RE),
|
|
308
338
|
source: restoreSourceSchema,
|
|
309
339
|
});
|
|
310
340
|
|
|
341
|
+
// --- backup.ls ---------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `backup.ls`, Home → Worker: one folder of one snapshot of one volume.
|
|
345
|
+
* `path` is relative to the volume's root, `""` for the root itself. A
|
|
346
|
+
* single-file volume (sqlite, pg-dump, mysql-dump, valkey-save) lists its one
|
|
347
|
+
* file at the root and has no folders.
|
|
348
|
+
*/
|
|
349
|
+
export const backupLsRequestSchema = snapshotRefSchema.extend({
|
|
350
|
+
appId: z.string().min(1),
|
|
351
|
+
path: z.union([z.literal(""), relPathSchema]),
|
|
352
|
+
});
|
|
353
|
+
export type BackupLsRequest = z.infer<typeof backupLsRequestSchema>;
|
|
354
|
+
|
|
355
|
+
const lsEntrySchema = z.object({
|
|
356
|
+
name: z.string().min(1),
|
|
357
|
+
type: z.enum(["dir", "file", "symlink"]),
|
|
358
|
+
/** Bytes; files only. */
|
|
359
|
+
size: z.number().int().nonnegative().optional(),
|
|
360
|
+
/** ISO-8601, UTC. */
|
|
361
|
+
mtime: z.string(),
|
|
362
|
+
});
|
|
363
|
+
export type BackupLsEntry = z.infer<typeof lsEntrySchema>;
|
|
364
|
+
|
|
365
|
+
/** The folder's entries, sorted by name. */
|
|
366
|
+
export const backupLsResultSchema = z.object({ entries: z.array(lsEntrySchema) });
|
|
367
|
+
export type BackupLsResult = z.infer<typeof backupLsResultSchema>;
|
|
368
|
+
|
|
369
|
+
// --- backup.read -------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/** Most volumes one read names. */
|
|
372
|
+
export const BACKUP_READ_ITEMS_MAX = 100;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* `backup.read`, Home → Worker: the bytes of one selection per volume of one
|
|
376
|
+
* app. An item's `paths` are relative to its volume's root, files and folders
|
|
377
|
+
* from anywhere in it; absent, the whole volume. Exactly one item naming
|
|
378
|
+
* exactly one file answers that file as itself; anything else, one tar. With
|
|
379
|
+
* one item its names are relative to the volume's root; with several, each
|
|
380
|
+
* item's sit under a top folder named after its volume, or
|
|
381
|
+
* `<serviceKey>-<volume>` where two services share a volume name.
|
|
382
|
+
*/
|
|
383
|
+
export const backupReadRequestSchema = z.object({
|
|
384
|
+
appId: z.string().min(1),
|
|
385
|
+
items: z
|
|
386
|
+
.array(snapshotRefSchema.extend({ paths: relPathsSchema.optional() }))
|
|
387
|
+
.min(1)
|
|
388
|
+
.max(BACKUP_READ_ITEMS_MAX)
|
|
389
|
+
.refine(
|
|
390
|
+
(items) => new Set(items.map((i) => `${i.serviceKey}/${i.volume}`)).size === items.length,
|
|
391
|
+
{ message: "a volume is named twice" },
|
|
392
|
+
),
|
|
393
|
+
});
|
|
394
|
+
export type BackupReadRequest = z.infer<typeof backupReadRequestSchema>;
|
|
395
|
+
export type BackupReadItem = BackupReadRequest["items"][number];
|
|
396
|
+
|
|
397
|
+
// --- backup.restore --------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
const restoreItemSchema = snapshotRefSchema.extend({
|
|
400
|
+
/**
|
|
401
|
+
* Put back only these, relative to the volume's root: each becomes exactly
|
|
402
|
+
* the snapshot's version, and nothing else in the volume moves. Folder
|
|
403
|
+
* (file-copy) volumes only. Absent, the whole volume.
|
|
404
|
+
*/
|
|
405
|
+
paths: relPathsSchema.optional(),
|
|
406
|
+
});
|
|
407
|
+
|
|
311
408
|
/**
|
|
312
409
|
* `backup.restore`, Home → Worker: put these snapshots back over one app's
|
|
313
410
|
* volumes. The Worker answers once it has accepted the restore - 404 an
|
|
314
|
-
* unknown app or volume,
|
|
315
|
-
*
|
|
316
|
-
*
|
|
411
|
+
* unknown app or volume, or an item's path the snapshot doesn't hold, 400
|
|
412
|
+
* `paths` on a volume that isn't a folder, 409 a restore of that app already
|
|
413
|
+
* queued or running, 503 an offsite item with no bucket set - and does the
|
|
414
|
+
* work afterwards, reporting through `backup.status`.
|
|
317
415
|
*/
|
|
318
416
|
export const backupRestoreRequestSchema = z.object({
|
|
319
417
|
restoreId: z.uuid(),
|
|
@@ -15,14 +15,20 @@ export { ingressInputSchema, PLATFORM_INGRESS_PORTS } from "./ingress.ts";
|
|
|
15
15
|
export {
|
|
16
16
|
BACKUP_FILE_EXISTS,
|
|
17
17
|
BACKUP_FILE_TYPES,
|
|
18
|
+
BACKUP_PATHS_MAX,
|
|
19
|
+
BACKUP_READ_ITEMS_MAX,
|
|
18
20
|
BACKUP_RESTORE_INTERRUPTED,
|
|
19
21
|
BACKUP_RESTORE_PHASES,
|
|
20
22
|
BACKUP_SAFETY_TAG,
|
|
21
23
|
BACKUP_STALE_GENERATION,
|
|
24
|
+
backupLsRequestSchema,
|
|
25
|
+
backupLsResultSchema,
|
|
26
|
+
backupReadRequestSchema,
|
|
22
27
|
backupRestoreRequestSchema,
|
|
23
28
|
backupSnapshotTags,
|
|
24
29
|
backupTargetKey,
|
|
25
30
|
backupVolumeSchema,
|
|
31
|
+
isBackupRelPath,
|
|
26
32
|
parseBackupSnapshotTags,
|
|
27
33
|
backupSignRequestSchema,
|
|
28
34
|
backupSignResultSchema,
|
|
@@ -31,6 +37,11 @@ export {
|
|
|
31
37
|
} from "./backup.ts";
|
|
32
38
|
export type {
|
|
33
39
|
BackupFileType,
|
|
40
|
+
BackupLsEntry,
|
|
41
|
+
BackupLsRequest,
|
|
42
|
+
BackupLsResult,
|
|
43
|
+
BackupReadItem,
|
|
44
|
+
BackupReadRequest,
|
|
34
45
|
BackupRepoStatus,
|
|
35
46
|
BackupRestore,
|
|
36
47
|
BackupRestoreItem,
|
|
@@ -17,12 +17,18 @@ export { PROTOCOL_VERSION } from "./protocol.ts";
|
|
|
17
17
|
export {
|
|
18
18
|
APPLY_MESSAGE_MAX_CHARS,
|
|
19
19
|
appApplyResultSchema,
|
|
20
|
+
BACKUP_READ_FILE_TYPE,
|
|
21
|
+
BACKUP_READ_SIZE_HEADER,
|
|
22
|
+
BACKUP_READ_TAR_TYPE,
|
|
23
|
+
BACKUP_READ_TYPE_HEADER,
|
|
24
|
+
callBackupRead,
|
|
20
25
|
callJson,
|
|
21
26
|
callUiProxy,
|
|
22
27
|
ETAG_HEADER,
|
|
23
28
|
jsonHandler,
|
|
24
29
|
MethodError,
|
|
25
30
|
methods,
|
|
31
|
+
paramsHandler,
|
|
26
32
|
pullState,
|
|
27
33
|
UPDATE_ERROR_MAX_CHARS,
|
|
28
34
|
UPDATE_PROJECTS,
|
|
@@ -30,6 +36,7 @@ export {
|
|
|
30
36
|
} from "./methods.ts";
|
|
31
37
|
export type {
|
|
32
38
|
AppApplyResult,
|
|
39
|
+
BackupRead,
|
|
33
40
|
JsonMethod,
|
|
34
41
|
MethodName,
|
|
35
42
|
Params,
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
homeProxyResponseSchema,
|
|
14
14
|
} from "../../runtime/docker/home-proxy-server/protocol.ts";
|
|
15
15
|
import {
|
|
16
|
+
backupLsRequestSchema,
|
|
17
|
+
backupLsResultSchema,
|
|
18
|
+
backupReadRequestSchema,
|
|
16
19
|
backupRestoreRequestSchema,
|
|
17
20
|
backupSignRequestSchema,
|
|
18
21
|
backupSignResultSchema,
|
|
@@ -23,11 +26,20 @@ import type { SiloOrphan } from "../silo-paths.ts";
|
|
|
23
26
|
import { UPDATE_ERROR_MAX_CHARS } from "../update-files.ts";
|
|
24
27
|
import { UplinkError, UplinkProtocolError } from "./errors.ts";
|
|
25
28
|
import { decodeUtf8, encodeUtf8 } from "./frame.ts";
|
|
26
|
-
import { jsonResponse, type Rpc, type RpcHandler } from "./rpc.ts";
|
|
29
|
+
import { jsonResponse, type Rpc, type RpcHandler, type RpcResponse } from "./rpc.ts";
|
|
27
30
|
|
|
28
31
|
/** The response header `state.pull` answers a 200 with. */
|
|
29
32
|
export const ETAG_HEADER = "etag";
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* `backup.read`'s 200: `content-type` says what the body is - one file as
|
|
36
|
+
* itself, or a tar - and a file's carries its size in `content-length`.
|
|
37
|
+
*/
|
|
38
|
+
export const BACKUP_READ_TYPE_HEADER = "content-type";
|
|
39
|
+
export const BACKUP_READ_SIZE_HEADER = "content-length";
|
|
40
|
+
export const BACKUP_READ_FILE_TYPE = "application/octet-stream";
|
|
41
|
+
export const BACKUP_READ_TAR_TYPE = "application/x-tar";
|
|
42
|
+
|
|
31
43
|
// --- shared pieces ------------------------------------------------------
|
|
32
44
|
|
|
33
45
|
const capabilitiesSchema = z.array(
|
|
@@ -317,16 +329,43 @@ export const methods = {
|
|
|
317
329
|
},
|
|
318
330
|
|
|
319
331
|
/**
|
|
320
|
-
* Home → worker. Restore snapshots over one app's volumes
|
|
321
|
-
*
|
|
322
|
-
* app
|
|
323
|
-
*
|
|
332
|
+
* Home → worker. Restore snapshots over one app's volumes, or only some
|
|
333
|
+
* paths of a folder volume. Answered once the worker has accepted it - 404
|
|
334
|
+
* unknown app or volume or a path the snapshot doesn't hold, 400 `paths` on
|
|
335
|
+
* a volume that isn't a folder, 409 a restore of that app already in
|
|
336
|
+
* progress, 503 an offsite item with no bucket - and run afterwards;
|
|
337
|
+
* progress rides `backup.status`.
|
|
324
338
|
*/
|
|
325
339
|
"backup.restore": {
|
|
326
340
|
params: backupRestoreRequestSchema,
|
|
327
341
|
result: okSchema,
|
|
328
342
|
},
|
|
329
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Home → worker. One folder of a snapshot: 404 an unknown app, volume,
|
|
346
|
+
* snapshot or folder, 503 the repository locked by a prune past the wait or
|
|
347
|
+
* an offsite source with no bucket. Read under restic's shared lock, beside
|
|
348
|
+
* any backup run rather than after it.
|
|
349
|
+
*/
|
|
350
|
+
"backup.ls": {
|
|
351
|
+
params: backupLsRequestSchema,
|
|
352
|
+
result: backupLsResultSchema,
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Home → worker. The bytes of a selection from one or more volumes' snapshots,
|
|
357
|
+
* streamed: one item naming one file as itself (`BACKUP_READ_FILE_TYPE`, its
|
|
358
|
+
* size in `content-length`), anything else one tar (`BACKUP_READ_TAR_TYPE`).
|
|
359
|
+
* One item's names are relative to its volume's root; several items' sit
|
|
360
|
+
* under a top folder each (`backupReadRequestSchema`). Every item is checked
|
|
361
|
+
* before the first byte: 404 an unknown app, volume, snapshot or path, 400 a
|
|
362
|
+
* malformed request, 503 as `backup.ls`. Cancelling the body stops the read
|
|
363
|
+
* on the worker.
|
|
364
|
+
*/
|
|
365
|
+
"backup.read": {
|
|
366
|
+
params: backupReadRequestSchema,
|
|
367
|
+
},
|
|
368
|
+
|
|
330
369
|
/**
|
|
331
370
|
* Home → worker. Release an app a restore left held (`BackupStatusReport.held`)
|
|
332
371
|
* and start it on its data as it is: 404 when it isn't held so, 409 while a
|
|
@@ -450,6 +489,47 @@ export async function callUiProxy(rpc: Rpc, payload: Params<"uiProxy">): Promise
|
|
|
450
489
|
return { status: res.status, headers: res.headers, body: await readAll(res.body) };
|
|
451
490
|
}
|
|
452
491
|
|
|
492
|
+
export interface BackupRead {
|
|
493
|
+
/** `file`: the one file's bytes; `tar`: an archive of the selection. */
|
|
494
|
+
kind: "file" | "tar";
|
|
495
|
+
/** A file's size in bytes. */
|
|
496
|
+
size?: number;
|
|
497
|
+
/** Unread. The caller drains or cancels it; cancelling stops the worker's read. */
|
|
498
|
+
body: ReadableStream<Uint8Array>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* `backup.read`, whose 200 body is the bytes. A non-2xx throws `MethodError`
|
|
503
|
+
* with the worker's text, as `callJson` does.
|
|
504
|
+
*/
|
|
505
|
+
export async function callBackupRead(
|
|
506
|
+
rpc: Rpc,
|
|
507
|
+
params: Params<"backup.read">,
|
|
508
|
+
opts?: { signal?: AbortSignal },
|
|
509
|
+
): Promise<BackupRead> {
|
|
510
|
+
const res = await rpc.call(
|
|
511
|
+
"backup.read",
|
|
512
|
+
{},
|
|
513
|
+
encodeJson(methods["backup.read"].params, params),
|
|
514
|
+
opts,
|
|
515
|
+
);
|
|
516
|
+
if (res.status < 200 || res.status >= 300) {
|
|
517
|
+
throw new MethodError(
|
|
518
|
+
"backup.read",
|
|
519
|
+
res.status,
|
|
520
|
+
errorText(decodeUtf8(await readAll(res.body))),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const type = res.headers[BACKUP_READ_TYPE_HEADER];
|
|
524
|
+
if (type === BACKUP_READ_TAR_TYPE) return { kind: "tar", body: res.body };
|
|
525
|
+
const size = Number(res.headers[BACKUP_READ_SIZE_HEADER]);
|
|
526
|
+
if (type !== BACKUP_READ_FILE_TYPE || !Number.isSafeInteger(size) || size < 0) {
|
|
527
|
+
await res.body.cancel().catch(() => {});
|
|
528
|
+
throw new UplinkProtocolError("backup.read answered without a body type and size");
|
|
529
|
+
}
|
|
530
|
+
return { kind: "file", size, body: res.body };
|
|
531
|
+
}
|
|
532
|
+
|
|
453
533
|
// --- answering ----------------------------------------------------------
|
|
454
534
|
|
|
455
535
|
/**
|
|
@@ -483,6 +563,37 @@ export function jsonHandler<M extends JsonMethod>(
|
|
|
483
563
|
};
|
|
484
564
|
}
|
|
485
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Wraps a handler for a method whose params are JSON and whose answer is
|
|
568
|
+
* whatever the handler builds - a streamed body. The params are checked as
|
|
569
|
+
* `jsonHandler` checks them (400); a `MethodError` thrown before the answer
|
|
570
|
+
* is answered as `{"error": body}` with its status.
|
|
571
|
+
*/
|
|
572
|
+
export function paramsHandler<M extends MethodName>(
|
|
573
|
+
method: M,
|
|
574
|
+
fn: (params: Params<M>) => Promise<RpcResponse>,
|
|
575
|
+
): RpcHandler {
|
|
576
|
+
const spec = methods[method] as { params: z.ZodType };
|
|
577
|
+
return async (req) => {
|
|
578
|
+
let raw: unknown;
|
|
579
|
+
try {
|
|
580
|
+
raw = JSON.parse(decodeUtf8(await readAll(req.body)));
|
|
581
|
+
} catch {
|
|
582
|
+
return jsonResponse(400, { error: `${method}: body is not JSON` });
|
|
583
|
+
}
|
|
584
|
+
const parsed = spec.params.safeParse(raw);
|
|
585
|
+
if (!parsed.success) {
|
|
586
|
+
return jsonResponse(400, { error: `${method}: ${issueText(parsed.error)}` });
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
return await fn(parsed.data as Params<M>);
|
|
590
|
+
} catch (e) {
|
|
591
|
+
if (e instanceof MethodError) return jsonResponse(e.status, { error: e.body });
|
|
592
|
+
throw e;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
486
597
|
// --- helpers ------------------------------------------------------------
|
|
487
598
|
|
|
488
599
|
function encodeJson(schema: z.ZodType, params: unknown): Uint8Array {
|