diff --git a/gateway/api/management/commands/sync_ce_project.py b/gateway/api/management/commands/sync_ce_project.py index 4afcc64575..5a70ebcffa 100644 --- a/gateway/api/management/commands/sync_ce_project.py +++ b/gateway/api/management/commands/sync_ce_project.py @@ -22,7 +22,6 @@ "project_name", "region", "resource_group_id", - "subnet_pool_id", "pds_name_state", "pds_name_users", "pds_name_providers", @@ -34,6 +33,48 @@ ] +def _resolve_subnet_pool_defaults(project_id: str, data: dict) -> dict | None: + """Build the subnet-pool fields for the upsert defaults. + + Config supplies either ``subnet_pool_id`` directly, or ``subnet_pool_name`` + (with the id resolved and cached at submit time by the fleets runner). At least + one must be present. + + When only a name is given, the cached ``subnet_pool_id`` on an existing row must + survive re-sync — ``update_or_create`` overwrites every default on every boot, so + a blank id in the defaults would wipe the cache each boot and force a needless + re-resolution. The id is therefore only blanked when the configured name differs + from the stored one (a rename that must invalidate the cache). + + Args: + project_id: The CE project UUID. + data: The config entry. + + Returns: + A dict of subnet-pool fields to merge into the upsert defaults, or ``None`` + when neither id nor name is configured. + """ + subnet_pool_id = data.get("subnet_pool_id") + subnet_pool_name = data.get("subnet_pool_name") + + if not subnet_pool_id and not subnet_pool_name: + logger.error("project_id=%s missing both subnet_pool_id and subnet_pool_name", project_id) + return None + + defaults = {"subnet_pool_name": subnet_pool_name or None} + + if subnet_pool_id: + # Explicit id in config wins and is written through. + defaults["subnet_pool_id"] = subnet_pool_id + return defaults + + # Name-only config: keep any cached id unless the name changed (rename → invalidate). + existing = CodeEngineProject.objects.filter(project_id=project_id).values("subnet_pool_name").first() + if existing is not None and existing["subnet_pool_name"] != subnet_pool_name: + defaults["subnet_pool_id"] = None + return defaults + + def _upsert_project(project_id: str, data: dict) -> bool: """Create or update a single CodeEngineProject row. @@ -49,7 +90,12 @@ def _upsert_project(project_id: str, data: dict) -> bool: logger.error("project_id=%s missing required fields: %s", project_id, ", ".join(missing)) return False + subnet_defaults = _resolve_subnet_pool_defaults(project_id, data) + if subnet_defaults is None: + return False + defaults = {k: data[k] for k in _REQUIRED_KEYS} + defaults.update(subnet_defaults) defaults["active"] = True _, created = CodeEngineProject.objects.update_or_create( diff --git a/gateway/api/migrations/0062_codeengineproject_subnet_pool_name_and_more.py b/gateway/api/migrations/0062_codeengineproject_subnet_pool_name_and_more.py new file mode 100644 index 0000000000..39473e6917 --- /dev/null +++ b/gateway/api/migrations/0062_codeengineproject_subnet_pool_name_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.8 on 2026-09-02 15:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0061_job_filler"), + ] + + operations = [ + migrations.AddField( + model_name="codeengineproject", + name="subnet_pool_name", + field=models.CharField( + blank=True, + help_text="Subnet pool name for fleet networking. When set and subnet_pool_id is empty, the id is resolved from this name on first job submission and cached into subnet_pool_id.", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="codeengineproject", + name="subnet_pool_id", + field=models.CharField( + blank=True, + help_text="Subnet pool ID for fleet networking. May be supplied directly in config, or left empty and resolved from subnet_pool_name (then cached here).", + max_length=255, + null=True, + ), + ), + ] diff --git a/gateway/core/ibm_cloud/code_engine/ce_client/__init__.py b/gateway/core/ibm_cloud/code_engine/ce_client/__init__.py index ffd2796051..f3684e8ce8 100644 --- a/gateway/core/ibm_cloud/code_engine/ce_client/__init__.py +++ b/gateway/core/ibm_cloud/code_engine/ce_client/__init__.py @@ -28,6 +28,7 @@ from core.ibm_cloud.code_engine.ce_client.api.fleets_api import FleetsApi from core.ibm_cloud.code_engine.ce_client.api.secrets_and_configmaps_api import SecretsAndConfigmapsApi +from core.ibm_cloud.code_engine.ce_client.api.subnet_pools_api import SubnetPoolsApi from core.ibm_cloud.code_engine.ce_client.api_client import ApiClient from core.ibm_cloud.code_engine.ce_client.configuration import Configuration @@ -45,3 +46,5 @@ from core.ibm_cloud.code_engine.ce_client.models.v2_network_placement import V2NetworkPlacement from core.ibm_cloud.code_engine.ce_client.models.v2_secret import V2Secret from core.ibm_cloud.code_engine.ce_client.models.v2_secret_list import V2SecretList +from core.ibm_cloud.code_engine.ce_client.models.v2_subnet_pool import V2SubnetPool +from core.ibm_cloud.code_engine.ce_client.models.v2_subnet_pool_list import V2SubnetPoolList diff --git a/gateway/core/ibm_cloud/code_engine/ce_client/api/subnet_pools_api.py b/gateway/core/ibm_cloud/code_engine/ce_client/api/subnet_pools_api.py new file mode 100644 index 0000000000..523831975d --- /dev/null +++ b/gateway/core/ibm_cloud/code_engine/ce_client/api/subnet_pools_api.py @@ -0,0 +1,150 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2026. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# coding: utf-8 + +""" +Code Engine + +REST API for Code Engine # noqa: E501 + +OpenAPI spec version: 2.0.0 + +Generated by: https://github.com/swagger-api/swagger-codegen.git +""" + +from __future__ import absolute_import + +import re # noqa: F401 + +# python 2 and python 3 compatibility library +import six + +from core.ibm_cloud.code_engine.ce_client.api_client import ApiClient + + +class SubnetPoolsApi(object): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + Ref: https://github.com/swagger-api/swagger-codegen + + Only ``list_subnet_pools`` is vendored: the gateway resolves a configured + subnet-pool name to its id and never creates, gets by id, or deletes pools. + """ + + def __init__(self, api_client=None): + if api_client is None: + api_client = ApiClient() + self.api_client = api_client + + def list_subnet_pools(self, project_id, **kwargs): # noqa: E501 + """List subnet pools # noqa: E501 + + List all subnet pools in a project. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + >>> thread = api.list_subnet_pools(project_id, async_req=True) + >>> result = thread.get() + + :param async_req bool + :param str project_id: The ID of the project. (required) + :param int limit: Optional maximum number of subnet pools per page. + :param str start: An optional token that indicates the beginning of the page of results to be returned. If omitted, the first page of results is returned. This value is obtained from the 'start' query parameter in the `next` object of the operation response. + :return: V2SubnetPoolList + If the method is called asynchronously, + returns the request thread. + """ + kwargs["_return_http_data_only"] = True + if kwargs.get("async_req"): + return self.list_subnet_pools_with_http_info(project_id, **kwargs) # noqa: E501 + else: + data = self.list_subnet_pools_with_http_info(project_id, **kwargs) # noqa: E501 + return data + + def list_subnet_pools_with_http_info(self, project_id, **kwargs): # noqa: E501 + """List subnet pools # noqa: E501 + + List all subnet pools in a project. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + >>> thread = api.list_subnet_pools_with_http_info(project_id, async_req=True) + >>> result = thread.get() + + :param async_req bool + :param str project_id: The ID of the project. (required) + :param int limit: Optional maximum number of subnet pools per page. + :param str start: An optional token that indicates the beginning of the page of results to be returned. If omitted, the first page of results is returned. This value is obtained from the 'start' query parameter in the `next` object of the operation response. + :return: V2SubnetPoolList + If the method is called asynchronously, + returns the request thread. + """ + + all_params = ["project_id", "limit", "start"] # noqa: E501 + all_params.append("async_req") + all_params.append("_return_http_data_only") + all_params.append("_preload_content") + all_params.append("_request_timeout") + + params = locals() + for key, val in six.iteritems(params["kwargs"]): + if key not in all_params: + raise TypeError("Got an unexpected keyword argument '%s'" " to method list_subnet_pools" % key) + params[key] = val + del params["kwargs"] + # verify the required parameter 'project_id' is set + if "project_id" not in params or params["project_id"] is None: + raise ValueError( + "Missing the required parameter `project_id` when calling `list_subnet_pools`" + ) # noqa: E501 + + collection_formats = {} + + path_params = {} + if "project_id" in params: + path_params["project_id"] = params["project_id"] # noqa: E501 + + query_params = [] + if "limit" in params: + query_params.append(("limit", params["limit"])) # noqa: E501 + if "start" in params: + query_params.append(("start", params["start"])) # noqa: E501 + + header_params = {} + + form_params = [] + local_var_files = {} + + body_params = None + # HTTP header `Accept` + header_params["Accept"] = self.api_client.select_header_accept(["application/json"]) # noqa: E501 + + # Authentication setting + auth_settings = ["Bearer"] # noqa: E501 + + return self.api_client.call_api( + "/projects/{project_id}/subnet_pools", + "GET", + path_params, + query_params, + header_params, + body=body_params, + post_params=form_params, + files=local_var_files, + response_type="V2SubnetPoolList", # noqa: E501 + auth_settings=auth_settings, + async_req=params.get("async_req"), + _return_http_data_only=params.get("_return_http_data_only"), + _preload_content=params.get("_preload_content", True), + _request_timeout=params.get("_request_timeout"), + collection_formats=collection_formats, + ) diff --git a/gateway/core/ibm_cloud/code_engine/ce_client/models/__init__.py b/gateway/core/ibm_cloud/code_engine/ce_client/models/__init__.py index b20740f77d..736d1add6f 100644 --- a/gateway/core/ibm_cloud/code_engine/ce_client/models/__init__.py +++ b/gateway/core/ibm_cloud/code_engine/ce_client/models/__init__.py @@ -40,3 +40,5 @@ from core.ibm_cloud.code_engine.ce_client.models.v2_network_placement import V2NetworkPlacement from core.ibm_cloud.code_engine.ce_client.models.v2_secret import V2Secret from core.ibm_cloud.code_engine.ce_client.models.v2_secret_list import V2SecretList +from core.ibm_cloud.code_engine.ce_client.models.v2_subnet_pool import V2SubnetPool +from core.ibm_cloud.code_engine.ce_client.models.v2_subnet_pool_list import V2SubnetPoolList diff --git a/gateway/core/ibm_cloud/code_engine/ce_client/models/v2_subnet_pool.py b/gateway/core/ibm_cloud/code_engine/ce_client/models/v2_subnet_pool.py new file mode 100644 index 0000000000..f61f396f92 --- /dev/null +++ b/gateway/core/ibm_cloud/code_engine/ce_client/models/v2_subnet_pool.py @@ -0,0 +1,286 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2026. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# coding: utf-8 + +""" +Code Engine + +REST API for Code Engine # noqa: E501 + +OpenAPI spec version: 2.0.0 + +Generated by: https://github.com/swagger-api/swagger-codegen.git +""" + +import pprint +import re # noqa: F401 + +import six + + +class V2SubnetPool(object): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + + Altered from the original generated model: the ``pool`` field + (``list[V2SubnetPoolReference]``) is dropped because the gateway only reads a + pool's ``id``, ``name`` and ``region`` to resolve a configured pool name, and + keeping it would pull in the ``V2SubnetPoolReference`` model for no benefit. + Setters do not reject ``None`` so a response missing an unused field still + deserializes. + """ + + """ + Attributes: + swagger_types (dict): The key is attribute name + and the value is attribute type. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + """ + swagger_types = { + "created_at": "datetime", + "href": "str", + "id": "str", + "name": "str", + "project_id": "str", + "region": "str", + "resource_type": "str", + } + + attribute_map = { + "created_at": "created_at", + "href": "href", + "id": "id", + "name": "name", + "project_id": "project_id", + "region": "region", + "resource_type": "resource_type", + } + + def __init__( + self, + created_at=None, + href=None, + id=None, + name=None, + project_id=None, + region=None, + resource_type=None, + ): # noqa: E501 + """V2SubnetPool - a model defined in Swagger""" # noqa: E501 + self._created_at = None + self._href = None + self._id = None + self._name = None + self._project_id = None + self._region = None + self._resource_type = None + self.discriminator = None + self.created_at = created_at + self.href = href + self.id = id + self.name = name + self.project_id = project_id + self.region = region + self.resource_type = resource_type + + @property + def created_at(self): + """Gets the created_at of this V2SubnetPool. # noqa: E501 + + :return: The created_at of this V2SubnetPool. # noqa: E501 + :rtype: datetime + """ + return self._created_at + + @created_at.setter + def created_at(self, created_at): + """Sets the created_at of this V2SubnetPool. + + :param created_at: The created_at of this V2SubnetPool. # noqa: E501 + :type: datetime + """ + self._created_at = created_at + + @property + def href(self): + """Gets the href of this V2SubnetPool. # noqa: E501 + + :return: The href of this V2SubnetPool. # noqa: E501 + :rtype: str + """ + return self._href + + @href.setter + def href(self, href): + """Sets the href of this V2SubnetPool. + + :param href: The href of this V2SubnetPool. # noqa: E501 + :type: str + """ + self._href = href + + @property + def id(self): + """Gets the id of this V2SubnetPool. # noqa: E501 + + The identifier of the resource. # noqa: E501 + + :return: The id of this V2SubnetPool. # noqa: E501 + :rtype: str + """ + return self._id + + @id.setter + def id(self, id): + """Sets the id of this V2SubnetPool. + + The identifier of the resource. # noqa: E501 + + :param id: The id of this V2SubnetPool. # noqa: E501 + :type: str + """ + self._id = id + + @property + def name(self): + """Gets the name of this V2SubnetPool. # noqa: E501 + + The name of the subnet pool. # noqa: E501 + + :return: The name of this V2SubnetPool. # noqa: E501 + :rtype: str + """ + return self._name + + @name.setter + def name(self, name): + """Sets the name of this V2SubnetPool. + + The name of the subnet pool. # noqa: E501 + + :param name: The name of this V2SubnetPool. # noqa: E501 + :type: str + """ + self._name = name + + @property + def project_id(self): + """Gets the project_id of this V2SubnetPool. # noqa: E501 + + The ID of the project in which the resource is located. # noqa: E501 + + :return: The project_id of this V2SubnetPool. # noqa: E501 + :rtype: str + """ + return self._project_id + + @project_id.setter + def project_id(self, project_id): + """Sets the project_id of this V2SubnetPool. + + The ID of the project in which the resource is located. # noqa: E501 + + :param project_id: The project_id of this V2SubnetPool. # noqa: E501 + :type: str + """ + self._project_id = project_id + + @property + def region(self): + """Gets the region of this V2SubnetPool. # noqa: E501 + + The region of the project the resource is located in. # noqa: E501 + + :return: The region of this V2SubnetPool. # noqa: E501 + :rtype: str + """ + return self._region + + @region.setter + def region(self, region): + """Sets the region of this V2SubnetPool. + + The region of the project the resource is located in. # noqa: E501 + + :param region: The region of this V2SubnetPool. # noqa: E501 + :type: str + """ + self._region = region + + @property + def resource_type(self): + """Gets the resource_type of this V2SubnetPool. # noqa: E501 + + The type of the subnet pool. # noqa: E501 + + :return: The resource_type of this V2SubnetPool. # noqa: E501 + :rtype: str + """ + return self._resource_type + + @resource_type.setter + def resource_type(self, resource_type): + """Sets the resource_type of this V2SubnetPool. + + The type of the subnet pool. # noqa: E501 + + :param resource_type: The resource_type of this V2SubnetPool. # noqa: E501 + :type: str + """ + self._resource_type = resource_type + + def to_dict(self): + """Returns the model properties as a dict""" + result = {} + + for attr, _ in six.iteritems(self.swagger_types): + value = getattr(self, attr) + if isinstance(value, list): + result[attr] = list(map(lambda x: x.to_dict() if hasattr(x, "to_dict") else x, value)) + elif hasattr(value, "to_dict"): + result[attr] = value.to_dict() + elif isinstance(value, dict): + result[attr] = dict( + map( + lambda item: (item[0], item[1].to_dict()) if hasattr(item[1], "to_dict") else item, + value.items(), + ) + ) + else: + result[attr] = value + if issubclass(V2SubnetPool, dict): + for key, value in self.items(): + result[key] = value + + return result + + def to_str(self): + """Returns the string representation of the model""" + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + if not isinstance(other, V2SubnetPool): + return False + + return self.__dict__ == other.__dict__ + + def __ne__(self, other): + """Returns true if both objects are not equal""" + return not self == other diff --git a/gateway/core/ibm_cloud/code_engine/ce_client/models/v2_subnet_pool_list.py b/gateway/core/ibm_cloud/code_engine/ce_client/models/v2_subnet_pool_list.py new file mode 100644 index 0000000000..c4f27559c7 --- /dev/null +++ b/gateway/core/ibm_cloud/code_engine/ce_client/models/v2_subnet_pool_list.py @@ -0,0 +1,195 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2026. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# coding: utf-8 + +""" +Code Engine + +REST API for Code Engine # noqa: E501 + +OpenAPI spec version: 2.0.0 + +Generated by: https://github.com/swagger-api/swagger-codegen.git +""" + +import pprint +import re # noqa: F401 + +import six + + +class V2SubnetPoolList(object): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + """ + Attributes: + swagger_types (dict): The key is attribute name + and the value is attribute type. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + """ + swagger_types = { + "first": "PaginationListFirstMetadata", + "limit": "int", + "next": "PaginationListNextMetadata", + "subnet_pools": "list[V2SubnetPool]", + } + + attribute_map = {"first": "first", "limit": "limit", "next": "next", "subnet_pools": "subnet_pools"} + + def __init__(self, first=None, limit=50, next=None, subnet_pools=None): # noqa: E501 + """V2SubnetPoolList - a model defined in Swagger""" # noqa: E501 + self._first = None + self._limit = None + self._next = None + self._subnet_pools = None + self.discriminator = None + if first is not None: + self.first = first + self.limit = limit + if next is not None: + self.next = next + self.subnet_pools = subnet_pools + + @property + def first(self): + """Gets the first of this V2SubnetPoolList. # noqa: E501 + + :return: The first of this V2SubnetPoolList. # noqa: E501 + :rtype: PaginationListFirstMetadata + """ + return self._first + + @first.setter + def first(self, first): + """Sets the first of this V2SubnetPoolList. + + :param first: The first of this V2SubnetPoolList. # noqa: E501 + :type: PaginationListFirstMetadata + """ + self._first = first + + @property + def limit(self): + """Gets the limit of this V2SubnetPoolList. # noqa: E501 + + Maximum number of resources per page. # noqa: E501 + + :return: The limit of this V2SubnetPoolList. # noqa: E501 + :rtype: int + """ + return self._limit + + @limit.setter + def limit(self, limit): + """Sets the limit of this V2SubnetPoolList. + + Maximum number of resources per page. # noqa: E501 + + :param limit: The limit of this V2SubnetPoolList. # noqa: E501 + :type: int + """ + if limit is None: + raise ValueError("Invalid value for `limit`, must not be `None`") # noqa: E501 + + self._limit = limit + + @property + def next(self): + """Gets the next of this V2SubnetPoolList. # noqa: E501 + + :return: The next of this V2SubnetPoolList. # noqa: E501 + :rtype: PaginationListNextMetadata + """ + return self._next + + @next.setter + def next(self, next): + """Sets the next of this V2SubnetPoolList. + + :param next: The next of this V2SubnetPoolList. # noqa: E501 + :type: PaginationListNextMetadata + """ + self._next = next + + @property + def subnet_pools(self): + """Gets the subnet_pools of this V2SubnetPoolList. # noqa: E501 + + List of subnet pools. # noqa: E501 + + :return: The subnet_pools of this V2SubnetPoolList. # noqa: E501 + :rtype: list[V2SubnetPool] + """ + return self._subnet_pools + + @subnet_pools.setter + def subnet_pools(self, subnet_pools): + """Sets the subnet_pools of this V2SubnetPoolList. + + List of subnet pools. # noqa: E501 + + :param subnet_pools: The subnet_pools of this V2SubnetPoolList. # noqa: E501 + :type: list[V2SubnetPool] + """ + if subnet_pools is None: + raise ValueError("Invalid value for `subnet_pools`, must not be `None`") # noqa: E501 + + self._subnet_pools = subnet_pools + + def to_dict(self): + """Returns the model properties as a dict""" + result = {} + + for attr, _ in six.iteritems(self.swagger_types): + value = getattr(self, attr) + if isinstance(value, list): + result[attr] = list(map(lambda x: x.to_dict() if hasattr(x, "to_dict") else x, value)) + elif hasattr(value, "to_dict"): + result[attr] = value.to_dict() + elif isinstance(value, dict): + result[attr] = dict( + map( + lambda item: (item[0], item[1].to_dict()) if hasattr(item[1], "to_dict") else item, + value.items(), + ) + ) + else: + result[attr] = value + if issubclass(V2SubnetPoolList, dict): + for key, value in self.items(): + result[key] = value + + return result + + def to_str(self): + """Returns the string representation of the model""" + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + if not isinstance(other, V2SubnetPoolList): + return False + + return self.__dict__ == other.__dict__ + + def __ne__(self, other): + """Returns true if both objects are not equal""" + return not self == other diff --git a/gateway/core/ibm_cloud/code_engine/fleets/handler.py b/gateway/core/ibm_cloud/code_engine/fleets/handler.py index 5885986e41..9998fa247a 100644 --- a/gateway/core/ibm_cloud/code_engine/fleets/handler.py +++ b/gateway/core/ibm_cloud/code_engine/fleets/handler.py @@ -45,6 +45,7 @@ from core.ibm_cloud.code_engine.ce_client import ApiClient from core.ibm_cloud.code_engine.ce_client.api.fleets_api import FleetsApi +from core.ibm_cloud.code_engine.ce_client.api.subnet_pools_api import SubnetPoolsApi from core.ibm_cloud.code_engine.ce_client.rest import ApiException logger = logging.getLogger("FleetHandler") @@ -71,6 +72,7 @@ def __init__( self.project_id = project_id self._client = ce_api_client self._fleets_api = FleetsApi(ce_api_client) + self._subnet_pools_api = SubnetPoolsApi(ce_api_client) def submit_job( # pylint: disable=too-many-arguments self, @@ -140,6 +142,60 @@ def submit_job( # pylint: disable=too-many-arguments ) raise + def resolve_subnet_pool_id(self, name: str) -> str: + """Resolve a subnet pool name to its id within this project. + + Lists every subnet pool in the project (following pagination) and matches + by exact name. The CE API identifies pools by id and only *advises* that + names be unique within a project, so a name can match more than one pool; + this fails loud rather than guess, because picking the wrong pool would + place the fleet on the wrong VPC subnet. + + Args: + name: The subnet pool name to resolve. + + Returns: + The id of the single pool whose name matches. + + Raises: + ValueError: When no pool or more than one pool carries the name. + ApiException: When the CE list call fails. + """ + pools = self._list_all_subnet_pools() + matches = [p for p in pools if p.name == name] + + if not matches: + available = sorted(p.name for p in pools) + raise ValueError(f"No subnet pool named '{name}' in project [{self.project_id}]. Available: {available}") + if len(matches) > 1: + details = ", ".join(f"id={p.id} region={p.region}" for p in matches) + raise ValueError( + f"Subnet pool name '{name}' is ambiguous in project [{self.project_id}]: " + f"matches {len(matches)} pools ({details}). Set subnet_pool_id explicitly in the " + f"project config to disambiguate." + ) + return matches[0].id + + def _list_all_subnet_pools(self) -> list[Any]: + """Return every subnet pool in the project, following pagination. + + Returns: + A list of ``V2SubnetPool`` objects. + + Raises: + ApiException: When a CE list call fails. + """ + pools: list[Any] = [] + start: str | None = None + while True: + kwargs: dict[str, Any] = {"start": start} if start else {} + page = self._subnet_pools_api.list_subnet_pools(project_id=self.project_id, **kwargs) + pools.extend(page.subnet_pools or []) + nxt = getattr(page, "next", None) + start = getattr(nxt, "start", None) if nxt else None + if not start: + return pools + def get_job_status(self, identifier: str) -> dict[str, Any]: """ Get the current status of a fleet ("job") by fleet UUID or name. diff --git a/gateway/core/models.py b/gateway/core/models.py index 9dfc9926c9..75a6a7fdde 100644 --- a/gateway/core/models.py +++ b/gateway/core/models.py @@ -327,7 +327,24 @@ class CodeEngineProject(models.Model): resource_group_id = models.CharField(max_length=255, help_text="IBM Cloud resource group ID") # Networking - subnet_pool_id = models.CharField(max_length=255, help_text="Subnet pool ID for fleet networking") + subnet_pool_name = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "Subnet pool name for fleet networking. When set and subnet_pool_id is empty, the id " + "is resolved from this name on first job submission and cached into subnet_pool_id." + ), + ) + subnet_pool_id = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "Subnet pool ID for fleet networking. May be supplied directly in config, or left " + "empty and resolved from subnet_pool_name (then cached here)." + ), + ) zone = models.CharField( max_length=64, null=True, diff --git a/gateway/core/services/runners/fleets_runner.py b/gateway/core/services/runners/fleets_runner.py index 9dd4fa2b3a..01043d9334 100644 --- a/gateway/core/services/runners/fleets_runner.py +++ b/gateway/core/services/runners/fleets_runner.py @@ -231,12 +231,14 @@ def submit(self) -> None: else: raise RunnerError(f"COS is not configured for job_id=[{self.job.id}] — cannot submit Fleets job") + subnet_pool_id = self._ensure_subnet_pool_id(handler) + fleet = _retry_on_rate_limit( lambda: handler.submit_job( name=fleet_name, image_reference=self._get_image(), image_secret=settings.CE_ICR_PULL_SECRET, - network_placements=[{"type": "subnet_pool", "reference": self._project.subnet_pool_id}], + network_placements=[{"type": "subnet_pool", "reference": subnet_pool_id}], scale_cpu_limit=cpu_limit, scale_memory_limit=memory_limit, scale_max_instances=self._get_max_instances(), @@ -269,6 +271,50 @@ def submit(self) -> None: logger.error("Failed to submit job_id=[%s]: %s", self.job.id, ex) raise RunnerError(f"Failed to submit job_id=[{self.job.id}] to Code Engine Fleets", ex) from ex + def _ensure_subnet_pool_id(self, handler: FleetHandler) -> str: + """Return the project's subnet pool id, resolving it from the name if needed. + + Config may supply ``subnet_pool_id`` directly, or supply ``subnet_pool_name`` + and leave the id empty. In the latter case the id is resolved from the name + via the CE API on the first submission for the project and cached back onto + the ``CodeEngineProject`` row, so later jobs reuse it without another CE call. + A pool renamed in config re-blanks the id in ``sync_ce_project``, which + triggers re-resolution here. + + Args: + handler: The initialised :class:`FleetHandler` for this project. + + Returns: + The subnet pool id to place the fleet on. + + Raises: + RunnerError: When neither id nor name is configured, or the name cannot + be resolved to exactly one pool. + """ + if self._project.subnet_pool_id: + return self._project.subnet_pool_id + + if not self._project.subnet_pool_name: + raise RunnerError( + f"CodeEngineProject '{self._project.project_name}' has neither subnet_pool_id nor " + f"subnet_pool_name configured" + ) + + try: + resolved = handler.resolve_subnet_pool_id(self._project.subnet_pool_name) + except ValueError as ex: + raise RunnerError(str(ex), ex) from ex + + self._project.subnet_pool_id = resolved + self._project.save(update_fields=["subnet_pool_id", "updated"]) + logger.info( + "Resolved subnet pool name [%s] to id [%s] for project [%s]", + self._project.subnet_pool_name, + resolved, + self._project.project_name, + ) + return resolved + # Task states Code Engine writes under ``{version}/queue/``, mapped to job # statuses, in PRIORITY ORDER: a terminal state wins over running or pending when # more than one state key is present. A tuple rather than a dict so the ordering diff --git a/gateway/tests/api/test_sync_ce_project.py b/gateway/tests/api/test_sync_ce_project.py index 863eaba8fc..c55ec7dc8e 100644 --- a/gateway/tests/api/test_sync_ce_project.py +++ b/gateway/tests/api/test_sync_ce_project.py @@ -53,3 +53,68 @@ def test_empty_projects_is_noop(self, settings): settings.CE_PROJECTS = [] call_command("sync_ce_project") assert CodeEngineProject.objects.count() == 0 + + def test_id_only_config_still_works(self, settings): + """A config with subnet_pool_id (no name) upserts the id as before.""" + settings.CE_PROJECTS = [_project(subnet_pool_id="subnet-1")] + call_command("sync_ce_project") + + project = CodeEngineProject.objects.get(project_id="ce-1") + assert project.subnet_pool_id == "subnet-1" + assert project.subnet_pool_name is None + + def test_name_only_config_is_accepted_and_leaves_id_empty(self, settings): + """A name-only config stores the name and leaves the id for the runner to fill.""" + entry = _project(subnet_pool_name="my-pool") + del entry["subnet_pool_id"] + settings.CE_PROJECTS = [entry] + call_command("sync_ce_project") + + project = CodeEngineProject.objects.get(project_id="ce-1") + assert project.subnet_pool_name == "my-pool" + assert not project.subnet_pool_id + + def test_entry_with_neither_id_nor_name_is_rejected(self, settings): + """An entry missing both subnet pool fields does not create a row.""" + entry = _project() + del entry["subnet_pool_id"] + settings.CE_PROJECTS = [entry] + call_command("sync_ce_project") + + assert CodeEngineProject.objects.count() == 0 + + def test_resync_preserves_cached_id_when_name_unchanged(self, settings): + """A cached id survives re-sync of a name-only config (not blanked every boot).""" + entry = _project(subnet_pool_name="my-pool") + del entry["subnet_pool_id"] + settings.CE_PROJECTS = [entry] + call_command("sync_ce_project") + + # simulate the runner caching a resolved id + project = CodeEngineProject.objects.get(project_id="ce-1") + project.subnet_pool_id = "cached-id" + project.save(update_fields=["subnet_pool_id"]) + + call_command("sync_ce_project") + project.refresh_from_db() + assert project.subnet_pool_id == "cached-id" + + def test_resync_invalidates_cached_id_when_name_changes(self, settings): + """Renaming the pool in config blanks the cached id so it re-resolves.""" + entry = _project(subnet_pool_name="old-pool") + del entry["subnet_pool_id"] + settings.CE_PROJECTS = [entry] + call_command("sync_ce_project") + + project = CodeEngineProject.objects.get(project_id="ce-1") + project.subnet_pool_id = "cached-id" + project.save(update_fields=["subnet_pool_id"]) + + renamed = _project(subnet_pool_name="new-pool") + del renamed["subnet_pool_id"] + settings.CE_PROJECTS = [renamed] + call_command("sync_ce_project") + + project.refresh_from_db() + assert project.subnet_pool_name == "new-pool" + assert not project.subnet_pool_id diff --git a/gateway/tests/core/services/ibm_cloud/code_engine/fleets/test_fleet_handler.py b/gateway/tests/core/services/ibm_cloud/code_engine/fleets/test_fleet_handler.py index 4ab0272ba5..2df97022ed 100644 --- a/gateway/tests/core/services/ibm_cloud/code_engine/fleets/test_fleet_handler.py +++ b/gateway/tests/core/services/ibm_cloud/code_engine/fleets/test_fleet_handler.py @@ -657,3 +657,81 @@ def test_wait_until_terminal_delegates_to_wait_until_state(project_id): timeout_seconds=10, poll_interval_seconds=1, ) + + +def _pool(name: str, pool_id: str, region: str = "us-east") -> MagicMock: + """Return a stub V2SubnetPool with the fields the resolver reads.""" + pool = MagicMock() + pool.name = name + pool.id = pool_id + pool.region = region + return pool + + +def _page(pools: list, start: str | None = None) -> MagicMock: + """Return a stub V2SubnetPoolList page, optionally pointing at a next page.""" + page = MagicMock() + page.subnet_pools = pools + page.next = MagicMock(start=start) if start else None + return page + + +class TestResolveSubnetPoolId: + """Tests for FleetHandler.resolve_subnet_pool_id.""" + + def _handler(self, project_id: str) -> tuple[FleetHandler, MagicMock]: + """Build a handler with a mocked SubnetPoolsApi.""" + with patch(f"{_HANDLER_MOD}.FleetsApi"), patch(f"{_HANDLER_MOD}.SubnetPoolsApi") as mock_api_cls: + mock_api = MagicMock() + mock_api_cls.return_value = mock_api + handler = FleetHandler(ce_api_client=MagicMock(), project_id=project_id) + return handler, mock_api + + def test_single_match_returns_its_id(self, project_id): + """A name matching exactly one pool resolves to that pool's id.""" + handler, mock_api = self._handler(project_id) + mock_api.list_subnet_pools.return_value = _page([_pool("alpha", "id-a"), _pool("beta", "id-b")]) + + assert handler.resolve_subnet_pool_id("beta") == "id-b" + + def test_no_match_raises_listing_available(self, project_id): + """A name matching no pool raises, and names the available pools.""" + handler, mock_api = self._handler(project_id) + mock_api.list_subnet_pools.return_value = _page([_pool("alpha", "id-a")]) + + with pytest.raises(ValueError) as exc: + handler.resolve_subnet_pool_id("missing") + assert "missing" in str(exc.value) + assert "alpha" in str(exc.value) + + def test_duplicate_names_raise_naming_both_ids(self, project_id): + """A name matching more than one pool fails loud, naming every match's id.""" + handler, mock_api = self._handler(project_id) + mock_api.list_subnet_pools.return_value = _page([_pool("dup", "id-1"), _pool("dup", "id-2")]) + + with pytest.raises(ValueError) as exc: + handler.resolve_subnet_pool_id("dup") + message = str(exc.value) + assert "id-1" in message + assert "id-2" in message + assert "ambiguous" in message + + def test_follows_pagination(self, project_id): + """The resolver walks every page via next.start before matching.""" + handler, mock_api = self._handler(project_id) + mock_api.list_subnet_pools.side_effect = [ + _page([_pool("a", "id-a")], start="tok2"), + _page([_pool("target", "id-t")]), + ] + + assert handler.resolve_subnet_pool_id("target") == "id-t" + assert mock_api.list_subnet_pools.call_count == 2 + assert mock_api.list_subnet_pools.call_args_list[1].kwargs.get("start") == "tok2" + + def test_empty_page_list_raises(self, project_id): + """A project with no subnet pools raises rather than returning None.""" + handler, mock_api = self._handler(project_id) + mock_api.list_subnet_pools.return_value = _page([]) + + with pytest.raises(ValueError): + handler.resolve_subnet_pool_id("anything") diff --git a/gateway/tests/core/services/runners/test_fleets_runner.py b/gateway/tests/core/services/runners/test_fleets_runner.py index 2e69cbd504..a7ff1c2a01 100644 --- a/gateway/tests/core/services/runners/test_fleets_runner.py +++ b/gateway/tests/core/services/runners/test_fleets_runner.py @@ -366,6 +366,64 @@ def test_submit_sets_fleet_id_with_cos(): mock_handler.submit_job.assert_called_once() +def test_submit_uses_configured_subnet_pool_id_without_resolving(): + """A project with subnet_pool_id set places the fleet on it and never lists pools.""" + runner, mock_handler = _make_submit_runner() + runner._project.subnet_pool_id = "subnet-1" + runner._project.subnet_pool_name = "any-name" + + with _patch_settings(): + runner.submit() + + placements = mock_handler.submit_job.call_args.kwargs["network_placements"] + assert placements == [{"type": "subnet_pool", "reference": "subnet-1"}] + mock_handler.resolve_subnet_pool_id.assert_not_called() + + +def test_submit_resolves_and_caches_subnet_pool_id_from_name(): + """With only a name set, submit() resolves the id, uses it, and caches it back.""" + runner, mock_handler = _make_submit_runner() + runner._project.subnet_pool_id = None + runner._project.subnet_pool_name = "my-pool" + mock_handler.resolve_subnet_pool_id.return_value = "resolved-id" + + with _patch_settings(): + runner.submit() + + mock_handler.resolve_subnet_pool_id.assert_called_once_with("my-pool") + placements = mock_handler.submit_job.call_args.kwargs["network_placements"] + assert placements == [{"type": "subnet_pool", "reference": "resolved-id"}] + # cached back onto the project row + assert runner._project.subnet_pool_id == "resolved-id" + runner._project.save.assert_called_once() + assert "subnet_pool_id" in runner._project.save.call_args.kwargs["update_fields"] + + +def test_submit_raises_when_neither_subnet_pool_id_nor_name_set(): + """A project missing both fields fails loud rather than submitting a bad placement.""" + runner, mock_handler = _make_submit_runner() + runner._project.subnet_pool_id = None + runner._project.subnet_pool_name = None + + with _patch_settings(), pytest.raises(RunnerError): + runner.submit() + + mock_handler.submit_job.assert_not_called() + + +def test_submit_wraps_ambiguous_subnet_pool_as_runner_error(): + """A ValueError from resolution (not found / ambiguous) surfaces as RunnerError.""" + runner, mock_handler = _make_submit_runner() + runner._project.subnet_pool_id = None + runner._project.subnet_pool_name = "dup" + mock_handler.resolve_subnet_pool_id.side_effect = ValueError("ambiguous: id-1, id-2") + + with _patch_settings(), pytest.raises(RunnerError, match="ambiguous"): + runner.submit() + + mock_handler.submit_job.assert_not_called() + + def test_submit_fleet_name_describes_the_job(): """A real job's fleet is named job---.""" runner, mock_handler = _make_submit_runner() diff --git a/gateway/tests/utils.py b/gateway/tests/utils.py index ef5773e244..2f665a25af 100644 --- a/gateway/tests/utils.py +++ b/gateway/tests/utils.py @@ -161,6 +161,7 @@ def get_or_create_ce_project( active: bool = True, resource_group_id: str = "rg-id", subnet_pool_id: str = "subnet-id", + subnet_pool_name: str = None, pds_name_state: str = "pds-state", pds_name_users: str = "pds-users", pds_name_providers: str = "pds-providers", @@ -199,6 +200,7 @@ def get_or_create_ce_project( "active": active, "resource_group_id": resource_group_id, "subnet_pool_id": subnet_pool_id, + "subnet_pool_name": subnet_pool_name, "pds_name_state": pds_name_state, "pds_name_users": pds_name_users, "pds_name_providers": pds_name_providers, diff --git a/releasenotes/notes/fleets-subnet-pool-name-73c596ed166536e8.yaml b/releasenotes/notes/fleets-subnet-pool-name-73c596ed166536e8.yaml new file mode 100644 index 0000000000..eabac69d0b --- /dev/null +++ b/releasenotes/notes/fleets-subnet-pool-name-73c596ed166536e8.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + A Code Engine project can now be configured with a subnet pool **name** + (``subnet_pool_name``) instead of its id. The IBM Cloud console shows a pool's + name readily but not its id, so requiring the id added friction to every project + onboarding. When only a name is configured, the gateway resolves it to the id + against the Code Engine API on the first job submission for that project and caches + the id back onto the project, so later jobs reuse it without another lookup. + Supplying ``subnet_pool_id`` directly still works and skips resolution. Because a + pool name is not guaranteed unique within a project, a name that matches more than + one pool fails the submission with an error listing the matching ids rather than + guessing which pool to use.