diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ca33b927 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + lint: + runs-on: ubuntu-20.04 + steps: + - name: Repository checkout + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.6" + + - name: Install dependencies + run: | + pip install --upgrade pip tox + + - name: Lint + run: | + tox -e lint + + test: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: + - "3.6" + - "3.7" + - "3.8" + - "3.9" + - "3.10" + steps: + - name: Repository checkout + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade pip tox codecov + + - name: Test + run: | + tox -e py3 + codecov diff --git a/.github/workflows/cla-check.yml b/.github/workflows/cla-check.yml new file mode 100644 index 00000000..6b767cce --- /dev/null +++ b/.github/workflows/cla-check.yml @@ -0,0 +1,10 @@ +name: cla-check + +on: [pull_request] + +jobs: + cla-check: + runs-on: ubuntu-latest + steps: + - name: Check if CLA signed + uses: canonical/has-signed-canonical-cla@v2 diff --git a/.github/workflows/stale-cron.yaml b/.github/workflows/stale-cron.yaml new file mode 100644 index 00000000..f5579966 --- /dev/null +++ b/.github/workflows/stale-cron.yaml @@ -0,0 +1,9 @@ +name: Close inactive issues +on: + schedule: + - cron: "0 0 * * *" + +jobs: + close-issues: + uses: canonical/maas-github-workflows/.github/workflows/stale-cron.yaml@v0 + secrets: inherit diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 25f3edb4..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - -install: - - pip install codecov tox - -script: - - tox -e py3,lint - -after_success: - - codecov --env TRAVIS_PYTHON_VERSION - -branches: - only: - - master diff --git a/README b/README index 1361fae3..3b292a58 100644 --- a/README +++ b/README @@ -2,8 +2,8 @@ Python client API library made especially for [MAAS][]. -[![Build Status](https://travis-ci.org/maas/python-libmaas.svg?branch=master)](https://travis-ci.org/maas/python-libmaas) -[![codecov.io](https://codecov.io/github/maas/python-libmaas/coverage.svg?branch=master)](https://codecov.io/github/maas/python-libmaas?branch=master) +[![CI tests](https://github.com/canonical/python-libmaas/workflows/CI%20tests/badge.svg)](https://github.com/canonical/python-libmaas/actions?query=workflow%3A%22CI+tests%22) +[![codecov.io](https://codecov.io/github/canonical/python-libmaas/coverage.svg?branch=master)](https://codecov.io/github/maas/python-libmaas?branch=master) ## Installation @@ -11,10 +11,10 @@ Python client API library made especially for [MAAS][]. All the dependencies are declared in `setup.py` so this can be installed with [pip](https://pip.pypa.io/). Python 3.5+ is required. -When working from trunk it can be helpful to use `virtualenv`: +When working from master it can be helpful to use a virtualenv: - $ virtualenv --python=python3 amc && source amc/bin/activate - $ pip install git+https://github.com/maas/python-libmaas.git + $ python3 -m venv ve && source ve/bin/activate + $ pip install git+https://github.com/canonical/python-libmaas.git $ maas --help Releases are periodically made to [PyPI](https://pypi.python.org/) but, diff --git a/debian/control b/debian/control index daaebe58..40fe1c62 100644 --- a/debian/control +++ b/debian/control @@ -4,7 +4,7 @@ Priority: optional Maintainer: Andres Rodriguez Build-Depends: debhelper (>= 10), dh-python, python3-all, python3-setuptools Standards-Version: 4.1.3 -Homepage: https://github.com/maas/python-libmaas +Homepage: https://github.com/canonical/python-libmaas X-Python3-Version: >= 3.2 Package: python3-libmaas diff --git a/debian/copyright b/debian/copyright index 9d516845..8af8f6a0 100644 --- a/debian/copyright +++ b/debian/copyright @@ -1,6 +1,6 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: python-libmaas -Source: https://github.com/maas/python-libmaas +Source: https://github.com/canonical/python-libmaas Files: * Copyright: 2017-2018 Canonical Ltd. diff --git a/debian/watch b/debian/watch index 78f8221f..929cbb7a 100644 --- a/debian/watch +++ b/debian/watch @@ -6,5 +6,5 @@ version=4 # GitHub hosted projects opts="filenamemangle=s%(?:.*?)?v?(\d[\d.]*)\.tar\.gz%python-libmaas-$1.tar.gz%" \ - https://github.com/maas/python-libmaas/tags \ + https://github.com/canonical/python-libmaas/tags \ (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate diff --git a/doc.yaml b/doc.yaml index 531676a3..d39de881 100644 --- a/doc.yaml +++ b/doc.yaml @@ -3,7 +3,7 @@ markdown_extensions: - codehilite - sane_lists - smarty -repo_url: https://github.com/maas/python-libmaas +repo_url: https://github.com/canonical/python-libmaas site_name: MAAS Client Library & CLI strict: true theme: readthedocs diff --git a/doc/client/index.md b/doc/client/index.md index c379b780..d6f6e9ad 100644 --- a/doc/client/index.md +++ b/doc/client/index.md @@ -11,7 +11,7 @@ Web API. ```python #!/usr/bin/env python3.6 -from maas.client import connect +import maas.client # Replace … with an API key previously obtained by hand from # http://$host:$port/MAAS/account/prefs/. diff --git a/doc/index.md b/doc/index.md index 9720c9c5..5813d83e 100644 --- a/doc/index.md +++ b/doc/index.md @@ -27,7 +27,7 @@ until we release a beta all APIs could change. Either work from a branch: ```console -$ git clone https://github.com/maas/python-libmaas.git +$ git clone https://github.com/canonical/python-libmaas.git $ cd python-libmaas $ make ``` @@ -37,7 +37,7 @@ Or install with [pip](https://pip.pypa.io/) into a ```console $ virtualenv --python=python3 amc && source amc/bin/activate -$ pip install git+https://github.com/maas/python-libmaas.git +$ pip install git+https://github.com/canonical/python-libmaas.git ``` Or install from [PyPI](https://pypi.python.org/): diff --git a/maas/client/bones/__init__.py b/maas/client/bones/__init__.py index 92ce5ad6..7b5b8d31 100644 --- a/maas/client/bones/__init__.py +++ b/maas/client/bones/__init__.py @@ -7,7 +7,9 @@ __all__ = ["CallError", "SessionAPI"] import typing -from collections import Iterable, namedtuple + +from collections import namedtuple +from collections.abc import Iterable import json from urllib.parse import urlparse @@ -36,7 +38,7 @@ async def fromURL(cls, url, *, credentials=None, insecure=False): # For now just re-raise as SessionError. raise SessionError(str(error)) else: - session = cls(description, credentials) + session = cls(url, description, credentials) session.scheme = urlparse(url).scheme session.insecure = insecure return session @@ -47,7 +49,7 @@ def fromProfile(cls, profile): :see: `ProfileStore`. """ - session = cls(profile.description, profile.credentials) + session = cls(profile.url, profile.description, profile.credentials) session.scheme = urlparse(profile.url).scheme session.insecure = profile.other.get("insecure", False) return session @@ -73,7 +75,7 @@ async def login(cls, url, *, username=None, password=None, insecure=False): profile = await helpers.login( url=url, username=username, password=password, insecure=insecure ) - session = cls(profile.description, profile.credentials) + session = cls(url, profile.description, profile.credentials) session.scheme = urlparse(url).scheme session.insecure = insecure return profile, session @@ -88,7 +90,7 @@ async def connect(cls, url, *, apikey=None, insecure=False): instance made using the profile. """ profile = await helpers.connect(url=url, apikey=apikey, insecure=insecure) - session = cls(profile.description, profile.credentials) + session = cls(url, profile.description, profile.credentials) session.scheme = urlparse(url).scheme session.insecure = insecure return profile, session @@ -98,13 +100,15 @@ async def connect(cls, url, *, apikey=None, insecure=False): insecure = False debug = False - def __init__(self, description, credentials=None): + def __init__(self, url, description, credentials=None): """Construct a `SessionAPI`. + :param url: MAAS URL :param description: The description of the remote API. See `fromURL`. :param credentials: Credentials for the remote system. Optional. """ super(SessionAPI, self).__init__() + self.__url = url self.__description = description self.__credentials = credentials self.__populate() @@ -114,15 +118,15 @@ def __populate(self): if self.__credentials is None: for resource in resources: if resource["anon"] is not None: - handler = HandlerAPI(resource["anon"], resource, self) + handler = HandlerAPI(self.__url, resource["anon"], resource, self) setattr(self, handler.name, handler) else: for resource in resources: if resource["auth"] is not None: - handler = HandlerAPI(resource["auth"], resource, self) + handler = HandlerAPI(self.__url, resource["auth"], resource, self) setattr(self, handler.name, handler) elif resource["anon"] is not None: - handler = HandlerAPI(resource["anon"], resource, self) + handler = HandlerAPI(self.__url, resource["anon"], resource, self) setattr(self, handler.name, handler) @property @@ -152,9 +156,10 @@ class HandlerAPI: operations. """ - def __init__(self, handler, resource, session): + def __init__(self, url, handler, resource, session): """Construct a `HandlerAPI`. + :param url: MAAS URL :param handler: The handler description from the overall API description document. See `SessionAPI`. :param resource: The parent of `handler` in the API description @@ -162,6 +167,7 @@ def __init__(self, handler, resource, session): :param session: The `SessionAPI`. """ super(HandlerAPI, self).__init__() + self.__url = url self.__handler = handler self.__resource = resource self.__session = session @@ -185,7 +191,8 @@ def uri(self): This will typically contain replacement patterns; these are interpolated in `CallAPI`. """ - return self.__handler["uri"] + url = urlparse(self.__url) + return f"{url.scheme}://{url.netloc}{self.__handler['path']}" @property def params(self): @@ -301,7 +308,7 @@ async def __call__(self, **data): del data[key] for nested_key, nested_value in value.items(): data[key + "_" + nested_key] = nested_value - for key, value in data.items(): + for key, value in data.copy().items(): if key.startswith("_"): data[key[1:]] = data.pop(key) response = await self.bind(**params).call(**data) @@ -415,7 +422,7 @@ def prepare(self, data): def expand(data): for name, value in data.items(): - if isinstance(value, Iterable): + if isinstance(value, Iterable) and not isinstance(value, str): for value in value: yield name, value else: diff --git a/maas/client/bones/testing/server.py b/maas/client/bones/testing/server.py index 9fd8d637..15648e89 100644 --- a/maas/client/bones/testing/server.py +++ b/maas/client/bones/testing/server.py @@ -22,9 +22,11 @@ def __init__(self, description): super(ApplicationBuilder, self).__init__() self._description = desc.Description(description) self._application = aiohttp.web.Application() - self._rootpath, self._basepath, self._version = ( - self._discover_version_and_paths() - ) + ( + self._rootpath, + self._basepath, + self._version, + ) = self._discover_version_and_paths() self._wire_up_description() self._actions = {} self._views = {} diff --git a/maas/client/bones/tests/test.py b/maas/client/bones/tests/test.py index f85b5fcb..1d7fa665 100644 --- a/maas/client/bones/tests/test.py +++ b/maas/client/bones/tests/test.py @@ -72,8 +72,10 @@ class TestActionAPI_APIVersions(TestCase): for name, version, description in testing.api_descriptions ) + url = "http://127.0.0.1:8080/MAAS/api/2.0/" + def test__Version_read(self): - session = bones.SessionAPI(self.description) + session = bones.SessionAPI(self.url, self.description) action = session.Version.read self.assertThat( action, @@ -91,7 +93,7 @@ def test__Machines_deployment_status(self): if self.version > (2, 0): self.skipTest("Machines.deployment_status only in <= 2.0") - session = bones.SessionAPI(self.description, ("a", "b", "c")) + session = bones.SessionAPI(self.url, self.description, ("a", "b", "c")) action = session.Machines.deployment_status self.assertThat( action, @@ -106,7 +108,7 @@ def test__Machines_deployment_status(self): ) def test__Machines_power_parameters(self): - session = bones.SessionAPI(self.description, ("a", "b", "c")) + session = bones.SessionAPI(self.url, self.description, ("a", "b", "c")) action = session.Machines.power_parameters self.assertThat( action, @@ -129,9 +131,11 @@ class TestCallAPI_APIVersions(TestCase): for name, version, description in testing.api_descriptions ) + url = "http://127.0.0.1:8080/MAAS/api/2.0/" + def test__marshals_lists_into_query_as_repeat_parameters(self): system_ids = list(str(uuid1()) for _ in range(3)) - session = bones.SessionAPI(self.description, ("a", "b", "c")) + session = bones.SessionAPI(self.url, self.description, ("a", "b", "c")) call = session.Machines.power_parameters.bind() call.dispatch = Mock() diff --git a/maas/client/errors.py b/maas/client/errors.py index bf2396eb..963339ff 100644 --- a/maas/client/errors.py +++ b/maas/client/errors.py @@ -10,15 +10,15 @@ def __init__(self, msg, obj): class OperationNotAllowed(Exception): - """ MAAS says this operation cannot be performed. """ + """MAAS says this operation cannot be performed.""" class ObjectNotLoaded(Exception): - """ Object is not loaded. """ + """Object is not loaded.""" class CannotDelete(Exception): - """ Object cannot be deleted. """ + """Object cannot be deleted.""" class PowerError(MAASException): diff --git a/maas/client/facade.py b/maas/client/facade.py index 5eb83249..0a8db25d 100644 --- a/maas/client/facade.py +++ b/maas/client/facade.py @@ -104,6 +104,20 @@ def devices(origin): "list": origin.Devices.read, } + @facade + def dnsresources(origin): + return { + "get": origin.DNSResource.read, + "list": origin.DNSResources.read, + } + + @facade + def dnsresourcerecords(origin): + return { + "get": origin.DNSResourceRecord.read, + "list": origin.DNSResourceRecords.read, + } + @facade def domains(origin): return { @@ -172,6 +186,12 @@ def ip_ranges(origin): "list": origin.IPRanges.read, } + @facade + def ip_addresses(origin): + return { + "list": origin.IPAddresses.read, + } + @facade def maas(origin): attrs = ( diff --git a/maas/client/flesh/tables.py b/maas/client/flesh/tables.py index 53acf3f7..70047590 100644 --- a/maas/client/flesh/tables.py +++ b/maas/client/flesh/tables.py @@ -155,6 +155,14 @@ def render(self, target, data): return super().render(target, data.name) +class NodeTagsColumn(Column): + def render(self, target, data): + if data: + return super().render(target, [tag.name for tag in data]) + else: + return "" + + class NodesTable(Table): def __init__(self): super().__init__( @@ -215,7 +223,7 @@ def __init__(self, with_type=False): NodeResourcePoolColumn("pool", "Resource pool"), NodeZoneColumn("zone", "Zone"), NodeOwnerColumn("owner", "Owner"), - Column("tags", "Tags"), + NodeTagsColumn("tags", "Tags"), ] if with_type: columns.insert(1, NodeTypeColumn("node_type", "Type")) diff --git a/maas/client/flesh/tabular.py b/maas/client/flesh/tabular.py index 91f202cc..a59c097d 100644 --- a/maas/client/flesh/tabular.py +++ b/maas/client/flesh/tabular.py @@ -4,6 +4,7 @@ from abc import ABCMeta, abstractmethod import collections +from collections.abc import Iterable import csv import enum from io import StringIO @@ -297,9 +298,7 @@ def render(self, target, datum): elif target is RenderTarget.json: return datum elif target is RenderTarget.csv: - if isinstance(datum, collections.Iterable) and not isinstance( - datum, (str, bytes) - ): + if isinstance(datum, Iterable) and not isinstance(datum, (str, bytes)): return ",".join(datum) else: return datum @@ -308,9 +307,7 @@ def render(self, target, datum): return "" elif isinstance(datum, colorclass.Color): return datum.value_no_colors - elif isinstance(datum, collections.Iterable) and not isinstance( - datum, (str, bytes) - ): + elif isinstance(datum, Iterable) and not isinstance(datum, (str, bytes)): return "\n".join(datum) else: return str(datum) @@ -319,9 +316,7 @@ def render(self, target, datum): return "" elif isinstance(datum, colorclass.Color): return datum - elif isinstance(datum, collections.Iterable) and not isinstance( - datum, (str, bytes) - ): + elif isinstance(datum, Iterable) and not isinstance(datum, (str, bytes)): return "\n".join(datum) else: return str(datum) diff --git a/maas/client/flesh/tests/test_controllers.py b/maas/client/flesh/tests/test_controllers.py index c57a8088..e5e7e411 100644 --- a/maas/client/flesh/tests/test_controllers.py +++ b/maas/client/flesh/tests/test_controllers.py @@ -70,7 +70,7 @@ def test_returns_table_with_controllers(self): cmd = controllers.cmd_controllers(parser) subparser = controllers.cmd_controllers.register(parser) options = subparser.parse_args([]) - output = yaml.load( + output = yaml.safe_load( cmd.execute(origin, options, target=tabular.RenderTarget.yaml) ) self.assertEquals( diff --git a/maas/client/flesh/tests/test_devices.py b/maas/client/flesh/tests/test_devices.py index 1c0e710e..2b355725 100644 --- a/maas/client/flesh/tests/test_devices.py +++ b/maas/client/flesh/tests/test_devices.py @@ -49,7 +49,7 @@ def test_returns_table_with_devices(self): cmd = devices.cmd_devices(parser) subparser = devices.cmd_devices.register(parser) options = subparser.parse_args([]) - output = yaml.load( + output = yaml.safe_load( cmd.execute(origin, options, target=tabular.RenderTarget.yaml) ) self.assertEquals( diff --git a/maas/client/flesh/tests/test_machines.py b/maas/client/flesh/tests/test_machines.py index 22f946dc..6e3c9412 100644 --- a/maas/client/flesh/tests/test_machines.py +++ b/maas/client/flesh/tests/test_machines.py @@ -1,5 +1,6 @@ """Tests for `maas.client.flesh.machines`.""" +from functools import partial from operator import itemgetter import yaml @@ -10,13 +11,14 @@ from ...viscera.testing import bind from ...viscera.machines import Machine, Machines from ...viscera.resource_pools import ResourcePool +from ...viscera.tags import Tag, Tags from ...viscera.users import User from ...viscera.zones import Zone def make_origin(): """Make origin for machines.""" - return bind(Machines, Machine, User, ResourcePool, Zone) + return bind(Machines, Machine, User, ResourcePool, Zone, Tag, Tags) class TestMachines(TestCaseWithProfile): @@ -55,7 +57,7 @@ def test_returns_table_with_machines(self): cmd = machines.cmd_machines(parser) subparser = machines.cmd_machines.register(parser) options = subparser.parse_args([]) - output = yaml.load( + output = yaml.safe_load( cmd.execute(origin, options, target=tabular.RenderTarget.yaml) ) self.assertEquals( @@ -101,3 +103,63 @@ def test_calls_handler_with_hostnames(self): options = subparser.parse_args(hostnames) cmd.execute(origin, options, target=tabular.RenderTarget.yaml) origin.Machines._handler.read.assert_called_once_with(hostname=hostnames) + + +class TestMachine(TestCaseWithProfile): + """Tests for `cmd_machine`.""" + + def setUp(self): + super().setUp() + origin = make_origin() + parser = ArgumentParser() + self.hostname = make_name_without_spaces() + machine_objs = [ + { + "hostname": self.hostname, + "architecture": "amd64/generic", + "status": NodeStatus.READY.value, + "status_name": NodeStatus.READY.name, + "owner": None, + "power_state": PowerState.OFF.value, + "cpu_count": 2, + "memory": 1024, + "pool": {"id": 1, "name": "pool1", "description": "pool1"}, + "zone": {"id": 1, "name": "zone1", "description": "zone1"}, + "tag_names": ["tag1", "tag2"], + "distro_series": "", + "power_type": "Manual", + }, + ] + origin.Machines._handler.read.return_value = machine_objs + cmd = machines.cmd_machine(parser) + subparser = machines.cmd_machine.register(parser) + options = subparser.parse_args([machine_objs[0]["hostname"]]) + self.cmd = partial(cmd.execute, origin, options) + + def test_yaml_machine_details_with_tags(self): + yaml_output = yaml.safe_load(self.cmd(target=tabular.RenderTarget.yaml)) + self.assertEqual(yaml_output.get("tags"), ["tag1", "tag2"]) + + def test_plain_machine_details_with_tags(self): + plain_output = self.cmd(target=tabular.RenderTarget.plain) + self.assertEqual( + plain_output, + f"""\ ++---------------+-------------+ +| Hostname | {self.hostname} | +| Status | READY | +| Image | (none) | +| Power | Off | +| Power Type | Manual | +| Arch | amd64 | +| #CPUs | 2 | +| RAM | 1.0 GB | +| Interfaces | 0 physical | +| IP addresses | | +| Resource pool | pool1 | +| Zone | zone1 | +| Owner | (none) | +| Tags | tag1 | +| | tag2 | ++---------------+-------------+""", + ) diff --git a/maas/client/flesh/tests/test_nodes.py b/maas/client/flesh/tests/test_nodes.py index 76ade873..5dce2604 100644 --- a/maas/client/flesh/tests/test_nodes.py +++ b/maas/client/flesh/tests/test_nodes.py @@ -48,7 +48,7 @@ def test_returns_table_with_nodes(self): cmd = nodes.cmd_nodes(parser) subparser = nodes.cmd_nodes.register(parser) options = subparser.parse_args([]) - output = yaml.load( + output = yaml.safe_load( cmd.execute(origin, options, target=tabular.RenderTarget.yaml) ) self.assertEquals( diff --git a/maas/client/utils/__init__.py b/maas/client/utils/__init__.py index bb9d5313..1ae5a928 100644 --- a/maas/client/utils/__init__.py +++ b/maas/client/utils/__init__.py @@ -26,7 +26,9 @@ "vars_class", ] -from collections import Iterable, namedtuple + +from collections import namedtuple +from collections.abc import Iterable from functools import lru_cache, partial from inspect import cleandoc, getdoc from itertools import chain, cycle, repeat @@ -175,7 +177,7 @@ def sign(uri, headers, credentials): docstring = namedtuple("docstring", ("title", "body")) -@lru_cache(2 ** 10) +@lru_cache(2**10) def parse_docstring(thing): """Parse a Python docstring, or the docstring found on `thing`. @@ -336,7 +338,7 @@ class Spinner: Use as a context manager. """ - def __init__(self, frames="/-\|", stream=sys.stdout): + def __init__(self, frames=r"/-\|", stream=sys.stdout): super(Spinner, self).__init__() self.frames = frames self.stream = stream diff --git a/maas/client/utils/multipart.py b/maas/client/utils/multipart.py index 2e6437cf..53f5ea54 100644 --- a/maas/client/utils/multipart.py +++ b/maas/client/utils/multipart.py @@ -16,7 +16,7 @@ __all__ = ["encode_multipart_data"] -from collections import Iterable, Mapping +from collections.abc import Iterable, Mapping from email.generator import BytesGenerator from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart diff --git a/maas/client/viscera/__init__.py b/maas/client/viscera/__init__.py index 6a6fd92f..40edd6fc 100644 --- a/maas/client/viscera/__init__.py +++ b/maas/client/viscera/__init__.py @@ -19,7 +19,8 @@ "OriginBase", ] -from collections import defaultdict, Iterable, Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence +from collections import defaultdict from copy import copy from datetime import datetime from functools import wraps @@ -1167,6 +1168,8 @@ def __init__(self, session): ".boot_sources", ".controllers", ".devices", + ".dnsresources", + ".dnsresourcerecords", ".domains", ".events", ".subnets", @@ -1177,6 +1180,7 @@ def __init__(self, session): ".filesystems", ".interfaces", ".ipranges", + ".ip_addresses", ".logical_volumes", ".maas", ".machines", diff --git a/maas/client/viscera/boot_source_selections.py b/maas/client/viscera/boot_source_selections.py index a550fed7..818e674f 100644 --- a/maas/client/viscera/boot_source_selections.py +++ b/maas/client/viscera/boot_source_selections.py @@ -2,8 +2,8 @@ __all__ = ["BootSourceSelection", "BootSourceSelections"] -from collections import Sequence +from collections.abc import Sequence from . import check, Object, ObjectField, ObjectFieldRelated, ObjectSet, ObjectType from .boot_sources import BootSource diff --git a/maas/client/viscera/dnsresourcerecords.py b/maas/client/viscera/dnsresourcerecords.py new file mode 100644 index 00000000..4aae103c --- /dev/null +++ b/maas/client/viscera/dnsresourcerecords.py @@ -0,0 +1,38 @@ +"""Objects for dnsresourcerecords.""" + +__all__ = ["DNSResourceRecord", "DNSResourceRecords"] + +from . import check, check_optional, Object, ObjectField, ObjectSet, ObjectType + + +class DNSResourceRecordType(ObjectType): + """Metaclass for `DNSResourceRecords`.""" + + async def read(cls): + data = await cls._handler.read() + return cls(map(cls._object, data)) + + +class DNSResourceRecords(ObjectSet, metaclass=DNSResourceRecordType): + """The set of dnsresourcerecords stored in MAAS.""" + + +class DNSResourceRecordType(ObjectType): + async def read(cls, id): + data = await cls._handler.read(id=id) + return cls(data) + + +class DNSResourceRecord(Object, metaclass=DNSResourceRecordType): + """A dnsresourcerecord stored in MAAS.""" + + id = ObjectField.Checked("id", check(int), readonly=True, pk=True) + ttl = ObjectField.Checked("ttl", check_optional(int), check_optional(int)) + rrtype = ObjectField.Checked("rrtype", check(str), check(str)) + rrdata = ObjectField.Checked("rrdata", check(str), check(str)) + fqdn = ObjectField.Checked("fqdn", check(str), check(str)) + + def __repr__(self): + return super(DNSResourceRecord, self).__repr__( + fields={"ttl", "rrtype", "rrdata", "fqdn"} + ) diff --git a/maas/client/viscera/dnsresources.py b/maas/client/viscera/dnsresources.py new file mode 100644 index 00000000..97e1e46b --- /dev/null +++ b/maas/client/viscera/dnsresources.py @@ -0,0 +1,48 @@ +"""Objects for dnsresources.""" + +__all__ = ["DNSResource", "DNSResources"] + +from . import ( + check, + check_optional, + Object, + ObjectField, + ObjectSet, + ObjectType, + ObjectFieldRelatedSet, +) + + +class DNSResourceType(ObjectType): + """Metaclass for `DNSResources`.""" + + async def read(cls): + data = await cls._handler.read() + return cls(map(cls._object, data)) + + +class DNSResources(ObjectSet, metaclass=DNSResourceType): + """The set of dnsresources stored in MAAS.""" + + +class DNSResourceType(ObjectType): + async def read(cls, id): + data = await cls._handler.read(id=id) + return cls(data) + + +class DNSResource(Object, metaclass=DNSResourceType): + """A dnsresource stored in MAAS.""" + + id = ObjectField.Checked("id", check(int), readonly=True, pk=True) + address_ttl = ObjectField.Checked( + "address_ttl", check_optional(int), check_optional(int) + ) + fqdn = ObjectField.Checked("fqdn", check(str), check(str)) + ip_addresses = ObjectFieldRelatedSet("ip_addresses", "IPAddresses") + resource_records = ObjectFieldRelatedSet("resource_records", "DNSResourceRecords") + + def __repr__(self): + return super(DNSResource, self).__repr__( + fields={"address_ttl", "fqdn", "ip_addresses", "resource_records"} + ) diff --git a/maas/client/viscera/interfaces.py b/maas/client/viscera/interfaces.py index e52119e4..c9d2a22e 100644 --- a/maas/client/viscera/interfaces.py +++ b/maas/client/viscera/interfaces.py @@ -102,6 +102,8 @@ class Interface(Object, metaclass=InterfaceTypeMeta): discovered = ObjectFieldRelatedSet( "discovered", "InterfaceDiscoveredLinks", reverse=None ) + interface_speed = ObjectField.Checked("interface_speed", check(int), readonly=True) + link_speed = ObjectField.Checked("link_speed", check(int), readonly=True) def __repr__(self): return super(Interface, self).__repr__(fields={"name", "mac_address", "type"}) diff --git a/maas/client/viscera/ip_addresses.py b/maas/client/viscera/ip_addresses.py new file mode 100644 index 00000000..5997e76e --- /dev/null +++ b/maas/client/viscera/ip_addresses.py @@ -0,0 +1,54 @@ +"""Objects for ipaddresses.""" + +__all__ = ["IPAddress", "IPAddresses"] + +from . import ( + check, + parse_timestamp, + Object, + ObjectField, + ObjectSet, + ObjectType, + ObjectFieldRelatedSet, + ObjectFieldRelated, + OriginObjectRef, +) + + +class IPAddressType(ObjectType): + """Metaclass for `IPAddresses`.""" + + async def read(cls): + data = await cls._handler.read() + return cls(map(cls._object, data)) + + +class IPAddresses(ObjectSet, metaclass=IPAddressType): + """The set of ipaddresses stored in MAAS.""" + + _object = OriginObjectRef(name="IPAddress") + + +class IPAddress(Object): + """An ipaddress stored in MAAS.""" + + alloc_type = ObjectField.Checked("alloc_type", check(int), check(int)) + alloc_type_name = ObjectField.Checked("alloc_type_name", check(str), check(str)) + created = ObjectField.Checked("created", parse_timestamp, readonly=True) + ip = ObjectField.Checked("ip", check(str)) + owner = ObjectFieldRelated("owner", "User") + interface_set = ObjectFieldRelatedSet("interface_set", "Interfaces") + subnet = ObjectFieldRelated("subnet", "Subnet", readonly=True, default=None) + + def __repr__(self): + return super(IPAddress, self).__repr__( + fields={ + "alloc_type", + "alloc_type_name", + "created", + "ip", + "owner", + "interface_set", + "subnet", + } + ) diff --git a/maas/client/viscera/machines.py b/maas/client/viscera/machines.py index 5445783f..f9ac96dc 100644 --- a/maas/client/viscera/machines.py +++ b/maas/client/viscera/machines.py @@ -373,6 +373,7 @@ class Machine(Node, metaclass=MachineType): status_name = ObjectField.Checked("status_name", check(str), readonly=True) raids = ObjectFieldRelatedSet("raids", "Raids", reverse=None) volume_groups = ObjectFieldRelatedSet("volume_groups", "VolumeGroups", reverse=None) + pod = ObjectFieldRelated("pod", "Pod", readonly=True) async def save(self): """Save the machine in MAAS.""" @@ -479,7 +480,9 @@ async def deploy( comment: str = None, wait: bool = False, install_kvm: bool = False, - wait_interval: int = 5 + wait_interval: int = 5, + ephemeral_deploy: bool = False, + enable_hw_sync: bool = False ): """Deploy this machine. @@ -493,6 +496,8 @@ async def deploy( :param comment: A comment for the event log. :param wait: If specified, wait until the deploy is complete. :param wait_interval: How often to poll, defaults to 5 seconds + :param ephemeral_deploy: Deploy a machine in Ephemeral mode + :param enable_hw_sync: Enables periodic hardware sync """ params = {"system_id": self.system_id} @@ -512,6 +517,11 @@ async def deploy( params["hwe_kernel"] = hwe_kernel if comment is not None: params["comment"] = comment + if ephemeral_deploy: + params["ephemeral_deploy"] = ephemeral_deploy + if enable_hw_sync: + params["enable_hw_sync"] = enable_hw_sync + self._reset(await self._handler.deploy(**params)) if not wait: return self diff --git a/maas/client/viscera/nodes.py b/maas/client/viscera/nodes.py index 12c8a00a..3b181471 100644 --- a/maas/client/viscera/nodes.py +++ b/maas/client/viscera/nodes.py @@ -2,7 +2,13 @@ __all__ = ["Node", "Nodes"] -from collections import Sequence +try: + # Python <= 3.9 + from collections import Sequence +except: + # Python > 3.9 + from collections.abc import Sequence + import typing from . import ( diff --git a/maas/client/viscera/sshkeys.py b/maas/client/viscera/sshkeys.py index 9568104d..bcd74098 100644 --- a/maas/client/viscera/sshkeys.py +++ b/maas/client/viscera/sshkeys.py @@ -41,3 +41,9 @@ class SSHKey(Object, metaclass=SSHKeyType): id = ObjectField.Checked("id", check(int), readonly=True) key = ObjectField.Checked("key", check(str), readonly=True) keysource = ObjectField.Checked("keysource", check_optional(str), readonly=True) + + async def delete(self): + """Delete this key.""" + await self._handler.delete( + id=self.id, + ) diff --git a/maas/client/viscera/testing/__init__.py b/maas/client/viscera/testing/__init__.py index eb40d060..f11ed71e 100644 --- a/maas/client/viscera/testing/__init__.py +++ b/maas/client/viscera/testing/__init__.py @@ -2,7 +2,8 @@ __all__ = ["bind"] -from collections import Mapping + +from collections.abc import Mapping from itertools import chain from unittest.mock import Mock diff --git a/maas/client/viscera/tests/test_dnsresourcerecords.py b/maas/client/viscera/tests/test_dnsresourcerecords.py new file mode 100644 index 00000000..aa6045f4 --- /dev/null +++ b/maas/client/viscera/tests/test_dnsresourcerecords.py @@ -0,0 +1,50 @@ +"""Tests for `maas.client.viscera.dnsresourcerecords`.""" + +import random + +from testtools.matchers import Equals + +from .. import dnsresourcerecords + +from ..testing import bind +from ...testing import make_string_without_spaces, TestCase + + +def make_origin(): + """ + Create a new origin with DNSResourceRecord and DNSResourceRecords. The former + refers to the latter via the origin, hence why it must be bound. + """ + return bind( + dnsresourcerecords.DNSResourceRecords, dnsresourcerecords.DNSResourceRecord + ) + + +class TestDNSResourceRecords(TestCase): + def test__dnsresourcerecords_read(self): + """DNSResourceRecords.read() returns a list of DNSResourceRecords.""" + DNSResourceRecords = make_origin().DNSResourceRecords + dnsresourcerecords = [ + {"id": random.randint(0, 100), "fqdn": make_string_without_spaces()} + for _ in range(3) + ] + DNSResourceRecords._handler.read.return_value = dnsresourcerecords + dnsresourcerecords = DNSResourceRecords.read() + self.assertThat(len(dnsresourcerecords), Equals(3)) + + +class TestDNSResourceRecord(TestCase): + def test__dnsresourcerecord_read(self): + DNSResourceRecord = make_origin().DNSResourceRecord + dnsresourcerecord = { + "id": random.randint(0, 100), + "fqdn": make_string_without_spaces(), + } + DNSResourceRecord._handler.read.return_value = dnsresourcerecord + self.assertThat( + DNSResourceRecord.read(id=dnsresourcerecord["id"]), + Equals(DNSResourceRecord(dnsresourcerecord)), + ) + DNSResourceRecord._handler.read.assert_called_once_with( + id=dnsresourcerecord["id"] + ) diff --git a/maas/client/viscera/tests/test_dnsresources.py b/maas/client/viscera/tests/test_dnsresources.py new file mode 100644 index 00000000..fc197eca --- /dev/null +++ b/maas/client/viscera/tests/test_dnsresources.py @@ -0,0 +1,45 @@ +"""Tests for `maas.client.viscera.dnsresources`.""" + +import random + +from testtools.matchers import Equals + +from .. import dnsresources + +from ..testing import bind +from ...testing import make_string_without_spaces, TestCase + + +def make_origin(): + """ + Create a new origin with DNSResource and DNSResources. The former + refers to the latter via the origin, hence why it must be bound. + """ + return bind(dnsresources.DNSResources, dnsresources.DNSResource) + + +class TestDNSResources(TestCase): + def test__dnsresources_read(self): + """DNSResources.read() returns a list of DNSResources.""" + DNSResources = make_origin().DNSResources + dnsresources = [ + {"id": random.randint(0, 100), "fqdn": make_string_without_spaces()} + for _ in range(3) + ] + DNSResources._handler.read.return_value = dnsresources + dnsresources = DNSResources.read() + self.assertThat(len(dnsresources), Equals(3)) + + +class TestDNSResource(TestCase): + def test__dnsresource_read(self): + DNSResource = make_origin().DNSResource + dnsresource = { + "id": random.randint(0, 100), + "fqdn": make_string_without_spaces(), + } + DNSResource._handler.read.return_value = dnsresource + self.assertThat( + DNSResource.read(id=dnsresource["id"]), Equals(DNSResource(dnsresource)) + ) + DNSResource._handler.read.assert_called_once_with(id=dnsresource["id"]) diff --git a/maas/client/viscera/tests/test_ip_addresses.py b/maas/client/viscera/tests/test_ip_addresses.py new file mode 100644 index 00000000..a5313e6b --- /dev/null +++ b/maas/client/viscera/tests/test_ip_addresses.py @@ -0,0 +1,29 @@ +"""Tests for `maas.client.viscera.ip_addresses`.""" + +from testtools.matchers import Equals + +from .. import ip_addresses + +from ..testing import bind +from ...testing import TestCase + + +def make_origin(): + """ + Create a new origin with IPAddress and IPAddresses. The former + refers to the latter via the origin, hence why it must be bound. + """ + return bind(ip_addresses.IPAddresses, ip_addresses.IPAddress) + + +class TestIPAddresses(TestCase): + def test__ip_addresses_read(self): + """IPAddresses.read() returns a list of IPAddresses.""" + IPAddresses = make_origin().IPAddresses + ip_addresses = [ + {"ip": "10.0.0.%s" % (i + 1), "alloc_type_name": "User reserved"} + for i in range(3) + ] + IPAddresses._handler.read.return_value = ip_addresses + ip_addresses = IPAddresses.read() + self.assertThat(len(ip_addresses), Equals(3)) diff --git a/maas/client/viscera/tests/test_machines.py b/maas/client/viscera/tests/test_machines.py index 37487741..62d97ad5 100644 --- a/maas/client/viscera/tests/test_machines.py +++ b/maas/client/viscera/tests/test_machines.py @@ -203,7 +203,7 @@ def test__commission_with_wait_failed(self): ) def test__commission_with_no_tests(self): - # Regression test for https://github.com/maas/python-libmaas/issues/185 + # Regression test for https://github.com/canonical/python-libmaas/issues/185 system_id = make_name_without_spaces("system-id") hostname = make_name_without_spaces("hostname") data = { @@ -259,6 +259,46 @@ def test__deploy_with_kvm_install(self): system_id=machine.system_id, install_kvm=True ) + def test__deploy_with_ephemeral_deploy(self): + system_id = make_name_without_spaces("system-id") + hostname = make_name_without_spaces("hostname") + data = { + "system_id": system_id, + "hostname": hostname, + "status": NodeStatus.READY, + } + deploying_data = { + "system_id": system_id, + "hostname": hostname, + "status": NodeStatus.DEPLOYING, + } + machine = make_machines_origin().Machine(data) + machine._handler.deploy.return_value = deploying_data + machine.deploy(ephemeral_deploy=True, wait=False) + machine._handler.deploy.assert_called_once_with( + system_id=machine.system_id, ephemeral_deploy=True + ) + + def test__deploy_with_enable_hw_sync(self): + system_id = make_name_without_spaces("system-id") + hostname = make_name_without_spaces("hostname") + data = { + "system_id": system_id, + "hostname": hostname, + "status": NodeStatus.READY, + } + deploying_data = { + "system_id": system_id, + "hostname": hostname, + "status": NodeStatus.DEPLOYING, + } + machine = make_machines_origin().Machine(data) + machine._handler.deploy.return_value = deploying_data + machine.deploy(enable_hw_sync=True, wait=False) + machine._handler.deploy.assert_called_once_with( + system_id=machine.system_id, enable_hw_sync=True + ) + def test__deploy_with_wait_failed(self): system_id = make_name_without_spaces("system-id") hostname = make_name_without_spaces("hostname") diff --git a/maas/client/viscera/tests/test_sshkeys.py b/maas/client/viscera/tests/test_sshkeys.py index ce570e05..db00fbab 100644 --- a/maas/client/viscera/tests/test_sshkeys.py +++ b/maas/client/viscera/tests/test_sshkeys.py @@ -17,7 +17,7 @@ def make_origin(): class TestSSHKeys(TestCase): def test__sshkeys_create(self): - """ SSHKeys.create() returns a new SSHKey. """ + """SSHKeys.create() returns a new SSHKey.""" SSHKeys = make_origin().SSHKeys key = make_string_without_spaces() SSHKeys._handler.create.return_value = {"id": 1, "key": key, "keysource": ""} @@ -25,7 +25,7 @@ def test__sshkeys_create(self): SSHKeys._handler.create.assert_called_once_with(key=key) def test__sshkeys_read(self): - """ SSHKeys.read() returns a list of SSH keys. """ + """SSHKeys.read() returns a list of SSH keys.""" SSHKeys = make_origin().SSHKeys keys = [ { @@ -42,9 +42,19 @@ def test__sshkeys_read(self): class TestSSHKey(TestCase): def test__sshkey_read(self): - """ SSHKeys.read() returns a single SSH key. """ + """SSHKeys.read() returns a single SSH key.""" SSHKey = make_origin().SSHKey key_id = random.randint(0, 100) key_dict = {"id": key_id, "key": make_string_without_spaces(), "keysource": ""} SSHKey._handler.read.return_value = key_dict self.assertThat(SSHKey.read(id=key_id), Equals(SSHKey(key_dict))) + + def test__sshkey_delete(self): + """SSHKeys.read() returns a single SSH key.""" + SSHKey = make_origin().SSHKey + key_id = random.randint(0, 100) + ssh_key = SSHKey( + {"id": key_id, "key": make_string_without_spaces(), "keysource": ""} + ) + ssh_key.delete() + SSHKey._handler.delete.assert_called_once_with(id=key_id) diff --git a/maas/client/viscera/tests/test_vlans.py b/maas/client/viscera/tests/test_vlans.py index 9bf4c699..750e7081 100644 --- a/maas/client/viscera/tests/test_vlans.py +++ b/maas/client/viscera/tests/test_vlans.py @@ -220,7 +220,7 @@ def test__vlan_update_relay_vlan_with_object(self): self.assertThat(vlan.relay_vlan.id, Equals(relay_vlan.id)) def test__vlan_update_relay_vlan_with_integer_id(self): - self.skip("see https://github.com/maas/python-libmaas/issues/180") + self.skip("see https://github.com/canonical/python-libmaas/issues/180") origin = make_origin() Vlan = origin.Vlan Vlan._handler.params = ["fabric_id", "vid"] diff --git a/maas/client/viscera/version.py b/maas/client/viscera/version.py index a3fe4262..185a4b36 100644 --- a/maas/client/viscera/version.py +++ b/maas/client/viscera/version.py @@ -2,13 +2,13 @@ __all__ = ["Version"] -from distutils.version import StrictVersion +from packaging.version import Version as PackagingVersion from . import Object, ObjectField, ObjectType def parse_version(version): - return StrictVersion(version).version + return PackagingVersion(version).release class VersionType(ObjectType): diff --git a/scripts/check-imports b/scripts/check-imports index daf072f1..c6cc3908 100755 --- a/scripts/check-imports +++ b/scripts/check-imports @@ -96,7 +96,6 @@ python_standard_libs = [ 'difflib', 'dircache', 'dis', - 'distutils', 'dl', 'doctest', 'DocXMLRPCServer', @@ -203,6 +202,7 @@ python_standard_libs = [ 'optparse', 'os', 'ossaudiodev', + 'packaging', 'parser', 'pathlib', 'pdb', diff --git a/setup.py b/setup.py index 0c7b42cc..071f10e9 100644 --- a/setup.py +++ b/setup.py @@ -26,16 +26,18 @@ def read(filename): name='python-libmaas', author='MAAS Developers', author_email='maas-devel@lists.launchpad.net', - url='https://github.com/maas/python-libmaas', - version="0.6.4", + url='https://github.com/canonical/python-libmaas', + version="0.6.8", classifiers=[ 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: GNU Affero General Public License v3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Topic :: Software Development :: Libraries', ], namespace_packages=['maas'], @@ -52,6 +54,7 @@ def read(filename): "colorclass >= 1.2.0", "macaroonbakery >= 1.1.3", "oauthlib >= 1.0.3", + "packaging >= 21.3", "pymongo >= 3.5.1", # for bson "pytz >= 2014.10", "PyYAML >= 3.11", @@ -64,7 +67,7 @@ def read(filename): "setuptools", "testscenarios", "testtools", - "Twisted", + "Twisted<23.0.0", ], description="A client API library specially for MAAS.", long_description=read('README'),