import { LoggerService } from '../logger/logger.service.js'; import { LOGGER_MODULE_PROVIDER } from '../logger/logger.constants.js'; import { Inject, Injectable } from '@nestjs/common'; import { EnvironmentService } from '../environment/environment.service.js'; import { K8sEnvironment } from '../config/environment.interface.js'; import { SqlService } from '../sql/sql.service.js'; import { DatabaseConfig } from '../backup-db/database-config.interface.js'; import { exec, throwIfFailed } from '../util/exec.js'; import { shquote } from '../util/sh-quote.js'; import { ExecOptions } from 'shelljs'; import { spawn } from 'child_process'; import { highlight } from 'cli-highlight'; import chalk from 'chalk'; import tmp from 'tmp'; import fs from 'node:fs'; import { ConfigService } from '../config/config.service.js'; import { RedactService } from '../redact/redact.service.js'; @Injectable() export class K8sService { private kubeconfigPath: string; constructor( private readonly environmentService: EnvironmentService, private readonly sqlService: SqlService, private readonly configService: ConfigService, private readonly redactService: RedactService, @Inject(LOGGER_MODULE_PROVIDER) protected readonly logger: LoggerService, ) {} public async cleanUp() { if (this.kubeconfigPath) { this.logger.debug( `Deleting kubeconfig at ${chalk.bold(this.kubeconfigPath)}`, ); // `force` because tmp's own exit hook may have won the race; forget the // path either way so a second cleanUp() is a no-op instead of an ENOENT. await fs.promises.rm(this.kubeconfigPath, { force: true }); this.kubeconfigPath = undefined; } } public async setup() { await this.setDefaultContext(); await this.substituteKubeconfig(); await this.retrieveKubeloginToken(); this.sqlService.databaseConfig = await this.retrieveDatabaseConfig(); } private async substituteKubeconfig() { const environment = this.environmentService.environment as K8sEnvironment; if (!environment.kubeconfig) { return; } const kubeconfigTemp = tmp.fileSync({ prefix: 'migrateus_kubeconfig_', }); const kubeconfigContent = await fs.promises.readFile( environment.kubeconfig, 'utf-8', ); const kubeconfigSubstitutedContent = kubeconfigContent.replaceAll( /\$(\w+)/g, (match, variable) => this.configService.envConfig[variable] || match, ); await fs.promises.writeFile( kubeconfigTemp.name, kubeconfigSubstitutedContent, ); this.logger.debug( `Substituted kubeconfig ${chalk.bold(environment.kubeconfig)} and copied to: ${chalk.bold(kubeconfigTemp.name)}`, ); this.kubeconfigPath = kubeconfigTemp.name; } private async retrieveKubeloginToken() { const environment = this.environmentService.environment as K8sEnvironment; if (!environment.kubelogin) { return; } throwIfFailed( await exec('kubectl oidc-login version', { silent: true }), `Kubelogin is not installed. See: ${chalk.bold('https://github.com/int128/kubelogin/tree/master?tab=readme-ov-file#setup')}`, ); const env = this.kubeconfigPath ? { ...process.env, KUBECONFIG: this.kubeconfigPath } : process.env; const child = spawn('kubectl', ['version'], { env, }); const port = await new Promise((resolve) => { child.on('close', () => { this.logger.debug(`Already logged in via kubelogin!`); resolve(null); }); child.stderr.on('data', (data) => { const match = data.toString().match(/http:\/\/localhost:(\d+)/); if (match) { resolve(match[1]); } }); }); if (!port) { return; } this.logger.info( `Open URL ${chalk.bold(`http://localhost:${port}`)} in your browser to login to the Kubernetes cluster`, ); await new Promise((resolve) => { child.on('close', () => { this.logger.debug(`Login completed successfully!`); resolve(); }); }); } public async execInDirectus(command: string) { return this.kubectl( `exec deploy/directus -- /bin/sh -c ${shquote(command)}`, { silent: true }, ); } public async restartDirectus() { await this.kubectl('rollout restart deploy directus', { silent: true }); } public async kubectl(command: string, options: ExecOptions = {}) { const environment = this.environmentService.environment as K8sEnvironment; let fullCommand = `kubectl ${command}`; if (environment.namespace) { fullCommand = fullCommand.replace( 'kubectl', `kubectl -n ${environment.namespace}`, ); } if (this.kubeconfigPath) { fullCommand = `KUBECONFIG=${this.kubeconfigPath} ${fullCommand}`; } this.logger.debug( `Running ${highlight(fullCommand, { language: 'bash' })}`, ); return throwIfFailed(await exec(fullCommand, options), (o) => o.stderr); } public kubectlApply(spec: object) { let fullCommand = `echo '${JSON.stringify(spec)}' |`; if (this.kubeconfigPath) { fullCommand = `${fullCommand} KUBECONFIG=${this.kubeconfigPath}`; } const environment = this.environmentService.environment as K8sEnvironment; fullCommand = `${fullCommand} kubectl apply -f -`; if (environment.namespace) { fullCommand = `${fullCommand} -n ${environment.namespace}`; } this.logger.debug( `Running ${highlight(fullCommand, { language: 'bash' })}`, ); return exec(fullCommand, { silent: true }); } public portForward( podName: string, sourcePort: number | string, targetPort: number | string, ) { const environment = this.environmentService.environment as K8sEnvironment; const env = this.kubeconfigPath ? { ...process.env, KUBECONFIG: this.kubeconfigPath } : process.env; let args = ['port-forward', podName, `${sourcePort}:${targetPort}`]; if (environment.namespace) { args = ['-n', environment.namespace, ...args]; } return spawn('kubectl', args, { // stderr is piped (and unref'd by the caller) so kubectl's reason for // dropping a forward reaches the debug log instead of /dev/null. stdio: ['ignore', 'ignore', 'pipe'], detached: true, env, }); } private async setDefaultContext() { const env = this.environmentService.environment as K8sEnvironment; if (env.kubeconfig) { return; } const context = (this.environmentService.environment as K8sEnvironment) .context; if (context) { throwIfFailed( await this.kubectl(`config use-context ${context}`, { silent: true }), (o) => `Failed to set default context with code ${o.code}: ${o.stderr}`, ); } } protected async retrieveDatabaseConfig() { const deployManifest = JSON.parse( (await this.kubectl(`get deploy directus -ojson`, { silent: true })) .stdout, ); const directusContainer = deployManifest.spec.template.spec.containers.find( (container: { name: string }) => container.name === 'directus', ) as { name: string; env: { name: string; value?: string; valueFrom?: { secretKeyRef: { key: string; name: string } }; }[]; envFrom: { configMapRef?: { name: string }; secretRef?: { name: string }; }[]; }; const envMap: Record = {}; const loadedSecrets: Map = new Map(); for (const { name, value, valueFrom } of directusContainer.env) { if (valueFrom) { const secretName = valueFrom.secretKeyRef.name; if (!loadedSecrets.has(secretName)) { loadedSecrets.set(secretName, await this.loadSecret(secretName)); } const secret = loadedSecrets.get(secretName); envMap[name] = secret[valueFrom.secretKeyRef.key]; } else { envMap[name] = value; } } if (directusContainer.envFrom?.length > 0) { const configMapNames = []; const secretNames = []; for (const entry of directusContainer.envFrom) { if (entry.configMapRef) { configMapNames.push(entry.configMapRef.name); } else if (entry.secretRef) { secretNames.push(entry.secretRef.name); } } const configMaps = await Promise.all( configMapNames.map(async (name) => { return await this.loadConfigMap(name); }), ); configMaps.forEach((configMap) => { Object.assign(envMap, configMap); }); const secrets = await Promise.all( secretNames.map(async (name) => { return await this.loadSecret(name); }), ); secrets.forEach((configMap) => { Object.assign(envMap, configMap); }); } for (const [key, value] of Object.entries(envMap)) { if ( key.includes('PASSWORD') || key.includes('SECRET') || key.includes('KEY') ) { this.redactService.addRedaction(value); } } this.logger.debug( `Retrieved container environment: ${highlight( JSON.stringify(envMap, null, 2), { language: 'json', }, )}`, ); const result: DatabaseConfig = { host: envMap['DB_HOST'], port: envMap['DB_PORT'], user: envMap['DB_USER'], password: envMap['DB_PASSWORD'], name: envMap['DB_DATABASE'], }; if (envMap['DB_CLIENT']) result.client = envMap['DB_CLIENT'] as DatabaseConfig['client']; if (envMap['DB_FILENAME']) result.filename = envMap['DB_FILENAME']; return result; } private async loadSecret(name: string) { const secretOutput = await this.kubectl(`get secret ${name} -ojson`, { silent: true, }); const secret = JSON.parse(secretOutput.stdout); const data = secret.data; const result = {}; Object.keys(data).forEach((key) => { result[key] = Buffer.from(data[key], 'base64').toString('utf8'); }); return result; } private async loadConfigMap(name: string) { const configMapOutput = await this.kubectl(`get configmap ${name} -ojson`, { silent: true, }); return JSON.parse(configMapOutput.stdout).data; } }