-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrelease_tool.py
executable file
·473 lines (424 loc) · 16.2 KB
/
release_tool.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
#!/usr/bin/env python3
import datetime
import os
import sys
import pathlib
import logging
import tempfile
import typing
import time
import click
import requests
import rich
import rich.align
from rich.console import Console
from rich.text import Text
from rich.table import Table
from rich.progress import track
from rich.logging import RichHandler
from rich.live import Live
from rich.layout import Layout
import ghlib
import git
import repoinfo
import util
from error_handling import error, warn
from repoinfo import RepoInfo
def get_token() -> str:
"""
Prompt user for GitHub token and do a quick verification
:return: str with GitHub token
"""
token = input("Enter your github token (PAT):")
if not ghlib.valid_gh_token(token):
warn("Token seems to be invalid")
resp = input("Continue [y/N]? ")
if resp.lower().strip() != 'y':
error("Exiting due to missing github PAT")
return token
def generate_tag(repo: RepoInfo) -> str:
"""
Given repo information, create a tag for it
:param repo: repo what will be tagged
:return: a tag for the repo
"""
match repo.tagtype:
case 'calver': return f"{util.generate_calver()}-{repo.label}"
case 'semver': return f"{repo.semver}-{repo.label}"
return ""
def get_confirmation(tagged_repos: list[RepoInfo]) -> bool:
"""
Get user confirmation of repo tags
:param tagged_repos: list with repo configs
:return: true if user confirms, false otherwise
"""
console = Console()
table = Table(title="Repo tags to be applied")
table.add_column("Repository")
table.add_column("URL")
table.add_column("Tag")
for repo in tagged_repos:
table.add_row(repo.name,
Text(repoinfo.generate_repo_url(repo), style=f"link {repoinfo.generate_repo_url(repo)}"),
repo.tag)
console.print(table)
console.print("Apply tags (y/N)? ")
resp = input().lower()
if resp == 'y':
return True
else:
return False
def update_branch_config(repo_config: RepoInfo, github_token: str = None) -> bool:
"""
Check and verify a branch exists in a repository, if it exists update
config to include the last commit made on the branch
:param repo_config: repo config
:param github_token: GitHub token to use for authentication
:return: True if branch is present
"""
if not repo_config.branch:
error(f"Can't verify branch in {repo_config.name} without a branch being given")
return False
branch_url = repoinfo.generate_repo_url(repo_config) + f"/branches/{repo_config.branch}"
get_headers = {'Accept': 'application/vnd.github.v3+json'}
if github_token:
get_headers['Authorization'] = f"token {github_token}"
r = requests.get(branch_url, headers=get_headers)
match r.status_code:
case 404: return False
case 200:
repo_config.commit = r.json()['commit']['sha']
return True
case _: error(f"Got {r.status_code} when accessing github: {branch_url}")
return False
def verify_commit(repo_config: RepoInfo, github_token: str = None) -> bool:
"""
Check and verify specified commit exists in a repository
:param repo_config: repo config
:param github_token: GitHub token to use for authentication
:return: True if branch is present
"""
if not repo_config.commit:
error(f"Can't verify commit to {repo_config.name} without a commit hash")
return False
return ghlib.verify_commit(repoinfo.generate_repo_url(repo_config), repo_config.commit, github_token)
def check_repos(repo_configs: list[RepoInfo], github_token: str = None) -> bool:
"""
Check a list of repos to verify that information provided is accurate
:param repo_configs: list of RepoInfo to check
:param github_token: GitHub token to use for authentication
:return: None
"""
for repo in repo_configs:
if not update_branch_config(repo, github_token):
error(f"Can't find branch {repo.branch} in {repo.name}", abort=False)
return False
return True
def generate_repo_tags(repo_configs: list[RepoInfo]) -> list[RepoInfo]:
"""
Given a list of repo configs, create a dictionary of repo url with associated tags
:param repo_configs: list of repo configs
:return: same list with tags defined
"""
new_configs = []
for repo in repo_configs:
repo.tag = generate_tag(repo)
new_configs.append(repo)
return new_configs
def tag_repos(repo_configs: list[RepoInfo], github_token: str = None) -> str:
"""
Tag repos with specified tags
:param repo_configs: list of repo configurations
:param github_token: GitHub token for authentication
:return: None
"""
check_repos(repo_configs, github_token)
tagged_configs = generate_repo_tags(repo_configs)
if not get_confirmation(tagged_configs):
error("Tagging operation was not confirmed", abort=True)
Console().print("Starting tagging operations:")
tag = ""
for repo in track(tagged_configs, description="Tagging.."):
tag = repo.tag
if not ghlib.tag_repo(repo, github_token):
error(f"Can't tag {repo.name}")
return tag
@click.command(help="Verify that workflows for given tags have completed successfully")
@click.option('--tag', type=str, default="", help="Check workflows associated with a tag")
@click.option("workflow_time", '--time', type=str, default="",
help="Check for workflows started within 5 minutes of specified time (YYYY-MM-DDTHH:MM:SS)")
@click.pass_obj
@click.pass_context
def monitor_workflows(ctx: click.Context, config: dict[str, typing.Any], tag: str, workflow_time: str) -> None:
"""
Verify that workflows for given tags have completed successfully
:param ctx: click.Context with information about invocation
:param config: dictionary with option information
:param tag: tag to use when finding workflows to monitor
:param workflow_time: ISO8601 time (YYYY-MM-DDTHH:MM:SS) giving the approximate time for monitored workflows
"""
if 'token' not in ctx.obj:
token = get_token()
ctx.obj['token'] = token
else:
token = ctx.obj['token']
if not ghlib.valid_gh_token(token):
error("Must provide a valid github token for authentication to get workflow information")
if workflow_time != "" and not isinstance(workflow_time, datetime.datetime):
workflow_datetime = datetime.datetime.fromisoformat(workflow_time)
if tag == "" and workflow_time == "":
resp = input("Get workflows started around the current time? [Y/n]")
if resp.lower().strip() != "y":
error("Need a tag or time to monitor workflows")
workflow_datetime = datetime.datetime.utcnow()
workflow_info = {}
if tag != "":
for repo in config['repo_configs']:
workflow_info[repo.name] = ghlib.get_repo_workflow_by_tag(repo, tag, token)
elif workflow_time != "":
for repo in config['repo_configs']:
workflow_info[repo.name] = ghlib.get_repo_workflow_by_time(repo, workflow_datetime, token)
console = Console()
with Live(console=console, auto_refresh=False) as live_table:
while True:
table = generate_table(workflow_info, token)
live_table.update(rich.align.Align.center(table), refresh=True)
if ghlib.workflows_complete(workflow_info, token):
table = generate_table(workflow_info, token)
live_layout = Layout()
live_layout.split_column(Layout(name="upper"), Layout(name="lower"))
live_layout['upper'].ratio = 1
live_layout['upper'].update(table)
live_layout['lower'].update("All workflows completed")
live_table.update(live_layout, refresh=True)
return
time.sleep(30)
def generate_table(workflow_info: dict[str, list[str]], token: str) -> rich.table.Table:
"""
Generate a rich table with workflow information
:param workflow_info: dictionary with workflow information
:param token: GitHub token
:return: a rich table with workflow information
"""
table = Table(title="Workflow Status")
table.add_column("Repo")
table.add_column("Workflow")
table.add_column("Workflow URL")
table.add_column("Workflow Status")
table.add_column("Current Job")
table.add_column("Job Status")
for repo, workflows in workflow_info.items():
for workflow_url in workflows:
status = ghlib.get_workflow_status(workflow_url, token)
if status == {}:
table.add_row(repo, None, None, None, None)
continue
if status['status'] in ['success', 'completed']:
status_text = Text(status['status'], style="bold green")
elif status['status'] in ['cancelled', 'failure', 'action_required', 'timed_out']:
status_text = Text(status['status'], style="blink red")
else:
status_text = Text(status['status'], style="dim green")
if status['job_status'] in ['success', 'completed']:
job_text = Text(status['status'], style="bold green")
elif status['job_status'] in ['cancelled', 'failure', 'action_required', 'timed_out']:
job_text = Text(status['job_status'], style="blink red")
else:
job_text = Text(status['job_status'], style="dim green")
status_url = Text(status['url'], style=f"link {status['url']} blue")
table.add_row(repo,
f"{status['workflow_id']}",
status_url,
status_text,
status['job_name'],
job_text)
return table
@click.command(help="Tag repos using settings from config file")
@click.option("--verify", default=True, help="Verify that images were generated and published")
@click.option("--publish", default=False, help="Publish helm chart")
@click.pass_obj
@click.pass_context
def tag(ctx: click.Context, config: dict[str, typing.Any], verify: bool, publish: bool) -> None:
"""
Tag repos
:param ctx: click.Context with information about invocation
:param config: dictionary with configuration parameters
:param verify: bool indicating whether to verify generation of containers
:param publish: bool indicating whether to publish chart
:return:
"""
if 'token' not in ctx.obj:
token = get_token()
ctx.obj['token'] = token
else:
token = ctx.obj['token']
repo_configs = config['repo_configs']
if not check_repos(repo_configs, token):
error("Can't verify that all repos and branches exist")
tag = tag_repos(repo_configs, token)
if not verify:
user = input("Verify container creation? [y/N]")
if user.lower().strip() != 'y':
sys.exit(0)
workflow_time = datetime.datetime.utcnow()
ctx.obj['config'] = repo_configs
ctx.invoke(monitor_workflows, tag=tag, workflow_time=workflow_time)
ctx.obj['config'] = config
ctx.invoke(verify_containers, tag=tag)
if publish:
chart_version = input("Chart version to publish? ")
user = input(f"Publish chart {chart_version} [y/N]? ")
if user.lower().strip() != 'y':
Console().print("Exiting since no chart version given")
sys.exit(0)
ctx.invoke(release, tag=tag, chart_version=chart_version)
Console().print("Release tagged and published")
else:
Console().print("Release tagged")
sys.exit(0)
@click.command(help="Verify containers have been published at sources given in config file")
@click.option("--tag", type=str, default="", required=True, prompt=True)
@click.pass_obj
def verify_containers(config: dict[str, typing.Any], tag: str) -> None:
"""
Tag repos
:param config: dictionary with configuration parameters
:param tag: container tag to check
:return: None
"""
if tag == "":
error("Must specify a valid tag")
repo_configs = config['repo_configs']
console = Console()
console.print("\nChecking for docker containers:")
registry_repos = [repo for repo in repo_configs if repo.container_registry != ""]
for repo in track(registry_repos, description="Checking.."):
if not find_container(repo, tag):
print(dir(repo))
error(f"Can't find container in {repo.container_repo} tagged as {tag} for {repo.name}")
console.print("All containers verified successfully")
def find_container(repo: RepoInfo, tag) -> bool:
"""
Check for containers
:param repo: RepoInfo object with information for repo
:param tag: tag for container image
:return: True if container found, false otherwise
"""
container_url = repoinfo.container_url(repo, tag)
r = requests.get(container_url)
if r.status_code == 200:
return True
return False
@click.command(help="Generate and publish chart")
@click.option("--tag", type=str, default="", required=True, prompt=True)
@click.option("--chart-version", type=str, default="", required=True, prompt=True)
@click.option("--verify", default=True, help="Verify that images were generated and published")
@click.pass_obj
@click.pass_context
def release(ctx: click.Context, config: dict[str, typing.Any], tag: str, chart_version: str, verify: bool) -> None:
"""
Tag repos
:param ctx: click.Context with information about invocation
:param config: dictionary with configuration parameters
:param tag: tag to use for images
:param chart_version: version string to use for released chart
:param verify: bool indicating whether to verify generation of containers
:return: None
"""
if 'token' not in ctx.obj:
token = get_token()
ctx.obj['token'] = token
else:
token = ctx.obj['token']
if not ghlib.valid_gh_token(token):
error("Must provide a valid github token for authentication to get workflow information")
if verify:
ctx.obj['config'] = config
ctx.invoke(verify_containers, tag=tag)
temp_dir = ""
try:
temp_dir = tempfile.mkdtemp()
except IOError as err:
error(f"Can't create temporary directory in order to publish chart: {err}")
orig_dir = ""
try:
orig_dir = os.getcwd()
os.chdir(temp_dir)
print(f"Checking out in {temp_dir}")
console = Console()
servicex_repo = git.checkout_repo("ssh://[email protected]/ssl-hep/ServiceX.git", f"{temp_dir}/ServiceX", console)
if servicex_repo is None:
error("Can't checkout ServiceX repo")
else:
service_repo_dir = pathlib.Path(servicex_repo.workdir)
if not git.checkout_branch(servicex_repo, "develop"):
error("Can't checkout develop branch from ServiceX repo")
util.replace_appver(service_repo_dir / "servicex" / "Chart.yaml", tag, chart_version)
util.replace_tags(service_repo_dir / "servicex" / "values.yaml", tag)
git.add_file(servicex_repo, "servicex/Chart.yaml")
git.add_file(servicex_repo, "servicex/values.yaml")
git.commit(servicex_repo)
chart_repo = git.checkout_repo("ssh://[email protected]/ssl-hep/ssl-helm-charts.git", f"{temp_dir}/ssl-helm-charts", console)
if chart_repo is None:
error("Can't checkout ssl-helm-charts repo")
else:
chart_repo_dir = pathlib.Path(chart_repo.workdir)
if not git.checkout_branch(chart_repo, "gh-pages"):
error("Can't checkout gh-pages branch from ssl-helm-charts repo")
util.generate_helm_package(service_repo_dir, chart_repo_dir)
git.add_file(chart_repo, "index.yaml")
git.add_file(chart_repo, f"servicex-{chart_version}.tgz")
git.commit(chart_repo)
if not git.push(servicex_repo):
error("Can't push changes to ServiceX repo")
if not git.push(chart_repo):
error("Can't push changes to ssl-helm-charts repo")
except IOError:
pass
finally:
if orig_dir is not None:
os.chdir(orig_dir)
#shutil.rmtree(temp_dir)
def setup_logging(debug: bool) -> None:
"""
Setup logging with rich
:return: None
"""
if debug:
log_level = "DEBUG"
else:
log_level = "INFO"
logging.basicConfig(
level=log_level,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True)]
)
def get_config(config: str) -> list[RepoInfo]:
config_path = pathlib.Path(config)
if pathlib.Path.is_file(config_path):
repo_configs = util.ingest_config(config_path)
return repo_configs
else:
error(f"Config file {config} not present\n")
return list()
@click.group()
@click.option("--config", default="repos.toml", type=str, help="Configuration file for toml", required=True)
@click.option("--debug", default=False, type=bool, help="Enable debugging")
@click.pass_context
def entry(ctx: click.Context, config: str, debug: bool) -> None:
"""
Do various release tasks for ServiceX
"""
setup_logging(debug)
repo_configs = get_config(config)
token = get_token()
ctx.obj = {'config': config,
'repo_configs': repo_configs,
'token': token}
entry.add_command(tag)
entry.add_command(verify_containers)
entry.add_command(monitor_workflows)
entry.add_command(release)
if __name__ == "__main__":
entry()