Skip to content

Commit a6034a0

Browse files
committed
chore: create script to submit AI conformance results to cncf/k8s-ai-conformance
1 parent 2e9acbc commit a6034a0

1 file changed

Lines changed: 318 additions & 0 deletions

File tree

dev/tasks/submit-ai-conformance

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env python3
2+
3+
"""Submit kOps AI conformance results to cncf/k8s-ai-conformance.
4+
5+
Usage:
6+
dev/tasks/submit-ai-conformance [--submit] <artifacts-url>
7+
8+
By default, runs in dry-run mode. Pass --submit to actually create the PR.
9+
10+
Examples:
11+
dev/tasks/submit-ai-conformance https://gcsweb.k8s.io/gcs/kubernetes-ci-logs/logs/e2e-kops-ai-conformance/2034963660111089664/artifacts/
12+
dev/tasks/submit-ai-conformance --submit https://gcsweb.k8s.io/gcs/kubernetes-ci-logs/logs/e2e-kops-ai-conformance/2034963660111089664/artifacts/
13+
"""
14+
15+
import os
16+
import re
17+
import shutil
18+
import subprocess
19+
import sys
20+
import tempfile
21+
import urllib.request
22+
23+
import yaml
24+
25+
26+
JOB_NAME = "e2e-kops-ai-conformance"
27+
GCS_BUCKET = "kubernetes-ci-logs"
28+
CONFORMANCE_REPO = "cncf/k8s-ai-conformance"
29+
KOPS_DIR_NAME = "kops"
30+
31+
32+
def run(cmd, **kwargs):
33+
"""Run a command, printing it first."""
34+
print(f"+ {' '.join(cmd)}")
35+
return subprocess.run(cmd, check=True, **kwargs)
36+
37+
38+
def capture(cmd, **kwargs):
39+
"""Run a command and return its stdout."""
40+
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs).stdout.strip()
41+
42+
43+
def gsutil_cp(src, dst):
44+
run(["gsutil", "-m", "cp", "-r", src, dst])
45+
46+
47+
def parse_build_id(input_str):
48+
"""Extract the build ID from a URL or raw ID."""
49+
if re.fullmatch(r"\d+", input_str):
50+
return input_str
51+
m = re.search(rf"logs/{JOB_NAME}/(\d+)", input_str)
52+
if m:
53+
return m.group(1)
54+
print(f"ERROR: Cannot parse build ID from: {input_str}", file=sys.stderr)
55+
print("Provide either a build ID (e.g. 2034963660111089664) or a GCS URL.", file=sys.stderr)
56+
sys.exit(1)
57+
58+
59+
def download_artifacts(build_id, tmpdir):
60+
"""Download ai-conformance.yaml and test evidence from GCS."""
61+
gcs_prefix = f"gs://{GCS_BUCKET}/logs/{JOB_NAME}/{build_id}/artifacts"
62+
print(f"Downloading artifacts from {gcs_prefix}...")
63+
gsutil_cp(f"{gcs_prefix}/ai-conformance.yaml", f"{tmpdir}/ai-conformance.yaml")
64+
65+
# List test evidence files and download only the .md ones.
66+
listing = capture(["gsutil", "ls", "-r", f"{gcs_prefix}/tests/"])
67+
for line in listing.splitlines():
68+
line = line.strip()
69+
if not line.endswith("/output.md"):
70+
continue
71+
# e.g. gs://.../artifacts/tests/TestFoo/output.md -> tests/TestFoo/output.md
72+
rel = line.split("/artifacts/", 1)[1]
73+
dest = os.path.join(tmpdir, rel)
74+
os.makedirs(os.path.dirname(dest), exist_ok=True)
75+
gsutil_cp(line, dest)
76+
77+
78+
def download_template(kube_minor, tmpdir):
79+
"""Download the official conformance template for this k8s version."""
80+
url = f"https://raw.githubusercontent.com/{CONFORMANCE_REPO}/main/docs/AIConformance-{kube_minor}.yaml"
81+
print(f"Downloading conformance template from {url}...")
82+
dest = os.path.join(tmpdir, "template.yaml")
83+
urllib.request.urlretrieve(url, dest)
84+
return dest
85+
86+
87+
def load_yaml(path):
88+
with open(path) as f:
89+
return yaml.safe_load(f)
90+
91+
92+
def build_product_yaml(template, results):
93+
"""Merge our test results into the conformance template."""
94+
# Build a lookup of our results by (category, id).
95+
results_lookup = {}
96+
for category, items in results.get("spec", {}).items():
97+
for item in items:
98+
results_lookup[(category, item["id"])] = item
99+
100+
# Merge metadata: start with template, overlay our results.
101+
metadata = template["metadata"].copy()
102+
metadata.update(results["metadata"])
103+
104+
# Fill in defaults for kOps.
105+
if not metadata.get("contactEmailAddress") or metadata["contactEmailAddress"].startswith("["):
106+
metadata["contactEmailAddress"] = "sig-cluster-lifecycle@kubernetes.io"
107+
if not metadata.get("k8sConformanceUrl") or metadata["k8sConformanceUrl"].startswith("["):
108+
kube_minor = metadata["kubernetesVersion"].lstrip("v").rsplit(".", 1)[0]
109+
metadata["k8sConformanceUrl"] = f"https://github.com/cncf/k8s-conformance/tree/master/v{kube_minor}/kops"
110+
111+
# Build merged spec: template structure with our results filled in.
112+
spec = {}
113+
for category, template_items in template.get("spec", {}).items():
114+
spec[category] = []
115+
for tmpl_item in template_items:
116+
merged = {
117+
"id": tmpl_item["id"],
118+
"description": tmpl_item["description"],
119+
"level": tmpl_item["level"],
120+
}
121+
122+
result = results_lookup.get((category, tmpl_item["id"]))
123+
if result:
124+
merged["status"] = result.get("status", "")
125+
# Convert evidence paths: prefer .md over .html for GitHub rendering.
126+
evidence = []
127+
for e in result.get("evidence", []):
128+
if e.startswith("tests/"):
129+
evidence.append(e.replace("/output.html", "/output.md"))
130+
else:
131+
evidence.append(e)
132+
merged["evidence"] = evidence
133+
merged["notes"] = result.get("notes", "")
134+
else:
135+
# Not in our results.
136+
if tmpl_item["level"] == "SHOULD":
137+
merged["status"] = "N/A"
138+
merged["evidence"] = []
139+
merged["notes"] = "Not applicable for kOps at this time."
140+
else:
141+
merged["status"] = ""
142+
merged["evidence"] = []
143+
merged["notes"] = ""
144+
145+
spec[category].append(merged)
146+
147+
return {"metadata": metadata, "spec": spec}
148+
149+
150+
def write_product_yaml(data, path):
151+
"""Write PRODUCT.yaml with the standard header."""
152+
class Dumper(yaml.Dumper):
153+
pass
154+
155+
def str_representer(dumper, s):
156+
if "\n" in s:
157+
return dumper.represent_scalar("tag:yaml.org,2002:str", s, style="|")
158+
return dumper.represent_scalar("tag:yaml.org,2002:str", s)
159+
160+
Dumper.add_representer(str, str_representer)
161+
162+
header = (
163+
"# Kubernetes AI Conformance Checklist\n"
164+
"# Notes: This checklist is based on the Kubernetes AI Conformance document.\n"
165+
"# Participants should fill in the 'status', 'evidence', and 'notes' fields for each requirement.\n\n"
166+
)
167+
with open(path, "w") as f:
168+
f.write(header)
169+
yaml.dump(data, f, Dumper=Dumper, default_flow_style=False, sort_keys=False, width=200)
170+
171+
print(f"Wrote {path}")
172+
173+
174+
def copy_evidence(tmpdir, submit_dir):
175+
"""Copy .md evidence files into the submission directory."""
176+
tests_src = os.path.join(tmpdir, "tests")
177+
if not os.path.isdir(tests_src):
178+
return
179+
for root, _dirs, files in os.walk(tests_src):
180+
for fname in files:
181+
if fname == "output.md":
182+
src = os.path.join(root, fname)
183+
rel = os.path.relpath(src, tmpdir) # e.g. tests/TestFoo/output.md
184+
dst = os.path.join(submit_dir, rel)
185+
os.makedirs(os.path.dirname(dst), exist_ok=True)
186+
shutil.copy2(src, dst)
187+
188+
189+
def create_pr(tmpdir, submit_dir, kube_minor, kube_version, platform_version, build_id):
190+
"""Clone the conformance repo, commit the submission, and open a PR."""
191+
github_user = os.environ.get("GITHUB_USER", os.environ.get("USER", ""))
192+
if not github_user:
193+
print("ERROR: Set GITHUB_USER or USER environment variable.", file=sys.stderr)
194+
sys.exit(1)
195+
196+
clone_dir = os.path.join(tmpdir, "k8s-ai-conformance")
197+
198+
# Ensure we have a fork.
199+
try:
200+
capture(["gh", "repo", "view", f"{github_user}/k8s-ai-conformance"])
201+
except subprocess.CalledProcessError:
202+
print(f"Forking {CONFORMANCE_REPO}...")
203+
run(["gh", "repo", "fork", CONFORMANCE_REPO, "--clone=false"])
204+
205+
run(["gh", "repo", "clone", f"{github_user}/k8s-ai-conformance", clone_dir, "--", "--depth=1"])
206+
207+
branch = f"kops-v{kube_minor}"
208+
run(["git", "remote", "add", "upstream", f"https://github.com/{CONFORMANCE_REPO}.git"], cwd=clone_dir)
209+
run(["git", "fetch", "upstream", "main", "--depth=1"], cwd=clone_dir)
210+
run(["git", "checkout", "-b", branch, "upstream/main"], cwd=clone_dir)
211+
212+
# Copy submission into the clone.
213+
dest = os.path.join(clone_dir, f"v{kube_minor}", KOPS_DIR_NAME)
214+
if os.path.exists(dest):
215+
shutil.rmtree(dest)
216+
shutil.copytree(submit_dir, dest)
217+
218+
# Commit.
219+
run(["git", "add", f"v{kube_minor}/{KOPS_DIR_NAME}/"], cwd=clone_dir)
220+
commit_msg = (
221+
f"Add kOps AI Conformance results for v{kube_minor}\n\n"
222+
f"kOps version: {platform_version}\n"
223+
f"Kubernetes version: {kube_version}\n"
224+
f"Build: https://prow.k8s.io/view/gs/{GCS_BUCKET}/logs/{JOB_NAME}/{build_id}\n"
225+
)
226+
run(["git", "commit", "-m", commit_msg], cwd=clone_dir)
227+
228+
# Push.
229+
print(f"\nPushing to {github_user}/k8s-ai-conformance...")
230+
run(["git", "push", "-u", "origin", branch, "--force-with-lease"], cwd=clone_dir)
231+
232+
# Create PR.
233+
print("\nCreating pull request...")
234+
pr_body = (
235+
f"## Conformance results for kOps v{kube_minor}\n\n"
236+
f"- **Platform**: kOps\n"
237+
f"- **Platform Version**: {platform_version}\n"
238+
f"- **Kubernetes Version**: {kube_version}\n"
239+
f"- **Vendor**: kOps Project\n\n"
240+
f"### Evidence\n\n"
241+
f"Test evidence is included directly in this PR as markdown files under "
242+
f"`v{kube_minor}/{KOPS_DIR_NAME}/tests/`.\n\n"
243+
f"The tests were run automatically by the "
244+
f"[e2e-kops-ai-conformance](https://prow.k8s.io/view/gs/{GCS_BUCKET}/logs/{JOB_NAME}/{build_id}) Prow job.\n\n"
245+
f"Full artifacts: https://gcsweb.k8s.io/gcs/{GCS_BUCKET}/logs/{JOB_NAME}/{build_id}/artifacts/\n"
246+
)
247+
pr_url = capture([
248+
"gh", "pr", "create",
249+
"--repo", CONFORMANCE_REPO,
250+
"--title", f"Add kOps AI Conformance results for v{kube_minor}",
251+
"--body", pr_body,
252+
], cwd=clone_dir)
253+
254+
print(f"\nPull request created: {pr_url}")
255+
256+
257+
def main():
258+
args = sys.argv[1:]
259+
dry_run = True
260+
if "--submit" in args:
261+
dry_run = False
262+
args.remove("--submit")
263+
264+
if len(args) < 1:
265+
print(__doc__, file=sys.stderr)
266+
sys.exit(1)
267+
268+
build_id = parse_build_id(args[0])
269+
print(f"Build ID: {build_id}")
270+
if dry_run:
271+
print("DRY RUN: will build submission locally but not create a PR")
272+
273+
tmpdir = tempfile.mkdtemp()
274+
try:
275+
# Download artifacts.
276+
download_artifacts(build_id, tmpdir)
277+
278+
# Load our results.
279+
results = load_yaml(os.path.join(tmpdir, "ai-conformance.yaml"))
280+
kube_version = results["metadata"]["kubernetesVersion"]
281+
platform_version = results["metadata"]["platformVersion"]
282+
kube_minor = re.sub(r"^v", "", kube_version).rsplit(".", 1)[0]
283+
print(f"Kubernetes version: {kube_version} (minor: {kube_minor})")
284+
print(f"Platform version: {platform_version}")
285+
286+
# Download and merge with template.
287+
template_path = download_template(kube_minor, tmpdir)
288+
template = load_yaml(template_path)
289+
product = build_product_yaml(template, results)
290+
291+
# Prepare submission directory.
292+
submit_dir = os.path.join(tmpdir, "submission")
293+
os.makedirs(submit_dir)
294+
write_product_yaml(product, os.path.join(submit_dir, "PRODUCT.yaml"))
295+
copy_evidence(tmpdir, submit_dir)
296+
297+
# Show what we're submitting.
298+
print("\nSubmission contents:")
299+
for root, _dirs, files in os.walk(submit_dir):
300+
for f in sorted(files):
301+
print(f" {os.path.relpath(os.path.join(root, f), submit_dir)}")
302+
303+
if dry_run:
304+
print(f"\nDry run output is in {submit_dir}")
305+
print("PRODUCT.yaml:")
306+
with open(os.path.join(submit_dir, "PRODUCT.yaml")) as f:
307+
print(f.read())
308+
return
309+
310+
# Create the PR.
311+
create_pr(tmpdir, submit_dir, kube_minor, kube_version, platform_version, build_id)
312+
finally:
313+
if not dry_run:
314+
shutil.rmtree(tmpdir)
315+
316+
317+
if __name__ == "__main__":
318+
main()

0 commit comments

Comments
 (0)