Browse Source

Support for HTTP image location

Story: #2002048
Task: #19695
Change-Id: I75f33ebca3ea65274dcfcd8f4ddbd193f34706a9
Dmitry Tantsur 7 months ago
parent
commit
6bdd479773

+ 16
- 0
.zuul.yaml View File

@@ -146,6 +146,20 @@
146 146
       devstack_localrc:
147 147
         IRONIC_DEFAULT_DEPLOY_INTERFACE: direct
148 148
 
149
+- job:
150
+    name: metalsmith-integration-http-netboot-cirros-direct-py3
151
+    description: |
152
+        Integration job using HTTP as image source and direct deploy.
153
+    parent: metalsmith-integration-base
154
+    run: playbooks/integration/run.yaml
155
+    vars:
156
+      metalsmith_netboot: true
157
+      metalsmith_python: python3
158
+      metalsmith_use_http: true
159
+      devstack_localrc:
160
+        IRONIC_DEFAULT_DEPLOY_INTERFACE: direct
161
+        USE_PYTHON3: true
162
+
149 163
 - project:
150 164
     templates:
151 165
       - check-requirements
@@ -161,9 +175,11 @@
161 175
         - metalsmith-integration-glance-localboot-centos7
162 176
         - metalsmith-integration-glance-netboot-cirros-iscsi-py3
163 177
         - metalsmith-integration-glance-netboot-cirros-direct
178
+        - metalsmith-integration-http-netboot-cirros-direct-py3
164 179
     gate:
165 180
       jobs:
166 181
         - openstack-tox-lower-constraints
167 182
         - metalsmith-integration-glance-localboot-centos7
168 183
         - metalsmith-integration-glance-netboot-cirros-iscsi-py3
169 184
         - metalsmith-integration-glance-netboot-cirros-direct
185
+        - metalsmith-integration-http-netboot-cirros-direct-py3

+ 21
- 2
metalsmith/_cmd.py View File

@@ -23,11 +23,16 @@ from metalsmith import _config
23 23
 from metalsmith import _format
24 24
 from metalsmith import _provisioner
25 25
 from metalsmith import _utils
26
+from metalsmith import sources
26 27
 
27 28
 
28 29
 LOG = logging.getLogger(__name__)
29 30
 
30 31
 
32
+def _is_http(smth):
33
+    return smth.startswith('http://') or smth.startswith('https://')
34
+
35
+
31 36
 class NICAction(argparse.Action):
32 37
     def __call__(self, parser, namespace, values, option_string=None):
33 38
         assert option_string in ('--port', '--network')
@@ -52,6 +57,18 @@ def _do_deploy(api, args, formatter):
52 57
     if args.hostname and not _utils.is_hostname_safe(args.hostname):
53 58
         raise RuntimeError("%s cannot be used as a hostname" % args.hostname)
54 59
 
60
+    if _is_http(args.image):
61
+        if not args.image_checksum:
62
+            raise RuntimeError("HTTP(s) images require --image-checksum")
63
+        elif _is_http(args.image_checksum):
64
+            source = sources.HttpWholeDiskImage(
65
+                args.image, checksum_url=args.image_checksum)
66
+        else:
67
+            source = sources.HttpWholeDiskImage(
68
+                args.image, checksum=args.image_checksum)
69
+    else:
70
+        source = args.image
71
+
55 72
     config = _config.InstanceConfig(ssh_keys=ssh_keys)
56 73
     if args.user_name:
57 74
         config.add_user(args.user_name, sudo=args.passwordless_sudo)
@@ -61,7 +78,7 @@ def _do_deploy(api, args, formatter):
61 78
                             capabilities=capabilities,
62 79
                             candidates=args.candidate)
63 80
     instance = api.provision_node(node,
64
-                                  image=args.image,
81
+                                  image=source,
65 82
                                   nics=args.nics,
66 83
                                   root_disk_size=args.root_disk_size,
67 84
                                   config=config,
@@ -122,8 +139,10 @@ def _parse_args(args, config):
122 139
                           'active')
123 140
     wait_grp.add_argument('--no-wait', action='store_true',
124 141
                           help='disable waiting for deploy to finish')
125
-    deploy.add_argument('--image', help='image to use (name or UUID)',
142
+    deploy.add_argument('--image', help='image to use (name, UUID or URL)',
126 143
                         required=True)
144
+    deploy.add_argument('--image-checksum',
145
+                        help='image MD5 checksum or URL with checksums')
127 146
     deploy.add_argument('--network', help='network to use (name or UUID)',
128 147
                         dest='nics', action=NICAction)
129 148
     deploy.add_argument('--port', help='port to attach (name or UUID)',

+ 1
- 1
metalsmith/_provisioner.py View File

@@ -234,7 +234,7 @@ class Provisioner(object):
234 234
         if config is None:
235 235
             config = _config.InstanceConfig()
236 236
         if isinstance(image, six.string_types):
237
-            image = sources.Glance(image)
237
+            image = sources.GlanceImage(image)
238 238
 
239 239
         node = self._check_node_for_deploy(node)
240 240
         created_ports = []

+ 13
- 0
metalsmith/_utils.py View File

@@ -112,3 +112,16 @@ def validate_nics(nics):
112 112
     if unknown_nic_types:
113 113
         raise ValueError("Unexpected NIC type(s) %s, supported values are "
114 114
                          "'port' and 'network'" % ', '.join(unknown_nic_types))
115
+
116
+
117
+def parse_checksums(checksums):
118
+    """Parse standard checksums file."""
119
+    result = {}
120
+    for line in checksums.split('\n'):
121
+        if not line.strip():
122
+            continue
123
+
124
+        checksum, fname = line.strip().split(None, 1)
125
+        result[fname.strip().lstrip('*')] = checksum.strip()
126
+
127
+    return result

+ 106
- 1
metalsmith/sources.py View File

@@ -17,10 +17,14 @@
17 17
 
18 18
 import abc
19 19
 import logging
20
+import os
20 21
 
21 22
 import openstack.exceptions
23
+import requests
22 24
 import six
25
+from six.moves.urllib import parse as urlparse
23 26
 
27
+from metalsmith import _utils
24 28
 from metalsmith import exceptions
25 29
 
26 30
 
@@ -38,7 +42,7 @@ class _Source(object):
38 42
         """Updates required for a node to use this source."""
39 43
 
40 44
 
41
-class Glance(_Source):
45
+class GlanceImage(_Source):
42 46
     """Image from the OpenStack Image service."""
43 47
 
44 48
     def __init__(self, image):
@@ -73,3 +77,104 @@ class Glance(_Source):
73 77
                 updates['/instance_info/%s' % prop] = value
74 78
 
75 79
         return updates
80
+
81
+
82
+class HttpWholeDiskImage(_Source):
83
+    """A whole-disk image from HTTP(s) location.
84
+
85
+    Some deployment methods require a checksum of the image. It has to be
86
+    provided via ``checksum`` or ``checksum_url``.
87
+
88
+    Only ``checksum_url`` (if provided) has to be accessible from the current
89
+    machine. Other URLs have to be accessible by the Bare Metal service (more
90
+    specifically, by **ironic-conductor** processes).
91
+    """
92
+
93
+    def __init__(self, url, checksum=None, checksum_url=None,
94
+                 kernel_url=None, ramdisk_url=None):
95
+        """Create an HTTP source.
96
+
97
+        :param url: URL of the image.
98
+        :param checksum: MD5 checksum of the image. Mutually exclusive with
99
+            ``checksum_url``.
100
+        :param checksum_url: URL of the checksum file for the image. Has to
101
+            be in the standard format of the ``md5sum`` tool. Mutually
102
+            exclusive with ``checksum``.
103
+        """
104
+        if (checksum and checksum_url) or (not checksum and not checksum_url):
105
+            raise TypeError('Exactly one of checksum and checksum_url has '
106
+                            'to be specified')
107
+
108
+        self.url = url
109
+        self.checksum = checksum
110
+        self.checksum_url = checksum_url
111
+        self.kernel_url = kernel_url
112
+        self.ramdisk_url = ramdisk_url
113
+
114
+    def _validate(self, connection):
115
+        # TODO(dtantsur): should we validate image URLs here? Ironic will do it
116
+        # as well, and images do not have to be accessible from where
117
+        # metalsmith is running.
118
+        if self.checksum:
119
+            return
120
+
121
+        try:
122
+            response = requests.get(self.checksum_url)
123
+            response.raise_for_status()
124
+            checksums = response.text
125
+        except requests.RequestException as exc:
126
+            raise exceptions.InvalidImage(
127
+                'Cannot download checksum file %(url)s: %(err)s' %
128
+                {'url': self.checksum_url, 'err': exc})
129
+
130
+        try:
131
+            checksums = _utils.parse_checksums(checksums)
132
+        except (ValueError, TypeError) as exc:
133
+            raise exceptions.InvalidImage(
134
+                'Invalid checksum file %(url)s: %(err)s' %
135
+                {'url': self.checksum_url, 'err': exc})
136
+
137
+        fname = os.path.basename(urlparse.urlparse(self.url).path)
138
+        try:
139
+            self.checksum = checksums[fname]
140
+        except KeyError:
141
+            raise exceptions.InvalidImage(
142
+                'There is no image checksum for %(fname)s in %(url)s' %
143
+                {'fname': fname, 'url': self.checksum_url})
144
+
145
+    def _node_updates(self, connection):
146
+        self._validate(connection)
147
+        LOG.debug('Image: %(image)s, checksum %(checksum)s',
148
+                  {'image': self.url, 'checksum': self.checksum})
149
+        return {
150
+            '/instance_info/image_source': self.url,
151
+            '/instance_info/image_checksum': self.checksum,
152
+        }
153
+
154
+
155
+class HttpPartitionImage(HttpWholeDiskImage):
156
+    """A partition image from an HTTP(s) location."""
157
+
158
+    def __init__(self, url, kernel_url, ramdisk_url, checksum=None,
159
+                 checksum_url=None):
160
+        """Create an HTTP source.
161
+
162
+        :param url: URL of the root disk image.
163
+        :param kernel_url: URL of the kernel image.
164
+        :param ramdisk_url: URL of the initramfs image.
165
+        :param checksum: MD5 checksum of the root disk image. Mutually
166
+            exclusive with ``checksum_url``.
167
+        :param checksum_url: URL of the checksum file for the root disk image.
168
+            Has to be in the standard format of the ``md5sum`` tool. Mutually
169
+            exclusive with ``checksum``.
170
+        """
171
+        super(HttpPartitionImage, self).__init__(url, checksum=checksum,
172
+                                                 checksum_url=checksum_url)
173
+        self.kernel_url = kernel_url
174
+        self.ramdisk_url = ramdisk_url
175
+
176
+    def _node_updates(self, connection):
177
+        updates = super(HttpPartitionImage, self)._node_updates(connection)
178
+        updates['/instance_info/kernel'] = self.kernel_url
179
+        updates['/instance_info/ramdisk'] = self.ramdisk_url
180
+        return updates

+ 67
- 0
metalsmith/test/test_cmd.py View File

@@ -25,6 +25,7 @@ from metalsmith import _cmd
25 25
 from metalsmith import _config
26 26
 from metalsmith import _instance
27 27
 from metalsmith import _provisioner
28
+from metalsmith import sources
28 29
 
29 30
 
30 31
 @mock.patch.object(_provisioner, 'Provisioner', autospec=True)
@@ -625,6 +626,72 @@ class TestDeploy(testtools.TestCase):
625 626
             netboot=False,
626 627
             wait=1800)
627 628
 
629
+    def test_args_http_image_with_checksum(self, mock_os_conf, mock_pr):
630
+        args = ['deploy', '--image', 'https://example.com/image.img',
631
+                '--image-checksum', '95e750180c7921ea0d545c7165db66b8',
632
+                '--resource-class', 'compute']
633
+        _cmd.main(args)
634
+        mock_pr.assert_called_once_with(
635
+            cloud_region=mock_os_conf.return_value.get_one.return_value,
636
+            dry_run=False)
637
+        mock_pr.return_value.reserve_node.assert_called_once_with(
638
+            resource_class='compute',
639
+            conductor_group=None,
640
+            capabilities={},
641
+            candidates=None
642
+        )
643
+        mock_pr.return_value.provision_node.assert_called_once_with(
644
+            mock_pr.return_value.reserve_node.return_value,
645
+            image=mock.ANY,
646
+            nics=None,
647
+            root_disk_size=None,
648
+            config=mock.ANY,
649
+            hostname=None,
650
+            netboot=False,
651
+            wait=1800)
652
+        source = mock_pr.return_value.provision_node.call_args[1]['image']
653
+        self.assertIsInstance(source, sources.HttpWholeDiskImage)
654
+        self.assertEqual('https://example.com/image.img', source.url)
655
+        self.assertEqual('95e750180c7921ea0d545c7165db66b8', source.checksum)
656
+
657
+    def test_args_http_image_with_checksum_url(self, mock_os_conf, mock_pr):
658
+        args = ['deploy', '--image', 'http://example.com/image.img',
659
+                '--image-checksum', 'http://example.com/CHECKSUMS',
660
+                '--resource-class', 'compute']
661
+        _cmd.main(args)
662
+        mock_pr.assert_called_once_with(
663
+            cloud_region=mock_os_conf.return_value.get_one.return_value,
664
+            dry_run=False)
665
+        mock_pr.return_value.reserve_node.assert_called_once_with(
666
+            resource_class='compute',
667
+            conductor_group=None,
668
+            capabilities={},
669
+            candidates=None
670
+        )
671
+        mock_pr.return_value.provision_node.assert_called_once_with(
672
+            mock_pr.return_value.reserve_node.return_value,
673
+            image=mock.ANY,
674
+            nics=None,
675
+            root_disk_size=None,
676
+            config=mock.ANY,
677
+            hostname=None,
678
+            netboot=False,
679
+            wait=1800)
680
+        source = mock_pr.return_value.provision_node.call_args[1]['image']
681
+        self.assertIsInstance(source, sources.HttpWholeDiskImage)
682
+        self.assertEqual('http://example.com/image.img', source.url)
683
+        self.assertEqual('http://example.com/CHECKSUMS', source.checksum_url)
684
+
685
+    @mock.patch.object(_cmd.LOG, 'critical', autospec=True)
686
+    def test_args_http_image_without_checksum(self, mock_log, mock_os_conf,
687
+                                              mock_pr):
688
+        args = ['deploy', '--image', 'http://example.com/image.img',
689
+                '--resource-class', 'compute']
690
+        self.assertRaises(SystemExit, _cmd.main, args)
691
+        self.assertTrue(mock_log.called)
692
+        self.assertFalse(mock_pr.return_value.reserve_node.called)
693
+        self.assertFalse(mock_pr.return_value.provision_node.called)
694
+
628 695
     def test_args_custom_wait(self, mock_os_conf, mock_pr):
629 696
         args = ['deploy', '--network', 'mynet', '--image', 'myimg',
630 697
                 '--wait', '3600', '--resource-class', 'compute']

+ 186
- 1
metalsmith/test/test_provisioner.py View File

@@ -16,6 +16,7 @@
16 16
 import fixtures
17 17
 import mock
18 18
 from openstack import exceptions as os_exc
19
+import requests
19 20
 import testtools
20 21
 
21 22
 from metalsmith import _config
@@ -280,7 +281,7 @@ class TestProvisionNode(Base):
280 281
         self.assertFalse(self.conn.network.delete_port.called)
281 282
 
282 283
     def test_ok_with_source(self):
283
-        inst = self.pr.provision_node(self.node, sources.Glance('image'),
284
+        inst = self.pr.provision_node(self.node, sources.GlanceImage('image'),
284 285
                                       [{'network': 'network'}])
285 286
 
286 287
         self.assertEqual(inst.uuid, self.node.uuid)
@@ -434,6 +435,100 @@ class TestProvisionNode(Base):
434 435
         self.assertFalse(self.api.release_node.called)
435 436
         self.assertFalse(self.conn.network.delete_port.called)
436 437
 
438
+    def test_with_http_and_checksum_whole_disk(self):
439
+        self.updates['/instance_info/image_source'] = 'https://host/image'
440
+        self.updates['/instance_info/image_checksum'] = 'abcd'
441
+        del self.updates['/instance_info/kernel']
442
+        del self.updates['/instance_info/ramdisk']
443
+
444
+        inst = self.pr.provision_node(
445
+            self.node,
446
+            sources.HttpWholeDiskImage('https://host/image', checksum='abcd'),
447
+            [{'network': 'network'}])
448
+
449
+        self.assertEqual(inst.uuid, self.node.uuid)
450
+        self.assertEqual(inst.node, self.node)
451
+
452
+        self.assertFalse(self.conn.image.find_image.called)
453
+        self.conn.network.create_port.assert_called_once_with(
454
+            network_id=self.conn.network.find_network.return_value.id)
455
+        self.api.attach_port_to_node.assert_called_once_with(
456
+            self.node.uuid, self.conn.network.create_port.return_value.id)
457
+        self.api.update_node.assert_called_once_with(self.node, self.updates)
458
+        self.api.validate_node.assert_called_once_with(self.node,
459
+                                                       validate_deploy=True)
460
+        self.api.node_action.assert_called_once_with(self.node, 'active',
461
+                                                     configdrive=mock.ANY)
462
+        self.assertFalse(self.wait_mock.called)
463
+        self.assertFalse(self.api.release_node.called)
464
+        self.assertFalse(self.conn.network.delete_port.called)
465
+
466
+    @mock.patch.object(requests, 'get', autospec=True)
467
+    def test_with_http_and_checksum_url(self, mock_get):
468
+        self.updates['/instance_info/image_source'] = 'https://host/image'
469
+        self.updates['/instance_info/image_checksum'] = 'abcd'
470
+        del self.updates['/instance_info/kernel']
471
+        del self.updates['/instance_info/ramdisk']
472
+        mock_get.return_value.text = """
473
+defg *something else
474
+abcd  image
475
+"""
476
+
477
+        inst = self.pr.provision_node(
478
+            self.node,
479
+            sources.HttpWholeDiskImage('https://host/image',
480
+                                       checksum_url='https://host/checksums'),
481
+            [{'network': 'network'}])
482
+
483
+        self.assertEqual(inst.uuid, self.node.uuid)
484
+        self.assertEqual(inst.node, self.node)
485
+
486
+        self.assertFalse(self.conn.image.find_image.called)
487
+        mock_get.assert_called_once_with('https://host/checksums')
488
+        self.conn.network.create_port.assert_called_once_with(
489
+            network_id=self.conn.network.find_network.return_value.id)
490
+        self.api.attach_port_to_node.assert_called_once_with(
491
+            self.node.uuid, self.conn.network.create_port.return_value.id)
492
+        self.api.update_node.assert_called_once_with(self.node, self.updates)
493
+        self.api.validate_node.assert_called_once_with(self.node,
494
+                                                       validate_deploy=True)
495
+        self.api.node_action.assert_called_once_with(self.node, 'active',
496
+                                                     configdrive=mock.ANY)
497
+        self.assertFalse(self.wait_mock.called)
498
+        self.assertFalse(self.api.release_node.called)
499
+        self.assertFalse(self.conn.network.delete_port.called)
500
+
501
+    def test_with_http_and_checksum_partition(self):
502
+        self.updates['/instance_info/image_source'] = 'https://host/image'
503
+        self.updates['/instance_info/image_checksum'] = 'abcd'
504
+        self.updates['/instance_info/kernel'] = 'https://host/kernel'
505
+        self.updates['/instance_info/ramdisk'] = 'https://host/ramdisk'
506
+
507
+        inst = self.pr.provision_node(
508
+            self.node,
509
+            sources.HttpPartitionImage('https://host/image',
510
+                                       checksum='abcd',
511
+                                       kernel_url='https://host/kernel',
512
+                                       ramdisk_url='https://host/ramdisk'),
513
+            [{'network': 'network'}])
514
+
515
+        self.assertEqual(inst.uuid, self.node.uuid)
516
+        self.assertEqual(inst.node, self.node)
517
+
518
+        self.assertFalse(self.conn.image.find_image.called)
519
+        self.conn.network.create_port.assert_called_once_with(
520
+            network_id=self.conn.network.find_network.return_value.id)
521
+        self.api.attach_port_to_node.assert_called_once_with(
522
+            self.node.uuid, self.conn.network.create_port.return_value.id)
523
+        self.api.update_node.assert_called_once_with(self.node, self.updates)
524
+        self.api.validate_node.assert_called_once_with(self.node,
525
+                                                       validate_deploy=True)
526
+        self.api.node_action.assert_called_once_with(self.node, 'active',
527
+                                                     configdrive=mock.ANY)
528
+        self.assertFalse(self.wait_mock.called)
529
+        self.assertFalse(self.api.release_node.called)
530
+        self.assertFalse(self.conn.network.delete_port.called)
531
+
437 532
     def test_with_root_disk_size(self):
438 533
         self.updates['/instance_info/root_gb'] = 50
439 534
 
@@ -700,6 +795,82 @@ class TestProvisionNode(Base):
700 795
         self.assertFalse(self.api.node_action.called)
701 796
         self.api.release_node.assert_called_once_with(self.node)
702 797
 
798
+    @mock.patch.object(requests, 'get', autospec=True)
799
+    def test_no_checksum_with_http_image(self, mock_get):
800
+        self.updates['/instance_info/image_source'] = 'https://host/image'
801
+        self.updates['/instance_info/image_checksum'] = 'abcd'
802
+        del self.updates['/instance_info/kernel']
803
+        del self.updates['/instance_info/ramdisk']
804
+        mock_get.return_value.text = """
805
+defg *something else
806
+abcd  and-not-image-again
807
+"""
808
+
809
+        self.assertRaisesRegex(exceptions.InvalidImage,
810
+                               'no image checksum',
811
+                               self.pr.provision_node,
812
+                               self.node,
813
+                               sources.HttpWholeDiskImage(
814
+                                   'https://host/image',
815
+                                   checksum_url='https://host/checksums'),
816
+                               [{'network': 'network'}])
817
+
818
+        self.assertFalse(self.conn.image.find_image.called)
819
+        mock_get.assert_called_once_with('https://host/checksums')
820
+        self.api.update_node.assert_called_once_with(self.node, CLEAN_UP)
821
+        self.assertFalse(self.api.node_action.called)
822
+        self.api.release_node.assert_called_once_with(self.node)
823
+
824
+    @mock.patch.object(requests, 'get', autospec=True)
825
+    def test_malformed_checksum_with_http_image(self, mock_get):
826
+        self.updates['/instance_info/image_source'] = 'https://host/image'
827
+        self.updates['/instance_info/image_checksum'] = 'abcd'
828
+        del self.updates['/instance_info/kernel']
829
+        del self.updates['/instance_info/ramdisk']
830
+        mock_get.return_value.text = """
831
+<html>
832
+    <p>I am not a checksum file!</p>
833
+</html>"""
834
+
835
+        self.assertRaisesRegex(exceptions.InvalidImage,
836
+                               'Invalid checksum file',
837
+                               self.pr.provision_node,
838
+                               self.node,
839
+                               sources.HttpWholeDiskImage(
840
+                                   'https://host/image',
841
+                                   checksum_url='https://host/checksums'),
842
+                               [{'network': 'network'}])
843
+
844
+        self.assertFalse(self.conn.image.find_image.called)
845
+        mock_get.assert_called_once_with('https://host/checksums')
846
+        self.api.update_node.assert_called_once_with(self.node, CLEAN_UP)
847
+        self.assertFalse(self.api.node_action.called)
848
+        self.api.release_node.assert_called_once_with(self.node)
849
+
850
+    @mock.patch.object(requests, 'get', autospec=True)
851
+    def test_cannot_download_checksum_with_http_image(self, mock_get):
852
+        self.updates['/instance_info/image_source'] = 'https://host/image'
853
+        self.updates['/instance_info/image_checksum'] = 'abcd'
854
+        del self.updates['/instance_info/kernel']
855
+        del self.updates['/instance_info/ramdisk']
856
+        mock_get.return_value.raise_for_status.side_effect = (
857
+            requests.RequestException("boom"))
858
+
859
+        self.assertRaisesRegex(exceptions.InvalidImage,
860
+                               'Cannot download checksum file',
861
+                               self.pr.provision_node,
862
+                               self.node,
863
+                               sources.HttpWholeDiskImage(
864
+                                   'https://host/image',
865
+                                   checksum_url='https://host/checksums'),
866
+                               [{'network': 'network'}])
867
+
868
+        self.assertFalse(self.conn.image.find_image.called)
869
+        mock_get.assert_called_once_with('https://host/checksums')
870
+        self.api.update_node.assert_called_once_with(self.node, CLEAN_UP)
871
+        self.assertFalse(self.api.node_action.called)
872
+        self.api.release_node.assert_called_once_with(self.node)
873
+
703 874
     def test_invalid_network(self):
704 875
         self.conn.network.find_network.side_effect = RuntimeError('Not found')
705 876
         self.assertRaisesRegex(exceptions.InvalidNIC, 'Not found',
@@ -835,6 +1006,20 @@ class TestProvisionNode(Base):
835 1006
         self.assertFalse(self.api.node_action.called)
836 1007
         self.assertFalse(self.api.release_node.called)
837 1008
 
1009
+    def test_invalid_http_source(self):
1010
+        self.assertRaises(TypeError, sources.HttpWholeDiskImage,
1011
+                          'http://host/image')
1012
+        self.assertRaises(TypeError, sources.HttpWholeDiskImage,
1013
+                          'http://host/image', checksum='abcd',
1014
+                          checksum_url='http://host/checksum')
1015
+        self.assertRaises(TypeError, sources.HttpPartitionImage,
1016
+                          'http://host/image', 'http://host/kernel',
1017
+                          'http://host/ramdisk')
1018
+        self.assertRaises(TypeError, sources.HttpPartitionImage,
1019
+                          'http://host/image', 'http://host/kernel',
1020
+                          'http://host/ramdisk', checksum='abcd',
1021
+                          checksum_url='http://host/checksum')
1022
+
838 1023
 
839 1024
 class TestUnprovisionNode(Base):
840 1025
 

+ 28
- 7
playbooks/integration/cirros-image.yaml View File

@@ -1,18 +1,39 @@
1 1
 - name: Find Cirros UEC image
2
-  shell: |
3
-    openstack image list -f value -c ID -c Name \
4
-      | awk '/cirros.*uec/ { print $1; exit 0; }'
2
+  shell: openstack image list -f value -c Name | grep 'cirros-.*-uec$'
5 3
   register: cirros_uec_image_result
6 4
   failed_when: cirros_uec_image_result.stdout == ""
7 5
 
8 6
 - name: Find Cirros disk image
9
-  shell: |
10
-    openstack image list -f value -c ID -c Name \
11
-      | awk '/cirros.*disk/ { print $1; exit 0; }'
7
+  shell: openstack image list -f value -c Name | grep 'cirros-.*-disk$'
12 8
   register: cirros_disk_image_result
13 9
   failed_when: cirros_disk_image_result.stdout == ""
14 10
 
15
-- name: Set image facts
11
+- name: Set image facts for Glance image
16 12
   set_fact:
17 13
     metalsmith_whole_disk_image: "{{ cirros_disk_image_result.stdout }}"
18 14
     metalsmith_partition_image: "{{ cirros_uec_image_result.stdout }}"
15
+  when: not (metalsmith_use_http | default(false))
16
+
17
+- block:
18
+    - name: Get baremetal HTTP endpoint
19
+      shell: |
20
+        source /opt/stack/devstack/openrc admin admin > /dev/null
21
+        iniget /etc/ironic/ironic.conf deploy http_url
22
+      args:
23
+        executable: /bin/bash
24
+      register: baremetal_endpoint_result
25
+      failed_when: baremetal_endpoint_result.stdout == ""
26
+
27
+    - name: Calculate MD5 checksum for HTTP disk image
28
+      shell: |
29
+          md5sum /opt/stack/devstack/files/{{ cirros_disk_image_result.stdout }}.img \
30
+              | awk '{ print $1; }'
31
+      register: cirros_disk_image_checksum_result
32
+      failed_when: cirros_disk_image_checksum_result.stdout == ""
33
+
34
+    - name: Set facts for HTTP image
35
+      set_fact:
36
+        metalsmith_whole_disk_image: "{{ baremetal_endpoint_result.stdout}}/{{ cirros_disk_image_result.stdout }}.img"
37
+        metalsmith_whole_disk_checksum: "{{ cirros_disk_image_checksum_result.stdout }}"
38
+
39
+  when: metalsmith_use_http | default(false)

+ 1
- 0
playbooks/integration/exercise.yaml View File

@@ -23,6 +23,7 @@
23 23
     metalsmith_instances:
24 24
       - hostname: test
25 25
         image: "{{ image }}"
26
+        image_checksum: "{{ image_checksum | default('') }}"
26 27
         nics:
27 28
           - "{{ nic }}"
28 29
         ssh_public_keys:

+ 8
- 4
playbooks/integration/run.yaml View File

@@ -8,14 +8,18 @@
8 8
     - include: cirros-image.yaml
9 9
       when: metalsmith_whole_disk_image is not defined
10 10
 
11
-    - name: Test a partition image
11
+    - name: Test a whole-disk image
12 12
       include: exercise.yaml
13 13
       vars:
14
-        image: "{{ metalsmith_partition_image }}"
14
+        image: "{{ metalsmith_whole_disk_image }}"
15
+        image_checksum: "{{ metalsmith_whole_disk_checksum | default('') }}"
15 16
         precreate_port: false
16 17
 
17
-    - name: Test a whole-disk image
18
+    - name: Test a partition image
18 19
       include: exercise.yaml
19 20
       vars:
20
-        image: "{{ metalsmith_whole_disk_image }}"
21
+        image: "{{ metalsmith_partition_image }}"
22
+        image_checksum: "{{ metalsmith_partition_checksum | default('') }}"
21 23
         precreate_port: false
24
+      # FIXME(dtantsur): cover partition images
25
+      when: not (metalsmith_use_http | default(false))

+ 1
- 0
requirements.txt View File

@@ -4,4 +4,5 @@
4 4
 pbr!=2.1.0,>=2.0.0 # Apache-2.0
5 5
 openstacksdk>=0.11.0 # Apache-2.0
6 6
 python-ironicclient>=1.14.0 # Apache-2.0
7
+requests>=2.18.4 # Apache-2.0
7 8
 six>=1.10.0 # MIT

+ 5
- 1
roles/metalsmith_deployment/README.rst View File

@@ -23,6 +23,8 @@ The following optional variables provide the defaults for Instance_ attributes:
23 23
     the default for ``extra_args``.
24 24
 ``metalsmith_image``
25 25
     the default for ``image``.
26
+``metalsmith_image_checksum``
27
+    the default for ``image_checksum``.
26 28
 ``metalsmith_netboot``
27 29
     the default for ``netboot``
28 30
 ``metalsmith_nics``
@@ -53,7 +55,9 @@ Each instances has the following attributes:
53 55
 ``extra_args`` (defaults to ``metalsmith_extra_args``)
54 56
     additional arguments to pass to the ``metalsmith`` CLI on all calls.
55 57
 ``image`` (defaults to ``metalsmith_image``)
56
-    UUID or name of the image to use for deployment. Mandatory.
58
+    UUID, name or HTTP(s) URL of the image to use for deployment. Mandatory.
59
+``image_checksum`` (defaults to ``metalsmith_image_checksum``)
60
+    MD5 checksum or checksum file URL for an HTTP(s) image.
57 61
 ``netboot``
58 62
     whether to boot the deployed instance from network (PXE, iPXE, etc).
59 63
     The default is to use local boot (requires a bootloader on the image).

+ 1
- 0
roles/metalsmith_deployment/defaults/main.yml View File

@@ -3,6 +3,7 @@ metalsmith_candidates: []
3 3
 metalsmith_capabilities: {}
4 4
 metalsmith_conductor_group:
5 5
 metalsmith_extra_args:
6
+metalsmith_image_checksum:
6 7
 metalsmith_netboot: false
7 8
 metalsmith_nics: []
8 9
 metalsmith_resource_class:

+ 4
- 0
roles/metalsmith_deployment/tasks/main.yml View File

@@ -34,6 +34,9 @@
34 34
     {% for node in candidates %}
35 35
       --candidate {{ node }}
36 36
     {% endfor %}
37
+    {% if image_checksum %}
38
+      --image-checksum {{ image_checksum }}
39
+    {% endif %}
37 40
   when: state == 'present'
38 41
   vars:
39 42
     candidates: "{{ instance.candidates | default(metalsmith_candidates) }}"
@@ -41,6 +44,7 @@
41 44
     conductor_group: "{{ instance.conductor_group | default(metalsmith_conductor_group) }}"
42 45
     extra_args: "{{ instance.extra_args | default(metalsmith_extra_args) }}"
43 46
     image: "{{ instance.image | default(metalsmith_image) }}"
47
+    image: "{{ instance.image_checksum | default(metalsmith_image_checksum) }}"
44 48
     netboot: "{{ instance.netboot | default(metalsmith_netboot) }}"
45 49
     nics: "{{ instance.nics | default(metalsmith_nics) }}"
46 50
     resource_class: "{{ instance.resource_class | default(metalsmith_resource_class) }}"

Loading…
Cancel
Save