diff --git a/src/index.ts b/src/index.ts index ebadd69..8595111 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,19 @@ export { asObject, asStruct, type Composite, + type Condition as ResourceCondition, type ConnectionDetails, type DesiredComposed, fromModel, fromObject, + getCondition, + type MergeOptions, mustStructJSON, mustStructObject, newDesiredComposed, type ObservedComposed, toObject, + update as updateResource, } from './resource/resource.js'; // Runtime utilities 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/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..6b01477 100644 --- a/src/resource/resource.test.ts +++ b/src/resource/resource.test.ts @@ -1,6 +1,17 @@ -import { describe, it, expect } from 'vitest'; -import { fromModel, fromObject, toObject } from './resource.js'; -import { Resource, Ready } from '../proto/run_function.js'; +import { describe, expect, it } from 'vitest'; +import { + asObject, + asStruct, + fromModel, + fromObject, + getCondition, + mustStructJSON, + mustStructObject, + newDesiredComposed, + toObject, + update, +} from './resource.js'; +import { Ready, Resource } from '../proto/run_function.js'; describe('fromModel', () => { it('should convert a kubernetes-models-like object with toJSON() method', () => { @@ -128,3 +139,560 @@ 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 replace arrays by default (Python SDK behavior)', () => { + const resource = Resource.fromJSON({ + resource: { + spec: { + items: [1, 2, 3], + }, + }, + }); + + update(resource, { + spec: { + items: [4, 5], + }, + }); + + // 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', () => { + 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..4ace76f 100644 --- a/src/resource/resource.ts +++ b/src/resource/resource.ts @@ -1,9 +1,39 @@ // 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; +} + +/** + * 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 */ @@ -194,3 +224,200 @@ 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 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: ["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, + 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' && + 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' && + srcObj.resource !== null && + !Array.isArray(srcObj.resource) + ) { + const resourcePart = srcObj.resource as Record; + 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; + } else { + sourceData = resourcePart; + } + } else { + sourceData = srcObj; + } + } + + // Perform deep merge with configured array handling + r.resource = merge.withOptions({ mergeArrays }, r.resource, sourceData) as Record< + string, + unknown + >; +} + +/** + * 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; +}