@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.
- package/components/configuration.values/manifest.toa.yaml +14 -21
- package/components/configuration.values/migrations/0001-system-properties.yaml +14 -0
- package/components/configuration.values/migrations/0002-indexes.yaml +7 -0
- package/components/configuration.values/operations/create.js +1 -2
- package/components/configuration.values/operations/fetch.js +1 -1
- package/components/configuration.values/operations/get.js +1 -1
- package/components/configuration.values/operations/lib/map.js +1 -1
- package/components/configuration.values/operations/lib/resolve.js +3 -3
- package/components/configuration.values/operations/list.js +1 -1
- package/components/configuration.values/source/create.ts +3 -5
- package/components/configuration.values/source/fetch.ts +12 -10
- package/components/configuration.values/source/get.ts +2 -3
- package/components/configuration.values/source/lib/map.test.ts +7 -2
- package/components/configuration.values/source/lib/map.ts +3 -3
- package/components/configuration.values/source/lib/resolve.ts +9 -6
- package/components/configuration.values/source/list.ts +8 -6
- package/components/configuration.values/tsconfig.tsbuildinfo +1 -1
- package/components/configuration.values/types/toa.d.ts +5 -5
- package/cucumber.mjs +3 -0
- package/features/site/index.html +6 -2
- package/features/steps/UI.ts +20 -13
- package/notes/consistency.md +10 -9
- package/package.json +6 -6
- package/readme.md +15 -14
- package/schemas/annotation.cos.yaml +1 -1
- package/source/Aspect.test.ts +32 -8
- package/source/Aspect.ts +28 -19
- package/source/Client.test.ts +31 -14
- package/source/Client.ts +41 -48
- package/source/Composition.ts +6 -6
- package/source/Factory.ts +7 -7
- package/source/Secret.test.ts +1 -1
- package/source/Secret.ts +5 -5
- package/source/UI.ts +33 -32
- package/source/configuration.test.ts +14 -8
- package/source/configuration.ts +11 -15
- package/source/const.ts +13 -1
- package/source/context.ts +2 -2
- package/source/deployment.test.ts +37 -12
- package/source/deployment.ts +45 -19
- package/source/epoch.test.ts +8 -2
- package/source/epoch.ts +6 -5
- package/source/index.ts +1 -1
- package/source/manifest.test.ts +12 -6
- package/source/manifest.ts +1 -1
- package/transpiled/Aspect.d.ts +2 -1
- package/transpiled/Aspect.js +15 -3
- package/transpiled/Aspect.js.map +1 -1
- package/transpiled/Client.d.ts +1 -1
- package/transpiled/Client.js +8 -2
- package/transpiled/Client.js.map +1 -1
- package/transpiled/Composition.js +2 -2
- package/transpiled/Composition.js.map +1 -1
- package/transpiled/Factory.d.ts +2 -1
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Secret.js.map +1 -1
- package/transpiled/UI.js +2 -1
- package/transpiled/UI.js.map +1 -1
- package/transpiled/configuration.js.map +1 -1
- package/transpiled/const.d.ts +8 -1
- package/transpiled/const.js +10 -0
- package/transpiled/const.js.map +1 -1
- package/transpiled/context.d.ts +1 -1
- package/transpiled/context.js.map +1 -1
- package/transpiled/deployment.d.ts +3 -0
- package/transpiled/deployment.js +19 -5
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/epoch.js +3 -1
- package/transpiled/epoch.js.map +1 -1
- package/transpiled/index.d.ts +1 -1
- package/transpiled/index.js +1 -1
- package/transpiled/index.js.map +1 -1
- package/transpiled/manifest.js.map +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/ui/dist/_app/immutable/chunks/{mvspXr3J.js → 6fUKYVLr.js} +2 -2
- package/ui/dist/_app/immutable/chunks/{p9RyiXQ-.js → BGxZNgfH.js} +1 -1
- package/ui/dist/_app/immutable/chunks/{Bxde3uE_.js → By3hKJVT.js} +1 -1
- package/ui/dist/_app/immutable/chunks/DamfpXnK.js +1 -0
- package/ui/dist/_app/immutable/chunks/{_I_MUoRn.js → vHown6GG.js} +1 -1
- package/ui/dist/_app/immutable/entry/{app.C1VIepN7.js → app.cjzrCTnf.js} +2 -2
- package/ui/dist/_app/immutable/entry/start.C18omPso.js +1 -0
- package/ui/dist/_app/immutable/nodes/{0.D_0dtFej.js → 0.DjKXI3st.js} +1 -1
- package/ui/dist/_app/immutable/nodes/{1.DFlgHh-H.js → 1.Dh71z_Aw.js} +1 -1
- package/ui/dist/_app/immutable/nodes/{2.BIHgAxXR.js → 2.TzrOmdn2.js} +1 -1
- package/ui/dist/_app/immutable/nodes/3.DlvWZctZ.js +1 -0
- package/ui/dist/_app/immutable/nodes/{4.BZMuJGfA.js → 4.D8kjaqXl.js} +1 -1
- package/ui/dist/_app/version.json +1 -1
- package/ui/dist/index.html +28 -23
- package/CHANGELOG.md +0 -372
- package/ui/dist/_app/immutable/chunks/BzG-gka8.js +0 -1
- package/ui/dist/_app/immutable/entry/start.BQWCs_Pq.js +0 -1
- package/ui/dist/_app/immutable/nodes/3.R-_yCt5X.js +0 -1
package/source/Secret.test.ts
CHANGED
|
@@ -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(!
|
|
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
|
|
11
|
+
public constructor(value: string) {
|
|
12
12
|
this.#value = value
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
public unwrap
|
|
15
|
+
public unwrap(): string {
|
|
16
16
|
return this.#value
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
public toString
|
|
19
|
+
public toString(): string {
|
|
20
20
|
return REDACTED
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
public toJSON
|
|
23
|
+
public toJSON(): string {
|
|
24
24
|
return REDACTED
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
public [inspect.custom]
|
|
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
|
|
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
|
|
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
|
|
58
|
-
const closed = new Promise<void>((resolve) =>
|
|
59
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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'
|
|
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), {
|
|
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'
|
|
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
|
|
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
|
|
package/source/configuration.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
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' })], {
|
|
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')], {
|
|
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')], {
|
|
92
|
+
const dependency = deployment([instance('base')], {
|
|
93
|
+
'configuration.base': { key: '$KEY' }
|
|
94
|
+
})
|
|
77
95
|
|
|
78
96
|
assert.deepStrictEqual(dependency.variables, {
|
|
79
|
-
'configuration-base': [
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
106
|
+
assert.deepStrictEqual(
|
|
107
|
+
map([instance('base')], { 'configuration.base': { key: '$KEY' } })[
|
|
108
|
+
'configuration.base'
|
|
109
|
+
].defaults,
|
|
110
|
+
{ key: '$KEY' }
|
|
111
|
+
)
|
|
87
112
|
})
|
package/source/deployment.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import assert from 'node:assert'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
118
|
-
|
|
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
|
}
|
package/source/epoch.test.ts
CHANGED
|
@@ -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 = {
|
|
12
|
-
|
|
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
|
|
4
|
+
export function epoch(schema: object): string {
|
|
5
5
|
return createHash('sha256').update(canonical(schema)).digest('hex')
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
function canonical
|
|
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)
|
|
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
package/source/manifest.test.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
+
assert.throws(
|
|
19
|
+
() => {
|
|
20
|
+
manifest(wrongType)
|
|
21
|
+
},
|
|
22
|
+
(error: any) => /object/.test(error.message)
|
|
23
|
+
)
|
|
18
24
|
})
|