@leverege/build-tools 2.64.1 โ†’ 2.65.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leverege/build-tools",
3
- "version": "2.64.1",
3
+ "version": "2.65.1",
4
4
  "description": "A collection of build / support tools for Leverege developers",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  "chart-to-museum": "src/chart-to-museum.mjs",
21
21
  "chart-to-registry": "src/chart-to-registry.mjs",
22
22
  "circleate": "src/circleate.sh",
23
+ "clone-cnpg-from-snapshot": "src/clone-cnpg-from-snapshot.mjs",
23
24
  "decrypt-secrets": "src/decrypt-secrets.sh",
24
25
  "dirty-git": "src/dirty-git.sh",
25
26
  "dockreate": "src/dockreate.sh",
@@ -64,23 +65,25 @@
64
65
  "chalk": "^5.4.1",
65
66
  "command-line-args": "^6.0.1",
66
67
  "command-line-usage": "^7.0.3",
68
+ "commander": "^13.1.0",
67
69
  "deepmerge": "^4.3.1",
68
70
  "enquirer": "^2.4.1",
69
71
  "execa": "^9.5.2",
70
72
  "glob": "^11.0.1",
71
73
  "handlebars": "^4.7.8",
72
- "inquirer": "^12.4.3",
74
+ "ignore": "^7.0.3",
75
+ "inquirer": "^12.5.2",
73
76
  "js-yaml": "^4.1.0",
74
77
  "jsdoc": "^4.0.4",
75
78
  "ms": "^2.1.3",
76
79
  "npm-registry-fetch": "^18.0.2",
80
+ "ora": "^8.2.0",
77
81
  "package-up": "^5.0.0",
78
- "parse-gitignore": "^2.0.0",
79
82
  "readline-sync": "^1.4.10",
80
83
  "semver": "^7.7.1",
81
84
  "simple-git": "^3.27.0",
82
85
  "toml": "^3.0.0",
83
- "zx": "^8.4.0"
86
+ "zx": "^8.5.0"
84
87
  },
85
88
  "devDependencies": {
86
89
  "@leverege/eslint-config-leverege": "^5.0.1",
package/src/Utils.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
1
+ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import url from 'node:url'
4
4
 
5
5
  import chalk from 'chalk'
6
6
  import { $ } from 'execa'
7
+ import ignore from 'ignore'
7
8
  import inquirer from 'inquirer'
8
9
  import { glob } from 'glob'
9
10
  import { packageUp } from 'package-up'
@@ -89,6 +90,43 @@ export const shellCmd = async ( cmdstr, opts = {} ) => {
89
90
  }
90
91
  }
91
92
 
93
+ export const mkdirSafe = ( dir ) => {
94
+ try {
95
+ fs.mkdirSync( dir, { recursive : true } ) // eslint-disable-line security/detect-non-literal-fs-filename
96
+ } catch ( error ) {
97
+ condir( { error }, '<==fs.mkdir failed?' )
98
+ warning( { error }, `Could not create output dir: ${dir}` )
99
+ }
100
+ }
101
+
102
+ export const getDirectories = ( { path : srcPath = process.cwd(), respectGitignore = false } = {} ) => {
103
+ let ig = null
104
+
105
+ if ( respectGitignore ) {
106
+ const gitignorePath = path.join( srcPath, '.gitignore' )
107
+
108
+ /* eslint-disable security/detect-non-literal-fs-filename */
109
+ if ( fs.existsSync( gitignorePath ) ) {
110
+ const gitignoreContent = fs.readFileSync( gitignorePath, 'utf8' )
111
+ ig = ignore().add( gitignoreContent )
112
+ }
113
+ /* eslint-enable security/detect-non-literal-fs-filename */
114
+ }
115
+
116
+ return fs.readdirSync( srcPath, { withFileTypes : true } ) // eslint-disable-line security/detect-non-literal-fs-filename
117
+ .filter( ( dirent ) => {
118
+ if ( !dirent.isDirectory() ) return false
119
+ return !( ig?.ignores( dirent.name ) )
120
+ } )
121
+ .map( dirent => dirent.name )
122
+ }
123
+
124
+ export const xgetDirectories = ( srcPath = process.cwd() ) => {
125
+ return fs.readdirSync( srcPath, { withFileTypes : true } ) // eslint-disable-line security/detect-non-literal-fs-filename
126
+ .filter( dirent => dirent.isDirectory() )
127
+ .map( dirent => dirent.name )
128
+ }
129
+
92
130
  // returns YYYMMDD-hhmm
93
131
  export const getDateTimestamp = () => {
94
132
  const fullDTS = new Date().toISOString().replace( /[-T:]/g, '' ).slice( 0, 14 )
@@ -158,9 +196,9 @@ export const gitRepoIsDirty = async () => {
158
196
 
159
197
  export const parseJsonFile = async ( jsonFile ) => {
160
198
  /* eslint-disable security/detect-non-literal-fs-filename */
161
- if ( existsSync( jsonFile ) ) {
199
+ if ( fs.existsSync( jsonFile ) ) {
162
200
  try {
163
- const json = JSON.parse( readFileSync( jsonFile, 'utf8' ) )
201
+ const json = JSON.parse( fs.readFileSync( jsonFile, 'utf8' ) )
164
202
  return json
165
203
  } catch ( error ) {
166
204
  throw new Error( `${error} in ${jsonFile}` )
@@ -369,13 +407,13 @@ https://console.cloud.google.com/artifacts/browse/leverege-registry?project=leve
369
407
 
370
408
  export const parseHelmChart = async ( helmroot = './helm' ) => {
371
409
  /* eslint-disable security/detect-non-literal-fs-filename */
372
- if ( !existsSync( helmroot ) ) { return undefined }
373
- const chartFiles = readdirSync( helmroot, { encoding : 'utf8', recursive : true } )
374
- const chartYaml = YAML.load( readFileSync( `${helmroot}/Chart.yaml`, 'utf8' ) )
410
+ if ( !fs.existsSync( helmroot ) ) { return undefined }
411
+ const chartFiles = fs.readdirSync( helmroot, { encoding : 'utf8', recursive : true } )
412
+ const chartYaml = YAML.load( fs.readFileSync( `${helmroot}/Chart.yaml`, 'utf8' ) )
375
413
  const chartName = chartYaml.name
376
414
  const chartVersion = chartYaml.version
377
415
  const chartPackage = `${chartName}-${chartVersion}.tgz`
378
- const valuesYaml = YAML.load( readFileSync( `${helmroot}/values.yaml`, 'utf8' ) )
416
+ const valuesYaml = YAML.load( fs.readFileSync( `${helmroot}/values.yaml`, 'utf8' ) )
379
417
  debug( valuesYaml, '<== helm/values.yaml' )
380
418
  if ( !valuesYaml.image ) {
381
419
  errorExit( 'Error: legacy helm chart detected - must upgrade to latest ignition template to proceed' )
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import yaml from 'js-yaml'
5
+
6
+ const SOURCE_URL = 'https://raw.githubusercontent.com/samber/awesome-prometheus-alerts/master/dist/rules/kubernetes/kubestate-exporter.yml'
7
+ const OUTPUT_FILE = 'converted-alerts.yaml'
8
+
9
+ async function fetchAndConvert() {
10
+ /* eslint-disable no-console */
11
+ try {
12
+ console.log( `Fetching YAML from ${SOURCE_URL}...` )
13
+ const response = await fetch( SOURCE_URL )
14
+ if ( !response.ok ) throw new Error( `Failed to fetch: ${response.statusText}` )
15
+
16
+ const rawYaml = await response.text()
17
+ const parsedYaml = yaml.load( rawYaml )
18
+ if ( !parsedYaml.groups ) throw new Error( 'Invalid YAML structure, missing "groups"' )
19
+
20
+ // Reformat into PrometheusRule CRD format
21
+ const prometheusRule = {
22
+ apiVersion : 'monitoring.coreos.com/v1',
23
+ kind : 'PrometheusRule',
24
+ metadata : {
25
+ name : 'node-alerts', // Adjust based on the source
26
+ },
27
+ spec : {
28
+ groups : parsedYaml.groups.map( group => ( {
29
+ name : `${group.name}-rules`,
30
+ rules : group.rules,
31
+ } ) ),
32
+ },
33
+ }
34
+
35
+ // Convert back to YAML with proper formatting
36
+ const formattedYaml = yaml.dump( prometheusRule, { noRefs : true, indent : 0 } )
37
+ fs.writeFileSync( OUTPUT_FILE, formattedYaml, 'utf8' )
38
+ console.log( `Converted YAML written to ${OUTPUT_FILE}` )
39
+ } catch ( error ) {
40
+ console.error( 'Error processing YAML:', error.message )
41
+ }
42
+ }
43
+
44
+ fetchAndConvert()
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+
3
+ // clone-cnpg-from-snapshot.mjs
4
+
5
+ import fs from 'node:fs/promises'
6
+ import path from 'node:path'
7
+ import ora from 'ora'
8
+
9
+ import { program } from 'commander'
10
+ import chalk from 'chalk'
11
+ import YAML from 'js-yaml'
12
+ import {
13
+ // condir,
14
+ debug,
15
+ // err,
16
+ errorExit,
17
+ log,
18
+ mkdirSafe,
19
+ shellCmd,
20
+ warning,
21
+ } from './Utils.mjs'
22
+
23
+ program
24
+ .requiredOption( '--snapshot <name>', 'CNPG VolumeSnapshot name' )
25
+ .requiredOption( '--source-project <project>', 'Source GCP project ID' )
26
+ .requiredOption( '--dest-project <project>', 'Destination GCP project ID' )
27
+ .requiredOption( '--dest-region <region>', 'Destination region, e.g. us-east4' )
28
+ .option( '--artifacts-only', 'Only generate the volume snapshot YAML, script and readme' )
29
+ .option( '--dry-run', 'Show commands but don\'t run them' )
30
+ .option( '--execute', 'Perform real actions (required to run gcloud commands)' )
31
+ .option( '--cleanup', 'Remove temporary GCP resources after snapshot creation' )
32
+ .option( '--cleanup-only', 'Only do removal of temporary GCP resources and exit' )
33
+ .parse()
34
+
35
+ const options = program.opts()
36
+
37
+ const {
38
+ execute,
39
+ cleanup,
40
+ cleanupOnly,
41
+ destProject,
42
+ destRegion,
43
+ dryRun,
44
+ snapshot,
45
+ sourceProject,
46
+ } = options
47
+
48
+ const k8sSnapshotName = `${snapshot}-final`
49
+ const tempDisk = `temp-from-${snapshot}`
50
+ const tempImage = `img-from-${snapshot}`
51
+ const intermediateSnapshot = `source-snapshot-${sourceProject}-${snapshot}`
52
+ const destSnapshot = `snapshot-${sourceProject}-${snapshot}`
53
+ const tempDiskFromImage = `temp-from-img-${snapshot}`
54
+ const destZone = `${destRegion}-a` // infer from region - implies "-a" zone always available
55
+
56
+ // directory for receiving the YAML necessary for defining the snapshot class,
57
+ // content and volume
58
+ const emitYamlDir = `cnpg-clone-${snapshot}`
59
+
60
+ let sourceZone // set and used in main(), also needed by cleanupResouces
61
+
62
+ const getPrettyTimeNow = () => {
63
+ const now = new Date()
64
+ const [ month, day, year ] = now.toLocaleDateString( 'en-US' ).split( '/' )
65
+ const time = now.toLocaleTimeString( 'en-US', { hour12 : true } )
66
+ return `${year}-${month.padStart( 2, '0' )}-${day.padStart( 2, '0' )} ${time}`
67
+ }
68
+
69
+ const getElapsedTime = ( startMs ) => {
70
+ const deltaSec = Math.floor( ( Date.now() - startMs ) / 1000 )
71
+ const minutes = Math.floor( deltaSec / 60 ).toString().padStart( 2, '0' )
72
+ const seconds = ( deltaSec % 60 ).toString().padStart( 2, '0' )
73
+ return `${minutes}:${seconds}`
74
+ }
75
+
76
+ // equivalent to Unix touch command to create an empty file
77
+ const touchFile = async ( filename ) => {
78
+ if ( filename ) {
79
+ const handle = await fs.open( filename, 'w' ) // eslint-disable-line security/detect-non-literal-fs-filename
80
+ await handle.close()
81
+ }
82
+ }
83
+
84
+ // generates an executable script to use for defining the volume on k8s
85
+ const writeApplyScript = async ( dir ) => {
86
+ const applyScript = `#!/ust/bin/env bash
87
+ #
88
+ # Apply VolumeSnapshot resources prior to deploying the CNPG cluster
89
+ #
90
+ kubectl apply -f ${dir}/volumesnapshotclass.yaml
91
+ kubectl apply -f ${dir}/volumesnapshotcontent.yaml
92
+ kubectl apply -f ${dir}/volumesnapshot.yaml
93
+ `
94
+
95
+ const scriptPath = path.join( dir, 'apply-volumesnapshots.sh' )
96
+ await fs.writeFile( scriptPath, applyScript, { mode : 0o755 } ) // eslint-disable-line security/detect-non-literal-fs-filename
97
+ log( chalk.green( ' [โœ“] Helper script written:' ), scriptPath )
98
+ }
99
+
100
+ const writeReadme = async ( dir ) => {
101
+ const readmePath = path.join( dir, 'readme.txt' )
102
+ const content = `This directory contains the Kubernetes manifests needed to bootstrap a new CNPG cluster
103
+ using a cloned volume snapshot from another GCP project.
104
+
105
+ Files:
106
+ - volumesnapshotclass.yaml (optional, use if the class doesn't already exist)
107
+ - volumesnapshotcontent.yaml (refers to the GCE snapshot copied into this project)
108
+ - volumesnapshot.yaml (used by CNPG to bootstrap from the volume snapshot)
109
+ - apply-volumesnapshots.sh (helper script to apply these manifests in correct order)
110
+ - .nohelm (marker file for overwhelm to skip values.yaml generation)
111
+
112
+ To deploy the volume snapshots to your Kubernetes cluster:
113
+
114
+ $ ./apply-volumesnapshots.sh
115
+
116
+ Once applied, reference the following VolumeSnapshot in your CNPG cluster bootstrap config:
117
+
118
+ spec.bootstrap.recovery.volumeSnapshot.volumeSnapshotName: ${k8sSnapshotName}
119
+
120
+ Then deploy your CNPG cluster normally. Validate the cluster startup:
121
+
122
+ $ kubectl get pods -n cnpg-operands
123
+
124
+ Enjoy your cloned cluster โœจ
125
+ `
126
+
127
+ await fs.writeFile( readmePath, content ) // eslint-disable-line security/detect-non-literal-fs-filename
128
+ log( chalk.green( ' [โœ“] README written:' ), readmePath )
129
+ }
130
+
131
+ const writeYaml = async ( filename, data ) => {
132
+ const fullPath = path.join( emitYamlDir, filename )
133
+ const yaml = YAML.dump( data )
134
+ await fs.writeFile( fullPath, yaml ) // eslint-disable-line security/detect-non-literal-fs-filename
135
+ log( chalk.green( ' [โœ“] YAML written:' ), fullPath )
136
+ }
137
+
138
+ const getVolumeSnapshotContentName = async ( snapshotName ) => {
139
+ const json = await shellCmd(
140
+ 'kubectl get volumesnapshotcontents.snapshot.storage.k8s.io -o json'
141
+ )
142
+
143
+ const data = JSON.parse( json )
144
+ const match = data.items.find(
145
+ item => item?.spec?.volumeSnapshotRef?.name === snapshotName
146
+ )
147
+
148
+ return match?.metadata?.name || ''
149
+ }
150
+
151
+ const getVolumeHandle = async ( vscName ) => {
152
+ const cmd = `kubectl get volumesnapshotcontent ${vscName} -o jsonpath='{.spec.source.volumeHandle}'`
153
+ const handle = ( await shellCmd( cmd ) ).replace( /'/g, '' )
154
+ debug( { handle }, '<==getVolumeHandle' )
155
+ return handle
156
+ }
157
+
158
+ // this function is responsible for emitting all of the artifacts for the creation
159
+ // of the k8s volume that will then be used to bootstrap the cnpg cluster
160
+ const emitCloningArtifacts = async () => {
161
+ try {
162
+ const snapshotYamlRaw = await shellCmd(
163
+ `kubectl get volumesnapshot ${snapshot} -n cnpg-operands -o json`
164
+ )
165
+
166
+ const snapshotData = JSON.parse( snapshotYamlRaw )
167
+ const annotations = snapshotData?.metadata?.annotations || {}
168
+ const labels = snapshotData?.metadata?.labels || {}
169
+
170
+ // Only keep CNPG-related annotations
171
+ const filteredAnnotations = Object.fromEntries(
172
+ Object.entries( annotations ).filter( ( [ key ] ) => key.startsWith( 'cnpg.io/' ) || key.startsWith( 'snapshotOf' )
173
+ )
174
+ )
175
+
176
+ // Only keep CNPG-related labels
177
+ const filteredLabels = Object.fromEntries(
178
+ Object.entries( labels ).filter( ( [ key ] ) => key.startsWith( 'cnpg.io/' ) || key.startsWith( 'snapshotOf' )
179
+ )
180
+ )
181
+
182
+ const snapshotClass = {
183
+ apiVersion : 'snapshot.storage.k8s.io/v1',
184
+ kind : 'VolumeSnapshotClass',
185
+ metadata : { name : 'cnpg-preprovisioned' },
186
+ driver : 'pd.csi.storage.gke.io',
187
+ deletionPolicy : 'Retain'
188
+ }
189
+
190
+ const snapshotContent = {
191
+ apiVersion : 'snapshot.storage.k8s.io/v1',
192
+ kind : 'VolumeSnapshotContent',
193
+ metadata : { name : k8sSnapshotName },
194
+ spec : {
195
+ deletionPolicy : 'Retain',
196
+ driver : 'pd.csi.storage.gke.io',
197
+ source : {
198
+ snapshotHandle : `projects/${destProject}/global/snapshots/${destSnapshot}`,
199
+ },
200
+ volumeSnapshotClassName : 'cnpg-preprovisioned',
201
+ volumeSnapshotRef : {
202
+ name : k8sSnapshotName,
203
+ namespace : 'cnpg-operands',
204
+ },
205
+ },
206
+ }
207
+
208
+ const snapshotYaml = {
209
+ apiVersion : 'snapshot.storage.k8s.io/v1',
210
+ kind : 'VolumeSnapshot',
211
+ metadata : {
212
+ name : k8sSnapshotName,
213
+ namespace : 'cnpg-operands',
214
+ annotations : filteredAnnotations,
215
+ labels : filteredLabels,
216
+ },
217
+ spec : {
218
+ volumeSnapshotClassName : 'cnpg-preprovisioned',
219
+ source : {
220
+ volumeSnapshotContentName : k8sSnapshotName,
221
+ },
222
+ },
223
+ }
224
+
225
+ mkdirSafe( emitYamlDir )
226
+
227
+ await writeYaml( 'volumesnapshotclass.yaml', snapshotClass )
228
+ await writeYaml( 'volumesnapshotcontent.yaml', snapshotContent )
229
+ await writeYaml( 'volumesnapshot.yaml', snapshotYaml )
230
+ await touchFile( path.join( emitYamlDir, '.nohelm' ) )
231
+ await writeApplyScript( emitYamlDir )
232
+ await writeReadme( emitYamlDir )
233
+
234
+ } catch ( err ) {
235
+ errorExit( `Unable to generate VolumeSnapshot YAML: ${err.message}` )
236
+ }
237
+ }
238
+
239
+ // runs a CLI command with a silly little spinner in an attempt to convince
240
+ // the user its worth waiting around for while also being somewhere tolerant
241
+ // of certain faults which are probably safe to ignore
242
+ const runCommand = async ( commandParts, opts = {} ) => {
243
+ const cmdIndent = ' '
244
+ const optIndent = ' '
245
+ const rendered = commandParts
246
+ .map( ( part, idx ) => {
247
+ const isLast = idx === commandParts.length - 1
248
+ return `${idx === 0 ? cmdIndent : optIndent}${part}${isLast ? '' : ' \\'}`
249
+ } )
250
+ .join( '\n' )
251
+
252
+ log( ` \n ${chalk.yellow( ` ๐Ÿš€ Launching @ ${getPrettyTimeNow()}` )}\n${chalk.cyan( rendered )}` )
253
+
254
+ if ( dryRun ) { return }
255
+
256
+ const fullCommand = commandParts.join( ' ' )
257
+ const spinner = ora( {
258
+ text : chalk.green( ' Executing' ),
259
+ prefixText : ' ',
260
+ spinner : 'dots13',
261
+ } ).start()
262
+
263
+ try {
264
+ const startTimeMs = Date.now()
265
+ await shellCmd( fullCommand, opts )
266
+ spinner.succeed( chalk.green( ` Completed @ ${getPrettyTimeNow()} (${getElapsedTime( startTimeMs )})` ) )
267
+ } catch ( error ) {
268
+ const nonFatal = [
269
+ 'already exists',
270
+ 'is already being used',
271
+ 'Cannot reuse name',
272
+ 'already exists',
273
+ ]
274
+
275
+ const msg = error.stderr || ''
276
+
277
+ if ( nonFatal.some( warnError => msg.includes( warnError ) ) ) {
278
+ log( { error }, '<==Full Error' )
279
+ warning( 'non fatal error ignored' )
280
+ spinner.succeed( chalk.yellow( ' Warned' ) )
281
+ return
282
+ }
283
+
284
+ spinner.fail( chalk.red( ' Failed' ) )
285
+ if ( cleanupOnly ) { return }
286
+
287
+ throw error
288
+ }
289
+ }
290
+
291
+ const main = async () => {
292
+ if ( !execute && !dryRun ) {
293
+ errorExit( 'Must specify either --dry-run or --execute' )
294
+ }
295
+
296
+ if ( dryRun ) {
297
+ warning( 'Dry run enabled โ€” no gcloud commands will run.' )
298
+ }
299
+
300
+ // STEP 1: Resolve volume handle from k8s VolumeSnapshotContent
301
+ log( `\n[STEP 1] ๐Ÿ“ฆ Snapshot clone process starting: ${snapshot}` )
302
+ const vscName = await getVolumeSnapshotContentName( snapshot )
303
+ if ( !vscName ) errorExit( `Unable to locate VolumeSnapshotContent for ${snapshot}` )
304
+
305
+ const volumeHandle = await getVolumeHandle( vscName )
306
+ if ( !volumeHandle ) errorExit( `Unable to locate volumeHandle for ${vscName}` )
307
+
308
+ // expect volumeHandle to be in this format:
309
+ // projects/<project>/zones/<zone>/disks/<diskName>
310
+ const volumeParts = volumeHandle.split( '/' )
311
+ const diskName = volumeParts[5]
312
+ sourceZone = volumeParts[3] // sourceZone also needed in cleanupResources
313
+
314
+ log( `\n [โœ“] Resolved disk: ${chalk.cyan( diskName )}` )
315
+ log( ` from handle: ${chalk.cyan( volumeHandle )}` )
316
+ log( ` in zone: ${chalk.cyan( sourceZone )}\n` )
317
+
318
+ // if all we want are the artifacts
319
+ if ( options.artifactsOnly ) {
320
+ await emitCloningArtifacts()
321
+ process.exit( 0 )
322
+ }
323
+
324
+ log( `\n[STEP 2] ๐Ÿ“ธ Creating GCE snapshot from source disk [${diskName}]` )
325
+ await runCommand( [
326
+ `gcloud compute disks snapshot ${diskName}`,
327
+ `--snapshot-names=${intermediateSnapshot}`,
328
+ `--zone=${sourceZone}`,
329
+ `--project=${sourceProject}`,
330
+ `--storage-location=${destRegion}`,
331
+ ] )
332
+
333
+ log( '\n[STEP 3] ๐Ÿ’ฝ Creating temp disk from snapshot...' )
334
+ await runCommand( [
335
+ `gcloud compute disks create ${tempDisk}`,
336
+ `--source-snapshot=${intermediateSnapshot}`,
337
+ `--zone=${sourceZone}`,
338
+ `--project=${sourceProject}`,
339
+ ] )
340
+
341
+ log( '\n[STEP 4] ๐Ÿ–ผ๏ธ Creating image from temp disk...' )
342
+ await runCommand( [
343
+ `gcloud compute images create ${tempImage}`,
344
+ `--source-disk=${tempDisk}`,
345
+ `--source-disk-zone=${sourceZone}`,
346
+ `--storage-location=${destRegion}`,
347
+ `--project=${sourceProject}`,
348
+ ] )
349
+
350
+ log( '\n[STEP 5] ๐Ÿ” Granting image access to destination project...' )
351
+ await runCommand( [
352
+ `gcloud compute images add-iam-policy-binding ${tempImage}`,
353
+ `--member=serviceAccount:${destProject}@appspot.gserviceaccount.com`,
354
+ '--role=roles/compute.imageUser',
355
+ `--project=${sourceProject}`,
356
+ ] )
357
+
358
+ log( '\n[STEP 6] ๐Ÿšš Importing image into destination project...' )
359
+ await runCommand( [
360
+ `gcloud compute images create ${tempImage}`,
361
+ `--source-image=${tempImage}`,
362
+ `--source-image-project=${sourceProject}`,
363
+ `--storage-location=${destRegion}`,
364
+ `--project=${destProject}`,
365
+ ] )
366
+
367
+ log( '\n[STEP 7] ๐ŸงŠ Creating temp disk from image in destination project...' )
368
+ await runCommand( [
369
+ `gcloud compute disks create ${tempDiskFromImage}`,
370
+ `--image=${tempImage}`,
371
+ `--image-project=${destProject}`,
372
+ `--zone=${destZone}`,
373
+ `--project=${destProject}`,
374
+ ] )
375
+
376
+ log( '\n[STEP 8] ๐ŸงŠ Creating snapshot in destination project...' )
377
+ await runCommand( [
378
+ `gcloud compute snapshots create ${destSnapshot}`,
379
+ `--source-disk=${tempDiskFromImage}`,
380
+ `--source-disk-zone=${destZone}`,
381
+ `--storage-location=${destRegion}`,
382
+ `--project=${destProject}`,
383
+ ] )
384
+
385
+ await emitCloningArtifacts()
386
+
387
+ log( `
388
+ โœ… Done. Snapshot: ${chalk.cyan( destSnapshot )}
389
+ Recovery YAMLs written to: ${chalk.cyan( emitYamlDir )}
390
+ Run '${chalk.yellow( './apply-recovery-yaml.sh' )}' to prep the cluster for recovery.
391
+ ` )
392
+ }
393
+
394
+ const cleanupResources = async () => {
395
+ log( '\n[STEP 9] ๐Ÿงน Cleaning up temporary resources (unwinding the stack)...' )
396
+
397
+ const commands = [
398
+ [ // STEP 7: Remove temp disk from image in destination project
399
+ `gcloud compute disks delete ${tempDiskFromImage}`,
400
+ `--zone=${destZone}`,
401
+ `--project=${destProject}`,
402
+ '--quiet'
403
+ ],
404
+
405
+ [ // STEP 6: Remove copied image from destination project
406
+ `gcloud compute images delete ${tempImage}`,
407
+ `--project=${destProject}`,
408
+ '--quiet'
409
+ ],
410
+
411
+ [ // STEP 4: Remove image from source project
412
+ `gcloud compute images delete ${tempImage}`,
413
+ `--project=${sourceProject}`,
414
+ '--quiet'
415
+ ],
416
+
417
+ [ // STEP 3: Remove temp disk from source project
418
+ `gcloud compute disks delete ${tempDisk}`,
419
+ `--zone=${sourceZone}`,
420
+ `--project=${sourceProject}`,
421
+ '--quiet'
422
+ ],
423
+
424
+ [ // STEP 2: Remove source snapshot
425
+ `gcloud compute snapshots delete ${intermediateSnapshot}`,
426
+ `--project=${sourceProject}`,
427
+ '--quiet'
428
+ ],
429
+ ]
430
+
431
+ await Promise.all(
432
+ commands.map( cmdParts => runCommand( cmdParts ).catch( ( err ) => {
433
+ warning( `Non-fatal error during cleanup: ${err.message}` )
434
+ } ) )
435
+ )
436
+
437
+ log( chalk.green( ' [โœ“] Parallel complete. Final GCE snapshot retained for CNPG bootstrapping.' ) )
438
+ }
439
+
440
+ if ( cleanupOnly ) {
441
+ await cleanupResources()
442
+ process.exit( 1 )
443
+ }
444
+
445
+ await main().catch( err => errorExit( err.message || err ) )
446
+
447
+ if ( cleanup ) {
448
+ await cleanupResources()
449
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import yaml from 'js-yaml'
6
+ import { Command } from 'commander'
7
+ import chalk from 'chalk'
8
+
9
+ import { log, err } from './Utils.mjs'
10
+
11
+ const program = new Command()
12
+
13
+ program
14
+ .name( 'fetch-alerts' )
15
+ .description( 'Fetch a Prometheus alert YAML file and convert it to a PrometheusRule CRD.' )
16
+ .argument( '<sourceFile>', 'YAML filename to fetch from awesome-prometheus' )
17
+ .option( '-o, --output <outputFile>', 'Output file name', 'converted-alerts.yaml' )
18
+ .option( '-n, --name <customName>', 'Override metadata.name in the PrometheusRule' )
19
+ .option( '--dry-run', 'Print output to console instead of writing to file' )
20
+ .parse()
21
+
22
+ const [ sourceFile ] = program.args
23
+ const { output : outputFile, dryRun, name : customName } = program.opts()
24
+
25
+ const BASE_URL = 'https://raw.githubusercontent.com/samber/awesome-prometheus-alerts/master/dist/rules'
26
+ const SOURCE_URL = `${BASE_URL}/${sourceFile}`
27
+
28
+ const defaultName = path.basename( sourceFile ).replace( /\.(yaml|yml)$/, '' )
29
+ const metadataName = customName || defaultName
30
+
31
+ async function fetchAndConvert() {
32
+ try {
33
+ log( chalk.blueBright( `๐Ÿ” Fetching YAML from ${SOURCE_URL}...` ) )
34
+ const response = await fetch( SOURCE_URL )
35
+ if ( !response.ok ) throw new Error( `Failed to fetch: ${response.statusText}` )
36
+
37
+ const rawYaml = await response.text()
38
+ const parsedYaml = yaml.load( rawYaml )
39
+ if ( !parsedYaml.groups ) throw new Error( 'Invalid YAML structure, missing "groups"' )
40
+
41
+ const prometheusRule = {
42
+ apiVersion : 'monitoring.coreos.com/v1',
43
+ kind : 'PrometheusRule',
44
+ metadata : {
45
+ name : metadataName,
46
+ namespace : 'prometheus',
47
+ },
48
+ spec : {
49
+ groups : parsedYaml.groups.map( group => ( {
50
+ name : `${group.name}-rules`,
51
+ rules : group.rules,
52
+ } ) ),
53
+ },
54
+ }
55
+
56
+ const formattedYaml = yaml.dump( prometheusRule, {
57
+ noRefs : true,
58
+ indent : 2,
59
+ lineWidth : -1,
60
+ } )
61
+
62
+ if ( dryRun ) {
63
+ log( chalk.green( `\n๐ŸŸข Dry Run Output for: ${chalk.yellow( metadataName )}\n` ) )
64
+ log( formattedYaml )
65
+ } else {
66
+ fs.writeFileSync( outputFile, formattedYaml, 'utf8' ) // eslint-disable-line security/detect-non-literal-fs-filename
67
+ log( chalk.green( `โœ… Converted YAML written to ${chalk.cyan( outputFile )}` ) )
68
+ }
69
+ } catch ( error ) {
70
+ err( chalk.red( 'โŒ Error:' ), error.message )
71
+ }
72
+ }
73
+
74
+ fetchAndConvert()