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,184 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import yaml from 'js-yaml'
|
|
3
|
+
import { dirname, resolve } from 'path'
|
|
4
|
+
import type { DeployConfig } from '../types/config'
|
|
5
|
+
import { loadAndApplyEnvFiles } from './env-loader'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Substitute environment variables in a string
|
|
9
|
+
* Replaces ${VAR_NAME} or ${VAR_NAME:-default} patterns with values from envVars or process.env
|
|
10
|
+
*
|
|
11
|
+
* @param text The text containing variable references
|
|
12
|
+
* @param envVars Environment variables object
|
|
13
|
+
* @returns Text with variables substituted
|
|
14
|
+
*/
|
|
15
|
+
function substituteEnvVars(text: string, envVars: Record<string, string>): string {
|
|
16
|
+
// Match ${VAR_NAME} or ${VAR_NAME:-default} patterns
|
|
17
|
+
return text.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (match, varName, defaultValue) => {
|
|
18
|
+
// Check envVars first, then process.env
|
|
19
|
+
const value = envVars[varName] || process.env[varName]
|
|
20
|
+
|
|
21
|
+
if (value !== undefined && value !== null) {
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Use default value if provided
|
|
26
|
+
if (defaultValue !== undefined) {
|
|
27
|
+
return defaultValue
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Return original match if not found and no default
|
|
31
|
+
return match
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Recursively substitute environment variables in an object
|
|
37
|
+
*/
|
|
38
|
+
function substituteInObject(obj: any, envVars: Record<string, string>): any {
|
|
39
|
+
if (typeof obj === 'string') {
|
|
40
|
+
return substituteEnvVars(obj, envVars)
|
|
41
|
+
} else if (Array.isArray(obj)) {
|
|
42
|
+
return obj.map((item) => substituteInObject(item, envVars))
|
|
43
|
+
} else if (obj !== null && typeof obj === 'object') {
|
|
44
|
+
const result: any = {}
|
|
45
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
46
|
+
result[key] = substituteInObject(value, envVars)
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
return obj
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load and parse a YAML configuration file with environment variable substitution
|
|
55
|
+
* Variables in the format ${VAR_NAME} or ${VAR_NAME:-default} will be replaced
|
|
56
|
+
* with values from .env files or process.env
|
|
57
|
+
*/
|
|
58
|
+
export async function loadConfig(filePath: string): Promise<DeployConfig> {
|
|
59
|
+
try {
|
|
60
|
+
// Load .env files from the directory containing the config file
|
|
61
|
+
const configDir = dirname(resolve(filePath))
|
|
62
|
+
const envVars = await loadAndApplyEnvFiles(configDir)
|
|
63
|
+
|
|
64
|
+
// Read the YAML file content
|
|
65
|
+
const fileContent = await fs.readFile(filePath, 'utf8')
|
|
66
|
+
|
|
67
|
+
// Substitute environment variables in the YAML content before parsing
|
|
68
|
+
const substitutedContent = substituteEnvVars(fileContent, envVars)
|
|
69
|
+
|
|
70
|
+
// Parse the YAML
|
|
71
|
+
const config = yaml.load(substitutedContent) as DeployConfig
|
|
72
|
+
|
|
73
|
+
// Also substitute in the parsed object (in case YAML parsing didn't handle strings properly)
|
|
74
|
+
const finalConfig = substituteInObject(config, envVars) as DeployConfig
|
|
75
|
+
|
|
76
|
+
validateConfig(finalConfig)
|
|
77
|
+
|
|
78
|
+
return finalConfig
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof Error) {
|
|
81
|
+
throw new Error(`Failed to load configuration from ${filePath}: ${error.message}`)
|
|
82
|
+
}
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate the configuration object
|
|
89
|
+
*/
|
|
90
|
+
function validateConfig(config: any): asserts config is DeployConfig {
|
|
91
|
+
if (!config.project || !config.project.name) {
|
|
92
|
+
throw new Error('Configuration must include project.name')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!config.services || !Array.isArray(config.services) || config.services.length === 0) {
|
|
96
|
+
throw new Error('Configuration must include at least one service')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Track ports and container names to detect conflicts
|
|
100
|
+
const usedPorts = new Map<number, string[]>()
|
|
101
|
+
const usedContainers = new Map<string, string>()
|
|
102
|
+
|
|
103
|
+
for (const service of config.services) {
|
|
104
|
+
if (!service.name) {
|
|
105
|
+
throw new Error('Each service must have a name')
|
|
106
|
+
}
|
|
107
|
+
if (!service.port) {
|
|
108
|
+
throw new Error(`Service ${service.name} must have a port`)
|
|
109
|
+
}
|
|
110
|
+
if (!service.domains || !Array.isArray(service.domains) || service.domains.length === 0) {
|
|
111
|
+
throw new Error(`Service ${service.name} must have at least one domain`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check for port conflicts
|
|
115
|
+
if (usedPorts.has(service.port)) {
|
|
116
|
+
const conflictingServices = usedPorts.get(service.port)!
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Port conflict: Service "${service.name}" uses port ${
|
|
119
|
+
service.port
|
|
120
|
+
} which is already used by: ${conflictingServices.join(
|
|
121
|
+
', '
|
|
122
|
+
)}. Each service must use a unique port.`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
usedPorts.set(service.port, [service.name])
|
|
126
|
+
|
|
127
|
+
// Check for Docker container name conflicts
|
|
128
|
+
if (service.docker) {
|
|
129
|
+
const containerName = service.docker.container
|
|
130
|
+
if (usedContainers.has(containerName)) {
|
|
131
|
+
const conflictingService = usedContainers.get(containerName)!
|
|
132
|
+
throw new Error(
|
|
133
|
+
`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.`
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
usedContainers.set(containerName, service.name)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for duplicate service names
|
|
141
|
+
const serviceNames = new Set<string>()
|
|
142
|
+
for (const service of config.services) {
|
|
143
|
+
if (serviceNames.has(service.name)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Duplicate service name: "${service.name}" is used multiple times. Each service must have a unique name.`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
serviceNames.add(service.name)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!config.nginx) {
|
|
152
|
+
config.nginx = {
|
|
153
|
+
configPath: '/etc/nginx/sites-available',
|
|
154
|
+
reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!config.certbot) {
|
|
159
|
+
config.certbot = {
|
|
160
|
+
email: '',
|
|
161
|
+
staging: false,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!config.deployment) {
|
|
166
|
+
config.deployment = {
|
|
167
|
+
strategy: 'rolling',
|
|
168
|
+
healthCheckTimeout: 30000,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Save configuration to a YAML file
|
|
175
|
+
*/
|
|
176
|
+
export async function saveConfig(filePath: string, config: DeployConfig): Promise<void> {
|
|
177
|
+
const yamlContent = yaml.dump(config, {
|
|
178
|
+
indent: 2,
|
|
179
|
+
lineWidth: 120,
|
|
180
|
+
noRefs: true,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
await fs.writeFile(filePath, yamlContent, 'utf8')
|
|
184
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { DeploymentConfig, ServiceConfig } from '../types/config'
|
|
2
|
+
import type { ZeroDowntimeContainerInfo } from './docker'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Perform a health check on a service endpoint
|
|
6
|
+
*/
|
|
7
|
+
export async function performHealthCheck(url: string, timeout: number = 30000): Promise<boolean> {
|
|
8
|
+
const startTime = Date.now()
|
|
9
|
+
const interval = 2000 // Check every 2 seconds
|
|
10
|
+
|
|
11
|
+
while (Date.now() - startTime < timeout) {
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
method: 'GET',
|
|
15
|
+
signal: AbortSignal.timeout(5000), // 5 second timeout per request
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
if (response.ok) {
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
} catch (error) {
|
|
22
|
+
// Endpoint not ready yet, continue waiting
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Wait before next check
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, interval))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deploy a service with zero-downtime strategy
|
|
34
|
+
*/
|
|
35
|
+
export async function deployService(
|
|
36
|
+
service: ServiceConfig,
|
|
37
|
+
deploymentConfig: DeploymentConfig,
|
|
38
|
+
tempInfo: ZeroDowntimeContainerInfo | null = null
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
if (deploymentConfig.strategy === 'rolling') {
|
|
41
|
+
await rollingDeploy(service, deploymentConfig, tempInfo)
|
|
42
|
+
} else if (deploymentConfig.strategy === 'blue-green') {
|
|
43
|
+
await blueGreenDeploy(service, deploymentConfig, tempInfo)
|
|
44
|
+
} else {
|
|
45
|
+
throw new Error(`Unknown deployment strategy: ${deploymentConfig.strategy}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rolling deployment strategy
|
|
51
|
+
* For single instance, uses zero-downtime approach similar to blue-green
|
|
52
|
+
*/
|
|
53
|
+
async function rollingDeploy(
|
|
54
|
+
service: ServiceConfig,
|
|
55
|
+
deploymentConfig: DeploymentConfig,
|
|
56
|
+
tempInfo: ZeroDowntimeContainerInfo | null
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
// For rolling deployment with single instance:
|
|
59
|
+
// Similar to blue-green - use temporary container and port
|
|
60
|
+
|
|
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)
|
|
66
|
+
|
|
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
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Add a small delay to ensure service is fully ready
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Blue-green deployment strategy for single instance
|
|
91
|
+
* Uses temporary container and port for zero-downtime deployment
|
|
92
|
+
*/
|
|
93
|
+
async function blueGreenDeploy(
|
|
94
|
+
service: ServiceConfig,
|
|
95
|
+
deploymentConfig: DeploymentConfig,
|
|
96
|
+
tempInfo: ZeroDowntimeContainerInfo | null
|
|
97
|
+
): Promise<void> {
|
|
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
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wait for a service to become healthy
|
|
131
|
+
*/
|
|
132
|
+
export async function waitForService(
|
|
133
|
+
service: ServiceConfig,
|
|
134
|
+
timeout: number = 60000
|
|
135
|
+
): Promise<boolean> {
|
|
136
|
+
if (!service.healthCheck) {
|
|
137
|
+
// No health check configured, assume service is ready after a short delay
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 5000))
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const healthUrl = `http://localhost:${service.port}${service.healthCheck.path}`
|
|
143
|
+
return await performHealthCheck(healthUrl, timeout)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Gracefully shutdown a service
|
|
148
|
+
*/
|
|
149
|
+
export async function gracefulShutdown(
|
|
150
|
+
_service: ServiceConfig,
|
|
151
|
+
timeout: number = 30000
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
// Send shutdown signal and wait for graceful termination
|
|
154
|
+
// This is a placeholder - actual implementation would depend on how services are managed
|
|
155
|
+
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(timeout, 5000)))
|
|
157
|
+
}
|