Skip to content

Commit 695fd7a

Browse files
authored
Process wheelhouse.txt holistically rather than per-layer (#569)
* Process wheelhouse.txt wholistically rather than per-layer Processing the wheelhouse.txt file from each layer in isolation means that pip can't resolve conflicts, duplicates, or dependency version pinning which spans across layers. This can lead to duplicate versions of libraries being installed which violate constraints to avoid certain upstream bugs. This came up in both of: * https://bugs.launchpad.net/charm-flannel/+bug/1884598 * https://bugs.launchpad.net/charm-octavia/+bug/1884164 And in both cases trying to resolve it on a per-layer or per-charm basis results in a whack-a-mole process of catching all of the layers which indirectly reintroduce the unpinned version of a dependency pinned in a base layer. * Improve error handling * Improve help text on --wheelhouse-per-layer flag * Comment out overridden lines in wheelhouse.txt This allows higher layers to override explicit requirements while still inheriting specs from lower layers. * Fix test
1 parent 2a9eb8a commit 695fd7a

File tree

3 files changed

+80
-9
lines changed

3 files changed

+80
-9
lines changed

charmtools/build/builder.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def __init__(self):
148148
self._top_layer = None
149149
self.hide_metrics = os.environ.get('CHARM_HIDE_METRICS', False)
150150
self.wheelhouse_overrides = None
151+
self.wheelhouse_per_layer = False
151152
self._warned_home = False
152153

153154
@property
@@ -904,11 +905,17 @@ def main(args=None):
904905
"from the interface service.")
905906
parser.add_argument('-n', '--name',
906907
help="Build a charm of 'name' from 'charm'")
907-
parser.add_argument('-r', '--report', action="store_true",
908+
parser.add_argument('-r', '--report', action="store_true", default=True,
908909
help="Show post-build report of changes")
910+
parser.add_argument('-R', '--no-report', action="store_false",
911+
dest='report', default=True,
912+
help="Don't show post-build report of changes")
909913
parser.add_argument('-w', '--wheelhouse-overrides', type=path,
910914
help="Provide a wheelhouse.txt file with overrides "
911915
"for the built wheelhouse")
916+
parser.add_argument('-W', '--wheelhouse-per-layer', action="store_true",
917+
help="Deprecated: Use original wheelhouse processing "
918+
"method (see PR juju/charm-tools#569)")
912919
parser.add_argument('-v', '--verbose', action='store_true', default=False,
913920
help="Increase output (same as -l DEBUG)")
914921
parser.add_argument('--debug', action='store_true',
@@ -932,6 +939,8 @@ def main(args=None):
932939
build.layer_index)
933940
LayerFetcher.NO_LOCAL_LAYERS = build.no_local_layers
934941

942+
WheelhouseTactic.per_layer = build.wheelhouse_per_layer
943+
935944
configLogging(build)
936945

937946
try:

charmtools/build/tactics.py

+69-8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from inspect import getargspec
88

99
from path import Path as path
10+
from pkg_resources import Requirement
1011
from ruamel import yaml
1112
from charmtools import utils
1213
from charmtools.build.errors import BuildError
@@ -1036,11 +1037,13 @@ class WheelhouseTactic(ExactMatch, Tactic):
10361037
kind = "dynamic"
10371038
FILENAME = 'wheelhouse.txt'
10381039
removed = [] # has to be class level to affect all tactics during signing
1040+
per_layer = False
10391041

10401042
def __init__(self, *args, **kwargs):
10411043
super(WheelhouseTactic, self).__init__(*args, **kwargs)
10421044
self.tracked = []
10431045
self.previous = []
1046+
self.lines = None
10441047
self._venv = None
10451048
self.purge_wheels = False
10461049

@@ -1051,14 +1054,48 @@ def __str__(self):
10511054
def combine(self, existing):
10521055
"" # suppress inherited doc
10531056
self.previous = existing.previous + [existing]
1057+
existing.read()
1058+
self.read()
1059+
new_pkgs = set()
1060+
for line in self.lines:
1061+
try:
1062+
req = Requirement.parse(line)
1063+
new_pkgs.add(req.project_name)
1064+
except ValueError:
1065+
pass # ignore comments, blank lines, etc
1066+
existing_lines = []
1067+
for line in existing.lines:
1068+
try:
1069+
req = Requirement.parse(line)
1070+
# new explicit reqs will override existing ones
1071+
if req.project_name not in new_pkgs:
1072+
existing_lines.append(line)
1073+
else:
1074+
existing_lines.append('# {} # overridden by {}'.format(
1075+
line, self.layer.url))
1076+
except ValueError:
1077+
existing_lines.append(line) # ignore comments, blank lines, &c
1078+
self.lines = existing_lines + self.lines
10541079
return self
10551080

1081+
def read(self):
1082+
if self.lines is None:
1083+
src = path(self.entity)
1084+
if src.exists():
1085+
self.lines = (['# ' + self.layer.url] +
1086+
src.lines(retain=False) +
1087+
[''])
1088+
else:
1089+
self.lines = []
1090+
10561091
def _add(self, wheelhouse, *reqs):
10571092
with utils.tempdir(chdir=False) as temp_dir:
10581093
# put in a temp dir first to ensure we track all of the files
10591094
self._pip('download', '--no-binary', ':all:', '-d', temp_dir,
10601095
*reqs)
1096+
log.debug('Copying wheels:')
10611097
for wheel in temp_dir.files():
1098+
log.debug(' ' + wheel.name)
10621099
dest = wheelhouse / wheel.basename()
10631100
if dest in self.tracked:
10641101
return
@@ -1076,9 +1113,12 @@ def _add(self, wheelhouse, *reqs):
10761113
def _run_in_venv(self, *args):
10771114
assert self._venv is not None
10781115
# have to use bash to activate the venv properly first
1079-
return utils.Process(('bash', '-c', ' '.join(
1116+
res = utils.Process(('bash', '-c', ' '.join(
10801117
('.', self._venv / 'bin' / 'activate', ';') + args
1081-
))).exit_on_error()()
1118+
)))()
1119+
if res.exit_code != 0:
1120+
raise BuildError(res.output)
1121+
return res
10821122

10831123
def _pip(self, *args):
10841124
return self._run_in_venv('pip3', *args)
@@ -1092,21 +1132,42 @@ def __call__(self):
10921132
utils.Process(
10931133
('virtualenv', '--python', 'python3', self._venv)
10941134
).exit_on_error()()
1135+
if self.per_layer:
1136+
self._process_per_layer(wheelhouse)
1137+
else:
1138+
self._process_combined(wheelhouse)
1139+
# clean up
1140+
if create_venv:
1141+
self._venv.rmtree_p()
1142+
self._venv = None
1143+
1144+
def _process_per_layer(self, wheelhouse):
10951145
# we are the top layer; process all lower layers first
10961146
for tactic in self.previous:
10971147
tactic()
10981148
# process this layer
1149+
log.debug('Processing wheelhouse for {}'.format(self.layer.url))
10991150
self._add(wheelhouse, '-r', self.entity)
1100-
# clean up
1101-
if create_venv:
1102-
self._venv.rmtree_p()
1103-
self._venv = None
1151+
1152+
def _process_combined(self, wheelhouse):
1153+
log.debug('Processing wheelhouse:')
1154+
self.read()
1155+
for line in self.lines:
1156+
log.debug(' ' + line.strip())
1157+
with utils.tempdir(chdir=False) as temp_dir:
1158+
wh_file = temp_dir / 'wheelhouse.txt'
1159+
wh_file.write_lines(self.lines)
1160+
self._add(wheelhouse, '-r', wh_file)
1161+
wh_file.move(self.target.directory / 'wheelhouse.txt')
11041162

11051163
def sign(self):
11061164
"" # suppress inherited doc
11071165
sigs = {}
1108-
for tactic in self.previous:
1109-
sigs.update(tactic.sign())
1166+
if self.per_layer:
1167+
for tactic in self.previous:
1168+
sigs.update(tactic.sign())
1169+
else:
1170+
sigs.update(super().sign())
11101171
for d in self.tracked:
11111172
if d in self.removed:
11121173
continue

tests/test_build.py

+1
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ def test_wheelhouse(self, Process, mkdtemp, rmtree_p, ph, pi, pv):
434434
bu.hide_metrics = True
435435
bu.report = False
436436
bu.wheelhouse_overrides = self.dirname / 'wh-over.txt'
437+
Process.return_value.return_value.exit_code = 0
437438

438439
# remove the sign phase
439440
bu.PHASES = bu.PHASES[:-2]

0 commit comments

Comments
 (0)