Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
98 changes: 98 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
161 changes: 161 additions & 0 deletions src/test/e2e/api_test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> labels = 11;
}
message ConfigMap {
optional ObjectMeta metadata = 1;
map<string, string> 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 });
});
});
Loading