Add unified logs platform with Helm-managed central Elasticsearch.

Deploy cloudhost-logging on cluster registration, ship app and optional service logs to ES with owner isolation, and fix Kibana 8.12 auth via kibana_system.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 15:56:33 +03:30
parent 2303985d0c
commit 35dd771f63
31 changed files with 1657 additions and 938 deletions
+315 -315
View File
@@ -1,21 +1,53 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import * as crypto from 'crypto';
import { ClustersService } from '../clusters/clusters.service';
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
interface ElasticsearchCredentials {
username: string;
password: string;
}
interface LogEntry {
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;
app: string;
namespace: string;
[key: string]: any;
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;
}
/**
@@ -33,13 +65,17 @@ export class ElasticsearchService {
// 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') || 'CloudHost2024!Secure';
this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer';
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System';
}
private async getK8sClients(clusterId?: string) {
@@ -133,25 +169,20 @@ export class ElasticsearchService {
}
/**
* Deploy central Elasticsearch + Kibana stack
* This should be called once per cluster by admin
* Deploy central Elasticsearch + Kibana stack via Helm.
*/
async deploy(clusterId?: string): Promise<{ esPassword: string; kibanaUrl: string }> {
const { coreApi, appsApi } = await this.getK8sClients(clusterId);
const { cluster } = await this.getK8sClients(clusterId);
// 1. Create logging namespace
await this.ensureNamespace(coreApi);
await this.helmService.installLoggingStack(cluster.kubeconfig, {
elasticPassword: this.ELASTIC_PASSWORD,
fluentbitPassword: this.FLUENTBIT_PASSWORD,
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
});
// 2. Create credentials secret
await this.createCredentialsSecret(coreApi);
// 3. Deploy Elasticsearch
await this.deployElasticsearch(coreApi, appsApi);
// 4. Deploy Kibana
await this.deployKibana(coreApi, appsApi);
this.logger.log('Central Elasticsearch stack deployed successfully');
this.logger.log(
`Central logging stack deployed via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`,
);
return {
esPassword: this.ELASTIC_PASSWORD,
@@ -160,300 +191,22 @@ export class ElasticsearchService {
}
/**
* Undeploy Elasticsearch stack
* Undeploy Elasticsearch stack (Helm release; PVC retained by chart policy).
*/
async undeploy(clusterId?: string): Promise<void> {
const { coreApi, appsApi } = await this.getK8sClients(clusterId);
const { cluster } = await this.getK8sClients(clusterId);
try {
// Delete Kibana
await appsApi.deleteNamespacedDeployment(this.KIBANA_NAME, this.ES_NAMESPACE);
await coreApi.deleteNamespacedService(this.KIBANA_NAME, this.ES_NAMESPACE);
this.logger.log('Kibana deleted');
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) {
if (e?.response?.statusCode !== 404) {
this.logger.warn(`Failed to delete Kibana: ${e.message}`);
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;
}
try {
// Delete Elasticsearch
await appsApi.deleteNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE);
await coreApi.deleteNamespacedService(this.ES_NAME, this.ES_NAMESPACE);
this.logger.log('Elasticsearch deleted');
} catch (e: any) {
if (e?.response?.statusCode !== 404) {
this.logger.warn(`Failed to delete Elasticsearch: ${e.message}`);
}
}
// Note: PVC is kept intentionally to preserve data
this.logger.log('Elasticsearch stack undeployed (PVC preserved)');
}
private async ensureNamespace(coreApi: k8s.CoreV1Api): Promise<void> {
try {
await coreApi.readNamespace(this.ES_NAMESPACE);
} catch {
await coreApi.createNamespace({
metadata: {
name: this.ES_NAMESPACE,
labels: { 'app.kubernetes.io/managed-by': 'cloudhost' },
},
});
this.logger.log(`Created namespace: ${this.ES_NAMESPACE}`);
}
}
private async createCredentialsSecret(coreApi: k8s.CoreV1Api): Promise<void> {
const secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: 'elasticsearch-credentials',
namespace: this.ES_NAMESPACE,
},
type: 'Opaque',
data: {
ELASTIC_PASSWORD: Buffer.from(this.ELASTIC_PASSWORD).toString('base64'),
FLUENTBIT_PASSWORD: Buffer.from(this.FLUENTBIT_PASSWORD).toString('base64'),
},
};
try {
await coreApi.replaceNamespacedSecret('elasticsearch-credentials', this.ES_NAMESPACE, secret);
} catch {
await coreApi.createNamespacedSecret(this.ES_NAMESPACE, secret);
}
}
private async deployElasticsearch(
coreApi: k8s.CoreV1Api,
appsApi: k8s.AppsV1Api,
): Promise<void> {
// PVC for Elasticsearch data
const pvc: k8s.V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: `${this.ES_NAME}-data`, namespace: this.ES_NAMESPACE },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '50Gi' } },
},
};
try {
await coreApi.readNamespacedPersistentVolumeClaim(pvc.metadata!.name!, this.ES_NAMESPACE);
} catch {
await coreApi.createNamespacedPersistentVolumeClaim(this.ES_NAMESPACE, pvc);
}
// Base64 encoded auth header
const authHeader = Buffer.from(`elastic:${this.ELASTIC_PASSWORD}`).toString('base64');
// StatefulSet for Elasticsearch
const statefulSet: k8s.V1StatefulSet = {
apiVersion: 'apps/v1',
kind: 'StatefulSet',
metadata: {
name: this.ES_NAME,
namespace: this.ES_NAMESPACE,
labels: { app: this.ES_NAME },
},
spec: {
serviceName: this.ES_NAME,
replicas: 1,
selector: { matchLabels: { app: this.ES_NAME } },
template: {
metadata: { labels: { app: this.ES_NAME } },
spec: {
securityContext: { fsGroup: 1000 },
initContainers: [
{
name: 'fix-permissions',
image: 'busybox:1.36',
command: ['sh', '-c', 'chown -R 1000:1000 /usr/share/elasticsearch/data'],
securityContext: { runAsUser: 0, privileged: true },
volumeMounts: [{ name: 'es-data', mountPath: '/usr/share/elasticsearch/data' }],
},
{
name: 'increase-vm-max-map',
image: 'busybox:1.36',
command: ['sysctl', '-w', 'vm.max_map_count=262144'],
securityContext: { privileged: true },
},
],
containers: [
{
name: 'elasticsearch',
image: 'docker.elastic.co/elasticsearch/elasticsearch:8.12.0',
ports: [
{ containerPort: 9200, name: 'http' },
{ containerPort: 9300, name: 'transport' },
],
env: [
{ name: 'discovery.type', value: 'single-node' },
{ name: 'xpack.security.enabled', value: 'true' },
{ name: 'xpack.security.http.ssl.enabled', value: 'false' },
{ name: 'xpack.security.transport.ssl.enabled', value: 'false' },
{
name: 'ELASTIC_PASSWORD',
valueFrom: {
secretKeyRef: { name: 'elasticsearch-credentials', key: 'ELASTIC_PASSWORD' },
},
},
{ name: 'ES_JAVA_OPTS', value: '-Xms1g -Xmx1g' },
{ name: 'cluster.name', value: 'cloudhost-logs' },
{ name: 'bootstrap.memory_lock', value: 'false' },
],
resources: {
requests: { cpu: '500m', memory: '2Gi' },
limits: { cpu: '2000m', memory: '4Gi' },
},
volumeMounts: [{ name: 'es-data', mountPath: '/usr/share/elasticsearch/data' }],
readinessProbe: {
httpGet: {
path: '/_cluster/health?local=true',
port: 9200 as any,
httpHeaders: [{ name: 'Authorization', value: `Basic ${authHeader}` }],
},
initialDelaySeconds: 30,
periodSeconds: 10,
timeoutSeconds: 5,
},
livenessProbe: {
httpGet: {
path: '/_cluster/health?local=true',
port: 9200 as any,
httpHeaders: [{ name: 'Authorization', value: `Basic ${authHeader}` }],
},
initialDelaySeconds: 60,
periodSeconds: 30,
timeoutSeconds: 10,
},
},
],
volumes: [
{
name: 'es-data',
persistentVolumeClaim: { claimName: `${this.ES_NAME}-data` },
},
],
},
},
},
};
try {
await appsApi.replaceNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE, statefulSet);
} catch {
await appsApi.createNamespacedStatefulSet(this.ES_NAMESPACE, statefulSet);
}
// Service for Elasticsearch
const service: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: this.ES_NAME, namespace: this.ES_NAMESPACE },
spec: {
selector: { app: this.ES_NAME },
ports: [
{ port: 9200, targetPort: 9200 as any, name: 'http' },
{ port: 9300, targetPort: 9300 as any, name: 'transport' },
],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService(this.ES_NAME, this.ES_NAMESPACE, service);
} catch {
await coreApi.createNamespacedService(this.ES_NAMESPACE, service);
}
this.logger.log('Elasticsearch deployed');
}
private async deployKibana(
coreApi: k8s.CoreV1Api,
appsApi: k8s.AppsV1Api,
): Promise<void> {
const deployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: this.KIBANA_NAME,
namespace: this.ES_NAMESPACE,
labels: { app: this.KIBANA_NAME },
},
spec: {
replicas: 1,
selector: { matchLabels: { app: this.KIBANA_NAME } },
template: {
metadata: { labels: { app: this.KIBANA_NAME } },
spec: {
containers: [
{
name: 'kibana',
image: 'docker.elastic.co/kibana/kibana:8.12.0',
ports: [{ containerPort: 5601 }],
env: [
{ name: 'ELASTICSEARCH_HOSTS', value: `http://${this.ES_NAME}:9200` },
{ name: 'ELASTICSEARCH_USERNAME', value: 'elastic' },
{
name: 'ELASTICSEARCH_PASSWORD',
valueFrom: {
secretKeyRef: { name: 'elasticsearch-credentials', key: 'ELASTIC_PASSWORD' },
},
},
{ name: 'SERVER_NAME', value: 'kibana' },
{ name: 'XPACK_SECURITY_ENABLED', value: 'true' },
],
resources: {
requests: { cpu: '200m', memory: '512Mi' },
limits: { cpu: '1000m', memory: '1Gi' },
},
readinessProbe: {
httpGet: { path: '/api/status', port: 5601 as any },
initialDelaySeconds: 30,
periodSeconds: 10,
},
livenessProbe: {
httpGet: { path: '/api/status', port: 5601 as any },
initialDelaySeconds: 60,
periodSeconds: 30,
},
},
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment(this.KIBANA_NAME, this.ES_NAMESPACE, deployment);
} catch {
await appsApi.createNamespacedDeployment(this.ES_NAMESPACE, deployment);
}
const service: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: this.KIBANA_NAME, namespace: this.ES_NAMESPACE },
spec: {
selector: { app: this.KIBANA_NAME },
ports: [{ port: 5601, targetPort: 5601 as any }],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService(this.KIBANA_NAME, this.ES_NAMESPACE, service);
} catch {
await coreApi.createNamespacedService(this.ES_NAMESPACE, service);
}
this.logger.log('Kibana deployed');
}
/**
@@ -506,20 +259,267 @@ export class ElasticsearchService {
}
/**
* Build Elasticsearch query to filter logs by user/owner
* Users can only see logs from their own applications
* Build must clauses for user log isolation (new + legacy fields).
*/
getUserLogsQuery(userId: string): { query: { bool: { must: any } } } {
buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] {
const userPrefix = userId.split('-')[0];
const namespace = `user-${userPrefix}`;
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: {
term: {
'kubernetes.labels.owner': userId,
},
},
must: this.buildUserLogMustClauses(userId),
},
},
};
}
getUserIndexPattern(userId: string): string {
return `logs-user-${userId.split('-')[0]}-*`;
}
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.',
);
}
const conn = this.getConnectionInfo();
const url = `http://${conn.host}:${conn.port}${path}`;
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
const response = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
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 }> {
const deployed = await this.isDeployed(clusterId);
return { available: deployed, deployed };
}
}