localstack-core 4.14.1.dev48__py3-none-any.whl → 4.14.1.dev50__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. localstack/services/cloudformation/engine/v2/change_set_model_transform.py +3 -3
  2. localstack/services/cloudformation/engine/v2/resolving.py +95 -0
  3. localstack/services/cloudformation/v2/entities.py +148 -5
  4. localstack/services/cloudformation/v2/provider.py +32 -259
  5. localstack/services/iam/models.py +215 -0
  6. localstack/services/iam/policy_validation.py +561 -0
  7. localstack/services/iam/provider.py +5358 -448
  8. localstack/services/iam/resources/aws_managed_policies.json +143872 -0
  9. localstack/services/iam/resources/policy_simulator.py +33 -43
  10. localstack/services/iam/utils.py +244 -0
  11. localstack/services/providers.py +2 -4
  12. localstack/services/s3/presigned_url.py +5 -12
  13. localstack/services/sts/models.py +52 -8
  14. localstack/services/sts/provider.py +460 -51
  15. localstack/testing/pytest/fixtures.py +146 -1
  16. localstack/testing/snapshots/transformer_utility.py +4 -0
  17. localstack/version.py +2 -2
  18. {localstack_core-4.14.1.dev48.dist-info → localstack_core-4.14.1.dev50.dist-info}/METADATA +2 -1
  19. {localstack_core-4.14.1.dev48.dist-info → localstack_core-4.14.1.dev50.dist-info}/RECORD +24 -21
  20. localstack/services/iam/iam_patches.py +0 -163
  21. {localstack_core-4.14.1.dev48.data → localstack_core-4.14.1.dev50.data}/scripts/localstack-supervisor +0 -0
  22. {localstack_core-4.14.1.dev48.dist-info → localstack_core-4.14.1.dev50.dist-info}/WHEEL +0 -0
  23. {localstack_core-4.14.1.dev48.dist-info → localstack_core-4.14.1.dev50.dist-info}/entry_points.txt +0 -0
  24. {localstack_core-4.14.1.dev48.dist-info → localstack_core-4.14.1.dev50.dist-info}/licenses/LICENSE.txt +0 -0
  25. {localstack_core-4.14.1.dev48.dist-info → localstack_core-4.14.1.dev50.dist-info}/top_level.txt +0 -0
@@ -97,9 +97,9 @@ class ChangeSetModelTransform(ChangeSetModelPreproc):
97
97
 
98
98
  def __init__(
99
99
  self,
100
- change_set: ChangeSet,
101
- before_parameters: dict,
102
- after_parameters: dict,
100
+ change_set: "ChangeSet",
101
+ before_parameters: dict | None,
102
+ after_parameters: dict | None,
103
103
  before_template: dict | None,
104
104
  after_template: dict | None,
105
105
  ):
@@ -7,6 +7,10 @@ from typing import Any
7
7
  from botocore.exceptions import ClientError
8
8
 
9
9
  from localstack.aws.connect import connect_to
10
+ from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter
11
+ from localstack.services.cloudformation.engine.validations import ValidationError
12
+ from localstack.services.cloudformation.v2.types import EngineParameter, engine_parameter_value
13
+ from localstack.utils.numbers import is_number
10
14
 
11
15
  LOG = logging.getLogger(__name__)
12
16
 
@@ -14,6 +18,10 @@ LOG = logging.getLogger(__name__)
14
18
  # we don't capture the parameter usage by excluding ${} characters
15
19
  REGEX_DYNAMIC_REF = re.compile(r"{{resolve:([^:]+):([^${}]+)}}")
16
20
 
21
+ SSM_PARAMETER_TYPE_RE = re.compile(
22
+ r"^AWS::SSM::Parameter::Value<(?P<listtype>List<)?(?P<innertype>[^>]+)>?>$"
23
+ )
24
+
17
25
 
18
26
  @dataclass
19
27
  class DynamicReference:
@@ -100,3 +108,90 @@ def perform_dynamic_reference_lookup(
100
108
  "Unsupported service for dynamic parameter: service_name=%s", reference.service_name
101
109
  )
102
110
  return None
111
+
112
+
113
+ def resolve_parameters(
114
+ template: dict | None,
115
+ parameters: dict | None,
116
+ account_id: str,
117
+ region_name: str,
118
+ before_parameters: dict | None,
119
+ ) -> dict[str, EngineParameter]:
120
+ template_parameters = template.get("Parameters", {})
121
+ resolved_parameters = {}
122
+ invalid_parameters = []
123
+ for name, parameter in template_parameters.items():
124
+ given_value = parameters.get(name)
125
+ default_value = parameter.get("Default")
126
+ resolved_parameter = EngineParameter(
127
+ type_=parameter["Type"],
128
+ given_value=given_value,
129
+ default_value=default_value,
130
+ no_echo=parameter.get("NoEcho"),
131
+ )
132
+
133
+ # validate the type
134
+ if parameter["Type"] == "Number" and not is_number(
135
+ engine_parameter_value(resolved_parameter)
136
+ ):
137
+ raise ValidationError(f"Parameter '{name}' must be a number.")
138
+
139
+ # TODO: support other parameter types
140
+ if match := SSM_PARAMETER_TYPE_RE.match(parameter["Type"]):
141
+ inner_type = match.group("innertype")
142
+ is_list_type = match.group("listtype") is not None
143
+ if is_list_type or inner_type == "CommaDelimitedList":
144
+ # list types
145
+ try:
146
+ resolved_value = resolve_ssm_parameter(
147
+ account_id, region_name, given_value or default_value
148
+ )
149
+ resolved_parameter["resolved_value"] = resolved_value.split(",")
150
+ except Exception:
151
+ raise ValidationError(
152
+ f"Parameter {name} should either have input value or default value"
153
+ )
154
+ else:
155
+ try:
156
+ resolved_parameter["resolved_value"] = resolve_ssm_parameter(
157
+ account_id, region_name, given_value or default_value
158
+ )
159
+ except Exception as e:
160
+ # we could not find the parameter however CDK provides the resolved value rather than the
161
+ # parameter name again so try to look up the value in the previous parameters
162
+ if (
163
+ before_parameters
164
+ and (before_param := before_parameters.get(name))
165
+ and isinstance(before_param, dict)
166
+ and (resolved_value := before_param.get("resolved_value"))
167
+ ):
168
+ LOG.debug(
169
+ "Parameter %s could not be resolved, using previous value of %s",
170
+ name,
171
+ resolved_value,
172
+ )
173
+ resolved_parameter["resolved_value"] = resolved_value
174
+ else:
175
+ raise ValidationError(
176
+ f"Parameter {name} should either have input value or default value"
177
+ ) from e
178
+ elif given_value is None and default_value is None:
179
+ invalid_parameters.append(name)
180
+ continue
181
+
182
+ resolved_parameters[name] = resolved_parameter
183
+
184
+ if invalid_parameters:
185
+ raise ValidationError(f"Parameters: [{','.join(invalid_parameters)}] must have values")
186
+
187
+ for name, parameter in resolved_parameters.items():
188
+ if (
189
+ parameter.get("resolved_value") is None
190
+ and parameter.get("given_value") is None
191
+ and parameter.get("default_value") is None
192
+ ):
193
+ raise ValidationError(
194
+ f"Parameter {name} should either have input value or default value"
195
+ )
196
+
197
+ return resolved_parameters
@@ -1,6 +1,8 @@
1
+ from dataclasses import dataclass
1
2
  from datetime import UTC, datetime
2
3
  from typing import NotRequired, TypedDict
3
4
 
5
+ from localstack import config
4
6
  from localstack.aws.api.cloudformation import (
5
7
  Capability,
6
8
  ChangeSetStatus,
@@ -28,9 +30,14 @@ from localstack.services.cloudformation.engine.entities import (
28
30
  StackIdentifierV2,
29
31
  )
30
32
  from localstack.services.cloudformation.engine.v2.change_set_model import (
33
+ ChangeSetModel,
31
34
  ChangeType,
32
35
  UpdateModel,
33
36
  )
37
+ from localstack.services.cloudformation.engine.v2.change_set_resource_support_checker import (
38
+ ChangeSetResourceSupportChecker,
39
+ )
40
+ from localstack.services.cloudformation.engine.v2.resolving import resolve_parameters
34
41
  from localstack.services.cloudformation.v2.types import EngineParameter, ResolvedResource
35
42
  from localstack.utils.aws import arns
36
43
  from localstack.utils.strings import long_uid, short_uid
@@ -190,11 +197,19 @@ class ChangeSetRequestPayload(TypedDict, total=False):
190
197
  ChangeSetType: NotRequired[ChangeSetType]
191
198
 
192
199
 
200
+ @dataclass
201
+ class UpdateModelInputs:
202
+ before_template: dict | None
203
+ after_template: dict | None
204
+ before_parameters: dict | None
205
+ after_parameters: dict | None
206
+ previous_update_model: UpdateModel | None = None
207
+
208
+
193
209
  class ChangeSet:
194
210
  change_set_name: str
195
211
  change_set_id: str
196
212
  change_set_type: ChangeSetType
197
- update_model: UpdateModel | None
198
213
  status: ChangeSetStatus
199
214
  status_reason: str | None
200
215
  execution_status: ExecutionStatus
@@ -217,7 +232,6 @@ class ChangeSet:
217
232
  self.status = ChangeSetStatus.CREATE_IN_PROGRESS
218
233
  self.status_reason = None
219
234
  self.execution_status = ExecutionStatus.AVAILABLE
220
- self.update_model = None
221
235
  self.creation_time = datetime.now(tz=UTC)
222
236
  self.resolved_parameters = {}
223
237
  self.tags = request_payload.get("Tags") or []
@@ -232,9 +246,9 @@ class ChangeSet:
232
246
  region_name=self.stack.region_name,
233
247
  )
234
248
  self.processed_template = None
235
-
236
- def set_update_model(self, update_model: UpdateModel) -> None:
237
- self.update_model = update_model
249
+ # Do not add the `_cached_update_model` attribute to the type annotations of the class. This is a runtime-only
250
+ # attribute that cannot be serialized by our persistence framework.
251
+ self._cached_update_model = None
238
252
 
239
253
  def set_change_set_status(self, status: ChangeSetStatus):
240
254
  self.status = status
@@ -243,6 +257,8 @@ class ChangeSet:
243
257
  self.execution_status = execution_status
244
258
 
245
259
  def has_changes(self) -> bool:
260
+ if self.update_model is None:
261
+ raise ValueError("update model has not been computed")
246
262
  return self.update_model.node_template.change_type != ChangeType.UNCHANGED
247
263
 
248
264
  @property
@@ -253,6 +269,133 @@ class ChangeSet:
253
269
  def region_name(self) -> str:
254
270
  return self.stack.region_name
255
271
 
272
+ @property
273
+ def update_model(self) -> UpdateModel | None:
274
+ # non-persisted state, runtime cache
275
+ # TODO: maybe move out of the `ChangeSet` class into the provider
276
+ return self._cached_update_model
277
+
278
+ def compute_update_model(self, inputs: UpdateModelInputs):
279
+ from localstack.services.cloudformation.engine.transformers import (
280
+ FailedTransformationException,
281
+ )
282
+ from localstack.services.cloudformation.engine.v2.change_set_model_transform import (
283
+ ChangeSetModelTransform,
284
+ )
285
+ from localstack.services.cloudformation.engine.v2.change_set_model_validator import (
286
+ ChangeSetModelValidator,
287
+ )
288
+
289
+ resolved_parameters = None
290
+ if inputs.after_parameters is not None:
291
+ resolved_parameters = resolve_parameters(
292
+ inputs.after_template,
293
+ inputs.after_parameters,
294
+ self.account_id,
295
+ self.region_name,
296
+ inputs.before_parameters,
297
+ )
298
+
299
+ self.resolved_parameters = resolved_parameters or {}
300
+
301
+ # Create and preprocess the update graph for this template update.
302
+ change_set_model = ChangeSetModel(
303
+ before_template=inputs.before_template,
304
+ after_template=inputs.after_template,
305
+ before_parameters=inputs.before_parameters,
306
+ after_parameters=resolved_parameters,
307
+ )
308
+ raw_update_model: UpdateModel = change_set_model.get_update_model()
309
+ # If there exists an update model which operated in the 'before' version of this change set,
310
+ # port the runtime values computed for the before version into this latest update model.
311
+ if inputs.previous_update_model:
312
+ raw_update_model.before_runtime_cache.clear()
313
+ raw_update_model.before_runtime_cache.update(
314
+ inputs.previous_update_model.after_runtime_cache
315
+ )
316
+ self._cached_update_model = raw_update_model
317
+
318
+ # Apply global transforms.
319
+ # TODO: skip this process iff both versions of the template don't specify transform blocks.
320
+ change_set_model_transform = ChangeSetModelTransform(
321
+ change_set=self,
322
+ before_parameters=inputs.before_parameters,
323
+ after_parameters=resolved_parameters,
324
+ before_template=inputs.before_template,
325
+ after_template=inputs.after_template,
326
+ )
327
+ try:
328
+ transformed_before_template, transformed_after_template = (
329
+ change_set_model_transform.transform()
330
+ )
331
+ except FailedTransformationException as e:
332
+ self.status = ChangeSetStatus.FAILED
333
+ self.status_reason = e.message
334
+ self.stack.set_stack_status(status=StackStatus.ROLLBACK_IN_PROGRESS, reason=e.message)
335
+ self.stack.set_stack_status(status=StackStatus.CREATE_FAILED)
336
+ return
337
+
338
+ # Remodel the update graph after the applying the global transforms.
339
+ change_set_model = ChangeSetModel(
340
+ before_template=transformed_before_template,
341
+ after_template=transformed_after_template,
342
+ before_parameters=inputs.before_parameters,
343
+ after_parameters=resolved_parameters,
344
+ )
345
+ update_model = change_set_model.get_update_model()
346
+ # Bring the cache for the previous operations forward in the update graph for this version
347
+ # of the templates. This enables downstream update graph visitors to access runtime
348
+ # information computed whilst evaluating the previous version of this template, and during
349
+ # the transformations.
350
+ update_model.before_runtime_cache.update(raw_update_model.before_runtime_cache)
351
+ update_model.after_runtime_cache.update(raw_update_model.after_runtime_cache)
352
+ self._cached_update_model = update_model
353
+
354
+ # perform validations
355
+ validator = ChangeSetModelValidator(
356
+ change_set=self,
357
+ )
358
+ validator.validate()
359
+
360
+ # hacky
361
+ if transform := raw_update_model.node_template.transform:
362
+ if transform.global_transforms:
363
+ # global transforms should always be considered "MODIFIED"
364
+ update_model.node_template.change_type = ChangeType.MODIFIED
365
+ self.processed_template = transformed_after_template
366
+
367
+ if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
368
+ support_visitor = ChangeSetResourceSupportChecker(change_set_type=self.change_set_type)
369
+ support_visitor.visit(self._cached_update_model.node_template)
370
+ failure_messages = support_visitor.failure_messages
371
+ if failure_messages:
372
+ reason_suffix = ", ".join(failure_messages)
373
+ status_reason = f"{ChangeSetResourceSupportChecker.TITLE_MESSAGE} {reason_suffix}"
374
+
375
+ self.status_reason = status_reason
376
+ self.set_change_set_status(ChangeSetStatus.FAILED)
377
+ failure_transitions = {
378
+ ChangeSetType.CREATE: (
379
+ StackStatus.ROLLBACK_IN_PROGRESS,
380
+ StackStatus.CREATE_FAILED,
381
+ ),
382
+ ChangeSetType.UPDATE: (
383
+ StackStatus.UPDATE_ROLLBACK_IN_PROGRESS,
384
+ StackStatus.UPDATE_ROLLBACK_FAILED,
385
+ ),
386
+ ChangeSetType.IMPORT: (
387
+ StackStatus.IMPORT_ROLLBACK_IN_PROGRESS,
388
+ StackStatus.IMPORT_ROLLBACK_FAILED,
389
+ ),
390
+ }
391
+ transitions = failure_transitions.get(self.change_set_type)
392
+ if transitions:
393
+ first_status, *remaining_statuses = transitions
394
+ self.stack.set_stack_status(first_status, status_reason)
395
+ for status in remaining_statuses:
396
+ self.stack.set_stack_status(status)
397
+ return
398
+
256
399
 
257
400
  class StackInstance:
258
401
  def __init__(