@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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.
Files changed (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,183 @@
1
+ // The worker's uplink identity, as three files under WORKER_DIR.
2
+ //
3
+ // identity.key 32 raw bytes, mode 600, generated once and never rewritten.
4
+ // It never leaves the box: a rebuilt box is a new worker.
5
+ // home.pub the pinned Home static key, encoded. Whatever the worker
6
+ // ends up trusting is written here, so the next boot needs no
7
+ // token and no mounted etc tree.
8
+ // enrolled.json `{workerId, slug}`, written when Home answers `enroll`.
9
+ //
10
+ // The pin is what the security of the uplink rests on
11
+ // (`todo/ideas/uplink.md` § What it guarantees), so it is resolved from one
12
+ // of three named sources and logged once, never guessed.
13
+
14
+ import { dirname, join } from "@std/path";
15
+ import { z } from "zod";
16
+ import { log } from "@local/logger";
17
+ import { generateKeypair, KEY_LENGTH, type Keypair, keypairFromPrivateKey } from "@yourrealm/noise";
18
+ import { decodePublicKey, encodePublicKey } from "../worker-protocol/uplink/identity.ts";
19
+
20
+ const IDENTITY_FILE = "identity.key";
21
+ const HOME_KEY_FILE = "home.pub";
22
+ const ENROLLMENT_FILE = "enrolled.json";
23
+
24
+ /** Home writes its public key here on boot; a co-located worker mounts the tree. */
25
+ const ETC_HOME_KEY = "home/uplink.pub";
26
+
27
+ /** How often the wait below re-checks for the file Home is about to write. */
28
+ const PIN_POLL_MS = 2000;
29
+
30
+ /** What Home hands back once it holds a row for this worker's key. */
31
+ export interface Enrollment {
32
+ workerId: string;
33
+ slug: string;
34
+ }
35
+
36
+ const enrollmentSchema = z.object({
37
+ workerId: z.string().min(1),
38
+ slug: z.string().min(1),
39
+ });
40
+
41
+ /**
42
+ * This worker's static keypair, generated on first boot.
43
+ *
44
+ * `mode` only applies on creation, so the file is chmod'd on every boot: a key
45
+ * that became group-readable (a restored backup, a hand-edit) is tightened
46
+ * rather than trusted.
47
+ */
48
+ export async function loadIdentity(workerDir: string): Promise<Keypair> {
49
+ const path = join(workerDir, IDENTITY_FILE);
50
+ const existing = await readFileOrNull(path);
51
+ if (existing !== null) {
52
+ if (existing.length !== KEY_LENGTH) {
53
+ throw new Error(`${path} holds ${existing.length} bytes, not a ${KEY_LENGTH}-byte key`);
54
+ }
55
+ await Deno.chmod(path, 0o600);
56
+ return keypairFromPrivateKey(existing);
57
+ }
58
+
59
+ await Deno.mkdir(workerDir, { recursive: true, mode: 0o700 });
60
+ const keypair = generateKeypair();
61
+ await Deno.writeFile(path, keypair.privateKey, { mode: 0o600 });
62
+ await Deno.chmod(path, 0o600);
63
+ log(`Generated this worker's uplink identity: ${encodePublicKey(keypair.publicKey)}`);
64
+ return keypair;
65
+ }
66
+
67
+ /** The enrollment from a previous boot, or null on a worker that never enrolled. */
68
+ export async function readEnrollment(workerDir: string): Promise<Enrollment | null> {
69
+ const path = join(workerDir, ENROLLMENT_FILE);
70
+ const raw = await readTextOrNull(path);
71
+ if (raw === null) return null;
72
+ let value: unknown;
73
+ try {
74
+ value = JSON.parse(raw);
75
+ } catch {
76
+ log.warn(`${path} is not JSON; treating this worker as unenrolled`);
77
+ return null;
78
+ }
79
+ const parsed = enrollmentSchema.safeParse(value);
80
+ if (!parsed.success) {
81
+ log.warn(`${path} is not an enrollment record; treating this worker as unenrolled`);
82
+ return null;
83
+ }
84
+ return parsed.data;
85
+ }
86
+
87
+ export async function writeEnrollment(workerDir: string, enrollment: Enrollment): Promise<void> {
88
+ await Deno.mkdir(workerDir, { recursive: true, mode: 0o700 });
89
+ await Deno.writeTextFile(join(workerDir, ENROLLMENT_FILE), `${JSON.stringify(enrollment)}\n`, {
90
+ mode: 0o600,
91
+ });
92
+ }
93
+
94
+ interface PinOptions {
95
+ workerDir: string;
96
+ /** `REALM_ETC_DIR`; the third source lives under it. */
97
+ etcDir: string;
98
+ /** The key half of `REALM_TOKEN`, when the token carried one. */
99
+ tokenKey?: Uint8Array | null;
100
+ /** Test seam for the wait below. */
101
+ pollIntervalMs?: number;
102
+ }
103
+
104
+ /**
105
+ * Home's static key, from the first source that has one:
106
+ *
107
+ * 1. the key half of `REALM_TOKEN` — the operator pasted it themselves;
108
+ * 2. `home.pub` — what a previous boot resolved and kept;
109
+ * 3. `${etcDir}/home/uplink.pub` — what Home writes on its own box.
110
+ *
111
+ * A bare-OTP token on a fresh co-located box has neither 2 nor 3 yet, because
112
+ * Home boots seconds ahead of the worker. That is a wait, not a failure: the
113
+ * worker polls for the file rather than dialling something it cannot verify.
114
+ * Whatever is resolved is persisted as `home.pub`.
115
+ */
116
+ export async function resolveHomePublicKey(opts: PinOptions): Promise<Uint8Array> {
117
+ const homePubPath = join(opts.workerDir, HOME_KEY_FILE);
118
+ const etcPath = join(opts.etcDir, ETC_HOME_KEY);
119
+
120
+ if (opts.tokenKey != null) {
121
+ await persistHomeKey(homePubPath, opts.tokenKey);
122
+ return pinned(opts.tokenKey, "REALM_TOKEN");
123
+ }
124
+
125
+ const kept = await readPublicKeyFile(homePubPath);
126
+ if (kept !== null) return pinned(kept, homePubPath);
127
+
128
+ const fromEtc = await readPublicKeyFile(etcPath);
129
+ if (fromEtc !== null) {
130
+ await persistHomeKey(homePubPath, fromEtc);
131
+ return pinned(fromEtc, etcPath);
132
+ }
133
+
134
+ log(`Home's uplink key is not on this box yet; waiting for ${etcPath}`);
135
+ const interval = opts.pollIntervalMs ?? PIN_POLL_MS;
136
+ for (;;) {
137
+ await new Promise((resolve) => setTimeout(resolve, interval));
138
+ const appeared = await readPublicKeyFile(etcPath);
139
+ if (appeared === null) continue;
140
+ await persistHomeKey(homePubPath, appeared);
141
+ return pinned(appeared, etcPath);
142
+ }
143
+ }
144
+
145
+ function pinned(key: Uint8Array, source: string): Uint8Array {
146
+ log(`Pinned Home's uplink key ${encodePublicKey(key)} from ${source}`);
147
+ return key;
148
+ }
149
+
150
+ async function persistHomeKey(path: string, key: Uint8Array): Promise<void> {
151
+ await Deno.mkdir(dirname(path), { recursive: true, mode: 0o700 });
152
+ await Deno.writeTextFile(path, `${encodePublicKey(key)}\n`, { mode: 0o600 });
153
+ }
154
+
155
+ /** A file holding one encoded public key, or null when it is absent or junk. */
156
+ async function readPublicKeyFile(path: string): Promise<Uint8Array | null> {
157
+ const raw = await readTextOrNull(path);
158
+ if (raw === null) return null;
159
+ const key = decodePublicKey(raw.trim());
160
+ if (key === null) {
161
+ log.warn(`${path} does not hold an uplink public key; ignoring it`);
162
+ return null;
163
+ }
164
+ return key;
165
+ }
166
+
167
+ async function readFileOrNull(path: string): Promise<Uint8Array | null> {
168
+ try {
169
+ return await Deno.readFile(path);
170
+ } catch (err) {
171
+ if (err instanceof Deno.errors.NotFound) return null;
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ async function readTextOrNull(path: string): Promise<string | null> {
177
+ try {
178
+ return await Deno.readTextFile(path);
179
+ } catch (err) {
180
+ if (err instanceof Deno.errors.NotFound) return null;
181
+ throw err;
182
+ }
183
+ }
@@ -0,0 +1,16 @@
1
+ // @yourrealm/sdk/worker — the worker harness.
2
+ //
3
+ // Long-running process that holds one uplink session to Home, pulls state
4
+ // over it, untars + parses, and calls back into a host-supplied apply. Pulls
5
+ // types from @yourrealm/sdk/worker-protocol; the host wires a runtime-specific
6
+ // renderer (e.g. @yourrealm/sdk/runtime/docker/compose) in the onNewState
7
+ // callback at reconcile time.
8
+
9
+ export {
10
+ type ReconcileContext,
11
+ startWorker,
12
+ type StartWorkerOptions,
13
+ type WorkerHandle,
14
+ } from "./start-worker.ts";
15
+ export { validateWorkerState } from "./validate.ts";
16
+ export { WorkerOffline } from "./uplink.ts";
@@ -0,0 +1,92 @@
1
+ // The worker's own public address, for Home's `worker.public_ip`.
2
+ //
3
+ // Home sees the address whatever proxies the uplink hands it, which on a
4
+ // co-located box is a container address and behind a CDN is the CDN's; a box
5
+ // reading its own interface behind NAT gets the LAN address. So the worker
6
+ // asks something outside and reports the answer, and Home keeps this one.
7
+ // `domain_claim` probing compares a resolved hostname against it (see
8
+ // `todo/ideas/naming-layer.md`).
9
+ //
10
+ // **The echo URL comes from Home, on the announce, and there is no default.**
11
+ // A realm with no Realm DNS configured gets `null` and this module then never
12
+ // makes a request - a hardcoded default would have every self-hosted worker
13
+ // quietly talking to `yourrealm.eu`. `REALM_PUBLIC_IP_URL` overrides it for an
14
+ // operator running their own echo.
15
+ //
16
+ // It measures the TCP path on purpose: a DNS A record and HTTP-01 both care
17
+ // about that, and on a symmetric NAT a UDP mapping differs from it.
18
+
19
+ import { z } from "zod";
20
+ import { log } from "@local/logger";
21
+
22
+ // Matches caddy-dynamicdns's `check_interval` - the same question, asked at the
23
+ // same rate, for the same reason.
24
+ const TTL_MS = 5 * 60_000;
25
+ // A reconcile must never wait on an external service. Short enough to be noise
26
+ // against the tar stream and compose run the pull already costs.
27
+ const TIMEOUT_MS = 2000;
28
+
29
+ const ipSchema = z.union([z.ipv4(), z.ipv6()]);
30
+
31
+ let echoUrl: string | null = null;
32
+ let cached: string | null = null;
33
+ let fetchedAt = 0;
34
+
35
+ /**
36
+ * Point this module at a source-IP echo, or at nothing. Called with whatever
37
+ * Home answers the announce with; a `REALM_PUBLIC_IP_URL` override always
38
+ * wins, so a later announce can't drag an operator's own echo back to the
39
+ * default.
40
+ */
41
+ export function setPublicIpEcho(url: string | null): void {
42
+ const override = Deno.env.get("REALM_PUBLIC_IP_URL");
43
+ const next = override ?? url;
44
+ if (next === echoUrl) return;
45
+ echoUrl = next;
46
+ // A changed echo invalidates what the old one said. Not a correctness
47
+ // problem (both report the same box) but it keeps `fetchedAt` honest.
48
+ cached = null;
49
+ fetchedAt = 0;
50
+ }
51
+
52
+ /**
53
+ * The last known public address, refreshed if it has gone stale. Returns null
54
+ * when no echo is configured or none has ever answered - callers must treat
55
+ * that as "unknown", never as "unreachable".
56
+ *
57
+ * Awaits the refresh rather than kicking it off in the background: reconciles
58
+ * are rare and trigger-driven, so a value refreshed after the pull would be
59
+ * one whole reconcile behind, and on a box whose address just moved that is
60
+ * exactly when the answer matters. Bounded by TIMEOUT_MS, and a failure keeps
61
+ * serving the previous value.
62
+ *
63
+ * `now` is a seam for the tests, which otherwise cannot reach the staleness
64
+ * branch without sitting out a five-minute TTL.
65
+ */
66
+ export async function currentPublicIp(now: number = Date.now()): Promise<string | null> {
67
+ if (echoUrl === null) return null;
68
+ if (cached !== null && now - fetchedAt < TTL_MS) return cached;
69
+
70
+ try {
71
+ const res = await fetch(echoUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
72
+ if (!res.ok) {
73
+ await res.body?.cancel();
74
+ throw new Error(`echo returned ${res.status}`);
75
+ }
76
+ const body = (await res.text()).trim();
77
+ const parsed = ipSchema.safeParse(body);
78
+ if (!parsed.success) {
79
+ // A captive portal or an error page answers 200 with HTML. Storing that
80
+ // would put junk in `worker.public_ip` and read as a mismatch on
81
+ // every claim, which is worse than knowing nothing.
82
+ throw new Error("echo did not answer with an IP address");
83
+ }
84
+ cached = parsed.data;
85
+ fetchedAt = now;
86
+ } catch (err) {
87
+ // Keep whatever we had. An address that moved is rare; an echo that
88
+ // blipped is not, and a null here would blank a good value on Home.
89
+ log.warn(`Public IP echo failed (${(err as Error).message})`);
90
+ }
91
+ return cached;
92
+ }
@@ -0,0 +1,54 @@
1
+ import { log } from "@local/logger";
2
+ import { reconcileWorker, type ReconcileDeps } from "./reconcile.ts";
3
+ import type { WorkerState } from "../worker-protocol/index.ts";
4
+
5
+ interface ReconcileQueue {
6
+ trigger(): void;
7
+ /** The etag this worker last applied, or null. Reported on every announce. */
8
+ appliedEtag(): string | null;
9
+ }
10
+
11
+ /**
12
+ * Debounced, serial reconcile queue. `trigger()` is fire-and-forget:
13
+ * if a reconcile is already running, it sets a flag so the loop
14
+ * runs again right after. Errors inside reconcile are logged and
15
+ * swallowed (the next trigger picks up).
16
+ */
17
+ export function createReconcileQueue(deps: ReconcileDeps): ReconcileQueue {
18
+ let isRunning = false;
19
+ let pendingWhileRunning = false;
20
+ let lastEtag: string | null = null;
21
+ // Kept so a 304 can re-run the idempotent healers (onUnchanged) against the
22
+ // state the current etag describes.
23
+ let lastState: WorkerState | null = null;
24
+
25
+ function trigger(): void {
26
+ if (isRunning) {
27
+ pendingWhileRunning = true;
28
+ return;
29
+ }
30
+ void run();
31
+ }
32
+
33
+ async function run(): Promise<void> {
34
+ isRunning = true;
35
+ try {
36
+ do {
37
+ pendingWhileRunning = false;
38
+ try {
39
+ log("Reconciling worker...");
40
+ const result = await reconcileWorker(deps, lastEtag, lastState);
41
+ lastEtag = result.etag;
42
+ lastState = result.state ?? lastState;
43
+ log(`Reconciled worker ${result.etag}`);
44
+ } catch (err) {
45
+ log.error("Worker reconcile failed:", { err });
46
+ }
47
+ } while (pendingWhileRunning);
48
+ } finally {
49
+ isRunning = false;
50
+ }
51
+ }
52
+
53
+ return { trigger, appliedEtag: () => lastEtag };
54
+ }
@@ -0,0 +1,127 @@
1
+ import { log } from "@local/logger";
2
+ import { extractTarToWorkerDir } from "./extract-tar.ts";
3
+ import { validateWorkerState } from "./validate.ts";
4
+ import { currentPublicIp } from "./public-ip.ts";
5
+ import { workerStateSchema, type WorkerState } from "../worker-protocol/index.ts";
6
+ import type { AppApplyResult, Params, StatePull } from "../worker-protocol/uplink/index.ts";
7
+
8
+ interface ReconcileResult {
9
+ /** Null until a pull has landed: a worker Home has nothing for yet. */
10
+ etag: string | null;
11
+ /** The applied state — parsed on a 200, echoed back from `lastState` on a 304. */
12
+ state?: WorkerState;
13
+ }
14
+
15
+ export interface ReconcileDeps {
16
+ workerDir: string;
17
+ /** `state.pull` over the live session. */
18
+ pull: (params: Params<"state.pull">) => Promise<StatePull>;
19
+ /**
20
+ * `state.applied`, so Home learns what this worker is running and which of
21
+ * its apps came up. `apps` is one entry per compose project the callback ran.
22
+ */
23
+ applied?: (etag: string, apps: AppApplyResult[]) => Promise<void>;
24
+ /** Applies the state; resolves with one result per app it brought up. */
25
+ onNewState: (state: WorkerState) => Promise<AppApplyResult[]>;
26
+ /**
27
+ * Fires on a 304 (state unchanged) with the last-known state. Wire the cheap,
28
+ * idempotent healers here (broker-socket ensure) so anything that can vanish
29
+ * without a state change — a stray `rm`, a crashed broker — is
30
+ * rebuilt on any reconcile trigger instead of staying dead until the next
31
+ * state change. Must be a strict subset of `onNewState`'s side effects.
32
+ *
33
+ * Resolves with the results of any app it re-upped, empty when it only
34
+ * healed. A non-empty answer is reported to Home against the held etag: that
35
+ * is how an app that failed on the state change and came up on a retry stops
36
+ * reading as failed.
37
+ */
38
+ onUnchanged?: (state: WorkerState) => Promise<AppApplyResult[]>;
39
+ }
40
+
41
+ /**
42
+ * Reconcile the whole worker by pulling the per-worker state.json + supporting
43
+ * files from Home over the uplink and handing the parsed wire state to
44
+ * `onNewState`. Home serves a content-hash etag; we send it back so unchanged
45
+ * state collapses to a 304.
46
+ *
47
+ * Tar's "only writes paths in the archive" semantics naturally preserves
48
+ * apps/${appId}/data/ runtime dirs — Home never includes those in the tar.
49
+ */
50
+ export async function reconcileWorker(
51
+ deps: ReconcileDeps,
52
+ lastEtag: string | null,
53
+ lastState: WorkerState | null,
54
+ ): Promise<ReconcileResult> {
55
+ // Ride the pull rather than the announce: an announce is a boot event, so a
56
+ // worker whose public address moves would keep reporting the old one until
57
+ // its container is recreated. Every trigger refreshes it here instead, which
58
+ // is why there is no heartbeat loop for this. Absent when unknown - Home
59
+ // reads a missing field as "leave the column alone", never as "clear it".
60
+ const publicIp = await currentPublicIp();
61
+ const res = await deps.pull({
62
+ etag: lastEtag,
63
+ ...(publicIp === null ? {} : { publicIp }),
64
+ });
65
+
66
+ if (res.status === 304) {
67
+ await res.body.cancel();
68
+ log("Skipping reconcile (no changes)");
69
+ // State is unchanged, but the local brokers it describes may not be — heal
70
+ // them against the last-known state so a vanished socket doesn't wait for
71
+ // the next state change to come back.
72
+ if (lastState && deps.onUnchanged) {
73
+ const apps = await deps.onUnchanged(lastState);
74
+ // Only a retry that actually ran something has news; an idle heal sends
75
+ // nothing, so a 304 costs Home no write.
76
+ if (apps.length > 0 && lastEtag !== null) await reportApplied(deps, lastEtag, apps);
77
+ }
78
+ return { etag: lastEtag, ...(lastState === null ? {} : { state: lastState }) };
79
+ }
80
+ if (res.status === 503) {
81
+ await res.body.cancel();
82
+ // Home is up but has not computed this worker's state yet. It pushes a
83
+ // reconcile the moment it has, so there is nothing to retry here.
84
+ log("Home has no state for this worker yet");
85
+ return { etag: lastEtag, ...(lastState === null ? {} : { state: lastState }) };
86
+ }
87
+ if (res.status !== 200) {
88
+ await res.body.cancel();
89
+ throw new Error(`state.pull answered ${res.status}`);
90
+ }
91
+ if (res.etag === undefined) {
92
+ await res.body.cancel();
93
+ throw new Error("state.pull answered 200 without an etag");
94
+ }
95
+
96
+ await Deno.mkdir(deps.workerDir, { recursive: true, mode: 0o700 });
97
+ // mkdir's `mode` is ignored when the dir already exists; chmod
98
+ // every reconcile so a pre-existing 0o755 dir gets tightened.
99
+ await Deno.chmod(deps.workerDir, 0o700);
100
+ const { stateJsonString } = await extractTarToWorkerDir(res.body, deps.workerDir);
101
+
102
+ if (stateJsonString == null) throw new Error("state.pull answered without a state.json entry");
103
+
104
+ const state = workerStateSchema.parse(JSON.parse(stateJsonString));
105
+ validateWorkerState(state);
106
+
107
+ const apps = await deps.onNewState(state);
108
+ await reportApplied(deps, res.etag, apps);
109
+
110
+ return { etag: res.etag, state };
111
+ }
112
+
113
+ /**
114
+ * Fire and forget: Home learns what this worker is running, and a failure
115
+ * here must not turn a landed reconcile into a failed one. The next announce
116
+ * carries the same etag, and the next trigger re-reports any app still down.
117
+ */
118
+ async function reportApplied(
119
+ deps: ReconcileDeps,
120
+ etag: string,
121
+ apps: AppApplyResult[],
122
+ ): Promise<void> {
123
+ if (!deps.applied) return;
124
+ await deps.applied(etag, apps).catch((err: unknown) => {
125
+ log.warn(`state.applied ${etag} failed (${(err as Error).message})`);
126
+ });
127
+ }