Skip to content

Commit 6af9e98

Browse files
authored
Validate parameter group changes (#132)
* feat: validate parameter group update with apply_method only changes * feat: validate parameter group apply_method only changes on creation * feat: validate immediate can't be used on static parameters * feat: use paginator when response can be paginated * chore: bump version to 0.6.7 Signed-off-by: Di Wang <[email protected]>
1 parent 29ce6d2 commit 6af9e98

File tree

12 files changed

+498
-77
lines changed

12 files changed

+498
-77
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
FROM quay.io/redhat-services-prod/app-sre-tenant/er-base-terraform-main/er-base-terraform-main:tf-1.6.6-py-3.12-v0.3.4-1@sha256:1579e58702182a02b55a0841254d188a6b99ff42c774279890567338c863a31b AS base
22
# keep in sync with pyproject.toml
3-
LABEL konflux.additional-tags="0.6.6"
3+
LABEL konflux.additional-tags="0.6.7"
44

55
FROM base AS builder
66
COPY --from=ghcr.io/astral-sh/uv:0.6.12@sha256:515b886e8eb99bcf9278776d8ea41eb4553a794195ef5803aa7ca6258653100d /uv /bin/uv

er_aws_rds/errors.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

er_aws_rds/input.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
model_validator,
1212
)
1313

14-
from er_aws_rds.errors import RDSLogicalReplicationError
15-
1614
ENHANCED_MONITORING_ROLE_NAME_MAX_LENGTH = 64
1715

1816

@@ -264,20 +262,6 @@ def replication(self) -> Self:
264262
self.backup_retention_period = 0
265263
return self
266264

267-
@model_validator(mode="after")
268-
def validate_parameter_group_parameters(self) -> Self:
269-
"""Validate that every parameter complies with our requirements"""
270-
if not self.parameter_group:
271-
return self
272-
for parameter in self.parameter_group.parameters or []:
273-
if (
274-
parameter.name == "rds.logical_replication"
275-
and parameter.apply_method != "pending-reboot"
276-
):
277-
msg = "rds.logical_replication must be set to pending-reboot"
278-
raise RDSLogicalReplicationError(msg)
279-
return self
280-
281265
@model_validator(mode="after")
282266
def parameter_groups(self) -> Self:
283267
"""

hooks/post_plan.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
ResourceChange,
1111
TerraformJsonPlanParser,
1212
)
13+
from mypy_boto3_rds.type_defs import ParameterOutputTypeDef
1314

14-
from er_aws_rds.input import AppInterfaceInput
15+
from er_aws_rds.input import (
16+
AppInterfaceInput,
17+
Parameter,
18+
)
1519
from hooks.utils.aws_api import AWSApi
1620
from hooks.utils.envvars import RuntimeEnvVars
1721
from hooks.utils.logger import setup_logging
@@ -158,13 +162,122 @@ def _validate_no_changes_when_blue_green_deployment_enabled(self) -> None:
158162
f"No changes allowed when Blue/Green Deployment enabled, detected changes: {resource_changes}"
159163
)
160164

165+
@staticmethod
166+
def _is_apply_method_change_only(
167+
before_parameter: Parameter,
168+
after_parameter: Parameter,
169+
) -> bool:
170+
return (
171+
before_parameter.name == after_parameter.name
172+
and before_parameter.value == after_parameter.value
173+
and before_parameter.apply_method != after_parameter.apply_method
174+
)
175+
176+
def _validate_parameter_group_change(self, change: Change) -> None:
177+
if not change.after:
178+
return
179+
180+
after_parameter_by_name = {
181+
parameter["name"]: Parameter.model_validate(parameter)
182+
for parameter in change.after.get("parameter") or []
183+
}
184+
185+
if not after_parameter_by_name:
186+
return
187+
188+
default_parameter_by_name = self.aws_api.get_engine_default_parameters(
189+
change.after["family"],
190+
list(after_parameter_by_name.keys()),
191+
)
192+
193+
before_parameter_by_name = (
194+
{
195+
parameter["name"]: Parameter.model_validate(parameter)
196+
for parameter in change.before.get("parameter") or []
197+
}
198+
if change.before
199+
else {
200+
parameter["ParameterName"]: Parameter(
201+
name=parameter["ParameterName"],
202+
value=parameter.get("ParameterValue", ""),
203+
apply_method=parameter.get("ApplyMethod", "pending-reboot"),
204+
)
205+
for parameter in default_parameter_by_name.values()
206+
}
207+
)
208+
209+
self._validate_apply_method_change_only(
210+
before_parameter_by_name, after_parameter_by_name
211+
)
212+
self._validate_apply_method_with_apply_type(
213+
default_parameter_by_name, after_parameter_by_name
214+
)
215+
216+
def _validate_apply_method_change_only(
217+
self,
218+
before_parameter_by_name: dict[str, Parameter],
219+
after_parameter_by_name: dict[str, Parameter],
220+
) -> None:
221+
common_names = before_parameter_by_name.keys() & after_parameter_by_name.keys()
222+
apply_method_change_only_parameter_names = [
223+
name
224+
for name in common_names
225+
if self._is_apply_method_change_only(
226+
before_parameter_by_name[name],
227+
after_parameter_by_name[name],
228+
)
229+
]
230+
if apply_method_change_only_parameter_names:
231+
parameters = ", ".join(apply_method_change_only_parameter_names)
232+
self.errors.append(
233+
f"Problematic plan changes for parameter group detected for parameters: {parameters}. "
234+
"Parameter with only apply_method toggled while value is same as before or default is not allowed, "
235+
"remove the parameter OR change value OR align apply_method with AWS default pending-reboot, "
236+
"checkout details at https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_parameter_group#problematic-plan-changes"
237+
)
238+
239+
def _validate_apply_method_with_apply_type(
240+
self,
241+
default_parameter_by_name: dict[str, ParameterOutputTypeDef],
242+
after_parameter_by_name: dict[str, Parameter],
243+
) -> None:
244+
common_names = default_parameter_by_name.keys() & after_parameter_by_name.keys()
245+
immediate_on_static_parameter_names = [
246+
name
247+
for name in common_names
248+
if (
249+
after_parameter_by_name[name].apply_method == "immediate"
250+
and default_parameter_by_name[name].get("ApplyType") == "static"
251+
)
252+
]
253+
if immediate_on_static_parameter_names:
254+
parameters = ", ".join(immediate_on_static_parameter_names)
255+
self.errors.append(
256+
"cannot use immediate apply method for static parameter, "
257+
f"must be set to pending-reboot: {parameters}"
258+
)
259+
260+
def _validate_parameter_group_changes(self) -> None:
261+
changed_actions = {
262+
Action.ActionCreate,
263+
Action.ActionUpdate,
264+
}
265+
for c in self.plan.resource_changes:
266+
if (
267+
c.change
268+
and changed_actions.intersection(c.change.actions)
269+
and c.type == "aws_db_parameter_group"
270+
):
271+
self._validate_parameter_group_change(c.change)
272+
161273
def validate(self) -> list[str]:
162274
"""Validate method, return validation errors"""
163275
self.errors.clear()
164276
self._validate_version_on_create()
165277
self._validate_version_upgrade()
166278
self._validate_deletion_protection_not_enabled_on_destroy()
167279
self._validate_no_changes_when_blue_green_deployment_enabled()
280+
self._validate_parameter_group_changes()
168281
return self.errors
169282

170283

hooks/utils/aws_api.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
BlueGreenDeploymentTypeDef,
1414
DBInstanceTypeDef,
1515
DBParameterGroupTypeDef,
16+
DescribeDBParametersMessagePaginateTypeDef,
17+
DescribeEngineDefaultParametersMessagePaginateTypeDef,
1618
ParameterOutputTypeDef,
1719
UpgradeTargetTypeDef,
1820
)
@@ -98,19 +100,50 @@ def get_db_parameter_group(self, name: str) -> DBParameterGroupTypeDef | None:
98100
def get_db_parameters(
99101
self,
100102
parameter_group_name: str,
101-
parameter_names: list[str],
103+
parameter_names: list[str] | None = None,
102104
) -> dict[str, ParameterOutputTypeDef]:
103105
"""Get DB parameters"""
104-
data = self.rds_client.describe_db_parameters(
105-
DBParameterGroupName=parameter_group_name,
106-
Filters=[
106+
kwargs: DescribeDBParametersMessagePaginateTypeDef = {
107+
"DBParameterGroupName": parameter_group_name,
108+
}
109+
if parameter_names:
110+
kwargs["Filters"] = [
107111
{
108112
"Name": "parameter-name",
109113
"Values": parameter_names,
110114
}
111-
],
112-
)
113-
return {item["ParameterName"]: item for item in data["Parameters"] or []}
115+
]
116+
paginator = self.rds_client.get_paginator("describe_db_parameters")
117+
page_iterator = paginator.paginate(**kwargs)
118+
return {
119+
item["ParameterName"]: item
120+
for data in page_iterator
121+
for item in data["Parameters"] or []
122+
}
123+
124+
def get_engine_default_parameters(
125+
self,
126+
parameter_group_family: str,
127+
parameter_names: list[str] | None = None,
128+
) -> dict[str, ParameterOutputTypeDef]:
129+
"""Get engine default parameters"""
130+
kwargs: DescribeEngineDefaultParametersMessagePaginateTypeDef = {
131+
"DBParameterGroupFamily": parameter_group_family,
132+
}
133+
if parameter_names:
134+
kwargs["Filters"] = [
135+
{
136+
"Name": "parameter-name",
137+
"Values": parameter_names,
138+
}
139+
]
140+
paginator = self.rds_client.get_paginator("describe_engine_default_parameters")
141+
page_iterator = paginator.paginate(**kwargs)
142+
return {
143+
item["ParameterName"]: item
144+
for data in page_iterator
145+
for item in data.get("EngineDefaults", {}).get("Parameters") or []
146+
}
114147

115148
def get_db_instance(self, identifier: str) -> DBInstanceTypeDef | None:
116149
"""Get DB instance info"""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "er-aws-rds"
3-
version = "0.6.6"
3+
version = "0.6.7"
44
description = "ERv2 module for managing AWS rds instances"
55
authors = [{ name = "AppSRE", email = "[email protected]" }]
66
license = { text = "Apache 2.0" }

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
def deep_merge(dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]:
1414
"""Merge two dictionaries recursively"""
15-
return dict1.copy() | {
15+
return dict1 | {
1616
key: (
1717
deep_merge(dict1[key], value)
1818
if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict)

0 commit comments

Comments
 (0)