Skip to content

Commit d0ba22f

Browse files
authored
feat(frontend): metrics-sdk support and devbox implement (#6613)
* feat: basic feat * feat: test * feat: perf code to full feat * chore: update readme.md * feat: optional namespace * perf: podName logic adjust * chore: Prometheus->Metrics * chore: test adjust * chore: test logResult * chore: readme.zh-CN.md * chore: remove minio type * chore: adjust launchpad storage to disk * chore: transform enum to type * fix: try to fix ts bug * feat: devbox metrics support * feat: adjust other route * fix: whitelistKubernetesHosts sdk transform bug * feat: cache
1 parent e699918 commit d0ba22f

File tree

35 files changed

+2580
-148
lines changed

35 files changed

+2580
-148
lines changed

frontend/packages/metrics-sdk/README.md

Lines changed: 458 additions & 0 deletions
Large diffs are not rendered by default.

frontend/packages/metrics-sdk/README.zh-CN.md

Lines changed: 430 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"name": "sealos-metrics-sdk",
3+
"version": "0.1.0",
4+
"description": "Sealos metrics monitoring SDK",
5+
"type": "module",
6+
"author": "mlhiter",
7+
"scripts": {
8+
"test": "tsx test/index.ts",
9+
"build": "rollup -c",
10+
"dev": "rollup -c -w"
11+
},
12+
"exports": {
13+
".": {
14+
"import": "./dist/index.esm.js",
15+
"require": "./dist/index.js",
16+
"types": "./dist/index.d.ts"
17+
}
18+
},
19+
"typesVersions": {
20+
"*": {
21+
"*": [
22+
"./dist/index.d.ts"
23+
]
24+
}
25+
},
26+
"files": [
27+
"dist/",
28+
"README.zh-CN.md",
29+
"README.md"
30+
],
31+
"keywords": [
32+
"sealos",
33+
"metrics",
34+
"monitoring",
35+
"sdk"
36+
],
37+
"license": "Apache-2.0",
38+
"dependencies": {
39+
"axios": "^1.5.1",
40+
"dayjs": "^1.11.10"
41+
},
42+
"peerDependencies": {
43+
"@kubernetes/client-node": "^0.18.1"
44+
},
45+
"devDependencies": {
46+
"@kubernetes/client-node": "^0.18.1",
47+
"@rollup/plugin-typescript": "^11.1.4",
48+
"@types/node": "^20.7.1",
49+
"dotenv": "^16.3.1",
50+
"rollup": "2.79.1",
51+
"rollup-plugin-copy": "^3.5.0",
52+
"rollup-plugin-dts": "^4.2.3",
53+
"tslib": "^2.6.2",
54+
"tsx": "^4.7.0",
55+
"typescript": "^5.0.0"
56+
}
57+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import typescript from '@rollup/plugin-typescript';
2+
import dts from 'rollup-plugin-dts';
3+
import copy from 'rollup-plugin-copy';
4+
5+
const external = ['axios', 'dayjs', '@kubernetes/client-node'];
6+
7+
export default [
8+
{
9+
input: 'src/index.ts',
10+
output: [
11+
{
12+
file: 'dist/index.js',
13+
format: 'cjs',
14+
sourcemap: true
15+
},
16+
{
17+
file: 'dist/index.esm.js',
18+
format: 'es',
19+
sourcemap: true
20+
}
21+
],
22+
external,
23+
plugins: [
24+
typescript({
25+
tsconfig: './tsconfig.json',
26+
declaration: false
27+
})
28+
]
29+
},
30+
{
31+
input: 'src/index.ts',
32+
output: {
33+
file: 'dist/index.d.ts',
34+
format: 'es'
35+
},
36+
external,
37+
plugins: [
38+
dts(),
39+
copy({
40+
targets: [{ src: 'package.json', dest: 'dist' }]
41+
})
42+
]
43+
}
44+
];
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import * as k8s from '@kubernetes/client-node';
2+
import * as http from 'http';
3+
import * as https from 'https';
4+
import * as net from 'net';
5+
6+
export class AuthService {
7+
private kubeconfig: string;
8+
private k8sApi?: k8s.CoreV1Api;
9+
private authApi?: k8s.AuthorizationV1Api;
10+
private kubeConfig?: k8s.KubeConfig;
11+
private whitelistKubernetesHosts: string[];
12+
private authCache: Map<string, { allowed: boolean; expiresAt: number }>;
13+
private cacheTTL: number;
14+
private inFlightAuth: Map<string, Promise<void>>;
15+
private lastCacheSweepAt: number;
16+
private readonly cacheSweepIntervalMs: number;
17+
18+
constructor(kubeconfig: string, whitelistKubernetesHosts?: string[], cacheTTL?: number) {
19+
this.kubeconfig = kubeconfig;
20+
this.whitelistKubernetesHosts =
21+
whitelistKubernetesHosts ||
22+
(process.env.WHITELIST_KUBERNETES_HOSTS || '')
23+
.split(',')
24+
.map((host) => host.trim())
25+
.filter(Boolean);
26+
this.cacheTTL = cacheTTL ?? 300000; // 5 minutes default
27+
this.authCache = new Map();
28+
this.inFlightAuth = new Map();
29+
this.lastCacheSweepAt = 0;
30+
this.cacheSweepIntervalMs = 60000;
31+
}
32+
33+
resolveNamespace(namespace?: string): string {
34+
if (namespace && namespace.trim().length > 0) {
35+
return namespace;
36+
}
37+
const contextNamespace = this.getCurrentContextNamespace();
38+
if (!contextNamespace) {
39+
throw new Error('Namespace not found');
40+
}
41+
return contextNamespace;
42+
}
43+
44+
private getKubernetesHostFromEnv(): string {
45+
const host = process.env.KUBERNETES_SERVICE_HOST;
46+
const port = process.env.KUBERNETES_SERVICE_PORT;
47+
if (!host || !port) return '';
48+
const formattedHost = net.isIPv6(host) ? `[${host}]` : host;
49+
return `https://${formattedHost}:${port}`;
50+
}
51+
52+
private isWhitelistKubernetesHost(host: string): boolean {
53+
return this.whitelistKubernetesHosts.includes(host);
54+
}
55+
56+
private getKubeConfig(): k8s.KubeConfig {
57+
if (this.kubeConfig) {
58+
return this.kubeConfig;
59+
}
60+
61+
const kc = new k8s.KubeConfig();
62+
kc.loadFromString(this.kubeconfig);
63+
const cluster = kc.getCurrentCluster();
64+
if (!cluster) {
65+
throw new Error('No active cluster');
66+
}
67+
68+
if (!this.isWhitelistKubernetesHost(cluster.server)) {
69+
const k8sHost = this.getKubernetesHostFromEnv();
70+
if (!k8sHost) {
71+
throw new Error('unable to get the sealos host');
72+
}
73+
const clusters = kc
74+
.getClusters()
75+
.map((item) => (item.name === cluster.name ? { ...item, server: k8sHost } : item));
76+
kc.loadFromOptions({
77+
clusters,
78+
users: kc.getUsers(),
79+
contexts: kc.getContexts(),
80+
currentContext: kc.getCurrentContext()
81+
});
82+
}
83+
84+
this.kubeConfig = kc;
85+
return kc;
86+
}
87+
88+
private getCurrentContextNamespace(): string | undefined {
89+
const kc = this.getKubeConfig();
90+
const contextName = kc.getCurrentContext();
91+
if (!contextName) return undefined;
92+
return kc.getContextObject(contextName)?.namespace;
93+
}
94+
95+
private sweepExpiredCache(): void {
96+
if (this.cacheTTL <= 0 || this.authCache.size === 0) return;
97+
const now = Date.now();
98+
if (now - this.lastCacheSweepAt < this.cacheSweepIntervalMs) return;
99+
this.lastCacheSweepAt = now;
100+
for (const [namespace, cache] of this.authCache.entries()) {
101+
if (cache.expiresAt <= now) {
102+
this.authCache.delete(namespace);
103+
}
104+
}
105+
}
106+
107+
private async pingReadyz(): Promise<void> {
108+
const kc = this.getKubeConfig();
109+
const cluster = kc.getCurrentCluster();
110+
if (!cluster) {
111+
throw new Error('No active cluster');
112+
}
113+
const serverUrl = new URL(cluster.server);
114+
const isHttps = serverUrl.protocol === 'https:';
115+
const port = serverUrl.port ? Number(serverUrl.port) : isHttps ? 443 : 80;
116+
117+
const requestOptions: https.RequestOptions = {
118+
method: 'GET',
119+
hostname: serverUrl.hostname,
120+
port,
121+
path: '/readyz',
122+
protocol: serverUrl.protocol,
123+
headers: {}
124+
};
125+
126+
await kc.applytoHTTPSOptions(requestOptions);
127+
128+
await new Promise<void>((resolve, reject) => {
129+
const requester = isHttps ? https : http;
130+
const req = requester.request(requestOptions, (res) => {
131+
let body = '';
132+
res.on('data', (chunk) => {
133+
body += chunk.toString();
134+
});
135+
res.on('end', () => {
136+
if (body.trim() === 'ok') {
137+
resolve();
138+
return;
139+
}
140+
reject(new Error(`ping apiserver is no ok: ${body}`));
141+
});
142+
});
143+
req.on('error', (error) => {
144+
reject(new Error(`ping apiserver error: ${error.message}`));
145+
});
146+
req.end();
147+
});
148+
}
149+
150+
private getK8sClient(): { coreApi: k8s.CoreV1Api; authApi: k8s.AuthorizationV1Api } {
151+
if (this.k8sApi && this.authApi) {
152+
return { coreApi: this.k8sApi, authApi: this.authApi };
153+
}
154+
155+
const kc = this.getKubeConfig();
156+
157+
this.k8sApi = kc.makeApiClient(k8s.CoreV1Api);
158+
this.authApi = kc.makeApiClient(k8s.AuthorizationV1Api);
159+
160+
return { coreApi: this.k8sApi, authApi: this.authApi };
161+
}
162+
163+
async authenticate(namespace: string): Promise<void> {
164+
const ns = namespace.trim();
165+
if (!ns) {
166+
throw new Error('Namespace not found');
167+
}
168+
169+
this.sweepExpiredCache();
170+
171+
if (this.cacheTTL > 0) {
172+
const cached = this.authCache.get(ns);
173+
if (cached) {
174+
if (cached.expiresAt > Date.now()) {
175+
if (!cached.allowed) {
176+
throw new Error('No permission for this namespace');
177+
}
178+
return;
179+
}
180+
this.authCache.delete(ns);
181+
}
182+
}
183+
184+
const inFlight = this.inFlightAuth.get(ns);
185+
if (inFlight) return inFlight;
186+
187+
const { authApi } = this.getK8sClient();
188+
189+
const authPromise = (async () => {
190+
await this.pingReadyz();
191+
192+
const review: k8s.V1SelfSubjectAccessReview = {
193+
apiVersion: 'authorization.k8s.io/v1',
194+
kind: 'SelfSubjectAccessReview',
195+
spec: {
196+
resourceAttributes: {
197+
namespace: ns,
198+
verb: 'get',
199+
group: '',
200+
version: 'v1',
201+
resource: 'pods'
202+
}
203+
}
204+
};
205+
206+
try {
207+
const response = await authApi.createSelfSubjectAccessReview(review);
208+
209+
if (!response.body.status?.allowed) {
210+
if (this.cacheTTL > 0) {
211+
this.authCache.set(ns, {
212+
allowed: false,
213+
expiresAt: Date.now() + this.cacheTTL
214+
});
215+
}
216+
throw new Error('No permission for this namespace');
217+
}
218+
219+
if (this.cacheTTL > 0) {
220+
this.authCache.set(ns, {
221+
allowed: true,
222+
expiresAt: Date.now() + this.cacheTTL
223+
});
224+
}
225+
} catch (error) {
226+
if (error instanceof Error) {
227+
throw new Error(`Authentication failed: ${error.message}`);
228+
}
229+
throw error;
230+
}
231+
})().finally(() => {
232+
this.inFlightAuth.delete(ns);
233+
});
234+
235+
this.inFlightAuth.set(ns, authPromise);
236+
return authPromise;
237+
}
238+
239+
clearCache(): void {
240+
this.authCache.clear();
241+
}
242+
243+
clearNamespaceCache(namespace: string): void {
244+
this.authCache.delete(namespace);
245+
}
246+
247+
getCacheStats(): {
248+
size: number;
249+
entries: Array<{ namespace: string; allowed: boolean; expiresIn: number }>;
250+
} {
251+
this.sweepExpiredCache();
252+
const entries = Array.from(this.authCache.entries()).map(([namespace, cache]) => ({
253+
namespace,
254+
allowed: cache.allowed,
255+
expiresIn: Math.max(0, cache.expiresAt - Date.now())
256+
}));
257+
return {
258+
size: this.authCache.size,
259+
entries
260+
};
261+
}
262+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './auth';

0 commit comments

Comments
 (0)