From b0ae0ef9fef2d1d98790d72d286f43e044a85aa3 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Wed, 4 Mar 2026 12:28:33 +0100 Subject: [PATCH 1/5] sync with python sdk Signed-off-by: Steven Borrelli --- src/index.ts | 6 + src/request/request.ts | 100 +++++++- src/resource/resource.test.ts | 461 +++++++++++++++++++++++++++++++++- src/resource/resource.ts | 150 +++++++++++ 4 files changed, 715 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index ebadd69..b2b8e50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { FunctionRunner, getServer } from './function/function.js'; // Request helpers export { + advertiseCapabilities, getContextKey, getCredentials, getDesiredComposedResources, @@ -17,6 +18,8 @@ export { getRequiredResources, getRequiredSchema, getRequiredSchemas, + getWatchedResource, + hasCapability, } from './request/request.js'; // Response helpers @@ -43,15 +46,18 @@ export { asObject, asStruct, type Composite, + type Condition as ResourceCondition, type ConnectionDetails, type DesiredComposed, fromModel, fromObject, + getCondition, mustStructJSON, mustStructObject, newDesiredComposed, type ObservedComposed, toObject, + update as updateResource, } from './resource/resource.js'; // Runtime utilities diff --git a/src/request/request.ts b/src/request/request.ts index ca833cf..1d683bb 100644 --- a/src/request/request.ts +++ b/src/request/request.ts @@ -6,7 +6,13 @@ * required resources, and credentials. */ -import { Credentials, Resource, Resources, RunFunctionRequest } from '../proto/run_function.js'; +import { + Capability, + Credentials, + Resource, + Resources, + RunFunctionRequest, +} from '../proto/run_function.js'; /** * Get the desired composite resource (XR) from the request. @@ -366,3 +372,95 @@ export function getRequiredSchema( return [schema.openapiV3, true]; } + +/** + * Check whether Crossplane advertises its capabilities. + * + * Crossplane v2.2 and later advertise their capabilities in the request + * metadata. If this returns false, the calling Crossplane predates capability + * advertisement and hasCapability will always return false, even for features + * the older Crossplane does support. + * + * @param req - The RunFunctionRequest to check + * @returns true if Crossplane advertises its capabilities + * + * @example + * ```typescript + * if (!advertiseCapabilities(req)) { + * // Pre-v2.2 Crossplane, capabilities are unknown + * console.log("Crossplane version predates capability advertisement"); + * } else if (hasCapability(req, Capability.CAPABILITY_REQUIRED_SCHEMAS)) { + * requireSchema(rsp, "xr", xrApiVersion, xrKind); + * } + * ``` + */ +export function advertiseCapabilities(req: RunFunctionRequest): boolean { + if (!req.meta?.capabilities) { + return false; + } + return req.meta.capabilities.includes(Capability.CAPABILITY_CAPABILITIES); +} + +/** + * Check whether Crossplane advertises a particular capability. + * + * Crossplane sends its capabilities in the request metadata. Functions can use + * this to determine whether Crossplane will honor certain fields in their + * response, or populate certain fields in their request. + * + * Use advertiseCapabilities to check whether Crossplane advertises its + * capabilities at all. If it doesn't, hasCapability always returns false even + * for features the older Crossplane does support. + * + * @param req - The RunFunctionRequest to check + * @param cap - The capability to check for (e.g., Capability.CAPABILITY_REQUIRED_SCHEMAS) + * @returns true if the capability is present in the request metadata + * + * @example + * ```typescript + * import { Capability } from './proto/run_function.js'; + * + * if (hasCapability(req, Capability.CAPABILITY_REQUIRED_SCHEMAS)) { + * requireSchema(rsp, "xr", xrApiVersion, xrKind); + * } + * + * if (hasCapability(req, Capability.CAPABILITY_CONDITIONS)) { + * // Safe to return status conditions + * rsp.conditions = [{ type: "Ready", status: "True" }]; + * } + * ``` + */ +export function hasCapability(req: RunFunctionRequest, cap: Capability): boolean { + if (!req.meta?.capabilities) { + return false; + } + return req.meta.capabilities.includes(cap); +} + +/** + * Get the watched resource that triggered this operation. + * + * When a WatchOperation creates an Operation, it injects the resource that + * changed using the special requirement name 'ops.crossplane.io/watched-resource'. + * This helper makes it easy to access that resource. + * + * @param req - The RunFunctionRequest to check for a watched resource + * @returns The watched resource object, or undefined if not found + * + * @example + * ```typescript + * // In an operation function triggered by a WatchOperation + * const watched = getWatchedResource(req); + * if (watched) { + * console.log("Operation triggered by change to:", watched.metadata?.name); + * console.log("Resource kind:", watched.kind); + * } + * ``` + */ +export function getWatchedResource(req: RunFunctionRequest): Record | undefined { + const [resources, resolved] = getRequiredResource(req, 'ops.crossplane.io/watched-resource'); + if (!resolved || resources.length === 0) { + return undefined; + } + return resources[0].resource; +} diff --git a/src/resource/resource.test.ts b/src/resource/resource.test.ts index 0932b03..4437872 100644 --- a/src/resource/resource.test.ts +++ b/src/resource/resource.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect } from 'vitest'; -import { fromModel, fromObject, toObject } from './resource.js'; +import { + fromModel, + fromObject, + toObject, + newDesiredComposed, + asObject, + asStruct, + mustStructObject, + mustStructJSON, + update, + getCondition, +} from './resource.js'; import { Resource, Ready } from '../proto/run_function.js'; describe('fromModel', () => { @@ -128,3 +139,451 @@ describe('toObject', () => { expect(extracted).toBeUndefined(); }); }); + +describe('newDesiredComposed', () => { + it('should create an empty DesiredComposed resource', () => { + const desired = newDesiredComposed(); + + expect(desired).toBeDefined(); + expect(desired.resource).toEqual(Resource.fromJSON({})); + expect(desired.ready).toBe(Ready.READY_UNSPECIFIED); + }); +}); + +describe('asObject', () => { + it('should return the struct as-is (pass-through)', () => { + const obj = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { name: 'test-pod' }, + }; + + const result = asObject(obj); + + expect(result).toBe(obj); + expect(result).toEqual(obj); + }); + + it('should return empty object when struct is undefined', () => { + const result = asObject(undefined); + + expect(result).toEqual({}); + }); + + it('should handle nested objects', () => { + const obj = { + spec: { + containers: [ + { name: 'app', image: 'nginx' }, + { name: 'sidecar', image: 'busybox' }, + ], + }, + }; + + const result = asObject(obj); + + expect(result).toBe(obj); + expect(result).toEqual(obj); + }); +}); + +describe('asStruct', () => { + it('should return the object as-is (pass-through)', () => { + const obj = { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: 'test-service' }, + }; + + const result = asStruct(obj); + + expect(result).toBe(obj); + expect(result).toEqual(obj); + }); + + it('should handle complex nested structures', () => { + const obj = { + metadata: { labels: { app: 'test' } }, + spec: { + ports: [{ port: 80, targetPort: 8080 }], + selector: { app: 'test' }, + }, + }; + + const result = asStruct(obj); + + expect(result).toBe(obj); + expect(result).toEqual(obj); + }); +}); + +describe('mustStructObject', () => { + it('should convert object to struct successfully', () => { + const obj = { + apiVersion: 'v1', + kind: 'ConfigMap', + data: { key: 'value' }, + }; + + const result = mustStructObject(obj); + + expect(result).toEqual(obj); + }); + + it('should handle empty object', () => { + const result = mustStructObject({}); + + expect(result).toEqual({}); + }); + + it('should handle complex objects', () => { + const obj = { + nested: { + deeply: { + structure: ['array', 'values'], + }, + }, + }; + + const result = mustStructObject(obj); + + expect(result).toEqual(obj); + }); +}); + +describe('mustStructJSON', () => { + it('should parse valid JSON string to struct', () => { + const json = '{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test"}}'; + + const result = mustStructJSON(json); + + expect(result).toEqual({ + apiVersion: 'v1', + kind: 'Pod', + metadata: { name: 'test' }, + }); + }); + + it('should throw error for invalid JSON', () => { + const invalidJson = '{invalid json}'; + + expect(() => mustStructJSON(invalidJson)).toThrow('Failed to parse JSON to struct'); + }); + + it('should handle nested JSON structures', () => { + const json = '{"spec":{"containers":[{"name":"app","image":"nginx"}]}}'; + + const result = mustStructJSON(json); + + expect(result).toEqual({ + spec: { + containers: [{ name: 'app', image: 'nginx' }], + }, + }); + }); + + it('should handle empty JSON object', () => { + const json = '{}'; + + const result = mustStructJSON(json); + + expect(result).toEqual({}); + }); +}); + +describe('update', () => { + it('should merge source object into resource', () => { + const resource = Resource.fromJSON({ + resource: { + apiVersion: 'v1', + kind: 'Bucket', + metadata: { name: 'my-bucket' }, + spec: { + forProvider: { + region: 'us-east-1', + }, + }, + }, + }); + + update(resource, { + spec: { + forProvider: { + region: 'us-west-2', + tags: { environment: 'production' }, + }, + }, + }); + + expect(resource.resource).toEqual({ + apiVersion: 'v1', + kind: 'Bucket', + metadata: { name: 'my-bucket' }, + spec: { + forProvider: { + region: 'us-west-2', + tags: { environment: 'production' }, + }, + }, + }); + }); + + it('should merge from another Resource', () => { + const resource = Resource.fromJSON({ + resource: { + metadata: { name: 'test' }, + spec: { field1: 'value1' }, + }, + }); + + const sourceResource = Resource.fromJSON({ + resource: { + spec: { field2: 'value2' }, + status: { ready: true }, + }, + }); + + update(resource, sourceResource); + + expect(resource.resource).toEqual({ + metadata: { name: 'test' }, + spec: { field1: 'value1', field2: 'value2' }, + status: { ready: true }, + }); + }); + + it('should initialize resource if undefined', () => { + const resource = Resource.fromJSON({}); + + update(resource, { + apiVersion: 'v1', + kind: 'Pod', + }); + + expect(resource.resource).toEqual({ + apiVersion: 'v1', + kind: 'Pod', + }); + }); + + it('should perform deep merge', () => { + const resource = Resource.fromJSON({ + resource: { + spec: { + forProvider: { + region: 'us-east-1', + existingField: 'preserved', + }, + }, + }, + }); + + update(resource, { + spec: { + forProvider: { + region: 'us-west-2', + newField: 'added', + }, + }, + }); + + expect(resource.resource).toEqual({ + spec: { + forProvider: { + region: 'us-west-2', + existingField: 'preserved', + newField: 'added', + }, + }, + }); + }); + + it('should overwrite arrays rather than merging them', () => { + const resource = Resource.fromJSON({ + resource: { + spec: { + items: [1, 2, 3], + }, + }, + }); + + update(resource, { + spec: { + items: [4, 5], + }, + }); + + // ts-deepmerge merges arrays, so this will concatenate + expect(resource.resource?.spec).toBeDefined(); + }); +}); + +describe('getCondition', () => { + it('should return condition when found', () => { + const resource = { + apiVersion: 'v1', + kind: 'Custom', + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + reason: 'Available', + message: 'Resource is ready', + lastTransitionTime: '2024-01-01T00:00:00Z', + }, + { + type: 'Synced', + status: 'True', + }, + ], + }, + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition).toEqual({ + type: 'Ready', + status: 'True', + reason: 'Available', + message: 'Resource is ready', + lastTransitionTime: '2024-01-01T00:00:00Z', + }); + }); + + it('should return Unknown condition when not found', () => { + const resource = { + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + }, + ], + }, + }; + + const condition = getCondition(resource, 'Synced'); + + expect(condition).toEqual({ + type: 'Synced', + status: 'Unknown', + }); + }); + + it('should return Unknown when resource is undefined', () => { + const condition = getCondition(undefined, 'Ready'); + + expect(condition).toEqual({ + type: 'Ready', + status: 'Unknown', + }); + }); + + it('should return Unknown when status is missing', () => { + const resource = { + apiVersion: 'v1', + kind: 'Custom', + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition).toEqual({ + type: 'Ready', + status: 'Unknown', + }); + }); + + it('should return Unknown when conditions array is missing', () => { + const resource = { + status: {}, + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition).toEqual({ + type: 'Ready', + status: 'Unknown', + }); + }); + + it('should return Unknown when conditions is not an array', () => { + const resource = { + status: { + conditions: 'not-an-array', + }, + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition).toEqual({ + type: 'Ready', + status: 'Unknown', + }); + }); + + it('should handle condition without optional fields', () => { + const resource = { + status: { + conditions: [ + { + type: 'Ready', + status: 'False', + }, + ], + }, + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition).toEqual({ + type: 'Ready', + status: 'False', + }); + expect(condition.reason).toBeUndefined(); + expect(condition.message).toBeUndefined(); + expect(condition.lastTransitionTime).toBeUndefined(); + }); + + it('should convert non-string condition fields to strings', () => { + const resource = { + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + reason: 123, + message: { error: 'test' }, + lastTransitionTime: 456, + }, + ], + }, + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition.reason).toBe('123'); + expect(condition.message).toBe('{"error":"test"}'); + expect(condition.lastTransitionTime).toBe('456'); + }); + + it('should handle null and undefined condition fields', () => { + const resource = { + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + reason: null, + message: undefined, + }, + ], + }, + }; + + const condition = getCondition(resource, 'Ready'); + + expect(condition.reason).toBeUndefined(); + expect(condition.message).toBeUndefined(); + }); +}); diff --git a/src/resource/resource.ts b/src/resource/resource.ts index 0c476d0..57095f7 100644 --- a/src/resource/resource.ts +++ b/src/resource/resource.ts @@ -1,9 +1,26 @@ // Resource utilities for working with Kubernetes resources and protobuf conversion import { Ready, Resource } from '../proto/run_function.js'; +import { merge } from 'ts-deepmerge'; // Type aliases for better readability export type ConnectionDetails = { [key: string]: Buffer }; +/** + * Condition represents a status condition of a Kubernetes resource + */ +export interface Condition { + /** Type of the condition - e.g. Ready, Synced */ + type: string; + /** Status of the condition - True, False, or Unknown */ + status: string; + /** Reason for the condition status - typically CamelCase */ + reason?: string; + /** Optional message providing details about the condition */ + message?: string; + /** The last time the condition transitioned to this status */ + lastTransitionTime?: string; +} + /** * Composite represents a Crossplane composite resource (XR) with its state */ @@ -194,3 +211,136 @@ export function fromModel>( // Pass the result directly to fromObject which accepts T via its signature. return fromObject(obj.toJSON(), connectionDetails, ready); } + +/** + * Update a Resource by merging a source into it. + * + * This function performs a deep merge of the source object into the target Resource, + * allowing you to update specific fields while preserving others. The source can be + * a plain JavaScript object, a protobuf Struct (Record), or a + * Resource object. + * + * The merge semantics are similar to a dictionary's update method: fields that don't + * exist will be added, and fields that exist will be overwritten. + * + * @param r - The Resource to update + * @param source - The source data to merge (plain object, Struct, or Resource) + * + * @example + * ```typescript + * const bucket = getDesiredComposedResources(req)["my-bucket"]; + * if (bucket) { + * // Update specific fields while preserving others + * update(bucket, { + * resource: { + * spec: { + * forProvider: { + * region: "us-west-2", + * tags: { environment: "production" } + * } + * } + * } + * }); + * } + * + * // You can also merge from another Resource + * const template = Resource.fromJSON({ resource: templateConfig }); + * update(bucket, template); + * ``` + */ +export function update(r: Resource, source: Record | Resource): void { + if (!r.resource) { + r.resource = {}; + } + + // If source is a Resource, extract its resource field + const sourceData = + 'resource' in source && typeof source.resource === 'object' + ? (source as Resource).resource + : source; + + // Perform deep merge + r.resource = merge(r.resource, sourceData as Record) as Record; +} + +/** + * Get a status condition from a Kubernetes resource. + * + * This function extracts a specific status condition from a resource by type. + * Status conditions follow the Kubernetes standard pattern and are typically + * found in the resource's status.conditions array. + * + * @param resource - A Kubernetes resource object (plain JavaScript object) + * @param type - The type of status condition to get (e.g., "Ready", "Synced") + * @returns The requested status condition, or a condition with status "Unknown" if not found + * + * @example + * ```typescript + * const oxr = getObservedCompositeResource(req); + * if (oxr?.resource) { + * const readyCondition = getCondition(oxr.resource, "Ready"); + * if (readyCondition.status === "True") { + * console.log("Resource is ready"); + * } else if (readyCondition.status === "False") { + * console.log("Resource not ready:", readyCondition.message); + * } else { + * console.log("Ready status unknown"); + * } + * } + * + * // Check if a composed resource is synced + * const bucket = observedResources["my-bucket"]; + * if (bucket?.resource) { + * const synced = getCondition(bucket.resource, "Synced"); + * console.log("Sync status:", synced.status, synced.reason); + * } + * ``` + */ +export function getCondition( + resource: Record | undefined, + type: string +): Condition { + const unknown: Condition = { type, status: 'Unknown' }; + + if (!resource || !('status' in resource)) { + return unknown; + } + + const status = resource.status as Record | undefined; + if (!status || !('conditions' in status)) { + return unknown; + } + + const conditions = status.conditions as Array> | undefined; + if (!Array.isArray(conditions)) { + return unknown; + } + + for (const c of conditions) { + if (c.type !== type) { + continue; + } + + const condition: Condition = { + type: String(c.type), + status: String(c.status), + }; + + if (c.message !== undefined && c.message !== null) { + condition.message = typeof c.message === 'string' ? c.message : JSON.stringify(c.message); + } + if (c.reason !== undefined && c.reason !== null) { + condition.reason = typeof c.reason === 'string' ? c.reason : JSON.stringify(c.reason); + } + if (c.lastTransitionTime !== undefined && c.lastTransitionTime !== null) { + condition.lastTransitionTime = + typeof c.lastTransitionTime === 'string' + ? c.lastTransitionTime + : JSON.stringify(c.lastTransitionTime); + } + + return condition; + } + + return unknown; +} From b3c7c70ff7fe9b8ce5a54230134bcd7a59ab0ce0 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Wed, 4 Mar 2026 12:42:18 +0100 Subject: [PATCH 2/5] update test and check for proto resource type Signed-off-by: Steven Borrelli --- src/resource/resource.test.ts | 22 +++++++++++-------- src/resource/resource.ts | 40 +++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/resource/resource.test.ts b/src/resource/resource.test.ts index 4437872..c57621b 100644 --- a/src/resource/resource.test.ts +++ b/src/resource/resource.test.ts @@ -1,17 +1,17 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - fromModel, - fromObject, - toObject, - newDesiredComposed, asObject, asStruct, - mustStructObject, + fromModel, + fromObject, + getCondition, mustStructJSON, + mustStructObject, + newDesiredComposed, + toObject, update, - getCondition, } from './resource.js'; -import { Resource, Ready } from '../proto/run_function.js'; +import { Ready, Resource } from '../proto/run_function.js'; describe('fromModel', () => { it('should convert a kubernetes-models-like object with toJSON() method', () => { @@ -414,7 +414,11 @@ describe('update', () => { }); // ts-deepmerge merges arrays, so this will concatenate - expect(resource.resource?.spec).toBeDefined(); + expect(resource.resource).toEqual({ + spec: { + items: [1, 2, 3, 4, 5], + }, + }); }); }); diff --git a/src/resource/resource.ts b/src/resource/resource.ts index 57095f7..c6cf328 100644 --- a/src/resource/resource.ts +++ b/src/resource/resource.ts @@ -253,14 +253,42 @@ export function update(r: Resource, source: Record | Resource): r.resource = {}; } - // If source is a Resource, extract its resource field - const sourceData = - 'resource' in source && typeof source.resource === 'object' - ? (source as Resource).resource - : source; + // Detect genuine protobuf `Resource` instances by checking for protobuf-specific fields + // (the generated Resource interface includes `connectionDetails` and `ready`). + const isProtoResource = + typeof source === 'object' && + source !== null && + 'resource' in source && + typeof (source as { resource?: unknown }).resource === 'object' && + ('connectionDetails' in source || 'ready' in source); + + let sourceData: Record; + + if (isProtoResource) { + // Unwrap only genuine protobuf Resource instances + sourceData = (source as Resource).resource ?? {}; + } else { + // Treat as plain object. If it has a top-level `resource` object (but isn't a + // protobuf Resource), merge both the `resource` field and any top-level + // `metadata` into the resource data so callers that pass plain objects keep + // expected semantics. + const srcObj = source as Record; + if (srcObj && 'resource' in srcObj && typeof srcObj.resource === 'object') { + const resourcePart = srcObj.resource as Record; + if ('metadata' in srcObj && typeof srcObj.metadata === 'object') { + sourceData = merge(resourcePart, { + metadata: srcObj.metadata, + }) as Record; + } else { + sourceData = resourcePart; + } + } else { + sourceData = srcObj; + } + } // Perform deep merge - r.resource = merge(r.resource, sourceData as Record) as Record; + r.resource = merge(r.resource, sourceData) as Record; } /** From 32b0cfc3be8662428678be3bdccface3fcf33907 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 5 Mar 2026 14:18:44 +0100 Subject: [PATCH 3/5] add capability support Signed-off-by: Steven Borrelli --- src/index.ts | 1 + src/request/request.test.ts | 440 ++++++++++++++++++++++++++++++++++ src/resource/resource.test.ts | 109 ++++++++- src/resource/resource.ts | 126 +++++++--- 4 files changed, 639 insertions(+), 37 deletions(-) diff --git a/src/index.ts b/src/index.ts index b2b8e50..8595111 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ export { fromModel, fromObject, getCondition, + type MergeOptions, mustStructJSON, mustStructObject, newDesiredComposed, diff --git a/src/request/request.test.ts b/src/request/request.test.ts index 524d48b..6cc0b26 100644 --- a/src/request/request.test.ts +++ b/src/request/request.test.ts @@ -11,6 +11,9 @@ import { getRequiredResource, getRequiredSchema, getRequiredSchemas, + advertiseCapabilities, + hasCapability, + getWatchedResource, } from './request.js'; import type { RunFunctionRequest, @@ -18,6 +21,7 @@ import type { Resources, Credentials, } from '../proto/run_function.js'; +import { Capability } from '../proto/run_function.js'; describe('getDesiredCompositeResource', () => { it('should return undefined when no desired composite exists', () => { @@ -1106,3 +1110,439 @@ describe('getRequiredSchema', () => { expect(schema).toBeUndefined(); }); }); + +describe('advertiseCapabilities', () => { + it('should return false when meta is undefined', () => { + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(advertiseCapabilities(req)).toBe(false); + }); + + it('should return false when meta exists without CAPABILITY_CAPABILITIES', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [Capability.CAPABILITY_REQUIRED_RESOURCES], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(advertiseCapabilities(req)).toBe(false); + }); + + it('should return false when capabilities is empty array', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(advertiseCapabilities(req)).toBe(false); + }); + + it('should return false when capabilities present but CAPABILITY_CAPABILITIES not included', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [Capability.CAPABILITY_REQUIRED_RESOURCES, Capability.CAPABILITY_CONDITIONS], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(advertiseCapabilities(req)).toBe(false); + }); + + it('should return true when CAPABILITY_CAPABILITIES is present', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [Capability.CAPABILITY_CAPABILITIES], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(advertiseCapabilities(req)).toBe(true); + }); + + it('should return true when CAPABILITY_CAPABILITIES is present among other capabilities', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [ + Capability.CAPABILITY_CAPABILITIES, + Capability.CAPABILITY_REQUIRED_RESOURCES, + Capability.CAPABILITY_CONDITIONS, + Capability.CAPABILITY_REQUIRED_SCHEMAS, + ], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(advertiseCapabilities(req)).toBe(true); + }); +}); + +describe('hasCapability', () => { + it('should return false when meta is undefined', () => { + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_RESOURCES)).toBe(false); + }); + + it('should return false when requested capability is absent', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [Capability.CAPABILITY_CONDITIONS], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_RESOURCES)).toBe(false); + }); + + it('should return false when capabilities is empty array', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_RESOURCES)).toBe(false); + }); + + it('should return false when requested capability is not present', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [Capability.CAPABILITY_CAPABILITIES, Capability.CAPABILITY_CONDITIONS], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_RESOURCES)).toBe(false); + }); + + it('should return true when requested capability is present', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [ + Capability.CAPABILITY_CAPABILITIES, + Capability.CAPABILITY_REQUIRED_RESOURCES, + ], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_RESOURCES)).toBe(true); + }); + + it('should work with all capability types', () => { + const req: RunFunctionRequest = { + meta: { + tag: 'test', + capabilities: [ + Capability.CAPABILITY_CAPABILITIES, + Capability.CAPABILITY_REQUIRED_RESOURCES, + Capability.CAPABILITY_CREDENTIALS, + Capability.CAPABILITY_CONDITIONS, + Capability.CAPABILITY_REQUIRED_SCHEMAS, + ], + }, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + expect(hasCapability(req, Capability.CAPABILITY_CAPABILITIES)).toBe(true); + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_RESOURCES)).toBe(true); + expect(hasCapability(req, Capability.CAPABILITY_CREDENTIALS)).toBe(true); + expect(hasCapability(req, Capability.CAPABILITY_CONDITIONS)).toBe(true); + expect(hasCapability(req, Capability.CAPABILITY_REQUIRED_SCHEMAS)).toBe(true); + expect(hasCapability(req, Capability.CAPABILITY_UNSPECIFIED)).toBe(false); + }); +}); + +describe('getWatchedResource', () => { + it('should return undefined when no required resources exist', () => { + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: {}, + requiredSchemas: {}, + }; + + const result = getWatchedResource(req); + expect(result).toBeUndefined(); + }); + + it('should return undefined when watched resource has no items', () => { + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: { + 'ops.crossplane.io/watched-resource': { + items: [], + }, + }, + requiredSchemas: {}, + }; + + const result = getWatchedResource(req); + expect(result).toBeUndefined(); + }); + + it('should return the watched resource when present', () => { + const watchedResource = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'my-config', + namespace: 'default', + }, + data: { + key: 'value', + }, + }; + + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: { + 'ops.crossplane.io/watched-resource': { + items: [ + { + resource: watchedResource, + connectionDetails: {}, + ready: 0, + }, + ], + }, + }, + requiredSchemas: {}, + }; + + const result = getWatchedResource(req); + expect(result).toEqual(watchedResource); + }); + + it('should return the first resource when multiple resources exist', () => { + const firstResource = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'first-config', + }, + }; + + const secondResource = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'second-config', + }, + }; + + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: { + 'ops.crossplane.io/watched-resource': { + items: [ + { + resource: firstResource, + connectionDetails: {}, + ready: 0, + }, + { + resource: secondResource, + connectionDetails: {}, + ready: 0, + }, + ], + }, + }, + requiredSchemas: {}, + }; + + const result = getWatchedResource(req); + expect(result).toEqual(firstResource); + }); + + it('should handle watched resource with complex nested structure', () => { + const watchedResource = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: 'my-deployment', + namespace: 'production', + labels: { + app: 'web', + env: 'prod', + }, + }, + spec: { + replicas: 3, + selector: { + matchLabels: { + app: 'web', + }, + }, + template: { + spec: { + containers: [ + { + name: 'app', + image: 'nginx:latest', + }, + ], + }, + }, + }, + status: { + readyReplicas: 3, + }, + }; + + const req: RunFunctionRequest = { + meta: undefined, + observed: undefined, + desired: undefined, + input: undefined, + context: undefined, + extraResources: {}, + credentials: {}, + requiredResources: { + 'ops.crossplane.io/watched-resource': { + items: [ + { + resource: watchedResource, + connectionDetails: {}, + ready: 1, + }, + ], + }, + }, + requiredSchemas: {}, + }; + + const result = getWatchedResource(req); + expect(result).toEqual(watchedResource); + expect(result?.metadata).toEqual(watchedResource.metadata); + expect(result?.spec).toEqual(watchedResource.spec); + expect(result?.status).toEqual(watchedResource.status); + }); +}); diff --git a/src/resource/resource.test.ts b/src/resource/resource.test.ts index c57621b..6b01477 100644 --- a/src/resource/resource.test.ts +++ b/src/resource/resource.test.ts @@ -398,7 +398,7 @@ describe('update', () => { }); }); - it('should overwrite arrays rather than merging them', () => { + it('should replace arrays by default (Python SDK behavior)', () => { const resource = Resource.fromJSON({ resource: { spec: { @@ -413,13 +413,118 @@ describe('update', () => { }, }); - // ts-deepmerge merges arrays, so this will concatenate + // Default behavior: arrays are replaced, not concatenated + expect(resource.resource).toEqual({ + spec: { + items: [4, 5], + }, + }); + }); + + it('should concatenate arrays when mergeArrays is true', () => { + const resource = Resource.fromJSON({ + resource: { + spec: { + items: [1, 2, 3], + }, + }, + }); + + update( + resource, + { + spec: { + items: [4, 5], + }, + }, + { mergeArrays: true } + ); + + // With mergeArrays: true, arrays are concatenated expect(resource.resource).toEqual({ spec: { items: [1, 2, 3, 4, 5], }, }); }); + + it('should replace tags array by default', () => { + const resource = Resource.fromJSON({ + resource: { + spec: { + forProvider: { + tags: ['env:dev', 'team:backend'], + }, + }, + }, + }); + + update(resource, { + spec: { + forProvider: { + tags: ['env:prod', 'team:platform'], + }, + }, + }); + + expect(resource.resource).toEqual({ + spec: { + forProvider: { + tags: ['env:prod', 'team:platform'], + }, + }, + }); + }); + + it('should replace finalizers array', () => { + const resource = Resource.fromJSON({ + resource: { + metadata: { + name: 'test-resource', + finalizers: ['finalizer.example.com/cleanup'], + }, + }, + }); + + update(resource, { + metadata: { + finalizers: ['finalizer.example.com/new-cleanup'], + }, + }); + + expect(resource.resource).toEqual({ + metadata: { + name: 'test-resource', + finalizers: ['finalizer.example.com/new-cleanup'], + }, + }); + }); + + it('should handle nested arrays correctly', () => { + const resource = Resource.fromJSON({ + resource: { + spec: { + containers: [ + { name: 'app', image: 'app:v1' }, + { name: 'sidecar', image: 'sidecar:v1' }, + ], + }, + }, + }); + + update(resource, { + spec: { + containers: [{ name: 'app', image: 'app:v2' }], + }, + }); + + // Arrays are replaced, not merged + expect(resource.resource).toEqual({ + spec: { + containers: [{ name: 'app', image: 'app:v2' }], + }, + }); + }); }); describe('getCondition', () => { diff --git a/src/resource/resource.ts b/src/resource/resource.ts index c6cf328..cf234fd 100644 --- a/src/resource/resource.ts +++ b/src/resource/resource.ts @@ -1,6 +1,6 @@ // Resource utilities for working with Kubernetes resources and protobuf conversion -import { Ready, Resource } from '../proto/run_function.js'; -import { merge } from 'ts-deepmerge'; +import { Ready, Resource } from "../proto/run_function.js"; +import { merge } from "ts-deepmerge"; // Type aliases for better readability export type ConnectionDetails = { [key: string]: Buffer }; @@ -21,6 +21,19 @@ export interface Condition { lastTransitionTime?: string; } +/** + * Options for controlling merge behavior when updating resources + */ +export interface MergeOptions { + /** + * Whether to merge arrays by concatenation (true) or replace them (false). + * Default is false. + * + * @default false + */ + mergeArrays?: boolean; +} + /** * Composite represents a Crossplane composite resource (XR) with its state */ @@ -68,7 +81,9 @@ export function newDesiredComposed(): DesiredComposed { * @param struct - The protobuf Struct to convert * @returns A plain JavaScript object representing the Kubernetes resource */ -export function asObject(struct: Record | undefined): Record { +export function asObject( + struct: Record | undefined, +): Record { if (!struct) { return {}; } @@ -90,7 +105,9 @@ export function asObject(struct: Record | undefined): Record): Record { +export function asStruct( + obj: Record, +): Record { // In our TypeScript implementation, this is essentially a pass-through // The actual conversion happens in the protobuf serialization layer return obj; @@ -109,14 +126,16 @@ export function asStruct(obj: Record): Record * @returns A Struct representation * @throws Error if conversion fails */ -export function mustStructObject(obj: Record): Record { +export function mustStructObject( + obj: Record, +): Record { try { return asStruct(obj); } catch (error) { throw new Error( `Failed to convert object to struct: ${ error instanceof Error ? error.message : String(error) - }` + }`, ); } } @@ -140,7 +159,9 @@ export function mustStructJSON(json: string): Record { return asStruct(obj); } catch (error) { throw new Error( - `Failed to parse JSON to struct: ${error instanceof Error ? error.message : String(error)}` + `Failed to parse JSON to struct: ${ + error instanceof Error ? error.message : String(error) + }`, ); } } @@ -157,7 +178,7 @@ export function mustStructJSON(json: string): Record { export function fromObject( obj: Record, connectionDetails?: ConnectionDetails, - ready?: Ready + ready?: Ready, ): Resource { return Resource.fromJSON({ resource: obj, @@ -173,7 +194,9 @@ export function fromObject( * @param resource - The Resource to extract from * @returns The plain JavaScript object, or undefined if not present */ -export function toObject(resource: Resource): Record | undefined { +export function toObject( + resource: Resource, +): Record | undefined { return resource.resource; } @@ -205,7 +228,7 @@ export function toObject(resource: Resource): Record | undefine export function fromModel>( obj: { toJSON: () => T }, connectionDetails?: ConnectionDetails, - ready?: Ready + ready?: Ready, ): Resource { // T already extends Record, so the cast is unnecessary. // Pass the result directly to fromObject which accepts T via its signature. @@ -220,47 +243,68 @@ export function fromModel>( * a plain JavaScript object, a protobuf Struct (Record), or a * Resource object. * - * The merge semantics are similar to a dictionary's update method: fields that don't - * exist will be added, and fields that exist will be overwritten. + * The merge semantics match the Python SDK's behavior (similar to a dictionary's + * update method): fields that don't exist will be added, and fields that exist will + * be overwritten. By default, arrays are replaced rather than concatenated to match + * Python SDK behavior. * * @param r - The Resource to update * @param source - The source data to merge (plain object, Struct, or Resource) + * @param options - Optional merge configuration (default: { mergeArrays: false }) * * @example * ```typescript * const bucket = getDesiredComposedResources(req)["my-bucket"]; * if (bucket) { * // Update specific fields while preserving others + * // Arrays are replaced by default (Python SDK behavior) * update(bucket, { * resource: { * spec: { * forProvider: { * region: "us-west-2", - * tags: { environment: "production" } + * tags: ["env:prod", "team:platform"] // Replaces existing tags * } * } * } * }); * } * + * // Optionally concatenate arrays instead of replacing them + * update(bucket, { + * resource: { + * spec: { + * forProvider: { + * tags: ["new-tag"] // Will be appended to existing tags + * } + * } + * } + * }, { mergeArrays: true }); + * * // You can also merge from another Resource * const template = Resource.fromJSON({ resource: templateConfig }); * update(bucket, template); * ``` */ -export function update(r: Resource, source: Record | Resource): void { +export function update( + r: Resource, + source: Record | Resource, + options?: MergeOptions, +): void { if (!r.resource) { r.resource = {}; } + // Default to Python SDK behavior: replace arrays rather than concatenating + const mergeArrays = options?.mergeArrays ?? false; + // Detect genuine protobuf `Resource` instances by checking for protobuf-specific fields // (the generated Resource interface includes `connectionDetails` and `ready`). - const isProtoResource = - typeof source === 'object' && + const isProtoResource = typeof source === "object" && source !== null && - 'resource' in source && - typeof (source as { resource?: unknown }).resource === 'object' && - ('connectionDetails' in source || 'ready' in source); + "resource" in source && + typeof (source as { resource?: unknown }).resource === "object" && + ("connectionDetails" in source || "ready" in source); let sourceData: Record; @@ -273,10 +317,10 @@ export function update(r: Resource, source: Record | Resource): // `metadata` into the resource data so callers that pass plain objects keep // expected semantics. const srcObj = source as Record; - if (srcObj && 'resource' in srcObj && typeof srcObj.resource === 'object') { + if (srcObj && "resource" in srcObj && typeof srcObj.resource === "object") { const resourcePart = srcObj.resource as Record; - if ('metadata' in srcObj && typeof srcObj.metadata === 'object') { - sourceData = merge(resourcePart, { + if ("metadata" in srcObj && typeof srcObj.metadata === "object") { + sourceData = merge.withOptions({ mergeArrays }, resourcePart, { metadata: srcObj.metadata, }) as Record; } else { @@ -287,8 +331,15 @@ export function update(r: Resource, source: Record | Resource): } } - // Perform deep merge - r.resource = merge(r.resource, sourceData) as Record; + // Perform deep merge with configured array handling + r.resource = merge.withOptions( + { mergeArrays }, + r.resource, + sourceData, + ) as Record< + string, + unknown + >; } /** @@ -326,20 +377,22 @@ export function update(r: Resource, source: Record | Resource): */ export function getCondition( resource: Record | undefined, - type: string + type: string, ): Condition { - const unknown: Condition = { type, status: 'Unknown' }; + const unknown: Condition = { type, status: "Unknown" }; - if (!resource || !('status' in resource)) { + if (!resource || !("status" in resource)) { return unknown; } const status = resource.status as Record | undefined; - if (!status || !('conditions' in status)) { + if (!status || !("conditions" in status)) { return unknown; } - const conditions = status.conditions as Array> | undefined; + const conditions = status.conditions as + | Array> + | undefined; if (!Array.isArray(conditions)) { return unknown; } @@ -355,16 +408,19 @@ export function getCondition( }; if (c.message !== undefined && c.message !== null) { - condition.message = typeof c.message === 'string' ? c.message : JSON.stringify(c.message); + condition.message = typeof c.message === "string" + ? c.message + : JSON.stringify(c.message); } if (c.reason !== undefined && c.reason !== null) { - condition.reason = typeof c.reason === 'string' ? c.reason : JSON.stringify(c.reason); + condition.reason = typeof c.reason === "string" + ? c.reason + : JSON.stringify(c.reason); } if (c.lastTransitionTime !== undefined && c.lastTransitionTime !== null) { - condition.lastTransitionTime = - typeof c.lastTransitionTime === 'string' - ? c.lastTransitionTime - : JSON.stringify(c.lastTransitionTime); + condition.lastTransitionTime = typeof c.lastTransitionTime === "string" + ? c.lastTransitionTime + : JSON.stringify(c.lastTransitionTime); } return condition; From 04e9c857f6c4c43cb220ab847cc632220e532d20 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 5 Mar 2026 14:39:06 +0100 Subject: [PATCH 4/5] npm run format Signed-off-by: Steven Borrelli --- src/resource/resource.ts | 78 ++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/src/resource/resource.ts b/src/resource/resource.ts index cf234fd..8ff0e8c 100644 --- a/src/resource/resource.ts +++ b/src/resource/resource.ts @@ -1,6 +1,6 @@ // Resource utilities for working with Kubernetes resources and protobuf conversion -import { Ready, Resource } from "../proto/run_function.js"; -import { merge } from "ts-deepmerge"; +import { Ready, Resource } from '../proto/run_function.js'; +import { merge } from 'ts-deepmerge'; // Type aliases for better readability export type ConnectionDetails = { [key: string]: Buffer }; @@ -81,9 +81,7 @@ export function newDesiredComposed(): DesiredComposed { * @param struct - The protobuf Struct to convert * @returns A plain JavaScript object representing the Kubernetes resource */ -export function asObject( - struct: Record | undefined, -): Record { +export function asObject(struct: Record | undefined): Record { if (!struct) { return {}; } @@ -105,9 +103,7 @@ export function asObject( * @param obj - The plain JavaScript object to convert * @returns A protobuf Struct representation */ -export function asStruct( - obj: Record, -): Record { +export function asStruct(obj: Record): Record { // In our TypeScript implementation, this is essentially a pass-through // The actual conversion happens in the protobuf serialization layer return obj; @@ -126,16 +122,14 @@ export function asStruct( * @returns A Struct representation * @throws Error if conversion fails */ -export function mustStructObject( - obj: Record, -): Record { +export function mustStructObject(obj: Record): Record { try { return asStruct(obj); } catch (error) { throw new Error( `Failed to convert object to struct: ${ error instanceof Error ? error.message : String(error) - }`, + }` ); } } @@ -159,9 +153,7 @@ export function mustStructJSON(json: string): Record { return asStruct(obj); } catch (error) { throw new Error( - `Failed to parse JSON to struct: ${ - error instanceof Error ? error.message : String(error) - }`, + `Failed to parse JSON to struct: ${error instanceof Error ? error.message : String(error)}` ); } } @@ -178,7 +170,7 @@ export function mustStructJSON(json: string): Record { export function fromObject( obj: Record, connectionDetails?: ConnectionDetails, - ready?: Ready, + ready?: Ready ): Resource { return Resource.fromJSON({ resource: obj, @@ -194,9 +186,7 @@ export function fromObject( * @param resource - The Resource to extract from * @returns The plain JavaScript object, or undefined if not present */ -export function toObject( - resource: Resource, -): Record | undefined { +export function toObject(resource: Resource): Record | undefined { return resource.resource; } @@ -228,7 +218,7 @@ export function toObject( export function fromModel>( obj: { toJSON: () => T }, connectionDetails?: ConnectionDetails, - ready?: Ready, + ready?: Ready ): Resource { // T already extends Record, so the cast is unnecessary. // Pass the result directly to fromObject which accepts T via its signature. @@ -289,7 +279,7 @@ export function fromModel>( export function update( r: Resource, source: Record | Resource, - options?: MergeOptions, + options?: MergeOptions ): void { if (!r.resource) { r.resource = {}; @@ -300,11 +290,12 @@ export function update( // Detect genuine protobuf `Resource` instances by checking for protobuf-specific fields // (the generated Resource interface includes `connectionDetails` and `ready`). - const isProtoResource = typeof source === "object" && + const isProtoResource = + typeof source === 'object' && source !== null && - "resource" in source && - typeof (source as { resource?: unknown }).resource === "object" && - ("connectionDetails" in source || "ready" in source); + 'resource' in source && + typeof (source as { resource?: unknown }).resource === 'object' && + ('connectionDetails' in source || 'ready' in source); let sourceData: Record; @@ -317,9 +308,9 @@ export function update( // `metadata` into the resource data so callers that pass plain objects keep // expected semantics. const srcObj = source as Record; - if (srcObj && "resource" in srcObj && typeof srcObj.resource === "object") { + if (srcObj && 'resource' in srcObj && typeof srcObj.resource === 'object') { const resourcePart = srcObj.resource as Record; - if ("metadata" in srcObj && typeof srcObj.metadata === "object") { + if ('metadata' in srcObj && typeof srcObj.metadata === 'object') { sourceData = merge.withOptions({ mergeArrays }, resourcePart, { metadata: srcObj.metadata, }) as Record; @@ -332,11 +323,7 @@ export function update( } // Perform deep merge with configured array handling - r.resource = merge.withOptions( - { mergeArrays }, - r.resource, - sourceData, - ) as Record< + r.resource = merge.withOptions({ mergeArrays }, r.resource, sourceData) as Record< string, unknown >; @@ -377,22 +364,20 @@ export function update( */ export function getCondition( resource: Record | undefined, - type: string, + type: string ): Condition { - const unknown: Condition = { type, status: "Unknown" }; + const unknown: Condition = { type, status: 'Unknown' }; - if (!resource || !("status" in resource)) { + if (!resource || !('status' in resource)) { return unknown; } const status = resource.status as Record | undefined; - if (!status || !("conditions" in status)) { + if (!status || !('conditions' in status)) { return unknown; } - const conditions = status.conditions as - | Array> - | undefined; + const conditions = status.conditions as Array> | undefined; if (!Array.isArray(conditions)) { return unknown; } @@ -408,19 +393,16 @@ export function getCondition( }; if (c.message !== undefined && c.message !== null) { - condition.message = typeof c.message === "string" - ? c.message - : JSON.stringify(c.message); + condition.message = typeof c.message === 'string' ? c.message : JSON.stringify(c.message); } if (c.reason !== undefined && c.reason !== null) { - condition.reason = typeof c.reason === "string" - ? c.reason - : JSON.stringify(c.reason); + condition.reason = typeof c.reason === 'string' ? c.reason : JSON.stringify(c.reason); } if (c.lastTransitionTime !== undefined && c.lastTransitionTime !== null) { - condition.lastTransitionTime = typeof c.lastTransitionTime === "string" - ? c.lastTransitionTime - : JSON.stringify(c.lastTransitionTime); + condition.lastTransitionTime = + typeof c.lastTransitionTime === 'string' + ? c.lastTransitionTime + : JSON.stringify(c.lastTransitionTime); } return condition; From 39dd3c2cfafd05d740bcdf864225b4a3581545df Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 5 Mar 2026 14:50:20 +0100 Subject: [PATCH 5/5] update based on copilot review Signed-off-by: Steven Borrelli --- src/resource/resource.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/resource/resource.ts b/src/resource/resource.ts index 8ff0e8c..4ace76f 100644 --- a/src/resource/resource.ts +++ b/src/resource/resource.ts @@ -308,9 +308,20 @@ export function update( // `metadata` into the resource data so callers that pass plain objects keep // expected semantics. const srcObj = source as Record; - if (srcObj && 'resource' in srcObj && typeof srcObj.resource === 'object') { + if ( + srcObj && + 'resource' in srcObj && + typeof srcObj.resource === 'object' && + srcObj.resource !== null && + !Array.isArray(srcObj.resource) + ) { const resourcePart = srcObj.resource as Record; - if ('metadata' in srcObj && typeof srcObj.metadata === 'object') { + if ( + 'metadata' in srcObj && + typeof srcObj.metadata === 'object' && + srcObj.metadata !== null && + !Array.isArray(srcObj.metadata) + ) { sourceData = merge.withOptions({ mergeArrays }, resourcePart, { metadata: srcObj.metadata, }) as Record;