suthep 0.1.0 → 0.2.0-beta.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/README.md +172 -71
- package/dist/commands/deploy.js +251 -37
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/down.js +179 -0
- package/dist/commands/down.js.map +1 -0
- package/dist/commands/redeploy.js +59 -0
- package/dist/commands/redeploy.js.map +1 -0
- package/dist/commands/up.js +213 -0
- package/dist/commands/up.js.map +1 -0
- package/dist/index.js +36 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/certbot.js +40 -3
- package/dist/utils/certbot.js.map +1 -1
- package/dist/utils/config-loader.js +30 -0
- package/dist/utils/config-loader.js.map +1 -1
- package/dist/utils/deployment.js +49 -16
- package/dist/utils/deployment.js.map +1 -1
- package/dist/utils/docker.js +396 -25
- package/dist/utils/docker.js.map +1 -1
- package/dist/utils/nginx.js +167 -8
- package/dist/utils/nginx.js.map +1 -1
- package/docs/README.md +25 -49
- package/docs/english/01-introduction.md +84 -0
- package/docs/english/02-installation.md +200 -0
- package/docs/english/03-quick-start.md +256 -0
- package/docs/english/04-configuration.md +358 -0
- package/docs/english/05-commands.md +363 -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 +256 -0
- package/docs/thai/04-configuration.md +358 -0
- package/docs/thai/05-commands.md +363 -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/README.md +286 -53
- package/example/suthep-complete.yml +103 -0
- package/example/suthep-docker-only.yml +71 -0
- package/example/suthep-no-docker.yml +51 -0
- package/example/suthep-path-routing.yml +62 -0
- package/example/suthep.example.yml +89 -0
- package/package.json +1 -1
- package/src/commands/deploy.ts +322 -50
- package/src/commands/down.ts +240 -0
- package/src/commands/redeploy.ts +78 -0
- package/src/commands/up.ts +271 -0
- package/src/index.ts +62 -1
- package/src/types/config.ts +25 -24
- package/src/utils/certbot.ts +68 -6
- package/src/utils/config-loader.ts +40 -0
- package/src/utils/deployment.ts +61 -36
- package/src/utils/docker.ts +634 -30
- package/src/utils/nginx.ts +187 -4
- package/suthep-0.1.0-beta.1.tgz +0 -0
- package/suthep-0.1.1.tgz +0 -0
- package/suthep.example.yml +34 -0
- package/suthep.yml +39 -0
- package/test +0 -0
- package/docs/api-reference.md +0 -545
- package/docs/architecture.md +0 -367
- package/docs/commands.md +0 -273
- package/docs/configuration.md +0 -347
- package/docs/examples.md +0 -537
- package/docs/getting-started.md +0 -197
- package/docs/troubleshooting.md +0 -441
- package/example/docker-compose.yml +0 -72
- package/example/suthep.yml +0 -31
package/src/types/config.ts
CHANGED
|
@@ -3,49 +3,50 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export interface ProjectConfig {
|
|
6
|
-
name: string
|
|
7
|
-
version: string
|
|
6
|
+
name: string
|
|
7
|
+
version: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface HealthCheckConfig {
|
|
11
|
-
path: string
|
|
12
|
-
interval: number
|
|
11
|
+
path: string
|
|
12
|
+
interval: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface DockerConfig {
|
|
16
|
-
image?: string
|
|
17
|
-
container: string
|
|
18
|
-
port: number
|
|
16
|
+
image?: string
|
|
17
|
+
container: string
|
|
18
|
+
port: number
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export interface ServiceConfig {
|
|
22
|
-
name: string
|
|
23
|
-
port: number
|
|
24
|
-
domains: string[]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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>
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface NginxConfig {
|
|
31
|
-
configPath: string
|
|
32
|
-
reloadCommand: string
|
|
32
|
+
configPath: string
|
|
33
|
+
reloadCommand: string
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface CertbotConfig {
|
|
36
|
-
email: string
|
|
37
|
-
staging: boolean
|
|
37
|
+
email: string
|
|
38
|
+
staging: boolean
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export interface DeploymentConfig {
|
|
41
|
-
strategy: 'rolling' | 'blue-green'
|
|
42
|
-
healthCheckTimeout: number
|
|
42
|
+
strategy: 'rolling' | 'blue-green'
|
|
43
|
+
healthCheckTimeout: number
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface DeployConfig {
|
|
46
|
-
project: ProjectConfig
|
|
47
|
-
services: ServiceConfig[]
|
|
48
|
-
nginx: NginxConfig
|
|
49
|
-
certbot: CertbotConfig
|
|
50
|
-
deployment: DeploymentConfig
|
|
47
|
+
project: ProjectConfig
|
|
48
|
+
services: ServiceConfig[]
|
|
49
|
+
nginx: NginxConfig
|
|
50
|
+
certbot: CertbotConfig
|
|
51
|
+
deployment: DeploymentConfig
|
|
51
52
|
}
|
package/src/utils/certbot.ts
CHANGED
|
@@ -8,6 +8,14 @@ export async function requestCertificate(
|
|
|
8
8
|
email: string,
|
|
9
9
|
staging: boolean = false
|
|
10
10
|
): Promise<void> {
|
|
11
|
+
// Check if certificate already exists before requesting
|
|
12
|
+
const exists = await certificateExists(domain)
|
|
13
|
+
if (exists) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Certificate for ${domain} already exists. Use certificateExists() to check before calling this function.`
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const args = [
|
|
12
20
|
'certonly',
|
|
13
21
|
'--nginx',
|
|
@@ -25,12 +33,20 @@ export async function requestCertificate(
|
|
|
25
33
|
|
|
26
34
|
try {
|
|
27
35
|
await execa('sudo', ['certbot', ...args])
|
|
28
|
-
} catch (error) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
} catch (error: any) {
|
|
37
|
+
const errorMessage = error?.stderr || error?.message || String(error) || 'Unknown error'
|
|
38
|
+
const errorLower = errorMessage.toLowerCase()
|
|
39
|
+
|
|
40
|
+
// Check if error is due to certificate already existing
|
|
41
|
+
if (
|
|
42
|
+
errorLower.includes('certificate already exists') ||
|
|
43
|
+
errorLower.includes('already have a certificate') ||
|
|
44
|
+
errorLower.includes('duplicate certificate')
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`Certificate for ${domain} already exists. Skipping certificate creation.`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw new Error(`Failed to obtain SSL certificate for ${domain}: ${errorMessage}`)
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
|
|
@@ -47,6 +63,52 @@ export async function renewCertificates(): Promise<void> {
|
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Check if a certificate exists for a domain
|
|
68
|
+
*/
|
|
69
|
+
export async function certificateExists(domain: string): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
// First, check if certificate files exist using test command (most reliable)
|
|
72
|
+
try {
|
|
73
|
+
await execa('sudo', ['test', '-f', `/etc/letsencrypt/live/${domain}/fullchain.pem`])
|
|
74
|
+
await execa('sudo', ['test', '-f', `/etc/letsencrypt/live/${domain}/privkey.pem`])
|
|
75
|
+
// Both files exist
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
// Files don't exist, continue to certbot check
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback: Check using certbot certificates command
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execa('sudo', ['certbot', 'certificates'])
|
|
84
|
+
|
|
85
|
+
// Check if the domain appears in the certificates list
|
|
86
|
+
const lines = stdout.split('\n')
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
const line = lines[i]
|
|
89
|
+
// Check if this line contains "Domains:" and includes our domain
|
|
90
|
+
if (line.includes('Domains:') && line.includes(domain)) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
// Also check for the domain in certificate paths
|
|
94
|
+
if (
|
|
95
|
+
line.includes(domain) &&
|
|
96
|
+
(line.includes('/live/') || line.includes('Certificate Name:'))
|
|
97
|
+
) {
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// If certbot command fails, assume no certificate exists
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// If all checks fail, assume no certificate exists
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
50
112
|
/**
|
|
51
113
|
* Check certificate expiration for a domain
|
|
52
114
|
*/
|
|
@@ -33,6 +33,10 @@ function validateConfig(config: any): asserts config is DeployConfig {
|
|
|
33
33
|
throw new Error('Configuration must include at least one service')
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Track ports and container names to detect conflicts
|
|
37
|
+
const usedPorts = new Map<number, string[]>()
|
|
38
|
+
const usedContainers = new Map<string, string>()
|
|
39
|
+
|
|
36
40
|
for (const service of config.services) {
|
|
37
41
|
if (!service.name) {
|
|
38
42
|
throw new Error('Each service must have a name')
|
|
@@ -43,6 +47,42 @@ function validateConfig(config: any): asserts config is DeployConfig {
|
|
|
43
47
|
if (!service.domains || !Array.isArray(service.domains) || service.domains.length === 0) {
|
|
44
48
|
throw new Error(`Service ${service.name} must have at least one domain`)
|
|
45
49
|
}
|
|
50
|
+
|
|
51
|
+
// Check for port conflicts
|
|
52
|
+
if (usedPorts.has(service.port)) {
|
|
53
|
+
const conflictingServices = usedPorts.get(service.port)!
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Port conflict: Service "${service.name}" uses port ${
|
|
56
|
+
service.port
|
|
57
|
+
} which is already used by: ${conflictingServices.join(
|
|
58
|
+
', '
|
|
59
|
+
)}. Each service must use a unique port.`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
usedPorts.set(service.port, [service.name])
|
|
63
|
+
|
|
64
|
+
// Check for Docker container name conflicts
|
|
65
|
+
if (service.docker) {
|
|
66
|
+
const containerName = service.docker.container
|
|
67
|
+
if (usedContainers.has(containerName)) {
|
|
68
|
+
const conflictingService = usedContainers.get(containerName)!
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Docker container name conflict: Service "${service.name}" uses container name "${containerName}" which is already used by service "${conflictingService}". Each Docker container must have a unique name.`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
usedContainers.set(containerName, service.name)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check for duplicate service names
|
|
78
|
+
const serviceNames = new Set<string>()
|
|
79
|
+
for (const service of config.services) {
|
|
80
|
+
if (serviceNames.has(service.name)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Duplicate service name: "${service.name}" is used multiple times. Each service must have a unique name.`
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
serviceNames.add(service.name)
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
if (!config.nginx) {
|
package/src/utils/deployment.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DeploymentConfig, ServiceConfig } from '../types/config'
|
|
2
|
+
import type { ZeroDowntimeContainerInfo } from './docker'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Perform a health check on a service endpoint
|
|
@@ -33,12 +34,13 @@ export async function performHealthCheck(url: string, timeout: number = 30000):
|
|
|
33
34
|
*/
|
|
34
35
|
export async function deployService(
|
|
35
36
|
service: ServiceConfig,
|
|
36
|
-
deploymentConfig: DeploymentConfig
|
|
37
|
+
deploymentConfig: DeploymentConfig,
|
|
38
|
+
tempInfo: ZeroDowntimeContainerInfo | null = null
|
|
37
39
|
): Promise<void> {
|
|
38
40
|
if (deploymentConfig.strategy === 'rolling') {
|
|
39
|
-
await rollingDeploy(service, deploymentConfig)
|
|
41
|
+
await rollingDeploy(service, deploymentConfig, tempInfo)
|
|
40
42
|
} else if (deploymentConfig.strategy === 'blue-green') {
|
|
41
|
-
await blueGreenDeploy(service, deploymentConfig)
|
|
43
|
+
await blueGreenDeploy(service, deploymentConfig, tempInfo)
|
|
42
44
|
} else {
|
|
43
45
|
throw new Error(`Unknown deployment strategy: ${deploymentConfig.strategy}`)
|
|
44
46
|
}
|
|
@@ -46,27 +48,37 @@ export async function deployService(
|
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Rolling deployment strategy
|
|
49
|
-
*
|
|
51
|
+
* For single instance, uses zero-downtime approach similar to blue-green
|
|
50
52
|
*/
|
|
51
53
|
async function rollingDeploy(
|
|
52
54
|
service: ServiceConfig,
|
|
53
|
-
deploymentConfig: DeploymentConfig
|
|
55
|
+
deploymentConfig: DeploymentConfig,
|
|
56
|
+
tempInfo: ZeroDowntimeContainerInfo | null
|
|
54
57
|
): Promise<void> {
|
|
55
|
-
// For rolling deployment:
|
|
56
|
-
//
|
|
57
|
-
// 2. Wait for health check
|
|
58
|
-
// 3. Switch traffic to new instance
|
|
59
|
-
// 4. Stop old instance (if applicable)
|
|
58
|
+
// For rolling deployment with single instance:
|
|
59
|
+
// Similar to blue-green - use temporary container and port
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
if (!tempInfo || !tempInfo.oldContainerExists) {
|
|
62
|
+
// No existing container, just check health on the new container
|
|
63
|
+
if (service.healthCheck) {
|
|
64
|
+
const healthUrl = `http://localhost:${service.port}${service.healthCheck.path}`
|
|
65
|
+
const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (!isHealthy) {
|
|
68
|
+
throw new Error(`Service ${service.name} failed health check during rolling deployment`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
// Check health on temporary port
|
|
73
|
+
if (service.healthCheck) {
|
|
74
|
+
const healthUrl = `http://localhost:${tempInfo.tempPort}${service.healthCheck.path}`
|
|
75
|
+
const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
|
|
76
|
+
|
|
77
|
+
if (!isHealthy) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Service ${service.name} failed health check on temporary container during rolling deployment`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
70
82
|
}
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -75,28 +87,41 @@ async function rollingDeploy(
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
78
|
-
* Blue-green deployment strategy
|
|
79
|
-
*
|
|
90
|
+
* Blue-green deployment strategy for single instance
|
|
91
|
+
* Uses temporary container and port for zero-downtime deployment
|
|
80
92
|
*/
|
|
81
93
|
async function blueGreenDeploy(
|
|
82
94
|
service: ServiceConfig,
|
|
83
|
-
deploymentConfig: DeploymentConfig
|
|
95
|
+
deploymentConfig: DeploymentConfig,
|
|
96
|
+
tempInfo: ZeroDowntimeContainerInfo | null
|
|
84
97
|
): Promise<void> {
|
|
85
|
-
// For blue-green deployment:
|
|
86
|
-
// 1.
|
|
87
|
-
// 2. Run health checks on
|
|
88
|
-
// 3. Switch
|
|
89
|
-
// 4.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// For blue-green deployment with single instance:
|
|
99
|
+
// 1. New container is already started on temporary port (handled in deploy command)
|
|
100
|
+
// 2. Run health checks on new container
|
|
101
|
+
// 3. Switch nginx to new port (handled in deploy command)
|
|
102
|
+
// 4. Stop old container and promote new one (handled in deploy command)
|
|
103
|
+
|
|
104
|
+
if (!tempInfo || !tempInfo.oldContainerExists) {
|
|
105
|
+
// No existing container, just check health on the new container
|
|
106
|
+
if (service.healthCheck) {
|
|
107
|
+
const healthUrl = `http://localhost:${service.port}${service.healthCheck.path}`
|
|
108
|
+
const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
|
|
109
|
+
|
|
110
|
+
if (!isHealthy) {
|
|
111
|
+
throw new Error(`Service ${service.name} failed health check during blue-green deployment`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// Check health on temporary port
|
|
116
|
+
if (service.healthCheck) {
|
|
117
|
+
const healthUrl = `http://localhost:${tempInfo.tempPort}${service.healthCheck.path}`
|
|
118
|
+
const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
|
|
119
|
+
|
|
120
|
+
if (!isHealthy) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Service ${service.name} failed health check on temporary container during blue-green deployment`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
100
125
|
}
|
|
101
126
|
}
|
|
102
127
|
}
|