@akash-chowdhury-24/deployhub 2.0.26 → 2.0.27
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 +33 -18
- 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/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 +67 -10
package/README.md
CHANGED
|
@@ -883,13 +883,13 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
883
883
|
- Nginx layout detection and config test before reload (frontend)
|
|
884
884
|
- Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
|
|
885
885
|
- OS user suggestion from AMI hint (ubuntu, ec2-user)
|
|
886
|
-
- Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
|
|
886
|
+
- Optional public IP lookup via `EC2_INSTANCE_ID` + `EC2_LOOKUP_AWS_*` (AWS CLI)
|
|
887
887
|
|
|
888
888
|
**After `init`:**
|
|
889
889
|
1. AWS Console → EC2 → Security Groups → Inbound rules → SSH port 22 from My IP
|
|
890
890
|
2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
|
|
891
|
-
3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` +
|
|
892
|
-
4. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `
|
|
891
|
+
3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + `EC2_LOOKUP_AWS_*`)
|
|
892
|
+
4. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `EC2_LOOKUP_AWS_*` if using instance ID lookup (these are **not** the same secrets as AWS S3 storage)
|
|
893
893
|
5. Run `deployhub doctor`, then `git push origin main`
|
|
894
894
|
|
|
895
895
|
| Variable | Description | Example | Where to get it |
|
|
@@ -898,9 +898,9 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
898
898
|
| `SSH_USER` | SSH user for AMI | `ec2-user` | AMI documentation |
|
|
899
899
|
| `SSH_KEY_PATH` | Path to .pem key | `~/.ssh/ec2-key.pem` | Downloaded at instance launch |
|
|
900
900
|
| `EC2_INSTANCE_ID` | Instance ID (optional) | `i-0abc123...` | EC2 Console |
|
|
901
|
-
| `
|
|
902
|
-
| `
|
|
903
|
-
| `
|
|
901
|
+
| `EC2_LOOKUP_AWS_ACCESS_KEY_ID` | AWS key for EC2 IP lookup (distinct from S3) | `AKIA...` | IAM → Users → Security credentials |
|
|
902
|
+
| `EC2_LOOKUP_AWS_SECRET_ACCESS_KEY` | AWS secret for EC2 IP lookup | *(secret)* | Same as above |
|
|
903
|
+
| `EC2_LOOKUP_AWS_REGION` | Instance region for DescribeInstances | `us-east-1` | EC2 Console top bar |
|
|
904
904
|
|
|
905
905
|
### Azure VM
|
|
906
906
|
|
|
@@ -926,7 +926,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
926
926
|
1. Azure Portal → VM → Networking → allow SSH (22) from your IP
|
|
927
927
|
2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
|
|
928
928
|
3. Copy `.env.example` → `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
|
|
929
|
-
4. For CI: add `
|
|
929
|
+
4. For CI: add `AZURE_VM_LOOKUP_TENANT_ID`, `AZURE_VM_LOOKUP_CLIENT_ID`, `AZURE_VM_LOOKUP_CLIENT_SECRET` as GitHub Secrets
|
|
930
930
|
5. Run `deployhub doctor`, then `git push origin main`
|
|
931
931
|
|
|
932
932
|
| Variable | Description | Example | Where to get it |
|
|
@@ -934,9 +934,9 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
934
934
|
| `SSH_HOST` | VM public IP | `20.1.2.3` | Azure Portal → VM overview |
|
|
935
935
|
| `SSH_USER` | SSH username | `azureuser` | Chosen at VM creation |
|
|
936
936
|
| `SSH_KEY_PATH` | Private key path | `~/.ssh/azure.pem` | Your key file |
|
|
937
|
-
| `
|
|
938
|
-
| `
|
|
939
|
-
| `
|
|
937
|
+
| `AZURE_VM_LOOKUP_SUBSCRIPTION_ID` | Subscription (optional IP lookup) | `uuid` | `az account show` |
|
|
938
|
+
| `AZURE_VM_LOOKUP_RESOURCE_GROUP` | Resource group | `my-app-rg` | Portal → Resource groups |
|
|
939
|
+
| `AZURE_VM_LOOKUP_VM_NAME` | VM name | `my-vm` | Portal → Virtual machines |
|
|
940
940
|
|
|
941
941
|
### GCP VM
|
|
942
942
|
|
|
@@ -975,10 +975,10 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
975
975
|
| `SSH_APP_NAME` | PM2 process name (backend) | `my-api` | Your choice |
|
|
976
976
|
| `SSH_PORT` | App listen port (backend) | `3000` | Your app config |
|
|
977
977
|
| `SSH_KEY` | Private key contents (CI only) | `-----BEGIN...` | Same key as `SSH_KEY_PATH` |
|
|
978
|
-
| `
|
|
978
|
+
| `GCP_VM_LOOKUP_PROJECT_ID` | Project ID for VM IP lookup (distinct from GCP Storage) | `my-project-123` | `gcloud config get-value project` |
|
|
979
979
|
| `GCP_ZONE` | VM zone (optional) | `us-central1-a` | VM instance details |
|
|
980
980
|
| `GCP_INSTANCE_NAME` | Instance name (optional) | `my-vm` | Compute Engine list |
|
|
981
|
-
| `
|
|
981
|
+
| `GCP_VM_LOOKUP_KEY_FILE` | Service account JSON for VM lookup (distinct from GCP Storage) | `/path/to/key.json` | IAM → Service Accounts → Keys |
|
|
982
982
|
|
|
983
983
|
### Kubernetes
|
|
984
984
|
|
|
@@ -993,7 +993,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
993
993
|
**Init prompts (what `deployhub init` asks for Kubernetes):**
|
|
994
994
|
1. Path to kubeconfig file (e.g. `~/.kube/config`)
|
|
995
995
|
2. Kubernetes context
|
|
996
|
-
3. Namespace (defaults to project name)
|
|
996
|
+
3. Namespace (defaults to project name for the first env; additional envs suggest `{project}-{envName}` to avoid collisions)
|
|
997
997
|
4. Container image name
|
|
998
998
|
5. Registry URL (leave empty for Docker Hub)
|
|
999
999
|
6. Registry username (required to push)
|
|
@@ -1009,6 +1009,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
1009
1009
|
- Cluster connectivity test during `init`
|
|
1010
1010
|
- On deploy: registry login → reuse or build image → push (unique tag unless `DOCKER_IMAGE_TAG` is set) → ensure namespace exists (prompt locally / auto-create in CI) → `kubectl apply` → `kubectl set image` with the full resolved image ref → `kubectl rollout restart` when that ref is unchanged so pods pick up a new digest
|
|
1011
1011
|
|
|
1012
|
+
> **Limitation — interpreted backends (Node / Python / PHP / Rails) + rollback:** Kubernetes rollback forces a rebuild from the restored artifact when the exact `buildId` image is not already local. Those artifacts ship source/`composer.json` (etc.) but **not** installed deps (`vendor/`, `node_modules`, …), so DeployHub **refuses** with a clear error instead of a confusing Docker build failure. Successful rollback needs the restored image tag already present locally (or a prior `pipeline.docker` build that produced it). Same rule as standalone Docker deploy fallback.
|
|
1013
|
+
|
|
1012
1014
|
> **Limitation — multiple Kubernetes clusters:** A generated workflow writes **one** kubeconfig file per job. Multiple Kubernetes environments that target **different clusters** in the same workflow run are not yet fully supported (follow-up). Same-cluster multi-namespace / multi-env is fine.
|
|
1013
1015
|
|
|
1014
1016
|
**After `init`:**
|
|
@@ -1140,7 +1142,20 @@ Run `deployhub doctor` after any config change.
|
|
|
1140
1142
|
| `deployhub clean` | Remove old local artifacts |
|
|
1141
1143
|
| `deployhub update` | Check for CLI updates |
|
|
1142
1144
|
|
|
1143
|
-
**Tests:** `npm test` — currently **
|
|
1145
|
+
**Tests:** `npm test` — currently **442 passing** across the Jest suites (1 skipped).
|
|
1146
|
+
|
|
1147
|
+
## Storage vs deployment lookup credentials
|
|
1148
|
+
|
|
1149
|
+
Storage credentials (`AWS_*` for S3, `GCP_*` for GCP Storage, …) are **project-wide and unprefixed**. Optional cloud-API credentials used only for **dynamic VM IP lookup** on EC2 / Azure VM / GCP VM use a distinct `{METHOD}_LOOKUP_…` namespace so they never collide with storage — even on a single-environment project.
|
|
1150
|
+
|
|
1151
|
+
| Concern | Examples |
|
|
1152
|
+
|---------|----------|
|
|
1153
|
+
| Storage | `AWS_ACCESS_KEY_ID`, `GCP_PROJECT_ID`, `GCP_KEY_FILE` |
|
|
1154
|
+
| EC2 instance-IP lookup | `EC2_LOOKUP_AWS_ACCESS_KEY_ID`, `EC2_LOOKUP_AWS_SECRET_ACCESS_KEY`, `EC2_LOOKUP_AWS_REGION` |
|
|
1155
|
+
| GCP VM IP lookup | `GCP_VM_LOOKUP_PROJECT_ID`, `GCP_VM_LOOKUP_KEY_FILE` |
|
|
1156
|
+
| Azure VM IP lookup | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME`, … |
|
|
1157
|
+
|
|
1158
|
+
Multi-env secret prefixing still applies on top (e.g. `PRODUCTION_EC2_LOOKUP_AWS_ACCESS_KEY_ID`).
|
|
1144
1159
|
|
|
1145
1160
|
## GitHub Secrets
|
|
1146
1161
|
|
|
@@ -1164,7 +1179,7 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
|
|
|
1164
1179
|
| `GCP_KEY_FILE` | GCP Storage |
|
|
1165
1180
|
| `GCP_BUCKET` | GCP Storage |
|
|
1166
1181
|
| `DROPBOX_ACCESS_TOKEN` | Dropbox |
|
|
1167
|
-
| `FTP_HOST`, `FTP_USER`, `FTP_PASSWORD` | FTP storage |
|
|
1182
|
+
| `FTP_HOST`, `FTP_USER`, `FTP_PASSWORD`, `FTP_PORT`, `FTP_PATH` | FTP storage (`FTP_PORT` default 21, `FTP_PATH` default `/uploads` — connection tuning, not secrets) |
|
|
1168
1183
|
|
|
1169
1184
|
### Server deployment (SSH, EC2, VMs, Docker, Kubernetes)
|
|
1170
1185
|
|
|
@@ -1178,9 +1193,9 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
|
|
|
1178
1193
|
| `SSH_DEPLOY_PATH` | Remote directory (optional if set in config) |
|
|
1179
1194
|
| `SSH_APP_NAME` | PM2 process name for backends |
|
|
1180
1195
|
| `SSH_PORT` | App port on server (backend) |
|
|
1181
|
-
| `EC2_INSTANCE_ID`, `
|
|
1182
|
-
| `
|
|
1183
|
-
| `
|
|
1196
|
+
| `EC2_INSTANCE_ID`, `EC2_LOOKUP_AWS_*` | Optional EC2 dynamic IP lookup (distinct from S3 `AWS_*`) |
|
|
1197
|
+
| `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME` | Optional Azure VM IP lookup |
|
|
1198
|
+
| `GCP_VM_LOOKUP_PROJECT_ID`, `GCP_ZONE`, `GCP_INSTANCE_NAME`, `GCP_VM_LOOKUP_KEY_FILE` | Optional GCP VM IP lookup (project/key distinct from GCP Storage) |
|
|
1184
1199
|
| `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
|
|
1185
1200
|
| `KUBECONFIG`, `KUBE_CONTEXT`, `KUBE_NAMESPACE`, `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_IMAGE_TAG`, `KUBE_IMAGE_PULL_SECRET` | Kubernetes — **`KUBECONFIG` in GitHub Secrets must be the kubeconfig file contents (or base64), not a filesystem path**. `DOCKER_IMAGE_TAG`, `KUBE_NAMESPACE`, `DOCKER_REGISTRY_URL`, and `KUBE_IMAGE_PULL_SECRET` are optional |
|
|
1186
1201
|
|
package/package.json
CHANGED
package/src/adapters/index.js
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -241,7 +241,7 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
241
241
|
await copyDirectoryIfExists(cwd, stagingDir, 'resources');
|
|
242
242
|
await copyDirectoryIfExists(cwd, stagingDir, 'storage');
|
|
243
243
|
await copyDirectoryIfExists(cwd, stagingDir, 'bin');
|
|
244
|
-
} else if (framework === 'spring') {
|
|
244
|
+
} else if (framework === 'spring' || framework === 'java') {
|
|
245
245
|
await copyIfExists(cwd, stagingDir, 'pom.xml');
|
|
246
246
|
const targetDir = path.join(cwd, 'target');
|
|
247
247
|
if (await fs.pathExists(targetDir)) {
|
|
@@ -261,7 +261,7 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
261
261
|
await copyIfExists(cwd, stagingDir, f);
|
|
262
262
|
}
|
|
263
263
|
await copyDirectoryIfExists(cwd, stagingDir, settings.buildOutput || 'publish');
|
|
264
|
-
} else if (framework === 'rails') {
|
|
264
|
+
} else if (framework === 'rails' || framework === 'ruby') {
|
|
265
265
|
await copyIfExists(cwd, stagingDir, 'Gemfile');
|
|
266
266
|
await copyIfExists(cwd, stagingDir, 'Gemfile.lock');
|
|
267
267
|
await copyIfExists(cwd, stagingDir, 'config.ru');
|
package/src/commands/doctor.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { loadEnvArtifactHistory } from '../storage/index.js';
|
|
14
14
|
import { testProvider } from '../storage/index.js';
|
|
15
15
|
import { getDeploymentProvider } from '../deployment/index.js';
|
|
16
|
-
import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
|
|
16
|
+
import { PROVIDER_ENV_MAP, STORAGE_PROVIDER_IDS, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
|
|
17
17
|
import { printDoctorFooter } from '../utils/author.js';
|
|
18
18
|
import { createLocalProvider } from '../storage/providers/local.js';
|
|
19
19
|
import {
|
|
@@ -561,9 +561,12 @@ export async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
561
561
|
if (deployType === 'ec2' && process.env.EC2_INSTANCE_ID) {
|
|
562
562
|
checks.push(
|
|
563
563
|
await runCheck('EC2 API', async () => {
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
564
|
+
const required = [
|
|
565
|
+
'EC2_LOOKUP_AWS_ACCESS_KEY_ID',
|
|
566
|
+
'EC2_LOOKUP_AWS_SECRET_ACCESS_KEY',
|
|
567
|
+
'EC2_LOOKUP_AWS_REGION',
|
|
568
|
+
];
|
|
569
|
+
const missing = required.filter((k) => !process.env[k]);
|
|
567
570
|
if (missing.length > 0) {
|
|
568
571
|
return {
|
|
569
572
|
name: 'EC2 API',
|
|
@@ -969,6 +972,7 @@ export function registerDoctorCommand(program) {
|
|
|
969
972
|
/** @type {string[]} */
|
|
970
973
|
const required = [];
|
|
971
974
|
for (const provider of config.storage || []) {
|
|
975
|
+
if (!STORAGE_PROVIDER_IDS.has(provider)) continue;
|
|
972
976
|
const keys = PROVIDER_ENV_MAP[provider] || [];
|
|
973
977
|
required.push(...keys);
|
|
974
978
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -54,15 +54,20 @@ const BACKEND_CHOICES = [
|
|
|
54
54
|
{ name: 'Node.js — NestJS', value: 'nestjs' },
|
|
55
55
|
{ name: 'Node.js — Fastify', value: 'fastify' },
|
|
56
56
|
{ name: 'Node.js — Koa', value: 'koa' },
|
|
57
|
+
{ name: 'Node.js — plain', value: 'node' },
|
|
57
58
|
{ name: 'Python — FastAPI', value: 'fastapi' },
|
|
58
59
|
{ name: 'Python — Django', value: 'django' },
|
|
59
60
|
{ name: 'Python — Flask', value: 'flask' },
|
|
61
|
+
{ name: 'Python — plain', value: 'python' },
|
|
60
62
|
{ name: 'PHP — Laravel', value: 'laravel' },
|
|
61
63
|
{ name: 'PHP — Symfony', value: 'symfony' },
|
|
64
|
+
{ name: 'PHP — plain (Composer / .php)', value: 'php' },
|
|
62
65
|
{ name: 'Java — Spring Boot', value: 'spring' },
|
|
66
|
+
{ name: 'Java — plain (Maven/Gradle)', value: 'java' },
|
|
63
67
|
{ name: 'Go', value: 'go' },
|
|
64
68
|
{ name: '.NET (ASP.NET Core)', value: 'dotnet' },
|
|
65
69
|
{ name: 'Ruby on Rails', value: 'rails' },
|
|
70
|
+
{ name: 'Ruby — plain (Rack/Puma)', value: 'ruby' },
|
|
66
71
|
{ name: "Other (I'll configure manually)", value: 'other' },
|
|
67
72
|
];
|
|
68
73
|
|
|
@@ -169,27 +169,32 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
169
169
|
when: 'optional',
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
|
-
key: '
|
|
172
|
+
key: 'EC2_LOOKUP_AWS_ACCESS_KEY_ID',
|
|
173
173
|
optionalReason:
|
|
174
174
|
'only required if using EC2_INSTANCE_ID for dynamic IP lookup; otherwise leave blank',
|
|
175
175
|
comment: [
|
|
176
|
+
'EC2 instance-IP lookup credential (NOT the same as AWS S3 storage credentials).',
|
|
176
177
|
'AWS access key with ec2:DescribeInstances permission.',
|
|
177
178
|
'Create in AWS Console → IAM → Users → Security credentials.',
|
|
178
179
|
],
|
|
179
180
|
when: 'optional',
|
|
180
181
|
},
|
|
181
182
|
{
|
|
182
|
-
key: '
|
|
183
|
+
key: 'EC2_LOOKUP_AWS_SECRET_ACCESS_KEY',
|
|
183
184
|
optionalReason:
|
|
184
185
|
'only required if using EC2_INSTANCE_ID for dynamic IP lookup; otherwise leave blank',
|
|
185
|
-
comment: [
|
|
186
|
+
comment: [
|
|
187
|
+
'Secret for EC2_LOOKUP_AWS_ACCESS_KEY_ID (distinct from AWS_SECRET_ACCESS_KEY used by S3).',
|
|
188
|
+
],
|
|
186
189
|
when: 'optional',
|
|
187
190
|
},
|
|
188
191
|
{
|
|
189
|
-
key: '
|
|
192
|
+
key: 'EC2_LOOKUP_AWS_REGION',
|
|
190
193
|
optionalReason:
|
|
191
194
|
'only required if using EC2_INSTANCE_ID for dynamic IP lookup; otherwise leave blank',
|
|
192
|
-
comment: [
|
|
195
|
+
comment: [
|
|
196
|
+
'AWS region for EC2 DescribeInstances lookup (distinct from AWS_REGION used by S3).',
|
|
197
|
+
],
|
|
193
198
|
example: 'us-east-1',
|
|
194
199
|
when: 'optional',
|
|
195
200
|
},
|
|
@@ -199,52 +204,60 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
199
204
|
...SSH_BACKEND_ENV_VARS,
|
|
200
205
|
...SSH_CI_ENV_VARS,
|
|
201
206
|
{
|
|
202
|
-
key: '
|
|
207
|
+
key: 'AZURE_VM_LOOKUP_SUBSCRIPTION_ID',
|
|
203
208
|
optionalReason:
|
|
204
209
|
'only required for dynamic VM IP lookup via Azure API; otherwise set SSH_HOST directly',
|
|
205
210
|
comment: [
|
|
206
|
-
'Azure subscription ID
|
|
211
|
+
'Azure VM IP-lookup subscription ID (deployment-side; not Azure Blob storage).',
|
|
207
212
|
'Find in Azure Portal → Subscriptions, or run: az account show --query id -o tsv',
|
|
208
213
|
],
|
|
209
214
|
when: 'optional',
|
|
210
215
|
},
|
|
211
216
|
{
|
|
212
|
-
key: '
|
|
217
|
+
key: 'AZURE_VM_LOOKUP_RESOURCE_GROUP',
|
|
213
218
|
optionalReason:
|
|
214
219
|
'only required for dynamic VM IP lookup via Azure API; otherwise leave blank',
|
|
215
|
-
comment: [
|
|
220
|
+
comment: [
|
|
221
|
+
'Resource group containing your VM (deployment lookup; not Azure Blob storage).',
|
|
222
|
+
],
|
|
216
223
|
example: 'my-app-rg',
|
|
217
224
|
when: 'optional',
|
|
218
225
|
},
|
|
219
226
|
{
|
|
220
|
-
key: '
|
|
227
|
+
key: 'AZURE_VM_LOOKUP_VM_NAME',
|
|
221
228
|
optionalReason:
|
|
222
229
|
'only required for dynamic VM IP lookup via Azure API; otherwise set SSH_HOST directly',
|
|
223
230
|
comment: [
|
|
224
|
-
'Name of the Azure virtual machine.',
|
|
231
|
+
'Name of the Azure virtual machine for IP lookup.',
|
|
225
232
|
'If unset, SSH_HOST must be set to the VM public IP or DNS.',
|
|
226
233
|
],
|
|
227
234
|
when: 'optional',
|
|
228
235
|
},
|
|
229
236
|
{
|
|
230
|
-
key: '
|
|
237
|
+
key: 'AZURE_VM_LOOKUP_TENANT_ID',
|
|
231
238
|
optionalReason:
|
|
232
239
|
'only required for non-interactive CI deploys (GitHub Actions) using a service principal',
|
|
233
|
-
comment: [
|
|
240
|
+
comment: [
|
|
241
|
+
'Azure AD tenant ID for service principal auth in CI (VM deploy lookup).',
|
|
242
|
+
],
|
|
234
243
|
when: 'optional',
|
|
235
244
|
},
|
|
236
245
|
{
|
|
237
|
-
key: '
|
|
246
|
+
key: 'AZURE_VM_LOOKUP_CLIENT_ID',
|
|
238
247
|
optionalReason:
|
|
239
248
|
'only required for non-interactive CI deploys (GitHub Actions) using a service principal',
|
|
240
|
-
comment: [
|
|
249
|
+
comment: [
|
|
250
|
+
'Service principal application (client) ID for CI auth (VM deploy lookup).',
|
|
251
|
+
],
|
|
241
252
|
when: 'optional',
|
|
242
253
|
},
|
|
243
254
|
{
|
|
244
|
-
key: '
|
|
255
|
+
key: 'AZURE_VM_LOOKUP_CLIENT_SECRET',
|
|
245
256
|
optionalReason:
|
|
246
257
|
'only required for non-interactive CI deploys (GitHub Actions) using a service principal',
|
|
247
|
-
comment: [
|
|
258
|
+
comment: [
|
|
259
|
+
'Service principal client secret for CI auth (VM deploy lookup).',
|
|
260
|
+
],
|
|
248
261
|
when: 'optional',
|
|
249
262
|
},
|
|
250
263
|
],
|
|
@@ -253,11 +266,11 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
253
266
|
...SSH_BACKEND_ENV_VARS,
|
|
254
267
|
...SSH_CI_ENV_VARS,
|
|
255
268
|
{
|
|
256
|
-
key: '
|
|
269
|
+
key: 'GCP_VM_LOOKUP_PROJECT_ID',
|
|
257
270
|
optionalReason:
|
|
258
271
|
'only required for dynamic VM IP lookup via GCP API; otherwise set SSH_HOST directly',
|
|
259
272
|
comment: [
|
|
260
|
-
'GCP project ID
|
|
273
|
+
'GCP project ID for Compute Engine IP lookup (NOT the same as GCP Storage GCP_PROJECT_ID).',
|
|
261
274
|
'Find in GCP Console → Dashboard, or run: gcloud config get-value project',
|
|
262
275
|
],
|
|
263
276
|
when: 'optional',
|
|
@@ -282,11 +295,11 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
282
295
|
when: 'optional',
|
|
283
296
|
},
|
|
284
297
|
{
|
|
285
|
-
key: '
|
|
298
|
+
key: 'GCP_VM_LOOKUP_KEY_FILE',
|
|
286
299
|
optionalReason:
|
|
287
300
|
'only required for dynamic VM IP lookup via GCP API or non-interactive CI auth; otherwise leave blank',
|
|
288
301
|
comment: [
|
|
289
|
-
'Path to a GCP service account JSON key
|
|
302
|
+
'Path to a GCP service account JSON key for VM IP lookup (distinct from GCP Storage GCP_KEY_FILE).',
|
|
290
303
|
'Create in GCP Console → IAM → Service Accounts → Keys.',
|
|
291
304
|
],
|
|
292
305
|
when: 'optional',
|
|
@@ -381,6 +394,27 @@ export const DEPLOYMENT_ENV_KEYS = Object.fromEntries(
|
|
|
381
394
|
])
|
|
382
395
|
);
|
|
383
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Deployment-side cloud-API lookup credentials — distinct from storage-provider
|
|
399
|
+
* env vars (storage stays project-wide / unprefixed).
|
|
400
|
+
*
|
|
401
|
+
* Convention: `{METHOD}_LOOKUP_…` — method-scoped, purpose-clear, layers under
|
|
402
|
+
* the existing multi-env prefixing system (e.g. PRODUCTION_EC2_LOOKUP_AWS_ACCESS_KEY_ID).
|
|
403
|
+
*/
|
|
404
|
+
export const DEPLOYMENT_LOOKUP_ENV_KEYS = new Set([
|
|
405
|
+
'EC2_LOOKUP_AWS_ACCESS_KEY_ID',
|
|
406
|
+
'EC2_LOOKUP_AWS_SECRET_ACCESS_KEY',
|
|
407
|
+
'EC2_LOOKUP_AWS_REGION',
|
|
408
|
+
'AZURE_VM_LOOKUP_SUBSCRIPTION_ID',
|
|
409
|
+
'AZURE_VM_LOOKUP_RESOURCE_GROUP',
|
|
410
|
+
'AZURE_VM_LOOKUP_VM_NAME',
|
|
411
|
+
'AZURE_VM_LOOKUP_TENANT_ID',
|
|
412
|
+
'AZURE_VM_LOOKUP_CLIENT_ID',
|
|
413
|
+
'AZURE_VM_LOOKUP_CLIENT_SECRET',
|
|
414
|
+
'GCP_VM_LOOKUP_PROJECT_ID',
|
|
415
|
+
'GCP_VM_LOOKUP_KEY_FILE',
|
|
416
|
+
]);
|
|
417
|
+
|
|
384
418
|
/**
|
|
385
419
|
* Locally required env keys for doctor method-specific checks.
|
|
386
420
|
* Excludes optional and CI-only vars.
|
|
@@ -482,13 +516,25 @@ export function getDeploymentSecretChecklistItems(deployType, config = null) {
|
|
|
482
516
|
|
|
483
517
|
const key = toGithubSecretKey(d.key);
|
|
484
518
|
const required = d.when !== 'optional';
|
|
485
|
-
|
|
519
|
+
let note =
|
|
486
520
|
d.when === 'optional'
|
|
487
521
|
? d.optionalReason
|
|
488
522
|
: d.when === 'ci'
|
|
489
523
|
? d.optionalReason || 'required for GitHub Actions CI (paste private key contents)'
|
|
490
524
|
: undefined;
|
|
491
525
|
|
|
526
|
+
if (DEPLOYMENT_LOOKUP_ENV_KEYS.has(d.key)) {
|
|
527
|
+
const purpose =
|
|
528
|
+
deployType === 'ec2'
|
|
529
|
+
? 'EC2 instance-IP lookup credential (distinct from AWS S3 storage)'
|
|
530
|
+
: deployType === 'gcp-vm'
|
|
531
|
+
? 'GCP VM instance-IP lookup credential (distinct from GCP Storage)'
|
|
532
|
+
: deployType === 'azure-vm'
|
|
533
|
+
? 'Azure VM IP-lookup credential (distinct from Azure Blob storage)'
|
|
534
|
+
: 'deployment cloud-API lookup credential';
|
|
535
|
+
note = note ? `${purpose}; ${note}` : purpose;
|
|
536
|
+
}
|
|
537
|
+
|
|
492
538
|
const existing = byKey.get(key);
|
|
493
539
|
if (existing) {
|
|
494
540
|
// Prefer required if any def for this key is required
|
|
@@ -731,21 +777,48 @@ export function formatSecretChecklistLine(item) {
|
|
|
731
777
|
* @param {string} deployType
|
|
732
778
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
733
779
|
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
780
|
+
* @param {{ envName?: string }} [options]
|
|
734
781
|
* @returns {string}
|
|
735
782
|
*/
|
|
736
783
|
export function generateDeploymentEnvSection(
|
|
737
784
|
deployType,
|
|
738
785
|
config = null,
|
|
739
|
-
environments = {}
|
|
786
|
+
environments = {},
|
|
787
|
+
options = {}
|
|
740
788
|
) {
|
|
741
789
|
const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
|
|
742
790
|
const projectType = config?.projectType || 'frontend';
|
|
743
791
|
const isBackend = projectType === 'backend' || projectType === 'both';
|
|
792
|
+
const envName = options.envName;
|
|
793
|
+
const cfg = {
|
|
794
|
+
...(config || {}),
|
|
795
|
+
environments: environments || config?.environments || {},
|
|
796
|
+
};
|
|
797
|
+
const shouldPrefix =
|
|
798
|
+
!!envName && envUsesPrefixedSecrets(envName, /** @type {any} */ (cfg));
|
|
744
799
|
|
|
745
|
-
const
|
|
800
|
+
const baseTitle = DEPLOYMENT_SECTION_TITLES[deployType] || deployType;
|
|
801
|
+
const title =
|
|
802
|
+
envName && Object.keys(environments || {}).length > 1
|
|
803
|
+
? `${baseTitle} (${envName})`
|
|
804
|
+
: baseTitle;
|
|
746
805
|
/** @type {string[]} */
|
|
747
806
|
const lines = [`# ${title}`];
|
|
748
807
|
|
|
808
|
+
// Prefer defaults from this environment's config when generating per-env sections.
|
|
809
|
+
const envEntry = envName
|
|
810
|
+
? /** @type {Record<string, unknown>} */ (
|
|
811
|
+
(environments || {})[envName] || {}
|
|
812
|
+
)
|
|
813
|
+
: /** @type {Record<string, unknown>} */ (
|
|
814
|
+
Object.values(environments || {})[0] || {}
|
|
815
|
+
);
|
|
816
|
+
// Support both new { config: {...} } shape and flat legacy env entries.
|
|
817
|
+
const settings =
|
|
818
|
+
envEntry && typeof envEntry.config === 'object' && envEntry.config
|
|
819
|
+
? /** @type {Record<string, unknown>} */ (envEntry.config)
|
|
820
|
+
: envEntry;
|
|
821
|
+
|
|
749
822
|
for (const d of defs) {
|
|
750
823
|
if (d.when === 'backend' && !isBackend) continue;
|
|
751
824
|
|
|
@@ -762,8 +835,10 @@ export function generateDeploymentEnvSection(
|
|
|
762
835
|
lines.push(`# Example: ${d.example}`);
|
|
763
836
|
}
|
|
764
837
|
|
|
765
|
-
const
|
|
766
|
-
|
|
838
|
+
const key = shouldPrefix ? prefixSecretKey(envName, d.key) : d.key;
|
|
839
|
+
const defaultVal =
|
|
840
|
+
d.default || getDefaultFromConfig(d.key, config, settings);
|
|
841
|
+
lines.push(defaultVal ? `${key}=${defaultVal}` : `${key}=`);
|
|
767
842
|
lines.push('');
|
|
768
843
|
}
|
|
769
844
|
|
|
@@ -774,23 +849,21 @@ export function generateDeploymentEnvSection(
|
|
|
774
849
|
/**
|
|
775
850
|
* @param {string} key
|
|
776
851
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
777
|
-
* @param {Record<string,
|
|
852
|
+
* @param {Record<string, unknown>} [settings]
|
|
778
853
|
*/
|
|
779
|
-
function getDefaultFromConfig(key, config,
|
|
780
|
-
const envEntry = Object.values(environments || {})[0] || {};
|
|
781
|
-
|
|
854
|
+
function getDefaultFromConfig(key, config, settings = {}) {
|
|
782
855
|
const map = {
|
|
783
|
-
SSH_HOST:
|
|
784
|
-
SSH_USER:
|
|
785
|
-
SSH_DEPLOY_PATH:
|
|
786
|
-
SSH_APP_NAME:
|
|
856
|
+
SSH_HOST: settings.host,
|
|
857
|
+
SSH_USER: settings.user,
|
|
858
|
+
SSH_DEPLOY_PATH: settings.deployPath || settings.path,
|
|
859
|
+
SSH_APP_NAME: settings.appName,
|
|
787
860
|
SSH_PORT: config?.port || config?.backend?.port,
|
|
788
861
|
SSH_SSH_PORT: '22',
|
|
789
|
-
KUBE_NAMESPACE: config?.project || 'default',
|
|
790
|
-
DOCKER_IMAGE_NAME: config?.project,
|
|
862
|
+
KUBE_NAMESPACE: settings.kubeNamespace || config?.project || 'default',
|
|
863
|
+
DOCKER_IMAGE_NAME: settings.dockerImageName || config?.project,
|
|
791
864
|
DOCKER_IMAGE_TAG: '',
|
|
792
|
-
DOCKER_REGISTRY_URL:
|
|
793
|
-
|
|
865
|
+
DOCKER_REGISTRY_URL: settings.dockerRegistryUrl,
|
|
866
|
+
EC2_LOOKUP_AWS_REGION: settings.awsRegion || 'us-east-1',
|
|
794
867
|
};
|
|
795
868
|
|
|
796
869
|
const val = map[key];
|
|
@@ -871,8 +944,8 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
871
944
|
after: [
|
|
872
945
|
'AWS Console → EC2 → Security Groups → your instance group → Inbound rules →',
|
|
873
946
|
' Add rule: SSH, port 22, source: My IP',
|
|
874
|
-
'Copy .env.example to .env — set SSH_KEY_PATH, SSH_HOST (or EC2_INSTANCE_ID +
|
|
875
|
-
'Add GitHub Secrets: SSH_HOST, SSH_USER, SSH_KEY, plus
|
|
947
|
+
'Copy .env.example to .env — set SSH_KEY_PATH, SSH_HOST (or EC2_INSTANCE_ID + EC2_LOOKUP_AWS_*).',
|
|
948
|
+
'Add GitHub Secrets: SSH_HOST, SSH_USER, SSH_KEY, plus EC2_LOOKUP_AWS_* if using instance ID lookup (distinct from S3 AWS_*).',
|
|
876
949
|
'Run deployhub doctor to verify SSH and optional AWS API access.',
|
|
877
950
|
'git push origin main to trigger your first deployment.',
|
|
878
951
|
],
|
|
@@ -893,7 +966,7 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
893
966
|
'Azure Portal → VM → Networking → Inbound port rules → allow SSH (22) from your IP.',
|
|
894
967
|
'Or: az network nsg rule create --name AllowSSH --priority 1000 --source-address-prefix YOUR_IP ...',
|
|
895
968
|
'Copy .env.example to .env and fill in SSH_HOST, SSH_USER, SSH_KEY_PATH.',
|
|
896
|
-
'For CI: add
|
|
969
|
+
'For CI: add AZURE_VM_LOOKUP_TENANT_ID, AZURE_VM_LOOKUP_CLIENT_ID, AZURE_VM_LOOKUP_CLIENT_SECRET as GitHub Secrets.',
|
|
897
970
|
'Run deployhub doctor, then git push origin main.',
|
|
898
971
|
],
|
|
899
972
|
},
|
|
@@ -940,6 +1013,7 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
940
1013
|
'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
|
|
941
1014
|
'Skipping registry credentials will very likely cause ImagePullBackOff — the cluster cannot see local Docker images.',
|
|
942
1015
|
'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
|
|
1016
|
+
'Interpreted backends (Node/Python/PHP/Rails): rollback refuses to rebuild from the artifact when the buildId image is not local — keep pipeline.docker builds so the restored tag exists.',
|
|
943
1017
|
'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions).',
|
|
944
1018
|
'Run deployhub doctor to verify cluster access and that your image is pullable.',
|
|
945
1019
|
'git push origin main to trigger your first deployment.',
|
|
@@ -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',
|