Skip to content

Commit d9deb77

Browse files
authored
Merge pull request #521 from jkloetzke/track-updates
Run changed checkoutScript in build-only mode
2 parents 33fdae7 + 1cca4ea commit d9deb77

File tree

14 files changed

+179
-30
lines changed

14 files changed

+179
-30
lines changed

doc/manual/policies.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,3 +563,30 @@ Old behavior
563563
New behavior
564564
Bob checks if the ``commit`` and / or ``tag`` is on the configured ``branch`` and
565565
performs a checkout of the ``commit`` on a local ``branch``.
566+
567+
fixImportScmVariant
568+
~~~~~~~~~~~~~~~~~~~
569+
570+
Introduced in: 0.23
571+
572+
Bob uses the concept of a :term:`Variant-Id` to track *how* a package is built.
573+
This includes the sub-directory in which a particular SCM is checked out. So if
574+
the ``dir`` attribute of an SCM changes, the respective Variant-Id of the
575+
package changes too. Bob versions before 0.23 contained a bug where the ``dir``
576+
attribute of an ``import`` SCM was not included in the Variant-Id calculation.
577+
This can cause build failures or wrongly used binary artifacts if just the
578+
``dir`` attribute of an ``import`` SCM is changed.
579+
580+
Fixing the bug will affect the :term:`Variant-Id` of all packages that use an
581+
``import`` SCM. This implies that binary artifacts of such packages will need
582+
to be built again. It also transitively affects packages that depend on
583+
packages that utilize an ``import`` SCM.
584+
585+
Old behavior
586+
Changes to the ``dir`` attribute of an ``import`` SCM do not cause rebuilds
587+
of the affected package. Wrong sharing of binary artifacts for such packages
588+
may occur.
589+
590+
New behavior
591+
Changes to the ``dir`` attribute of an ``import`` SCM behave the same as for
592+
any other SCM.

pym/bob/builder.py

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,67 @@ def hashWorkspace(step):
6565
return hashDirectory(step.getStoragePath(),
6666
os.path.join(os.path.dirname(step.getWorkspacePath()), "cache.bin"))
6767

68+
CHECKOUT_STATE_VARIANT_ID = None # Key in checkout directory state for step variant-id
69+
CHECKOUT_STATE_BUILD_ONLY = 1 # Key for checkout state of build-only builds
70+
71+
# Keys in checkout getDirectoryState that are not directories
72+
CHECKOUT_NON_DIR_KEYS = {CHECKOUT_STATE_VARIANT_ID, CHECKOUT_STATE_BUILD_ONLY}
73+
6874
def compareDirectoryState(left, right):
6975
"""Compare two directory states while ignoring the SCM specs.
7076
7177
The SCM specs might change even though the digest stays the same (e.g. the
7278
URL changes but the commit id stays the same). This function filters the
7379
spec to detect real changes.
80+
81+
It also compares the CHECKOUT_STATE_VARIANT_ID to detect recipe changes. In
82+
contrast, the CHECKOUT_STATE_BUILD_ONLY sub-state is ignored as this is
83+
only relevant for build-only builds that have their dedicated functions
84+
below.
7485
"""
75-
left = { d : v[0] for d, v in left.items() }
76-
right = { d : v[0] for d, v in right.items() }
86+
left = { d : v[0] for d, v in left.items() if d != CHECKOUT_STATE_BUILD_ONLY }
87+
right = { d : v[0] for d, v in right.items() if d != CHECKOUT_STATE_BUILD_ONLY }
7788
return left == right
7889

90+
def checkoutsFromState(state):
91+
"""Return only the tuples related to SCMs from the checkout state."""
92+
return [ (k, v) for k, v in state.items() if k not in CHECKOUT_NON_DIR_KEYS ]
93+
94+
def checkoutBuildOnlyState(checkoutStep, inputHashes):
95+
"""Obtain state for build-only checkout updates.
96+
97+
The assumption is that we can run updates as long as all local SCMs stayed
98+
the same. As this is just for build-only builds, we can assume that
99+
dependencies have been setup correctly (direct deps, tools).
100+
101+
Because of the fixImportScmVariant bug, we include the directory too. This
102+
should not have been necessary otherwise. Needs to return a tuple because
103+
that's what is expected in the checkout SCM state (will be silently
104+
upgraded to a tuple by BobState otherwise).
105+
"""
106+
return ("\n".join("{} {}".format(scm.getDirectory(), scm.asDigestScript())
107+
for scm in checkoutStep.getScmList()
108+
if scm.isLocal()),
109+
checkoutStep.getUpdateScriptDigest(),
110+
inputHashes)
111+
112+
def checkoutBuildOnlyStateCompatible(left, right):
113+
"""Returns True if it's safe to run build-only checkout updates.
114+
115+
Current policy is to just require that local SCMs are compatible. A
116+
different step variant-id, changed update script or dependency changes do
117+
*not* prohibit an in-place update.
118+
"""
119+
left = left.get(CHECKOUT_STATE_BUILD_ONLY, (None, None, None))[0]
120+
right = right.get(CHECKOUT_STATE_BUILD_ONLY, (None, None, None))[0]
121+
return left == right
122+
123+
def checkoutBuildOnlyStateChanged(left, right):
124+
"""Returns True if the update script has changed"""
125+
left = left.get(CHECKOUT_STATE_BUILD_ONLY, None)
126+
right = right.get(CHECKOUT_STATE_BUILD_ONLY, None)
127+
return left != right
128+
79129
def dissectPackageInputState(oldInputBuildId):
80130
"""Take a package step input hashes and convert them to a common
81131
representation.
@@ -1016,17 +1066,25 @@ async def _cookCheckoutStep(self, checkoutStep, depth):
10161066
checkoutExecuted = False
10171067
checkoutDigest = checkoutStep.getVariantId()
10181068
checkoutState = checkoutStep.getScmDirectories().copy()
1019-
checkoutState[None] = (checkoutDigest, None)
1069+
checkoutState[CHECKOUT_STATE_VARIANT_ID] = (checkoutDigest, None)
1070+
checkoutState[CHECKOUT_STATE_BUILD_ONLY] = checkoutBuildOnlyState(checkoutStep, checkoutInputHashes)
10201071
if self.__buildOnly and (BobState().getResultHash(prettySrcPath) is not None):
1021-
inputChanged = checkoutInputHashes != BobState().getInputHashes(prettySrcPath)
1072+
inputChanged = checkoutBuildOnlyStateChanged(checkoutState, oldCheckoutState)
10221073
rehash = lambda: hashWorkspace(checkoutStep)
1023-
if not compareDirectoryState(checkoutState, oldCheckoutState):
1074+
if checkoutStep.mayUpdate(inputChanged, BobState().getResultHash(prettySrcPath), rehash):
1075+
if checkoutBuildOnlyStateCompatible(checkoutState, oldCheckoutState):
1076+
with stepExec(checkoutStep, "UPDATE",
1077+
"{} {}".format(prettySrcPath, overridesString)) as a:
1078+
await self._runShell(checkoutStep, "checkout", a, mode=InvocationMode.UPDATE)
1079+
newCheckoutState = oldCheckoutState.copy()
1080+
newCheckoutState[CHECKOUT_STATE_BUILD_ONLY] = checkoutState[CHECKOUT_STATE_BUILD_ONLY]
1081+
BobState().setDirectoryState(prettySrcPath, newCheckoutState)
1082+
else:
1083+
stepMessage(checkoutStep, "UPDATE", "WARNING: recipe changed - cannot update ({})"
1084+
.format(prettySrcPath), WARNING)
1085+
elif not compareDirectoryState(checkoutState, oldCheckoutState):
10241086
stepMessage(checkoutStep, "CHECKOUT", "WARNING: recipe changed but skipped due to --build-only ({})"
10251087
.format(prettySrcPath), WARNING)
1026-
elif checkoutStep.mayUpdate(inputChanged, BobState().getResultHash(prettySrcPath), rehash):
1027-
with stepExec(checkoutStep, "UPDATE",
1028-
"{} {}".format(prettySrcPath, overridesString)) as a:
1029-
await self._runShell(checkoutStep, "checkout", a, mode=InvocationMode.UPDATE)
10301088
else:
10311089
stepMessage(checkoutStep, "CHECKOUT", "skipped due to --build-only ({}) {}".format(prettySrcPath, overridesString),
10321090
SKIPPED, IMPORTANT)
@@ -1036,8 +1094,7 @@ async def _cookCheckoutStep(self, checkoutStep, depth):
10361094

10371095
if self.__cleanCheckout:
10381096
# check state of SCMs and invalidate if the directory is dirty
1039-
for (scmDir, (scmDigest, scmSpec)) in oldCheckoutState.copy().items():
1040-
if scmDir is None: continue
1097+
for (scmDir, (scmDigest, scmSpec)) in checkoutsFromState(oldCheckoutState):
10411098
if scmDigest != checkoutState.get(scmDir, (None, None))[0]: continue
10421099
if not os.path.exists(os.path.join(prettySrcPath, scmDir)): continue
10431100
if scmMap[scmDir].status(checkoutStep.getWorkspacePath()).dirty:
@@ -1050,8 +1107,8 @@ async def _cookCheckoutStep(self, checkoutStep, depth):
10501107
not compareDirectoryState(checkoutState, oldCheckoutState) or
10511108
(checkoutInputHashes != BobState().getInputHashes(prettySrcPath))):
10521109
# Switch or move away old or changed source directories
1053-
for (scmDir, (scmDigest, scmSpec)) in oldCheckoutState.copy().items():
1054-
if (scmDir is not None) and (scmDigest != checkoutState.get(scmDir, (None, None))[0]):
1110+
for (scmDir, (scmDigest, scmSpec)) in checkoutsFromState(oldCheckoutState):
1111+
if scmDigest != checkoutState.get(scmDir, (None, None))[0]:
10551112
scmPath = os.path.normpath(os.path.join(prettySrcPath, scmDir))
10561113
canSwitch = (scmDir in scmMap) and scmDigest and \
10571114
scmSpec is not None and \
@@ -1087,8 +1144,8 @@ async def _cookCheckoutStep(self, checkoutStep, depth):
10871144
# workspace. Do it before we store the new SCM state to
10881145
# check again if the step is rerun.
10891146
if not checkoutStep.JENKINS:
1090-
for scmDir in checkoutState.keys():
1091-
if scmDir is None or scmDir == ".": continue
1147+
for scmDir in [ k for k,v in checkoutsFromState(checkoutState)]:
1148+
if scmDir == ".": continue
10921149
if scmDir in oldCheckoutState: continue
10931150
scmPath = os.path.normpath(os.path.join(prettySrcPath, scmDir))
10941151
if os.path.exists(scmPath):
@@ -1100,7 +1157,7 @@ async def _cookCheckoutStep(self, checkoutStep, depth):
11001157
# record the SCM directories as some checkouts might already
11011158
# succeeded before the step ultimately fails.
11021159
BobState().setDirectoryState(prettySrcPath,
1103-
{ d:s for (d,s) in checkoutState.items() if d is not None })
1160+
{ d:s for (d,s) in checkoutState.items() if d != CHECKOUT_STATE_VARIANT_ID })
11041161

11051162
# Forge checkout result before we run the step again.
11061163
# Normally the correct result is set directly after the

pym/bob/cmds/build/clean.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
# SPDX-License-Identifier: GPL-3.0-or-later
55

6-
from ...builder import LocalBuilder
6+
from ...builder import LocalBuilder, checkoutsFromState
77
from ...input import RecipeSet
88
from ...scm import getScm, ScmTaint, ScmStatus
99
from ...share import getShare
@@ -55,7 +55,6 @@ def walk(package):
5555
return paths
5656

5757
def checkSCM(workspace, scmDir, scmSpec, verbose):
58-
if scmDir is None: return True
5958
if scmSpec is not None:
6059
status = getScm(scmSpec).status(workspace)
6160
else:
@@ -78,7 +77,7 @@ def checkSCM(workspace, scmDir, scmSpec, verbose):
7877
def checkRegularSource(workspace, verbose):
7978
ret = True
8079
state = BobState().getDirectoryState(workspace, True)
81-
for (scmDir, (scmDigest, scmSpec)) in state.items():
80+
for (scmDir, (scmDigest, scmSpec)) in checkoutsFromState(state):
8281
if not checkSCM(workspace, scmDir, scmSpec, verbose):
8382
ret = False
8483

pym/bob/cmds/build/status.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
# SPDX-License-Identifier: GPL-3.0-or-later
55

6-
from ...builder import LocalBuilder
6+
from ...builder import LocalBuilder, checkoutsFromState
77
from ...input import RecipeSet
88
from ...scm import getScm, ScmTaint, ScmStatus
99
from ...state import BobState
@@ -112,8 +112,7 @@ def __showCheckoutStep(self, pp, checkoutStep):
112112
# First scan old checkout state. This is what the user is most
113113
# interested in. The recipe might have changed compared to the
114114
# persisted state!
115-
for (scmDir, (scmDigest, scmSpec)) in oldCheckoutState.items():
116-
if scmDir is None: continue # checkoutScript state -> ignored
115+
for (scmDir, (scmDigest, scmSpec)) in checkoutsFromState(oldCheckoutState):
117116
if not os.path.exists(os.path.join(workspace, scmDir)): continue
118117

119118
if scmDigest == checkoutState.get(scmDir, (None, None))[0]:
@@ -208,10 +207,10 @@ def showAllDirs(self, showAttic):
208207
BobState().delDirectoryState(workspace)
209208
continue
210209

211-
# Upgrade from old format without scmSpec. Drop None dir.
210+
# Upgrade from old format without scmSpec.
212211
dirState = sorted(
213212
(dir, state) if isinstance(state, tuple) else (dir, (state, None))
214-
for dir,state in dirState.items() if dir is not None)
213+
for dir,state in checkoutsFromState(dirState))
215214
for (scmDir, (scmDigest, scmSpec)) in dirState:
216215
scmDir = os.path.join(workspace, scmDir)
217216
if scmSpec is not None:

pym/bob/input.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2985,6 +2985,7 @@ class RecipeSet:
29852985
schema.Optional('scmIgnoreUser') : bool,
29862986
schema.Optional('pruneImportScm') : bool,
29872987
schema.Optional('gitCommitOnBranch') : bool,
2988+
schema.Optional('fixImportScmVariant') : bool,
29882989
},
29892990
error="Invalid policy specified! Maybe your Bob is too old?"
29902991
),
@@ -3112,6 +3113,11 @@ def __init__(self):
31123113
InfoOnce("gitCommitOnBranch policy not set. Will not check if commit / tag is on configured branch.",
31133114
help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#gitcommitonbranch for more information.")
31143115
),
3116+
'fixImportScmVariant' : (
3117+
"0.22.1.dev34",
3118+
InfoOnce("fixImportScmVariant policy not set. Recipe variant calculation w/ import SCM is boguous.",
3119+
help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#fiximportscmvariant for more information.")
3120+
),
31153121
}
31163122
self.__buildHooks = {}
31173123
self.__sandboxOpts = {}

pym/bob/intermediate.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ def mayUpdate(self, inputChanged, oldHash, rehash):
298298
return True
299299
if not self.getUpdateScript():
300300
return False
301-
if not self.isUpdateDeterministic():
301+
if not self.isUpdateDeterministic() or inputChanged:
302302
return True
303303
return rehash() != oldHash
304304

@@ -389,6 +389,21 @@ def calcLiveBuildId(self):
389389
h.update(liveBId)
390390
return h.digest()
391391

392+
def getUpdateScriptDigest(self):
393+
"""Return a digest that tracks relevant changes to the update script behaviour"""
394+
h = hashlib.sha1()
395+
script = self.getUpdateScript()
396+
if script:
397+
h.update(struct.pack("<I", len(script)))
398+
h.update(script.encode("utf8"))
399+
else:
400+
h.update(b'\x00\x00\x00\x00')
401+
h.update(struct.pack("<I", len(self.__data['digestEnv'])))
402+
for (key, val) in sorted(self.__data['digestEnv'].items()):
403+
h.update(struct.pack("<II", len(key), len(val)))
404+
h.update((key+val).encode('utf8'))
405+
return h.digest()
406+
392407

393408
class PackageIR(AbstractIR):
394409

@@ -549,6 +564,7 @@ def fromRecipeSet(cls, recipeSet):
549564
'tidyUrlScm' : recipeSet.getPolicy('tidyUrlScm'),
550565
'sandboxFingerprints' : recipeSet.getPolicy('sandboxFingerprints'),
551566
'gitCommitOnBranch' : recipeSet.getPolicy('gitCommitOnBranch'),
567+
'fixImportScmVariant' : recipeSet.getPolicy('fixImportScmVariant'),
552568
}
553569
self.__data['archiveSpec'] = recipeSet.archiveSpec()
554570
self.__data['envWhiteList'] = sorted(recipeSet.envWhiteList())

pym/bob/scm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def getScm(spec, overrides=[], recipeSet=None):
5151
elif scm == "import":
5252
return ImportScm(spec, overrides,
5353
recipeSet and recipeSet.getPolicy('pruneImportScm'),
54+
recipeSet and recipeSet.getPolicy('fixImportScmVariant'),
5455
recipeSet and recipeSet.getProjectRoot())
5556
elif scm == "svn":
5657
return SvnScm(spec, overrides)

pym/bob/scm/imp.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,14 @@ class ImportScm(Scm):
127127

128128
SCHEMA = schema.Schema({**__SCHEMA, **DEFAULTS})
129129

130-
def __init__(self, spec, overrides=[], pruneDefault=None, projectRoot=""):
130+
def __init__(self, spec, overrides=[], pruneDefault=None, fixDigestBug=False, projectRoot=""):
131131
super().__init__(spec, overrides)
132132
self.__url = spec["url"]
133133
self.__dir = spec.get("dir", ".")
134134
self.__prune = spec.get("prune", pruneDefault or False)
135135
self.__data = spec.get("__data")
136136
self.__projectRoot = spec.get("__projectRoot", projectRoot)
137+
self.__fixDigestBug = fixDigestBug
137138

138139
def getProperties(self, isJenkins):
139140
ret = super().getProperties(isJenkins)
@@ -162,7 +163,10 @@ async def invoke(self, invoker):
162163
unpackTree(self.__data, dest)
163164

164165
def asDigestScript(self):
165-
return self.__url
166+
if self.__fixDigestBug:
167+
return self.__url + " " + self.__dir
168+
else:
169+
return self.__url
166170

167171
def getDirectory(self):
168172
return self.__dir

pym/bob/state.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,9 @@ class _BobState():
289289
# 5 -> 6: build state stores predicted live-build-ids too
290290
# 6 -> 7: amended directory state for source steps, store attic directories
291291
# 7 -> 8: normalize attic directories
292+
# 8 -> 9: checkout directory state tracks import scm / update script state
292293
MIN_VERSION = 2
293-
CUR_VERSION = 8
294+
CUR_VERSION = 9
294295

295296
VERSION_SINCE_ATTIC_TRACKED = 7
296297

test/black-box/checkout-update/recipes/deterministic-root.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ root: True
22
inherit: [update, no-update]
33
checkoutDeterministic: True
44
checkoutUpdateIf: True
5+
checkoutVars: [DUMMY]
56
checkoutScript: |
67
bumpCounter "root.txt"
78
buildScript: |

0 commit comments

Comments
 (0)