@akash-chowdhury-24/deployhub 2.0.41 → 2.0.44
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 +133 -16
- package/package.json +1 -1
- package/src/commands/doctor.js +10 -0
- package/src/core/config.js +10 -0
- package/src/deployment/deployment-env.js +46 -11
- package/src/deployment/env-file.js +344 -0
- package/src/deployment/hooks.js +16 -6
- package/src/deployment/index.js +3 -0
- package/src/deployment/init-prompts.js +99 -6
- package/src/deployment/providers/docker.js +48 -2
- package/src/deployment/providers/ssh.js +75 -11
- package/src/deployment/ssh-connection.js +6 -2
- package/src/utils/docker-image-deploy.js +29 -8
- package/src/utils/docker-remote.js +7 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeployHub-managed .env: GitHub Secret name in config, binary SFTP of the
|
|
3
|
+
* secret value onto the deploy target. SSH methods write deployPath/.env
|
|
4
|
+
* (overwrite every deploy). Docker remote.mode "ssh" writes a per-project-
|
|
5
|
+
* per-env host path and passes --env-file to docker run.
|
|
6
|
+
*
|
|
7
|
+
* Never interpolate secret content into a shell command — the [ssh]/[docker]
|
|
8
|
+
* `$ …` logger prints every exec() string verbatim.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { createLogger } from '../logger/index.js';
|
|
15
|
+
import { shellQuote } from '../utils/shell-quote.js';
|
|
16
|
+
import { getEnvMethod, getEnvSettings } from '../core/environments.js';
|
|
17
|
+
import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
|
|
18
|
+
import { prefixSecretKey } from './deployment-env.js';
|
|
19
|
+
|
|
20
|
+
/** Unprefixed GitHub Actions / process.env key after secret overlay. */
|
|
21
|
+
export const MANAGED_ENV_FILE_UNPREFIXED_KEY = 'ENV_FILE';
|
|
22
|
+
|
|
23
|
+
/** Host directory for docker-ssh managed env files (project+env scoped). */
|
|
24
|
+
export const DOCKER_MANAGED_ENV_ROOT = '/opt/deployhub/envs';
|
|
25
|
+
|
|
26
|
+
const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} settings
|
|
30
|
+
* @returns {string|null}
|
|
31
|
+
*/
|
|
32
|
+
export function getEnvFileSecretName(settings = {}) {
|
|
33
|
+
const name = settings && /** @type {Record<string, unknown>} */ (settings).envFileSecretName;
|
|
34
|
+
if (typeof name !== 'string') return null;
|
|
35
|
+
const trimmed = name.trim();
|
|
36
|
+
return trimmed || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Record<string, unknown>} [settings]
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function envHasManagedEnvFile(settings = {}) {
|
|
44
|
+
return getEnvFileSecretName(settings) != null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* First environment (init / empty existing list) → ENV_FILE.
|
|
49
|
+
* Additional environments → {ENV}_ENV_FILE (same grandfathering as SSH_HOST).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} envName
|
|
52
|
+
* @param {string[]} [existingEnvNames]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function suggestEnvFileSecretName(envName, existingEnvNames = []) {
|
|
56
|
+
if (!Array.isArray(existingEnvNames) || existingEnvNames.length === 0) {
|
|
57
|
+
return MANAGED_ENV_FILE_UNPREFIXED_KEY;
|
|
58
|
+
}
|
|
59
|
+
return prefixSecretKey(envName || 'production', MANAGED_ENV_FILE_UNPREFIXED_KEY);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string|undefined} method
|
|
64
|
+
* @param {Record<string, unknown>} [settings]
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function envFileSupportedForMethod(method, settings = {}) {
|
|
68
|
+
if (SSH_BASED_METHODS.has(String(method || ''))) return true;
|
|
69
|
+
if (method === 'docker') {
|
|
70
|
+
return resolveDockerRemoteMode(settings) === 'ssh';
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Throw if a managed .env is configured on a method that cannot deliver it.
|
|
77
|
+
* No-op when envFileSecretName is unset (additive).
|
|
78
|
+
*
|
|
79
|
+
* @param {string|undefined} method
|
|
80
|
+
* @param {Record<string, unknown>} [settings]
|
|
81
|
+
* @param {string} [envName]
|
|
82
|
+
*/
|
|
83
|
+
export function assertEnvFileAllowed(method, settings = {}, envName = 'this environment') {
|
|
84
|
+
if (!envHasManagedEnvFile(settings)) return;
|
|
85
|
+
if (envFileSupportedForMethod(method, settings)) return;
|
|
86
|
+
|
|
87
|
+
let why;
|
|
88
|
+
if (method === 'kubernetes') {
|
|
89
|
+
why = 'Kubernetes is out of scope for DeployHub-managed .env (no SSH file transfer / docker --env-file).';
|
|
90
|
+
} else if (method === 'docker') {
|
|
91
|
+
why =
|
|
92
|
+
`Docker remote.mode "${resolveDockerRemoteMode(settings)}" has no DeployHub-managed SSH session ` +
|
|
93
|
+
`(managed .env requires remote.mode "ssh").`;
|
|
94
|
+
} else {
|
|
95
|
+
why = `Method "${method}" does not support DeployHub-managed .env.`;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Managed .env is configured for environment "${envName}" but is not supported: ${why} ` +
|
|
99
|
+
`Remove environments.${envName}.config.envFileSecretName or use ssh / ec2 / azure-vm / gcp-vm / docker (remote.mode ssh).`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the secret value from CI env. After applyEnvSecretOverlay the unprefixed
|
|
105
|
+
* ENV_FILE key is populated for prefixed environments. Never log the return value.
|
|
106
|
+
*
|
|
107
|
+
* @param {Record<string, string|undefined>} [env]
|
|
108
|
+
* @param {Record<string, unknown>} [settings]
|
|
109
|
+
* @returns {string|null} null when not configured
|
|
110
|
+
*/
|
|
111
|
+
export function readManagedEnvFileContent(env = {}, settings = {}) {
|
|
112
|
+
const name = getEnvFileSecretName(settings);
|
|
113
|
+
if (!name) return null;
|
|
114
|
+
const raw = env[MANAGED_ENV_FILE_UNPREFIXED_KEY] ?? env[name];
|
|
115
|
+
if (raw == null || raw === '') {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Managed .env is configured (GitHub Secret "${name}") but the value is missing or empty. ` +
|
|
118
|
+
`Add that secret with the FULL CONTENTS of your .env file. ` +
|
|
119
|
+
`DeployHub cannot verify the secret exists ahead of time (GitHub Secrets are write-only).`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return String(raw);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} value
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function sanitizePathSegment(value) {
|
|
130
|
+
return (
|
|
131
|
+
String(value || 'app')
|
|
132
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
133
|
+
.replace(/^-+|-+$/g, '') || 'app'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} remotePath
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
function stripTrailingSlash(remotePath) {
|
|
142
|
+
const s = String(remotePath || '').replace(/\\/g, '/');
|
|
143
|
+
if (s.length > 1 && s.endsWith('/')) return s.replace(/\/+$/, '');
|
|
144
|
+
return s || '/';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} remotePath
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function posixDirname(remotePath) {
|
|
152
|
+
const normalized = stripTrailingSlash(remotePath);
|
|
153
|
+
const idx = normalized.lastIndexOf('/');
|
|
154
|
+
if (idx <= 0) return '/';
|
|
155
|
+
return normalized.slice(0, idx);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Docker-ssh host path: /opt/deployhub/envs/<project>-<env>/.env
|
|
160
|
+
* Always project AND environment scoped so two apps on one host never collide.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ project?: string }} config
|
|
163
|
+
* @param {string} envName
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
export function resolveDockerManagedEnvRemotePath(config, envName) {
|
|
167
|
+
const project = sanitizePathSegment(config?.project || 'app');
|
|
168
|
+
const env = sanitizePathSegment(envName || 'default');
|
|
169
|
+
return `${DOCKER_MANAGED_ENV_ROOT}/${project}-${env}/.env`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* SSH methods: deployPath/.env. Full-stack writes both frontend and backend
|
|
174
|
+
* deploy paths (same secret — one environment, two processes).
|
|
175
|
+
*
|
|
176
|
+
* @param {{ projectType?: string }} config
|
|
177
|
+
* @param {Record<string, unknown>} settings
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function resolveSshManagedEnvRemotePaths(config, settings = {}) {
|
|
181
|
+
const projectType = config?.projectType || 'frontend';
|
|
182
|
+
const deployPath = stripTrailingSlash(
|
|
183
|
+
String(settings.deployPath || settings.path || '/var/www/app')
|
|
184
|
+
);
|
|
185
|
+
if (projectType === 'both') {
|
|
186
|
+
const fe = stripTrailingSlash(String(settings.frontendDeployPath || deployPath));
|
|
187
|
+
const be = stripTrailingSlash(String(settings.backendDeployPath || deployPath));
|
|
188
|
+
return [...new Set([`${fe}/.env`, `${be}/.env`])];
|
|
189
|
+
}
|
|
190
|
+
return [`${deployPath}/.env`];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write content to a local temp file, SFTP it with ssh.putFile (binary, never
|
|
195
|
+
* shell-interpolated), chmod 600, then delete the local temp file.
|
|
196
|
+
*
|
|
197
|
+
* @param {{
|
|
198
|
+
* ssh: { putFile: Function },
|
|
199
|
+
* session: { exec: Function },
|
|
200
|
+
* content: string,
|
|
201
|
+
* remotePath: string,
|
|
202
|
+
* user: string,
|
|
203
|
+
* log?: { info: Function },
|
|
204
|
+
* }} opts
|
|
205
|
+
*/
|
|
206
|
+
export async function uploadManagedEnvFile(opts) {
|
|
207
|
+
const { ssh, session, content, remotePath, user } = opts;
|
|
208
|
+
const log = opts.log || createLogger('deploy');
|
|
209
|
+
if (typeof content !== 'string') {
|
|
210
|
+
throw new Error('Managed .env content must be a string');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deployhub-env-'));
|
|
214
|
+
const localPath = path.join(tmpDir, '.env');
|
|
215
|
+
try {
|
|
216
|
+
await fs.writeFile(localPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
217
|
+
const parent = posixDirname(remotePath);
|
|
218
|
+
const parentQ = shellQuote(parent);
|
|
219
|
+
const remoteQ = shellQuote(remotePath);
|
|
220
|
+
const userQ = shellQuote(user);
|
|
221
|
+
log.info(`Uploading managed .env to ${remotePath}`);
|
|
222
|
+
const dockerHostRoot = '/opt/deployhub';
|
|
223
|
+
const underDockerRoot =
|
|
224
|
+
parent === DOCKER_MANAGED_ENV_ROOT || parent.startsWith(`${DOCKER_MANAGED_ENV_ROOT}/`);
|
|
225
|
+
// Chown /opt/deployhub (not just the leaf) so later projects can mkdir as the
|
|
226
|
+
// deploy user without sudo — same idea as README `chown $USER:$USER /opt/deployhub`.
|
|
227
|
+
const chownTarget = underDockerRoot ? dockerHostRoot : parent;
|
|
228
|
+
const chownQ = shellQuote(chownTarget);
|
|
229
|
+
try {
|
|
230
|
+
await session.exec(ssh, `mkdir -p ${parentQ}`);
|
|
231
|
+
} catch {
|
|
232
|
+
try {
|
|
233
|
+
await session.exec(
|
|
234
|
+
ssh,
|
|
235
|
+
`sudo -n mkdir -p ${parentQ} && sudo -n chown -R ${userQ}:${userQ} ${chownQ}`
|
|
236
|
+
);
|
|
237
|
+
} catch {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Cannot create ${parent} for managed .env (the SSH user cannot write there).\n` +
|
|
240
|
+
` Run this on your server once:\n` +
|
|
241
|
+
` sudo mkdir -p ${parent}\n` +
|
|
242
|
+
` sudo chown ${user}:${user} ${chownTarget}\n` +
|
|
243
|
+
` (or grant passwordless sudo for mkdir/chown).`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await session.exec(
|
|
248
|
+
ssh,
|
|
249
|
+
`if [ ! -w ${parentQ} ]; then sudo -n chown ${userQ}:${userQ} ${parentQ}; fi`
|
|
250
|
+
);
|
|
251
|
+
await ssh.putFile(localPath, remotePath);
|
|
252
|
+
await session.exec(ssh, `chmod 600 ${remoteQ}`);
|
|
253
|
+
try {
|
|
254
|
+
await session.exec(ssh, `chown ${userQ}:${userQ} ${remoteQ}`);
|
|
255
|
+
} catch {
|
|
256
|
+
await session.exec(ssh, `sudo -n chown ${userQ}:${userQ} ${remoteQ}`);
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
await fs.remove(tmpDir).catch(() => {});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Collect secret names referenced as ${{ secrets.NAME }} in workflow YAML.
|
|
265
|
+
* Local copy so env-file.js does not import github-actions.js (cycle risk).
|
|
266
|
+
*
|
|
267
|
+
* @param {string} yaml
|
|
268
|
+
* @returns {string[]}
|
|
269
|
+
*/
|
|
270
|
+
function extractWorkflowSecretKeys(yaml) {
|
|
271
|
+
/** @type {Set<string>} */
|
|
272
|
+
const keys = new Set();
|
|
273
|
+
const re = /\$\{\{\s*secrets\.([A-Z0-9_]+)\s*\}\}/g;
|
|
274
|
+
let match;
|
|
275
|
+
while ((match = re.exec(String(yaml || ''))) !== null) {
|
|
276
|
+
keys.add(match[1]);
|
|
277
|
+
}
|
|
278
|
+
return [...keys];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Informational doctor lines — pass: true, never blocks.
|
|
283
|
+
* Cannot verify the secret VALUE (GitHub Secrets are write-only).
|
|
284
|
+
*
|
|
285
|
+
* @param {Record<string, unknown>} config
|
|
286
|
+
* @param {{ workflowText?: string }} [options]
|
|
287
|
+
* @returns {{ name: string, pass: boolean, message: string }[]}
|
|
288
|
+
*/
|
|
289
|
+
export function getEnvFileDoctorChecks(config, options = {}) {
|
|
290
|
+
const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
|
|
291
|
+
const workflowKeys = new Set(extractWorkflowSecretKeys(options.workflowText || ''));
|
|
292
|
+
const hasWorkflowText =
|
|
293
|
+
typeof options.workflowText === 'string' && options.workflowText.length > 0;
|
|
294
|
+
/** @type {{ name: string, pass: boolean, message: string }[]} */
|
|
295
|
+
const checks = [];
|
|
296
|
+
|
|
297
|
+
for (const [name, entry] of Object.entries(envs)) {
|
|
298
|
+
const settings = getEnvSettings(entry);
|
|
299
|
+
const secretName = getEnvFileSecretName(settings);
|
|
300
|
+
if (!secretName) continue;
|
|
301
|
+
const method = getEnvMethod(entry);
|
|
302
|
+
const supported = envFileSupportedForMethod(method, settings);
|
|
303
|
+
const listed = workflowKeys.has(secretName);
|
|
304
|
+
let listing;
|
|
305
|
+
if (!hasWorkflowText) {
|
|
306
|
+
listing =
|
|
307
|
+
`Workflow file not available here — cannot confirm "${secretName}" is in the secret list. ` +
|
|
308
|
+
`Run deployhub sync-workflows after enabling managed .env.`;
|
|
309
|
+
} else if (listed) {
|
|
310
|
+
listing = `GitHub Secret "${secretName}" is referenced in the deploy workflow's secret list.`;
|
|
311
|
+
} else {
|
|
312
|
+
listing =
|
|
313
|
+
`GitHub Secret "${secretName}" is NOT referenced in the deploy workflow ` +
|
|
314
|
+
`(run deployhub sync-workflows).`;
|
|
315
|
+
}
|
|
316
|
+
const limitation =
|
|
317
|
+
'Secret VALUES cannot be verified (GitHub Secrets are write-only).';
|
|
318
|
+
const overwrite =
|
|
319
|
+
'DeployHub-managed .env is overwritten every deploy; do not also hand-place a .env for this environment.';
|
|
320
|
+
checks.push({
|
|
321
|
+
name: `Managed .env (${name})`,
|
|
322
|
+
pass: true,
|
|
323
|
+
message: supported
|
|
324
|
+
? `Managed .env for '${name}': ${listing} ${limitation} ${overwrite}`
|
|
325
|
+
: `Managed .env configured for '${name}' (secret "${secretName}") but ${method} does not deliver it — remove envFileSecretName or use an SSH-based method / docker remote.mode ssh. ${limitation}`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return checks;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export default {
|
|
332
|
+
MANAGED_ENV_FILE_UNPREFIXED_KEY,
|
|
333
|
+
DOCKER_MANAGED_ENV_ROOT,
|
|
334
|
+
getEnvFileSecretName,
|
|
335
|
+
envHasManagedEnvFile,
|
|
336
|
+
suggestEnvFileSecretName,
|
|
337
|
+
envFileSupportedForMethod,
|
|
338
|
+
assertEnvFileAllowed,
|
|
339
|
+
readManagedEnvFileContent,
|
|
340
|
+
resolveDockerManagedEnvRemotePath,
|
|
341
|
+
resolveSshManagedEnvRemotePaths,
|
|
342
|
+
uploadManagedEnvFile,
|
|
343
|
+
getEnvFileDoctorChecks,
|
|
344
|
+
};
|
package/src/deployment/hooks.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-environment remote shell hooks (preDeploy / postDeploy / rollback).
|
|
2
|
+
* Per-environment remote shell hooks (preDeploy / postInstall / postDeploy / rollback).
|
|
3
3
|
* One implementation for ssh, ec2, azure-vm, gcp-vm, and docker remote.mode ssh.
|
|
4
4
|
* Callers must pass the deploy's existing SSH session — do not connect again.
|
|
5
|
+
* `postInstall` is invoked only from ssh.js `runBackendStartSequence` (plain SSH
|
|
6
|
+
* methods). Docker-ssh never runs it — image build already installed dependencies.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { createLogger } from '../logger/index.js';
|
|
@@ -9,10 +11,10 @@ import { formatRemoteCommandFailure } from '../utils/shell-quote.js';
|
|
|
9
11
|
import { getEnvMethod, getEnvSettings } from '../core/environments.js';
|
|
10
12
|
import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
|
|
11
13
|
|
|
12
|
-
/** @typedef {'preDeploy'|'postDeploy'|'rollback'} HookStage */
|
|
14
|
+
/** @typedef {'preDeploy'|'postInstall'|'postDeploy'|'rollback'} HookStage */
|
|
13
15
|
|
|
14
16
|
/** @type {HookStage[]} */
|
|
15
|
-
export const HOOK_STAGES = ['preDeploy', 'postDeploy', 'rollback'];
|
|
17
|
+
export const HOOK_STAGES = ['preDeploy', 'postInstall', 'postDeploy', 'rollback'];
|
|
16
18
|
|
|
17
19
|
const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
|
|
18
20
|
|
|
@@ -75,16 +77,17 @@ function normalizeHookList(raw) {
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* @param {Record<string, unknown>} [settings]
|
|
78
|
-
* @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
|
|
80
|
+
* @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postInstall: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
|
|
79
81
|
*/
|
|
80
82
|
export function getEnvHooks(settings = {}) {
|
|
81
83
|
const raw = settings.hooks;
|
|
82
84
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
83
|
-
return { preDeploy: [], postDeploy: [], rollback: [] };
|
|
85
|
+
return { preDeploy: [], postInstall: [], postDeploy: [], rollback: [] };
|
|
84
86
|
}
|
|
85
87
|
const h = /** @type {Record<string, unknown>} */ (raw);
|
|
86
88
|
return {
|
|
87
89
|
preDeploy: normalizeHookList(h.preDeploy),
|
|
90
|
+
postInstall: normalizeHookList(h.postInstall),
|
|
88
91
|
postDeploy: normalizeHookList(h.postDeploy),
|
|
89
92
|
rollback: normalizeHookList(h.rollback),
|
|
90
93
|
};
|
|
@@ -96,7 +99,13 @@ export function getEnvHooks(settings = {}) {
|
|
|
96
99
|
*/
|
|
97
100
|
export function envHasAnyHooks(settings = {}) {
|
|
98
101
|
const h = getEnvHooks(settings);
|
|
99
|
-
return
|
|
102
|
+
return (
|
|
103
|
+
h.preDeploy.length +
|
|
104
|
+
h.postInstall.length +
|
|
105
|
+
h.postDeploy.length +
|
|
106
|
+
h.rollback.length >
|
|
107
|
+
0
|
|
108
|
+
);
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
/**
|
|
@@ -241,6 +250,7 @@ export function getHooksDoctorChecks(config) {
|
|
|
241
250
|
const h = getEnvHooks(settings);
|
|
242
251
|
const parts = [];
|
|
243
252
|
if (h.preDeploy.length) parts.push(`${h.preDeploy.length} preDeploy`);
|
|
253
|
+
if (h.postInstall.length) parts.push(`${h.postInstall.length} postInstall`);
|
|
244
254
|
if (h.postDeploy.length) parts.push(`${h.postDeploy.length} postDeploy`);
|
|
245
255
|
if (h.rollback.length) parts.push(`${h.rollback.length} rollback`);
|
|
246
256
|
const method = getEnvMethod(entry);
|
package/src/deployment/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getEnvSettings,
|
|
12
12
|
} from '../core/environments.js';
|
|
13
13
|
import { assertHooksAllowed } from './hooks.js';
|
|
14
|
+
import { assertEnvFileAllowed } from './env-file.js';
|
|
14
15
|
import { applyEnvSecretOverlay } from './deployment-env.js';
|
|
15
16
|
import { recordEnvDeployment } from '../storage/index.js';
|
|
16
17
|
import { buildArtifactRemoteKey } from '../utils/build-id.js';
|
|
@@ -84,6 +85,7 @@ export async function deployToAll(config, artifactDir, envNames) {
|
|
|
84
85
|
|
|
85
86
|
const method = getEnvMethod(envConfig);
|
|
86
87
|
assertHooksAllowed(method, getEnvSettings(envConfig), envName);
|
|
88
|
+
assertEnvFileAllowed(method, getEnvSettings(envConfig), envName);
|
|
87
89
|
const provider = getDeploymentProvider(method, config, envName);
|
|
88
90
|
log.info(`Deploying to ${envName} (${method})...`);
|
|
89
91
|
await provider.deploy(artifactDir);
|
|
@@ -133,6 +135,7 @@ export async function rollbackAll(config, artifactDir, envNames, meta) {
|
|
|
133
135
|
const envConfig = config.environments[envName];
|
|
134
136
|
const method = getEnvMethod(envConfig);
|
|
135
137
|
assertHooksAllowed(method, getEnvSettings(envConfig), envName);
|
|
138
|
+
assertEnvFileAllowed(method, getEnvSettings(envConfig), envName);
|
|
136
139
|
const provider = getDeploymentProvider(method, config, envName);
|
|
137
140
|
await provider.rollback(artifactDir, meta);
|
|
138
141
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { createEnvNamePromptValidate, normalizeGitBranchName } from '../core/environments.js';
|
|
4
|
+
import { suggestEnvFileSecretName } from './env-file.js';
|
|
4
5
|
import {
|
|
5
6
|
suggestSshUser,
|
|
6
7
|
listKubeContexts,
|
|
@@ -154,12 +155,17 @@ export async function promptServerDeployment(
|
|
|
154
155
|
|
|
155
156
|
const triggerMeta = await promptTriggerAndBranch(options);
|
|
156
157
|
const hookMeta = await promptDeployHooksIfSupported(deployType, methodAnswers);
|
|
157
|
-
|
|
158
|
+
const envFileMeta = await promptManagedEnvFileIfSupported(deployType, methodAnswers, {
|
|
159
|
+
existingEnvNames,
|
|
160
|
+
envName: methodAnswers.envName || options.envName,
|
|
161
|
+
});
|
|
162
|
+
return { ...methodAnswers, ...triggerMeta, ...hookMeta, ...envFileMeta };
|
|
158
163
|
}
|
|
159
164
|
|
|
160
165
|
/**
|
|
161
|
-
* Optional pre/post/rollback commands. Skipped unless the method
|
|
162
|
-
* DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
|
|
166
|
+
* Optional pre/post-install/post/rollback commands. Skipped unless the method
|
|
167
|
+
* has a DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
|
|
168
|
+
* `postInstall` is ssh-based only — Docker-ssh installs deps at image build time.
|
|
163
169
|
*
|
|
164
170
|
* @param {string} deployType
|
|
165
171
|
* @param {Record<string, unknown>} methodAnswers
|
|
@@ -170,7 +176,65 @@ async function promptDeployHooksIfSupported(deployType, methodAnswers) {
|
|
|
170
176
|
if (!sshBased && !dockerSsh) {
|
|
171
177
|
return {};
|
|
172
178
|
}
|
|
173
|
-
return promptDeployHooks();
|
|
179
|
+
return promptDeployHooks({ includePostInstall: sshBased });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Optional DeployHub-managed .env (GitHub Secret). Same method scope as hooks:
|
|
184
|
+
* ssh / ec2 / azure-vm / gcp-vm / docker-ssh. `--yes` never enters this path.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} deployType
|
|
187
|
+
* @param {Record<string, unknown>} methodAnswers
|
|
188
|
+
* @param {{ existingEnvNames?: string[], envName?: string }} [options]
|
|
189
|
+
*/
|
|
190
|
+
async function promptManagedEnvFileIfSupported(deployType, methodAnswers, options = {}) {
|
|
191
|
+
const sshBased = SSH_BASED.includes(deployType);
|
|
192
|
+
const dockerSsh = deployType === 'docker' && methodAnswers.remoteMode === 'ssh';
|
|
193
|
+
if (!sshBased && !dockerSsh) {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
return promptManagedEnvFile({
|
|
197
|
+
envName: options.envName || methodAnswers.envName || 'production',
|
|
198
|
+
existingEnvNames: options.existingEnvNames || [],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {{ envName?: string, existingEnvNames?: string[] }} [options]
|
|
204
|
+
* @returns {Promise<{ envFileSecretName?: string }>}
|
|
205
|
+
*/
|
|
206
|
+
export async function promptManagedEnvFile(options = {}) {
|
|
207
|
+
const envName = options.envName || 'production';
|
|
208
|
+
const existingEnvNames = options.existingEnvNames || [];
|
|
209
|
+
const suggested = suggestEnvFileSecretName(envName, existingEnvNames);
|
|
210
|
+
|
|
211
|
+
const { deployEnvFile } = await inquirer.prompt([
|
|
212
|
+
{
|
|
213
|
+
type: 'confirm',
|
|
214
|
+
name: 'deployEnvFile',
|
|
215
|
+
message: 'Does this project have a .env file that should be deployed to the server?',
|
|
216
|
+
default: false,
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
if (!deployEnvFile) return {};
|
|
220
|
+
|
|
221
|
+
console.log(
|
|
222
|
+
chalk.gray(
|
|
223
|
+
`\n Paste the FULL CONTENTS of your .env file as a GitHub Secret named ${suggested}, then confirm below.\n` +
|
|
224
|
+
` DeployHub cannot verify the secret exists ahead of time (GitHub Secrets are write-only).\n`
|
|
225
|
+
)
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await inquirer.prompt([
|
|
229
|
+
{
|
|
230
|
+
type: 'confirm',
|
|
231
|
+
name: 'envFileSecretAdded',
|
|
232
|
+
message: `Confirm once you've added the secret ${suggested}`,
|
|
233
|
+
default: false,
|
|
234
|
+
},
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
return { envFileSecretName: suggested };
|
|
174
238
|
}
|
|
175
239
|
|
|
176
240
|
/**
|
|
@@ -247,11 +311,13 @@ async function promptHookStageCommands(opts) {
|
|
|
247
311
|
}
|
|
248
312
|
|
|
249
313
|
/**
|
|
314
|
+
* @param {{ includePostInstall?: boolean }} [options]
|
|
250
315
|
* @returns {Promise<{ hooks?: Record<string, { command: string, continueOnError: boolean }[]> }>}
|
|
251
316
|
*/
|
|
252
|
-
export async function promptDeployHooks() {
|
|
317
|
+
export async function promptDeployHooks(options = {}) {
|
|
253
318
|
/** @type {Record<string, { command: string, continueOnError: boolean }[]>} */
|
|
254
319
|
const hooks = {};
|
|
320
|
+
const includePostInstall = options.includePostInstall !== false;
|
|
255
321
|
|
|
256
322
|
const preDeploy = await promptHookStageCommands({
|
|
257
323
|
firstConfirmName: 'addPreDeploy',
|
|
@@ -263,6 +329,19 @@ export async function promptDeployHooks() {
|
|
|
263
329
|
});
|
|
264
330
|
if (preDeploy.length) hooks.preDeploy = preDeploy;
|
|
265
331
|
|
|
332
|
+
if (includePostInstall) {
|
|
333
|
+
const postInstall = await promptHookStageCommands({
|
|
334
|
+
firstConfirmName: 'addPostInstall',
|
|
335
|
+
firstMessage:
|
|
336
|
+
'Add a post-install command? (e.g. prisma generate, collectstatic — runs after dependencies install, before the app starts)',
|
|
337
|
+
anotherMessage: 'Add another post-install command?',
|
|
338
|
+
commandMessage: 'Post-install command:',
|
|
339
|
+
abortMessage: 'Abort deploy if this command fails?',
|
|
340
|
+
abortDefault: true,
|
|
341
|
+
});
|
|
342
|
+
if (postInstall.length) hooks.postInstall = postInstall;
|
|
343
|
+
}
|
|
344
|
+
|
|
266
345
|
const postDeploy = await promptHookStageCommands({
|
|
267
346
|
firstConfirmName: 'addPostDeploy',
|
|
268
347
|
firstMessage: 'Add a post-deploy command? (e.g. clear cache, notify)',
|
|
@@ -885,6 +964,7 @@ export function buildServerEnvEntry(
|
|
|
885
964
|
settings.port = n;
|
|
886
965
|
}
|
|
887
966
|
attachHooksFromAnswers(settings, deployAnswers);
|
|
967
|
+
attachEnvFileFromAnswers(settings, deployAnswers);
|
|
888
968
|
return withTriggerAndBranch(
|
|
889
969
|
{
|
|
890
970
|
enabled: true,
|
|
@@ -931,6 +1011,7 @@ export function buildServerEnvEntry(
|
|
|
931
1011
|
}
|
|
932
1012
|
|
|
933
1013
|
attachHooksFromAnswers(settings, deployAnswers);
|
|
1014
|
+
attachEnvFileFromAnswers(settings, deployAnswers);
|
|
934
1015
|
return withTriggerAndBranch(
|
|
935
1016
|
{
|
|
936
1017
|
enabled: true,
|
|
@@ -951,11 +1032,23 @@ function attachHooksFromAnswers(settings, deployAnswers) {
|
|
|
951
1032
|
const raw = deployAnswers && deployAnswers.hooks;
|
|
952
1033
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
|
|
953
1034
|
const hooks = /** @type {Record<string, unknown>} */ (raw);
|
|
954
|
-
if (hooks.preDeploy || hooks.postDeploy || hooks.rollback) {
|
|
1035
|
+
if (hooks.preDeploy || hooks.postInstall || hooks.postDeploy || hooks.rollback) {
|
|
955
1036
|
settings.hooks = hooks;
|
|
956
1037
|
}
|
|
957
1038
|
}
|
|
958
1039
|
|
|
1040
|
+
/**
|
|
1041
|
+
* Store the GitHub Secret *name* only — never the .env contents.
|
|
1042
|
+
* @param {Record<string, unknown>} settings
|
|
1043
|
+
* @param {Record<string, unknown>} deployAnswers
|
|
1044
|
+
*/
|
|
1045
|
+
function attachEnvFileFromAnswers(settings, deployAnswers) {
|
|
1046
|
+
const name = deployAnswers && deployAnswers.envFileSecretName;
|
|
1047
|
+
if (typeof name === 'string' && name.trim()) {
|
|
1048
|
+
settings.envFileSecretName = name.trim();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
959
1052
|
/**
|
|
960
1053
|
* Overlay prompt answers onto the env entry. `--yes` / missing answers keep
|
|
961
1054
|
* trigger `manual` and omit `branch` (backward compatible).
|
|
@@ -6,6 +6,12 @@ import { resolveDockerContainerName } from '../../utils/docker-container-name.js
|
|
|
6
6
|
import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
|
|
7
7
|
import { createSshExecSession } from '../ssh-connection.js';
|
|
8
8
|
import { assertHooksAllowed, runDeployHooks } from '../hooks.js';
|
|
9
|
+
import {
|
|
10
|
+
assertEnvFileAllowed,
|
|
11
|
+
readManagedEnvFileContent,
|
|
12
|
+
resolveDockerManagedEnvRemotePath,
|
|
13
|
+
uploadManagedEnvFile,
|
|
14
|
+
} from '../env-file.js';
|
|
9
15
|
import { resolveDockerRemoteMode } from '../../utils/docker-remote-mode.js';
|
|
10
16
|
import {
|
|
11
17
|
resolveDockerSshTarget,
|
|
@@ -68,6 +74,10 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
68
74
|
*/
|
|
69
75
|
async function deploy(artifactDir, options = {}) {
|
|
70
76
|
assertHooksAllowed('docker', settings, envName);
|
|
77
|
+
assertEnvFileAllowed('docker', settings, envName);
|
|
78
|
+
// Fail fast (before image pull / SSH): missing or empty GitHub Secret must
|
|
79
|
+
// not reach uploadManagedEnvFile. Actions injects "" for a never-created name.
|
|
80
|
+
readManagedEnvFileContent(effectiveEnv, settings);
|
|
71
81
|
const imageRef = options.fullImage || fullImage;
|
|
72
82
|
log.info(`Deploying via Docker (image: ${imageRef})...`);
|
|
73
83
|
|
|
@@ -159,7 +169,15 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
159
169
|
*/
|
|
160
170
|
async function deployOverSsh(imageRef, port, options = {}) {
|
|
161
171
|
const isRollback = options.isRollback === true;
|
|
162
|
-
|
|
172
|
+
// Current secret value (not the old build's). Rollback re-delivers this too —
|
|
173
|
+
// managed .env is not versioned per buildId.
|
|
174
|
+
const envContent = readManagedEnvFileContent(effectiveEnv, settings);
|
|
175
|
+
const envFilePath =
|
|
176
|
+
envContent != null ? resolveDockerManagedEnvRemotePath(config, envName) : undefined;
|
|
177
|
+
const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, {
|
|
178
|
+
publishPort: port,
|
|
179
|
+
envFile: envFilePath,
|
|
180
|
+
});
|
|
163
181
|
const session = sshSession();
|
|
164
182
|
let ssh = await session.connect();
|
|
165
183
|
try {
|
|
@@ -184,12 +202,40 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
184
202
|
);
|
|
185
203
|
}
|
|
186
204
|
|
|
205
|
+
if (envContent != null && envFilePath) {
|
|
206
|
+
await uploadManagedEnvFile({
|
|
207
|
+
ssh,
|
|
208
|
+
session,
|
|
209
|
+
content: envContent,
|
|
210
|
+
remotePath: envFilePath,
|
|
211
|
+
user: sshTarget().user,
|
|
212
|
+
log,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
187
216
|
await session.exec(ssh, cmds.stop);
|
|
188
217
|
await session.exec(ssh, cmds.rm);
|
|
189
218
|
await session.exec(ssh, cmds.pull, {
|
|
190
219
|
timeoutMs: Math.max(session.defaultExecTimeoutMs, 300_000),
|
|
191
220
|
});
|
|
192
|
-
|
|
221
|
+
// `docker rm -f` can return before the name is fully released on a
|
|
222
|
+
// contended dockerd (`--rm` + stop). Re-rm immediately before run and
|
|
223
|
+
// retry name conflicts instead of failing a good deploy.
|
|
224
|
+
let lastRunErr;
|
|
225
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
226
|
+
await session.exec(ssh, cmds.rm);
|
|
227
|
+
try {
|
|
228
|
+
await session.exec(ssh, cmds.run);
|
|
229
|
+
lastRunErr = null;
|
|
230
|
+
break;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
lastRunErr = err;
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
if (!/already in use|Conflict/i.test(msg) || attempt === 3) throw err;
|
|
235
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (lastRunErr) throw lastRunErr;
|
|
193
239
|
const inspected = await session.execUnchecked(
|
|
194
240
|
ssh,
|
|
195
241
|
buildDockerInspectPortsCommand(containerName)
|