@brutalsystems/dray 0.1.11 → 0.1.13
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 -2
- package/package.json +1 -1
- package/src/cli/index.js +1 -0
- package/src/cli/run-action.js +6 -1
- package/src/config/repo.js +6 -1
- package/src/core/ciGate.js +98 -0
- package/src/core/engine.js +16 -16
- package/src/core/resolve.js +1 -1
- package/src/primitives/gh.js +37 -0
- package/src/primitives/kubectl.js +27 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ dray list # registered repos + targets
|
|
|
37
37
|
dray ship <repo>:<target> # deps?→build→push(SHA)→render+apply→rollout
|
|
38
38
|
dray ship <repo> # all enabled workloads in the repo (skips manual ones)
|
|
39
39
|
dray apply <repo>:<target> # render + apply manifests only
|
|
40
|
-
dray rollout <repo>:<target>
|
|
40
|
+
dray rollout <repo>:<target> # restart + wait; skips the restart if one is already rolling
|
|
41
41
|
dray status <repo> # running image SHA vs HEAD
|
|
42
42
|
dray rollback <repo>:<target> <sha>
|
|
43
43
|
dray publish <repo>[:<pilet>] # publish pilet(s) via sops exec-env
|
|
@@ -45,13 +45,18 @@ dray secrets sync <repo>
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Global flags: `--dry-run` (print the plan, run nothing), `--allow-dirty`
|
|
48
|
-
(build a dirty tree, tags `:<sha>-dirty`), `--all` (every registered repo)
|
|
48
|
+
(build a dirty tree, tags `:<sha>-dirty`), `--all` (every registered repo),
|
|
49
|
+
`--skip-ci-check` (deploy even if CI is red — see [CI gate](#ci-gate)).
|
|
49
50
|
|
|
50
51
|
## `.dray/config.json` (per repo)
|
|
51
52
|
|
|
52
53
|
```jsonc
|
|
53
54
|
{
|
|
54
55
|
"name": "myrepo",
|
|
56
|
+
// optional per-repo overrides of ~/.dray/config.json defaults
|
|
57
|
+
// (profile, region, account, platform, context, namespace, ciGate);
|
|
58
|
+
// a workload may override them again.
|
|
59
|
+
"defaults": { "namespace": "bs", "ciGate": true },
|
|
55
60
|
"images": [
|
|
56
61
|
{ "name": "app", "ecr": "app", "source": { "local": true },
|
|
57
62
|
"dockerfile": "Dockerfile", "context": ".",
|
|
@@ -93,6 +98,49 @@ Global flags: `--dry-run` (print the plan, run nothing), `--allow-dirty`
|
|
|
93
98
|
}
|
|
94
99
|
```
|
|
95
100
|
|
|
101
|
+
### CI gate
|
|
102
|
+
|
|
103
|
+
With `"ciGate": true` in a repo's `defaults`, `push`, `apply`, `rollout` and
|
|
104
|
+
`ship` refuse to run unless that repo's GitHub Actions CI is green for the code
|
|
105
|
+
being shipped. It reads `gh run list` (so `gh` must be installed and
|
|
106
|
+
authenticated) and blocks on all three of:
|
|
107
|
+
|
|
108
|
+
- **failed** — CI is red.
|
|
109
|
+
- **pending** — CI is still running. This is the case that motivated the gate:
|
|
110
|
+
a deploy once pushed an image to ECR six minutes before the test suite it was
|
|
111
|
+
racing had finished, and nothing would have stopped a red result.
|
|
112
|
+
- **absent** — no run exists for HEAD *or any recent ancestor*, i.e. CI has
|
|
113
|
+
never run for this line of work. "No run found" has to block: if it passed,
|
|
114
|
+
never pushing would be the way around the gate.
|
|
115
|
+
|
|
116
|
+
It does *not* demand a run for HEAD itself. Workflows commonly use
|
|
117
|
+
`paths-ignore`, so a docs-only commit produces no run at all; requiring one
|
|
118
|
+
would refuse to ship a README change, and a gate that blocks legitimate work
|
|
119
|
+
gets switched off. Instead dray walks back to the **newest CI-covered
|
|
120
|
+
ancestor** and requires that verdict to be green.
|
|
121
|
+
|
|
122
|
+
Default is off, so registering a repo does not change its behavior — arm it
|
|
123
|
+
per repo. `--skip-ci-check` overrides the gate for one command and prints a
|
|
124
|
+
loud warning. The gate also self-disables when `GITHUB_ACTIONS=true` (inside
|
|
125
|
+
CI it would be waiting on the run that invoked it — a deadlock) and on
|
|
126
|
+
`--dry-run`.
|
|
127
|
+
|
|
128
|
+
**What it does not cover**, deliberately:
|
|
129
|
+
|
|
130
|
+
- Images built from `source.git` — their code lives in another repository whose
|
|
131
|
+
CI is not this repo's to read, so those units are skipped rather than falsely
|
|
132
|
+
reported as verified.
|
|
133
|
+
- `rollback`, which re-applies a SHA that was already deployed. Blocking a
|
|
134
|
+
rollback because `main` is red would be exactly backwards during an incident.
|
|
135
|
+
- `publish` (pilets), which does not go through the deploy path.
|
|
136
|
+
- A commit you have not pushed: with no run of its own it falls back to its
|
|
137
|
+
newest covered ancestor, so an unpushed change on top of a green commit
|
|
138
|
+
passes. The gate closes the red/racing cases, not "I never pushed it".
|
|
139
|
+
|
|
140
|
+
It reads the repo's 100 most recent workflow runs. In a repo busy enough that
|
|
141
|
+
HEAD's run has already fallen out of that window, the gate reports `absent` and
|
|
142
|
+
blocks — noisy, but never the wrong way around.
|
|
143
|
+
|
|
96
144
|
### Image pinning
|
|
97
145
|
|
|
98
146
|
Manifests reference managed images by a placeholder var — `${APP_IMAGE}`
|
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -6,6 +6,7 @@ async function run(argv) {
|
|
|
6
6
|
program.version(require('../../package.json').version, '-v, --version', 'show dray version');
|
|
7
7
|
program.option('--dry-run', 'print planned commands, run nothing');
|
|
8
8
|
program.option('--allow-dirty', 'allow building from a dirty tree (tags :<sha>-dirty)');
|
|
9
|
+
program.option('--skip-ci-check', 'deploy even if CI is red, pending, or absent');
|
|
9
10
|
for (const c of ['version', 'init', 'add', 'remove', 'reload', 'list',
|
|
10
11
|
'build', 'push', 'apply', 'rollout', 'ship', 'status', 'rollback', 'publish', 'secrets']) {
|
|
11
12
|
require(`./commands/${c}`)(program);
|
package/src/cli/run-action.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
const { loadGlobalConfig } = require('../config/global'); const { allRepos } = require('../config/registry');
|
|
2
2
|
const { resolveTargets } = require('../core/resolve'); const { planFor } = require('../core/plan'); const { execute } = require('../core/engine');
|
|
3
|
-
|
|
3
|
+
const { assertCiGreen } = require('../core/ciGate');
|
|
4
|
+
// Actions that publish an artifact or touch the cluster. `build` is local and
|
|
5
|
+
// leaves nothing behind, so it stays ungated.
|
|
6
|
+
const GATED = new Set(['push', 'apply', 'rollout', 'ship']);
|
|
7
|
+
async function runAction(target, action, { dryRun, allowDirty, all, skipCiCheck }) {
|
|
4
8
|
const registry = allRepos(); const { defaults } = loadGlobalConfig();
|
|
5
9
|
const spec = all ? '--all' : target;
|
|
6
10
|
const units = resolveTargets({ registry, globalDefaults: defaults, spec });
|
|
11
|
+
if (GATED.has(action)) await assertCiGreen(units, { dryRun, allowDirty, skipCiCheck });
|
|
7
12
|
await execute(planFor(units, action), { dryRun, allowDirty });
|
|
8
13
|
}
|
|
9
14
|
module.exports = { runAction };
|
package/src/config/repo.js
CHANGED
|
@@ -24,12 +24,17 @@ function validateRepoConfig(cfg) {
|
|
|
24
24
|
// Both gate deployment on truthiness, so a JSON string ("false", "no") would
|
|
25
25
|
// silently skip the workload -- in `manual`'s case, silently drop it from CI.
|
|
26
26
|
// Fail loud instead of deploying something different from what was meant.
|
|
27
|
-
for (const flag of ['disabled', 'manual']) {
|
|
27
|
+
for (const flag of ['disabled', 'manual', 'ciGate']) {
|
|
28
28
|
if (w[flag] !== undefined && typeof w[flag] !== 'boolean') {
|
|
29
29
|
e.push(`workload "${w.name}" ${flag} must be a boolean, got ${typeof w[flag]}`);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
// A JSON string ("false") is truthy and would silently ARM the gate -- or, as
|
|
34
|
+
// "true" in the wrong place, silently disarm it. Both are worth failing loud.
|
|
35
|
+
if (cfg.defaults && cfg.defaults.ciGate !== undefined && typeof cfg.defaults.ciGate !== 'boolean') {
|
|
36
|
+
e.push(`defaults.ciGate must be a boolean, got ${typeof cfg.defaults.ciGate}`);
|
|
37
|
+
}
|
|
33
38
|
for (const s of cfg.secrets || []) {
|
|
34
39
|
if (!s.name) e.push('secret missing name');
|
|
35
40
|
if (!SECRET_KINDS.has(s.kind)) e.push(`secret "${s.name}" kind must be sops-manifest|literal-from-env|sops-exec-env`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const real = { gh: require('../primitives/gh'), git: require('../primitives/git') };
|
|
2
|
+
|
|
3
|
+
const PASSING = new Set(['success', 'skipped', 'neutral']);
|
|
4
|
+
|
|
5
|
+
// Pure decision: given HEAD's ancestry (newest first) and the repo's recent
|
|
6
|
+
// workflow runs, may we deploy?
|
|
7
|
+
//
|
|
8
|
+
// Why walk back through history instead of demanding a run for HEAD itself:
|
|
9
|
+
// workflows commonly use paths-ignore, so a docs-only commit produces NO run
|
|
10
|
+
// at all -- not a skipped one. Requiring a run for HEAD would refuse to ship a
|
|
11
|
+
// README change, and a gate that blocks legitimate work is a gate that gets
|
|
12
|
+
// switched off. Instead we require the newest CI-COVERED ancestor to be green:
|
|
13
|
+
// if the commits after it were deemed unable to affect the artifact, the last
|
|
14
|
+
// verdict still stands.
|
|
15
|
+
function evaluate({ commits, runs }) {
|
|
16
|
+
const byShaPrefix = new Map();
|
|
17
|
+
for (const r of runs) {
|
|
18
|
+
if (!r.headSha) continue;
|
|
19
|
+
const list = byShaPrefix.get(r.headSha) || [];
|
|
20
|
+
list.push(r);
|
|
21
|
+
byShaPrefix.set(r.headSha, list);
|
|
22
|
+
}
|
|
23
|
+
for (const sha of commits) {
|
|
24
|
+
const hits = byShaPrefix.get(sha);
|
|
25
|
+
if (!hits || !hits.length) continue; // not CI-covered; keep walking back
|
|
26
|
+
const short = sha.slice(0, 7);
|
|
27
|
+
const pending = hits.filter((r) => r.status !== 'completed');
|
|
28
|
+
if (pending.length) {
|
|
29
|
+
return { ok: false, sha: short, reason: 'pending',
|
|
30
|
+
detail: `CI is still running for ${short} (${pending.map((r) => r.workflowName).join(', ')}). Wait for it to finish.`,
|
|
31
|
+
url: pending[0].url };
|
|
32
|
+
}
|
|
33
|
+
const failed = hits.filter((r) => !PASSING.has(r.conclusion));
|
|
34
|
+
if (failed.length) {
|
|
35
|
+
return { ok: false, sha: short, reason: 'failed',
|
|
36
|
+
detail: `CI failed for ${short}: ${failed.map((r) => `${r.workflowName}=${r.conclusion}`).join(', ')}`,
|
|
37
|
+
url: failed[0].url };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, sha: short, reason: 'green',
|
|
40
|
+
detail: `CI green for ${short} (${hits.map((r) => r.workflowName).join(', ')})` };
|
|
41
|
+
}
|
|
42
|
+
return { ok: false, reason: 'absent',
|
|
43
|
+
detail: `no CI run found for HEAD or any of its ${commits.length} most recent ancestors. `
|
|
44
|
+
+ 'Push the branch and let CI run, or override.' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Repos to gate, deduped by path. Images sourced from a remote git ref are
|
|
48
|
+
// skipped: their code lives in another repository and its CI is not this
|
|
49
|
+
// repo's to read. That is a real hole -- see README -- not an oversight.
|
|
50
|
+
function gatedRepos(units) {
|
|
51
|
+
const out = new Map();
|
|
52
|
+
for (const u of units) {
|
|
53
|
+
if (!u.defaults || !u.defaults.ciGate) continue;
|
|
54
|
+
if (u.image && u.image.source && u.image.source.git) continue;
|
|
55
|
+
if (!out.has(u.repoPath)) out.set(u.repoPath, u.repo);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function assertCiGreen(units, { dryRun = false, allowDirty = false, skipCiCheck = false, deps, log = console.error } = {}) {
|
|
61
|
+
const d = { ...real, ...deps };
|
|
62
|
+
const repos = gatedRepos(units);
|
|
63
|
+
if (!repos.size) return;
|
|
64
|
+
if (dryRun) return;
|
|
65
|
+
// Running inside GitHub Actions, the gate would be checking the very run that
|
|
66
|
+
// invoked it -- always "pending", a guaranteed deadlock. CI reaching dray at
|
|
67
|
+
// all means the pipeline already decided to deploy.
|
|
68
|
+
if (process.env.GITHUB_ACTIONS === 'true') return;
|
|
69
|
+
if (skipCiCheck) {
|
|
70
|
+
for (const [, name] of repos) log(`!! CI gate SKIPPED for ${name} (--skip-ci-check) -- deploying unverified code`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const [repoPath, name] of repos) {
|
|
74
|
+
if (allowDirty && await d.git.isDirty(repoPath)) {
|
|
75
|
+
throw new Error(`${name}: working tree is dirty, so no CI run can exist for what you are about to ship.\n`
|
|
76
|
+
+ ' Commit and push it, or pass --skip-ci-check to override.');
|
|
77
|
+
}
|
|
78
|
+
let commits, runs;
|
|
79
|
+
try {
|
|
80
|
+
[commits, runs] = await Promise.all([d.gh.recentCommits(repoPath), d.gh.recentRuns(repoPath)]);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// A gh that cannot answer is not a green suite. Say it in the gate's own
|
|
83
|
+
// voice: a bare "gh CLI not authenticated" reads like an unrelated tool
|
|
84
|
+
// failure rather than like the deploy having been stopped on purpose.
|
|
85
|
+
throw new Error(`${name}: CI gate could not check CI, so it is refusing to deploy.\n ${err.message}`
|
|
86
|
+
+ '\n Fix that, or pass --skip-ci-check to deploy without the check.');
|
|
87
|
+
}
|
|
88
|
+
const verdict = evaluate({ commits, runs });
|
|
89
|
+
if (!verdict.ok) {
|
|
90
|
+
throw new Error(`${name}: CI gate blocked this deploy.\n ${verdict.detail}`
|
|
91
|
+
+ (verdict.url ? `\n ${verdict.url}` : '')
|
|
92
|
+
+ '\n Override with --skip-ci-check if you must ship anyway.');
|
|
93
|
+
}
|
|
94
|
+
log(`✓ ${name}: ${verdict.detail}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { evaluate, gatedRepos, assertCiGreen };
|
package/src/core/engine.js
CHANGED
|
@@ -52,25 +52,25 @@ async function execute(steps, { dryRun = false, allowDirty = false, deps } = {})
|
|
|
52
52
|
const vars = {}; for (const s of u.stamp) vars[s.var] = `${s.repoUri}:${sha}`;
|
|
53
53
|
const files = d.render.renderManifests(u.manifests, vars, u.repoPath, { dryRun });
|
|
54
54
|
if (files[0]) renderDirs.push(path.dirname(files[0]));
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// Applying may or may not change the pod template; the rollout step below decides
|
|
56
|
+
// whether a restart is needed by asking the CLUSTER, not by us telling it from here.
|
|
57
|
+
// A flag set in this step cannot reach a separate `dray rollout` invocation.
|
|
58
|
+
for (const f of files) await d.kubectl.applyFile({ file: f, context: u.defaults.context, namespace: u.defaults.namespace, dryRun });
|
|
59
|
+
} else if (step.kind === 'rollout') {
|
|
60
|
+
// If the controller is already rolling this deployment -- because an apply just
|
|
61
|
+
// changed the pod template, in THIS process or a previous `dray apply` invocation --
|
|
62
|
+
// then `rollout restart` would start a second one, producing a second ReplicaSet and
|
|
63
|
+
// replacing every pod twice. Wait for the one in flight instead.
|
|
60
64
|
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
u.
|
|
65
|
-
if (!dryRun && u.workload && u.kind !== 'cronjob') {
|
|
65
|
+
// Conservative on doubt (dry run, cronjob, unreadable deployment): keep restarting,
|
|
66
|
+
// because a skipped restart silently does nothing at all.
|
|
67
|
+
let inFlight = false;
|
|
68
|
+
if (!dryRun && u.kind !== 'cronjob') {
|
|
66
69
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} catch { u._applyStartsRollout = false; }
|
|
70
|
+
inFlight = await d.kubectl.rolloutInProgress({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace });
|
|
71
|
+
} catch { inFlight = false; }
|
|
70
72
|
}
|
|
71
|
-
|
|
72
|
-
} else if (step.kind === 'rollout') {
|
|
73
|
-
try { await d.kubectl.rollout({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun, restart: !u._applyStartsRollout }); }
|
|
73
|
+
try { await d.kubectl.rollout({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun, restart: !inFlight }); }
|
|
74
74
|
catch (err) { await d.kubectl.rolloutUndo({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun }); throw err; }
|
|
75
75
|
}
|
|
76
76
|
}
|
package/src/core/resolve.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const KEYS = ['profile', 'region', 'account', 'platform', 'context', 'namespace'];
|
|
1
|
+
const KEYS = ['profile', 'region', 'account', 'platform', 'context', 'namespace', 'ciGate'];
|
|
2
2
|
const upperSnake = (s) => s.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
|
|
3
3
|
function imageVar(image) { return image.templateVar || `${upperSnake(image.name)}_IMAGE`; }
|
|
4
4
|
function mergeDefaults(globalDefaults = {}, repoCfg = {}, entry = {}) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
|
|
2
|
+
|
|
3
|
+
// Runs for the last `limit` workflow executions in this repo, newest first.
|
|
4
|
+
// One call for the whole repo rather than one per commit: the gate walks back
|
|
5
|
+
// through history looking for the newest CI-covered ancestor, and N lookups
|
|
6
|
+
// would be N round trips to the API.
|
|
7
|
+
async function recentRuns(cwd, limit = 100) {
|
|
8
|
+
let code, stdout, stderr;
|
|
9
|
+
try {
|
|
10
|
+
({ code, stdout, stderr } = await _run('gh',
|
|
11
|
+
['run', 'list', '--limit', String(limit), '--json', 'headSha,status,conclusion,workflowName,url'],
|
|
12
|
+
{ cwd, capture: true, allowFail: true }));
|
|
13
|
+
} catch (err) {
|
|
14
|
+
// spawn() itself failed -- with no `gh` on PATH the process never starts, so
|
|
15
|
+
// there is no exit code or stderr to inspect below.
|
|
16
|
+
if (/ENOENT/i.test(err.message)) throw new Error('gh CLI not installed (brew install gh)');
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
if (code !== 0) {
|
|
20
|
+
const msg = (stderr || '').trim();
|
|
21
|
+
// Distinguish "gh cannot answer" from "gh says no". A missing binary, an
|
|
22
|
+
// expired token or a repo with Actions disabled must not read as a green
|
|
23
|
+
// suite -- the caller turns this into a block, not a pass.
|
|
24
|
+
if (/not found|ENOENT/i.test(msg)) throw new Error('gh CLI not installed (brew install gh)');
|
|
25
|
+
if (/auth|login|token/i.test(msg)) throw new Error('gh CLI not authenticated (gh auth login)');
|
|
26
|
+
throw new Error(`gh run list failed: ${msg || `exit ${code}`}`);
|
|
27
|
+
}
|
|
28
|
+
try { return JSON.parse(stdout || '[]'); } catch { throw new Error('gh run list returned unparseable JSON'); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Commit shas from HEAD backwards, newest first.
|
|
32
|
+
async function recentCommits(cwd, limit = 50) {
|
|
33
|
+
const { stdout } = await _run('git', ['rev-list', '-n', String(limit), 'HEAD'], { cwd, capture: true });
|
|
34
|
+
return stdout.trim().split('\n').filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { recentRuns, recentCommits, _withRun };
|
|
@@ -10,6 +10,32 @@ async function rollout({ deployment, context, namespace, dryRun, restart = true
|
|
|
10
10
|
return _run('kubectl', ['rollout', 'status', `deployment/${deployment}`, '--timeout=180s', ...ns(context, namespace)], { dryRun });
|
|
11
11
|
}
|
|
12
12
|
function rolloutUndo({ deployment, context, namespace, dryRun }) { return _run('kubectl', ['rollout', 'undo', `deployment/${deployment}`, ...ns(context, namespace)], { dryRun }); }
|
|
13
|
+
// Is the Deployment controller ALREADY rolling out a change?
|
|
14
|
+
//
|
|
15
|
+
// Whether a rollout is under way is a property of the cluster, not of this process. An
|
|
16
|
+
// earlier design inferred it in the apply step and passed it to the rollout step through a
|
|
17
|
+
// field on the unit -- which works for `dray ship` but silently fails for `dray apply &&
|
|
18
|
+
// dray rollout`, two separate invocations with no shared memory. That is the shape every CI
|
|
19
|
+
// pipeline uses, so every deploy there rolled twice. Ask the cluster instead.
|
|
20
|
+
//
|
|
21
|
+
// Unreadable or absent deployment -> false, i.e. keep restarting. Conservative in the same
|
|
22
|
+
// direction as before: a missed restart does nothing at all, which is the worse failure.
|
|
23
|
+
async function rolloutInProgress({ deployment, context, namespace }) {
|
|
24
|
+
const { code, stdout } = await _run('kubectl', ['get', `deployment/${deployment}`, '-o', 'json', ...ns(context, namespace)], { capture: true, allowFail: true });
|
|
25
|
+
if (code !== 0) return false;
|
|
26
|
+
let d;
|
|
27
|
+
try { d = JSON.parse(stdout); } catch { return false; }
|
|
28
|
+
const gen = d.metadata && d.metadata.generation;
|
|
29
|
+
const obs = d.status && d.status.observedGeneration;
|
|
30
|
+
if (typeof gen !== 'number') return false;
|
|
31
|
+
// The controller has not yet observed the spec we just applied.
|
|
32
|
+
if (gen !== obs) return true;
|
|
33
|
+
const want = (d.spec && typeof d.spec.replicas === 'number') ? d.spec.replicas : 1;
|
|
34
|
+
const updated = (d.status && d.status.updatedReplicas) || 0;
|
|
35
|
+
const available = (d.status && d.status.availableReplicas) || 0;
|
|
36
|
+
// Pods are still converging on the desired template.
|
|
37
|
+
return updated < want || available < want;
|
|
38
|
+
}
|
|
13
39
|
async function runningImage({ workload, kind, context, namespace }) {
|
|
14
40
|
const kindPath = kind === 'cronjob'
|
|
15
41
|
? ['cronjob/' + workload, '-o', 'jsonpath={.spec.jobTemplate.spec.template.spec.containers[0].image}']
|
|
@@ -17,4 +43,4 @@ async function runningImage({ workload, kind, context, namespace }) {
|
|
|
17
43
|
const { stdout } = await _run('kubectl', ['get', ...kindPath, ...ns(context, namespace)], { capture: true, allowFail: true });
|
|
18
44
|
return stdout.trim();
|
|
19
45
|
}
|
|
20
|
-
module.exports = { applyFile, rollout, rolloutUndo, runningImage, _withRun };
|
|
46
|
+
module.exports = { applyFile, rollout, rolloutUndo, runningImage, rolloutInProgress, _withRun };
|