kanon 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/docs/decisions.md +176 -0
- data/docs/specification.md +333 -0
- data/kanon.gemspec +37 -0
- data/lib/kanon/document.rb +103 -0
- data/lib/kanon/env_declarations.rb +140 -0
- data/lib/kanon/errors.rb +16 -0
- data/lib/kanon/node.rb +121 -0
- data/lib/kanon/typecast.rb +76 -0
- data/lib/kanon/version.rb +5 -0
- data/lib/kanon.rb +113 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7b9d154f9d3f8b311fe5b617c77b52aec339b2a61c906f788fcadb430f457749
|
|
4
|
+
data.tar.gz: 898efbcb49ce65f1b80c0e58b0b0ddcdbfd1c9946285272e153452611b757116
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: db7a72555e10af6a7ab0ca3568e5044b2fc291bdb6767865c61afd8eba87d42b04d10afdfead728a44971bc8bcfcd60acd8a768e0400fcfaa9d093f3ce32d335
|
|
7
|
+
data.tar.gz: 5949ad82bef351c5025a90ee6f25ef5eaa023f5277faac5f3a812f55040e6ff0278728af1bb4cf2050f3cb781465c79415447ee704e878dcffaf8fab002f6cd3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- A YAML file becomes a deeply frozen tree of nodes, read once per process, with
|
|
6
|
+
dotted access, `[]`, `dig`, `key?`, `keys` and `to_h`.
|
|
7
|
+
- A file is read through the section named after the current environment, and read
|
|
8
|
+
whole when it carries no such section. A single root key leaves the data only
|
|
9
|
+
when it holds a hash of its own.
|
|
10
|
+
- Values that belong to the environment are declared in the same file. A name
|
|
11
|
+
derives from the path, an array index joining it as a segment of its own; `env`
|
|
12
|
+
writes the name out instead, `default` gives a fallback.
|
|
13
|
+
- A value is mandatory unless it carries a `default` or `optional: true`. A read
|
|
14
|
+
that misses one stops the application, naming every absent variable at once. An
|
|
15
|
+
empty variable counts as absent.
|
|
16
|
+
- `type` casts to `string`, `integer`, `float`, `boolean` or `list`; without it
|
|
17
|
+
the class of the default decides, and a `type` written and left empty is refused
|
|
18
|
+
rather than ignored. Integers are read as decimal, so a leading zero is not
|
|
19
|
+
octal.
|
|
20
|
+
- A typo in a key raises `NoMethodError` listing the available keys, never a
|
|
21
|
+
silent `nil`.
|
|
22
|
+
- `expected_variables` reports the names a file reads and whether each is
|
|
23
|
+
mandatory. It walks the file the way a read does with nothing behind it, so it
|
|
24
|
+
refuses a file that could not load instead of reporting a clean list.
|
|
25
|
+
- Everything the loader raises descends from `Kanon::Error`, including a name no
|
|
26
|
+
shell could set, two paths reading one variable, two keys that differ only by
|
|
27
|
+
quoting, a file that cannot be opened, and a directory or environment set to
|
|
28
|
+
nothing.
|
|
29
|
+
- Inspection, serialization and error messages carry key names, classes and
|
|
30
|
+
lengths — never values. `Marshal.dump` refuses, and a wrapped failure drops the
|
|
31
|
+
exception that caused it, so the text that broke a cast or a template tag cannot
|
|
32
|
+
reach a log.
|
|
33
|
+
- ERB runs before YAML is parsed. Parsing goes through `safe_load` on every psych
|
|
34
|
+
version.
|
|
35
|
+
- The format is specified in `docs/specification.md` without reference to Ruby,
|
|
36
|
+
and the reasons behind it are in `docs/decisions.md`.
|
|
37
|
+
- Ruby 2.7 and newer. Nothing outside the standard library.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maksimenko Pavel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Kanon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MaksimenkoPG/kanon/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
One way to read application configuration: a YAML file becomes a frozen tree of
|
|
6
|
+
nodes, and the values that belong to the environment are declared in that same
|
|
7
|
+
file.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
Kanon[:service].token
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What it is for
|
|
14
|
+
|
|
15
|
+
Reading configuration tends to accumulate: a framework helper in one place,
|
|
16
|
+
`YAML.load` with ERB in another, `YAML.load_file` inside a class in a third. None
|
|
17
|
+
of them caches, so global constants appear for the sole purpose of reading a file
|
|
18
|
+
once. Access forms multiply, and a typo returns `nil` — which is how a credential
|
|
19
|
+
is silently dropped and an application boots into an outage.
|
|
20
|
+
|
|
21
|
+
So the defaults here are the opposite ones:
|
|
22
|
+
|
|
23
|
+
- **A value is mandatory unless declared otherwise.** A missing one brings the
|
|
24
|
+
application down at boot, naming every absent variable at once.
|
|
25
|
+
- **A typo raises.** `NoMethodError` with the available keys in the message,
|
|
26
|
+
never a silent `nil`.
|
|
27
|
+
- **What was read cannot change.** The tree is deeply frozen and read once per
|
|
28
|
+
process.
|
|
29
|
+
- **The file states which variables exist.** That list can be extracted without
|
|
30
|
+
reading the environment, so a pre-boot check needs no grammar of its own.
|
|
31
|
+
- **A secret never leaves through diagnostics.** Inspection, serialization and
|
|
32
|
+
error messages carry key names, classes and lengths — never values.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem 'kanon'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Ruby 2.7 or newer. Nothing outside the standard library: `yaml`, `erb`, `monitor`,
|
|
41
|
+
and `json` when `to_json` is called.
|
|
42
|
+
|
|
43
|
+
## A configuration file
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
default: &default
|
|
47
|
+
:service:
|
|
48
|
+
:endpoint: "https://service.example.com"
|
|
49
|
+
:token: ~
|
|
50
|
+
:timeout: { default: 5 }
|
|
51
|
+
:recipients: { type: list, default: [ops@example.com] }
|
|
52
|
+
:verbose: { default: false }
|
|
53
|
+
|
|
54
|
+
production:
|
|
55
|
+
<<: *default
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The variables it expects:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
SERVICE_TOKEN=a-real-token
|
|
62
|
+
SERVICE_RECIPIENTS=alice@example.com,bob@example.com
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
What the application gets:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Kanon[:service].token # => "a-real-token"
|
|
69
|
+
Kanon[:service].recipients # => ["alice@example.com", "bob@example.com"]
|
|
70
|
+
Kanon[:service].timeout # => 5
|
|
71
|
+
Kanon[:service].verbose # => false
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Which part of the file is read.** A file is read through the section named after
|
|
75
|
+
the current environment; one whose top level carries no such key is read whole, so
|
|
76
|
+
a list or a dictionary needs no section wrapper around it. A single root key
|
|
77
|
+
becomes the prefix of every variable name below it, and leaves the data when what
|
|
78
|
+
it holds is a hash of its own — hence `Kanon[:service].token` above, not
|
|
79
|
+
`.service.token`. Over a list or over a declaration it stays in the data and only
|
|
80
|
+
lends its name, and several root keys all stay, each prefixing its own subtree.
|
|
81
|
+
|
|
82
|
+
**What a scalar means.** A scalar holding a value is a constant of the file and
|
|
83
|
+
never looks at the environment. An empty one means "expected and not given", under
|
|
84
|
+
a name built from the path with levels joined by a single `_`: `service` → `token`
|
|
85
|
+
reads `SERVICE_TOKEN`, and a nested `pool` → `size` would read
|
|
86
|
+
`SERVICE_POOL_SIZE`.
|
|
87
|
+
|
|
88
|
+
**What a hash means.** A hash whose keys all come from the four words below
|
|
89
|
+
declares a value the environment may provide, so `timeout` stays `5` until
|
|
90
|
+
`SERVICE_TIMEOUT` says otherwise.
|
|
91
|
+
|
|
92
|
+
- **`env`** — the variable name, written out instead of derived from the path.
|
|
93
|
+
- **`default`** — the value when the variable is absent, cast the same way a
|
|
94
|
+
variable is.
|
|
95
|
+
- **`type`** — one of `string`, `integer`, `float`, `boolean`, `list`. Writing the
|
|
96
|
+
key and leaving it empty is refused rather than ignored; leaving it out lets the
|
|
97
|
+
class of the `default` decide.
|
|
98
|
+
- **`optional: true`** — the only way to let a value be missing and still boot.
|
|
99
|
+
Only a literal `true` counts: the string `'false'` is truthy in Ruby and would
|
|
100
|
+
otherwise switch the flag on. An empty string written as the `default` is a
|
|
101
|
+
different thing — it is a value, and it reaches the consumer.
|
|
102
|
+
|
|
103
|
+
**Two forms whose sides look different.** A list is a real array in the file and
|
|
104
|
+
one comma separated string in the environment, spaces around the commas ignored;
|
|
105
|
+
an array default implies `type: list`. A variable that leaves no items after
|
|
106
|
+
parsing, `,,` for instance, counts as no value at all and fails the boot rather
|
|
107
|
+
than falling back to the default. A boolean takes `1`, `true`, `yes`, `on` and
|
|
108
|
+
`0`, `false`, `no`, `off` in any case, stopping the boot on anything else and
|
|
109
|
+
naming what it does accept. Numbers and strings hold no such surprise —
|
|
110
|
+
`SERVICE_TIMEOUT=10` yields the integer `10`, and a leading zero is not octal.
|
|
111
|
+
|
|
112
|
+
An array written out in the file is a different thing entirely from `type: list`,
|
|
113
|
+
and the next section is about it.
|
|
114
|
+
|
|
115
|
+
**How the file is processed.** ERB runs before YAML is parsed, so
|
|
116
|
+
`<%= ENV.fetch(...) %>` needs no marker, both styles mix in one file, and control
|
|
117
|
+
tags (`<% if %> ... <% end %>`) may build the structure of the file itself. YAML is
|
|
118
|
+
then parsed with `safe_load` on every psych version: symbol keys and `<<: *default`
|
|
119
|
+
anchors work, while `!ruby/...` tags and types outside the allowed list are never
|
|
120
|
+
built.
|
|
121
|
+
|
|
122
|
+
### Arrays
|
|
123
|
+
|
|
124
|
+
An index is a path segment like any other key, so a value inside an array is
|
|
125
|
+
named after the position it sits at:
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
:service:
|
|
129
|
+
:queues:
|
|
130
|
+
- :name: ~ # SERVICE_QUEUES_0_NAME
|
|
131
|
+
- :name: ~ # SERVICE_QUEUES_1_NAME
|
|
132
|
+
:matrix:
|
|
133
|
+
- [~, ~] # SERVICE_MATRIX_0_0, SERVICE_MATRIX_0_1
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Depth is not limited; one segment joins the name per level. A file whose root is
|
|
137
|
+
an array has no root key to borrow, so it takes the name of the file — the same
|
|
138
|
+
name that wraps it into a node:
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
# endpoints.yml
|
|
142
|
+
- :url: ~ # ENDPOINTS_0_URL, read back as .endpoints[0].url
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The position is the entire name, which makes these names brittle. Insert an
|
|
146
|
+
element at the head and every name below it shifts by one: the value meant for one
|
|
147
|
+
element lands in its neighbour, and the boot stops only if some name in the
|
|
148
|
+
shifted range holds nothing. Reordering a list becomes an edit of the environment,
|
|
149
|
+
not of the file alone.
|
|
150
|
+
|
|
151
|
+
So an array the environment fills is usually better declared than written out.
|
|
152
|
+
`type: list` gives the whole list one stable name, and `env` gives a single
|
|
153
|
+
element a name of its own:
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
:service:
|
|
157
|
+
:queues:
|
|
158
|
+
- :name: { env: PRIMARY_QUEUE }
|
|
159
|
+
- :name: { env: BACKUP_QUEUE }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
An array whose values are all in the file asks nothing of the environment and
|
|
163
|
+
carries none of this.
|
|
164
|
+
|
|
165
|
+
## The interface
|
|
166
|
+
|
|
167
|
+
- **`Kanon.directory`, `Kanon.environment`** — what is in force right now.
|
|
168
|
+
- **`Kanon.directory = path`** — where the files live, `config` until set.
|
|
169
|
+
- **`Kanon.environment = name`** — which section to read, taken from `APP_ENV`,
|
|
170
|
+
then `RAILS_ENV`, then `RACK_ENV`, else `development`; an empty variable is
|
|
171
|
+
skipped in favour of the next one, and surrounding whitespace is trimmed off
|
|
172
|
+
whichever name wins. Setting either of the two empties the cache.
|
|
173
|
+
- **`Kanon[:service]`** — the node from the cache, the same object every time. On
|
|
174
|
+
a miss it falls back to `load_config`, so a cleared cache heals itself.
|
|
175
|
+
- **`Kanon.load_config(:service)`** — reads once and remembers; later calls return
|
|
176
|
+
that same object.
|
|
177
|
+
- **`Kanon.load_configs(:service, :storage)`** — the same for several names at
|
|
178
|
+
once, returning `{ service: node, storage: node }`.
|
|
179
|
+
- **`Kanon.read_config(:service)`** — reads the file again and leaves the cache
|
|
180
|
+
alone. A new object with equal data, so compare readings by value.
|
|
181
|
+
- **`Kanon.expected_variables(:service)`** — the names the file reads and whether
|
|
182
|
+
each is mandatory, `{ 'SERVICE_POOL_SIZE' => false }`, without reading one of
|
|
183
|
+
them. It walks the file the way a read does, so a file that could not load is
|
|
184
|
+
refused here too instead of reporting a clean list.
|
|
185
|
+
- **`Kanon.loaded_files`** — which files the cache holds.
|
|
186
|
+
|
|
187
|
+
Every reader that resolves values stops the application when a mandatory one is
|
|
188
|
+
missing, naming every absent variable at once; `expected_variables` reads none of
|
|
189
|
+
those variables and never fails that way — what it refuses is a file the loader
|
|
190
|
+
could not read at all. One `load_configs` at boot turns this into a single check at
|
|
191
|
+
startup instead of a failure at first use.
|
|
192
|
+
|
|
193
|
+
A reader hands back a node, never a hash:
|
|
194
|
+
|
|
195
|
+
- **`node.token`** — a key written out in the code. A typo raises `NoMethodError`
|
|
196
|
+
listing the keys that are there.
|
|
197
|
+
- **`node[:token]`, `node['token']`** — a key computed at runtime, as a symbol or
|
|
198
|
+
a string.
|
|
199
|
+
- **`node.dig(:pool, :size)`** — the same at depth, and into arrays by index.
|
|
200
|
+
- **`node.key?(:token)`, `node.keys`** — what the node holds.
|
|
201
|
+
- **`node.to_h`** — a copy for a third party that needs a hash. Its containers are
|
|
202
|
+
new and unfrozen; leaf strings stay the frozen ones of the tree.
|
|
203
|
+
|
|
204
|
+
What any implementation of this format must do — the grammar of a declaration, how
|
|
205
|
+
a variable name is derived, the casting table, the order of checks — is in
|
|
206
|
+
[docs/specification.md](docs/specification.md). Why it is shaped that way, and the
|
|
207
|
+
known edges it leaves, are in [docs/decisions.md](docs/decisions.md).
|
|
208
|
+
|
|
209
|
+
## What the loader refuses
|
|
210
|
+
|
|
211
|
+
Every name below lives under `Kanon::` and descends from `Kanon::Error`.
|
|
212
|
+
|
|
213
|
+
- **`FileMissing`** — the file is absent, or the name is not a bare file name at
|
|
214
|
+
all.
|
|
215
|
+
- **`InvalidSource`** — the file exists but cannot be read: a directory or a file
|
|
216
|
+
the process may not open, broken YAML, an alias with no anchor, a failing ERB
|
|
217
|
+
tag, a forbidden YAML type, a document that refers to itself through an alias,
|
|
218
|
+
or two keys that become one when they turn into symbols.
|
|
219
|
+
- **`EmptyDirectory`** — the directory was set to nothing: `nil`, or a string with
|
|
220
|
+
no more than whitespace in it.
|
|
221
|
+
- **`EmptyEnvironment`** — the environment was set to nothing, by the same
|
|
222
|
+
measure.
|
|
223
|
+
- **`MissingValue`** — a mandatory value is absent or empty, naming every such
|
|
224
|
+
variable at once.
|
|
225
|
+
- **`InvalidValue`** — a value does not fit its type, or a declaration names a
|
|
226
|
+
type outside the five.
|
|
227
|
+
- **`InvalidDeclaration`** — a declaration carries an unsupported key.
|
|
228
|
+
- **`InvalidVariableName`** — a name no shell could set, derived or written out
|
|
229
|
+
in `env`: anything but a letter or `_` followed by letters, digits and `_`.
|
|
230
|
+
- **`ConflictingVariable`** — two paths read one variable, derived or explicit.
|
|
231
|
+
- **`UnknownKey`** — an unknown key in `[]`, a `dig` that ran into a scalar, an
|
|
232
|
+
index past the end of an array.
|
|
233
|
+
- **`ReservedKey`** — a key named after a node method, which would otherwise
|
|
234
|
+
shadow the value.
|
|
235
|
+
|
|
236
|
+
Outside that family the loader lets Ruby speak: `NoMethodError` for a typo in a
|
|
237
|
+
key, listing the keys that are there, and `FrozenError` for an attempt to change
|
|
238
|
+
what was read.
|
|
239
|
+
|
|
240
|
+
An empty string counts as an absent value. No message of Kanon's own prints a
|
|
241
|
+
value: a failing cast reports the class and the length instead. Names it does
|
|
242
|
+
print — a key, a variable, a declared type — so a secret an ERB tag put in one of
|
|
243
|
+
those places reaches the message like any other name.
|
|
244
|
+
|
|
245
|
+
Reserved keys are every public instance method the node has: its own (`[]`,
|
|
246
|
+
`dig`, `key?`, `keys`, `to_h`, `==`, `eql?`, `hash`, `inspect`, `as_json`,
|
|
247
|
+
`to_json`, `encode_with`, `marshal_dump`, `instance_variables`) and all of
|
|
248
|
+
`Object`'s, `class` and `dup` and `send` and `freeze` and `tap` among them. Private
|
|
249
|
+
`Kernel` methods such as `format` or `timeout` are free to be keys.
|
|
250
|
+
|
|
251
|
+
## What never leaves through diagnostics
|
|
252
|
+
|
|
253
|
+
`inspect`, `as_json`, `to_json` and `to_yaml` print key names only,
|
|
254
|
+
`instance_variables` is empty and `Marshal.dump` refuses, so a tree of secrets
|
|
255
|
+
reaches neither a JSON response, nor a log, nor a queue. A typo reports what is
|
|
256
|
+
there without any value: `undefined method 'tokn' for #<Kanon::Node keys: url,
|
|
257
|
+
username, token>`.
|
|
258
|
+
|
|
259
|
+
A failure the library wraps drops the exception that caused it, so the offending
|
|
260
|
+
text cannot reach a log through `cause`, through `full_message` or through an
|
|
261
|
+
exception tracker that walks the chain. Ruby's own `FrozenError` is the exception
|
|
262
|
+
to all of this: an attempt to change a leaf prints the value it refused to change,
|
|
263
|
+
and nothing here can stop it.
|
|
264
|
+
|
|
265
|
+
One hole cannot be closed from here: `Oj.dump` in `:object` mode reads instance
|
|
266
|
+
variables through the C API, past any Ruby method, and prints the values. That
|
|
267
|
+
holds for every object of an application, not only these; the cure is
|
|
268
|
+
`Oj.default_options = { mode: :compat }` on the application side.
|
|
269
|
+
|
|
270
|
+
## What it is not
|
|
271
|
+
|
|
272
|
+
- **Not a validator.** There is casting and there is mandatoriness; format,
|
|
273
|
+
ranges and allowed values are out of scope.
|
|
274
|
+
- **Not hot reload.** A config is read once per process; changing a value means
|
|
275
|
+
restarting.
|
|
276
|
+
- **Not a secret store.** Values arrive from the environment; rotation and
|
|
277
|
+
permissions belong to the infrastructure.
|
|
278
|
+
- **Not a way to express structures through the environment.** Nested structures
|
|
279
|
+
live in the file. A variable overrides a scalar, or a flat list of scalars
|
|
280
|
+
given as a comma separated string.
|
|
281
|
+
|
|
282
|
+
One edge is worth knowing before converting a file: a hash of data whose keys all
|
|
283
|
+
come from the declaration vocabulary is read as a declaration, and a key named
|
|
284
|
+
`env` makes one on its own. The rest are in
|
|
285
|
+
[docs/decisions.md](docs/decisions.md).
|
|
286
|
+
|
|
287
|
+
Reading through a node costs about five times a plain hash read, and about seven
|
|
288
|
+
times when every read goes through `Kanon[...]`. It shows only in loops of hundreds
|
|
289
|
+
of thousands of reads — on a hot path take the node into a local variable once. The
|
|
290
|
+
numbers come from `script/benchmark.rb`, which needs no framework:
|
|
291
|
+
`ruby -Ilib script/benchmark.rb`.
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT. See [LICENSE](LICENSE).
|
data/docs/decisions.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Kanon: decisions and their reasons
|
|
2
|
+
|
|
3
|
+
Why the format and the library are shaped the way they are. What an
|
|
4
|
+
implementation must do is in [specification.md](specification.md); this document
|
|
5
|
+
never restates a rule, only the thinking behind it.
|
|
6
|
+
|
|
7
|
+
## Principles
|
|
8
|
+
|
|
9
|
+
Break any of these and the solution stops being itself.
|
|
10
|
+
|
|
11
|
+
**1. Configuration is data, not code.** Composition, defaults and types live in
|
|
12
|
+
the file. Changing a value never means changing the application, and the whole
|
|
13
|
+
shape of a configuration is visible in one place.
|
|
14
|
+
|
|
15
|
+
**2. One way to read, one shape of access.** A node always comes out. There is no
|
|
16
|
+
second entry point handing out a raw mapping, and the node never pretends to be
|
|
17
|
+
one — partial resemblance hides a forgotten conversion until it breaks somewhere
|
|
18
|
+
else.
|
|
19
|
+
|
|
20
|
+
**3. What was read cannot change.** Immutability is what makes "read once" safe to
|
|
21
|
+
rely on: a tree that nobody can edit is the same tree wherever it is passed. The
|
|
22
|
+
copy handed to a third party has left the configuration and lives by that party's
|
|
23
|
+
rules.
|
|
24
|
+
|
|
25
|
+
**4. Read once, on first use.** Laziness exists so that a configuration the
|
|
26
|
+
application does not need cannot keep it from starting. Caching exists so that a
|
|
27
|
+
crowd of global constants does not appear for the sole purpose of reading a file
|
|
28
|
+
once.
|
|
29
|
+
|
|
30
|
+
**5. Failure is loud and early.** A missing value brings the application down at
|
|
31
|
+
boot naming every absent variable at once, not one per restart. A typo in a key
|
|
32
|
+
is an error listing what is there. The silent empty value is what all of this
|
|
33
|
+
exists to prevent: a credential quietly dropped is an outage that starts long
|
|
34
|
+
before anyone reads a log.
|
|
35
|
+
|
|
36
|
+
**6. The file states which variables exist.** The structure is the single source
|
|
37
|
+
of truth about what must be provided, and that list can be extracted without
|
|
38
|
+
reading any of it, so a check before boot needs no grammar of its own.
|
|
39
|
+
|
|
40
|
+
**7. No value leaves through the library's own diagnostics.** Names do — of keys,
|
|
41
|
+
of variables, of declared types — because a refusal nobody can act on is worse than
|
|
42
|
+
one that names what to fix, and a name built by a template tag is printed like any
|
|
43
|
+
other. What the host language says on its own is outside this: the error Ruby
|
|
44
|
+
raises for an attempt to change an immutable leaf prints the value it refused to
|
|
45
|
+
change, and nothing in the library can stop it.
|
|
46
|
+
|
|
47
|
+
**8. No framework underneath.** The standard library only, with the directory and
|
|
48
|
+
the environment injected from outside. Without that there is no library, only a
|
|
49
|
+
piece of somebody's application.
|
|
50
|
+
|
|
51
|
+
**9. A configuration class is optional.** It appears only where something has to
|
|
52
|
+
be computed. Reading data takes no code at all.
|
|
53
|
+
|
|
54
|
+
**10. Whatever was read becomes one shape.** The consumer cannot tell where a
|
|
55
|
+
value came from.
|
|
56
|
+
|
|
57
|
+
## Settled
|
|
58
|
+
|
|
59
|
+
**YAML, at a cost that is worth naming.** Six complications in the specification
|
|
60
|
+
exist only because the format is YAML: a second document after `---`, a
|
|
61
|
+
self-referring alias, an alias with no anchor, a forbidden tagged type, two keys
|
|
62
|
+
that differ only by a leading colon, and what a file built on `default: &default`
|
|
63
|
+
does when it is read outside its sections. A simpler format would carry none of
|
|
64
|
+
them.
|
|
65
|
+
|
|
66
|
+
It is still the right base. The library is meant to read configuration files a
|
|
67
|
+
project already has, with the smallest possible edit, and such files are built on
|
|
68
|
+
`default: &default` anchors and carry template tags. A format without anchors turns
|
|
69
|
+
adopting the library into rewriting every file it is meant to read. YAML also
|
|
70
|
+
comes with the standard library, while every alternative costs a dependency and
|
|
71
|
+
with it principle 8.
|
|
72
|
+
|
|
73
|
+
**Domain dictionaries do not belong to this layer.** Lists of types, tax tables and
|
|
74
|
+
similar data are the same on every installation and change together with the code.
|
|
75
|
+
Principles 5 and 6 work against them: a variable name would be derived from a data
|
|
76
|
+
key, an empty entry would read as a missing variable, and arbitrary key text would
|
|
77
|
+
be normalised. Dropping the demand for a section made such a file readable; it did
|
|
78
|
+
not make reading it a good idea.
|
|
79
|
+
|
|
80
|
+
**Values come from the environment, and from nothing else.** A pluggable source —
|
|
81
|
+
a setter in place of the single environment lookup — was weighed and left out.
|
|
82
|
+
Secret stores are reached before the process starts, by tools that export what they
|
|
83
|
+
fetched, or by the secret injection of container platforms, and their values arrive
|
|
84
|
+
as ordinary variables. One deployment would justify the seam: one forbidden to put
|
|
85
|
+
secrets into the process environment at all. Until such a deployment exists the
|
|
86
|
+
seam is an abstraction with a single implementation, and adding it later breaks
|
|
87
|
+
nothing — the setter is new surface, not changed surface.
|
|
88
|
+
|
|
89
|
+
**A derived name has to be one a shell could set.** Nothing else can satisfy it,
|
|
90
|
+
so a name outside `[A-Za-z_][A-Za-z0-9_]*` is refused at boot rather than asked
|
|
91
|
+
for and never answered.
|
|
92
|
+
|
|
93
|
+
**A marker for declarations was rejected.** The one way to close the edge below
|
|
94
|
+
where data can look like a declaration is to demand a marker on every declaration.
|
|
95
|
+
That makes the common case pay for the rare one, and every existing file pay for a
|
|
96
|
+
collision most of them never hit.
|
|
97
|
+
|
|
98
|
+
## The Ruby surface
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
Kanon[:name] # node from the cache, load_config on a miss
|
|
102
|
+
Kanon.read_config(:name) # node, reads the file every time
|
|
103
|
+
Kanon.load_config(:name) # node, reads once and remembers
|
|
104
|
+
Kanon.load_configs(:one, :two) # { one: node, two: node }
|
|
105
|
+
Kanon.expected_variables(:name) # { name => mandatory? }, reads no such variable
|
|
106
|
+
Kanon.directory # and Kanon.directory = path
|
|
107
|
+
Kanon.environment # and Kanon.environment = name
|
|
108
|
+
Kanon.loaded_files # diagnostics
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`read` and `load` split "read" from "read and remember"; `[]` is the consumer's
|
|
112
|
+
form, honest because everything is warmed up at boot.
|
|
113
|
+
|
|
114
|
+
Contracts that are easy to lose in a rewrite:
|
|
115
|
+
|
|
116
|
+
- `load_configs` returns a name-to-node mapping on purpose: a one-letter typo
|
|
117
|
+
(`load_configs` instead of `load_config`) then fails on the same line.
|
|
118
|
+
- `read_config` returns a new object with equal data every time. Only
|
|
119
|
+
`load_config` guarantees identity, so compare configurations by value.
|
|
120
|
+
- `expected_variables` walks the file through the same code a read walks. A walk
|
|
121
|
+
of its own would drift from the real one, and did.
|
|
122
|
+
|
|
123
|
+
## Known edges
|
|
124
|
+
|
|
125
|
+
Consequences of the rules, documented rather than fixed. A conforming
|
|
126
|
+
implementation reproduces them.
|
|
127
|
+
|
|
128
|
+
**A data key can be mistaken for an environment section.** Not demanding a section
|
|
129
|
+
costs one silent case: a file whose top-level key happens to be `test` or
|
|
130
|
+
`production` is truncated to that branch without a word. A file built around a
|
|
131
|
+
`default: &default` anchor gives itself away instead, because every copy of the
|
|
132
|
+
anchor resolves at once — the read stops either on the values those copies expect,
|
|
133
|
+
named with the section in front (`DEFAULT_SERVICE_TOKEN` beside
|
|
134
|
+
`TEST_SERVICE_TOKEN`), or on two paths reading one variable where a declaration
|
|
135
|
+
carries an explicit `env`.
|
|
136
|
+
|
|
137
|
+
**A hash of data can look like a declaration.** Real data uses the four vocabulary
|
|
138
|
+
words too. Most such collisions announce themselves: a `type` outside the five
|
|
139
|
+
names is refused, and a declaration that demands a value stops the boot naming a
|
|
140
|
+
variable nobody meant to set. It passes quietly only when the declaration it
|
|
141
|
+
imitates demands nothing — it carries a `default`, or it is `optional` — and the
|
|
142
|
+
subtree is then replaced by that default or by nothing. One key from outside the
|
|
143
|
+
vocabulary keeps a mapping as data, and that is the escape when converting a file —
|
|
144
|
+
but not for a mapping carrying `env`, which is a declaration on that key alone.
|
|
145
|
+
|
|
146
|
+
**A misspelt declaration key turns the declaration into data.** `{ dfault: 5 }`
|
|
147
|
+
carries no word of the vocabulary, so it is data, reads back as data and expects
|
|
148
|
+
nothing. "A typo raises" covers a key read out of the tree; nothing can tell a near
|
|
149
|
+
miss from a dictionary that happens to be worded that way.
|
|
150
|
+
|
|
151
|
+
**Only the first document of a file is read.** Everything past a `---` separator is
|
|
152
|
+
dropped without a word.
|
|
153
|
+
|
|
154
|
+
**The shape of the tree depends on how much data is in the file.** A single root
|
|
155
|
+
key over a mapping of its own is dropped, so removing the last but one entry of a
|
|
156
|
+
dictionary changes every reading path.
|
|
157
|
+
|
|
158
|
+
**A value with a default hides a vanished variable.** The default takes over
|
|
159
|
+
silently; the report of expected variables names it, but nothing compares that list
|
|
160
|
+
against the example environment file.
|
|
161
|
+
|
|
162
|
+
**An index is a position, not a name.** Inserting an element into a sequence
|
|
163
|
+
renames everything below it. The file keeps parsing and the shifted names keep
|
|
164
|
+
resolving, so the mistake surfaces as wrong values rather than as a failure. A name
|
|
165
|
+
that must survive reordering is written out with `env`.
|
|
166
|
+
|
|
167
|
+
**Whitespace counts differently on the two sides.** A variable holding only spaces
|
|
168
|
+
is a value, while an environment name holding only spaces is refused. A name made of
|
|
169
|
+
spaces can mean nothing, and a value made of spaces can; the asymmetry is deliberate
|
|
170
|
+
and easy to trip over.
|
|
171
|
+
|
|
172
|
+
**A value reaches the consumer verbatim.** A declaration is substituted after the
|
|
173
|
+
document has been parsed, so a value cannot add keys, define anchors or overwrite a
|
|
174
|
+
neighbour the way it could through a template tag. In exchange the parser no longer
|
|
175
|
+
folds control characters: what the source holds arrives as it is, which matters for
|
|
176
|
+
values that end up in mail headers or glued into a URL.
|