Files
cloud-host/backend/src/kubernetes/elasticsearch.service.ts
T
keyhan 22359be40e fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:35:07 +03:30

989 lines
32 KiB
TypeScript

import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import * as crypto from 'crypto';
import { ChildProcess, spawn } from 'child_process';
import { ClustersService } from '../clusters/clusters.service';
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
import { userNamespace } from './k8s-workload.util';
interface ElasticsearchCredentials {
username: string;
password: string;
}
export interface LogSearchFilters {
applicationId?: string;
applicationName?: string;
workload?: string;
level?: string;
from?: string;
to?: string;
search?: string;
page?: number;
limit?: number;
}
export interface NormalizedLogEntry {
id: string;
timestamp: string;
level: string;
message: string;
applicationId?: string;
applicationName?: string;
workload?: string;
namespace?: string;
pod?: string;
}
export interface LogSearchResult {
hits: NormalizedLogEntry[];
total: number;
page: number;
limit: number;
}
export interface LogStatsResult {
total: number;
errors: number;
warnings: number;
byLevel: Record<string, number>;
byWorkload: Record<string, number>;
period: string;
}
export type LoggingDeployStatus = 'not_installed' | 'installing' | 'failed' | 'ready';
export interface LoggingDeployState {
status: LoggingDeployStatus;
helmReleaseStatus?: string | null;
health: {
status: string;
clusterName: string;
numberOfNodes: number;
activePrimaryShards: number;
activeShards: number;
kibanaReady: boolean;
} | null;
}
/**
* Central Elasticsearch management service.
* Deploys a shared Elasticsearch + Kibana stack in a dedicated namespace
* that all user apps can send logs to via Fluent Bit sidecars.
*/
@Injectable()
export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ElasticsearchService.name);
private readonly ES_NAMESPACE = 'logging';
private readonly ES_NAME = 'elasticsearch';
private readonly KIBANA_NAME = 'kibana';
private portForwardChild: ChildProcess | null = null;
private portForwardStartedByUs = false;
private portForwardKubeconfigFile: string | null = null;
private portForwardClusterId: string | null = null;
private ensureInFlight: Promise<void> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempt = 0;
// Default credentials - should be overridden via env in production
private readonly ELASTIC_PASSWORD: string;
private readonly FLUENTBIT_PASSWORD: string;
private readonly KIBANA_SYSTEM_PASSWORD: string;
constructor(
@Inject(forwardRef(() => ClustersService))
private clustersService: ClustersService,
private configService: ConfigService,
private helmService: HelmService,
) {
this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || '';
this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || '';
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || '';
}
async onModuleInit(): Promise<void> {
await this.ensureLocalElasticsearchAccess({ waitForCluster: true });
if (this.shouldAutoPortForward()) {
this.healthCheckTimer = setInterval(() => {
void this.periodicElasticsearchHealthCheck();
}, 30_000);
}
}
onModuleDestroy(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopDevPortForward();
}
private isLoopbackHost(host: string): boolean {
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
}
private isRunningInKubernetes(): boolean {
return Boolean(process.env.KUBERNETES_SERVICE_HOST);
}
/** Hostnames only resolvable from inside the workload cluster (not from a laptop). */
private isClusterInternalHost(host: string): boolean {
const h = host.toLowerCase();
return h.includes('svc.cluster.local') || h.includes('.cluster.') || h === 'elasticsearch' || h === 'kibana';
}
private configuredElasticsearchHost(): string {
return this.configService.get<string>('elasticsearch.host') || `${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`;
}
/** HTTP target: loopback when we tunnel; cluster DNS when the API pod runs in-cluster. */
private effectiveElasticsearchHost(): string {
const configured = this.configuredElasticsearchHost();
if (this.shouldAutoPortForward() && this.isClusterInternalHost(configured)) {
return '127.0.0.1';
}
return configured;
}
private shouldAutoPortForward(): boolean {
if (this.configService.get<string>('elasticsearch.autoPortForward') === 'false') {
return false;
}
if (process.env.ELASTICSEARCH_AUTO_PORT_FORWARD === 'false') {
return false;
}
if (this.isRunningInKubernetes()) {
return false;
}
const nodeEnv = process.env.NODE_ENV || 'development';
if (nodeEnv === 'production') {
return false;
}
const host = this.configuredElasticsearchHost();
return this.isLoopbackHost(host) || this.isClusterInternalHost(host);
}
private stopDevPortForward(): void {
if (!this.portForwardChild) {
return;
}
const startedByUs = this.portForwardStartedByUs;
const child = this.portForwardChild;
const kubeconfigFile = this.portForwardKubeconfigFile;
this.portForwardChild = null;
this.portForwardStartedByUs = false;
this.portForwardKubeconfigFile = null;
this.portForwardClusterId = null;
child.kill('SIGTERM');
if (kubeconfigFile) {
this.helmService.removeTempFile(kubeconfigFile);
}
if (startedByUs) {
this.logger.log('Stopped Elasticsearch kubectl port-forward');
}
}
private schedulePortForwardReconnect(reason: string): void {
if (!this.shouldAutoPortForward()) {
return;
}
if (this.reconnectTimer) {
return;
}
const delay = Math.min(60_000, 2_000 * Math.pow(2, this.reconnectAttempt));
this.reconnectAttempt += 1;
this.logger.warn(`Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.ensureLocalElasticsearchAccess().then((ok) => {
if (ok) {
this.reconnectAttempt = 0;
}
});
}, delay);
}
private async periodicElasticsearchHealthCheck(): Promise<void> {
if (!this.shouldAutoPortForward()) {
return;
}
const deployed = await this.isDeployed();
if (!deployed) {
return;
}
if (await this.probeElasticsearch()) {
this.reconnectAttempt = 0;
return;
}
this.logger.debug('Elasticsearch health check failed; restoring tunnel…');
await this.ensureLocalElasticsearchAccess();
}
private async waitForLoggingStack(maxWaitMs = 120_000): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
if (await this.isDeployed()) {
return true;
}
await new Promise((r) => setTimeout(r, 5_000));
}
return false;
}
private async probeElasticsearch(timeoutMs = 3000): Promise<boolean> {
try {
const conn = this.getConnectionInfo();
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
const response = await fetch(`http://${conn.host}:${conn.port}/_cluster/health`, {
headers: { Authorization: `Basic ${auth}` },
signal: AbortSignal.timeout(timeoutMs),
});
return response.ok;
} catch {
return false;
}
}
private async waitForElasticsearch(maxWaitMs = 15_000): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
if (await this.probeElasticsearch(2000)) {
return true;
}
await new Promise((r) => setTimeout(r, 400));
}
return false;
}
private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> {
const targetClusterId = clusterId || null;
if (this.portForwardChild && this.portForwardClusterId === targetClusterId) {
return;
}
if (this.portForwardChild) {
this.stopDevPortForward();
await new Promise((r) => setTimeout(r, 300));
}
const { cluster } = await this.getK8sClients(clusterId);
const kubeconfigFile = await this.helmService.createKubeconfigFile(cluster.kubeconfig);
this.portForwardKubeconfigFile = kubeconfigFile;
this.portForwardClusterId = targetClusterId;
const args = ['--kubeconfig', kubeconfigFile, 'port-forward', '-n', this.ES_NAMESPACE, `svc/${this.ES_NAME}`, `${localPort}:9200`];
this.logger.log(`Starting kubectl port-forward to Elasticsearch on cluster "${cluster.name}" (local log search)`);
const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
this.portForwardChild = child;
this.portForwardStartedByUs = true;
child.on('exit', (code, signal) => {
const wasOurs = this.portForwardChild === child;
if (wasOurs) {
this.portForwardChild = null;
this.portForwardStartedByUs = false;
}
if (wasOurs) {
const reason = code !== 0 && code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : 'connection closed';
this.schedulePortForwardReconnect(reason);
}
});
child.stderr?.on('data', (chunk: Buffer) => {
const line = chunk.toString().trim();
if (line && !line.includes('Handling connection')) {
this.logger.debug(`kubectl port-forward: ${line}`);
}
});
}
/**
* When the API runs on the host with ELASTICSEARCH_HOST=127.0.0.1, open a tunnel to the cluster.
* Safe to call repeatedly (e.g. after cluster/API restart or port-forward drop).
*/
private async ensureLocalElasticsearchAccess(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<boolean> {
if (this.ensureInFlight) {
await this.ensureInFlight;
return this.probeElasticsearch();
}
this.ensureInFlight = this.ensureLocalElasticsearchAccessImpl(options);
try {
await this.ensureInFlight;
return this.probeElasticsearch();
} finally {
this.ensureInFlight = null;
}
}
private async ensureLocalElasticsearchAccessImpl(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<void> {
if (!this.shouldAutoPortForward()) {
return;
}
if (await this.probeElasticsearch()) {
this.reconnectAttempt = 0;
return;
}
const clusterId = options?.clusterId;
let deployed = await this.isDeployed(clusterId);
if (!deployed && options?.waitForCluster) {
this.logger.log('Waiting for logging stack after cluster reconnect…');
deployed = await this.waitForLoggingStack();
}
if (!deployed) {
return;
}
const port = this.configService.get<number>('elasticsearch.port') || 9200;
// Stale tunnel after sleep/reboot: port may be bound but ES unreachable
if (this.portForwardChild) {
this.stopDevPortForward();
await new Promise((r) => setTimeout(r, 300));
}
await this.startDevPortForward(port, clusterId);
const ready = await this.waitForElasticsearch(90_000);
if (ready) {
this.reconnectAttempt = 0;
this.logger.log(`Elasticsearch reachable at 127.0.0.1:${port}`);
} else {
this.stopDevPortForward();
this.logger.warn(`Could not reach Elasticsearch on 127.0.0.1:${port}. Will retry. Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${port}:9200`);
this.schedulePortForwardReconnect('probe timeout');
}
}
private localElasticsearchHint(): string {
const conn = this.getConnectionInfo();
const configured = this.configuredElasticsearchHost();
if (this.isLoopbackHost(conn.host)) {
return (
`Ensure port ${conn.port} is forwarded to the cluster (the API auto-starts kubectl port-forward in development). ` +
`Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200`
);
}
if (this.isClusterInternalHost(configured)) {
if (this.isRunningInKubernetes()) {
return 'Ensure the logging stack is deployed on the same cluster as this API pod ' + `(Helm release ${LOGGING_HELM_RELEASE} in namespace ${LOGGING_HELM_NAMESPACE}).`;
}
return (
'Run the API inside the cluster, or restart the API locally so it can auto port-forward Elasticsearch ' +
`(kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200 using the cluster kubeconfig).`
);
}
return `Ensure Elasticsearch is listening on ${conn.host}:${conn.port}.`;
}
private async getK8sClients(clusterId?: string) {
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
return {
coreApi: kc.makeApiClient(k8s.CoreV1Api),
appsApi: kc.makeApiClient(k8s.AppsV1Api),
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
cluster,
};
}
/**
* Check if central Elasticsearch is deployed and healthy in a cluster.
*/
async isDeployed(clusterId?: string): Promise<boolean> {
const state = await this.getDeployState(clusterId);
return state.status === 'ready';
}
/**
* Detailed logging stack state (Helm release + pod readiness).
*/
async getDeployState(clusterId?: string): Promise<LoggingDeployState> {
const { cluster } = await this.getK8sClients(clusterId);
const helmStatus = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
let hasEsWorkload = false;
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
await appsApi.readNamespacedStatefulSet({
name: this.ES_NAME,
namespace: this.ES_NAMESPACE,
});
hasEsWorkload = true;
} catch {
hasEsWorkload = false;
}
if (!helmStatus && !hasEsWorkload) {
return { status: 'not_installed', helmReleaseStatus: null, health: null };
}
const health = await this.getHealth(clusterId);
const helmReleaseStatus = helmStatus?.status || null;
if (health?.status === 'green') {
return { status: 'ready', helmReleaseStatus, health };
}
if (helmReleaseStatus === 'failed' || helmReleaseStatus === 'pending-install') {
return { status: 'failed', helmReleaseStatus, health };
}
if (hasEsWorkload || helmReleaseStatus === 'deployed') {
return { status: 'installing', helmReleaseStatus, health };
}
return { status: 'installing', helmReleaseStatus, health };
}
private resolveLoggingStorageClass(): string | undefined {
const storageClass = this.configService.get<string>('platform.storageClass');
return storageClass?.trim() || undefined;
}
/**
* Ensure platform StorageClass exists before Helm creates PVCs (same as app deploy chart).
*/
private async ensureClusterStorageClass(kubeconfig: string): Promise<void> {
const storageClass = this.resolveLoggingStorageClass();
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
if (!storageClass || !createStorageClass) {
return;
}
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
const provisioner = this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
try {
await storageApi.readStorageClass({ name: storageClass });
return;
} catch (err: any) {
if (err.code !== 404 && err.body?.code !== 404) {
throw err;
}
}
await storageApi.createStorageClass({
body: {
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
metadata: { name: storageClass },
provisioner,
allowVolumeExpansion: true,
reclaimPolicy: 'Delete',
volumeBindingMode: 'WaitForFirstConsumer',
},
});
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
}
private buildLoggingHelmValues(): {
elasticPassword: string;
fluentbitPassword: string;
kibanaSystemPassword: string;
storageClass?: string;
images: {
elasticsearch?: string;
kibana?: string;
busybox?: string;
curl?: string;
};
} {
const storageClass = this.resolveLoggingStorageClass();
return {
elasticPassword: this.ELASTIC_PASSWORD,
fluentbitPassword: this.FLUENTBIT_PASSWORD,
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
...(storageClass ? { storageClass } : {}),
images: {
elasticsearch: this.configService.get<string>('elasticsearch.images.elasticsearch'),
kibana: this.configService.get<string>('elasticsearch.images.kibana'),
busybox: this.configService.get<string>('elasticsearch.images.busybox'),
curl: this.configService.get<string>('elasticsearch.images.curl'),
},
};
}
/**
* Get Elasticsearch health and status
*/
async getHealth(clusterId?: string): Promise<{
status: string;
clusterName: string;
numberOfNodes: number;
activePrimaryShards: number;
activeShards: number;
kibanaReady: boolean;
} | null> {
const { coreApi } = await this.getK8sClients(clusterId);
try {
// Check ES pods
const esPods = await coreApi.listNamespacedPod({
namespace: this.ES_NAMESPACE,
labelSelector: 'app=elasticsearch',
});
const kibanaPods = await coreApi.listNamespacedPod({
namespace: this.ES_NAMESPACE,
labelSelector: 'app=kibana',
});
if (esPods.items.length === 0) {
return null;
}
const esPod = esPods.items[0];
const isEsReady = esPod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True');
const kibanaPod = kibanaPods.items[0];
const isKibanaReady = kibanaPod?.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') || false;
return {
status: isEsReady ? 'green' : 'yellow',
clusterName: 'cloudhost-logs',
numberOfNodes: 1,
activePrimaryShards: isEsReady ? 1 : 0,
activeShards: isEsReady ? 1 : 0,
kibanaReady: isKibanaReady,
};
} catch {
return null;
}
}
/**
* Deploy or repair central Elasticsearch + Kibana stack via Helm.
* Always runs upgrade --install; does not wait for pods (avoids timeout errors on slow image pulls).
*/
async deploy(clusterId?: string): Promise<{ esPassword: string; kibanaUrl: string; deploying: boolean }> {
const { cluster } = await this.getK8sClients(clusterId);
await this.ensureClusterStorageClass(cluster.kubeconfig);
const helmValues = this.buildLoggingHelmValues();
try {
await this.helmService.installLoggingStack(cluster.kubeconfig, helmValues, {
wait: false,
timeout: '10m',
});
} catch (error: any) {
const release = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
if (!release) {
throw error;
}
this.logger.warn(`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`);
}
this.logger.log(`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`);
const state = await this.getDeployState(clusterId);
return {
esPassword: this.ELASTIC_PASSWORD,
kibanaUrl: `http://kibana.${this.ES_NAMESPACE}.svc.cluster.local:5601`,
deploying: state.status !== 'ready',
};
}
/**
* Undeploy Elasticsearch stack (Helm release; PVC retained by chart policy).
*/
async undeploy(clusterId?: string): Promise<void> {
const { cluster } = await this.getK8sClients(clusterId);
try {
await this.helmService.uninstall(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
this.logger.log('Elasticsearch stack undeployed via Helm (PVC preserved)');
} catch (e: any) {
const msg = e?.message || String(e);
if (msg.includes('not found')) {
this.logger.log('Logging Helm release not found — nothing to undeploy');
return;
}
throw e;
}
}
/**
* Get Elasticsearch connection info for apps
*/
getConnectionInfo(): {
host: string;
port: number;
username: string;
password: string;
} {
return {
host: this.effectiveElasticsearchHost(),
port: this.configService.get<number>('elasticsearch.port') || 9200,
username: 'elastic',
password: this.ELASTIC_PASSWORD,
};
}
/**
* Get Fluent Bit credentials for log shipping
*/
getFluentBitCredentials(): ElasticsearchCredentials {
return {
username: 'elastic',
password: this.ELASTIC_PASSWORD,
};
}
/**
* Generate a unique user for log access
*/
generateUserCredentials(userId: string): ElasticsearchCredentials {
const hash = crypto.createHash('sha256').update(`${userId}-${this.ELASTIC_PASSWORD}`).digest('hex');
return {
username: userNamespace(userId),
password: hash.substring(0, 24),
};
}
/**
* Get logs URL for an application (Kibana discover with filter)
*/
getLogsUrl(appName: string, namespace: string, kibanaHost: string): string {
const filter = encodeURIComponent(`app:${appName} AND namespace:${namespace}`);
return `https://${kibanaHost}/app/discover#/?_g=(filters:!(),query:(language:kuery,query:'${filter}'))`;
}
/**
* Get index pattern for a user's applications
*/
getIndexPattern(userId: string): string {
return `logs-${userNamespace(userId)}-*`;
}
/**
* Build must clauses for user log isolation (new + legacy fields).
*/
buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] {
// Full-UUID namespace — a truncated prefix would match other tenants'
// namespaces and leak their logs.
const namespace = userNamespace(userId);
const must: any[] = [
{
bool: {
should: [{ term: { ownerId: userId } }, { term: { 'ownerId.keyword': userId } }, { term: { namespace } }, { term: { 'namespace.keyword': namespace } }],
minimum_should_match: 1,
},
},
];
if (filters.applicationId) {
must.push({
bool: {
should: [{ term: { applicationId: filters.applicationId } }, { term: { 'applicationId.keyword': filters.applicationId } }],
minimum_should_match: 1,
},
});
}
if (filters.applicationName) {
must.push({
bool: {
should: [
{ term: { applicationName: filters.applicationName } },
{ term: { 'applicationName.keyword': filters.applicationName } },
{ term: { app: filters.applicationName } },
{ term: { 'app.keyword': filters.applicationName } },
],
minimum_should_match: 1,
},
});
}
if (filters.workload) {
must.push({
bool: {
should: [{ term: { workload: filters.workload } }, { term: { 'workload.keyword': filters.workload } }],
minimum_should_match: 1,
},
});
}
if (filters.level) {
must.push({
bool: {
should: [{ term: { level: filters.level.toLowerCase() } }, { term: { 'level.keyword': filters.level.toLowerCase() } }],
minimum_should_match: 1,
},
});
}
if (filters.from || filters.to) {
const rangeFilter: any = { range: { '@timestamp': {} } };
if (filters.from) rangeFilter.range['@timestamp'].gte = filters.from;
if (filters.to) rangeFilter.range['@timestamp'].lte = filters.to;
must.push(rangeFilter);
}
if (filters.search) {
must.push({
multi_match: {
query: filters.search,
fields: ['message', 'log', 'msg', 'error.message'],
type: 'phrase_prefix',
},
});
}
return must;
}
getUserLogsQuery(userId: string): { query: { bool: { must: any[] } } } {
return {
query: {
bool: {
must: this.buildUserLogMustClauses(userId),
},
},
};
}
getUserIndexPattern(userId: string): string {
return `logs-${userNamespace(userId)}-*`;
}
private elasticsearchFetch(url: string, auth: string, body: unknown): Promise<Response> {
return fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(15_000),
});
}
private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> {
const deployed = await this.isDeployed(clusterId);
if (!deployed) {
throw new ServiceUnavailableException('Central logging is not configured. Ask an administrator to deploy Elasticsearch.');
}
if (this.shouldAutoPortForward()) {
await this.ensureLocalElasticsearchAccess({ clusterId });
}
const conn = this.getConnectionInfo();
const url = `http://${conn.host}:${conn.port}${path}`;
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
let response: Response | undefined;
try {
response = await this.elasticsearchFetch(url, auth, body);
} catch (err: any) {
if (this.shouldAutoPortForward()) {
await this.ensureLocalElasticsearchAccess({ clusterId });
try {
response = await this.elasticsearchFetch(url, auth, body);
} catch {
// retry failed
}
}
if (!response) {
this.logger.warn(`Elasticsearch unreachable at ${conn.host}:${conn.port}: ${err?.message || err}`);
throw new ServiceUnavailableException(`Cannot reach Elasticsearch. ${this.localElasticsearchHint()}`);
}
}
if (!response.ok) {
const text = await response.text();
this.logger.warn(`Elasticsearch request failed: ${response.status} ${text}`);
throw new ServiceUnavailableException('Failed to query log storage');
}
return response.json();
}
private normalizeHit(hit: any): NormalizedLogEntry {
const src = hit._source || {};
const message = src.message || src.log || src.msg || (typeof src.error === 'string' ? src.error : src.error?.message) || '';
return {
id: hit._id || '',
timestamp: src['@timestamp'] || src.timestamp || new Date().toISOString(),
level: (src.level || 'info').toString().toLowerCase(),
message: typeof message === 'string' ? message : JSON.stringify(message),
applicationId: src.applicationId,
applicationName: src.applicationName || src.app,
workload: src.workload || 'app',
namespace: src.namespace,
pod: src.kubernetes?.pod_name || src.pod_name,
};
}
async searchLogs(userId: string, filters: LogSearchFilters, clusterId?: string): Promise<LogSearchResult> {
const page = filters.page || 1;
const limit = Math.min(filters.limit || 100, 1000);
const from = (page - 1) * limit;
const body = {
query: { bool: { must: this.buildUserLogMustClauses(userId, filters) } },
sort: [{ '@timestamp': 'desc' }],
from,
size: limit,
};
const index = this.getUserIndexPattern(userId);
const result = await this.esRequest(`/${index}/_search`, body, clusterId);
const hits = result.hits?.hits || [];
return {
hits: hits.map((h: any) => this.normalizeHit(h)),
total: result.hits?.total?.value ?? result.hits?.total ?? hits.length,
page,
limit,
};
}
async searchLogStats(
userId: string,
filters: {
applicationId?: string;
applicationName?: string;
workload?: string;
period?: string;
},
clusterId?: string,
): Promise<LogStatsResult> {
const periodMap: Record<string, string> = {
'1h': 'now-1h',
'6h': 'now-6h',
'24h': 'now-24h',
'7d': 'now-7d',
};
const period = filters.period || '24h';
const timeRange = periodMap[period] || 'now-24h';
const must = this.buildUserLogMustClauses(userId, {
applicationId: filters.applicationId,
applicationName: filters.applicationName,
workload: filters.workload,
});
must.push({ range: { '@timestamp': { gte: timeRange } } });
const body = {
query: { bool: { must } },
size: 0,
aggs: {
by_level: {
terms: { field: 'level.keyword', size: 10, missing: 'unknown' },
},
by_workload: {
terms: { field: 'workload.keyword', size: 10, missing: 'app' },
},
error_count: { filter: { term: { 'level.keyword': 'error' } } },
warn_count: { filter: { term: { 'level.keyword': 'warn' } } },
},
};
const index = this.getUserIndexPattern(userId);
const result = await this.esRequest(`/${index}/_search`, body, clusterId);
const byLevel: Record<string, number> = {};
for (const bucket of result.aggregations?.by_level?.buckets || []) {
byLevel[bucket.key] = bucket.doc_count;
}
const byWorkload: Record<string, number> = {};
for (const bucket of result.aggregations?.by_workload?.buckets || []) {
byWorkload[bucket.key] = bucket.doc_count;
}
const errors = result.aggregations?.error_count?.doc_count || 0;
const warnings = result.aggregations?.warn_count?.doc_count || 0;
const total = Object.values(byLevel).reduce((a, b) => a + b, 0);
return { total, errors, warnings, byLevel, byWorkload, period };
}
async searchRecentErrors(
userId: string,
filters: {
applicationId?: string;
applicationName?: string;
workload?: string;
hours?: number;
limit?: number;
},
clusterId?: string,
): Promise<NormalizedLogEntry[]> {
const hours = filters.hours || 24;
const limit = Math.min(filters.limit || 50, 500);
const must = this.buildUserLogMustClauses(userId, {
applicationId: filters.applicationId,
applicationName: filters.applicationName,
workload: filters.workload,
level: 'error',
});
must.push({ range: { '@timestamp': { gte: `now-${hours}h` } } });
const body = {
query: { bool: { must } },
sort: [{ '@timestamp': 'desc' }],
size: limit,
};
const index = this.getUserIndexPattern(userId);
const result = await this.esRequest(`/${index}/_search`, body, clusterId);
return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h));
}
async getLoggingStatus(clusterId?: string): Promise<{
available: boolean;
deployed: boolean;
recovering?: boolean;
message?: string;
}> {
let deployed = await this.isDeployed(clusterId);
if (!deployed && this.shouldAutoPortForward()) {
deployed = await this.waitForLoggingStack(8_000);
}
if (!deployed) {
return {
available: false,
deployed: false,
message: 'Central logging is not deployed. Ask an administrator to deploy Elasticsearch.',
};
}
if (this.shouldAutoPortForward() && !(await this.probeElasticsearch())) {
void this.ensureLocalElasticsearchAccess({ clusterId });
}
if (await this.probeElasticsearch()) {
return { available: true, deployed: true };
}
return {
available: false,
deployed: true,
recovering: this.shouldAutoPortForward(),
message: this.shouldAutoPortForward()
? 'Reconnecting to Elasticsearch after cluster or API restart. This usually takes under a minute.'
: `Elasticsearch is running in the cluster, but this backend cannot reach it. ${this.localElasticsearchHint()}`,
};
}
}