mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-16 17:11:58 +00:00
156 lines
5.5 KiB
Python
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())
|