Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(230)

Side by Side Diff: recipe_engine/package.py

Issue 1344583003: Recipe package system. (Closed) Base URL: git@github.com:luci/recipes-py.git@master
Patch Set: Recompiled proto Created 5 years, 3 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « recipe_engine/package.proto ('k') | recipe_engine/package_pb2.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 import ast
2 import collections
3 import contextlib
4 import copy
5 import functools
6 import itertools
7 import logging
8 import os
9 import subprocess
10 import sys
11 import tempfile
12
13 from .third_party.google.protobuf import text_format
14 from . import package_pb2
15
16 class UncleanFilesystemError(Exception):
17 pass
18
19
20 class InconsistentDependencyGraphError(Exception):
21 pass
22
23
24 class CyclicDependencyError(Exception):
25 pass
26
27
28 class InfraRepoConfig(object):
29 def to_recipes_cfg(self, repo_root):
30 # TODO(luqui): This is not always correct. It can be configured in
31 # infra/config:refs.cfg.
32 return os.path.join(repo_root, 'infra', 'config', 'recipes.cfg')
33
34 def from_recipes_cfg(self, recipes_cfg):
35 return os.path.dirname( # <repo root>
36 os.path.dirname( # infra
37 os.path.dirname( # config
38 os.path.abspath(recipes_cfg)))) # recipes.cfg
39
40
41 class ProtoFile(object):
42 """A collection of functions operating on a proto path.
43
44 This is an object so that it can be mocked in the tests.
45 """
46 def __init__(self, path):
47 self._path = path
48
49 @property
50 def path(self):
51 return os.path.realpath(self._path)
52
53 def read_text(self):
54 with open(self._path, 'r') as fh:
55 return fh.read()
56
57 def read(self):
58 text = self.read_text()
59 buf = package_pb2.Package()
60 text_format.Merge(text, buf)
61 return buf
62
63 def to_text(self, buf):
64 return text_format.MessageToString(buf)
65
66 def write(self, buf):
67 with open(self._path, 'w') as fh:
68 fh.write(self.to_text(buf))
69
70
71 class PackageContext(object):
72 """Contains information about where the root package and its dependency
73 checkouts live.
74
75 - recipes_dir is the location of recipes/ and recipe_modules/ which contain
76 the actual recipes of the root package.
77 - package_dir is where dependency checkouts live, e.g.
78 package_dir/recipe_engine/recipes/...
79 - repo_root is the root of the repository containing the root package.
80 """
81
82 def __init__(self, recipes_dir, package_dir, repo_root):
83 self.recipes_dir = recipes_dir
84 self.package_dir = package_dir
85 self.repo_root = repo_root
86
87 @classmethod
88 def from_proto_file(cls, repo_root, proto_file):
89 proto_path = proto_file.path
90 buf = proto_file.read()
91
92 recipes_path = buf.recipes_path.replace('/', os.sep)
93
94 return cls(os.path.join(repo_root, recipes_path),
95 os.path.join(repo_root, recipes_path, '.recipe_deps'),
96 repo_root)
97
98
99 @functools.total_ordering
100 class RepoUpdate(object):
101 """Wrapper class that specifies the sort order of roll updates when merging.
102 """
103
104 def __init__(self, spec):
105 self.spec = spec
106
107 @property
108 def id(self):
109 return self.spec.id
110
111 def __eq__(self, other):
112 return (self.id, self.spec.revision) == (other.id, other.spec.revision)
113
114 def __le__(self, other):
115 return (self.id, self.spec.revision) <= (other.id, other.spec.revision)
116
117
118 class RepoSpec(object):
119 """RepoSpec is the specification of a repository to check out.
120
121 The prototypical example is GitRepoSpec, which includes a url, revision,
122 and branch.
123 """
124
125 def checkout(self, context):
126 """Fetches the specified package and returns the path of the package root
127 (the directory that contains recipes and recipe_modules).
128 """
129 raise NotImplementedError()
130
131 def check_checkout(self, context):
132 """Checks that the package is already fetched and in a good state, without
133 actually changing anything.
134
135 Returns None in normal conditions, otherwise raises some sort of exception.
136 """
137 raise NotImplementedError()
138
139 def repo_root(self, context):
140 """Returns the root of this repository."""
141 raise NotImplementedError()
142
143 def proto_file(self, context):
144 """Returns the ProtoFile of the recipes config file in this repository.
145 Requires a good checkout."""
146 return ProtoFile(InfraRepoConfig().to_recipes_cfg(self.repo_root(context)))
147
148
149 class GitRepoSpec(RepoSpec):
150 def __init__(self, id, repo, branch, revision, path):
151 self.id = id
152 self.repo = repo
153 self.branch = branch
154 self.revision = revision
155 self.path = path
156
157 def checkout(self, context):
158 package_dir = context.package_dir
159 dep_dir = os.path.join(package_dir, self.id)
160 logging.info('Freshening repository %s' % dep_dir)
161
162 if not os.path.isdir(dep_dir):
163 _run_cmd(['git', 'clone', self.repo, dep_dir])
164 elif not os.path.isdir(os.path.join(dep_dir, '.git')):
165 raise UncleanFilesystemError('%s exists but is not a git repo' % dep_dir)
166
167 try:
168 subprocess.check_output(['git', 'rev-parse', '-q', '--verify',
169 '%s^{commit}' % self.revision], cwd=dep_dir)
170 except subprocess.CalledProcessError:
171 _run_cmd(['git', 'fetch'], cwd=dep_dir)
172 _run_cmd(['git', 'reset', '-q', '--hard', self.revision], cwd=dep_dir)
173
174 def check_checkout(self, context):
175 dep_dir = os.path.join(context.package_dir, self.id)
176 if not os.path.isdir(dep_dir):
177 raise UncleanFilesystemError('Dependency %s does not exist' %
178 dep_dir)
179 elif not os.path.isdir(os.path.join(dep_dir, '.git')):
180 raise UncleanFilesystemError('Dependency %s is not a git repo' %
181 dep_dir)
182
183 git_status_command = ['git', 'status', '--porcelain']
184 logging.info('%s', git_status_command)
185 output = subprocess.check_output(git_status_command, cwd=dep_dir)
186 if output:
187 raise UncleanFilesystemError('Dependency %s is unclean:\n%s' %
188 (dep_dir, output))
189
190 def repo_root(self, context):
191 return os.path.join(context.package_dir, self.id, self.path)
192
193 def dump(self):
194 buf = package_pb2.DepSpec(
195 project_id=self.id,
196 url=self.repo,
197 branch=self.branch,
198 revision=self.revision)
199 if self.path:
200 buf.path_override = self.path
201 return buf
202
203 def updates(self, context):
204 """Returns a list of all updates to the branch since the revision this
205 repo spec refers to, paired with their commit timestamps; i.e.
206 (timestamp, GitRepoSpec).
207
208 Although timestamps are not completely reliable, they are the best tool we
209 have to approximate global coherence.
210 """
211 lines = filter(bool, self._raw_updates(context).strip().split('\n'))
212 return [ RepoUpdate(
213 GitRepoSpec(self.id, self.repo, self.branch, rev, self.path))
214 for rev in lines ]
215
216 def _raw_updates(self, context):
217 self.checkout(context)
218 # XXX(luqui): Should this just focus on the recipes subtree rather than
219 # the whole repo?
220 git = subprocess.Popen(['git', 'log',
221 '%s..origin/%s' % (self.revision, self.branch),
222 '--pretty=%H',
223 '--reverse'],
224 stdout=subprocess.PIPE,
225 cwd=os.path.join(context.package_dir, self.id))
226 (stdout, _) = git.communicate()
227 return stdout
228
229 def _components(self):
230 return (self.id, self.repo, self.revision, self.path)
231
232 def __eq__(self, other):
233 return self._components() == other._components()
234
235 def __ne__(self, other):
236 return not self.__eq__(other)
237
238
239 class RootRepoSpec(RepoSpec):
240 def __init__(self, proto_file):
241 self._proto_file = proto_file
242
243 def checkout(self, context):
244 # We assume this is already checked out.
245 pass
246
247 def check_checkout(self, context):
248 pass
249
250 def repo_root(self, context):
251 return context.repo_root
252
253 def proto_file(self, context):
254 return self._proto_file
255
256
257
258
259 class Package(object):
260 """Package represents a loaded package, and contains path and dependency
261 information.
262
263 This is accessed by loader.py through RecipeDeps.get_package.
264 """
265 def __init__(self, repo_spec, deps, recipes_dir):
266 self.repo_spec = repo_spec
267 self.deps = deps
268 self.recipes_dir = recipes_dir
269
270 @property
271 def recipe_dirs(self):
272 return [os.path.join(self.recipes_dir, 'recipes')]
273
274 @property
275 def module_dirs(self):
276 return [os.path.join(self.recipes_dir, 'recipe_modules')]
277
278 def find_dep(self, dep_name):
279 return self.deps[dep_name]
280
281 def module_path(self, module_name):
282 return os.path.join(self.recipes_dir, 'recipe_modules', module_name)
283
284
285 class PackageSpec(object):
286 API_VERSION = 1
287
288 def __init__(self, project_id, recipes_path, deps):
289 self._project_id = project_id
290 self._recipes_path = recipes_path
291 self._deps = deps
292
293 @classmethod
294 def load_proto(cls, proto_file):
295 buf = proto_file.read()
296 assert buf.api_version == cls.API_VERSION
297
298 deps = { dep.project_id: GitRepoSpec(dep.project_id,
299 dep.url,
300 dep.branch,
301 dep.revision,
302 dep.path_override)
303 for dep in buf.deps }
304 return cls(buf.project_id, buf.recipes_path, deps)
305
306 @property
307 def project_id(self):
308 return self._project_id
309
310 @property
311 def recipes_path(self):
312 return self._recipes_path
313
314 @property
315 def deps(self):
316 return self._deps
317
318 def dump(self):
319 return package_pb2.Package(
320 api_version=self.API_VERSION,
321 project_id=self._project_id,
322 recipes_path=self._recipes_path,
323 deps=[ self._deps[dep].dump() for dep in sorted(self._deps.keys()) ])
324
325 def updates(self, context):
326 """Returns a list of RepoUpdate<PackageSpec>s, corresponding to the updates
327 in self's dependencies.
328
329 See iterate_consistent_updates below."""
330
331 dep_updates = _merge([
332 self._deps[dep].updates(context) for dep in sorted(self._deps.keys()) ])
333
334 deps_so_far = self._deps
335 ret_updates = []
336 for update in dep_updates:
337 deps_so_far = _updated(deps_so_far, { update.id: update.spec })
338 ret_updates.append(RepoUpdate(PackageSpec(
339 self._project_id, self._recipes_path, deps_so_far)))
340 return ret_updates
341
342 def iterate_consistent_updates(self, proto_file, context):
343 """Returns a list of RepoUpdate<PackageSpec>s, corresponding to the updates
344 in self's dependencies, with inconsistent dependency graphs filtered out.
345
346 This is the entry point of the rolling logic, which is called by recipes.py.
347
348 To roll, we look at all updates on the specified branches in each of our
349 direct dependencies. We don't look at transitive dependencies because
350 our direct dependencies are responsible for rolling those. If we have two
351 dependencies A and B, each with three updates, we can visualize this in
352 a two-dimensional space like so:
353
354 A1 A2 A3
355 +--------
356 B1 | . . .
357 B2 | . . .
358 B3 | . . .
359
360 Each of the 9 locations here corresponds to a possible PackageSpec. Some
361 of these will be inconsistent; e.g. A and B depend on the same package at
362 different versions. Let's mark a few with X's to indicate inconsistent
363 dependencies:
364
365 A1 A2 A3
366 +--------
367 B1 | . . X
368 B2 | . X .
369 B3 | X X .
370
371 We are trying to find which consistent versions to commit, and in which
372 order. We only want to commit in monotone order (left to right and top to
373 bottom); i.e. committing a spec depending on A3 then in the next commit
374 depending on A2 doesn't make sense. But as we can see here, there are
375 multiple monotone paths.
376
377 A1B1 A2B1 A3B2 A3B3
378 A1B1 A1B2 A3B2 A3B3
379
380 So we necessarily need to choose one over the other. We would like to go
381 for as fine a granularity as possible, so it would seem we need to choose
382 the longest one. But since the granularity of our updates depends on the
383 granularity of our dependencies' updates, what we would actually aim for is
384 "global coherence"; i.e. everybody chooses mutually consistent paths. So if
385 we update A2B1, somebody else who also depends on A and B will update to
386 A2B1, in order to be consistent for anybody downstream.
387
388 It also needs to be consistent with the future; e.g. we don't want to choose
389 A2B1 if there is an A2 and A1B2 otherwise, because in the future A2 might
390 become available, which would make the order of rolls depend on when you
391 did the roll. That leads to, as far as I can tell, the only global
392 coherence strategy, which is to roll along whichever axis has the smallest
393 time delta from the current configuration.
394
395 HOWEVER timestamps on git commits are not reliable, so we don't do any of
396 this logic. Instead, we rely on the fact that we expect the auto-roller bot
397 to roll frequently, which means that we will roll in minimum-time-delta
398 order anyway (at least up to an accuracy of the auto-roller bot's cycle
399 time). So in the rare that there are multiple commits to roll, we naively
400 choose to roll them in lexicographic order: roll all of A's commits, then
401 all of B's.
402
403 In the case that we need rolling to be more distributed, it will be
404 important to solve the timestamp issue so we ensure coherence.
405 """
406
407 root_spec = RootRepoSpec(proto_file)
408 for update in self.updates(context):
409 try:
410 package_deps = PackageDeps(context)
411 # Inconsistent graphs will throw an exception here, thus skipping the
412 # yield.
413 package_deps._create_from_spec(root_spec, update.spec, allow_fetch=True)
414 yield update
415 except InconsistentDependencyGraphError:
416 pass
417
418 def __eq__(self, other):
419 return (
420 self._project_id == other._project_id and
421 self._recipes_path == other._recipes_path and
422 self._deps == other._deps)
423
424 def __ne__(self, other):
425 return not self.__eq__(other)
426
427
428 class PackageDeps(object):
429 """An object containing all the transitive dependencies of the root package.
430 """
431 def __init__(self, context):
432 self._context = context
433 self._repos = {}
434
435 @classmethod
436 def create(cls, repo_root, proto_file, allow_fetch=False):
437 """Creates a PackageDeps object.
438
439 Arguments:
440 repo_root: the root of the repository containing this package.
441 proto_file: a ProtoFile object corresponding to the repos recipes.cfg
442 allow_fetch: whether to fetch dependencies rather than just checking for
443 them.
444 """
445 context = PackageContext.from_proto_file(repo_root, proto_file)
446 package_deps = cls(context)
447
448 root_package = package_deps._create_package(
449 RootRepoSpec(proto_file), allow_fetch)
450 return package_deps
451
452 def _create_package(self, repo_spec, allow_fetch):
453 if allow_fetch:
454 repo_spec.checkout(self._context)
455 else:
456 try:
457 repo_spec.check_checkout(self._context)
458 except UncleanFilesystemError as e:
459 logging.warn(
460 'Unclean environment. You probably need to run "recipes.py fetch"\n'
461 '%s' % e.message)
462
463 package_spec = PackageSpec.load_proto(repo_spec.proto_file(self._context))
464
465 return self._create_from_spec(repo_spec, package_spec, allow_fetch)
466
467 def _create_from_spec(self, repo_spec, package_spec, allow_fetch):
468 project_id = package_spec.project_id
469 if project_id in self._repos:
470 if self._repos[project_id] is None:
471 raise CyclicDependencyError(
472 'Package %s depends on itself' % project_id)
473 if repo_spec != self._repos[project_id].repo_spec:
474 raise InconsistentDependencyGraphError(
475 'Package specs do not match: %s vs %s' %
476 (repo_spec, self._repos[project_id].repo_spec))
477 self._repos[project_id] = None
478
479 deps = {}
480 for dep, dep_repo in sorted(package_spec.deps.items()):
481 deps[dep] = self._create_package(dep_repo, allow_fetch)
482
483 package = Package(
484 repo_spec, deps,
485 os.path.join(repo_spec.repo_root(self._context),
486 package_spec.recipes_path))
487
488 self._repos[project_id] = package
489 return package
490
491 # TODO(luqui): Remove this, so all accesses to packages are done
492 # via other packages with properly scoped deps.
493 def get_package(self, package_id):
494 return self._repos[package_id]
495
496 @property
497 def all_recipe_dirs(self):
498 for repo in self._repos.values():
499 for subdir in repo.recipe_dirs:
500 yield str(subdir)
501
502 @property
503 def all_module_dirs(self):
504 for repo in self._repos.values():
505 for subdir in repo.module_dirs:
506 yield str(subdir)
507
508
509 def _run_cmd(cmd, cwd=None):
510 cwd_str = ' (in %s)' % cwd if cwd else ''
511 logging.info('%s%s', cmd, cwd_str)
512 subprocess.check_call(cmd, cwd=cwd)
513
514
515 def _merge2(xs, ys, compare=lambda x, y: x <= y):
516 """Merges two sorted iterables, preserving sort order.
517
518 >>> list(_merge2([1, 3, 6], [2, 4, 5]))
519 [1, 2, 3, 4, 5, 6]
520 >>> list(_merge2([1, 2, 3], []))
521 [1, 2, 3]
522 >>> list(_merge2([], [4, 5, 6]))
523 [4, 5, 6]
524 >>> list(_merge2([], []))
525 []
526 >>> list(_merge2([4, 2], [3, 1], compare=lambda x, y: x >= y))
527 [4, 3, 2, 1]
528
529 The merge is left-biased and preserves order within each argument.
530
531 >>> list(_merge2([1, 4], [3, 2], compare=lambda x, y: True))
532 [1, 4, 3, 2]
533 """
534 nothing = object()
535
536 xs = iter(xs)
537 ys = iter(ys)
538 x = nothing
539 y = nothing
540 try:
541 x = xs.next()
542 y = ys.next()
543
544 while True:
545 if compare(x, y):
546 yield x
547 x = nothing
548 x = xs.next()
549 else:
550 yield y
551 y = nothing
552 y = ys.next()
553 except StopIteration:
554 if x is not nothing: yield x
555 for x in xs: yield x
556 if y is not nothing: yield y
557 for y in ys: yield y
558
559
560 def _merge(xss, compare=lambda x, y: x <= y):
561 """Merges a sequence of sorted iterables in sorted order.
562
563 >>> list(_merge([ [1,5], [2,5,6], [], [0,7] ]))
564 [0, 1, 2, 5, 5, 6, 7]
565 >>> list(_merge([ [1,2,3] ]))
566 [1, 2, 3]
567 >>> list(_merge([]))
568 []
569 """
570 return reduce(lambda xs, ys: _merge2(xs, ys, compare=compare), xss, [])
571
572
573 def _updated(d, updates):
574 """Updates a dictionary without mutation.
575
576 >>> d = { 'x': 1, 'y': 2 }
577 >>> sorted(_updated(d, { 'y': 3, 'z': 4 }).items())
578 [('x', 1), ('y', 3), ('z', 4)]
579 >>> sorted(d.items())
580 [('x', 1), ('y', 2)]
581 """
582
583 d = copy.copy(d)
584 d.update(updates)
585 return d
OLDNEW
« no previous file with comments | « recipe_engine/package.proto ('k') | recipe_engine/package_pb2.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698