OLD | NEW |
(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 |
OLD | NEW |