Skip to content

feat: incorporate johnduhart fork customizations - #2

Merged
linvald merged 29 commits into
mainfrom
feat/johnduhart-customizations
Aug 31, 2026
Merged

feat: incorporate johnduhart fork customizations#2
linvald merged 29 commits into
mainfrom
feat/johnduhart-customizations

Conversation

@linvald

@linvald linvald commented Aug 28, 2026

Copy link
Copy Markdown

Summary

This PR brings in commits from the johnduhart/temporal-operator fork that are ahead of the upstream alexandrevilain/temporal-operator.

Changes Included

New Features

  • Add support for Temporal Server v1.29, v1.30 and v1.31

Bug Fixes

  • Decode patched overrides into zeroed structs to avoid list corruption
  • Add omitempty to optional nullable fields to stop webhook emitting JSON nulls
  • Sync bundled CRDs with generated manifests
  • Use internal frontend for operator connections even with frontend mTLS
  • Clear ReconcileError condition on successful reconcile
  • Fix dynamicconfig parsing of long integers
  • Forbid retracted releases v1.26.0 and v1.26.1
  • Make SQLPasswordCommandSpec.Timeout a pointer
  • Correct the Temporal >= 1.30 Elasticsearch schema scripts
  • Remove operator-managed pod metadata when the spec stops asking for it
  • Keep exponent-form and 64-bit dynamic config integers as integers
  • Suggest an upgrade target that is not itself broken
  • Use corrected broken-release suggestion, warn on passwordCommand

CI/Build Improvements

  • Verify chart bundled CRDs stay in sync with config/crd
  • Migrate golangci-lint to v2.12.2
  • Run golangci-lint without its analysis cache
  • Raise the e2e timeout and prefer podman for local container targets

Tests

  • Cover admin-tools tags for 1.29, 1.30 and 1.31
  • Run every persistence case, and revive the Elasticsearch one
  • Make the workflow assertion retry, and hold mysql8 at 1.28.1

Other

  • Bump controller-runtime to v0.23.3
  • Refactor: single source for the server config file path
  • Correct the CHANGELOG defaults and document the CRD schema change
  • Preserve existing pod annotations/labels
  • Linting fixes

Files Changed

52 files changed with 3786 insertions and 759 deletions, including new test files for improved coverage.

johnduhart and others added 28 commits July 27, 2026 13:48
…ption

ApplyPodTemplateSpecOverrides and the JSONPatch path of
ApplyDeploymentOverrides unmarshaled the patched JSON back into the
still-populated target struct. encoding/json merges JSON arrays
element-wise into existing slice elements, so patched list element i
inherited leftover fields from the old element i: an env var added via
a strategic merge override ended up with both value and valueFrom, and
an added secret volume kept the configMap source of the volume it
displaced -- both rejected by the API server. The patch output itself
is correct; only the decode step corrupted it.

Decode into a zeroed value and assign it to the target instead.

The regression was introduced in alexandrevilain#720.
Fixes alexandrevilain#793.
…mitting JSON nulls

The defaulting webhook re-serializes the whole TemporalCluster to compute
its admission patch. Optional pointer, slice and map fields whose json tag
lacked omitempty were serialized as JSON null when left unset (e.g. omitting
spec.authorization.jwtKeyProvider produced
"jwtKeyProvider":{"keySourceURIs":null,"refreshInterval":null}). The
api-server validates the webhook's own output against the CRD structural
schema, which rejects null for non-nullable fields, so an otherwise valid
TemporalCluster could not be applied.

Add omitempty to every +optional pointer, slice and map field in
api/v1beta1 whose tag was missing it. Nil values are now omitted from the
serialized object instead of rendered as null, and CRD-level defaults
(e.g. jobTtlSecondsAfterFinished) can apply as intended.

Value-typed fields (string/bool/int/struct) are deliberately left
untouched: per alexandrevilain#514, omitempty on
defaulter-set value fields (e.g. datastore skipCreate) makes every
serialization drop the field and causes useless mutating webhook patches.

json tags do not affect generated CRDs: make generate and make manifests
produce zero diffs under config/crd/bases.
The chart's bundled CRD aggregate (charts/temporal-operator/crds/
temporal-operator.crds.yaml) is only regenerated by the release-chart
workflow at release time and the result is never committed back, so the
committed copy drifted from config/crd/bases. It predates the overrides
jsonPatch field introduced in alexandrevilain#875 and
also lacks customSearchAttributes, allowSearchAttributeDeletion and
permissiveMetrics, so installing CRDs from the committed chart rejects
or prunes documented fields.

Regenerated with the existing mechanism used by make artifacts/helm:
kustomize build config/crd (kustomize v4.5.7). config/crd/bases itself
was already in sync with the Go types (make manifests is a no-op).
…d mTLS

The operator's own cluster client (used to reconcile TemporalNamespace,
TemporalSchedule, etc.) relied on GetPublicClientAddress, which always
returns the public frontend address when frontend mTLS is enabled — even
when the internal frontend is enabled. On clusters combining frontend
mTLS, authorization and the internal frontend, the operator therefore
dialed the authorized public frontend and required a user-side grant for
its identity, while temporal hard-wires the internal frontend to noop
claim mapper/authorizer precisely for internal callers.

Root cause: the internal frontend is served using the internode mTLS
settings, not the frontend ones (its gRPC server uses the internode TLS
group, whose client CA pool only contains the internode intermediate
CA), so the frontend client certificate can't be used against it —
which is why alexandrevilain#961 added the frontend
mTLS carve-out to GetPublicClientAddress instead of switching
certificates.

Add GetOperatorClientAddress, which always prefers the internal
frontend when enabled and delegates to GetPublicClientAddress
otherwise, and make buildClusterClientOptions use it with a matching
TLS selection: when the internal frontend is enabled, authenticate with
the internode certificate when internode mTLS is enabled (the same
certificate the server's own system worker presents to the internal
frontend) and connect without TLS otherwise, since the internal
frontend serves plaintext when internode mTLS is disabled.

GetPublicClientAddress and the rendered server configuration are left
untouched, preserving alexandrevilain#961 behavior
for every cluster shape without internal frontend, and for the rendered
publicClient stanza in all shapes.

Closes alexandrevilain#957
The ReconcileError condition was only ever set to True by the error
handlers and never reset afterwards. A transient failure (e.g. an
optimistic-lock conflict updating a deployment) therefore stayed
reported as ReconcileError=True forever, even while every subsequent
reconcile succeeded and reported Ready=True and ReconcileSuccess=True,
which is misleading for anything monitoring status conditions.

Following the convention for abnormal-true conditions, the
SetTemporal{Cluster,Namespace,Schedule}ReconcileSuccess helpers now
keep the ReconcileError condition present and flip it to False (with
the success reason and an empty message) whenever a reconcile cycle
succeeds.
The chart's CRD aggregate (charts/temporal-operator/crds/
temporal-operator.crds.yaml) is only regenerated by the release-time
make helm/artifacts targets, so a change under config/crd that forgets
to refresh the aggregate lands silently and the committed chart drifts
from the generated manifests -- exactly the drift fixed in 12d0379.

Add a verify-chart-crds Makefile target that installs the pinned
kustomize (v4.5.7) via the existing installer, builds config/crd and
diffs the result against the committed aggregate, failing with the
regeneration instruction on mismatch. Wire it into the tests workflow
as a chart-crds job running on pull_request and push to main.
Adds operator support for Temporal Server v1.29, v1.30 and v1.31, extending the
supported version range to `>= 1.14.0 < 1.32.0` (default version 1.31.1, default
UI 2.49.1). All >= 1.30 behaviour is version-gated (version.V1_30_0 / V1_31_0);
clusters < 1.30 keep the previous dockerize/curl paths unchanged, so the operator
stays backward compatible across the whole supported range.

v1.29
- Version range/default bumps only. v1.29 is a dynamic-config-only release
  (task-queue fairness, task-queue config API), already covered by the cluster
  dynamicConfig field.

v1.30
- dockerize/auto-setup were removed from the temporalio/server image and config
  templating moved into the server binary (embedded sprig engine). For clusters
  >= 1.30 the operator now emits config templates with the `# enable-template`
  header and sprig `{{ env "X" }}` placeholders (instead of dockerize
  `{{ .Env.X }}`), sets TEMPORAL_SERVER_CONFIG_FILE_PATH, and selects the service
  via the new TEMPORAL_SERVICES env var (legacy SERVICES kept for compatibility).
- curl and jq were removed from the temporalio/admin-tools image, which broke the
  operator's Elasticsearch visibility setup scripts. For clusters >= 1.30 the
  operator now drives ES visibility through the temporal-elasticsearch-tool
  shipped in the image (setup-schema, create-index, update-schema), analogous to
  temporal-sql-tool. Its embedded index template applies all built-in search
  attributes automatically. The MTLS sidecar-shutdown step uses wget instead of
  curl on >= 1.30.
- v1.30.0 has no published GitHub release upstream (silently skipped) and is now
  rejected as a broken release; use v1.30.1+.

v1.31
- New sql.passwordCommand datastore field: resolves the datastore password by
  running an external command (e.g. to generate a short-lived cloud IAM auth
  token for AWS RDS / GCP Cloud SQL). Wired into both the rendered server config
  and the persistence schema-setup jobs (temporal-sql-tool via a shell command
  substitution). Mutually exclusive with passwordSecretRef and validated by the
  webhook (rejected on clusters < 1.31 and when combined with a password secret).

Build / dependencies
- go directive bumped to 1.26.4 with the Dockerfile builder image updated to
  match.
- go.temporal.io/server v1.31.1, go.temporal.io/api v1.62.8,
  go.temporal.io/sdk v1.41.1, plus the associated Kubernetes dependency bumps.

Testing
- make test (unit + envtest) is green, including new unit tests that load the
  generated 1.30 config through the real go.temporal.io/server config loader and
  cover the passwordCommand and ES-tool script rendering.
- Validated end-to-end on a real RKE2 cluster (k8s v1.35): 1.29.7, 1.30.5 and
  1.31.1 clusters each reach Ready=True with a working namespace-create +
  workflow round-trip; the 1.30 sprig/entrypoint contract, passwordCommand auth
  (including webhook rejection paths) and the temporal-elasticsearch-tool ES
  visibility setup (index + v10-v13 built-in search attributes) were exercised
  against real images.
Signed-off-by: Ivan Milchev <ivan@mondoo.com>
PR alexandrevilain#987 extended defaultUpgradePath with 1.29.7/1.30.5/1.31.1, which is
correct, but also moved newDatastoreVersion from 1.24.3 to 1.31.1.

Those two variables play opposite roles: newDatastoreVersion is the
version the cluster is CREATED at (persistence_test.go:110,157,262,307),
and defaultUpgradePath is the sequence it is then upgraded THROUGH. With
both at 1.31.1 the first upgrade step asks for 1.25.2, a six-minor
downgrade that ValidateUpdate rejects via UpgradeConstraint.

Restoring 1.24.3 makes the walk 1.24.3 -> 1.25.2 -> ... -> 1.31.1 again,
so each step is the single-minor increment the constraint allows.
PR alexandrevilain#987 added v1.30.0 to ForbiddenBrokenReleases but missed the other
two retracted releases in the supported range.

Upstream's own go.mod carries the authoritative list:

    retract (
        v1.30.0
        v1.26.1 // Contains retractions only.
        v1.26.0 // Published accidentally.
    )

Confirmed independently: none of the three has a published GitHub
release, while v1.26.2 and v1.30.1 do. Without this, the webhook would
accept a spec.version that has no corresponding container image and the
cluster would sit in ImagePullBackOff.

Also rewords the v1.30.0 comment to cite the retraction rather than the
absent release page, since the retract block is the primary source.
PR alexandrevilain#987 moves the module to go 1.26.4, which the pinned golangci-lint
v1.64.8 cannot lint: the prebuilt binary is built with go1.24.1 and exits
1 with 'package requires newer Go version go1.26 (application built with
go1.24) (typecheck)'. v1.64.8 is the last v1 release, so there is no v1
version to move to - v2 is the only way forward.

- .github/workflows/tests.yaml: GOLANG_CI_VERSION v1.64.8 -> v2.12.2, and
  golangci-lint-action v6 -> v9 (v6 cannot drive a v2 binary). v2.12.2 is
  built with go1.26.2, the same language version as our module.
- Makefile: same version bump, plus the /v2 module suffix that the v2
  install path requires.
- .golangci.yaml: converted by 'golangci-lint migrate'. v2 merges
  gosimple, stylecheck and typecheck into staticcheck, and moves gofmt
  and goimports into a formatters section.

The migration is deliberately signal-neutral - it should not smuggle in
unrelated refactors or suppressions. Two settings restore the v1 scope:

- staticcheck: exclude QF*. v1 applied 'all' to stylecheck, where it
  meant the ST* checks; under the merged linter 'all' also pulls in the
  QF* quickfix suggestions, which v1 never ran (18 findings, all
  pre-existing).
- goconst: ignore-tests. Table-driven tests repeat short literals by
  nature and v1 did not report them (~69 findings, all pre-existing).

Dropped the stale run.go: "1.22" pin so the language version derives
from go.mod rather than silently holding linters to older semantics.

Genuine findings are fixed rather than suppressed:

- pkg/version/version.go: //nolint:stylecheck -> //nolint:staticcheck.
  The old directive silently stopped matching after the merge, which
  un-suppressed ST1003 on all nine V1_x_x constants.
- govet: disable the inline analyzer. It reports 'cannot inline: type
  parameter inference is not yet supported' on generic calls such as
  slices.Contains - the analyzer describing its own limitation, and it
  only started firing at go 1.26.
- prealloc (4) and goconst (1): preallocate the schema-job slices and the
  e2e feature table, and hoist the repeated 0.0.0.0 bind address to a
  constant.

Verified 0 issues both via the prebuilt binary CI uses and via make lint,
which installs from source.
PR alexandrevilain#987 declared Timeout as metav1.Duration - a struct - with omitempty.
omitempty has no effect on structs, so the field always serializes, and
the mutating webhook would emit "timeout":"0s" on every cluster that
uses passwordCommand without setting a timeout. That is the same class of
useless webhook patch churn our 8393ba1 removed from 21 other fields.

Making it *metav1.Duration lets omitempty work, and the consumer in
pkg/temporal/persistence/config.go now only sets the server-side Timeout
when the user actually specified one, so the server applies its own
default rather than receiving a hard 0s.

Regenerating required bumping controller-gen v0.16.3 -> v0.21.0: the old
version cannot build under the go 1.26.4 toolchain alexandrevilain#987 introduces
(golang.org/x/tools v0.24.0 fails to compile). The regenerated output is
almost identical, with one substantive and welcome difference:

  cassandra consistency / serialConsistency: type: integer -> type: string

gocql.Consistency is uint16 underneath but implements MarshalText, so it
serializes as a string, and the field already carried string enums
(ANY, ONE, LOCAL_QUORUM, ...). v0.16.3 typed it from the underlying kind
and produced a schema where those enum values could never validate.
v0.21.0 honours the TextMarshaler and emits the correct type. This fixes
a latent bug for Cassandra users; we run postgres12 so we are unaffected.

Chart CRDs regenerated to match; make verify-chart-crds passes.
Decided on platform-temporal#26: alexandrevilain#987 forces client-go, api and
apimachinery to 0.35.1 because go.temporal.io/server v1.31.1 requires
them, but left controller-runtime at v0.21.0, which targets client-go
0.33.0. Running the layer that drives every reconcile two minors ahead of
its tested client-go is the kind of skew that surfaces as subtle
informer/cache/watch misbehaviour rather than a build error, so we take
the matched pair instead.

v0.23.x is the release paired with client-go 0.35 (v0.24.x pairs with
0.36). The bump also pulled apiextensions-apiserver and component-base
from 0.33.3 to 0.35.0, closing the rest of the skew alexandrevilain#987 left behind.

Two API changes needed handling:

- The webhook builder is now generic: NewWebhookManagedBy takes the object
  and .For() is gone. Migrated to the typed builder rather than the
  deprecated CustomDefaulter/CustomValidator aliases, which let the four
  webhook methods take *v1beta1.TemporalCluster directly and made
  getClusterFromRequest and its five call sites redundant. No coverage is
  lost - no test exercised the wrong-type path it guarded.

- mgr.GetEventRecorderFor is deprecated in favour of GetEventRecorder, but
  the replacement returns events.EventRecorder rather than
  record.EventRecorder. Those are different interfaces, and switching
  moves event emission from the core v1 API group to events.k8s.io/v1 -
  an observable behaviour change that does not belong in a dependency
  bump. Kept with a documented nolint; there is a single Event call site
  (controllers/temporalcluster_controller.go:268) so the migration is
  cheap whenever we choose to do it.

Verified with a cold golangci-lint cache: a warm cache reported both a
false 0-issues result and a false 'unused nolint directive'.
…cripts

Three defects in the temporal-elasticsearch-tool path added for >= 1.30, the
worst of which reports success while doing nothing.

An empty Elasticsearch username rendered a bare "--user". argsMapToString
renders an empty value as a flag with no value, and the tool's parser
(urfave/cli v1 over the stdlib flag package) takes the *following* token as a
string flag's value. For the setup script that token is the "setup-schema"
subcommand itself, so the tool ran no command at all, printed its help and
exited 0 -- the schema job was recorded successful while neither the index
template nor the visibility index was ever created, and the failure only
surfaced later as query errors from the frontend. Verified directly against
go.temporal.io/server v1.31.1's BuildCLIOptions: with a bare --user the
"user" flag comes back as "setup-schema" and no command action fires.

An empty username is what an auth-less Elasticsearch needs and the CRD permits
it (username is required but has no minimum length), so this was reachable.
The same rendering hazard existed for the SQL and Cassandra --user flags; those
fail loudly rather than silently, but they are guarded here too.

"set -eu" in both new templates could skip the shared "scripts" footer. Every
other template deliberately omits set -e so that footer always runs. With ES
visibility plus a linkerd or istio provider, a failing tool invocation exited
immediately and the sidecar was never told to shut down, leaving the Job pod
Running indefinitely instead of failing and retrying -- and persistence
reconciliation blocked on that job forever. The steps are now chained with &&,
which keeps fail-fast behaviour while leaving $? for the footer to propagate.
Chaining is safe because create-index is idempotent: the tool treats
resource_already_exists_exception as success.

The wget shutdown branches appended "|| true" while the curl branches did not.
Since $x is captured before the shutdown call, that could only ever matter
under set -e; with set -e gone it just hid an unreachable proxy, so both
providers now behave identically. Checked the image rather than assuming:
temporalio/admin-tools:1.31.1 ships BusyBox v1.37.0 wget (which supports
--post-data) and no curl at all.

Finally, getStoreTool no longer returns "temporal-elasticsearch-tool"
unconditionally. That binary does not exist in admin-tools <= 1.29, and the
>= 1.30 gate was duplicated in two callers, so any third caller would have
emitted a command that cannot run. The gate now lives in one helper and the
empty sentinel is restored for older versions.
…sking for it

Merging the existing pod template metadata first, as the preserve-annotations
change did, made every operator-managed key permanent. The feature helpers
return an *empty* map when their feature is disabled rather than a removal
signal, so nothing they had previously set could ever be deleted.

Concretely: create a cluster with spec.mTLS.provider istio, then remove the
mTLS block. istio.GetLabels/GetAnnotations return {}, so
`sidecar.istio.io/inject: "true"` and `proxy.istio.io/config` survived from the
existing template and istio kept injecting sidecars indefinitely. The same held
for `linkerd.io/inject` after a provider switch, and for the `prometheus.io/*`
scrape annotations after setting spec.metrics.enabled to false.

Keys under the metadata namespaces this operator computes are now dropped from
the existing template before the freshly computed set is overlaid. Everything
else is still preserved untouched, which is the whole point of merging:
`kubectl.kubernetes.io/restartedAt` and other externally-written annotations
must survive reconciliation.

Prefixes are used rather than an exact key list so that a helper gaining a new
key (a fifth prometheus.io annotation, say) does not silently reintroduce the
bug.

The existing tests only covered the additive direction; tests are added for
removal, for the enabled case still working, and for a stale version label.
… integers

normalizeJSONNumbers had two problems, the second reintroducing the very bug
it exists to prevent.

It narrowed to int unconditionally, so on a 32-bit build a value such as
5368709120 (a 5 GiB blob-size limit) wrapped to a different number, and that
number was written into the dynamic_config.yaml ConfigMap. Values that do not
fit now stay int64; values that do are still int, which is what yaml.v3
produces when it unmarshals the config back and what keeps the reconciliation
deep-equal comparison stable.

More importantly, a value written in exponent form -- `limit.blobSize.error:
1e9`, valid JSON -- is not parseable by json.Number.Int64, so it fell through
to Float64 and yaml.v3 wrote it back as "1e+09". Temporal's file-based dynamic
config client rejects scientific notation for a setting that expects an
integer, which is exactly the failure this normalisation was added to avoid.
Integral values are now converted back to an integer type.

Note that returning the number's original text instead would not help: that
emits a quoted YAML string, which Temporal rejects just the same. Text is kept
only for values too precise for float64, where the alternative is silently
dropping digits.
The webhook's rejection message for a broken release suggested IncPatch(), but
broken releases can be consecutive: v1.26.0 and v1.26.1 are both retracted
upstream, as are v1.21.0 and v1.21.1. A user applying 1.26.0 was told to move
to 1.26.1 and was then rejected again, with no hint that 1.26.2 is the real
target. NextNonBrokenPatch skips any candidate that is itself forbidden.

Also memoize the compiled semver constraints behind GreaterOrEqual and
LessThan. Both formatted a constraint string and recompiled it on every call,
and they are called repeatedly within a single reconcile -- once per datastore,
once per deployment builder, once per config section -- nearly always against
the same handful of package-level version constants. The previous code also
discarded the parse error, which would have made Check dereference a nil
*Constraints had the format string ever changed.
…asswordCommand

The existing test asserted the buggy suggestion (1.21.0 -> 1.21.1, itself a
forbidden release); it now asserts 1.21.2.

Also warn when sql.passwordCommand is set. The field works for the server pods,
which resolve the password natively, but the persistence schema jobs run the
same command inside the admin-tools image and that image cannot be extended:
SchemaJobBuilder.Build hardcodes the pod's volumes to the scripts ConfigMap
plus datastore TLS, and exposes only JobInitContainers/JobResources/
JobTTLSecondsAfterFinished -- an init container has no shared writable volume
through which to hand a binary over.

So for the documented use case, an RDS or Cloud SQL IAM token helper, the
command is not found, the substitution yields an empty string, and the first
create-database job fails with a password-authentication error that is hard to
attribute. Users get told this up front instead of discovering it there.
Giving the schema jobs pod-level overrides is a feature in its own right and is
tracked separately.
The path /etc/temporal/config/config_template.yaml was written out three times
-- as the TEMPORAL_SERVER_CONFIG_FILE_PATH value, as the config volumeMount's
MountPath, and as its SubPath -- plus a fourth time as the ConfigMap key in the
config builder. Changing the mount path or the key in one place would leave the
others pointing at a file that does not exist, and on Temporal >= 1.30 (which
has no fixed built-in location and relies on the env var) the server would exit
at startup with "could not read config file". Nothing links the four at compile
time and no test covers the pairing.
The 1.30 and 1.31 entries each stated a default Temporal/UI version and a
supported range, so the Unreleased section named two different sets of defaults
in consecutive bullets. Only the 1.31 values match temporalcluster_defaults.go;
the 1.30 entry now describes the mechanism change without restating a
superseded default.

Document the CRD schema change that came in with the controller-gen v0.16.3 ->
v0.21.0 bump: cassandra.consistency and cassandra.serialConsistency move from
type: integer to type: string. This is a fix -- the old schema declared
type: integer alongside string enum values, so no value could ever validate,
and gocql.Consistency has always marshalled as text -- but it is a schema
change to pre-existing user-facing fields, and it was bundled inside a tooling
bump in a PR about 1.29/1.30/1.31 support. It belongs in the changelog.

Also record the known limitation of sql.passwordCommand in the schema jobs, and
add a Fixes section for the pod-metadata, broken-release-suggestion and
dynamic-config-integer defects.

Finally, replace the assertion in the goconst comment with the evidence for it.
The claim that v1 did not report these findings is testable: the v1 config
carried no test exclusions at all (only zz_generated), the lint job was green
on upstream main 1398896 with v1.64.8, and the files this setting affects
already existed at that commit. So this restores v1's signal rather than
suppressing it.
The lint job restores a golangci-lint cache whose contents change the verdict.
Two runs of this workflow over a byte-identical main.go disagreed: the run that
populated the cache reported no issues, and the next run, which restored it,
reported both `//nolint:staticcheck // SA1019` directives in main.go as unused.

The cached verdict is the wrong one. Deleting those directives and running cold
shows SA1019 firing at main.go:116 and main.go:136 (`mgr.GetEventRecorderFor`
is deprecated in controller-runtime v0.23), so the directives are used and
necessary. The cache fails in the other direction too: a warm local cache
reported "0 issues" on a tree that genuinely had one, which is the more
dangerous failure since it is silent.

setup-go already runs with cache: false in this job. The cached run still took
130s, so the cache buys roughly nothing while making lint results depend on
which commit last populated it.
…rgets

TestPersistence is about to go from 8 version-steps to 35 as the skip filter
that hid five of its six cases is removed. Measured against the current suite,
one case (cassandra, 8 steps) takes 308-412s depending on runner, and the whole
e2e step takes ~18m; the extra cases push the package well past the old 60m
budget's comfortable margin. E2E_TIMEOUT defaults to 90m and is overridable.

Also introduce CONTAINER_TOOL for the local-development targets. It prefers
podman when installed and falls back to docker, so a podman-only machine works
without extra flags while CI, which has docker, is unaffected.
TestPersistence has skipped all but "cassandra persistence" since 624c28f
(2024-12-01). That left the pure-SQL upgrade paths -- the ones most deployments
actually use -- completely unexercised. The cassandra case does use postgres12
for its *visibility* store, so SQL visibility migrations had incidental
coverage, but nothing verified a SQL *default* store surviving an upgrade,
which is the shape this operator is most often deployed in.

Removing the filter turns on five more cases and takes the suite from 8
version-steps to 35.

One of those five could never have passed. "postgres persistence with ES
advanced visibility" set spec.persistence.advancedVisibilityStore while
creating the cluster at 1.24.3, and the webhook has forbidden that field for
clusters >= 1.24 since 84722d5 (2024-09-26) -- Temporal 1.24 folded "advanced
visibility" into plain visibility. Admission would have rejected it. The skip
filter landed later and hid it, so the case has been both dead and invalid.

It is revived in the supported shape, Elasticsearch as the visibility store,
and moved to defaultVersion (>= 1.30) rather than 1.24.3. That is where the ES
code actually needs coverage: admin-tools >= 1.30 dropped curl and jq, so the
operator drives Elasticsearch through temporal-elasticsearch-tool, and that
path had no end-to-end coverage at all despite being new. Nothing is lost by
not exercising the older curl path here, since this case has not run since 2024.

All six cases were checked against the real validating webhook at their
creation version and at every rung of their upgrade path before this change:
zero rejections.

Also drop a dead branch in deployAndWaitForTemporalWithPostgres whose two arms
assigned the same plugin name, and preallocate featureTable now that the loop
appends unconditionally.
The table stopped at 1.26, so nothing pinned the tag scheme for the versions
this fork just added support for. 1.30 is the interesting one: it is where the
admin-tools image was stripped to bare alpine and gained
temporal-elasticsearch-tool, but the tag naming is unchanged, so the major.minor
rule still applies and should stay asserted.
The first run of the full persistence matrix surfaced two failures. Neither is
caused by the operator changes in this branch; both are exposed by running 35
upgrade steps where only 8 ran before.

AssertClusterCanHandleWorkflows port-forwards to the frontend and runs a
workflow with no retry. A cluster reporting Ready does not guarantee the
frontend Service has stopped routing to a pod still terminating from the
rolling update, so this can fail transiently. It did, once, on one of four
Kubernetes versions, at the very first rung of the legacy postgres path. At 8
upgrade steps a per-step failure rate that small goes unnoticed; at 35 it is a
regularly red build. The connect-worker-workflow cycle now returns an error
instead of failing the test, and is retried for up to a minute.

mysql8 is a real failure, so it is documented and bounded rather than papered
over. Upgrading a mysql8 cluster to 1.29.7 leaves it permanently un-Ready: the
operator updates every Deployment successfully and the pods then never reach
Ready, timing out the 600s wait. It reproduced on all four Kubernetes versions.
It is not flakiness or resource exhaustion -- in two of the four jobs mysql8 ran
second, on a barely loaded node, and still failed at exactly that rung, while
postgres ran last in those same jobs and passed.

Three candidate causes were ruled out directly rather than by argument:

  - the schema migration. Running the real 1.17 -> 1.18 mysql8 update
    (v1.18/tasks_v2.sql) with temporal-sql-tool from admin-tools:1.29 against
    MySQL 8.4.11 succeeds cleanly.
  - the server. temporalio/auto-setup:1.29.7 with DB=mysql8 starts and serves
    against that same MySQL.
  - the migration content. mysql8 and postgresql12 receive the same
    1.17 -> 1.18 migration, and postgres12 walks the entire path fine.

Root-causing it needs the failing pods' logs, which the e2e artifacts do not
capture: kind exports logs after the test namespace is torn down, so the
namespace is already gone. Rather than block the SQL coverage this change
exists to provide, mysql8 is held at the last version it is known to reach.
Restore defaultUpgradePath there once the 1.29 failure is understood.
@linvald
linvald merged commit c972610 into main Aug 31, 2026
10 checks passed
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.

6 participants