Skip to content

Commit d7f6cbe

Browse files
committed
graph: auto rebuild/validation of reverse deps
This commit introduces the graph modules with class to handle a graph of reverse dependencies between packages in a Rift project. When a package is built, especially when its version has changed, it is important to verify that it does not break build and tests of other packages that depends on it (aka. reverse dependencies). With this new feature, as soon as dependency_tracking option is enabled in project configuration, the graph of reverse dependencies is automatically created with rift build and validate actions, unless --skip-deps option is set on command line. Dependencies are either found in packages info.yaml metadata file or by parsing subpackages (including their provides) and build requirements out of RPM spec files as a fallback. Rift then solves the reverse dependencies of the packages provided by users with build and validate actions to determine the packages that should be rebuilt or validated consequently. Unit tests are added to validate the code introduced for this feature.
1 parent a8f35df commit d7f6cbe

File tree

13 files changed

+913
-19
lines changed

13 files changed

+913
-19
lines changed

lib/rift/Config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class RiftDeprecatedConfWarning(FutureWarning):
8686
_DEFAULT_SYNC_METHOD = 'dnf'
8787
_DEFAULT_SYNC_INCLUDE = []
8888
_DEFAULT_SYNC_EXCLUDE = []
89+
_DEFAULT_DEPENDENCY_TRACKING = False
8990

9091
class Config():
9192
"""
@@ -301,6 +302,10 @@ class Config():
301302
'values': ['9p', 'virtiofs'],
302303
},
303304
'sync_output': {},
305+
'dependency_tracking': {
306+
'check': 'bool',
307+
'default': _DEFAULT_DEPENDENCY_TRACKING,
308+
},
304309
# XXX?: 'mock.name' ?
305310
# XXX?: 'mock.template' ?
306311
}

lib/rift/Controller.py

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from rift.Mock import Mock
5353
from rift.Package import Package, Test
5454
from rift.Repository import LocalRepository, ProjectArchRepositories
55+
from rift.graph import PackagesDependencyGraph
5556
from rift.RPM import RPM, Spec, RPMLINT_CONFIG_V1, RPMLINT_CONFIG_V2
5657
from rift.TempDir import TempDir
5758
from rift.TestResults import TestCase, TestResults
@@ -140,6 +141,8 @@ def make_parser():
140141
subprs.add_argument('-s', '--sign', action='store_true',
141142
help='sign built packages with GPG key '
142143
'(implies -p, --publish)')
144+
subprs.add_argument('-S', '--skip-deps', action='store_true',
145+
help='Skip automatic rebuild of reverse dependencies')
143146
subprs.add_argument('--junit', metavar='FILENAME',
144147
help='write junit result file')
145148
subprs.add_argument('--dont-update-repo', dest='updaterepo', action='store_false',
@@ -178,6 +181,8 @@ def make_parser():
178181
help='write junit result file')
179182
subprs.add_argument('-p', '--publish', action='store_true',
180183
help='publish build RPMS to repository')
184+
subprs.add_argument('-S', '--skip-deps', action='store_true',
185+
help='Skip automatic validation of reverse dependencies')
181186

182187
# Validate diff
183188
subprs = subparsers.add_parser('validdiff')
@@ -443,15 +448,19 @@ def __init__(self, pkg, config=None):
443448
Test.__init__(self, cmd, "basic_install")
444449
self.local = False
445450

446-
def build_pkg(config, args, pkg, arch):
451+
def build_pkg(config, args, pkg, arch, staging):
447452
"""
448453
Build a package for a specific architecture
449454
- config: rift configuration
455+
- args: command line arguments
450456
- pkg: package to build
451-
- repo: rpm repositories to use
452-
- suppl_repos: optional additional repositories
457+
- arch: CPU architecture
458+
- staging: temporary staging rpm repositories to hold dependencies when
459+
testing builds of reserve dependencies recursively.
453460
"""
454-
repos = ProjectArchRepositories(config, arch)
461+
repos = ProjectArchRepositories(config, arch,
462+
extra=staging.consumables[arch]
463+
if staging is not None else None)
455464
if args.publish and not repos.can_publish():
456465
raise RiftError("Cannot publish if 'working_repo' is undefined")
457466

@@ -468,6 +477,14 @@ def build_pkg(config, args, pkg, arch):
468477
logging.info('Built: %s', rpm.filepath)
469478
message("RPMS successfully built")
470479

480+
# If defined, publish in staging repository
481+
if staging:
482+
message("Publishing RPMS in staging repository...")
483+
mock.publish(staging)
484+
485+
message("Updating staging repository...")
486+
staging.update()
487+
471488
# Publish
472489
if args.publish:
473490
message("Publishing RPMS...")
@@ -576,7 +593,11 @@ def validate_pkgs(config, args, pkgs, arch):
576593
- launch tests
577594
"""
578595

579-
repos = ProjectArchRepositories(config, arch)
596+
# Create staging repository for all packages and add it to the project
597+
# supplementary repositories.
598+
(staging, stagedir) = create_staging_repo(config)
599+
repos = ProjectArchRepositories(config, arch,
600+
extra=staging.consumables[arch])
580601

581602
if args.publish and not repos.can_publish():
582603
raise RiftError("Cannot publish if 'working_repo' is undefined")
@@ -615,10 +636,12 @@ def validate_pkgs(config, args, pkgs, arch):
615636
message('Validate specfile...')
616637
spec.check(pkg)
617638

618-
(staging, stagedir) = create_staging_repo(config)
619-
620639
message('Preparing Mock environment...')
621640
mock = Mock(config, arch, config.get('version'))
641+
642+
for repo in repos.all:
643+
logging.debug("Mock with repo %s: %s", repo.name, repo.url)
644+
622645
mock.init(repos.all)
623646

624647
try:
@@ -665,7 +688,8 @@ def validate_pkgs(config, args, pkgs, arch):
665688
message("Keep environment, VM is running. Use: rift vm connect")
666689
else:
667690
mock.clean()
668-
stagedir.delete()
691+
692+
stagedir.delete()
669693

670694
banner(f"All packages checked on architecture {arch}")
671695

@@ -747,7 +771,7 @@ def action_vm(args, config):
747771
ret = vm_build(vm, args, config)
748772
return ret
749773

750-
def build_pkgs(config, args, pkgs, arch):
774+
def build_pkgs(config, args, pkgs, arch, staging):
751775
"""
752776
Build a list of packages on a given architecture and return results.
753777
"""
@@ -776,7 +800,7 @@ def build_pkgs(config, args, pkgs, arch):
776800
now = time.time()
777801
try:
778802
pkg.load()
779-
build_pkg(config, args, pkg, arch)
803+
build_pkg(config, args, pkg, arch, staging)
780804
except RiftError as ex:
781805
logging.error("Build failure: %s", str(ex))
782806
results.add_failure(case, time.time() - now, err=str(ex))
@@ -800,17 +824,30 @@ def action_build(args, config):
800824
results = TestResults('build')
801825

802826
staff, modules = staff_modules(config)
827+
pkgs = get_packages_to_build(config, staff, modules, args)
828+
logging.info(
829+
"Ordered list of packages to build: %s",
830+
str([pkg.name for pkg in pkgs])
831+
)
803832

804833
# Build all packages for all project supported architectures
805834
for arch in config.get('arch'):
806835

807-
pkgs = Package.list(config, staff, modules, args.packages)
808-
results.extend(build_pkgs(config, args, pkgs, arch))
836+
# Create temporary staging repository to hold dependencies unless
837+
# dependency tracking is disabled in project configuration or user set
838+
# --skip-deps argument.
839+
staging = stagedir = None
840+
if config.get('dependency_tracking') and not args.skip_deps:
841+
(staging, stagedir) = create_staging_repo(config)
842+
843+
results.extend(build_pkgs(config, args, pkgs, arch, staging))
809844

810845
if getattr(args, 'junit', False):
811846
logging.info('Writing test results in %s', args.junit)
812847
results.junit(args.junit)
813848

849+
if stagedir:
850+
stagedir.delete()
814851
banner(f"All packages processed for architecture {arch}")
815852

816853
banner('All architectures processed')
@@ -866,13 +903,18 @@ def action_validate(args, config):
866903

867904
staff, modules = staff_modules(config)
868905
results = TestResults('validate')
906+
pkgs = get_packages_to_build(config, staff, modules, args)
907+
logging.info(
908+
"Ordered list of packages to validate: %s",
909+
str([pkg.name for pkg in pkgs])
910+
)
869911
# Validate packages on all project supported architectures
870912
for arch in config.get('arch'):
871913
results.extend(
872914
validate_pkgs(
873915
config,
874916
args,
875-
Package.list(config, staff, modules, args.packages),
917+
pkgs,
876918
arch
877919
)
878920
)
@@ -1047,6 +1089,54 @@ def get_packages_from_patch(patch, config, modules, staff):
10471089

10481090
return updated, removed
10491091

1092+
def get_packages_to_build(config, staff, modules, args):
1093+
"""
1094+
Return ordered list of Packages to build. If dependency_tracking is disabled
1095+
in project configuration or --skip-deps arguments is set, only the list of
1096+
packages in arguments is selected. Else, this function builds a dependency
1097+
graph of all packages in the project to determine the list of packages that
1098+
reverse depends on the list of packages in arguments, recursively.
1099+
"""
1100+
if not config.get('dependency_tracking') or args.skip_deps:
1101+
return list(Package.list(config, staff, modules, args.packages))
1102+
1103+
# Build dependency graph with all projects packages.
1104+
graph = PackagesDependencyGraph.from_project(config, staff, modules)
1105+
1106+
result = []
1107+
1108+
def result_position(new_build_requirements):
1109+
"""
1110+
Return the first index in result of packages in provided build
1111+
requirements list.
1112+
"""
1113+
for build_requirement in new_build_requirements:
1114+
for index, package in enumerate(result):
1115+
if build_requirement.package == package:
1116+
return index
1117+
return -1
1118+
1119+
for pkg in Package.list(config, staff, modules, args.packages):
1120+
required_builds = graph.solve(pkg)
1121+
for index, required_build in enumerate(required_builds):
1122+
if required_build.package in result:
1123+
continue
1124+
# Search the position in result before all its own reverse
1125+
# dependencies.
1126+
position = result_position(required_builds[index+1:])
1127+
logging.info(
1128+
"Package %s must be built: %s",
1129+
required_build.package.name,
1130+
required_build.reasons,
1131+
)
1132+
# No position constraint in result, just append the package at the
1133+
# end. Else insert at the right position.
1134+
if position == -1:
1135+
result.append(required_build.package)
1136+
else:
1137+
result.insert(position, required_build.package)
1138+
return result
1139+
10501140
def create_staging_repo(config):
10511141
"""
10521142
Create and return staging temporary repository with a 2-tuple containing

lib/rift/Package.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def __init__(self, name, config, staff, modules):
6969
self.origin = None
7070
self.ignore_rpms = None
7171
self.rpmnames = None
72+
self.depends = None
7273

7374
# Static paths
7475
pkgdir = os.path.join(self._config.get('packages_dir'), self.name)
@@ -164,6 +165,13 @@ def load(self, infopath=None):
164165
else:
165166
self.ignore_rpms = data.get('ignore_rpms', [])
166167

168+
depends = data.get('depends')
169+
if depends is not None:
170+
if isinstance(depends, str):
171+
self.depends = [depends]
172+
else:
173+
self.depends = depends
174+
167175
self.check_info()
168176

169177
if os.path.exists(self.sourcesdir):

lib/rift/RPM.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import shutil
4141
from subprocess import Popen, PIPE, STDOUT, run, CalledProcessError
4242
import time
43+
import itertools
4344

4445
import rpm
4546

@@ -213,6 +214,7 @@ def __init__(self, filepath=None, config=None):
213214
self.filepath = filepath
214215
self.srpmname = None
215216
self.pkgnames = []
217+
self.provides = []
216218
self.sources = []
217219
self.basename = None
218220
self.version = None
@@ -265,6 +267,12 @@ def load(self):
265267
except ValueError as exp:
266268
raise RiftError(f"{self.filepath}: {exp}") from exp
267269
self.pkgnames = [_header_values(pkg.header['name']) for pkg in spec.packages]
270+
# Global unique list of provides. Here dict.fromkeys() is used to remove
271+
# duplicates as an alternative to set() for the sake of preserving order.
272+
self.provides = list(dict.fromkeys(
273+
itertools.chain(
274+
*[_header_values(pkg.header['provides'])
275+
for pkg in spec.packages])))
268276
hdr = spec.sourceHeader
269277
self.srpmname = hdr.sprintf('%{NAME}-%{VERSION}-%{RELEASE}.src.rpm')
270278
self.basename = hdr.sprintf('%{NAME}')

lib/rift/Repository.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,8 @@ class ProjectArchRepositories:
263263
"""
264264
Manipulate repositories defined in a project for a particular architecture.
265265
"""
266-
def __init__(self, config, arch):
266+
def __init__(self, config, arch, extra=None):
267+
267268
self.working = None
268269
self.arch = arch
269270
if config.get('working_repo'):
@@ -275,6 +276,8 @@ def __init__(self, config, arch):
275276
)
276277
self.working.create()
277278
self.supplementaries = []
279+
if extra:
280+
self.supplementaries.append(extra)
278281
repos = config.get('repos', arch=arch)
279282
if repos:
280283
for name, data in repos.items():

0 commit comments

Comments
 (0)