Keep Settings and Secrets out of the Code
The shop emails receipts through a mail provider. The provider’s password must not be in the repository, the sender address differs between your machine and production, and the catalog needs a whole table of currency rates. All of these are configuration.
Add Configuration declares a value — the limit of store.orders, above which its create answers LIMIT_EXCEEDED — reads it from context.configuration and sets it per environment. What follows is everything else: secrets,
what a schema can hold, and how the values of the Context combine
with those of the manifest.
§Keep an API key out of the repository
Receipts are sent by a component of their own, notifications. Declare the value with format: secret:
# components/notifications/manifest.toa.yaml
name: notifications
configuration:
schema:
properties:
provider: { type: string, enum: [console, smtp] }
sender: { type: string, format: email }
password: { type: string, format: secret }
required: [provider, sender]
operations:
send:
input:
type: object
properties:
to: { type: string }
text: { type: string }
required: [to, text] In the Context, give a reference instead of the value: an uppercase name prefixed with $.
Add the component under configuration:
# context.toa.yaml
configuration:
identity.tokens:
# …
notifications:
provider: smtp
sender: shop@example.com
password: $SMTP_PASSWORD The key under identity.tokens, which toa create wrote, is such a
reference too.
A plain string in place of a secret is refused:
'password' is a secret and must be given as a $NAME reference. A secret may sit anywhere in the schema, inside nested objects and array items, and several components may refer to the same name.
§Read it
On the context a secret is not a string. Call unwrap() where the value is used:
// components/notifications/operations/send.ts
import type { Context, SendInput } from '../types/index.d.ts'
export async function effect (input: SendInput, context: Context) {
const { provider, sender, password } = context.configuration
await deliver(provider, {
from: sender,
to: input.to,
text: input.text,
password: password?.unwrap()
})
} deliver stands for the code that hands the message to the provider. Converted to a string,
serialized to JSON or inspected, a secret gives <REDACTED>, so returning or logging context.configuration does not leak it. Log specific values rather than the whole object
anyway.
§Supply the value locally
toa env, which npm run env runs, writes one empty variable per reference into .env, named TOA_CONFIGURATION__<NAME>:
$ npm run env TOA_CONFIGURATION__SMTP_PASSWORD= Fill it in; values already in the file are kept when toa env runs again. Then restart the
application and call the operation:
$ npm run restart $ npx toa call notifications.send "{ input: { to: 'customer@example.com', text: 'Thank you' } }" The variable is not visible to the component’s code, which reads the secret only through context.configuration.
§Supply the value to a deployment
Secret values are not deployed with the application. Deploy them once per environment, into the secret named configuration:
$ npx toa conceal configuration SMTP_PASSWORD=xxxxxxxx Several values can be given in one command, and values are added to a secret that already exists.
§What the manifest takes
configuration takes two keys and nothing else:
| Key | |
|---|---|
schema | Required. A JSON Schema of the configuration object. |
defaults | Optional. The values the component runs with unless the Context says otherwise. |
context.configuration is one object, already checked against the schema. A value that does not
fit never reaches an operation, so operations do not check it.
§Defaults
defaults must fit the schema. It is the only way to give a value a default: a default keyword
written inside the schema fills nothing.
Mark a value required when there is no sensible default and the Context has to supply it:
configuration:
schema:
properties:
sender: { type: string, format: email }
required: [sender] §Nested values
The schema can be as deep as the values need. Objects and arrays work as in any JSON Schema:
# components/store.catalog/manifest.toa.yaml
configuration:
schema:
properties:
currencies:
type: array
items:
type: object
properties:
code: { type: string }
rate: { type: number }
required: [code, rate]
required: [currencies]
defaults:
currencies:
- code: EUR
rate: 1 const { rate } = context.configuration.currencies.find((c) => c.code === input.currency) §With a prototype
A component whose prototype declares configuration extends it: the two schemas and the two sets of defaults are combined, and the component’s operations read the prototype’s values and its own from the same object.
§What the Context takes
The configuration annotation holds values per component, keyed by component id:
# context.toa.yaml
configuration:
store.orders:
limit: 10000 The Context’s values stand over the manifest’s defaults value by value: naming limit here
leaves every other default as the manifest declares it. A component of the default namespace
may be written by its bare name.
A key that names no component, or a component that declares no configuration, stops the command that reads the Context:
Component 'foo.bar' does not request configuration or does not exist. §Per environment
The @environment suffix works at any depth:
# context.toa.yaml
configuration:
store.orders:
limit: 10000
limit@staging: 100
notifications:
provider: console
provider@production: smtp
sender@local: shop@localhost
sender@production: shop@example.com A key with only suffixed forms has no value from the Context in the other environments, which then take the manifest’s default if there is one.
§Resources of the values service
Configuration is served to components by a service deployed with the application. One key of the
annotation is not a component: resources states what that service may take in a deployment.
# context.toa.yaml
configuration:
resources:
cpu: [200m, 1000m]
memory: [200Mi, 500Mi]
store.orders:
limit: 10000 Each is a pair of request and limit; resources: ~ deploys the service without any. A component
that is really named resources is written with its namespace: default.resources.
§Try a component with other values
Set TOA_CONFIGURATION_<NAMESPACE>_<NAME> to a YAML or JSON object to give a component its
configuration directly. The manifest’s defaults fill what the object leaves out, the schema is
applied, and the Context is not consulted. The components of the application run in app, one
of the two processes npm start keeps under PM2; stop it
and start store.orders by hand, with the variable:
$ pm2 stop app
$ TOA_CONFIGURATION_STORE_ORDERS='{"limit":1}' npx toa compose components/store.orders $ npx toa call store.orders.create "{ input: { customer: 'c1', items: [], total: 1500 } }" The reply is the error LIMIT_EXCEEDED. Use the variable to try a component with values that
differ from the Context, or to run it where the values service is absent. Stop the composition
with Ctrl+C; npm run restart starts app again.
§Where values come from when a component starts
A component asks the values service for its configuration when it starts, and serves nothing until
it is answered. In the application toa create wrote, the service is part of sys, the process
that runs beside the components. toa mono runs it inside its one process; beside compositions
started by hand, start it on its own:
$ npx toa serve configuration It answers with, in order:
- the latest configuration created for the component while running, if there is one;
- otherwise the values the component was deployed with: the manifest’s
defaultswith the Context’s values over them.
The first is how a value changes without a deployment: Change a Setting without Redeploying.
§What does not belong in configuration
- Addresses of the database, the broker or the cache. They are annotations of the Context: Addresses and Credentials.
- Data that users of the application change. That is entity state, with operations and events of its own.