@toa.io/extensions.configuration 1.0.0-alpha.284 → 1.0.0-alpha.286

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.
Files changed (92) hide show
  1. package/components/configuration.values/manifest.toa.yaml +14 -21
  2. package/components/configuration.values/migrations/0001-system-properties.yaml +14 -0
  3. package/components/configuration.values/migrations/0002-indexes.yaml +7 -0
  4. package/components/configuration.values/operations/create.js +1 -2
  5. package/components/configuration.values/operations/fetch.js +1 -1
  6. package/components/configuration.values/operations/get.js +1 -1
  7. package/components/configuration.values/operations/lib/map.js +1 -1
  8. package/components/configuration.values/operations/lib/resolve.js +3 -3
  9. package/components/configuration.values/operations/list.js +1 -1
  10. package/components/configuration.values/source/create.ts +3 -5
  11. package/components/configuration.values/source/fetch.ts +12 -10
  12. package/components/configuration.values/source/get.ts +2 -3
  13. package/components/configuration.values/source/lib/map.test.ts +7 -2
  14. package/components/configuration.values/source/lib/map.ts +3 -3
  15. package/components/configuration.values/source/lib/resolve.ts +9 -6
  16. package/components/configuration.values/source/list.ts +8 -6
  17. package/components/configuration.values/tsconfig.tsbuildinfo +1 -1
  18. package/components/configuration.values/types/toa.d.ts +5 -5
  19. package/cucumber.mjs +3 -0
  20. package/features/site/index.html +6 -2
  21. package/features/steps/UI.ts +20 -13
  22. package/notes/consistency.md +10 -9
  23. package/package.json +6 -6
  24. package/readme.md +15 -14
  25. package/schemas/annotation.cos.yaml +1 -1
  26. package/source/Aspect.test.ts +32 -8
  27. package/source/Aspect.ts +28 -19
  28. package/source/Client.test.ts +31 -14
  29. package/source/Client.ts +41 -48
  30. package/source/Composition.ts +6 -6
  31. package/source/Factory.ts +7 -7
  32. package/source/Secret.test.ts +1 -1
  33. package/source/Secret.ts +5 -5
  34. package/source/UI.ts +33 -32
  35. package/source/configuration.test.ts +14 -8
  36. package/source/configuration.ts +11 -15
  37. package/source/const.ts +13 -1
  38. package/source/context.ts +2 -2
  39. package/source/deployment.test.ts +37 -12
  40. package/source/deployment.ts +45 -19
  41. package/source/epoch.test.ts +8 -2
  42. package/source/epoch.ts +6 -5
  43. package/source/index.ts +1 -1
  44. package/source/manifest.test.ts +12 -6
  45. package/source/manifest.ts +1 -1
  46. package/transpiled/Aspect.d.ts +2 -1
  47. package/transpiled/Aspect.js +15 -3
  48. package/transpiled/Aspect.js.map +1 -1
  49. package/transpiled/Client.d.ts +1 -1
  50. package/transpiled/Client.js +8 -2
  51. package/transpiled/Client.js.map +1 -1
  52. package/transpiled/Composition.js +2 -2
  53. package/transpiled/Composition.js.map +1 -1
  54. package/transpiled/Factory.d.ts +2 -1
  55. package/transpiled/Factory.js.map +1 -1
  56. package/transpiled/Secret.js.map +1 -1
  57. package/transpiled/UI.js +2 -1
  58. package/transpiled/UI.js.map +1 -1
  59. package/transpiled/configuration.js.map +1 -1
  60. package/transpiled/const.d.ts +8 -1
  61. package/transpiled/const.js +10 -0
  62. package/transpiled/const.js.map +1 -1
  63. package/transpiled/context.d.ts +1 -1
  64. package/transpiled/context.js.map +1 -1
  65. package/transpiled/deployment.d.ts +3 -0
  66. package/transpiled/deployment.js +19 -5
  67. package/transpiled/deployment.js.map +1 -1
  68. package/transpiled/epoch.js +3 -1
  69. package/transpiled/epoch.js.map +1 -1
  70. package/transpiled/index.d.ts +1 -1
  71. package/transpiled/index.js +1 -1
  72. package/transpiled/index.js.map +1 -1
  73. package/transpiled/manifest.js.map +1 -1
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/ui/dist/_app/immutable/chunks/{mvspXr3J.js → 6fUKYVLr.js} +2 -2
  76. package/ui/dist/_app/immutable/chunks/{p9RyiXQ-.js → BGxZNgfH.js} +1 -1
  77. package/ui/dist/_app/immutable/chunks/{Bxde3uE_.js → By3hKJVT.js} +1 -1
  78. package/ui/dist/_app/immutable/chunks/DamfpXnK.js +1 -0
  79. package/ui/dist/_app/immutable/chunks/{_I_MUoRn.js → vHown6GG.js} +1 -1
  80. package/ui/dist/_app/immutable/entry/{app.C1VIepN7.js → app.cjzrCTnf.js} +2 -2
  81. package/ui/dist/_app/immutable/entry/start.C18omPso.js +1 -0
  82. package/ui/dist/_app/immutable/nodes/{0.D_0dtFej.js → 0.DjKXI3st.js} +1 -1
  83. package/ui/dist/_app/immutable/nodes/{1.DFlgHh-H.js → 1.Dh71z_Aw.js} +1 -1
  84. package/ui/dist/_app/immutable/nodes/{2.BIHgAxXR.js → 2.TzrOmdn2.js} +1 -1
  85. package/ui/dist/_app/immutable/nodes/3.DlvWZctZ.js +1 -0
  86. package/ui/dist/_app/immutable/nodes/{4.BZMuJGfA.js → 4.D8kjaqXl.js} +1 -1
  87. package/ui/dist/_app/version.json +1 -1
  88. package/ui/dist/index.html +28 -23
  89. package/CHANGELOG.md +0 -372
  90. package/ui/dist/_app/immutable/chunks/BzG-gka8.js +0 -1
  91. package/ui/dist/_app/immutable/entry/start.BQWCs_Pq.js +0 -1
  92. package/ui/dist/_app/immutable/nodes/3.R-_yCt5X.js +0 -1
@@ -23,7 +23,7 @@ it('should not show in JSON', () => {
23
23
  it('should not show when inspected', () => {
24
24
  assert.deepStrictEqual(inspect(secret), REDACTED)
25
25
  assert.ok(inspect({ key: secret }).includes(REDACTED))
26
- assert.ok(!(inspect({ key: secret }).includes('s3cret')))
26
+ assert.ok(!inspect({ key: secret }).includes('s3cret'))
27
27
  })
28
28
 
29
29
  it('should not expose the value as a property', () => {
package/source/Secret.ts CHANGED
@@ -8,23 +8,23 @@ import { inspect } from 'node:util'
8
8
  export class Secret {
9
9
  readonly #value: string
10
10
 
11
- public constructor (value: string) {
11
+ public constructor(value: string) {
12
12
  this.#value = value
13
13
  }
14
14
 
15
- public unwrap (): string {
15
+ public unwrap(): string {
16
16
  return this.#value
17
17
  }
18
18
 
19
- public toString (): string {
19
+ public toString(): string {
20
20
  return REDACTED
21
21
  }
22
22
 
23
- public toJSON (): string {
23
+ public toJSON(): string {
24
24
  return REDACTED
25
25
  }
26
26
 
27
- public [inspect.custom] (): string {
27
+ public [inspect.custom](): string {
28
28
  return REDACTED
29
29
  }
30
30
  }
package/source/UI.ts CHANGED
@@ -17,7 +17,7 @@ export class UI extends Connector {
17
17
  private readonly port: number
18
18
  private readonly root: string
19
19
 
20
- public constructor (port: number, root: string = SITE) {
20
+ public constructor(port: number, root: string = SITE) {
21
21
  super()
22
22
 
23
23
  this.port = port
@@ -27,7 +27,7 @@ export class UI extends Connector {
27
27
  })
28
28
  }
29
29
 
30
- protected override async open (): Promise<void> {
30
+ protected override async open(): Promise<void> {
31
31
  /*
32
32
  * A taken port is a real error: uniqueness across services is settled at export
33
33
  * time, so nothing here has to negotiate for one.
@@ -54,10 +54,12 @@ export class UI extends Connector {
54
54
  console.info('Configuration UI started', { port: this.port, path: UI_PATH + '/' })
55
55
  }
56
56
 
57
- protected override async close (): Promise<void> {
58
- const closed = new Promise<void>((resolve) => this.server.once('close', () => {
59
- resolve()
60
- }))
57
+ protected override async close(): Promise<void> {
58
+ const closed = new Promise<void>((resolve) =>
59
+ this.server.once('close', () => {
60
+ resolve()
61
+ })
62
+ )
61
63
 
62
64
  this.server.close()
63
65
  this.server.closeAllConnections()
@@ -65,16 +67,18 @@ export class UI extends Connector {
65
67
  await closed
66
68
  }
67
69
 
68
- private listen (request: http.IncomingMessage, response: http.ServerResponse): void {
70
+ private listen(request: http.IncomingMessage, response: http.ServerResponse): void {
69
71
  void this.respond(request, response).catch((error: Error) => {
70
72
  console.error('Configuration UI failure', { message: error.message })
71
73
 
72
- if (!response.writableEnded)
73
- response.writeHead(500).end()
74
+ if (!response.writableEnded) response.writeHead(500).end()
74
75
  })
75
76
  }
76
77
 
77
- private async respond (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
78
+ private async respond(
79
+ request: http.IncomingMessage,
80
+ response: http.ServerResponse
81
+ ): Promise<void> {
78
82
  if (request.method !== 'GET' && request.method !== 'HEAD') {
79
83
  response.writeHead(405, { allow: 'GET, HEAD' }).end()
80
84
 
@@ -98,10 +102,8 @@ export class UI extends Connector {
98
102
 
99
103
  const file = this.resolve(pathname)
100
104
 
101
- if (file === null)
102
- response.writeHead(404).end()
103
- else
104
- await this.send(file, request, response)
105
+ if (file === null) response.writeHead(404).end()
106
+ else await this.send(file, request, response)
105
107
  }
106
108
 
107
109
  /**
@@ -109,22 +111,18 @@ export class UI extends Connector {
109
111
  * served as it is; anything else that could be a route falls back to the page,
110
112
  * because the client router — not this server — knows what routes there are.
111
113
  */
112
- private resolve (pathname: string): string | null {
113
- if (!pathname.startsWith(UI_PATH))
114
- return null
114
+ private resolve(pathname: string): string | null {
115
+ if (!pathname.startsWith(UI_PATH)) return null
115
116
 
116
117
  const relative = pathname.slice(UI_PATH.length)
117
118
 
118
- if (relative !== '' && !relative.startsWith('/'))
119
- return null
119
+ if (relative !== '' && !relative.startsWith('/')) return null
120
120
 
121
121
  const file = path.join(this.root, relative)
122
122
 
123
- if (file !== this.root && !file.startsWith(this.root + path.sep))
124
- return null
123
+ if (file !== this.root && !file.startsWith(this.root + path.sep)) return null
125
124
 
126
- if (isFile(file))
127
- return file
125
+ if (isFile(file)) return file
128
126
 
129
127
  /*
130
128
  * A missing asset is missing, but a route can look like one: `identity.passkeys` is a
@@ -137,11 +135,16 @@ export class UI extends Connector {
137
135
  return asset ? null : path.join(this.root, 'index.html')
138
136
  }
139
137
 
140
- private async send (file: string, request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
138
+ private async send(
139
+ file: string,
140
+ request: http.IncomingMessage,
141
+ response: http.ServerResponse
142
+ ): Promise<void> {
141
143
  const stats = await fs.promises.stat(file).catch(() => null)
142
144
 
143
145
  if (stats === null) {
144
- response.writeHead(503, { 'content-type': 'text/plain' })
146
+ response
147
+ .writeHead(503, { 'content-type': 'text/plain' })
145
148
  .end('The configuration UI is not built. Run `npm run build:ui`.\n')
146
149
 
147
150
  return
@@ -153,14 +156,12 @@ export class UI extends Connector {
153
156
  'cache-control': caching(path.relative(this.root, file))
154
157
  })
155
158
 
156
- if (request.method === 'HEAD')
157
- response.end()
158
- else
159
- fs.createReadStream(file).pipe(response)
159
+ if (request.method === 'HEAD') response.end()
160
+ else fs.createReadStream(file).pipe(response)
160
161
  }
161
162
  }
162
163
 
163
- function decode (url: string): string | null {
164
+ function decode(url: string): string | null {
164
165
  try {
165
166
  return decodeURIComponent(url.split('?')[0])
166
167
  } catch {
@@ -168,7 +169,7 @@ function decode (url: string): string | null {
168
169
  }
169
170
  }
170
171
 
171
- function isFile (file: string): boolean {
172
+ function isFile(file: string): boolean {
172
173
  return fs.existsSync(file) && fs.statSync(file).isFile()
173
174
  }
174
175
 
@@ -188,7 +189,7 @@ const DAY = 'public, max-age=86400'
188
189
  * and their names carry the build. An icon is named for what it is rather than for its
189
190
  * content, so it is asked about again, but not on every page load.
190
191
  */
191
- function caching (relative: string): string {
192
+ function caching(relative: string): string {
192
193
  if (relative.startsWith(IMMUTABLE)) return FOREVER
193
194
 
194
195
  return ICONS.has(relative) ? DAY : 'no-cache'
@@ -21,8 +21,7 @@ beforeEach(() => {
21
21
  })
22
22
 
23
23
  afterEach(() => {
24
- for (const name of used)
25
- delete process.env[name]
24
+ for (const name of used) delete process.env[name]
26
25
 
27
26
  used = []
28
27
  })
@@ -92,14 +91,21 @@ describe('local', () => {
92
91
  manifest.schema = {
93
92
  type: 'object',
94
93
  properties: {
95
- foo: { type: 'string', default: 'hello' },
94
+ foo: { type: 'string' },
96
95
  bar: { type: 'number' }
97
96
  }
98
97
  }
99
98
 
100
99
  set({ bar: 5 })
101
100
 
102
- assert.deepStrictEqual(local(locator, manifest), { foo: 'hello', bar: 5 })
101
+ assert.deepStrictEqual(local(locator, manifest), { bar: 5 })
102
+
103
+ set({ bar: 'five' })
104
+
105
+ assert.throws(
106
+ () => local(locator, manifest),
107
+ (error: Error) => /must be number/.test(error.message)
108
+ )
103
109
  })
104
110
  })
105
111
 
@@ -109,18 +115,18 @@ describe('fit', () => {
109
115
  type: 'object',
110
116
  properties: {
111
117
  foo: { type: 'string' },
112
- bar: { type: 'number', default: 1 }
118
+ bar: { type: 'number' }
113
119
  }
114
120
  }
115
121
 
116
122
  set('secret', '_FOO')
117
123
 
118
- const raw = { foo: '$FOO' }
124
+ const raw = { foo: '$FOO', bar: 1 }
119
125
  const values = fit(raw, manifest)
120
126
 
121
127
  assert.deepStrictEqual((values.foo as Secret).unwrap(), 'secret')
122
128
  assert.deepStrictEqual(values.bar, 1)
123
- assert.deepStrictEqual(raw, { foo: '$FOO' }) // untouched
129
+ assert.deepStrictEqual(raw, { foo: '$FOO', bar: 1 }) // untouched
124
130
  })
125
131
 
126
132
  it('should not apply the manifest defaults', async () => {
@@ -134,7 +140,7 @@ describe('fit', () => {
134
140
  })
135
141
  })
136
142
 
137
- function set (value: object | string, key = locator.uppercase): void {
143
+ function set(value: object | string, key = locator.uppercase): void {
138
144
  const string = typeof value === 'string' ? value : JSON.stringify(value)
139
145
  const name = 'TOA_CONFIGURATION_' + key
140
146
 
@@ -7,22 +7,21 @@ import type { Schema } from '@toa.io/schemas'
7
7
  import type { Manifest } from './manifest.js'
8
8
 
9
9
  /** The variable is set, so the values service is not consulted. */
10
- export function overridden (locator: Locator): boolean {
10
+ export function overridden(locator: Locator): boolean {
11
11
  return process.env[PREFIX + locator.uppercase] !== undefined
12
12
  }
13
13
 
14
14
  /** The variable, the manifest defaults, then the schema. */
15
- export function local (locator: Locator, manifest: Manifest): Node {
15
+ export function local(locator: Locator, manifest: Manifest): Node {
16
16
  const values = read(locator.uppercase)
17
17
 
18
- if (manifest.defaults !== undefined)
19
- add(values, manifest.defaults)
18
+ if (manifest.defaults !== undefined) add(values, manifest.defaults)
20
19
 
21
20
  return fit(values, manifest)
22
21
  }
23
22
 
24
23
  /** A copy of the value with the schema applied and the secrets substituted. */
25
- export function fit (raw: object, manifest: Manifest): Node {
24
+ export function fit(raw: object, manifest: Manifest): Node {
26
25
  // a copy of the caller's, and one of this realm: what came over the wire is JSON anyway
27
26
  const values = JSON.parse(JSON.stringify(raw)) as Node
28
27
 
@@ -33,26 +32,23 @@ export function fit (raw: object, manifest: Manifest): Node {
33
32
  return values
34
33
  }
35
34
 
36
- function validate (values: Node, manifest: Manifest): void {
35
+ function validate(values: Node, manifest: Manifest): void {
37
36
  const schema: Schema<any> = schemas.schema(manifest.schema)
38
37
 
39
38
  schema.validate(values)
40
39
  }
41
40
 
42
- function read (suffix: string): Node {
41
+ function read(suffix: string): Node {
43
42
  const variable = PREFIX + suffix
44
43
  const string = process.env[variable]
45
44
 
46
- if (string === undefined)
47
- return {}
48
- else
49
- return JSON.parse(string)
45
+ if (string === undefined) return {}
46
+ else return JSON.parse(string)
50
47
  }
51
48
 
52
- function substituteSecrets (configuration: Node): void {
49
+ function substituteSecrets(configuration: Node): void {
53
50
  for (const [key, value] of Object.entries(configuration)) {
54
- if (typeof value === 'object' && value !== null)
55
- substituteSecrets(value as Node)
51
+ if (typeof value === 'object' && value !== null) substituteSecrets(value as Node)
56
52
 
57
53
  if (typeof value !== 'string') continue
58
54
 
@@ -66,7 +62,7 @@ function substituteSecrets (configuration: Node): void {
66
62
  }
67
63
  }
68
64
 
69
- function getSecret (name: string): string {
65
+ function getSecret(name: string): string {
70
66
  const variable = PREFIX + '_' + name
71
67
  const value = process.env[variable]
72
68
 
package/source/const.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Source } from '@toa.io/core'
1
+ import type { Source } from '@toa.io/core/types'
2
2
 
3
3
  /** Per-component variables: the local override and the secrets. */
4
4
  export const PREFIX = 'TOA_CONFIGURATION_'
@@ -12,6 +12,18 @@ export const SECRET_RX = /^\$(?<variable>[A-Z0-9_]{1,32})$/
12
12
  export const UI_PATH = '/.configuration'
13
13
  export const UI_PORT = 8003
14
14
 
15
+ /**
16
+ * The port the UI is served on. `UI_PORT` is what a deployment publishes and what the chart
17
+ * renders; the variable is for a machine that runs more than one Toa process — a Toa checkout
18
+ * beside an application, or two applications — where one of them has to give it up.
19
+ */
20
+ export const UI_PORT_ENV = 'TOA_CONFIGURATION_UI_PORT'
21
+ export function uiPort(): number {
22
+ const value = process.env[UI_PORT_ENV]
23
+
24
+ return value === undefined ? UI_PORT : Number(value)
25
+ }
26
+
15
27
  export const EVENT = 'configuration.values.created'
16
28
 
17
29
  export const SOURCE: Source = { service: 'configuration' }
package/source/context.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { Manifest } from './manifest.js'
2
- import type { Contribution } from '@toa.io/core'
2
+ import type { Contribution } from '@toa.io/core/types'
3
3
 
4
4
  /** What this extension puts on the context of a component that declares it. */
5
- export function context (declaration: Manifest): Contribution {
5
+ export function context(declaration: Manifest): Contribution {
6
6
  const contribution: Contribution = { name: 'configuration', schema: declaration.schema }
7
7
 
8
8
  // a value the schema declares a secret is one on the context, not a string
@@ -2,7 +2,12 @@ import { it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
 
4
4
  import { Locator } from '@toa.io/core'
5
- import { type Annotation, type Instance, deployment, describe as map } from './deployment.js'
5
+ import {
6
+ type Annotation,
7
+ type Instance,
8
+ deployment,
9
+ describe as map
10
+ } from './deployment.js'
6
11
  import { epoch } from './epoch.js'
7
12
  import { EVENT, VALUES } from './const.js'
8
13
 
@@ -11,7 +16,7 @@ const schema = {
11
16
  properties: { foo: { type: 'string' }, key: { type: 'string' } }
12
17
  }
13
18
 
14
- function instance (name: string, defaults?: Record<string, unknown>): Instance {
19
+ function instance(name: string, defaults?: Record<string, unknown>): Instance {
15
20
  return {
16
21
  locator: new Locator(name, 'configuration'),
17
22
  manifest: { schema, defaults },
@@ -22,11 +27,18 @@ function instance (name: string, defaults?: Record<string, unknown>): Instance {
22
27
  it('should validate annotation', async () => {
23
28
  const wrongType = 'not ok' as unknown as Annotation
24
29
 
25
- assert.throws(() => deployment([], wrongType), (error: any) => /object/.test(error.message))
30
+ assert.throws(
31
+ () => deployment([], wrongType),
32
+ (error: any) => /object/.test(error.message)
33
+ )
26
34
  })
27
35
 
28
36
  it('should reject unknown components', async () => {
29
- assert.throws(() => deployment([instance('base')], { 'configuration.nope': {} }), (error: any) => /Component 'configuration\.nope' does not request configuration/.test(error.message))
37
+ assert.throws(
38
+ () => deployment([instance('base')], { 'configuration.nope': {} }),
39
+ (error: any) =>
40
+ /Component 'configuration\.nope' does not request configuration/.test(error.message)
41
+ )
30
42
  })
31
43
 
32
44
  it('should deploy the values service with the map', async () => {
@@ -61,27 +73,40 @@ it('should describe every component', async () => {
61
73
  })
62
74
 
63
75
  it('should prefer the context over the manifest defaults', async () => {
64
- const values = map([instance('base', { foo: 'hello' })], { 'configuration.base': { foo: 'bye' } })
76
+ const values = map([instance('base', { foo: 'hello' })], {
77
+ 'configuration.base': { foo: 'bye' }
78
+ })
65
79
 
66
80
  assert.deepStrictEqual(values['configuration.base'].defaults, { foo: 'bye' })
67
81
  })
68
82
 
69
83
  it('should not map values to the component', async () => {
70
- const dependency = deployment([instance('base')], { 'configuration.base': { foo: 'set' } })
84
+ const dependency = deployment([instance('base')], {
85
+ 'configuration.base': { foo: 'set' }
86
+ })
71
87
 
72
88
  assert.deepStrictEqual(dependency.variables, {})
73
89
  })
74
90
 
75
91
  it('should map secrets to the component', async () => {
76
- const dependency = deployment([instance('base')], { 'configuration.base': { key: '$KEY' } })
92
+ const dependency = deployment([instance('base')], {
93
+ 'configuration.base': { key: '$KEY' }
94
+ })
77
95
 
78
96
  assert.deepStrictEqual(dependency.variables, {
79
- 'configuration-base': [{
80
- name: 'TOA_CONFIGURATION__KEY',
81
- secret: { name: 'toa-configuration', key: 'KEY' }
82
- }]
97
+ 'configuration-base': [
98
+ {
99
+ name: 'TOA_CONFIGURATION__KEY',
100
+ secret: { name: 'toa-configuration', key: 'KEY' }
101
+ }
102
+ ]
83
103
  })
84
104
 
85
105
  // the service holds the reference, not the secret
86
- assert.deepStrictEqual(map([instance('base')], { 'configuration.base': { key: '$KEY' } })['configuration.base'].defaults, { key: '$KEY' })
106
+ assert.deepStrictEqual(
107
+ map([instance('base')], { 'configuration.base': { key: '$KEY' } })[
108
+ 'configuration.base'
109
+ ].defaults,
110
+ { key: '$KEY' }
111
+ )
87
112
  })
@@ -1,6 +1,13 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import assert from 'node:assert'
3
- import { type Dependency, type Resources, type Service, type Variable, type Variables } from '@toa.io/operations'
3
+ import {
4
+ type Dependency,
5
+ type Resources,
6
+ type Service,
7
+ type Variable,
8
+ type Variables
9
+ } from '@toa.io/operations'
10
+ import { add } from '@toa.io/generic'
4
11
  import { components } from './Composition.js'
5
12
  import { EVENT, PREFIX, SECRET_RX, UI_PATH, UI_PORT, VALUES } from './const.js'
6
13
  import { epoch } from './epoch.js'
@@ -8,7 +15,14 @@ import * as validators from './schemas.js'
8
15
  import type { Manifest } from './manifest.js'
9
16
  import type { context } from '@toa.io/norm'
10
17
 
11
- export function deployment (instances: Instance[], annotation: Annotation = {}): Dependency {
18
+ /** Where Toa's release publishes this service's image. An application takes it
19
+ * instead of building one when its context says `registry.services: published`. */
20
+ export const image = 'ghcr.io/toa-io/extension-configuration-values'
21
+
22
+ export function deployment(
23
+ instances: Instance[],
24
+ annotation: Annotation = {}
25
+ ): Dependency {
12
26
  const { resources, values } = split(annotation)
13
27
 
14
28
  annotation = prepare(values, instances)
@@ -18,50 +32,60 @@ export function deployment (instances: Instance[], annotation: Annotation = {}):
18
32
  for (const instance of instances) {
19
33
  const values = annotation[instance.locator.id]
20
34
 
21
- if (values === undefined)
22
- continue
35
+ if (values === undefined) continue
23
36
 
24
37
  const secrets = createSecrets(values)
25
38
 
26
- if (secrets.length > 0)
27
- variables[instance.locator.label] = secrets
39
+ if (secrets.length > 0) variables[instance.locator.label] = secrets
28
40
  }
29
41
 
30
42
  const service: Service = {
31
43
  group: 'configuration',
32
44
  name: 'values',
33
- version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version,
45
+ image,
46
+ version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
47
+ .version,
34
48
  components: components().labels,
35
49
  resources,
36
50
  // the service that holds the values also serves the page that reads them
37
51
  port: UI_PORT,
38
52
  ingress: { path: UI_PATH },
39
- variables: [{
40
- name: VALUES,
41
- value: JSON.stringify(describe(instances, annotation))
42
- }]
53
+ variables: [
54
+ {
55
+ name: VALUES,
56
+ value: JSON.stringify(describe(instances, annotation))
57
+ }
58
+ ]
43
59
  }
44
60
 
45
61
  return { services: [service], variables, events: [EVENT] }
46
62
  }
47
63
 
48
64
  /** What the values service is given: the epoch, the schema and the defaults of every component. */
49
- export function describe (instances: Instance[], annotation: Annotation = {}): Values {
65
+ export function describe(instances: Instance[], annotation: Annotation = {}): Values {
50
66
  annotation = prepare(split(annotation).values, instances)
51
67
 
52
68
  const values: Values = {}
53
69
 
54
- for (const { locator, manifest } of instances)
70
+ for (const { locator, manifest } of instances) {
71
+ const deployed = annotation[locator.id]
72
+
55
73
  values[locator.id] = {
56
74
  epoch: epoch(manifest.schema),
57
75
  schema: manifest.schema,
58
- defaults: annotation[locator.id] ?? manifest.defaults
76
+ // what the Context says stands over what the manifest declares, value by value: a
77
+ // deployment that names one of them does not thereby unset the rest
78
+ defaults:
79
+ deployed === undefined
80
+ ? manifest.defaults
81
+ : add(structuredClone(deployed), manifest.defaults ?? {})
59
82
  }
83
+ }
60
84
 
61
85
  return values
62
86
  }
63
87
 
64
- function createSecrets (values: object): Variable[] {
88
+ function createSecrets(values: object): Variable[] {
65
89
  const secrets: Variable[] = []
66
90
 
67
91
  for (const value of Object.values(values)) {
@@ -98,7 +122,7 @@ function createSecrets (values: object): Variable[] {
98
122
  * called that is written with its namespace, `default.resources`, which is what an id is
99
123
  * anyway — the bare form is the shorthand.
100
124
  */
101
- function split (annotation: Annotation): { resources?: Resources, values: Annotation } {
125
+ function split(annotation: Annotation): { resources?: Resources; values: Annotation } {
102
126
  validators.annotation.validate(annotation)
103
127
 
104
128
  const { resources, ...values } = annotation as Annotation & { resources?: Resources }
@@ -107,15 +131,17 @@ function split (annotation: Annotation): { resources?: Resources, values: Annota
107
131
  }
108
132
 
109
133
  /** Validated, keyed by full component ids, and checked against the components that ask. */
110
- function prepare (annotation: Annotation, instances: Instance[]): Annotation {
134
+ function prepare(annotation: Annotation, instances: Instance[]): Annotation {
111
135
  const normalized: Annotation = {}
112
136
  const requested = instances.map((instance) => instance.locator.id)
113
137
 
114
138
  for (const [key, values] of Object.entries(annotation)) {
115
139
  const id = key.includes('.') ? key : 'default.' + key
116
140
 
117
- assert.ok(requested.includes(id),
118
- `Component '${id}' does not request configuration or does not exist.`)
141
+ assert.ok(
142
+ requested.includes(id),
143
+ `Component '${id}' does not request configuration or does not exist.`
144
+ )
119
145
 
120
146
  normalized[id] = values
121
147
  }
@@ -8,8 +8,14 @@ it('should be a sha256 hex', () => {
8
8
  })
9
9
 
10
10
  it('should not depend on key order', () => {
11
- const a = { type: 'object', properties: { foo: { type: 'string', default: 'x' }, bar: { type: 'number' } } }
12
- const b = { properties: { bar: { type: 'number' }, foo: { default: 'x', type: 'string' } }, type: 'object' }
11
+ const a = {
12
+ type: 'object',
13
+ properties: { foo: { type: 'string', default: 'x' }, bar: { type: 'number' } }
14
+ }
15
+ const b = {
16
+ properties: { bar: { type: 'number' }, foo: { default: 'x', type: 'string' } },
17
+ type: 'object'
18
+ }
13
19
 
14
20
  assert.deepStrictEqual(epoch(a), epoch(b))
15
21
  })
package/source/epoch.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import { createHash } from 'node:crypto'
2
2
 
3
3
  /** The same for the same schema, whatever the key order it was written in. */
4
- export function epoch (schema: object): string {
4
+ export function epoch(schema: object): string {
5
5
  return createHash('sha256').update(canonical(schema)).digest('hex')
6
6
  }
7
7
 
8
- function canonical (value: unknown): string {
9
- if (Array.isArray(value))
10
- return '[' + value.map(canonical).join(',') + ']'
8
+ function canonical(value: unknown): string {
9
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']'
11
10
 
12
11
  if (value !== null && typeof value === 'object') {
13
12
  const object = value as Record<string, unknown>
14
- const keys = Object.keys(object).filter((key) => object[key] !== undefined).sort()
13
+ const keys = Object.keys(object)
14
+ .filter((key) => object[key] !== undefined)
15
+ .sort()
15
16
  const entries = keys.map((key) => JSON.stringify(key) + ':' + canonical(object[key]))
16
17
 
17
18
  return '{' + entries.join(',') + '}'
package/source/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { manifest } from './manifest.js'
2
- export { deployment, describe } from './deployment.js'
2
+ export { deployment, describe, image } from './deployment.js'
3
3
  export { components } from './Composition.js'
4
4
  export { Factory } from './Factory.js'
5
5
 
@@ -6,13 +6,19 @@ import { type Manifest, manifest } from './manifest.js'
6
6
  it('should validate', async () => {
7
7
  const additional = { schema: {}, foo: 'bar' } as unknown as Manifest
8
8
 
9
- assert.throws(() => {
10
- manifest(additional)
11
- }, (error: any) => /not expected/.test(error.message))
9
+ assert.throws(
10
+ () => {
11
+ manifest(additional)
12
+ },
13
+ (error: any) => /not expected/.test(error.message)
14
+ )
12
15
 
13
16
  const wrongType = { schema: 'not ok' } as unknown as Manifest
14
17
 
15
- assert.throws(() => {
16
- manifest(wrongType)
17
- }, (error: any) => /object/.test(error.message))
18
+ assert.throws(
19
+ () => {
20
+ manifest(wrongType)
21
+ },
22
+ (error: any) => /object/.test(error.message)
23
+ )
18
24
  })