mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
refactor(image): deduplicate retry logic into registry adapter base class
- Move _request_with_retry, _next_page_url, _MAX_RETRIES, _BACKOFF_BASE from OciRegistryAdapter and DockerHubAdapter into RegistryAdapter base - Add context_label parameter to _request_with_retry for contextual error/warning messages (defaults to self.registry_url) - Route _hub_login and _get_registry_token through retry logic - Change 404 exception in _check_hub_response from ImageRegistryAuthError to ImageRegistryCatalogError (missing namespace is not an auth failure) - Remove redundant time, requests imports from leaf adapters
This commit is contained in:
@@ -2,8 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import requests
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageRegistryNetworkError,
|
||||
)
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
_BACKOFF_BASE = 1
|
||||
|
||||
|
||||
class RegistryAdapter(ABC):
|
||||
"""Abstract base class for registry adapters."""
|
||||
@@ -26,3 +38,50 @@ class RegistryAdapter(ABC):
|
||||
def list_tags(self, repository: str) -> list[str]:
|
||||
"""Enumerate all tags for a repository."""
|
||||
...
|
||||
|
||||
def _request_with_retry(self, method, url, **kwargs):
|
||||
context_label = kwargs.pop("context_label", None) or self.registry_url
|
||||
kwargs.setdefault("timeout", 30)
|
||||
kwargs.setdefault("verify", self.verify_ssl)
|
||||
last_exception = None
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.request(method, url, **kwargs)
|
||||
if resp.status_code == 429:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Rate limited by {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return resp
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
last_exception = exc
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Connection error to {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Connection timed out to {context_label}.",
|
||||
original_exception=exc,
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Failed to connect to {context_label} after {_MAX_RETRIES} attempts.",
|
||||
original_exception=last_exception,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _next_page_url(resp):
|
||||
link_header = resp.headers.get("Link", "")
|
||||
if not link_header:
|
||||
return None
|
||||
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
@@ -3,19 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageRegistryAuthError,
|
||||
ImageRegistryCatalogError,
|
||||
ImageRegistryNetworkError,
|
||||
)
|
||||
from prowler.providers.image.lib.registry.base import RegistryAdapter
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
_BACKOFF_BASE = 1
|
||||
_HUB_API = "https://hub.docker.com"
|
||||
_REGISTRY_HOST = "https://registry-1.docker.io"
|
||||
_AUTH_URL = "https://auth.docker.io/token"
|
||||
@@ -106,11 +102,11 @@ class DockerHubAdapter(RegistryAdapter):
|
||||
return
|
||||
if not self.username or not self.password:
|
||||
return
|
||||
resp = requests.post(
|
||||
resp = self._request_with_retry(
|
||||
"POST",
|
||||
f"{_HUB_API}/v2/users/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
verify=self.verify_ssl,
|
||||
timeout=30,
|
||||
context_label="Docker Hub",
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ImageRegistryAuthError(
|
||||
@@ -129,8 +125,8 @@ class DockerHubAdapter(RegistryAdapter):
|
||||
auth = None
|
||||
if self.username and self.password:
|
||||
auth = (self.username, self.password)
|
||||
resp = requests.get(
|
||||
_AUTH_URL, params=params, auth=auth, verify=self.verify_ssl, timeout=30
|
||||
resp = self._request_with_retry(
|
||||
"GET", _AUTH_URL, params=params, auth=auth, context_label="Docker Hub",
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ImageRegistryAuthError(
|
||||
@@ -142,54 +138,20 @@ class DockerHubAdapter(RegistryAdapter):
|
||||
return token
|
||||
|
||||
def _hub_request(self, method, url, **kwargs):
|
||||
kwargs.setdefault("timeout", 30)
|
||||
kwargs.setdefault("verify", self.verify_ssl)
|
||||
headers = kwargs.pop("headers", {})
|
||||
if self._hub_jwt:
|
||||
headers["Authorization"] = f"Bearer {self._hub_jwt}"
|
||||
kwargs["headers"] = headers
|
||||
return self._request_with_retry(method, url, **kwargs)
|
||||
return self._request_with_retry(
|
||||
method, url, context_label="Docker Hub", **kwargs
|
||||
)
|
||||
|
||||
def _registry_request(self, method, url, token, **kwargs):
|
||||
kwargs.setdefault("timeout", 30)
|
||||
kwargs.setdefault("verify", self.verify_ssl)
|
||||
headers = kwargs.pop("headers", {})
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
kwargs["headers"] = headers
|
||||
return self._request_with_retry(method, url, **kwargs)
|
||||
|
||||
def _request_with_retry(self, method, url, **kwargs):
|
||||
last_exception = None
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.request(method, url, **kwargs)
|
||||
if resp.status_code == 429:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Rate limited by Docker Hub, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return resp
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
last_exception = exc
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Connection error to Docker Hub, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message="Connection timed out to Docker Hub.",
|
||||
original_exception=exc,
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Failed to connect to Docker Hub after {_MAX_RETRIES} attempts.",
|
||||
original_exception=last_exception,
|
||||
return self._request_with_retry(
|
||||
method, url, context_label="Docker Hub", **kwargs
|
||||
)
|
||||
|
||||
def _check_hub_response(self, resp, context):
|
||||
@@ -201,7 +163,7 @@ class DockerHubAdapter(RegistryAdapter):
|
||||
message=f"Authentication failed for {context} on Docker Hub (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD environment variables.",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise ImageRegistryAuthError(
|
||||
raise ImageRegistryCatalogError(
|
||||
file=__file__,
|
||||
message=f"Namespace '{self.namespace}' not found on Docker Hub. Check the namespace in --registry docker.io/{{namespace}}.",
|
||||
)
|
||||
|
||||
@@ -4,11 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageRegistryAuthError,
|
||||
ImageRegistryCatalogError,
|
||||
@@ -16,9 +12,6 @@ from prowler.providers.image.exceptions.exceptions import (
|
||||
)
|
||||
from prowler.providers.image.lib.registry.base import RegistryAdapter
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
_BACKOFF_BASE = 1
|
||||
|
||||
|
||||
class OciRegistryAdapter(RegistryAdapter):
|
||||
"""Adapter for registries implementing OCI Distribution Spec."""
|
||||
@@ -162,42 +155,6 @@ class OciRegistryAdapter(RegistryAdapter):
|
||||
kwargs["headers"] = headers
|
||||
return self._request_with_retry(method, url, **kwargs)
|
||||
|
||||
def _request_with_retry(self, method, url, **kwargs):
|
||||
kwargs.setdefault("timeout", 30)
|
||||
kwargs.setdefault("verify", self.verify_ssl)
|
||||
last_exception = None
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.request(method, url, **kwargs)
|
||||
if resp.status_code == 429:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Rate limited by registry, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return resp
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
last_exception = exc
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Connection error to registry, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Connection timed out to {self.registry_url}.",
|
||||
original_exception=exc,
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Failed to connect to {self.registry_url} after {_MAX_RETRIES} attempts.",
|
||||
original_exception=last_exception,
|
||||
)
|
||||
|
||||
def _check_response(self, resp, context):
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
@@ -210,13 +167,3 @@ class OciRegistryAdapter(RegistryAdapter):
|
||||
file=__file__,
|
||||
message=f"Unexpected error during {context} on {self.registry_url} (HTTP {resp.status_code}): {resp.text[:200]}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _next_page_url(resp):
|
||||
link_header = resp.headers.get("Link", "")
|
||||
if not link_header:
|
||||
return None
|
||||
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user