cloudconsolelink 4.1.2__tar.gz → 4.3.0__tar.gz

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 (37) hide show
  1. {cloudconsolelink-4.1.2/cloudconsolelink.egg-info → cloudconsolelink-4.3.0}/PKG-INFO +26 -2
  2. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/README.md +25 -1
  3. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/aws/__init__.py +81 -8
  4. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/aws/links.py +47 -23
  5. cloudconsolelink-4.3.0/cloudconsolelink/clouds/azure/__init__.py +160 -0
  6. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/gcp/__init__.py +55 -2
  7. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/gcp/links.py +166 -16
  8. cloudconsolelink-4.3.0/cloudconsolelink/clouds/git/__init__.py +107 -0
  9. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/oci/links.py +90 -0
  10. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0/cloudconsolelink.egg-info}/PKG-INFO +26 -2
  11. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink.egg-info/SOURCES.txt +2 -0
  12. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/setup.py +11 -2
  13. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/tests/test_aws.py +122 -4
  14. cloudconsolelink-4.3.0/tests/test_azure.py +184 -0
  15. cloudconsolelink-4.3.0/tests/test_gcp.py +228 -0
  16. cloudconsolelink-4.3.0/tests/test_git.py +104 -0
  17. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/tests/test_oci.py +74 -0
  18. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/tests/test_packaging.py +29 -0
  19. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/tests/test_provider_coverage.py +15 -0
  20. cloudconsolelink-4.1.2/cloudconsolelink/clouds/azure/__init__.py +0 -51
  21. cloudconsolelink-4.1.2/tests/test_azure.py +0 -37
  22. cloudconsolelink-4.1.2/tests/test_gcp.py +0 -119
  23. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/LICENSE +0 -0
  24. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/MANIFEST.in +0 -0
  25. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/__init__.py +0 -0
  26. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/__init__.py +0 -0
  27. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/aws/errors.py +0 -0
  28. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/oci/__init__.py +0 -0
  29. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink/clouds/registry/__init__.py +0 -0
  30. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink.egg-info/dependency_links.txt +0 -0
  31. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink.egg-info/requires.txt +0 -0
  32. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/cloudconsolelink.egg-info/top_level.txt +0 -0
  33. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/requirements-dev.txt +0 -0
  34. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/requirements.txt +0 -0
  35. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/setup.cfg +0 -0
  36. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/tests/test_registry_link.py +0 -0
  37. {cloudconsolelink-4.1.2 → cloudconsolelink-4.3.0}/tests/test_registry_parse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudconsolelink
3
- Version: 4.1.2
3
+ Version: 4.3.0
4
4
  Summary: Generate console links for cloud resources
5
5
  Home-page: https://github.com/Cloudanix/cloud-console-links
6
6
  Maintainer: Cloudanix
@@ -31,7 +31,8 @@ Dynamic: provides-extra
31
31
  Dynamic: summary
32
32
 
33
33
  # What is cloudconsolelink?
34
- This project for generate console links for AWS, GCP and Azure cloud resources
34
+ This project for generate console links for AWS, GCP, Azure and OCI cloud resources, and web links for
35
+ GitHub, GitLab, Bitbucket and Azure DevOps entities
35
36
 
36
37
  # Install
37
38
  Run ```pip install cloudconsolelink``` to install our code
@@ -65,6 +66,12 @@ make check
65
66
  #### For GCP
66
67
  ```from cloudconsolelink.clouds.gcp import GCPLinker```
67
68
 
69
+ #### For OCI
70
+ ```from cloudconsolelink.clouds.oci import OCILinker```
71
+
72
+ #### For GitHub, GitLab, Bitbucket, Azure DevOps
73
+ ```from cloudconsolelink.clouds.git import GitLinker```
74
+
68
75
  #### For container images (ECR, GAR/GCR, ACR, Docker Hub, OCIR)
69
76
  ```from cloudconsolelink.clouds.registry import image_console_link```
70
77
 
@@ -141,6 +148,21 @@ console_link = image_console_link(
141
148
  )
142
149
  ```
143
150
 
151
+ #### Git providers:
152
+ ```python
153
+ from cloudconsolelink.clouds.git import GitLinker
154
+
155
+ git = GitLinker()
156
+
157
+ git.get_console_link("github", "branch", owner="acme", repo="api", branch="main")
158
+ # https://github.com/acme/api/tree/main
159
+
160
+ # Self-hosted GitHub Enterprise / GitLab: pass base_url
161
+ git.get_console_link("gitlab", "project", base_url="https://gitlab.acme.com", project_path="platform/api")
162
+ # https://gitlab.acme.com/platform/api
163
+ ```
164
+ Resource names per provider are the keys of `LINKS` in `cloudconsolelink/clouds/git/__init__.py`.
165
+
144
166
  ## get_console_link() parameters description:
145
167
 
146
168
  ### AWS:
@@ -187,6 +209,8 @@ console_link = image_console_link(
187
209
  31) firestore_collection_name
188
210
  32) cloud_run_service_name
189
211
 
212
+ The full, current list of GCP resource names is the `resources` dict in `cloudconsolelink/clouds/gcp/__init__.py`.
213
+
190
214
 
191
215
  # Who uses cloudconsolelink?
192
216
  1. [Cloudanix](https://www.cloudanix.com/)
@@ -1,5 +1,6 @@
1
1
  # What is cloudconsolelink?
2
- This project for generate console links for AWS, GCP and Azure cloud resources
2
+ This project for generate console links for AWS, GCP, Azure and OCI cloud resources, and web links for
3
+ GitHub, GitLab, Bitbucket and Azure DevOps entities
3
4
 
4
5
  # Install
5
6
  Run ```pip install cloudconsolelink``` to install our code
@@ -33,6 +34,12 @@ make check
33
34
  #### For GCP
34
35
  ```from cloudconsolelink.clouds.gcp import GCPLinker```
35
36
 
37
+ #### For OCI
38
+ ```from cloudconsolelink.clouds.oci import OCILinker```
39
+
40
+ #### For GitHub, GitLab, Bitbucket, Azure DevOps
41
+ ```from cloudconsolelink.clouds.git import GitLinker```
42
+
36
43
  #### For container images (ECR, GAR/GCR, ACR, Docker Hub, OCIR)
37
44
  ```from cloudconsolelink.clouds.registry import image_console_link```
38
45
 
@@ -109,6 +116,21 @@ console_link = image_console_link(
109
116
  )
110
117
  ```
111
118
 
119
+ #### Git providers:
120
+ ```python
121
+ from cloudconsolelink.clouds.git import GitLinker
122
+
123
+ git = GitLinker()
124
+
125
+ git.get_console_link("github", "branch", owner="acme", repo="api", branch="main")
126
+ # https://github.com/acme/api/tree/main
127
+
128
+ # Self-hosted GitHub Enterprise / GitLab: pass base_url
129
+ git.get_console_link("gitlab", "project", base_url="https://gitlab.acme.com", project_path="platform/api")
130
+ # https://gitlab.acme.com/platform/api
131
+ ```
132
+ Resource names per provider are the keys of `LINKS` in `cloudconsolelink/clouds/git/__init__.py`.
133
+
112
134
  ## get_console_link() parameters description:
113
135
 
114
136
  ### AWS:
@@ -155,6 +177,8 @@ console_link = image_console_link(
155
177
  31) firestore_collection_name
156
178
  32) cloud_run_service_name
157
179
 
180
+ The full, current list of GCP resource names is the `resources` dict in `cloudconsolelink/clouds/gcp/__init__.py`.
181
+
158
182
 
159
183
  # Who uses cloudconsolelink?
160
184
  1. [Cloudanix](https://www.cloudanix.com/)
@@ -2,9 +2,14 @@ import logging
2
2
  import re
3
3
  import urllib.parse
4
4
  from typing import Dict
5
+ from typing import Optional
5
6
 
6
- from .errors import ARNTooShortError, InvalidARNError, InvalidPartitionError, InvalidServiceError
7
- from .links import HOME_URLS, get_links
7
+ from .errors import ARNTooShortError
8
+ from .errors import InvalidARNError
9
+ from .errors import InvalidPartitionError
10
+ from .errors import InvalidServiceError
11
+ from .links import get_links
12
+ from .links import HOME_URLS
8
13
 
9
14
  logger = logging.getLogger(__name__)
10
15
 
@@ -20,16 +25,19 @@ def _render(template: str, data: Dict, arn: str = "") -> str:
20
25
  produce spaces inside expressions). Unknown expressions render as empty string.
21
26
  """
22
27
  resource = str(data.get("resource", ""))
28
+ region = str(data.get("region", ""))
23
29
  ctx: Dict[str, str] = {
30
+ **_resource_part_ctx(resource),
24
31
  'data.get("region","")': str(data.get("region", "")),
25
32
  'data.get("console","")': str(data.get("console", "")),
26
33
  'data.get("resource","")': resource,
27
34
  'data.get("account","")': str(data.get("account", "")),
28
35
  'data.get("service","")': str(data.get("service", "")),
29
- 'get_arn_string(data)': get_arn_string(data),
36
+ "get_arn_string(data)": get_arn_string(data),
30
37
  'get_resource_path(data.get("resource",""))': get_resource_path(resource),
31
38
  "arn": arn,
32
- "region": str(data.get("region", "")),
39
+ "partition": str(data.get("partition", "")),
40
+ "region": region,
33
41
  "console": str(data.get("console", "")),
34
42
  "resource": resource,
35
43
  "account": str(data.get("account", "")),
@@ -37,12 +45,50 @@ def _render(template: str, data: Dict, arn: str = "") -> str:
37
45
  }
38
46
 
39
47
  def _sub(m: re.Match) -> str:
40
- expr = re.sub(r"\s+", "", m.group(1)) # collapse whitespace from line continuations
48
+ expr = re.sub(
49
+ r"\s+",
50
+ "",
51
+ m.group(1),
52
+ ) # collapse whitespace from line continuations
41
53
  if expr not in ctx:
42
54
  logger.debug("Unknown template expression %r", expr)
43
55
  return ctx.get(expr, "")
44
56
 
45
- return _EXPR_RE.sub(_sub, template).replace(" ", "")
57
+ link = _EXPR_RE.sub(_sub, template).replace(" ", "")
58
+ return link if region else _drop_empty_region(link)
59
+
60
+
61
+ def _resource_part_ctx(resource: str) -> Dict[str, str]:
62
+ """Template keys for the pieces of a path-style resource.
63
+
64
+ resource_part(N) is the Nth "/"-separated segment (ecs "service/cluster/svc" -> cluster, svc),
65
+ resource_tail is everything after the first "/" (autoscaling "uuid:autoScalingGroupName/name" -> name),
66
+ log_group_id is the log group name in the double-encoded form the CloudWatch console expects.
67
+ """
68
+ raw = urllib.parse.unquote(resource)
69
+ parts = raw.split("/")
70
+ ctx = {
71
+ f"resource_part({i})": urllib.parse.quote(part, safe="")
72
+ for i, part in enumerate(parts)
73
+ }
74
+ ctx["resource_tail"] = urllib.parse.quote(raw.split("/", 1)[-1], safe="")
75
+ log_group = raw[: -len(":*")] if raw.endswith(":*") else raw
76
+ ctx["log_group_id"] = urllib.parse.quote(
77
+ urllib.parse.quote(log_group, safe=""),
78
+ safe="",
79
+ ).replace("%", "$")
80
+ return ctx
81
+
82
+
83
+ def _drop_empty_region(link: str) -> str:
84
+ """Remove the empty region a region-less ARN (IAM, Route 53, SSO, ...) leaves in a template.
85
+
86
+ "https://.console..." becomes "https://console..." and a bare "region=" query parameter is dropped.
87
+ """
88
+ link = link.replace("https://.", "https://", 1)
89
+ link = link.replace("?region=&", "?")
90
+ link = re.sub(r"[?&]region=(?=#|$)", "", link)
91
+ return re.sub(r"&region=(?=&)", "", link)
46
92
 
47
93
 
48
94
  def get_console(partition: str) -> str:
@@ -173,14 +219,41 @@ class AWSLinker:
173
219
  logger.debug(f"Invalid AWS ARN {arn}")
174
220
  raise InvalidARNError(f"Invalid AWS ARN {arn}")
175
221
 
176
- if not links.get(data["service"], {}).get(data["resourceType"], None):
222
+ # An SNS subscription ARN is the topic ARN plus a subscription id: sns:...:topic:uuid.
223
+ if data["service"] == "sns" and len(resource_tokens) == 2:
224
+ data["resourceType"] = "subscription"
225
+
226
+ template = _find_template(
227
+ links[data["service"]],
228
+ str(data["resourceType"]),
229
+ str(data["resource"]),
230
+ )
231
+ if not template:
177
232
  logger.debug(
178
233
  f"AWS service {data['service']} resource type {data['resourceType']} not supported",
179
234
  )
180
235
  return get_service_home_link(data)
181
236
 
182
237
  data["resource"] = urllib.parse.quote(str(data["resource"]))
183
- return _render(links[data["service"]][data["resourceType"]], data, arn)
238
+ return _render(template, data, arn)
239
+
240
+
241
+ def _find_template(
242
+ service_links: Dict,
243
+ resource_type: str,
244
+ resource: str,
245
+ ) -> Optional[str]:
246
+ """Pick the template for a resource, preferring a "type/sub-type" key over the plain type.
247
+
248
+ Some ARNs carry their real kind one level down: apigateway "restapis/{id}/stages/{stage}" (sub-type in
249
+ the second segment) or wafv2 "regional/webacl/{name}/{id}" (sub-type in the first segment).
250
+ """
251
+ parts = resource.split("/")
252
+ for sub_type in parts[1:2] + parts[:1] if len(parts) > 1 else []:
253
+ template = service_links.get(f"{resource_type}/{sub_type}")
254
+ if template:
255
+ return template
256
+ return service_links.get(resource_type)
184
257
 
185
258
 
186
259
  __all__ = [
@@ -42,12 +42,15 @@ def get_links() -> Dict:
42
42
  "apps": 'https://{data.get("region", "")}.{data.get("console", "")}/amplify/home?region={data.get("region", "")}#/{data.get("resource", "")}',
43
43
  },
44
44
  "apigateway": { # Manage Amazon API Gateway
45
- "restapis": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/home?region={data.get("region", "")}\
46
- #/{data.get("resource", "")}/resources/',
45
+ "restapis": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/main/apis/\
46
+ {resource_part(0)}/resources?api={resource_part(0)}&region={data.get("region", "")}',
47
+ "restapis/stages": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/main/apis/\
48
+ {resource_part(0)}/stages?api={resource_part(0)}&region={data.get("region", "")}',
47
49
  "stages": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/\
48
50
  home?region={data.get("region", "")}#/{data.get("resource", "")}',
49
- "apis": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/home?region={data.get("region", "")}\
50
- #/{data.get("resource", "")}/resources/',
51
+ # HTTP and WebSocket APIs (API Gateway v2)
52
+ "apis": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/main/api-detail?\
53
+ api={resource_part(0)}&region={data.get("region", "")}',
51
54
  "stage": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/\
52
55
  home?region={data.get("region", "")}#/{data.get("resource", "")}',
53
56
  "clientcertificates": 'https://{data.get("region", "")}.{data.get("console", "")}/apigateway/home?region={data.get("region", "")}#/client-certificates',
@@ -95,7 +98,7 @@ def get_links() -> Dict:
95
98
  "workgroup": 'https://{data.get("region", "")}.{data.get("console", "")}/athena/home?region={data.get("region", "")}#/workgroups/details/{data.get("resource", "")}',
96
99
  },
97
100
  "autoscaling": { # Amazon EC2 Auto Scaling
98
- "autoScalingGroup": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#AutoScalingGroupDetails:id={data.get("resource", "")}',
101
+ "autoScalingGroup": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#AutoScalingGroupDetails:id={resource_tail}',
99
102
  "launchConfiguration": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#LaunchConfigurations:',
100
103
  },
101
104
  "aws-marketplace": { # AWS Marketplace Catalog
@@ -238,6 +241,8 @@ def get_links() -> Dict:
238
241
  "aggregation-authorization": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/aggregations',
239
242
  "config-aggregator": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/aggregations',
240
243
  "config-rule": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/rules/rule-details/{data.get("resource", "")}',
244
+ "configuration-recorder": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/settings',
245
+ "delivery-channel": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/settings',
241
246
  "conformance-pack": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/conformance-packs/details/{data.get("resource", "")}',
242
247
  "organization-config-rule": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/rules',
243
248
  "organization-conformance-pack": 'https://{data.get("region", "")}.{data.get("console", "")}/config/home?region={data.get("region", "")}#/conformance-packs',
@@ -345,7 +350,7 @@ def get_links() -> Dict:
345
350
  "table": 'https://{data.get("region", "")}.{data.get("console", "")}/dynamodbv2/home?region=\
346
351
  {data.get("region", "")}#table?name={data.get("resource", "")}',
347
352
  "secondary_indexes": 'https://{data.get("region", "")}.{data.get("console", "")}/dynamodbv2/\
348
- home?region=us-east-1#table?initialTagKey=&name={data.get("resource", "")}&tab=indexes',
353
+ home?region={data.get("region", "")}#table?initialTagKey=&name={data.get("resource", "")}&tab=indexes',
349
354
  },
350
355
  "ec2": { # AWS Systems Manager
351
356
  "capacity-reservation": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#CapacityReservationDetails:capacityReservationId={data.get("resource", "")}',
@@ -387,7 +392,7 @@ def get_links() -> Dict:
387
392
  "traffic-mirror-session": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TrafficMirrorSessions:',
388
393
  "traffic-mirror-target": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TrafficMirrorTargets:',
389
394
  "transit-gateway": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TransitGateways:transitGatewayId={data.get("resource", "")}',
390
- "transit-gateway-attachment": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TransitGatewayAttachments:',
395
+ "transit-gateway-attachment": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TransitGatewayAttachmentDetails:transitGatewayAttachmentId={data.get("resource", "")}',
391
396
  "transit-gateway-multicast-domain": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TransitGatewayMulticastDomains:',
392
397
  "transit-gateway-route-table": 'https://{data.get("region", "")}.{data.get("console", "")}/vpcconsole/home?region={data.get("region", "")}#TransitGatewayRouteTables:',
393
398
  "volume": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2\
@@ -414,19 +419,19 @@ def get_links() -> Dict:
414
419
  {data.get("account", "")}/{data.get("resource", "")}?region={data.get("region", "")}',
415
420
  },
416
421
  "ecs": { # Amazon Elastic Container Service
417
- "cluster": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}',
418
- "container-instance": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}/containerInstances',
419
- "service": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}/services',
420
- "task": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}/tasks',
421
- "task-definition": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs\
422
- /home?region={data.get("region", "")}#/taskDefinitions/{data.get("resource", "")}',
422
+ # Resource paths are cluster/<id>; the classic #/clusters console is retired, so these use /ecs/v2.
423
+ "cluster": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/v2/clusters/{resource_part(0)}?region={data.get("region", "")}',
424
+ "container-instance": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/v2/clusters/{resource_part(0)}/infrastructure?region={data.get("region", "")}',
425
+ "service": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/v2/clusters/{resource_part(0)}/services/{resource_part(1)}?region={data.get("region", "")}',
426
+ "task": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/v2/clusters/{resource_part(0)}/tasks/{resource_part(1)}?region={data.get("region", "")}',
427
+ "task-definition": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/v2/task-definitions/{resource_part(0)}?region={data.get("region", "")}',
423
428
  "task-set": None, # task-set is a sub-resource of service, no direct deep-link
424
429
  },
425
430
  "eks": { # Amazon Elastic Container Service for Kubernetes
426
431
  "cluster": 'https://{data.get("region", "")}.{data.get("console", "")}/eks/home?region={data.get\
427
432
  ("region", "")}#/clusters/{data.get("resource", "")}',
428
433
  "fargateprofile": 'https://{data.get("region", "")}.{data.get("console", "")}/eks/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}/fargate-profiles',
429
- "nodegroup": 'https://{data.get("region", "")}.{data.get("console", "")}/eks/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}/nodegroups',
434
+ "nodegroup": 'https://{data.get("region", "")}.{data.get("console", "")}/eks/home?region={data.get("region", "")}#/clusters/{resource_part(0)}/nodegroups/{resource_part(1)}',
430
435
  },
431
436
  "elastic-inference": { # Amazon Elastic Inference (retired April 2024)
432
437
  "elastic-inference-accelerator": None, # service retired
@@ -456,10 +461,20 @@ def get_links() -> Dict:
456
461
  "file-system": 'https://{data.get("region", "")}.{data.get("console", "")}/efs/home?region={data.get("region", "")}#/file-systems/{data.get("resource", "")}',
457
462
  },
458
463
  "elasticloadbalancing": { # AWS WAF V2
459
- "listener": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#LoadBalancers:',
464
+ # A listener (listener/app/<lb>/<lb-id>/<id>) links to its load balancer's page.
465
+ "listener": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}\
466
+ #LoadBalancer:loadBalancerArn=arn:{partition}:elasticloadbalancing:{data.get("region", "")}:\
467
+ {data.get("account", "")}:loadbalancer/{resource_part(0)}/{resource_part(1)}/{resource_part(2)}',
460
468
  "listener-rule": None, # sub-resource of listener, no direct deep-link
469
+ # Classic load balancers (loadbalancer/<name>) have no ARN-addressable page.
461
470
  "loadbalancer": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/\
462
471
  home?region={data.get("region", "")}#LoadBalancers:sort=loadBalancerName',
472
+ "loadbalancer/app": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region=\
473
+ {data.get("region", "")}#LoadBalancer:loadBalancerArn={arn}',
474
+ "loadbalancer/net": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region=\
475
+ {data.get("region", "")}#LoadBalancer:loadBalancerArn={arn}',
476
+ "loadbalancer/gwy": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region=\
477
+ {data.get("region", "")}#LoadBalancer:loadBalancerArn={arn}',
463
478
  "targetgroup": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#TargetGroups:targetGroupArn={arn}',
464
479
  },
465
480
  "elasticmapreduce": { # Amazon Elastic MapReduce
@@ -484,6 +499,8 @@ def get_links() -> Dict:
484
499
  home?region={data.get("region", "")}#opensearch/domains/{data.get("resource", "")}',
485
500
  "reserved_instance": 'https://{data.get("region", "")}.{data.get("console", "")}/esv3/\
486
501
  home?region={data.get("region", "")}#opensearch/reserved-instances',
502
+ "reserved-instances": 'https://{data.get("region", "")}.{data.get("console", "")}/esv3/\
503
+ home?region={data.get("region", "")}#opensearch/reserved-instances',
487
504
  },
488
505
  "events": { # Amazon EventBridge
489
506
  "event-bus": 'https://{data.get("region", "")}.{data.get("console", "")}/events/home?region={data.get("region", "")}#/eventbus/{data.get("resource", "")}',
@@ -585,7 +602,7 @@ def get_links() -> Dict:
585
602
  "saml-provider": 'https://{data.get("console", "")}/iamv2/home#/identity-providers',
586
603
  "server-certificate": 'https://{data.get("console", "")}/iam/home?#/server-certificates',
587
604
  "sms-mfa": None, # no direct console deep-link for SMS MFA device
588
- "user": 'https://{data.get("console", "")}/iam/home?#/users/{data.get("resource", "")}',
605
+ "user": 'https://{data.get("console", "")}/iam/home?#/users/{get_resource_path(data.get("resource", ""))}',
589
606
  "access_keys": 'https://{data.get("console", "")}/iam/home#/users/{data.get("resource", "")}?section=security_credentials',
590
607
  "policy_statement": 'https://{data.get("console", "")}/iam/home?#/policies/{get_arn_string(data)}',
591
608
  },
@@ -729,7 +746,7 @@ def get_links() -> Dict:
729
746
  "StaticIp": "https://lightsail.aws.amazon.com/ls/webapp/home/static-ips",
730
747
  },
731
748
  "logs": { # Amazon CloudWatch Logs
732
- "log-group": 'https://{data.get("region", "")}.{data.get("console", "")}/cloudwatch/home?region={data.get("region", "")}#logsV2:log-groups/log-group/{data.get("resource", "")}',
749
+ "log-group": 'https://{data.get("region", "")}.{data.get("console", "")}/cloudwatch/home?region={data.get("region", "")}#logsV2:log-groups/log-group/{log_group_id}',
733
750
  },
734
751
  "lookoutequipment": { # Amazon Lookout for Equipment
735
752
  "dataset": 'https://{data.get("region", "")}.{data.get("console", "")}/lookoutequipment/home?region={data.get("region", "")}#/datasets/{data.get("resource", "")}',
@@ -850,7 +867,7 @@ def get_links() -> Dict:
850
867
  "organizations": { # AWS Organizations
851
868
  "account": 'https://{data.get("console", "")}/organizations/v2/home/accounts/{data.get("resource", "")}',
852
869
  "handshake": None, # no direct console deep-link for handshakes
853
- "organization": 'https://{data.get("region", "")}.{data.get("console", "")}/organizations/v2/home/accounts/{data.get("resource", "")}',
870
+ "organization": 'https://{data.get("console", "")}/organizations/v2/home',
854
871
  "ou": 'https://{data.get("console", "")}/organizations/v2/home/roots',
855
872
  "policy": 'https://{data.get("console", "")}/organizations/v2/home/policies/{data.get("resource", "")}',
856
873
  "root": 'https://{data.get("console", "")}/organizations/v2/home/roots',
@@ -923,8 +940,8 @@ def get_links() -> Dict:
923
940
  "ri": 'https://{data.get("region", "")}.{data.get("console", "")}/rds/home?region={data.get("region", "")}#reserved-instances:',
924
941
  "secgrp": 'https://{data.get("region", "")}.{data.get("console", "")}/vpc/home?region=\
925
942
  {data.get("region", "")}#SecurityGroup:groupId={data.get("resource", "")}',
926
- "snapshot": 'https://{data.get("region", "")}.{data.get("console", "")}/rds/home?region={data.get("region", "")}#db-snapshot:engine=sqlserver-ee;id={arn}',
927
- "subgrp": 'https://{data.get("region", "")}.{data.get("console", "")}/rds/home?region={data.get("region", "")}#subnet-groups-list:',
943
+ "snapshot": 'https://{data.get("region", "")}.{data.get("console", "")}/rds/home?region={data.get("region", "")}#db-snapshot:id={data.get("resource", "")}',
944
+ "subgrp": 'https://{data.get("region", "")}.{data.get("console", "")}/rds/home?region={data.get("region", "")}#db-subnet-group:id={data.get("resource", "")}',
928
945
  "target": None, # sub-resource of db-proxy, no direct deep-link
929
946
  "target-group": None, # sub-resource of db-proxy, no direct deep-link
930
947
  },
@@ -976,8 +993,9 @@ def get_links() -> Dict:
976
993
  "change": None, # internal resource, no direct console page
977
994
  "delegationset": 'https://{data.get("console", "")}/route53/home#/hosted-zones',
978
995
  "healthcheck": 'https://{data.get("console", "")}/route53/healthchecks/home',
979
- "hostedzone": 'https://{data.get("console", "")}/route53/home?#resource-record-sets:\
980
- {data.get("resource", "")}',
996
+ # get_resource_path also accepts the "hostedzone//hostedzone/Z1" ARNs built from the API's zone Id.
997
+ "hostedzone": 'https://{data.get("console", "")}/route53/v2/hostedzones#ListRecordSets/\
998
+ {get_resource_path(data.get("resource", ""))}',
981
999
  "queryloggingconfig": 'https://{data.get("console", "")}/route53/home#/query-logging',
982
1000
  "trafficpolicy": 'https://{data.get("console", "")}/route53/trafficflow/home#/policy/\
983
1001
  {data.get("resource", "")}',
@@ -1106,8 +1124,9 @@ def get_links() -> Dict:
1106
1124
  "windowtask": 'https://{data.get("region", "")}.{data.get("console", "")}/systems-manager/maintenance-windows?region={data.get("region", "")}',
1107
1125
  },
1108
1126
  "sso": { # AWS IAM Identity Center
1127
+ # Identity Center ARNs carry no region; the console opens the instance's home region.
1109
1128
  "instance": 'https://{data.get("region", "")}.{data.get("console", "")}/singlesignon/home?region={data.get("region", "")}',
1110
- "permissionSet": 'https://{data.get("region", "")}.{data.get("console", "")}/singlesignon/home?region={data.get("region", "")}#/permission-sets/{data.get("resource", "")}',
1129
+ "permissionSet": 'https://{data.get("region", "")}.{data.get("console", "")}/singlesignon/home?region={data.get("region", "")}',
1111
1130
  },
1112
1131
  "states": { # AWS Step Functions
1113
1132
  "activity": 'https://{data.get("region", "")}.{data.get("console", "")}/states/home?region={data.get("region", "")}#/activities/{arn}',
@@ -1178,6 +1197,11 @@ def get_links() -> Dict:
1178
1197
  "xssmatchset": None, # WAF Classic condition, no dedicated console deep-link
1179
1198
  },
1180
1199
  "wafv2": { # AWS WAF V2
1200
+ # Web ACL ARNs: {regional|global}/webacl/<name>/<id>; global (CloudFront) ACLs use region=global.
1201
+ "regional/webacl": 'https://{data.get("region", "")}.{data.get("console", "")}/wafv2/homev2/web-acl/\
1202
+ {resource_part(1)}/{resource_part(2)}/overview?region={data.get("region", "")}',
1203
+ "global/webacl": 'https://{data.get("region", "")}.{data.get("console", "")}/wafv2/homev2/web-acl/\
1204
+ {resource_part(1)}/{resource_part(2)}/overview?region=global',
1181
1205
  },
1182
1206
  "wellarchitected": { # AWS Well-Architected Tool
1183
1207
  "workload": 'https://{data.get("region", "")}.{data.get("console", "")}/wellarchitected/home?region={data.get("region", "")}#/workload/{data.get("resource", "")}',
@@ -0,0 +1,160 @@
1
+ import logging
2
+ import re
3
+ from typing import Tuple
4
+
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ PORTAL = "https://portal.azure.com"
9
+ SUBSCRIPTIONS_BLADE = f"{PORTAL}/#view/Microsoft_Azure_Billing/SubscriptionsBlade"
10
+ ACTIVITY_LOG_BLADE = f"{PORTAL}/#view/Microsoft_Azure_Monitoring/AzureMonitoringBrowseBlade/~/activityLog"
11
+
12
+ _GUID_RE = re.compile(r"^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$")
13
+ _PROVIDERS_RE = re.compile(r"/providers/", re.IGNORECASE)
14
+ _AUTHORIZATION_RE = re.compile(r"/providers/microsoft\.authorization/", re.IGNORECASE)
15
+
16
+ # Nested ARM types the portal opens as resources in their own right, so a link keeps
17
+ # drilling into them instead of stopping at their parent. Keys are lowercase type paths.
18
+ FIRST_CLASS_CHILDREN = {
19
+ "microsoft.sql/servers/databases",
20
+ "microsoft.sql/servers/elasticpools",
21
+ "microsoft.sql/managedinstances/databases",
22
+ "microsoft.web/sites/slots",
23
+ }
24
+
25
+ # Nested ARM types with no page of their own: link to the parent blade that lists them.
26
+ # Anything nested and not listed here links to the parent's overview.
27
+ CHILD_BLADES = {
28
+ "microsoft.network/virtualnetworks/subnets": "subnets",
29
+ "microsoft.network/routetables/routes": "routes",
30
+ "microsoft.network/networksecuritygroups/securityrules": "inboundSecurityRules",
31
+ "microsoft.network/networksecuritygroups/defaultsecurityrules": "inboundSecurityRules",
32
+ "microsoft.containerservice/managedclusters/agentpools": "nodePools",
33
+ "microsoft.storage/storageaccounts/blobservices": "containersList",
34
+ "microsoft.storage/storageaccounts/fileservices": "fileList",
35
+ "microsoft.storage/storageaccounts/queueservices": "queueList",
36
+ "microsoft.storage/storageaccounts/tableservices": "tableList",
37
+ "microsoft.compute/virtualmachines/extensions": "extensions",
38
+ "microsoft.compute/virtualmachinescalesets/extensions": "extensions",
39
+ "microsoft.documentdb/databaseaccounts/sqldatabases": "dataExplorer",
40
+ "microsoft.documentdb/databaseaccounts/mongodbdatabases": "dataExplorer",
41
+ "microsoft.documentdb/databaseaccounts/cassandrakeyspaces": "dataExplorer",
42
+ "microsoft.documentdb/databaseaccounts/gremlindatabases": "dataExplorer",
43
+ "microsoft.documentdb/databaseaccounts/tables": "dataExplorer",
44
+ "microsoft.web/sites/config": "configuration",
45
+ "microsoft.web/sites/slots/config": "configuration",
46
+ "microsoft.containerregistry/registries/replications": "replications",
47
+ "microsoft.containerregistry/registries/webhooks": "webhooks",
48
+ }
49
+
50
+ # Provider types with no portal page at all: link to the scope they live under.
51
+ SCOPE_ONLY_TYPES = {
52
+ "microsoft.security/securitycontacts",
53
+ "microsoft.network/locations",
54
+ }
55
+
56
+
57
+ def _anchor_and_blade(segments: list) -> Tuple[str, str]:
58
+ """Split `ns/type/name[/childType/childName...]` into the linkable resource and its blade."""
59
+ type_path = "/".join(segments[:2]).lower()
60
+ end = 3
61
+ while (
62
+ len(segments) > end
63
+ and f"{type_path}/{segments[end].lower()}" in FIRST_CLASS_CHILDREN
64
+ ):
65
+ type_path = f"{type_path}/{segments[end].lower()}"
66
+ end += 2
67
+ if len(segments) <= end:
68
+ return "/".join(segments), "overview"
69
+ return "/".join(segments[:end]), CHILD_BLADES.get(
70
+ f"{type_path}/{segments[end].lower()}",
71
+ "overview",
72
+ )
73
+
74
+
75
+ def _management_link(arm_id: str, domain: str) -> str:
76
+ base = f"{PORTAL}/#@{domain}/resource"
77
+ if _GUID_RE.match(arm_id): # bare subscription id
78
+ return f"{base}/subscriptions/{arm_id}/overview"
79
+
80
+ authorization = _AUTHORIZATION_RE.search(arm_id)
81
+ if (
82
+ authorization
83
+ ): # role definitions/assignments: the scope's Access control (IAM) blade
84
+ scope = arm_id[: authorization.start()]
85
+ return f"{base}{scope}/users" if scope else SUBSCRIPTIONS_BLADE
86
+
87
+ parts = _PROVIDERS_RE.split(arm_id, maxsplit=1)
88
+ if len(parts) == 1: # subscription or resource group
89
+ return f"{base}{arm_id}/overview"
90
+
91
+ scope, tail = parts
92
+ segments = tail.split("/")
93
+ type_path = "/".join(segments[:2]).lower()
94
+ if type_path == "microsoft.insights/logprofiles":
95
+ return ACTIVITY_LOG_BLADE
96
+ if type_path in SCOPE_ONLY_TYPES:
97
+ return f"{base}{scope}/overview" if scope else SUBSCRIPTIONS_BLADE
98
+
99
+ anchor, blade = _anchor_and_blade(segments)
100
+ return f"{base}{scope}/providers/{anchor}/{blade}"
101
+
102
+
103
+ def _key_vault_link(uri: str, domain: str) -> str:
104
+ for marker, kind in (
105
+ ("/keys/", "Key"),
106
+ ("/secrets/", "Secret"),
107
+ ("/certificates/", "Certificate"),
108
+ ):
109
+ if marker in uri:
110
+ return f"{PORTAL}/#@{domain}/asset/Microsoft_Azure_KeyVault/{kind}/{uri}"
111
+ return ""
112
+
113
+
114
+ class AzureLinker:
115
+ def get_console_link(
116
+ self,
117
+ id: str,
118
+ app_id: str = "",
119
+ primary_ad_domain_name: str = "",
120
+ iam_entity_type: str = "",
121
+ ) -> str:
122
+ if not primary_ad_domain_name and not iam_entity_type:
123
+ logger.debug(
124
+ "For IAM entity required `iam_entity_type` and for other entity required `primary_ad_domain_name`",
125
+ )
126
+ raise ValueError("Invalid parameters provided")
127
+ iam_entities = {
128
+ "user": "https://portal.azure.com/#blade/Microsoft_AAD_IAM/UserDetailsMenuBlade/Profile/userId/{id}",
129
+ "group": "https://portal.azure.com/#blade/Microsoft_AAD_IAM/GroupDetailsMenuBlade/Overview/groupId/{id}",
130
+ "application": "https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade\
131
+ /Overview/appId/{id}",
132
+ "role": "https://portal.azure.com/#@{primary_ad_domain_name}/resource{id}/roles",
133
+ "service_principal": "https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Properties/\
134
+ objectId/{id}/appId/{app_id}/preferredSingleSignOnMode~/null",
135
+ "domain": "https://portal.azure.com/#view/Microsoft_AAD_IAM/DomainPropertiesBladeV2/domainName/{id}",
136
+ "tenant": "https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview",
137
+ }
138
+
139
+ if iam_entity_type and id and iam_entities.get(iam_entity_type):
140
+ return (
141
+ iam_entities[iam_entity_type]
142
+ .format(
143
+ id=id,
144
+ primary_ad_domain_name=primary_ad_domain_name,
145
+ app_id=app_id,
146
+ )
147
+ .replace(" ", "")
148
+ )
149
+
150
+ elif primary_ad_domain_name and id:
151
+ if id.startswith("https"):
152
+ key_vault_link = _key_vault_link(id, primary_ad_domain_name)
153
+ if key_vault_link:
154
+ return key_vault_link
155
+
156
+ return _management_link(id, primary_ad_domain_name)
157
+
158
+ else:
159
+ logger.debug("entity id required")
160
+ raise ValueError("Invalid parameters provided")