blob: 47aa084cc7ec1cf077bb396833f9fefa59144e21 [file] [log] [blame]
Fathi Boudra422bf772019-12-02 11:10:16 +02001#!/usr/bin/env python3
2#
3# Copyright (c) 2019, Arm Limited. All rights reserved.
4#
5# SPDX-License-Identifier: BSD-3-Clause
6#
7#
8# This script is used to walk a job tree, primarily to identify sub-jobs
9# triggered by a top-level job.
10#
11# The script works by scraping console output of jobs, starting from the
12# top-level one, sniffing for patterns indicative of sub-jobs, and following the
13# trail.
14
15import argparse
16import contextlib
17import re
18import sys
19import urllib.request
20
21# Sub-job patters. All of them capture job name (j) and build number (b).
22_SUBJOB_PATTERNS = (
23 # Usualy seen on freestyle jobs
24 re.compile(r"(?P<j>[-a-z_]+) #(?P<b>[0-9]+) completed. Result was (?P<s>[A-Z]+)",
25 re.IGNORECASE),
26
27 # Usualy seen on multi-phase jobs
28 re.compile(r"Finished Build : #(?P<b>[0-9]+) of Job : (?P<j>[-a-z_]+) with status : (?P<s>[A-Z]+)",
29 re.IGNORECASE)
30)
31
32
33# Generator that yields lines on a job console as strings
34def _console_lines(console_url):
35 with urllib.request.urlopen(console_url) as console_fd:
36 for line in filter(None, console_fd):
37 # Console might have special characters. Yield an empty line in that case.
38 try:
39 yield line.decode().rstrip("\n")
40 except UnicodeDecodeError as e:
41 # In case of decode error, return up until the character that
42 # caused the error
43 yield line[:e.start].decode().rstrip("\n")
44
45
46# Class representing Jenkins job
47class JobInstance:
48 def __init__(self, url, status=None):
49 self.sub_jobs = []
50 self.url = url
51 self.name = None
52 self.build_number = None
53 self.config = None
54 self.status = status
55 self.depth = 0
56
57 # Representation for debugging
58 def __repr__(self):
59 return "{}#{}".format(self.name, self.build_number)
60
61 # Scrape job's console to identify sub jobs, and recurseively parse them.
62 def parse(self, *, depth=0):
63 url_fields = self.url.rstrip("/").split("/")
64
65 # Identify job name and number from the URL
66 try:
67 stem_url_list = url_fields[:-3]
68 self.name, self.build_number = url_fields[-2:]
69 if self.build_number not in ("lastBuild", "lastSuccessfulBuild"):
70 int(self.build_number)
71 except:
72 raise Exception(self.url + " is not a valid Jenkins build URL.")
73
74 self.depth = depth
75
76 # Scrape the job's console
77 console_url = "/".join(url_fields + ["consoleText"])
78 try:
79 for line in _console_lines(console_url):
80 # A job that prints CONFIGURATION is where we'd find the build
81 # artefacts
82 fields = line.split()
83 if len(fields) == 2 and fields[0] == "CONFIGURATION:":
84 self.config = fields[1]
85 return
86
87 # Look for sub job pattern, and recurse into the sub-job
88 child_matches = filter(None, map(lambda p: p.match(line),
89 _SUBJOB_PATTERNS))
90 for match in child_matches:
91 child = JobInstance("/".join(stem_url_list +
92 ["job", match.group("j"), match.group("b")]),
93 match.group("s"))
94 child.parse(depth=depth+1)
95 self.sub_jobs.append(child)
96 except urllib.error.HTTPError:
97 print(console_url + " is not accessible.", file=sys.stderr)
98
99 # Generator that yields individual jobs in the hierarchy
100 def walk(self, *, sort=False):
101 if not self.sub_jobs:
102 yield self
103 else:
104 descendants = self.sub_jobs
105 if sort:
106 descendants = sorted(self.sub_jobs, key=lambda j: j.build_number)
107 for child in descendants:
108 yield from child.walk(sort=sort)
109
110 # Print one job
111 def print(self):
112 config_str = "[" + self.config + "]" if self.config else ""
113 status = self.status if self.status else ""
114
115 print("{}{} #{} {} {}".format(" " * 2 * self.depth, self.name,
116 self.build_number, status, config_str))
117
118 # Print the whole hierarchy
119 def print_tree(self, *, sort=False):
120 self.print()
121 if not self.sub_jobs:
122 return
123
124 descendants = self.sub_jobs
125 if sort:
126 descendants = sorted(self.sub_jobs, key=lambda j: j.build_number)
127 for child in descendants:
128 child.print_tree(sort=sort)
129
130 @contextlib.contextmanager
131 def open_artefact(self, path, *, text=False):
132 # Wrapper class that offer string reads from a byte descriptor
133 class TextStream:
134 def __init__(self, byte_fd):
135 self.byte_fd = byte_fd
136
137 def read(self, sz=None):
138 return self.byte_fd.read(sz).decode("utf-8")
139
140 art_url = "/".join([self.url, "artifact", path])
141 with urllib.request.urlopen(art_url) as fd:
142 yield TextStream(fd) if text else fd
143
144
145
146# When invoked from command line, print the whole tree
147if __name__ == "__main__":
148 parser = argparse.ArgumentParser()
149
150 parser.add_argument("build_url",
151 help="URL to specific build number to walk")
152 parser.add_argument("--unique-tf-configs", default=False,
153 action="store_const", const=True, help="Print unique TF configs")
154
155 opts = parser.parse_args()
156
157 top = JobInstance(opts.build_url)
158 top.parse()
159
160 if opts.unique_tf_configs:
161 unique_configs = set()
162
163 # Extract the base TF config name from the job's config, which contains
164 # group, TFTF configs etc.
165 for job in filter(lambda j: j.config, top.walk()):
166 unique_configs.add(job.config.split("/")[1].split(":")[0].split(",")[0])
167
168 for config in sorted(unique_configs):
169 print(config)
170 else:
171 top.print_tree()