Skip to content

Commit ce1a67e

Browse files
fix(docker-to-sealos): enforce KubeBlocks database workloads (#47)
1 parent 8225058 commit ce1a67e

10 files changed

Lines changed: 408 additions & 7 deletions

File tree

skills/docker-to-sealos/references/database-templates.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,8 @@ Redis default account secrets usually contain:
614614
- `username`
615615
- `password`
616616

617+
Redis and MongoDB account secrets can appear after the first component pods report progress. During deployment validation, wait for the KubeBlocks Cluster to reach `Running`/Ready and then poll for the expected account Secret before judging application initialization. For Redis, the Sentinel component may become ready before the primary `redis` component and `${{ defaults.app_name }}-redis-redis-account-default`; validate the final Redis Service FQDN `${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local` plus business registration/login behavior. For MongoDB, poll `${{ defaults.app_name }}-mongo-mongodb-account-root` or the matching `${{ defaults.app_name }}-mongodb-mongodb-account-root` name when the Cluster uses the `mongodb` suffix.
618+
617619
### Environment Variable Configuration Examples
618620

619621
```yaml

skills/docker-to-sealos/references/rules-registry.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ rules:
7777
description: Database cluster component resources must use limits cpu=500m/memory=512Mi and requests cpu=50m/memory=51Mi.
7878
severity: error
7979
- id: R039
80-
description: Database services must use KubeBlocks Cluster resources instead of raw Deployment or StatefulSet workloads.
80+
description: Database services must use KubeBlocks Cluster resources instead of raw Kubernetes resources.
8181
severity: error
8282
- id: R038
8383
description: Managed workload container resources must use allowed Sealos limits ladder values and derive requests from limits.

skills/docker-to-sealos/scripts/check_consistency_rules_app.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@
9797
"timescaledb",
9898
"valkey",
9999
}
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"}
100104
OFFICIAL_HEALTH_HTTP_EXPECTATIONS: Dict[str, Dict[str, str]] = {
101105
"goauthentik/server": {
102106
"liveness_path": "/-/health/live/",
@@ -1509,14 +1513,88 @@ def _is_database_image(image: str) -> bool:
15091513
return _image_repository_basename(image) in DATABASE_WORKLOAD_IMAGE_NAMES
15101514

15111515

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+
15121558
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):
15141564
return False
15151565

15161566
for container in iter_containers(doc.data):
15171567
image = container.get("image")
15181568
if isinstance(image, str) and _is_database_image(image):
15191569
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
15201598

15211599
return False
15221600

@@ -1610,18 +1688,21 @@ def check_database_services_use_clusters(context: ScanContext) -> List[Violation
16101688
continue
16111689
if not isinstance(doc.data, dict):
16121690
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:
16141693
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:
16161697
continue
16171698

16181699
add_doc_violation(
16191700
violations,
16201701
rule_id="R039",
16211702
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*$",
16231704
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",
16251706
)
16261707

16271708
return violations

skills/docker-to-sealos/scripts/check_must_coverage.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ def resolve_path(value: str, base: Path) -> Path:
144144
path = Path(value)
145145
if path.is_absolute():
146146
return path
147+
cwd_path = path.resolve()
148+
if cwd_path.exists():
149+
return cwd_path
147150
return (base / path).resolve()
148151

149152

skills/docker-to-sealos/scripts/test_check_consistency.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3877,6 +3877,93 @@ def test_detects_raw_database_statefulset_in_artifact(self):
38773877
)
38783878
self.assertTrue(any(item.rule_id == "R039" for item in violations))
38793879

3880+
def test_detects_raw_database_resources_across_supported_kinds(self):
3881+
with tempfile.TemporaryDirectory() as temp_dir:
3882+
root = Path(temp_dir)
3883+
skill = root / "SKILL.md"
3884+
refs_dir = root / "references"
3885+
refs_file = refs_dir / "sample.md"
3886+
rules_file = refs_dir / "rules-registry.yaml"
3887+
artifact_file = root / "template" / "demo" / "index.yaml"
3888+
3889+
write_file(skill, "# no yaml snippets\n")
3890+
write_file(refs_file, "# refs\n")
3891+
write_registry(rules_file)
3892+
write_file(
3893+
artifact_file,
3894+
"""
3895+
apiVersion: apps/v1
3896+
kind: StatefulSet
3897+
metadata:
3898+
name: redis
3899+
labels:
3900+
app: redis
3901+
spec:
3902+
template:
3903+
spec:
3904+
containers:
3905+
- name: redis
3906+
image: redis:7.2.7
3907+
imagePullPolicy: IfNotPresent
3908+
---
3909+
apiVersion: apps/v1
3910+
kind: Deployment
3911+
metadata:
3912+
name: postgres
3913+
spec:
3914+
template:
3915+
spec:
3916+
containers:
3917+
- name: postgres
3918+
image: postgres:16.4
3919+
imagePullPolicy: IfNotPresent
3920+
---
3921+
apiVersion: apps/v1
3922+
kind: DaemonSet
3923+
metadata:
3924+
name: mysql
3925+
spec:
3926+
template:
3927+
spec:
3928+
containers:
3929+
- name: mysql
3930+
image: mysql:8.0.35
3931+
imagePullPolicy: IfNotPresent
3932+
---
3933+
apiVersion: batch/v1
3934+
kind: Job
3935+
metadata:
3936+
name: kafka
3937+
spec:
3938+
template:
3939+
spec:
3940+
containers:
3941+
- name: kafka
3942+
image: bitnami/kafka:3.3.2
3943+
imagePullPolicy: IfNotPresent
3944+
---
3945+
apiVersion: v1
3946+
kind: Service
3947+
metadata:
3948+
name: mongo
3949+
spec:
3950+
selector:
3951+
app: mongo
3952+
ports:
3953+
- name: tcp-27017
3954+
port: 27017
3955+
targetPort: 27017
3956+
""",
3957+
)
3958+
3959+
violations = CHECKER.run_checks(
3960+
skill,
3961+
refs_dir,
3962+
rules_file,
3963+
additional_include_paths=["template/demo/index.yaml"],
3964+
)
3965+
self.assertGreaterEqual(len([item for item in violations if item.rule_id == "R039"]), 5)
3966+
38803967
def test_allows_stateful_application_workload_in_artifact(self):
38813968
with tempfile.TemporaryDirectory() as temp_dir:
38823969
root = Path(temp_dir)
@@ -3973,6 +4060,127 @@ def test_allows_database_named_non_database_image_workload(self):
39734060
)
39744061
self.assertFalse(any(item.rule_id == "R039" for item in violations))
39754062

4063+
def test_allows_kubeblocks_redis_cluster_and_app_statefulset_dependency(self):
4064+
with tempfile.TemporaryDirectory() as temp_dir:
4065+
root = Path(temp_dir)
4066+
skill = root / "SKILL.md"
4067+
refs_dir = root / "references"
4068+
refs_file = refs_dir / "sample.md"
4069+
rules_file = refs_dir / "rules-registry.yaml"
4070+
artifact_file = root / "template" / "demo" / "index.yaml"
4071+
4072+
write_file(skill, "# no yaml snippets\n")
4073+
write_file(refs_file, "# refs\n")
4074+
write_registry(rules_file)
4075+
write_file(
4076+
artifact_file,
4077+
"""
4078+
apiVersion: apps.kubeblocks.io/v1alpha1
4079+
kind: Cluster
4080+
metadata:
4081+
name: demo-redis
4082+
labels:
4083+
kb.io/database: redis-7.2.7
4084+
sealos-db-provider-cr: demo-redis
4085+
clusterdefinition.kubeblocks.io/name: redis
4086+
spec:
4087+
componentSpecs:
4088+
- name: redis
4089+
resources:
4090+
requests:
4091+
cpu: 50m
4092+
memory: 51Mi
4093+
limits:
4094+
cpu: 500m
4095+
memory: 512Mi
4096+
- name: redis-sentinel
4097+
resources:
4098+
requests:
4099+
cpu: 50m
4100+
memory: 51Mi
4101+
limits:
4102+
cpu: 500m
4103+
memory: 512Mi
4104+
---
4105+
apiVersion: apps/v1
4106+
kind: StatefulSet
4107+
metadata:
4108+
name: demo-data
4109+
labels:
4110+
app: demo-data
4111+
cloud.sealos.io/app-deploy-manager: demo-data
4112+
spec:
4113+
revisionHistoryLimit: 1
4114+
template:
4115+
spec:
4116+
automountServiceAccountToken: false
4117+
containers:
4118+
- name: demo
4119+
image: ghcr.io/example/demo:1.0.0
4120+
imagePullPolicy: IfNotPresent
4121+
env:
4122+
- name: REDIS_HOST
4123+
value: demo-redis-redis-redis.default.svc.cluster.local
4124+
- name: REDIS_PASSWORD
4125+
valueFrom:
4126+
secretKeyRef:
4127+
name: demo-redis-redis-account-default
4128+
key: password
4129+
volumeClaimTemplates:
4130+
- metadata:
4131+
name: data
4132+
spec:
4133+
resources:
4134+
requests:
4135+
storage: 1Gi
4136+
""",
4137+
)
4138+
4139+
violations = CHECKER.run_checks(
4140+
skill,
4141+
refs_dir,
4142+
rules_file,
4143+
additional_include_paths=["template/demo/index.yaml"],
4144+
)
4145+
self.assertFalse(any(item.rule_id == "R039" for item in violations))
4146+
4147+
def test_allows_database_client_init_jobs(self):
4148+
with tempfile.TemporaryDirectory() as temp_dir:
4149+
root = Path(temp_dir)
4150+
skill = root / "SKILL.md"
4151+
refs_dir = root / "references"
4152+
refs_file = refs_dir / "sample.md"
4153+
rules_file = refs_dir / "rules-registry.yaml"
4154+
artifact_file = root / "template" / "demo" / "index.yaml"
4155+
4156+
write_file(skill, "# no yaml snippets\n")
4157+
write_file(refs_file, "# refs\n")
4158+
write_registry(rules_file)
4159+
write_file(
4160+
artifact_file,
4161+
"""
4162+
apiVersion: batch/v1
4163+
kind: Job
4164+
metadata:
4165+
name: demo-pg-init
4166+
spec:
4167+
template:
4168+
spec:
4169+
containers:
4170+
- name: pg-init
4171+
image: postgres:16-alpine
4172+
imagePullPolicy: IfNotPresent
4173+
""",
4174+
)
4175+
4176+
violations = CHECKER.run_checks(
4177+
skill,
4178+
refs_dir,
4179+
rules_file,
4180+
additional_include_paths=["template/demo/index.yaml"],
4181+
)
4182+
self.assertFalse(any(item.rule_id == "R039" for item in violations))
4183+
39764184
def test_detects_invalid_managed_workload_resource_ladder(self):
39774185
with tempfile.TemporaryDirectory() as temp_dir:
39784186
root = Path(temp_dir)

0 commit comments

Comments
 (0)