@akash-chowdhury-24/deployhub 2.0.26 → 2.0.28
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 +38 -20
- package/package.json +1 -1
- package/src/adapters/index.js +1 -0
- package/src/artifact/engine.js +2 -2
- package/src/commands/doctor.js +8 -4
- package/src/commands/init.js +5 -0
- package/src/core/config.js +7 -0
- package/src/deployment/deployment-env.js +115 -41
- package/src/deployment/init-prompts.js +52 -7
- package/src/deployment/providers/azure-vm.js +6 -4
- package/src/deployment/providers/ec2.js +8 -3
- package/src/deployment/providers/gcp-vm.js +4 -4
- package/src/deployment/providers/ssh.js +2 -2
- package/src/detectors/backend.detector.js +107 -2
- package/src/detectors/index.js +3 -2
- package/src/utils/credential-inventory.js +292 -0
- package/src/utils/docker-image.js +6 -3
- package/src/utils/dockerfile-expose.js +1 -0
- package/src/utils/dockerfile.js +181 -28
- package/src/utils/github-actions.js +194 -17
|
@@ -81,7 +81,12 @@ export async function promptServerDeployment(
|
|
|
81
81
|
'Non-interactive env add requires --method <ssh|docker|ec2|azure-vm|gcp-vm|kubernetes>.'
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
|
-
return buildNonInteractiveDeployAnswers(
|
|
84
|
+
return buildNonInteractiveDeployAnswers(
|
|
85
|
+
options.deployType,
|
|
86
|
+
projectName,
|
|
87
|
+
options.envName,
|
|
88
|
+
existingEnvNames
|
|
89
|
+
);
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/** @type {import('inquirer').QuestionCollection} */
|
|
@@ -122,7 +127,9 @@ export async function promptServerDeployment(
|
|
|
122
127
|
const deployType = base.deployType;
|
|
123
128
|
|
|
124
129
|
if (deployType === 'kubernetes') {
|
|
125
|
-
return promptKubernetesDeployment(base, projectName, projectType
|
|
130
|
+
return promptKubernetesDeployment(base, projectName, projectType, {
|
|
131
|
+
existingEnvNames,
|
|
132
|
+
});
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
if (deployType === 'docker') {
|
|
@@ -137,8 +144,14 @@ export async function promptServerDeployment(
|
|
|
137
144
|
* @param {string} deployType
|
|
138
145
|
* @param {string} projectName
|
|
139
146
|
* @param {string} [envName]
|
|
147
|
+
* @param {string[]} [existingEnvNames]
|
|
140
148
|
*/
|
|
141
|
-
function buildNonInteractiveDeployAnswers(
|
|
149
|
+
function buildNonInteractiveDeployAnswers(
|
|
150
|
+
deployType,
|
|
151
|
+
projectName,
|
|
152
|
+
envName,
|
|
153
|
+
existingEnvNames = []
|
|
154
|
+
) {
|
|
142
155
|
const base = { deployType, envName: envName || 'default' };
|
|
143
156
|
if (deployType === 'docker') {
|
|
144
157
|
return {
|
|
@@ -156,7 +169,11 @@ function buildNonInteractiveDeployAnswers(deployType, projectName, envName) {
|
|
|
156
169
|
...base,
|
|
157
170
|
kubeconfig: '~/.kube/config',
|
|
158
171
|
kubeContext: '',
|
|
159
|
-
kubeNamespace:
|
|
172
|
+
kubeNamespace: suggestKubeNamespaceDefault(
|
|
173
|
+
projectName,
|
|
174
|
+
base.envName,
|
|
175
|
+
existingEnvNames
|
|
176
|
+
),
|
|
160
177
|
dockerImageName: projectName,
|
|
161
178
|
dockerRegistryUrl: '',
|
|
162
179
|
dockerRegistryUsername: '',
|
|
@@ -176,14 +193,39 @@ function buildNonInteractiveDeployAnswers(deployType, projectName, envName) {
|
|
|
176
193
|
};
|
|
177
194
|
}
|
|
178
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Suggested Kubernetes namespace for prompts / --yes defaults.
|
|
198
|
+
* First environment → bare project name; additional envs → `{project}-{envName}`
|
|
199
|
+
* so the suggested default matches resolveKubeNamespace auto-scoping behavior.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} projectName
|
|
202
|
+
* @param {string} envName
|
|
203
|
+
* @param {string[]} existingEnvNames
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
export function suggestKubeNamespaceDefault(projectName, envName, existingEnvNames = []) {
|
|
207
|
+
if ((existingEnvNames || []).length > 0) {
|
|
208
|
+
return `${projectName}-${envName}`;
|
|
209
|
+
}
|
|
210
|
+
return projectName;
|
|
211
|
+
}
|
|
212
|
+
|
|
179
213
|
/**
|
|
180
214
|
* @param {Record<string, string>} base
|
|
181
215
|
* @param {string} projectName
|
|
182
216
|
* @param {'frontend'|'backend'|'both'} projectType
|
|
217
|
+
* @param {{ existingEnvNames?: string[] }} [options]
|
|
183
218
|
*/
|
|
184
|
-
async function promptKubernetesDeployment(base, projectName, projectType) {
|
|
219
|
+
async function promptKubernetesDeployment(base, projectName, projectType, options = {}) {
|
|
185
220
|
const defaultKubeconfig = await detectKubeconfigPath();
|
|
186
221
|
const contexts = await listKubeContexts();
|
|
222
|
+
const envName = base.envName || 'production';
|
|
223
|
+
const existingEnvNames = options.existingEnvNames || [];
|
|
224
|
+
const namespaceDefault = suggestKubeNamespaceDefault(
|
|
225
|
+
projectName,
|
|
226
|
+
envName,
|
|
227
|
+
existingEnvNames
|
|
228
|
+
);
|
|
187
229
|
|
|
188
230
|
const kubeAnswers = await inquirer.prompt([
|
|
189
231
|
{
|
|
@@ -202,8 +244,11 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
|
|
|
202
244
|
{
|
|
203
245
|
type: 'input',
|
|
204
246
|
name: 'kubeNamespace',
|
|
205
|
-
message:
|
|
206
|
-
|
|
247
|
+
message:
|
|
248
|
+
existingEnvNames.length > 0
|
|
249
|
+
? `Kubernetes namespace (suggested ${namespaceDefault} so it does not collide with existing envs):`
|
|
250
|
+
: 'Kubernetes namespace (e.g. my-app or default):',
|
|
251
|
+
default: namespaceDefault,
|
|
207
252
|
},
|
|
208
253
|
{
|
|
209
254
|
type: 'input',
|
|
@@ -11,9 +11,11 @@ import { getEnvSettings } from '../../core/config.js';
|
|
|
11
11
|
export function createAzureVmProvider(config, envName, env = process.env) {
|
|
12
12
|
const log = createLogger('azure-vm');
|
|
13
13
|
const settings = getEnvSettings(config.environments[envName]);
|
|
14
|
-
const subscriptionId =
|
|
15
|
-
|
|
16
|
-
const
|
|
14
|
+
const subscriptionId =
|
|
15
|
+
env.AZURE_VM_LOOKUP_SUBSCRIPTION_ID || settings.azureSubscriptionId;
|
|
16
|
+
const resourceGroup =
|
|
17
|
+
env.AZURE_VM_LOOKUP_RESOURCE_GROUP || settings.azureResourceGroup;
|
|
18
|
+
const vmName = env.AZURE_VM_LOOKUP_VM_NAME || settings.azureVmName;
|
|
17
19
|
|
|
18
20
|
async function resolveHost() {
|
|
19
21
|
if (settings.host || env.SSH_HOST) {
|
|
@@ -23,7 +25,7 @@ export function createAzureVmProvider(config, envName, env = process.env) {
|
|
|
23
25
|
if (!subscriptionId || !resourceGroup || !vmName) {
|
|
24
26
|
throw new Error(
|
|
25
27
|
'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — ' +
|
|
26
|
-
'provide SSH_HOST (VM public IP/DNS) or set
|
|
28
|
+
'provide SSH_HOST (VM public IP/DNS) or set AZURE_VM_LOOKUP_SUBSCRIPTION_ID, AZURE_VM_LOOKUP_RESOURCE_GROUP, and AZURE_VM_LOOKUP_VM_NAME for auto lookup.'
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -12,7 +12,8 @@ export function createEc2Provider(config, envName, env = process.env) {
|
|
|
12
12
|
const log = createLogger('ec2');
|
|
13
13
|
const settings = getEnvSettings(config.environments[envName]);
|
|
14
14
|
const instanceId = env.EC2_INSTANCE_ID || settings.ec2InstanceId;
|
|
15
|
-
const region =
|
|
15
|
+
const region =
|
|
16
|
+
env.EC2_LOOKUP_AWS_REGION || settings.awsRegion || 'us-east-1';
|
|
16
17
|
|
|
17
18
|
async function resolveHost() {
|
|
18
19
|
if (settings.host || env.SSH_HOST) {
|
|
@@ -30,8 +31,12 @@ export function createEc2Provider(config, envName, env = process.env) {
|
|
|
30
31
|
|
|
31
32
|
/** @type {Record<string, string>} */
|
|
32
33
|
const awsEnv = { ...process.env };
|
|
33
|
-
if (env.
|
|
34
|
-
|
|
34
|
+
if (env.EC2_LOOKUP_AWS_ACCESS_KEY_ID) {
|
|
35
|
+
awsEnv.AWS_ACCESS_KEY_ID = env.EC2_LOOKUP_AWS_ACCESS_KEY_ID;
|
|
36
|
+
}
|
|
37
|
+
if (env.EC2_LOOKUP_AWS_SECRET_ACCESS_KEY) {
|
|
38
|
+
awsEnv.AWS_SECRET_ACCESS_KEY = env.EC2_LOOKUP_AWS_SECRET_ACCESS_KEY;
|
|
39
|
+
}
|
|
35
40
|
awsEnv.AWS_DEFAULT_REGION = region;
|
|
36
41
|
|
|
37
42
|
try {
|
|
@@ -11,7 +11,7 @@ import { getEnvSettings } from '../../core/config.js';
|
|
|
11
11
|
export function createGcpVmProvider(config, envName, env = process.env) {
|
|
12
12
|
const log = createLogger('gcp-vm');
|
|
13
13
|
const settings = getEnvSettings(config.environments[envName]);
|
|
14
|
-
const projectId = env.
|
|
14
|
+
const projectId = env.GCP_VM_LOOKUP_PROJECT_ID || settings.gcpProjectId;
|
|
15
15
|
const zone = env.GCP_ZONE || settings.gcpZone;
|
|
16
16
|
const instanceName = env.GCP_INSTANCE_NAME || settings.gcpInstanceName;
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ export function createGcpVmProvider(config, envName, env = process.env) {
|
|
|
23
23
|
if (!projectId || !zone || !instanceName) {
|
|
24
24
|
throw new Error(
|
|
25
25
|
'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — ' +
|
|
26
|
-
'provide SSH_HOST (instance external IP/DNS) or set
|
|
26
|
+
'provide SSH_HOST (instance external IP/DNS) or set GCP_VM_LOOKUP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -31,8 +31,8 @@ export function createGcpVmProvider(config, envName, env = process.env) {
|
|
|
31
31
|
|
|
32
32
|
/** @type {Record<string, string>} */
|
|
33
33
|
const gcpEnv = { ...process.env };
|
|
34
|
-
if (env.
|
|
35
|
-
gcpEnv.GOOGLE_APPLICATION_CREDENTIALS = env.
|
|
34
|
+
if (env.GCP_VM_LOOKUP_KEY_FILE) {
|
|
35
|
+
gcpEnv.GOOGLE_APPLICATION_CREDENTIALS = env.GCP_VM_LOOKUP_KEY_FILE;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
try {
|
|
@@ -426,10 +426,10 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
426
426
|
return;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
-
if (framework === 'rails') {
|
|
429
|
+
if (framework === 'rails' || framework === 'ruby') {
|
|
430
430
|
await exec(ssh, `cd ${dir} && bundle install --deployment`);
|
|
431
431
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
432
|
-
await startScopedNohup(ssh, targetPath, `bundle exec puma -
|
|
432
|
+
await startScopedNohup(ssh, targetPath, `bundle exec puma -b tcp://0.0.0.0:${port}`);
|
|
433
433
|
return;
|
|
434
434
|
}
|
|
435
435
|
|
|
@@ -64,6 +64,20 @@ const FRAMEWORKS = {
|
|
|
64
64
|
port: 3000,
|
|
65
65
|
},
|
|
66
66
|
},
|
|
67
|
+
node: {
|
|
68
|
+
// After named Node frameworks: package.json backend without Express/Nest/Fastify/Koa.
|
|
69
|
+
// Exclude obvious frontend-only trees so React/Vue apps are not labeled "node".
|
|
70
|
+
detect: (cwd) =>
|
|
71
|
+
fs.existsSync(path.join(cwd, 'package.json')) && !isLikelyFrontendPackage(cwd),
|
|
72
|
+
defaults: {
|
|
73
|
+
language: 'node',
|
|
74
|
+
buildCommand: null,
|
|
75
|
+
startCommand: 'npm start',
|
|
76
|
+
buildOutput: '.',
|
|
77
|
+
testCommand: 'npm test',
|
|
78
|
+
port: 3000,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
67
81
|
fastapi: {
|
|
68
82
|
detect: (cwd) => fileContains(cwd, 'requirements.txt', 'fastapi'),
|
|
69
83
|
defaults: {
|
|
@@ -99,6 +113,20 @@ const FRAMEWORKS = {
|
|
|
99
113
|
port: 5000,
|
|
100
114
|
},
|
|
101
115
|
},
|
|
116
|
+
python: {
|
|
117
|
+
detect: (cwd) =>
|
|
118
|
+
fs.existsSync(path.join(cwd, 'requirements.txt')) ||
|
|
119
|
+
fs.existsSync(path.join(cwd, 'pyproject.toml')) ||
|
|
120
|
+
fs.existsSync(path.join(cwd, 'setup.py')),
|
|
121
|
+
defaults: {
|
|
122
|
+
language: 'python',
|
|
123
|
+
buildCommand: null,
|
|
124
|
+
startCommand: 'python -m http.server 8000 --bind 0.0.0.0',
|
|
125
|
+
buildOutput: '.',
|
|
126
|
+
testCommand: 'pytest',
|
|
127
|
+
port: 8000,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
102
130
|
laravel: {
|
|
103
131
|
detect: (cwd) => hasComposerPackage(cwd, 'laravel/framework'),
|
|
104
132
|
defaults: {
|
|
@@ -115,12 +143,25 @@ const FRAMEWORKS = {
|
|
|
115
143
|
defaults: {
|
|
116
144
|
language: 'php',
|
|
117
145
|
buildCommand: null,
|
|
118
|
-
startCommand: 'php-
|
|
146
|
+
startCommand: 'php -S 0.0.0.0:80 -t public',
|
|
119
147
|
buildOutput: '.',
|
|
120
148
|
testCommand: 'php bin/phpunit',
|
|
121
149
|
port: 80,
|
|
122
150
|
},
|
|
123
151
|
},
|
|
152
|
+
php: {
|
|
153
|
+
// After Laravel/Symfony: composer.json without those frameworks, or bare .php sources.
|
|
154
|
+
detect: (cwd) =>
|
|
155
|
+
fs.existsSync(path.join(cwd, 'composer.json')) || hasPhpSources(cwd),
|
|
156
|
+
defaults: {
|
|
157
|
+
language: 'php',
|
|
158
|
+
buildCommand: null,
|
|
159
|
+
startCommand: 'php -S 0.0.0.0:80 -t .',
|
|
160
|
+
buildOutput: '.',
|
|
161
|
+
testCommand: null,
|
|
162
|
+
port: 80,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
124
165
|
spring: {
|
|
125
166
|
detect: (cwd) => fileContains(cwd, 'pom.xml', 'spring-boot'),
|
|
126
167
|
defaults: {
|
|
@@ -132,6 +173,20 @@ const FRAMEWORKS = {
|
|
|
132
173
|
port: 8080,
|
|
133
174
|
},
|
|
134
175
|
},
|
|
176
|
+
java: {
|
|
177
|
+
detect: (cwd) =>
|
|
178
|
+
fs.existsSync(path.join(cwd, 'pom.xml')) ||
|
|
179
|
+
fs.existsSync(path.join(cwd, 'build.gradle')) ||
|
|
180
|
+
fs.existsSync(path.join(cwd, 'build.gradle.kts')),
|
|
181
|
+
defaults: {
|
|
182
|
+
language: 'java',
|
|
183
|
+
buildCommand: 'mvn package',
|
|
184
|
+
startCommand: 'java -jar target/*.jar',
|
|
185
|
+
buildOutput: 'target',
|
|
186
|
+
testCommand: 'mvn test',
|
|
187
|
+
port: 8080,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
135
190
|
go: {
|
|
136
191
|
detect: (cwd) => fs.existsSync(path.join(cwd, 'go.mod')),
|
|
137
192
|
defaults: {
|
|
@@ -165,12 +220,23 @@ const FRAMEWORKS = {
|
|
|
165
220
|
defaults: {
|
|
166
221
|
language: 'ruby',
|
|
167
222
|
buildCommand: 'bundle exec rake assets:precompile',
|
|
168
|
-
startCommand: 'bundle exec puma',
|
|
223
|
+
startCommand: 'bundle exec puma -b tcp://0.0.0.0:3000',
|
|
169
224
|
buildOutput: '.',
|
|
170
225
|
testCommand: 'bundle exec rspec',
|
|
171
226
|
port: 3000,
|
|
172
227
|
},
|
|
173
228
|
},
|
|
229
|
+
ruby: {
|
|
230
|
+
detect: (cwd) => fs.existsSync(path.join(cwd, 'Gemfile')),
|
|
231
|
+
defaults: {
|
|
232
|
+
language: 'ruby',
|
|
233
|
+
buildCommand: null,
|
|
234
|
+
startCommand: 'bundle exec puma -b tcp://0.0.0.0:9292',
|
|
235
|
+
buildOutput: '.',
|
|
236
|
+
testCommand: 'bundle exec rspec',
|
|
237
|
+
port: 9292,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
174
240
|
};
|
|
175
241
|
|
|
176
242
|
const DETECTION_ORDER = [
|
|
@@ -178,15 +244,20 @@ const DETECTION_ORDER = [
|
|
|
178
244
|
'express',
|
|
179
245
|
'fastify',
|
|
180
246
|
'koa',
|
|
247
|
+
'node',
|
|
181
248
|
'fastapi',
|
|
182
249
|
'django',
|
|
183
250
|
'flask',
|
|
251
|
+
'python',
|
|
184
252
|
'laravel',
|
|
185
253
|
'symfony',
|
|
254
|
+
'php',
|
|
186
255
|
'spring',
|
|
256
|
+
'java',
|
|
187
257
|
'go',
|
|
188
258
|
'dotnet',
|
|
189
259
|
'rails',
|
|
260
|
+
'ruby',
|
|
190
261
|
];
|
|
191
262
|
|
|
192
263
|
/**
|
|
@@ -201,6 +272,22 @@ function hasPackageDep(cwd, dep) {
|
|
|
201
272
|
return !!deps[dep];
|
|
202
273
|
}
|
|
203
274
|
|
|
275
|
+
/**
|
|
276
|
+
* @param {string} cwd
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
function isLikelyFrontendPackage(cwd) {
|
|
280
|
+
return [
|
|
281
|
+
'react',
|
|
282
|
+
'vue',
|
|
283
|
+
'@angular/core',
|
|
284
|
+
'svelte',
|
|
285
|
+
'astro',
|
|
286
|
+
'next',
|
|
287
|
+
'vite',
|
|
288
|
+
].some((dep) => hasPackageDep(cwd, dep));
|
|
289
|
+
}
|
|
290
|
+
|
|
204
291
|
/**
|
|
205
292
|
* @param {string} cwd
|
|
206
293
|
* @param {string} filename
|
|
@@ -225,6 +312,24 @@ function hasComposerPackage(cwd, packageName) {
|
|
|
225
312
|
return !!deps[packageName];
|
|
226
313
|
}
|
|
227
314
|
|
|
315
|
+
/**
|
|
316
|
+
* @param {string} cwd
|
|
317
|
+
* @returns {boolean}
|
|
318
|
+
*/
|
|
319
|
+
function hasPhpSources(cwd) {
|
|
320
|
+
try {
|
|
321
|
+
const root = fs.readdirSync(cwd);
|
|
322
|
+
if (root.some((f) => f.endsWith('.php'))) return true;
|
|
323
|
+
const publicDir = path.join(cwd, 'public');
|
|
324
|
+
if (fs.existsSync(publicDir) && fs.statSync(publicDir).isDirectory()) {
|
|
325
|
+
return fs.readdirSync(publicDir).some((f) => f.endsWith('.php'));
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
228
333
|
/**
|
|
229
334
|
* @param {string} [cwd]
|
|
230
335
|
* @returns {string|null}
|
package/src/detectors/index.js
CHANGED
|
@@ -43,13 +43,14 @@ export async function detectFramework(cwd = process.cwd()) {
|
|
|
43
43
|
for (const detector of legacyDetectors) {
|
|
44
44
|
if (detector.detect(cwd)) {
|
|
45
45
|
const info = detector.getInfo(cwd);
|
|
46
|
+
const backendLegacy = new Set(['node', 'php', 'python', 'java', 'go', 'dotnet', 'ruby']);
|
|
46
47
|
return {
|
|
47
48
|
...info,
|
|
48
|
-
projectType: info.framework
|
|
49
|
+
projectType: backendLegacy.has(info.framework) ? 'backend' : 'frontend',
|
|
49
50
|
language: info.framework,
|
|
50
51
|
startCommand: null,
|
|
51
52
|
testCommand: 'npm test',
|
|
52
|
-
port: 3000,
|
|
53
|
+
port: info.framework === 'php' ? 80 : info.framework === 'ruby' ? 9292 : 3000,
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical credential / env-var inventory for storage × deployment collision audits.
|
|
3
|
+
*
|
|
4
|
+
* Storage lists are the COMPLETE runtime reads from src/storage/providers/*.js
|
|
5
|
+
* (plus schema keys from PROVIDER_ENV_MAP). Deployment lists come from
|
|
6
|
+
* DEPLOYMENT_ENV_DEFS / DEPLOYMENT_ENV_KEYS (the declared complete method sets).
|
|
7
|
+
*
|
|
8
|
+
* Keep this module in sync when adding providers or methods — the matrix test
|
|
9
|
+
* fails if storage and deployment declare overlapping bare names.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DEPLOYMENT_ENV_KEYS } from '../deployment/deployment-env.js';
|
|
13
|
+
|
|
14
|
+
/** Storage provider ids in matrix order. */
|
|
15
|
+
export const STORAGE_PROVIDER_ORDER = [
|
|
16
|
+
'aws',
|
|
17
|
+
'azure',
|
|
18
|
+
'gcp',
|
|
19
|
+
'gdrive',
|
|
20
|
+
'dropbox',
|
|
21
|
+
'ftp',
|
|
22
|
+
'local',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Deployment method ids in matrix order. */
|
|
26
|
+
export const DEPLOYMENT_METHOD_ORDER = [
|
|
27
|
+
'ssh',
|
|
28
|
+
'ec2',
|
|
29
|
+
'azure-vm',
|
|
30
|
+
'gcp-vm',
|
|
31
|
+
'docker',
|
|
32
|
+
'kubernetes',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Complete env vars each storage provider reads at runtime.
|
|
37
|
+
* Local: no credentials — DEPLOYHUB_LOCAL_STORAGE_DIR is an optional test pin only
|
|
38
|
+
* (listed under runtimeExtras, not as a credential).
|
|
39
|
+
*
|
|
40
|
+
* @type {Record<string, string[]>}
|
|
41
|
+
*/
|
|
42
|
+
export const STORAGE_RUNTIME_ENV_KEYS = {
|
|
43
|
+
aws: [
|
|
44
|
+
'AWS_ACCESS_KEY_ID',
|
|
45
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
46
|
+
'AWS_BUCKET',
|
|
47
|
+
'AWS_REGION',
|
|
48
|
+
],
|
|
49
|
+
azure: ['AZURE_CONNECTION_STRING', 'AZURE_CONTAINER'],
|
|
50
|
+
gcp: ['GCP_PROJECT_ID', 'GCP_KEY_FILE', 'GCP_BUCKET'],
|
|
51
|
+
gdrive: [
|
|
52
|
+
'GDRIVE_CLIENT_ID',
|
|
53
|
+
'GDRIVE_CLIENT_SECRET',
|
|
54
|
+
'GDRIVE_REFRESH_TOKEN',
|
|
55
|
+
'GDRIVE_FOLDER_ID',
|
|
56
|
+
],
|
|
57
|
+
dropbox: ['DROPBOX_ACCESS_TOKEN'],
|
|
58
|
+
ftp: ['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD', 'FTP_PORT', 'FTP_PATH'],
|
|
59
|
+
local: [],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Non-credential runtime extras (optional paths / tuning). Not treated as
|
|
64
|
+
* storage credentials for collision purposes, but tracked for schema-drift docs.
|
|
65
|
+
*
|
|
66
|
+
* @type {Record<string, string[]>}
|
|
67
|
+
*/
|
|
68
|
+
export const STORAGE_RUNTIME_EXTRAS = {
|
|
69
|
+
local: ['DEPLOYHUB_LOCAL_STORAGE_DIR'],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Complete declared deployment method env keys (core + optional + CI).
|
|
74
|
+
* @type {Record<string, string[]>}
|
|
75
|
+
*/
|
|
76
|
+
export const DEPLOYMENT_RUNTIME_ENV_KEYS = {
|
|
77
|
+
ssh: DEPLOYMENT_ENV_KEYS.ssh,
|
|
78
|
+
ec2: DEPLOYMENT_ENV_KEYS.ec2,
|
|
79
|
+
'azure-vm': DEPLOYMENT_ENV_KEYS['azure-vm'],
|
|
80
|
+
'gcp-vm': DEPLOYMENT_ENV_KEYS['gcp-vm'],
|
|
81
|
+
docker: DEPLOYMENT_ENV_KEYS.docker,
|
|
82
|
+
kubernetes: DEPLOYMENT_ENV_KEYS.kubernetes,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* SSH runtime also reads these tuning knobs (not in DEPLOYMENT_ENV_DEFS).
|
|
87
|
+
* Not credentials; listed for inventory completeness.
|
|
88
|
+
*/
|
|
89
|
+
export const SSH_RUNTIME_TUNING_KEYS = [
|
|
90
|
+
'DEPLOYHUB_SSH_EXEC_TIMEOUT_MS',
|
|
91
|
+
'DEPLOYHUB_SSH_START_TIMEOUT_MS',
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Intentional same-name sharing across deployment methods (same concern).
|
|
96
|
+
* Separated per-environment by the existing secret-prefixing system when
|
|
97
|
+
* multiple environments exist.
|
|
98
|
+
*
|
|
99
|
+
* @type {{ methods: [string, string], keys: string[], reason: string }[]}
|
|
100
|
+
*/
|
|
101
|
+
export const SHARED_BY_DESIGN_DEPLOY_PAIRS = [
|
|
102
|
+
{
|
|
103
|
+
methods: ['ssh', 'ec2'],
|
|
104
|
+
keys: [
|
|
105
|
+
'SSH_HOST',
|
|
106
|
+
'SSH_USER',
|
|
107
|
+
'SSH_KEY_PATH',
|
|
108
|
+
'SSH_SSH_PORT',
|
|
109
|
+
'SSH_DEPLOY_PATH',
|
|
110
|
+
'SSH_APP_NAME',
|
|
111
|
+
'SSH_PORT',
|
|
112
|
+
'SSH_KEY',
|
|
113
|
+
],
|
|
114
|
+
reason: 'EC2 wraps SSH after optional host lookup — same SSH concern',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
methods: ['ssh', 'azure-vm'],
|
|
118
|
+
keys: [
|
|
119
|
+
'SSH_HOST',
|
|
120
|
+
'SSH_USER',
|
|
121
|
+
'SSH_KEY_PATH',
|
|
122
|
+
'SSH_SSH_PORT',
|
|
123
|
+
'SSH_DEPLOY_PATH',
|
|
124
|
+
'SSH_APP_NAME',
|
|
125
|
+
'SSH_PORT',
|
|
126
|
+
'SSH_KEY',
|
|
127
|
+
],
|
|
128
|
+
reason: 'Azure VM wraps SSH after optional host lookup — same SSH concern',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
methods: ['ssh', 'gcp-vm'],
|
|
132
|
+
keys: [
|
|
133
|
+
'SSH_HOST',
|
|
134
|
+
'SSH_USER',
|
|
135
|
+
'SSH_KEY_PATH',
|
|
136
|
+
'SSH_SSH_PORT',
|
|
137
|
+
'SSH_DEPLOY_PATH',
|
|
138
|
+
'SSH_APP_NAME',
|
|
139
|
+
'SSH_PORT',
|
|
140
|
+
'SSH_KEY',
|
|
141
|
+
],
|
|
142
|
+
reason: 'GCP VM wraps SSH after optional host lookup — same SSH concern',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
methods: ['ec2', 'azure-vm'],
|
|
146
|
+
keys: [
|
|
147
|
+
'SSH_HOST',
|
|
148
|
+
'SSH_USER',
|
|
149
|
+
'SSH_KEY_PATH',
|
|
150
|
+
'SSH_SSH_PORT',
|
|
151
|
+
'SSH_DEPLOY_PATH',
|
|
152
|
+
'SSH_APP_NAME',
|
|
153
|
+
'SSH_PORT',
|
|
154
|
+
'SSH_KEY',
|
|
155
|
+
],
|
|
156
|
+
reason: 'Both SSH-based cloud VMs share the SSH credential namespace',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
methods: ['ec2', 'gcp-vm'],
|
|
160
|
+
keys: [
|
|
161
|
+
'SSH_HOST',
|
|
162
|
+
'SSH_USER',
|
|
163
|
+
'SSH_KEY_PATH',
|
|
164
|
+
'SSH_SSH_PORT',
|
|
165
|
+
'SSH_DEPLOY_PATH',
|
|
166
|
+
'SSH_APP_NAME',
|
|
167
|
+
'SSH_PORT',
|
|
168
|
+
'SSH_KEY',
|
|
169
|
+
],
|
|
170
|
+
reason: 'Both SSH-based cloud VMs share the SSH credential namespace',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
methods: ['azure-vm', 'gcp-vm'],
|
|
174
|
+
keys: [
|
|
175
|
+
'SSH_HOST',
|
|
176
|
+
'SSH_USER',
|
|
177
|
+
'SSH_KEY_PATH',
|
|
178
|
+
'SSH_SSH_PORT',
|
|
179
|
+
'SSH_DEPLOY_PATH',
|
|
180
|
+
'SSH_APP_NAME',
|
|
181
|
+
'SSH_PORT',
|
|
182
|
+
'SSH_KEY',
|
|
183
|
+
],
|
|
184
|
+
reason: 'Both SSH-based cloud VMs share the SSH credential namespace',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
methods: ['docker', 'kubernetes'],
|
|
188
|
+
keys: [
|
|
189
|
+
'DOCKER_IMAGE_NAME',
|
|
190
|
+
'DOCKER_IMAGE_TAG',
|
|
191
|
+
'DOCKER_REGISTRY_URL',
|
|
192
|
+
'DOCKER_REGISTRY_USERNAME',
|
|
193
|
+
'DOCKER_REGISTRY_TOKEN',
|
|
194
|
+
],
|
|
195
|
+
reason:
|
|
196
|
+
'Same container-registry concern — Kubernetes builds/pushes via the same Docker image helpers',
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string[]} a
|
|
202
|
+
* @param {string[]} b
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
export function intersectKeys(a, b) {
|
|
206
|
+
const setB = new Set(b);
|
|
207
|
+
return a.filter((k) => setB.has(k)).sort();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Every storage × deployment overlap cell.
|
|
212
|
+
* Empty array = no overlap.
|
|
213
|
+
*
|
|
214
|
+
* @returns {Record<string, Record<string, string[]>>}
|
|
215
|
+
*/
|
|
216
|
+
export function buildStorageDeployOverlapMatrix() {
|
|
217
|
+
/** @type {Record<string, Record<string, string[]>>} */
|
|
218
|
+
const matrix = {};
|
|
219
|
+
for (const storageId of STORAGE_PROVIDER_ORDER) {
|
|
220
|
+
matrix[storageId] = {};
|
|
221
|
+
const storageKeys = STORAGE_RUNTIME_ENV_KEYS[storageId] || [];
|
|
222
|
+
for (const method of DEPLOYMENT_METHOD_ORDER) {
|
|
223
|
+
const deployKeys = DEPLOYMENT_RUNTIME_ENV_KEYS[method] || [];
|
|
224
|
+
matrix[storageId][method] = intersectKeys(storageKeys, deployKeys);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return matrix;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Flatten all unintended storage×deploy collisions (should be empty).
|
|
232
|
+
* @returns {{ storage: string, method: string, keys: string[] }[]}
|
|
233
|
+
*/
|
|
234
|
+
export function listStorageDeployCollisions() {
|
|
235
|
+
const matrix = buildStorageDeployOverlapMatrix();
|
|
236
|
+
/** @type {{ storage: string, method: string, keys: string[] }[]} */
|
|
237
|
+
const hits = [];
|
|
238
|
+
for (const storageId of STORAGE_PROVIDER_ORDER) {
|
|
239
|
+
for (const method of DEPLOYMENT_METHOD_ORDER) {
|
|
240
|
+
const keys = matrix[storageId][method];
|
|
241
|
+
if (keys.length > 0) {
|
|
242
|
+
hits.push({ storage: storageId, method, keys });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return hits;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Deploy×deploy shared keys, classified against SHARED_BY_DESIGN_DEPLOY_PAIRS.
|
|
251
|
+
* @returns {{ a: string, b: string, keys: string[], designed: boolean, reason?: string }[]}
|
|
252
|
+
*/
|
|
253
|
+
export function listDeployDeployShares() {
|
|
254
|
+
/** @type {{ a: string, b: string, keys: string[], designed: boolean, reason?: string }[]} */
|
|
255
|
+
const rows = [];
|
|
256
|
+
for (let i = 0; i < DEPLOYMENT_METHOD_ORDER.length; i++) {
|
|
257
|
+
for (let j = i + 1; j < DEPLOYMENT_METHOD_ORDER.length; j++) {
|
|
258
|
+
const a = DEPLOYMENT_METHOD_ORDER[i];
|
|
259
|
+
const b = DEPLOYMENT_METHOD_ORDER[j];
|
|
260
|
+
const keys = intersectKeys(
|
|
261
|
+
DEPLOYMENT_RUNTIME_ENV_KEYS[a] || [],
|
|
262
|
+
DEPLOYMENT_RUNTIME_ENV_KEYS[b] || []
|
|
263
|
+
);
|
|
264
|
+
if (keys.length === 0) continue;
|
|
265
|
+
const designed = SHARED_BY_DESIGN_DEPLOY_PAIRS.find(
|
|
266
|
+
(p) =>
|
|
267
|
+
(p.methods[0] === a && p.methods[1] === b) ||
|
|
268
|
+
(p.methods[0] === b && p.methods[1] === a)
|
|
269
|
+
);
|
|
270
|
+
const unexpected = designed
|
|
271
|
+
? keys.filter((k) => !designed.keys.includes(k))
|
|
272
|
+
: keys;
|
|
273
|
+
rows.push({
|
|
274
|
+
a,
|
|
275
|
+
b,
|
|
276
|
+
keys,
|
|
277
|
+
designed: Boolean(designed) && unexpected.length === 0,
|
|
278
|
+
reason: designed?.reason,
|
|
279
|
+
});
|
|
280
|
+
if (designed && unexpected.length > 0) {
|
|
281
|
+
rows.push({
|
|
282
|
+
a,
|
|
283
|
+
b,
|
|
284
|
+
keys: unexpected,
|
|
285
|
+
designed: false,
|
|
286
|
+
reason: `unexpected share beyond designed set for ${a}∩${b}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return rows;
|
|
292
|
+
}
|