diff options
| -rw-r--r-- | data/templates/https/nginx.default.tmpl | 2 | ||||
| -rw-r--r-- | python/vyos/defaults.py | 5 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/__init__.py | 0 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/directives.py | 17 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/mutations.py | 60 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/schema/dhcp_server.graphql | 35 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/schema/interface_ethernet.graphql | 18 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/schema/schema.graphql | 15 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/__init__.py | 0 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/dhcp_server.py | 13 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/interface_ethernet.py | 13 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/recipe.py | 49 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/templates/dhcp_server.tmpl | 9 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/templates/interface_ethernet.tmpl | 5 | ||||
| -rw-r--r-- | src/services/api/graphql/state.py | 4 | ||||
| -rwxr-xr-x | src/services/vyos-http-api-server | 27 | 
16 files changed, 270 insertions, 2 deletions
diff --git a/data/templates/https/nginx.default.tmpl b/data/templates/https/nginx.default.tmpl index 5459fe98d..b40ddcc74 100644 --- a/data/templates/https/nginx.default.tmpl +++ b/data/templates/https/nginx.default.tmpl @@ -41,7 +41,7 @@ server {  {% endif %}          # proxy settings for HTTP API, if enabled; 503, if not -        location ~ /(retrieve|configure|config-file|image|generate|show|docs|openapi.json|redoc) { +        location ~ /(retrieve|configure|config-file|image|generate|show|docs|openapi.json|redoc|graphql) {  {% if server.api %}                  proxy_pass http://localhost:{{ server.api.port }};                  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 9921e3b5f..03006c383 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -22,7 +22,10 @@ directories = {    "migrate": "/opt/vyatta/etc/config-migrate/migrate",    "log": "/var/log/vyatta",    "templates": "/usr/share/vyos/templates/", -  "certbot": "/config/auth/letsencrypt" +  "certbot": "/config/auth/letsencrypt", +  "api_schema": "/usr/libexec/vyos/services/api/graphql/graphql/schema/", +  "api_templates": "/usr/libexec/vyos/services/api/graphql/recipes/templates/" +  }  cfg_group = 'vyattacfg' diff --git a/src/services/api/graphql/graphql/__init__.py b/src/services/api/graphql/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/services/api/graphql/graphql/__init__.py diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py new file mode 100644 index 000000000..651421c35 --- /dev/null +++ b/src/services/api/graphql/graphql/directives.py @@ -0,0 +1,17 @@ +from ariadne import SchemaDirectiveVisitor, ObjectType +from . mutations import make_resolver + +class DataDirective(SchemaDirectiveVisitor): +    """ +    Class providing implementation of 'generate' directive in schema. + +    """ +    def visit_field_definition(self, field, object_type): +        name = f'{field.type}' +        # field.type contains the return value of the mutation; trim value +        # to produce canonical name +        name = name.replace('Result', '', 1) + +        func = make_resolver(name) +        field.resolve = func +        return field diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py new file mode 100644 index 000000000..af779d06f --- /dev/null +++ b/src/services/api/graphql/graphql/mutations.py @@ -0,0 +1,60 @@ + +from importlib import import_module +from typing import Any, Dict +from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake +from graphql import GraphQLResolveInfo +from makefun import with_signature + +from .. import state + +mutation = ObjectType("Mutation") + +def make_resolver(mutation_name): +    """Dynamically generate a resolver for the mutation named in the +    schema by 'mutation_name'. + +    Dynamic generation is provided using the package 'makefun' (via the +    decorator 'with_signature'), which provides signature-preserving +    function wrappers; it provides several improvements over, say, +    functools.wraps. + +    :raise Exception: +        encapsulating ConfigErrors, or internal errors +    """ +    class_name = mutation_name.replace('create', '', 1).replace('delete', '', 1) +    func_base_name = convert_camel_case_to_snake(class_name) +    resolver_name = f'resolve_create_{func_base_name}' +    func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict)' + +    @mutation.field(mutation_name) +    @convert_kwargs_to_snake_case +    @with_signature(func_sig, func_name=resolver_name) +    async def func_impl(*args, **kwargs): +        try: +            if 'data' not in kwargs: +                return { +                    "success": False, +                    "errors": ['missing data'] +                } + +            data = kwargs['data'] +            session = state.settings['app'].state.vyos_session + +            mod = import_module(f'api.recipes.{func_base_name}') +            klass = getattr(mod, class_name) +            k = klass(session, data) +            k.configure() + +            return { +                "success": True, +                "data": data +            } +        except Exception as error: +            return { +                "success": False, +                "errors": [str(error)] +            } + +    return func_impl + + diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql new file mode 100644 index 000000000..a7ee75d40 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql @@ -0,0 +1,35 @@ +input dhcpServerConfigInput { +    sharedNetworkName: String +    subnet: String +    defaultRouter: String +    dnsServer: String +    domainName: String +    lease: Int +    range: Int +    start: String +    stop: String +    dnsForwardingAllowFrom: String +    dnsForwardingCacheSize: Int +    dnsForwardingListenAddress: String +} + +type dhcpServerConfig { +    sharedNetworkName: String +    subnet: String +    defaultRouter: String +    dnsServer: String +    domainName: String +    lease: Int +    range: Int +    start: String +    stop: String +    dnsForwardingAllowFrom: String +    dnsForwardingCacheSize: Int +    dnsForwardingListenAddress: String +} + +type createDhcpServerResult { +    data: dhcpServerConfig +    success: Boolean! +    errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql new file mode 100644 index 000000000..fdcf97bad --- /dev/null +++ b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql @@ -0,0 +1,18 @@ +input interfaceEthernetConfigInput { +    interface: String +    address: String +    replace: Boolean = true +    description: String +} + +type interfaceEthernetConfig { +    interface: String +    address: String +    description: String +} + +type createInterfaceEthernetResult { +    data: interfaceEthernetConfig +    success: Boolean! +    errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql new file mode 100644 index 000000000..8a5e17962 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -0,0 +1,15 @@ +schema { +    query: Query +    mutation: Mutation +} + +type Query { +    _dummy: String +} + +directive @generate on FIELD_DEFINITION + +type Mutation { +    createDhcpServer(data: dhcpServerConfigInput) : createDhcpServerResult @generate +    createInterfaceEthernet(data: interfaceEthernetConfigInput) : createInterfaceEthernetResult @generate +} diff --git a/src/services/api/graphql/recipes/__init__.py b/src/services/api/graphql/recipes/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/services/api/graphql/recipes/__init__.py diff --git a/src/services/api/graphql/recipes/dhcp_server.py b/src/services/api/graphql/recipes/dhcp_server.py new file mode 100644 index 000000000..3edb3028e --- /dev/null +++ b/src/services/api/graphql/recipes/dhcp_server.py @@ -0,0 +1,13 @@ + +from . recipe import Recipe + +class DhcpServer(Recipe): +    def __init__(self, session, command_file): +        super().__init__(session, command_file) + +    # Define any custom processing of parameters here by overriding +    # configure: +    # +    # def configure(self): +    #     self.data = transform_data(self.data) +    #     super().configure() diff --git a/src/services/api/graphql/recipes/interface_ethernet.py b/src/services/api/graphql/recipes/interface_ethernet.py new file mode 100644 index 000000000..f88f5924f --- /dev/null +++ b/src/services/api/graphql/recipes/interface_ethernet.py @@ -0,0 +1,13 @@ + +from . recipe import Recipe + +class InterfaceEthernet(Recipe): +    def __init__(self, session, command_file): +        super().__init__(session, command_file) + +    # Define any custom processing of parameters here by overriding +    # configure: +    # +    # def configure(self): +    #     self.data = transform_data(self.data) +    #     super().configure() diff --git a/src/services/api/graphql/recipes/recipe.py b/src/services/api/graphql/recipes/recipe.py new file mode 100644 index 000000000..8fbb9e0bf --- /dev/null +++ b/src/services/api/graphql/recipes/recipe.py @@ -0,0 +1,49 @@ +from ariadne import convert_camel_case_to_snake +import vyos.defaults +from vyos.template import render + +class Recipe(object): +    def __init__(self, session, data): +        self._session = session +        self.data = data +        self._name = convert_camel_case_to_snake(type(self).__name__) + +    @property +    def data(self): +        return self.__data + +    @data.setter +    def data(self, data): +        if isinstance(data, dict): +            self.__data = data +        else: +            raise ValueError("data must be of type dict") + +    def configure(self): +        session = self._session +        data = self.data +        func_base_name = self._name + +        tmpl_file = f'{func_base_name}.tmpl' +        cmd_file = f'/tmp/{func_base_name}.cmds' +        tmpl_dir = vyos.defaults.directories['api_templates'] + +        try: +            render(cmd_file, tmpl_file, data, location=tmpl_dir) +            commands = [] +            with open(cmd_file) as f: +                lines = f.readlines() +            for line in lines: +                commands.append(line.split()) +            for cmd in commands: +                if cmd[0] == 'set': +                    session.set(cmd[1:]) +                elif cmd[0] == 'delete': +                    session.delete(cmd[1:]) +                else: +                    raise ValueError('Operation must be "set" or "delete"') +            session.commit() +        except Exception as error: +            raise error + + diff --git a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl new file mode 100644 index 000000000..629ce83c1 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl @@ -0,0 +1,9 @@ +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} default-router {{ default_router }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} dns-server {{ dns_server }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} domain-name {{ domain_name }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} lease {{ lease }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} start {{ start }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} stop {{ stop }} +set service dns forwarding allow-from {{ dns_forwarding_allow_from }} +set service dns forwarding cache-size {{ dns_forwarding_cache_size }} +set service dns forwarding listen-address {{ dns_forwarding_listen_address }} diff --git a/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl b/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl new file mode 100644 index 000000000..d9d7ed691 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl @@ -0,0 +1,5 @@ +{% if replace %} +delete interfaces ethernet {{ interface }} address +{% endif %} +set interfaces ethernet {{ interface }} address {{ address }} +set interfaces ethernet {{ interface }} description {{ description }} diff --git a/src/services/api/graphql/state.py b/src/services/api/graphql/state.py new file mode 100644 index 000000000..63db9f4ef --- /dev/null +++ b/src/services/api/graphql/state.py @@ -0,0 +1,4 @@ + +def init(): +    global settings +    settings = {} diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index cbf321dc8..cd7c92270 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -36,10 +36,16 @@ from starlette.datastructures import FormData, MutableHeaders  from starlette.formparsers import FormParser, MultiPartParser  from multipart.multipart import parse_options_header +from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers +from ariadne.asgi import GraphQL +  import vyos.config +import vyos.defaults  from vyos.configsession import ConfigSession, ConfigSessionError +import api.state +  DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf'  CFG_GROUP = 'vyattacfg' @@ -603,6 +609,25 @@ def show_op(data: ShowModel):      return success(res) +### +# GraphQL integration +### + +api.state.init() + +from api.graphql.mutations import mutation +from api.graphql.directives import DataDirective + +api_schema_dir = vyos.defaults.directories['api_schema'] + +type_defs = load_schema_from_path(api_schema_dir) + +schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives={"generate": DataDirective}) + +app.add_route('/graphql', GraphQL(schema, debug=True)) + +### +  if __name__ == '__main__':      # systemd's user and group options don't work, do it by hand here,      # else no one else will be able to commit @@ -626,6 +651,8 @@ if __name__ == '__main__':      app.state.vyos_debug = True if server_config['debug'] == 'true' else False      app.state.vyos_strict = True if server_config['strict'] == 'true' else False +    api.state.settings['app'] = app +      try:          uvicorn.run(app, host=server_config["listen_address"],                           port=int(server_config["port"]),  | 
