Lens app source code (#119)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2020-03-15 09:52:02 +02:00
1 parent 936cbd53d4
commit 1d0815abd2
797 files changed
+73714 -215

No files matched your search

@@ -0,0 +1,23 @@
// Get certificate auth data
import * as fs from "fs";
import * as util from "util";
import config from "../config";
let caData: string = null
export async function getCertificateAuthorityData(encoding = 'utf8'): Promise<string> {
if (caData) {
return caData
}
if (!fs.existsSync(config.KUBERNETES_CA_CERT)) {
caData = config.KUBERNETES_CA_CERT
return caData
}
try {
const ca = await util.promisify(fs.readFile)(config.KUBERNETES_CA_CERT);
return Buffer.from(ca).toString(encoding);
} catch (error) {
return ''
}
}
+39
View File
@@ -0,0 +1,39 @@
// Get cluster info
import config from "../config"
import { kubeRequest } from "./kube-request";
import { IClusterInfo, IClusterConfigMap } from "../common/cluster"
export async function getClusterInfo(): Promise<IClusterInfo> {
const [configMap, kubeVersion, pharosVersion] = await Promise.all([
getClusterConfigMap().catch(() => ({} as IClusterConfigMap)),
getKubeVersion().catch(() => null),
getPharosVersion().catch(() => null),
]);
return {
...configMap,
kubeVersion,
pharosVersion,
};
}
export async function getClusterConfigMap() {
const res = await kubeRequest<{ data: IClusterConfigMap }>({
path: `/api/v1/namespaces/${config.LENS_NAMESPACE}/configmaps/config`,
});
return res.data;
}
export async function getKubeVersion() {
const res = await kubeRequest<{ gitVersion: string }>({
path: "/version",
});
return res.gitVersion.slice(1);
}
export async function getPharosVersion() {
const res = await kubeRequest<{ data: { "pharos-version": string } }>({
path: `/api/v1/namespaces/kube-system/configmaps/pharos-config`,
});
return res ? res.data["pharos-version"] : null;
}
+59
View File
@@ -0,0 +1,59 @@
// Get namespaces
import config from "../config";
import { KubeJsonApiDataList } from "../../client/api/kube-json-api";
import { IKubeRequestParams, kubeRequest } from "./kube-request";
import { reviewResourceAccess } from "./review-resource-access";
import { getServiceAccountToken } from "./get-service-account-token"
export async function getNamespaces(params: Partial<IKubeRequestParams> = {}) {
return kubeRequest<KubeJsonApiDataList>({
...params,
path: "/api/v1/namespaces",
});
}
export async function getAllowedNamespaces(
params: Partial<IKubeRequestParams>,
fallbackNs = config.KUBERNETES_NAMESPACE,
): Promise<string[]> {
try {
const allNamespaces = await getNamespaces(params);
const nsAccessStatuses = await Promise.all(
allNamespaces.items.map(ns => {
const { name } = ns.metadata;
return reviewResourceAccess(params, {
namespace: name,
resource: "pods",
verb: "list",
});
})
);
return allNamespaces.items
.filter((ns, i) => nsAccessStatuses[i].allowed)
.map(ns => ns.metadata.name);
} catch (e) {
const serviceToken = await getServiceAccountToken();
if (!serviceToken) {
return fallbackNs ? [fallbackNs] : [];
}
// fetch namespaces with service-account token (cluster-wide)
// and for every namespace make additional request to check if namespace available for user-token
const allNamespaces = await getNamespaces({
authHeader: `Bearer ${serviceToken}`
});
const nsAccessStatuses = await Promise.all(
allNamespaces.items.map(ns => {
const { name } = ns.metadata;
return reviewResourceAccess(params, {
namespace: name,
resource: "pods",
verb: "list",
});
})
);
return allNamespaces.items
.filter((ns, i) => nsAccessStatuses[i].allowed)
.map(ns => ns.metadata.name);
}
}
@@ -0,0 +1,22 @@
// Get service-account token
import { existsSync, readFile } from "fs";
import { promisify } from "util";
import config from "../config"
const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
export async function getServiceAccountToken() {
const { SERVICE_ACCOUNT_TOKEN } = config;
if (SERVICE_ACCOUNT_TOKEN) {
return SERVICE_ACCOUNT_TOKEN;
}
if (existsSync(tokenPath)) {
const token = await promisify(readFile)(tokenPath);
return token.toString().trim();
}
return null;
}
+19
View File
@@ -0,0 +1,19 @@
// Check cluster-admin rights for auth-token
// CLI: kubectl auth can-i '*' '*' --all-namespaces
import { reviewResourceAccess } from "./review-resource-access";
import { IKubeRequestParams } from "./kube-request";
export async function isClusterAdmin(params: Partial<IKubeRequestParams>): Promise<boolean> {
try {
const accessCheck = await reviewResourceAccess(params, {
resource: "*",
namespace: "*",
group: "*",
verb: "*",
});
return accessCheck.allowed;
} catch (err) {
return false;
}
}
+58
View File
@@ -0,0 +1,58 @@
// Kubernetes request api helper
import config, { isSecure } from "../config";
import axios, { AxiosError, AxiosRequestConfig } from "axios"
import * as https from "https";
import { getCertificateAuthorityData } from "./get-cert-auth-data";
import { logger, sanitizeHeaders } from "../utils/logger";
import { getServiceAccountToken } from "./get-service-account-token";
export interface IKubeRequestParams extends AxiosRequestConfig {
path: string;
authHeader?: string;
}
export async function kubeRequest<T>(params: IKubeRequestParams): Promise<T> {
const { KUBE_CLUSTER_URL, KUBERNETES_CLIENT_CERT, KUBERNETES_CLIENT_KEY } = config;
const serviceToken = await getServiceAccountToken();
const defaultAuthHeader = serviceToken ? `Bearer ${serviceToken}` : "";
const {
authHeader = defaultAuthHeader,
url = KUBE_CLUSTER_URL,
path = "",
...reqConfig
} = params;
// add access token
reqConfig.headers = Object.assign({}, reqConfig.headers, {
"Content-type": "application/json",
});
if (!KUBERNETES_CLIENT_CERT && authHeader) {
reqConfig.headers["Authorization"] = authHeader;
}
// allow requests to kube-cluster without valid ssl certs..
reqConfig.httpsAgent = new https.Agent({
rejectUnauthorized: isSecure(),
cert: KUBERNETES_CLIENT_CERT,
key: KUBERNETES_CLIENT_KEY,
ca: await getCertificateAuthorityData(),
});
const reqUrl = url + path;
return axios(reqUrl, reqConfig)
.then(res => res.data)
.catch((error: AxiosError<T>) => {
const { message, config } = error;
logger.error(`[KUBE-REQUEST]: ${message}`, {
code: error.code,
method: config.method,
url: config.url,
headers: sanitizeHeaders(config.headers),
params: config.params,
});
throw error;
});
}
@@ -0,0 +1,47 @@
// Get resource access review
// Docs: https://kubernetes.io/docs/reference/access-authn-authz/authorization/
import { IKubeRequestParams, kubeRequest } from "./kube-request";
interface IResourceAccess {
apiVersion: string;
kind: string;
status: IResourceAccessStatus;
}
export interface IResourceAccessStatus {
allowed: boolean;
denied?: boolean;
reason?: string;
evaluationError?: string;
}
interface IResourceAccessAttributes {
group?: string | "*";
resource?: string | "*";
verb?: "get" | "list" | "create" | "update" | "patch" | "watch" | "proxy" | "redirect" | "delete" | "deletecollection" | "*";
namespace?: string | "*";
}
export async function reviewResourceAccess(
params: Partial<IKubeRequestParams> = {},
attrs: IResourceAccessAttributes
): Promise<IResourceAccessStatus> {
try {
const accessReview = await kubeRequest<IResourceAccess>({
...params,
method: "POST",
path: "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews",
data: {
spec: {
resourceAttributes: attrs
}
}
});
return accessReview.status;
} catch (err) {
return {
allowed: false,
reason: err.toString(),
}
}
}
+39
View File
@@ -0,0 +1,39 @@
// Check validity of auth-token
import { kubeRequest } from "./kube-request";
export interface ITokenReview {
apiVersion: string;
kind: string;
status: ITokenReviewStatus;
}
export interface ITokenReviewStatus {
authenticated: boolean;
user: {
username?: string;
uid?: string;
groups?: string[];
};
error?: string[];
}
export async function reviewToken(authToken: string): Promise<ITokenReviewStatus> {
try {
const tokenReview = await kubeRequest<ITokenReview>({
path: "/apis/authentication.k8s.io/v1/tokenreviews",
method: "POST",
data: {
spec: {
token: authToken
}
}
});
return tokenReview.status;
} catch (err) {
return {
authenticated: false,
user: {},
error: [err.toString()],
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import config, { BUILD_DIR, CLIENT_DIR } from "../server/config"
import path from "path"
import fs from "fs"
import express from "express"
import cookieSession from "cookie-session"
import compression from "compression"
import helmet from "helmet"
import morgan from "morgan"
import { logger } from "../server/utils/logger"
import { configRoute, kubeconfigRoute, kubewatchRoute, metricsRoute, readyStateRoute } from "../server/routes";
import { useRequestHeaderToken } from "../server/middlewares";
const {
IS_PRODUCTION, LOCAL_SERVER_PORT, API_PREFIX,
SESSION_NAME, SESSION_SECRET,
} = config;
const app = express();
const localApis = express.Router();
const outputDir = path.resolve(process.cwd(), BUILD_DIR, CLIENT_DIR);
app.set('trust proxy', 1); // trust first proxy
localApis.use(
configRoute(),
readyStateRoute(),
kubeconfigRoute(),
kubewatchRoute(),
metricsRoute()
);
// https://github.com/expressjs/cookie-session
app.use(cookieSession({
name: SESSION_NAME,
secret: SESSION_SECRET,
secure: IS_PRODUCTION,
httpOnly: true,
maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year
}));
// protect from well-known web vulnerabilities by setting HTTP headers appropriately
// https://github.com/helmetjs/helmet
app.use(helmet({
hsts: {
includeSubDomains: false,
}
}));
// use auth-token from request headers (if applicable via proxy)
app.use(useRequestHeaderToken());
// requests logging
app.use(morgan('tiny'));
// enable gzip compression
app.use(compression());
app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
app.use("/", express.static(outputDir)); // handle static files (assets)
app.use(API_PREFIX.BASE, express.json({ limit: "10mb" }), localApis);
// handle all page requests via index.html, in development mode it's managed by webpack-dev-server
app.all('*', (req, res) => {
const indexHtml = path.resolve(outputDir, 'index.html');
if (fs.existsSync(indexHtml)) res.sendFile(indexHtml);
else {
res.send("Error: build/index.html doesn't exists");
}
});
// run server
const server = app.listen(LOCAL_SERVER_PORT, "127.0.0.1", () => {
logger.appStarted(LOCAL_SERVER_PORT, 'Server started');
});
+9
View File
@@ -0,0 +1,9 @@
export interface IClusterConfigMap {
clusterName: string;
clusterUrl: string;
}
export interface IClusterInfo extends IClusterConfigMap {
kubeVersion?: string;
pharosVersion?: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { IClusterInfo } from "../common/cluster";
export interface IConfig extends Partial<IClusterInfo> {
lensVersion?: string;
lensTheme?: string;
username?: string;
token?: string;
allowedNamespaces?: string[];
isClusterAdmin?: boolean;
chartsEnabled: boolean;
kubectlAccess?: boolean; // User accessed via kubectl-lens plugin
}
+14
View File
@@ -0,0 +1,14 @@
export interface IKubeWatchEvent<T = any> {
type: "ADDED" | "MODIFIED" | "DELETED";
object?: T;
}
export interface IKubeWatchRouteEvent {
type: "STREAM_END";
url: string;
status: number;
}
export interface IKubeWatchRouteQuery {
api: string | string[];
}
+12
View File
@@ -0,0 +1,12 @@
export interface ILicense {
name?: string;
maxNodes?: number;
owner?: {
company?: string;
firstName?: string;
lastName?: string;
username?: string;
};
validUntil?: string;
recurring?: boolean;
}
+4
View File
@@ -0,0 +1,4 @@
export type IMetricsQuery = string | string[] | {
[metricName: string]: string;
}
+74
View File
@@ -0,0 +1,74 @@
// Server-side config
export const CLIENT_DIR = "client";
export const BUILD_DIR = "build";
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
export const KUBERNETES_SERVICE_HOST = process.env.KUBERNETES_SERVICE_HOST || "kubernetes";
export const KUBERNETES_SERVICE_PORT = Number(process.env.KUBERNETES_SERVICE_PORT || 443);
export const KUBERNETES_SERVICE_URL = `https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}`;
export const config = {
IS_PRODUCTION: IS_PRODUCTION,
LENS_VERSION: process.env.LENS_VERSION,
LENS_THEME: process.env.LENS_THEME,
BUILD_VERSION: process.env.BUILD_VERSION,
API_PREFIX: {
BASE: '/api', // local express.js server api
TERMINAL: '/api-terminal', // terminal api
KUBE_BASE: '/api-kube', // kubernetes cluster api
KUBE_USERS: '/api-users', // users & groups api
KUBE_HELM: '/api-helm', // helm charts api middleware
KUBE_RESOURCE_APPLIER: "/api-resource",
},
// express.js port
LOCAL_SERVER_PORT: Number(process.env.LOCAL_SERVER_PORT || 8889),
WEBPACK_DEV_SERVER_PORT: Number(process.env.LOCAL_SERVER_PORT || 8080),
// session
SESSION_NAME: process.env.SESSION_NAME || "lens-s3ss10n",
SESSION_SECRET: process.env.SESSION_SECRET || "k0nt3n@-s3cr3t-key",
// kubernetes apis
KUBE_CLUSTER_NAME: process.env.KUBE_CLUSTER_NAME,
KUBE_CLUSTER_URL: process.env.KUBE_CLUSTER_URL || KUBERNETES_SERVICE_URL,
KUBE_USERS_URL: process.env.KUBE_USERS_URL || `http://localhost:9999`,
KUBE_TERMINAL_URL: process.env.KUBE_TERMINAL_URL || `http://localhost:9998`,
KUBE_HELM_URL: process.env.KUBE_HELM_URL || `http://localhost:9292`,
KUBE_RESOURCE_APPLIER_URL: process.env.KUBE_RESOURCE_APPLIER_URL || `http://localhost:9393`,
KUBE_METRICS_URL: process.env.KUBE_METRICS_URL || `http://localhost:9090`, // rbac-proxy-url
// flags define visibility of some ui-parts and pages in dashboard
USER_MANAGEMENT_ENABLED: JSON.parse(process.env.USER_MANAGEMENT_ENABLED || "false"),
CHARTS_ENABLED: JSON.parse(process.env.CHARTS_ENABLED || "false"),
// kontena namespaces
LENS_NAMESPACE: process.env.LENS_NAMESPACE || "kontena-lens",
STATS_NAMESPACE: process.env.STATS_NAMESPACE || "kontena-stats",
SERVICE_ACCOUNT_TOKEN: process.env.SERVICE_ACCOUNT_TOKEN
|| null,
KUBERNETES_CA_CERT: process.env.KUBERNETES_CA_CERT,
KUBERNETES_CLIENT_CERT: process.env.KUBERNETES_CLIENT_CERT || "",
KUBERNETES_CLIENT_KEY: process.env.KUBERNETES_CLIENT_KEY || "",
KUBERNETES_TLS_SKIP: JSON.parse(process.env.KUBERNETES_TLS_SKIP || "false"),
KUBERNETES_NAMESPACE: process.env.KUBERNETES_NAMESPACE || "", // default allowed namespace
}
export function isSecure() {
return IS_PRODUCTION ? !config.KUBERNETES_TLS_SKIP : false;
}
export default config;
// Client-side process.env, must be provided by webpack.DefinePlugin
export const clientVars = {
BUILD_VERSION: config.BUILD_VERSION,
IS_PRODUCTION: config.IS_PRODUCTION,
API_PREFIX: config.API_PREFIX,
LOCAL_SERVER_PORT: config.LOCAL_SERVER_PORT,
}
export type IClientVars = typeof clientVars;
+3
View File
@@ -0,0 +1,3 @@
export * from "./kube-proxy"
export * from "./terminal-proxy"
export * from "./use-header-token"
@@ -0,0 +1,24 @@
import { Request } from "express";
import proxy from "http-proxy-middleware"
import { userSession } from "../user-session";
import config, { isSecure } from "../config";
export function kubeProxy(serviceUrl: string, proxyConfig: proxy.Config = {}) {
const { IS_PRODUCTION } = config;
return proxy({
target: serviceUrl,
secure: isSecure(), // verify the ssl certs
logLevel: IS_PRODUCTION ? "info" : "debug",
changeOrigin: true, // needed for virtual hosted sites
pathRewrite: (path, req: Request) => {
return path.replace(req.baseUrl, ""); // remove client-prefix, e.g "/api-kube"
},
onProxyReq(proxyReq, req: Request, res) {
const { authHeader } = userSession.get(req);
if (authHeader) {
proxyReq.setHeader("Authorization", authHeader);
}
},
...proxyConfig,
})
}
@@ -0,0 +1,19 @@
import { NextFunction } from "express";
import proxy from "http-proxy-middleware"
import appConfig from "../config"
const { KUBE_TERMINAL_URL, API_PREFIX, IS_PRODUCTION } = appConfig;
interface ITerminalProxy extends NextFunction {
upgrade: () => void;
}
export const terminalProxy = proxy({
target: KUBE_TERMINAL_URL,
ws: true,
changeOrigin: true,
logLevel: IS_PRODUCTION ? "info" : "debug",
pathRewrite: {
["^" + API_PREFIX.TERMINAL]: "" // remove api-prefix
}
}) as ITerminalProxy;
@@ -0,0 +1,20 @@
// Allow to use "Authorization" from request for auto-login (when provided by proxy)
import { NextFunction, Request, Response } from "express"
import { userSession } from "../user-session";
export function useRequestHeaderToken() {
return (req: Request, res: Response, next: NextFunction) => {
const authorization = req.headers["authorization"] || req.headers["x-lens-kubectl-token"];
const { authHeader, isUserLogin } = userSession.get(req);
const userHasOwnToken = authHeader && isUserLogin;
// don't overwrite user's login credentials
if (authorization && !userHasOwnToken && authHeader !== authorization) {
userSession.save(req, {
authHeader: authorization.toString(),
});
}
next();
}
}
+48
View File
@@ -0,0 +1,48 @@
//-- Config route
import config from "../config";
import { IConfig } from "../common/config"
import { Router } from "express";
import { userSession } from "../user-session";
import { getClusterInfo } from "../api/get-cluster-info";
import { isClusterAdmin } from "../api/is-cluster-admin";
import { getAllowedNamespaces } from "../api/get-namespaces";
import { parseJwt } from "../utils/parse-jwt";
export function configRoute() {
const router = Router();
router.route('/config')
.get(async (req, res) => {
const { username, authHeader } = userSession.get(req);
const authToken = userSession.getToken(req);
const data: IConfig = {
clusterName: config.KUBE_CLUSTER_NAME,
lensVersion: config.LENS_VERSION,
lensTheme: config.LENS_THEME,
chartsEnabled: !!config.CHARTS_ENABLED,
kubectlAccess: !!req.headers["x-lens-kubectl-token"]
};
// load config data from other places
const loading: Promise<any>[] = [
getClusterInfo().then(info => Object.assign(data, info)),
];
// validate user token from session and fetch more config data
if (authToken) {
const { sub, email } = parseJwt(authToken);
data.username = email || sub || username;
data.token = authToken;
loading.push(
isClusterAdmin({ authHeader }).then(isAdmin => data.isClusterAdmin = isAdmin),
getAllowedNamespaces({ authHeader }).then(list => data.allowedNamespaces = list),
);
}
await Promise.all(loading);
res.json(data);
});
return router;
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./config-route"
export * from "./kubeconfig-route"
export * from "./kubewatch-route"
export * from "./metrics-route"
export * from "./ready-state-route"
+100
View File
@@ -0,0 +1,100 @@
//-- Kubeconfig route
import { Router } from "express";
import { AxiosError } from "axios";
import { userSession } from "../user-session";
import { kubeRequest } from "../api/kube-request";
import { Secret } from "../../client/api/endpoints";
import { base64 } from "../../client/utils/base64";
import { getCertificateAuthorityData } from "../api/get-cert-auth-data";
import { getClusterConfigMap } from "../api/get-cluster-info";
interface IKubeConfigParams {
username: string;
authToken: string;
certificateAuthority: string;
namespace?: string;
}
export function kubeconfigRoute() {
const router = Router();
router.route('/kubeconfig/user')
.get(async (req, res) => {
const { username = "kubernetes" } = userSession.get(req);
const authToken = userSession.getToken(req);
const cert = await getCertificateAuthorityData('base64');
const data = await generateKubeConfig({
username,
authToken: authToken,
certificateAuthority: cert
});
res.json(data);
});
router.route('/kubeconfig/service-account/:namespace/:account')
.get(async (req, res) => {
const { authHeader } = userSession.get(req);
const { namespace, account } = req.params;
try {
const secret = await kubeRequest<{ items: Secret[] }>({
path: `/api/v1/namespaces/${namespace}/secrets`,
authHeader: authHeader,
}).then(secrets => {
return secrets.items.find(secret => {
const { annotations } = secret.metadata;
return annotations && annotations["kubernetes.io/service-account.name"] == account;
});
});
const data = await generateKubeConfig({
username: account,
namespace: namespace,
authToken: base64.decode(secret.data.token),
certificateAuthority: secret.data["ca.crt"],
});
res.json(data);
} catch (err) {
const { response }: AxiosError = err;
res.status(403).json(response ? response.data : err.toString());
}
});
return router;
}
async function generateKubeConfig(params: IKubeConfigParams) {
const { clusterName, clusterUrl } = await getClusterConfigMap();
const { authToken, username, certificateAuthority, namespace = "" } = params;
return {
'apiVersion': 'v1',
'kind': 'Config',
'clusters': [
{
'name': clusterName,
'cluster': {
'server': clusterUrl,
'certificate-authority-data': certificateAuthority
}
}
],
'users': [
{
'name': username,
'user': {
'token': authToken,
}
}
],
'contexts': [
{
'name': clusterName,
'context': {
'user': username,
'cluster': clusterName,
'namespace': namespace,
}
}
],
'current-context': clusterName
}
}
+139
View File
@@ -0,0 +1,139 @@
//-- Streaming k8s watch-api events
import axios from "axios"
import { Router } from "express";
import { IncomingMessage } from "http";
import { kubeRequest } from "../api/kube-request";
import { IKubeWatchEvent, IKubeWatchRouteEvent, IKubeWatchRouteQuery} from "../common/kubewatch"
import { userSession } from "../user-session";
import { logger } from "../utils/logger";
export function kubewatchRoute() {
const router = Router();
router.route('/watch')
.get(async (req, res) => {
const { authHeader } = userSession.get(req);
const queryParams: IKubeWatchRouteQuery = req.query;
const apis: string[] = [].concat(queryParams.api || []);
const streams = new Map<string, IncomingMessage>();
const eventsBuffer = new Map<string, IKubeWatchEvent>();
let isClosing = false;
if (!apis.length) {
res.status(400).json({
message: "Empty request. Query params 'api' are not provided.",
example: "?api=/api/v1/pods&api=/api/v1/nodes",
});
return;
}
res.header({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// init streams
const cancelToken = axios.CancelToken.source();
apis.forEach(apiUrl => {
console.log("[KUBE-WATCH] init stream", apiUrl);
const connecting = kubeRequest<IncomingMessage>({
path: apiUrl,
responseType: "stream",
authHeader: authHeader,
cancelToken: cancelToken.token,
});
connecting.then(stream => {
streams.set(apiUrl, stream); // save connection for clean up
stream.socket.setKeepAlive(true); // keep connection alive
let lastUnusedBuffer = ""
return stream
.on("data", (buffer: Buffer) => {
const data = lastUnusedBuffer + buffer.toString().trim();
data.split("\n").map(str => {
try {
const eventObj = JSON.parse(str);
bufferEvent(eventObj); // handle
lastUnusedBuffer = ""; // clean up since parsing was successful
} catch (err) {
lastUnusedBuffer = str; // invalid json, tail must wait next incoming data
}
});
})
.on("end", () => {
// client must update resource-version and try to reconnect
console.log(`[KUBE-WATCH] stream ended ${apiUrl}`)
sendEvent({
type: "STREAM_END",
url: apiUrl,
status: stream.statusCode,
})
});
}, err => {
logger.error(`[KUBE-WATCH] error ${apiUrl}`, err);
sendEvent({
type: "STREAM_END",
url: apiUrl,
status: 410,
})
});
});
function getEventBufferId(evt: IKubeWatchEvent) {
const { object, type } = evt;
const { kind } = object;
let { metadata: { uid } } = object;
if (kind === "Event") {
uid = (object as any).involvedObject.uid; // reason: uid for events always unique
}
return `${type}:${kind}-${uid}`
}
function bufferEvent(evt: IKubeWatchEvent) {
const id = getEventBufferId(evt);
if (eventsBuffer.has(id)) {
eventsBuffer.delete(id); // clear to move event to the end in map's "timeline"
}
eventsBuffer.set(id, evt); // save latest event by object's identity
}
function sendEvent(evt: IKubeWatchEvent | IKubeWatchRouteEvent, autoFlush = true) {
if (isClosing) return;
// convert to "text/event-stream" format
res.write(`data: ${JSON.stringify(evt)}\n\n`);
if (autoFlush) {
// @ts-ignore
res.flush();
}
}
// process sending events
const flushInterval = setInterval(() => {
const eventsPack = Array.from(eventsBuffer.entries())
.slice(0, 100) // max limit per sending
.map(([id, evt]) => {
eventsBuffer.delete(id); // clean up used event
return evt;
});
if (eventsPack.length > 0) {
eventsPack.forEach(evt => sendEvent(evt, false));
// @ts-ignore
res.flush();
}
}, 1000);
function onClose() {
if (isClosing) return;
isClosing = true;
clearInterval(flushInterval);
streams.forEach(stream => stream.removeAllListeners("end"));
cancelToken.cancel();
}
req.on("close", onClose);
res.on("finish", onClose);
});
return router;
}
+82
View File
@@ -0,0 +1,82 @@
//-- Metrics
// https://prometheus.io/docs/prometheus/latest/querying/api/
import { Router } from "express";
import config from "../config";
import { kubeRequest } from "../api/kube-request";
import { userSession } from "../user-session";
import { AxiosError } from "axios";
import { IMetrics } from "../../client/api/endpoints/metrics.api";
import { IMetricsQuery } from "../common/metrics"
export function metricsRoute() {
const router = Router();
router.post("/metrics", async (req, res, next) => {
const { authHeader } = userSession.get(req);
const { namespace, ...queryParams } = req.query;
const query: IMetricsQuery = req.body;
/*eslint-disable */
// add default namespace for rbac-proxy validation
if (!queryParams.kubernetes_namespace) {
queryParams.kubernetes_namespace = config.STATS_NAMESPACE;
}
/*eslint-enble */
// prometheus metrics loader
const attempts: { [query: string]: number } = {};
const maxAttempts = 5;
const loadMetrics = (query: string): Promise<IMetrics> => {
const attempt = attempts[query] = (attempts[query] || 0) + 1;
return kubeRequest<IMetrics>({
url: config.KUBE_METRICS_URL,
path: "/api/v1/query_range",
authHeader: authHeader,
params: {
query: query,
...queryParams,
},
}).catch(async (err: AxiosError) => {
// https://github.com/axios/axios#handling-errors
if (!err.response && attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request
return loadMetrics(query);
}
return {
status: err.toString(),
data: {
result: []
},
} as IMetrics;
})
};
// return data in same structure as query
let data: any;
try {
if (typeof query === "string") {
data = await loadMetrics(query)
}
else if (Array.isArray(query)) {
data = await Promise.all(query.map(loadMetrics));
}
else {
data = {};
const result = await Promise.all(
Object.values(query).map(loadMetrics)
);
Object.keys(query).forEach((metricName, index) => {
data[metricName] = result[index];
});
}
res.json(data);
} catch (err) {
next(err);
}
});
return router;
}
@@ -0,0 +1,16 @@
//-- App readiness checker
import { Router } from "express";
export function readyStateRoute() {
const router = Router();
router.route('/ready')
.get(async (req, res) => {
const serviceWaitingList: string[] = [];
res.json(serviceWaitingList);
});
return router;
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../client/tsconfig.json",
"compilerOptions": {
"outDir": "../build",
"module": "commonjs",
"moduleResolution": "node",
"target": "esnext",
"sourceMap": false,
"esModuleInterop": true
},
"include": [
"./app.ts"
]
}
+24
View File
@@ -0,0 +1,24 @@
//-- User sessions helper
import { Request } from "express";
import CookieSessionObject = CookieSessionInterfaces.CookieSessionObject;
interface IUserSession extends CookieSessionObject {
authHeader: string;
username?: string;
isUserLogin?: boolean; // authorization via user's manual login with credentials
}
export const userSession = {
get(req: Request): Partial<IUserSession> {
return req.session;
},
save(req: Request, data: Partial<IUserSession> = {}) {
Object.assign(req.session, data);
},
getToken(req: Request): string {
const { authHeader = "" } = this.get(req);
const [type, token = ""] = authHeader.split(" ");
return token;
}
};
+32
View File
@@ -0,0 +1,32 @@
// Load & parse local kubernetes config (dev-only)
import * as jsYaml from "js-yaml"
import * as fs from "fs"
import * as os from "os"
import chalk from "chalk";
import { logger } from "./logger";
interface IKubeConfigParams {
clusterUrl: string;
userToken: string;
}
export function getKubeConfigDev(): Partial<IKubeConfigParams> {
const KUBE_CONFIG_FILE = process.env.KUBE_CONFIG_FILE;
if (!KUBE_CONFIG_FILE) {
return {}
}
let filePath = ""
try {
filePath = KUBE_CONFIG_FILE.replace("~", os.homedir());
const yaml = fs.readFileSync(filePath).toString();
const config = jsYaml.safeLoad(yaml);
return {
clusterUrl: config.clusters[0].cluster.server,
userToken: config.users[0].user.token,
}
} catch (err) {
logger.error(`[KUBE-CONFIG] Parsing config file ${chalk.bold(filePath)} failed.`, err)
return {};
}
}
+36
View File
@@ -0,0 +1,36 @@
import chalk from "chalk";
import * as ip from "ip"
const divider = chalk.gray('-----------------------------------');
export const logger = {
// Called when express.js app starts on given port w/o errors
appStarted: (port: string | number, title = 'Server started ') => {
console.log(chalk.underline.bold(title) + ` ${chalk.green('✓')}`);
console.log(`
${chalk.bold('Access URLs:')}
${divider}
Localhost: ${chalk.magenta(`http://localhost:${port}`)}
LAN: ${chalk.magenta(`http://${ip.address()}:${port}`)}
${divider}
`);
},
error(message: string, error: any) {
let errString = ""
try {
errString = JSON.stringify(error, null, 2);
} catch (e) {
errString = String(error);
}
console.error(chalk.bold.red(`[ERROR] -> ${message}`), errString);
}
};
export function sanitizeHeaders(headers: { [name: string]: string }) {
if (headers.Authorization) {
const [authType, authToken] = headers.Authorization.split(" ");
headers.Authorization = `${authType} *****`
}
return headers;
}
+30
View File
@@ -0,0 +1,30 @@
// Parse payload from jwt token
// Format: https://github.com/kontena/kube-oidc#openid-connect-and-kubernetes
import { base64 } from "../../client/utils/base64";
interface JwtPayload {
"azp": string;// "1077841816959-kkdh0lvq1au80qv4gtubotvgs9am4a95.apps.googleusercontent.com",
"aud": string;// "1077841816959-kkdh0lvq1au80qv4gtubotvgs9am4a95.apps.googleusercontent.com",
"sub": string;// "103613003764490648449",
"hd": string;// "redhat.com",
"email": string;// "echiang@redhat.com",
"email_verified": boolean; // true,
"at_hash": string;// "OGDOjIJ92FkatDBoCm8ydg",
"exp": number;// 1527203940,
"iss": string;// "https://accounts.google.com",
"iat": number;// 1527200340,
"name": string; // "Eric Chiang",
"picture": string; // "https://lh5.googleusercontent.com/-Cs2iHTXiETs/AAAAAAAAAAI/AAAAAAAAACM/0Q85UhZizjg/s96-c/photo.jpg",
"given_name": string; // "Eric",
"family_name": string; //"Chiang",
"locale": string; // "en"
}
export function parseJwt(token: string): Partial<JwtPayload> {
try {
const [header, payload, signature] = token.split(".");
return base64.decode(payload);
} catch (e) {
return {}
}
}