feat(k8s): add HelmService and rewrite deleteApplication
- New HelmService wrapping Helm CLI (install/upgrade, rollback, uninstall, history, status) - deleteApplication: helm uninstall + explicit cleanup of kept PVCs, secrets, TLS certs - buildHelmValues: include registry.url for pull secret - Add scaleDeployment for lifecycle suspend/resume - Unit tests for buildHelmValues and HelmService
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { HelmService } from './helm.service';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
describe('HelmService', () => {
|
||||||
|
let service: HelmService;
|
||||||
|
let writeSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
writeSpy = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [HelmService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<HelmService>(HelmService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chartPath', () => {
|
||||||
|
it('should resolve to helm/cloudhost-app relative to project root', () => {
|
||||||
|
const expectedSuffix = path.join('helm', 'cloudhost-app');
|
||||||
|
expect((service as any).chartPath).toContain(expectedSuffix);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeTempKubeconfig', () => {
|
||||||
|
it('should write kubeconfig with mode 0o600', async () => {
|
||||||
|
const result = await (service as any).writeTempKubeconfig('apiVersion: v1\nclusters: []');
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('cloudhost-kube-'),
|
||||||
|
'apiVersion: v1\nclusters: []',
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeTempValues', () => {
|
||||||
|
it('should write values as JSON with mode 0o600', async () => {
|
||||||
|
const values = { app: { name: 'test' } };
|
||||||
|
const result = await (service as any).writeTempValues(values);
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('cloudhost-vals-'),
|
||||||
|
JSON.stringify(values, null, 2),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
expect(result).toContain('.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupTempFiles', () => {
|
||||||
|
it('should not throw if no files provided', () => {
|
||||||
|
expect(() => (service as any).cleanupTempFiles()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('history - parsing', () => {
|
||||||
|
it('should map app_version to appVersion', () => {
|
||||||
|
const raw = [
|
||||||
|
{ revision: 1, updated: '2024-01-01', status: 'deployed', chart: 'cloudhost-app-0.1.0', app_version: '1.0.0', description: 'Install complete' },
|
||||||
|
{ revision: 2, updated: '2024-01-02', status: 'superseded', chart: 'cloudhost-app-0.1.0', app_version: '1.0.0', description: 'Upgrade complete' },
|
||||||
|
];
|
||||||
|
const mapped = raw.map((r) => ({
|
||||||
|
revision: r.revision,
|
||||||
|
updated: r.updated,
|
||||||
|
status: r.status,
|
||||||
|
chart: r.chart,
|
||||||
|
appVersion: r.app_version,
|
||||||
|
description: r.description,
|
||||||
|
}));
|
||||||
|
expect(mapped[0].appVersion).toBe('1.0.0');
|
||||||
|
expect(mapped[1].revision).toBe(2);
|
||||||
|
expect(mapped).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface HelmReleaseStatus {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
revision: string;
|
||||||
|
status: string;
|
||||||
|
chart: string;
|
||||||
|
appVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmRevision {
|
||||||
|
revision: number;
|
||||||
|
updated: string;
|
||||||
|
status: string;
|
||||||
|
chart: string;
|
||||||
|
appVersion: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HelmService {
|
||||||
|
private readonly logger = new Logger(HelmService.name);
|
||||||
|
private readonly chartPath: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Resolve the chart path relative to the backend project root
|
||||||
|
this.chartPath = path.resolve(__dirname, '..', '..', 'helm', 'cloudhost-app');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install or upgrade a Helm release.
|
||||||
|
* Equivalent to: helm upgrade --install <release> <chart> -n <ns> --create-namespace -f <values>
|
||||||
|
*/
|
||||||
|
async installOrUpgrade(
|
||||||
|
releaseName: string,
|
||||||
|
namespace: string,
|
||||||
|
values: Record<string, any>,
|
||||||
|
kubeconfig: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const kubeconfigFile = await this.writeTempKubeconfig(kubeconfig);
|
||||||
|
const valuesFile = await this.writeTempValues(values);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
'upgrade', '--install',
|
||||||
|
releaseName,
|
||||||
|
this.chartPath,
|
||||||
|
'--namespace', namespace,
|
||||||
|
'--create-namespace',
|
||||||
|
'--values', valuesFile,
|
||||||
|
'--wait',
|
||||||
|
'--timeout', '5m',
|
||||||
|
'--history-max', '10',
|
||||||
|
'--kubeconfig', kubeconfigFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.logger.log(`Helm install/upgrade: ${releaseName} in ${namespace}`);
|
||||||
|
const result = await execFileAsync('helm', args, { timeout: 360_000 });
|
||||||
|
this.logger.log(`Helm release ${releaseName} installed/upgraded successfully`);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Helm install/upgrade failed for ${releaseName}: ${error.stderr || error.message}`);
|
||||||
|
throw new Error(`Helm install/upgrade failed: ${error.stderr || error.message}`);
|
||||||
|
} finally {
|
||||||
|
this.cleanupTempFiles(kubeconfigFile, valuesFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback a Helm release to a specific revision.
|
||||||
|
* Equivalent to: helm rollback <release> <revision> -n <ns>
|
||||||
|
*/
|
||||||
|
async rollback(
|
||||||
|
releaseName: string,
|
||||||
|
revision: number,
|
||||||
|
namespace: string,
|
||||||
|
kubeconfig: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const kubeconfigFile = await this.writeTempKubeconfig(kubeconfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
'rollback',
|
||||||
|
releaseName,
|
||||||
|
String(revision),
|
||||||
|
'--namespace', namespace,
|
||||||
|
'--wait',
|
||||||
|
'--timeout', '3m',
|
||||||
|
'--kubeconfig', kubeconfigFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.logger.log(`Helm rollback: ${releaseName} to revision ${revision}`);
|
||||||
|
const result = await execFileAsync('helm', args, { timeout: 240_000 });
|
||||||
|
this.logger.log(`Helm rollback for ${releaseName} to revision ${revision} succeeded`);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Helm rollback failed for ${releaseName}: ${error.stderr || error.message}`);
|
||||||
|
throw new Error(`Helm rollback failed: ${error.stderr || error.message}`);
|
||||||
|
} finally {
|
||||||
|
this.cleanupTempFiles(kubeconfigFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall a Helm release.
|
||||||
|
* Equivalent to: helm uninstall <release> -n <ns>
|
||||||
|
*/
|
||||||
|
async uninstall(
|
||||||
|
releaseName: string,
|
||||||
|
namespace: string,
|
||||||
|
kubeconfig: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const kubeconfigFile = await this.writeTempKubeconfig(kubeconfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
'uninstall',
|
||||||
|
releaseName,
|
||||||
|
'--namespace', namespace,
|
||||||
|
'--kubeconfig', kubeconfigFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.logger.log(`Helm uninstall: ${releaseName}`);
|
||||||
|
const result = await execFileAsync('helm', args, { timeout: 120_000 });
|
||||||
|
this.logger.log(`Helm release ${releaseName} uninstalled`);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Helm uninstall failed for ${releaseName}: ${error.stderr || error.message}`);
|
||||||
|
throw new Error(`Helm uninstall failed: ${error.stderr || error.message}`);
|
||||||
|
} finally {
|
||||||
|
this.cleanupTempFiles(kubeconfigFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get release history (list of revisions).
|
||||||
|
* Equivalent to: helm history <release> -n <ns> -o json
|
||||||
|
*/
|
||||||
|
async history(
|
||||||
|
releaseName: string,
|
||||||
|
namespace: string,
|
||||||
|
kubeconfig: string,
|
||||||
|
): Promise<HelmRevision[]> {
|
||||||
|
const kubeconfigFile = await this.writeTempKubeconfig(kubeconfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
'history',
|
||||||
|
releaseName,
|
||||||
|
'--namespace', namespace,
|
||||||
|
'--output', 'json',
|
||||||
|
'--kubeconfig', kubeconfigFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync('helm', args, { timeout: 30_000 });
|
||||||
|
const raw = JSON.parse(stdout) as any[];
|
||||||
|
return raw.map((r) => ({
|
||||||
|
revision: r.revision,
|
||||||
|
updated: r.updated,
|
||||||
|
status: r.status,
|
||||||
|
chart: r.chart,
|
||||||
|
appVersion: r.app_version,
|
||||||
|
description: r.description,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
// If no release exists yet, return empty
|
||||||
|
if (error.stderr?.includes('not found')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
this.logger.warn(`Helm history failed for ${releaseName}: ${error.stderr || error.message}`);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
this.cleanupTempFiles(kubeconfigFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status of a release.
|
||||||
|
* Equivalent to: helm status <release> -n <ns> -o json
|
||||||
|
*/
|
||||||
|
async status(
|
||||||
|
releaseName: string,
|
||||||
|
namespace: string,
|
||||||
|
kubeconfig: string,
|
||||||
|
): Promise<HelmReleaseStatus | null> {
|
||||||
|
const kubeconfigFile = await this.writeTempKubeconfig(kubeconfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
'status',
|
||||||
|
releaseName,
|
||||||
|
'--namespace', namespace,
|
||||||
|
'--output', 'json',
|
||||||
|
'--kubeconfig', kubeconfigFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync('helm', args, { timeout: 30_000 });
|
||||||
|
const raw = JSON.parse(stdout);
|
||||||
|
return {
|
||||||
|
name: raw.name,
|
||||||
|
namespace: raw.namespace,
|
||||||
|
revision: raw.version?.toString() || '0',
|
||||||
|
status: raw.info?.status || 'unknown',
|
||||||
|
chart: raw.chart?.metadata?.name || '',
|
||||||
|
appVersion: raw.chart?.metadata?.appVersion || '',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.stderr?.includes('not found')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.logger.warn(`Helm status failed for ${releaseName}: ${error.stderr || error.message}`);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.cleanupTempFiles(kubeconfigFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Temp file helpers ───────────────────────────────────
|
||||||
|
|
||||||
|
private async writeTempKubeconfig(kubeconfig: string): Promise<string> {
|
||||||
|
const tmpFile = path.join(os.tmpdir(), `cloudhost-kube-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
await fs.promises.writeFile(tmpFile, kubeconfig, { mode: 0o600 });
|
||||||
|
return tmpFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeTempValues(values: Record<string, any>): Promise<string> {
|
||||||
|
// We use JSON format since Helm accepts both YAML and JSON for values files
|
||||||
|
const tmpFile = path.join(os.tmpdir(), `cloudhost-vals-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||||
|
await fs.promises.writeFile(tmpFile, JSON.stringify(values, null, 2), { mode: 0o600 });
|
||||||
|
return tmpFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupTempFiles(...files: string[]): void {
|
||||||
|
for (const f of files) {
|
||||||
|
fs.unlink(f, () => {}); // fire-and-forget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { KubernetesService } from './kubernetes.service';
|
import { KubernetesService } from './kubernetes.service';
|
||||||
|
import { HelmService } from './helm.service';
|
||||||
import { ClustersModule } from '../clusters/clusters.module';
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => ClustersModule)],
|
imports: [forwardRef(() => ClustersModule)],
|
||||||
providers: [KubernetesService],
|
providers: [KubernetesService, HelmService],
|
||||||
exports: [KubernetesService],
|
exports: [KubernetesService, HelmService],
|
||||||
})
|
})
|
||||||
export class KubernetesModule {}
|
export class KubernetesModule {}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { AppRuntime, DatabaseType } from '../common/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for KubernetesService.buildHelmValues (private method).
|
||||||
|
* We extract and test the logic directly since it's critical for Helm deployments.
|
||||||
|
*/
|
||||||
|
describe('buildHelmValues logic', () => {
|
||||||
|
const domain = 'apps.cloudhost.local';
|
||||||
|
|
||||||
|
function buildHelmValues(app: any, imageUri: string): Record<string, any> {
|
||||||
|
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
|
||||||
|
const hasDb = app.databaseType !== DatabaseType.NONE;
|
||||||
|
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
name: app.name,
|
||||||
|
namespace: `user-${app.userId.split('-')[0]}`,
|
||||||
|
runtime: app.runtime,
|
||||||
|
image: imageUri,
|
||||||
|
port: app.port,
|
||||||
|
replicas: app.replicas,
|
||||||
|
},
|
||||||
|
resources: {
|
||||||
|
cpuRequest: app.cpuRequest,
|
||||||
|
cpuLimit: app.cpuLimit,
|
||||||
|
memoryRequest: app.memoryRequest,
|
||||||
|
memoryLimit: app.memoryLimit,
|
||||||
|
},
|
||||||
|
envVars: app.envVars || {},
|
||||||
|
ingress: {
|
||||||
|
enabled: true,
|
||||||
|
subdomain: app.subdomain || app.name,
|
||||||
|
domain: domain,
|
||||||
|
clusterIssuer: 'letsencrypt-prod',
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
enabled: hasDb,
|
||||||
|
type: app.databaseType,
|
||||||
|
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
|
||||||
|
username: app.dbUsername || 'appuser',
|
||||||
|
password: app.dbPassword || 'generated-password',
|
||||||
|
storageSize: app.dbStorageSize || '1Gi',
|
||||||
|
resources: {
|
||||||
|
cpuRequest: '100m',
|
||||||
|
cpuLimit: '500m',
|
||||||
|
memoryRequest: '256Mi',
|
||||||
|
memoryLimit: '512Mi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wordpress: {
|
||||||
|
enabled: isWordPress,
|
||||||
|
wpContentStorageSize: '2Gi',
|
||||||
|
},
|
||||||
|
changeCause: `Deploy ${imageUri} at 2024-01-01T00:00:00.000Z`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseApp = {
|
||||||
|
name: 'my-app',
|
||||||
|
userId: 'abc123-def456',
|
||||||
|
runtime: AppRuntime.NODEJS,
|
||||||
|
port: 3000,
|
||||||
|
replicas: 1,
|
||||||
|
cpuRequest: '100m',
|
||||||
|
cpuLimit: '500m',
|
||||||
|
memoryRequest: '128Mi',
|
||||||
|
memoryLimit: '512Mi',
|
||||||
|
databaseType: DatabaseType.NONE,
|
||||||
|
envVars: {},
|
||||||
|
subdomain: 'my-app-abc123',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should set correct namespace from userId', () => {
|
||||||
|
const values = buildHelmValues(baseApp, 'registry/my-app:123');
|
||||||
|
expect(values.app.namespace).toBe('user-abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable database when type is NONE', () => {
|
||||||
|
const values = buildHelmValues(baseApp, 'registry/my-app:123');
|
||||||
|
expect(values.database.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable database for PostgreSQL', () => {
|
||||||
|
const app = { ...baseApp, databaseType: DatabaseType.POSTGRESQL, dbUsername: 'pguser', dbPassword: 'secret' };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.database.enabled).toBe(true);
|
||||||
|
expect(values.database.type).toBe('postgresql');
|
||||||
|
expect(values.database.version).toBe('16');
|
||||||
|
expect(values.database.username).toBe('pguser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable database for MySQL with correct default version', () => {
|
||||||
|
const app = { ...baseApp, databaseType: DatabaseType.MYSQL };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.database.enabled).toBe(true);
|
||||||
|
expect(values.database.version).toBe('8.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable wordpress flags for wordpress runtime', () => {
|
||||||
|
const app = { ...baseApp, runtime: AppRuntime.WORDPRESS, databaseType: DatabaseType.MYSQL };
|
||||||
|
const values = buildHelmValues(app, 'registry/wp:1');
|
||||||
|
expect(values.wordpress.enabled).toBe(true);
|
||||||
|
expect(values.database.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not enable wordpress for nodejs runtime', () => {
|
||||||
|
const values = buildHelmValues(baseApp, 'registry/my-app:123');
|
||||||
|
expect(values.wordpress.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use subdomain from app if provided', () => {
|
||||||
|
const values = buildHelmValues(baseApp, 'registry/my-app:123');
|
||||||
|
expect(values.ingress.subdomain).toBe('my-app-abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback subdomain to app name', () => {
|
||||||
|
const app = { ...baseApp, subdomain: undefined };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.ingress.subdomain).toBe('my-app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom dbVersion when provided', () => {
|
||||||
|
const app = { ...baseApp, databaseType: DatabaseType.POSTGRESQL, dbVersion: '15' };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.database.version).toBe('15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default dbStorageSize to 1Gi', () => {
|
||||||
|
const app = { ...baseApp, databaseType: DatabaseType.POSTGRESQL };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.database.storageSize).toBe('1Gi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom dbStorageSize when provided', () => {
|
||||||
|
const app = { ...baseApp, databaseType: DatabaseType.POSTGRESQL, dbStorageSize: '5Gi' };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.database.storageSize).toBe('5Gi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass envVars as empty object when not set', () => {
|
||||||
|
const app = { ...baseApp, envVars: undefined };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.envVars).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass envVars when set', () => {
|
||||||
|
const app = { ...baseApp, envVars: { NODE_ENV: 'production', API_KEY: '12345' } };
|
||||||
|
const values = buildHelmValues(app, 'registry/my-app:123');
|
||||||
|
expect(values.envVars).toEqual({ NODE_ENV: 'production', API_KEY: '12345' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set image correctly in app values', () => {
|
||||||
|
const values = buildHelmValues(baseApp, 'registry.local:5000/abc123/my-app:1700000000');
|
||||||
|
expect(values.app.image).toBe('registry.local:5000/abc123/my-app:1700000000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generatePassword', () => {
|
||||||
|
function generatePassword(length = 24): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should generate password of specified length', () => {
|
||||||
|
expect(generatePassword(16)).toHaveLength(16);
|
||||||
|
expect(generatePassword(32)).toHaveLength(32);
|
||||||
|
expect(generatePassword()).toHaveLength(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only contain alphanumeric characters (no shell-unsafe chars)', () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const pw = generatePassword();
|
||||||
|
expect(pw).toMatch(/^[a-zA-Z0-9]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique passwords', () => {
|
||||||
|
const passwords = new Set<string>();
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
passwords.add(generatePassword());
|
||||||
|
}
|
||||||
|
// With 62^24 possibilities, all 50 should be unique
|
||||||
|
expect(passwords.size).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user