Skip to content

Commit be305d6

Browse files
authored
Merge pull request #1125 from Otaiki1/feat/issues-1064-1067-validation-wave
Add validation wave: dead-code report, CRD lint, secret rotation chec…
2 parents a18e7b8 + c8b0c00 commit be305d6

8 files changed

Lines changed: 619 additions & 0 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Dead Code & Unused Config Report
2+
3+
# Periodic maintenance report (issue #1064). Informational only: the job
4+
# uploads a Markdown report as an artifact and never fails the build.
5+
6+
on:
7+
schedule:
8+
- cron: "0 6 * * 1" # Mondays 06:00 UTC
9+
workflow_dispatch: {}
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
report:
16+
name: Generate report
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 30
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Install Rust toolchain
23+
uses: dtolnay/rust-toolchain@stable
24+
25+
- name: Cache cargo
26+
uses: Swatinem/rust-cache@v2
27+
28+
- name: Generate dead-code and unused-config report
29+
run: |
30+
chmod +x scripts/dead-code-report.sh
31+
./scripts/dead-code-report.sh
32+
33+
- name: Upload report artifact
34+
uses: actions/upload-artifact@v4
35+
with:
36+
name: dead-code-report
37+
path: target/reports/dead-code-report.md
38+
retention-days: 30
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Quickstart Golden Path Validation
2+
3+
# Golden-path quickstart validation pipeline (issue #1067) with a
4+
# CRD backward-compatibility gate (issue #1065) and a smoke test of the
5+
# secret-rotation integration check (issue #1066).
6+
7+
on:
8+
pull_request:
9+
branches: [main]
10+
push:
11+
branches: [main]
12+
workflow_dispatch: {}
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
golden-path:
19+
name: Validate quickstart golden path
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 10
22+
steps:
23+
- uses: actions/checkout@v4
24+
25+
- name: Install PyYAML
26+
run: python3 -m pip install --user pyyaml
27+
28+
- name: Run golden-path validation
29+
run: |
30+
chmod +x scripts/quickstart-golden-path.sh
31+
./scripts/quickstart-golden-path.sh
32+
33+
- name: Smoke-test secret rotation check (dry run)
34+
run: |
35+
chmod +x scripts/secret-rotation-check.sh
36+
./scripts/secret-rotation-check.sh --dry-run
37+
38+
crd-migration-lint:
39+
name: CRD backward-compatibility lint
40+
runs-on: ubuntu-latest
41+
timeout-minutes: 10
42+
steps:
43+
- uses: actions/checkout@v4
44+
with:
45+
fetch-depth: 0
46+
47+
- name: Install PyYAML
48+
run: python3 -m pip install --user pyyaml
49+
50+
- name: Run linter unit tests
51+
run: python3 scripts/tests/test_crd_migration_lint.py
52+
53+
- name: Lint CRDs against origin/main
54+
run: python3 scripts/crd_migration_lint.py --against origin/main

docs/validation-pipelines.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Maintenance & Validation Pipelines
2+
3+
This page documents the maintenance and validation tooling added for
4+
issues #1064, #1065, #1066, and #1067.
5+
6+
## Periodic dead-code and unused-config report (#1064)
7+
8+
The [`dead-code-report.yml`](../.github/workflows/dead-code-report.yml)
9+
workflow runs every Monday (and on manual dispatch). It executes
10+
`scripts/dead-code-report.sh`, which collects:
11+
12+
- `rustc` dead-code diagnostics from `cargo check --all-targets`
13+
- top-level keys in `config/operator-config.yaml` that are never
14+
referenced from `src/`
15+
16+
The result is uploaded as the `dead-code-report` artifact. The job is
17+
informational and never fails the build.
18+
19+
**Verification**
20+
21+
```bash
22+
SKIP_CARGO=1 ./scripts/dead-code-report.sh
23+
cat target/reports/dead-code-report.md
24+
```
25+
26+
## CRD migration linter (#1065)
27+
28+
`scripts/crd_migration_lint.py` compares every manifest in `config/crd/`
29+
against a baseline git ref (default `origin/main`) and fails when it finds
30+
backward-incompatible evolution:
31+
32+
- a served API version was removed
33+
- a schema property was removed or changed type
34+
- an existing optional field became required
35+
36+
It runs in CI as the `crd-migration-lint` job of the quickstart validation
37+
workflow, on every pull request.
38+
39+
**Verification**
40+
41+
```bash
42+
python3 scripts/tests/test_crd_migration_lint.py
43+
python3 scripts/crd_migration_lint.py --against origin/main
44+
```
45+
46+
## Secret rotation integration check (#1066)
47+
48+
`scripts/secret-rotation-check.sh` rotates a secret (by annotating it with
49+
a rotation timestamp) and then polls the consuming deployment, failing if
50+
its available replicas ever drop below the pre-rotation baseline during
51+
the observation window.
52+
53+
**Verification**
54+
55+
```bash
56+
# No cluster required:
57+
./scripts/secret-rotation-check.sh --dry-run
58+
59+
# Against a real cluster:
60+
./scripts/secret-rotation-check.sh \
61+
--namespace stellar-system \
62+
--secret stellar-core-secret \
63+
--deployment stellar-k8s-operator \
64+
--window 60
65+
```
66+
67+
CI smoke-tests the dry-run mode on every pull request.
68+
69+
## Golden-path quickstart validation pipeline (#1067)
70+
71+
The [`quickstart-validation.yml`](../.github/workflows/quickstart-validation.yml)
72+
workflow guards the documented "golden path" for new users. Its
73+
`golden-path` job runs `scripts/quickstart-golden-path.sh`, which checks
74+
that:
75+
76+
- the quickstart entry points (`scripts/quickstart-verify.sh`, `README.md`,
77+
`Makefile`) exist
78+
- the quickstart shell scripts parse cleanly
79+
- every manifest under `config/crd/` and `config/samples/` is valid YAML
80+
- the README still documents a quickstart section
81+
82+
**Verification**
83+
84+
```bash
85+
./scripts/quickstart-golden-path.sh
86+
```

scripts/crd_migration_lint.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env python3
2+
"""Migration linter for backward-compatible CRD evolution (issue #1065).
3+
4+
Compares the CRD manifests in config/crd/ against a baseline git ref
5+
(default: origin/main) and reports changes that would break existing
6+
custom resources:
7+
8+
* a served API version was removed
9+
* a schema property was removed
10+
* a property changed its declared type
11+
* a previously optional field became required
12+
13+
Usage:
14+
scripts/crd_migration_lint.py [--against REF] [--crd-dir DIR]
15+
16+
Exit codes: 0 = no breaking changes, 1 = breaking changes found.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import argparse
22+
import subprocess
23+
import sys
24+
from pathlib import Path
25+
26+
import yaml
27+
28+
29+
def _schema_of(version: dict) -> dict:
30+
return (version.get("schema") or {}).get("openAPIV3Schema") or {}
31+
32+
33+
def _walk_properties(schema: dict, prefix: str = "") -> dict:
34+
"""Flatten an openAPIV3Schema into {dotted.path: type}."""
35+
out = {}
36+
for name, prop in (schema.get("properties") or {}).items():
37+
path = f"{prefix}.{name}" if prefix else name
38+
out[path] = prop.get("type", "object")
39+
if isinstance(prop, dict):
40+
out.update(_walk_properties(prop, path))
41+
items = prop.get("items")
42+
if isinstance(items, dict):
43+
out.update(_walk_properties(items, f"{path}[]"))
44+
return out
45+
46+
47+
def _required_paths(schema: dict, prefix: str = "") -> set:
48+
out = set()
49+
for name in schema.get("required") or []:
50+
out.add(f"{prefix}.{name}" if prefix else name)
51+
for name, prop in (schema.get("properties") or {}).items():
52+
if isinstance(prop, dict):
53+
path = f"{prefix}.{name}" if prefix else name
54+
out.update(_required_paths(prop, path))
55+
return out
56+
57+
58+
def compare_crds(old: dict, new: dict) -> list:
59+
"""Return a list of human-readable breaking changes between two CRDs."""
60+
problems = []
61+
name = new.get("metadata", {}).get("name", "<unknown>")
62+
old_versions = {v["name"]: v for v in old.get("spec", {}).get("versions", [])}
63+
new_versions = {v["name"]: v for v in new.get("spec", {}).get("versions", [])}
64+
65+
for ver_name, old_ver in old_versions.items():
66+
if old_ver.get("served") and ver_name not in new_versions:
67+
problems.append(f"{name}: served version '{ver_name}' was removed")
68+
continue
69+
if ver_name not in new_versions:
70+
continue
71+
72+
old_schema = _schema_of(old_ver)
73+
new_schema = _schema_of(new_versions[ver_name])
74+
old_props = _walk_properties(old_schema)
75+
new_props = _walk_properties(new_schema)
76+
77+
for path, old_type in old_props.items():
78+
if path not in new_props:
79+
problems.append(
80+
f"{name}/{ver_name}: property '{path}' was removed"
81+
)
82+
elif new_props[path] != old_type:
83+
problems.append(
84+
f"{name}/{ver_name}: property '{path}' changed type "
85+
f"'{old_type}' -> '{new_props[path]}'"
86+
)
87+
88+
newly_required = _required_paths(new_schema) - _required_paths(old_schema)
89+
for path in sorted(newly_required):
90+
if path in old_props:
91+
problems.append(
92+
f"{name}/{ver_name}: existing field '{path}' became required"
93+
)
94+
return problems
95+
96+
97+
def _first_crd_doc(text: str):
98+
"""Return the first CustomResourceDefinition document in a YAML stream."""
99+
for doc in yaml.safe_load_all(text):
100+
if isinstance(doc, dict) and doc.get("kind") == "CustomResourceDefinition":
101+
return doc
102+
return None
103+
104+
105+
def _load_at_ref(ref: str, rel_path: str, repo_root: Path):
106+
proc = subprocess.run(
107+
["git", "show", f"{ref}:{rel_path}"],
108+
capture_output=True,
109+
text=True,
110+
cwd=repo_root,
111+
)
112+
if proc.returncode != 0:
113+
return None # file did not exist at the baseline ref -> new CRD, skip
114+
return _first_crd_doc(proc.stdout)
115+
116+
117+
def main() -> int:
118+
parser = argparse.ArgumentParser(description=__doc__)
119+
parser.add_argument("--against", default="origin/main", help="baseline git ref")
120+
parser.add_argument("--crd-dir", default="config/crd", help="CRD manifest directory")
121+
args = parser.parse_args()
122+
123+
repo_root = Path(__file__).resolve().parent.parent
124+
crd_dir = repo_root / args.crd_dir
125+
if not crd_dir.is_dir():
126+
print(f"error: CRD directory not found: {crd_dir}", file=sys.stderr)
127+
return 1
128+
129+
all_problems = []
130+
for crd_file in sorted(crd_dir.glob("*.yaml")):
131+
rel = crd_file.relative_to(repo_root).as_posix()
132+
old = _load_at_ref(args.against, rel, repo_root)
133+
if old is None:
134+
print(f"skip (new or non-CRD file at baseline): {rel}")
135+
continue
136+
new = _first_crd_doc(crd_file.read_text())
137+
if new is None:
138+
all_problems.append(f"{rel}: CRD document was removed from the file")
139+
print(f"FAIL: {rel}")
140+
continue
141+
problems = compare_crds(old, new)
142+
all_problems.extend(problems)
143+
status = "FAIL" if problems else "ok"
144+
print(f"{status}: {rel}")
145+
146+
if all_problems:
147+
print("\nBackward-incompatible CRD changes detected:", file=sys.stderr)
148+
for p in all_problems:
149+
print(f" - {p}", file=sys.stderr)
150+
return 1
151+
print("\nAll CRDs are backward compatible with", args.against)
152+
return 0
153+
154+
155+
if __name__ == "__main__":
156+
sys.exit(main())

0 commit comments

Comments
 (0)