chore(deps): upgrade all dependencies to latest stable
Bring backend and frontend to the latest stable releases (no pre-releases), including major upgrades that required code migration. Both projects pass typecheck and production builds. Backend - NestJS 10 -> 11 (common/core/platform-express/jwt/passport/bull/cli/ schematics/testing), @nestjs/config 3->4, @nestjs/swagger 7->11, @nestjs/typeorm 10->11 - @kubernetes/client-node 0.21 -> 1.4: migrate ~200+ call sites across 6 services to the v1 single-object argument API, unwrapped responses, err.code, setHeaderOptions for patch content-type, applyToHTTPSOptions. Add regression spec k8s-client-v1-migration.spec.ts. - typeorm 0.3 -> 1.0: relations/select string arrays -> object form - uuid 9->14 (drops @types/uuid), multer 1->2, bcrypt 5->6, helmet 7->8, class-validator 0.14->0.15 - TypeScript 5->6, ESLint 8->9, @typescript-eslint 6->8, jest 29->30, @types/node 20->24; tsconfig: strictPropertyInitialization:false, ignoreDeprecations, rootDir, explicit types[] - @nestjs/config 4: jwt.strategy uses getOrThrow; @types/express kept at 4 (Nest 11 runs Express 4) Frontend - React 18->19, Next 14->16 (async params via official codemod), Tailwind 3->4 (@tailwindcss/postcss, @import + @config, inline custom @apply), framer-motion 11->12, zustand 4->5, three 0.169->0.184, @react-three/* majors - TypeScript 5->6 (tsconfig target es5->ES2017), ESLint 8->9, eslint-config-next 14->16 Infra/docs - Dockerfiles node:20-alpine -> node:24-alpine (require-esm for k8s client) - Add UPGRADE.md / UPGRADE.en.md; refresh README tech-stack versions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
@@ -142,19 +134,11 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
/** 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'
|
||||
);
|
||||
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`
|
||||
);
|
||||
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. */
|
||||
@@ -213,9 +197,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
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.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) => {
|
||||
@@ -280,10 +262,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> {
|
||||
const targetClusterId = clusterId || null;
|
||||
if (
|
||||
this.portForwardChild &&
|
||||
this.portForwardClusterId === targetClusterId
|
||||
) {
|
||||
if (this.portForwardChild && this.portForwardClusterId === targetClusterId) {
|
||||
return;
|
||||
}
|
||||
if (this.portForwardChild) {
|
||||
@@ -296,18 +275,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
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 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;
|
||||
@@ -318,12 +287,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
this.portForwardStartedByUs = false;
|
||||
}
|
||||
if (wasOurs) {
|
||||
const reason =
|
||||
code !== 0 && code !== null
|
||||
? `exit code ${code}`
|
||||
: signal
|
||||
? `signal ${signal}`
|
||||
: 'connection closed';
|
||||
const reason = code !== 0 && code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : 'connection closed';
|
||||
this.schedulePortForwardReconnect(reason);
|
||||
}
|
||||
});
|
||||
@@ -339,10 +303,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
* 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> {
|
||||
private async ensureLocalElasticsearchAccess(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<boolean> {
|
||||
if (this.ensureInFlight) {
|
||||
await this.ensureInFlight;
|
||||
return this.probeElasticsearch();
|
||||
@@ -357,10 +318,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureLocalElasticsearchAccessImpl(options?: {
|
||||
waitForCluster?: boolean;
|
||||
clusterId?: string;
|
||||
}): Promise<void> {
|
||||
private async ensureLocalElasticsearchAccessImpl(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<void> {
|
||||
if (!this.shouldAutoPortForward()) {
|
||||
return;
|
||||
}
|
||||
@@ -395,9 +353,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
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.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');
|
||||
}
|
||||
}
|
||||
@@ -413,10 +369,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
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 '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 ' +
|
||||
@@ -427,9 +380,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async getK8sClients(clusterId?: string) {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -455,18 +406,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
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,
|
||||
);
|
||||
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(this.ES_NAME, this.ES_NAMESPACE);
|
||||
await appsApi.readNamespacedStatefulSet({
|
||||
name: this.ES_NAME,
|
||||
namespace: this.ES_NAMESPACE,
|
||||
});
|
||||
hasEsWorkload = true;
|
||||
} catch {
|
||||
hasEsWorkload = false;
|
||||
@@ -512,26 +462,27 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
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';
|
||||
const provisioner = this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
|
||||
|
||||
try {
|
||||
await storageApi.readStorageClass(storageClass);
|
||||
await storageApi.readStorageClass({ name: storageClass });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err.statusCode !== 404 && err.body?.code !== 404) {
|
||||
if (err.code !== 404 && err.body?.code !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await storageApi.createStorageClass({
|
||||
apiVersion: 'storage.k8s.io/v1',
|
||||
kind: 'StorageClass',
|
||||
metadata: { name: storageClass },
|
||||
provisioner,
|
||||
allowVolumeExpansion: true,
|
||||
reclaimPolicy: 'Delete',
|
||||
volumeBindingMode: 'WaitForFirstConsumer',
|
||||
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})`);
|
||||
}
|
||||
@@ -578,37 +529,25 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
try {
|
||||
// Check ES pods
|
||||
const esPods = await coreApi.listNamespacedPod(
|
||||
this.ES_NAMESPACE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'app=elasticsearch',
|
||||
);
|
||||
const esPods = await coreApi.listNamespacedPod({
|
||||
namespace: this.ES_NAMESPACE,
|
||||
labelSelector: 'app=elasticsearch',
|
||||
});
|
||||
|
||||
const kibanaPods = await coreApi.listNamespacedPod(
|
||||
this.ES_NAMESPACE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'app=kibana',
|
||||
);
|
||||
const kibanaPods = await coreApi.listNamespacedPod({
|
||||
namespace: this.ES_NAMESPACE,
|
||||
labelSelector: 'app=kibana',
|
||||
});
|
||||
|
||||
if (esPods.body.items.length === 0) {
|
||||
if (esPods.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const esPod = esPods.body.items[0];
|
||||
const isEsReady = esPod.status?.conditions?.some(
|
||||
(c) => c.type === 'Ready' && c.status === 'True',
|
||||
);
|
||||
const esPod = esPods.items[0];
|
||||
const isEsReady = esPod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True');
|
||||
|
||||
const kibanaPod = kibanaPods.body.items[0];
|
||||
const isKibanaReady = kibanaPod?.status?.conditions?.some(
|
||||
(c) => c.type === 'Ready' && c.status === 'True',
|
||||
) || false;
|
||||
const kibanaPod = kibanaPods.items[0];
|
||||
const isKibanaReady = kibanaPod?.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') || false;
|
||||
|
||||
return {
|
||||
status: isEsReady ? 'green' : 'yellow',
|
||||
@@ -640,22 +579,14 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
timeout: '10m',
|
||||
});
|
||||
} catch (error: any) {
|
||||
const release = await this.helmService.status(
|
||||
LOGGING_HELM_RELEASE,
|
||||
LOGGING_HELM_NAMESPACE,
|
||||
cluster.kubeconfig,
|
||||
);
|
||||
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.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})`,
|
||||
);
|
||||
this.logger.log(`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`);
|
||||
|
||||
const state = await this.getDeployState(clusterId);
|
||||
|
||||
@@ -688,7 +619,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Get Elasticsearch connection info for apps
|
||||
*/
|
||||
getConnectionInfo(): { host: string; port: number; username: string; password: string } {
|
||||
getConnectionInfo(): {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
return {
|
||||
host: this.effectiveElasticsearchHost(),
|
||||
port: this.configService.get<number>('elasticsearch.port') || 9200,
|
||||
@@ -744,12 +680,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
const must: any[] = [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { ownerId: userId } },
|
||||
{ term: { 'ownerId.keyword': userId } },
|
||||
{ term: { namespace } },
|
||||
{ term: { 'namespace.keyword': namespace } },
|
||||
],
|
||||
should: [{ term: { ownerId: userId } }, { term: { 'ownerId.keyword': userId } }, { term: { namespace } }, { term: { 'namespace.keyword': namespace } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
@@ -758,10 +689,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.applicationId) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { applicationId: filters.applicationId } },
|
||||
{ term: { 'applicationId.keyword': filters.applicationId } },
|
||||
],
|
||||
should: [{ term: { applicationId: filters.applicationId } }, { term: { 'applicationId.keyword': filters.applicationId } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -784,10 +712,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.workload) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { workload: filters.workload } },
|
||||
{ term: { 'workload.keyword': filters.workload } },
|
||||
],
|
||||
should: [{ term: { workload: filters.workload } }, { term: { 'workload.keyword': filters.workload } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -796,10 +721,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.level) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { level: filters.level.toLowerCase() } },
|
||||
{ term: { 'level.keyword': filters.level.toLowerCase() } },
|
||||
],
|
||||
should: [{ term: { level: filters.level.toLowerCase() } }, { term: { 'level.keyword': filters.level.toLowerCase() } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -854,9 +776,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
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.',
|
||||
);
|
||||
throw new ServiceUnavailableException('Central logging is not configured. Ask an administrator to deploy Elasticsearch.');
|
||||
}
|
||||
|
||||
if (this.shouldAutoPortForward()) {
|
||||
@@ -896,12 +816,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
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) ||
|
||||
'';
|
||||
const message = src.message || src.log || src.msg || (typeof src.error === 'string' ? src.error : src.error?.message) || '';
|
||||
|
||||
return {
|
||||
id: hit._id || '',
|
||||
@@ -942,7 +857,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async searchLogStats(
|
||||
userId: string,
|
||||
filters: { applicationId?: string; applicationName?: string; workload?: string; period?: string },
|
||||
filters: {
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
workload?: string;
|
||||
period?: string;
|
||||
},
|
||||
clusterId?: string,
|
||||
): Promise<LogStatsResult> {
|
||||
const periodMap: Record<string, string> = {
|
||||
@@ -965,8 +885,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
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' } },
|
||||
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' } } },
|
||||
},
|
||||
@@ -994,7 +918,13 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async searchRecentErrors(
|
||||
userId: string,
|
||||
filters: { applicationId?: string; applicationName?: string; workload?: string; hours?: number; limit?: number },
|
||||
filters: {
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
workload?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
},
|
||||
clusterId?: string,
|
||||
): Promise<NormalizedLogEntry[]> {
|
||||
const hours = filters.hours || 24;
|
||||
@@ -1019,9 +949,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h));
|
||||
}
|
||||
|
||||
async getLoggingStatus(
|
||||
clusterId?: string,
|
||||
): Promise<{ available: boolean; deployed: boolean; recovering?: boolean; message?: string }> {
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @kubernetes/client-node 1.x ships as ESM (package.json "type": "module").
|
||||
// Production (Node 20.19+/24) loads it fine via require(esm), but Jest's own
|
||||
// CommonJS module system cannot parse it, so we mock it. Unit tests inject mock
|
||||
// API clients anyway — the only runtime helper the tested paths touch is
|
||||
// setHeaderOptions (used by the migrated patch calls).
|
||||
jest.mock('@kubernetes/client-node', () => ({
|
||||
setHeaderOptions: (key: string, value: string) => ({ headers: { [key]: value } }),
|
||||
}));
|
||||
|
||||
import { RegistryService } from './registry.service';
|
||||
import { KubernetesService } from './kubernetes.service';
|
||||
|
||||
/**
|
||||
* Regression tests for the @kubernetes/client-node 1.x migration.
|
||||
*
|
||||
* v1.x changed every API method from positional arguments returning `{ body }`
|
||||
* to a single options object returning the body directly, and changed the
|
||||
* thrown error shape from `.statusCode` to `.code`. These tests pin that the
|
||||
* migrated services:
|
||||
* 1. call the client with the new single-object argument shape,
|
||||
* 2. read the unwrapped response (no `.body`),
|
||||
* 3. detect "not found" via the new `err.code` field.
|
||||
*
|
||||
* They mock the API clients so no real cluster is required.
|
||||
*/
|
||||
|
||||
const configStub = { get: jest.fn().mockReturnValue(undefined) } as any;
|
||||
|
||||
describe('RegistryService — k8s v1 client shape', () => {
|
||||
let service: RegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RegistryService(configStub);
|
||||
});
|
||||
|
||||
it('replaces the pull secret with the v1 single-object argument', async () => {
|
||||
const coreApi = {
|
||||
replaceNamespacedSecret: jest.fn().mockResolvedValue({}),
|
||||
createNamespacedSecret: jest.fn(),
|
||||
} as any;
|
||||
|
||||
await service.ensureRegistryPullSecret(coreApi, 'team-ns');
|
||||
|
||||
expect(coreApi.replaceNamespacedSecret).toHaveBeenCalledTimes(1);
|
||||
const arg = coreApi.replaceNamespacedSecret.mock.calls[0][0];
|
||||
// v1 passes ONE object, not positional (name, namespace, body)
|
||||
expect(coreApi.replaceNamespacedSecret.mock.calls[0]).toHaveLength(1);
|
||||
expect(arg).toMatchObject({ name: 'registry-pull-secret', namespace: 'team-ns' });
|
||||
expect(arg.body?.metadata?.name).toBe('registry-pull-secret');
|
||||
expect(coreApi.createNamespacedSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the secret when replace fails with the v1 err.code 404', async () => {
|
||||
const coreApi = {
|
||||
replaceNamespacedSecret: jest.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedSecret: jest.fn().mockResolvedValue({}),
|
||||
} as any;
|
||||
|
||||
await service.ensureRegistryPullSecret(coreApi, 'team-ns');
|
||||
|
||||
expect(coreApi.createNamespacedSecret).toHaveBeenCalledTimes(1);
|
||||
const arg = coreApi.createNamespacedSecret.mock.calls[0][0];
|
||||
expect(coreApi.createNamespacedSecret.mock.calls[0]).toHaveLength(1);
|
||||
expect(arg).toMatchObject({ namespace: 'team-ns' });
|
||||
expect(arg.body?.type).toBe('kubernetes.io/dockerconfigjson');
|
||||
});
|
||||
|
||||
it('rethrows non-404 errors instead of creating', async () => {
|
||||
const coreApi = {
|
||||
replaceNamespacedSecret: jest.fn().mockRejectedValue({ code: 500 }),
|
||||
createNamespacedSecret: jest.fn(),
|
||||
} as any;
|
||||
|
||||
await expect(service.ensureRegistryPullSecret(coreApi, 'team-ns')).rejects.toEqual({ code: 500 });
|
||||
expect(coreApi.createNamespacedSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KubernetesService — k8s v1 client shape', () => {
|
||||
let service: KubernetesService;
|
||||
|
||||
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
|
||||
const svc = new KubernetesService(
|
||||
configStub,
|
||||
{} as any, // clustersService
|
||||
{} as any, // helmService
|
||||
{} as any, // registryService
|
||||
{} as any, // deploymentsRepository
|
||||
);
|
||||
jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({
|
||||
coreApi: clients.coreApi,
|
||||
appsApi: clients.appsApi,
|
||||
networkingApi: clients.networkingApi,
|
||||
kc: clients.kc,
|
||||
});
|
||||
return svc;
|
||||
};
|
||||
|
||||
const app = {
|
||||
id: 'app-1',
|
||||
name: 'my-app',
|
||||
userId: 'abc123-def456',
|
||||
clusterId: 'cluster-1',
|
||||
productType: 'web_service',
|
||||
databaseType: 'none',
|
||||
} as any;
|
||||
|
||||
it('getPodLogs lists pods and reads the log with v1 object args and unwrapped result', async () => {
|
||||
const coreApi = {
|
||||
listNamespacedPod: jest.fn().mockResolvedValue({ items: [{ metadata: { name: 'pod-1' } }] }),
|
||||
readNamespacedPodLog: jest.fn().mockResolvedValue('hello logs'),
|
||||
};
|
||||
service = makeService({ coreApi });
|
||||
|
||||
const logs = await service.getPodLogs(app);
|
||||
|
||||
// unwrapped string returned directly (v0.x returned { body })
|
||||
expect(logs).toBe('hello logs');
|
||||
|
||||
const listArg = coreApi.listNamespacedPod.mock.calls[0][0];
|
||||
expect(listArg).toMatchObject({ namespace: 'user-abc123' });
|
||||
expect(typeof listArg.labelSelector).toBe('string');
|
||||
|
||||
const logArg = coreApi.readNamespacedPodLog.mock.calls[0][0];
|
||||
expect(logArg).toMatchObject({ name: 'pod-1', namespace: 'user-abc123', tailLines: 200 });
|
||||
});
|
||||
|
||||
it('getDatabasePvcSize reads the PVC with v1 object args and unwrapped spec', async () => {
|
||||
const coreApi = {
|
||||
readNamespacedPersistentVolumeClaim: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ spec: { resources: { requests: { storage: '5Gi' } } } }),
|
||||
};
|
||||
service = makeService({ coreApi });
|
||||
|
||||
const size = await service.getDatabasePvcSize(app);
|
||||
|
||||
expect(size).toBe('5Gi');
|
||||
const arg = coreApi.readNamespacedPersistentVolumeClaim.mock.calls[0][0];
|
||||
expect(arg).toMatchObject({ name: 'my-app-db', namespace: 'user-abc123' });
|
||||
});
|
||||
|
||||
it('scaleDeployment patches with the v1 object body and a header-options 2nd arg', async () => {
|
||||
const appsApi = { patchNamespacedDeployment: jest.fn().mockResolvedValue({}) };
|
||||
service = makeService({ appsApi });
|
||||
|
||||
await service.scaleDeployment(app, 3);
|
||||
|
||||
expect(appsApi.patchNamespacedDeployment).toHaveBeenCalledTimes(1);
|
||||
const [param, options] = appsApi.patchNamespacedDeployment.mock.calls[0];
|
||||
expect(param).toMatchObject({
|
||||
name: 'my-app',
|
||||
namespace: 'user-abc123',
|
||||
body: { spec: { replicas: 3 } },
|
||||
});
|
||||
// v1 takes the merge-patch content-type via the 2nd ConfigurationOptions arg
|
||||
expect(options).toBeDefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,7 @@ export class RegistryService {
|
||||
/** Registry host:port used for build push and app image pull. */
|
||||
getRegistryUrl(): string {
|
||||
const buildNs = this.getBuildNamespace();
|
||||
const url =
|
||||
this.configService.get<string>('registry.pullUrl') ||
|
||||
this.configService.get<string>('registry.url') ||
|
||||
`registry.${buildNs}.svc.cluster.local:5000`;
|
||||
const url = this.configService.get<string>('registry.pullUrl') || this.configService.get<string>('registry.url') || `registry.${buildNs}.svc.cluster.local:5000`;
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
@@ -67,15 +64,14 @@ export class RegistryService {
|
||||
|
||||
buildDockerConfigJson(): string {
|
||||
const { username, password } = this.getRegistryCredentials();
|
||||
const auth =
|
||||
username && password
|
||||
? Buffer.from(`${username}:${password}`).toString('base64')
|
||||
: '';
|
||||
const auth = username && password ? Buffer.from(`${username}:${password}`).toString('base64') : '';
|
||||
const host = this.getRegistryUrl();
|
||||
return JSON.stringify({
|
||||
auths: {
|
||||
[host]: { auth },
|
||||
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: { auth },
|
||||
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -97,10 +93,14 @@ export class RegistryService {
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.replaceNamespacedSecret(secretName, namespace, secret);
|
||||
await coreApi.replaceNamespacedSecret({
|
||||
name: secretName,
|
||||
namespace,
|
||||
body: secret,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedSecret(namespace, secret);
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedSecret({ namespace, body: secret });
|
||||
this.logger.log(`Created ${secretName} in ${namespace}`);
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user