@akash-chowdhury-24/deployhub 2.0.13 → 2.0.14
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/package.json +1 -1
- package/src/cli/index.js +2 -0
- package/src/commands/artifact.js +17 -9
- package/src/commands/doctor.js +19 -2
- package/src/commands/init.js +1 -0
- package/src/commands/sync-workflows.js +43 -0
- package/src/deployment/index.js +3 -2
- package/src/deployment/providers/azure-vm.js +39 -18
- package/src/deployment/providers/docker.js +29 -7
- package/src/deployment/providers/ec2.js +39 -18
- package/src/deployment/providers/gcp-vm.js +39 -18
- package/src/deployment/providers/kubernetes.js +67 -12
- package/src/deployment/providers/ssh.js +1 -1
- package/src/storage/index.js +34 -10
- package/src/storage/providers/aws.js +6 -2
- package/src/storage/providers/dropbox.js +8 -2
- package/src/storage/providers/ftp.js +8 -2
- package/src/storage/storage-errors.js +106 -0
- package/src/utils/docker-image-deploy.js +44 -21
- package/src/utils/docker-image.js +48 -9
- package/src/utils/github-actions.js +175 -8
- package/src/utils/rollback/engine.js +14 -12
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerDoctorCommand } from '../commands/doctor.js';
|
|
|
13
13
|
import { registerVerifyCommand } from '../commands/verify.js';
|
|
14
14
|
import { registerCleanCommand } from '../commands/clean.js';
|
|
15
15
|
import { registerUpdateCommand } from '../commands/update.js';
|
|
16
|
+
import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
|
|
16
17
|
import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
|
|
17
18
|
|
|
18
19
|
loadEnv();
|
|
@@ -41,5 +42,6 @@ registerDoctorCommand(program);
|
|
|
41
42
|
registerVerifyCommand(program);
|
|
42
43
|
registerCleanCommand(program);
|
|
43
44
|
registerUpdateCommand(program);
|
|
45
|
+
registerSyncWorkflowsCommand(program);
|
|
44
46
|
|
|
45
47
|
program.parse();
|
package/src/commands/artifact.js
CHANGED
|
@@ -55,10 +55,21 @@ export function registerArtifactCommand(program) {
|
|
|
55
55
|
if (opts.remote) {
|
|
56
56
|
console.log(chalk.bold('\nRemote history (storage):\n'));
|
|
57
57
|
try {
|
|
58
|
-
const history = await loadArtifactHistory(
|
|
58
|
+
const { entries: history, source } = await loadArtifactHistory(
|
|
59
|
+
config.storage || [],
|
|
60
|
+
config.project
|
|
61
|
+
);
|
|
59
62
|
if (history.length === 0) {
|
|
60
|
-
console.log(
|
|
63
|
+
console.log(
|
|
64
|
+
chalk.yellow(
|
|
65
|
+
' No artifact history found for this project — you may not have deployed any builds yet.'
|
|
66
|
+
)
|
|
67
|
+
);
|
|
61
68
|
} else {
|
|
69
|
+
if (source) {
|
|
70
|
+
console.log(chalk.gray(` Source: ${source}`));
|
|
71
|
+
console.log('');
|
|
72
|
+
}
|
|
62
73
|
for (const e of history) {
|
|
63
74
|
console.log(
|
|
64
75
|
` ${chalk.cyan(e.buildId)} semver=${e.semver} ${e.uploadedAt || ''}`
|
|
@@ -67,11 +78,8 @@ export function registerArtifactCommand(program) {
|
|
|
67
78
|
}
|
|
68
79
|
}
|
|
69
80
|
} catch (err) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
` Could not load remote history: ${err instanceof Error ? err.message : String(err)}`
|
|
73
|
-
)
|
|
74
|
-
);
|
|
81
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
82
|
+
console.log(chalk.red(` ${detail}`));
|
|
75
83
|
}
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -101,8 +109,8 @@ export function registerArtifactCommand(program) {
|
|
|
101
109
|
|
|
102
110
|
const history = await loadArtifactHistory(config.storage || [], config.project);
|
|
103
111
|
const histMatch =
|
|
104
|
-
history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
|
|
105
|
-
history.find((e) => e.semver === needle);
|
|
112
|
+
history.entries.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
|
|
113
|
+
history.entries.find((e) => e.semver === needle);
|
|
106
114
|
|
|
107
115
|
const restoreDir = path.join(cwd, '.deployhub-restore', `v${needle}`);
|
|
108
116
|
await fs.ensureDir(restoreDir);
|
package/src/commands/doctor.js
CHANGED
|
@@ -7,7 +7,7 @@ import axios from 'axios';
|
|
|
7
7
|
import { loadConfig, loadEnv } from '../core/config.js';
|
|
8
8
|
import { testProvider } from '../storage/index.js';
|
|
9
9
|
import { getDeploymentProvider } from '../deployment/index.js';
|
|
10
|
-
import { PROVIDER_ENV_MAP } from '../utils/github-actions.js';
|
|
10
|
+
import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
|
|
11
11
|
import { printDoctorFooter } from '../utils/author.js';
|
|
12
12
|
import { createLocalProvider } from '../storage/providers/local.js';
|
|
13
13
|
import {
|
|
@@ -888,11 +888,28 @@ export function registerDoctorCommand(program) {
|
|
|
888
888
|
return {
|
|
889
889
|
name: 'GitHub Actions',
|
|
890
890
|
pass: false,
|
|
891
|
-
message: 'Workflow file missing — run deployhub init',
|
|
891
|
+
message: 'Workflow file missing — run deployhub init or deployhub sync-workflows',
|
|
892
892
|
};
|
|
893
893
|
})
|
|
894
894
|
);
|
|
895
895
|
|
|
896
|
+
const hasStorage = (config.storage || []).length > 0;
|
|
897
|
+
const hasDeploy = (config.deploy || []).length > 0;
|
|
898
|
+
if (hasStorage && hasDeploy) {
|
|
899
|
+
results.push(
|
|
900
|
+
await runCheck('Rollback workflow', async () => {
|
|
901
|
+
const check = await getRollbackWorkflowDoctorCheck(cwd, config);
|
|
902
|
+
return (
|
|
903
|
+
check || {
|
|
904
|
+
name: 'Rollback workflow',
|
|
905
|
+
pass: true,
|
|
906
|
+
message: 'Skipped',
|
|
907
|
+
}
|
|
908
|
+
);
|
|
909
|
+
})
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
896
913
|
results.push(
|
|
897
914
|
await runCheck('Storage write', async () => {
|
|
898
915
|
const provider = createLocalProvider();
|
package/src/commands/init.js
CHANGED
|
@@ -535,6 +535,7 @@ export function registerInitCommand(program) {
|
|
|
535
535
|
console.log(chalk.bold('Generated files:'));
|
|
536
536
|
console.log(' • deployhub.config.json');
|
|
537
537
|
console.log(' • .github/workflows/deployhub.yml');
|
|
538
|
+
console.log(' • .github/workflows/deployhub-rollback.yml');
|
|
538
539
|
console.log(' • .env.example');
|
|
539
540
|
console.log('');
|
|
540
541
|
printAuthorFooter();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig, loadEnv } from '../core/config.js';
|
|
3
|
+
import {
|
|
4
|
+
writeWorkflowFile,
|
|
5
|
+
DEPLOY_WORKFLOW_FILENAME,
|
|
6
|
+
ROLLBACK_WORKFLOW_FILENAME,
|
|
7
|
+
} from '../utils/github-actions.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regenerate GitHub Actions workflows from deployhub.config.json (no interactive init).
|
|
11
|
+
* @param {import('commander').Command} program
|
|
12
|
+
*/
|
|
13
|
+
export function registerSyncWorkflowsCommand(program) {
|
|
14
|
+
program
|
|
15
|
+
.command('sync-workflows')
|
|
16
|
+
.description(
|
|
17
|
+
'Regenerate .github/workflows/deployhub.yml and deployhub-rollback.yml from deployhub.config.json'
|
|
18
|
+
)
|
|
19
|
+
.action(async () => {
|
|
20
|
+
loadEnv();
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
const config = await loadConfig(cwd);
|
|
23
|
+
|
|
24
|
+
const storage = config.storage || [];
|
|
25
|
+
const deploy = config.deploy || [];
|
|
26
|
+
const environments = config.environments || {};
|
|
27
|
+
const cliSource = config.cli?.source;
|
|
28
|
+
|
|
29
|
+
await writeWorkflowFile(storage, deploy, environments, cwd, cliSource, config);
|
|
30
|
+
|
|
31
|
+
console.log(chalk.green('✓ Regenerated GitHub Actions workflows:'));
|
|
32
|
+
console.log(` • .github/workflows/${DEPLOY_WORKFLOW_FILENAME}`);
|
|
33
|
+
console.log(` • .github/workflows/${ROLLBACK_WORKFLOW_FILENAME}`);
|
|
34
|
+
console.log('');
|
|
35
|
+
console.log(
|
|
36
|
+
chalk.gray(
|
|
37
|
+
'Commit and push these files, then use Actions → DeployHub Rollback (workflow_dispatch) to roll back.'
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default { registerSyncWorkflowsCommand };
|
package/src/deployment/index.js
CHANGED
|
@@ -71,13 +71,14 @@ export async function deployToAll(config, artifactDir, envNames) {
|
|
|
71
71
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
72
72
|
* @param {string} artifactDir
|
|
73
73
|
* @param {string[]} [envNames]
|
|
74
|
+
* @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
|
|
74
75
|
*/
|
|
75
|
-
export async function rollbackAll(config, artifactDir, envNames) {
|
|
76
|
+
export async function rollbackAll(config, artifactDir, envNames, meta) {
|
|
76
77
|
const targets = envNames || config.deploy || [];
|
|
77
78
|
for (const envName of targets) {
|
|
78
79
|
const envConfig = config.environments[envName];
|
|
79
80
|
const provider = getDeploymentProvider(envConfig.type, config, envName);
|
|
80
|
-
await provider.rollback(artifactDir);
|
|
81
|
+
await provider.rollback(artifactDir, meta);
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
@@ -21,7 +21,8 @@ export function createAzureVmProvider(config, envName, env = process.env) {
|
|
|
21
21
|
|
|
22
22
|
if (!subscriptionId || !resourceGroup || !vmName) {
|
|
23
23
|
throw new Error(
|
|
24
|
-
'
|
|
24
|
+
'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — ' +
|
|
25
|
+
'provide SSH_HOST (VM public IP/DNS) or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -55,34 +56,54 @@ export function createAzureVmProvider(config, envName, env = process.env) {
|
|
|
55
56
|
} catch (err) {
|
|
56
57
|
const msg = err instanceof Error ? err.message : String(err);
|
|
57
58
|
throw new Error(
|
|
58
|
-
`Could not resolve
|
|
59
|
+
`Could not resolve host via Azure VM lookup (${vmName}): ${msg}. ` +
|
|
60
|
+
'Set SSH_HOST to the VM public IP/DNS, or run az login and verify resource group/VM name.'
|
|
59
61
|
);
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
|
|
67
|
+
* then create an SSH provider that closes over the resolved host.
|
|
68
|
+
*/
|
|
69
|
+
async function getSshProvider() {
|
|
66
70
|
const host = await resolveHost();
|
|
71
|
+
if (!host) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — provide one or the other.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
67
76
|
const environment = config.environments[envName];
|
|
68
|
-
if (environment
|
|
77
|
+
if (environment) {
|
|
69
78
|
environment.host = host;
|
|
70
79
|
}
|
|
71
|
-
|
|
72
|
-
env.SSH_HOST = host;
|
|
73
|
-
}
|
|
74
|
-
return sshProvider.connect();
|
|
80
|
+
return createSshProvider(config, envName, { ...env, SSH_HOST: host });
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
async connect() {
|
|
85
|
+
const ssh = await getSshProvider();
|
|
86
|
+
return ssh.connect();
|
|
87
|
+
},
|
|
88
|
+
async deploy(artifactDir, options) {
|
|
89
|
+
const ssh = await getSshProvider();
|
|
90
|
+
return ssh.deploy(artifactDir, options);
|
|
91
|
+
},
|
|
92
|
+
async rollback(artifactDir, meta) {
|
|
93
|
+
const ssh = await getSshProvider();
|
|
94
|
+
return ssh.rollback(artifactDir, meta);
|
|
95
|
+
},
|
|
96
|
+
async healthCheck() {
|
|
97
|
+
const ssh = await getSshProvider();
|
|
98
|
+
return ssh.healthCheck();
|
|
99
|
+
},
|
|
100
|
+
async testConnection() {
|
|
101
|
+
const ssh = await getSshProvider();
|
|
102
|
+
return ssh.testConnection();
|
|
103
|
+
},
|
|
104
|
+
async runRemoteCheck(command) {
|
|
105
|
+
const ssh = await getSshProvider();
|
|
106
|
+
return ssh.runRemoteCheck(command);
|
|
86
107
|
},
|
|
87
108
|
};
|
|
88
109
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
2
|
import { createLogger } from '../../logger/index.js';
|
|
3
3
|
import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
|
|
4
|
+
import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -14,12 +15,17 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @param {string} artifactDir
|
|
18
|
+
* @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
|
|
17
19
|
*/
|
|
18
|
-
async function deploy(artifactDir) {
|
|
19
|
-
|
|
20
|
+
async function deploy(artifactDir, options = {}) {
|
|
21
|
+
const imageRef = options.fullImage || fullImage;
|
|
22
|
+
log.info(`Deploying via Docker (image: ${imageRef})...`);
|
|
20
23
|
const dockerEnv = getDockerEnv();
|
|
21
24
|
|
|
22
|
-
const result = await ensureImageReadyForDeploy(artifactDir
|
|
25
|
+
const result = await ensureImageReadyForDeploy(artifactDir, {
|
|
26
|
+
fullImage: options.fullImage,
|
|
27
|
+
skipImageReuse: options.skipImageReuse,
|
|
28
|
+
});
|
|
23
29
|
if (result.ranCompose) {
|
|
24
30
|
log.success('Docker deployment complete');
|
|
25
31
|
return;
|
|
@@ -31,7 +37,7 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
31
37
|
{ stdio: 'pipe', env: dockerEnv }
|
|
32
38
|
).catch(() => {});
|
|
33
39
|
|
|
34
|
-
await execa('docker', ['run', '-d', '--rm', '--name', config.project,
|
|
40
|
+
await execa('docker', ['run', '-d', '--rm', '--name', config.project, imageRef], {
|
|
35
41
|
stdio: 'inherit',
|
|
36
42
|
env: dockerEnv,
|
|
37
43
|
});
|
|
@@ -39,9 +45,25 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
39
45
|
log.success('Docker deployment complete');
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} artifactDir
|
|
50
|
+
* @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
|
|
51
|
+
*/
|
|
52
|
+
async function rollback(artifactDir, meta = {}) {
|
|
53
|
+
if (!meta.buildId) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Docker rollback requires buildId from the restored artifact history entry'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
|
|
60
|
+
log.info(
|
|
61
|
+
`Rolling back Docker to buildId=${meta.buildId} (image: ${rollbackImage})...`
|
|
62
|
+
);
|
|
63
|
+
await deploy(artifactDir, {
|
|
64
|
+
fullImage: rollbackImage,
|
|
65
|
+
skipImageReuse: true,
|
|
66
|
+
});
|
|
45
67
|
}
|
|
46
68
|
|
|
47
69
|
async function healthCheck() {
|
|
@@ -20,7 +20,8 @@ export function createEc2Provider(config, envName, env = process.env) {
|
|
|
20
20
|
|
|
21
21
|
if (!instanceId) {
|
|
22
22
|
throw new Error(
|
|
23
|
-
'
|
|
23
|
+
'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set — ' +
|
|
24
|
+
'provide SSH_HOST (instance public IP/DNS) or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
|
|
24
25
|
);
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -58,34 +59,54 @@ export function createEc2Provider(config, envName, env = process.env) {
|
|
|
58
59
|
} catch (err) {
|
|
59
60
|
const msg = err instanceof Error ? err.message : String(err);
|
|
60
61
|
throw new Error(
|
|
61
|
-
`Could not resolve
|
|
62
|
+
`Could not resolve host via EC2 instance lookup (${instanceId}): ${msg}. ` +
|
|
63
|
+
'Set SSH_HOST to the instance public IP/DNS, or fix AWS CLI credentials / ec2:DescribeInstances access.'
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
|
|
70
|
+
* then create an SSH provider that closes over the resolved host.
|
|
71
|
+
*/
|
|
72
|
+
async function getSshProvider() {
|
|
69
73
|
const host = await resolveHost();
|
|
74
|
+
if (!host) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set — provide one or the other.'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
70
79
|
const environment = config.environments[envName];
|
|
71
|
-
if (environment
|
|
80
|
+
if (environment) {
|
|
72
81
|
environment.host = host;
|
|
73
82
|
}
|
|
74
|
-
|
|
75
|
-
env.SSH_HOST = host;
|
|
76
|
-
}
|
|
77
|
-
return sshProvider.connect();
|
|
83
|
+
return createSshProvider(config, envName, { ...env, SSH_HOST: host });
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
return {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
async connect() {
|
|
88
|
+
const ssh = await getSshProvider();
|
|
89
|
+
return ssh.connect();
|
|
90
|
+
},
|
|
91
|
+
async deploy(artifactDir, options) {
|
|
92
|
+
const ssh = await getSshProvider();
|
|
93
|
+
return ssh.deploy(artifactDir, options);
|
|
94
|
+
},
|
|
95
|
+
async rollback(artifactDir, meta) {
|
|
96
|
+
const ssh = await getSshProvider();
|
|
97
|
+
return ssh.rollback(artifactDir, meta);
|
|
98
|
+
},
|
|
99
|
+
async healthCheck() {
|
|
100
|
+
const ssh = await getSshProvider();
|
|
101
|
+
return ssh.healthCheck();
|
|
102
|
+
},
|
|
103
|
+
async testConnection() {
|
|
104
|
+
const ssh = await getSshProvider();
|
|
105
|
+
return ssh.testConnection();
|
|
106
|
+
},
|
|
107
|
+
async runRemoteCheck(command) {
|
|
108
|
+
const ssh = await getSshProvider();
|
|
109
|
+
return ssh.runRemoteCheck(command);
|
|
89
110
|
},
|
|
90
111
|
};
|
|
91
112
|
}
|
|
@@ -21,7 +21,8 @@ export function createGcpVmProvider(config, envName, env = process.env) {
|
|
|
21
21
|
|
|
22
22
|
if (!projectId || !zone || !instanceName) {
|
|
23
23
|
throw new Error(
|
|
24
|
-
'
|
|
24
|
+
'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — ' +
|
|
25
|
+
'provide SSH_HOST (instance external IP/DNS) or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -59,34 +60,54 @@ export function createGcpVmProvider(config, envName, env = process.env) {
|
|
|
59
60
|
} catch (err) {
|
|
60
61
|
const msg = err instanceof Error ? err.message : String(err);
|
|
61
62
|
throw new Error(
|
|
62
|
-
`Could not resolve
|
|
63
|
+
`Could not resolve host via GCP instance lookup (${instanceName}): ${msg}. ` +
|
|
64
|
+
'Set SSH_HOST to the instance external IP/DNS, or run gcloud auth login and verify project/zone/instance name.'
|
|
63
65
|
);
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
|
|
71
|
+
* then create an SSH provider that closes over the resolved host.
|
|
72
|
+
*/
|
|
73
|
+
async function getSshProvider() {
|
|
70
74
|
const host = await resolveHost();
|
|
75
|
+
if (!host) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — provide one or the other.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
71
80
|
const environment = config.environments[envName];
|
|
72
|
-
if (environment
|
|
81
|
+
if (environment) {
|
|
73
82
|
environment.host = host;
|
|
74
83
|
}
|
|
75
|
-
|
|
76
|
-
env.SSH_HOST = host;
|
|
77
|
-
}
|
|
78
|
-
return sshProvider.connect();
|
|
84
|
+
return createSshProvider(config, envName, { ...env, SSH_HOST: host });
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
return {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
async connect() {
|
|
89
|
+
const ssh = await getSshProvider();
|
|
90
|
+
return ssh.connect();
|
|
91
|
+
},
|
|
92
|
+
async deploy(artifactDir, options) {
|
|
93
|
+
const ssh = await getSshProvider();
|
|
94
|
+
return ssh.deploy(artifactDir, options);
|
|
95
|
+
},
|
|
96
|
+
async rollback(artifactDir, meta) {
|
|
97
|
+
const ssh = await getSshProvider();
|
|
98
|
+
return ssh.rollback(artifactDir, meta);
|
|
99
|
+
},
|
|
100
|
+
async healthCheck() {
|
|
101
|
+
const ssh = await getSshProvider();
|
|
102
|
+
return ssh.healthCheck();
|
|
103
|
+
},
|
|
104
|
+
async testConnection() {
|
|
105
|
+
const ssh = await getSshProvider();
|
|
106
|
+
return ssh.testConnection();
|
|
107
|
+
},
|
|
108
|
+
async runRemoteCheck(command) {
|
|
109
|
+
const ssh = await getSshProvider();
|
|
110
|
+
return ssh.runRemoteCheck(command);
|
|
90
111
|
},
|
|
91
112
|
};
|
|
92
113
|
}
|
|
@@ -5,6 +5,7 @@ import os from 'os';
|
|
|
5
5
|
import { createLogger } from '../../logger/index.js';
|
|
6
6
|
import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
|
|
7
7
|
import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
|
|
8
|
+
import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
|
|
8
9
|
import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
|
|
9
10
|
import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
|
|
10
11
|
|
|
@@ -50,10 +51,24 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* @param {string} artifactDir
|
|
54
|
+
* @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
|
|
53
55
|
*/
|
|
54
|
-
async function deploy(artifactDir) {
|
|
56
|
+
async function deploy(artifactDir, options = {}) {
|
|
57
|
+
const imageRef = options.fullImage || imageOps.fullImage;
|
|
58
|
+
const isRollbackRedeploy = Boolean(options.skipImageReuse);
|
|
59
|
+
|
|
55
60
|
log.info(`Deploying to Kubernetes (namespace: ${namespace}${context ? `, context: ${context}` : ''})...`);
|
|
56
61
|
|
|
62
|
+
// Rollback always rebuilds and must push — without registry creds the cluster
|
|
63
|
+
// cannot pull the new tag and would sit in ImagePullBackOff after a false success.
|
|
64
|
+
if (isRollbackRedeploy && !imageOps.hasRegistryCredentials()) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'Kubernetes rollback requires DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN ' +
|
|
67
|
+
`so the rebuilt image (${imageRef}) can be pushed for the cluster to pull. ` +
|
|
68
|
+
'Set those credentials and retry.'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
const manifestDir = artifactDir;
|
|
58
73
|
const hasManifests =
|
|
59
74
|
(await fs.pathExists(path.join(manifestDir, 'k8s'))) ||
|
|
@@ -65,8 +80,11 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
65
80
|
);
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
log.info(`Ensuring container image ${
|
|
69
|
-
const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir
|
|
83
|
+
log.info(`Ensuring container image ${imageRef} is built and pushed before apply...`);
|
|
84
|
+
const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir, {
|
|
85
|
+
fullImage: options.fullImage,
|
|
86
|
+
skipImageReuse: options.skipImageReuse,
|
|
87
|
+
});
|
|
70
88
|
if (imageResult.ranCompose) {
|
|
71
89
|
log.warn(
|
|
72
90
|
'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
|
|
@@ -93,23 +111,60 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
93
111
|
// If the live ref already equals fullImage, rollout restart so a new digest is pulled.
|
|
94
112
|
await syncKubernetesDeploymentImage({
|
|
95
113
|
deploymentName,
|
|
96
|
-
fullImage:
|
|
114
|
+
fullImage: imageRef,
|
|
97
115
|
kubectlArgs,
|
|
98
116
|
getKubectlEnv,
|
|
99
117
|
log,
|
|
100
118
|
});
|
|
101
119
|
|
|
120
|
+
// Rollback-only safety net: wait until pods are actually healthy (catches ImagePullBackOff).
|
|
121
|
+
if (isRollbackRedeploy) {
|
|
122
|
+
const timeout = '120s';
|
|
123
|
+
log.info(
|
|
124
|
+
`Waiting for deployment/${deploymentName} rollout to complete (timeout ${timeout})...`
|
|
125
|
+
);
|
|
126
|
+
try {
|
|
127
|
+
await execa(
|
|
128
|
+
'kubectl',
|
|
129
|
+
kubectlArgs([
|
|
130
|
+
'rollout',
|
|
131
|
+
'status',
|
|
132
|
+
`deployment/${deploymentName}`,
|
|
133
|
+
`--timeout=${timeout}`,
|
|
134
|
+
]),
|
|
135
|
+
{ stdio: 'inherit', env: getKubectlEnv() }
|
|
136
|
+
);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Kubernetes rollback failed: deployment/${deploymentName} did not become healthy within ${timeout}. ` +
|
|
141
|
+
`The cluster may be unable to pull ${imageRef} (ImagePullBackOff) or pods are failing. ${detail}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
102
146
|
log.success('Kubernetes deployment complete');
|
|
103
147
|
}
|
|
104
148
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Artifact-based rollback: restore buildId X's code and image (not cluster undo history).
|
|
151
|
+
* @param {string} artifactDir
|
|
152
|
+
* @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
|
|
153
|
+
*/
|
|
154
|
+
async function rollback(artifactDir, meta = {}) {
|
|
155
|
+
if (!meta.buildId) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'Kubernetes rollback requires buildId from the restored artifact history entry'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
|
|
162
|
+
log.info(
|
|
163
|
+
`Rolling back Kubernetes to buildId=${meta.buildId} (image: ${rollbackImage})...`
|
|
164
|
+
);
|
|
165
|
+
await deploy(artifactDir, {
|
|
166
|
+
fullImage: rollbackImage,
|
|
167
|
+
skipImageReuse: true,
|
|
113
168
|
});
|
|
114
169
|
}
|
|
115
170
|
|
|
@@ -405,7 +405,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
405
405
|
return result.code === 0 && result.stdout.trim() === 'yes';
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
-
async function rollback(artifactDir) {
|
|
408
|
+
async function rollback(artifactDir, _meta) {
|
|
409
409
|
await deploy(artifactDir);
|
|
410
410
|
}
|
|
411
411
|
|