Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENT.md → AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ test: add unit tests for helpers
- `feat:` triggers a minor version bump
- `fix:` triggers a patch version bump
- Append `BREAKING CHANGE:` in the body for major bumps
- No AI attribution trailers. Do not add `Co-Authored-By:` for an AI assistant,
and no `Claude-Session:` or comparable session links.

## Pull Requests

Expand All @@ -46,6 +48,10 @@ test: add unit tests for helpers
- Body should include a `## Summary` with bullet points and a `## Test plan`
- Reference related issues with `Closes #<number>`
- PRs always target `develop`, not `main`
- No AI attribution footers in the description ("Generated with Claude Code" or
similar) and no session links. The same goes for issue and review comments.
If tooling appends such a footer server-side, remove it from the body
afterwards.

## Build & Test

Expand Down
8 changes: 6 additions & 2 deletions api/v1alpha1/nodeclassifier_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,16 @@ type NodeClassifierCache struct {
}

// NodeClassifierPhase represents the current lifecycle phase.
// +kubebuilder:validation:Enum=Active;Error
// +kubebuilder:validation:Enum=Active;Disabled;Error
type NodeClassifierPhase string

const (
NodeClassifierPhaseActive NodeClassifierPhase = "Active"
NodeClassifierPhaseError NodeClassifierPhase = "Error"
// NodeClassifierPhaseDisabled marks a classifier that is deliberately
// bypassed rather than broken, so an intentional configuration does not
// read as a fault in the Phase column.
NodeClassifierPhaseDisabled NodeClassifierPhase = "Disabled"
NodeClassifierPhaseError NodeClassifierPhase = "Error"
)

// NodeClassifierStatus defines the observed state of NodeClassifier.
Expand Down
8 changes: 6 additions & 2 deletions api/v1alpha1/signingpolicy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,16 @@ type SecretKeyRef struct {
}

// SigningPolicyPhase represents the current lifecycle phase of a SigningPolicy.
// +kubebuilder:validation:Enum=Active;Error
// +kubebuilder:validation:Enum=Active;Disabled;Error
type SigningPolicyPhase string

const (
SigningPolicyPhaseActive SigningPolicyPhase = "Active"
SigningPolicyPhaseError SigningPolicyPhase = "Error"
// SigningPolicyPhaseDisabled marks a policy that is deliberately bypassed
// rather than broken, so an intentional configuration does not read as a
// fault in the Phase column.
SigningPolicyPhaseDisabled SigningPolicyPhase = "Disabled"
SigningPolicyPhaseError SigningPolicyPhase = "Error"
)

// SigningPolicyStatus defines the observed state of SigningPolicy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ spec:
the phase does not change what the operator does.
enum:
- Active
- Disabled
- Error
type: string
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ spec:
the phase does not change what the operator does.
enum:
- Active
- Disabled
- Error
type: string
type: object
Expand Down
14 changes: 14 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ func main() {
os.Exit(1)
}

if err = (&controller.SigningPolicyReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "SigningPolicy")
os.Exit(1)
}

if err = (&controller.NodeClassifierReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "NodeClassifier")
os.Exit(1)
}

if enableWebhooks {
if err := webhook.SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to set up webhooks")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ spec:
the phase does not change what the operator does.
enum:
- Active
- Disabled
- Error
type: string
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ spec:
the phase does not change what the operator does.
enum:
- Active
- Disabled
- Error
type: string
type: object
Expand Down
27 changes: 27 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,33 @@ In namespace mode the operator uses Role/RoleBinding instead of ClusterRole/Clus

## Upgrading

### Status of SigningPolicy, NodeClassifier and ReportProcessor

From the version that introduced the `openvox.voxpupuli.org/rendered-from`
annotation, these three resources derive their `Ready` condition from the
Secrets the Config controller renders, and match themselves against that
annotation. Two things follow for an upgrade:

- Secrets rendered by the previous version carry no annotation, so every
SigningPolicy, NodeClassifier and ReportProcessor reports
`RenderedConfigSourceUnknown` with `Ready=False` until the Config controller
re-renders -- normally seconds after the new operator starts. A Config that is
[paused](../guides/pausing-reconciliation.md) never re-renders, so its
resources stay in that state until it is resumed. Where several Configs
reference one NodeClassifier, it can briefly flip to `Ready=False` while the
Configs are re-rendered one at a time.
- The condition `reason` strings changed. `PolicyRendered` and `ConfigRendered`
became `Rendered`, and the single catch-all `Error` reason was split into
specific cases. Automation matching the old strings needs updating; see the
reason tables in the [CRD reference](../reference/index.md).

A resource that is deliberately bypassed by an `autosignCommand` or
`externalNodesCommand` override now reports `phase: Disabled` rather than
`Active`, with `Ready=False` and a reason naming the override. Alerting on
`phase: Error` is unaffected by that case; alerting on `Ready=True` is not.

### CRDs are not upgraded by Helm

Helm installs the CRDs from the chart's `crds/` directory on the first install,
but [does not update them on `helm upgrade`](https://helm.sh/docs/chart_best_practices/custom_resource_definitions/).
An operator upgraded with `helm upgrade` alone keeps running against the CRDs of
Expand Down
33 changes: 33 additions & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,38 @@ of the status has not caught up with the current spec yet.
| `Pool` | `Ready` | At least one ready endpoint is behind the Service |
| `SigningPolicy`, `NodeClassifier`, `ReportProcessor` | `Ready` | The resource was rendered into the configuration the servers mount |

These three are policy resources: the Config controller renders them into the
ConfigMaps and Secrets it owns, and each one derives its own `Ready` from
whether it ended up in that rendered output. So a failure to render is reported
as an event on the Config, while the policy resource reports only whether it is
in effect -- including the cases where nothing references it, or where an
`autosignCommand` / `externalNodesCommand` override bypasses it entirely. The
condition's `reason` names the case; see
[SigningPolicy](signingpolicy.md#phases) and
[NodeClassifier](nodeclassifier.md#phases).

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. That is what a policy resource
matches itself against, so `Ready` distinguishes "my current spec is in effect"
from "an earlier version of it is": a spec edit whose re-render fails leaves the
previous Secret in place, and the resource reports `RenderedConfigStale` rather
than claiming the new spec reached a server. A Secret rendered before this
mechanism existed carries no annotation at all, which is not the same as being
rendered from nothing; those resources report `RenderedConfigSourceUnknown`
until the Config controller re-renders.

Because the annotation records the generation, this only covers failures a spec
edit caused. A render that starts failing under an *unchanged* spec -- a
referenced credential Secret rotated out from under it -- leaves the generation
matching, so the resource keeps reporting `Ready`. The render failure is an
event on the Config, which is where that case is visible.

A resource that is deliberately bypassed reports `phase: Disabled` rather than
`Error` -- an `autosignCommand` or `externalNodesCommand` override replaces the
built-in binary, which is a configuration choice, not a fault. `Ready` is still
`False`, because the resource genuinely is not in effect. Everything else that
is not `Active` is `Error`.

Any resource can additionally carry `Paused` -- see
[Pausing Reconciliation](../guides/pausing-reconciliation.md).
44 changes: 43 additions & 1 deletion docs/reference/nodeclassifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,49 @@ At most one authentication method may be configured.
| Phase | Description |
|---|---|
| `Active` | Classifier configuration is rendered and active |
| `Error` | Configuration error (e.g. referenced Secret not found) |
| `Disabled` | Deliberately bypassed by an [`externalNodesCommand`](config.md) override -- a configuration choice, not a fault |
| `Error` | The classifier is not in effect -- see the `Ready` condition for which case |

The status is derived from the rendered ENC Secret, so it reports whether this
classifier actually reached a server rather than whether the resource itself is
well-formed. The `Ready` condition carries the reason:

| Reason | Meaning |
|---|---|
| `Rendered` | The endpoint is present in the rendered Secret, at the classifier's current generation |
| `NoConfig` | No [Config](config.md) sets `nodeClassifierRef` to this NodeClassifier, so nothing renders it |
| `OverriddenByExternalNodesCommand` | Every Config referencing it sets [`spec.puppet.externalNodesCommand`](config.md), which replaces the built-in binary and bypasses NodeClassifier resources |
| `NotRendered` | No Secret rendered from this NodeClassifier exists, or the one that exists was rendered from a different one |
| `RenderedConfigStale` | A Secret was rendered from this NodeClassifier, but from an earlier generation |
| `RenderedConfigSourceUnknown` | The Secret predates this mechanism and does not record what it was rendered from; it resolves once the Config controller re-renders |

Automation upgrading from an earlier operator version should note that these
reasons replace the previous two: `ConfigRendered` became `Rendered`, and a
single catch-all `Error` reason was split into the specific cases above.

`enc.yaml` carries no resource name, so the Secret's
`openvox.voxpupuli.org/rendered-from` annotation is what ties the rendered file
back to this NodeClassifier and to the generation it was rendered at. That also
catches a Secret left over from a previous `nodeClassifierRef`, which would
otherwise read as active. Where several Configs reference the same classifier,
any one of them holding a Secret that does not match the current generation --
an earlier generation, a different classifier, or one that records no source at
all -- holds the whole resource out of `Ready`: the current spec is not in
effect everywhere yet. A Config that has rendered no Secret at all is the
exception, since nothing is mounted there to contradict it.

Rendering failures -- an unresolvable auth Secret, for example -- are reported
on the Config that owns the Secret, as an `ENCRenderFailed` event, and the
failed render leaves the previous Secret untouched.

What the classifier reports then depends on whether its own spec changed. If a
spec edit triggered the failing render, the generation moved on and the
classifier reports `RenderedConfigStale`. If the spec did not change -- the
referenced auth Secret was rotated or deleted underneath it -- the generation
is unchanged, the previous Secret still matches it, and the classifier keeps
reporting `Active` while the servers classify against the old credential. Watch
the Config's `ENCRenderFailed` events for that case; the classifier's own
status cannot see it.

## How It Works

Expand Down
26 changes: 24 additions & 2 deletions docs/reference/reportprocessor.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,30 @@ Either `value` or `valueFrom` may be set, not both.

| Phase | Description |
|---|---|
| `Active` | Report processor configuration is rendered and active |
| `Error` | Configuration error (e.g. referenced Secret not found) |
| `Active` | The endpoint is rendered and active |
| `Error` | The endpoint is not in effect -- see the `Ready` condition for which case |

The status is derived from the rendered report-webhook Secret, so it reports
whether this processor actually reached a server rather than whether the
resource itself is well-formed. The `Ready` condition carries the reason:

| Reason | Meaning |
|---|---|
| `Rendered` | The endpoint is present in the rendered Secret, at this processor's current generation |
| `ConfigRefMissing` | `spec.configRef` is empty, so the processor is bound to no Config |
| `ConfigNotFound` | `spec.configRef` points at a [Config](config.md) that does not exist |
| `NotRendered` | The Secret does not (yet) contain an endpoint for this processor |
| `RenderedConfigStale` | The Secret contains this processor, but as it was at an earlier generation |
| `RenderedConfigSourceUnknown` | The Secret predates this mechanism and does not record what it was rendered from; it resolves once the Config controller re-renders |

The Secret's `openvox.voxpupuli.org/rendered-from` annotation names the
processors its content was built from and the generation each was rendered at,
which is what separates `Rendered` from `RenderedConfigStale`. Rendering
failures are reported on the Config that owns the Secret, as a
`ReportWebhookRenderFailed` event; as with the other policy resources, a render
that fails under an unchanged spec leaves the generation matching, so the
processor keeps reporting `Active`. See the
[shared mechanism](index.md#status-phases-and-conditions).

## Processor Types

Expand Down
40 changes: 39 additions & 1 deletion docs/reference/signingpolicy.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,45 @@ Either `value` or `valueFrom` must be set.
| Phase | Description |
|---|---|
| `Active` | Policy is rendered and active |
| `Error` | Policy has a configuration error (e.g. referenced Secret not found) |
| `Disabled` | Deliberately bypassed by an [`autosignCommand`](config.md) override -- a configuration choice, not a fault |
| `Error` | Policy is not in effect -- see the `Ready` condition for which case |

The status is derived from the rendered autosign policy Secret, so it reports
whether this policy actually reached the CA rather than whether the resource
itself is well-formed. The `Ready` condition carries the reason:

| Reason | Meaning |
|---|---|
| `Rendered` | The policy is present in the rendered Secret, at its current generation |
| `CertificateAuthorityRefMissing` | `spec.certificateAuthorityRef` is empty, so the policy is bound to no CA |
| `CertificateAuthorityNotFound` | `spec.certificateAuthorityRef` points at a CertificateAuthority that does not exist |
| `NoConfig` | No [Config](config.md) references that CertificateAuthority, so nothing renders the policy |
| `OverriddenByAutosignCommand` | Every Config referencing the CA sets [`spec.puppet.autosignCommand`](config.md), which replaces the built-in binary and bypasses SigningPolicy resources |
| `NotRendered` | The Secret does not (yet) contain this policy |
| `RenderedConfigStale` | The Secret contains this policy, but as it was at an earlier generation |
| `RenderedConfigSourceUnknown` | The Secret predates this mechanism and does not record what it was rendered from; it resolves once the Config controller re-renders |

Automation upgrading from an earlier operator version should note that these
reasons replace the previous two: `PolicyRendered` became `Rendered`, and a
single catch-all `Error` reason was split into the specific cases above.

The Secret's `openvox.voxpupuli.org/rendered-from` annotation names the
policies its content was built from and the generation each was rendered at,
which is what separates `Rendered` from `RenderedConfigStale`.

Rendering failures -- an unresolvable `csrAttributes` Secret, for example --
are reported on the Config that owns the Secret, as an
`AutosignPolicyRenderFailed` event, and the failed render leaves the previous
Secret untouched.

What the policy reports then depends on whether its own spec changed. If a spec
edit triggered the failing render, the generation moved on and the policy
reports `RenderedConfigStale`. If the spec did not change -- the referenced
`csrAttributes` Secret was rotated or deleted underneath it -- the generation is
unchanged, the previous Secret still matches it, and the policy keeps reporting
`Active` while the CA signs under the last policy that rendered cleanly. Watch
the Config's `AutosignPolicyRenderFailed` events for that case; the policy's own
status cannot see it.

## How It Works

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ require (
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
sigs.k8s.io/controller-runtime v0.25.0
sigs.k8s.io/gateway-api v1.6.2
sigs.k8s.io/yaml v1.6.0
)

require (
Expand Down Expand Up @@ -113,6 +112,7 @@ require (
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

tool (
Expand Down
Loading
Loading