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

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({
@@ -120,17 +124,46 @@ export class RuntimeGenerator extends BaseGenerator {
120
124
  return
121
125
  }
122
126
  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
- })
127
+ /*
128
+ Whichever dialect the project is in: a v4 configuration is a module and is invisible to the
129
+ legacy lookup, so without this the wizard treats a project it scaffolded itself as a new one.
130
+ */
131
+ const existingConfigFile = await findAnyConfigurationFile(this.targetDirectory)
130
132
 
131
- const { PLT_ROOT, ...existingEnvironment } = this.existingConfig[kMetadata].env
132
- this.config.env = existingEnvironment
133
- this.existingApplications = this.existingConfig.applications.map(s => s.id)
133
+ if (existingConfigFile && existsSync(join(this.targetDirectory, existingConfigFile))) {
134
+ const existingConfigPath = join(this.targetDirectory, existingConfigFile)
135
+
136
+ this.existingConfigRaw = await this.readConfigurationFile(existingConfigPath, 'root', this.targetDirectory)
137
+ /*
138
+ The file as written, kept so that editing it can be an edit. Re-emitting from the evaluated
139
+ configuration would bake this machine's environment into the user's root -- a reference to
140
+ PLT_SERVER_LOGGER_LEVEL becomes whatever it happens to resolve to here -- and take every
141
+ comment with it.
142
+ */
143
+ this.existingConfigFile = existingConfigFile
144
+ this.existingConfigSource = await readFile(existingConfigPath, 'utf-8')
145
+
146
+ /*
147
+ A legacy root is refused with the hint every other v4 entry point gives, rather than loaded
148
+ and rewritten: continuing wrote the v4 module form over the .json file, and migrate is the
149
+ tool whose whole job is that conversion -- with the refusals and divergence reports the
150
+ wizard has no way to make.
151
+ */
152
+ if (existingConfigFile.endsWith('.json')) {
153
+ throw new LegacyConfigurationFileError(existingConfigPath)
154
+ } else {
155
+ /*
156
+ Derived from the root rather than loaded through it. A full load evaluates every
157
+ application's configuration, and those import their capabilities -- so the wizard would
158
+ need the project's dependencies installed before it could read the project, which is
159
+ exactly the state it is called in to fix.
160
+ */
161
+ const { PLT_ROOT, ...existingEnvironment } = await readEnvFile(this.targetDirectory)
162
+
163
+ this.config.env = existingEnvironment
164
+ this.existingConfig = this.existingConfigRaw
165
+ this.existingApplications = await this.#listExistingApplications(this.existingConfigRaw)
166
+ }
134
167
 
135
168
  this.updateRuntimeConfig(this.existingConfigRaw)
136
169
  this.updateRuntimeEnv(await readFile(join(this.targetDirectory, '.env'), 'utf-8'))
@@ -171,6 +204,30 @@ export class RuntimeGenerator extends BaseGenerator {
171
204
  }
172
205
  }
173
206
 
207
+ /*
208
+ The root is the one v4 configuration that is not a capability factory call: it imports
209
+ `defineConfig` from wattpm, whose whole job is to type its argument.
210
+ */
211
+ /*
212
+ The stamped plain-object form rather than an imported `defineConfig`, which is the machine-writer
213
+ case the `$schema` marker exists for: the wizard writes this file before anything is installed
214
+ and reads it back on a later run, so a root that imported `wattpm` could not be read in the
215
+ state that produced it. A person editing the file afterwards can switch to the imported form.
216
+ */
217
+ serializeConfigFile (config) {
218
+ /*
219
+ Unstamped while this line is still 3.x. The marker is a version declaration, and the loader
220
+ refuses a v3 one outright -- correctly, since that is how it catches a configuration nobody
221
+ migrated. It becomes writable, and worth writing, at 4.0.0.
222
+ */
223
+ const { $schema, module: _module, ...rest } = config
224
+ // Resolved like every other scaffolded value: the root's placeholders are the same kind of
225
+ // thing, and leaving them would write the placeholder's own text where an expression belongs.
226
+ const resolved = this.resolveScaffoldedPlaceholders(rest)
227
+
228
+ return `export default ${serializeConfiguration(resolved)}\n`
229
+ }
230
+
174
231
  async _getConfigFileContents () {
175
232
  const config = {
176
233
  $schema: `https://schemas.platformatic.dev/wattpm/${this.platformaticVersion}.json`,
@@ -236,7 +293,11 @@ export class RuntimeGenerator extends BaseGenerator {
236
293
  }
237
294
  let basePath
238
295
  if (this.existingConfig) {
239
- basePath = this.existingConfig.autoload.path
296
+ /*
297
+ Resolved against the project, because the configuration says it relative to itself. The v3
298
+ loader handed back an absolute path; reading the file directly hands back what it says.
299
+ */
300
+ basePath = resolve(this.targetDirectory, this.existingConfig.autoload.path)
240
301
  } else {
241
302
  basePath = join(this.targetDirectory, this.config.autoload || this.applicationsFolder)
242
303
  }
@@ -287,11 +348,38 @@ export class RuntimeGenerator extends BaseGenerator {
287
348
  return (await import(pathToFileURL(fileToImport))).Generator
288
349
  }
289
350
 
351
+ /*
352
+ Which applications a root already describes: the entries it lists, and the directories its
353
+ autoload would discover. Neither needs an application's own configuration, which is the point.
354
+ */
355
+ async #listExistingApplications (config) {
356
+ const ids = (config.applications ?? config.services ?? config.web ?? []).map(entry => entry.id).filter(Boolean)
357
+
358
+ if (!config.autoload?.path) {
359
+ return ids
360
+ }
361
+
362
+ const exclude = config.autoload.exclude ?? []
363
+ const directory = join(this.targetDirectory, config.autoload.path)
364
+
365
+ for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
366
+ if (entry.isDirectory() && !exclude.includes(entry.name) && !ids.includes(entry.name)) {
367
+ ids.push(entry.name)
368
+ }
369
+ }
370
+
371
+ return ids
372
+ }
373
+
290
374
  async loadFromDir () {
291
375
  const output = {
292
376
  applications: []
293
377
  }
294
- const runtimePkgConfigFileData = JSON.parse(await readFile(join(this.targetDirectory, this.runtimeConfig), 'utf-8'))
378
+ const runtimePkgConfigFileData = await this.readConfigurationFile(
379
+ join(this.targetDirectory, await findAnyConfigurationFile(this.targetDirectory)),
380
+ 'root',
381
+ this.targetDirectory
382
+ )
295
383
  const applicationsPath = join(this.targetDirectory, runtimePkgConfigFileData.autoload.path)
296
384
 
297
385
  // load all applications
@@ -302,8 +390,14 @@ export class RuntimeGenerator extends BaseGenerator {
302
390
  const dirStat = await stat(currentApplicationPath)
303
391
  if (dirStat.isDirectory()) {
304
392
  // load the application config
305
- const configFile = await findConfigurationFile(currentApplicationPath)
306
- const applicationPltJson = JSON.parse(await readFile(join(currentApplicationPath, configFile), 'utf-8'))
393
+ const configFile = await findAnyConfigurationFile(currentApplicationPath)
394
+ // The environment is the runtime's: an application's expressions read the project's .env,
395
+ // which lives at the root beside the configuration that autoloads it.
396
+ const applicationPltJson = await this.readConfigurationFile(
397
+ join(currentApplicationPath, configFile),
398
+ 'application',
399
+ this.targetDirectory
400
+ )
307
401
  // get module to load
308
402
  const template = applicationPltJson.module || getApplicationTemplateFromSchemaUrl(applicationPltJson.$schema)
309
403
  const Generator = await this._getGeneratorForTemplate(currentApplicationPath, template)
@@ -350,8 +444,12 @@ export class RuntimeGenerator extends BaseGenerator {
350
444
 
351
445
  // delete dependencies
352
446
  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'))
447
+ const configFile = await findAnyConfigurationFile(applicationPath)
448
+ const applicationPackageJson = await this.readConfigurationFile(
449
+ join(applicationPath, configFile),
450
+ 'application',
451
+ this.targetDirectory
452
+ )
355
453
  if (applicationPackageJson.plugins && applicationPackageJson.plugins.packages) {
356
454
  applicationPackageJson.plugins.packages.forEach(p => {
357
455
  delete currrentPackageJson.dependencies[p.name]
@@ -468,14 +566,67 @@ export class RuntimeGenerator extends BaseGenerator {
468
566
  }
469
567
 
470
568
  updateRuntimeConfig (config) {
569
+ this.generatedConfig = config
570
+
571
+ // Editing what is there, rather than replacing it with a rendering of what it evaluated to.
572
+ if (this.existingConfigSource && !this.existingConfigFile.endsWith('.json')) {
573
+ this.addFile({
574
+ path: '',
575
+ file: this.existingConfigFile,
576
+ contents: this.#editExistingRoot(config),
577
+ tags: ['runtime-config']
578
+ })
579
+
580
+ return
581
+ }
582
+
471
583
  this.addFile({
472
584
  path: '',
473
- file: this.runtimeConfig,
474
- contents: JSON.stringify(config, null, 2),
585
+ file: this.existingConfigFile ?? this.configurationFileName(),
586
+ contents: this.serializeConfigFile(config),
475
587
  tags: ['runtime-config']
476
588
  })
477
589
  }
478
590
 
591
+ /*
592
+ An existing root is edited rather than rewritten, so that everything it says survives -- see
593
+ `appendApplications`. A shape it cannot edit is reported here rather than papered over by
594
+ writing the file from the loaded configuration, which would be a silent rewrite of every
595
+ reference the root contains.
596
+ */
597
+ #editExistingRoot (config) {
598
+ /*
599
+ The evaluated configuration arrives with autoload already expanded, so its application list
600
+ holds every discovered directory as an explicit entry with an absolute machine path.
601
+ Appending those would bake this machine's layout into the user's root -- and duplicate what
602
+ autoload will discover again on the next boot. Only entries autoload does not cover belong in
603
+ the file: the ones this run scaffolds outside the autoload directory, or a root with no
604
+ autoload at all.
605
+ */
606
+ const autoloadPath = config.autoload?.path
607
+ ? resolve(this.targetDirectory, config.autoload.path) + sep
608
+ : null
609
+ const additions = listedApplications(config).filter(entry => {
610
+ if (!autoloadPath || typeof entry.path !== 'string') {
611
+ return true
612
+ }
613
+
614
+ return !resolve(this.targetDirectory, entry.path).startsWith(autoloadPath)
615
+ })
616
+
617
+ const edited = appendApplications(this.existingConfigSource, additions, entry =>
618
+ this.resolveScaffoldedPlaceholders(entry, rawSource)
619
+ )
620
+
621
+ if (edited === null) {
622
+ throw new Error(
623
+ `Cannot add applications to ${this.existingConfigFile}: its default export is not a configuration object this tool can edit. Add them by hand.`
624
+ )
625
+ }
626
+
627
+ return edited
628
+ }
629
+
479
630
  updateRuntimeEnv (contents) {
480
631
  this.addFile({
481
632
  path: '',
@@ -551,15 +702,28 @@ export class WrappedGenerator extends BaseGenerator {
551
702
  }
552
703
 
553
704
  async #createConfigFile () {
705
+ /*
706
+ The wrapped single-app root. v3 spelled the runtime settings under a `runtime` key inside the
707
+ application's own configuration; v4 has no such block, so they are the root's own and the
708
+ application is the singular shorthand.
709
+ */
710
+ /*
711
+ The application is the root's own, named by the singular shorthand. Without it the root
712
+ describes a runtime with nothing in it -- it would load, and start none of the code it was
713
+ wrapped around.
714
+
715
+ Its capability is spelled as a plain object rather than a factory call because this file is
716
+ read before anything is installed, and an import cannot be resolved in that state.
717
+ */
554
718
  const config = {
555
- $schema: `https://schemas.platformatic.dev/${this.module}/${this.platformaticVersion}.json`,
556
- runtime: getRuntimeWrappableProperties()
719
+ ...this.resolveScaffoldedPlaceholders(getRuntimeWrappableProperties()),
720
+ application: { config: { module: this.module } }
557
721
  }
558
722
 
559
723
  this.addFile({
560
724
  path: '',
561
- file: 'watt.json',
562
- contents: JSON.stringify(config, null, 2)
725
+ file: this.configurationFileName(),
726
+ contents: `export default ${serializeConfiguration(config)}\n`
563
727
  })
564
728
  }
565
729
 
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
+ }