🌳
pt0/haS3/bin/mesh/epSync.mjs
4const do1demoCfg = {}
5const harv3demoCfg = {}
20import { execSync } from 'node:child_process'
27// HA S3 mesh: pts3serv + mesh sync sidecar on every cluster. barman writes
28// to its cluster-local svc (oryy://lay3kcxr:3472) — no failover config anywhere; the sidecar
29// union-pulls all peers over public ingress; deletes propagate from the single deleteAuthority.
30export const ptbackBucket = 'zatppz-7f18h989329g9i26758hyt50'
31const hetzbackBucket = 'hztxtzng-20510rm0iz0z0yr454c9u1s789a62w0t'
32const hetzbackHostname = `${hetzbackBucket}.harv2.demokluster.com`
33// single list driving envConf.buckets (sidecar cycle), secretNames (ptcont mounts), and the
34// sidecar's secret mounts — adding a bucket here is the only change needed to mesh-serve it.
35const meshBuckets = [ptbackBucket, hetzbackBucket]
36const name = 'pts3mesh', svcPort = 8333, diskDir = '/ptdisk'
37const deleteAuthorityCluster = 'harv2demo'
38// mesh failure alerts (meshAlertAI → noThrowNotifErr) email from the meshsync sidecar — needs the
39// smtp secret + exceptionemail-token mounted and demokFmEmailCfg envConf below to find them
41/** @type {Record<string, {cfg: any, hostname: string, sizeGb?: number, hostPath?: string, pvcStorClassName?: string}>} */
42const meshClusters = {
43 harv2demo: {cfg: harv2demoCfg, hostname: 'pts3back.harv2.demokluster.com', sizeGb: 60},
44 do1demo: {cfg: do1demoCfg, hostname: 'pts3back.ss.demokluster.com', sizeGb: 60},
45 hetzn1: {cfg: hetzn1Cfg, hostname: 'pts3back.hetzn1.demokluster.com', hostPath: '/mnt/hd16t'},
46 harv3demo: {cfg: harv3demoCfg, hostname: 'pts3back.harv3.demokluster.com', sizeGb: 60},
49// placement source of truth is bktPlacementH — keep meshClusters in lockstep
50for (const bucket of meshBuckets) {
51 const placement = bktPlacementH[bucket]
52 const sameNames = (a, b) => a.length === b.length && a.every(x => b.includes(x))
53 throwIf(() => !placement || !sameNames(Object.keys(meshClusters), placement.clusters ?? []) || placement.deleteAuthority !== deleteAuthorityCluster,
54 {bucket, placement, deleteAuthorityCluster})
57const genMeshBktSecret = async () => {
58 const desiredPeers = Object.fromEntries(Object.entries(meshClusters).map(([c, cl]) => [c, `https://${cl.hostname}`]))
59 const desiredEndpoints = Object.fromEntries(Object.keys(meshClusters).map(c => [c, `http://${name}:${svcPort}`]))
60 for (const bucket of meshBuckets) {
61 const existing = /** @type {any} */ (getOptS3Cfg({bucket_name: bucket}))
62 if (!existing) {
63 // never fresh-gen adopted buckets (e.g. hetzback): would rotate live creds and silently break
64 // clients holding the old pair (barman's deployed cnpg-s3creds secret).
65 throwIf(() => bucket !== ptbackBucket, {bucket})
67 secretName: secretForBkt(bucket),
68 autoYes: true,
69 genValue: () => ({
70 accessKeyId: bucket,
71 secretAccessKey: randomUrlSafeBytes(32),
72 endpoint: `https://${meshClusters[deleteAuthorityCluster].hostname}`,
73 region: 'us-east-1',
74 s3ForcePathStyle: true,
75 useAwsv4Sig: true,
76 cluster_endpoints: desiredEndpoints,
77 meshPeers: desiredPeers,
78 meshDeleteAuthority: deleteAuthorityCluster,
79 }),
80 })
81 continue
82 }
83 // merge any new clusters/authority into the existing cfg (preserves creds, adds mesh fields)
84 const needsPeers = Object.keys(desiredPeers).some(c => !existing.meshPeers?.[c])
85 const needsEndpoints = Object.keys(desiredEndpoints).some(c => !existing.cluster_endpoints?.[c])
86 const needsAuthority = existing.meshDeleteAuthority !== deleteAuthorityCluster
87 if (needsPeers || needsEndpoints || needsAuthority) {
88 const fs = await import('node:fs')
89 const {secretsDir} = await import('../../../serverF/secretsDirF.mts')
90 const {pathDownJoin} = await import('../../../serverF/pathF.mts')
91 const cfgPath = pathDownJoin(secretsDir, secretForBkt(bucket))
92 const merged = {...existing, cluster_endpoints: {...existing.cluster_endpoints, ...desiredEndpoints}, meshPeers: {...existing.meshPeers, ...desiredPeers}, meshDeleteAuthority: deleteAuthorityCluster}
93 fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2) + '\n')
94 console.log(`updated ${secretForBkt(bucket)}: added ${Object.keys(meshClusters).filter(c => !existing.meshPeers?.[c]).join(', ')} to meshPeers`)
95 }
96 }
99const action = getAction()
100await genMeshBktSecret()
101getGenPlainSec('exceptionemail-token', 8)
103const runtestsAction = () => {
104 execSync('ptnode pt0/haS3/bktF/epMeshLiveTestsF.mts', {stdio: 'inherit'})
105 process.exit(0)
107runtestsAction.cliDescript = 'run e2e tests against live mesh endpoints (~4min)'
108availActionsCtx.enterWith({...availActionsCtx.getStore(), runtests: runtestsAction})
110// bundle image is tiny (~2MB over the cached node base) → local docker build-once + push a thin
111// layer to each dockreg (vs 3 separate ~40min kaniko jobs). anchored here so no --withDocker flag.
112const onlyCluster = process.env.PTMESH_ONLY // single-cluster ops (e.g. PTMESH_ONLY=harv3demo delete)
113const notifSmtpSec = demokFmEmailCfg.envConf.secretsMapping.smtpSecretName
114await dockBuildCtx.run({dockName: 'docker'}, async () => {
115 for (const [clusterName, cl] of Object.entries(meshClusters)) {
116 if (onlyCluster && clusterName !== onlyCluster) continue
117 enterKlusterCtxs(cl.cfg, {importMetaUrl: import.meta.url})
118 appCfgCtx.enterWith({...cl.cfg, action, name})
120 const dataVolName = cl.hostPath ? 'hd16t' : name
121 nextContCtx.enterWith({ // ctx:clear
122 command: ['node', 'bundle.mjs', 'server'],
123 ports: [{containerPort: svcPort}],
124 })
126 // sidecar needs the same cfg mounts as ptcont (PT_DIR=/approot → /approot/data paths)
127 const modVolsFnc = async ({volumes, volumeMounts, extraContainers, image}) => {
128 if (cl.hostPath) {
129 volumes.push({name: 'hd16t', hostPath: {path: cl.hostPath}})
130 volumeMounts.push({name: 'hd16t', mountPath: diskDir})
131 }
132 extraContainers.push({
133 name: 'meshsync',
134 image,
135 command: ['node', 'bundle.mjs', 'meshsync'],
136 volumeMounts: [
137 {name: dataVolName, mountPath: diskDir},
138 {name: `kube-envconf-${name}`, mountPath: `${dockerApproot}/data/envConf.json`, subPath: 'envConfS'},
139 ...meshBuckets.map(b => ({name: secretNameToK8sVolName(secretForBkt(b)), mountPath: `${dockerApproot}/data/secrets2/${secretForBkt(b)}`, subPath: secretForBkt(b)})),
140 ...[notifSmtpSec, 'exceptionemail-token'].map(s => ({name: secretNameToK8sVolName(s), mountPath: `${dockerApproot}/data/secrets2/${s}`, subPath: s})),
141 ],
142 })
143 }
144 ptKubeCtx.enterWith({...ptKubeCtx.getStore(), modVolsFnc})
146 const bigBodyIngressAnnotH = {
147 'nginx.ingress.kubernetes.io/proxy-body-size': '0',
148 'nginx.ingress.kubernetes.io/proxy-read-timeout': '99999',
149 'nginx.ingress.kubernetes.io/proxy-send-timeout': '99999',
150 'nginx.ingress.kubernetes.io/client-body-buffer-size': '512m',
151 }
155 importMetaUrl: import.meta.url,
156 name,
157 kube_extHostname: cl.hostname,
158 svcPortNo: svcPort,
159 appPath: 'pt0/haS3',
160 bundleEntryPtPath: 'pt0/haS3/main.mjs',
161 envConf: {
162 pts3serv: {buckets: meshBuckets, diskDir, port: svcPort, clusterName, deleteAuthorityCluster},
163 ...demokFmEmailCfg.envConf,
164 },
165 deployStrategy: {type: 'Recreate'},
166 secretNames: [...meshBuckets.map(secretForBkt), notifSmtpSec, 'exceptionemail-token'],
167 ...(cl.sizeGb && {mountPath: diskDir, sizeGb: cl.sizeGb, ...(cl.pvcStorClassName && {pvcStorClassName: cl.pvcStorClassName})}),
168 ingressAnnotationsH: bigBodyIngressAnnotH,
169 copyPathsA: [
170 ptAnchorPath('pt0/haS3/server.mjs'),
171 ptAnchorPath('pt0/haS3/meshSync.mjs'),
172 ptAnchorPath('pt0/haS3/package.json'),
173 ],
174 })
176 // hetzback's public hostname moves from minio to the authority's mesh svc. created alongside the
177 // deploy; nginx's oldest-ingress-wins keeps it inert until minio's ingress is deleted (cutover).
178 if (clusterName === deleteAuthorityCluster) {
179 await resourcesAction({resources: [
180 genericIngressTmpl({name: `${name}-hetzback`, svcName: name, portNo: svcPort, hostname: hetzbackHostname, ingressAnnotationsH: bigBodyIngressAnnotH}),
181 ], action, cluster_name: clusterName})
182 }
183 }
184})