rules_cloudformation
Bazel rules for AWS CloudFormation templates — schema-derived typed Bazel rules via rules_jsonschema, Java-based linter via rules_java + the official cloudformation-template-schema.
| Latest | 0.10.0 |
|---|---|
| Versions | 13 |
| Category | Bazel rules |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_cloudformation/ |
| Source | github.com/tomato-bazel/rules_cloudformation |
bazel_dep(name = "rules_cloudformation", version = "0.10.0")
View source & releases on GitHub ↗
Bazel rules for AWS CloudFormation templates — schema-derived typed
Bazel rules via rules_jsonschema,
plus a Java-based linter via rules_java.
Each CFN resource type becomes a typed Bazel rule with one attr.*
per JSON Schema property.
The user-facing Starlark surface mirrors the
aws-cloudformation/cloudformation-template-schema
exhaustively — every property the schema accepts is a typed Bazel
attr.*. There’s no hand-curated subset and no allowlist of deferred
fields. Drift is impossible by construction:
- The canonical schema is fetched on-demand from
aws-cloudformation/cloudformation-template-schemaat a commit + sha256 pinned incloudformation/private/extensions.bzl. rules_jsonschema’sjsonschema_starlark_codegenemitscloudformation/cloudformation_rules.bzl— onerule()perAWS::*resource type definition in the schema, typedattr.*per property.cloudformation/private/stack_aggregator.pymerges the per-target JSON shards into one canonical template, rewriting thecfn_ref/cfn_getatt/cfn_sub/cfn_join/cfn_import_valuesentinels and validating cross-resource names (conditions,DependsOn) so a typo fails at build time rather than at deploy.- The Java linter (port of cfn-lint patterns, built with
rules_java) runs after rendering and reports semantic issues the schema alone cannot express (e.g. cross-property constraints, recommended-name conventions).
The hand-written part of the repo is small and scoped to things the
schema can’t describe: graph aggregation, cross-stack reference
resolution, and bazel run wrappers around aws cloudformation deploy. Codegen goes through rules_jsonschema’s plugin contract —
see that repo’s
plugin_contract.md
if you want to swap a plugin for one of your own.
Status: v0.10.0
What v0.10 adds on top of v0.9.0:
-
cfn_join—Fn::Join, which is what makes a list-valuedFn::GetAttusable in a string slot. The slot that matters is a template Output’sValue:cloudformation_output( name = "ZoneNameServers", # AWS::Route53::HostedZone.NameServers is a List of String, and an # Output Value must be a string. Bare, this builds, deploys, and then # rolls the stack back with "Template format error: Every Value member # must be a string." — naming neither the output nor the attribute. Value = cfn_join(",", cfn_getatt("Zone", "NameServers")), )Two forms, and the difference is load-bearing.
valuesmay be a list of literals and/or sentinels, or a single sentinel that is itself list-valued:cfn_join("-", [cfn_ref("Environment"), "assets"]) # ["-", [{"Ref": …}, "assets"]] cfn_join(",", cfn_getatt("Zone", "NameServers")) # [",", {"Fn::GetAtt": […]}]Passing the second form’s argument as a one-element list renders
[",", [{"Fn::GetAtt": …}]]— a list of one element that happens to be a list, not a list-valued reference — and fails the same way the unjoined GetAtt does. Nestedcfn_ref/cfn_getatt/cfn_import_value/cfn_find_in_mapinside a list compose and are name-validated as usual; a nestedcfn_joinis rejected at load time.
What v0.9 adds on top of v0.8.0:
-
resource_depends_on— explicitDependsOn, for the ordering CFN cannot infer.cfn_ref/cfn_getattalready give CFN a dependency edge wherever a value flows between two resources, which covers most cases; this is for the ones where the ordering is real and no value flows. The canonical example, which every VPC hits:cloudformation_stack( name = "vpc", resources = [":Igw", ":IgwAttachment", ":PublicRt", ":PublicDefaultRoute"], # Both the route and the attachment merely Ref the gateway, so neither # depends on the other. Without this the route can be created first and # fails: "The gateway ID 'igw-…' does not exist or is not attached". resource_depends_on = {"PublicDefaultRoute": "IgwAttachment"}, )Names are validated against the stack’s resources at build time.
What v0.6 added on top of v0.5.0:
- Deploy wrappers —
cloudformation_upandcloudformation_downincloudformation/deploy.bzl.bazel run :stack_updeploys viaaws cloudformation deploy;bazel run :stack_downdeletes viadelete-stack. - AWS CLI toolchain abstraction — default toolchain auto-uses
system
awson PATH. Consumers wanting hermeticity register an alternate toolchain (rules_multitool,http_file, sidecar container) for@rules_cloudformation//cloudformation/aws_cli:toolchain_type.
load("@rules_cloudformation//cloudformation:stack.bzl", "cloudformation_stack")
load("@rules_cloudformation//cloudformation:deploy.bzl",
"cloudformation_up", "cloudformation_down")
cloudformation_stack(name = "app_stack", resources = [":Assets"])
cloudformation_up(
name = "app_up",
stack = ":app_stack",
stack_name = "prod-app",
region = "us-east-1",
capabilities = ["CAPABILITY_IAM"],
)
cloudformation_down(name = "app_down", stack_name = "prod-app", region = "us-east-1")
# bazel run //path:app_up
# bazel run //path:app_down
Status: v0.5.0 (prior)
What v0.5 adds on top of v0.4.0:
- Cross-resource refs —
cfn_ref(name)andcfn_getatt(name, attr)Starlark helpers incloudformation/stack.bzlproduce sentinel strings the aggregator rewrites into{"Ref": ...}/{"Fn::GetAtt": [...]}. The aggregator validates that every ref points at a real resource in the stack — typos surface at Bazel-build time, not at AWS-deploy time.
load("@rules_cloudformation//cloudformation:defs.bzl",
"cloudformation_aws_s3_bucket",
"cloudformation_aws_s3_bucket_policy")
load("@rules_cloudformation//cloudformation:stack.bzl",
"cfn_ref", "cfn_getatt", "cloudformation_stack")
cloudformation_aws_s3_bucket(name = "Assets", BucketName = "app-assets")
cloudformation_aws_s3_bucket_policy(
name = "AssetsPolicy",
Bucket = cfn_ref("Assets"),
PolicyDocument = json.encode({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": cfn_getatt("Assets", "Arn"),
}],
}),
)
cloudformation_stack(name = "stack", resources = [":Assets", ":AssetsPolicy"])
Status: v0.4.0 (prior)
What v0.4 adds on top of v0.3.1:
cloudformation_stackaggregator (cloudformation/stack.bzl) — collects typed-rule shards into a complete CFN template. Intrinsics splice into per-resource (Init) or template-level (Interface)Metadata. Phase-1 limitation: CFN logical ids = contributing rule’slabel.name(name targets PascalCase). Cross- resource refs + deploy wrappers are deferred to later phases.
Example:
load("@rules_cloudformation//cloudformation:defs.bzl",
"cloudformation_aws_s3_bucket")
load("@rules_cloudformation//cloudformation:stack.bzl",
"cloudformation_stack")
cloudformation_aws_s3_bucket(name = "AppAssets", BucketName = "my-app-assets")
cloudformation_stack(
name = "app_stack",
description = "App backing services.",
resources = [":AppAssets"],
)
# Output: `app_stack.json` — a deployable CFN template.
Status: v0.3.1 (prior)
What v0.3.1 adds on top of v0.3.0:
- Hand-authored CFN metadata intrinsics in
cloudformation/intrinsics.bzl:cloudformation_aws_cloudformation_init(cfn-init config-set tree) andcloudformation_aws_cloudformation_interface(template Metadata parameter-group block). These live outside the Resource Spec so they’re not covered by the auto-kinds pipeline; loaded separately:load("@rules_cloudformation//cloudformation:intrinsics.bzl", ...).
What v0.3 adds on top of v0.2.0:
- Exhaustive coverage — 1582 typed rules across the entire
pinned CFN Resource Specification (was 26 in v0.2 / one in v0.1).
Powered by
rules_jsonschemav0.3’s new auto-kinds flags (--kinds-pointer-base,--kinds-key-filter, the template flags) instead of hand-enumerating each--kind=mapping. - Adding a new resource type is a no-op — bump the upstream
spec pin (
cfn_sources_extension) and the new resources show up in the regenerateddefs.bzl. - Single
defs.bzl— the per-group files (storage.bzl,compute.bzl, …) collapsed into one generated artifact. Consumersload("//cloudformation:defs.bzl", ...)exactly like they did in v0.2; the loaded surface is a strict superset. - Breaking: the per-kind item-name attr is namespaced
(
aws_s3_bucket_namerather than the v0.2 short tagbucket_name) to prevent collisions in the 1500+ rule set. The CFN property attrs (BucketName,VersioningConfiguration, …) are unchanged.
v0.2.0 status (still shipped):
What v0.2 adds on top of v0.1.0:
- 6 resource-type groups, 26 typed rules — every common AWS
resource type for storage / compute / identity / messaging /
observability / database. Each group is a separate
cfn_assemble+jsonschema_starlark_codegenpair; consumers pick which group(s) to load. Re-exported from//cloudformation:defs.bzl. Adding a new resource is two edits incloudformation/BUILD.bazel. - Docstring overlay —
cfn_overlay_descriptionslayers AWS-endpoint per-resource propertydescriptionfields onto the assembler-derived schemas before codegen. Trades URL-only attr docs for rich prose. v0.2 ships endpoint coverage forAWS::S3::Bucket; expanding coverage is a one-line pin per resource. Endpoints not yet pinned pass through unchanged. - Internal cleanup — dropped the unused
cfn_template_schema_srcuse_repo from MODULE.bazel; trimmed the stale.bazelrcJava language-version pin.
v0.1.0 status (still shipped):
What ships:
- Schema source — the upstream Java assembler
(
aws.cfn.codegen.json.Mainfromaws-cloudformation/cloudformation-template-schema) is built and run at build time against a sha-pinned snapshot of the AWS CloudFormation Resource Specification. The assembler sources are vendored (delomboked, seedocs/SCHEMA_SOURCE.mdfor the Lombok trade-off); the spec is fetched byhttp_fileat a sha256 pinned incloudformation/private/extensions.bzl. cfn_assemblecustom rule (incloudformation/private/assemble.bzl) runs the assembler with a synthesized YAML config — one region (us-east-1), one custom resource group (anyAWS::*regex pattern), one emitted<group>-spec.jsonper invocation. The output is a consumer-ready JSON Schema.- Codegen pipeline —
rules_jsonschema’sjsonschema_starlark_codegenproducescloudformation/aws_s3_bucket.bzlfrom the assembledstoragegroup’s schema. Committed + gated by adiff_testso CI fails on drift between the upstream schema source and the committed.bzl. cloudformation_aws_s3_bucket— typed Bazel rule, oneattr.*per CFN Resource Specification property, emits a JSON shard ready for a futurecloudformation_stackaggregator. Re-exported from//cloudformation:defs.bzl.- End-to-end smoke (
examples/smoke/) — declares an S3 bucket- a byte-stability diff_test on the emitted shard. Green.
Note on the schema source: v0.1.0 was retagged to swap an early per-resource AWS-endpoint approach for the upstream Java assembler. This keeps the source-of-truth aligned with cfn-lint and the CFN documentation, at the cost of a build-time Java compile. See
docs/SCHEMA_SOURCE.mdfor the trade-offs and the Lombok wrinkle.
Deferred to v0.2 / v0.3 (see docs/ROADMAP.md):
- Bundle tag class — opt into N resource types in one MODULE.bazel call.
cloudformation_stackaggregator (collects shards into onetemplate.yamlvia a Rustcfn-genbinary).cloudformation_resource_reffor cross-stack refs (resolves stack outputs at build time, likedocker_compose_oci_image_ref).cloudformation_up/_downbazel runwrappers aroundaws cloudformation deploy/delete-stack.- Java linter port of cfn-lint patterns.
Planned architecture
Mirrors rules_docker_compose:
-
Hand-written rules (will be re-exported by
cloudformation/defs.bzl):cloudformation_stack— aggregator. Collects per-target resource/parameter/output/mapping shards fromdepsand renders one canonicaltemplate.yaml. Analogous todocker_compose.cloudformation_resource_ref— resolves a cross-stackRef/Fn::ImportValue/ stack-output target at build time and overrides a referenced resource property in the rendered output. Analogous todocker_compose_oci_image_ref, which resolves OCI digests into a service’simage:.cloudformation_up/cloudformation_down—bazel runwrappers aroundaws cloudformation deployandaws cloudformation delete-stack. Analogous todocker_compose_up/_down.
-
Schema-derived rules (generated, committed,
diff_test-gated): onecloudformation_<resource_type>rule perAWS::*resource type, generated from the official CFN schema viajsonschema_starlark_codegen. Examples:cloudformation_aws_s3_bucket,cloudformation_aws_lambda_function,cloudformation_aws_ec2_instance. The full set is ~1000+ rules. See docs/SCHEMA_SOURCE.md for how the schema’sAWS::*type definitions map to Starlark rules. -
Java linter — port of cfn-lint–style validation rules, packaged as a
java_binaryviarules_java. Runs over the renderedtemplate.yamlat test time. Why Java: the upstream schema repo is itself a Maven project, so the schema’s intrinsic function tables and reference data are already in Java; reusing them avoids a parallel reimplementation. -
Refs + labels — every shard produced by a schema-derived rule emits a
CloudformationResourceInfoprovider carrying its logical ID, type, and the labels of any resources it references.cloudformation_stackwalks the provider graph to validate that everyRefresolves inside the stack (or is satisfied by acloudformation_resource_refshard).
Schema source (current)
The schema is sourced via the upstream Java assembler from
aws-cloudformation/cloudformation-template-schema,
run at build time against a sha-pinned snapshot of the AWS
CloudFormation Resource Specification (us-east-1). The assembler
sources are vendored under
cloudformation/private/assembler_src/ in delomboked form (see
docs/SCHEMA_SOURCE.md for the Lombok-vs-JDK
context).
Install
.bazelrc:
common --registry=https://raw.githubusercontent.com/fastverk/bazel-registry/main/
common --registry=https://bcr.bazel.build/
MODULE.bazel:
bazel_dep(name = "rules_cloudformation", version = "0.2.0")
rules_jsonschema, rules_java, rules_jvm_external, and
(transitively) a Rust toolchain are pulled in once the v0.1
codegen pipeline lands. The Maven artifacts for the assembler are
pinned by maven_install.json; consumers don’t need to repin.
License
MIT.
Usage#
Real usage, taken from the module’s examples/.
examples/smoke/BUILD.bazel
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("@rules_cloudformation//cloudformation:condition.bzl", "cloudformation_condition")
load(
"@rules_cloudformation//cloudformation:defs.bzl",
"cloudformation_aws_ec2_instance",
"cloudformation_aws_ec2_internet_gateway",
"cloudformation_aws_ec2_route",
"cloudformation_aws_ec2_route_table",
"cloudformation_aws_ec2_vpc",
"cloudformation_aws_ec2_vpcgateway_attachment",
"cloudformation_aws_ecr_repository",
"cloudformation_aws_route53_hosted_zone",
"cloudformation_aws_s3_bucket",
"cloudformation_aws_s3_bucket_policy",
)
load(
"@rules_cloudformation//cloudformation:deploy.bzl",
"cloudformation_down",
"cloudformation_up",
)
load(
"@rules_cloudformation//cloudformation:intrinsics.bzl",
"cloudformation_aws_cloudformation_init",
"cloudformation_aws_cloudformation_interface",
)
load("@rules_cloudformation//cloudformation:mapping.bzl", "cloudformation_mapping")
load("@rules_cloudformation//cloudformation:output.bzl", "cloudformation_output")
load("@rules_cloudformation//cloudformation:parameter.bzl", "cloudformation_parameter")
load(
"@rules_cloudformation//cloudformation:stack.bzl",
"cfn_equals",
"cfn_find_in_map",
"cfn_getatt",
"cfn_if",
"cfn_join",
"cfn_ref",
"cloudformation_stack",
)
package(default_visibility = ["//visibility:public"])
# Declare an S3 bucket via the schema-derived typed rule. The rule
# enforces every property's JSON Schema type at Bazel-loading time
# — pass an integer to BucketName and the load fails with a clear
# error. The output is a JSON shard the consumer's stack-aggregator
# rule would pick up.
cloudformation_aws_s3_bucket(
name = "smoke_bucket",
BucketName = "smoke-bucket",
VersioningConfiguration = '{"Status": "Enabled"}',
# Per-kind item-name attr. v0.3 namespaces this with the full
# AWS_Service_Resource id (was `bucket_name` in v0.2 when kinds
# were hand-enumerated with a short tag).
aws_s3_bucket_name = "smoke-bucket",
)
# Lock down the shard's bytes so a regression in the schema codegen
# (e.g. an attr renamed, dropped, or stringified differently) shows
# up as a Bazel test failure.
diff_test(
name = "smoke_bucket_shard_stable",
file1 = "expected_smoke_bucket.json",
file2 = ":smoke_bucket",
)
# AWS::CloudFormation::Init — a config-set tree attached to an
# EC2-like resource's `Metadata`. Hand-authored rule (lives outside
# the Resource Spec).
cloudformation_aws_cloudformation_init(
name = "smoke_init",
config_sets = '{"default": ["install", "config"]}',
configs = json.encode({
"install": {
"packages": {"yum": {"httpd": []}},
},
"config": {
"files": {
"/etc/httpd/conf.d/site.conf": {
"content": "ServerName localhost\n",
"mode": "000644",
},
},
},
}),
target_resource_name = "WebServer",
)
diff_test(
name = "smoke_init_shard_stable",
file1 = "expected_smoke_init.json",
file2 = ":smoke_init",
)
# AWS::CloudFormation::Interface — top-level template metadata that
# groups Parameters for the AWS console UI.
cloudformation_aws_cloudformation_interface(
name = "smoke_interface",
parameter_groups = json.encode([
{
"Label": {"default": "Network"},
"Parameters": [
"VpcId",
"SubnetIds",
],
},
]),
parameter_labels = '{"VpcId": {"default": "Which VPC?"}}',
)
diff_test(
name = "smoke_interface_shard_stable",
file1 = "expected_smoke_interface.json",
file2 = ":smoke_interface",
)
# ---- cloudformation_stack: aggregate shards into a CFN template ----
# PascalCase target names because the v0.4 aggregator uses
# label.name as the CFN logical id (which must be alphanumeric).
cloudformation_aws_s3_bucket(
name = "SmokeBucket",
BucketName = "smoke-bucket",
aws_s3_bucket_name = "smoke-bucket",
)
cloudformation_aws_ec2_instance(
name = "WebServer",
ImageId = "ami-0abcdef1234567890",
InstanceType = "t3.micro",
aws_ec2_instance_name = "web-server",
)
cloudformation_aws_cloudformation_init(
name = "WebServerInit",
config_sets = '{"default": ["install"]}',
configs = json.encode({
"install": {"packages": {"yum": {"httpd": []}}},
}),
target_resource_name = "WebServer",
)
cloudformation_aws_cloudformation_interface(
name = "StackInterface",
parameter_groups = json.encode([
{
"Label": {"default": "Compute"},
"Parameters": ["InstanceType"],
},
]),
)
cloudformation_aws_s3_bucket_policy(
name = "SmokeBucketPolicy",
Bucket = cfn_ref("SmokeBucket"),
PolicyDocument = json.encode({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
# … truncated — see the repo for the full exampleRules & providers#
Generated with Stardoc from the module's .bzl sources.
from docs/ROADMAP.md
rules_cloudformation roadmap
Three milestones to first useful release. Numbering matches the
rules_docker_compose cadence: v0.1 = schema-derived primitives,
v0.2 = hand-written orchestration, v0.3 = deploy wrappers + linter.
v0.1 — schema fetch + codegen
Get the schema into the repo as Bazel-fetched data, run codegen, ship the first typed rule end-to-end.
-
Schema fetch.
cloudformation/private/extensions.bzldefines anhttp_archive-backed module extension pinningaws-cloudformation/cloudformation-template-schemato a specific commit + sha256. Same shape asrules_docker_compose’scompose_spec_extension, excepthttp_archive(nothttp_file) because the upstream packages the schema as part of a Maven build, not a single JSON file — seedocs/SCHEMA_SOURCE.md. -
MODULE.bazel wires
rules_jsonschema. Addsbazel_dep(name = "rules_jsonschema", version = "0.2.0")and ause_extensionblock consuming the schema repo. -
Codegen pipeline. A single
jsonschema_starlark_codegeninvocation reads the masterSchema.templateand emitscloudformation/cloudformation_rules.bzl— onerule()perAWS::*definition. Estimated ~1000+ rules (e.g.cloudformation_aws_s3_bucket,cloudformation_aws_lambda_function,cloudformation_aws_ec2_instance,cloudformation_aws_iam_role,cloudformation_aws_dynamodb_table, …). The committed.bzlis diff-tested against fresh codegen on every CI build, exactly likecompose_rules.bzlinrules_docker_compose. -
Smoke test. One end-to-end example: a single
cloudformation_aws_s3_buckettarget rendered through a placeholder aggregator into golden YAML. Validates that the schema-fetch → codegen → typed-attr → JSON-shard → YAML pipeline works for at least one resource type before the v0.2 aggregator arrives.
v0.2 — hand-written orchestration
Replace the placeholder aggregator with the real graph-walking implementation, plus cross-stack ref resolution.
-
cloudformation_stack. Aggregator rule. Collects shards fromdeps, validates theRefgraph (every logical ID referenced is defined or has a matchingcloudformation_resource_ref), and renders one canonicaltemplate.yamlvia a Rustcfn-genbinary. Same shape asdocker_compose: shard JSON → typed struct (fromrules_jsonschema’sjsonschema_rust_library) → canonical YAML. Stable key ordering so re-renders are byte-identical. -
cloudformation_resource_ref. Cross-stack reference resolver. Given a target stack label and an output name, resolves to the exported value at build time (via either a checked-inoutputs.jsonor a stack-output index file), then rewrites a property of a named resource in the rendered template to that value. Same role asdocker_compose_oci_image_ref: a build-time override that turns a symbolic reference into a concrete pinned value before deploy. -
Providers.
CloudformationResourceInfo,CloudformationStackInfo,CloudformationResourceRefInfo.
v0.3 — deploy + lint
Ship the runtime wrappers and the Java-based linter.
-
cloudformation_up.bazel runwrapper that invokesaws cloudformation deploy --template-file <rendered> --stack-name <stack>against the rendered template, with--parameter-overridesflowing through from rule attrs. Same shape asdocker_compose_up’sbazel runwrapper. -
cloudformation_down.bazel runwrapper foraws cloudformation delete-stack. Mirrorsdocker_compose_down. -
Java linter. Port of cfn-lint–style rules built with
rules_java. Why Java: the upstream schema repo is a Maven project whose intrinsic-function and reference tables already exist in Java — reusing them is cheaper than reimplementing. Packaged as ajava_binaryinvoked from acloudformation_lint_testrule that runs against everycloudformation_stack.
from docs/SCHEMA_SOURCE.md
Schema source
Where rules_cloudformation’s typed rules ultimately come from.
Choice (v0.1)
We run the upstream Java assembler
(aws.cfn.codegen.json.Main from
aws-cloudformation/cloudformation-template-schema) at build time
against a sha-pinned snapshot of the AWS CloudFormation Resource
Specification. The assembler emits one JSON Schema per resource
group; we feed the storage group’s output (scoped to AWS::S3.*
in v0.1) through rules_jsonschema’s
jsonschema_starlark_codegen to produce the typed Bazel rules.
Two artifacts are pinned in
cloudformation/private/extensions.bzl:
aws-cloudformation/cloudformation-template-schemaat commit5d7815b14fd533c15c30f9046a76cdcb89afd32a(sha2567f40b919bbea6109244903744262074f6afa32fdd780a6dca0540ef1b57bd774). Fetched but not on the compile path — see the Lombok wrinkle section below. Vendored undercloudformation/private/assembler_src/in delomboked form.- The us-east-1
CloudFormationResourceSpecification.jsonat sha2563bf0f8b5034b51c622da82f7cec9499112a40719f28fff5c6d2050a0c3a24459. Endpoint:https://d1uauaxba7bl26.cloudfront.net/latest/CloudFormationResourceSpecification.json.
How the build composes
@cfn_resource_spec//file:CloudFormationResourceSpecification.json
│
▼
//cloudformation:assembled_storage (cfn_assemble)
│
│ storage-spec.json (JSON Schema, ~280 KB,
│ 223 AWS::S3.* + Tag definitions)
▼
//cloudformation:aws_s3_bucket_gen (jsonschema_starlark_codegen)
│
▼
aws_s3_bucket.bzl (committed, diff_test-gated)
cfn_assemble synthesizes a YAML config that points the assembler
at the local pinned spec (the upstream bundled config.yml has all
25 region URLs hard-coded to the AWS CDN, which would defeat
build-time reproducibility), narrows the region set to us-east-1
(the source-of-truth region), and declares a single custom group
with the requested includes/excludes.
Lombok wrinkle
The upstream sources use Lombok 1.16.22 (released 2018). That
release predates JDK 21+. The current Lombok release line (1.18.x)
fails to initialize under JDK 25 with
com.sun.tools.javac.code.TypeTag :: UNKNOWN, and Bazel 9.1.0’s
rules_java toolchain runs the JavaBuilder on remotejdk25 by default
without an easy override.
After running the prompt’s listed fallbacks (bump Lombok, pin
--java_runtime_version=remotejdk_21, pin Lombok 1.18.36 — none of
which sidestepped the issue because the JavaBuilder process itself
runs on remotejdk25), we took the documented nuclear option: ran
lombok.jar delombok against the upstream sources locally
(java -jar lombok.jar delombok src/main/java -d cloudformation/private/assembler_src),
stripped the @lombok.Generated annotations the delomboker leaves
on each generated method, and committed the result.
Trade-off: refreshing the assembler from a newer upstream commit
isn’t a one-line bump anymore — it’s a delombok + commit. In
exchange, the build has no annotation-processor at compile time and
no Lombok runtime dep, so it stays buildable on whichever JDK Bazel
ships with going forward.
The patched upstream Codegen has one rules_cloudformation-local
fix: newer CFN spec entries can have Type: Json with no
PrimitiveType set, which the upstream code treats as a primitive
but then NPEs on. The patch in Codegen.addPrimitiveType falls back
to “Json” when the primitive name is null.
Known gap: registry-only resources
The legacy Resource Specification we pin covers ~1582 of the
~1600+ types AWS publishes. A handful of newer types
(post-2023 additions — e.g. AWS::EC2::Image,
AWS::EC2::SnapshotBlockPublicAccess) only ship via the newer
CloudFormation Registry schema source (per-resource JSON files at
schema.cloudformation.us-east-1.amazonaws.com/) and never
landed in the legacy spec. Surfacing them would mean pulling
from the Registry endpoint as a second source — same per-resource-
file shape as the v0.0.1 source, but only for the resources the
legacy spec is missing. v0.7+ work item; not on the current
roadmap because demand is low (savvi-ops, the design’s stress
test, hits ~1 of 87 in-use AWS types as a registry-only).
Alternatives considered
| Source | Why not chosen |
|---|---|
Per-resource AWS endpoint (schema.cloudformation.us-east-1.amazonaws.com/<resource>.json) | The v0.0.1 / first-cut v0.1 used this. It works but it’s a per-resource fetch (1200+ URLs to track for full coverage) and the schema content is the AWS resource-provider schema, which is divergent from the CloudFormation Resource Specification. Pivoting now keeps the same source-of-truth as cfn-lint and the CFN Linter docs. |
aws-cloudformation/cloudformation-cli registry schemas | Same per-resource shape, different repository. No advantage. |
| Hand-curated subset | rules_jsonschema’s whole point is avoiding drift between hand-written rules and upstream. Hard-no. |
Refreshing
Three independent bumps:
-
CFN Resource Specification (typical: track AWS-published spec versions):
curl -fsSL https://d1uauaxba7bl26.cloudfront.net/latest/CloudFormationResourceSpecification.json | shasum -a 256 # bump _RESOURCE_SPEC_SHA256 in cloudformation/private/extensions.bzl bazel run //cloudformation:update -
Upstream assembler source (rare: only when upstream changes how groups are computed or fixes a Codegen bug):
# Compute the new tarball hash curl -fsSL https://github.com/aws-cloudformation/cloudformation-template-schema/archive/<commit>.tar.gz | shasum -a 256 # Re-delombok + commit curl -fsSL https://projectlombok.org/downloads/lombok-1.18.36.jar -o /tmp/lombok.jar java -jar /tmp/lombok.jar delombok \ <unpacked-src>/src/main/java \ -d cloudformation/private/assembler_src find cloudformation/private/assembler_src -name '*.java' -exec sed -i '' 's/@lombok\.Generated//g' {} + # Bump _TEMPLATE_SCHEMA_COMMIT + _TEMPLATE_SCHEMA_SHA256 in extensions.bzl bazel run //cloudformation:update -
Maven deps (rare: only when upstream pom.xml shifts):
# Edit MODULE.bazel's maven.install(artifacts=[...]) list REPIN=1 bazel run @cfn_assembler_maven//:pin
Path to ~1200 resource types
v0.1 covers AWS::S3::Bucket as a codegen smoke. v0.2 lifts the
hard-coded resource set into a tag class:
cfn_resources = use_extension(
"@rules_cloudformation//cloudformation/private:extensions.bzl",
"cfn_sources_extension",
)
cfn_resources.bundle(
name = "storage",
includes = ["AWS::S3.*", "AWS::DynamoDB.*"],
)
cfn_resources.bundle(
name = "compute",
includes = ["AWS::EC2.*", "AWS::Lambda.*"],
)
so consumers opt into the resource set they care about — declaring
1200 typed Bazel rules per consumer when they use 10 is wasted
analysis time. Bundling lands in v0.2 (see
ROADMAP.md).
Conformance#
2 findings across 2 invariants. 10 contested atoms. See how gating works or the full report.
| version | toolchain |
|---|---|
0.10.0 | //cloudformation/aws_cli:default_aws_cli_toolchain |
| repo | extension |
|---|---|
cfn_assembler_maven | @rules_jvm_external//:extensions.bzl |
Contested atoms
Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.
| Atom | Resolved here | Elsewhere |
|---|---|---|
apple_support | 1.24.2 | 2.2.0 ×1 |
bazel_skylib | 1.8.2 | 1.9.0 ×2 |
gazelle | 0.36.0 | 0.30.0 ×50.44.0 ×10.51.0 ×3 |
nlohmann_json | 3.6.1 | 3.12.0.bcr.1 ×1 |
protobuf | 33.4 | 34.0.bcr.1 ×2 |
rules_go | 0.60.0 | 0.39.1 ×5 |
rules_jvm_external | 6.7 | 6.8 ×4 |
rules_python | 1.7.0 | 2.0.1 ×1 |
rules_swift | 3.1.2 | 3.6.1 ×1 |
upb | 0.0.0-20220923-a547704 | 0.0.0-20230516-61a97ef ×1 |
Dependencies#
Depends on
Used by (3 in the registry)
Versions#
13 published versions, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (sha256) | Source archive |
|---|---|---|
0.10.0 latest | o5sLRyGyxPjE/Hfk… | tag archive ↗ |
0.9.0 | I8Es44u5ZBfnnopM… | tag archive ↗ |
0.8.0 | SYieaShtc2LOSfyO… | tag archive ↗ |
0.7.1 | Y30w0rVGTC6xVZX5… | tag archive ↗ |
0.7.0 | YrTOD/mfKbunLcvF… | tag archive ↗ |
0.6.0 | lK9CR3bCxLgA5txc… | tag archive ↗ |
0.5.0 | nUZF6b8LPapEgTSs… | tag archive ↗ |
0.4.0 | HhDekFeaZKE0d2ND… | tag archive ↗ |
0.3.1 | lNXr7dEGcvSD/TlS… | tag archive ↗ |
0.3.0 | h+8zn+m4lO8/wORV… | tag archive ↗ |
0.2.0 | x1Hzd5JrnQYmw5xO… | tag archive ↗ |
0.1.0 | BVKr+1RNGuCMPp7A… | tag archive ↗ |
0.0.1 | BhexA0JOtRDhYeA5… | tag archive ↗ |
Changelog#
All notable changes to rules_cloudformation. The format is loosely Keep a Changelog — version headers mirror the published bazel-registry entries.
0.10.0 — cfn_join: Fn::Join, and list-valued GetAtt in an Output
-
New
cfn_join(delimiter, values)Starlark helper incloudformation/stack.bzl, rewritten by the aggregator into{"Fn::Join": [delimiter, <values>]}. -
The gap it closes: a CFN
Outputs.*.Valuemust be a string, and severalFn::GetAttattributes are lists —AWS::Route53::HostedZone.NameServersmost visibly. Emitted bare, such an output builds clean, deploys, and then rolls the stack back with “Template format error: Every Value member must be a string.”, which names neither the output nor the attribute. Nothing upstream catches it: the typed rules check property types rather than output values, and the aggregator validates that acfn_getattnames a real resource but has no notion of that attribute’s type. -
Two forms, distinguished by the Starlark type of
valuesand encoded in two separate sentinels (@@cfn:join:/@@cfn:joinlistref:), because both reach the aggregator as a flat string with nothing left to infer from:- a list of literals and/or sentinels →
{"Fn::Join": [d, [v1, v2, …]]}; - a single
cfn_ref/cfn_getatt/cfn_import_value/cfn_find_in_mapsentinel that is itself list-valued →{"Fn::Join": [d, {"Fn::GetAtt": […]}]}.
Wrapping the second form’s argument in a one-element list renders
[d, [{"Fn::GetAtt": …}]]— a list of one element that happens to be a list, not a list-valued reference — which fails the same template format error the join was added to fix, and reads as correct. - a list of literals and/or sentinels →
-
Nested sentinels inside a list are rewritten normally, so
cfn_ref,cfn_getatt,cfn_import_valueandcfn_find_in_mapall compose, and their names are validated as anywhere else (… BucketName[1] points at a name that isn't in the stack). -
The Join separator is the Record Separator (
\036), deliberately notcfn_find_in_map’s Unit Separator (\037): acfn_find_in_mapnested in a join’s value list arrives carrying\037inside its own sentinel, and sharing the character would shred it into fragments that match no prefix and render as literal strings — a silently vanished map lookup. Join-inside-Join is the case two characters cannot rescue, socfn_joinrejects it outright rather than mis-splitting it. -
Rejected at Bazel-load time, each with a message naming the fix: an empty
valueslist (an empty join evaluates to"", so a list comprehension that matched nothing would render an empty property rather than an error), a single value that is a literal or a string-valued sentinel (cfn_sub/cfn_base64/cfn_join— CFN rejects all three asFn::Join’s second argument), a nestedcfn_join, a dict element such ascfn_if(...), and a delimiter or value containing the separator. -
Closes #6.
0.9.0 — resource_depends_on: explicit DependsOn
cloudformation_stack: newresource_depends_onattr, astring_dictof resourcelabel.name-> comma-separated resource names it must be created after. EmitsDependsOn:. A single dependency renders as a bare string, several as a sorted list — both valid CFN, with the string form matching what hand-authored templates use so a generated template stays diffable against the one it replaces.- Only needed where the ordering is real but no value flows between the
two resources, so CFN cannot infer it from a
cfn_ref/cfn_getattedge. The canonical case, which every VPC hits, isAWS::EC2::Routewith aGatewayId: it and theAWS::EC2::VPCGatewayAttachmentboth merelyRefthe gateway, so both depend on IT and neither on the OTHER. CFN is then free to create the route first, which fails with “The gateway ID ‘igw-…’ does not exist or is not attached”. AWS documents the dependency as required. Being a race, it can pass once and fail on the next rebuild — which is why the new smoke test locks a byte-stable template rather than relying on a deploy that worked. - Every name is validated against the stack’s resources at build time,
matching
resource_conditions. Unknown target, unknown dependency, self-dependency and malformed input are each a hard failure — a typo’dDependsOnis otherwise silent and reinstates the exact race it was added to remove. - Closes #1.
0.8.0 — cloudformation_up: in-place --use-previous-template
cloudformation_up: add ause_previous_templateattribute. When set,stackis omitted and the launcher deploys with--use-previous-templateinstead of--template-file— an in-place update of an already-deployed stack that reuses its live template and leaves every unspecified parameter at its previous value. Pass the values to change viaparameter_overrides(orbazel run … -- --parameter-overrides Key=Value).stackanduse_previous_templateare mutually exclusive; exactly one is required.- This lets a repo flip a single parameter (e.g. a container image tag) on a stack whose template another repo owns, without vendoring the whole template.
cloudformation_stack: Conditions + Mappings support. Newcloudformation_conditionandcloudformation_mappingrules emit top-levelConditions/Mappingsblocks; newconditions/mappings/resource_conditionsattrs oncloudformation_stack(the last attaches aCondition:to a resource, validated against the declared conditions). Newcfn_find_in_map(...)sentinel (rewrites toFn::FindInMap, accepts nestedcfn_ref) andcfn_equals/cfn_and/cfn_or/cfn_not/cfn_ifcondition-function helpers.cfn_refnow also accepts CFN pseudo-parameters (AWS::Region, …) without failing name validation. This closes the gap that forced hand-authored YAML for templates using Conditions/Mappings.
0.6.0 — deploy wrappers (cloudformation_up / cloudformation_down)
- New
cloudformation_upandcloudformation_downexecutable rules incloudformation/deploy.bzl.bazel run :foo_updeploys the stack via the aws CLI’scloudformation deploy;bazel run :foo_downcallsdelete-stack. Extra argv after--flows through to the aws CLI. - New aws CLI toolchain abstraction
(
cloudformation/aws_cli/toolchain_type.bzl). The default toolchain (@rules_cloudformation//cloudformation/aws_cli:default_aws_cli_toolchain, auto-registered) is a thinsh_binaryaround systemawson PATH — friendliest for dev + CI runners that already have aws CLI installed. Consumers can register their own toolchain (e.g. multitool-fetched, http_file, sidecar Docker) and Bazel’s toolchain resolution will prefer it without any changes to the deploy rules. - Deploy rule attrs:
stack(the cloudformation_stack target; mandatory),stack_name(defaults to label.name),region,capabilities(list — e.g.["CAPABILITY_IAM"]),parameter_overrides(string_dict). - Smoke targets exercise the launcher generation end-to-end (build-only — no real AWS calls in CI).
0.5.0 — cross-resource refs (cfn_ref / cfn_getatt)
- New
cfn_ref(resource_name)andcfn_getatt(resource_name, attribute)Starlark helpers incloudformation/stack.bzl. They return sentinel strings (@@cfn:ref:Name,@@cfn:getatt:Name.Attr) that the aggregator rewrites into{"Ref": ...}/{"Fn::GetAtt": [...]}CFN intrinsic dicts at template-render time. - Aggregator validates that every sentinel points at a name in the
stack’s resource set — typos fail the Bazel build with a
$.Resources.X.Properties.Ybreadcrumb instead of a deferred AWS-side template rejection. - Smoke stack now exercises both helpers: a
BucketPolicywhoseBucketiscfn_ref("SmokeBucket")and whose policy statement referencescfn_getatt("SmokeBucket", "Arn"). Expected JSON updated.
0.4.0 — cloudformation_stack aggregator
- New
cloudformation_stackrule (cloudformation/stack.bzl) — takes typed-rule shards (fromdefs.bzl) and intrinsic shards (fromintrinsics.bzl) and renders one CFN template:Resources.X = {Type, Properties}per resource, Init shards spliced underResources.<target>.Metadata.AWS::CloudFormation::Init, Interface shards spliced under template-levelMetadata.AWS::CloudFormation::Interface. Optionaldescriptionattr fills the template’sDescriptionfield. - New generated
cfn_types.bzl(snake-id →AWS::Service::Resourcemap for all 1582 spec rules) — needed because snake-case loses the segment boundaries that distinguish e.g.ApplicationAutoScaling::ScalableTargetfromApplication::AutoScalingScalableTarget. Regenerated alongsidedefs.bzlviabazel run //cloudformation:update. - Smoke stack in
examples/smokeaggregates an S3 bucket + EC2 instance + Init metadata + Interface block into one template, byte-stablediff_testagainst committed expected JSON. - Phase-1 limitations — the aggregator uses each contributing
rule’s
label.nameas the CFN logical id (so Bazel targets must be PascalCase and alphanumeric). The<kind_id>_namecustom-name attr on the typed rules is unused for now. Cross-resource refs (Ref/Fn::GetAtt) and deploy wrappers (cloudformation_up/_down) are deferred to later phases.
0.3.1 — CFN intrinsics (Init, Interface)
- New
cloudformation/intrinsics.bzlwith two hand-authored rules for the CFN metadata directives that live outside the Resource Spec:cloudformation_aws_cloudformation_init— emits theAWS::CloudFormation::Initconfig-set tree (configSets + named config blocks) thatcfn-initinterprets at instance boot. Carries atarget_resource_namefor the future stack aggregator to attach the shard under the right resource’sMetadata.cloudformation_aws_cloudformation_interface— emits theAWS::CloudFormation::Interfacetemplate-level metadata that groups Parameters into labelled sections for the AWS console UI.
- Smoke tests in
examples/smokecover both with byte-stablediff_testgates.
Purely additive — no changes to the spec-derived rules in
defs.bzl. Consumers wanting the intrinsics
load("@rules_cloudformation//cloudformation:intrinsics.bzl", ...)
alongside the existing defs.bzl load.
0.3.0 — exhaustive coverage via rules_jsonschema auto-kinds
- Switched from 6 hand-curated service groups to a single
assembler invocation covering the entire CFN Resource Spec,
driven by rules_jsonschema v0.3’s new auto-kinds flags
(
--kinds-pointer-base,--kinds-key-filter, the template flags). Result: 1582 typed Bazel rules across everyAWS::Service::Resourcein the pinned spec (was 26). - Adding a new resource type is now a no-op — bump the upstream
spec pin in
cfn_sources_extensionand the new resources show up in the regenerateddefs.bzl. defs.bzlis now the generated artifact (was a hand-written re-export shim over 6 per-group.bzlfiles). The per-group files (storage.bzl,compute.bzl, …) are removed.- Breaking: the per-kind item-name attr is now namespaced with
the full
aws_service_resourceid (e.g.aws_s3_bucket_name) rather than the v0.2 short tag (bucket_name). The change prevents collisions across the 1500+ resource set; the v0.2 short-tag form wasn’t unique once coverage expanded past one service per “kind concept” (S3, EC2, and S3Outposts all have “bucket”-ish resources, etc.). - Endpoint-description overlay (
cfn_overlay_descriptions) preserved against the new singleassembled_alltarget;AWS::S3::Bucketretains its rich property docs. Endpoint coverage for additional resources is still pin-per-resource incloudformation/private/extensions.bzl.
0.2.0 — 6 groups, 26 typed rules, docstring overlay
- Scaled the codegen pipeline from one S3 Bucket rule to 26
typed Bazel rules across 6 resource-type groups:
storage— AWS::S3::Bucket / BucketPolicy / AccessPointcompute— Lambda Function/Permission, ECS Service/Cluster/TaskDefinition, ECR Repositoryidentity— IAM Role/Policy/ManagedPolicy/User/Groupmessaging— SQS Queue/QueuePolicy, SNS Topic/Subscription/TopicPolicy, EventBridge EventBus/Ruleobservability— CloudWatch Logs LogGroup/LogStream, CloudWatch Alarmdatabase— DynamoDB Table/GlobalTable
- One
cfn_assemble+jsonschema_starlark_codegenpair per group, emittingcloudformation/<group>.bzl. Each gated by its owndiff_test.bazel run //cloudformation:updateregenerates all groups. cfn_overlay_descriptions(cloudformation/private/overlay.bzl) layers AWS-endpoint per-resource property descriptions on top of the assembler-derived schema before codegen. Trades URL-only attr docs for rich prose. v0.2 pins endpoint coverage forAWS::S3::Bucket; expanding to other resources is a one-line pin per resource incloudformation/private/extensions.bzl.defs.bzlre-exports every group’s rules + providers, so consumers only need oneload(...)call.- Internal cleanup: dropped unused
cfn_template_schema_srcfromuse_repo; trimmed stale Java language-version pins from.bazelrc(kept only the runtime pin needed for the remote JDK).
0.1.0 — (retag) Java-assembler-based schema source
- Pivoted the schema source from the per-resource AWS endpoint
(
schema.cloudformation.us-east-1.amazonaws.com/*.json) to the upstream Java assembler inaws-cloudformation/cloudformation-template-schema, run at build time against a sha-pinned snapshot of the AWS CloudFormation Resource Specification (us-east-1, sha2563bf0f8b5...). This aligns the source of truth with cfn-lint and the CFN Linter docs. - New module extension
cfn_sources_extension(incloudformation/private/extensions.bzl) —http_archives the upstream source tarball (commit5d7815b1...) andhttp_files the Resource Specification. - New
cfn_assemblecustom rule (incloudformation/private/assemble.bzl) — runs the assembler with a synthesized YAML config that pins region → local-file URI and declares a single custom resource group. Output: one<group>-spec.jsonconsumable byjsonschema_starlark_codegen. - Assembler sources are vendored in delomboked form under
cloudformation/private/assembler_src/because Bazel 9.1.0’s rules_java toolchain runs JavaBuilder on remotejdk25, and Lombok has no JDK-25-compatible release. The trade-off + refresh procedure is indocs/SCHEMA_SOURCE.md. - One local Codegen patch:
addPrimitiveTypefalls back to “Json” when the upstream-treated-as-primitivepropTypeis null (newer CFN spec entries can haveType: Jsonwith noPrimitiveTypeset — upstream NPEs on these). cloudformation_aws_s3_bucketrule re-derived from the assembledstoragegroup’s schema; the emitted JSON shard byte-matches the v0.0.1/early-v0.1 output for the smoke test inputs.
0.0.1 — scaffold
- Initial scaffold via
rels scaffold. No public API yet.