Skip to content

Commit

Permalink
CLI Updates (#2747)
Browse files Browse the repository at this point in the history
* Add invoke action commands

* Add shell completion for shared service IDs

* Remove TODO comment

* Add basic table query for overall costs

* Add migrations command

* Handle missing app role name

* Add raw output option

* Add airlock request title

* airlock -> airlock-request and fix error text

* Fix airlock request id shell completion

* Add table format for airlock request submit response

* Fix up airlock request review URL

* Add example airlock-request creation

* Add CLI changes to CHANGELOG

* Add table format to airlock review

* Fix table output for airlock list

* Add airlock-request cancel command

* Add TODO comments

* Add workspace service delete command

* Update comment on table formatting

* Update output command to take response and exit on error

* Update api_client typing to avoid private module import

* Add type annotations to shell_complete methods
  • Loading branch information
stuartleeks authored Oct 21, 2022
1 parent a3022dc commit ac7261d
Show file tree
Hide file tree
Showing 32 changed files with 420 additions and 90 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ENHANCEMENTS:
* Upgrade Github Actions versions ([#2731](https://github.com/microsoft/AzureTRE/pull/2744))
* Install TRE CLI inside the devcontainer image (rather than via a post-create step) ([#2757](https://github.com/microsoft/AzureTRE/pull/2757))
* Upgrade Terraform to 1.3.2 ([#2758](https://github.com/microsoft/AzureTRE/pull/2758))
* `tre` CLI: added `raw` output option, improved `airlock-requests` handling, more consistent exit codes on error, added examples to CLI README.md

BUG FIXES:

Expand Down
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.54"
__version__ = "0.4.55"
2 changes: 1 addition & 1 deletion api_app/services/aad_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def get_workspace_role_assignment_details(self, workspace: Workspace):

if principal_type == "User" and principal_id in user_emails:
app_role_id = role_assignment["appRoleId"]
app_role_name = inverted_app_role_ids[app_role_id]
app_role_name = inverted_app_role_ids.get(app_role_id)

if app_role_name:
workspace_role_assignments_details[app_role_name].append(user_emails[principal_id])
Expand Down
34 changes: 33 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,15 @@ The commands corresponding to these asynchronous operations will poll this resul

### Output formats

Most commands support formatting output as `json` (default), `table`, or `none` via the `--output` option. This can also be controlled using the `TRECLI_OUTPUT` environment variable, i.e. set `TRECLI_OUTPUT` to `table` to default to the table output format.
Most commands support formatting output as `table` (default), `json`, `jsonc`, `raw`, or `none` via the `--output` option. This can also be controlled using the `TRECLI_OUTPUT` environment variable, i.e. set `TRECLI_OUTPUT` to `table` to default to the table output format.

| Option | Description |
| ------- | ----------------------------------------------------------------------------- |
| `table` | Works well for interactive use |
| `json` | Plain JSON output, ideal for parsing via `jq` or other tools |
| `jsonc` | Coloured, formatted JSON |
| `raw` | Results are output as-is. Useful with `--query` when capturing a single value |
| `none` | No output |

### Querying output

Expand Down Expand Up @@ -211,3 +219,27 @@ Or you can load the content from a file that contains embedded environment varia
When you run `tre login` you specify the base URL for the API, but when you are developing AzureTRE you may want to make calls against the locally running API.

To support this, you can set the `TRECLI_BASE_URL` environment variable and that will override the API endpoint used by the CLI.


## Example usage

### Creating an import airlock request

```bash
# Set the ID of the workspace to create the import request for
WORKSPACE_ID=__ADD_ID_HERE__

# Create the airlock request - change the justification as appropriate
request=$(tre workspace $WORKSPACE_ID airlock-requests new --type import --title "Ant" --justification "It's import-ant" --output json)
request_id=$(echo $request | jq -r .airlockRequest.id)

# Get the storage upload URL
upload_url=$(tre workspace $WORKSPACE_ID airlock-request $request_id get-url --query containerUrl --output raw)

# Use the az CLI to upload ant.txt from the current directory (change as required)
az storage blob upload-batch --source . --pattern ant.txt --destination $upload_url

# Submit the request for review
tre workspace $WORKSPACE_ID airlock-request $request_id submit

```
4 changes: 3 additions & 1 deletion cli/tre/api_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import sys
from typing import Union
import click
import json
import msal
Expand Down Expand Up @@ -80,11 +81,12 @@ def call_api(
json_data=None,
scope_id: str = None,
throw_on_error: bool = True,
params: "Union[dict[str, str], None]" = None
) -> Response:
with Client(verify=self.verify) as client:
headers = headers.copy()
headers['Authorization'] = f"Bearer {self.get_auth_token(log, scope_id)}"
response = client.request(method, f'{self.base_url}{url}', headers=headers, json=json_data)
response = client.request(method, f'{self.base_url}{url}', headers=headers, json=json_data, params=params)
if throw_on_error and response.is_error:
error_info = {
'status_code': response.status_code,
Expand Down
10 changes: 6 additions & 4 deletions cli/tre/commands/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ def costs_overall(from_date, to_date, granularity, output_format, query):
url = url + "?" + query_string

response = client.call_api(log, 'GET', url)
# TODO - default table format
output(
response.text,
response,
output_format=output_format,
# To properly flatten the costs structure for table rendering, we need `let`
# as per https://jmespath.site/#wiki-lexical-scopes. For now:
default_table_query="[{category: 'core_services', id: null, name: null, costs: core_services}, shared_services[].{category:'shared_service', id:id, name:name, costs:costs}, workspaces[].{category:'workspace', id:id, name:name, costs:costs}] | []",
query=query)
return response.text

Expand Down Expand Up @@ -80,9 +82,9 @@ def workspace_costs(workspace_id, from_date, to_date, granularity, output_format
url = url + "?" + query_string

response = client.call_api(log, 'GET', url)
# TODO - default table format
# TODO - default table format (needs JMESPath let, as per https://jmespath.site/#wiki-lexical-scopes)
output(
response.text,
response,
output_format=output_format,
query=query)
return response.text
Expand Down
2 changes: 1 addition & 1 deletion cli/tre/commands/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def health(output_format, query) -> None:
client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', '/api/health')
output(
response.text,
response,
output_format=output_format,
query=query,
default_table_query="services")
Expand Down
24 changes: 24 additions & 0 deletions cli/tre/commands/migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import sys
import click
import logging

from tre.api_client import ApiClient
from tre.output import output, output_option, query_option


@click.command(name="migrations", help="Run migrations")
@output_option()
@query_option()
def migrations(output_format, query) -> None:
log = logging.getLogger(__name__)

client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'POST', '/api/migrations')
output(
response,
output_format=output_format,
query=query,
default_table_query="migrations")

if not response.is_success:
sys.exit(1)
9 changes: 5 additions & 4 deletions cli/tre/commands/operation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from logging import Logger
import sys
from time import sleep

Expand All @@ -6,7 +7,7 @@
from tre.output import output


def get_operation_id_completion(ctx, log, list_url, param, incomplete, scope_id: str = None):
def get_operation_id_completion(ctx: click.Context, log: Logger, list_url: str, param: click.Parameter, incomplete: str, scope_id: str = None):
client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', list_url, scope_id=scope_id)
if response.is_success:
Expand Down Expand Up @@ -85,8 +86,8 @@ def operation_show(log, operation_url, no_wait, output_format, query, suppress_o
action = response_json['operation']['action']
state = response_json['operation']['status']

if not suppress_output:
output(response.text, output_format=output_format, query=query, default_table_query=default_operation_table_query_single())
if not suppress_output or not response.is_success:
output(response, output_format=output_format, query=query, default_table_query=default_operation_table_query_single())

if wait_for_completion and not is_operation_state_success(state):
sys.exit(1)
Expand All @@ -103,4 +104,4 @@ def operations_list(log, operations_url, output_format, query, scope_id: str = N
operations_url,
scope_id=scope_id
)
output(response.text, output_format=output_format, query=query, default_table_query=default_operation_table_query_list())
output(response, output_format=output_format, query=query, default_table_query=default_operation_table_query_list())
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from .contexts import SharedServiceTemplateContext, pass_shared_service_template_context


def template_name_completion(ctx, param, incomplete):
def template_name_completion(ctx: click.Context, param: click.Parameter, incomplete: str):
log = logging.getLogger(__name__)
client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', '/api/shared-service-templates')
Expand Down Expand Up @@ -41,7 +41,7 @@ def shared_service_template_show(shared_service_template_context: SharedServiceT
f'/api/shared-service-templates/{template_name}',
)

output(response.text, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")


shared_service_template.add_command(shared_service_template_show)
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def shared_service_templates_list(output_format, query):
'GET',
'/api/shared-service-templates',
)
output(response.text, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")


shared_service_templates.add_command(shared_service_templates_list)

# TODO register shared service template
2 changes: 1 addition & 1 deletion cli/tre/commands/shared_services/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .contexts import pass_shared_service_operation_context, SharedServiceOperationContext


def operation_id_completion(ctx, param, incomplete):
def operation_id_completion(ctx: click.Context, param: click.Parameter, incomplete: str):
log = logging.getLogger(__name__)
parent_ctx = ctx.parent
workspace_id = parent_ctx.params["workspace_id"]
Expand Down
19 changes: 13 additions & 6 deletions cli/tre/commands/shared_services/shared_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@
from .operations import shared_service_operations


def shared_service_id_completion(ctx: click.Context, param: click.Parameter, incomplete: str):
log = logging.getLogger(__name__)
client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', '/api/shared-services')
if response.is_success:
ids = [shared_service["id"] for shared_service in response.json()["sharedServices"]]
return [id for id in ids if id.startswith(incomplete)]


@click.group(invoke_without_command=True, help="Perform actions on an individual shared_service")
@click.argument('shared_service_id', required=True, type=click.UUID)
@click.argument('shared_service_id', required=True, type=click.UUID, shell_complete=shared_service_id_completion)
@click.pass_context
def shared_service(ctx: click.Context, shared_service_id: str) -> None:
ctx.obj = SharedServiceContext(shared_service_id)
Expand All @@ -30,12 +39,10 @@ def shared_service_show(shared_service_context: SharedServiceContext, output_for

client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', f'/api/shared-services/{shared_service_id}', )
output(response.text, output_format=output_format, query=query, default_table_query=r"sharedServices[].{id:id,name:templateName, version:templateVersion, is_enabled:isEnabled, status: deploymentStatus}")
output(response, output_format=output_format, query=query, default_table_query=r"sharedServices[].{id:id,name:templateName, version:templateVersion, is_enabled:isEnabled, status: deploymentStatus}")


# TODO - add PATCH (and ?set-enabled)
# TODO - invoke action


@click.command(name="invoke-action", help="Invoke an action on a shared_service")
@click.argument('action-name', required=True)
Expand All @@ -61,7 +68,7 @@ def shared_service_invoke_action(shared_service_context: SharedServiceContext, c
f'/api/shared-services/{shared_service_id}/invoke-action?action={action_name}'
)
if no_wait:
output(response.text, output_format=output_format, query=query)
output(response, output_format=output_format, query=query)
else:
operation_url = response.headers['location']
operation_show(log, operation_url, no_wait=False, output_format=output_format, query=query)
Expand Down Expand Up @@ -90,7 +97,7 @@ def shared_service_delete(shared_service_context: SharedServiceContext, ctx: cli
click.echo("Deleting shared_service...\n", err=True)
response = client.call_api(log, 'DELETE', f'/api/shared-services/{shared_service_id}')
if no_wait:
output(response.text, output_format=output_format, query=query)
output(response, output_format=output_format, query=query)
else:
operation_url = response.headers['location']
operation_show(log, operation_url, no_wait=False, output_format=output_format, query=query)
Expand Down
4 changes: 2 additions & 2 deletions cli/tre/commands/shared_services/shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def shared_services_list(output_format, query):

client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', '/api/shared-services')
output(response.text, output_format=output_format, query=query, default_table_query=r"sharedServices[].{id:id,name:templateName, version:templateVersion, is_enabled:isEnabled, status: deploymentStatus}")
output(response, output_format=output_format, query=query, default_table_query=r"sharedServices[].{id:id,name:templateName, version:templateVersion, is_enabled:isEnabled, status: deploymentStatus}")


@click.command(name="new", help="Create a new shared_service")
Expand All @@ -47,7 +47,7 @@ def shared_services_create(ctx, definition, definition_file, no_wait, output_for
response = client.call_api(log, 'POST', '/api/shared-services', json_data=definition_dict)

if no_wait:
output(response.text, output_format=output_format, query=query, default_table_query=default_operation_table_query_single())
output(response, output_format=output_format, query=query, default_table_query=default_operation_table_query_single())
else:
operation_url = response.headers['location']
operation_show(log, operation_url, no_wait=False, output_format=output_format, query=query)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from .contexts import UserResourceTemplateContext, pass_user_resource_template_context


def template_name_completion(ctx, param, incomplete):
def template_name_completion(ctx: click.Context, param: click.Parameter, incomplete: str):
log = logging.getLogger(__name__)
parent_ctx = ctx.parent
workspace_service_name = parent_ctx.params["template_name"]
Expand Down Expand Up @@ -46,7 +46,7 @@ def user_resource_template_show(user_resource_template_context: UserResourceTemp
f'/api/workspace-service-templates/{workspace_service_name}/user-resource-templates/{template_name}',
)

output(response.text, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")


user_resource_template.add_command(user_resource_template_show)
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ def user_resource_templates_list(workspace_service_template_context: WorkspaceSe
'GET',
f'/api/workspace-service-templates/{template_name}/user-resource-templates',
)
output(response.text, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")


user_resource_templates.add_command(user_resource_templates_list)

# TODO register user resource template
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .user_resource_templates.user_resource_template import user_resource_template


def template_name_completion(ctx, param, incomplete):
def template_name_completion(ctx: click.Context, param: click.Parameter, incomplete: str):
log = logging.getLogger(__name__)
client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', '/api/workspace-service-templates')
Expand Down Expand Up @@ -44,7 +44,7 @@ def workspace_service_template_show(workspace_service_template_context: Workspac
f'/api/workspace-service-templates/{template_name}',
)

output(response.text, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")


workspace_service_template.add_command(workspace_service_template_show)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def workspace_service_templates_list(output_format, query):
'GET',
'/api/workspace-service-templates',
)
output(response.text, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")


workspace_service_templates.add_command(workspace_service_templates_list)

# TODO register workspace service template
4 changes: 2 additions & 2 deletions cli/tre/commands/workspace_templates/workspace_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from .contexts import WorkspaceTemplateContext, pass_workspace_template_context


def template_name_completion(ctx, param, incomplete):
def template_name_completion(ctx: click.Context, param: click.Parameter, incomplete: str):
log = logging.getLogger(__name__)
client = ApiClient.get_api_client_from_config()
response = client.call_api(log, 'GET', '/api/workspace-templates')
Expand Down Expand Up @@ -41,7 +41,7 @@ def workspace_template_show(workspace_template_context: WorkspaceTemplateContext
f'/api/workspace-templates/{template_name}',
)

output(response.text, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"{id: id, name:name, title: title, version:version, description:description}")


workspace_template.add_command(workspace_template_show)
5 changes: 4 additions & 1 deletion cli/tre/commands/workspace_templates/workspace_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ def workspace_templates_list(output_format, query):
'GET',
'/api/workspace-templates',
)
output(response.text, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")
output(response, output_format=output_format, query=query, default_table_query=r"templates[].{name:name, title: title, description:description}")


workspace_templates.add_command(workspace_templates_list)


# TODO register workspace template
Loading

0 comments on commit ac7261d

Please sign in to comment.