Skip to content

Commit 97f843d

Browse files
MGMT-19400 - create tag management flow to tag assisted installer components according to capbcoa versions
1 parent 41e2533 commit 97f843d

11 files changed

+1002
-8
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ go.work
2525
*.swp
2626
*.swo
2727
*~
28+
scripts/__pycache__/*

scripts/ansible_test_runner.py

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import sys
5+
import subprocess
6+
import logging
7+
import yaml
8+
from datetime import datetime
9+
from shared_types import ReleaseCandidates, SnapshotInfo
10+
11+
logging.basicConfig(
12+
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
13+
)
14+
logger = logging.getLogger("test-runner")
15+
16+
RELEASE_CANDIDATES_FILE = "release-candidates.yaml"
17+
18+
19+
def read_release_candidates() -> ReleaseCandidates:
20+
try:
21+
with open(RELEASE_CANDIDATES_FILE, "r") as f:
22+
data = yaml.safe_load(f)
23+
if not data or "snapshots" not in data:
24+
logger.error(f"Invalid format in {RELEASE_CANDIDATES_FILE}")
25+
sys.exit(1)
26+
return data
27+
except Exception as e:
28+
logger.error(f"Error reading {RELEASE_CANDIDATES_FILE}: {e}")
29+
sys.exit(1)
30+
31+
32+
def find_pending_snapshot(data: ReleaseCandidates) -> tuple[str, SnapshotInfo] | None:
33+
for snapshot in data["snapshots"]:
34+
if "metadata" in snapshot and snapshot["metadata"].get("status") == "pending":
35+
return snapshot["metadata"].get("generated_at"), snapshot
36+
return None, None
37+
38+
def export_component_variables(snapshot: SnapshotInfo) -> None:
39+
env_vars = {}
40+
component_env_map = {
41+
"kubernetes-sigs/cluster-api": "CAPI_VERSION",
42+
"metal3-io/cluster-api-provider-metal3": "CAPM3_VERSION",
43+
"openshift/assisted-service": "ASSISTED_SERVICE_IMAGE",
44+
"openshift/assisted-image-service": "ASSISTED_IMAGE_SERVICE_IMAGE",
45+
"openshift/assisted-installer-agent": "ASSISTED_INSTALLER_AGENT_IMAGE",
46+
"openshift/assisted-installer-controller": "ASSISTED_INSTALLER_CONTROLLER_IMAGE",
47+
"openshift/assisted-installer": "ASSISTED_INSTALLER_IMAGE",
48+
}
49+
for component in snapshot["components"]:
50+
repo = component["repository"]
51+
env_var = component_env_map.get(repo)
52+
if not env_var:
53+
logger.warning(
54+
f"No environment variable mapping for component: {component}"
55+
)
56+
continue
57+
58+
if (
59+
repo == "kubernetes-sigs/cluster-api"
60+
or repo == "metal3-io/cluster-api-provider-metal3"
61+
):
62+
value = component["ref"]
63+
else:
64+
value = component["image_url"]
65+
66+
if value:
67+
env_vars[env_var] = value
68+
os.environ[env_var] = value
69+
logger.info(f"Exported {env_var}={value}")
70+
71+
72+
def run_ansible_tests() -> bool:
73+
try:
74+
logger.info("Running Ansible tests...")
75+
ansible_cmd = [
76+
"ansible-playbook",
77+
"test/ansible/run_test.yaml",
78+
"-i",
79+
"test/ansible/inventory.yaml",
80+
]
81+
result = subprocess.run(
82+
ansible_cmd, check=False, capture_output=False, text=True
83+
)
84+
85+
if result.returncode == 0:
86+
logger.info("Ansible tests passed successfully")
87+
return True
88+
else:
89+
logger.error(f"Ansible tests failed with return code {result.returncode}")
90+
return False
91+
92+
except Exception as e:
93+
logger.error(f"Error running Ansible tests: {e}")
94+
return False
95+
96+
def update_snapshot_status(timestamp: str, success: bool) -> None:
97+
try:
98+
data: ReleaseCandidates = read_release_candidates()
99+
100+
for snapshot in data["snapshots"]:
101+
if snapshot.get("metadata", {}).get("generated_at") == timestamp:
102+
if "metadata" not in snapshot:
103+
snapshot["metadata"] = {}
104+
105+
snapshot["metadata"]["status"] = "successful" if success else "failed"
106+
snapshot["metadata"]["tested_at"] = datetime.now().isoformat()
107+
108+
with open(RELEASE_CANDIDATES_FILE, "w") as f:
109+
yaml.dump(data, f, default_flow_style=False)
110+
111+
logger.info(f"Updated snapshot from {timestamp} status to {'successful' if success else 'failed'}")
112+
return
113+
114+
logger.error(f"Snapshot with timestamp {timestamp} not found")
115+
except Exception as e:
116+
logger.error(f"Error updating snapshot status: {e}")
117+
118+
119+
def main() -> None:
120+
try:
121+
logger.info("Starting test runner")
122+
data: ReleaseCandidates = read_release_candidates()
123+
124+
timestamp, snapshot = find_pending_snapshot(data)
125+
if snapshot is None:
126+
logger.info("No pending snapshots to process")
127+
return
128+
129+
logger.info(f"Processing pending snapshot from {timestamp}")
130+
export_component_variables(snapshot)
131+
132+
success = run_ansible_tests()
133+
update_snapshot_status(timestamp, success)
134+
135+
if success:
136+
logger.info("Snapshot processed successfully")
137+
else:
138+
logger.error("Snapshot testing failed")
139+
sys.exit(1)
140+
141+
except Exception as e:
142+
logger.error(f"Test runner failed: {e}")
143+
sys.exit(1)
144+
145+
146+
if __name__ == "__main__":
147+
main()

scripts/github_auth.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import logging
5+
from github import Github, GithubIntegration
6+
7+
logger = logging.getLogger("github-auth")
8+
9+
def get_github_client():
10+
"""
11+
Creates a PyGithub client using GitHub App authentication
12+
13+
Environment variables:
14+
GITHUB_APP_ID: The ID of your GitHub App
15+
GITHUB_APP_INSTALLATION_ID: The installation ID for your GitHub App
16+
GITHUB_APP_PRIVATE_KEY: Private key .pem file
17+
"""
18+
try:
19+
app_id = os.environ["GITHUB_APP_ID"]
20+
installation_id = os.environ["GITHUB_APP_INSTALLATION_ID"]
21+
private_key = os.environ["GITHUB_APP_PRIVATE_KEY"]
22+
23+
if not all([app_id, installation_id, private_key]):
24+
logger.error("Missing GitHub App credentials in environment")
25+
raise ValueError("""
26+
Missing GitHub App credentials.
27+
Ensure GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY are set.
28+
""")
29+
30+
integration = GithubIntegration(
31+
int(app_id),
32+
private_key
33+
)
34+
access_token = integration.get_access_token(int(installation_id))
35+
github_client = Github(access_token.token)
36+
logger.debug("Created GitHub client with new token")
37+
return github_client
38+
except Exception as e:
39+
logger.error(f"Error creating GitHub client: {e}")
40+
raise

scripts/shared_types.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
3+
from typing import TypedDict
4+
5+
class RepositoryInfo(TypedDict):
6+
repository: str
7+
ref: str
8+
image_url: str | None
9+
10+
class SnapshotMetadata(TypedDict):
11+
generated_at: str
12+
status: str
13+
tested_at: str
14+
15+
class SnapshotInfo(TypedDict):
16+
metadata: SnapshotMetadata
17+
components: list[RepositoryInfo]
18+
19+
class ReleaseCandidates(TypedDict):
20+
snapshots: list[SnapshotInfo]
21+
22+
class VersionEntry(TypedDict):
23+
name: str
24+
components: list[RepositoryInfo]
25+
26+
class VersionsFile(TypedDict):
27+
components: list[VersionEntry]
28+
29+
class RepositoryConfig(TypedDict, total=False):
30+
images: list[str] | None
31+
version_prefix: str

scripts/tag_reconciler.py

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
3+
import re
4+
import os
5+
import sys
6+
import logging
7+
from ruamel.yaml import YAML
8+
from github_auth import get_github_client
9+
from shared_types import RepositoryInfo, VersionEntry, VersionsFile
10+
11+
logging.basicConfig(
12+
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
13+
)
14+
logger = logging.getLogger("tag-reconciler")
15+
16+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
17+
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
18+
VERSIONS_FILE = os.path.join(PROJECT_ROOT, "versions.yaml")
19+
20+
yaml = YAML(typ="rt")
21+
yaml.default_flow_style = False
22+
yaml.explicit_start = False
23+
yaml.preserve_quotes = True
24+
yaml.width = 4096
25+
yaml.indent(mapping=2, sequence=2, offset=0)
26+
27+
28+
class TagReconciler:
29+
def __init__(self):
30+
self.github_client = get_github_client()
31+
32+
def read_versions_file(self) -> VersionsFile:
33+
if not os.path.exists(VERSIONS_FILE):
34+
logger.error(f"{VERSIONS_FILE} does not exist")
35+
sys.exit(1)
36+
37+
try:
38+
with open(VERSIONS_FILE, "r") as f:
39+
data = yaml.load(f)
40+
return data or {}
41+
except Exception as e:
42+
logger.error(f"Error reading {VERSIONS_FILE}: {e}")
43+
return {}
44+
45+
def tag_exists(self, repo: str, tag: str) -> bool:
46+
try:
47+
github_repo = self.github_client.get_repo(repo)
48+
github_repo.get_git_ref(f"tags/{tag}")
49+
return True
50+
except Exception as e:
51+
logger.error(f"Error checking tag {tag} for {repo}: {e}")
52+
return False
53+
54+
def create_tag(self, repo: str, commit_sha: str, tag: str) -> bool:
55+
message = f"Version {tag} - Tagged by CAPBCOA CI"
56+
57+
try:
58+
github_repo = self.github_client.get_repo(repo)
59+
tag_obj = github_repo.create_git_tag(
60+
tag=tag,
61+
message=message,
62+
object=commit_sha,
63+
type="commit"
64+
)
65+
66+
github_repo.create_git_ref(f"refs/tags/{tag}", tag_obj.sha)
67+
logger.info(f"Created tag {tag} for {repo}")
68+
return True
69+
except Exception as e:
70+
logger.error(f"Error creating tag {tag} for {repo}: {e}")
71+
return False
72+
73+
def extract_base_repo(self, full_repo_name: str) -> str:
74+
parts = full_repo_name.split("/")
75+
if len(parts) > 2:
76+
return f"{parts[0]}/{parts[1]}"
77+
return full_repo_name
78+
79+
def reconcile_tag(self, version: VersionEntry) -> bool:
80+
version_name: str = version["name"]
81+
if not version_name:
82+
logger.error("Version entry missing 'name' field")
83+
return False
84+
85+
logger.info(f"Processing version {version_name}")
86+
success = True
87+
88+
components = version["components"]
89+
if not components:
90+
logger.warning(f"No repositories found for version {version_name}")
91+
return True
92+
93+
for component in components:
94+
repo_name: str = component["repository"]
95+
ref: str = component["ref"]
96+
97+
if not re.match(r"^openshift/", repo_name) or not ref:
98+
logger.warning(f"Skipping repository {repo_name}")
99+
continue
100+
101+
base_repo = self.extract_base_repo(repo_name)
102+
if self.tag_exists(base_repo, version_name):
103+
logger.info(f"Tag {version_name} already exists for {base_repo}")
104+
continue
105+
if not self.create_tag(base_repo, ref, version_name):
106+
logger.error(f"Failed to create tag {version_name} for {base_repo}")
107+
success = False
108+
109+
return success
110+
111+
def reconcile_tags(self) -> bool:
112+
versions_data = self.read_versions_file()
113+
versions: VersionsFile = versions_data.get("versions", [])
114+
115+
if not versions:
116+
logger.info("No versions to process")
117+
return True
118+
119+
logger.info(f"Processing {len(versions)} versions")
120+
success = True
121+
122+
for version_entry in versions:
123+
if not self.reconcile_tag(version_entry):
124+
success = False
125+
126+
return success
127+
128+
129+
def main() -> None:
130+
try:
131+
logger.info("Starting tag reconciliation")
132+
reconciler = TagReconciler()
133+
success = reconciler.reconcile_tags()
134+
135+
if not success:
136+
logger.error("Tag reconciliation completed with errors")
137+
sys.exit(1)
138+
139+
except Exception as e:
140+
logger.error(f"Error in tag reconciliation: {e}")
141+
sys.exit(1)
142+
143+
144+
if __name__ == "__main__":
145+
main()

0 commit comments

Comments
 (0)