@brutalsystems/dray 0.1.0

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 ADDED
@@ -0,0 +1,91 @@
1
+ # dray
2
+
3
+ Convention-driven multi-repo deploy orchestrator — "forge, but for shipping."
4
+
5
+ `dray` builds/pushes container images to ECR, renders + applies k8s manifests
6
+ (pinning a git SHA into image references), rolls out workloads, publishes Piral
7
+ pilets, and syncs secrets — driven by a `.dray/config.json` committed in each
8
+ repo. One entry point, interactive or scripted; no daemon.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm i -g @brutalsystems/dray
14
+ ```
15
+
16
+ ## Setup (per machine)
17
+
18
+ 1. Global defaults — `~/.dray/config.json`:
19
+ ```json
20
+ {
21
+ "defaults": {
22
+ "profile": "<aws-profile>", "region": "<region>", "account": "<acct-id>",
23
+ "platform": "linux/arm64", "context": "<kube-context>", "namespace": "<namespace>"
24
+ }
25
+ }
26
+ ```
27
+ 2. Register each repo: `dray add /path/to/repo` (reads its `.dray/config.json`;
28
+ registry is stored at `~/.dray/registry.json`).
29
+
30
+ Requires `docker buildx`, `kubectl`, `git`, `aws`, and (for pilets/secrets) `sops`.
31
+
32
+ ## Commands
33
+
34
+ ```bash
35
+ dray # interactive menu
36
+ dray list # registered repos + targets
37
+ dray ship <repo>:<target> # deps?→build→push(SHA)→render+apply→rollout
38
+ dray ship <repo> # all enabled workloads in the repo
39
+ dray apply <repo>:<target> # render + apply manifests only
40
+ dray rollout <repo>:<target>
41
+ dray status <repo> # running image SHA vs HEAD
42
+ dray rollback <repo>:<target> <sha>
43
+ dray publish <repo>[:<pilet>] # publish pilet(s) via sops exec-env
44
+ dray secrets sync <repo>
45
+ ```
46
+
47
+ Global flags: `--dry-run` (print the plan, run nothing), `--allow-dirty`
48
+ (build a dirty tree, tags `:<sha>-dirty`), `--all` (every registered repo).
49
+
50
+ ## `.dray/config.json` (per repo)
51
+
52
+ ```jsonc
53
+ {
54
+ "name": "myrepo",
55
+ "images": [
56
+ { "name": "app", "ecr": "app", "source": { "local": true },
57
+ "dockerfile": "Dockerfile", "context": ".",
58
+ // optional: rebuild a cached deps layer when lockfiles change
59
+ "depsImage": { "dockerfile": "Dockerfile.deps", "tag": "app:deps", "rebuildOn": ["uv.lock"] },
60
+ // optional: inject --build-arg from an env file (e.g. VITE_* from .env.production)
61
+ "buildArgs": { "envFile": ".env.production", "prefix": "VITE_" } },
62
+ // image from another repo (cloned to a tmp dir, built, pushed):
63
+ { "name": "svc", "ecr": "svc", "source": { "git": "https://github.com/org/svc", "ref": "main" } }
64
+ ],
65
+ "workloads": [
66
+ // kind: deployment (rolled out) or cronjob (applied only)
67
+ { "name": "app", "kind": "deployment", "image": "app",
68
+ "manifests": [".k8s/deployment.yaml", ".k8s/service.yaml"], "stampImages": ["app"] },
69
+ // disabled: kept in config but skipped (e.g. served elsewhere)
70
+ { "name": "legacy", "kind": "deployment", "image": "app", "manifests": ["..."], "disabled": true },
71
+ // image-less: apply-only (third-party image lives literally in the manifest)
72
+ { "name": "searxng", "kind": "deployment", "manifests": [".k8s/searxng.yaml"] }
73
+ ],
74
+ "secrets": [
75
+ { "name": "app-secrets", "kind": "sops-manifest", "file": ".k8s/secrets.enc.yaml" }
76
+ ],
77
+ "pilets": {
78
+ "secretsFile": "secrets.env",
79
+ "command": ["npm", "run", "publish:feed", "--", "--pilet", "{name}"],
80
+ "names": ["foo", "bar"]
81
+ }
82
+ }
83
+ ```
84
+
85
+ ### Image pinning
86
+
87
+ Manifests reference managed images by a placeholder var — `${APP_IMAGE}`
88
+ (derived `<UPPER_SNAKE(name)>_IMAGE`, or set `templateVar`). On apply, dray
89
+ substitutes `<ecr>:<gitSHA>` for that var across the listed files (container
90
+ image, env-var image refs, cronjob images — uniformly). A raw `kubectl apply`
91
+ of an unrendered manifest fails loud, so applies always go through dray.
package/bin/dray.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../src/cli').run(process.argv).catch((e) => { console.error(e.message || e); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@brutalsystems/dray",
3
+ "version": "0.1.0",
4
+ "description": "Convention-driven multi-repo deploy orchestrator (forge for shipping)",
5
+ "bin": { "dray": "bin/dray.js" },
6
+ "files": ["bin", "src", "README.md"],
7
+ "scripts": { "test": "node --test" },
8
+ "engines": { "node": ">=20" },
9
+ "publishConfig": { "access": "public" },
10
+ "license": "UNLICENSED",
11
+ "repository": { "type": "git", "url": "git+https://github.com/BrutalSystems/dray.git" },
12
+ "dependencies": { "commander": "^12.0.0" }
13
+ }
@@ -0,0 +1,7 @@
1
+ const { loadRepoConfig } = require('../../config/repo'); const { addRepo } = require('../../config/registry');
2
+ module.exports = function registerAdd(program) {
3
+ program.command('add [repoPath]').description('Register a repo').action((repoPath) => {
4
+ const p = repoPath || process.cwd(); const config = loadRepoConfig(p);
5
+ addRepo(undefined, { name: config.name, path: p, config }); console.log(`registered ${config.name} → ${p}`);
6
+ });
7
+ };
@@ -0,0 +1,6 @@
1
+ const { runAction } = require('../run-action');
2
+ module.exports = function registerApply(program) {
3
+ program.command('apply [target]').description('Render + apply manifests')
4
+ .option('--all', 'every registered repo')
5
+ .action((target, opts, cmd) => runAction(target, 'apply', { ...cmd.optsWithGlobals(), all: opts.all }));
6
+ };
@@ -0,0 +1,6 @@
1
+ const { runAction } = require('../run-action');
2
+ module.exports = function registerBuild(program) {
3
+ program.command('build [target]').description('Build image(s)')
4
+ .option('--all', 'every registered repo')
5
+ .action((target, opts, cmd) => runAction(target, 'build', { ...cmd.optsWithGlobals(), all: opts.all }));
6
+ };
@@ -0,0 +1,14 @@
1
+ const fs = require('node:fs'); const path = require('node:path');
2
+ module.exports = function registerInit(program) {
3
+ program.command('init').description('Scaffold .dray/config.json').action(() => {
4
+ const dir = path.join(process.cwd(), '.dray'); fs.mkdirSync(dir, { recursive: true });
5
+ const file = path.join(dir, 'config.json'); if (fs.existsSync(file)) return console.log('.dray/config.json exists');
6
+ fs.writeFileSync(file, JSON.stringify({
7
+ name: path.basename(process.cwd()),
8
+ images: [{ name: 'app', ecr: 'app', source: { local: true }, dockerfile: 'Dockerfile', context: '.' }],
9
+ workloads: [{ name: 'app', kind: 'deployment', image: 'app', manifests: ['.k8s/deployment.yaml'] }],
10
+ secrets: [], pilets: [],
11
+ }, null, 2));
12
+ console.log(`wrote ${file}`);
13
+ });
14
+ };
@@ -0,0 +1,9 @@
1
+ const { allRepos } = require('../../config/registry');
2
+ module.exports = function registerList(program) {
3
+ program.command('list').description('List repos + workloads').action(() => {
4
+ for (const [n, e] of Object.entries(allRepos())) {
5
+ const w = (e.config.workloads || []).map((x) => `${x.name}(${x.kind})`).join(', ') || '(none)';
6
+ console.log(`${n} [${w}] ${e.path}`);
7
+ }
8
+ });
9
+ };
@@ -0,0 +1,19 @@
1
+ const { allRepos } = require('../../config/registry'); const { publish } = require('../../primitives/pilet');
2
+ module.exports = function registerPublish(program) {
3
+ program.command('publish <target>').description('Publish pilet(s): "<repo>" for all, or "<repo>:<pilet>" for one')
4
+ .action(async (target, opts, cmd) => {
5
+ const dryRun = cmd.optsWithGlobals().dryRun;
6
+ const [repo, name] = target.split(':');
7
+ const entry = allRepos()[repo]; if (!entry) throw new Error(`unknown repo "${repo}"`);
8
+ const pilets = entry.config.pilets;
9
+ if (!pilets || !Array.isArray(pilets.names) || !pilets.names.length) {
10
+ throw new Error(`no pilets configured in ${repo}`);
11
+ }
12
+ const names = name ? [name] : pilets.names;
13
+ for (const n of names) {
14
+ if (!pilets.names.includes(n)) throw new Error(`unknown pilet "${n}" in ${repo}`);
15
+ const command = pilets.command.map((part) => part.split('{name}').join(n));
16
+ await publish({ repoPath: entry.path, secretsFile: pilets.secretsFile, publishCommand: command, dryRun });
17
+ }
18
+ });
19
+ };
@@ -0,0 +1,6 @@
1
+ const { runAction } = require('../run-action');
2
+ module.exports = function registerPush(program) {
3
+ program.command('push [target]').description('Build + push image(s)')
4
+ .option('--all', 'every registered repo')
5
+ .action((target, opts, cmd) => runAction(target, 'push', { ...cmd.optsWithGlobals(), all: opts.all }));
6
+ };
@@ -0,0 +1,8 @@
1
+ const { loadRepoConfig } = require('../../config/repo'); const { allRepos, addRepo } = require('../../config/registry');
2
+ module.exports = function registerReload(program) {
3
+ program.command('reload [repo]').description('Re-read config').action((repo) => {
4
+ const reg = allRepos(); for (const n of (repo ? [repo] : Object.keys(reg))) {
5
+ addRepo(undefined, { name: n, path: reg[n].path, config: loadRepoConfig(reg[n].path) }); console.log(`reloaded ${n}`);
6
+ }
7
+ });
8
+ };
@@ -0,0 +1,4 @@
1
+ const { removeRepo } = require('../../config/registry');
2
+ module.exports = function registerRemove(program) {
3
+ program.command('remove <repo>').description('Unregister a repo').action((r) => { removeRepo(undefined, r); console.log(`removed ${r}`); });
4
+ };
@@ -0,0 +1,15 @@
1
+ const { loadGlobalConfig } = require('../../config/global'); const { allRepos } = require('../../config/registry');
2
+ const { resolveTargets } = require('../../core/resolve'); const { renderManifests } = require('../../core/render'); const { applyFile } = require('../../primitives/kubectl');
3
+ module.exports = function registerRollback(program) {
4
+ program.command('rollback <target> <sha>').description('Re-render + apply a prior SHA')
5
+ .action(async (target, sha, opts, cmd) => {
6
+ const dryRun = cmd.optsWithGlobals().dryRun; const { defaults } = loadGlobalConfig();
7
+ const units = resolveTargets({ registry: allRepos(), globalDefaults: defaults, spec: target }).filter((u) => u.manifests.length);
8
+ for (const u of units) {
9
+ const vars = {}; for (const s of u.stamp) vars[s.var] = `${s.repoUri}:${sha}`;
10
+ for (const f of renderManifests(u.manifests, vars, u.repoPath, { dryRun }))
11
+ await applyFile({ file: f, context: u.defaults.context, namespace: u.defaults.namespace, dryRun });
12
+ console.log(`rolled ${u.repo}:${u.workload || u.image.name} → ${sha}`);
13
+ }
14
+ });
15
+ };
@@ -0,0 +1,6 @@
1
+ const { runAction } = require('../run-action');
2
+ module.exports = function registerRollout(program) {
3
+ program.command('rollout [target]').description('Restart + wait deployments')
4
+ .option('--all', 'every registered repo')
5
+ .action((target, opts, cmd) => runAction(target, 'rollout', { ...cmd.optsWithGlobals(), all: opts.all }));
6
+ };
@@ -0,0 +1,14 @@
1
+ const { loadGlobalConfig } = require('../../config/global'); const { allRepos } = require('../../config/registry');
2
+ const { mergeDefaults } = require('../../core/resolve'); const { syncSecret } = require('../../primitives/secrets');
3
+ module.exports = function registerSecrets(program) {
4
+ const grp = program.command('secrets').description('Secrets operations');
5
+ grp.command('sync <repo> [name]').description('Sync declared secrets')
6
+ .action(async (repo, name, opts, c) => {
7
+ const dryRun = c.optsWithGlobals().dryRun; const entry = allRepos()[repo]; if (!entry) throw new Error(`unknown repo "${repo}"`);
8
+ const { defaults } = loadGlobalConfig(); const d = mergeDefaults(defaults, entry.config, {});
9
+ for (const s of (entry.config.secrets || []).filter((s) => !name || s.name === name)) {
10
+ await syncSecret(s, { repoPath: entry.path, context: d.context, namespace: d.namespace, profile: d.profile, dryRun });
11
+ console.log(`synced ${s.name}`);
12
+ }
13
+ });
14
+ };
@@ -0,0 +1,6 @@
1
+ const { runAction } = require('../run-action');
2
+ module.exports = function registerShip(program) {
3
+ program.command('ship [target]').description('Build + push + apply + rollout a target')
4
+ .option('--all', 'every registered repo')
5
+ .action((target, opts, cmd) => runAction(target, 'ship', { ...cmd.optsWithGlobals(), all: opts.all }));
6
+ };
@@ -0,0 +1,15 @@
1
+ const { loadGlobalConfig } = require('../../config/global'); const { allRepos } = require('../../config/registry');
2
+ const { resolveTargets } = require('../../core/resolve'); const { runningImage } = require('../../primitives/kubectl'); const { currentSha } = require('../../primitives/git');
3
+ module.exports = function registerStatus(program) {
4
+ program.command('status [target]').description('Running image SHA vs HEAD').option('--all', 'all repos')
5
+ .action(async (target, opts) => {
6
+ const { defaults } = loadGlobalConfig();
7
+ const units = resolveTargets({ registry: allRepos(), globalDefaults: defaults, spec: opts.all || !target ? '--all' : target }).filter((u) => u.workload);
8
+ for (const u of units) {
9
+ const live = await runningImage({ workload: u.workload, kind: u.kind, context: u.defaults.context, namespace: u.defaults.namespace });
10
+ const head = await currentSha(u.repoPath).catch(() => '?');
11
+ const tag = live.includes(':') ? live.split(':').pop() : '(none)';
12
+ console.log(`${u.repo}:${u.workload} running ${tag} ${tag === head ? 'up-to-date' : `behind (HEAD ${head})`}`);
13
+ }
14
+ });
15
+ };
@@ -0,0 +1,4 @@
1
+ const { version } = require('../../../package.json');
2
+ module.exports = function registerVersion(program) {
3
+ program.command('version').description('Show dray version').action(() => console.log(`dray ${version}`));
4
+ };
@@ -0,0 +1,14 @@
1
+ const { Command } = require('commander');
2
+ async function run(argv) {
3
+ if (argv.length <= 2) { await require('./menu').menu(); return; }
4
+ const program = new Command();
5
+ program.name('dray').description('Convention-driven multi-repo deploy orchestrator');
6
+ program.option('--dry-run', 'print planned commands, run nothing');
7
+ program.option('--allow-dirty', 'allow building from a dirty tree (tags :<sha>-dirty)');
8
+ for (const c of ['version', 'init', 'add', 'remove', 'reload', 'list',
9
+ 'build', 'push', 'apply', 'rollout', 'ship', 'status', 'rollback', 'publish', 'secrets']) {
10
+ require(`./commands/${c}`)(program);
11
+ }
12
+ await program.parseAsync(argv);
13
+ }
14
+ module.exports = { run };
@@ -0,0 +1,20 @@
1
+ const readline = require('node:readline'); const { allRepos } = require('../config/registry'); const { runAction } = require('./run-action');
2
+ function buildMenuTree(registry) {
3
+ return {
4
+ repos: Object.entries(registry).map(([name, e]) => ({ name, targets: (e.config.workloads || []).map((w) => ({ label: w.name, spec: `${name}:${w.name}` })) })),
5
+ actions: ['ship', 'build', 'apply', 'rollout', 'status'],
6
+ };
7
+ }
8
+ function defaultIo() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return { question: (q) => new Promise((r) => rl.question(q, r)), close: () => rl.close() }; }
9
+ async function menu(io = defaultIo()) {
10
+ const t = buildMenuTree(allRepos());
11
+ t.repos.forEach((r, i) => console.log(`${i + 1}) ${r.name}`));
12
+ const repo = t.repos[parseInt(await io.question('repo #: '), 10) - 1]; if (!repo) return io.close();
13
+ repo.targets.forEach((x, i) => console.log(`${i + 1}) ${x.label}`));
14
+ const target = repo.targets[parseInt(await io.question('target #: '), 10) - 1];
15
+ t.actions.forEach((a, i) => console.log(`${i + 1}) ${a}`));
16
+ const action = t.actions[parseInt(await io.question('action #: '), 10) - 1];
17
+ const ok = (await io.question(`${action} ${target.spec}? [y/N] `)).trim().toLowerCase() === 'y';
18
+ io.close(); if (ok) await runAction(target.spec, action, { dryRun: false });
19
+ }
20
+ module.exports = { buildMenuTree, menu };
@@ -0,0 +1,9 @@
1
+ const { loadGlobalConfig } = require('../config/global'); const { allRepos } = require('../config/registry');
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 }) {
4
+ const registry = allRepos(); const { defaults } = loadGlobalConfig();
5
+ const spec = all ? '--all' : target;
6
+ const units = resolveTargets({ registry, globalDefaults: defaults, spec });
7
+ await execute(planFor(units, action), { dryRun, allowDirty });
8
+ }
9
+ module.exports = { runAction };
@@ -0,0 +1,4 @@
1
+ const fs = require('node:fs'); const { GLOBAL_CONFIG } = require('../constants');
2
+ function parseGlobalConfig(raw) { if (!raw) return { defaults: {} }; const o = JSON.parse(raw); return { defaults: o.defaults || {} }; }
3
+ function loadGlobalConfig() { return parseGlobalConfig(fs.existsSync(GLOBAL_CONFIG) ? fs.readFileSync(GLOBAL_CONFIG, 'utf8') : null); }
4
+ module.exports = { parseGlobalConfig, loadGlobalConfig };
@@ -0,0 +1,8 @@
1
+ const fs = require('node:fs'); const path = require('node:path'); const { REGISTRY } = require('../constants');
2
+ function loadRegistry(file = REGISTRY) { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; }
3
+ function saveRegistry(file = REGISTRY, reg = {}) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(reg, null, 2)); }
4
+ 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
+ function getRepo(file, name) { return loadRegistry(file)[name]; }
6
+ function removeRepo(file, name) { const r = loadRegistry(file); delete r[name]; saveRegistry(file, r); }
7
+ function allRepos(file) { return loadRegistry(file); }
8
+ module.exports = { loadRegistry, saveRegistry, addRepo, getRepo, removeRepo, allRepos };
@@ -0,0 +1,32 @@
1
+ const fs = require('node:fs'); const path = require('node:path');
2
+ const WORKLOAD_KINDS = new Set(['deployment', 'cronjob']);
3
+ const SECRET_KINDS = new Set(['sops-manifest', 'literal-from-env', 'sops-exec-env']);
4
+ function loadRepoConfig(repoPath) {
5
+ const file = path.join(repoPath, '.dray', 'config.json');
6
+ if (!fs.existsSync(file)) throw new Error(`no .dray/config.json in ${repoPath}`);
7
+ return validateRepoConfig(JSON.parse(fs.readFileSync(file, 'utf8')));
8
+ }
9
+ function validateRepoConfig(cfg) {
10
+ const e = [];
11
+ if (!cfg.name || typeof cfg.name !== 'string') e.push('name must be a non-empty string');
12
+ const imageNames = new Set();
13
+ for (const img of cfg.images || []) {
14
+ if (!img.name) e.push('image missing name');
15
+ if (!img.ecr) e.push(`image "${img.name}" missing ecr`);
16
+ if (!(img.source && (img.source.local || img.source.git))) e.push(`image "${img.name}" needs source.local or source.git`);
17
+ imageNames.add(img.name);
18
+ }
19
+ for (const w of cfg.workloads || []) {
20
+ if (!w.name) e.push('workload missing name');
21
+ if (!WORKLOAD_KINDS.has(w.kind)) e.push(`workload "${w.name}" kind must be deployment|cronjob`);
22
+ if (w.image !== undefined && !imageNames.has(w.image)) e.push(`workload "${w.name}" references unknown image "${w.image}"`);
23
+ if (!Array.isArray(w.manifests) || w.manifests.length === 0) e.push(`workload "${w.name}" needs a non-empty manifests list`);
24
+ }
25
+ for (const s of cfg.secrets || []) {
26
+ if (!s.name) e.push('secret missing name');
27
+ if (!SECRET_KINDS.has(s.kind)) e.push(`secret "${s.name}" kind must be sops-manifest|literal-from-env|sops-exec-env`);
28
+ }
29
+ if (e.length) throw new Error(`invalid .dray/config.json:\n - ${e.join('\n - ')}`);
30
+ return cfg;
31
+ }
32
+ module.exports = { loadRepoConfig, validateRepoConfig };
@@ -0,0 +1,3 @@
1
+ const os = require('node:os'); const path = require('node:path');
2
+ const DRAY_HOME = path.join(os.homedir(), '.dray');
3
+ module.exports = { DRAY_HOME, GLOBAL_CONFIG: path.join(DRAY_HOME, 'config.json'), REGISTRY: path.join(DRAY_HOME, 'registry.json') };
@@ -0,0 +1,34 @@
1
+ const fs = require('node:fs'); const path = require('node:path');
2
+
3
+ function stripQuotes(v) {
4
+ const t = v.trim();
5
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
6
+ return t.slice(1, -1);
7
+ }
8
+ return t;
9
+ }
10
+
11
+ // Returns ["KEY=VALUE", ...] for `docker build --build-arg`. An image may set
12
+ // `buildArgs: { envFile, prefix }` to inject vars from an env file (e.g. VITE_*
13
+ // from .env.production), and/or `buildArgs.values: { K: V }` for explicit pairs.
14
+ function resolveBuildArgs(image, cwd) {
15
+ const cfg = image && image.buildArgs;
16
+ if (!cfg) return [];
17
+ const out = [];
18
+ if (cfg.envFile) {
19
+ const p = path.join(cwd, cfg.envFile);
20
+ if (fs.existsSync(p)) {
21
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
22
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
23
+ if (!m) continue;
24
+ const [, k, v] = m;
25
+ if (cfg.prefix && !k.startsWith(cfg.prefix)) continue;
26
+ out.push(`${k}=${stripQuotes(v)}`);
27
+ }
28
+ }
29
+ }
30
+ for (const [k, v] of Object.entries(cfg.values || {})) out.push(`${k}=${v}`);
31
+ return out;
32
+ }
33
+
34
+ module.exports = { resolveBuildArgs };
@@ -0,0 +1,17 @@
1
+ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto');
2
+ const { DRAY_HOME } = require('../constants');
3
+ function needsDepsRebuild(image, repoPath, repo, stateDir = path.join(DRAY_HOME, 'depshash')) {
4
+ if (!image.depsImage) return false;
5
+ const h = crypto.createHash('sha256');
6
+ for (const rel of image.depsImage.rebuildOn || []) {
7
+ const p = path.join(repoPath, rel); h.update(rel);
8
+ h.update(fs.existsSync(p) ? fs.readFileSync(p) : Buffer.from('MISSING'));
9
+ }
10
+ const digest = h.digest('hex');
11
+ fs.mkdirSync(stateDir, { recursive: true });
12
+ const file = path.join(stateDir, `${repo}__${image.name}.depshash`);
13
+ const prev = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
14
+ if (prev === digest) return false;
15
+ fs.writeFileSync(file, digest); return true;
16
+ }
17
+ module.exports = { needsDepsRebuild };
@@ -0,0 +1,61 @@
1
+ const fs = require('node:fs'); const path = require('node:path');
2
+ const real = {
3
+ docker: require('../primitives/docker'), kubectl: require('../primitives/kubectl'),
4
+ git: require('../primitives/git'), secrets: require('../primitives/secrets'),
5
+ render: require('./render'), depsCache: require('./depsCache'),
6
+ buildArgs: require('./buildArgs'),
7
+ };
8
+ async function shaForUnit(u, d, cache, dryRun, allowDirty) {
9
+ if (!u.image) return null; // apply-only workload: no image, no sha/build
10
+ const key = `${u.repo}/${u.image.name}`;
11
+ if (cache.has(key)) return cache.get(key);
12
+ let sha;
13
+ if (u.image.source && u.image.source.git) {
14
+ if (dryRun) sha = 'DRYRUN';
15
+ else { const c = await d.git.cloneToTmp(u.image.source); u._clone = c; sha = c.sha; }
16
+ } else if (dryRun) {
17
+ sha = 'DRYRUN';
18
+ } else {
19
+ const dirty = await d.git.isDirty(u.repoPath);
20
+ if (dirty && !allowDirty) throw new Error(`working tree is dirty in ${u.repoPath} (commit, or pass --allow-dirty)`);
21
+ sha = await d.git.currentSha(u.repoPath);
22
+ if (dirty) sha = `${sha}-dirty`;
23
+ }
24
+ cache.set(key, sha);
25
+ return sha;
26
+ }
27
+ async function execute(steps, { dryRun = false, allowDirty = false, deps } = {}) {
28
+ const d = { ...real, ...deps };
29
+ const shaCache = new Map(); const clones = []; const renderDirs = [];
30
+ let loggedIn = false;
31
+ try {
32
+ for (const step of steps) {
33
+ const u = step.unit;
34
+ if (step.kind === 'secret') {
35
+ await d.secrets.syncSecret(step.secret, { repoPath: u.repoPath, context: u.defaults.context, namespace: u.defaults.namespace, profile: u.defaults.profile, dryRun });
36
+ continue;
37
+ }
38
+ const sha = await shaForUnit(u, d, shaCache, dryRun, allowDirty);
39
+ if (u._clone && !clones.includes(u._clone)) clones.push(u._clone);
40
+ const cwd = u._clone ? u._clone.dir : u.repoPath;
41
+ if (step.kind === 'deps') { if (d.depsCache.needsDepsRebuild(u.image, cwd, u.repo)) await d.docker.buildDeps(u.image, cwd, dryRun); }
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: d.buildArgs.resolveBuildArgs(u.image, cwd) });
43
+ else if (step.kind === 'push') {
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 });
46
+ } else if (step.kind === 'apply') {
47
+ const vars = {}; for (const s of u.stamp) vars[s.var] = `${s.repoUri}:${sha}`;
48
+ const files = d.render.renderManifests(u.manifests, vars, u.repoPath, { dryRun });
49
+ if (files[0]) renderDirs.push(path.dirname(files[0]));
50
+ for (const f of files) await d.kubectl.applyFile({ file: f, context: u.defaults.context, namespace: u.defaults.namespace, dryRun });
51
+ } else if (step.kind === 'rollout') {
52
+ try { await d.kubectl.rollout({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun }); }
53
+ catch (err) { await d.kubectl.rolloutUndo({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun }); throw err; }
54
+ }
55
+ }
56
+ } finally {
57
+ for (const c of clones) c.cleanup();
58
+ for (const dir of renderDirs) fs.rmSync(dir, { recursive: true, force: true });
59
+ }
60
+ }
61
+ module.exports = { execute };
@@ -0,0 +1,65 @@
1
+ function uniqueImages(units) {
2
+ const seen = new Map();
3
+ for (const u of units) {
4
+ if (!u.image) continue; // apply-only workload (no image to build/push)
5
+ const key = `${u.repo}/${u.image.name}`;
6
+ if (!seen.has(key)) seen.set(key, u);
7
+ }
8
+ return [...seen.values()];
9
+ }
10
+ const buildSteps = (u) => {
11
+ const s = [];
12
+ if (u.image.depsImage) s.push({ kind: 'deps', label: `deps ${u.image.name}`, unit: u });
13
+ s.push({ kind: 'build', label: `build ${u.image.name}`, unit: u });
14
+ return s;
15
+ };
16
+ const withManifests = (units) => units.filter((u) => u.manifests && u.manifests.length);
17
+ const deployments = (units) => units.filter((u) => u.kind === 'deployment');
18
+ function topoSort(units) {
19
+ const byName = new Map(units.map((u) => [u.workload, u]));
20
+ const out = []; const seen = new Set();
21
+ const visit = (u) => {
22
+ if (!u || seen.has(u.workload)) return; seen.add(u.workload);
23
+ for (const dep of u.dependsOn || []) if (!dep.startsWith('secret:')) visit(byName.get(dep));
24
+ out.push(u);
25
+ };
26
+ for (const u of units) visit(u);
27
+ return out;
28
+ }
29
+ function secretSteps(units) {
30
+ // Repo-scoped dedup (a secret named "mongodb-app" in two repos is two
31
+ // distinct steps). Each step carries the fully-resolved secret object from
32
+ // its OWNING unit — no fragile name lookup / silent fallback in the engine.
33
+ const out = []; const seen = new Set();
34
+ for (const u of units) {
35
+ for (const d of u.dependsOn || []) {
36
+ if (!d.startsWith('secret:')) continue;
37
+ const name = d.slice('secret:'.length);
38
+ const key = `${u.repo}/${name}`;
39
+ if (seen.has(key)) continue;
40
+ seen.add(key);
41
+ const secret = (u._secrets || []).find((s) => s.name === name);
42
+ if (!secret) throw new Error(`${u.repo}: workload "${u.workload}" dependsOn secret "${name}" not declared in secrets[]`);
43
+ out.push({ kind: 'secret', label: `secret ${u.repo}:${name}`, secret, unit: u });
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ function planFor(units, action) {
49
+ const imgs = uniqueImages(units);
50
+ if (action === 'build') return imgs.flatMap(buildSteps);
51
+ if (action === 'push') return imgs.flatMap((u) => [...buildSteps(u), { kind: 'push', label: `push ${u.image.name}`, unit: u }]);
52
+ if (action === 'apply') return withManifests(units).map((u) => ({ kind: 'apply', label: `apply ${u.workload || u.image.name}`, unit: u }));
53
+ if (action === 'rollout') return deployments(units).map((u) => ({ kind: 'rollout', label: `rollout ${u.workload}`, unit: u }));
54
+ if (action === 'ship') {
55
+ const ordered = topoSort(units);
56
+ return [
57
+ ...imgs.flatMap((u) => [...buildSteps(u), { kind: 'push', label: `push ${u.image.name}`, unit: u }]),
58
+ ...secretSteps(ordered),
59
+ ...withManifests(ordered).map((u) => ({ kind: 'apply', label: `apply ${u.workload || u.image.name}`, unit: u })),
60
+ ...deployments(ordered).map((u) => ({ kind: 'rollout', label: `rollout ${u.workload}`, unit: u })),
61
+ ];
62
+ }
63
+ throw new Error(`unknown action "${action}"`);
64
+ }
65
+ module.exports = { planFor };
@@ -0,0 +1,17 @@
1
+ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
2
+ function renderString(text, vars) {
3
+ let out = text;
4
+ for (const [k, v] of Object.entries(vars)) out = out.split(`\${${k}}`).join(v);
5
+ return out;
6
+ }
7
+ function renderManifests(files, vars, cwd, { dryRun } = {}) {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dray-render-'));
9
+ return files.map((rel) => {
10
+ const src = path.join(cwd, rel);
11
+ const rendered = renderString(fs.readFileSync(src, 'utf8'), vars);
12
+ const out = path.join(dir, rel.replace(/[/\\]/g, '__'));
13
+ fs.writeFileSync(out, rendered);
14
+ return out;
15
+ });
16
+ }
17
+ module.exports = { renderString, renderManifests };
@@ -0,0 +1,59 @@
1
+ const KEYS = ['profile', 'region', 'account', 'platform', 'context', 'namespace'];
2
+ const upperSnake = (s) => s.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
3
+ function imageVar(image) { return image.templateVar || `${upperSnake(image.name)}_IMAGE`; }
4
+ function mergeDefaults(globalDefaults = {}, repoCfg = {}, entry = {}) {
5
+ const repo = repoCfg.defaults || {}; const out = {};
6
+ for (const k of KEYS) out[k] = entry[k] ?? repo[k] ?? globalDefaults[k];
7
+ return out;
8
+ }
9
+ function ecrRepoUri(defaults, image) { return `${defaults.account}.dkr.ecr.${defaults.region}.amazonaws.com/${image.ecr}`; }
10
+
11
+ function unitsForRepo(name, entry, globalDefaults, filter) {
12
+ const cfg = entry.config;
13
+ const images = new Map((cfg.images || []).map((i) => [i.name, i]));
14
+ const workloads = cfg.workloads || [];
15
+ const units = [];
16
+ const mk = (image, w) => {
17
+ const defaults = mergeDefaults(globalDefaults, cfg, w || {});
18
+ // A workload may have no image (apply-only, e.g. a third-party image that
19
+ // lives literally in the manifest) — then there's nothing to build or stamp.
20
+ const stampNames = (w && w.stampImages) || (image ? [image.name] : []);
21
+ const stamp = stampNames.map((n) => ({ var: imageVar(images.get(n)), repoUri: ecrRepoUri(defaults, images.get(n)) }));
22
+ units.push({
23
+ repo: name, repoPath: entry.path, image: image || null,
24
+ workload: w ? w.name : null, kind: w ? w.kind : null,
25
+ manifests: w ? w.manifests : [], dependsOn: (w && w.dependsOn) || [],
26
+ defaults, repoUri: image ? ecrRepoUri(defaults, image) : null, stamp,
27
+ _secrets: cfg.secrets || [],
28
+ });
29
+ };
30
+ const want = (w) => !filter || w.name === filter || w.image === filter;
31
+ for (const w of workloads) {
32
+ if (!want(w)) continue;
33
+ if (w.disabled) {
34
+ // Documented in config but not deployed by dray (e.g. endpoints served
35
+ // elsewhere). Skip in bare-repo/--all; error if explicitly targeted by name.
36
+ if (filter && w.name === filter) {
37
+ throw new Error(`workload "${w.name}" is disabled in ${name}'s .dray/config.json`);
38
+ }
39
+ continue;
40
+ }
41
+ mk(images.get(w.image), w);
42
+ }
43
+ for (const img of images.values()) {
44
+ const matches = !filter || img.name === filter;
45
+ const hasWorkload = workloads.some((w) => w.image === img.name);
46
+ if (matches && !hasWorkload) mk(img, null);
47
+ }
48
+ return units;
49
+ }
50
+ function resolveTargets({ registry, globalDefaults, spec }) {
51
+ if (spec === '--all') return Object.entries(registry).flatMap(([n, e]) => unitsForRepo(n, e, globalDefaults, null));
52
+ const [repo, name] = spec.split(':');
53
+ const entry = registry[repo];
54
+ if (!entry) throw new Error(`unknown repo "${repo}" (run: dray add)`);
55
+ const units = unitsForRepo(repo, entry, globalDefaults, name || null);
56
+ if (!units.length) throw new Error(`no target "${spec}" found`);
57
+ return units;
58
+ }
59
+ module.exports = { mergeDefaults, imageVar, ecrRepoUri, resolveTargets };
@@ -0,0 +1,22 @@
1
+ let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
2
+ function buildImage({ ecrUri, sha, dockerfile, context, platform, cwd, dryRun, buildArgs = [] }) {
3
+ const args = ['buildx', 'build', '--platform', platform, '-f', dockerfile, '-t', `${ecrUri}:${sha}`];
4
+ for (const a of buildArgs) args.push('--build-arg', a);
5
+ args.push('--load', context);
6
+ return _run('docker', args, { cwd, dryRun });
7
+ }
8
+ function pushImage({ ecrUri, sha, dryRun }) { return _run('docker', ['push', `${ecrUri}:${sha}`], { dryRun }); }
9
+ function buildDeps(image, cwd, dryRun) {
10
+ const d = image.depsImage;
11
+ return _run('docker', ['buildx', 'build', '--platform', 'linux/arm64', '-f', d.dockerfile, '-t', d.tag, '--load', '.'], { cwd, dryRun });
12
+ }
13
+ function ecrLogin({ account, region, profile, dryRun }) {
14
+ const reg = `${account}.dkr.ecr.${region}.amazonaws.com`; const prof = profile ? `--profile ${profile} ` : '';
15
+ return _run('bash', ['-c', `aws ecr get-login-password --region ${region} ${prof}| docker login --username AWS --password-stdin ${reg}`], { dryRun });
16
+ }
17
+ async function ensureRepo({ ecr, account, region, profile, dryRun }) {
18
+ const prof = profile ? ['--profile', profile] : [];
19
+ const r = await _run('aws', ['ecr', 'describe-repositories', '--repository-names', ecr, '--region', region, ...prof], { capture: true, allowFail: true, dryRun });
20
+ if (r.code !== 0) await _run('aws', ['ecr', 'create-repository', '--repository-name', ecr, '--region', region, ...prof], { dryRun });
21
+ }
22
+ module.exports = { buildImage, pushImage, buildDeps, ecrLogin, ensureRepo, _withRun };
@@ -0,0 +1,18 @@
1
+ const { spawn } = require('node:child_process');
2
+ function run(cmd, args = [], opts = {}) {
3
+ if (opts.dryRun) {
4
+ console.log(`▶ ${cmd} ${args.join(' ')}${opts.cwd ? ` (cwd: ${opts.cwd})` : ''}`);
5
+ return Promise.resolve({ code: 0, stdout: '', stderr: '' });
6
+ }
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(cmd, args, { cwd: opts.cwd, stdio: opts.capture ? ['inherit', 'pipe', 'pipe'] : 'inherit' });
9
+ let stdout = '', stderr = '';
10
+ if (opts.capture) { child.stdout.on('data', (d) => { stdout += d; }); child.stderr.on('data', (d) => { stderr += d; }); }
11
+ child.on('error', reject);
12
+ child.on('close', (code) => {
13
+ if (code !== 0 && !opts.allowFail) reject(new Error(`${cmd} ${args.join(' ')} failed (exit ${code})\n${stderr}`));
14
+ else resolve({ code, stdout, stderr });
15
+ });
16
+ });
17
+ }
18
+ module.exports = { run };
@@ -0,0 +1,16 @@
1
+ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
2
+ const { run } = require('./exec');
3
+ async function currentSha(cwd) { const { stdout } = await run('git', ['rev-parse', '--short', 'HEAD'], { cwd, capture: true }); return stdout.trim(); }
4
+ async function isDirty(cwd) { const { stdout } = await run('git', ['status', '--porcelain'], { cwd, capture: true }); return stdout.trim().length > 0; }
5
+ async function cloneToTmp({ git, ref }) {
6
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dray-clone-'));
7
+ // Works for branch/tag refs always; arbitrary commit SHAs require the remote
8
+ // to allow SHA fetches (uploadpack.allowAnySHA1InWant).
9
+ await run('git', ['init', '-q', dir], { capture: true });
10
+ await run('git', ['-C', dir, 'remote', 'add', 'origin', git], { capture: true });
11
+ await run('git', ['-C', dir, 'fetch', '--depth', '1', 'origin', ref], { capture: true });
12
+ await run('git', ['-C', dir, 'checkout', '-q', 'FETCH_HEAD'], { capture: true });
13
+ const sha = await currentSha(dir);
14
+ return { dir, sha, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
15
+ }
16
+ module.exports = { currentSha, isDirty, cloneToTmp };
@@ -0,0 +1,16 @@
1
+ let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
2
+ const ns = (c, n) => ['-n', n, '--context', c];
3
+ function applyFile({ file, context, namespace, dryRun }) { return _run('kubectl', ['apply', '-f', file, ...ns(context, namespace)], { dryRun }); }
4
+ async function rollout({ deployment, context, namespace, dryRun }) {
5
+ await _run('kubectl', ['rollout', 'restart', `deployment/${deployment}`, ...ns(context, namespace)], { dryRun });
6
+ return _run('kubectl', ['rollout', 'status', `deployment/${deployment}`, '--timeout=180s', ...ns(context, namespace)], { dryRun });
7
+ }
8
+ function rolloutUndo({ deployment, context, namespace, dryRun }) { return _run('kubectl', ['rollout', 'undo', `deployment/${deployment}`, ...ns(context, namespace)], { dryRun }); }
9
+ async function runningImage({ workload, kind, context, namespace }) {
10
+ const kindPath = kind === 'cronjob'
11
+ ? ['cronjob/' + workload, '-o', 'jsonpath={.spec.jobTemplate.spec.template.spec.containers[0].image}']
12
+ : ['deployment/' + workload, '-o', 'jsonpath={.spec.template.spec.containers[0].image}'];
13
+ const { stdout } = await _run('kubectl', ['get', ...kindPath, ...ns(context, namespace)], { capture: true, allowFail: true });
14
+ return stdout.trim();
15
+ }
16
+ module.exports = { applyFile, rollout, rolloutUndo, runningImage, _withRun };
@@ -0,0 +1,3 @@
1
+ const sops = require('./sops');
2
+ function publish({ repoPath, secretsFile, publishCommand, dryRun }) { return sops.execEnv(secretsFile, publishCommand, { cwd: repoPath, dryRun }); }
3
+ module.exports = { publish };
@@ -0,0 +1,16 @@
1
+ let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
2
+ async function syncSecret(secret, { repoPath, context, namespace, profile, dryRun }) {
3
+ if (secret.kind === 'sops-exec-env') return; // handled by pilet publish
4
+ if (secret.kind === 'sops-manifest') {
5
+ return _run('bash', ['-c', `sops -d ${secret.file} | kubectl apply -f - -n ${namespace} --context ${context}`], { cwd: repoPath, dryRun });
6
+ }
7
+ if (secret.kind === 'literal-from-env') {
8
+ await _run('bash', ['-c',
9
+ `kubectl create secret generic ${secret.name} --from-env-file=${secret.file} --dry-run=client -o yaml -n ${namespace} --context ${context} | kubectl apply -f - -n ${namespace} --context ${context}`],
10
+ { cwd: repoPath, dryRun });
11
+ for (const [k, v] of Object.entries(secret.reflector || {})) {
12
+ await _run('kubectl', ['annotate', 'secret', secret.name, `${k}=${v}`, '--overwrite', '-n', namespace, '--context', context], { dryRun });
13
+ }
14
+ }
15
+ }
16
+ module.exports = { syncSecret, _withRun };
@@ -0,0 +1,12 @@
1
+ let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
2
+
3
+ // sops `exec-env` takes the command as a SINGLE string (run via the shell),
4
+ // not as `-- <argv>` (that form breaks on sops 3.x: "missing file to decrypt").
5
+ // Join + shell-quote so args like `typer[all]` survive.
6
+ function shQuote(a) {
7
+ return /^[A-Za-z0-9_\-.,:=/@]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`;
8
+ }
9
+ function execEnv(file, command, opts = {}) {
10
+ return _run('sops', ['exec-env', file, command.map(shQuote).join(' ')], opts);
11
+ }
12
+ module.exports = { execEnv, _withRun };