Schema Registry¶
The schema registry is a Git repository that holds two things:
- CUE schema files — used to validate config files before any write
bundle.json/rules.json— the tenant's layer of rule settings, laid over the rules inconfig.cue(see How a rule is built)
Repo structure¶
my-org/schemas/ ← schema registry repo
├── bundle.json ← the tenant's rule settings (optional)
├── app.yaml.cue ← CUE schema for every file named app.yaml
├── config.toml.cue ← CUE schema for every file named config.toml
├── .env.cue ← CUE schema for every file named .env
├── default.cue ← schema for any other file (optional)
├── teams/
│ └── payments.cue ← a schema a `schema_map` entry names (optional)
└── presets/
└── team.json ← a preset bundle.json can extend (optional)
Schemas found by a file's name are read from the repository's root only. A schema in a folder is used when a rule's schema_map names it. See CUE schemas for what goes in one.
The schema repo is pointed to from config.cue:
varTrack clones this repo for each tenant on first use and refreshes it automatically on a TTL (default 5 minutes) or immediately when the schema repo itself receives a push webhook.
A tenant is something the operator declares; a request only names one. The repository, branch and credential of a tenant's schema repo are its schema_repo in tenants (the SCHEMA_TENANT_<ID>_* variables still work for one release and log a warning), and nothing a request carries can add or change them:
- A schema push belongs to the tenant whose registered schema repository it came from. A push from a repository no tenant registered is refused (
404on the HTTP route,NOT_FOUNDover gRPC), and what is re-read is the registered repository with the registered credential, never the pushed one and never a token from the webhook's headers. A push to a branch the registry does not follow is ignored. Atenant_idin the body, when there is one, must be that tenant's. POST /v1/tenants/{tenant}/schemas/warmtakes no repository, branch or token; an unregistered tenant is404.- A tenant name is letters, digits,
-and_, starting with a letter or digit, up to 63 characters, wherever it is given (a header, a body, a URL,vt --tenant); anything else is400. - A file never says which tenant it belongs to. A rule's tenant is the tenant whose schema repository holds the
bundle.jsonthat layers it, or, for aconfig.cuerule nobundle.jsonnames, the first of itstenants(else the declared tenant whose repositories it covers, elsedefault). Atenant_idwritten in abundle.jsonor avartrack.jsonis not a setting: a sync drops it with a warning, andvt validate-configreports it as an error. A file that could name its own tenant could act as another one.
CUE schemas¶
A schema says which keys a config file must have and what their values may be. varTrack checks every file a sync reads against its schema before writing it to a sink.
Which schema a file gets¶
In this order, the first that applies:
| # | Source | When it names a schema the schema repository does not have |
|---|---|---|
| 1 | the first schema_map entry whose match matches the file's path |
the file fails, strict_validation or not |
| 2 | the file's own $schema key, when the rule sets schema_from_file |
the file fails, the same way |
| 3 | <file name>.cue at the schema repo's root, by the file's name without its folder: configs/app.yaml and services/api/app.yaml both get app.yaml.cue |
not a miss: go on |
| 4 | default.cue |
not a miss: go on |
| 5 | none: the file is not checked | with strict_validation the file fails |
An explicit choice that cannot be honoured is an error, never a quiet fall-back to the next step: the failure reads schema api/settings.cue (schema_map[0], services/api/**/*.yaml) is not in the schema repository of tenant acme at 5c4e1f0a2b3c. The file is not written, so a placeholder its missing schema would have replaced with a @secret() value never reaches the store.
If the cue binary is not on the orchestrator's PATH (or at CUE_BINARY), files are not checked and a warning is logged; with strict_validation they fail.
Every file says what checked it. A task's files, a dry run's report, the web UI and vt validate show validation (ok: checked and passed, warn: checked, failed, not strict, failed: strict and failed, none: nothing checked it), the schema (its path in the schema repository, or none) and the schema_source (schema_map[N], file, name, default or none). Next to the task's schema_commit, that answers "which schema, at which commit, checked this file?". A file nothing checked reports none, never ok.
schema_map: a schema for a path¶
rules: [{
platform: "gitea", datasource: "mongo", repositories: ["acme/configs"]
schema_map: [
{match: "services/api/**/*.yaml", schema: "api/settings.cue"},
{match: "services/web/config.yaml", schema: "web.cue"},
{match: "legacy/**", schema: "none"}, // on purpose not checked
]
}]
matchis a glob over the file's path in its repository, with forward slashes:*stays inside one folder,**crosses folders (services/**/app.yamlalso matchesservices/app.yaml),?is one character,[abc]a set. Dotfiles match (.env). Case counts. There are no regular expressions.schemais a path inside the schema repository ending in.cue, ornonefor "not checked, on purpose". An absolute path, a..segment or a backslash is refused when the configuration loads; at sync time the path is joined to the clone and has to stay inside it, symlinks included.- The first match wins. A file no entry matches is found by its name, as before:
schema_mapadds to the name lookup, it does not replace it. config.cueand a tenant'sbundle.jsonmay set it; a repository'svartrack.jsonmay not, because the people who write the data must not choose what checks it. The layers add up,config.cue's entries first, so a path the operator mapped cannot be remapped by a tenant.- Every entry is checked when the configuration loads, and all the bad ones are named in one error. For a
bundle.jsonthat makes the tenant'sbundle.jsonunusable until it is fixed, as for any invalidbundle.json. When the schema repository is cloned or refreshed, an entry whose schema is not in it is logged once with the rule that names it.
A file names its own schema¶
With schema_from_file: true in the rule (config.cue or bundle.json), a top-level $schema key in a JSON, YAML or TOML file names its schema, in the syntax of a schema_map schema:
- It is read only when no
schema_mapentry matched the file. If one did and the file names a different schema, the map's schema is used and the file's result says so (the file's $schema (…) was overridden by schema_map[0]): the operator outranks the one who writes the data. - A URL (
https://json.schemastore.org/…, an editor's hint) is not a schema in the schema repository: it is ignored, with a note in the file's result. $schemais never synced as a key, whether it is honoured or not. Editors add it to YAML and JSON files for completion; it is taken out before the file is flattened.
What a schema sees¶
A schema checks the data as the sink receives it, not the file as written: after root_key is taken out, the environment slice is picked, the data is flattened to dotted keys, variables_map renames are applied and @secret() values are read.
Every value is a string by then: 5432 is "5432", true is "true", null is "". Write string & =~"^[0-9]+$" for a number — int rejects every value.
Example¶
The config repository has configs/app.yaml:
app:
name: checkout
database:
host: db.internal
port: 5432
log:
level: info
features:
dark_mode: true
which the schema sees as:
{
"app.name": "checkout",
"database.host": "db.internal",
"database.port": "5432",
"log.level": "info",
"features.dark_mode": "true"
}
The schema repo has app.yaml.cue:
```cue notest // Required: a file without one of these keys fails. "app.name": string & !="" "database.host": string & !="" "database.port": string & =~"^[0-9]{1,5}$"
// One of a fixed set of values. "log.level": "debug" | "info" | "warn" | "error"
// Optional: checked only when the file has it. "features.dark_mode"?: "true" | "false"
"log.level": 4 errors in empty disjunction: … "database.port": invalid value "55x" (out of bound =~"^[0-9]{1,5}$")and without `app.name` with `"app.name": incomplete value !=""`.
Keys the schema does not name are allowed. To reject them, wrap the schema in `close({ … })`:
```cue notest
close({
"app.name": string & !=""
"database.host": string & !=""
})
A field can also carry @secret() and @logger().
When a file fails¶
strict_validation |
The file |
|---|---|
false (default) |
is written; the failure is logged as a warning and the file's validation is warn |
true |
is not written; its validation is failed. The push's other files still sync |
With strict_validation every file has to be checked: a file no schema checks (step 5 above) fails with strict_validation: no schema checks configs/app.yaml (rule gitea/mongo): add a schema_map entry for it, or map it to "none" to leave it unchecked on purpose. A rule that relied on "strict, but nothing checks this file" needs one schema_map entry for it.
vt validate always treats a failure as an error and exits 1, so a pull request check catches it before the merge.
To try a schema on your machine, put the flat form above in data.json and run cue vet app.yaml.cue data.json.
How a rule is built¶
varTrack builds the rule a sync runs with in layers: the admin's config, the tenant's config and the repository's own. Each layer overrides the one before it, key by key:
| # | Layer | Where |
|---|---|---|
| 1 | The rule in config.cue |
the bundle every service reads |
| 2 | The tenant's rule | bundle.json in the tenant's schema repo — optional |
| 3 | The repository's overrides | vartrack.json in the pushed repo — optional (Per-Repo Overrides) |
| 4 | force |
the config.cue rule's force block |
branch_map, file_path_map, variables_map and prune are merged key by key across layers: a branch_map of {develop: staging} in bundle.json adds to the {main: production} of config.cue. The prune.protection patterns of every layer add up — a layer can protect more keys, never fewer. Every other key is replaced.
bundle.json and vartrack.json can also pull in shared settings with extends — see Presets.
Keys only config.cue sets. These are dropped from bundle.json with a warning (… sets endpoint, self_heal, which only config.cue sets — ignored). The gateway routes pushes by config.cue's repositories and the watcher heals the rules config.cue marks self_heal, so another value further down would split the services:
- the datasource's connection settings and credentials: every field of a datasource block (
endpoint,database,collection,update_strategy,hosts,bucket,host, …). A sink reads them from that block alone, under the names the block uses; a rule's copy of one, in any layer, is never read. The old flat names (mongo_uri,s3_bucket,ssh_host, …) are not options at all and are dropped as undeclared. - these rule options:
force cannot pin these either; they are set on the rule itself.
Which rules exist. The rules are config.cue's, since the gateway only routes pushes to those. A bundle.json rule for a platform and datasource with no rule in config.cue is ignored with a warning. A config.cue rule that no tenant's bundle.json names still syncs, as tenant default's. Its repo_url is derived from the platform's endpoint and the rule's only repository (http://gitea:3000 + acme/configs → http://gitea:3000/acme/configs.git); a webhook for a rule covering several repositories reads the pushed one.
A bundle whose config.cue has no rules gets only the second layer: bundle.json's rules are used as they are. Where the sink connects still comes from the datasource's block in config.cue only; a datasource config.cue does not declare runs on the sink's defaults.
Presets (extends)¶
A preset is a JSON file of rule settings in a Git repository, shared by every bundle.json or vartrack.json that names it in extends. A presets repository might hold:
my-org/presets/
├── default.json ← {"prune": {"protection": ["_vt_*"]}}
├── production.json ← {"extends": ["local>my-org/presets"], "prune": {"protection": ["manual.*"]}}
└── repos/
└── checkout.json ← {"prune": {"protection": ["features.*"]}}
A preset is named like this:
extends entry |
File |
|---|---|
local>my-org/presets |
default.json in my-org/presets |
local>my-org/presets:production |
production.json |
local>my-org/presets//repos/checkout |
repos/checkout.json |
local>my-org/presets:production#v2 |
production.json on branch or tag v2 (default: main) |
gitea>my-org/presets:production |
the same file on config.cue's platform gitea (any platform name: github, gitea-team, …), read with that platform's token |
local> (or no prefix) is the Git server of the file naming the preset: the schema repo's for bundle.json, the pushed repo's for vartrack.json, and the preset's own inside a preset.
Where a tenant's presets may come from. Once tenants are declared, a preset is read only from a repository the tenant owns: one of its repositories (with the platform credential a sync of them uses), its schema_repo (with that repository's token), or one its presets lists (with its presets_token). A preset anywhere else is refused with the preset named, before anything is fetched, and the platform's own token is never sent to it; vt validate-config reports it. Without declared tenants presets are unrestricted, as before.
So an entry reads as <server>><owner>/<repo>, then optionally :<file> (a file at the repository's root, without .json) or //<path> (a file in a folder, without .json), then optionally #<branch or tag>. With neither : nor // the file is default.json.
Example — one setting shared by every checkout repository. Create a repository my-org/presets with the file repos/checkout.json:
{
"description": "Settings every checkout service repository uses",
"prune": {"protection": ["features.*"]},
"branch_map": {"release": "production"}
}
Then each repository that wants it names it in its vartrack.json, and adds what is its own:
The sync of that repository runs with features.* added to the rule's prune.protection, release: production added to its branch_map, and root_key checkout. Change repos/checkout.json once and every repository that extends it gets the change on its next sync. A preset can hold any option the file extending it may set — for a vartrack.json that is the overridable keys; description is only a note and is dropped.
How presets are resolved:
- presets are merged left to right, then the settings beside
extendsgo on top; - a preset can extend presets itself; one already in the chain is skipped;
- a preset's
descriptionis dropped; - a preset that cannot be read, or is not a JSON object, is an error: a tenant whose
bundle.jsonnames one has no rules until it is fixed (logged asbundle.json cannot be used — the tenant has no rules until it is fixed: …), and a sync whosevartrack.jsonnames one fails.
The same holds for a bundle.json that does not parse: the tenant does not fall back to the config.cue rules without its layer.
A preset adds to its layer only: presets that a bundle.json extends are part of the tenant's layer, so keys only config.cue sets are dropped from them too, and presets that a vartrack.json extends are limited to its overridable keys. A changed preset is picked up when the tenant's schema repo is next refreshed (every SCHEMA_TTL_SECONDS, or on a push to it); a vartrack.json's presets are read on every sync.
bundle.json¶
bundle.json is a JSON array of rule objects — one entry per platform + datasource the tenant syncs. Each entry holds only what the tenant adds to, or changes in, the config.cue rule for that platform and datasource.
Format¶
Two formats are accepted:
Array (recommended):
Object with a rules key — which can also extends presets for every rule in it:
A rule can extends presets of its own as well; they come after the file's.
Example¶
With this rule in config.cue:
rules: [{
platform: "github"
datasource: "mongo"
repositories: ["acme/app-configs"]
file_name: "configs/app.yaml"
branch_map: {main: "production"}
self_heal: true
force: {prune: {enabled: false}} // production never prunes
}]
the tenant's bundle.json adds a staging branch, a root key and the smart-repair sync mode:
[
{
"platform": "github",
"datasource": "mongo",
"branch_map": {"develop": "staging"},
"root_key": "vartrack",
"sync_mode": "GIT_SMART_REPAIR",
"prune": true
}
]
The rule a sync runs with: file_name configs/app.yaml, branch_map {main: production, develop: staging}, root_key vartrack, sync_mode GIT_SMART_REPAIR, self_heal true, prune {enabled: false} (forced), and the mongo datasource's connection settings from config.cue.
Rule object fields¶
Every option, with the files that may set it, is in Rule options. The fields of a bundle.json rule:
| Field | Required | Description |
|---|---|---|
platform |
Yes | Platform name — github, gitea, or {type}-{tag}; with datasource, picks the config.cue rule |
datasource |
Yes | Datasource name — mongo, redis-cfg, s3-minio, … |
repo_url |
No | HTTPS clone URL of the config repository (default: derived from the platform and the rule's only repository) |
file_name |
One of the two | Single file path to track in all repos. A rule that names neither this nor file_path_map is refused |
file_path_map |
One of the two | { env → file_path } for per-environment file selection — merged |
branch |
No | The rule's own branch: it feeds the file_path_map environments, and self-heal compares it when there is no branch_map (default: main) |
branch_map |
No | { branch → env } label mapping — merged |
variables_map |
No | Key renames, {NEW_NAME: flat.key} — merged |
sync_mode |
No | AUTO, GIT_UPSERT_ALL, GIT_SMART_REPAIR; any other value is refused |
prune |
No | true, or {enabled, protection} — merged with the other layers; protection patterns add up, and an object with only protection does not enable pruning |
prune_protection |
No | Former flat name of prune.protection, still read. prune_last and prune.last were removed and are ignored |
root_key |
No | Subtree key to extract before flattening (default: "vartrack") |
branch_as_env / pr_as_env / tag_as_env |
No | Environment from the branch name, pr-{number}, or the tag |
destination_template, strict_validation |
No | As in config.cue |
schema_map, schema_from_file |
No | Which schema checks which file; see Which schema a file gets. schema_map adds to config.cue's entries |
key_format, file_format, env_as_filename, helm_values_mode, s3_object_tags, s3_tag_keys |
No | Sink layout options with no config.cue datasource field: how a key is flattened, how a file or object is laid out. They never say where a destination is or how to reach it, so a tenant may set them. See Rule options |
extends |
No | Presets for this rule, after the file's |
repositories, self_heal, secret_manager and the sink connection settings are set in config.cue only (see above). A key that is not an option at all is dropped too, with a warning that names it (… sets helm_binary, which is not an option a tenant may set — ignored): a bundle.json can set exactly the options listed here, whatever a sink happens to read. Helm's extra_flags and values_file_path are fields of the helm datasource in config.cue; which helm program runs is the orchestrator's HELM_BINARY environment variable.
rules.json¶
rules.json is a drop-in alias for bundle.json. If both files exist, bundle.json takes precedence. Use rules.json if you prefer a shorter name or want to distinguish the manifest from other bundle exports.
Tenant configuration¶
Tenants are declared in config.cue's tenants. Before that existed they were registered with environment variables on the orchestrator-service and its Celery workers; those still work for one release, log a warning, and lose to a declared tenant of the same name:
SCHEMA_TENANT_ACME_REPO=https://github.com/acme/schemas.git
SCHEMA_TENANT_ACME_BRANCH=main
SCHEMA_TENANT_ACME_TOKEN=ghp_xxx
The tenant ID is derived from the variable name (ACME → acme). Multiple tenants can be registered in the same process:
SCHEMA_TENANT_ACME_REPO=https://github.com/acme/schemas.git
SCHEMA_TENANT_ACME_TOKEN=ghp_xxx
SCHEMA_TENANT_BETA_REPO=https://github.com/beta-corp/schemas.git
SCHEMA_TENANT_BETA_BRANCH=production
SCHEMA_TENANT_BETA_TOKEN=ghp_yyy
Caching and refresh¶
| Behaviour | Detail |
|---|---|
| Clone on first use | Cloned to SCHEMA_CACHE_DIR (default /tmp/schema_registry) |
| TTL refresh | Re-fetched after SCHEMA_TTL_SECONDS (default 300 s) |
| Webhook invalidation | A push to the schema repo triggers an immediate re-clone for that tenant |
| Worker disk fallback | Celery workers read from the shared SCHEMA_CACHE_DIR volume without needing their own warm-up |
| Rule resolution cache | The tenant's bundle.json rule is cached in Redis for 300 s under key rule:{tenant_id}:{platform}:{datasource}; the other layers are laid over it on every lookup, so a config.cue change applies at once |
To share the clone cache between the API process and workers in Docker Compose or Kubernetes, mount SCHEMA_CACHE_DIR as a shared volume.
Relationship to config.cue¶
config.cue decides which platforms, datasources and rules exist, how to connect to each datasource, and which settings no tenant may change (force). bundle.json is the tenant's layer on top: nothing in it repeats config.cue, and nothing has to be kept in sync. A push to the schema repo makes workers pick up a changed bundle.json within seconds.