🌳
pt0/deployF/cnpgF/syncCnPgDbF.mts
1import * as _ from 'lodash-es'
2import { scryptSync } from 'node:crypto'
17import { klusterCtx, type KubeResource } from "../k8sF/ctxF/klusterCtxF.mts"
40const cnpgPwSeedSecretName = 'cnpg-pw-seed'
42const getCnPgDeterministicPw = async (dbName: string) => {
43 const seed = await genPlainSecretIfMissing({secretName: cnpgPwSeedSecretName, autoYes: true})
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}) => {
54 const secret = await readKlustResource({
55 apiVersion: 'v1',
56 kind: 'Secret',
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
70 name: string
71 backupBktName?: string
72 restoreFromName?: string | boolean
73 backupToName?: string | boolean
74 imageName?: string
75 enableSchedBackup?: boolean
76 sizeGb: number
77 instances?: number
78 storageClass?: string
79 extraH?: Record<string, any>
80 catalogResource?: any
81 getBackDestinPath?: (args: {backupBktName: string, serverName: string}) => string
82 hibernate?: boolean
83 migrationOpts?: Record<string, unknown>
84 gpIsProd?: boolean
85 dbType?: string
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) => {
91 if (!isDirectlyRun(importMetaUrl)) return {importMetaUrl, name}
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}`
98 }
100 const action = getAction()
101 if (action === 'apply' && cliFlag('--history')) {
102 await applyHistoryGuard(importMetaUrl)
103 return
104 }
105 const qsName = name + '_qs'
106 const qsSecretPath = pathDownJoin(secretsDir, qsName)
107 deployDbCtx.enterWith({migrationOpts, gpIsProd, dbType, qsName, kyselyOutFile}) // ctx:clear
109 const deployClusterName = klusterCtx.getStore()?.cluster_name
110 if (action == 'apply' && deployClusterName) {
111 const {k8sCloudName} = getCurKlusterCfg(deployClusterName) as { k8sCloudName?: string }
112 validatePvcSizeForCloud(k8sCloudName, sizeGb)
113 }
115 const qs = async () => {
116 // if (resource.kind != 'Cluster') return
117 const qs = await getCnPgQs({name})
118 if (!qs) {
119 betLog('cnpg cluster notexist')
120 return
121 }
122 betLog('writeCnpgQs', {qsName}, absPathToPtPath(qsSecretPath), qs)
123 await write1File(qsSecretPath, qs)
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`
129 await write1File(pathDownJoin(secretsDir, `${name}-su_qs`), suQs)
130 }
131 }
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}`})
137 }
138 pods.runActionPerK8sRes = true
140 const runtests = async () => {
141 const allSuites = ['clitests'] as string[]
142 const cli = parseCli(mkRuntestsCli(allSuites))
143 if (cli.history) { printTestHistory({ep: importMetaUrl}); return }
144 const config = {
145 allSuites,
146 localAppCfg: {},
147 suiteConfigs: [{name: 'clitests', runner: runDbCliTestsSuite, deps: []}],
148 }
149 const opts = optsFromCli(cli, allSuites)
150 const result = await runTestsCore({config, opts})
151 if (!result.allPassed) process.exit(1)
152 }
153 runtests.cliDescript = 'run CLI behavior tests (--suite=clitests)'
155 availActionsCtx.enterWith({
156 ...availActionsCtx.getStore(),
157 qs, pods, runtests,
158 ..._.chain(getDbActions()).map((fnc: any, action) => {
159 const wrapped = () => runDbAction({action, qsName})
160 if (fnc.cliDescript) wrapped.cliDescript = fnc.cliDescript
161 return [action, wrapped]
162 }).fromPairs().value()
163 })
165 const qsWarn = () => betLog('!fileExists run qs to create', {qsSecretPath})
167 const localQsSecExists = await fileExists(qsSecretPath)
168 if (action == 'dbconsole' && !localQsSecExists) {
169 qsWarn(); return
170 }
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)
174 if (!isResourceAction(action) && await runAction({action})) return
176 // Deterministic password support - if seed exists, use deterministic pw & auto-write qs
177 const deterministicPw = await getCnPgDeterministicPw(name)
178 const appSecretName = `${name}-app`
180 const regcacheTarget = getRegcacheTargetHost()
181 if (imageName && regcacheTarget && await isRegcacheServing(regcacheTarget)) imageName = regcacheImgRef({image: imageName, cacheHost: regcacheTarget})
183 let cnPgCluster = {
184 apiVersion: 'postgresql.cnpg.io/v1',
185 kind: 'Cluster',
186 metadata: {
187 name, labels: cnpgClusterLabels(name),
188 ...(hibernate && {annotations: {'cnpg.io/hibernation': 'on'}}),
189 },
191 enableSuperuserAccess: true, // req to fix pg_restore
192 imageName,
193 instances: instances ?? 1,
194 priorityClassName: k8DbPriorityClass,
195 storage: {
196 size: `${sizeGb}Gi`,
197 storageClass,
198 },
199 postgresql: {
200 parameters: {
201 max_slot_wal_keep_size: `${_.round(sizeGb * 0.5)}GB`,
202 idle_in_transaction_session_timeout: '60000', // 60s - auto-kill stuck transactions
203 }
204 },
205 // managed roles/secrets for deterministic password
206 ...(deterministicPw && !restoreFromName ? {
207 managed: {
208 roles: [{
209 name: 'app',
210 login: true,
211 superuser: false,
212 createdb: false,
213 createrole: false,
214 passwordSecret: { name: appSecretName },
215 }],
216 },
217 } : {}),
218 }, extraH || {}),
219 }
220 const svcInfo = await getReqSvcInfo(name)
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`)
226 }
228 const resources: Record<string, any>[] = [dbPriorityClassTmpl()]
230 // Pre-create app secret with deterministic password (only for fresh dbs, not restores)
231 if (deterministicPw && !restoreFromName) {
232 resources.push(secretTemplate({name: appSecretName, secretsH: {
233 username: 'app',
234 password: deterministicPw,
235 }}))
236 }
238 resources.push(genericNodePortTmpl({name: name + '-nodeport',
239 nodePort: ptNodePortNo!,
240 svcPort: 5432, selector: cnpgPrimaryLabels(name)}))
241 if (catalogResource) {
242 resources.push(catalogResource)
243 }
245 if (backupBktName) {
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') {
251 resources.push(
252 secretTemplate({name: s3SecretName, secretsH: {
253 accessKeyId, secretAccessKey,
254 ...(region && {region}),
255 }})
256 )
257 }
259 throwIf(() => backupToName && restoreFromName, {backupBktName, restoreFromName})
260 const serverName = (backupToName || restoreFromName) as string
261 const barmanObjectStore = {
262 serverName,
263 destinationPath: getBackDestinPath({serverName, backupBktName: backupBktName!}),
264 endpointURL: endpoint,
265 s3Credentials: {
266 accessKeyId: {
267 name: s3SecretName,
268 key: 'accessKeyId'
269 },
270 secretAccessKey: {
271 name: s3SecretName,
272 key: 'secretAccessKey'
273 },
274 ...(region && {region: {name: s3SecretName, key: 'region'}}),
275 },
276 wal: {
277 compression: 'snappy', maxParallel: 8,
278 }
279 }
280 if (backupToName) {
281 Object.assign(cnPgCluster.spec, {
282 backup: {
283 retentionPolicy: '30d',
284 barmanObjectStore,
285 },
286 })
287 }
288 if (restoreFromName) {
289 Object.assign(cnPgCluster.spec, {
290 externalClusters: [
291 {
292 name: `${name}-source`,
293 barmanObjectStore
294 }
295 ],
296 bootstrap: {
297 recovery: {
298 source: `${name}-source`,
299 // recoveryTarget: { // for point-in-time
300 // targetTime: "2025-04-19T01:28:28Z",
301 // backupID: '20250419T123008',
302 // },
303 }
304 }
305 // bootstrap: { // works but prefer^ since this req spec backup name
306 // recovery: {
307 // backup: {
308 // name: manualBackupName
309 // }
310 // }
311 // },
312 })
313 }
314 }
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',
324 metadata: { name },
325 }
326 const existingSchedBackup = await readKlustResource(schedBackupTmpl)
327 resources.push({
328 ...schedBackupTmpl,
329 spec: {
330 backupOwnerReference: 'cluster',
331 // only trigger immediate backup on first creation, not on updates
332 immediate: !existingSchedBackup,
333 schedule: '0 0 0 * * *',
334 cluster: { name }
335 }
336 })
337 }
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})
346 await write1File(qsSecretPath, deterministicQs)
347 }
348 }
350 if (action == 'info') {
351 const allK8sResExist = !_.includes(resActionRet, undefined)
352 if (allK8sResExist && !localQsSecExists) {
353 qsWarn()
354 }
355 }