@toa.io/extensions.configuration 0.2.1-dev.3

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.
@@ -0,0 +1,69 @@
1
+ # Configuration Consistency
2
+
3
+ ![Not Implemented](https://img.shields.io/badge/Not_Implemented-red)
4
+
5
+ ## Problem Definition
6
+
7
+ At arbitrary moments when the configuration has changed, some distributed operations have started
8
+ but haven't yet
9
+ finished. This leads to operations that start with certain configuration values but will finish with
10
+ another, which may
11
+ result in logical problems.
12
+
13
+ Given that distributed operations may have arbitrary participants and last for an arbitrary period,
14
+ there is no such
15
+ single moment when configuration can be safely changed. Thus, it must be a process.
16
+
17
+ ## ~~Not a~~ Solution
18
+
19
+ 1. Configuration is a single versioned object.
20
+ 2. Configuration version value is included in the Uniform Communication Interface.
21
+ 3. The first distributed operation participant (which is the one who has received a UCI without the
22
+ configuration
23
+ version value) must include in the outgoing UCI the configuration version value that it considers
24
+ as current.
25
+ 4. Non-first distributed operations participants (which is those who has received a UCI with the
26
+ configuration version
27
+ value) must use the configuration version specified in the UCI.
28
+ 5. Non-first distributed operation participants must include in the outgoing UCI the same
29
+ configuration version value as
30
+ they received in the incoming.
31
+
32
+ ## Restrictions
33
+
34
+ 1. Configuration updates are always backward compatible.
35
+ 2. Configuration updates are always being delivered before algorithm updates.
36
+ 1. Including federated deployment, that is: first deliver configuration to all facilities (data
37
+ centers, zones,
38
+ whatever), then deliver algorithm updates.
39
+ 3. Configuration storage and access solution must provide the transactional updates, that is if any
40
+ arbitrary
41
+ participant observed a certain configuration version, then any other participant is guaranteed to
42
+ be able to
43
+ subsequently observe that version.
44
+ 1. Including federated deployment[^1].
45
+
46
+ ## Solution
47
+
48
+ The configuration compatibility problem described above is a particular case of a common
49
+ compatibility problem for
50
+ distributed operation participants. It is not the only configuration that may change while a
51
+ distributed operation is
52
+ running, but participant algorithms themselves. This leads to the conclusion that this particular
53
+ solution must be
54
+ propagated to algorithm versions, that is: which system (as a set of algorithms and configuration)
55
+ version was used to
56
+ start the operation, and that system version must be used to finish the operation.
57
+
58
+ This kind of solution results in the need to run all versions of the system with appropriate message
59
+ routing. The
60
+ implementation complexity of this solution and its operations costs are considered unreasonable.
61
+ However, an attempt
62
+ will be made to implement this solution with a certain
63
+ constraints [#147](https://github.com/toa-io/toa/issues/147).
64
+
65
+ [^1]: Since it looks like there is no reasonable way to provide this kind of guarantee without
66
+ significant performance
67
+ and/or availability impact, it may be implemented as a mechanism with read retries and with a
68
+ timeout considered as
69
+ “enough for the most of fail-over scenarios”.
@@ -0,0 +1,109 @@
1
+ # Discussion
2
+
3
+ ## Change Requests
4
+
5
+ - [x] feat(configuration): add configuration extension
6
+ - manifest (schema) validation
7
+ - context extension
8
+ - [x] feat(formation): add well-known extension 'configuration'
9
+ - component
10
+ - context
11
+ - [x] feat(node): add well-known context extension 'configuration'
12
+ - [x] feat(configuration): add concise declarations
13
+ - [x] feat(configuration): add runtime configuration resolution
14
+ - [x] feat(cli): add `toa configure <key> <value> --reset`
15
+ - validate type
16
+ - [x] feat(operations): add configuration deployment
17
+ - annotations (values) validation
18
+ - [ ] feat(configuration): add secrets resolution
19
+ - [ ] feat(operations): add secrets deployment
20
+ - [ ] feat(cli): add `toa conceal`
21
+ - validate type
22
+ - [ ] feat(cli): add `toa configure`
23
+ - prompt required values
24
+ - use JSON Schema title
25
+
26
+ ## Statements
27
+
28
+ ### Common
29
+
30
+ - Secrets are being deployed separately by `toa conceal` command
31
+
32
+ ### 1: Environment variables
33
+
34
+ - Configuration values and secrets are mapped as environment variables to composition deployments
35
+ - Extensions may expose *deployment mutators*, which are able to modify deployment declaration
36
+ - Configuration context extension reads environment variables to resolve configuration and secrets
37
+
38
+ ### 2: Dedicated Components
39
+
40
+ - Hot updates
41
+ - [Configuration consistency](consistency.md)
42
+
43
+ ## Questions
44
+
45
+ ### Where are values comes from?
46
+
47
+ Environment variables.
48
+
49
+ ### Is there a configuration service or configuration component?
50
+
51
+ No. It will be implemented later as a part of [consistent configuration](consistency.md).
52
+
53
+ ### How are configuration values being stored?
54
+
55
+ As a kubernetes secrets mapped as environment variables.
56
+
57
+ ### Where are secrets being stored and how do they resolve to value?
58
+
59
+ As a kubernetes secrets mapped as environment variables.
60
+
61
+ ### Is configuration a single environment variable or a set (one per component)?
62
+
63
+ #### Context Configuration
64
+
65
+ In later versions, context extension will resolve configuration values by component locator. Given
66
+ that it is yet
67
+ unknown when this will happen, a certain context might have appeared which configuration is big
68
+ enough to not fit the
69
+ environment variable limitations.
70
+
71
+ That is, Context Configuration must be mapped as a set of environment variables (one per component).
72
+ Values are
73
+ serialized Configuration Objects.
74
+
75
+ > This will also allow to configure local environment per component.
76
+
77
+ #### Secrets
78
+
79
+ Secrets are mapped per secret as they are not bound to components.
80
+
81
+ ### Is configuration a single kubernetes secret or a set (one per component)?
82
+
83
+ #### Configuration
84
+
85
+ Single secret with a set of values per component.
86
+
87
+ #### Secrets
88
+
89
+ Once kubernetes secret per configuration secret.
90
+
91
+ ### Is there an option to configure local environment?
92
+
93
+ <dl>
94
+ <dt><code>toa configure &lt;component&gt;</code></dt>
95
+ <dd>Create local environment configuration values</dd>
96
+ </dl>
97
+
98
+ ### Whose responsibility is to call annotations?
99
+
100
+ - norm
101
+ - deployment
102
+
103
+ `toa export context` should throw errors if context has invalid annotations, and it's not a part of
104
+ the deployment.
105
+
106
+ ## References
107
+
108
+ - [#125](https://github.com/toa-io/toa/issues/125)
109
+ - [#132](https://github.com/toa-io/toa/issues/132)
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@toa.io/extensions.configuration",
3
+ "version": "0.2.1-dev.3",
4
+ "description": "Toa Configuration",
5
+ "author": "temich <tema.gurtovoy@gmail.com>",
6
+ "homepage": "https://github.com/toa-io/toa#readme",
7
+ "main": "src/index.js",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/toa-io/toa.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/toa-io/toa/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "dependencies": {
19
+ "@toa.io/core": "*",
20
+ "@toa.io/generic": "*",
21
+ "@toa.io/schema": "*",
22
+ "@toa.io/yaml": "*",
23
+ "clone-deep": "4.0.1"
24
+ }
25
+ }
package/readme.md ADDED
@@ -0,0 +1,231 @@
1
+ # Toa Configuration Extension
2
+
3
+ ## TL;DR
4
+
5
+ ### Define
6
+
7
+ ```yaml
8
+ # component.toa.yaml
9
+ name: dummy
10
+ namespace: dummies
11
+
12
+ configuration:
13
+ foo: bar
14
+ baz: 1
15
+ ```
16
+
17
+ ### Use
18
+
19
+ ```javascript
20
+ function transition (input, entity, context) {
21
+ const { foo, baz } = context.configuration
22
+
23
+ // ...
24
+ }
25
+ ```
26
+
27
+ ### Override
28
+
29
+ ```yaml
30
+ # context.toa.yaml
31
+ configuration:
32
+ dummies.dummy:
33
+ foo: qux
34
+ foo@staging: quux # use deployment environment discriminator
35
+ baz: $BAZ_VALUE # use secrets
36
+ ```
37
+
38
+ ### Deploy secrets
39
+
40
+ ```shell
41
+ $ toa conceal
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Problem Definition
47
+
48
+ - Components must be reusable in different contexts and deployment environments,
49
+ that is in different configurations.
50
+ - Some algorithm parameters must be deployed secretly.
51
+
52
+ ## Definitions
53
+
54
+ ### Configuration (Distributed System Configuration)
55
+
56
+ Set of static[^1] parameters for all algorithms within a given system.
57
+
58
+ ### Configuration Schema
59
+
60
+ Schema defining component's algorithms parameters (optionally with default
61
+ values).
62
+
63
+ ### Configuration Object
64
+
65
+ Value valid against Configuration Schema.
66
+
67
+ ### Configuration Value
68
+
69
+ Merge result of Configuration Schema's defaults and Configuration Object.
70
+
71
+ ### Context Configuration
72
+
73
+ Map of Configuration Objects for components added to a given context.
74
+
75
+ ## Responsibility Segregation
76
+
77
+ Configuration Schema is a *form* of configuration defined by component. Specific *values* for
78
+ specific contexts and deployment environments are defined by Context Configuration according to the
79
+ Schema.
80
+
81
+ See [Reusable Components](#).
82
+
83
+ ## Configuration Schema
84
+
85
+ Configuration Schema is declared as a component extension
86
+ using [JSON Schema](https://json-schema.org) `object` type.
87
+
88
+ > ![Warning](https://img.shields.io/badge/Warning-yellow)<br/>
89
+ > By introducing non-backward compatible changes to a Configuration Schema the compatibility
90
+ > with existent contexts and deployment environments will be broken. That is, Configuration
91
+ > Schema changes are subjects of component versioning.
92
+
93
+ > ![Recommendation](https://img.shields.io/badge/Recommendation-green)<br/>
94
+ > Having default values for all required parameters will allow components to be runnable
95
+ > without configuration (i.e. on local environment).
96
+
97
+ ### Example
98
+
99
+ ```yaml
100
+ # component.toa.yaml
101
+ name: dummy
102
+ namespace: dummies
103
+
104
+ extensions:
105
+ @toa.io/extensions.configuration:
106
+ properties:
107
+ foo:
108
+ type: string
109
+ default: 'baz'
110
+ bar:
111
+ type: number
112
+ required: [foo]
113
+ ```
114
+
115
+ ### Concise Declaration
116
+
117
+ As it is known that Configuration Schema is declared with a JSON Schema `object` type, any
118
+ configuration declaration without defined `properties` considered as concise. Properties of concise
119
+ declaration are treated as required Configuration Schema properties with the same type as its value
120
+ type and no additional properties allowed.
121
+
122
+ Also note that a well-known shortcut `configuration` is available.
123
+
124
+ Next two declarations are equivalent.
125
+
126
+ ```yaml
127
+ # component.toa.yaml
128
+ configuration:
129
+ foo: baz
130
+ bar: 1
131
+ ```
132
+
133
+ ```yaml
134
+ # component.toa.yaml
135
+ extensions:
136
+ @toa.io/extensions.configuration:
137
+ properties:
138
+ foo:
139
+ type: string
140
+ default: baz
141
+ bar:
142
+ type: number
143
+ default: 1
144
+ additionalProperties: false
145
+ required: [foo, bar]
146
+ ```
147
+
148
+ ## Context Configuration
149
+
150
+ Context Configuration is declared as a context annotaion. Its keys must be
151
+ component identifiers and its values must be Configuration Objects for those
152
+ components.
153
+
154
+ Context Configuration keys and Configuration Object keys may be defined
155
+ with [deployment environment discriminators](#).
156
+
157
+ ### Example
158
+
159
+ ```yaml
160
+ # context.toa.yaml
161
+ configuration:
162
+ dummies.dummy:
163
+ foo: quu
164
+ bar: 1
165
+ bar@staging: 2
166
+ ```
167
+
168
+ ### Local environment
169
+
170
+ Configuration Objects for local environment may be created
171
+ by [`toa configure`](../../runtime/cli/readme.md#configure) command.
172
+
173
+ ## Configuration Secrets
174
+
175
+ Context Configuration values which are uppercase strings prefixed with `$`
176
+ considered as Secrets.
177
+
178
+ ### Example
179
+
180
+ ```yaml
181
+ # context.toa.yaml
182
+ configuration:
183
+ payments.gateway:
184
+ api-key: $STRIPE_API_KEY
185
+ ```
186
+
187
+ ### Secrets Deployment
188
+
189
+ Secrets are not being deployed with context
190
+ deployment ([`toa deploy`](../../runtime/cli/readme.md#deploy)),
191
+ thus must be deployed separately at least once for each deployment environment
192
+ manually ([`toa conceal`](../../runtime/cli/readme.md#conceal)).
193
+
194
+ ## Operation Context
195
+
196
+ Configuration Value is available as a well-known operation context extension `configuration`.
197
+
198
+ ### Usage: node
199
+
200
+ ```javascript
201
+ function transition (input, entity, context) {
202
+ const foo = context.configiuration.foo
203
+
204
+ // ...
205
+ }
206
+ ```
207
+
208
+ > ![Warning](https://img.shields.io/badge/Warning-yellow)<br/>
209
+ > It is strongly **not** recommended to store a copy of value type configuration
210
+ > values outside of operation scope, thus it prevents operation to benefit
211
+ > from [hot updates](#).
212
+ >
213
+ > ```javascript
214
+ > // THIS IS WEIRD, BAD AND NOT RECOMMENDED
215
+ > let foo
216
+ >
217
+ > function transition (input, entity, context) {
218
+ > if (foo === undefined) foo = context.configuration.foo
219
+ >
220
+ > // ...
221
+ > }
222
+ > ```
223
+ > See [Genuine operations](#).
224
+
225
+ ## Appendix
226
+
227
+ - [Discussion](./docs/discussion.md)
228
+ - [Configuration consistency](./docs/consistency.md)
229
+
230
+ [^1]: Cannot be changed without a deployment. New values are considered to be a subject of
231
+ testing. [#146](https://github.com/toa-io/toa/issues/146)
@@ -0,0 +1,48 @@
1
+ 'use strict'
2
+
3
+ const { remap } = require('@toa.io/generic')
4
+
5
+ const verbose = (node) => {
6
+ const isObject = node.properties !== undefined
7
+ const isValue = node.type !== undefined && node.type !== 'object'
8
+ const isProperties = node[SYM] === 1
9
+
10
+ if (!isObject && !isValue && !isProperties) return convert(node)
11
+
12
+ return node
13
+ }
14
+
15
+ const convert = (node) => {
16
+ const properties = remap(node, property)
17
+
18
+ properties[SYM] = 1
19
+
20
+ return { type: 'object', properties, additionalProperties: false }
21
+ }
22
+
23
+ function property (node) {
24
+ if (node === null) throw new Error('Configuration: cannot resolve type of null, use JSONSchema declaration.')
25
+
26
+ const type = Array.isArray(node) ? 'array' : typeof node
27
+
28
+ if (type === 'object') return node
29
+ if (type === 'array') return array(node)
30
+
31
+ return { type, default: node }
32
+ }
33
+
34
+ const array = (array) => {
35
+ if (array.length === 0) throw new Error('Configuration: cannot resolve concise array items type because it\'s empty')
36
+
37
+ const type = typeof array[0]
38
+
39
+ return {
40
+ type: 'array',
41
+ items: { type },
42
+ default: array
43
+ }
44
+ }
45
+
46
+ const SYM = Symbol()
47
+
48
+ exports.verbose = verbose
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ const { normalize } = require('./normalize')
4
+ const { validate } = require('./validate')
5
+
6
+ exports.normalize = normalize
7
+ exports.validate = validate
@@ -0,0 +1,16 @@
1
+ 'use strict'
2
+
3
+ const { traverse } = require('@toa.io/generic')
4
+ const { verbose } = require('./.normalize/verbose')
5
+
6
+ const normalize = (manifest) => {
7
+ if (manifest.properties === undefined) manifest = expand(manifest)
8
+
9
+ return manifest
10
+ }
11
+
12
+ const expand = (concise) => {
13
+ return traverse(concise, verbose)
14
+ }
15
+
16
+ exports.normalize = normalize
@@ -0,0 +1,8 @@
1
+ $schema: https://json-schema.org/draft/2019-09/schema
2
+ $id: https://schemas.toa.io/0.0.0/extensions/configuration/manifest
3
+
4
+ $ref: 'https://schemas.toa.io/0.0.0/definitions#/definitions/schema'
5
+ properties:
6
+ type:
7
+ const: object
8
+ default: object
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+
5
+ const { Schema } = require('@toa.io/schema')
6
+ const { load } = require('@toa.io/yaml')
7
+
8
+ const schema = load.sync(path.resolve(__dirname, 'schema.yaml'))
9
+ const validator = new Schema(schema)
10
+
11
+ const validate = (declaration) => validator.validate(declaration)
12
+
13
+ exports.validate = validate
@@ -0,0 +1,20 @@
1
+ 'use strict'
2
+
3
+ const { traverse } = require('@toa.io/generic')
4
+
5
+ /**
6
+ * @param {toa.schema.JSON | Object} schema
7
+ * @return {Object}
8
+ */
9
+ const form = (schema) => {
10
+ const defaults = (node) => {
11
+ if (node.properties !== undefined) return { ...node.properties }
12
+ if (node.default !== undefined) return node.default
13
+
14
+ return null
15
+ }
16
+
17
+ return traverse(schema, defaults)
18
+ }
19
+
20
+ exports.form = form
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+
3
+ const { Schema } = require('@toa.io/schema')
4
+
5
+ /**
6
+ * @param {Object} annotation
7
+ * @param {toa.norm.context.dependencies.Instance[]} instances
8
+ */
9
+ const annotation = (annotation, instances) => {
10
+ const keys = Object.keys(annotation)
11
+
12
+ check(keys, instances)
13
+
14
+ for (const instance of instances) {
15
+ const object = annotation[instance.locator.id] ?? {}
16
+ const schema = new Schema(instance.manifest)
17
+
18
+ schema.validate(object)
19
+ }
20
+
21
+ return annotation
22
+ }
23
+
24
+ /**
25
+ * @param {string[]} keys
26
+ * @param {toa.norm.context.dependencies.Instance[]} instances
27
+ */
28
+ const check = (keys, instances) => {
29
+ const ids = instances.map((instance) => instance.locator.id)
30
+
31
+ for (const key of keys) {
32
+ if (!ids.includes(key)) throw new Error(`Configuration Schema '${key}' is not defined`)
33
+ }
34
+ }
35
+
36
+ exports.annotation = annotation
package/src/aspect.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+
3
+ const { Connector } = require('@toa.io/core')
4
+
5
+ /**
6
+ * @implements {toa.extensions.configuration.Aspect}
7
+ */
8
+ class Aspect extends Connector {
9
+ /** @readonly */
10
+ name = 'configuration'
11
+
12
+ /** @type {toa.core.Reflection} */
13
+ #refection
14
+
15
+ /**
16
+ * @param {toa.core.Reflection} reflection
17
+ */
18
+ constructor (reflection) {
19
+ super()
20
+
21
+ this.#refection = reflection
22
+
23
+ this.depends(reflection)
24
+ }
25
+
26
+ invoke (path) {
27
+ /** @type {any} */
28
+ let cursor = this.#refection.value
29
+
30
+ if (path !== undefined) for (const segment of path) cursor = cursor[segment]
31
+
32
+ return cursor
33
+ }
34
+ }
35
+
36
+ exports.Aspect = Aspect
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+
3
+ const { Reflection } = require('@toa.io/core')
4
+
5
+ /**
6
+ * @implements {toa.core.Reflection}
7
+ */
8
+ class Configuration extends Reflection {
9
+ /**
10
+ * @param {toa.extensions.configuration.Provider} provider
11
+ */
12
+ constructor (provider) {
13
+ super(provider.source)
14
+
15
+ this.depends(provider)
16
+ }
17
+ }
18
+
19
+ exports.Configuration = Configuration
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ const { encode } = require('@toa.io/generic')
4
+
5
+ /**
6
+ * @type {toa.deployment.dependency.Constructor}
7
+ */
8
+ const deployment = (components, annotations) => {
9
+ const variables = {}
10
+
11
+ for (const [id, annotation] of Object.entries(annotations)) {
12
+ const component = components.find((component) => component.locator.id === id)
13
+
14
+ variables[component.locator.label] = [{
15
+ name: 'TOA_CONFIGURATION_' + component.locator.uppercase,
16
+ value: encode(annotation)
17
+ }]
18
+ }
19
+
20
+ return { variables }
21
+ }
22
+
23
+ exports.deployment = deployment
package/src/factory.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ const { Schema } = require('@toa.io/schema')
4
+ const { Aspect } = require('./aspect')
5
+ const { Configuration } = require('./configuration')
6
+ const { Provider } = require('./provider')
7
+
8
+ /**
9
+ * @implements {toa.extensions.configuration.Factory}
10
+ */
11
+ class Factory {
12
+ /**
13
+ * @param {toa.core.Locator} locator
14
+ * @param {toa.schema.JSON | Object} declaration
15
+ * @return {toa.extensions.configuration.Aspect}
16
+ */
17
+ aspect (locator, declaration) {
18
+ const schema = new Schema(declaration)
19
+ const provider = new Provider(locator, schema)
20
+ const configuration = new Configuration(provider)
21
+
22
+ return new Aspect(configuration)
23
+ }
24
+
25
+ provider (component) {
26
+ const locator = component.locator
27
+ const declaration = component.extensions?.[ID]
28
+
29
+ if (declaration === undefined) throw new Error(`Configuration extension not found in '${locator.id}'`)
30
+
31
+ const schema = new Schema(declaration)
32
+
33
+ return new Provider(locator, schema)
34
+ }
35
+ }
36
+
37
+ const ID = require('../package.json').name
38
+
39
+ exports.Factory = Factory
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ const { manifest } = require('./manifest')
4
+ const { annotation } = require('./annotation')
5
+ const { deployment } = require('./deployment')
6
+
7
+ const { Factory } = require('./factory')
8
+
9
+ exports.manifest = manifest
10
+ exports.annotation = annotation
11
+ exports.deployment = deployment
12
+
13
+ exports.Factory = Factory
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ const { normalize, validate } = require('./.manifest')
4
+
5
+ const manifest = (manifest) => {
6
+ const declaration = normalize(manifest)
7
+
8
+ validate(declaration)
9
+
10
+ return declaration
11
+ }
12
+
13
+ exports.manifest = manifest
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ const clone = require('clone-deep')
4
+ const { decode, encode, empty, overwrite } = require('@toa.io/generic')
5
+
6
+ const { Connector } = require('@toa.io/core')
7
+ const { form } = require('./.provider/form')
8
+
9
+ /**
10
+ * @implements {toa.extensions.configuration.Provider}
11
+ */
12
+ class Provider extends Connector {
13
+ /** @type {toa.schema.Schema} */
14
+ #schema
15
+
16
+ /** @type {Object} */
17
+ #form
18
+ /** @type {Object} */
19
+ #value
20
+
21
+ source
22
+ object
23
+ key
24
+
25
+ /**
26
+ * @param {toa.core.Locator} locator
27
+ * @param {toa.schema.Schema} schema
28
+ */
29
+ constructor (locator, schema) {
30
+ super()
31
+
32
+ this.source = this.#source.bind(this)
33
+
34
+ this.key = PREFIX + locator.uppercase
35
+ this.#schema = schema
36
+
37
+ // form is required to enable nested defaults
38
+ this.#form = form(schema.schema)
39
+ }
40
+
41
+ async connection () {
42
+ await this.#retrieve()
43
+ }
44
+
45
+ async #source () {
46
+ return this.#value
47
+ }
48
+
49
+ set (key, value) {
50
+ const object = this.object === undefined ? {} : clone(this.object)
51
+ const properties = key.split('.')
52
+ const property = properties.pop()
53
+
54
+ let cursor = object
55
+
56
+ for (const name of properties) {
57
+ if (cursor[name] === undefined) cursor[name] = {}
58
+
59
+ cursor = cursor[name]
60
+ }
61
+
62
+ if (value === undefined) delete cursor[property]
63
+ else cursor[property] = value
64
+
65
+ this.#set(object)
66
+ }
67
+
68
+ unset (key) {
69
+ this.set(key, undefined)
70
+ }
71
+
72
+ reset () {
73
+ this.object = undefined
74
+ }
75
+
76
+ export () {
77
+ return this.object === undefined ? undefined : encode(this.object)
78
+ }
79
+
80
+ async #retrieve () {
81
+ const string = process.env[this.key]
82
+ const object = string === undefined ? {} : decode(string)
83
+
84
+ this.#set(object)
85
+ }
86
+
87
+ #set (object) {
88
+ this.#validate(object)
89
+ this.#merge(object)
90
+
91
+ this.object = empty(object) ? undefined : object
92
+ }
93
+
94
+ #validate (object) {
95
+ const error = this.#schema.match(object)
96
+
97
+ if (error !== null) throw new TypeError(error.message)
98
+ }
99
+
100
+ #merge (object) {
101
+ object = clone(object)
102
+
103
+ const form = clone(this.#form)
104
+ const value = overwrite(form, object)
105
+
106
+ this.#schema.validate(value)
107
+
108
+ this.#value = value
109
+ }
110
+ }
111
+
112
+ const PREFIX = 'TOA_CONFIGURATION_'
113
+
114
+ exports.Provider = Provider
@@ -0,0 +1,38 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const { Locator } = require('@toa.io/core')
5
+ const { random } = require('@toa.io/generic')
6
+
7
+ const instance = () => {
8
+ const name = generate()
9
+ const namespace = generate()
10
+ const locator = new Locator(name, namespace)
11
+
12
+ const manifest = {
13
+ properties: {
14
+ foo: {
15
+ type: 'number',
16
+ default: 1
17
+ }
18
+ }
19
+ }
20
+
21
+ return { locator, manifest }
22
+ }
23
+
24
+ /** @type {toa.norm.context.dependencies.Instance[]} */
25
+ const instances = []
26
+
27
+ for (let i = 0; i < random(5) + 5; i++) instances.push(instance())
28
+
29
+ /** @type {Object} */
30
+ const annotation = {}
31
+
32
+ for (let i = 0; i < random(instances.length - 1) + 1; i++) {
33
+ const instance = instances[i]
34
+ annotation[instance.locator.id] = { foo: random() }
35
+ }
36
+
37
+ exports.instances = instances
38
+ exports.annotation = annotation
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ const clone = require('clone-deep')
4
+ const { generate } = require('randomstring')
5
+ const { sample } = require('@toa.io/generic')
6
+
7
+ const fixtures = require('./annotations.fixtures')
8
+ const { annotation } = require('../')
9
+
10
+ let input
11
+ /** @type {toa.norm.context.dependencies.Instance[]} */ let instances
12
+
13
+ const call = () => annotation(input, instances)
14
+
15
+ beforeEach(() => {
16
+ input = clone(fixtures.annotation)
17
+ instances = clone(fixtures.instances)
18
+ })
19
+
20
+ it('sample must be valid', () => {
21
+ expect(call).not.toThrow()
22
+ })
23
+
24
+ it('should must be a function', () => {
25
+ expect(annotation).toBeDefined()
26
+ expect(annotation).toBeInstanceOf(Function)
27
+ })
28
+
29
+ it('should throw on non-existent component', () => {
30
+ input[generate()] = {}
31
+
32
+ expect(call).toThrow(/Configuration Schema/)
33
+ })
34
+
35
+ it('should throw if object doesn\'t match schema', () => {
36
+ const keys = Object.keys(input)
37
+ const key = sample(keys)
38
+ const object = input[key]
39
+
40
+ object.foo = generate()
41
+
42
+ expect(call).toThrow(/foo must be number/)
43
+ })
@@ -0,0 +1,33 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+
5
+ const schema = {
6
+ properties: {
7
+ foo: {
8
+ type: 'string',
9
+ default: generate()
10
+ },
11
+ bar: {
12
+ properties: {
13
+ baz: {
14
+ type: 'number',
15
+ default: 1
16
+ }
17
+ }
18
+ },
19
+ quu: {
20
+ type: 'number'
21
+ }
22
+ }
23
+ }
24
+
25
+ const concise = {
26
+ foo: schema.properties.foo.default,
27
+ bar: {
28
+ baz: schema.properties.bar.properties.baz.default
29
+ }
30
+ }
31
+
32
+ exports.schema = schema
33
+ exports.concise = concise
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+
3
+ const { Locator } = require('@toa.io/core')
4
+ const { encode } = require('@toa.io/generic')
5
+
6
+ const fixtures = require('./aspect.fixtures')
7
+ const { Factory } = require('../')
8
+ const { generate } = require('randomstring')
9
+
10
+ const factory = new Factory()
11
+
12
+ /** @type {toa.extensions.configuration.Aspect} */
13
+ let aspect
14
+
15
+ /** @type {toa.core.Locator} */
16
+ let locator
17
+
18
+ describe('defaults', () => {
19
+ beforeEach(async () => {
20
+ const namespace = generate()
21
+ const name = generate()
22
+
23
+ locator = new Locator(name, namespace)
24
+
25
+ aspect = factory.aspect(locator, fixtures.schema)
26
+
27
+ await aspect.connect()
28
+ })
29
+
30
+ it('should return schema defaults', () => {
31
+ const foo = aspect.invoke(['foo'])
32
+
33
+ expect(foo).toStrictEqual(fixtures.schema.properties.foo.default)
34
+ })
35
+
36
+ it('should return nested values', () => {
37
+ const baz = aspect.invoke(['bar', 'baz'])
38
+
39
+ expect(baz).toStrictEqual(fixtures.schema.properties.bar.properties.baz.default)
40
+ })
41
+
42
+ it('should expose configuration tree', () => {
43
+ const configuration = aspect.invoke()
44
+
45
+ expect(configuration).toStrictEqual({
46
+ foo: fixtures.schema.properties.foo.default,
47
+ bar: {
48
+ baz: fixtures.schema.properties.bar.properties.baz.default
49
+ },
50
+ quu: 0
51
+ })
52
+ })
53
+ })
54
+
55
+ describe('resolution', () => {
56
+ let object
57
+ let varname
58
+
59
+ beforeEach(() => {
60
+ object = { foo: generate() }
61
+
62
+ varname = 'TOA_CONFIGURATION_' + locator.uppercase
63
+ })
64
+
65
+ it('should resolve configuration object from environment variable', async () => {
66
+ process.env[varname] = encode(object)
67
+
68
+ aspect = factory.aspect(locator, fixtures.schema)
69
+
70
+ await aspect.connect()
71
+
72
+ const configuration = aspect.invoke()
73
+
74
+ expect(configuration.foo).toStrictEqual(object.foo)
75
+ expect(configuration.bar.baz).toStrictEqual(fixtures.schema.properties.bar.properties.baz.default)
76
+ })
77
+ })
@@ -0,0 +1,38 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+
5
+ const { Locator } = require('@toa.io/core')
6
+ const { random } = require('@toa.io/generic')
7
+
8
+ const component = () => {
9
+ const namespace = generate()
10
+ const name = generate()
11
+
12
+ return { locator: new Locator(name, namespace) }
13
+ }
14
+
15
+ /** @type {toa.norm.context.dependencies.Instance[]} */
16
+ const components = []
17
+ const annotations = {}
18
+
19
+ const annotate = (component) => {
20
+ const key = component.locator.id
21
+
22
+ annotations[key] = { [generate()]: generate() }
23
+ }
24
+
25
+ for (let i = 0; i < random(10) + 5; i++) components.push(component())
26
+ for (let i = 0; i < components.length; i++) if (i % 2 === 0) annotate(components[i])
27
+
28
+ /**
29
+ * @param {string} id
30
+ * @returns {toa.norm.context.dependencies.Instance}
31
+ */
32
+ const find = (id) => {
33
+ return components.find((component) => component.locator.id === id)
34
+ }
35
+
36
+ exports.components = components
37
+ exports.annotations = annotations
38
+ exports.find = find
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ const { encode } = require('@toa.io/generic')
4
+
5
+ const fixtures = require('./deployment.fixtures')
6
+ const { deployment } = require('../')
7
+
8
+ /** @type {toa.deployment.dependency.Declaration} */
9
+ let declaration
10
+
11
+ beforeAll(() => {
12
+ declaration = deployment(fixtures.components, fixtures.annotations)
13
+ })
14
+
15
+ it('should exist', () => {
16
+ expect(deployment).toBeDefined()
17
+ })
18
+
19
+ it('should declare variables', () => {
20
+ expect(declaration.variables).toBeDefined()
21
+ })
22
+
23
+ it('should map configurations', () => {
24
+ const keys = Object.keys(fixtures.annotations)
25
+
26
+ expect(keys.length).toBeGreaterThan(0)
27
+
28
+ for (const [id, annotations] of Object.entries(fixtures.annotations)) {
29
+ const component = fixtures.find(id)
30
+ const variables = declaration.variables[component.locator.label]
31
+ const encoded = encode(annotations)
32
+
33
+ expect(component).toBeDefined()
34
+ expect(variables).toBeDefined()
35
+ expect(variables).toBeInstanceOf(Array)
36
+ expect(variables.length).toStrictEqual(1)
37
+
38
+ const env = variables[0]
39
+
40
+ expect(env.name).toBeDefined()
41
+ expect(env.name).toStrictEqual('TOA_CONFIGURATION_' + component.locator.uppercase)
42
+ expect(env.value).toBeDefined()
43
+ expect(env.value).toStrictEqual(encoded)
44
+ }
45
+ })
@@ -0,0 +1,22 @@
1
+ 'use strict'
2
+
3
+ const { Factory } = require('../')
4
+
5
+ it('should export', () => {
6
+ expect(Factory).toBeInstanceOf(Function)
7
+ })
8
+
9
+ /** @type {toa.extensions.configuration.Factory} */
10
+ let factory
11
+
12
+ beforeAll(async () => {
13
+ factory = new Factory()
14
+ })
15
+
16
+ it('should expose context', () => {
17
+ expect(factory.aspect).toBeInstanceOf(Function)
18
+ })
19
+
20
+ it('should expose provider', () => {
21
+ expect(factory.provider).toBeInstanceOf(Function)
22
+ })
@@ -0,0 +1,132 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const { random } = require('@toa.io/generic')
5
+
6
+ const { manifest } = require('../')
7
+
8
+ it('should export', () => {
9
+ expect(manifest).toBeInstanceOf(Function)
10
+ })
11
+
12
+ describe('validation', () => {
13
+ it('should throw if not an object', () => {
14
+ const call = () => manifest(generate())
15
+ expect(call).toThrow(/must be object/)
16
+ })
17
+
18
+ it('should throw if not a valid schema', () => {
19
+ const object = { type: generate() }
20
+ const call = () => manifest(object)
21
+
22
+ expect(call).toThrow(/one of the allowed values/)
23
+ })
24
+
25
+ it('should throw if schema is not an object type', () => {
26
+ const schema = { type: 'number' }
27
+ const call = () => manifest(schema)
28
+
29
+ expect(call).toThrow(/equal to constant 'object'/)
30
+ })
31
+ })
32
+
33
+ describe('normalization', () => {
34
+ it('should expand concise', () => {
35
+ const concise = {
36
+ foo: generate(),
37
+ bar: {
38
+ baz: random()
39
+ }
40
+ }
41
+
42
+ const declaration = manifest(concise)
43
+
44
+ expect(declaration).toMatchObject({
45
+ type: 'object',
46
+ properties: {
47
+ foo: {
48
+ type: 'string',
49
+ default: concise.foo
50
+ },
51
+ bar: {
52
+ type: 'object',
53
+ properties: {
54
+ baz: {
55
+ type: 'number',
56
+ default: concise.bar.baz
57
+ }
58
+ }
59
+ }
60
+ }
61
+ })
62
+ })
63
+
64
+ it('should expand partially concise', () => {
65
+ const concise = {
66
+ foo: generate(),
67
+ bar: {
68
+ baz: random()
69
+ },
70
+ qux: {
71
+ type: 'string',
72
+ default: null
73
+ }
74
+ }
75
+
76
+ const declaration = manifest(concise)
77
+
78
+ expect(declaration).toMatchObject({
79
+ type: 'object',
80
+ properties: {
81
+ foo: {
82
+ type: 'string',
83
+ default: concise.foo
84
+ },
85
+ bar: {
86
+ type: 'object',
87
+ properties: {
88
+ baz: {
89
+ type: 'number',
90
+ default: concise.bar.baz
91
+ }
92
+ }
93
+ },
94
+ qux: {
95
+ type: 'string',
96
+ default: null
97
+ }
98
+ }
99
+ })
100
+ })
101
+
102
+ it('should expand arrays', () => {
103
+ const concise = { foo: [1, 2, 3] }
104
+
105
+ const declaration = manifest(concise)
106
+
107
+ expect(declaration).toMatchObject({
108
+ type: 'object',
109
+ properties: {
110
+ foo: {
111
+ type: 'array',
112
+ items: {
113
+ type: 'number'
114
+ },
115
+ default: [1, 2, 3]
116
+ }
117
+ }
118
+ })
119
+ })
120
+
121
+ it('should throw on empty array', () => {
122
+ const concise = { foo: [] }
123
+
124
+ expect(() => manifest(concise)).toThrow(/array items type because it's empty/)
125
+ })
126
+
127
+ it('should throw on null', () => {
128
+ const concise = { foo: null }
129
+
130
+ expect(() => manifest(concise)).toThrow(/type of null/)
131
+ })
132
+ })
@@ -0,0 +1,11 @@
1
+ // noinspection ES6UnusedImports
2
+
3
+ import * as core from '@toa.io/core/types/extensions'
4
+
5
+ declare namespace toa.extensions.configuration {
6
+
7
+ interface Aspect extends core.Aspect {
8
+ invoke(path?: string[]): any
9
+ }
10
+
11
+ }
@@ -0,0 +1,12 @@
1
+ import { Component } from '@toa.io/norm/types'
2
+
3
+ import * as _extensions from '@toa.io/core/types/extensions'
4
+ import * as _provider from './provider'
5
+
6
+ declare namespace toa.extensions.configuration {
7
+
8
+ interface Factory extends _extensions.Factory {
9
+ provider(component: Component): _provider.Provider
10
+ }
11
+
12
+ }
@@ -0,0 +1,22 @@
1
+ import { Source } from '@toa.io/core/types/reflection'
2
+ import { Connector } from '@toa.io/core/types'
3
+
4
+ declare namespace toa.extensions.configuration {
5
+
6
+ interface Provider extends Connector {
7
+ source: Source
8
+ object: Object
9
+ key: string
10
+
11
+ set(key: string, value: any): void
12
+
13
+ unset(key: string): void
14
+
15
+ reset(): void
16
+
17
+ export(): string
18
+ }
19
+
20
+ }
21
+
22
+ export type Provider = toa.extensions.configuration.Provider