Convention-driven multi-repo deploy orchestrator — "forge, but for shipping."
dray builds/pushes container images to ECR, renders + applies k8s manifests
(pinning a git SHA into image references), rolls out workloads, publishes Piral
pilets, and syncs secrets — driven by a .dray/config.json committed in each
repo. One entry point, interactive or scripted; no daemon.
npm i -g @brutalsystems/dray- Global defaults —
~/.dray/config.json:{ "defaults": { "profile": "<aws-profile>", "region": "<region>", "account": "<acct-id>", "platform": "linux/arm64", "context": "<kube-context>", "namespace": "<namespace>" } } - Register each repo:
dray add /path/to/repo(reads its.dray/config.json; registry is stored at~/.dray/registry.json).
Requires docker buildx, kubectl, git, aws, and (for pilets/secrets) sops.
dray # interactive menu
dray list # registered repos + targets
dray ship <repo>:<target> # deps?→build→push(SHA)→render+apply→rollout
dray ship <repo> # all enabled workloads in the repo
dray apply <repo>:<target> # render + apply manifests only
dray rollout <repo>:<target>
dray status <repo> # running image SHA vs HEAD
dray rollback <repo>:<target> <sha>
dray publish <repo>[:<pilet>] # publish pilet(s) via sops exec-env
dray secrets sync <repo>Global flags: --dry-run (print the plan, run nothing), --allow-dirty
(build a dirty tree, tags :<sha>-dirty), --all (every registered repo).
Manifests reference managed images by a placeholder var — ${APP_IMAGE}
(derived <UPPER_SNAKE(name)>_IMAGE, or set templateVar). On apply, dray
substitutes <ecr>:<gitSHA> for that var across the listed files (container
image, env-var image refs, cronjob images — uniformly). A raw kubectl apply
of an unrendered manifest fails loud, so applies always go through dray.
An image with "latest": true additionally gets a mutable <ecr>:latest tag
moved onto each non-dirty push. This is for the opposite need: a workload that
should track the newest build of a shared image without being re-shipped
(e.g. a low-frequency cronjob sharing an image with a frequently-shipped
service). Reference <ecr>:latest literally in that manifest (with
imagePullPolicy: Always) instead of the ${..._IMAGE} placeholder. You trade
away per-commit reproducibility/rollback for that workload — use it only where
that's the point.
{ "name": "myrepo", "images": [ { "name": "app", "ecr": "app", "source": { "local": true }, "dockerfile": "Dockerfile", "context": ".", // optional: rebuild a cached deps layer when lockfiles change "depsImage": { "dockerfile": "Dockerfile.deps", "tag": "app:deps", "rebuildOn": ["uv.lock"] }, // optional: inject --build-arg from an env file (e.g. VITE_* from .env.production), // or from a sops-encrypted file decrypted in memory at build time (no plaintext on disk): // "buildArgs": { "sopsEnvFile": "secrets.env", "prefix": "VITE_" } "buildArgs": { "envFile": ".env.production", "prefix": "VITE_" }, // optional: also move a mutable <ecr>:latest tag onto every (non-dirty) // push, so workloads that reference <ecr>:latest track the newest build // without being re-shipped (e.g. rarely-shipped cronjobs sharing an image). "latest": true }, // image from another repo (cloned to a tmp dir, built, pushed): { "name": "svc", "ecr": "svc", "source": { "git": "https://github.com/org/svc", "ref": "main" } } ], "workloads": [ // kind: deployment (rolled out) or cronjob (applied only) { "name": "app", "kind": "deployment", "image": "app", "manifests": [".k8s/deployment.yaml", ".k8s/service.yaml"], "stampImages": ["app"] }, // disabled: kept in config but skipped (e.g. served elsewhere) { "name": "legacy", "kind": "deployment", "image": "app", "manifests": ["..."], "disabled": true }, // image-less: apply-only (third-party image lives literally in the manifest) { "name": "searxng", "kind": "deployment", "manifests": [".k8s/searxng.yaml"] } ], "secrets": [ { "name": "app-secrets", "kind": "sops-manifest", "file": ".k8s/secrets.enc.yaml" } ], "pilets": { "secretsFile": "secrets.env", "command": ["npm", "run", "publish:feed", "--", "--pilet", "{name}"], "names": ["foo", "bar"] } }