From 2da65944666529c259568dedbb2d077f151cc79d Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Sat, 15 Aug 2026 15:30:52 +0500 Subject: [PATCH] fix(dashboard): materialise an immutable leaf whose ancestor is gone overlayImmutable stopped walking as soon as the submitted body had nothing at a path segment, so a YAML edit that dropped a whole parent object also dropped the immutable leaf under it -- reachable through foundationdb storage.storageClass and kafka kafka.storageClass. Create the missing ancestor when the persisted spec has one, and turn the pinned FIXME test into a test of the fixed behaviour. Fixes #3102 Signed-off-by: Myasnikov Daniil --- .../console/src/lib/immutable-paths.test.ts | 29 +++++++++++++------ .../apps/console/src/lib/immutable-paths.ts | 6 +++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts index 7111c426d..9318fb0ee 100644 --- a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts +++ b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts @@ -377,20 +377,31 @@ describe("overlayImmutable", () => { }) }) - it("does NOT materialise an immutable leaf when its ancestor is missing in target (pinned current behaviour)", () => { - // Tracked in cozystack/cozystack-ui#9. - // Defence-in-depth gap: if the YAML editor strips the parent object - // entirely, overlay cannot reach the leaf to copy the original value. - // Pin behaviour so a future contributor fixing the gap notices the - // test and revises it. + it("materialises an immutable leaf when its ancestor is missing in target", () => { + // A YAML edit that strips the parent object must not strip the immutable + // leaf with it. foundationdb's storage.storageClass is a shipped path of + // this shape. const submitted = { spec: {} } as Record const original = { - spec: { backup: { storageClass: "slow" } }, + spec: { storage: { storageClass: "replicated", size: "10Gi" } }, } const result = overlayImmutable(submitted, original, [ - ["spec", "backup", "storageClass"], + ["spec", "storage", "storageClass"], ]) - expect(result).toEqual({ spec: {} }) + expect(result).toEqual({ spec: { storage: { storageClass: "replicated" } } }) + }) + + it("materialises an immutable leaf when its ancestor is null in target", () => { + // `storage:` with nothing under it parses to null, not to a missing key, + // so the walker has to treat it the same as absent. + const submitted = { spec: { storage: null } } as Record + const original = { + spec: { storage: { storageClass: "replicated", size: "10Gi" } }, + } + const result = overlayImmutable(submitted, original, [ + ["spec", "storage", "storageClass"], + ]) + expect(result).toEqual({ spec: { storage: { storageClass: "replicated" } } }) }) it("array reordering by the user with index-aligned overlay re-anchors source values to the new index (pinned current behaviour)", () => { diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts index 34acc4b02..2f6d1bc31 100644 --- a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts +++ b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts @@ -174,7 +174,11 @@ function overlayPath( const sourceVal = sourceObj ? sourceObj[seg] : undefined const targetObj = isPlainObject(target) ? (target as Record) - : null + : // A YAML edit can drop the whole intermediate object; materialise it so + // the immutable leaf underneath is still restored from source. + sourceVal !== undefined && (target === undefined || target === null) + ? {} + : null if (!targetObj) return target if ( sourceVal === undefined &&