Repository: juju/python-libjuju

Issue:
Inconsistent handling of contraints in juju cli and python-libjuju
When trying to limit a charm to only deploy to machines with a minimum amount of disk space, I noticed that juju command line and python-libjuju validate syntax of constraints differently. This happened in a (wrong) attempt of use a relational ">=".


In juju cli, we got the error as expected:

    jenkins@juju-38dff2-0:~$ juju deploy ubuntu --constraints 'tags=scalebot-dev-controller arch=amd64 root-disk>=16G'
    ERROR unknown constraint "root-disk>"
    jenkins@juju-38dff2-0:~$ juju deploy ubuntu --constraints 'tags=scalebot-dev-controller arch=amd64 root-disk=16G'
    Located charm "cs:ubuntu-18".
    Deploying charm "cs:ubuntu-18".
    jenkins@juju-38dff2-0:~$ juju deploy ubuntu --constraints 'tags=scalebot-dev-controller arch=amd64 root-disk>16G'
    ERROR malformed constraint "root-disk>16G"
    jenkins@juju-38dff2-0:~$ 


With python-libjuju 2.9.7:

    Python 3.8.0 (default, Dec  9 2021, 17:53:27) 
    [GCC 8.4.0] on linux
    Type "help", "copyright", "credits" or "license" for more information.
    >>> import juju
    >>> import juju.constraints
    >>> juju.constraints.parse('tags=scalebot-dev-controller arch=amd64 root-disk>=16G')
    {'tags': ['scalebot-dev-controller'], 'arch': 'amd64', 'root_disk>': 16384}
    >>> 


If we try to deploy using the API with something similar to:

        application = await model.deploy(
            "ubuntu",
            application_name="ubuntu",
            series=str_series,
            channel="stable",
            constraints=constraints.parse(str_constraints),
        )

... the invalid constraint 'root_disk>' will be sent to MAAS having not effect but also raising no exception. I only tested with MAAS, but other cloud providers may also fail to parse invalid constraints.



Discussion:
cderici: !!build!!
cderici: $$merge$$

Patch:
diff --git a/juju/constraints.py b/juju/constraints.py
index 3191f28..5c141ed 100644
--- a/juju/constraints.py
+++ b/juju/constraints.py
@@ -32,6 +32,25 @@ FACTORS = {
     "Y": 1024 ** 6
 }
 
+# List of supported constraint keys, see
+# http://github.com/cderici/juju/blob/2.9/core/constraints/constraints.go#L20-L39
+SUPPORTED_KEYS = [
+    "arch",
+    "container",
+    "cpu_cores",
+    "cores",
+    "cpu_power",
+    "mem",
+    "root_disk",
+    "root_disk_source",
+    "tags",
+    "instance_role",
+    "instance_type",
+    "spaces",
+    "virt_type",
+    "zones",
+    "allocate_public_ip"]
+
 LIST_KEYS = {'tags', 'spaces'}
 
 SNAKE1 = re.compile(r'(.)([A-Z][a-z]+)')
@@ -51,17 +70,20 @@ def parse(constraints):
         # Fowards compatibilty: already parsed
         return constraints
 
-    constraints = {
-        normalize_key(k): (
-            normalize_list_value(v) if k in LIST_KEYS else
-            normalize_value(v)
-        ) for k, v in [s.split("=") for s in constraints.split(" ")]}
+    normalized_constraints = {}
+    for s in constraints.split(" "):
+        if "=" not in s:
+            raise Exception("malformed constraint %s" % s)
+
+        k, v = s.split("=")
+        normalized_constraints[normalize_key(k)] = normalize_list_value(v) if\
+            k in LIST_KEYS else normalize_value(v)
 
-    return constraints
+    return normalized_constraints
 
 
-def normalize_key(key):
-    key = key.strip()
+def normalize_key(orig_key):
+    key = orig_key.strip()
 
     key = key.replace("-", "_")  # Our _client lib wants "_" in place of "-"
 
@@ -69,6 +91,8 @@ def normalize_key(key):
     key = SNAKE1.sub(r'\1_\2', key)
     key = SNAKE2.sub(r'\1_\2', key).lower()
 
+    if key not in SUPPORTED_KEYS:
+        raise Exception("unknown constraint in %s" % orig_key)
     return key
 
 


Test patch:
diff --git a/tests/unit/test_constraints.py b/tests/unit/test_constraints.py
index f76f516..244883e 100644
--- a/tests/unit/test_constraints.py
+++ b/tests/unit/test_constraints.py
@@ -20,11 +20,13 @@ class TestConstraints(unittest.TestCase):
     def test_normalize_key(self):
         _ = constraints.normalize_key
 
-        self.assertEqual(_("test-key"), "test_key")
-        self.assertEqual(_("test-key  "), "test_key")
-        self.assertEqual(_("  test-key"), "test_key")
-        self.assertEqual(_("TestKey"), "test_key")
-        self.assertEqual(_("testKey"), "test_key")
+        self.assertEqual(_("root-disk"), "root_disk")
+        self.assertEqual(_("root-disk  "), "root_disk")
+        self.assertEqual(_("  root-disk"), "root_disk")
+        self.assertEqual(_("RootDisk"), "root_disk")
+        self.assertEqual(_("rootDisk"), "root_disk")
+
+        self.assertRaises(Exception, lambda: _("not-one-of-the-supported-keys"))
 
     def test_normalize_val(self):
         _ = constraints.normalize_value
@@ -53,13 +55,16 @@ class TestConstraints(unittest.TestCase):
         )
 
         self.assertEqual(
-            _("mem=10G foo=bar,baz tags=tag1 spaces=space1,space2"),
+            _("mem=10G zones=bar,baz tags=tag1 spaces=space1,space2"),
             {"mem": 10 * 1024,
-             "foo": "bar,baz",
+             "zones": "bar,baz",
              "tags": ["tag1"],
              "spaces": ["space1", "space2"]}
         )
 
+        self.assertRaises(Exception, lambda: _("root-disk>16G"))
+        self.assertRaises(Exception, lambda: _("root-disk>=16G"))
+
     def test_parse_storage_constraint(self):
         _ = constraints.parse_storage_constraint