Skip to content

feat: keep the tenant's configuration in Git with export --tenant - #78

Merged
achoimet merged 9 commits into
mainfrom
feat/export-tenant
Sep 29, 2026
Merged

achoimet merged 9 commits into
mainfrom
feat/export-tenant

Conversation

@achoimet

Copy link
Copy Markdown
Member

export/diff/apply kept one team in Git (experiments, schedules, services, profiles). Admins want the tenant's configuration there too. This is the use case on the Kanbanize card: automating templates and experiments.

steadybit export --tenant -d ./platform   # templates, environments, teams, property definitions, hubs, integrations, custom service profiles
steadybit diff -d ./platform              # exit 2 on drift
steadybit apply -d ./platform --dry-run

Layout and order

  • One directory per kind: property-definitions/, environments/, teams/, hubs/, templates/, integrations/{webhook,slack,preflight,preflight-action}/, service-profiles/. --team and --tenant are mutually exclusive.
  • Each file is what the kind's get writes; each package's read-only list is reused.
  • diff -d / apply -d handle whichever directories exist, in one dependency order:
    1. property definitions
    2. environments
    3. teams (they name environments)
    4. hubs
    5. templates (service profiles name them)
    6. integrations
    7. service profiles
    8. services
    9. experiments
    10. schedules
  • Each new kind also gets its own diff and apply --dry-run.
  • Matching, checked on dev:
    • environments by id, else by name (the platform upserts by name);
    • teams by key;
    • property definitions by key;
    • templates, hubs and integrations by id.
  • Left out:
    • the hubs the platform connects itself;
    • templates imported from a hub (template import brings them back);
    • Steadybit's provided service profiles;
    • property associations, which belong to a team's experiments and services.

Secrets. Verified on dev and in the platform source:

  • On read, the platform masks secret with one * per character. Sending that mask back is refused with 422. Leaving the secret out deletes it.
  • Header values and Slack webhook URLs are returned in the clear.

So:

  • Exported files hold '********' for every credential: the secret, every header value, and the Slack URL.
  • In diff, a mask matches whatever the platform holds, so a fresh export shows no drift. A credential is never printed: a real one from the file shows as '******** (from the file)'.
  • apply -d skips integrations that match the platform. For a changed one, masked headers and the Slack URL are restored from what the platform holds. A masked secret is refused, with a message pointing at get/export.

Defaults (see #70):

  • A field left as an empty list or map now counts as its default too.
  • Teams default to allowedActions: [wait, service-validation] and managedBy: MANUAL, webhooks to targetAttributeIncludes: ['*'].
  • Members' name, pictureUrl and managedBy are read-only.

Testing

  • Unit tests with the fake platform, covering:
    • export contents byte for byte;
    • provided items excluded, imported templates never fetched;
    • an export diffing clean;
    • apply skipping matching integrations and keeping the order;
    • masked Slack URLs and headers filled in;
    • credentials never printed;
    • team and webhook defaults, with a real difference still reported;
    • an environment found by name.
  • go test -race ./... and go vet (also GOOS=windows) pass.
  • Reviewed on dev:
    • export --team W writes byte-identical files with main and with this branch;
    • export --tenant wrote 272 templates, 45 environments, 31 teams, 23 property definitions, 1 hub, 11 integrations and 12 service profiles;
    • no Slack URL and no unmasked header value appears;
    • diff -d on that export: every file matches, exit 0.
  • Apply, on resources the agent created only (cli-e2e-export-*, team CLIX, URLs on example.com, all deleted afterwards):
    • export → diff clean → apply updated everything and skipped the integrations;
    • the webhook secret was still stored afterwards;
    • editing the Slack channel and the webhook events showed only those changes;
    • apply refused the masked webhook secret; with the secret put in it succeeded, and the Slack URL was kept.

Not verified: applying an export to a different platform, where the ids don't exist. It's unknown whether the platform creates templates, hubs and integrations under the id it is sent. Also untested: OIDC-managed teams with members.

Open questions

  1. Mask every header value (safe, but hides harmless ones in Git), or only credential-like names?
  2. An integration file with its real secret filled in always shows as a difference, because the platform can't confirm a secret. Acceptable for drift checks?
  3. Include custom service profiles in the tenant export, as now?
  4. The tenant export is sequential and takes about 3 minutes on dev. Should it fetch in parallel?

The CHANGELOG v6.1.0 section will conflict trivially with the other open feature PRs.

Admins want templates, environments, teams, property definitions, hubs and
integrations under version control the way a team keeps its experiments.
`export --tenant -d dir` writes them one directory per kind, and `diff -d` and
`apply -d [--dry-run]` recognise those directories next to a team's. Apply
follows the references between kinds: property definitions, environments
(teams name them), teams (integrations and experiments name them), hubs,
templates (service profiles name them), integrations, then service profiles,
services, experiments and schedules.

What every platform provides is left out: the two hubs the platform connects
itself (fixed ids), templates imported from a hub (they keep the hub's id, and
`template import` brings them back) and Steadybit's service profiles.

The platform masks webhook secrets on read, refuses the mask on write and
drops the secret when it is left out, so neither keeping nor removing it in
the file round-trips. Exported files hold a mask for every credential,
including header values and Slack webhook URLs, which the platform returns in
the clear. A mask matches whatever the platform holds, so an export shows no
drift; the project apply skips integrations that match, and `integration
apply` sends the stored header values and URLs in place of masks. A secret has
to be put in to change its integration. Diffs never print credentials.

The platform turns a team's empty allowedActions into [wait,
service-validation] and a webhook's empty targetAttributeIncludes into ['*'];
Kind.Defaults now also covers a field the file leaves empty, so a hand-written
file is not drift right after it is applied. Team members' read-only fields are
stripped by path, as `team get` does.

Each new kind also gets its own `diff` and `apply --dry-run`.
The platform lists hidden templates, and those whose actions, target types or
property definitions are not available right now, only when asked, so the
tenant export silently left them out and the Git copy was incomplete. It now
asks for both.

An id that is no UUID was sent as the zero UUID. The export now fails on one,
and a hub, template, environment or integration file holding one is reported
with its name instead of being taken for a new one.
Diffs masked only string values in secret fields, so a header value written as
an unquoted YAML number was printed in the clear. Any non-empty value in a
secret field is now masked, whatever its type.
Teams were looked up by key but compared on their id, so an export diffed or
applied on another platform, where the team has another id, showed an id
difference forever and sent that platform a foreign id.

The platform upserts a team by its key and ignores the id: on dev, a file with
a foreign id and an existing key updated that team, and one with the team's id
and a new key created a second team. The id is therefore neither exported nor
compared, and `team apply` no longer sends it, so it cannot contradict the key
should the platform ever start reading it.
Hubs were saved without being synchronized, so a restored tenant had the hub
but none of its templates by the time the service profiles were applied, and
profiles naming those templates failed. `apply -d` now synchronizes each hub
as `hub apply --synchronize` does, and a hub that cannot be synchronized ends
the apply with the platform's reason, before anything that depends on it.
`integration <kind> apply` refused an untouched exported file for its masked
secret, while `apply -d` skipped the same file as matching the platform. A file
holding a masked secret that otherwise matches what the platform holds now has
nothing to apply and is reported as unchanged; a changed one still needs the
secret put in. The platform's version is read once for both the masked header
values and this check.
# Conflicts:
#	CHANGELOG.md
#	internal/cli/experiment.go
@achoimet
achoimet merged commit 466c38d into main Sep 29, 2026
8 checks passed
@achoimet
achoimet deleted the feat/export-tenant branch September 29, 2026 13:20
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant