@volter/world-runtime 3.0.163 → 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 +3 -3
- 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 +3 -3
- 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 +3 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
package/dist/src/init.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { copyNativeServerInputs, readNativeServerInputs, nativeServerRuntimeReference } from "./native-server-inputs.js";
|
|
2
|
+
import { goMysqlBinding, renderGoMysqlBinding } from "./go-mysql-binding.js";
|
|
3
|
+
import * as managedOriginalInputs from '@volter/world-core/managed-original-inputs';
|
|
4
|
+
import { copyOriginalInputSnapshot, readOriginalInputSnapshot } from "./original-inputs.js";
|
|
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';
|
|
@@ -40,7 +45,7 @@ import { coverWorld, detectRepoVendors, envNameVendor, coverageRecord, formatCov
|
|
|
40
45
|
import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from "./fixture-env.js";
|
|
41
46
|
import { findInstalledPackage, packCli, packEntry, resolveCatalog } from "./catalog.js";
|
|
42
47
|
import { CATALOG_INDEX_PACKAGE, defaults as catalogDefaults } from "./catalog-index.js";
|
|
43
|
-
import { projectEnvReads, projectManifestDirs, projectMigrationTools } from "./project-inspect.js";
|
|
48
|
+
import { filesLeftAsThePersonsOwn, projectEnvReads, projectManifestDirs, projectMigrationTools, readOnlyWhatTheSourceHolds, sourceText } from "./project-inspect.js";
|
|
44
49
|
import { overlayEndpointEnv, packFacts } from "./pack-facts.js";
|
|
45
50
|
import { VENDOR_SERVER_BACKINGS } from "./backings.js";
|
|
46
51
|
import { APP_URL_ENV, DEFAULT_WORLD_SELECTION, ISSUE_ENV_PREFIX, MINT_ENV, worldConfigDocument, worldSelectionIncludes } from "./schema.js";
|
|
@@ -84,6 +89,96 @@ function standInStem(name, value, detected) {
|
|
|
84
89
|
}
|
|
85
90
|
/** Names that carry the application's own origin: its base URL (Next's public one included) and its auth library's. */
|
|
86
91
|
const APP_ORIGIN_ENV = /^(?:NEXT_PUBLIC_)?(?:APP_)?(?:BASE_)?URL$|^NEXT_PUBLIC_APP_(?:BASE_)?URL$|^(?:NEXTAUTH|BETTER_AUTH|AUTH)_URL$/;
|
|
92
|
+
/** The families the image builder names (its SERVICE_FAMILIES) that are a managed kind here. */
|
|
93
|
+
const FAMILY_KIND = { postgres: 'postgres', mysql: 'mysql', mongodb: 'mongodb', redis: 'redis', search: 'search', clickhouse: 'clickhouse', memcached: 'memcached' };
|
|
94
|
+
const CONVENTIONAL_NAMES = { postgres: ['DATABASE_URL'], mysql: ['DATABASE_URL'], mongodb: ['MONGODB_URI'], redis: ['REDIS_URL'], clickhouse: ['CLICKHOUSE_URL'] };
|
|
95
|
+
/** The needs file, checked for its kind: a file of another shape is refused by name, never half-read. */
|
|
96
|
+
function readStackNeeds(path) {
|
|
97
|
+
let document;
|
|
98
|
+
try {
|
|
99
|
+
document = JSON.parse(readFileSync(path, 'utf8'));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw new Error(`volter init: --needs ${path} could not be read as JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
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)) {
|
|
105
|
+
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)`);
|
|
106
|
+
}
|
|
107
|
+
const identifier = (name, what) => {
|
|
108
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
109
|
+
throw new Error(`volter init: --needs ${path} names a variable that is not an identifier among ${what}: ${JSON.stringify(name)}`);
|
|
110
|
+
return name;
|
|
111
|
+
};
|
|
112
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value) : [];
|
|
113
|
+
return {
|
|
114
|
+
composeFile: typeof document.composeFile === 'string' ? document.composeFile : 'the project\'s Compose file',
|
|
115
|
+
...(typeof document.application?.service === 'string' ? { application: document.application.service } : {}),
|
|
116
|
+
// The application's own variables that name no service: its settings as the stack writes them, and those whose
|
|
117
|
+
// value the stack makes from a variable nothing sets, which are named and have no value here.
|
|
118
|
+
settings: record(document.settings).filter(([, value]) => typeof value === 'string').map(([name, value]) => ({ name: identifier(name, 'the application\'s settings'), value: value })),
|
|
119
|
+
unset: record(document.unset).map(([name]) => identifier(name, 'the application\'s unset variables')),
|
|
120
|
+
needs: document.needs.filter((need) => typeof need.service === 'string').map((need) => ({
|
|
121
|
+
service: need.service,
|
|
122
|
+
...(need.originalInputs === undefined ? {} : { originalInputs: (() => {
|
|
123
|
+
if (document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7)
|
|
124
|
+
throw new Error('volter init: original inputs require needs version4,5,6 or7');
|
|
125
|
+
const reference = managedOriginalInputs.referenceOf(need.originalInputs);
|
|
126
|
+
const source = readOriginalInputSnapshot(dirname(resolve(path)), reference).snapshot.manifest;
|
|
127
|
+
const image = source.start.service.image;
|
|
128
|
+
if (source.service !== need.service || source.family !== need.family || typeof need.image !== 'string' || image?.raw !== need.image)
|
|
129
|
+
throw new Error('volter init: original inputs belong to another declared service');
|
|
130
|
+
return reference;
|
|
131
|
+
})() }),
|
|
132
|
+
...(need.nativeServerInputs === undefined ? {} : (() => {
|
|
133
|
+
if (document.version !== 5 && document.version !== 7)
|
|
134
|
+
throw new Error('volter init: native managed input requires needs5 or7');
|
|
135
|
+
const checked = readNativeServerInputs(dirname(resolve(path)), need.nativeServerInputs);
|
|
136
|
+
const source = JSON.parse(checked.files.get('declaration.json').toString('utf8'));
|
|
137
|
+
if (need.family !== 'mysql' || need.supplier !== 'a World' || source.name !== need.service || source.image?.raw !== need.image)
|
|
138
|
+
throw new Error('volter init: native managed source belongs to another declared service');
|
|
139
|
+
return { nativeServerInputs: checked.reference, nativeCredentialAliases: checked.aliases };
|
|
140
|
+
})()),
|
|
141
|
+
...(typeof need.image === 'string' ? { image: need.image } : {}),
|
|
142
|
+
...(typeof need.family === 'string' ? { family: need.family } : {}),
|
|
143
|
+
names: (Array.isArray(need.names) ? need.names : []).map((entry) => {
|
|
144
|
+
// A name becomes a key of the World's environment file: an identifier, or it is refused by name. Anything
|
|
145
|
+
// else (a newline, an `=`) would write lines of its own there.
|
|
146
|
+
if (typeof entry.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.name))
|
|
147
|
+
throw new Error(`volter init: --needs ${path} names a variable that is not an identifier under service ${String(need.service)}: ${JSON.stringify(entry.name)}`);
|
|
148
|
+
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))
|
|
149
|
+
throw new Error(`volter init: --needs ${path} needs version3 and a valid declared vendor port`);
|
|
150
|
+
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 } : {}) };
|
|
151
|
+
}),
|
|
152
|
+
components: (() => {
|
|
153
|
+
if (need.components === undefined)
|
|
154
|
+
return [];
|
|
155
|
+
if ((document.version !== 2 && document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Array.isArray(need.components))
|
|
156
|
+
throw new Error(`volter init: --needs ${path} requires version 2,3,4,5,6 or7 for store components`);
|
|
157
|
+
return need.components.map((part) => {
|
|
158
|
+
if (!part || typeof part !== 'object' || Array.isArray(part))
|
|
159
|
+
throw new Error(`volter init: --needs ${path} has an invalid store component`);
|
|
160
|
+
const field = part;
|
|
161
|
+
if (typeof field.name !== 'string' || !['database', 'user', 'password'].includes(String(field.component)))
|
|
162
|
+
throw new Error(`volter init: --needs ${path} has an invalid store component`);
|
|
163
|
+
return { name: identifier(field.name, `the components of service ${String(need.service)}`), component: field.component };
|
|
164
|
+
});
|
|
165
|
+
})(),
|
|
166
|
+
// The variables that hold the service's port beside one that holds its host: keys of the environment file too.
|
|
167
|
+
ports: (Array.isArray(need.ports) ? need.ports : []).map((name) => identifier(String(name), `the port variables of service ${String(need.service)}`)),
|
|
168
|
+
portTargets: (() => {
|
|
169
|
+
if (need.portTargets === undefined)
|
|
170
|
+
return {};
|
|
171
|
+
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))
|
|
172
|
+
throw new Error(`volter init: --needs ${path} needs version3 for named vendor ports`);
|
|
173
|
+
return Object.fromEntries(Object.entries(need.portTargets).map(([name, value]) => {
|
|
174
|
+
if (!Array.isArray(need.ports) || !need.ports.includes(name) || !Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)
|
|
175
|
+
throw new Error(`volter init: --needs ${path} has an invalid named vendor port`);
|
|
176
|
+
return [identifier(name, 'named vendor ports'), Number(value)];
|
|
177
|
+
}));
|
|
178
|
+
})(),
|
|
179
|
+
})),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
87
182
|
// ---------------------------------------------------------------------------------------------
|
|
88
183
|
// The pack catalog
|
|
89
184
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -278,6 +373,14 @@ const INFRA_PLACEHOLDER = {
|
|
|
278
373
|
/** The compose recipes. Versions are CONSERVATIVE major pins (current LTS-grade, not latest):
|
|
279
374
|
* a pilot wants "boots everywhere", not "newest features" — bump per pilot if the app demands. */
|
|
280
375
|
const COMPOSE_INFRA = {
|
|
376
|
+
clickhouse: {
|
|
377
|
+
image: 'clickhouse/clickhouse-server:26.9.13.15',
|
|
378
|
+
containerPort: 8123,
|
|
379
|
+
// The existing managed-database Docker default; a backend limit, not measured adequacy.
|
|
380
|
+
memoryMiB: 1024,
|
|
381
|
+
volumePath: '/var/lib/clickhouse',
|
|
382
|
+
healthcheck: () => ['CMD', 'wget', '--quiet', '--output-document=-', 'http://127.0.0.1:8123/ping'],
|
|
383
|
+
},
|
|
281
384
|
postgres: {
|
|
282
385
|
image: 'postgres:16',
|
|
283
386
|
containerPort: 5432,
|
|
@@ -299,8 +402,8 @@ const COMPOSE_INFRA = {
|
|
|
299
402
|
volumePath: '/data',
|
|
300
403
|
healthcheck: () => ['CMD', 'redis-cli', 'ping'],
|
|
301
404
|
},
|
|
302
|
-
// The
|
|
303
|
-
//
|
|
405
|
+
// The installed declaring twin, or MongoDB's own server, owns this kind's backing.
|
|
406
|
+
// A replica-set name is metadata passed to that backing, never a choice of executable.
|
|
304
407
|
mongodb: {
|
|
305
408
|
image: 'mongo:7',
|
|
306
409
|
containerPort: 27017,
|
|
@@ -352,11 +455,14 @@ function composeService(name, kind, signals, taken) {
|
|
|
352
455
|
// redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
|
|
353
456
|
const url = kind === 'redis'
|
|
354
457
|
? `redis://127.0.0.1:${hostPort}`
|
|
355
|
-
: kind === '
|
|
356
|
-
? `
|
|
357
|
-
:
|
|
458
|
+
: kind === 'clickhouse'
|
|
459
|
+
? `http://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`
|
|
460
|
+
: kind === 'mongodb'
|
|
461
|
+
? `mongodb://127.0.0.1:${hostPort}/${ident}`
|
|
462
|
+
: `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
|
|
358
463
|
return {
|
|
359
464
|
kind,
|
|
465
|
+
...(kind === 'clickhouse' ? { sides: { native: { hostPort: composePort(name, 'clickhouse-native', taken), containerPort: 9000 } }, configuration: { users: { default: '', [ident]: ident }, databases: [ident] } } : {}),
|
|
360
466
|
image: recipe.image,
|
|
361
467
|
hostPort,
|
|
362
468
|
containerPort: recipe.containerPort,
|
|
@@ -372,11 +478,21 @@ function composeEnvironment(name, kind) {
|
|
|
372
478
|
if (kind === 'postgres') {
|
|
373
479
|
return [['POSTGRES_USER', ident], ['POSTGRES_PASSWORD', ident], ['POSTGRES_DB', ident]];
|
|
374
480
|
}
|
|
481
|
+
if (kind === 'clickhouse')
|
|
482
|
+
return [['CLICKHOUSE_DB', ident], ['CLICKHOUSE_USER', ident], ['CLICKHOUSE_PASSWORD', ident], ['CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT', '1']];
|
|
375
483
|
if (kind === 'mysql') {
|
|
376
484
|
return [['MYSQL_ROOT_PASSWORD', ident], ['MYSQL_DATABASE', ident], ['MYSQL_USER', composeUser(name, kind)], ['MYSQL_PASSWORD', ident]];
|
|
377
485
|
}
|
|
378
486
|
return [];
|
|
379
487
|
}
|
|
488
|
+
function managedPort(service, containerPort) {
|
|
489
|
+
if (containerPort === undefined || containerPort === service.containerPort)
|
|
490
|
+
return service.hostPort;
|
|
491
|
+
const side = Object.values(service.sides ?? {}).find(port => port.containerPort === containerPort);
|
|
492
|
+
if (!side)
|
|
493
|
+
throw new Error(`volter init: managed ${service.kind} does not serve vendor port ${containerPort}`);
|
|
494
|
+
return side.hostPort;
|
|
495
|
+
}
|
|
380
496
|
/** Render the private managed-infrastructure definition — deterministic, with no absolute paths.
|
|
381
497
|
* Only meaningful when `plan.compose` is non-empty (writeWorldInit skips it otherwise). */
|
|
382
498
|
export function renderComposeFile(plan) {
|
|
@@ -390,13 +506,49 @@ export function renderComposeFile(plan) {
|
|
|
390
506
|
for (const service of plan.compose) {
|
|
391
507
|
const recipe = COMPOSE_INFRA[service.kind];
|
|
392
508
|
lines.push(` ${service.kind}:`);
|
|
393
|
-
lines.push(` image: ${service.image}`);
|
|
509
|
+
lines.push(` image: ${JSON.stringify(service.image)}`);
|
|
510
|
+
if (service.virtualTcpEndpoint)
|
|
511
|
+
lines.push(` x-volter-virtual-tcp-endpoint: ${JSON.stringify(service.virtualTcpEndpoint)}`);
|
|
512
|
+
if (service.replicaSet !== undefined)
|
|
513
|
+
lines.push(` x-volter-replica-set: ${JSON.stringify(service.replicaSet)}`);
|
|
514
|
+
if (service.nativeServerInputs) {
|
|
515
|
+
lines.push(' x-volter-interfaces: 3');
|
|
516
|
+
lines.push(` x-volter-native-server: ${JSON.stringify(nativeServerRuntimeReference(service.nativeServerInputs))}`);
|
|
517
|
+
}
|
|
518
|
+
if (service.originalInputs) {
|
|
519
|
+
if (!service.sides)
|
|
520
|
+
lines.push(' x-volter-interfaces: 2');
|
|
521
|
+
lines.push(` x-volter-original-inputs: ${JSON.stringify({ ...service.originalInputs, root: `original-inputs/${service.originalInputs.sha256}` })}`);
|
|
522
|
+
}
|
|
394
523
|
lines.push(` mem_limit: ${service.memoryMiB}m`);
|
|
395
524
|
lines.push(` memswap_limit: ${service.memoryMiB}m`);
|
|
396
525
|
lines.push(' cpus: 1');
|
|
526
|
+
if (service.connectionBindings) {
|
|
527
|
+
lines.push(' x-volter-issued-dsn-bindings:');
|
|
528
|
+
for (const [name, binding] of Object.entries(service.connectionBindings)) {
|
|
529
|
+
lines.push(` ${name}:`);
|
|
530
|
+
for (const [field, value] of Object.entries(binding))
|
|
531
|
+
lines.push(` ${field}: ${JSON.stringify(value)}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
397
534
|
lines.push(' ports:');
|
|
398
535
|
lines.push(` - "127.0.0.1:${service.hostPort}:${service.containerPort}"`);
|
|
536
|
+
for (const side of Object.values(service.sides ?? {}))
|
|
537
|
+
lines.push(` - "127.0.0.1:${side.hostPort}:${side.containerPort}"`);
|
|
538
|
+
if (service.sides) {
|
|
539
|
+
lines.push(` x-volter-interfaces: ${service.originalInputs ? 2 : 1}`);
|
|
540
|
+
lines.push(` x-volter-side-ports: ${JSON.stringify(service.sides)}`);
|
|
541
|
+
lines.push(` x-volter-configuration: ${JSON.stringify(service.configuration)}`);
|
|
542
|
+
}
|
|
399
543
|
const environment = composeEnvironment(plan.name, service.kind);
|
|
544
|
+
if (service.nativeServerInputs)
|
|
545
|
+
for (const alias of service.nativeCredentialAliases ?? []) {
|
|
546
|
+
const canonical = alias.replace(/^MARIADB_/, 'MYSQL_');
|
|
547
|
+
const entry = environment.find(([name]) => name === canonical);
|
|
548
|
+
if (!entry)
|
|
549
|
+
throw new Error('volter init: native provider credential alias has no issued field');
|
|
550
|
+
environment.push([alias, entry[1]]);
|
|
551
|
+
}
|
|
400
552
|
if (environment.length > 0) {
|
|
401
553
|
lines.push(' environment:');
|
|
402
554
|
for (const [key, value] of environment)
|
|
@@ -455,8 +607,11 @@ function repoEnvEntries(repo) {
|
|
|
455
607
|
const path = resolve(dir, file);
|
|
456
608
|
if (!existsSync(path))
|
|
457
609
|
continue;
|
|
610
|
+
const text = sourceText(path);
|
|
611
|
+
if (text === undefined)
|
|
612
|
+
continue;
|
|
458
613
|
const source = relative(repo, path) || file;
|
|
459
|
-
for (const entry of parseEnvFile(
|
|
614
|
+
for (const entry of parseEnvFile(text, source)) {
|
|
460
615
|
if (inDir.has(entry.name))
|
|
461
616
|
continue;
|
|
462
617
|
inDir.add(entry.name);
|
|
@@ -497,7 +652,11 @@ function isInside(parent, candidate) {
|
|
|
497
652
|
}
|
|
498
653
|
export function planWorldInit(name, repoPath, options = {}) {
|
|
499
654
|
assertSafeName(name);
|
|
655
|
+
// The World's own name, kept under a name no loop below takes: what it issues is made from it.
|
|
656
|
+
const worldName = name;
|
|
500
657
|
const repo = resolve(repoPath);
|
|
658
|
+
// A World made from a stack's needs is made for an image: only what the project's source holds is read.
|
|
659
|
+
readOnlyWhatTheSourceHolds(options.needs ? repo : undefined);
|
|
501
660
|
if (!existsSync(repo))
|
|
502
661
|
throw new Error(`volter init: the application repo does not exist: ${repo}`);
|
|
503
662
|
const root = resolve(options.root ?? repo);
|
|
@@ -519,7 +678,7 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
519
678
|
/** a path the config carries: relative to the world root when the config lives under it
|
|
520
679
|
* (portable between teammates), absolute otherwise (a pilot emitted away from its root) */
|
|
521
680
|
const carried = (path) => (isInside(root, path) ? relative(root, path) : path);
|
|
522
|
-
const catalogSource = resolveCatalog(root, { sources: options.sources, vendors: options.vendors });
|
|
681
|
+
const catalogSource = resolveCatalog(root, { sources: options.sources, vendors: options.vendors, mayBeEmpty: options.needs !== undefined });
|
|
523
682
|
const catalog = new Set(catalogSource.vendors());
|
|
524
683
|
// the catalog index's defaults (the installed `@volter/twin-catalog`), which name the twin to install for a vendor
|
|
525
684
|
// this catalog lacks
|
|
@@ -528,7 +687,9 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
528
687
|
// baked into the pack). Its handlers starter is the answering turns its own life scripts (journeys/scenario.json, the
|
|
529
688
|
// World's handler format, its faults left out); its default data is src/semantics/seed.ts.
|
|
530
689
|
const defaultsCopies = [];
|
|
531
|
-
const
|
|
690
|
+
const stack = options.needs ? readStackNeeds(resolve(options.needs)) : undefined;
|
|
691
|
+
const stackEnvironmentNames = stack ? [...new Set([...stack.settings.map(entry => entry.name), ...stack.unset])].sort() : [];
|
|
692
|
+
const signals = options.vendors ? null : detectRepoVendors(repo, existing?.services, stackEnvironmentNames);
|
|
532
693
|
const detected = options.vendors
|
|
533
694
|
? new Map(options.vendors.map((v) => [v, ['--twins']]))
|
|
534
695
|
: new Map([...signals.detected].filter(([vendor]) => (signals.uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
|
|
@@ -689,6 +850,36 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
689
850
|
const mintedEnv = [];
|
|
690
851
|
const infraSignals = new Map();
|
|
691
852
|
const infraEntries = [];
|
|
853
|
+
// The project's own stack, where its image builder read it: each variable it wires to a service of a managed kind
|
|
854
|
+
// is that kind's signal whatever the variable is called, and is decided here before a name's pattern is asked.
|
|
855
|
+
const stackNames = new Map();
|
|
856
|
+
const stackNeeds = [];
|
|
857
|
+
/** The names actually given each service's address, filled where an entry is written: the report counts these. */
|
|
858
|
+
const stackWritten = new Map();
|
|
859
|
+
const kinds = (stack?.needs ?? []).map((need) => {
|
|
860
|
+
const fromScheme = need.names.map((entry) => entry.scheme === undefined ? null : schemeKindFor(`${entry.scheme}://`)).find((kind) => kind !== null) ?? null;
|
|
861
|
+
return { need, fromScheme, kind: fromScheme ?? (need.family !== undefined ? FAMILY_KIND[need.family] ?? null : null), source: `${stack.composeFile} service ${need.service}` };
|
|
862
|
+
});
|
|
863
|
+
// Explicit names first, over the whole stack: a service the stack wires by name keeps its names whatever is
|
|
864
|
+
// listed before it. Conventional names are for what is left, and never one another service was given.
|
|
865
|
+
for (const { need, kind, source } of kinds) {
|
|
866
|
+
if (kind === null)
|
|
867
|
+
continue;
|
|
868
|
+
for (const entry of need.names)
|
|
869
|
+
if (!stackNames.has(entry.name))
|
|
870
|
+
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 });
|
|
871
|
+
}
|
|
872
|
+
for (const { need, kind, source } of kinds) {
|
|
873
|
+
if (kind === null || need.names.length > 0)
|
|
874
|
+
continue;
|
|
875
|
+
for (const name of CONVENTIONAL_NAMES[kind] ?? [])
|
|
876
|
+
if (!stackNames.has(name))
|
|
877
|
+
stackNames.set(name, { kind, source, host: false, withPort: false, service: need.service, conventional: true });
|
|
878
|
+
}
|
|
879
|
+
const writeStackName = (name) => {
|
|
880
|
+
const wired = stackNames.get(name);
|
|
881
|
+
stackWritten.set(wired.service, [...(stackWritten.get(wired.service) ?? []), name]);
|
|
882
|
+
};
|
|
692
883
|
// SMTP's host and port under the names this app declares: apps name them their own way (twenty's EMAIL_SMTP_HOST/
|
|
693
884
|
// EMAIL_SMTP_PORT beside nodemailer's SMTP_HOST), and a raw-TCP twin is reached only through the env the app reads,
|
|
694
885
|
// so every declared `…SMTP_HOST` / `…SMTP_PORT` points at the smtp twin (twenty lane: its mail went to the log). A World
|
|
@@ -717,6 +908,19 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
717
908
|
...(existing?.services ?? []),
|
|
718
909
|
...services.filter((service) => !existingIds.has(service.id)),
|
|
719
910
|
].flatMap(endpointNames));
|
|
911
|
+
const stackComponents = new Map();
|
|
912
|
+
const addressNames = new Set((stack?.needs ?? []).flatMap(need => [...need.names.map(entry => entry.name), ...need.ports]));
|
|
913
|
+
for (const { need, kind, source } of kinds)
|
|
914
|
+
for (const part of need.components) {
|
|
915
|
+
if (kind === null || addressNames.has(part.name) || worldInjected.has(part.name))
|
|
916
|
+
throw new Error(`volter init: ${part.name} has no exclusive managed store component binding`);
|
|
917
|
+
const held = stackComponents.get(part.name);
|
|
918
|
+
if (held && (held.kind !== kind || held.service !== need.service || held.component !== part.component))
|
|
919
|
+
throw new Error(`volter init: ${part.name} has conflicting managed store component bindings`);
|
|
920
|
+
stackComponents.set(part.name, { kind, source, service: need.service, component: part.component });
|
|
921
|
+
if (!held)
|
|
922
|
+
stackWritten.set(need.service, [...(stackWritten.get(need.service) ?? []), part.name]);
|
|
923
|
+
}
|
|
720
924
|
// Every name a twin of this World issues (its pack's credentialDoor fill), by the twin: the pack names them, so a name
|
|
721
925
|
// is issued whatever its stem (Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
|
|
722
926
|
// that vendor's twin is in the World, and never when it is not.
|
|
@@ -727,7 +931,18 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
727
931
|
issuedBy.set(name, service.id);
|
|
728
932
|
}
|
|
729
933
|
const standInStems = new Set();
|
|
730
|
-
|
|
934
|
+
// THE STACK'S OWN STATEMENT OF THE APPLICATION'S ENVIRONMENT comes before the env files: the Compose file is what
|
|
935
|
+
// the project runs, an example env file what it suggests. Each name goes through the rules below as a name from a
|
|
936
|
+
// file does (a credential-shaped one is replaced by a fake or issued, never copied; an infrastructure one is a
|
|
937
|
+
// signal), so an application with no env file is still given its settings, and a secret the stack leaves to a
|
|
938
|
+
// variable nothing sets is one the World supplies.
|
|
939
|
+
const stackSource = stack ? `${stack.composeFile}${stack.application ? ` service ${stack.application}` : ''} environment` : '';
|
|
940
|
+
const stackEntries = stack ? [
|
|
941
|
+
...stack.settings.map(({ name, value }) => ({ name, value, source: stackSource })),
|
|
942
|
+
...stack.unset.filter((name) => !stack.settings.some((entry) => entry.name === name)).map((name) => ({ name, value: '', source: `${stackSource} (left to a variable nothing sets)` })),
|
|
943
|
+
] : [];
|
|
944
|
+
const stated = new Set(stackEntries.map((entry) => entry.name));
|
|
945
|
+
for (const entry of [...stackEntries, ...repoEnvEntries(repo).filter((entry) => !stated.has(entry.name))]) {
|
|
731
946
|
if (!envSources.includes(entry.source))
|
|
732
947
|
envSources.push(entry.source);
|
|
733
948
|
const { name, value, source, conflict } = entry;
|
|
@@ -800,12 +1015,33 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
800
1015
|
envRows.push({ name, disposition: 'faked', source, reason: `${vendor} credential` });
|
|
801
1016
|
continue;
|
|
802
1017
|
}
|
|
803
|
-
|
|
1018
|
+
// A conventional name is the stack's guess; where the application's own example gives that name a value whose
|
|
1019
|
+
// scheme says another kind (`DATABASE_URL=mysql://…` beside an unnamed PostgreSQL), the value is the evidence.
|
|
1020
|
+
const offered = stackNames.get(name);
|
|
1021
|
+
const valueKind = schemeKindFor(value);
|
|
1022
|
+
const wired = offered !== undefined && offered.conventional && valueKind !== null && valueKind !== offered.kind ? undefined : offered;
|
|
1023
|
+
if (offered !== undefined && wired === undefined)
|
|
1024
|
+
stackNames.delete(name);
|
|
1025
|
+
const infraKind = wired?.kind ?? infraKindFor(name, value);
|
|
804
1026
|
if (infraKind !== null) {
|
|
1027
|
+
if (wired)
|
|
1028
|
+
writeStackName(name);
|
|
1029
|
+
let query = wired?.query;
|
|
1030
|
+
if (query === undefined && infraKind === 'mongodb') {
|
|
1031
|
+
const authority = /^mongodb(?:\+srv)?:\/\/([^/?#]+)/u.exec(value)?.[1]?.split('@').at(-1);
|
|
1032
|
+
if (authority?.includes(','))
|
|
1033
|
+
throw new Error(`volter init: ${name} names a multi-host MongoDB URI; one managed listener cannot retain that seed list`);
|
|
1034
|
+
try {
|
|
1035
|
+
const asked = new URL(value).searchParams.getAll('replicaSet');
|
|
1036
|
+
if (asked.length)
|
|
1037
|
+
query = new URLSearchParams(asked.map(set => ['replicaSet', set])).toString();
|
|
1038
|
+
}
|
|
1039
|
+
catch { /* a host or empty example declares no URI query */ }
|
|
1040
|
+
}
|
|
805
1041
|
// DEFERRED: the value depends on whether this kind gets a compose service (a URL pointing
|
|
806
1042
|
// at it) or stays a documented placeholder — decided below, once every signal is in.
|
|
807
1043
|
infraSignals.set(infraKind, [...(infraSignals.get(infraKind) ?? []), name]);
|
|
808
|
-
infraEntries.push({ name, source, kind: infraKind });
|
|
1044
|
+
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 } : {}), ...(wired?.withPort ? { withPort: true } : {}) });
|
|
809
1045
|
continue;
|
|
810
1046
|
}
|
|
811
1047
|
// A detected vendor's endpoint at a loopback server is the repository's local stand-in for the vendor (a garage for
|
|
@@ -820,8 +1056,16 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
820
1056
|
// app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
|
|
821
1057
|
// reason a live key committed to `.env.example` can never reach the emitted world.
|
|
822
1058
|
if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
|
|
823
|
-
|
|
824
|
-
|
|
1059
|
+
// AN APPLICATION'S OWN SECRET IS ISSUED AT A GENERATOR'S LENGTH. A name no vendor claims and no shape answers
|
|
1060
|
+
// (AUTH_SECRET, JWT_SECRET, SESSION_SECRET) is the application's own, and applications check theirs: kaneo
|
|
1061
|
+
// refuses one under 32 characters, and `twin-fake-auth-secret` is 21. It is issued as 64 hexadecimal
|
|
1062
|
+
// characters, what `openssl rand -hex 32` gives, made from the World's name and the variable's so a World made
|
|
1063
|
+
// twice is the same World and two Worlds do not share a secret anyone could compute from the variable alone.
|
|
1064
|
+
// A vendor's credential keeps `twin-fake-…`, which its twin accepts and which reads as what it is.
|
|
1065
|
+
const generic = fakeEnvValue(name);
|
|
1066
|
+
const appsOwn = generic === `twin-fake-${name.toLowerCase().replace(/_/g, '-')}` && envNameVendor(name) === null;
|
|
1067
|
+
env[name] = appsOwn ? createHash('sha256').update(`${worldName}\0${generic}`).digest('hex') : generic;
|
|
1068
|
+
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' });
|
|
825
1069
|
continue;
|
|
826
1070
|
}
|
|
827
1071
|
if (value !== '') {
|
|
@@ -879,6 +1123,33 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
879
1123
|
row.reason = `the local stand-in's region, not one ${stem}'s vendor has — left unset`;
|
|
880
1124
|
row.unset = true;
|
|
881
1125
|
}
|
|
1126
|
+
// What the stack wires that no env file declared (an application with no env file at all), and what an env file
|
|
1127
|
+
// declared under a name no pattern knows, which was then kept or left as an ordinary value: the stack says it
|
|
1128
|
+
// names a service, so it takes the service's address.
|
|
1129
|
+
for (const [name, wired] of stackNames) {
|
|
1130
|
+
if (infraEntries.some((entry) => entry.name === name))
|
|
1131
|
+
continue;
|
|
1132
|
+
const held = envRows.findIndex((row) => row.name === name);
|
|
1133
|
+
if (held >= 0)
|
|
1134
|
+
envRows.splice(held, 1);
|
|
1135
|
+
delete env[name];
|
|
1136
|
+
infraSignals.set(wired.kind, [...(infraSignals.get(wired.kind) ?? []), name]);
|
|
1137
|
+
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 } : {}), ...(wired.withPort ? { withPort: true } : {}) });
|
|
1138
|
+
writeStackName(name);
|
|
1139
|
+
}
|
|
1140
|
+
// What was made of each service, from what was WRITTEN: a service is named, or declared under conventional
|
|
1141
|
+
// names, only by the names that were given its address.
|
|
1142
|
+
for (const { need, kind, fromScheme } of kinds) {
|
|
1143
|
+
const base = { service: need.service, ...(need.image !== undefined ? { image: need.image } : {}), ...(need.family !== undefined ? { family: need.family } : {}) };
|
|
1144
|
+
const written = [...(stackWritten.get(need.service) ?? [])].sort();
|
|
1145
|
+
const conventional = kind !== null && need.names.length === 0;
|
|
1146
|
+
stackNeeds.push({ ...base, kind, names: written, conventional,
|
|
1147
|
+
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` : ''}`)
|
|
1148
|
+
: 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`)
|
|
1149
|
+
: `its names (${need.names.map((entry) => entry.name).join(', ')}) were given to another service of the stack first: nothing is written for it`)
|
|
1150
|
+
: conventional ? 'not told by the definition which variable names it; the conventional names are supplied'
|
|
1151
|
+
: `the kind is told by ${fromScheme !== null ? 'a value\'s scheme' : 'the service\'s image'}` });
|
|
1152
|
+
}
|
|
882
1153
|
// A selected twin's declared database is part of the World's infrastructure, even when the application
|
|
883
1154
|
// only reads that twin's issued connection URL. Do not substitute a guessed DATABASE_URL for its door.
|
|
884
1155
|
const infrastructurePath = resolve(out, 'world.infrastructure.yml');
|
|
@@ -906,22 +1177,145 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
906
1177
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
907
1178
|
.map(([kind, signals]) => composeService(name, kind, [...signals].sort(), takenPorts));
|
|
908
1179
|
const composeByKind = new Map(compose.map((service) => [service.kind, service]));
|
|
1180
|
+
// A declared provider image remains the source stack's choice through endpoint allocation.
|
|
1181
|
+
const requestedImages = new Map();
|
|
1182
|
+
for (const need of stack?.needs ?? [])
|
|
1183
|
+
if (need.image) {
|
|
1184
|
+
const kind = need.family ? FAMILY_KIND[need.family] : undefined;
|
|
1185
|
+
const service = kind ? composeByKind.get(kind) : undefined;
|
|
1186
|
+
if (!service)
|
|
1187
|
+
continue;
|
|
1188
|
+
const prior = requestedImages.get(kind);
|
|
1189
|
+
if (prior && prior !== need.image)
|
|
1190
|
+
throw new Error(`volter init: distinct requested ${kind} images cannot share one managed backing`);
|
|
1191
|
+
requestedImages.set(kind, need.image);
|
|
1192
|
+
service.image = need.image;
|
|
1193
|
+
}
|
|
1194
|
+
for (const need of stack?.needs ?? [])
|
|
1195
|
+
if (need.originalInputs) {
|
|
1196
|
+
const kind = need.family ? FAMILY_KIND[need.family] : undefined;
|
|
1197
|
+
const service = kind ? composeByKind.get(kind) : undefined;
|
|
1198
|
+
if (!service)
|
|
1199
|
+
throw new Error(`volter init: original inputs for ${need.service} have no managed service`);
|
|
1200
|
+
if (service.originalInputs && service.originalInputs.sha256 !== need.originalInputs.sha256)
|
|
1201
|
+
throw new Error(`volter init: distinct original ${kind} service inputs cannot share one managed backing`);
|
|
1202
|
+
service.originalInputs = need.originalInputs;
|
|
1203
|
+
service.originalInputsFrom = dirname(resolve(options.needs));
|
|
1204
|
+
}
|
|
1205
|
+
for (const need of stack?.needs ?? [])
|
|
1206
|
+
if (need.nativeServerInputs) {
|
|
1207
|
+
const service = composeByKind.get('mysql');
|
|
1208
|
+
if (!service || need.family !== 'mysql')
|
|
1209
|
+
throw new Error('volter init: native MariaDB input has no managed MySQL service');
|
|
1210
|
+
if (service.originalInputs)
|
|
1211
|
+
throw new Error('volter init: a native provider cannot discard a separate original-input consumer requirement');
|
|
1212
|
+
if (service.nativeServerInputs && (service.nativeServerInputs.receiptSha256 !== need.nativeServerInputs.receiptSha256
|
|
1213
|
+
|| service.nativeServerInputs.declarationSha256 !== need.nativeServerInputs.declarationSha256))
|
|
1214
|
+
throw new Error('volter init: distinct native MariaDB services cannot share one backing');
|
|
1215
|
+
service.nativeServerInputs = need.nativeServerInputs;
|
|
1216
|
+
service.nativeServerInputsFrom = dirname(resolve(options.needs));
|
|
1217
|
+
service.nativeCredentialAliases = need.nativeCredentialAliases ?? [];
|
|
1218
|
+
service.image = need.nativeServerInputs.image;
|
|
1219
|
+
service.volume = 'native-mariadb-data';
|
|
1220
|
+
}
|
|
1221
|
+
// The caller's MongoDB URI declares a server mode as well as connection options. Keep the raw query below,
|
|
1222
|
+
// and carry its set name to the backing instead of silently serving a standalone server at that address.
|
|
1223
|
+
const mongodb = composeByKind.get('mongodb');
|
|
1224
|
+
if (mongodb) {
|
|
1225
|
+
const sets = new Set();
|
|
1226
|
+
for (const entry of infraEntries.filter(entry => entry.kind === 'mongodb' && entry.query !== undefined)) {
|
|
1227
|
+
const asked = new URLSearchParams(entry.query).getAll('replicaSet');
|
|
1228
|
+
if (asked.length > 1 || asked.some(value => !/^[A-Za-z][A-Za-z0-9_-]*$/.test(value))) {
|
|
1229
|
+
throw new Error(`volter init: ${entry.name} must name one valid MongoDB replicaSet`);
|
|
1230
|
+
}
|
|
1231
|
+
for (const set of asked)
|
|
1232
|
+
sets.add(set);
|
|
1233
|
+
}
|
|
1234
|
+
if (sets.size > 1)
|
|
1235
|
+
throw new Error(`volter init: the managed MongoDB service cannot serve conflicting replicaSet names: ${[...sets].join(', ')}`);
|
|
1236
|
+
if (sets.size === 1)
|
|
1237
|
+
mongodb.replicaSet = [...sets][0];
|
|
1238
|
+
}
|
|
1239
|
+
const hostNamed = new Set(infraEntries.filter((entry) => entry.host).map((entry) => entry.name));
|
|
1240
|
+
// THE PORT BESIDE A HOST IS THE WORLD'S TO SAY. A stack that names a service by host and port apart (`REDIS_HOST:
|
|
1241
|
+
// redis`, `REDIS_PORT: 6379`) had its host pointed here and its port left at the stack's number, where nothing of
|
|
1242
|
+
// this World listens: the port is handed out per World, so two Worlds on one machine do not meet on 6379.
|
|
1243
|
+
for (const { need, kind } of kinds) {
|
|
1244
|
+
const composed = kind === null ? undefined : composeByKind.get(kind);
|
|
1245
|
+
if (composed === undefined)
|
|
1246
|
+
continue;
|
|
1247
|
+
for (const name of need.ports) {
|
|
1248
|
+
const held = envRows.findIndex((row) => row.name === name);
|
|
1249
|
+
if (held >= 0)
|
|
1250
|
+
envRows.splice(held, 1);
|
|
1251
|
+
const target = need.portTargets[name];
|
|
1252
|
+
if (composed.sides && target === undefined)
|
|
1253
|
+
throw new Error(`volter init: ${name} requires stack-needs version3 naming the vendor port for this multiwire service`);
|
|
1254
|
+
env[name] = String(managedPort(composed, target));
|
|
1255
|
+
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` });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
909
1258
|
for (const entry of infraEntries) {
|
|
910
1259
|
const composed = composeByKind.get(entry.kind);
|
|
911
1260
|
if (composed !== undefined) {
|
|
912
|
-
|
|
1261
|
+
// A variable the stack set to a bare host (`REDIS_HOST: redis`) is a host, not a connection string.
|
|
1262
|
+
// The World owns the authority and database. The caller's raw options
|
|
1263
|
+
// remain byte-for-byte, including an explicitly empty query.
|
|
1264
|
+
if (composed.sides && entry.host && entry.withPort && entry.containerPort === undefined)
|
|
1265
|
+
throw new Error(`volter init: ${entry.name} requires stack-needs version3 naming the vendor port`);
|
|
1266
|
+
const target = entry.containerPort ?? (entry.kind === 'clickhouse' && entry.scheme === 'clickhouse' ? 9000 : undefined);
|
|
1267
|
+
const physicalPort = managedPort(composed, target);
|
|
1268
|
+
const port = entry.dsnFormat && entry.endpoint === 'default-port' ? composed.containerPort : physicalPort;
|
|
1269
|
+
if (entry.dsnFormat && entry.endpoint === 'default-port') {
|
|
1270
|
+
if (target !== composed.containerPort)
|
|
1271
|
+
throw new Error(`volter init: ${entry.name} default port differs from its declared listener`);
|
|
1272
|
+
composed.virtualTcpEndpoint ??= { version: 1, host: `w${fnv1a(name).toString(16)}-${composed.kind}.world.volter.internal`, port, targetPort: physicalPort };
|
|
1273
|
+
}
|
|
1274
|
+
// URL.protocol cannot change a special HTTP URL into the non-special native scheme.
|
|
1275
|
+
const connection = new URL(entry.kind === 'clickhouse' && port !== composed.hostPort
|
|
1276
|
+
? composed.url.replace(/^http:/, 'clickhouse:') : composed.url);
|
|
1277
|
+
if (entry.dsnFormat && entry.endpoint === 'default-port')
|
|
1278
|
+
connection.hostname = composed.virtualTcpEndpoint.host;
|
|
1279
|
+
connection.port = String(port);
|
|
1280
|
+
if (entry.dsnFormat) {
|
|
1281
|
+
(composed.connectionBindings ??= {})[entry.name] = { dsnFormat: entry.dsnFormat, endpoint: entry.endpoint,
|
|
1282
|
+
dsnPrefix: entry.dsnPrefix, dsnProtocol: entry.dsnProtocol, containerPort: entry.containerPort, ...(entry.query !== undefined ? { query: entry.query } : {}) };
|
|
1283
|
+
}
|
|
1284
|
+
env[entry.name] = entry.dsnFormat ? renderGoMysqlBinding(entry, connection, port)
|
|
1285
|
+
: entry.host ? (entry.withPort ? `127.0.0.1:${port}` : '127.0.0.1')
|
|
1286
|
+
: `${entry.kind === 'clickhouse' ? connection.toString() : composed.url}${entry.query !== undefined ? `?${entry.query}` : ''}`;
|
|
913
1287
|
envRows.push({
|
|
914
1288
|
name: entry.name,
|
|
915
1289
|
disposition: 'compose',
|
|
916
1290
|
source: entry.source,
|
|
917
|
-
reason:
|
|
1291
|
+
reason: entry.dsnFormat ? 'mysql connection in its declared Go driver grammar — from the World-managed provider' : entry.host
|
|
1292
|
+
? (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)`)
|
|
1293
|
+
: `${entry.kind} connection string — points at the World-managed infrastructure service`,
|
|
918
1294
|
});
|
|
919
1295
|
}
|
|
920
1296
|
else {
|
|
1297
|
+
if (entry.dsnFormat)
|
|
1298
|
+
throw new Error(`volter init: ${entry.name} needs a supplied MySQL service for its declared Go driver grammar`);
|
|
921
1299
|
env[entry.name] = INFRA_PLACEHOLDER[entry.kind] ?? 'REPLACE_ME';
|
|
922
1300
|
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` });
|
|
923
1301
|
}
|
|
924
1302
|
}
|
|
1303
|
+
// A split field comes from the same connection as the host/URL. It is not
|
|
1304
|
+
// an endpoint signal: discovery must never replace a scalar with a URL.
|
|
1305
|
+
for (const [name, binding] of stackComponents) {
|
|
1306
|
+
const composed = composeByKind.get(binding.kind);
|
|
1307
|
+
if (!composed)
|
|
1308
|
+
throw new Error(`volter init: ${name} needs a managed ${binding.kind} connection for its ${binding.component}`);
|
|
1309
|
+
const url = new URL(composed.url);
|
|
1310
|
+
const held = envRows.findIndex(row => row.name === name);
|
|
1311
|
+
if (held >= 0)
|
|
1312
|
+
envRows.splice(held, 1);
|
|
1313
|
+
const value = decodeURIComponent(binding.component === 'database' ? url.pathname.slice(1) : binding.component === 'user' ? url.username : url.password);
|
|
1314
|
+
if (value === '')
|
|
1315
|
+
throw new Error(`volter init: ${name} asks for a ${binding.component} the managed ${binding.kind} connection does not provide`);
|
|
1316
|
+
env[name] = value;
|
|
1317
|
+
envRows.push({ name, disposition: 'compose', source: binding.source, reason: `${binding.kind} ${binding.component} — from the World-managed connection, beside its host and port` });
|
|
1318
|
+
}
|
|
925
1319
|
const infra = [...infraSignals.entries()]
|
|
926
1320
|
.filter(([kind]) => !composeByKind.has(kind))
|
|
927
1321
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
@@ -961,7 +1355,10 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
961
1355
|
external: {
|
|
962
1356
|
up: ['volter-world-infra', 'up'],
|
|
963
1357
|
status: ['volter-world-infra', 'status'],
|
|
964
|
-
|
|
1358
|
+
// Only a connection string is discovered: status publishes the values that are a URL at the service's own
|
|
1359
|
+
// port (infraConnections), so a variable that holds a bare host (`REDIS_HOST: redis`, written above as
|
|
1360
|
+
// 127.0.0.1) is never among them, and asking for it made `up` fail on a World whose services were ready.
|
|
1361
|
+
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 } : {}) }))),
|
|
965
1362
|
down: ['volter-world-infra', 'down'],
|
|
966
1363
|
readyWhen: { command: 'volter-world-infra', args: ['status'], timeoutMs: 120000, intervalMs: 500 },
|
|
967
1364
|
},
|
|
@@ -1058,6 +1455,7 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
1058
1455
|
? [] : projectMigrationTools(repo),
|
|
1059
1456
|
infra,
|
|
1060
1457
|
compose,
|
|
1458
|
+
...(stack ? { stackNeeds, stackEnvironmentNames, leftAsThePersonsOwn: filesLeftAsThePersonsOwn() } : {}),
|
|
1061
1459
|
composePath: compose.length > 0 ? resolve(out, 'world.infrastructure.yml') : null,
|
|
1062
1460
|
// Standing registry acknowledgments leave the worklist but never vanish (each keeps its
|
|
1063
1461
|
// recorded reason) — the same partition coverWorld's proof applies to the same signals.
|
|
@@ -1135,7 +1533,22 @@ export function writeWorldInit(plan, options = {}) {
|
|
|
1135
1533
|
// Recheck the planned name-derived paths before any output is written.
|
|
1136
1534
|
for (const copy of plan.defaultsCopies)
|
|
1137
1535
|
ownedPath(plan.out, relative(plan.out, copy.to));
|
|
1536
|
+
for (const service of plan.compose)
|
|
1537
|
+
if (service.nativeServerInputs)
|
|
1538
|
+
ownedPath(plan.out, `native-inputs/${service.nativeServerInputs.receiptSha256}/${service.nativeServerInputs.declarationSha256}`);
|
|
1138
1539
|
mkdirSync(plan.out, { recursive: true });
|
|
1540
|
+
for (const service of plan.compose)
|
|
1541
|
+
if (service.nativeServerInputs) {
|
|
1542
|
+
if (!service.nativeServerInputsFrom)
|
|
1543
|
+
throw new Error('Native managed input has no retained source parent');
|
|
1544
|
+
copyNativeServerInputs(service.nativeServerInputsFrom, service.nativeServerInputs, plan.out);
|
|
1545
|
+
}
|
|
1546
|
+
for (const service of plan.compose)
|
|
1547
|
+
if (service.originalInputs) {
|
|
1548
|
+
if (!service.originalInputsFrom)
|
|
1549
|
+
throw new Error('Original inputs have no retained source parent');
|
|
1550
|
+
copyOriginalInputSnapshot(service.originalInputsFrom, service.originalInputs, plan.out);
|
|
1551
|
+
}
|
|
1139
1552
|
writeFileSync(plan.configPath, `${JSON.stringify(worldConfigDocument(plan.config), null, 2)}\n`);
|
|
1140
1553
|
if (plan.inRepo)
|
|
1141
1554
|
writeFileSync(join(plan.out, '.gitignore'), STATE_GITIGNORE);
|
|
@@ -1253,6 +1666,7 @@ export function initWorld(name, repoPath, options = {}) {
|
|
|
1253
1666
|
root: resolve(options.root ?? process.cwd()),
|
|
1254
1667
|
allowUnknown: options.allowUnknown ?? false,
|
|
1255
1668
|
acknowledge: options.acknowledge ?? {},
|
|
1669
|
+
...(plan.stackEnvironmentNames ? { declaredEnvironmentNames: plan.stackEnvironmentNames } : {}),
|
|
1256
1670
|
});
|
|
1257
1671
|
// The proof was addressed BY PATH (so it reads the bytes just written, not the in-memory plan);
|
|
1258
1672
|
// report it under the world's id, which is what every other verb takes.
|
|
@@ -1342,6 +1756,19 @@ export function formatInitReport(result) {
|
|
|
1342
1756
|
}
|
|
1343
1757
|
lines.push(' Resolve these before expecting the app to listen. Vendor coverage can be green while the app rejects its own env.');
|
|
1344
1758
|
}
|
|
1759
|
+
if (plan.stackNeeds !== undefined) {
|
|
1760
|
+
const told = (test) => plan.stackNeeds.filter(test).length;
|
|
1761
|
+
lines.push('');
|
|
1762
|
+
lines.push('READ FOR AN IMAGE: only files the project\'s source holds were read (--needs).');
|
|
1763
|
+
for (const file of plan.leftAsThePersonsOwn ?? [])
|
|
1764
|
+
lines.push(` ${file} is the person's own and was not read`);
|
|
1765
|
+
lines.push(`THE PROJECT'S OWN STACK (${plan.stackNeeds.length} service${plan.stackNeeds.length === 1 ? '' : 's'} beside the application): `
|
|
1766
|
+
+ `${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, `
|
|
1767
|
+
+ `${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`);
|
|
1768
|
+
for (const need of plan.stackNeeds) {
|
|
1769
|
+
lines.push(` ${need.service}${need.image ? ` (${need.image})` : ''}: ${need.kind ?? 'no managed kind'}${need.names.length ? ` — ${need.names.join(', ')}` : ''}; ${need.note}`);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1345
1772
|
if (plan.undeclaredMigrations.length > 0) {
|
|
1346
1773
|
lines.push('');
|
|
1347
1774
|
lines.push(`MIGRATION TOOL FOUND, NO STEP DECLARED (${plan.undeclaredMigrations.length}):`);
|