@celilo/cli 0.23.0 → 0.24.0

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 (61) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +27 -7
  3. package/package.json +6 -5
  4. package/src/cli/commands/alerts-act.ts +1 -1
  5. package/src/cli/commands/backup-create.ts +26 -11
  6. package/src/cli/commands/backup-list.test.ts +83 -0
  7. package/src/cli/commands/backup-list.ts +67 -3
  8. package/src/cli/commands/backup-prune.ts +17 -17
  9. package/src/cli/commands/backup-sweep.ts +20 -8
  10. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  11. package/src/cli/commands/firewall-interface-list.ts +123 -0
  12. package/src/cli/commands/machine-add.ts +30 -2
  13. package/src/cli/commands/module-config.test.ts +64 -2
  14. package/src/cli/commands/module-config.ts +159 -8
  15. package/src/cli/commands/module-status.ts +124 -0
  16. package/src/cli/commands/monitor.ts +116 -19
  17. package/src/cli/commands/system-migrate.ts +14 -0
  18. package/src/cli/commands/system-update.ts +4 -1
  19. package/src/cli/completion.ts +35 -9
  20. package/src/cli/index.ts +59 -2
  21. package/src/cli/tui/audit-state.ts +2 -0
  22. package/src/hooks/capability-loader.ts +130 -4
  23. package/src/hooks/types.ts +2 -1
  24. package/src/manifest/contracts/v1.ts +16 -0
  25. package/src/manifest/schema.ts +40 -65
  26. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  27. package/src/services/alerting/cadence-migration.test.ts +155 -0
  28. package/src/services/alerting/cadence-migration.ts +90 -0
  29. package/src/services/alerting/coverage-source.ts +8 -11
  30. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  31. package/src/services/alerting/deploy-hooks.ts +11 -5
  32. package/src/services/alerting/health-cadence.test.ts +58 -0
  33. package/src/services/alerting/health-cadence.ts +128 -0
  34. package/src/services/alerting/health-coverage.ts +18 -8
  35. package/src/services/alerting/monitors.ts +50 -15
  36. package/src/services/alerting/sweep-runner.test.ts +51 -3
  37. package/src/services/alerting/sweep-runner.ts +30 -7
  38. package/src/services/audit/backup-source.ts +24 -1
  39. package/src/services/audit/backups.test.ts +95 -10
  40. package/src/services/audit/backups.ts +40 -37
  41. package/src/services/audit/interface-classification.test.ts +220 -0
  42. package/src/services/audit/interface-classification.ts +167 -0
  43. package/src/services/audit/types.ts +2 -1
  44. package/src/services/backup-age-agreement.test.ts +118 -0
  45. package/src/services/backup-create.ts +36 -30
  46. package/src/services/backup-metadata.ts +52 -1
  47. package/src/services/backup-retention.test.ts +123 -0
  48. package/src/services/backup-retention.ts +66 -5
  49. package/src/services/backup-schedule.test.ts +166 -0
  50. package/src/services/backup-schedule.ts +105 -15
  51. package/src/services/backup-staging.ts +14 -1
  52. package/src/services/backup-sweep.test.ts +22 -3
  53. package/src/services/backup-sweep.ts +15 -5
  54. package/src/services/cadence.test.ts +97 -0
  55. package/src/services/cadence.ts +165 -0
  56. package/src/services/machine-detector.ts +23 -1
  57. package/src/services/module-config.ts +33 -0
  58. package/src/services/storage-providers/s3.test.ts +96 -13
  59. package/src/services/storage-providers/s3.ts +48 -15
  60. package/src/services/zone-detector.test.ts +34 -3
  61. package/src/services/zone-detector.ts +33 -13
@@ -0,0 +1,165 @@
1
+ /**
2
+ * How often celilo does something to a module, in one type.
3
+ *
4
+ * celilo has three per-module cadences — how often to back a module up, how
5
+ * often to health-check it, and (in the same family) how long to keep the
6
+ * backups. They were three spellings of the same idea: an enum of four words
7
+ * for backups, a `15m`-style duration for health checks, nothing shared. One
8
+ * namespace holding two unrelated formats is not one concept, so an operator
9
+ * setting `6h` on a backup had no way to be right.
10
+ *
11
+ * A cadence is therefore a word OR a duration, both normalised to minutes, plus
12
+ * `manual` for "the operator opted out". Every existing manifest keeps working:
13
+ * the words are the same words.
14
+ *
15
+ * The FLOOR is derived from the tick of the sweep that would act on the
16
+ * cadence, never stated independently. A cadence finer than its sweep's tick
17
+ * cannot be served, and accepting one leaves the operator believing they
18
+ * configured something that silently never happens. Deriving it means a changed
19
+ * tick moves the floor with it rather than waiting for someone to remember.
20
+ *
21
+ * Pure: no database, no clock. See
22
+ * openspec/changes/operator-cadence-overrides/design.md D3, D4.
23
+ */
24
+
25
+ import { z } from 'zod';
26
+
27
+ /** Minutes between runs, or `manual` — the operator opted out entirely. */
28
+ export type Cadence = { minutes: number } | 'manual';
29
+
30
+ /** The words a cadence may be spelled with, and what each means in minutes. */
31
+ const NAMED_PERIODS: Record<string, number> = {
32
+ hourly: 60,
33
+ daily: 24 * 60,
34
+ weekly: 7 * 24 * 60,
35
+ // 30 days, matching what the backup schedule has always meant by `monthly`.
36
+ // No calendar-month semantics are introduced here, and none are lost.
37
+ monthly: 30 * 24 * 60,
38
+ };
39
+
40
+ export const DURATION_PATTERN = /^(\d+)(m|h|d)$/;
41
+
42
+ /**
43
+ * Everything a cadence may be spelled as, for the JSON Schema export.
44
+ *
45
+ * Editors validate `modules/*​/manifest.yml` against the exported JSON Schema,
46
+ * which cannot carry a Zod refinement — so well-formedness is duplicated as a
47
+ * regex the same way `health_check.interval` already does it. The floor is not
48
+ * expressible here and stays a refinement.
49
+ */
50
+ export const CADENCE_PATTERN = /^(hourly|daily|weekly|monthly|manual|\d+(m|h|d))$/;
51
+
52
+ /**
53
+ * Parse a duration string (`15m`, `1h`, `1d`) to whole minutes.
54
+ * Returns null when the string is not a well-formed duration.
55
+ */
56
+ export function parseIntervalMinutes(value: string): number | null {
57
+ const match = DURATION_PATTERN.exec(value);
58
+ if (!match) return null;
59
+ const amount = Number.parseInt(match[1], 10);
60
+ if (!Number.isFinite(amount) || amount <= 0) return null;
61
+ const unit = match[2];
62
+ if (unit === 'm') return amount;
63
+ if (unit === 'h') return amount * 60;
64
+ return amount * 60 * 24;
65
+ }
66
+
67
+ /** Parse a cadence in any accepted spelling. Returns null when malformed. */
68
+ export function parseCadence(value: string): Cadence | null {
69
+ if (value === 'manual') return 'manual';
70
+ const named = NAMED_PERIODS[value];
71
+ if (named !== undefined) return { minutes: named };
72
+ const minutes = parseIntervalMinutes(value);
73
+ return minutes === null ? null : { minutes };
74
+ }
75
+
76
+ /**
77
+ * Spell a cadence back the way an operator would write it, preferring the word
78
+ * when one exists — a resolved `1440` reads as `daily`, not `24h`.
79
+ */
80
+ export function formatCadence(cadence: Cadence): string {
81
+ if (cadence === 'manual') return 'manual';
82
+ for (const [word, minutes] of Object.entries(NAMED_PERIODS)) {
83
+ if (minutes === cadence.minutes) return word;
84
+ }
85
+ if (cadence.minutes % (24 * 60) === 0) return `${cadence.minutes / (24 * 60)}d`;
86
+ if (cadence.minutes % 60 === 0) return `${cadence.minutes / 60}h`;
87
+ return `${cadence.minutes}m`;
88
+ }
89
+
90
+ /** Milliseconds between runs. `manual` is infinite — never due, never stale. */
91
+ export function cadenceMs(cadence: Cadence): number {
92
+ return cadence === 'manual' ? Number.POSITIVE_INFINITY : cadence.minutes * 60_000;
93
+ }
94
+
95
+ /**
96
+ * Minutes between ticks of a bus timer pattern (`timer.tick.5m` → 5).
97
+ *
98
+ * The floors below are derived through this rather than written down, so
99
+ * changing which tick a sweep rides changes what it can serve in the same edit.
100
+ */
101
+ export function tickIntervalMinutes(pattern: string): number {
102
+ const suffix = pattern.replace(/^timer\.tick\./, '');
103
+ const minutes = parseIntervalMinutes(suffix);
104
+ if (minutes === null) throw new Error(`Not a timer tick pattern: ${pattern}`);
105
+ return minutes;
106
+ }
107
+
108
+ /**
109
+ * The bus tick each sweep rides.
110
+ *
111
+ * They live here, with the floors that derive from them, rather than beside
112
+ * each sweep's subscriber registration — a floor stated in one file and a tick
113
+ * chosen in another is exactly the pair that drifts. The sweeps import their
114
+ * pattern from here.
115
+ */
116
+ export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
117
+ export const ALERTING_SWEEP_PATTERN = 'timer.tick.5m';
118
+
119
+ /** Finest backup cadence the hourly backup sweep can serve. */
120
+ export const BACKUP_CADENCE_FLOOR_MINUTES = tickIntervalMinutes(BACKUP_SWEEP_PATTERN);
121
+
122
+ /** Finest health-check cadence the five-minute alerting sweep can serve. */
123
+ export const MONITOR_INTERVAL_FLOOR_MINUTES = tickIntervalMinutes(ALERTING_SWEEP_PATTERN);
124
+
125
+ /** Human list of accepted spellings, naming the finest cadence this sweep serves. */
126
+ export function describeCadenceForm(floorMinutes: number): string {
127
+ return `Allowed: a named period (hourly, daily, weekly, monthly), a duration like "6h", "90m" or "3d" no finer than ${formatCadence({ minutes: floorMinutes })}, or "manual" to opt out.`;
128
+ }
129
+
130
+ /**
131
+ * A cadence value that the named sweep can actually serve.
132
+ *
133
+ * Validates the string form (what an operator types and what a manifest
134
+ * carries); the caller parses it with `parseCadence` once it is known good.
135
+ *
136
+ * The well-formedness check is a `.regex` on the inner string rather than part
137
+ * of the refinement because only the regex survives the export to JSON Schema,
138
+ * and that export is what validates `modules/*​/manifest.yml` in an editor. The
139
+ * floor cannot be expressed in JSON Schema at all, so it stays a refinement.
140
+ */
141
+ export function cadenceSchema({
142
+ floorMinutes,
143
+ description,
144
+ }: {
145
+ floorMinutes: number;
146
+ description?: string;
147
+ }): z.ZodType<string> {
148
+ const form = describeCadenceForm(floorMinutes);
149
+ let base = z.string().regex(CADENCE_PATTERN, { message: form });
150
+ if (description) base = base.describe(description);
151
+ return base.superRefine((value, ctx) => {
152
+ const cadence = parseCadence(value);
153
+ if (cadence === null) {
154
+ // `0h` matches the pattern and is not a cadence.
155
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: form });
156
+ return;
157
+ }
158
+ if (cadence !== 'manual' && cadence.minutes < floorMinutes) {
159
+ ctx.addIssue({
160
+ code: z.ZodIssueCode.custom,
161
+ message: `"${value}" is finer than the sweep that would serve it can run (every ${formatCadence({ minutes: floorMinutes })}). ${form}`,
162
+ });
163
+ }
164
+ });
165
+ }
@@ -298,7 +298,15 @@ async function detectNetworkInterfacesWith(
298
298
  interfaces.push({ name: iface.name, ipAddress: iface.ipAddress, zone });
299
299
  }
300
300
 
301
- // Classify: router if interfaces span multiple distinct zones
301
+ // Classify: router if interfaces span multiple distinct zones.
302
+ //
303
+ // The `!== 'unknown'` filter was dead code until now — `detectZoneFromIp`
304
+ // could not produce `'unknown'`, it claimed `external` instead, so every
305
+ // unmatched leg counted as a distinct zone and inflated this set. On the e2e
306
+ // firewall that meant four legs reported as `external`, collapsing to ONE
307
+ // zone here rather than the three real ones. The filter is live now, and it
308
+ // is the right rule: an interface celilo cannot attribute is not evidence of
309
+ // spanning anything.
302
310
  const uniqueZones = new Set(interfaces.map((i) => i.zone).filter((z) => z !== 'unknown'));
303
311
  const role: MachineRole = uniqueZones.size > 1 ? 'router' : 'host';
304
312
 
@@ -404,3 +412,17 @@ export async function testSshConnection(
404
412
  return false;
405
413
  }
406
414
  }
415
+
416
+ /**
417
+ * How an interface's zone reads to an operator.
418
+ *
419
+ * `machine add` used to print the raw zone for every leg, which meant a firewall
420
+ * with five RFC1918 interfaces printed four of them as `(external)` — because
421
+ * `detectZoneFromIp` answered `external` when it meant "no idea". The vocabulary
422
+ * now distinguishes the two: a zone name when celilo matched one, and
423
+ * `unaccounted for` when it did not, which is the honest answer and the one that
424
+ * tells an operator there is something to declare.
425
+ */
426
+ export function describeInterfaceZone(iface: NetworkInterface): string {
427
+ return iface.zone === 'unknown' ? 'unaccounted for — no declared subnet contains it' : iface.zone;
428
+ }
@@ -151,6 +151,39 @@ export function parseStoredConfigValue(
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * A module's whole operator config as a plain key → typed-value map — the shape
156
+ * every policy resolver takes.
157
+ */
158
+ export function loadModuleConfigs(db: DbClient, moduleId: string): Record<string, unknown> {
159
+ const configs: Record<string, unknown> = {};
160
+ for (const row of db
161
+ .select()
162
+ .from(moduleConfigs)
163
+ .where(eq(moduleConfigs.moduleId, moduleId))
164
+ .all()) {
165
+ configs[row.key] = parseStoredConfigValue(row);
166
+ }
167
+ return configs;
168
+ }
169
+
170
+ /**
171
+ * The raw string form of an operator override from an already-loaded config
172
+ * map, or undefined when the key is unset.
173
+ *
174
+ * Framework policy keys (cadences, retention, upgrade controls) are stored
175
+ * through the same typed path as any other config, so a cadence arrives as a
176
+ * string and a retention count as a number. Every resolver takes the string
177
+ * form and parses it itself, so this is the one place that flattening happens.
178
+ */
179
+ export function configOverride(
180
+ configs: Record<string, unknown> | undefined,
181
+ key: string,
182
+ ): string | undefined {
183
+ const raw = configs?.[key];
184
+ return raw === undefined || raw === null ? undefined : String(raw);
185
+ }
186
+
154
187
  /**
155
188
  * Get module configuration value
156
189
  * Returns parsed value (primitive or complex type)
@@ -1,17 +1,26 @@
1
1
  /**
2
- * Recurrence gate for ISS-0016: the S3 provider's upload() must send a
3
- * self-describing, replayable Buffer body — NOT a one-shot Node read stream.
2
+ * Recurrence gates for the two ways this provider's upload has been wrong.
4
3
  *
5
- * The original bug passed `createReadStream(localPath)` as PutObject Body with
6
- * no ContentLength; AWS SDK v3 fell back to aws-chunked streaming and failed
7
- * with "The request body terminated unexpectedly" (and couldn't replay the
8
- * stream across S3's retries/redirects). The verify path used a string body
9
- * (length known) and so never exercised the broken path every real S3 backup
10
- * silently failed. These tests upload a REAL on-disk file and assert the body
11
- * is a Buffer, so a regression back to a stream is caught here.
4
+ * ISS-0016 a one-shot `createReadStream` passed straight to PutObject as
5
+ * `Body` with no ContentLength. AWS SDK v3 fell back to aws-chunked streaming
6
+ * and failed with "The request body terminated unexpectedly", and could not
7
+ * replay the stream across S3's retries and redirects. The verify path used a
8
+ * string body (length known) and so never exercised the broken path, which is
9
+ * why every real S3 backup silently failed.
10
+ *
11
+ * celilo#685 — the fix for ISS-0016 was `readFileSync` into one Buffer, which
12
+ * is replayable and was fine for the system envelopes this provider carried at
13
+ * the time. Module backups then started using the same provider at a thousand
14
+ * times the size, and a ~1.9 GB Buffer on a 3784 MB management server was
15
+ * OOM-killed every hour for a day.
16
+ *
17
+ * The two constrain opposite things — replayable versus not resident — so
18
+ * neither test is meaningful alone, and satisfying one by breaking the other is
19
+ * exactly the history here. `Upload` (multipart) satisfies both: each PART is a
20
+ * replayable buffer, and only a bounded number of parts exist at once.
12
21
  *
13
22
  * No live S3 / MinIO harness exists, so we intercept S3Client.prototype.send
14
- * and inspect the command the provider builds.
23
+ * and inspect the commands the provider builds.
15
24
  */
16
25
 
17
26
  import { type Mock, afterEach, describe, expect, it, spyOn } from 'bun:test';
@@ -19,8 +28,20 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
28
  import { tmpdir } from 'node:os';
20
29
  import { join } from 'node:path';
21
30
  import { Readable } from 'node:stream';
22
- import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
23
- import { type S3StorageConfig, createS3StorageProvider } from './s3';
31
+ import {
32
+ CompleteMultipartUploadCommand,
33
+ CreateMultipartUploadCommand,
34
+ GetObjectCommand,
35
+ PutObjectCommand,
36
+ S3Client,
37
+ UploadPartCommand,
38
+ } from '@aws-sdk/client-s3';
39
+ import {
40
+ type S3StorageConfig,
41
+ UPLOAD_PART_SIZE,
42
+ UPLOAD_QUEUE_SIZE,
43
+ createS3StorageProvider,
44
+ } from './s3';
24
45
 
25
46
  const CONFIG: S3StorageConfig = {
26
47
  bucket: 'test-bucket',
@@ -44,7 +65,7 @@ describe('s3 storage provider (ISS-0016)', () => {
44
65
  }
45
66
  });
46
67
 
47
- it('uploads a real on-disk file as a self-describing Buffer body, not a stream', async () => {
68
+ it('uploads a small file as a self-describing Buffer body, not a stream (ISS-0016)', async () => {
48
69
  dir = mkdtempSync(join(tmpdir(), 's3-upload-'));
49
70
  const file = join(dir, 'envelope.tar.gz');
50
71
  // A multi-KB real file — the case that broke against live S3.
@@ -61,6 +82,8 @@ describe('s3 storage provider (ISS-0016)', () => {
61
82
  const provider = createS3StorageProvider(CONFIG);
62
83
  await provider.upload(file, 'celilo-mgmt/2026/envelope.tar.gz');
63
84
 
85
+ // Under one part, so no multipart ceremony — a single PutObject, exactly
86
+ // as before. The bound below is what changed, not the small-file path.
64
87
  expect(sent).toHaveLength(1);
65
88
  const command = sent[0];
66
89
  expect(command).toBeInstanceOf(PutObjectCommand);
@@ -78,6 +101,66 @@ describe('s3 storage provider (ISS-0016)', () => {
78
101
  expect(command.input.Bucket).toBe('test-bucket');
79
102
  });
80
103
 
104
+ it('never holds more than one part per queue slot, however large the file (celilo#685)', async () => {
105
+ dir = mkdtempSync(join(tmpdir(), 's3-upload-large-'));
106
+ const file = join(dir, 'backup.tar.enc');
107
+
108
+ // Deliberately larger than one part, so the multipart path runs for real.
109
+ // It does not need to approach forgejo's 1.87 GB: the property under test
110
+ // is that peak residency is set by the part size rather than by the file,
111
+ // and a file that spans several parts demonstrates that at any scale. A
112
+ // test that had to allocate the failing size to prove the fix would be
113
+ // reproducing the bug rather than gating it.
114
+ const parts = 3;
115
+ const fileSize = UPLOAD_PART_SIZE * parts;
116
+ writeFileSync(file, Buffer.alloc(fileSize, 0x7a));
117
+
118
+ // Deliberately NOT a running total. Parts are uploaded concurrently and can
119
+ // arrive in any order, so the only way to show the artifact survives is to
120
+ // keep each part against its number and reassemble.
121
+ const received = new Map<number, Buffer>();
122
+ let inFlight = 0;
123
+ let peakInFlight = 0;
124
+ sendSpy = spyOn(S3Client.prototype, 'send').mockImplementation(async (command: unknown) => {
125
+ if (command instanceof CreateMultipartUploadCommand) return { UploadId: 'upload-1' };
126
+ if (command instanceof CompleteMultipartUploadCommand) return {};
127
+
128
+ expect(command).toBeInstanceOf(UploadPartCommand);
129
+ const part = command as UploadPartCommand;
130
+ const body = part.input.Body as Buffer;
131
+ const partNumber = part.input.PartNumber as number;
132
+
133
+ // Each part is still a replayable Buffer — ISS-0016 holds per part.
134
+ expect(Buffer.isBuffer(body)).toBe(true);
135
+ // A part number reused would silently lose data on reassembly.
136
+ expect(received.has(partNumber)).toBe(false);
137
+ received.set(partNumber, Buffer.from(body));
138
+
139
+ inFlight += 1;
140
+ peakInFlight = Math.max(peakInFlight, inFlight);
141
+ await new Promise((resolve) => setTimeout(resolve, 1));
142
+ inFlight -= 1;
143
+ return { ETag: `"etag-${partNumber}"` };
144
+ });
145
+
146
+ const provider = createS3StorageProvider(CONFIG);
147
+ await provider.upload(file, 'forgejo/2026-08-13/backup.tar.enc');
148
+
149
+ // The bound. No single request ever carries the whole artifact, and no more
150
+ // than the queue depth are resident at once, so peak bytes is
151
+ // UPLOAD_PART_SIZE * UPLOAD_QUEUE_SIZE no matter how big the file gets.
152
+ expect(received.size).toBe(parts);
153
+ for (const body of received.values()) expect(body.length).toBeLessThanOrEqual(UPLOAD_PART_SIZE);
154
+ expect(peakInFlight).toBeLessThanOrEqual(UPLOAD_QUEUE_SIZE);
155
+
156
+ // A bound is only worth having if the artifact still arrives. Reassembled
157
+ // in part order, the bytes must be the file — sizes summing correctly would
158
+ // not catch a swapped or duplicated part, and a backup that restores to
159
+ // scrambled bytes is worse than one that fails loudly.
160
+ const ordered = [...received.entries()].sort(([a], [b]) => a - b).map(([, body]) => body);
161
+ expect(Buffer.concat(ordered).equals(readFileSync(file))).toBe(true);
162
+ });
163
+
81
164
  it('downloads a multi-chunk response body to disk intact (short-read safe)', async () => {
82
165
  dir = mkdtempSync(join(tmpdir(), 's3-download-'));
83
166
  const out = join(dir, 'restored.bin');
@@ -3,7 +3,7 @@
3
3
  * Works with AWS S3, MinIO, Backblaze B2, Wasabi, and any S3-compatible service.
4
4
  */
5
5
 
6
- import { createWriteStream, readFileSync } from 'node:fs';
6
+ import { createReadStream, createWriteStream } from 'node:fs';
7
7
  import { mkdir } from 'node:fs/promises';
8
8
  import { dirname } from 'node:path';
9
9
  import { Readable } from 'node:stream';
@@ -15,10 +15,47 @@ import {
15
15
  PutObjectCommand,
16
16
  S3Client,
17
17
  } from '@aws-sdk/client-s3';
18
+ import { Upload } from '@aws-sdk/lib-storage';
18
19
  import type { StorageProvider, StorageVerifyResult } from './types';
19
20
 
20
21
  const BACKUP_PREFIX = 'celilo-backups';
21
22
 
23
+ /**
24
+ * Multipart upload sizing. Peak resident bytes for an upload is
25
+ * `UPLOAD_PART_SIZE * UPLOAD_QUEUE_SIZE` — 64 MB — and does NOT grow with the
26
+ * artifact. That bound is the whole point of these two constants.
27
+ *
28
+ * `upload` used to `readFileSync` the artifact into one Buffer and hand it to
29
+ * PutObject. The comment justifying that read:
30
+ *
31
+ * > Upload a Buffer, NOT a read stream (ISS-0016). A streamed Body fails with
32
+ * > "The request body terminated unexpectedly" [...] Backup envelopes are
33
+ * > small (state + key, not provider binaries — ISS-0015), so buffering is
34
+ * > fine.
35
+ *
36
+ * The first half is true and still is: a one-shot Node stream as a PutObject
37
+ * `Body` cannot be replayed across the retries and redirects S3 issues, and
38
+ * with no ContentLength the SDK falls back to aws-chunked encoding on top.
39
+ * Passing `createReadStream` straight to PutObject would reintroduce exactly
40
+ * that bug.
41
+ *
42
+ * The second half stopped being true without anyone revisiting it. It was
43
+ * written when this provider only carried SYSTEM backups (celilo state plus a
44
+ * key, a few MB). MODULE backups now use the same provider and are three orders
45
+ * of magnitude larger — forgejo's envelope reached 1.87 GB — so "buffering is
46
+ * fine" became a ~1.9 GB Buffer on a 3784 MB management server, and the OOM
47
+ * killer took the backup every hour for a day while the on_backup hook itself
48
+ * reported success (celilo#685).
49
+ *
50
+ * `Upload` resolves the two halves rather than trading one for the other: it
51
+ * reads the stream a part at a time and each PART is a replayable buffer, so
52
+ * retries work without the whole object ever being resident. 16 MB parts keep
53
+ * a 1.87 GB artifact at ~117 requests, well inside S3's 10,000-part limit,
54
+ * which leaves headroom to ~160 GB.
55
+ */
56
+ export const UPLOAD_PART_SIZE = 16 * 1024 * 1024;
57
+ export const UPLOAD_QUEUE_SIZE = 4;
58
+
22
59
  export interface S3StorageConfig {
23
60
  bucket: string;
24
61
  region: string;
@@ -49,22 +86,18 @@ export function createS3StorageProvider(config: S3StorageConfig): StorageProvide
49
86
 
50
87
  return {
51
88
  async upload(localPath: string, remotePath: string): Promise<void> {
52
- // Upload a Buffer, NOT a read stream (ISS-0016). A streamed Body fails
53
- // with "The request body terminated unexpectedly": the SDK can't replay a
54
- // one-shot Node stream across the retries/redirects S3 issues (e.g. a
55
- // region 301), and with no ContentLength it falls back to aws-chunked
56
- // encoding on top. A Buffer is replayable and self-describing — the same
57
- // reason the string-bodied verify write succeeds where the stream upload
58
- // did not. Backup envelopes are small (state + key, not provider
59
- // binaries — ISS-0015), so buffering is fine.
60
- const body = readFileSync(localPath);
61
- await client.send(
62
- new PutObjectCommand({
89
+ // Multipart, so peak memory is UPLOAD_PART_SIZE * UPLOAD_QUEUE_SIZE
90
+ // regardless of how large the artifact is.
91
+ await new Upload({
92
+ client,
93
+ partSize: UPLOAD_PART_SIZE,
94
+ queueSize: UPLOAD_QUEUE_SIZE,
95
+ params: {
63
96
  Bucket: bucket,
64
97
  Key: prefixedKey(remotePath),
65
- Body: body,
66
- }),
67
- );
98
+ Body: createReadStream(localPath),
99
+ },
100
+ }).done();
68
101
  },
69
102
 
70
103
  async download(remotePath: string, localPath: string): Promise<void> {
@@ -70,9 +70,14 @@ describe('zone-detector', () => {
70
70
  expect(zone).toBe('secure');
71
71
  });
72
72
 
73
- it('returns external for non-matching IP', async () => {
73
+ it("returns 'unknown' for a PUBLIC address in no declared subnet", async () => {
74
+ // This asserted `external` before. It is now `'unknown'` because that is
75
+ // the question this function answers: containment. Whether 167.99.123.45
76
+ // is an external EDGE is `isPubliclyRoutable`'s question, and the caller
77
+ // resolves the two — see machine-add. Conflating them is the defect this
78
+ // change exists to remove (design D1).
74
79
  const zone = await detectZoneFromIp('167.99.123.45');
75
- expect(zone).toBe('external');
80
+ expect(zone).toBe('unknown');
76
81
  });
77
82
 
78
83
  it('matches first IP in subnet', async () => {
@@ -87,7 +92,33 @@ describe('zone-detector', () => {
87
92
 
88
93
  it('does not match IP outside subnet', async () => {
89
94
  const zone = await detectZoneFromIp('192.168.1.100');
90
- expect(zone).toBe('external');
95
+ expect(zone).toBe('unknown');
96
+ });
97
+
98
+ it("a PRIVATE address in no declared subnet is 'unknown', never 'external'", async () => {
99
+ // The heart of it. 172.16.5.5 is RFC 1918 — the internet cannot route to
100
+ // it under any circumstances — and it matched no declared subnet. Calling
101
+ // that `external` is the claim that broke `machine add`.
102
+ const zone = await detectZoneFromIp('172.16.5.5');
103
+ expect(zone).toBe('unknown');
104
+ });
105
+
106
+ /**
107
+ * §5.7 — PROOF THE OLD BEHAVIOUR IS GONE.
108
+ *
109
+ * The proposal's opening example, against the real subnet declarations in
110
+ * this fixture. Three RFC1918 gateway legs that no declared subnet contains.
111
+ * Pre-change, `detectZoneFromIp` returned `'external'` for every one of
112
+ * them, so `machine add` printed three private addresses as facing the
113
+ * internet. This test fails against that code and passes against this.
114
+ */
115
+ it('§5.7: three RFC1918 legs in no declared subnet are NOT external', async () => {
116
+ const undeclaredPrivateLegs = ['172.16.5.1', '10.99.0.1', '192.168.77.1'];
117
+ const zones = await Promise.all(undeclaredPrivateLegs.map((ip) => detectZoneFromIp(ip)));
118
+
119
+ expect(zones).toEqual(['unknown', 'unknown', 'unknown']);
120
+ // Stated separately so a failure says WHICH property broke.
121
+ expect(zones.filter((z) => z === 'external')).toEqual([]);
91
122
  });
92
123
  });
93
124
 
@@ -6,7 +6,24 @@
6
6
  import { subnetContains } from '@celilo/capabilities';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import { getDb } from '../db/client';
9
- import { type NetworkZone, systemConfig } from '../db/schema';
9
+ import { NETWORK_ZONES, type NetworkZone, systemConfig } from '../db/schema';
10
+
11
+ /**
12
+ * The zones a subnet can be declared for — every `NetworkZone` except
13
+ * `external`.
14
+ *
15
+ * `external` is deliberately absent and must stay absent: it has no
16
+ * `network.external.subnet` and must never be given one. It is not a segment
17
+ * celilo manages, it is whatever the ISP handed you, so it is the RESIDUAL —
18
+ * decided by `isPubliclyRoutable`, not by containment (design D1, amended).
19
+ *
20
+ * Derived from NETWORK_ZONES rather than listed by hand. The hand-written list
21
+ * that used to be here had already dropped `control-plane-vpn`, which is the
22
+ * third instance of that bug class in this repo.
23
+ */
24
+ const SUBNET_BACKED_ZONES: readonly NetworkZone[] = NETWORK_ZONES.filter(
25
+ (zone) => zone !== 'external',
26
+ );
10
27
 
11
28
  /**
12
29
  * Get system network configuration for a zone
@@ -28,25 +45,28 @@ async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
28
45
  }
29
46
 
30
47
  /**
31
- * Detect network zone from IP address
32
- * Matches IP against system network configuration
48
+ * Which zone an address belongs to, or `'unknown'` when celilo cannot say.
49
+ *
50
+ * **`'unknown'` is the honest answer, and it used to be unreachable.** This
51
+ * returned `'external'` on no-match — so on a firewall with five RFC1918 legs
52
+ * and three declared zones, private gateway addresses were each reported as
53
+ * facing the internet. `NetworkInterface.zone` was already typed
54
+ * `NetworkZone | 'unknown'` and nothing could produce it, because this claimed
55
+ * `external` instead. The slot for the honest answer existed and was
56
+ * unreachable.
33
57
  *
34
- * @param ip - IP address to check
35
- * @returns Detected network zone, or 'external' if no match
58
+ * This answers ONLY the containment question. Whether an unmatched address is
59
+ * an external edge is a different question, answered by `isPubliclyRoutable`
60
+ * see design D1 on why conflating the two was the defect.
36
61
  */
37
- export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
38
- // Check each zone's subnet
39
- const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
40
-
41
- for (const zone of zones) {
62
+ export async function detectZoneFromIp(ip: string): Promise<NetworkZone | 'unknown'> {
63
+ for (const zone of SUBNET_BACKED_ZONES) {
42
64
  const subnet = await getZoneSubnet(zone);
43
65
  if (subnet && subnetContains(subnet, ip)) {
44
66
  return zone;
45
67
  }
46
68
  }
47
-
48
- // No match found - must be external
49
- return 'external';
69
+ return 'unknown';
50
70
  }
51
71
 
52
72
  /**