diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000000..216ca0467d0 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,33 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - '.github/workflows/copilot-setup-steps.yml' + pull_request: + paths: + - '.github/workflows/copilot-setup-steps.yml' + +permissions: {} + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + cache: npm + - run: npm ci + - name: Create Kubernetes KIND cluster + uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1 + with: + cluster_name: client-node-e2e + - run: npm run e2e-test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 939346ba456..8491aabd9e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,4 @@ jobs: - name: Create k8s Kind Cluster uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1 - run: npm run integration-test + - run: npm run e2e-test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d548a493eba..b16bec02341 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,29 @@ To run tests: npm test ``` +### End-to-end tests + +The end-to-end suite requires a reachable Kubernetes cluster and uses the current +kubeconfig context. Use a disposable [KIND](https://kind.sigs.k8s.io/) cluster: + +```console +kind create cluster --name client-node-e2e --wait 120s +npm run e2e-test +kind delete cluster --name client-node-e2e +``` + +Tests create an isolated, uniquely named namespace and delete it afterward. They +exercise ConfigMap create/read/list/update/delete operations through both the +generated JSON client and kubeconfig-authenticated protobuf requests. Protobuf +tests validate the response content type, Kubernetes binary envelope, and decoded +resource data; they do not accept a JSON fallback. The generated API client itself +still uses JSON; the tests use a small protobuf schema for binary requests. + +The validation workflow runs this suite against KIND. The +`.github/workflows/copilot-setup-steps.yml` workflow also installs dependencies, +creates a KIND cluster, and runs the suite before Copilot starts, leaving the +cluster available for subsequent `npm run e2e-test` runs. + ## Contact You can reach the maintainers of this project at: diff --git a/package-lock.json b/package-lock.json index 39670aff47f..00be1265d73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "mock-fs": "^5.2.0", "prettier": "^3.0.0", "pretty-quick": "^4.0.0", + "protobufjs": "^7.5.4", "ts-mockito": "^2.3.1", "tsx": "^4.21.0", "typescript": "~7.0.2" @@ -565,6 +566,72 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1785,6 +1852,13 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -2067,6 +2141,30 @@ "node": ">= 4" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", diff --git a/package.json b/package.json index 932dd8898dc..ad198582150 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test-no-coverage": "node --test --test-reporter=spec --import tsx src/*_test.ts", "test-transpiled": "node --test --test-reporter=spec dist/*_test.js", "integration-test": "tsx src/test/integration/index.ts", + "e2e-test": "node --test --test-reporter=spec --import tsx src/test/e2e/*_test.ts", "prepare": "npm run build && husky", "prepack": "npm run build", "docs": "npm --prefix ./tools/docs ci && npm --prefix ./tools/docs run docs" @@ -80,6 +81,7 @@ "mock-fs": "^5.2.0", "prettier": "^3.0.0", "pretty-quick": "^4.0.0", + "protobufjs": "^7.5.4", "ts-mockito": "^2.3.1", "tsx": "^4.21.0", "typescript": "~7.0.2" diff --git a/src/test/e2e/api_test.ts b/src/test/e2e/api_test.ts new file mode 100644 index 00000000000..5e444461ef2 --- /dev/null +++ b/src/test/e2e/api_test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { after, before, describe, it } from 'node:test'; +import protobuf from 'protobufjs'; +import { fetch } from 'undici'; +import { CoreV1Api, HttpMethod, KubeConfig, PatchStrategy, setHeaderOptions } from '../../index.js'; +import { RequestContext } from '../../gen/http/http.js'; + +const protobufContentType = 'application/vnd.kubernetes.protobuf'; +const magic = Buffer.from([0x6b, 0x38, 0x73, 0x00]); + +// Only the fields exercised here are needed; protobuf decoders skip unknown fields. +const schema = protobuf.parse(` + syntax = "proto2"; + message TypeMeta { + optional string apiVersion = 1; + optional string kind = 2; + } + message Unknown { + optional TypeMeta typeMeta = 1; + optional bytes raw = 2; + optional string contentEncoding = 3; + optional string contentType = 4; + } + message ObjectMeta { + optional string name = 1; + optional string namespace = 3; + optional string resourceVersion = 6; + map labels = 11; + } + message ConfigMap { + optional ObjectMeta metadata = 1; + map data = 2; + } + message ConfigMapList { + repeated ConfigMap items = 2; + } +`).root; +const unknownType = schema.lookupType('Unknown'); + +function encode(kind: string, value: object): Buffer { + const type = schema.lookupType(kind); + const raw = type.encode(type.fromObject(value)).finish(); + const envelope = unknownType.encode({ typeMeta: { apiVersion: 'v1', kind }, raw }).finish(); + return Buffer.concat([magic, envelope]); +} + +function decode(kind: string, body: Buffer) { + assert.deepEqual(body.subarray(0, magic.length), magic, 'Missing Kubernetes protobuf prefix'); + const envelope = unknownType.toObject(unknownType.decode(body.subarray(magic.length))); + assert.equal(envelope.typeMeta.apiVersion, 'v1'); + assert.equal(envelope.typeMeta.kind, kind); + const type = schema.lookupType(kind); + return type.toObject(type.decode(envelope.raw)); +} + +describe('Kubernetes API end-to-end', { timeout: 120_000 }, () => { + const config = new KubeConfig(); + const namespace = `client-node-e2e-${randomUUID()}`; + let api: CoreV1Api; + let namespaceCreated = false; + + before(async () => { + config.loadFromDefault(); + assert.ok(config.getCurrentCluster(), 'A reachable Kubernetes cluster and kubeconfig are required'); + api = config.makeApiClient(CoreV1Api); + await api.createNamespace({ body: { metadata: { name: namespace } } }); + namespaceCreated = true; + }); + + after(async () => { + if (namespaceCreated) { + await api.deleteNamespace({ name: namespace }); + } + }); + + async function request(method: HttpMethod, path: string, status: number, body?: Buffer) { + const cluster = config.getCurrentCluster(); + assert.ok(cluster); + const context = new RequestContext(`${cluster.server}/api/v1/namespaces/${namespace}${path}`, method); + context.setHeaderParam('Accept', protobufContentType); + if (body) { + context.setHeaderParam('Content-Type', protobufContentType); + } + await config.applySecurityAuthentication(context); + const response = await fetch(context.getUrl(), { + method, + headers: context.getHeaders(), + dispatcher: context.getDispatcher(), + body, + signal: AbortSignal.timeout(30_000), + }); + const bytes = Buffer.from(await response.arrayBuffer()); + assert.equal(response.status, status, `${method} ${path}: ${bytes.toString()}`); + assert.equal(response.headers.get('content-type'), protobufContentType); + return bytes; + } + + it('creates, reads, lists, patches, and deletes ConfigMaps using the generated JSON client', async () => { + const name = 'json-config'; + const created = await api.createNamespacedConfigMap({ + namespace, + body: { metadata: { name, labels: { suite: 'e2e-json' } }, data: { greeting: 'hello' } }, + }); + assert.equal(created.metadata?.namespace, namespace); + const read = await api.readNamespacedConfigMap({ namespace, name }); + assert.deepEqual(read.data, { greeting: 'hello' }); + const list = await api.listNamespacedConfigMap({ namespace, labelSelector: 'suite=e2e-json' }); + assert.deepEqual( + list.items.map((item) => item.metadata?.name), + [name], + ); + const patched = await api.patchNamespacedConfigMap( + { namespace, name, body: { data: { greeting: 'updated' } } }, + setHeaderOptions('Content-Type', PatchStrategy.MergePatch), + ); + assert.deepEqual(patched.data, { greeting: 'updated' }); + await api.deleteNamespacedConfigMap({ namespace, name }); + await assert.rejects(api.readNamespacedConfigMap({ namespace, name }), { code: 404 }); + }); + + it('creates, reads, lists, updates, and deletes ConfigMaps over the protobuf wire protocol', async () => { + const name = 'protobuf-config'; + const value = { + metadata: { name, namespace, labels: { suite: 'e2e-protobuf' } }, + data: { greeting: 'hello \u2603', multiline: 'first\nsecond' }, + }; + const created = decode( + 'ConfigMap', + await request(HttpMethod.POST, '/configmaps', 201, encode('ConfigMap', value)), + ); + assert.equal(created.metadata.name, name); + assert.equal(created.metadata.namespace, namespace); + assert.deepEqual(created.data, value.data); + const read = decode('ConfigMap', await request(HttpMethod.GET, `/configmaps/${name}`, 200)); + assert.deepEqual(read.data, value.data); + assert.ok(read.metadata.resourceVersion); + const list = decode( + 'ConfigMapList', + await request(HttpMethod.GET, '/configmaps?labelSelector=suite%3De2e-protobuf', 200), + ); + assert.equal(list.items.length, 1); + assert.equal(list.items[0].metadata.name, name); + assert.deepEqual(list.items[0].data, value.data); + const updated = decode( + 'ConfigMap', + await request( + HttpMethod.PUT, + `/configmaps/${name}`, + 200, + encode('ConfigMap', { metadata: read.metadata, data: { greeting: 'updated' } }), + ), + ); + assert.deepEqual(updated.data, { greeting: 'updated' }); + // Cross-check decoding and persistence through the generated JSON client. + const json = await api.readNamespacedConfigMap({ namespace, name }); + assert.deepEqual(json.data, updated.data); + await request(HttpMethod.DELETE, `/configmaps/${name}`, 200); + await assert.rejects(api.readNamespacedConfigMap({ namespace, name }), { code: 404 }); + }); +});