@akash-chowdhury-24/deployhub 2.0.0 → 2.0.3
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 +227 -5
- package/package.json +4 -3
- package/src/commands/deploy.js +1 -1
- package/src/commands/doctor.js +244 -30
- package/src/commands/init.js +66 -223
- package/src/commands/rollback.js +1 -1
- package/src/core/config.js +15 -0
- package/src/deployment/deployment-env.js +625 -0
- package/src/deployment/init-helpers.js +471 -0
- package/src/deployment/init-prompts.js +434 -0
- package/src/deployment/providers/azure-vm.js +85 -2
- package/src/deployment/providers/docker.js +101 -8
- package/src/deployment/providers/ec2.js +88 -2
- package/src/deployment/providers/gcp-vm.js +89 -2
- package/src/deployment/providers/kubernetes.js +104 -7
- package/src/deployment/providers/ssh.js +70 -42
- package/src/utils/github-actions.js +24 -37
- package/src/{rollback → utils/rollback}/engine.js +6 -6
- package/src/utils/shell-quote.js +64 -0
|
@@ -1,7 +1,93 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
1
2
|
import { createSshProvider } from './ssh.js';
|
|
3
|
+
import { createLogger } from '../../logger/index.js';
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
7
|
+
* @param {string} envName
|
|
8
|
+
* @param {Record<string, string>} [env]
|
|
9
|
+
*/
|
|
10
|
+
export function createEc2Provider(config, envName, env = process.env) {
|
|
11
|
+
const log = createLogger('ec2');
|
|
12
|
+
const instanceId = env.EC2_INSTANCE_ID;
|
|
13
|
+
const region = env.AWS_REGION || 'us-east-1';
|
|
14
|
+
|
|
15
|
+
async function resolveHost() {
|
|
16
|
+
const environment = config.environments[envName];
|
|
17
|
+
if (environment?.host || env.SSH_HOST) {
|
|
18
|
+
return environment?.host || env.SSH_HOST;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!instanceId) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'EC2 host unknown. Set SSH_HOST to your instance public IP, or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
log.info(`Looking up public IP for instance ${instanceId}...`);
|
|
28
|
+
|
|
29
|
+
/** @type {Record<string, string>} */
|
|
30
|
+
const awsEnv = { ...process.env };
|
|
31
|
+
if (env.AWS_ACCESS_KEY_ID) awsEnv.AWS_ACCESS_KEY_ID = env.AWS_ACCESS_KEY_ID;
|
|
32
|
+
if (env.AWS_SECRET_ACCESS_KEY) awsEnv.AWS_SECRET_ACCESS_KEY = env.AWS_SECRET_ACCESS_KEY;
|
|
33
|
+
awsEnv.AWS_DEFAULT_REGION = region;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execa(
|
|
37
|
+
'aws',
|
|
38
|
+
[
|
|
39
|
+
'ec2',
|
|
40
|
+
'describe-instances',
|
|
41
|
+
'--instance-ids',
|
|
42
|
+
instanceId,
|
|
43
|
+
'--query',
|
|
44
|
+
'Reservations[0].Instances[0].PublicIpAddress',
|
|
45
|
+
'--output',
|
|
46
|
+
'text',
|
|
47
|
+
'--region',
|
|
48
|
+
region,
|
|
49
|
+
],
|
|
50
|
+
{ stdio: 'pipe', env: awsEnv }
|
|
51
|
+
);
|
|
52
|
+
const publicIp = stdout.trim();
|
|
53
|
+
if (!publicIp || publicIp === 'None') {
|
|
54
|
+
throw new Error('No public IP returned');
|
|
55
|
+
}
|
|
56
|
+
log.info(`Resolved EC2 host: ${publicIp}`);
|
|
57
|
+
return publicIp;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Could not resolve public IP for ${instanceId} — ${msg}. Set SSH_HOST manually or install/configure AWS CLI with ec2:DescribeInstances access.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sshProvider = createSshProvider(config, envName, env);
|
|
67
|
+
|
|
68
|
+
async function connect() {
|
|
69
|
+
const host = await resolveHost();
|
|
70
|
+
const environment = config.environments[envName];
|
|
71
|
+
if (environment && !environment.host) {
|
|
72
|
+
environment.host = host;
|
|
73
|
+
}
|
|
74
|
+
if (!env.SSH_HOST) {
|
|
75
|
+
env.SSH_HOST = host;
|
|
76
|
+
}
|
|
77
|
+
return sshProvider.connect();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...sshProvider,
|
|
82
|
+
connect,
|
|
83
|
+
deploy: sshProvider.deploy.bind(sshProvider),
|
|
84
|
+
rollback: sshProvider.rollback.bind(sshProvider),
|
|
85
|
+
healthCheck: sshProvider.healthCheck.bind(sshProvider),
|
|
86
|
+
testConnection: async () => {
|
|
87
|
+
const ssh = await connect();
|
|
88
|
+
ssh.dispose();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
5
91
|
}
|
|
6
92
|
|
|
7
93
|
export default { createEc2Provider };
|
|
@@ -1,7 +1,94 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
1
2
|
import { createSshProvider } from './ssh.js';
|
|
3
|
+
import { createLogger } from '../../logger/index.js';
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
7
|
+
* @param {string} envName
|
|
8
|
+
* @param {Record<string, string>} [env]
|
|
9
|
+
*/
|
|
10
|
+
export function createGcpVmProvider(config, envName, env = process.env) {
|
|
11
|
+
const log = createLogger('gcp-vm');
|
|
12
|
+
const projectId = env.GCP_PROJECT_ID;
|
|
13
|
+
const zone = env.GCP_ZONE;
|
|
14
|
+
const instanceName = env.GCP_INSTANCE_NAME;
|
|
15
|
+
|
|
16
|
+
async function resolveHost() {
|
|
17
|
+
const environment = config.environments[envName];
|
|
18
|
+
if (environment?.host || env.SSH_HOST) {
|
|
19
|
+
return environment?.host || env.SSH_HOST;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!projectId || !zone || !instanceName) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'GCP VM host unknown. Set SSH_HOST to your instance external IP, or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
log.info(`Looking up external IP for GCP instance ${instanceName}...`);
|
|
29
|
+
|
|
30
|
+
/** @type {Record<string, string>} */
|
|
31
|
+
const gcpEnv = { ...process.env };
|
|
32
|
+
if (env.GCP_KEY_FILE) {
|
|
33
|
+
gcpEnv.GOOGLE_APPLICATION_CREDENTIALS = env.GCP_KEY_FILE;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execa(
|
|
38
|
+
'gcloud',
|
|
39
|
+
[
|
|
40
|
+
'compute',
|
|
41
|
+
'instances',
|
|
42
|
+
'describe',
|
|
43
|
+
instanceName,
|
|
44
|
+
'--zone',
|
|
45
|
+
zone,
|
|
46
|
+
'--project',
|
|
47
|
+
projectId,
|
|
48
|
+
'--format',
|
|
49
|
+
'get(networkInterfaces[0].accessConfigs[0].natIP)',
|
|
50
|
+
],
|
|
51
|
+
{ stdio: 'pipe', env: gcpEnv }
|
|
52
|
+
);
|
|
53
|
+
const publicIp = stdout.trim();
|
|
54
|
+
if (!publicIp) {
|
|
55
|
+
throw new Error('No external IP returned');
|
|
56
|
+
}
|
|
57
|
+
log.info(`Resolved GCP VM host: ${publicIp}`);
|
|
58
|
+
return publicIp;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Could not resolve external IP for ${instanceName} — ${msg}. Set SSH_HOST manually or run gcloud auth login and verify project/zone/instance name.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sshProvider = createSshProvider(config, envName, env);
|
|
68
|
+
|
|
69
|
+
async function connect() {
|
|
70
|
+
const host = await resolveHost();
|
|
71
|
+
const environment = config.environments[envName];
|
|
72
|
+
if (environment && !environment.host) {
|
|
73
|
+
environment.host = host;
|
|
74
|
+
}
|
|
75
|
+
if (!env.SSH_HOST) {
|
|
76
|
+
env.SSH_HOST = host;
|
|
77
|
+
}
|
|
78
|
+
return sshProvider.connect();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...sshProvider,
|
|
83
|
+
connect,
|
|
84
|
+
deploy: sshProvider.deploy.bind(sshProvider),
|
|
85
|
+
rollback: sshProvider.rollback.bind(sshProvider),
|
|
86
|
+
healthCheck: sshProvider.healthCheck.bind(sshProvider),
|
|
87
|
+
testConnection: async () => {
|
|
88
|
+
const ssh = await connect();
|
|
89
|
+
ssh.dispose();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
5
92
|
}
|
|
6
93
|
|
|
7
94
|
export default { createGcpVmProvider };
|
|
@@ -1,27 +1,124 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
2
5
|
import { createLogger } from '../../logger/index.js';
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
9
|
+
* @param {string} envName
|
|
10
|
+
* @param {Record<string, string>} [env]
|
|
11
|
+
*/
|
|
12
|
+
export function createKubernetesProvider(config, envName, env = process.env) {
|
|
5
13
|
const log = createLogger('kubernetes');
|
|
6
14
|
|
|
15
|
+
const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
16
|
+
const context = env.KUBE_CONTEXT || '';
|
|
17
|
+
const namespace = env.KUBE_NAMESPACE || config.project || 'default';
|
|
18
|
+
|
|
19
|
+
function getKubectlEnv() {
|
|
20
|
+
const expanded = kubeconfig.replace(/^~/, os.homedir());
|
|
21
|
+
return { ...process.env, KUBECONFIG: path.resolve(expanded) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string[]} baseArgs
|
|
26
|
+
*/
|
|
27
|
+
function kubectlArgs(baseArgs) {
|
|
28
|
+
/** @type {string[]} */
|
|
29
|
+
const args = [...baseArgs];
|
|
30
|
+
if (context) args.push('--context', context);
|
|
31
|
+
if (namespace) args.push('--namespace', namespace);
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} artifactDir
|
|
37
|
+
*/
|
|
7
38
|
async function deploy(artifactDir) {
|
|
8
|
-
log.info(
|
|
9
|
-
|
|
10
|
-
|
|
39
|
+
log.info(`Deploying to Kubernetes (namespace: ${namespace}${context ? `, context: ${context}` : ''})...`);
|
|
40
|
+
|
|
41
|
+
const manifestDir = artifactDir;
|
|
42
|
+
const hasManifests =
|
|
43
|
+
(await fs.pathExists(path.join(manifestDir, 'k8s'))) ||
|
|
44
|
+
(await fs.readdir(manifestDir)).some((f) => /\.ya?ml$/.test(f));
|
|
45
|
+
|
|
46
|
+
if (!hasManifests) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'No Kubernetes manifests found in artifact. Add .yaml files or a k8s/ directory to your project.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
|
|
53
|
+
? path.join(manifestDir, 'k8s')
|
|
54
|
+
: manifestDir;
|
|
55
|
+
|
|
56
|
+
await execa('kubectl', kubectlArgs(['apply', '-f', applyTarget]), {
|
|
11
57
|
stdio: 'inherit',
|
|
58
|
+
env: getKubectlEnv(),
|
|
12
59
|
});
|
|
60
|
+
|
|
61
|
+
const imageName = env.DOCKER_IMAGE_NAME;
|
|
62
|
+
const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
|
|
63
|
+
if (imageName && config.project) {
|
|
64
|
+
await execa(
|
|
65
|
+
'kubectl',
|
|
66
|
+
kubectlArgs([
|
|
67
|
+
'set',
|
|
68
|
+
'image',
|
|
69
|
+
`deployment/${config.project}`,
|
|
70
|
+
`${config.project}=${imageName}:${imageTag}`,
|
|
71
|
+
]),
|
|
72
|
+
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
73
|
+
).catch(() => {
|
|
74
|
+
log.warn('kubectl set image skipped (deployment name may differ from project name)');
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
log.success('Kubernetes deployment complete');
|
|
13
79
|
}
|
|
14
80
|
|
|
15
81
|
async function rollback(artifactDir) {
|
|
16
|
-
|
|
82
|
+
log.info('Rolling back Kubernetes deployment...');
|
|
83
|
+
await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${config.project}`]), {
|
|
84
|
+
stdio: 'inherit',
|
|
85
|
+
env: getKubectlEnv(),
|
|
86
|
+
}).catch(async () => {
|
|
87
|
+
log.warn('kubectl rollout undo failed — redeploying previous artifact instead');
|
|
88
|
+
await deploy(artifactDir);
|
|
89
|
+
});
|
|
17
90
|
}
|
|
18
91
|
|
|
19
92
|
async function healthCheck() {
|
|
20
|
-
|
|
93
|
+
const url = config.healthCheck?.url;
|
|
94
|
+
if (url) {
|
|
95
|
+
try {
|
|
96
|
+
const { stdout } = await execa('curl', ['-sf', '-o', '/dev/null', '-w', '%{http_code}', url], {
|
|
97
|
+
stdio: 'pipe',
|
|
98
|
+
});
|
|
99
|
+
return stdout.trim().startsWith('2');
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await execa(
|
|
107
|
+
'kubectl',
|
|
108
|
+
kubectlArgs(['rollout', 'status', `deployment/${config.project}`, '--timeout=30s']),
|
|
109
|
+
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
110
|
+
);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
21
115
|
}
|
|
22
116
|
|
|
23
117
|
async function testConnection() {
|
|
24
|
-
await execa('kubectl', ['cluster-info'], {
|
|
118
|
+
await execa('kubectl', kubectlArgs(['cluster-info']), {
|
|
119
|
+
stdio: 'pipe',
|
|
120
|
+
env: getKubectlEnv(),
|
|
121
|
+
});
|
|
25
122
|
}
|
|
26
123
|
|
|
27
124
|
return { deploy, rollback, healthCheck, testConnection };
|
|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { createLogger } from '../../logger/index.js';
|
|
6
6
|
import { getNginxSitePath } from '../../utils/nginx.js';
|
|
7
|
+
import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
|
|
7
8
|
|
|
8
9
|
/** @type {Set<string>} */
|
|
9
10
|
const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
|
|
@@ -12,6 +13,8 @@ const PYTHON_FRAMEWORKS = new Set(['fastapi', 'django', 'flask', 'python']);
|
|
|
12
13
|
/** @type {Set<string>} */
|
|
13
14
|
const PHP_FRAMEWORKS = new Set(['laravel', 'symfony', 'php']);
|
|
14
15
|
|
|
16
|
+
const sh = shellQuote;
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
17
20
|
* @param {string} envName
|
|
@@ -39,27 +42,44 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
39
42
|
const port = environment.port || config.port || Number(env.SSH_PORT) || 3000;
|
|
40
43
|
const sshKey = env.SSH_KEY;
|
|
41
44
|
const keyPath = environment.keyPath || env.SSH_KEY_PATH;
|
|
45
|
+
const sshPort = Number(env.SSH_SSH_PORT) || environment.sshPort || 22;
|
|
42
46
|
|
|
43
47
|
const log = createLogger('ssh');
|
|
44
48
|
|
|
45
49
|
async function connect() {
|
|
46
50
|
if (!host || !user) {
|
|
47
|
-
throw new Error(
|
|
51
|
+
throw new Error(
|
|
52
|
+
'SSH host and user are required. Set SSH_HOST and SSH_USER in .env (see .env.example comments).'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!sshKey && !keyPath) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'SSH authentication required. Set SSH_KEY_PATH (local) or SSH_KEY (CI secret) in .env — see .env.example.'
|
|
59
|
+
);
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
const ssh = new NodeSSH();
|
|
51
63
|
/** @type {import('node-ssh').SSHConnectOptions} */
|
|
52
|
-
const connectOpts = { host, username: user };
|
|
64
|
+
const connectOpts = { host, username: user, port: sshPort };
|
|
53
65
|
|
|
54
66
|
if (sshKey) {
|
|
55
67
|
const tmpKeyPath = path.join(os.tmpdir(), 'deployhub-ssh-key');
|
|
56
68
|
await fs.writeFile(tmpKeyPath, sshKey, { mode: 0o600 });
|
|
57
69
|
connectOpts.privateKeyPath = tmpKeyPath;
|
|
58
70
|
} else if (keyPath) {
|
|
59
|
-
|
|
71
|
+
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
72
|
+
connectOpts.privateKeyPath = path.resolve(expanded);
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
|
|
75
|
+
try {
|
|
76
|
+
await ssh.connect(connectOpts);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
throw new Error(
|
|
80
|
+
`SSH connection failed to ${user}@${host}:${sshPort} — ${msg}. Check SSH_HOST, SSH_USER, SSH_KEY_PATH, and that port ${sshPort} is open in your firewall/security group.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
63
83
|
return ssh;
|
|
64
84
|
}
|
|
65
85
|
|
|
@@ -71,7 +91,14 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
71
91
|
log.info(`$ ${command}`);
|
|
72
92
|
const result = await ssh.execCommand(command);
|
|
73
93
|
if (result.code !== 0 && result.code !== null) {
|
|
74
|
-
|
|
94
|
+
const message = formatRemoteCommandFailure(
|
|
95
|
+
command,
|
|
96
|
+
result.code,
|
|
97
|
+
result.stderr,
|
|
98
|
+
result.stdout
|
|
99
|
+
);
|
|
100
|
+
log.error(message);
|
|
101
|
+
throw new Error(message);
|
|
75
102
|
}
|
|
76
103
|
return result;
|
|
77
104
|
}
|
|
@@ -106,26 +133,27 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
106
133
|
async function runBackendStartSequence(ssh, targetPath) {
|
|
107
134
|
const framework = resolveFramework();
|
|
108
135
|
const startCommand = resolveStartCommand();
|
|
136
|
+
const dir = sh(targetPath);
|
|
109
137
|
|
|
110
138
|
if (NODE_FRAMEWORKS.has(framework)) {
|
|
111
|
-
await exec(ssh, `cd ${
|
|
139
|
+
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
112
140
|
const start = startCommand || 'npm start';
|
|
113
141
|
if (start === 'npm start') {
|
|
114
142
|
await exec(
|
|
115
143
|
ssh,
|
|
116
|
-
`cd ${
|
|
144
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
|
|
117
145
|
);
|
|
118
146
|
} else if (start.startsWith('npm run ')) {
|
|
119
147
|
const script = start.replace('npm run ', '');
|
|
120
148
|
await exec(
|
|
121
149
|
ssh,
|
|
122
|
-
`cd ${
|
|
150
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- run ${script}`
|
|
123
151
|
);
|
|
124
152
|
} else {
|
|
125
153
|
const [cmd, ...args] = start.split(' ');
|
|
126
154
|
await exec(
|
|
127
155
|
ssh,
|
|
128
|
-
`cd ${
|
|
156
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start ${cmd} --name ${sh(appName)} -- ${args.join(' ')}`
|
|
129
157
|
);
|
|
130
158
|
}
|
|
131
159
|
await exec(ssh, 'pm2 save');
|
|
@@ -133,15 +161,15 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
if (PYTHON_FRAMEWORKS.has(framework)) {
|
|
136
|
-
await exec(ssh, `cd ${
|
|
164
|
+
await exec(ssh, `cd ${dir} && pip install -r requirements.txt`);
|
|
137
165
|
if (framework === 'django') {
|
|
138
|
-
await exec(ssh, `cd ${
|
|
166
|
+
await exec(ssh, `cd ${dir} && python manage.py migrate`);
|
|
139
167
|
}
|
|
140
168
|
if (framework === 'fastapi') {
|
|
141
169
|
await exec(ssh, 'pkill uvicorn || true');
|
|
142
170
|
await exec(
|
|
143
171
|
ssh,
|
|
144
|
-
`cd ${
|
|
172
|
+
`cd ${dir} && nohup uvicorn main:app --host 0.0.0.0 --port ${port} > app.log 2>&1 &`
|
|
145
173
|
);
|
|
146
174
|
} else {
|
|
147
175
|
await exec(ssh, 'pkill gunicorn || true');
|
|
@@ -149,17 +177,17 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
149
177
|
framework === 'django' ? 'config.wsgi:application' : 'app:app';
|
|
150
178
|
await exec(
|
|
151
179
|
ssh,
|
|
152
|
-
`cd ${
|
|
180
|
+
`cd ${dir} && nohup gunicorn ${appTarget} --bind 0.0.0.0:${port} --daemon`
|
|
153
181
|
);
|
|
154
182
|
}
|
|
155
183
|
return;
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
159
|
-
await exec(ssh, `cd ${
|
|
187
|
+
await exec(ssh, `cd ${dir} && composer install --no-dev`);
|
|
160
188
|
if (framework === 'laravel') {
|
|
161
|
-
await exec(ssh, `cd ${
|
|
162
|
-
await exec(ssh, `cd ${
|
|
189
|
+
await exec(ssh, `cd ${dir} && php artisan migrate --force`);
|
|
190
|
+
await exec(ssh, `cd ${dir} && php artisan config:cache`);
|
|
163
191
|
}
|
|
164
192
|
await exec(ssh, 'sudo systemctl restart php8.2-fpm');
|
|
165
193
|
await exec(ssh, 'sudo systemctl reload nginx');
|
|
@@ -167,47 +195,47 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
if (framework === 'spring' || framework === 'java') {
|
|
170
|
-
await exec(ssh, `cd ${
|
|
198
|
+
await exec(ssh, `cd ${dir} && pkill -f "*.jar" || true`);
|
|
171
199
|
await exec(
|
|
172
200
|
ssh,
|
|
173
|
-
`cd ${
|
|
201
|
+
`cd ${dir} && nohup java -jar target/*.jar > app.log 2>&1 &`
|
|
174
202
|
);
|
|
175
203
|
return;
|
|
176
204
|
}
|
|
177
205
|
|
|
178
206
|
if (framework === 'go') {
|
|
179
|
-
await exec(ssh, `cd ${
|
|
207
|
+
await exec(ssh, `cd ${dir} && pkill ${sh(appName)} || true`);
|
|
180
208
|
await exec(
|
|
181
209
|
ssh,
|
|
182
|
-
`cd ${
|
|
210
|
+
`cd ${dir} && nohup ./bin/app > app.log 2>&1 &`
|
|
183
211
|
);
|
|
184
212
|
return;
|
|
185
213
|
}
|
|
186
214
|
|
|
187
215
|
if (framework === 'dotnet') {
|
|
188
|
-
await exec(ssh, `cd ${
|
|
216
|
+
await exec(ssh, `cd ${dir} && pkill -f "dotnet" || true`);
|
|
189
217
|
const dll = startCommand?.replace('dotnet ', '') || 'App.dll';
|
|
190
218
|
await exec(
|
|
191
219
|
ssh,
|
|
192
|
-
`cd ${
|
|
220
|
+
`cd ${dir} && nohup dotnet ${dll} > app.log 2>&1 &`
|
|
193
221
|
);
|
|
194
222
|
return;
|
|
195
223
|
}
|
|
196
224
|
|
|
197
225
|
if (framework === 'rails') {
|
|
198
|
-
await exec(ssh, `cd ${
|
|
199
|
-
await exec(ssh, `cd ${
|
|
226
|
+
await exec(ssh, `cd ${dir} && bundle install --deployment`);
|
|
227
|
+
await exec(ssh, `cd ${dir} && pkill puma || true`);
|
|
200
228
|
await exec(
|
|
201
229
|
ssh,
|
|
202
|
-
`cd ${
|
|
230
|
+
`cd ${dir} && nohup bundle exec puma -p ${port} > app.log 2>&1 &`
|
|
203
231
|
);
|
|
204
232
|
return;
|
|
205
233
|
}
|
|
206
234
|
|
|
207
|
-
await exec(ssh, `cd ${
|
|
235
|
+
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
208
236
|
await exec(
|
|
209
237
|
ssh,
|
|
210
|
-
`cd ${
|
|
238
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
|
|
211
239
|
);
|
|
212
240
|
await exec(ssh, 'pm2 save');
|
|
213
241
|
}
|
|
@@ -222,11 +250,11 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
222
250
|
|
|
223
251
|
await exec(
|
|
224
252
|
ssh,
|
|
225
|
-
`sudo cp ${nginxConfRemote} ${sitePath} 2>/dev/null || sudo cp ${targetPath}/nginx.conf ${sitePath}`
|
|
253
|
+
`sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)} 2>/dev/null || sudo cp ${sh(`${targetPath}/nginx.conf`)} ${sh(sitePath)}`
|
|
226
254
|
);
|
|
227
255
|
await exec(
|
|
228
256
|
ssh,
|
|
229
|
-
`sudo ln -sf ${sitePath}
|
|
257
|
+
`sudo ln -sf ${sh(sitePath)} ${sh(`/etc/nginx/sites-enabled/${path.basename(sitePath)}`)}`
|
|
230
258
|
);
|
|
231
259
|
await exec(ssh, 'sudo nginx -t');
|
|
232
260
|
await exec(ssh, 'sudo systemctl reload nginx');
|
|
@@ -238,8 +266,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
238
266
|
* @param {string} targetPath
|
|
239
267
|
*/
|
|
240
268
|
async function extractToPath(ssh, remoteZip, targetPath) {
|
|
241
|
-
await exec(ssh, `mkdir -p ${targetPath}`);
|
|
242
|
-
await exec(ssh, `unzip -o ${remoteZip} -d ${targetPath}`);
|
|
269
|
+
await exec(ssh, `mkdir -p ${sh(targetPath)}`);
|
|
270
|
+
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
|
|
243
271
|
}
|
|
244
272
|
|
|
245
273
|
/**
|
|
@@ -259,19 +287,19 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
259
287
|
|
|
260
288
|
if (projectType === 'both') {
|
|
261
289
|
const remoteStaging = `/tmp/deployhub-staging-${Date.now()}`;
|
|
262
|
-
await exec(ssh, `mkdir -p ${remoteStaging}`);
|
|
263
|
-
await exec(ssh, `unzip -o ${remoteZip} -d ${remoteStaging}`);
|
|
290
|
+
await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
|
|
291
|
+
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
|
|
264
292
|
|
|
265
|
-
await exec(ssh, `mkdir -p ${frontendDeployPath}`);
|
|
293
|
+
await exec(ssh, `mkdir -p ${sh(frontendDeployPath)}`);
|
|
266
294
|
await exec(
|
|
267
295
|
ssh,
|
|
268
|
-
`rsync -a ${remoteStaging}/ ${frontendDeployPath}/ --exclude backend || cp -r ${remoteStaging}/* ${frontendDeployPath}/`
|
|
296
|
+
`rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
|
|
269
297
|
);
|
|
270
298
|
|
|
271
|
-
await exec(ssh, `mkdir -p ${backendDeployPath}`);
|
|
299
|
+
await exec(ssh, `mkdir -p ${sh(backendDeployPath)}`);
|
|
272
300
|
await exec(
|
|
273
301
|
ssh,
|
|
274
|
-
`rsync -a ${remoteStaging}/backend/ ${backendDeployPath}/ || cp -r ${remoteStaging}/backend/* ${backendDeployPath}/`
|
|
302
|
+
`rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
|
|
275
303
|
);
|
|
276
304
|
|
|
277
305
|
if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
|
|
@@ -279,7 +307,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
279
307
|
}
|
|
280
308
|
|
|
281
309
|
await runBackendStartSequence(ssh, backendDeployPath);
|
|
282
|
-
await exec(ssh, `rm -rf ${remoteStaging}`);
|
|
310
|
+
await exec(ssh, `rm -rf ${sh(remoteStaging)}`);
|
|
283
311
|
} else if (projectType === 'backend') {
|
|
284
312
|
log.info(`Backend deploy path: ${deployPath}`);
|
|
285
313
|
await extractToPath(ssh, remoteZip, deployPath);
|
|
@@ -296,7 +324,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
326
|
|
|
299
|
-
await exec(ssh, `rm -f ${remoteZip}`);
|
|
327
|
+
await exec(ssh, `rm -f ${sh(remoteZip)}`);
|
|
300
328
|
log.success('Deployment complete');
|
|
301
329
|
} finally {
|
|
302
330
|
ssh.dispose();
|
|
@@ -308,8 +336,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
308
336
|
* @param {string} remotePath
|
|
309
337
|
*/
|
|
310
338
|
async function remoteFileExists(ssh, remotePath) {
|
|
311
|
-
const result = await ssh.execCommand(`test -f ${remotePath} && echo yes`);
|
|
312
|
-
return result.stdout.trim() === 'yes';
|
|
339
|
+
const result = await ssh.execCommand(`test -f ${sh(remotePath)} && echo yes`);
|
|
340
|
+
return result.code === 0 && result.stdout.trim() === 'yes';
|
|
313
341
|
}
|
|
314
342
|
|
|
315
343
|
async function rollback(artifactDir) {
|
|
@@ -322,7 +350,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
322
350
|
|
|
323
351
|
const ssh = await connect();
|
|
324
352
|
try {
|
|
325
|
-
const result = await ssh.execCommand(`curl -sf -o /dev/null -w "%{http_code}"
|
|
353
|
+
const result = await ssh.execCommand(`curl -sf -o /dev/null -w "%{http_code}" ${sh(url)}`);
|
|
326
354
|
return result.stdout.trim().startsWith('2');
|
|
327
355
|
} finally {
|
|
328
356
|
ssh.dispose();
|