Merge pull request #7 from damjanek/master
Adding couple more features to formula
diff --git a/README.rst b/README.rst
index 95cdd09..e07e7e6 100644
--- a/README.rst
+++ b/README.rst
@@ -20,24 +20,104 @@
.. code-block:: yaml
- maas:
- region:
- theme: theme
- bind:
- host: localhost
- port: 80
- admin:
- username: admin
- password: password
- email: email@example.com
- database:
- engine: postgresql
- host: localhost
- name: maasdb
- password: password
- username: maas
- enabled: true
+ maas:
+ region:
+ theme: mirantis
+ bind:
+ host: 192.168.0.10:5240
+ port: 5240
+ admin:
+ username: exampleuser
+ password: examplepassword
+ email: email@example.com
+ database:
+ engine: null
+ host: localhost
+ name: maasdb
+ password: qwqwqw
+ username: maas
+ enabled: true
+ user: mirantis
+ token: "89EgtWkX45ddjMYpuL:SqVjxFG87Dr6kVf4Wp:5WLfbUgmm9XQtJxm3V2LUUy7bpCmqmnk"
+ fabrics:
+ test-fabric:
+ description: Test fabric
+ subnets:
+ subnet1:
+ fabric: test-fabric
+ cidr: 2.2.3.0/24
+ gateway_ip: 2.2.3.2
+ iprange:
+ start: 2.2.3.20
+ end: 2.2.3.250
+ dhcp_snippets:
+ test-snippet:
+ value: option bootfile-name "tftp://192.168.0.10/snippet";
+ description: Test snippet
+ enabled: true
+ subnet: subnet1
+ boot_resources:
+ bootscript1:
+ title: bootscript
+ architecture: amd64/generic
+ filetype: tgz
+ content: /srv/salt/reclass/nodes/path_to_file
+ package_repositories:
+ Saltstack:
+ url: http://repo.saltstack.com/apt/ubuntu/14.04/amd64/2016.3/
+ distributions:
+ - trusty
+ components:
+ - main
+ - extra
+ arches: amd64
+ key: "-----BEGIN PGP PUBLIC KEY BLOCK-----
+ Version: GnuPG v2
+ mQENBFOpvpgBCADkP656H41i8fpplEEB8IeLhugyC2rTEwwSclb8tQNYtUiGdna9
+ m38kb0OS2DDrEdtdQb2hWCnswxaAkUunb2qq18vd3dBvlnI+C4/xu5ksZZkRj+fW
+ tArNR18V+2jkwcG26m8AxIrT+m4M6/bgnSfHTBtT5adNfVcTHqiT1JtCbQcXmwVw
+ WbqS6v/LhcsBE//SHne4uBCK/GHxZHhQ5jz5h+3vWeV4gvxS3Xu6v1IlIpLDwUts
+ kT1DumfynYnnZmWTGc6SYyIFXTPJLtnoWDb9OBdWgZxXfHEcBsKGha+bXO+m2tHA
+ gNneN9i5f8oNxo5njrL8jkCckOpNpng18BKXABEBAAG0MlNhbHRTdGFjayBQYWNr
+ YWdpbmcgVGVhbSA8cGFja2FnaW5nQHNhbHRzdGFjay5jb20+iQE4BBMBAgAiBQJT
+ qb6YAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRAOCKFJ3le/vhkqB/0Q
+ WzELZf4d87WApzolLG+zpsJKtt/ueXL1W1KA7JILhXB1uyvVORt8uA9FjmE083o1
+ yE66wCya7V8hjNn2lkLXboOUd1UTErlRg1GYbIt++VPscTxHxwpjDGxDB1/fiX2o
+ nK5SEpuj4IeIPJVE/uLNAwZyfX8DArLVJ5h8lknwiHlQLGlnOu9ulEAejwAKt9CU
+ 4oYTszYM4xrbtjB/fR+mPnYh2fBoQO4d/NQiejIEyd9IEEMd/03AJQBuMux62tjA
+ /NwvQ9eqNgLw9NisFNHRWtP4jhAOsshv1WW+zPzu3ozoO+lLHixUIz7fqRk38q8Q
+ 9oNR31KvrkSNrFbA3D89uQENBFOpvpgBCADJ79iH10AfAfpTBEQwa6vzUI3Eltqb
+ 9aZ0xbZV8V/8pnuU7rqM7Z+nJgldibFk4gFG2bHCG1C5aEH/FmcOMvTKDhJSFQUx
+ uhgxttMArXm2c22OSy1hpsnVG68G32Nag/QFEJ++3hNnbyGZpHnPiYgej3FrerQJ
+ zv456wIsxRDMvJ1NZQB3twoCqwapC6FJE2hukSdWB5yCYpWlZJXBKzlYz/gwD/Fr
+ GL578WrLhKw3UvnJmlpqQaDKwmV2s7MsoZogC6wkHE92kGPG2GmoRD3ALjmCvN1E
+ PsIsQGnwpcXsRpYVCoW7e2nW4wUf7IkFZ94yOCmUq6WreWI4NggRcFC5ABEBAAGJ
+ AR8EGAECAAkFAlOpvpgCGwwACgkQDgihSd5Xv74/NggA08kEdBkiWWwJZUZEy7cK
+ WWcgjnRuOHd4rPeT+vQbOWGu6x4bxuVf9aTiYkf7ZjVF2lPn97EXOEGFWPZeZbH4
+ vdRFH9jMtP+rrLt6+3c9j0M8SIJYwBL1+CNpEC/BuHj/Ra/cmnG5ZNhYebm76h5f
+ T9iPW9fFww36FzFka4VPlvA4oB7ebBtquFg3sdQNU/MmTVV4jPFWXxh4oRDDR+8N
+ 1bcPnbB11b5ary99F/mqr7RgQ+YFF0uKRE3SKa7a+6cIuHEZ7Za+zhPaQlzAOZlx
+ fuBmScum8uQTrEF5+Um5zkwC7EXTdH1co/+/V/fpOtxIg4XO4kcugZefVm5ERfVS
+ MA==
+ =dtMN
+ -----END PGP PUBLIC KEY BLOCK-----"
+ enabled: true
+ machines:
+ machine1:
+ interfaces:
+ - one1: "11:22:33:44:55:66"
+ power_parameters:
+ power_type: ipmi
+ power_address: '192.168.10.10'
+ power_user: bmc_user
+ power_password: bmc_password
+ devices:
+ machine1-ipmi:
+ interface:
+ ip_address: 192.168.10.10
+ subnet: cidr:192.168.10.0/24
+ mac: '66:55:44:33:22:11'
Single MAAS cluster service [multiple racks]
diff --git a/_modules/apiclient/__init__.py b/_modules/apiclient/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/_modules/apiclient/__init__.py
diff --git a/_modules/apiclient/creds.py b/_modules/apiclient/creds.py
new file mode 100644
index 0000000..5cace7e
--- /dev/null
+++ b/_modules/apiclient/creds.py
@@ -0,0 +1,48 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Handling of MAAS API credentials.
+
+The API client deals with credentials consisting of 3 elements: consumer
+key, resource token, and resource secret. These are in OAuth, but the
+consumer secret is hardwired to the empty string.
+
+Credentials are represented internally as tuples of these three elements,
+but can also be converted to a colon-separated string format for easy
+transport between processes.
+"""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ 'convert_string_to_tuple',
+ 'convert_tuple_to_string',
+ ]
+
+
+def convert_tuple_to_string(creds_tuple):
+ """Represent a MAAS API credentials tuple as a colon-separated string."""
+ if len(creds_tuple) != 3:
+ raise ValueError(
+ "Credentials tuple does not consist of 3 elements as expected; "
+ "it contains %d."
+ % len(creds_tuple))
+ return ':'.join(creds_tuple)
+
+
+def convert_string_to_tuple(creds_string):
+ """Recreate a MAAS API credentials tuple from a colon-separated string."""
+ creds_tuple = tuple(creds_string.split(':'))
+ if len(creds_tuple) != 3:
+ raise ValueError(
+ "Malformed credentials string. Expected 3 colon-separated items, "
+ "got %r."
+ % creds_string)
+ return creds_tuple
diff --git a/_modules/apiclient/encode_json.py b/_modules/apiclient/encode_json.py
new file mode 100644
index 0000000..534ac9e
--- /dev/null
+++ b/_modules/apiclient/encode_json.py
@@ -0,0 +1,34 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Encoding requests as JSON data."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ 'encode_json_data',
+ ]
+
+import json
+
+
+def encode_json_data(params):
+ """Encode params as JSON and set up headers for an HTTP POST.
+
+ :param params: Can be a dict or a list, but should generally be a dict, to
+ match the key-value data expected by most receiving APIs.
+ :return: (body, headers)
+ """
+ body = json.dumps(params)
+ headers = {
+ 'Content-Length': unicode(len(body)),
+ 'Content-Type': 'application/json',
+ }
+ return body, headers
diff --git a/_modules/apiclient/maas_client.py b/_modules/apiclient/maas_client.py
new file mode 100644
index 0000000..530e6b2
--- /dev/null
+++ b/_modules/apiclient/maas_client.py
@@ -0,0 +1,245 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""MAAS OAuth API connection library."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ 'MAASClient',
+ 'MAASDispatcher',
+ 'MAASOAuth',
+ ]
+
+import gzip
+from io import BytesIO
+import urllib2
+
+from apiclient.encode_json import encode_json_data
+from apiclient.multipart import encode_multipart_data
+from apiclient.utils import urlencode
+import oauth.oauth as oauth
+
+
+class MAASOAuth:
+ """Helper class to OAuth-sign an HTTP request."""
+
+ def __init__(self, consumer_key, resource_token, resource_secret):
+ resource_tok_string = "oauth_token_secret=%s&oauth_token=%s" % (
+ resource_secret, resource_token)
+ self.resource_token = oauth.OAuthToken.from_string(resource_tok_string)
+ self.consumer_token = oauth.OAuthConsumer(consumer_key, "")
+
+ def sign_request(self, url, headers):
+ """Sign a request.
+
+ @param url: The URL to which the request is to be sent.
+ @param headers: The headers in the request. These will be updated
+ with the signature.
+ """
+ oauth_request = oauth.OAuthRequest.from_consumer_and_token(
+ self.consumer_token, token=self.resource_token, http_url=url)
+ oauth_request.sign_request(
+ oauth.OAuthSignatureMethod_PLAINTEXT(), self.consumer_token,
+ self.resource_token)
+ headers.update(oauth_request.to_header())
+
+
+class NoAuth:
+ """Anonymous authentication class for making unauthenticated requests."""
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def sign_request(self, *args, **kwargs):
+ """Go through the motions of signing a request.
+
+ Since this class does not really authenticate, this does nothing.
+ """
+
+
+class RequestWithMethod(urllib2.Request):
+ """Enhances urllib2.Request so an http method can be supplied."""
+ def __init__(self, *args, **kwargs):
+ self._method = kwargs.pop('method', None)
+ urllib2.Request.__init__(self, *args, **kwargs)
+
+ def get_method(self):
+ return (
+ self._method if self._method
+ else super(RequestWithMethod, self).get_method())
+
+
+class MAASDispatcher:
+ """Helper class to connect to a MAAS server using blocking requests.
+
+ Be careful when changing its API: this class is designed so that it
+ can be replaced with a Twisted-enabled alternative. See the MAAS
+ provider in Juju for the code this would require.
+ """
+
+ def dispatch_query(self, request_url, headers, method="GET", data=None):
+ """Synchronously dispatch an OAuth-signed request to L{request_url}.
+
+ :param request_url: The URL to which the request is to be sent.
+ :param headers: Headers to include in the request.
+ :type headers: A dict.
+ :param method: The HTTP method, e.g. C{GET}, C{POST}, etc.
+ An AssertionError is raised if trying to pass data for a GET.
+ :param data: The data to send, if any.
+ :type data: A byte string.
+
+ :return: A open file-like object that contains the response.
+ """
+ headers = dict(headers)
+ # header keys are case insensitive, so we have to pass over them
+ set_accept_encoding = False
+ for key in headers:
+ if key.lower() == 'accept-encoding':
+ # The user already supplied a requested encoding, so just pass
+ # it along.
+ break
+ else:
+ set_accept_encoding = True
+ headers['Accept-encoding'] = 'gzip'
+ req = RequestWithMethod(request_url, data, headers, method=method)
+ res = urllib2.urlopen(req)
+ # If we set the Accept-encoding header, then we decode the header for
+ # the caller.
+ is_gzip = (
+ set_accept_encoding
+ and res.info().get('Content-Encoding') == 'gzip')
+ if is_gzip:
+ # Workaround python's gzip failure, gzip.GzipFile wants to be able
+ # to seek the file object.
+ res_content_io = BytesIO(res.read())
+ ungz = gzip.GzipFile(mode='rb', fileobj=res_content_io)
+ res = urllib2.addinfourl(ungz, res.headers, res.url, res.code)
+ return res
+
+
+class MAASClient:
+ """Base class for connecting to MAAS servers.
+
+ All "path" parameters can be either a string describing an absolute
+ resource path, or a sequence of items that, when represented as unicode,
+ make up the elements of the resource's path. So `['nodes', node_id]`
+ is equivalent to `"nodes/%s" % node_id`.
+ """
+
+ def __init__(self, auth, dispatcher, base_url):
+ """Intialise the client.
+
+ :param auth: A `MAASOAuth` to sign requests.
+ :param dispatcher: An object implementing the MAASOAuthConnection
+ base class.
+ :param base_url: The base URL for the MAAS server, e.g.
+ http://my.maas.com:5240/
+ """
+ self.dispatcher = dispatcher
+ self.auth = auth
+ self.url = base_url
+
+ def _make_url(self, path):
+ """Compose an absolute URL to `path`.
+
+ :param path: Either a string giving a path to the desired resource,
+ or a sequence of items that make up the path.
+ :return: An absolute URL leading to `path`.
+ """
+ assert not isinstance(path, bytes)
+ if not isinstance(path, unicode):
+ assert not any(isinstance(element, bytes) for element in path)
+ path = '/'.join(unicode(element) for element in path)
+ # urljoin is very sensitive to leading slashes and when spurious
+ # slashes appear it removes path parts. This is why joining is
+ # done manually here.
+ return self.url.rstrip("/") + "/" + path.lstrip("/")
+
+ def _formulate_get(self, path, params=None):
+ """Return URL and headers for a GET request.
+
+ This is similar to _formulate_change, except parameters are encoded
+ into the URL.
+
+ :param path: Path to the object to issue a GET on.
+ :param params: Optional dict of parameter values.
+ :return: A tuple: URL and headers for the request.
+ """
+ url = self._make_url(path)
+ if params is not None and len(params) > 0:
+ url += "?" + urlencode(params.items())
+ headers = {}
+ self.auth.sign_request(url, headers)
+ return url, headers
+
+ def _formulate_change(self, path, params, as_json=False):
+ """Return URL, headers, and body for a non-GET request.
+
+ This is similar to _formulate_get, except parameters are encoded as
+ a multipart form body.
+
+ :param path: Path to the object to issue a GET on.
+ :param params: A dict of parameter values.
+ :param as_json: Encode params as application/json instead of
+ multipart/form-data. Only use this if you know the API already
+ supports JSON requests.
+ :return: A tuple: URL, headers, and body for the request.
+ """
+ url = self._make_url(path)
+ if 'op' in params:
+ params = dict(params)
+ op = params.pop('op')
+ url += '?' + urlencode([('op', op)])
+ if as_json:
+ body, headers = encode_json_data(params)
+ else:
+ body, headers = encode_multipart_data(params, {})
+ self.auth.sign_request(url, headers)
+ return url, headers, body
+
+ def get(self, path, op=None, **kwargs):
+ """Dispatch a GET.
+
+ :param op: Optional: named GET operation to invoke. If given, any
+ keyword arguments are passed to the named operation.
+ :return: The result of the dispatch_query call on the dispatcher.
+ """
+ if op is not None:
+ kwargs['op'] = op
+ url, headers = self._formulate_get(path, kwargs)
+ return self.dispatcher.dispatch_query(
+ url, method="GET", headers=headers)
+
+ def post(self, path, op, as_json=False, **kwargs):
+ """Dispatch POST method `op` on `path`, with the given parameters.
+
+ :param as_json: Instead of POSTing the content as multipart/form-data
+ POST it as application/json
+ :return: The result of the dispatch_query call on the dispatcher.
+ """
+ if op is not None:
+ kwargs['op'] = op
+ url, headers, body = self._formulate_change(
+ path, kwargs, as_json=as_json)
+ return self.dispatcher.dispatch_query(
+ url, method="POST", headers=headers, data=body)
+
+ def put(self, path, **kwargs):
+ """Dispatch a PUT on the resource at `path`."""
+ url, headers, body = self._formulate_change(path, kwargs)
+ return self.dispatcher.dispatch_query(
+ url, method="PUT", headers=headers, data=body)
+
+ def delete(self, path):
+ """Dispatch a DELETE on the resource at `path`."""
+ url, headers, body = self._formulate_change(path, {})
+ return self.dispatcher.dispatch_query(
+ url, method="DELETE", headers=headers)
diff --git a/_modules/apiclient/multipart.py b/_modules/apiclient/multipart.py
new file mode 100644
index 0000000..7ed17a6
--- /dev/null
+++ b/_modules/apiclient/multipart.py
@@ -0,0 +1,140 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Encoding of MIME multipart data."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ 'encode_multipart_data',
+ ]
+
+from collections import (
+ Iterable,
+ Mapping,
+ )
+from email.generator import Generator
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from io import (
+ BytesIO,
+ IOBase,
+ )
+from itertools import chain
+import mimetypes
+
+
+def get_content_type(*names):
+ """Return the MIME content type for the file with the given name."""
+ for name in names:
+ if name is not None:
+ mimetype, encoding = mimetypes.guess_type(name)
+ if mimetype is not None:
+ return mimetype
+ else:
+ return "application/octet-stream"
+
+
+def make_bytes_payload(name, content):
+ payload = MIMEApplication(content)
+ payload.add_header("Content-Disposition", "form-data", name=name)
+ return payload
+
+
+def make_string_payload(name, content):
+ payload = MIMEApplication(content.encode("utf-8"), charset="utf-8")
+ payload.add_header("Content-Disposition", "form-data", name=name)
+ payload.set_type("text/plain")
+ return payload
+
+
+def make_file_payload(name, content):
+ print('********************************')
+ print('content %s' % content)
+ payload = MIMEApplication(content.read())
+ payload.add_header(
+ "Content-Disposition", "form-data", name=name, filename=name)
+ names = name, getattr(content, "name", None)
+ payload.set_type(get_content_type(*names))
+ return payload
+
+
+def make_payloads(name, content):
+ if isinstance(content, bytes):
+ yield make_bytes_payload(name, content)
+ elif isinstance(content, unicode):
+ yield make_string_payload(name, content)
+ elif isinstance(content, IOBase):
+ yield make_file_payload(name, content)
+ elif callable(content):
+ with content() as content:
+ for payload in make_payloads(name, content):
+ yield payload
+ elif isinstance(content, Iterable):
+ for part in content:
+ for payload in make_payloads(name, part):
+ yield payload
+ else:
+ raise AssertionError(
+ "%r is unrecognised: %r" % (name, content))
+
+
+def build_multipart_message(data):
+ message = MIMEMultipart("form-data")
+ for name, content in data:
+ for payload in make_payloads(name, content):
+ message.attach(payload)
+ return message
+
+
+def encode_multipart_message(message):
+ # The message must be multipart.
+ assert message.is_multipart()
+ # The body length cannot yet be known.
+ assert "Content-Length" not in message
+ # So line-endings can be fixed-up later on, component payloads must have
+ # no Content-Length and their Content-Transfer-Encoding must be base64
+ # (and not quoted-printable, which Django doesn't appear to understand).
+ for part in message.get_payload():
+ assert "Content-Length" not in part
+ assert part["Content-Transfer-Encoding"] == "base64"
+ # Flatten the message without headers.
+ buf = BytesIO()
+ generator = Generator(buf, False) # Don't mangle "^From".
+ generator._write_headers = lambda self: None # Ignore.
+ generator.flatten(message)
+ # Ensure the body has CRLF-delimited lines. See
+ # http://bugs.python.org/issue1349106.
+ body = b"\r\n".join(buf.getvalue().splitlines())
+ # Only now is it safe to set the content length.
+ message.add_header("Content-Length", "%d" % len(body))
+ return message.items(), body
+
+
+def encode_multipart_data(data=(), files=()):
+ """Create a MIME multipart payload from L{data} and L{files}.
+
+ **Note** that this function is deprecated. Use `build_multipart_message`
+ and `encode_multipart_message` instead.
+
+ @param data: A mapping of names (ASCII strings) to data (byte string).
+ @param files: A mapping of names (ASCII strings) to file objects ready to
+ be read.
+ @return: A 2-tuple of C{(body, headers)}, where C{body} is a a byte string
+ and C{headers} is a dict of headers to add to the enclosing request in
+ which this payload will travel.
+ """
+ if isinstance(data, Mapping):
+ data = data.items()
+ if isinstance(files, Mapping):
+ files = files.items()
+ message = build_multipart_message(chain(data, files))
+ headers, body = encode_multipart_message(message)
+ return body, dict(headers)
diff --git a/_modules/apiclient/testing/__init__.py b/_modules/apiclient/testing/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/_modules/apiclient/testing/__init__.py
diff --git a/_modules/apiclient/testing/credentials.py b/_modules/apiclient/testing/credentials.py
new file mode 100644
index 0000000..b51beb2
--- /dev/null
+++ b/_modules/apiclient/testing/credentials.py
@@ -0,0 +1,28 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Testing facilities for API credentials."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ 'make_api_credentials',
+ ]
+
+from maastesting.factory import factory
+
+
+def make_api_credentials():
+ """Create a tuple of fake API credentials."""
+ return (
+ factory.make_name('consumer-key'),
+ factory.make_name('resource-token'),
+ factory.make_name('resource-secret'),
+ )
diff --git a/_modules/apiclient/testing/django.py b/_modules/apiclient/testing/django.py
new file mode 100644
index 0000000..280a1e9
--- /dev/null
+++ b/_modules/apiclient/testing/django.py
@@ -0,0 +1,82 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Convenience functions for testing against Django."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = []
+
+from io import BytesIO
+import os
+
+from django.core.files.uploadhandler import MemoryFileUploadHandler
+from django.http.multipartparser import MultiPartParser
+from maasserver.utils import ignore_unused
+
+
+def parse_headers_and_body_with_django(headers, body):
+ """Parse `headers` and `body` with Django's :class:`MultiPartParser`.
+
+ `MultiPartParser` is a curiously ugly and RFC non-compliant concoction.
+
+ Amongst other things, it coerces all field names, field data, and
+ filenames into Unicode strings using the "replace" error strategy, so be
+ warned that your data may be silently mangled.
+
+ It also, in 1.3.1 at least, does not recognise any transfer encodings at
+ *all* because its header parsing code was broken.
+
+ I'm also fairly sure that it'll fall over on headers than span more than
+ one line.
+
+ In short, it's a piece of code that inspires little confidence, yet we
+ must work with it, hence we need to round-trip test multipart handling
+ with it.
+ """
+ handler = MemoryFileUploadHandler()
+ meta = {
+ "HTTP_CONTENT_TYPE": headers["Content-Type"],
+ "HTTP_CONTENT_LENGTH": headers["Content-Length"],
+ }
+ parser = MultiPartParser(
+ META=meta, input_data=BytesIO(body),
+ upload_handlers=[handler])
+ return parser.parse()
+
+
+def parse_headers_and_body_with_mimer(headers, body):
+ """Use piston's Mimer functionality to handle the content.
+
+ :return: The value of 'request.data' after using Piston's translate_mime on
+ the input.
+ """
+ # JAM 2012-10-09 Importing emitters has a side effect of registering mime
+ # type handlers with utils.translate_mime. So we must import it, even
+ # though we don't use it. However, piston loads Django's QuerySet code
+ # which fails if you don't have a settings.py available. Which we don't
+ # during 'test.pserv'. So we import this late.
+ from piston import emitters
+ ignore_unused(emitters)
+ from piston.utils import translate_mime
+
+ environ = {'wsgi.input': BytesIO(body)}
+ for name, value in headers.items():
+ environ[name.upper().replace('-', '_')] = value
+ environ['REQUEST_METHOD'] = 'POST'
+ environ['SCRIPT_NAME'] = ''
+ environ['PATH_INFO'] = ''
+ # Django 1.6 needs DJANGO_SETTINGS_MODULE to be defined
+ # when importing WSGIRequest.
+ os.environ['DJANGO_SETTINGS_MODULE'] = 'maas.development'
+ from django.core.handlers.wsgi import WSGIRequest
+ request = WSGIRequest(environ)
+ translate_mime(request)
+ return request.data
diff --git a/_modules/apiclient/testing/django_client_proxy.py b/_modules/apiclient/testing/django_client_proxy.py
new file mode 100644
index 0000000..3d67284
--- /dev/null
+++ b/_modules/apiclient/testing/django_client_proxy.py
@@ -0,0 +1,55 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A proxy that looks like MAASClient.
+
+This actually passes the requests on to a django.test.client.Client, to avoid
+having to go via a real HTTP server.
+"""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ 'MAASDjangoTestClient',
+ ]
+
+import httplib
+import io
+import urllib2
+
+
+def to_addinfourl(response):
+ """Convert a `django.http.HttpResponse` to a `urllib2.addinfourl`."""
+ headers_raw = response.serialize_headers()
+ headers = httplib.HTTPMessage(io.BytesIO(headers_raw))
+ return urllib2.addinfourl(
+ fp=io.BytesIO(response.content), headers=headers,
+ url=None, code=response.status_code)
+
+
+class MAASDjangoTestClient:
+ """Wrap the Django testing Client to look like a MAASClient."""
+
+ def __init__(self, django_client):
+ self.django_client = django_client
+
+ def get(self, path, op=None, **kwargs):
+ kwargs['op'] = op
+ return to_addinfourl(self.django_client.get(path, kwargs))
+
+ def post(self, path, op=None, **kwargs):
+ kwargs['op'] = op
+ return to_addinfourl(self.django_client.post(path, kwargs))
+
+ def put(self, path, **kwargs):
+ return to_addinfourl(self.django_client.put(path, kwargs))
+
+ def delete(self, path):
+ return to_addinfourl(self.django_client.delete(path))
diff --git a/_modules/apiclient/utils.py b/_modules/apiclient/utils.py
new file mode 100644
index 0000000..5b61d8b
--- /dev/null
+++ b/_modules/apiclient/utils.py
@@ -0,0 +1,48 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Remote API library."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+ "ascii_url",
+ "urlencode",
+ ]
+
+
+from urllib import quote_plus
+from urlparse import urlparse
+
+
+def ascii_url(url):
+ """Encode `url` as ASCII if it isn't already."""
+ if isinstance(url, unicode):
+ urlparts = urlparse(url)
+ urlparts = urlparts._replace(
+ netloc=urlparts.netloc.encode("idna"))
+ url = urlparts.geturl()
+ return url.encode("ascii")
+
+
+def urlencode(data):
+ """A version of `urllib.urlencode` that isn't insane.
+
+ This only cares that `data` is an iterable of iterables. Each sub-iterable
+ must be of overall length 2, i.e. a name/value pair.
+
+ Unicode strings will be encoded to UTF-8. This is what Django expects; see
+ `smart_text` in the Django documentation.
+ """
+ enc = lambda string: quote_plus(
+ string.encode("utf-8") if isinstance(string, unicode) else string)
+ return b"&".join(
+ b"%s=%s" % (enc(name), enc(value))
+ for name, value in data)
diff --git a/_modules/maas.py b/_modules/maas.py
index 7338e27..e1cbf0f 100644
--- a/_modules/maas.py
+++ b/_modules/maas.py
@@ -13,8 +13,12 @@
from __future__ import absolute_import
+import io
import logging
-import os
+import os.path
+import subprocess
+import urllib2
+import hashlib
import json
@@ -26,8 +30,7 @@
from apiclient.maas_client import MAASClient, MAASDispatcher, MAASOAuth
HAS_MASS = True
except ImportError:
- pass
-
+ LOG.exception('why??')
def __virtual__():
'''
@@ -38,166 +41,416 @@
return 'maas'
return False
-__opts__ = {}
+APIKEY_FILE = '/var/lib/maas/.maas_credentials'
+
+def _format_data(data):
+ class Lazy:
+ def __str__(self):
+ return ' '.join(['{0}={1}'.format(k, v)
+ for k, v in data.iteritems()])
+
+ return Lazy()
-def _auth(**connection_args):
- '''
- Set up maas credentials
-
- Only intended to be used within maas-enabled modules
- '''
-
- prefix = "maas."
-
- # look in connection_args first, then default to config file
- def get(key, default=None):
- return connection_args.get('connection_' + key,
- __salt__['config.get'](prefix + key, default))
-
- api_token = get('token')
- api_url = get('url', 'https://localhost/')
-
- LOG.debug("MAAS url: " + api_url)
- LOG.debug("MAAS token: " + api_token)
- auth = MAASOAuth(*api_token.split(":"))
+def _create_maas_client():
+ global APIKEY_FILE
+ try:
+ api_token = file(APIKEY_FILE).read().splitlines()[-1].strip().split(':')
+ except:
+ LOG.exception('token')
+ auth = MAASOAuth(*api_token)
+ api_url = 'http://localhost:5240/MAAS'
dispatcher = MAASDispatcher()
- client = MAASClient(auth, dispatcher, api_url)
+ return MAASClient(auth, dispatcher, api_url)
- return client
+class MaasObject(object):
+ def __init__(self):
+ self._maas = _create_maas_client()
+ self._extra_data_urls = {}
+ self._extra_data = {}
+ self._update = False
+ self._element_key = 'name'
+ self._update_key = 'id'
+ def send(self, data):
+ LOG.info('%s %s', self.__class__.__name__.lower(), _format_data(data))
+ if self._update:
+ return self._maas.put(self._update_url.format(data[self._update_key]), **data).read()
+ if isinstance(self._create_url, tuple):
+ return self._maas.post(*self._create_url, **data).read()
+ return self._maas.post(self._create_url, None, **data).read()
-def cluster_get(cluster_name=None, **connection_args):
- '''
- Return a specific cluster
-
- CLI Example:
-
- .. code-block:: bash
-
- salt '*' maas.cluster_get cluster
- '''
- maas = _auth(**connection_args)
-
- response = maas.get(u"nodegroups/", "list").read()
- LOG.debug("Response: " + response)
-
- object_list = json.loads(response)
-
-
- for cluster in object_list:
- if cluster.get('cluster_name') == cluster_name:
- return {cluster.get('cluster_name'): cluster}
- return {'Error': 'Could not find specified cluster'}
-
-
-def cluster_list(**connection_args):
- '''
- Return a list of MAAS clusters
-
- CLI Example:
-
- .. code-block:: bash
-
- salt '*' maas.cluster_list
- '''
- maas = _auth(**connection_args)
- ret = {}
-
- response = maas.get(u"nodegroups/", "list").read()
-
- LOG.debug("Clusters in maas: " + response )
-
- object_list = json.loads(response)
-
- for cluster in object_list:
- ret[cluster.get('cluster_name')] = cluster
- return ret
-
-
-def cluster_create(cluster_name=None, **connection_args):
- '''
- Create MAAS cluster
-
- CLI Examples:
-
- .. code-block:: bash
-
- salt '*' maas.cluster_create cluster
- '''
- maas = auth(**connection_args)
- if project_name:
- project = _get_project(maas, project_name)
- else:
- project = _get_project_by_id(maas, project_id)
- if not project:
- return {'Error': 'Unable to resolve project'}
- create = True
- for cluster in maas.getprojectclusters(project.get('id')):
- if cluster.get('url') == cluster_url:
- create = False
- if create:
- maas.addprojectcluster(project['id'], cluster_url)
- return cluster_get(cluster_url, project_id=project['id'])
-
-
-def cluster_delete(cluster_name=None, **connection_args):
- '''
- Delete MAAS cluster
-
- CLI Examples:
-
- .. code-block:: bash
-
- salt '*' maas.cluster_delete 'https://cluster.url/' project_id=300
- '''
- maas = _auth(**connection_args)
- project = _get_project(maas, project_name)
-
- for cluster in maas.getprojectclusters(project.get('id')):
- if cluster.get('url') == cluster_url:
- return maas.deleteprojectcluster(project['id'], cluster['id'])
- return {'Error': 'Could not find cluster'}
-
-
-def cluster_update(cluster_id=None, old_cluster_name=None, new_cluster_name=None, domain=None, status=None, **connection_args):
- '''
- Update information in specific MAAS cluster
-
- CLI Examples:
-
- .. code-block:: bash
-
- salt '*' maas.cluster_update cluster_id cluster_name dns_name status
- '''
- maas = _auth(**connection_args)
-
- cluster = {}
-
- if not cluster_id and old_cluster_name:
- cluster = cluster_get(old_cluster_name)
- if cluster.get("Error"):
- return cluster
+ def process(self):
+ config = __salt__['config.get']('maas')
+ for part in self._config_path.split('.'):
+ config = config.get(part, {})
+ extra = {}
+ for name, url_call in self._extra_data_urls.iteritems():
+ key = 'id'
+ if isinstance(url_call, tuple):
+ url_call, key = url_call[:]
+ extra[name] = {v['name']: v[key] for v in
+ json.loads(self._maas.get(url_call).read())}
+ if self._all_elements_url:
+ elements = self._maas.get(self._all_elements_url).read()
+ all_elements = {v[self._element_key]: v for v in json.loads(elements)}
else:
- cluster_id = cluster_get(old_cluster_name).get(old_cluster_name).get("uuid")
+ all_elements = {}
+ ret = {
+ 'success': [],
+ 'errors': {},
+ 'updated': [],
+ }
+ for name, config_data in config.iteritems():
+ try:
+ data = self.fill_data(name, config_data, **extra)
+ if name in all_elements:
+ self._update = True
+ data = self.update(data, all_elements[name])
+ self.send(data)
+ ret['updated'].append(name)
+ else:
+ self.send(data)
+ ret['success'].append(name)
+ except urllib2.HTTPError as e:
+ etxt = e.read()
+ LOG.exception('Failed for object %s reason %s', name, etxt)
+ ret['errors'][name] = str(etxt)
+ except Exception as e:
+ LOG.exception('Failed for object %s reason %s', name, e)
+ ret['errors'][name] = str(e)
+ if ret['errors']:
+ raise Exception(ret)
+ return ret
- else:
- return {'Error': 'No cluster id or name specified'}
-
- if new_cluster_name:
- cluster["cluster_name"] = new_cluster_name
- if domain:
- cluster["name"] = domain
-
- if status:
- cluster["status"] = status
-
- LOG.debug("Cluster id: " + cluster_id)
- LOG.debug("New cluster info: " + str(cluster))
-
- response = maas.put(u"nodegroups/" + cluster_id + "/", **cluster)
-
- #TODO check response status
- return {'Status': True}
+class Fabric(MaasObject):
+ def __init__(self):
+ super(Fabric, self).__init__()
+ self._all_elements_url = u'api/2.0/fabrics/'
+ self._create_url = u'api/2.0/fabrics/'
+ self._update_url = u'api/2.0/fabrics/{0}/'
+ self._config_path = 'region.fabrics'
+ def fill_data(self, name, fabric):
+ data = {
+ 'name': name,
+ 'description': fabric.get('description', ''),
+ }
+ if 'class_type' in fabric:
+ data['class_type'] = fabric.get('class_type'),
+ return data
+
+ def update(self, new, old):
+ new['id'] = str(old['id'])
+ return new
+
+class Subnet(MaasObject):
+ def __init__(self):
+ super(Subnet, self).__init__()
+ self._all_elements_url = u'api/2.0/subnets/'
+ self._create_url = u'api/2.0/subnets/'
+ self._update_url = u'api/2.0/subnets/{0}/'
+ self._config_path = 'region.subnets'
+ self._extra_data_urls = {'fabrics':u'api/2.0/fabrics/'}
+
+ def fill_data(self, name, subnet, fabrics):
+ data = {
+ 'name': name,
+ 'fabric': str(fabrics[subnet.get('fabric', '')]),
+ 'cidr': subnet.get('cidr'),
+ 'gateway_ip': subnet['gateway_ip'],
+ }
+ self._iprange = subnet['iprange']
+ return data
+
+ def update(self, new, old):
+ new['id'] = str(old['id'])
+ return new
+
+ def send(self, data):
+ response = super(Subnet, self).send(data)
+ res_json = json.loads(response)
+ self._process_iprange(res_json['id'])
+ return response
+
+ def _process_iprange(self, subnet_id):
+ ipranges = json.loads(self._maas.get(u'api/2.0/ipranges/').read())
+ LOG.warn('all %s ipranges %s', subnet_id, ipranges)
+ update = False
+ old_data = None
+ for iprange in ipranges:
+ if iprange['subnet']['id'] == subnet_id:
+ update = True
+ old_data = iprange
+ break
+ data = {
+ 'start_ip': self._iprange.get('start'),
+ 'end_ip': self._iprange.get('end'),
+ 'subnet': str(subnet_id),
+ 'type': self._iprange.get('type', 'dynamic')
+ }
+ LOG.warn('INFO: %s\n OLD: %s', data, old_data)
+ LOG.info('iprange %s', _format_data(data))
+ if update:
+ LOG.warn('UPDATING %s %s', data, old_data)
+ self._maas.put(u'api/2.0/ipranges/{0}/'.format(old_data['id']), **data)
+ else:
+ self._maas.post(u'api/2.0/ipranges/', None, **data)
+
+class DHCPSnippet(MaasObject):
+ def __init__(self):
+ super(DHCPSnippet, self).__init__()
+ self._all_elements_url = u'api/2.0/dhcp-snippets/'
+ self._create_url = u'api/2.0/dhcp-snippets/'
+ self._update_url = u'api/2.0/dhcp-snippets/{0}/'
+ self._config_path = 'region.dhcp_snippets'
+ self._extra_data_urls = {'subnets': u'api/2.0/subnets/'}
+
+ def fill_data(self, name, snippet, subnets):
+ data = {
+ 'name': name,
+ 'value': snippet['value'],
+ 'description': snippet['description'],
+ 'enabled': str(snippet['enabled'] and 1 or 0),
+ 'subnet': str(subnets[snippet['subnet']]),
+ }
+ return data
+
+ def update(self, new, old):
+ new['id'] = str(old['id'])
+ return new
+
+class PacketRepository(MaasObject):
+ def __init__(self):
+ super(PacketRepository, self).__init__()
+ self._all_elements_url = u'api/2.0/package-repositories/'
+ self._create_url = u'api/2.0/package-repositories/'
+ self._update_url = u'api/2.0/package-repositories/{0}/'
+ self._config_path = 'region.package_repositories'
+
+ def fill_data(self, name, package_repository):
+ data = {
+ 'name': name,
+ 'url': package_repository['url'],
+ 'distributions': package_repository['distributions'],
+ 'components': package_repository['components'],
+ 'arches': package_repository['arches'],
+ 'key': package_repository['key'],
+ 'enabled': str(package_repository['enabled'] and 1 or 0),
+ }
+ if 'disabled_pockets' in package_repository:
+ data['disabled_pockets'] = package_repository['disable_pockets'],
+ return data
+
+ def update(self, new, old):
+ new['id'] = str(old['id'])
+ return new
+
+class Device(MaasObject):
+ def __init__(self):
+ super(Device, self).__init__()
+ self._all_elements_url = u'api/2.0/devices/'
+ self._create_url = u'api/2.0/devices/'
+ self._update_url = u'api/2.0/devices/{0}/'
+ self._config_path = 'region.devices'
+ self._element_key = 'hostname'
+ self._update_key = 'system_id'
+
+ def fill_data(self, name, device_data):
+ data = {
+ 'mac_addresses': device_data['mac'],
+ 'hostname': name,
+ }
+ self._interface = device_data['interface']
+ return data
+
+ def update(self, new, old):
+ new_macs = set(new['mac_addresses'])
+ old_macs = set(v['mac_address'] for v in old[interface_set])
+ if new_macs - old_macs:
+ self._update = False
+ self._maas.delete(u'api/2.0/devices/{0}/'.format(old['system_id']))
+ else:
+ new[self._update_key] = str(old[self._update_key])
+ return new
+
+ def send(self, data):
+ response = super(Device, self).send(data)
+ resp_json = json.loads(response)
+ system_id = resp_json['system_id']
+ iface_id = resp_json['interface_set'][0]['id']
+ self._link_interface(maas, system_id, iface_id)
+ return response
+
+ def _link_interface(self, system_id, interface_id):
+ data = {
+ 'mode': self._interface.get('mode', 'STATIC'),
+ 'subnet': self._interface.get('subnet'),
+ 'ip_address': self._interface.get('ip_address'),
+ }
+ if 'default_gateway' in self._interface:
+ data['default_gateway'] = self._interface.get('default_gateway')
+ if self._update:
+ data['force'] = '1'
+ LOG.info('interfaces link_subnet %s %s %s', system_id, interface_id,
+ _format_data(data))
+ self._maas.post(u'/api/2.0/nodes/{0}/interfaces/{1}/'
+ .format(system_id, interface_id), 'link_subnet',
+ **data)
+
+
+class Machine(MaasObject):
+ def __init__(self):
+ super(Machine, self).__init__()
+ self._all_elements_url = u'api/2.0/machines/'
+ self._create_url = u'api/2.0/machines/'
+ self._update_url = u'api/2.0/machines/{0}/'
+ self._config_path = 'region.machines'
+ self._element_key = 'hostname'
+ self._update_key = 'system_id'
+
+ def fill_data(self, name, machine_data):
+ self._interface = machine_data['interface']
+ power_data = machine_data['power_parameters']
+ data = {
+ 'hostname': name,
+ 'architecture': machine_data.get('architecture', 'amd64/generic'),
+ 'mac_addresses': self._interface['mac'],
+ 'power_type': machine_data.get('power_type', 'ipmi'),
+ 'power_parameters_power_address': power_data['power_address'],
+ }
+ if 'power_user' in power_data:
+ data['power_parameters_power_user'] = power_data['power_user']
+ if 'power_password' in power_data:
+ data['power_parameters_power_pass'] = \
+ power_data['power_password']
+ return data
+
+ def update(self, new, old):
+ new_macs = set(new['mac_addresses'])
+ old_macs = set(v['mac_address'] for v in old[interface_set])
+ if new_macs - old_macs:
+ self._update = False
+ self._maas.delete(u'api/2.0/machiens/{0}/'.format(old['system_id']))
+ else:
+ new[self._update_key] = str(old[self._update_key])
+ return new
+
+ def _link_interface(self, system_id, interface_id):
+ if 'ip' not in self._interface:
+ return
+ data = {
+ 'mode': 'STATIC',
+ 'subnet': self._interface.get('subnet'),
+ 'ip_address': self._interface.get('ip'),
+ }
+ if 'default_gateway' in self._interface:
+ data['default_gateway'] = self._interface.get('gateway')
+ if self._update:
+ data['force'] = '1'
+ LOG.info('interfaces link_subnet %s %s %s', system_id, interface_id,
+ _format_data(data))
+ self._maas.post(u'/api/2.0/nodes/{0}/interfaces/{1}/'
+ .format(system_id, interface_id), 'link_subnet',
+ **data)
+
+ def send(self, data):
+ response = super(Device, self).send(data)
+ resp_json = json.loads(response)
+ system_id = resp_json['system_id']
+ iface_id = resp_json['interface_set'][0]['id']
+ self._link_interface(maas, system_id, iface_id)
+ return response
+
+
+class BootResource(MaasObject):
+ def __init__(self):
+ super(BootResource, self).__init__()
+ self._all_elements_url = u'api/2.0/boot-resources/'
+ self._create_url = u'api/2.0/boot-resources/'
+ self._update_url = u'api/2.0/boot-resources/{0}/'
+ self._config_path = 'region.boot_resources'
+
+ def fill_data(self, name, boot_data):
+ sha256 = hashlib.sha256()
+ sha256.update(file(boot_data['content']).read())
+ data = {
+ 'name': name,
+ 'title': boot_data['title'],
+ 'architecture': boot_data['architecture'],
+ 'filetype': boot_data['filetype'],
+ 'size': str(os.path.getsize(boot_data['content'])),
+ 'sha256': sha256.hexdigest(),
+ 'content': io.open(boot_data['content']),
+ }
+ return data
+
+ def update(self, new, old):
+ self._update = False
+ return new
+
+class CommissioningScripts(MaasObject):
+ def __init__(self):
+ super(CommissioningScripts, self).__init__()
+ self._all_elements_url = u'api/2.0/commissioning-scripts/'
+ self._create_url = u'api/2.0/commissioning-scripts/'
+ self._update_url = u'api/2.0/commissioning-scripts/{0}/'
+ self._config_path = 'region.commissioning_scripts'
+ self._update_key = 'name'
+
+ def fill_data(self, name, file_path):
+ data = {
+ 'name': name,
+ 'content': io.open(file_path),
+ }
+ return data
+
+ def update(self, new, old):
+ return new
+
+class MaasConfig(MaasObject):
+ def __init__(self):
+ super(MaasConfig, self).__init__()
+ self._all_elements_url = None
+ self._create_url = (u'api/2.0/maas/', u'set_config')
+ self._config_path = 'region.maas_config'
+
+ def fill_data(self, name, value):
+ data = {
+ 'name': name,
+ 'value': value,
+ }
+ return data
+
+ def update(self, new, old):
+ self._update = False
+ return new
+
+
+def process_fabrics():
+ return Fabric().process()
+
+def process_subnets():
+ return Subnet().process()
+
+def process_dhcp_snippets():
+ return DHCPSnippet().process()
+
+def process_package_repositories():
+ return PacketRepository().process()
+
+def process_devices():
+ return Device().process()
+
+def process_machines():
+ return Machine().process()
+
+def process_boot_resources():
+ return BootResource().process()
+
+def process_maas_config():
+ return MaasConfig().process()
+
+def process_commissioning_scripts():
+ return CommissioningScripts().process()
diff --git a/maas/region.sls b/maas/region.sls
index b196774..16c7f24 100644
--- a/maas/region.sls
+++ b/maas/region.sls
@@ -79,4 +79,70 @@
- require:
- service: maas_region_services
+maas_login_admin:
+ cmd.run:
+ - name: "maas-region apikey --username {{ region.admin.username }} > /var/lib/maas/.maas_credentials"
+
+maas_config:
+ module.run:
+ - name: maas.process_maas_config
+ - require:
+ - cmd: maas_login_admin
+
+maas_commissioning_scripts:
+ module.run:
+ - name: maas.process_commissioning_scripts
+ - require:
+ - module: maas_config
+
+maas_fabrics:
+ module.run:
+ - name: maas.process_fabrics
+ - require:
+ - module: maas_config
+
+maas_subnets:
+ module.run:
+ - name: maas.process_subnets
+ - require:
+ - module: maas_config
+ - module: maas_fabrics
+ - module: maas_config
+
+maas_devices:
+ module.run:
+ - name: maas.process_devices
+ - require:
+ - module: maas_config
+ - module: maas_subnets
+ - module: maas_config
+
+maas_machines:
+ module.run:
+ - name: maas.process_machines
+ - require:
+ - module: maas_config
+ - module: maas_subnets
+ - module: maas_config
+ - module: maas_commissioning_scripts
+
+maas_dhcp_snippets:
+ module.run:
+ - name: maas.process_dhcp_snippets
+ - require:
+ - module: maas_config
+
+maas_package_repositories:
+ module.run:
+ - name: maas.process_package_repositories
+ - require:
+ - module: maas_config
+ - module: maas_config
+
+maas_boot_resources:
+ module.run:
+ - name: maas.process_boot_resources
+ - require:
+ - module: maas_config
+
{%- endif %}