From 3bcf4e168dd8e57feae565a7585d5a2eebe58e68 Mon Sep 17 00:00:00 2001 From: RomilShah Date: Thu, 27 Jul 2023 14:37:14 +0530 Subject: [PATCH] :sparkles: feat(secrets): adds v2 secrets This commit introduces support for V2 secrets BREAKING CHANGE: Secret of type "Source Secret" will be depreicated --- riocli/jsonschema/schemas/secret-schema.yaml | 58 +--------- riocli/secret/__init__.py | 5 +- riocli/secret/create.py | 50 --------- riocli/secret/delete.py | 8 +- riocli/secret/docker_secret.py | 44 -------- riocli/secret/import_secret.py | 106 ------------------- riocli/secret/inspect.py | 22 +--- riocli/secret/list.py | 21 ++-- riocli/secret/model.py | 65 ++++-------- riocli/secret/source_secret.py | 80 -------------- riocli/secret/util.py | 18 ++-- riocli/v2client/client.py | 103 ++++++++++++++++++ 12 files changed, 150 insertions(+), 430 deletions(-) delete mode 100644 riocli/secret/create.py delete mode 100644 riocli/secret/docker_secret.py delete mode 100644 riocli/secret/import_secret.py delete mode 100644 riocli/secret/source_secret.py diff --git a/riocli/jsonschema/schemas/secret-schema.yaml b/riocli/jsonschema/schemas/secret-schema.yaml index 09e79d7b..7d2aa98e 100644 --- a/riocli/jsonschema/schemas/secret-schema.yaml +++ b/riocli/jsonschema/schemas/secret-schema.yaml @@ -57,7 +57,6 @@ definitions: default: Docker enum: - Docker - - Git required: - type dependencies: @@ -70,62 +69,6 @@ definitions: docker: type: object "$ref": "#/definitions/docker" - - properties: - type: - enum: - - Git - git: - type: object - "$ref": "#/definitions/git" - - git: - type: object - properties: - authMethod: - type: string - default: HTTP/S Basic Auth - enum: - - HTTP/S Basic Auth - - HTTP/S Token Auth - - SSH Auth - - dependencies: - authMethod: - oneOf: - - properties: - authMethod: - type: string - enum: - - HTTP/S Basic Auth - username: - type: string - password: - type: string - caCert: - type: string - required: - - username - - password - - properties: - authMethod: - type: string - enum: - - HTTP/S Token Auth - token: - type: string - caCert: - type: string - required: - - token - - properties: - authMethod: - type: string - enum: - - SSH Auth - privateKey: - type: string - required: - - privateKey docker: type: object properties: @@ -139,6 +82,7 @@ definitions: email: type: string required: + - registry - username - password - email diff --git a/riocli/secret/__init__.py b/riocli/secret/__init__.py index 94623ed9..22b16d4c 100644 --- a/riocli/secret/__init__.py +++ b/riocli/secret/__init__.py @@ -15,9 +15,8 @@ from click_help_colors import HelpColorsGroup from riocli.constants import Colors -from riocli.secret.create import create_secret + from riocli.secret.delete import delete_secret -from riocli.secret.import_secret import import_secret from riocli.secret.inspect import inspect_secret from riocli.secret.list import list_secrets @@ -35,8 +34,6 @@ def secret() -> None: pass -secret.add_command(create_secret) secret.add_command(delete_secret) secret.add_command(list_secrets) secret.add_command(inspect_secret) -secret.add_command(import_secret) diff --git a/riocli/secret/create.py b/riocli/secret/create.py deleted file mode 100644 index 3f5713e4..00000000 --- a/riocli/secret/create.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2021 Rapyuta Robotics -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import click -from rapyuta_io.clients.secret import DOCKER_HUB_REGISTRY - -from riocli.secret.docker_secret import create_docker_secret -from riocli.secret.source_secret import create_source_secret - - -@click.command('create', hidden=True) -@click.option('--secret-type', '-t', help='Type of Secret', type=click.Choice(['docker', 'source'])) -@click.option('--username', type=str, - help='Docker registry username for docker secret, Git username for source secret') -@click.option('--password', '-p', type=str, - help='Password (only for docker and source with basic auth)') -@click.option('--email', type=str, - help='Email ID for Docker registry') -@click.option('--registry', default=DOCKER_HUB_REGISTRY, type=str, - help='Docker Registry URL for Docker secret [Default: Docker Hub]') -@click.option('--ca-cert', type=click.File(mode='r', lazy=True), - help='Path of CA Certificate (only for source with basic auth)') -@click.option('--ssh-priv-key', type=click.File(mode='r', lazy=True), - help='Path of SSH Key (only for source with ssh auth)') -@click.argument('secret-name', type=str) -def create_secret(secret_type: str, username: str, password: str, email: str, registry: str, - ca_cert: click.File, ssh_priv_key: click.File, secret_name: str) -> None: - """ - Creates a new instance of secret - """ - try: - if secret_type == 'docker': - create_docker_secret(secret_name, username=username, password=password, email=email, - registry=registry) - elif secret_type == 'source': - create_source_secret(secret_name, username=username, password=password, ca_cert=ca_cert, - ssh_key=ssh_priv_key) - except Exception as e: - click.secho(str(e), fg='red') - raise SystemExit(1) diff --git a/riocli/secret/delete.py b/riocli/secret/delete.py index d9d74e5b..a8998631 100644 --- a/riocli/secret/delete.py +++ b/riocli/secret/delete.py @@ -12,12 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. import click +from click_help_colors import HelpColorsCommand -from riocli.config import new_client +from riocli.config import new_v2_client from riocli.constants import Colors, Symbols from riocli.secret.util import name_to_guid from riocli.utils.spinner import with_spinner -from click_help_colors import HelpColorsCommand @click.command( 'delete', @@ -41,8 +41,8 @@ def delete_secret(force: str, secret_name: str, secret_guid: str, spinner=None) abort=True) try: - client = new_client() - client.delete_secret(secret_guid) + client = new_v2_client(with_project=True) + client.delete_secret(secret_name) spinner.text = click.style('Secret deleted successfully.', fg=Colors.GREEN) spinner.green.ok(Symbols.SUCCESS) except Exception as e: diff --git a/riocli/secret/docker_secret.py b/riocli/secret/docker_secret.py deleted file mode 100644 index f10759d7..00000000 --- a/riocli/secret/docker_secret.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2021 Rapyuta Robotics -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import click -from click_spinner import spinner -from rapyuta_io.clients.secret import DOCKER_HUB_REGISTRY, SecretConfigDocker, Secret - -from riocli.config import new_client - - -def create_docker_secret( - secret_name: str, - username: str = None, - password: str = None, - email: str = None, - registry=DOCKER_HUB_REGISTRY, -) -> None: - if not username: - username = click.prompt('docker username') - - if not password: - password = click.prompt('docker password', hide_input=True) - - if not email: - email = click.prompt('docker email') - - secret_config = SecretConfigDocker(username=username, password=password, email=email, - registry=registry) - client = new_client() - - with spinner(): - client.create_secret(Secret(secret_name, secret_config=secret_config)) - - click.secho('Secret created successfully!', fg='green') diff --git a/riocli/secret/import_secret.py b/riocli/secret/import_secret.py deleted file mode 100644 index c0718b5d..00000000 --- a/riocli/secret/import_secret.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright 2021 Rapyuta Robotics -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import base64 -import json -from pathlib import Path - -import click -from click_help_colors import HelpColorsGroup -from click_spinner import spinner -from rapyuta_io import SecretConfigDocker, Secret, SecretConfigSourceSSHAuth - -from riocli.config import new_client -from riocli.utils import random_string, run_bash -from riocli.utils.selector import show_selection - - -@click.group( - 'import', - hidden=True, - invoke_without_command=False, - cls=HelpColorsGroup, - help_headers_color='yellow', - help_options_color='green', -) -def import_secret() -> None: - """ - Imports the secrets from the user environment - """ - pass - - -@import_secret.command('ssh') -def ssh_auto_import() -> None: - """ - Imports the selected private SSH keys from the ~/.ssh directory - """ - secret = secret_from_ssh_dir() - create_secret(secret) - - -@import_secret.command('docker') -def docker_auto_import() -> None: - """ - Imports the authentication tokens for the configured registries - """ - secret = secret_from_docker_config() - create_secret(secret) - - -def create_secret(secret: Secret) -> None: - try: - client = new_client() - with spinner(): - client.create_secret(secret) - click.secho("Secret created successfully!", fg='green') - except Exception as e: - click.secho(str(e), fg='red') - raise SystemExit(1) - - -def secret_from_docker_config() -> Secret: - docker_config_path = Path.home().joinpath(".docker", "config.json") - with open(docker_config_path, 'r') as file: - config = json.load(file) - - if not config or 'auths' not in config: - click.secho("docker config not found!", fg='red') - raise SystemExit(1) - - registries = list(filter(lambda x: 'rapyuta.io' not in x, config['auths'].keys())) - choice = show_selection(registries, header='Found these registries in the docker config') - encoded_auth = str(config['auths'][choice]['auth']) - auth = base64.b64decode(encoded_auth).decode("utf-8") - parts = auth.split(":") - username = str(parts[0]) - password = str(parts[1]) - secret_config = SecretConfigDocker(username=username, password=password, - email='example@example.com', registry=choice) - - return Secret(_generate_secret_name('docker'), secret_config=secret_config) - - -def secret_from_ssh_dir() -> Secret: - cmd = '/bin/bash -c "file ~/.ssh/* | grep \'private key\' | awk -v FS=\':\' \'{print $1}\'"' - files = run_bash(cmd).split('\n') - choice = show_selection(files, header='Found these private SSH keys') - with open(choice) as file: - data = file.read() - - secret_config = SecretConfigSourceSSHAuth(ssh_key=data) - return Secret(_generate_secret_name('ssh'), secret_config=secret_config) - - -def _generate_secret_name(prefix: str) -> str: - return '{}-{}'.format(prefix, random_string(5, 0)).lower() diff --git a/riocli/secret/inspect.py b/riocli/secret/inspect.py index daf4ff0a..278f4ac5 100644 --- a/riocli/secret/inspect.py +++ b/riocli/secret/inspect.py @@ -13,9 +13,9 @@ # limitations under the License. import click from click_help_colors import HelpColorsCommand -from rapyuta_io import Secret +from munch import unmunchify -from riocli.config import new_client +from riocli.config import new_v2_client from riocli.constants import Colors from riocli.secret.util import name_to_guid from riocli.utils import inspect_with_format @@ -36,21 +36,9 @@ def inspect_secret(format_type: str, secret_name: str, secret_guid: str) -> None Inspect a secret """ try: - client = new_client() - secret = client.get_secret(secret_guid) - data = make_secret_inspectable(secret) - inspect_with_format(data, format_type) + client = new_v2_client() + secret = client.get_secret(secret_name) + inspect_with_format(unmunchify(secret), format_type) except Exception as e: click.secho(str(e), fg=Colors.RED) raise SystemExit(1) - - -def make_secret_inspectable(obj: Secret) -> dict: - return { - 'created_at': obj.created_at, - 'creator': obj.creator, - 'guid': obj.guid, - 'name': obj.name, - 'project': obj.project_guid, - 'secret_type': obj.secret_type.value, - } diff --git a/riocli/secret/list.py b/riocli/secret/list.py index c2820fdf..e090ec53 100644 --- a/riocli/secret/list.py +++ b/riocli/secret/list.py @@ -16,7 +16,7 @@ import click from rapyuta_io import Secret -from riocli.config import new_client +from riocli.config import new_v2_client from riocli.utils import tabulate_data from riocli.constants import Colors from click_help_colors import HelpColorsCommand @@ -27,17 +27,15 @@ help_headers_color=Colors.YELLOW, help_options_color=Colors.GREEN, ) -@click.option('--secret-type', '-t', default=['docker', 'source'], multiple=True, - help='Types to filter the list of Secret [default: docker,source]') -def list_secrets(secret_type: typing.Union[str, typing.Tuple[str]]) -> None: +def list_secrets() -> None: """ List the secrets in the selected project """ try: - client = new_client() + client = new_v2_client(with_project=True) secrets = client.list_secrets() secrets = sorted(secrets, key=lambda s: s.name.lower()) - _display_secret_list(secrets, secret_type, show_header=True) + _display_secret_list(secrets, show_header=True) except Exception as e: click.secho(str(e), fg=Colors.RED) raise SystemExit(1) @@ -45,18 +43,13 @@ def list_secrets(secret_type: typing.Union[str, typing.Tuple[str]]) -> None: def _display_secret_list( secrets: typing.List[Secret], - secret_type: typing.Union[str, typing.Tuple[str]], show_header: bool = True, ) -> None: headers = [] if show_header: - headers = ('Secret ID', 'Secret Name', 'Type', 'Created_At', 'Creator') + headers = ('ID', 'Name', 'Created At', 'Creator') - data = [] - for secret in secrets: - for prefix in secret_type: - if secret.secret_type.name.lower().find(prefix) != -1: - data.append([secret.guid, secret.name, secret.secret_type.name.lower(), - secret.created_at, secret.creator]) + data = [ [secret.guid, secret.name, + secret.createdAt, secret.creatorGUID] for secret in secrets ] tabulate_data(data, headers) diff --git a/riocli/secret/model.py b/riocli/secret/model.py index 59ad3ef2..343dae72 100644 --- a/riocli/secret/model.py +++ b/riocli/secret/model.py @@ -12,19 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. import typing +from munch import unmunchify -from rapyuta_io import ( - Client, - Secret as v1Secret, - SecretConfigDocker, - SecretConfigSourceBasicAuth, - SecretConfigSourceSSHAuth, -) +from rapyuta_io import Client +from riocli.config import new_v2_client from riocli.jsonschema.validate import load_schema from riocli.model import Model - class Secret(Model): def __init__(self, *args, **kwargs): self.update(*args, **kwargs) @@ -40,48 +35,28 @@ def find_object(self, client: Client) -> bool: return secret - def create_object(self, client: Client, **kwargs) -> v1Secret: - secret = client.create_secret(self.to_v1()) - return secret - - def update_object(self, client: Client, obj: typing.Any) -> None: - pass + def create_object(self, client: Client, **kwargs) -> typing.Any: + client = new_v2_client() - def delete_object(self, client: Client, obj: typing.Any) -> typing.Any: - client.delete_secret(obj.guid) + # convert to a dict and remove the ResolverCache + # field since it's not JSON serializable + secret = unmunchify(self) + secret.pop("rc", None) + r = client.create_secret(secret) + return unmunchify(r) - def to_v1(self) -> v1Secret: - if self.spec.type == 'Docker': - return self._docker_secret_to_v1() - else: - return self._git_secret_to_v1() + def update_object(self, client: Client, obj: typing.Any) -> typing.Any: + client = new_v2_client() - def _docker_secret_to_v1(self) -> v1Secret: - config = SecretConfigDocker( - self.spec.docker.username, - self.spec.docker.password, - self.spec.docker.email, - self.spec.docker.registry, - ) - return v1Secret(self.metadata.name, config) + secret = unmunchify(self) + secret.pop("rc", None) - def _git_secret_to_v1(self) -> v1Secret: - if self.spec.git.authMethod == 'SSH Auth': - config = SecretConfigSourceSSHAuth(self.spec.git.privateKey) - elif self.spec.git.authMethod == 'HTTP/S Basic Auth': - ca_cert = self.spec.git.get('ca_cert', None) - config = SecretConfigSourceBasicAuth( - self.spec.git.username, - self.spec.git.password, - ca_cert=ca_cert - ) - elif self.spec.git.authMethod == 'HTTP/S Token Auth': - # TODO(ankit): Implement it once SDK has support for it. - raise Exception('token-based secret is not supported yet!') - else: - raise Exception('invalid gitAuthMethod for secret!') + r = client.update_secret(obj.name, secret) + return unmunchify(r) - return v1Secret(self.metadata.name, config) + def delete_object(self, client: Client, obj: typing.Any) -> typing.Any: + client = new_v2_client() + client.delete_secret(obj.name) @classmethod def pre_process(cls, client: Client, d: typing.Dict) -> None: diff --git a/riocli/secret/source_secret.py b/riocli/secret/source_secret.py deleted file mode 100644 index eb10858d..00000000 --- a/riocli/secret/source_secret.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright 2021 Rapyuta Robotics -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import click -from click_spinner import spinner -from rapyuta_io import Secret, SecretConfigSourceSSHAuth, SecretConfigSourceBasicAuth - -from riocli.config import new_client - - -def create_source_secret( - secret_name: str, - username: str = None, - password: str = None, - ca_cert: click.File = None, - ssh_key: click.File = None, -) -> None: - secret_type = click.prompt('Source secret type[basic-auth, ssh]') - if secret_type == 'basic-auth': - create_basic_auth_secret(secret_name, username=username, password=password, ca_cert=ca_cert) - elif secret_type == 'ssh': - create_ssh_secret(secret_name, ssh_key=ssh_key) - else: - click.secho('Invalid Source secret type. Try again!', fg='red') - - -def create_basic_auth_secret( - secret_name: str, - username: str, - password: str, - ca_cert: click.File = None, -) -> None: - if not username: - username = click.prompt('git username') - - if not password: - password = click.prompt('git password', hide_input=True) - - ca_cert_data = None - if ca_cert: - ca_cert_data = ca_cert.read() - - if not ca_cert_data: - click.secho("Empty CA Cert file. Try again with correct file", fg='red') - raise SystemExit(1) - - secret_config = SecretConfigSourceBasicAuth(username=username, password=password, - ca_cert=ca_cert_data) - - client = new_client() - with spinner(): - client.create_secret(Secret(secret_name, secret_config=secret_config)) - click.secho('Secret created successfully!', fg='green') - - -def create_ssh_secret(secret_name: str, ssh_key: click.File = None) -> None: - if not ssh_key: - ssh_key = click.prompt('ssh key path', type=click.File('r', lazy=True)) - - data = ssh_key.read() - if not data: - click.secho("Empty key file. Try again with correct key file", fg='red') - raise SystemExit(1) - - secret_config = SecretConfigSourceSSHAuth(ssh_key=data) - client = new_client() - - with spinner(): - client.create_secret(Secret(secret_name, secret_config=secret_config)) - click.secho('Secret created successfully!', fg='green') diff --git a/riocli/secret/util.py b/riocli/secret/util.py index c939ec6f..5dfdc21d 100644 --- a/riocli/secret/util.py +++ b/riocli/secret/util.py @@ -17,16 +17,16 @@ import click from rapyuta_io import Client -from riocli.config import new_client - +from riocli.config import new_v2_client +from riocli.constants import Colors def name_to_guid(f: typing.Callable) -> typing.Callable: @functools.wraps(f) def decorated(**kwargs: typing.Any): try: - client = new_client() + client = new_v2_client() except Exception as e: - click.secho(str(e), fg='red') + click.secho(str(e), fg=Colors.RED) raise SystemExit(1) name = kwargs.pop('secret_name') @@ -54,6 +54,11 @@ def decorated(**kwargs: typing.Any): def find_secret_guid(client: Client, name: str) -> str: + secret = client.get_secret(name) + return secret.metadata.guid + + +def get_secret_name(client: Client, name: str) -> str: secrets = client.list_secrets() for secret in secrets: if secret.name == name: @@ -62,11 +67,6 @@ def find_secret_guid(client: Client, name: str) -> str: raise SecretNotFound() -def get_secret_name(client: Client, guid: str) -> str: - secret = client.get_secret(guid) - return secret.name - - class SecretNotFound(Exception): def __init__(self, message='secret not found'): self.message = message diff --git a/riocli/v2client/client.py b/riocli/v2client/client.py index 67644766..3005b383 100644 --- a/riocli/v2client/client.py +++ b/riocli/v2client/client.py @@ -332,3 +332,106 @@ def delete_instance_binding(self, instance_name: str, binding_name: str) -> Munc raise Exception("managedservice: {}".format(err_msg)) return munchify(data) + + def create_secret(self, payload: dict) -> Munch: + """ + Create a new secret + """ + url = "{}/v2/secrets/".format(self._host) + headers = self._config.get_auth_header() + response = RestClient(url).method(HttpMethod.POST).headers( + headers).execute(payload=payload) + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("secret: {}".format(err_msg)) + + return munchify(data) + + def delete_secret(self, secret_name: str) -> Munch: + """ + Delete a secret + """ + url = "{}/v2/secrets/{}/".format(self._host, secret_name) + headers = self._config.get_auth_header() + response = RestClient(url).method(HttpMethod.DELETE).headers( + headers).execute() + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("secret: {}".format(err_msg)) + + return munchify(data) + + def list_secrets( + self, + query: dict = None + ) -> Munch: + """ + List all secrets in a project + """ + url = "{}/v2/secrets/".format(self._host) + headers = self._config.get_auth_header() + + params = {} + params.update(query or {}) + + offset, result = 0, [] + while True: + params.update({ + "continue": offset, + "limit": 10, + }) + response = RestClient(url).method(HttpMethod.GET).query_param( + params).headers(headers).execute() + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("secrets: {}".format(err_msg)) + secrets = data.get('items', []) + if not secrets: + break + offset = data['metadata']['continue'] + for secret in secrets: + result.append(secret['metadata']) + + return munchify(result) + + def get_secret( + self, + secret_name: str + ) -> Munch: + """ + Get secret by name + """ + url = "{}/v2/secrets/{}/".format(self._host, secret_name) + headers = self._config.get_auth_header() + + response = RestClient(url).method(HttpMethod.GET).headers(headers).execute() + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("secrets: {}".format(err_msg)) + + return munchify(data) + + def update_secret(self, secret_name: str, spec: dict) -> Munch: + """ + Update a secret + """ + url = "{}/v2/secrets/{}/".format(self._host, secret_name) + headers = self._config.get_auth_header() + response = RestClient(url).method(HttpMethod.PUT).headers( + headers).execute(payload=spec) + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("secret: {}".format(err_msg)) + + return munchify(data) \ No newline at end of file