From 4a8bf1ee1e1e5b6fee1850b5cb82085c0edd75ab Mon Sep 17 00:00:00 2001 From: Brandon Stepler Date: Thu, 29 Jul 2021 14:30:00 -0400 Subject: configd: T3694: always set script.argv Several scripts imported by vyos-configd (including src/conf_mode/protocols_static.py) rely on argv for operating on VRFs. Always setting script.argv in src/services/vyos-configd ensures those scripts will operate on the default VRF when called with no arguments. Otherwise, a stale argv might cause those scripts to operate on the last modified VRF instead of the default VRF. (cherry picked from commit 3341c591ad1190f39ff3ffd475eddf5d95aef763) --- src/services/vyos-configd | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'src/services') diff --git a/src/services/vyos-configd b/src/services/vyos-configd index 6f770b696..670b6e66a 100755 --- a/src/services/vyos-configd +++ b/src/services/vyos-configd @@ -133,8 +133,7 @@ def explicit_print(path, mode, msg): logger.critical("error explicit_print") def run_script(script, config, args) -> int: - if args: - script.argv = args + script.argv = args config.set_level([]) try: c = script.get_config(config) @@ -208,7 +207,7 @@ def process_node_data(config, data) -> int: return R_ERROR_DAEMON script_name = None - args = None + args = [] res = re.match(r'^(VYOS_TAGNODE_VALUE=[^/]+)?.*\/([^/]+).py(.*)', data) if res.group(1): @@ -221,7 +220,7 @@ def process_node_data(config, data) -> int: return R_ERROR_DAEMON if res.group(3): args = res.group(3).split() - args.insert(0, f'{script_name}.py') + args.insert(0, f'{script_name}.py') if script_name not in include_set: return R_PASS -- cgit v1.2.3 From 37c3ebc8aba14ba7605fbbb9c4013cbd2513400d Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 26 Mar 2021 11:25:44 -0500 Subject: http api: T3412: use FastAPI as web framework; support application/json Replace the Flask micro-framework with FastAPI, in order to support extensions to the API and OpenAPI 3.* generation. This change will remain backwards compatible with previous versions. Notably, the multipart forms version of requests remain supported; in addition application/json requests are now natively supported. (cherry picked from commit 0125fff200efe3259aa25953e7505f69679261f8) --- data/templates/https/nginx.default.tmpl | 4 +- debian/control | 1 + src/services/vyos-http-api-server | 571 ++++++++++++++++++++++---------- src/systemd/vyos-http-api.service | 3 +- 4 files changed, 392 insertions(+), 187 deletions(-) (limited to 'src/services') diff --git a/data/templates/https/nginx.default.tmpl b/data/templates/https/nginx.default.tmpl index 26d0b5d73..625ef4486 100644 --- a/data/templates/https/nginx.default.tmpl +++ b/data/templates/https/nginx.default.tmpl @@ -41,9 +41,11 @@ server { ssl_protocols TLSv1.2 TLSv1.3; # proxy settings for HTTP API, if enabled; 503, if not - location ~ /(retrieve|configure|config-file|image|generate|show) { + location ~ /(retrieve|configure|config-file|image|generate|show|docs|openapi.json|redoc) { {% if server.api %} proxy_pass http://localhost:{{ server.api.port }}; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600; proxy_buffering off; {% else %} diff --git a/debian/control b/debian/control index 87a0258d2..8cafd8257 100644 --- a/debian/control +++ b/debian/control @@ -141,6 +141,7 @@ Depends: usbutils, vyatta-bash, vyatta-cfg, + vyos-http-api-tools, vyos-utils, wide-dhcpv6-client, wireguard-tools, diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 703628558..8069d7146 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -1,6 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/share/vyos-http-api-tools/bin/python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright (C) 2019-2021 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,25 +19,37 @@ import os import sys import grp +import copy import json +import logging import traceback import threading -import signal +from typing import List, Union, Callable, Dict -import vyos.config - -from flask import Flask, request -from waitress import serve +import uvicorn +from fastapi import FastAPI, Depends, Request, Response, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute +from pydantic import BaseModel, StrictStr, validator -from functools import wraps +import vyos.config from vyos.configsession import ConfigSession, ConfigSessionError - DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf' CFG_GROUP = 'vyattacfg' -app = Flask(__name__) +debug = True + +logger = logging.getLogger(__name__) +logs_handler = logging.StreamHandler() +logger.addHandler(logs_handler) + +if debug: + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) # Giant lock! lock = threading.Lock() @@ -56,55 +68,310 @@ def check_auth(key_list, key): def error(code, msg): resp = {"success": False, "error": msg, "data": None} - return json.dumps(resp), code + resp = json.dumps(resp) + return HTMLResponse(resp, status_code=code) def success(data): resp = {"success": True, "data": data, "error": None} - return json.dumps(resp) - -def get_command(f): - @wraps(f) - def decorated_function(*args, **kwargs): - cmd = request.form.get("data") - if not cmd: - return error(400, "Non-empty data field is required") - try: - cmd = json.loads(cmd) - except Exception as e: - return error(400, "Failed to parse JSON: {0}".format(e)) - return f(cmd, *args, **kwargs) - - return decorated_function - -def auth_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - key = request.form.get("key") - api_keys = app.config['vyos_keys'] - id = check_auth(api_keys, key) - if not id: - return error(401, "Valid API key is required") - return f(*args, **kwargs) - - return decorated_function - -@app.route('/configure', methods=['POST']) -@get_command -@auth_required -def configure_op(commands): - session = app.config['vyos_session'] + resp = json.dumps(resp) + return HTMLResponse(resp) + +# Pydantic models for validation +# Pydantic will cast when possible, so use StrictStr +# validators added as needed for additional constraints +# schema_extra adds anotations to OpenAPI, to add examples + +class ApiModel(BaseModel): + key: StrictStr + +class BaseConfigureModel(BaseModel): + op: StrictStr + path: List[StrictStr] + value: StrictStr = None + + @validator("path", pre=True, always=True) + def check_non_empty(cls, path): + assert len(path) > 0 + return path + +class ConfigureModel(ApiModel): + op: StrictStr + path: List[StrictStr] + value: StrictStr = None + + @validator("path", pre=True, always=True) + def check_non_empty(cls, path): + assert len(path) > 0 + return path + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "set | delete | comment", + "path": ['config', 'mode', 'path'], + } + } + +class ConfigureListModel(ApiModel): + commands: List[BaseConfigureModel] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "commands": "list of commands", + } + } + +class RetrieveModel(ApiModel): + op: StrictStr + path: List[StrictStr] + configFormat: StrictStr = None + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "returnValue | returnValues | exists | showConfig", + "path": ['config', 'mode', 'path'], + "configFormat": "json (default) | json_ast | raw", + + } + } + +class ConfigFileModel(ApiModel): + op: StrictStr + file: StrictStr = None + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "save | load", + "file": "filename", + } + } + +class ImageModel(ApiModel): + op: StrictStr + url: StrictStr = None + name: StrictStr = None + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "add | delete", + "url": "imagelocation", + "name": "imagename", + } + } + +class GenerateModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "generate", + "path": ["op", "mode", "path"], + } + } + +class ShowModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "show", + "path": ["op", "mode", "path"], + } + } + +class Success(BaseModel): + success: bool + data: Union[str, bool, Dict] + error: str + +class Error(BaseModel): + success: bool = False + data: Union[str, bool, Dict] + error: str + +responses = { + 200: {'model': Success}, + 400: {'model': Error}, + 422: {'model': Error, 'description': 'Validation Error'}, + 500: {'model': Error} +} + +def auth_required(data: ApiModel): + key = data.key + api_keys = app.state.vyos_keys + id = check_auth(api_keys, key) + if not id: + raise HTTPException(status_code=401, detail="Valid API key is required") + app.state.vyos_id = id + +# override Request and APIRoute classes in order to convert form request to json; +# do all explicit validation here, for backwards compatability of error messages; +# the explicit validation may be dropped, if desired, in favor of native +# validation by FastAPI/Pydantic, as is used for application/json requests +class MultipartRequest(Request): + ERR_MISSING_KEY = False + ERR_MISSING_DATA = False + ERR_NOT_JSON = False + ERR_NOT_DICT = False + ERR_NO_OP = False + ERR_NO_PATH = False + ERR_EMPTY_PATH = False + ERR_PATH_NOT_LIST = False + ERR_VALUE_NOT_STRING = False + ERR_PATH_NOT_LIST_OF_STR = False + offending_command = {} + exception = None + async def body(self) -> bytes: + if not hasattr(self, "_body"): + forms = {} + merge = {} + body = await super().body() + self._body = body + + form_data = await self.form() + if form_data: + logger.debug("processing form data") + for k, v in form_data.multi_items(): + forms[k] = v + + if 'data' not in forms: + self.ERR_MISSING_DATA = True + else: + try: + tmp = json.loads(forms['data']) + except json.JSONDecodeError as e: + self.ERR_NOT_JSON = True + self.exception = e + tmp = {} + if isinstance(tmp, list): + merge['commands'] = tmp + else: + merge = tmp + + if 'commands' in merge: + cmds = merge['commands'] + else: + cmds = copy.deepcopy(merge) + cmds = [cmds] + + for c in cmds: + if not isinstance(c, dict): + self.ERR_NOT_DICT = True + self.offending_command = c + elif 'op' not in c: + self.ERR_NO_OP = True + self.offending_command = c + elif 'path' not in c: + self.ERR_NO_PATH = True + self.offending_command = c + elif not c['path']: + self.ERR_EMPTY_PATH = True + self.offending_command = c + elif not isinstance(c['path'], list): + self.ERR_PATH_NOT_LIST = True + self.offending_command = c + elif not all(isinstance(el, str) for el in c['path']): + self.ERR_PATH_NOT_LIST_OF_STR = True + self.offending_command = c + elif 'value' in c and not isinstance(c['value'], str): + self.ERR_VALUE_NOT_STRING = True + self.offending_command = c + + if 'key' not in forms and 'key' not in merge: + self.ERR_MISSING_KEY = True + if 'key' in forms and 'key' not in merge: + merge['key'] = forms['key'] + + new_body = json.dumps(merge) + new_body = new_body.encode() + self._body = new_body + + return self._body + +class MultipartRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = MultipartRequest(request.scope, request.receive) + endpoint = request.url.path + try: + response: Response = await original_route_handler(request) + except HTTPException as e: + return error(e.status_code, e.detail) + except Exception as e: + if request.ERR_MISSING_KEY: + return error(422, "Valid API key is required") + if request.ERR_MISSING_DATA: + return error(422, "Non-empty data field is required") + if request.ERR_NOT_JSON: + return error(400, "Failed to parse JSON: {0}".format(request.exception)) + if endpoint == '/configure': + if request.ERR_NOT_DICT: + return error(400, "Malformed command \"{0}\": any command must be a dict".format(json.dumps(request.offending_command))) + if request.ERR_NO_OP: + return error(400, "Malformed command \"{0}\": missing \"op\" field".format(json.dumps(request.offending_command))) + if request.ERR_NO_PATH: + return error(400, "Malformed command \"{0}\": missing \"path\" field".format(json.dumps(request.offending_command))) + if request.ERR_EMPTY_PATH: + return error(400, "Malformed command \"{0}\": empty path".format(json.dumps(request.offending_command))) + if request.ERR_PATH_NOT_LIST: + return error(400, "Malformed command \"{0}\": \"path\" field must be a list".format(json.dumps(request.offending_command))) + if request.ERR_VALUE_NOT_STRING: + return error(400, "Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(request.offending_command))) + if request.ERR_PATH_NOT_LIST_OF_STR: + return error(400, "Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(request.offending_command))) + if endpoint in ('/retrieve','/generate','/show'): + if request.ERR_NO_OP or request.ERR_NO_PATH: + return error(400, "Missing required field. \"op\" and \"path\" fields are required") + if endpoint in ('/config-file', '/image'): + if request.ERR_NO_OP: + return error(400, "Missing required field \"op\"") + + raise e + + return response + + return custom_route_handler + +app = FastAPI(debug=True, + title="VyOS API", + version="0.1.0", + responses={**responses}, + dependencies=[Depends(auth_required)]) + +app.router.route_class = MultipartRoute + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + return error(400, str(exc.errors()[0])) + +@app.post('/configure') +def configure_op(data: Union[ConfigureModel, ConfigureListModel]): + session = app.state.vyos_session env = session.get_session_env() config = vyos.config.Config(session_env=env) - strict_field = request.form.get("strict") - if strict_field == "true": - strict = True - else: - strict = False - # Allow users to pass just one command - if not isinstance(commands, list): - commands = [commands] + if not isinstance(data, ConfigureListModel): + data = [data] + else: + data = data.commands # We don't want multiple people/apps to be able to commit at once, # or modify the shared session while someone else is doing the same, @@ -114,53 +381,25 @@ def configure_op(commands): status = 200 error_msg = None try: - for c in commands: - # What we've got may not even be a dict - if not isinstance(c, dict): - raise ConfigSessionError("Malformed command \"{0}\": any command must be a dict".format(json.dumps(c))) - - # Missing op or path is a show stopper - if not ('op' in c): - raise ConfigSessionError("Malformed command \"{0}\": missing \"op\" field".format(json.dumps(c))) - if not ('path' in c): - raise ConfigSessionError("Malformed command \"{0}\": missing \"path\" field".format(json.dumps(c))) - - # Missing value is fine, substitute for empty string - if 'value' in c: - value = c['value'] - else: - value = "" - - op = c['op'] - path = c['path'] - - if not path: - raise ConfigSessionError("Malformed command \"{0}\": empty path".format(json.dumps(c))) - - # Type checking - if not isinstance(path, list): - raise ConfigSessionError("Malformed command \"{0}\": \"path\" field must be a list".format(json.dumps(c))) + for c in data: + op = c.op + path = c.path - if not isinstance(value, str): - raise ConfigSessionError("Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(c))) - - # Account for the case when value field is present and set to null - if not value: + if c.value: + value = c.value + else: value = "" - # For vyos.configsessios calls that have no separate value arguments, + # For vyos.configsession calls that have no separate value arguments, # and for type checking too - try: - cfg_path = " ".join(path + [value]).strip() - except TypeError: - raise ConfigSessionError("Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(c))) + cfg_path = " ".join(path + [value]).strip() if op == 'set': # XXX: it would be nice to do a strict check for "path already exists", # but there's probably no way to do that session.set(path, value=value) elif op == 'delete': - if strict and not config.exists(cfg_path): + if app.state.vyos_strict and not config.exists(cfg_path): raise ConfigSessionError("Cannot delete [{0}]: path/value does not exist".format(cfg_path)) session.delete(path, value=value) elif op == 'comment': @@ -169,16 +408,16 @@ def configure_op(commands): raise ConfigSessionError("\"{0}\" is not a valid operation".format(op)) # end for session.commit() - print("Configuration modified via HTTP API using key \"{0}\"".format(id)) + logger.info(f"Configuration modified via HTTP API using key '{app.state.vyos_id}'") except ConfigSessionError as e: session.discard() status = 400 - if app.config['vyos_debug']: - print(traceback.format_exc(), file=sys.stderr) + if app.state.vyos_debug: + logger.critical(f"ConfigSessionError:\n {traceback.format_exc()}") error_msg = str(e) except Exception as e: session.discard() - print(traceback.format_exc(), file=sys.stderr) + logger.critical(traceback.format_exc()) status = 500 # Don't give the details away to the outer world @@ -188,22 +427,17 @@ def configure_op(commands): if status != 200: return error(status, error_msg) - else: - return success(None) -@app.route('/retrieve', methods=['POST']) -@get_command -@auth_required -def retrieve_op(command): - session = app.config['vyos_session'] + return success(None) + +@app.post("/retrieve") +def retrieve_op(data: RetrieveModel): + session = app.state.vyos_session env = session.get_session_env() config = vyos.config.Config(session_env=env) - try: - op = command['op'] - path = " ".join(command['path']) - except KeyError: - return error(400, "Missing required field. \"op\" and \"path\" fields are required") + op = data.op + path = " ".join(data.path) try: if op == 'returnValue': @@ -214,10 +448,10 @@ def retrieve_op(command): res = config.exists(path) elif op == 'showConfig': config_format = 'json' - if 'configFormat' in command: - config_format = command['configFormat'] + if data.configFormat: + config_format = data.configFormat - res = session.show_config(path=command['path']) + res = session.show_config(path=data.path) if config_format == 'json': config_tree = vyos.configtree.ConfigTree(res) res = json.loads(config_tree.to_json()) @@ -233,33 +467,28 @@ def retrieve_op(command): except ConfigSessionError as e: return error(400, str(e)) except Exception as e: - print(traceback.format_exc(), file=sys.stderr) + logger.critical(traceback.format_exc()) return error(500, "An internal error occured. Check the logs for details.") return success(res) -@app.route('/config-file', methods=['POST']) -@get_command -@auth_required -def config_file_op(command): - session = app.config['vyos_session'] +@app.post('/config-file') +def config_file_op(data: ConfigFileModel): + session = app.state.vyos_session - try: - op = command['op'] - except KeyError: - return error(400, "Missing required field \"op\"") + op = data.op try: if op == 'save': - try: - path = command['file'] - except KeyError: + if data.file: + path = data.file + else: path = '/config/config.boot' res = session.save_config(path) elif op == 'load': - try: - path = command['file'] - except KeyError: + if data.file: + path = data.file + else: return error(400, "Missing required field \"file\"") res = session.migrate_and_load_config(path) res = session.commit() @@ -268,33 +497,28 @@ def config_file_op(command): except ConfigSessionError as e: return error(400, str(e)) except Exception as e: - print(traceback.format_exc(), file=sys.stderr) + logger.critical(traceback.format_exc()) return error(500, "An internal error occured. Check the logs for details.") return success(res) -@app.route('/image', methods=['POST']) -@get_command -@auth_required -def image_op(command): - session = app.config['vyos_session'] +@app.post('/image') +def image_op(data: ImageModel): + session = app.state.vyos_session - try: - op = command['op'] - except KeyError: - return error(400, "Missing required field \"op\"") + op = data.op try: if op == 'add': - try: - url = command['url'] - except KeyError: + if data.url: + url = data.url + else: return error(400, "Missing required field \"url\"") res = session.install_image(url) elif op == 'delete': - try: - name = command['name'] - except KeyError: + if data.name: + name = data.name + else: return error(400, "Missing required field \"name\"") res = session.remove_image(name) else: @@ -302,26 +526,17 @@ def image_op(command): except ConfigSessionError as e: return error(400, str(e)) except Exception as e: - print(traceback.format_exc(), file=sys.stderr) + logger.critical(traceback.format_exc()) return error(500, "An internal error occured. Check the logs for details.") return success(res) +@app.post('/generate') +def generate_op(data: GenerateModel): + session = app.state.vyos_session -@app.route('/generate', methods=['POST']) -@get_command -@auth_required -def generate_op(command): - session = app.config['vyos_session'] - - try: - op = command['op'] - path = command['path'] - except KeyError: - return error(400, "Missing required field. \"op\" and \"path\" fields are required") - - if not isinstance(path, list): - return error(400, "Malformed command: \"path\" field must be a list of strings") + op = data.op + path = data.path try: if op == 'generate': @@ -331,25 +546,17 @@ def generate_op(command): except ConfigSessionError as e: return error(400, str(e)) except Exception as e: - print(traceback.format_exc(), file=sys.stderr) + logger.critical(traceback.format_exc()) return error(500, "An internal error occured. Check the logs for details.") return success(res) -@app.route('/show', methods=['POST']) -@get_command -@auth_required -def show_op(command): - session = app.config['vyos_session'] +@app.post('/show') +def show_op(data: ShowModel): + session = app.state.vyos_session - try: - op = command['op'] - path = command['path'] - except KeyError: - return error(400, "Missing required field. \"op\" and \"path\" fields are required") - - if not isinstance(path, list): - return error(400, "Malformed command: \"path\" field must be a list of strings") + op = data.op + path = data.path try: if op == 'show': @@ -359,14 +566,11 @@ def show_op(command): except ConfigSessionError as e: return error(400, str(e)) except Exception as e: - print(traceback.format_exc(), file=sys.stderr) + logger.critical(traceback.format_exc()) return error(500, "An internal error occured. Check the logs for details.") return success(res) -def shutdown(): - raise KeyboardInterrupt - 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 @@ -380,21 +584,20 @@ if __name__ == '__main__': try: server_config = load_server_config() except Exception as e: - print("Failed to load the HTTP API server config: {0}".format(e)) + logger.critical("Failed to load the HTTP API server config: {0}".format(e)) session = ConfigSession(os.getpid()) - app.config['vyos_session'] = session - app.config['vyos_keys'] = server_config['api_keys'] - app.config['vyos_debug'] = server_config['debug'] - - def sig_handler(signum, frame): - shutdown() + app.state.vyos_session = session + app.state.vyos_keys = server_config['api_keys'] - signal.signal(signal.SIGTERM, sig_handler) + app.state.vyos_debug = True if server_config['debug'] == 'true' else False + app.state.vyos_strict = True if server_config['strict'] == 'true' else False try: - serve(app, host=server_config["listen_address"], - port=server_config["port"]) + uvicorn.run(app, host=server_config["listen_address"], + port=int(server_config["port"]), + proxy_headers=True) except OSError as e: - print(f"OSError {e}") + logger.critical(f"OSError {e}") + sys.exit(1) diff --git a/src/systemd/vyos-http-api.service b/src/systemd/vyos-http-api.service index 4fa68b4ff..ba5df5984 100644 --- a/src/systemd/vyos-http-api.service +++ b/src/systemd/vyos-http-api.service @@ -5,9 +5,8 @@ Requires=vyos-router.service [Service] ExecStartPre=/usr/libexec/vyos/init/vyos-config -ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-http-api-server +ExecStart=/usr/libexec/vyos/services/vyos-http-api-server Type=idle -KillMode=process SyslogIdentifier=vyos-http-api SyslogFacility=daemon -- cgit v1.2.3 From 8b8c2b36f85072def2bbb59040305e19b187a399 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sat, 12 Jun 2021 11:43:29 -0500 Subject: http-api: T3616: update for strict content-type check in FastAPI 0.65.2 FastAPI 0.65.2 checks content-type request header before assuming JSON, closing a well-known loophole. This requires a modification of the code providing backwards compatibility of multipart forms. (cherry picked from commit 3a9041e2d4d4a48ba7c01439e69c5f86a4a850c2) --- src/services/vyos-http-api-server | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) (limited to 'src/services') diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 8069d7146..cbf321dc8 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -32,6 +32,9 @@ from fastapi.responses import HTMLResponse from fastapi.exceptions import RequestValidationError from fastapi.routing import APIRoute from pydantic import BaseModel, StrictStr, validator +from starlette.datastructures import FormData, MutableHeaders +from starlette.formparsers import FormParser, MultiPartParser +from multipart.multipart import parse_options_header import vyos.config @@ -236,6 +239,35 @@ class MultipartRequest(Request): ERR_PATH_NOT_LIST_OF_STR = False offending_command = {} exception = None + + @property + def orig_headers(self): + self._orig_headers = super().headers + return self._orig_headers + + @property + def headers(self): + self._headers = super().headers.mutablecopy() + self._headers['content-type'] = 'application/json' + return self._headers + + async def form(self) -> FormData: + if not hasattr(self, "_form"): + assert ( + parse_options_header is not None + ), "The `python-multipart` library must be installed to use form parsing." + content_type_header = self.orig_headers.get("Content-Type") + content_type, options = parse_options_header(content_type_header) + if content_type == b"multipart/form-data": + multipart_parser = MultiPartParser(self.orig_headers, self.stream()) + self._form = await multipart_parser.parse() + elif content_type == b"application/x-www-form-urlencoded": + form_parser = FormParser(self.orig_headers, self.stream()) + self._form = await form_parser.parse() + else: + self._form = FormData() + return self._form + async def body(self) -> bytes: if not hasattr(self, "_body"): forms = {} -- cgit v1.2.3 From f87f6c249535453b8bd3718dc7cdc84dcbbdbe13 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Thu, 5 Aug 2021 11:24:47 -0500 Subject: http-api: T2768: example using GraphQL for high-level config operations (cherry picked from commit b168b4cc7da456f14714d917cdc7a1c6b8df9af5) --- data/templates/https/nginx.default.tmpl | 2 +- python/vyos/defaults.py | 5 +- src/services/api/graphql/graphql/__init__.py | 0 src/services/api/graphql/graphql/directives.py | 17 ++++++ src/services/api/graphql/graphql/mutations.py | 60 ++++++++++++++++++++++ .../api/graphql/graphql/schema/dhcp_server.graphql | 35 +++++++++++++ .../graphql/schema/interface_ethernet.graphql | 18 +++++++ .../api/graphql/graphql/schema/schema.graphql | 15 ++++++ src/services/api/graphql/recipes/__init__.py | 0 src/services/api/graphql/recipes/dhcp_server.py | 13 +++++ .../api/graphql/recipes/interface_ethernet.py | 13 +++++ src/services/api/graphql/recipes/recipe.py | 49 ++++++++++++++++++ .../api/graphql/recipes/templates/dhcp_server.tmpl | 9 ++++ .../recipes/templates/interface_ethernet.tmpl | 5 ++ src/services/api/graphql/state.py | 4 ++ src/services/vyos-http-api-server | 27 ++++++++++ 16 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/services/api/graphql/graphql/__init__.py create mode 100644 src/services/api/graphql/graphql/directives.py create mode 100644 src/services/api/graphql/graphql/mutations.py create mode 100644 src/services/api/graphql/graphql/schema/dhcp_server.graphql create mode 100644 src/services/api/graphql/graphql/schema/interface_ethernet.graphql create mode 100644 src/services/api/graphql/graphql/schema/schema.graphql create mode 100644 src/services/api/graphql/recipes/__init__.py create mode 100644 src/services/api/graphql/recipes/dhcp_server.py create mode 100644 src/services/api/graphql/recipes/interface_ethernet.py create mode 100644 src/services/api/graphql/recipes/recipe.py create mode 100644 src/services/api/graphql/recipes/templates/dhcp_server.tmpl create mode 100644 src/services/api/graphql/recipes/templates/interface_ethernet.tmpl create mode 100644 src/services/api/graphql/state.py (limited to 'src/services') diff --git a/data/templates/https/nginx.default.tmpl b/data/templates/https/nginx.default.tmpl index 625ef4486..d25e5193a 100644 --- a/data/templates/https/nginx.default.tmpl +++ b/data/templates/https/nginx.default.tmpl @@ -41,7 +41,7 @@ server { ssl_protocols TLSv1.2 TLSv1.3; # 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 ca5e02834..dacdbdef2 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -23,7 +23,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 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 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"]), -- cgit v1.2.3 From 9756b870d503eab00714b21c66bf30de47878f6e Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Thu, 5 Aug 2021 11:25:31 -0500 Subject: http-api: T2768: add README.graphql (cherry picked from commit 5b69aad5bfe1fd1dfc51afb1d4b6323028009deb) --- src/services/api/graphql/README.graphql | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/services/api/graphql/README.graphql (limited to 'src/services') 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 -- cgit v1.2.3 From 66ff05703ed260c744290fb604dbf31a0dfcd1da Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 7 Nov 2021 14:47:40 -0600 Subject: http-api: T2768: update dhcp-server example for migration 5-to-6 (cherry picked from commit dc9a2821d063a96681d6cb1d962618829b71937d) --- src/services/api/graphql/README.graphql | 2 +- src/services/api/graphql/graphql/schema/dhcp_server.graphql | 4 ++-- src/services/api/graphql/recipes/templates/dhcp_server.tmpl | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) (limited to 'src/services') diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql index a04138010..580c0eb7f 100644 --- a/src/services/api/graphql/README.graphql +++ b/src/services/api/graphql/README.graphql @@ -25,7 +25,7 @@ mutation { createDhcpServer(data: {sharedNetworkName: "BOB", subnet: "192.168.0.0/24", defaultRouter: "192.168.0.1", - dnsServer: "192.168.0.1", + nameServer: "192.168.0.1", domainName: "vyos.net", lease: 86400, range: 0, diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql index a7ee75d40..9f741a0a5 100644 --- a/src/services/api/graphql/graphql/schema/dhcp_server.graphql +++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql @@ -2,7 +2,7 @@ input dhcpServerConfigInput { sharedNetworkName: String subnet: String defaultRouter: String - dnsServer: String + nameServer: String domainName: String lease: Int range: Int @@ -17,7 +17,7 @@ type dhcpServerConfig { sharedNetworkName: String subnet: String defaultRouter: String - dnsServer: String + nameServer: String domainName: String lease: Int range: Int diff --git a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl index 629ce83c1..70de43183 100644 --- a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl +++ b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl @@ -1,5 +1,5 @@ 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 }} name-server {{ name_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 }} -- cgit v1.2.3 From 8e8d3d8e6dfec86c210a1b5874e839ad8adaef4b Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sat, 13 Nov 2021 13:04:41 -0600 Subject: graphql: T3993: move schema generation to bindings.py; clean up for linting (cherry picked from commit 9e2694b24b06d928240522322c9a6d60c7a7d290) --- src/services/api/graphql/bindings.py | 14 ++++++++++++ src/services/vyos-http-api-server | 44 +++++++++++++++--------------------- 2 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 src/services/api/graphql/bindings.py (limited to 'src/services') diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py new file mode 100644 index 000000000..1403841b4 --- /dev/null +++ b/src/services/api/graphql/bindings.py @@ -0,0 +1,14 @@ +import vyos.defaults +from . graphql.mutations import mutation +from . graphql.directives import DataDirective + +from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers + +def generate_schema(): + 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}) + + return schema diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index cb4ce4072..aa7ac6708 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -32,16 +32,13 @@ from fastapi.responses import HTMLResponse from fastapi.exceptions import RequestValidationError from fastapi.routing import APIRoute from pydantic import BaseModel, StrictStr, validator -from starlette.datastructures import FormData, MutableHeaders +from starlette.datastructures import FormData 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 @@ -69,11 +66,11 @@ def load_server_config(): return config def check_auth(key_list, key): - id = None + key_id = None for k in key_list: if k['key'] == key: - id = k['id'] - return id + key_id = k['id'] + return key_id def error(code, msg): resp = {"success": False, "error": msg, "data": None} @@ -223,10 +220,10 @@ responses = { def auth_required(data: ApiModel): key = data.key api_keys = app.state.vyos_keys - id = check_auth(api_keys, key) - if not id: + key_id = check_auth(api_keys, key) + if not key_id: raise HTTPException(status_code=401, detail="Valid API key is required") - app.state.vyos_id = id + app.state.vyos_id = key_id # override Request and APIRoute classes in order to convert form request to json; # do all explicit validation here, for backwards compatability of error messages; @@ -613,16 +610,11 @@ def show_op(data: ShowModel): # GraphQL integration ### -api.graphql.state.init() - -from api.graphql.graphql.mutations import mutation -from api.graphql.graphql.directives import DataDirective +from api.graphql.bindings import generate_schema -api_schema_dir = vyos.defaults.directories['api_schema'] - -type_defs = load_schema_from_path(api_schema_dir) +api.graphql.state.init() -schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives={"generate": DataDirective}) +schema = generate_schema() app.add_route('/graphql', GraphQL(schema, debug=True)) @@ -640,16 +632,16 @@ if __name__ == '__main__': try: server_config = load_server_config() - except Exception as e: - logger.critical("Failed to load the HTTP API server config: {0}".format(e)) + except Exception as err: + logger.critical(f"Failed to load the HTTP API server config: {err}") - session = ConfigSession(os.getpid()) + config_session = ConfigSession(os.getpid()) - app.state.vyos_session = session + app.state.vyos_session = config_session app.state.vyos_keys = server_config['api_keys'] - app.state.vyos_debug = True if server_config['debug'] == 'true' else False - app.state.vyos_strict = True if server_config['strict'] == 'true' else False + app.state.vyos_debug = bool(server_config['debug'] == 'true') + app.state.vyos_strict = bool(server_config['strict'] == 'true') api.graphql.state.settings['app'] = app @@ -657,6 +649,6 @@ if __name__ == '__main__': uvicorn.run(app, host=server_config["listen_address"], port=int(server_config["port"]), proxy_headers=True) - except OSError as e: - logger.critical(f"OSError {e}") + except OSError as err: + logger.critical(f"OSError {err}") sys.exit(1) -- cgit v1.2.3 From 419f81a0c39740de0ff61ce25325ebea76c4a395 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 14 Nov 2021 19:03:25 -0600 Subject: graphql: T3993: add config file save/load (cherry picked from commit 8915a19f7761253b7bdf6ca847069539ee33851d) --- src/services/api/graphql/README.graphql | 24 +++++++++++ src/services/api/graphql/bindings.py | 4 +- src/services/api/graphql/graphql/directives.py | 17 +++++++- src/services/api/graphql/graphql/mutations.py | 49 ++++++++++++++++++++++ .../api/graphql/graphql/schema/config_file.graphql | 27 ++++++++++++ .../api/graphql/graphql/schema/schema.graphql | 3 ++ src/services/api/graphql/recipes/config_file.py | 16 +++++++ src/services/api/graphql/recipes/recipe.py | 19 +++++++++ 8 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/services/api/graphql/graphql/schema/config_file.graphql create mode 100644 src/services/api/graphql/recipes/config_file.py (limited to 'src/services') diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql index 580c0eb7f..c91b70782 100644 --- a/src/services/api/graphql/README.graphql +++ b/src/services/api/graphql/README.graphql @@ -42,6 +42,30 @@ mutation { } } +mutation { + saveConfigFile(data: {fileName: "/config/config.boot"}) { + success + errors + data { + fileName + } + } +} + +N.B. fileName can be empty (fileName: "") or data can be empty (data: {}) to save to +/config/config.boot; to save to an alternative path, specify fileName. + +mutation { + loadConfigFile(data: {fileName: "/home/vyos/config.boot"}) { + success + errors + data { + fileName + } + } +} + + The GraphQL playground will be found at: https://{{ host_address }}/graphql diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 1403841b4..c123f68d8 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -1,6 +1,6 @@ import vyos.defaults from . graphql.mutations import mutation -from . graphql.directives import DataDirective +from . graphql.directives import DataDirective, ConfigFileDirective from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers @@ -9,6 +9,6 @@ def generate_schema(): type_defs = load_schema_from_path(api_schema_dir) - schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives={"generate": DataDirective}) + schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives={"generate": DataDirective, "configfile": ConfigFileDirective}) return schema diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index 651421c35..85d514de4 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -1,5 +1,5 @@ from ariadne import SchemaDirectiveVisitor, ObjectType -from . mutations import make_resolver +from . mutations import make_resolver, make_config_file_resolver class DataDirective(SchemaDirectiveVisitor): """ @@ -15,3 +15,18 @@ class DataDirective(SchemaDirectiveVisitor): func = make_resolver(name) field.resolve = func return field + +class ConfigFileDirective(SchemaDirectiveVisitor): + """ + Class providing implementation of 'configfile' 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_config_file_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 index 98c665c9a..2eb0a0b4a 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -57,4 +57,53 @@ def make_resolver(mutation_name): return func_impl +def make_config_file_resolver(mutation_name): + op = '' + if 'save' in mutation_name: + op = 'save' + elif 'load' in mutation_name: + op = 'load' + class_name = mutation_name.replace('save', '', 1).replace('load', '', 1) + func_base_name = convert_camel_case_to_snake(class_name) + resolver_name = f'resolve_{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) + if op == 'save': + k.save() + elif op == 'load': + k.load() + else: + return { + "success": False, + "errors": ["Input must be saveConfigFile | loadConfigFile"] + } + + 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/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql new file mode 100644 index 000000000..3096cf743 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/config_file.graphql @@ -0,0 +1,27 @@ +input saveConfigFileInput { + fileName: String +} + +type saveConfigFile { + fileName: String +} + +type saveConfigFileResult { + data: saveConfigFile + success: Boolean! + errors: [String] +} + +input loadConfigFileInput { + fileName: String! +} + +type loadConfigFile { + fileName: String! +} + +type loadConfigFileResult { + data: loadConfigFile + success: Boolean! + errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 8a5e17962..70fe0d726 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -8,8 +8,11 @@ type Query { } directive @generate on FIELD_DEFINITION +directive @configfile on FIELD_DEFINITION type Mutation { createDhcpServer(data: dhcpServerConfigInput) : createDhcpServerResult @generate createInterfaceEthernet(data: interfaceEthernetConfigInput) : createInterfaceEthernetResult @generate + saveConfigFile(data: saveConfigFileInput) : saveConfigFileResult @configfile + loadConfigFile(data: loadConfigFileInput) : loadConfigFileResult @configfile } diff --git a/src/services/api/graphql/recipes/config_file.py b/src/services/api/graphql/recipes/config_file.py new file mode 100644 index 000000000..850e5326e --- /dev/null +++ b/src/services/api/graphql/recipes/config_file.py @@ -0,0 +1,16 @@ + +from . recipe import Recipe + +class ConfigFile(Recipe): + def __init__(self, session, command_file): + super().__init__(session, command_file) + + # Define any custom processing of parameters here by overriding + # save/load: + # + # def save(self): + # self.data = transform_data(self.data) + # super().save() + # def load(self): + # self.data = transform_data(self.data) + # super().load() diff --git a/src/services/api/graphql/recipes/recipe.py b/src/services/api/graphql/recipes/recipe.py index 8fbb9e0bf..91d8bd67a 100644 --- a/src/services/api/graphql/recipes/recipe.py +++ b/src/services/api/graphql/recipes/recipe.py @@ -46,4 +46,23 @@ class Recipe(object): except Exception as error: raise error + def save(self): + session = self._session + data = self.data + if 'file_name' not in data or not data['file_name']: + data['file_name'] = '/config/config.boot' + try: + session.save_config(data['file_name']) + except Exception as error: + raise error + + def load(self): + session = self._session + data = self.data + + try: + session.load_config(data['file_name']) + session.commit() + except Exception as error: + raise error -- cgit v1.2.3