Skip to content

Commit f2867ca

Browse files
committed
VM: check remote image Last-Modified header
When starting VM with a remote image and local copy is present, try to send HEAD request on remote URL to get its Last-Modified header for comparison with local copy mtime on filesystem (converted in UTC timezone). If mtime is older than remote URL Last-Modified, remove local copy and download latest VM image.
1 parent 1defa62 commit f2867ca

File tree

4 files changed

+162
-20
lines changed

4 files changed

+162
-20
lines changed

lib/rift/VM.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
from rift.Config import _DEFAULT_VIRTIOFSD
6363
from rift.Repository import ProjectArchRepositories
6464
from rift.TempDir import TempDir
65-
from rift.utils import download_file, setup_dl_opener, message
65+
from rift.utils import last_modified, download_file, setup_dl_opener, message
6666
from rift.run import run_command
6767

6868
__all__ = ['VM']
@@ -389,6 +389,9 @@ def _download(self, force: bool):
389389
if not self.image_is_remote():
390390
return
391391

392+
# Setup proxy if defined
393+
setup_dl_opener(self.proxy, self.no_proxy)
394+
392395
# Check presence of the local copy. If present and force is True, remove it
393396
# to force re-download. Otherwise skip download.
394397
if os.path.exists(self.image_local):
@@ -399,14 +402,27 @@ def _download(self, force: bool):
399402
)
400403
os.unlink(self.image_local)
401404
else:
402-
logging.debug(
403-
"Local copy of VM image is present, skipping download of "
404-
"remote image"
405+
try:
406+
last_remote_modification = last_modified(self._image_src.geturl())
407+
except RiftError as err:
408+
logging.debug(
409+
"Local copy of VM image is present, unable to get remote image "
410+
"modification date because of error (%s), skipping download of "
411+
"remote image",
412+
err
413+
)
414+
return
415+
# Compare local copy UTC mtime with remote image Last-Modified header.
416+
if datetime.datetime.fromtimestamp(
417+
os.path.getmtime(self.image_local), tz=datetime.timezone.utc
418+
).timestamp() > last_remote_modification:
419+
return
420+
logging.info(
421+
"Remote VM image has been updated, removing local copy"
405422
)
406-
return
423+
os.unlink(self.image_local)
424+
407425
message(f"Download remote VM image {self._image_src.geturl()}")
408-
# Setup proxy if defined
409-
setup_dl_opener(self.proxy, self.no_proxy)
410426
# Download VM image
411427
download_file(self._image_src.geturl(), self.image_local)
412428

lib/rift/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import os
3737
import urllib
38+
from datetime import datetime
3839

3940
from rift import RiftError
4041

@@ -66,6 +67,32 @@ def download_file(url, output):
6667
f"URL error while downloading {url}: {str(error)}"
6768
) from error
6869

70+
def last_modified(url):
71+
"""
72+
Return timestamp of Last-Modified header for the given URL. By convention,
73+
Last-Modified is always in GMT/UTC timezone. Raises RiftError when unable
74+
to get or convert Last-Modified header to timestamp.
75+
"""
76+
req = urllib.request.Request(url, method='HEAD')
77+
78+
try:
79+
with urllib.request.urlopen(req) as response:
80+
return datetime.strptime(
81+
response.getheader('Last-Modified'), '%a, %d %b %Y %H:%M:%S %Z'
82+
).timestamp()
83+
except urllib.error.URLError as err:
84+
raise RiftError(
85+
f"Unable to send HTTP HEAD request for URL {url}: {err}"
86+
) from err
87+
except TypeError as err:
88+
raise RiftError(
89+
f"Unable to get Last-Modified header for URL {url}"
90+
) from err
91+
except ValueError as err:
92+
raise RiftError(
93+
f"Unable to convert Last-Modified header to datetime for URL {url}"
94+
) from err
95+
6996
def setup_dl_opener(proxy, no_proxy, fake_user_agent=True):
7097
"""
7198
Setup urllib handler/opener with proxy, no_proxy settings. Also set fake

tests/VM.py

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,8 @@ def test_download(self, mock_message, mock_download_file):
438438

439439
@patch('rift.VM.download_file')
440440
@patch('rift.VM.message')
441-
def test_download_skip_exists(self, mock_message, mock_download_file):
442-
"""Test VM download skipped when local copy is present"""
441+
def test_download_force(self, mock_message, mock_download_file):
442+
"""Test VM download force remove local image when present"""
443443
url = 'http://localhost/path/to/my_image.qcow2'
444444
self.config.set(
445445
'vm',
@@ -455,26 +455,57 @@ def test_download_skip_exists(self, mock_message, mock_download_file):
455455
mock_image_local.return_value = tmpfile.name
456456
self.assertTrue(os.path.exists(vm.image_local))
457457
with self.assertLogs(level='DEBUG') as cm:
458-
vm._download(False)
459-
mock_message.assert_not_called()
460-
mock_download_file.assert_not_called()
458+
vm._download(True)
459+
mock_message.assert_called_once_with(f"Download remote VM image {url}")
460+
mock_download_file.assert_called_once_with(url, vm.image_local)
461461
self.assertIn(
462-
'DEBUG:root:Local copy of VM image is present, skipping download of remote '
462+
'INFO:root:Remove VM image local copy and force re-download for remote '
463463
'image',
464464
cm.output
465465
)
466466

467+
@patch('rift.VM.last_modified')
467468
@patch('rift.VM.download_file')
468469
@patch('rift.VM.message')
469-
def test_download_force(self, mock_message, mock_download_file):
470-
"""Test VM download force remove local image when present"""
470+
def test_download_exists_last_modified_older(
471+
self, mock_message, mock_download_file, mock_last_modified
472+
):
473+
"""Test VM download skipped when local copy is present"""
471474
url = 'http://localhost/path/to/my_image.qcow2'
472475
self.config.set(
473476
'vm',
474477
{
475478
'image': url,
476479
}
477480
)
481+
mock_last_modified.return_value = 0.0
482+
with patch(
483+
'rift.VM.VM.image_local', new_callable=PropertyMock
484+
) as mock_image_local:
485+
vm = VM(self.config, platform.machine())
486+
tmpfile = make_temp_file("")
487+
mock_image_local.return_value = tmpfile.name
488+
self.assertTrue(os.path.exists(vm.image_local))
489+
vm._download(False)
490+
mock_message.assert_not_called()
491+
# Check download_file() has not been called
492+
mock_download_file.assert_not_called()
493+
494+
@patch('rift.VM.last_modified')
495+
@patch('rift.VM.download_file')
496+
@patch('rift.VM.message')
497+
def test_download_exists_last_modified_newer(
498+
self, mock_message, mock_download_file, mock_last_modified
499+
):
500+
"""Test VM download skipped when local copy is present"""
501+
url = 'http://localhost/path/to/my_image.qcow2'
502+
self.config.set(
503+
'vm',
504+
{
505+
'image': url,
506+
}
507+
)
508+
mock_last_modified.return_value = float(2**32)
478509
with patch(
479510
'rift.VM.VM.image_local', new_callable=PropertyMock
480511
) as mock_image_local:
@@ -483,12 +514,44 @@ def test_download_force(self, mock_message, mock_download_file):
483514
mock_image_local.return_value = tmpfile.name
484515
self.assertTrue(os.path.exists(vm.image_local))
485516
with self.assertLogs(level='DEBUG') as cm:
486-
vm._download(True)
517+
vm._download(False)
487518
mock_message.assert_called_once_with(f"Download remote VM image {url}")
488519
mock_download_file.assert_called_once_with(url, vm.image_local)
489520
self.assertIn(
490-
'INFO:root:Remove VM image local copy and force re-download for remote '
491-
'image',
521+
'INFO:root:Remote VM image has been updated, removing local copy',
522+
cm.output
523+
)
524+
525+
@patch('rift.VM.last_modified')
526+
@patch('rift.VM.download_file')
527+
@patch('rift.VM.message')
528+
def test_download_exists_last_modified_error(
529+
self, mock_message, mock_download_file, mock_last_modified
530+
):
531+
"""Test VM download skipped when local copy is present"""
532+
url = 'http://localhost/path/to/my_image.qcow2'
533+
self.config.set(
534+
'vm',
535+
{
536+
'image': url,
537+
}
538+
)
539+
mock_last_modified.side_effect = RiftError("last-modified failure")
540+
with patch(
541+
'rift.VM.VM.image_local', new_callable=PropertyMock
542+
) as mock_image_local:
543+
vm = VM(self.config, platform.machine())
544+
tmpfile = make_temp_file("")
545+
mock_image_local.return_value = tmpfile.name
546+
self.assertTrue(os.path.exists(vm.image_local))
547+
with self.assertLogs(level='DEBUG') as cm:
548+
vm._download(False)
549+
mock_message.assert_not_called()
550+
mock_download_file.assert_not_called()
551+
self.assertIn(
552+
"DEBUG:root:Local copy of VM image is present, unable to get remote image "
553+
"modification date because of error (last-modified failure), skipping "
554+
"download of remote image",
492555
cm.output
493556
)
494557

tests/utils.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
#
44

55
from io import StringIO
6-
from unittest.mock import patch
6+
from unittest.mock import patch, Mock
77

88
from TestUtils import RiftTestCase
9-
from rift.utils import message, banner
9+
from rift import RiftError
10+
from rift.utils import message, banner, last_modified
1011

1112
class UtilsTest(RiftTestCase):
1213

@@ -19,3 +20,38 @@ def test_message(self, mock_stdout):
1920
def test_banner(self, mock_stdout):
2021
banner("bar")
2122
self.assertEqual(mock_stdout.getvalue(), "** bar **\n")
23+
24+
@patch('urllib.request.urlopen')
25+
def test_last_modified(self, mock_urlopen):
26+
mock_response = Mock()
27+
mock_response.getheader.return_value = "Sat, 1 Jan 2000 00:00:00 GMT"
28+
mock_urlopen.return_value.__enter__.return_value = mock_response
29+
self.assertEqual(last_modified("http://test"), 946681200.0)
30+
31+
@patch('urllib.request.urlopen')
32+
def test_last_modified_header_not_found(self, mock_urlopen):
33+
mock_response = Mock()
34+
mock_response.getheader.return_value = None
35+
mock_urlopen.return_value.__enter__.return_value = mock_response
36+
with self.assertRaisesRegex(
37+
RiftError, "^Unable to get Last-Modified header for URL http://test$"
38+
):
39+
last_modified("http://test")
40+
41+
@patch('urllib.request.urlopen')
42+
def test_last_modified_header_conversion_error(self, mock_urlopen):
43+
mock_response = Mock()
44+
mock_response.getheader.return_value = "Sat, 1 Jan 2000 00:00:00"
45+
mock_urlopen.return_value.__enter__.return_value = mock_response
46+
with self.assertRaisesRegex(
47+
RiftError,
48+
"^Unable to convert Last-Modified header to datetime for URL http://test$"
49+
):
50+
last_modified("http://test")
51+
52+
def test_last_modified_url_error(self):
53+
with self.assertRaisesRegex(
54+
RiftError,
55+
"^Unable to send HTTP HEAD request for URL http://localhost: .*$"
56+
):
57+
last_modified("http://localhost")

0 commit comments

Comments
 (0)