Skip to content

refactor: migrate zookeeper-operator to the redesigned base-operator-go framework - #362

Merged
whg517 merged 57 commits into
zncdatadev:mainfrom
whg517:refactor/base-operator-go
Aug 26, 2026
Merged

whg517 merged 57 commits into
zncdatadev:mainfrom
whg517:refactor/base-operator-go

Conversation

@whg517

@whg517 whg517 commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

Migrates zookeeper-operator onto the redesigned operator-go framework, moving product-generic
concerns (ServiceAccount, security context, config mount, Vector logging, CSI volume injection)
into the framework and keeping only ZooKeeper-specific logic in the operator. Consumes the merged
operator-go work (PRs #498/#499/#501/#503/#506/#507/#508/#510).

Depends on operator-go pinned via a pseudo-version of main (v0.12.7-0.20260705104121-d90fc0400ed4);
the previous local replace => ../operator-go has been dropped.

What moved to the framework (removed from zk)

  • ServiceAccount binding — framework auto-creates & binds (was manual in customizeStatefulSet).
  • Pod/container SecurityContext — framework canonical default (1001 identity + hardening);
    per-role-group override via podOverrides.
  • Config mount — the role-group ConfigMap is mounted at the kubedoop-canonical
    /kubedoop/mount/config; zk dropped its divergent mount constants and its own config/log-config
    volumes.
  • Vector logging — framework owns the shared log volume (producer/consumer) and vector.yaml;
    zk declares one ContainerLogging producer.
  • EnableServiceLinks=false — now a framework default.
  • TLS CSI volumes — injected via the framework's new VolumeProvider (zk registers its
    per-CR SecretProvisioner on the build context instead of hand-appending volumes/mounts).

ZooKeeper-specific fixes included

  • fix(tls): serverCnxnFactory=NettyServerCnxnFactory and authProvider.x509 are now emitted
    whenever ANY TLS is enabled, not only for quorum TLS — a client-TLS-only config previously lost
    them and TLS never came up. Adds unit tests for ConfigSettings (package had none).
  • fix(znode): a ZookeeperZnode no longer gets stuck behind its delete finalizer when the
    referenced ZookeeperCluster is already gone; transient API errors now propagate instead of
    being swallowed. Adds the first znodecontroller unit test.
  • startup command: execs the JVM (direct SIGTERM / graceful shutdown) and relies on the
    framework's native Vector sidecar for shutdown ordering, replacing the old background + trap +
    wait + shutdown-file dance; single config-copy source; dropped a vestigial no-op line.
  • Container name kept as zookeeper for backward compatibility.
  • golangci-lint pinned to v2.12.2 (goconst configured for tests).

Testing

Unit tests + lint green (go test ./..., golangci-lint v2.12.2). e2e not yet run in CI.

Notes / follow-ups

  • TLS keystore password is still hardcoded changeit (pre-existing); to be revisited from the
    secret-operator side.
  • The znode controller connects to ZooKeeper in plaintext and relies on client.portUnification
    (always on when TLS is enabled) — worth an e2e sanity check with a client-cert auth class.

🤖 Generated with Claude Code

whg517 and others added 30 commits June 20, 2026 19:38
Refactor the zookeeper-operator to use the operator-go SDK's
reconciler framework and the new SecretProvisioner API for CSI
secret volume management.

Key changes:
- New controller package using reconciler.RoleGroupHandler pattern
- ZkRoleGroupHandler with buildSecretProvisioner() that declaratively
  registers server-tls, client-tls, and quorum-tls CSI volumes
- ZookeeperSecurity.ConfigSettings() now accepts SecretProvisioner
  for path resolution instead of hardcoded directory constants
- Replaced old AddVolumeMounts() with provisioner Volumes()/VolumeMounts()
- Added public accessors to ZookeeperSecurity for secret class info
- Deleted internal/util/secret.go (replaced by framework)
- Removed duplicate annotation constants from internal/constant
- Updated znode controller to use new discovery and resource utilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Guard server TLS registration against empty ServerSecretClass
- Cache runtime scheme in package-level var instead of per-call allocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion

- Fix panic when TLS auth class exists without serverSecretClass by
  falling back to TlsDefaultSecretClass ("tls") with log warning
- Update E2E test mount paths to match SecretProvisioner convention
  (/kubedoop/mount/server-tls/ instead of /kubedoop/server_tls/)
- Replace unsafe Containers[0] index with name-based container lookup
- Consolidate duplicated JvmJmxOpts and ToProperties into util package
- Remove unused RBAC pods/exec permission, fix import ordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Integrate Vector sidecar injection for log collection when logging is
enabled, bridge ZK log volume into Vector, and update e2e test selectors
to match the new base-operator-go label conventions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ZkRoleGroupHandler now embeds reconciler.BaseRoleGroupHandler: the framework builds
the canonical labels, headless/client Services, StatefulSet (with data PVC) and PDB,
and the handler customizes the returned StatefulSet (start command, exec probes, TLS
CSI volumes, config/log volumes) and ConfigMap. The myid init container is injected
through the SidecarManager (StaticContainerProvider); Vector runs as a native sidecar.

Labels: adopt the framework canonical app.kubernetes.io/* descriptive labels (plus
app.kubernetes.io/name) and use product-owned zookeeper.kubedoop.dev/{cluster,role}
identity labels for all operator selectors (cluster Service, health-check pod list,
metrics Service) so they never select another product's pods.

Naming: resources follow the framework "<cluster>-<role>-<group>" convention; discovery
pod FQDNs and the main container are aligned (container named "server").

ConfigMap: logback via the framework generator with a rolling file appender so the
Vector sidecar can collect logs; storage defaults when resources.storage is omitted.

Adds ClusterServiceExtension for the cluster-wide client Service; removes the
hand-rolled StatefulSet/Service builders. Updates unit tests and e2e label/name asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch from the local `replace => ../operator-go` to the published
operator-go containing the framework enhancements (PR #494), pinned to
v0.12.7-0.20260623053059-70aacdb72243.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Temporary local-development dependency: builds zookeeper-operator against the
local operator-go checkout for in-tandem iteration. Must be dropped (restore the
plain pseudo-version require) before opening the PR, since upstream CI cannot
resolve the local path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Still developing against the local `replace => ../operator-go` (the framework
refactor is ongoing); this only changes zk-side code.

- logging: generate logback via the framework's productlogging
  (reconciler.RenderContainerLogging) instead of the removed config generator.
- keep the main container name "zookeeper" (referenced via a constant) with
  matching e2e assertions; exec the JVM so it receives SIGTERM for graceful
  shutdown.
- extract serverRoleName / bashShell constants; resolve golangci-lint findings
  (unparam, staticcheck, unconvert, gofmt, lll) surfaced during the migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Makefile pinned golangci-lint v2.8.0 (built with go1.24), incompatible with
the module's go1.25.8 so `make lint` could not run; CI used the action default
(unpinned), so local and CI could drift. Pin a go1.25-compatible v2.12.2 and
derive the CI version from the Makefile (single source of truth). Enable goconst
ignore-tests so test fixtures may repeat short literals (role names, namespaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… done

Add a prominent comment above the replace => ../operator-go directive so it is not stripped again during future iterations. The base-operator-go refactor is still in progress and built locally; the replace must stay until it lands upstream and operator-go is pinned to a released version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ext/Vector/logging)

Migrate the server role group handler onto the merged + redesigned operator-go
framework, dropping the product-level workarounds:

- ServiceAccount binding, the default pod/container SecurityContext, and the
  Vector log bridge are now framework-owned (#498 / #499 / #501); remove the
  manual podSpec wiring and append (not replace) main-container volume mounts.
- Adopt the Vector-provider-owned shared log pipeline (operator-go #503): rename
  the main container via MainContainerName, declare LoggingContainers +
  LogVolumeSize, and render logback.xml + vector.yaml through
  reconciler.RenderLoggingConfigMapData.
- Delete server_logging.go (buildVectorConfigMapData is framework-owned); the CR
  implements reconciler.VectorAggregatorProvider so the framework generates
  vector.yaml from spec.clusterConfig.vectorAggregatorConfigMapName.

Drop ContainerLogging.OutputFile (framework derives <container>.stdout.log).
Builds against local ../operator-go (feat/vector-owned-log-pipeline) pending the
#503 merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
operator-go #506 makes EnableServiceLinks=false a framework default (overridable via PodOverrides). Drop zk's manual podSpec.EnableServiceLinks — which was also a latent bug: set after the base build, it clobbered any user podOverrides.spec.enableServiceLinks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
operator-go #507/#508 make the framework unconditionally mount the role-group config ConfigMap at the kubedoop-canonical KubedoopConfigDirMount (/kubedoop/mount/config). zk now consumes it instead of stripping + re-adding its own:

- drop removeNamedVolume/removeNamedVolumeMount + zk's own 'config' volume/mount (framework provides it); drop the redundant 'log-config' volume (same ConfigMap, second mount) and its copy — logback.xml reaches /kubedoop/config via the config-mount copy, which is where the JVM reads it (-Dlogback.configurationFile).
- delete zk's divergent internal/constant KubedoopConfigDirMount (/kubedoop/config-mount) and KubedoopLogDirMount (/kubedoop/log-mount); the start-script copy source now references the framework's canonical pkg/constant.KubedoopConfigDirMount (single source of truth).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopt operator-go's VolumeProvider (operator-go#510): register the per-CR
SecretProvisioner on buildCtx.VolumeProviders before base.BuildResources so the
framework injects the TLS CSI volumes into the pod and the main container, instead
of hand-appending secretProvisioner.Volumes()/VolumeMounts() in customizeStatefulSet
after the base build.

- zk_handler.go: append secretProvisioner to buildCtx.VolumeProviders; drop the
  secretProvisioner arg from the customizeStatefulSet call.
- server_statefulset.go: remove the manual podSpec.Volumes / main.VolumeMounts
  appends and the now-unused secretProvisioner param + opgosecurity import; refresh
  the doc comment. secretProvisioner is still built per-CR and passed to buildConfigMap
  (keystore paths), so nothing else changes.

Build/vet/test/lint (golangci-lint v2.12.2) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The main container start script carried a leftover `ls /kubedoop/ > /dev/null
2>&1 || true` — a no-op with no output or side effect, left behind when the old
Vector shutdown-file / trap / wait block was replaced by `exec`. Remove it; the
start script now just copies the config, echoes, and execs zkServer.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
serverCnxnFactory=NettyServerCnxnFactory and authProvider.x509 were emitted only
in the quorum-TLS branch of ConfigSettings, gated on quorumSecretClass. But both
are required by ZooKeeper for ANY TLS: client/server TLS is unusable with the
default NIO connection factory, and X509 client-cert auth needs the X509 provider.

A client-TLS-only config — a TLS AuthenticationClass with no `tls` block, so
quorumSecretClass is empty while TLSEnabled() is true — therefore opened the secure
client port with a keystore and clientAuth=need but WITHOUT Netty or the X509
provider, so TLS never worked. (Pre-existing since before the framework refactor;
the common path is unaffected because quorumSecretClass defaults to "tls" whenever
a tls block is present.)

Move both settings to a common block emitted whenever TLSEnabled() || quorum TLS.
Add internal unit tests for ConfigSettings covering no-TLS / server-only /
quorum-only / server+quorum (the security package previously had zero coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a ZookeeperZnode was being deleted but its referenced ZookeeperCluster no
longer existed (e.g. the cluster was deleted first), Reconcile returned early with
a 10s requeue from the getClusterInstance error path — before the finalizer logic
ran. The delete finalizer could therefore never complete, so the ZookeeperZnode was
stuck forever, requeueing every 10s.

- getClusterInstance now returns the original API error instead of collapsing every
  failure into an opaque sentinel, so the caller can tell "not found" from a
  transient error.
- On not-found: if the znode is being deleted, drop the delete finalizer directly
  (the ensemble is gone, there is nothing to remove from ZooKeeper); otherwise keep
  the existing "wait for the cluster to appear" requeue. Transient API errors now
  propagate (requeue with backoff) instead of being silently swallowed.
- Add a unit test (fake client) for the deleting-znode + missing-cluster case; the
  znodecontroller package previously had no tests.

Pre-existing since before the framework refactor. Build/vet/test/lint (v2.12.2) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The base-operator-go framework work has all merged upstream (operator-go PRs
#498/#499/#501/#503/#506/#507/#508/#510), so the local `replace => ../operator-go`
is no longer needed. Pin to the operator-go main commit that carries the full
refactor (v0.12.7-0.20260705104121-d90fc0400ed4, commit d90fc04) via a pseudo-
version so CI resolves the dependency from the public module proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-pin to operator-go main (v0.12.7-0.20260705162431-27c59c1877a3, commit 27c59c1)
which merges the ClusterOperation pause-gate ordering fix (#512, closes #511) and
the ResourceName comment correction (#515).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ions

The base-operator-go refactor deleted the old zookeepercluster_controller.go along
with its kubebuilder RBAC markers, and the ZookeeperCluster controller — now wired
through operator-go's GenericReconciler — was left with none. The generated manager
ClusterRole therefore lacked list/watch on the operator's OWN ZookeeperCluster CRD as
well as create/manage on statefulsets, poddisruptionbudgets, serviceaccounts and
pods/exec. At runtime the manager could not sync its informer caches and crash-looped
("failed to wait for caches to sync"), so no cluster was ever reconciled.

Add an rbac.go marker file in internal/controller (lll is excluded there, matching the
znode controller markers) covering: zookeeperclusters(+status,+finalizers),
apps/statefulsets, policy/poddisruptionbudgets, core/serviceaccounts (full — the
framework provisions the SA), core/pods/exec (service health check), and
authentication.kubedoop.dev/authenticationclasses (TLS auth resolution). Regenerate
config/rbac/role.yaml. Verified in a kind e2e cluster: the operator now reconciles the
StatefulSet, ConfigMap and Services.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tls-override-pdb test's final step exec'd into container 'server', a stale
reference from before the main container was renamed to 'zookeeper' (the cp step two
lines above already used 'zookeeper'). Align it so kubectl exec targets the real
container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveImage produced a bare "<repo>/zookeeper:<productVersion>" tag whenever the CR
did not set image.kubedoopVersion (the default path and the no-image path). But the
kubedoop ZooKeeper product images are ONLY published as "<productVersion>-kubedoop<v>"
(e.g. 3.9.3-kubedoop0.0.0-dev) — a bare "3.9.3" tag does not exist — so every default
cluster failed to pull its image (ErrImagePull / NotFound), leaving all pods stuck in
Init.

The pre-refactor operator built the tag via operator-go's util.NewImage, defaulting the
kubedoop version to the operator's own build version. Restore that: default
kubedoopVersion to version.BuildVersion (injected via -ldflags, e.g. "0.0.0-dev") and
always emit the "-kubedoop<version>" suffix, while still honoring image.custom / repo /
productVersion / kubedoopVersion overrides.

Verified in a kind e2e cluster: the server pod now pulls quay.io/zncdatadev/zookeeper:
3.9.3-kubedoop0.0.0-dev and reaches 1/1 Running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The base-operator-go refactor dropped cluster-level discovery: the GenericReconciler
only builds role-group-scoped resources, and the ZookeeperZnode controller only creates
per-znode discovery ConfigMaps, so a ZookeeperCluster no longer published a discovery
ConfigMap named after itself. Clients (and the znode discovery assertion) that expect to
connect to the whole ensemble at the root znode without creating a ZookeeperZnode had
nothing to read. Pre-refactor, the cluster reconcile owned this ConfigMap.

Restore it in ClusterServiceExtension.PostReconcile (which already owns cluster-scope
resources and runs after the role groups' pods/endpoints exist): build a ClusterInternal
discovery ConfigMap named "<cluster>" (root znode "/") always, plus an ExternalUnstable
"<cluster>-nodeport" ConfigMap for the external-unstable listener class — mirroring the
per-znode discovery the ZookeeperZnode controller emits. Both are owned by the cluster and
share the common discoverer.

Verified: the znode e2e (test/e2e/znode) now passes end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ck layout

Re-pin operator-go to main (v0.12.7-0.20260707111227-2291143ff3ca), picking up 10
merged commits including several that resolve e2e findings:

- #523 restores the v0.12.6 stable Vector log pipeline: the framework no longer emits
  the invalid VRL `string!(.message) ?? ""` (Vector E651 crash-loop), and the logback
  file appender now writes /kubedoop/log/<container>/<container>.log4j.xml (XMLLayout)
  for Vector edge parsing. Update the server logback default test to the new file path
  and refresh the stale comment.
- #519 adds MetricsServiceBuilder.WithTargetPortName; call it with MetricsPortName so
  the metrics Service targets the container port by name ("metrics") — matching the
  observability e2e assertion and staying valid if the numeric port changes.

Build/vet/test/lint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump operator-go to pick up the stopped-reconcile-all fix (zncdatadev/
operator-go#529): stopped is now a replica modifier that reconciles every
resource at 0 replicas instead of a reconcile short-circuit, so a cluster
created stopped and config changes made while stopped are handled correctly.

Rewrite the cluster-operation chainsaw test to exercise the full lifecycle:
install -> 1, stopped -> 0, restart -> 1, then two pause cases proving the
reconciliationPaused freeze semantics -- requesting stopped WHILE paused has
no effect (stays 1), and unpausing applies the previously-frozen stop (-> 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-pins operator-go to the merged main (0994784) that emits one PodDisruptionBudget
per role instead of per role group. The tls-override-pdb e2e asserts the role-level
PDB "test-zk-server" (expectedPods=3 across both role groups), which this framework
version now produces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
generateServerList numbered the zoo.cfg "server.N" entries from
clusterConfig.minServerId (server.<minServerId+ordinal>), but the prepare init
container wrote each pod's myid file as "MYID_OFFSET + ordinal" with MYID_OFFSET
hardcoded to "1". The two only agreed at the default minServerId=1; any other
value produced myid files that matched no server.N entry, so no node could join
the quorum and the ensemble never formed. (The pre-refactor code used the same
offset on both sides, so this was a regression introduced when the server list
started honoring minServerId.)

- Add resolveMinServerID(cr) as the single source of truth for the myid base,
  clamping values below 1 to 1 (ZooKeeper myid must be >= 1; minServerId: 0 would
  otherwise yield the invalid server.0).
- generateServerList and the prepare init container's MYID_OFFSET both derive from
  it, so the on-disk myid always matches the server.N id for that ordinal.
- Drop the dead MYID_OFFSET env from the main container (only the prepare init
  container writes myid; the main container never reads it).

Unit tests assert resolveMinServerID's defaulting/clamping and that the prepare
container's MYID_OFFSET equals the lowest server.N id for every minServerId
(mutation-verified: the test fails if MYID_OFFSET is hardcoded). Adds a
min-server-id chainsaw e2e (minServerId=5, 3 replicas) that asserts the quorum
forms and that the myid files and zoo.cfg server.N ids are 5/6/7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…actor

The refactor stopped calling the old DefaultServerConfig/MergeDefaultConfig path,
so the ZooKeeper-specific defaults it injected were lost and the base-operator-go
framework does not supply them (it applies resources/affinity/gracefulShutdown
only when the merged role group config already carries them). A cluster without an
explicit resources/affinity block therefore got:
- no CPU/memory requests or limits, and an unbounded JVM heap (ZK_SERVER_HEAP was
  only set when memory was specified) — OOM risk;
- no pod anti-affinity, so ensemble members could be co-scheduled onto one node,
  defeating ZooKeeper's availability model;
- a 30s (k8s default) graceful-shutdown window instead of the previous 120s.

Extend the existing storage defaulting into ensureServerConfigDefaults, which fills
CPU (100m/200m), memory (512Mi, which also drives ZK_SERVER_HEAP), a weight-70
preferred pod anti-affinity keyed on the instance/component labels, and a 120s
gracefulShutdownTimeout. Values are written into the role group config the framework
reads, with field-level precedence group > role > default, so user values at either
level win — and because the framework only reads the role-group config, folding the
role-level value in here is also what finally makes role-level config take effect.

Unit tests cover the minimal-cluster defaults, user overrides winning, role->group
folding, and heap derivation. Adds a config-defaults chainsaw e2e asserting a bare
cluster gets the anti-affinity, 120s grace, CPU/memory requests+limits and heap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
generateServerList only emitted server.N entries for the current role group's own
pods, so each group's zoo.cfg described a separate quorum — while the discovery
ConfigMap and health check aggregate pods across all groups into one connection
string and one quorum calculation. A cluster with more than one server role group
therefore formed N disjoint ensembles that discovery advertised as a single one,
and every group reused MYID_OFFSET=1 so myids collided across groups.

Make the server role a single ensemble spanning every role group:
- serverGroupBaseIDs assigns each group a non-overlapping myid range (groups
  ordered by name, each base = minServerId + running total of prior groups'
  replicas), so every pod's myid is unique ensemble-wide.
- generateServerList now walks all role groups and emits the full server.N list
  (identical in every group's zoo.cfg), with each entry pointing at that group's
  own headless Service.
- The prepare init container's MYID_OFFSET is the pod's group base, so the on-disk
  myid matches the server.N id for that pod.
- The standalone/ensemble switch is keyed on the total desired member count across
  groups (serverEnsembleSize), not the current group's replica count.

Replica counts come from the desired spec (default 1 when unset), independent of
stopped/paused scaling, so the quorum config is preserved while a StatefulSet is
scaled to zero. Single-group clusters are unchanged (base = minServerId).

Unit tests cover base assignment, the cross-group server list and FQDNs, ensemble
size, and the per-group myid/server.N agreement. Adds a multi-rolegroup chainsaw
e2e that asserts the spanning server list, unique myids, and — the real proof —
that a znode written through one group is readable through the other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
whg517 and others added 27 commits July 12, 2026 19:22
…covery labels

Follow-up cleanup after the default-restoration and single-ensemble fixes:

- Remove internal/common/role_config.go and affinity.go (and the fully
  commented-out role_config_test.go). Since the handler builds its own role group
  defaults, these old DefaultServerConfig/MergeDefaultConfig/affinity-builder paths
  were entirely unreferenced dead code that misleadingly looked like the config
  source of truth.
- getExpectedReplicas now delegates to serverEnsembleSize, so the health check's
  quorum math shares the one place that applies the "unset replicas -> 1 per group"
  rule and always matches the generated server.N list.
- Discovery ConfigMap labels: "app.kubernetes.io/name" now carries the product
  (zookeeper) and the cluster name moves to "app.kubernetes.io/instance", matching
  the recommended-label semantics (previously "name" held the instance and
  "instance" was absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…default

The e2e caught that a minimal cluster still got terminationGracePeriodSeconds=30
despite the default-restoration change. Root cause: the base-operator-go CRD marks
gracefulShutdownTimeout with a kubebuilder default of "30s", so the API server
auto-injects servers.config={gracefulShutdownTimeout:"30s"} even when the user
omits the block. ensureServerConfigDefaults then folded that injected "30s" in as
if the user had chosen it, so ZooKeeper's 120s product default never applied
(resources and affinity have no CRD default, which is why only grace was affected).

Treat the framework's platform default "30s" as "unset" when resolving the
graceful-shutdown window, so ZooKeeper's 120s default applies for a bare cluster
while any other explicit value (group or role level) is still honored.

Verified against a live minimal cluster: terminationGracePeriodSeconds is now 120.
Unit tests cover the CRD-injected-30s-at-role-level case and an explicit non-default
role value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etries)

The cross-group read used the plaintext client port 2181, but ZooKeeper is
TLS-by-default (serverSecretClass defaults to "tls"), so the client port is 2282
with portUnification (which accepts plaintext). Point zkCli at 2282, and retry the
write/read since zkCli opens a fresh session per call and can hit a transient
ConnectionLoss — distinguishing that from a genuine split ensemble, which returns a
stable "Node does not exist". Verified end-to-end: the znode written through the
default group is read back through the secondary group (cross-group read OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zkCli opens a fresh session with a TLS handshake on the unified client port for
every call, which can take longer than the previous 2-retry window under load.
Raise the unsecure-connection retries and wrap the secure-connection check in the
same retry loop so a transient ConnectionLoss during session setup no longer fails
the test; a genuine failure still surfaces after the retries are exhausted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ers (#531)

Syncs operator-go to main (966aad8), which adds per-role overrides for
MainContainerName and LoggingContainers (#531) and a test-only FakeRecorder
buffer fix (#532). The per-role change is backward compatible — ZooKeeper has a
single "server" role and keeps setting the global MainContainerName and
LoggingContainers fields, so its behavior is unchanged. Build, unit tests and
lint pass against the new module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…restarts

CI's chainsaw run failed the multi-rolegroup "verify server list" step: the script
read zoo.cfg and the myid files via kubectl exec under `set -euo pipefail` with no
retry, so it failed spuriously when it raced the one-time pod restart that happens
while a fresh multi-node ensemble forms. Wrap the checks (and the equivalent
min-server-id myid check) in a retry loop that tolerates a transient exec failure
and only fails after the values are stably wrong. Since install (availableReplicas)
already proves the ensemble formed, the assertions themselves are sound — they just
needed to wait out the restart. Verified: multi-rolegroup passes with the retries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chainsaw executes inline script content via /bin/sh, ignoring the #!/bin/bash
shebang. On the Linux CI runner /bin/sh is dash, which does not support
`set -o pipefail` and aborts immediately with exit status 2 — before any check
runs — which is why the multi-rolegroup verification failed only in CI. Locally
/bin/sh is bash (pipefail supported), so it passed and the failure could not be
reproduced. Replace `set -uo/-euo pipefail` with the POSIX `set -u`/`set -eu`
(matching the passing observability test); the checks already handle command
failures explicitly with `|| return 1`, so pipefail was redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssed by 62d06aa

62d06aa ("drop dead default code, unify quorum count, fix discovery labels")
described these two changes in its message, but the `git add` that staged them
aborted (a pathspec for an already-removed file made the whole command fatal), so
only the dead-code deletions were committed. Commit the intended source changes:

- Discovery ConfigMap labels: app.kubernetes.io/name now carries the product
  (zookeeper) and the cluster name moves to app.kubernetes.io/instance.
- getExpectedReplicas delegates to serverEnsembleSize so the health check's quorum
  math shares the single "unset replicas -> 1 per group" rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-pins operator-go to main (4d5d74f), which sorts the container env vars built
from EnvVars before appending them (#534). Without it, the map-iteration order was
non-deterministic, so any cluster with envOverrides re-rendered a different
StatefulSet every reconcile — an endless CreateOrUpdate loop that recreated the
pods continuously and kept them from ever reaching readiness (which is why the
tls-override-pdb e2e, whose cluster sets envOverrides, could not stabilize).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… test

Earlier commits weakened the new e2e scripts to chase a CI failure (dropping
set -e / -o pipefail and wrapping the checks in retry loops). The real cause was
an operator-go reconcile-churn bug (fixed in #534), not the scripts, so restore
min-server-id, config-defaults and multi-rolegroup to their strict, straightforward
form (set -euo pipefail, no retries) — the tests are a gate and must stay strict.

Also fix the one genuine staleness in the pre-existing test_tls.sh: it addressed
the peer via <pod>.<resource> DNS, but the base-operator-go framework names the
StatefulSet's governing headless Service <resource>-headless (the discovery
ConfigMap advertises the same FQDN), so the per-pod name needs the -headless
suffix. Verified: the full tls-override-pdb suite (install, PDB, env/config
overrides, TLS handshake) passes end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

The new min-server-id/config-defaults/multi-rolegroup scripts used
`set -euo pipefail`, a form that appears nowhere else in the suite — every
existing e2e script uses `set -e` (or no `set`). chainsaw runs script content
through /bin/sh, whose `-o pipefail` support is not portable, so those three
scripts aborted immediately with exit status 2 under CI while the `set -e`
scripts (e.g. logging, observability) ran fine. Align with the established
`set -e` convention; the checks already surface failures directly (grep/`[ ]`
as the last command in each pipeline), so pipefail was never needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's chart-e2e fails the logging test with the StatefulSet reporting
status.replicas=0 (no pods created), which does not reproduce locally and which the
current chainsaw output does not explain (it dumps neither pod state nor operator
logs). Add a global catch to the chainsaw config that, on any test failure and
before cleanup deletes the namespace, dumps the failing namespace's StatefulSets
(spec+status), pods, `describe pods` (scheduling/creation events), the namespace
events, and the operator log. This makes the next CI failure self-diagnosing.

The catch uses only POSIX sh so it runs under the CI shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The refactor onto base-operator-go runs the Vector log agent as a native
sidecar: an init container with restartPolicy=Always and a readinessProbe. That
requires the SidecarContainers feature gate, which is only on by default from
k8s 1.29. chart-e2e still created its kind cluster at 1.26.15 (the pre-refactor
default, unchanged since zncdatadev#347), where the API server drops the gated
init-container restartPolicy but keeps the readinessProbe. Pod validation then
rejects every server pod with

  spec.initContainers[1].readinessProbe: Forbidden: may not be set for init containers

so the StatefulSet creates zero pods, status.replicas stays 0, and the logging
test's assert times out at 300s. The failure did not reproduce locally only
because the local kind node happened to be 1.35.

Bump KIND_K8S_VERSION to 1.35.0 (native sidecars GA) and document 1.29 as the
hard floor. The e2e suite was validated end-to-end on 1.35 locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move from 1.35.0 to 1.36.1, the highest published kindest/node image, so
chart-e2e runs on the newest k8s the tests support. The 1.29 native-sidecar
floor is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the missing authenticationclasses rule and drop grants the operator
no longer has markers for: secrets, clusterroles, and write verbs on
rolebindings. Chart rules now match config/rbac/role.yaml exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation

Bumps operator-go from #534 (4d5d74f) to current main (0d765be), which lands the
v0.13.0-unreleased standardization pass, and adapts zookeeper-operator to its
breaking changes:

- commons resource fields (CPU Min/Max, Memory Limit, Storage Capacity) and
  RoleGroupConfigSpec.GracefulShutdownTimeout became pointers so "unset" is
  representable. Construct them with ptr.To and nil-guard Capacity.IsZero.
- gracefulShutdownTimeout is no longer defaulted by the CRD, so a nil pointer now
  means "unset". Drop the isUnsetGrace hack that treated the auto-injected "30s"
  as unset: an explicit "30s" is now honored, and ZooKeeper's 120s product
  default applies only when the value is nil at both the group and role level.
- BaseRoleGroupHandler.ExtraLabels/ExtraAnnotations were removed (#555). Publish
  app.kubernetes.io/name through buildCtx.ClusterLabels, the per-reconcile map
  the framework copies onto every built resource and pod. The cluster Service
  selector keys on the product identity labels, not this one, so nothing else
  changes. ProductName is intentionally left unset: setting it would switch image
  resolution to spec.image, which cannot reproduce ZooKeeper's always
  -kubedoop-suffixed default tag.
- The extension registry is now per-CR-type. Create it with
  NewExtensionRegistry[*ZookeeperCluster], re-type ClusterServiceExtension to that
  CR (dropping the ClusterInterface type-assertion dances), and pass it in
  GenericReconcilerConfig.ExtensionRegistry.

Regenerate the CRD (which loses the removed default: markers) and sync the chart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… liveness

The base-operator-go migration gave the server container a liveness probe that
TCP-checks the client port with a ~30s budget (initialDelay 10s + 3x10s).
ZooKeeper 3.9.3 does not bind that port until ~25s into JVM startup, and under
the default 200m CPU limit on a loaded node it slips past the budget: three
liveness failures then SIGTERM the container (exit 143) into a CrashLoop before
it ever serves, so the quorum never forms and the StatefulSet never becomes
available. Pre-migration main had no liveness probe, so a slow start there only
delayed readiness. The full-suite e2e run (contended node) exposed this.

Add a startup probe running the same ruok check with a generous budget
(30 x 10s = 5m). The kubelet suspends the liveness and readiness probes until the
startup probe first succeeds, so a slow first start is no longer fatal while the
liveness probe still guards steady-state hangs. Verified by re-running the
logging e2e case against the rebuilt image: the servers start and the StatefulSet
becomes available.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o v0.13.0

Two follow-up cleanups the operator-go bump enables:

- Remove the manual SetProductImage call in registerServerContainers. operator-go
  #536 now propagates the product image to the framework-constructed Vector
  sidecar inside base.BuildResources, so the hand call is redundant; the function
  no longer needs to return an error.

- Stop defaulting the data PVC capacity by hand. The framework builds the
  VolumeClaimTemplate with StorageResource.GetCapacity(), which applies the 10Gi
  DefaultStorageCapacity when unset, so ensureServerConfigDefaults only has to
  guarantee Resources.Storage is non-nil (the framework builds a PVC only then).
  Drops the defaultStorageCapacity constant and the IsZero re-defaulting.

GracefulShutdownTimeout keeps its own defaulting: the framework's
GetGracefulShutdownTimeout returns the 30s platform default, but ZooKeeper needs
its longer 120s product default, so that helper does not apply.

Verified against the rebuilt image: the full chainsaw e2e suite passes (logging,
config-defaults, min-server-id, multi-rolegroup, cluster-operation, metrics,
tls-override-pdb, znode; delete-rolegroup skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps operator-go from 0d765be to deb6578, and adapts to the four breaking
changes plus the two seams that were added for this operator.

ServiceAccountName is gone from GenericReconcilerConfig (#616). The framework
now derives the workload ServiceAccount per CR as "<kind>-<cluster>" and owns
it, which also fixes the failure the static name caused: every cluster of a
product in one namespace resolved to the SAME ServiceAccount, so the second one
to reconcile failed forever and deleting the first garbage-collected the SA out
from under the other's running pods. WorkloadRBACRules stays unset — the
ZooKeeper workload needs no Kubernetes API permissions, and a nil hook is what
keeps the operator from having to grant itself cluster-wide access to roles and
rolebindings.

ProductName no longer decides whether spec.image is read (#581), so ZooKeeper
can finally set it: the new ImageDefaults supplies whatever spec.image leaves
empty, re-evaluated every reconcile, which is what the old API could not express
and why this operator hand-rolled resolveImage. That function is deleted and
resolution now goes through the same ImageSpec.ResolveImage the framework uses
for the main container, so the prepare init container cannot drift from it, and
an unresolvable spec.image is reported instead of being silently replaced by the
default version. Setting ProductName also publishes app.kubernetes.io/name —
no longer hand-written into ClusterLabels — and app.kubernetes.io/version, which
this operator was not emitting at all.

The primary container is customized through buildCtx.MainContainerCustomizer
(#585) rather than by editing the built StatefulSet. The old code reached for
podSpec.Containers[0], a position the framework never promised and that any
sidecar provider inserting a container earlier would have silently broken.
Precedence is unchanged: the hook runs after the framework applies envOverrides
(so appending them after ours keeps the user's last word) and before podOverrides
are strategic-merged.

Per-CR ports move to buildCtx.ContainerPorts/ServicePorts (#582). One handler
instance serves every ZookeeperCluster, so writing them to its fields let
concurrent reconciles of different clusters overwrite each other.

CRDs are regenerated: logging levels lose `default: INFO` (#573), the CRD default
that made a role group's empty console block override the role's threshold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The readiness probe ran with periodSeconds and timeoutSeconds both at 1 while
the server container carries the default 200m CPU limit. The check forks bash,
opens a TCP connection to the client port and greps the reply — normally ~100ms,
but under that limit the container is CFS-throttled in the large majority of
scheduling periods (measured: 78% of periods, with probe latencies reaching
1297ms), so the command regularly overran its one-second timeout even though
ZooKeeper was serving and had formed a quorum. Three such samples in a row took
a healthy server out of the Service endpoints, and with replicas flapping the
StatefulSet never reported them all available: the logging e2e case failed its
availability assert with both pods Running, 0 restarts and 1/2 ready.

Raise the period and timeout to 5s, which covers the throttled tail. The failure
threshold stays at 3, so a genuinely unresponsive server still leaves the
endpoints within 15s.

This is the same class of defect as the startup probe added in 7ff68fb, on the
other probe: an exec probe budget that assumed an unthrottled container.

Verified by re-running the full chainsaw suite, which now passes end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The operator has never held any permission on core Events, so every event the
framework records — resources created and updated, a role group that failed to
build, the ImmutableFieldIgnored warning explaining that a StatefulSet field the
spec asks for cannot be applied — was rejected by the API server:

  events is forbidden: User "system:serviceaccount:zookeeper-operator:..."
  cannot create resource "events" in API group "" in the namespace "..."

The information the operator meant to surface on the CR was therefore invisible
to `kubectl describe`, leaving the operator log as the only place a user could
learn why their cluster was not converging. Grant create and patch: repeated
events are aggregated onto the existing object rather than written anew, so
create alone still fails on the second occurrence.

The helm chart's ClusterRole is maintained by hand, so it gets the same rule;
the rendered chart role and the generated config/rbac/role.yaml are identical
again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… dead merge

Two simplifications that follow from what the framework already does, with no
change to the rendered resources.

The default pod anti-affinity is now reconciler.DefaultAntiAffinity +
EncodeAffinity instead of a hand-built corev1.Affinity marshalled by hand. The
helper takes the same inputs and emits the same structure — the existing
assertions on weight 70, app.kubernetes.io/{instance,component} and the hostname
topology key pass untouched, which is the equivalence check. An encoding failure
is now propagated rather than swallowed into "no affinity at all": silently
dropping a scheduling constraint is worse than failing, because the pods land on
nodes the constraint existed to avoid.

ensureServerConfigDefaults no longer folds the role level itself. GenericReconciler
already hands the handler a role group spec whose Config is
MergeRoleGroupConfig(roleSpec.Config, groupSpec.Config), so every roleCfg/roleRes
branch here was unreachable: cfg.Resources.CPU == nil already implies the role set
none either. The doc comment claimed the opposite ("the framework itself only reads
the role-group config"), which is how the duplicate survived. What remains is what
the framework genuinely does not do: fill the fields nobody set.

The unit tests that drove this logic through RoleSpec were exercising the deleted
reimplementation, not the real contract, so they now feed RoleGroupSpec.Config —
the shape a handler is actually handed — and say so. Role-level config keeps its
end-to-end coverage in the tls-override-pdb case, which sets config, envOverrides
and configOverrides at the role level and asserts them.

Verified with the full chainsaw suite: all eight runnable cases pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…taining it

The chart's ClusterRole duplicated the rules controller-gen writes into
config/rbac/role.yaml from the kubebuilder markers, and nothing kept the copy in
step. Adding a marker updated the kustomize deployment and silently left the
chart behind — and the chart is what chart-e2e, the release and every user
actually deploy. This repo has now fixed that same drift twice: 2ea40dd, and
867fd76, where a missing core/events rule meant every event the framework emitted
was rejected in the helm deployment, invisibly, because event emission is
best-effort. Fixing one hole twice is a missing-automation signal, not a
diligence problem.

helm-rbac-sync splices the generated rules into the chart template. Only the
rules are generated: the document's name and labels come from helm helpers and
its body is wrapped in an `{{- if .Values.serviceAccount.create -}}` guard, so it
cannot be replaced wholesale, and a YAML-aware tool cannot parse a template in
place. Splicing by line range keeps that chart-owned wrapper intact and needs
nothing beyond sed, since `rules:` is the last top-level key of role.yaml.

The CI gate is extended rather than duplicated. check-crds-sync already proved
the files under config/ were current; it now regenerates the chart's copies too,
so the artifacts that actually ship are the ones being verified — which also
closes the same gap for the chart's CRDs.

Verified three ways: the target is idempotent (byte-identical output on this
repo), it is corrective (adding a marker without syncing is now caught by the
gate, and the sync produces the missing rule), and the workflow still exposes the
check-crds-sync job the two downstream jobs depend on.

Reference implementation for zncdatadev#371, which other operator repos can copy: the chart
layout is identical across them, and 0 of 14 have this automation today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dler state

Bumps operator-go to e8a9495 and adopts the declare / fold / derive split it
introduces (#632), which answers the three issues this operator filed: the data
PVC no longer reports an unchanged template as a dropped change (#627), the
operator-side permissions the framework consumes are now documented (#629), and
the config-defaults seam collapsed into one entry point with the CR in scope
(#631).

DeclareRoles replaces nine handler fields and four per-call assignments on the
build context. Everything the server role is made of — container name, ports,
entrypoint, the three probes, the data volume, log producers, log volume size,
config defaults and static env — is now one statement produced per pass with the
CR in hand. That is what makes it static data: the client port moves when the CR
enables TLS and the anti-affinity selector names the cluster, and both are read
from THIS cluster rather than written into handler state the next cluster would
inherit.

ResolveRoleGroup carries what cannot be declared beside the defaults that feed
it. ZK_SERVER_HEAP is a function of the memory limit that survives the fold, so
it is contributed after it, which is exactly the derived-value path we could not
express before.

ensureServerConfigDefaults is gone. Its content is declared as ConfigDefaults and
folded by the framework under the CR's role and role group levels, which also
retires a defect of the hand-rolled version: it tested `Resources.CPU == nil` as
a whole, so a user who set only cpu.max silently lost the default cpu.min. The
framework folds per leaf, so the default now survives.

The primary container is no longer customized after the build — MainContainerCustomizer
is removed upstream. The entrypoint carries its script inline in Command, because
arguments are the user's channel through cliOverrides and a product writing them
would delete what the user wrote.

DefaultAntiAffinity is removed upstream; the default is now built from
PreferredAffinityTerm and RoleSelectorLabels, which is the composable form that
lets a product express more than one weighted term.

Image resolution moves to GenericReconcilerConfig.ImageResolution, and the myid
init container takes the reference the framework already resolved for the main
container so the two cannot drift.

Static gates pass: build, vet, unit tests, golangci-lint, and the CRDs regenerate
unchanged. The rendered workload is NOT yet verified — every one of these moved
the channel a value travels through, so the e2e suite is the gate and runs next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The only conflict was the check-crds-sync step, where both sides had widened the
same gate along different axes:

- this branch widened WHAT is regenerated, so the chart's own copies of the CRDs
  and of the operator ClusterRole are verified too (they are what chart-e2e and
  the release actually deploy);
- main (zncdatadev#365) widened WHAT COUNTS as drift, staging first so a newly generated
  but untracked file is caught, which a plain `git diff` ignores.

Both are kept: regenerate everything, then compare the staged tree.

Everything else merged cleanly, including main's chainsaw bump to v0.2.14 and its
--set product_version wiring (zncdatadev#364), alongside this branch's helm-rbac-sync target
and the 1.36.1 kind node.

Verified after the merge: the gate command reports no drift, the operator builds,
and all five unit test packages pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both conflicts were the golangci-lint version, where the two sides had fixed the
same defect — CI installed the newest release and drifted from `make lint` — in
different ways.

main (zncdatadev#372) pinned the literal version in the workflow, with a comment asking the
reader to keep it in sync with the Makefile. This branch derives it from the
Makefile in a preceding step instead. The derivation is kept: it removes the
duplicate main's own comment describes, which is the same reasoning that put the
chart's ClusterRole under `make helm-rbac-sync` rather than under a review
convention.

The version itself is taken from main. v2.12.1 is the baseline zncdatadev#372 aligned to
and what hive, hdfs and trino all pin; this branch's v2.12.2 would have left
zookeeper as the only operator on a different linter. `make lint` reports no
issues under v2.12.1.

Verified after the merge: no generated-file drift, the operator builds, and all
five unit test packages pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This branch tracked operator-go through pseudo-versions while the declare / fold
/ derive work was landing upstream. v0.13.0 is now tagged, so the dependency
moves onto the release.

It is a pin change and nothing else: the tag sits one commit past the
pseudo-version this branch already used, and that commit only writes the
CHANGELOG — the diff touches no Go file, so no adaptation is possible or needed.

Verified: build, vet, all five unit test packages, golangci-lint (0 issues), and
the generated files regenerate unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@whg517
whg517 merged commit 63e6c28 into zncdatadev:main Aug 26, 2026
8 checks passed
@whg517
whg517 deleted the refactor/base-operator-go branch August 27, 2026 12:41
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