@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.
- 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 +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- package/src/cli/commands/module-status.ts +124 -0
- 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/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/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
|
@@ -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
|
|
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
|
|
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
/**
|