Skip to content

Commit 45dc21a

Browse files
committed
build: track state for build-only checkout step updates
The tracking of recipe changes was not sufficient for build-only updates of checkouts that involve the import-SCM or recipes that utilize checkoutUpdateIf. We were over-cautious and prevented running these updates if *anything* related to the checkout step changed. To make Bobs behaviour more predictable, track the related state more closely. Updates of checkouts in build-only mode are now only prevented if an import-SCM is changed. On the other side, changes to the checkoutScript will trigger the update in build-only mode even if the script is deterministic. Fixes #506.
1 parent 94134e8 commit 45dc21a

File tree

9 files changed

+135
-27
lines changed

9 files changed

+135
-27
lines changed

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/intermediate.py

Lines changed: 16 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

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: |

test/black-box/checkout-update/run.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,25 @@ read -r CNT_ROOT_NEW < dev/src/deterministic-root/1/workspace/root.txt
2525
read -r CNT_NO_UPDATE_NEW < dev/src/deterministic-root/1/workspace/no-update.txt
2626
expect_not_equal "$CNT_ROOT_BASE" "$CNT_ROOT_NEW"
2727
expect_equal "$CNT_NO_UPDATE_BASE" "$CNT_NO_UPDATE_NEW"
28+
CNT_ROOT_BASE="$CNT_ROOT_NEW"
2829

30+
# Changing the checkout script will run the deterministic checkout again
31+
run_bob dev deterministic-root --build-only -DDUMMY=foo
32+
read -r CNT_ROOT_NEW < dev/src/deterministic-root/1/workspace/root.txt
33+
read -r CNT_NO_UPDATE_NEW < dev/src/deterministic-root/1/workspace/no-update.txt
34+
expect_not_equal "$CNT_ROOT_BASE" "$CNT_ROOT_NEW"
35+
expect_equal "$CNT_NO_UPDATE_BASE" "$CNT_NO_UPDATE_NEW"
36+
CNT_ROOT_BASE="$CNT_ROOT_NEW"
37+
38+
# Running with the same checkout script won't run the deterministic checkout
39+
# again
40+
run_bob dev deterministic-root --build-only -DDUMMY=foo
41+
read -r CNT_ROOT_NEW < dev/src/deterministic-root/1/workspace/root.txt
42+
read -r CNT_NO_UPDATE_NEW < dev/src/deterministic-root/1/workspace/no-update.txt
43+
expect_equal "$CNT_ROOT_BASE" "$CNT_ROOT_NEW"
44+
expect_equal "$CNT_NO_UPDATE_BASE" "$CNT_NO_UPDATE_NEW"
45+
46+
### indeterministic tests #####################################################
2947

3048
# checkout sources and remember state of indeterministic checkout
3149
run_bob dev indeterministic-root --build-only
@@ -48,3 +66,4 @@ read -r CNT_ROOT_NEW < dev/src/indeterministic-root/1/workspace/root.txt
4866
read -r CNT_NO_UPDATE_NEW < dev/src/indeterministic-root/1/workspace/no-update.txt
4967
expect_not_equal "$CNT_ROOT_BASE" "$CNT_ROOT_NEW"
5068
expect_equal "$CNT_NO_UPDATE_BASE" "$CNT_NO_UPDATE_NEW"
69+
CNT_ROOT_BASE="$CNT_ROOT_NEW"

test/black-box/import-scm/root.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ root: True
22
checkoutSCM:
33
scm: import
44
url: src
5+
dir: "${IMPORT_DIR:-.}"
56
buildScript: cp -a "$1/"* .
67
packageScript: cp -a "$1/"* .

test/black-box/import-scm/run.sh

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ cleanup
99
check_result()
1010
{
1111
local DATA
12-
read -r DATA < "$prj/output/result.txt"
12+
read -r DATA < "$prj/output/${2:-.}/result.txt"
1313
[[ "$1" = "$DATA" ]] || { echo "Mismatch: $1 <> $DATA" ; exit 1; }
1414
}
1515

@@ -38,3 +38,19 @@ check_result "2"
3838
echo "3" > "$prj/src/result.txt"
3939
run_bob -C "$prj" dev --destination output root --build-only
4040
check_result "3"
41+
42+
# The import SCM is even updated when the checkoutScript changes!
43+
# But the checkoutScript itself must not run.
44+
echo 'checkoutScript: "rm -f result.txt"' >>"$prj/recipes/root.yaml"
45+
echo "4" > "$prj/src/result.txt"
46+
run_bob -C "$prj" dev --destination output root --build-only
47+
check_result "4"
48+
49+
# But when changing the import SCM itself, Bob will refuse to update the
50+
# workspace on build-only builds. This can only be rectified by running without
51+
# --build-only.
52+
echo "5" > "$prj/src/result.txt"
53+
run_bob -C "$prj" dev --destination output root -DIMPORT_DIR=sub --build-only
54+
check_result "4"
55+
run_bob -C "$prj" dev --destination output root -DIMPORT_DIR=sub
56+
check_result "5" "sub"

0 commit comments

Comments
 (0)