Skip to content

fix: give SigningPolicy and NodeClassifier accurate Ready status - #603

Open
slauger wants to merge 6 commits into
developfrom
claude/openvox-operator-review-maptcd
Open

slauger wants to merge 6 commits into
developfrom
claude/openvox-operator-review-maptcd

Conversation

@slauger

@slauger slauger commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

SigningPolicy and NodeClassifier reported a Ready status that could be wrong and stay wrong. A classifier whose Config repointed away from it kept reporting Active until the next informer resync; a spec edit whose re-render failed reported the new, unrendered generation as Active while the servers ran the old one. Both resources had their status written by a controller that only ever touched the ones it happened to render, so anything outside that path got no status at all.

The fix is to let them derive their own status from what was actually rendered. Structurally that is the same shape ReportProcessor already had.

Three CRDs play the same role — a policy resource that the Config controller renders into a ConfigMap or Secret — but only one of them owned its status.

CRD status written by (before)
ReportProcessor its own status-only controller, derived from the rendered Secret
SigningPolicy the Config controller (config_autosign.go)
NodeClassifier the Config controller (config_enc.go)

This moves SigningPolicy and NodeClassifier onto the ReportProcessor pattern, so no controller writes into another resource's status any more.

  • New SigningPolicyReconciler and NodeClassifierReconciler: status-only observers that derive Ready from whether the resource actually reached the servers.
  • The Config controller keeps ownership of the rendered Secrets and now reports rendering failures as events on the Config (AutosignPolicyRenderFailed, ENCRenderFailed), matching what it already did for the report webhook.
  • updateSigningPolicyStatus and updateNodeClassifierStatus are gone.

How a resource is matched against the rendered file

Each rendered Secret carries an openvox.voxpupuli.org/rendered-from annotation listing the resources its content was built from and the metadata.generation each had at the time. The observers match on that rather than re-parsing the rendered file, which is what lets Ready separate my current spec is in effect from an earlier version of it is:

  • A failed re-render leaves the previous Secret in place. Matching on a name — or, for enc.yaml, on the endpoint URL — still finds the resource there, so the new, unrendered generation would report as Active. That case is now RenderedConfigStale.
  • enc.yaml carries no resource name at all, so any spec change that keeps spec.url (an auth rotation above all) would otherwise be invisible to the observer.
  • The rendered schema no longer has to be declared twice, once with yaml.v3 tags on the render side and once with json tags on the parse side, where a key rename would compile cleanly and flip every resource to NotRendered in production.

The annotation also drives the Secret watches, so an unrelated Secret that merely ends in -enc no longer fans out over every NodeClassifier in the namespace.

Cases that were silent before

The Config controller only ever wrote status on the resources it happened to render, so anything outside that path got no status at all. Deriving it instead closes those gaps:

Reason Case
NoConfig a SigningPolicy whose CertificateAuthority no Config references
NotReferenced a NodeClassifier no Config references
OverriddenByAutosignCommand / OverriddenByExternalNodesCommand the built-in binary is replaced, so the resource is bypassed
RenderedConfigStale a Secret was rendered from this resource, but from an earlier generation
RenderSourceUnknown the Secret predates this mechanism and records no source, so nothing can be concluded from it yet

The override is checked before the Secret is read: a Secret rendered before the override was set still exists, and reporting that as Active would claim an effect the resource no longer has. The override only counts when every referencing Config sets it — one Config opting out does not disable the policy for the one that did not.

Where several Configs render the same NodeClassifier, any one of them holding a Secret that does not match the current generation holds the whole resource out of Ready — whether that Secret is an earlier generation, one rendered from a different classifier, or one recording no source at all. All three are the same statement: a server is running something that is not this generation, so a Config that renders cleanly must not mask a Config that is stuck. A Config that has rendered no Secret at all is the exception, since nothing is mounted there to contradict anything, and the verdict never depends on listing order.

No API or RBAC change — config/rbac/role.yaml and the chart's ClusterRole already grant both status subresources. The kubebuilder markers are narrowed to what the new controllers actually do (read + /status), and the Config controller's now-unused signingpolicies/status and nodeclassifiers/status markers are removed.

Upgrade behaviour

A Secret rendered by an earlier operator version carries no annotation at all, which is not the same as being rendered from nothing. Reading it as "this resource is not in it" would flip every SigningPolicy and NodeClassifier to NotRendered on upgrade — and for a paused Config, whose reconcile returns before the render step, it would stay there. Those resources report RenderSourceUnknown instead, which resolves as soon as the Config controller re-renders.

One limit worth naming: the annotation records metadata.generation, so a re-render that fails without a spec edit — a credential rotated underneath an unchanged spec — leaves the generation matching and the resource reporting Active while the old rendered file stays in effect. That is long-standing behaviour rather than new here (the previous code also returned before writing status on a render error), and the reference pages now say so and point at the Config's render-failure events.

Watches and lookups

  • nodeClassifiersForConfig reads nodeClassifierRef off the event object instead of re-fetching the Config. controller-runtime runs a map function against both the old and the new object of an update, so repointing or clearing the field now enqueues the classifier that lost its Config — which previously stayed Active until the informer resync.
  • ObservedGeneration is captured before the observation: updateStatusWithRetry re-reads the object, so a spec edit landing in between stamped the new generation onto a verdict derived from the old spec.
  • Reference lookups use the registered field indexes, with a new spec.nodeClassifierRef index for Config. findSigningPolicies, the duplicated Config filters and the twin override helpers collapse into shared functions.

Also in this branch

A separate commit renames AGENT.md to AGENTS.md — the name coding agents look for by convention; nothing in the repository referenced the old path — and spells out that commits, PR descriptions and comments carry no AI attribution trailers, footers or session links.

Test plan

  • go build ./..., go vet ./..., gofmt clean
  • go test ./... — pass, including ./api/v1alpha1/
  • golangci-lint run ./... (v2.13.2, built with the repo's Go 1.27 toolchain) — 0 issues
  • make manifests generate — no CRD or deepcopy drift
  • New: TestSigningPolicyReconcile_Status (9 subtests), TestNodeClassifierReconcile_Status (8 subtests), TestNodeClassifiersForConfig, TestRenderedFromAnnotationRoundTrip, TestRenderedSourceRequests — covering active, each error reason, the stale-render cases, the override and partial-override cases, the multi-Config ordering rule, and the watch mapping for a repointed or cleared nodeClassifierRef
  • Mutation-checked: dropping the annotation write, passing nil from either render path, recording generation zero, writing the annotation only on create, reading ObservedGeneration from the re-read object, forcing Ready=True in error cases, removing a Secret-watch suffix filter, and hardcoding the request namespace each turn at least one test red
  • Removed the four TestUpdate*Status_* tests, which asserted the cross-controller writes this PR deletes, and the two rendered-file parser tests, whose parsers are gone
  • Docs updated: reason tables in docs/reference/signingpolicy.md and nodeclassifier.md, plus the shared mechanism in docs/reference/index.md

@slauger
slauger force-pushed the claude/openvox-operator-review-maptcd branch from 29b4b88 to 32025b2 Compare September 8, 2026 14:51
@slauger slauger changed the title refactor: give SigningPolicy and NodeClassifier their own status controllers refactor: give SigningPolicy and NodeClassifier own status controllers Sep 9, 2026
@slauger
slauger force-pushed the claude/openvox-operator-review-maptcd branch from efb5285 to 6fecc1b Compare September 9, 2026 09:52
Three CRDs play the same role -- a policy resource the Config controller
renders into a ConfigMap or Secret -- but only ReportProcessor owned its
status. SigningPolicy and NodeClassifier had theirs written by the Config
controller, from config_autosign.go and config_enc.go respectively.

Both now follow the ReportProcessor pattern: a status-only observer derives
Ready from whether the resource actually reached the servers, so no controller
writes into another resource's status any more. The Config controller keeps
ownership of the rendered Secrets and reports rendering failures as events on
the Config (AutosignPolicyRenderFailed, ENCRenderFailed), matching what it
already did for the report webhook. updateSigningPolicyStatus and
updateNodeClassifierStatus are gone.

Rendered Secrets carry an openvox.voxpupuli.org/rendered-from annotation
listing the resources their content was built from and the generation each had
at the time. The observers match on that rather than re-parsing the rendered
file, which matters in three ways:

- A failed re-render leaves the previous Secret in place. Matching on a name
  -- or, for enc.yaml, on the endpoint URL -- still finds the resource there,
  so the new, unrendered generation would report as Active. That case is
  RenderedConfigStale.
- enc.yaml carries no resource name at all, so any spec change that keeps
  spec.url, an auth rotation above all, would be invisible to the observer.
- The rendered schema is not declared twice, once with yaml.v3 tags on the
  render side and once with json tags on the parse side, where a key rename
  would compile cleanly and flip every resource to NotRendered in production.

Cases that had no status at all before, because the Config controller only
wrote status on the resources it happened to render: NoConfig for a
SigningPolicy whose CertificateAuthority no Config references, NotReferenced
for an unreferenced NodeClassifier, and the two override reasons for a
replaced built-in binary. The override is checked before the Secret is read,
since a Secret rendered before the override was set still exists, and it only
counts when every referencing Config sets it.

Watches and lookups:

- nodeClassifiersForConfig reads nodeClassifierRef off the event object rather
  than re-fetching the Config. Both the old and the new object of an update
  run through the map function, so repointing or clearing the field enqueues
  the classifier that lost its Config, not only the one that gained it.
- The Secret watches key off the annotation, so an unrelated Secret that
  merely ends in -enc does not fan out over every NodeClassifier in the
  namespace.
- A Config whose Secret cannot be read does not mask another Config that did
  render the resource, so the verdict does not depend on listing order. Where
  several Configs render the same classifier, one still on an earlier
  generation holds it at RenderedConfigStale.
- ObservedGeneration is captured before the observation. updateStatusWithRetry
  re-reads the object, so a spec edit landing in between would stamp the new
  generation onto a verdict derived from the old spec.
- Reference lookups use the registered field indexes, with a new
  spec.nodeClassifierRef index for Config. findSigningPolicies, the duplicated
  Config filters and the twin override helpers collapse into shared functions.

No API or RBAC change: config/rbac/role.yaml and the chart's ClusterRole
already grant both status subresources. The kubebuilder markers narrow to what
the new controllers do, and the Config controller loses its now-unused
signingpolicies/status and nodeclassifiers/status markers.
AGENTS.md is the name coding agents look for by convention; the singular file
was picked up by nothing. Nothing in the repository referenced the old path,
so the rename is self-contained.

While renaming, spell out that commits, pull request descriptions and comments
carry no Co-Authored-By line for an AI assistant, no "Generated with Claude
Code" footer and no session links.
The status tests built their Secret fixtures by calling renderedFromAnnotation
themselves, so reader and writer agreed by construction rather than by
observation. A mutation run showed how little that proved: reconcileSecret
could stop writing the annotation entirely -- or write it only on create, or
record generation zero -- and the whole suite still passed.

New tests observe what the Config controller actually produces: the ENC and
autosign Secrets carry the expected name=generation pairs, only the policies of
the CA being rendered, refreshed on every re-render rather than frozen at
create; and both halves now run against each other, Config controller rendering
first and the status controller reading what it wrote. Each of those mutants is
killed by at least one of them.

Two defects the review turned up, both now covered:

- A Secret rendered before this mechanism existed carries no annotation at all,
  which is not the same as being rendered from nothing. Reading it as "this
  resource is not in it" flipped every SigningPolicy and NodeClassifier to
  NotRendered on upgrade, and for a paused Config -- whose reconcile returns
  before the render step -- it stayed there. Those now report
  RenderSourceUnknown until the Config controller re-renders.
- A NodeClassifier whose Config rendered a *different* classifier reported "no
  Secret rendered from X exists yet", which is false; a Secret exists, it just
  belongs to someone else. The SigningPolicy observer already distinguished the
  two cases.

Also tightened: ObservedGeneration is now proven to come from the generation
captured before the observation, by serving a bumped generation from the read
updateStatusWithRetry performs -- the previous assertion could not tell the two
apart. Error subtests assert Ready=False and not just the reason, which 15 of
them failed to notice before. The Secret watches' suffix filters, the malformed
annotation values and the request namespace all have tests; the namespace
assertion no longer uses the default namespace, where a hardcoded value would
have passed.

The docs claimed a failed re-render always yields RenderedConfigStale. That
holds only when a spec edit triggered it: a credential rotated underneath an
unchanged spec leaves the generation untouched, so the resource keeps reporting
Active while the old rendered file stays in effect. Both reference pages now
say so and point at the Config's render-failure events, which is where that case
is visible. This is long-standing behaviour, not new here -- the previous code
also returned before writing status on a render error.
@slauger
slauger force-pushed the claude/openvox-operator-review-maptcd branch from 32b6608 to a1d8135 Compare September 9, 2026 20:50
@slauger slauger changed the title refactor: give SigningPolicy and NodeClassifier own status controllers fix: give SigningPolicy and NodeClassifier accurate Ready status Sep 9, 2026
The verdict ranked a current render above a Secret rendered from a different
classifier and above one recording no source at all, while ranking a stale
render below it. Those three are the same statement -- a server is running
something that is not this generation -- so the ordering contradicted the rule
it stated one line above, and one Config that renders cleanly hid the others.

The case is persistent rather than a startup race. Two Configs reference the
same classifier; the second fails its reconcile before the ENC step, so its
Secret keeps whatever it held before. The classifier reported Ready=True and
named only the healthy Secret in its message, leaving nothing to point at the
Config that was actually stuck.

A Secret that exists and does not match the current generation now holds the
resource out of Ready whichever of the three forms it takes, and the message
names it. A Config with no Secret at all still does not hold anything back:
nothing is mounted there, so it contradicts nothing.

Also from the review:

- docs/reference/index.md still carried the claim the two reference pages had
  already been corrected for: that a failed re-render always yields
  RenderedConfigStale. It holds only when a spec edit caused the failure. The
  shared page now says so, and covers RenderSourceUnknown, which it omitted.
- docs/reference/nodeclassifier.md stated the multi-Config rule for stale
  renders only, which read as a guarantee that Ready means every referencing
  Config is current.
- The comment justifying requireErrorCondition's hardcoded condition type
  claimed every readiness condition is named "Ready". ConditionCAReady and
  ConditionConfigReady are not; the two types it is used on are.
…des errors

Three independent reviews of this branch converged on the same two gaps.

ReportProcessor is the third resource of this kind and was left matching by
endpoint name alone, which is exactly what the rest of this branch argues
against: a spec edit whose re-render fails leaves the previous file in place,
the name still matches, and the processor reports Active while the servers post
to the old endpoint. It now carries the rendered-from annotation and reads it
like its two siblings, so all three answer the same question the same way and
the last hand-rolled parser of a rendered file is gone. It also picks up the
generation-captured-before-observation fix the other two got, and a test for it.

A deliberate override is not a fault. Setting autosignCommand or
externalNodesCommand replaces the built-in binary on purpose, and reporting the
bypassed resource as phase Error made a supported configuration -- one the
charts exercise in CI, see charts/openvox-stack/ci/puppet-command-overrides
-values.yaml -- look permanently broken in the Phase column. Both CRDs gain a
Disabled phase for that case. Ready stays False, because the resource genuinely
is not in effect.

Two more from the same reviews:

- The Configs come back from the field index in map iteration order, so joining
  their Secret names into the condition Message made it a non-deterministic
  function of unchanged state. Every reordering was a real status write that
  re-enqueued this controller and fanned a Config re-render out behind it. The
  slices are sorted before they reach a message, which is what the render side
  already does.
- The upgrade path -- adopting a Secret the previous version wrote, without the
  annotation -- was the most upgrade-critical behaviour in the branch and had no
  test. It does now, including the Config controller stamping the source and the
  resource going Active afterwards.

Docs: docs/getting-started/installation.md gains an upgrade section covering the
transient RenderedConfigSourceUnknown window, the paused-Config case, the
renamed reason strings and the new Disabled phase. The reference pages get a
reason table for ReportProcessor, which had none, and the shared mechanism on
the index page is now true of all three resources rather than only two.
The rendered-file parsers were the only direct users of sigs.k8s.io/yaml.
Reading the rendered-from annotation instead removed the last one, so the
dependency moves to indirect and the CI tidy check goes green again.
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.

1 participant