diff options
22 files changed, 525 insertions, 137 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/debian/control b/debian/control index f3b8a77ce..a3e1f8b01 100644 --- a/debian/control +++ b/debian/control @@ -51,7 +51,7 @@ Depends: etherwake, ethtool, fdisk, - fastnetmon, + fastnetmon [amd64], file, frr (>= 7.5), frr-pythontools, 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/python/vyos/template.py b/python/vyos/template.py index 6902d3720..08a5712af 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -29,13 +29,17 @@ _FILTERS = {} # reuse Environments with identical settings to improve performance @functools.lru_cache(maxsize=2) -def _get_environment(): +def _get_environment(location=None): + if location is None: + loc_loader=FileSystemLoader(directories["templates"]) + else: + loc_loader=FileSystemLoader(location) env = Environment( # Don't check if template files were modified upon re-rendering auto_reload=False, # Cache up to this number of templates for quick re-rendering cache_size=100, - loader=FileSystemLoader(directories["templates"]), + loader=loc_loader, trim_blocks=True, ) env.filters.update(_FILTERS) @@ -63,7 +67,7 @@ def register_filter(name, func=None): return func -def render_to_string(template, content, formater=None): +def render_to_string(template, content, formater=None, location=None): """Render a template from the template directory, raise on any errors. :param template: the path to the template relative to the template folder @@ -78,7 +82,7 @@ def render_to_string(template, content, formater=None): package is build (recovering the load time and overhead caused by having the file out of the code). """ - template = _get_environment().get_template(template) + template = _get_environment(location).get_template(template) rendered = template.render(content) if formater is not None: rendered = formater(rendered) @@ -93,6 +97,7 @@ def render( permission=None, user=None, group=None, + location=None, ): """Render a template from the template directory to a file, raise on any errors. @@ -109,7 +114,7 @@ def render( # As we are opening the file with 'w', we are performing the rendering before # calling open() to not accidentally erase the file if rendering fails - rendered = render_to_string(template, content, formater) + rendered = render_to_string(template, content, formater, location) # Write to file with open(destination, "w") as file: diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 82956b219..c1cfc1dcb 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -242,21 +242,20 @@ def apply(vrf): if tmp == 0: cmd('nft delete table inet vrf_zones') - # add configuration to FRR - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(frr_daemon) - frr_cfg.modify_section(f'^vrf [a-zA-Z-]*$', '') - frr_cfg.add_before(r'(interface .*|line vty)', vrf['new_frr_config']) - frr_cfg.commit_configuration(frr_daemon) - - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if vrf['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(frr_daemon) - - # Save configuration to /run/frr/config/frr.conf - frr.save_configuration() + # T3694: Somehow we hit a priority inversion here as we need to remove the + # VRF assigned VNI before we can remove a BGP bound VRF instance. Maybe + # move this to an individual helper script that set's up the VNI for the + # given VRF after any routing protocol. + # + # # add configuration to FRR + # frr_cfg = frr.FRRConfig() + # frr_cfg.load_configuration(frr_daemon) + # frr_cfg.modify_section(f'^vrf [a-zA-Z-]*$', '') + # frr_cfg.add_before(r'(interface .*|line vty)', vrf['new_frr_config']) + # frr_cfg.commit_configuration(frr_daemon) + # + # # Save configuration to /run/frr/config/frr.conf + # frr.save_configuration() return None diff --git a/src/migration-scripts/quagga/7-to-8 b/src/migration-scripts/quagga/7-to-8 index 38507bd3d..15c44924f 100755 --- a/src/migration-scripts/quagga/7-to-8 +++ b/src/migration-scripts/quagga/7-to-8 @@ -14,76 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# - T2450: drop interface-route and interface-route6 from "protocols static" +# - T3391: Migrate "maximum-paths" setting from "protocols bgp asn maximum-paths" +# under the IPv4 address-family tree. Reason is we currently have no way in +# configuring this for IPv6 address-family. This mimics the FRR configuration. from sys import argv from sys import exit - from vyos.configtree import ConfigTree -def migrate_interface_route(config, base, path, route_route6): - """ Generic migration function which can be called on every instance of - interface-route, beeing it ipv4, ipv6 or nested under the "static table" nodes. - - What we do? - - Drop 'interface-route' or 'interface-route6' and migrate the route unter the - 'route' or 'route6' tag node. - """ - if config.exists(base + path): - for route in config.list_nodes(base + path): - interface = config.list_nodes(base + path + [route, 'next-hop-interface']) - - tmp = base + path + [route, 'next-hop-interface'] - for interface in config.list_nodes(tmp): - new_base = base + [route_route6, route, 'interface'] - config.set(new_base) - config.set_tag(base + [route_route6]) - config.set_tag(new_base) - config.copy(tmp + [interface], new_base + [interface]) - - config.delete(base + path) - -def migrate_route(config, base, path, route_route6): - """ Generic migration function which can be called on every instance of - route, beeing it ipv4, ipv6 or even nested under the static table nodes. - - What we do? - - for consistency reasons rename next-hop-interface to interface - - for consistency reasons rename next-hop-vrf to vrf - """ - if config.exists(base + path): - for route in config.list_nodes(base + path): - next_hop = base + path + [route, 'next-hop'] - if config.exists(next_hop): - for gateway in config.list_nodes(next_hop): - # IPv4 routes calls it next-hop-interface, rename this to - # interface instead so it's consitent with IPv6 - interface_path = next_hop + [gateway, 'next-hop-interface'] - if config.exists(interface_path): - config.rename(interface_path, 'interface') - - # When VRFs got introduced, I (c-po) named it next-hop-vrf, - # we can also call it vrf which is simply shorter. - vrf_path = next_hop + [gateway, 'next-hop-vrf'] - if config.exists(vrf_path): - config.rename(vrf_path, 'vrf') - - next_hop = base + path + [route, 'interface'] - if config.exists(next_hop): - for interface in config.list_nodes(next_hop): - # IPv4 routes calls it next-hop-interface, rename this to - # interface instead so it's consitent with IPv6 - interface_path = next_hop + [interface, 'next-hop-interface'] - if config.exists(interface_path): - config.rename(interface_path, 'interface') - - # When VRFs got introduced, I (c-po) named it next-hop-vrf, - # we can also call it vrf which is simply shorter. - vrf_path = next_hop + [interface, 'next-hop-vrf'] - if config.exists(vrf_path): - config.rename(vrf_path, 'vrf') - - if (len(argv) < 2): print("Must specify file name!") exit(1) @@ -93,41 +31,27 @@ file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() -base = ['protocols', 'static'] - +base = ['protocols', 'bgp'] config = ConfigTree(config_file) + if not config.exists(base): # Nothing to do exit(0) -# Migrate interface-route into route -migrate_interface_route(config, base, ['interface-route'], 'route') - -# Migrate interface-route6 into route6 -migrate_interface_route(config, base, ['interface-route6'], 'route6') - -# Cleanup nodes inside route -migrate_route(config, base, ['route'], 'route') - -# Cleanup nodes inside route6 -migrate_route(config, base, ['route6'], 'route6') - -# -# PBR table cleanup -table_path = base + ['table'] -if config.exists(table_path): - for table in config.list_nodes(table_path): - # Migrate interface-route into route - migrate_interface_route(config, table_path + [table], ['interface-route'], 'route') - - # Migrate interface-route6 into route6 - migrate_interface_route(config, table_path + [table], ['interface-route6'], 'route6') - - # Cleanup nodes inside route - migrate_route(config, table_path + [table], ['route'], 'route') - - # Cleanup nodes inside route6 - migrate_route(config, table_path + [table], ['route6'], 'route6') +# Check if BGP is actually configured and obtain the ASN +asn_list = config.list_nodes(base) +if asn_list: + # There's always just one BGP node, if any + bgp_base = base + [asn_list[0]] + + maximum_paths = bgp_base + ['maximum-paths'] + if config.exists(maximum_paths): + for bgp_type in ['ebgp', 'ibgp']: + if config.exists(maximum_paths + [bgp_type]): + new_base = bgp_base + ['address-family', 'ipv4-unicast', 'maximum-paths'] + config.set(new_base) + config.copy(maximum_paths + [bgp_type], new_base + [bgp_type]) + config.delete(maximum_paths) try: with open(file_name, 'w') as f: diff --git a/src/migration-scripts/quagga/8-to-9 b/src/migration-scripts/quagga/8-to-9 index 15c44924f..38507bd3d 100755 --- a/src/migration-scripts/quagga/8-to-9 +++ b/src/migration-scripts/quagga/8-to-9 @@ -14,14 +14,76 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# - T3391: Migrate "maximum-paths" setting from "protocols bgp asn maximum-paths" -# under the IPv4 address-family tree. Reason is we currently have no way in -# configuring this for IPv6 address-family. This mimics the FRR configuration. +# - T2450: drop interface-route and interface-route6 from "protocols static" from sys import argv from sys import exit + from vyos.configtree import ConfigTree +def migrate_interface_route(config, base, path, route_route6): + """ Generic migration function which can be called on every instance of + interface-route, beeing it ipv4, ipv6 or nested under the "static table" nodes. + + What we do? + - Drop 'interface-route' or 'interface-route6' and migrate the route unter the + 'route' or 'route6' tag node. + """ + if config.exists(base + path): + for route in config.list_nodes(base + path): + interface = config.list_nodes(base + path + [route, 'next-hop-interface']) + + tmp = base + path + [route, 'next-hop-interface'] + for interface in config.list_nodes(tmp): + new_base = base + [route_route6, route, 'interface'] + config.set(new_base) + config.set_tag(base + [route_route6]) + config.set_tag(new_base) + config.copy(tmp + [interface], new_base + [interface]) + + config.delete(base + path) + +def migrate_route(config, base, path, route_route6): + """ Generic migration function which can be called on every instance of + route, beeing it ipv4, ipv6 or even nested under the static table nodes. + + What we do? + - for consistency reasons rename next-hop-interface to interface + - for consistency reasons rename next-hop-vrf to vrf + """ + if config.exists(base + path): + for route in config.list_nodes(base + path): + next_hop = base + path + [route, 'next-hop'] + if config.exists(next_hop): + for gateway in config.list_nodes(next_hop): + # IPv4 routes calls it next-hop-interface, rename this to + # interface instead so it's consitent with IPv6 + interface_path = next_hop + [gateway, 'next-hop-interface'] + if config.exists(interface_path): + config.rename(interface_path, 'interface') + + # When VRFs got introduced, I (c-po) named it next-hop-vrf, + # we can also call it vrf which is simply shorter. + vrf_path = next_hop + [gateway, 'next-hop-vrf'] + if config.exists(vrf_path): + config.rename(vrf_path, 'vrf') + + next_hop = base + path + [route, 'interface'] + if config.exists(next_hop): + for interface in config.list_nodes(next_hop): + # IPv4 routes calls it next-hop-interface, rename this to + # interface instead so it's consitent with IPv6 + interface_path = next_hop + [interface, 'next-hop-interface'] + if config.exists(interface_path): + config.rename(interface_path, 'interface') + + # When VRFs got introduced, I (c-po) named it next-hop-vrf, + # we can also call it vrf which is simply shorter. + vrf_path = next_hop + [interface, 'next-hop-vrf'] + if config.exists(vrf_path): + config.rename(vrf_path, 'vrf') + + if (len(argv) < 2): print("Must specify file name!") exit(1) @@ -31,27 +93,41 @@ file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() -base = ['protocols', 'bgp'] -config = ConfigTree(config_file) +base = ['protocols', 'static'] +config = ConfigTree(config_file) if not config.exists(base): # Nothing to do exit(0) -# Check if BGP is actually configured and obtain the ASN -asn_list = config.list_nodes(base) -if asn_list: - # There's always just one BGP node, if any - bgp_base = base + [asn_list[0]] - - maximum_paths = bgp_base + ['maximum-paths'] - if config.exists(maximum_paths): - for bgp_type in ['ebgp', 'ibgp']: - if config.exists(maximum_paths + [bgp_type]): - new_base = bgp_base + ['address-family', 'ipv4-unicast', 'maximum-paths'] - config.set(new_base) - config.copy(maximum_paths + [bgp_type], new_base + [bgp_type]) - config.delete(maximum_paths) +# Migrate interface-route into route +migrate_interface_route(config, base, ['interface-route'], 'route') + +# Migrate interface-route6 into route6 +migrate_interface_route(config, base, ['interface-route6'], 'route6') + +# Cleanup nodes inside route +migrate_route(config, base, ['route'], 'route') + +# Cleanup nodes inside route6 +migrate_route(config, base, ['route6'], 'route6') + +# +# PBR table cleanup +table_path = base + ['table'] +if config.exists(table_path): + for table in config.list_nodes(table_path): + # Migrate interface-route into route + migrate_interface_route(config, table_path + [table], ['interface-route'], 'route') + + # Migrate interface-route6 into route6 + migrate_interface_route(config, table_path + [table], ['interface-route6'], 'route6') + + # Cleanup nodes inside route + migrate_route(config, table_path + [table], ['route'], 'route') + + # Cleanup nodes inside route6 + migrate_route(config, table_path + [table], ['route6'], 'route6') try: with open(file_name, 'w') as f: diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql new file mode 100644 index 000000000..a04138010 --- /dev/null +++ b/src/services/api/graphql/README.graphql @@ -0,0 +1,116 @@ + +Example using GraphQL mutations to configure a DHCP server: + +This assumes that the http-api is running: + +'set service https api' + +One can configure an address on an interface, and configure the DHCP server +to run with that address as default router by requesting these 'mutations' +in the GraphQL playground: + +mutation { + createInterfaceEthernet (data: {interface: "eth1", + address: "192.168.0.1/24", + description: "BOB"}) { + success + errors + data { + address + } + } +} + +mutation { + createDhcpServer(data: {sharedNetworkName: "BOB", + subnet: "192.168.0.0/24", + defaultRouter: "192.168.0.1", + dnsServer: "192.168.0.1", + domainName: "vyos.net", + lease: 86400, + range: 0, + start: "192.168.0.9", + stop: "192.168.0.254", + dnsForwardingAllowFrom: "192.168.0.0/24", + dnsForwardingCacheSize: 0, + dnsForwardingListenAddress: "192.168.0.1"}) { + success + errors + data { + defaultRouter + } + } +} + +The GraphQL playground will be found at: + +https://{{ host_address }}/graphql + +An equivalent curl command to the first example above would be: + +curl -k 'https://192.168.100.168/graphql' -H 'Content-Type: application/json' --data-binary '{"query": "mutation {createInterfaceEthernet (data: {interface: \"eth1\", address: \"192.168.0.1/24\", description: \"BOB\"}) {success errors data {address}}}"}' + +Note that the 'mutation' term is prefaced by 'query' in the curl command. + +What's here: + +services +├── api +│ └── graphql +│ ├── graphql +│ │ ├── directives.py +│ │ ├── __init__.py +│ │ ├── mutations.py +│ │ └── schema +│ │ ├── dhcp_server.graphql +│ │ ├── interface_ethernet.graphql +│ │ └── schema.graphql +│ ├── recipes +│ │ ├── dhcp_server.py +│ │ ├── __init__.py +│ │ ├── interface_ethernet.py +│ │ ├── recipe.py +│ │ └── templates +│ │ ├── dhcp_server.tmpl +│ │ └── interface_ethernet.tmpl +│ └── state.py +├── vyos-configd +├── vyos-hostsd +└── vyos-http-api-server + +The GraphQL library that we are using, Ariadne, advertises itself as a +'schema-first' implementation: define the schema; define resolvers +(handlers) for declared Query and Mutation types (Subscription types are not +currently used). + +In the current approach to a high-level API, we consider the +Jinja2-templated collection of configuration mode 'set'/'delete' commands as +the Ur-data; the GraphQL schema is produced from those files, located in +'api/graphql/recipes/templates'. + +Resolvers for the schema Mutation fields are dynamically generated using a +'directive' added to the respective schema field. The directive, +'@generate', is handled by the class 'DataDirective' in +'api/graphql/graphql/directives.py', which calls the 'make_resolver' function in +'api/graphql/graphql/mutations.py'; the produced resolver calls the appropriate +wrapper in 'api/graphql/recipes', with base class doing the (overridable) +configuration steps of calling all defined 'set'/'delete' commands. + +Integrating the above with vyos-http-api-server is ~10 lines of code. + +What needs to be done: + +• automate generation of schema and wrappers from templated configuration +commands + +• investigate whether the subclassing provided by the named wrappers in +'api/graphql/recipes' is sufficient for use cases which need to modify data + +• encapsulate the manipulation of 'canonical names' which transforms the +prefixed camel-case schema names to various snake-case file/function names + +• consider mechanism for migration of templates: offline vs. on-the-fly + +• define the naming convention for those schema fields that refer to +configuration mode parameters: e.g. how much of the path is needed as prefix +to uniquely define the term 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..98c665c9a --- /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.graphql.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..cb4ce4072 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.graphql.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.graphql.state.init() + +from api.graphql.graphql.mutations import mutation +from api.graphql.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.graphql.state.settings['app'] = app + try: uvicorn.run(app, host=server_config["listen_address"], port=int(server_config["port"]), |