1import * as _ from 'lodash-es' 2import { scryptSync } from 'node:crypto' 40const cnpgPwSeedSecretName = 'cnpg-pw-seed' 42const getCnPgDeterministicPw = async (dbName: string) => { 44 if (!seed) return null 45 return scryptSync(dbName, seed, 32).toString('base64url') 48const buildDeterministicQs = ({name, password}: {name: string, password: string}) => { 49 const host = `${name}-rw.default` 50 return `postgresql://app:${password}@${host}:5432/app` 53const getCnPgQs = async ({name}: {name: string}) => { 57 metadata: { name: `${name}-app` }, 58 }) as {data?: {username: string, password: string}} | undefined 59 if (!secret?.data) return 61 const username = Buffer.from(secret.data.username, 'base64').toString() 62 const password = Buffer.from(secret.data.password, 'base64').toString() 63 const host = `${name}-rw.default` 65 return `postgresql://${username}:${password}@${host}:5432/app` 68type SyncCnPgParams = { 69 importMetaUrl: string // branded type at runtime, string for callers 71 backupBktName?: string 72 restoreFromName?: string | boolean 73 backupToName?: string | boolean 75 enableSchedBackup?: boolean 79 extraH?: Record<string, any> 81 getBackDestinPath?: (args: {backupBktName: string, serverName: string}) => string 83 migrationOpts?: Record<string, unknown> 86 kyselyOutFile?: string 89export const eptCnPgDbK8sRes = {kind: 'Cluster', apiVersion: 'postgresql.cnpg.io/v1'} 90export const eptCnPgDb = async ({importMetaUrl, name, backupBktName, restoreFromName, backupToName, imageName, enableSchedBackup, sizeGb, instances, storageClass, extraH, catalogResource, getBackDestinPath, hibernate = false, migrationOpts, gpIsProd, dbType, kyselyOutFile}: SyncCnPgParams) => { 92 appCfgCtx.enterWith({...appCfgCtx.getStore(), importMetaUrl: importMetaUrl as any, name}) 93 if (restoreFromName === true) restoreFromName = name 94 if (backupToName === true) backupToName = name 96 getBackDestinPath ||= ({backupBktName, serverName}: {backupBktName: string, serverName: string}) => { 97 return `s3://${backupBktName}/${serverName}` 101 if (action === 'apply' && cliFlag('--history')) { 105 const qsName = name + '_qs' 107 deployDbCtx.enterWith({migrationOpts, gpIsProd, dbType, qsName, kyselyOutFile}) // ctx:clear 109 const deployClusterName = klusterCtx.getStore()?.cluster_name 110 if (action == 'apply' && deployClusterName) { 115 const qs = async () => { 116 // if (resource.kind != 'Cluster') return 117 const qs = await getCnPgQs({name}) 124 // Also cache superuser qs locally so dbconsole works without k8s API 125 const suSecret = await readKlustResource({apiVersion: 'v1', kind: 'Secret', metadata: {name: `${name}-superuser`}}) as {data?: {password: string}} | undefined 126 if (suSecret?.data?.password) { 127 const suPw = Buffer.from(suSecret.data.password, 'base64').toString() 128 const suQs = `postgresql://postgres:${suPw}@${name}-rw.default:5432/app` 132 qs.cliDescript = 'writes the cnpg cluster secret qs to a local secret' 134 const pods = async ({resource, cluster_name}: {resource: {kind: string}, cluster_name: string}) => { 135 if (resource.kind != 'Cluster') return 136 await kubePods({cluster_name, labelSel: `cnpg.io/cluster=${name}`}) 138 pods.runActionPerK8sRes = true 140 const runtests = async () => { 141 const allSuites = ['clitests'] as string[] 147 suiteConfigs: [{name: 'clitests', runner: runDbCliTestsSuite, deps: []}], 151 if (!result.allPassed) process.exit(1) 153 runtests.cliDescript = 'run CLI behavior tests (--suite=clitests)' 155 availActionsCtx.enterWith({ 156 ...availActionsCtx.getStore(), 160 if (fnc.cliDescript) wrapped.cliDescript = fnc.cliDescript 161 return [action, wrapped] 162 }).fromPairs().value() 165 const qsWarn = () => betLog('!fileExists run qs to create', {qsSecretPath}) 168 if (action == 'dbconsole' && !localQsSecExists) { 172 // Custom actions (runtests/dbconsole/qs/backup/etc) dispatch without building resources — they 173 // must not require build-time secrets (e.g. backup bucket cfg absent in OSS checkouts) 176 // Deterministic password support - if seed exists, use deterministic pw & auto-write qs 177 const deterministicPw = await getCnPgDeterministicPw(name) 178 const appSecretName = `${name}-app` 184 apiVersion: 'postgresql.cnpg.io/v1', 188 ...(hibernate && {annotations: {'cnpg.io/hibernation': 'on'}}), 191 enableSuperuserAccess: true, // req to fix pg_restore 193 instances: instances ?? 1, 194 priorityClassName: k8DbPriorityClass, 201 max_slot_wal_keep_size: `${_.round(sizeGb * 0.5)}GB`, 202 idle_in_transaction_session_timeout: '60000', // 60s - auto-kill stuck transactions 205 // managed roles/secrets for deterministic password 206 ...(deterministicPw && !restoreFromName ? { 214 passwordSecret: { name: appSecretName }, 221 const {ptNodePortNo} = svcInfo 223 const deployCluster = klusterCtx.getStore()?.cluster_name 224 if (deployCluster && svcInfo.cluster_name !== deployCluster) { 225 throPtErr(`nodeport cluster mismatch: ${name} is defined in '${svcInfo.cluster_name}' but deploying to '${deployCluster}'. Move the nodeport definition to nodeports/${deployCluster}.mjs`) 230 // Pre-create app secret with deterministic password (only for fresh dbs, not restores) 231 if (deterministicPw && !restoreFromName) { 234 password: deterministicPw, 239 nodePort: ptNodePortNo!, 241 if (catalogResource) { 242 resources.push(catalogResource) 246 const s3SecretName = 'cnpg-s3creds-' + backupBktName 247 const {accessKeyId, secretAccessKey, endpoint, region} = await getS3Cfg({ 248 bucket_name: backupBktName, 249 }) as Record<string, any> 250 if (action != 'delete') { 253 accessKeyId, secretAccessKey, 254 ...(region && {region}), 259 throwIf(() => backupToName && restoreFromName, {backupBktName, restoreFromName}) 260 const serverName = (backupToName || restoreFromName) as string 261 const barmanObjectStore = { 263 destinationPath: getBackDestinPath({serverName, backupBktName: backupBktName!}), 264 endpointURL: endpoint, 272 key: 'secretAccessKey' 274 ...(region && {region: {name: s3SecretName, key: 'region'}}), 277 compression: 'snappy', maxParallel: 8, 281 Object.assign(cnPgCluster.spec, { 283 retentionPolicy: '30d', 288 if (restoreFromName) { 289 Object.assign(cnPgCluster.spec, { 292 name: `${name}-source`, 298 source: `${name}-source`, 299 // recoveryTarget: { // for point-in-time 300 // targetTime: "2025-04-19T01:28:28Z", 301 // backupID: '20250419T123008', 305 // bootstrap: { // works but prefer^ since this req spec backup name 308 // name: manualBackupName 315 resources.push(cnPgCluster) 317 // ScheduledBackup must be applied AFTER the Cluster (which contains the backup section) 318 // otherwise immediate:true triggers a backup before the cluster has backup config 319 // Only create ScheduledBackup when backupToName is set (not during restore mode) 320 if (backupBktName && backupToName && (action == 'delete' || enableSchedBackup) && ['apply', 'delete', 'info'].includes(action)) { 321 const schedBackupTmpl = { 322 apiVersion: 'postgresql.cnpg.io/v1', 323 kind: 'ScheduledBackup', 330 backupOwnerReference: 'cluster', 331 // only trigger immediate backup on first creation, not on updates 332 immediate: !existingSchedBackup, 333 schedule: '0 0 0 * * *', 339 const resActionRet = await resourcesAction({resources: resources as KubeResource[], action}) 341 // Auto-write local qs file for deterministic passwords 342 if (action == 'apply' && deterministicPw && !restoreFromName) { 343 const deterministicQs = buildDeterministicQs({name, password: deterministicPw}) 344 if (!localQsSecExists) { 345 betLog('auto-writing deterministic qs', {qsSecretPath}) 350 if (action == 'info') { 351 const allK8sResExist = !_.includes(resActionRet, undefined) 352 if (allK8sResExist && !localQsSecExists) {