← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jameinel/maas/cpu_mem_tag_constraints into lp:maas

 

John A Meinel has proposed merging lp:~jameinel/maas/cpu_mem_tag_constraints into lp:maas.

Commit message:
Add CPU count, Memory and Tags as exposed logic for get_available_node_for_acquisition.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jameinel/maas/cpu_mem_tag_constraints/+merge/126863

This is my version of gz`s branch: https://code.launchpad.net/~gz/maas/cpu_mem_tag_constraints/+merge/126502

Things were getting big enough that I evolved a helper class, which I think cleans things up and makes it easier to add more constraints in the future.

It also makes it more obvious to directly test that class.

And I changed the tag lookup logic, so it now gives InvalidConstraint rather than 404.

I *think* this helper class will even let us do the same constraints for a search interface, though we'll probably want to name it differently.
-- 
https://code.launchpad.net/~jameinel/maas/cpu_mem_tag_constraints/+merge/126863
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jameinel/maas/cpu_mem_tag_constraints into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-09-27 09:24:41 +0000
+++ src/maasserver/api.py	2012-09-28 06:39:21 +0000
@@ -643,6 +643,17 @@
         return ('nodes_handler', [])
 
 
+# Map the constraint as passed in the request to the name of the constraint in
+# the database. Many of them have the same name
+_constraints_map = {
+    'name': 'name',
+    'tags': 'tags',
+    'arch': 'arch',
+    'cpu_count': 'cpu_count',
+    'mem': 'memory',
+    }
+
+
 def extract_constraints(request_params):
     """Extract a dict of node allocation constraints from http parameters.
 
@@ -651,10 +662,12 @@
     :return: A mapping of applicable constraint names to their values.
     :rtype: :class:`dict`
     """
-    supported_constraints = ('name', 'arch')
-    return {constraint: request_params[constraint]
-        for constraint in supported_constraints
-            if constraint in request_params}
+    constraints = {}
+    for request_name in _constraints_map:
+        if request_name in request:
+            db_name = _constraints_map[request_name]
+            constraints[db_name] = request_params[request_name]
+    return constraints
 
 
 @api_operations

=== modified file 'src/maasserver/exceptions.py'
--- src/maasserver/exceptions.py	2012-08-21 20:14:12 +0000
+++ src/maasserver/exceptions.py	2012-09-28 06:39:21 +0000
@@ -84,6 +84,20 @@
     api_error = httplib.CONFLICT
 
 
+class InvalidConstraint(MAASAPIBadRequest):
+    """Node allocation constraint given cannot be interpreted."""
+
+    def __init__(self, constraint, value, err=None):
+        super(InvalidConstraint, self).__init__(constraint, value)
+        self.err = err
+
+    def __str__(self):
+        s = "Invalid '%s' constraint '%s'" % self.args
+        if self.err:
+            return "%s: %s" % (s, str(self.err))
+        return s
+
+
 class Redirect(MAASAPIException):
     """Redirect.  The exception message is the target URL."""
     api_error = httplib.FOUND

=== modified file 'src/maasserver/models/node.py'
--- src/maasserver/models/node.py	2012-09-26 08:54:29 +0000
+++ src/maasserver/models/node.py	2012-09-28 06:39:21 +0000
@@ -51,7 +51,10 @@
     NODE_STATUS_CHOICES,
     NODE_STATUS_CHOICES_DICT,
     )
-from maasserver.exceptions import NodeStateViolation
+from maasserver.exceptions import (
+    InvalidConstraint,
+    NodeStateViolation,
+    )
 from maasserver.fields import (
     JSONObjectField,
     XMLField,
@@ -61,7 +64,7 @@
 from maasserver.models.tag import Tag
 from maasserver.models.timestampedmodel import TimestampedModel
 from maasserver.utils import get_db_state
-from maasserver.utils.orm import get_first
+from maasserver.utils.orm import get_first, get_one
 from piston.models import Token
 from provisioningserver.enum import (
     POWER_TYPE,
@@ -250,20 +253,10 @@
         :type constraints: :class:`dict`
         :return: A matching `Node`, or None if none are available.
         """
-        if constraints is None:
-            constraints = {}
-        available_nodes = (
-            self.get_nodes(for_user, NODE_PERMISSION.VIEW)
-                .filter(status=NODE_STATUS.READY))
-
-        if constraints.get('name'):
-            available_nodes = available_nodes.filter(
-                hostname=constraints['name'])
-        if constraints.get('arch'):
-            # GZ 2012-09-11: This only supports an exact match on arch type,
-            #                using an i386 image on amd64 hardware will wait.
-            available_nodes = available_nodes.filter(
-                architecture=constraints['arch'])
+        available_nodes = self.get_nodes(for_user, NODE_PERMISSION.VIEW)
+        available_nodes = available_nodes.filter(status=NODE_STATUS.READY)
+        constrainer = AcquisitionConstrainer(available_nodes, constraints)
+        available_nodes = constrainer.apply()
 
         return get_first(available_nodes)
 
@@ -373,6 +366,61 @@
     node.save()
 
 
+class AcquisitionConstrainer:
+    """Add filters to a query for nodes, based on a constraints dict.
+    """
+
+    _known_constraints = ['name', 'architecture', 'cpu_count', 'memory',
+                          'tags']
+
+    def __init__(self, nodes, constraints):
+        self.nodes = nodes
+        self.constraints = constraints
+
+    def apply(self):
+        if not self.constraints:
+            return self.nodes
+
+        for constraint_name in self._known_constraints:
+            value = self.constraints.get(constraint_name)
+            if value:
+                attr_name = '_' + constraint_name
+                getattr(self, attr_name)(value)
+        return self.nodes
+
+    def _architecture(self, arch):
+        # GZ 2012-09-11: This only supports an exact match on arch type,
+        #                using an i386 image on amd64 hardware will wait.
+        self.nodes = self.nodes.filter(architecture=arch)
+
+    def _name(self, name):
+        self.nodes = self.nodes.filter(hostname=name)
+
+    def _int_gte_constraint(self, key, str_value):
+        try:
+            int_value = int(str_value)
+        except ValueError as e:
+            raise InvalidConstraint(key, str_value, e)
+        q = {key + "__gte": int_value}
+        self.nodes = self.nodes.filter(**q)
+
+    def _cpu_count(self, cpu_count):
+        self._int_gte_constraint('cpu_count', cpu_count)
+
+    def _memory(self, mem):
+        self._int_gte_constraint('memory', mem)
+
+    def _tags(self, tag_expression):
+        # We use ',' separated or space ' ' separated values.
+        tag_names = tag_expression.replace(",", " ").strip().split()
+        for tag_name in tag_names:
+            tag = get_one(Tag.objects.filter(name=tag_name))
+            if tag is None:
+                raise InvalidConstraint('tags', tag_name, 'No such tag')
+            self.nodes = self.nodes.filter(tags=tag)
+
+
+
 class Node(CleanSave, TimestampedModel):
     """A `Node` represents a physical machine used by the MAAS Server.
 

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-09-28 05:11:12 +0000
+++ src/maasserver/tests/test_api.py	2012-09-28 06:39:21 +0000
@@ -828,6 +828,11 @@
         self.logged_in_user.is_superuser = True
         self.logged_in_user.save()
 
+    def assertResponseCode(self, expected_code, response):
+        if response.status_code != expected_code:
+            self.fail("Expected %s response, got %s:\n%s" % (
+                expected_code, response.status_code, response.content))
+
 
 def extract_system_ids(parsed_result):
     """List the system_ids of the nodes in `parsed_result`."""
@@ -1712,6 +1717,81 @@
         })
         self.assertEqual(httplib.CONFLICT, response.status_code)
 
+    def test_POST_acquire_allocates_node_by_cpu(self):
+        # Asking for enough cpu acquires a node with at least that.
+        node = factory.make_node(status=NODE_STATUS.READY, cpu_count=3)
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'cpu_count': 2,
+        })
+        self.assertResponseCode(httplib.OK, response)
+        response_json = json.loads(response.content)
+        self.assertEqual(node.system_id, response_json['system_id'])
+
+    def test_POST_acquire_fails_with_invalid_cpu(self):
+        # Asking for an invalid amount of cpu returns a bad request.
+        factory.make_node(status=NODE_STATUS.READY)
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'cpu_count': 'plenty',
+        })
+        self.assertResponseCode(httplib.BAD_REQUEST, response)
+
+    def test_POST_acquire_allocates_node_by_mem(self):
+        # Asking for enough memory acquires a node with at least that.
+        node = factory.make_node(status=NODE_STATUS.READY, memory=1024)
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'mem': 1024,
+        })
+        self.assertResponseCode(httplib.OK, response)
+        response_json = json.loads(response.content)
+        self.assertEqual(node.system_id, response_json['system_id'])
+
+    def test_POST_acquire_fails_with_invalid_mem(self):
+        # Asking for an invalid amount of cpu returns a bad request.
+        factory.make_node(status=NODE_STATUS.READY)
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'mem': 'bags',
+        })
+        self.assertResponseCode(httplib.BAD_REQUEST, response)
+
+    def test_POST_acquire_allocates_node_by_tags(self):
+        # Asking for particular tags acquires a node with those tags.
+        node = factory.make_node(status=NODE_STATUS.READY)
+        node_tag_names = ["fast", "stable", "cute"]
+        node.tags = [factory.make_tag(t) for t in node_tag_names]
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'tags': 'fast, stable',
+        })
+        self.assertResponseCode(httplib.OK, response)
+        response_json = json.loads(response.content)
+        self.assertEqual(node_tag_names, response_json['tag_names'])
+
+    def test_POST_acquire_fails_without_all_tags(self):
+        # Asking for particular tags does not acquire if no node has all tags.
+        node1 = factory.make_node(status=NODE_STATUS.READY)
+        node1.tags = [factory.make_tag(t) for t in ("fast", "stable", "cute")]
+        node2 = factory.make_node(status=NODE_STATUS.READY)
+        node2.tags = [factory.make_tag("cheap")]
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'tags': 'fast, cheap',
+        })
+        self.assertResponseCode(httplib.CONFLICT, response)
+
+    def test_POST_acquire_fails_with_unknown_tags(self):
+        # Asking for a tag that does not exist gives a specific error.
+        node = factory.make_node(status=NODE_STATUS.READY)
+        node.tags = [factory.make_tag("fast")]
+        response = self.client.post(self.get_uri('nodes/'), {
+            'op': 'acquire',
+            'tags': 'fast, hairy',
+        })
+        self.assertResponseCode(httplib.NOT_FOUND, response)
+
     def test_POST_acquire_sets_a_token(self):
         # "acquire" should set the Token being used in the request on
         # the Node that is allocated.

=== modified file 'src/maasserver/tests/test_exceptions.py'
--- src/maasserver/tests/test_exceptions.py	2012-04-27 12:38:18 +0000
+++ src/maasserver/tests/test_exceptions.py	2012-09-28 06:39:21 +0000
@@ -15,6 +15,7 @@
 import httplib
 
 from maasserver.exceptions import (
+    InvalidConstraint,
     MAASAPIBadRequest,
     Redirect,
     )
@@ -38,3 +39,21 @@
         exception = Redirect(target)
         response = exception.make_http_response()
         self.assertEqual(target, extract_redirect(response))
+
+    def test_InvalidConstraint_is_bad_request(self):
+        err = InvalidConstraint("height", "-1", ValueError("Not positive"))
+        response = err.make_http_response()
+        self.assertEqual(httplib.BAD_REQUEST, response.status_code)
+
+    def test_InvalidConstraint_str_without_context(self):
+        err = InvalidConstraint("hue", "shiny")
+        self.assertEqual(str(err), "Invalid 'hue' constraint 'shiny'")
+
+    def test_InvalidConstraint_str_with_context(self):
+        try:
+            int("hundreds")
+        except ValueError as e:
+            context = e
+        err = InvalidConstraint("limbs", "hundreds", context)
+        self.assertEqual(str(err),
+            "Invalid 'limbs' constraint 'hundreds': " + str(context))

=== modified file 'src/maasserver/tests/test_node.py'
--- src/maasserver/tests/test_node.py	2012-09-26 08:54:29 +0000
+++ src/maasserver/tests/test_node.py	2012-09-28 06:39:21 +0000
@@ -19,6 +19,7 @@
     PermissionDenied,
     ValidationError,
     )
+from django.http import Http404
 from maasserver.enum import (
     ARCHITECTURE,
     DISTRO_SERIES,
@@ -27,14 +28,20 @@
     NODE_STATUS_CHOICES,
     NODE_STATUS_CHOICES_DICT,
     )
-from maasserver.exceptions import NodeStateViolation
+from maasserver.exceptions import (
+    InvalidConstraint,
+    NodeStateViolation,
+    )
 from maasserver.models import (
     Config,
     MACAddress,
     Node,
     node as node_module,
     )
-from maasserver.models.node import NODE_TRANSITIONS
+from maasserver.models.node import (
+    AcquisitionConstrainer,
+    NODE_TRANSITIONS,
+    )
 from maasserver.models.user import create_auth_token
 from maasserver.testing import reload_object
 from maasserver.testing.factory import factory
@@ -722,6 +729,88 @@
             Node.objects.get_available_node_for_acquisition(
                 user, {'arch': "sparc"}))
 
+    def test_get_available_node_with_cpu_enough(self):
+        user = factory.make_user()
+        node = self.make_node(cpu_count=2)
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'cpu_count': "2"})
+        self.assertEqual(node, available_node)
+
+    def test_get_available_node_with_cpu_not_enough(self):
+        user = factory.make_user()
+        node = self.make_node(cpu_count=1)
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'cpu_count': "2"})
+        self.assertEqual(None, available_node)
+
+    def test_get_available_node_with_cpu_invalid(self):
+        user = factory.make_user()
+        err = self.assertRaises(InvalidConstraint,
+            Node.objects.get_available_node_for_acquisition,
+            user, {'cpu_count': "lots"})
+        self.assertEqual(("cpu_count", "lots"), err.args)
+
+    def test_get_available_node_with_mem_enough(self):
+        user = factory.make_user()
+        node = self.make_node(memory=4096)
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'memory': "2048"})
+        self.assertEqual(node, available_node)
+
+    def test_get_available_node_with_mem_not_enough(self):
+        user = factory.make_user()
+        node = self.make_node(memory=4096)
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'memory': "4097"})
+        self.assertEqual(None, available_node)
+
+    def test_get_available_node_with_mem_invalid(self):
+        user = factory.make_user()
+        err = self.assertRaises(InvalidConstraint,
+            Node.objects.get_available_node_for_acquisition,
+            user, {'memory': "forgetful"})
+        self.assertEqual(("memory", "forgetful"), err.args)
+
+    def test_get_available_node_with_single_matching_tag(self):
+        user = factory.make_user()
+        node = self.make_node()
+        node.tags = [factory.make_tag("happy")]
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'tags': "happy"})
+        self.assertEqual(node, available_node)
+
+    def test_get_available_node_with_multiple_matching_tags(self):
+        user = factory.make_user()
+        node = self.make_node()
+        node.tags = [factory.make_tag("happy"), factory.make_tag("friendly")]
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'tags': "happy, friendly"})
+        self.assertEqual(node, available_node)
+
+    def test_get_available_node_with_single_mismatching_tag(self):
+        user = factory.make_user()
+        node = self.make_node()
+        node.tags = [factory.make_tag("happy")]
+        factory.make_tag("unhappy")
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'tags': "unhappy"})
+        self.assertEqual(None, available_node)
+
+    def test_get_available_node_with_partially_mismatching_tags(self):
+        user = factory.make_user()
+        node = self.make_node()
+        node.tags = [factory.make_tag("happy"), factory.make_tag("friendly")]
+        factory.make_tag("unfriendly")
+        available_node = Node.objects.get_available_node_for_acquisition(
+            user, {'tags': "happy, unfriendly"})
+        self.assertEqual(None, available_node)
+
+    def test_get_available_node_with_single_missing_tag(self):
+        user = factory.make_user()
+        self.assertRaises(Http404,
+            Node.objects.get_available_node_for_acquisition,
+            user, {'tags': "lonely"})
+
     def test_stop_nodes_stops_nodes(self):
         # We don't actually want to fire off power events, but we'll go
         # through the motions right up to the point where we'd normally
@@ -897,3 +986,84 @@
         node = factory.make_node(netboot=True)
         node.set_netboot(False)
         self.assertFalse(node.netboot)
+
+
+class TestAcquisitionConstrainer(TestCase):
+
+    def assertConstrainedNodes(self, expected_nodes, constraints):
+        nodes = Node.objects.all()
+        constrainer = AcquisitionConstrainer(nodes, constraints)
+        nodes = constrainer.apply()
+        self.assertItemsEqual(expected_nodes, nodes)
+
+    def test_no_constraints(self):
+        node1 = factory.make_node()
+        node2 = factory.make_node()
+        self.assertConstrainedNodes([node1, node2], None)
+        self.assertConstrainedNodes([node1, node2], {})
+
+    def test_name(self):
+        node1 = factory.make_node(set_hostname=True)
+        node2 = factory.make_node(set_hostname=True)
+        self.assertConstrainedNodes([node1], {'name': node1.hostname})
+        self.assertConstrainedNodes([node2], {'name': node2.hostname})
+
+    def test_architecture(self):
+        node1 = factory.make_node(architecture=ARCHITECTURE.i386)
+        node2 = factory.make_node(architecture=ARCHITECTURE.armhf)
+        self.assertConstrainedNodes([node1], {'architecture': 'i386'})
+        self.assertConstrainedNodes([node2], {'architecture': 'armhf'})
+
+    def test_cpu_count(self):
+        node1 = factory.make_node(cpu_count=1)
+        node2 = factory.make_node(cpu_count=2)
+        self.assertConstrainedNodes([node1, node2], {'cpu_count': '0'})
+        self.assertConstrainedNodes([node1, node2], {'cpu_count': '1'})
+        self.assertConstrainedNodes([node2], {'cpu_count': '2'})
+        self.assertConstrainedNodes([], {'cpu_count': '4'})
+        self.assertRaises(InvalidConstraint,
+            self.assertConstrainedNodes, [], {'cpu_count': 'notint'})
+
+    def test_memory(self):
+        node1 = factory.make_node(memory=1024)
+        node2 = factory.make_node(memory=4096)
+        self.assertConstrainedNodes([node1, node2], {'memory': '512'})
+        self.assertConstrainedNodes([node1, node2], {'memory': '1024'})
+        self.assertConstrainedNodes([node2], {'memory': '2048'})
+        self.assertConstrainedNodes([node2], {'memory': '4096'})
+        self.assertConstrainedNodes([], {'memory': '8192'})
+        self.assertRaises(InvalidConstraint,
+            self.assertConstrainedNodes, [], {'memory': 'notint'})
+
+    def test_tags(self):
+        tag_big = factory.make_tag(name='big')
+        tag_burly = factory.make_tag(name='burly')
+        node_big = factory.make_node()
+        node_big.tags.add(tag_big)
+        node_burly = factory.make_node()
+        node_burly.tags.add(tag_burly)
+        node_bignburly = factory.make_node()
+        node_bignburly.tags.add(tag_big)
+        node_bignburly.tags.add(tag_burly)
+        self.assertConstrainedNodes([node_big, node_bignburly],
+                                    {'tags': 'big'})
+        self.assertConstrainedNodes([node_burly, node_bignburly],
+                                    {'tags': 'burly'})
+        self.assertConstrainedNodes([node_bignburly],
+                                    {'tags': 'big,burly'})
+        self.assertConstrainedNodes([node_bignburly],
+                                    {'tags': 'big burly'})
+        self.assertRaises(InvalidConstraint,
+            self.assertConstrainedNodes, [], {'tags': 'big unknown'})
+
+    def test_combined_constraints(self):
+        tag_big = factory.make_tag(name='big')
+        node_big = factory.make_node(architecture=ARCHITECTURE.i386)
+        node_big.tags.add(tag_big)
+        node_small = factory.make_node(architecture=ARCHITECTURE.i386)
+        node_big_arm = factory.make_node(architecture=ARCHITECTURE.armhf)
+        node_big_arm.tags.add(tag_big)
+        self.assertConstrainedNodes([node_big, node_big_arm],
+                                    {'tags': 'big'})
+        self.assertConstrainedNodes([node_big],
+                                    {'architecture': 'i386', 'tags': 'big'})


Follow ups