Files
prowler/docs/scripts/generate_provider_cards.py
T

156 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Generate docs/snippets/provider-cards.mdx from provider getting-started pages.
Scans docs/user-guide/providers/<name>/getting-started-*.mdx, keeps only the
providers that Prowler App/Cloud actually supports (source of truth: the
`ProviderChoices` enum in api/src/backend/api/models.py — CLI-only providers
such as Linode/LLM/Scaleway/StackIT are excluded), reads the frontmatter
`title`, derives a display name, and emits a snippet exporting a
`ProviderCards` component. Wired into pre-commit so the snippet stays in sync
whenever a provider page or the API enum changes.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PROVIDERS_DIR = REPO_ROOT / "docs" / "user-guide" / "providers"
SNIPPET_PATH = REPO_ROOT / "docs" / "snippets" / "provider-cards.mdx"
API_MODELS_PATH = REPO_ROOT / "api" / "src" / "backend" / "api" / "models.py"
# Docs folder names that don't match the API enum key. Keep tiny — only rename
# entries when the docs folder disagrees with the API-side identifier.
DOCS_DIR_TO_API_KEY = {
"microsoft365": "m365",
"oci": "oraclecloud",
}
# Folder-name → Mintlify icon override. Providers not listed fall back to
# DEFAULT_ICON. Add an entry only when the default looks wrong for a provider.
ICON_OVERRIDES = {
"alibabacloud": "cloud",
"aws": "aws",
"azure": "microsoft",
"cloudflare": "cloudflare",
"gcp": "google",
"github": "github",
"googleworkspace": "users",
"iac": "code",
"image": "docker",
"kubernetes": "dharmachakra",
"microsoft365": "briefcase",
"mongodbatlas": "leaf",
"oci": "database",
"okta": "key",
"openstack": "cubes",
"vercel": "triangle",
}
DEFAULT_ICON = "cloud"
TITLE_RE = re.compile(r"^\s*title\s*:\s*['\"](?P<title>.+?)['\"]\s*$", re.MULTILINE)
NAME_CLEANUP_RE = re.compile(
r"^Getting Started [Ww]ith (?:the )?(?P<name>.+?)(?: on Prowler)?(?: Provider)?$"
)
def app_supported_provider_keys() -> set[str]:
"""Return the set of provider keys declared in the API's ProviderChoices enum.
Uses ast rather than regex so formatting changes, decorators, comments, or
multi-line values in the enum body don't silently drop or invent providers.
"""
tree = ast.parse(API_MODELS_PATH.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not (isinstance(node, ast.ClassDef) and node.name == "ProviderChoices"):
continue
keys: set[str] = set()
for item in node.body:
if not isinstance(item, ast.Assign):
continue
value = item.value
# Django TextChoices members look like: NAME = "key", _("Label")
# which parses as an ast.Tuple whose first element is the key.
if isinstance(value, ast.Tuple) and value.elts:
first = value.elts[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
keys.add(first.value)
return keys
raise RuntimeError(
f"Could not locate ProviderChoices class in {API_MODELS_PATH.relative_to(REPO_ROOT)}"
)
def extract_title(mdx_path: Path) -> str:
text = mdx_path.read_text(encoding="utf-8")
match = TITLE_RE.search(text)
if not match:
raise ValueError(f"No frontmatter title in {mdx_path}")
return match.group("title")
def display_name(title: str) -> str:
match = NAME_CLEANUP_RE.match(title)
return match.group("name") if match else title
def collect_providers() -> list[dict]:
supported = app_supported_provider_keys()
providers = []
for provider_dir in sorted(PROVIDERS_DIR.iterdir()):
if not provider_dir.is_dir():
continue
api_key = DOCS_DIR_TO_API_KEY.get(provider_dir.name, provider_dir.name)
if api_key not in supported:
continue
pages = sorted(provider_dir.glob("getting-started-*.mdx"))
if not pages:
continue
page = pages[0]
name = display_name(extract_title(page))
href = f"/user-guide/providers/{provider_dir.name}/{page.stem}"
icon = ICON_OVERRIDES.get(provider_dir.name, DEFAULT_ICON)
providers.append({"name": name, "href": href, "icon": icon})
providers.sort(key=lambda p: p["name"].lower())
return providers
def render_snippet(providers: list[dict]) -> str:
cards = "\n".join(
f' <Card title="{p["name"]}" icon="{p["icon"]}" href="{p["href"]}" />'
for p in providers
)
return (
"{/* AUTO-GENERATED by docs/scripts/generate_provider_cards.py — do not edit by hand. */}\n"
"{/* Regenerated on pre-commit whenever any provider getting-started page changes. */}\n"
"\n"
"export const ProviderCards = () => (\n"
" <Columns cols={3}>\n"
f"{cards}\n"
" </Columns>\n"
");\n"
)
def main() -> int:
providers = collect_providers()
if not providers:
print("No provider getting-started pages found", file=sys.stderr)
return 1
new_content = render_snippet(providers)
current = SNIPPET_PATH.read_text(encoding="utf-8") if SNIPPET_PATH.exists() else ""
if new_content == current:
return 0
SNIPPET_PATH.write_text(new_content, encoding="utf-8")
print(
f"Regenerated {SNIPPET_PATH.relative_to(REPO_ROOT)} ({len(providers)} providers)"
)
return 1 # signal pre-commit that the file changed
if __name__ == "__main__":
sys.exit(main())