@toa.io/extensions.configuration 1.0.0-alpha.28 → 1.0.0-alpha.281
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/CHANGELOG.md +340 -0
- package/components/configuration.values/manifest.toa.yaml +158 -0
- package/components/configuration.values/operations/create.d.ts +16 -0
- package/components/configuration.values/operations/create.js +31 -0
- package/components/configuration.values/operations/create.js.map +1 -0
- package/components/configuration.values/operations/fetch.d.ts +11 -0
- package/components/configuration.values/operations/fetch.js +13 -0
- package/components/configuration.values/operations/fetch.js.map +1 -0
- package/components/configuration.values/operations/get.d.ts +17 -0
- package/components/configuration.values/operations/get.js +19 -0
- package/components/configuration.values/operations/get.js.map +1 -0
- package/components/configuration.values/operations/lib/map.d.ts +11 -0
- package/components/configuration.values/operations/lib/map.js +21 -0
- package/components/configuration.values/operations/lib/map.js.map +1 -0
- package/components/configuration.values/operations/lib/map.test.d.ts +1 -0
- package/components/configuration.values/operations/lib/map.test.js +26 -0
- package/components/configuration.values/operations/lib/map.test.js.map +1 -0
- package/components/configuration.values/operations/lib/resolve.d.ts +27 -0
- package/components/configuration.values/operations/lib/resolve.js +24 -0
- package/components/configuration.values/operations/lib/resolve.js.map +1 -0
- package/components/configuration.values/operations/list.d.ts +10 -0
- package/components/configuration.values/operations/list.js +11 -0
- package/components/configuration.values/operations/list.js.map +1 -0
- package/components/configuration.values/source/create.ts +56 -0
- package/components/configuration.values/source/fetch.ts +24 -0
- package/components/configuration.values/source/get.ts +33 -0
- package/components/configuration.values/source/lib/map.test.ts +37 -0
- package/components/configuration.values/source/lib/map.ts +34 -0
- package/components/configuration.values/source/lib/resolve.ts +54 -0
- package/components/configuration.values/source/list.ts +19 -0
- package/components/configuration.values/tsconfig.json +10 -0
- package/components/configuration.values/tsconfig.tsbuildinfo +1 -0
- package/cucumber.mjs +5 -0
- package/features/site/_app/immutable/asset.js +1 -0
- package/features/site/favicon.ico +0 -0
- package/features/site/index.html +6 -0
- package/features/steps/UI.ts +91 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/ui.feature +93 -0
- package/package.json +11 -10
- package/readme.md +160 -47
- package/schemas/annotation.cos.yaml +29 -1
- package/schemas/manifest.cos.yaml +9 -2
- package/source/Aspect.test.ts +89 -9
- package/source/Aspect.ts +64 -5
- package/source/Client.test.ts +108 -0
- package/source/Client.ts +258 -0
- package/source/Composition.ts +57 -0
- package/source/Factory.ts +29 -6
- package/source/Secret.test.ts +32 -0
- package/source/Secret.ts +29 -0
- package/source/UI.ts +212 -0
- package/source/configuration.test.ts +96 -40
- package/source/configuration.ts +30 -11
- package/source/const.ts +17 -0
- package/source/deployment.test.ts +78 -12
- package/source/deployment.ts +75 -27
- package/source/epoch.test.ts +29 -0
- package/source/epoch.ts +21 -0
- package/source/index.ts +4 -3
- package/source/manifest.test.ts +8 -5
- package/source/manifest.ts +2 -4
- package/source/schemas.ts +1 -1
- package/transpiled/Aspect.d.ts +18 -4
- package/transpiled/Aspect.js +50 -9
- package/transpiled/Aspect.js.map +1 -1
- package/transpiled/Aspect.test.js +63 -12
- package/transpiled/Aspect.test.js.map +1 -1
- package/transpiled/Client.d.ts +61 -0
- package/transpiled/Client.js +169 -0
- package/transpiled/Client.js.map +1 -0
- package/transpiled/Client.test.d.ts +1 -0
- package/transpiled/Client.test.js +78 -0
- package/transpiled/Client.test.js.map +1 -0
- package/transpiled/Composition.d.ts +14 -0
- package/transpiled/Composition.js +38 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Factory.d.ts +8 -2
- package/transpiled/Factory.js +20 -9
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Secret.d.ts +12 -0
- package/transpiled/Secret.js +22 -0
- package/transpiled/Secret.js.map +1 -0
- package/transpiled/Secret.test.d.ts +1 -0
- package/transpiled/Secret.test.js +26 -0
- package/transpiled/Secret.test.js.map +1 -0
- package/transpiled/UI.d.ts +25 -0
- package/transpiled/UI.js +172 -0
- package/transpiled/UI.js.map +1 -0
- package/transpiled/configuration.d.ts +7 -2
- package/transpiled/configuration.js +30 -41
- package/transpiled/configuration.js.map +1 -1
- package/transpiled/configuration.test.js +90 -43
- package/transpiled/configuration.test.js.map +1 -1
- package/transpiled/const.d.ts +11 -0
- package/transpiled/const.js +11 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/deployment.d.ts +9 -3
- package/transpiled/deployment.js +62 -57
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/deployment.test.js +61 -13
- package/transpiled/deployment.test.js.map +1 -1
- package/transpiled/epoch.d.ts +2 -0
- package/transpiled/epoch.js +17 -0
- package/transpiled/epoch.js.map +1 -0
- package/transpiled/epoch.test.d.ts +1 -0
- package/transpiled/epoch.test.js +22 -0
- package/transpiled/epoch.test.js.map +1 -0
- package/transpiled/index.d.ts +4 -3
- package/transpiled/index.js +4 -9
- package/transpiled/index.js.map +1 -1
- package/transpiled/manifest.d.ts +1 -1
- package/transpiled/manifest.js +2 -31
- package/transpiled/manifest.js.map +1 -1
- package/transpiled/manifest.test.js +9 -9
- package/transpiled/manifest.test.js.map +1 -1
- package/transpiled/schemas.js +5 -31
- package/transpiled/schemas.js.map +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/dist/_app/env.js +1 -0
- package/ui/dist/_app/immutable/assets/0.CNS8Cqhc.css +2 -0
- package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/ui.BghF56l_.css +1 -0
- package/ui/dist/_app/immutable/chunks/BBRsRBKl.js +6 -0
- package/ui/dist/_app/immutable/chunks/BBjoSbGn.js +8 -0
- package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/ui/dist/_app/immutable/chunks/Bzfl3Xp_.js +1 -0
- package/ui/dist/_app/immutable/chunks/C6nSKt5F.js +50 -0
- package/ui/dist/_app/immutable/chunks/CcD_ISxM.js +3 -0
- package/ui/dist/_app/immutable/chunks/DWNBcI-9.js +1 -0
- package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/ui/dist/_app/immutable/entry/app.Dr8IgX3r.js +2 -0
- package/ui/dist/_app/immutable/entry/start.Dy9qIspC.js +1 -0
- package/ui/dist/_app/immutable/nodes/0.zxJ_VvIF.js +1 -0
- package/ui/dist/_app/immutable/nodes/1.DasPjSN2.js +1 -0
- package/ui/dist/_app/immutable/nodes/2.9VqQT6jB.js +1 -0
- package/ui/dist/_app/immutable/nodes/3.D8a8RCsW.js +1 -0
- package/ui/dist/_app/immutable/nodes/4.y19mXraP.js +1 -0
- package/ui/dist/_app/version.json +1 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/favicon-96x96.png +0 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/index.html +52 -0
- package/ui/dist/robots.txt +3 -0
- package/transpiled/tsconfig.tsbuildinfo +0 -1
package/source/UI.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as http from 'node:http'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { Connector } from '@toa.io/core'
|
|
5
|
+
import { console } from 'openspan'
|
|
6
|
+
import { UI_PATH } from './const.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serves the configuration UI: the directory `ui` builds, and nothing else.
|
|
10
|
+
*
|
|
11
|
+
* The page is a single-page application whose mount path is baked into the bundle,
|
|
12
|
+
* so this server routes relative to `UI_PATH` — which is also what the ingress
|
|
13
|
+
* forwards. `/configuration/*` is left alone: that is the component's own API.
|
|
14
|
+
*/
|
|
15
|
+
export class UI extends Connector {
|
|
16
|
+
private readonly server: http.Server = http.createServer()
|
|
17
|
+
private readonly port: number
|
|
18
|
+
private readonly root: string
|
|
19
|
+
|
|
20
|
+
public constructor (port: number, root: string = SITE) {
|
|
21
|
+
super()
|
|
22
|
+
|
|
23
|
+
this.port = port
|
|
24
|
+
this.root = root
|
|
25
|
+
this.server.on('request', (request, response) => {
|
|
26
|
+
this.listen(request, response)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
protected override async open (): Promise<void> {
|
|
31
|
+
/*
|
|
32
|
+
* A taken port is a real error: uniqueness across services is settled at export
|
|
33
|
+
* time, so nothing here has to negotiate for one.
|
|
34
|
+
*/
|
|
35
|
+
await new Promise<void>((resolve, reject) => {
|
|
36
|
+
const failed = (error: Error): void => {
|
|
37
|
+
this.server.off('listening', listening)
|
|
38
|
+
reject(error)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const listening = (): void => {
|
|
42
|
+
this.server.off('error', failed)
|
|
43
|
+
resolve()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.server.once('error', failed)
|
|
47
|
+
this.server.once('listening', listening)
|
|
48
|
+
this.server.listen(this.port)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (!isFile(path.join(this.root, 'index.html')))
|
|
52
|
+
console.warn('Configuration UI is not built', { root: this.root })
|
|
53
|
+
|
|
54
|
+
console.info('Configuration UI started', { port: this.port, path: UI_PATH + '/' })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected override async close (): Promise<void> {
|
|
58
|
+
const closed = new Promise<void>((resolve) => this.server.once('close', () => {
|
|
59
|
+
resolve()
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
this.server.close()
|
|
63
|
+
this.server.closeAllConnections()
|
|
64
|
+
|
|
65
|
+
await closed
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private listen (request: http.IncomingMessage, response: http.ServerResponse): void {
|
|
69
|
+
void this.respond(request, response).catch((error: Error) => {
|
|
70
|
+
console.error('Configuration UI failure', { message: error.message })
|
|
71
|
+
|
|
72
|
+
if (!response.writableEnded)
|
|
73
|
+
response.writeHead(500).end()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async respond (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
|
|
78
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
79
|
+
response.writeHead(405, { allow: 'GET, HEAD' }).end()
|
|
80
|
+
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pathname = decode(request.url ?? '/')
|
|
85
|
+
|
|
86
|
+
if (pathname === null) {
|
|
87
|
+
response.writeHead(400).end()
|
|
88
|
+
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// the port is the explorer's alone, so the root is the way in
|
|
93
|
+
if (pathname === '/') {
|
|
94
|
+
response.writeHead(302, { location: UI_PATH + '/' }).end()
|
|
95
|
+
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const file = this.resolve(pathname)
|
|
100
|
+
|
|
101
|
+
if (file === null)
|
|
102
|
+
response.writeHead(404).end()
|
|
103
|
+
else
|
|
104
|
+
await this.send(file, request, response)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The file a request lands on, or `null` when nothing does. A path that exists is
|
|
109
|
+
* served as it is; anything else that could be a route falls back to the page,
|
|
110
|
+
* because the client router — not this server — knows what routes there are.
|
|
111
|
+
*/
|
|
112
|
+
private resolve (pathname: string): string | null {
|
|
113
|
+
if (!pathname.startsWith(UI_PATH))
|
|
114
|
+
return null
|
|
115
|
+
|
|
116
|
+
const relative = pathname.slice(UI_PATH.length)
|
|
117
|
+
|
|
118
|
+
if (relative !== '' && !relative.startsWith('/'))
|
|
119
|
+
return null
|
|
120
|
+
|
|
121
|
+
const file = path.join(this.root, relative)
|
|
122
|
+
|
|
123
|
+
if (file !== this.root && !file.startsWith(this.root + path.sep))
|
|
124
|
+
return null
|
|
125
|
+
|
|
126
|
+
if (isFile(file))
|
|
127
|
+
return file
|
|
128
|
+
|
|
129
|
+
/*
|
|
130
|
+
* A missing asset is missing, but a route can look like one: `identity.passkeys` is a
|
|
131
|
+
* component, not a file with an extension. What this server would have served is what
|
|
132
|
+
* it knows how to serve, so anything else is a route and falls back to the page — as
|
|
133
|
+
* does anything ending in a slash, which is no name for a file at all.
|
|
134
|
+
*/
|
|
135
|
+
const asset = !relative.endsWith('/') && path.extname(relative) in TYPES
|
|
136
|
+
|
|
137
|
+
return asset ? null : path.join(this.root, 'index.html')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async send (file: string, request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
|
|
141
|
+
const stats = await fs.promises.stat(file).catch(() => null)
|
|
142
|
+
|
|
143
|
+
if (stats === null) {
|
|
144
|
+
response.writeHead(503, { 'content-type': 'text/plain' })
|
|
145
|
+
.end('The configuration UI is not built. Run `npm run build:ui`.\n')
|
|
146
|
+
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
response.writeHead(200, {
|
|
151
|
+
'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream',
|
|
152
|
+
'content-length': stats.size,
|
|
153
|
+
'cache-control': caching(path.relative(this.root, file))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (request.method === 'HEAD')
|
|
157
|
+
response.end()
|
|
158
|
+
else
|
|
159
|
+
fs.createReadStream(file).pipe(response)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function decode (url: string): string | null {
|
|
164
|
+
try {
|
|
165
|
+
return decodeURIComponent(url.split('?')[0])
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isFile (file: string): boolean {
|
|
172
|
+
return fs.existsSync(file) && fs.statSync(file).isFile()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Where `npm run build:ui` puts the page, from both `source` and `transpiled`. */
|
|
176
|
+
const SITE = path.resolve(import.meta.dirname, '..', 'ui', 'dist')
|
|
177
|
+
|
|
178
|
+
/** Assets under this prefix carry their build hash in the name. */
|
|
179
|
+
const IMMUTABLE = path.join('_app', 'immutable')
|
|
180
|
+
const FOREVER = 'public, max-age=31536000, immutable'
|
|
181
|
+
|
|
182
|
+
/** The icons: named without a hash, so they are re-read, but rarely. */
|
|
183
|
+
const ICONS = new Set(['favicon.ico', 'favicon-96x96.png', 'apple-touch-icon.png'])
|
|
184
|
+
const DAY = 'public, max-age=86400'
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* How long what is served may be held. The page is never cached — it names the assets,
|
|
188
|
+
* and their names carry the build. An icon is named for what it is rather than for its
|
|
189
|
+
* content, so it is asked about again, but not on every page load.
|
|
190
|
+
*/
|
|
191
|
+
function caching (relative: string): string {
|
|
192
|
+
if (relative.startsWith(IMMUTABLE)) return FOREVER
|
|
193
|
+
|
|
194
|
+
return ICONS.has(relative) ? DAY : 'no-cache'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const TYPES: Record<string, string> = {
|
|
198
|
+
'.css': 'text/css; charset=utf-8',
|
|
199
|
+
'.html': 'text/html; charset=utf-8',
|
|
200
|
+
'.ico': 'image/x-icon',
|
|
201
|
+
'.jpg': 'image/jpeg',
|
|
202
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
203
|
+
'.json': 'application/json; charset=utf-8',
|
|
204
|
+
'.map': 'application/json; charset=utf-8',
|
|
205
|
+
'.png': 'image/png',
|
|
206
|
+
'.svg': 'image/svg+xml',
|
|
207
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
208
|
+
'.webmanifest': 'application/manifest+json',
|
|
209
|
+
'.webp': 'image/webp',
|
|
210
|
+
'.woff': 'font/woff',
|
|
211
|
+
'.woff2': 'font/woff2'
|
|
212
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
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 = {
|
|
15
|
+
manifest = {
|
|
16
|
+
schema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { foo: { type: 'string' } }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
13
21
|
})
|
|
14
22
|
|
|
15
23
|
afterEach(() => {
|
|
@@ -19,67 +27,115 @@ afterEach(() => {
|
|
|
19
27
|
used = []
|
|
20
28
|
})
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
describe('overridden', () => {
|
|
31
|
+
it('should be false without the variable', async () => {
|
|
32
|
+
assert.deepStrictEqual(overridden(locator), false)
|
|
33
|
+
})
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
it('should be true with the variable', async () => {
|
|
36
|
+
set({})
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
assert.deepStrictEqual(overridden(locator), true)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
describe('local', () => {
|
|
43
|
+
it('should read value', async () => {
|
|
44
|
+
const value: object = { foo: generate() }
|
|
30
45
|
|
|
31
|
-
|
|
32
|
-
})
|
|
46
|
+
set(value)
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
})
|
|
48
|
+
assert.deepStrictEqual(local(locator, manifest), value)
|
|
49
|
+
})
|
|
37
50
|
|
|
38
|
-
it('should
|
|
39
|
-
|
|
51
|
+
it('should return empty object if no value set', async () => {
|
|
52
|
+
assert.deepStrictEqual(local(locator, manifest), {})
|
|
53
|
+
})
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
it('should substitute secrets', async () => {
|
|
56
|
+
set({ foo: '$BAR' })
|
|
57
|
+
set('bar', '_BAR')
|
|
43
58
|
|
|
44
|
-
|
|
59
|
+
const { foo } = local(locator, manifest)
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
assert.ok(foo instanceof Secret)
|
|
62
|
+
assert.deepStrictEqual((foo as Secret).unwrap(), 'bar')
|
|
63
|
+
assert.deepStrictEqual(String(foo), '<REDACTED>')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should substitute secrets in defaults', async () => {
|
|
67
|
+
manifest.defaults = { foo: '$BAR' }
|
|
48
68
|
|
|
49
|
-
|
|
50
|
-
manifest.schema = { foo: 'string', bar: ['number'], 'baz?': 'string' }
|
|
51
|
-
manifest.defaults = { foo: 'bar', bar: [1] }
|
|
69
|
+
set('bar', '_BAR')
|
|
52
70
|
|
|
53
|
-
|
|
71
|
+
assert.deepStrictEqual((local(locator, manifest).foo as Secret).unwrap(), 'bar')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should use defaults', async () => {
|
|
75
|
+
manifest.schema = {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
foo: { type: 'string' },
|
|
79
|
+
bar: { type: 'array', items: { type: 'number' } },
|
|
80
|
+
baz: { type: 'string' }
|
|
81
|
+
},
|
|
82
|
+
required: ['foo', 'bar']
|
|
83
|
+
}
|
|
84
|
+
manifest.defaults = { foo: 'bar', bar: [1] }
|
|
85
|
+
|
|
86
|
+
set({ bar: [2], baz: 'foo' })
|
|
87
|
+
|
|
88
|
+
assert.deepStrictEqual(local(locator, manifest), { foo: 'bar', bar: [2], baz: 'foo' })
|
|
89
|
+
})
|
|
54
90
|
|
|
55
|
-
|
|
91
|
+
it('should validate', async () => {
|
|
92
|
+
manifest.schema = {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
foo: { type: 'string', default: 'hello' },
|
|
96
|
+
bar: { type: 'number' }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
56
99
|
|
|
57
|
-
|
|
100
|
+
set({ bar: 5 })
|
|
58
101
|
|
|
59
|
-
|
|
60
|
-
foo: 'bar',
|
|
61
|
-
bar: [2],
|
|
62
|
-
baz: 'foo'
|
|
102
|
+
assert.deepStrictEqual(local(locator, manifest), { foo: 'hello', bar: 5 })
|
|
63
103
|
})
|
|
64
104
|
})
|
|
65
105
|
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
describe('fit', () => {
|
|
107
|
+
it('should substitute secrets and apply the schema', async () => {
|
|
108
|
+
manifest.schema = {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
foo: { type: 'string' },
|
|
112
|
+
bar: { type: 'number', default: 1 }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
68
115
|
|
|
69
|
-
|
|
116
|
+
set('secret', '_FOO')
|
|
70
117
|
|
|
71
|
-
|
|
118
|
+
const raw = { foo: '$FOO' }
|
|
119
|
+
const values = fit(raw, manifest)
|
|
72
120
|
|
|
73
|
-
|
|
121
|
+
assert.deepStrictEqual((values.foo as Secret).unwrap(), 'secret')
|
|
122
|
+
assert.deepStrictEqual(values.bar, 1)
|
|
123
|
+
assert.deepStrictEqual(raw, { foo: '$FOO' }) // untouched
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should not apply the manifest defaults', async () => {
|
|
127
|
+
manifest.defaults = { foo: 'hello' }
|
|
128
|
+
|
|
129
|
+
assert.deepStrictEqual(fit({}, manifest), {})
|
|
130
|
+
})
|
|
74
131
|
|
|
75
|
-
|
|
76
|
-
foo:
|
|
77
|
-
bar: 5
|
|
132
|
+
it('should throw on a value not fitting the schema', async () => {
|
|
133
|
+
assert.throws(() => fit({ foo: { nested: true } }, manifest))
|
|
78
134
|
})
|
|
79
135
|
})
|
|
80
136
|
|
|
81
137
|
function set (value: object | string, key = locator.uppercase): void {
|
|
82
|
-
const string = typeof value === 'string' ? value :
|
|
138
|
+
const string = typeof value === 'string' ? value : JSON.stringify(value)
|
|
83
139
|
const name = 'TOA_CONFIGURATION_' + key
|
|
84
140
|
|
|
85
141
|
process.env[name] = string
|
package/source/configuration.ts
CHANGED
|
@@ -1,33 +1,52 @@
|
|
|
1
1
|
import { type Locator } from '@toa.io/core'
|
|
2
|
-
import {
|
|
2
|
+
import { add } from '@toa.io/generic'
|
|
3
3
|
import * as schemas from '@toa.io/schemas'
|
|
4
|
-
import { PREFIX, SECRET_RX } from './
|
|
5
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
9
|
+
/** The variable is set, so the values service is not consulted. */
|
|
10
|
+
export function overridden (locator: Locator): boolean {
|
|
11
|
+
return process.env[PREFIX + locator.uppercase] !== undefined
|
|
12
|
+
}
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
/** The variable, the manifest defaults, then the schema. */
|
|
15
|
+
export function local (locator: Locator, manifest: Manifest): Node {
|
|
16
|
+
const values = read(locator.uppercase)
|
|
12
17
|
|
|
13
18
|
if (manifest.defaults !== undefined)
|
|
14
19
|
add(values, manifest.defaults)
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
return fit(values, manifest)
|
|
22
|
+
}
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
/** A copy of the value with the schema applied and the secrets substituted. */
|
|
25
|
+
export function fit (raw: object, manifest: Manifest): Node {
|
|
26
|
+
// a copy of the caller's, and one of this realm: what came over the wire is JSON anyway
|
|
27
|
+
const values = JSON.parse(JSON.stringify(raw)) as Node
|
|
28
|
+
|
|
29
|
+
// the schema sees the references, which are the strings it declares
|
|
30
|
+
validate(values, manifest)
|
|
31
|
+
substituteSecrets(values)
|
|
19
32
|
|
|
20
33
|
return values
|
|
21
34
|
}
|
|
22
35
|
|
|
23
|
-
function
|
|
36
|
+
function validate (values: Node, manifest: Manifest): void {
|
|
37
|
+
const schema: Schema<any> = schemas.schema(manifest.schema)
|
|
38
|
+
|
|
39
|
+
schema.validate(values)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function read (suffix: string): Node {
|
|
24
43
|
const variable = PREFIX + suffix
|
|
25
44
|
const string = process.env[variable]
|
|
26
45
|
|
|
27
46
|
if (string === undefined)
|
|
28
47
|
return {}
|
|
29
48
|
else
|
|
30
|
-
return
|
|
49
|
+
return JSON.parse(string)
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
function substituteSecrets (configuration: Node): void {
|
|
@@ -43,7 +62,7 @@ function substituteSecrets (configuration: Node): void {
|
|
|
43
62
|
|
|
44
63
|
const name = match.groups?.variable
|
|
45
64
|
|
|
46
|
-
configuration[key] = getSecret(name!)
|
|
65
|
+
configuration[key] = new Secret(getSecret(name!))
|
|
47
66
|
}
|
|
48
67
|
}
|
|
49
68
|
|
package/source/const.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Source } from '@toa.io/core'
|
|
2
|
+
|
|
3
|
+
/** Per-component variables: the local override and the secrets. */
|
|
4
|
+
export const PREFIX = 'TOA_CONFIGURATION_'
|
|
5
|
+
|
|
6
|
+
/** The map of every configured component, on the values service. */
|
|
7
|
+
export const VALUES = 'TOA_CONFIGURATION_VALUES'
|
|
8
|
+
|
|
9
|
+
export const SECRET_RX = /^\$(?<variable>[A-Z0-9_]{1,32})$/
|
|
10
|
+
|
|
11
|
+
/** Where the UI is mounted; `/configuration/*` belongs to the component's own API. */
|
|
12
|
+
export const UI_PATH = '/.configuration'
|
|
13
|
+
export const UI_PORT = 8003
|
|
14
|
+
|
|
15
|
+
export const EVENT = 'configuration.values.created'
|
|
16
|
+
|
|
17
|
+
export const SOURCE: Source = { service: 'configuration' }
|
|
@@ -1,21 +1,87 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { Locator } from '@toa.io/core'
|
|
5
|
+
import { type Annotation, type Instance, deployment, describe as map } from './deployment.js'
|
|
6
|
+
import { epoch } from './epoch.js'
|
|
7
|
+
import { EVENT, VALUES } from './const.js'
|
|
8
|
+
|
|
9
|
+
const schema = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: { foo: { type: 'string' }, key: { type: 'string' } }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function instance (name: string, defaults?: Record<string, unknown>): Instance {
|
|
15
|
+
return {
|
|
16
|
+
locator: new Locator(name, 'configuration'),
|
|
17
|
+
manifest: { schema, defaults },
|
|
18
|
+
component: {} as any
|
|
19
|
+
}
|
|
20
|
+
}
|
|
3
21
|
|
|
4
22
|
it('should validate annotation', async () => {
|
|
5
23
|
const wrongType = 'not ok' as unknown as Annotation
|
|
6
24
|
|
|
7
|
-
|
|
25
|
+
assert.throws(() => deployment([], wrongType), (error: any) => /object/.test(error.message))
|
|
8
26
|
})
|
|
9
27
|
|
|
10
|
-
it('should
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
28
|
+
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))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should deploy the values service with the map', async () => {
|
|
33
|
+
const instances = [instance('base', { foo: 'hello' }), instance('other')]
|
|
34
|
+
const annotation = { 'configuration.other': { foo: 'set' } }
|
|
35
|
+
|
|
36
|
+
const dependency = deployment(instances, annotation)
|
|
37
|
+
|
|
38
|
+
assert.deepStrictEqual(dependency.events, [EVENT])
|
|
39
|
+
assert.strictEqual(dependency.services?.length, 1)
|
|
40
|
+
|
|
41
|
+
const service = dependency.services![0]
|
|
42
|
+
|
|
43
|
+
assert.deepStrictEqual(service.group, 'configuration')
|
|
44
|
+
assert.deepStrictEqual(service.name, 'values')
|
|
45
|
+
assert.deepStrictEqual(service.components, ['configuration-values'])
|
|
46
|
+
|
|
47
|
+
const variable = service.variables!.find((variable) => variable.name === VALUES)
|
|
48
|
+
|
|
49
|
+
assert.notStrictEqual(variable, undefined)
|
|
50
|
+
assert.deepStrictEqual(JSON.parse(variable!.value!), map(instances, annotation))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should describe every component', async () => {
|
|
54
|
+
const instances = [instance('base', { foo: 'hello' }), instance('other')]
|
|
55
|
+
const values = map(instances, { 'configuration.other': { foo: 'set' } })
|
|
56
|
+
|
|
57
|
+
assert.deepStrictEqual(values, {
|
|
58
|
+
'configuration.base': { epoch: epoch(schema), schema, defaults: { foo: 'hello' } },
|
|
59
|
+
'configuration.other': { epoch: epoch(schema), schema, defaults: { foo: 'set' } }
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should prefer the context over the manifest defaults', async () => {
|
|
64
|
+
const values = map([instance('base', { foo: 'hello' })], { 'configuration.base': { foo: 'bye' } })
|
|
65
|
+
|
|
66
|
+
assert.deepStrictEqual(values['configuration.base'].defaults, { foo: 'bye' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should not map values to the component', async () => {
|
|
70
|
+
const dependency = deployment([instance('base')], { 'configuration.base': { foo: 'set' } })
|
|
71
|
+
|
|
72
|
+
assert.deepStrictEqual(dependency.variables, {})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should map secrets to the component', async () => {
|
|
76
|
+
const dependency = deployment([instance('base')], { 'configuration.base': { key: '$KEY' } })
|
|
15
77
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
78
|
+
assert.deepStrictEqual(dependency.variables, {
|
|
79
|
+
'configuration-base': [{
|
|
80
|
+
name: 'TOA_CONFIGURATION__KEY',
|
|
81
|
+
secret: { name: 'toa-configuration', key: 'KEY' }
|
|
82
|
+
}]
|
|
83
|
+
})
|
|
19
84
|
|
|
20
|
-
|
|
85
|
+
// the service holds the reference, not the secret
|
|
86
|
+
assert.deepStrictEqual(map([instance('base')], { 'configuration.base': { key: '$KEY' } })['configuration.base'].defaults, { key: '$KEY' })
|
|
21
87
|
})
|