@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +27 -7
- package/package.json +6 -5
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +70 -3
- package/src/cli/commands/module-config.ts +262 -28
- package/src/cli/commands/module-status.ts +155 -12
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +35 -9
- package/src/cli/index.ts +59 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/sweep-runner.test.ts +51 -3
- package/src/services/alerting/sweep-runner.ts +30 -7
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +33 -13
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- 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
|
|
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
|
-
*
|
|
6
|
-
* no ContentLength
|
|
7
|
-
* with "The request body terminated unexpectedly"
|
|
8
|
-
* stream across S3's retries
|
|
9
|
-
* (length known) and so never exercised the broken path
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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 {
|
|
23
|
-
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
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(
|
|
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('
|
|
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('
|
|
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
|
|