Fix backend Elasticsearch connectivity for local dev and app logs.

Auto port-forward using the registered cluster kubeconfig, default to loopback outside Kubernetes, route log APIs by app cluster, and document platform env settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-27 11:51:51 +03:30
parent 3435eff256
commit 44ad1d63a0
7 changed files with 167 additions and 53 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ REGISTRY_PASSWORD=registry_secret
# Central logging (Elasticsearch + Kibana) # Central logging (Elasticsearch + Kibana)
# In-cluster backend: leave ELASTICSEARCH_HOST unset (uses elasticsearch.logging.svc.cluster.local). # In-cluster backend: leave ELASTICSEARCH_HOST unset (uses elasticsearch.logging.svc.cluster.local).
# Local backend (npm run dev): API auto-runs kubectl port-forward when host is loopback # Local backend (npm run dev): defaults to 127.0.0.1 and auto-runs kubectl port-forward to the default cluster.
# ELASTICSEARCH_HOST=127.0.0.1 # ELASTICSEARCH_HOST=127.0.0.1
# ELASTICSEARCH_PORT=9200 # ELASTICSEARCH_PORT=9200
# ELASTICSEARCH_AUTO_PORT_FORWARD=false # ELASTICSEARCH_AUTO_PORT_FORWARD=false
@@ -60,6 +60,8 @@ backend:
PLATFORM_CREATE_STORAGE_CLASS: "true" PLATFORM_CREATE_STORAGE_CLASS: "true"
PLATFORM_STORAGE_CLASS: cloudhost-expandable PLATFORM_STORAGE_CLASS: cloudhost-expandable
PLATFORM_STORAGE_PROVISIONER: rancher.io/local-path PLATFORM_STORAGE_PROVISIONER: rancher.io/local-path
ELASTICSEARCH_HOST: elasticsearch.logging.svc.cluster.local
ELASTICSEARCH_AUTO_PORT_FORWARD: "false"
frontend: frontend:
enabled: true enabled: true
+6 -2
View File
@@ -36,8 +36,12 @@ export default () => ({
}, },
elasticsearch: { elasticsearch: {
/** API host for log search. Use cluster DNS in-cluster; 127.0.0.1 + port-forward when backend runs locally. */ /** API host for log search. In-cluster: cluster DNS. Local dev: loopback + auto port-forward. */
host: process.env.ELASTICSEARCH_HOST || 'elasticsearch.logging.svc.cluster.local', host:
process.env.ELASTICSEARCH_HOST ||
(process.env.KUBERNETES_SERVICE_HOST
? 'elasticsearch.logging.svc.cluster.local'
: '127.0.0.1'),
port: parseInt(process.env.ELASTICSEARCH_PORT || '9200', 10), port: parseInt(process.env.ELASTICSEARCH_PORT || '9200', 10),
/** In development with loopback host, start kubectl port-forward on API boot (set false to manage manually). */ /** In development with loopback host, start kubectl port-forward on API boot (set false to manage manually). */
autoPortForward: process.env.ELASTICSEARCH_AUTO_PORT_FORWARD ?? 'true', autoPortForward: process.env.ELASTICSEARCH_AUTO_PORT_FORWARD ?? 'true',
+87 -15
View File
@@ -87,6 +87,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private readonly KIBANA_NAME = 'kibana'; private readonly KIBANA_NAME = 'kibana';
private portForwardChild: ChildProcess | null = null; private portForwardChild: ChildProcess | null = null;
private portForwardStartedByUs = false; private portForwardStartedByUs = false;
private portForwardKubeconfigFile: string | null = null;
private portForwardClusterId: string | null = null;
private ensureInFlight: Promise<void> | null = null; private ensureInFlight: Promise<void> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private healthCheckTimer: ReturnType<typeof setInterval> | null = null; private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
@@ -133,6 +135,37 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
return host === '127.0.0.1' || host === 'localhost' || host === '::1'; 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 { private shouldAutoPortForward(): boolean {
if (this.configService.get<string>('elasticsearch.autoPortForward') === 'false') { if (this.configService.get<string>('elasticsearch.autoPortForward') === 'false') {
return false; return false;
@@ -140,12 +173,15 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
if (process.env.ELASTICSEARCH_AUTO_PORT_FORWARD === 'false') { if (process.env.ELASTICSEARCH_AUTO_PORT_FORWARD === 'false') {
return false; return false;
} }
if (this.isRunningInKubernetes()) {
return false;
}
const nodeEnv = process.env.NODE_ENV || 'development'; const nodeEnv = process.env.NODE_ENV || 'development';
if (nodeEnv === 'production') { if (nodeEnv === 'production') {
return false; return false;
} }
const host = this.configService.get<string>('elasticsearch.host') || ''; const host = this.configuredElasticsearchHost();
return this.isLoopbackHost(host); return this.isLoopbackHost(host) || this.isClusterInternalHost(host);
} }
private stopDevPortForward(): void { private stopDevPortForward(): void {
@@ -154,9 +190,15 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
const startedByUs = this.portForwardStartedByUs; const startedByUs = this.portForwardStartedByUs;
const child = this.portForwardChild; const child = this.portForwardChild;
const kubeconfigFile = this.portForwardKubeconfigFile;
this.portForwardChild = null; this.portForwardChild = null;
this.portForwardStartedByUs = false; this.portForwardStartedByUs = false;
this.portForwardKubeconfigFile = null;
this.portForwardClusterId = null;
child.kill('SIGTERM'); child.kill('SIGTERM');
if (kubeconfigFile) {
this.helmService.removeTempFile(kubeconfigFile);
}
if (startedByUs) { if (startedByUs) {
this.logger.log('Stopped Elasticsearch kubectl port-forward'); this.logger.log('Stopped Elasticsearch kubectl port-forward');
} }
@@ -236,18 +278,36 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
return false; return false;
} }
private startDevPortForward(localPort: number): void { private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> {
if (this.portForwardChild) { const targetClusterId = clusterId || null;
if (
this.portForwardChild &&
this.portForwardClusterId === targetClusterId
) {
return; 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 = [ const args = [
'--kubeconfig',
kubeconfigFile,
'port-forward', 'port-forward',
'-n', '-n',
this.ES_NAMESPACE, this.ES_NAMESPACE,
`svc/${this.ES_NAME}`, `svc/${this.ES_NAME}`,
`${localPort}:9200`, `${localPort}:9200`,
]; ];
this.logger.log(`Starting kubectl ${args.join(' ')} (local log search)`); 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'] }); const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
this.portForwardChild = child; this.portForwardChild = child;
this.portForwardStartedByUs = true; this.portForwardStartedByUs = true;
@@ -281,6 +341,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
*/ */
private async ensureLocalElasticsearchAccess(options?: { private async ensureLocalElasticsearchAccess(options?: {
waitForCluster?: boolean; waitForCluster?: boolean;
clusterId?: string;
}): Promise<boolean> { }): Promise<boolean> {
if (this.ensureInFlight) { if (this.ensureInFlight) {
await this.ensureInFlight; await this.ensureInFlight;
@@ -298,6 +359,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private async ensureLocalElasticsearchAccessImpl(options?: { private async ensureLocalElasticsearchAccessImpl(options?: {
waitForCluster?: boolean; waitForCluster?: boolean;
clusterId?: string;
}): Promise<void> { }): Promise<void> {
if (!this.shouldAutoPortForward()) { if (!this.shouldAutoPortForward()) {
return; return;
@@ -308,7 +370,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
return; return;
} }
let deployed = await this.isDeployed(); const clusterId = options?.clusterId;
let deployed = await this.isDeployed(clusterId);
if (!deployed && options?.waitForCluster) { if (!deployed && options?.waitForCluster) {
this.logger.log('Waiting for logging stack after cluster reconnect…'); this.logger.log('Waiting for logging stack after cluster reconnect…');
deployed = await this.waitForLoggingStack(); deployed = await this.waitForLoggingStack();
@@ -325,7 +388,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
} }
this.startDevPortForward(port); await this.startDevPortForward(port, clusterId);
const ready = await this.waitForElasticsearch(90_000); const ready = await this.waitForElasticsearch(90_000);
if (ready) { if (ready) {
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
@@ -341,16 +404,23 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private localElasticsearchHint(): string { private localElasticsearchHint(): string {
const conn = this.getConnectionInfo(); const conn = this.getConnectionInfo();
const configured = this.configuredElasticsearchHost();
if (this.isLoopbackHost(conn.host)) { if (this.isLoopbackHost(conn.host)) {
return ( return (
`Ensure port ${conn.port} is forwarded to the cluster (the API auto-starts kubectl port-forward in development). ` + `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` `Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200`
); );
} }
if (conn.host.includes('svc.cluster.local') || conn.host.includes('.cluster.')) { 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 ( return (
'Run the API inside the cluster, or set ELASTICSEARCH_HOST=127.0.0.1 and keep port-forward running: ' + '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` `(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}.`; return `Ensure Elasticsearch is listening on ${conn.host}:${conn.port}.`;
@@ -620,9 +690,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
*/ */
getConnectionInfo(): { host: string; port: number; username: string; password: string } { getConnectionInfo(): { host: string; port: number; username: string; password: string } {
return { return {
host: host: this.effectiveElasticsearchHost(),
this.configService.get<string>('elasticsearch.host') ||
`${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`,
port: this.configService.get<number>('elasticsearch.port') || 9200, port: this.configService.get<number>('elasticsearch.port') || 9200,
username: 'elastic', username: 'elastic',
password: this.ELASTIC_PASSWORD, password: this.ELASTIC_PASSWORD,
@@ -791,6 +859,10 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
); );
} }
if (this.shouldAutoPortForward()) {
await this.ensureLocalElasticsearchAccess({ clusterId });
}
const conn = this.getConnectionInfo(); const conn = this.getConnectionInfo();
const url = `http://${conn.host}:${conn.port}${path}`; const url = `http://${conn.host}:${conn.port}${path}`;
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64'); const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
@@ -800,7 +872,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
response = await this.elasticsearchFetch(url, auth, body); response = await this.elasticsearchFetch(url, auth, body);
} catch (err: any) { } catch (err: any) {
if (this.shouldAutoPortForward()) { if (this.shouldAutoPortForward()) {
await this.ensureLocalElasticsearchAccess(); await this.ensureLocalElasticsearchAccess({ clusterId });
try { try {
response = await this.elasticsearchFetch(url, auth, body); response = await this.elasticsearchFetch(url, auth, body);
} catch { } catch {
@@ -963,7 +1035,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
if (this.shouldAutoPortForward() && !(await this.probeElasticsearch())) { if (this.shouldAutoPortForward() && !(await this.probeElasticsearch())) {
void this.ensureLocalElasticsearchAccess(); void this.ensureLocalElasticsearchAccess({ clusterId });
} }
if (await this.probeElasticsearch()) { if (await this.probeElasticsearch()) {
+9
View File
@@ -329,6 +329,15 @@ export class HelmService {
// ── Temp file helpers ─────────────────────────────────── // ── Temp file helpers ───────────────────────────────────
/** Write kubeconfig to a temp file for kubectl/helm CLIs (caller may delete when done). */
async createKubeconfigFile(kubeconfig: string): Promise<string> {
return this.writeTempKubeconfig(kubeconfig);
}
removeTempFile(filePath: string): void {
this.cleanupTempFiles(filePath);
}
private async writeTempKubeconfig(kubeconfig: string): Promise<string> { private async writeTempKubeconfig(kubeconfig: string): Promise<string> {
const tmpFile = path.join(os.tmpdir(), `cloudhost-kube-${Date.now()}-${Math.random().toString(36).slice(2)}`); const tmpFile = path.join(os.tmpdir(), `cloudhost-kube-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await fs.promises.writeFile(tmpFile, kubeconfig, { mode: 0o600 }); await fs.promises.writeFile(tmpFile, kubeconfig, { mode: 0o600 });
+59 -32
View File
@@ -46,12 +46,16 @@ export class LogsController {
userId: string, userId: string,
appId?: string, appId?: string,
allowStaff = false, allowStaff = false,
): Promise<{ applicationId?: string; applicationName?: string }> { ): Promise<{ applicationId?: string; applicationName?: string; clusterId?: string }> {
if (!appId) return {}; if (!appId) return {};
const where = allowStaff ? { id: appId } : { id: appId, userId }; const where = allowStaff ? { id: appId } : { id: appId, userId };
const app = await this.appsRepo.findOne({ where }); const app = await this.appsRepo.findOne({ where });
if (!app) throw new NotFoundException('Application not found'); if (!app) throw new NotFoundException('Application not found');
return { applicationId: app.id, applicationName: app.name }; return {
applicationId: app.id,
applicationName: app.name,
clusterId: app.clusterId || undefined,
};
} }
private isStaff(role: string): boolean { private isStaff(role: string): boolean {
@@ -60,8 +64,15 @@ export class LogsController {
@Get('status') @Get('status')
@ApiOperation({ summary: 'Check if central logging is available' }) @ApiOperation({ summary: 'Check if central logging is available' })
async getStatus() { @ApiQuery({ name: 'appId', required: false, description: 'Resolve logging cluster from this app' })
return this.esService.getLoggingStatus(); async getStatus(
@Request() req: AuthenticatedRequest,
@Query('appId') appId?: string,
) {
const appFilters = appId
? await this.resolveAppFilters(req.user.id, appId, this.isStaff(req.user.role))
: {};
return this.esService.getLoggingStatus(appFilters.clusterId);
} }
@Get() @Get()
@@ -102,16 +113,20 @@ export class LogsController {
const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
return this.esService.searchLogs(userId, { return this.esService.searchLogs(
...appFilters, userId,
workload, {
level: level?.toLowerCase(), ...appFilters,
from, workload,
to, level: level?.toLowerCase(),
search, from,
page: parseInt(page || '1', 10), to,
limit: Math.min(parseInt(limit || '100', 10), 1000), search,
}); page: parseInt(page || '1', 10),
limit: Math.min(parseInt(limit || '100', 10), 1000),
},
appFilters.clusterId,
);
} }
@Get('stream') @Get('stream')
@@ -127,13 +142,17 @@ export class LogsController {
const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
return this.esService.searchLogs(userId, { return this.esService.searchLogs(
...appFilters, userId,
workload, {
from: fiveMinAgo, ...appFilters,
limit: 100, workload,
page: 1, from: fiveMinAgo,
}); limit: 100,
page: 1,
},
appFilters.clusterId,
);
} }
@Get('stats') @Get('stats')
@@ -149,11 +168,15 @@ export class LogsController {
) { ) {
const userId = req.user.id; const userId = req.user.id;
const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
return this.esService.searchLogStats(userId, { return this.esService.searchLogStats(
...appFilters, userId,
workload, {
period: period || '24h', ...appFilters,
}); workload,
period: period || '24h',
},
appFilters.clusterId,
);
} }
@Get('errors') @Get('errors')
@@ -171,12 +194,16 @@ export class LogsController {
) { ) {
const userId = req.user.id; const userId = req.user.id;
const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
const hits = await this.esService.searchRecentErrors(userId, { const hits = await this.esService.searchRecentErrors(
...appFilters, userId,
workload, {
hours: parseInt(hours || '24', 10), ...appFilters,
limit: Math.min(parseInt(limit || '50', 10), 500), workload,
}); hours: parseInt(hours || '24', 10),
limit: Math.min(parseInt(limit || '50', 10), 500),
},
appFilters.clusterId,
);
return { hits, total: hits.length }; return { hits, total: hits.length };
} }
+3 -3
View File
@@ -68,10 +68,10 @@ function LogsPageContent() {
}, [initialAppId]); }, [initialAppId]);
const { data: loggingStatus, isFetching: statusFetching } = useQuery({ const { data: loggingStatus, isFetching: statusFetching } = useQuery({
queryKey: ['logs-status'], queryKey: ['logs-status', appId],
queryFn: () => queryFn: () =>
api api
.get('/logs/status') .get('/logs/status', { params: appId ? { appId } : undefined })
.then((r) => .then((r) =>
r.data as { r.data as {
available: boolean; available: boolean;
@@ -185,7 +185,7 @@ function LogsPageContent() {
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1; const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
if (loggingStatus && !loggingStatus.available) { if (loggingStatus && !loggingStatus.available) {
const isRecovering = loggingStatus.recovering || loggingStatus.deployed === true; const isRecovering = loggingStatus.recovering === true;
return ( return (
<div className="max-w-3xl mx-auto card p-8 text-center"> <div className="max-w-3xl mx-auto card p-8 text-center">
{isRecovering ? ( {isRecovering ? (