@platformatic/runtime 4.0.0-new-config.2 → 4.0.0-new-config.6

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/lib/generator.js CHANGED
@@ -1,21 +1,20 @@
1
+ import { createEnvFileTool, defaultPackageManager, generateDashedName, safeRemove } from '@platformatic/foundation'
2
+ import { LegacyConfigurationFileError, serializeConfiguration } from '@platformatic/foundation/lib/v4/index.js'
1
3
  import {
2
- createEnvFileTool,
3
- defaultPackageManager,
4
- findConfigurationFile,
5
- generateDashedName,
6
- kMetadata,
7
- loadConfiguration,
8
- loadConfigurationFile,
9
- safeRemove
10
- } from '@platformatic/foundation'
11
- import { BaseGenerator, envObjectToString, getApplicationTemplateFromSchemaUrl } from '@platformatic/generators'
4
+ BaseGenerator,
5
+ appendApplications,
6
+ envObjectToString,
7
+ findAnyConfigurationFile,
8
+ listedApplications,
9
+ rawSource,
10
+ readEnvFile,
11
+ getApplicationTemplateFromSchemaUrl
12
+ } from '@platformatic/generators'
12
13
  import { existsSync } from 'node:fs'
13
14
  import { readFile, readdir, stat } from 'node:fs/promises'
14
15
  import { createRequire } from 'node:module'
15
- import { basename, join } from 'node:path'
16
+ import { basename, join, resolve, sep } from 'node:path'
16
17
  import { pathToFileURL } from 'node:url'
17
- import { transform } from './config.js'
18
- import { schema } from './schema.js'
19
18
  import { getArrayDifference } from './utils.js'
20
19
 
21
20
  const engines = {
@@ -38,6 +37,11 @@ function getRuntimeBaseEnvVars (config) {
38
37
  }
39
38
  }
40
39
 
40
+ /*
41
+ The one thing an import changes about a root: which applications it lists. Applied to the module
42
+ the user has rather than to a re-rendering of it, so their formatting, their comments and the
43
+ expressions their values are written as all survive the edit.
44
+ */
41
45
  export class RuntimeGenerator extends BaseGenerator {
42
46
  constructor (opts) {
43
47
  super({
@@ -74,6 +78,7 @@ export class RuntimeGenerator extends BaseGenerator {
74
78
  async generatePackageJson () {
75
79
  const template = {
76
80
  name: `${this.runtimeName}`,
81
+ type: 'module',
77
82
  scripts: {
78
83
  dev: this.config.devCommand ?? 'wattpm dev',
79
84
  build: this.config.buildCommand ?? 'wattpm build',
@@ -120,17 +125,46 @@ export class RuntimeGenerator extends BaseGenerator {
120
125
  return
121
126
  }
122
127
  this._hasCheckedForExistingConfig = true
123
- const existingConfigFile = this.runtimeConfig ?? (await findConfigurationFile(this.targetDirectory, 'runtime'))
124
- if (existingConfigFile && existsSync(join(this.targetDirectory, existingConfigFile))) {
125
- this.existingConfigRaw = await loadConfigurationFile(join(this.targetDirectory, existingConfigFile))
126
- this.existingConfig = await loadConfiguration(join(this.targetDirectory, existingConfigFile), schema, {
127
- transform,
128
- ignoreProcessEnv: true
129
- })
128
+ /*
129
+ Whichever dialect the project is in: a v4 configuration is a module and is invisible to the
130
+ legacy lookup, so without this the wizard treats a project it scaffolded itself as a new one.
131
+ */
132
+ const existingConfigFile = await findAnyConfigurationFile(this.targetDirectory)
130
133
 
131
- const { PLT_ROOT, ...existingEnvironment } = this.existingConfig[kMetadata].env
132
- this.config.env = existingEnvironment
133
- this.existingApplications = this.existingConfig.applications.map(s => s.id)
134
+ if (existingConfigFile && existsSync(join(this.targetDirectory, existingConfigFile))) {
135
+ const existingConfigPath = join(this.targetDirectory, existingConfigFile)
136
+
137
+ this.existingConfigRaw = await this.readConfigurationFile(existingConfigPath, 'root', this.targetDirectory)
138
+ /*
139
+ The file as written, kept so that editing it can be an edit. Re-emitting from the evaluated
140
+ configuration would bake this machine's environment into the user's root -- a reference to
141
+ PLT_SERVER_LOGGER_LEVEL becomes whatever it happens to resolve to here -- and take every
142
+ comment with it.
143
+ */
144
+ this.existingConfigFile = existingConfigFile
145
+ this.existingConfigSource = await readFile(existingConfigPath, 'utf-8')
146
+
147
+ /*
148
+ A legacy root is refused with the hint every other v4 entry point gives, rather than loaded
149
+ and rewritten: continuing wrote the v4 module form over the .json file, and migrate is the
150
+ tool whose whole job is that conversion -- with the refusals and divergence reports the
151
+ wizard has no way to make.
152
+ */
153
+ if (existingConfigFile.endsWith('.json')) {
154
+ throw new LegacyConfigurationFileError(existingConfigPath)
155
+ } else {
156
+ /*
157
+ Derived from the root rather than loaded through it. A full load evaluates every
158
+ application's configuration, and those import their capabilities -- so the wizard would
159
+ need the project's dependencies installed before it could read the project, which is
160
+ exactly the state it is called in to fix.
161
+ */
162
+ const { PLT_ROOT, ...existingEnvironment } = await readEnvFile(this.targetDirectory)
163
+
164
+ this.config.env = existingEnvironment
165
+ this.existingConfig = this.existingConfigRaw
166
+ this.existingApplications = await this.#listExistingApplications(this.existingConfigRaw)
167
+ }
134
168
 
135
169
  this.updateRuntimeConfig(this.existingConfigRaw)
136
170
  this.updateRuntimeEnv(await readFile(join(this.targetDirectory, '.env'), 'utf-8'))
@@ -154,6 +188,15 @@ export class RuntimeGenerator extends BaseGenerator {
154
188
 
155
189
  setApplicationsConfigValues () {
156
190
  let newApplicationOrdinal = 0
191
+ // A runtime of exactly one application has no mesh of siblings to fall back on: with the v4
192
+ // entrypoint gone, a sole application that declares no port would bind nothing and be reachable
193
+ // from nowhere. So the sole application is marked as the entrypoint here -- the one place the
194
+ // whole application set is known -- and a capability that otherwise scaffolds no port reads the
195
+ // flag and exposes itself on PORT/3042 (@platformatic/node does; the framework capabilities,
196
+ // which also scaffold no port, can adopt the same flag). The count is the gate: the moment a
197
+ // second application exists, nothing is forced and a portless application stays mesh-only, which
198
+ // is what keeps "expose the one" from becoming "expose them all".
199
+ const totalApplications = this.existingApplications.length + this.applications.length
157
200
  this.applications.forEach(({ name, application }) => {
158
201
  if (!application.config) {
159
202
  // set default config
@@ -162,6 +205,7 @@ export class RuntimeGenerator extends BaseGenerator {
162
205
  const existingOrdinal = this.existingApplications.indexOf(name)
163
206
  const ordinal = existingOrdinal === -1 ? this.existingApplications.length + newApplicationOrdinal++ : existingOrdinal
164
207
  this.setApplicationPort(application, ordinal)
208
+ application.config.entrypoint = totalApplications === 1
165
209
  })
166
210
  }
167
211
 
@@ -171,6 +215,33 @@ export class RuntimeGenerator extends BaseGenerator {
171
215
  }
172
216
  }
173
217
 
218
+ /*
219
+ The root is spelled the same way an application is: it imports `defineConfig` from wattpm and
220
+ calls it, so the editor types and checks the shape the loader would otherwise only reject at
221
+ boot. The import means the file cannot be evaluated before its dependencies are installed --
222
+ which is why `wattpm install`/`create` install the root's own dependencies before reading it to
223
+ discover the applications (see installDependencies in wattpm-utils).
224
+ */
225
+ serializeConfigFile (config) {
226
+ /*
227
+ Unstamped while this line is still 3.x. The marker is a version declaration, and the loader
228
+ refuses a v3 one outright -- correctly, since that is how it catches a configuration nobody
229
+ migrated. It becomes writable, and worth writing, at 4.0.0.
230
+ */
231
+ const { $schema, module: _module, ...rest } = config
232
+ // Resolved like every other scaffolded value: the root's placeholders are the same kind of
233
+ // thing, and leaving them would write the placeholder's own text where an expression belongs.
234
+ const resolved = this.resolveScaffoldedPlaceholders(rest)
235
+
236
+ /*
237
+ The root is spelled the same way an application is: a factory call, `defineConfig` from
238
+ wattpm, whose whole job is to type its argument so the editor completes and checks the shape
239
+ the loader would otherwise only reject at boot. wattpm's defineConfig imports nothing, so the
240
+ root evaluates in the eval worker without paying for the runtime it would otherwise pull in.
241
+ */
242
+ return `import { defineConfig } from 'wattpm'\n\nexport default defineConfig(${serializeConfiguration(resolved)})\n`
243
+ }
244
+
174
245
  async _getConfigFileContents () {
175
246
  const config = {
176
247
  $schema: `https://schemas.platformatic.dev/wattpm/${this.platformaticVersion}.json`,
@@ -236,7 +307,11 @@ export class RuntimeGenerator extends BaseGenerator {
236
307
  }
237
308
  let basePath
238
309
  if (this.existingConfig) {
239
- basePath = this.existingConfig.autoload.path
310
+ /*
311
+ Resolved against the project, because the configuration says it relative to itself. The v3
312
+ loader handed back an absolute path; reading the file directly hands back what it says.
313
+ */
314
+ basePath = resolve(this.targetDirectory, this.existingConfig.autoload.path)
240
315
  } else {
241
316
  basePath = join(this.targetDirectory, this.config.autoload || this.applicationsFolder)
242
317
  }
@@ -287,11 +362,38 @@ export class RuntimeGenerator extends BaseGenerator {
287
362
  return (await import(pathToFileURL(fileToImport))).Generator
288
363
  }
289
364
 
365
+ /*
366
+ Which applications a root already describes: the entries it lists, and the directories its
367
+ autoload would discover. Neither needs an application's own configuration, which is the point.
368
+ */
369
+ async #listExistingApplications (config) {
370
+ const ids = (config.applications ?? config.services ?? config.web ?? []).map(entry => entry.id).filter(Boolean)
371
+
372
+ if (!config.autoload?.path) {
373
+ return ids
374
+ }
375
+
376
+ const exclude = config.autoload.exclude ?? []
377
+ const directory = join(this.targetDirectory, config.autoload.path)
378
+
379
+ for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
380
+ if (entry.isDirectory() && !exclude.includes(entry.name) && !ids.includes(entry.name)) {
381
+ ids.push(entry.name)
382
+ }
383
+ }
384
+
385
+ return ids
386
+ }
387
+
290
388
  async loadFromDir () {
291
389
  const output = {
292
390
  applications: []
293
391
  }
294
- const runtimePkgConfigFileData = JSON.parse(await readFile(join(this.targetDirectory, this.runtimeConfig), 'utf-8'))
392
+ const runtimePkgConfigFileData = await this.readConfigurationFile(
393
+ join(this.targetDirectory, await findAnyConfigurationFile(this.targetDirectory)),
394
+ 'root',
395
+ this.targetDirectory
396
+ )
295
397
  const applicationsPath = join(this.targetDirectory, runtimePkgConfigFileData.autoload.path)
296
398
 
297
399
  // load all applications
@@ -302,8 +404,14 @@ export class RuntimeGenerator extends BaseGenerator {
302
404
  const dirStat = await stat(currentApplicationPath)
303
405
  if (dirStat.isDirectory()) {
304
406
  // load the application config
305
- const configFile = await findConfigurationFile(currentApplicationPath)
306
- const applicationPltJson = JSON.parse(await readFile(join(currentApplicationPath, configFile), 'utf-8'))
407
+ const configFile = await findAnyConfigurationFile(currentApplicationPath)
408
+ // The environment is the runtime's: an application's expressions read the project's .env,
409
+ // which lives at the root beside the configuration that autoloads it.
410
+ const applicationPltJson = await this.readConfigurationFile(
411
+ join(currentApplicationPath, configFile),
412
+ 'application',
413
+ this.targetDirectory
414
+ )
307
415
  // get module to load
308
416
  const template = applicationPltJson.module || getApplicationTemplateFromSchemaUrl(applicationPltJson.$schema)
309
417
  const Generator = await this._getGeneratorForTemplate(currentApplicationPath, template)
@@ -350,8 +458,12 @@ export class RuntimeGenerator extends BaseGenerator {
350
458
 
351
459
  // delete dependencies
352
460
  const applicationPath = join(this.targetDirectory, this.applicationsFolder, s.name)
353
- const configFile = await findConfigurationFile(applicationPath)
354
- const applicationPackageJson = JSON.parse(await readFile(join(applicationPath, configFile), 'utf-8'))
461
+ const configFile = await findAnyConfigurationFile(applicationPath)
462
+ const applicationPackageJson = await this.readConfigurationFile(
463
+ join(applicationPath, configFile),
464
+ 'application',
465
+ this.targetDirectory
466
+ )
355
467
  if (applicationPackageJson.plugins && applicationPackageJson.plugins.packages) {
356
468
  applicationPackageJson.plugins.packages.forEach(p => {
357
469
  delete currrentPackageJson.dependencies[p.name]
@@ -468,14 +580,67 @@ export class RuntimeGenerator extends BaseGenerator {
468
580
  }
469
581
 
470
582
  updateRuntimeConfig (config) {
583
+ this.generatedConfig = config
584
+
585
+ // Editing what is there, rather than replacing it with a rendering of what it evaluated to.
586
+ if (this.existingConfigSource && !this.existingConfigFile.endsWith('.json')) {
587
+ this.addFile({
588
+ path: '',
589
+ file: this.existingConfigFile,
590
+ contents: this.#editExistingRoot(config),
591
+ tags: ['runtime-config']
592
+ })
593
+
594
+ return
595
+ }
596
+
471
597
  this.addFile({
472
598
  path: '',
473
- file: this.runtimeConfig,
474
- contents: JSON.stringify(config, null, 2),
599
+ file: this.existingConfigFile ?? this.configurationFileName(),
600
+ contents: this.serializeConfigFile(config),
475
601
  tags: ['runtime-config']
476
602
  })
477
603
  }
478
604
 
605
+ /*
606
+ An existing root is edited rather than rewritten, so that everything it says survives -- see
607
+ `appendApplications`. A shape it cannot edit is reported here rather than papered over by
608
+ writing the file from the loaded configuration, which would be a silent rewrite of every
609
+ reference the root contains.
610
+ */
611
+ #editExistingRoot (config) {
612
+ /*
613
+ The evaluated configuration arrives with autoload already expanded, so its application list
614
+ holds every discovered directory as an explicit entry with an absolute machine path.
615
+ Appending those would bake this machine's layout into the user's root -- and duplicate what
616
+ autoload will discover again on the next boot. Only entries autoload does not cover belong in
617
+ the file: the ones this run scaffolds outside the autoload directory, or a root with no
618
+ autoload at all.
619
+ */
620
+ const autoloadPath = config.autoload?.path
621
+ ? resolve(this.targetDirectory, config.autoload.path) + sep
622
+ : null
623
+ const additions = listedApplications(config).filter(entry => {
624
+ if (!autoloadPath || typeof entry.path !== 'string') {
625
+ return true
626
+ }
627
+
628
+ return !resolve(this.targetDirectory, entry.path).startsWith(autoloadPath)
629
+ })
630
+
631
+ const edited = appendApplications(this.existingConfigSource, additions, entry =>
632
+ this.resolveScaffoldedPlaceholders(entry, rawSource)
633
+ )
634
+
635
+ if (edited === null) {
636
+ throw new Error(
637
+ `Cannot add applications to ${this.existingConfigFile}: its default export is not a configuration object this tool can edit. Add them by hand.`
638
+ )
639
+ }
640
+
641
+ return edited
642
+ }
643
+
479
644
  updateRuntimeEnv (contents) {
480
645
  this.addFile({
481
646
  path: '',
@@ -497,6 +662,13 @@ export class WrappedGenerator extends BaseGenerator {
497
662
  async #updateEnvironment () {
498
663
  this.addEnvVars(getRuntimeBaseEnvVars(this.config), { overwrite: false, default: true })
499
664
 
665
+ // A wrapped project is a runtime of one application by construction, so that application is the
666
+ // one that must face the network -- otherwise the wrap would leave the code it was built around
667
+ // reachable from nowhere. The port is registered here so #createConfigFile can read it back as
668
+ // `Number(process.env.PORT || 3042)`; bare PORT rather than a scoped name, because the wrapped
669
+ // application is the root's own singular one and is what a hosting platform's PORT addresses.
670
+ this.addEnvVar('PORT', 3042, { overwrite: false, default: true })
671
+
500
672
  this.addFile({
501
673
  path: '',
502
674
  file: '.env',
@@ -551,15 +723,30 @@ export class WrappedGenerator extends BaseGenerator {
551
723
  }
552
724
 
553
725
  async #createConfigFile () {
726
+ /*
727
+ The wrapped single-app root. v3 spelled the runtime settings under a `runtime` key inside the
728
+ application's own configuration; v4 has no such block, so they are the root's own and the
729
+ application is the singular shorthand.
730
+ */
731
+ /*
732
+ The application is the root's own, named by the singular shorthand. Without it the root
733
+ describes a runtime with nothing in it -- it would load, and start none of the code it was
734
+ wrapped around.
735
+
736
+ Its capability is spelled as a plain object rather than a factory call because this file is
737
+ read before anything is installed, and an import cannot be resolved in that state.
738
+ */
554
739
  const config = {
555
- $schema: `https://schemas.platformatic.dev/${this.module}/${this.platformaticVersion}.json`,
556
- runtime: getRuntimeWrappableProperties()
740
+ ...this.resolveScaffoldedPlaceholders(getRuntimeWrappableProperties()),
741
+ application: {
742
+ config: this.resolveScaffoldedPlaceholders({ module: this.module, server: { port: '{PORT}' } })
743
+ }
557
744
  }
558
745
 
559
746
  this.addFile({
560
747
  path: '',
561
- file: 'watt.json',
562
- contents: JSON.stringify(config, null, 2)
748
+ file: this.configurationFileName(),
749
+ contents: `export default ${serializeConfiguration(config)}\n`
563
750
  })
564
751
  }
565
752
 
package/lib/logger.js CHANGED
@@ -191,13 +191,13 @@ export async function createLogger (config) {
191
191
 
192
192
  const multiStream = pino.multistream([{ stream: cliStream, level: loggerConfig.level }])
193
193
 
194
- if (config.telemetry && config.logger.openTelemetryExporter) {
194
+ if (config.tracing && config.logger.openTelemetryExporter) {
195
195
  const openTelemetryTransport = pino.transport({
196
196
  target: 'pino-opentelemetry-transport',
197
197
  options: {
198
198
  resourceAttributes: {
199
- 'service.name': config.telemetry.applicationName,
200
- 'service.version': config.telemetry.version
199
+ 'service.name': config.tracing.applicationName,
200
+ 'service.version': config.tracing.version
201
201
  },
202
202
  logRecordProcessorOptions: [
203
203
  {
@@ -99,18 +99,6 @@ export async function managementApiPlugin (app, opts) {
99
99
  return runtime.getRuntimeMetadata()
100
100
  })
101
101
 
102
- app.get('/config', quiet, async request => {
103
- const metadata = request.query.metadata === 'true'
104
- const rawConfig = await runtime.getRuntimeConfig(metadata)
105
-
106
- if (metadata) {
107
- const { [kMetadata]: __metadata, ...config } = rawConfig
108
- return { ...config, __metadata }
109
- }
110
-
111
- return rawConfig
112
- })
113
-
114
102
  app.get('/env', quiet, async () => {
115
103
  return { ...process.env, ...runtime.getRuntimeEnv() }
116
104
  })
@@ -301,7 +289,10 @@ export async function managementApiPlugin (app, opts) {
301
289
  })
302
290
 
303
291
  app.get('/metrics', quiet, async (req, reply) => {
304
- const config = await runtime.getRuntimeConfig()
292
+ // The live read: this needs one boolean, and the public getter builds a frozen snapshot of the
293
+ // whole configuration -- which is the right shape to hand a consumer and the wrong one to pay
294
+ // for on a scrape.
295
+ const config = runtime.getRuntimeConfig(true)
305
296
 
306
297
  if (config.metrics?.enabled === false) {
307
298
  reply.code(501)
@@ -324,53 +315,6 @@ export async function managementApiPlugin (app, opts) {
324
315
  return metrics
325
316
  })
326
317
 
327
- // TODO: Remove in next major version - deprecated endpoint
328
- app.get('/metrics/live', { ...quiet, websocket: true }, async socket => {
329
- const config = await runtime.getRuntimeConfig()
330
-
331
- if (config.metrics?.enabled === false) {
332
- socket.send(
333
- JSON.stringify({
334
- statusCode: 501,
335
- error: 'Not Implemented',
336
- message: 'Metrics are disabled.'
337
- }),
338
- () => {
339
- socket.close()
340
- }
341
- )
342
-
343
- return
344
- }
345
-
346
- const pollAndSendMetrics = async () => {
347
- try {
348
- const metrics = await runtime.getFormattedMetrics()
349
- if (metrics) {
350
- const serializedMetrics = JSON.stringify(metrics)
351
- socket.send(serializedMetrics + '\n')
352
- }
353
- } catch (error) {
354
- // If there's an error, stop polling and close the connection
355
- clearInterval(pollingInterval)
356
- socket.close()
357
- }
358
- }
359
-
360
- // Poll every second
361
- const pollingInterval = setInterval(pollAndSendMetrics, 1000)
362
-
363
- // Send initial metrics immediately
364
- await pollAndSendMetrics()
365
-
366
- const cleanup = () => {
367
- clearInterval(pollingInterval)
368
- }
369
-
370
- socket.on('error', cleanup)
371
- socket.on('close', cleanup)
372
- })
373
-
374
318
  app.get('/logs/live', { ...quiet, websocket: true }, async socket => {
375
319
  runtime.addLoggerDestination(createWebSocketStream(socket))
376
320
  })
package/lib/policies.js CHANGED
@@ -21,3 +21,30 @@ export function createChannelCreationHook (config) {
21
21
  return !forbidden.has(`${first.toLowerCase()}:${second.toLowerCase()}`)
22
22
  }
23
23
  }
24
+
25
+ export function createTargetPermissionHook (config) {
26
+ const denyList = config.policies?.deny
27
+
28
+ if (typeof denyList === 'undefined') {
29
+ return undefined
30
+ }
31
+
32
+ const forbidden = new Set()
33
+
34
+ for (let [first, unalloweds] of Object.entries(denyList)) {
35
+ for (let second of unalloweds) {
36
+ first = first.toLowerCase()
37
+ second = second.toLowerCase()
38
+
39
+ forbidden.add(`${first}:${second}`)
40
+ forbidden.add(`${second}:${first}`)
41
+ }
42
+ }
43
+
44
+ return function targetPermissionHook (req, target) {
45
+ const source = config.applicationId ?? req?.headers?.['x-plt-application-id']
46
+ const destination = target?.metadata?.applicationId
47
+
48
+ return !source || !destination || !forbidden.has(`${source.toLowerCase()}:${destination.toLowerCase()}`)
49
+ }
50
+ }