Skip to content

Release develop -> main (no release) - #540

Merged
slauger merged 73 commits into
mainfrom
develop
Sep 2, 2026
Merged

slauger merged 73 commits into
mainfrom
develop

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Release develop -> main. 64 commits, 125 files, +7108/-903 since the last merge.

The bulk is a hardening pass over the API and the controllers, plus the e2e suite that had been red since 6 August and is now fully green (19 tests across four groups).

Upgrade notes

Apply the CRDs before upgrading. Helm installs a chart's crds/ directory on first install and never updates it on helm upgrade. Almost everything in this release depends on the new schema -- observedGeneration, all conditions, signedSpecHash, the CEL rules, the list types. Without the CRD update the operator writes status fields the API server strips, and the symptom (a status that never fills in) points away from the cause.

helm pull oci://ghcr.io/slauger/charts/openvox-operator --version "$VERSION" --untar
kubectl apply -f openvox-operator/crds/
helm upgrade openvox-operator oci://ghcr.io/slauger/charts/openvox-operator --version "$VERSION"

Existing Servers stay pinned to their old image. ImageSpec used to default repository and tag, and a nested default is applied even when the parent object is omitted -- so every stored Server carries ghcr.io/slauger/openvox-server-8:latest in its spec, whether or not anyone set it. resolveImage prefers a non-empty Server image, so those Servers keep the baked-in value and still ignore spec.image on the Config. For chart users a helm upgrade clears the field and inheritance takes over; hand-managed resources need repository and tag set to "" explicitly.

Rolling back leaves a finalizer behind. Every CertificateAuthority gains openvox.voxpupuli.org/ca-protection. An older operator does not know it, so CA deletion would hang indefinitely. To recover:

kubectl patch certificateauthority <name> -n <ns> --type=json \
  -p '[{"op":"remove","path":"/metadata/finalizers"}]'

Duplicate list entries become invalid. dnsAltNames, poolRefs, service.externalIPs, the signing-policy allow lists and the authorization-rule methods are now x-kubernetes-list-type: set. An existing resource holding a duplicate stays readable but is rejected on the next write. Worth a quick check before upgrading.

Features

  • observedGeneration on all nine status types, with conditions carrying it too
  • Reconciliation can be paused per resource via the openvox.voxpupuli.org/paused annotation
  • CertificateAuthority deletion is protected by a finalizer while certificates still reference it
  • certname is immutable and CA storage cannot be shrunk
  • List semantics declared for every keyed list field

Fixes

  • Certificates are re-signed when the signing-relevant spec changes, tracked through signedSpecHash
  • Transient API errors are propagated instead of being treated as NotFound, so a hiccup no longer looks like a deleted resource
  • Replica and endpoint lookups report failures instead of zero, which used to flap the status
  • Prometheus series are retired when their resource is deleted; adds openvox_crl_last_refresh_timestamp_seconds
  • A Pool hostname conflict is reported as a condition instead of being retried forever, with a deterministic winner
  • The CA setup job retry loop is bounded and reports why it gave up
  • Defaulted boolean fields are nullable, so false is settable
  • StorageSpec.Size is typed as resource.Quantity
  • Chart derives Service names into the certificates that need them, fixing TLS handshakes through Pool Services
  • Three separate CA teardown deadlocks that made helm uninstall --wait hang

Refactors

  • Child resources are reconciled with CreateOrUpdate behind an ownership guard, replacing hand-rolled Get/Create/Update
  • Field indexers for cross-resource lookups
  • Status writes stay inside the owning controller
  • The shared image default is gone from the API

Chores

15 dependency updates via Renovate.

Testing

make test and golangci-lint are clean. The full e2e suite is green: 12 base tests, 2 ENC tests, the Gateway API test and the 4 webhook tests with cert-manager.

Not covered by e2e: the namespace-scoped mode, and there is no upgrade scenario that installs the previous release and upgrades onto this one.

renovate Bot added 2 commits August 20, 2026 04:48
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
renovate Bot and others added 27 commits August 20, 2026 19:12
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
k8s.io/api v0.37.0 adds new fields to the embedded corev1 types, which
controller-gen renders into the Server CRD schema:

- user / defaultUser on projected, secret and configMap volume items
  (alpha, gated behind AtomicWriteVolumeUserFields)
- bindMountOptions on VolumeMount
- reworded descriptions for mountPath and dataSource

Regenerated with 'make manifests' (controller-gen v0.21.0, pinned via the
go.mod tool directive) to fix the manifests drift check.
fix(deps): update go dependencies to v0.37.0
A bool field with omitempty serialises false as absent, so the API server
re-applies the CRD default of true. Seven fields could therefore never be
set to false.

The most visible effect was on ServerSpec.Server: the CA-only role
(ca: true, server: false) was unreachable, because the server flag always
resolved to true and role never became RoleCA.

Also affected: ReadOnlyRootFilesystem, Storeconfigs, AllowSubjectAltNames,
AllowAuthorizationExtensions, EnableInfraCRL and AllowAutoRenewal.

The CRD defaults are unchanged, so existing manifests keep working.

BREAKING CHANGE: the Go types of the listed fields change from bool to *bool.
Consumers of the Go API must use the BoolValue helper. Custom resources are
not affected.
…hem as NotFound

findCertificateAuthority and findSigningPolicies returned nil on any error,
not just on NotFound. A timeout or a 5xx therefore caused the Config
ConfigMap to be rendered without CA settings, and the autosign policy secret
to be overwritten with an empty (deny-all) policy -- followed by
Phase=Running as if everything had succeeded.

Both helpers now return an error, which ends the reconcile and lets
controller-runtime back off, leaving the previous valid state in place.

Closes #509
Without observedGeneration it is not decidable whether a status refers to
the current spec. Controllers had no way to detect that a resource had been
changed since it was last reconciled.

Each status type now carries observedGeneration, set only on a successful
reconcile, and every condition records the generation it was derived from.

Also drops the explicit LastTransitionTime: metav1.Now() from all condition
literals. meta.SetStatusCondition manages that field and only updates it on
an actual status transition; setting it manually produced a fresh timestamp
on every reconcile.
…changes

A Certificate in phase Signed went straight to the renewal check, so changes
to dnsAltNames or csrExtensions only took effect at the next renewal.

The controller now records a hash of the signing-relevant fields in
status.signedSpecHash and re-signs when it no longer matches the spec.
renewBefore is excluded from the hash: it moves the renewal point but does not
change the certificate. Certificates issued before the field existed carry an
empty hash and are adopted rather than re-signed, so an operator upgrade does
not re-issue every certificate in the cluster.

Renewal is no longer driven by status.phase either. Whether a certificate has
entered its renewal window is recomputed from status.notAfter, spec.renewBefore
and the cooldown annotation, so a lost or hand-edited status no longer changes
behaviour. Phase remains as a display value.
Watch map functions listed every resource in the namespace to find matching
references. Indexing spec.configRef, spec.certificateRef and spec.authorityRef
lets them query directly and keeps the added watches cheap.

The index set is defined once and registered both on the manager and on the
test client, so map functions behave in tests the way they do in a cluster.
The Server controller derived image, resources, code and the config hash from
Config, Certificate and CertificateAuthority without watching any of them, so
changing Config.spec.image.tag produced no rollout until an unrelated event
happened to trigger a reconcile. The Config controller had the same gap for
CertificateAuthority, which meant a CA created after its Config never caused a
re-render. Database never reconciled on certificate rotation, leaving its
ssl-secret-hash stale.

The secret watchers and the new map functions are covered by tests; none of
them had any before.

Closes #508
Deleting a CertificateAuthority garbage-collected the CA data PVC and the CA
key secret through their owner references, destroying the private key while
servers and agents were still using it. There was no confirmation and no
warning.

The controller now holds a finalizer and refuses to release it while any
Certificate still references the CA, surfacing the blocking certificates in a
DeletionBlocked condition and a warning event. The admission webhook rejects
such a delete outright, and warns about the irreversible key loss otherwise.

The reference documentation describes the intended deletion order and the
manual finalizer removal for the cases where it has to be forced.
…sources

Managed child resources are addressed by a name derived from their owner, so a
pre-existing resource that happens to share that name was silently taken over
and overwritten.

assertControlledBy refuses that: the operator does not own the object, and a
reconcile error makes the collision visible instead of destroying it.
…pdate

The PodDisruptionBudget, HorizontalPodAutoscaler and NetworkPolicy were
reconciled with a hand-rolled get-then-create-or-update sequence. That left a
window between get and create in which a parallel reconcile could win the
create, wrote on every reconcile even when nothing had changed, and emitted an
"updated" event each time regardless.

controllerutil.CreateOrUpdate handles the already-exists race, skips no-op
writes, and reports the actual operation, so events now only fire on real
changes. Each mutate function asserts ownership first, and the delete-when-
disabled paths do the same before removing anything.

The Deployment keeps its explicit conflict retry, which is correct as it is,
and gains the same ownership check.
…h CreateOrUpdate

Same treatment as the Server child resources: the already-exists race is gone,
no-op writes no longer happen, events fire only on real changes, and every
mutate function asserts ownership before touching anything.

Both Services now write only the fields their owner controls. Replacing the
whole spec would have dropped the Kubernetes-assigned clusterIP, and clearing
an unset nodePort would hand out a new port and break every client using the
old one.
…eateOrUpdate

Completes the conversion. The shared createOrUpdateSecret helper, which the
Certificate and CertificateAuthority controllers also use, now goes through the
same path and asserts ownership.

Test fixtures that stand in for previously created child resources gained a
controller reference through a new ownedBy helper. Without one they modelled a
foreign object, which the reconcilers now correctly refuse to touch.
The Pool controller reset Certificate.status.phase to Pending to force a
re-signing after injecting a DNS alt name, racing the Certificate controller
for the same subresource. With spec drift detection in place that write is no
longer needed: changing the alt names changes the signed spec hash, and the
Certificate controller re-signs on its own.

ReportProcessor status is no longer written by the Config controller either.
The ReportProcessor controller, until now a no-op that only logged, derives its
own state from what it can observe -- whether the referenced Config exists and
whether its endpoint made it into the rendered webhook Secret -- and watches
both. Rendering failures are reported as an event on the Config, which owns
that Secret.
…inistic

findConfigForCA returned the first match from an unordered list, so with two
Configs referencing the same CA the winner could change between reconciles --
and the Config determines the image of the CA setup job.

The lookup now sorts by name, propagates list errors instead of treating them
as "no Config yet", and reports the ambiguity through a warning event naming
all claimants. The Config webhook rejects a second Config referencing an
already-referenced CA, which is where the 1:1 relationship actually belongs;
the controller-side tie-break remains for deployments that run without
webhooks.
Stopping the operator for a single resource previously meant scaling the whole
deployment to zero, which affects every resource in the cluster.

Setting openvox.voxpupuli.org/paused=true now skips reconciliation for that one
object across all seven controllers and reports it through a Paused condition.
The status is only written when the condition actually changes, so a paused
resource stays quiet. Deletion is handled before the pause check, so a paused
resource can still be removed and its finalizers still run.

An annotation was chosen over a spec field so the flag stays out of GitOps
repositories, where a temporary operational pause does not belong.

PoolStatus gained the conditions field it was missing, which is what made the
Paused condition observable there as well.
…the default

The CEL rule compared storage.size against the literal '1Gi', so a CA that set
storage.size to exactly the default value alongside external passed validation,
and changing the default would have broken the rule silently.

Making storage a pointer gives has(self.storage) real meaning and reduces the
rule to the condition it was always meant to express. The default size now
resolves in code, matching how environmentPath is handled -- a nested
kubebuilder default does not apply when the parent object is omitted.

The envtest suite covers the case the old rule let through.
slauger and others added 18 commits September 2, 2026 01:02
The gauges were never removed, so a client library that keeps every label
combination it has seen kept reporting them for the lifetime of the
operator process. An alert on openvox_server_replicas_ready == 0 kept
firing for a Server that no longer existed, expiry alerts fired for
certificates nobody held any more, and a cluster with churn grew its
series count without bound.

Server gauges are retired on the NotFound path, the only signal available
without a finalizer. Certificate and CertificateAuthority retire theirs
when their finalizer is released, with the NotFound path as a safety net
for objects that were force-deleted.

Also adds openvox_crl_last_refresh_timestamp_seconds, which the review
found missing: a CRL that is no longer refreshed means revoked agents keep
being accepted, and nothing else surfaced that. Documented in the
monitoring guide together with an alert rule.

Closes #554
The collision check returned an error, so controller-runtime retried it
with exponential backoff indefinitely. Nothing about a duplicate hostname
improves by waiting -- only a spec change resolves it -- so the retry loop
produced only a growing backlog of identical events.

The conflict is now reported as Ready=False with reason HostnameConflict,
naming the Pool that holds the hostname, plus a single warning event on the
transition. The reconcile returns without an error.

The check was also racy: two Pools reconciled concurrently could each
conclude the hostname was free and both create a TLSRoute for it. The
winner is now decided by a property every observer agrees on, the oldest
creationTimestamp with the name as tie-breaker, so both converge on the
same Pool. A Pool that loses a hostname it previously held releases its
TLSRoute, and a terminating Pool no longer keeps a hostname hostage.

Since the conflict is no longer polled, Pools competing for the same
hostname watch each other, so a Pool picks the hostname up once the holder
gives it up or is deleted.

Closes #557
reconcileJob deleted and recreated the CA setup Job on every failure with
no attempt counter and no terminal state. A Job that cannot succeed -- a
bad image reference, a broken PVC, an unschedulable pod -- therefore
produced a delete/create cycle roughly every 15 seconds indefinitely, while
the CertificateAuthority sat in Initializing with no indication of why.

Consecutive failures are now counted in the openvox.voxpupuli.org/
setup-attempts annotation. After five the controller stops recreating,
sets CAReady=False with reason SetupFailed carrying the Job's own failure
message, moves the phase to Error and emits one warning event. The failed
Job is kept, since its logs are the only record of what went wrong.

The counter is cleared when the Job succeeds and when the image it runs
changes, so correcting spec.image on the Config resumes the setup without a
manual step. The job-succeeded-but-secret-missing path draws from the same
budget, as it loops just as endlessly.

Closes #553
spec.image on the Config reached no Server until #549 removed the nested
default from ImageSpec: a nested default is applied whether or not the
parent object was specified, so every Server came back from the API server
with repository=ghcr.io/slauger/openvox-server-8 and tag=latest, and
resolveImage always took the override branch.

The chart's servers[] entries do not set an image and the e2e tests
configure it on the Config, so neither value reached the Server pods. The
suite ran against openvox-server-8:latest rather than the image built for
the run, and nothing failed when it did.

Assert in single-node and multi-server that the running pods carry
IMAGE_REGISTRY/openvox-server-MAJOR:IMAGE_TAG, so a regression here fails
the suite instead of passing silently.

Closes #550
The polling loop ran thirty iterations of sleep 5 plus the kubectl exec
overhead, which exceeds the step's three-minute timeout. chainsaw killed
the script before it reached the diagnostic, so a failure produced twenty
odd 'waiting for facts' lines and nothing else -- neither the nodes query
nor any hint of what went wrong.

Budget the loop by wall clock with headroom under a four-minute timeout so
the diagnostic always runs, and distinguish the two failure modes: a query
API that never answered says nothing about the facts, while one that
answered without the node is a real routing failure. curl gets an explicit
--max-time so a hung exec cannot eat the budget either.
updateStatusWithRetry re-reads the whole object on every attempt, so a Pool
whose spec.route is removed while the reconcile is in flight comes back
with a nil Spec.Route. Both the conflict condition and the event that
follows it read the hostname from the refreshed object, which panics the
controller on that interleaving.

Capture the hostname when the conflict is detected and report from that.

The regression test drives the interleaving through an interceptor that
strips the route from every read after the first; it panics without this
change.
The pool-gateway assertion pinned the dnsAltNames the chart produced before
#561, which derives the Service name of every Pool a server joins into its
certificate. This server has poolRefs [ca, server], so it is reachable
through both Services and its certificate now carries both names.

That is the point of #561: a server reached through a Pool Service whose
name is missing from the certificate fails the TLS handshake, which is what
broke database-cnpg with 'Server hostname did not match server
certificate'. The expectation was stale, not the behaviour.

The failure only surfaced now because e2e-base had been red since 6 August,
so the gateway group never ran.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
docs: document the CRD update step for helm upgrade
fix(ca): bound the setup job retry loop and report the failure
test(e2e): assert the image under test, and fix the facts assertion timeout
fix(controller): make status, metrics and hostname conflicts honest
The webhooks group is the only one that installs cert-manager, which
serves a Certificate kind of its own. 'kubectl delete certificate' there
resolves to cert-manager's resource and silently deletes nothing, so the
OpenVox Certificate survived its own cleanup. The following
'kubectl delete certificateauthority' then blocked on the CA finalizer,
which correctly refuses to release while a Certificate still references the
CA.

kubectl delete waits without a timeout by default, and chainsaw's step
timeout cannot stop it: the kubectl started through sh -c outlives the
step's cancelled context and keeps the pipe open. The job ran for four
hours until it was cancelled, with an orphaned kubectl still alive.

Use the fully qualified resource name in the two tests that create their
own Certificate, and bound every CA and Certificate delete with
--timeout=120s so a blocked finalizer fails the step instead of hanging the
job. cert-rotation already used the qualified name.

This only surfaced now because the webhooks group had never run to
completion: every earlier run failed in a group before it.
…guity

test(e2e): delete the OpenVox Certificate, not cert-manager's
The Analyze changes with AI step calls the GitHub Models API and falls back
to a commit list when the call yields nothing. That fallback was
unreachable: the step runs under bash -e, so the failing curl aborted it
before the check could run. Since 2 September the PR body has not been
updated at all, and #540 still describes three dependency commits while
carrying 64.

The endpoint had gone away underneath it. models.inference.ai.azure.com is
a CNAME to modelmesh-nexus-global-main.trafficmanager.net, which no longer
resolves, so curl exits 6 - the exit code the failing job reported.

Keep the curl exit code instead of letting it kill the step, and log it
together with the response body, so the next outage is diagnosable rather
than silent. Point at models.github.ai, which is where the API moved, and
namespace the model id as the current API expects.

Note that models.github.ai currently answers 410
github_models_retirement_brownout for every path, so the fallback
description is what this workflow will produce until a provider decision is
made. That is still the commit and file list, which beats a stale body and
a red run.
slauger and others added 7 commits September 2, 2026 18:49
…d API

GitHub Models was retired on 2026-07-30, endpoint included. The step called
models.inference.ai.azure.com, whose CNAME target no longer resolves, so
curl exited 6. The step runs under bash -e, so that aborted it before the
fallback could run, and the auto-PR workflow has been failing since.

Make the description deterministic. The commits are grouped the way
semantic-release categorises them, with the file list in a collapsed block,
so the result stands on its own without any external service. Verified
against the current develop: 51 commits in, 51 listed.

The LLM call is now opt-in. With no LLM_API_KEY secret the step logs a
notice and uses the generated summary; with one it posts to LLM_API_URL,
any OpenAI-compatible chat/completions endpoint. On failure the exit code,
curl's stderr and the start of the response are logged, since the previous
warning could not distinguish a vanished endpoint from an empty answer.

The description file now holds the body only, so the consumer no longer
strips its first line, and the footer says a replaced description is left
alone, which is what the is_auto check already did.
fix(ci): generate the PR description instead of depending on a retired API
fix(deps): update docker.io/library/golang docker tag to v1.27.1
The generated schemas are unchanged; only the version annotation each CRD
carries moves from v0.21.0 to v0.22.0. Without this the check-manifests job
fails, since it regenerates with the version go.mod pins and compares.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
chore(deps): update controller-tools to v0.22.0 and regenerate manifests
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