@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/dray",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Convention-driven multi-repo deploy orchestrator (forge for shipping)",
5
5
  "bin": {
6
6
  "dray": "bin/dray.js"
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);
@@ -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
- async function runAction(target, action, { dryRun, allowDirty, all }) {
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 };
@@ -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 };
@@ -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
- // Read the running image BEFORE applying. If it differs from what we
56
- // are about to stamp, this apply changes the pod template and the
57
- // Deployment controller starts a rollout on its own so the rollout
58
- // step that follows must wait rather than restart, or every ship
59
- // produces two ReplicaSets and replaces the pod twice.
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
- // Deliberately conservative: any doubt (dry run, cronjob, unreadable
62
- // deployment) leaves the restart in place, which is the previous
63
- // behaviour.
64
- u._applyStartsRollout = false;
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
- const before = await d.kubectl.runningImage({ workload: u.workload, kind: u.kind, context: u.defaults.context, namespace: u.defaults.namespace });
68
- u._applyStartsRollout = before !== `${u.repoUri}:${sha}`;
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
- for (const f of files) await d.kubectl.applyFile({ file: f, context: u.defaults.context, namespace: u.defaults.namespace, dryRun });
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
  }
@@ -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 };