@celilo/cli 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) 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 +70 -3
  14. package/src/cli/commands/module-config.ts +262 -28
  15. package/src/cli/commands/module-status.ts +155 -12
  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/load-hook-config.test.ts +169 -1
  24. package/src/hooks/load-hook-config.ts +118 -20
  25. package/src/hooks/types.ts +2 -1
  26. package/src/manifest/contracts/v1.ts +16 -0
  27. package/src/manifest/schema.ts +40 -65
  28. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  29. package/src/services/alerting/cadence-migration.test.ts +155 -0
  30. package/src/services/alerting/cadence-migration.ts +90 -0
  31. package/src/services/alerting/coverage-source.ts +8 -11
  32. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  33. package/src/services/alerting/deploy-hooks.ts +11 -5
  34. package/src/services/alerting/health-cadence.test.ts +58 -0
  35. package/src/services/alerting/health-cadence.ts +128 -0
  36. package/src/services/alerting/health-coverage.ts +18 -8
  37. package/src/services/alerting/monitors.ts +50 -15
  38. package/src/services/alerting/sweep-runner.test.ts +51 -3
  39. package/src/services/alerting/sweep-runner.ts +30 -7
  40. package/src/services/audit/backup-source.ts +24 -1
  41. package/src/services/audit/backups.test.ts +95 -10
  42. package/src/services/audit/backups.ts +40 -37
  43. package/src/services/audit/interface-classification.test.ts +220 -0
  44. package/src/services/audit/interface-classification.ts +167 -0
  45. package/src/services/audit/types.ts +2 -1
  46. package/src/services/backup-age-agreement.test.ts +118 -0
  47. package/src/services/backup-create.ts +36 -30
  48. package/src/services/backup-metadata.ts +52 -1
  49. package/src/services/backup-retention.test.ts +123 -0
  50. package/src/services/backup-retention.ts +66 -5
  51. package/src/services/backup-schedule.test.ts +166 -0
  52. package/src/services/backup-schedule.ts +105 -15
  53. package/src/services/backup-staging.ts +14 -1
  54. package/src/services/backup-sweep.test.ts +22 -3
  55. package/src/services/backup-sweep.ts +15 -5
  56. package/src/services/cadence.test.ts +97 -0
  57. package/src/services/cadence.ts +165 -0
  58. package/src/services/config-provenance.test.ts +155 -0
  59. package/src/services/config-provenance.ts +104 -0
  60. package/src/services/machine-detector.ts +23 -1
  61. package/src/services/module-config.ts +33 -0
  62. package/src/services/storage-providers/s3.test.ts +96 -13
  63. package/src/services/storage-providers/s3.ts +48 -15
  64. package/src/services/zone-detector.test.ts +34 -3
  65. package/src/services/zone-detector.ts +33 -13
  66. package/src/variables/context.ts +69 -15
  67. package/src/variables/declarative-derivation.test.ts +53 -0
  68. package/src/variables/declarative-derivation.ts +13 -2
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Who owns a config value — the operator, or celilo.
3
+ *
4
+ * The load-bearing case here is the one that looks like a special case and is
5
+ * not: `derive_from` does NOT mean derived. Getting that backwards refuses an
6
+ * operator's edit to their own config, and a migration written on the same test
7
+ * would delete the row.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import type { VariableDeclare } from '../manifest/schema';
12
+ import {
13
+ declaredVariables,
14
+ describeDerivedSource,
15
+ explainNotSettable,
16
+ isDerivedVariable,
17
+ } from './config-provenance';
18
+
19
+ function variable(overrides: Partial<VariableDeclare> & { name: string }): VariableDeclare {
20
+ return {
21
+ type: 'string',
22
+ required: false,
23
+ source: 'user',
24
+ ...overrides,
25
+ } as VariableDeclare;
26
+ }
27
+
28
+ describe('isDerivedVariable', () => {
29
+ test('a user-sourced variable is the operator’s', () => {
30
+ expect(isDerivedVariable(variable({ name: 'acme_email', source: 'user' }))).toBe(false);
31
+ });
32
+
33
+ test.each(['capability', 'system', 'infrastructure', 'terraform'] as const)(
34
+ 'a %s-sourced variable is celilo’s',
35
+ (source) => {
36
+ expect(isDerivedVariable(variable({ name: 'x', source }))).toBe(true);
37
+ },
38
+ );
39
+
40
+ test('a variable with NO declared source reads as the operator’s', () => {
41
+ // The manifest schema requires `source`, so this only happens for a
42
+ // malformed or pre-schema manifest already sitting in `manifest_data`. The
43
+ // question is which way to be wrong: guessing "derived" refuses an
44
+ // operator's `set` with a message insisting celilo owns a value nothing
45
+ // computes, which they cannot act on.
46
+ const noSource = {
47
+ name: 'app_port',
48
+ type: 'integer',
49
+ required: false,
50
+ } as unknown as VariableDeclare;
51
+
52
+ expect(isDerivedVariable(noSource)).toBe(false);
53
+ });
54
+
55
+ test('a user-sourced variable WITH a derive_from is still the operator’s', () => {
56
+ // iptables: `firewall_ip`, `source: user`, `derive_from: $machine:ipAddress`.
57
+ // `$machine:` derives are answered by the config interview — they seed a
58
+ // default the operator confirms — so the row is operator config. Classing
59
+ // it as derived would refuse an operator correcting their own firewall
60
+ // address, and deleting it on the same test would blind the trusted-sources
61
+ // audit in services/firewall-reach.ts, which reads exactly this row.
62
+ const firewallIp = variable({
63
+ name: 'firewall_ip',
64
+ source: 'user',
65
+ required: true,
66
+ derive_from: '$machine:ipAddress',
67
+ });
68
+
69
+ expect(isDerivedVariable(firewallIp)).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe('declaredVariables', () => {
74
+ test('indexes a manifest’s owned variables by name', () => {
75
+ const declared = declaredVariables({
76
+ variables: {
77
+ owns: [variable({ name: 'hostname' }), variable({ name: 'vpn_subnet', source: 'system' })],
78
+ },
79
+ } as never);
80
+
81
+ expect([...declared.keys()].sort()).toEqual(['hostname', 'vpn_subnet']);
82
+ expect(declared.get('vpn_subnet')?.source).toBe('system');
83
+ });
84
+
85
+ test('a manifest declaring nothing yields an empty index, not a throw', () => {
86
+ expect(declaredVariables({} as never).size).toBe(0);
87
+ });
88
+ });
89
+
90
+ describe('explainNotSettable', () => {
91
+ test('a capability-sourced value points at the provider, not at this module', () => {
92
+ // The live footgun: `celilo module config set authentik auth_url …`
93
+ // reported success, wrote the row, and was discarded on the next deploy.
94
+ const message = explainNotSettable(
95
+ 'authentik',
96
+ variable({
97
+ name: 'auth_url',
98
+ source: 'capability',
99
+ derive_from: '$capability:authentication.url',
100
+ }),
101
+ );
102
+
103
+ expect(message).toContain('not operator-settable');
104
+ expect(message).toContain('source: capability');
105
+ // Actionable: the only way to change a derived value is to fix its source.
106
+ expect(message).toContain('provider');
107
+ expect(message).toContain('redeploy');
108
+ });
109
+
110
+ test('a system-sourced value names the system key to set', () => {
111
+ const message = explainNotSettable(
112
+ 'technitium',
113
+ variable({
114
+ name: 'vpn_subnet',
115
+ source: 'system',
116
+ derive_from: '$system:network.control-plane-vpn.subnet',
117
+ }),
118
+ );
119
+
120
+ // The operator should be able to copy the fix out of the error. The
121
+ // `$system:` prefix is stripped so the key is the one `system config set`
122
+ // actually takes.
123
+ expect(message).toContain('celilo system config set network.control-plane-vpn.subnet');
124
+ });
125
+
126
+ test('an infrastructure-sourced value keeps the placement guidance', () => {
127
+ const message = explainNotSettable(
128
+ 'caddy',
129
+ variable({ name: 'vmid', source: 'infrastructure' }),
130
+ );
131
+
132
+ expect(message).toContain('IPAM');
133
+ expect(message).toContain('celilo proxmox migrate');
134
+ });
135
+ });
136
+
137
+ describe('describeDerivedSource', () => {
138
+ test('names the upstream, and the template when there is one', () => {
139
+ expect(
140
+ describeDerivedSource(
141
+ variable({
142
+ name: 'dmz_subnet',
143
+ source: 'system',
144
+ derive_from: '$system:network.dmz.subnet',
145
+ }),
146
+ ),
147
+ ).toBe('from system config ($system:network.dmz.subnet)');
148
+ });
149
+
150
+ test('degrades to the upstream alone when no template is declared', () => {
151
+ expect(describeDerivedSource(variable({ name: 'vmid', source: 'infrastructure' }))).toContain(
152
+ 'infrastructure celilo selected',
153
+ );
154
+ });
155
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Who owns a module-config value: the operator, or celilo.
3
+ *
4
+ * A module's manifest declares a `source` for every variable it owns. `user`
5
+ * means the operator supplies it. Every other source — `capability`, `system`,
6
+ * `infrastructure`, `terraform` — means celilo computes it from somewhere else:
7
+ * another module's published capability data, system config, the selected
8
+ * infrastructure, a Terraform output.
9
+ *
10
+ * That distinction has to be shared rather than re-derived per command, because
11
+ * it was previously spelled differently in each place and the disagreements were
12
+ * silent. `module config set` refused only `source: infrastructure`, so setting
13
+ * a `capability`- or `system`-sourced value REPORTED SUCCESS, wrote the row, and
14
+ * was then discarded on the next deploy —
15
+ * `celilo module config set authentik auth_url …` being the live example. And
16
+ * `module config get` printed every row flat, so a value celilo derived was
17
+ * indistinguishable from one the operator had chosen.
18
+ *
19
+ * ## `derive_from` does not mean derived
20
+ *
21
+ * The tempting shortcut is "it has a `derive_from` template, so celilo computes
22
+ * it". That is wrong, and expensively so. `iptables` declares:
23
+ *
24
+ * - name: firewall_ip
25
+ * source: user
26
+ * derive_from: "$machine:ipAddress"
27
+ *
28
+ * `$machine:` derivations are answered by the config interview — they seed a
29
+ * default the operator confirms — not by template resolution. The row is
30
+ * operator config. Treating it as derived would refuse an operator's attempt to
31
+ * correct their own firewall address, and a migration that deleted rows on the
32
+ * same test would blind the trusted-sources audit that reads it.
33
+ *
34
+ * `source` is the authority. Nothing else is.
35
+ */
36
+ import type { ModuleManifest, VariableDeclare } from '../manifest/schema';
37
+
38
+ /**
39
+ * Does celilo compute this variable, rather than the operator supply it?
40
+ *
41
+ * An ABSENT source reads as the operator's. The manifest schema requires
42
+ * `source`, so absent means a malformed or pre-schema manifest sitting in
43
+ * `modules.manifest_data` — and for those the question is which way to be
44
+ * wrong. Guessing "derived" refuses an operator's attempt to set their own
45
+ * config with a message insisting celilo owns a value nothing computes, which
46
+ * is unanswerable. Guessing "user" preserves what celilo did before this
47
+ * predicate existed, when only `infrastructure` was refused.
48
+ */
49
+ export function isDerivedVariable(variable: Pick<VariableDeclare, 'source'>): boolean {
50
+ return variable.source !== undefined && variable.source !== 'user';
51
+ }
52
+
53
+ /** Every variable a module's manifest declares, indexed by name. */
54
+ export function declaredVariables(manifest: ModuleManifest): Map<string, VariableDeclare> {
55
+ return new Map((manifest.variables?.owns ?? []).map((variable) => [variable.name, variable]));
56
+ }
57
+
58
+ /**
59
+ * A one-line explanation of where a derived value comes from, for output an
60
+ * operator reads. Says which upstream to go fix, since fixing the source is the
61
+ * only way to change a derived value.
62
+ */
63
+ export function describeDerivedSource(variable: VariableDeclare): string {
64
+ switch (variable.source) {
65
+ case 'capability':
66
+ return variable.derive_from
67
+ ? `from another module's capability data (${variable.derive_from})`
68
+ : "from another module's capability data";
69
+ case 'system':
70
+ return variable.derive_from
71
+ ? `from system config (${variable.derive_from})`
72
+ : 'from system config';
73
+ case 'infrastructure':
74
+ return 'from the infrastructure celilo selected for this module';
75
+ case 'terraform':
76
+ return 'from a Terraform output, at deploy time';
77
+ default:
78
+ return `computed by celilo (source: ${variable.source})`;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Why `module config set` refuses this variable, and what to do instead.
84
+ * Actionable per source: a derived value is only wrong because its upstream is
85
+ * wrong, and fixing the upstream fixes every consumer at once, where pinning one
86
+ * module hides the divergence.
87
+ */
88
+ export function explainNotSettable(moduleId: string, variable: VariableDeclare): string {
89
+ const header = `'${variable.name}' is derived by celilo (source: ${variable.source}) — not operator-settable.`;
90
+ const origin = `It is computed ${describeDerivedSource(variable)}, so a value set here would be overwritten the next time ${moduleId} is generated.`;
91
+
92
+ switch (variable.source) {
93
+ case 'capability':
94
+ return `${header}\n${origin}\n • Fix it at the provider: change the config of the module that publishes this capability, then redeploy it.\n • 'celilo module config get ${moduleId}' shows the value celilo currently computes.`;
95
+ case 'system':
96
+ return `${header}\n${origin}\n • Fix it at the source: 'celilo system config set ${variable.derive_from?.replace(/^\$\{?system:/, '').replace(/\}$/, '') ?? '<key>'} <value>'.\n • That corrects every module deriving from it at once, rather than pinning this one.`;
97
+ case 'infrastructure':
98
+ return `${header}\n${origin}\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate'.\n • vmid / IP: auto-allocated by IPAM.`;
99
+ case 'terraform':
100
+ return `${header}\n${origin}\n • It is read back from Terraform outputs after the deploy creates the resource.`;
101
+ default:
102
+ return `${header}\n${origin}`;
103
+ }
104
+ }
@@ -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