Skip to content

fix(E3671): allow gp3 Iops from 100 and warn when it is raised - #4930

Merged
kddejong merged 3 commits into
aws-cloudformation:mainfrom
kddejong:fix/issue/4929
Oct 26, 2026
Merged

kddejong merged 3 commits into
aws-cloudformation:mainfrom
kddejong:fix/issue/4929

Conversation

@kddejong

@kddejong kddejong commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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.json applied minimum: 3000 to Iops. 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.

Resources:
  Inst:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-1234567890abcdef0
      BlockDeviceMappings:
        - DeviceName: /dev/sdb
          Ebs:
            VolumeType: gp3
            Iops: 2999
            VolumeSize: 100
E3671 2999 is less than the minimum of 3000

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 Iops changed. Stacks deleted afterwards.

Iops Result
2999 CREATE_COMPLETE. DescribeVolumes reports the volume provisioned at 3000 IOPS — EC2 raised the value.
50 ROLLBACK: 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 the minimum is 100 the service itself reports — gp3's 3000 was the outlier in its own file.

An AWS::EC2::Volume with gp3 Iops: 2999 (us-east-1, 2026-10-24) behaves the same way: it deploys and provisions at 3000.

AWS::AutoScaling::LaunchConfiguration is deliberately left at 3000. That API genuinely rejects lower values, with its own error text:

GP3 volumes must have between 3000 and 16000 IOPS.

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: 80000 is unchanged, and aws_autoscaling_launchconfiguration/blockdevicemappings.json is untouched.

2. New warning W3673 (EbsIopsAdjusted), for the case the schema change made silent:

W3673 'Iops' of 2999 is raised to 3000 for 'VolumeType' 'gp3'

It fires for a gp3 Iops from 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 the not. 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 unresolved Ref, GetAtt or Fn::If and on every non scalar. Removing the guard fails four of the silence tests.
  • const rather than a case insensitive match. VolumeType is a case sensitive enum, so GP3 already earns E3030 '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 off AWS::AutoScaling::LaunchConfiguration.

Scope of W3673

Covers AWS::EC2::Instance only. Each exclusion is deliberate:

  • AWS::AutoScaling::LaunchConfiguration is 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::Instance is excluded. The type is absent from the schemas, so E3006 fires first.
  • AWS::EC2::LaunchTemplate and AWS::EC2::SpotFleet are excluded, and this is the part I could not prove. Neither is validated at stack create — Spot Fleet accepted Iops: 999999 without complaint. The volume is created by EBS when an instance launches, so adjustment is the plausible expectation, and the AWS::EC2::Volume control 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 gp3 Iops: 2999 reported W3672 2999 is less than the minimum of 3000, and now reports nothing. That old message asserted a launch-time failure that the EC2::Volume evidence 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 Iops is 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.

cfn-lint --update-specs
cfn-lint -f parseable -t template.yaml
Template Before (upstream/main) After (this branch)
AWS::EC2::Instance, gp3 Iops: 2999 E3671 2999 is less than the minimum of 3000 W3673 'Iops' of 2999 is raised to 3000 for 'VolumeType' 'gp3'
AWS::EC2::Instance, gp3 Iops: 100 (new floor, boundary) E3671 100 is less than the minimum of 3000 W3673 'Iops' of 100 is raised to 3000 for 'VolumeType' 'gp3'
AWS::EC2::Instance, gp3 Iops: 50 E3671 50 is less than the minimum of 3000 E3671 50 is less than the minimum of 100 — error only, no warning
AWS::EC2::Instance, gp3 Iops: 3000 silent silent
AWS::EC2::Instance, gp3 Iops: 80000 silent silent
AWS::AutoScaling::LaunchConfiguration, gp3 Iops: 2999 E3671 ... minimum of 3000 E3671 ... minimum of 3000 — unchanged, and no W3673 beside it
AWS::EC2::Instance, gp3 Iops: "2999" (string) E3671 ... minimum of 3000 W3673 'Iops' of 2999 is raised to 3000 for 'VolumeType' 'gp3'
AWS::EC2::Instance, gp3 VolumeType: GP3, Iops: 2999 E3030 + E3671 E3030 alone — no W3673 stacked on an invalid enum

Valid templates confirmed to stay silent, by real lint: gp3 Iops: 3000 (the boundary), gp3 Iops: 80000 (the maximum), gp3 with Iops absent, gp3 Iops: !Ref to a parameter, gp3 Iops: !If [...], and an Ebs object that is itself an !If with 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 W3673 at 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: gp3 2999 silent, gp3 100 silent at the new boundary, gp3 99 firing at minimum of 100, Iops absent silent, Iops: {"Ref": ...} silent, and AWS::AutoScaling::LaunchConfiguration gp3 2999 still firing at minimum of 3000 to 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:

  • fires on gp3 2999 and gp3 100, and with an unrelated sibling present
  • silent at and above the baseline: 3000, 80000
  • silent below the minimum: 99, 50 — E3671's territory
  • silent when a sibling VolumeType makes it inapplicable: io1, io2, gp2, and VolumeType absent
  • silent on VolumeType: GP3, which is an E3030 enum error rather than an adjusted value
  • silent on Iops absent
  • silent on unresolved functions, on the value and on the enclosing object: Ref, Fn::GetAtt, Fn::If on Iops, Ref on VolumeType, and Fn::If wrapping the whole Ebs object
  • silent on non-scalars: a list instance, Iops: [], {}, null, a boolean, and a non-numeric string

Confirmed 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.py builds its validator with strict_types=True, under which "2999" is not a number and the rule is silent, whereas the real validation path evolves to strict_types=False and the warning fires — so a unit assertion there would be testing the harness rather than the behaviour.

Note on the docs diff

docs/rules.md is generated, and regenerating it for W3673 also picks up a stale E3688 description 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.

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
kddejong marked this pull request as ready for review October 26, 2026 17:10
@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.70%. Comparing base (658d6f1) to head (eca5396).
⚠️ Report is 1 commits behind head on main.

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     
Flag Coverage Δ
unittests 94.70% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
@kddejong kddejong changed the title fix(E3671): allow gp3 Iops from 100 on AWS::EC2::Instance fix(E3671): allow gp3 Iops from 100 and warn when it is raised Oct 26, 2026
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.
@kddejong
kddejong merged commit ccefd71 into aws-cloudformation:main Oct 26, 2026
24 of 25 checks passed
@kddejong
kddejong deleted the fix/issue/4929 branch October 26, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

E3671 reports a false positive for gp3 Iops between 100 and 2999 — EC2 clamps, it does not reject

1 participant