@akash-chowdhury-24/deployhub 2.0.13 → 2.0.15

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 CHANGED
@@ -86,7 +86,7 @@ This interactive wizard will:
86
86
  - Set up storage providers (AWS, Google Drive, Azure, GCP, Dropbox, Local)
87
87
  - Optionally configure deployment targets (SSH, Docker, EC2, Azure VM, GCP VM, Kubernetes)
88
88
  - Generate `deployhub.config.json`
89
- - Generate `.github/workflows/deployhub.yml`
89
+ - Generate `.github/workflows/deployhub.yml` and `.github/workflows/deployhub-rollback.yml`
90
90
  - Generate `.env.example`
91
91
 
92
92
  ### 2. Configure credentials
@@ -182,7 +182,8 @@ The wizard asks the same core questions for every setup:
182
182
  **Generated files:**
183
183
 
184
184
  - `deployhub.config.json` — project settings (no secrets)
185
- - `.github/workflows/deployhub.yml` — CI pipeline
185
+ - `.github/workflows/deployhub.yml` — CI deploy pipeline (push to main)
186
+ - `.github/workflows/deployhub-rollback.yml` — manual CI rollback (`workflow_dispatch`)
186
187
  - `.env.example` — list of env vars you may need
187
188
  - `nginx.conf` — auto-generated if frontend deploys to SSH
188
189
  - `Dockerfile` — auto-generated if missing and you chose Docker or Kubernetes deploy (your existing `Dockerfile` is never overwritten)
@@ -231,9 +232,25 @@ deployhub artifact restore <buildId> # download a past build
231
232
  deployhub deploy # deploy latest artifact without rebuilding
232
233
  deployhub rollback # previous build from history
233
234
  deployhub rollback <buildId> # exact build (required if semver is ambiguous)
235
+ deployhub sync-workflows # regenerate deploy + rollback GitHub Actions YAML
236
+ deployhub sync-k8s-ports # fix containerPort/targetPort in existing k8s manifests
234
237
  deployhub logs # last deployment logs
235
238
  ```
236
239
 
240
+ ### Rollback behavior
241
+
242
+ `deployhub rollback` restores a previous artifact from storage `history.json` and redeploys it:
243
+
244
+ | Argument | Behavior |
245
+ |----------|----------|
246
+ | *(none)* | Rolls back to the **previous** build (second entry in newest-first history) |
247
+ | Exact `buildId` | Rolls back to that specific build |
248
+ | Semver / version string matching **multiple** builds | Does **not** guess — prints the matching `buildId`s and exits; re-run with an exact one |
249
+
250
+ `buildId` looks like `{semver}-{stamp}` where the stamp is a short git SHA when available, otherwise a CI run id, otherwise a high-resolution timestamp. When `DOCKER_IMAGE_TAG` is left unset, Docker and Kubernetes use that same `buildId` as the image tag — so you can correlate an artifact in storage with the image that was pushed.
251
+
252
+ For CI-triggered rollback, see [CI rollback](#ci-rollback-deployhub-rollbackyml).
253
+
237
254
  ---
238
255
 
239
256
  ## Walkthrough: Storage only
@@ -555,17 +572,27 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
555
572
  After `init`, commit these files:
556
573
 
557
574
  ```bash
558
- git add deployhub.config.json .github/workflows/deployhub.yml .env.example
575
+ git add deployhub.config.json .github/workflows/deployhub.yml .github/workflows/deployhub-rollback.yml .env.example
559
576
  git commit -m "Add DeployHub CI"
560
577
  ```
561
578
 
562
579
  1. Open **Settings → Secrets and variables → Actions** in your GitHub repo.
563
580
  2. Add every secret listed at the end of `deployhub init` (storage + deployment).
564
- 3. Push to `main` or `master` — the workflow triggers on push.
581
+ 3. Push to `main` or `master` — the deploy workflow triggers on push.
582
+
583
+ The deploy workflow (`deployhub.yml`) installs the correct language runtime (Node, Python, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets.
584
+
585
+ To run a deploy manually: **Actions → DeployHub → Run workflow**.
586
+
587
+ ### CI rollback (`deployhub-rollback.yml`)
565
588
 
566
- The workflow installs the correct language runtime (Node, Python, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets.
589
+ `init` also generates a separate **DeployHub Rollback** workflow (`deployhub-rollback.yml`). It is triggered only via GitHub Actions' **Run workflow** button (not on push):
567
590
 
568
- To run manually: **Actions → DeployHub → Run workflow**.
591
+ 1. Open **Actions → DeployHub Rollback → Run workflow**.
592
+ 2. Optionally enter an exact `buildId` (leave blank to roll back to the previous build).
593
+ 3. Run the workflow.
594
+
595
+ Already-initialized projects that only have `deployhub.yml` will not get the rollback workflow from a re-run of unrelated commands — run **`deployhub sync-workflows`** once to regenerate both workflow files from your current `deployhub.config.json`, then commit and push.
569
596
 
570
597
  ---
571
598
 
@@ -648,6 +675,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
648
675
 
649
676
  ### SSH
650
677
 
678
+ **Verification:** Real-world verified DEPLOY and ROLLBACK.
679
+
651
680
  **Prerequisites (before `deployhub init`):**
652
681
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (deploy path ownership + Nginx/sudo for frontends)
653
682
  - [ ] A Linux server with SSH enabled
@@ -687,7 +716,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
687
716
 
688
717
  ### Docker
689
718
 
690
- **Verification:** Real-world verified (local and CI Docker deploys).
719
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (local and CI).
691
720
 
692
721
  **Prerequisites:**
693
722
  - [ ] Docker installed (`docker --version` works)
@@ -722,6 +751,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
722
751
 
723
752
  ### AWS EC2
724
753
 
754
+ **Verification:** Real-world verified DEPLOY and ROLLBACK.
755
+
725
756
  **Prerequisites:**
726
757
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
727
758
  - [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
@@ -758,6 +789,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
758
789
 
759
790
  ### Azure VM
760
791
 
792
+ **Verification:** Real-world verified DEPLOY. Rollback logic confirmed via shared-SSH-path audit; not yet live-tested independently.
793
+
761
794
  **Prerequisites:**
762
795
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
763
796
  - [ ] Azure VM created in Portal (DeployHub does not provision it)
@@ -792,6 +825,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
792
825
 
793
826
  ### GCP VM
794
827
 
828
+ **Verification:** Real-world verified DEPLOY. Rollback logic confirmed via shared-SSH-path audit; not yet live-tested independently.
829
+
795
830
  **Prerequisites:**
796
831
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
797
832
  - [ ] Compute Engine VM created (DeployHub does not create it)
@@ -832,7 +867,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
832
867
 
833
868
  ### Kubernetes
834
869
 
835
- **Verification:** Real-world verified (including k3s and CI deploys).
870
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (including k3s and CI).
836
871
 
837
872
  **Prerequisites:**
838
873
  - [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
@@ -868,7 +903,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
868
903
  6. Add GitHub Secrets for CI (see table — **`KUBECONFIG` must be the file contents, not a path**)
869
904
  7. Run `deployhub doctor`, then `git push origin main`
870
905
 
871
- > **Warning Service `targetPort`:** the default `targetPort` in generated `k8s/service.yaml` may not match your app's actual exposed port (e.g. a static nginx image serves on port **80**, not the config's default like 3000). Verify and adjust `k8s/service.yaml`'s `targetPort` (and the Deployment `containerPort` if needed) before your first deploy.
906
+ > **Ports in generated Kubernetes manifests:** `containerPort` and `targetPort` are derived from your project's Dockerfile `EXPOSE` line (frontend / nginx images correctly get **80**; backends get their real exposed port). If no Dockerfile or usable `EXPOSE` exists yet, DeployHub falls back to a per-project-type default that matches its Dockerfile templates. Fresh `init` does **not** require a manual port edit. The Service's external `port:` stays **80** for all project types by design (Ingress-friendly); only the internal `targetPort` / `containerPort` track the container. Already-initialized projects with stale `3000` values can run `deployhub sync-k8s-ports` to patch just those fields.
872
907
 
873
908
  | Variable | Description | Example | Where to get it |
874
909
  |----------|-------------|---------|-----------------|
@@ -961,8 +996,10 @@ Run `deployhub doctor` after any config change.
961
996
  | `deployhub storage list` | List storage providers and connection status |
962
997
  | `deployhub deploy` | Deploy latest artifact |
963
998
  | `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
999
+ | `deployhub sync-workflows` | Regenerate `.github/workflows/deployhub.yml` and `deployhub-rollback.yml` from config |
1000
+ | `deployhub sync-k8s-ports` | Update only `containerPort` / `targetPort` in `k8s/deployment.yaml` and `k8s/service.yaml` from Dockerfile `EXPOSE` (or config/fallback). Does **not** change replicas, resources, env, or probes. Heavily customized / multi-container manifests may need a manual review |
964
1001
  | `deployhub logs` | Show logs from last deployment |
965
- | `deployhub doctor` | Pre-flight checks |
1002
+ | `deployhub doctor` | Pre-flight checks (including an informational warning if the rollback workflow file is missing) |
966
1003
  | `deployhub verify` | Health check on configured endpoint |
967
1004
  | `deployhub clean` | Remove old local artifacts |
968
1005
  | `deployhub update` | Check for CLI updates |
@@ -1009,6 +1046,8 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1009
1046
  | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1010
1047
  | `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 |
1011
1048
 
1049
+ **Kubernetes rollback vs deploy — registry credentials:** Kubernetes **rollback** specifically **requires** `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`. Rollback rebuilds and must push a fresh image tagged with the restored `buildId`; without registry credentials it fails loudly and early (a rollback that cannot push can never succeed against a real cluster). This is stricter than a normal Kubernetes **deploy**, which may still allow local-only / no-push flows in some setups. If deploy worked without those secrets but rollback fails asking for them, that asymmetry is intentional.
1050
+
1012
1051
  See [Deployment method guides](#deployment-method-guides) for full per-method variable tables with examples.
1013
1052
 
1014
1053
  ## `deployhub doctor` Output
@@ -1025,11 +1064,14 @@ The doctor command runs independent checks and always completes without crashing
1025
1064
  Checking Health endpoint... ✓ URL reachable (HTTP 200)
1026
1065
  Checking Secrets... ✓ All required env vars present
1027
1066
  Checking GitHub Actions... ✓ Workflow file exists at .github/workflows/deployhub.yml
1067
+ Checking Rollback workflow... ✓ Missing .github/workflows/deployhub-rollback.yml — run deployhub sync-workflows to add CI rollback (workflow_dispatch)
1028
1068
  Checking Storage write... ✓ Test upload succeeded
1029
1069
 
1030
- ✓ Ready to deploy (10/10 checks passed)
1070
+ ✓ Ready to deploy (11/11 checks passed)
1031
1071
  ```
1032
1072
 
1073
+ The **Rollback workflow** check is informational and non-blocking (`✓` even when the file is missing). It suggests `deployhub sync-workflows` so already-initialized projects can pick up `deployhub-rollback.yml` without a full re-init. When the file exists, the message confirms the path instead.
1074
+
1033
1075
  If checks fail:
1034
1076
 
1035
1077
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
package/src/cli/index.js CHANGED
@@ -13,6 +13,8 @@ import { registerDoctorCommand } from '../commands/doctor.js';
13
13
  import { registerVerifyCommand } from '../commands/verify.js';
14
14
  import { registerCleanCommand } from '../commands/clean.js';
15
15
  import { registerUpdateCommand } from '../commands/update.js';
16
+ import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
17
+ import { registerSyncK8sPortsCommand } from '../commands/sync-k8s-ports.js';
16
18
  import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
17
19
 
18
20
  loadEnv();
@@ -41,5 +43,7 @@ registerDoctorCommand(program);
41
43
  registerVerifyCommand(program);
42
44
  registerCleanCommand(program);
43
45
  registerUpdateCommand(program);
46
+ registerSyncWorkflowsCommand(program);
47
+ registerSyncK8sPortsCommand(program);
44
48
 
45
49
  program.parse();
@@ -55,10 +55,21 @@ export function registerArtifactCommand(program) {
55
55
  if (opts.remote) {
56
56
  console.log(chalk.bold('\nRemote history (storage):\n'));
57
57
  try {
58
- const history = await loadArtifactHistory(config.storage || [], config.project);
58
+ const { entries: history, source } = await loadArtifactHistory(
59
+ config.storage || [],
60
+ config.project
61
+ );
59
62
  if (history.length === 0) {
60
- console.log(chalk.yellow(' (no history.json found)'));
63
+ console.log(
64
+ chalk.yellow(
65
+ ' No artifact history found for this project — you may not have deployed any builds yet.'
66
+ )
67
+ );
61
68
  } else {
69
+ if (source) {
70
+ console.log(chalk.gray(` Source: ${source}`));
71
+ console.log('');
72
+ }
62
73
  for (const e of history) {
63
74
  console.log(
64
75
  ` ${chalk.cyan(e.buildId)} semver=${e.semver} ${e.uploadedAt || ''}`
@@ -67,11 +78,8 @@ export function registerArtifactCommand(program) {
67
78
  }
68
79
  }
69
80
  } catch (err) {
70
- console.log(
71
- chalk.yellow(
72
- ` Could not load remote history: ${err instanceof Error ? err.message : String(err)}`
73
- )
74
- );
81
+ const detail = err instanceof Error ? err.message : String(err);
82
+ console.log(chalk.red(` ${detail}`));
75
83
  }
76
84
  }
77
85
 
@@ -101,8 +109,8 @@ export function registerArtifactCommand(program) {
101
109
 
102
110
  const history = await loadArtifactHistory(config.storage || [], config.project);
103
111
  const histMatch =
104
- history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
105
- history.find((e) => e.semver === needle);
112
+ history.entries.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
113
+ history.entries.find((e) => e.semver === needle);
106
114
 
107
115
  const restoreDir = path.join(cwd, '.deployhub-restore', `v${needle}`);
108
116
  await fs.ensureDir(restoreDir);
@@ -7,7 +7,7 @@ import axios from 'axios';
7
7
  import { loadConfig, loadEnv } from '../core/config.js';
8
8
  import { testProvider } from '../storage/index.js';
9
9
  import { getDeploymentProvider } from '../deployment/index.js';
10
- import { PROVIDER_ENV_MAP } from '../utils/github-actions.js';
10
+ import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
11
11
  import { printDoctorFooter } from '../utils/author.js';
12
12
  import { createLocalProvider } from '../storage/providers/local.js';
13
13
  import {
@@ -888,11 +888,28 @@ export function registerDoctorCommand(program) {
888
888
  return {
889
889
  name: 'GitHub Actions',
890
890
  pass: false,
891
- message: 'Workflow file missing — run deployhub init',
891
+ message: 'Workflow file missing — run deployhub init or deployhub sync-workflows',
892
892
  };
893
893
  })
894
894
  );
895
895
 
896
+ const hasStorage = (config.storage || []).length > 0;
897
+ const hasDeploy = (config.deploy || []).length > 0;
898
+ if (hasStorage && hasDeploy) {
899
+ results.push(
900
+ await runCheck('Rollback workflow', async () => {
901
+ const check = await getRollbackWorkflowDoctorCheck(cwd, config);
902
+ return (
903
+ check || {
904
+ name: 'Rollback workflow',
905
+ pass: true,
906
+ message: 'Skipped',
907
+ }
908
+ );
909
+ })
910
+ );
911
+ }
912
+
896
913
  results.push(
897
914
  await runCheck('Storage write', async () => {
898
915
  const provider = createLocalProvider();
@@ -535,6 +535,7 @@ export function registerInitCommand(program) {
535
535
  console.log(chalk.bold('Generated files:'));
536
536
  console.log(' • deployhub.config.json');
537
537
  console.log(' • .github/workflows/deployhub.yml');
538
+ console.log(' • .github/workflows/deployhub-rollback.yml');
538
539
  console.log(' • .env.example');
539
540
  console.log('');
540
541
  printAuthorFooter();
@@ -0,0 +1,71 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, loadEnv } from '../core/config.js';
3
+ import { resolveContainerPort } from '../utils/dockerfile-expose.js';
4
+ import {
5
+ getDefaultKubernetesManifestPaths,
6
+ syncKubernetesManifestPorts,
7
+ } from '../utils/kubernetes-manifests.js';
8
+ import fs from 'fs-extra';
9
+
10
+ /**
11
+ * Surgically fix containerPort/targetPort in existing k8s manifests from Dockerfile EXPOSE.
12
+ * @param {import('commander').Command} program
13
+ */
14
+ export function registerSyncK8sPortsCommand(program) {
15
+ program
16
+ .command('sync-k8s-ports')
17
+ .description(
18
+ 'Update only containerPort/targetPort in k8s/deployment.yaml and k8s/service.yaml ' +
19
+ 'from the Dockerfile EXPOSE port (or config/fallback). Does not change replicas, ' +
20
+ 'resources, env, probes, or Service port. Heavily customized / multi-container ' +
21
+ 'manifests may need a manual review.'
22
+ )
23
+ .action(async () => {
24
+ loadEnv();
25
+ const cwd = process.cwd();
26
+ const config = await loadConfig(cwd);
27
+
28
+ const { deploymentPath, servicePath } = getDefaultKubernetesManifestPaths(cwd);
29
+ const hasDeployment = await fs.pathExists(deploymentPath);
30
+ const hasService = await fs.pathExists(servicePath);
31
+
32
+ if (!hasDeployment && !hasService) {
33
+ console.log(
34
+ chalk.yellow(
35
+ 'No k8s/deployment.yaml or k8s/service.yaml found — nothing to patch. ' +
36
+ 'Generate starter manifests via deployhub init, or add manifests under ./k8s/.'
37
+ )
38
+ );
39
+ return;
40
+ }
41
+
42
+ const { port, source } = await resolveContainerPort(cwd, config);
43
+ const result = await syncKubernetesManifestPorts(cwd, port);
44
+
45
+ console.log(
46
+ chalk.green(
47
+ `✓ Resolved container port ${port} (source: ${source})`
48
+ )
49
+ );
50
+
51
+ if (result.patched.length === 0) {
52
+ console.log(chalk.gray(' Port fields already match — no files changed.'));
53
+ } else {
54
+ for (const file of result.patched) {
55
+ console.log(chalk.gray(` Updated ${file}`));
56
+ }
57
+ }
58
+
59
+ console.log('');
60
+ console.log(
61
+ chalk.yellow(
62
+ 'Note: Only containerPort and targetPort are updated. Replicas, resources, env, ' +
63
+ 'probes, and Service port: are left unchanged. If your manifests are heavily ' +
64
+ 'customized (multiple containers, non-standard field layout), review the diff ' +
65
+ 'manually before deploying.'
66
+ )
67
+ );
68
+ });
69
+ }
70
+
71
+ export default { registerSyncK8sPortsCommand };
@@ -0,0 +1,43 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, loadEnv } from '../core/config.js';
3
+ import {
4
+ writeWorkflowFile,
5
+ DEPLOY_WORKFLOW_FILENAME,
6
+ ROLLBACK_WORKFLOW_FILENAME,
7
+ } from '../utils/github-actions.js';
8
+
9
+ /**
10
+ * Regenerate GitHub Actions workflows from deployhub.config.json (no interactive init).
11
+ * @param {import('commander').Command} program
12
+ */
13
+ export function registerSyncWorkflowsCommand(program) {
14
+ program
15
+ .command('sync-workflows')
16
+ .description(
17
+ 'Regenerate .github/workflows/deployhub.yml and deployhub-rollback.yml from deployhub.config.json'
18
+ )
19
+ .action(async () => {
20
+ loadEnv();
21
+ const cwd = process.cwd();
22
+ const config = await loadConfig(cwd);
23
+
24
+ const storage = config.storage || [];
25
+ const deploy = config.deploy || [];
26
+ const environments = config.environments || {};
27
+ const cliSource = config.cli?.source;
28
+
29
+ await writeWorkflowFile(storage, deploy, environments, cwd, cliSource, config);
30
+
31
+ console.log(chalk.green('✓ Regenerated GitHub Actions workflows:'));
32
+ console.log(` • .github/workflows/${DEPLOY_WORKFLOW_FILENAME}`);
33
+ console.log(` • .github/workflows/${ROLLBACK_WORKFLOW_FILENAME}`);
34
+ console.log('');
35
+ console.log(
36
+ chalk.gray(
37
+ 'Commit and push these files, then use Actions → DeployHub Rollback (workflow_dispatch) to roll back.'
38
+ )
39
+ );
40
+ });
41
+ }
42
+
43
+ export default { registerSyncWorkflowsCommand };
@@ -71,13 +71,14 @@ export async function deployToAll(config, artifactDir, envNames) {
71
71
  * @param {import('../core/config.js').DeployHubConfig} config
72
72
  * @param {string} artifactDir
73
73
  * @param {string[]} [envNames]
74
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
74
75
  */
75
- export async function rollbackAll(config, artifactDir, envNames) {
76
+ export async function rollbackAll(config, artifactDir, envNames, meta) {
76
77
  const targets = envNames || config.deploy || [];
77
78
  for (const envName of targets) {
78
79
  const envConfig = config.environments[envName];
79
80
  const provider = getDeploymentProvider(envConfig.type, config, envName);
80
- await provider.rollback(artifactDir);
81
+ await provider.rollback(artifactDir, meta);
81
82
  }
82
83
  }
83
84
 
@@ -21,7 +21,8 @@ export function createAzureVmProvider(config, envName, env = process.env) {
21
21
 
22
22
  if (!subscriptionId || !resourceGroup || !vmName) {
23
23
  throw new Error(
24
- 'Azure VM host unknown. Set SSH_HOST to your VM public IP, or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
24
+ 'Could not resolve host via Azure VM lookup, and no SSH_HOST was set ' +
25
+ 'provide SSH_HOST (VM public IP/DNS) or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
25
26
  );
26
27
  }
27
28
 
@@ -55,34 +56,54 @@ export function createAzureVmProvider(config, envName, env = process.env) {
55
56
  } catch (err) {
56
57
  const msg = err instanceof Error ? err.message : String(err);
57
58
  throw new Error(
58
- `Could not resolve public IP for VM ${vmName} ${msg}. Set SSH_HOST manually or run az login and verify resource group/VM name.`
59
+ `Could not resolve host via Azure VM lookup (${vmName}): ${msg}. ` +
60
+ 'Set SSH_HOST to the VM public IP/DNS, or run az login and verify resource group/VM name.'
59
61
  );
60
62
  }
61
63
  }
62
64
 
63
- const sshProvider = createSshProvider(config, envName, env);
64
-
65
- async function connect() {
65
+ /**
66
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
67
+ * then create an SSH provider that closes over the resolved host.
68
+ */
69
+ async function getSshProvider() {
66
70
  const host = await resolveHost();
71
+ if (!host) {
72
+ throw new Error(
73
+ 'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — provide one or the other.'
74
+ );
75
+ }
67
76
  const environment = config.environments[envName];
68
- if (environment && !environment.host) {
77
+ if (environment) {
69
78
  environment.host = host;
70
79
  }
71
- if (!env.SSH_HOST) {
72
- env.SSH_HOST = host;
73
- }
74
- return sshProvider.connect();
80
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
75
81
  }
76
82
 
77
83
  return {
78
- ...sshProvider,
79
- connect,
80
- deploy: sshProvider.deploy.bind(sshProvider),
81
- rollback: sshProvider.rollback.bind(sshProvider),
82
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
83
- testConnection: async () => {
84
- const ssh = await connect();
85
- ssh.dispose();
84
+ async connect() {
85
+ const ssh = await getSshProvider();
86
+ return ssh.connect();
87
+ },
88
+ async deploy(artifactDir, options) {
89
+ const ssh = await getSshProvider();
90
+ return ssh.deploy(artifactDir, options);
91
+ },
92
+ async rollback(artifactDir, meta) {
93
+ const ssh = await getSshProvider();
94
+ return ssh.rollback(artifactDir, meta);
95
+ },
96
+ async healthCheck() {
97
+ const ssh = await getSshProvider();
98
+ return ssh.healthCheck();
99
+ },
100
+ async testConnection() {
101
+ const ssh = await getSshProvider();
102
+ return ssh.testConnection();
103
+ },
104
+ async runRemoteCheck(command) {
105
+ const ssh = await getSshProvider();
106
+ return ssh.runRemoteCheck(command);
86
107
  },
87
108
  };
88
109
  }
@@ -1,6 +1,7 @@
1
1
  import { execa } from 'execa';
2
2
  import { createLogger } from '../../logger/index.js';
3
3
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
4
+ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
4
5
 
5
6
  /**
6
7
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -14,12 +15,17 @@ export function createDockerProvider(config, envName, env = process.env) {
14
15
 
15
16
  /**
16
17
  * @param {string} artifactDir
18
+ * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
17
19
  */
18
- async function deploy(artifactDir) {
19
- log.info(`Deploying via Docker (image: ${fullImage})...`);
20
+ async function deploy(artifactDir, options = {}) {
21
+ const imageRef = options.fullImage || fullImage;
22
+ log.info(`Deploying via Docker (image: ${imageRef})...`);
20
23
  const dockerEnv = getDockerEnv();
21
24
 
22
- const result = await ensureImageReadyForDeploy(artifactDir);
25
+ const result = await ensureImageReadyForDeploy(artifactDir, {
26
+ fullImage: options.fullImage,
27
+ skipImageReuse: options.skipImageReuse,
28
+ });
23
29
  if (result.ranCompose) {
24
30
  log.success('Docker deployment complete');
25
31
  return;
@@ -31,7 +37,7 @@ export function createDockerProvider(config, envName, env = process.env) {
31
37
  { stdio: 'pipe', env: dockerEnv }
32
38
  ).catch(() => {});
33
39
 
34
- await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
40
+ await execa('docker', ['run', '-d', '--rm', '--name', config.project, imageRef], {
35
41
  stdio: 'inherit',
36
42
  env: dockerEnv,
37
43
  });
@@ -39,9 +45,25 @@ export function createDockerProvider(config, envName, env = process.env) {
39
45
  log.success('Docker deployment complete');
40
46
  }
41
47
 
42
- async function rollback(artifactDir) {
43
- log.info('Rolling back Docker deployment (redeploy previous artifact)...');
44
- await deploy(artifactDir);
48
+ /**
49
+ * @param {string} artifactDir
50
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
51
+ */
52
+ async function rollback(artifactDir, meta = {}) {
53
+ if (!meta.buildId) {
54
+ throw new Error(
55
+ 'Docker rollback requires buildId from the restored artifact history entry'
56
+ );
57
+ }
58
+
59
+ const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
60
+ log.info(
61
+ `Rolling back Docker to buildId=${meta.buildId} (image: ${rollbackImage})...`
62
+ );
63
+ await deploy(artifactDir, {
64
+ fullImage: rollbackImage,
65
+ skipImageReuse: true,
66
+ });
45
67
  }
46
68
 
47
69
  async function healthCheck() {