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:
keyhan
2026-04-22 16:43:45 +03:30
parent 1a0b04feec
commit c42557a86b
5 changed files with 811 additions and 644 deletions
@@ -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);
});
});
});
+246
View File
@@ -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
}
}
}
+3 -2
View File
@@ -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