Brad Bishop | 977dc1a | 2019-02-06 16:01:43 -0500 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 2 | # |
| 3 | # Helper script for committing data to git and pushing upstream |
| 4 | # |
| 5 | # Copyright (c) 2017, Intel Corporation. |
| 6 | # |
| 7 | # This program is free software; you can redistribute it and/or modify it |
| 8 | # under the terms and conditions of the GNU General Public License, |
| 9 | # version 2, as published by the Free Software Foundation. |
| 10 | # |
| 11 | # This program is distributed in the hope it will be useful, but WITHOUT |
| 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| 13 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for |
| 14 | # more details. |
| 15 | # |
| 16 | import argparse |
| 17 | import glob |
| 18 | import json |
| 19 | import logging |
| 20 | import math |
| 21 | import os |
| 22 | import re |
| 23 | import sys |
| 24 | from collections import namedtuple, OrderedDict |
| 25 | from datetime import datetime, timedelta, tzinfo |
| 26 | from operator import attrgetter |
| 27 | |
| 28 | # Import oe and bitbake libs |
| 29 | scripts_path = os.path.dirname(os.path.realpath(__file__)) |
| 30 | sys.path.append(os.path.join(scripts_path, 'lib')) |
| 31 | import scriptpath |
| 32 | scriptpath.add_bitbake_lib_path() |
| 33 | scriptpath.add_oe_lib_path() |
| 34 | |
| 35 | from oeqa.utils.git import GitRepo, GitError |
| 36 | from oeqa.utils.metadata import metadata_from_bb |
| 37 | |
| 38 | |
| 39 | # Setup logging |
| 40 | logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") |
| 41 | log = logging.getLogger() |
| 42 | |
| 43 | |
| 44 | class ArchiveError(Exception): |
| 45 | """Internal error handling of this script""" |
| 46 | |
| 47 | |
| 48 | def format_str(string, fields): |
| 49 | """Format string using the given fields (dict)""" |
| 50 | try: |
| 51 | return string.format(**fields) |
| 52 | except KeyError as err: |
| 53 | raise ArchiveError("Unable to expand string '{}': unknown field {} " |
| 54 | "(valid fields are: {})".format( |
| 55 | string, err, ', '.join(sorted(fields.keys())))) |
| 56 | |
| 57 | |
| 58 | def init_git_repo(path, no_create, bare): |
| 59 | """Initialize local Git repository""" |
| 60 | path = os.path.abspath(path) |
| 61 | if os.path.isfile(path): |
| 62 | raise ArchiveError("Invalid Git repo at {}: path exists but is not a " |
| 63 | "directory".format(path)) |
| 64 | if not os.path.isdir(path) or not os.listdir(path): |
| 65 | if no_create: |
| 66 | raise ArchiveError("No git repo at {}, refusing to create " |
| 67 | "one".format(path)) |
| 68 | if not os.path.isdir(path): |
| 69 | try: |
| 70 | os.mkdir(path) |
| 71 | except (FileNotFoundError, PermissionError) as err: |
| 72 | raise ArchiveError("Failed to mkdir {}: {}".format(path, err)) |
| 73 | if not os.listdir(path): |
| 74 | log.info("Initializing a new Git repo at %s", path) |
| 75 | repo = GitRepo.init(path, bare) |
| 76 | try: |
| 77 | repo = GitRepo(path, is_topdir=True) |
| 78 | except GitError: |
| 79 | raise ArchiveError("Non-empty directory that is not a Git repository " |
| 80 | "at {}\nPlease specify an existing Git repository, " |
| 81 | "an empty directory or a non-existing directory " |
| 82 | "path.".format(path)) |
| 83 | return repo |
| 84 | |
| 85 | |
| 86 | def git_commit_data(repo, data_dir, branch, message, exclude, notes): |
| 87 | """Commit data into a Git repository""" |
| 88 | log.info("Committing data into to branch %s", branch) |
| 89 | tmp_index = os.path.join(repo.git_dir, 'index.oe-git-archive') |
| 90 | try: |
| 91 | # Create new tree object from the data |
| 92 | env_update = {'GIT_INDEX_FILE': tmp_index, |
| 93 | 'GIT_WORK_TREE': os.path.abspath(data_dir)} |
| 94 | repo.run_cmd('add .', env_update) |
| 95 | |
| 96 | # Remove files that are excluded |
| 97 | if exclude: |
| 98 | repo.run_cmd(['rm', '--cached'] + [f for f in exclude], env_update) |
| 99 | |
| 100 | tree = repo.run_cmd('write-tree', env_update) |
| 101 | |
| 102 | # Create new commit object from the tree |
| 103 | parent = repo.rev_parse(branch) |
| 104 | git_cmd = ['commit-tree', tree, '-m', message] |
| 105 | if parent: |
| 106 | git_cmd += ['-p', parent] |
| 107 | commit = repo.run_cmd(git_cmd, env_update) |
| 108 | |
| 109 | # Create git notes |
| 110 | for ref, filename in notes: |
| 111 | ref = ref.format(branch_name=branch) |
| 112 | repo.run_cmd(['notes', '--ref', ref, 'add', |
| 113 | '-F', os.path.abspath(filename), commit]) |
| 114 | |
| 115 | # Update branch head |
| 116 | git_cmd = ['update-ref', 'refs/heads/' + branch, commit] |
| 117 | if parent: |
| 118 | git_cmd.append(parent) |
| 119 | repo.run_cmd(git_cmd) |
| 120 | |
| 121 | # Update current HEAD, if we're on branch 'branch' |
| 122 | if not repo.bare and repo.get_current_branch() == branch: |
| 123 | log.info("Updating %s HEAD to latest commit", repo.top_dir) |
| 124 | repo.run_cmd('reset --hard') |
| 125 | |
| 126 | return commit |
| 127 | finally: |
| 128 | if os.path.exists(tmp_index): |
| 129 | os.unlink(tmp_index) |
| 130 | |
| 131 | |
| 132 | def expand_tag_strings(repo, name_pattern, msg_subj_pattern, msg_body_pattern, |
| 133 | keywords): |
| 134 | """Generate tag name and message, with support for running id number""" |
| 135 | keyws = keywords.copy() |
| 136 | # Tag number is handled specially: if not defined, we autoincrement it |
| 137 | if 'tag_number' not in keyws: |
| 138 | # Fill in all other fields than 'tag_number' |
| 139 | keyws['tag_number'] = '{tag_number}' |
| 140 | tag_re = format_str(name_pattern, keyws) |
| 141 | # Replace parentheses for proper regex matching |
| 142 | tag_re = tag_re.replace('(', '\(').replace(')', '\)') + '$' |
| 143 | # Inject regex group pattern for 'tag_number' |
| 144 | tag_re = tag_re.format(tag_number='(?P<tag_number>[0-9]{1,5})') |
| 145 | |
| 146 | keyws['tag_number'] = 0 |
| 147 | for existing_tag in repo.run_cmd('tag').splitlines(): |
| 148 | match = re.match(tag_re, existing_tag) |
| 149 | |
| 150 | if match and int(match.group('tag_number')) >= keyws['tag_number']: |
| 151 | keyws['tag_number'] = int(match.group('tag_number')) + 1 |
| 152 | |
| 153 | tag_name = format_str(name_pattern, keyws) |
| 154 | msg_subj= format_str(msg_subj_pattern.strip(), keyws) |
| 155 | msg_body = format_str(msg_body_pattern, keyws) |
| 156 | return tag_name, msg_subj + '\n\n' + msg_body |
| 157 | |
| 158 | |
| 159 | def parse_args(argv): |
| 160 | """Parse command line arguments""" |
| 161 | parser = argparse.ArgumentParser( |
| 162 | description="Commit data to git and push upstream", |
| 163 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
| 164 | |
| 165 | parser.add_argument('--debug', '-D', action='store_true', |
| 166 | help="Verbose logging") |
| 167 | parser.add_argument('--git-dir', '-g', required=True, |
| 168 | help="Local git directory to use") |
| 169 | parser.add_argument('--no-create', action='store_true', |
| 170 | help="If GIT_DIR is not a valid Git repository, do not " |
| 171 | "try to create one") |
| 172 | parser.add_argument('--bare', action='store_true', |
| 173 | help="Initialize a bare repository when creating a " |
| 174 | "new one") |
| 175 | parser.add_argument('--push', '-p', nargs='?', default=False, const=True, |
| 176 | help="Push to remote") |
| 177 | parser.add_argument('--branch-name', '-b', |
| 178 | default='{hostname}/{branch}/{machine}', |
| 179 | help="Git branch name (pattern) to use") |
| 180 | parser.add_argument('--no-tag', action='store_true', |
| 181 | help="Do not create Git tag") |
| 182 | parser.add_argument('--tag-name', '-t', |
| 183 | default='{hostname}/{branch}/{machine}/{commit_count}-g{commit}/{tag_number}', |
| 184 | help="Tag name (pattern) to use") |
| 185 | parser.add_argument('--commit-msg-subject', |
| 186 | default='Results of {branch}:{commit} on {hostname}', |
| 187 | help="Subject line (pattern) to use in the commit message") |
| 188 | parser.add_argument('--commit-msg-body', |
| 189 | default='branch: {branch}\ncommit: {commit}\nhostname: {hostname}', |
| 190 | help="Commit message body (pattern)") |
| 191 | parser.add_argument('--tag-msg-subject', |
| 192 | default='Test run #{tag_number} of {branch}:{commit} on {hostname}', |
| 193 | help="Subject line (pattern) of the tag message") |
| 194 | parser.add_argument('--tag-msg-body', |
| 195 | default='', |
| 196 | help="Tag message body (pattern)") |
| 197 | parser.add_argument('--exclude', action='append', default=[], |
| 198 | help="Glob to exclude files from the commit. Relative " |
| 199 | "to DATA_DIR. May be specified multiple times") |
| 200 | parser.add_argument('--notes', nargs=2, action='append', default=[], |
| 201 | metavar=('GIT_REF', 'FILE'), |
| 202 | help="Add a file as a note under refs/notes/GIT_REF. " |
| 203 | "{branch_name} in GIT_REF will be expanded to the " |
| 204 | "actual target branch name (specified by " |
| 205 | "--branch-name). This option may be specified " |
| 206 | "multiple times.") |
| 207 | parser.add_argument('data_dir', metavar='DATA_DIR', |
| 208 | help="Data to commit") |
| 209 | return parser.parse_args(argv) |
| 210 | |
Brad Bishop | 977dc1a | 2019-02-06 16:01:43 -0500 | [diff] [blame] | 211 | def get_nested(d, list_of_keys): |
| 212 | try: |
| 213 | for k in list_of_keys: |
| 214 | d = d[k] |
| 215 | return d |
| 216 | except KeyError: |
| 217 | return "" |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 218 | |
| 219 | def main(argv=None): |
| 220 | """Script entry point""" |
| 221 | args = parse_args(argv) |
| 222 | if args.debug: |
| 223 | log.setLevel(logging.DEBUG) |
| 224 | |
| 225 | try: |
| 226 | if not os.path.isdir(args.data_dir): |
| 227 | raise ArchiveError("Not a directory: {}".format(args.data_dir)) |
| 228 | |
| 229 | data_repo = init_git_repo(args.git_dir, args.no_create, args.bare) |
| 230 | |
| 231 | # Get keywords to be used in tag and branch names and messages |
| 232 | metadata = metadata_from_bb() |
Brad Bishop | 977dc1a | 2019-02-06 16:01:43 -0500 | [diff] [blame] | 233 | keywords = {'hostname': get_nested(metadata, ['hostname']), |
| 234 | 'branch': get_nested(metadata, ['layers', 'meta', 'branch']), |
| 235 | 'commit': get_nested(metadata, ['layers', 'meta', 'commit']), |
| 236 | 'commit_count': get_nested(metadata, ['layers', 'meta', 'commit_count']), |
| 237 | 'machine': get_nested(metadata, ['config', 'MACHINE'])} |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 238 | |
| 239 | # Expand strings early in order to avoid getting into inconsistent |
| 240 | # state (e.g. no tag even if data was committed) |
| 241 | commit_msg = format_str(args.commit_msg_subject.strip(), keywords) |
| 242 | commit_msg += '\n\n' + format_str(args.commit_msg_body, keywords) |
| 243 | branch_name = format_str(args.branch_name, keywords) |
| 244 | tag_name = None |
| 245 | if not args.no_tag and args.tag_name: |
| 246 | tag_name, tag_msg = expand_tag_strings(data_repo, args.tag_name, |
| 247 | args.tag_msg_subject, |
| 248 | args.tag_msg_body, keywords) |
| 249 | |
| 250 | # Commit data |
| 251 | commit = git_commit_data(data_repo, args.data_dir, branch_name, |
| 252 | commit_msg, args.exclude, args.notes) |
| 253 | |
| 254 | # Create tag |
| 255 | if tag_name: |
| 256 | log.info("Creating tag %s", tag_name) |
| 257 | data_repo.run_cmd(['tag', '-a', '-m', tag_msg, tag_name, commit]) |
| 258 | |
| 259 | # Push data to remote |
| 260 | if args.push: |
| 261 | cmd = ['push', '--tags'] |
| 262 | # If no remote is given we push with the default settings from |
| 263 | # gitconfig |
| 264 | if args.push is not True: |
| 265 | notes_refs = ['refs/notes/' + ref.format(branch_name=branch_name) |
| 266 | for ref, _ in args.notes] |
| 267 | cmd.extend([args.push, branch_name] + notes_refs) |
| 268 | log.info("Pushing data to remote") |
| 269 | data_repo.run_cmd(cmd) |
| 270 | |
| 271 | except ArchiveError as err: |
| 272 | log.error(str(err)) |
| 273 | return 1 |
| 274 | |
| 275 | return 0 |
| 276 | |
| 277 | if __name__ == "__main__": |
| 278 | sys.exit(main()) |