Skip to content

Commit b0530a1

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

16 files changed

+1158
-8
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ go.work
2525
*.swp
2626
*.swo
2727
*~
28+
hack/__pycache__/*
29+
hack/test/__pycache__/*

hack/__init__.py

Whitespace-only changes.

hack/ansible_test_runner.py

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

hack/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(bool(x) for x in [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

hack/release_candidates_repository.py

+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env python3
2+
3+
import logging
4+
import os
5+
import sys
6+
import uuid
7+
from datetime import datetime
8+
9+
from ruamel.yaml import YAML
10+
11+
script_dir = os.path.dirname(os.path.abspath(__file__))
12+
project_root = os.path.dirname(script_dir)
13+
if project_root not in sys.path:
14+
sys.path.insert(0, project_root)
15+
16+
from hack.shared_types import ReleaseCandidates, RepositoryInfo, SnapshotInfo
17+
18+
logging.basicConfig(
19+
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
20+
)
21+
logger = logging.getLogger("release-repository")
22+
23+
# Configure YAML parser
24+
yaml = YAML(typ="rt")
25+
yaml.default_flow_style = False
26+
yaml.explicit_start = False
27+
yaml.preserve_quotes = True
28+
yaml.width = 4096
29+
yaml.indent(mapping=2, sequence=2, offset=0)
30+
31+
32+
class ReleaseCandidate:
33+
34+
def __init__(
35+
self,
36+
components: list[RepositoryInfo],
37+
generated_at: str = None,
38+
status: str = "pending",
39+
tested_at: str = None,
40+
id: str = None,
41+
):
42+
self.metadata = {
43+
"generated_at": generated_at or datetime.now().isoformat(),
44+
"status": status,
45+
}
46+
47+
if tested_at:
48+
self.metadata["tested_at"] = tested_at
49+
50+
if id:
51+
self.metadata["id"] = id
52+
else:
53+
self.metadata["id"] = str(uuid.uuid4())
54+
55+
self.components = components
56+
57+
def to_dict(self) -> SnapshotInfo:
58+
return {"metadata": self.metadata, "components": self.components}
59+
60+
@classmethod
61+
def from_dict(cls, data: SnapshotInfo) -> "ReleaseCandidate":
62+
metadata = data.get("metadata", {})
63+
components = data.get("components", [])
64+
65+
return cls(
66+
components=components,
67+
generated_at=metadata.get("generated_at"),
68+
status=metadata.get("status", "pending"),
69+
tested_at=metadata.get("tested_at"),
70+
id=metadata.get("id"),
71+
)
72+
73+
def equals(self, other: "ReleaseCandidate") -> bool:
74+
set1 = {
75+
(d.get("repository", ""), d.get("ref", ""), d.get("image_url", ""))
76+
for d in self.components
77+
}
78+
set2 = {
79+
(d.get("repository", ""), d.get("ref", ""), d.get("image_url", ""))
80+
for d in other.components
81+
}
82+
return set1 == set2
83+
84+
85+
class ReleaseCandidatesRepository:
86+
87+
def __init__(self, file_path: str = "release-candidates.yaml"):
88+
self.file_path = file_path
89+
90+
def as_list(self) -> list[ReleaseCandidate]:
91+
data = self._read_file()
92+
snapshots = data.get("snapshots", [])
93+
94+
return [ReleaseCandidate.from_dict(snapshot) for snapshot in snapshots]
95+
96+
def find_by_status(self, status: str) -> list[ReleaseCandidate]:
97+
candidates = self.as_list()
98+
return [rc for rc in candidates if rc.metadata.get("status") == status]
99+
100+
def find_by_id(self, id: str) -> ReleaseCandidate | None:
101+
candidates = self.as_list()
102+
for candidate in candidates:
103+
if candidate.metadata.get("id") == id:
104+
return candidate
105+
return None
106+
107+
def save(self, candidate: ReleaseCandidate) -> bool:
108+
data = self._read_file()
109+
110+
candidate_dict = candidate.to_dict()
111+
112+
candidates = data.get("snapshots", [])
113+
for i, existing in enumerate(candidates):
114+
existing_id = existing.get("metadata", {}).get("id")
115+
if existing_id and existing_id == candidate.metadata.get("id"):
116+
candidates[i] = candidate_dict
117+
data["snapshots"] = candidates
118+
self._write_file(data)
119+
return True
120+
121+
if "snapshots" not in data:
122+
data["snapshots"] = []
123+
124+
new_rc = ReleaseCandidate.from_dict(candidate_dict)
125+
for existing in self.as_list():
126+
if new_rc.equals(existing):
127+
logger.info("Similar release candidate already exists, skipping")
128+
return False
129+
130+
data["snapshots"].insert(0, candidate_dict)
131+
self._write_file(data)
132+
return True
133+
134+
def update_status(self, id: str, status: str, tested_at: str = None) -> bool:
135+
candidate = self.find_by_id(id)
136+
if not candidate:
137+
return False
138+
139+
candidate.metadata["status"] = status
140+
if tested_at or status in ["successful", "failed"]:
141+
candidate.metadata["tested_at"] = tested_at or datetime.now().isoformat()
142+
143+
return self.save(candidate)
144+
145+
def _read_file(self) -> ReleaseCandidates:
146+
if not os.path.exists(self.file_path):
147+
return {"snapshots": []}
148+
149+
try:
150+
with open(self.file_path, "r") as f:
151+
data = yaml.load(f)
152+
return data or {"snapshots": []}
153+
except Exception as e:
154+
logger.error(f"Error reading {self.file_path}: {e}")
155+
return {"snapshots": []}
156+
157+
def _write_file(self, data: ReleaseCandidates) -> None:
158+
try:
159+
with open(self.file_path, "w") as f:
160+
yaml.dump(data, f)
161+
except Exception as e:
162+
logger.error(f"Error writing to {self.file_path}: {e}")
163+
raise

hack/shared_types.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
id: str
15+
16+
class SnapshotInfo(TypedDict):
17+
metadata: SnapshotMetadata
18+
components: list[RepositoryInfo]
19+
20+
class ReleaseCandidates(TypedDict):
21+
snapshots: list[SnapshotInfo]
22+
23+
class VersionEntry(TypedDict):
24+
name: str
25+
components: list[RepositoryInfo]
26+
27+
class VersionsFile(TypedDict):
28+
versions: list[VersionEntry]
29+
30+
class RepositoryConfig(TypedDict, total=False):
31+
images: list[str] | None
32+
version_prefix: str

0 commit comments

Comments
 (0)