@akash-chowdhury-24/deployhub 2.0.8 → 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 +1 -1
- package/src/commands/doctor.js +56 -0
- package/src/deployment/deployment-env.js +13 -7
- package/src/deployment/providers/kubernetes.js +29 -17
- package/src/utils/docker-image-deploy.js +9 -1
- package/src/utils/docker-image.js +86 -2
- package/src/utils/interactive.js +27 -0
- package/src/utils/kubernetes-deploy-image.js +111 -0
- package/src/utils/kubernetes-manifests.js +1 -0
- package/src/utils/kubernetes-namespace.js +130 -0
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { execa } from 'execa';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
5
6
|
import axios from 'axios';
|
|
6
7
|
import { loadConfig, loadEnv } from '../core/config.js';
|
|
7
8
|
import { testProvider } from '../storage/index.js';
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from '../utils/shell-quote.js';
|
|
21
22
|
import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
|
|
22
23
|
import { checkImagePullability } from '../utils/docker-image-deploy.js';
|
|
24
|
+
import { namespaceExists } from '../utils/kubernetes-namespace.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -412,6 +414,60 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
412
414
|
})
|
|
413
415
|
);
|
|
414
416
|
|
|
417
|
+
checks.push(
|
|
418
|
+
await runCheck('Kubernetes namespace', async () => {
|
|
419
|
+
const ns = process.env.KUBE_NAMESPACE || config.project || 'default';
|
|
420
|
+
const kubeconfig =
|
|
421
|
+
process.env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
422
|
+
const context = process.env.KUBE_CONTEXT || '';
|
|
423
|
+
const expanded = kubeconfig.replace(/^~/, os.homedir());
|
|
424
|
+
const kubectlEnv = { ...process.env, KUBECONFIG: path.resolve(expanded) };
|
|
425
|
+
|
|
426
|
+
/** @param {string[]} baseArgs */
|
|
427
|
+
function kubectlClusterArgs(baseArgs) {
|
|
428
|
+
const args = [...baseArgs];
|
|
429
|
+
if (context) args.push('--context', context);
|
|
430
|
+
return args;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const exists = await namespaceExists(ns, {
|
|
434
|
+
kubectlArgs: kubectlClusterArgs,
|
|
435
|
+
getKubectlEnv: () => kubectlEnv,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (exists) {
|
|
439
|
+
return {
|
|
440
|
+
name: 'Kubernetes namespace',
|
|
441
|
+
pass: true,
|
|
442
|
+
message: `Namespace '${ns}' exists`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
name: 'Kubernetes namespace',
|
|
447
|
+
pass: true,
|
|
448
|
+
message: `Namespace '${ns}' does not exist yet — deploy will prompt locally or auto-create in CI (kubectl create namespace ${ns})`,
|
|
449
|
+
};
|
|
450
|
+
})
|
|
451
|
+
);
|
|
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
|
+
|
|
415
471
|
checks.push(
|
|
416
472
|
await runCheck('Container image pullable', async () => {
|
|
417
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:
|
|
100
|
-
|
|
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
|
},
|
|
@@ -309,7 +313,7 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
309
313
|
optionalReason: 'defaults to your project name or "default" if unset',
|
|
310
314
|
comment: [
|
|
311
315
|
'Kubernetes namespace for your deployment.',
|
|
312
|
-
'
|
|
316
|
+
'If missing, deployhub deploy prompts to create it locally (or auto-creates in CI).',
|
|
313
317
|
],
|
|
314
318
|
example: 'my-app',
|
|
315
319
|
when: 'optional',
|
|
@@ -324,9 +328,11 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
324
328
|
},
|
|
325
329
|
{
|
|
326
330
|
key: 'DOCKER_IMAGE_TAG',
|
|
327
|
-
optionalReason:
|
|
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
|
-
'
|
|
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:
|
|
581
|
+
DOCKER_IMAGE_TAG: '',
|
|
576
582
|
DOCKER_REGISTRY_URL: envEntry.dockerRegistryUrl,
|
|
577
583
|
AWS_REGION: 'us-east-1',
|
|
578
584
|
};
|
|
@@ -720,7 +726,7 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
720
726
|
],
|
|
721
727
|
after: [
|
|
722
728
|
'Ensure your kubeconfig context points to the correct cluster.',
|
|
723
|
-
'
|
|
729
|
+
'Namespace is created on first deploy if missing (prompt locally; auto-create in CI). Or: kubectl create namespace YOUR_NAMESPACE',
|
|
724
730
|
'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
|
|
725
731
|
'Skipping registry credentials will very likely cause ImagePullBackOff — the cluster cannot see local Docker images.',
|
|
726
732
|
'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
|
|
@@ -5,6 +5,8 @@ import os from 'os';
|
|
|
5
5
|
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
|
+
import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
|
|
9
|
+
import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -26,12 +28,22 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
31
|
+
* Cluster-scoped kubectl args (context only). Used for Namespace get/create.
|
|
29
32
|
* @param {string[]} baseArgs
|
|
30
33
|
*/
|
|
31
|
-
function
|
|
34
|
+
function kubectlClusterArgs(baseArgs) {
|
|
32
35
|
/** @type {string[]} */
|
|
33
36
|
const args = [...baseArgs];
|
|
34
37
|
if (context) args.push('--context', context);
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string[]} baseArgs
|
|
43
|
+
*/
|
|
44
|
+
function kubectlArgs(baseArgs) {
|
|
45
|
+
/** @type {string[]} */
|
|
46
|
+
const args = kubectlClusterArgs(baseArgs);
|
|
35
47
|
if (namespace) args.push('--namespace', namespace);
|
|
36
48
|
return args;
|
|
37
49
|
}
|
|
@@ -61,6 +73,13 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
61
73
|
);
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
await ensureKubernetesNamespace({
|
|
77
|
+
namespace,
|
|
78
|
+
log,
|
|
79
|
+
kubectlArgs: kubectlClusterArgs,
|
|
80
|
+
getKubectlEnv,
|
|
81
|
+
});
|
|
82
|
+
|
|
64
83
|
const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
|
|
65
84
|
? path.join(manifestDir, 'k8s')
|
|
66
85
|
: manifestDir;
|
|
@@ -70,22 +89,15 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
70
89
|
env: getKubectlEnv(),
|
|
71
90
|
});
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`${deploymentName}=${imageName}:${imageTag}`,
|
|
83
|
-
]),
|
|
84
|
-
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
85
|
-
).catch(() => {
|
|
86
|
-
log.warn('kubectl set image skipped (deployment name may differ from project name)');
|
|
87
|
-
});
|
|
88
|
-
}
|
|
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
|
+
});
|
|
89
101
|
|
|
90
102
|
log.success('Kubernetes deployment complete');
|
|
91
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
|
|
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,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether the CLI can safely prompt the user.
|
|
3
|
+
* Non-interactive when stdin is not a TTY, or when common CI env vars are set.
|
|
4
|
+
*
|
|
5
|
+
* @param {{ env?: NodeJS.ProcessEnv, stdinIsTTY?: boolean|null }} [options]
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isInteractive(options = {}) {
|
|
9
|
+
const env = options.env || process.env;
|
|
10
|
+
const stdinIsTTY =
|
|
11
|
+
options.stdinIsTTY !== undefined ? options.stdinIsTTY : Boolean(process.stdin.isTTY);
|
|
12
|
+
|
|
13
|
+
if (env.CI === 'true' || env.CI === '1') return false;
|
|
14
|
+
if (env.GITHUB_ACTIONS === 'true' || env.GITHUB_ACTIONS === '1') return false;
|
|
15
|
+
if (!stdinIsTTY) return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ env?: NodeJS.ProcessEnv, stdinIsTTY?: boolean|null }} [options]
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function isNonInteractive(options = {}) {
|
|
24
|
+
return !isInteractive(options);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default { isInteractive, isNonInteractive };
|
|
@@ -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 };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import { isInteractive } from './interactive.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {unknown} err
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function kubectlErrorDetail(err) {
|
|
10
|
+
if (err && typeof err === 'object' && 'stderr' in err) {
|
|
11
|
+
const stderr = String(/** @type {{ stderr?: unknown }} */ (err).stderr || '').trim();
|
|
12
|
+
if (stderr) return stderr;
|
|
13
|
+
}
|
|
14
|
+
if (err instanceof Error && err.message) return err.message;
|
|
15
|
+
return String(err);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether a namespace exists using a cluster-scoped get that exits 0 for
|
|
20
|
+
* both found and missing. Non-zero exits (auth, connectivity, bad kubeconfig)
|
|
21
|
+
* are rethrown — they must not be treated as "not found".
|
|
22
|
+
*
|
|
23
|
+
* @param {string} namespace
|
|
24
|
+
* @param {{
|
|
25
|
+
* kubectlArgs?: (args: string[]) => string[],
|
|
26
|
+
* getKubectlEnv?: () => NodeJS.ProcessEnv,
|
|
27
|
+
* execaFn?: typeof execa,
|
|
28
|
+
* }} [options]
|
|
29
|
+
* @returns {Promise<boolean>}
|
|
30
|
+
*/
|
|
31
|
+
export async function namespaceExists(namespace, options = {}) {
|
|
32
|
+
const kubectlArgs = options.kubectlArgs || ((args) => args);
|
|
33
|
+
const getKubectlEnv = options.getKubectlEnv || (() => process.env);
|
|
34
|
+
const execaFn = options.execaFn || execa;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execaFn(
|
|
38
|
+
'kubectl',
|
|
39
|
+
kubectlArgs(['get', 'namespace', namespace, '--ignore-not-found', '-o', 'name']),
|
|
40
|
+
{
|
|
41
|
+
stdio: 'pipe',
|
|
42
|
+
env: getKubectlEnv(),
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
return Boolean(stdout && String(stdout).trim());
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Failed to check whether namespace '${namespace}' exists: ${kubectlErrorDetail(err)}`,
|
|
49
|
+
{ cause: err instanceof Error ? err : undefined }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} namespace
|
|
56
|
+
* @returns {Promise<boolean>}
|
|
57
|
+
*/
|
|
58
|
+
async function defaultConfirmCreate(namespace) {
|
|
59
|
+
const { create } = await inquirer.prompt([
|
|
60
|
+
{
|
|
61
|
+
type: 'confirm',
|
|
62
|
+
name: 'create',
|
|
63
|
+
message: `Namespace '${namespace}' does not exist. Create it now?`,
|
|
64
|
+
default: true,
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
return Boolean(create);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ensure the target namespace exists before kubectl apply.
|
|
72
|
+
* Interactive: prompt to create. CI / non-TTY: auto-create with a clear log.
|
|
73
|
+
* Connectivity/auth failures from the existence check abort before create/prompt.
|
|
74
|
+
*
|
|
75
|
+
* @param {{
|
|
76
|
+
* namespace: string,
|
|
77
|
+
* log: { info: Function, warn: Function, success: Function, error?: Function },
|
|
78
|
+
* kubectlArgs?: (args: string[]) => string[],
|
|
79
|
+
* getKubectlEnv?: () => NodeJS.ProcessEnv,
|
|
80
|
+
* execaFn?: typeof execa,
|
|
81
|
+
* confirmFn?: (namespace: string) => Promise<boolean>,
|
|
82
|
+
* interactive?: boolean,
|
|
83
|
+
* }} options
|
|
84
|
+
* @returns {Promise<{ existed: boolean, created: boolean }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function ensureKubernetesNamespace(options) {
|
|
87
|
+
const {
|
|
88
|
+
namespace,
|
|
89
|
+
log,
|
|
90
|
+
kubectlArgs = (args) => args,
|
|
91
|
+
getKubectlEnv = () => process.env,
|
|
92
|
+
execaFn = execa,
|
|
93
|
+
confirmFn = defaultConfirmCreate,
|
|
94
|
+
interactive = isInteractive(),
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
if (!namespace) {
|
|
98
|
+
throw new Error('Kubernetes namespace is empty — set KUBE_NAMESPACE or config.project.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Throws on auth/connectivity/kubeconfig errors — only false means genuine NotFound.
|
|
102
|
+
if (await namespaceExists(namespace, { kubectlArgs, getKubectlEnv, execaFn })) {
|
|
103
|
+
return { existed: true, created: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
log.warn(`Namespace '${namespace}' was not found on the cluster.`);
|
|
107
|
+
|
|
108
|
+
let shouldCreate = true;
|
|
109
|
+
if (interactive) {
|
|
110
|
+
shouldCreate = await confirmFn(namespace);
|
|
111
|
+
if (!shouldCreate) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Namespace '${namespace}' does not exist. Create it manually with: kubectl create namespace ${namespace}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
log.info(
|
|
118
|
+
`Non-interactive session detected — creating namespace '${namespace}' automatically.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await execaFn('kubectl', kubectlArgs(['create', 'namespace', namespace]), {
|
|
123
|
+
stdio: 'inherit',
|
|
124
|
+
env: getKubectlEnv(),
|
|
125
|
+
});
|
|
126
|
+
log.success(`Created namespace '${namespace}'`);
|
|
127
|
+
return { existed: false, created: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default { namespaceExists, ensureKubernetesNamespace };
|