mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(api): warm compliance caches when starting the worker (#11530)
This commit is contained in:
@@ -19,6 +19,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
|
||||
- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476)
|
||||
- Compliance catalog now warms in a background thread after each worker forks, and `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the Gunicorn worker timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from api.models import Provider
|
||||
@@ -6,8 +8,19 @@ from prowler.lib.check.compliance_models import (
|
||||
)
|
||||
from prowler.lib.check.models import CheckMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
|
||||
|
||||
# Per-process readiness flags for the background compliance warm-up.
|
||||
# `STARTED` is set as soon as warming begins (only happens under Gunicorn via
|
||||
# the post_fork hook); `WARMED` is set when it finishes. The attributes
|
||||
# endpoint checks both: it returns 503 only while warming is in progress.
|
||||
# Under `runserver` warming never runs, so `STARTED` stays clear and the
|
||||
# endpoint keeps lazy-loading as before.
|
||||
COMPLIANCE_WARMING_STARTED = threading.Event()
|
||||
COMPLIANCE_WARMED = threading.Event()
|
||||
|
||||
|
||||
class LazyComplianceTemplate(Mapping):
|
||||
"""Lazy-load compliance templates per provider on first access."""
|
||||
@@ -174,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None:
|
||||
PROWLER_CHECKS._cache[provider_type] = checks
|
||||
|
||||
|
||||
def warm_compliance_caches(
|
||||
provider_types: Iterable[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Eagerly populate the per-process compliance caches at server startup.
|
||||
|
||||
Moves the cold-cache catalog load off the request thread so the first
|
||||
request does not trip the Gunicorn worker timeout. Reads only on-disk
|
||||
metadata (no database access). Each provider is warmed in isolation;
|
||||
failures are logged and fall back to lazy loading.
|
||||
|
||||
Args:
|
||||
provider_types (Iterable[str] | None): Subset to warm. Defaults to all.
|
||||
|
||||
Returns:
|
||||
list[str]: Provider types that could not be warmed.
|
||||
"""
|
||||
if provider_types is None:
|
||||
provider_types = Provider.ProviderChoices.values
|
||||
provider_types = list(provider_types)
|
||||
|
||||
COMPLIANCE_WARMING_STARTED.set()
|
||||
logger.info("Compliance cache warm-up started for providers: %s", provider_types)
|
||||
|
||||
failed = []
|
||||
for provider_type in provider_types:
|
||||
try:
|
||||
get_compliance_frameworks(provider_type)
|
||||
_ensure_provider_loaded(provider_type)
|
||||
# Prowler check loading may sys.exit (SystemExit, not Exception).
|
||||
except (Exception, SystemExit):
|
||||
logger.warning(
|
||||
"Failed to warm compliance caches for provider '%s'; "
|
||||
"loading lazily on first request",
|
||||
provider_type,
|
||||
exc_info=True,
|
||||
)
|
||||
failed.append(provider_type)
|
||||
|
||||
# Mark as warmed even when some providers failed: a failed provider falls
|
||||
# back to a single-provider lazy load, which stays under the worker timeout.
|
||||
COMPLIANCE_WARMED.set()
|
||||
logger.info(
|
||||
"Compliance cache warm-up finished (providers warmed: %d, failed: %s)",
|
||||
len(provider_types) - len(failed),
|
||||
failed,
|
||||
)
|
||||
return failed
|
||||
|
||||
|
||||
def load_prowler_checks(
|
||||
prowler_compliance, provider_types: Iterable[str] | None = None
|
||||
):
|
||||
|
||||
@@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException):
|
||||
)
|
||||
|
||||
|
||||
class ComplianceWarmingError(APIException):
|
||||
"""Compliance catalog is still warming (503 Service Unavailable).
|
||||
|
||||
Returned by the compliance attributes endpoint while the per-process
|
||||
catalog warm-up is in progress, so the request thread never triggers the
|
||||
slow cold load that would trip the Gunicorn worker timeout.
|
||||
"""
|
||||
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
default_detail = (
|
||||
"Compliance data is still loading. Please try again in a few seconds."
|
||||
)
|
||||
default_code = "compliance_warming"
|
||||
|
||||
def __init__(self, detail=None):
|
||||
super().__init__(
|
||||
detail=[
|
||||
{
|
||||
"detail": detail or self.default_detail,
|
||||
"status": str(self.status_code),
|
||||
"code": self.default_code,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class UpstreamInternalError(APIException):
|
||||
"""Unexpected error communicating with provider (500 Internal Server Error).
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from api.compliance import (
|
||||
get_prowler_provider_checks,
|
||||
get_prowler_provider_compliance,
|
||||
load_prowler_checks,
|
||||
warm_compliance_caches,
|
||||
)
|
||||
from api.models import Provider
|
||||
from prowler.lib.check.compliance_models import (
|
||||
@@ -267,11 +268,17 @@ def reset_compliance_cache():
|
||||
"""Reset the module-level cache so each test starts cold."""
|
||||
previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS)
|
||||
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
|
||||
# The warming flags are module-global; clear them so they do not leak
|
||||
# between tests that call warm_compliance_caches.
|
||||
compliance_module.COMPLIANCE_WARMING_STARTED.clear()
|
||||
compliance_module.COMPLIANCE_WARMED.clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
|
||||
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous)
|
||||
compliance_module.COMPLIANCE_WARMING_STARTED.clear()
|
||||
compliance_module.COMPLIANCE_WARMED.clear()
|
||||
|
||||
|
||||
class TestGetComplianceFrameworks:
|
||||
@@ -321,3 +328,89 @@ class TestGetComplianceFrameworks:
|
||||
f"loadable by get_bulk_compliance_frameworks_universal: "
|
||||
f"{sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
class TestWarmComplianceCaches:
|
||||
def test_warms_all_provider_types_by_default(self, reset_compliance_cache):
|
||||
provider_types = list(Provider.ProviderChoices.values)
|
||||
with (
|
||||
patch("api.compliance.get_compliance_frameworks") as mock_frameworks,
|
||||
patch("api.compliance._ensure_provider_loaded") as mock_ensure,
|
||||
):
|
||||
warm_compliance_caches()
|
||||
|
||||
warmed = {call.args[0] for call in mock_frameworks.call_args_list}
|
||||
assert warmed == set(provider_types)
|
||||
assert mock_frameworks.call_count == len(provider_types)
|
||||
assert mock_ensure.call_count == len(provider_types)
|
||||
|
||||
def test_warms_only_requested_provider_types(self, reset_compliance_cache):
|
||||
with (
|
||||
patch("api.compliance.get_compliance_frameworks") as mock_frameworks,
|
||||
patch("api.compliance._ensure_provider_loaded") as mock_ensure,
|
||||
):
|
||||
warm_compliance_caches([Provider.ProviderChoices.AWS])
|
||||
|
||||
mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS)
|
||||
mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS)
|
||||
|
||||
def test_populates_module_cache(self, reset_compliance_cache):
|
||||
with (
|
||||
patch(
|
||||
"api.compliance.get_bulk_compliance_frameworks_universal"
|
||||
) as mock_get_bulk,
|
||||
patch("api.compliance._ensure_provider_loaded"),
|
||||
):
|
||||
mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
|
||||
warm_compliance_caches([Provider.ProviderChoices.AWS])
|
||||
|
||||
assert (
|
||||
Provider.ProviderChoices.AWS
|
||||
in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS
|
||||
)
|
||||
|
||||
def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache):
|
||||
"""A failing provider (even on SystemExit) is isolated; others warm."""
|
||||
providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA]
|
||||
|
||||
def fake_frameworks(provider_type):
|
||||
if provider_type == Provider.ProviderChoices.OKTA:
|
||||
raise SystemExit(1)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.compliance.get_compliance_frameworks", side_effect=fake_frameworks
|
||||
),
|
||||
patch("api.compliance._ensure_provider_loaded") as mock_ensure,
|
||||
):
|
||||
failed = warm_compliance_caches(providers)
|
||||
|
||||
assert failed == [Provider.ProviderChoices.OKTA]
|
||||
mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS)
|
||||
|
||||
def test_sets_readiness_flags(self, reset_compliance_cache):
|
||||
assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set()
|
||||
assert not compliance_module.COMPLIANCE_WARMED.is_set()
|
||||
|
||||
with (
|
||||
patch("api.compliance.get_compliance_frameworks"),
|
||||
patch("api.compliance._ensure_provider_loaded"),
|
||||
):
|
||||
warm_compliance_caches([Provider.ProviderChoices.AWS])
|
||||
|
||||
assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set()
|
||||
assert compliance_module.COMPLIANCE_WARMED.is_set()
|
||||
|
||||
def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache):
|
||||
"""A failed provider still leaves the caches flagged as warmed."""
|
||||
with (
|
||||
patch(
|
||||
"api.compliance.get_compliance_frameworks",
|
||||
side_effect=SystemExit(1),
|
||||
),
|
||||
patch("api.compliance._ensure_provider_loaded"),
|
||||
):
|
||||
warm_compliance_caches([Provider.ProviderChoices.AWS])
|
||||
|
||||
assert compliance_module.COMPLIANCE_WARMED.is_set()
|
||||
|
||||
@@ -9578,6 +9578,39 @@ class TestComplianceOverviewViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_compliance_overview_attributes_503_while_warming(
|
||||
self, authenticated_client
|
||||
):
|
||||
from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED
|
||||
|
||||
COMPLIANCE_WARMING_STARTED.set()
|
||||
COMPLIANCE_WARMED.clear()
|
||||
try:
|
||||
response = authenticated_client.get(
|
||||
reverse("complianceoverview-attributes"),
|
||||
{"filter[compliance_id]": "aws_account_security_onboarding_aws"},
|
||||
)
|
||||
finally:
|
||||
COMPLIANCE_WARMING_STARTED.clear()
|
||||
|
||||
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
assert response.json()["errors"][0]["code"] == "compliance_warming"
|
||||
|
||||
def test_compliance_overview_attributes_serves_when_warming_not_started(
|
||||
self, authenticated_client
|
||||
):
|
||||
# Dev fallback: under runserver warming never runs, so the guard must
|
||||
# not refuse — the endpoint lazily loads and serves as before.
|
||||
from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED
|
||||
|
||||
COMPLIANCE_WARMING_STARTED.clear()
|
||||
COMPLIANCE_WARMED.clear()
|
||||
response = authenticated_client.get(
|
||||
reverse("complianceoverview-attributes"),
|
||||
{"filter[compliance_id]": "aws_account_security_onboarding_aws"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_compliance_overview_task_management_integration(
|
||||
self, authenticated_client, compliance_requirements_overviews_fixture
|
||||
):
|
||||
|
||||
@@ -114,6 +114,8 @@ from api.attack_paths import get_queries_for_provider, get_query_by_id
|
||||
from api.attack_paths import views_helpers as attack_paths_views_helpers
|
||||
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
|
||||
from api.compliance import (
|
||||
COMPLIANCE_WARMED,
|
||||
COMPLIANCE_WARMING_STARTED,
|
||||
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
|
||||
get_compliance_frameworks,
|
||||
get_prowler_provider_compliance,
|
||||
@@ -122,6 +124,7 @@ from api.constants import SEVERITY_ORDER
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import (
|
||||
ComplianceWarmingError,
|
||||
TaskFailedException,
|
||||
UpstreamAccessDeniedError,
|
||||
UpstreamAuthenticationError,
|
||||
@@ -5059,6 +5062,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="attributes")
|
||||
def attributes(self, request):
|
||||
# While the background warm-up is in progress, refuse immediately
|
||||
# instead of falling through to the slow cold load on the request
|
||||
# thread (which would trip the Gunicorn worker timeout). `is_set()` is
|
||||
# a non-blocking flag read, so this never touches the loader.
|
||||
if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set():
|
||||
raise ComplianceWarmingError()
|
||||
|
||||
compliance_id = request.query_params.get("filter[compliance_id]")
|
||||
if not compliance_id:
|
||||
raise ValidationError(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import threading
|
||||
|
||||
from config.env import env
|
||||
|
||||
@@ -11,6 +12,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production")
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
from api.compliance import warm_compliance_caches # noqa: E402
|
||||
from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402
|
||||
from config.custom_logging import BackendLogger # noqa: E402
|
||||
|
||||
@@ -41,3 +43,26 @@ def on_reload(_):
|
||||
|
||||
def when_ready(_):
|
||||
gunicorn_logger.info("Gunicorn server is ready")
|
||||
|
||||
|
||||
def _warm_compliance_caches_in_background():
|
||||
"""Warm compliance caches off the request path and log the outcome."""
|
||||
failed = warm_compliance_caches()
|
||||
if failed:
|
||||
gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed)
|
||||
else:
|
||||
gunicorn_logger.info("Compliance caches warmed")
|
||||
|
||||
|
||||
def post_fork(_server, worker):
|
||||
"""Warm compliance caches after each worker fork.
|
||||
|
||||
Warm compliance caches in a background thread so the worker becomes ready
|
||||
immediately. A request for a not-yet-warmed provider lazily loads just that
|
||||
provider, which stays well under the worker timeout.
|
||||
"""
|
||||
threading.Thread(
|
||||
target=_warm_compliance_caches_in_background,
|
||||
name="warm-compliance-caches",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🔄 Changed
|
||||
|
||||
- Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508)
|
||||
- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -84,6 +84,13 @@ export const getComplianceAttributes = async (complianceId: string) => {
|
||||
headers,
|
||||
});
|
||||
|
||||
// The compliance catalog is still warming after a deploy/restart. Signal
|
||||
// the page to render the "still loading" state instead of letting this
|
||||
// become a thrown 5xx (which would be captured as a server error).
|
||||
if (response.status === 503) {
|
||||
return { warming: true as const, status: 503 };
|
||||
}
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching compliance attributes:", error);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ClientAccordionWrapper,
|
||||
ComplianceDownloadContainer,
|
||||
ComplianceHeader,
|
||||
ComplianceWarming,
|
||||
RequirementsStatusCard,
|
||||
RequirementsStatusCardSkeleton,
|
||||
// SectionsFailureRateCard,
|
||||
@@ -92,6 +93,16 @@ export default async function ComplianceDetail({
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// The compliance catalog is still warming after a deploy/restart. Show the
|
||||
// "still loading" state with a Try Again instead of rendering an empty page.
|
||||
if (attributesData?.warming) {
|
||||
return (
|
||||
<ContentLayout title={pageTitle}>
|
||||
<ComplianceWarming />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedScanResponse?.data) {
|
||||
const scan = selectedScanResponse.data;
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
export const ComplianceWarming = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[calc(100vh-56px)] items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<Card variant="base" padding="lg">
|
||||
<CardContent>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Icon
|
||||
icon="tabler:clock"
|
||||
className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="mb-1 text-base font-medium text-gray-900 dark:text-white">
|
||||
Compliance data is still loading
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
This can happen for a few seconds right after an update.
|
||||
Please try again shortly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0 gap-2"
|
||||
onClick={() => router.refresh()}
|
||||
aria-label="Reload compliance data"
|
||||
>
|
||||
<Icon icon="tabler:refresh" className="h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ export * from "./compliance-header/compliance-scan-info";
|
||||
export * from "./compliance-header/data-compliance";
|
||||
export * from "./compliance-header/scan-selector";
|
||||
export * from "./compliance-overview-grid";
|
||||
export * from "./compliance-warming";
|
||||
export * from "./no-scans-available";
|
||||
export * from "./skeletons/bar-chart-skeleton";
|
||||
export * from "./skeletons/compliance-accordion-skeleton";
|
||||
|
||||
Reference in New Issue
Block a user