From 6dcd7405bb12fa16ee34720c92bc30fc884714c3 Mon Sep 17 00:00:00 2001 From: Carlos Goncalves Date: Thu, 9 Nov 2017 17:21:38 +0000 Subject: [PATCH] Add Quota client API and OSC support Includes list, show, set and reset of quotas, and show quota defaults. As per OSC CLI guidelines, long options names shall not use underscore but single dashes internally between words. ChangeId Iaf7d251ef4054fa28ef61e02fdebc05c83786e9e will address the newly added --loadbalancer option for quota setting. DocImpact Task: 5751 Task: 5752 Task: 5753 Task: 5754 Task: 5755 Story: 2001235 Change-Id: I336477e723867b5da2a041b441a25a7a611596ee --- doc/source/cli/osc/v2/load-balancer.rst | 7 + octaviaclient/api/constants.py | 4 + octaviaclient/api/load_balancer_v2.py | 66 +++++ octaviaclient/osc/v2/constants.py | 17 ++ octaviaclient/osc/v2/quota.py | 185 ++++++++++++++ octaviaclient/osc/v2/utils.py | 20 ++ .../tests/unit/api/test_load_balancer.py | 77 ++++++ octaviaclient/tests/unit/osc/v2/fakes.py | 25 ++ octaviaclient/tests/unit/osc/v2/test_quota.py | 227 ++++++++++++++++++ .../add-quota-support-effed2cf2a8c7ad4.yaml | 7 + setup.cfg | 5 + 11 files changed, 640 insertions(+) create mode 100644 octaviaclient/osc/v2/quota.py create mode 100644 octaviaclient/tests/unit/osc/v2/test_quota.py create mode 100644 releasenotes/notes/add-quota-support-effed2cf2a8c7ad4.yaml diff --git a/doc/source/cli/osc/v2/load-balancer.rst b/doc/source/cli/osc/v2/load-balancer.rst index 9c065c9..ed6b2ef 100644 --- a/doc/source/cli/osc/v2/load-balancer.rst +++ b/doc/source/cli/osc/v2/load-balancer.rst @@ -67,3 +67,10 @@ l7rule .. autoprogram-cliff:: openstack.load_balancer.v2 :command: loadbalancer l7rule * + +===== +quota +===== + +.. autoprogram-cliff:: openstack.load_balancer.v2 + :command: loadbalancer quota * diff --git a/octaviaclient/api/constants.py b/octaviaclient/api/constants.py index 41848b8..59bed04 100644 --- a/octaviaclient/api/constants.py +++ b/octaviaclient/api/constants.py @@ -31,3 +31,7 @@ BASE_L7POLICY_URL = '/l7policies' BASE_SINGLE_L7POLICY_URL = BASE_L7POLICY_URL + '/{policy_uuid}' BASE_L7RULE_URL = BASE_SINGLE_L7POLICY_URL + '/rules' BASE_SINGLE_L7RULE_URL = BASE_SINGLE_L7POLICY_URL + '/rules/{rule_uuid}' + +BASE_QUOTA_URL = '/quotas' +BASE_SINGLE_QUOTA_URL = BASE_QUOTA_URL + '/{uuid}' +BASE_QUOTA_DEFAULT_URL = BASE_QUOTA_URL + '/defaults' diff --git a/octaviaclient/api/load_balancer_v2.py b/octaviaclient/api/load_balancer_v2.py index 1c0d38d..396874d 100644 --- a/octaviaclient/api/load_balancer_v2.py +++ b/octaviaclient/api/load_balancer_v2.py @@ -580,6 +580,72 @@ class APIv2(api.BaseAPI): return response + def quota_list(self, **params): + """List all quotas + + :param params: + Parameters to filter on (not implemented) + :return: + A ``dict`` representing a list of quotas for the project + """ + url = const.BASE_QUOTA_URL + response = self.list(url, **params) + + return response + + def quota_show(self, project_id): + """Show a quota + + :param string project_id: + ID of the project to show + :return: + A ``dict`` representing the quota for the project + """ + response = self.find(path=const.BASE_QUOTA_URL, value=project_id) + + return response + + @correct_return_codes + def quota_reset(self, project_id): + """Reset a quota + + :param string project_id: + The ID of the project to reset quotas + :return: + ``None`` + """ + url = const.BASE_SINGLE_QUOTA_URL.format(uuid=project_id) + response = self.delete(url) + + return response + + @correct_return_codes + def quota_set(self, project_id, **params): + """Update a quota's settings + + :param string project_id: + The ID of the project to update + :param params: + A ``dict`` of arguments to update project quota + :return: + A ``dict`` representing the updated quota + """ + url = const.BASE_SINGLE_QUOTA_URL.format(uuid=project_id) + response = self.create(url, method='PUT', **params) + + return response + + def quota_defaults_show(self): + """Show quota defaults + + :return: + A ``dict`` representing a list of quota defaults + """ + url = const.BASE_QUOTA_DEFAULT_URL + response = self.list(url) + + return response + class OctaviaClientException(Exception): """The base exception class for all exceptions this library raises.""" diff --git a/octaviaclient/osc/v2/constants.py b/octaviaclient/osc/v2/constants.py index a015ce7..c3ca91f 100644 --- a/octaviaclient/osc/v2/constants.py +++ b/octaviaclient/osc/v2/constants.py @@ -206,3 +206,20 @@ MONITOR_COLUMNS = ( 'type', 'admin_state_up', ) + +QUOTA_ROWS = ( + 'load_balancer', + 'listener', + 'pool', + 'health_monitor', + 'member' +) + +QUOTA_COLUMNS = ( + 'project_id', + 'load_balancer', + 'listener', + 'pool', + 'health_monitor', + 'member' +) diff --git a/octaviaclient/osc/v2/quota.py b/octaviaclient/osc/v2/quota.py new file mode 100644 index 0000000..20253a1 --- /dev/null +++ b/octaviaclient/osc/v2/quota.py @@ -0,0 +1,185 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +"""Quota action implementation""" + + +from cliff import lister +from osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils + +from octaviaclient.osc.v2 import constants as const +from octaviaclient.osc.v2 import utils as v2_utils + + +class ListQuota(lister.Lister): + """List quotas""" + + def get_parser(self, prog_name): + parser = super(ListQuota, self).get_parser(prog_name) + + parser.add_argument( + '--project', + metavar='', + help="Name or UUID of the project." + ) + + return parser + + def take_action(self, parsed_args): + columns = const.QUOTA_COLUMNS + attrs = v2_utils.get_listener_attrs(self.app.client_manager, + parsed_args) + data = self.app.client_manager.load_balancer.quota_list(**attrs) + formatters = {'quotas': v2_utils.format_list} + return (columns, + (utils.get_dict_properties(s, columns, formatters=formatters) + for s in data['quotas'])) + + +class ShowQuota(command.ShowOne): + """Show the quota details for a project""" + + def get_parser(self, prog_name): + parser = super(ShowQuota, self).get_parser(prog_name) + + parser.add_argument( + 'project', + metavar='', + help="Name or UUID of the project." + ) + + return parser + + def take_action(self, parsed_args): + rows = const.QUOTA_ROWS + attrs = v2_utils.get_quota_attrs(self.app.client_manager, + parsed_args) + project_id = attrs.pop('project_id') + + data = self.app.client_manager.load_balancer.quota_show( + project_id=project_id + ) + + return (rows, (utils.get_dict_properties(data, rows))) + + +class ShowQuotaDefaults(command.ShowOne): + """Show quota defaults""" + + def take_action(self, parsed_args): + rows = const.QUOTA_ROWS + data = self.app.client_manager.load_balancer.quota_defaults_show() + return (rows, (utils.get_dict_properties(data['quota'], rows))) + + +class SetQuota(command.ShowOne): + """Update a quota""" + + @staticmethod + def _check_attrs(attrs): + args = ['health_monitor', 'listener', 'load_balancer', 'member', + 'pool'] + + if not any(arg in attrs for arg in args): + args = [arg.replace('_', '') for arg in args] + msg = ('Missing required argument. Requires at least one of:%s' % + ','.join((' --%s' % arg) for arg in args)) + raise exceptions.CommandError(msg) + + def get_parser(self, prog_name): + parser = super(SetQuota, self).get_parser(prog_name) + + quota_group = parser.add_argument_group( + "Quota limits", + description='At least one of the following arguments is required.' + ) + + quota_group.add_argument( + '--healthmonitor', + dest='health_monitor', + metavar='', + help=('New value for the health monitor quota. Value -1 means ' + 'unlimited.') + ) + quota_group.add_argument( + '--listener', + metavar='', + help=('New value for the listener quota. Value -1 means ' + 'unlimited.') + ) + quota_group.add_argument( + '--loadbalancer', + dest='load_balancer', + metavar='', + help=('New value for the load balancer quota limit. Value -1 ' + 'means unlimited.') + ) + quota_group.add_argument( + '--member', + metavar='', + help=('New value for the member quota limit. Value -1 means ' + 'unlimited.') + ) + quota_group.add_argument( + '--pool', + metavar='', + help=('New value for the pool quota limit. Value -1 means ' + 'unlimited.') + ) + + parser.add_argument( + 'project', + metavar='', + help="Name or UUID of the project." + ) + + return parser + + def take_action(self, parsed_args): + rows = const.QUOTA_ROWS + attrs = v2_utils.get_quota_attrs(self.app.client_manager, + parsed_args) + self._check_attrs(attrs) + project_id = attrs.pop('project_id') + body = {'quota': attrs} + + data = self.app.client_manager.load_balancer.quota_set(project_id, + json=body) + + return (rows, (utils.get_dict_properties(data['quota'], rows))) + + +class ResetQuota(command.Command): + """Resets quotas to default quotas""" + + def get_parser(self, prog_name): + parser = super(ResetQuota, self).get_parser(prog_name) + + parser.add_argument( + 'project', + metavar="", + help="Project to reset quotas (name or ID)" + ) + + return parser + + def take_action(self, parsed_args): + attrs = v2_utils.get_quota_attrs(self.app.client_manager, + parsed_args) + + project_id = attrs.pop('project_id') + + self.app.client_manager.load_balancer.quota_reset( + project_id=project_id) diff --git a/octaviaclient/osc/v2/utils.py b/octaviaclient/osc/v2/utils.py index 047d190..249908e 100644 --- a/octaviaclient/osc/v2/utils.py +++ b/octaviaclient/osc/v2/utils.py @@ -376,6 +376,26 @@ def get_health_monitor_attrs(client_manager, parsed_args): return attrs +def get_quota_attrs(client_manager, parsed_args): + attr_map = { + 'health_monitor': ('health_monitor', int), + 'listener': ('listener', int), + 'load_balancer': ('load_balancer', int), + 'member': ('member', int), + 'pool': ('pool', int), + 'project': ( + 'project_id', + 'project', + client_manager.identity + ), + } + + _attrs = vars(parsed_args) + attrs = _map_attrs(_attrs, attr_map) + + return attrs + + def format_list(data): return '\n'.join(i['id'] for i in data) diff --git a/octaviaclient/tests/unit/api/test_load_balancer.py b/octaviaclient/tests/unit/api/test_load_balancer.py index 1512490..af77b25 100644 --- a/octaviaclient/tests/unit/api/test_load_balancer.py +++ b/octaviaclient/tests/unit/api/test_load_balancer.py @@ -31,6 +31,7 @@ FAKE_ME = uuidutils.generate_uuid() FAKE_L7PO = uuidutils.generate_uuid() FAKE_L7RU = uuidutils.generate_uuid() FAKE_HM = uuidutils.generate_uuid() +FAKE_PRJ = uuidutils.generate_uuid() LIST_LB_RESP = { @@ -74,6 +75,16 @@ LIST_HM_RESP = { {'id': uuidutils.generate_uuid()}] } +LIST_QT_RESP = { + 'quotas': + [{'health_monitor': -1}, + {'listener': -1}, + {'load_balancer': 5}, + {'member': 10}, + {'pool': 20}, + {'project': uuidutils.generate_uuid()}] +} + SINGLE_LB_RESP = {'loadbalancer': {'id': FAKE_LB, 'name': 'lb1'}} SINGLE_LB_UPDATE = {"loadbalancer": {"admin_state_up": False}} SINGLE_LB_STATS_RESP = {'bytes_in': '0'} @@ -96,6 +107,9 @@ SINGLE_L7RU_UPDATE = {'rule': {'admin_state_up': False}} SINGLE_HM_RESP = {'healthmonitor': {'id': FAKE_ME}} SINGLE_HM_UPDATE = {'healthmonitor': {'admin_state_up': False}} +SINGLE_QT_RESP = {'quota': {'pool': -1}} +SINGLE_QT_UPDATE = {'quota': {'pool': -1}} + class TestLoadBalancerv2(utils.TestCase): @@ -727,3 +741,66 @@ class TestLoadBalancer(TestLoadBalancerv2): self._error_message, self.api.health_monitor_delete, FAKE_HM) + + def test_list_quota_no_options(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + 'quotas', + json=LIST_QT_RESP, + status_code=200, + ) + ret = self.api.quota_list() + self.assertEqual(LIST_QT_RESP, ret) + + def test_show_quota(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + 'quotas/' + FAKE_PRJ, + json=SINGLE_QT_RESP, + status_code=200 + ) + ret = self.api.quota_show(FAKE_PRJ) + self.assertEqual(SINGLE_QT_RESP['quota'], ret) + + def test_set_quota(self): + self.requests_mock.register_uri( + 'PUT', + FAKE_URL + 'quotas/' + FAKE_PRJ, + json=SINGLE_QT_UPDATE, + status_code=200 + ) + ret = self.api.quota_set(FAKE_PRJ, json=SINGLE_QT_UPDATE) + self.assertEqual(SINGLE_QT_UPDATE, ret) + + def test_set_quota_error(self): + self.requests_mock.register_uri( + 'PUT', + FAKE_URL + 'quotas/' + FAKE_PRJ, + text='{"faultstring": "%s"}' % self._error_message, + status_code=400 + ) + self.assertRaisesRegex(lb.OctaviaClientException, + self._error_message, + self.api.quota_set, + FAKE_PRJ, json=SINGLE_QT_UPDATE) + + def test_reset_quota(self): + self.requests_mock.register_uri( + 'DELETE', + FAKE_URL + 'quotas/' + FAKE_PRJ, + status_code=200 + ) + ret = self.api.quota_reset(FAKE_PRJ) + self.assertEqual(200, ret.status_code) + + def test_reset_quota_error(self): + self.requests_mock.register_uri( + 'DELETE', + FAKE_URL + 'quotas/' + FAKE_PRJ, + text='{"faultstring": "%s"}' % self._error_message, + status_code=400 + ) + self.assertRaisesRegex(lb.OctaviaClientException, + self._error_message, + self.api.quota_reset, + FAKE_PRJ) diff --git a/octaviaclient/tests/unit/osc/v2/fakes.py b/octaviaclient/tests/unit/osc/v2/fakes.py index 93e3868..a2ab14d 100644 --- a/octaviaclient/tests/unit/osc/v2/fakes.py +++ b/octaviaclient/tests/unit/osc/v2/fakes.py @@ -264,3 +264,28 @@ class FakeHM(object): loaded=True) return hm + + +class FakeQT(object): + """Fake one or more Quota.""" + + @staticmethod + def create_one_quota(attrs=None): + attrs = attrs or {} + + qt_info = { + "health_monitor": -1, + "listener": None, + "load_balancer": 5, + "member": 50, + "pool": None, + "project_id": uuidutils.generate_uuid(dashed=True) + } + + qt_info.update(attrs) + + qt = fakes.FakeResource( + info=copy.deepcopy(qt_info), + loaded=True) + + return qt diff --git a/octaviaclient/tests/unit/osc/v2/test_quota.py b/octaviaclient/tests/unit/osc/v2/test_quota.py new file mode 100644 index 0000000..503632d --- /dev/null +++ b/octaviaclient/tests/unit/osc/v2/test_quota.py @@ -0,0 +1,227 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import copy +import mock + +from osc_lib import exceptions + +from octaviaclient.osc.v2 import quota +from octaviaclient.tests.unit.osc.v2 import fakes as qt_fakes + +AUTH_TOKEN = "foobar" +AUTH_URL = "http://192.0.2.2" + + +class TestQuota(qt_fakes.TestLoadBalancerv2): + + _qt = qt_fakes.FakeQT.create_one_quota() + + columns = ('project_id', 'load_balancer', 'listener', 'pool', + 'health_monitor', 'member') + + datalist = ( + ( + _qt.project_id, + _qt.load_balancer, + _qt.listener, + _qt.pool, + _qt.health_monitor, + _qt.member + ), + ) + + info = { + 'quotas': + [{ + "project_id": _qt.project_id, + "load_balancer": _qt.load_balancer, + "listener": _qt.listener, + "pool": _qt.pool, + "health_monitor": _qt.health_monitor, + "member": _qt.member + }] + } + qt_info = copy.deepcopy(info) + + def setUp(self): + super(TestQuota, self).setUp() + self.qt_mock = self.app.client_manager.load_balancer.load_balancers + self.qt_mock.reset_mock() + + self.api_mock = mock.Mock() + self.api_mock.quota_list.return_value = self.qt_info + lb_client = self.app.client_manager + lb_client.load_balancer = self.api_mock + + +class TestQuotaList(TestQuota): + + def setUp(self): + super(TestQuotaList, self).setUp() + self.cmd = quota.ListQuota(self.app, None) + + def test_quota_list_no_options(self): + arglist = [] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + + self.api_mock.quota_list.assert_called_with() + self.assertEqual(self.columns, columns) + self.assertEqual(self.datalist, tuple(data)) + + +class TestQuotaShow(TestQuota): + + def setUp(self): + super(TestQuotaShow, self).setUp() + self.api_mock = mock.Mock() + self.api_mock.quota_list.return_value = self.qt_info + self.api_mock.quota_show.return_value = { + 'quota': self.qt_info['quotas'][0]} + lb_client = self.app.client_manager + lb_client.load_balancer = self.api_mock + + self.cmd = quota.ShowQuota(self.app, None) + + @mock.patch('octaviaclient.osc.v2.utils.get_quota_attrs') + def test_quota_show(self, mock_attrs): + mock_attrs.return_value = self.qt_info['quotas'][0] + arglist = [self._qt.project_id] + verifylist = [ + ('project', self._qt.project_id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + self.api_mock.quota_show.assert_called_with( + project_id=self._qt.project_id) + + +class TestQuotaDefaultsShow(TestQuota): + + qt_defaults = { + "health_monitor": 1, + "listener": 2, + "load_balancer": 3, + "member": 4, + "pool": 5 + } + + def setUp(self): + super(TestQuotaDefaultsShow, self).setUp() + + self.api_mock = mock.Mock() + self.api_mock.quota_defaults_show.return_value = { + 'quota': self.qt_defaults} + + lb_client = self.app.client_manager + lb_client.load_balancer = self.api_mock + + self.cmd = quota.ShowQuotaDefaults(self.app, None) + + def test_quota_defaults_show(self): + arglist = [] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + rows, data = self.cmd.take_action(parsed_args) + data = dict(zip(rows, data)) + + self.api_mock.quota_defaults_show.assert_called_with() + self.assertEqual(self.qt_defaults, data) + + +class TestQuotaSet(TestQuota): + + def setUp(self): + super(TestQuotaSet, self).setUp() + self.api_mock = mock.Mock() + self.api_mock.quota_set.return_value = { + 'quota': self.qt_info['quotas'][0]} + lb_client = self.app.client_manager + lb_client.load_balancer = self.api_mock + + self.cmd = quota.SetQuota(self.app, None) + + @mock.patch('octaviaclient.osc.v2.utils.get_quota_attrs') + def test_quota_set(self, mock_attrs): + mock_attrs.return_value = { + 'project_id': self._qt.project_id, + 'health_monitor': '-1', + 'listener': '1', + 'load_balancer': '2', + 'member': '3', + 'pool': '4' + } + arglist = [self._qt.project_id, '--healthmonitor', '-1', '--listener', + '1', '--loadbalancer', '2', '--member', '3', '--pool', '4'] + verifylist = [ + ('project', self._qt.project_id), + ('health_monitor', '-1'), + ('listener', '1'), + ('load_balancer', '2'), + ('member', '3'), + ('pool', '4') + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + self.api_mock.quota_set.assert_called_with( + self._qt.project_id, json={'quota': {'health_monitor': '-1', + 'listener': '1', + 'load_balancer': '2', + 'member': '3', + 'pool': '4'}}) + + @mock.patch('octaviaclient.osc.v2.utils.get_quota_attrs') + def test_quota_set_no_args(self, mock_attrs): + project_id = ['fake_project_id'] + mock_attrs.return_value = {'project_id': project_id} + + arglist = [project_id] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.assertRaises(exceptions.CommandError, self.cmd.take_action, + parsed_args) + self.assertNotCalled(self.api_mock.quota_set) + + +class TestQuotaReset(TestQuota): + + def setUp(self): + super(TestQuotaReset, self).setUp() + self.cmd = quota.ResetQuota(self.app, None) + + @mock.patch('octaviaclient.osc.v2.utils.get_quota_attrs') + def test_quota_reset(self, mock_attrs): + # create new quota, otherwise other quota tests will fail occasionally + # due to a race condition + project_id = 'fake_project_id' + attrs = {'project_id': project_id} + qt_reset = qt_fakes.FakeQT.create_one_quota(attrs) + + mock_attrs.return_value = qt_reset.to_dict() + + arglist = [project_id] + verifylist = [ + ('project', project_id) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + self.api_mock.quota_reset.assert_called_with( + project_id=qt_reset.project_id) diff --git a/releasenotes/notes/add-quota-support-effed2cf2a8c7ad4.yaml b/releasenotes/notes/add-quota-support-effed2cf2a8c7ad4.yaml new file mode 100644 index 0000000..c4702f2 --- /dev/null +++ b/releasenotes/notes/add-quota-support-effed2cf2a8c7ad4.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Octavia quota support for the OpenStack client plugin. + + * List, show, set, reset quotas + * Show quota defaults diff --git a/setup.cfg b/setup.cfg index cc604f3..582c153 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,11 @@ openstack.load_balancer.v2 = loadbalancer_healthmonitor_show = octaviaclient.osc.v2.health_monitor:ShowHealthMonitor loadbalancer_healthmonitor_delete = octaviaclient.osc.v2.health_monitor:DeleteHealthMonitor loadbalancer_healthmonitor_set = octaviaclient.osc.v2.health_monitor:SetHealthMonitor + loadbalancer_quota_list = octaviaclient.osc.v2.quota:ListQuota + loadbalancer_quota_show = octaviaclient.osc.v2.quota:ShowQuota + loadbalancer_quota_defaults_show = octaviaclient.osc.v2.quota:ShowQuotaDefaults + loadbalancer_quota_reset = octaviaclient.osc.v2.quota:ResetQuota + loadbalancer_quota_set = octaviaclient.osc.v2.quota:SetQuota [build_sphinx] source-dir = doc/source