~martin-nowack/ubuntu/utopic/maas/bug-1425837

« back to all changes in this revision

Viewing changes to src/maasserver/forms.py

  • Committer: Package Import Robot
  • Author(s): Julian Edwards, Julian Edwards, Andres Rodriguez
  • Date: 2014-08-21 18:38:27 UTC
  • mfrom: (1.2.34)
  • Revision ID: package-import@ubuntu.com-20140821183827-9xyb5u2o4l8g3zxj
Tags: 1.6.1+bzr2550-0ubuntu1
* New upstream bugfix release:
  - Auto-link node MACs to Networks (LP: #1341619)

[ Julian Edwards ]
* debian/maas-region-controller.postinst: Don't restart RabbitMQ on
  upgrades, just ensure it's running.  Should prevent a race with the
  cluster celery restarting.
* debian/rules: Pull upstream branch from the right place.

[ Andres Rodriguez ]
* debian/maas-region-controller.postinst: Ensure cluster celery is
  started if it also runs on the region.

Show diffs side-by-side

added added

removed removed

Lines of Context:
15
15
__all__ = [
16
16
    "AdminNodeForm",
17
17
    "AdminNodeWithMACAddressesForm",
 
18
    "BootSourceForm",
 
19
    "BootSourceSelectionForm",
18
20
    "BulkNodeActionForm",
 
21
    "create_Network_from_NodeGroupInterface",
19
22
    "CommissioningForm",
20
23
    "CommissioningScriptForm",
21
24
    "DownloadProgressForm",
32
35
    "NodeGroupEdit",
33
36
    "NodeGroupInterfaceForeignDHCPForm",
34
37
    "NodeGroupInterfaceForm",
35
 
    "NodeGroupWithInterfacesForm",
 
38
    "NodeGroupDefineForm",
36
39
    "NodeWithMACAddressesForm",
37
40
    "SSHKeyForm",
 
41
    "SSLKeyForm",
38
42
    "TagForm",
39
43
    "ThirdPartyDriversForm",
40
44
    "UbuntuForm",
57
61
    User,
58
62
    )
59
63
from django.core.exceptions import ValidationError
 
64
from django.db import connection
 
65
from django.db.utils import IntegrityError
60
66
from django.forms import (
61
67
    Form,
62
68
    MultipleChoiceField,
71
77
    )
72
78
from maasserver.config_forms import SKIP_CHECK_NAME
73
79
from maasserver.enum import (
74
 
    COMMISSIONING_DISTRO_SERIES_CHOICES,
75
 
    DISTRO_SERIES,
76
 
    DISTRO_SERIES_CHOICES,
77
80
    NODE_STATUS,
78
81
    NODEGROUPINTERFACE_MANAGEMENT,
79
82
    NODEGROUPINTERFACE_MANAGEMENT_CHOICES,
80
83
    )
81
 
from maasserver.exceptions import ClusterUnavailable
 
84
from maasserver.exceptions import (
 
85
    ClusterUnavailable,
 
86
    NodeActionError,
 
87
    )
82
88
from maasserver.fields import (
83
89
    MACAddressFormField,
84
90
    NodeGroupFormField,
86
92
from maasserver.forms_settings import (
87
93
    CONFIG_ITEMS_KEYS,
88
94
    get_config_field,
89
 
    INVALID_DISTRO_SERIES_MESSAGE,
90
95
    INVALID_SETTING_MSG_TEMPLATE,
 
96
    list_commisioning_choices,
91
97
    )
92
98
from maasserver.models import (
93
99
    BootImage,
 
100
    BootSource,
 
101
    BootSourceSelection,
94
102
    Config,
95
103
    DownloadProgress,
 
104
    LicenseKey,
96
105
    MACAddress,
97
106
    Network,
98
107
    Node,
99
108
    NodeGroup,
100
109
    NodeGroupInterface,
101
110
    SSHKey,
 
111
    SSLKey,
102
112
    Tag,
103
113
    Zone,
104
114
    )
 
115
from maasserver.models.network import get_name_and_vlan_from_cluster_interface
105
116
from maasserver.models.nodegroup import NODEGROUP_CLUSTER_NAME_TEMPLATE
106
117
from maasserver.node_action import (
107
118
    ACTION_CLASSES,
110
121
    )
111
122
from maasserver.utils import strip_domain
112
123
from maasserver.utils.forms import compose_invalid_choice_text
113
 
from maasserver.utils.network import make_network
 
124
from maasserver.utils.osystems import (
 
125
    get_distro_series_initial,
 
126
    get_release_requires_key,
 
127
    list_all_releases_requiring_keys,
 
128
    list_all_usable_osystems,
 
129
    list_all_usable_releases,
 
130
    list_osystem_choices,
 
131
    list_release_choices,
 
132
    )
114
133
from metadataserver.fields import Bin
115
134
from metadataserver.models import CommissioningScript
 
135
from provisioningserver.drivers.osystem import OperatingSystemRegistry
 
136
from provisioningserver.utils.network import make_network
116
137
 
117
138
# A reusable null-option for choice fields.
118
139
BLANK_CHOICE = ('', '-------')
213
234
        return all_architectures[0]
214
235
 
215
236
 
 
237
def clean_distro_series_field(form, field, os_field):
 
238
    """Cleans the distro_series field in the form. Validating that
 
239
    the selected operating system matches the distro_series.
 
240
 
 
241
    :param form: `Form` class
 
242
    :param field: distro_series field name
 
243
    :param os_field: osystem field name
 
244
    :returns: clean distro_series field value
 
245
    """
 
246
    new_distro_series = form.cleaned_data.get(field)
 
247
    if '*' in new_distro_series:
 
248
        new_distro_series = new_distro_series.replace('*', '')
 
249
    if new_distro_series is None or '/' not in new_distro_series:
 
250
        return new_distro_series
 
251
    os, release = new_distro_series.split('/', 1)
 
252
    if os_field in form.cleaned_data:
 
253
        new_os = form.cleaned_data[os_field]
 
254
        if os != new_os:
 
255
            raise ValidationError(
 
256
                "%s in %s does not match with "
 
257
                "operating system %s" % (release, field, os))
 
258
    return release
 
259
 
 
260
 
 
261
def get_osystem_from_release(release):
 
262
    """Returns the operating system that supports that release."""
 
263
    for _, osystem in OperatingSystemRegistry:
 
264
        if release in osystem.get_supported_releases():
 
265
            return osystem
 
266
    return None
 
267
 
 
268
 
216
269
class NodeForm(ModelForm):
217
270
 
218
271
    def __init__(self, request=None, *args, **kwargs):
225
278
            self.fields['nodegroup'] = NodeGroupFormField(
226
279
                required=False, empty_label="Default (master)")
227
280
        self.set_up_architecture_field()
 
281
        self.set_up_osystem_and_distro_series_fields(kwargs.get('instance'))
228
282
 
229
283
    def set_up_architecture_field(self):
230
284
        """Create the `architecture` field.
244
298
            choices=choices, required=True, initial=default_arch,
245
299
            error_messages={'invalid_choice': invalid_arch_message})
246
300
 
 
301
    def set_up_osystem_and_distro_series_fields(self, instance):
 
302
        """Create the `osystem` and `distro_series` fields.
 
303
 
 
304
        This needs to be done on the fly so that we can pass a dynamic list of
 
305
        usable operating systems and distro_series.
 
306
        """
 
307
        osystems = list_all_usable_osystems()
 
308
        releases = list_all_usable_releases(osystems)
 
309
        os_choices = list_osystem_choices(osystems)
 
310
        distro_choices = list_release_choices(releases)
 
311
        invalid_osystem_message = compose_invalid_choice_text(
 
312
            'osystem', os_choices)
 
313
        invalid_distro_series_message = compose_invalid_choice_text(
 
314
            'distro_series', distro_choices)
 
315
        self.fields['osystem'] = forms.ChoiceField(
 
316
            label="OS", choices=os_choices, required=False, initial='',
 
317
            error_messages={'invalid_choice': invalid_osystem_message})
 
318
        self.fields['distro_series'] = forms.ChoiceField(
 
319
            label="Release", choices=distro_choices,
 
320
            required=False, initial='',
 
321
            error_messages={'invalid_choice': invalid_distro_series_message})
 
322
        if instance is not None:
 
323
            initial_value = get_distro_series_initial(instance)
 
324
            if instance is not None:
 
325
                self.initial['distro_series'] = initial_value
 
326
 
247
327
    def clean_hostname(self):
248
328
        # Don't allow the hostname to be changed if the node is
249
329
        # currently allocated.  Juju knows the node by its old name, so
257
337
 
258
338
        return new_hostname
259
339
 
 
340
    def clean_distro_series(self):
 
341
        return clean_distro_series_field(self, 'distro_series', 'osystem')
 
342
 
260
343
    def is_valid(self):
261
344
        is_valid = super(NodeForm, self).is_valid()
262
345
        if len(list_all_usable_architectures()) == 0:
265
348
            is_valid = False
266
349
        return is_valid
267
350
 
268
 
    distro_series = forms.ChoiceField(
269
 
        choices=DISTRO_SERIES_CHOICES, required=False,
270
 
        initial=DISTRO_SERIES.default,
271
 
        label="Release",
272
 
        error_messages={'invalid_choice': INVALID_DISTRO_SERIES_MESSAGE})
 
351
    def clean_license_key(self):
 
352
        key = self.cleaned_data.get('license_key')
 
353
        osystem = self.cleaned_data.get('osystem')
 
354
        distro = self.cleaned_data.get('distro_series')
 
355
        if osystem != '':
 
356
            os_obj = OperatingSystemRegistry.get_item(osystem)
 
357
            if os_obj is not None and os_obj.requires_license_key(distro):
 
358
                if not key or len(key) == 0:
 
359
                    raise ValidationError(
 
360
                        "This OS/Release requires a license_key")
 
361
                if not os_obj.validate_license_key(distro, key):
 
362
                    raise ValidationError(
 
363
                        "Invalid license key.")
 
364
                return key
 
365
        return ''
 
366
 
 
367
    def set_distro_series(self, series=''):
 
368
        """Sets the osystem and distro_series, from the provided
 
369
        distro_series.
 
370
        """
 
371
        # This implementation is used so that current API, is not broken. This
 
372
        # makes the distro_series a flat namespace. The distro_series is used
 
373
        # to search through the supporting operating systems, to find the
 
374
        # correct operating system that supports this distro_series.
 
375
        self.is_bound = True
 
376
        self.data['osystem'] = ''
 
377
        self.data['distro_series'] = ''
 
378
        if series is not None and series != '':
 
379
            osystem = get_osystem_from_release(series)
 
380
            if osystem is not None:
 
381
                key_required = get_release_requires_key(osystem, series)
 
382
                self.data['osystem'] = osystem.name
 
383
                self.data['distro_series'] = '%s/%s%s' % (
 
384
                    osystem.name,
 
385
                    series,
 
386
                    key_required,
 
387
                    )
 
388
            else:
 
389
                self.data['distro_series'] = series
 
390
 
 
391
    def set_license_key(self, license_key=''):
 
392
        """Sets the license key."""
 
393
        self.is_bound = True
 
394
        self.data['license_key'] = license_key
273
395
 
274
396
    hostname = forms.CharField(
275
397
        label="Host name", required=False, help_text=(
280
402
            "does not manage DNS, then the host name as entered will be the "
281
403
            "FQDN."))
282
404
 
 
405
    license_key = forms.CharField(
 
406
        label="License Key (Required)", required=False, help_text=(
 
407
            "License key for operating system"),
 
408
        max_length=30)
 
409
 
283
410
    class Meta:
284
411
        model = Node
285
412
 
288
415
        fields = (
289
416
            'hostname',
290
417
            'architecture',
 
418
            'osystem',
291
419
            'distro_series',
 
420
            'license_key',
292
421
            )
293
422
 
294
423
 
475
604
        return mac
476
605
 
477
606
 
478
 
class SSHKeyForm(ModelForm):
479
 
    key = forms.CharField(
480
 
        label="Public key",
481
 
        widget=forms.Textarea(attrs={'rows': '5', 'cols': '30'}),
482
 
        required=True)
483
 
 
484
 
    class Meta:
485
 
        model = SSHKey
 
607
class KeyForm(ModelForm):
 
608
    """Base class for `SSHKeyForm` and `SSLKeyForm`."""
486
609
 
487
610
    def __init__(self, user, *args, **kwargs):
488
 
        super(SSHKeyForm, self).__init__(*args, **kwargs)
 
611
        super(KeyForm, self).__init__(*args, **kwargs)
489
612
        self.user = user
490
613
 
491
614
    def validate_unique(self):
514
637
            self._errors.setdefault('key', self.error_class()).extend(error)
515
638
 
516
639
 
 
640
class SSHKeyForm(KeyForm):
 
641
    key = forms.CharField(
 
642
        label="Public key",
 
643
        widget=forms.Textarea(attrs={'rows': '5', 'cols': '30'}),
 
644
        required=True)
 
645
 
 
646
    class Meta:
 
647
        model = SSHKey
 
648
 
 
649
 
 
650
class SSLKeyForm(KeyForm):
 
651
    key = forms.CharField(
 
652
        label="SSL key",
 
653
        widget=forms.Textarea(attrs={'rows': '15', 'cols': '30'}),
 
654
        required=True)
 
655
 
 
656
    class Meta:
 
657
        model = SSLKey
 
658
 
 
659
 
517
660
class MultipleMACAddressField(forms.MultiValueField):
518
661
 
519
662
    def __init__(self, nb_macs=1, *args, **kwargs):
542
685
 
543
686
IP_BASED_HOSTNAME_REGEXP = re.compile('\d{1,3}-\d{1,3}-\d{1,3}-\d{1,3}')
544
687
 
 
688
MAX_MESSAGES = 10
 
689
 
 
690
 
 
691
def merge_error_messages(summary, errors, limit=MAX_MESSAGES):
 
692
    """Merge a collection of errors into a summary message of limited size.
 
693
 
 
694
    :param summary: The message summarizing the error.
 
695
    :type summary: unicode
 
696
    :param errors: The list of errors to merge.
 
697
    :type errors: iterable
 
698
    :param limit: The maximum number of individual error messages to include in
 
699
        the summary error message.
 
700
    :type limit: int
 
701
    """
 
702
    ellipsis_msg = ''
 
703
    if len(errors) > limit:
 
704
        nb_errors = len(errors) - limit
 
705
        ellipsis_msg = (
 
706
            " and %d more error%s" % (
 
707
                nb_errors,
 
708
                's' if nb_errors > 1 else ''))
 
709
    return "%s (%s%s)" % (
 
710
        summary,
 
711
        ' \u2014 '.join(errors[:limit]),
 
712
        ellipsis_msg
 
713
    )
 
714
 
545
715
 
546
716
class WithMACAddressesMixin:
547
717
    """A form mixin which dynamically adds a MultipleMACAddressField to the
568
738
            self.errors.get('mac_addresses', None) is not None and
569
739
            len(self.data['mac_addresses']) > 1)
570
740
        if reformat_mac_address_error:
571
 
            self.errors['mac_addresses'] = (
572
 
                ['One or more MAC addresses is invalid.'])
 
741
            self.errors['mac_addresses'] = [merge_error_messages(
 
742
                "One or more MAC addresses is invalid.",
 
743
                self.errors['mac_addresses'])]
573
744
        return valid
574
745
 
575
746
    def clean_mac_addresses(self):
576
747
        data = self.cleaned_data['mac_addresses']
 
748
        errors = []
577
749
        for mac in data:
578
750
            if MACAddress.objects.filter(mac_address=mac.lower()).exists():
579
 
                raise ValidationError(
580
 
                    {'mac_addresses': [
581
 
                        'Mac address %s already in use.' % mac]})
 
751
                errors.append('MAC address %s already in use.' % mac)
 
752
        if errors:
 
753
            raise ValidationError({'mac_addresses': errors})
582
754
        return data
583
755
 
584
756
    def save(self):
654
826
        self.actions = compile_node_actions(instance, self.user, self.request)
655
827
        self.action_buttons = self.actions.values()
656
828
 
657
 
    def display_message(self, message):
 
829
    def display_message(self, message, msg_level=messages.INFO):
658
830
        """Show `message` as feedback after performing an action."""
659
831
        if self.request is not None:
660
 
            messages.add_message(self.request, messages.INFO, message)
 
832
            messages.add_message(self.request, msg_level, message)
661
833
 
662
834
    def clean_action(self):
663
835
        action_name = self.cleaned_data['action']
681
853
        """
682
854
        action_name = self.data.get('action')
683
855
        action = self.actions.get(action_name)
684
 
        message = action.execute(allow_redirect=allow_redirect)
685
 
        self.display_message(message)
 
856
        msg_level = messages.INFO
 
857
        try:
 
858
            message = action.execute(allow_redirect=allow_redirect)
 
859
        except NodeActionError as e:
 
860
            message = e.message
 
861
            msg_level = messages.ERROR
 
862
        self.display_message(message, msg_level)
686
863
        # Return updated node.
687
864
        return Node.objects.get(system_id=self.node.system_id)
688
865
 
839
1016
    """Settings page, Commissioning section."""
840
1017
    check_compatibility = get_config_field('check_compatibility')
841
1018
    commissioning_distro_series = forms.ChoiceField(
842
 
        choices=COMMISSIONING_DISTRO_SERIES_CHOICES, required=False,
843
 
        label="Default distro series used for commissioning",
 
1019
        choices=list_commisioning_choices(), required=False,
 
1020
        label="Default Ubuntu release used for commissioning",
844
1021
        error_messages={'invalid_choice': compose_invalid_choice_text(
845
1022
            'commissioning_distro_series',
846
 
            COMMISSIONING_DISTRO_SERIES_CHOICES)})
 
1023
            list_commisioning_choices())})
 
1024
 
 
1025
 
 
1026
class DeployForm(ConfigForm):
 
1027
    """Settings page, Deploy section."""
 
1028
 
 
1029
    def __init__(self, *args, **kwargs):
 
1030
        Form.__init__(self, *args, **kwargs)
 
1031
        self.fields['default_osystem'] = get_config_field('default_osystem')
 
1032
        self.fields['default_distro_series'] = get_config_field(
 
1033
            'default_distro_series')
 
1034
        self._load_initials()
 
1035
 
 
1036
    def _load_initials(self):
 
1037
        super(DeployForm, self)._load_initials()
 
1038
        initial_os = self.fields['default_osystem'].initial
 
1039
        initial_series = self.fields['default_distro_series'].initial
 
1040
        self.initial['default_distro_series'] = '%s/%s' % (
 
1041
            initial_os,
 
1042
            initial_series
 
1043
            )
 
1044
 
 
1045
    def clean_default_distro_series(self):
 
1046
        return clean_distro_series_field(
 
1047
            self, 'default_distro_series', 'default_osystem')
847
1048
 
848
1049
 
849
1050
class UbuntuForm(ConfigForm):
850
1051
    """Settings page, Ubuntu section."""
851
 
    default_distro_series = get_config_field('default_distro_series')
852
1052
    main_archive = get_config_field('main_archive')
853
1053
    ports_archive = get_config_field('ports_archive')
854
1054
 
855
1055
 
 
1056
class WindowsForm(ConfigForm):
 
1057
    """Settings page, Windows section."""
 
1058
    windows_kms_host = get_config_field('windows_kms_host')
 
1059
 
 
1060
 
856
1061
class GlobalKernelOptsForm(ConfigForm):
857
1062
    """Settings page, Global Kernel Parameters section."""
858
1063
    kernel_opts = get_config_field('kernel_opts')
859
1064
 
860
1065
 
 
1066
ERROR_MESSAGE_STATIC_IPS_OUTSIDE_RANGE = (
 
1067
    "New static IP range does not include already-allocated IP "
 
1068
    "addresses.")
 
1069
 
 
1070
 
 
1071
ERROR_MESSAGE_STATIC_RANGE_IN_USE = (
 
1072
    "Cannot remove static IP range when there are allocated IP addresses "
 
1073
    "in that range.")
 
1074
 
 
1075
 
 
1076
def validate_new_static_ip_ranges(instance, static_ip_range_low,
 
1077
                                  static_ip_range_high):
 
1078
    """Check that new static IP ranges don't exclude allocated addresses.
 
1079
 
 
1080
    If there are IP addresses allocated within a `NodeGroupInterface`'s
 
1081
    existing static IP range which would fall outside of the new range,
 
1082
    raise a ValidationError.
 
1083
    """
 
1084
    # Return early if the instance is not already managed, it currently
 
1085
    # has no static IP range, or the static IP range hasn't changed.
 
1086
    if not instance.is_managed:
 
1087
        return True
 
1088
    # Deliberately vague check to allow for empty strings.
 
1089
    if (not instance.static_ip_range_low or
 
1090
       not instance.static_ip_range_high):
 
1091
        return True
 
1092
    if (static_ip_range_low == instance.static_ip_range_low and
 
1093
       static_ip_range_high == instance.static_ip_range_high):
 
1094
        return True
 
1095
 
 
1096
    cursor = connection.cursor()
 
1097
 
 
1098
    # Deliberately vague check to allow for empty strings.
 
1099
    if static_ip_range_low or static_ip_range_high:
 
1100
        # Find any allocated addresses within the old static range which do
 
1101
        # not fall within the *new* static range. This means that we allow
 
1102
        # for range expansion and contraction *unless* that means dropping
 
1103
        # IP addresses that are already allocated.
 
1104
        cursor.execute("""
 
1105
            SELECT TRUE FROM maasserver_staticipaddress
 
1106
                WHERE  ip >= %s AND ip <= %s
 
1107
                    AND (ip < %s OR ip > %s)
 
1108
            """, (
 
1109
            instance.static_ip_range_low,
 
1110
            instance.static_ip_range_high,
 
1111
            static_ip_range_low,
 
1112
            static_ip_range_high))
 
1113
        results = cursor.fetchall()
 
1114
        if any(results):
 
1115
            raise forms.ValidationError(
 
1116
                ERROR_MESSAGE_STATIC_IPS_OUTSIDE_RANGE)
 
1117
    else:
 
1118
        # Check that there's no IP addresses allocated in the old range;
 
1119
        # if there are, we can't remove the range yet.
 
1120
        cursor.execute("""
 
1121
            SELECT TRUE FROM maasserver_staticipaddress
 
1122
                WHERE ip >= %s AND ip <= %s
 
1123
            """, (
 
1124
            instance.static_ip_range_low,
 
1125
            instance.static_ip_range_high))
 
1126
        results = cursor.fetchall()
 
1127
        if any(results):
 
1128
            raise forms.ValidationError(
 
1129
                ERROR_MESSAGE_STATIC_RANGE_IN_USE)
 
1130
    return True
 
1131
 
 
1132
 
 
1133
def create_Network_from_NodeGroupInterface(interface):
 
1134
    """Given a `NodeGroupInterface`, create its Network counterpart."""
 
1135
    # This method cannot use non-orm model properties because it needs
 
1136
    # to be used in a data migration, where they won't work.
 
1137
    if not interface.subnet_mask:
 
1138
        # Can be None or empty string, do nothing if so.
 
1139
        return
 
1140
 
 
1141
    name, vlan_tag = get_name_and_vlan_from_cluster_interface(interface)
 
1142
    ipnetwork = make_network(interface.ip, interface.subnet_mask)
 
1143
    network = Network(
 
1144
        name=name,
 
1145
        ip=unicode(ipnetwork.network),
 
1146
        netmask=unicode(ipnetwork.netmask),
 
1147
        vlan_tag=vlan_tag,
 
1148
        description=(
 
1149
            "Auto created when creating interface %s on cluster "
 
1150
            "%s" % (interface.interface, interface.nodegroup.name)),
 
1151
        )
 
1152
    try:
 
1153
        network.save()
 
1154
    except (IntegrityError, ValidationError):
 
1155
        # It probably already exists, keep calm and carry on.
 
1156
        return
 
1157
    return network
 
1158
 
 
1159
 
861
1160
class NodeGroupInterfaceForm(ModelForm):
862
1161
 
863
1162
    management = forms.TypedChoiceField(
881
1180
            'router_ip',
882
1181
            'ip_range_low',
883
1182
            'ip_range_high',
 
1183
            'static_ip_range_low',
 
1184
            'static_ip_range_high',
884
1185
            )
885
1186
 
 
1187
    def save(self, *args, **kwargs):
 
1188
        """Override `ModelForm`.save() so that the network data is copied
 
1189
        to a `Network` instance."""
 
1190
        interface = super(NodeGroupInterfaceForm, self).save(*args, **kwargs)
 
1191
        if interface.network is None:
 
1192
            return interface
 
1193
        create_Network_from_NodeGroupInterface(interface)
 
1194
        return interface
 
1195
 
 
1196
    def clean(self):
 
1197
        cleaned_data = super(NodeGroupInterfaceForm, self).clean()
 
1198
        static_ip_range_low = cleaned_data.get('static_ip_range_low')
 
1199
        static_ip_range_high = cleaned_data.get('static_ip_range_high')
 
1200
        try:
 
1201
            validate_new_static_ip_ranges(
 
1202
                self.instance, static_ip_range_low, static_ip_range_high)
 
1203
        except forms.ValidationError as exception:
 
1204
            set_form_error(self, 'static_ip_range_low', exception.message)
 
1205
            set_form_error(self, 'static_ip_range_high', exception.message)
 
1206
        return cleaned_data
 
1207
 
886
1208
 
887
1209
class NodeGroupInterfaceForeignDHCPForm(ModelForm):
888
1210
    """A form to update a nodegroupinterface's foreign_dhcp_ip field."""
925
1247
    """Check `NodeGroupInterface` definitions as found in a requst.
926
1248
 
927
1249
    This validates that the `NodeGroupInterface` definitions found in a
928
 
    request to `NodeGroupWithInterfacesForm` conforms to the expected basic
929
 
    structure: a list of dicts.
 
1250
    request to `NodeGroupDefineForm` conforms to the expected basic structure:
 
1251
    a list of dicts.
930
1252
 
931
1253
    :type interface: `dict` extracted from JSON request body.
932
1254
    :raises ValidationError: If the interfaces definition is not a list of
987
1309
                % (networks[index - 1]['name'], networks[index]['name']))
988
1310
 
989
1311
 
990
 
class NodeGroupWithInterfacesForm(ModelForm):
991
 
    """Create a NodeGroup with unmanaged interfaces."""
 
1312
class NodeGroupDefineForm(ModelForm):
 
1313
    """Define a `NodeGroup`, along with its interfaces.
 
1314
 
 
1315
    This form can create a new `NodeGroup`, or in the case where a cluster
 
1316
    automatically becomes the master, updating an existing one.
 
1317
    """
992
1318
 
993
1319
    interfaces = forms.CharField(required=False)
994
1320
    cluster_name = forms.CharField(required=False)
1002
1328
            )
1003
1329
 
1004
1330
    def __init__(self, status=None, *args, **kwargs):
1005
 
        super(NodeGroupWithInterfacesForm, self).__init__(*args, **kwargs)
 
1331
        super(NodeGroupDefineForm, self).__init__(*args, **kwargs)
1006
1332
        self.status = status
1007
1333
 
1008
1334
    def clean_name(self):
1013
1339
            return data
1014
1340
 
1015
1341
    def clean(self):
1016
 
        cleaned_data = super(NodeGroupWithInterfacesForm, self).clean()
 
1342
        cleaned_data = super(NodeGroupDefineForm, self).clean()
1017
1343
        cluster_name = cleaned_data.get("cluster_name")
1018
1344
        uuid = cleaned_data.get("uuid")
1019
1345
        if uuid and not cluster_name:
1039
1365
        return interfaces
1040
1366
 
1041
1367
    def save(self):
1042
 
        nodegroup = super(NodeGroupWithInterfacesForm, self).save()
 
1368
        nodegroup = super(NodeGroupDefineForm, self).save()
 
1369
        nodegroup.ensure_boot_source_definition()
1043
1370
        for interface in self.cleaned_data['interfaces']:
1044
1371
            instance = NodeGroupInterface(nodegroup=nodegroup)
1045
1372
            form = NodeGroupInterfaceForm(data=interface, instance=instance)
1299
1626
                    if action_instance.is_permitted():
1300
1627
                        # Do not let execute() raise a redirect exception
1301
1628
                        # because this action is part of a bulk operation.
1302
 
                        action_instance.execute(allow_redirect=False)
1303
 
                        done += 1
 
1629
                        try:
 
1630
                            action_instance.execute(allow_redirect=False)
 
1631
                        except NodeActionError:
 
1632
                            not_actionable += 1
 
1633
                        else:
 
1634
                            done += 1
1304
1635
                    else:
1305
1636
                        not_permitted += 1
1306
1637
            else:
1336
1667
        transition was not allowed and the number of nodes for which the
1337
1668
        action could not be performed because the user does not have the
1338
1669
        required permission.
 
1670
 
 
1671
        Currently, in the event of a NodeActionError this is thrown into the
 
1672
        "not actionable" bucket in lieu of an overhaul of this form to
 
1673
        properly report errors for part-failing actions.  In this case
 
1674
        the transaction will still be valid for the actions that did complete
 
1675
        successfully.
1339
1676
        """
1340
1677
        action_name = self.cleaned_data['action']
1341
1678
        system_ids = self.cleaned_data['system_id']
1529
1866
    def save(self):
1530
1867
        """Disconnect the MAC addresses from the form's network."""
1531
1868
        self.network.macaddress_set.remove(*self.get_macs())
 
1869
 
 
1870
 
 
1871
class BootSourceForm(ModelForm):
 
1872
    """Form for the Boot Source API."""
 
1873
 
 
1874
    class Meta:
 
1875
        model = BootSource
 
1876
        fields = (
 
1877
            'url',
 
1878
            'keyring_filename',
 
1879
            'keyring_data',
 
1880
            )
 
1881
 
 
1882
    keyring_filename = forms.CharField(
 
1883
        label="The path to the keyring file for this BootSource.",
 
1884
        required=False)
 
1885
 
 
1886
    keyring_data = forms.FileField(
 
1887
        label="The GPG keyring for this BootSource, as a binary blob.",
 
1888
        required=False)
 
1889
 
 
1890
    def __init__(self, nodegroup=None, **kwargs):
 
1891
        super(BootSourceForm, self).__init__(**kwargs)
 
1892
        if 'instance' in kwargs:
 
1893
            self.nodegroup = kwargs['instance'].cluster
 
1894
        else:
 
1895
            self.nodegroup = nodegroup
 
1896
 
 
1897
    def clean_keyring_data(self):
 
1898
        """Process 'keyring_data' field.
 
1899
 
 
1900
        Return the InMemoryUploadedFile's content so that it can be
 
1901
        stored in the boot source's 'keyring_data' binary field.
 
1902
        """
 
1903
        data = self.cleaned_data.get('keyring_data', None)
 
1904
        if data is not None:
 
1905
            return data.read()
 
1906
        return data
 
1907
 
 
1908
    def save(self, *args, **kwargs):
 
1909
        boot_source = super(BootSourceForm, self).save(commit=False)
 
1910
        boot_source.cluster = self.nodegroup
 
1911
        if kwargs.get('commit', True):
 
1912
            boot_source.save(*args, **kwargs)
 
1913
        return boot_source
 
1914
 
 
1915
 
 
1916
class BootSourceSelectionForm(ModelForm):
 
1917
    """Form for the Boot Source Selection API."""
 
1918
 
 
1919
    class Meta:
 
1920
        model = BootSourceSelection
 
1921
        fields = (
 
1922
            'release',
 
1923
            'arches',
 
1924
            'subarches',
 
1925
            'labels',
 
1926
            )
 
1927
 
 
1928
    # Use UnconstrainedMultipleChoiceField fields for multiple-choices
 
1929
    # fields instead of the default (djorm-ext-pgarray's ArrayFormField):
 
1930
    # ArrayFormField deals with comma-separated lists and here we want to
 
1931
    # handle multiple-values submissions.
 
1932
    arches = UnconstrainedMultipleChoiceField(label="Architecture list")
 
1933
    subarches = UnconstrainedMultipleChoiceField(label="Subarchitecture list")
 
1934
    labels = UnconstrainedMultipleChoiceField(label="Label list")
 
1935
 
 
1936
    def __init__(self, boot_source=None, **kwargs):
 
1937
        super(BootSourceSelectionForm, self).__init__(**kwargs)
 
1938
        if 'instance' in kwargs:
 
1939
            self.boot_source = kwargs['instance'].boot_source
 
1940
        else:
 
1941
            self.boot_source = boot_source
 
1942
 
 
1943
    def save(self, *args, **kwargs):
 
1944
        boot_source_selection = super(
 
1945
            BootSourceSelectionForm, self).save(commit=False)
 
1946
        boot_source_selection.boot_source = self.boot_source
 
1947
        if kwargs.get('commit', True):
 
1948
            boot_source_selection.save(*args, **kwargs)
 
1949
        return boot_source_selection
 
1950
 
 
1951
 
 
1952
class LicenseKeyForm(ModelForm):
 
1953
    """Form for global license keys."""
 
1954
 
 
1955
    class Meta:
 
1956
        model = LicenseKey
 
1957
        fields = (
 
1958
            'osystem',
 
1959
            'distro_series',
 
1960
            'license_key',
 
1961
            )
 
1962
 
 
1963
    def __init__(self, *args, **kwargs):
 
1964
        super(LicenseKeyForm, self).__init__(*args, **kwargs)
 
1965
        self.set_up_osystem_and_distro_series_fields(kwargs.get('instance'))
 
1966
 
 
1967
    def set_up_osystem_and_distro_series_fields(self, instance):
 
1968
        """Create the `osystem` and `distro_series` fields.
 
1969
 
 
1970
        This needs to be done on the fly so that we can pass a dynamic list of
 
1971
        usable operating systems and distro_series.
 
1972
        """
 
1973
        osystems = list_all_usable_osystems(have_images=False)
 
1974
        releases = list_all_releases_requiring_keys(osystems)
 
1975
 
 
1976
        # Remove the operating systems that do not have any releases that
 
1977
        # require license keys. Don't want them to show up in the UI or be
 
1978
        # used in the API.
 
1979
        osystems = [
 
1980
            osystem
 
1981
            for osystem in osystems
 
1982
            if osystem.name in releases
 
1983
            ]
 
1984
 
 
1985
        os_choices = list_osystem_choices(osystems, include_default=False)
 
1986
        distro_choices = list_release_choices(
 
1987
            releases, include_default=False, include_latest=False,
 
1988
            with_key_required=False)
 
1989
        invalid_osystem_message = compose_invalid_choice_text(
 
1990
            'osystem', os_choices)
 
1991
        invalid_distro_series_message = compose_invalid_choice_text(
 
1992
            'distro_series', distro_choices)
 
1993
        self.fields['osystem'] = forms.ChoiceField(
 
1994
            label="OS", choices=os_choices, required=True,
 
1995
            error_messages={'invalid_choice': invalid_osystem_message})
 
1996
        self.fields['distro_series'] = forms.ChoiceField(
 
1997
            label="Release", choices=distro_choices, required=True,
 
1998
            error_messages={'invalid_choice': invalid_distro_series_message})
 
1999
        if instance is not None:
 
2000
            initial_value = get_distro_series_initial(
 
2001
                instance, with_key_required=False)
 
2002
            if instance is not None:
 
2003
                self.initial['distro_series'] = initial_value
 
2004
 
 
2005
    def full_clean(self):
 
2006
        # When this form is used from the API, the distro_series field will
 
2007
        # not be formatted correctly. This is to make it easy on the user, and
 
2008
        # not have to call the api with distro_series=os/series. This occurs
 
2009
        # in full_clean, so the value is correct before validation occurs on
 
2010
        # the distro_series field.
 
2011
        if 'distro_series' in self.data and 'osystem' in self.data:
 
2012
            if '/' not in self.data['distro_series']:
 
2013
                self.data['distro_series'] = '%s/%s' % (
 
2014
                    self.data['osystem'],
 
2015
                    self.data['distro_series'],
 
2016
                    )
 
2017
        super(LicenseKeyForm, self).full_clean()
 
2018
 
 
2019
    def clean(self):
 
2020
        """Validate distro_series and osystem match, and license_key is valid
 
2021
        for selected operating system and series."""
 
2022
        # Get the clean_data, check that all of the fields we need are
 
2023
        # present. If not then the form will error, so no reason to continue.
 
2024
        cleaned_data = super(LicenseKeyForm, self).clean()
 
2025
        required_fields = ['license_key', 'osystem', 'distro_series']
 
2026
        for field in required_fields:
 
2027
            if field not in cleaned_data:
 
2028
                return cleaned_data
 
2029
        cleaned_data['distro_series'] = self.clean_osystem_distro_series_field(
 
2030
            cleaned_data)
 
2031
        self.validate_license_key(cleaned_data)
 
2032
        return cleaned_data
 
2033
 
 
2034
    def clean_osystem_distro_series_field(self, cleaned_data):
 
2035
        """Validate that os/distro_series matches osystem, and update the
 
2036
        distro_series field, to remove the leading os/."""
 
2037
        cleaned_osystem = cleaned_data['osystem']
 
2038
        cleaned_series = cleaned_data['distro_series']
 
2039
        series_os, release = cleaned_series.split('/', 1)
 
2040
        if series_os != cleaned_osystem:
 
2041
            raise ValidationError(
 
2042
                "%s in distro_series does not match with "
 
2043
                "operating system %s" % (release, cleaned_osystem))
 
2044
        return release
 
2045
 
 
2046
    def validate_license_key(self, cleaned_data):
 
2047
        """Validates that the license key is valid."""
 
2048
        cleaned_key = cleaned_data['license_key']
 
2049
        cleaned_osystem = cleaned_data['osystem']
 
2050
        cleaned_series = cleaned_data['distro_series']
 
2051
        os_obj = OperatingSystemRegistry.get_item(cleaned_osystem)
 
2052
        if os_obj is None:
 
2053
            raise ValidationError(
 
2054
                "Failed to retrieve %s from os registry." % cleaned_osystem)
 
2055
        elif not os_obj.validate_license_key(cleaned_series, cleaned_key):
 
2056
            raise ValidationError("Invalid license key.")