Alberto Donato has proposed merging ~ack/maas:macaddress-field-as-text into 
maas:master.

Commit message:
use text db field for MACs, normalise values in MACAddressFormField



Requested reviews:
  MAAS Maintainers (maas-maintainers)

For more details, see:
https://code.launchpad.net/~ack/maas/+git/maas/+merge/438199
-- 
Your team MAAS Committers is subscribed to branch maas:master.
diff --git a/src/maasserver/api/nodes.py b/src/maasserver/api/nodes.py
index f6d58e6..5754853 100644
--- a/src/maasserver/api/nodes.py
+++ b/src/maasserver/api/nodes.py
@@ -41,7 +41,7 @@ from maasserver.exceptions import (
     NoScriptsFound,
     StaticIPAddressExhaustion,
 )
-from maasserver.fields import MAC_RE
+from maasserver.fields import MAC_RE, normalise_macaddress
 from maasserver.forms import BulkNodeSetZoneForm
 from maasserver.forms.ephemeral import TestForm
 from maasserver.models import Filesystem, Interface, Node, OwnerData
@@ -209,13 +209,17 @@ def filtered_nodes_list_from_request(request, model=None):
 
 def is_registered(request, ignore_statuses=None):
     """Used by both `NodesHandler` and `AnonNodesHandler`."""
-    mac_address = get_mandatory_param(request.GET, "mac_address")
-    interfaces = Interface.objects.filter(mac_address=mac_address)
-    interfaces = interfaces.exclude(node_config__node__isnull=True)
     if ignore_statuses is None:
         ignore_statuses = [NODE_STATUS.RETIRED]
-    interfaces = interfaces.exclude(
-        node_config__node__status__in=ignore_statuses
+
+    mac_address = normalise_macaddress(
+        get_mandatory_param(request.GET, "mac_address")
+    )
+
+    interfaces = (
+        Interface.objects.filter(mac_address=mac_address)
+        .exclude(node_config__node__isnull=True)
+        .exclude(node_config__node__status__in=ignore_statuses)
     )
     return interfaces.exists()
 
diff --git a/src/maasserver/api/tests/test_nodes.py b/src/maasserver/api/tests/test_nodes.py
index 91922f8..2b252b2 100644
--- a/src/maasserver/api/tests/test_nodes.py
+++ b/src/maasserver/api/tests/test_nodes.py
@@ -63,20 +63,14 @@ class TestIsRegisteredAnonAPI(APITestCase.ForAnonymousAndUserAndAdmin):
 
     def test_is_registered_normalizes_mac_address(self):
         node = self.make_node()
-        # These two non-normalized MAC addresses are the same.
-        non_normalized_mac_address = "AA-bb-cc-dd-ee-ff"
-        non_normalized_mac_address2 = "aabbccddeeff"
         factory.make_Interface(
             INTERFACE_TYPE.PHYSICAL,
-            mac_address=non_normalized_mac_address,
+            mac_address="aa:bb:cc:dd:ee:ff",
             node=node,
         )
         response = self.client.get(
             reverse("nodes_handler"),
-            {
-                "op": "is_registered",
-                "mac_address": non_normalized_mac_address2,
-            },
+            {"op": "is_registered", "mac_address": "aabbccddeeff"},
         )
         self.assertEqual(
             (http.client.OK.value, "true"),
diff --git a/src/maasserver/fields.py b/src/maasserver/fields.py
index 0d014ff..be67650 100644
--- a/src/maasserver/fields.py
+++ b/src/maasserver/fields.py
@@ -3,6 +3,7 @@
 
 """Custom model and form fields."""
 
+from itertools import chain
 import re
 
 from django import forms
@@ -40,28 +41,38 @@ MAC_VALIDATOR = RegexValidator(
     regex=MAC_RE, message="'%(value)s' is not a valid MAC address."
 )
 
+MAC_SPLIT_RE = re.compile(r"[-:.]")
 
-class MACAddressFormField(forms.CharField):
-    """Form field type: MAC address."""
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.validators.append(MAC_VALIDATOR)
+def normalise_macaddress(mac: str) -> str:
+    """Return a colon-separated format for the specified MAC.
 
+    This supports converting from input formats matching the MAC_RE regexp.
+    """
 
-class MACAddressField(Field):
-    """Model field type: MAC address."""
+    tokens = MAC_SPLIT_RE.split(mac.lower())
+    match len(tokens):
+        case 1:  # no separator
+            tokens = re.findall("..", tokens[0])
+        case 3:  # each token is two bytes
+            tokens = chain(
+                *(re.findall("..", token.zfill(4)) for token in tokens)
+            )
+        case _:  # single-byte tokens
+            tokens = (token.zfill(2) for token in tokens)
+    return ":".join(tokens)
 
-    description = "MAC address"
 
-    default_validators = [MAC_VALIDATOR]
+class MACAddressFormField(forms.CharField):
+    """Form field type: MAC address."""
 
-    def db_type(self, *args, **kwargs):
-        return "macaddr"
+    def validate(self, value):
+        if value:
+            MAC_VALIDATOR(value)
 
-    def get_prep_value(self, value):
-        # Convert empty string to None.
-        return super().get_prep_value(value) or None
+    def clean(self, value):
+        value = super().clean(value)
+        return normalise_macaddress(value)
 
 
 class XMLField(Field):
diff --git a/src/maasserver/forms/tests/test_machinewithmacaddresses.py b/src/maasserver/forms/tests/test_machinewithmacaddresses.py
index 2fed36d..6af84d5 100644
--- a/src/maasserver/forms/tests/test_machinewithmacaddresses.py
+++ b/src/maasserver/forms/tests/test_machinewithmacaddresses.py
@@ -62,6 +62,22 @@ class TestMachineWithMACAddressesForm(MAASServerTestCase):
         )
         self.assertEqual(architecture, form.cleaned_data["architecture"])
 
+    def test_valid_values_normalised(self):
+        architecture = make_usable_architecture(self)
+        form = MachineWithMACAddressesForm(
+            data=self.make_params(
+                mac_addresses=["aabbccddeeff", "9a-bb-c3-33-e5-7f"],
+                architecture=architecture,
+            )
+        )
+
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertEqual(
+            ["aa:bb:cc:dd:ee:ff", "9a:bb:c3:33:e5:7f"],
+            form.cleaned_data["mac_addresses"],
+        )
+        self.assertEqual(architecture, form.cleaned_data["architecture"])
+
     def test_simple_invalid(self):
         # If the form only has one (invalid) MAC address field to validate,
         # the error message in form.errors['mac_addresses'] is the
diff --git a/src/maasserver/migrations/fields.py b/src/maasserver/migrations/fields.py
index 2ccad90..edcee82 100644
--- a/src/maasserver/migrations/fields.py
+++ b/src/maasserver/migrations/fields.py
@@ -67,3 +67,13 @@ class JSONObjectField(Field):
         if form_class is None:
             form_class = forms.Field
         return super().formfield(form_class=form_class, **kwargs)
+
+
+class MACAddressField(Field):
+    """Model for MAC addresses."""
+
+    def db_type(self, *args, **kwargs):
+        return "macaddr"
+
+    def get_prep_value(self, value):
+        return super().get_prep_value(value) or None
diff --git a/src/maasserver/migrations/maasserver/0001_initial.py b/src/maasserver/migrations/maasserver/0001_initial.py
index ddff643..bf1d1a9 100644
--- a/src/maasserver/migrations/maasserver/0001_initial.py
+++ b/src/maasserver/migrations/maasserver/0001_initial.py
@@ -802,7 +802,9 @@ class Migration(migrations.Migration):
                 ),
                 (
                     "mac_address",
-                    maasserver.fields.MACAddressField(null=True, blank=True),
+                    maasserver.migrations.fields.MACAddressField(
+                        null=True, blank=True
+                    ),
                 ),
                 (
                     "ipv4_params",
@@ -1029,7 +1031,7 @@ class Migration(migrations.Migration):
                     "routers",
                     django.contrib.postgres.fields.ArrayField(
                         size=None,
-                        base_field=maasserver.fields.MACAddressField(),
+                        base_field=maasserver.migrations.fields.MACAddressField(),
                         null=True,
                         blank=True,
                         default=list,
diff --git a/src/maasserver/migrations/maasserver/0008_use_new_arrayfield.py b/src/maasserver/migrations/maasserver/0008_use_new_arrayfield.py
index 5f4d628..1fad8dc 100644
--- a/src/maasserver/migrations/maasserver/0008_use_new_arrayfield.py
+++ b/src/maasserver/migrations/maasserver/0008_use_new_arrayfield.py
@@ -2,6 +2,7 @@ import django.contrib.postgres.fields
 from django.db import migrations, models
 
 import maasserver.fields
+import maasserver.migrations.fields
 
 
 class Migration(migrations.Migration):
@@ -68,7 +69,7 @@ class Migration(migrations.Migration):
             name="routers",
             field=django.contrib.postgres.fields.ArrayField(
                 size=None,
-                base_field=maasserver.fields.MACAddressField(),
+                base_field=maasserver.migrations.fields.MACAddressField(),
                 null=True,
                 blank=True,
                 default=list,
diff --git a/src/maasserver/migrations/maasserver/0076_interface_discovery_rescue_mode.py b/src/maasserver/migrations/maasserver/0076_interface_discovery_rescue_mode.py
index 7ac37ef..12d4131 100644
--- a/src/maasserver/migrations/maasserver/0076_interface_discovery_rescue_mode.py
+++ b/src/maasserver/migrations/maasserver/0076_interface_discovery_rescue_mode.py
@@ -81,7 +81,7 @@ class Migration(migrations.Migration):
                 ("count", models.IntegerField(default=1)),
                 (
                     "mac_address",
-                    maasserver.fields.MACAddressField(
+                    maasserver.migrations.fields.MACAddressField(
                         null=True, blank=True, editable=False
                     ),
                 ),
diff --git a/src/maasserver/migrations/maasserver/0083_device_discovery.py b/src/maasserver/migrations/maasserver/0083_device_discovery.py
index 39a1b04..739695d 100644
--- a/src/maasserver/migrations/maasserver/0083_device_discovery.py
+++ b/src/maasserver/migrations/maasserver/0083_device_discovery.py
@@ -2,6 +2,7 @@ import django
 from django.db import migrations, models
 
 import maasserver.fields
+import maasserver.migrations.fields
 import maasserver.models.cleansave
 
 
@@ -39,7 +40,7 @@ class Migration(migrations.Migration):
                 ),
                 (
                     "mac_address",
-                    maasserver.fields.MACAddressField(
+                    maasserver.migrations.fields.MACAddressField(
                         blank=True, null=True, editable=False
                     ),
                 ),
diff --git a/src/maasserver/migrations/maasserver/0219_vm_nic_link.py b/src/maasserver/migrations/maasserver/0219_vm_nic_link.py
index ded377a..c5967b6 100644
--- a/src/maasserver/migrations/maasserver/0219_vm_nic_link.py
+++ b/src/maasserver/migrations/maasserver/0219_vm_nic_link.py
@@ -4,6 +4,7 @@ from django.db import migrations, models
 import django.db.models.deletion
 
 import maasserver.fields
+import maasserver.migrations.fields
 import maasserver.models.cleansave
 
 
@@ -29,7 +30,9 @@ class Migration(migrations.Migration):
                 ("updated", models.DateTimeField(editable=False)),
                 (
                     "mac_address",
-                    maasserver.fields.MACAddressField(blank=True, null=True),
+                    maasserver.migrations.fields.MACAddressField(
+                        blank=True, null=True
+                    ),
                 ),
                 (
                     "attachment_type",
diff --git a/src/maasserver/migrations/maasserver/0295_macaddress_text_field.py b/src/maasserver/migrations/maasserver/0295_macaddress_text_field.py
new file mode 100644
index 0000000..13fe495
--- /dev/null
+++ b/src/maasserver/migrations/maasserver/0295_macaddress_text_field.py
@@ -0,0 +1,63 @@
+# Generated by Django 3.2.12 on 2023-03-01 16:07
+
+import re
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("maasserver", "0294_keyring_data_binary_field"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="interface",
+            name="mac_address",
+            field=models.TextField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.RegexValidator(
+                        message="'%(value)s' is not a valid MAC address.",
+                        regex=re.compile(
+                            "^([0-9a-fA-F]{1,2}:){5}[0-9a-fA-F]{1,2}|([0-9a-fA-F]{1,2}-){5}[0-9a-fA-F]{1,2}|([0-9a-fA-F]{3,4}.){2}[0-9a-fA-F]{3,4}$"
+                        ),
+                    )
+                ],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="neighbour",
+            name="mac_address",
+            field=models.TextField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.RegexValidator(
+                        message="'%(value)s' is not a valid MAC address.",
+                        regex=re.compile(
+                            "^([0-9a-fA-F]{1,2}:){5}[0-9a-fA-F]{1,2}|([0-9a-fA-F]{1,2}-){5}[0-9a-fA-F]{1,2}|([0-9a-fA-F]{3,4}.){2}[0-9a-fA-F]{3,4}$"
+                        ),
+                    )
+                ],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="virtualmachineinterface",
+            name="mac_address",
+            field=models.TextField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.RegexValidator(
+                        message="'%(value)s' is not a valid MAC address.",
+                        regex=re.compile(
+                            "^([0-9a-fA-F]{1,2}:){5}[0-9a-fA-F]{1,2}|([0-9a-fA-F]{1,2}-){5}[0-9a-fA-F]{1,2}|([0-9a-fA-F]{3,4}.){2}[0-9a-fA-F]{3,4}$"
+                        ),
+                    )
+                ],
+            ),
+        ),
+    ]
diff --git a/src/maasserver/models/discovery.py b/src/maasserver/models/discovery.py
index 21bf8ff..60061f7 100644
--- a/src/maasserver/models/discovery.py
+++ b/src/maasserver/models/discovery.py
@@ -13,10 +13,11 @@ from django.db.models import (
     GenericIPAddressField,
     IntegerField,
     Manager,
+    TextField,
 )
 from django.db.models.query import QuerySet
 
-from maasserver.fields import CIDRField, DomainNameField, MACAddressField
+from maasserver.fields import CIDRField, DomainNameField, MAC_VALIDATOR
 from maasserver.models.cleansave import CleanSave
 from maasserver.models.viewmodel import ViewModel
 from maasserver.utils.orm import MAASQueriesMixin
@@ -208,8 +209,11 @@ class Discovery(CleanSave, ViewModel):
         verbose_name="IP",
     )
 
-    mac_address = MACAddressField(
-        unique=False, null=True, blank=True, editable=False
+    mac_address = TextField(
+        unique=False,
+        null=True,
+        blank=True,
+        validators=[MAC_VALIDATOR],
     )
 
     first_seen = DateTimeField(editable=False)
diff --git a/src/maasserver/models/interface.py b/src/maasserver/models/interface.py
index 1b89447..a861807 100644
--- a/src/maasserver/models/interface.py
+++ b/src/maasserver/models/interface.py
@@ -49,7 +49,7 @@ from maasserver.exceptions import (
     StaticIPAddressOutOfRange,
     StaticIPAddressUnavailable,
 )
-from maasserver.fields import MAC_VALIDATOR, MACAddressField
+from maasserver.fields import MAC_VALIDATOR
 from maasserver.models.cleansave import CleanSave
 from maasserver.models.staticipaddress import StaticIPAddress
 from maasserver.models.timestampedmodel import TimestampedModel
@@ -575,7 +575,7 @@ class Interface(CleanSave, TimestampedModel):
         "StaticIPAddress", editable=True, blank=True
     )
 
-    mac_address = MACAddressField(unique=False, null=True, blank=True)
+    mac_address = TextField(null=True, blank=True, validators=[MAC_VALIDATOR])
 
     ipv4_params = JSONField(blank=True, default=dict)
 
diff --git a/src/maasserver/models/neighbour.py b/src/maasserver/models/neighbour.py
index 9b88885..36b301d 100644
--- a/src/maasserver/models/neighbour.py
+++ b/src/maasserver/models/neighbour.py
@@ -10,10 +10,11 @@ from django.db.models import (
     GenericIPAddressField,
     IntegerField,
     Manager,
+    TextField,
 )
 from django.db.models.query import QuerySet
 
-from maasserver.fields import MACAddressField
+from maasserver.fields import MAC_VALIDATOR
 from maasserver.models.cleansave import CleanSave
 from maasserver.models.interface import Interface
 from maasserver.models.timestampedmodel import TimestampedModel
@@ -171,9 +172,7 @@ class Neighbour(CleanSave, TimestampedModel):
     )
 
     # Observed MAC address.
-    mac_address = MACAddressField(
-        unique=False, null=True, blank=True, editable=False
-    )
+    mac_address = TextField(null=True, blank=True, validators=[MAC_VALIDATOR])
 
     objects = NeighbourManager()
 
diff --git a/src/maasserver/models/virtualmachine.py b/src/maasserver/models/virtualmachine.py
index 55c7900..b43da7f 100644
--- a/src/maasserver/models/virtualmachine.py
+++ b/src/maasserver/models/virtualmachine.py
@@ -30,7 +30,7 @@ from django.db.models import (
 from django.db.models.constraints import UniqueConstraint
 from django.db.models.functions import Coalesce
 
-from maasserver.fields import MACAddressField
+from maasserver.fields import MAC_VALIDATOR
 from maasserver.models.blockdevice import BlockDevice
 from maasserver.models.bmc import BMC
 from maasserver.models.cleansave import CleanSave
@@ -125,7 +125,7 @@ class VirtualMachineInterface(CleanSave, TimestampedModel):
         on_delete=CASCADE,
         related_name="+",
     )
-    mac_address = MACAddressField(null=True, blank=True)
+    mac_address = TextField(null=True, blank=True, validators=[MAC_VALIDATOR])
     host_interface = ForeignKey(Interface, null=True, on_delete=SET_NULL)
     attachment_type = CharField(
         max_length=10,
diff --git a/src/maasserver/pytest_tests/test_fields.py b/src/maasserver/pytest_tests/test_fields.py
new file mode 100644
index 0000000..a8f8736
--- /dev/null
+++ b/src/maasserver/pytest_tests/test_fields.py
@@ -0,0 +1,39 @@
+from django.core.exceptions import ValidationError
+import pytest
+
+from maasserver.fields import MACAddressFormField, normalise_macaddress
+
+
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        ("aabbccddeeff", "aa:bb:cc:dd:ee:ff"),
+        ("aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:ff"),
+        ("aa:b:cc:d:ee:f", "aa:0b:cc:0d:ee:0f"),
+        ("aa-bb-cc-dd-ee-ff", "aa:bb:cc:dd:ee:ff"),
+        ("aa-b-cc-d-ee-f", "aa:0b:cc:0d:ee:0f"),
+        ("aabb.ccdd.eeff", "aa:bb:cc:dd:ee:ff"),
+        ("abb.cdd.eeff", "0a:bb:0c:dd:ee:ff"),
+    ],
+)
+def test_normalise_mac_address(value, expected):
+    assert normalise_macaddress(value) == expected
+
+
+class TestMACAddressFormField:
+    def test_validate_valid(self):
+        assert MACAddressFormField().validate("aa-bb-cc-dd-ee-ff") is None
+
+    def test_validate_invalid(self):
+        with pytest.raises(ValidationError):
+            MACAddressFormField().validate("invalid")
+
+    @pytest.mark.parametrize("value", ["", None])
+    def test_validate_empty(self, value):
+        assert MACAddressFormField().validate(value) is None
+
+    def test_normalise_mac_format(self):
+        assert (
+            MACAddressFormField().clean("aa-bb-cc-dd-ee-ff")
+            == "aa:bb:cc:dd:ee:ff"
+        )
diff --git a/src/maasserver/tests/test_fields.py b/src/maasserver/tests/test_fields.py
index 24014da..643366f 100644
--- a/src/maasserver/tests/test_fields.py
+++ b/src/maasserver/tests/test_fields.py
@@ -9,7 +9,6 @@ from django.db import connection, DatabaseError
 from psycopg2 import OperationalError
 from testtools import ExpectedException
 
-from maasserver.enum import INTERFACE_TYPE
 from maasserver.fields import (
     HostListFormField,
     IPListFormField,
@@ -23,7 +22,7 @@ from maasserver.fields import (
     URLOrPPAValidator,
     VersionedTextFileField,
 )
-from maasserver.models import Interface, Node, VersionedTextFile
+from maasserver.models import Node, VersionedTextFile
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import (
     MAASLegacyServerTestCase,
@@ -61,15 +60,6 @@ class TestModelNameValidator(MAASServerTestCase):
         )
 
 
-class TestMACAddressField(MAASServerTestCase):
-    def test_mac_address_is_stored_normalized_and_loaded(self):
-        interface = factory.make_Interface(
-            INTERFACE_TYPE.PHYSICAL, mac_address=" AA-bb-CC-dd-EE-Ff "
-        )
-        loaded_mac = Interface.objects.get(id=interface.id)
-        self.assertEqual("aa:bb:cc:dd:ee:ff", loaded_mac.mac_address)
-
-
 class TestXMLField(MAASLegacyServerTestCase):
     apps = ["maasserver.tests"]
 
-- 
Mailing list: https://launchpad.net/~sts-sponsors
Post to     : sts-sponsors@lists.launchpad.net
Unsubscribe : https://launchpad.net/~sts-sponsors
More help   : https://help.launchpad.net/ListHelp

Reply via email to