Skip to content

Commit 32c2545

Browse files
add artifact manager script for downloading toolchains
1 parent 906f215 commit 32c2545

File tree

1 file changed

+223
-0
lines changed

1 file changed

+223
-0
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Artifact management for Jenkins CI builds.
4+
Handles fetching artifacts with fallback patterns and state information triggering stage 1 builds.
5+
This is to be used by CI jobs.
6+
"""
7+
8+
import os
9+
import sys
10+
import subprocess
11+
import argparse
12+
import logging
13+
import shutil
14+
from pathlib import Path
15+
16+
# Set up logging
17+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class ArtifactManager:
22+
def __init__(self, workspace, s3_bucket):
23+
self.workspace = Path(workspace)
24+
self.s3_bucket = s3_bucket
25+
26+
def run_cmd(self, cwd, cmd):
27+
"""Run a command in the specified directory."""
28+
logger.info(f"Running command: {' '.join(cmd)}")
29+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
30+
if result.returncode != 0:
31+
logger.error(f"Command failed with return code {result.returncode}")
32+
logger.error(f"stderr: {result.stderr}")
33+
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
34+
return result
35+
36+
def get_git_info(self):
37+
"""Extract git distance and SHA from the llvm-project directory."""
38+
try:
39+
llvm_project_dir = self.workspace / "llvm-project"
40+
os.chdir(llvm_project_dir)
41+
42+
# Create first_commit tag if it doesn't exist
43+
subprocess.run([
44+
"git", "tag", "-a", "-m", "First Commit", "first_commit",
45+
"97724f18c79c7cc81ced24239eb5e883bf1398ef"
46+
], capture_output=True) # Ignore errors if tag exists
47+
48+
# Get git description
49+
result = subprocess.run([
50+
"git", "describe", "--match", "first_commit"
51+
], capture_output=True, text=True, check=True)
52+
53+
git_desc = result.stdout.strip()
54+
parts = git_desc.split("-")
55+
56+
git_distance = parts[1]
57+
git_sha = parts[2][1:] # Remove 'g' prefix
58+
59+
os.chdir(self.workspace)
60+
61+
logger.info(f"Git info: distance={git_distance}, sha={git_sha}")
62+
return git_distance, git_sha
63+
64+
except subprocess.CalledProcessError as e:
65+
logger.error(f"Failed to get git info: {e}")
66+
raise
67+
except Exception as e:
68+
logger.error(f"Unexpected error getting git info: {e}")
69+
raise
70+
71+
@staticmethod
72+
def construct_artifact_names(job_name, git_distance, git_sha):
73+
"""Construct mainline and bisection artifact names. In the case of
74+
a bisection job the artifact can come from either the mainline stage 1 job or the
75+
bisection version of the stage 1 job, so we return two artifact names to check."""
76+
base_name = f"clang-d{git_distance}-g{git_sha}.tar.gz"
77+
78+
# Define the bisection jobs suffix. All bisection jobs end in -bisect
79+
bisection_job_suffix = '-bisect'
80+
81+
# Remove bisection suffix from job name for primary artifact
82+
if job_name.endswith(bisection_job_suffix):
83+
mainline_job_name = job_name[:-len(bisection_job_suffix)]
84+
bisection_artifact = f"{job_name}/{base_name}"
85+
mainline_artifact = f"{mainline_job_name}/{base_name}"
86+
return mainline_artifact, bisection_artifact
87+
else:
88+
# Not a bisection job, so just return the mainline artifact name
89+
return f"{job_name}/{base_name}", None
90+
91+
def fetch_artifact(self, artifact_name):
92+
"""Attempt to fetch a specific artifact."""
93+
try:
94+
logger.info(f"Attempting to fetch artifact: {artifact_name}")
95+
96+
local_name = "host-compiler.tar.gz"
97+
98+
# Download the artifact from S3
99+
download_cmd = ["aws", "s3", "cp", f"{self.s3_bucket}/clangci/{artifact_name}", local_name]
100+
try:
101+
self.run_cmd(self.workspace, download_cmd)
102+
except subprocess.CalledProcessError:
103+
logger.warning(f"Failed to fetch artifact: {artifact_name}")
104+
return False
105+
106+
# Determine if the compiler package is actually a pointer to another file stored.
107+
# If so, download the file at the pointer
108+
if Path(local_name).stat().st_size < 1000:
109+
logger.info("Artifact is a pointer file, following to actual artifact...")
110+
with open(local_name, "r") as pointer:
111+
package = pointer.read().strip()
112+
download_cmd = ["aws", "s3", "cp", f"{self.s3_bucket}/clangci/{package}", local_name]
113+
self.run_cmd(self.workspace, download_cmd)
114+
115+
logger.info("Decompressing artifact...")
116+
host_compiler_dir = self.workspace / "host-compiler"
117+
118+
if host_compiler_dir.exists():
119+
shutil.rmtree(host_compiler_dir)
120+
121+
host_compiler_dir.mkdir()
122+
self.run_cmd(host_compiler_dir, ['tar', 'zxf', f"../{local_name}"])
123+
os.unlink(local_name)
124+
125+
logger.info(f"Successfully fetched and extracted artifact: {artifact_name}")
126+
return True
127+
128+
except Exception as e:
129+
logger.error(f"Error fetching artifact {artifact_name}: {e}")
130+
return False
131+
132+
def fetch_with_fallback(self, job_name, provided_artifact=None):
133+
"""
134+
Main method to fetch artifacts with fallback logic.
135+
136+
Args:
137+
job_name: Jenkins job name responsible for producing the artifact to download
138+
provided_artifact: Explicit artifact name (for non-bisection jobs)
139+
140+
Returns:
141+
tuple: (success, used_artifact, needs_stage1)
142+
"""
143+
# If explicit artifact is provided, use it directly. Non bisection jobs
144+
# will specify the artifact they want to use i.e. llvm.org/clang-stage1-RA/latest
145+
if provided_artifact:
146+
logger.info(f"Using provided artifact: {provided_artifact}")
147+
success = self.fetch_artifact(provided_artifact)
148+
if success:
149+
return True, provided_artifact, False
150+
else:
151+
logger.info("Provided artifact not found, stage 1 build needed")
152+
return False, provided_artifact, True
153+
else:
154+
# In the case that a job name was provided and not a specific artifact
155+
# Try to download the artifact using the associated job name to find the artifact.
156+
# If it's a bisection job, we can check both the mainline build and the bisection build
157+
git_distance, git_sha = self.get_git_info()
158+
mainline_artifact, bisection_artifact = ArtifactManager.construct_artifact_names(
159+
job_name, git_distance, git_sha
160+
)
161+
162+
# Try primary artifact first
163+
if self.fetch_artifact(mainline_artifact):
164+
return True, mainline_artifact, False
165+
166+
# No primary artifact found and we're not checking for a bisection job, so return
167+
if bisection_artifact is None:
168+
return False, mainline_artifact, True
169+
170+
# Try bisection artifact as fallback
171+
logger.info("Primary artifact not found, trying to find bisection job artifact...")
172+
if self.fetch_artifact(bisection_artifact):
173+
return True, bisection_artifact, False
174+
175+
# Neither found, we need stage 1 build
176+
logger.info("No artifacts found, stage 1 build needed")
177+
return False, mainline_artifact, True
178+
179+
180+
def main():
181+
parser = argparse.ArgumentParser(description='Download CI build artifacts')
182+
parser.add_argument('--workspace', default=os.getcwd(), help='Jenkins workspace directory')
183+
parser.add_argument('--s3-bucket', help='S3 bucket name (from environment if not provided)')
184+
parser.add_argument('--output-file', default='artifact_result.properties',
185+
help='File to write results to')
186+
187+
# Create a mutually exclusive group
188+
exclusive_group = parser.add_mutually_exclusive_group(required=True)
189+
exclusive_group.add_argument('--job-name', help='Jenkins job responsible for producing the artifact to download')
190+
exclusive_group.add_argument('--artifact', help='Specific artifact name to download')
191+
192+
args = parser.parse_args()
193+
194+
# Get S3 bucket from environment if not provided
195+
s3_bucket = args.s3_bucket or os.environ.get('S3_BUCKET')
196+
if not s3_bucket:
197+
logger.error("S3_BUCKET must be provided via --s3-bucket or S3_BUCKET environment variable")
198+
sys.exit(1)
199+
200+
try:
201+
manager = ArtifactManager(args.workspace, s3_bucket)
202+
success, used_artifact, needs_stage1 = manager.fetch_with_fallback(
203+
args.job_name, args.artifact
204+
)
205+
206+
# Write results to properties file for Jenkins to read
207+
with open(args.output_file, 'w') as f:
208+
f.write(f"ARTIFACT_FOUND={str(success).lower()}\n")
209+
f.write(f"USED_ARTIFACT={used_artifact}\n")
210+
f.write(f"NEEDS_STAGE1={str(needs_stage1).lower()}\n")
211+
212+
logger.info(f"Results written to {args.output_file}")
213+
214+
# Exit with appropriate code
215+
sys.exit(0 if success else 1)
216+
217+
except Exception as e:
218+
logger.error(f"Fatal error: {e}")
219+
sys.exit(2)
220+
221+
222+
if __name__ == "__main__":
223+
main()

0 commit comments

Comments
 (0)