Skip to content

Commit 6a0e8ee

Browse files
authored
CFn telemetry: capture actions on resources (localstack#12798)
1 parent 0f5ebcc commit 6a0e8ee

File tree

4 files changed

+102
-21
lines changed

4 files changed

+102
-21
lines changed
Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,67 @@
1+
import enum
2+
from typing import Self
3+
4+
from localstack.aws.api.cloudformation import ChangeAction
15
from localstack.utils.analytics.metrics import LabeledCounter
26

37
COUNTER_NAMESPACE = "cloudformation"
8+
COUNTER_VERSION = 2
9+
10+
11+
class ActionOptions(enum.StrEnum):
12+
"""
13+
Available actions that can be performed on a resource.
14+
15+
Must support both CFn and CloudControl.
16+
"""
17+
18+
CREATE = "create"
19+
DELETE = "delete"
20+
UPDATE = "update"
21+
# for cloudcontrol
22+
READ = "read"
23+
LIST = "list"
24+
25+
@classmethod
26+
def from_action(cls, action: Self | str | ChangeAction) -> Self:
27+
if isinstance(action, cls):
28+
return action
29+
30+
# only used in CFn
31+
if isinstance(action, ChangeAction):
32+
action = action.value
33+
34+
match action:
35+
case "Add":
36+
return cls.CREATE
37+
case "Modify" | "Dynamic":
38+
return cls.UPDATE
39+
case "Remove":
40+
return cls.DELETE
41+
case "Read":
42+
return cls.READ
43+
case "List":
44+
return cls.LIST
45+
case _:
46+
available_values = [every.value for every in cls]
47+
raise ValueError(
48+
f"Invalid action option '{action}', should be one of {available_values}"
49+
)
50+
451

552
resources = LabeledCounter(
6-
namespace=COUNTER_NAMESPACE, name="resources", labels=["resource_type", "missing"]
53+
namespace=COUNTER_NAMESPACE,
54+
name="resources",
55+
labels=["resource_type", "missing", "action"],
56+
schema_version=COUNTER_VERSION,
757
)
58+
59+
60+
def track_resource_operation(
61+
action: ActionOptions | str, expected_resource_type: str, *, missing: bool
62+
):
63+
resources.labels(
64+
resource_type=expected_resource_type,
65+
missing=missing,
66+
action=ActionOptions.from_action(action),
67+
).increment()

localstack-core/localstack/services/cloudformation/engine/template_deployer.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
from localstack import config
1212
from localstack.aws.connect import connect_to
1313
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
14+
from localstack.services.cloudformation.analytics import track_resource_operation
1415
from localstack.services.cloudformation.deployment_utils import (
1516
PLACEHOLDER_AWS_NO_VALUE,
1617
get_action_name_for_resource_change,
18+
log_not_available_message,
1719
remove_none_values,
1820
)
1921
from localstack.services.cloudformation.engine.changes import ChangeConfig, ResourceChange
@@ -31,6 +33,7 @@
3133
)
3234
from localstack.services.cloudformation.resource_provider import (
3335
Credentials,
36+
NoResourceProvider,
3437
OperationStatus,
3538
ProgressEvent,
3639
ResourceProviderExecutor,
@@ -1295,7 +1298,9 @@ def apply_change(self, change: ChangeConfig, stack: Stack) -> None:
12951298
action, logical_resource_id=resource_id
12961299
)
12971300

1298-
resource_provider = executor.try_load_resource_provider(get_resource_type(resource))
1301+
resource_type = get_resource_type(resource)
1302+
resource_provider = executor.try_load_resource_provider(resource_type)
1303+
track_resource_operation(action, resource_type, missing=resource_provider is None)
12991304
if resource_provider is not None:
13001305
# add in-progress event
13011306
resource_status = f"{get_action_name_for_resource_change(action)}_IN_PROGRESS"
@@ -1321,6 +1326,15 @@ def apply_change(self, change: ChangeConfig, stack: Stack) -> None:
13211326
resource_provider, resource, resource_provider_payload
13221327
)
13231328
else:
1329+
# track that we don't handle the resource, and possibly raise an exception
1330+
log_not_available_message(
1331+
resource_type,
1332+
f'No resource provider found for "{resource_type}"',
1333+
)
1334+
1335+
if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
1336+
raise NoResourceProvider
1337+
13241338
resource["PhysicalResourceId"] = MOCK_REFERENCE
13251339
progress_event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
13261340

@@ -1419,6 +1433,7 @@ def delete_stack(self):
14191433
)
14201434
for i, resource_id in enumerate(ordered_resource_ids):
14211435
resource = resources[resource_id]
1436+
resource_type = get_resource_type(resource)
14221437
try:
14231438
# TODO: cache condition value in resource details on deployment and use cached value here
14241439
if not evaluate_resource_condition(
@@ -1427,23 +1442,32 @@ def delete_stack(self):
14271442
):
14281443
continue
14291444

1445+
action = "Remove"
14301446
executor = self.create_resource_provider_executor()
14311447
resource_provider_payload = self.create_resource_provider_payload(
1432-
"Remove", logical_resource_id=resource_id
1448+
action, logical_resource_id=resource_id
14331449
)
14341450
LOG.debug(
14351451
'Handling "Remove" for resource "%s" (%s/%s) type "%s"',
14361452
resource_id,
14371453
i + 1,
14381454
len(resources),
1439-
resource["ResourceType"],
1455+
resource_type,
14401456
)
1441-
resource_provider = executor.try_load_resource_provider(get_resource_type(resource))
1457+
resource_provider = executor.try_load_resource_provider(resource_type)
1458+
track_resource_operation(action, resource_type, missing=resource_provider is None)
14421459
if resource_provider is not None:
14431460
event = executor.deploy_loop(
14441461
resource_provider, resource, resource_provider_payload
14451462
)
14461463
else:
1464+
log_not_available_message(
1465+
resource_type,
1466+
f'No resource provider found for "{resource_type}"',
1467+
)
1468+
1469+
if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
1470+
raise NoResourceProvider
14471471
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
14481472
match event.status:
14491473
case OperationStatus.SUCCESS:

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
from dataclasses import dataclass
55
from typing import Final, Optional
66

7+
from localstack import config
78
from localstack.aws.api.cloudformation import (
89
ChangeAction,
910
ResourceStatus,
1011
StackStatus,
1112
)
1213
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
14+
from localstack.services.cloudformation.analytics import track_resource_operation
15+
from localstack.services.cloudformation.deployment_utils import log_not_available_message
1316
from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter
1417
from localstack.services.cloudformation.engine.v2.change_set_model import (
1518
NodeDependsOn,
@@ -27,6 +30,7 @@
2730
)
2831
from localstack.services.cloudformation.resource_provider import (
2932
Credentials,
33+
NoResourceProvider,
3034
OperationStatus,
3135
ProgressEvent,
3236
ResourceProviderExecutor,
@@ -350,6 +354,13 @@ def _execute_resource_action(
350354
after_properties=after_properties,
351355
)
352356
resource_provider = resource_provider_executor.try_load_resource_provider(resource_type)
357+
track_resource_operation(action, resource_type, missing=resource_provider is not None)
358+
log_not_available_message(
359+
resource_type,
360+
f'No resource provider found for "{resource_type}"',
361+
)
362+
if resource_provider is None and not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
363+
raise NoResourceProvider
353364

354365
extra_resource_properties = {}
355366
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})

localstack-core/localstack/services/cloudformation/resource_provider.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,11 @@
1919

2020
from localstack import config
2121
from localstack.aws.connect import InternalClientFactory, ServiceLevelClientFactory
22-
from localstack.services.cloudformation import analytics
2322
from localstack.services.cloudformation.deployment_utils import (
2423
check_not_found_exception,
2524
convert_data_types,
2625
fix_account_id_in_arns,
2726
fix_boto_parameters_based_on_report,
28-
log_not_available_message,
2927
remove_none_values,
3028
)
3129
from localstack.services.cloudformation.engine.quirks import PHYSICAL_RESOURCE_ID_SPECIAL_CASES
@@ -581,7 +579,6 @@ def try_load_resource_provider(resource_type: str) -> ResourceProvider | None:
581579
# 2. try to load community resource provider
582580
try:
583581
plugin = plugin_manager.load(resource_type)
584-
analytics.resources.labels(resource_type=resource_type, missing=False).increment()
585582
return plugin.factory()
586583
except ValueError:
587584
# could not find a plugin for that name
@@ -594,19 +591,8 @@ def try_load_resource_provider(resource_type: str) -> ResourceProvider | None:
594591
exc_info=LOG.isEnabledFor(logging.DEBUG),
595592
)
596593

597-
# 3. we could not find the resource provider so log the missing resource provider
598-
log_not_available_message(
599-
resource_type,
600-
f'No resource provider found for "{resource_type}"',
601-
)
602-
603-
analytics.resources.labels(resource_type=resource_type, missing=True).increment()
604-
605-
if config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
606-
# TODO: figure out a better way to handle non-implemented here?
607-
return None
608-
else:
609-
raise NoResourceProvider
594+
# we could not find the resource provider
595+
return None
610596

611597
def extract_physical_resource_id_from_model_with_schema(
612598
self, resource_model: Properties, resource_type: str, resource_type_schema: dict

0 commit comments

Comments
 (0)