Skip to content

feat(console): mark immutable resource fields as read-only in edit forms#6

Open
lexfrei wants to merge 6 commits into
mainfrom
claude/magical-meitner-7db476
Open

feat(console): mark immutable resource fields as read-only in edit forms#6
lexfrei wants to merge 6 commits into
mainfrom
claude/magical-meitner-7db476

Conversation

@lexfrei
Copy link
Copy Markdown
Contributor

@lexfrei lexfrei commented May 12, 2026

Summary

Resource fields declared immutable in the schema via x-kubernetes-validations: [{rule: "self == oldSelf"}] are now:

  • Rendered grey-out with the helper text "This field cannot be changed after creation." on edit screens.
  • Overlaid with the persisted value before PUT so the admission server observes no delta on those paths and the CEL rule is never tripped — defence-in-depth against the YAML editor, devtools, or RJSF bugs bypassing the read-only state.

Create flows are untouched.

Acceptance criterion

The companion cozystack/cozystack#2639 marks MariaDB storageClass immutable (itself dependent on cozystack/cozyvalues-gen#24). With all three PRs in place, opening an existing MariaDB instance for edit grays out storageClass, shows the helper text, keeps the rest editable, and the outgoing PUT carries the original storageClass value.

What's in this PR

  • New helpers in apps/console/src/lib/immutable-paths.ts: findImmutablePaths walks the schema tree and returns every path carrying the CEL rule. overlayImmutable copies the original spec's values along those paths into the submitted body. Wildcard semantics: whole-array/whole-map (*-last) → clone from source; per-element nested (*-not-last) → overlay shared indices/keys, keep user adds, respect deletes. Length shrink with a nested-immutable path bails out with a console.warn (admission stays authoritative). Top-level wildcard paths are handled like root-immutable: warn + clone source.
  • apps/console/src/lib/prepare-update.ts — pure wrapper; warns on malformed schema; short-circuits to a clone when persisted spec is null/undefined.
  • SchemaForm accepts immutableMode (enforce | off, default off). When enforcing, walks the schema and emits ui:disabled + ui:help per immutable path. For object maps (additionalProperties) the field itself is marked disabled — per-entry-value semantics is deliberately deferred.
  • sanitizeSchema strips x-kubernetes-validations once it has been harvested. AJV has no CEL evaluator, so the rule is pure noise to the form validator.
  • ApplicationOrderPage and BackupResourceEditPage opt in to enforcement on edit and anchor the overlay source to a mount-time snapshot so a background React-Query refetch can't sneak a different immutable value into the PUT.

Known limitations (pinned with FIXME tests + tracking issues)

Tests

The console workspace already had vitest + jsdom + @testing-library/react on main; this PR adds 7 new test files (immutable-paths walker, overlay edges, prepare-update, SchemaForm immutable rendering, AdditionalPropertiesField asymmetry, keys-order characterization) totalling 85 passing tests. pnpm test runs them all.

Tested manually against a hand-edited MariaDB values.schema.json carrying the rule on storageClass:

  • Edit form: storageClass greyed out, helper text below, every other field editable.
  • YAML editor bypass + Save → PUT body keeps the original storageClass value.
  • Create flow: storageClass fully editable.
  • Resources without the CEL rule: behaviour identical to master.

No-op until cozystack ships the schema annotation

When no schema carries the rule, findImmutablePaths returns [] everywhere, all walkers are no-ops, the overlay is identity, edit pages behave exactly as today. The UI lights up resource-by-resource as upstream annotates immutable fields.

Summary by CodeRabbit

Release Notes

  • New Features

    • Immutable form fields are now disabled during editing, preventing unintended modifications to protected data
    • Kubernetes immutability rules are enforced in forms with visual help text indicators
  • Documentation

    • Added test suite documentation to README with instructions for running tests via pnpm test
  • Tests

    • Comprehensive test coverage added for immutable field behavior, form validation, and schema processing
  • Chores

    • Specified pnpm 10.28.2 as the required package manager

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

This PR adds Kubernetes-style immutability enforcement to the console UI. It discovers immutable schema fields via x-kubernetes-validations rules, disables those fields in edit forms, and preserves their values during updates, with comprehensive test coverage and integration into resource edit pages.

Changes

Immutability Discovery and Overlay

Layer / File(s) Summary
Immutable Path Discovery & Overlay Engine
apps/console/src/lib/immutable-paths.ts
New module exports ImmutablePath, IMMUTABLE_HELP_TEXT, findImmutablePaths(schema), and overlayImmutable(submitted, original, paths). Discovers immutable paths from x-kubernetes-validations rules with self == oldSelf, including handling for oneOf/anyOf/allOf, wildcard array items, and additionalProperties maps. Overlay function deep-clones submitted values and restores immutable fields from original values, with guards against unsafe array-length-change overlays.
Immutable Paths Test Coverage
apps/console/src/lib/immutable-paths.test.ts
Comprehensive Vitest suite: findImmutablePaths tests cover empty/non-object inputs, CEL rule detection, wildcard/map handling, deterministic ordering, and oneOf/anyOf/allOf composition. overlayImmutable tests cover deep-clone behavior, field overlays, nested path handling, array length alignment, whole-map freezing, edge cases with missing ancestors, and unsafe wildcard array warnings.

SchemaForm Immutability UI

Layer / File(s) Summary
SchemaForm Immutable Mode Implementation
apps/console/src/components/SchemaForm.tsx
Adds optional immutableMode?: "enforce" | "off" prop. Refactors schema parsing into parsedSchema and schema memos, syncs onChange via useEffect, computes immutablePaths when mode is enforce, and applies addImmutableReadonly walker to set ui:disabled and ui:help on immutable-path UI nodes, including wildcard array and additionalProperties map handling.
SchemaForm Immutable Mode Tests
apps/console/src/components/SchemaForm.test.tsx
Vitest/React Testing Library tests validating immutableMode behavior: omitted/off modes leave fields enabled and hide help text; enforce mode disables immutable fields, shows IMMUTABLE_HELP_TEXT, disables array Add buttons for whole-array immutability, and handles nested immutable fields within array items.
AdditionalPropertiesField Immutability Tests
apps/console/src/components/AdditionalPropertiesField.test.tsx
RTL tests covering non-immutable map rendering (key/Add/Remove controls), nested per-value immutability freezing (hides key-name input, shows help text), and enforce-mode immutability (disables inner fields, hides controls, shows help text).

Update Preparation

Layer / File(s) Summary
Update Spec Preparation Helper
apps/console/src/lib/prepare-update.ts
New prepareUpdateSpec<T>(submitted, original, openAPISchema) function returns cloned submitted value when original is missing; otherwise parses schema to find immutable paths with console.warn on failure, then delegates overlay to overlayImmutable. Includes cloneShallowAsDeep JSON-based cloning utility.
Update Spec Preparation Tests
apps/console/src/lib/prepare-update.test.ts
Vitest tests covering immutable-field overlay, cloning/non-mutation, missing initial value behavior, malformed schema JSON handling with fallback, and console.warn assertions on schema parse failures.

Schema Sanitization

Layer / File(s) Summary
Schema Sanitization Enhancements
apps/console/src/lib/keys-order.ts
keysOrderToUiSchema parameter type now accepts ReadonlyArray<ReadonlyArray<string>> instead of mutable arrays. sanitizeSchema now strips x-kubernetes-validations property entirely. Documentation expanded to note that CEL rules are removed and immutability is enforced via separate uiSchema mechanism instead.
Schema Sanitization Tests
apps/console/src/lib/keys-order.test.ts
Vitest tests: keysOrderToUiSchema covers empty input, meta-path filtering (emits only spec ordering), and nested ui:order with wildcards. sanitizeSchema covers scalar/null passthrough, recursive array handling, x-kubernetes-int-or-string coercion, x-kubernetes-preserve-unknown-fields conversion, title rewriting, and x-kubernetes-validations removal at any nesting depth.

Edit Page Integration

Layer / File(s) Summary
ApplicationOrderPage Immutability
apps/console/src/routes/ApplicationOrderPage.tsx
Snapshots editMode.initialSpec on first render via refs and keeps it stable for saves. Updates React imports to include useEffect/useRef. Sets SchemaForm immutableMode to "enforce" when editing, "off" otherwise. On save, replaces body.spec with prepareUpdateSpec(...) result using captured snapshot and application OpenAPI schema.
BackupResourceEditPage Immutability
apps/console/src/routes/BackupResourceEditPage.tsx
Captures initial resource.spec snapshot via initialSpecRef when form state initializes. Sets SchemaForm immutableMode="enforce". Builds submit payload spec via prepareUpdateSpec(...) instead of using formData directly, preserving immutable-field values.

Documentation and Configuration

Layer / File(s) Summary
README and Package Manager
README.md, package.json
README adds a new "Test" section describing pnpm test and the vitest/jsdom tooling setup. package.json adds packageManager: "pnpm@10.28.2" to pin the workspace package manager version.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SchemaForm
  participant findImmutablePaths
  participant overlayImmutable
  participant ApplicationOrderPage
  participant API
  
  User->>ApplicationOrderPage: Click edit
  ApplicationOrderPage->>SchemaForm: Set immutableMode="enforce"
  ApplicationOrderPage->>ApplicationOrderPage: Capture initialSpec
  SchemaForm->>findImmutablePaths: Parse schema for immutable paths
  findImmutablePaths-->>SchemaForm: List of immutable paths
  SchemaForm->>SchemaForm: Disable UI nodes on immutable paths
  User->>SchemaForm: Edit non-immutable fields
  User->>ApplicationOrderPage: Click save
  ApplicationOrderPage->>overlayImmutable: Apply overlay with captured initial spec
  overlayImmutable-->>ApplicationOrderPage: Spec with immutable fields restored
  ApplicationOrderPage->>API: PUT request with prepared spec
  API-->>ApplicationOrderPage: Success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐰 A rabbit's ode to locked-in specs

What once could change, now stands immutable—
The Kubernetes way, written in cells and rules!
Immutable paths discovered, UI fields bound,
Updates prepared where old values are found. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding read-only rendering for immutable resource fields in edit forms, which is the primary feature across all modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/magical-meitner-7db476

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements immutability enforcement in the SchemaForm component by parsing x-kubernetes-validations and applying ui:disabled and ui:help to immutable paths. It introduces a prepareUpdateSpec utility to ensure immutable values are preserved during updates and adds Vitest for testing. The review feedback highlights improvements for the array overlay logic to prevent potential null-value issues, suggests consolidating utility functions like isPlainObject and deepClone for better consistency, and recommends cleaning up redundant code in the update preparation logic.

Comment thread apps/console/src/lib/immutable-paths.ts Outdated
Comment on lines +120 to +131
const sourceArr = Array.isArray(source) ? source : []
const targetArr = Array.isArray(target) ? target : []
const len = Math.max(sourceArr.length, targetArr.length)
const out: unknown[] = []
for (let i = 0; i < len; i++) {
if (i >= sourceArr.length) {
out[i] = deepClone(targetArr[i])
} else {
out[i] = overlayPath(targetArr[i], sourceArr[i], path, depth + 1)
}
}
return out
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current array overlay logic can produce arrays with undefined elements (holes) when the source array is longer than the target array (e.g., when an item is deleted). When serialized to JSON, these holes become null, which may violate the schema or cause unexpected behavior on the API server. Using targetArr.map is a safer and more concise way to handle positional overlays while correctly respecting deletions and additions.

    const sourceArr = Array.isArray(source) ? source : []
    const targetArr = Array.isArray(target) ? target : []
    return targetArr.map((item, i) =>
      i < sourceArr.length
        ? overlayPath(item, sourceArr[i], path, depth + 1)
        : deepClone(item),
    )

Comment on lines +19 to +20
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export isPlainObject so it can be reused in SchemaForm.tsx and deepClone for better consistency and code reuse.

Suggested change
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)
export const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)

Comment on lines +143 to +154
function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export deepClone to allow its reuse in prepare-update.ts, and use the isPlainObject helper for a cleaner and more consistent implementation.

Suggested change
function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}
export function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (isPlainObject(value)) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}

Comment on lines +1 to +5
import {
findImmutablePaths,
overlayImmutable,
type ImmutablePath,
} from "./immutable-paths.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add deepClone to the imports from ./immutable-paths.ts to replace the redundant cloneShallowAsDeep function.

Suggested change
import {
findImmutablePaths,
overlayImmutable,
type ImmutablePath,
} from "./immutable-paths.ts"
import {
findImmutablePaths,
overlayImmutable,
deepClone,
type ImmutablePath,
} from "./immutable-paths.ts"

openAPISchema: string,
): T {
if (original === undefined || original === null) {
return cloneShallowAsDeep(submitted)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the deepClone utility instead of cloneShallowAsDeep.

Suggested change
return cloneShallowAsDeep(submitted)
return deepClone(submitted)

Comment on lines +46 to +49
function cloneShallowAsDeep<T>(value: T): T {
if (value === null || value === undefined) return value
return JSON.parse(JSON.stringify(value)) as T
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function is redundant as deepClone is already implemented in immutable-paths.ts. Additionally, the name is misleading (it performs a deep clone) and the implementation using JSON.stringify is less efficient and can strip undefined values from objects.

Comment on lines +6 to +10
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
type ImmutablePath,
} from "../lib/immutable-paths.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add isPlainObject to the imports from ../lib/immutable-paths.ts.

Suggested change
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
type ImmutablePath,
} from "../lib/immutable-paths.ts"
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
isPlainObject,
type ImmutablePath,
} from "../lib/immutable-paths.ts"

key: string,
): Record<string, unknown> {
const existing = uiNode[key]
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the isPlainObject helper for better readability and consistency.

Suggested change
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
if (isPlainObject(existing)) {

@lexfrei lexfrei self-assigned this May 13, 2026
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch from 5727ac1 to 4416ec3 Compare May 14, 2026 11:53
lexfrei added 2 commits May 14, 2026 16:52
Introduce pure helpers used to recognise and enforce Kubernetes-style
immutability rules on resource schemas.

findImmutablePaths(schema) walks an OpenAPI/JSON-schema tree and
returns every path whose subschema carries an
x-kubernetes-validations entry with rule "self == oldSelf". Object
properties contribute named segments; array items and
additionalProperties contribute the wildcard segment "*". oneOf,
anyOf and allOf branches are considered when determining whether a
node is immutable.

overlayImmutable(submitted, original, paths) deep-clones the submitted
value, then for each path copies the value from original. Wildcard
semantics dispatch on the runtime value shape:

  - *-last on an array: replace with a clone of source (whole-array
    immutable).
  - *-last on an object map: replace with a clone of source (whole-map
    immutable).
  - *-not-last on an array: iterate the user's array, restore the
    immutable nested subfield from source at shared indices, keep
    user-added entries verbatim. Length shrinks are detected and the
    overlay is skipped with a console.warn so admission stays the
    authoritative enforcer (index-aligned overlay would otherwise
    silently swap which element the user deleted).
  - *-not-last on an object map: freeze the whole map. The UI marks
    such fields ui:disabled (Add/Remove hidden); the overlay matches
    so YAML-editor and devtools bypasses can't add or rename keys.

A root-level immutable path or a top-level wildcard immutable path
(both pathological schema shapes) replaces submitted wholesale with a
clone of original and logs a warning.

IMMUTABLE_HELP_TEXT centralises the user-facing string consumed by
later UI wiring.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
AJV has no CEL evaluator, so the rule does nothing for JSON-Schema
validation; the UI consults the raw schema once to harvest
immutability paths and then drops the extension so RJSF traversal
stays small.

Also add characterization tests for keysOrderToUiSchema and
sanitizeSchema so existing behaviour (int-or-string coercion,
preserve-unknown-fields → additionalProperties, "Chart Values" →
"Parameters") is locked in alongside the new strip.

Widen keysOrderToUiSchema's parameter to
ReadonlyArray<ReadonlyArray<string>> so call sites can pass
immutable-path-style arrays without casts.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch 2 times, most recently from b41f2aa to 55c8e1e Compare May 14, 2026 14:06
lexfrei added 4 commits May 14, 2026 17:24
Add an immutableMode prop ("enforce" | "off", default "off"). When
"enforce", every immutable schema path (as reported by
findImmutablePaths) receives ui:disabled=true plus a ui:help string
sourced from IMMUTABLE_HELP_TEXT. RJSF then renders the corresponding
inputs greyed out and surfaces the helper text underneath. The default
keeps every field editable so create flows are unaffected.

Wildcard segments map to RJSF's "items" key for arrays; for object
maps (additionalProperties) the field itself is marked disabled and
the existing AdditionalPropertiesField renderer propagates that to
the nested form, hiding Add/Remove controls.

Parse the raw schema once and derive both the sanitised RJSFSchema
and the immutable-path set from the shared parsed object so the JSON
string isn't decoded twice per render.

Add component-level tests covering:

  - enforce / off / omitted prop branches on scalar fields,
  - per-element-nested-immutable fields inside array items,
  - whole-array immutable shape,
  - the deliberate UI/overlay asymmetry on additionalProperties
    object maps (the whole map is frozen in both UI and overlay).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
ApplicationOrderPage and BackupResourceEditPage now opt their
SchemaForm into immutableMode="enforce" when in edit flow so the
relevant fields render greyed out with helper text.

On submit, prepareUpdateSpec walks the schema for immutable paths and
copies the original values from the persisted spec into the outgoing
body. The API server therefore observes no delta on those paths and
the CEL immutability check is not triggered, even if the user managed
to bypass the read-only UI (YAML editor, devtools, RJSF bug).

Both pages anchor the overlay source to a mount-time snapshot stored
in a ref so a background React-Query refetch can't sneak a different
immutable value into the PUT. The snapshot capture lives inside
useEffect to keep render side-effect-free.

prepareUpdateSpec is a thin pure wrapper around findImmutablePaths +
overlayImmutable. It short-circuits to a deep clone when the persisted
spec is null/undefined (avoids blanking immutable fields with
undefined) and logs a console.warn when the schema string fails to
parse instead of silently disabling enforcement.

BackupResourceEditPage.handleSubmit also refuses to save when the CRD
schema hasn't loaded yet, mirroring the existing !resource guard.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The workspace already configures vitest + jsdom + @testing-library
in apps/console; the README mentioned only install/dev/build. Add a
one-line Test section pointing at the workspace-wide "pnpm test".

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
pnpm/action-setup@v4 fails when the workspace doesn't pin a pnpm
version anywhere. Declare it in the standard packageManager field on
the root package.json so both the Test workflow and any local
contributor's corepack pick up the same version we develop against.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch from 55c8e1e to d51f984 Compare May 14, 2026 14:24
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level + nodeGroups[]),
mariadb, mongodb, nats, openbao, opensearch, postgres, qdrant,
rabbitmq, redis, vm-disk. Without coverage across the catalogue,
the UI would render storageClass read-only on some apps and
editable on others — a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed
resources; that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in
rest_spec_schema_test.go pin the contract that
x-kubernetes-validations survives the v1→internal→structural
conversion (loaded from the actual MariaDB ApplicationDefinition
fixture on disk, not a hand-edited mock).

Requires cozystack/cozyvalues-gen with @immutable support — bump
the pre-commit pin in .github/workflows/pre-commit.yml once the
release exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level + nodeGroups[]),
mariadb, mongodb, nats, openbao, opensearch, postgres, qdrant,
rabbitmq, redis, vm-disk. Without coverage across the catalogue,
the UI would render storageClass read-only on some apps and
editable on others — a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed
resources; that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in
rest_spec_schema_test.go pin the contract that
x-kubernetes-validations survives the v1→internal→structural
conversion (loaded from the actual MariaDB ApplicationDefinition
fixture on disk, not a hand-edited mock).

Requires cozystack/cozyvalues-gen with @immutable support — bump
the pre-commit pin in .github/workflows/pre-commit.yml once the
release exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level + nodeGroups[]),
mariadb, mongodb, nats, openbao, opensearch, postgres, qdrant,
rabbitmq, redis, vm-disk. Without coverage across the catalogue,
the UI would render storageClass read-only on some apps and
editable on others — a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed
resources; that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in
rest_spec_schema_test.go pin the contract that
x-kubernetes-validations survives the v1→internal→structural
conversion (loaded from the actual MariaDB ApplicationDefinition
fixture on disk, not a hand-edited mock).

Requires cozystack/cozyvalues-gen with @immutable support — bump
the pre-commit pin in .github/workflows/pre-commit.yml once the
release exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level only), mariadb,
mongodb, nats, openbao, opensearch, postgres, qdrant, rabbitmq,
redis, vm-disk. Without coverage across the catalogue the UI would
render storageClass read-only on some apps and editable on others —
a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed resources;
that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

kubernetes.nodeGroups[name].storageClass is intentionally NOT
annotated: the field is optional and undefaulted, so a strict
'self == oldSelf' rule would block any future attempt to set it on
an existing node group. A richer ratcheting form is needed there —
see docs/storage-immutability.md.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in rest_spec_schema_test.go pin
the contract that x-kubernetes-validations survives the
v1->internal->structural conversion (loaded from the actual MariaDB
ApplicationDefinition fixture on disk, not a hand-edited mock).

Each per-app README gains a one-line blockquote pointing to a new
docs/storage-immutability.md that explains the contract and which
consumers enforce it today.

Requires cozystack/cozyvalues-gen with @immutable support — bump the
pre-commit pin in .github/workflows/pre-commit.yml once the release
exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei lexfrei marked this pull request as ready for review May 18, 2026 08:05
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/console/src/lib/immutable-paths.ts`:
- Around line 123-147: wildcardArrayLengthChanged currently returns when it sees
the first "*" segment and therefore misses shrink events deeper in the path;
update wildcardArrayLengthChanged so that when you encounter a "*" that is not
the last segment you iterate over the array elements (using subCur and origCur)
up to Math.min(subCur.length, origCur.length) and for each element
recursively/iteratively continue checking the remaining path segments instead of
returning early; if any element-check detects shrink (or if subCur and origCur
themselves are arrays and subCur.length < origCur.length at the current non-last
"*" level) return true, otherwise continue; keep the existing behavior for the
last "*" segment (isLast -> false).

In `@apps/console/src/routes/ApplicationOrderPage.tsx`:
- Around line 50-56: initialSpecRef currently holds the same object reference as
editMode.initialSpec, so in useEffect where you set initialSpecRef.current
(guarded by initialSpecCapturedRef), deep-clone the snapshot before storing it
to prevent in-place nested edits from mutating the captured "original"; update
the useEffect that checks editMode and initialSpecCapturedRef to assign a cloned
copy (use structuredClone(editMode.initialSpec) when available, falling back to
a JSON deep-copy) to initialSpecRef.current so the original snapshot remains
immutable.

In `@apps/console/src/routes/BackupResourceEditPage.tsx`:
- Around line 67-71: The current initialization assigns the same object to both
form state and the immutable snapshot (resource.spec -> setFormData and
initialSpecRef.current), causing shared-mutability issues; fix it by creating a
deep copy when setting formData and initialSpecRef (e.g., clone resource.spec
before assigning) so setFormData(resource.spec) and initialSpecRef.current =
resource.spec are replaced with cloned copies, ensuring initializedRef.current
logic remains the same but both form state and the immutable snapshot reference
independent objects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a5e802b-fecd-4b84-a881-0cdf6f2294f0

📥 Commits

Reviewing files that changed from the base of the PR and between fa41573 and d51f984.

📒 Files selected for processing (13)
  • README.md
  • apps/console/src/components/AdditionalPropertiesField.test.tsx
  • apps/console/src/components/SchemaForm.test.tsx
  • apps/console/src/components/SchemaForm.tsx
  • apps/console/src/lib/immutable-paths.test.ts
  • apps/console/src/lib/immutable-paths.ts
  • apps/console/src/lib/keys-order.test.ts
  • apps/console/src/lib/keys-order.ts
  • apps/console/src/lib/prepare-update.test.ts
  • apps/console/src/lib/prepare-update.ts
  • apps/console/src/routes/ApplicationOrderPage.tsx
  • apps/console/src/routes/BackupResourceEditPage.tsx
  • package.json

Comment on lines +123 to +147
function wildcardArrayLengthChanged(
submitted: unknown,
original: unknown,
path: ImmutablePath,
): boolean {
let subCur: unknown = submitted
let origCur: unknown = original
for (let i = 0; i < path.length; i++) {
const seg = path[i]
if (seg === "*") {
// Whole-array immutability (*-last) is fine to overlay even when the
// user changed the length — the semantics is "freeze, replace from
// source", and that's exactly what the *-last branch does.
const isLast = i === path.length - 1
if (isLast) return false
// Per-element-nested immutability (*-not-last) is the case that can
// silently corrupt on SHRINK (we'd re-anchor source values onto the
// user's surviving indices, which may belong to a different element
// than the user thought they kept). On grow the shared indices stay
// put and the new entries past source.length keep the user's values,
// so growth is safe to overlay.
if (Array.isArray(subCur) && Array.isArray(origCur)) {
return subCur.length < origCur.length
}
return false
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle nested wildcard shrink detection before overlaying immutable array-item paths.

wildcardArrayLengthChanged returns on the first "*" segment, so it can miss array shrinkage on deeper wildcard segments. For nested array paths, this can still re-anchor immutable values to wrong indices.

💡 Suggested fix
 function wildcardArrayLengthChanged(
   submitted: unknown,
   original: unknown,
   path: ImmutablePath,
 ): boolean {
-  let subCur: unknown = submitted
-  let origCur: unknown = original
-  for (let i = 0; i < path.length; i++) {
-    const seg = path[i]
-    if (seg === "*") {
-      // Whole-array immutability (*-last) is fine to overlay even when the
-      // user changed the length — the semantics is "freeze, replace from
-      // source", and that's exactly what the *-last branch does.
-      const isLast = i === path.length - 1
-      if (isLast) return false
-      // Per-element-nested immutability (*-not-last) is the case that can
-      // silently corrupt on SHRINK (we'd re-anchor source values onto the
-      // user's surviving indices, which may belong to a different element
-      // than the user thought they kept). On grow the shared indices stay
-      // put and the new entries past source.length keep the user's values,
-      // so growth is safe to overlay.
-      if (Array.isArray(subCur) && Array.isArray(origCur)) {
-        return subCur.length < origCur.length
-      }
-      return false
-    }
-    if (!isPlainObject(subCur) || !isPlainObject(origCur)) return false
-    subCur = (subCur as Record<string, unknown>)[seg]
-    origCur = (origCur as Record<string, unknown>)[seg]
-  }
-  return false
+  const visit = (subCur: unknown, origCur: unknown, depth: number): boolean => {
+    if (depth >= path.length) return false
+    const seg = path[depth]
+
+    if (seg === "*") {
+      const isLast = depth === path.length - 1
+      if (isLast) return false
+      if (!Array.isArray(subCur) || !Array.isArray(origCur)) return false
+      if (subCur.length < origCur.length) return true
+
+      const shared = Math.min(subCur.length, origCur.length)
+      for (let i = 0; i < shared; i++) {
+        if (visit(subCur[i], origCur[i], depth + 1)) return true
+      }
+      return false
+    }
+
+    if (!isPlainObject(subCur) || !isPlainObject(origCur)) return false
+    return visit(
+      (subCur as Record<string, unknown>)[seg],
+      (origCur as Record<string, unknown>)[seg],
+      depth + 1,
+    )
+  }
+
+  return visit(submitted, original, 0)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/immutable-paths.ts` around lines 123 - 147,
wildcardArrayLengthChanged currently returns when it sees the first "*" segment
and therefore misses shrink events deeper in the path; update
wildcardArrayLengthChanged so that when you encounter a "*" that is not the last
segment you iterate over the array elements (using subCur and origCur) up to
Math.min(subCur.length, origCur.length) and for each element
recursively/iteratively continue checking the remaining path segments instead of
returning early; if any element-check detects shrink (or if subCur and origCur
themselves are arrays and subCur.length < origCur.length at the current non-last
"*" level) return true, otherwise continue; keep the existing behavior for the
last "*" segment (isLast -> false).

Comment on lines +50 to +56
const initialSpecRef = useRef<unknown>(editMode?.initialSpec)
const initialSpecCapturedRef = useRef(false)
useEffect(() => {
if (editMode && !initialSpecCapturedRef.current) {
initialSpecCapturedRef.current = true
initialSpecRef.current = editMode.initialSpec
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clone the persisted snapshot before storing it in initialSpecRef.

initialSpecRef currently keeps the same object reference as editable data. If nested edits mutate in place, the “original” snapshot drifts and immutable overlay protection can be bypassed.

Suggested fix
-  const initialSpecRef = useRef<unknown>(editMode?.initialSpec)
+  const initialSpecRef = useRef<unknown>(
+    editMode?.initialSpec === undefined
+      ? undefined
+      : JSON.parse(JSON.stringify(editMode.initialSpec)),
+  )
@@
-      initialSpecRef.current = editMode.initialSpec
+      initialSpecRef.current = JSON.parse(JSON.stringify(editMode.initialSpec))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const initialSpecRef = useRef<unknown>(editMode?.initialSpec)
const initialSpecCapturedRef = useRef(false)
useEffect(() => {
if (editMode && !initialSpecCapturedRef.current) {
initialSpecCapturedRef.current = true
initialSpecRef.current = editMode.initialSpec
}
const initialSpecRef = useRef<unknown>(
editMode?.initialSpec === undefined
? undefined
: JSON.parse(JSON.stringify(editMode.initialSpec)),
)
const initialSpecCapturedRef = useRef(false)
useEffect(() => {
if (editMode && !initialSpecCapturedRef.current) {
initialSpecCapturedRef.current = true
initialSpecRef.current = JSON.parse(JSON.stringify(editMode.initialSpec))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/routes/ApplicationOrderPage.tsx` around lines 50 - 56,
initialSpecRef currently holds the same object reference as
editMode.initialSpec, so in useEffect where you set initialSpecRef.current
(guarded by initialSpecCapturedRef), deep-clone the snapshot before storing it
to prevent in-place nested edits from mutating the captured "original"; update
the useEffect that checks editMode and initialSpecCapturedRef to assign a cloned
copy (use structuredClone(editMode.initialSpec) when available, falling back to
a JSON deep-copy) to initialSpecRef.current so the original snapshot remains
immutable.

Comment on lines 67 to 71
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
setFormData(resource.spec)
initialSpecRef.current = resource.spec
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid sharing the same resource.spec object between form state and immutable snapshot.

formData and initialSpecRef currently point to the same value. Any in-place mutation can invalidate the immutable overlay source used during submit.

Suggested fix
-      setFormData(resource.spec)
-      initialSpecRef.current = resource.spec
+      const formSpec = JSON.parse(JSON.stringify(resource.spec))
+      const initialSpec = JSON.parse(JSON.stringify(resource.spec))
+      setFormData(formSpec)
+      initialSpecRef.current = initialSpec
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
setFormData(resource.spec)
initialSpecRef.current = resource.spec
}
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
const formSpec = JSON.parse(JSON.stringify(resource.spec))
const initialSpec = JSON.parse(JSON.stringify(resource.spec))
setFormData(formSpec)
initialSpecRef.current = initialSpec
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/routes/BackupResourceEditPage.tsx` around lines 67 - 71, The
current initialization assigns the same object to both form state and the
immutable snapshot (resource.spec -> setFormData and initialSpecRef.current),
causing shared-mutability issues; fix it by creating a deep copy when setting
formData and initialSpecRef (e.g., clone resource.spec before assigning) so
setFormData(resource.spec) and initialSpecRef.current = resource.spec are
replaced with cloned copies, ensuring initializedRef.current logic remains the
same but both form state and the immutable snapshot reference independent
objects.

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