Skip to content

Commit 5c2861a

Browse files
Merge branch 'main' into feat/200-agent-marketplace-api
2 parents a930ada + ce6ffcd commit 5c2861a

39 files changed

Lines changed: 4512 additions & 372 deletions

.github/workflows/deploy-docs.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Deploy Docs Website
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'website/**'
8+
- 'docs/**'
9+
- '.github/workflows/deploy-docs.yml'
10+
pull_request:
11+
branches: [main]
12+
paths:
13+
- 'website/**'
14+
- 'docs/**'
15+
- '.github/workflows/deploy-docs.yml'
16+
17+
permissions:
18+
contents: read
19+
20+
# Allow one concurrent deployment in flight; don't cancel in-progress deploys
21+
# from earlier main pushes (those are real publishes we want to complete).
22+
concurrency:
23+
group: pages
24+
cancel-in-progress: false
25+
26+
jobs:
27+
build:
28+
name: Build website
29+
runs-on: ubuntu-latest
30+
timeout-minutes: 15
31+
defaults:
32+
run:
33+
working-directory: website
34+
35+
steps:
36+
- name: Checkout repository
37+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
38+
39+
- name: Setup Bun
40+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
41+
with:
42+
bun-version: 1.3.11
43+
44+
- name: Install dependencies
45+
run: bun ci
46+
47+
- name: Build website
48+
run: bun run build
49+
50+
- name: Upload Pages artifact
51+
# GitHub Pages needs the artifact uploaded under the specific name
52+
# "github-pages" so deploy-pages picks it up.
53+
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
54+
with:
55+
path: website/build
56+
57+
deploy:
58+
name: Deploy to GitHub Pages
59+
needs: build
60+
runs-on: ubuntu-latest
61+
timeout-minutes: 10
62+
# Only publish on push to main on the canonical repo. PR builds verify the
63+
# build but never deploy. Permissions scoped to this job alone so PR
64+
# builds keep a read-only token.
65+
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && github.repository == 'kaito-project/airunway'
66+
permissions:
67+
pages: write
68+
id-token: write
69+
environment:
70+
name: github-pages
71+
url: ${{ steps.deployment.outputs.page_url }}
72+
73+
steps:
74+
- name: Deploy to GitHub Pages
75+
id: deployment
76+
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ The controller automatically selects the best engine and provider, creates provi
100100
101101
## Documentation
102102
103+
📖 **Browse the docs at [kaito-project.github.io/airunway](https://kaito-project.github.io/airunway/)**
104+
105+
The same content also lives in [`docs/`](docs/) for in-repo browsing.
106+
103107
| Topic | Link |
104108
| --- | --- |
105109
| Architecture | [docs/architecture.md](docs/architecture.md) |

agents.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
- `backend/src/` - Hono app, providers, services
2121
- `shared/types/` - Shared TypeScript definitions
2222
- `plugins/headlamp/` - Headlamp dashboard plugin
23-
- `docs/` - Detailed documentation (read as needed)
23+
- `docs/` - Detailed documentation (read as needed; also the source rendered on the website)
24+
- `website/` - Docusaurus site published to https://kaito-project.github.io/airunway/. Reads from `docs/` via `docs.path: '../docs'` — write docs as plain GitHub-Flavored Markdown and they render in both places.
2425

2526
**Core pattern**: Provider abstraction via CRDs:
2627
- `ModelDeployment` - Unified API for deploying ML models
@@ -71,6 +72,22 @@ make setup # Install deps, build, and deploy to Headlamp
7172
make dev # Build and deploy for development
7273
```
7374

75+
### Website (Docusaurus)
76+
77+
```bash
78+
cd website
79+
bun install # Install website dependencies
80+
bun run start # Local dev server with hot reload
81+
bun run build # Production build (must pass before merge)
82+
bun run serve # Serve the production build locally
83+
```
84+
85+
Docs sources stay in `/docs/*.md` (single source of truth). When the build
86+
warns about a broken link or MDX issue, fix the source markdown — the site is
87+
configured with `markdown.format: 'detect'` so `.md` files are treated as
88+
plain GFM, not MDX. Anything in `{curly braces}` or bare `<angle-tags>` in a
89+
`.md` file will only be parsed as JSX if the file is renamed to `.mdx`.
90+
7491
**Always run `bun run test` after implementing functionality to verify both frontend and backend changes.**
7592

7693
**Always validate changes immediately after editing files:**

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,6 @@
3535
"@types/node": "^25.9.1",
3636
"eslint": "^10.4.0",
3737
"pino-pretty": "^13.1.3",
38-
"typescript": "5.3.3"
38+
"typescript": "6.0.3"
3939
}
4040
}

backend/src/lib/kubeconfig.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2+
import * as k8s from '@kubernetes/client-node';
3+
import { kubeConfigToBunTls, BunTlsHttpLibrary, makeApiClient } from './kubeconfig';
4+
5+
/**
6+
* Regression guards for the Bun TLS shim (`kubeConfigToBunTls`,
7+
* `BunTlsHttpLibrary`, `makeApiClient`). This is security-sensitive auth/TLS
8+
* code: the most important assertion in this file is that the **default path
9+
* never disables certificate verification** — see `does NOT set
10+
* rejectUnauthorized on the default path`.
11+
*
12+
* Tests build real `KubeConfig` objects via `loadFromOptions` (no disk/network)
13+
* rather than mocking SDK internals, so they keep working across SDK patch bumps.
14+
*/
15+
16+
// PEM-shaped placeholders. `applyToHTTPSOptions` only base64-decodes the *Data
17+
// fields into Buffers and copies them; it does not parse/validate the contents.
18+
const CA_PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakeCApem\n-----END CERTIFICATE-----\n';
19+
const CERT_PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakeClientpem\n-----END CERTIFICATE-----\n';
20+
const KEY_PEM = '-----BEGIN PRIVATE KEY-----\nMIIBfakeKeypem\n-----END PRIVATE KEY-----\n';
21+
22+
const b64 = (s: string) => Buffer.from(s).toString('base64');
23+
24+
interface KcOpts {
25+
skipTLSVerify?: boolean;
26+
tlsServerName?: string;
27+
token?: string;
28+
withClientCert?: boolean;
29+
caData?: string | null;
30+
}
31+
32+
function makeKubeConfig(opts: KcOpts = {}): k8s.KubeConfig {
33+
const kc = new k8s.KubeConfig();
34+
const cluster: Record<string, unknown> = {
35+
name: 'test-cluster',
36+
server: 'https://api.example.test:443',
37+
skipTLSVerify: !!opts.skipTLSVerify,
38+
};
39+
if (opts.caData !== null) cluster.caData = opts.caData ?? b64(CA_PEM);
40+
if (opts.tlsServerName) cluster.tlsServerName = opts.tlsServerName;
41+
42+
const user: Record<string, unknown> = { name: 'test-user' };
43+
if (opts.token) user.token = opts.token;
44+
if (opts.withClientCert) {
45+
user.certData = b64(CERT_PEM);
46+
user.keyData = b64(KEY_PEM);
47+
}
48+
49+
kc.loadFromOptions({
50+
clusters: [cluster as any],
51+
users: [user as any],
52+
contexts: [{ name: 'test-ctx', cluster: 'test-cluster', user: 'test-user' }],
53+
currentContext: 'test-ctx',
54+
});
55+
return kc;
56+
}
57+
58+
describe('kubeConfigToBunTls', () => {
59+
it('maps ca/cert/key into the Bun tls option', async () => {
60+
const tls = await kubeConfigToBunTls(makeKubeConfig({ withClientCert: true }));
61+
expect(tls).toBeDefined();
62+
expect(Buffer.isBuffer(tls!.ca)).toBe(true);
63+
expect(Buffer.isBuffer(tls!.cert)).toBe(true);
64+
expect(Buffer.isBuffer(tls!.key)).toBe(true);
65+
expect(tls!.ca!.toString()).toBe(CA_PEM);
66+
});
67+
68+
it('maps the kubeconfig SNI (servername) to Bun camelCase serverName', async () => {
69+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', tlsServerName: 'sni.override.test' }));
70+
expect(tls).toBeDefined();
71+
expect(tls!.serverName).toBe('sni.override.test');
72+
});
73+
74+
it('does NOT set serverName when the kubeconfig has no tls-server-name', async () => {
75+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok' }));
76+
// ca is still present, so tls is defined; serverName must be absent.
77+
expect(tls).toBeDefined();
78+
expect(tls!.serverName).toBeUndefined();
79+
});
80+
81+
it('sets rejectUnauthorized=false when skipTLSVerify is true', async () => {
82+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', skipTLSVerify: true }));
83+
expect(tls).toBeDefined();
84+
expect(tls!.rejectUnauthorized).toBe(false);
85+
});
86+
87+
// *** KEY SECURITY REGRESSION GUARD ***
88+
it('does NOT set rejectUnauthorized on the default (verifying) path', async () => {
89+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok' }));
90+
expect(tls).toBeDefined();
91+
// Must be absent (not `true`, not `false`) so Bun keeps verification ON.
92+
expect(tls!.rejectUnauthorized).toBeUndefined();
93+
expect(Object.prototype.hasOwnProperty.call(tls!, 'rejectUnauthorized')).toBe(false);
94+
});
95+
96+
it('returns undefined when the kubeconfig configures no TLS material', async () => {
97+
// No CA, no client cert, token auth, verification left on → nothing to map.
98+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', caData: null }));
99+
expect(tls).toBeUndefined();
100+
});
101+
});
102+
103+
describe('BunTlsHttpLibrary.send', () => {
104+
const realFetch = globalThis.fetch;
105+
let calls: Array<{ url: string; options: any }>;
106+
107+
beforeEach(() => {
108+
calls = [];
109+
});
110+
afterEach(() => {
111+
globalThis.fetch = realFetch;
112+
});
113+
114+
function stubFetch(status = 200, body = '{"ok":true}') {
115+
globalThis.fetch = (async (url: any, options: any) => {
116+
calls.push({ url: String(url), options });
117+
return new Response(body, { status, headers: { 'content-type': 'application/json' } });
118+
}) as typeof fetch;
119+
}
120+
121+
function makeRequest(): k8s.RequestContext {
122+
const req = new k8s.RequestContext('https://api.example.test:443/healthz', k8s.HttpMethod.GET);
123+
// Mirror how the generated client applies auth before send() runs.
124+
req.setHeaderParam('Authorization', 'Bearer test-token-123');
125+
return req;
126+
}
127+
128+
it('passes the mapped tls material to fetch', async () => {
129+
stubFetch();
130+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ withClientCert: true, tlsServerName: 'sni.test' }));
131+
await lib.send(makeRequest()).toPromise();
132+
133+
expect(calls).toHaveLength(1);
134+
const tls = calls[0].options.tls;
135+
expect(tls).toBeDefined();
136+
expect(Buffer.isBuffer(tls.ca)).toBe(true);
137+
expect(tls.serverName).toBe('sni.test');
138+
});
139+
140+
it('preserves the Authorization header applied upstream (auth survives the override)', async () => {
141+
stubFetch();
142+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'ignored' }));
143+
await lib.send(makeRequest()).toPromise();
144+
145+
const headers = new Headers(calls[0].options.headers);
146+
expect(headers.get('Authorization')).toBe('Bearer test-token-123');
147+
});
148+
149+
it('omits tls entirely when the kubeconfig configures none', async () => {
150+
stubFetch();
151+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'tok', caData: null }));
152+
await lib.send(makeRequest()).toPromise();
153+
expect(calls[0].options.tls).toBeUndefined();
154+
});
155+
156+
it('returns a ResponseContext (not a thrown error) for a non-2xx response', async () => {
157+
stubFetch(404, '{"kind":"Status","code":404}');
158+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'tok' }));
159+
const res = await lib.send(makeRequest()).toPromise();
160+
161+
expect(res).toBeInstanceOf(k8s.ResponseContext);
162+
expect(res.httpStatusCode).toBe(404);
163+
expect(await res.body.text()).toContain('Status');
164+
});
165+
166+
it('resolves the kubeconfig TLS material only once across multiple requests', async () => {
167+
stubFetch();
168+
const kc = makeKubeConfig({ withClientCert: true });
169+
let applyCount = 0;
170+
const orig = kc.applyToHTTPSOptions.bind(kc);
171+
kc.applyToHTTPSOptions = (async (opts: any) => {
172+
applyCount += 1;
173+
return orig(opts);
174+
}) as typeof kc.applyToHTTPSOptions;
175+
176+
const lib = new BunTlsHttpLibrary(kc);
177+
await lib.send(makeRequest()).toPromise();
178+
await lib.send(makeRequest()).toPromise();
179+
await lib.send(makeRequest()).toPromise();
180+
181+
// Cached after the first call — guards against re-running the auth/cert
182+
// pipeline (e.g. exec credential plugins) on every request.
183+
expect(applyCount).toBe(1);
184+
});
185+
});
186+
187+
describe('makeApiClient', () => {
188+
it('builds a typed API client wired to the Bun TLS http library', () => {
189+
const api = makeApiClient(makeKubeConfig({ token: 'tok' }), k8s.CoreV1Api);
190+
expect(api).toBeInstanceOf(k8s.CoreV1Api);
191+
});
192+
193+
it('throws when the kubeconfig has no active cluster', () => {
194+
const kc = new k8s.KubeConfig();
195+
expect(() => makeApiClient(kc, k8s.CoreV1Api)).toThrow('No active cluster!');
196+
});
197+
});

0 commit comments

Comments
 (0)