@brutalsystems/dray 0.1.12 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/dray",
3
- "version": "0.1.12",
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"
@@ -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
  }
@@ -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 };