Reconcile
Frontier can manage parts of its platform configuration from a YAML file instead of
one-off API calls. You write down what should exist, and the frontier reconcile command
makes the server match it. The frontier export command does the reverse: it prints what
the server has, in the same file format.
This gives you three things the API alone does not:
- A reviewable file that says what should exist. Keep it in git and change it via pull requests.
- A plan before every change. A dry run prints every add, update, and delete the file would cause, without applying anything.
- Removal that works. Anything the file no longer wants shows up in the plan and is removed on apply.
The desired-state file
A file holds one or more YAML documents. Each document names the format version, a kind of resource, and a spec:
apiVersion: v1
kind: PlatformUser
spec:
- type: user
ref: alice@example.org
relation: admin
- type: user
ref: bob@example.org
relation: member
- type: serviceuser
ref: 9d776a1c-2f2e-4e56-a6f9-71a25a2eab5f
relation: adminRules that apply to every document:
- A document without
apiVersionis read asv1. An unknown version is rejected. - A document with a missing spec is rejected, because that is usually a typo. To mean an
empty list on purpose, write
spec: []. - The whole file is parsed and checked before anything applies. A malformed later document stops the run before any change is made.
- Documents apply in the order they appear in the file.
- Some kinds depend on others. A role can grant a permission, so a
Permissiondocument must come before aRoledocument in the same file. The wrong order is rejected before anything applies.
The PlatformUser kind
PlatformUser manages who holds platform-wide access: admin (superuser) or member.
An entry is:
| Field | Value |
|---|---|
type | user or serviceuser |
ref | email or id for a user; id for a service user |
relation | admin or member |
The file is the full access list. Anyone listed gets that access. Anyone on the server but not in the file loses it. To add access, add an entry; to remove it, delete the entry. Someone who holds both relations has two entries.
Two safety properties:
- Adds run before removes, so changing someone from
admintomembernever leaves them with no access if a step fails in between. - The bootstrap service user (the automation account from
app.admin.bootstrap) is never touched: the reconciler skips it on the server side and rejects file entries that name it.
Adding a user by an email that does not exist creates that user.
A ref must be a plain email address or a uuid (a user id, or a service user id). A
display-name email like Alice <alice@x.com> or a name slug is rejected, so an entry always
resolves to exactly the principal you meant, and a non-canonical id (uppercase, urn:uuid:)
still matches its principal instead of being treated as a new one.
Because the file is the full list, spec: [] removes every platform user. The bootstrap
service account is the exception: it is never removed. Keep app.admin.bootstrap configured,
since that account is the recovery path if a file ever empties the list.
The Permission kind
A permission is an identity: a service/resource namespace plus a verb. There is nothing
else to manage on it, so it is either created or deleted.
apiVersion: v1
kind: Permission
spec:
- namespace: compute/order
name: get
- namespace: compute/order
name: legacy
delete: true- The namespace has two parts,
service/resource. The name is alphanumeric. - Frontier's own permissions (the
appnamespaces) belong to the base schema. The reconciler ignores them and rejects a file entry that names one. - A custom permission on the server that the file does not list fails the plan. Nothing
is deleted just because it is missing; deleting needs
delete: trueon the entry. - Creating a permission also updates the authorization schema, so a new permission is ready to use in roles right away.
The Role kind
Role manages platform-level roles. The role name is the identity and never changes. The
managed fields are the title, the description, the permissions, and the scopes (the
resource types a role can attach to).
apiVersion: v1
kind: Role
spec:
- name: compute_order_manager # custom role: must list its permissions
title: Order Manager
description: Manages compute orders
permissions:
- compute_order_get
- compute_order_update
scopes: # resource types this role can attach to
- compute/order
- name: app_project_viewer # predefined role: override only the fields you list
permissions:
- app_project_get
- resource_aoi_get
scopes: # override the scope too, if you need to
- app/project
- name: old_role
delete: trueThere are two kinds of roles, and they behave differently.
Custom roles are yours to manage in full. A custom role must list at least one
permission. Every custom role on the server must appear in the file, kept or marked
delete: true; one that is missing fails the plan. A role that still has policy bindings
cannot be deleted, and the apply fails with a clear error.
Predefined roles are the ones Frontier ships, like app_organization_owner or
app_project_viewer. They converge to their shipped definitions. A file entry overrides
only the fields it lists; a field you leave out goes back to the default. A predefined
role you do not list at all resets fully. The plan marks these resets, so a hand-made
change to a predefined role shows up as an update back to the default. You cannot delete a
predefined role; the server recreates it at boot.
Two edge cases to know. A field that is empty on the server — say the scopes on a role created before that field existed — is changed only when you list it, so exports keep round-tripping cleanly. And an empty permission or scope list is rejected: you cannot blank those out through the file.
Because a predefined role resets to its default, removing its entry from the file is a
real change. If an entry adds permissions to app_project_viewer, deleting that entry
takes those permissions away on the next apply.
Permission references accept any form the server knows: the slug (compute_order_get),
service/resource:verb, or service.resource.verb.
scopes is the list of resource types a role can attach to (app/organization,
app/project, compute/order, and so on). Note how it shows up in an export: a custom
role lists its scopes in full, but a predefined role lists scopes only when they differ
from the shipped default. So a predefined role whose scope already matches the default
won't show a scopes line — the scope is still enforced, it just isn't repeated. The same
goes for title: it appears only when you have changed it.
The Preference kind
Preference manages platform settings, like whether new organizations can be created or
what an invite email says. An entry is a trait name and a value.
apiVersion: v1
kind: Preference
spec:
- name: disable_orgs_on_create
value: "true"
- name: invite_with_roles
value: "false"- Values are strings. A yes/no setting is
"true"or"false". - The name must be a platform trait the server knows. An unknown name fails the plan.
- The value must be one the trait accepts. The plan checks it against the live trait, so an empty value, or a value outside a yes/no or select trait's options, fails the plan up front instead of failing later when it is applied.
- This kind resets instead of deleting. The file is the full desired state: a preference
you list is set to your value, and a preference you leave out goes back to its default.
There is no
deleteflag, because setting a preference back to its default is what removal means here. - Export writes only the preferences whose value differs from the default, so settings at their default stay out of the file.
The Webhook kind
Webhook manages webhook endpoints: a URL, the events it subscribes to, and whether it is
enabled. The URL is the identity and never changes.
apiVersion: v1
kind: Webhook
spec:
- url: https://hooks.example.org/frontier
description: Ops notifications
subscribed_events:
- app.user.created
- app.group.created
state: enabled
- url: https://old.example.org/frontier
delete: true- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the server share a URL, the identity is ambiguous: the plan fails and names the ids so you can remove the extra one by hand.
subscribed_eventsis the full set of events the endpoint receives, compared as a set. An empty list, or leaving the field out, means every event, which is the server default. It is the complete desired set, not a keep-if-omitted field: dropping it sets the endpoint to all events rather than keeping the current set, and export always writes it, showing[]for an all-events endpoint.descriptionandstate(enabledordisabled) follow the same one field model: a value you write is used as-is, and leaving the field out converges it to the default. Description defaults to empty, so dropping it clears any description on the server; state defaults toenabled, so dropping it re-enables the endpoint.- Every endpoint on the server must appear in the file, kept or marked
delete: true. An endpoint that is missing fails the plan; nothing is deleted just because it is missing. - The signing secret is server-owned. The server generates it when the endpoint is created and never returns it on read, so it is not part of the file, never shows up in a plan, and can never appear in an export.
- Export leaves out
statewhen it is the defaultenabledanddescriptionwhen it is empty, so reconciling an export plans nothing, and headers and metadata set through other tools are carried through an update untouched.
The BillingProduct kind
BillingProduct manages billing products: the thing a customer buys, its prices, and the
features attached to it. The product name is the identity and never changes.
apiVersion: v1
kind: BillingProduct
spec:
- name: standard_plan_product
title: Standard Plan
behavior: basic
features:
- name: order_archive_images
- name: open_source_data
prices:
- name: monthly
amount: 15000
currency: usd
interval: month
- name: tokens
title: Tokens
behavior: credits
config:
credit_amount: 1
min_quantity: 1
max_quantity: 100000
prices:
- name: default
amount: 100
currency: usd- The product name is the identity and must be at least three characters. A
titleis required, because the billing provider uses it as the product name and does not allow an empty one.title,description,config,prices, andfeaturesare the managed fields. title,description, andconfigstate the whole desired value. The file writes them as given, so leavingdescriptionout or zeroing aconfigfield resets it.behavioris set only when the product is created and cannot change afterward; a file that asks to change it fails the plan. Metadata is out of scope for this kind: it is never set, changed, or exported here.- The valid values for
behavior,interval,usage_type, andbilling_schemeare checked against the API's own rules when the file is validated, up front, so a wrong value fails before anything applies. This kind does not keep its own copy of those lists, so it stays in step with the server as the lists change. - Prices are keyed by their name within the product. A new price name is added, and an active price the file no longer lists is retired (marked inactive, not deleted, because a provider price cannot be removed). Listing a retired name again brings it back, as long as its fields match. Reusing a retired name with different fields fails the plan, because the old price still exists on the server. Amount, currency, interval, and the other pricing fields cannot change once a price exists: to change an amount, add a new price under a new name and drop the old one. Changing a field on an existing price name fails the plan with that advice. A tiered billing scheme is not supported. An empty price list is left alone, so a product's last price cannot be removed through the file; retire it by hand.
- Features are attached by name. A feature that does not exist yet is created.
- Every product on the server must appear in the file. A product that is missing fails the
plan. There is no API to remove a product, so
delete: trueis rejected; archive a product by hand. The one exception is a product this kind cannot represent — one that uses a tiered price, has an empty title, or has a name shorter than three characters. That product is out of scope: it is left untouched, not required in the file, and not exported, and a file that names it fails the plan instead of trying to recreate it. - Export writes each product sorted by name, and each product's active prices sorted by name, so reconciling an export plans nothing. Provider ids, timestamps, and price state are server-owned and not written; metadata and out-of-scope products are left out too.
Running it
Log in as a superuser. The bootstrap service user exists for exactly this; its client id and secret make a Basic token:
BASIC=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 | tr -d '\n')Always dry-run first and read the plan:
$ frontier reconcile -f platform-users.yaml --dry-run \
--host <host> -H "Authorization:Basic ${BASIC}"
PlatformUser (planned 2):
- add user alice@example.org as admin
- remove user 5f7b...9c1d (member)Then apply by running the same command without --dry-run. The report shows what was
applied; a run with nothing to do prints PlatformUser: no changes.
The -H flag is an interim way to pass the token: command arguments are visible in
process listings, so automation should mask the token in its logs.
Exporting the current state
frontier export <kind> prints the live state as a desired-state document on stdout:
frontier export platformuser --host <host> -H "Authorization:Basic ${BASIC}" > platform-users.yamlUse it to write the first version of a file from a running server. The output is sorted, so exporting twice gives identical files, and it round-trips: reconciling an export's output always plans no changes. A clean dry run is proof that a file matches its server.
The kind argument is case-insensitive and accepts a plural, so platformuser and
PlatformUsers both work.
More kinds
This page covers PlatformUser, Permission, Role, Preference, Webhook, and
BillingProduct. The design and
the rules every kind follows live in
RFC 0001,
which also lists the kinds proposed next. The flag reference for both commands is in the
CLI reference.