diff --git a/scripts/cluster/README.md b/scripts/cluster/README.md new file mode 100644 index 0000000000..003fcdae04 --- /dev/null +++ b/scripts/cluster/README.md @@ -0,0 +1,206 @@ +# Cluster Agent REST API + +## Test calls with curl + +### Add the callback-token + +``` +sudo echo "xyztoken" > /var/snap/microk8s/current/credentials/callback-token.txt +``` + +### Sending the token + +You can send the token-to-validate having two options: +1. Passing a json body {"callback":"\"} to the POST request and declaring a 'Content-Type' = 'application/json' header +2. Passing the token value in a header with the name 'Callback-Token' + +A curl example for getting the /version endpoint with json body or header: +``` +curl -k -v -d '{"callback":"xyztoken"}' -H "Content-Type: application/json" -X POST https://127.0.0.1:25000/cluster/api/v1.0/version +# or +curl -k -v -H "Callback-Token: xyztoken" -X POST https://127.0.0.1:25000/cluster/api/v1.0/version +``` +### /configure + +- Enable dns +``` +curl -k -v -d '{"callback":"xyztoken", "addon": [{"name":"dns","enable":true}]}' -H "Content-Type: application/json" -X POST https://127.0.0.1:25000/cluster/api/v1.0/configure +``` +- Response: +``` +{"result": "ok"} +``` +- Restart flanneld +``` +curl -k -v -d '{"callback":"xyztoken", "service": [{"name":"flanneld","restart":true}]}' -H "Content-Type: application/json" -X POST https://127.0.0.1:25000/cluster/api/v1.0/configure +``` +- Response: +``` +{"result": "ok"} +``` + +### /status +- General status: +``` +curl -k -v -d '{"callback":"xyztoken"}' -H "Content-Type: application/json" -X POST https://127.0.0.1:25000/cluster/api/v1.0/status +``` +- Response: +```json +{ + "addons": [{ + "version": 1.6, + "status": "disabled", + "name": "cilium", + "description": "SDN, fast with full network policy" + }, { + "version": "2.0.0-beta5", + "status": "disabled", + "name": "dashboard", + "description": "The Kubernetes dashboard" + }, { + "version": "1.6.6", + "status": "disabled", + "name": "dns", + "description": "CoreDNS" + }, { + "version": null, + "status": "disabled", + "name": "fluentd", + "description": "Elasticsearch-Fluentd-Kibana logging and monitoring" + }, { + "version": 1.11, + "status": "disabled", + "name": "gpu", + "description": "Automatic enablement of Nvidia CUDA" + }, { + "version": "2.16.0", + "status": "disabled", + "name": "helm", + "description": "Helm 2 - the package manager for Kubernetes" + }, { + "version": "3.0.2", + "status": "disabled", + "name": "helm3", + "description": "Helm 3 - Kubernetes package manager" + }, { + "version": "0.25.1", + "status": "disabled", + "name": "ingress", + "description": "Ingress controller for external access" + }, { + "version": "1.3.4", + "status": "disabled", + "name": "istio", + "description": "Core Istio service mesh services" + }, { + "version": "1.14.0", + "status": "disabled", + "name": "jaeger", + "description": "Kubernetes Jaeger operator with its simple config" + }, { + "version": "0.9.0", + "status": "disabled", + "name": "knative", + "description": "The Knative framework on Kubernetes." + }, { + "version": null, + "status": "disabled", + "name": "kubeflow", + "description": "Kubeflow for easy ML deployments" + }, { + "version": "2.7.0", + "status": "disabled", + "name": "linkerd", + "description": "Linkerd is a service mesh for Kubernetes and other frameworks" + }, { + "version": "0.8.2", + "status": "disabled", + "name": "metallb", + "description": "Loadbalancer for your Kubernetes cluster" + }, { + "version": "0.2.1", + "status": "disabled", + "name": "metrics-server", + "description": "K8s Metrics Server for API access to service metrics" + }, { + "version": null, + "status": "disabled", + "name": "prometheus", + "description": "Prometheus operator for monitoring and logging" + }, { + "version": null, + "status": "disabled", + "name": "rbac", + "description": "Role-Based Access Control for authorisation" + }, { + "version": 2.6, + "status": "disabled", + "name": "registry", + "description": "Private image registry exposed on localhost:32000" + }, { + "version": "1.0.0", + "status": "disabled", + "name": "storage", + "description": "Storage class; allocates storage from host directory" + }], + "microk8s": { + "running": true + } +} +``` +- Status for an addon: +``` +curl -k -v -d '{"callback":"xyztoken","addon":"dns"}' -H "Content-Type: application/json" -X POST https://127.0.0.1:25000/cluster/api/v1.0/status +``` +- Response: +```json +{"status": "disabled", "addon": "dns"} +``` + +### /services +- Get all available services +``` +curl -k -v -d '{"callback":"xyztoken"}' -H "Content-Type: application/json" -X POST https://127.0.0.1:25000/cluster/api/v1.0/services +``` +- Response: +```json +{ + "services": [ + "apiserver", + "apiserver-kicker", + "cluster-agent", + "containerd", + "controller-manager", + "etcd", + "flanneld", + "kubelet", + "proxy", + "scheduler" + ] +} +``` + +### How to get Kubernetes version from k8s API +- Get version data +``` +APISERVER=$(microk8s.kubectl config view --minify | grep server | cut -f 2- -d ":" | tr -d " ") +SECRET_NAME=$(microk8s.kubectl get secrets | grep ^default | cut -f1 -d ' ') +TOKEN=$(microk8s.kubectl describe secret $SECRET_NAME | grep -E '^token' | cut -f2 -d':' | tr -d " ") + +curl -k $APISERVER/version --header "Authorization: Bearer $TOKEN" + +``` +- Response: +```json +{ + "major": "1", + "minor": "18", + "gitVersion": "v1.18.2", + "gitCommit": "52c56ce7a8272c798dbc29846288d7cd9fbae032", + "gitTreeState": "clean", + "buildDate": "2020-04-16T11:48:36Z", + "goVersion": "go1.13.9", + "compiler": "gc", + "platform": "linux/amd64" +} +``` diff --git a/scripts/cluster/agent.py b/scripts/cluster/agent.py index 6c847b1812..c5854298df 100644 --- a/scripts/cluster/agent.py +++ b/scripts/cluster/agent.py @@ -355,22 +355,20 @@ def configure(): """ Web call to configure the node """ + # validate the callback token + ct = callback_token_validation() + if not ct["valid"]: return ct["response"] + + # get the configuration if request.headers['Content-Type'] == 'application/json': - callback_token = request.json['callback'] configuration = request.json else: - callback_token = request.form['callback'] configuration = json.loads(request.form['configuration']) - callback_token = callback_token.strip() - if not is_valid(callback_token, callback_token_file): - error_msg = {"error": "Invalid token"} - return Response(json.dumps(error_msg), mimetype='application/json', status=500) - # We expect something like this: ''' { - "callback": "xyztoken" + "callback": "xyztoken", "service": [ { @@ -446,6 +444,66 @@ def configure(): return resp +@app.route('/{}/services'.format(CLUSTER_API), methods=['POST']) +def services(): + """ + Web call to get all microk8s services + """ + ct = callback_token_validation() + if not ct["valid"]: return ct["response"] + output = {"services": ["apiserver", "apiserver-kicker", "cluster-agent", "containerd", "controller-manager", "etcd", + "flanneld", "kubelet", "proxy", "scheduler"]} + return app.response_class(response=json.dumps(output, sort_keys=False, indent=4), status=200, + mimetype='application/json') + + +@app.route('/{}/status'.format(CLUSTER_API), methods=['POST']) +def status(): + """ + Web call to get the microk8s status + """ + cmd = "{}/microk8s-status.wrapper --format yaml --timeout 60".format(snap_path) + + ct = callback_token_validation() + if not ct["valid"]: return ct["response"] + if "addon" in request.json: + cmd = "{}/microk8s-status.wrapper -a {}".format(snap_path, request.json["addon"]) + + output = subprocess.check_output(cmd.split()) + + if request.json and "addon" in request.json: + json_output = {"addon": request.json["addon"], "status": output.decode().strip('\n')} + else: + json_output = yaml.full_load(output) + + return app.response_class(response=json.dumps(json_output, sort_keys=False, indent=4), status=200, mimetype='application/json') + + +def callback_token_validation(): + """ + Validate the callback token. There are three ways for the API consumer to do so: + - 1. The token value can be passed using JSON in the body of the request. + eg.: {"callback":"xyztoken"} + * Additionally, "Content-Type: application/json" header must be defined + - 2. The token value can be passed as a header in the request. + eg. "Callback-Token: xyztoken" + - 3. Pass the token from a form based request with a field name 'callback' + """ + if 'Content-Type' in request.headers and request.headers['Content-Type'] == 'application/json' and 'callback' in request.json: + callback_token = request.json['callback'] + elif 'Callback-Token' in request.headers: + callback_token = request.headers['Callback-Token'] + else: + callback_token = request.form['callback'] + callback_token = callback_token.strip() + valid = is_valid(callback_token, callback_token_file) + resp = None + if not valid: + error_msg = {"error": "Invalid token"} + resp = Response(json.dumps(error_msg), mimetype='application/json', status=500) + return {"valid": valid, "response": resp} + + def get_dqlite_voters(): """ Get the voting members of the dqlite cluster diff --git a/tests/test-addons.py b/tests/test-addons.py index c041a222e8..780d920e0c 100644 --- a/tests/test-addons.py +++ b/tests/test-addons.py @@ -19,6 +19,7 @@ validate_rbac, validate_cilium, validate_kubeflow, + validate_cluster_agent_api ) from utils import ( microk8s_enable, @@ -247,3 +248,10 @@ def test_kubeflow_addon(self): validate_kubeflow() print("Disabling kubeflow") microk8s_disable("kubeflow") + + def test_cluster_agent_api(self): + """ + Test cluster-agent API + """ + print("Validating cluster-agent REST APIs") + validate_cluster_agent_api() diff --git a/tests/utils_api.py b/tests/utils_api.py new file mode 100644 index 0000000000..56640688dd --- /dev/null +++ b/tests/utils_api.py @@ -0,0 +1,83 @@ +import requests +import os +import random +import string +import json +import shutil + +BASE_URL = "https://127.0.0.1:25000/cluster/api/v1.0" +CALLBACK_TOKEN_FILE = "/var/snap/microk8s/current/credentials/callback-token.txt" + + +class TestClusterAgentApi(object): + + def test_status(self): + """ + Test /status endpoint + """ + token_value = self.get_or_generate_callback_token(CALLBACK_TOKEN_FILE) + print("Token retrieved: {}".format(token_value)) + callback_json = {"callback": token_value} + + r = requests.post("{}/status".format(BASE_URL), json=callback_json, verify=False) + assert r.status_code == 200, "Expecting 200 OK but {} received".format(r.status_code) + is_valid_json = self.is_json(r.text) + assert is_valid_json + print("MicroK8s status: {}".format(r.text)) + print("Is valid json: {}".format(is_valid_json)) + + def test_services(self): + """ + Test /services endpoint + """ + valid_output = {"services": ["apiserver", "apiserver-kicker", "cluster-agent", "containerd", + "controller-manager", "etcd", "flanneld", "kubelet", "proxy", "scheduler"]} + token_value = self.get_or_generate_callback_token(CALLBACK_TOKEN_FILE) + print("Token retrieved: {}".format(token_value)) + callback_json = {"callback": token_value} + + r = requests.post("{}/services".format(BASE_URL), json=callback_json, verify=False) + assert r.status_code == 200, "Expecting 200 OK but {} received".format(r.status_code) + is_valid_json = self.is_json(r.text) + assert is_valid_json + output = json.loads(r.text) + assert sorted(valid_output['services']) == sorted(output['services']) + print("MicroK8s services: {}".format(r.text)) + print("Is valid json: {}".format(is_valid_json)) + + + @staticmethod + def get_or_generate_callback_token(callback_token_file): + """ + Get token from file or generate a token and store it in the callback token file + + :return: the token + """ + try: + with open(callback_token_file) as f: + token = f.read() + if token.strip(): + return token + except FileNotFoundError: + # if file not found, continue + pass + + token = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(64)) + with open(callback_token_file, "w") as fp: + fp.write("{}\n".format(token)) + + os.chmod(callback_token_file, 0o660) + try: + shutil.chown(callback_token_file, group='microk8s') + except: + # not setting the group means only the current user can access the file + pass + return token + + @staticmethod + def is_json(myjson): + try: + json_object = json.loads(myjson) + except ValueError as e: + return False + return True diff --git a/tests/validators.py b/tests/validators.py index 9dd5a0506a..cdb5273f1b 100644 --- a/tests/validators.py +++ b/tests/validators.py @@ -3,6 +3,7 @@ import re import requests import platform +import utils_api import yaml from utils import ( @@ -405,3 +406,13 @@ def validate_kubeflow(): return wait_for_pod_state("ambassador-operator-0", "kubeflow", "running") + + +def validate_cluster_agent_api(): + """ + Validate basic API endpoints + """ + api = utils_api.TestClusterAgentApi() + api.test_status() + api.test_services() +