patches: make re-applied patches idempotent (#26784)

We use POSIX `patch` to apply patches to files when building, but
`patch` by default prompts the user when it looks like a patch
has already been applied. This means that:

1. If a patch lands in upstream and we don't disable it
   in a package, the build will start failing.
2. `spack develop` builds (which keep the stage around) will
   fail the second time you try to use them.

To avoid that, we can run `patch` with `-N` (also called
`--forward`, but the long option is not in POSIX). `-N` causes
`patch` to just ignore patches that have already been applied.
This *almost* makes `patch` idempotent, except that it returns 1
when it detects already applied patches with `-N`, so we have to
look at the output of the command to see if it's safe to ignore
the error.

- [x] Remove non-POSIX `-s` option from `patch` call
- [x] Add `-N` option to `patch`
- [x] Ignore error status when `patch` returns 1 due to `-N`
- [x] Add tests for applying a patch twice and applying a bad patch
- [x] Tweak `spack.util.executable` so that it saves the error that
      *would have been* raised with `fail_on_error=True`. This lets
      us easily re-raise it.

Co-authored-by: Greg Becker <becker33@llnl.gov>
This commit is contained in:
Todd Gamblin 2021-10-18 16:11:42 -07:00 committed by GitHub
parent a118e799ae
commit c5ca0db27f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 25 deletions

View File

@ -7,6 +7,7 @@
import inspect
import os
import os.path
import sys
import llnl.util.filesystem
import llnl.util.lang
@ -25,6 +26,12 @@
def apply_patch(stage, patch_path, level=1, working_dir='.'):
"""Apply the patch at patch_path to code in the stage.
Spack runs ``patch`` with ``-N`` so that it does not reject already-applied
patches. This is useful for develop specs, so that the build does not fail
due to repeated application of patches, and for easing requirements on patch
specifications in packages -- packages won't stop working when patches we
previously had to apply land in upstream.
Args:
stage (spack.stage.Stage): stage with code that will be patched
patch_path (str): filesystem location for the patch to apply
@ -34,10 +41,31 @@ def apply_patch(stage, patch_path, level=1, working_dir='.'):
"""
patch = which("patch", required=True)
with llnl.util.filesystem.working_dir(stage.source_path):
patch('-s',
'-p', str(level),
'-i', patch_path,
'-d', working_dir)
output = patch(
'-N', # don't reject already-applied patches
'-p', str(level), # patch level (directory depth)
'-i', patch_path, # input source is the patch file
'-d', working_dir, # patch chdir's to here before patching
output=str,
fail_on_error=False,
)
if patch.returncode != 0:
# `patch` returns 1 both:
# a) when an error applying a patch, and
# b) when -N is supplied and the patch has already been applied
#
# It returns > 1 if there's something more serious wrong.
#
# So, the best we can do is to look for return code 1, look for output
# indicating that the patch was already applied, and ignore the error
# if we see it. Most implementations (BSD and GNU) seem to have the
# same messages, so we expect these checks to be reliable.
if patch.returncode > 1 or not any(
s in output for s in ("Skipping patch", "ignored")
):
sys.stderr.write(output)
raise patch.error
class Patch(object):

View File

@ -15,8 +15,9 @@
import spack.paths
import spack.repo
import spack.util.compression
import spack.util.crypto
from spack.spec import Spec
from spack.stage import Stage
from spack.stage import DIYStage, Stage
from spack.util.executable import Executable
# various sha256 sums (using variables for legibility)
@ -33,6 +34,43 @@
url2_archive_sha256 = 'abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'
# some simple files for patch tests
file_to_patch = """\
first line
second line
"""
patch_file = """\
diff a/foo.txt b/foo-expected.txt
--- a/foo.txt
+++ b/foo-expected.txt
@@ -1,2 +1,3 @@
+zeroth line
first line
-second line
+third line
"""
expected_patch_result = """\
zeroth line
first line
third line
"""
file_patch_cant_apply_to = """\
this file
is completely different
from anything in the files
or patch above
"""
def write_file(filename, contents):
"""Helper function for setting up tests."""
with open(filename, 'w') as f:
f.write(contents)
@pytest.fixture()
def mock_patch_stage(tmpdir_factory, monkeypatch):
# Don't disrupt the spack install directory with tests.
@ -67,19 +105,9 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256):
mkdirp(stage.source_path)
with working_dir(stage.source_path):
# write a file to be patched
with open('foo.txt', 'w') as f:
f.write("""\
first line
second line
""")
# write the expected result of patching.
with open('foo-expected.txt', 'w') as f:
f.write("""\
zeroth line
first line
third line
""")
write_file("foo.txt", file_to_patch)
write_file("foo-expected.txt", expected_patch_result)
# apply the patch and compare files
patch.fetch()
patch.apply(stage)
@ -89,6 +117,47 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256):
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
def test_apply_patch_twice(mock_patch_stage, tmpdir):
"""Ensure that patch doesn't fail if applied twice."""
stage = DIYStage(str(tmpdir))
with tmpdir.as_cwd():
write_file("foo.txt", file_to_patch)
write_file("foo-expected.txt", expected_patch_result)
write_file("foo.patch", patch_file)
FakePackage = collections.namedtuple(
'FakePackage', ['name', 'namespace', 'fullname'])
fake_pkg = FakePackage('fake-package', 'test', 'fake-package')
def make_patch(filename):
path = os.path.realpath(str(tmpdir.join(filename)))
url = 'file://' + path
sha256 = spack.util.crypto.checksum("sha256", path)
return spack.patch.UrlPatch(fake_pkg, url, sha256=sha256)
# apply the first time
patch = make_patch('foo.patch')
patch.fetch()
patch.apply(stage)
with working_dir(stage.source_path):
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
# ensure apply() is idempotent
patch.apply(stage)
with working_dir(stage.source_path):
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
# now write a file that can't be patched
with working_dir(stage.source_path):
write_file("foo.txt", file_patch_cant_apply_to)
# this application should fail with a real error
with pytest.raises(spack.util.executable.ProcessError):
patch.apply(stage)
def test_patch_in_spec(mock_packages, config):
"""Test whether patches in a package appear in the spec."""
spec = Spec('patch')

View File

@ -92,6 +92,11 @@ def checksum(hashlib_algo, filename, **kwargs):
"""Returns a hex digest of the filename generated using an
algorithm from hashlib.
"""
if isinstance(hashlib_algo, str):
if hashlib_algo not in hashes:
raise ValueError("Invalid hash algorithm: ", hashlib_algo)
hashlib_algo = hash_fun_for_algo(hashlib_algo)
block_size = kwargs.get('block_size', 2**20)
hasher = hashlib_algo()
with open(filename, 'rb') as file:

View File

@ -27,6 +27,7 @@ def __init__(self, name):
from spack.util.environment import EnvironmentModifications # no cycle
self.default_envmod = EnvironmentModifications()
self.returncode = None
self.error = None # saved ProcessError when fail_on_error
if not self.exe:
raise ProcessError("Cannot construct executable for '%s'" % name)
@ -90,7 +91,8 @@ def __call__(self, *args, **kwargs):
the environment (neither requires nor precludes env)
fail_on_error (bool): Raise an exception if the subprocess returns
an error. Default is True. The return code is available as
``exe.returncode``
``exe.returncode``, and a saved ``ProcessError`` that would
have been raised is in ``exe.error``.
ignore_errors (int or list): A list of error codes to ignore.
If these codes are returned, this process will not raise
an exception even if ``fail_on_error`` is set to ``True``
@ -213,7 +215,7 @@ def streamify(arg, mode):
sys.stderr.write(errstr)
rc = self.returncode = proc.returncode
if fail_on_error and rc != 0 and (rc not in ignore_errors):
if rc != 0:
long_msg = cmd_line_string
if result:
# If the output is not captured in the result, it will have
@ -222,8 +224,11 @@ def streamify(arg, mode):
# stdout/stderr (e.g. if 'output' is not specified)
long_msg += '\n' + result
raise ProcessError('Command exited with status %d:' %
proc.returncode, long_msg)
self.error = ProcessError(
'Command exited with status %d:' % proc.returncode, long_msg
)
if fail_on_error and (rc not in ignore_errors):
raise self.error
return result
@ -232,10 +237,15 @@ def streamify(arg, mode):
'%s: %s' % (self.exe[0], e.strerror), 'Command: ' + cmd_line_string)
except subprocess.CalledProcessError as e:
self.error = ProcessError(
str(e),
'\nExit status %d when invoking command: %s' % (
proc.returncode,
cmd_line_string,
),
)
if fail_on_error:
raise ProcessError(
str(e), '\nExit status %d when invoking command: %s' %
(proc.returncode, cmd_line_string))
raise self.error
finally:
if close_ostream: