From 80e054948f57541dc34fb0b8e65cec099dabec5f Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 9 Jun 2026 15:04:52 +0200 Subject: [PATCH 01/27] feat: initial queue implementation --- cloudos_cli/queue/cli.py | 111 +++++- cloudos_cli/queue/queue.py | 217 ++++++++++++ .../queue/create_queue_response.json | 21 ++ tests/test_queue/test_create_queue.py | 326 ++++++++++++++++++ 4 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 tests/test_data/queue/create_queue_response.json create mode 100644 tests/test_queue/test_create_queue.py diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 1bf9f1f6..8e33ead4 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -1,8 +1,9 @@ """CLI commands for Lifebit Platform job queue management.""" +import sys import rich_click as click import json -from cloudos_cli.queue.queue import Queue +from cloudos_cli.queue.queue import Queue, QUEUE_PRESETS from cloudos_cli.utils.resources import ssl_selector from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands @@ -93,3 +94,111 @@ def list_queues(ctx, o.write(json.dumps(my_queues)) print(f'\tJob queue list collected with a total of {len(my_queues)} queues.') print(f'\tJob queue list saved to {outfile}') + + +@queue.command('create') +@click.option('-k', + '--apikey', + help='Your Lifebit Platform API key', + required=True) +@click.option('-c', + '--cloudos-url', + help=(f'The Lifebit Platform url you are trying to access to. Default={CLOUDOS_URL}.'), + default=CLOUDOS_URL, + required=True) +@click.option('--workspace-id', + help='The specific Lifebit Platform workspace id.', + required=True) +@click.option('--label', + help='Name (label) for the new job queue.', + required=True) +@click.option('--description', + help='Short description of the new job queue.', + default='', + required=False) +@click.option('--preset', + help=( + 'Preset template to use. Choices: ' + + ', '.join(QUEUE_PRESETS.keys()) + + '. Default=standard-stable.' + ), + type=click.Choice(list(QUEUE_PRESETS.keys()), case_sensitive=False), + default='standard-stable', + show_default=True, + required=False) +@click.option('--executor', + help='Workflow executor for the queue. Default=nextflow.', + default='nextflow', + show_default=True, + required=False) +@click.option('-y', + '--yes', + 'skip_confirmation', + help='Skip the confirmation prompt and proceed immediately.', + is_flag=True) +@click.option('--disable-ssl-verification', + help=('Disable SSL certificate verification. Please, remember that this option is ' + 'not generally recommended for security reasons.'), + is_flag=True) +@click.option('--ssl-cert', + help='Path to your SSL certificate file.') +@click.option('--profile', help='Profile to use from the config file', default=None) +@click.pass_context +@with_profile_config(required_params=['apikey', 'workspace_id']) +def create_queue(ctx, + apikey, + cloudos_url, + workspace_id, + label, + description, + preset, + executor, + skip_confirmation, + disable_ssl_verification, + ssl_cert, + profile): + """Create a new job queue in a Lifebit Platform workspace using a preset template.""" + + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + + # Resolve the preset to show the user what will be created + preset_info = QUEUE_PRESETS[preset] + ce_name = preset_info['computeEnvironmentName'] + cr = preset_info['computeResources'] + resource_type = cr.get('type', 'EC2') + max_vcpus = cr.get('maxvCpus', 'N/A') + instance_count = len(cr.get('instanceTypes', [])) + template_name = preset_info['templateName'] + + if not skip_confirmation: + click.echo('\nYou are about to create the following job queue:') + click.echo(f' Label : {label}') + click.echo(f' Description : {description or "(none)"}') + click.echo(f' Preset : {template_name}') + click.echo(f' Compute env name : {ce_name}') + click.echo(f' Resource type : {resource_type}') + click.echo(f' Max vCPUs : {max_vcpus}') + click.echo(f' Instance types : {instance_count} types') + click.echo(f' Executor : {executor}') + click.echo(f' Workspace : {workspace_id}') + click.echo('') + if not click.confirm('Proceed with queue creation?'): + click.echo('Aborted.') + sys.exit(0) + + print('Executing queue create...') + j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) + + try: + queue_id = j_queue.create_job_queue( + label=label, + description=description, + preset_name=preset, + executor=executor, + ) + print(f'\tQueue "{label}" created successfully.') + print(f'\tQueue ID : {queue_id}') + print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') + except Exception as e: + print(f'\tError creating queue: {str(e)}') + sys.exit(1) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 3ecffe51..13fbd729 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -9,6 +9,119 @@ from typing import Union from cloudos_cli.clos import Cloudos from cloudos_cli.utils.errors import BadRequestException +from cloudos_cli.utils.requests import retry_requests_post + + +# --------------------------------------------------------------------------- +# Preset templates (match the CloudOS Platform UI presets exactly) +# --------------------------------------------------------------------------- + +_STANDARD_INSTANCE_TYPES = [ + "optimal", + "c4.2xlarge", "c4.4xlarge", "c4.8xlarge", + "c5.xlarge", "c5.2xlarge", "c5.4xlarge", "c5.9xlarge", + "c5.12xlarge", "c5.18xlarge", "c5.24xlarge", "c5.metal", + "m4.xlarge", "m4.2xlarge", "m4.4xlarge", "m4.10xlarge", "m4.16xlarge", + "m5.xlarge", "m5.2xlarge", "m5.4xlarge", "m5.8xlarge", + "m5.12xlarge", "m5.16xlarge", "m5.24xlarge", "m5.metal", + "r4.xlarge", "r4.2xlarge", "r4.4xlarge", "r4.8xlarge", "r4.16xlarge", + "r5.xlarge", "r5.2xlarge", "r5.4xlarge", "r5.8xlarge", + "r5.12xlarge", "r5.16xlarge", "r5.24xlarge", "r5.metal", +] + +_GPU_INSTANCE_TYPES = [ + "optimal", + "c4.2xlarge", "c4.4xlarge", "c4.8xlarge", + "c5.xlarge", "c5.2xlarge", "c5.4xlarge", "c5.9xlarge", + "c5.12xlarge", "c5.18xlarge", "c5.24xlarge", "c5.metal", + "g4dn.xlarge", "g4dn.2xlarge", "g4dn.4xlarge", "g4dn.8xlarge", + "g4dn.12xlarge", "g4dn.16xlarge", "g4dn.metal", + "m4.xlarge", "m4.2xlarge", "m4.4xlarge", "m4.10xlarge", "m4.16xlarge", + "m5.xlarge", "m5.2xlarge", "m5.4xlarge", "m5.8xlarge", + "m5.12xlarge", "m5.16xlarge", "m5.24xlarge", "m5.metal", + "p3.2xlarge", "p3.8xlarge", "p3.16xlarge", + "r4.xlarge", "r4.2xlarge", "r4.4xlarge", "r4.8xlarge", "r4.16xlarge", + "r5.xlarge", "r5.2xlarge", "r5.4xlarge", "r5.8xlarge", + "r5.12xlarge", "r5.16xlarge", "r5.24xlarge", "r5.metal", +] + +QUEUE_PRESETS = { + "standard-stable": { + "computeEnvironmentName": "OnDemandStandard", + "computeResources": { + "allocationStrategy": "BEST_FIT_PROGRESSIVE", + "instanceTypes": _STANDARD_INSTANCE_TYPES, + "maxvCpus": 512, + "type": "EC2", + "minvCpus": 0, + }, + "templateName": "Standard stable", + "templateDescription": ( + "Standard stable (on-demand) instances of all resource types from " + "c5, r5, m5, c4, r4, m4 instance families." + ), + }, + "standard-cost-saving": { + "computeEnvironmentName": "OnDemandSpot", + "computeResources": { + "allocationStrategy": "SPOT_CAPACITY_OPTIMIZED", + "instanceTypes": _STANDARD_INSTANCE_TYPES, + "maxvCpus": 512, + "type": "SPOT", + "minvCpus": 0, + "bidPercentage": 100, + }, + "templateName": "Standard cost-saving", + "templateDescription": ( + "Standard cost-saving (spot) instances of all resource types from " + "c5, r5, m5, c4, r4, m4 instance families. Spot instances allow to " + "save up to 80% cost compared to on-demand stable instances at a risk " + "of being prematurely terminated. Useful for short-running processes. " + "It is advised to use retry error strategy in the workflow for this job queue." + ), + }, + "read-write-optimised": { + "computeEnvironmentName": "OnDemandStandardHighDiskThroughput", + "computeResources": { + "allocationStrategy": "BEST_FIT_PROGRESSIVE", + "instanceTypes": _STANDARD_INSTANCE_TYPES, + "maxvCpus": 512, + "type": "EC2", + "minvCpus": 0, + "volume": { + "type": "gp3", + "size": {"usageQuantity": 1000, "usageUnit": "Gb"}, + "iops": 5000, + "throughput": 500, + "deviceName": "/dev/xvda", + "deleteOnTermination": False, + "encrypted": False, + }, + }, + "templateName": "Read/write optimised", + "templateDescription": ( + "Standard stable (on-demand) instances of all resource types from " + "c5, r5, m5, c4, r4, m4 instance families and increased disk I/O " + "performance. Useful for the jobs that require significant file read " + "and write activity. May increase the job cost." + ), + }, + "standard-gpu": { + "computeEnvironmentName": "OnDemandStandardGPUs", + "computeResources": { + "allocationStrategy": "BEST_FIT_PROGRESSIVE", + "instanceTypes": _GPU_INSTANCE_TYPES, + "maxvCpus": 512, + "type": "EC2", + "minvCpus": 0, + }, + "templateName": "Standard with GPUs", + "templateDescription": ( + "Standard stable (on-demand) instances as well as GPU instances of " + "p3 and/or g4dn families. On-demand GPU machines typically incur higher costs." + ), + }, +} @dataclass @@ -164,3 +277,107 @@ def fetch_job_queue_id(self, workflow_type, batch=True, job_queue=None): f'queue instead: {default_queue_name}.') return default_queue_id return selected_queue[0]['id'] + + @staticmethod + def get_preset_template(preset_name): + """Return the environment and template fields for a given preset name. + + Parameters + ---------- + preset_name : str + One of: 'standard-stable', 'standard-cost-saving', + 'read-write-optimised', 'standard-gpu'. + + Returns + ------- + template : dict + A dict with keys 'computeEnvironmentName', 'computeResources', + 'templateName', and 'templateDescription'. + + Raises + ------ + ValueError + If ``preset_name`` is not a recognised preset. + """ + if preset_name not in QUEUE_PRESETS: + valid = ', '.join(QUEUE_PRESETS.keys()) + raise ValueError( + f"Unknown preset '{preset_name}'. Valid presets are: {valid}" + ) + return QUEUE_PRESETS[preset_name] + + def get_available_instances(self): + """Return the list of available AWS instance types for the workspace. + + Returns + ------- + instances : list + A list of dicts describing available instance types. + """ + headers = {"apikey": self.apikey} + r = requests.get( + "{}/api/v1/aws/instances?teamId={}".format( + self.cloudos_url, self.workspace_id + ), + headers=headers, + verify=self.verify, + ) + if r.status_code >= 400: + raise BadRequestException(r) + return json.loads(r.content) + + def create_job_queue(self, label, description, preset_name, executor="nextflow"): + """Create a new job queue in the workspace using a preset template. + + Parameters + ---------- + label : str + Human-readable name for the queue. + description : str + Short description of the queue's purpose. + preset_name : str + One of the supported preset keys (see ``QUEUE_PRESETS``). + executor : str, optional + Workflow executor. Defaults to ``'nextflow'``. + + Returns + ------- + queue_id : str + The Lifebit Platform ID assigned to the newly created queue. + + Raises + ------ + BadRequestException + If the API returns a 4xx or 5xx response. + """ + preset = self.get_preset_template(preset_name) + payload = { + "id": "", + "label": label, + "description": description, + "executor": executor, + "status": "ToCreate", + "environment": { + "computeEnvironmentName": preset["computeEnvironmentName"], + "computeResources": preset["computeResources"], + }, + "templateName": preset["templateName"], + "templateDescription": preset["templateDescription"], + "isDefault": False, + } + headers = { + "Content-Type": "application/json", + "apikey": self.apikey, + } + r = retry_requests_post( + "{}/api/v1/teams/aws/v2/job-queue?teamId={}".format( + self.cloudos_url, self.workspace_id + ), + headers=headers, + json=payload, + verify=self.verify, + ) + if r.status_code >= 400: + raise BadRequestException(r) + response_data = json.loads(r.content) + return response_data.get("id") or response_data.get("_id", "") diff --git a/tests/test_data/queue/create_queue_response.json b/tests/test_data/queue/create_queue_response.json new file mode 100644 index 00000000..7b0c3181 --- /dev/null +++ b/tests/test_data/queue/create_queue_response.json @@ -0,0 +1,21 @@ +{ + "_id": "6a2039447e4338212900fe65", + "id": "6a2039447e4338212900fe65", + "label": "My Custom Job Queue", + "description": "Custom job queue for batch processing workloads", + "executor": "nextflow", + "status": "ToCreate", + "isDefault": false, + "templateName": "Standard stable", + "templateDescription": "Standard stable (on-demand) instances of all resource types from c5, r5, m5, c4, r4, m4 instance families.", + "environment": { + "computeEnvironmentName": "OnDemandStandard", + "computeResources": { + "allocationStrategy": "BEST_FIT_PROGRESSIVE", + "instanceTypes": ["optimal", "c5.xlarge"], + "maxvCpus": 512, + "type": "EC2", + "minvCpus": 0 + } + } +} diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py new file mode 100644 index 00000000..d2ba1a2f --- /dev/null +++ b/tests/test_queue/test_create_queue.py @@ -0,0 +1,326 @@ +"""Tests for the queue create command and Queue.create_job_queue() method.""" + +import json +import pytest +import responses +import requests_mock as requests_mock_module +from click.testing import CliRunner + +from cloudos_cli.queue.queue import Queue, QUEUE_PRESETS +from cloudos_cli.utils.errors import BadRequestException +from cloudos_cli.__main__ import run_cloudos_cli +from tests.functions_for_pytest import load_json_file + +# --------------------------------------------------------------------------- +# Constants shared across tests +# --------------------------------------------------------------------------- + +APIKEY = 'vnoiweur89u2ongs' +CLOUDOS_URL = 'https://cloudos.lifebit.ai' +WORKSPACE_ID = 'lv89ufc838sdig' +CREATE_RESPONSE_FILE = 'tests/test_data/queue/create_queue_response.json' +QUEUES_FILE = 'tests/test_data/queue/queues.json' +SYSTEM_QUEUES_FILE = 'tests/test_data/queue/system_queues.json' + +with open(CREATE_RESPONSE_FILE) as f: + CREATE_RESPONSE_JSON_STR = f.read() + CREATE_RESPONSE_JSON_DICT = json.loads(CREATE_RESPONSE_JSON_STR) + + +# =========================================================================== +# Unit tests – Queue.get_preset_template() +# =========================================================================== + +class TestGetPresetTemplate: + def test_returns_dict_for_each_known_preset(self): + for preset_name in QUEUE_PRESETS: + result = Queue.get_preset_template(preset_name) + assert isinstance(result, dict) + assert 'computeEnvironmentName' in result + assert 'computeResources' in result + assert 'templateName' in result + assert 'templateDescription' in result + + def test_standard_stable_preset_uses_ec2(self): + preset = Queue.get_preset_template('standard-stable') + assert preset['computeResources']['type'] == 'EC2' + assert preset['computeResources']['allocationStrategy'] == 'BEST_FIT_PROGRESSIVE' + + def test_standard_cost_saving_preset_uses_spot(self): + preset = Queue.get_preset_template('standard-cost-saving') + assert preset['computeResources']['type'] == 'SPOT' + assert preset['computeResources']['allocationStrategy'] == 'SPOT_CAPACITY_OPTIMIZED' + assert preset['computeResources']['bidPercentage'] == 100 + + def test_read_write_optimised_has_volume(self): + preset = Queue.get_preset_template('read-write-optimised') + assert 'volume' in preset['computeResources'] + assert preset['computeResources']['volume']['type'] == 'gp3' + + def test_standard_gpu_includes_gpu_instances(self): + preset = Queue.get_preset_template('standard-gpu') + instance_types = preset['computeResources']['instanceTypes'] + gpu_instances = [i for i in instance_types if i.startswith(('g4dn', 'p3'))] + assert len(gpu_instances) > 0 + + def test_raises_value_error_for_unknown_preset(self): + with pytest.raises(ValueError, match="Unknown preset"): + Queue.get_preset_template('nonexistent-preset') + + def test_all_presets_have_optimal_instance_type(self): + for preset_name in QUEUE_PRESETS: + preset = Queue.get_preset_template(preset_name) + assert 'optimal' in preset['computeResources']['instanceTypes'] + + def test_all_presets_have_max_vcpus(self): + for preset_name in QUEUE_PRESETS: + preset = Queue.get_preset_template(preset_name) + assert preset['computeResources']['maxvCpus'] > 0 + + +# =========================================================================== +# Unit tests – Queue.create_job_queue() (API mocked via responses) +# =========================================================================== + +class TestCreateJobQueue: + def _make_queue(self): + return Queue( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + cromwell_token=None, + workspace_id=WORKSPACE_ID, + ) + + @responses.activate + def test_create_job_queue_success_returns_id(self): + responses.add( + responses.POST, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + body=CREATE_RESPONSE_JSON_STR, + status=200, + content_type='application/json', + ) + q = self._make_queue() + queue_id = q.create_job_queue( + label='Test Queue', + description='A test queue', + preset_name='standard-stable', + ) + assert queue_id == CREATE_RESPONSE_JSON_DICT['_id'] + + @responses.activate + def test_create_job_queue_posts_correct_payload(self): + responses.add( + responses.POST, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + body=CREATE_RESPONSE_JSON_STR, + status=200, + content_type='application/json', + ) + q = self._make_queue() + q.create_job_queue( + label='My Queue', + description='desc', + preset_name='standard-cost-saving', + executor='nextflow', + ) + # Inspect what was sent + sent_payload = json.loads(responses.calls[0].request.body) + assert sent_payload['label'] == 'My Queue' + assert sent_payload['description'] == 'desc' + assert sent_payload['executor'] == 'nextflow' + assert sent_payload['status'] == 'ToCreate' + assert sent_payload['isDefault'] is False + assert sent_payload['id'] == '' + assert sent_payload['environment']['computeResources']['type'] == 'SPOT' + + @responses.activate + def test_create_job_queue_raises_on_400(self): + error_body = json.dumps({ + 'statusCode': 400, + 'code': 'BadRequest', + 'message': 'Bad Request.', + }) + responses.add( + responses.POST, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + body=error_body, + status=400, + content_type='application/json', + ) + q = self._make_queue() + with pytest.raises(BadRequestException): + q.create_job_queue( + label='Bad Queue', + description='', + preset_name='standard-stable', + ) + + @responses.activate + def test_create_job_queue_all_presets_succeed(self): + for preset_name in QUEUE_PRESETS: + responses.add( + responses.POST, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + body=CREATE_RESPONSE_JSON_STR, + status=200, + content_type='application/json', + ) + q = self._make_queue() + for preset_name in QUEUE_PRESETS: + queue_id = q.create_job_queue( + label=f'queue-{preset_name}', + description='test', + preset_name=preset_name, + ) + assert queue_id != '' + + def test_create_job_queue_raises_on_invalid_preset(self): + q = self._make_queue() + with pytest.raises(ValueError, match='Unknown preset'): + q.create_job_queue( + label='Queue', + description='', + preset_name='not-a-preset', + ) + + +# =========================================================================== +# CLI integration tests – `cloudos queue create` +# =========================================================================== + +class TestCreateQueueCLI: + def _base_args(self, extra=None): + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Test Queue', + '--description', 'A test queue', + '--preset', 'standard-stable', + '--yes', + ] + if extra: + args.extend(extra) + return args + + def test_create_command_exists_in_help(self): + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['queue', '--help']) + assert result.exit_code == 0 + assert 'create' in result.output + + def test_create_command_help(self): + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) + assert result.exit_code == 0 + assert '--label' in result.output + assert '--preset' in result.output + assert '--yes' in result.output + assert '--description' in result.output + + def test_create_command_all_presets_in_help(self): + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) + for preset_name in QUEUE_PRESETS: + assert preset_name in result.output + + def test_create_queue_success_with_yes_flag(self): + runner = CliRunner() + with requests_mock_module.Mocker() as m: + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + text=CREATE_RESPONSE_JSON_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, self._base_args()) + assert result.exit_code == 0 + assert 'created successfully' in result.output + assert CREATE_RESPONSE_JSON_DICT['_id'] in result.output + + def test_create_queue_shows_url_on_success(self): + runner = CliRunner() + with requests_mock_module.Mocker() as m: + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + text=CREATE_RESPONSE_JSON_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, self._base_args()) + assert CLOUDOS_URL in result.output + assert 'job-queues' in result.output + + def test_create_queue_aborted_on_confirmation_decline(self): + runner = CliRunner() + # Do NOT pass --yes; answer 'n' to the prompt + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Test Queue', + '--preset', 'standard-stable', + ] + result = runner.invoke(run_cloudos_cli, args, input='n\n') + assert result.exit_code == 0 + assert 'Aborted' in result.output + + def test_create_queue_proceeds_on_confirmation_accept(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Test Queue', + '--preset', 'standard-stable', + ] + with requests_mock_module.Mocker() as m: + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + text=CREATE_RESPONSE_JSON_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, args, input='y\n') + assert result.exit_code == 0 + assert 'created successfully' in result.output + + def test_create_queue_api_error_exits_nonzero(self): + runner = CliRunner() + error_body = json.dumps({'statusCode': 400, 'message': 'Bad Request.'}) + with requests_mock_module.Mocker() as m: + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + text=error_body, + status_code=400, + ) + result = runner.invoke(run_cloudos_cli, self._base_args()) + assert result.exit_code == 1 + assert 'Error' in result.output + + def test_create_queue_invalid_preset_rejected(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Test Queue', + '--preset', 'not-a-real-preset', + '--yes', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_create_queue_missing_label_fails(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--yes', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 From 8bae61d845241dade121dab9108d5effa8ed3255 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Thu, 11 Jun 2026 17:05:43 +0200 Subject: [PATCH 02/27] docs: update version --- cloudos_cli/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index 1271f796..49b42349 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.91.0' +__version__ = '2.93.0' From 200ff1f761801d467c4595a2056eea5102e50388 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Mon, 15 Jun 2026 13:01:29 +0200 Subject: [PATCH 03/27] feat: create queues from scratch --- cloudos_cli/queue/cli.py | 625 +++++++++++++++++++++++++- cloudos_cli/queue/queue.py | 174 +++++++ tests/test_queue/test_create_queue.py | 237 ++++++++++ 3 files changed, 1031 insertions(+), 5 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 8e33ead4..d1d37b07 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -3,13 +3,457 @@ import sys import rich_click as click import json -from cloudos_cli.queue.queue import Queue, QUEUE_PRESETS +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from cloudos_cli.queue.queue import ( + Queue, + QUEUE_PRESETS, + PROVISIONING_TYPES, + ALLOCATION_STRATEGIES, + VOLUME_SPECS, + MAX_VCPUS_LIMIT, + DEFAULT_MAX_VCPUS, + DEFAULT_MIN_VCPUS, + _STANDARD_INSTANCE_TYPES, +) from cloudos_cli.utils.resources import ssl_selector from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands from cloudos_cli.utils.details import create_queue_list_table +# Union of all allocation strategies, used for the CLI option choices. +_ALL_ALLOCATION_STRATEGIES = ["BEST_FIT", "BEST_FIT_PROGRESSIVE", "SPOT_CAPACITY_OPTIMIZED"] + +# --------------------------------------------------------------------------- +# Wizard styling helpers +# --------------------------------------------------------------------------- + +# Colour theme for the interactive --from-scratch wizard. +_C_ACCENT = "cyan" +_C_STEP = "bold cyan" +_C_TITLE = "bold white" +_C_OPTION = "bold green" +_C_DESC = "grey62" +_C_HINT = "grey50" + + +def _print_section(console, step, total, title, subtitle=None, options=None, hint=None): + """Print a styled wizard section: step badge, title, options and hint. + + Parameters + ---------- + console : rich.console.Console + The console used for rich output. + step : int + The current step number. + total : int + The total number of steps. + title : str + The section title. + subtitle : str or None, optional + A short clarifying line shown under the title. + options : list[tuple[str, str]] or None, optional + A list of ``(name, description)`` pairs. Names are highlighted and + descriptions shown in a dimmed (grey) style, both indented. + hint : str or None, optional + A short hint (e.g. allowed range / default) shown just above the prompt. + """ + console.print() + console.rule( + f"[{_C_STEP}]Step {step}/{total}[/{_C_STEP}] [{_C_TITLE}]{title}[/{_C_TITLE}]", + align="left", + characters="─", + style=_C_ACCENT, + ) + if subtitle: + console.print(f" [{_C_DESC}]{subtitle}[/{_C_DESC}]") + if options: + console.print() + for name, description in options: + console.print(f" [{_C_OPTION}]●[/{_C_OPTION}] [{_C_OPTION}]{name}[/{_C_OPTION}]") + console.print(f" [{_C_DESC}]{description}[/{_C_DESC}]") + if hint: + console.print() + console.print(f" [{_C_HINT}]{hint}[/{_C_HINT}]") + console.print() + + +def _styled_prompt(label, **kwargs): + """Issue a ``click.prompt`` with a consistent, coloured prompt line. + + Parameters + ---------- + label : str + The prompt label (without the leading arrow). + **kwargs + Forwarded to ``click.prompt`` (e.g. ``type``, ``default``). + + Returns + ------- + The value returned by ``click.prompt``. + """ + arrow = click.style(" ❯ ", fg="cyan", bold=True) + text = arrow + click.style(label, fg="white", bold=True) + return click.prompt(text, **kwargs) + + +def _from_scratch_wizard(console): + """Interactively collect custom queue parameters, emulating the UI flow. + + Parameters + ---------- + console : rich.console.Console + The console used for rich output. + + Returns + ------- + params : dict + A dict with keys: ``label``, ``provisioning_type``, + ``allocation_strategy``, ``max_vcpus``, ``min_vcpus``, + ``instance_types``, ``volume_type``, ``size``, ``iops`` and + ``throughput``. + """ + total = 9 + console.print() + console.print( + Panel.fit( + "[bold cyan]Create a job queue from scratch[/bold cyan]\n" + "[grey62]Answer the prompts below to configure your custom " + "compute environment.[/grey62]", + border_style="cyan", + padding=(1, 4), + ) + ) + + # 1. Name of the queue + _print_section( + console, 1, total, "Name", + subtitle="A human-readable name for your job queue.", + ) + label = _styled_prompt("Name of the queue", type=str) + + # 2. Provisioning type + _print_section( + console, 2, total, "Provisioning type", + subtitle="Choose how your compute instances are provisioned.", + options=[ + ("On demand", + "EC2 usage and provisioned storage for EBS volumes are billed on " + "one second increments, with a minimum of 60 seconds."), + ("Spot", + "Save money by using Spot instances but your instances can be " + "interrupted with a two minute notification when EC2 needs the " + "capacity back."), + ], + ) + provisioning_type = _styled_prompt( + "Provisioning type", + type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), + default="on-demand", + ) + + # 3. Allocation strategy + allowed_strategies = ALLOCATION_STRATEGIES[provisioning_type] + _print_section( + console, 3, total, "Allocation strategy", + subtitle="Choose how batch launches instances on your behalf.", + hint="We recommend Best Fit Progressive for On-Demand CEs and " + "Spot Capacity Optimised for Spot CEs.", + ) + allocation_strategy = _styled_prompt( + "Allocation strategy", + type=click.Choice(allowed_strategies, case_sensitive=False), + default="BEST_FIT_PROGRESSIVE", + ) + + # 4. Max vCPUs + _print_section( + console, 4, total, "Max vCPUs", + subtitle="Maximum number of vCPUs the queue can scale up to.", + hint=f"Max {MAX_VCPUS_LIMIT}. Default {DEFAULT_MAX_VCPUS}.", + ) + max_vcpus = _styled_prompt( + "Max vCPUs", + type=click.IntRange(0, MAX_VCPUS_LIMIT), + default=DEFAULT_MAX_VCPUS, + ) + + # 5. Min vCPUs + _print_section( + console, 5, total, "Min vCPUs", + subtitle="Minimum number of vCPUs kept running (optional).", + hint="Default 0.", + ) + min_vcpus = _styled_prompt( + "Min vCPUs", + type=click.IntRange(0, MAX_VCPUS_LIMIT), + default=DEFAULT_MIN_VCPUS, + ) + + # 6. Instance types + _print_section( + console, 6, total, "Instance types", + subtitle="Optimal or a combination of instances.", + hint="Enter 'optimal' or a comma-separated list of instance types " + "from the standard families (c5, r5, m5, c4, r4, m4).", + ) + instance_types = _prompt_instance_types(console) + + # 7. Volume type + _print_section( + console, 7, total, "Volume type", + subtitle="Select your preferred volume type.", + options=[ + ("General Purpose SSD (gp3)", + "Balanced price and performance for a wide variety of workloads."), + ("Provisioned IOPS SSD (io2)", + "High-performance SSD for I/O-intensive workloads."), + ], + ) + volume_type = _styled_prompt( + "Volume type", + type=click.Choice(list(VOLUME_SPECS.keys()), case_sensitive=False), + default="gp3", + ) + if volume_type == "io2": + console.print() + console.print( + Panel( + "[bold yellow]⚠ Warning, high cost disk type.[/bold yellow]\n" + "[grey62]Read more: " + "https://lifebit.atlassian.net/wiki/spaces/CD/pages/316506431/" + "Disk+types[/grey62]", + border_style="yellow", + padding=(0, 2), + ) + ) + + spec = VOLUME_SPECS[volume_type] + + # 8. Size (GiB) + size_default, size_min, size_max = spec["size"] + _print_section( + console, 8, total, "Size (GiB)", + subtitle="Volume size in GiB.", + hint=f"Min {size_min}, max {size_max}. Default {size_default}.", + ) + size = _styled_prompt( + "Size (GiB)", + type=click.IntRange(size_min, size_max), + default=size_default, + ) + + # 9. IOPS + iops_default, iops_min, iops_max = spec["iops"] + _print_section( + console, 9, total, "IOPS", + subtitle="Input/output Operations per Second (IOPS).", + hint="A high IOPS is needed for jobs with high throughput that need " + f"many files to be written/read. Min {iops_min}, max {iops_max}. " + f"Default {iops_default}.", + ) + iops = _styled_prompt( + "IOPS", + type=click.IntRange(iops_min, iops_max), + default=iops_default, + ) + + # Throughput (gp3 only) + throughput = None + if spec["throughput"] is not None: + tp_default, tp_min, tp_max = spec["throughput"] + _print_section( + console, 9, total, "Throughput (MB/s)", + subtitle="Volume throughput in MB/s.", + hint=f"Min {tp_min}, max {tp_max}. Default {tp_default}.", + ) + throughput = _styled_prompt( + "Throughput (MB/s)", + type=click.IntRange(tp_min, tp_max), + default=tp_default, + ) + + params = { + "label": label, + "provisioning_type": provisioning_type, + "allocation_strategy": allocation_strategy, + "max_vcpus": max_vcpus, + "min_vcpus": min_vcpus, + "instance_types": instance_types, + "volume_type": volume_type, + "size": size, + "iops": iops, + "throughput": throughput, + } + _print_summary(console, params) + return params + + +def _print_summary(console, params): + """Print a styled summary table of the collected wizard parameters. + + Parameters + ---------- + console : rich.console.Console + The console used for rich output. + params : dict + The collected from-scratch parameters. + """ + table = Table( + title="[bold cyan]Queue configuration summary[/bold cyan]", + show_header=False, + box=None, + padding=(0, 2), + ) + table.add_column(justify="right", style="grey62", no_wrap=True) + table.add_column(style="white") + + instance_label = ", ".join(params["instance_types"]) + rows = [ + ("Name", params["label"]), + ("Provisioning type", params["provisioning_type"]), + ("Allocation strategy", params["allocation_strategy"]), + ("Max vCPUs", str(params["max_vcpus"])), + ("Min vCPUs", str(params["min_vcpus"])), + ("Instance types", instance_label), + ("Volume type", params["volume_type"]), + ("Size (GiB)", str(params["size"])), + ("IOPS", str(params["iops"])), + ] + if params["throughput"] is not None: + rows.append(("Throughput (MB/s)", str(params["throughput"]))) + + for name, value in rows: + table.add_row(name, value) + + console.print() + console.print(table) + console.print() + + +def _prompt_instance_types(console): + """Prompt for instance types, validating them against the standard list. + + Parameters + ---------- + console : rich.console.Console + The console used for rich output. + + Returns + ------- + instance_types : list[str] + The validated list of instance types. + """ + while True: + raw = _styled_prompt("Instance types", type=str, default="optimal") + instance_types = [item.strip() for item in raw.split(",") if item.strip()] + invalid = [item for item in instance_types if item not in _STANDARD_INSTANCE_TYPES] + if not instance_types: + console.print("[red]Please provide at least one instance type.[/red]") + continue + if invalid: + console.print( + f"[red]Invalid instance type(s): {', '.join(invalid)}.[/red] " + "[dim]Allowed values are 'optimal' or standard instance types.[/dim]" + ) + continue + return instance_types + + +def _parse_instance_types(raw): + """Parse and validate a comma-separated instance types string. + + Parameters + ---------- + raw : str + Comma-separated instance types (e.g. ``'optimal'`` or ``'c5.xlarge,m5.xlarge'``). + + Returns + ------- + instance_types : list[str] + The parsed list of instance types. + + Raises + ------ + click.BadParameter + If the string is empty or contains unrecognised instance types. + """ + instance_types = [item.strip() for item in raw.split(",") if item.strip()] + if not instance_types: + raise click.BadParameter("At least one instance type is required.") + invalid = [item for item in instance_types if item not in _STANDARD_INSTANCE_TYPES] + if invalid: + raise click.BadParameter( + f"Invalid instance type(s): {', '.join(invalid)}. " + "Allowed values are 'optimal' or standard instance types." + ) + return instance_types + + +def _validate_from_scratch_flags(params): + """Validate non-interactive ``--from-scratch`` flag values. + + Parameters + ---------- + params : dict + A dict with the from-scratch parameters (same keys as produced by + ``_from_scratch_wizard``). + + Raises + ------ + click.BadParameter + If the allocation strategy is incompatible with the provisioning type + or the volume size/IOPS/throughput fall outside the allowed range. + """ + provisioning_type = params["provisioning_type"] + allowed_strategies = ALLOCATION_STRATEGIES[provisioning_type] + if params["allocation_strategy"] not in allowed_strategies: + raise click.BadParameter( + f"Allocation strategy '{params['allocation_strategy']}' is not valid " + f"for '{provisioning_type}' provisioning. Valid options are: " + f"{', '.join(allowed_strategies)}." + ) + + spec = VOLUME_SPECS[params["volume_type"]] + _check_range("--size", params["size"], spec["size"]) + _check_range("--iops", params["iops"], spec["iops"]) + if spec["throughput"] is not None: + if params["throughput"] is None: + params["throughput"] = spec["throughput"][0] + _check_range("--throughput", params["throughput"], spec["throughput"]) + else: + params["throughput"] = None + + +def _check_range(name, value, spec): + """Validate that ``value`` is within the ``(default, min, max)`` spec. + + Parameters + ---------- + name : str + The option name (for error messages). + value : int + The value to validate. + spec : tuple[int, int, int] + A ``(default, minimum, maximum)`` tuple. + + Raises + ------ + click.BadParameter + If ``value`` is outside ``[minimum, maximum]``. + """ + _, minimum, maximum = spec + if value < minimum or value > maximum: + raise click.BadParameter( + f"{name} must be between {minimum} and {maximum} for the selected " + f"volume type (got {value})." + ) + + + + # Create the queue group @click.group(cls=pass_debug_to_subcommands()) def queue(): @@ -111,7 +555,8 @@ def list_queues(ctx, required=True) @click.option('--label', help='Name (label) for the new job queue.', - required=True) + required=False, + default=None) @click.option('--description', help='Short description of the new job queue.', default='', @@ -131,6 +576,61 @@ def list_queues(ctx, default='nextflow', show_default=True, required=False) +@click.option('--from-scratch', + help=('Create a custom job queue from scratch. By default this ' + 'launches an interactive wizard. Combine with -y/--yes to ' + 'create non-interactively using the options below. Mutually ' + 'exclusive with --preset.'), + is_flag=True) +@click.option('--provisioning-type', + help='Provisioning type for --from-scratch. Default=on-demand.', + type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), + default='on-demand', + show_default=True) +@click.option('--allocation-strategy', + help=('Allocation strategy for --from-scratch. spot supports all ' + 'strategies; on-demand supports BEST_FIT and ' + 'BEST_FIT_PROGRESSIVE. Default=BEST_FIT_PROGRESSIVE.'), + type=click.Choice(_ALL_ALLOCATION_STRATEGIES, case_sensitive=False), + default='BEST_FIT_PROGRESSIVE', + show_default=True) +@click.option('--max-vcpus', + help=f'Max vCPUs for --from-scratch. Max {MAX_VCPUS_LIMIT}.', + type=click.IntRange(0, MAX_VCPUS_LIMIT), + default=DEFAULT_MAX_VCPUS, + show_default=True) +@click.option('--min-vcpus', + help='Min vCPUs for --from-scratch.', + type=click.IntRange(0, MAX_VCPUS_LIMIT), + default=DEFAULT_MIN_VCPUS, + show_default=True) +@click.option('--instance-types', + help=("Instance types for --from-scratch. 'optimal' or a " + 'comma-separated list of standard instance types. ' + 'Default=optimal.'), + default='optimal', + show_default=True) +@click.option('--volume-type', + help='Volume type for --from-scratch. Default=gp3.', + type=click.Choice(list(VOLUME_SPECS.keys()), case_sensitive=False), + default='gp3', + show_default=True) +@click.option('--size', + help='Volume size in GiB for --from-scratch. Default=1000.', + type=int, + default=1000, + show_default=True) +@click.option('--iops', + help='Provisioned IOPS for --from-scratch. Default=3000.', + type=int, + default=3000, + show_default=True) +@click.option('--throughput', + help=('Volume throughput in MB/s for --from-scratch (gp3 only). ' + 'Default=125.'), + type=int, + default=125, + show_default=True) @click.option('-y', '--yes', 'skip_confirmation', @@ -153,20 +653,60 @@ def create_queue(ctx, description, preset, executor, + from_scratch, + provisioning_type, + allocation_strategy, + max_vcpus, + min_vcpus, + instance_types, + volume_type, + size, + iops, + throughput, skip_confirmation, disable_ssl_verification, ssl_cert, profile): - """Create a new job queue in a Lifebit Platform workspace using a preset template.""" + """Create a new job queue in a Lifebit Platform workspace. + + By default a preset template is used. Pass --from-scratch to build a custom + queue, either interactively (default) or non-interactively with -y/--yes. + """ verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + if from_scratch: + _create_queue_from_scratch( + ctx=ctx, + cloudos_url=cloudos_url, + apikey=apikey, + workspace_id=workspace_id, + verify_ssl=verify_ssl, + label=label, + description=description, + executor=executor, + provisioning_type=provisioning_type, + allocation_strategy=allocation_strategy, + max_vcpus=max_vcpus, + min_vcpus=min_vcpus, + instance_types=instance_types, + volume_type=volume_type, + size=size, + iops=iops, + throughput=throughput, + skip_confirmation=skip_confirmation, + ) + return + + if label is None: + raise click.UsageError('Missing option --label.') + # Resolve the preset to show the user what will be created preset_info = QUEUE_PRESETS[preset] ce_name = preset_info['computeEnvironmentName'] cr = preset_info['computeResources'] resource_type = cr.get('type', 'EC2') - max_vcpus = cr.get('maxvCpus', 'N/A') + max_vcpus_preset = cr.get('maxvCpus', 'N/A') instance_count = len(cr.get('instanceTypes', [])) template_name = preset_info['templateName'] @@ -177,7 +717,7 @@ def create_queue(ctx, click.echo(f' Preset : {template_name}') click.echo(f' Compute env name : {ce_name}') click.echo(f' Resource type : {resource_type}') - click.echo(f' Max vCPUs : {max_vcpus}') + click.echo(f' Max vCPUs : {max_vcpus_preset}') click.echo(f' Instance types : {instance_count} types') click.echo(f' Executor : {executor}') click.echo(f' Workspace : {workspace_id}') @@ -202,3 +742,78 @@ def create_queue(ctx, except Exception as e: print(f'\tError creating queue: {str(e)}') sys.exit(1) + + +def _create_queue_from_scratch(ctx, + cloudos_url, + apikey, + workspace_id, + verify_ssl, + label, + description, + executor, + provisioning_type, + allocation_strategy, + max_vcpus, + min_vcpus, + instance_types, + volume_type, + size, + iops, + throughput, + skip_confirmation): + """Handle the --from-scratch branch of ``cloudos queue create``. + + When ``skip_confirmation`` is False, an interactive wizard collects all the + parameters. Otherwise the provided option flags are validated and used + directly (non-interactive mode). + """ + console = Console() + + # --from-scratch is mutually exclusive with an explicitly-set --preset. + if ctx.get_parameter_source('preset') == click.core.ParameterSource.COMMANDLINE: + raise click.UsageError('--from-scratch cannot be combined with --preset.') + + if skip_confirmation: + params = { + 'label': label, + 'provisioning_type': provisioning_type, + 'allocation_strategy': allocation_strategy, + 'max_vcpus': max_vcpus, + 'min_vcpus': min_vcpus, + 'instance_types': _parse_instance_types(instance_types), + 'volume_type': volume_type, + 'size': size, + 'iops': iops, + 'throughput': throughput, + } + if params['label'] is None: + raise click.UsageError('Missing option --label for --from-scratch -y.') + _validate_from_scratch_flags(params) + else: + params = _from_scratch_wizard(console) + + print('Executing queue create...') + j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) + + try: + queue_id = j_queue.create_job_queue_from_scratch( + label=params['label'], + description=description, + provisioning_type=params['provisioning_type'], + allocation_strategy=params['allocation_strategy'], + max_vcpus=params['max_vcpus'], + min_vcpus=params['min_vcpus'], + instance_types=params['instance_types'], + volume_type=params['volume_type'], + size=params['size'], + iops=params['iops'], + throughput=params['throughput'], + executor=executor, + ) + print(f'\tQueue "{params["label"]}" created successfully.') + print(f'\tQueue ID : {queue_id}') + print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') + except Exception as e: + print(f'\tError creating queue: {str(e)}') + sys.exit(1) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 13fbd729..5d975b04 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -124,6 +124,43 @@ } +# --------------------------------------------------------------------------- +# Custom ("from scratch") queue creation options +# --------------------------------------------------------------------------- + +# Provisioning type -> AWS Batch compute environment resource type. +PROVISIONING_TYPES = { + "on-demand": "EC2", + "spot": "SPOT", +} + +# Allocation strategies allowed for each provisioning type. +ALLOCATION_STRATEGIES = { + "on-demand": ["BEST_FIT", "BEST_FIT_PROGRESSIVE"], + "spot": ["BEST_FIT", "BEST_FIT_PROGRESSIVE", "SPOT_CAPACITY_OPTIMIZED"], +} + +# vCPU bounds. +MAX_VCPUS_LIMIT = 20000 +DEFAULT_MAX_VCPUS = 512 +DEFAULT_MIN_VCPUS = 0 + +# Volume types and their size/IOPS/throughput specifications. Each spec stores +# (default, minimum, maximum). ``throughput`` is ``None`` when not applicable. +VOLUME_SPECS = { + "gp3": { + "size": (1000, 50, 16384), + "iops": (3000, 3000, 16000), + "throughput": (125, 125, 1000), + }, + "io2": { + "size": (1000, 50, 16384), + "iops": (3000, 100, 64000), + "throughput": None, + }, +} + + @dataclass class Queue(Cloudos): """Class to store and operate job queues. @@ -365,6 +402,26 @@ def create_job_queue(self, label, description, preset_name, executor="nextflow") "templateDescription": preset["templateDescription"], "isDefault": False, } + return self._post_job_queue(payload) + + def _post_job_queue(self, payload): + """POST a job queue payload to the Lifebit Platform and return its ID. + + Parameters + ---------- + payload : dict + The fully-built job queue creation payload. + + Returns + ------- + queue_id : str + The Lifebit Platform ID assigned to the newly created queue. + + Raises + ------ + BadRequestException + If the API returns a 4xx or 5xx response. + """ headers = { "Content-Type": "application/json", "apikey": self.apikey, @@ -381,3 +438,120 @@ def create_job_queue(self, label, description, preset_name, executor="nextflow") raise BadRequestException(r) response_data = json.loads(r.content) return response_data.get("id") or response_data.get("_id", "") + + def create_job_queue_from_scratch(self, + label, + description, + provisioning_type, + allocation_strategy, + max_vcpus, + min_vcpus, + instance_types, + volume_type, + size, + iops, + throughput=None, + executor="nextflow"): + """Create a custom job queue without using a preset template. + + Parameters + ---------- + label : str + Human-readable name for the queue. Also used as the compute + environment name. + description : str + Short description of the queue's purpose. + provisioning_type : str + One of ``'on-demand'`` or ``'spot'``. + allocation_strategy : str + AWS Batch allocation strategy. Must be valid for the chosen + ``provisioning_type`` (see ``ALLOCATION_STRATEGIES``). + max_vcpus : int + Maximum number of vCPUs for the compute environment. + min_vcpus : int + Minimum number of vCPUs for the compute environment. + instance_types : list[str] + Instance types to allow (e.g. ``['optimal']`` or a list from + ``_STANDARD_INSTANCE_TYPES``). + volume_type : str + One of ``'gp3'`` or ``'io2'``. + size : int + Volume size in GiB. + iops : int + Provisioned IOPS for the volume. + throughput : int or None, optional + Volume throughput in MB/s. Only applicable to ``gp3`` volumes. + executor : str, optional + Workflow executor. Defaults to ``'nextflow'``. + + Returns + ------- + queue_id : str + The Lifebit Platform ID assigned to the newly created queue. + + Raises + ------ + ValueError + If the provisioning type, allocation strategy or volume type are + not recognised, or the allocation strategy is incompatible with + the provisioning type. + BadRequestException + If the API returns a 4xx or 5xx response. + """ + if provisioning_type not in PROVISIONING_TYPES: + valid = ', '.join(PROVISIONING_TYPES.keys()) + raise ValueError( + f"Unknown provisioning type '{provisioning_type}'. " + f"Valid options are: {valid}" + ) + allowed_strategies = ALLOCATION_STRATEGIES[provisioning_type] + if allocation_strategy not in allowed_strategies: + valid = ', '.join(allowed_strategies) + raise ValueError( + f"Allocation strategy '{allocation_strategy}' is not valid for " + f"'{provisioning_type}' provisioning. Valid options are: {valid}" + ) + if volume_type not in VOLUME_SPECS: + valid = ', '.join(VOLUME_SPECS.keys()) + raise ValueError( + f"Unknown volume type '{volume_type}'. Valid options are: {valid}" + ) + + resource_type = PROVISIONING_TYPES[provisioning_type] + volume = { + "type": volume_type, + "size": {"usageQuantity": size, "usageUnit": "Gb"}, + "iops": iops, + "deviceName": "/dev/xvda", + "deleteOnTermination": False, + "encrypted": False, + } + if volume_type == "gp3" and throughput is not None: + volume["throughput"] = throughput + + compute_resources = { + "allocationStrategy": allocation_strategy, + "instanceTypes": instance_types, + "maxvCpus": max_vcpus, + "type": resource_type, + "minvCpus": min_vcpus, + "volume": volume, + } + if provisioning_type == "spot": + compute_resources["bidPercentage"] = 100 + + payload = { + "id": "", + "label": label, + "description": description, + "executor": executor, + "status": "ToCreate", + "environment": { + "computeEnvironmentName": label, + "computeResources": compute_resources, + }, + "templateName": "", + "templateDescription": "", + "isDefault": False, + } + return self._post_job_queue(payload) diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index d2ba1a2f..4b9076b1 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -324,3 +324,240 @@ def test_create_queue_missing_label_fails(self): ] result = runner.invoke(run_cloudos_cli, args) assert result.exit_code != 0 + + +# =========================================================================== +# Unit tests – Queue.create_job_queue_from_scratch() (API mocked) +# =========================================================================== + +class TestCreateJobQueueFromScratch: + def _make_queue(self): + return Queue( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + cromwell_token=None, + workspace_id=WORKSPACE_ID, + ) + + def _add_post(self): + responses.add( + responses.POST, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + body=CREATE_RESPONSE_JSON_STR, + status=200, + content_type='application/json', + ) + + @responses.activate + def test_on_demand_gp3_payload(self): + self._add_post() + q = self._make_queue() + queue_id = q.create_job_queue_from_scratch( + label='Custom', + description='d', + provisioning_type='on-demand', + allocation_strategy='BEST_FIT_PROGRESSIVE', + max_vcpus=512, + min_vcpus=0, + instance_types=['optimal'], + volume_type='gp3', + size=1000, + iops=3000, + throughput=125, + ) + assert queue_id == CREATE_RESPONSE_JSON_DICT['_id'] + payload = json.loads(responses.calls[0].request.body) + cr = payload['environment']['computeResources'] + assert payload['environment']['computeEnvironmentName'] == 'Custom' + assert payload['templateName'] == '' + assert cr['type'] == 'EC2' + assert 'bidPercentage' not in cr + assert cr['allocationStrategy'] == 'BEST_FIT_PROGRESSIVE' + assert cr['maxvCpus'] == 512 + assert cr['minvCpus'] == 0 + assert cr['instanceTypes'] == ['optimal'] + assert cr['volume']['type'] == 'gp3' + assert cr['volume']['size'] == {'usageQuantity': 1000, 'usageUnit': 'Gb'} + assert cr['volume']['iops'] == 3000 + assert cr['volume']['throughput'] == 125 + + @responses.activate + def test_spot_io2_payload_has_bid_and_no_throughput(self): + self._add_post() + q = self._make_queue() + q.create_job_queue_from_scratch( + label='Custom', + description='d', + provisioning_type='spot', + allocation_strategy='SPOT_CAPACITY_OPTIMIZED', + max_vcpus=256, + min_vcpus=0, + instance_types=['c5.xlarge', 'm5.xlarge'], + volume_type='io2', + size=200, + iops=5000, + throughput=125, + ) + payload = json.loads(responses.calls[0].request.body) + cr = payload['environment']['computeResources'] + assert cr['type'] == 'SPOT' + assert cr['bidPercentage'] == 100 + assert 'throughput' not in cr['volume'] + assert cr['volume']['type'] == 'io2' + + def test_invalid_provisioning_type_raises(self): + q = self._make_queue() + with pytest.raises(ValueError, match='Unknown provisioning type'): + q.create_job_queue_from_scratch( + label='C', description='', provisioning_type='nope', + allocation_strategy='BEST_FIT', max_vcpus=512, min_vcpus=0, + instance_types=['optimal'], volume_type='gp3', size=1000, + iops=3000, throughput=125, + ) + + def test_incompatible_allocation_strategy_raises(self): + q = self._make_queue() + with pytest.raises(ValueError, match='not valid'): + q.create_job_queue_from_scratch( + label='C', description='', provisioning_type='on-demand', + allocation_strategy='SPOT_CAPACITY_OPTIMIZED', max_vcpus=512, + min_vcpus=0, instance_types=['optimal'], volume_type='gp3', + size=1000, iops=3000, throughput=125, + ) + + def test_invalid_volume_type_raises(self): + q = self._make_queue() + with pytest.raises(ValueError, match='Unknown volume type'): + q.create_job_queue_from_scratch( + label='C', description='', provisioning_type='on-demand', + allocation_strategy='BEST_FIT', max_vcpus=512, min_vcpus=0, + instance_types=['optimal'], volume_type='ssd', size=1000, + iops=3000, throughput=125, + ) + + +# =========================================================================== +# CLI integration tests – `cloudos queue create --from-scratch` +# =========================================================================== + +class TestCreateQueueFromScratchCLI: + def test_from_scratch_options_in_help(self): + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) + assert result.exit_code == 0 + for opt in ['--from-scratch', '--provisioning-type', '--allocation-strategy', + '--max-vcpus', '--min-vcpus', '--instance-types', '--volume-type', + '--size', '--iops', '--throughput']: + assert opt in result.output + + def test_from_scratch_yes_success(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Custom Queue', + '--from-scratch', '--yes', + ] + with requests_mock_module.Mocker() as m: + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + text=CREATE_RESPONSE_JSON_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 0 + assert 'created successfully' in result.output + + def test_from_scratch_mutually_exclusive_with_preset(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Custom Queue', + '--from-scratch', '--preset', 'standard-gpu', '--yes', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + assert 'cannot be combined with --preset' in result.output + + def test_from_scratch_incompatible_strategy_rejected(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Custom Queue', + '--from-scratch', '--yes', + '--provisioning-type', 'on-demand', + '--allocation-strategy', 'SPOT_CAPACITY_OPTIMIZED', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_from_scratch_gp3_iops_out_of_range_rejected(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Custom Queue', + '--from-scratch', '--yes', + '--volume-type', 'gp3', + '--iops', '100', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_from_scratch_invalid_instance_type_rejected(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Custom Queue', + '--from-scratch', '--yes', + '--instance-types', 'not-an-instance', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_from_scratch_interactive_wizard_success(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--from-scratch', + ] + # Wizard answers: name, provisioning, strategy, max, min, instances, + # volume type, size, iops, throughput. + wizard_input = '\n'.join([ + 'My Wizard Queue', + 'on-demand', + 'BEST_FIT_PROGRESSIVE', + '512', + '0', + 'optimal', + 'gp3', + '1000', + '3000', + '125', + ]) + '\n' + with requests_mock_module.Mocker() as m: + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", + text=CREATE_RESPONSE_JSON_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, args, input=wizard_input) + assert result.exit_code == 0 + assert 'created successfully' in result.output + From 0bdaf585c32fcf0e6460fd38d8aa9d7470b81dca Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Mon, 15 Jun 2026 14:33:42 +0200 Subject: [PATCH 04/27] refactor: update ux --- cloudos_cli/queue/cli.py | 204 +++++++++++++++-- cloudos_cli/queue/queue.py | 197 +++++++++++++++- tests/test_queue/test_create_queue.py | 318 ++++++++++++++++++++++++++ 3 files changed, 692 insertions(+), 27 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index d1d37b07..1ff8cc48 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -15,6 +15,8 @@ MAX_VCPUS_LIMIT, DEFAULT_MAX_VCPUS, DEFAULT_MIN_VCPUS, + MAX_COMPUTE_ENVS, + CE_LIMIT_REACHED_MESSAGE, _STANDARD_INSTANCE_TYPES, ) from cloudos_cli.utils.resources import ssl_selector @@ -99,13 +101,20 @@ def _styled_prompt(label, **kwargs): return click.prompt(text, **kwargs) -def _from_scratch_wizard(console): +def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): """Interactively collect custom queue parameters, emulating the UI flow. Parameters ---------- console : rich.console.Console The console used for rich output. + for_compute_env : bool, optional + When True, the wizard collects a compute environment to add to an + existing queue (step 1 asks for the compute environment name) rather + than a brand new queue. + queue_label : str or None, optional + The label of the target queue, shown in the intro when + ``for_compute_env`` is True. Returns ------- @@ -113,26 +122,40 @@ def _from_scratch_wizard(console): A dict with keys: ``label``, ``provisioning_type``, ``allocation_strategy``, ``max_vcpus``, ``min_vcpus``, ``instance_types``, ``volume_type``, ``size``, ``iops`` and - ``throughput``. + ``throughput``. When ``for_compute_env`` is True, ``label`` holds the + compute environment name. """ total = 9 console.print() - console.print( - Panel.fit( + if for_compute_env: + intro = ( + "[bold cyan]Add a compute environment to a job queue[/bold cyan]\n" + f"[grey62]Target queue: [white]{queue_label}[/white]. Answer the " + "prompts below to configure the new compute environment.[/grey62]" + ) + else: + intro = ( "[bold cyan]Create a job queue from scratch[/bold cyan]\n" "[grey62]Answer the prompts below to configure your custom " - "compute environment.[/grey62]", - border_style="cyan", - padding=(1, 4), + "compute environment.[/grey62]" ) + console.print( + Panel.fit(intro, border_style="cyan", padding=(1, 4)) ) - # 1. Name of the queue - _print_section( - console, 1, total, "Name", - subtitle="A human-readable name for your job queue.", - ) - label = _styled_prompt("Name of the queue", type=str) + # 1. Name + if for_compute_env: + _print_section( + console, 1, total, "Name", + subtitle="A human-readable name for the new compute environment.", + ) + label = _styled_prompt("Name of the compute environment", type=str) + else: + _print_section( + console, 1, total, "Name", + subtitle="A human-readable name for your job queue.", + ) + label = _styled_prompt("Name of the queue", type=str) # 2. Provisioning type _print_section( @@ -287,11 +310,11 @@ def _from_scratch_wizard(console): "iops": iops, "throughput": throughput, } - _print_summary(console, params) + _print_summary(console, params, for_compute_env=for_compute_env) return params -def _print_summary(console, params): +def _print_summary(console, params, for_compute_env=False): """Print a styled summary table of the collected wizard parameters. Parameters @@ -302,7 +325,11 @@ def _print_summary(console, params): The collected from-scratch parameters. """ table = Table( - title="[bold cyan]Queue configuration summary[/bold cyan]", + title=( + "[bold cyan]Compute environment configuration summary[/bold cyan]" + if for_compute_env + else "[bold cyan]Queue configuration summary[/bold cyan]" + ), show_header=False, box=None, padding=(0, 2), @@ -311,8 +338,9 @@ def _print_summary(console, params): table.add_column(style="white") instance_label = ", ".join(params["instance_types"]) + name_label = "Compute environment" if for_compute_env else "Name" rows = [ - ("Name", params["label"]), + (name_label, params["label"]), ("Provisioning type", params["provisioning_type"]), ("Allocation strategy", params["allocation_strategy"]), ("Max vCPUs", str(params["max_vcpus"])), @@ -582,6 +610,17 @@ def list_queues(ctx, 'create non-interactively using the options below. Mutually ' 'exclusive with --preset.'), is_flag=True) +@click.option('--add-compute-env', + help=('Add a compute environment to an existing job queue ' + '(identified by --label, which is required). By default ' + 'this launches an interactive wizard; combine with -y/--yes ' + 'to add non-interactively using the options below. A queue ' + f'can hold up to {MAX_COMPUTE_ENVS} compute environments.'), + is_flag=True) +@click.option('--compute-env-name', + help=('Name for the new compute environment when using ' + '--add-compute-env with -y/--yes.'), + default=None) @click.option('--provisioning-type', help='Provisioning type for --from-scratch. Default=on-demand.', type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), @@ -654,6 +693,8 @@ def create_queue(ctx, preset, executor, from_scratch, + add_compute_env, + compute_env_name, provisioning_type, allocation_strategy, max_vcpus, @@ -671,10 +712,38 @@ def create_queue(ctx, By default a preset template is used. Pass --from-scratch to build a custom queue, either interactively (default) or non-interactively with -y/--yes. + Pass --add-compute-env to add a compute environment to an existing queue. """ verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + if from_scratch and add_compute_env: + raise click.UsageError( + '--from-scratch and --add-compute-env cannot be used together.' + ) + + if add_compute_env: + _add_compute_environment( + ctx=ctx, + cloudos_url=cloudos_url, + apikey=apikey, + workspace_id=workspace_id, + verify_ssl=verify_ssl, + label=label, + compute_env_name=compute_env_name, + provisioning_type=provisioning_type, + allocation_strategy=allocation_strategy, + max_vcpus=max_vcpus, + min_vcpus=min_vcpus, + instance_types=instance_types, + volume_type=volume_type, + size=size, + iops=iops, + throughput=throughput, + skip_confirmation=skip_confirmation, + ) + return + if from_scratch: _create_queue_from_scratch( ctx=ctx, @@ -817,3 +886,104 @@ def _create_queue_from_scratch(ctx, except Exception as e: print(f'\tError creating queue: {str(e)}') sys.exit(1) + + +def _add_compute_environment(ctx, + cloudos_url, + apikey, + workspace_id, + verify_ssl, + label, + compute_env_name, + provisioning_type, + allocation_strategy, + max_vcpus, + min_vcpus, + instance_types, + volume_type, + size, + iops, + throughput, + skip_confirmation): + """Handle the --add-compute-env branch of ``cloudos queue create``. + + Adds a compute environment to an existing queue (identified by ``label``). + The queue must exist and have fewer than ``MAX_COMPUTE_ENVS`` compute + environments. When ``skip_confirmation`` is False an interactive wizard + collects the compute environment configuration. + """ + console = Console() + + if label is None: + raise click.UsageError('Missing option --label for --add-compute-env.') + + j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) + + # The queue must already exist to add a compute environment to it. + target_queue = j_queue.find_job_queue_by_label(label) + if target_queue is None: + console.print( + f"[red]Error:[/red] No job queue with label '{label}' was found. " + "Compute environments can only be added to existing queues." + ) + sys.exit(1) + + queue_id = target_queue.get('id') or target_queue.get('_id', '') + current_ce_count = len(target_queue.get('computeEnvironments', [])) + + # A queue cannot exceed the compute environment limit. + if current_ce_count >= MAX_COMPUTE_ENVS: + console.print( + f"[yellow]Warning:[/yellow] {CE_LIMIT_REACHED_MESSAGE}" + ) + sys.exit(0) + + if skip_confirmation: + if compute_env_name is None: + raise click.UsageError( + 'Missing option --compute-env-name for --add-compute-env -y.' + ) + params = { + 'label': compute_env_name, + 'provisioning_type': provisioning_type, + 'allocation_strategy': allocation_strategy, + 'max_vcpus': max_vcpus, + 'min_vcpus': min_vcpus, + 'instance_types': _parse_instance_types(instance_types), + 'volume_type': volume_type, + 'size': size, + 'iops': iops, + 'throughput': throughput, + } + _validate_from_scratch_flags(params) + else: + params = _from_scratch_wizard( + console, for_compute_env=True, queue_label=label + ) + + print('Executing add compute environment...') + + try: + j_queue.add_compute_environment( + queue_id=queue_id, + queue_label=label, + ce_name=params['label'], + provisioning_type=params['provisioning_type'], + allocation_strategy=params['allocation_strategy'], + max_vcpus=params['max_vcpus'], + min_vcpus=params['min_vcpus'], + instance_types=params['instance_types'], + volume_type=params['volume_type'], + size=params['size'], + iops=params['iops'], + throughput=params['throughput'], + ) + print(f'\tCompute environment "{params["label"]}" added successfully ' + f'to queue "{label}".') + print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') + # Inform the user if this addition reached the compute environment limit. + if current_ce_count + 1 >= MAX_COMPUTE_ENVS: + print(f'\t{CE_LIMIT_REACHED_MESSAGE}') + except Exception as e: + print(f'\tError adding compute environment: {str(e)}') + sys.exit(1) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 5d975b04..bf4878eb 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -160,6 +160,15 @@ }, } +# Maximum number of compute environments a single job queue can hold. +MAX_COMPUTE_ENVS = 3 + +# Message shown once a job queue reaches the compute environment limit. +CE_LIMIT_REACHED_MESSAGE = ( + "You have reached the limit for compute environments for this job queue. " + "Job queues can have up to 3 compute environments." +) + @dataclass class Queue(Cloudos): @@ -498,6 +507,79 @@ def create_job_queue_from_scratch(self, BadRequestException If the API returns a 4xx or 5xx response. """ + compute_resources = self._build_compute_resources( + provisioning_type=provisioning_type, + allocation_strategy=allocation_strategy, + max_vcpus=max_vcpus, + min_vcpus=min_vcpus, + instance_types=instance_types, + volume_type=volume_type, + size=size, + iops=iops, + throughput=throughput, + ) + payload = { + "id": "", + "label": label, + "description": description, + "executor": executor, + "status": "ToCreate", + "environment": { + "computeEnvironmentName": label, + "computeResources": compute_resources, + }, + "templateName": "", + "templateDescription": "", + "isDefault": False, + } + return self._post_job_queue(payload) + + @staticmethod + def _build_compute_resources(provisioning_type, + allocation_strategy, + max_vcpus, + min_vcpus, + instance_types, + volume_type, + size, + iops, + throughput=None): + """Validate inputs and build an AWS Batch ``computeResources`` dict. + + Parameters + ---------- + provisioning_type : str + One of ``'on-demand'`` or ``'spot'``. + allocation_strategy : str + AWS Batch allocation strategy. Must be valid for the chosen + ``provisioning_type`` (see ``ALLOCATION_STRATEGIES``). + max_vcpus : int + Maximum number of vCPUs. + min_vcpus : int + Minimum number of vCPUs. + instance_types : list[str] + Instance types to allow. + volume_type : str + One of ``'gp3'`` or ``'io2'``. + size : int + Volume size in GiB. + iops : int + Provisioned IOPS for the volume. + throughput : int or None, optional + Volume throughput in MB/s. Only applicable to ``gp3`` volumes. + + Returns + ------- + compute_resources : dict + The assembled ``computeResources`` dict. + + Raises + ------ + ValueError + If the provisioning type, allocation strategy or volume type are + not recognised, or the allocation strategy is incompatible with + the provisioning type. + """ if provisioning_type not in PROVISIONING_TYPES: valid = ', '.join(PROVISIONING_TYPES.keys()) raise ValueError( @@ -539,19 +621,114 @@ def create_job_queue_from_scratch(self, } if provisioning_type == "spot": compute_resources["bidPercentage"] = 100 + return compute_resources + + def find_job_queue_by_label(self, label): + """Find a job queue in the workspace by its label. + + Parameters + ---------- + label : str + The label of the job queue to find. + + Returns + ------- + queue : dict or None + The matching job queue dict, or ``None`` if no queue with that + label exists. + """ + for q in self.get_job_queues(): + if q.get('label') == label: + return q + return None + + def add_compute_environment(self, + queue_id, + queue_label, + ce_name, + provisioning_type, + allocation_strategy, + max_vcpus, + min_vcpus, + instance_types, + volume_type, + size, + iops, + throughput=None): + """Add a compute environment to an existing job queue. + Parameters + ---------- + queue_id : str + The Lifebit Platform ID of the target job queue. + queue_label : str + The label of the target job queue. + ce_name : str + Name for the new compute environment. + provisioning_type : str + One of ``'on-demand'`` or ``'spot'``. + allocation_strategy : str + AWS Batch allocation strategy. Must be valid for the chosen + ``provisioning_type`` (see ``ALLOCATION_STRATEGIES``). + max_vcpus : int + Maximum number of vCPUs for the compute environment. + min_vcpus : int + Minimum number of vCPUs for the compute environment. + instance_types : list[str] + Instance types to allow. + volume_type : str + One of ``'gp3'`` or ``'io2'``. + size : int + Volume size in GiB. + iops : int + Provisioned IOPS for the volume. + throughput : int or None, optional + Volume throughput in MB/s. Only applicable to ``gp3`` volumes. + + Returns + ------- + response_data : dict + The updated job queue as returned by the API. + + Raises + ------ + ValueError + If the provisioning type, allocation strategy or volume type are + not recognised, or the allocation strategy is incompatible with + the provisioning type. + BadRequestException + If the API returns a 4xx or 5xx response. + """ + compute_resources = self._build_compute_resources( + provisioning_type=provisioning_type, + allocation_strategy=allocation_strategy, + max_vcpus=max_vcpus, + min_vcpus=min_vcpus, + instance_types=instance_types, + volume_type=volume_type, + size=size, + iops=iops, + throughput=throughput, + ) payload = { - "id": "", - "label": label, - "description": description, - "executor": executor, - "status": "ToCreate", + "label": queue_label, "environment": { - "computeEnvironmentName": label, + "computeEnvironmentName": ce_name, "computeResources": compute_resources, }, - "templateName": "", - "templateDescription": "", - "isDefault": False, } - return self._post_job_queue(payload) + headers = { + "Content-Type": "application/json", + "apikey": self.apikey, + } + r = retry_requests_post( + "{}/api/v1/teams/aws/v2/job-queue/{}/compute-environment?teamId={}".format( + self.cloudos_url, queue_id, self.workspace_id + ), + headers=headers, + json=payload, + verify=self.verify, + ) + if r.status_code >= 400: + raise BadRequestException(r) + return json.loads(r.content) diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index 4b9076b1..e48f3015 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -561,3 +561,321 @@ def test_from_scratch_interactive_wizard_success(self): assert result.exit_code == 0 assert 'created successfully' in result.output + +# =========================================================================== +# Unit tests – Queue.find_job_queue_by_label() & add_compute_environment() +# =========================================================================== + +QUEUES_LIST_FILE = 'tests/test_data/queue/queues.json' +SYSTEM_QUEUES_LIST_FILE = 'tests/test_data/queue/system_queues.json' + +with open(QUEUES_LIST_FILE) as f: + QUEUES_LIST_STR = f.read() +with open(SYSTEM_QUEUES_LIST_FILE) as f: + SYSTEM_QUEUES_LIST_STR = f.read() + + +def _queue_with_n_ces(label, queue_id, n): + """Build a single-queue list JSON string with ``n`` compute environments.""" + ces = [ + { + 'label': f'CE-{i}', + 'environment': {}, + 'status': 'Ready', + } + for i in range(n) + ] + return json.dumps([ + { + 'id': queue_id, + 'name': label, + 'label': label, + 'description': '', + 'isDefault': False, + 'resourceType': '', + 'executor': 'nextflow', + 'computeEnvironments': ces, + 'status': 'Ready', + } + ]) + + +class TestFindAndAddComputeEnvironment: + def _make_queue(self): + return Queue( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + cromwell_token=None, + workspace_id=WORKSPACE_ID, + ) + + def _add_get_queues(self): + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", + body=QUEUES_LIST_STR, + status=200, + content_type='application/json', + ) + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/system-job-queues?teamId={WORKSPACE_ID}", + body=SYSTEM_QUEUES_LIST_STR, + status=200, + content_type='application/json', + ) + + @responses.activate + def test_find_job_queue_by_label_found(self): + self._add_get_queues() + q = self._make_queue() + found = q.find_job_queue_by_label('test_queue_label') + assert found is not None + assert found['label'] == 'test_queue_label' + + @responses.activate + def test_find_job_queue_by_label_not_found(self): + self._add_get_queues() + q = self._make_queue() + assert q.find_job_queue_by_label('does-not-exist') is None + + @responses.activate + def test_add_compute_environment_posts_correct_payload(self): + queue_id = 'q123' + responses.add( + responses.POST, + url=(f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/{queue_id}/" + f"compute-environment?teamId={WORKSPACE_ID}"), + body=QUEUES_LIST_STR, + status=200, + content_type='application/json', + ) + q = self._make_queue() + q.add_compute_environment( + queue_id=queue_id, + queue_label='my-queue', + ce_name='New_spot_CE', + provisioning_type='spot', + allocation_strategy='SPOT_CAPACITY_OPTIMIZED', + max_vcpus=512, + min_vcpus=0, + instance_types=['optimal'], + volume_type='gp3', + size=1000, + iops=3000, + throughput=125, + ) + payload = json.loads(responses.calls[0].request.body) + assert payload['label'] == 'my-queue' + env = payload['environment'] + assert env['computeEnvironmentName'] == 'New_spot_CE' + cr = env['computeResources'] + assert cr['type'] == 'SPOT' + assert cr['bidPercentage'] == 100 + assert cr['allocationStrategy'] == 'SPOT_CAPACITY_OPTIMIZED' + assert cr['volume']['throughput'] == 125 + + @responses.activate + def test_add_compute_environment_raises_on_400(self): + queue_id = 'q123' + responses.add( + responses.POST, + url=(f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/{queue_id}/" + f"compute-environment?teamId={WORKSPACE_ID}"), + body=json.dumps({'statusCode': 400, 'message': 'Bad Request.'}), + status=400, + content_type='application/json', + ) + q = self._make_queue() + with pytest.raises(BadRequestException): + q.add_compute_environment( + queue_id=queue_id, queue_label='my-queue', ce_name='CE', + provisioning_type='on-demand', + allocation_strategy='BEST_FIT_PROGRESSIVE', max_vcpus=512, + min_vcpus=0, instance_types=['optimal'], volume_type='gp3', + size=1000, iops=3000, throughput=125, + ) + + +# =========================================================================== +# CLI integration tests – `cloudos queue create --add-compute-env` +# =========================================================================== + +class TestAddComputeEnvironmentCLI: + def _mock_get_queues(self, m, queues_str): + m.get( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", + text=queues_str, + status_code=200, + ) + m.get( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/system-job-queues?teamId={WORKSPACE_ID}", + text='[]', + status_code=200, + ) + + def test_add_compute_env_options_in_help(self): + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) + assert result.exit_code == 0 + assert '--add-compute-env' in result.output + assert '--compute-env-name' in result.output + + def test_add_compute_env_missing_label_fails(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--add-compute-env', '--yes', + '--compute-env-name', 'CE-new', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_add_compute_env_queue_not_found(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'no-such-queue', + '--add-compute-env', '--yes', + '--compute-env-name', 'CE-new', + ] + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, _queue_with_n_ces('other', 'q1', 1)) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 1 + assert 'was found' in result.output or 'No job queue' in result.output + + def test_add_compute_env_limit_reached_exits(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'full-queue', + '--add-compute-env', '--yes', + '--compute-env-name', 'CE-new', + ] + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, _queue_with_n_ces('full-queue', 'qfull', 3)) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 0 + assert 'reached the limit' in result.output + + def test_add_compute_env_success(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'my-queue', + '--add-compute-env', '--yes', + '--compute-env-name', 'CE-new', + ] + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, _queue_with_n_ces('my-queue', 'qok', 1)) + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/qok/" + f"compute-environment?teamId={WORKSPACE_ID}", + text=QUEUES_LIST_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 0 + assert 'added successfully' in result.output + assert 'reached the limit' not in result.output + + def test_add_compute_env_third_ce_shows_limit_message(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'two-ce-queue', + '--add-compute-env', '--yes', + '--compute-env-name', 'CE-third', + ] + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, _queue_with_n_ces('two-ce-queue', 'q2ce', 2)) + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/q2ce/" + f"compute-environment?teamId={WORKSPACE_ID}", + text=QUEUES_LIST_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 0 + assert 'added successfully' in result.output + assert 'reached the limit' in result.output + + def test_add_compute_env_missing_ce_name_fails(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'my-queue', + '--add-compute-env', '--yes', + ] + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, _queue_with_n_ces('my-queue', 'qok', 1)) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_add_compute_env_conflicts_with_from_scratch(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'my-queue', + '--add-compute-env', '--from-scratch', '--yes', + '--compute-env-name', 'CE-new', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + + def test_add_compute_env_interactive_wizard_success(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'my-queue', + '--add-compute-env', + ] + wizard_input = '\n'.join([ + 'My Wizard CE', + 'spot', + 'SPOT_CAPACITY_OPTIMIZED', + '512', + '0', + 'optimal', + 'gp3', + '1000', + '3000', + '125', + ]) + '\n' + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, _queue_with_n_ces('my-queue', 'qwiz', 1)) + m.post( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/qwiz/" + f"compute-environment?teamId={WORKSPACE_ID}", + text=QUEUES_LIST_STR, + status_code=200, + ) + result = runner.invoke(run_cloudos_cli, args, input=wizard_input) + assert result.exit_code == 0 + assert 'added successfully' in result.output + From 53817692c8c2ce3e3fe2dcbeb99fdcb346cd55ca Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Mon, 15 Jun 2026 16:09:45 +0200 Subject: [PATCH 05/27] refactor: update queue --- cloudos_cli/queue/cli.py | 77 ++++++++-- cloudos_cli/queue/queue.py | 44 +++++- cloudos_cli/utils/errors.py | 26 ++++ tests/test_queue/test_create_queue.py | 197 +++++++++++++++++++++++++- 4 files changed, 325 insertions(+), 19 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 1ff8cc48..1605afdd 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -17,6 +17,8 @@ DEFAULT_MIN_VCPUS, MAX_COMPUTE_ENVS, CE_LIMIT_REACHED_MESSAGE, + MAX_WORKSPACE_COMPUTE_ENVS, + WORKSPACE_CE_LIMIT_REACHED_MESSAGE, _STANDARD_INSTANCE_TYPES, ) from cloudos_cli.utils.resources import ssl_selector @@ -480,6 +482,27 @@ def _check_range(name, value, spec): ) +def _check_workspace_ce_limit(console, j_queue, queues=None): + """Exit with a warning if the workspace has reached its CE limit. + + Parameters + ---------- + console : rich.console.Console + The console used for rich output. + j_queue : Queue + The queue client used to count compute environments. + queues : list or None, optional + A pre-fetched list of job queue dicts. If ``None``, the queues are + fetched. + """ + count = j_queue.count_workspace_compute_environments(queues=queues) + if count >= MAX_WORKSPACE_COMPUTE_ENVS: + console.print( + f"[yellow]Warning:[/yellow] {WORKSPACE_CE_LIMIT_REACHED_MESSAGE}" + ) + sys.exit(0) + + # Create the queue group @@ -586,7 +609,8 @@ def list_queues(ctx, required=False, default=None) @click.option('--description', - help='Short description of the new job queue.', + help=('Short description of the new job queue. Required when ' + 'creating a queue (not used with --add-compute-env).'), default='', required=False) @click.option('--preset', @@ -744,6 +768,11 @@ def create_queue(ctx, ) return + # --description is required when creating a queue (both preset and + # from-scratch paths). It is not used when adding a compute environment. + if not description: + raise click.UsageError('Missing option --description.') + if from_scratch: _create_queue_from_scratch( ctx=ctx, @@ -779,6 +808,11 @@ def create_queue(ctx, instance_count = len(cr.get('instanceTypes', [])) template_name = preset_info['templateName'] + j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) + + # Creating a queue creates a compute environment; enforce the workspace limit. + _check_workspace_ce_limit(Console(), j_queue) + if not skip_confirmation: click.echo('\nYou are about to create the following job queue:') click.echo(f' Label : {label}') @@ -795,8 +829,8 @@ def create_queue(ctx, click.echo('Aborted.') sys.exit(0) + console = Console() print('Executing queue create...') - j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) try: queue_id = j_queue.create_job_queue( @@ -805,11 +839,11 @@ def create_queue(ctx, preset_name=preset, executor=executor, ) - print(f'\tQueue "{label}" created successfully.') + console.print(f'\t[green]Queue "{label}" created successfully.[/green]') print(f'\tQueue ID : {queue_id}') print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') except Exception as e: - print(f'\tError creating queue: {str(e)}') + console.print(f'\t[red]Error creating queue:[/red] {str(e)}') sys.exit(1) @@ -843,6 +877,8 @@ def _create_queue_from_scratch(ctx, if ctx.get_parameter_source('preset') == click.core.ParameterSource.COMMANDLINE: raise click.UsageError('--from-scratch cannot be combined with --preset.') + j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) + if skip_confirmation: params = { 'label': label, @@ -859,11 +895,14 @@ def _create_queue_from_scratch(ctx, if params['label'] is None: raise click.UsageError('Missing option --label for --from-scratch -y.') _validate_from_scratch_flags(params) + # Creating a queue creates a compute environment; enforce the limit. + _check_workspace_ce_limit(console, j_queue) else: + # Creating a queue creates a compute environment; enforce the limit. + _check_workspace_ce_limit(console, j_queue) params = _from_scratch_wizard(console) print('Executing queue create...') - j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) try: queue_id = j_queue.create_job_queue_from_scratch( @@ -880,11 +919,13 @@ def _create_queue_from_scratch(ctx, throughput=params['throughput'], executor=executor, ) - print(f'\tQueue "{params["label"]}" created successfully.') + console.print( + f'\t[green]Queue "{params["label"]}" created successfully.[/green]' + ) print(f'\tQueue ID : {queue_id}') print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') except Exception as e: - print(f'\tError creating queue: {str(e)}') + console.print(f'\t[red]Error creating queue:[/red] {str(e)}') sys.exit(1) @@ -919,8 +960,13 @@ def _add_compute_environment(ctx, j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) - # The queue must already exist to add a compute environment to it. - target_queue = j_queue.find_job_queue_by_label(label) + # The queue must already exist to add a compute environment to it. Compute + # environments can only be added to created (non-system) queues, and system + # queues do not count towards the workspace limit. + team_queues = j_queue.get_job_queues(exclude_system_queues=True) + target_queue = next( + (q for q in team_queues if q.get('label') == label), None + ) if target_queue is None: console.print( f"[red]Error:[/red] No job queue with label '{label}' was found. " @@ -938,6 +984,9 @@ def _add_compute_environment(ctx, ) sys.exit(0) + # Adding a compute environment must not exceed the workspace limit. + _check_workspace_ce_limit(console, j_queue, queues=team_queues) + if skip_confirmation: if compute_env_name is None: raise click.UsageError( @@ -978,12 +1027,14 @@ def _add_compute_environment(ctx, iops=params['iops'], throughput=params['throughput'], ) - print(f'\tCompute environment "{params["label"]}" added successfully ' - f'to queue "{label}".') + console.print( + f'\t[green]Compute environment "{params["label"]}" added ' + f'successfully to queue "{label}".[/green]' + ) print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') # Inform the user if this addition reached the compute environment limit. if current_ce_count + 1 >= MAX_COMPUTE_ENVS: - print(f'\t{CE_LIMIT_REACHED_MESSAGE}') + console.print(f'\t[yellow]Warning:[/yellow] {CE_LIMIT_REACHED_MESSAGE}') except Exception as e: - print(f'\tError adding compute environment: {str(e)}') + console.print(f'\t[red]Error adding compute environment:[/red] {str(e)}') sys.exit(1) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index bf4878eb..1e50168d 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -8,7 +8,10 @@ from dataclasses import dataclass from typing import Union from cloudos_cli.clos import Cloudos -from cloudos_cli.utils.errors import BadRequestException +from cloudos_cli.utils.errors import ( + BadRequestException, + ComputeEnvAuthorizationException, +) from cloudos_cli.utils.requests import retry_requests_post @@ -169,6 +172,15 @@ "Job queues can have up to 3 compute environments." ) +# Maximum number of compute environments a workspace can hold across all queues. +MAX_WORKSPACE_COMPUTE_ENVS = 10 + +# Message shown once a workspace reaches the compute environment limit. +WORKSPACE_CE_LIMIT_REACHED_MESSAGE = ( + "You have reached the limit for compute environments in your workspace. " + "Workspaces can have up to 10 compute environments." +) + @dataclass class Queue(Cloudos): @@ -642,6 +654,27 @@ def find_job_queue_by_label(self, label): return q return None + def count_workspace_compute_environments(self, queues=None): + """Count the total compute environments across all queues in the workspace. + + System job queues are not counted towards the workspace limit. + + Parameters + ---------- + queues : list or None, optional + A list of (non-system) job queue dicts as returned by + ``get_job_queues(exclude_system_queues=True)``. If ``None``, the + queues are fetched, excluding system queues. + + Returns + ------- + count : int + The total number of compute environments in the workspace. + """ + if queues is None: + queues = self.get_job_queues(exclude_system_queues=True) + return sum(len(q.get('computeEnvironments', [])) for q in queues) + def add_compute_environment(self, queue_id, queue_label, @@ -710,8 +743,9 @@ def add_compute_environment(self, iops=iops, throughput=throughput, ) + # The add-compute-environment endpoint only accepts the ``environment`` + # object in the request body (see apiAddComputeEnvironmentToJobQueue). payload = { - "label": queue_label, "environment": { "computeEnvironmentName": ce_name, "computeResources": compute_resources, @@ -729,6 +763,12 @@ def add_compute_environment(self, json=payload, verify=self.verify, ) + if r.status_code == 401: + # The add-compute-environment endpoint + # (apiAddComputeEnvironmentToJobQueue) only accepts session/bearer + # authentication; API keys are not authorised for it. Surface a + # clear, actionable message instead of a raw "Unauthorized". + raise ComputeEnvAuthorizationException(queue_label) if r.status_code >= 400: raise BadRequestException(r) return json.loads(r.content) diff --git a/cloudos_cli/utils/errors.py b/cloudos_cli/utils/errors.py index 8bdd72e0..f5b0d28d 100755 --- a/cloudos_cli/utils/errors.py +++ b/cloudos_cli/utils/errors.py @@ -80,6 +80,32 @@ def __init__(self, workspace_id): self.workspace_id = workspace_id +class ComputeEnvAuthorizationException(Exception): + """Raised when adding a compute environment is rejected by the platform. + + The add-compute-environment endpoint + (``apiAddComputeEnvironmentToJobQueue``) only accepts session/bearer + authentication. API keys are authenticated but not authorised for this + operation, so the platform responds with HTTP 401. + + Parameters + ---------- + queue_label : str + The label of the target job queue. + """ + def __init__(self, queue_label): + msg = ( + "Not authorised to add a compute environment to queue " + "'{}'. Adding a compute environment to an existing queue is not " + "supported with API key authentication; this operation requires " + "an interactive (session/bearer) login. You can still create a " + "new queue with a compute environment using " + "'cloudos queue create'.".format(queue_label) + ) + super(ComputeEnvAuthorizationException, self).__init__(msg) + self.queue_label = queue_label + + class JobAccessDeniedException(Exception): def __init__(self, job_id, job_owner_name=None, current_user_name=None): if job_owner_name and current_user_name: diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index e48f3015..87bd4cd3 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -7,7 +7,10 @@ from click.testing import CliRunner from cloudos_cli.queue.queue import Queue, QUEUE_PRESETS -from cloudos_cli.utils.errors import BadRequestException +from cloudos_cli.utils.errors import ( + BadRequestException, + ComputeEnvAuthorizationException, +) from cloudos_cli.__main__ import run_cloudos_cli from tests.functions_for_pytest import load_json_file @@ -27,6 +30,47 @@ CREATE_RESPONSE_JSON_DICT = json.loads(CREATE_RESPONSE_JSON_STR) +def _mock_get_queues_with_total_ces(m, total_ces, n_queues=1): + """Mock the GET job-queues endpoints with queues totalling ``total_ces`` CEs. + + The compute environments are spread across ``n_queues`` team queues. The + system-job-queues endpoint is mocked with an empty list. + """ + per_queue = [] + remaining = total_ces + for i in range(n_queues): + count = remaining if i == n_queues - 1 else remaining // (n_queues - i) + remaining -= count + per_queue.append(count) + queues = [ + { + 'id': f'q{i}', + 'name': f'queue-{i}', + 'label': f'queue-{i}', + 'description': '', + 'isDefault': False, + 'resourceType': '', + 'executor': 'nextflow', + 'computeEnvironments': [ + {'label': f'CE-{i}-{j}', 'environment': {}, 'status': 'Ready'} + for j in range(count) + ], + 'status': 'Ready', + } + for i, count in enumerate(per_queue) + ] + m.get( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", + text=json.dumps(queues), + status_code=200, + ) + m.get( + f"{CLOUDOS_URL}/api/v1/teams/aws/v2/system-job-queues?teamId={WORKSPACE_ID}", + text='[]', + status_code=200, + ) + + # =========================================================================== # Unit tests – Queue.get_preset_template() # =========================================================================== @@ -229,6 +273,7 @@ def test_create_command_all_presets_in_help(self): def test_create_queue_success_with_yes_flag(self): runner = CliRunner() with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) m.post( f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", text=CREATE_RESPONSE_JSON_STR, @@ -242,6 +287,7 @@ def test_create_queue_success_with_yes_flag(self): def test_create_queue_shows_url_on_success(self): runner = CliRunner() with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) m.post( f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", text=CREATE_RESPONSE_JSON_STR, @@ -260,9 +306,12 @@ def test_create_queue_aborted_on_confirmation_decline(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Test Queue', + '--description', 'A test queue', '--preset', 'standard-stable', ] - result = runner.invoke(run_cloudos_cli, args, input='n\n') + with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) + result = runner.invoke(run_cloudos_cli, args, input='n\n') assert result.exit_code == 0 assert 'Aborted' in result.output @@ -274,9 +323,11 @@ def test_create_queue_proceeds_on_confirmation_accept(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Test Queue', + '--description', 'A test queue', '--preset', 'standard-stable', ] with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) m.post( f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", text=CREATE_RESPONSE_JSON_STR, @@ -290,6 +341,7 @@ def test_create_queue_api_error_exits_nonzero(self): runner = CliRunner() error_body = json.dumps({'statusCode': 400, 'message': 'Bad Request.'}) with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) m.post( f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", text=error_body, @@ -299,6 +351,15 @@ def test_create_queue_api_error_exits_nonzero(self): assert result.exit_code == 1 assert 'Error' in result.output + def test_create_queue_workspace_limit_reached_exits(self): + runner = CliRunner() + with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 10, n_queues=4) + result = runner.invoke(run_cloudos_cli, self._base_args()) + assert result.exit_code == 0 + assert 'reached the limit for compute environments in your workspace' \ + in result.output + def test_create_queue_invalid_preset_rejected(self): runner = CliRunner() args = [ @@ -325,6 +386,21 @@ def test_create_queue_missing_label_fails(self): result = runner.invoke(run_cloudos_cli, args) assert result.exit_code != 0 + def test_create_queue_missing_description_fails(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Test Queue', + '--preset', 'standard-stable', + '--yes', + ] + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code != 0 + assert 'Missing option --description' in result.output + # =========================================================================== # Unit tests – Queue.create_job_queue_from_scratch() (API mocked) @@ -458,9 +534,11 @@ def test_from_scratch_yes_success(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Custom Queue', + '--description', 'A custom queue', '--from-scratch', '--yes', ] with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) m.post( f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", text=CREATE_RESPONSE_JSON_STR, @@ -470,6 +548,24 @@ def test_from_scratch_yes_success(self): assert result.exit_code == 0 assert 'created successfully' in result.output + def test_from_scratch_workspace_limit_reached_exits(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'Custom Queue', + '--description', 'A custom queue', + '--from-scratch', '--yes', + ] + with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 10, n_queues=3) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 0 + assert 'reached the limit for compute environments in your workspace' \ + in result.output + def test_from_scratch_mutually_exclusive_with_preset(self): runner = CliRunner() args = [ @@ -478,6 +574,7 @@ def test_from_scratch_mutually_exclusive_with_preset(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Custom Queue', + '--description', 'A custom queue', '--from-scratch', '--preset', 'standard-gpu', '--yes', ] result = runner.invoke(run_cloudos_cli, args) @@ -535,6 +632,7 @@ def test_from_scratch_interactive_wizard_success(self): '--apikey', APIKEY, '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, + '--description', 'A wizard queue', '--from-scratch', ] # Wizard answers: name, provisioning, strategy, max, min, instances, @@ -552,6 +650,7 @@ def test_from_scratch_interactive_wizard_success(self): '125', ]) + '\n' with requests_mock_module.Mocker() as m: + _mock_get_queues_with_total_ces(m, 1) m.post( f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", text=CREATE_RESPONSE_JSON_STR, @@ -639,6 +738,33 @@ def test_find_job_queue_by_label_not_found(self): q = self._make_queue() assert q.find_job_queue_by_label('does-not-exist') is None + def test_count_workspace_compute_environments_from_list(self): + q = self._make_queue() + queues = [ + {'computeEnvironments': [{}, {}]}, + {'computeEnvironments': [{}]}, + {}, + ] + assert q.count_workspace_compute_environments(queues=queues) == 3 + + @responses.activate + def test_count_workspace_compute_environments_fetches_queues(self): + # System queues are excluded from the workspace CE count, so only the + # team job-queues endpoint contributes. + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", + body=QUEUES_LIST_STR, + status=200, + content_type='application/json', + ) + q = self._make_queue() + team_queues = json.loads(QUEUES_LIST_STR) + expected = sum( + len(x.get('computeEnvironments', [])) for x in team_queues + ) + assert q.count_workspace_compute_environments() == expected + @responses.activate def test_add_compute_environment_posts_correct_payload(self): queue_id = 'q123' @@ -665,8 +791,11 @@ def test_add_compute_environment_posts_correct_payload(self): iops=3000, throughput=125, ) - payload = json.loads(responses.calls[0].request.body) - assert payload['label'] == 'my-queue' + request = responses.calls[0].request + assert request.headers['apikey'] == APIKEY + payload = json.loads(request.body) + # Only the environment object should be sent in the body. + assert list(payload.keys()) == ['environment'] env = payload['environment'] assert env['computeEnvironmentName'] == 'New_spot_CE' cr = env['computeResources'] @@ -696,6 +825,27 @@ def test_add_compute_environment_raises_on_400(self): size=1000, iops=3000, throughput=125, ) + @responses.activate + def test_add_compute_environment_raises_on_401(self): + queue_id = 'q123' + responses.add( + responses.POST, + url=(f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/{queue_id}/" + f"compute-environment?teamId={WORKSPACE_ID}"), + body='', + status=401, + content_type='application/json', + ) + q = self._make_queue() + with pytest.raises(ComputeEnvAuthorizationException): + q.add_compute_environment( + queue_id=queue_id, queue_label='my-queue', ce_name='CE', + provisioning_type='on-demand', + allocation_strategy='BEST_FIT_PROGRESSIVE', max_vcpus=512, + min_vcpus=0, instance_types=['optimal'], volume_type='gp3', + size=1000, iops=3000, throughput=125, + ) + # =========================================================================== # CLI integration tests – `cloudos queue create --add-compute-env` @@ -768,6 +918,45 @@ def test_add_compute_env_limit_reached_exits(self): assert result.exit_code == 0 assert 'reached the limit' in result.output + def test_add_compute_env_workspace_limit_reached_exits(self): + runner = CliRunner() + args = [ + 'queue', 'create', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--workspace-id', WORKSPACE_ID, + '--label', 'my-queue', + '--add-compute-env', '--yes', + '--compute-env-name', 'CE-new', + ] + # Target queue has only 1 CE (under per-queue limit) but the workspace + # already holds 10 CEs in total across all queues. + queues = [ + { + 'id': 'qok', 'name': 'my-queue', 'label': 'my-queue', + 'description': '', 'isDefault': False, 'resourceType': '', + 'executor': 'nextflow', 'status': 'Ready', + 'computeEnvironments': [ + {'label': 'CE-0', 'environment': {}, 'status': 'Ready'} + ], + }, + { + 'id': 'qother', 'name': 'other', 'label': 'other', + 'description': '', 'isDefault': False, 'resourceType': '', + 'executor': 'nextflow', 'status': 'Ready', + 'computeEnvironments': [ + {'label': f'CE-{i}', 'environment': {}, 'status': 'Ready'} + for i in range(9) + ], + }, + ] + with requests_mock_module.Mocker() as m: + self._mock_get_queues(m, json.dumps(queues)) + result = runner.invoke(run_cloudos_cli, args) + assert result.exit_code == 0 + assert 'reached the limit for compute environments in your workspace' \ + in result.output + def test_add_compute_env_success(self): runner = CliRunner() args = [ From 5de372abb887a709a054dd2e77b70cdfe0eb96fe Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Mon, 15 Jun 2026 17:51:08 +0200 Subject: [PATCH 06/27] refactor: add message for azure --- cloudos_cli/queue/cli.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 1605afdd..fa6fcde0 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -546,6 +546,10 @@ def queue(): @click.option('--exclude-system-queues', help='Exclude system job queues from the list.', is_flag=True) +@click.option('--execution-platform', + help='Name of the execution platform implemented in your Lifebit Platform. Default=aws.', + type=click.Choice(['aws', 'azure', 'hpc']), + default='aws') @click.option('--disable-ssl-verification', help=('Disable SSL certificate verification. Please, remember that this option is ' + 'not generally recommended for security reasons.'), @@ -563,6 +567,7 @@ def list_queues(ctx, output_format, all_fields, exclude_system_queues, + execution_platform, disable_ssl_verification, ssl_cert, profile): @@ -570,6 +575,16 @@ def list_queues(ctx, # apikey, cloudos_url, and workspace_id are now automatically resolved by the decorator verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + + # Batch job queues are an AWS-only feature; they are not available in + # Azure workspaces. + if execution_platform == 'azure': + Console().print( + '[yellow]Warning:[/yellow] Batch job queues are not available in ' + 'Azure workspaces.' + ) + sys.exit(0) + print('Executing list...') j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) my_queues = j_queue.get_job_queues(exclude_system_queues=exclude_system_queues) @@ -699,6 +714,10 @@ def list_queues(ctx, 'skip_confirmation', help='Skip the confirmation prompt and proceed immediately.', is_flag=True) +@click.option('--execution-platform', + help='Name of the execution platform implemented in your Lifebit Platform. Default=aws.', + type=click.Choice(['aws', 'azure', 'hpc']), + default='aws') @click.option('--disable-ssl-verification', help=('Disable SSL certificate verification. Please, remember that this option is ' 'not generally recommended for security reasons.'), @@ -729,6 +748,7 @@ def create_queue(ctx, iops, throughput, skip_confirmation, + execution_platform, disable_ssl_verification, ssl_cert, profile): @@ -741,6 +761,15 @@ def create_queue(ctx, verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + # Batch job queues are an AWS-only feature; they are not available in + # Azure workspaces. + if execution_platform == 'azure': + Console().print( + '[yellow]Warning:[/yellow] Batch job queues are not available in ' + 'Azure workspaces.' + ) + sys.exit(0) + if from_scratch and add_compute_env: raise click.UsageError( '--from-scratch and --add-compute-env cannot be used together.' From 6d5535aba20a1f20c2a76c70553004c3b7d20f56 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Mon, 15 Jun 2026 18:02:41 +0200 Subject: [PATCH 07/27] refactor: add default option --- cloudos_cli/queue/cli.py | 11 ++++++++++- cloudos_cli/queue/queue.py | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index fa6fcde0..4e6bc0a2 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -714,6 +714,9 @@ def list_queues(ctx, 'skip_confirmation', help='Skip the confirmation prompt and proceed immediately.', is_flag=True) +@click.option('--set-default', + help='Set the new job queue as the workspace default. Default=False.', + is_flag=True) @click.option('--execution-platform', help='Name of the execution platform implemented in your Lifebit Platform. Default=aws.', type=click.Choice(['aws', 'azure', 'hpc']), @@ -748,6 +751,7 @@ def create_queue(ctx, iops, throughput, skip_confirmation, + set_default, execution_platform, disable_ssl_verification, ssl_cert, @@ -822,6 +826,7 @@ def create_queue(ctx, iops=iops, throughput=throughput, skip_confirmation=skip_confirmation, + set_default=set_default, ) return @@ -852,6 +857,7 @@ def create_queue(ctx, click.echo(f' Max vCPUs : {max_vcpus_preset}') click.echo(f' Instance types : {instance_count} types') click.echo(f' Executor : {executor}') + click.echo(f' Set as default : {set_default}') click.echo(f' Workspace : {workspace_id}') click.echo('') if not click.confirm('Proceed with queue creation?'): @@ -867,6 +873,7 @@ def create_queue(ctx, description=description, preset_name=preset, executor=executor, + is_default=set_default, ) console.print(f'\t[green]Queue "{label}" created successfully.[/green]') print(f'\tQueue ID : {queue_id}') @@ -893,7 +900,8 @@ def _create_queue_from_scratch(ctx, size, iops, throughput, - skip_confirmation): + skip_confirmation, + set_default): """Handle the --from-scratch branch of ``cloudos queue create``. When ``skip_confirmation`` is False, an interactive wizard collects all the @@ -947,6 +955,7 @@ def _create_queue_from_scratch(ctx, iops=params['iops'], throughput=params['throughput'], executor=executor, + is_default=set_default, ) console.print( f'\t[green]Queue "{params["label"]}" created successfully.[/green]' diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 1e50168d..8b701d51 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -384,7 +384,8 @@ def get_available_instances(self): raise BadRequestException(r) return json.loads(r.content) - def create_job_queue(self, label, description, preset_name, executor="nextflow"): + def create_job_queue(self, label, description, preset_name, executor="nextflow", + is_default=False): """Create a new job queue in the workspace using a preset template. Parameters @@ -397,6 +398,9 @@ def create_job_queue(self, label, description, preset_name, executor="nextflow") One of the supported preset keys (see ``QUEUE_PRESETS``). executor : str, optional Workflow executor. Defaults to ``'nextflow'``. + is_default : bool, optional + Whether to set the queue as the workspace default. Defaults to + ``False``. Returns ------- @@ -421,7 +425,7 @@ def create_job_queue(self, label, description, preset_name, executor="nextflow") }, "templateName": preset["templateName"], "templateDescription": preset["templateDescription"], - "isDefault": False, + "isDefault": is_default, } return self._post_job_queue(payload) @@ -472,7 +476,8 @@ def create_job_queue_from_scratch(self, size, iops, throughput=None, - executor="nextflow"): + executor="nextflow", + is_default=False): """Create a custom job queue without using a preset template. Parameters @@ -504,6 +509,9 @@ def create_job_queue_from_scratch(self, Volume throughput in MB/s. Only applicable to ``gp3`` volumes. executor : str, optional Workflow executor. Defaults to ``'nextflow'``. + is_default : bool, optional + Whether to set the queue as the workspace default. Defaults to + ``False``. Returns ------- @@ -542,7 +550,7 @@ def create_job_queue_from_scratch(self, }, "templateName": "", "templateDescription": "", - "isDefault": False, + "isDefault": is_default, } return self._post_job_queue(payload) From 742b969cb41998c6aa80c225433c4abb93eb304d Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 09:11:00 +0200 Subject: [PATCH 08/27] review: initial ai review --- cloudos_cli/queue/__init__.py | 2 +- cloudos_cli/queue/cli.py | 38 +++++++++++++++--------- cloudos_cli/queue/queue.py | 55 ++++++++++++++--------------------- cloudos_cli/utils/errors.py | 19 ++++++++++++ 4 files changed, 67 insertions(+), 47 deletions(-) diff --git a/cloudos_cli/queue/__init__.py b/cloudos_cli/queue/__init__.py index 15a458fe..fa4a104d 100755 --- a/cloudos_cli/queue/__init__.py +++ b/cloudos_cli/queue/__init__.py @@ -5,4 +5,4 @@ from .queue import Queue -__all__ = ['queue'] +__all__ = ['Queue'] diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 4e6bc0a2..54b182f0 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -19,7 +19,7 @@ CE_LIMIT_REACHED_MESSAGE, MAX_WORKSPACE_COMPUTE_ENVS, WORKSPACE_CE_LIMIT_REACHED_MESSAGE, - _STANDARD_INSTANCE_TYPES, + _ALL_INSTANCE_TYPES, ) from cloudos_cli.utils.resources import ssl_selector from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL @@ -30,6 +30,16 @@ # Union of all allocation strategies, used for the CLI option choices. _ALL_ALLOCATION_STRATEGIES = ["BEST_FIT", "BEST_FIT_PROGRESSIVE", "SPOT_CAPACITY_OPTIMIZED"] +# Workflow executors supported for job queues. +_EXECUTORS = ["nextflow", "cromwell"] + +# Default volume parameters for the non-interactive --from-scratch flags. These +# mirror the gp3 spec (the default volume type) in VOLUME_SPECS so the CLI +# defaults never drift from the validated specification. +_DEFAULT_SIZE = VOLUME_SPECS["gp3"]["size"][0] +_DEFAULT_IOPS = VOLUME_SPECS["gp3"]["iops"][0] +_DEFAULT_THROUGHPUT = VOLUME_SPECS["gp3"]["throughput"][0] + # --------------------------------------------------------------------------- # Wizard styling helpers # --------------------------------------------------------------------------- @@ -222,7 +232,8 @@ def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): console, 6, total, "Instance types", subtitle="Optimal or a combination of instances.", hint="Enter 'optimal' or a comma-separated list of instance types " - "from the standard families (c5, r5, m5, c4, r4, m4).", + "from the standard (c5, r5, m5, c4, r4, m4) or GPU (p3, g4dn) " + "families.", ) instance_types = _prompt_instance_types(console) @@ -379,14 +390,14 @@ def _prompt_instance_types(console): while True: raw = _styled_prompt("Instance types", type=str, default="optimal") instance_types = [item.strip() for item in raw.split(",") if item.strip()] - invalid = [item for item in instance_types if item not in _STANDARD_INSTANCE_TYPES] + invalid = [item for item in instance_types if item not in _ALL_INSTANCE_TYPES] if not instance_types: console.print("[red]Please provide at least one instance type.[/red]") continue if invalid: console.print( f"[red]Invalid instance type(s): {', '.join(invalid)}.[/red] " - "[dim]Allowed values are 'optimal' or standard instance types.[/dim]" + "[dim]Allowed values are 'optimal' or standard/GPU instance types.[/dim]" ) continue return instance_types @@ -413,11 +424,11 @@ def _parse_instance_types(raw): instance_types = [item.strip() for item in raw.split(",") if item.strip()] if not instance_types: raise click.BadParameter("At least one instance type is required.") - invalid = [item for item in instance_types if item not in _STANDARD_INSTANCE_TYPES] + invalid = [item for item in instance_types if item not in _ALL_INSTANCE_TYPES] if invalid: raise click.BadParameter( f"Invalid instance type(s): {', '.join(invalid)}. " - "Allowed values are 'optimal' or standard instance types." + "Allowed values are 'optimal' or standard/GPU instance types." ) return instance_types @@ -640,6 +651,7 @@ def list_queues(ctx, required=False) @click.option('--executor', help='Workflow executor for the queue. Default=nextflow.', + type=click.Choice(_EXECUTORS, case_sensitive=False), default='nextflow', show_default=True, required=False) @@ -684,7 +696,7 @@ def list_queues(ctx, show_default=True) @click.option('--instance-types', help=("Instance types for --from-scratch. 'optimal' or a " - 'comma-separated list of standard instance types. ' + 'comma-separated list of standard or GPU instance types. ' 'Default=optimal.'), default='optimal', show_default=True) @@ -694,20 +706,20 @@ def list_queues(ctx, default='gp3', show_default=True) @click.option('--size', - help='Volume size in GiB for --from-scratch. Default=1000.', + help=f'Volume size in GiB for --from-scratch. Default={_DEFAULT_SIZE}.', type=int, - default=1000, + default=_DEFAULT_SIZE, show_default=True) @click.option('--iops', - help='Provisioned IOPS for --from-scratch. Default=3000.', + help=f'Provisioned IOPS for --from-scratch. Default={_DEFAULT_IOPS}.', type=int, - default=3000, + default=_DEFAULT_IOPS, show_default=True) @click.option('--throughput', help=('Volume throughput in MB/s for --from-scratch (gp3 only). ' - 'Default=125.'), + f'Default={_DEFAULT_THROUGHPUT}.'), type=int, - default=125, + default=_DEFAULT_THROUGHPUT, show_default=True) @click.option('-y', '--yes', diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 8b701d51..96d3af0a 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -2,7 +2,6 @@ This is the main class to create job queues. """ -import requests import json import pandas as pd from dataclasses import dataclass @@ -11,8 +10,9 @@ from cloudos_cli.utils.errors import ( BadRequestException, ComputeEnvAuthorizationException, + NoJobQueuesAvailableException, ) -from cloudos_cli.utils.requests import retry_requests_post +from cloudos_cli.utils.requests import retry_requests_get, retry_requests_post # --------------------------------------------------------------------------- @@ -48,6 +48,10 @@ "r5.12xlarge", "r5.16xlarge", "r5.24xlarge", "r5.metal", ] +# Union of every selectable instance type (standard + GPU families), used to +# validate user-supplied instance types for custom (from-scratch) queues. +_ALL_INSTANCE_TYPES = sorted(set(_STANDARD_INSTANCE_TYPES) | set(_GPU_INSTANCE_TYPES)) + QUEUE_PRESETS = { "standard-stable": { "computeEnvironmentName": "OnDemandStandard", @@ -169,7 +173,7 @@ # Message shown once a job queue reaches the compute environment limit. CE_LIMIT_REACHED_MESSAGE = ( "You have reached the limit for compute environments for this job queue. " - "Job queues can have up to 3 compute environments." + f"Job queues can have up to {MAX_COMPUTE_ENVS} compute environments." ) # Maximum number of compute environments a workspace can hold across all queues. @@ -178,7 +182,7 @@ # Message shown once a workspace reaches the compute environment limit. WORKSPACE_CE_LIMIT_REACHED_MESSAGE = ( "You have reached the limit for compute environments in your workspace. " - "Workspaces can have up to 10 compute environments." + f"Workspaces can have up to {MAX_WORKSPACE_COMPUTE_ENVS} compute environments." ) @@ -218,9 +222,9 @@ def get_job_queues(self, exclude_system_queues=False): A list of dicts, each corresponding to a job queue. """ headers = {"apikey": self.apikey} - r = requests.get("{}/api/v1/teams/aws/v2/job-queues?teamId={}".format(self.cloudos_url, - self.workspace_id), - headers=headers, verify=self.verify) + r = retry_requests_get("{}/api/v1/teams/aws/v2/job-queues?teamId={}".format(self.cloudos_url, + self.workspace_id), + headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) queues = json.loads(r.content) @@ -240,9 +244,9 @@ def get_system_job_queues(self): A list of dicts, each corresponding to a system job queue. """ headers = {"apikey": self.apikey} - r = requests.get("{}/api/v1/teams/aws/v2/system-job-queues?teamId={}".format(self.cloudos_url, - self.workspace_id), - headers=headers, verify=self.verify) + r = retry_requests_get("{}/api/v1/teams/aws/v2/system-job-queues?teamId={}".format(self.cloudos_url, + self.workspace_id), + headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) return json.loads(r.content) @@ -314,8 +318,7 @@ def fetch_job_queue_id(self, workflow_type, batch=True, job_queue=None): available_queues = [q for q in job_queues if q['status'] == 'Ready' and q['executor'] == workflow_type] if len(available_queues) == 0: - raise Exception(f'There are no available job queues for {workflow_type} ' + - 'workflows. Consider creating one using Lifebit Platform UI.') + raise NoJobQueuesAvailableException(workflow_type) default_queue = [q for q in available_queues if q.get('isDefault', False)] if len(default_queue) > 0: default_queue_id = default_queue[0]['id'] @@ -364,26 +367,6 @@ def get_preset_template(preset_name): ) return QUEUE_PRESETS[preset_name] - def get_available_instances(self): - """Return the list of available AWS instance types for the workspace. - - Returns - ------- - instances : list - A list of dicts describing available instance types. - """ - headers = {"apikey": self.apikey} - r = requests.get( - "{}/api/v1/aws/instances?teamId={}".format( - self.cloudos_url, self.workspace_id - ), - headers=headers, - verify=self.verify, - ) - if r.status_code >= 400: - raise BadRequestException(r) - return json.loads(r.content) - def create_job_queue(self, label, description, preset_name, executor="nextflow", is_default=False): """Create a new job queue in the workspace using a preset template. @@ -462,7 +445,13 @@ def _post_job_queue(self, payload): if r.status_code >= 400: raise BadRequestException(r) response_data = json.loads(r.content) - return response_data.get("id") or response_data.get("_id", "") + queue_id = response_data.get("id") or response_data.get("_id") + if not queue_id: + raise RuntimeError( + "Job queue creation succeeded but the server response did not " + "include a queue ID." + ) + return queue_id def create_job_queue_from_scratch(self, label, diff --git a/cloudos_cli/utils/errors.py b/cloudos_cli/utils/errors.py index f5b0d28d..503c7644 100755 --- a/cloudos_cli/utils/errors.py +++ b/cloudos_cli/utils/errors.py @@ -80,6 +80,25 @@ def __init__(self, workspace_id): self.workspace_id = workspace_id +class NoJobQueuesAvailableException(Exception): + """Raised when no suitable job queues exist for a given workflow type. + + Parameters + ---------- + workflow_type : str + The workflow type (e.g. ``'nextflow'`` or ``'cromwell'``) for which no + ready job queue could be found. + """ + def __init__(self, workflow_type): + msg = ( + f"There are no available job queues for {workflow_type} workflows. " + "Consider creating one using 'cloudos queue create' or the Lifebit " + "Platform UI." + ) + super(NoJobQueuesAvailableException, self).__init__(msg) + self.workflow_type = workflow_type + + class ComputeEnvAuthorizationException(Exception): """Raised when adding a compute environment is rejected by the platform. From 587f8f6539443c2b45df35000dbc10791e09c9be Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 09:33:10 +0200 Subject: [PATCH 09/27] pytest: fix --- tests/test_queue/test_create_queue.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index 87bd4cd3..c3511c1d 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -1,6 +1,7 @@ """Tests for the queue create command and Queue.create_job_queue() method.""" import json +import re import pytest import responses import requests_mock as requests_mock_module @@ -12,6 +13,18 @@ ComputeEnvAuthorizationException, ) from cloudos_cli.__main__ import run_cloudos_cli + +# rich_click renders usage errors in a styled panel and highlights option +# tokens (e.g. ``--description``) with ANSI escape codes. When colour output is +# enabled (as in CI), those escape codes are inserted between the surrounding +# text and the option token, breaking naive substring assertions. Strip ANSI +# escape sequences before asserting on the human-readable message. +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _plain(text): + """Return ``text`` with ANSI escape sequences removed.""" + return _ANSI_RE.sub("", text) from tests.functions_for_pytest import load_json_file # --------------------------------------------------------------------------- @@ -399,7 +412,7 @@ def test_create_queue_missing_description_fails(self): ] result = runner.invoke(run_cloudos_cli, args) assert result.exit_code != 0 - assert 'Missing option --description' in result.output + assert 'Missing option --description' in _plain(result.output) # =========================================================================== @@ -579,7 +592,7 @@ def test_from_scratch_mutually_exclusive_with_preset(self): ] result = runner.invoke(run_cloudos_cli, args) assert result.exit_code != 0 - assert 'cannot be combined with --preset' in result.output + assert 'cannot be combined with --preset' in _plain(result.output) def test_from_scratch_incompatible_strategy_rejected(self): runner = CliRunner() From 69f768905025ae7375dae9771a82dc2a6e1f2ae0 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 10:57:31 +0200 Subject: [PATCH 10/27] docs: update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f2df07..9048bcbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.93.0 (2026-06-16) + +### Feat: + +- Adds `cloudos queue create` with preset templates (e.g. `standard-stable`, `standard-gpu`) +- Adds `--from-scratch` to build custom queues via an interactive wizard or flags +- Adds `--add-compute-env` to add a compute environment to an existing queue +- Adds `--set-default` to mark a new queue as the workspace default + ## v2.91.0 (2026-05-28) ### Feat: From ba620501ad24fb9492408466a10cf766e57e2705 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 11:16:46 +0200 Subject: [PATCH 11/27] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/cli.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 54b182f0..0cdf6a91 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -301,7 +301,7 @@ def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): if spec["throughput"] is not None: tp_default, tp_min, tp_max = spec["throughput"] _print_section( - console, 9, total, "Throughput (MB/s)", + console, 10, total + 1, "Throughput (MB/s)", subtitle="Volume throughput in MB/s.", hint=f"Min {tp_min}, max {tp_max}. Default {tp_default}.", ) @@ -588,11 +588,11 @@ def list_queues(ctx, verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) # Batch job queues are an AWS-only feature; they are not available in - # Azure workspaces. - if execution_platform == 'azure': + # Azure or HPC workspaces. + if execution_platform in ('azure', 'hpc'): Console().print( '[yellow]Warning:[/yellow] Batch job queues are not available in ' - 'Azure workspaces.' + f'{execution_platform.upper()} workspaces.' ) sys.exit(0) @@ -778,11 +778,11 @@ def create_queue(ctx, verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) # Batch job queues are an AWS-only feature; they are not available in - # Azure workspaces. - if execution_platform == 'azure': + # Azure or HPC workspaces. + if execution_platform in ('azure', 'hpc'): Console().print( '[yellow]Warning:[/yellow] Batch job queues are not available in ' - 'Azure workspaces.' + f'{execution_platform.upper()} workspaces.' ) sys.exit(0) From 7c5e3e09d4e5dc8cd540600bb0e90ae0922adf0b Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 11:32:26 +0200 Subject: [PATCH 12/27] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/cli.py | 5 +++++ tests/test_queue/test_create_queue.py | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 0cdf6a91..3fc9ede6 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -457,6 +457,11 @@ def _validate_from_scratch_flags(params): f"{', '.join(allowed_strategies)}." ) + if params["min_vcpus"] > params["max_vcpus"]: + raise click.BadParameter( + f"--min-vcpus cannot be greater than --max-vcpus (got {params['min_vcpus']} > {params['max_vcpus']})." + ) + spec = VOLUME_SPECS[params["volume_type"]] _check_range("--size", params["size"], spec["size"]) _check_range("--iops", params["iops"], spec["iops"]) diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index c3511c1d..29a9ae47 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -25,7 +25,6 @@ def _plain(text): """Return ``text`` with ANSI escape sequences removed.""" return _ANSI_RE.sub("", text) -from tests.functions_for_pytest import load_json_file # --------------------------------------------------------------------------- # Constants shared across tests @@ -35,8 +34,6 @@ def _plain(text): CLOUDOS_URL = 'https://cloudos.lifebit.ai' WORKSPACE_ID = 'lv89ufc838sdig' CREATE_RESPONSE_FILE = 'tests/test_data/queue/create_queue_response.json' -QUEUES_FILE = 'tests/test_data/queue/queues.json' -SYSTEM_QUEUES_FILE = 'tests/test_data/queue/system_queues.json' with open(CREATE_RESPONSE_FILE) as f: CREATE_RESPONSE_JSON_STR = f.read() From 812a3f7778e42996023b7ccdfcbafab8d9a707ba Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 11:33:13 +0200 Subject: [PATCH 13/27] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/queue.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 96d3af0a..f227cb7f 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -607,6 +607,10 @@ def _build_compute_resources(provisioning_type, raise ValueError( f"Unknown volume type '{volume_type}'. Valid options are: {valid}" ) + if min_vcpus > max_vcpus: + raise ValueError( + f"min_vcpus ({min_vcpus}) cannot be greater than max_vcpus ({max_vcpus})." + ) resource_type = PROVISIONING_TYPES[provisioning_type] volume = { From 8955d16ab12bcc9f07baa9900c9141d132cdb6db Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 12:11:22 +0200 Subject: [PATCH 14/27] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/cli.py | 4 ++-- tests/test_queue/test_create_queue.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 3fc9ede6..9289ac1f 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -137,7 +137,7 @@ def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): ``throughput``. When ``for_compute_env`` is True, ``label`` holds the compute environment name. """ - total = 9 + total = 10 console.print() if for_compute_env: intro = ( @@ -301,7 +301,7 @@ def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): if spec["throughput"] is not None: tp_default, tp_min, tp_max = spec["throughput"] _print_section( - console, 10, total + 1, "Throughput (MB/s)", + console, 10, total, "Throughput (MB/s)", subtitle="Volume throughput in MB/s.", hint=f"Min {tp_min}, max {tp_max}. Default {tp_default}.", ) diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index 29a9ae47..25811579 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -378,6 +378,7 @@ def test_create_queue_invalid_preset_rejected(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Test Queue', + '--description', 'A test queue', '--preset', 'not-a-real-preset', '--yes', ] @@ -391,6 +392,7 @@ def test_create_queue_missing_label_fails(self): '--apikey', APIKEY, '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, + '--description', 'A test queue', '--yes', ] result = runner.invoke(run_cloudos_cli, args) @@ -599,6 +601,7 @@ def test_from_scratch_incompatible_strategy_rejected(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Custom Queue', + '--description', 'A custom queue', '--from-scratch', '--yes', '--provisioning-type', 'on-demand', '--allocation-strategy', 'SPOT_CAPACITY_OPTIMIZED', @@ -614,6 +617,7 @@ def test_from_scratch_gp3_iops_out_of_range_rejected(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Custom Queue', + '--description', 'A custom queue', '--from-scratch', '--yes', '--volume-type', 'gp3', '--iops', '100', @@ -629,6 +633,7 @@ def test_from_scratch_invalid_instance_type_rejected(self): '--cloudos-url', CLOUDOS_URL, '--workspace-id', WORKSPACE_ID, '--label', 'Custom Queue', + '--description', 'A custom queue', '--from-scratch', '--yes', '--instance-types', 'not-an-instance', ] From 8e8ecb7bd9bc1aeef46e3f1c62b6615d9cdfaaec Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 13:00:12 +0200 Subject: [PATCH 15/27] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/queue.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index f227cb7f..91f3f2cf 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -365,7 +365,8 @@ def get_preset_template(preset_name): raise ValueError( f"Unknown preset '{preset_name}'. Valid presets are: {valid}" ) - return QUEUE_PRESETS[preset_name] + import copy + return copy.deepcopy(QUEUE_PRESETS[preset_name]) def create_job_queue(self, label, description, preset_name, executor="nextflow", is_default=False): @@ -611,6 +612,27 @@ def _build_compute_resources(provisioning_type, raise ValueError( f"min_vcpus ({min_vcpus}) cannot be greater than max_vcpus ({max_vcpus})." ) + if not instance_types: + raise ValueError("At least one instance type is required.") + invalid = [t for t in instance_types if t not in _ALL_INSTANCE_TYPES] + if invalid: + raise ValueError( + f"Invalid instance type(s): {', '.join(invalid)}. " + "Allowed values are 'optimal' or standard/GPU instance types." + ) + spec = VOLUME_SPECS[volume_type] + for field, value in (("size", size), ("iops", iops)): + _, minimum, maximum = spec[field] + if value < minimum or value > maximum: + raise ValueError( + f"{field} ({value}) must be between {minimum} and {maximum} for volume type '{volume_type}'." + ) + if spec["throughput"] is not None and throughput is not None: + _, minimum, maximum = spec["throughput"] + if throughput < minimum or throughput > maximum: + raise ValueError( + f"throughput ({throughput}) must be between {minimum} and {maximum} for volume type '{volume_type}'." + ) resource_type = PROVISIONING_TYPES[provisioning_type] volume = { From b8e667060fc187041ef180a3d51480c44d7a4b0b Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 13:19:09 +0200 Subject: [PATCH 16/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/queue.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 91f3f2cf..b26d4b2b 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -608,6 +608,14 @@ def _build_compute_resources(provisioning_type, raise ValueError( f"Unknown volume type '{volume_type}'. Valid options are: {valid}" ) + if max_vcpus < 0 or max_vcpus > MAX_VCPUS_LIMIT: + raise ValueError( + f"max_vcpus ({max_vcpus}) must be between 0 and {MAX_VCPUS_LIMIT}." + ) + if min_vcpus < 0 or min_vcpus > MAX_VCPUS_LIMIT: + raise ValueError( + f"min_vcpus ({min_vcpus}) must be between 0 and {MAX_VCPUS_LIMIT}." + ) if min_vcpus > max_vcpus: raise ValueError( f"min_vcpus ({min_vcpus}) cannot be greater than max_vcpus ({max_vcpus})." From bc94381fcf07b213a66b331c92cc6c175798dfeb Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 15:09:23 +0200 Subject: [PATCH 17/27] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/cli.py | 7 +++---- cloudos_cli/queue/queue.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 9289ac1f..47316bf4 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -16,10 +16,9 @@ DEFAULT_MAX_VCPUS, DEFAULT_MIN_VCPUS, MAX_COMPUTE_ENVS, - CE_LIMIT_REACHED_MESSAGE, MAX_WORKSPACE_COMPUTE_ENVS, WORKSPACE_CE_LIMIT_REACHED_MESSAGE, - _ALL_INSTANCE_TYPES, + ALL_INSTANCE_TYPES, ) from cloudos_cli.utils.resources import ssl_selector from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL @@ -390,7 +389,7 @@ def _prompt_instance_types(console): while True: raw = _styled_prompt("Instance types", type=str, default="optimal") instance_types = [item.strip() for item in raw.split(",") if item.strip()] - invalid = [item for item in instance_types if item not in _ALL_INSTANCE_TYPES] + invalid = [item for item in instance_types if item not in ALL_INSTANCE_TYPES] if not instance_types: console.print("[red]Please provide at least one instance type.[/red]") continue @@ -424,7 +423,7 @@ def _parse_instance_types(raw): instance_types = [item.strip() for item in raw.split(",") if item.strip()] if not instance_types: raise click.BadParameter("At least one instance type is required.") - invalid = [item for item in instance_types if item not in _ALL_INSTANCE_TYPES] + invalid = [item for item in instance_types if item not in ALL_INSTANCE_TYPES] if invalid: raise click.BadParameter( f"Invalid instance type(s): {', '.join(invalid)}. " diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index b26d4b2b..d0654281 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -50,7 +50,7 @@ # Union of every selectable instance type (standard + GPU families), used to # validate user-supplied instance types for custom (from-scratch) queues. -_ALL_INSTANCE_TYPES = sorted(set(_STANDARD_INSTANCE_TYPES) | set(_GPU_INSTANCE_TYPES)) +ALL_INSTANCE_TYPES = sorted(set(_STANDARD_INSTANCE_TYPES) | set(_GPU_INSTANCE_TYPES)) QUEUE_PRESETS = { "standard-stable": { @@ -622,7 +622,7 @@ def _build_compute_resources(provisioning_type, ) if not instance_types: raise ValueError("At least one instance type is required.") - invalid = [t for t in instance_types if t not in _ALL_INSTANCE_TYPES] + invalid = [t for t in instance_types if t not in ALL_INSTANCE_TYPES] if invalid: raise ValueError( f"Invalid instance type(s): {', '.join(invalid)}. " From 08c49ec16edfa0abd4be7216bbe427df5350208d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:14:24 +0000 Subject: [PATCH 18/27] Add test for NoJobQueuesAvailableException in fetch_job_queue_id --- tests/test_queue/test_fetch_job_queue_id.py | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_queue/test_fetch_job_queue_id.py b/tests/test_queue/test_fetch_job_queue_id.py index 39a3cdc2..93aede0a 100644 --- a/tests/test_queue/test_fetch_job_queue_id.py +++ b/tests/test_queue/test_fetch_job_queue_id.py @@ -2,6 +2,7 @@ import pytest import responses from cloudos_cli.queue import Queue +from cloudos_cli.utils.errors import NoJobQueuesAvailableException from tests.functions_for_pytest import load_json_file INPUT = 'tests/test_data/queue/queues.json' @@ -147,3 +148,39 @@ def test_fetch_job_queue_id_batch_true_workflow_type_wrong(): with pytest.raises(ValueError) as error: j_queue.fetch_job_queue_id('wrong_workflow_type', batch=True) assert 'Only nextflow or cromwell workflows are allowed' in str(error) + + +@mock.patch('cloudos_cli.queue', mock.MagicMock()) +@responses.activate +def test_fetch_job_queue_id_no_available_queues(): + """ + Tests fetch_job_queue_id when batch=True but there are no ready job queues + for the requested workflow type, so a NoJobQueuesAvailableException is raised. + """ + header = { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json;charset=UTF-8", + "apikey": APIKEY + } + # mock GET method for regular queues returning an empty list + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", + body="[]", + headers=header, + status=200) + # mock GET method for system queues returning an empty list + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/system-job-queues?teamId={WORKSPACE_ID}", + body="[]", + headers=header, + status=200) + # Initialise Queue + j_queue = Queue(cloudos_url=CLOUDOS_URL, apikey=APIKEY, cromwell_token=None, + workspace_id=WORKSPACE_ID) + # Raise NoJobQueuesAvailableException + with pytest.raises(NoJobQueuesAvailableException) as error: + j_queue.fetch_job_queue_id(WORKFLOW_TYPE, batch=True) + assert f'There are no available job queues for {WORKFLOW_TYPE} workflows' in str(error.value) + assert error.value.workflow_type == WORKFLOW_TYPE From 824eb48914596eed987c84b0bbacb0bd7f03ceda Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 16 Jun 2026 15:34:56 +0200 Subject: [PATCH 19/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudos_cli/queue/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 47316bf4..5a250ffe 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -16,6 +16,7 @@ DEFAULT_MAX_VCPUS, DEFAULT_MIN_VCPUS, MAX_COMPUTE_ENVS, + CE_LIMIT_REACHED_MESSAGE, MAX_WORKSPACE_COMPUTE_ENVS, WORKSPACE_CE_LIMIT_REACHED_MESSAGE, ALL_INSTANCE_TYPES, From 345d4e89ba20082bd47da69bcd40cbadca25afc2 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 19 Jun 2026 18:47:07 +0200 Subject: [PATCH 20/27] docs: update README with queue generation --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 36471980..a277a431 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Python package for interacting with Lifebit Platform - [Create Projects](#create-projects) - [Queue](#queue) - [List Queues](#list-queues) + - [Create Queue](#create-queue) - [Workflow](#workflow) - [List All Available Workflows](#list-all-available-workflows) - [Import a Nextflow Workflow](#import-a-nextflow-workflow) @@ -441,6 +442,41 @@ cloudos queue list --profile my_profile --output-format csv > NOTE: The queue name that is visible in Lifebit Platform and must be used with the `--job-queue` parameter is the one in the `label` field. +#### Create Queue + +You can create a new AWS batch job queue in your Lifebit Platform workspace using the `queue create` command. By default the queue is built from a preset template, selected with the `--preset` option. Available presets are: + +- **standard-stable** (default): On-demand stable instances +- **standard-cost-saving**: Spot instances (up to ~80% cheaper, at risk of premature termination) +- **read-write-optimised**: On-demand instances with increased disk I/O performance +- **standard-gpu**: On-demand standard instances plus GPU instances + +To create a queue from a preset: + +```bash +cloudos queue create --profile my_profile --label "my-new-queue" --description "Queue for RNA-seq jobs" --preset standard-stable +``` + +Before creating the queue, a summary is shown and confirmation is requested. To skip the confirmation prompt, add the `-y`/`--yes` flag. You can also set the new queue as the workspace default with `--set-default`. + +The expected output is something similar to: + +```console +Executing queue create... + Queue "my-new-queue" created successfully. + Queue ID : 64f1a23b8e4c9d001234abcd + View at : https://cloudos.lifebit.ai/app/job-queues/64f1a23b8e4c9d001234abcd +``` + +For full control over the compute environment, use the `--from-scratch` flag, which launches an interactive wizard (or runs non-interactively when combined with `-y`/`--yes`). This lets you customize provisioning type, allocation strategy, vCPUs, instance types, and volume settings: + +```bash +cloudos queue create --profile my_profile --label "custom-queue" --description "Custom spot queue" --from-scratch --provisioning-type spot --max-vcpus 256 --instance-types optimal -y +``` + +> [!NOTE] +> **Azure Platform**: Batch job queues are an AWS-only feature and are not available in Azure or HPC workspaces. + **Job queues for platform workflows** Platform workflows (those provided by Lifebit Platform in your workspace as modules) run on separate and specific AWS batch queues (system queues). Therefore, Lifebit Platform will automatically assign the valid queue and you should not specify any queue using the `--job-queue` parameter. Any attempt to use this parameter will be ignored. Examples of such platform workflows are "System Tools" and "Data Factory" workflows. From c7aee29ec3f42b5b4e52a80f83d374b3fbb105cd Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 19 Jun 2026 18:48:12 +0200 Subject: [PATCH 21/27] refactor: remove unused method and its tests --- cloudos_cli/queue/queue.py | 19 --------------- tests/test_queue/test_create_queue.py | 35 +-------------------------- 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index d0654281..604ef29b 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -666,25 +666,6 @@ def _build_compute_resources(provisioning_type, compute_resources["bidPercentage"] = 100 return compute_resources - def find_job_queue_by_label(self, label): - """Find a job queue in the workspace by its label. - - Parameters - ---------- - label : str - The label of the job queue to find. - - Returns - ------- - queue : dict or None - The matching job queue dict, or ``None`` if no queue with that - label exists. - """ - for q in self.get_job_queues(): - if q.get('label') == label: - return q - return None - def count_workspace_compute_environments(self, queues=None): """Count the total compute environments across all queues in the workspace. diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index 25811579..88d9a7e7 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -677,16 +677,13 @@ def test_from_scratch_interactive_wizard_success(self): # =========================================================================== -# Unit tests – Queue.find_job_queue_by_label() & add_compute_environment() +# Unit tests – Queue.add_compute_environment() # =========================================================================== QUEUES_LIST_FILE = 'tests/test_data/queue/queues.json' -SYSTEM_QUEUES_LIST_FILE = 'tests/test_data/queue/system_queues.json' with open(QUEUES_LIST_FILE) as f: QUEUES_LIST_STR = f.read() -with open(SYSTEM_QUEUES_LIST_FILE) as f: - SYSTEM_QUEUES_LIST_STR = f.read() def _queue_with_n_ces(label, queue_id, n): @@ -723,36 +720,6 @@ def _make_queue(self): workspace_id=WORKSPACE_ID, ) - def _add_get_queues(self): - responses.add( - responses.GET, - url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", - body=QUEUES_LIST_STR, - status=200, - content_type='application/json', - ) - responses.add( - responses.GET, - url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/system-job-queues?teamId={WORKSPACE_ID}", - body=SYSTEM_QUEUES_LIST_STR, - status=200, - content_type='application/json', - ) - - @responses.activate - def test_find_job_queue_by_label_found(self): - self._add_get_queues() - q = self._make_queue() - found = q.find_job_queue_by_label('test_queue_label') - assert found is not None - assert found['label'] == 'test_queue_label' - - @responses.activate - def test_find_job_queue_by_label_not_found(self): - self._add_get_queues() - q = self._make_queue() - assert q.find_job_queue_by_label('does-not-exist') is None - def test_count_workspace_compute_environments_from_list(self): q = self._make_queue() queues = [ From 6a74d60cbfed99d00b47c4541b8df671e048260c Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 19 Jun 2026 18:53:06 +0200 Subject: [PATCH 22/27] style: move import at the top --- cloudos_cli/queue/queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 604ef29b..376da73f 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -3,6 +3,7 @@ """ import json +import copy import pandas as pd from dataclasses import dataclass from typing import Union @@ -365,7 +366,6 @@ def get_preset_template(preset_name): raise ValueError( f"Unknown preset '{preset_name}'. Valid presets are: {valid}" ) - import copy return copy.deepcopy(QUEUE_PRESETS[preset_name]) def create_job_queue(self, label, description, preset_name, executor="nextflow", From 668a62d3590d0cc114169a2d07753d904442ff5c Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 19 Jun 2026 19:13:57 +0200 Subject: [PATCH 23/27] refactor: remove --add-compute-env --- cloudos_cli/queue/cli.py | 218 ++--------------- cloudos_cli/queue/queue.py | 108 --------- cloudos_cli/utils/errors.py | 26 -- tests/test_queue/test_create_queue.py | 333 +------------------------- 4 files changed, 20 insertions(+), 665 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 5a250ffe..a762c347 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -15,8 +15,6 @@ MAX_VCPUS_LIMIT, DEFAULT_MAX_VCPUS, DEFAULT_MIN_VCPUS, - MAX_COMPUTE_ENVS, - CE_LIMIT_REACHED_MESSAGE, MAX_WORKSPACE_COMPUTE_ENVS, WORKSPACE_CE_LIMIT_REACHED_MESSAGE, ALL_INSTANCE_TYPES, @@ -113,20 +111,13 @@ def _styled_prompt(label, **kwargs): return click.prompt(text, **kwargs) -def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): +def _from_scratch_wizard(console): """Interactively collect custom queue parameters, emulating the UI flow. Parameters ---------- console : rich.console.Console The console used for rich output. - for_compute_env : bool, optional - When True, the wizard collects a compute environment to add to an - existing queue (step 1 asks for the compute environment name) rather - than a brand new queue. - queue_label : str or None, optional - The label of the target queue, shown in the intro when - ``for_compute_env`` is True. Returns ------- @@ -134,40 +125,25 @@ def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): A dict with keys: ``label``, ``provisioning_type``, ``allocation_strategy``, ``max_vcpus``, ``min_vcpus``, ``instance_types``, ``volume_type``, ``size``, ``iops`` and - ``throughput``. When ``for_compute_env`` is True, ``label`` holds the - compute environment name. + ``throughput``. """ total = 10 console.print() - if for_compute_env: - intro = ( - "[bold cyan]Add a compute environment to a job queue[/bold cyan]\n" - f"[grey62]Target queue: [white]{queue_label}[/white]. Answer the " - "prompts below to configure the new compute environment.[/grey62]" - ) - else: - intro = ( - "[bold cyan]Create a job queue from scratch[/bold cyan]\n" - "[grey62]Answer the prompts below to configure your custom " - "compute environment.[/grey62]" - ) + intro = ( + "[bold cyan]Create a job queue from scratch[/bold cyan]\n" + "[grey62]Answer the prompts below to configure your custom " + "compute environment.[/grey62]" + ) console.print( Panel.fit(intro, border_style="cyan", padding=(1, 4)) ) # 1. Name - if for_compute_env: - _print_section( - console, 1, total, "Name", - subtitle="A human-readable name for the new compute environment.", - ) - label = _styled_prompt("Name of the compute environment", type=str) - else: - _print_section( - console, 1, total, "Name", - subtitle="A human-readable name for your job queue.", - ) - label = _styled_prompt("Name of the queue", type=str) + _print_section( + console, 1, total, "Name", + subtitle="A human-readable name for your job queue.", + ) + label = _styled_prompt("Name of the queue", type=str) # 2. Provisioning type _print_section( @@ -323,11 +299,11 @@ def _from_scratch_wizard(console, for_compute_env=False, queue_label=None): "iops": iops, "throughput": throughput, } - _print_summary(console, params, for_compute_env=for_compute_env) + _print_summary(console, params) return params -def _print_summary(console, params, for_compute_env=False): +def _print_summary(console, params): """Print a styled summary table of the collected wizard parameters. Parameters @@ -338,11 +314,7 @@ def _print_summary(console, params, for_compute_env=False): The collected from-scratch parameters. """ table = Table( - title=( - "[bold cyan]Compute environment configuration summary[/bold cyan]" - if for_compute_env - else "[bold cyan]Queue configuration summary[/bold cyan]" - ), + title="[bold cyan]Queue configuration summary[/bold cyan]", show_header=False, box=None, padding=(0, 2), @@ -351,9 +323,8 @@ def _print_summary(console, params, for_compute_env=False): table.add_column(style="white") instance_label = ", ".join(params["instance_types"]) - name_label = "Compute environment" if for_compute_env else "Name" rows = [ - (name_label, params["label"]), + ("Name", params["label"]), ("Provisioning type", params["provisioning_type"]), ("Allocation strategy", params["allocation_strategy"]), ("Max vCPUs", str(params["max_vcpus"])), @@ -640,8 +611,7 @@ def list_queues(ctx, required=False, default=None) @click.option('--description', - help=('Short description of the new job queue. Required when ' - 'creating a queue (not used with --add-compute-env).'), + help='Short description of the new job queue.', default='', required=False) @click.option('--preset', @@ -666,17 +636,6 @@ def list_queues(ctx, 'create non-interactively using the options below. Mutually ' 'exclusive with --preset.'), is_flag=True) -@click.option('--add-compute-env', - help=('Add a compute environment to an existing job queue ' - '(identified by --label, which is required). By default ' - 'this launches an interactive wizard; combine with -y/--yes ' - 'to add non-interactively using the options below. A queue ' - f'can hold up to {MAX_COMPUTE_ENVS} compute environments.'), - is_flag=True) -@click.option('--compute-env-name', - help=('Name for the new compute environment when using ' - '--add-compute-env with -y/--yes.'), - default=None) @click.option('--provisioning-type', help='Provisioning type for --from-scratch. Default=on-demand.', type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), @@ -756,8 +715,6 @@ def create_queue(ctx, preset, executor, from_scratch, - add_compute_env, - compute_env_name, provisioning_type, allocation_strategy, max_vcpus, @@ -777,7 +734,6 @@ def create_queue(ctx, By default a preset template is used. Pass --from-scratch to build a custom queue, either interactively (default) or non-interactively with -y/--yes. - Pass --add-compute-env to add a compute environment to an existing queue. """ verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) @@ -791,35 +747,8 @@ def create_queue(ctx, ) sys.exit(0) - if from_scratch and add_compute_env: - raise click.UsageError( - '--from-scratch and --add-compute-env cannot be used together.' - ) - - if add_compute_env: - _add_compute_environment( - ctx=ctx, - cloudos_url=cloudos_url, - apikey=apikey, - workspace_id=workspace_id, - verify_ssl=verify_ssl, - label=label, - compute_env_name=compute_env_name, - provisioning_type=provisioning_type, - allocation_strategy=allocation_strategy, - max_vcpus=max_vcpus, - min_vcpus=min_vcpus, - instance_types=instance_types, - volume_type=volume_type, - size=size, - iops=iops, - throughput=throughput, - skip_confirmation=skip_confirmation, - ) - return - # --description is required when creating a queue (both preset and - # from-scratch paths). It is not used when adding a compute environment. + # from-scratch paths). if not description: raise click.UsageError('Missing option --description.') @@ -982,114 +911,3 @@ def _create_queue_from_scratch(ctx, except Exception as e: console.print(f'\t[red]Error creating queue:[/red] {str(e)}') sys.exit(1) - - -def _add_compute_environment(ctx, - cloudos_url, - apikey, - workspace_id, - verify_ssl, - label, - compute_env_name, - provisioning_type, - allocation_strategy, - max_vcpus, - min_vcpus, - instance_types, - volume_type, - size, - iops, - throughput, - skip_confirmation): - """Handle the --add-compute-env branch of ``cloudos queue create``. - - Adds a compute environment to an existing queue (identified by ``label``). - The queue must exist and have fewer than ``MAX_COMPUTE_ENVS`` compute - environments. When ``skip_confirmation`` is False an interactive wizard - collects the compute environment configuration. - """ - console = Console() - - if label is None: - raise click.UsageError('Missing option --label for --add-compute-env.') - - j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) - - # The queue must already exist to add a compute environment to it. Compute - # environments can only be added to created (non-system) queues, and system - # queues do not count towards the workspace limit. - team_queues = j_queue.get_job_queues(exclude_system_queues=True) - target_queue = next( - (q for q in team_queues if q.get('label') == label), None - ) - if target_queue is None: - console.print( - f"[red]Error:[/red] No job queue with label '{label}' was found. " - "Compute environments can only be added to existing queues." - ) - sys.exit(1) - - queue_id = target_queue.get('id') or target_queue.get('_id', '') - current_ce_count = len(target_queue.get('computeEnvironments', [])) - - # A queue cannot exceed the compute environment limit. - if current_ce_count >= MAX_COMPUTE_ENVS: - console.print( - f"[yellow]Warning:[/yellow] {CE_LIMIT_REACHED_MESSAGE}" - ) - sys.exit(0) - - # Adding a compute environment must not exceed the workspace limit. - _check_workspace_ce_limit(console, j_queue, queues=team_queues) - - if skip_confirmation: - if compute_env_name is None: - raise click.UsageError( - 'Missing option --compute-env-name for --add-compute-env -y.' - ) - params = { - 'label': compute_env_name, - 'provisioning_type': provisioning_type, - 'allocation_strategy': allocation_strategy, - 'max_vcpus': max_vcpus, - 'min_vcpus': min_vcpus, - 'instance_types': _parse_instance_types(instance_types), - 'volume_type': volume_type, - 'size': size, - 'iops': iops, - 'throughput': throughput, - } - _validate_from_scratch_flags(params) - else: - params = _from_scratch_wizard( - console, for_compute_env=True, queue_label=label - ) - - print('Executing add compute environment...') - - try: - j_queue.add_compute_environment( - queue_id=queue_id, - queue_label=label, - ce_name=params['label'], - provisioning_type=params['provisioning_type'], - allocation_strategy=params['allocation_strategy'], - max_vcpus=params['max_vcpus'], - min_vcpus=params['min_vcpus'], - instance_types=params['instance_types'], - volume_type=params['volume_type'], - size=params['size'], - iops=params['iops'], - throughput=params['throughput'], - ) - console.print( - f'\t[green]Compute environment "{params["label"]}" added ' - f'successfully to queue "{label}".[/green]' - ) - print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') - # Inform the user if this addition reached the compute environment limit. - if current_ce_count + 1 >= MAX_COMPUTE_ENVS: - console.print(f'\t[yellow]Warning:[/yellow] {CE_LIMIT_REACHED_MESSAGE}') - except Exception as e: - console.print(f'\t[red]Error adding compute environment:[/red] {str(e)}') - sys.exit(1) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 376da73f..32146319 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -10,7 +10,6 @@ from cloudos_cli.clos import Cloudos from cloudos_cli.utils.errors import ( BadRequestException, - ComputeEnvAuthorizationException, NoJobQueuesAvailableException, ) from cloudos_cli.utils.requests import retry_requests_get, retry_requests_post @@ -168,15 +167,6 @@ }, } -# Maximum number of compute environments a single job queue can hold. -MAX_COMPUTE_ENVS = 3 - -# Message shown once a job queue reaches the compute environment limit. -CE_LIMIT_REACHED_MESSAGE = ( - "You have reached the limit for compute environments for this job queue. " - f"Job queues can have up to {MAX_COMPUTE_ENVS} compute environments." -) - # Maximum number of compute environments a workspace can hold across all queues. MAX_WORKSPACE_COMPUTE_ENVS = 10 @@ -686,101 +676,3 @@ def count_workspace_compute_environments(self, queues=None): if queues is None: queues = self.get_job_queues(exclude_system_queues=True) return sum(len(q.get('computeEnvironments', [])) for q in queues) - - def add_compute_environment(self, - queue_id, - queue_label, - ce_name, - provisioning_type, - allocation_strategy, - max_vcpus, - min_vcpus, - instance_types, - volume_type, - size, - iops, - throughput=None): - """Add a compute environment to an existing job queue. - - Parameters - ---------- - queue_id : str - The Lifebit Platform ID of the target job queue. - queue_label : str - The label of the target job queue. - ce_name : str - Name for the new compute environment. - provisioning_type : str - One of ``'on-demand'`` or ``'spot'``. - allocation_strategy : str - AWS Batch allocation strategy. Must be valid for the chosen - ``provisioning_type`` (see ``ALLOCATION_STRATEGIES``). - max_vcpus : int - Maximum number of vCPUs for the compute environment. - min_vcpus : int - Minimum number of vCPUs for the compute environment. - instance_types : list[str] - Instance types to allow. - volume_type : str - One of ``'gp3'`` or ``'io2'``. - size : int - Volume size in GiB. - iops : int - Provisioned IOPS for the volume. - throughput : int or None, optional - Volume throughput in MB/s. Only applicable to ``gp3`` volumes. - - Returns - ------- - response_data : dict - The updated job queue as returned by the API. - - Raises - ------ - ValueError - If the provisioning type, allocation strategy or volume type are - not recognised, or the allocation strategy is incompatible with - the provisioning type. - BadRequestException - If the API returns a 4xx or 5xx response. - """ - compute_resources = self._build_compute_resources( - provisioning_type=provisioning_type, - allocation_strategy=allocation_strategy, - max_vcpus=max_vcpus, - min_vcpus=min_vcpus, - instance_types=instance_types, - volume_type=volume_type, - size=size, - iops=iops, - throughput=throughput, - ) - # The add-compute-environment endpoint only accepts the ``environment`` - # object in the request body (see apiAddComputeEnvironmentToJobQueue). - payload = { - "environment": { - "computeEnvironmentName": ce_name, - "computeResources": compute_resources, - }, - } - headers = { - "Content-Type": "application/json", - "apikey": self.apikey, - } - r = retry_requests_post( - "{}/api/v1/teams/aws/v2/job-queue/{}/compute-environment?teamId={}".format( - self.cloudos_url, queue_id, self.workspace_id - ), - headers=headers, - json=payload, - verify=self.verify, - ) - if r.status_code == 401: - # The add-compute-environment endpoint - # (apiAddComputeEnvironmentToJobQueue) only accepts session/bearer - # authentication; API keys are not authorised for it. Surface a - # clear, actionable message instead of a raw "Unauthorized". - raise ComputeEnvAuthorizationException(queue_label) - if r.status_code >= 400: - raise BadRequestException(r) - return json.loads(r.content) diff --git a/cloudos_cli/utils/errors.py b/cloudos_cli/utils/errors.py index 503c7644..6ba23edb 100755 --- a/cloudos_cli/utils/errors.py +++ b/cloudos_cli/utils/errors.py @@ -99,32 +99,6 @@ def __init__(self, workflow_type): self.workflow_type = workflow_type -class ComputeEnvAuthorizationException(Exception): - """Raised when adding a compute environment is rejected by the platform. - - The add-compute-environment endpoint - (``apiAddComputeEnvironmentToJobQueue``) only accepts session/bearer - authentication. API keys are authenticated but not authorised for this - operation, so the platform responds with HTTP 401. - - Parameters - ---------- - queue_label : str - The label of the target job queue. - """ - def __init__(self, queue_label): - msg = ( - "Not authorised to add a compute environment to queue " - "'{}'. Adding a compute environment to an existing queue is not " - "supported with API key authentication; this operation requires " - "an interactive (session/bearer) login. You can still create a " - "new queue with a compute environment using " - "'cloudos queue create'.".format(queue_label) - ) - super(ComputeEnvAuthorizationException, self).__init__(msg) - self.queue_label = queue_label - - class JobAccessDeniedException(Exception): def __init__(self, job_id, job_owner_name=None, current_user_name=None): if job_owner_name and current_user_name: diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index 88d9a7e7..c2bd9f0a 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -10,7 +10,6 @@ from cloudos_cli.queue.queue import Queue, QUEUE_PRESETS from cloudos_cli.utils.errors import ( BadRequestException, - ComputeEnvAuthorizationException, ) from cloudos_cli.__main__ import run_cloudos_cli @@ -677,7 +676,7 @@ def test_from_scratch_interactive_wizard_success(self): # =========================================================================== -# Unit tests – Queue.add_compute_environment() +# Unit tests – Queue.count_workspace_compute_environments() # =========================================================================== QUEUES_LIST_FILE = 'tests/test_data/queue/queues.json' @@ -686,32 +685,7 @@ def test_from_scratch_interactive_wizard_success(self): QUEUES_LIST_STR = f.read() -def _queue_with_n_ces(label, queue_id, n): - """Build a single-queue list JSON string with ``n`` compute environments.""" - ces = [ - { - 'label': f'CE-{i}', - 'environment': {}, - 'status': 'Ready', - } - for i in range(n) - ] - return json.dumps([ - { - 'id': queue_id, - 'name': label, - 'label': label, - 'description': '', - 'isDefault': False, - 'resourceType': '', - 'executor': 'nextflow', - 'computeEnvironments': ces, - 'status': 'Ready', - } - ]) - - -class TestFindAndAddComputeEnvironment: +class TestCountWorkspaceComputeEnvironments: def _make_queue(self): return Queue( cloudos_url=CLOUDOS_URL, @@ -747,306 +721,3 @@ def test_count_workspace_compute_environments_fetches_queues(self): ) assert q.count_workspace_compute_environments() == expected - @responses.activate - def test_add_compute_environment_posts_correct_payload(self): - queue_id = 'q123' - responses.add( - responses.POST, - url=(f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/{queue_id}/" - f"compute-environment?teamId={WORKSPACE_ID}"), - body=QUEUES_LIST_STR, - status=200, - content_type='application/json', - ) - q = self._make_queue() - q.add_compute_environment( - queue_id=queue_id, - queue_label='my-queue', - ce_name='New_spot_CE', - provisioning_type='spot', - allocation_strategy='SPOT_CAPACITY_OPTIMIZED', - max_vcpus=512, - min_vcpus=0, - instance_types=['optimal'], - volume_type='gp3', - size=1000, - iops=3000, - throughput=125, - ) - request = responses.calls[0].request - assert request.headers['apikey'] == APIKEY - payload = json.loads(request.body) - # Only the environment object should be sent in the body. - assert list(payload.keys()) == ['environment'] - env = payload['environment'] - assert env['computeEnvironmentName'] == 'New_spot_CE' - cr = env['computeResources'] - assert cr['type'] == 'SPOT' - assert cr['bidPercentage'] == 100 - assert cr['allocationStrategy'] == 'SPOT_CAPACITY_OPTIMIZED' - assert cr['volume']['throughput'] == 125 - - @responses.activate - def test_add_compute_environment_raises_on_400(self): - queue_id = 'q123' - responses.add( - responses.POST, - url=(f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/{queue_id}/" - f"compute-environment?teamId={WORKSPACE_ID}"), - body=json.dumps({'statusCode': 400, 'message': 'Bad Request.'}), - status=400, - content_type='application/json', - ) - q = self._make_queue() - with pytest.raises(BadRequestException): - q.add_compute_environment( - queue_id=queue_id, queue_label='my-queue', ce_name='CE', - provisioning_type='on-demand', - allocation_strategy='BEST_FIT_PROGRESSIVE', max_vcpus=512, - min_vcpus=0, instance_types=['optimal'], volume_type='gp3', - size=1000, iops=3000, throughput=125, - ) - - @responses.activate - def test_add_compute_environment_raises_on_401(self): - queue_id = 'q123' - responses.add( - responses.POST, - url=(f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/{queue_id}/" - f"compute-environment?teamId={WORKSPACE_ID}"), - body='', - status=401, - content_type='application/json', - ) - q = self._make_queue() - with pytest.raises(ComputeEnvAuthorizationException): - q.add_compute_environment( - queue_id=queue_id, queue_label='my-queue', ce_name='CE', - provisioning_type='on-demand', - allocation_strategy='BEST_FIT_PROGRESSIVE', max_vcpus=512, - min_vcpus=0, instance_types=['optimal'], volume_type='gp3', - size=1000, iops=3000, throughput=125, - ) - - -# =========================================================================== -# CLI integration tests – `cloudos queue create --add-compute-env` -# =========================================================================== - -class TestAddComputeEnvironmentCLI: - def _mock_get_queues(self, m, queues_str): - m.get( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queues?teamId={WORKSPACE_ID}", - text=queues_str, - status_code=200, - ) - m.get( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/system-job-queues?teamId={WORKSPACE_ID}", - text='[]', - status_code=200, - ) - - def test_add_compute_env_options_in_help(self): - runner = CliRunner() - result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) - assert result.exit_code == 0 - assert '--add-compute-env' in result.output - assert '--compute-env-name' in result.output - - def test_add_compute_env_missing_label_fails(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--add-compute-env', '--yes', - '--compute-env-name', 'CE-new', - ] - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - - def test_add_compute_env_queue_not_found(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'no-such-queue', - '--add-compute-env', '--yes', - '--compute-env-name', 'CE-new', - ] - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, _queue_with_n_ces('other', 'q1', 1)) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 1 - assert 'was found' in result.output or 'No job queue' in result.output - - def test_add_compute_env_limit_reached_exits(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'full-queue', - '--add-compute-env', '--yes', - '--compute-env-name', 'CE-new', - ] - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, _queue_with_n_ces('full-queue', 'qfull', 3)) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 0 - assert 'reached the limit' in result.output - - def test_add_compute_env_workspace_limit_reached_exits(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'my-queue', - '--add-compute-env', '--yes', - '--compute-env-name', 'CE-new', - ] - # Target queue has only 1 CE (under per-queue limit) but the workspace - # already holds 10 CEs in total across all queues. - queues = [ - { - 'id': 'qok', 'name': 'my-queue', 'label': 'my-queue', - 'description': '', 'isDefault': False, 'resourceType': '', - 'executor': 'nextflow', 'status': 'Ready', - 'computeEnvironments': [ - {'label': 'CE-0', 'environment': {}, 'status': 'Ready'} - ], - }, - { - 'id': 'qother', 'name': 'other', 'label': 'other', - 'description': '', 'isDefault': False, 'resourceType': '', - 'executor': 'nextflow', 'status': 'Ready', - 'computeEnvironments': [ - {'label': f'CE-{i}', 'environment': {}, 'status': 'Ready'} - for i in range(9) - ], - }, - ] - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, json.dumps(queues)) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 0 - assert 'reached the limit for compute environments in your workspace' \ - in result.output - - def test_add_compute_env_success(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'my-queue', - '--add-compute-env', '--yes', - '--compute-env-name', 'CE-new', - ] - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, _queue_with_n_ces('my-queue', 'qok', 1)) - m.post( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/qok/" - f"compute-environment?teamId={WORKSPACE_ID}", - text=QUEUES_LIST_STR, - status_code=200, - ) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 0 - assert 'added successfully' in result.output - assert 'reached the limit' not in result.output - - def test_add_compute_env_third_ce_shows_limit_message(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'two-ce-queue', - '--add-compute-env', '--yes', - '--compute-env-name', 'CE-third', - ] - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, _queue_with_n_ces('two-ce-queue', 'q2ce', 2)) - m.post( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/q2ce/" - f"compute-environment?teamId={WORKSPACE_ID}", - text=QUEUES_LIST_STR, - status_code=200, - ) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 0 - assert 'added successfully' in result.output - assert 'reached the limit' in result.output - - def test_add_compute_env_missing_ce_name_fails(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'my-queue', - '--add-compute-env', '--yes', - ] - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, _queue_with_n_ces('my-queue', 'qok', 1)) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - - def test_add_compute_env_conflicts_with_from_scratch(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'my-queue', - '--add-compute-env', '--from-scratch', '--yes', - '--compute-env-name', 'CE-new', - ] - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - - def test_add_compute_env_interactive_wizard_success(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'my-queue', - '--add-compute-env', - ] - wizard_input = '\n'.join([ - 'My Wizard CE', - 'spot', - 'SPOT_CAPACITY_OPTIMIZED', - '512', - '0', - 'optimal', - 'gp3', - '1000', - '3000', - '125', - ]) + '\n' - with requests_mock_module.Mocker() as m: - self._mock_get_queues(m, _queue_with_n_ces('my-queue', 'qwiz', 1)) - m.post( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue/qwiz/" - f"compute-environment?teamId={WORKSPACE_ID}", - text=QUEUES_LIST_STR, - status_code=200, - ) - result = runner.invoke(run_cloudos_cli, args, input=wizard_input) - assert result.exit_code == 0 - assert 'added successfully' in result.output - From 03d1cbbe8e3b8f6244603f1b8c0a7a05b3f92ace Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Tue, 23 Jun 2026 19:05:05 +0200 Subject: [PATCH 24/27] feat: add all instances --- cloudos_cli/queue/queue.py | 56 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 32146319..387dba26 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -48,9 +48,59 @@ "r5.12xlarge", "r5.16xlarge", "r5.24xlarge", "r5.metal", ] -# Union of every selectable instance type (standard + GPU families), used to -# validate user-supplied instance types for custom (from-scratch) queues. -ALL_INSTANCE_TYPES = sorted(set(_STANDARD_INSTANCE_TYPES) | set(_GPU_INSTANCE_TYPES)) +# --------------------------------------------------------------------------- +# Additional instance types accepted for custom (from-scratch) queues. +# +# The preset lists above intentionally mirror the CloudOS Platform UI presets, +# so they are left untouched. Custom queues may use any instance type AWS Batch +# supports, so the validation set (``ALL_INSTANCE_TYPES``) is widened with the +# current-generation Intel families below: compute-optimised (c6i/c7i), +# general-purpose (m6i/m7i) and memory-optimised (r6i/r7i) for standard +# workloads, plus g5/g6/p4d/p5 for GPU workloads. Sizes follow the AWS EC2 +# instance type catalogue. +# https://aws.amazon.com/ec2/instance-types/ +# https://aws.amazon.com/ec2/instance-types/accelerated-computing/ +# --------------------------------------------------------------------------- + +_STANDARD_INSTANCE_TYPES_EXTRA = [ + # Compute optimised + "c6i.large", "c6i.xlarge", "c6i.2xlarge", "c6i.4xlarge", "c6i.8xlarge", + "c6i.12xlarge", "c6i.16xlarge", "c6i.24xlarge", "c6i.32xlarge", "c6i.metal", + "c7i.large", "c7i.xlarge", "c7i.2xlarge", "c7i.4xlarge", "c7i.8xlarge", + "c7i.12xlarge", "c7i.16xlarge", "c7i.24xlarge", "c7i.48xlarge", + "c7i.metal-24xl", "c7i.metal-48xl", + # General purpose + "m6i.large", "m6i.xlarge", "m6i.2xlarge", "m6i.4xlarge", "m6i.8xlarge", + "m6i.12xlarge", "m6i.16xlarge", "m6i.24xlarge", "m6i.32xlarge", "m6i.metal", + "m7i.large", "m7i.xlarge", "m7i.2xlarge", "m7i.4xlarge", "m7i.8xlarge", + "m7i.12xlarge", "m7i.16xlarge", "m7i.24xlarge", "m7i.48xlarge", + "m7i.metal-24xl", "m7i.metal-48xl", + # Memory optimised + "r6i.large", "r6i.xlarge", "r6i.2xlarge", "r6i.4xlarge", "r6i.8xlarge", + "r6i.12xlarge", "r6i.16xlarge", "r6i.24xlarge", "r6i.32xlarge", "r6i.metal", + "r7i.large", "r7i.xlarge", "r7i.2xlarge", "r7i.4xlarge", "r7i.8xlarge", + "r7i.12xlarge", "r7i.16xlarge", "r7i.24xlarge", "r7i.48xlarge", + "r7i.metal-24xl", "r7i.metal-48xl", +] + +_GPU_INSTANCE_TYPES_EXTRA = [ + "g5.xlarge", "g5.2xlarge", "g5.4xlarge", "g5.8xlarge", + "g5.12xlarge", "g5.16xlarge", "g5.24xlarge", "g5.48xlarge", + "g6.xlarge", "g6.2xlarge", "g6.4xlarge", "g6.8xlarge", + "g6.12xlarge", "g6.16xlarge", "g6.24xlarge", "g6.48xlarge", + "p4d.24xlarge", + "p5.48xlarge", +] + +# Union of every selectable instance type (preset standard + GPU families plus +# the current-generation families above), used to validate user-supplied +# instance types for custom (from-scratch) queues. +ALL_INSTANCE_TYPES = sorted( + set(_STANDARD_INSTANCE_TYPES) + | set(_GPU_INSTANCE_TYPES) + | set(_STANDARD_INSTANCE_TYPES_EXTRA) + | set(_GPU_INSTANCE_TYPES_EXTRA) +) QUEUE_PRESETS = { "standard-stable": { From 549905fffe4ba35548dd876a2978c6ed37fe8622 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 26 Jun 2026 12:06:00 +0200 Subject: [PATCH 25/27] revert: disable --from-scratch until api is open --- cloudos_cli/queue/cli.py | 195 +++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 90 deletions(-) diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index a762c347..8ca6e4f2 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -630,61 +630,72 @@ def list_queues(ctx, default='nextflow', show_default=True, required=False) -@click.option('--from-scratch', - help=('Create a custom job queue from scratch. By default this ' - 'launches an interactive wizard. Combine with -y/--yes to ' - 'create non-interactively using the options below. Mutually ' - 'exclusive with --preset.'), - is_flag=True) -@click.option('--provisioning-type', - help='Provisioning type for --from-scratch. Default=on-demand.', - type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), - default='on-demand', - show_default=True) -@click.option('--allocation-strategy', - help=('Allocation strategy for --from-scratch. spot supports all ' - 'strategies; on-demand supports BEST_FIT and ' - 'BEST_FIT_PROGRESSIVE. Default=BEST_FIT_PROGRESSIVE.'), - type=click.Choice(_ALL_ALLOCATION_STRATEGIES, case_sensitive=False), - default='BEST_FIT_PROGRESSIVE', - show_default=True) -@click.option('--max-vcpus', - help=f'Max vCPUs for --from-scratch. Max {MAX_VCPUS_LIMIT}.', - type=click.IntRange(0, MAX_VCPUS_LIMIT), - default=DEFAULT_MAX_VCPUS, - show_default=True) -@click.option('--min-vcpus', - help='Min vCPUs for --from-scratch.', - type=click.IntRange(0, MAX_VCPUS_LIMIT), - default=DEFAULT_MIN_VCPUS, - show_default=True) -@click.option('--instance-types', - help=("Instance types for --from-scratch. 'optimal' or a " - 'comma-separated list of standard or GPU instance types. ' - 'Default=optimal.'), - default='optimal', - show_default=True) -@click.option('--volume-type', - help='Volume type for --from-scratch. Default=gp3.', - type=click.Choice(list(VOLUME_SPECS.keys()), case_sensitive=False), - default='gp3', - show_default=True) -@click.option('--size', - help=f'Volume size in GiB for --from-scratch. Default={_DEFAULT_SIZE}.', - type=int, - default=_DEFAULT_SIZE, - show_default=True) -@click.option('--iops', - help=f'Provisioned IOPS for --from-scratch. Default={_DEFAULT_IOPS}.', - type=int, - default=_DEFAULT_IOPS, - show_default=True) -@click.option('--throughput', - help=('Volume throughput in MB/s for --from-scratch (gp3 only). ' - f'Default={_DEFAULT_THROUGHPUT}.'), - type=int, - default=_DEFAULT_THROUGHPUT, - show_default=True) +# --------------------------------------------------------------------------- +# DISABLED: --from-scratch (custom queue) options. +# +# The custom (from-scratch) queue creation flow depends on a per-workspace API +# endpoint that returns the workspace's available instance types. That endpoint +# is currently closed, so the entire --from-scratch feature is hidden from the +# user. The code is intentionally kept (commented out) so it can be re-enabled +# once the endpoint is opened: simply uncomment this option block, the matching +# parameters in the create_queue signature, and the `if from_scratch:` dispatch +# branch below. +# --------------------------------------------------------------------------- +# @click.option('--from-scratch', +# help=('Create a custom job queue from scratch. By default this ' +# 'launches an interactive wizard. Combine with -y/--yes to ' +# 'create non-interactively using the options below. Mutually ' +# 'exclusive with --preset.'), +# is_flag=True) +# @click.option('--provisioning-type', +# help='Provisioning type for --from-scratch. Default=on-demand.', +# type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), +# default='on-demand', +# show_default=True) +# @click.option('--allocation-strategy', +# help=('Allocation strategy for --from-scratch. spot supports all ' +# 'strategies; on-demand supports BEST_FIT and ' +# 'BEST_FIT_PROGRESSIVE. Default=BEST_FIT_PROGRESSIVE.'), +# type=click.Choice(_ALL_ALLOCATION_STRATEGIES, case_sensitive=False), +# default='BEST_FIT_PROGRESSIVE', +# show_default=True) +# @click.option('--max-vcpus', +# help=f'Max vCPUs for --from-scratch. Max {MAX_VCPUS_LIMIT}.', +# type=click.IntRange(0, MAX_VCPUS_LIMIT), +# default=DEFAULT_MAX_VCPUS, +# show_default=True) +# @click.option('--min-vcpus', +# help='Min vCPUs for --from-scratch.', +# type=click.IntRange(0, MAX_VCPUS_LIMIT), +# default=DEFAULT_MIN_VCPUS, +# show_default=True) +# @click.option('--instance-types', +# help=("Instance types for --from-scratch. 'optimal' or a " +# 'comma-separated list of standard or GPU instance types. ' +# 'Default=optimal.'), +# default='optimal', +# show_default=True) +# @click.option('--volume-type', +# help='Volume type for --from-scratch. Default=gp3.', +# type=click.Choice(list(VOLUME_SPECS.keys()), case_sensitive=False), +# default='gp3', +# show_default=True) +# @click.option('--size', +# help=f'Volume size in GiB for --from-scratch. Default={_DEFAULT_SIZE}.', +# type=int, +# default=_DEFAULT_SIZE, +# show_default=True) +# @click.option('--iops', +# help=f'Provisioned IOPS for --from-scratch. Default={_DEFAULT_IOPS}.', +# type=int, +# default=_DEFAULT_IOPS, +# show_default=True) +# @click.option('--throughput', +# help=('Volume throughput in MB/s for --from-scratch (gp3 only). ' +# f'Default={_DEFAULT_THROUGHPUT}.'), +# type=int, +# default=_DEFAULT_THROUGHPUT, +# show_default=True) @click.option('-y', '--yes', 'skip_confirmation', @@ -714,16 +725,17 @@ def create_queue(ctx, description, preset, executor, - from_scratch, - provisioning_type, - allocation_strategy, - max_vcpus, - min_vcpus, - instance_types, - volume_type, - size, - iops, - throughput, + # DISABLED: --from-scratch parameters (see option block above). + # from_scratch, + # provisioning_type, + # allocation_strategy, + # max_vcpus, + # min_vcpus, + # instance_types, + # volume_type, + # size, + # iops, + # throughput, skip_confirmation, set_default, execution_platform, @@ -732,8 +744,7 @@ def create_queue(ctx, profile): """Create a new job queue in a Lifebit Platform workspace. - By default a preset template is used. Pass --from-scratch to build a custom - queue, either interactively (default) or non-interactively with -y/--yes. + A preset template is used to create the queue. """ verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) @@ -752,29 +763,33 @@ def create_queue(ctx, if not description: raise click.UsageError('Missing option --description.') - if from_scratch: - _create_queue_from_scratch( - ctx=ctx, - cloudos_url=cloudos_url, - apikey=apikey, - workspace_id=workspace_id, - verify_ssl=verify_ssl, - label=label, - description=description, - executor=executor, - provisioning_type=provisioning_type, - allocation_strategy=allocation_strategy, - max_vcpus=max_vcpus, - min_vcpus=min_vcpus, - instance_types=instance_types, - volume_type=volume_type, - size=size, - iops=iops, - throughput=throughput, - skip_confirmation=skip_confirmation, - set_default=set_default, - ) - return + # DISABLED: --from-scratch dispatch branch. The custom queue flow is hidden + # until the per-workspace instance-types endpoint is opened. To re-enable, + # uncomment this block along with the option block and signature parameters + # above. + # if from_scratch: + # _create_queue_from_scratch( + # ctx=ctx, + # cloudos_url=cloudos_url, + # apikey=apikey, + # workspace_id=workspace_id, + # verify_ssl=verify_ssl, + # label=label, + # description=description, + # executor=executor, + # provisioning_type=provisioning_type, + # allocation_strategy=allocation_strategy, + # max_vcpus=max_vcpus, + # min_vcpus=min_vcpus, + # instance_types=instance_types, + # volume_type=volume_type, + # size=size, + # iops=iops, + # throughput=throughput, + # skip_confirmation=skip_confirmation, + # set_default=set_default, + # ) + # return if label is None: raise click.UsageError('Missing option --label.') From 2f629ef5e7aeaf5745ae925b549813f2cab83503 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 26 Jun 2026 14:20:07 +0200 Subject: [PATCH 26/27] test: disable --from-scratch tests --- tests/test_queue/test_create_queue.py | 299 +++++++++++++------------- 1 file changed, 152 insertions(+), 147 deletions(-) diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index c2bd9f0a..97018b12 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -526,153 +526,158 @@ def test_invalid_volume_type_raises(self): # =========================================================================== # CLI integration tests – `cloudos queue create --from-scratch` # =========================================================================== - -class TestCreateQueueFromScratchCLI: - def test_from_scratch_options_in_help(self): - runner = CliRunner() - result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) - assert result.exit_code == 0 - for opt in ['--from-scratch', '--provisioning-type', '--allocation-strategy', - '--max-vcpus', '--min-vcpus', '--instance-types', '--volume-type', - '--size', '--iops', '--throughput']: - assert opt in result.output - - def test_from_scratch_yes_success(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'Custom Queue', - '--description', 'A custom queue', - '--from-scratch', '--yes', - ] - with requests_mock_module.Mocker() as m: - _mock_get_queues_with_total_ces(m, 1) - m.post( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", - text=CREATE_RESPONSE_JSON_STR, - status_code=200, - ) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 0 - assert 'created successfully' in result.output - - def test_from_scratch_workspace_limit_reached_exits(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'Custom Queue', - '--description', 'A custom queue', - '--from-scratch', '--yes', - ] - with requests_mock_module.Mocker() as m: - _mock_get_queues_with_total_ces(m, 10, n_queues=3) - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code == 0 - assert 'reached the limit for compute environments in your workspace' \ - in result.output - - def test_from_scratch_mutually_exclusive_with_preset(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'Custom Queue', - '--description', 'A custom queue', - '--from-scratch', '--preset', 'standard-gpu', '--yes', - ] - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - assert 'cannot be combined with --preset' in _plain(result.output) - - def test_from_scratch_incompatible_strategy_rejected(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'Custom Queue', - '--description', 'A custom queue', - '--from-scratch', '--yes', - '--provisioning-type', 'on-demand', - '--allocation-strategy', 'SPOT_CAPACITY_OPTIMIZED', - ] - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - - def test_from_scratch_gp3_iops_out_of_range_rejected(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'Custom Queue', - '--description', 'A custom queue', - '--from-scratch', '--yes', - '--volume-type', 'gp3', - '--iops', '100', - ] - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - - def test_from_scratch_invalid_instance_type_rejected(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--label', 'Custom Queue', - '--description', 'A custom queue', - '--from-scratch', '--yes', - '--instance-types', 'not-an-instance', - ] - result = runner.invoke(run_cloudos_cli, args) - assert result.exit_code != 0 - - def test_from_scratch_interactive_wizard_success(self): - runner = CliRunner() - args = [ - 'queue', 'create', - '--apikey', APIKEY, - '--cloudos-url', CLOUDOS_URL, - '--workspace-id', WORKSPACE_ID, - '--description', 'A wizard queue', - '--from-scratch', - ] - # Wizard answers: name, provisioning, strategy, max, min, instances, - # volume type, size, iops, throughput. - wizard_input = '\n'.join([ - 'My Wizard Queue', - 'on-demand', - 'BEST_FIT_PROGRESSIVE', - '512', - '0', - 'optimal', - 'gp3', - '1000', - '3000', - '125', - ]) + '\n' - with requests_mock_module.Mocker() as m: - _mock_get_queues_with_total_ces(m, 1) - m.post( - f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", - text=CREATE_RESPONSE_JSON_STR, - status_code=200, - ) - result = runner.invoke(run_cloudos_cli, args, input=wizard_input) - assert result.exit_code == 0 - assert 'created successfully' in result.output +# +# DISABLED: the --from-scratch CLI flow is hidden until the per-workspace +# instance-types API endpoint is opened (the CLI options were commented out in +# cloudos_cli/queue/cli.py). These integration tests are commented out together +# with that feature and should be restored when it is re-enabled. +# +# class TestCreateQueueFromScratchCLI: +# def test_from_scratch_options_in_help(self): +# runner = CliRunner() +# result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) +# assert result.exit_code == 0 +# for opt in ['--from-scratch', '--provisioning-type', '--allocation-strategy', +# '--max-vcpus', '--min-vcpus', '--instance-types', '--volume-type', +# '--size', '--iops', '--throughput']: +# assert opt in result.output +# +# def test_from_scratch_yes_success(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--label', 'Custom Queue', +# '--description', 'A custom queue', +# '--from-scratch', '--yes', +# ] +# with requests_mock_module.Mocker() as m: +# _mock_get_queues_with_total_ces(m, 1) +# m.post( +# f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", +# text=CREATE_RESPONSE_JSON_STR, +# status_code=200, +# ) +# result = runner.invoke(run_cloudos_cli, args) +# assert result.exit_code == 0 +# assert 'created successfully' in result.output +# +# def test_from_scratch_workspace_limit_reached_exits(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--label', 'Custom Queue', +# '--description', 'A custom queue', +# '--from-scratch', '--yes', +# ] +# with requests_mock_module.Mocker() as m: +# _mock_get_queues_with_total_ces(m, 10, n_queues=3) +# result = runner.invoke(run_cloudos_cli, args) +# assert result.exit_code == 0 +# assert 'reached the limit for compute environments in your workspace' \ +# in result.output +# +# def test_from_scratch_mutually_exclusive_with_preset(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--label', 'Custom Queue', +# '--description', 'A custom queue', +# '--from-scratch', '--preset', 'standard-gpu', '--yes', +# ] +# result = runner.invoke(run_cloudos_cli, args) +# assert result.exit_code != 0 +# assert 'cannot be combined with --preset' in _plain(result.output) +# +# def test_from_scratch_incompatible_strategy_rejected(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--label', 'Custom Queue', +# '--description', 'A custom queue', +# '--from-scratch', '--yes', +# '--provisioning-type', 'on-demand', +# '--allocation-strategy', 'SPOT_CAPACITY_OPTIMIZED', +# ] +# result = runner.invoke(run_cloudos_cli, args) +# assert result.exit_code != 0 +# +# def test_from_scratch_gp3_iops_out_of_range_rejected(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--label', 'Custom Queue', +# '--description', 'A custom queue', +# '--from-scratch', '--yes', +# '--volume-type', 'gp3', +# '--iops', '100', +# ] +# result = runner.invoke(run_cloudos_cli, args) +# assert result.exit_code != 0 +# +# def test_from_scratch_invalid_instance_type_rejected(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--label', 'Custom Queue', +# '--description', 'A custom queue', +# '--from-scratch', '--yes', +# '--instance-types', 'not-an-instance', +# ] +# result = runner.invoke(run_cloudos_cli, args) +# assert result.exit_code != 0 +# +# def test_from_scratch_interactive_wizard_success(self): +# runner = CliRunner() +# args = [ +# 'queue', 'create', +# '--apikey', APIKEY, +# '--cloudos-url', CLOUDOS_URL, +# '--workspace-id', WORKSPACE_ID, +# '--description', 'A wizard queue', +# '--from-scratch', +# ] +# # Wizard answers: name, provisioning, strategy, max, min, instances, +# # volume type, size, iops, throughput. +# wizard_input = '\n'.join([ +# 'My Wizard Queue', +# 'on-demand', +# 'BEST_FIT_PROGRESSIVE', +# '512', +# '0', +# 'optimal', +# 'gp3', +# '1000', +# '3000', +# '125', +# ]) + '\n' +# with requests_mock_module.Mocker() as m: +# _mock_get_queues_with_total_ces(m, 1) +# m.post( +# f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", +# text=CREATE_RESPONSE_JSON_STR, +# status_code=200, +# ) +# result = runner.invoke(run_cloudos_cli, args, input=wizard_input) +# assert result.exit_code == 0 +# assert 'created successfully' in result.output # =========================================================================== From 4b9220043533da37cb0e92090e5b22a9788d3ac3 Mon Sep 17 00:00:00 2001 From: Daniel Boloc Date: Fri, 26 Jun 2026 16:30:39 +0200 Subject: [PATCH 27/27] refactor: remove --from-scratch helpers --- CHANGELOG.md | 1 - README.md | 6 - cloudos_cli/queue/cli.py | 640 +------------------------- cloudos_cli/queue/queue.py | 302 ------------ tests/test_queue/test_create_queue.py | 267 ----------- 5 files changed, 1 insertion(+), 1215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cce73f83..a3b7200d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ ### Feat: - Adds `cloudos queue create` with preset templates (e.g. `standard-stable`, `standard-gpu`) -- Adds `--from-scratch` to build custom queues via an interactive wizard or flags - Adds `--set-default` to mark a new queue as the workspace default ## v2.93.1 (2026-06-12) diff --git a/README.md b/README.md index e424ac40..c4fdb3f6 100644 --- a/README.md +++ b/README.md @@ -468,12 +468,6 @@ Executing queue create... View at : https://cloudos.lifebit.ai/app/job-queues/64f1a23b8e4c9d001234abcd ``` -For full control over the compute environment, use the `--from-scratch` flag, which launches an interactive wizard (or runs non-interactively when combined with `-y`/`--yes`). This lets you customize provisioning type, allocation strategy, vCPUs, instance types, and volume settings: - -```bash -cloudos queue create --profile my_profile --label "custom-queue" --description "Custom spot queue" --from-scratch --provisioning-type spot --max-vcpus 256 --instance-types optimal -y -``` - > [!NOTE] > **Azure Platform**: Batch job queues are an AWS-only feature and are not available in Azure or HPC workspaces. diff --git a/cloudos_cli/queue/cli.py b/cloudos_cli/queue/cli.py index 8ca6e4f2..40bd222e 100644 --- a/cloudos_cli/queue/cli.py +++ b/cloudos_cli/queue/cli.py @@ -4,20 +4,11 @@ import rich_click as click import json from rich.console import Console -from rich.panel import Panel -from rich.table import Table from cloudos_cli.queue.queue import ( Queue, QUEUE_PRESETS, - PROVISIONING_TYPES, - ALLOCATION_STRATEGIES, - VOLUME_SPECS, - MAX_VCPUS_LIMIT, - DEFAULT_MAX_VCPUS, - DEFAULT_MIN_VCPUS, MAX_WORKSPACE_COMPUTE_ENVS, WORKSPACE_CE_LIMIT_REACHED_MESSAGE, - ALL_INSTANCE_TYPES, ) from cloudos_cli.utils.resources import ssl_selector from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL @@ -25,449 +16,9 @@ from cloudos_cli.utils.details import create_queue_list_table -# Union of all allocation strategies, used for the CLI option choices. -_ALL_ALLOCATION_STRATEGIES = ["BEST_FIT", "BEST_FIT_PROGRESSIVE", "SPOT_CAPACITY_OPTIMIZED"] - # Workflow executors supported for job queues. _EXECUTORS = ["nextflow", "cromwell"] -# Default volume parameters for the non-interactive --from-scratch flags. These -# mirror the gp3 spec (the default volume type) in VOLUME_SPECS so the CLI -# defaults never drift from the validated specification. -_DEFAULT_SIZE = VOLUME_SPECS["gp3"]["size"][0] -_DEFAULT_IOPS = VOLUME_SPECS["gp3"]["iops"][0] -_DEFAULT_THROUGHPUT = VOLUME_SPECS["gp3"]["throughput"][0] - -# --------------------------------------------------------------------------- -# Wizard styling helpers -# --------------------------------------------------------------------------- - -# Colour theme for the interactive --from-scratch wizard. -_C_ACCENT = "cyan" -_C_STEP = "bold cyan" -_C_TITLE = "bold white" -_C_OPTION = "bold green" -_C_DESC = "grey62" -_C_HINT = "grey50" - - -def _print_section(console, step, total, title, subtitle=None, options=None, hint=None): - """Print a styled wizard section: step badge, title, options and hint. - - Parameters - ---------- - console : rich.console.Console - The console used for rich output. - step : int - The current step number. - total : int - The total number of steps. - title : str - The section title. - subtitle : str or None, optional - A short clarifying line shown under the title. - options : list[tuple[str, str]] or None, optional - A list of ``(name, description)`` pairs. Names are highlighted and - descriptions shown in a dimmed (grey) style, both indented. - hint : str or None, optional - A short hint (e.g. allowed range / default) shown just above the prompt. - """ - console.print() - console.rule( - f"[{_C_STEP}]Step {step}/{total}[/{_C_STEP}] [{_C_TITLE}]{title}[/{_C_TITLE}]", - align="left", - characters="─", - style=_C_ACCENT, - ) - if subtitle: - console.print(f" [{_C_DESC}]{subtitle}[/{_C_DESC}]") - if options: - console.print() - for name, description in options: - console.print(f" [{_C_OPTION}]●[/{_C_OPTION}] [{_C_OPTION}]{name}[/{_C_OPTION}]") - console.print(f" [{_C_DESC}]{description}[/{_C_DESC}]") - if hint: - console.print() - console.print(f" [{_C_HINT}]{hint}[/{_C_HINT}]") - console.print() - - -def _styled_prompt(label, **kwargs): - """Issue a ``click.prompt`` with a consistent, coloured prompt line. - - Parameters - ---------- - label : str - The prompt label (without the leading arrow). - **kwargs - Forwarded to ``click.prompt`` (e.g. ``type``, ``default``). - - Returns - ------- - The value returned by ``click.prompt``. - """ - arrow = click.style(" ❯ ", fg="cyan", bold=True) - text = arrow + click.style(label, fg="white", bold=True) - return click.prompt(text, **kwargs) - - -def _from_scratch_wizard(console): - """Interactively collect custom queue parameters, emulating the UI flow. - - Parameters - ---------- - console : rich.console.Console - The console used for rich output. - - Returns - ------- - params : dict - A dict with keys: ``label``, ``provisioning_type``, - ``allocation_strategy``, ``max_vcpus``, ``min_vcpus``, - ``instance_types``, ``volume_type``, ``size``, ``iops`` and - ``throughput``. - """ - total = 10 - console.print() - intro = ( - "[bold cyan]Create a job queue from scratch[/bold cyan]\n" - "[grey62]Answer the prompts below to configure your custom " - "compute environment.[/grey62]" - ) - console.print( - Panel.fit(intro, border_style="cyan", padding=(1, 4)) - ) - - # 1. Name - _print_section( - console, 1, total, "Name", - subtitle="A human-readable name for your job queue.", - ) - label = _styled_prompt("Name of the queue", type=str) - - # 2. Provisioning type - _print_section( - console, 2, total, "Provisioning type", - subtitle="Choose how your compute instances are provisioned.", - options=[ - ("On demand", - "EC2 usage and provisioned storage for EBS volumes are billed on " - "one second increments, with a minimum of 60 seconds."), - ("Spot", - "Save money by using Spot instances but your instances can be " - "interrupted with a two minute notification when EC2 needs the " - "capacity back."), - ], - ) - provisioning_type = _styled_prompt( - "Provisioning type", - type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), - default="on-demand", - ) - - # 3. Allocation strategy - allowed_strategies = ALLOCATION_STRATEGIES[provisioning_type] - _print_section( - console, 3, total, "Allocation strategy", - subtitle="Choose how batch launches instances on your behalf.", - hint="We recommend Best Fit Progressive for On-Demand CEs and " - "Spot Capacity Optimised for Spot CEs.", - ) - allocation_strategy = _styled_prompt( - "Allocation strategy", - type=click.Choice(allowed_strategies, case_sensitive=False), - default="BEST_FIT_PROGRESSIVE", - ) - - # 4. Max vCPUs - _print_section( - console, 4, total, "Max vCPUs", - subtitle="Maximum number of vCPUs the queue can scale up to.", - hint=f"Max {MAX_VCPUS_LIMIT}. Default {DEFAULT_MAX_VCPUS}.", - ) - max_vcpus = _styled_prompt( - "Max vCPUs", - type=click.IntRange(0, MAX_VCPUS_LIMIT), - default=DEFAULT_MAX_VCPUS, - ) - - # 5. Min vCPUs - _print_section( - console, 5, total, "Min vCPUs", - subtitle="Minimum number of vCPUs kept running (optional).", - hint="Default 0.", - ) - min_vcpus = _styled_prompt( - "Min vCPUs", - type=click.IntRange(0, MAX_VCPUS_LIMIT), - default=DEFAULT_MIN_VCPUS, - ) - - # 6. Instance types - _print_section( - console, 6, total, "Instance types", - subtitle="Optimal or a combination of instances.", - hint="Enter 'optimal' or a comma-separated list of instance types " - "from the standard (c5, r5, m5, c4, r4, m4) or GPU (p3, g4dn) " - "families.", - ) - instance_types = _prompt_instance_types(console) - - # 7. Volume type - _print_section( - console, 7, total, "Volume type", - subtitle="Select your preferred volume type.", - options=[ - ("General Purpose SSD (gp3)", - "Balanced price and performance for a wide variety of workloads."), - ("Provisioned IOPS SSD (io2)", - "High-performance SSD for I/O-intensive workloads."), - ], - ) - volume_type = _styled_prompt( - "Volume type", - type=click.Choice(list(VOLUME_SPECS.keys()), case_sensitive=False), - default="gp3", - ) - if volume_type == "io2": - console.print() - console.print( - Panel( - "[bold yellow]⚠ Warning, high cost disk type.[/bold yellow]\n" - "[grey62]Read more: " - "https://lifebit.atlassian.net/wiki/spaces/CD/pages/316506431/" - "Disk+types[/grey62]", - border_style="yellow", - padding=(0, 2), - ) - ) - - spec = VOLUME_SPECS[volume_type] - - # 8. Size (GiB) - size_default, size_min, size_max = spec["size"] - _print_section( - console, 8, total, "Size (GiB)", - subtitle="Volume size in GiB.", - hint=f"Min {size_min}, max {size_max}. Default {size_default}.", - ) - size = _styled_prompt( - "Size (GiB)", - type=click.IntRange(size_min, size_max), - default=size_default, - ) - - # 9. IOPS - iops_default, iops_min, iops_max = spec["iops"] - _print_section( - console, 9, total, "IOPS", - subtitle="Input/output Operations per Second (IOPS).", - hint="A high IOPS is needed for jobs with high throughput that need " - f"many files to be written/read. Min {iops_min}, max {iops_max}. " - f"Default {iops_default}.", - ) - iops = _styled_prompt( - "IOPS", - type=click.IntRange(iops_min, iops_max), - default=iops_default, - ) - - # Throughput (gp3 only) - throughput = None - if spec["throughput"] is not None: - tp_default, tp_min, tp_max = spec["throughput"] - _print_section( - console, 10, total, "Throughput (MB/s)", - subtitle="Volume throughput in MB/s.", - hint=f"Min {tp_min}, max {tp_max}. Default {tp_default}.", - ) - throughput = _styled_prompt( - "Throughput (MB/s)", - type=click.IntRange(tp_min, tp_max), - default=tp_default, - ) - - params = { - "label": label, - "provisioning_type": provisioning_type, - "allocation_strategy": allocation_strategy, - "max_vcpus": max_vcpus, - "min_vcpus": min_vcpus, - "instance_types": instance_types, - "volume_type": volume_type, - "size": size, - "iops": iops, - "throughput": throughput, - } - _print_summary(console, params) - return params - - -def _print_summary(console, params): - """Print a styled summary table of the collected wizard parameters. - - Parameters - ---------- - console : rich.console.Console - The console used for rich output. - params : dict - The collected from-scratch parameters. - """ - table = Table( - title="[bold cyan]Queue configuration summary[/bold cyan]", - show_header=False, - box=None, - padding=(0, 2), - ) - table.add_column(justify="right", style="grey62", no_wrap=True) - table.add_column(style="white") - - instance_label = ", ".join(params["instance_types"]) - rows = [ - ("Name", params["label"]), - ("Provisioning type", params["provisioning_type"]), - ("Allocation strategy", params["allocation_strategy"]), - ("Max vCPUs", str(params["max_vcpus"])), - ("Min vCPUs", str(params["min_vcpus"])), - ("Instance types", instance_label), - ("Volume type", params["volume_type"]), - ("Size (GiB)", str(params["size"])), - ("IOPS", str(params["iops"])), - ] - if params["throughput"] is not None: - rows.append(("Throughput (MB/s)", str(params["throughput"]))) - - for name, value in rows: - table.add_row(name, value) - - console.print() - console.print(table) - console.print() - - -def _prompt_instance_types(console): - """Prompt for instance types, validating them against the standard list. - - Parameters - ---------- - console : rich.console.Console - The console used for rich output. - - Returns - ------- - instance_types : list[str] - The validated list of instance types. - """ - while True: - raw = _styled_prompt("Instance types", type=str, default="optimal") - instance_types = [item.strip() for item in raw.split(",") if item.strip()] - invalid = [item for item in instance_types if item not in ALL_INSTANCE_TYPES] - if not instance_types: - console.print("[red]Please provide at least one instance type.[/red]") - continue - if invalid: - console.print( - f"[red]Invalid instance type(s): {', '.join(invalid)}.[/red] " - "[dim]Allowed values are 'optimal' or standard/GPU instance types.[/dim]" - ) - continue - return instance_types - - -def _parse_instance_types(raw): - """Parse and validate a comma-separated instance types string. - - Parameters - ---------- - raw : str - Comma-separated instance types (e.g. ``'optimal'`` or ``'c5.xlarge,m5.xlarge'``). - - Returns - ------- - instance_types : list[str] - The parsed list of instance types. - - Raises - ------ - click.BadParameter - If the string is empty or contains unrecognised instance types. - """ - instance_types = [item.strip() for item in raw.split(",") if item.strip()] - if not instance_types: - raise click.BadParameter("At least one instance type is required.") - invalid = [item for item in instance_types if item not in ALL_INSTANCE_TYPES] - if invalid: - raise click.BadParameter( - f"Invalid instance type(s): {', '.join(invalid)}. " - "Allowed values are 'optimal' or standard/GPU instance types." - ) - return instance_types - - -def _validate_from_scratch_flags(params): - """Validate non-interactive ``--from-scratch`` flag values. - - Parameters - ---------- - params : dict - A dict with the from-scratch parameters (same keys as produced by - ``_from_scratch_wizard``). - - Raises - ------ - click.BadParameter - If the allocation strategy is incompatible with the provisioning type - or the volume size/IOPS/throughput fall outside the allowed range. - """ - provisioning_type = params["provisioning_type"] - allowed_strategies = ALLOCATION_STRATEGIES[provisioning_type] - if params["allocation_strategy"] not in allowed_strategies: - raise click.BadParameter( - f"Allocation strategy '{params['allocation_strategy']}' is not valid " - f"for '{provisioning_type}' provisioning. Valid options are: " - f"{', '.join(allowed_strategies)}." - ) - - if params["min_vcpus"] > params["max_vcpus"]: - raise click.BadParameter( - f"--min-vcpus cannot be greater than --max-vcpus (got {params['min_vcpus']} > {params['max_vcpus']})." - ) - - spec = VOLUME_SPECS[params["volume_type"]] - _check_range("--size", params["size"], spec["size"]) - _check_range("--iops", params["iops"], spec["iops"]) - if spec["throughput"] is not None: - if params["throughput"] is None: - params["throughput"] = spec["throughput"][0] - _check_range("--throughput", params["throughput"], spec["throughput"]) - else: - params["throughput"] = None - - -def _check_range(name, value, spec): - """Validate that ``value`` is within the ``(default, min, max)`` spec. - - Parameters - ---------- - name : str - The option name (for error messages). - value : int - The value to validate. - spec : tuple[int, int, int] - A ``(default, minimum, maximum)`` tuple. - - Raises - ------ - click.BadParameter - If ``value`` is outside ``[minimum, maximum]``. - """ - _, minimum, maximum = spec - if value < minimum or value > maximum: - raise click.BadParameter( - f"{name} must be between {minimum} and {maximum} for the selected " - f"volume type (got {value})." - ) - def _check_workspace_ce_limit(console, j_queue, queues=None): """Exit with a warning if the workspace has reached its CE limit. @@ -630,72 +181,6 @@ def list_queues(ctx, default='nextflow', show_default=True, required=False) -# --------------------------------------------------------------------------- -# DISABLED: --from-scratch (custom queue) options. -# -# The custom (from-scratch) queue creation flow depends on a per-workspace API -# endpoint that returns the workspace's available instance types. That endpoint -# is currently closed, so the entire --from-scratch feature is hidden from the -# user. The code is intentionally kept (commented out) so it can be re-enabled -# once the endpoint is opened: simply uncomment this option block, the matching -# parameters in the create_queue signature, and the `if from_scratch:` dispatch -# branch below. -# --------------------------------------------------------------------------- -# @click.option('--from-scratch', -# help=('Create a custom job queue from scratch. By default this ' -# 'launches an interactive wizard. Combine with -y/--yes to ' -# 'create non-interactively using the options below. Mutually ' -# 'exclusive with --preset.'), -# is_flag=True) -# @click.option('--provisioning-type', -# help='Provisioning type for --from-scratch. Default=on-demand.', -# type=click.Choice(list(PROVISIONING_TYPES.keys()), case_sensitive=False), -# default='on-demand', -# show_default=True) -# @click.option('--allocation-strategy', -# help=('Allocation strategy for --from-scratch. spot supports all ' -# 'strategies; on-demand supports BEST_FIT and ' -# 'BEST_FIT_PROGRESSIVE. Default=BEST_FIT_PROGRESSIVE.'), -# type=click.Choice(_ALL_ALLOCATION_STRATEGIES, case_sensitive=False), -# default='BEST_FIT_PROGRESSIVE', -# show_default=True) -# @click.option('--max-vcpus', -# help=f'Max vCPUs for --from-scratch. Max {MAX_VCPUS_LIMIT}.', -# type=click.IntRange(0, MAX_VCPUS_LIMIT), -# default=DEFAULT_MAX_VCPUS, -# show_default=True) -# @click.option('--min-vcpus', -# help='Min vCPUs for --from-scratch.', -# type=click.IntRange(0, MAX_VCPUS_LIMIT), -# default=DEFAULT_MIN_VCPUS, -# show_default=True) -# @click.option('--instance-types', -# help=("Instance types for --from-scratch. 'optimal' or a " -# 'comma-separated list of standard or GPU instance types. ' -# 'Default=optimal.'), -# default='optimal', -# show_default=True) -# @click.option('--volume-type', -# help='Volume type for --from-scratch. Default=gp3.', -# type=click.Choice(list(VOLUME_SPECS.keys()), case_sensitive=False), -# default='gp3', -# show_default=True) -# @click.option('--size', -# help=f'Volume size in GiB for --from-scratch. Default={_DEFAULT_SIZE}.', -# type=int, -# default=_DEFAULT_SIZE, -# show_default=True) -# @click.option('--iops', -# help=f'Provisioned IOPS for --from-scratch. Default={_DEFAULT_IOPS}.', -# type=int, -# default=_DEFAULT_IOPS, -# show_default=True) -# @click.option('--throughput', -# help=('Volume throughput in MB/s for --from-scratch (gp3 only). ' -# f'Default={_DEFAULT_THROUGHPUT}.'), -# type=int, -# default=_DEFAULT_THROUGHPUT, -# show_default=True) @click.option('-y', '--yes', 'skip_confirmation', @@ -725,17 +210,6 @@ def create_queue(ctx, description, preset, executor, - # DISABLED: --from-scratch parameters (see option block above). - # from_scratch, - # provisioning_type, - # allocation_strategy, - # max_vcpus, - # min_vcpus, - # instance_types, - # volume_type, - # size, - # iops, - # throughput, skip_confirmation, set_default, execution_platform, @@ -758,39 +232,10 @@ def create_queue(ctx, ) sys.exit(0) - # --description is required when creating a queue (both preset and - # from-scratch paths). + # --description is required when creating a queue. if not description: raise click.UsageError('Missing option --description.') - # DISABLED: --from-scratch dispatch branch. The custom queue flow is hidden - # until the per-workspace instance-types endpoint is opened. To re-enable, - # uncomment this block along with the option block and signature parameters - # above. - # if from_scratch: - # _create_queue_from_scratch( - # ctx=ctx, - # cloudos_url=cloudos_url, - # apikey=apikey, - # workspace_id=workspace_id, - # verify_ssl=verify_ssl, - # label=label, - # description=description, - # executor=executor, - # provisioning_type=provisioning_type, - # allocation_strategy=allocation_strategy, - # max_vcpus=max_vcpus, - # min_vcpus=min_vcpus, - # instance_types=instance_types, - # volume_type=volume_type, - # size=size, - # iops=iops, - # throughput=throughput, - # skip_confirmation=skip_confirmation, - # set_default=set_default, - # ) - # return - if label is None: raise click.UsageError('Missing option --label.') @@ -843,86 +288,3 @@ def create_queue(ctx, console.print(f'\t[red]Error creating queue:[/red] {str(e)}') sys.exit(1) - -def _create_queue_from_scratch(ctx, - cloudos_url, - apikey, - workspace_id, - verify_ssl, - label, - description, - executor, - provisioning_type, - allocation_strategy, - max_vcpus, - min_vcpus, - instance_types, - volume_type, - size, - iops, - throughput, - skip_confirmation, - set_default): - """Handle the --from-scratch branch of ``cloudos queue create``. - - When ``skip_confirmation`` is False, an interactive wizard collects all the - parameters. Otherwise the provided option flags are validated and used - directly (non-interactive mode). - """ - console = Console() - - # --from-scratch is mutually exclusive with an explicitly-set --preset. - if ctx.get_parameter_source('preset') == click.core.ParameterSource.COMMANDLINE: - raise click.UsageError('--from-scratch cannot be combined with --preset.') - - j_queue = Queue(cloudos_url, apikey, None, workspace_id, verify=verify_ssl) - - if skip_confirmation: - params = { - 'label': label, - 'provisioning_type': provisioning_type, - 'allocation_strategy': allocation_strategy, - 'max_vcpus': max_vcpus, - 'min_vcpus': min_vcpus, - 'instance_types': _parse_instance_types(instance_types), - 'volume_type': volume_type, - 'size': size, - 'iops': iops, - 'throughput': throughput, - } - if params['label'] is None: - raise click.UsageError('Missing option --label for --from-scratch -y.') - _validate_from_scratch_flags(params) - # Creating a queue creates a compute environment; enforce the limit. - _check_workspace_ce_limit(console, j_queue) - else: - # Creating a queue creates a compute environment; enforce the limit. - _check_workspace_ce_limit(console, j_queue) - params = _from_scratch_wizard(console) - - print('Executing queue create...') - - try: - queue_id = j_queue.create_job_queue_from_scratch( - label=params['label'], - description=description, - provisioning_type=params['provisioning_type'], - allocation_strategy=params['allocation_strategy'], - max_vcpus=params['max_vcpus'], - min_vcpus=params['min_vcpus'], - instance_types=params['instance_types'], - volume_type=params['volume_type'], - size=params['size'], - iops=params['iops'], - throughput=params['throughput'], - executor=executor, - is_default=set_default, - ) - console.print( - f'\t[green]Queue "{params["label"]}" created successfully.[/green]' - ) - print(f'\tQueue ID : {queue_id}') - print(f'\tView at : {cloudos_url}/app/job-queues/{queue_id}') - except Exception as e: - console.print(f'\t[red]Error creating queue:[/red] {str(e)}') - sys.exit(1) diff --git a/cloudos_cli/queue/queue.py b/cloudos_cli/queue/queue.py index 387dba26..37a3a7a8 100644 --- a/cloudos_cli/queue/queue.py +++ b/cloudos_cli/queue/queue.py @@ -48,60 +48,6 @@ "r5.12xlarge", "r5.16xlarge", "r5.24xlarge", "r5.metal", ] -# --------------------------------------------------------------------------- -# Additional instance types accepted for custom (from-scratch) queues. -# -# The preset lists above intentionally mirror the CloudOS Platform UI presets, -# so they are left untouched. Custom queues may use any instance type AWS Batch -# supports, so the validation set (``ALL_INSTANCE_TYPES``) is widened with the -# current-generation Intel families below: compute-optimised (c6i/c7i), -# general-purpose (m6i/m7i) and memory-optimised (r6i/r7i) for standard -# workloads, plus g5/g6/p4d/p5 for GPU workloads. Sizes follow the AWS EC2 -# instance type catalogue. -# https://aws.amazon.com/ec2/instance-types/ -# https://aws.amazon.com/ec2/instance-types/accelerated-computing/ -# --------------------------------------------------------------------------- - -_STANDARD_INSTANCE_TYPES_EXTRA = [ - # Compute optimised - "c6i.large", "c6i.xlarge", "c6i.2xlarge", "c6i.4xlarge", "c6i.8xlarge", - "c6i.12xlarge", "c6i.16xlarge", "c6i.24xlarge", "c6i.32xlarge", "c6i.metal", - "c7i.large", "c7i.xlarge", "c7i.2xlarge", "c7i.4xlarge", "c7i.8xlarge", - "c7i.12xlarge", "c7i.16xlarge", "c7i.24xlarge", "c7i.48xlarge", - "c7i.metal-24xl", "c7i.metal-48xl", - # General purpose - "m6i.large", "m6i.xlarge", "m6i.2xlarge", "m6i.4xlarge", "m6i.8xlarge", - "m6i.12xlarge", "m6i.16xlarge", "m6i.24xlarge", "m6i.32xlarge", "m6i.metal", - "m7i.large", "m7i.xlarge", "m7i.2xlarge", "m7i.4xlarge", "m7i.8xlarge", - "m7i.12xlarge", "m7i.16xlarge", "m7i.24xlarge", "m7i.48xlarge", - "m7i.metal-24xl", "m7i.metal-48xl", - # Memory optimised - "r6i.large", "r6i.xlarge", "r6i.2xlarge", "r6i.4xlarge", "r6i.8xlarge", - "r6i.12xlarge", "r6i.16xlarge", "r6i.24xlarge", "r6i.32xlarge", "r6i.metal", - "r7i.large", "r7i.xlarge", "r7i.2xlarge", "r7i.4xlarge", "r7i.8xlarge", - "r7i.12xlarge", "r7i.16xlarge", "r7i.24xlarge", "r7i.48xlarge", - "r7i.metal-24xl", "r7i.metal-48xl", -] - -_GPU_INSTANCE_TYPES_EXTRA = [ - "g5.xlarge", "g5.2xlarge", "g5.4xlarge", "g5.8xlarge", - "g5.12xlarge", "g5.16xlarge", "g5.24xlarge", "g5.48xlarge", - "g6.xlarge", "g6.2xlarge", "g6.4xlarge", "g6.8xlarge", - "g6.12xlarge", "g6.16xlarge", "g6.24xlarge", "g6.48xlarge", - "p4d.24xlarge", - "p5.48xlarge", -] - -# Union of every selectable instance type (preset standard + GPU families plus -# the current-generation families above), used to validate user-supplied -# instance types for custom (from-scratch) queues. -ALL_INSTANCE_TYPES = sorted( - set(_STANDARD_INSTANCE_TYPES) - | set(_GPU_INSTANCE_TYPES) - | set(_STANDARD_INSTANCE_TYPES_EXTRA) - | set(_GPU_INSTANCE_TYPES_EXTRA) -) - QUEUE_PRESETS = { "standard-stable": { "computeEnvironmentName": "OnDemandStandard", @@ -181,42 +127,6 @@ } -# --------------------------------------------------------------------------- -# Custom ("from scratch") queue creation options -# --------------------------------------------------------------------------- - -# Provisioning type -> AWS Batch compute environment resource type. -PROVISIONING_TYPES = { - "on-demand": "EC2", - "spot": "SPOT", -} - -# Allocation strategies allowed for each provisioning type. -ALLOCATION_STRATEGIES = { - "on-demand": ["BEST_FIT", "BEST_FIT_PROGRESSIVE"], - "spot": ["BEST_FIT", "BEST_FIT_PROGRESSIVE", "SPOT_CAPACITY_OPTIMIZED"], -} - -# vCPU bounds. -MAX_VCPUS_LIMIT = 20000 -DEFAULT_MAX_VCPUS = 512 -DEFAULT_MIN_VCPUS = 0 - -# Volume types and their size/IOPS/throughput specifications. Each spec stores -# (default, minimum, maximum). ``throughput`` is ``None`` when not applicable. -VOLUME_SPECS = { - "gp3": { - "size": (1000, 50, 16384), - "iops": (3000, 3000, 16000), - "throughput": (125, 125, 1000), - }, - "io2": { - "size": (1000, 50, 16384), - "iops": (3000, 100, 64000), - "throughput": None, - }, -} - # Maximum number of compute environments a workspace can hold across all queues. MAX_WORKSPACE_COMPUTE_ENVS = 10 @@ -494,218 +404,6 @@ def _post_job_queue(self, payload): ) return queue_id - def create_job_queue_from_scratch(self, - label, - description, - provisioning_type, - allocation_strategy, - max_vcpus, - min_vcpus, - instance_types, - volume_type, - size, - iops, - throughput=None, - executor="nextflow", - is_default=False): - """Create a custom job queue without using a preset template. - - Parameters - ---------- - label : str - Human-readable name for the queue. Also used as the compute - environment name. - description : str - Short description of the queue's purpose. - provisioning_type : str - One of ``'on-demand'`` or ``'spot'``. - allocation_strategy : str - AWS Batch allocation strategy. Must be valid for the chosen - ``provisioning_type`` (see ``ALLOCATION_STRATEGIES``). - max_vcpus : int - Maximum number of vCPUs for the compute environment. - min_vcpus : int - Minimum number of vCPUs for the compute environment. - instance_types : list[str] - Instance types to allow (e.g. ``['optimal']`` or a list from - ``_STANDARD_INSTANCE_TYPES``). - volume_type : str - One of ``'gp3'`` or ``'io2'``. - size : int - Volume size in GiB. - iops : int - Provisioned IOPS for the volume. - throughput : int or None, optional - Volume throughput in MB/s. Only applicable to ``gp3`` volumes. - executor : str, optional - Workflow executor. Defaults to ``'nextflow'``. - is_default : bool, optional - Whether to set the queue as the workspace default. Defaults to - ``False``. - - Returns - ------- - queue_id : str - The Lifebit Platform ID assigned to the newly created queue. - - Raises - ------ - ValueError - If the provisioning type, allocation strategy or volume type are - not recognised, or the allocation strategy is incompatible with - the provisioning type. - BadRequestException - If the API returns a 4xx or 5xx response. - """ - compute_resources = self._build_compute_resources( - provisioning_type=provisioning_type, - allocation_strategy=allocation_strategy, - max_vcpus=max_vcpus, - min_vcpus=min_vcpus, - instance_types=instance_types, - volume_type=volume_type, - size=size, - iops=iops, - throughput=throughput, - ) - payload = { - "id": "", - "label": label, - "description": description, - "executor": executor, - "status": "ToCreate", - "environment": { - "computeEnvironmentName": label, - "computeResources": compute_resources, - }, - "templateName": "", - "templateDescription": "", - "isDefault": is_default, - } - return self._post_job_queue(payload) - - @staticmethod - def _build_compute_resources(provisioning_type, - allocation_strategy, - max_vcpus, - min_vcpus, - instance_types, - volume_type, - size, - iops, - throughput=None): - """Validate inputs and build an AWS Batch ``computeResources`` dict. - - Parameters - ---------- - provisioning_type : str - One of ``'on-demand'`` or ``'spot'``. - allocation_strategy : str - AWS Batch allocation strategy. Must be valid for the chosen - ``provisioning_type`` (see ``ALLOCATION_STRATEGIES``). - max_vcpus : int - Maximum number of vCPUs. - min_vcpus : int - Minimum number of vCPUs. - instance_types : list[str] - Instance types to allow. - volume_type : str - One of ``'gp3'`` or ``'io2'``. - size : int - Volume size in GiB. - iops : int - Provisioned IOPS for the volume. - throughput : int or None, optional - Volume throughput in MB/s. Only applicable to ``gp3`` volumes. - - Returns - ------- - compute_resources : dict - The assembled ``computeResources`` dict. - - Raises - ------ - ValueError - If the provisioning type, allocation strategy or volume type are - not recognised, or the allocation strategy is incompatible with - the provisioning type. - """ - if provisioning_type not in PROVISIONING_TYPES: - valid = ', '.join(PROVISIONING_TYPES.keys()) - raise ValueError( - f"Unknown provisioning type '{provisioning_type}'. " - f"Valid options are: {valid}" - ) - allowed_strategies = ALLOCATION_STRATEGIES[provisioning_type] - if allocation_strategy not in allowed_strategies: - valid = ', '.join(allowed_strategies) - raise ValueError( - f"Allocation strategy '{allocation_strategy}' is not valid for " - f"'{provisioning_type}' provisioning. Valid options are: {valid}" - ) - if volume_type not in VOLUME_SPECS: - valid = ', '.join(VOLUME_SPECS.keys()) - raise ValueError( - f"Unknown volume type '{volume_type}'. Valid options are: {valid}" - ) - if max_vcpus < 0 or max_vcpus > MAX_VCPUS_LIMIT: - raise ValueError( - f"max_vcpus ({max_vcpus}) must be between 0 and {MAX_VCPUS_LIMIT}." - ) - if min_vcpus < 0 or min_vcpus > MAX_VCPUS_LIMIT: - raise ValueError( - f"min_vcpus ({min_vcpus}) must be between 0 and {MAX_VCPUS_LIMIT}." - ) - if min_vcpus > max_vcpus: - raise ValueError( - f"min_vcpus ({min_vcpus}) cannot be greater than max_vcpus ({max_vcpus})." - ) - if not instance_types: - raise ValueError("At least one instance type is required.") - invalid = [t for t in instance_types if t not in ALL_INSTANCE_TYPES] - if invalid: - raise ValueError( - f"Invalid instance type(s): {', '.join(invalid)}. " - "Allowed values are 'optimal' or standard/GPU instance types." - ) - spec = VOLUME_SPECS[volume_type] - for field, value in (("size", size), ("iops", iops)): - _, minimum, maximum = spec[field] - if value < minimum or value > maximum: - raise ValueError( - f"{field} ({value}) must be between {minimum} and {maximum} for volume type '{volume_type}'." - ) - if spec["throughput"] is not None and throughput is not None: - _, minimum, maximum = spec["throughput"] - if throughput < minimum or throughput > maximum: - raise ValueError( - f"throughput ({throughput}) must be between {minimum} and {maximum} for volume type '{volume_type}'." - ) - - resource_type = PROVISIONING_TYPES[provisioning_type] - volume = { - "type": volume_type, - "size": {"usageQuantity": size, "usageUnit": "Gb"}, - "iops": iops, - "deviceName": "/dev/xvda", - "deleteOnTermination": False, - "encrypted": False, - } - if volume_type == "gp3" and throughput is not None: - volume["throughput"] = throughput - - compute_resources = { - "allocationStrategy": allocation_strategy, - "instanceTypes": instance_types, - "maxvCpus": max_vcpus, - "type": resource_type, - "minvCpus": min_vcpus, - "volume": volume, - } - if provisioning_type == "spot": - compute_resources["bidPercentage"] = 100 - return compute_resources - def count_workspace_compute_environments(self, queues=None): """Count the total compute environments across all queues in the workspace. diff --git a/tests/test_queue/test_create_queue.py b/tests/test_queue/test_create_queue.py index 97018b12..08ed1690 100644 --- a/tests/test_queue/test_create_queue.py +++ b/tests/test_queue/test_create_queue.py @@ -413,273 +413,6 @@ def test_create_queue_missing_description_fails(self): assert 'Missing option --description' in _plain(result.output) -# =========================================================================== -# Unit tests – Queue.create_job_queue_from_scratch() (API mocked) -# =========================================================================== - -class TestCreateJobQueueFromScratch: - def _make_queue(self): - return Queue( - cloudos_url=CLOUDOS_URL, - apikey=APIKEY, - cromwell_token=None, - workspace_id=WORKSPACE_ID, - ) - - def _add_post(self): - responses.add( - responses.POST, - url=f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", - body=CREATE_RESPONSE_JSON_STR, - status=200, - content_type='application/json', - ) - - @responses.activate - def test_on_demand_gp3_payload(self): - self._add_post() - q = self._make_queue() - queue_id = q.create_job_queue_from_scratch( - label='Custom', - description='d', - provisioning_type='on-demand', - allocation_strategy='BEST_FIT_PROGRESSIVE', - max_vcpus=512, - min_vcpus=0, - instance_types=['optimal'], - volume_type='gp3', - size=1000, - iops=3000, - throughput=125, - ) - assert queue_id == CREATE_RESPONSE_JSON_DICT['_id'] - payload = json.loads(responses.calls[0].request.body) - cr = payload['environment']['computeResources'] - assert payload['environment']['computeEnvironmentName'] == 'Custom' - assert payload['templateName'] == '' - assert cr['type'] == 'EC2' - assert 'bidPercentage' not in cr - assert cr['allocationStrategy'] == 'BEST_FIT_PROGRESSIVE' - assert cr['maxvCpus'] == 512 - assert cr['minvCpus'] == 0 - assert cr['instanceTypes'] == ['optimal'] - assert cr['volume']['type'] == 'gp3' - assert cr['volume']['size'] == {'usageQuantity': 1000, 'usageUnit': 'Gb'} - assert cr['volume']['iops'] == 3000 - assert cr['volume']['throughput'] == 125 - - @responses.activate - def test_spot_io2_payload_has_bid_and_no_throughput(self): - self._add_post() - q = self._make_queue() - q.create_job_queue_from_scratch( - label='Custom', - description='d', - provisioning_type='spot', - allocation_strategy='SPOT_CAPACITY_OPTIMIZED', - max_vcpus=256, - min_vcpus=0, - instance_types=['c5.xlarge', 'm5.xlarge'], - volume_type='io2', - size=200, - iops=5000, - throughput=125, - ) - payload = json.loads(responses.calls[0].request.body) - cr = payload['environment']['computeResources'] - assert cr['type'] == 'SPOT' - assert cr['bidPercentage'] == 100 - assert 'throughput' not in cr['volume'] - assert cr['volume']['type'] == 'io2' - - def test_invalid_provisioning_type_raises(self): - q = self._make_queue() - with pytest.raises(ValueError, match='Unknown provisioning type'): - q.create_job_queue_from_scratch( - label='C', description='', provisioning_type='nope', - allocation_strategy='BEST_FIT', max_vcpus=512, min_vcpus=0, - instance_types=['optimal'], volume_type='gp3', size=1000, - iops=3000, throughput=125, - ) - - def test_incompatible_allocation_strategy_raises(self): - q = self._make_queue() - with pytest.raises(ValueError, match='not valid'): - q.create_job_queue_from_scratch( - label='C', description='', provisioning_type='on-demand', - allocation_strategy='SPOT_CAPACITY_OPTIMIZED', max_vcpus=512, - min_vcpus=0, instance_types=['optimal'], volume_type='gp3', - size=1000, iops=3000, throughput=125, - ) - - def test_invalid_volume_type_raises(self): - q = self._make_queue() - with pytest.raises(ValueError, match='Unknown volume type'): - q.create_job_queue_from_scratch( - label='C', description='', provisioning_type='on-demand', - allocation_strategy='BEST_FIT', max_vcpus=512, min_vcpus=0, - instance_types=['optimal'], volume_type='ssd', size=1000, - iops=3000, throughput=125, - ) - - -# =========================================================================== -# CLI integration tests – `cloudos queue create --from-scratch` -# =========================================================================== -# -# DISABLED: the --from-scratch CLI flow is hidden until the per-workspace -# instance-types API endpoint is opened (the CLI options were commented out in -# cloudos_cli/queue/cli.py). These integration tests are commented out together -# with that feature and should be restored when it is re-enabled. -# -# class TestCreateQueueFromScratchCLI: -# def test_from_scratch_options_in_help(self): -# runner = CliRunner() -# result = runner.invoke(run_cloudos_cli, ['queue', 'create', '--help']) -# assert result.exit_code == 0 -# for opt in ['--from-scratch', '--provisioning-type', '--allocation-strategy', -# '--max-vcpus', '--min-vcpus', '--instance-types', '--volume-type', -# '--size', '--iops', '--throughput']: -# assert opt in result.output -# -# def test_from_scratch_yes_success(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--label', 'Custom Queue', -# '--description', 'A custom queue', -# '--from-scratch', '--yes', -# ] -# with requests_mock_module.Mocker() as m: -# _mock_get_queues_with_total_ces(m, 1) -# m.post( -# f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", -# text=CREATE_RESPONSE_JSON_STR, -# status_code=200, -# ) -# result = runner.invoke(run_cloudos_cli, args) -# assert result.exit_code == 0 -# assert 'created successfully' in result.output -# -# def test_from_scratch_workspace_limit_reached_exits(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--label', 'Custom Queue', -# '--description', 'A custom queue', -# '--from-scratch', '--yes', -# ] -# with requests_mock_module.Mocker() as m: -# _mock_get_queues_with_total_ces(m, 10, n_queues=3) -# result = runner.invoke(run_cloudos_cli, args) -# assert result.exit_code == 0 -# assert 'reached the limit for compute environments in your workspace' \ -# in result.output -# -# def test_from_scratch_mutually_exclusive_with_preset(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--label', 'Custom Queue', -# '--description', 'A custom queue', -# '--from-scratch', '--preset', 'standard-gpu', '--yes', -# ] -# result = runner.invoke(run_cloudos_cli, args) -# assert result.exit_code != 0 -# assert 'cannot be combined with --preset' in _plain(result.output) -# -# def test_from_scratch_incompatible_strategy_rejected(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--label', 'Custom Queue', -# '--description', 'A custom queue', -# '--from-scratch', '--yes', -# '--provisioning-type', 'on-demand', -# '--allocation-strategy', 'SPOT_CAPACITY_OPTIMIZED', -# ] -# result = runner.invoke(run_cloudos_cli, args) -# assert result.exit_code != 0 -# -# def test_from_scratch_gp3_iops_out_of_range_rejected(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--label', 'Custom Queue', -# '--description', 'A custom queue', -# '--from-scratch', '--yes', -# '--volume-type', 'gp3', -# '--iops', '100', -# ] -# result = runner.invoke(run_cloudos_cli, args) -# assert result.exit_code != 0 -# -# def test_from_scratch_invalid_instance_type_rejected(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--label', 'Custom Queue', -# '--description', 'A custom queue', -# '--from-scratch', '--yes', -# '--instance-types', 'not-an-instance', -# ] -# result = runner.invoke(run_cloudos_cli, args) -# assert result.exit_code != 0 -# -# def test_from_scratch_interactive_wizard_success(self): -# runner = CliRunner() -# args = [ -# 'queue', 'create', -# '--apikey', APIKEY, -# '--cloudos-url', CLOUDOS_URL, -# '--workspace-id', WORKSPACE_ID, -# '--description', 'A wizard queue', -# '--from-scratch', -# ] -# # Wizard answers: name, provisioning, strategy, max, min, instances, -# # volume type, size, iops, throughput. -# wizard_input = '\n'.join([ -# 'My Wizard Queue', -# 'on-demand', -# 'BEST_FIT_PROGRESSIVE', -# '512', -# '0', -# 'optimal', -# 'gp3', -# '1000', -# '3000', -# '125', -# ]) + '\n' -# with requests_mock_module.Mocker() as m: -# _mock_get_queues_with_total_ces(m, 1) -# m.post( -# f"{CLOUDOS_URL}/api/v1/teams/aws/v2/job-queue?teamId={WORKSPACE_ID}", -# text=CREATE_RESPONSE_JSON_STR, -# status_code=200, -# ) -# result = runner.invoke(run_cloudos_cli, args, input=wizard_input) -# assert result.exit_code == 0 -# assert 'created successfully' in result.output - - # =========================================================================== # Unit tests – Queue.count_workspace_compute_environments() # ===========================================================================