diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c20f142..b36aa48 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 0cf3930..ffd4289 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__ # misc .idea +.tox diff --git a/CODEOWNERS b/CODEOWNERS index 90e16ad..1b08999 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @vdloo @AlexanderGrooff @alejandrogarza @pieterbeens @tdgroot @markdentoom @martijneichelsheim +* @vdloo @alejandrogarza @tdgroot @martijneichelsheim diff --git a/README.md b/README.md index 72e6a6f..48bd049 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,36 @@ pip install -r requirements/development.txt Each Hypernode has an API token associated with it, you can use that to talk to the API directly. You can find the token in `/etc/hypernode/hypernode_api_token`. For API tokens with special permissions please contact support@hypernode.com. Not all functionality in the API is currently generally available but if you'd like to start automating and have an interesting use-case we'd love to hear from you. +## Using the library from the commandline + +In the `bin/` directory you'll find entry points to interact with the API directly from the commandline. + +See for example: +```bash +$ export PYTHONPATH=. + +$ ./bin/get_slas --help +usage: get_slas [-h] + +List all available SLAs. + +Example: +$ ./bin/get_slas +[ + { + "id": 123, + "code": "sla-standard", + "name": "SLA Standard", + "price": 1234, + "billing_period": 1, + "billing_period_unit": "month" + }, + ... +] + +optional arguments: + -h, --help show this help message and exit +``` ### Installing the library in your project @@ -33,7 +63,7 @@ $ python3 -m venv venv $ . venv/bin/activate $ pip install hypernode-api-python $ pip freeze | grep hypernode-api-python -hypernode-api-python==0.0.2 +hypernode-api-python==0.0.6 ``` ### Performing API calls @@ -56,7 +86,7 @@ client.set_app_setting('yourhypernodeappname', 'php_version', '8.1').json() {'name': 'yourhypernodeappname', 'type': 'persistent', 'php_version': '8.1', ...} ``` -To even performing acts of cloud automation, like scaling to the first next larger plan: +You can even perform acts of cloud automation, like scaling to the first next larger plan: ```python client.xgrade( 'yourhypernodeappname', diff --git a/bin/block_attack b/bin/block_attack new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/block_attack @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/check_payment_information_for_app b/bin/check_payment_information_for_app new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/check_payment_information_for_app @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/check_xgrade b/bin/check_xgrade new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/check_xgrade @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/command b/bin/command new file mode 100755 index 0000000..3d67aa5 --- /dev/null +++ b/bin/command @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +import os +import sys +from hypernode_api_python import commands + + +if __name__ == '__main__': + command = os.path.basename(__file__) + + if hasattr(commands, command): + sys.exit(getattr(commands, command)() or 0) + else: + sys.stderr.write("Command '{}' not found in hypernode_api_python.commands\n".format(command)) + sys.exit(1) diff --git a/bin/create_brancher b/bin/create_brancher new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/create_brancher @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/destroy_brancher b/bin/destroy_brancher new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/destroy_brancher @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_active_branchers b/bin/get_active_branchers new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_active_branchers @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_active_products b/bin/get_active_products new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_active_products @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_app_configurations b/bin/get_app_configurations new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_app_configurations @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_app_flavor b/bin/get_app_flavor new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_app_flavor @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_app_info b/bin/get_app_info new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_app_info @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_available_backups_for_app b/bin/get_available_backups_for_app new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_available_backups_for_app @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_block_attack_descriptions b/bin/get_block_attack_descriptions new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_block_attack_descriptions @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_cluster_relations b/bin/get_cluster_relations new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_cluster_relations @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_current_product_for_app b/bin/get_current_product_for_app new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_current_product_for_app @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_eav_description b/bin/get_eav_description new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_eav_description @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_flows b/bin/get_flows new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_flows @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_fpm_status b/bin/get_fpm_status new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_fpm_status @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_next_best_plan_for_app b/bin/get_next_best_plan_for_app new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_next_best_plan_for_app @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_product_info b/bin/get_product_info new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_product_info @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_sla b/bin/get_sla new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_sla @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_slas b/bin/get_slas new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_slas @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_whitelist_options b/bin/get_whitelist_options new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_whitelist_options @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/get_whitelist_rules b/bin/get_whitelist_rules new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/get_whitelist_rules @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/validate_app_name b/bin/validate_app_name new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/validate_app_name @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/bin/xgrade b/bin/xgrade new file mode 120000 index 0000000..e42252a --- /dev/null +++ b/bin/xgrade @@ -0,0 +1 @@ +command \ No newline at end of file diff --git a/hypernode_api_python/client.py b/hypernode_api_python/client.py index 52b5b03..942f38e 100644 --- a/hypernode_api_python/client.py +++ b/hypernode_api_python/client.py @@ -2,24 +2,32 @@ DEFAULT_USER_AGENT = "hypernode-api-python" HYPERNODE_API_URL = "https://api.hypernode.com" +HYPERNODE_API_ADDON_LIST_ENDPOINT = "/v2/addon/" HYPERNODE_API_ADDON_SLA_LIST_ENDPOINT = "/v2/addon/slas/" HYPERNODE_API_APP_CHECK_PAYMENT_INFORMATION = "/v2/app/{}/check-payment-information/" HYPERNODE_API_APP_CONFIGURATION_ENDPOINT = "/v2/configuration/" +HYPERNODE_API_APP_CLUSTER_RELATIONS = "/v2/app/{}/relations/" HYPERNODE_API_APP_DETAIL_ENDPOINT = "/v2/app/{}/?destroyed=false" HYPERNODE_API_APP_DETAIL_WITH_ADDONS_ENDPOINT = "/v2/app/{}/with_addons?destroyed=false" HYPERNODE_API_APP_EAV_DESCRIPTION_ENDPOINT = "/v2/app/eav_descriptions/" HYPERNODE_API_APP_FLAVOR_ENDPOINT = "/v2/app/{}/flavor/" +HYPERNODE_API_APP_FLOWS_ENDPOINT = "/logbook/v1/logbooks/{}/flows" HYPERNODE_API_APP_NEXT_BEST_PLAN_ENDPOINT = "/v2/app/{}/next_best_plan/" +HYPERNODE_API_APP_ORDER_ENDPOINT = "/v2/app/order/" HYPERNODE_API_APP_PRODUCT_LIST_ENDPOINT = "/v2/product/app/{}/" HYPERNODE_API_APP_XGRADE_CHECK_ENDPOINT = "/v2/app/xgrade/{}/check/{}/" HYPERNODE_API_APP_XGRADE_ENDPOINT = "/v2/app/xgrade/{}/" HYPERNODE_API_BACKUPS_ENDPOINT = "/v2/app/{}/backup/" +HYPERNODE_API_BLOCK_ATTACK_ENDPOINT = "/v2/app/{}/block_attack/" +HYPERNODE_API_BLOCK_ATTACK_DESCRIPTION_ENDPOINT = "/v2/app/block_attack_descriptions/" +HYPERNODE_API_BRANCHER_APP_ENDPOINT = "/v2/brancher/app/{}/" +HYPERNODE_API_FPM_STATUS_APP_ENDPOINT = "/v2/nats/{}/hypernode.show-fpm-status" +HYPERNODE_API_BRANCHER_ENDPOINT = "/v2/brancher/{}/" HYPERNODE_API_PRODUCT_APP_DETAIL_ENDPOINT = "/v2/product/app/{}/current/" HYPERNODE_API_PRODUCT_LIST_ENDPOINT = "/v2/product/" HYPERNODE_API_PRODUCT_PRICE_DETAIL_ENDPOINT = "/v2/product/{}/with_price/" HYPERNODE_API_VALIDATE_APP_NAME_ENDPOINT = "/v2/app/name/validate/" HYPERNODE_API_WHITELIST_ENDPOINT = "/v2/whitelist/{}/" -HYPERNODE_API_APP_ORDER_ENDPOINT = "/v2/app/order/" class HypernodeAPIPython: @@ -167,6 +175,88 @@ def get_app_flavor(self, app_name): """ return self.requests("GET", HYPERNODE_API_APP_FLAVOR_ENDPOINT.format(app_name)) + def get_flows(self, app_name): + """ + List the flows for an app. Take note that this result is paginated and will not + retrieve all flows. If you are looking to retrieve all flows and not those available + on just the first page, look at the get_all_flows method instead. + + Example: + > client.get_flows('yourhypernodeappname').json() + > {'count': 2, + > 'next': None, + > 'previous': None, + > 'results': [{'uuid': '1d1b8437-c8c0-4e96-a28a-26cc311c1f4c', + > 'state': 'success', + > 'name': 'update_node', + > 'created_at': '2023-03-04T14:42:24Z', + > 'updated_at': '2023-03-04T14:42:57Z', + > 'progress': {'running': [], 'total': 8, 'completed': 8}, + > 'logbook': 'yourhypernodeappname', + > 'tracker': {'uuid': '6012bf2c-952d-4cb0-97ff-1022614b50b7', + > 'description': None}}, + > {'uuid': 'fe696417-024e-4a57-8442-4967f6df24a3', + > 'state': 'success', + > 'name': 'create_backup', + > 'created_at': '2023-03-04T14:13:00Z', + > 'updated_at': '2023-03-04T14:13:12Z', + > 'progress': {'running': [], 'total': 2, 'completed': 2}, + > 'logbook': 'yourhypernodeappname', + > 'tracker': {'uuid': None, 'description': None}}] + > } + + + :param str app_name: The name of the app to get the flows for + :return obj response: The request response object + """ + return self.requests("GET", HYPERNODE_API_APP_FLOWS_ENDPOINT.format(app_name)) + + def get_all_flows(self, app_name, limit=None): + """ + List all the flows for an app (paginate over the results), or + retrieve results until we have enough (if limit is specified). + Note that this method does not return a response object but a + list of dicts instead. + Example: + > client.get_all_flows('yourhypernodeappname') + > [{'uuid': '03bd6e10-5493-4ee8-92fc-cc429faebead', + > 'state': None, + > 'name': 'update_node', + > 'created_at': '2023-03-05T14:01:56Z', + > 'updated_at': None, + > 'progress': {'running': [], 'total': 0, 'completed': 0}, + > 'logbook': 'yourhypernodeappname', + > 'tracker': {'uuid': '0dd83d83-6b9b-4fb5-9665-79fcf8235069', + > 'description': None}}, + > {'uuid': 'ace36ab9-323e-4a95-a0c6-46f96945b623', + > 'state': None, + > 'name': 'update_node', + > 'created_at': '2023-03-05T14:01:55Z', + > 'updated_at': None, + > 'progress': {'running': [], 'total': 0, 'completed': 0}, + > 'logbook': 'yourhypernodeappname', + > 'tracker': {'uuid': '0006206e-df48-4af4-8e7b-b3db86d35bb0', + > 'description': None}}, + > ... + > ] + + :param str app_name: The name of the app to get all the flows for + :param int limit: How many flows to get. If None is specified all + available flows will be retrieved. + :return list flows: A list of all flows + """ + flows = [] + next_url = HYPERNODE_API_APP_FLOWS_ENDPOINT.format(app_name) + while next_url: + if limit and len(flows) >= limit: + break + response = self.requests("GET", next_url.replace(HYPERNODE_API_URL, "")) + response_data = response.json() + results = response_data["results"] + flows.extend(results) + next_url = response_data["next"] + return flows[:limit] + def get_slas(self): """ List the available SLAs @@ -184,6 +274,22 @@ def get_slas(self): """ return self.requests("GET", HYPERNODE_API_ADDON_SLA_LIST_ENDPOINT) + def get_sla(self, sla_code): + """ + List a specific SLA + Example: + > client.get_sla("sla-standard").json() + > {'billing_period': 1, + > 'billing_period_unit': 'month', + > 'code': 'sla-standard', + > 'id': 123, + > 'name': 'SLA Standard', + > 'price': 1234} + + :return obj response: The request response object + """ + return self.requests("GET", HYPERNODE_API_ADDON_LIST_ENDPOINT + sla_code + "/") + def get_available_backups_for_app(self, app_name): """ Lists the available backups for the specified app @@ -226,6 +332,7 @@ def get_app_eav_description(self): """ return self.requests("GET", HYPERNODE_API_APP_EAV_DESCRIPTION_ENDPOINT) + # TODO: add entrypoint for this method in bin/ and commands.py def set_app_setting(self, app_name, attribute, value): """ Update a setting on the app, like the PHP or MySQL version. See @@ -278,6 +385,34 @@ def get_app_configurations(self): """ return self.requests("GET", HYPERNODE_API_APP_CONFIGURATION_ENDPOINT) + def get_cluster_relations(self, app_name): + """ + List all relations for the specified app. This will return all the + relations that are currently configured for the specified app. + + Example: + > client.get_cluster_relations('mytestappweb').json() + > {'children': [], + > 'parents': [{'child': 'mytestappweb', + > 'cluster_description': None, + > 'id': 182, + > 'parent': 'mytestappdb', + > 'relation_type': 'mysql'}, + > {'child': 'mytestappweb', + > 'cluster_description': None, + > 'id': 180, + > 'parent': 'mytestapp', + > 'relation_type': 'loadbalancer'}, + > {'child': 'mytestappweb', + > 'cluster_description': None, + > 'id': 181, + > 'parent': 'mytestapp', + > 'relation_type': 'nfs'}]} + """ + return self.requests( + "GET", HYPERNODE_API_APP_CLUSTER_RELATIONS.format(app_name) + ) + def get_product_info_with_price(self, product_code, error_to_raise=None): """ Get information about a specific product @@ -319,6 +454,43 @@ def get_product_info_with_price(self, product_code, error_to_raise=None): raise error_to_raise return response + def get_block_attack_descriptions(self): + """ + Get the block attack descriptions. These can be used to block attacks + by automatically placing an NGINX snippet in /data/web/nginx if the + specified block is compatible with the current NGINX configuration. + Example: + > client.get_block_attack_descriptions().json() + > { + > 'BlockSqliBruteForce': 'Attempts to deploy NGINX rules to block suspected (blind) SQL injection attack', + > ... + > } + + :return obj response: The request response object + """ + return self.requests("GET", HYPERNODE_API_BLOCK_ATTACK_DESCRIPTION_ENDPOINT) + + def block_attack(self, app_name, attack_name): + """ + Attempts to deploy one of the block attack rules. The list of rules can be retrieved with the + get_block_attack_descriptions method. This will place an NGINX snippet to block the specified attack + in /data/web/nginx. If the nginx-config-validator detects that the new config is valid, it will leave + the newly placed config. Otherwise it will be removed again. + + Example: + > client.block_attack('yourhypernodeappname', 'BlockSqliBruteForce').ok + > True + + :param str app_name: The name of the Hypernode you want to deploy the block attack nginx configuration on + :param str attack_name: The specific attack you want to block. See get_block_attack_descriptions for options. + :return obj response: The request response object + """ + return self.requests( + "POST", + HYPERNODE_API_BLOCK_ATTACK_ENDPOINT.format(app_name), + data={"attack_name": attack_name}, + ) + def get_whitelist_options(self, app_name): """ Get whitelist options for app. Retrieve the options for specifying @@ -610,6 +782,7 @@ def xgrade(self, app_name, data): "PATCH", HYPERNODE_API_APP_XGRADE_ENDPOINT.format(app_name), data=data ) + # TODO: add entrypoint for this method in bin/ and commands.py def order_hypernode(self, data): """ Orders a new Hypernode. Note that you can not do this with the API permissions @@ -628,3 +801,84 @@ def order_hypernode(self, data): :return obj response: The request response object """ return self.requests("POST", HYPERNODE_API_APP_ORDER_ENDPOINT, data=data) + + def get_active_branchers(self, app_name): + """ + List all active brancher nodes of your Hypernode. + Example: + > client.get_active_branchers("yourhypernodeappname").json() + > { + > "monthly_total_time": 6397, + > "monthly_total_cost": 109, + > "branchers": [ + > { + > "id": 21, + > "name": "yourhypernodeappname-eph123456", + > "cost": 131, + > "created": "2019-08-24T14:15:22Z" + > "ip": "127.0.0.1", + > "end_time": None, + > "elapsed_time": 7824, + > "labels": {"description": "php upgrade test", "label without equal sign": None}, + > }, + > { + > "id": 22, + > "name": "yourhypernodeappname-eph654321", + > "cost": 121, + > "ip": "52.68.96.58", + > "created": "2019-08-24T14:15:22Z" + > "end_time": None, + > "elapsed_time": 7224, + > "labels": {}, + > } + > ] + > } + + :param str app_name: The name of the Hypernode to get your active branchers for + :return obj response: The request response object + """ + return self.requests( + "GET", HYPERNODE_API_BRANCHER_APP_ENDPOINT.format(app_name) + ) + + def get_fpm_status(self, app_name): + """ + Get the status of the FPM service for the specified app + Example: + > client.get_fpm_status("yourhypernodeappname").json() + { + "message": null, + "data": "50570 IDLE 0.0s - phpfpm 127.0.0.1 GET magweb/status.php (python-requests/2.28.1)\n50571 IDLE 0.0s - phpfpm 127.0.0.1 GET magweb/status.php (python-requests/2.28.1)\n", + "status": 200 + } + + :param str app_name: The name of the Hypernode to get the FPM status for + :return obj response: The request response object + """ + return self.requests( + "POST", HYPERNODE_API_FPM_STATUS_APP_ENDPOINT.format(app_name) + ) + + def create_brancher(self, app_name, data): + """ + Create a new branch (server replica) of your Hypernode. + + :param str app_name: The name of the Hypernode to create the branch from + :param dict data: Data regarding the branch to be created. An example could be: + {'clear_services': ['mysql', 'cron']}. + :return obj response: The request response object + """ + return self.requests( + "POST", HYPERNODE_API_BRANCHER_APP_ENDPOINT.format(app_name), data=data + ) + + def destroy_brancher(self, brancher_name): + """ + Destroy an existing brancher node of your Hypernode. + + :param str brancher_name: The name of the brancher node to destroy. + :return obj response: The request response object + """ + return self.requests( + "DELETE", HYPERNODE_API_BRANCHER_ENDPOINT.format(brancher_name) + ) diff --git a/hypernode_api_python/commands.py b/hypernode_api_python/commands.py new file mode 100644 index 0000000..7f96f6b --- /dev/null +++ b/hypernode_api_python/commands.py @@ -0,0 +1,724 @@ +import json +from os import environ, EX_OK, EX_UNAVAILABLE +from argparse import ArgumentParser, RawTextHelpFormatter +from hypernode_api_python.client import HypernodeAPIPython + + +def get_client(): + """ + Instantiates the HypernodeAPIPython client with the token from the environment. + :return obj HypernodeAPIPython: The instantiated client + """ + api_token = environ.get("HYPERNODE_API_TOKEN") + if not api_token: + raise ValueError( + "HYPERNODE_API_TOKEN environment variable not set. " + "Try running `export HYPERNODE_API_TOKEN=yourapitoken`" + ) + client = HypernodeAPIPython(environ["HYPERNODE_API_TOKEN"]) + return client + + +def get_app_name(): + """ + Gets the app name from the environment. + :return str app_name: The app name + """ + app_name = environ.get("HYPERNODE_APP_NAME") + if not app_name: + raise ValueError( + "HYPERNODE_APP_NAME environment variable not set. " + "Try running `export HYPERNODE_APP_NAME=yourhypernodeappname`" + ) + return app_name + + +def print_response(response): + """ + Pretty prints the JSON response. + :param obj response: The response object + :return NoneType None: None + """ + print(json.dumps(response.json(), indent=2)) + + +def get_app_info(args=None): + parser = ArgumentParser( + description=""" +Get information about the Hypernode app. + +Example: +$ ./bin/get_app_info +{ + "name": "yourhypernodeappname", + "type": "persistent", + "product": { + "code": "FALCON_M_202203", + "name": "Falcon M", + "backups_enabled": true, + "storage_size_in_gb": 75, + "price": 1234, + "is_development": false, + "provider": "combell", + "varnish_supported": true, + "supports_sla": true + }, + "domainname": "yourhypernodeappname.hypernode.io", + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_app_info_or_404(app_name)) + + +def get_next_best_plan_for_app(args=None): + parser = ArgumentParser( + description=""" +Get the plan that is the first bigger plan than the current plan for this Hypernode. +This is convenient for if you want to upgrade your Hypernode to a bigger plan using the +xgrade feature and you need to know which plan to upgrade to. + +Example: +$ ./bin/get_next_best_plan_for_app +{ + "code": "FALCON_L_202203", + "name": "Falcon L" +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_next_best_plan_for_app_or_404(app_name)) + + +def validate_app_name(args=None): + parser = ArgumentParser( + description=""" +Check if the specified app_name is valid and available. An app name can not be +registered if it's already taken. Also there are certain restrictions on the +app name, like it can't contain certain characters or exceed a certain length. + +Examples: +$ ./bin/validate_app_name hypernode +App name 'hypernode' is valid. + +$ ./bin/validate_app_name hyper_node +App name 'hyper_node' is invalid: ["This value can only contain non-capital letters 'a' through 'z' or digits 0 through 9."] +""", + formatter_class=RawTextHelpFormatter, + ) + parser.add_argument( + "app_name", + help="The app name to validate", + ) + args = parser.parse_args(args=args) + client = get_client() + try: + client.validate_app_name(args.app_name) + print("App name '{}' is valid.".format(args.app_name)) + exit(EX_OK) + except Exception as e: + print("App name '{}' is invalid: {}".format(args.app_name, e)) + exit(EX_UNAVAILABLE) + + +def get_app_flavor(args=None): + parser = ArgumentParser( + description=""" +Get the current flavor of the Hypernode app. + +Example: +$ ./bin/get_app_flavor +{ + "name": "3CPU/16GB/80GB (Falcon M 202202)", + "redis_size": "2048" +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_app_flavor(app_name)) + + +def get_flows(args=None): + parser = ArgumentParser( + description=""" +Llist the flows for the Hypernode app. This is a history of all the +Hypernode automation jobs that have been ran for this Hypernode. + +Example: +$ ./bin/get_flows +{ + "count": 36, + "next": null, + "previous": null, + "results": [ + { + "uuid": "ad3b520e-a424-4bcb-a6fb-7d1fc055c3fa", + "state": "success", + "name": "create_backup", + "created_at": "2024-05-25T10:01:25Z", + "updated_at": "2024-05-25T10:01:57Z", + "progress": { + "running": [], + "total": 2, + "completed": 2 + }, + "logbook": "myhypernodeappname", + "tracker": { + "uuid": null, + "description": null + } + }, + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_flows(app_name)) + + +def get_slas(args=None): + parser = ArgumentParser( + description=""" +List all available SLAs. + +Example: +$ ./bin/get_slas +[ + { + "id": 123, + "code": "sla-standard", + "name": "SLA Standard", + "price": 1234, + "billing_period": 1, + "billing_period_unit": "month" + }, + ... +] +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + print_response(client.get_slas()) + + +def get_sla(args=None): + parser = ArgumentParser( + description=""" +Get a specific SLA. + +$ ./bin/get_sla sla-standard +{ + "id": 123, + "code": "sla-standard", + "name": "SLA Standard", + "price": 1234, + "billing_period": 1, + "billing_period_unit": "month" +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.add_argument("sla_code", help="The code of the SLA to get") + args = parser.parse_args(args=args) + client = get_client() + print_response(client.get_sla(args.sla_code)) + + +def get_available_backups_for_app(args=None): + parser = ArgumentParser( + description=""" +List the available backups for the Hypernode + +Example: +$ ./bin/get_available_backups_for_app +{ + "count": 10, + "next": null, + "previous": null, + "results": [ + { + "backup_created_at": "2024-05-02T12:00:33+02:00", + "type": "periodic", + "backup_id": "1169e792-8b05-449c-a7b1-7d52cf43153a", + "expired_at": "2024-05-30T12:00:33+02:00" + }, + ... +] +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_available_backups_for_app(app_name)) + + +def get_eav_description(args=None): + parser = ArgumentParser( + description=""" +List all available EAV settings and their descriptions. + +Example: +$ ./bin/get_eav_description +{ + "supervisor_enabled": [ + true, + false + ], + "redis_eviction_policy": [ + "noeviction", + "allkeys-lru", + "allkeys-lfu", + "volatile-lru", + "volatile-lfu", + "allkeys-random", + "volatile-random", + "volatile-ttl" + ], + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + print_response(client.get_app_eav_description()) + + +def get_app_configurations(args=None): + parser = ArgumentParser( + description=""" +List all the available configurations that can be selected when ordering a new Hypernode. + +Example: +$ ./bin/get_app_configurations +{ + "count": 7, + "next": null, + "previous": null, + "results": [ + { + "name": "Akeneo 6.0", + "configuration_id": "akeneo_6_0", + "php_version": "8.0", + "mysql_version": "8.0", + ... + }, + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + print_response(client.get_app_configurations()) + + +def get_cluster_relations(args=None): + parser = ArgumentParser( + description=""" +List all the cluster relations for the Hypernode. + +Example: +$ ./bin/get_cluster_relations +{ + "parents": [ + { + "id": 182, + "parent": "mytestappdb", + "child": "mytestappweb", + "relation_type": "mysql", + "cluster_description": null + }, + { + "id": 180, + "parent": "mytestapp", + "child": "mytestappweb", + "relation_type": "loadbalancer", + "cluster_description": null + }, + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_cluster_relations(app_name)) + + +def get_product_info(args=None): + parser = ArgumentParser( + description=""" +Gets the product info for the specified product + +$ ./bin/get_product_info FALCON_S_202203 +{ + "code": "FALCON_S_202203", + "name": "Falcon S", + "backups_enabled": true, + "storage_size_in_gb": 57, + "price": 1234, + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + client = get_client() + parser.add_argument( + "product_code", + help="The code of the product to get", + choices=[p["code"] for p in client.get_active_products().json()], + ) + args = parser.parse_args(args=args) + print_response(client.get_product_info_with_price(args.product_code)) + + +def get_block_attack_descriptions(args=None): + parser = ArgumentParser( + description=""" +List all attack blocking strategies and their descriptions. + +Example: +$ ./bin/get_block_attack_descriptions +{ + "BlockSqliBruteForce": "Attempts to deploy NGINX rules to block suspected (blind) SQL injection attacks", + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + print_response(client.get_block_attack_descriptions()) + + +def block_attack(args=None): + parser = ArgumentParser( + description=""" +Block a specific attack based on a pre-defined attack blocking strategy. + +$ ./bin/block_attack BlockSqliBruteForce +A job to block the 'BlockSqliBruteForce' attack has been posted. +""", + formatter_class=RawTextHelpFormatter, + ) + client = get_client() + choices = client.get_block_attack_descriptions().json().keys() + parser.add_argument("attack_name", help="The attack to block", choices=choices) + args = parser.parse_args(args=args) + app_name = get_app_name() + output = client.block_attack(app_name, args.attack_name).content + if output: + print(output) + else: + print( + "A job to block the '{}' attack has been posted.".format(args.attack_name) + ) + + +def get_whitelist_options(args=None): + parser = ArgumentParser( + description=""" +List all available WAF whitelist options for the Hypernode. + +Example: +$ ./bin/get_whitelist_options +{ + "name": "Whitelist", + "description": "", + "renders": [ + "application/json" + ], + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_whitelist_options(app_name)) + + +def get_whitelist_rules(args=None): + parser = ArgumentParser( + description=""" +List all currently configured WAF whitelist rules for the Hypernode. + +Example: +$ ./bin/get_whitelist_rules +[ + { + "id": 1234, + "created": "2024-05-25T13:39:48Z", + "domainname": "yourhypernodeappname.hypernode.io", + "ip": "1.2.3.4", + "type": "database", + "description": "my description" + }, + ... +] +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_whitelist_rules(app_name)) + + +def get_current_product_for_app(args=None): + parser = ArgumentParser( + description=""" +Gets the current product for the specified app. + +Example: +$ ./bin/get_current_product_for_app +{ + "code": "FALCON_M_202203", + "name": "Falcon M", + "backups_enabled": true, + "is_development": false, + "varnish_supported": true, + "supports_sla": true, + "provider_flavors": [ + { + "vcpus": 3, + "ram_in_mb": 16384, + ... + }, + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_current_product_for_app(app_name)) + + +def check_payment_information_for_app(args=None): + parser = ArgumentParser( + description=""" +Shows the payment information for the specified app. + +Example: +$ ./bin/check_payment_information_for_app +{ + "has_valid_vat_number": true, + "has_valid_payment_method": true +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.check_payment_information_for_app(app_name)) + + +def get_active_products(args=None): + parser = ArgumentParser( + description=""" +Lists all available products. + +Example: +$ ./bin/get_active_products +[ + { + "code": "JACKAL_S_202301", + "name": "Jackal S", + "backups_enabled": true, + "is_development": false, + ... + }, + ... +] +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + print_response(client.get_active_products()) + + +def check_xgrade(args=None): + parser = ArgumentParser( + description=""" +Verify that the specified app can be upgraded to the specified product. +This checks if there is enough disk space available. The output will also +show whether or not there will be an IP change and if a volume swap xgrade +would be performed instead of an rsync xgrade. + +Example: +$ ./bin/check_xgrade FALCON_L_202203 +{ + "has_valid_vat_number": true, + "has_valid_payment_method": true, + "will_change_ip": false, + "will_do_volswap": false, + "will_disk_fit": true +} +""", + formatter_class=RawTextHelpFormatter, + ) + client = get_client() + parser.add_argument( + "product_code", + help="The code of the product to check", + choices=[p["code"] for p in client.get_active_products().json()], + ) + args = parser.parse_args(args=args) + app_name = get_app_name() + print_response(client.check_xgrade(app_name, args.product_code)) + + +def xgrade(args=None): + parser = ArgumentParser( + description=""" +Change the plan of your Hypernode. + +Example: +$ ./bin/xgrade FALCON_L_202203 +The job to xgrade Hypernode 'yourappname' to product 'FALCON_L_202203' has been posted +""", + formatter_class=RawTextHelpFormatter, + ) + client = get_client() + parser.add_argument( + "product_code", + help="The code of the product to check", + choices=[p["code"] for p in client.get_active_products().json()], + ) + args = parser.parse_args(args=args) + app_name = get_app_name() + data = {"product": args.product_code} + output = client.xgrade(app_name, data=data).content + if output: + print(output) + else: + print( + "The job to xgrade Hypernode '{}' to product '{}' " + "has been posted".format(app_name, args.product_code) + ) + + +def get_active_branchers(args=None): + parser = ArgumentParser( + description=""" +List all active branchers + +Example: +$ ./bin/get_active_branchers +{ + "monthly_total_time": 0, + "total_minutes_elapsed": 0, + "actual_monthly_total_cost": 0, + "monthly_total_cost": 0, + "branchers": [] +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_active_branchers(app_name)) + + +def create_brancher(args=None): + parser = ArgumentParser( + description=""" +Create a Brancher Hypernode from the specified app. +Outputs the app_info of the brancher to be created.. + +Example: +$ ./bin/create_brancher +{ + "name": "yourappname-ephoj82yb", + "parent": "yourappname", + "type": "brancher", + "product": "FALCON_M_202203", + "domainname": "yourappname-ephoj82yb.hypernode.io", + ... +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + data = {} + print_response(client.create_brancher(app_name, data=data)) + + +def destroy_brancher(args=None): + parser = ArgumentParser( + description=""" +Destroy a Brancher Hypernode. + +Examples: +$ ./bin/destroy_brancher yourbrancherappname-eph12345 +A job has been posted to cancel the 'yourbrancherappname-eph12345' brancher app. +""", + formatter_class=RawTextHelpFormatter, + ) + parser.add_argument( + "brancher_app_name", + help="The name of the brancher to destroy. See ./bin/get_active_branchers", + ) + args = parser.parse_args(args=args) + client = get_client() + try: + client.destroy_brancher(args.brancher_app_name) + print( + "A job has been posted to cancel the '{}' brancher app.".format( + args.brancher_app_name + ) + ) + exit(EX_OK) + except Exception as e: + print( + "Brancher app '{}' failed to be cancelled: {}".format( + args.brancher_app_name, e + ) + ) + exit(EX_UNAVAILABLE) + + +def get_fpm_status(args=None): + parser = ArgumentParser( + description=""" +Show the status of the PHP-FPM workers. + +Example: +$ ./bin/get_fpm_status +{ + "message": null, + "data": "50570 IDLE 0.0s - phpfpm 127.0.0.1 GET magweb/status.php (python-requests/2.28.1)\n50571 IDLE 0.0s - phpfpm 127.0.0.1 GET magweb/status.php (python-requests/2.28.1)\n", + "status": 200 +} +""", + formatter_class=RawTextHelpFormatter, + ) + parser.parse_args(args=args) + client = get_client() + app_name = get_app_name() + print_response(client.get_fpm_status(app_name)) diff --git a/setup.py b/setup.py index ac60f8d..6728a59 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( name="hypernode_api_python", - version="0.0.2", + version="0.0.6", description='"Hypernode API Client for Python"', url="https://github.com/ByteInternet/hypernode_api_python", packages=find_packages( diff --git a/tests/client/test_block_attack.py b/tests/client/test_block_attack.py new file mode 100644 index 0000000..aef443e --- /dev/null +++ b/tests/client/test_block_attack.py @@ -0,0 +1,39 @@ +from unittest import TestCase +from unittest.mock import Mock + +from hypernode_api_python.client import ( + HYPERNODE_API_BLOCK_ATTACK_ENDPOINT, + HypernodeAPIPython, +) + + +class TestBlockAttack(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="my_token") + self.mock_request = Mock() + self.client.requests = self.mock_request + self.app_name = "my_app" + + def test_block_attack_endpoint_is_correct(self): + self.assertEqual( + "/v2/app/{}/block_attack/", HYPERNODE_API_BLOCK_ATTACK_ENDPOINT + ) + + def test_calls_block_attack_endpoint_correctly(self): + self.client.block_attack(self.app_name, "BlockSqliBruteForce") + + self.mock_request.assert_called_once_with( + "POST", + f"/v2/app/{self.app_name}/block_attack/", + data={"attack_name": "BlockSqliBruteForce"}, + ) + + def test_returns_block_attack_data(self): + response = Mock() + response.status_code = 202 + self.mock_request.return_value = response + + self.assertEqual( + self.client.block_attack(self.app_name, "BlockSqliBruteForce"), + self.mock_request.return_value, + ) diff --git a/tests/client/test_create_brancher.py b/tests/client/test_create_brancher.py new file mode 100644 index 0000000..f980681 --- /dev/null +++ b/tests/client/test_create_brancher.py @@ -0,0 +1,36 @@ +from unittest import TestCase +from unittest.mock import Mock + +from hypernode_api_python.client import ( + HYPERNODE_API_BRANCHER_APP_ENDPOINT, + HypernodeAPIPython, +) + + +class TestCreateBrancher(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="my_token") + self.mock_request = Mock() + self.client.requests = self.mock_request + self.app_name = "my_app" + + def test_brancher_endpoint_is_correct(self): + self.assertEqual("/v2/brancher/app/{}/", HYPERNODE_API_BRANCHER_APP_ENDPOINT) + + def test_calls_create_brancher_endpoint_properly(self): + data = {"clear_services": ["mysql"]} + self.client.create_brancher(self.app_name, data) + + self.mock_request.assert_called_once_with( + "POST", f"/v2/brancher/app/{self.app_name}/", data=data + ) + + def test_returns_create_brancher_data(self): + response = Mock() + response.status_code = 200 + self.mock_request.return_value = response + + self.assertEqual( + self.client.create_brancher(self.app_name, {}), + self.mock_request.return_value, + ) diff --git a/tests/client/test_destroy_brancher.py b/tests/client/test_destroy_brancher.py new file mode 100644 index 0000000..698ef4a --- /dev/null +++ b/tests/client/test_destroy_brancher.py @@ -0,0 +1,35 @@ +from unittest import TestCase +from unittest.mock import Mock + +from hypernode_api_python.client import ( + HypernodeAPIPython, + HYPERNODE_API_BRANCHER_ENDPOINT, +) + + +class TestDestroyBrancher(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="my_token") + self.mock_request = Mock() + self.client.requests = self.mock_request + self.brancher_name = "app-branchermobyname" + + def test_brancher_endpoint_is_correct(self): + self.assertEqual("/v2/brancher/{}/", HYPERNODE_API_BRANCHER_ENDPOINT) + + def test_calls_destroy_brancher_endpoint_properly(self): + self.client.destroy_brancher(self.brancher_name) + + self.mock_request.assert_called_once_with( + "DELETE", HYPERNODE_API_BRANCHER_ENDPOINT.format(self.brancher_name) + ) + + def test_returns_destroy_brancher_data(self): + response = Mock() + response.status_code = 204 + self.mock_request.return_value = response + + self.assertEqual( + self.client.destroy_brancher(self.brancher_name), + self.mock_request.return_value, + ) diff --git a/tests/client/test_get_active_branchers.py b/tests/client/test_get_active_branchers.py new file mode 100644 index 0000000..bb71fc9 --- /dev/null +++ b/tests/client/test_get_active_branchers.py @@ -0,0 +1,35 @@ +from unittest import TestCase +from unittest.mock import Mock + +from hypernode_api_python.client import ( + HYPERNODE_API_BRANCHER_APP_ENDPOINT, + HypernodeAPIPython, +) + + +class TestGetActiveBranchers(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="my_token") + self.mock_request = Mock() + self.client.requests = self.mock_request + self.app_name = "my_app" + + def test_brancher_endpoint_is_correct(self): + self.assertEqual("/v2/brancher/app/{}/", HYPERNODE_API_BRANCHER_APP_ENDPOINT) + + def test_calls_get_active_branchers_endpoint_properly(self): + self.client.get_active_branchers(self.app_name) + + self.mock_request.assert_called_once_with( + "GET", f"/v2/brancher/app/{self.app_name}/" + ) + + def test_returns_active_branchers_data(self): + response = Mock() + response.status_code = 200 + self.mock_request.return_value = response + + self.assertEqual( + self.client.get_active_branchers(self.app_name), + self.mock_request.return_value, + ) diff --git a/tests/client/test_get_all_flows.py b/tests/client/test_get_all_flows.py new file mode 100644 index 0000000..ea87878 --- /dev/null +++ b/tests/client/test_get_all_flows.py @@ -0,0 +1,146 @@ +from unittest.mock import Mock, call + +from tests.testcase import TestCase +from hypernode_api_python.client import ( + HypernodeAPIPython, +) + + +class TestGetAllFlows(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="mytoken") + self.mock_request = Mock() + self.client.requests = self.mock_request + self.flow1 = { + "uuid": "e1db2b60-882d-4b43-8910-ce6d38ca5393", + "state": None, + "name": "create_backup", + "created_at": "2023-03-05T14:13:21Z", + "updated_at": None, + "progress": {"running": [], "total": 0, "completed": 0}, + "logbook": "my_app", + "tracker": {"uuid": None, "description": None}, + } + self.flow2 = { + "uuid": "03bd6e10-5493-4ee8-92fc-cc429faebead", + "state": None, + "name": "update_node", + "created_at": "2023-03-05T14:01:56Z", + "updated_at": None, + "progress": {"running": [], "total": 0, "completed": 0}, + "logbook": "my_app", + "tracker": { + "uuid": "0dd83d83-6b9b-4fb5-9665-79fcf8235069", + "description": None, + }, + } + + def test_get_all_flows_returns_flows_if_only_one_page(self): + self.mock_request.return_value.json.return_value = { + "count": 2, + "next": None, + "previous": None, + "results": [self.flow1, self.flow2], + } + + ret = self.client.get_all_flows("my_app") + + expected_calls = [ + call("GET", "/logbook/v1/logbooks/my_app/flows"), + call().json(), + ] + self.assertEqual(expected_calls, self.mock_request.mock_calls) + expected_results = [self.flow1, self.flow2] + self.assertEqual(expected_results, ret) + + def test_get_all_flows_returns_flows_if_only_one_page_but_limited_results_requested( + self, + ): + self.mock_request.return_value.json.return_value = { + "count": 2, + "next": None, + "previous": None, + "results": [self.flow1, self.flow2], + } + + ret = self.client.get_all_flows("my_app", limit=1) + + expected_calls = [ + call("GET", "/logbook/v1/logbooks/my_app/flows"), + call().json(), + ] + self.assertEqual(expected_calls, self.mock_request.mock_calls) + expected_results = [self.flow1] + self.assertEqual(expected_results, ret) + + def test_get_all_flows_returns_flows_if_more_than_one_page(self): + self.mock_request.return_value.json.side_effect = [ + { + "count": 101, + "next": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=50", + "previous": None, + "results": [self.flow1, self.flow2] * 25, + }, + { + "count": 101, + "next": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=100", + "previous": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=50", + "results": [self.flow1, self.flow2] * 25, + }, + { + "count": 101, + "next": None, + "previous": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=100", + "results": [self.flow1], + }, + ] + + ret = self.client.get_all_flows("my_app") + + expected_calls = [ + call("GET", "/logbook/v1/logbooks/my_app/flows"), + call().json(), + call("GET", "/logbook/v1/logbooks/my_app/flows/?limit=50&offset=50"), + call().json(), + call("GET", "/logbook/v1/logbooks/my_app/flows/?limit=50&offset=100"), + call().json(), + ] + self.assertEqual(expected_calls, self.mock_request.mock_calls) + expected_results = [self.flow1, self.flow2] * 50 + [self.flow1] + self.assertEqual(expected_results, ret) + + def test_get_all_flows_returns_flows_if_more_than_one_page_but_limited_results_requested( + self, + ): + self.mock_request.return_value.json.side_effect = [ + { + "count": 101, + "next": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=50", + "previous": None, + "results": [self.flow1, self.flow2] * 25, + }, + { + "count": 101, + "next": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=100", + "previous": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=50", + "results": [self.flow1, self.flow2] * 25, + }, + { + "count": 101, + "next": None, + "previous": "https://api.hypernode.com/logbook/v1/logbooks/my_app/flows/?limit=50&offset=100", + "results": [self.flow1], + }, + ] + + ret = self.client.get_all_flows("my_app", limit=51) + + expected_calls = [ + call("GET", "/logbook/v1/logbooks/my_app/flows"), + call().json(), + call("GET", "/logbook/v1/logbooks/my_app/flows/?limit=50&offset=50"), + call().json(), + ] + self.assertEqual(expected_calls, self.mock_request.mock_calls) + expected_results = [self.flow1, self.flow2] * 25 + [self.flow1] + self.assertEqual(expected_results, ret) diff --git a/tests/client/test_get_app_flavor.py b/tests/client/test_get_app_flavor.py index 0b63704..3cdaa33 100644 --- a/tests/client/test_get_app_flavor.py +++ b/tests/client/test_get_app_flavor.py @@ -16,7 +16,7 @@ def setUp(self): def test_app_flavor_endpoint_is_correct(self): self.assertEqual("/v2/app/{}/flavor/", HYPERNODE_API_APP_FLAVOR_ENDPOINT) - def test_calls_app_flavor_endpoint_propertly(self): + def test_calls_app_flavor_endpoint_properly(self): self.client.get_app_flavor("my_app") self.mock_request.assert_called_once_with("GET", "/v2/app/my_app/flavor/") diff --git a/tests/client/test_get_block_attack_descriptions.py b/tests/client/test_get_block_attack_descriptions.py new file mode 100644 index 0000000..ebd3275 --- /dev/null +++ b/tests/client/test_get_block_attack_descriptions.py @@ -0,0 +1,37 @@ +from unittest.mock import Mock + +from tests.testcase import TestCase +from hypernode_api_python.client import ( + HypernodeAPIPython, + HYPERNODE_API_BLOCK_ATTACK_DESCRIPTION_ENDPOINT, +) + + +class TestGetBlockAttackDescriptions(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="mytoken") + self.mock_request = Mock() + self.client.requests = self.mock_request + + def test_block_attack_descriptions_endpoint_is_correct(self): + self.assertEqual( + "/v2/app/block_attack_descriptions/", + HYPERNODE_API_BLOCK_ATTACK_DESCRIPTION_ENDPOINT, + ) + + def test_calls_block_attack_descriptions_detail_endpoint_properly(self): + self.client.get_block_attack_descriptions() + + self.mock_request.assert_called_once_with( + "GET", "/v2/app/block_attack_descriptions/" + ) + + def test_returns_block_attack_descriptions(self): + response = Mock() + response.status_code = 200 + self.mock_request.return_value = response + + self.assertEqual( + self.client.get_block_attack_descriptions(), + self.mock_request.return_value, + ) diff --git a/tests/client/test_get_cluster_relations.py b/tests/client/test_get_cluster_relations.py new file mode 100644 index 0000000..4b5b7ec --- /dev/null +++ b/tests/client/test_get_cluster_relations.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock + +from tests.testcase import TestCase +from hypernode_api_python.client import ( + HypernodeAPIPython, + HYPERNODE_API_APP_CLUSTER_RELATIONS, +) + + +class TestGetClusterRelations(TestCase): + def setUp(self): + self.mock_request = Mock() + self.client = HypernodeAPIPython(token="mytoken") + self.client.requests = self.mock_request + + def test_calls_hypernode_api_cluster_relations_endpoint_with_correct_parameters( + self, + ): + self.client.get_cluster_relations("yourhypernodeappname") + + self.mock_request.assert_called_once_with( + "GET", HYPERNODE_API_APP_CLUSTER_RELATIONS.format("yourhypernodeappname") + ) + + def test_returns_result_for_hypernode_api_cluster_relations(self): + ret = self.client.get_cluster_relations("yourhypernodeappname") + + self.assertEqual(ret, self.mock_request.return_value) diff --git a/tests/client/test_get_flows.py b/tests/client/test_get_flows.py new file mode 100644 index 0000000..8fcb2d4 --- /dev/null +++ b/tests/client/test_get_flows.py @@ -0,0 +1,31 @@ +from unittest.mock import Mock + +from tests.testcase import TestCase +from hypernode_api_python.client import ( + HypernodeAPIPython, + HYPERNODE_API_APP_FLOWS_ENDPOINT, +) + + +class TestGetFlows(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="mytoken") + self.mock_request = Mock() + self.client.requests = self.mock_request + + def test_flows_endpoint_is_correct(self): + self.assertEqual( + "/logbook/v1/logbooks/{}/flows", HYPERNODE_API_APP_FLOWS_ENDPOINT + ) + + def test_calls_flows_endpoint_properly(self): + self.client.get_flows("my_app") + + self.mock_request.assert_called_once_with( + "GET", "/logbook/v1/logbooks/my_app/flows" + ) + + def test_returns_flows_data(self): + self.assertEqual( + self.client.get_flows("my_app"), self.mock_request.return_value + ) diff --git a/tests/client/test_get_fpm_status.py b/tests/client/test_get_fpm_status.py new file mode 100644 index 0000000..8f54034 --- /dev/null +++ b/tests/client/test_get_fpm_status.py @@ -0,0 +1,39 @@ +from unittest import TestCase +from unittest.mock import Mock + +from hypernode_api_python.client import ( + HypernodeAPIPython, + HYPERNODE_API_FPM_STATUS_APP_ENDPOINT, +) + + +class TestGetFPMStatus(TestCase): + def setUp(self): + self.client = HypernodeAPIPython(token="my_token") + self.mock_request = Mock() + self.client.requests = self.mock_request + self.app_name = "my_app" + + def test_get_fpm_status_endpoint_is_correct(self): + self.assertEqual( + "/v2/nats/{}/hypernode.show-fpm-status", + HYPERNODE_API_FPM_STATUS_APP_ENDPOINT, + ) + + def test_calls_fpm_status_endpoint_properly(self): + self.client.get_fpm_status(self.app_name) + + self.mock_request.assert_called_once_with( + "POST", + f"/v2/nats/{self.app_name}/hypernode.show-fpm-status", + ) + + def test_returns_fpm_status_data(self): + response = Mock() + response.status_code = 200 + self.mock_request.return_value = response + + self.assertEqual( + self.client.get_fpm_status(self.app_name), + self.mock_request.return_value, + ) diff --git a/tests/client/test_get_sla.py b/tests/client/test_get_sla.py new file mode 100644 index 0000000..02a0487 --- /dev/null +++ b/tests/client/test_get_sla.py @@ -0,0 +1,26 @@ +from unittest.mock import Mock + +from tests.testcase import TestCase +from hypernode_api_python.client import ( + HypernodeAPIPython, + HYPERNODE_API_ADDON_LIST_ENDPOINT, +) + + +class TestGetSla(TestCase): + def setUp(self): + self.mock_request = Mock() + self.client = HypernodeAPIPython(token="mytoken") + self.client.requests = self.mock_request + + def test_calls_hypernode_api_endpoint_with_correct_parameters(self): + self.client.get_sla("sla-standard") + + self.mock_request.assert_called_once_with( + "GET", HYPERNODE_API_ADDON_LIST_ENDPOINT + "sla-standard/" + ) + + def test_returns_json_result(self): + ret = self.client.get_sla("sla-standard") + + self.assertEqual(ret, self.mock_request.return_value) diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/test_block_attack.py b/tests/commands/test_block_attack.py new file mode 100644 index 0000000..270ca49 --- /dev/null +++ b/tests/commands/test_block_attack.py @@ -0,0 +1,54 @@ +from unittest.mock import Mock + +from hypernode_api_python.commands import block_attack +from tests.testcase import TestCase + + +class TestBlockAttack(TestCase): + def setUp(self): + self.print = self.set_up_patch("hypernode_api_python.commands.print") + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + self.client.get_block_attack_descriptions.return_value = Mock( + json=lambda: {"BlockSqliBruteForce": "", "BlockDownloaderBruteForce": ""} + ) + self.client.block_attack.return_value = Mock(content="") + + def test_block_attack_gets_client(self): + block_attack(["BlockSqliBruteForce"]) + + self.get_client.assert_called_once_with() + + def test_block_attack_blocks_attack(self): + block_attack(["BlockSqliBruteForce"]) + + self.client.block_attack.assert_called_once_with( + "myappname", "BlockSqliBruteForce" + ) + + def test_block_attack_prints_output_on_success(self): + block_attack(["BlockSqliBruteForce"]) + + self.print.assert_called_once_with( + "A job to block the 'BlockSqliBruteForce' attack has been posted." + ) + + def test_block_attack_prints_output_on_failure(self): + self.client.block_attack.return_value = Mock( + content='{"attack_name":["\\"BlockDownloaderBruteForce\\" is not a valid choice."]}' + ) + + block_attack(["BlockDownloaderBruteForce"]) + + self.print.assert_called_once_with( + '{"attack_name":["\\"BlockDownloaderBruteForce\\" is not a valid choice."]}' + ) + + def test_block_attack_raises_on_invalid_choice(self): + with self.assertRaises(SystemExit): + # We get the valid choices from get_block_attack_descriptions + block_attack(["DoesNotExist"]) diff --git a/tests/commands/test_check_payment_information_for_app.py b/tests/commands/test_check_payment_information_for_app.py new file mode 100644 index 0000000..114cb39 --- /dev/null +++ b/tests/commands/test_check_payment_information_for_app.py @@ -0,0 +1,39 @@ +from hypernode_api_python.commands import check_payment_information_for_app +from tests.testcase import TestCase + + +class TestCheckPaymentInformationForApp(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_check_payment_information_for_app_gets_client(self): + check_payment_information_for_app([]) + + self.get_client.assert_called_once_with() + + def test_check_payment_information_for_app_gets_app_name(self): + check_payment_information_for_app([]) + + self.get_app_name.assert_called_once_with() + + def test_check_payment_information_for_app_gets_current_product_for_app(self): + check_payment_information_for_app([]) + + self.client.check_payment_information_for_app.assert_called_once_with( + "myappname" + ) + + def test_check_payment_information_for_app_prints_current_product_for_app(self): + check_payment_information_for_app([]) + + self.print_response.assert_called_once_with( + self.client.check_payment_information_for_app.return_value + ) diff --git a/tests/commands/test_check_xgrade.py b/tests/commands/test_check_xgrade.py new file mode 100644 index 0000000..8ee612e --- /dev/null +++ b/tests/commands/test_check_xgrade.py @@ -0,0 +1,49 @@ +from hypernode_api_python.commands import check_xgrade +from tests.testcase import TestCase + + +class TestCheckXgrade(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + self.client.get_active_products.return_value.json.return_value = [ + { + "code": "FALCON_S_202203", + }, + { + "code": "JACKAL_M_202201", + }, + ] + + def test_check_xgrade_gets_client(self): + check_xgrade(["FALCON_S_202203"]) + + self.get_client.assert_called_once_with() + + def test_check_xgrade_gets_app_name(self): + check_xgrade(["FALCON_S_202203"]) + + self.get_app_name.assert_called_once_with() + + def test_check_xgrade_gets_product_info(self): + check_xgrade(["FALCON_S_202203"]) + + self.client.check_xgrade.assert_called_once_with("myappname", "FALCON_S_202203") + + def test_check_xgrade_prints_product(self): + check_xgrade(["FALCON_S_202203"]) + + self.print_response.assert_called_once_with( + self.client.check_xgrade.return_value + ) + + def test_check_xgrade_raises_if_product_not_found(self): + with self.assertRaises(SystemExit): + check_xgrade(["ProductThatDoesNotExist"]) diff --git a/tests/commands/test_create_brancher.py b/tests/commands/test_create_brancher.py new file mode 100644 index 0000000..d198775 --- /dev/null +++ b/tests/commands/test_create_brancher.py @@ -0,0 +1,40 @@ +from hypernode_api_python.commands import create_brancher +from tests.testcase import TestCase + + +class TestCreateBrancher(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_create_brancher_gets_client(self): + create_brancher([]) + + self.get_client.assert_called_once_with() + + def test_create_brancher_gets_app_name(self): + create_brancher([]) + + self.get_app_name.assert_called_once_with() + + def test_create_brancher_creates_brancher(self): + create_brancher([]) + + expected_data = {} + self.client.create_brancher.assert_called_once_with( + "myappname", data=expected_data + ) + + def test_create_brancher_prints_response(self): + create_brancher([]) + + self.print_response.assert_called_once_with( + self.client.create_brancher.return_value + ) diff --git a/tests/commands/test_destroy_brancher.py b/tests/commands/test_destroy_brancher.py new file mode 100644 index 0000000..56bfc83 --- /dev/null +++ b/tests/commands/test_destroy_brancher.py @@ -0,0 +1,53 @@ +from os import EX_UNAVAILABLE, EX_OK + +from hypernode_api_python.commands import destroy_brancher +from tests.testcase import TestCase + + +class TestDestroyBrancher(TestCase): + def setUp(self): + self.exit = self.set_up_patch("hypernode_api_python.commands.exit") + self.print = self.set_up_patch("hypernode_api_python.commands.print") + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_destroy_brancher_gets_client(self): + destroy_brancher(["myappname-eph1234"]) + + self.get_client.assert_called_once_with() + + def test_destroy_brancher_destroys_brancher(self): + destroy_brancher(["myappname-eph1234"]) + + self.client.destroy_brancher.assert_called_once_with("myappname-eph1234") + + def test_destroy_brancher_prints_app_name_is_valid(self): + destroy_brancher(["myappname-eph1234"]) + + self.print.assert_called_once_with( + "A job has been posted to cancel the 'myappname-eph1234' brancher app." + ) + + def test_destroy_brancher_prints_app_name_is_invalid(self): + self.client.destroy_brancher.side_effect = MemoryError("Killed") + + destroy_brancher(["myappname-eph1234"]) + + self.print.assert_called_once_with( + "Brancher app 'myappname-eph1234' failed to be cancelled: Killed" + ) + + def test_destroy_brancher_exits_zero_when_cancel_job_posted(self): + destroy_brancher(["myappname-eph1234"]) + + self.exit.assert_called_once_with(EX_OK) + + def test_destroy_brancher_exits_nonzero_when_unexpected_exception_occurs(self): + self.client.destroy_brancher.side_effect = RuntimeError + + destroy_brancher(["myappname-eph1234"]) + + self.exit.assert_called_once_with(EX_UNAVAILABLE) diff --git a/tests/commands/test_get_active_branchers.py b/tests/commands/test_get_active_branchers.py new file mode 100644 index 0000000..c4b2316 --- /dev/null +++ b/tests/commands/test_get_active_branchers.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_active_branchers +from tests.testcase import TestCase + + +class TestGetActiveBranchers(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_active_branchers_gets_client(self): + get_active_branchers([]) + + self.get_client.assert_called_once_with() + + def test_get_active_branchers_gets_app_name(self): + get_active_branchers([]) + + self.get_app_name.assert_called_once_with() + + def test_get_active_branchers_gets_active_branchers(self): + get_active_branchers([]) + + self.client.get_active_branchers.assert_called_once_with("myappname") + + def test_get_active_branchers_prints_active_branchers(self): + get_active_branchers([]) + + self.print_response.assert_called_once_with( + self.client.get_active_branchers.return_value + ) diff --git a/tests/commands/test_get_active_products.py b/tests/commands/test_get_active_products.py new file mode 100644 index 0000000..284fb39 --- /dev/null +++ b/tests/commands/test_get_active_products.py @@ -0,0 +1,28 @@ +from hypernode_api_python.commands import get_active_products +from tests.testcase import TestCase + + +class TestGetActiveProducts(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_get_active_products_gets_client(self): + get_active_products([]) + + self.get_client.assert_called_once_with() + + def test_get_active_products_gets_active_products(self): + get_active_products([]) + + self.client.get_active_products.assert_called_once_with() + + def test_get_active_products_prints_active_products(self): + get_active_products([]) + + self.print_response.assert_called_once_with( + self.client.get_active_products.return_value + ) diff --git a/tests/commands/test_get_app_configurations.py b/tests/commands/test_get_app_configurations.py new file mode 100644 index 0000000..e0dff12 --- /dev/null +++ b/tests/commands/test_get_app_configurations.py @@ -0,0 +1,28 @@ +from hypernode_api_python.commands import get_app_configurations +from tests.testcase import TestCase + + +class TestGetAppConfigurations(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_get_app_configurations_gets_client(self): + get_app_configurations([]) + + self.get_client.assert_called_once_with() + + def test_get_app_configurations_gets_slas(self): + get_app_configurations([]) + + self.client.get_app_configurations.assert_called_once_with() + + def test_get_app_configurations_prints_slas(self): + get_app_configurations([]) + + self.print_response.assert_called_once_with( + self.client.get_app_configurations.return_value + ) diff --git a/tests/commands/test_get_app_flavor.py b/tests/commands/test_get_app_flavor.py new file mode 100644 index 0000000..9493448 --- /dev/null +++ b/tests/commands/test_get_app_flavor.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_app_flavor +from tests.testcase import TestCase + + +class TestGetAppFlavor(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_app_flavor_gets_client(self): + get_app_flavor([]) + + self.get_client.assert_called_once_with() + + def test_get_app_flavor_gets_app_name(self): + get_app_flavor([]) + + self.get_app_name.assert_called_once_with() + + def test_get_app_flavor_gets_app_flavor(self): + get_app_flavor([]) + + self.client.get_app_flavor.assert_called_once_with("myappname") + + def test_get_app_flavor_prints_app_flavor(self): + get_app_flavor([]) + + self.print_response.assert_called_once_with( + self.client.get_app_flavor.return_value + ) diff --git a/tests/commands/test_get_app_info.py b/tests/commands/test_get_app_info.py new file mode 100644 index 0000000..e83ae99 --- /dev/null +++ b/tests/commands/test_get_app_info.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_app_info +from tests.testcase import TestCase + + +class TestGetAppInfo(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_app_info_gets_client(self): + get_app_info([]) + + self.get_client.assert_called_once_with() + + def test_get_app_info_gets_app_name(self): + get_app_info([]) + + self.get_app_name.assert_called_once_with() + + def test_get_app_info_gets_app_info(self): + get_app_info([]) + + self.client.get_app_info_or_404.assert_called_once_with("myappname") + + def test_get_app_info_prints_app_info(self): + get_app_info([]) + + self.print_response.assert_called_once_with( + self.client.get_app_info_or_404.return_value + ) diff --git a/tests/commands/test_get_app_name.py b/tests/commands/test_get_app_name.py new file mode 100644 index 0000000..3b2321a --- /dev/null +++ b/tests/commands/test_get_app_name.py @@ -0,0 +1,29 @@ +from hypernode_api_python.commands import get_app_name +from tests.testcase import TestCase + + +class TestGetAppName(TestCase): + def setUp(self): + self.expected_environment = { + "HYPERNODE_APP_NAME": "myappname", + } + self.set_up_patch( + "hypernode_api_python.commands.environ", self.expected_environment + ) + + def test_get_app_name_returns_app_name(self): + ret = get_app_name() + + self.assertEqual(ret, "myappname") + + def test_get_app_name_raises_value_error_if_no_app_name(self): + del self.expected_environment["HYPERNODE_APP_NAME"] + + with self.assertRaises(ValueError): + get_app_name() + + def test_get_app_name_raises_value_error_if_empty_app_name(self): + self.expected_environment["HYPERNODE_APP_NAME"] = "" + + with self.assertRaises(ValueError): + get_app_name() diff --git a/tests/commands/test_get_available_backups_for_app.py b/tests/commands/test_get_available_backups_for_app.py new file mode 100644 index 0000000..3a8d0e8 --- /dev/null +++ b/tests/commands/test_get_available_backups_for_app.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_available_backups_for_app +from tests.testcase import TestCase + + +class TestGetAvailableBackupsForApp(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_available_backups_for_app_gets_client(self): + get_available_backups_for_app([]) + + self.get_client.assert_called_once_with() + + def test_get_available_backups_for_app_gets_app_name(self): + get_available_backups_for_app([]) + + self.get_app_name.assert_called_once_with() + + def test_get_available_backups_for_app_gets_available_backups_for_app(self): + get_available_backups_for_app([]) + + self.client.get_available_backups_for_app.assert_called_once_with("myappname") + + def test_get_available_backups_for_app_prints_available_backups_for_app(self): + get_available_backups_for_app([]) + + self.print_response.assert_called_once_with( + self.client.get_available_backups_for_app.return_value + ) diff --git a/tests/commands/test_get_block_attack_descriptions.py b/tests/commands/test_get_block_attack_descriptions.py new file mode 100644 index 0000000..e9a161c --- /dev/null +++ b/tests/commands/test_get_block_attack_descriptions.py @@ -0,0 +1,28 @@ +from hypernode_api_python.commands import get_block_attack_descriptions +from tests.testcase import TestCase + + +class TestGetBlockAttackDescriptions(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_get_block_attack_descriptions_gets_client(self): + get_block_attack_descriptions([]) + + self.get_client.assert_called_once_with() + + def test_get_block_attack_descriptions_gets_slas(self): + get_block_attack_descriptions([]) + + self.client.get_block_attack_descriptions.assert_called_once_with() + + def test_get_block_attack_descriptions_prints_slas(self): + get_block_attack_descriptions([]) + + self.print_response.assert_called_once_with( + self.client.get_block_attack_descriptions.return_value + ) diff --git a/tests/commands/test_get_client.py b/tests/commands/test_get_client.py new file mode 100644 index 0000000..81e4d51 --- /dev/null +++ b/tests/commands/test_get_client.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_client +from tests.testcase import TestCase + + +class TestGetClient(TestCase): + def setUp(self): + self.hypernode_api_python = self.set_up_patch( + "hypernode_api_python.commands.HypernodeAPIPython" + ) + self.expected_environment = { + "HYPERNODE_API_TOKEN": "mytoken", + } + self.set_up_patch( + "hypernode_api_python.commands.environ", self.expected_environment + ) + + def test_get_client_instantiates_client(self): + get_client() + + self.hypernode_api_python.assert_called_once_with("mytoken") + + def test_get_client_returns_instantiated_client(self): + ret = get_client() + + self.assertEqual(ret, self.hypernode_api_python.return_value) + + def test_get_client_raises_value_error_if_no_token(self): + del self.expected_environment["HYPERNODE_API_TOKEN"] + + with self.assertRaises(ValueError): + get_client() + + def test_get_client_raises_value_error_if_empty_token(self): + self.expected_environment["HYPERNODE_API_TOKEN"] = "" + + with self.assertRaises(ValueError): + get_client() diff --git a/tests/commands/test_get_cluster_relations.py b/tests/commands/test_get_cluster_relations.py new file mode 100644 index 0000000..c77cb00 --- /dev/null +++ b/tests/commands/test_get_cluster_relations.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_cluster_relations +from tests.testcase import TestCase + + +class TestGetClusterRelations(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_cluster_relations_gets_client(self): + get_cluster_relations([]) + + self.get_client.assert_called_once_with() + + def test_get_cluster_relations_gets_app_name(self): + get_cluster_relations([]) + + self.get_app_name.assert_called_once_with() + + def test_get_cluster_relations_gets_cluster_relations(self): + get_cluster_relations([]) + + self.client.get_cluster_relations.assert_called_once_with("myappname") + + def test_get_cluster_relations_prints_cluster_relations(self): + get_cluster_relations([]) + + self.print_response.assert_called_once_with( + self.client.get_cluster_relations.return_value + ) diff --git a/tests/commands/test_get_current_product_for_app.py b/tests/commands/test_get_current_product_for_app.py new file mode 100644 index 0000000..e19d9d6 --- /dev/null +++ b/tests/commands/test_get_current_product_for_app.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_current_product_for_app +from tests.testcase import TestCase + + +class TestGetCurrentProductForApp(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_current_product_for_app_gets_client(self): + get_current_product_for_app([]) + + self.get_client.assert_called_once_with() + + def test_get_current_product_for_app_gets_app_name(self): + get_current_product_for_app([]) + + self.get_app_name.assert_called_once_with() + + def test_get_current_product_for_app_gets_current_product_for_app(self): + get_current_product_for_app([]) + + self.client.get_current_product_for_app.assert_called_once_with("myappname") + + def test_get_current_product_for_app_prints_current_product_for_app(self): + get_current_product_for_app([]) + + self.print_response.assert_called_once_with( + self.client.get_current_product_for_app.return_value + ) diff --git a/tests/commands/test_get_eav_description.py b/tests/commands/test_get_eav_description.py new file mode 100644 index 0000000..b73fa63 --- /dev/null +++ b/tests/commands/test_get_eav_description.py @@ -0,0 +1,28 @@ +from hypernode_api_python.commands import get_eav_description +from tests.testcase import TestCase + + +class TestGetEavDescription(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_get_eav_description_gets_client(self): + get_eav_description([]) + + self.get_client.assert_called_once_with() + + def test_get_eav_description_gets_slas(self): + get_eav_description([]) + + self.client.get_app_eav_description.assert_called_once_with() + + def test_get_eav_description_prints_slas(self): + get_eav_description([]) + + self.print_response.assert_called_once_with( + self.client.get_app_eav_description.return_value + ) diff --git a/tests/commands/test_get_flows.py b/tests/commands/test_get_flows.py new file mode 100644 index 0000000..ed01b13 --- /dev/null +++ b/tests/commands/test_get_flows.py @@ -0,0 +1,35 @@ +from hypernode_api_python.commands import get_flows +from tests.testcase import TestCase + + +class TestGetFlows(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_flows_gets_client(self): + get_flows([]) + + self.get_client.assert_called_once_with() + + def test_get_flows_gets_app_name(self): + get_flows([]) + + self.get_app_name.assert_called_once_with() + + def test_get_flows_gets_flows(self): + get_flows([]) + + self.client.get_flows.assert_called_once_with("myappname") + + def test_get_flows_prints_flows(self): + get_flows([]) + + self.print_response.assert_called_once_with(self.client.get_flows.return_value) diff --git a/tests/commands/test_get_fpm_status.py b/tests/commands/test_get_fpm_status.py new file mode 100644 index 0000000..88a19a2 --- /dev/null +++ b/tests/commands/test_get_fpm_status.py @@ -0,0 +1,32 @@ +from hypernode_api_python.commands import get_fpm_status +from tests.testcase import TestCase + + +class TestGetFPMStatus(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_fpm_status_gets_client(self): + get_fpm_status([]) + + self.get_client.assert_called_once_with() + + def test_get_fpm_status_gets_fpm_status(self): + get_fpm_status([]) + + self.client.get_fpm_status.assert_called_once_with("myappname") + + def test_get_fpm_status_prints_fpm_status(self): + get_fpm_status([]) + + self.print_response.assert_called_once_with( + self.client.get_fpm_status.return_value + ) diff --git a/tests/commands/test_get_next_best_plan.py b/tests/commands/test_get_next_best_plan.py new file mode 100644 index 0000000..75809c5 --- /dev/null +++ b/tests/commands/test_get_next_best_plan.py @@ -0,0 +1,39 @@ +from hypernode_api_python.commands import get_next_best_plan_for_app +from tests.testcase import TestCase + + +class TestGetNextBestPlan(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_next_best_plan_for_app_gets_client(self): + get_next_best_plan_for_app([]) + + self.get_client.assert_called_once_with() + + def test_get_next_best_plan_for_app_gets_app_name(self): + get_next_best_plan_for_app([]) + + self.get_app_name.assert_called_once_with() + + def test_get_next_best_plan_for_app_gets_next_best_plan_for_app(self): + get_next_best_plan_for_app([]) + + self.client.get_next_best_plan_for_app_or_404.assert_called_once_with( + "myappname" + ) + + def test_get_next_best_plan_for_app_prints_next_best_plan_for_app(self): + get_next_best_plan_for_app([]) + + self.print_response.assert_called_once_with( + self.client.get_next_best_plan_for_app_or_404.return_value + ) diff --git a/tests/commands/test_get_product_info.py b/tests/commands/test_get_product_info.py new file mode 100644 index 0000000..e3a6156 --- /dev/null +++ b/tests/commands/test_get_product_info.py @@ -0,0 +1,42 @@ +from hypernode_api_python.commands import get_product_info +from tests.testcase import TestCase + + +class TestGetProductInfo(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.client.get_active_products.return_value.json.return_value = [ + { + "code": "FALCON_S_202203", + }, + { + "code": "JACKAL_M_202201", + }, + ] + + def test_get_product_info_gets_client(self): + get_product_info(["FALCON_S_202203"]) + + self.get_client.assert_called_once_with() + + def test_get_product_info_gets_product_info_with_price(self): + get_product_info(["FALCON_S_202203"]) + + self.client.get_product_info_with_price.assert_called_once_with( + "FALCON_S_202203" + ) + + def test_get_product_info_prints_product(self): + get_product_info(["FALCON_S_202203"]) + + self.print_response.assert_called_once_with( + self.client.get_product_info_with_price.return_value + ) + + def test_get_product_raises_if_product_not_found(self): + with self.assertRaises(SystemExit): + get_product_info(["ProductThatDoesNotExist"]) diff --git a/tests/commands/test_get_sla.py b/tests/commands/test_get_sla.py new file mode 100644 index 0000000..abbb026 --- /dev/null +++ b/tests/commands/test_get_sla.py @@ -0,0 +1,26 @@ +from hypernode_api_python.commands import get_sla +from tests.testcase import TestCase + + +class TestGetSla(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_get_sla_gets_client(self): + get_sla(["sla-standard"]) + + self.get_client.assert_called_once_with() + + def test_get_sla_gets_sla(self): + get_sla(["sla-standard"]) + + self.client.get_sla.assert_called_once_with("sla-standard") + + def test_get_sla_prints_sla(self): + get_sla(["sla-standard"]) + + self.print_response.assert_called_once_with(self.client.get_sla.return_value) diff --git a/tests/commands/test_get_slas.py b/tests/commands/test_get_slas.py new file mode 100644 index 0000000..d545f13 --- /dev/null +++ b/tests/commands/test_get_slas.py @@ -0,0 +1,26 @@ +from hypernode_api_python.commands import get_slas +from tests.testcase import TestCase + + +class TestGetSlas(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_get_slas_gets_client(self): + get_slas([]) + + self.get_client.assert_called_once_with() + + def test_get_slas_gets_slas(self): + get_slas([]) + + self.client.get_slas.assert_called_once_with() + + def test_get_slas_prints_slas(self): + get_slas([]) + + self.print_response.assert_called_once_with(self.client.get_slas.return_value) diff --git a/tests/commands/test_get_whitelist_options.py b/tests/commands/test_get_whitelist_options.py new file mode 100644 index 0000000..6b989d6 --- /dev/null +++ b/tests/commands/test_get_whitelist_options.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_whitelist_options +from tests.testcase import TestCase + + +class TestGetWhitelistOptions(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_whitelist_options_gets_client(self): + get_whitelist_options([]) + + self.get_client.assert_called_once_with() + + def test_get_whitelist_options_gets_app_name(self): + get_whitelist_options([]) + + self.get_app_name.assert_called_once_with() + + def test_get_whitelist_options_gets_whitelist_options(self): + get_whitelist_options([]) + + self.client.get_whitelist_options.assert_called_once_with("myappname") + + def test_get_whitelist_options_prints_whitelist_options(self): + get_whitelist_options([]) + + self.print_response.assert_called_once_with( + self.client.get_whitelist_options.return_value + ) diff --git a/tests/commands/test_get_whitelist_rules.py b/tests/commands/test_get_whitelist_rules.py new file mode 100644 index 0000000..daca133 --- /dev/null +++ b/tests/commands/test_get_whitelist_rules.py @@ -0,0 +1,37 @@ +from hypernode_api_python.commands import get_whitelist_rules +from tests.testcase import TestCase + + +class TestGetWhitelistRules(TestCase): + def setUp(self): + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + + def test_get_whitelist_rules_gets_client(self): + get_whitelist_rules([]) + + self.get_client.assert_called_once_with() + + def test_get_whitelist_rules_gets_app_name(self): + get_whitelist_rules([]) + + self.get_app_name.assert_called_once_with() + + def test_get_whitelist_rules_gets_whitelist_rules(self): + get_whitelist_rules([]) + + self.client.get_whitelist_rules.assert_called_once_with("myappname") + + def test_get_whitelist_rules_prints_whitelist_rules(self): + get_whitelist_rules([]) + + self.print_response.assert_called_once_with( + self.client.get_whitelist_rules.return_value + ) diff --git a/tests/commands/test_print_response.py b/tests/commands/test_print_response.py new file mode 100644 index 0000000..d72a606 --- /dev/null +++ b/tests/commands/test_print_response.py @@ -0,0 +1,32 @@ +from unittest.mock import Mock + +from hypernode_api_python.commands import print_response +from tests.testcase import TestCase + + +class TestPrintResponse(TestCase): + def setUp(self): + self.response = Mock() + self.response.json.return_value = { + "id": 123, + "code": "sla-standard", + "name": "SLA Standard", + "price": 1234, + "billing_period": 1, + "billing_period_unit": "month", + } + + self.print = self.set_up_patch("hypernode_api_python.commands.print") + + def test_print_response_prints_pretty_response(self): + print_response(self.response) + + self.print.assert_called_once_with( + '{\n "id": 123,\n "code": "sla-standard",\n "name": "SLA Standard",\n ' + '"price": 1234,\n "billing_period": 1,\n "billing_period_unit": "month"\n}' + ) + + def test_print_response_returns_none(self): + ret = print_response(self.response) + + self.assertIsNone(ret) diff --git a/tests/commands/test_validate_app_name.py b/tests/commands/test_validate_app_name.py new file mode 100644 index 0000000..b0c3b73 --- /dev/null +++ b/tests/commands/test_validate_app_name.py @@ -0,0 +1,55 @@ +from os import EX_UNAVAILABLE, EX_OK + +from hypernode_api_python.commands import validate_app_name +from tests.testcase import TestCase + + +class TestValidateAppName(TestCase): + def setUp(self): + self.exit = self.set_up_patch("hypernode_api_python.commands.exit") + self.print = self.set_up_patch("hypernode_api_python.commands.print") + self.print_response = self.set_up_patch( + "hypernode_api_python.commands.print_response" + ) + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + + def test_validate_app_name_gets_client(self): + validate_app_name(["myappname"]) + + self.get_client.assert_called_once_with() + + def test_validate_app_name_validates_app_name(self): + validate_app_name(["myappname"]) + + self.client.validate_app_name.assert_called_once_with("myappname") + + def test_validate_app_name_prints_app_name_is_valid(self): + validate_app_name(["myappname"]) + + self.print.assert_called_once_with("App name 'myappname' is valid.") + + def test_validate_app_name_prints_app_name_is_invalid(self): + self.client.validate_app_name.side_effect = RuntimeError( + "[\"This value can only contain non-capital letters 'a' through 'z' or digits " + '0 through 9."]' + ) + + validate_app_name(["myappname"]) + + self.print.assert_called_once_with( + "App name 'myappname' is invalid: [\"This value can only contain non-capital letters " + "'a' through 'z' or digits 0 through 9.\"]" + ) + + def test_validate_app_name_exits_zero_when_valid(self): + validate_app_name(["myappname"]) + + self.exit.assert_called_once_with(EX_OK) + + def test_validate_app_name_exits_nonzero_when_invalid(self): + self.client.validate_app_name.side_effect = RuntimeError + + validate_app_name(["myappname"]) + + self.exit.assert_called_once_with(EX_UNAVAILABLE) diff --git a/tests/commands/test_xgrade.py b/tests/commands/test_xgrade.py new file mode 100644 index 0000000..cad88ce --- /dev/null +++ b/tests/commands/test_xgrade.py @@ -0,0 +1,62 @@ +from unittest.mock import Mock + +from hypernode_api_python.commands import xgrade +from tests.testcase import TestCase + + +class TestXgrade(TestCase): + def setUp(self): + self.print = self.set_up_patch("hypernode_api_python.commands.print") + self.get_client = self.set_up_patch("hypernode_api_python.commands.get_client") + self.client = self.get_client.return_value + self.get_app_name = self.set_up_patch( + "hypernode_api_python.commands.get_app_name" + ) + self.get_app_name.return_value = "myappname" + self.client.get_xgrade_descriptions.return_value = Mock( + json=lambda: {"FALCON_L_202203": "", "BlockDownloaderBruteForce": ""} + ) + self.client.xgrade.return_value = Mock(content="") + self.client.get_active_products.return_value.json.return_value = [ + { + "code": "FALCON_L_202203", + }, + { + "code": "FALCON_6XL_202203", + }, + ] + + def test_xgrade_gets_client(self): + xgrade(["FALCON_L_202203"]) + + self.get_client.assert_called_once_with() + + def test_xgrade_performs_xgrade(self): + xgrade(["FALCON_L_202203"]) + + self.client.xgrade.assert_called_once_with( + "myappname", data={"product": "FALCON_L_202203"} + ) + + def test_xgrade_prints_output_on_success(self): + xgrade(["FALCON_L_202203"]) + + self.print.assert_called_once_with( + "The job to xgrade Hypernode 'myappname' to product 'FALCON_L_202203' has been posted" + ) + + def test_xgrade_prints_output_on_failure(self): + self.client.xgrade.return_value = Mock( + content='{"product":["Object with code=FALCON_6XL_202203 does not exist."]}' + ) + + xgrade(["FALCON_6XL_202203"]) + + self.print.assert_called_once_with( + '{"product":["Object with code=FALCON_6XL_202203 does not exist."]}' + ) + + def test_xgrade_raises_on_invalid_choice(self): + with self.assertRaises(SystemExit): + # We get the valid choices from get_xgrade_descriptions + xgrade(["DoesNotExist"]) diff --git a/tox.ini b/tox.ini index a596b98..54ee538 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{3.7,3.8,3.9} +envlist = py{3.7,3.8,3.9,3.10,3.11} skipsdist = True skip_missing_interpreters = True @@ -9,6 +9,8 @@ basepython = py3.7: python3.7 py3.8: python3.8 py3.9: python3.9 + py3.10: python3.10 + py3.11: python3.11 deps = -rrequirements/development.txt