@toa.io/extensions.configuration 1.0.0-alpha.29 → 1.0.0-alpha.291

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 (140) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/components/configuration.values/manifest.toa.yaml +151 -0
  3. package/components/configuration.values/migrations/0002-indexes.yaml +7 -0
  4. package/components/configuration.values/operations/create.js +30 -0
  5. package/components/configuration.values/operations/fetch.js +13 -0
  6. package/components/configuration.values/operations/get.js +19 -0
  7. package/components/configuration.values/operations/lib/map.js +22 -0
  8. package/components/configuration.values/operations/lib/resolve.js +24 -0
  9. package/components/configuration.values/operations/list.js +11 -0
  10. package/components/configuration.values/source/create.ts +54 -0
  11. package/components/configuration.values/source/fetch.ts +26 -0
  12. package/components/configuration.values/source/get.ts +32 -0
  13. package/components/configuration.values/source/lib/map.test.ts +42 -0
  14. package/components/configuration.values/source/lib/map.ts +36 -0
  15. package/components/configuration.values/source/lib/resolve.ts +57 -0
  16. package/components/configuration.values/source/list.ts +21 -0
  17. package/components/configuration.values/tsconfig.json +10 -0
  18. package/components/configuration.values/tsconfig.tsbuildinfo +1 -0
  19. package/components/configuration.values/types/index.d.ts +3 -0
  20. package/components/configuration.values/types/toa.d.ts +68 -0
  21. package/cucumber.mjs +8 -0
  22. package/features/site/_app/immutable/asset.js +1 -0
  23. package/features/site/favicon.ico +0 -0
  24. package/features/site/index.html +10 -0
  25. package/features/steps/UI.ts +98 -0
  26. package/features/steps/tsconfig.json +9 -0
  27. package/features/ui.feature +93 -0
  28. package/notes/consistency.md +10 -9
  29. package/package.json +11 -10
  30. package/readme.md +165 -51
  31. package/schemas/annotation.cos.yaml +29 -1
  32. package/schemas/manifest.cos.yaml +9 -2
  33. package/source/Aspect.test.ts +114 -10
  34. package/source/Aspect.ts +76 -8
  35. package/source/Client.test.ts +125 -0
  36. package/source/Client.ts +251 -0
  37. package/source/Composition.ts +57 -0
  38. package/source/Factory.ts +29 -7
  39. package/source/Secret.test.ts +32 -0
  40. package/source/Secret.ts +32 -0
  41. package/source/UI.ts +213 -0
  42. package/source/configuration.test.ts +105 -43
  43. package/source/configuration.ts +36 -21
  44. package/source/const.ts +30 -0
  45. package/source/context.ts +13 -0
  46. package/source/deployment.test.ts +103 -12
  47. package/source/deployment.ts +105 -31
  48. package/source/epoch.test.ts +35 -0
  49. package/source/epoch.ts +22 -0
  50. package/source/index.ts +10 -3
  51. package/source/manifest.test.ts +16 -7
  52. package/source/manifest.ts +3 -5
  53. package/source/schemas.ts +1 -1
  54. package/transpiled/Aspect.d.ts +19 -4
  55. package/transpiled/Aspect.js +62 -9
  56. package/transpiled/Aspect.js.map +1 -1
  57. package/transpiled/Client.d.ts +61 -0
  58. package/transpiled/Client.js +175 -0
  59. package/transpiled/Client.js.map +1 -0
  60. package/transpiled/Composition.d.ts +14 -0
  61. package/transpiled/Composition.js +38 -0
  62. package/transpiled/Composition.js.map +1 -0
  63. package/transpiled/Factory.d.ts +9 -2
  64. package/transpiled/Factory.js +20 -9
  65. package/transpiled/Factory.js.map +1 -1
  66. package/transpiled/Secret.d.ts +15 -0
  67. package/transpiled/Secret.js +26 -0
  68. package/transpiled/Secret.js.map +1 -0
  69. package/transpiled/UI.d.ts +25 -0
  70. package/transpiled/UI.js +173 -0
  71. package/transpiled/UI.js.map +1 -0
  72. package/transpiled/configuration.d.ts +7 -2
  73. package/transpiled/configuration.js +32 -43
  74. package/transpiled/configuration.js.map +1 -1
  75. package/transpiled/const.d.ts +18 -0
  76. package/transpiled/const.js +22 -0
  77. package/transpiled/const.js.map +1 -0
  78. package/transpiled/context.d.ts +4 -0
  79. package/transpiled/context.js +9 -0
  80. package/transpiled/context.js.map +1 -0
  81. package/transpiled/deployment.d.ts +12 -3
  82. package/transpiled/deployment.js +76 -57
  83. package/transpiled/deployment.js.map +1 -1
  84. package/transpiled/epoch.d.ts +2 -0
  85. package/transpiled/epoch.js +19 -0
  86. package/transpiled/epoch.js.map +1 -0
  87. package/transpiled/index.d.ts +6 -3
  88. package/transpiled/index.js +8 -9
  89. package/transpiled/index.js.map +1 -1
  90. package/transpiled/manifest.d.ts +1 -1
  91. package/transpiled/manifest.js +2 -31
  92. package/transpiled/manifest.js.map +1 -1
  93. package/transpiled/schemas.js +5 -31
  94. package/transpiled/schemas.js.map +1 -1
  95. package/tsconfig.json +5 -1
  96. package/tsconfig.tsbuildinfo +1 -0
  97. package/ui/dist/_app/env.js +1 -0
  98. package/ui/dist/_app/immutable/assets/0.CNS8Cqhc.css +2 -0
  99. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  100. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  101. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  102. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  103. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  104. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  105. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  106. package/ui/dist/_app/immutable/assets/ui.BghF56l_.css +1 -0
  107. package/ui/dist/_app/immutable/chunks/3Kbn_1Vv.js +1 -0
  108. package/ui/dist/_app/immutable/chunks/BV5cDXGB.js +50 -0
  109. package/ui/dist/_app/immutable/chunks/B_CH3nFk.js +1 -0
  110. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  111. package/ui/dist/_app/immutable/chunks/CNZAv2Es.js +8 -0
  112. package/ui/dist/_app/immutable/chunks/CcD_ISxM.js +3 -0
  113. package/ui/dist/_app/immutable/chunks/vl8APhvf.js +6 -0
  114. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  115. package/ui/dist/_app/immutable/entry/app.CK-db-gz.js +2 -0
  116. package/ui/dist/_app/immutable/entry/start.Bs7xr9Ne.js +1 -0
  117. package/ui/dist/_app/immutable/nodes/0.vPwVABRs.js +1 -0
  118. package/ui/dist/_app/immutable/nodes/1.DS63PME5.js +1 -0
  119. package/ui/dist/_app/immutable/nodes/2.KRpWYBfz.js +1 -0
  120. package/ui/dist/_app/immutable/nodes/3.DCxORePS.js +1 -0
  121. package/ui/dist/_app/immutable/nodes/4.D8lluH3u.js +1 -0
  122. package/ui/dist/_app/version.json +1 -0
  123. package/ui/dist/apple-touch-icon.png +0 -0
  124. package/ui/dist/favicon-96x96.png +0 -0
  125. package/ui/dist/favicon.ico +0 -0
  126. package/ui/dist/index.html +57 -0
  127. package/ui/dist/robots.txt +3 -0
  128. package/transpiled/Aspect.test.d.ts +0 -1
  129. package/transpiled/Aspect.test.js +0 -15
  130. package/transpiled/Aspect.test.js.map +0 -1
  131. package/transpiled/configuration.test.d.ts +0 -1
  132. package/transpiled/configuration.test.js +0 -64
  133. package/transpiled/configuration.test.js.map +0 -1
  134. package/transpiled/deployment.test.d.ts +0 -1
  135. package/transpiled/deployment.test.js +0 -18
  136. package/transpiled/deployment.test.js.map +0 -1
  137. package/transpiled/manifest.test.d.ts +0 -1
  138. package/transpiled/manifest.test.js +0 -14
  139. package/transpiled/manifest.test.js.map +0 -1
  140. package/transpiled/tsconfig.tsbuildinfo +0 -1
@@ -1,85 +1,147 @@
1
- import { encode } from '@toa.io/generic'
1
+ import { describe, it, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
2
4
  import { Locator } from '@toa.io/core'
3
5
  import { generate } from 'randomstring'
4
- import { get } from './configuration'
5
- import { type Manifest } from './manifest'
6
+ import { fit, local, overridden } from './configuration.js'
7
+ import { Secret } from './Secret.js'
8
+ import { type Manifest } from './manifest.js'
6
9
 
7
10
  let locator: Locator
8
11
  let manifest: Manifest
9
12
 
10
13
  beforeEach(() => {
11
14
  locator = new Locator(generate(), generate())
12
- manifest = { schema: { foo: 'string' } }
15
+ manifest = {
16
+ schema: {
17
+ type: 'object',
18
+ properties: { foo: { type: 'string' } }
19
+ }
20
+ }
13
21
  })
14
22
 
15
23
  afterEach(() => {
16
- for (const name of used)
17
- delete process.env[name]
24
+ for (const name of used) delete process.env[name]
18
25
 
19
26
  used = []
20
27
  })
21
28
 
22
- it('should read value', async () => {
23
- manifest.schema = { foo: 'string' }
29
+ describe('overridden', () => {
30
+ it('should be false without the variable', async () => {
31
+ assert.deepStrictEqual(overridden(locator), false)
32
+ })
24
33
 
25
- const value: object = { foo: generate() }
34
+ it('should be true with the variable', async () => {
35
+ set({})
26
36
 
27
- set(value)
37
+ assert.deepStrictEqual(overridden(locator), true)
38
+ })
39
+ })
28
40
 
29
- const result = get(locator, manifest)
41
+ describe('local', () => {
42
+ it('should read value', async () => {
43
+ const value: object = { foo: generate() }
30
44
 
31
- expect(result).toStrictEqual(value)
32
- })
45
+ set(value)
33
46
 
34
- it('should return empty object if no value set', async () => {
35
- expect(get(locator, manifest)).toStrictEqual({})
36
- })
47
+ assert.deepStrictEqual(local(locator, manifest), value)
48
+ })
37
49
 
38
- it('should substitute secrets', async () => {
39
- const value: object = { foo: '$BAR' }
50
+ it('should return empty object if no value set', async () => {
51
+ assert.deepStrictEqual(local(locator, manifest), {})
52
+ })
40
53
 
41
- set(value)
42
- set('bar', '_BAR')
54
+ it('should substitute secrets', async () => {
55
+ set({ foo: '$BAR' })
56
+ set('bar', '_BAR')
43
57
 
44
- const result = get(locator, manifest)
58
+ const { foo } = local(locator, manifest)
45
59
 
46
- expect(result).toStrictEqual({ foo: 'bar' })
47
- })
60
+ assert.ok(foo instanceof Secret)
61
+ assert.deepStrictEqual((foo as Secret).unwrap(), 'bar')
62
+ assert.deepStrictEqual(String(foo), '<REDACTED>')
63
+ })
48
64
 
49
- it('should use defaults', async () => {
50
- manifest.schema = { foo: 'string', bar: ['number'], 'baz?': 'string' }
51
- manifest.defaults = { foo: 'bar', bar: [1] }
65
+ it('should substitute secrets in defaults', async () => {
66
+ manifest.defaults = { foo: '$BAR' }
52
67
 
53
- const values = { bar: [2], baz: 'foo' }
68
+ set('bar', '_BAR')
54
69
 
55
- set(values)
70
+ assert.deepStrictEqual((local(locator, manifest).foo as Secret).unwrap(), 'bar')
71
+ })
72
+
73
+ it('should use defaults', async () => {
74
+ manifest.schema = {
75
+ type: 'object',
76
+ properties: {
77
+ foo: { type: 'string' },
78
+ bar: { type: 'array', items: { type: 'number' } },
79
+ baz: { type: 'string' }
80
+ },
81
+ required: ['foo', 'bar']
82
+ }
83
+ manifest.defaults = { foo: 'bar', bar: [1] }
84
+
85
+ set({ bar: [2], baz: 'foo' })
86
+
87
+ assert.deepStrictEqual(local(locator, manifest), { foo: 'bar', bar: [2], baz: 'foo' })
88
+ })
89
+
90
+ it('should validate', async () => {
91
+ manifest.schema = {
92
+ type: 'object',
93
+ properties: {
94
+ foo: { type: 'string' },
95
+ bar: { type: 'number' }
96
+ }
97
+ }
98
+
99
+ set({ bar: 5 })
56
100
 
57
- const result = get(locator, manifest)
101
+ assert.deepStrictEqual(local(locator, manifest), { bar: 5 })
58
102
 
59
- expect(result).toStrictEqual({
60
- foo: 'bar',
61
- bar: [2],
62
- baz: 'foo'
103
+ set({ bar: 'five' })
104
+
105
+ assert.throws(
106
+ () => local(locator, manifest),
107
+ (error: Error) => /must be number/.test(error.message)
108
+ )
63
109
  })
64
110
  })
65
111
 
66
- it('should validate', async () => {
67
- manifest.schema = { foo: 'hello', bar: 'number' }
112
+ describe('fit', () => {
113
+ it('should substitute secrets and apply the schema', async () => {
114
+ manifest.schema = {
115
+ type: 'object',
116
+ properties: {
117
+ foo: { type: 'string' },
118
+ bar: { type: 'number' }
119
+ }
120
+ }
121
+
122
+ set('secret', '_FOO')
68
123
 
69
- const values = { bar: 5 }
124
+ const raw = { foo: '$FOO', bar: 1 }
125
+ const values = fit(raw, manifest)
70
126
 
71
- set(values)
127
+ assert.deepStrictEqual((values.foo as Secret).unwrap(), 'secret')
128
+ assert.deepStrictEqual(values.bar, 1)
129
+ assert.deepStrictEqual(raw, { foo: '$FOO', bar: 1 }) // untouched
130
+ })
72
131
 
73
- const result = get(locator, manifest)
132
+ it('should not apply the manifest defaults', async () => {
133
+ manifest.defaults = { foo: 'hello' }
134
+
135
+ assert.deepStrictEqual(fit({}, manifest), {})
136
+ })
74
137
 
75
- expect(result).toStrictEqual({
76
- foo: 'hello',
77
- bar: 5
138
+ it('should throw on a value not fitting the schema', async () => {
139
+ assert.throws(() => fit({ foo: { nested: true } }, manifest))
78
140
  })
79
141
  })
80
142
 
81
- function set (value: object | string, key = locator.uppercase): void {
82
- const string = typeof value === 'string' ? value : encode(value)
143
+ function set(value: object | string, key = locator.uppercase): void {
144
+ const string = typeof value === 'string' ? value : JSON.stringify(value)
83
145
  const name = 'TOA_CONFIGURATION_' + key
84
146
 
85
147
  process.env[name] = string
@@ -1,39 +1,54 @@
1
1
  import { type Locator } from '@toa.io/core'
2
- import { decode, add } from '@toa.io/generic'
2
+ import { add, environment } from '@toa.io/generic'
3
3
  import * as schemas from '@toa.io/schemas'
4
- import { PREFIX, SECRET_RX } from './deployment'
5
- import { type Manifest } from './manifest'
4
+ import { PREFIX, SECRET_RX } from './const.js'
5
+ import { Secret } from './Secret.js'
6
6
  import type { Schema } from '@toa.io/schemas'
7
+ import type { Manifest } from './manifest.js'
7
8
 
8
- export function get (locator: Locator, manifest: Manifest): Node {
9
- const values = getConfiguration(locator.uppercase)
9
+ /** The variable is set, so the values service is not consulted. */
10
+ export function overridden(locator: Locator): boolean {
11
+ return environment.get(PREFIX + locator.uppercase) !== undefined
12
+ }
13
+
14
+ /** The variable, the manifest defaults, then the schema. */
15
+ export function local(locator: Locator, manifest: Manifest): Node {
16
+ const values = read(locator.uppercase)
17
+
18
+ if (manifest.defaults !== undefined) add(values, manifest.defaults)
19
+
20
+ return fit(values, manifest)
21
+ }
10
22
 
23
+ /** A copy of the value with the schema applied and the secrets substituted. */
24
+ export function fit(raw: object, manifest: Manifest): Node {
25
+ // a copy of the caller's, and one of this realm: what came over the wire is JSON anyway
26
+ const values = JSON.parse(JSON.stringify(raw)) as Node
27
+
28
+ // the schema sees the references, which are the strings it declares
29
+ validate(values, manifest)
11
30
  substituteSecrets(values)
12
31
 
13
- if (manifest.defaults !== undefined)
14
- add(values, manifest.defaults)
32
+ return values
33
+ }
15
34
 
35
+ function validate(values: Node, manifest: Manifest): void {
16
36
  const schema: Schema<any> = schemas.schema(manifest.schema)
17
37
 
18
38
  schema.validate(values)
19
-
20
- return values
21
39
  }
22
40
 
23
- function getConfiguration (suffix: string): Node {
41
+ function read(suffix: string): Node {
24
42
  const variable = PREFIX + suffix
25
- const string = process.env[variable]
43
+ const string = environment.get(variable)
26
44
 
27
- if (string === undefined)
28
- return {}
29
- else
30
- return decode(string)
45
+ if (string === undefined) return {}
46
+ else return JSON.parse(string)
31
47
  }
32
48
 
33
- function substituteSecrets (configuration: Node): void {
49
+ function substituteSecrets(configuration: Node): void {
34
50
  for (const [key, value] of Object.entries(configuration)) {
35
- if (typeof value === 'object' && value !== null)
36
- substituteSecrets(value as Node)
51
+ if (typeof value === 'object' && value !== null) substituteSecrets(value as Node)
37
52
 
38
53
  if (typeof value !== 'string') continue
39
54
 
@@ -43,13 +58,13 @@ function substituteSecrets (configuration: Node): void {
43
58
 
44
59
  const name = match.groups?.variable
45
60
 
46
- configuration[key] = getSecret(name!)
61
+ configuration[key] = new Secret(getSecret(name!))
47
62
  }
48
63
  }
49
64
 
50
- function getSecret (name: string): string {
65
+ function getSecret(name: string): string {
51
66
  const variable = PREFIX + '_' + name
52
- const value = process.env[variable]
67
+ const value = environment.get(variable)
53
68
 
54
69
  if (value === undefined) throw new Error(`${variable} is not set.`)
55
70
 
@@ -0,0 +1,30 @@
1
+ import { environment } from '@toa.io/generic'
2
+ import type { Source } from '@toa.io/core/types'
3
+
4
+ /** Per-component variables: the local override and the secrets. */
5
+ export const PREFIX = 'TOA_CONFIGURATION_'
6
+
7
+ /** The map of every configured component, on the values service. */
8
+ export const VALUES = 'TOA_CONFIGURATION_VALUES'
9
+
10
+ export const SECRET_RX = /^\$(?<variable>[A-Z0-9_]{1,32})$/
11
+
12
+ /** Where the UI is mounted; `/configuration/*` belongs to the component's own API. */
13
+ export const UI_PATH = '/.configuration'
14
+ export const UI_PORT = 8003
15
+
16
+ /**
17
+ * The port the UI is served on. `UI_PORT` is what a deployment publishes and what the chart
18
+ * renders; the variable is for a machine that runs more than one Toa process — a Toa checkout
19
+ * beside an application, or two applications — where one of them has to give it up.
20
+ */
21
+ export const UI_PORT_ENV = 'TOA_CONFIGURATION_UI_PORT'
22
+ export function uiPort(): number {
23
+ const value = environment.get(UI_PORT_ENV)
24
+
25
+ return value === undefined ? UI_PORT : Number(value)
26
+ }
27
+
28
+ export const EVENT = 'configuration.values.created'
29
+
30
+ export const SOURCE: Source = { service: 'configuration' }
@@ -0,0 +1,13 @@
1
+ import type { Manifest } from './manifest.js'
2
+ import type { Contribution } from '@toa.io/core/types'
3
+
4
+ /** What this extension puts on the context of a component that declares it. */
5
+ export function context(declaration: Manifest): Contribution {
6
+ const contribution: Contribution = { name: 'configuration', schema: declaration.schema }
7
+
8
+ // a value the schema declares a secret is one on the context, not a string
9
+ if (JSON.stringify(declaration.schema).includes('"secret"'))
10
+ contribution.imports = { '@toa.io/extensions.configuration': ['Secret'] }
11
+
12
+ return contribution
13
+ }
@@ -1,21 +1,112 @@
1
- import { type Annotation, deployment, type Instance } from './deployment'
2
- import { type Manifest } from './manifest'
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { Locator } from '@toa.io/core'
5
+ import {
6
+ type Annotation,
7
+ type Instance,
8
+ deployment,
9
+ describe as map
10
+ } from './deployment.js'
11
+ import { epoch } from './epoch.js'
12
+ import { EVENT, VALUES } from './const.js'
13
+
14
+ const schema = {
15
+ type: 'object',
16
+ properties: { foo: { type: 'string' }, key: { type: 'string' } }
17
+ }
18
+
19
+ function instance(name: string, defaults?: Record<string, unknown>): Instance {
20
+ return {
21
+ locator: new Locator(name, 'configuration'),
22
+ manifest: { schema, defaults },
23
+ component: {} as any
24
+ }
25
+ }
3
26
 
4
27
  it('should validate annotation', async () => {
5
28
  const wrongType = 'not ok' as unknown as Annotation
6
29
 
7
- expect(() => deployment([], wrongType)).toThrow('object')
30
+ assert.throws(
31
+ () => deployment([], wrongType),
32
+ (error: any) => /object/.test(error.message)
33
+ )
8
34
  })
9
35
 
10
- it('should validate values', async () => {
11
- const manifest: Manifest = {
12
- schema: { foo: 'string', bar: 'number' },
13
- defaults: { foo: 'ok', bar: 0 }
14
- }
36
+ it('should reject unknown components', async () => {
37
+ assert.throws(
38
+ () => deployment([instance('base')], { 'configuration.nope': {} }),
39
+ (error: any) =>
40
+ /Component 'configuration\.nope' does not request configuration/.test(error.message)
41
+ )
42
+ })
43
+
44
+ it('should deploy the values service with the map', async () => {
45
+ const instances = [instance('base', { foo: 'hello' }), instance('other')]
46
+ const annotation = { 'configuration.other': { foo: 'set' } }
47
+
48
+ const dependency = deployment(instances, annotation)
49
+
50
+ assert.deepStrictEqual(dependency.events, [EVENT])
51
+ assert.strictEqual(dependency.services?.length, 1)
52
+
53
+ const service = dependency.services![0]
54
+
55
+ assert.deepStrictEqual(service.group, 'configuration')
56
+ assert.deepStrictEqual(service.name, 'values')
57
+ assert.deepStrictEqual(service.components, ['configuration-values'])
58
+
59
+ const variable = service.variables!.find((variable) => variable.name === VALUES)
60
+
61
+ assert.notStrictEqual(variable, undefined)
62
+ assert.deepStrictEqual(JSON.parse(variable!.value!), map(instances, annotation))
63
+ })
64
+
65
+ it('should describe every component', async () => {
66
+ const instances = [instance('base', { foo: 'hello' }), instance('other')]
67
+ const values = map(instances, { 'configuration.other': { foo: 'set' } })
68
+
69
+ assert.deepStrictEqual(values, {
70
+ 'configuration.base': { epoch: epoch(schema), schema, defaults: { foo: 'hello' } },
71
+ 'configuration.other': { epoch: epoch(schema), schema, defaults: { foo: 'set' } }
72
+ })
73
+ })
74
+
75
+ it('should prefer the context over the manifest defaults', async () => {
76
+ const values = map([instance('base', { foo: 'hello' })], {
77
+ 'configuration.base': { foo: 'bye' }
78
+ })
79
+
80
+ assert.deepStrictEqual(values['configuration.base'].defaults, { foo: 'bye' })
81
+ })
82
+
83
+ it('should not map values to the component', async () => {
84
+ const dependency = deployment([instance('base')], {
85
+ 'configuration.base': { foo: 'set' }
86
+ })
87
+
88
+ assert.deepStrictEqual(dependency.variables, {})
89
+ })
90
+
91
+ it('should map secrets to the component', async () => {
92
+ const dependency = deployment([instance('base')], {
93
+ 'configuration.base': { key: '$KEY' }
94
+ })
15
95
 
16
- const locator = { id: 'component' }
17
- const instances = [{ manifest, locator }] as unknown as Instance[]
18
- const annotation: Annotation = { [locator.id]: { bar: 'not a number' } }
96
+ assert.deepStrictEqual(dependency.variables, {
97
+ 'configuration-base': [
98
+ {
99
+ name: 'TOA_CONFIGURATION__KEY',
100
+ secret: { name: 'toa-configuration', key: 'KEY' }
101
+ }
102
+ ]
103
+ })
19
104
 
20
- expect(() => deployment(instances, annotation)).toThrow('number')
105
+ // the service holds the reference, not the secret
106
+ assert.deepStrictEqual(
107
+ map([instance('base')], { 'configuration.base': { key: '$KEY' } })[
108
+ 'configuration.base'
109
+ ].defaults,
110
+ { key: '$KEY' }
111
+ )
21
112
  })
@@ -1,38 +1,91 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import assert from 'node:assert'
2
- import { type Dependency, type Variable, type Variables } from '@toa.io/operations'
3
- import * as schemas from '@toa.io/schemas'
4
- import { encode, overwrite } from '@toa.io/generic'
5
- import { type Manifest } from './manifest'
6
- import * as validators from './schemas'
7
- import type { Schema } from '@toa.io/schemas'
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'
11
+ import { components } from './Composition.js'
12
+ import { EVENT, PREFIX, SECRET_RX, UI_PATH, UI_PORT, VALUES } from './const.js'
13
+ import { epoch } from './epoch.js'
14
+ import * as validators from './schemas.js'
15
+ import type { Manifest } from './manifest.js'
8
16
  import type { context } from '@toa.io/norm'
9
17
 
10
- export function deployment (instances: Instance[], annotation: Annotation = {}): Dependency {
11
- validate(annotation, instances)
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 {
26
+ const { resources, values } = split(annotation)
27
+
28
+ annotation = prepare(values, instances)
12
29
 
13
30
  const variables: Variables = {}
14
31
 
15
32
  for (const instance of instances) {
16
33
  const values = annotation[instance.locator.id]
17
34
 
18
- validateInstance(instance, values)
19
-
20
35
  if (values === undefined) continue
21
36
 
22
- variables[instance.locator.label] = [{
23
- name: PREFIX + instance.locator.uppercase,
24
- value: encode(values)
25
- }]
26
-
27
37
  const secrets = createSecrets(values)
28
38
 
29
- variables[instance.locator.label].push(...secrets)
39
+ if (secrets.length > 0) variables[instance.locator.label] = secrets
40
+ }
41
+
42
+ const service: Service = {
43
+ group: 'configuration',
44
+ name: 'values',
45
+ image,
46
+ version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
47
+ .version,
48
+ components: components().labels,
49
+ resources,
50
+ // the service that holds the values also serves the page that reads them
51
+ port: UI_PORT,
52
+ ingress: { path: UI_PATH },
53
+ variables: [
54
+ {
55
+ name: VALUES,
56
+ value: JSON.stringify(describe(instances, annotation))
57
+ }
58
+ ]
59
+ }
60
+
61
+ return { services: [service], variables, events: [EVENT] }
62
+ }
63
+
64
+ /** What the values service is given: the epoch, the schema and the defaults of every component. */
65
+ export function describe(instances: Instance[], annotation: Annotation = {}): Values {
66
+ annotation = prepare(split(annotation).values, instances)
67
+
68
+ const values: Values = {}
69
+
70
+ for (const { locator, manifest } of instances) {
71
+ const deployed = annotation[locator.id]
72
+
73
+ values[locator.id] = {
74
+ epoch: epoch(manifest.schema),
75
+ schema: manifest.schema,
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 ?? {})
82
+ }
30
83
  }
31
84
 
32
- return { variables }
85
+ return values
33
86
  }
34
87
 
35
- function createSecrets (values: object): Variable[] {
88
+ function createSecrets(values: object): Variable[] {
36
89
  const secrets: Variable[] = []
37
90
 
38
91
  for (const value of Object.values(values)) {
@@ -61,26 +114,47 @@ function createSecrets (values: object): Variable[] {
61
114
  return secrets
62
115
  }
63
116
 
64
- function validate (annotation: Annotation, instances: Instance[]): void {
117
+ /**
118
+ * The service's own resources, and the component values that are the rest of the annotation.
119
+ *
120
+ * Every key here names a component, so the one option the service has of its own needs a
121
+ * name that cannot be mistaken for one. `resources` is reserved: a component actually
122
+ * called that is written with its namespace, `default.resources`, which is what an id is
123
+ * anyway — the bare form is the shorthand.
124
+ */
125
+ function split(annotation: Annotation): { resources?: Resources; values: Annotation } {
65
126
  validators.annotation.validate(annotation)
66
127
 
67
- const requested = instances.map((instance) => instance.locator.id)
128
+ const { resources, ...values } = annotation as Annotation & { resources?: Resources }
68
129
 
69
- for (const id of Object.keys(annotation))
70
- assert.ok(requested.includes(id),
71
- `Component '${id}' does not request configuration or does not exist.`)
130
+ return { resources, values }
72
131
  }
73
132
 
74
- function validateInstance (instace: Instance, values: object = {}): void {
75
- const defaults = instace.manifest.defaults ?? {}
76
- const configuration = overwrite(defaults, values)
77
- const schema: Schema<any> = schemas.schema(instace.manifest.schema)
133
+ /** Validated, keyed by full component ids, and checked against the components that ask. */
134
+ function prepare(annotation: Annotation, instances: Instance[]): Annotation {
135
+ const normalized: Annotation = {}
136
+ const requested = instances.map((instance) => instance.locator.id)
78
137
 
79
- schema.validate(configuration)
80
- }
138
+ for (const [key, values] of Object.entries(annotation)) {
139
+ const id = key.includes('.') ? key : 'default.' + key
140
+
141
+ assert.ok(
142
+ requested.includes(id),
143
+ `Component '${id}' does not request configuration or does not exist.`
144
+ )
81
145
 
82
- export const SECRET_RX = /^\$(?<variable>[A-Z0-9_]{1,32})$/
83
- export const PREFIX = 'TOA_CONFIGURATION_'
146
+ normalized[id] = values
147
+ }
148
+
149
+ return normalized
150
+ }
84
151
 
85
152
  export type Annotation = Record<string, object>
86
153
  export type Instance = context.Dependency<Manifest>
154
+ export type Values = Record<string, Entry>
155
+
156
+ export interface Entry {
157
+ epoch: string
158
+ schema: object
159
+ defaults?: object
160
+ }
@@ -0,0 +1,35 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { epoch } from './epoch.js'
5
+
6
+ it('should be a sha256 hex', () => {
7
+ assert.match(epoch({ type: 'object' }), /^[a-f0-9]{64}$/)
8
+ })
9
+
10
+ it('should not depend on key order', () => {
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
+ }
19
+
20
+ assert.deepStrictEqual(epoch(a), epoch(b))
21
+ })
22
+
23
+ it('should depend on values', () => {
24
+ const a = { type: 'object', properties: { foo: { type: 'string' } } }
25
+ const b = { type: 'object', properties: { foo: { type: 'number' } } }
26
+
27
+ assert.notDeepStrictEqual(epoch(a), epoch(b))
28
+ })
29
+
30
+ it('should keep array order', () => {
31
+ const a = { enum: [1, 2] }
32
+ const b = { enum: [2, 1] }
33
+
34
+ assert.notDeepStrictEqual(epoch(a), epoch(b))
35
+ })