@volter/world-runtime 3.0.162 → 3.0.164
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/dist/src/ancestry.js +2 -2
- package/dist/src/app-url.d.ts +5 -1
- package/dist/src/app-url.js +14 -1
- package/dist/src/branch.js +28 -8
- package/dist/src/browser-files.d.ts +5 -0
- package/dist/src/browser-files.js +14 -0
- package/dist/src/browsers.d.ts +2 -3
- package/dist/src/browsers.js +4 -4
- package/dist/src/catalog.d.ts +2 -1
- package/dist/src/catalog.js +3 -2
- package/dist/src/changeset.js +1 -1
- package/dist/src/cli-commands.js +71 -18
- package/dist/src/cli-load-clock.d.ts +1 -0
- package/dist/src/cli-load-clock.js +2 -0
- package/dist/src/cli.js +5 -1
- package/dist/src/clickhouse-backing.d.ts +2 -0
- package/dist/src/clickhouse-backing.js +145 -0
- package/dist/src/command-lifetime.d.mts +1 -1
- package/dist/src/command-lifetime.mjs +5 -2
- package/dist/src/configs.js +1 -1
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +4 -2
- package/dist/src/covers.js +8 -4
- package/dist/src/go-mysql-binding.d.ts +19 -0
- package/dist/src/go-mysql-binding.js +51 -0
- package/dist/src/guest-processes.d.mts +1 -1
- package/dist/src/guest-processes.mjs +22 -14
- package/dist/src/host-cli.js +8 -1
- package/dist/src/host-ready.d.ts +16 -2
- package/dist/src/host-ready.js +66 -39
- package/dist/src/host-worker.js +7 -1
- package/dist/src/host.d.ts +26 -0
- package/dist/src/host.js +93 -3
- package/dist/src/infra-definition.d.ts +35 -0
- package/dist/src/infra-definition.js +146 -0
- package/dist/src/infra.d.ts +3 -0
- package/dist/src/infra.js +375 -105
- package/dist/src/init.d.ts +46 -0
- package/dist/src/init.js +445 -18
- package/dist/src/lifecycle-record.d.ts +5 -1
- package/dist/src/lifecycle-record.js +27 -4
- package/dist/src/local-branches.js +1 -1
- package/dist/src/mariadb-bootstrap.d.ts +32 -0
- package/dist/src/mariadb-bootstrap.js +90 -0
- package/dist/src/mariadb-supplier.d.ts +23 -0
- package/dist/src/mariadb-supplier.js +215 -0
- package/dist/src/mongodb-backing.d.ts +4 -0
- package/dist/src/mongodb-backing.js +71 -1
- package/dist/src/native-mariadb-backing.d.ts +13 -0
- package/dist/src/native-mariadb-backing.js +512 -0
- package/dist/src/native-postgres-backing.d.ts +4 -10
- package/dist/src/native-postgres-backing.js +428 -43
- package/dist/src/native-server-inputs.d.ts +16 -0
- package/dist/src/native-server-inputs.js +204 -0
- package/dist/src/origin.js +3 -3
- package/dist/src/original-inputs.d.ts +14 -0
- package/dist/src/original-inputs.js +84 -0
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pglite-backing.d.ts +41 -17
- package/dist/src/pglite-backing.js +213 -79
- package/dist/src/pglite-host.mjs +482 -10
- package/dist/src/process-groups.d.mts +6 -1
- package/dist/src/process-groups.d.ts +2 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/process-groups.mjs +82 -15
- package/dist/src/process-signal.d.mts +2 -2
- package/dist/src/process-signal.mjs +29 -9
- package/dist/src/project-inspect.d.ts +12 -0
- package/dist/src/project-inspect.js +41 -4
- package/dist/src/proxy-env.d.ts +34 -0
- package/dist/src/proxy-env.js +114 -0
- package/dist/src/redirect-proxy.d.ts +1 -31
- package/dist/src/redirect-proxy.js +3 -112
- package/dist/src/redis-backing.d.ts +6 -4
- package/dist/src/redis-backing.js +20 -168
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +503 -218
- package/dist/src/schema.d.ts +1 -0
- package/dist/src/schema.js +5 -3
- package/dist/src/served-world.js +15 -10
- package/dist/src/service-exit.d.ts +3 -2
- package/dist/src/service-exit.js +7 -5
- package/dist/src/twin-backing.d.ts +47 -0
- package/dist/src/twin-backing.js +343 -0
- package/dist/src/up-cli.js +56 -3
- package/package.json +4 -4
- package/src/ancestry.ts +2 -2
- package/src/app-url.ts +9 -2
- package/src/branch.ts +26 -8
- package/src/browser-files.ts +15 -0
- package/src/browsers.ts +4 -4
- package/src/catalog.ts +4 -3
- package/src/changeset.ts +1 -1
- package/src/cli-commands.ts +85 -18
- package/src/cli-load-clock.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/clickhouse-backing.ts +70 -0
- package/src/command-lifetime.d.mts +1 -1
- package/src/command-lifetime.mjs +5 -2
- package/src/configs.ts +1 -1
- package/src/consumers.ts +1 -1
- package/src/covers.ts +9 -4
- package/src/go-mysql-binding.ts +49 -0
- package/src/guest-processes.d.mts +1 -1
- package/src/guest-processes.mjs +22 -14
- package/src/host-cli.ts +7 -1
- package/src/host-ready.ts +62 -31
- package/src/host-worker.ts +6 -2
- package/src/host.ts +88 -6
- package/src/infra-definition.ts +135 -0
- package/src/infra.ts +250 -45
- package/src/init.ts +416 -17
- package/src/lifecycle-record.ts +18 -4
- package/src/local-branches.ts +1 -1
- package/src/mariadb-bootstrap.ts +107 -0
- package/src/mariadb-supplier.ts +199 -0
- package/src/mongodb-backing.ts +59 -1
- package/src/native-mariadb-backing.ts +418 -0
- package/src/native-postgres-backing.ts +353 -41
- package/src/native-server-inputs.ts +169 -0
- package/src/origin.ts +3 -3
- package/src/original-inputs.ts +72 -0
- package/src/pack-facts.ts +1 -1
- package/src/pglite-backing.ts +192 -80
- package/src/pglite-host.mjs +482 -10
- package/src/process-groups.d.mts +6 -1
- package/src/process-groups.mjs +82 -15
- package/src/process-groups.ts +2 -1
- package/src/process-signal.d.mts +2 -2
- package/src/process-signal.mjs +29 -9
- package/src/project-inspect.ts +37 -4
- package/src/proxy-env.ts +128 -0
- package/src/redirect-proxy.ts +3 -125
- package/src/redis-backing.ts +19 -136
- package/src/runtime.ts +342 -107
- package/src/schema.ts +5 -4
- package/src/served-world.ts +14 -10
- package/src/service-exit.ts +5 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
package/src/init.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { copyNativeServerInputs, readNativeServerInputs, nativeServerRuntimeReference, type NativeServerInputs } from './native-server-inputs.ts';
|
|
2
|
+
import { goMysqlBinding, renderGoMysqlBinding, type GoMysqlBinding } from './go-mysql-binding.ts';
|
|
3
|
+
import * as managedOriginalInputs from '@volter/world-core/managed-original-inputs';
|
|
4
|
+
import { copyOriginalInputSnapshot, readOriginalInputSnapshot, type OriginalInputReference } from './original-inputs.ts';
|
|
1
5
|
// `volter-world init <name> --repo <path>` — the DETERMINISTIC FRONT DOOR of world adoption.
|
|
2
6
|
//
|
|
3
7
|
// Everything it needs already existed, in three pieces that no one command composed:
|
|
@@ -33,6 +37,7 @@
|
|
|
33
37
|
// with a remediation line each. App-boot risks are a separate, prominent signal: an empty
|
|
34
38
|
// example value read by production source can block before listen even when vendor coverage is
|
|
35
39
|
// green. `init` never boots the world: it diagnoses, the operator (or the room) boots.
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
36
41
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
42
|
import { PROTOCOL_MAJOR, stateDirName } from '@volter/world-core/runtime';
|
|
38
43
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
@@ -51,7 +56,7 @@ import {
|
|
|
51
56
|
import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
|
|
52
57
|
import { findInstalledPackage, packCli, packEntry, resolveCatalog, type Catalog } from './catalog.ts';
|
|
53
58
|
import { CATALOG_INDEX_PACKAGE, defaults as catalogDefaults } from './catalog-index.ts';
|
|
54
|
-
import { projectEnvReads, projectManifestDirs, projectMigrationTools, type ProjectMigrationTool } from './project-inspect.ts';
|
|
59
|
+
import { filesLeftAsThePersonsOwn, projectEnvReads, projectManifestDirs, projectMigrationTools, readOnlyWhatTheSourceHolds, sourceText, type ProjectMigrationTool } from './project-inspect.ts';
|
|
55
60
|
import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
|
|
56
61
|
import { VENDOR_SERVER_BACKINGS } from './backings.ts';
|
|
57
62
|
import { APP_URL_ENV, DEFAULT_WORLD_SELECTION, ISSUE_ENV_PREFIX, MINT_ENV, worldConfigDocument, worldSelectionIncludes, type WorldConfig, type WorldSelection, type WorldServiceConfig } from './schema.ts';
|
|
@@ -173,9 +178,20 @@ export type InitInfraStub = {
|
|
|
173
178
|
export type InitComposeService = {
|
|
174
179
|
kind: string;
|
|
175
180
|
image: string;
|
|
181
|
+
/** An explicit MongoDB connection mode, also carried to the selected backing. */
|
|
182
|
+
replicaSet?: string;
|
|
176
183
|
/** deterministic host port, derived from the world name (see composePort) */
|
|
177
184
|
hostPort: number;
|
|
178
185
|
containerPort: number;
|
|
186
|
+
virtualTcpEndpoint?: { version: 1; host: string; port: number; targetPort: number };
|
|
187
|
+
connectionBindings?: Record<string, GoMysqlBinding & { containerPort: number; query?: string }>;
|
|
188
|
+
sides?: Record<string, { hostPort: number; containerPort: number }>;
|
|
189
|
+
configuration?: Record<string, unknown>;
|
|
190
|
+
originalInputs?: OriginalInputReference;
|
|
191
|
+
originalInputsFrom?: string;
|
|
192
|
+
nativeServerInputs?: NativeServerInputs;
|
|
193
|
+
nativeServerInputsFrom?: string;
|
|
194
|
+
nativeCredentialAliases?: string[];
|
|
179
195
|
memoryMiB: number;
|
|
180
196
|
/** compose volume key, or null (the on-disk docker volume is `<project>_<key>` — the compose
|
|
181
197
|
* project name carries the world name, so volumes are named per world) */
|
|
@@ -208,6 +224,12 @@ export type InitPlan = {
|
|
|
208
224
|
* (runtime.database.prepare): reported, never written, and no part of the vendor-coverage verdict */
|
|
209
225
|
undeclaredMigrations: ProjectMigrationTool[];
|
|
210
226
|
infra: InitInfraStub[];
|
|
227
|
+
/** each service of the project's own stack and what was made of it, where `--needs` gave the stack */
|
|
228
|
+
stackNeeds?: InitStackNeed[];
|
|
229
|
+
/** The same declared application names used by selection and its coverage proof. */
|
|
230
|
+
stackEnvironmentNames?: string[];
|
|
231
|
+
/** the project files a World made for an image left unread: the source does not hold them, so they are the person's own */
|
|
232
|
+
leftAsThePersonsOwn?: string[];
|
|
211
233
|
/** the services emitted into the private managed-infrastructure definition */
|
|
212
234
|
compose: InitComposeService[];
|
|
213
235
|
/** private managed-infrastructure definition, or null when no detected infra has a recipe */
|
|
@@ -271,8 +293,104 @@ export type InitOptions = {
|
|
|
271
293
|
sources?: Record<string, string>;
|
|
272
294
|
/** A world with no app: served as `/<org>/<world>/`. */
|
|
273
295
|
bare?: string;
|
|
296
|
+
/** The project's own stack as its image builder read it (`needs.json`): architecture, "The project's own stack as evidence". */
|
|
297
|
+
needs?: string;
|
|
274
298
|
};
|
|
275
299
|
|
|
300
|
+
/** One service of the project's stack, as `init` took it. */
|
|
301
|
+
export type InitStackNeed = {
|
|
302
|
+
service: string;
|
|
303
|
+
image?: string;
|
|
304
|
+
family?: string;
|
|
305
|
+
/** the managed kind it is, or null where the World has none for it */
|
|
306
|
+
kind: string | null;
|
|
307
|
+
/** the application's variables given this service's address */
|
|
308
|
+
names: string[];
|
|
309
|
+
/** true where the stack named no variable and the conventional names were supplied */
|
|
310
|
+
conventional: boolean;
|
|
311
|
+
note: string;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** The families the image builder names (its SERVICE_FAMILIES) that are a managed kind here. */
|
|
315
|
+
const FAMILY_KIND: Record<string, string> = { postgres: 'postgres', mysql: 'mysql', mongodb: 'mongodb', redis: 'redis', search: 'search', clickhouse: 'clickhouse', memcached: 'memcached' };
|
|
316
|
+
const CONVENTIONAL_NAMES: Record<string, string[]> = { postgres: ['DATABASE_URL'], mysql: ['DATABASE_URL'], mongodb: ['MONGODB_URI'], redis: ['REDIS_URL'], clickhouse: ['CLICKHOUSE_URL'] };
|
|
317
|
+
|
|
318
|
+
type StackConnectionComponent = "database" | "user" | "password";
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
type StackNeedsFile = { kind?: unknown; version?: unknown; composeFile?: unknown; application?: { service?: unknown }; settings?: unknown; unset?: unknown; needs?: Array<{ service?: unknown; image?: unknown; family?: unknown; supplier?: unknown; names?: Array<{ name?: unknown; scheme?: unknown; query?: unknown; withPort?: unknown; containerPort?: unknown; dsnFormat?: unknown; endpoint?: unknown; dsnPrefix?: unknown; dsnProtocol?: unknown }>; ports?: unknown; portTargets?: unknown; components?: unknown; originalInputs?: unknown; nativeServerInputs?: unknown }> };
|
|
322
|
+
|
|
323
|
+
/** The needs file, checked for its kind: a file of another shape is refused by name, never half-read. */
|
|
324
|
+
function readStackNeeds(path: string): { composeFile: string; application?: string; settings: Array<{ name: string; value: string }>; unset: string[]; needs: Array<{ service: string; image?: string; family?: string; names: Array<{ name: string; scheme?: string; query?: string; withPort?: true; containerPort?: number } & Partial<GoMysqlBinding>>; originalInputs?: OriginalInputReference; nativeServerInputs?: NativeServerInputs; nativeCredentialAliases?: string[]; ports: string[]; portTargets: Record<string, number>; components: Array<{ name: string; component: StackConnectionComponent }> }> } {
|
|
325
|
+
let document: StackNeedsFile;
|
|
326
|
+
try { document = JSON.parse(readFileSync(path, 'utf8')) as StackNeedsFile; }
|
|
327
|
+
catch (error) { throw new Error(`volter init: --needs ${path} could not be read as JSON: ${error instanceof Error ? error.message : String(error)}`); }
|
|
328
|
+
if (document.kind !== 'browser-substrate.stack-needs' || (document.version !== 1 && document.version !== 2 && document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Array.isArray(document.needs)) {
|
|
329
|
+
throw new Error(`volter init: --needs ${path} is not a stack-needs file (kind "browser-substrate.stack-needs", version 1, 2, 3, 4, 5, 6 or 7)`);
|
|
330
|
+
}
|
|
331
|
+
const identifier = (name: string, what: string): string => {
|
|
332
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`volter init: --needs ${path} names a variable that is not an identifier among ${what}: ${JSON.stringify(name)}`);
|
|
333
|
+
return name;
|
|
334
|
+
};
|
|
335
|
+
const record = (value: unknown): Array<[string, unknown]> => value && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value as Record<string, unknown>) : [];
|
|
336
|
+
return {
|
|
337
|
+
composeFile: typeof document.composeFile === 'string' ? document.composeFile : 'the project\'s Compose file',
|
|
338
|
+
...(typeof document.application?.service === 'string' ? { application: document.application.service } : {}),
|
|
339
|
+
// The application's own variables that name no service: its settings as the stack writes them, and those whose
|
|
340
|
+
// value the stack makes from a variable nothing sets, which are named and have no value here.
|
|
341
|
+
settings: record(document.settings).filter(([, value]) => typeof value === 'string').map(([name, value]) => ({ name: identifier(name, 'the application\'s settings'), value: value as string })),
|
|
342
|
+
unset: record(document.unset).map(([name]) => identifier(name, 'the application\'s unset variables')),
|
|
343
|
+
needs: document.needs.filter((need) => typeof need.service === 'string').map((need) => ({
|
|
344
|
+
service: need.service as string,
|
|
345
|
+
...(need.originalInputs === undefined ? {} : { originalInputs: (() => {
|
|
346
|
+
if (document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) throw new Error('volter init: original inputs require needs version4,5,6 or7');
|
|
347
|
+
const reference = managedOriginalInputs.referenceOf(need.originalInputs);
|
|
348
|
+
const source = readOriginalInputSnapshot(dirname(resolve(path)), reference).snapshot.manifest;
|
|
349
|
+
const image = source.start.service.image as { raw?: unknown } | undefined;
|
|
350
|
+
if (source.service !== need.service || source.family !== need.family || typeof need.image !== 'string' || image?.raw !== need.image) throw new Error('volter init: original inputs belong to another declared service');
|
|
351
|
+
return reference;
|
|
352
|
+
})() }),
|
|
353
|
+
...(need.nativeServerInputs === undefined ? {} : (() => {
|
|
354
|
+
if (document.version !== 5 && document.version !== 7) throw new Error('volter init: native managed input requires needs5 or7');
|
|
355
|
+
const checked = readNativeServerInputs(dirname(resolve(path)), need.nativeServerInputs);
|
|
356
|
+
const source = JSON.parse(checked.files.get('declaration.json')!.toString('utf8')) as { name?: unknown; image?: { raw?: unknown } };
|
|
357
|
+
if (need.family !== 'mysql' || need.supplier !== 'a World' || source.name !== need.service || source.image?.raw !== need.image)
|
|
358
|
+
throw new Error('volter init: native managed source belongs to another declared service');
|
|
359
|
+
return { nativeServerInputs: checked.reference, nativeCredentialAliases: checked.aliases };
|
|
360
|
+
})()),
|
|
361
|
+
...(typeof need.image === 'string' ? { image: need.image } : {}),
|
|
362
|
+
...(typeof need.family === 'string' ? { family: need.family } : {}),
|
|
363
|
+
names: (Array.isArray(need.names) ? need.names : []).map((entry) => {
|
|
364
|
+
// A name becomes a key of the World's environment file: an identifier, or it is refused by name. Anything
|
|
365
|
+
// else (a newline, an `=`) would write lines of its own there.
|
|
366
|
+
if (typeof entry.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.name)) throw new Error(`volter init: --needs ${path} names a variable that is not an identifier under service ${String(need.service)}: ${JSON.stringify(entry.name)}`);
|
|
367
|
+
if (entry.containerPort !== undefined && ((document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535)) throw new Error(`volter init: --needs ${path} needs version3 and a valid declared vendor port`);
|
|
368
|
+
return { name: entry.name, ...goMysqlBinding(entry, document.version, entry.name, COMPOSE_INFRA.mysql!.containerPort), ...(entry.containerPort !== undefined ? { containerPort: Number(entry.containerPort) } : {}), ...(typeof entry.scheme === 'string' && /^[a-z][a-z0-9+.-]*$/i.test(entry.scheme) ? { scheme: entry.scheme } : {}), ...(typeof entry.query === 'string' ? { query: entry.query } : {}), ...(entry.withPort === true ? { withPort: true as const } : {}) };
|
|
369
|
+
}),
|
|
370
|
+
components: (() => {
|
|
371
|
+
if (need.components === undefined) return [];
|
|
372
|
+
if ((document.version !== 2 && document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Array.isArray(need.components)) throw new Error(`volter init: --needs ${path} requires version 2,3,4,5,6 or7 for store components`);
|
|
373
|
+
return need.components.map((part: unknown) => {
|
|
374
|
+
if (!part || typeof part !== 'object' || Array.isArray(part)) throw new Error(`volter init: --needs ${path} has an invalid store component`);
|
|
375
|
+
const field = part as { name?: unknown; component?: unknown };
|
|
376
|
+
if (typeof field.name !== 'string' || !['database', 'user', 'password'].includes(String(field.component))) throw new Error(`volter init: --needs ${path} has an invalid store component`);
|
|
377
|
+
return { name: identifier(field.name, `the components of service ${String(need.service)}`), component: field.component as StackConnectionComponent };
|
|
378
|
+
});
|
|
379
|
+
})(),
|
|
380
|
+
// The variables that hold the service's port beside one that holds its host: keys of the environment file too.
|
|
381
|
+
ports: (Array.isArray(need.ports) ? need.ports as unknown[] : []).map((name) => identifier(String(name), `the port variables of service ${String(need.service)}`)),
|
|
382
|
+
portTargets: (() => {
|
|
383
|
+
if (need.portTargets === undefined) return {};
|
|
384
|
+
if ((document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !need.portTargets || typeof need.portTargets !== 'object' || Array.isArray(need.portTargets)) throw new Error(`volter init: --needs ${path} needs version3 for named vendor ports`);
|
|
385
|
+
return Object.fromEntries(Object.entries(need.portTargets).map(([name, value]) => {
|
|
386
|
+
if (!Array.isArray(need.ports) || !need.ports.includes(name) || !Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error(`volter init: --needs ${path} has an invalid named vendor port`);
|
|
387
|
+
return [identifier(name, 'named vendor ports'), Number(value)];
|
|
388
|
+
}));
|
|
389
|
+
})(),
|
|
390
|
+
})),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
276
394
|
// ---------------------------------------------------------------------------------------------
|
|
277
395
|
// The pack catalog
|
|
278
396
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -485,6 +603,14 @@ const COMPOSE_INFRA: Record<string, {
|
|
|
485
603
|
/** healthcheck exec-form argv (makes `docker compose up -d --wait` meaningful) */
|
|
486
604
|
healthcheck: (user: string) => string[];
|
|
487
605
|
}> = {
|
|
606
|
+
clickhouse: {
|
|
607
|
+
image: 'clickhouse/clickhouse-server:26.9.13.15',
|
|
608
|
+
containerPort: 8123,
|
|
609
|
+
// The existing managed-database Docker default; a backend limit, not measured adequacy.
|
|
610
|
+
memoryMiB: 1024,
|
|
611
|
+
volumePath: '/var/lib/clickhouse',
|
|
612
|
+
healthcheck: () => ['CMD', 'wget', '--quiet', '--output-document=-', 'http://127.0.0.1:8123/ping'],
|
|
613
|
+
},
|
|
488
614
|
postgres: {
|
|
489
615
|
image: 'postgres:16',
|
|
490
616
|
containerPort: 5432,
|
|
@@ -506,8 +632,8 @@ const COMPOSE_INFRA: Record<string, {
|
|
|
506
632
|
volumePath: '/data',
|
|
507
633
|
healthcheck: () => ['CMD', 'redis-cli', 'ping'],
|
|
508
634
|
},
|
|
509
|
-
// The
|
|
510
|
-
//
|
|
635
|
+
// The installed declaring twin, or MongoDB's own server, owns this kind's backing.
|
|
636
|
+
// A replica-set name is metadata passed to that backing, never a choice of executable.
|
|
511
637
|
mongodb: {
|
|
512
638
|
image: 'mongo:7',
|
|
513
639
|
containerPort: 27017,
|
|
@@ -564,11 +690,14 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
|
|
|
564
690
|
// redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
|
|
565
691
|
const url = kind === 'redis'
|
|
566
692
|
? `redis://127.0.0.1:${hostPort}`
|
|
567
|
-
: kind === '
|
|
693
|
+
: kind === 'clickhouse'
|
|
694
|
+
? `http://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`
|
|
695
|
+
: kind === 'mongodb'
|
|
568
696
|
? `mongodb://127.0.0.1:${hostPort}/${ident}`
|
|
569
697
|
: `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
|
|
570
698
|
return {
|
|
571
699
|
kind,
|
|
700
|
+
...(kind === 'clickhouse' ? { sides: { native: { hostPort: composePort(name, 'clickhouse-native', taken), containerPort: 9000 } }, configuration: { users: { default: '', [ident]: ident }, databases: [ident] } } : {}),
|
|
572
701
|
image: recipe.image,
|
|
573
702
|
hostPort,
|
|
574
703
|
containerPort: recipe.containerPort,
|
|
@@ -585,12 +714,20 @@ function composeEnvironment(name: string, kind: string): Array<[string, string]>
|
|
|
585
714
|
if (kind === 'postgres') {
|
|
586
715
|
return [['POSTGRES_USER', ident], ['POSTGRES_PASSWORD', ident], ['POSTGRES_DB', ident]];
|
|
587
716
|
}
|
|
717
|
+
if (kind === 'clickhouse') return [['CLICKHOUSE_DB', ident], ['CLICKHOUSE_USER', ident], ['CLICKHOUSE_PASSWORD', ident], ['CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT', '1']];
|
|
588
718
|
if (kind === 'mysql') {
|
|
589
719
|
return [['MYSQL_ROOT_PASSWORD', ident], ['MYSQL_DATABASE', ident], ['MYSQL_USER', composeUser(name, kind)], ['MYSQL_PASSWORD', ident]];
|
|
590
720
|
}
|
|
591
721
|
return [];
|
|
592
722
|
}
|
|
593
723
|
|
|
724
|
+
function managedPort(service: InitComposeService, containerPort?: number): number {
|
|
725
|
+
if (containerPort === undefined || containerPort === service.containerPort) return service.hostPort;
|
|
726
|
+
const side = Object.values(service.sides ?? {}).find(port => port.containerPort === containerPort);
|
|
727
|
+
if (!side) throw new Error(`volter init: managed ${service.kind} does not serve vendor port ${containerPort}`);
|
|
728
|
+
return side.hostPort;
|
|
729
|
+
}
|
|
730
|
+
|
|
594
731
|
/** Render the private managed-infrastructure definition — deterministic, with no absolute paths.
|
|
595
732
|
* Only meaningful when `plan.compose` is non-empty (writeWorldInit skips it otherwise). */
|
|
596
733
|
export function renderComposeFile(plan: InitPlan): string {
|
|
@@ -604,13 +741,42 @@ export function renderComposeFile(plan: InitPlan): string {
|
|
|
604
741
|
for (const service of plan.compose) {
|
|
605
742
|
const recipe = COMPOSE_INFRA[service.kind]!;
|
|
606
743
|
lines.push(` ${service.kind}:`);
|
|
607
|
-
lines.push(` image: ${service.image}`);
|
|
744
|
+
lines.push(` image: ${JSON.stringify(service.image)}`);
|
|
745
|
+
if (service.virtualTcpEndpoint) lines.push(` x-volter-virtual-tcp-endpoint: ${JSON.stringify(service.virtualTcpEndpoint)}`);
|
|
746
|
+
if (service.replicaSet !== undefined) lines.push(` x-volter-replica-set: ${JSON.stringify(service.replicaSet)}`);
|
|
747
|
+
if (service.nativeServerInputs) {
|
|
748
|
+
lines.push(' x-volter-interfaces: 3');
|
|
749
|
+
lines.push(` x-volter-native-server: ${JSON.stringify(nativeServerRuntimeReference(service.nativeServerInputs))}`);
|
|
750
|
+
}
|
|
751
|
+
if (service.originalInputs) {
|
|
752
|
+
if (!service.sides) lines.push(' x-volter-interfaces: 2');
|
|
753
|
+
lines.push(` x-volter-original-inputs: ${JSON.stringify({ ...service.originalInputs, root: `original-inputs/${service.originalInputs.sha256}` })}`);
|
|
754
|
+
}
|
|
608
755
|
lines.push(` mem_limit: ${service.memoryMiB}m`);
|
|
609
756
|
lines.push(` memswap_limit: ${service.memoryMiB}m`);
|
|
610
757
|
lines.push(' cpus: 1');
|
|
758
|
+
if (service.connectionBindings) {
|
|
759
|
+
lines.push(' x-volter-issued-dsn-bindings:');
|
|
760
|
+
for (const [name, binding] of Object.entries(service.connectionBindings)) {
|
|
761
|
+
lines.push(` ${name}:`);
|
|
762
|
+
for (const [field, value] of Object.entries(binding)) lines.push(` ${field}: ${JSON.stringify(value)}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
611
765
|
lines.push(' ports:');
|
|
612
766
|
lines.push(` - "127.0.0.1:${service.hostPort}:${service.containerPort}"`);
|
|
767
|
+
for (const side of Object.values(service.sides ?? {})) lines.push(` - "127.0.0.1:${side.hostPort}:${side.containerPort}"`);
|
|
768
|
+
if (service.sides) {
|
|
769
|
+
lines.push(` x-volter-interfaces: ${service.originalInputs ? 2 : 1}`);
|
|
770
|
+
lines.push(` x-volter-side-ports: ${JSON.stringify(service.sides)}`);
|
|
771
|
+
lines.push(` x-volter-configuration: ${JSON.stringify(service.configuration)}`);
|
|
772
|
+
}
|
|
613
773
|
const environment = composeEnvironment(plan.name, service.kind);
|
|
774
|
+
if (service.nativeServerInputs) for (const alias of service.nativeCredentialAliases ?? []) {
|
|
775
|
+
const canonical = alias.replace(/^MARIADB_/, 'MYSQL_');
|
|
776
|
+
const entry = environment.find(([name]) => name === canonical);
|
|
777
|
+
if (!entry) throw new Error('volter init: native provider credential alias has no issued field');
|
|
778
|
+
environment.push([alias, entry[1]]);
|
|
779
|
+
}
|
|
614
780
|
if (environment.length > 0) {
|
|
615
781
|
lines.push(' environment:');
|
|
616
782
|
for (const [key, value] of environment) lines.push(` ${key}: ${value}`);
|
|
@@ -668,8 +834,10 @@ function repoEnvEntries(repo: string): ParsedEnvEntry[] {
|
|
|
668
834
|
for (const file of ENV_SOURCE_FILES) {
|
|
669
835
|
const path = resolve(dir, file);
|
|
670
836
|
if (!existsSync(path)) continue;
|
|
837
|
+
const text = sourceText(path);
|
|
838
|
+
if (text === undefined) continue;
|
|
671
839
|
const source = relative(repo, path) || file;
|
|
672
|
-
for (const entry of parseEnvFile(
|
|
840
|
+
for (const entry of parseEnvFile(text, source)) {
|
|
673
841
|
if (inDir.has(entry.name)) continue;
|
|
674
842
|
inDir.add(entry.name);
|
|
675
843
|
byName.set(entry.name, [...(byName.get(entry.name) ?? []), { ...entry, dir }]);
|
|
@@ -707,7 +875,11 @@ function isInside(parent: string, candidate: string): boolean {
|
|
|
707
875
|
|
|
708
876
|
export function planWorldInit(name: string, repoPath: string, options: InitOptions = {}): InitPlan {
|
|
709
877
|
assertSafeName(name);
|
|
878
|
+
// The World's own name, kept under a name no loop below takes: what it issues is made from it.
|
|
879
|
+
const worldName = name;
|
|
710
880
|
const repo = resolve(repoPath);
|
|
881
|
+
// A World made from a stack's needs is made for an image: only what the project's source holds is read.
|
|
882
|
+
readOnlyWhatTheSourceHolds(options.needs ? repo : undefined);
|
|
711
883
|
if (!existsSync(repo)) throw new Error(`volter init: the application repo does not exist: ${repo}`);
|
|
712
884
|
const root = resolve(options.root ?? repo);
|
|
713
885
|
// The world lives in the app repo: `.volter/world.json` beside the code, committed like a
|
|
@@ -729,7 +901,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
729
901
|
* (portable between teammates), absolute otherwise (a pilot emitted away from its root) */
|
|
730
902
|
const carried = (path: string): string => (isInside(root, path) ? relative(root, path) : path);
|
|
731
903
|
|
|
732
|
-
const catalogSource: Catalog = resolveCatalog(root, { sources: options.sources, vendors: options.vendors });
|
|
904
|
+
const catalogSource: Catalog = resolveCatalog(root, { sources: options.sources, vendors: options.vendors, mayBeEmpty: options.needs !== undefined });
|
|
733
905
|
const catalog = new Set(catalogSource.vendors());
|
|
734
906
|
// the catalog index's defaults (the installed `@volter/twin-catalog`), which name the twin to install for a vendor
|
|
735
907
|
// this catalog lacks
|
|
@@ -738,7 +910,9 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
738
910
|
// baked into the pack). Its handlers starter is the answering turns its own life scripts (journeys/scenario.json, the
|
|
739
911
|
// World's handler format, its faults left out); its default data is src/semantics/seed.ts.
|
|
740
912
|
const defaultsCopies: InitPlan['defaultsCopies'] = [];
|
|
741
|
-
const
|
|
913
|
+
const stack = options.needs ? readStackNeeds(resolve(options.needs)) : undefined;
|
|
914
|
+
const stackEnvironmentNames = stack ? [...new Set([...stack.settings.map(entry => entry.name), ...stack.unset])].sort() : [];
|
|
915
|
+
const signals = options.vendors ? null : detectRepoVendors(repo, existing?.services, stackEnvironmentNames);
|
|
742
916
|
const detected = options.vendors
|
|
743
917
|
? new Map<string, string[]>(options.vendors.map((v) => [v, ['--twins']]))
|
|
744
918
|
: new Map([...signals!.detected].filter(([vendor]) => (signals!.uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
|
|
@@ -889,7 +1063,31 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
889
1063
|
const envReads = projectEnvReads(repo);
|
|
890
1064
|
const mintedEnv: string[] = [];
|
|
891
1065
|
const infraSignals = new Map<string, string[]>();
|
|
892
|
-
const infraEntries: Array<{ name: string; source: string; kind: string }
|
|
1066
|
+
const infraEntries: Array<{ name: string; source: string; kind: string; query?: string; host?: true; withPort?: true; scheme?: string; containerPort?: number } & Partial<GoMysqlBinding>> = [];
|
|
1067
|
+
// The project's own stack, where its image builder read it: each variable it wires to a service of a managed kind
|
|
1068
|
+
// is that kind's signal whatever the variable is called, and is decided here before a name's pattern is asked.
|
|
1069
|
+
const stackNames = new Map<string, { kind: string; source: string; host: boolean; withPort: boolean; query?: string; scheme?: string; containerPort?: number; service: string; conventional: boolean } & Partial<GoMysqlBinding>>();
|
|
1070
|
+
const stackNeeds: InitStackNeed[] = [];
|
|
1071
|
+
/** The names actually given each service's address, filled where an entry is written: the report counts these. */
|
|
1072
|
+
const stackWritten = new Map<string, string[]>();
|
|
1073
|
+
const kinds = (stack?.needs ?? []).map((need) => {
|
|
1074
|
+
const fromScheme = need.names.map((entry) => entry.scheme === undefined ? null : schemeKindFor(`${entry.scheme}://`)).find((kind) => kind !== null) ?? null;
|
|
1075
|
+
return { need, fromScheme, kind: fromScheme ?? (need.family !== undefined ? FAMILY_KIND[need.family] ?? null : null), source: `${stack!.composeFile} service ${need.service}` };
|
|
1076
|
+
});
|
|
1077
|
+
// Explicit names first, over the whole stack: a service the stack wires by name keeps its names whatever is
|
|
1078
|
+
// listed before it. Conventional names are for what is left, and never one another service was given.
|
|
1079
|
+
for (const { need, kind, source } of kinds) {
|
|
1080
|
+
if (kind === null) continue;
|
|
1081
|
+
for (const entry of need.names) if (!stackNames.has(entry.name)) stackNames.set(entry.name, { kind, source, ...(entry.dsnFormat ? { dsnFormat: entry.dsnFormat, endpoint: entry.endpoint, dsnPrefix: entry.dsnPrefix, dsnProtocol: entry.dsnProtocol } : {}), host: entry.scheme === undefined, withPort: entry.withPort === true, ...(entry.scheme ? { scheme: entry.scheme } : {}), ...(entry.containerPort !== undefined ? { containerPort: entry.containerPort } : {}), ...(entry.query !== undefined ? { query: entry.query } : {}), service: need.service, conventional: false });
|
|
1082
|
+
}
|
|
1083
|
+
for (const { need, kind, source } of kinds) {
|
|
1084
|
+
if (kind === null || need.names.length > 0) continue;
|
|
1085
|
+
for (const name of CONVENTIONAL_NAMES[kind] ?? []) if (!stackNames.has(name)) stackNames.set(name, { kind, source, host: false, withPort: false, service: need.service, conventional: true });
|
|
1086
|
+
}
|
|
1087
|
+
const writeStackName = (name: string): void => {
|
|
1088
|
+
const wired = stackNames.get(name)!;
|
|
1089
|
+
stackWritten.set(wired.service, [...(stackWritten.get(wired.service) ?? []), name]);
|
|
1090
|
+
};
|
|
893
1091
|
// SMTP's host and port under the names this app declares: apps name them their own way (twenty's EMAIL_SMTP_HOST/
|
|
894
1092
|
// EMAIL_SMTP_PORT beside nodemailer's SMTP_HOST), and a raw-TCP twin is reached only through the env the app reads,
|
|
895
1093
|
// so every declared `…SMTP_HOST` / `…SMTP_PORT` points at the smtp twin (twenty lane: its mail went to the log). A World
|
|
@@ -914,6 +1112,15 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
914
1112
|
...(existing?.services ?? []),
|
|
915
1113
|
...services.filter((service) => !existingIds.has(service.id)),
|
|
916
1114
|
].flatMap(endpointNames));
|
|
1115
|
+
const stackComponents = new Map<string, { kind: string; source: string; service: string; component: StackConnectionComponent }>();
|
|
1116
|
+
const addressNames = new Set((stack?.needs ?? []).flatMap(need => [...need.names.map(entry => entry.name), ...need.ports]));
|
|
1117
|
+
for (const { need, kind, source } of kinds) for (const part of need.components) {
|
|
1118
|
+
if (kind === null || addressNames.has(part.name) || worldInjected.has(part.name)) throw new Error(`volter init: ${part.name} has no exclusive managed store component binding`);
|
|
1119
|
+
const held = stackComponents.get(part.name);
|
|
1120
|
+
if (held && (held.kind !== kind || held.service !== need.service || held.component !== part.component)) throw new Error(`volter init: ${part.name} has conflicting managed store component bindings`);
|
|
1121
|
+
stackComponents.set(part.name, { kind, source, service: need.service, component: part.component });
|
|
1122
|
+
if (!held) stackWritten.set(need.service, [...(stackWritten.get(need.service) ?? []), part.name]);
|
|
1123
|
+
}
|
|
917
1124
|
// Every name a twin of this World issues (its pack's credentialDoor fill), by the twin: the pack names them, so a name
|
|
918
1125
|
// is issued whatever its stem (Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
|
|
919
1126
|
// that vendor's twin is in the World, and never when it is not.
|
|
@@ -922,7 +1129,18 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
922
1129
|
for (const name of Object.keys(service.issues?.fill ?? {})) if (!issuedBy.has(name)) issuedBy.set(name, service.id);
|
|
923
1130
|
}
|
|
924
1131
|
const standInStems = new Set<string>();
|
|
925
|
-
|
|
1132
|
+
// THE STACK'S OWN STATEMENT OF THE APPLICATION'S ENVIRONMENT comes before the env files: the Compose file is what
|
|
1133
|
+
// the project runs, an example env file what it suggests. Each name goes through the rules below as a name from a
|
|
1134
|
+
// file does (a credential-shaped one is replaced by a fake or issued, never copied; an infrastructure one is a
|
|
1135
|
+
// signal), so an application with no env file is still given its settings, and a secret the stack leaves to a
|
|
1136
|
+
// variable nothing sets is one the World supplies.
|
|
1137
|
+
const stackSource = stack ? `${stack.composeFile}${stack.application ? ` service ${stack.application}` : ''} environment` : '';
|
|
1138
|
+
const stackEntries: ParsedEnvEntry[] = stack ? [
|
|
1139
|
+
...stack.settings.map(({ name, value }) => ({ name, value, source: stackSource })),
|
|
1140
|
+
...stack.unset.filter((name) => !stack.settings.some((entry) => entry.name === name)).map((name) => ({ name, value: '', source: `${stackSource} (left to a variable nothing sets)` })),
|
|
1141
|
+
] : [];
|
|
1142
|
+
const stated = new Set(stackEntries.map((entry) => entry.name));
|
|
1143
|
+
for (const entry of [...stackEntries, ...repoEnvEntries(repo).filter((entry) => !stated.has(entry.name))]) {
|
|
926
1144
|
if (!envSources.includes(entry.source)) envSources.push(entry.source);
|
|
927
1145
|
const { name, value, source, conflict } = entry;
|
|
928
1146
|
// World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
|
|
@@ -993,12 +1211,29 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
993
1211
|
envRows.push({ name, disposition: 'faked', source, reason: `${vendor} credential` });
|
|
994
1212
|
continue;
|
|
995
1213
|
}
|
|
996
|
-
|
|
1214
|
+
// A conventional name is the stack's guess; where the application's own example gives that name a value whose
|
|
1215
|
+
// scheme says another kind (`DATABASE_URL=mysql://…` beside an unnamed PostgreSQL), the value is the evidence.
|
|
1216
|
+
const offered = stackNames.get(name);
|
|
1217
|
+
const valueKind = schemeKindFor(value);
|
|
1218
|
+
const wired = offered !== undefined && offered.conventional && valueKind !== null && valueKind !== offered.kind ? undefined : offered;
|
|
1219
|
+
if (offered !== undefined && wired === undefined) stackNames.delete(name);
|
|
1220
|
+
const infraKind = wired?.kind ?? infraKindFor(name, value);
|
|
997
1221
|
if (infraKind !== null) {
|
|
1222
|
+
if (wired) writeStackName(name);
|
|
1223
|
+
let query = wired?.query;
|
|
1224
|
+
if (query === undefined && infraKind === 'mongodb') {
|
|
1225
|
+
const authority = /^mongodb(?:\+srv)?:\/\/([^/?#]+)/u.exec(value)?.[1]?.split('@').at(-1);
|
|
1226
|
+
if (authority?.includes(',')) throw new Error(`volter init: ${name} names a multi-host MongoDB URI; one managed listener cannot retain that seed list`);
|
|
1227
|
+
try {
|
|
1228
|
+
const asked = new URL(value).searchParams.getAll('replicaSet');
|
|
1229
|
+
if (asked.length) query = new URLSearchParams(asked.map(set => ['replicaSet', set])).toString();
|
|
1230
|
+
}
|
|
1231
|
+
catch { /* a host or empty example declares no URI query */ }
|
|
1232
|
+
}
|
|
998
1233
|
// DEFERRED: the value depends on whether this kind gets a compose service (a URL pointing
|
|
999
1234
|
// at it) or stays a documented placeholder — decided below, once every signal is in.
|
|
1000
1235
|
infraSignals.set(infraKind, [...(infraSignals.get(infraKind) ?? []), name]);
|
|
1001
|
-
infraEntries.push({ name, source, kind: infraKind });
|
|
1236
|
+
infraEntries.push({ name, source: wired ? `${wired.source}; ${source}` : source, kind: infraKind, ...(wired?.dsnFormat ? { dsnFormat: wired!.dsnFormat, endpoint: wired!.endpoint, dsnPrefix: wired!.dsnPrefix, dsnProtocol: wired!.dsnProtocol } : {}), ...(wired?.scheme ? { scheme: wired.scheme } : {}), ...(wired?.containerPort !== undefined ? { containerPort: wired.containerPort } : {}), ...(query !== undefined ? { query } : {}), ...(wired?.host ? { host: true as const } : {}), ...(wired?.withPort ? { withPort: true as const } : {}) });
|
|
1002
1237
|
continue;
|
|
1003
1238
|
}
|
|
1004
1239
|
// A detected vendor's endpoint at a loopback server is the repository's local stand-in for the vendor (a garage for
|
|
@@ -1013,8 +1248,16 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
1013
1248
|
// app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
|
|
1014
1249
|
// reason a live key committed to `.env.example` can never reach the emitted world.
|
|
1015
1250
|
if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1251
|
+
// AN APPLICATION'S OWN SECRET IS ISSUED AT A GENERATOR'S LENGTH. A name no vendor claims and no shape answers
|
|
1252
|
+
// (AUTH_SECRET, JWT_SECRET, SESSION_SECRET) is the application's own, and applications check theirs: kaneo
|
|
1253
|
+
// refuses one under 32 characters, and `twin-fake-auth-secret` is 21. It is issued as 64 hexadecimal
|
|
1254
|
+
// characters, what `openssl rand -hex 32` gives, made from the World's name and the variable's so a World made
|
|
1255
|
+
// twice is the same World and two Worlds do not share a secret anyone could compute from the variable alone.
|
|
1256
|
+
// A vendor's credential keeps `twin-fake-…`, which its twin accepts and which reads as what it is.
|
|
1257
|
+
const generic = fakeEnvValue(name);
|
|
1258
|
+
const appsOwn = generic === `twin-fake-${name.toLowerCase().replace(/_/g, '-')}` && envNameVendor(name) === null;
|
|
1259
|
+
env[name] = appsOwn ? createHash('sha256').update(`${worldName}\0${generic}`).digest('hex') : generic;
|
|
1260
|
+
envRows.push({ name, disposition: 'faked', source, reason: appsOwn ? 'the application\'s own secret — issued as 64 hex characters, never the example value' : 'credential-shaped name — the example value is never copied' });
|
|
1018
1261
|
continue;
|
|
1019
1262
|
}
|
|
1020
1263
|
if (value !== '') {
|
|
@@ -1069,6 +1312,32 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
1069
1312
|
row.unset = true;
|
|
1070
1313
|
}
|
|
1071
1314
|
|
|
1315
|
+
// What the stack wires that no env file declared (an application with no env file at all), and what an env file
|
|
1316
|
+
// declared under a name no pattern knows, which was then kept or left as an ordinary value: the stack says it
|
|
1317
|
+
// names a service, so it takes the service's address.
|
|
1318
|
+
for (const [name, wired] of stackNames) {
|
|
1319
|
+
if (infraEntries.some((entry) => entry.name === name)) continue;
|
|
1320
|
+
const held = envRows.findIndex((row) => row.name === name);
|
|
1321
|
+
if (held >= 0) envRows.splice(held, 1);
|
|
1322
|
+
delete env[name];
|
|
1323
|
+
infraSignals.set(wired.kind, [...(infraSignals.get(wired.kind) ?? []), name]);
|
|
1324
|
+
infraEntries.push({ name, source: wired.source, kind: wired.kind, ...(wired.dsnFormat ? { dsnFormat: wired.dsnFormat, endpoint: wired.endpoint, dsnPrefix: wired.dsnPrefix, dsnProtocol: wired.dsnProtocol } : {}), ...(wired.scheme ? { scheme: wired.scheme } : {}), ...(wired.containerPort !== undefined ? { containerPort: wired.containerPort } : {}), ...(wired.query !== undefined ? { query: wired.query } : {}), ...(wired.host ? { host: true as const } : {}), ...(wired.withPort ? { withPort: true as const } : {}) });
|
|
1325
|
+
writeStackName(name);
|
|
1326
|
+
}
|
|
1327
|
+
// What was made of each service, from what was WRITTEN: a service is named, or declared under conventional
|
|
1328
|
+
// names, only by the names that were given its address.
|
|
1329
|
+
for (const { need, kind, fromScheme } of kinds) {
|
|
1330
|
+
const base = { service: need.service, ...(need.image !== undefined ? { image: need.image } : {}), ...(need.family !== undefined ? { family: need.family } : {}) };
|
|
1331
|
+
const written = [...(stackWritten.get(need.service) ?? [])].sort();
|
|
1332
|
+
const conventional = kind !== null && need.names.length === 0;
|
|
1333
|
+
stackNeeds.push({ ...base, kind, names: written, conventional,
|
|
1334
|
+
note: kind === null ? (need.family === undefined ? 'an image of no family the World knows: nothing is declared for it' : `the World manages no ${need.family}: nothing is declared for it${need.names.length ? `, and ${need.names.map((entry) => entry.name).join(', ')} keep what init gives them` : ''}`)
|
|
1335
|
+
: written.length === 0 ? (conventional ? (CONVENTIONAL_NAMES[kind] ? 'not told by the definition which variable names it, and its conventional names were given to another service or carry a value of another kind: nothing is written for it' : `not told by the definition which variable names it, and ${kind} has no conventional name: nothing is written for it`)
|
|
1336
|
+
: `its names (${need.names.map((entry) => entry.name).join(', ')}) were given to another service of the stack first: nothing is written for it`)
|
|
1337
|
+
: conventional ? 'not told by the definition which variable names it; the conventional names are supplied'
|
|
1338
|
+
: `the kind is told by ${fromScheme !== null ? 'a value\'s scheme' : 'the service\'s image'}` });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1072
1341
|
// A selected twin's declared database is part of the World's infrastructure, even when the application
|
|
1073
1342
|
// only reads that twin's issued connection URL. Do not substitute a guessed DATABASE_URL for its door.
|
|
1074
1343
|
const infrastructurePath = resolve(out, 'world.infrastructure.yml');
|
|
@@ -1095,22 +1364,125 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
1095
1364
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
1096
1365
|
.map(([kind, signals]) => composeService(name, kind, [...signals].sort(), takenPorts));
|
|
1097
1366
|
const composeByKind = new Map(compose.map((service) => [service.kind, service]));
|
|
1367
|
+
// A declared provider image remains the source stack's choice through endpoint allocation.
|
|
1368
|
+
const requestedImages = new Map<string, string>();
|
|
1369
|
+
for (const need of stack?.needs ?? []) if (need.image) {
|
|
1370
|
+
const kind = need.family ? FAMILY_KIND[need.family] : undefined;
|
|
1371
|
+
const service = kind ? composeByKind.get(kind) : undefined;
|
|
1372
|
+
if (!service) continue;
|
|
1373
|
+
const prior = requestedImages.get(kind!);
|
|
1374
|
+
if (prior && prior !== need.image) throw new Error(`volter init: distinct requested ${kind} images cannot share one managed backing`);
|
|
1375
|
+
requestedImages.set(kind!, need.image); service.image = need.image;
|
|
1376
|
+
}
|
|
1377
|
+
for (const need of stack?.needs ?? []) if (need.originalInputs) {
|
|
1378
|
+
const kind = need.family ? FAMILY_KIND[need.family] : undefined;
|
|
1379
|
+
const service = kind ? composeByKind.get(kind) : undefined;
|
|
1380
|
+
if (!service) throw new Error(`volter init: original inputs for ${need.service} have no managed service`);
|
|
1381
|
+
if (service.originalInputs && service.originalInputs.sha256 !== need.originalInputs.sha256) throw new Error(`volter init: distinct original ${kind} service inputs cannot share one managed backing`);
|
|
1382
|
+
service.originalInputs = need.originalInputs;
|
|
1383
|
+
service.originalInputsFrom = dirname(resolve(options.needs!));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
for (const need of stack?.needs ?? []) if (need.nativeServerInputs) {
|
|
1387
|
+
const service = composeByKind.get('mysql');
|
|
1388
|
+
if (!service || need.family !== 'mysql') throw new Error('volter init: native MariaDB input has no managed MySQL service');
|
|
1389
|
+
if (service.originalInputs) throw new Error('volter init: a native provider cannot discard a separate original-input consumer requirement');
|
|
1390
|
+
if (service.nativeServerInputs && (service.nativeServerInputs.receiptSha256 !== need.nativeServerInputs.receiptSha256
|
|
1391
|
+
|| service.nativeServerInputs.declarationSha256 !== need.nativeServerInputs.declarationSha256))
|
|
1392
|
+
throw new Error('volter init: distinct native MariaDB services cannot share one backing');
|
|
1393
|
+
service.nativeServerInputs = need.nativeServerInputs;
|
|
1394
|
+
service.nativeServerInputsFrom = dirname(resolve(options.needs!));
|
|
1395
|
+
service.nativeCredentialAliases = need.nativeCredentialAliases ?? [];
|
|
1396
|
+
service.image = need.nativeServerInputs.image;
|
|
1397
|
+
service.volume = 'native-mariadb-data';
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// The caller's MongoDB URI declares a server mode as well as connection options. Keep the raw query below,
|
|
1401
|
+
// and carry its set name to the backing instead of silently serving a standalone server at that address.
|
|
1402
|
+
const mongodb = composeByKind.get('mongodb');
|
|
1403
|
+
if (mongodb) {
|
|
1404
|
+
const sets = new Set<string>();
|
|
1405
|
+
for (const entry of infraEntries.filter(entry => entry.kind === 'mongodb' && entry.query !== undefined)) {
|
|
1406
|
+
const asked = new URLSearchParams(entry.query).getAll('replicaSet');
|
|
1407
|
+
if (asked.length > 1 || asked.some(value => !/^[A-Za-z][A-Za-z0-9_-]*$/.test(value))) {
|
|
1408
|
+
throw new Error(`volter init: ${entry.name} must name one valid MongoDB replicaSet`);
|
|
1409
|
+
}
|
|
1410
|
+
for (const set of asked) sets.add(set);
|
|
1411
|
+
}
|
|
1412
|
+
if (sets.size > 1) throw new Error(`volter init: the managed MongoDB service cannot serve conflicting replicaSet names: ${[...sets].join(', ')}`);
|
|
1413
|
+
if (sets.size === 1) mongodb.replicaSet = [...sets][0]!;
|
|
1414
|
+
}
|
|
1415
|
+
const hostNamed = new Set(infraEntries.filter((entry) => entry.host).map((entry) => entry.name));
|
|
1416
|
+
// THE PORT BESIDE A HOST IS THE WORLD'S TO SAY. A stack that names a service by host and port apart (`REDIS_HOST:
|
|
1417
|
+
// redis`, `REDIS_PORT: 6379`) had its host pointed here and its port left at the stack's number, where nothing of
|
|
1418
|
+
// this World listens: the port is handed out per World, so two Worlds on one machine do not meet on 6379.
|
|
1419
|
+
for (const { need, kind } of kinds) {
|
|
1420
|
+
const composed = kind === null ? undefined : composeByKind.get(kind);
|
|
1421
|
+
if (composed === undefined) continue;
|
|
1422
|
+
for (const name of need.ports) {
|
|
1423
|
+
const held = envRows.findIndex((row) => row.name === name);
|
|
1424
|
+
if (held >= 0) envRows.splice(held, 1);
|
|
1425
|
+
const target = need.portTargets[name];
|
|
1426
|
+
if (composed.sides && target === undefined) throw new Error(`volter init: ${name} requires stack-needs version3 naming the vendor port for this multiwire service`);
|
|
1427
|
+
env[name] = String(managedPort(composed, target));
|
|
1428
|
+
envRows.push({ name, disposition: 'compose', source: `${stack!.composeFile} service ${need.service}`, reason: `${kind} port — the World-managed infrastructure service's, in place of the stack's own number` });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1098
1431
|
for (const entry of infraEntries) {
|
|
1099
1432
|
const composed = composeByKind.get(entry.kind);
|
|
1100
1433
|
if (composed !== undefined) {
|
|
1101
|
-
|
|
1434
|
+
// A variable the stack set to a bare host (`REDIS_HOST: redis`) is a host, not a connection string.
|
|
1435
|
+
// The World owns the authority and database. The caller's raw options
|
|
1436
|
+
// remain byte-for-byte, including an explicitly empty query.
|
|
1437
|
+
if (composed.sides && entry.host && entry.withPort && entry.containerPort === undefined) throw new Error(`volter init: ${entry.name} requires stack-needs version3 naming the vendor port`);
|
|
1438
|
+
const target = entry.containerPort ?? (entry.kind === 'clickhouse' && entry.scheme === 'clickhouse' ? 9000 : undefined);
|
|
1439
|
+
const physicalPort = managedPort(composed, target);
|
|
1440
|
+
const port = entry.dsnFormat && entry.endpoint === 'default-port' ? composed.containerPort : physicalPort;
|
|
1441
|
+
if (entry.dsnFormat && entry.endpoint === 'default-port') {
|
|
1442
|
+
if (target !== composed.containerPort) throw new Error(`volter init: ${entry.name} default port differs from its declared listener`);
|
|
1443
|
+
composed.virtualTcpEndpoint ??= { version: 1, host: `w${fnv1a(name).toString(16)}-${composed.kind}.world.volter.internal`, port, targetPort: physicalPort };
|
|
1444
|
+
}
|
|
1445
|
+
// URL.protocol cannot change a special HTTP URL into the non-special native scheme.
|
|
1446
|
+
const connection = new URL(entry.kind === 'clickhouse' && port !== composed.hostPort
|
|
1447
|
+
? composed.url.replace(/^http:/, 'clickhouse:') : composed.url);
|
|
1448
|
+
if (entry.dsnFormat && entry.endpoint === 'default-port') connection.hostname = composed.virtualTcpEndpoint!.host;
|
|
1449
|
+
connection.port = String(port);
|
|
1450
|
+
if (entry.dsnFormat) {
|
|
1451
|
+
(composed.connectionBindings ??= {})[entry.name] = { dsnFormat: entry.dsnFormat, endpoint: entry.endpoint!,
|
|
1452
|
+
dsnPrefix: entry.dsnPrefix!, dsnProtocol: entry.dsnProtocol!, containerPort: entry.containerPort!, ...(entry.query !== undefined ? { query: entry.query } : {}) };
|
|
1453
|
+
}
|
|
1454
|
+
env[entry.name] = entry.dsnFormat ? renderGoMysqlBinding(entry as GoMysqlBinding & typeof entry, connection, port)
|
|
1455
|
+
: entry.host ? (entry.withPort ? `127.0.0.1:${port}` : '127.0.0.1')
|
|
1456
|
+
: `${entry.kind === 'clickhouse' ? connection.toString() : composed.url}${entry.query !== undefined ? `?${entry.query}` : ''}`;
|
|
1102
1457
|
envRows.push({
|
|
1103
1458
|
name: entry.name,
|
|
1104
1459
|
disposition: 'compose',
|
|
1105
1460
|
source: entry.source,
|
|
1106
|
-
reason:
|
|
1461
|
+
reason: entry.dsnFormat ? 'mysql connection in its declared Go driver grammar — from the World-managed provider' : entry.host
|
|
1462
|
+
? (entry.withPort ? `${entry.kind} host and port — the World-managed infrastructure service's` : `${entry.kind} host — the World-managed infrastructure service's (its port is ${composed.hostPort}; a port variable beside it is not set by this)`)
|
|
1463
|
+
: `${entry.kind} connection string — points at the World-managed infrastructure service`,
|
|
1107
1464
|
});
|
|
1108
1465
|
} else {
|
|
1466
|
+
if (entry.dsnFormat) throw new Error(`volter init: ${entry.name} needs a supplied MySQL service for its declared Go driver grammar`);
|
|
1109
1467
|
env[entry.name] = INFRA_PLACEHOLDER[entry.kind] ?? 'REPLACE_ME';
|
|
1110
1468
|
envRows.push({ name: entry.name, disposition: 'placeholder', source: entry.source, reason: `${entry.kind} connection string — no twin models it; point it at your own local instance` });
|
|
1111
1469
|
}
|
|
1112
1470
|
}
|
|
1113
1471
|
|
|
1472
|
+
// A split field comes from the same connection as the host/URL. It is not
|
|
1473
|
+
// an endpoint signal: discovery must never replace a scalar with a URL.
|
|
1474
|
+
for (const [name, binding] of stackComponents) {
|
|
1475
|
+
const composed = composeByKind.get(binding.kind);
|
|
1476
|
+
if (!composed) throw new Error(`volter init: ${name} needs a managed ${binding.kind} connection for its ${binding.component}`);
|
|
1477
|
+
const url = new URL(composed.url);
|
|
1478
|
+
const held = envRows.findIndex(row => row.name === name);
|
|
1479
|
+
if (held >= 0) envRows.splice(held, 1);
|
|
1480
|
+
const value = decodeURIComponent(binding.component === 'database' ? url.pathname.slice(1) : binding.component === 'user' ? url.username : url.password);
|
|
1481
|
+
if (value === '') throw new Error(`volter init: ${name} asks for a ${binding.component} the managed ${binding.kind} connection does not provide`);
|
|
1482
|
+
env[name] = value;
|
|
1483
|
+
envRows.push({ name, disposition: 'compose', source: binding.source, reason: `${binding.kind} ${binding.component} — from the World-managed connection, beside its host and port` });
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1114
1486
|
const infra: InitInfraStub[] = [...infraSignals.entries()]
|
|
1115
1487
|
.filter(([kind]) => !composeByKind.has(kind))
|
|
1116
1488
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
@@ -1151,7 +1523,10 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
1151
1523
|
external: {
|
|
1152
1524
|
up: ['volter-world-infra', 'up'],
|
|
1153
1525
|
status: ['volter-world-infra', 'status'],
|
|
1154
|
-
|
|
1526
|
+
// Only a connection string is discovered: status publishes the values that are a URL at the service's own
|
|
1527
|
+
// port (infraConnections), so a variable that holds a bare host (`REDIS_HOST: redis`, written above as
|
|
1528
|
+
// 127.0.0.1) is never among them, and asking for it made `up` fail on a World whose services were ready.
|
|
1529
|
+
discover: compose.flatMap((service) => service.signals.filter((name) => !hostNamed.has(name)).map((name) => ({ as: name, jsonPath: `connections.${name}`, ...(['postgres', 'mysql', 'mongodb', 'redis', 'clickhouse'].includes(service.kind) ? { protocol: service.kind === 'clickhouse' ? (env[name]?.startsWith('clickhouse:') ? 'clickhouse' : 'http') : service.kind } : {}) }))),
|
|
1155
1530
|
down: ['volter-world-infra', 'down'],
|
|
1156
1531
|
readyWhen: { command: 'volter-world-infra', args: ['status'], timeoutMs: 120000, intervalMs: 500 },
|
|
1157
1532
|
},
|
|
@@ -1250,6 +1625,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
1250
1625
|
? [] : projectMigrationTools(repo),
|
|
1251
1626
|
infra,
|
|
1252
1627
|
compose,
|
|
1628
|
+
...(stack ? { stackNeeds, stackEnvironmentNames, leftAsThePersonsOwn: filesLeftAsThePersonsOwn() } : {}),
|
|
1253
1629
|
composePath: compose.length > 0 ? resolve(out, 'world.infrastructure.yml') : null,
|
|
1254
1630
|
// Standing registry acknowledgments leave the worklist but never vanish (each keeps its
|
|
1255
1631
|
// recorded reason) — the same partition coverWorld's proof applies to the same signals.
|
|
@@ -1330,7 +1706,17 @@ export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}
|
|
|
1330
1706
|
}
|
|
1331
1707
|
// Recheck the planned name-derived paths before any output is written.
|
|
1332
1708
|
for (const copy of plan.defaultsCopies) ownedPath(plan.out, relative(plan.out, copy.to));
|
|
1709
|
+
for (const service of plan.compose) if (service.nativeServerInputs)
|
|
1710
|
+
ownedPath(plan.out, `native-inputs/${service.nativeServerInputs.receiptSha256}/${service.nativeServerInputs.declarationSha256}`);
|
|
1333
1711
|
mkdirSync(plan.out, { recursive: true });
|
|
1712
|
+
for (const service of plan.compose) if (service.nativeServerInputs) {
|
|
1713
|
+
if (!service.nativeServerInputsFrom) throw new Error('Native managed input has no retained source parent');
|
|
1714
|
+
copyNativeServerInputs(service.nativeServerInputsFrom, service.nativeServerInputs, plan.out);
|
|
1715
|
+
}
|
|
1716
|
+
for (const service of plan.compose) if (service.originalInputs) {
|
|
1717
|
+
if (!service.originalInputsFrom) throw new Error('Original inputs have no retained source parent');
|
|
1718
|
+
copyOriginalInputSnapshot(service.originalInputsFrom, service.originalInputs, plan.out);
|
|
1719
|
+
}
|
|
1334
1720
|
writeFileSync(plan.configPath, `${JSON.stringify(worldConfigDocument(plan.config), null, 2)}\n`);
|
|
1335
1721
|
if (plan.inRepo) writeFileSync(join(plan.out, '.gitignore'), STATE_GITIGNORE);
|
|
1336
1722
|
writeFileSync(plan.envPath, renderEnvFile(plan));
|
|
@@ -1448,6 +1834,7 @@ export function initWorld(name: string, repoPath: string, options: InitOptions =
|
|
|
1448
1834
|
root: resolve(options.root ?? process.cwd()),
|
|
1449
1835
|
allowUnknown: options.allowUnknown ?? false,
|
|
1450
1836
|
acknowledge: options.acknowledge ?? {},
|
|
1837
|
+
...(plan.stackEnvironmentNames ? { declaredEnvironmentNames: plan.stackEnvironmentNames } : {}),
|
|
1451
1838
|
});
|
|
1452
1839
|
// The proof was addressed BY PATH (so it reads the bytes just written, not the in-memory plan);
|
|
1453
1840
|
// report it under the world's id, which is what every other verb takes.
|
|
@@ -1538,6 +1925,18 @@ export function formatInitReport(result: InitResult): string {
|
|
|
1538
1925
|
}
|
|
1539
1926
|
lines.push(' Resolve these before expecting the app to listen. Vendor coverage can be green while the app rejects its own env.');
|
|
1540
1927
|
}
|
|
1928
|
+
if (plan.stackNeeds !== undefined) {
|
|
1929
|
+
const told = (test: (need: InitStackNeed) => boolean): number => plan.stackNeeds!.filter(test).length;
|
|
1930
|
+
lines.push('');
|
|
1931
|
+
lines.push('READ FOR AN IMAGE: only files the project\'s source holds were read (--needs).');
|
|
1932
|
+
for (const file of plan.leftAsThePersonsOwn ?? []) lines.push(` ${file} is the person's own and was not read`);
|
|
1933
|
+
lines.push(`THE PROJECT'S OWN STACK (${plan.stackNeeds.length} service${plan.stackNeeds.length === 1 ? '' : 's'} beside the application): `
|
|
1934
|
+
+ `${told((need) => need.kind !== null && !need.conventional && need.names.length > 0)} given their address under the stack's own variables, ${told((need) => need.conventional && need.names.length > 0)} under conventional names, `
|
|
1935
|
+
+ `${told((need) => need.kind !== null && need.names.length === 0)} of a managed kind with nothing written, ${told((need) => need.kind === null)} the World has no kind for`);
|
|
1936
|
+
for (const need of plan.stackNeeds) {
|
|
1937
|
+
lines.push(` ${need.service}${need.image ? ` (${need.image})` : ''}: ${need.kind ?? 'no managed kind'}${need.names.length ? ` — ${need.names.join(', ')}` : ''}; ${need.note}`);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1541
1940
|
if (plan.undeclaredMigrations.length > 0) {
|
|
1542
1941
|
lines.push('');
|
|
1543
1942
|
lines.push(`MIGRATION TOOL FOUND, NO STEP DECLARED (${plan.undeclaredMigrations.length}):`);
|