suthep 1.0.0
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/.editorconfig +17 -0
- package/.github/workflows/publish.yml +42 -0
- package/.prettierignore +6 -0
- package/.prettierrc +7 -0
- package/.scannerwork/.sonar_lock +0 -0
- package/.scannerwork/report-task.txt +6 -0
- package/.vscode/settings.json +19 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/dist/commands/deploy.js +371 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/down.js +179 -0
- package/dist/commands/down.js.map +1 -0
- package/dist/commands/init.js +188 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/setup.js +90 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/up.js +213 -0
- package/dist/commands/up.js.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/certbot.js +64 -0
- package/dist/utils/certbot.js.map +1 -0
- package/dist/utils/config-loader.js +127 -0
- package/dist/utils/config-loader.js.map +1 -0
- package/dist/utils/deployment.js +85 -0
- package/dist/utils/deployment.js.map +1 -0
- package/dist/utils/docker.js +425 -0
- package/dist/utils/docker.js.map +1 -0
- package/dist/utils/env-loader.js +53 -0
- package/dist/utils/env-loader.js.map +1 -0
- package/dist/utils/nginx.js +378 -0
- package/dist/utils/nginx.js.map +1 -0
- package/docs/README.md +38 -0
- package/docs/english/01-introduction.md +84 -0
- package/docs/english/02-installation.md +200 -0
- package/docs/english/03-quick-start.md +258 -0
- package/docs/english/04-configuration.md +433 -0
- package/docs/english/05-commands.md +336 -0
- package/docs/english/06-examples.md +456 -0
- package/docs/english/07-troubleshooting.md +417 -0
- package/docs/english/08-advanced.md +411 -0
- package/docs/english/README.md +48 -0
- package/docs/thai/01-introduction.md +84 -0
- package/docs/thai/02-installation.md +200 -0
- package/docs/thai/03-quick-start.md +258 -0
- package/docs/thai/04-configuration.md +433 -0
- package/docs/thai/05-commands.md +336 -0
- package/docs/thai/06-examples.md +456 -0
- package/docs/thai/07-troubleshooting.md +417 -0
- package/docs/thai/08-advanced.md +411 -0
- package/docs/thai/README.md +48 -0
- package/example/suthep-complete.yml +103 -0
- package/example/suthep-docker-only.yml +71 -0
- package/example/suthep-env-example.yml +113 -0
- package/example/suthep-no-docker.yml +51 -0
- package/example/suthep-path-routing.yml +62 -0
- package/example/suthep.example.yml +88 -0
- package/package.json +51 -0
- package/src/commands/deploy.ts +488 -0
- package/src/commands/down.ts +240 -0
- package/src/commands/init.ts +214 -0
- package/src/commands/setup.ts +112 -0
- package/src/commands/up.ts +271 -0
- package/src/index.ts +109 -0
- package/src/types/config.ts +52 -0
- package/src/utils/__tests__/certbot.test.ts +222 -0
- package/src/utils/__tests__/config-loader.test.ts +419 -0
- package/src/utils/__tests__/deployment.test.ts +243 -0
- package/src/utils/__tests__/nginx.test.ts +412 -0
- package/src/utils/certbot.ts +144 -0
- package/src/utils/config-loader.ts +184 -0
- package/src/utils/deployment.ts +157 -0
- package/src/utils/docker.ts +768 -0
- package/src/utils/env-loader.ts +135 -0
- package/src/utils/nginx.ts +443 -0
- package/suthep-1.0.0.tgz +0 -0
- package/suthep.example.yml +98 -0
- package/suthep.yml +39 -0
- package/todo.md +6 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +46 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import fs from 'fs-extra'
|
|
3
|
+
import type { ServiceConfig } from '../types/config'
|
|
4
|
+
import { loadConfig } from '../utils/config-loader'
|
|
5
|
+
import { waitForService } from '../utils/deployment'
|
|
6
|
+
import { isContainerRunning, startDockerContainer } from '../utils/docker'
|
|
7
|
+
import {
|
|
8
|
+
enableSite,
|
|
9
|
+
generateMultiServiceNginxConfig,
|
|
10
|
+
generateNginxConfig,
|
|
11
|
+
reloadNginx,
|
|
12
|
+
writeNginxConfig,
|
|
13
|
+
} from '../utils/nginx'
|
|
14
|
+
|
|
15
|
+
interface UpOptions {
|
|
16
|
+
file: string
|
|
17
|
+
all: boolean
|
|
18
|
+
serviceName?: string
|
|
19
|
+
https: boolean
|
|
20
|
+
nginx: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function upCommand(options: UpOptions): Promise<void> {
|
|
24
|
+
console.log(chalk.blue.bold('\n🚀 Bringing Up Services\n'))
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Load configuration (this will also load .env files for variable substitution)
|
|
28
|
+
if (!(await fs.pathExists(options.file))) {
|
|
29
|
+
throw new Error(`Configuration file not found: ${options.file}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.cyan(`📄 Loading configuration from ${options.file}...`))
|
|
33
|
+
const config = await loadConfig(options.file)
|
|
34
|
+
|
|
35
|
+
console.log(chalk.green(`✅ Configuration loaded for project: ${config.project.name}`))
|
|
36
|
+
|
|
37
|
+
// Determine which services to bring up
|
|
38
|
+
let servicesToUp: ServiceConfig[] = []
|
|
39
|
+
|
|
40
|
+
if (options.all) {
|
|
41
|
+
servicesToUp = config.services
|
|
42
|
+
console.log(
|
|
43
|
+
chalk.cyan(`📋 Bringing up all services: ${servicesToUp.map((s) => s.name).join(', ')}\n`)
|
|
44
|
+
)
|
|
45
|
+
} else if (options.serviceName) {
|
|
46
|
+
const service = config.services.find((s) => s.name === options.serviceName)
|
|
47
|
+
if (!service) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Service "${
|
|
50
|
+
options.serviceName
|
|
51
|
+
}" not found in configuration. Available services: ${config.services
|
|
52
|
+
.map((s) => s.name)
|
|
53
|
+
.join(', ')}`
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
servicesToUp = [service]
|
|
57
|
+
console.log(chalk.cyan(`📋 Bringing up service: ${options.serviceName}\n`))
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error('Either specify a service name or use --all flag')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Group services by domain for nginx config management
|
|
63
|
+
const domainToServices = new Map<string, ServiceConfig[]>()
|
|
64
|
+
const allDomains = new Set<string>()
|
|
65
|
+
|
|
66
|
+
for (const service of servicesToUp) {
|
|
67
|
+
for (const domain of service.domains) {
|
|
68
|
+
allDomains.add(domain)
|
|
69
|
+
if (!domainToServices.has(domain)) {
|
|
70
|
+
domainToServices.set(domain, [])
|
|
71
|
+
}
|
|
72
|
+
domainToServices.get(domain)!.push(service)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Start Docker containers
|
|
77
|
+
for (const service of servicesToUp) {
|
|
78
|
+
if (service.docker) {
|
|
79
|
+
console.log(chalk.cyan(`\n🐳 Starting Docker container for service: ${service.name}`))
|
|
80
|
+
try {
|
|
81
|
+
await startDockerContainer(service)
|
|
82
|
+
console.log(chalk.green(` ✅ Container started for service: ${service.name}`))
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error(
|
|
85
|
+
chalk.red(` ❌ Failed to start container for service ${service.name}:`),
|
|
86
|
+
error instanceof Error ? error.message : error
|
|
87
|
+
)
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Wait for services to be healthy
|
|
94
|
+
for (const service of servicesToUp) {
|
|
95
|
+
if (service.healthCheck) {
|
|
96
|
+
console.log(chalk.cyan(`\n🏥 Waiting for service ${service.name} to be healthy...`))
|
|
97
|
+
const isHealthy = await waitForService(service, config.deployment.healthCheckTimeout)
|
|
98
|
+
if (isHealthy) {
|
|
99
|
+
console.log(chalk.green(` ✅ Service ${service.name} is healthy`))
|
|
100
|
+
} else {
|
|
101
|
+
console.log(
|
|
102
|
+
chalk.yellow(` ⚠️ Service ${service.name} health check timeout, continuing anyway...`)
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Configure Nginx
|
|
109
|
+
if (options.nginx && allDomains.size > 0) {
|
|
110
|
+
console.log(chalk.cyan(`\n⚙️ Configuring Nginx reverse proxy...`))
|
|
111
|
+
|
|
112
|
+
// Create a set of service names being brought up for quick lookup
|
|
113
|
+
const servicesBeingUpped = new Set(servicesToUp.map((s) => s.name))
|
|
114
|
+
|
|
115
|
+
// For each domain, find all services that use it (from all config services)
|
|
116
|
+
// and include all active services (both newly brought up and already running)
|
|
117
|
+
for (const domain of allDomains) {
|
|
118
|
+
const configName = domain.replace(/\./g, '_')
|
|
119
|
+
|
|
120
|
+
// Find all services that use this domain (from entire config)
|
|
121
|
+
const allServicesForDomain = config.services.filter((s) => s.domains.includes(domain))
|
|
122
|
+
|
|
123
|
+
// Check which services are actually running (have active containers)
|
|
124
|
+
const activeServices: ServiceConfig[] = []
|
|
125
|
+
for (const service of allServicesForDomain) {
|
|
126
|
+
if (service.docker) {
|
|
127
|
+
const isRunning = await isContainerRunning(service.docker.container)
|
|
128
|
+
if (isRunning) {
|
|
129
|
+
activeServices.push(service)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
// Service without docker - include if it's being brought up or assume it's running
|
|
133
|
+
if (servicesBeingUpped.has(service.name)) {
|
|
134
|
+
activeServices.push(service)
|
|
135
|
+
} else {
|
|
136
|
+
// For non-docker services, assume they're running if not explicitly being brought up
|
|
137
|
+
// This is a best-effort approach
|
|
138
|
+
activeServices.push(service)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (activeServices.length === 0) {
|
|
144
|
+
console.log(
|
|
145
|
+
chalk.yellow(` ⚠️ No active services found for ${domain}, skipping Nginx config`)
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// Log domain and services configuration
|
|
152
|
+
if (activeServices.length > 1) {
|
|
153
|
+
console.log(
|
|
154
|
+
chalk.cyan(
|
|
155
|
+
` 📋 Configuring ${domain} with ${
|
|
156
|
+
activeServices.length
|
|
157
|
+
} active service(s): ${activeServices.map((s) => s.name).join(', ')}`
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
} else {
|
|
161
|
+
console.log(
|
|
162
|
+
chalk.cyan(` 📋 Configuring ${domain} for service: ${activeServices[0].name}`)
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Generate Nginx config for all active services on this domain
|
|
167
|
+
let nginxConfigContent: string
|
|
168
|
+
if (activeServices.length === 1) {
|
|
169
|
+
nginxConfigContent = generateNginxConfig(activeServices[0], false)
|
|
170
|
+
} else {
|
|
171
|
+
nginxConfigContent = generateMultiServiceNginxConfig(activeServices, domain, false)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Write config file
|
|
175
|
+
await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
|
|
176
|
+
await enableSite(configName, config.nginx.configPath)
|
|
177
|
+
|
|
178
|
+
console.log(chalk.green(` ✅ Nginx configured for ${domain}`))
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(
|
|
181
|
+
chalk.red(` ❌ Failed to configure Nginx for ${domain}:`),
|
|
182
|
+
error instanceof Error ? error.message : error
|
|
183
|
+
)
|
|
184
|
+
throw error
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Update with HTTPS if enabled
|
|
189
|
+
if (options.https) {
|
|
190
|
+
console.log(chalk.cyan(`\n🔄 Updating Nginx configs with HTTPS...`))
|
|
191
|
+
for (const domain of allDomains) {
|
|
192
|
+
const configName = domain.replace(/\./g, '_')
|
|
193
|
+
|
|
194
|
+
// Find all active services for this domain again
|
|
195
|
+
const allServicesForDomain = config.services.filter((s) => s.domains.includes(domain))
|
|
196
|
+
const activeServices: ServiceConfig[] = []
|
|
197
|
+
for (const service of allServicesForDomain) {
|
|
198
|
+
if (service.docker) {
|
|
199
|
+
const isRunning = await isContainerRunning(service.docker.container)
|
|
200
|
+
if (isRunning) {
|
|
201
|
+
activeServices.push(service)
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
if (servicesBeingUpped.has(service.name)) {
|
|
205
|
+
activeServices.push(service)
|
|
206
|
+
} else {
|
|
207
|
+
activeServices.push(service)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (activeServices.length === 0) {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
let nginxConfigContent: string
|
|
218
|
+
if (activeServices.length === 1) {
|
|
219
|
+
nginxConfigContent = generateNginxConfig(activeServices[0], true)
|
|
220
|
+
} else {
|
|
221
|
+
nginxConfigContent = generateMultiServiceNginxConfig(activeServices, domain, true)
|
|
222
|
+
}
|
|
223
|
+
await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
|
|
224
|
+
console.log(chalk.green(` ✅ HTTPS config updated for ${domain}`))
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(
|
|
227
|
+
chalk.red(` ❌ Failed to update HTTPS config for ${domain}:`),
|
|
228
|
+
error instanceof Error ? error.message : error
|
|
229
|
+
)
|
|
230
|
+
throw error
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Reload Nginx
|
|
236
|
+
console.log(chalk.cyan(`\n🔄 Reloading Nginx...`))
|
|
237
|
+
try {
|
|
238
|
+
await reloadNginx(config.nginx.reloadCommand)
|
|
239
|
+
console.log(chalk.green(` ✅ Nginx reloaded`))
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.error(
|
|
242
|
+
chalk.red(` ❌ Failed to reload Nginx:`),
|
|
243
|
+
error instanceof Error ? error.message : error
|
|
244
|
+
)
|
|
245
|
+
throw error
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(chalk.green.bold('\n✅ Services brought up successfully!\n'))
|
|
250
|
+
|
|
251
|
+
// Print service URLs
|
|
252
|
+
if (allDomains.size > 0) {
|
|
253
|
+
console.log(chalk.cyan('📋 Service URLs:'))
|
|
254
|
+
for (const service of servicesToUp) {
|
|
255
|
+
for (const domain of service.domains) {
|
|
256
|
+
const protocol = options.https ? 'https' : 'http'
|
|
257
|
+
const servicePath = service.path || '/'
|
|
258
|
+
const fullPath = servicePath === '/' ? '' : servicePath
|
|
259
|
+
console.log(chalk.dim(` ${service.name}: ${protocol}://${domain}${fullPath}`))
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
console.log()
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error(
|
|
266
|
+
chalk.red('\n❌ Failed to bring up services:'),
|
|
267
|
+
error instanceof Error ? error.message : error
|
|
268
|
+
)
|
|
269
|
+
process.exit(1)
|
|
270
|
+
}
|
|
271
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { dirname, join } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { deployCommand } from './commands/deploy'
|
|
6
|
+
import { downCommand } from './commands/down'
|
|
7
|
+
import { initCommand } from './commands/init'
|
|
8
|
+
import { setupCommand } from './commands/setup'
|
|
9
|
+
import { upCommand } from './commands/up'
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
12
|
+
const __dirname = dirname(__filename)
|
|
13
|
+
const packageJsonPath = join(__dirname, '..', 'package.json')
|
|
14
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
|
|
15
|
+
|
|
16
|
+
const program = new Command()
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name('suthep')
|
|
20
|
+
.description('CLI tool for deploying projects with automatic Nginx reverse proxy and HTTPS setup')
|
|
21
|
+
.version(packageJson.version)
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('init')
|
|
25
|
+
.description('Initialize a new deployment configuration file')
|
|
26
|
+
.option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
|
|
27
|
+
.action(initCommand)
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command('setup')
|
|
31
|
+
.description('Setup Nginx and Certbot on the system')
|
|
32
|
+
.option('--nginx-only', 'Only setup Nginx')
|
|
33
|
+
.option('--certbot-only', 'Only setup Certbot')
|
|
34
|
+
.action(setupCommand)
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.command('deploy')
|
|
38
|
+
.description('Deploy a project using the configuration file')
|
|
39
|
+
.option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
|
|
40
|
+
.option('--no-https', 'Skip HTTPS setup')
|
|
41
|
+
.option('--no-nginx', 'Skip Nginx configuration')
|
|
42
|
+
.option(
|
|
43
|
+
'-e, --env <key=value>',
|
|
44
|
+
'Set environment variables (can be used multiple times, e.g., -e KEY1=value1 -e KEY2=value2)',
|
|
45
|
+
(value, previous: string[] = []) => {
|
|
46
|
+
return [...previous, value]
|
|
47
|
+
},
|
|
48
|
+
[]
|
|
49
|
+
)
|
|
50
|
+
.argument('[service-name]', 'Name of the service to deploy (deploys all if not specified)')
|
|
51
|
+
.action((serviceName, options: any) => {
|
|
52
|
+
// Parse environment variables from CLI
|
|
53
|
+
const cliEnvVars: Record<string, string> = {}
|
|
54
|
+
if (options.env && Array.isArray(options.env)) {
|
|
55
|
+
for (const envVar of options.env) {
|
|
56
|
+
const equalsIndex = envVar.indexOf('=')
|
|
57
|
+
if (equalsIndex === -1 || equalsIndex === 0) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid environment variable format: ${envVar}. Expected KEY=VALUE (e.g., -e KEY=value)`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const key = envVar.substring(0, equalsIndex)
|
|
63
|
+
const value = envVar.substring(equalsIndex + 1)
|
|
64
|
+
cliEnvVars[key] = value
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
deployCommand({
|
|
69
|
+
file: options.file || 'suthep.yml',
|
|
70
|
+
https: options.https !== false,
|
|
71
|
+
nginx: options.nginx !== false,
|
|
72
|
+
serviceName: serviceName,
|
|
73
|
+
cliEnvVars,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command('down')
|
|
79
|
+
.description('Bring down services (stop containers and disable Nginx configs)')
|
|
80
|
+
.option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
|
|
81
|
+
.option('--all', 'Bring down all services', false)
|
|
82
|
+
.argument('[service-name]', 'Name of the service to bring down')
|
|
83
|
+
.action((serviceName, options) => {
|
|
84
|
+
downCommand({
|
|
85
|
+
file: options.file || 'suthep.yml',
|
|
86
|
+
all: options.all || false,
|
|
87
|
+
serviceName: serviceName,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
program
|
|
92
|
+
.command('up')
|
|
93
|
+
.description('Bring up services (start containers and enable Nginx configs)')
|
|
94
|
+
.option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
|
|
95
|
+
.option('--all', 'Bring up all services', false)
|
|
96
|
+
.option('--no-https', 'Skip HTTPS setup')
|
|
97
|
+
.option('--no-nginx', 'Skip Nginx configuration')
|
|
98
|
+
.argument('[service-name]', 'Name of the service to bring up')
|
|
99
|
+
.action((serviceName, options) => {
|
|
100
|
+
upCommand({
|
|
101
|
+
file: options.file || 'suthep.yml',
|
|
102
|
+
all: options.all || false,
|
|
103
|
+
serviceName: serviceName,
|
|
104
|
+
https: options.https !== false,
|
|
105
|
+
nginx: options.nginx !== false,
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
program.parse()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration type definitions for the Suthep deployment tool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ProjectConfig {
|
|
6
|
+
name: string
|
|
7
|
+
version: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HealthCheckConfig {
|
|
11
|
+
path: string
|
|
12
|
+
interval: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DockerConfig {
|
|
16
|
+
image?: string
|
|
17
|
+
container: string
|
|
18
|
+
port: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ServiceConfig {
|
|
22
|
+
name: string
|
|
23
|
+
port: number
|
|
24
|
+
domains: string[]
|
|
25
|
+
path?: string // Path prefix for this service (e.g., '/api', '/'). Defaults to '/'
|
|
26
|
+
docker?: DockerConfig
|
|
27
|
+
healthCheck?: HealthCheckConfig
|
|
28
|
+
environment?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NginxConfig {
|
|
32
|
+
configPath: string
|
|
33
|
+
reloadCommand: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CertbotConfig {
|
|
37
|
+
email: string
|
|
38
|
+
staging: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeploymentConfig {
|
|
42
|
+
strategy: 'rolling' | 'blue-green'
|
|
43
|
+
healthCheckTimeout: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DeployConfig {
|
|
47
|
+
project: ProjectConfig
|
|
48
|
+
services: ServiceConfig[]
|
|
49
|
+
nginx: NginxConfig
|
|
50
|
+
certbot: CertbotConfig
|
|
51
|
+
deployment: DeploymentConfig
|
|
52
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
certificateExists,
|
|
5
|
+
checkCertificateExpiration,
|
|
6
|
+
renewCertificates,
|
|
7
|
+
requestCertificate,
|
|
8
|
+
revokeCertificate,
|
|
9
|
+
} from '../certbot'
|
|
10
|
+
|
|
11
|
+
// Mock execa
|
|
12
|
+
vi.mock('execa', () => ({
|
|
13
|
+
execa: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('certbot', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('certificateExists', () => {
|
|
22
|
+
it('should return true if certificate files exist', async () => {
|
|
23
|
+
vi.mocked(execa)
|
|
24
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // test fullchain.pem
|
|
25
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // test privkey.pem
|
|
26
|
+
|
|
27
|
+
const result = await certificateExists('example.com')
|
|
28
|
+
|
|
29
|
+
expect(execa).toHaveBeenCalledWith('sudo', [
|
|
30
|
+
'test',
|
|
31
|
+
'-f',
|
|
32
|
+
'/etc/letsencrypt/live/example.com/fullchain.pem',
|
|
33
|
+
])
|
|
34
|
+
expect(execa).toHaveBeenCalledWith('sudo', [
|
|
35
|
+
'test',
|
|
36
|
+
'-f',
|
|
37
|
+
'/etc/letsencrypt/live/example.com/privkey.pem',
|
|
38
|
+
])
|
|
39
|
+
expect(result).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should return false if certificate files do not exist', async () => {
|
|
43
|
+
vi.mocked(execa).mockRejectedValue(new Error('File not found'))
|
|
44
|
+
|
|
45
|
+
const result = await certificateExists('example.com')
|
|
46
|
+
|
|
47
|
+
expect(result).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should fallback to certbot certificates command if file test fails', async () => {
|
|
51
|
+
vi.mocked(execa)
|
|
52
|
+
.mockRejectedValueOnce(new Error('File not found')) // test fullchain.pem fails
|
|
53
|
+
.mockResolvedValueOnce({
|
|
54
|
+
stdout: 'Certificate Name: example.com\nDomains: example.com',
|
|
55
|
+
stderr: '',
|
|
56
|
+
} as any) // certbot certificates
|
|
57
|
+
|
|
58
|
+
const result = await certificateExists('example.com')
|
|
59
|
+
|
|
60
|
+
expect(execa).toHaveBeenCalledWith('sudo', ['certbot', 'certificates'])
|
|
61
|
+
expect(result).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should return false if certbot command also fails', async () => {
|
|
65
|
+
vi.mocked(execa)
|
|
66
|
+
.mockRejectedValueOnce(new Error('File not found')) // test fullchain.pem fails
|
|
67
|
+
.mockRejectedValueOnce(new Error('Certbot error')) // certbot certificates fails
|
|
68
|
+
|
|
69
|
+
const result = await certificateExists('example.com')
|
|
70
|
+
|
|
71
|
+
expect(result).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('requestCertificate', () => {
|
|
76
|
+
it('should request certificate with correct arguments', async () => {
|
|
77
|
+
// Mock certificateExists to return false (certificate doesn't exist)
|
|
78
|
+
vi.mocked(execa)
|
|
79
|
+
.mockRejectedValueOnce(new Error('File not found')) // certificateExists - file test fails
|
|
80
|
+
.mockRejectedValueOnce(new Error('Certbot error')) // certificateExists - certbot check fails
|
|
81
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certbot certonly
|
|
82
|
+
|
|
83
|
+
await requestCertificate('example.com', 'admin@example.com', false)
|
|
84
|
+
|
|
85
|
+
expect(execa).toHaveBeenCalledWith('sudo', [
|
|
86
|
+
'certbot',
|
|
87
|
+
'certonly',
|
|
88
|
+
'--nginx',
|
|
89
|
+
'-d',
|
|
90
|
+
'example.com',
|
|
91
|
+
'--non-interactive',
|
|
92
|
+
'--agree-tos',
|
|
93
|
+
'--email',
|
|
94
|
+
'admin@example.com',
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should include --staging flag when staging is true', async () => {
|
|
99
|
+
// Mock certificateExists to return false (certificate doesn't exist)
|
|
100
|
+
vi.mocked(execa)
|
|
101
|
+
.mockRejectedValueOnce(new Error('File not found')) // certificateExists - file test fails
|
|
102
|
+
.mockRejectedValueOnce(new Error('Certbot error')) // certificateExists - certbot check fails
|
|
103
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certbot certonly
|
|
104
|
+
|
|
105
|
+
await requestCertificate('example.com', 'admin@example.com', true)
|
|
106
|
+
|
|
107
|
+
expect(execa).toHaveBeenCalledWith(
|
|
108
|
+
'sudo',
|
|
109
|
+
expect.arrayContaining(['certbot', 'certonly', '--staging'])
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should throw error if certificate already exists', async () => {
|
|
114
|
+
// Mock certificateExists to return true (both file checks succeed)
|
|
115
|
+
vi.mocked(execa)
|
|
116
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certificateExists - fullchain.pem exists
|
|
117
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certificateExists - privkey.pem exists
|
|
118
|
+
|
|
119
|
+
await expect(requestCertificate('example.com', 'admin@example.com', false)).rejects.toThrow(
|
|
120
|
+
'Certificate for example.com already exists'
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should handle certificate already exists error from certbot', async () => {
|
|
125
|
+
// Mock certificateExists to return false, but certbot returns already exists error
|
|
126
|
+
vi.mocked(execa)
|
|
127
|
+
.mockRejectedValueOnce(new Error('File not found')) // certificateExists - file test fails
|
|
128
|
+
.mockRejectedValueOnce(new Error('Certbot error')) // certificateExists - certbot check fails
|
|
129
|
+
.mockRejectedValueOnce({
|
|
130
|
+
stderr: 'Certificate already exists for example.com',
|
|
131
|
+
message: 'Certificate already exists',
|
|
132
|
+
} as any)
|
|
133
|
+
|
|
134
|
+
await expect(requestCertificate('example.com', 'admin@example.com', false)).rejects.toThrow(
|
|
135
|
+
'Certificate for example.com already exists'
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should throw error with details if certificate request fails', async () => {
|
|
140
|
+
vi.mocked(execa)
|
|
141
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certificateExists check
|
|
142
|
+
.mockRejectedValueOnce({
|
|
143
|
+
stderr: 'DNS validation failed',
|
|
144
|
+
message: 'Validation error',
|
|
145
|
+
} as any)
|
|
146
|
+
|
|
147
|
+
await expect(requestCertificate('example.com', 'admin@example.com', false)).rejects.toThrow(
|
|
148
|
+
'Failed to obtain SSL certificate for example.com'
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('renewCertificates', () => {
|
|
154
|
+
it('should renew certificates with correct command', async () => {
|
|
155
|
+
vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
|
|
156
|
+
|
|
157
|
+
await renewCertificates()
|
|
158
|
+
|
|
159
|
+
expect(execa).toHaveBeenCalledWith('sudo', ['certbot', 'renew', '--quiet'])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should throw error if renewal fails', async () => {
|
|
163
|
+
vi.mocked(execa).mockRejectedValue(new Error('Renewal failed'))
|
|
164
|
+
|
|
165
|
+
await expect(renewCertificates()).rejects.toThrow('Failed to renew SSL certificates')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('checkCertificateExpiration', () => {
|
|
170
|
+
it('should return expiration date if certificate exists', async () => {
|
|
171
|
+
const mockStdout = 'Expiry Date: 2024-12-31 23:59:59+00:00'
|
|
172
|
+
vi.mocked(execa).mockResolvedValue({ stdout: mockStdout, stderr: '' } as any)
|
|
173
|
+
|
|
174
|
+
const result = await checkCertificateExpiration('example.com')
|
|
175
|
+
|
|
176
|
+
expect(execa).toHaveBeenCalledWith('sudo', ['certbot', 'certificates', '-d', 'example.com'])
|
|
177
|
+
expect(result).toBeInstanceOf(Date)
|
|
178
|
+
// Check that it's a valid date - the exact year might vary based on parsing
|
|
179
|
+
expect(result).not.toBeNull()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('should return null if expiration date cannot be parsed', async () => {
|
|
183
|
+
vi.mocked(execa).mockResolvedValue({ stdout: 'No expiry date found', stderr: '' } as any)
|
|
184
|
+
|
|
185
|
+
const result = await checkCertificateExpiration('example.com')
|
|
186
|
+
|
|
187
|
+
expect(result).toBeNull()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should return null if command fails', async () => {
|
|
191
|
+
vi.mocked(execa).mockRejectedValue(new Error('Command failed'))
|
|
192
|
+
|
|
193
|
+
const result = await checkCertificateExpiration('example.com')
|
|
194
|
+
|
|
195
|
+
expect(result).toBeNull()
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('revokeCertificate', () => {
|
|
200
|
+
it('should revoke certificate with correct command', async () => {
|
|
201
|
+
vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
|
|
202
|
+
|
|
203
|
+
await revokeCertificate('example.com')
|
|
204
|
+
|
|
205
|
+
expect(execa).toHaveBeenCalledWith('sudo', [
|
|
206
|
+
'certbot',
|
|
207
|
+
'revoke',
|
|
208
|
+
'-d',
|
|
209
|
+
'example.com',
|
|
210
|
+
'--non-interactive',
|
|
211
|
+
])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should throw error if revocation fails', async () => {
|
|
215
|
+
vi.mocked(execa).mockRejectedValue(new Error('Revocation failed'))
|
|
216
|
+
|
|
217
|
+
await expect(revokeCertificate('example.com')).rejects.toThrow(
|
|
218
|
+
'Failed to revoke certificate for example.com'
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|