feat(provider): model the Cozystack 1.6 kubernetes and tenant surface - #25
feat(provider): model the Cozystack 1.6 kubernetes and tenant surface#25Aleksei Sviridkin (lexfrei) wants to merge 24 commits into
Conversation
Cozystack 1.6 moved tenant worker nodes from Ubuntu container disks to Talos, and the image coordinates became part of the Kubernetes spec. Left unmodelled, an operator has no way to point workers at a self-hosted image factory or a custom schematic without dropping out of Terraform. None of the four fields carries a provider-side default. Upstream moves the Talos release and the tested schematic ID with every platform release, so a materialised default would pin a cluster to whatever was current when the provider was built and silently detach it from the platform's rolling value. Unset fields are omitted from the emitted spec instead; the aggregated apiserver materialises its own defaults on every read, so state still reports the effective value. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The 1.6 chart remediates worker nodes through a MachineHealthCheck whose tolerance and startup timeout are now part of the Kubernetes spec. Slow first boots — a Talos image pulled from the image factory onto a busy StorageClass — otherwise trip remediation and put the node group into a reboot loop with no way to raise the timeout from Terraform. Both fields are omitted from the spec while unset, so the platform's own tuning stays in force and moves with it. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Without this block the only way into a tenant cluster is the static admin kubeconfig, which cannot be scoped per person and cannot be revoked individually. Modelling it lets an operator turn on platform identity and declare the per-user bindings in the same place the cluster is declared. mode and the user roles are validated against the upstream enums, so a typo fails at plan time rather than as a chart render error. custom_config takes either an inline AuthenticationConfiguration or a Secret reference and the two conflict, matching the chart, which reads only one. users keeps its presence distinction: an explicitly empty list binds nobody, an unset attribute leaves the platform default in place. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
A node group that boots slower than the rest — a GPU pool pulling a large Talos image, say — needs a longer startup timeout than the cluster-wide one, and 1.6 added the per-group knobs for exactly that. Both are undefaulted upstream, where an absent key means "inherit the cluster-wide value", so they are written only when set rather than as empty strings. The node group's resources block now binds cpu and memory together. Upstream sizes the node by instanceType unless both are set, because KubeVirt cannot override an instance type's CPU and memory, and rejects a half-filled block at render time; the schema turns that into a plan-time error instead of a failed apply. The shared resources helper keeps its own any-field-wins semantics for the kinds that want it. The new model round-trip test binds the whole fixture through the resource schema, which is what catches a tfsdk tag that names no attribute — a failure mode the expand and flatten tests never reach. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Cozystack 1.6 removed v1.30 from the Kubernetes version enum. The provider still advertised it, so a config naming v1.30 planned cleanly and then failed against the server — the worst shape of validation error, since it surfaces after the practitioner has already committed to the apply. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
controlPlane was unmanaged on purpose while it held nothing but component sizing. 1.6 added the apiServer passthrough — extra flags, extra volumes, extra volume mounts — which is the only supported way to hand the tenant kube-apiserver a feature gate or an AuthenticationConfiguration file. That is worth managing; the sizing, replica count, konnectivity and scheduler blocks stay with the server and are recorded as deliberate omissions in the new nested coverage guard. The two volume lists are free-form core/v1 objects upstream, so they travel as normalized JSON strings rather than a hand-modelled Volume schema that would need chasing every core/v1 addition. The attribute description carries the upstream warning about hand-rolled --oidc-* flags, which make the apiserver refuse to start when the chart also injects --authentication-config. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
An air-gapped or rate-limited environment needs the tenant-side images pulled from a mirror, and 1.6 exposes all three of them — the bootstrap kubectl Job, the talos-csr-signer sidecar, the wait-for-kubeconfig init container. Until now the whole block was unmanaged, which meant no mirror without editing the release by hand. No field carries a provider-side default. Upstream treats an empty value as "use the tag this chart shipped with", and that tag moves every release, so writing one from the provider would hold a cluster on an image the platform has already replaced. With controlPlane and images now modelled, the top-level coverage guard omits only addons. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
gateway landed in the pinned API, so the coverage guard now demands it and the attribute stops being a speculative passthrough. Its encoding is the subtle part and had no test: the chart distinguishes "unset" from "explicitly off" by whether the key is present, not by its value. An unset attribute therefore has to leave the key out — writing `gateway: null` fails the schema generated from the field's own documentation, and writing `false` silently takes every derived-apex tenant off the auto-enable path. The behaviour was already right; this makes it a contract. The table test covers all four states, a second test covers the read direction, and the attribute description now explains what the platform decides on the operator's behalf instead of describing the field as version-gated. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Upstream marks the cluster storageClass immutable with a CEL rule, but the aggregated apiserver does not evaluate CEL rules embedded in an application schema, so the write is accepted. That is worse than a rejection: apply reports success, state records the new class, and every existing volume stays on the old one, because a PersistentVolumeClaim's class is fixed at creation and editing a StatefulSet's volumeClaimTemplates never migrates data. The divergence is permanent and invisible. Planning a replacement makes the cost of the change visible before it happens, which is the only place a practitioner can still decide against it. The per-node-group storageClass is deliberately left mutable, matching upstream, which does not mark it immutable precisely because a strict rule would block setting an optional field for the first time. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The examples now show what each new block is for rather than listing every attribute: a node group sized by explicit cpu/memory, a slow GPU pool overriding only its own remediation timeout, a self-hosted image factory with the Talos release still left to the platform, platform identity with user bindings, a bring-your-own issuer with the apiserver passthrough, and mirrored images. The tenant example covers the case that motivates the gateway attribute at all — a custom apex that wants a Gateway anyway, which the platform would not enable on its own. Both example files were checked against the real schema with `tofu validate`, and the resource description no longer claims the control-plane and image blocks are unmanaged. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The 1.6 blocks deliberately carry no provider-side defaults, which only works because the aggregated apiserver materialises the schema defaults on every read. That assumption is invisible to unit tests — they never talk to a server — so the acceptance test now pins it: a cluster that pins nothing must still report a Talos release, a health-check tolerance, and an OIDC mode. The negative checks matter as much. A node group's health overrides are undefaulted upstream and must stay absent, and an unset tenant gateway must stay absent so the platform keeps deciding. Either one turning into an empty string or a false would be the encoding quietly collapsing. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The package splits its name constants two ways: attr* when the Terraform attribute and the spec key are the same word, spec* when the camelCase form differs. Three of the new blocks fell on the attr* side but were named spec*, which reads as though the schema were keyed by a spec name. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The unset-block contract held only until the first update. Those blocks are Optional+Computed so the platform's effective values can land in state, and Terraform copies prior state into the plan wherever the configuration is silent — so expanding the plan alone sent the platform's own defaults back as explicit spec keys. From that write on, the release carried talos.version and the schematic ID, the chart's defaults no longer applied, and the cluster sat frozen on whatever was current the day something unrelated changed. Nothing in the plan showed it. Models can now take the configuration as the authority for the attributes they nominate, through an optional hook the shared Create/Update path calls before expand. Only cozystack_kubernetes uses it, and only for the five nested blocks; every other attribute still expands from the plan, where its materialised default belongs. The engine refuses to run a model that wants the configuration through a path that cannot supply it, rather than silently expanding the plan. Two smaller cases of the same "emit only what was asked for" rule: an oidc secretRef with no name, and a controlPlane with no apiServer, no longer travel as empty objects. The unit tests pin the round trip that was missing — a plan that only echoes a server response must still expand to no keys — and the acceptance test now runs a second apply, the one that used to do the pinning, and reads the release back to prove the keys stayed out. The JSON-document list helpers move to spec.go, where the other spec helpers live; they are not kubernetes-specific. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
An unconditional RequiresReplace reads the plan, and the plan is not only what the practitioner wrote. Import a cluster that runs on a non-default class with a configuration that never mentions storage_class, and the schema default pulls the plan back to "replicated" — which the modifier then turns into a destroy and recreate of a cluster, from a configuration that says nothing about storage at all. Keying the replacement to a configured value keeps the protection where it belongs: a practitioner asking for a different class still gets the replacement, because the alternative is an apply that reports success while every existing volume stays on the old class. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Making the blocks Optional+Computed so the platform's effective values could be read off the resource was the wrong trade. Terraform copies prior state into the plan wherever the configuration is silent, so those values came back as the plan and the next update wrote them into the release as explicit keys — the cluster froze on the Talos release and schematic that were current that day, with nothing in the plan to show it. Reading the request from the configuration instead fixed the freeze but broke the opposite motion: deleting a pinned line left the plan asserting the old value while the apply sent nothing, and Terraform failed the apply with an inconsistent-result error telling the operator to report a provider bug. Both symptoms come from asking one attribute to be two things. The resource now tracks only what the configuration sets: unset fields stay out of the request and out of state, so the platform's default applies and keeps moving with the platform, and a configured field still refreshes, so drift against it is still planned away. The data source is where the effective values — including the ones nobody configured — are read; it keeps reporting the whole surface, because that is what a data source is for. This drops the plan/config divergence entirely, so the hook added to the shared Create/Update path goes away with it and the engine is back to expanding the plan. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The presence-preserving section of spec.go exists to pin the difference between an absent key and an empty value, and every helper in it has a test saying so — except the two JSON-document ones, whose only exercise was the populated path through a kubernetes test. The invalid-document branch is reachable from configuration, not just defensive: `extra_volumes = [null]` decodes to a null value whose string form is empty, and the unmarshal fails. The diagnostic named the spec key but not the element, so a practitioner with six volumes got "unexpected end of JSON input" and nowhere to look; it now names the index. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The attribute defaulted to "replicated" in the provider, which meant any cluster running on another class with a configuration that stays silent about storage got its plan pulled back to "replicated". The server accepts that write — it does not evaluate the upstream immutability rule — and the PersistentVolumeClaims stay where they are, so state and reality part ways for good. Dropping the default closes the path: an unset attribute leaves the key out and the platform supplies "replicated" itself, and the plan keeps whatever the cluster already runs on. Two more cases of the same rule, that a key nobody wrote should not be sent. A node group with `roles = []` now reaches the server as an empty list instead of an omitted key; collapsing the two made the read return null against a plan holding an empty list, which fails the apply on a perfectly valid configuration. And the control-plane block now says plainly that configuring it rewrites the whole control-plane section, so the sizing and replica count it does not model go back to platform defaults — those cannot be named from the configuration, so the usual "name it to keep it" escape does not exist there. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Three small corrections and one test. The storage_class comment claimed the key stays out for the life of the resource; it stays out on create, and on later applies the plan holds what the last read reported and writes it back — a no-op, but the comment said something else. keepConfiguredAttributes returned a configured value verbatim when the server did not report the block; on a 1.6 cluster that cannot happen, but against an older one it would put a possibly-unknown value into state, so an unreported attribute now trims to null like any other. And oidc.custom_config.secret_ref exists to name a Secret, so `name` is required inside it rather than silently expanding to nothing. The new round trip sends the full model through expand and back through flatten. Expand's keys are guarded against the upstream json tags, but flatten's are hand-written in both the code and the tests, so a matched typo on both sides would have passed everything else here. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
`node_groups = {}` is the configuration upstream defaults to and documents:
the chart renders a single md0 that provisions nothing until an
unschedulable Pod brings the autoscaler in. The provider could not express
it. The attribute is required, so the plan holds an empty map, while the
shared map flatten collapses an empty map to null — the apply failed with
an inconsistent-result error on a configuration the platform supports, and
the provider's own example used it.
An empty map now reads back as an empty map, while an absent key still
reads as null, since that is a server that did not report the field rather
than a cluster with no groups.
While trimming the platform blocks, the null built for an attribute the
server left out now comes from the attribute's own type instead of a switch
over the shapes those blocks happen to use today; a bool or a number added
to one of them would otherwise have produced a null string and panicked.
The acceptance test grows a step for the empty-list contract. Whether an
explicitly empty list survives the round trip is a question only a real
server can answer — the unit tests echo the provider's own request back.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The description promised behaviour the chart does not implement: that an unset key auto-enables a Gateway for a tenant whose apex derives from its parent, and disables it for a custom apex. The chart's helper resolves a missing key to false, unconditionally, and never reads `host` — the tenant simply gets no Gateway of its own and inherits its nearest ancestor's, falling back to Ingress if no ancestor owns one. The custom-apex tenant is the one that must ask for `true`, because the ancestor's certificate does not cover its apex. The wrong story came from upstream's own field comment, which the provider had copied verbatim. The encoding is unchanged and still correct: absent and false deploy the same thing today, but the chart branches on the key being missing rather than on a null, and the absence is what records that nobody chose. Two more places where the words claimed more than the code does. The control-plane block said out-of-band values are lost "once this block is in play"; the provider replaces the whole spec on every update, so they are lost either way. And an acceptance step described coverage for an empty node-group map that it did not have — it has it now, as its own step, because the map is exactly the case the unit tests cannot decide. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Upstream carries three different stories about this key. The field comment in the pinned api module says an unset key auto-enables the Gateway for a derived apex; the chart's own test suite says false is an opt-out that parks the tenant off its ancestor's Gateway while unset inherits; and the templates in that same release resolve both to the identical render, since the helper collapses absent and false before namespace.yaml ever reads it. The last statement is the one with a mechanism behind it, but it is a statement about today's templates, not about the contract — and betting a practitioner's tenant on it is what the previous wording did. The description now says what the provider does and what upstream means by each state, and steers toward leaving the key absent rather than writing false, which is the choice that cannot be wrong under either reading. Three smaller corrections in the same pass. The kubernetes API-server passthrough reserves `authentication-config` too, whenever OIDC is on, and the chart fails the render on it. The tenant data source called the inherited Gateway a per-tenant controller. And the flagship kubernetes example pinned storage_class to the value the schema had just stopped defaulting — pinning it is now the one thing that makes a later change a cluster replacement, which is not what a first example should teach. The empty-list acceptance step also covers oidc.users now, the other list whose empty form the provider claims to preserve. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Upstream says three different things about this key. The generated field comment says an unset key auto-enables the Gateway for a derived apex; a chart test comment says false parks the tenant off its ancestor's Gateway; the helper that actually decides says neither. Two rewrites of this paragraph have now tracked two of those stories, so this one follows the helper and nothing else. tenant.gatewayEffective returns true only for an explicit true, and its own header states what false means: the tenant does not skip Gateway routing, it attaches its published Routes to the nearest ancestor that owns a Gateway, exactly as ingress already inherits. So ownership is what the flag buys, and in this release false and an absent key buy the same nothing. The description now says that, and says which one this provider sends. The rest of the pass: the schema said out-of-band values are dropped after an import, when the provider replaces the whole spec on every update and drops them whichever way they were set; the resource flatten paired its blocks across two parallel slices, where a sixth block added to one and not the other would trim against its neighbour; and the data-source binding — the surface behind the documented talos.version output — had no test. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The oidc.users description said an empty list "is not the same as leaving the attribute unset", and two comments said the same of a node group's roles. On the cluster they are the same: the chart ranges over both lists, and the platform defaults users to empty, so neither can tell an empty list from a missing key. What the empty list actually buys is on the Terraform side — the plan holds an empty list, so the read has to return one. The group's genuinely presence-sensitive keys are maxUnhealthy and nodeStartupTimeout, which the chart reads with hasKey and falls back to the cluster-wide values for. That is where the argument holds, and where it now lives. Also spelled out, next to the snapshot it depends on, that the resource flatten captures the configured blocks before the embedded flatten overwrites them — the ordering is the mechanism, and a refactor that moved one line past the other would invert it silently. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
IvanHunters
left a comment
There was a problem hiding this comment.
Verdict
LGTM with non-blocking notes
The provider code is correct and internally consistent, the no-provider-default design is the right shape for the roll-with-the-platform fields and is well unit-tested; the only residual risks are two server-side behaviours that cannot be proven without a live 1.6 cluster (which the PR already defers), plus an inherited-red base.
Caveats
-
Base branch is red, but this PR adds nothing to it. At the merge-base
583e1a9(thev1.4.3 -> v1.6.1api bump, the tip offeat/cozystack-1.6),TestNatsExpandKeysMatchConfigSpec,TestQdrantExpandKeysMatchConfigSpec,TestPostgresqlExpandKeysMatchConfigSpec,TestKafkaExpandKeysMatchConfigSpecalready fail withConfigSpec has "tls" but expand does not emit it. Verified by checking out583e1a9and running those four guards: identical failures, and this PR's diff touches none of the four*_model.gofiles nortls. They are owned by the sibling tls PR (feat/cozystack-1.6-tls). Zero new failures from this PR, but the integration branch stays red until the tls PR lands. -
Two correctness properties are server-side and were not (could not be) executed here; both are unit-tested against synthetic server responses but rest on how the live aggregated apiserver actually behaves:
- Block-trimming vs pinning (
keepConfiguredAttributes,kubernetes_model.go:139): the design assumes the aggregated apiserver materialises thetalos/nodeHealthCheck/oidc/controlPlane/imagesdefaults on read and that trimming to configured fields keeps them out of state.TestKubernetesResourceFlatten_KeepsUnconfiguredBlocksOut/_RefreshesConfiguredFieldsOnly/_TrimsNestedObjectsexercise the flatten against a server view that includes materialised values, so the provider side is verified; what remains unverified is that the real server materialises exactly that shape. - Gateway three-state (
tenant_model.go:77-105): relies on the tenant apiserver NOT materialising agatewaydefault on the create response — otherwise a first apply withgatewayunset would flatten tofalseagainst a null plan and fail with "provider produced inconsistent result after apply".TestFlatten_GatewayAbsentStaysNullproves the provider maps an absent key to null, but not that the server ever returns it absent. This is the exact class the fourdocs(tenant)commits wrestled with; presence-reliance is correct only if the tenant chart readsgatewaywithhasKeyand does not default it.
- Block-trimming vs pinning (
Recommended follow-ups
- Run the new acceptance steps (the update-does-not-pin case and the empty-collection cases) against a live 1.6 cluster before the integration PR reaches
master; they are the authoritative check for both server-side assumptions above and are the only unchecked box in the PR's own Testing section. controlPlane.apiServerextra_volumes/extra_volume_mounts andoidc.usersare refreshed from the server value on a configured block (keepConfiguredAttributestakesfromServerfor a non-null list). If the aggregated apiserver defaults fields inside those free-form passthrough objects, the refreshed value could diverge from config and surface as inconsistent-result-after-apply or perpetual drift.jsontypes.Normalizedabsorbs whitespace/key-order but not added keys. Worth an explicit acc assertion on round-trip of a populatedextra_volumes.- Consider marking
oidc.custom_config.config(kubernetes_schema.go:243)Sensitive: truefor defence-in-depth. An apiserverAuthenticationConfigurationnormally carries no secret (issuer, audiences, claim mappings, CA), so this is optional, butkubeconfigis alreadySensitiveand an inline config is a plausible place for a tenant to paste a bearer. - Dropping
v1.30from the version validator (kubernetes_schema.go:439) is a plan-time breaking change for anyone whose config literally namesversion = "v1.30"; it is correct for a 1.6-targeting provider and documented in the PR body's Breaking-changes section, but the commit/PR title carry no Conventional-Commits!. Confirm the consolidated integration PR tomastercarries the breaking marker.
Checked and correct
storage_classgenuinely wiresRequiresReplaceIfConfigured()(kubernetes_schema.go:430), not just prose; provider-sidereplicateddefault removed;TestKubernetesStorageClassRequiresReplaceandTestKubernetesExpand_StorageClassOmittedWhenUnsetcover it.client.Updatedoes a fullspecreplace (internal/client/application.go:121, unchanged by this PR), so the whole-spec-replace behaviour the schema description documents is real.- Resource uses the trimming
flatten(kubernetesResourceModel), data source uses the fullflatten(kubernetesModel); confirmed atprovider.go:248,289. Method shadowing over the embedded model is correct. node_groups = {}androles = []regressions have targeted tests (TestKubernetesFlatten_EmptyNodeGroupsStayEmpty,TestKubernetesExpand_NodeGroupRolesThreeStates,TestKubernetesFlatten_NodeGroupRolesKeepEmptyList); the presence-preserving helpers inspec.goback them.- cpu/memory both-or-neither is a plan-time
AlsoRequirespair (kubernetes_schema.go:79-99), tested byTestKubernetesNodeGroupResourcesRequireBothOrNeither. - The
*ExpandKeysMatchConfigSpec/*KeysMatch*Specdrift guards are a strong regression harness against the pinned api module and are exactly what surfaces the tls gap. go build ./...andgo vet ./internal/provider/...clean; docs and examples regenerated and cover every new block.
Pull Request
Summary
Models the Cozystack 1.6 surface on the
kubernetesandtenantkinds: newtalos,node_health_check,oidc,control_plane.api_serverandimagesblocks, per-node-group health overrides, thetenant.gatewayflag, and thev1.30removal from the version validator. All three spec-coverage guards that went red after the v1.6.1 pin bump are green again through modelling; no omit list grew.Defaults design: no provider-side default on any new 1.6 field, and the resource stores only what the configuration sets. The aggregated apiserver materialises chart defaults on every read, so a resource that stored them would write them back as explicit spec keys on the next update and silently pin the cluster to the Talos release and schematic of creation day. The data source still reports the full effective surface.
Breaking changes:
version = "v1.30"now fails at plan time; a configuredstorage_classchange now replaces the cluster (the server accepts the write but the PVC never migrates);storage_classlost its provider-sidereplicateddefault for the same reason.Changes
talos,node_health_check,oidc,control_plane.api_serverandimageson the kubernetes resource and data source, each with a nested drift guardmax_unhealthyandnode_startup_timeout, bindresources.cpu/memoryboth-or-neither at plan time, fixnode_groups = {}androles = []failing applytenant.gatewaythree-state contract with tests and corrected descriptionsstorage_class: RequiresReplaceIfConfigured, provider default removed,v1.30dropped from the version validatorTesting
make test): green except the 4 tls spec-coverage guards inherited from the integration base, owned by the sibling tls PR. Identical at the merge base, zero new failures.make lint)make docsproduces no diff)make testacc), if applicable: new acc steps added for the update-does-not-pin case and the empty-collection cases, they need a live 1.6 clusterDocumentation
examples/make docs)Checklist
type(scope): description)Additional Notes
Targets the
feat/cozystack-1.6integration branch, notmaster.