@akash-chowdhury-24/deployhub 2.0.9 → 2.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.9",
3
+ "version": "2.0.10",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -450,6 +450,24 @@ async function runDeploymentChecks(config, envName, envConfig) {
450
450
  })
451
451
  );
452
452
 
453
+ checks.push(
454
+ await runCheck('Image tag strategy', async () => {
455
+ if (process.env.DOCKER_IMAGE_TAG) {
456
+ return {
457
+ name: 'Image tag strategy',
458
+ pass: true,
459
+ message: `DOCKER_IMAGE_TAG='${process.env.DOCKER_IMAGE_TAG}' is set explicitly — deploy will rollout-restart when the full image ref is unchanged; prefer unset for unique tags per build`,
460
+ };
461
+ }
462
+ return {
463
+ name: 'Image tag strategy',
464
+ pass: true,
465
+ message:
466
+ 'DOCKER_IMAGE_TAG unset — deploy will auto-generate a unique tag (git SHA → CI id → timestamp)',
467
+ };
468
+ })
469
+ );
470
+
453
471
  checks.push(
454
472
  await runCheck('Container image pullable', async () => {
455
473
  const result = await checkImagePullability(config, process.env);
@@ -96,8 +96,12 @@ export const DEPLOYMENT_ENV_DEFS = {
96
96
  },
97
97
  {
98
98
  key: 'DOCKER_IMAGE_TAG',
99
- optionalReason: 'defaults to your project version if unset',
100
- comment: ['Image tag to build and deploy.'],
99
+ optionalReason:
100
+ 'leave unset for a unique tag per build (git SHA → CI run id → timestamp); explicit tags are reused as-is',
101
+ comment: [
102
+ 'Optional. Leave unset for a unique tag per build (git SHA, CI run id, or timestamp).',
103
+ 'If set explicitly, the same tag is reused — Kubernetes may keep stale pods unless imagePullPolicy is Always or a rollout restart runs.',
104
+ ],
101
105
  example: 'latest',
102
106
  when: 'optional',
103
107
  },
@@ -324,9 +328,11 @@ export const DEPLOYMENT_ENV_DEFS = {
324
328
  },
325
329
  {
326
330
  key: 'DOCKER_IMAGE_TAG',
327
- optionalReason: 'defaults to your project version, then "latest" if unset',
331
+ optionalReason:
332
+ 'leave unset for a unique tag per build (git SHA → CI run id → timestamp); explicit tags are reused as-is',
328
333
  comment: [
329
- 'Image tag written into generated manifests and used at deploy time.',
334
+ 'Optional image tag. Unset DeployHub auto-generates a unique tag each build.',
335
+ 'If set, that exact tag is used — reusing it can leave pods on a stale image (IfNotPresent) unless you rely on deploy-time rollout restart or set imagePullPolicy: Always.',
330
336
  ],
331
337
  example: 'latest',
332
338
  when: 'optional',
@@ -572,7 +578,7 @@ function getDefaultFromConfig(key, config, environments) {
572
578
  SSH_SSH_PORT: '22',
573
579
  KUBE_NAMESPACE: config?.project || 'default',
574
580
  DOCKER_IMAGE_NAME: config?.project,
575
- DOCKER_IMAGE_TAG: config?.version || 'latest',
581
+ DOCKER_IMAGE_TAG: '',
576
582
  DOCKER_REGISTRY_URL: envEntry.dockerRegistryUrl,
577
583
  AWS_REGION: 'us-east-1',
578
584
  };
@@ -6,6 +6,7 @@ import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
7
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
8
8
  import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
9
+ import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
9
10
 
10
11
  /**
11
12
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -88,22 +89,15 @@ export function createKubernetesProvider(config, envName, env = process.env) {
88
89
  env: getKubectlEnv(),
89
90
  });
90
91
 
91
- const imageName = env.DOCKER_IMAGE_NAME;
92
- const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
93
- if (imageName && config.project) {
94
- await execa(
95
- 'kubectl',
96
- kubectlArgs([
97
- 'set',
98
- 'image',
99
- `deployment/${deploymentName}`,
100
- `${deploymentName}=${imageName}:${imageTag}`,
101
- ]),
102
- { stdio: 'pipe', env: getKubectlEnv() }
103
- ).catch(() => {
104
- log.warn('kubectl set image skipped (deployment name may differ from project name)');
105
- });
106
- }
92
+ // Always set image to the resolved fullImage (includes registry URL prefix when set).
93
+ // If the live ref already equals fullImage, rollout restart so a new digest is pulled.
94
+ await syncKubernetesDeploymentImage({
95
+ deploymentName,
96
+ fullImage: imageOps.fullImage,
97
+ kubectlArgs,
98
+ getKubectlEnv,
99
+ log,
100
+ });
107
101
 
108
102
  log.success('Kubernetes deployment complete');
109
103
  }
@@ -11,6 +11,7 @@ import {
11
11
  isFrontendStaticFramework,
12
12
  isInterpretedBackendFramework,
13
13
  resolveDockerImageRef,
14
+ EXPLICIT_IMAGE_TAG_WARNING,
14
15
  } from './docker-image.js';
15
16
 
16
17
  /**
@@ -22,13 +23,19 @@ import {
22
23
  * @param {{ info: Function, warn: Function, success: Function }} log
23
24
  */
24
25
  export function createDockerImageDeployContext(config, env = process.env, log) {
25
- const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
+ const { fullImage, latestImage, legacyLatestImage, imageTag, tagSource } =
26
27
  resolveDockerImageRef(config, env);
27
28
  const registryUrl = env.DOCKER_REGISTRY_URL || '';
28
29
  const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
29
30
  const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
30
31
  const dockerHost = env.DOCKER_HOST || '';
31
32
 
33
+ if (tagSource === 'explicit') {
34
+ log.warn(EXPLICIT_IMAGE_TAG_WARNING);
35
+ } else {
36
+ log.info(`Using auto image tag '${imageTag}' (source: ${tagSource})`);
37
+ }
38
+
32
39
  function getDockerEnv() {
33
40
  /** @type {Record<string, string>} */
34
41
  const dockerEnv = { ...process.env };
@@ -324,6 +331,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
324
331
  fullImage,
325
332
  latestImage,
326
333
  imageTag,
334
+ tagSource,
327
335
  getDockerEnv,
328
336
  hasRegistryCredentials,
329
337
  dockerLogin,
@@ -2,20 +2,100 @@
2
2
  * Shared Docker image naming for pipeline builds and deploy.
3
3
  */
4
4
 
5
+ import { execFileSync } from 'child_process';
6
+
7
+ /** @typedef {'explicit'|'git'|'ci'|'timestamp'} ImageTagSource */
8
+
9
+ export const EXPLICIT_IMAGE_TAG_WARNING =
10
+ 'DOCKER_IMAGE_TAG is set — reusing the same tag across deploys can leave Kubernetes pods on a stale image (imagePullPolicy defaults to IfNotPresent) unless imagePullPolicy is Always or a rollout restart runs.';
11
+
12
+ /**
13
+ * High-resolution timestamp for image tags only (artifact versioning keeps getDateVersion()).
14
+ * Minute prefix matches getDateVersion(); seconds+ms avoid collisions in fast rebuild loops.
15
+ * @param {Date} [now]
16
+ * @returns {string}
17
+ */
18
+ export function highResImageTagFallback(now = new Date()) {
19
+ const y = now.getFullYear();
20
+ const m = String(now.getMonth() + 1).padStart(2, '0');
21
+ const d = String(now.getDate()).padStart(2, '0');
22
+ const h = String(now.getHours()).padStart(2, '0');
23
+ const min = String(now.getMinutes()).padStart(2, '0');
24
+ const sec = String(now.getSeconds()).padStart(2, '0');
25
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
26
+ return `${y}.${m}.${d}.${h}${min}-${sec}${ms}`;
27
+ }
28
+
29
+ /**
30
+ * @returns {string|null}
31
+ */
32
+ function defaultGetGitShortSha() {
33
+ try {
34
+ const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ }).trim();
38
+ return sha || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Resolve image tag when DOCKER_IMAGE_TAG is unset: git SHA → CI id → high-res timestamp.
46
+ * Does not use config.version (static package versions would prevent redeploys).
47
+ *
48
+ * @param {Record<string, string|undefined>} [env]
49
+ * @param {{
50
+ * getGitShortSha?: () => string|null,
51
+ * now?: () => Date,
52
+ * }} [options]
53
+ * @returns {{ imageTag: string, tagSource: ImageTagSource }}
54
+ */
55
+ export function resolveImageTag(env = process.env, options = {}) {
56
+ const explicit = env.DOCKER_IMAGE_TAG;
57
+ if (explicit) {
58
+ return { imageTag: explicit, tagSource: 'explicit' };
59
+ }
60
+
61
+ const getGitShortSha = options.getGitShortSha || defaultGetGitShortSha;
62
+ const gitSha = getGitShortSha();
63
+ if (gitSha) {
64
+ return { imageTag: gitSha, tagSource: 'git' };
65
+ }
66
+
67
+ const ciTag =
68
+ (env.GITHUB_SHA && String(env.GITHUB_SHA).slice(0, 7)) ||
69
+ env.GITHUB_RUN_ID ||
70
+ env.CI_COMMIT_SHORT_SHA ||
71
+ env.CI_PIPELINE_ID;
72
+ if (ciTag) {
73
+ return { imageTag: String(ciTag), tagSource: 'ci' };
74
+ }
75
+
76
+ const now = options.now ? options.now() : new Date();
77
+ return { imageTag: highResImageTagFallback(now), tagSource: 'timestamp' };
78
+ }
79
+
5
80
  /**
6
81
  * @param {import('../core/config.js').DeployHubConfig} config
7
82
  * @param {Record<string, string|undefined>} [env]
83
+ * @param {{
84
+ * getGitShortSha?: () => string|null,
85
+ * now?: () => Date,
86
+ * }} [options]
8
87
  * @returns {{
9
88
  * imageName: string,
10
89
  * imageTag: string,
11
90
  * fullImage: string,
12
91
  * latestImage: string,
13
92
  * legacyLatestImage: string,
93
+ * tagSource: ImageTagSource,
14
94
  * }}
15
95
  */
16
- export function resolveDockerImageRef(config, env = process.env) {
96
+ export function resolveDockerImageRef(config, env = process.env, options = {}) {
17
97
  const imageName = env.DOCKER_IMAGE_NAME || config.project;
18
- const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
98
+ const { imageTag, tagSource } = resolveImageTag(env, options);
19
99
  const registryUrl = env.DOCKER_REGISTRY_URL || '';
20
100
 
21
101
  const repository =
@@ -29,6 +109,7 @@ export function resolveDockerImageRef(config, env = process.env) {
29
109
  fullImage: `${repository}:${imageTag}`,
30
110
  latestImage: `${repository}:latest`,
31
111
  legacyLatestImage: `${config.project}:latest`,
112
+ tagSource,
32
113
  };
33
114
  }
34
115
 
@@ -170,6 +251,9 @@ export function describeInterpretedBackendGap(framework) {
170
251
 
171
252
  export default {
172
253
  resolveDockerImageRef,
254
+ resolveImageTag,
255
+ highResImageTagFallback,
256
+ EXPLICIT_IMAGE_TAG_WARNING,
173
257
  generateFrontendRuntimeDockerfile,
174
258
  generateSpringRuntimeDockerfile,
175
259
  generateGoRuntimeDockerfile,
@@ -0,0 +1,111 @@
1
+ import { execa } from 'execa';
2
+
3
+ /**
4
+ * After kubectl apply, set the Deployment container image to fullImage and
5
+ * rollout-restart only when the live image ref is already identical (same
6
+ * registry + name + tag), so a new digest under a reused tag still redeploys.
7
+ *
8
+ * @param {{
9
+ * deploymentName: string,
10
+ * fullImage: string,
11
+ * kubectlArgs: (args: string[]) => string[],
12
+ * getKubectlEnv: () => NodeJS.ProcessEnv,
13
+ * log: { info: Function, warn: Function, success?: Function },
14
+ * execaFn?: typeof execa,
15
+ * }} options
16
+ * @returns {Promise<{ beforeImage: string, setImage: boolean, restarted: boolean }>}
17
+ */
18
+ export async function syncKubernetesDeploymentImage(options) {
19
+ const {
20
+ deploymentName,
21
+ fullImage,
22
+ kubectlArgs,
23
+ getKubectlEnv,
24
+ log,
25
+ execaFn = execa,
26
+ } = options;
27
+
28
+ const beforeImage = await readDeploymentContainerImage({
29
+ deploymentName,
30
+ kubectlArgs,
31
+ getKubectlEnv,
32
+ execaFn,
33
+ });
34
+
35
+ let setImage = false;
36
+ try {
37
+ await execaFn(
38
+ 'kubectl',
39
+ kubectlArgs([
40
+ 'set',
41
+ 'image',
42
+ `deployment/${deploymentName}`,
43
+ `${deploymentName}=${fullImage}`,
44
+ ]),
45
+ { stdio: 'pipe', env: getKubectlEnv() }
46
+ );
47
+ setImage = true;
48
+ } catch {
49
+ log.warn('kubectl set image skipped (deployment name may differ from project name)');
50
+ }
51
+
52
+ // Compare full image refs (registry + name + tag), not tag alone.
53
+ if (beforeImage === fullImage) {
54
+ log.info(
55
+ `Image ref unchanged (${fullImage}) — running rollout restart so pods pick up a new digest`
56
+ );
57
+ await execaFn(
58
+ 'kubectl',
59
+ kubectlArgs(['rollout', 'restart', `deployment/${deploymentName}`]),
60
+ { stdio: 'inherit', env: getKubectlEnv() }
61
+ );
62
+ return { beforeImage, setImage, restarted: true };
63
+ }
64
+
65
+ return { beforeImage, setImage, restarted: false };
66
+ }
67
+
68
+ /**
69
+ * @param {{
70
+ * deploymentName: string,
71
+ * kubectlArgs: (args: string[]) => string[],
72
+ * getKubectlEnv: () => NodeJS.ProcessEnv,
73
+ * execaFn: typeof execa,
74
+ * }} options
75
+ * @returns {Promise<string>}
76
+ */
77
+ async function readDeploymentContainerImage(options) {
78
+ const { deploymentName, kubectlArgs, getKubectlEnv, execaFn } = options;
79
+
80
+ const jsonpath = `{.spec.template.spec.containers[?(@.name=="${deploymentName}")].image}`;
81
+ try {
82
+ const { stdout } = await execaFn(
83
+ 'kubectl',
84
+ kubectlArgs(['get', 'deployment', deploymentName, '-o', `jsonpath=${jsonpath}`]),
85
+ { stdio: 'pipe', env: getKubectlEnv() }
86
+ );
87
+ const trimmed = String(stdout || '').trim();
88
+ if (trimmed) return trimmed;
89
+ } catch {
90
+ // fall through to containers[0]
91
+ }
92
+
93
+ try {
94
+ const { stdout } = await execaFn(
95
+ 'kubectl',
96
+ kubectlArgs([
97
+ 'get',
98
+ 'deployment',
99
+ deploymentName,
100
+ '-o',
101
+ 'jsonpath={.spec.template.spec.containers[0].image}',
102
+ ]),
103
+ { stdio: 'pipe', env: getKubectlEnv() }
104
+ );
105
+ return String(stdout || '').trim();
106
+ } catch {
107
+ return '';
108
+ }
109
+ }
110
+
111
+ export default { syncKubernetesDeploymentImage };
@@ -83,6 +83,7 @@ spec:
83
83
  spec:
84
84
  ${pullSecretBlock} containers:
85
85
  - name: ${name}
86
+ # Image tag is overwritten at deploy time (kubectl set image uses the resolved build tag)
86
87
  image: ${image}
87
88
  ports:
88
89
  - containerPort: ${port}