Skip to content

derive nix modules from TF provider schemas - #6

Open
KiaraGrouwstra wants to merge 18 commits into
applicative-systems:mainfrom
KiaraGrouwstra:derived-schemas
Open

derive nix modules from TF provider schemas#6
KiaraGrouwstra wants to merge 18 commits into
applicative-systems:mainfrom
KiaraGrouwstra:derived-schemas

Conversation

@KiaraGrouwstra

Copy link
Copy Markdown

Use nix-tf-schema to derive nix modules from TF provider schemas, reducing the maintainable surface in favor of generic code plus generated json. Adds CI checks against drift from the underlying providers.

Closes #5.

Generic Terraform-schema conversion helpers, so each pairing's resource
surface can be derived from its provider's own schema rather than
hand-written.

Pinned source-only: nix-tf-schema's flake builds its `lib` from its own
nixpkgs pin for x86_64 alone, and this flake evaluates for aarch64 too. We
instantiate `conversion.nix` against our own `pkgs`, keeping one nixpkgs in
play. This does make CI depend on git.fediversity.eu being reachable.
Hand-writing each pairing's resource surface makes provider drift
invisible: an attribute added, removed or retyped upstream only surfaces
when `tofu apply` fails, if at all. `mkResourceTypes` derives the surface
from a vendored provider schema instead, so drift is an eval-time error
at `nix flake check`.

A pairing supplies a per-collection overlay carrying only what a schema
cannot state -- the NixOS-facing descriptions, the reference graph, and a
short list of documented corrections. There is deliberately no way to
declare an option with no schema counterpart (`refs` is the sole
exception), which is what makes the drift check total.

`checks` is `deepSeq`'d into `resourceTypes`, so a pairing cannot use the
generated surface without also firing every assertion.

Nothing consumes this yet.

Assisted-by: Claude:claude-opus-5
`nix build .#<svc>-provider-schema` extracts the pinned provider's schema
in a sandbox (`tofu providers schema -json`, offline via
`opentofu.withPlugins`) and normalizes it. `nix run
.#update-provider-schemas` installs both over the vendored copies, so a
nixpkgs bump becomes: refresh, then let `nix flake check` name every
resource and attribute that moved.

The schemas are vendored rather than read at eval time: the flake
evaluates for aarch64-linux as well, and IFD would mean running a
foreign-arch provider binary during evaluation.

Each pairing's `lib.nix` now exports `provider` and `providerSource`, the
single source of truth the schema tooling reads.

Assisted-by: Claude:claude-opus-5
`services/forgejo/provider-schema.json` is the normalized output of
`tofu providers schema -json` for the pinned svalabs/forgejo 1.5.0, and
`schema.nix` parses it. Committing it keeps evaluation IFD-free, which
matters because the flake evaluates for aarch64-linux too.

The new `forgejo-schema-current` check rebuilds the schema and diffs it
against the vendored copy, so a provider whose schema changes without a
version bump still fails CI.

Nothing derives options from it yet.

Assisted-by: Claude:claude-opus-5
`external_tracker`, `internal_tracker` and `external_wiki` are the only
nested blocks the forgejo provider has, and no test or example exercised
them -- so a refactor of the resource surface would move them blind.

The fixture declares the built-in tracker on one repository and the
external tracker plus external wiki on another (a repository routes
issues to one tracker or the other, never both), and asserts each block
arrived intact through the live API.

Assisted-by: Claude:claude-opus-5
The VM test's `services.forgejo.runtime` block moves to `fixtures.nix`,
and the new `forgejo-rendered-fixtures` package renders it through the
real option system and renderer.

That makes the fixture serve twice: the VM test proves it converges
against a live Forgejo, and the rendered `.tf.json` snapshot lets a
refactor of the resource surface be checked with a diff. The test
derivation is byte-identical either side of this commit.

Assisted-by: Claude:claude-opus-5
The line escaped the previous commit because treefmt's local cache had
already recorded the file as formatted; the `formatting` check builds in a
fresh tree and reformats it.

Assisted-by: Claude:claude-opus-5
…hema

The 15 resource specs were hand-transcribed from the provider's
documentation: every attribute name, type, optionality and description
written out by hand, with nothing checking them against the provider. A
provider bump could add, remove or retype an attribute and the only symptom
would be an apply-time error on a live host.

They are now derived from `services/forgejo/provider-schema.json` by
`modules/lib/tf-schema.nix`. What each collection still states by hand is
what a schema cannot carry: the Terraform label prefix, which attribute the
attrset key fills, the Forgejo token scope, the reference graph between
collections, and the NixOS-facing collection description. Everything else --
attribute names, Nix types, required-vs-optional, secrets, block wrapping,
descriptions -- comes from the schema, and any drift between the two is now
an eval-time error.

`nixTfSchema` reaches the module via `_module.args`, injected by the flake:
a NixOS module cannot resolve a flake input by path.

One deliberate correction: `organization_id` is dropped from
`forgejo_team`, `forgejo_organization_action_secret` and
`forgejo_organization_action_variable`. The provider requires exactly one of
`organization` / `organization_id`, and the existing `organization`
reference already accepts both a managed sibling's key and a literal name,
so exposing the numeric twin could only be used to violate that constraint.

Verified:

- `forgejo-rendered-fixtures` builds to the same store path as before the
  refactor -- the generated `.tf.json` is byte-for-byte unchanged.
- `checks.x86_64-linux.forgejo` builds to the same store path as before, so
  the VM test's whole system closure is unchanged.
- Options doc: 169 options before and after, none added or removed, none
  changed between required and optional. 110 descriptions now come from the
  provider instead of hand prose. Five types widen: three `int` -> `number`
  (faithful to the schema's `number`), and `repository_webhooks.config` /
  `teams.units_map` go from a required `attrsOf str` to a nullable one --
  a collection default of `{ }` cannot express "unset", so both stay
  enforced via `requiredAttrs` instead.

Assisted-by: Claude:claude-opus-5
Three gaps in what `nix flake check` proved after the surface became
schema-derived:

- The generator's drift assertions only fired as a side effect of forcing
  `resourceTypes`, so drift surfaced as whichever VM test or example
  happened to evaluate first -- a confusing place to read the error.
- Nothing recorded what the pairing covers. "Is this resource modelled, and
  how much of it?" could only be answered by reading `lib.nix`.
- Nothing rendered the user-facing option surface, so an API change could
  only be reviewed as a diff of generator input, not of generator output.

`<svc>-schema-coverage` builds the coverage table from the generator's new
`coverage` output, forcing every assertion on its own along the way, and is
the source for the pairing README's resource table (and, once a pairing has
one, its `unsupported` list). `<svc>-options-doc` renders the option surface
with `nixosOptionsDoc`; `warningsAreErrors` stays on, so an option without a
description fails the check.

Both are generic over pairings: keycloak already gets an options doc, and
picks up a coverage report as soon as it exports `coverage`.

Assisted-by: Claude:claude-opus-5
The README read as though the option surface were hand-maintained, and gave
no answer to "how do I move to a newer provider?" -- which is now a
mechanical procedure with a check that reports exactly what changed.

Points the resource table at `forgejo-options-doc` and
`forgejo-schema-coverage` as the authoritative lists, explains why
`organization_id` has no option, and adds a "Provider updates" section
covering the bump procedure, what drift the check reports, and
`forceOptional` as the release valve for a newly-required attribute.

Assisted-by: Claude:claude-opus-5
Commits the normalized schema of the pinned keycloak/keycloak provider, so
the pairing can derive its resource surface from the provider's own
declaration instead of a hand transcription.

Vendored rather than extracted at eval time: the flake evaluates for
aarch64-linux as well, and IFD would mean running a foreign-arch provider
binary during evaluation. Provider schemas are platform-independent, so one
committed file per provider version is correct.

Activates `keycloak-schema-current`, which rebuilds the schema from the
pinned provider and fails if the committed copy differs -- catching a
provider that changes its schema without changing its version.

Assisted-by: Claude:claude-opus-5
`keycloak_openid_client_js_policy`, `keycloak_openid_script_protocol_mapper`
and `keycloak_saml_script_protocol_mapper` were removed from the provider
along with Keycloak's `upload-scripts` feature, but the pairing kept
offering options for them. Declaring any of the three produced a
`.tf.json` naming a resource type the provider no longer implements, so the
reconciler failed at apply time on a live host -- the exact class of drift
the vendored schema now makes an eval-time error.

Nothing but the removal is intended here: it keeps the schema-derived
refactor a pure refactor rather than mixing a behaviour fix into it.

Assisted-by: Claude:claude-opus-5
The six `services.keycloak.runtime` blocks the VM tests declare move to
`fixtures.nix`, and `keycloak-rendered-fixtures` renders them through the
real option system and renderer into a `.tf.json` snapshot. That snapshot
is the acceptance evidence for the coming schema-derived resource surface:
build it before and after, and an empty diff proves the wire format is
untouched.

Pure refactor -- all six test derivations resolve to the same store path.

Assisted-by: Claude:claude-opus-5
…chema

`services/keycloak/lib.nix` now builds its 97 resource collections with
`modules/lib/tf-schema.nix` from `services/keycloak/provider-schema.json`
instead of declaring every attribute by hand. What stays hand-written is
what the provider schema cannot express: the NixOS-facing resource
descriptions, the reference graph, and the per-resource corrections
(`omit`, `extraSecrets`, `requiredAttrs`).

The generator refuses to ignore a schema resource silently, so eval is
only green once every resource is either modelled or listed in
`unsupported` with a reason. That set -- the four resources new in
provider 5.8.0 -- therefore lands here rather than in the follow-up, and
its four entries are the complete statement of what this pairing does not
cover.

`omitEverywhere = [ "id" ]` is new in the generator: every sdk/v2
resource declares a synthetic `id`, which is the resource's own identity,
computed on apply, and nothing a configuration declares. It is a
provider-wide dialect artifact, not a per-resource correction, and a
global check reports it as stale once no modelled resource declares it.

Two references the hand-written surface lacked: `roles.client` (a role
scoped to a client rather than to the realm) and `groups.organization`.

Verification:

- Rendered fixtures (`nix build .#keycloak-rendered-fixtures`) are
  byte-identical to the pre-refactor snapshot except `smtp_server.auth`,
  which now renders as `[ { ... } ]`. That is the deliberate correctness
  fix: the schema marks it `nesting_mode: "list", max_items: 1`, so
  Terraform reads it as a one-element list, and the hand-written
  `blockAttrs` list omitted it. Nothing else changed and nothing was
  removed.
- Options doc: 1217 -> 1238 options; 22 added, 1 removed, 917
  descriptions changed, 124 types changed, 81 became required. The single
  removal, `kubernetes_identity_providers.<name>.hide_on_login_page`, is
  drift the schema catches -- the provider marks it computed-only ("This
  is always set to true for Kubernetes identity provider."). Type changes
  are `int` -> `number` widenings plus one fix
  (`ldap_user_federations.<name>.cache.eviction_day` was typed `str`).
- `nix flake check` green, including all five keycloak VM tests.

Assisted-by: Claude:claude-opus-5
Model the four resources provider 5.8.0 added, which the previous commit
had to list as `unsupported` for eval to be green:

- `oidc_openshift_v4_identity_providers` and `spiffe_identity_providers`,
  both per-realm IdPs keyed by alias, joining the six existing IdP
  collections as targets of the mapper `identity_provider` reference.
- `openid_client_regex_policies`, an authorization policy matching a
  token claim against a regular expression; same realm and
  `resource_server` references as the other client policies.
- `workflows`, a realm event trigger plus an ordered `step` list.

`unsupported` is now empty and the coverage report reads 101 of 101.
The option surface grows by 68 options; rendered fixtures are unchanged,
since no fixture uses these resources. They are derived from the schema
and checked at eval, but not exercised against a live Keycloak -- the
existing VM tests cover the resources they always covered.

Assisted-by: Claude:claude-opus-5
The README read as though the option surface were hand-maintained, and
gave no answer to "how do I move to a newer provider?" -- which is now a
mechanical procedure with a check that reports exactly what changed.

Points the resource tables at `keycloak-options-doc` and
`keycloak-schema-coverage` as the authoritative lists, adds the four
collections new in provider 5.8.0 to those tables, and adds a "Provider
updates" section covering the bump procedure, what drift the check
reports, and `forceOptional` as the release valve for a newly-required
attribute.

Assisted-by: Claude:claude-opus-5
…ration

The contract still told a new pairing to enumerate every provider resource
by hand, which is no longer how either implemented pairing works and would
produce a surface with no drift detection.

Rewrites "Modeling the resource surface" around `mkResourceTypes`, with a
table splitting what the schema derives from what the overlay must state,
the correction fields and the rule that every overlay key names a real
schema path. Adds "Vendored provider schema" (what is committed, how it is
normalized, why never IFD), "Two schema dialects" (sdk/v2 `block_types`
needing `[ { ... } ]` wrapping vs plugin-framework `nested_type`, and which
pairing is which), and "Drift is a hard error" (the assertion set,
`unsupported` with mandatory reasons, and the three checks). Records the
derivation as a settled decision, and documents the four force-optional
cases with the reason each exists -- the collection one especially, since
an empty `listOf` would silently strip server-side state.

Also updates the repository layout, the `lib.nix`/`module.nix` signatures,
`fixtures.nix` and the rendered-fixtures snapshot, the README outline, and
the Development section with the schema-update and before/after diff
commands.

Assisted-by: Claude:claude-opus-5
The header and the pairings table still called Keycloak "designed, not
yet built" at provider 5.7.0. It has been implemented for a while: all
101 resources of provider 5.8.0 are modelled and six VM tests prove them
against a live instance.

Also names it as the pairing to read for the sdk/v2 schema dialect and
for a large resource surface, which forgejo (plugin-framework, 15
resources) cannot demonstrate.

Assisted-by: Claude:claude-opus-5
@aforemny
aforemny self-requested a review August 11, 2026 08:21
@aforemny aforemny self-assigned this Aug 11, 2026
@KiaraGrouwstra

Copy link
Copy Markdown
Author

one design decision here was inlining the generated schemas. one alternate route would be to load those dynamically by IFD. that would give less git bloat, tho it would make for more run-time calculations, on top of not working with cross-compilation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

generate by reflection?

2 participants