| OLD | NEW |
| 1 # Copyright 2015 The LUCI Authors. All rights reserved. | 1 # Copyright 2015 The LUCI Authors. All rights reserved. |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
| 4 | 4 |
| 5 import ast | 5 import ast |
| 6 import collections | 6 import collections |
| 7 import contextlib | 7 import contextlib |
| 8 import copy | 8 import copy |
| 9 import difflib | 9 import difflib |
| 10 import functools | 10 import functools |
| 11 import itertools | 11 import itertools |
| 12 import logging | 12 import logging |
| 13 import operator | 13 import operator |
| 14 import os | 14 import os |
| 15 import subprocess | 15 import subprocess |
| 16 import sys | 16 import sys |
| 17 import tempfile | 17 import tempfile |
| 18 | 18 |
| 19 from . import env | 19 from . import env |
| 20 | 20 |
| 21 from google.protobuf import text_format | 21 from google.protobuf import text_format |
| 22 from . import package_pb2 | 22 from . import package_pb2 |
| 23 from . import fetch | 23 from . import fetch |
| 24 | 24 |
| 25 | 25 |
| 26 class InconsistentDependencyGraphError(Exception): | 26 class InconsistentDependencyGraphError(Exception): |
| 27 def __init__(self, project_id, specs): | 27 def __init__(self, project_id, specs): |
| 28 super(InconsistentDependencyGraphError, self).__init__( | |
| 29 'Package specs for %s do not match: %s vs %s' % ( | |
| 30 project_id, specs[0], specs[1])) | |
| 31 self.project_id = project_id | 28 self.project_id = project_id |
| 32 self.specs = specs | 29 self.specs = specs |
| 33 | 30 |
| 31 def __str__(self): |
| 32 return 'Package specs for %s do not match: %s vs %s' % ( |
| 33 project_id, self.specs[0], self.specs[1]) |
| 34 |
| 34 | 35 |
| 35 class CyclicDependencyError(Exception): | 36 class CyclicDependencyError(Exception): |
| 36 pass | 37 pass |
| 37 | 38 |
| 38 | 39 |
| 39 def cleanup_pyc(path): | 40 def cleanup_pyc(path): |
| 40 """Removes any .pyc files from |path|'s directory tree. | 41 """Removes any .pyc files from |path|'s directory tree. |
| 41 | 42 |
| 42 This ensures we always use the fresh code. | 43 This ensures we always use the fresh code. |
| 43 """ | 44 """ |
| (...skipping 107 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 151 def checkout(self, context): | 152 def checkout(self, context): |
| 152 """Fetches the specified package and returns the path of the package root | 153 """Fetches the specified package and returns the path of the package root |
| 153 (the directory that contains recipes and recipe_modules). | 154 (the directory that contains recipes and recipe_modules). |
| 154 """ | 155 """ |
| 155 raise NotImplementedError() | 156 raise NotImplementedError() |
| 156 | 157 |
| 157 def repo_root(self, context): | 158 def repo_root(self, context): |
| 158 """Returns the root of this repository.""" | 159 """Returns the root of this repository.""" |
| 159 raise NotImplementedError() | 160 raise NotImplementedError() |
| 160 | 161 |
| 161 def is_consistent_with(self, other): | |
| 162 """Returns True iff |other| can be used in place of |self| while keeping | |
| 163 the dependency graph consistent. Most often it means being pinned at the | |
| 164 same revision, but some specs (like path-based) are always compatible.""" | |
| 165 raise NotImplementedError() | |
| 166 | |
| 167 def __eq__(self, other): | 162 def __eq__(self, other): |
| 168 raise NotImplementedError() | 163 raise NotImplementedError() |
| 169 | 164 |
| 170 def __ne__(self, other): | 165 def __ne__(self, other): |
| 171 return not (self == other) | 166 return not (self == other) |
| 172 | 167 |
| 173 def proto_file(self, context): | 168 def proto_file(self, context): |
| 174 """Returns the ProtoFile of the recipes config file in this repository. | 169 """Returns the ProtoFile of the recipes config file in this repository. |
| 175 Requires a good checkout.""" | 170 Requires a good checkout.""" |
| 176 return ProtoFile(InfraRepoConfig().to_recipes_cfg(self.repo_root(context))) | 171 return ProtoFile(InfraRepoConfig().to_recipes_cfg(self.repo_root(context))) |
| (...skipping 24 matching lines...) Expand all Loading... |
| 201 | 196 |
| 202 def checkout(self, context): | 197 def checkout(self, context): |
| 203 checkout_dir = self._dep_dir(context) | 198 checkout_dir = self._dep_dir(context) |
| 204 self.backend.checkout( | 199 self.backend.checkout( |
| 205 self.repo, self.revision, checkout_dir, context.allow_fetch) | 200 self.repo, self.revision, checkout_dir, context.allow_fetch) |
| 206 cleanup_pyc(checkout_dir) | 201 cleanup_pyc(checkout_dir) |
| 207 | 202 |
| 208 def repo_root(self, context): | 203 def repo_root(self, context): |
| 209 return os.path.join(self._dep_dir(context), self.path) | 204 return os.path.join(self._dep_dir(context), self.path) |
| 210 | 205 |
| 211 def is_consistent_with(self, other): | |
| 212 return (self == other) | |
| 213 | |
| 214 def dump(self): | 206 def dump(self): |
| 215 buf = package_pb2.DepSpec( | 207 buf = package_pb2.DepSpec( |
| 216 project_id=self.project_id, | 208 project_id=self.project_id, |
| 217 url=self.repo, | 209 url=self.repo, |
| 218 branch=self.branch, | 210 branch=self.branch, |
| 219 revision=self.revision) | 211 revision=self.revision) |
| 220 if self.path: | 212 if self.path: |
| 221 buf.path_override = self.path | 213 buf.path_override = self.path |
| 222 | 214 |
| 223 # Only dump repo_type if it's different from default. This preserves | 215 # Only dump repo_type if it's different from default. This preserves |
| (...skipping 102 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 326 def proto_file(self, context): | 318 def proto_file(self, context): |
| 327 """Returns the ProtoFile of the recipes config file in this repository. | 319 """Returns the ProtoFile of the recipes config file in this repository. |
| 328 Requires a good checkout.""" | 320 Requires a good checkout.""" |
| 329 return ProtoFile(InfraRepoConfig().to_recipes_cfg(self.path)) | 321 return ProtoFile(InfraRepoConfig().to_recipes_cfg(self.path)) |
| 330 | 322 |
| 331 def __eq__(self, other): | 323 def __eq__(self, other): |
| 332 if not isinstance(other, type(self)): | 324 if not isinstance(other, type(self)): |
| 333 return False | 325 return False |
| 334 return self.path == other.path | 326 return self.path == other.path |
| 335 | 327 |
| 336 def is_consistent_with(self, other): | |
| 337 # PathRepoSpec is always compatible, unless it's PathRepoSpec with a | |
| 338 # different path. | |
| 339 if isinstance(other, type(self)): | |
| 340 return self.path == other.path | |
| 341 return True | |
| 342 | |
| 343 | 328 |
| 344 class RootRepoSpec(RepoSpec): | 329 class RootRepoSpec(RepoSpec): |
| 345 def __init__(self, proto_file): | 330 def __init__(self, proto_file): |
| 346 self._proto_file = proto_file | 331 self._proto_file = proto_file |
| 347 | 332 |
| 348 def checkout(self, context): | 333 def checkout(self, context): |
| 349 # We assume this is already checked out. | 334 # We assume this is already checked out. |
| 350 pass | 335 pass |
| 351 | 336 |
| 352 def repo_root(self, context): | 337 def repo_root(self, context): |
| 353 return context.repo_root | 338 return context.repo_root |
| 354 | 339 |
| 355 def is_consistent_with(self, other): | |
| 356 return (self == other) | |
| 357 | |
| 358 def proto_file(self, context): | 340 def proto_file(self, context): |
| 359 return self._proto_file | 341 return self._proto_file |
| 360 | 342 |
| 361 def __eq__(self, other): | 343 def __eq__(self, other): |
| 362 if not isinstance(other, type(self)): | 344 if not isinstance(other, type(self)): |
| 363 return False | 345 return False |
| 364 return self._proto_file == other._proto_file | 346 return self._proto_file == other._proto_file |
| 365 | 347 |
| 366 | 348 |
| 367 class Package(object): | 349 class Package(object): |
| 368 """Package represents a loaded package, and contains path and dependency | 350 """Package represents a loaded package, and contains path and dependency |
| 369 information. | 351 information. |
| 370 | 352 |
| 371 This is accessed by loader.py through RecipeDeps.get_package. | 353 This is accessed by loader.py through RecipeDeps.get_package. |
| 372 """ | 354 """ |
| 373 def __init__(self, name, repo_spec, deps, repo_root, relative_recipes_dir, | 355 def __init__(self, name, repo_spec, deps, repo_root, relative_recipes_dir): |
| 374 is_override): | |
| 375 self.name = name | 356 self.name = name |
| 376 self.repo_spec = repo_spec | 357 self.repo_spec = repo_spec |
| 377 self.deps = deps | 358 self.deps = deps |
| 378 self.repo_root = repo_root | 359 self.repo_root = repo_root |
| 379 self.relative_recipes_dir = relative_recipes_dir | 360 self.relative_recipes_dir = relative_recipes_dir |
| 380 self.is_override = is_override | |
| 381 | 361 |
| 382 def __repr__(self): | 362 def __repr__(self): |
| 383 return ('<Package(name=%r,repo_spec=%r,deps=%r,recipes_dir=%r,' | 363 return '<Package(name=%r,repo_spec=%r,deps=%r,recipes_dir=%r)>' % ( |
| 384 'override=%r)>' % (self.name, self.repo_spec, self.deps, | 364 self.name, self.repo_spec, self.deps, self.recipes_dir) |
| 385 self.recipes_dir, self.is_override)) | |
| 386 | 365 |
| 387 @property | 366 @property |
| 388 def recipes_dir(self): | 367 def recipes_dir(self): |
| 389 return os.path.join(self.repo_root, self.relative_recipes_dir) | 368 return os.path.join(self.repo_root, self.relative_recipes_dir) |
| 390 | 369 |
| 391 @property | 370 @property |
| 392 def recipe_dirs(self): | 371 def recipe_dirs(self): |
| 393 return [os.path.join(self.recipes_dir, 'recipes')] | 372 return [os.path.join(self.recipes_dir, 'recipes')] |
| 394 | 373 |
| 395 @property | 374 @property |
| 396 def module_dirs(self): | 375 def module_dirs(self): |
| 397 return [os.path.join(self.recipes_dir, 'recipe_modules')] | 376 return [os.path.join(self.recipes_dir, 'recipe_modules')] |
| 398 | 377 |
| 399 def find_dep(self, dep_name): | 378 def find_dep(self, dep_name): |
| 400 if dep_name == self.name: | 379 if dep_name == self.name: |
| 401 return self | 380 return self |
| 402 | 381 |
| 403 assert dep_name in self.deps, ( | 382 assert dep_name in self.deps, ( |
| 404 '%s does not exist or is not declared as a dependency of %s' % ( | 383 '%s does not exist or is not declared as a dependency of %s' % ( |
| 405 dep_name, self.name)) | 384 dep_name, self.name)) |
| 406 return self.deps[dep_name] | 385 return self.deps[dep_name] |
| 407 | 386 |
| 408 def module_path(self, module_name): | 387 def module_path(self, module_name): |
| 409 return os.path.join(self.recipes_dir, 'recipe_modules', module_name) | 388 return os.path.join(self.recipes_dir, 'recipe_modules', module_name) |
| 410 | 389 |
| 411 def __repr__(self): | 390 def __repr__(self): |
| 412 return 'Package(%r, %r, %r, %r, %r)' % ( | 391 return 'Package(%r, %r, %r, %r)' % ( |
| 413 self.name, self.repo_spec, self.deps, self.recipe_dirs, | 392 self.name, self.repo_spec, self.deps, self.recipe_dirs) |
| 414 self.is_override) | |
| 415 | 393 |
| 416 def __str__(self): | 394 def __str__(self): |
| 417 return 'Package %s, with dependencies %s' % (self.name, self.deps.keys()) | 395 return 'Package %s, with dependencies %s' % (self.name, self.deps.keys()) |
| 418 | 396 |
| 419 | 397 |
| 420 class RollCandidate(object): | 398 class RollCandidate(object): |
| 421 """RollCandidate represents a recipe roll candidate, i.e. updates | 399 """RollCandidate represents a recipe roll candidate, i.e. updates |
| 422 to pinned revisions of recipe dependencies. | 400 to pinned revisions of recipe dependencies. |
| 423 | 401 |
| 424 This is mostly used by recipes.py autoroll command. | 402 This is mostly used by recipes.py autoroll command. |
| (...skipping 18 matching lines...) Expand all Loading... |
| 443 """Attempts to make the after-roll dependency graph consistent by rolling | 421 """Attempts to make the after-roll dependency graph consistent by rolling |
| 444 other package dependencies (changing their revisions). A consistent | 422 other package dependencies (changing their revisions). A consistent |
| 445 dependency graph means that all of the repos in the graph are pinned | 423 dependency graph means that all of the repos in the graph are pinned |
| 446 at the same revision. | 424 at the same revision. |
| 447 | 425 |
| 448 Returns True on success. | 426 Returns True on success. |
| 449 """ | 427 """ |
| 450 while True: | 428 while True: |
| 451 try: | 429 try: |
| 452 package_deps = PackageDeps(self._context) | 430 package_deps = PackageDeps(self._context) |
| 453 package_deps._create_from_spec(root_spec, self.get_rolled_spec(), False) | 431 package_deps._create_from_spec(root_spec, self.get_rolled_spec()) |
| 454 return True | 432 return True |
| 455 except InconsistentDependencyGraphError as e: | 433 except InconsistentDependencyGraphError as e: |
| 456 # Don't update the same project twice - that'd mean we have two | 434 # Don't update the same project twice - that'd mean we have two |
| 457 # conflicting updates anyway. | 435 # conflicting updates anyway. |
| 458 if e.project_id in self._updates: | 436 if e.project_id in self._updates: |
| 459 return False | 437 return False |
| 460 | 438 |
| 461 # Get the spec that is different from the one we already have. | 439 # Get the spec that is different from the one we already have. |
| 462 # The order in which they're returned is not guaranteed. | 440 # The order in which they're returned is not guaranteed. |
| 463 current_revision = self._package_spec.deps[e.project_id].revision | 441 current_revision = self._package_spec.deps[e.project_id].revision |
| (...skipping 137 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 601 | 579 |
| 602 def __ne__(self, other): | 580 def __ne__(self, other): |
| 603 return not self.__eq__(other) | 581 return not self.__eq__(other) |
| 604 | 582 |
| 605 | 583 |
| 606 class PackageDeps(object): | 584 class PackageDeps(object): |
| 607 """An object containing all the transitive dependencies of the root package. | 585 """An object containing all the transitive dependencies of the root package. |
| 608 """ | 586 """ |
| 609 def __init__(self, context, overrides=None): | 587 def __init__(self, context, overrides=None): |
| 610 self._context = context | 588 self._context = context |
| 589 self._packages = {} |
| 611 self._overrides = overrides or {} | 590 self._overrides = overrides or {} |
| 612 self._packages = {} | |
| 613 self._root_package = None | 591 self._root_package = None |
| 614 | 592 |
| 615 @property | 593 @property |
| 616 def root_package(self): | 594 def root_package(self): |
| 617 return self._root_package | 595 return self._root_package |
| 618 | 596 |
| 619 @classmethod | 597 @classmethod |
| 620 def create(cls, repo_root, proto_file, deps_path=None, allow_fetch=False, | 598 def create(cls, repo_root, proto_file, deps_path=None, allow_fetch=False, |
| 621 overrides=None): | 599 overrides=None): |
| 622 """Creates a PackageDeps object. | 600 """Creates a PackageDeps object. |
| 623 | 601 |
| 624 Arguments: | 602 Arguments: |
| 625 repo_root: the root of the repository containing this package. | 603 repo_root: the root of the repository containing this package. |
| 626 proto_file: a ProtoFile object corresponding to the repos recipes.cfg | 604 proto_file: a ProtoFile object corresponding to the repos recipes.cfg |
| 627 allow_fetch: whether to fetch dependencies rather than just checking for | 605 allow_fetch: whether to fetch dependencies rather than just checking for |
| 628 them. | 606 them. |
| 629 overrides: if not None, a dictionary of project overrides. Dictionary keys | 607 overrides: if not None, a dictionary of project overrides. Dictionary keys |
| 630 are the `project_id` field to override, and dictionary values | 608 are the `project_id` field to override, and dictionary values |
| 631 are the override path. | 609 are the override path. |
| 632 """ | 610 """ |
| 633 context = PackageContext.from_proto_file(repo_root, proto_file, allow_fetch, | 611 context = PackageContext.from_proto_file(repo_root, proto_file, allow_fetch, |
| 634 deps_path=deps_path) | 612 deps_path=deps_path) |
| 635 | 613 |
| 636 if overrides: | 614 if overrides: |
| 637 overrides = {project_id: PathRepoSpec(path) | 615 overrides = {project_id: PathRepoSpec(path) |
| 638 for project_id, path in overrides.iteritems()} | 616 for project_id, path in overrides.iteritems()} |
| 639 package_deps = cls(context, overrides=overrides) | 617 package_deps = cls(context, overrides=overrides) |
| 640 | 618 |
| 641 # Install our overrides and their dependencies first. Each of these will be | 619 package_deps._root_package = package_deps._create_package(RootRepoSpec(proto
_file)) |
| 642 # marked as overriding, meaning that they will supercede equivalent packages | |
| 643 # defined in the root package without error. | |
| 644 # | |
| 645 # Package resolution is required to be consistent within the overridden | |
| 646 # packages, and required to be consistent within the non-overridden | |
| 647 # packages, but a conflict between an overridden package and a | |
| 648 # non-overridden package will prefer the overridden one. | |
| 649 package_deps._register_overrides() | |
| 650 | |
| 651 # Install our root package and its dependencies. | |
| 652 package_deps._root_package = package_deps._create_package( | |
| 653 RootRepoSpec(proto_file), False) | |
| 654 | 620 |
| 655 return package_deps | 621 return package_deps |
| 656 | 622 |
| 657 def _register_overrides(self): | 623 def _create_package(self, repo_spec): |
| 658 for project_id, repo_spec in self._overrides.iteritems(): | |
| 659 self._create_package(repo_spec, True) | |
| 660 | |
| 661 def _create_package(self, repo_spec, overriding): | |
| 662 repo_spec.checkout(self._context) | 624 repo_spec.checkout(self._context) |
| 663 package_spec = PackageSpec.load_proto(repo_spec.proto_file(self._context)) | 625 package_spec = PackageSpec.load_proto(repo_spec.proto_file(self._context)) |
| 664 return self._create_from_spec(repo_spec, package_spec, overriding) | 626 return self._create_from_spec(repo_spec, package_spec) |
| 665 | 627 |
| 666 def _create_from_spec(self, repo_spec, package_spec, overriding): | 628 def _create_from_spec(self, repo_spec, package_spec): |
| 667 project_id = package_spec.project_id | 629 project_id = package_spec.project_id |
| 668 | 630 repo_spec = self._overrides.get(project_id, repo_spec) |
| 669 if project_id in self._packages: | 631 if project_id in self._packages: |
| 670 current = self._packages[project_id] | 632 # TODO(phajdan.jr): Are exceptions the best way to report these errors? |
| 671 if current is None: | 633 # The way this is used in practice, especially inconsistent dependency |
| 634 # graph condition, might be considered as using exceptions for control |
| 635 # flow. |
| 636 if self._packages[project_id] is None: |
| 672 raise CyclicDependencyError( | 637 raise CyclicDependencyError( |
| 673 'Package %s depends on itself' % project_id) | 638 'Package %s depends on itself' % project_id) |
| 674 | 639 if repo_spec != self._packages[project_id].repo_spec: |
| 675 # Only enforce package consistency within the override boundary. | |
| 676 # It's fine if either spec claims it's consistent with the other. | |
| 677 if (current.is_override == overriding and | |
| 678 not repo_spec.is_consistent_with(current.repo_spec) and | |
| 679 not current.repo_spec.is_consistent_with(repo_spec)): | |
| 680 raise InconsistentDependencyGraphError( | 640 raise InconsistentDependencyGraphError( |
| 681 project_id, (repo_spec, current.repo_spec)) | 641 project_id, (repo_spec, self._packages[project_id].repo_spec)) |
| 682 self._packages[project_id] = None | 642 self._packages[project_id] = None |
| 683 | 643 |
| 684 deps = {} | 644 deps = {} |
| 685 for dep, dep_repo in sorted(package_spec.deps.items()): | 645 for dep, dep_repo in sorted(package_spec.deps.items()): |
| 686 dep_package = self._packages.get(dep) | 646 dep_repo = self._overrides.get(dep, dep_repo) |
| 687 if not (dep_package and dep_package.is_override): | 647 deps[dep] = self._create_package(dep_repo) |
| 688 dep_package = self._create_package(dep_repo, overriding) | |
| 689 deps[dep] = dep_package | |
| 690 | 648 |
| 691 package = Package( | 649 package = Package( |
| 692 project_id, repo_spec, deps, | 650 project_id, repo_spec, deps, |
| 693 repo_spec.repo_root(self._context), | 651 repo_spec.repo_root(self._context), |
| 694 package_spec.recipes_path, | 652 package_spec.recipes_path) |
| 695 overriding) | |
| 696 | 653 |
| 697 self._packages[project_id] = package | 654 self._packages[project_id] = package |
| 698 return package | 655 return package |
| 699 | 656 |
| 700 # TODO(luqui): Remove this, so all accesses to packages are done | 657 # TODO(luqui): Remove this, so all accesses to packages are done |
| 701 # via other packages with properly scoped deps. | 658 # via other packages with properly scoped deps. |
| 702 def get_package(self, package_id): | 659 def get_package(self, package_id): |
| 703 return self._packages[package_id] | 660 return self._packages[package_id] |
| 704 | 661 |
| 705 @property | 662 @property |
| (...skipping 12 matching lines...) Expand all Loading... |
| 718 >>> d = { 'x': 1, 'y': 2 } | 675 >>> d = { 'x': 1, 'y': 2 } |
| 719 >>> sorted(_updated(d, { 'y': 3, 'z': 4 }).items()) | 676 >>> sorted(_updated(d, { 'y': 3, 'z': 4 }).items()) |
| 720 [('x', 1), ('y', 3), ('z', 4)] | 677 [('x', 1), ('y', 3), ('z', 4)] |
| 721 >>> sorted(d.items()) | 678 >>> sorted(d.items()) |
| 722 [('x', 1), ('y', 2)] | 679 [('x', 1), ('y', 2)] |
| 723 """ | 680 """ |
| 724 | 681 |
| 725 d = copy.copy(d) | 682 d = copy.copy(d) |
| 726 d.update(updates) | 683 d.update(updates) |
| 727 return d | 684 return d |
| OLD | NEW |