diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e3dfdef..9eae9b6d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,8 +48,8 @@ jobs: uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - - name: Install tox - run: sudo apt update && sudo apt install --yes tox libev-dev libvirt-dev + - name: Install dependencies + run: sudo apt update && sudo apt install --yes tox libev-dev libvirt-dev pdns-server pdns-backend-pgsql - name: Unit tests run: | tox -e ${{ matrix.python-version }} @@ -74,4 +74,4 @@ jobs: run: sudo apt update && sudo apt install --yes tox libev-dev libvirt-dev - name: Coverage run: | - tox -e begin,${{ matrix.python-version }},end \ No newline at end of file + tox -e begin,${{ matrix.python-version }},end diff --git a/etc/powerdns/genesis.conf b/etc/powerdns/genesis.conf new file mode 100644 index 00000000..ba1c4716 --- /dev/null +++ b/etc/powerdns/genesis.conf @@ -0,0 +1,9 @@ +local-port=5300 + +launch+=gpgsql +gpgsql-dbname=genesis_core +gpgsql-user=genesis_core +gpgsql-password=genesis_core +gpgsql-host=localhost +# or set several ips explicitly, it'll try to use them in order +# gpgsql-extra-connection-parameters=hostaddr=172.17.0.1,172.17.0.2 diff --git a/genesis/images/install.sh b/genesis/images/install.sh index 6216c7ca..0605f23f 100644 --- a/genesis/images/install.sh +++ b/genesis/images/install.sh @@ -114,3 +114,14 @@ sudo cp "$GC_PATH/etc/systemd/genesis-universal-scheduler.service" $SYSTEMD_SERV sudo systemctl enable gc-user-api gc-orch-api gc-status-api gc-gservice \ genesis-universal-agent \ genesis-universal-scheduler + + +# Prepare DNSaaS + +# Install packages +sudo apt install pdns-backend-pgsql pdns-server -y + +sudo rm /etc/powerdns/pdns.d/bind.conf +sudo cp "$GC_PATH/etc/powerdns/genesis.conf" /etc/powerdns/pdns.d/genesis.conf + +sudo systemctl enable pdns diff --git a/genesis_core/tests/functional/service/dns/__init__.py b/genesis_core/tests/functional/service/dns/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/genesis_core/tests/functional/service/dns/conftest.py b/genesis_core/tests/functional/service/dns/conftest.py new file mode 100644 index 00000000..17bd38bf --- /dev/null +++ b/genesis_core/tests/functional/service/dns/conftest.py @@ -0,0 +1,99 @@ +# Copyright 2025 Genesis Corporation. +# +# All Rights Reserved. +# +# 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. + +from __future__ import annotations + +import logging +import os +import socket +import subprocess +from contextlib import closing +import typing as tp +import uuid as sys_uuid +from urllib.parse import urlparse + +import pytest + +from restalchemy.tests.functional import consts as ra_c + + +LOG = logging.getLogger(__name__) + +PDNS_BIN = "/usr/sbin/pdns_server" + + +def find_free_port(): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(("", 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] + + +@pytest.fixture() +def pdns_server(user_api, tmp_path_factory: pytest.TempPathFactory): + result = urlparse(ra_c.DATABASE_URI) + if result.scheme != "postgresql": + pytest.skip("Only PostgreSQL is supported for PowerDNS tests") + if not os.path.exists(PDNS_BIN): + pytest.skip( + "PowerDNS server binary not found, dataplane can't be checked" + ) + + port = result.port + + directory = tmp_path_factory.mktemp("pdns") + config_file = directory / "pdns.conf" + port = find_free_port() + + config = f"""\ +local-port={port} +launch=gpgsql +gpgsql-dbname={result.path[1:]} +gpgsql-user={result.username} +gpgsql-password={result.password} +gpgsql-host={result.hostname} +loglevel=100 +query-logging=yes +log-dns-queries=yes +""" + with open(config_file, "w") as f: + f.write(config) + + proc = subprocess.Popen( + [ + PDNS_BIN, + "--guardian=no", + "--daemon=no", + "--disable-syslog", + "--log-timestamp=no", + "--write-pid=no", + "--socket-dir=" + str(directory), + "--config-dir=" + str(directory), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + # Check it started successfully + assert not proc.poll(), proc.stdout.read().decode("utf-8") + + yield port + + proc.terminate() + + # Useful to debug if things go wrong, will be shown only on test failure. + LOG.warning("PDNS log:") + LOG.warning(proc.stdout.read().decode("utf-8")) diff --git a/genesis_core/tests/functional/service/dns/test_pdns.py b/genesis_core/tests/functional/service/dns/test_pdns.py new file mode 100644 index 00000000..2dc40c05 --- /dev/null +++ b/genesis_core/tests/functional/service/dns/test_pdns.py @@ -0,0 +1,224 @@ +# Copyright 2025 Genesis Corporation. +# +# All Rights Reserved. +# +# 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 uuid as sys_uuid +import typing as tp + +import dns.resolver +import pytest +from gcl_iam.tests.functional import clients as iam_clients + +from genesis_core.common import constants as c + + +DEF_DOMAIN = "core.internal" + + +class TestDnsApi: + + # Utils + + @staticmethod + def _cmp_shallow( + left: tp.Dict[str, tp.Any], + right: tp.Dict[str, tp.Any], + ): + return all((left[key] == right[key]) for key in left.keys()) + + @pytest.fixture() + def domain1( + self, + user_api_client: iam_clients.GenesisCoreTestRESTClient, + auth_user_admin: iam_clients.GenesisCoreAuth, + ): + domain = { + "uuid": str(sys_uuid.uuid4()), + "name": DEF_DOMAIN, + "project_id": str(c.SERVICE_PROJECT_ID), + } + client = user_api_client(auth_user_admin) + url = client.build_collection_uri(["dns", "domains"]) + + response = client.post(url, json=domain) + output = response.json() + + assert response.status_code == 201 + assert self._cmp_shallow(domain, output) + yield output + + # DNS + + def test_domains_list( + self, + user_api_client: iam_clients.GenesisCoreTestRESTClient, + auth_user_admin: iam_clients.GenesisCoreAuth, + ): + client = user_api_client(auth_user_admin) + url = client.build_collection_uri(["dns", "domains"]) + + response = client.get(url) + + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_domains_add( + self, + user_api_client: iam_clients.GenesisCoreTestRESTClient, + auth_user_admin: iam_clients.GenesisCoreAuth, + domain1: tp.Dict, + pdns_server: int | None, + ): + client = user_api_client(auth_user_admin) + + # Check SOA Record + + url = client.build_collection_uri( + ["dns", "domains", domain1["uuid"], "records"] + ) + + response = client.get(url) + records = response.json() + assert response.status_code == 200 + assert len(records) == 1 + assert records[0]["type"] == "SOA" + assert records[0]["record"]["name"] == "@" + assert ( + records[0]["record"]["primary_dns"] + == "a.misconfigured.dns.server.invalid" + ) + + if pdns_server: + res = dns.resolver.make_resolver_at("127.0.0.1", port=pdns_server) + answer = res.resolve(DEF_DOMAIN, "SOA") + + assert len(answer) == 1 + assert ( + answer[0].to_text() + == "a.misconfigured.dns.server.invalid. core.internal. 0 10800 3600 604800 3600" + ) + + # Delete + + url = client.build_resource_uri(["dns", "domains", domain1["uuid"]]) + + response = client.delete(url) + + assert response.status_code == 204 + + url = client.build_collection_uri(["dns", "domains"]) + + response = client.get(url) + + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_a_record( + self, + user_api_client: iam_clients.GenesisCoreTestRESTClient, + auth_user_admin: iam_clients.GenesisCoreAuth, + domain1: tp.Dict, + pdns_server: int | None, + ): + client = user_api_client(auth_user_admin) + + data = { + "uuid": str(sys_uuid.uuid4()), + "type": "A", + "ttl": 0, + "record": {"kind": "A", "name": "test", "address": "1.2.3.4"}, + } + + url = client.build_collection_uri( + ["dns", "domains", domain1["uuid"], "records"] + ) + + response = client.post(url, json=data) + output = response.json() + + assert response.status_code == 201 + assert self._cmp_shallow(data, output) + + url = client.build_resource_uri( + ["dns", "domains", domain1["uuid"], "records", data["uuid"]] + ) + + response = client.get(url) + record = response.json() + assert response.status_code == 200 + assert self._cmp_shallow(data, record) + + if pdns_server: + res = dns.resolver.make_resolver_at("127.0.0.1", port=pdns_server) + answer = res.resolve(f"test.{DEF_DOMAIN}", "A") + + assert len(answer) == 1 + assert answer[0].address == "1.2.3.4" + + # Delete + response = client.delete(url) + + assert response.status_code == 204 + + def test_txt_record( + self, + user_api_client: iam_clients.GenesisCoreTestRESTClient, + auth_user_admin: iam_clients.GenesisCoreAuth, + domain1: tp.Dict, + pdns_server: int | None, + ): + client = user_api_client(auth_user_admin) + + data = { + "uuid": str(sys_uuid.uuid4()), + "type": "TXT", + "ttl": 0, + "record": {"kind": "TXT", "name": "test", "content": "a" * 5000}, + } + + url = client.build_collection_uri( + ["dns", "domains", domain1["uuid"], "records"] + ) + + response = client.post(url, json=data) + output = response.json() + + assert response.status_code == 201 + assert self._cmp_shallow(data, output) + + url = client.build_resource_uri( + ["dns", "domains", domain1["uuid"], "records", data["uuid"]] + ) + + response = client.get(url) + record = response.json() + assert response.status_code == 200 + assert self._cmp_shallow(data, record) + + if pdns_server: + res = dns.resolver.make_resolver_at("127.0.0.1", port=pdns_server) + answer = res.resolve(f"test.{DEF_DOMAIN}", "TXT") + + assert len(answer) == 1 + # TXT records may not fit in one UDP frame, so there'll be many + # strings inside + assert ( + "".join([i.decode() for i in answer[0].strings]) == "a" * 5000 + ) + + # Delete + response = client.delete(url) + + assert response.status_code == 204 diff --git a/genesis_core/tests/functional/utils.py b/genesis_core/tests/functional/utils.py index f67e5311..16ae9ad3 100644 --- a/genesis_core/tests/functional/utils.py +++ b/genesis_core/tests/functional/utils.py @@ -44,6 +44,8 @@ def setup_class(cls): def teardown_class(cls): cls.drop_all_views() cls.drop_all_tables(cascade=True) + # Hack for psycopg to finish fast, otherwise we'll need to wait for GC + cls.engine.__del__() cls.destroy_engine() @staticmethod diff --git a/genesis_core/user_api/api/routes.py b/genesis_core/user_api/api/routes.py index eef98e3a..d7a6aebe 100644 --- a/genesis_core/user_api/api/routes.py +++ b/genesis_core/user_api/api/routes.py @@ -17,6 +17,7 @@ from restalchemy.api import routes from genesis_core.user_api.api import controllers +from genesis_core.user_api.dns.api import routes as dns_routes from genesis_core.user_api.em.api import routes as em_routes from genesis_core.user_api.iam.api import routes as iam_routes from genesis_core.user_api.config.api import routes as config_routes @@ -60,6 +61,7 @@ class ApiEndpointRoute(routes.Route): __controller__ = controllers.ApiEndpointController __allow_methods__ = [routes.FILTER] + dns = routes.route(dns_routes.DnsRoute) health = routes.route(HealthRoute) iam = routes.route(iam_routes.IamRoute) em = routes.route(em_routes.ElementManagerRoute) diff --git a/genesis_core/user_api/dns/__init__.py b/genesis_core/user_api/dns/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/genesis_core/user_api/dns/api/__init__.py b/genesis_core/user_api/dns/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/genesis_core/user_api/dns/api/controllers.py b/genesis_core/user_api/dns/api/controllers.py new file mode 100644 index 00000000..9554879d --- /dev/null +++ b/genesis_core/user_api/dns/api/controllers.py @@ -0,0 +1,78 @@ +# Copyright 2025 Genesis Corporation. +# +# All Rights Reserved. +# +# 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 collections +import urllib.parse + +import netaddr +from oslo_config import cfg +from gcl_iam import controllers as iam_controllers +from restalchemy.api import controllers as ra_controllers +from restalchemy.api import constants +from restalchemy.api import field_permissions as field_p +from restalchemy.api import resources + +from genesis_core.user_api.dns.dm import models +from genesis_core.user_api.api import versions + + +CONF = cfg.CONF + + +class DnsController(ra_controllers.RoutesListController): + + __TARGET_PATH__ = "/v1/dns/" + + +class DomainController( + iam_controllers.PolicyBasedController, + ra_controllers.BaseResourceControllerPaginated, +): + __policy_service_name__ = "dns" + __policy_name__ = "domains" + + __resource__ = resources.ResourceByRAModel( + models.Domain, + convert_underscore=False, + fields_permissions=field_p.FieldsPermissions( + default=field_p.Permissions.RW, + fields={ + "id": {constants.ALL: field_p.Permissions.HIDDEN}, + }, + ), + ) + + +class RecordController( + iam_controllers.NestedPolicyBasedController, + ra_controllers.BaseResourceControllerPaginated, +): + __pr_name__ = "domain" + __policy_service_name__ = "dns" + __policy_name__ = "records" + + __resource__ = resources.ResourceByRAModel( + models.Record, + convert_underscore=False, + fields_permissions=field_p.FieldsPermissions( + default=field_p.Permissions.RW, + fields={ + "domain_id": {constants.ALL: field_p.Permissions.HIDDEN}, + "name": {constants.ALL: field_p.Permissions.HIDDEN}, + "content": {constants.ALL: field_p.Permissions.HIDDEN}, + }, + ), + ) diff --git a/genesis_core/user_api/dns/api/routes.py b/genesis_core/user_api/dns/api/routes.py new file mode 100644 index 00000000..b8045fc4 --- /dev/null +++ b/genesis_core/user_api/dns/api/routes.py @@ -0,0 +1,42 @@ +# Copyright 2025 Genesis Corporation. +# +# All Rights Reserved. +# +# 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. + +from restalchemy.api import routes + +from genesis_core.user_api.dns.api import controllers + + +class RecordsRoute(routes.Route): + """Handler for /v1/dns//records/ endpoint""" + + __controller__ = controllers.RecordController + + +class DomainsRoute(routes.Route): + """Handler for /v1/dns/domains/ endpoint""" + + __controller__ = controllers.DomainController + + records = routes.route(RecordsRoute, resource_route=True) + + +class DnsRoute(routes.Route): + """Handler for /v1/dns/ endpoint""" + + __controller__ = controllers.DnsController + __allow_methods__ = [routes.FILTER] + + domains = routes.route(DomainsRoute) diff --git a/genesis_core/user_api/dns/dm/__init__.py b/genesis_core/user_api/dns/dm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/genesis_core/user_api/dns/dm/models.py b/genesis_core/user_api/dns/dm/models.py new file mode 100644 index 00000000..0508d75b --- /dev/null +++ b/genesis_core/user_api/dns/dm/models.py @@ -0,0 +1,225 @@ +# Copyright 2025 Genesis Corporation. +# +# All Rights Reserved. +# +# 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 re + +from oslo_config import cfg +from restalchemy.common import contexts +from restalchemy.common import exceptions +from restalchemy.dm import filters +from restalchemy.dm import models +from restalchemy.dm import properties +from restalchemy.dm import relationships +from restalchemy.dm import types +from restalchemy.dm import types_dynamic +from restalchemy.dm import types_network +from restalchemy.storage.sql import orm + +from genesis_core.common import utils as u + + +CONF = cfg.CONF + + +class SOARecordDeleteRestricted(exceptions.RestAlchemyException): + code = 403 + message = "SOA record cannot be deleted without domain deletion." + + +class CommonModel( + models.ModelWithTimestamp, + models.ModelWithUUID, + orm.SQLStorableMixin, + models.SimpleViewMixin, +): + pass + + +class Domain(CommonModel, models.ModelWithProject): + __tablename__ = "dns_domains" + name = properties.property(types.String(), required=True) + # Used only for PDNS + id = properties.property(types.Integer()) + # Next columns exist in DB but used only for PDNS support and have + # sane defaults. + # id = properties.property(types.Integer()) + # last_check = properties.property(types.Integer(), default=None) + # notified_serial = properties.property(types.Integer(), default=None) + # type = properties.property(types.Enum(("PRIMARY", "SLAVE")), required=True) + # master = properties.property(types.String(), default=None) + # account = properties.property(types.String(), default=None) + # options = properties.property(types.Text(), default=None) + # catalog = properties.property(types.Text(), default=None) + + @classmethod + def get_next_domain_id(cls, session=None): + session = session or contexts.Context().get_session() + return session.execute( + "SELECT nextval('dns_domain_id_seq') as val" + ).fetchall()[0]["val"] + + def __init__(self, session=None, **kwargs): + super().__init__(id=self.get_next_domain_id(session=session), **kwargs) + + def insert(self, session=None): + # TODO: to be public autoritative DNS, we need: + # - make sure the SOA record is correct (serial, too, for zone transfers) + # (or don't update serial, it's needed only for secondary DNS replicaion, + # we can just don't support it, route53 doesn't support it either) + super().insert(session=session) + # TODO: make default soa record configurable + # TODO: set soa serial as date, see ya.ru for example + soa = Record( + domain=self, + type="SOA", + record=SOARecord( + name="", + ), + ) + soa.save(session=session) + + def delete(self, session=None, **kwargs): + Record.objects.get_one( + session=session, + filters={"domain": filters.EQ(self), "type": "SOA"}, + ).delete(session=session, force=True) + u.remove_nested_dm(Record, "domain", self, session=session) + return super().delete(session=session, **kwargs) + + +# TODO: configure powerdns to not even try to read domainmetadata? +# NOTE: Powerdns checks settings per each domain, non-existent row is ok too, +# so just don't implement it if not needed. +# class DomainMetadata: +# __tablename__ = "domainmetadata" + + +class AbstractRecord(types_dynamic.AbstractKindModel): + def get_name(self, domain) -> str: + return ( + (".").join((self.name, domain.name)) if self.name else domain.name + ) + + def get_content(self, domain) -> str: + return str(self.content) + + +class ARecord(AbstractRecord): + KIND = "A" + + name = properties.property( + types_network.RecordName(), + required=True, + ) + address = properties.property( + types_network.IPAddress(), + required=True, + ) + + def get_content(self, domain) -> str: + return str(self.address) + + +class SOARecord(AbstractRecord): + KIND = "SOA" + + name = properties.property( + types_network.RecordName(), + required=True, + ) + primary_dns = properties.property( + types_network.Hostname(), default="a.misconfigured.dns.server.invalid" + ) + # serial may not be incremented if we don't need domain transfers + serial = properties.property(types.Integer(min_value=0), default=0) + refresh = properties.property(types.Integer(min_value=60), default=10800) + retry = properties.property(types.Integer(min_value=60), default=3600) + expire = properties.property(types.Integer(min_value=60), default=604800) + ttl = properties.property(types.Integer(min_value=60), default=3600) + + def get_content(self, domain) -> str: + return f"{self.primary_dns} {domain.name} {self.serial} {self.refresh} {self.retry} {self.expire} {self.ttl}" + + +class TXTRecord(AbstractRecord): + KIND = "TXT" + + name = properties.property( + types_network.RecordName(), + required=True, + ) + content = properties.property( + # Restrict newline and length + types.BaseCompiledRegExpType(re.compile(r"^([^\n]{1,8192})$")), + required=True, + ) + + +class Record(CommonModel): + __tablename__ = "dns_records" + domain = relationships.relationship(Domain, required=True) + domain_id = properties.property(types.Integer()) + type = properties.property( + types.Enum(("A", "SOA", "TXT")), # "AAAA", "CNAME", "MX", + read_only=True, + required=True, + ) + ttl = properties.property(types.Integer(), required=True, default=3600) + prio = properties.property(types.Integer(), default=None) + disabled = properties.property(types.Boolean(), default=False) + record = properties.property( + types_dynamic.KindModelSelectorType( + types_dynamic.KindModelType(ARecord), + types_dynamic.KindModelType(SOARecord), + types_dynamic.KindModelType(TXTRecord), + ), + required=True, + ) + # Next columns are autofilled with record submodel's data for powerdns + name = properties.property(types.String()) + content = properties.property(types.String()) + # Next columns exist in DB but used only for PDNS support and have + # sane defaults. + # domain_id = properties.property(types.Integer()) + # ordername = properties.property(types.String(), default=None) + # auth = properties.property(types.Boolean(), default=True)ะก + + def __init__(self, domain: Domain, **kwargs) -> None: + super().__init__(domain=domain, domain_id=domain.id, **kwargs) + + self._fill_n_validate_record() + + def _fill_n_validate_record(self) -> None: + if self.type != self.record.kind: + raise ValueError("Types of model and record must match") + + self.content = self.record.get_content(self.domain) + self.name = self.record.get_name(self.domain) + + def update(self, session=None, force=False): + self._fill_n_validate_record() + + super().update(session=session, force=force) + + def insert(self, session=None): + self._fill_n_validate_record() + + super().insert(session=session) + + def delete(self, session=None, force=False, **kwargs): + if not force and self.type == "SOA": + raise SOARecordDeleteRestricted() + return super().delete(session=session, **kwargs) diff --git a/migrations/0019-init-dns-40a307.py b/migrations/0019-init-dns-40a307.py new file mode 100644 index 00000000..df8fb519 --- /dev/null +++ b/migrations/0019-init-dns-40a307.py @@ -0,0 +1,136 @@ +# Copyright 2016 Eugene Frolov +# Copyright 2025 Genesis Corporation +# +# All Rights Reserved. +# +# 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. + +from restalchemy.storage.sql import migrations + + +class MigrationStep(migrations.AbstarctMigrationStep): + + def __init__(self): + self._depends = ["0018-add-elements-76bca4.py"] + + @property + def migration_id(self): + return "40a307b3-fdcc-46d8-bc81-1e2a53ac59e4" + + @property + def is_manual(self): + return False + + def upgrade(self, session): + expressions = [ + """ +CREATE SEQUENCE dns_domain_id_seq; + """, + """ +CREATE TABLE dns_domains ( + uuid UUID PRIMARY KEY, + id INT UNIQUE DEFAULT nextval('dns_domain_id_seq'), + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type TEXT NOT NULL DEFAULT 'NATIVE', + notified_serial BIGINT DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + options TEXT DEFAULT NULL, + catalog TEXT DEFAULT NULL, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))), + -- our (plus uuid) + project_id UUID NOT NULL, + "created_at" TIMESTAMP(6) NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX ON dns_domains(id); +CREATE UNIQUE INDEX ON dns_domains(name); +CREATE INDEX ON dns_domains(catalog); +CREATE INDEX on dns_domains(project_id, name); + """, + """ +CREATE TABLE dns_domainmetadata ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES dns_domains(id) ON DELETE CASCADE, + kind VARCHAR(32), + content TEXT +); + +CREATE INDEX ON dns_domainmetadata(domain_id); + """, + """ +CREATE TABLE dns_records ( + uuid UUID PRIMARY KEY, + domain_id INT DEFAULT NULL REFERENCES dns_domains(id) ON DELETE RESTRICT, + name VARCHAR(255) DEFAULT NULL CHECK (((name)::TEXT = LOWER((name)::TEXT))), + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(65535) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + disabled BOOL DEFAULT 'f', + ordername VARCHAR(255), + auth BOOL DEFAULT 't', + -- our (plus uuid) + domain UUID NOT NULL REFERENCES dns_domains(uuid) ON DELETE CASCADE, + "record" JSONB NOT NULL, + "created_at" TIMESTAMP(6) NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT NOW() +); + +CREATE INDEX rec_name_index ON dns_records(name); +CREATE INDEX nametype_index ON dns_records(name,type); +CREATE INDEX domain_id ON dns_records(domain_id); +CREATE INDEX recordorder ON dns_records (domain_id, ordername text_pattern_ops); + """, + """ +CREATE VIEW domains AS +SELECT id, name, master, last_check, type, notified_serial, account, options, catalog FROM dns_domains; + """, + """ +CREATE VIEW records AS +SELECT domain_id, name, type, content, ttl, prio, disabled, ordername, auth FROM dns_records; + """, + """ +CREATE VIEW domainmetadata AS +SELECT id, domain_id, kind, content FROM dns_domainmetadata; + """, + ] + + for expression in expressions: + session.execute(expression) + + def downgrade(self, session): + views = [ + "domains", + "records", + "domainmetadata", + ] + + tables = [ + "dns_records", + "dns_domainmetadata", + "dns_domains", + ] + + for view in views: + self._delete_view_if_exists(session, view) + + for table in tables: + self._delete_table_if_exists(session, table) + + session.execute("DROP SEQUENCE IF EXISTS dns_domain_id_seq;") + + +migration_step = MigrationStep() diff --git a/requirements.txt b/requirements.txt index 68898d0a..65cb4fc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pbr>=1.10.0,<=5.8.1 # Apache-2.0 oslo.config>=3.22.2,<10.0.0 # Apache-2.0 bjoern>=3.2.2 # BSD License (BSD-3-Clause) gcl_looper>=0.1.0,<=1.0.0 # Apache-2.0 -restalchemy>=13.0.1,<15.0.0 # Apache-2.0 +restalchemy>=14.1.0,<15.0.0 # Apache-2.0 libvirt-python>=11.0.0,<12.0.0 # GNU Lesser General Public License v2 or later (LGPLv2+) Authlib>=1.5.0,<2.0.0 # BSD License (BSD-3-Clause) bazooka>=1.3.0,<2.0.0 # Apache-2.0 @@ -13,4 +13,3 @@ gcl_sdk>=0.3.0,<1.0.0 # Apache-2.0 pyotp>=2.9.0,<3.0.0 # MIT License pyyaml>=6.0.0,<7.0.0 # MIT netaddr>=1.3.0,<2.0.0 # BSD License (BSD License) - diff --git a/test-requirements.txt b/test-requirements.txt index 03d6e384..eb46f04a 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,5 +1,6 @@ coverage>=4.0 flake8>=6.1.0 # MIT License (MIT) mock>=3.0.5,<4.0.0 # BSD -pytest==7.0.1,<8.0.0 # MIT License (MIT) -pytest-timer==0.0.11 # MIT License (MIT) \ No newline at end of file +pytest>=8.0.0,<9.0.0 # MIT License (MIT) +pytest-timer>=1.0.0,<2.0.0 # MIT License (MIT) +dnspython>=2.7.0,<3.0.0 # ISC License