@akash-chowdhury-24/deployhub 1.0.16 → 2.0.2
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 +264 -59
- package/package.json +4 -3
- package/src/commands/doctor.js +168 -170
- package/src/commands/init.js +80 -371
- package/src/commands/rollback.js +1 -1
- package/src/core/config.js +17 -13
- package/src/core/stages.js +0 -11
- package/src/deployment/deployment-env.js +625 -0
- package/src/deployment/index.js +0 -72
- package/src/deployment/init-helpers.js +389 -0
- package/src/deployment/init-prompts.js +432 -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 +21 -4
- package/src/utils/github-actions.js +28 -131
- package/src/utils/rollback/engine.js +69 -0
- package/src/deployment/providers/platforms/_shared.js +0 -167
- package/src/deployment/providers/platforms/aws-amplify.js +0 -367
- package/src/deployment/providers/platforms/azure-static-web-apps.js +0 -68
- package/src/deployment/providers/platforms/cloudflare-pages.js +0 -103
- package/src/deployment/providers/platforms/firebase-app-hosting.js +0 -95
- package/src/deployment/providers/platforms/firebase-hosting.js +0 -99
- package/src/deployment/providers/platforms/index.js +0 -44
- package/src/deployment/providers/platforms/netlify.js +0 -102
- package/src/deployment/providers/platforms/vercel.js +0 -92
- package/src/rollback/engine.js +0 -102
- package/src/utils/firebase-config-generator.js +0 -35
- package/src/utils/init-platform.js +0 -236
- package/src/utils/platform-env.js +0 -132
|
@@ -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 };
|
|
@@ -39,27 +39,44 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
39
39
|
const port = environment.port || config.port || Number(env.SSH_PORT) || 3000;
|
|
40
40
|
const sshKey = env.SSH_KEY;
|
|
41
41
|
const keyPath = environment.keyPath || env.SSH_KEY_PATH;
|
|
42
|
+
const sshPort = Number(env.SSH_SSH_PORT) || environment.sshPort || 22;
|
|
42
43
|
|
|
43
44
|
const log = createLogger('ssh');
|
|
44
45
|
|
|
45
46
|
async function connect() {
|
|
46
47
|
if (!host || !user) {
|
|
47
|
-
throw new Error(
|
|
48
|
+
throw new Error(
|
|
49
|
+
'SSH host and user are required. Set SSH_HOST and SSH_USER in .env (see .env.example comments).'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!sshKey && !keyPath) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'SSH authentication required. Set SSH_KEY_PATH (local) or SSH_KEY (CI secret) in .env — see .env.example.'
|
|
56
|
+
);
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
const ssh = new NodeSSH();
|
|
51
60
|
/** @type {import('node-ssh').SSHConnectOptions} */
|
|
52
|
-
const connectOpts = { host, username: user };
|
|
61
|
+
const connectOpts = { host, username: user, port: sshPort };
|
|
53
62
|
|
|
54
63
|
if (sshKey) {
|
|
55
64
|
const tmpKeyPath = path.join(os.tmpdir(), 'deployhub-ssh-key');
|
|
56
65
|
await fs.writeFile(tmpKeyPath, sshKey, { mode: 0o600 });
|
|
57
66
|
connectOpts.privateKeyPath = tmpKeyPath;
|
|
58
67
|
} else if (keyPath) {
|
|
59
|
-
|
|
68
|
+
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
69
|
+
connectOpts.privateKeyPath = path.resolve(expanded);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
try {
|
|
73
|
+
await ssh.connect(connectOpts);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
76
|
+
throw new Error(
|
|
77
|
+
`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.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
63
80
|
return ssh;
|
|
64
81
|
}
|
|
65
82
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
4
|
-
PLATFORM_ENV_MAP,
|
|
5
|
-
PLATFORM_CLI_MAP,
|
|
6
|
-
PLATFORM_CHOICES,
|
|
7
|
-
getPlatformEnvExample,
|
|
8
|
-
} from './platform-env.js';
|
|
9
3
|
import { getWorkflowHeaderComment } from './author.js';
|
|
4
|
+
import {
|
|
5
|
+
generateDeploymentEnvSection,
|
|
6
|
+
getDeploymentSecretKeys,
|
|
7
|
+
DEPLOYMENT_ENV_KEYS,
|
|
8
|
+
} from '../deployment/deployment-env.js';
|
|
10
9
|
|
|
11
10
|
/** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
|
|
12
11
|
|
|
@@ -28,34 +27,35 @@ const PROVIDER_ENV_MAP = {
|
|
|
28
27
|
dropbox: ['DROPBOX_ACCESS_TOKEN'],
|
|
29
28
|
local: [],
|
|
30
29
|
ftp: ['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD'],
|
|
31
|
-
ssh:
|
|
30
|
+
ssh: DEPLOYMENT_ENV_KEYS.ssh,
|
|
31
|
+
docker: DEPLOYMENT_ENV_KEYS.docker,
|
|
32
|
+
ec2: DEPLOYMENT_ENV_KEYS.ec2,
|
|
33
|
+
'azure-vm': DEPLOYMENT_ENV_KEYS['azure-vm'],
|
|
34
|
+
'gcp-vm': DEPLOYMENT_ENV_KEYS['gcp-vm'],
|
|
35
|
+
kubernetes: DEPLOYMENT_ENV_KEYS.kubernetes,
|
|
32
36
|
};
|
|
33
37
|
|
|
34
|
-
const BACKEND_SSH_ENV_VARS = [
|
|
35
|
-
'SSH_DEPLOY_PATH',
|
|
36
|
-
'SSH_APP_NAME',
|
|
37
|
-
'SSH_PORT',
|
|
38
|
-
];
|
|
39
|
-
|
|
40
38
|
const PROVIDER_LABELS = {
|
|
41
39
|
aws: 'AWS S3',
|
|
42
40
|
azure: 'Azure Blob',
|
|
43
|
-
gcp: 'GCP',
|
|
41
|
+
gcp: 'GCP Storage',
|
|
44
42
|
gdrive: 'Google Drive',
|
|
45
43
|
dropbox: 'Dropbox',
|
|
46
44
|
ftp: 'FTP',
|
|
47
45
|
ssh: 'SSH Deployment',
|
|
46
|
+
docker: 'Docker Deployment',
|
|
47
|
+
ec2: 'AWS EC2 Deployment',
|
|
48
|
+
'azure-vm': 'Azure VM Deployment',
|
|
49
|
+
'gcp-vm': 'GCP VM Deployment',
|
|
50
|
+
kubernetes: 'Kubernetes Deployment',
|
|
48
51
|
};
|
|
49
52
|
|
|
50
|
-
const PLATFORM_LABELS = Object.fromEntries(
|
|
51
|
-
PLATFORM_CHOICES.map(({ name, value }) => [value, name])
|
|
52
|
-
);
|
|
53
|
-
|
|
54
53
|
const ENV_VAR_DEFAULTS = {
|
|
55
54
|
AWS_REGION: 'us-east-1',
|
|
56
55
|
FTP_PORT: '21',
|
|
57
56
|
FTP_PATH: '/uploads',
|
|
58
57
|
SSH_DEPLOY_PATH: '/var/www/app',
|
|
58
|
+
SSH_SSH_PORT: '22',
|
|
59
59
|
SMTP_PORT: '587',
|
|
60
60
|
};
|
|
61
61
|
|
|
@@ -208,54 +208,6 @@ function getBackendSetupSteps(config) {
|
|
|
208
208
|
return steps;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
/**
|
|
212
|
-
* @param {Record<string, unknown>[]} environments
|
|
213
|
-
* @returns {string[]}
|
|
214
|
-
*/
|
|
215
|
-
function getPlatformInstallSteps(environments) {
|
|
216
|
-
/** @type {Set<string>} */
|
|
217
|
-
const steps = new Set();
|
|
218
|
-
|
|
219
|
-
for (const env of environments) {
|
|
220
|
-
if (env.deploymentType !== 'platform' && env.frontendDeploymentType !== 'platform') {
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const platform = env.platform;
|
|
224
|
-
if (!platform) continue;
|
|
225
|
-
const cli = PLATFORM_CLI_MAP[platform];
|
|
226
|
-
if (cli?.globalInstall) {
|
|
227
|
-
steps.add(` - name: Install ${platform} CLI\n run: ${cli.globalInstall}`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return Array.from(steps);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* @param {string[]} deployEnvironments
|
|
236
|
-
* @param {Record<string, Record<string, unknown>>} environments
|
|
237
|
-
*/
|
|
238
|
-
function collectPlatformEnvVars(deployEnvironments, environments) {
|
|
239
|
-
/** @type {Set<string>} */
|
|
240
|
-
const envVars = new Set();
|
|
241
|
-
|
|
242
|
-
for (const envName of deployEnvironments) {
|
|
243
|
-
const env = environments[envName];
|
|
244
|
-
if (!env) continue;
|
|
245
|
-
|
|
246
|
-
if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
|
|
247
|
-
const platform = env.platform;
|
|
248
|
-
if (!platform) continue;
|
|
249
|
-
const keys = PLATFORM_ENV_MAP[platform] || [];
|
|
250
|
-
for (const key of keys) {
|
|
251
|
-
envVars.add(`${key}: \${{ secrets.${key} }}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return envVars;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
211
|
/**
|
|
260
212
|
* @param {string[]} storageProviders
|
|
261
213
|
* @param {string[]} deployEnvironments
|
|
@@ -285,27 +237,12 @@ export function generateWorkflowYaml(
|
|
|
285
237
|
const env = environments[envName];
|
|
286
238
|
if (!env) continue;
|
|
287
239
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
envVars.add(`${key}: \${{ secrets.${key} }}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (env.type === 'ssh' && config) {
|
|
296
|
-
const projectType = config.projectType || 'frontend';
|
|
297
|
-
if (projectType === 'backend' || projectType === 'both') {
|
|
298
|
-
for (const key of BACKEND_SSH_ENV_VARS) {
|
|
299
|
-
envVars.add(`${key}: \${{ secrets.${key} }}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
240
|
+
const keys = getDeploymentSecretKeys(env.type, config);
|
|
241
|
+
for (const key of keys) {
|
|
242
|
+
envVars.add(`${key}: \${{ secrets.${key} }}`);
|
|
302
243
|
}
|
|
303
244
|
}
|
|
304
245
|
|
|
305
|
-
for (const line of collectPlatformEnvVars(deployEnvironments, environments)) {
|
|
306
|
-
envVars.add(line);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
246
|
const envBlock = Array.from(envVars)
|
|
310
247
|
.map((line) => ` ${line}`)
|
|
311
248
|
.join('\n');
|
|
@@ -317,11 +254,6 @@ export function generateWorkflowYaml(
|
|
|
317
254
|
? `${getGithubGitConfigStep()}\n`
|
|
318
255
|
: '';
|
|
319
256
|
|
|
320
|
-
const envList = deployEnvironments
|
|
321
|
-
.map((name) => environments[name])
|
|
322
|
-
.filter(Boolean);
|
|
323
|
-
const platformSteps = getPlatformInstallSteps(envList).join('\n');
|
|
324
|
-
|
|
325
257
|
const projectType = config?.projectType || 'frontend';
|
|
326
258
|
const installDepsCommand =
|
|
327
259
|
projectType === 'backend' || projectType === 'both'
|
|
@@ -342,7 +274,7 @@ ${uniqueBackendSteps ? `${uniqueBackendSteps}\n` : ''} - uses: actions/setu
|
|
|
342
274
|
node-version: '20'
|
|
343
275
|
${githubGitConfigStep} - name: Install project dependencies
|
|
344
276
|
run: ${installDepsCommand}
|
|
345
|
-
|
|
277
|
+
- name: Install DeployHub CLI
|
|
346
278
|
run: npm install ${installSpec} --no-save
|
|
347
279
|
- run: ${getCliBuildCommand()}
|
|
348
280
|
env:
|
|
@@ -481,23 +413,8 @@ export function getRequiredSecrets(
|
|
|
481
413
|
const env = environments[envName];
|
|
482
414
|
if (!env) continue;
|
|
483
415
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (platform) {
|
|
487
|
-
const keys = PLATFORM_ENV_MAP[platform] || [];
|
|
488
|
-
keys.forEach((k) => secrets.add(k));
|
|
489
|
-
}
|
|
490
|
-
} else {
|
|
491
|
-
const keys = PROVIDER_ENV_MAP[env.type] || [];
|
|
492
|
-
keys.forEach((k) => secrets.add(k));
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
if (env.type === 'ssh' && config) {
|
|
496
|
-
const projectType = config.projectType || 'frontend';
|
|
497
|
-
if (projectType === 'backend' || projectType === 'both') {
|
|
498
|
-
BACKEND_SSH_ENV_VARS.forEach((k) => secrets.add(k));
|
|
499
|
-
}
|
|
500
|
-
}
|
|
416
|
+
const keys = getDeploymentSecretKeys(env.type, config);
|
|
417
|
+
keys.forEach((k) => secrets.add(k));
|
|
501
418
|
}
|
|
502
419
|
|
|
503
420
|
return Array.from(secrets);
|
|
@@ -558,31 +475,11 @@ export function generateEnvExampleContent(
|
|
|
558
475
|
|
|
559
476
|
for (const envName of deployEnvironments) {
|
|
560
477
|
const env = environments[envName];
|
|
561
|
-
if (!env) continue;
|
|
562
|
-
|
|
563
|
-
if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
|
|
564
|
-
const platform = env.platform;
|
|
565
|
-
if (platform) {
|
|
566
|
-
const examples = getPlatformEnvExample(platform);
|
|
567
|
-
addSection(
|
|
568
|
-
PLATFORM_LABELS[platform] || platform,
|
|
569
|
-
Object.keys(examples),
|
|
570
|
-
examples
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const keys = PROVIDER_ENV_MAP[env.type] || [];
|
|
577
|
-
if (keys.length > 0) {
|
|
578
|
-
addSection(PROVIDER_LABELS[env.type] || env.type, keys);
|
|
579
|
-
}
|
|
478
|
+
if (!env?.type) continue;
|
|
580
479
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
addSection('SSH Deployment (backend)', BACKEND_SSH_ENV_VARS);
|
|
585
|
-
}
|
|
480
|
+
const deploySection = generateDeploymentEnvSection(env.type, config, environments);
|
|
481
|
+
if (deploySection) {
|
|
482
|
+
sections.push(`${deploySection}\n`);
|
|
586
483
|
}
|
|
587
484
|
}
|
|
588
485
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { downloadFromFirst } from '../../storage/index.js';
|
|
2
|
+
import { getDeploymentProvider } from '../../deployment/index.js';
|
|
3
|
+
import { extractArtifact } from '../../artifact/engine.js';
|
|
4
|
+
import { createLogger } from '../../logger/index.js';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
10
|
+
* @param {string} artifactDir
|
|
11
|
+
* @param {string} envName
|
|
12
|
+
*/
|
|
13
|
+
async function rollbackTarget(config, artifactDir, envName) {
|
|
14
|
+
const envConfig = config.environments[envName];
|
|
15
|
+
if (!envConfig) {
|
|
16
|
+
throw new Error(`Environment "${envName}" not found in config`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const log = createLogger('rollback');
|
|
20
|
+
log.info(`Rolling back ${envName} (${envConfig.type || 'server'})...`);
|
|
21
|
+
|
|
22
|
+
const provider = getDeploymentProvider(envConfig.type, config, envName);
|
|
23
|
+
await provider.rollback(artifactDir);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
28
|
+
* @param {string} version
|
|
29
|
+
* @param {string} [cwd]
|
|
30
|
+
*/
|
|
31
|
+
export async function rollbackToVersion(config, version, cwd = process.cwd()) {
|
|
32
|
+
const log = createLogger('rollback');
|
|
33
|
+
const remoteKey = `${config.project}/v${version}/artifact.zip`;
|
|
34
|
+
const restoreDir = path.join(cwd, '.deployhub-restore', `v${version}`);
|
|
35
|
+
const artifactDir = path.join(restoreDir, 'artifact');
|
|
36
|
+
|
|
37
|
+
log.info(`Downloading artifact v${version}...`);
|
|
38
|
+
await fs.emptyDir(restoreDir);
|
|
39
|
+
await fs.ensureDir(artifactDir);
|
|
40
|
+
|
|
41
|
+
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
42
|
+
await downloadFromFirst(config.storage, remoteKey, zipPath);
|
|
43
|
+
|
|
44
|
+
log.info('Extracting artifact for rollback...');
|
|
45
|
+
const extractedDir = path.join(artifactDir, '_extracted');
|
|
46
|
+
await fs.emptyDir(extractedDir);
|
|
47
|
+
await extractArtifact(artifactDir, extractedDir);
|
|
48
|
+
|
|
49
|
+
const extractedDeployment = path.join(extractedDir, 'deployment.json');
|
|
50
|
+
if (await fs.pathExists(extractedDeployment)) {
|
|
51
|
+
await fs.copy(extractedDeployment, path.join(artifactDir, 'deployment.json'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const targets = config.deploy || [];
|
|
55
|
+
if (targets.length === 0) {
|
|
56
|
+
log.warn('No deployment targets configured');
|
|
57
|
+
return artifactDir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
log.info('Redeploying previous artifact to server targets...');
|
|
61
|
+
for (const envName of targets) {
|
|
62
|
+
await rollbackTarget(config, artifactDir, envName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log.success(`Rollback to v${version} complete`);
|
|
66
|
+
return artifactDir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default { rollbackToVersion };
|