@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.
- package/CHANGELOG.md +35 -0
- package/components/configuration.values/manifest.toa.yaml +151 -0
- package/components/configuration.values/migrations/0002-indexes.yaml +7 -0
- package/components/configuration.values/operations/create.js +30 -0
- package/components/configuration.values/operations/fetch.js +13 -0
- package/components/configuration.values/operations/get.js +19 -0
- package/components/configuration.values/operations/lib/map.js +22 -0
- package/components/configuration.values/operations/lib/resolve.js +24 -0
- package/components/configuration.values/operations/list.js +11 -0
- package/components/configuration.values/source/create.ts +54 -0
- package/components/configuration.values/source/fetch.ts +26 -0
- package/components/configuration.values/source/get.ts +32 -0
- package/components/configuration.values/source/lib/map.test.ts +42 -0
- package/components/configuration.values/source/lib/map.ts +36 -0
- package/components/configuration.values/source/lib/resolve.ts +57 -0
- package/components/configuration.values/source/list.ts +21 -0
- package/components/configuration.values/tsconfig.json +10 -0
- package/components/configuration.values/tsconfig.tsbuildinfo +1 -0
- package/components/configuration.values/types/index.d.ts +3 -0
- package/components/configuration.values/types/toa.d.ts +68 -0
- package/cucumber.mjs +8 -0
- package/features/site/_app/immutable/asset.js +1 -0
- package/features/site/favicon.ico +0 -0
- package/features/site/index.html +10 -0
- package/features/steps/UI.ts +98 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/ui.feature +93 -0
- package/notes/consistency.md +10 -9
- package/package.json +11 -10
- package/readme.md +165 -51
- package/schemas/annotation.cos.yaml +29 -1
- package/schemas/manifest.cos.yaml +9 -2
- package/source/Aspect.test.ts +114 -10
- package/source/Aspect.ts +76 -8
- package/source/Client.test.ts +125 -0
- package/source/Client.ts +251 -0
- package/source/Composition.ts +57 -0
- package/source/Factory.ts +29 -7
- package/source/Secret.test.ts +32 -0
- package/source/Secret.ts +32 -0
- package/source/UI.ts +213 -0
- package/source/configuration.test.ts +105 -43
- package/source/configuration.ts +36 -21
- package/source/const.ts +30 -0
- package/source/context.ts +13 -0
- package/source/deployment.test.ts +103 -12
- package/source/deployment.ts +105 -31
- package/source/epoch.test.ts +35 -0
- package/source/epoch.ts +22 -0
- package/source/index.ts +10 -3
- package/source/manifest.test.ts +16 -7
- package/source/manifest.ts +3 -5
- package/source/schemas.ts +1 -1
- package/transpiled/Aspect.d.ts +19 -4
- package/transpiled/Aspect.js +62 -9
- package/transpiled/Aspect.js.map +1 -1
- package/transpiled/Client.d.ts +61 -0
- package/transpiled/Client.js +175 -0
- package/transpiled/Client.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 +9 -2
- package/transpiled/Factory.js +20 -9
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Secret.d.ts +15 -0
- package/transpiled/Secret.js +26 -0
- package/transpiled/Secret.js.map +1 -0
- package/transpiled/UI.d.ts +25 -0
- package/transpiled/UI.js +173 -0
- package/transpiled/UI.js.map +1 -0
- package/transpiled/configuration.d.ts +7 -2
- package/transpiled/configuration.js +32 -43
- package/transpiled/configuration.js.map +1 -1
- package/transpiled/const.d.ts +18 -0
- package/transpiled/const.js +22 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/context.d.ts +4 -0
- package/transpiled/context.js +9 -0
- package/transpiled/context.js.map +1 -0
- package/transpiled/deployment.d.ts +12 -3
- package/transpiled/deployment.js +76 -57
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/epoch.d.ts +2 -0
- package/transpiled/epoch.js +19 -0
- package/transpiled/epoch.js.map +1 -0
- package/transpiled/index.d.ts +6 -3
- package/transpiled/index.js +8 -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/schemas.js +5 -31
- package/transpiled/schemas.js.map +1 -1
- package/tsconfig.json +5 -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/3Kbn_1Vv.js +1 -0
- package/ui/dist/_app/immutable/chunks/BV5cDXGB.js +50 -0
- package/ui/dist/_app/immutable/chunks/B_CH3nFk.js +1 -0
- package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/ui/dist/_app/immutable/chunks/CNZAv2Es.js +8 -0
- package/ui/dist/_app/immutable/chunks/CcD_ISxM.js +3 -0
- package/ui/dist/_app/immutable/chunks/vl8APhvf.js +6 -0
- package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/ui/dist/_app/immutable/entry/app.CK-db-gz.js +2 -0
- package/ui/dist/_app/immutable/entry/start.Bs7xr9Ne.js +1 -0
- package/ui/dist/_app/immutable/nodes/0.vPwVABRs.js +1 -0
- package/ui/dist/_app/immutable/nodes/1.DS63PME5.js +1 -0
- package/ui/dist/_app/immutable/nodes/2.KRpWYBfz.js +1 -0
- package/ui/dist/_app/immutable/nodes/3.DCxORePS.js +1 -0
- package/ui/dist/_app/immutable/nodes/4.D8lluH3u.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 +57 -0
- package/ui/dist/robots.txt +3 -0
- package/transpiled/Aspect.test.d.ts +0 -1
- package/transpiled/Aspect.test.js +0 -15
- package/transpiled/Aspect.test.js.map +0 -1
- package/transpiled/configuration.test.d.ts +0 -1
- package/transpiled/configuration.test.js +0 -64
- package/transpiled/configuration.test.js.map +0 -1
- package/transpiled/deployment.test.d.ts +0 -1
- package/transpiled/deployment.test.js +0 -18
- package/transpiled/deployment.test.js.map +0 -1
- package/transpiled/manifest.test.d.ts +0 -1
- package/transpiled/manifest.test.js +0 -14
- package/transpiled/manifest.test.js.map +0 -1
- package/transpiled/tsconfig.tsbuildinfo +0 -1
package/readme.md
CHANGED
|
@@ -11,8 +11,12 @@ namespace: dummies
|
|
|
11
11
|
|
|
12
12
|
configuration:
|
|
13
13
|
schema:
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
type: object
|
|
15
|
+
properties:
|
|
16
|
+
foo:
|
|
17
|
+
type: string
|
|
18
|
+
bar:
|
|
19
|
+
type: number
|
|
16
20
|
defaults:
|
|
17
21
|
foo: bar
|
|
18
22
|
bar: 1
|
|
@@ -21,7 +25,7 @@ configuration:
|
|
|
21
25
|
### Use
|
|
22
26
|
|
|
23
27
|
```javascript
|
|
24
|
-
function transition
|
|
28
|
+
function transition(input, entity, context) {
|
|
25
29
|
const { foo, bar } = context.configuration
|
|
26
30
|
|
|
27
31
|
// ...
|
|
@@ -34,9 +38,9 @@ function transition (input, entity, context) {
|
|
|
34
38
|
# context.toa.yaml
|
|
35
39
|
configuration:
|
|
36
40
|
dummies.dummy:
|
|
37
|
-
foo: qux
|
|
41
|
+
foo: qux # override default value
|
|
38
42
|
foo@staging: quux # deployment environment discriminator
|
|
39
|
-
bar: $BAZ_VALUE
|
|
43
|
+
bar: $BAZ_VALUE # secret
|
|
40
44
|
```
|
|
41
45
|
|
|
42
46
|
### Deploy secrets
|
|
@@ -45,22 +49,28 @@ configuration:
|
|
|
45
49
|
$ toa conceal configuration BAZ_VALUE=$ecr3t
|
|
46
50
|
```
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
### Change at runtime
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
```http
|
|
55
|
+
POST /configuration/values/dummies.dummy/ HTTP/1.1
|
|
56
|
+
authorization: Token ...
|
|
57
|
+
content-type: application/yaml
|
|
51
58
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
configuration:
|
|
60
|
+
foo: quux
|
|
61
|
+
bar: 2
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
55
65
|
|
|
56
66
|
## Manifest
|
|
57
67
|
|
|
58
|
-
Component's configuration is declared using `configuration` manifest,
|
|
59
|
-
|
|
68
|
+
Component's configuration is declared using the `configuration` manifest, containing `schema`
|
|
69
|
+
and optionally `defaults` properties.
|
|
60
70
|
|
|
61
71
|
### Schema
|
|
62
72
|
|
|
63
|
-
Configuration schema is declared with [
|
|
73
|
+
Configuration schema is declared with [JSON Schema](https://json-schema.org).
|
|
64
74
|
|
|
65
75
|
```yaml
|
|
66
76
|
# manifest.toa.yaml
|
|
@@ -69,30 +79,18 @@ namespace: dummies
|
|
|
69
79
|
|
|
70
80
|
configuration:
|
|
71
81
|
schema:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
> Therefore, configuration schema changes are subject to component versioning.
|
|
79
|
-
|
|
80
|
-
If `configuration` object doesn't contain property `schema`, then it is considered to be schema.
|
|
81
|
-
|
|
82
|
-
```yaml
|
|
83
|
-
# manifest.toa.yaml
|
|
84
|
-
name: dummy
|
|
85
|
-
namespace: dummies
|
|
86
|
-
|
|
87
|
-
configuration:
|
|
88
|
-
foo: string
|
|
89
|
-
bar: number
|
|
82
|
+
type: object
|
|
83
|
+
properties:
|
|
84
|
+
foo:
|
|
85
|
+
type: string
|
|
86
|
+
bar:
|
|
87
|
+
type: number
|
|
90
88
|
```
|
|
91
89
|
|
|
92
90
|
### Defaults
|
|
93
91
|
|
|
94
|
-
The default configuration value can be provided using the `defaults` property, which should
|
|
95
|
-
to the configuration schema.
|
|
92
|
+
The default configuration value can be provided using the `defaults` property, which should
|
|
93
|
+
conform to the configuration schema.
|
|
96
94
|
|
|
97
95
|
```yaml
|
|
98
96
|
# manifest.toa.yaml
|
|
@@ -101,27 +99,22 @@ namespace: dummies
|
|
|
101
99
|
|
|
102
100
|
configuration:
|
|
103
101
|
schema:
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
type: object
|
|
103
|
+
properties:
|
|
104
|
+
foo:
|
|
105
|
+
type: string
|
|
106
|
+
bar:
|
|
107
|
+
type: number
|
|
106
108
|
defaults:
|
|
107
109
|
foo: hello
|
|
108
110
|
bar: 0
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
### Epoch
|
|
112
114
|
|
|
113
|
-
The configuration
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# manifest.toa.yaml
|
|
117
|
-
name: dummy
|
|
118
|
-
namespace: dummies
|
|
119
|
-
|
|
120
|
-
configuration:
|
|
121
|
-
schema:
|
|
122
|
-
foo: hello
|
|
123
|
-
bar: 0
|
|
124
|
-
```
|
|
115
|
+
The configuration epoch of a component is the SHA-256 of its configuration schema, as
|
|
116
|
+
canonical JSON. A configuration object belongs to the epoch of the schema it was validated
|
|
117
|
+
against. A schema change is a new epoch.
|
|
125
118
|
|
|
126
119
|
## Annotation
|
|
127
120
|
|
|
@@ -136,9 +129,26 @@ configuration:
|
|
|
136
129
|
bar@staging: 2
|
|
137
130
|
```
|
|
138
131
|
|
|
132
|
+
The annotated object is deployed as the defaults of the component for its epoch, in place of
|
|
133
|
+
the manifest `defaults`.
|
|
134
|
+
|
|
135
|
+
Every key names a component except `resources`, which is the values service's own: it deploys
|
|
136
|
+
like any other and states what it may take. A component actually named `resources` is written
|
|
137
|
+
with its namespace, `default.resources` — the bare form is only shorthand for that.
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
# context.toa.yaml
|
|
141
|
+
configuration:
|
|
142
|
+
resources:
|
|
143
|
+
cpu: [200m, 1000m]
|
|
144
|
+
memory: [200Mi, 500Mi]
|
|
145
|
+
dummies.dummy:
|
|
146
|
+
foo: bye
|
|
147
|
+
```
|
|
148
|
+
|
|
139
149
|
## Secrets
|
|
140
150
|
|
|
141
|
-
Configuration
|
|
151
|
+
Configuration values which are uppercase strings prefixed with `$` are considered as secrets.
|
|
142
152
|
|
|
143
153
|
```yaml
|
|
144
154
|
# context.toa.yaml
|
|
@@ -158,14 +168,118 @@ Deployed kubernetes secret's name is predefined as `configuration`.
|
|
|
158
168
|
$ toa conceal configuration STRIPE_API_KEY=xxxxxxxx
|
|
159
169
|
```
|
|
160
170
|
|
|
171
|
+
A secret is substituted in the component's process, from the variable
|
|
172
|
+
`TOA_CONFIGURATION__<NAME>` deployed to it — a variable the component's code cannot read.
|
|
173
|
+
The values service holds and returns the reference.
|
|
174
|
+
|
|
175
|
+
In the component, a secret is a `Secret` object: `unwrap()` returns the string, while
|
|
176
|
+
`toString()`, JSON and `util.inspect` give `<REDACTED>`.
|
|
177
|
+
|
|
178
|
+
```javascript
|
|
179
|
+
function transition(input, entity, context) {
|
|
180
|
+
const key = context.configuration.apiKey.unwrap()
|
|
181
|
+
|
|
182
|
+
// ...
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A value a component reads as a secret is given as a reference: a plain string in its place
|
|
187
|
+
has no `unwrap`.
|
|
188
|
+
|
|
189
|
+
## Values
|
|
190
|
+
|
|
191
|
+
Configuration values are held by the `configuration.values` component, deployed as the
|
|
192
|
+
`configuration-values` service. The service is deployed with the variable
|
|
193
|
+
`TOA_CONFIGURATION_VALUES`: the epoch, the schema and the defaults of every component
|
|
194
|
+
declaring configuration.
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"dummies.dummy": {
|
|
199
|
+
"epoch": "3f2a…",
|
|
200
|
+
"schema": { "type": "object", "properties": { "foo": { "type": "string" } } },
|
|
201
|
+
"defaults": { "foo": "bye" }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Configuration objects are immutable. Creating a configuration is creating a new object for
|
|
207
|
+
the component's current epoch; the latest object for a component and an epoch is the last
|
|
208
|
+
created one. Each object records its `originator`.
|
|
209
|
+
|
|
210
|
+
The configuration of a component for an epoch is:
|
|
211
|
+
|
|
212
|
+
1. The latest object created for the component and the epoch;
|
|
213
|
+
2. Otherwise, the deployed defaults, if the epoch is the deployed one;
|
|
214
|
+
3. Otherwise, none.
|
|
215
|
+
|
|
216
|
+
### Operations
|
|
217
|
+
|
|
218
|
+
- `get({ component, epoch? })`: the configuration with the schema it is checked against, as
|
|
219
|
+
`{ configuration, schema, epoch }`, or `null` when there is none. The epoch is the deployed
|
|
220
|
+
one when omitted; an epoch the deployment does not know has no `schema`.
|
|
221
|
+
- `fetch([{ component, epoch }])`: the same for several pairs at once, as
|
|
222
|
+
`[{ component, epoch, configuration }]`.
|
|
223
|
+
- `list()`: every component's configuration for its deployed epoch, by component name, as
|
|
224
|
+
`[{ component, epoch, schema, configuration }]`.
|
|
225
|
+
- `create({ component, configuration, originator })`: a new object for the component's
|
|
226
|
+
deployed epoch. The configuration must satisfy the schema. Errors: `UNKNOWN_COMPONENT`,
|
|
227
|
+
`INVALID_CONFIGURATION`.
|
|
228
|
+
|
|
229
|
+
Creating a configuration publishes the `configuration.values.created` event with the
|
|
230
|
+
object as stored.
|
|
231
|
+
|
|
232
|
+
### Resources
|
|
233
|
+
|
|
234
|
+
| Method | Path | Role |
|
|
235
|
+
| ------ | ----------------------------------- | ----------------------------- |
|
|
236
|
+
| `GET` | `/configuration/values/` | `system:configuration:get` |
|
|
237
|
+
| `GET` | `/configuration/values/:component/` | `system:configuration:get` |
|
|
238
|
+
| `POST` | `/configuration/values/:component/` | `system:configuration:create` |
|
|
239
|
+
|
|
240
|
+
`GET /configuration/values/` lists every component's configuration for its deployed epoch, by
|
|
241
|
+
component name, as `[{ component, epoch, schema, configuration }]`.
|
|
242
|
+
|
|
243
|
+
`GET /configuration/values/:component/` returns `{ configuration, schema, epoch }` for the
|
|
244
|
+
deployed epoch, `404` when there is none.
|
|
245
|
+
|
|
246
|
+
`POST` takes `{ configuration }`, records the Identity as the `originator`, and returns
|
|
247
|
+
`{ id, epoch }`. A configuration not satisfying the schema, or an unknown component, is
|
|
248
|
+
`422`.
|
|
249
|
+
|
|
250
|
+
## UI
|
|
251
|
+
|
|
252
|
+
The values service serves a page listing the configured components and creating
|
|
253
|
+
configurations, mounted at `/.configuration` on port `8003`. Reading it needs the
|
|
254
|
+
`system:configuration:get` role, creating needs `system:configuration:create`.
|
|
255
|
+
|
|
256
|
+
The page is always published: unlike the introspection annotation, the configuration
|
|
257
|
+
annotation is the per-component values map and has nowhere to carry a switch.
|
|
258
|
+
|
|
161
259
|
## Aspect
|
|
162
260
|
|
|
163
261
|
Component's configuration values are available as a well-known Aspect `configuration`.
|
|
164
262
|
|
|
165
263
|
```javascript
|
|
166
|
-
function transition
|
|
167
|
-
const foo = context.
|
|
264
|
+
function transition(input, entity, context) {
|
|
265
|
+
const foo = context.configuration.foo
|
|
168
266
|
|
|
169
267
|
// ...
|
|
170
268
|
}
|
|
171
269
|
```
|
|
270
|
+
|
|
271
|
+
On start, a component requests its configuration for its epoch from the values service and
|
|
272
|
+
waits until there is one, reporting every fifth attempt. The schema is applied, and secrets
|
|
273
|
+
are substituted. What is served is what was stored, whole: a `default` written into the schema
|
|
274
|
+
fills nothing, so a value every component is to have is declared in `defaults`. After a configuration is created, the running component receives the new
|
|
275
|
+
object and takes it when its `CREATED` is later than that of the value it holds.
|
|
276
|
+
|
|
277
|
+
### Local override
|
|
278
|
+
|
|
279
|
+
When the variable `TOA_CONFIGURATION_<NAMESPACE>_<NAME>` is set, the component's
|
|
280
|
+
configuration is the variable's value with the manifest `defaults` filled in and the schema
|
|
281
|
+
applied, and the values service is not used.
|
|
282
|
+
|
|
283
|
+
```shell
|
|
284
|
+
$ TOA_CONFIGURATION_DUMMIES_DUMMY='{"foo":"local"}' toa run components/dummy
|
|
285
|
+
```
|
|
@@ -1 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
# context.toa.yaml configuration annotation
|
|
2
|
+
#
|
|
3
|
+
# Every key names a component and holds that component's values, except `resources`, which
|
|
4
|
+
# is the values service's own: it deploys like any other and has to state what it may take.
|
|
5
|
+
# A component actually named `resources` is written with its namespace, `default.resources`
|
|
6
|
+
# — the bare form is only shorthand for that.
|
|
7
|
+
properties:
|
|
8
|
+
resources:
|
|
9
|
+
# `null` deploys the service without any, which every deployment has to state one way
|
|
10
|
+
# or the other — see `migrations/262.md`.
|
|
11
|
+
type: object
|
|
12
|
+
nullable: true
|
|
13
|
+
properties:
|
|
14
|
+
cpu:
|
|
15
|
+
type: array
|
|
16
|
+
items:
|
|
17
|
+
type: string
|
|
18
|
+
minItems: 2
|
|
19
|
+
maxItems: 2
|
|
20
|
+
memory:
|
|
21
|
+
type: array
|
|
22
|
+
items:
|
|
23
|
+
type: string
|
|
24
|
+
minItems: 2
|
|
25
|
+
maxItems: 2
|
|
26
|
+
patternProperties:
|
|
27
|
+
^(?!resources$).+$:
|
|
28
|
+
type: object
|
|
29
|
+
type: object
|
package/source/Aspect.test.ts
CHANGED
|
@@ -1,15 +1,119 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { Connector, Locator } from '@toa.io/core'
|
|
6
|
+
import { generate } from 'randomstring'
|
|
7
|
+
import { Aspect } from './Aspect.js'
|
|
8
|
+
import type { Client, Listener } from './Client.js'
|
|
9
|
+
import type { Manifest } from './manifest.js'
|
|
10
|
+
|
|
11
|
+
class Fake extends Connector {
|
|
12
|
+
public readonly fetch = mock.fn(async () => ({
|
|
13
|
+
configuration: { foo: 'served' },
|
|
14
|
+
created: 5
|
|
15
|
+
}))
|
|
16
|
+
public readonly subscribe = mock.fn()
|
|
17
|
+
public readonly unsubscribe = mock.fn()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const manifest: Manifest = {
|
|
21
|
+
schema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
foo: { type: 'string' },
|
|
25
|
+
bar: { type: 'object', properties: { baz: { type: 'string' } } }
|
|
8
26
|
}
|
|
9
|
-
}
|
|
27
|
+
},
|
|
28
|
+
defaults: { bar: { baz: 'quux' } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let locator: Locator
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
locator = new Locator(generate(), generate())
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
delete process.env['TOA_CONFIGURATION_' + locator.uppercase]
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should be named', async () => {
|
|
42
|
+
assert.deepStrictEqual(new Aspect(locator, manifest, null).name, 'configuration')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should resolve locally without a client', async () => {
|
|
46
|
+
process.env['TOA_CONFIGURATION_' + locator.uppercase] = JSON.stringify({ foo: 'local' })
|
|
47
|
+
|
|
48
|
+
const aspect = new Aspect(locator, manifest, null)
|
|
49
|
+
|
|
50
|
+
await aspect.connect()
|
|
51
|
+
|
|
52
|
+
assert.deepStrictEqual(aspect.invoke(), { foo: 'local', bar: { baz: 'quux' } })
|
|
53
|
+
assert.deepStrictEqual(aspect.invoke(['foo']), 'local')
|
|
54
|
+
assert.deepStrictEqual(aspect.invoke(['bar', 'baz']), 'quux')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should fetch from the client and follow it', async () => {
|
|
58
|
+
const client = new Fake()
|
|
59
|
+
const aspect = new Aspect(locator, manifest, client as unknown as Client)
|
|
60
|
+
|
|
61
|
+
await aspect.connect()
|
|
62
|
+
|
|
63
|
+
assert.deepStrictEqual(client.connected, true)
|
|
64
|
+
assert.strictEqual(client.fetch.mock.callCount(), 1)
|
|
65
|
+
|
|
66
|
+
const [component, epoch] = client.fetch.mock.calls[0].arguments as unknown as [
|
|
67
|
+
string,
|
|
68
|
+
string
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
assert.deepStrictEqual(component, locator.id)
|
|
72
|
+
assert.match(epoch, /^[a-f0-9]{64}$/)
|
|
73
|
+
// what the service serves is what was stored, and the manifest defaults are not applied to it
|
|
74
|
+
assert.deepStrictEqual(aspect.invoke(), { foo: 'served' })
|
|
75
|
+
|
|
76
|
+
assert.ok(
|
|
77
|
+
client.subscribe.mock.calls.some(
|
|
78
|
+
(call: any) =>
|
|
79
|
+
call.arguments.length === 3 &&
|
|
80
|
+
isDeepStrictEqual(call.arguments[0], component) &&
|
|
81
|
+
isDeepStrictEqual(call.arguments[1], epoch) &&
|
|
82
|
+
typeof call.arguments[2] === 'function'
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const listener = client.subscribe.mock.calls[0].arguments[2] as Listener
|
|
87
|
+
|
|
88
|
+
listener({ configuration: { foo: 'updated' }, created: 6 })
|
|
89
|
+
|
|
90
|
+
assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
|
|
91
|
+
|
|
92
|
+
// what is not newer than the held value is left alone
|
|
93
|
+
listener({ configuration: { foo: 'stale' }, created: 6 })
|
|
94
|
+
listener({ configuration: { foo: 'older' }, created: 4 })
|
|
95
|
+
|
|
96
|
+
assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
|
|
97
|
+
|
|
98
|
+
// a value that does not fit keeps the previous one
|
|
99
|
+
listener({ configuration: { foo: { nested: true } }, created: 7 })
|
|
100
|
+
|
|
101
|
+
assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
|
|
102
|
+
|
|
103
|
+
// and the one after it still applies
|
|
104
|
+
listener({ configuration: { foo: 'latest' }, created: 8 })
|
|
105
|
+
|
|
106
|
+
assert.deepStrictEqual(aspect.invoke(['foo']), 'latest')
|
|
10
107
|
|
|
11
|
-
|
|
108
|
+
await aspect.disconnect()
|
|
12
109
|
|
|
13
|
-
|
|
14
|
-
|
|
110
|
+
assert.ok(
|
|
111
|
+
client.unsubscribe.mock.calls.some(
|
|
112
|
+
(call: any) =>
|
|
113
|
+
call.arguments.length === 3 &&
|
|
114
|
+
isDeepStrictEqual(call.arguments[0], component) &&
|
|
115
|
+
isDeepStrictEqual(call.arguments[1], epoch) &&
|
|
116
|
+
isDeepStrictEqual(call.arguments[2], listener)
|
|
117
|
+
)
|
|
118
|
+
)
|
|
15
119
|
})
|
package/source/Aspect.ts
CHANGED
|
@@ -1,23 +1,91 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { console } from 'openspan'
|
|
2
|
+
import { Connector, type Locator } from '@toa.io/core'
|
|
3
|
+
import type { extensions } from '@toa.io/core/types'
|
|
4
|
+
import { fit, local, type Node } from './configuration.js'
|
|
5
|
+
import { epoch } from './epoch.js'
|
|
6
|
+
import type { Client, Value } from './Client.js'
|
|
7
|
+
import type { Manifest } from './manifest.js'
|
|
2
8
|
|
|
3
9
|
export class Aspect extends Connector implements extensions.Aspect {
|
|
4
10
|
public readonly name = 'configuration'
|
|
5
11
|
|
|
6
|
-
private readonly
|
|
12
|
+
private readonly locator: Locator
|
|
13
|
+
private readonly manifest: Manifest
|
|
14
|
+
private readonly client: Client | null
|
|
15
|
+
private readonly epoch: string
|
|
16
|
+
private value: Node = {}
|
|
17
|
+
private created = 0
|
|
7
18
|
|
|
8
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Without a client the value is local: the variable, the defaults and the schema.
|
|
21
|
+
* With one, the value is what the service holds, and it follows the service.
|
|
22
|
+
*/
|
|
23
|
+
public constructor(locator: Locator, manifest: Manifest, client: Client | null) {
|
|
9
24
|
super()
|
|
10
25
|
|
|
11
|
-
this.
|
|
26
|
+
this.locator = locator
|
|
27
|
+
this.manifest = manifest
|
|
28
|
+
this.client = client
|
|
29
|
+
this.epoch = epoch(manifest.schema)
|
|
30
|
+
|
|
31
|
+
if (client !== null) this.depends(client)
|
|
12
32
|
}
|
|
13
33
|
|
|
14
|
-
public invoke
|
|
34
|
+
public invoke(path?: string[]): any {
|
|
15
35
|
let cursor: any = this.value
|
|
16
36
|
|
|
17
|
-
if (path !== undefined)
|
|
18
|
-
for (const segment of path)
|
|
19
|
-
cursor = cursor[segment]
|
|
37
|
+
if (path !== undefined) for (const segment of path) cursor = cursor[segment]
|
|
20
38
|
|
|
21
39
|
return cursor
|
|
22
40
|
}
|
|
41
|
+
|
|
42
|
+
protected override async open(): Promise<void> {
|
|
43
|
+
if (this.client === null) {
|
|
44
|
+
this.value = local(this.locator, this.manifest)
|
|
45
|
+
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { configuration, created } = await this.client.fetch(
|
|
50
|
+
this.locator.id,
|
|
51
|
+
this.epoch
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
this.value = fit(configuration, this.manifest)
|
|
55
|
+
this.created = created
|
|
56
|
+
this.client.subscribe(this.locator.id, this.epoch, this.listener)
|
|
57
|
+
|
|
58
|
+
console.info('Configuration resolved', {
|
|
59
|
+
component: this.locator.id,
|
|
60
|
+
epoch: this.epoch,
|
|
61
|
+
created
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected override async close(): Promise<void> {
|
|
66
|
+
this.client?.unsubscribe(this.locator.id, this.epoch, this.listener)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private readonly listener = ({ configuration, created }: Value): void => {
|
|
70
|
+
// deliveries may repeat or cross: only what is newer than the held value replaces it
|
|
71
|
+
if (created <= this.created) return
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
this.value = fit(configuration, this.manifest)
|
|
75
|
+
this.created = created
|
|
76
|
+
|
|
77
|
+
console.info('Configuration updated', {
|
|
78
|
+
component: this.locator.id,
|
|
79
|
+
epoch: this.epoch,
|
|
80
|
+
created
|
|
81
|
+
})
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// the service validated it against the schema of its epoch, so the two schemas differ
|
|
84
|
+
console.error('Configuration value does not match the schema', {
|
|
85
|
+
component: this.locator.id,
|
|
86
|
+
epoch: this.epoch,
|
|
87
|
+
error
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
23
91
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
4
|
+
|
|
5
|
+
import { Connector, type Locator, type Receiver } from '@toa.io/core'
|
|
6
|
+
import type { Message } from '@toa.io/core/types'
|
|
7
|
+
import { timeout } from '@toa.io/generic'
|
|
8
|
+
import { Client, type Fetched } from './Client.js'
|
|
9
|
+
import { EVENT } from './const.js'
|
|
10
|
+
import type { Host } from './Factory.js'
|
|
11
|
+
|
|
12
|
+
class Remote extends Connector {
|
|
13
|
+
public readonly invoke = mock.fn(
|
|
14
|
+
async (
|
|
15
|
+
_endpoint: string,
|
|
16
|
+
request: { input: Array<{ component: string; epoch: string }> }
|
|
17
|
+
) =>
|
|
18
|
+
request.input.map((pair): Fetched => ({
|
|
19
|
+
...pair,
|
|
20
|
+
configuration: this.values[pair.component] ?? null,
|
|
21
|
+
created: this.values[pair.component] === undefined ? 0 : 7
|
|
22
|
+
}))
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
public values: Record<string, object | null> = {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let remote: Remote
|
|
29
|
+
let receiver: Receiver | null
|
|
30
|
+
let host: Host
|
|
31
|
+
let client: Client
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
remote = new Remote()
|
|
35
|
+
receiver = null
|
|
36
|
+
|
|
37
|
+
host = {
|
|
38
|
+
remote: mock.fn(async (locator: Locator) => {
|
|
39
|
+
assert.deepStrictEqual(locator.id, 'configuration.values')
|
|
40
|
+
|
|
41
|
+
return remote
|
|
42
|
+
}),
|
|
43
|
+
receive: mock.fn(async (label: string, consumer: Receiver) => {
|
|
44
|
+
assert.deepStrictEqual(label, EVENT)
|
|
45
|
+
|
|
46
|
+
receiver = consumer
|
|
47
|
+
|
|
48
|
+
return new Connector()
|
|
49
|
+
})
|
|
50
|
+
} as unknown as Host
|
|
51
|
+
|
|
52
|
+
client = new Client(host, { base: 10, max: 20, warn: 2 })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await client.disconnect()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should send the requests of one tick as one call', async () => {
|
|
60
|
+
remote.values = { 'a.one': { foo: 1 }, 'a.two': { foo: 2 } }
|
|
61
|
+
|
|
62
|
+
await client.connect()
|
|
63
|
+
|
|
64
|
+
const [one, two] = await Promise.all([
|
|
65
|
+
client.fetch('a.one', 'e1'),
|
|
66
|
+
client.fetch('a.two', 'e2')
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
assert.deepStrictEqual(one, { configuration: { foo: 1 }, created: 7 })
|
|
70
|
+
assert.deepStrictEqual(two, { configuration: { foo: 2 }, created: 7 })
|
|
71
|
+
assert.strictEqual(remote.invoke.mock.callCount(), 1)
|
|
72
|
+
assert.deepStrictEqual(remote.invoke.mock.calls[0].arguments[1], {
|
|
73
|
+
input: [
|
|
74
|
+
{ component: 'a.one', epoch: 'e1' },
|
|
75
|
+
{ component: 'a.two', epoch: 'e2' }
|
|
76
|
+
]
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should keep asking until served', async () => {
|
|
81
|
+
await client.connect()
|
|
82
|
+
|
|
83
|
+
const fetching = client.fetch('a.one', 'e1')
|
|
84
|
+
const deadline = Date.now() + 1000
|
|
85
|
+
|
|
86
|
+
while (remote.invoke.mock.calls.length < 2 && Date.now() < deadline) await timeout(5)
|
|
87
|
+
|
|
88
|
+
assert.ok(remote.invoke.mock.calls.length >= 2)
|
|
89
|
+
|
|
90
|
+
remote.values = { 'a.one': { foo: 1 } }
|
|
91
|
+
|
|
92
|
+
assert.deepStrictEqual((await fetching).configuration, { foo: 1 })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should hand a created object to its subscribers', async () => {
|
|
96
|
+
await client.connect()
|
|
97
|
+
|
|
98
|
+
const listener = mock.fn()
|
|
99
|
+
const other = mock.fn()
|
|
100
|
+
|
|
101
|
+
client.subscribe('a.one', 'e1', listener)
|
|
102
|
+
client.subscribe('a.one', 'e0', other)
|
|
103
|
+
|
|
104
|
+
await receiver!.receive({
|
|
105
|
+
payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 2 }, CREATED: 12 }
|
|
106
|
+
} satisfies Message)
|
|
107
|
+
|
|
108
|
+
assert.ok(
|
|
109
|
+
listener.mock.calls.some(
|
|
110
|
+
(call: any) =>
|
|
111
|
+
call.arguments.length === 1 &&
|
|
112
|
+
isDeepStrictEqual(call.arguments[0], { configuration: { foo: 2 }, created: 12 })
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
assert.strictEqual(other.mock.callCount(), 0)
|
|
116
|
+
assert.strictEqual(remote.invoke.mock.callCount(), 0)
|
|
117
|
+
|
|
118
|
+
client.unsubscribe('a.one', 'e1', listener)
|
|
119
|
+
|
|
120
|
+
await receiver!.receive({
|
|
121
|
+
payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 3 }, CREATED: 13 }
|
|
122
|
+
} satisfies Message)
|
|
123
|
+
|
|
124
|
+
assert.strictEqual(listener.mock.callCount(), 1)
|
|
125
|
+
})
|