@akash-chowdhury-24/deployhub 2.0.3 → 2.0.5
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 +50 -4
- package/package.json +1 -1
- package/src/artifact/engine.js +13 -0
- package/src/commands/doctor.js +154 -0
- package/src/commands/init.js +28 -2
- package/src/core/stages.js +13 -0
- package/src/deployment/deployment-env.js +9 -0
- package/src/deployment/providers/kubernetes.js +6 -4
- package/src/deployment/providers/ssh.js +75 -10
- package/src/utils/dockerfile.js +486 -0
- package/src/utils/github-actions.js +56 -1
- package/src/utils/kubernetes-manifests.js +168 -0
- package/src/utils/nginx.js +60 -3
- package/src/utils/scaffold.js +192 -0
package/README.md
CHANGED
|
@@ -185,6 +185,8 @@ The wizard asks the same core questions for every setup:
|
|
|
185
185
|
- `.github/workflows/deployhub.yml` — CI pipeline
|
|
186
186
|
- `.env.example` — list of env vars you may need
|
|
187
187
|
- `nginx.conf` — auto-generated if frontend deploys to SSH
|
|
188
|
+
- `Dockerfile` — auto-generated if missing and you chose Docker or Kubernetes deploy (your existing `Dockerfile` is never overwritten)
|
|
189
|
+
- `k8s/deployment.yaml` and `k8s/service.yaml` — auto-generated if missing and you chose Kubernetes deploy (existing manifests are never overwritten)
|
|
188
190
|
|
|
189
191
|
---
|
|
190
192
|
|
|
@@ -596,7 +598,7 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
|
|
|
596
598
|
| **ec2** | AWS users with an existing EC2 instance | Running EC2 instance, security group, key pair |
|
|
597
599
|
| **azure-vm** | Azure users with an existing virtual machine | Running Azure VM, NSG allowing SSH |
|
|
598
600
|
| **gcp-vm** | GCP users with an existing Compute Engine VM | Running VM, firewall rule for SSH, metadata SSH key |
|
|
599
|
-
| **kubernetes** | Teams with an existing K8s cluster | Cluster, kubectl access
|
|
601
|
+
| **kubernetes** | Teams with an existing K8s cluster | Cluster, kubectl access; manifests auto-generated if missing |
|
|
600
602
|
|
|
601
603
|
---
|
|
602
604
|
|
|
@@ -604,9 +606,38 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
|
|
|
604
606
|
|
|
605
607
|
Each method below follows the same structure: **prerequisites** (before `deployhub init`), **what DeployHub automates**, **after init** (matches terminal output), and a **variable reference**.
|
|
606
608
|
|
|
609
|
+
### One-time server setup (before your first deploy)
|
|
610
|
+
|
|
611
|
+
SSH-based methods (SSH, EC2, Azure VM, GCP VM) require a few one-time steps on the server **before your first deploy**. DeployHub does not silently change ownership or sudo policy for you.
|
|
612
|
+
|
|
613
|
+
SSH into your server once and run:
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
sudo mkdir -p /var/www/your-app-name
|
|
617
|
+
sudo chown your-ssh-user:your-ssh-user /var/www/your-app-name
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Replace `/var/www/your-app-name` with your actual deploy path and `your-ssh-user` with your configured `SSH_USER` (e.g. `ec2-user` on Amazon Linux, `ubuntu` on Ubuntu). Without this, DeployHub cannot write your build output — `deployhub doctor` will catch it and show the exact fix.
|
|
621
|
+
|
|
622
|
+
**Frontend deploys** that auto-activate `nginx.conf` also need **passwordless sudo** for Nginx test/reload (and `cp` into `/etc/nginx/`). After installing Nginx, run `sudo visudo` and add a line like:
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
your-ssh-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /bin/cp, /usr/bin/cp, /bin/systemctl, /usr/bin/systemctl
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
> **Security note:** This example grants broad privileges — unrestricted `cp` (any source/destination) and `systemctl` (any unit/action), not just Nginx. That keeps setup simple but is a significant trust boundary. For production, prefer a dedicated deploy user and a **narrow wrapper script** (e.g. `/usr/local/bin/deployhub-nginx-reload` that only copies to your project's config path and runs `nginx -t` + reload), then grant `NOPASSWD` only for that script. The line above is a starting point for dev/test servers; tighten it before production.
|
|
629
|
+
|
|
630
|
+
Install Nginx if it is not already present:
|
|
631
|
+
|
|
632
|
+
- **Amazon Linux / RHEL:** `sudo yum install -y nginx && sudo systemctl enable --now nginx`
|
|
633
|
+
- **Ubuntu / Debian:** `sudo apt install -y nginx`
|
|
634
|
+
|
|
635
|
+
DeployHub detects whether the server uses Debian-style `sites-available` or RHEL-style `conf.d` at deploy time and writes a **uniquely named** config file for your project only — it does not overwrite unrelated Nginx configs.
|
|
636
|
+
|
|
607
637
|
### SSH
|
|
608
638
|
|
|
609
639
|
**Prerequisites (before `deployhub init`):**
|
|
640
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (deploy path ownership + Nginx/sudo for frontends)
|
|
610
641
|
- [ ] A Linux server with SSH enabled
|
|
611
642
|
- [ ] Private SSH key file (.pem/.key) and public key in `authorized_keys`
|
|
612
643
|
- [ ] Port 22 open in firewall for your IP
|
|
@@ -618,6 +649,9 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
618
649
|
- SSH key permission check (offers to `chmod 600`)
|
|
619
650
|
- SSH connectivity test during `init`
|
|
620
651
|
- Deploy path write-permission check during `deployhub doctor`
|
|
652
|
+
- Nginx layout detection (Debian `sites-available` vs RHEL `conf.d`) at deploy time
|
|
653
|
+
- Nginx config test (`nginx -t`) before reload
|
|
654
|
+
- Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
|
|
621
655
|
- Artifact upload, extract, app restart (PM2, gunicorn, etc.)
|
|
622
656
|
|
|
623
657
|
**After `init`:**
|
|
@@ -643,10 +677,11 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
643
677
|
|
|
644
678
|
**Prerequisites:**
|
|
645
679
|
- [ ] Docker installed (`docker --version` works)
|
|
646
|
-
- [ ] `Dockerfile` or `docker-compose.yml` in project
|
|
647
680
|
- [ ] Registry account if pushing private images
|
|
681
|
+
- [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
|
|
648
682
|
|
|
649
683
|
**What DeployHub automates:**
|
|
684
|
+
- Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
|
|
650
685
|
- `.env.example` for image name, registry, remote `DOCKER_HOST`
|
|
651
686
|
- Docker daemon connectivity test during `init`
|
|
652
687
|
- `docker compose up` or build/push/run during deploy
|
|
@@ -669,6 +704,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
669
704
|
### AWS EC2
|
|
670
705
|
|
|
671
706
|
**Prerequisites:**
|
|
707
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
|
|
672
708
|
- [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
|
|
673
709
|
- [ ] Key pair `.pem` downloaded at launch
|
|
674
710
|
- [ ] Security group: inbound SSH (22) from your IP
|
|
@@ -679,6 +715,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
679
715
|
- EC2-specific `.env.example` (SSH + optional AWS API vars)
|
|
680
716
|
- SSH key validation and connectivity test
|
|
681
717
|
- Deploy path write-permission check during `deployhub doctor`
|
|
718
|
+
- Nginx layout detection and config test before reload (frontend)
|
|
719
|
+
- Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
|
|
682
720
|
- OS user suggestion from AMI hint (ubuntu, ec2-user)
|
|
683
721
|
- Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
|
|
684
722
|
|
|
@@ -702,6 +740,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
702
740
|
### Azure VM
|
|
703
741
|
|
|
704
742
|
**Prerequisites:**
|
|
743
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
|
|
705
744
|
- [ ] Azure VM created in Portal (DeployHub does not provision it)
|
|
706
745
|
- [ ] NSG rule allowing inbound SSH (port 22)
|
|
707
746
|
- [ ] SSH public key on the VM
|
|
@@ -713,6 +752,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
713
752
|
- Auto-detects subscription ID via `az` CLI if logged in
|
|
714
753
|
- SSH key validation and connectivity test
|
|
715
754
|
- Deploy path write-permission check during `deployhub doctor`
|
|
755
|
+
- Nginx layout detection and config test before reload (frontend)
|
|
756
|
+
- Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
|
|
716
757
|
|
|
717
758
|
**After `init`:**
|
|
718
759
|
1. Azure Portal → VM → Networking → allow SSH (22) from your IP
|
|
@@ -733,6 +774,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
733
774
|
### GCP VM
|
|
734
775
|
|
|
735
776
|
**Prerequisites:**
|
|
777
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
|
|
736
778
|
- [ ] Compute Engine VM created (DeployHub does not create it)
|
|
737
779
|
- [ ] Firewall rule allowing `tcp:22` (default `default-allow-ssh` may exist)
|
|
738
780
|
- [ ] SSH public key in **Metadata → SSH Keys** (GCP uses metadata keys, not launch key pairs like AWS)
|
|
@@ -744,6 +786,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
744
786
|
- Auto-detects project ID via `gcloud` if authenticated
|
|
745
787
|
- SSH key validation and connectivity test
|
|
746
788
|
- Deploy path write-permission check during `deployhub doctor`
|
|
789
|
+
- Nginx layout detection and config test before reload (frontend)
|
|
790
|
+
- Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
|
|
747
791
|
|
|
748
792
|
**After `init`:**
|
|
749
793
|
1. GCP Console → VPC → Firewall → ensure SSH (tcp:22) allowed from your IP
|
|
@@ -771,11 +815,12 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
771
815
|
|
|
772
816
|
**Prerequisites:**
|
|
773
817
|
- [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
|
|
774
|
-
- [ ] `kubectl` installed and configured
|
|
775
|
-
- [ ] Kubernetes manifests (`.yaml` or `k8s/` directory) in your repo
|
|
818
|
+
- [ ] `kubectl` installed and configured on your **local machine** (for `deployhub doctor` / manual `deployhub deploy`)
|
|
776
819
|
- [ ] Cluster reachable from CI (kubeconfig secret or cloud auth)
|
|
777
820
|
|
|
778
821
|
**What DeployHub automates:**
|
|
822
|
+
- Starter `k8s/deployment.yaml` and `k8s/service.yaml` when no manifests exist (skipped if you already have a `k8s/` directory or root-level Kubernetes YAML files)
|
|
823
|
+
- GitHub Actions installs `kubectl` on the CI runner and writes kubeconfig from secrets (no local `kubectl` required for the automated push-to-main deploy path)
|
|
779
824
|
- Lists `kubectl` contexts during `init` for easy selection
|
|
780
825
|
- Auto-detects `~/.kube/config`
|
|
781
826
|
- Complete `.env.example` for kubeconfig, context, namespace
|
|
@@ -794,6 +839,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
794
839
|
| `KUBE_CONTEXT` | Context name | `my-cluster` | `kubectl config get-contexts` |
|
|
795
840
|
| `KUBE_NAMESPACE` | Target namespace | `my-app` | `kubectl get namespaces` |
|
|
796
841
|
| `DOCKER_IMAGE_NAME` | Container image | `ghcr.io/org/app` | Your registry |
|
|
842
|
+
| `DOCKER_IMAGE_TAG` | Image tag | `1.0.0` or `latest` | Project version or your choice |
|
|
797
843
|
| `KUBE_IMAGE_PULL_SECRET` | Pull secret name | `regcred` | `kubectl create secret docker-registry` |
|
|
798
844
|
|
|
799
845
|
---
|
package/package.json
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -6,6 +6,11 @@ import { createLogger } from '../logger/index.js';
|
|
|
6
6
|
import { generateChecksums, formatChecksums } from '../utils/checksums.js';
|
|
7
7
|
import { getProjectVersion } from '../utils/version.js';
|
|
8
8
|
import { generateNginxConfig } from '../utils/nginx.js';
|
|
9
|
+
import {
|
|
10
|
+
ensureDeployScaffold,
|
|
11
|
+
copyKubernetesManifestsIfPresent,
|
|
12
|
+
copyDeployAssetsToArtifactDir,
|
|
13
|
+
} from '../utils/scaffold.js';
|
|
9
14
|
import { getGeneratedByMetadata, getArtifactReadmeFooter } from '../utils/author.js';
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -143,6 +148,8 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
|
|
|
143
148
|
await copyIfExists(cwd, stagingDir, file);
|
|
144
149
|
}
|
|
145
150
|
|
|
151
|
+
await copyKubernetesManifestsIfPresent(cwd, stagingDir);
|
|
152
|
+
|
|
146
153
|
const hasSshDeploy = (config.deploy || []).some(
|
|
147
154
|
(envName) => config.environments[envName]?.type === 'ssh'
|
|
148
155
|
);
|
|
@@ -172,6 +179,8 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
172
179
|
await copyIfExists(cwd, stagingDir, file);
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
await copyKubernetesManifestsIfPresent(cwd, stagingDir);
|
|
183
|
+
|
|
175
184
|
await copyDirectoryIfExists(cwd, stagingDir, 'config');
|
|
176
185
|
await copyDirectoryIfExists(cwd, stagingDir, 'migrations');
|
|
177
186
|
|
|
@@ -259,6 +268,8 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
259
268
|
|
|
260
269
|
log.info(`Staging ${projectType} artifact...`);
|
|
261
270
|
|
|
271
|
+
await ensureDeployScaffold(cwd, config, config.environments || {}, { silent: false });
|
|
272
|
+
|
|
262
273
|
if (projectType === 'both') {
|
|
263
274
|
await stageFrontendArtifact(cwd, stagingDir, config);
|
|
264
275
|
const backendStaging = path.join(stagingDir, 'backend');
|
|
@@ -337,6 +348,8 @@ ${getArtifactReadmeFooter()}`;
|
|
|
337
348
|
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
338
349
|
await createZip(stagingDir, zipPath);
|
|
339
350
|
|
|
351
|
+
await copyDeployAssetsToArtifactDir(stagingDir, artifactDir);
|
|
352
|
+
|
|
340
353
|
const checksums = await generateChecksums(stagingDir);
|
|
341
354
|
const checksumContent = formatChecksums(checksums);
|
|
342
355
|
await fs.writeFile(path.join(artifactDir, 'checksums.txt'), checksumContent);
|
package/src/commands/doctor.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
buildDeployPathWriteTestCommand,
|
|
19
19
|
formatDeployPathWriteFailure,
|
|
20
20
|
} from '../utils/shell-quote.js';
|
|
21
|
+
import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -82,6 +83,16 @@ function resolveSshDeployPaths(config, envConfig) {
|
|
|
82
83
|
return [...new Set(paths.filter(Boolean))];
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
function needsNginxActivationForDeploy(config) {
|
|
91
|
+
if (config.projectType === 'backend') return false;
|
|
92
|
+
if (config.projectType === 'frontend' && config.framework === 'nextjs') return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
/**
|
|
86
97
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
87
98
|
* @param {string} envName
|
|
@@ -207,6 +218,149 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
207
218
|
);
|
|
208
219
|
}
|
|
209
220
|
|
|
221
|
+
if (needsNginxActivationForDeploy(config)) {
|
|
222
|
+
checks.push(
|
|
223
|
+
await runCheck('Nginx installed', async () => {
|
|
224
|
+
if (!host || !user) {
|
|
225
|
+
return {
|
|
226
|
+
name: 'Nginx installed',
|
|
227
|
+
pass: false,
|
|
228
|
+
message: 'SSH_HOST and SSH_USER are required to check Nginx on the server.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (!keyPath && !process.env.SSH_KEY) {
|
|
232
|
+
return {
|
|
233
|
+
name: 'Nginx installed',
|
|
234
|
+
pass: false,
|
|
235
|
+
message: 'SSH_KEY_PATH or SSH_KEY is required to check Nginx on the server.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const provider = getDeploymentProvider(deployType, config, envName);
|
|
240
|
+
if (!provider.runRemoteCheck) {
|
|
241
|
+
return {
|
|
242
|
+
name: 'Nginx installed',
|
|
243
|
+
pass: false,
|
|
244
|
+
message: 'Deploy provider does not support remote checks.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const result = await provider.runRemoteCheck('command -v nginx >/dev/null 2>&1 && echo yes');
|
|
249
|
+
if (result.pass && result.message.includes('yes')) {
|
|
250
|
+
return { name: 'Nginx installed', pass: true, message: 'Nginx installed on server' };
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
name: 'Nginx installed',
|
|
254
|
+
pass: false,
|
|
255
|
+
message:
|
|
256
|
+
'Nginx not found on server — install it first (e.g. sudo yum install nginx on Amazon Linux, or sudo apt install nginx on Ubuntu).',
|
|
257
|
+
};
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
checks.push(
|
|
262
|
+
await runCheck('Passwordless sudo', async () => {
|
|
263
|
+
if (!host || !user) {
|
|
264
|
+
return {
|
|
265
|
+
name: 'Passwordless sudo',
|
|
266
|
+
pass: false,
|
|
267
|
+
message: 'SSH_HOST and SSH_USER are required for sudo check.',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (!keyPath && !process.env.SSH_KEY) {
|
|
271
|
+
return {
|
|
272
|
+
name: 'Passwordless sudo',
|
|
273
|
+
pass: false,
|
|
274
|
+
message: 'SSH_KEY_PATH or SSH_KEY is required for sudo check.',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const provider = getDeploymentProvider(deployType, config, envName);
|
|
279
|
+
if (!provider.runRemoteCheck) {
|
|
280
|
+
return {
|
|
281
|
+
name: 'Passwordless sudo',
|
|
282
|
+
pass: false,
|
|
283
|
+
message: 'Deploy provider does not support remote checks.',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const result = await provider.runRemoteCheck('sudo -n true');
|
|
288
|
+
if (result.pass) {
|
|
289
|
+
return {
|
|
290
|
+
name: 'Passwordless sudo',
|
|
291
|
+
pass: true,
|
|
292
|
+
message: 'Non-interactive sudo available (required for Nginx config activation)',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
name: 'Passwordless sudo',
|
|
297
|
+
pass: false,
|
|
298
|
+
message: formatPasswordlessSudoGuidance(sshUser),
|
|
299
|
+
};
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
checks.push(
|
|
304
|
+
await runCheck('Nginx sudo access', async () => {
|
|
305
|
+
if (!host || !user) {
|
|
306
|
+
return {
|
|
307
|
+
name: 'Nginx sudo access',
|
|
308
|
+
pass: false,
|
|
309
|
+
message: 'SSH_HOST and SSH_USER are required for Nginx sudo check.',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!keyPath && !process.env.SSH_KEY) {
|
|
313
|
+
return {
|
|
314
|
+
name: 'Nginx sudo access',
|
|
315
|
+
pass: false,
|
|
316
|
+
message: 'SSH_KEY_PATH or SSH_KEY is required for Nginx sudo check.',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const provider = getDeploymentProvider(deployType, config, envName);
|
|
321
|
+
if (!provider.runRemoteCheck) {
|
|
322
|
+
return {
|
|
323
|
+
name: 'Nginx sudo access',
|
|
324
|
+
pass: false,
|
|
325
|
+
message: 'Deploy provider does not support remote checks.',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const nginxInstalled = await provider.runRemoteCheck('command -v nginx >/dev/null 2>&1 && echo yes');
|
|
330
|
+
if (!nginxInstalled.pass || !nginxInstalled.message.includes('yes')) {
|
|
331
|
+
return {
|
|
332
|
+
name: 'Nginx sudo access',
|
|
333
|
+
pass: false,
|
|
334
|
+
message: 'Skipped — install Nginx first.',
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const sudoOk = await provider.runRemoteCheck('sudo -n true');
|
|
339
|
+
if (!sudoOk.pass) {
|
|
340
|
+
return {
|
|
341
|
+
name: 'Nginx sudo access',
|
|
342
|
+
pass: false,
|
|
343
|
+
message: 'Skipped — configure passwordless sudo first.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = await provider.runRemoteCheck('sudo -n nginx -t 2>&1');
|
|
348
|
+
if (result.pass) {
|
|
349
|
+
return {
|
|
350
|
+
name: 'Nginx sudo access',
|
|
351
|
+
pass: true,
|
|
352
|
+
message: 'sudo nginx -t OK (can test config before reload)',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
name: 'Nginx sudo access',
|
|
357
|
+
pass: false,
|
|
358
|
+
message: `sudo nginx -t failed — ${result.message}. Check Nginx install and sudoers (see README one-time server setup).`,
|
|
359
|
+
};
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
210
364
|
const isBackend = config.projectType === 'backend' || config.projectType === 'both';
|
|
211
365
|
if (isBackend) {
|
|
212
366
|
const backendChecks = await runBackendProcessChecks(config, envName, deployType);
|
package/src/commands/init.js
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
} from '../utils/github-actions.js';
|
|
21
21
|
import { printAuthorFooter } from '../utils/author.js';
|
|
22
22
|
import { generateNginxConfig } from '../utils/nginx.js';
|
|
23
|
+
import {
|
|
24
|
+
ensureDockerfile,
|
|
25
|
+
ensureKubernetesManifests,
|
|
26
|
+
} from '../utils/scaffold.js';
|
|
23
27
|
import {
|
|
24
28
|
promptServerDeployment,
|
|
25
29
|
buildServerEnvEntry,
|
|
@@ -190,6 +194,21 @@ async function generateProjectScaffold(config, environments, cwd) {
|
|
|
190
194
|
await fs.writeFile(path.join(cwd, 'nginx.conf'), nginxConf);
|
|
191
195
|
console.log(chalk.gray(' • nginx.conf (auto-generated)'));
|
|
192
196
|
}
|
|
197
|
+
|
|
198
|
+
const dockerResult = await ensureDockerfile(cwd, config);
|
|
199
|
+
if (dockerResult.generated) {
|
|
200
|
+
console.log(chalk.gray(' • Dockerfile (auto-generated)'));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const k8sResult = await ensureKubernetesManifests(cwd, config, environments);
|
|
204
|
+
if (k8sResult.generated) {
|
|
205
|
+
console.log(chalk.gray(' • k8s/deployment.yaml, k8s/service.yaml (auto-generated)'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
dockerfileGenerated: dockerResult.generated,
|
|
210
|
+
kubernetesGenerated: k8sResult.generated,
|
|
211
|
+
};
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
/**
|
|
@@ -397,7 +416,7 @@ export function registerInitCommand(program) {
|
|
|
397
416
|
}
|
|
398
417
|
|
|
399
418
|
const version = await getProjectVersion(cwd);
|
|
400
|
-
|
|
419
|
+
let hasDocker =
|
|
401
420
|
(detectedFrontend?.hasDocker || detectedBackend?.hasDocker) ?? false;
|
|
402
421
|
|
|
403
422
|
/** @type {Record<string, unknown>} */
|
|
@@ -458,7 +477,14 @@ export function registerInitCommand(program) {
|
|
|
458
477
|
config
|
|
459
478
|
);
|
|
460
479
|
|
|
461
|
-
await generateProjectScaffold(config, environments, cwd);
|
|
480
|
+
const scaffoldResult = await generateProjectScaffold(config, environments, cwd);
|
|
481
|
+
|
|
482
|
+
if (scaffoldResult?.dockerfileGenerated) {
|
|
483
|
+
hasDocker = true;
|
|
484
|
+
config.docker = true;
|
|
485
|
+
config.pipeline.docker = true;
|
|
486
|
+
await saveConfig(config, cwd);
|
|
487
|
+
}
|
|
462
488
|
|
|
463
489
|
const envExampleDest = path.join(cwd, '.env.example');
|
|
464
490
|
const envExampleContent = generateEnvExampleContent(
|
package/src/core/stages.js
CHANGED
|
@@ -6,6 +6,7 @@ import { deployToAll } from '../deployment/index.js';
|
|
|
6
6
|
import { sendNotifications } from '../notifications/index.js';
|
|
7
7
|
import axios from 'axios';
|
|
8
8
|
import { getProjectVersion } from '../utils/version.js';
|
|
9
|
+
import { ensureDeployScaffold } from '../utils/scaffold.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
@@ -40,6 +41,18 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
40
41
|
ctx.config.port = detected.port;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
const scaffold = await ensureDeployScaffold(
|
|
45
|
+
ctx.cwd,
|
|
46
|
+
ctx.config,
|
|
47
|
+
ctx.config.environments || {},
|
|
48
|
+
{ silent: false }
|
|
49
|
+
);
|
|
50
|
+
if (scaffold.dockerfile) {
|
|
51
|
+
ctx.config.docker = true;
|
|
52
|
+
if (ctx.config.pipeline) {
|
|
53
|
+
ctx.config.pipeline.docker = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
43
56
|
ctx.state.framework = ctx.config.framework;
|
|
44
57
|
ctx.state.projectType = ctx.config.projectType || 'frontend';
|
|
45
58
|
},
|
|
@@ -323,6 +323,15 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
323
323
|
example: 'ghcr.io/myorg/myapp',
|
|
324
324
|
when: 'optional',
|
|
325
325
|
},
|
|
326
|
+
{
|
|
327
|
+
key: 'DOCKER_IMAGE_TAG',
|
|
328
|
+
optionalReason: 'defaults to your project version, then "latest" if unset',
|
|
329
|
+
comment: [
|
|
330
|
+
'Image tag written into generated manifests and used at deploy time.',
|
|
331
|
+
],
|
|
332
|
+
example: 'latest',
|
|
333
|
+
when: 'optional',
|
|
334
|
+
},
|
|
326
335
|
{
|
|
327
336
|
key: 'KUBE_IMAGE_PULL_SECRET',
|
|
328
337
|
optionalReason: 'only required when pulling from a private container registry',
|
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { createLogger } from '../../logger/index.js';
|
|
6
|
+
import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -15,6 +16,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
15
16
|
const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
16
17
|
const context = env.KUBE_CONTEXT || '';
|
|
17
18
|
const namespace = env.KUBE_NAMESPACE || config.project || 'default';
|
|
19
|
+
const deploymentName = sanitizeK8sName(config.project || 'app');
|
|
18
20
|
|
|
19
21
|
function getKubectlEnv() {
|
|
20
22
|
const expanded = kubeconfig.replace(/^~/, os.homedir());
|
|
@@ -66,8 +68,8 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
66
68
|
kubectlArgs([
|
|
67
69
|
'set',
|
|
68
70
|
'image',
|
|
69
|
-
`deployment/${
|
|
70
|
-
`${
|
|
71
|
+
`deployment/${deploymentName}`,
|
|
72
|
+
`${deploymentName}=${imageName}:${imageTag}`,
|
|
71
73
|
]),
|
|
72
74
|
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
73
75
|
).catch(() => {
|
|
@@ -80,7 +82,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
80
82
|
|
|
81
83
|
async function rollback(artifactDir) {
|
|
82
84
|
log.info('Rolling back Kubernetes deployment...');
|
|
83
|
-
await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${
|
|
85
|
+
await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
|
|
84
86
|
stdio: 'inherit',
|
|
85
87
|
env: getKubectlEnv(),
|
|
86
88
|
}).catch(async () => {
|
|
@@ -105,7 +107,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
105
107
|
try {
|
|
106
108
|
await execa(
|
|
107
109
|
'kubectl',
|
|
108
|
-
kubectlArgs(['rollout', 'status', `deployment/${
|
|
110
|
+
kubectlArgs(['rollout', 'status', `deployment/${deploymentName}`, '--timeout=30s']),
|
|
109
111
|
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
110
112
|
);
|
|
111
113
|
return true;
|
|
@@ -3,7 +3,11 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { createLogger } from '../../logger/index.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getNginxSitesAvailablePath,
|
|
8
|
+
getNginxSitesEnabledPath,
|
|
9
|
+
getNginxConfDPath,
|
|
10
|
+
} from '../../utils/nginx.js';
|
|
7
11
|
import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
|
|
8
12
|
|
|
9
13
|
/** @type {Set<string>} */
|
|
@@ -240,24 +244,85 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
240
244
|
await exec(ssh, 'pm2 save');
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
/**
|
|
248
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
249
|
+
* @param {string} remotePath
|
|
250
|
+
* @param {'f'|'d'} [kind='f']
|
|
251
|
+
*/
|
|
252
|
+
async function remotePathExists(ssh, remotePath, kind = 'f') {
|
|
253
|
+
const flag = kind === 'd' ? '-d' : '-f';
|
|
254
|
+
const result = await ssh.execCommand(`test ${flag} ${sh(remotePath)} && echo yes`);
|
|
255
|
+
return result.code === 0 && result.stdout.trim() === 'yes';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
260
|
+
* @param {string} command
|
|
261
|
+
*/
|
|
262
|
+
async function remoteCommandExists(ssh, command) {
|
|
263
|
+
const result = await ssh.execCommand(`command -v ${sh(command)} >/dev/null 2>&1 && echo yes`);
|
|
264
|
+
return result.code === 0 && result.stdout.trim() === 'yes';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
269
|
+
* @returns {Promise<'debian'|'rhel'>}
|
|
270
|
+
*/
|
|
271
|
+
async function detectNginxLayout(ssh) {
|
|
272
|
+
if (await remotePathExists(ssh, '/etc/nginx/sites-available', 'd')) {
|
|
273
|
+
return 'debian';
|
|
274
|
+
}
|
|
275
|
+
return 'rhel';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
280
|
+
*/
|
|
281
|
+
async function reloadNginx(ssh) {
|
|
282
|
+
await exec(ssh, 'sudo systemctl reload nginx 2>/dev/null || sudo nginx -s reload');
|
|
283
|
+
}
|
|
284
|
+
|
|
243
285
|
/**
|
|
244
286
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
245
287
|
* @param {string} targetPath
|
|
246
288
|
*/
|
|
247
289
|
async function setupNginx(ssh, targetPath) {
|
|
248
|
-
const sitePath = getNginxSitePath(config.project);
|
|
249
290
|
const nginxConfRemote = `${targetPath}/nginx.conf`;
|
|
250
291
|
|
|
251
|
-
await
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
292
|
+
if (!(await remoteCommandExists(ssh, 'nginx'))) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
'Nginx is not installed on the server. Install it first (e.g. sudo yum install nginx on Amazon Linux, or sudo apt install nginx on Ubuntu), then re-run deploy.'
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!(await remotePathExists(ssh, nginxConfRemote))) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Nginx config not found at ${nginxConfRemote} — artifact may be missing nginx.conf.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const layout = await detectNginxLayout(ssh);
|
|
305
|
+
log.info(
|
|
306
|
+
layout === 'debian'
|
|
307
|
+
? 'Detected Nginx layout: Debian/Ubuntu (sites-available)'
|
|
308
|
+
: 'Detected Nginx layout: RHEL/Amazon Linux (conf.d)'
|
|
258
309
|
);
|
|
310
|
+
|
|
311
|
+
if (layout === 'debian') {
|
|
312
|
+
const sitePath = getNginxSitesAvailablePath(config.project);
|
|
313
|
+
const enabledPath = getNginxSitesEnabledPath(config.project);
|
|
314
|
+
await exec(ssh, `sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)}`);
|
|
315
|
+
await exec(ssh, `sudo ln -sf ${sh(sitePath)} ${sh(enabledPath)}`);
|
|
316
|
+
log.info(`Nginx config installed: ${sitePath}`);
|
|
317
|
+
} else {
|
|
318
|
+
const confPath = getNginxConfDPath(config.project);
|
|
319
|
+
await exec(ssh, `sudo cp ${sh(nginxConfRemote)} ${sh(confPath)}`);
|
|
320
|
+
log.info(`Nginx config installed: ${confPath}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
259
323
|
await exec(ssh, 'sudo nginx -t');
|
|
260
|
-
await
|
|
324
|
+
await reloadNginx(ssh);
|
|
325
|
+
log.success('Nginx config tested and reloaded');
|
|
261
326
|
}
|
|
262
327
|
|
|
263
328
|
/**
|