|
97 | 97 | "timescaledb", |
98 | 98 | "valkey", |
99 | 99 | } |
| 100 | +DATABASE_RAW_WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"} |
| 101 | +DATABASE_RAW_RESOURCE_KINDS = DATABASE_RAW_WORKLOAD_KINDS | {"Service"} |
| 102 | +DATABASE_CLIENT_JOB_TOKENS = {"init", "migrate", "migration", "bootstrap", "setup", "seed", "backup", "restore"} |
| 103 | +DATABASE_RESOURCE_NAME_TOKENS = {"postgres", "postgresql", "mysql", "mariadb", "mongo", "mongodb", "redis", "kafka"} |
100 | 104 | OFFICIAL_HEALTH_HTTP_EXPECTATIONS: Dict[str, Dict[str, str]] = { |
101 | 105 | "goauthentik/server": { |
102 | 106 | "liveness_path": "/-/health/live/", |
@@ -1509,14 +1513,88 @@ def _is_database_image(image: str) -> bool: |
1509 | 1513 | return _image_repository_basename(image) in DATABASE_WORKLOAD_IMAGE_NAMES |
1510 | 1514 |
|
1511 | 1515 |
|
| 1516 | +def _normalize_database_token(value: Any) -> str: |
| 1517 | + return re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-") |
| 1518 | + |
| 1519 | + |
| 1520 | +def _iter_mapping_values(value: Any) -> Iterable[str]: |
| 1521 | + if isinstance(value, dict): |
| 1522 | + for key, item in value.items(): |
| 1523 | + if isinstance(key, str): |
| 1524 | + yield key |
| 1525 | + yield from _iter_mapping_values(item) |
| 1526 | + elif isinstance(value, list): |
| 1527 | + for item in value: |
| 1528 | + yield from _iter_mapping_values(item) |
| 1529 | + elif isinstance(value, str): |
| 1530 | + yield value |
| 1531 | + |
| 1532 | + |
| 1533 | +def _matches_database_resource_name(value: Any) -> bool: |
| 1534 | + return _normalize_database_token(value) in DATABASE_RESOURCE_NAME_TOKENS |
| 1535 | + |
| 1536 | + |
| 1537 | +def _contains_any_database_token(value: Any, tokens: Set[str]) -> bool: |
| 1538 | + normalized = _normalize_database_token(value) |
| 1539 | + if not normalized: |
| 1540 | + return False |
| 1541 | + return bool(set(normalized.split("-")) & tokens) |
| 1542 | + |
| 1543 | + |
| 1544 | +def _is_database_client_job(doc) -> bool: |
| 1545 | + if not isinstance(doc.data, dict) or doc.data.get("kind") not in {"Job", "CronJob"}: |
| 1546 | + return False |
| 1547 | + |
| 1548 | + metadata = doc.data.get("metadata") |
| 1549 | + names: List[Any] = [] |
| 1550 | + if isinstance(metadata, dict): |
| 1551 | + names.append(metadata.get("name")) |
| 1552 | + for container in iter_containers(doc.data): |
| 1553 | + names.append(container.get("name")) |
| 1554 | + |
| 1555 | + return any(_contains_any_database_token(name, DATABASE_CLIENT_JOB_TOKENS) for name in names) |
| 1556 | + |
| 1557 | + |
1512 | 1558 | def _is_database_like_workload(doc) -> bool: |
1513 | | - if not is_app_workload_document(doc) or not isinstance(doc.data, dict): |
| 1559 | + if not isinstance(doc.data, dict): |
| 1560 | + return False |
| 1561 | + if doc.data.get("kind") not in DATABASE_RAW_WORKLOAD_KINDS: |
| 1562 | + return False |
| 1563 | + if _is_database_client_job(doc): |
1514 | 1564 | return False |
1515 | 1565 |
|
1516 | 1566 | for container in iter_containers(doc.data): |
1517 | 1567 | image = container.get("image") |
1518 | 1568 | if isinstance(image, str) and _is_database_image(image): |
1519 | 1569 | return True |
| 1570 | + if _matches_database_resource_name(container.get("name")): |
| 1571 | + return True |
| 1572 | + |
| 1573 | + return False |
| 1574 | + |
| 1575 | + |
| 1576 | +def _is_database_like_service(doc) -> bool: |
| 1577 | + if not isinstance(doc.data, dict) or doc.data.get("kind") != "Service": |
| 1578 | + return False |
| 1579 | + |
| 1580 | + metadata = doc.data.get("metadata") |
| 1581 | + if isinstance(metadata, dict): |
| 1582 | + for value in _iter_mapping_values( |
| 1583 | + { |
| 1584 | + "name": metadata.get("name"), |
| 1585 | + "labels": metadata.get("labels"), |
| 1586 | + } |
| 1587 | + ): |
| 1588 | + if _matches_database_resource_name(value): |
| 1589 | + return True |
| 1590 | + |
| 1591 | + spec = doc.data.get("spec") |
| 1592 | + if isinstance(spec, dict): |
| 1593 | + selector = spec.get("selector") |
| 1594 | + if isinstance(selector, dict): |
| 1595 | + for value in _iter_mapping_values(selector): |
| 1596 | + if _matches_database_resource_name(value): |
| 1597 | + return True |
1520 | 1598 |
|
1521 | 1599 | return False |
1522 | 1600 |
|
@@ -1610,18 +1688,21 @@ def check_database_services_use_clusters(context: ScanContext) -> List[Violation |
1610 | 1688 | continue |
1611 | 1689 | if not isinstance(doc.data, dict): |
1612 | 1690 | continue |
1613 | | - if doc.data.get("kind") not in {"Deployment", "StatefulSet"}: |
| 1691 | + kind = doc.data.get("kind") |
| 1692 | + if kind not in DATABASE_RAW_RESOURCE_KINDS: |
1614 | 1693 | continue |
1615 | | - if not _is_database_like_workload(doc): |
| 1694 | + |
| 1695 | + is_database_resource = _is_database_like_service(doc) if kind == "Service" else _is_database_like_workload(doc) |
| 1696 | + if not is_database_resource: |
1616 | 1697 | continue |
1617 | 1698 |
|
1618 | 1699 | add_doc_violation( |
1619 | 1700 | violations, |
1620 | 1701 | rule_id="R039", |
1621 | 1702 | doc=doc, |
1622 | | - pattern=r"^\s*kind\s*:\s*(?:Deployment|StatefulSet)\s*$", |
| 1703 | + pattern=r"^\s*kind\s*:\s*(?:Deployment|StatefulSet|DaemonSet|Job|CronJob|Service)\s*$", |
1623 | 1704 | default_pattern=r"^\s*kind\s*:", |
1624 | | - message="database services must use KubeBlocks Cluster resources, not raw Deployment/StatefulSet workloads", |
| 1705 | + message="database services require KubeBlocks Cluster resources; raw Kubernetes resources are invalid", |
1625 | 1706 | ) |
1626 | 1707 |
|
1627 | 1708 | return violations |
|
0 commit comments