@quatrain/cli 1.1.8
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/README.md +60 -0
- package/bin/core.js +3 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +33 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +696 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/generate/config.d.ts +2 -0
- package/dist/commands/generate/config.d.ts.map +1 -0
- package/dist/commands/generate/config.js +121 -0
- package/dist/commands/generate/config.js.map +1 -0
- package/dist/commands/generate/migration.d.ts +2 -0
- package/dist/commands/generate/migration.d.ts.map +1 -0
- package/dist/commands/generate/migration.js +42 -0
- package/dist/commands/generate/migration.js.map +1 -0
- package/dist/commands/generate/scaffold.d.ts +2 -0
- package/dist/commands/generate/scaffold.d.ts.map +1 -0
- package/dist/commands/generate/scaffold.js +65 -0
- package/dist/commands/generate/scaffold.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/cli.ts +38 -0
- package/src/commands/deploy.ts +829 -0
- package/src/commands/generate/config.ts +130 -0
- package/src/commands/generate/migration.ts +49 -0
- package/src/commands/generate/scaffold.ts +86 -0
- package/src/index.ts +59 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import inquirer from 'inquirer'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execSync } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
interface AppMetadata {
|
|
7
|
+
appName: string
|
|
8
|
+
namespace: string
|
|
9
|
+
env: 'dev' | 'prod'
|
|
10
|
+
domain: string
|
|
11
|
+
imageRef: string
|
|
12
|
+
authUser: string
|
|
13
|
+
authPass: string
|
|
14
|
+
resources: {
|
|
15
|
+
cpuRequests: string
|
|
16
|
+
cpuLimits: string
|
|
17
|
+
memRequests: string
|
|
18
|
+
memLimits: string
|
|
19
|
+
dataPvcSize: string
|
|
20
|
+
storagePvcSize: string
|
|
21
|
+
}
|
|
22
|
+
createdAt: string
|
|
23
|
+
updatedAt?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Discovers the absolute path to the CoreDeploy repository.
|
|
28
|
+
*/
|
|
29
|
+
function discoverCoreDeployPath(): string {
|
|
30
|
+
const cwd = process.cwd()
|
|
31
|
+
|
|
32
|
+
// 1. Check if we are inside CoreDeploy already
|
|
33
|
+
if (fs.existsSync(path.join(cwd, 'k8s/templates/namespace.yaml'))) {
|
|
34
|
+
return cwd
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Check sibling directories
|
|
38
|
+
const siblingCandidates = [
|
|
39
|
+
path.resolve(cwd, '../CoreDeploy'),
|
|
40
|
+
path.resolve(cwd, '../../CoreDeploy'),
|
|
41
|
+
path.resolve(cwd, 'CoreDeploy'),
|
|
42
|
+
'/Users/crapougnax/CODE/QUATRAIN/CoreDeploy'
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for (const candidate of siblingCandidates) {
|
|
46
|
+
if (fs.existsSync(path.join(candidate, 'k8s/templates/namespace.yaml'))) {
|
|
47
|
+
return candidate
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generates a high-entropy 24-character password.
|
|
56
|
+
*/
|
|
57
|
+
function generateSecurePassword(): string {
|
|
58
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#%^*()-_=+[]{}'
|
|
59
|
+
let pass = ''
|
|
60
|
+
for (let i = 0; i < 24; i++) {
|
|
61
|
+
const idx = Math.floor(Math.random() * chars.length)
|
|
62
|
+
pass += chars[idx]
|
|
63
|
+
}
|
|
64
|
+
return pass
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Attempts to deduce the latest stable studio-image tag from CoreApps workspace.
|
|
69
|
+
*/
|
|
70
|
+
function detectLatestStableTag(coreDeployPath: string): string {
|
|
71
|
+
const fallback = '1.1.49'
|
|
72
|
+
try {
|
|
73
|
+
const packageJsonPath = path.resolve(coreDeployPath, '../CoreApps/containers/studio-image/package.json')
|
|
74
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
75
|
+
const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
|
76
|
+
if (content && content.version) {
|
|
77
|
+
return content.version
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore and use fallback
|
|
82
|
+
}
|
|
83
|
+
return fallback
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parses existing YAML files to reconstruct metadata if metadata.json is missing.
|
|
88
|
+
*/
|
|
89
|
+
function parseMetadataFromYaml(appDir: string, namespaceName: string): AppMetadata | null {
|
|
90
|
+
try {
|
|
91
|
+
const nsPath = path.join(appDir, 'namespace.yaml')
|
|
92
|
+
const ingPath = path.join(appDir, 'ingressroute.yaml')
|
|
93
|
+
const depPath = path.join(appDir, 'deployment.yaml')
|
|
94
|
+
const secPath = path.join(appDir, 'secret.yaml')
|
|
95
|
+
const pvcPath = path.join(appDir, 'pvc.yaml')
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(nsPath) || !fs.existsSync(ingPath) || !fs.existsSync(depPath) || !fs.existsSync(secPath)) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const nsContent = fs.readFileSync(nsPath, 'utf8')
|
|
102
|
+
const ingContent = fs.readFileSync(ingPath, 'utf8')
|
|
103
|
+
const depContent = fs.readFileSync(depPath, 'utf8')
|
|
104
|
+
const secContent = fs.readFileSync(secPath, 'utf8')
|
|
105
|
+
|
|
106
|
+
// Deducing env and appName
|
|
107
|
+
const env: 'dev' | 'prod' = namespaceName.endsWith('-dev') ? 'dev' : 'prod'
|
|
108
|
+
|
|
109
|
+
// Extract appName from namespace.yaml labels: app.kubernetes.io/instance: name
|
|
110
|
+
let appName = namespaceName.replace(/-[a-z0-9]{5}(-dev)?$/, '')
|
|
111
|
+
const instanceMatch = nsContent.match(/app\.kubernetes\.io\/instance:\s*([^\s\n]+)/)
|
|
112
|
+
if (instanceMatch) {
|
|
113
|
+
appName = instanceMatch[1]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Extract domain from ingressroute.yaml Host(`domain`)
|
|
117
|
+
let domain = ''
|
|
118
|
+
const hostMatch = ingContent.match(/Host\(`([^`]+)`\)/)
|
|
119
|
+
if (hostMatch) {
|
|
120
|
+
domain = hostMatch[1]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Extract image reference
|
|
124
|
+
let imageRef = ''
|
|
125
|
+
const imageMatch = depContent.match(/image:\s*(ghcr\.io\/quatrain\/studio-image:[^\s\n]+)/)
|
|
126
|
+
if (imageMatch) {
|
|
127
|
+
imageRef = imageMatch[1]
|
|
128
|
+
} else {
|
|
129
|
+
const generalImageMatch = depContent.match(/image:\s*([^\s\n]+)/)
|
|
130
|
+
if (generalImageMatch) {
|
|
131
|
+
imageRef = generalImageMatch[1]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Extract Auth User/Pass from secret
|
|
136
|
+
let authUser = 'admin'
|
|
137
|
+
let authPass = ''
|
|
138
|
+
const userMatch = secContent.match(/STUDIO_AUTH_USER:\s*([^\s\n]+)/)
|
|
139
|
+
const passMatch = secContent.match(/STUDIO_AUTH_PASS:\s*([^\s\n]+)/)
|
|
140
|
+
if (userMatch) {
|
|
141
|
+
authUser = Buffer.from(userMatch[1].trim(), 'base64').toString('utf8')
|
|
142
|
+
}
|
|
143
|
+
if (passMatch) {
|
|
144
|
+
authPass = Buffer.from(passMatch[1].trim(), 'base64').toString('utf8')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Extract resources
|
|
148
|
+
let cpuRequests = '100m'
|
|
149
|
+
let cpuLimits = '500m'
|
|
150
|
+
let memRequests = '256Mi'
|
|
151
|
+
let memLimits = '512Mi'
|
|
152
|
+
|
|
153
|
+
// We focus on the core-studio container resources
|
|
154
|
+
const containerSplit = depContent.split('containers:')
|
|
155
|
+
if (containerSplit.length > 1) {
|
|
156
|
+
const resourcesPart = containerSplit[1]
|
|
157
|
+
const reqCpuMatch = resourcesPart.match(/requests:[\s\S]*?cpu:\s*"?([^\s\n"]+)"?/)
|
|
158
|
+
const reqMemMatch = resourcesPart.match(/requests:[\s\S]*?memory:\s*"?([^\s\n"]+)"?/)
|
|
159
|
+
const limCpuMatch = resourcesPart.match(/limits:[\s\S]*?cpu:\s*"?([^\s\n"]+)"?/)
|
|
160
|
+
const limMemMatch = resourcesPart.match(/limits:[\s\S]*?memory:\s*"?([^\s\n"]+)"?/)
|
|
161
|
+
|
|
162
|
+
if (reqCpuMatch) cpuRequests = reqCpuMatch[1]
|
|
163
|
+
if (reqMemMatch) memRequests = reqMemMatch[1]
|
|
164
|
+
if (limCpuMatch) cpuLimits = limCpuMatch[1]
|
|
165
|
+
if (limMemMatch) memLimits = limMemMatch[1]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Extract PVC sizes
|
|
169
|
+
let dataPvcSize = '1Gi'
|
|
170
|
+
let storagePvcSize = '10Gi'
|
|
171
|
+
if (fs.existsSync(pvcPath)) {
|
|
172
|
+
const pvcContent = fs.readFileSync(pvcPath, 'utf8')
|
|
173
|
+
// We split by standard YAML document separator
|
|
174
|
+
const docs = pvcContent.split('---')
|
|
175
|
+
docs.forEach(doc => {
|
|
176
|
+
const nameMatch = doc.match(/name:\s*([^\s\n]+)/)
|
|
177
|
+
const storageMatch = doc.match(/storage:\s*([^\s\n]+)/)
|
|
178
|
+
if (nameMatch && storageMatch) {
|
|
179
|
+
if (nameMatch[1].includes('data-pvc')) {
|
|
180
|
+
dataPvcSize = storageMatch[1]
|
|
181
|
+
} else if (nameMatch[1].includes('storage-pvc')) {
|
|
182
|
+
storagePvcSize = storageMatch[1]
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const meta: AppMetadata = {
|
|
189
|
+
appName,
|
|
190
|
+
namespace: namespaceName,
|
|
191
|
+
env,
|
|
192
|
+
domain,
|
|
193
|
+
imageRef,
|
|
194
|
+
authUser,
|
|
195
|
+
authPass,
|
|
196
|
+
resources: {
|
|
197
|
+
cpuRequests,
|
|
198
|
+
cpuLimits,
|
|
199
|
+
memRequests,
|
|
200
|
+
memLimits,
|
|
201
|
+
dataPvcSize,
|
|
202
|
+
storagePvcSize
|
|
203
|
+
},
|
|
204
|
+
createdAt: new Date().toISOString()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Write the file so we don't have to parse it next time
|
|
208
|
+
fs.writeFileSync(path.join(appDir, 'metadata.json'), JSON.stringify(meta, null, 3), 'utf8')
|
|
209
|
+
return meta
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`Failed to parse YAML configuration for ${namespaceName}:`, err)
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Loads all app metadata from the CoreDeploy k8s/apps directory.
|
|
218
|
+
*/
|
|
219
|
+
function loadAllDeployments(coreDeployPath: string): AppMetadata[] {
|
|
220
|
+
const appsDir = path.join(coreDeployPath, 'k8s/apps')
|
|
221
|
+
if (!fs.existsSync(appsDir)) {
|
|
222
|
+
return []
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const directories = fs.readdirSync(appsDir).filter(name => {
|
|
226
|
+
return fs.statSync(path.join(appsDir, name)).isDirectory()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const deployments: AppMetadata[] = []
|
|
230
|
+
for (const dir of directories) {
|
|
231
|
+
const appDir = path.join(appsDir, dir)
|
|
232
|
+
const metaPath = path.join(appDir, 'metadata.json')
|
|
233
|
+
|
|
234
|
+
if (fs.existsSync(metaPath)) {
|
|
235
|
+
try {
|
|
236
|
+
const data = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
|
237
|
+
deployments.push(data)
|
|
238
|
+
} catch {
|
|
239
|
+
// Attempt fallback parsing from YAML
|
|
240
|
+
const meta = parseMetadataFromYaml(appDir, dir)
|
|
241
|
+
if (meta) deployments.push(meta)
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
// Attempt fallback parsing from YAML
|
|
245
|
+
const meta = parseMetadataFromYaml(appDir, dir)
|
|
246
|
+
if (meta) deployments.push(meta)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return deployments
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Scaffolds manifest files from templates with variable substitution.
|
|
255
|
+
*/
|
|
256
|
+
function scaffoldManifests(
|
|
257
|
+
coreDeployPath: string,
|
|
258
|
+
meta: AppMetadata
|
|
259
|
+
) {
|
|
260
|
+
const templatesDir = path.join(coreDeployPath, 'k8s/templates')
|
|
261
|
+
const targetDir = path.join(coreDeployPath, 'k8s/apps', meta.namespace)
|
|
262
|
+
|
|
263
|
+
if (!fs.existsSync(targetDir)) {
|
|
264
|
+
fs.mkdirSync(targetDir, { recursive: true })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const templates = [
|
|
268
|
+
'namespace.yaml',
|
|
269
|
+
'pvc.yaml',
|
|
270
|
+
'configmap.yaml',
|
|
271
|
+
'secret.yaml',
|
|
272
|
+
'deployment.yaml',
|
|
273
|
+
'service.yaml',
|
|
274
|
+
'ingressroute.yaml'
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
const authUserB64 = Buffer.from(meta.authUser).toString('base64')
|
|
278
|
+
const authPassB64 = Buffer.from(meta.authPass).toString('base64')
|
|
279
|
+
|
|
280
|
+
for (const file of templates) {
|
|
281
|
+
const srcFile = path.join(templatesDir, file)
|
|
282
|
+
const destFile = path.join(targetDir, file)
|
|
283
|
+
|
|
284
|
+
if (!fs.existsSync(srcFile)) {
|
|
285
|
+
throw new Error(`Template missing: ${srcFile}`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let content = fs.readFileSync(srcFile, 'utf8')
|
|
289
|
+
|
|
290
|
+
// Replace placeholders
|
|
291
|
+
content = content.replace(/{{NAMESPACE}}/g, meta.namespace)
|
|
292
|
+
content = content.replace(/{{APP_NAME}}/g, meta.appName)
|
|
293
|
+
content = content.replace(/{{IMAGE_REF}}/g, meta.imageRef)
|
|
294
|
+
content = content.replace(/{{AUTH_USER_B64}}/g, authUserB64)
|
|
295
|
+
content = content.replace(/{{AUTH_PASS_B64}}/g, authPassB64)
|
|
296
|
+
content = content.replace(/{{DOMAIN}}/g, meta.domain)
|
|
297
|
+
content = content.replace(/{{CPU_REQUESTS}}/g, meta.resources.cpuRequests)
|
|
298
|
+
content = content.replace(/{{CPU_LIMITS}}/g, meta.resources.cpuLimits)
|
|
299
|
+
content = content.replace(/{{MEM_REQUESTS}}/g, meta.resources.memRequests)
|
|
300
|
+
content = content.replace(/{{MEM_LIMITS}}/g, meta.resources.memLimits)
|
|
301
|
+
content = content.replace(/{{DATA_PVC_SIZE}}/g, meta.resources.dataPvcSize)
|
|
302
|
+
content = content.replace(/{{STORAGE_PVC_SIZE}}/g, meta.resources.storagePvcSize)
|
|
303
|
+
|
|
304
|
+
fs.writeFileSync(destFile, content, 'utf8')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Write metadata.json
|
|
308
|
+
fs.writeFileSync(path.join(targetDir, 'metadata.json'), JSON.stringify(meta, null, 3), 'utf8')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Applies the scaffolding manifests in the correct order to ensure the namespace exists first
|
|
313
|
+
* and non-k8s files (like metadata.json) are ignored.
|
|
314
|
+
*/
|
|
315
|
+
function applyManifests(coreDeployPath: string, namespace: string): string {
|
|
316
|
+
const targetDir = path.join(coreDeployPath, 'k8s/apps', namespace)
|
|
317
|
+
const manifestFiles = [
|
|
318
|
+
'namespace.yaml',
|
|
319
|
+
'pvc.yaml',
|
|
320
|
+
'configmap.yaml',
|
|
321
|
+
'secret.yaml',
|
|
322
|
+
'deployment.yaml',
|
|
323
|
+
'service.yaml',
|
|
324
|
+
'ingressroute.yaml'
|
|
325
|
+
]
|
|
326
|
+
const filesArgs = manifestFiles
|
|
327
|
+
.map(f => `-f "${path.join(targetDir, f)}"`)
|
|
328
|
+
.join(' ')
|
|
329
|
+
const cmd = `kubectl apply ${filesArgs}`
|
|
330
|
+
console.log(`Executing: ${cmd}`)
|
|
331
|
+
return execSync(cmd, { encoding: 'utf8' })
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Command Entrypoint for `core deploy`
|
|
336
|
+
*/
|
|
337
|
+
export async function deployCommand() {
|
|
338
|
+
console.log('\n==============================================')
|
|
339
|
+
console.log(' Quatrain Core Studio - Deployment Manager ')
|
|
340
|
+
console.log('==============================================\n')
|
|
341
|
+
|
|
342
|
+
// 1. Auto-discover CoreDeploy Repository Path
|
|
343
|
+
let coreDeployPath = discoverCoreDeployPath()
|
|
344
|
+
if (coreDeployPath) {
|
|
345
|
+
console.log(`📡 Auto-detected CoreDeploy at: \x1b[33m${coreDeployPath}\x1b[0m`)
|
|
346
|
+
} else {
|
|
347
|
+
console.log('⚠️ Could not auto-detect the CoreDeploy repository directory.')
|
|
348
|
+
const pathAnswer = await inquirer.prompt([
|
|
349
|
+
{
|
|
350
|
+
type: 'input',
|
|
351
|
+
name: 'path',
|
|
352
|
+
message: 'Please provide the absolute path to CoreDeploy:',
|
|
353
|
+
validate: (input) => {
|
|
354
|
+
if (!input || !fs.existsSync(path.join(input, 'k8s/templates/namespace.yaml'))) {
|
|
355
|
+
return 'Invalid directory. Ensure it is the root of CoreDeploy containing k8s/templates/namespace.yaml.'
|
|
356
|
+
}
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
])
|
|
361
|
+
coreDeployPath = path.resolve(pathAnswer.path)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Main loop
|
|
365
|
+
let exitCli = false
|
|
366
|
+
while (!exitCli) {
|
|
367
|
+
const { action } = await inquirer.prompt([
|
|
368
|
+
{
|
|
369
|
+
type: 'list',
|
|
370
|
+
name: 'action',
|
|
371
|
+
message: 'What deployment action would you like to perform?',
|
|
372
|
+
choices: [
|
|
373
|
+
{ name: '📋 List deployments', value: 'list' },
|
|
374
|
+
{ name: '✨ Create new deployment', value: 'create' },
|
|
375
|
+
{ name: '⚙️ Modify existing deployment', value: 'modify' },
|
|
376
|
+
{ name: '🚀 Promote deployment (Dev -> Prod)', value: 'promote' },
|
|
377
|
+
{ name: '❌ Delete deployment', value: 'delete' },
|
|
378
|
+
{ name: '🚪 Exit', value: 'exit' }
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
])
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
switch (action) {
|
|
385
|
+
case 'list': {
|
|
386
|
+
const deployments = loadAllDeployments(coreDeployPath)
|
|
387
|
+
if (deployments.length === 0) {
|
|
388
|
+
console.log('\nℹ️ No deployments found.\n')
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
console.log('\nActive Studio Deployments:')
|
|
393
|
+
console.log('------------------------------------------------------------------------------------------------------------------------')
|
|
394
|
+
console.log(
|
|
395
|
+
`${'App Name'.padEnd(15)} | ${'Namespace'.padEnd(23)} | ${'Env'.padEnd(4)} | ${'FQDN Access Domain'.padEnd(35)} | ${'Image Reference'.padEnd(35)}`
|
|
396
|
+
)
|
|
397
|
+
console.log('------------------------------------------------------------------------------------------------------------------------')
|
|
398
|
+
deployments.forEach(d => {
|
|
399
|
+
console.log(
|
|
400
|
+
`${d.appName.padEnd(15)} | ${d.namespace.padEnd(23)} | ${d.env.padEnd(4)} | ${d.domain.padEnd(35)} | ${d.imageRef.padEnd(35)}`
|
|
401
|
+
)
|
|
402
|
+
})
|
|
403
|
+
console.log('------------------------------------------------------------------------------------------------------------------------\n')
|
|
404
|
+
break
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
case 'create': {
|
|
408
|
+
const answers = await inquirer.prompt([
|
|
409
|
+
{
|
|
410
|
+
type: 'input',
|
|
411
|
+
name: 'appName',
|
|
412
|
+
message: 'Nom de l\'application (alphanumeric/dashes only):',
|
|
413
|
+
validate: (input) => {
|
|
414
|
+
if (!input || !/^[a-zA-Z0-9\-]+$/.test(input)) {
|
|
415
|
+
return 'Application name is required and can only contain letters, numbers, and dashes.'
|
|
416
|
+
}
|
|
417
|
+
return true
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
type: 'list',
|
|
422
|
+
name: 'env',
|
|
423
|
+
message: 'Environnement (dev / prod) :',
|
|
424
|
+
choices: [
|
|
425
|
+
{ name: 'Development (suffixed with -dev)', value: 'dev' },
|
|
426
|
+
{ name: 'Production', value: 'prod' }
|
|
427
|
+
]
|
|
428
|
+
}
|
|
429
|
+
])
|
|
430
|
+
|
|
431
|
+
const appNameClean = answers.appName.toLowerCase()
|
|
432
|
+
const suffix = Math.random().toString(36).substring(2, 7) // 5 character alphanumeric string
|
|
433
|
+
|
|
434
|
+
let defaultNamespace = `${appNameClean}-${suffix}`
|
|
435
|
+
let defaultDomain = `${appNameClean}-${suffix}.quatrain.app`
|
|
436
|
+
if (answers.env === 'dev') {
|
|
437
|
+
defaultNamespace += '-dev'
|
|
438
|
+
defaultDomain = `${appNameClean}-${suffix}.quatrain.dev`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const flowAnswers = await inquirer.prompt([
|
|
442
|
+
{
|
|
443
|
+
type: 'input',
|
|
444
|
+
name: 'domain',
|
|
445
|
+
message: `Subdomain Access FQDN (Par défaut: ${defaultDomain}) :`,
|
|
446
|
+
default: defaultDomain
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
type: 'input',
|
|
450
|
+
name: 'imageRef',
|
|
451
|
+
message: `Référence de l'image (Par défaut: studio-image:${detectLatestStableTag(coreDeployPath)}) :`,
|
|
452
|
+
default: `ghcr.io/quatrain/studio-image:${detectLatestStableTag(coreDeployPath)}`
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
type: 'input',
|
|
456
|
+
name: 'authUser',
|
|
457
|
+
message: 'Auth Username (Par défaut: admin) :',
|
|
458
|
+
default: 'admin'
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
type: 'input',
|
|
462
|
+
name: 'authPass',
|
|
463
|
+
message: `Auth Password (Par défaut (généré sécurisé) :`,
|
|
464
|
+
default: () => generateSecurePassword()
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
type: 'list',
|
|
468
|
+
name: 'resourceChoice',
|
|
469
|
+
message: 'Configure Resources / Physical Storage:',
|
|
470
|
+
choices: [
|
|
471
|
+
{ name: 'Use Default Limits (CPU req: 100m, limit: 500m | Mem req: 256Mi, limit: 512Mi | Data: 1Gi, Storage: 10Gi)', value: 'default' },
|
|
472
|
+
{ name: 'Customize Resources & Storage', value: 'custom' }
|
|
473
|
+
]
|
|
474
|
+
}
|
|
475
|
+
])
|
|
476
|
+
|
|
477
|
+
let resources = {
|
|
478
|
+
cpuRequests: '100m',
|
|
479
|
+
cpuLimits: '500m',
|
|
480
|
+
memRequests: '256Mi',
|
|
481
|
+
memLimits: '512Mi',
|
|
482
|
+
dataPvcSize: '1Gi',
|
|
483
|
+
storagePvcSize: '10Gi'
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (flowAnswers.resourceChoice === 'custom') {
|
|
487
|
+
const customRes = await inquirer.prompt([
|
|
488
|
+
{
|
|
489
|
+
type: 'input',
|
|
490
|
+
name: 'cpuRequests',
|
|
491
|
+
message: 'CPU Requests (e.g. 100m, 50m) :',
|
|
492
|
+
default: '100m'
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
type: 'input',
|
|
496
|
+
name: 'cpuLimits',
|
|
497
|
+
message: 'CPU Limits (e.g. 500m, 200m) :',
|
|
498
|
+
default: '500m'
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
type: 'input',
|
|
502
|
+
name: 'memRequests',
|
|
503
|
+
message: 'Memory Requests (e.g. 256Mi, 128Mi) :',
|
|
504
|
+
default: '256Mi'
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
type: 'input',
|
|
508
|
+
name: 'memLimits',
|
|
509
|
+
message: 'Memory Limits (e.g. 512Mi, 256Mi) :',
|
|
510
|
+
default: '512Mi'
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
type: 'input',
|
|
514
|
+
name: 'dataPvcSize',
|
|
515
|
+
message: 'Data PVC Storage Capacity (e.g. 1Gi, 2Gi) :',
|
|
516
|
+
default: '1Gi'
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
type: 'input',
|
|
520
|
+
name: 'storagePvcSize',
|
|
521
|
+
message: 'Storage PVC Storage Capacity (e.g. 10Gi, 5Gi) :',
|
|
522
|
+
default: '10Gi'
|
|
523
|
+
}
|
|
524
|
+
])
|
|
525
|
+
resources = customRes
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const meta: AppMetadata = {
|
|
529
|
+
appName: appNameClean,
|
|
530
|
+
namespace: defaultNamespace,
|
|
531
|
+
env: answers.env,
|
|
532
|
+
domain: flowAnswers.domain,
|
|
533
|
+
imageRef: flowAnswers.imageRef,
|
|
534
|
+
authUser: flowAnswers.authUser,
|
|
535
|
+
authPass: flowAnswers.authPass,
|
|
536
|
+
resources,
|
|
537
|
+
createdAt: new Date().toISOString()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
scaffoldManifests(coreDeployPath, meta)
|
|
541
|
+
|
|
542
|
+
console.log(`\n✅ Manifests and metadata successfully scaffolded under: k8s/apps/${meta.namespace}/`)
|
|
543
|
+
console.log(`🌐 FQDN access URL: https://${meta.domain}`)
|
|
544
|
+
console.log(`🔑 Credentials : ${meta.authUser} / ${meta.authPass}\n`)
|
|
545
|
+
|
|
546
|
+
const { applyNow } = await inquirer.prompt([
|
|
547
|
+
{
|
|
548
|
+
type: 'confirm',
|
|
549
|
+
name: 'applyNow',
|
|
550
|
+
message: `Do you want to deploy ${meta.namespace} immediately to the Kubernetes cluster?`,
|
|
551
|
+
default: true
|
|
552
|
+
}
|
|
553
|
+
])
|
|
554
|
+
|
|
555
|
+
if (applyNow) {
|
|
556
|
+
const output = applyManifests(coreDeployPath, meta.namespace)
|
|
557
|
+
console.log(`\x1b[32m${output}\x1b[0m`)
|
|
558
|
+
}
|
|
559
|
+
break
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
case 'modify': {
|
|
563
|
+
const deployments = loadAllDeployments(coreDeployPath)
|
|
564
|
+
if (deployments.length === 0) {
|
|
565
|
+
console.log('\nℹ️ No deployments found to modify.\n')
|
|
566
|
+
break
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const { targetNs } = await inquirer.prompt([
|
|
570
|
+
{
|
|
571
|
+
type: 'list',
|
|
572
|
+
name: 'targetNs',
|
|
573
|
+
message: 'Select the deployment to modify:',
|
|
574
|
+
choices: deployments.map(d => ({
|
|
575
|
+
name: `${d.namespace} (domain: ${d.domain})`,
|
|
576
|
+
value: d.namespace
|
|
577
|
+
}))
|
|
578
|
+
}
|
|
579
|
+
])
|
|
580
|
+
|
|
581
|
+
const targetMeta = deployments.find(d => d.namespace === targetNs)!
|
|
582
|
+
|
|
583
|
+
const updates = await inquirer.prompt([
|
|
584
|
+
{
|
|
585
|
+
type: 'input',
|
|
586
|
+
name: 'domain',
|
|
587
|
+
message: 'Subdomain Access FQDN:',
|
|
588
|
+
default: targetMeta.domain
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
type: 'input',
|
|
592
|
+
name: 'imageRef',
|
|
593
|
+
message: 'Image Reference:',
|
|
594
|
+
default: targetMeta.imageRef
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
type: 'input',
|
|
598
|
+
name: 'authUser',
|
|
599
|
+
message: 'Auth Username:',
|
|
600
|
+
default: targetMeta.authUser
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
type: 'input',
|
|
604
|
+
name: 'authPass',
|
|
605
|
+
message: 'Auth Password:',
|
|
606
|
+
default: targetMeta.authPass
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
type: 'input',
|
|
610
|
+
name: 'cpuRequests',
|
|
611
|
+
message: 'CPU Requests:',
|
|
612
|
+
default: targetMeta.resources?.cpuRequests || '100m'
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
type: 'input',
|
|
616
|
+
name: 'cpuLimits',
|
|
617
|
+
message: 'CPU Limits:',
|
|
618
|
+
default: targetMeta.resources?.cpuLimits || '500m'
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
type: 'input',
|
|
622
|
+
name: 'memRequests',
|
|
623
|
+
message: 'Memory Requests:',
|
|
624
|
+
default: targetMeta.resources?.memRequests || '256Mi'
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
type: 'input',
|
|
628
|
+
name: 'memLimits',
|
|
629
|
+
message: 'Memory Limits:',
|
|
630
|
+
default: targetMeta.resources?.memLimits || '512Mi'
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
type: 'input',
|
|
634
|
+
name: 'dataPvcSize',
|
|
635
|
+
message: 'Data PVC storage size:',
|
|
636
|
+
default: targetMeta.resources?.dataPvcSize || '1Gi'
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
type: 'input',
|
|
640
|
+
name: 'storagePvcSize',
|
|
641
|
+
message: 'Storage PVC storage size:',
|
|
642
|
+
default: targetMeta.resources?.storagePvcSize || '10Gi'
|
|
643
|
+
}
|
|
644
|
+
])
|
|
645
|
+
|
|
646
|
+
const updatedMeta: AppMetadata = {
|
|
647
|
+
...targetMeta,
|
|
648
|
+
domain: updates.domain,
|
|
649
|
+
imageRef: updates.imageRef,
|
|
650
|
+
authUser: updates.authUser,
|
|
651
|
+
authPass: updates.authPass,
|
|
652
|
+
resources: {
|
|
653
|
+
cpuRequests: updates.cpuRequests,
|
|
654
|
+
cpuLimits: updates.cpuLimits,
|
|
655
|
+
memRequests: updates.memRequests,
|
|
656
|
+
memLimits: updates.memLimits,
|
|
657
|
+
dataPvcSize: updates.dataPvcSize,
|
|
658
|
+
storagePvcSize: updates.storagePvcSize
|
|
659
|
+
},
|
|
660
|
+
updatedAt: new Date().toISOString()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
scaffoldManifests(coreDeployPath, updatedMeta)
|
|
664
|
+
console.log(`\n✅ Manifests and metadata updated for namespace ${targetNs}.`)
|
|
665
|
+
|
|
666
|
+
const { applyNow } = await inquirer.prompt([
|
|
667
|
+
{
|
|
668
|
+
type: 'confirm',
|
|
669
|
+
name: 'applyNow',
|
|
670
|
+
message: 'Do you want to apply these updates to the cluster now?',
|
|
671
|
+
default: true
|
|
672
|
+
}
|
|
673
|
+
])
|
|
674
|
+
|
|
675
|
+
if (applyNow) {
|
|
676
|
+
const output = applyManifests(coreDeployPath, targetNs)
|
|
677
|
+
console.log(`\x1b[32m${output}\x1b[0m`)
|
|
678
|
+
}
|
|
679
|
+
break
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
case 'promote': {
|
|
683
|
+
const deployments = loadAllDeployments(coreDeployPath)
|
|
684
|
+
const devDeployments = deployments.filter(d => d.env === 'dev' || d.namespace.endsWith('-dev'))
|
|
685
|
+
|
|
686
|
+
if (devDeployments.length === 0) {
|
|
687
|
+
console.log('\nℹ️ No development deployments found to promote.\n')
|
|
688
|
+
break
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const { targetNs } = await inquirer.prompt([
|
|
692
|
+
{
|
|
693
|
+
type: 'list',
|
|
694
|
+
name: 'targetNs',
|
|
695
|
+
message: 'Select the development app to promote to production:',
|
|
696
|
+
choices: devDeployments.map(d => ({
|
|
697
|
+
name: `${d.namespace} (domain: ${d.domain})`,
|
|
698
|
+
value: d.namespace
|
|
699
|
+
}))
|
|
700
|
+
}
|
|
701
|
+
])
|
|
702
|
+
|
|
703
|
+
const devMeta = devDeployments.find(d => d.namespace === targetNs)!
|
|
704
|
+
|
|
705
|
+
// Deduce prod settings: strip -dev suffix
|
|
706
|
+
const prodNamespace = devMeta.namespace.replace(/-dev$/, '')
|
|
707
|
+
const prodDomain = devMeta.domain.replace(/\.dev$/, '.app')
|
|
708
|
+
|
|
709
|
+
console.log(`\nPromoting installation to production:`)
|
|
710
|
+
console.log(` Dev Namespace : ${devMeta.namespace} -> Prod Namespace : \x1b[32m${prodNamespace}\x1b[0m`)
|
|
711
|
+
console.log(` Dev Domain : ${devMeta.domain} -> Prod Domain : \x1b[32m${prodDomain}\x1b[0m\n`)
|
|
712
|
+
|
|
713
|
+
const confirmPromote = await inquirer.prompt([
|
|
714
|
+
{
|
|
715
|
+
type: 'confirm',
|
|
716
|
+
name: 'confirm',
|
|
717
|
+
message: 'Do you want to proceed with this promotion?',
|
|
718
|
+
default: true
|
|
719
|
+
}
|
|
720
|
+
])
|
|
721
|
+
|
|
722
|
+
if (!confirmPromote.confirm) {
|
|
723
|
+
console.log('Promotion cancelled.\n')
|
|
724
|
+
break
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const prodMeta: AppMetadata = {
|
|
728
|
+
appName: devMeta.appName,
|
|
729
|
+
namespace: prodNamespace,
|
|
730
|
+
env: 'prod',
|
|
731
|
+
domain: prodDomain,
|
|
732
|
+
imageRef: devMeta.imageRef,
|
|
733
|
+
authUser: devMeta.authUser,
|
|
734
|
+
authPass: devMeta.authPass,
|
|
735
|
+
resources: { ...devMeta.resources },
|
|
736
|
+
createdAt: new Date().toISOString()
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
scaffoldManifests(coreDeployPath, prodMeta)
|
|
740
|
+
console.log(`\n✅ Production manifests scaffolded under: k8s/apps/${prodNamespace}/`)
|
|
741
|
+
|
|
742
|
+
const { applyNow } = await inquirer.prompt([
|
|
743
|
+
{
|
|
744
|
+
type: 'confirm',
|
|
745
|
+
name: 'applyNow',
|
|
746
|
+
message: `Do you want to deploy ${prodNamespace} immediately to production?`,
|
|
747
|
+
default: true
|
|
748
|
+
}
|
|
749
|
+
])
|
|
750
|
+
|
|
751
|
+
if (applyNow) {
|
|
752
|
+
const output = applyManifests(coreDeployPath, prodNamespace)
|
|
753
|
+
console.log(`\x1b[32m${output}\x1b[0m`)
|
|
754
|
+
}
|
|
755
|
+
break
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
case 'delete': {
|
|
759
|
+
const deployments = loadAllDeployments(coreDeployPath)
|
|
760
|
+
if (deployments.length === 0) {
|
|
761
|
+
console.log('\nℹ️ No deployments found to delete.\n')
|
|
762
|
+
break
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const { targetNs } = await inquirer.prompt([
|
|
766
|
+
{
|
|
767
|
+
type: 'list',
|
|
768
|
+
name: 'targetNs',
|
|
769
|
+
message: 'Select the deployment to delete:',
|
|
770
|
+
choices: deployments.map(d => ({
|
|
771
|
+
name: `${d.namespace} (domain: ${d.domain})`,
|
|
772
|
+
value: d.namespace
|
|
773
|
+
}))
|
|
774
|
+
}
|
|
775
|
+
])
|
|
776
|
+
|
|
777
|
+
const { confirmDelete } = await inquirer.prompt([
|
|
778
|
+
{
|
|
779
|
+
type: 'confirm',
|
|
780
|
+
name: 'confirmDelete',
|
|
781
|
+
message: `Are you sure you want to delete local manifests & config for ${targetNs}? (This action is irreversible!)`,
|
|
782
|
+
default: false
|
|
783
|
+
}
|
|
784
|
+
])
|
|
785
|
+
|
|
786
|
+
if (!confirmDelete) {
|
|
787
|
+
console.log('Deletion cancelled.\n')
|
|
788
|
+
break
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const { deleteK8s } = await inquirer.prompt([
|
|
792
|
+
{
|
|
793
|
+
type: 'confirm',
|
|
794
|
+
name: 'deleteK8s',
|
|
795
|
+
message: `Do you also want to delete the namespace "${targetNs}" from the active Kubernetes cluster?`,
|
|
796
|
+
default: true
|
|
797
|
+
}
|
|
798
|
+
])
|
|
799
|
+
|
|
800
|
+
if (deleteK8s) {
|
|
801
|
+
console.log(`Executing: kubectl delete namespace ${targetNs}`)
|
|
802
|
+
try {
|
|
803
|
+
const output = execSync(`kubectl delete namespace "${targetNs}"`, { encoding: 'utf8' })
|
|
804
|
+
console.log(`\x1b[32m${output}\x1b[0m`)
|
|
805
|
+
} catch (err: any) {
|
|
806
|
+
console.error(`Failed to delete namespace ${targetNs} on the cluster:`, err.message)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Remove local files
|
|
811
|
+
const appDir = path.join(coreDeployPath, 'k8s/apps', targetNs)
|
|
812
|
+
if (fs.existsSync(appDir)) {
|
|
813
|
+
fs.rmSync(appDir, { recursive: true, force: true })
|
|
814
|
+
console.log(`✅ Deleted local directory: k8s/apps/${targetNs}\n`)
|
|
815
|
+
}
|
|
816
|
+
break
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
case 'exit':
|
|
820
|
+
exitCli = true
|
|
821
|
+
break
|
|
822
|
+
}
|
|
823
|
+
} catch (err: any) {
|
|
824
|
+
console.error(`\n❌ Error performing action: ${err.message}\n`)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log('🚪 Exiting Deployment Manager.')
|
|
829
|
+
}
|