Skip to content

Commit 95751f7

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. If mtime is older than remote URL Last-Modified, remove local copy and download latest VM image.
1 parent c675131 commit 95751f7

File tree

4 files changed

+173
-19
lines changed

4 files changed

+173
-19
lines changed

lib/rift/VM.py

Lines changed: 28 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,32 @@ 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 mtime with remote image Last-Modified header.
416+
local_copy_mtime = int(os.path.getmtime(self.image_local))
417+
if local_copy_mtime > last_remote_modification:
418+
logging.debug(
419+
"Local copy of VM image is already updated (%d > %d), "
420+
"skipping download of remote image",
421+
int(local_copy_mtime),
422+
int(last_remote_modification)
423+
)
424+
return
425+
logging.info(
426+
"Remote VM image has been updated, removing local copy"
405427
)
406-
return
428+
os.unlink(self.image_local)
429+
407430
message(f"Download remote VM image {self._image_src.geturl()}")
408-
# Setup proxy if defined
409-
setup_dl_opener(self.proxy, self.no_proxy)
410431
# Download VM image
411432
download_file(self._image_src.geturl(), self.image_local)
412433

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, timezone
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 number of seconds since epoch of Last-Modified header for the given
73+
URL. By convention, Last-Modified is always in GMT/UTC timezone. Raises
74+
RiftError when unable 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 int(datetime.strptime(
81+
response.getheader('Last-Modified'), '%a, %d %b %Y %H:%M:%S %Z'
82+
).replace(tzinfo=timezone.utc).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: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,38 @@ 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):
441+
def test_download_force(self, mock_message, mock_download_file):
442+
"""Test VM download force remove local image when present"""
443+
url = 'http://localhost/path/to/my_image.qcow2'
444+
self.config.set(
445+
'vm',
446+
{
447+
'image': url,
448+
}
449+
)
450+
with patch(
451+
'rift.VM.VM.image_local', new_callable=PropertyMock
452+
) as mock_image_local:
453+
vm = VM(self.config, platform.machine())
454+
tmpfile = make_temp_file("")
455+
mock_image_local.return_value = tmpfile.name
456+
self.assertTrue(os.path.exists(vm.image_local))
457+
with self.assertLogs(level='DEBUG') as cm:
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)
461+
self.assertIn(
462+
'INFO:root:Remove VM image local copy and force re-download for remote '
463+
'image',
464+
cm.output
465+
)
466+
467+
@patch('rift.VM.last_modified')
468+
@patch('rift.VM.download_file')
469+
@patch('rift.VM.message')
470+
def test_download_exists_last_modified_older(
471+
self, mock_message, mock_download_file, mock_last_modified
472+
):
442473
"""Test VM download skipped when local copy is present"""
443474
url = 'http://localhost/path/to/my_image.qcow2'
444475
self.config.set(
@@ -447,6 +478,7 @@ def test_download_skip_exists(self, mock_message, mock_download_file):
447478
'image': url,
448479
}
449480
)
481+
mock_last_modified.return_value = 0.0
450482
with patch(
451483
'rift.VM.VM.image_local', new_callable=PropertyMock
452484
) as mock_image_local:
@@ -457,24 +489,61 @@ def test_download_skip_exists(self, mock_message, mock_download_file):
457489
with self.assertLogs(level='DEBUG') as cm:
458490
vm._download(False)
459491
mock_message.assert_not_called()
492+
# Check download_file() has not been called
460493
mock_download_file.assert_not_called()
494+
self.assertIn(
495+
"DEBUG:root:Local copy of VM image is already updated "
496+
f"({int(os.path.getmtime(tmpfile.name))} > 0), skipping download of "
497+
"remote image",
498+
cm.output
499+
)
500+
501+
@patch('rift.VM.last_modified')
502+
@patch('rift.VM.download_file')
503+
@patch('rift.VM.message')
504+
def test_download_exists_last_modified_newer(
505+
self, mock_message, mock_download_file, mock_last_modified
506+
):
507+
"""Test VM download skipped when local copy is present"""
508+
url = 'http://localhost/path/to/my_image.qcow2'
509+
self.config.set(
510+
'vm',
511+
{
512+
'image': url,
513+
}
514+
)
515+
mock_last_modified.return_value = float(2**32)
516+
with patch(
517+
'rift.VM.VM.image_local', new_callable=PropertyMock
518+
) as mock_image_local:
519+
vm = VM(self.config, platform.machine())
520+
tmpfile = make_temp_file("")
521+
mock_image_local.return_value = tmpfile.name
522+
self.assertTrue(os.path.exists(vm.image_local))
523+
with self.assertLogs(level='DEBUG') as cm:
524+
vm._download(False)
525+
mock_message.assert_called_once_with(f"Download remote VM image {url}")
526+
mock_download_file.assert_called_once_with(url, vm.image_local)
461527
self.assertIn(
462-
'DEBUG:root:Local copy of VM image is present, skipping download of remote '
463-
'image',
528+
'INFO:root:Remote VM image has been updated, removing local copy',
464529
cm.output
465530
)
466531

532+
@patch('rift.VM.last_modified')
467533
@patch('rift.VM.download_file')
468534
@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"""
535+
def test_download_exists_last_modified_error(
536+
self, mock_message, mock_download_file, mock_last_modified
537+
):
538+
"""Test VM download skipped when local copy is present"""
471539
url = 'http://localhost/path/to/my_image.qcow2'
472540
self.config.set(
473541
'vm',
474542
{
475543
'image': url,
476544
}
477545
)
546+
mock_last_modified.side_effect = RiftError("last-modified failure")
478547
with patch(
479548
'rift.VM.VM.image_local', new_callable=PropertyMock
480549
) as mock_image_local:
@@ -483,12 +552,13 @@ def test_download_force(self, mock_message, mock_download_file):
483552
mock_image_local.return_value = tmpfile.name
484553
self.assertTrue(os.path.exists(vm.image_local))
485554
with self.assertLogs(level='DEBUG') as cm:
486-
vm._download(True)
487-
mock_message.assert_called_once_with(f"Download remote VM image {url}")
488-
mock_download_file.assert_called_once_with(url, vm.image_local)
555+
vm._download(False)
556+
mock_message.assert_not_called()
557+
mock_download_file.assert_not_called()
489558
self.assertIn(
490-
'INFO:root:Remove VM image local copy and force re-download for remote '
491-
'image',
559+
"DEBUG:root:Local copy of VM image is present, unable to get remote image "
560+
"modification date because of error (last-modified failure), skipping "
561+
"download of remote image",
492562
cm.output
493563
)
494564

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"), 946684800)
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)