Profiles in depth
Profiles is the everyday page: defining an environment, using it, switching it on. This one is what comes after, when one environment is not enough or the config is written by something other than you: several connections to one plugin, a profile from a script, why every value has a type, and completion for the file in your editor.
Several connections to one plugin
An environment rarely has exactly one of everything: staging holds the main database and the analytics one, two buckets, sometimes two Vault mounts. One profile holds them all, as instances — a label inside the key you already write:
profiles:
staging:
plugins:
pg: # the default instance — a bare key means what it always meant
set: {host: db.staging.internal}
secrets: {password: kv:staging-db-password}
pg/analytics: # a second database, same plugin
set: {host: analytics.staging.internal}
secrets: {password: kv:staging-analytics-password}
s3/assets:
set: {bucket: shop-assets}
s3/logs:
set: {bucket: shop-logs}A call picks one with the same string everywhere — the flag, the MCP argument, the grant:
rta pg query --profile staging "select 1" # the default instance
rta pg query --profile staging/analytics "select 1" # the labeled one
rta profile set staging --plugin pg/analytics --set host=… # stating it from a script
rta profile show staging/analytics # that one connection, inside its environmentThe resolution rules are small and fail closed:
- A bare
--profile stagingruns the default instance — the unlabeled entry, which is written as one. - With no unlabeled entry and exactly one labeled, that one is unambiguous and wins.
- With several labeled entries and no default — the
s3above — a bare name is never resolved by sort order. A read from a terminal with the bare name (rta s3 bucket list --profile staging, or no--profileat all whilestagingis on) runs on every instance, one titled section each, sostaging/assetsandstaging/logsboth answer; a failure on one is on the page and in a non-zero exit, and a value you type that the instances state themselves (--endpoint) still has to name its instance. A write or destructive call is refused with the list:staging/assetsorstaging/logs, your call. An agent never fans out, since a grant names exactly one. Completion offers the targets of the environment that is on, and the other environments asname/, described by each instance's address.
Two things follow from an instance being one connection:
- Grants name exactly one.
rta grant allow pg.query --profile staging/analyticsconsents to the analytics database and nothing else; asking for a bare--profile stagingwhen several pg instances exist is refused with the same list, because "analytics, not the main database" is precisely the decision consent is recording. A policyneverProfile: [prod]covers every instance inside prod.rta use stagingstill switches — and bounds — the whole environment, instances included. - A labeled instance's credentials come from
secrets:references (kv:orkube:), not fromRTA_PROFILE_*variables — that channel belongs to the default instance only, because a variable name forstaging/analyticswould be forgeable by a carefully-named profile.
What a fence holds, exactly
A profile's only: is a list per config key, and what it holds to the list is the value an input resolves to: the caller's, then the profile's, then the plugin's section in your config, then the declared default, with the empty string a caller can send counted as a value like any other. Checking what set: says or what was typed would check the wrong thing.
A fenced input nobody supplied resolves to the one allowed value when the list holds exactly one, so
only: {database: [app]}is the whole of a profile's database. It sits under every layer that states something — a value the plugin's own section sets is held to the list, not replaced by it — and over the plugin's default. With two entries or more the call has to say which: an input left out reads as blank, and a blank is not in a list that does not hold it. An input that holds a list (a list of namespaces, of tables) is the exception: left out, it is every value the list allows, however many, which is no more than naming each of them would reach, so a profile that fences a dozen namespaces is read across all of them by a call that names none. The input a grant names as its scope is the exception, and is never filled this way, as a profile never fills it either: the grant is checked against the record the call names, so a call has to name it. A connection that states it underset:and fences it — kube'snamespace: appswithonly: {namespace: [apps]}— still refuses a call that leaves the namespace out, and says what happened:--namespace is not given: profile "staging" states namespace: apps, but a call has to name it. The fence holds the value the call names; it does not supply one (a call names its scope).Values are compared by the input's declared type.
5432and"5432"are different entries of an integer's list,30sand30000msthe same entry of a duration's, an option in another case is the option, and a boolean is a boolean. A list-shaped input is fenced a name at a time, and a call that passes none is refused, since most handlers read an empty list as every one.A switch that widens a fenced input is refused, where its plugin says it does. A plugin declares which of its switches read past which input (
Widens:all-namespaceswidensnamespace, andrta explainsays so on a switch that declares it), and a call that resolves one to true while the input it widens is fenced is outside the fence, whatever it says about the input. The host cannot read a handler, so a switch its plugin did not declare is not seen: a plugin that has not adopted the field leaves it open, and one that refuses the combination itself does so after the connection is opened.Widensnames one input. Aset:line that turns such a switch on beside the fence it contradicts is a configuration error.A
set:value outside its own list, a list that cannot hold what the input takes, and asecrets:mapping onto a fenced input are configuration errors, reported where every profile problem is reported and refused by every call. The mapping is refused because a credential is fetched after the fence is checked, so its value would be one the fence never saw. A fence on an address akube:orssh:forward fills is refused for the reasonset:is.The refusal is worded for whoever can act on it. At a terminal or in a form it names the input, what the connection reaches and the ways across — another profile that reaches the value, or
rta profile set <profile> --plugin <plugin> --only <key>=…. A call at the command line that named no environment is also offered--no-profile, the one way across that leaves the profile as it is (what a fence does not hold); one that named the environment with--profileasked for it, and a form and a server have no such flag to offer. Over MCP it names the input and nothing else — that it is blank when the agent left it out or sent it empty, that it lies outside what the connection reaches when it named a value the list does not hold: an allowlist is an inventory of your cluster, and an agent that could read it from a refusal could map it one call at a time. The call is recorded ascore.profile.fenced, orcore.profile.fenced.switchfor a widening switch, and it is given back to a grant that counts uses, since it never reached the handler.It is checked after the grant gate, so a call nobody granted is refused exactly as an unknown profile is, and a fence is no way to learn that one exists.
Completion offers what the connection reaches and nothing else. On the command line the profile on the line, or the one switched on, narrows what
--namespace <tab>offers — a declared list, a suggestion, a closed set, the shortlist of what you used before — to the allowlist, and an input with nothing to complete from offers the allowlist itself. A form built for a fenced environment does the same, picker included, and so does what a tab fetches from the service. A tab on a box that asks the service is held to the fence before it asks, as a run is: with another box holding a value the connection does not reach, leaving a fenced one blank, or a switch reading past a fenced one — the box being completed included — nothing is read, the line under the form names that box, and the next tab moves on. The box being completed is held by its text as well: one that is not the start of a name the connection reaches (payrollwhere the fence holdsshop) reads nothing either, because a plugin may list under what is typed and the answer can only be filtered after the read; an empty box, and one the connection does not fence, are not held by their text. When the service gives a fenced box no answer, or only names the connection does not reach, the press offers the allowlist itself, narrowed to what is typed, as the keystrokes do, and says which of the two it was. Shell completion never asks a service, so for an input that does, the allowlist is the whole answer there.
Every surface holds a call to it, because a rule one of them forgets is the one that gets used: the CLI, the TUI's forms, the switched-on environment and a dashboard tile, and the MCP server.
What a fence does not hold
It holds the inputs a call carries. A capability that has no such input — a listing of every namespace, a cluster-wide overview — reads what its connection reads whatever the fence says, so for those the credential behind the connection is the boundary, and a minted ServiceAccount bound to a namespaced Role is the narrow form of it (Kubernetes). Ad hoc connection flags (--kube, --secret, --secrets-from) replace the environment for the one call a person types, its fence with the rest; an agent cannot send them. --no-profile leaves it the same way without typing a connection: the call runs on the flags typed and the base configuration, and on a terminal the line above the result says which environment it left. It is on no MCP tool either. A call that stays inside the fence is only as narrow as the list is: a fence of [staging, prod] is a fence around two namespaces.
Writing one from a script
rta profile set states a profile from flags. Nothing about it needs a terminal, which is the point: before it existed the only alternatives were a TTY form and hand-written YAML, and a team that cannot script its setup ships the YAML — the path where nothing checks the block until something tries to use it.
rta profile set <name> [--note ...] [--ttl 8h|none] [--color "#dd3333"|none]
[--theme slot=#rrggbb ... | --untheme]
[--plugin <name> [--set k=v ...] [--only k=v ...] [--unfence]
[--secret input=kv:entry ...]
[--kube ...] [--ssh ...] [--direct] [--tunnel-tls]]
rta profile rm <name> [--plugin <name>]Each block is replaced by what the flags state, and a block no flag mentions is left alone. So --set states that plugin's whole set: block — omit a key to remove it — while its secrets: stays exactly as it was, and a run with neither touches neither. That is the only reading under which running the command twice and running it once are the same thing, which is what makes it safe in a script that runs on every boot. --only states the only: block the same way — one argument per allowed value, repeating the key for a list: --only namespace=staging --only namespace=staging-jobs — and --unfence takes every fence off that plugin's entry. --theme states the profile's palette the same way — one slot=#rrggbb per colour, quoted since # starts a comment in a shell: --theme label='#5D8A9C' --theme accent='#108CFE' — and is the profile's whole theme: block; --untheme takes the block off, and a run that states neither leaves it alone. It belongs to the profile and not to a plugin's entry, so it needs no --plugin, and a slot or a colour the palette does not have, or a status colour, is refused before anything is written. --theme completes the five slots a profile can state, each with the colour it has when nothing states one. A palette per environment says what the block does.
Anything a restatement leaves out is named. Changing one key of four is also how sslmode: require disappears from the line beside it, so the loss is reported rather than assumed to be intended:
dropped set.database, set.sslmode — the flags state the whole block, and these were not among themA fence that a restatement leaves out is not a default that went, and is not reported as one. Restating --only database=app over an entry that also fenced schema and port takes those two fences off, which is said as --unfence says it, one row for both:
unfenced only.port, only.schema — calls through this connection are no longer held to those listsOne plugin per invocation. A profile spanning three plugins is three lines, and each of them is independently re-runnable — including in parallel: every writer of the config file takes a lock across the whole read-modify-write, so concurrent runs cannot lose each other's profiles.
rta profile rm <name> removes the environment, switches it off if it was on, and revokes every grant naming it. That last part is not tidiness: a grant naming a profile nothing can look up authorizes nothing, so leaving it behind is a row in rta grant list that reads like access and is not. --plugin removes one entry and keeps the environment.
What it refuses
| What you typed | Why it is refused |
|---|---|
--set password=… on a declared credential | set: is a plaintext value in a world-readable file. It names --secret instead |
--secret password=hunter2 | that block takes a reference, never a value — and the refusal does not repeat what you passed |
--set port=six-thousand, --set tls=yes | the declared type cannot hold it (see below) — core.profile.set.type |
--set encoding=b64 on an input with a closed set | no call would accept it — core.profile.set.option. A listed value in another case is written the way the plugin declares it |
--set ping.count=0 | it is outside the widest range of any capability reading the key — core.profile.set.range. Inside it, each capability holds the value to its own range |
--set hsot=… | nothing in that plugin reads the key |
--only password=… on a declared credential | an allowlist for a credential would be the credential, written into the file it is kept out of |
--only port=six-thousand, --only port=70000 | each entry is typed and range-checked like a --set value — core.profile.only.type, core.profile.only.range, core.profile.only.option |
--set namespace=prod beside --only namespace=staging | a default the fence refuses on every call is not a default — the same sentence the loader uses |
--only … together with --unfence | one states the fence and the other removes it |
--theme primry=#112233, --theme primary=red | the palette has ten slots, five of which a profile may state, and each takes #rrggbb; a typo is answered with the nearest slot — core.profile.theme.slot, core.profile.theme.color |
--theme good=#00AA66 (or warn, bad, inverse, ink) | the status colours are only set in your own config file, so a profile cannot state them; the hint names rta config set theme.good — core.profile.theme.status |
--theme status=off | how status is drawn is only set in your own config file, for every environment, so a profile cannot state it; the hint names rta config set theme.status — core.profile.theme.status |
--theme … together with --untheme, or a slot given twice | one states the block and the other removes it, and two colours for a slot have no order to rank them by |
--kube … and --ssh … together | a call opens one forward |
--tunnel-tls with neither --kube nor --ssh in effect (this run or already stored) | it states something about the far side of a forward that does not exist — --direct clears a stored tunnelTLS: true for the same reason |
| a profile named after an installed plugin | a profile name and a namespace share a command line |
| writing where the file is not honoured | with no config directory the config path falls back to ./.rta.yaml — ordinary in a container or in CI — and nothing in a working-directory file is honoured: profiles:, plugins: and dashboard: are all ignored, because that file could have come from a repository you cloned. Set $RTA_CONFIG |
Neither credential refusal echoes the value it was given. If you did pass a real one, it is in your shell history — rta will not put it anywhere else.
--plugin pg is enough; the artifact pin is filled in from what is installed. A digest is not something anyone should type, and typing one wrong is exactly the failure the pin exists to prevent.
Repinning after a plugin rebuild
Trust binds to the artifact's digest, never to a name or a version — see the boundary — so rta plugin upgrade pg changes what "pg" pins to, and every profile still naming the old digest starts refusing with this profile's pin does not match the installed "pg" the next time it is resolved. rta profile set staging --plugin pg fixes one profile at a time, resolving the new digest itself rather than asking for one typed in. rta profile repin makes the same rewrite across every profile at once, touching nothing else stored beside the entry — the set:, secrets:, kube: and ssh: blocks all come along unchanged.
rta plugin upgrade pg
rta profile repin --all --plugin pg # every profile with a pg entry
rta profile repin staging --plugin pg # just one profile
rta profile repin staging --plugin pg/analytics # just one instance of itName a profile, or pass --all — one or the other is required, so a repin's scope is always stated rather than assumed. --dry-run reports what would change without writing it. An entry already pinned to what is installed is reported and left alone.
Tab completion knows the keys. With --plugin on the line, --set <tab> and --only <tab> offer exactly what that plugin reads, with its help text and — for a closed set — its accepted values. --secret <tab> offers the inputs a mapping may target, marking which of them are credentials. A credential never appears under --set, because it cannot be a config key at all:
$ rta profile set staging --plugin pg --set <tab>
database= database to connect to
host= database host
port= database port
sslmode= disable|prefer|require|verify-ca|verify-full
user= role to connect asAdding a forward to an existing connection
A forward fills the endpoint inputs itself, so a stated host beside a coordinate is a line no run reads. A host given on the call — typed into the form, or passed as a flag — is different: it connects directly and no forward is opened, which is the override for a coordinate that is wrong. A TLS switch on the call — --sslmode, --tls — says how to talk, never where, so it opens no such way out: the call still goes through the forward, which turns that switch off, and a value asking for TLS is refused as core.profile.tls.forward before the forward opens, rather than dropped or taken somewhere else. Leave the switch out to go through the forward, or give the host and port as well to reach the server directly with it — the only place TLS you ask for is negotiated end to end. --kube on a connection that already sets one drops the keys it replaces and says so:
removed set.host, set.port — the forward fills those, so nothing read themStating both in the same command is refused instead. Quietly dropping half of what you just typed is a different thing from clearing a line you are not looking at.
Types are part of the declaration
Every value in set: is read back as the type the plugin declared, by a type assertion — so a value of the wrong shape would be read as the zero, and the host refuses it instead, on every call that reads it:
set:
tls: "true" # a string where a boolean is declared. Refused — read, it would be false.
tls: yes # also a string — YAML 1.2. Refused the same way.
port: "5432" # a string. Refused — never run as port 0.
sslmode: true # a boolean where text is declared. Refused — read, it would be empty.Each of these would otherwise leave a connection running somewhere, or without the transport security, its own configuration does not state. rta profile list and rta doctor report all four, and a mistyped profile refuses to resolve rather than connecting somewhere unexpected:
profiles.staging.pg: `set: tls` is written as text where a boolean is declared — every call reading it is refused
(write it unquoted as `true` or `false` — a quoted `"true"` is a string, and so is a bare `yes`)There is deliberately no coercion. Reading "true" as true would then have to answer for yes, on, 1 and TRUE, and every answer is a guess about a value that decides whether a connection is encrypted.
rta profile set cannot produce this: a flag argument is always text, so it converts to the declared type before writing, and refuses what will not convert.
The same rule covers the base plugins: block: rta doctor reports the line, and every call reading it is refused, naming the key it came from under the heading you wrote — at a terminal, `rta mysql status` takes text for --tls, not a boolean, which the config's plugins.mysql@f5074594a1c3.tls sets.
A number of the right shape outside what every capability reading its key takes is different: each capability holds it to its own nearest bound, so the profile still resolves — but not as written. rta profile set refuses to write one; written by hand, rta profile list shows the profile as warn — or as on, with the same note, while it is switched on — and rta profile show and rta doctor name the range to write instead.
Editor completion for the file
The config file has a JSON Schema, and rta keeps it beside the file for you: the first write puts config.schema.json next to config.yaml and a modeline at the top of the file that points at it, and rta config edit refreshes it with the keys of the plugins you have installed. For a config file somewhere else, rta prints the schema:
rta config schema > config.schema.json # next to the config file — `rta config path` prints where that isThen put one modeline at the top of that file:
# yaml-language-server: $schema=config.schema.jsonVS Code's YAML extension (redhat.vscode-yaml) and every other editor speaking yaml-language-server now complete each key, flag unknown ones, and show the explanation on hover — tunnelTLS tells you it is about the destination and not the hop without leaving the file.
The schema states the envelope, deliberately. What a plugins: section or a set: overlay may hold inside is each plugin's own declaration, which the schema cannot know without knowing every plugin — rta explain on one of its capabilities lists those keys (rta explain pg.status; rta explain pg lists the capabilities), and rta doctor stays the deep validator for everything the envelope cannot see.
Related
- Profiles — the everyday page
- Grants — a grant names a profile and, with an instance, one connection inside it
Next
Reaching private services — a connection that goes through kubectl port-forward or ssh, and the TLS rules that come with it.