Better new-pocket affordances in invirtibuilder and build submission script
[invirt/packages/invirt-dev.git] / python / invirt / builder.py
1 """Invirt build utilities.
2
3 This module contains utility functions used by both the invirtibuilder
4 and the remctl submission scripts that insert items into its queue.
5 """
6
7
8 import os
9 import subprocess
10
11 from debian_bundle import changelog
12
13 import invirt.common as c
14 from invirt.config import structs as config
15
16
17 _QUEUE_DIR = '/var/lib/invirt-dev/queue'
18 _REPO_DIR = '/srv/git'
19 _LOG_DIR = '/var/log/invirt/builds'
20 _HOOKS_DIR = '/usr/share/invirt-dev/build-hooks'
21
22 _DEFAULT_DISTRIBUTION = 'hardy'
23
24
25 class InvalidBuild(ValueError):
26     pass
27
28 _DISTRO_TO_SUFFIX = {
29     'etch': '~debian4.0',
30     'lenny': '~debian5.0',
31     'squeeze': '~debian6.0',
32
33     'hardy': '~ubuntu8.04',
34     'lucid': '~ubuntu10.04',
35     'maverick': '~ubuntu10.10',
36     'natty': '~ubuntu11.04',
37     'oneiric': '~ubuntu11.10',
38     'precise': '~ubuntu12.04',
39     }
40
41 def distroToSuffix(distro):
42     return _DISTRO_TO_SUFFIX.get(distro, '~'+distro)
43
44 def getRepo(package):
45     """Return the path to the git repo for a given package."""
46     return os.path.join(_REPO_DIR, 'invirt/packages', '%s.git' % package)
47
48 def ensureValidPackage(package):
49     """Perform some basic sanity checks that the requested repo is in a
50     subdirectory of _REPO_DIR/invirt/packages.  This prevents weirdness
51     such as submitting a package like '../prod/...git'.  Also ensures that
52     the repo exists."""
53     # TODO: this might be easier just to regex
54     repo = os.path.abspath(getRepo(package))
55     parent_dir = os.path.dirname(repo)
56     prefix = os.path.join(_REPO_DIR, 'invirt/packages')
57     if not parent_dir.startswith(prefix):
58         raise InvalidBuild('Invalid package name %s' % package)
59     elif not os.path.exists(repo):
60         raise InvalidBuild('Nonexisting package %s' % package)
61
62 def canonicalize_commit(package, commit, shorten=False):
63     if shorten:
64         flags = ['--short']
65     else:
66         flags = []
67     return c.captureOutput(['git', 'rev-parse'] + flags + [commit],
68                            cwd=getRepo(package)).strip()
69
70 def pocketToGit(pocket):
71     """Map a pocket in the configuration to a git branch."""
72     return getattr(getattr(config.build.pockets, pocket), 'git', pocket)
73
74
75 def pocketToApt(pocket):
76     """Map a pocket in the configuration to an apt repo pocket."""
77     return getattr(getattr(config.build.pockets, pocket), 'apt', pocket)
78
79 def pocketToDistro(pocket):
80     """Map a pocket in the configuration to the distro we build for."""
81     return getattr(getattr(config.build.pockets, pocket), 'distro', _DEFAULT_DISTRIBUTION)
82
83 def getGitFile(package, ref, path):
84     """Return the contents of a path from a git ref in a package."""
85     return c.captureOutput(['git', 'cat-file', 'blob', '%s:%s' % (ref, path)],
86                            cwd=getRepo(package))
87
88
89 def getChangelog(package, ref):
90     """Get a changelog object for a given ref in a given package.
91
92     This returns a debian_bundle.changelog.Changelog object for a
93     given ref of a given package.
94     """
95     return changelog.Changelog(getGitFile(package, ref, 'debian/changelog'))
96
97 def runHook(hook, args=[], stdin_str=None):
98     """Run a named hook."""
99     hook = os.path.join(_HOOKS_DIR, hook)
100     try:
101         c.captureOutput([hook] + args, stdin_str=stdin_str)
102     except OSError:
103         pass
104
105 def getVersion(package, ref):
106     """Get the version of a given package at a particular ref."""
107     return getChangelog(package, ref).get_version()
108
109 def pocketExists(pocket, repo):
110     branch = pocketToGit(pocket)
111     try:
112         c.captureOutput(['git', 'rev-parse', branch], cwd=repo)
113     except subprocess.CalledProcessError:
114         return False
115     return True
116
117 def validateBuild(pocket, package, commit):
118     """Given the parameters of a new build, validate that build.
119
120     The checks this function performs vary based on whether or not the
121     pocket is configured with allow_backtracking.
122
123     A build of a pocket without allow_backtracking set must be a
124     fast-forward of the previous revision, and the most recent version
125     in the changelog most be strictly greater than the version
126     currently in the repository.
127
128     In all cases, this revision of the package can only have the same
129     version number as any other revision currently in the apt
130     repository if they have the same commit ID.
131
132     If it's unspecified, it is assumed that pocket do not
133     allow_backtracking.
134
135     If this build request fails validation, this function will raise a
136     InvalidBuild exception, with information about why the validation
137     failed.
138
139     If this build request can be satisfied by copying the package from
140     another pocket, then this function returns that pocket. Otherwise,
141     it returns True.
142     """
143     ensureValidPackage(package)
144     package_repo = getRepo(package)
145     new_version = getVersion(package, commit)
146     new_distro = pocketToDistro(pocket)
147
148     ret = True
149
150     for p in config.build.pockets:
151         if p == pocket:
152             continue
153
154         b = pocketToGit(p)
155         try:
156             current_commit = c.captureOutput(['git', 'rev-parse', b],
157                                              cwd=package_repo).strip()
158         except subprocess.CalledProcessError:
159             # Guess we haven't created this pocket yet
160             continue
161
162         current_version = getVersion(package, b)
163         current_distro = pocketToDistro(p)
164
165         # NB: Neither current_version nor new_version will have the
166         # distro-specific prefix.
167
168         if current_version == new_version and current_distro == new_distro:
169             if current_commit == commit:
170                 ret = p
171             else:
172                 raise InvalidBuild('Version %s of %s already available is in '
173                                    'pocket %s from commit %s' %
174                                    (new_version, package, p, current_commit))
175
176     if not config.build.pockets[pocket].get('allow_backtracking', False):
177         if not pocketExists(pocket, package_repo):
178             return True
179
180         branch = pocketToGit(pocket)
181         current_version = getVersion(package, branch)
182         if new_version <= current_version:
183             raise InvalidBuild('New version %s of %s is not newer than '
184                                'version %s currently in pocket %s' %
185                                (new_version, package, current_version, pocket))
186
187         # Almost by definition, A is a fast-forward of B if B..A is
188         # empty
189         if c.captureOutput(['git', 'rev-list', '%s..%s' % (commit, branch)],
190                            cwd=package_repo):
191             raise InvalidBuild('New commit %s of %s is not a fast-forward of'
192                                'commit currently in pocket %s' %
193                                (commit, package, pocket))
194
195     return ret