A CLI tool for managing AWS Batch job definitions as code.
Think of it as ecspresso for AWS Batch job definitions:
write your job definitions in JSON / Jsonnet / YAML, review them with diff, then deploy.
Compute environments and job queues are out of scope. Manage those with Terraform or similar.
So are scheduling policies and production job submission; run exists only to smoke-test a
definition right after deploying it.
SubmitJob can override a job's command, environment and resources at submission time, but
not its container image. If you use immutable image tags (recommended), every application
release therefore needs a new job definition revision — the definition changes on the
application's release cadence, not the infrastructure's. batchbru exists for exactly that
situation: registering revisions from a release pipeline, with a reviewable diff, without
touching anything else.
Conversely, if your job definitions change rarely, or you use a mutable image tag that you repoint on release, managing the definitions in Terraform alongside the compute environment is perfectly fine and you may not need this tool.
Terraform can manage job definitions too, and batchbru is not a replacement for it. What batchbru changes is the workflow for the part that moves with application releases:
- Deploy cadence separation. Registering a revision from an application release pipeline
with Terraform means running
terraform apply— dragging the entire infrastructure state, its lock, and its blast radius into every application deploy. batchbru only ever touches job definitions. - No state file. Revisions are immutable and the latest ACTIVE one is authoritative, so batchbru diffs directly against what is actually in AWS. There is no state to lock, drift, or reconcile.
- Straight mapping to the API. Terraform models the immutable revision history as one
updatable resource, which makes the history and rollback awkward. batchbru embraces the API:
deployisRegisterJobDefinition,rollbackderegisters the newest revision so the previous one becomes current, and old revisions remain available.
The two compose: compute environments, job queues, IAM roles and ECR repositories stay in
Terraform, and batchbru reads their outputs through the tfstate template function.
batchbru's emphasis is on making unattended CI deploys safe:
- The diff is normalized — API-materialised defaults are pruned, member names are canonical —
so
deployis idempotent and can run unconditionally from CI without piling up redundant revisions. - Typos in a definition are an error, never silently dropped members.
- Destructive commands have guardrails:
rollbackandderegisterrefuse to leave a job definition with zero ACTIVE revisions, andderegisterprompts before acting. - One configuration file manages many definitions (globs,
--namenarrowing), in JSON, Jsonnet, or YAML.
$ go install github.com/ikaro1192/batchbru/cmd/batchbru@latestThis repository is itself an action that puts batchbru on PATH, so the commands can be used
from a workflow as they are on a terminal:
- uses: ikaro1192/batchbru@v0.1.0
with:
version: v0.1.0 # or "latest", the default
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- run: batchbru diff| Input | Default | Description |
|---|---|---|
version |
latest |
Release tag to install, or latest |
token |
${{ github.token }} |
Token used to reach this repository |
go-version |
stable |
Go version used when batchbru has to be built from source |
The action installs a published release archive when there is one, and otherwise builds from source with Go, so it also works for a revision that was never released.
While this repository is private, token needs read access to it: the default
${{ github.token }} only works for workflows in this repository, and using the action from
another repository requires a personal access token.
A typical pipeline reviews on pull requests and deploys on merge:
- run: batchbru diff # on pull requests: show what deploying would change
- run: batchbru deploy # on the default branch: registers nothing when unchanged
- run: batchbru run --name etl-daily --queue default --wait # smoke-test the new revisionbatchbru diff --exit-code fails when the local definitions and AWS disagree, which makes it a
drift check to run on a schedule.
$ batchbru init --job-definition etl-daily
wrote jobdefs/etl-daily.jsonnet
wrote batchbru.yml# batchbru.yml
region: ap-northeast-1
job_definitions:
- jobdefs/*.jsonnet
- jobdefs/*.yaml
plugins:
- name: tfstate
config:
url: s3://my-bucket/terraform.tfstateBoth job_definitions and local tfstate paths are resolved relative to the configuration file.
A single configuration file can manage multiple job definitions, and every command can narrow the
target with --name (wildcards allowed, repeatable).
Job definitions use the same format as the RegisterJobDefinition API input
(the same shape as aws batch register-job-definition --cli-input-json).
The content is passed to the API as-is, so all types are supported: container, multinode, ECS, and EKS.
// jobdefs/etl-daily.jsonnet
{
jobDefinitionName: 'etl-daily',
type: 'container',
platformCapabilities: ['FARGATE'],
containerProperties: {
image: '{{ tfstate `aws_ecr_repository.example.repository_url` }}:{{ must_env `IMAGE_TAG` }}',
jobRoleArn: '{{ tfstate `aws_iam_role.batch_job.arn` }}',
resourceRequirements: [
{ type: 'VCPU', value: '1' },
{ type: 'MEMORY', value: '2048' },
],
},
retryStrategy: { attempts: 3 },
}Working examples live in example/.
$ batchbru diff
$ batchbru deploy
etl-daily: registered revision 12
report: no changes, skipping (use --force to register anyway)deploy registers a new revision only when there is a difference from the latest ACTIVE revision.
It never piles up redundant revisions with identical content, so it is safe to run repeatedly from CI.
AWS Batch has no rollback API, so rollback deregisters the latest ACTIVE revision, effectively
making the previous revision current again.
$ batchbru rollback
etl-daily: deregister revision 12, making revision 11 currentIf only one ACTIVE revision exists, the command fails to avoid leaving the job definition unusable.
run submits a one-off job using the latest ACTIVE revision — that is, what deploy just
registered — and with --wait follows it until it succeeds or fails, so a broken definition
stops the pipeline right there.
$ batchbru run --name etl-daily --queue default --wait
etl-daily: submitted job 4f0f8a9c-... to queue default
etl-daily: RUNNABLE
etl-daily: RUNNING
etl-daily: SUCCEEDEDrun targets exactly one job definition and supports no overrides, dependencies, or array
jobs. It is a smoke test, not a job runner: production job submission and scheduling are
intentionally out of scope, because they belong to whatever orchestrates your jobs
(EventBridge Scheduler, Step Functions, your application), not to a deploy tool.
$ batchbru deregister --keep-latest 5--keep-latest must be 1 or greater. Specifying 0 would mark every revision INACTIVE and render the
job definition unusable, so it is rejected for the same reason as rollback.
| Command | Description |
|---|---|
init |
Import an existing job definition from AWS and start managing it |
render |
Print the JSON produced after template evaluation |
diff |
Show the difference against the latest ACTIVE revision (--exit-code for CI) |
deploy |
Register a new revision for job definitions that have changed |
run |
Submit a one-off job to smoke-test the latest ACTIVE revision (--wait to follow it) |
status |
Show the latest ACTIVE revision of each job definition |
revisions |
List revisions (--all to include INACTIVE ones) |
rollback |
Deregister the latest ACTIVE revision |
deregister |
Deregister revisions (--revision / --keep-latest) |
version |
Print the version |
Configuration files and job definition files are evaluated as text/template.
| Function | Description |
|---|---|
env "NAME" "default" |
Read an environment variable, falling back to the default when it is unset or empty |
must_env "NAME" |
Read an environment variable; error if undefined |
ssm "/path/to/param" |
Fetch a value from SSM Parameter Store (SecureString is decrypted) |
json_escape |
Escape a value as a JSON string |
tfstate "TYPE.NAME.ATTR" |
Read a resource attribute from Terraform state (tfstate plugin) |
From Jsonnet, the same functions are available as std.native('env') / std.native('must_env') /
std.native('ssm') / std.native('tfstate').
Use --ext-str / --ext-code to pass Jsonnet external variables.
When using multiple tfstate sources, set func_prefix.
With func_prefix: shared_, reference it as {{ shared_tfstate ... }}.
A value returned by ssm becomes part of the rendered definition: it appears in render and
diff output (and therefore in CI logs), and it is stored in the registered job definition,
readable by anyone allowed batch:DescribeJobDefinitions. Use ssm for configuration, not for
secrets — pass secrets to the container at runtime with containerProperties.secrets instead.
Neither function resolves anything by itself: env and must_env only read the environment, so
{{ env `IMAGE_TAG` `latest` }} produces the literal string latest when IMAGE_TAG is unset
— never a commit hash or the newest image in the repository. Whatever identifies the build has to
be put into the environment by whoever invokes batchbru:
- run: batchbru deploy
env:
IMAGE_TAG: ${{ github.sha }}For anything deployed for real, write the image tag with must_env rather than env:
image: '{{ tfstate `aws_ecr_repository.example.repository_url` }}:{{ must_env `IMAGE_TAG` }}',A default turns a forgotten environment variable into a silent success. Falling back to a mutable
tag such as latest leaves the rendered definition byte-for-byte identical between releases, so
deploy reports no changes and registers nothing, while the image behind the tag is replaced
underneath the revision that is already current. Jobs then run code that no revision records, and
rollback cannot get back to the previous image because no revision ever described it. With
must_env the deploy fails instead, which is the outcome you want at that point.
The definitions under example/ use env with a default so that they can be rendered
without any environment set up; that is a convenience for reading the examples, not a pattern to
copy into a pipeline.
| Command | Actions |
|---|---|
render |
none — it evaluates the files locally |
init / diff / status / revisions |
batch:DescribeJobDefinitions |
deploy |
the above + batch:RegisterJobDefinition |
rollback / deregister |
the above + batch:DeregisterJobDefinition |
run |
batch:SubmitJob, plus batch:DescribeJobs for --wait |
deploy needs DescribeJobDefinitions as well as RegisterJobDefinition, because it compares
against the latest ACTIVE revision before deciding to register. The same goes for rollback and
deregister, which list the revisions before choosing what to deregister.
iam:PassRole on every role a definition names. Registering a definition that sets
jobRoleArn or executionRoleArn counts as passing those roles to AWS Batch, so deploy is
denied without it — even though the roles are used by the job at runtime and never by batchbru
itself. This is the permission a pipeline usually trips over first, and the error names
iam:PassRole rather than anything about job definitions. Grant it on the specific role ARNs.
kms:Decrypt alongside ssm:GetParameter. batchbru always requests SecureString parameters
with decryption. If a parameter is encrypted with a customer managed key, reading it needs
kms:Decrypt on that key as well; the AWS managed key for Parameter Store needs no extra grant.
| Function | Actions |
|---|---|
ssm |
ssm:GetParameter, plus kms:Decrypt on the key as described above |
tfstate |
read access to wherever the state lives: s3:GetObject for s3://, a Terraform Cloud token for remote://, nothing for a local file |
Add batch:TagResource if your definitions set tags: registering with tags is authorized as a
tagging operation as well on some AWS services, and granting it costs nothing.
Enough for diff, deploy and rollback. Replace the account ID and role names, and add the
ssm, s3 or batch:SubmitJob statements only if you use those.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageJobDefinitions",
"Effect": "Allow",
"Action": [
"batch:DescribeJobDefinitions",
"batch:RegisterJobDefinition",
"batch:DeregisterJobDefinition",
"batch:TagResource"
],
"Resource": "*"
},
{
"Sid": "PassTheRolesTheDefinitionsReference",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::123456789012:role/batch-job-role",
"arn:aws:iam::123456789012:role/batch-execution-role"
]
}
]
}batch:DescribeJobDefinitions does not support resource-level permissions, so it has to be
granted on * and that is what pins the statement above to *. RegisterJobDefinition,
DeregisterJobDefinition and TagResource do support them, so if you want a tighter policy,
split those three into a second statement scoped to
arn:aws:batch:<region>:<account-id>:job-definition/*.
Job definitions are immutable: RegisterJobDefinition always creates a new revision and existing
revisions can never be updated. Revisions are ACTIVE or INACTIVE, and DeregisterJobDefinition
makes one INACTIVE (actual deletion happens later on the AWS side). Submitting a job by name
without a revision number uses the latest ACTIVE revision. Therefore:
deploymeans registering a new revision.rollbackmeans deregistering the latest ACTIVE revision so the previous one becomes current.diffcompares your local definition against the latest ACTIVE revision.
- Job definition names are not written in the configuration file: the
jobDefinitionNameinside each job definition file is authoritative. Duplicate names after glob expansion are an error. - A glob that matches no file is also an error, so a typo cannot silently reduce the target set to zero.
job_definitionspaths and tfstate local paths are resolved relative to the configuration file, never to the current working directory.regionapplies to bothssmlookups inside job definition files and Batch API calls. The one exception isssmused inside the configuration file itself:regionhas not been read yet at that point, so the AWS SDK default resolution is used. SetAWS_REGIONif you do this.- Unknown members (typos) in a job definition are reported as an error rather than silently dropped.
- If the configuration file exists, one line is appended textually to the
job_definitions:block list so that comments and template expressions are preserved. - If it cannot be edited safely (no list found, flow style such as
job_definitions: [a, b], or a Jsonnet configuration file), the configuration file is left completely untouched and the lines to add by hand are printed instead. The job definition file was still written, so this is not an error (exit code 0). - Existing job definition files are never overwritten without
--overwrite.
Local and remote definitions are normalized through the same path before comparison: both are decoded
into RegisterJobDefinitionInput and re-encoded with API member names; the read-only fields
(jobDefinitionArn, revision, status, containerOrchestrationType) are stripped from the remote
side; and fields absent locally whose remote value is the API default are not reported as differences.
If a key is present locally it is always compared, even when its value equals the default.
The list of non-empty API defaults is maintained by hand, so when AWS starts materialising a new
default that batchbru does not know about, it shows up as a false diff: a member that exists
only in the remote definition. This matters because deploy registers whenever diff is
non-empty — a false diff would register a redundant revision on every CI run.
diff and deploy therefore print a warning whenever a diff contains members that exist only
remotely, naming them. If you removed the member from your file on purpose, the warning is
noise — ignore it. If you never wrote that member, it is a default the API filled in:
- Workaround: add the member with the remote value to your local file; the diff disappears.
- Please also report it so the default can be added to batchbru and the workaround becomes unnecessary for everyone.
--revisionand--keep-latestare mutually exclusive; exactly one must be given.- Without
--force, the affected revisions are listed and a confirmation prompt is shown.
0: success (includingdifffinding no changes)1: runtime error, ordiff --exit-codefinding changes
When a command targets multiple job definitions and some of them fail, the remaining ones are still processed; errors are reported together at the end with exit code 1.
$ make test
$ make build
$ make lintmake lint and make test are what CI runs (.github/workflows/ci.yml), and
.github/workflows/test-action.yml installs batchbru through action.yml on Linux and macOS
runners to check that the action itself still works.