cloudconsolelink 4.0.0__tar.gz → 4.1.1__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 (32) hide show
  1. {cloudconsolelink-4.0.0/cloudconsolelink.egg-info → cloudconsolelink-4.1.1}/PKG-INFO +25 -3
  2. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/README.md +23 -1
  3. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/__init__.py +1 -1
  4. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/aws/links.py +11 -4
  5. cloudconsolelink-4.1.1/cloudconsolelink/clouds/registry/__init__.py +230 -0
  6. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1/cloudconsolelink.egg-info}/PKG-INFO +25 -3
  7. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink.egg-info/SOURCES.txt +4 -1
  8. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink.egg-info/requires.txt +1 -1
  9. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/requirements-dev.txt +1 -1
  10. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/setup.py +17 -1
  11. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/tests/test_aws.py +59 -1
  12. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/tests/test_provider_coverage.py +38 -0
  13. cloudconsolelink-4.1.1/tests/test_registry_link.py +116 -0
  14. cloudconsolelink-4.1.1/tests/test_registry_parse.py +92 -0
  15. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/LICENSE +0 -0
  16. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/MANIFEST.in +0 -0
  17. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/__init__.py +0 -0
  18. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/aws/__init__.py +0 -0
  19. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/aws/errors.py +0 -0
  20. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/azure/__init__.py +0 -0
  21. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/gcp/__init__.py +0 -0
  22. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/gcp/links.py +0 -0
  23. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/oci/__init__.py +0 -0
  24. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink/clouds/oci/links.py +0 -0
  25. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink.egg-info/dependency_links.txt +0 -0
  26. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/cloudconsolelink.egg-info/top_level.txt +0 -0
  27. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/requirements.txt +0 -0
  28. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/setup.cfg +0 -0
  29. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/tests/test_azure.py +0 -0
  30. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/tests/test_gcp.py +0 -0
  31. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/tests/test_oci.py +0 -0
  32. {cloudconsolelink-4.0.0 → cloudconsolelink-4.1.1}/tests/test_packaging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudconsolelink
3
- Version: 4.0.0
3
+ Version: 4.1.1
4
4
  Summary: Generate console links for cloud resources
5
5
  Home-page: https://github.com/Cloudanix/cloud-console-links
6
6
  Maintainer: Cloudanix
@@ -14,7 +14,7 @@ Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Provides-Extra: dev
16
16
  Requires-Dist: pre-commit~=4.2; extra == "dev"
17
- Requires-Dist: pytest~=8.3; extra == "dev"
17
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
18
18
  Requires-Dist: pytest-cov==2.11; extra == "dev"
19
19
  Requires-Dist: pytest-mock~=3.14; extra == "dev"
20
20
  Requires-Dist: types-PyYAML~=6.0; extra == "dev"
@@ -65,6 +65,9 @@ make check
65
65
  #### For GCP
66
66
  ```from cloudconsolelink.clouds.gcp import GCPLinker```
67
67
 
68
+ #### For container images (ECR, GAR/GCR, ACR, Docker Hub, OCIR)
69
+ ```from cloudconsolelink.clouds.registry import image_console_link```
70
+
68
71
  #### To get console link
69
72
  call method ```get_console_link()```
70
73
 
@@ -119,7 +122,26 @@ resource_name = "storage_bucket"
119
122
  console_link = gcp.get_console_link(bucket_name=bucket_name, resource_name=resource_name)
120
123
  ```
121
124
 
122
- ## get_console_link() parameters discription:
125
+ #### Container image (ECR, GAR/GCR, ACR, Docker Hub, OCIR):
126
+ ```python
127
+ from cloudconsolelink.clouds.registry import image_console_link
128
+
129
+ # Provider is detected from the registry hostname of the image reference.
130
+ console_link = image_console_link(
131
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni-init"
132
+ )
133
+
134
+ # Azure Container Registry needs subscription/resource-group hints for a deep
135
+ # link (they are not present in the image reference); without them it degrades
136
+ # to a registry browse link.
137
+ console_link = image_console_link(
138
+ "myregistry.azurecr.io/app",
139
+ subscription_id="5592e8dc-...",
140
+ resource_group="my-rg",
141
+ )
142
+ ```
143
+
144
+ ## get_console_link() parameters description:
123
145
 
124
146
  ### AWS:
125
147
  1) arn: arn of resource
@@ -33,6 +33,9 @@ make check
33
33
  #### For GCP
34
34
  ```from cloudconsolelink.clouds.gcp import GCPLinker```
35
35
 
36
+ #### For container images (ECR, GAR/GCR, ACR, Docker Hub, OCIR)
37
+ ```from cloudconsolelink.clouds.registry import image_console_link```
38
+
36
39
  #### To get console link
37
40
  call method ```get_console_link()```
38
41
 
@@ -87,7 +90,26 @@ resource_name = "storage_bucket"
87
90
  console_link = gcp.get_console_link(bucket_name=bucket_name, resource_name=resource_name)
88
91
  ```
89
92
 
90
- ## get_console_link() parameters discription:
93
+ #### Container image (ECR, GAR/GCR, ACR, Docker Hub, OCIR):
94
+ ```python
95
+ from cloudconsolelink.clouds.registry import image_console_link
96
+
97
+ # Provider is detected from the registry hostname of the image reference.
98
+ console_link = image_console_link(
99
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni-init"
100
+ )
101
+
102
+ # Azure Container Registry needs subscription/resource-group hints for a deep
103
+ # link (they are not present in the image reference); without them it degrades
104
+ # to a registry browse link.
105
+ console_link = image_console_link(
106
+ "myregistry.azurecr.io/app",
107
+ subscription_id="5592e8dc-...",
108
+ resource_group="my-rg",
109
+ )
110
+ ```
111
+
112
+ ## get_console_link() parameters description:
91
113
 
92
114
  ### AWS:
93
115
  1) arn: arn of resource
@@ -1,2 +1,2 @@
1
1
  # version of the module
2
- __version__ = "4.0.0"
2
+ __version__ = "4.0.1"
@@ -6,6 +6,7 @@ from typing import Dict
6
6
  # https://{region}.console.aws.amazon.com/{service}/home?region={region}.
7
7
  HOME_URLS: Dict[str, str] = {
8
8
  "elasticloadbalancing": 'https://{data.get("region", "")}.{data.get("console", "")}/ec2/home?region={data.get("region", "")}#LoadBalancers:',
9
+ "network-firewall": 'https://{data.get("region", "")}.{data.get("console", "")}/vpc/home?region={data.get("region", "")}#Firewalls:',
9
10
  "logs": 'https://{data.get("region", "")}.{data.get("console", "")}/cloudwatch/home?region={data.get("region", "")}#logsV2:log-groups',
10
11
  "sns": 'https://{data.get("region", "")}.{data.get("console", "")}/sns/v3/home?region={data.get("region", "")}',
11
12
  "sqs": 'https://{data.get("region", "")}.{data.get("console", "")}/sqs/v2/home?region={data.get("region", "")}',
@@ -407,10 +408,10 @@ def get_links() -> Dict:
407
408
  home?region=u{data.get("region", "")}#LoadBalancers:search={data.get("resource", "")};sort=loadBalancerName',
408
409
  },
409
410
  "ecr": { # Amazon Elastic Container Registry
410
- "repository": 'https://{data.get("region", "")}.{data.get("console", "")}/ecr/repositories/{data.get\
411
- ("resource", "")}?region={data.get("region", "")}',
412
- "image": 'https://{data.get("region", "")}.{data.get("console", "")}/ecr/repositories/{data.get\
413
- ("resource", "")}?region={data.get("region", "")}',
411
+ "repository": 'https://{data.get("region", "")}.{data.get("console", "")}/ecr/repositories/private/\
412
+ {data.get("account", "")}/{data.get("resource", "")}?region={data.get("region", "")}',
413
+ "image": 'https://{data.get("region", "")}.{data.get("console", "")}/ecr/repositories/private/\
414
+ {data.get("account", "")}/{data.get("resource", "")}?region={data.get("region", "")}',
414
415
  },
415
416
  "ecs": { # Amazon Elastic Container Service
416
417
  "cluster": 'https://{data.get("region", "")}.{data.get("console", "")}/ecs/home?region={data.get("region", "")}#/clusters/{data.get("resource", "")}',
@@ -828,6 +829,12 @@ def get_links() -> Dict:
828
829
  },
829
830
  "neptune-db": { # Amazon Neptune
830
831
  },
832
+ "network-firewall": { # AWS Network Firewall
833
+ "firewall": 'https://{data.get("region", "")}.{data.get("console", "")}/vpc/home?region={data.get("region", "")}#FirewallDetails:firewallArn={arn}',
834
+ "firewall-policy": 'https://{data.get("region", "")}.{data.get("console", "")}/vpc/home?region={data.get("region", "")}#FirewallPolicyDetails:firewallPolicyArn={arn}',
835
+ "stateful-rulegroup": 'https://{data.get("region", "")}.{data.get("console", "")}/vpc/home?region={data.get("region", "")}#StatefulRuleGroupDetails:ruleGroupArn={arn}',
836
+ "stateless-rulegroup": 'https://{data.get("region", "")}.{data.get("console", "")}/vpc/home?region={data.get("region", "")}#StatelessRuleGroupDetails:ruleGroupArn={arn}',
837
+ },
831
838
  "networkmanager": { # Network Manager
832
839
  "device": 'https://{data.get("console", "")}/networkmanager/home?region={data.get("region", "")}#/networks/{data.get("resource", "")}/devices',
833
840
  "global-network": 'https://{data.get("console", "")}/networkmanager/home?region={data.get("region", "")}#/networks/{data.get("resource", "")}',
@@ -0,0 +1,230 @@
1
+ """Container-registry (OCI image) console deep-links.
2
+
3
+ An image reference (``registry-host/repository[:tag][@digest]``) is a different
4
+ identifier axis than an ARN / resource_name / resource id: the *hostname*
5
+ identifies the provider. This module parses the reference, classifies the host,
6
+ and routes to a console link — delegating to the existing provider linkers where
7
+ a path already exists, and building the URL directly only where none does
8
+ (Docker Hub, ECR Public, GCR, OCIR, ACR, and image/digest drill-in).
9
+
10
+ See ``docs/oci-images/spec.md``.
11
+ """
12
+ import re
13
+ from collections import namedtuple
14
+ from urllib.parse import quote
15
+
16
+ from cloudconsolelink.clouds.aws import AWSLinker, get_console
17
+ from cloudconsolelink.clouds.gcp import GCPLinker
18
+
19
+ ImageRef = namedtuple("ImageRef", ["host", "repository", "tag", "digest"])
20
+
21
+ # <account>.dkr.ecr.<region>.amazonaws.com (optional .cn for the aws-cn partition)
22
+ _ECR_HOST = re.compile(r"^(\d+)\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com(\.cn)?$")
23
+ # <region>.gcr.io legacy hosts (gcr.io, us.gcr.io, eu.gcr.io, asia.gcr.io, ...)
24
+ _GCR_HOST = re.compile(r"^([a-z0-9-]+\.)?gcr\.io$")
25
+
26
+ _DOCKERHUB_HOSTS = {"", "docker.io", "registry-1.docker.io", "index.docker.io"}
27
+
28
+
29
+ def parse_image_ref(ref):
30
+ """Split an OCI image reference into ``ImageRef(host, repository, tag, digest)``.
31
+
32
+ Grammar: ``[registry-host[:port]/]repository[:tag][@digest]``. A first path
33
+ segment that looks like a hostname (contains ``.`` or ``:``, or is
34
+ ``localhost``) is treated as the registry host; otherwise the reference is
35
+ hostless (Docker Hub). ``repository`` keeps all remaining ``/`` segments.
36
+ """
37
+ ref = (ref or "").strip()
38
+ if not ref:
39
+ raise ValueError("empty image reference")
40
+
41
+ digest = ""
42
+ if "@" in ref:
43
+ ref, digest = ref.split("@", 1)
44
+ if not digest:
45
+ raise ValueError(f"image reference has an empty digest: {ref!r}@")
46
+
47
+ first, slash, rest = ref.partition("/")
48
+ if slash and ("." in first or ":" in first or first == "localhost"):
49
+ host, path = first, rest
50
+ else:
51
+ host, path = "", ref
52
+
53
+ tag = ""
54
+ seg_start = path.rfind("/") + 1
55
+ colon = path.find(":", seg_start)
56
+ if colon != -1:
57
+ tag = path[colon + 1:]
58
+ path = path[:colon]
59
+ if not tag:
60
+ raise ValueError(f"image reference has an empty tag: {path!r}:")
61
+
62
+ if not path:
63
+ raise ValueError(f"image reference has no repository: {ref!r}")
64
+
65
+ return ImageRef(host=host, repository=path, tag=tag, digest=digest)
66
+
67
+
68
+ def classify(host):
69
+ """Map a registry hostname to a registry family key.
70
+
71
+ Returns one of ``ecr`` | ``ecr-public`` | ``gar`` | ``gcr`` | ``ocir`` |
72
+ ``acr`` | ``dockerhub``, or ``""`` for an unrecognized host.
73
+ """
74
+ if host in _DOCKERHUB_HOSTS:
75
+ return "dockerhub"
76
+ if host == "public.ecr.aws":
77
+ return "ecr-public"
78
+ if _ECR_HOST.match(host):
79
+ return "ecr"
80
+ if host.endswith("-docker.pkg.dev"):
81
+ return "gar"
82
+ if _GCR_HOST.match(host):
83
+ return "gcr"
84
+ if host.endswith(".ocir.io"):
85
+ return "ocir"
86
+ if host.endswith(".azurecr.io"):
87
+ return "acr"
88
+ return ""
89
+
90
+
91
+ # --- per-family builders ---------------------------------------------------
92
+ # Repo-level links delegate to the existing provider linker (no URL duplicated).
93
+ # Image/tag/digest drill-in URLs are built here because no existing identifier
94
+ # (ARN / resource_name) can carry image coordinates.
95
+
96
+
97
+ def _ecr(ref, hints):
98
+ account, region, cn = _ECR_HOST.match(ref.host).groups()
99
+ if region.startswith("us-gov-"):
100
+ partition = "aws-us-gov"
101
+ elif cn:
102
+ partition = "aws-cn"
103
+ else:
104
+ partition = "aws"
105
+
106
+ if ref.digest:
107
+ # ECR console addresses images by digest — net-new, ARN can't carry it.
108
+ console = get_console(partition)
109
+ return (
110
+ f"https://{region}.{console}/ecr/repositories/private/{account}/"
111
+ f"{ref.repository}/_/image/{ref.digest}/details?region={region}"
112
+ )
113
+
114
+ # Repo view (tag-only refs land here too — tags are listed on the repo page).
115
+ arn = f"arn:{partition}:ecr:{region}:{account}:repository/{ref.repository}"
116
+ return AWSLinker().get_console_link(arn=arn)
117
+
118
+
119
+ def _ecr_public(ref, hints):
120
+ return f"https://gallery.ecr.aws/{ref.repository}"
121
+
122
+
123
+ def _gar(ref, hints):
124
+ location = ref.host[: -len("-docker.pkg.dev")]
125
+ parts = ref.repository.split("/")
126
+ if len(parts) < 2:
127
+ raise ValueError(
128
+ f"GAR image reference needs <project>/<repository>[/<image>]: {ref.repository!r}"
129
+ )
130
+ project, repo = parts[0], parts[1]
131
+
132
+ if len(parts) >= 3:
133
+ image = "/".join(parts[2:]) # net-new: image drill-in below the repo
134
+ return (
135
+ f"https://console.cloud.google.com/artifacts/docker/{project}/{location}/"
136
+ f"{repo}/{image}?project={project}"
137
+ )
138
+
139
+ # project/repo -> repo view via the existing Artifact Registry template.
140
+ return GCPLinker().get_console_link(
141
+ resource_name="artifact_registry_repository",
142
+ project_id=project,
143
+ region=location,
144
+ instance_name=repo,
145
+ )
146
+
147
+
148
+ def _gcr(ref, hints):
149
+ # gcr.io -> GLOBAL, us.gcr.io -> US, eu.gcr.io -> EU, asia.gcr.io -> ASIA
150
+ prefix = ref.host[: -len(".gcr.io")] if ref.host != "gcr.io" else ""
151
+ location = prefix.upper() if prefix else "GLOBAL"
152
+ parts = ref.repository.split("/")
153
+ project = parts[0]
154
+ image = "/".join(parts[1:])
155
+ if not image:
156
+ return f"https://console.cloud.google.com/gcr/images/{project}?project={project}"
157
+ return (
158
+ f"https://console.cloud.google.com/gcr/images/{project}/{location}/"
159
+ f"{image}?project={project}"
160
+ )
161
+
162
+
163
+ def _dockerhub(ref, hints):
164
+ parts = ref.repository.split("/")
165
+ if len(parts) == 1:
166
+ namespace, repo = "library", parts[0]
167
+ else:
168
+ namespace, repo = parts[0], "/".join(parts[1:])
169
+
170
+ if namespace == "library":
171
+ url = f"https://hub.docker.com/_/{repo}"
172
+ else:
173
+ url = f"https://hub.docker.com/r/{namespace}/{repo}"
174
+ if ref.tag:
175
+ url += f"/tags?name={ref.tag}"
176
+ return url
177
+
178
+
179
+ def _ocir(ref, hints):
180
+ # OCIR console needs an OCID (absent from the image ref) to reach a repo, so
181
+ # fall back to the Container Registry landing page. ponytail: no OCID lookup.
182
+ return "https://cloud.oracle.com/registry/containers/repos"
183
+
184
+
185
+ def _acr(ref, hints):
186
+ name = ref.host[: -len(".azurecr.io")]
187
+ sub = hints.get("subscription_id")
188
+ rg = hints.get("resource_group")
189
+ if sub and rg:
190
+ arm_id = (
191
+ f"/subscriptions/{sub}/resourceGroups/{rg}/providers/"
192
+ f"Microsoft.ContainerRegistry/registries/{name}"
193
+ )
194
+ return (
195
+ "https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/"
196
+ f"RepositoryBlade/registryId/{quote(arm_id, safe='')}/repository/{ref.repository}"
197
+ )
198
+ # No subscription/resource-group hints -> registry browse fallback.
199
+ return (
200
+ "https://portal.azure.com/#blade/HubsExtension/BrowseResource/"
201
+ "resourceType/Microsoft.ContainerRegistry%2Fregistries"
202
+ )
203
+
204
+
205
+ _BUILDERS = {
206
+ "ecr": _ecr,
207
+ "ecr-public": _ecr_public,
208
+ "gar": _gar,
209
+ "gcr": _gcr,
210
+ "dockerhub": _dockerhub,
211
+ "ocir": _ocir,
212
+ "acr": _acr,
213
+ }
214
+
215
+
216
+ def image_console_link(image_ref, **hints):
217
+ """Return a cloud-console deep-link for a container image reference.
218
+
219
+ ``hints`` supplies data absent from the reference itself — currently
220
+ ``subscription_id`` and ``resource_group`` for Azure Container Registry.
221
+ An unrecognized registry host falls back to a Docker Hub search.
222
+ """
223
+ ref = parse_image_ref(image_ref)
224
+ builder = _BUILDERS.get(classify(ref.host))
225
+ if builder is None:
226
+ return f"https://hub.docker.com/search?q={quote(ref.repository)}"
227
+ return builder(ref, hints)
228
+
229
+
230
+ __all__ = ["ImageRef", "parse_image_ref", "classify", "image_console_link"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudconsolelink
3
- Version: 4.0.0
3
+ Version: 4.1.1
4
4
  Summary: Generate console links for cloud resources
5
5
  Home-page: https://github.com/Cloudanix/cloud-console-links
6
6
  Maintainer: Cloudanix
@@ -14,7 +14,7 @@ Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Provides-Extra: dev
16
16
  Requires-Dist: pre-commit~=4.2; extra == "dev"
17
- Requires-Dist: pytest~=8.3; extra == "dev"
17
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
18
18
  Requires-Dist: pytest-cov==2.11; extra == "dev"
19
19
  Requires-Dist: pytest-mock~=3.14; extra == "dev"
20
20
  Requires-Dist: types-PyYAML~=6.0; extra == "dev"
@@ -65,6 +65,9 @@ make check
65
65
  #### For GCP
66
66
  ```from cloudconsolelink.clouds.gcp import GCPLinker```
67
67
 
68
+ #### For container images (ECR, GAR/GCR, ACR, Docker Hub, OCIR)
69
+ ```from cloudconsolelink.clouds.registry import image_console_link```
70
+
68
71
  #### To get console link
69
72
  call method ```get_console_link()```
70
73
 
@@ -119,7 +122,26 @@ resource_name = "storage_bucket"
119
122
  console_link = gcp.get_console_link(bucket_name=bucket_name, resource_name=resource_name)
120
123
  ```
121
124
 
122
- ## get_console_link() parameters discription:
125
+ #### Container image (ECR, GAR/GCR, ACR, Docker Hub, OCIR):
126
+ ```python
127
+ from cloudconsolelink.clouds.registry import image_console_link
128
+
129
+ # Provider is detected from the registry hostname of the image reference.
130
+ console_link = image_console_link(
131
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni-init"
132
+ )
133
+
134
+ # Azure Container Registry needs subscription/resource-group hints for a deep
135
+ # link (they are not present in the image reference); without them it degrades
136
+ # to a registry browse link.
137
+ console_link = image_console_link(
138
+ "myregistry.azurecr.io/app",
139
+ subscription_id="5592e8dc-...",
140
+ resource_group="my-rg",
141
+ )
142
+ ```
143
+
144
+ ## get_console_link() parameters description:
123
145
 
124
146
  ### AWS:
125
147
  1) arn: arn of resource
@@ -20,9 +20,12 @@ cloudconsolelink/clouds/gcp/__init__.py
20
20
  cloudconsolelink/clouds/gcp/links.py
21
21
  cloudconsolelink/clouds/oci/__init__.py
22
22
  cloudconsolelink/clouds/oci/links.py
23
+ cloudconsolelink/clouds/registry/__init__.py
23
24
  tests/test_aws.py
24
25
  tests/test_azure.py
25
26
  tests/test_gcp.py
26
27
  tests/test_oci.py
27
28
  tests/test_packaging.py
28
- tests/test_provider_coverage.py
29
+ tests/test_provider_coverage.py
30
+ tests/test_registry_link.py
31
+ tests/test_registry_parse.py
@@ -1,7 +1,7 @@
1
1
 
2
2
  [dev]
3
3
  pre-commit~=4.2
4
- pytest~=8.3
4
+ pytest>=9.0.3
5
5
  pytest-cov==2.11
6
6
  pytest-mock~=3.14
7
7
  types-PyYAML~=6.0
@@ -1,5 +1,5 @@
1
1
  pre-commit~=4.2
2
- pytest~=8.3
2
+ pytest>=9.0.3
3
3
  pytest-cov==2.11
4
4
  pytest-mock~=3.14
5
5
 
@@ -1,3 +1,4 @@
1
+ import subprocess
1
2
  from pathlib import Path
2
3
 
3
4
  import setuptools
@@ -24,8 +25,23 @@ def read_requirements(path: str) -> list[str]:
24
25
  return lines
25
26
 
26
27
 
28
+ def git_version(fallback: str) -> str:
29
+ # ponytail: build-time version from the current git tag; falls back to
30
+ # __init__.py when git/tags are absent (e.g. building from an sdist tarball).
31
+ try:
32
+ tag = subprocess.check_output(
33
+ ["git", "describe", "--tags", "--abbrev=0"],
34
+ cwd=ROOT,
35
+ stderr=subprocess.DEVNULL,
36
+ )
37
+ except (subprocess.CalledProcessError, FileNotFoundError):
38
+ return fallback
39
+ return tag.decode().strip().lstrip("v") or fallback
40
+
41
+
27
42
  package_info: dict[str, object] = {}
28
43
  exec(read_text("cloudconsolelink/__init__.py"), package_info)
44
+ version = git_version(str(package_info["__version__"]))
29
45
  long_description = read_text("README.md")
30
46
  install_requires = read_requirements("requirements.txt")
31
47
  dev_requirements = read_requirements("requirements-dev.txt")
@@ -35,7 +51,7 @@ setuptools.setup(
35
51
  # Here is the module name.
36
52
  name="cloudconsolelink",
37
53
  # version of the module
38
- version=str(package_info["__version__"]),
54
+ version=version,
39
55
  # Name of Maintainer
40
56
  maintainer="Cloudanix",
41
57
  # Maintainer Email address
@@ -6,6 +6,7 @@ from cloudconsolelink.clouds.aws import InvalidARNError
6
6
  from cloudconsolelink.clouds.aws import InvalidPartitionError
7
7
  from cloudconsolelink.clouds.aws import InvalidServiceError
8
8
  from cloudconsolelink.clouds.aws import get_service_home_link
9
+ from cloudconsolelink.clouds.aws import _render
9
10
  from cloudconsolelink.clouds.aws.links import get_links
10
11
 
11
12
  aws = AWSLinker()
@@ -23,7 +24,7 @@ def test_aws_ec2_instance():
23
24
 
24
25
  def test_aws_ecr_repository():
25
26
  arn = "arn:aws:ecr:us-east1:1234567890:repository/repo1"
26
- expected_link = "https://us-east1.console.aws.amazon.com/ecr/repositories/repo1\
27
+ expected_link = "https://us-east1.console.aws.amazon.com/ecr/repositories/private/1234567890/repo1\
27
28
  ?region=us-east1"
28
29
 
29
30
  out_link = aws.get_console_link(arn=arn)
@@ -283,3 +284,60 @@ def test_aws_ec2_key_pair_trailing_colon_falls_back_to_key_pair_page():
283
284
  out_link = aws.get_console_link(arn=arn)
284
285
  assert out_link == "https://us-east-1.console.aws.amazon.com/ec2/v2/home?region=us-east-1#KeyPairs:"
285
286
 
287
+
288
+ def test_aws_network_firewall_firewall():
289
+ arn = "arn:aws:network-firewall:us-east-1:123456789012:firewall/my-firewall"
290
+ out_link = aws.get_console_link(arn=arn)
291
+ assert out_link == (
292
+ "https://us-east-1.console.aws.amazon.com/vpc/home"
293
+ "?region=us-east-1"
294
+ "#FirewallDetails:firewallArn=arn:aws:network-firewall:us-east-1:123456789012:firewall/my-firewall"
295
+ )
296
+
297
+
298
+ def test_aws_network_firewall_policy():
299
+ arn = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my-policy"
300
+ out_link = aws.get_console_link(arn=arn)
301
+ assert out_link == (
302
+ "https://us-east-1.console.aws.amazon.com/vpc/home"
303
+ "?region=us-east-1"
304
+ "#FirewallPolicyDetails:firewallPolicyArn=arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my-policy"
305
+ )
306
+
307
+
308
+ def test_aws_network_firewall_stateful_rulegroup():
309
+ arn = "arn:aws:network-firewall:us-east-1:123456789012:stateful-rulegroup/my-rules"
310
+ out_link = aws.get_console_link(arn=arn)
311
+ assert out_link == (
312
+ "https://us-east-1.console.aws.amazon.com/vpc/home"
313
+ "?region=us-east-1"
314
+ "#StatefulRuleGroupDetails:ruleGroupArn=arn:aws:network-firewall:us-east-1:123456789012:stateful-rulegroup/my-rules"
315
+ )
316
+
317
+
318
+ def test_aws_network_firewall_stateless_rulegroup():
319
+ arn = "arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/my-rules"
320
+ out_link = aws.get_console_link(arn=arn)
321
+ assert out_link == (
322
+ "https://us-east-1.console.aws.amazon.com/vpc/home"
323
+ "?region=us-east-1"
324
+ "#StatelessRuleGroupDetails:ruleGroupArn=arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/my-rules"
325
+ )
326
+
327
+
328
+ def test_aws_network_firewall_home():
329
+ arn = "arn:aws:network-firewall:us-east-1:123456789012"
330
+ out_link = aws.get_console_link(arn=arn)
331
+ assert out_link == "https://us-east-1.console.aws.amazon.com/vpc/home?region=us-east-1#Firewalls:"
332
+
333
+
334
+ def test_aws_render_unknown_template_expression_returns_empty_string():
335
+ result = _render("{unknown_expr}", {"region": "us-east-1", "console": "console.aws.amazon.com"})
336
+ assert result == ""
337
+
338
+
339
+ def test_aws_network_firewall_unknown_resource_falls_back_to_home():
340
+ arn = "arn:aws:network-firewall:us-east-1:123456789012:unknown-type/res-1"
341
+ out_link = aws.get_console_link(arn=arn)
342
+ assert out_link == "https://us-east-1.console.aws.amazon.com/vpc/home?region=us-east-1#Firewalls:"
343
+
@@ -363,3 +363,41 @@ def test_oci_linker_supports_all_resource_builders(method_name: str):
363
363
  def test_oci_linker_rejects_unknown_resource_name():
364
364
  with pytest.raises(ValueError, match="Invalid parameters provided"):
365
365
  OCILinker().get_console_link(resource_name="missing_resource", region="us-ashburn-1")
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Container-registry (OCI image) coverage
370
+ # ---------------------------------------------------------------------------
371
+
372
+ from cloudconsolelink.clouds.registry import _BUILDERS, classify, image_console_link
373
+
374
+ # One representative image ref per registry family, exercising every builder.
375
+ REGISTRY_SAMPLE_REFS = {
376
+ "ecr": "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo",
377
+ "ecr-public": "public.ecr.aws/alias/repo",
378
+ "gar": "us-docker.pkg.dev/proj/repo/img",
379
+ "gcr": "gcr.io/proj/img",
380
+ "dockerhub": "docker.io/library/nginx",
381
+ "ocir": "iad.ocir.io/tenancy/repo",
382
+ "acr": "myreg.azurecr.io/app",
383
+ }
384
+
385
+
386
+ def test_every_builder_has_a_sample_ref():
387
+ # A new registry family must be wired into classify() *and* covered here.
388
+ assert set(_BUILDERS) == set(REGISTRY_SAMPLE_REFS)
389
+
390
+
391
+ @pytest.mark.parametrize("family", sorted(_BUILDERS))
392
+ def test_registry_family_is_reachable(family: str):
393
+ ref = REGISTRY_SAMPLE_REFS[family]
394
+ # The sample ref classifies to the family it stands for...
395
+ assert classify(classify_host(ref)) == family
396
+ # ...and routing it yields an https console link.
397
+ assert image_console_link(ref).startswith("https://")
398
+
399
+
400
+ def classify_host(ref: str) -> str:
401
+ from cloudconsolelink.clouds.registry import parse_image_ref
402
+
403
+ return parse_image_ref(ref).host
@@ -0,0 +1,116 @@
1
+ from urllib.parse import quote
2
+
3
+ import pytest
4
+
5
+ from cloudconsolelink.clouds.registry import image_console_link
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ "ref, expected",
10
+ [
11
+ # --- ECR private (delegates to AWSLinker via a reconstructed ARN) ---
12
+ (
13
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni-init",
14
+ "https://us-east-2.console.aws.amazon.com/ecr/repositories/private/"
15
+ "602401143452/amazon-k8s-cni-init?region=us-east-2",
16
+ ),
17
+ # digest drill-in (built directly, ARN cannot carry a digest)
18
+ (
19
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/repo@sha256:abc123",
20
+ "https://us-east-2.console.aws.amazon.com/ecr/repositories/private/"
21
+ "602401143452/repo/_/image/sha256:abc123/details?region=us-east-2",
22
+ ),
23
+ # tag-only lands on the repo view
24
+ (
25
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/repo:v1",
26
+ "https://us-east-2.console.aws.amazon.com/ecr/repositories/private/"
27
+ "602401143452/repo?region=us-east-2",
28
+ ),
29
+ # China partition
30
+ (
31
+ "123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn/repo",
32
+ "https://cn-north-1.console.amazonaws.cn/ecr/repositories/private/"
33
+ "123456789012/repo?region=cn-north-1",
34
+ ),
35
+ # GovCloud partition (detected from the region prefix)
36
+ (
37
+ "123456789012.dkr.ecr.us-gov-west-1.amazonaws.com/repo",
38
+ "https://us-gov-west-1.console.amazonaws-us-gov.com/ecr/repositories/private/"
39
+ "123456789012/repo?region=us-gov-west-1",
40
+ ),
41
+ # --- ECR Public ---
42
+ ("public.ecr.aws/nginx/nginx", "https://gallery.ecr.aws/nginx/nginx"),
43
+ # --- GAR: image drill-in vs repo-only delegation ---
44
+ (
45
+ "us-docker.pkg.dev/proj/repo/img",
46
+ "https://console.cloud.google.com/artifacts/docker/proj/us/repo/img?project=proj",
47
+ ),
48
+ (
49
+ "europe-west1-docker.pkg.dev/proj/repo",
50
+ "https://console.cloud.google.com/artifacts/docker/proj/europe-west1/repo?project=proj",
51
+ ),
52
+ # --- GCR legacy ---
53
+ (
54
+ "gcr.io/proj/img",
55
+ "https://console.cloud.google.com/gcr/images/proj/GLOBAL/img?project=proj",
56
+ ),
57
+ (
58
+ "us.gcr.io/proj/img",
59
+ "https://console.cloud.google.com/gcr/images/proj/US/img?project=proj",
60
+ ),
61
+ (
62
+ "gcr.io/proj",
63
+ "https://console.cloud.google.com/gcr/images/proj?project=proj",
64
+ ),
65
+ # --- Docker Hub ---
66
+ ("nginx", "https://hub.docker.com/_/nginx"),
67
+ ("library/nginx", "https://hub.docker.com/_/nginx"),
68
+ ("nginx:1.25", "https://hub.docker.com/_/nginx/tags?name=1.25"),
69
+ ("bitnami/nginx", "https://hub.docker.com/r/bitnami/nginx"),
70
+ ("bitnami/nginx:1.25", "https://hub.docker.com/r/bitnami/nginx/tags?name=1.25"),
71
+ # --- OCIR (no OCID in ref -> registry landing page) ---
72
+ ("iad.ocir.io/tenancy/repo", "https://cloud.oracle.com/registry/containers/repos"),
73
+ # --- unknown registry -> Docker Hub search fallback ---
74
+ ("quay.io/org/app", "https://hub.docker.com/search?q=org/app"),
75
+ ],
76
+ )
77
+ def test_image_console_link(ref, expected):
78
+ assert image_console_link(ref) == expected
79
+
80
+
81
+ def test_acr_with_hints():
82
+ url = image_console_link(
83
+ "myreg.azurecr.io/app",
84
+ subscription_id="SUB",
85
+ resource_group="RG",
86
+ )
87
+ arm_id = "/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.ContainerRegistry/registries/myreg"
88
+ expected = (
89
+ "https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/"
90
+ f"RepositoryBlade/registryId/{quote(arm_id, safe='')}/repository/app"
91
+ )
92
+ assert url == expected
93
+
94
+
95
+ def test_acr_without_hints_browse_fallback():
96
+ url = image_console_link("myreg.azurecr.io/app")
97
+ assert url == (
98
+ "https://portal.azure.com/#blade/HubsExtension/BrowseResource/"
99
+ "resourceType/Microsoft.ContainerRegistry%2Fregistries"
100
+ )
101
+
102
+
103
+ def test_acr_partial_hints_falls_back():
104
+ # only one of the two required hints -> still the browse fallback
105
+ url = image_console_link("myreg.azurecr.io/app", subscription_id="SUB")
106
+ assert "BrowseResource" in url
107
+
108
+
109
+ def test_invalid_ref_raises():
110
+ with pytest.raises(ValueError):
111
+ image_console_link("")
112
+
113
+
114
+ def test_gar_single_segment_raises_clear_error():
115
+ with pytest.raises(ValueError, match="GAR image reference needs"):
116
+ image_console_link("us-docker.pkg.dev/proj")
@@ -0,0 +1,92 @@
1
+ import pytest
2
+
3
+ from cloudconsolelink.clouds.registry import ImageRef, classify, parse_image_ref
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "ref, expected",
8
+ [
9
+ # bare name -> Docker Hub, no host
10
+ ("nginx", ImageRef("", "nginx", "", "")),
11
+ ("nginx:1.25", ImageRef("", "nginx", "1.25", "")),
12
+ ("library/nginx", ImageRef("", "library/nginx", "", "")),
13
+ # explicit docker.io host
14
+ ("docker.io/library/nginx:latest", ImageRef("docker.io", "library/nginx", "latest", "")),
15
+ # ECR private, the motivating example
16
+ (
17
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni-init",
18
+ ImageRef("602401143452.dkr.ecr.us-east-2.amazonaws.com", "amazon-k8s-cni-init", "", ""),
19
+ ),
20
+ # tag + digest together
21
+ (
22
+ "reg.example.com/team/app:v1@sha256:abcdef",
23
+ ImageRef("reg.example.com", "team/app", "v1", "sha256:abcdef"),
24
+ ),
25
+ # digest only
26
+ (
27
+ "602401143452.dkr.ecr.us-east-2.amazonaws.com/repo@sha256:deadbeef",
28
+ ImageRef("602401143452.dkr.ecr.us-east-2.amazonaws.com", "repo", "", "sha256:deadbeef"),
29
+ ),
30
+ # host with port
31
+ ("localhost:5000/my/img:dev", ImageRef("localhost:5000", "my/img", "dev", "")),
32
+ ("localhost/img", ImageRef("localhost", "img", "", "")),
33
+ # multi-segment repository (GAR: project/repo/image)
34
+ (
35
+ "us-docker.pkg.dev/proj/repo/img:tag",
36
+ ImageRef("us-docker.pkg.dev", "proj/repo/img", "tag", ""),
37
+ ),
38
+ # surrounding whitespace stripped
39
+ (" nginx:1 ", ImageRef("", "nginx", "1", "")),
40
+ ],
41
+ )
42
+ def test_parse_image_ref(ref, expected):
43
+ assert parse_image_ref(ref) == expected
44
+
45
+
46
+ @pytest.mark.parametrize("bad", ["", " ", None])
47
+ def test_parse_image_ref_empty_raises(bad):
48
+ with pytest.raises(ValueError):
49
+ parse_image_ref(bad)
50
+
51
+
52
+ def test_parse_image_ref_host_only_no_repo_raises():
53
+ # host present but nothing after the slash
54
+ with pytest.raises(ValueError):
55
+ parse_image_ref("reg.example.com/")
56
+
57
+
58
+ @pytest.mark.parametrize("bad", ["repo@", "reg.io/repo@", "reg.io/a/b:v1@"])
59
+ def test_parse_image_ref_empty_digest_raises(bad):
60
+ with pytest.raises(ValueError, match="empty digest"):
61
+ parse_image_ref(bad)
62
+
63
+
64
+ @pytest.mark.parametrize("bad", ["repo:", "reg.io/repo:", "reg.io/a/b:"])
65
+ def test_parse_image_ref_empty_tag_raises(bad):
66
+ with pytest.raises(ValueError, match="empty tag"):
67
+ parse_image_ref(bad)
68
+
69
+
70
+ @pytest.mark.parametrize(
71
+ "host, family",
72
+ [
73
+ ("", "dockerhub"),
74
+ ("docker.io", "dockerhub"),
75
+ ("registry-1.docker.io", "dockerhub"),
76
+ ("index.docker.io", "dockerhub"),
77
+ ("public.ecr.aws", "ecr-public"),
78
+ ("602401143452.dkr.ecr.us-east-2.amazonaws.com", "ecr"),
79
+ ("123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn", "ecr"),
80
+ ("us-docker.pkg.dev", "gar"),
81
+ ("europe-west1-docker.pkg.dev", "gar"),
82
+ ("gcr.io", "gcr"),
83
+ ("us.gcr.io", "gcr"),
84
+ ("asia.gcr.io", "gcr"),
85
+ ("iad.ocir.io", "ocir"),
86
+ ("myregistry.azurecr.io", "acr"),
87
+ ("unknown.example.com", ""),
88
+ ("quay.io", ""),
89
+ ],
90
+ )
91
+ def test_classify(host, family):
92
+ assert classify(host) == family