eoas 3.0.5 → 3.1.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/README.md +5 -5
- package/bin/dev.js +3 -0
- package/bin/run.js +3 -0
- package/dist/commands/generate-certs.js +22 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/publish.js +55 -31
- package/dist/commands/republish.js +87 -27
- package/dist/commands/server/init.d.ts +17 -0
- package/dist/commands/server/init.js +654 -0
- package/dist/commands/server/validate.d.ts +10 -0
- package/dist/commands/server/validate.js +115 -0
- package/dist/lib/assets.d.ts +30 -8
- package/dist/lib/assets.js +158 -4
- package/dist/lib/auth.js +2 -1
- package/dist/lib/log.d.ts +8 -2
- package/dist/lib/log.js +47 -31
- package/dist/lib/ora.d.ts +10 -9
- package/dist/lib/ora.js +49 -88
- package/dist/lib/packageRunner.js +2 -1
- package/dist/lib/prompts.d.ts +32 -0
- package/dist/lib/prompts.js +86 -1
- package/dist/lib/serverConfig/choices.d.ts +62 -0
- package/dist/lib/serverConfig/choices.js +65 -0
- package/dist/lib/serverConfig/envCatalog.d.ts +41 -0
- package/dist/lib/serverConfig/envCatalog.js +582 -0
- package/dist/lib/serverConfig/helmValues.d.ts +22 -0
- package/dist/lib/serverConfig/helmValues.js +281 -0
- package/dist/lib/serverConfig/passwordPolicy.d.ts +3 -0
- package/dist/lib/serverConfig/passwordPolicy.js +36 -0
- package/dist/lib/serverUpdates.d.ts +45 -0
- package/dist/lib/serverUpdates.js +108 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +18 -14
- package/package.json +8 -6
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Single source of truth for the server env vars server:init writes and
|
|
3
|
+
// server:validate checks. Every var declares when it applies to a set of
|
|
4
|
+
// choices, whether the server needs it to boot, and the value or
|
|
5
|
+
// <placeholder> to write. Variable names and rules mirror the server config
|
|
6
|
+
// (config/config.go, internal/cdn, helm/values.yaml).
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.validateEnvMap = exports.inferChoicesFromEnv = exports.parseEnvFile = exports.renderEnvFile = exports.isPlaceholder = exports.applicableVars = exports.ENV_SECTIONS = void 0;
|
|
9
|
+
const choices_1 = require("./choices");
|
|
10
|
+
const passwordPolicy_1 = require("./passwordPolicy");
|
|
11
|
+
const isS3Family = (c) => c.storage === 'aws-s3' || c.storage === 's3-compatible';
|
|
12
|
+
const or = (value, placeholder) => value && value.trim() !== '' ? value : placeholder;
|
|
13
|
+
exports.ENV_SECTIONS = [
|
|
14
|
+
{
|
|
15
|
+
title: 'Server',
|
|
16
|
+
vars: [
|
|
17
|
+
{
|
|
18
|
+
name: 'BASE_URL',
|
|
19
|
+
applies: () => true,
|
|
20
|
+
required: true,
|
|
21
|
+
value: c => or(c.baseUrl, '<https://your-ota-domain>'),
|
|
22
|
+
comment: 'Public HTTPS URL of the server; manifest and asset URLs are built from it.',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'JWT_SECRET',
|
|
26
|
+
applies: () => true,
|
|
27
|
+
required: true,
|
|
28
|
+
value: c => or(c.jwtSecret, '<openssl rand -base64 32>'),
|
|
29
|
+
comment: 'Signs dashboard sessions and upload tokens.',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
title: 'Database (control plane)',
|
|
35
|
+
vars: [
|
|
36
|
+
{
|
|
37
|
+
name: 'DB_URL',
|
|
38
|
+
applies: () => true,
|
|
39
|
+
required: true,
|
|
40
|
+
value: c => or(c.dbUrl, '<postgresql://user:password@host:5432/xprem>'),
|
|
41
|
+
comment: 'The server runs its schema migrations itself, but never creates the database.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'DB_KEYS_MASTER_KEY_B64',
|
|
45
|
+
applies: c => c.masterKeySource === 'environment',
|
|
46
|
+
required: true,
|
|
47
|
+
value: c => or(c.masterKey, '<openssl rand -base64 32>'),
|
|
48
|
+
comment: 'Seals the per-app signing keys in Postgres. Back it up, it is not recoverable.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID',
|
|
52
|
+
applies: c => c.masterKeySource === 'aws-secrets-manager',
|
|
53
|
+
required: true,
|
|
54
|
+
value: c => or(c.masterKeySecretId, 'xprem/db-master-key'),
|
|
55
|
+
comment: 'Secrets Manager secret holding the master key (openssl rand -base64 32).',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: 'AWS credentials',
|
|
61
|
+
note: c => !(0, choices_1.needsAwsAccessKeys)(c) &&
|
|
62
|
+
(c.storage === 'aws-s3' ||
|
|
63
|
+
c.masterKeySource === 'aws-secrets-manager' ||
|
|
64
|
+
c.delivery === 'cloudfront')
|
|
65
|
+
? 'AWS auth comes from the IAM role attached to the runtime; no keys in the env.'
|
|
66
|
+
: undefined,
|
|
67
|
+
vars: [
|
|
68
|
+
{
|
|
69
|
+
name: 'AWS_ACCESS_KEY_ID',
|
|
70
|
+
applies: choices_1.needsAwsAccessKeys,
|
|
71
|
+
required: true,
|
|
72
|
+
value: () => '<your-access-key-id>',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'AWS_SECRET_ACCESS_KEY',
|
|
76
|
+
applies: choices_1.needsAwsAccessKeys,
|
|
77
|
+
required: true,
|
|
78
|
+
value: () => '<your-secret-access-key>',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
title: 'Storage',
|
|
84
|
+
vars: [
|
|
85
|
+
{
|
|
86
|
+
name: 'STORAGE_MODE',
|
|
87
|
+
applies: () => true,
|
|
88
|
+
required: true,
|
|
89
|
+
value: c => (c.storage === 'gcs' ? 'gcs' : c.storage === 'azure' ? 'azure' : 's3'),
|
|
90
|
+
helmKey: 'storageMode',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'S3_BUCKET_NAME',
|
|
94
|
+
applies: isS3Family,
|
|
95
|
+
required: true,
|
|
96
|
+
value: c => or(c.s3BucketName, '<your-bucket-name>'),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'AWS_REGION',
|
|
100
|
+
applies: isS3Family,
|
|
101
|
+
required: true,
|
|
102
|
+
value: c => or(c.awsRegion, 'eu-west-1'),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'AWS_BASE_ENDPOINT',
|
|
106
|
+
applies: c => c.storage === 's3-compatible',
|
|
107
|
+
required: true,
|
|
108
|
+
value: c => or(c.awsBaseEndpoint, '<https://your-s3-endpoint>'),
|
|
109
|
+
comment: "The provider's S3 API endpoint.",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'AWS_S3_FORCE_PATH_STYLE',
|
|
113
|
+
applies: c => c.forcePathStyle === true,
|
|
114
|
+
required: true,
|
|
115
|
+
value: () => 'true',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'GCS_BUCKET_NAME',
|
|
119
|
+
applies: c => c.storage === 'gcs',
|
|
120
|
+
required: true,
|
|
121
|
+
value: c => or(c.gcsBucketName, '<your-bucket-name>'),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'GOOGLE_APPLICATION_CREDENTIALS_B64',
|
|
125
|
+
applies: c => c.storage === 'gcs',
|
|
126
|
+
required: true,
|
|
127
|
+
value: () => '<base64 service-account.json>',
|
|
128
|
+
comment: 'Service account with Storage Admin on the bucket; also signs delivery URLs.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'AZURE_BLOB_CONTAINER_NAME',
|
|
132
|
+
applies: c => c.storage === 'azure',
|
|
133
|
+
required: true,
|
|
134
|
+
value: c => or(c.azureContainerName, '<your-container-name>'),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'AZURE_STORAGE_ACCOUNT_NAME',
|
|
138
|
+
applies: c => c.storage === 'azure',
|
|
139
|
+
required: true,
|
|
140
|
+
value: c => or(c.azureAccountName, '<your-account-name>'),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'AZURE_STORAGE_ACCOUNT_KEY',
|
|
144
|
+
applies: c => c.storage === 'azure',
|
|
145
|
+
required: true,
|
|
146
|
+
value: () => '<account access key>',
|
|
147
|
+
comment: 'Authenticates the server and signs the SAS delivery URLs.',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
title: 'CDN & Assets Delivery',
|
|
153
|
+
note: c => c.delivery === 'presigned'
|
|
154
|
+
? 'Nothing to configure: the server signs short-lived URLs straight into the private bucket.'
|
|
155
|
+
: undefined,
|
|
156
|
+
vars: [
|
|
157
|
+
{
|
|
158
|
+
name: 'CLOUDFRONT_DOMAIN',
|
|
159
|
+
applies: c => c.delivery === 'cloudfront',
|
|
160
|
+
required: true,
|
|
161
|
+
value: () => '<your-cloudfront-domain>',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'CLOUDFRONT_KEY_PAIR_ID',
|
|
165
|
+
applies: c => c.delivery === 'cloudfront',
|
|
166
|
+
required: true,
|
|
167
|
+
value: () => '<your-public-key-id>',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'PRIVATE_CLOUDFRONT_KEY_B64',
|
|
171
|
+
applies: c => c.delivery === 'cloudfront' && c.masterKeySource === 'environment',
|
|
172
|
+
required: true,
|
|
173
|
+
value: () => '<base64 private_key.pem>',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID',
|
|
177
|
+
applies: c => c.delivery === 'cloudfront' && c.masterKeySource === 'aws-secrets-manager',
|
|
178
|
+
required: true,
|
|
179
|
+
value: () => 'xprem/cloudfront-private-key',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'DISABLE_S3_DIRECT_CDN',
|
|
183
|
+
applies: c => c.delivery === 'through-server',
|
|
184
|
+
required: true,
|
|
185
|
+
value: () => 'true',
|
|
186
|
+
comment: 'Assets stream through the server instead of redirecting to signed bucket URLs.',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'CDN_BASE_URL',
|
|
190
|
+
applies: c => c.delivery === 'generic-cdn',
|
|
191
|
+
required: true,
|
|
192
|
+
value: c => or(c.cdnBaseUrl, '<https://cdn.example.com>'),
|
|
193
|
+
comment: 'Base URL of the CDN fronting the bucket; the bucket must be publicly readable.',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
title: 'Cache',
|
|
199
|
+
vars: [
|
|
200
|
+
{
|
|
201
|
+
name: 'CACHE_MODE',
|
|
202
|
+
applies: () => true,
|
|
203
|
+
required: true,
|
|
204
|
+
value: c => c.cacheMode,
|
|
205
|
+
helmKey: 'cacheMode',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'REDIS_HOST',
|
|
209
|
+
applies: c => c.cacheMode === 'redis',
|
|
210
|
+
required: true,
|
|
211
|
+
value: c => or(c.redisHost, '<your-redis-host>'),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'REDIS_PORT',
|
|
215
|
+
applies: c => c.cacheMode === 'redis',
|
|
216
|
+
required: true,
|
|
217
|
+
value: c => or(c.redisPort, '6379'),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'REDIS_SENTINEL_ADDRS',
|
|
221
|
+
applies: c => c.cacheMode === 'redis-sentinel',
|
|
222
|
+
required: true,
|
|
223
|
+
value: () => '<sentinel-0:26379,sentinel-1:26379>',
|
|
224
|
+
comment: 'Comma-separated, at least one.',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'REDIS_SENTINEL_MASTER_NAME',
|
|
228
|
+
applies: c => c.cacheMode === 'redis-sentinel',
|
|
229
|
+
required: false,
|
|
230
|
+
value: () => 'mymaster',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'REDIS_PASSWORD',
|
|
234
|
+
applies: c => c.cacheMode !== 'local',
|
|
235
|
+
required: true,
|
|
236
|
+
lenient: true,
|
|
237
|
+
value: () => '<your-redis-password>',
|
|
238
|
+
comment: 'Leave empty only if your Redis accepts unauthenticated connections.',
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'REDIS_USERNAME',
|
|
242
|
+
applies: c => c.cacheMode !== 'local',
|
|
243
|
+
required: false,
|
|
244
|
+
value: () => '',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'REDIS_USE_TLS',
|
|
248
|
+
applies: c => c.cacheMode !== 'local',
|
|
249
|
+
required: false,
|
|
250
|
+
value: () => 'true',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: 'CACHE_KEY_PREFIX',
|
|
254
|
+
applies: c => c.cacheMode !== 'local',
|
|
255
|
+
required: false,
|
|
256
|
+
value: () => '',
|
|
257
|
+
comment: 'Prefix on every cache key, for a Redis shared with other apps.',
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
title: 'Dashboard',
|
|
263
|
+
vars: [
|
|
264
|
+
{
|
|
265
|
+
name: 'USE_DASHBOARD',
|
|
266
|
+
applies: c => c.dashboard !== false,
|
|
267
|
+
required: true,
|
|
268
|
+
value: () => 'true',
|
|
269
|
+
helmKey: 'useDashboard',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'ADMIN_EMAIL',
|
|
273
|
+
applies: c => c.dashboard !== false,
|
|
274
|
+
required: true,
|
|
275
|
+
value: c => or(c.adminEmail, '<you@example.com>'),
|
|
276
|
+
comment: 'Seeds the first admin at first boot; removable after.',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'ADMIN_PASSWORD',
|
|
280
|
+
applies: c => c.dashboard !== false,
|
|
281
|
+
required: true,
|
|
282
|
+
value: c => or(c.adminPassword, '<strong-password>'),
|
|
283
|
+
comment: 'Policy: 8+ characters, an uppercase, a lowercase, a digit and a special character.',
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
title: 'Observe',
|
|
289
|
+
vars: [
|
|
290
|
+
{
|
|
291
|
+
name: 'CLICKHOUSE_URL',
|
|
292
|
+
applies: c => c.observe,
|
|
293
|
+
required: true,
|
|
294
|
+
value: c => or(c.clickhouseUrl, '<clickhouse://user:password@host:9000/xprem>'),
|
|
295
|
+
comment: 'Enables telemetry ingestion and update-health history.',
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
title: 'Geolocation',
|
|
301
|
+
note: c => c.geoip && c.geoipStrategy === 'proxy-headers'
|
|
302
|
+
? 'GEOIP_HEADER_COUNTRY/CITY/LATITUDE/LONGITUDE replace the recognized header names when your proxy uses custom ones.'
|
|
303
|
+
: undefined,
|
|
304
|
+
vars: [
|
|
305
|
+
{
|
|
306
|
+
name: 'TRUST_GEOIP_HEADERS',
|
|
307
|
+
applies: c => c.geoip && c.geoipStrategy === 'proxy-headers',
|
|
308
|
+
required: true,
|
|
309
|
+
value: () => 'true',
|
|
310
|
+
comment: 'Locate devices from the visitor-location headers of your proxy or CDN (Cloudflare, CloudFront, Vercel, X-Geo-*). Only safe when the server is reachable exclusively through it.',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'MAXMIND_ACCOUNT_ID',
|
|
314
|
+
applies: c => c.geoip && c.geoipStrategy === 'maxmind',
|
|
315
|
+
required: true,
|
|
316
|
+
value: c => or(c.maxmindAccountId, '<maxmind-account-id>'),
|
|
317
|
+
comment: 'The server downloads the free GeoLite2 City database itself at startup; both MAXMIND_* variables must be set together.',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'MAXMIND_LICENSE_KEY',
|
|
321
|
+
applies: c => c.geoip && c.geoipStrategy === 'maxmind',
|
|
322
|
+
required: true,
|
|
323
|
+
value: c => or(c.maxmindLicenseKey, '<maxmind-license-key>'),
|
|
324
|
+
comment: 'Generate one in the MaxMind account portal (free GeoLite2 signup).',
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: 'GEOIP_CACHE_DIR',
|
|
328
|
+
applies: c => c.geoip && c.geoipStrategy === 'maxmind',
|
|
329
|
+
required: false,
|
|
330
|
+
value: () => '',
|
|
331
|
+
comment: 'Where the downloaded database is cached; point it at a writable volume when the filesystem is read-only.',
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
title: 'Optional extras',
|
|
337
|
+
vars: [
|
|
338
|
+
{
|
|
339
|
+
name: 'PORT',
|
|
340
|
+
applies: () => true,
|
|
341
|
+
required: false,
|
|
342
|
+
value: () => '3000',
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
name: 'PROMETHEUS_ENABLED',
|
|
346
|
+
applies: () => true,
|
|
347
|
+
required: false,
|
|
348
|
+
value: () => 'true',
|
|
349
|
+
comment: 'Exposes /metrics; keep the route private at the proxy or ingress.',
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'TRUST_PROXY_HEADERS',
|
|
353
|
+
applies: () => true,
|
|
354
|
+
required: false,
|
|
355
|
+
value: () => 'true',
|
|
356
|
+
comment: 'Read the client IP from X-Forwarded-For when behind a trusted proxy.',
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: 'BUCKET_KEY_PREFIX',
|
|
360
|
+
applies: () => true,
|
|
361
|
+
required: false,
|
|
362
|
+
value: () => '',
|
|
363
|
+
comment: 'Key prefix inside the bucket, for a bucket shared with other data.',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'AZURE_BLOB_ENDPOINT',
|
|
367
|
+
applies: c => c.storage === 'azure',
|
|
368
|
+
required: false,
|
|
369
|
+
value: () => '',
|
|
370
|
+
comment: 'Blob service URL override (Azurite, private endpoints).',
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: 'DISABLE_DEVICE_TELEMETRY',
|
|
374
|
+
applies: c => !c.observe,
|
|
375
|
+
required: false,
|
|
376
|
+
value: () => 'true',
|
|
377
|
+
comment: 'Record nothing about devices: no registry, no update health, no telemetry.',
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
];
|
|
382
|
+
function applicableVars(choices) {
|
|
383
|
+
return exports.ENV_SECTIONS.flatMap(section => section.vars.filter(spec => spec.applies(choices)));
|
|
384
|
+
}
|
|
385
|
+
exports.applicableVars = applicableVars;
|
|
386
|
+
const PLACEHOLDER_PATTERN = /<[^>]+>/;
|
|
387
|
+
function isPlaceholder(value) {
|
|
388
|
+
return PLACEHOLDER_PATTERN.test(value);
|
|
389
|
+
}
|
|
390
|
+
exports.isPlaceholder = isPlaceholder;
|
|
391
|
+
/** Renders the .env.xprem content for the wizard's choices. */
|
|
392
|
+
function renderEnvFile(choices) {
|
|
393
|
+
const lines = [
|
|
394
|
+
'# Generated by eoas server:init.',
|
|
395
|
+
'# Fill every <placeholder>, then check the result with: eoas server:validate .env.xprem',
|
|
396
|
+
'',
|
|
397
|
+
];
|
|
398
|
+
for (const section of exports.ENV_SECTIONS) {
|
|
399
|
+
const vars = section.vars.filter(spec => spec.applies(choices));
|
|
400
|
+
const note = section.note?.(choices);
|
|
401
|
+
if (vars.length === 0 && !note) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
lines.push(`# ----- ${section.title} -----`);
|
|
405
|
+
if (note) {
|
|
406
|
+
lines.push(`# ${note}`);
|
|
407
|
+
}
|
|
408
|
+
for (const spec of vars) {
|
|
409
|
+
if (spec.comment) {
|
|
410
|
+
lines.push(`# ${spec.comment}`);
|
|
411
|
+
}
|
|
412
|
+
const assignment = `${spec.name}=${spec.value(choices)}`;
|
|
413
|
+
lines.push(spec.required ? assignment : `# ${assignment}`);
|
|
414
|
+
}
|
|
415
|
+
lines.push('');
|
|
416
|
+
}
|
|
417
|
+
return lines.join('\n');
|
|
418
|
+
}
|
|
419
|
+
exports.renderEnvFile = renderEnvFile;
|
|
420
|
+
/** Parses a dotenv-style file into a name/value map. Commented vars are skipped. */
|
|
421
|
+
function parseEnvFile(content) {
|
|
422
|
+
const env = {};
|
|
423
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
424
|
+
const line = rawLine.trim();
|
|
425
|
+
if (line === '' || line.startsWith('#')) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
429
|
+
if (!match) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
let value = match[2].trim();
|
|
433
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
434
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
435
|
+
value = value.slice(1, -1);
|
|
436
|
+
}
|
|
437
|
+
env[match[1]] = value;
|
|
438
|
+
}
|
|
439
|
+
return env;
|
|
440
|
+
}
|
|
441
|
+
exports.parseEnvFile = parseEnvFile;
|
|
442
|
+
function isHttpUrl(value) {
|
|
443
|
+
try {
|
|
444
|
+
const url = new URL(value);
|
|
445
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Rebuilds the wizard choices implied by an env map, so the catalog's
|
|
453
|
+
* applicability rules can run against an existing file.
|
|
454
|
+
*/
|
|
455
|
+
function inferChoicesFromEnv(env) {
|
|
456
|
+
const storageMode = env.STORAGE_MODE ?? '';
|
|
457
|
+
const storage = storageMode === 'gcs'
|
|
458
|
+
? 'gcs'
|
|
459
|
+
: storageMode === 'azure'
|
|
460
|
+
? 'azure'
|
|
461
|
+
: env.AWS_BASE_ENDPOINT
|
|
462
|
+
? 's3-compatible'
|
|
463
|
+
: 'aws-s3';
|
|
464
|
+
const delivery = env.CLOUDFRONT_DOMAIN || env.CLOUDFRONT_KEY_PAIR_ID
|
|
465
|
+
? 'cloudfront'
|
|
466
|
+
: env.CDN_BASE_URL
|
|
467
|
+
? 'generic-cdn'
|
|
468
|
+
: env.DISABLE_S3_DIRECT_CDN === 'true'
|
|
469
|
+
? 'through-server'
|
|
470
|
+
: 'presigned';
|
|
471
|
+
const masterKeySource = env.AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID
|
|
472
|
+
? 'aws-secrets-manager'
|
|
473
|
+
: 'environment';
|
|
474
|
+
const cacheMode = (env.CACHE_MODE ?? 'local');
|
|
475
|
+
return {
|
|
476
|
+
baseUrl: env.BASE_URL,
|
|
477
|
+
jwtSecret: env.JWT_SECRET,
|
|
478
|
+
dbUrl: env.DB_URL,
|
|
479
|
+
masterKeySource,
|
|
480
|
+
masterKey: env.DB_KEYS_MASTER_KEY_B64,
|
|
481
|
+
masterKeySecretId: env.AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID,
|
|
482
|
+
awsAuth: env.AWS_ACCESS_KEY_ID ? 'access-keys' : 'iam-role',
|
|
483
|
+
storage,
|
|
484
|
+
forcePathStyle: env.AWS_S3_FORCE_PATH_STYLE === 'true',
|
|
485
|
+
delivery,
|
|
486
|
+
replicas: 'single',
|
|
487
|
+
cacheMode,
|
|
488
|
+
dashboard: env.USE_DASHBOARD === 'true',
|
|
489
|
+
adminEmail: env.ADMIN_EMAIL,
|
|
490
|
+
adminPassword: env.ADMIN_PASSWORD,
|
|
491
|
+
deployment: 'docker',
|
|
492
|
+
observe: !!env.CLICKHOUSE_URL,
|
|
493
|
+
geoip: env.TRUST_GEOIP_HEADERS === 'true' || !!env.MAXMIND_ACCOUNT_ID || !!env.MAXMIND_LICENSE_KEY,
|
|
494
|
+
// Headers win over MaxMind server-side; the inference mirrors that.
|
|
495
|
+
geoipStrategy: env.TRUST_GEOIP_HEADERS === 'true'
|
|
496
|
+
? 'proxy-headers'
|
|
497
|
+
: env.MAXMIND_ACCOUNT_ID || env.MAXMIND_LICENSE_KEY
|
|
498
|
+
? 'maxmind'
|
|
499
|
+
: undefined,
|
|
500
|
+
maxmindAccountId: env.MAXMIND_ACCOUNT_ID,
|
|
501
|
+
maxmindLicenseKey: env.MAXMIND_LICENSE_KEY,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
exports.inferChoicesFromEnv = inferChoicesFromEnv;
|
|
505
|
+
const VALID_STORAGE_MODES = ['s3', 'gcs', 'azure', 'local'];
|
|
506
|
+
const VALID_CACHE_MODES = ['redis', 'redis-sentinel', 'local'];
|
|
507
|
+
/**
|
|
508
|
+
* Validates an env map against the catalog: every applicable required var is
|
|
509
|
+
* present, no placeholder is left, and the server's own consistency rules
|
|
510
|
+
* hold. Local storage mode is accepted (it has working defaults) even though
|
|
511
|
+
* the wizard never generates it.
|
|
512
|
+
*/
|
|
513
|
+
function validateEnvMap(env, overrides) {
|
|
514
|
+
const issues = [];
|
|
515
|
+
const error = (message) => {
|
|
516
|
+
issues.push({ level: 'error', message });
|
|
517
|
+
};
|
|
518
|
+
const warning = (message) => {
|
|
519
|
+
issues.push({ level: 'warning', message });
|
|
520
|
+
};
|
|
521
|
+
if (!env.DB_URL) {
|
|
522
|
+
error('DB_URL is not set. server:validate checks control-plane configurations; the server needs a PostgreSQL connection string to run as a control plane.');
|
|
523
|
+
}
|
|
524
|
+
else if (isPlaceholder(env.DB_URL)) {
|
|
525
|
+
error(`DB_URL is still a placeholder: ${env.DB_URL}`);
|
|
526
|
+
}
|
|
527
|
+
const storageMode = env.STORAGE_MODE ?? '';
|
|
528
|
+
if (!VALID_STORAGE_MODES.includes(storageMode)) {
|
|
529
|
+
error(`STORAGE_MODE must be one of ${VALID_STORAGE_MODES.join(', ')} (got "${storageMode || 'nothing'}").`);
|
|
530
|
+
}
|
|
531
|
+
if (env.CACHE_MODE && !VALID_CACHE_MODES.includes(env.CACHE_MODE)) {
|
|
532
|
+
error(`CACHE_MODE must be one of ${VALID_CACHE_MODES.join(', ')} (got "${env.CACHE_MODE}").`);
|
|
533
|
+
}
|
|
534
|
+
const choices = { ...inferChoicesFromEnv(env), ...overrides };
|
|
535
|
+
if (choices.dashboard === false) {
|
|
536
|
+
warning('USE_DASHBOARD is not "true": a control plane without the dashboard cannot manage apps, keys or channels.');
|
|
537
|
+
}
|
|
538
|
+
const skipStorageVars = storageMode === 'local';
|
|
539
|
+
for (const spec of applicableVars(choices)) {
|
|
540
|
+
if (spec.name === 'DB_URL' || spec.name === 'STORAGE_MODE') {
|
|
541
|
+
continue; // Reported above with a fuller message.
|
|
542
|
+
}
|
|
543
|
+
const storageVar = spec.name.startsWith('S3_') ||
|
|
544
|
+
spec.name.startsWith('AWS_') ||
|
|
545
|
+
spec.name.startsWith('GCS_') ||
|
|
546
|
+
spec.name.startsWith('AZURE_') ||
|
|
547
|
+
spec.name === 'GOOGLE_APPLICATION_CREDENTIALS_B64';
|
|
548
|
+
if (skipStorageVars && storageVar) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const value = env[spec.name];
|
|
552
|
+
const report = spec.required && !spec.lenient ? error : warning;
|
|
553
|
+
if (spec.required && (value === undefined || value === '')) {
|
|
554
|
+
report(`${spec.name} is required${spec.comment ? ` (${spec.comment})` : ''}.`);
|
|
555
|
+
}
|
|
556
|
+
else if (value !== undefined && isPlaceholder(value)) {
|
|
557
|
+
report(`${spec.name} is still a placeholder: ${value}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (env.DB_KEYS_MASTER_KEY_B64 && env.AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID) {
|
|
561
|
+
error('Both DB_KEYS_MASTER_KEY_B64 and AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID are set; the server requires exactly one master key source.');
|
|
562
|
+
}
|
|
563
|
+
if (!!env.MAXMIND_ACCOUNT_ID !== !!env.MAXMIND_LICENSE_KEY) {
|
|
564
|
+
error('MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY must be set together; the server refuses to start with only one of them.');
|
|
565
|
+
}
|
|
566
|
+
if (env.BASE_URL && !isPlaceholder(env.BASE_URL) && !isHttpUrl(env.BASE_URL)) {
|
|
567
|
+
error(`BASE_URL must be a valid http(s) URL (got "${env.BASE_URL}").`);
|
|
568
|
+
}
|
|
569
|
+
if (env.ADMIN_EMAIL &&
|
|
570
|
+
!isPlaceholder(env.ADMIN_EMAIL) &&
|
|
571
|
+
!/^\S+@\S+\.\S+$/.test(env.ADMIN_EMAIL)) {
|
|
572
|
+
error(`ADMIN_EMAIL does not look like an email address (got "${env.ADMIN_EMAIL}").`);
|
|
573
|
+
}
|
|
574
|
+
if (env.ADMIN_PASSWORD && !isPlaceholder(env.ADMIN_PASSWORD)) {
|
|
575
|
+
const missing = (0, passwordPolicy_1.missingPasswordRules)(env.ADMIN_PASSWORD);
|
|
576
|
+
if (missing.length > 0) {
|
|
577
|
+
error(`ADMIN_PASSWORD does not meet the dashboard password policy, it needs ${missing.join(', ')}. The first boot fails otherwise.`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return issues;
|
|
581
|
+
}
|
|
582
|
+
exports.validateEnvMap = validateEnvMap;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ServerChoices } from './choices';
|
|
2
|
+
import { type ValidationIssue } from './envCatalog';
|
|
3
|
+
export declare const HELM_SECRET_NAME = "xprem-secrets";
|
|
4
|
+
export declare const HELM_SECRETS_FILE = "secrets.yaml";
|
|
5
|
+
/** Renders the values.yaml content: toggles and structure, no secret values. */
|
|
6
|
+
export declare function renderHelmValues(choices: ServerChoices): string;
|
|
7
|
+
/**
|
|
8
|
+
* Renders the secrets.yaml content: a values overlay whose secretEnv map the
|
|
9
|
+
* chart turns into the secretName Secret. Every non-computed value lands
|
|
10
|
+
* here; optional vars stay commented out.
|
|
11
|
+
*/
|
|
12
|
+
export declare function renderHelmSecretsValues(choices: ServerChoices): string;
|
|
13
|
+
/** The secretEnv map of a values document, when it carries one. */
|
|
14
|
+
export declare function extractSecretEnv(doc: Record<string, unknown>): Record<string, string> | undefined;
|
|
15
|
+
export declare function parseYamlFile(content: string): Record<string, unknown>;
|
|
16
|
+
/** True for a YAML mapping that plausibly is a values file for the chart. */
|
|
17
|
+
export declare function looksLikeChartValues(doc: Record<string, unknown>): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Validates a values/secret-env pair. Either side can be missing: the checks
|
|
20
|
+
* that need the other half are skipped and replaced with an issue saying so.
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateHelmPair(values: Record<string, unknown> | undefined, secretEnv: Record<string, string> | undefined): ValidationIssue[];
|