diff --git a/gradient/api_sdk/clients/__init__.py b/gradient/api_sdk/clients/__init__.py index bbfbb8fe..82cb776d 100644 --- a/gradient/api_sdk/clients/__init__.py +++ b/gradient/api_sdk/clients/__init__.py @@ -8,5 +8,6 @@ from .model_client import ModelsClient from .notebook_client import NotebooksClient from .project_client import ProjectsClient +from .secret_client import SecretsClient from .sdk_client import SdkClient from .tensorboards_client import TensorboardClient diff --git a/gradient/api_sdk/clients/sdk_client.py b/gradient/api_sdk/clients/sdk_client.py index 7c47c546..15f35f47 100644 --- a/gradient/api_sdk/clients/sdk_client.py +++ b/gradient/api_sdk/clients/sdk_client.py @@ -1,5 +1,5 @@ from . import DeploymentsClient, ExperimentsClient, HyperparameterJobsClient, ModelsClient, ProjectsClient, \ - MachinesClient, NotebooksClient + MachinesClient, NotebooksClient, SecretsClient from .job_client import JobsClient from .. import logger as sdk_logger @@ -18,3 +18,4 @@ def __init__(self, api_key, logger=sdk_logger.MuteLogger()): self.projects = ProjectsClient(api_key=api_key, logger=logger) self.machines = MachinesClient(api_key=api_key, logger=logger) self.notebooks = NotebooksClient(api_key=api_key, logger=logger) + self.secrets = SecretsClient(api_key=api_key, logger=logger) diff --git a/gradient/api_sdk/clients/secret_client.py b/gradient/api_sdk/clients/secret_client.py new file mode 100644 index 00000000..3122bc0f --- /dev/null +++ b/gradient/api_sdk/clients/secret_client.py @@ -0,0 +1,59 @@ +from .base_client import BaseClient +from .. import models, repositories +from ...exceptions import ReceivingDataFailedError + +SECRET_ENTITIES = ("cluster", "project", "team") + + +class SecretsClient(BaseClient): + def _validate_entity(self, entity, entity_id): + if entity not in SECRET_ENTITIES: + raise ReceivingDataFailedError("Unknown entity type provided") + if not entity_id and entity != 'team': + raise ReceivingDataFailedError("Entity ID is required") + + def list(self, entity, entity_id): + """List secrets by entity type and ID. + + :param str entity: entity type (ex: team, cluster, project) + :param str entity_id: entity ID + + :returns: list of secrets + :rtype: list[models.Secret] + """ + self._validate_entity(entity, entity_id) + + repository = self.build_repository(repositories.ListSecrets) + secrets = repository.list(entity=entity, entity_id=entity_id) + return secrets + + def set(self, entity, entity_id, name, value): + """Set entity secret. + + :param str entity: entity type (ex: team, cluster, project) + :param str entity_id: entity ID + :param str name: secret name + :param str value: secret value + + :returns: + :rtype: None + """ + self._validate_entity(entity, entity_id) + + repository = self.build_repository(repositories.SetSecret) + repository.set(entity=entity, entity_id=entity_id, name=name, value=value) + + def delete(self, entity, entity_id, name): + """Delete entity secret. + + :param str entity: entity type (ex: team, cluster, project) + :param str entity_id: entity ID + :param str name: secret name + + :returns: + :rtype: None + """ + self._validate_entity(entity, entity_id) + + repository = self.build_repository(repositories.DeleteSecret) + repository.delete(entity=entity, entity_id=entity_id, name=name) diff --git a/gradient/api_sdk/models/__init__.py b/gradient/api_sdk/models/__init__.py index 49bdc657..75a49ac1 100644 --- a/gradient/api_sdk/models/__init__.py +++ b/gradient/api_sdk/models/__init__.py @@ -10,6 +10,7 @@ from .model import Model, ModelFile from .notebook import Notebook, NotebookStart from .project import Project +from .secret import Secret from .tag import Tag from .tensorboard import Instance, Tensorboard from .vm_type import VmType, VmTypeGpuModel diff --git a/gradient/api_sdk/models/secret.py b/gradient/api_sdk/models/secret.py new file mode 100644 index 00000000..6d146589 --- /dev/null +++ b/gradient/api_sdk/models/secret.py @@ -0,0 +1,6 @@ +import attr + + +@attr.s +class Secret(object): + name = attr.ib(type=str, default=None) diff --git a/gradient/api_sdk/repositories/__init__.py b/gradient/api_sdk/repositories/__init__.py index 76564d3c..31592af8 100644 --- a/gradient/api_sdk/repositories/__init__.py +++ b/gradient/api_sdk/repositories/__init__.py @@ -15,4 +15,5 @@ from .notebooks import CreateNotebook, DeleteNotebook, GetNotebook, ListNotebooks, GetNotebookMetrics, \ StreamNotebookMetrics, StopNotebook, StartNotebook, ForkNotebook, ListNotebookArtifacts from .projects import CreateProject, ListProjects, DeleteProject, GetProject +from .secrets import ListSecrets, SetSecret, DeleteSecret from .tensorboards import CreateTensorboard, GetTensorboard, ListTensorboards, UpdateTensorboard, DeleteTensorboard diff --git a/gradient/api_sdk/repositories/secrets.py b/gradient/api_sdk/repositories/secrets.py new file mode 100644 index 00000000..9137e088 --- /dev/null +++ b/gradient/api_sdk/repositories/secrets.py @@ -0,0 +1,55 @@ +from .common import BaseRepository, ListResources +from .. import config, serializers + + +class SecretsMixin(object): + SERIALIZER_CLS = serializers.SecretSchema + + def _get_api_url(self, **kwargs): + return config.config.CONFIG_HOST + + def _resource_url(self, **kwargs): + return "/{}s/secrets/{}".format(kwargs.get("entity"), kwargs.get("name")) + + def _get_request_params(self, kwargs): + params = {} + + entity_id = kwargs.get("entity_id") + if entity_id: + params["{}Id".format(kwargs.get("entity"))] = entity_id + + return params + + +class ListSecrets(SecretsMixin, ListResources): + def get_request_url(self, **kwargs): + return "/{}s/secrets".format(kwargs.get("entity")) + + +class SetSecret(SecretsMixin, BaseRepository): + def get_request_url(self, **kwargs): + return self._resource_url(**kwargs) + + def _get_request_json(self, kwargs): + return {"value": kwargs.get("value")} + + def _send_request(self, client, url, json=None, params=None): + response = client.put(url, json=json, params=params) + return response + + def set(self, **kwargs): + response = self._get(**kwargs) + self._validate_response(response) + + +class DeleteSecret(SecretsMixin, BaseRepository): + def get_request_url(self, **kwargs): + return self._resource_url(**kwargs) + + def _send_request(self, client, url, json=None, params=None): + response = client.delete(url, params=params) + return response + + def delete(self, **kwargs): + response = self._get(**kwargs) + self._validate_response(response) \ No newline at end of file diff --git a/gradient/api_sdk/serializers/__init__.py b/gradient/api_sdk/serializers/__init__.py index fab53f33..53f6243b 100644 --- a/gradient/api_sdk/serializers/__init__.py +++ b/gradient/api_sdk/serializers/__init__.py @@ -9,6 +9,7 @@ from .model import Model, ModelFileSchema from .notebook import NotebookSchema, NotebookStartSchema from .project import Project +from .secret import SecretSchema from .tag import TagSchema from .tensorboard import InstanceSchema, TensorboardSchema, TensorboardDetailSchema from .vm_type import VmTypeSchema, VmTypeGpuModelSchema diff --git a/gradient/api_sdk/serializers/secret.py b/gradient/api_sdk/serializers/secret.py new file mode 100644 index 00000000..c5f62a9e --- /dev/null +++ b/gradient/api_sdk/serializers/secret.py @@ -0,0 +1,10 @@ +import marshmallow as ma + +from .. import models +from ..serializers.base import BaseSchema + + +class SecretSchema(BaseSchema): + MODEL = models.Secret + + name = ma.fields.Str() diff --git a/gradient/cli/__init__.py b/gradient/cli/__init__.py index 1105b317..c72c5aae 100644 --- a/gradient/cli/__init__.py +++ b/gradient/cli/__init__.py @@ -14,6 +14,7 @@ import gradient.cli.notebooks import gradient.cli.projects import gradient.cli.run +import gradient.cli.secrets import gradient.cli.tensorboards diff --git a/gradient/cli/secrets.py b/gradient/cli/secrets.py new file mode 100644 index 00000000..657e91fa --- /dev/null +++ b/gradient/cli/secrets.py @@ -0,0 +1,88 @@ +import click + +from gradient.cli import common +from gradient.cli.cli import cli +from gradient.cli.common import ClickGroup, api_key_option +from gradient.commands.secrets import DeleteSecretCommand, ListSecretsCommand, SetSecretCommand +from gradient.api_sdk.clients.secret_client import SECRET_ENTITIES + + +class EntityId(common.GradientOption): + def handle_parse_result(self, ctx, opts, args): + self.required = opts.get('entity') != 'team' + return super(EntityId, self).handle_parse_result(ctx, opts, args) + + +@cli.group("secrets", help="Manage secrets", cls=ClickGroup) +def secrets(): + pass + + +@secrets.command("list", help="List secrets") +@click.argument("entity", type=click.Choice(SECRET_ENTITIES)) +@click.option( + "--id", + "entity_id", + help="Entity ID", + cls=EntityId, +) +@api_key_option +@common.options_file +def get_secrets_list(api_key, entity, entity_id, options_file): + command = ListSecretsCommand(api_key=api_key) + command.execute(entity=entity, entity_id=entity_id) + + +@secrets.command("set", help="Set secret") +@click.argument("entity", type=click.Choice(SECRET_ENTITIES)) +@click.option( + "--id", + "entity_id", + help="Entity ID", + cls=EntityId, +) +@click.option( + "--name", + "name", + prompt=True, + required=True, + help="Secret name", + cls=common.GradientOption, +) +@click.option( + "--value", + "value", + hide_input=True, + prompt=True, + required=True, + help="Secret value", + cls=common.GradientOption, +) +@api_key_option +@common.options_file +def set_secret(api_key, entity, entity_id, name, value, options_file): + command = SetSecretCommand(api_key=api_key) + command.execute(entity=entity, entity_id=entity_id, name=name, value=value) + + +@secrets.command("delete", help="Delete secret") +@click.argument("entity", type=click.Choice(SECRET_ENTITIES)) +@click.option( + "--id", + "entity_id", + help="Entity ID", + cls=EntityId, +) +@click.option( + "--name", + "name", + prompt=True, + required=True, + help="Secret name", + cls=common.GradientOption, +) +@api_key_option +@common.options_file +def delete_secret(api_key, entity, entity_id, name, options_file): + command = DeleteSecretCommand(api_key=api_key) + command.execute(entity=entity, entity_id=entity_id, name=name) \ No newline at end of file diff --git a/gradient/commands/secrets.py b/gradient/commands/secrets.py new file mode 100644 index 00000000..0a6091ff --- /dev/null +++ b/gradient/commands/secrets.py @@ -0,0 +1,46 @@ +import abc + +import six + +from gradient import api_sdk +from gradient.cli_constants import CLI_PS_CLIENT_NAME +from gradient.commands.common import BaseCommand, ListCommandMixin + + +@six.add_metaclass(abc.ABCMeta) +class BaseSecretsCommand(BaseCommand): + def _get_client(self, api_key, logger): + client = api_sdk.clients.SecretsClient( + api_key=api_key, + logger=logger, + ps_client_name=CLI_PS_CLIENT_NAME, + ) + return client + + +class ListSecretsCommand(ListCommandMixin, BaseSecretsCommand): + def _get_instances(self, kwargs): + instances = self.client.list(**kwargs) + return instances + + def _get_table_data(self, objects): + """ + :param list[Secret] objects: object + """ + data = [("Name",)] + for secret in objects: + data.append((secret.name,)) + + return data + + +class SetSecretCommand(BaseSecretsCommand): + def execute(self, entity, entity_id, name, value): + self.client.set(entity=entity, entity_id=entity_id, name=name, value=value) + self.logger.log("Set {} secret '{}'".format(entity, name)) + + +class DeleteSecretCommand(BaseSecretsCommand): + def execute(self, entity, entity_id, name): + self.client.delete(entity=entity, entity_id=entity_id, name=name) + self.logger.log("Deleted {} secret '{}'".format(entity, name)) \ No newline at end of file diff --git a/setup.py b/setup.py index d138dcd1..d801aba4 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def run(self): 'PyYAML==5.*', 'python-dateutil==2.*', 'websocket-client==0.57.*', - 'gradient-utils>=0.1.1', + 'gradient-utils>=0.1.2', ], entry_points={'console_scripts': [ 'gradient = gradient:main.main', diff --git a/tests/example_responses.py b/tests/example_responses.py index 32b0f643..04450ccd 100644 --- a/tests/example_responses.py +++ b/tests/example_responses.py @@ -8062,3 +8062,12 @@ "message": "PSEOF" } ] + +LIST_SECRETS_RESPONSE = [ + { + "name": "aws_access_key_id" + }, + { + "name": "aws_secret_access_key" + } +] diff --git a/tests/functional/test_secrets.py b/tests/functional/test_secrets.py new file mode 100644 index 00000000..7b7b3269 --- /dev/null +++ b/tests/functional/test_secrets.py @@ -0,0 +1,123 @@ +import mock +from click.testing import CliRunner + +from gradient.api_sdk.clients import http_client +from gradient.cli import cli +from tests import example_responses, MockResponse + +EXPECTED_HEADERS = http_client.default_headers.copy() +EXPECTED_HEADERS["ps_client_name"] = "gradient-cli" + +EXPECTED_HEADERS_WITH_CHANGED_API_KEY = EXPECTED_HEADERS.copy() +EXPECTED_HEADERS_WITH_CHANGED_API_KEY["X-API-Key"] = "some_key" + +URL = "https://api.paperspace.io" + + +class TestSecretsList(object): + COMMAND = ["secrets", "list"] + + LIST_SECRETS = example_responses.LIST_SECRETS_RESPONSE + + LIST_STDOUT = """+-----------------------+ +| Name | ++-----------------------+ +| aws_access_key_id | +| aws_secret_access_key | ++-----------------------+ +""" + + @mock.patch("gradient.api_sdk.clients.http_client.requests.get") + def test_should_send_get_request_and_print_list_of_secrets(self, get_patched): + get_patched.return_value = MockResponse(self.LIST_SECRETS) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND + ["team"]) + + assert self.LIST_STDOUT in result.output, result.exc_info + get_patched.assert_called_once_with(URL + "/teams/secrets", + headers=EXPECTED_HEADERS, + json=None, + params={}) + + @mock.patch("gradient.api_sdk.clients.http_client.requests.get") + def test_should_send_get_request_and_print_list_of_secrets_with_id(self, get_patched): + get_patched.return_value = MockResponse(self.LIST_SECRETS) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND + ["team", "--id", "te1234567"]) + + assert self.LIST_STDOUT in result.output, result.exc_info + get_patched.assert_called_once_with(URL + "/teams/secrets", + headers=EXPECTED_HEADERS, + json=None, + params={"teamId": "te1234567"}) + + +class TestSecretsSet(object): + COMMAND = ["secrets", "set"] + + SET_STDOUT = "Set {} secret 'aws_access_key_id'\n" + + @mock.patch("gradient.api_sdk.clients.http_client.requests.put") + def test_should_send_put_request_and_print_status(self, put_patched): + put_patched.return_value = MockResponse() + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND + ["team", "--name=aws_access_key_id", "--value=secret"]) + + assert self.SET_STDOUT.format("team") in result.output, result.exc_info + put_patched.assert_called_once_with(URL + "/teams/secrets/aws_access_key_id", + headers=EXPECTED_HEADERS, + params={}, + json={"value": "secret"}, + data=None) + + + @mock.patch("gradient.api_sdk.clients.http_client.requests.put") + def test_should_send_put_request_and_print_status_with_id(self, put_patched): + put_patched.return_value = MockResponse() + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND + ["project", "--id=pr1234567", + "--name=aws_access_key_id", "--value=secret"]) + + assert self.SET_STDOUT.format("project") in result.output, result.exc_info + put_patched.assert_called_once_with(URL + "/projects/secrets/aws_access_key_id", + headers=EXPECTED_HEADERS, + params={"projectId": "pr1234567"}, + json={"value": "secret"}, + data=None) + + +class TestSecretsDelete(object): + COMMAND = ["secrets", "delete"] + + SET_STDOUT = "Deleted cluster secret 'aws_secret_access_key'\n" + + @mock.patch("gradient.api_sdk.clients.http_client.requests.delete") + def test_should_send_delete_request_and_print_status_with_id(self, delete_patched): + delete_patched.return_value = MockResponse() + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND + ["team", "--name=aws_secret_access_key"]) + + assert self.SET_STDOUT in result.output, result.exc_info + delete_patched.assert_called_once_with(URL + "/teams/secrets/aws_secret_access_key", + headers=EXPECTED_HEADERS, + params={}, + json=None) + + @mock.patch("gradient.api_sdk.clients.http_client.requests.delete") + def test_should_send_delete_request_and_print_status_with_id(self, delete_patched): + delete_patched.return_value = MockResponse() + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND + ["cluster", "--id=cl1234567", + "--name=aws_secret_access_key"]) + + assert self.SET_STDOUT in result.output, result.exc_info + delete_patched.assert_called_once_with(URL + "/clusters/secrets/aws_secret_access_key", + headers=EXPECTED_HEADERS, + params={"clusterId": "cl1234567"}, + json=None)