From 2c3f9ec968ba367f4f17447599cbee9c9a013529 Mon Sep 17 00:00:00 2001 From: Jay Windsor Date: Mon, 17 Jul 2023 21:53:43 -0400 Subject: [PATCH] add KubernetesObjectApi for v1 --- examples/typescript/apply/apply-example.ts | 4 +- src/index.ts | 1 + src/object.ts | 673 +++++++ src/object_test.ts | 2128 ++++++++++++++++++++ src/patch.ts | 17 +- 5 files changed, 2816 insertions(+), 7 deletions(-) create mode 100644 src/object.ts create mode 100644 src/object_test.ts diff --git a/examples/typescript/apply/apply-example.ts b/examples/typescript/apply/apply-example.ts index 0dc2572e4dc..d3bf20568b7 100644 --- a/examples/typescript/apply/apply-example.ts +++ b/examples/typescript/apply/apply-example.ts @@ -33,11 +33,11 @@ export async function apply(specPath: string): Promise { await client.read(spec); // we got the resource, so it exists, so patch it const response = await client.patch(spec); - created.push(response.body); + created.push(response); } catch (e) { // we did not get the resource, so it does not exist, so create it const response = await client.create(spec); - created.push(response.body); + created.push(response); } } return created; diff --git a/src/index.ts b/src/index.ts index 7df7307973e..af5bb374d9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,4 +13,5 @@ export * from './top'; export * from './cp'; export * from './patch'; export * from './metrics'; +export * from './object'; export { ConfigOptions, User, Cluster, Context } from './config_types'; diff --git a/src/object.ts b/src/object.ts new file mode 100644 index 00000000000..5207fe38010 --- /dev/null +++ b/src/object.ts @@ -0,0 +1,673 @@ +import { + ApiException, + Configuration, + HttpMethod, + RequestContext, + ResponseContext, + SecurityAuthentication, + V1APIResource, + V1APIResourceList, + V1DeleteOptions, + V1Status, +} from './api'; +import { KubeConfig } from './config'; +import { KubernetesListObject, KubernetesObject } from './types'; +import { ObjectSerializer } from './util'; +import { from, mergeMap, of } from './gen/rxjsStub'; +import { PatchStrategy } from './patch'; + +/** Kubernetes API verbs. */ +type KubernetesApiAction = 'create' | 'delete' | 'patch' | 'read' | 'list' | 'replace'; + +interface GroupVersion { + group: string; + version: string; +} + +/** + * Dynamically construct Kubernetes API request URIs so client does not have to know what type of object it is acting + * on. + */ +export class KubernetesObjectApi { + /** + * Create a KubernetesObjectApi object from the provided KubeConfig. This method should be used rather than + * [[KubeConfig.makeApiClient]] so we can properly determine the default namespace if one is provided by the current + * context. + * + * @param kc Valid Kubernetes config + * @return Properly instantiated [[KubernetesObjectApi]] object + */ + public static makeApiClient(kc: KubeConfig): KubernetesObjectApi { + const client = kc.makeApiClient(KubernetesObjectApi); + client.setDefaultNamespace(kc); + return client; + } + + /** Initialize the default namespace. May be overwritten by context. */ + protected defaultNamespace: string = 'default'; + + /** Cache resource API response. */ + protected apiVersionResourceCache: Record = {}; + + constructor(protected configuration: Configuration) {} + + /** + * Create any Kubernetes resource. + * @param spec Kubernetes resource spec. + * @param pretty If \'true\', then the output is pretty printed. + * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized + * dryRun directive will result in an error response and no further processing of the request. Valid values + * are: - All: all dry run stages will be processed + * @param fieldManager fieldManager is a name associated with the actor or entity that is making these changes. The + * value must be less than or 128 characters long, and only contain printable characters, as defined by + * https://golang.org/pkg/unicode/#IsPrint. + * @param options Optional headers to use in the request. + * @return Promise containing the request response and [[KubernetesObject]]. + */ + public async create( + spec: T, + pretty?: string, + dryRun?: string, + fieldManager?: string, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + // verify required parameter 'spec' is not null or undefined + if (spec === null || spec === undefined) { + throw new Error('Required parameter spec was null or undefined when calling create.'); + } + + const localVarPath = await this.specUriPath(spec, 'create'); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.POST); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + + if (pretty !== undefined) { + requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string')); + } + + if (dryRun !== undefined) { + requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string')); + } + + if (fieldManager !== undefined) { + requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string')); + } + + // Body Params + const contentType = ObjectSerializer.getPreferredMediaType([]); + requestContext.setHeaderParam('Content-Type', contentType); + const serializedBody = ObjectSerializer.stringify( + ObjectSerializer.serialize(spec, 'any'), + contentType, + ); + requestContext.setBody(serializedBody); + + return this.requestPromise(requestContext); + } + + /** + * Delete any Kubernetes resource. + * @param spec Kubernetes resource spec + * @param pretty If \'true\', then the output is pretty printed. + * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized + * dryRun directive will result in an error response and no further processing of the request. Valid values + * are: - All: all dry run stages will be processed + * @param gracePeriodSeconds The duration in seconds before the object should be deleted. Value must be non-negative + * integer. The value zero indicates delete immediately. If this value is nil, the default grace period for + * the specified type will be used. Defaults to a per object value if not specified. zero means delete + * immediately. + * @param orphanDependents Deprecated: please use the PropagationPolicy, this field will be deprecated in + * 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be + * added to/removed from the object\'s finalizers list. Either this field or PropagationPolicy may be + * set, but not both. + * @param propagationPolicy Whether and how garbage collection will be performed. Either this field or + * OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in + * the metadata.finalizers and the resource-specific default policy. Acceptable values are: + * \'Orphan\' - orphan the dependents; \'Background\' - allow the garbage collector to delete + * the dependents in the background; \'Foreground\' - a cascading policy that deletes all dependents + * in the foreground. + * @param body See [[V1DeleteOptions]]. + * @param options Optional headers to use in the request. + * @return Promise containing the request response and a Kubernetes [[V1Status]]. + */ + public async delete( + spec: KubernetesObject, + pretty?: string, + dryRun?: string, + gracePeriodSeconds?: number, + orphanDependents?: boolean, + propagationPolicy?: string, + body?: V1DeleteOptions, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + // verify required parameter 'spec' is not null or undefined + if (spec === null || spec === undefined) { + throw new Error('Required parameter spec was null or undefined when calling delete.'); + } + + const localVarPath = await this.specUriPath(spec, 'delete'); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.DELETE); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + + if (pretty !== undefined) { + requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string')); + } + + if (dryRun !== undefined) { + requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string')); + } + + if (gracePeriodSeconds !== undefined) { + requestContext.setQueryParam( + 'gracePeriodSeconds', + ObjectSerializer.serialize(gracePeriodSeconds, 'number'), + ); + } + + if (orphanDependents !== undefined) { + requestContext.setQueryParam( + 'orphanDependents', + ObjectSerializer.serialize(orphanDependents, 'boolean'), + ); + } + + if (propagationPolicy !== undefined) { + requestContext.setQueryParam( + 'propagationPolicy', + ObjectSerializer.serialize(propagationPolicy, 'string'), + ); + } + + // Body Params + if (body) { + const contentType = ObjectSerializer.getPreferredMediaType([]); + requestContext.setHeaderParam('Content-Type', contentType); + const serializedBody = ObjectSerializer.stringify( + ObjectSerializer.serialize(body, 'V1DeleteOptions'), + contentType, + ); + requestContext.setBody(serializedBody); + } + + return this.requestPromise(requestContext, 'V1Status'); + } + + /** + * Patch any Kubernetes resource. + * @param spec Kubernetes resource spec + * @param pretty If \'true\', then the output is pretty printed. + * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized + * dryRun directive will result in an error response and no further processing of the request. Valid values + * are: - All: all dry run stages will be processed + * @param fieldManager fieldManager is a name associated with the actor or entity that is making these changes. The + * value must be less than or 128 characters long, and only contain printable characters, as defined by + * https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests + * (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, + * StrategicMergePatch). + * @param force Force is going to \"force\" Apply requests. It means user will re-acquire conflicting + * fields owned by other people. Force flag must be unset for non-apply patch requests. + * @param patchStrategy Content-Type header used to control how the patch will be performed. See + * See https://kubernetes.io/docs/tasks/run-application/update-api-object-kubectl-patch/ + * for details. + * @param options Optional headers to use in the request. + * @return Promise containing the request response and [[KubernetesObject]]. + */ + public async patch( + spec: T, + pretty?: string, + dryRun?: string, + fieldManager?: string, + force?: boolean, + patchStrategy: PatchStrategy = PatchStrategy.StrategicMergePatch, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + // verify required parameter 'spec' is not null or undefined + if (spec === null || spec === undefined) { + throw new Error('Required parameter spec was null or undefined when calling patch.'); + } + + const localVarPath = await this.specUriPath(spec, 'patch'); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.PATCH); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + requestContext.setHeaderParam('Content-Type', patchStrategy); + + if (pretty !== undefined) { + requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string')); + } + + if (dryRun !== undefined) { + requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string')); + } + + if (fieldManager !== undefined) { + requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string')); + } + + if (force !== undefined) { + requestContext.setQueryParam('force', ObjectSerializer.serialize(force, 'boolean')); + } + + // Body Params + const serializedBody = ObjectSerializer.stringify( + ObjectSerializer.serialize(spec, 'any'), + // TODO: use the patch content type once ObjectSerializer supports it. + 'application/json', + ); + requestContext.setBody(serializedBody); + + return this.requestPromise(requestContext); + } + + /** + * Read any Kubernetes resource. + * @param spec Kubernetes resource spec + * @param pretty If \'true\', then the output is pretty printed. + * @param exact Should the export be exact. Exact export maintains cluster-specific fields like + * \'Namespace\'. Deprecated. Planned for removal in 1.18. + * @param exportt Should this value be exported. Export strips fields that a user can not + * specify. Deprecated. Planned for removal in 1.18. + * @param options Optional headers to use in the request. + * @return Promise containing the request response and [[KubernetesObject]]. + */ + public async read( + spec: T, + pretty?: string, + exact?: boolean, + exportt?: boolean, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + // verify required parameter 'spec' is not null or undefined + if (spec === null || spec === undefined) { + throw new Error('Required parameter spec was null or undefined when calling read.'); + } + // verify required parameter 'kind' is not null or undefined + if (spec.kind === null || spec.kind === undefined) { + throw new Error('Required parameter spec.kind was null or undefined when calling read.'); + } + if (!spec.apiVersion) { + throw new Error('Required parameter spec.apiVersion was null or undefined when calling read.'); + } + + const localVarPath = await this.specUriPath(spec, 'read'); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + + if (pretty !== undefined) { + requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string')); + } + + if (exact !== undefined) { + requestContext.setQueryParam('exact', ObjectSerializer.serialize(exact, 'boolean')); + } + + if (exportt !== undefined) { + requestContext.setQueryParam('export', ObjectSerializer.serialize(exportt, 'boolean')); + } + + return this.requestPromise(requestContext); + } + + /** + * List any Kubernetes resources. + * @param apiVersion api group and version of the form / + * @param kind Kubernetes resource kind + * @param namespace list resources in this namespace + * @param pretty If \'true\', then the output is pretty printed. + * @param exact Should the export be exact. Exact export maintains cluster-specific fields like + * \'Namespace\'. Deprecated. Planned for removal in 1.18. + * @param exportt Should this value be exported. Export strips fields that a user can not + * specify. Deprecated. Planned for removal in 1.18. + * @param fieldSelector A selector to restrict the list of returned objects by their fields. Defaults to everything. + * @param labelSelector A selector to restrict the list of returned objects by their labels. Defaults to everything. + * @param limit Number of returned resources. + * @param options Optional headers to use in the request. + * @return Promise containing the request response and [[KubernetesListObject]]. + */ + public async list( + apiVersion: string, + kind: string, + namespace?: string, + pretty?: string, + exact?: boolean, + exportt?: boolean, + fieldSelector?: string, + labelSelector?: string, + limit?: number, + continueToken?: string, + options?: Configuration, + ): Promise> { + const _config = options || this.configuration; + + // verify required parameters 'apiVersion', 'kind' is not null or undefined + if (apiVersion === null || apiVersion === undefined) { + throw new Error('Required parameter apiVersion was null or undefined when calling list.'); + } + if (kind === null || kind === undefined) { + throw new Error('Required parameter kind was null or undefined when calling list.'); + } + + const localVarPath = await this.specUriPath( + { + apiVersion, + kind, + metadata: { + namespace, + }, + }, + 'list', + ); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + + if (pretty !== undefined) { + requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string')); + } + + if (exact !== undefined) { + requestContext.setQueryParam('exact', ObjectSerializer.serialize(exact, 'boolean')); + } + + if (exportt !== undefined) { + requestContext.setQueryParam('export', ObjectSerializer.serialize(exportt, 'boolean')); + } + + if (fieldSelector !== undefined) { + requestContext.setQueryParam( + 'fieldSelector', + ObjectSerializer.serialize(fieldSelector, 'string'), + ); + } + + if (labelSelector !== undefined) { + requestContext.setQueryParam( + 'labelSelector', + ObjectSerializer.serialize(labelSelector, 'string'), + ); + } + + if (limit !== undefined) { + requestContext.setQueryParam('limit', ObjectSerializer.serialize(limit, 'number')); + } + + if (continueToken !== undefined) { + requestContext.setQueryParam('continue', ObjectSerializer.serialize(continueToken, 'string')); + } + + return this.requestPromise>(requestContext); + } + + /** + * Replace any Kubernetes resource. + * @param spec Kubernetes resource spec + * @param pretty If \'true\', then the output is pretty printed. + * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized + * dryRun directive will result in an error response and no further processing of the request. Valid values + * are: - All: all dry run stages will be processed + * @param fieldManager fieldManager is a name associated with the actor or entity that is making these changes. The + * value must be less than or 128 characters long, and only contain printable characters, as defined by + * https://golang.org/pkg/unicode/#IsPrint. + * @param options Optional headers to use in the request. + * @return Promise containing the request response and [[KubernetesObject]]. + */ + public async replace( + spec: T, + pretty?: string, + dryRun?: string, + fieldManager?: string, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + // verify required parameter 'spec' is not null or undefined + if (spec === null || spec === undefined) { + throw new Error('Required parameter spec was null or undefined when calling replace.'); + } + + const localVarPath = await this.specUriPath(spec, 'replace'); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.PUT); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + + if (pretty !== undefined) { + requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string')); + } + + if (dryRun !== undefined) { + requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string')); + } + + if (fieldManager !== undefined) { + requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string')); + } + + // Body Params + const contentType = ObjectSerializer.getPreferredMediaType([]); + requestContext.setHeaderParam('Content-Type', contentType); + const serializedBody = ObjectSerializer.stringify( + ObjectSerializer.serialize(spec, 'any'), + contentType, + ); + requestContext.setBody(serializedBody); + + return this.requestPromise(requestContext); + } + + /** Set default namespace from current context, if available. */ + protected setDefaultNamespace(kc: KubeConfig): string { + if (kc.currentContext) { + const currentContext = kc.getContextObject(kc.currentContext); + if (currentContext && currentContext.namespace) { + this.defaultNamespace = currentContext.namespace; + } + } + return this.defaultNamespace; + } + + /** + * Use spec information to construct resource URI path. If any required information in not provided, an Error is + * thrown. If an `apiVersion` is not provided, 'v1' is used. If a `metadata.namespace` is not provided for a + * request that requires one, the context default is used, if available, if not, 'default' is used. + * + * @param spec Kubernetes resource spec which must define kind and apiVersion properties. + * @param action API action, see [[K8sApiAction]]. + * @return tail of resource-specific URIDeploym + */ + protected async specUriPath(spec: KubernetesObject, action: KubernetesApiAction): Promise { + if (!spec.kind) { + throw new Error('Required spec property kind is not set'); + } + if (!spec.apiVersion) { + spec.apiVersion = 'v1'; + } + if (!spec.metadata) { + spec.metadata = {}; + } + const resource = await this.resource(spec.apiVersion, spec.kind); + if (!resource) { + throw new Error(`Unrecognized API version and kind: ${spec.apiVersion} ${spec.kind}`); + } + if (resource.namespaced && !spec.metadata.namespace && action !== 'list') { + spec.metadata.namespace = this.defaultNamespace; + } + const parts = [this.apiVersionPath(spec.apiVersion)]; + if (resource.namespaced && spec.metadata.namespace) { + parts.push('namespaces', encodeURIComponent(String(spec.metadata.namespace))); + } + parts.push(resource.name); + if (action !== 'create' && action !== 'list') { + if (!spec.metadata.name) { + throw new Error('Required spec property name is not set'); + } + parts.push(encodeURIComponent(String(spec.metadata.name))); + } + return parts.join('/').toLowerCase(); + } + + /** Return root of API path up to API version. */ + protected apiVersionPath(apiVersion: string): string { + const api = apiVersion.includes('/') ? 'apis' : 'api'; + return '/' + [api, apiVersion].join('/'); + } + + /** + * Get metadata from Kubernetes API for resources described by `kind` and `apiVersion`. If it is unable to find the + * resource `kind` under the provided `apiVersion`, `undefined` is returned. + * + * This method caches responses from the Kubernetes API to use for future requests. If the cache for apiVersion + * exists but the kind is not found the request is attempted again. + * + * @param apiVersion Kubernetes API version, e.g., 'v1' or 'apps/v1'. + * @param kind Kubernetes resource kind, e.g., 'Pod' or 'Namespace'. + * @return Promise of the resource metadata or `undefined` if the resource is not found. + */ + protected async resource( + apiVersion: string, + kind: string, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + // verify required parameter 'apiVersion' is not null or undefined + if (apiVersion === null || apiVersion === undefined) { + throw new Error('Required parameter apiVersion was null or undefined when calling resource'); + } + // verify required parameter 'kind' is not null or undefined + if (kind === null || kind === undefined) { + throw new Error('Required parameter kind was null or undefined when calling resource'); + } + + if (this.apiVersionResourceCache[apiVersion]) { + const resource = this.apiVersionResourceCache[apiVersion].resources.find((r) => r.kind === kind); + if (resource) { + return resource; + } + } + + const localVarPath = this.apiVersionPath(apiVersion); + + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); + requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8'); + + try { + const getApiResponse = await this.requestPromise( + requestContext, + 'V1APIResourceList', + ); + this.apiVersionResourceCache[apiVersion] = getApiResponse; + return this.apiVersionResourceCache[apiVersion].resources.find((r) => r.kind === kind); + } catch (e) { + if (e instanceof Error) { + e.message = `Failed to fetch resource metadata for ${apiVersion}/${kind}: ${e.message}`; + } + throw e; + } + } + + protected async getSerializationType(apiVersion?: string, kind?: string): Promise { + if (apiVersion === undefined || kind === undefined) { + return 'KubernetesObject'; + } + // Types are defined in src/gen/api/models with the format "". + // Version and Kind are in PascalCase. + const gv = this.groupVersion(apiVersion); + const version = gv.version.charAt(0).toUpperCase() + gv.version.slice(1); + return `${version}${kind}`; + } + + protected groupVersion(apiVersion: string): GroupVersion { + const v = apiVersion.split('/'); + return v.length === 1 + ? { + group: 'core', + version: apiVersion, + } + : { + group: v[0], + version: v[1], + }; + } + + protected async requestPromise( + requestContext: RequestContext, + type?: string, + options?: Configuration, + ): Promise { + const _config = options || this.configuration; + + let authMethod: SecurityAuthentication | undefined; + // Apply auth methods + authMethod = _config.authMethods.BearerToken; + if (authMethod?.applySecurityAuthentication) { + await authMethod?.applySecurityAuthentication(requestContext); + } + + const defaultAuth: SecurityAuthentication | undefined = + options?.authMethods?.default || this.configuration?.authMethods?.default; + if (defaultAuth?.applySecurityAuthentication) { + await defaultAuth?.applySecurityAuthentication(requestContext); + } + + // build promise chain + let middlewarePreObservable = from(Promise.resolve(requestContext)); + for (const middleware of this.configuration.middleware) { + middlewarePreObservable = middlewarePreObservable.pipe( + mergeMap((ctx: RequestContext) => middleware.pre(ctx)), + ); + } + + return middlewarePreObservable + .pipe(mergeMap((ctx: RequestContext) => this.configuration.httpApi.send(ctx))) + .pipe( + mergeMap((response: ResponseContext) => { + let middlewarePostObservable = of(response); + for (const middleware of this.configuration.middleware) { + middlewarePostObservable = middlewarePostObservable.pipe( + mergeMap((rsp: ResponseContext) => middleware.post(rsp)), + ); + } + return middlewarePostObservable.pipe((rsp: ResponseContext) => + this.processResponse(rsp, type), + ); + }), + ) + .toPromise(); + } + + protected async processResponse( + response: ResponseContext, + type?: string, + ): Promise { + const contentType = ObjectSerializer.normalizeMediaType(response.headers['content-type']); + if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { + const data = ObjectSerializer.parse(await response.body.text(), contentType); + if (type === undefined) { + type = await this.getSerializationType(data.apiVersion, data.kind); + } + + if (!type) { + throw new Error('Unable to determine type of Kubernetes type of response.'); + } + + return ObjectSerializer.deserialize(data, type) as T; + } + + throw new ApiException( + response.httpStatusCode, + 'Unsuccessful HTTP Request', + await response.getBodyAsAny(), + response.headers, + ); + } +} diff --git a/src/object_test.ts b/src/object_test.ts new file mode 100644 index 00000000000..805a8837207 --- /dev/null +++ b/src/object_test.ts @@ -0,0 +1,2128 @@ +import { fail } from 'assert'; +import { expect } from 'chai'; +import nock = require('nock'); +import { Configuration, V1APIResource, V1APIResourceList, V1Secret } from './api'; +import { KubeConfig } from './config'; +import { KubernetesObjectApi } from './object'; +import { KubernetesObject } from './types'; +import { of } from './gen/rxjsStub'; +import { ApiException } from '../dist'; + +describe('KubernetesObject', () => { + const testConfigOptions = { + clusters: [{ name: 'dc', server: 'https://d.i.y' }], + users: [{ name: 'ian', password: 'mackaye' }], + contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian' }], + currentContext: 'dischord', + }; + + describe('makeApiClient', () => { + it('should create the client', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(testConfigOptions); + const c = KubernetesObjectApi.makeApiClient(kc); + expect(c).to.be.ok; + expect((c as any).defaultNamespace).to.equal('default'); + }); + + it('should set the default namespace from context', () => { + const kc = new KubeConfig(); + kc.loadFromOptions({ + clusters: [{ name: 'dc', server: 'https://d.i.y' }], + users: [{ name: 'ian', password: 'mackaye' }], + contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian', namespace: 'straight-edge' }], + currentContext: 'dischord', + }); + const c = KubernetesObjectApi.makeApiClient(kc); + expect(c).to.be.ok; + expect((c as any).defaultNamespace).to.equal('straight-edge'); + }); + }); + + class KubernetesObjectApiTest extends KubernetesObjectApi { + constructor(public configuration: Configuration) { + super(configuration); + } + + public static makeApiClient(kc?: KubeConfig): KubernetesObjectApiTest { + if (!kc) { + kc = new KubeConfig(); + kc.loadFromOptions(testConfigOptions); + } + const client = kc.makeApiClient(KubernetesObjectApiTest); + client.setDefaultNamespace(kc); + return client; + } + public apiVersionResourceCache: Record = {}; + public async specUriPath(spec: KubernetesObject, method: any): Promise { + return super.specUriPath(spec, method); + } + public async resource(apiVersion: string, kind: string): Promise { + return super.resource(apiVersion, kind); + } + } + + const contentTypeJsonHeader = { + 'Content-Type': 'application/json', + }; + + const resourceBodies = { + core: `{ + "groupVersion": "v1", + "kind": "APIResourceList", + "resources": [ + { + "kind": "Binding", + "name": "bindings", + "namespaced": true + }, + { + "kind": "ComponentStatus", + "name": "componentstatuses", + "namespaced": false + }, + { + "kind": "ConfigMap", + "name": "configmaps", + "namespaced": true + }, + { + "kind": "Endpoints", + "name": "endpoints", + "namespaced": true + }, + { + "kind": "Event", + "name": "events", + "namespaced": true + }, + { + "kind": "LimitRange", + "name": "limitranges", + "namespaced": true + }, + { + "kind": "Namespace", + "name": "namespaces", + "namespaced": false + }, + { + "kind": "Namespace", + "name": "namespaces/finalize", + "namespaced": false + }, + { + "kind": "Namespace", + "name": "namespaces/status", + "namespaced": false + }, + { + "kind": "Node", + "name": "nodes", + "namespaced": false + }, + { + "kind": "NodeProxyOptions", + "name": "nodes/proxy", + "namespaced": false + }, + { + "kind": "Node", + "name": "nodes/status", + "namespaced": false + }, + { + "kind": "PersistentVolumeClaim", + "name": "persistentvolumeclaims", + "namespaced": true + }, + { + "kind": "PersistentVolumeClaim", + "name": "persistentvolumeclaims/status", + "namespaced": true + }, + { + "kind": "PersistentVolume", + "name": "persistentvolumes", + "namespaced": false + }, + { + "kind": "PersistentVolume", + "name": "persistentvolumes/status", + "namespaced": false + }, + { + "kind": "Pod", + "name": "pods", + "namespaced": true + }, + { + "kind": "PodAttachOptions", + "name": "pods/attach", + "namespaced": true + }, + { + "kind": "Binding", + "name": "pods/binding", + "namespaced": true + }, + { + "group": "policy", + "kind": "Eviction", + "name": "pods/eviction", + "namespaced": true, + "version": "v1beta1" + }, + { + "kind": "PodExecOptions", + "name": "pods/exec", + "namespaced": true + }, + { + "kind": "Pod", + "name": "pods/log", + "namespaced": true + }, + { + "kind": "PodPortForwardOptions", + "name": "pods/portforward", + "namespaced": true + }, + { + "kind": "PodProxyOptions", + "name": "pods/proxy", + "namespaced": true + }, + { + "kind": "Pod", + "name": "pods/status", + "namespaced": true + }, + { + "kind": "PodTemplate", + "name": "podtemplates", + "namespaced": true + }, + { + "kind": "ReplicationController", + "name": "replicationcontrollers", + "namespaced": true + }, + { + "group": "autoscaling", + "kind": "Scale", + "name": "replicationcontrollers/scale", + "namespaced": true, + "version": "v1" + }, + { + "kind": "ReplicationController", + "name": "replicationcontrollers/status", + "namespaced": true + }, + { + "kind": "ResourceQuota", + "name": "resourcequotas", + "namespaced": true + }, + { + "kind": "ResourceQuota", + "name": "resourcequotas/status", + "namespaced": true + }, + { + "kind": "Secret", + "name": "secrets", + "namespaced": true + }, + { + "kind": "ServiceAccount", + "name": "serviceaccounts", + "namespaced": true + }, + { + "kind": "Service", + "name": "services", + "namespaced": true + }, + { + "kind": "ServiceProxyOptions", + "name": "services/proxy", + "namespaced": true + }, + { + "kind": "Service", + "name": "services/status", + "namespaced": true + } + ] +}`, + + apps: `{ + "apiVersion": "v1", + "groupVersion": "apps/v1", + "kind": "APIResourceList", + "resources": [ + { + "kind": "ControllerRevision", + "name": "controllerrevisions", + "namespaced": true + }, + { + "kind": "DaemonSet", + "name": "daemonsets", + "namespaced": true + }, + { + "kind": "DaemonSet", + "name": "daemonsets/status", + "namespaced": true + }, + { + "kind": "Deployment", + "name": "deployments", + "namespaced": true + }, + { + "group": "autoscaling", + "kind": "Scale", + "name": "deployments/scale", + "namespaced": true, + "version": "v1" + }, + { + "kind": "Deployment", + "name": "deployments/status", + "namespaced": true + }, + { + "kind": "ReplicaSet", + "name": "replicasets", + "namespaced": true + }, + { + "group": "autoscaling", + "kind": "Scale", + "name": "replicasets/scale", + "namespaced": true, + "version": "v1" + }, + { + "kind": "ReplicaSet", + "name": "replicasets/status", + "namespaced": true + }, + { + "kind": "StatefulSet", + "name": "statefulsets", + "namespaced": true + }, + { + "group": "autoscaling", + "kind": "Scale", + "name": "statefulsets/scale", + "namespaced": true, + "version": "v1" + }, + { + "kind": "StatefulSet", + "name": "statefulsets/status", + "namespaced": true + } + ] +}`, + extensions: `{ + "groupVersion": "extensions/v1beta1", + "kind": "APIResourceList", + "resources": [ + { + "kind": "DaemonSet", + "name": "daemonsets", + "namespaced": true + }, + { + "kind": "DaemonSet", + "name": "daemonsets/status", + "namespaced": true + }, + { + "kind": "Deployment", + "name": "deployments", + "namespaced": true + }, + { + "kind": "DeploymentRollback", + "name": "deployments/rollback", + "namespaced": true + }, + { + "group": "extensions", + "kind": "Scale", + "name": "deployments/scale", + "namespaced": true, + "version": "v1beta1" + }, + { + "kind": "Deployment", + "name": "deployments/status", + "namespaced": true + }, + { + "kind": "Ingress", + "name": "ingresses", + "namespaced": true + }, + { + "kind": "Ingress", + "name": "ingresses/status", + "namespaced": true + }, + { + "kind": "NetworkPolicy", + "name": "networkpolicies", + "namespaced": true + }, + { + "kind": "PodSecurityPolicy", + "name": "podsecuritypolicies", + "namespaced": false + }, + { + "kind": "ReplicaSet", + "name": "replicasets", + "namespaced": true + }, + { + "group": "extensions", + "kind": "Scale", + "name": "replicasets/scale", + "namespaced": true, + "version": "v1beta1" + }, + { + "kind": "ReplicaSet", + "name": "replicasets/status", + "namespaced": true + }, + { + "kind": "ReplicationControllerDummy", + "name": "replicationcontrollers", + "namespaced": true + }, + { + "kind": "Scale", + "name": "replicationcontrollers/scale", + "namespaced": true + } + ] +}`, + networking: `{ + "apiVersion": "v1", + "groupVersion": "networking.k8s.io/v1", + "kind": "APIResourceList", + "resources": [ + { + "kind": "NetworkPolicy", + "name": "networkpolicies", + "namespaced": true + } + ] +}`, + rbac: `{ + "apiVersion": "v1", + "groupVersion": "rbac.authorization.k8s.io/v1", + "kind": "APIResourceList", + "resources": [ + { + "kind": "ClusterRoleBinding", + "name": "clusterrolebindings", + "namespaced": false + }, + { + "kind": "ClusterRole", + "name": "clusterroles", + "namespaced": false + }, + { + "kind": "RoleBinding", + "name": "rolebindings", + "namespaced": true + }, + { + "kind": "Role", + "name": "roles", + "namespaced": true + } + ] +}`, + storage: `{ + "apiVersion": "v1", + "groupVersion": "storage.k8s.io/v1", + "kind": "APIResourceList", + "resources": [ + { + "kind": "StorageClass", + "name": "storageclasses", + "namespaced": false + }, + { + "kind": "VolumeAttachment", + "name": "volumeattachments", + "namespaced": false + }, + { + "kind": "VolumeAttachment", + "name": "volumeattachments/status", + "namespaced": false + } + ] +}`, + }; + + describe('specUriPath', () => { + it('should return a namespaced path', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'repeater', + namespace: 'fugazi', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'patch'); + expect(r).to.equal('/api/v1/namespaces/fugazi/services/repeater'); + scope.done(); + }); + + it('should default to apiVersion v1', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + kind: 'ServiceAccount', + metadata: { + name: 'repeater', + namespace: 'fugazi', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'patch'); + expect(r).to.equal('/api/v1/namespaces/fugazi/serviceaccounts/repeater'); + scope.done(); + }); + + it('should default to context namespace', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions({ + clusters: [{ name: 'dc', server: 'https://d.i.y' }], + users: [{ name: 'ian', password: 'mackaye' }], + contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian', namespace: 'straight-edge' }], + currentContext: 'dischord', + }); + const c = KubernetesObjectApiTest.makeApiClient(kc); + const o = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + name: 'repeater', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'patch'); + expect(r).to.equal('/api/v1/namespaces/straight-edge/pods/repeater'); + scope.done(); + }); + + it('should default to default namespace', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions({ + clusters: [{ name: 'dc', server: 'https://d.i.y' }], + users: [{ name: 'ian', password: 'mackaye' }], + contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian' }], + currentContext: 'dischord', + }); + const c = KubernetesObjectApiTest.makeApiClient(kc); + const o = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + name: 'repeater', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'patch'); + expect(r).to.equal('/api/v1/namespaces/default/pods/repeater'); + scope.done(); + }); + + it('should return a non-namespaced path', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: 'repeater', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'delete'); + expect(r).to.equal('/api/v1/namespaces/repeater'); + scope.done(); + }); + + it('should return a namespaced path without name', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'fugazi', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'create'); + expect(r).to.equal('/api/v1/namespaces/fugazi/services'); + scope.done(); + }); + + it('should return a non-namespaced path without name', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: 'repeater', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'create'); + expect(r).to.equal('/api/v1/namespaces'); + scope.done(); + }); + + it('should return a namespaced path for non-core resource', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: 'repeater', + namespace: 'fugazi', + }, + }; + const scope = nock('https://d.i.y') + .get('/apis/apps/v1') + .reply(200, resourceBodies.apps, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'read'); + expect(r).to.equal('/apis/apps/v1/namespaces/fugazi/deployments/repeater'); + scope.done(); + }); + + it('should return a non-namespaced path for non-core resource', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + metadata: { + name: 'repeater', + }, + }; + const scope = nock('https://d.i.y') + .get('/apis/rbac.authorization.k8s.io/v1') + .reply(200, resourceBodies.rbac, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'read'); + expect(r).to.equal('/apis/rbac.authorization.k8s.io/v1/clusterroles/repeater'); + scope.done(); + }); + + it('should handle a variety of resources', async () => { + const a = [ + { + apiVersion: 'v1', + kind: 'Service', + ns: true, + p: '/api/v1', + b: resourceBodies.core, + e: '/api/v1/namespaces/fugazi/services/repeater', + }, + { + apiVersion: 'v1', + kind: 'ServiceAccount', + ns: true, + p: '/api/v1', + b: resourceBodies.core, + e: '/api/v1/namespaces/fugazi/serviceaccounts/repeater', + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'Role', + ns: true, + p: '/apis/rbac.authorization.k8s.io/v1', + b: resourceBodies.rbac, + e: '/apis/rbac.authorization.k8s.io/v1/namespaces/fugazi/roles/repeater', + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + ns: false, + p: '/apis/rbac.authorization.k8s.io/v1', + b: resourceBodies.rbac, + e: '/apis/rbac.authorization.k8s.io/v1/clusterroles/repeater', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'NetworkPolicy', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/networkpolicies/repeater', + }, + { + apiVersion: 'networking.k8s.io/v1', + kind: 'NetworkPolicy', + ns: true, + p: '/apis/networking.k8s.io/v1', + b: resourceBodies.networking, + e: '/apis/networking.k8s.io/v1/namespaces/fugazi/networkpolicies/repeater', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'Ingress', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/ingresses/repeater', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'DaemonSet', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/daemonsets/repeater', + }, + { + apiVersion: 'apps/v1', + kind: 'DaemonSet', + ns: true, + p: '/apis/apps/v1', + b: resourceBodies.apps, + e: '/apis/apps/v1/namespaces/fugazi/daemonsets/repeater', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'Deployment', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/deployments/repeater', + }, + { + apiVersion: 'apps/v1', + kind: 'Deployment', + ns: true, + p: '/apis/apps/v1', + b: resourceBodies.apps, + e: '/apis/apps/v1/namespaces/fugazi/deployments/repeater', + }, + { + apiVersion: 'storage.k8s.io/v1', + kind: 'StorageClass', + ns: false, + p: '/apis/storage.k8s.io/v1', + b: resourceBodies.storage, + e: '/apis/storage.k8s.io/v1/storageclasses/repeater', + }, + ]; + for (const k of a) { + const c = KubernetesObjectApiTest.makeApiClient(); + const o: KubernetesObject = { + apiVersion: k.apiVersion, + kind: k.kind, + metadata: { + name: 'repeater', + }, + }; + if (k.ns) { + o.metadata = o.metadata || {}; + o.metadata.namespace = 'fugazi'; + } + const scope = nock('https://d.i.y').get(k.p).reply(200, k.b, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'patch'); + expect(r).to.equal(k.e); + scope.done(); + } + }); + + it('should handle a variety of resources without names', async () => { + const a = [ + { + apiVersion: 'v1', + kind: 'Service', + ns: true, + p: '/api/v1', + b: resourceBodies.core, + e: '/api/v1/namespaces/fugazi/services', + }, + { + apiVersion: 'v1', + kind: 'ServiceAccount', + ns: true, + p: '/api/v1', + b: resourceBodies.core, + e: '/api/v1/namespaces/fugazi/serviceaccounts', + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'Role', + ns: true, + p: '/apis/rbac.authorization.k8s.io/v1', + b: resourceBodies.rbac, + e: '/apis/rbac.authorization.k8s.io/v1/namespaces/fugazi/roles', + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + ns: false, + p: '/apis/rbac.authorization.k8s.io/v1', + b: resourceBodies.rbac, + e: '/apis/rbac.authorization.k8s.io/v1/clusterroles', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'NetworkPolicy', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/networkpolicies', + }, + { + apiVersion: 'networking.k8s.io/v1', + kind: 'NetworkPolicy', + ns: true, + p: '/apis/networking.k8s.io/v1', + b: resourceBodies.networking, + e: '/apis/networking.k8s.io/v1/namespaces/fugazi/networkpolicies', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'Ingress', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/ingresses', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'DaemonSet', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/daemonsets', + }, + { + apiVersion: 'apps/v1', + kind: 'DaemonSet', + ns: true, + p: '/apis/apps/v1', + b: resourceBodies.apps, + e: '/apis/apps/v1/namespaces/fugazi/daemonsets', + }, + { + apiVersion: 'extensions/v1beta1', + kind: 'Deployment', + ns: true, + p: '/apis/extensions/v1beta1', + b: resourceBodies.extensions, + e: '/apis/extensions/v1beta1/namespaces/fugazi/deployments', + }, + { + apiVersion: 'apps/v1', + kind: 'Deployment', + ns: true, + p: '/apis/apps/v1', + b: resourceBodies.apps, + e: '/apis/apps/v1/namespaces/fugazi/deployments', + }, + { + apiVersion: 'storage.k8s.io/v1', + kind: 'StorageClass', + ns: false, + p: '/apis/storage.k8s.io/v1', + b: resourceBodies.storage, + e: '/apis/storage.k8s.io/v1/storageclasses', + }, + ]; + for (const k of a) { + const c = KubernetesObjectApiTest.makeApiClient(); + const o: KubernetesObject = { + apiVersion: k.apiVersion, + kind: k.kind, + }; + if (k.ns) { + o.metadata = { namespace: 'fugazi' }; + } + const scope = nock('https://d.i.y').get(k.p).reply(200, k.b, contentTypeJsonHeader); + const r = await c.specUriPath(o, 'create'); + expect(r).to.equal(k.e); + scope.done(); + } + }); + + it('should throw an error if kind missing', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + metadata: { + name: 'repeater', + namespace: 'fugazi', + }, + }; + let thrown = false; + try { + await c.specUriPath(o, 'create'); + expect.fail('should have thrown error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.equal('Required spec property kind is not set'); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + }); + + it('should throw an error if name required and missing', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'fugazi', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + let thrown = false; + try { + await c.specUriPath(o, 'read'); + expect.fail('should have thrown error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.equal('Required spec property name is not set'); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + scope.done(); + }); + + it('should throw an error if resource is not valid', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const o = { + apiVersion: 'v1', + kind: 'Ingress', + metadata: { + name: 'repeater', + namespace: 'fugazi', + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + let thrown = false; + try { + await c.specUriPath(o, 'create'); + expect.fail('should have thrown error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.equal('Unrecognized API version and kind: v1 Ingress'); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + scope.done(); + }); + }); + + describe('resource', () => { + let client: KubernetesObjectApiTest; + before(function (this: Mocha.Context): void { + client = KubernetesObjectApiTest.makeApiClient(); + }); + + it('should throw an error if apiVersion not set', async () => { + for (const a of [null, undefined]) { + let thrown = false; + try { + await client.resource(a as unknown as string, 'Service'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.equal( + 'Required parameter apiVersion was null or undefined when calling resource', + ); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + } + }); + + it('should throw an error if kind not set', async () => { + for (const a of [null, undefined]) { + let thrown = false; + try { + await client.resource('v1', a as unknown as string); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.equal( + 'Required parameter kind was null or undefined when calling resource', + ); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + } + }); + + it('should use middleware', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + + let preMiddlewareCalled = false; + let postMiddlewareCalled = false; + c.configuration.middleware.push({ + pre: (context) => { + preMiddlewareCalled = true; + return of(context); + }, + post: (context) => { + postMiddlewareCalled = true; + return of(context); + }, + }); + + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + await c.resource('v1', 'Service'); + expect(preMiddlewareCalled).to.be.true; + expect(postMiddlewareCalled).to.be.true; + scope.done(); + }); + + it('should cache API response', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const s = await c.resource('v1', 'Service'); + expect(s).to.be.ok; + if (!s) { + throw new Error('old TypeScript compiler'); + } + expect(s.kind).to.equal('Service'); + expect(s.name).to.equal('services'); + expect(s.namespaced).to.be.true; + expect(c.apiVersionResourceCache).to.be.ok; + expect(c.apiVersionResourceCache.v1).to.be.ok; + const sa = await c.resource('v1', 'ServiceAccount'); + expect(sa).to.be.ok; + if (!sa) { + throw new Error('old TypeScript compiler'); + } + expect(sa.kind).to.equal('ServiceAccount'); + expect(sa.name).to.equal('serviceaccounts'); + expect(sa.namespaced).to.be.true; + const p = await c.resource('v1', 'Pod'); + if (!p) { + throw new Error('old TypeScript compiler'); + } + expect(p).to.be.ok; + expect(p.kind).to.equal('Pod'); + expect(p.name).to.equal('pods'); + expect(p.namespaced).to.be.true; + const pv = await c.resource('v1', 'PersistentVolume'); + if (!pv) { + throw new Error('old TypeScript compiler'); + } + expect(pv).to.be.ok; + expect(pv.kind).to.equal('PersistentVolume'); + expect(pv.name).to.equal('persistentvolumes'); + expect(pv.namespaced).to.be.false; + scope.done(); + }); + + it('should re-request on cache miss', async () => { + const c = KubernetesObjectApiTest.makeApiClient(); + c.apiVersionResourceCache.v1 = { + groupVersion: 'v1', + kind: 'APIResourceList', + resources: [ + { + kind: 'Binding', + name: 'bindings', + namespaced: true, + }, + { + kind: 'ComponentStatus', + name: 'componentstatuses', + namespaced: false, + }, + ], + } as any; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader); + const s = await c.resource('v1', 'Service'); + expect(s).to.be.ok; + if (!s) { + throw new Error('old TypeScript compiler'); + } + expect(s.kind).to.equal('Service'); + expect(s.name).to.equal('services'); + expect(s.namespaced).to.be.true; + expect(c.apiVersionResourceCache).to.be.ok; + expect(c.apiVersionResourceCache.v1).to.be.ok; + expect(c.apiVersionResourceCache.v1.resources.length).to.deep.equal( + JSON.parse(resourceBodies.core).resources.length, + ); + scope.done(); + }); + }); + + describe('verbs', () => { + let client: KubernetesObjectApi; + before(() => { + const kc = new KubeConfig(); + kc.loadFromOptions(testConfigOptions); + client = KubernetesObjectApi.makeApiClient(kc); + (client as any).apiVersionResourceCache.v1 = JSON.parse(resourceBodies.core); + }); + + it('should modify resources with defaults', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'k8s-js-client-test', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + }, + }; + const methods = [ + { + m: client.create, + v: 'POST', + p: '/api/v1/namespaces/default/services', + c: 201, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.patch, + v: 'PATCH', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.read, + v: 'GET', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.delete, + v: 'DELETE', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "apiVersion": "v1", + "details": { + "kind": "services", + "name": "k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a" + }, + "kind": "Status", + "metadata": {}, + "status": "Success" +}`, + }, + ]; + for (const m of methods) { + const scope = nock('https://d.i.y') + .intercept(m.p, m.v, m.v === 'DELETE' || m.v === 'GET' ? undefined : s) + .reply(m.c, m.b, contentTypeJsonHeader); + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, s); + scope.done(); + } + }); + + it('should modify resources with pretty set', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'k8s-js-client-test', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + }, + }; + const methods = [ + { + m: client.create, + v: 'POST', + p: '/api/v1/namespaces/default/services', + c: 201, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.patch, + v: 'PATCH', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.read, + v: 'GET', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.delete, + v: 'DELETE', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "apiVersion": "v1", + "details": { + "kind": "services", + "name": "k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a" + }, + "kind": "Status", + "metadata": {}, + "status": "Success" +}`, + }, + ]; + for (const p of ['true', 'false']) { + for (const m of methods) { + const scope = nock('https://d.i.y') + .intercept( + `${m.p}?pretty=${p}`, + m.v, + m.v === 'DELETE' || m.v === 'GET' ? undefined : s, + ) + .reply(m.c, m.b, contentTypeJsonHeader); + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, s, p); + scope.done(); + } + } + }); + + it('should set dryRun', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'k8s-js-client-test', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + }, + }; + const methods = [ + { + m: client.create, + v: 'POST', + p: '/api/v1/namespaces/default/services', + c: 201, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.patch, + v: 'PATCH', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a", + "resourceVersion": "32373", + "creationTimestamp": "2020-05-11T17:34:25Z" + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.97.191.144", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + }, + { + m: client.delete, + v: 'DELETE', + p: '/api/v1/namespaces/default/services/k8s-js-client-test', + c: 200, + b: `{ + "apiVersion": "v1", + "details": { + "kind": "services", + "name": "k8s-js-client-test", + "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a" + }, + "kind": "Status", + "metadata": {}, + "status": "Success" +}`, + }, + ]; + for (const m of methods) { + const scope = nock('https://d.i.y') + .intercept(`${m.p}?dryRun=All`, m.v, m.v === 'DELETE' || m.v === 'GET' ? undefined : s) + .reply(m.c, m.b, contentTypeJsonHeader); + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, s, undefined, 'All'); + scope.done(); + } + }); + + it('should replace a resource', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + annotations: { + owner: 'test', + }, + name: 'k8s-js-client-test', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + }, + }; + const scope = nock('https://d.i.y') + .post('/api/v1/namespaces/default/services?fieldManager=ManageField', s) + .reply( + 201, + `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "a4fd7a65-2af5-4ef1-a0bc-cb34a308b821", + "resourceVersion": "41183", + "creationTimestamp": "2020-05-11T19:35:01Z", + "annotations": { + "owner": "test" + } + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.106.153.133", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + contentTypeJsonHeader, + ) + .put('/api/v1/namespaces/default/services/k8s-js-client-test?pretty=true', { + kind: 'Service', + apiVersion: 'v1', + metadata: { + name: 'k8s-js-client-test', + namespace: 'default', + selfLink: '/api/v1/namespaces/default/services/k8s-js-client-test', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + resourceVersion: '41183', + creationTimestamp: '2020-05-11T19:35:01.000Z', + annotations: { + owner: 'test', + test: '1', + }, + }, + spec: { + ports: [ + { + protocol: 'TCP', + port: 80, + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + clusterIP: '10.106.153.133', + type: 'ClusterIP', + sessionAffinity: 'None', + }, + status: { + loadBalancer: {}, + }, + }) + .reply( + 200, + `{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "k8s-js-client-test", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test", + "uid": "a4fd7a65-2af5-4ef1-a0bc-cb34a308b821", + "resourceVersion": "41185", + "creationTimestamp": "2020-05-11T19:35:01Z", + "annotations": { + "owner": "test", + "test": "1" + } + }, + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "sleep" + }, + "clusterIP": "10.106.153.133", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +}`, + contentTypeJsonHeader, + ) + .delete( + '/api/v1/namespaces/default/services/k8s-js-client-test?gracePeriodSeconds=7&propagationPolicy=Foreground', + ) + .reply( + 200, + `{ + "apiVersion": "v1", + "details": { + "kind": "services", + "name": "k8s-js-client-test", + "uid": "a4fd7a65-2af5-4ef1-a0bc-cb34a308b821" + }, + "kind": "Status", + "metadata": {}, + "status": "Success" +}`, + contentTypeJsonHeader, + ); + const c = await client.create(s, undefined, undefined, 'ManageField'); + (c.metadata.annotations as Record).test = '1'; + const r = await client.replace(c, 'true'); + expect((r.metadata.annotations as Record).test).to.equal('1'); + expect(parseInt((r.metadata as any).resourceVersion, 10)).to.be.greaterThan( + parseInt((c.metadata as any).resourceVersion, 10), + ); + await client.delete(s, undefined, undefined, 7, undefined, 'Foreground'); + scope.done(); + }); + + it('should read a resource', async () => { + const scope = nock('https://d.i.y') + .get('/api/v1/namespaces/default/secrets/test-secret-1') + .reply( + 200, + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'test-secret-1', + namespace: 'default', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + creationTimestamp: '2022-01-01T00:00:00.000Z', + }, + data: { + key: 'value', + }, + }, + contentTypeJsonHeader, + ); + const secret = await client.read({ + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'test-secret-1', + namespace: 'default', + }, + }); + expect(secret).to.be.instanceof(V1Secret); + expect(secret.data).to.deep.equal({ + key: 'value', + }); + expect(secret.metadata).to.be.ok; + expect(secret.metadata!.creationTimestamp).to.deep.equal(new Date('2022-01-01T00:00:00.000Z')); + scope.done(); + }); + + it('should read a custom resource', async () => { + interface CustomTestResource extends KubernetesObject { + spec?: { + key: string; + }; + } + (client as any).apiVersionResourceCache['example.com/v1'] = { + groupVersion: 'example.com/v1', + kind: 'APIResourceList', + resources: [ + { + kind: 'CustomTestResource', + name: 'customtestresources', + namespaced: true, + }, + ], + }; + const scope = nock('https://d.i.y') + .get('/apis/example.com/v1/namespaces/default/customtestresources/test-1') + .reply( + 200, + { + apiVersion: 'example.com/v1', + kind: 'CustomTestResource', + metadata: { + name: 'test-1', + namespace: 'default', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + creationTimestamp: '2022-01-01T00:00:00.000Z', + }, + spec: { + key: 'value', + }, + }, + contentTypeJsonHeader, + ); + const custom = await client.read({ + apiVersion: 'example.com/v1', + kind: 'CustomTestResource', + metadata: { + name: 'test-1', + namespace: 'default', + }, + }); + expect(custom.spec).to.deep.equal({ + key: 'value', + }); + expect(custom.metadata).to.be.ok; + // TODO(schrodit): this should be a Date rather than a string + expect(custom.metadata!.creationTimestamp).to.equal('2022-01-01T00:00:00.000Z'); + scope.done(); + }); + + it('should list resources in a namespace', async () => { + const scope = nock('https://d.i.y') + .get('/api/v1/namespaces/default/secrets') + .reply( + 200, + { + apiVersion: 'v1', + kind: 'SecretList', + items: [ + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'test-secret-1', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + }, + }, + ], + metadata: { + resourceVersion: '216532459', + continue: 'abc', + }, + }, + contentTypeJsonHeader, + ); + const lr = await client.list('v1', 'Secret', 'default'); + const items = lr.items; + expect(items).to.have.length(1); + expect(items[0]).to.be.instanceof(V1Secret); + scope.done(); + }); + + it('should list resources in all namespaces', async () => { + const scope = nock('https://d.i.y') + .get( + '/api/v1/secrets?fieldSelector=metadata.name%3Dtest-secret1&labelSelector=app%3Dmy-app&limit=5', + ) + .reply( + 200, + { + apiVersion: 'v1', + kind: 'SecretList', + items: [ + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'test-secret-1', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + }, + }, + ], + metadata: { + resourceVersion: '216532459', + continue: 'abc', + }, + }, + contentTypeJsonHeader, + ); + const lr = await client.list( + 'v1', + 'Secret', + undefined, + undefined, + undefined, + undefined, + 'metadata.name=test-secret1', + 'app=my-app', + 5, + ); + const items = lr.items; + expect(items).to.have.length(1); + scope.done(); + }); + }); + + describe('errors', () => { + let client: KubernetesObjectApi; + before(() => { + const kc = new KubeConfig(); + kc.loadFromOptions(testConfigOptions); + client = KubernetesObjectApi.makeApiClient(kc); + }); + + it('should throw error if no spec', async () => { + const methods = [client.create, client.patch, client.read, client.replace, client.delete]; + for (const s of [null, undefined]) { + for (const m of methods) { + let thrown = false; + try { + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m as any; + await hack_m.call(client, s); + expect.fail('should have thrown an error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.contain( + 'Required parameter spec was null or undefined when calling ', + ); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + } + } + }); + + it('should throw an error if request throws an error', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'valid-name', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + }, + }; + nock('https://d.i.y'); + let thrown = false; + try { + await client.read(s); + expect.fail('should have thrown error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.contain('Nock: No match for request'); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + }); + + it('should throw an error if name not valid', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: '_not_a_valid_name_', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', + }, + }, + }; + const scope = nock('https://d.i.y') + .get('/api/v1') + .reply(200, resourceBodies.core, contentTypeJsonHeader) + .post('/api/v1/namespaces/default/services', s) + .reply( + 422, + `{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "Service \"_not_a_valid_name_\" is invalid: metadata.name: Invalid value: \"_not_a_valid_name_\": a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')", + "reason": "Invalid", + "details": { + "name": "_not_a_valid_name_", + "kind": "Service", + "causes": [ + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"_not_a_valid_name_\": a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')", + "field": "metadata.name" + } + ] + }, + "code": 422 +}`, + contentTypeJsonHeader, + ); + let thrown = false; + try { + await client.create(s); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect((e as ApiException).code).to.equal(422); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + scope.done(); + }); + + it('should throw an error if apiVersion not valid', async () => { + const d = { + apiVersion: 'applications/v1', + kind: 'Deployment', + metadata: { + name: 'should-not-be-created', + namespace: 'default', + }, + spec: { + selector: { + matchLabels: { + app: 'sleep', + }, + }, + template: { + metadata: { + labels: { + app: 'sleep', + }, + }, + spec: { + containers: [ + { + args: ['60'], + command: ['sleep'], + image: 'alpine', + name: 'sleep', + ports: [{ containerPort: 80 }], + }, + ], + }, + }, + }, + }; + const scope = nock('https://d.i.y') + .get('/apis/applications/v1') + .reply(404, '{}', contentTypeJsonHeader); + let thrown = false; + try { + await client.create(d); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect((e as ApiException).code).to.equal(404); + expect(e.message).to.satisfy((m: string) => + m.startsWith('Failed to fetch resource metadata for applications/v1/Deployment'), + ); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + scope.done(); + }); + + it('should throw error if no apiVersion', async () => { + let thrown = false; + try { + await (client.list as any)(undefined, undefined); + expect.fail('should have thrown an error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.contain( + 'Required parameter apiVersion was null or undefined when calling ', + ); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + }); + + it('should throw error if no kind', async () => { + let thrown = false; + try { + await (client.list as any)('', undefined); + expect.fail('should have thrown an error'); + } catch (e) { + thrown = true; + if (e instanceof Error) { + expect(e.message).to.contain( + 'Required parameter kind was null or undefined when calling ', + ); + } else { + fail(`unknown exception: ${e}`); + } + } + expect(thrown).to.be.true; + }); + }); +}); diff --git a/src/patch.ts b/src/patch.ts index 8595f21075c..b13aba14eb3 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -1,6 +1,13 @@ -export class PatchUtils { - public static PATCH_FORMAT_JSON_PATCH: string = 'application/json-patch+json'; - public static PATCH_FORMAT_JSON_MERGE_PATCH: string = 'application/merge-patch+json'; - public static PATCH_FORMAT_STRATEGIC_MERGE_PATCH: string = 'application/strategic-merge-patch+json'; - public static PATCH_FORMAT_APPLY_YAML: string = 'application/apply-patch+yaml'; +/** + * Valid Content-Type header values for patch operations. See + * https://kubernetes.io/docs/tasks/run-application/update-api-object-kubectl-patch/ + * for details. + */ +export enum PatchStrategy { + /** Diff-like JSON format. */ + JsonPatch = 'application/json-patch+json', + /** Simple merge. */ + MergePatch = 'application/merge-patch+json', + /** Merge with different strategies depending on field metadata. */ + StrategicMergePatch = 'application/strategic-merge-patch+json', }