@brutalsystems/dray 0.1.5 → 0.1.8

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
@@ -60,7 +60,11 @@ Global flags: `--dry-run` (print the plan, run nothing), `--allow-dirty`
60
60
  // optional: inject --build-arg from an env file (e.g. VITE_* from .env.production),
61
61
  // or from a sops-encrypted file decrypted in memory at build time (no plaintext on disk):
62
62
  // "buildArgs": { "sopsEnvFile": "secrets.env", "prefix": "VITE_" }
63
- "buildArgs": { "envFile": ".env.production", "prefix": "VITE_" } },
63
+ "buildArgs": { "envFile": ".env.production", "prefix": "VITE_" },
64
+ // optional: also move a mutable <ecr>:latest tag onto every (non-dirty)
65
+ // push, so workloads that reference <ecr>:latest track the newest build
66
+ // without being re-shipped (e.g. rarely-shipped cronjobs sharing an image).
67
+ "latest": true },
64
68
  // image from another repo (cloned to a tmp dir, built, pushed):
65
69
  { "name": "svc", "ecr": "svc", "source": { "git": "https://github.com/org/svc", "ref": "main" } }
66
70
  ],
@@ -91,3 +95,12 @@ Manifests reference managed images by a placeholder var — `${APP_IMAGE}`
91
95
  substitutes `<ecr>:<gitSHA>` for that var across the listed files (container
92
96
  image, env-var image refs, cronjob images — uniformly). A raw `kubectl apply`
93
97
  of an unrendered manifest fails loud, so applies always go through dray.
98
+
99
+ An image with `"latest": true` additionally gets a mutable `<ecr>:latest` tag
100
+ moved onto each non-dirty push. This is for the opposite need: a workload that
101
+ should track the newest build of a shared image *without* being re-shipped
102
+ (e.g. a low-frequency cronjob sharing an image with a frequently-shipped
103
+ service). Reference `<ecr>:latest` literally in that manifest (with
104
+ `imagePullPolicy: Always`) instead of the `${..._IMAGE}` placeholder. You trade
105
+ away per-commit reproducibility/rollback for that workload — use it only where
106
+ that's the point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/dray",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
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
@@ -3,6 +3,7 @@ async function run(argv) {
3
3
  if (argv.length <= 2) { await require('./menu').menu(); return; }
4
4
  const program = new Command();
5
5
  program.name('dray').description('Convention-driven multi-repo deploy orchestrator');
6
+ program.version(require('../../package.json').version, '-v, --version', 'show dray version');
6
7
  program.option('--dry-run', 'print planned commands, run nothing');
7
8
  program.option('--allow-dirty', 'allow building from a dirty tree (tags :<sha>-dirty)');
8
9
  for (const c of ['version', 'init', 'add', 'remove', 'reload', 'list',
@@ -1,8 +1,26 @@
1
1
  const fs = require('node:fs'); const path = require('node:path'); const { REGISTRY } = require('../constants');
2
+ const { loadRepoConfig } = require('./repo');
2
3
  function loadRegistry(file = REGISTRY) { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; }
3
4
  function saveRegistry(file = REGISTRY, reg = {}) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(reg, null, 2)); }
4
5
  function addRepo(file, { name, path: p, config }) { const r = loadRegistry(file); r[name] = { path: p, config, addedAt: new Date().toISOString() }; saveRegistry(file, r); return r[name]; }
5
6
  function getRepo(file, name) { return loadRegistry(file)[name]; }
6
7
  function removeRepo(file, name) { const r = loadRegistry(file); delete r[name]; saveRegistry(file, r); }
7
- function allRepos(file) { return loadRegistry(file); }
8
+ // The registry caches a copy of each repo's config. Auto-reload it when the
9
+ // live .dray/config.json is newer than that snapshot, so edits take effect
10
+ // without a manual `dray reload`. Falls back to the snapshot when the repo
11
+ // isn't checked out here (stat throws) or its config is momentarily invalid
12
+ // (loadRepoConfig throws) — a broken repo must not break `list`/`--all`.
13
+ function refreshEntry(entry) {
14
+ try {
15
+ const mtime = Math.floor(fs.statSync(path.join(entry.path, '.dray', 'config.json')).mtimeMs);
16
+ if (mtime <= (Date.parse(entry.addedAt) || 0)) return entry;
17
+ return { ...entry, config: loadRepoConfig(entry.path), addedAt: new Date(mtime).toISOString() };
18
+ } catch { return entry; }
19
+ }
20
+ function allRepos(file = REGISTRY) {
21
+ const reg = loadRegistry(file); let changed = false;
22
+ for (const [n, e] of Object.entries(reg)) { const f = refreshEntry(e); if (f !== e) { reg[n] = f; changed = true; } }
23
+ if (changed) saveRegistry(file, reg);
24
+ return reg;
25
+ }
8
26
  module.exports = { loadRegistry, saveRegistry, addRepo, getRepo, removeRepo, allRepos };
@@ -1,6 +1,18 @@
1
1
  const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto');
2
+ const { execFileSync } = require('node:child_process');
2
3
  const { DRAY_HOME } = require('../constants');
3
- function needsDepsRebuild(image, repoPath, repo, stateDir = path.join(DRAY_HOME, 'depshash')) {
4
+
5
+ // Whether the deps base image tag exists in the local Docker image store. A
6
+ // `docker system prune` can evict it while the lockfiles are unchanged — the hash
7
+ // check alone would then skip the rebuild and the main build fails at
8
+ // `FROM <depsImage.tag>: not found`.
9
+ function depsImagePresent(tag) {
10
+ try { execFileSync('docker', ['image', 'inspect', tag], { stdio: 'ignore' }); return true; }
11
+ catch { return false; }
12
+ }
13
+
14
+ // `imagePresent` is injectable for tests; defaults to the real docker check.
15
+ function needsDepsRebuild(image, repoPath, repo, stateDir = path.join(DRAY_HOME, 'depshash'), imagePresent = depsImagePresent) {
4
16
  if (!image.depsImage) return false;
5
17
  const h = crypto.createHash('sha256');
6
18
  for (const rel of image.depsImage.rebuildOn || []) {
@@ -11,7 +23,9 @@ function needsDepsRebuild(image, repoPath, repo, stateDir = path.join(DRAY_HOME,
11
23
  fs.mkdirSync(stateDir, { recursive: true });
12
24
  const file = path.join(stateDir, `${repo}__${image.name}.depshash`);
13
25
  const prev = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
14
- if (prev === digest) return false;
26
+ // Rebuild when the lockfiles changed OR the cached deps image tag is gone.
27
+ const present = !image.depsImage.tag || imagePresent(image.depsImage.tag);
28
+ if (prev === digest && present) return false;
15
29
  fs.writeFileSync(file, digest); return true;
16
30
  }
17
- module.exports = { needsDepsRebuild };
31
+ module.exports = { needsDepsRebuild, depsImagePresent };
@@ -42,7 +42,7 @@ async function execute(steps, { dryRun = false, allowDirty = false, deps } = {})
42
42
  else if (step.kind === 'build') await d.docker.buildImage({ ecrUri: u.repoUri, sha, dockerfile: u.image.dockerfile, context: u.image.context || '.', platform: u.defaults.platform, cwd, dryRun, buildArgs: await d.buildArgs.resolveBuildArgs(u.image, cwd, { dryRun }) });
43
43
  else if (step.kind === 'push') {
44
44
  if (!loggedIn) { await d.docker.ecrLogin({ account: u.defaults.account, region: u.defaults.region, profile: u.defaults.profile, dryRun }); await d.docker.ensureRepo({ ecr: u.image.ecr, account: u.defaults.account, region: u.defaults.region, profile: u.defaults.profile, dryRun }); loggedIn = true; }
45
- await d.docker.pushImage({ ecrUri: u.repoUri, sha, dryRun });
45
+ await d.docker.pushImage({ ecrUri: u.repoUri, sha, latest: !!u.image.latest, dryRun });
46
46
  } else if (step.kind === 'apply') {
47
47
  const vars = {}; for (const s of u.stamp) vars[s.var] = `${s.repoUri}:${sha}`;
48
48
  const files = d.render.renderManifests(u.manifests, vars, u.repoPath, { dryRun });
@@ -6,7 +6,17 @@ function buildImage({ ecrUri, sha, dockerfile, context, platform, cwd, dryRun, b
6
6
  args.push('--load', context);
7
7
  return _run('docker', args, { cwd, dryRun });
8
8
  }
9
- function pushImage({ ecrUri, sha, dryRun }) { return _run('docker', ['push', `${ecrUri}:${sha}`], { dryRun }); }
9
+ // Push the immutable :<sha> tag. When the image opts into `latest: true`, also
10
+ // move a mutable :latest tag onto this build so workloads that reference
11
+ // <ecr>:latest (e.g. rarely-shipped cronjobs) track the newest build without
12
+ // being re-shipped. Never advance :latest for a dirty (`-dirty`) build.
13
+ async function pushImage({ ecrUri, sha, latest = false, dryRun }) {
14
+ await _run('docker', ['push', `${ecrUri}:${sha}`], { dryRun });
15
+ if (latest && !String(sha).endsWith('-dirty')) {
16
+ await _run('docker', ['tag', `${ecrUri}:${sha}`, `${ecrUri}:latest`], { dryRun });
17
+ await _run('docker', ['push', `${ecrUri}:latest`], { dryRun });
18
+ }
19
+ }
10
20
  function buildDeps(image, cwd, dryRun) {
11
21
  const d = image.depsImage;
12
22
  return _run('docker', ['buildx', 'build', '--platform', 'linux/arm64', '-f', d.dockerfile, '-t', d.tag, '--load', '.'], { cwd, dryRun });