feat(schema): generate config JSON Schema, folded into docgen - #600
feat(schema): generate config JSON Schema, folded into docgen#600viniciusdc wants to merge 6 commits into
Conversation
ef457d2 to
2210aa6
Compare
2210aa6 to
9707118
Compare
9707118 to
bbc9ca0
Compare
dcmcand
left a comment
There was a problem hiding this comment.
Summary
Reviewed at bbc9ca0. I'd started this review at 9707118 and you pushed while I was verifying, so everything below was re-checked against the current head - including the findings that are now fixed.
The design is right and the tooling work is good: folding schema generation into cmd/docgen rather than shipping a second binary is the correct call, registry-driven discovery genuinely works with no hard-coded provider list, and the two silent-omission guards are well built. The three boundary rules in AGENTS.md "Abstraction Boundaries" all hold - cmd/ imports no concrete provider package, pkg/config gains no provider knowledge, and ConfigTyped follows the optional-capability pattern already used by backupRoleARNResolver and the repository.Source variants. No architectural blockers.
What blocks it is the output rather than the pipeline: the primary artifact this PR exists to publish does not validate the configs it describes.
Blockers
schemas/nebari-config.jsonrejects all 9examples/*.yaml.cluster,dnsandrepositoryreflect as closed empty objects, because invopop does not expand ayaml:",inline"field of map kind andAllowAdditionalProperties: falsethen closes them.cluster+repositorybreak all nine;dnsbreaks the one example with adns:block. Inline onpkg/configschema/configschema.go.requiredinferred from missing,omitemptyrejects configs NIC accepts.repository: {local: {}}- shipped inexamples/local-config.yaml, accepted bynic validateat this commit - failsrepository-local.json.repository-existing.jsonrequirespathwhile its own generated description calls it optional, and likewise rejects apath-less config the binary accepts. Two inline comments.docs/adr/0005-nic-config-cli-surface.md:140-144now asserts something false. Its Update section says, in the present indicative, that "thecmd/schemagen/schemas/pipeline described in the Context did not ship" and that NIC emits finished markdown "rather than NIC emitting JSON Schema fornebari-docsto render into markdown downstream". At this commitschemas/ships and nebari-docs#710 renders it. To be precise about scope: thecmd/schemagenhalf is still true (no such binary exists), and the next bullet's "not a second binary" prescription is satisfied by this PR - it's the outcome claim that is now wrong. That file isn't in the diff so I can't anchor a comment to it; an addendum recording what actually shipped would close it, and it's the natural home for the deferral in Question A below.- Checklist parity in AGENTS.md - the
ConfigTypedstep is documented for cluster providers only, while the enforcing test covers DNS and repository too. Inline.
Blockers 1 and 2 share one fix shape and one missing test: validate examples/*.yaml against the generated schemas in CI. That single test would have caught both, plus the chart_version default you just fixed. pkg/nic/examples_test.go already exists on exactly that argument ("the examples are the documented starting point for users, so a schema change that leaves them behind is a user-visible break"), and no test currently references schemas/.
Fixed since my first pass - all re-verified, thank you: chart_version now correctly declares 3.4.3 in the tag, the markdown page and aws.json; schemas/README.md now documents the repository key and the group-qualified filename rule; the six orphaned // Name returns the provider name comments are separated from ConfigType; both cmd/schemagen references now say cmd/docgen; and the configschema package doc no longer claims YAML is supported. The filepath.Abs(outDir) fix and the unexported-field skip in the parser are both good catches.
Questions
A. Two extraction paths over the same structs. cmd/docgen/schema.go is explicit that the schema emitter shares only the project root with the go/ast emitters. ADR-0005:150-158 decided the letter - "a docgen output format, not a second binary" - and this PR satisfies it. But the rationale was avoiding two extractors that can drift, and this PR had to fix exactly that bug class (markdown silently dropping jsonschema enums). The deferral is disclosed in the description, which I appreciate; my question is whether it gets an ADR addendum or a tracking issue so "two sources of truth" doesn't become permanent by default.
B. Category enumeration is back to a hand-maintained list. cmd/docgen/schema.go's categories table is a literal three-entry list, pkg/nic/config_types.go is three near-identical loops, and schemaFileName re-hardcodes bareNameGroups - three files to touch for a fourth category, where the markdown side solved the same problem with glob discovery precisely because (per main.go:44-48) a literal list is what let the repository category ship undocumented. TestConfigTypesCoversEveryRegistryCategory means it fails loudly rather than silently, so this is a question, not a blocker: is driving the category list off reflection over registry.Registry worth doing now, or is the test considered sufficient?
C. Is whole-file validation a supported use of nebari-config.json? schemas/README.md names only the docs renderer, and ADR-0005 lists editor/LSP validation as a future need - so in fairness, "validate a config with this" isn't a promise the repo makes yet. I'm still calling blocker 1 a blocker because pkg/configschema/configschema.go explicitly justifies closing the schema as mirroring the validator, and because the empty blocks are equally broken for a renderer. If renderer-only is the intent for now, saying so in the README (and dropping the validator-fidelity claim) would at least make the limitation honest.
Kudos
Left inline, but the short list: the AddGoComments diagnosis is the most valuable comment in the PR; TestConfigTypesCoversEveryRegistryCategory guards the next category without naming it; appending constraints after truncation so a constraint can never be the part that gets cut; manifest.json keeping providers as the cluster list with the compatibility reason recorded in code; and three emitters behind one make docs with one drift gate, retiring a 67-line paths filter. The claim that merging the generators surfaced two invisible bugs holds up - and the same argument now applies to the schema/example mismatch, which merging hasn't surfaced only because nothing compares them.
Verification notes
Everything was reproduced in a clean worktree, then independently re-checked by reviewers instructed to refute each claim with "probably right" counting as refuted. Nothing was refuted; three claims were tightened (the 9/9 vs dns scope, blocker 2 being worse than first stated, and the cmd/schemagen half of blocker 3 surviving).
make docsatbbc9ca0leaves the tracked tree untouched: determinism and the drift gate are clean, including after the rebase onto #583.- The one open item in your test plan passes:
golangci-lint runis runnable in a worktree at this commit and reports 0 issues.go build ./...,go vet ./...andgo test -short ./...all pass. - Schema validation used the draft each document declares (2020-12), with
check_schemapassing first - the documents are legal, just unsatisfiable for real configs. nic validatebehaviours cited inline were checked against a binary built at this commit, including confirming that a passing exit code means the provider validator actually ran (a deliberately-invalid path is rejected from the expected line).- Two corrections to the description:
.github/workflows/schemas.ymlnever existed on this base (only on the superseded #507), so no standalone gate is removed by this diff; and the drift gate lives inside thedeployjob, so a flaky deploy means docs and schemas go unchecked - pre-existing from #567, but this PR widens that gate toschemas/. - Not verified: anything requiring cloud credentials, and the nebari-docs#710 consumer side.
One pre-existing issue the new schema usefully caught: examples/custom-tls-config.yaml has no node_groups, which pkg/providers/cluster/aws/provider.go:200 requires. nic validate can't catch it because cluster-provider validation isn't reached on that path. The schema is right and the example is wrong - worth fixing separately.
| Anonymous: true, | ||
| // nebari-config does not accept unknown fields at any level; | ||
| // the validator surfaces them as errors. Reflect that in the schema. | ||
| AllowAdditionalProperties: false, |
There was a problem hiding this comment.
Blocker. AllowAdditionalProperties: false combined with the inline provider maps makes the published top-level schema reject every valid config. Re-verified at bbc9ca0.
cluster, dns and repository each hold their providers in a single Providers map[string]any with yaml:",inline" (pkg/config/config.go:80,140,201). Under FieldNameTag: "yaml", invopop v0.14.0 does not expand an inline field of map kind (reflect.go:1074-1076 returns ("", shouldEmbed=true), then reflectStructFields aborts at 484-490 for a non-struct kind). All three therefore reflect as:
{"properties": {}, "additionalProperties": false, "type": "object"}Validated against the draft the document declares (2020-12; check_schema passes first, so the document is legal - just unsatisfiable): 9 of 9 examples/*.yaml fail. cluster and repository break all nine, dns breaks aws-config-with-dns.yaml, the only example with a dns: key:
['cluster'] Additional properties are not allowed ('aws' was unexpected)
['repository'] Additional properties are not allowed ('existing' was unexpected)
No $ref, patternProperties, allOf or unevaluatedProperties anywhere in schemas/ wires the per-provider schemas in, and no type in the repo implements JSONSchemaExtend/JSONSchema(), so nothing rehabilitates them today.
Two notes on the justification in the comment above:
- It isn't accurate. There is no strict/
KnownFieldsdecoding inpkg/config, and at this commitnic validateaccepts both an unknown top-level key and an unknown key inside a provider block (exit 0 both times). The schema is stricter than the real validator in one direction and looser in the other. - The blocker holds even if the docs renderer is the only intended consumer: a renderer reading these three defs shows three fields with no children and no hint that a provider key belongs there.
Suggested fix: give the three wrapper types a JSONSchemaExtend emitting additionalProperties/patternProperties for the provider blocks - ideally a oneOf over the registered provider names, which the generator already knows - or stop closing structs that carry an inline catch-all. Then add the regression test that would have caught it: validate examples/*.yaml against the generated schemas. pkg/nic/examples_test.go already exists on exactly that argument, and nothing under *_test.go references schemas/ today.
| }, | ||
| "additionalProperties": false, | ||
| "type": "object", | ||
| "required": [ |
There was a problem hiding this comment.
Blocker. required: ["path", "branch"] rejects a config NIC accepts. Re-verified at bbc9ca0.
examples/local-config.yaml ships repository: / local: {}. Built from this commit:
$ ./nic validate -f examples/local-config.yaml
✓ Configuration file is valid
EXIT=0
That exit 0 is a real accept, not a skipped path - repository validation does reach the provider (pkg/nic/validate.go:130-134), which I confirmed by feeding it a relative path and getting path must be an absolute directory (exit 1) from pkg/providers/repository/local/config.go:23. Both fields are genuinely defaulted at runtime: resolveDir (provider.go:62-64) resolves an empty Path to ~/.nic/gitops/<project_name>, and provider.go:149 is cmp.Or(localCfg.Branch, defaultBranch) with defaultBranch = "main" (:26). extractConfig states the contract outright: "An empty or absent provider config is valid and yields the zero Config, which Provision fills with per-project defaults."
Root cause is the ,omitempty-absence heuristic: local/config.go:14,17 carry yaml:"path" / yaml:"branch" with no ,omitempty, so invopop infers required. ADR-0005's own addendum already flags this signal as imperfect and recommends adding omitempty to genuinely-optional tags - that's the fix here.
Worth a sweep of the other root-level required arrays in the same pass. existing.json's required context looks correct (its godoc says it is deliberately mandatory); the repository pair does not.
| }, | ||
| "additionalProperties": false, | ||
| "type": "object", | ||
| "required": [ |
There was a problem hiding this comment.
Blocker (same root cause as repository-local.json). This document contradicts itself, and rejects configs NIC accepts.
path is in required here, while line 36 of this same file describes it as "Path is an optional subdirectory within the repository" - faithfully generated from the godoc at pkg/providers/repository/existing/config.go:21-23, which says "optional". examples/aws-config.yaml also annotates the key inline as "Optional subdirectory within the repository".
Verified against the binary: a config with url, branch and auth.ssh.env but no path validates clean (✓ Configuration file is valid, exit 0) and fails this schema with 'path' is a required property.
Fix is the same one-word change - yaml:"path,omitempty" on existing/config.go:23 - plus the examples-vs-schemas test, which would have caught both instances at once.
|
|
||
| 1. Create `pkg/providers/cluster/<name>/`. | ||
| 2. Implement the `Provider` interface (`Name`, `Validate`, `Deploy`, `Destroy`, `GetKubeconfig`, `Summary`, `InfraSettings`). | ||
| 2. Implement the `Provider` interface (`Name`, `Validate`, `Deploy`, `Destroy`, `GetKubeconfig`, `Summary`, `InfraSettings`). Also implement the optional `cluster.ConfigTyped` capability (a one-line `ConfigType() reflect.Type` in `configtype.go`) so the provider's config schema and reference docs generate themselves; `pkg/nic.TestRegisteredProvidersImplementConfigTyped` fails if you skip it. |
There was a problem hiding this comment.
Good addition, and pointing at the enforcing test is exactly the right thing to put in a checklist. But the parity is still off, and the omission has teeth.
TestRegisteredProvidersImplementConfigTyped loops over all three categories (pkg/nic/config_types_test.go:26,33,39), and both dns.ConfigTyped and repository.ConfigTyped exist - yet the DNS checklist below (4 steps) and the repository checklist (6 steps) mention neither the capability, the test, nor make docs. A contributor following either to the letter hits a failing test it never warned about. Verified by adding a DNS provider and a repository provider that satisfy their interfaces but omit ConfigType():
config_types_test.go:35: dns provider "throwaway-dns" does not implement dns.ConfigTyped
config_types_test.go:41: repository provider "throwaway-repo" does not implement repository.ConfigTyped
--- FAIL: TestRegisteredProvidersImplementConfigTyped
schemas/README.md names the capability in prose, but descriptively rather than as a required step, with no signature, no test name and no cross-reference from either checklist. Please add the step to both, ideally with the make docs reminder that currently lives only in "Adding a New Configuration Field".
| // cluster/local and repository/local both write providers/local.json and one | ||
| // silently overwrites the other. | ||
| func schemaFileName(group, name string) string { | ||
| if bareNameGroups[group] { |
There was a problem hiding this comment.
A cluster provider and a DNS provider sharing a name silently overwrite each other's schema - which is the exact failure mode this function's comment describes preventing for the other categories.
Both cluster and dns are in bareNameGroups (main.go:39), so both return the same bare filename and the categories order decides the winner. Reproduced: registering a DNS provider named aws produced 9 files instead of 10, with providers/aws.json containing "$ref": "#/$defs/cloudflare.Config" and a zone_name property - the DNS schema overwrote the AWS cluster schema, exit 0, no warning, while manifest.json advertised aws under both keys.
cmd/docgen already has the machinery: checkOutputNameCollisions (main.go:467, called from the markdown path) covers markdown filenames only. Extending it over schemaFileName output closes this. TestSchemaFileNameQualifiesNonBareGroups iterates a hardcoded list with no cluster+dns same-name case, so it can't catch it as written.
Not hypothetical for long - an existing DNS provider, or a route53 that exists in both categories, would trip it.
| // into manifest.json. | ||
| // | ||
| // rootDir must be the module root. Package paths are collected relative to it | ||
| // because invopop/jsonschema's AddGoComments uses the path it is handed as both |
There was a problem hiding this comment.
Kudos - this is the most valuable comment in the PR. It names the precise upstream mechanism (gopath.Join(base, dir) being used as both walk root and import-path suffix), which is not discoverable from the API surface, and it explains why the failure mode is so nasty: every schema still generates and still validates, just with no descriptions at all.
Pairing it with TestGenerateSchemasIncludesGodocDescriptions is the right move, because a missing description has no other signal - nothing errors and nothing looks wrong until someone reads the output.
| // outDir is resolved against the *caller's* working directory before the | ||
| // chdir below, so a relative -root and a relative -schema-output cannot | ||
| // compound into rootDir/rootDir/schemas. | ||
| outDir, err := filepath.Abs(outDir) |
There was a problem hiding this comment.
Nice catch in the latest push - resolving outDir before the chdir closes a footgun that would only have shown up with a relative -root, and the comment explains the compounding clearly.
| // file. Adding a provider extends its category's list automatically. | ||
| // | ||
| // "providers" holds the cluster providers rather than every provider; it | ||
| // predates the other categories and keeps its name so the docs site's existing |
There was a problem hiding this comment.
Kudos for recording why providers holds only the cluster providers instead of renaming it for symmetry. Consumer-compatibility decisions are the ones that get "cleaned up" a year later by someone who can't see the constraint; having the reason inline is what prevents that.
| if runes := []rune(desc); len(runes) > 200 { | ||
| desc = string(runes[:197]) + "..." | ||
| } | ||
| // Append the `jsonschema` constraints after truncation, so they are never |
There was a problem hiding this comment.
Kudos for the ordering here. Appending constraints after truncation so a constraint can never be the part that gets cut is a small decision that keeps paying off - the 200-rune limit would otherwise silently eat exactly the most actionable half of a long description. The comment says why, and TestConstraintSuffix exercises the boundary.
Catching the silent enum loss (aws.Taint.Effect, azure.NodeGroup.Mode) as a side effect of merging the two generators is a good argument for the merge.
| // fields keeps the check honest without naming the categories, so it also | ||
| // covers the next one. | ||
| func TestConfigTypesCoversEveryRegistryCategory(t *testing.T) { | ||
| categories := reflect.TypeOf(registry.Registry{}).NumField() |
There was a problem hiding this comment.
This is a genuinely clever guard - comparing NumField catches the next category without naming it, which is the failure mode that let pkg/providers/repository/ ship undocumented in the first place. Keeping the "why" in the comment above it is what makes it maintainable.
One caveat worth a line of defence: it couples to registry.Registry's field count, not to its provider lists. Adding any non-provider field to Registry (a mutex, a logger, a clock) fails this test with a message telling the author to add a category map - the wrong instruction. Filtering to fields whose type is a *ProviderList[...] would make it precise and let it fail for the right reason.
Generate JSON Schema documents for nebari-config.yaml and every registered provider's Config struct straight from the Go types, and commit them under schemas/ for the docs site to consume. Rather than ship a second generator with its own make target and its own CI gate, the pipeline lands as a third emitter inside cmd/docgen, beside the config-markdown and CLI-markdown ones: one `make docs`, one drift gate in ci.yml, and no standalone schemas.yml workflow with its own 67-line paths filter to keep in sync with the set of files the generator actually reads. The provider list comes from the registry, not a hard-coded list. Providers expose their config type through ConfigTyped, an optional capability (`ConfigType() reflect.Type`) discovered by type assertion rather than added to the Provider interface, so out-of-tree providers are unaffected. A test asserts every registered provider implements it, and a second test compares the number of ConfigTypes category maps against registry.Registry's fields, so a future provider category cannot go undocumented the way the repository category did - a missing category emits nothing, and an absent file produces no diff for the drift gate to fail on. pkg/configschema wraps invopop/jsonschema: it reads yaml tags, package-qualifies $defs keys so aws.Config and longhorn.Config cannot merge, emits additionalProperties: false, and pulls field descriptions from godoc. Two bugs the merge surfaced, both invisible while the generators were separate: - The markdown emitter never read the `jsonschema` tag. Moving the allowed values for aws.Taint.Effect and azure.NodeGroup.Mode out of trailing comments and into tags gave the schema an enum and left the corresponding cell in docs/configuration/aws.md blank. It now renders enum/default, and appends them after description truncation so a constraint can never be the part that gets cut. - Every godoc description had vanished from the schemas. AddGoComments uses the path it is handed as both the directory to walk and the import-path suffix it matches comments against, so an absolute path still generates valid schemas that silently carry no descriptions at all. Package paths are now module-relative with the working directory pinned to the module root, and a test asserts a description actually lands. Two collisions closed along the way: cluster/local and repository/local share a provider name, so non-bare categories are qualified on disk (repository-local.json) rather than one silently overwriting the other; and a `jsonschema:"default=..."` tag is a second hand-written declaration of a default that already lives in a Go const, which had already drifted (the tag said 3.2.1 for the load-balancer-controller chart while the const said 3.4.3, and both generators published the tag). The tag is corrected and a test now compares the two. Also bumps govulncheck to v1.6.0 in the CI gate. Deferred: configschema.FormatYAML (the commented-YAML reference) is declared but returns an error; publishing schemas/ per release tag; broader examples/default tags and provider godoc coverage. The two emitters still extract independently - go/ast for markdown, reflection for JSON Schema - and rendering the markdown reference *from* the schema would collapse them to one extraction path, worth doing once both have settled.
The generated schemas were legal JSON Schema documents that rejected every
config NIC accepts, and nothing compared the two: the drift gate passes on a
schema that describes nothing, so the pipeline looked healthy.
Validate examples/ against the generated schemas. Every file under examples/ is
checked against nebari-config.json and each provider block against that
provider's schema. This is the guard the pipeline was missing - run against the
schemas as they were, it fails on all nine examples.
Describe the inline provider maps. cluster, dns and repository each hold their
providers in a single `yaml:",inline"` map. invopop reports such a field as
embedded and then declines to walk it because it is not a struct, so all three
reflected as objects with no properties, which closing them turned into schemas
nothing can satisfy. configschema grows an InlineMaps option - generic, keyed by
$defs name - and docgen supplies the provider names from the registry. The
result states what the validator enforces: the block names one registered
provider, and no more than one.
Stop inferring `required` for runtime-defaulted fields. repository/local's path
and branch, and repository/existing's path and branch, are all defaulted during
Provision (resolveDir, and cmp.Or with defaultBranch), but their yaml tags
carried no `,omitempty`, so the schema demanded them - rejecting
`repository: {local: {}}`, which ships in examples/local-config.yaml. url and
auth stay required; those the validator does enforce.
Fail on a schema filename collision. cluster and dns are both bare-named
groups, so a DNS provider sharing a cluster provider's name silently overwrote
its schema. The markdown emitter already guards its own filenames; this is the
same guard over the schema filenames, and the test now includes the cluster+dns
case it was missing.
Fail on an unmatched -schema-providers entry, which previously wrote nothing,
exited 0, and printed the full provider list as though it had regenerated it. A
name may now be qualified with its category, since a bare "local" cannot
distinguish cluster/local from repository/local.
Pass the pkg root to AddGoComments once. It walks recursively, so the
per-directory testdata/vendor skips could never fire - every ancestor was
already in the list - and a syntax error under any testdata/ failed the run
while looking like the parent package's fault.
Correct the rationale for closing objects. The comment claimed the validator
rejects unknown fields; it does not - an unknown top-level key and an unknown
key inside a provider block both validate clean. The strictness is kept, since a
typo'd key that silently does nothing at runtime is exactly what a schema is
useful for catching, but it is now described as deliberately stricter than the
decoder rather than as mirroring it.
Also: drop FormatYAML and the uncalled Options.Description, leaving no exported
member that always errors; reject absolute PackagePaths, which walk the right
files, match no import path, and silently produce descriptions-free schemas;
give pkg/configschema a test file; count only ProviderList fields in the
category guard, so a future mutex on Registry cannot fail it with an
instruction to add a category map; document ConfigTyped and `make docs` in the
DNS and repository provider checklists, which the enforcing test already
covered; and record in ADR-0005 what actually shipped, since its update section
asserted that this pipeline did not.
examples/custom-tls-config.yaml gains the node_groups the AWS provider
requires. The schema was right and the example was wrong; nic validate cannot
catch it because cluster-provider validation is not reached on that path.
Four comments claimed a category missing from categoryTable or from RegisteredConfigTypes "produces no diff for the drift gate to fail on". Half of that was a two-line CI bug rather than a property of the gate: with git add -N the gate was blind to a generated file that disappeared, and it now uses git add -A plus a staged diff, which catches deletions. What the gate genuinely cannot catch is the case these counting tests exist for: a category that was never walked has no committed page or schema to go missing, so there is nothing to diff against at all. Say that instead. The tests are unchanged.
-schema-providers aws writes one file and printed "cluster: [aws azure existing gcp hetzner local]", claiming work it had not done. Track what the filter let through and summarise that instead. Build the summary from the category table rather than a hard-coded cluster/dns/repository triple, so a fourth category cannot be silently left out of it.
The addendum records what this change shipped: cmd/schemagen did not and will not exist, schemas/ does ship as a third docgen emitter, and schema provider discovery is registry-driven rather than glob-driven. That is the correction this change owes the ADR, because it is what falsifies the earlier "did not ship" claim. Drop the two forward-looking paragraphs - the deferred single-extraction- path direction and the consequence for the required-from-omitempty open question. Both are being answered in a different voice by the change that flips this ADR to Accepted, and merging them textually produces an ADR that argues with itself.
v1.4.0 cannot. It vendors x/tools v0.46.0, whose ssautil.AllFunctions panics with "ForEachElement called on type containing *types.TypeParam" on the generics reached through pkg/configschema's jsonschema dependency. The scan aborts with exit 2 before emitting any JSON, so the gate script never runs at all. Reproducible with v1.4.0 against ./pkg/configschema/... alone; ./pkg/nic/... is clean, and main is clean because it has no such package. v1.6.0 scans the same tree cleanly - six advisories grouped, verdict OK, exit 0 - and the gate script's jq contract is unchanged. Kept as its own commit so a later CI bisect lands on the tool bump rather than on the schema emitter.
aba0be5 to
331e2da
Compare
What
Generates JSON Schema documents for
nebari-config.yamland every registered provider'sConfigstruct, straight from the Go types, and commits them underschemas/for the docs site to consume.Rather than ship a second generator with its own make target and its own CI gate, this folds the pipeline into
cmd/docgen- the tool #567 established for everything NIC derives from Go source. One tool, onemake docs, one drift gate.Lineage: #362 built this against the old
pkg/provider/(singular) layout and was superseded by #507, which re-homed it onto the currentpkg/providers/{cluster,dns}/split. This PR folds #507's standalone generator into docgen.Changes
The schema pipeline
ConfigTyped, an optional capability (ConfigType() reflect.Type) that a cluster, DNS, or repository provider may implement, discovered by type assertion rather than added to theProviderinterface - so out-of-tree providers (ADR-0004) are unaffected. One line per registered provider.pkg/configschemawrapsinvopop/jsonschema: readsyamltags, package-qualifies$defskeys (soaws.Configandlonghorn.Configdon't merge), closes objects, describes inline provider maps (InlineMaps), and pulls field descriptions from godoc. AbsolutePackagePathsare refused rather than silently yielding description-free schemas.pkg/nic/config_types.goexposesRegisteredConfigTypesthrough the registry, so the generator enumerates providers with no hard-coded list.TestRegisteredProvidersImplementConfigTypedfails if a registered provider skips the capability, andTestConfigTypesCoversEveryRegistryCategorycompares the number of category maps againstregistry.Registry'sProviderListfields - so a future category can't go undocumented the way the repository category did. A category missing fromcategoryTable(or fromRegisteredConfigTypes) emits no schema, and CI cannot see the gap. The drift gate does catch a generated file that disappears - it regenerates and diffs against what is committed - but only for a category that was emitted at least once, leaving a tracked file to go missing. A category that was never walked has no committed schema to diff against at all, which is why counting is the check.schemas/committed in-tree:manifest.json({providers, dns, repository, top_level}),nebari-config.json,providers/<name>.json. Cluster and DNS providers keep bare filenames so the docs site's URLs stay valid; other categories are qualified on disk (repository-local.json), becausecluster/localandrepository/localshare a name and one would otherwise silently overwrite the other.Folded into docgen
cmd/schemagen/main.go->cmd/docgen/schema.go, a third emitter beside the config-markdown and CLI-markdown ones. Flags namespaced:-schema-output,-schema-providers,-schema-version.make schemasfolds intomake docs, which cleans and regeneratesdocs/configuration/,docs/reference/cli/andschemas/in one pass..github/workflows/schemas.ymlwith its own drift check and a 67-line paths filter - a second list to keep in sync with the files the generator reads. That never lands: the drift check is theci.ymlstep docs: config + CLI reference generation (docgen) #567 added, widened to coverschemas/. (Correction from review: since that workflow only ever existed on the superseded feat(schema): generate config JSON Schema from the provider registry #507, this diff deletes nothing - it just doesn't add it.) After the rebase that check lives in Move verify docs steps in CI to its own job #655's standaloneverify-docsjob, so it runs in seconds with no cluster and no dependency ondeploy-and-journeys; this PR widens it to coverschemas/, and fixes it to usegit add -A+git diff --cachedso a generated file that disappears is caught (git add -Nstages the deletion, and the following unstaged diff then has nothing to report).cmd/docgen/schema_test.goadded, matching the per-file coverage the rest of the tool has.Bugs the merge surfaced
All of these were invisible while the generators were separate, which is most of the argument for merging them.
The markdown reference was silently losing enum values. Moving the allowed values for
aws.Taint.Effectandazure.NodeGroup.Modeout of trailing comments and intojsonschematags gave the schema anenumand left the corresponding cell indocs/configuration/aws.mdblank - the markdown emitter never read that tag. It does now, renderingenum/defaultand appending them after description truncation so a constraint can never be the part that gets cut. Prose that restated a tagged default is trimmed, leaving the tag as the single declaration.Every godoc description had vanished from the schemas.
AddGoCommentsuses the path it is handed as both the directory to walk and the import-path suffix it matches comments against (gopath.Join(base, dir)). Hand it an absolute path and every schema still generates, still validates, and carries no descriptions at all. Package paths are now module-relative with the working directory pinned to the module root for that step, andTestGenerateSchemasIncludesGodocDescriptionsasserts a description actually lands.The published schemas rejected every config they describe.
cluster,dnsandrepositoryeach hold their providers in a singleyaml:",inline"map. invopop reports such a field as embedded and then declines to walk it because it is not a struct, so all three reflected as objects with no properties - andadditionalProperties: falseturned that into a schema nothing can satisfy: 9 of 9 examples failed.pkg/configschemagrows anInlineMapsoption and docgen supplies the provider names from the registry, so the three wrappers now state what the validator actually enforces (one registered provider, no more than one). Found in review.Two repository fields were marked
requiredthough NIC defaults them at runtime.requiredis inferred from ayamltag without,omitempty, andrepository/local'spath/branchplusrepository/existing'spath/branchare all filled in duringProvision- so the schema rejectedrepository: {local: {}}, which ships inexamples/local-config.yaml. Found in review.A
default=tag had already drifted from the const it duplicates.AWSLoadBalancerControllerConfig.ChartVersionwas taggeddefault=3.2.1whiledefaultLBCChartVersionis3.4.3- and both generators publish the tag, so the wrong default would have shipped to every reader of the reference and the schema. Corrected, andTestChartVersionSchemaDefaultMatchesConstnow compares the tag against the const, since nothing else in the pipeline relates them.Docs updated alongside
AGENTS.mdgains theConfigTyped+make docsstep in all three provider checklists (the enforcing test always covered all three) and a warning that ayamltag without,omitemptypublishes a schema that rejects valid configs. ADR-0005's update section asserted that this pipeline "did not ship", which is no longer true; an addendum records what shipped, keeps the still-accurate half (cmd/schemagendoes not exist and will not), and carries the one-extraction-path deferral.Docs consumption
The docs site renders these schemas live - see the config-schema reference PoC and its Netlify preview (nebari-docs PR: https://redirect.github.com/nebari-dev/nebari-docs/pull/710). It currently tracks this branch via
raw.githubusercontent.com; once this lands on a tagged release the docs pin to the tag.Also in here
govulncheck v1.4.0 -> v1.6.0 (
ci.yml+scripts/govulncheck-gate.sh). Not a drive-by: v1.4.0 cannot scan this branch. It vendorsx/toolsv0.46.0, whosessautil.AllFunctionspanics withForEachElement called on type containing *types.TypeParamon the generics reached throughpkg/configschema'sjsonschemadependency. The scan aborts with exit 2 before emitting any JSON, soscripts/govulncheck-gate.shnever runs at all.#659 cleared the gate on
mainwith v1.4.0, which is why this looked redundant - butmainhas nopkg/configschema. Theci.ymlstep carries this reasoning as a comment, and the bump is its own commit.The guard that was missing
All three of the schema bugs above share one root cause: nothing compared the generated schemas to a real config. They are legal JSON Schema documents whether or not they describe what NIC accepts, so the drift gate passes on a schema that rejects everything.
TestExampleConfigsMatchGeneratedSchemascloses that: every file underexamples/is validated againstnebari-config.json, and each provider block against that provider's schema. Run against the schemas as they were, it fails on all nine examples.Landing it required completing
examples/custom-tls-config.yaml, which had nonode_groupsthough the AWS provider requires them - a pre-existing gapnic validatecannot catch, because cluster-provider validation is not reached on that path.Test plan
go build ./...,go vet ./...,gofmtcleango test -short ./...passes (23 packages)make docsis deterministic - regenerating on a clean tree is byte-identical, and the drift gate passes locally after the rebase ontomainmain's Longhorninstance_manager_cpu_percentwith no code change - registry-driven discovery workinggo run ./cmd/docgen -schema-providers awsnarrows correctly and leaves the manifest and top-level schema untouchedmainDeferred
Broader
examples/defaultstruct tags and provider godoc coverage - most provider fields still have no description; the schema surfaces them the moment the godoc lands. Publishingschemas/per release tag plus aschema-versions.jsonso the docs version toggle can list real versions.The two emitters still extract independently -
go/astfor markdown, reflection for JSON Schema. Rendering the markdown config reference from the schema would collapse them to one extraction path and retire docgen'sconfigFilesallowlist, which is what produced both blockers on #567. Worth doing now that both have landed.