suthep 0.1.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/.editorconfig +17 -0
- package/.prettierignore +6 -0
- package/.prettierrc +7 -0
- package/.vscode/settings.json +19 -0
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/dist/commands/deploy.js +318 -0
- package/dist/commands/deploy.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/index.js +19 -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 +95 -0
- package/dist/utils/config-loader.js.map +1 -0
- package/dist/utils/deployment.js +76 -0
- package/dist/utils/deployment.js.map +1 -0
- package/dist/utils/docker.js +393 -0
- package/dist/utils/docker.js.map +1 -0
- package/dist/utils/nginx.js +303 -0
- package/dist/utils/nginx.js.map +1 -0
- package/docs/README.md +95 -0
- package/docs/TRANSLATIONS.md +211 -0
- package/docs/en/README.md +76 -0
- package/docs/en/api-reference.md +545 -0
- package/docs/en/architecture.md +369 -0
- package/docs/en/commands.md +273 -0
- package/docs/en/configuration.md +347 -0
- package/docs/en/developer-guide.md +588 -0
- package/docs/en/docker-ports-config.md +333 -0
- package/docs/en/examples.md +537 -0
- package/docs/en/getting-started.md +202 -0
- package/docs/en/port-binding.md +268 -0
- package/docs/en/troubleshooting.md +441 -0
- package/docs/th/README.md +64 -0
- package/docs/th/commands.md +202 -0
- package/docs/th/configuration.md +325 -0
- package/docs/th/getting-started.md +203 -0
- package/example/README.md +85 -0
- package/example/docker-compose.yml +76 -0
- package/example/docker-ports-example.yml +81 -0
- package/example/muacle.yml +47 -0
- package/example/port-binding-example.yml +45 -0
- package/example/suthep.yml +46 -0
- package/example/suthep=1.yml +46 -0
- package/package.json +45 -0
- package/src/commands/deploy.ts +405 -0
- package/src/commands/init.ts +214 -0
- package/src/commands/setup.ts +112 -0
- package/src/index.ts +42 -0
- package/src/types/config.ts +52 -0
- package/src/utils/certbot.ts +144 -0
- package/src/utils/config-loader.ts +121 -0
- package/src/utils/deployment.ts +157 -0
- package/src/utils/docker.ts +755 -0
- package/src/utils/nginx.ts +326 -0
- package/suthep-0.1.1.tgz +0 -0
- package/suthep.example.yml +98 -0
- package/test +0 -0
- package/todo.md +6 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +46 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
|
|
4
|
+
interface SetupOptions {
|
|
5
|
+
nginxOnly?: boolean;
|
|
6
|
+
certbotOnly?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function setupCommand(options: SetupOptions): Promise<void> {
|
|
10
|
+
console.log(chalk.blue.bold('\nš§ Setting up prerequisites\n'));
|
|
11
|
+
|
|
12
|
+
const setupNginx = !options.certbotOnly;
|
|
13
|
+
const setupCertbot = !options.nginxOnly;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Setup Nginx
|
|
17
|
+
if (setupNginx) {
|
|
18
|
+
console.log(chalk.cyan('š¦ Installing Nginx...'));
|
|
19
|
+
|
|
20
|
+
// Check if Nginx is already installed
|
|
21
|
+
try {
|
|
22
|
+
await execa('nginx', ['-v']);
|
|
23
|
+
console.log(chalk.green('ā
Nginx is already installed'));
|
|
24
|
+
} catch {
|
|
25
|
+
// Install Nginx based on OS
|
|
26
|
+
const platform = process.platform;
|
|
27
|
+
|
|
28
|
+
if (platform === 'linux') {
|
|
29
|
+
// Detect Linux distribution
|
|
30
|
+
try {
|
|
31
|
+
await execa('apt-get', ['--version']);
|
|
32
|
+
console.log(chalk.dim('Using apt-get...'));
|
|
33
|
+
await execa('sudo', ['apt-get', 'update'], { stdio: 'inherit' });
|
|
34
|
+
await execa('sudo', ['apt-get', 'install', '-y', 'nginx'], { stdio: 'inherit' });
|
|
35
|
+
} catch {
|
|
36
|
+
try {
|
|
37
|
+
await execa('yum', ['--version']);
|
|
38
|
+
console.log(chalk.dim('Using yum...'));
|
|
39
|
+
await execa('sudo', ['yum', 'install', '-y', 'nginx'], { stdio: 'inherit' });
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error('Unsupported Linux distribution. Please install Nginx manually.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else if (platform === 'darwin') {
|
|
45
|
+
console.log(chalk.dim('Using Homebrew...'));
|
|
46
|
+
await execa('brew', ['install', 'nginx'], { stdio: 'inherit' });
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`Unsupported platform: ${platform}. Please install Nginx manually.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(chalk.green('ā
Nginx installed successfully'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Start Nginx service
|
|
55
|
+
console.log(chalk.cyan('š Starting Nginx service...'));
|
|
56
|
+
try {
|
|
57
|
+
await execa('sudo', ['systemctl', 'start', 'nginx']);
|
|
58
|
+
await execa('sudo', ['systemctl', 'enable', 'nginx']);
|
|
59
|
+
console.log(chalk.green('ā
Nginx service started'));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.log(chalk.yellow('ā ļø Could not start Nginx via systemctl (might not be available)'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Setup Certbot
|
|
66
|
+
if (setupCertbot) {
|
|
67
|
+
console.log(chalk.cyan('\nš Installing Certbot...'));
|
|
68
|
+
|
|
69
|
+
// Check if Certbot is already installed
|
|
70
|
+
try {
|
|
71
|
+
await execa('certbot', ['--version']);
|
|
72
|
+
console.log(chalk.green('ā
Certbot is already installed'));
|
|
73
|
+
} catch {
|
|
74
|
+
const platform = process.platform;
|
|
75
|
+
|
|
76
|
+
if (platform === 'linux') {
|
|
77
|
+
// Install Certbot based on package manager
|
|
78
|
+
try {
|
|
79
|
+
await execa('apt-get', ['--version']);
|
|
80
|
+
console.log(chalk.dim('Using apt-get...'));
|
|
81
|
+
await execa('sudo', ['apt-get', 'update'], { stdio: 'inherit' });
|
|
82
|
+
await execa('sudo', ['apt-get', 'install', '-y', 'certbot', 'python3-certbot-nginx'], { stdio: 'inherit' });
|
|
83
|
+
} catch {
|
|
84
|
+
try {
|
|
85
|
+
await execa('yum', ['--version']);
|
|
86
|
+
console.log(chalk.dim('Using yum...'));
|
|
87
|
+
await execa('sudo', ['yum', 'install', '-y', 'certbot', 'python3-certbot-nginx'], { stdio: 'inherit' });
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error('Unsupported Linux distribution. Please install Certbot manually.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (platform === 'darwin') {
|
|
93
|
+
console.log(chalk.dim('Using Homebrew...'));
|
|
94
|
+
await execa('brew', ['install', 'certbot'], { stdio: 'inherit' });
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`Unsupported platform: ${platform}. Please install Certbot manually.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(chalk.green('ā
Certbot installed successfully'));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(chalk.green.bold('\n⨠Setup completed successfully!\n'));
|
|
104
|
+
console.log(chalk.dim('Next steps:'));
|
|
105
|
+
console.log(chalk.dim(' 1. Create a configuration file: suthep init'));
|
|
106
|
+
console.log(chalk.dim(' 2. Deploy your services: suthep deploy\n'));
|
|
107
|
+
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error(chalk.red('\nā Setup failed:'), error instanceof Error ? error.message : error);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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 { initCommand } from './commands/init'
|
|
7
|
+
import { setupCommand } from './commands/setup'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = dirname(__filename)
|
|
11
|
+
const packageJsonPath = join(__dirname, '..', 'package.json')
|
|
12
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
|
|
13
|
+
|
|
14
|
+
const program = new Command()
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('suthep')
|
|
18
|
+
.description('CLI tool for deploying projects with automatic Nginx reverse proxy and HTTPS setup')
|
|
19
|
+
.version(packageJson.version)
|
|
20
|
+
|
|
21
|
+
program
|
|
22
|
+
.command('init')
|
|
23
|
+
.description('Initialize a new deployment configuration file')
|
|
24
|
+
.option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
|
|
25
|
+
.action(initCommand)
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command('setup')
|
|
29
|
+
.description('Setup Nginx and Certbot on the system')
|
|
30
|
+
.option('--nginx-only', 'Only setup Nginx')
|
|
31
|
+
.option('--certbot-only', 'Only setup Certbot')
|
|
32
|
+
.action(setupCommand)
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('deploy')
|
|
36
|
+
.description('Deploy a project using the configuration file')
|
|
37
|
+
.option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
|
|
38
|
+
.option('--no-https', 'Skip HTTPS setup')
|
|
39
|
+
.option('--no-nginx', 'Skip Nginx configuration')
|
|
40
|
+
.action(deployCommand)
|
|
41
|
+
|
|
42
|
+
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,144 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Request an SSL certificate from Let's Encrypt using Certbot
|
|
5
|
+
*/
|
|
6
|
+
export async function requestCertificate(
|
|
7
|
+
domain: string,
|
|
8
|
+
email: string,
|
|
9
|
+
staging: boolean = false
|
|
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
|
+
|
|
19
|
+
const args = [
|
|
20
|
+
'certonly',
|
|
21
|
+
'--nginx',
|
|
22
|
+
'-d',
|
|
23
|
+
domain,
|
|
24
|
+
'--non-interactive',
|
|
25
|
+
'--agree-tos',
|
|
26
|
+
'--email',
|
|
27
|
+
email,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
if (staging) {
|
|
31
|
+
args.push('--staging')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await execa('sudo', ['certbot', ...args])
|
|
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}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renew all SSL certificates
|
|
55
|
+
*/
|
|
56
|
+
export async function renewCertificates(): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await execa('sudo', ['certbot', 'renew', '--quiet'])
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to renew SSL certificates: ${error instanceof Error ? error.message : error}`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
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
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check certificate expiration for a domain
|
|
114
|
+
*/
|
|
115
|
+
export async function checkCertificateExpiration(domain: string): Promise<Date | null> {
|
|
116
|
+
try {
|
|
117
|
+
const { stdout } = await execa('sudo', ['certbot', 'certificates', '-d', domain])
|
|
118
|
+
|
|
119
|
+
// Parse expiration date from output
|
|
120
|
+
const expiryMatch = stdout.match(/Expiry Date: ([^\n]+)/)
|
|
121
|
+
if (expiryMatch) {
|
|
122
|
+
return new Date(expiryMatch[1])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Revoke a certificate for a domain
|
|
133
|
+
*/
|
|
134
|
+
export async function revokeCertificate(domain: string): Promise<void> {
|
|
135
|
+
try {
|
|
136
|
+
await execa('sudo', ['certbot', 'revoke', '-d', domain, '--non-interactive'])
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Failed to revoke certificate for ${domain}: ${
|
|
140
|
+
error instanceof Error ? error.message : error
|
|
141
|
+
}`
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import yaml from 'js-yaml'
|
|
3
|
+
import type { DeployConfig } from '../types/config'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load and parse a YAML configuration file
|
|
7
|
+
*/
|
|
8
|
+
export async function loadConfig(filePath: string): Promise<DeployConfig> {
|
|
9
|
+
try {
|
|
10
|
+
const fileContent = await fs.readFile(filePath, 'utf8')
|
|
11
|
+
const config = yaml.load(fileContent) as DeployConfig
|
|
12
|
+
|
|
13
|
+
validateConfig(config)
|
|
14
|
+
|
|
15
|
+
return config
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
throw new Error(`Failed to load configuration from ${filePath}: ${error.message}`)
|
|
19
|
+
}
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate the configuration object
|
|
26
|
+
*/
|
|
27
|
+
function validateConfig(config: any): asserts config is DeployConfig {
|
|
28
|
+
if (!config.project || !config.project.name) {
|
|
29
|
+
throw new Error('Configuration must include project.name')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!config.services || !Array.isArray(config.services) || config.services.length === 0) {
|
|
33
|
+
throw new Error('Configuration must include at least one service')
|
|
34
|
+
}
|
|
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
|
+
|
|
40
|
+
for (const service of config.services) {
|
|
41
|
+
if (!service.name) {
|
|
42
|
+
throw new Error('Each service must have a name')
|
|
43
|
+
}
|
|
44
|
+
if (!service.port) {
|
|
45
|
+
throw new Error(`Service ${service.name} must have a port`)
|
|
46
|
+
}
|
|
47
|
+
if (!service.domains || !Array.isArray(service.domains) || service.domains.length === 0) {
|
|
48
|
+
throw new Error(`Service ${service.name} must have at least one domain`)
|
|
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)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!config.nginx) {
|
|
89
|
+
config.nginx = {
|
|
90
|
+
configPath: '/etc/nginx/sites-available',
|
|
91
|
+
reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!config.certbot) {
|
|
96
|
+
config.certbot = {
|
|
97
|
+
email: '',
|
|
98
|
+
staging: false,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!config.deployment) {
|
|
103
|
+
config.deployment = {
|
|
104
|
+
strategy: 'rolling',
|
|
105
|
+
healthCheckTimeout: 30000,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Save configuration to a YAML file
|
|
112
|
+
*/
|
|
113
|
+
export async function saveConfig(filePath: string, config: DeployConfig): Promise<void> {
|
|
114
|
+
const yamlContent = yaml.dump(config, {
|
|
115
|
+
indent: 2,
|
|
116
|
+
lineWidth: 120,
|
|
117
|
+
noRefs: true,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await fs.writeFile(filePath, yamlContent, 'utf8')
|
|
121
|
+
}
|
|
@@ -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
|
+
}
|