Repository navigation
fix(E3671): allow gp3 Iops from 100 and warn when it is raised - #4930
Merged
Merged
Conversation
The gp3 branch of the EC2 instance block device mapping extension
applied a minimum of 3000 to Iops, but EC2 accepts any value from 100
upward on this path and clamps it up to 3000. Every value from 100 to
2999 was reported as an error on templates that deploy successfully.
Verified by deployment in us-east-1 on 2026-10-26, same template shape
with only Iops changed:
Iops: 2999 CREATE_COMPLETE; DescribeVolumes reports the volume
provisioned at 3000 IOPS, so EC2 clamped the value up.
Iops: 50 ROLLBACK with "Volume iops of 50 is too low; minimum is
100. (Service: Ec2, Status Code: 400)".
So the hard floor on this path is 100, matching the io1 and io2 branches
in the same file, which already use 100 and agree with the minimum the
service itself reports.
AWS::AutoScaling::LaunchConfiguration is left at 3000. That API
genuinely rejects lower values with "GP3 volumes must have between 3000
and 16000 IOPS.", so its own schema keeps the higher floor. The per-path
split that makes this possible landed in aws-cloudformation#4866.
Fixes aws-cloudformation#4929
kddejong
marked this pull request as ready for review
October 26, 2026 17:10
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4930 +/- ##
==========================================
+ Coverage 94.69% 94.70% +0.01%
==========================================
Files 440 441 +1
Lines 16043 16058 +15
Branches 3113 3113
==========================================
+ Hits 15192 15208 +16
Misses 468 468
+ Partials 383 382 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Dropping the gp3 Iops minimum to 100 removed a false positive but left the user with no signal at all. EC2 does not reject a gp3 Iops between 100 and 2999, it provisions the volume at the gp3 baseline of 3000, so the stack deploys and the value in the template is not the value in effect. Measured on live CloudFormation in us-east-1 on 2026-10-26: an AWS::EC2::Instance with a gp3 Iops of 2999 reached CREATE_COMPLETE and DescribeVolumes reported the volume at 3000 IOPS. W3673 covers AWS::EC2::Instance only. AWS::AutoScaling::LaunchConfiguration is deliberately excluded because it rejects the value outright with "GP3 volumes must have between 3000 and 16000 IOPS.", which E3671 already reports, and a warning beside that error would describe behaviour that does not happen. AWS::OpsWorks::Instance is excluded because the type is absent from the schemas, so E3006 fires first. The warning stops at 100. Below that the volume is genuinely rejected with "Volume iops of 50 is too low; minimum is 100." and E3671 reports it, so one value never draws both a warning and an error. A separate id rather than an extension of W3671: W3671 states that the value is ignored, which is a different fact from the value being adjusted, and its keyword list spans paths where no adjustment happens. The docs/rules.md diff also picks up a stale E3688 description that predates this change, because the file is generated.
W3673 was imperative Python where a schema extension does the same job, against the preference for the simplest rule type and out of step with E3671 and W3672, which are both schema driven on the same path. The condition moves to blockdevicemappings_gp3_iops.json as an if/then with a negated range, and the rule keeps only the message. This also fixes a fault in the imperative version. It matched 'VolumeType' case insensitively, so a value of 'GP3' drew the warning on top of the E3030 enum error it already earns, which is the same "warning beside an error about one value" problem that keeps this rule off AWS::AutoScaling::LaunchConfiguration. 'VolumeType' is a case sensitive enum, so the schema uses a plain const and 'GP3' now reports E3030 alone. A negated range needs an explicit type guard. Without "type": "number" inside the "not", the inner schema is vacuously true for anything that is not a number, the negation then fails, and the warning fires on an unresolved Ref, GetAtt or Fn::If and on every non scalar. Removing the guard fails four of the silence tests, which is what pins it. Behaviour on resolvable values is unchanged: verified by real lint that gp3 Iops of 100 and 2999 warn, 50 reports E3671 alone, 3000 and 80000 are silent, Auto Scaling still reports E3671 with no warning beside it, and the good-fixture sweep is byte identical to a run with no rule.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4929
The first commit removes a false positive; the rest replace the signal that removing it took away.
Problem
The gp3 branch of
src/cfnlint/data/schemas/extensions/aws_ec2_instance/blockdevicemappings.jsonappliedminimum: 3000toIops. EC2 accepts any value from 100 upward on this path and raises it to 3000, so every value from 100 to 2999 was reported as an error on a template that deploys successfully.Dropping the floor to 100 fixes that, but on its own it leaves the user with nothing: a template asking for 2999 IOPS gets a volume provisioned at 3000 and the linter says nothing at all. The value in the template is not the value in effect, and that deserves a warning. Hence the second commit.
Evidence
Deployments against live CloudFormation in us-east-1 on 2026-10-26, same template shape, only
Iopschanged. Stacks deleted afterwards.Iops2999CREATE_COMPLETE.DescribeVolumesreports the volume provisioned at 3000 IOPS — EC2 raised the value.50ROLLBACK:Volume iops of 50 is too low; minimum is 100. (Service: Ec2, Status Code: 400)So the hard floor on this path is 100, not 3000, and between 100 and 2999 the value is adjusted rather than refused. The io1 and io2 branches in the same file already use
minimum: 100, matching theminimum is 100the service itself reports — gp3's3000was the outlier in its own file.An
AWS::EC2::Volumewith gp3Iops: 2999(us-east-1, 2026-10-24) behaves the same way: it deploys and provisions at 3000.AWS::AutoScaling::LaunchConfigurationis deliberately left at 3000. That API genuinely rejects lower values, with its own error text:The per-path split that lets each path own its own numbers landed in #4866, so the schema change is confined to the EC2 instance file.
Changes
1. Schema floor. One value, gp3 branch of the EC2 instance schema only:
"Iops": { "maximum": 80000, - "minimum": 3000 + "minimum": 100 }maximum: 80000is unchanged, andaws_autoscaling_launchconfiguration/blockdevicemappings.jsonis untouched.2. New warning W3673 (
EbsIopsAdjusted), for the case the schema change made silent:It fires for a gp3
Iopsfrom 100 up to 2999 and states the value and what it becomes, because the reader is looking at their template rather than at the rule.The condition is a schema extension,
aws_ec2_instance/blockdevicemappings_gp3_iops.json, matching E3671 and W3672 which are both schema driven on this same path. The rule itself carries only the message:{ "if": { "properties": {"VolumeType": {"const": "gp3", "type": "string"}}, "required": ["VolumeType"] }, "then": { "properties": { "Iops": {"not": {"exclusiveMaximum": 3000, "minimum": 100, "type": "number"}} } } }Two details in that schema are load bearing:
"type": "number"inside thenot. Without it the inner schema is vacuously true for anything that is not a number, the negation then fails, and the warning fires on an unresolvedRef,GetAttorFn::Ifand on every non scalar. Removing the guard fails four of the silence tests.constrather than a case insensitive match.VolumeTypeis a case sensitive enum, soGP3already earnsE3030 'GP3' is not one of [...]. Matching it here would stack a warning on top of that error, which is the same objection that keeps this rule offAWS::AutoScaling::LaunchConfiguration.Scope of W3673
Covers
AWS::EC2::Instanceonly. Each exclusion is deliberate:AWS::AutoScaling::LaunchConfigurationis excluded. It rejects the value rather than adjusting it, E3671 already errors there correctly, and a warning saying the value "is raised to 3000" would sit next to an error about the same value and describe behaviour that does not happen.AWS::OpsWorks::Instanceis excluded. The type is absent from the schemas, so E3006 fires first.AWS::EC2::LaunchTemplateandAWS::EC2::SpotFleetare excluded, and this is the part I could not prove. Neither is validated at stack create — Spot Fleet acceptedIops: 999999without complaint. The volume is created by EBS when an instance launches, so adjustment is the plausible expectation, and theAWS::EC2::Volumecontrol above shows EBS adjusting rather than refusing. But that is inference, not measurement: confirming it means actually launching an instance from a launch template, which I have not done. Rather than assert adjustment on a path I have not measured, the rule leaves those two alone. Worth noting that the schema change in commit 1 also silences W3672 on those paths for this value range, since W3672 loads the same file — previously a LaunchTemplate with gp3Iops: 2999reportedW3672 2999 is less than the minimum of 3000, and now reports nothing. That old message asserted a launch-time failure that theEC2::Volumeevidence suggests does not occur, so losing it is defensible, but a measured replacement warning for those two paths is follow-up work rather than something this PR settles.Why a new id rather than extending W3671
W3671 states that
Iopsis ignored, which is a different fact from the value being adjusted — a user reading "ignored" would reasonably conclude the volume has no provisioned IOPS setting at all. W3671's keyword list also spans five paths, only some of which adjust, so folding this in would put path scoping inside the code of a rule whose keyword list had become the union of two unrelated behaviours.The lower bound
The warning stops at 100. Below that the volume is genuinely rejected and E3671 now reports it at the real floor, so no single value draws both a warning and an error.
Verification
Real lints, not only unit tests.
upstream/main)AWS::EC2::Instance, gp3Iops: 2999E3671 2999 is less than the minimum of 3000W3673 'Iops' of 2999 is raised to 3000 for 'VolumeType' 'gp3'AWS::EC2::Instance, gp3Iops: 100(new floor, boundary)E3671 100 is less than the minimum of 3000W3673 'Iops' of 100 is raised to 3000 for 'VolumeType' 'gp3'AWS::EC2::Instance, gp3Iops: 50E3671 50 is less than the minimum of 3000E3671 50 is less than the minimum of 100— error only, no warningAWS::EC2::Instance, gp3Iops: 3000AWS::EC2::Instance, gp3Iops: 80000AWS::AutoScaling::LaunchConfiguration, gp3Iops: 2999E3671 ... minimum of 3000E3671 ... minimum of 3000— unchanged, and no W3673 beside itAWS::EC2::Instance, gp3Iops: "2999"(string)E3671 ... minimum of 3000W3673 'Iops' of 2999 is raised to 3000 for 'VolumeType' 'gp3'AWS::EC2::Instance, gp3VolumeType: GP3,Iops: 2999E3030+E3671E3030alone — no W3673 stacked on an invalid enumValid templates confirmed to stay silent, by real lint: gp3
Iops: 3000(the boundary), gp3Iops: 80000(the maximum), gp3 withIopsabsent, gp3Iops: !Refto a parameter, gp3Iops: !If [...], and anEbsobject that is itself an!Ifwith a gp3 body in both branches.No new false positives — the good-fixture sweep is byte-identical with and without the new rule, and contains no
W3673at all:cfn-lint -f parseable -t "test/fixtures/templates/good/**/*.yaml"The full test suite produces the same set of results before and after, plus the 21 new passing cases.
Tests
test/unit/rules/resources/ec2/test_ebs.py— six cases for the schema floor, covering the silence cases as well as the firing one: gp32999silent, gp3100silent at the new boundary, gp399firing atminimum of 100,Iopsabsent silent,Iops: {"Ref": ...}silent, andAWS::AutoScaling::LaunchConfigurationgp32999still firing atminimum of 3000to pin that path against drift. Confirmed non-vacuous: with the schema change reverted, the three floor-dependent cases fail.test/unit/rules/resources/ec2/test_ebs_iops_adjusted.py— 24 cases for W3673, weighted toward silence:2999and gp3100, and with an unrelated sibling present3000,8000099,50— E3671's territoryVolumeTypemakes it inapplicable:io1,io2,gp2, andVolumeTypeabsentVolumeType: GP3, which is anE3030enum error rather than an adjusted valueIopsabsentRef,Fn::GetAtt,Fn::IfonIops,RefonVolumeType, andFn::Ifwrapping the wholeEbsobjectIops: [],{},null, a boolean, and a non-numeric stringConfirmed non-vacuous: deleting
"type": "number"from the schema fails four of the silence cases.A string
Iops: "2999"is covered by real lint rather than by a unit case.test/unit/conftest.pybuilds its validator withstrict_types=True, under which"2999"is not a number and the rule is silent, whereas the real validation path evolves tostrict_types=Falseand the warning fires — so a unit assertion there would be testing the harness rather than the behaviour.Note on the docs diff
docs/rules.mdis generated, and regenerating it for W3673 also picks up a staleE3688description that predates this branch. That one line is not part of this change; hand-reverting it would leave the file out of step with its own generator.