mtn-cloud 0.3.3__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/PKG-INFO +8 -2
  2. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/README.md +7 -1
  3. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-overview.md +1 -0
  4. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/client.md +3 -0
  5. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/index.md +1 -0
  6. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/networks.md +4 -3
  7. mtn_cloud-0.4.0/docs/api-reference/routers.md +65 -0
  8. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/networking.md +60 -1
  9. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/_version.py +1 -1
  10. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/client.py +10 -0
  11. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/config.py +1 -1
  12. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/http.py +5 -3
  13. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/__init__.py +5 -0
  14. mtn_cloud-0.4.0/src/mtn_cloud/models/router.py +63 -0
  15. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/__init__.py +2 -0
  16. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/networks.py +9 -1
  17. mtn_cloud-0.4.0/src/mtn_cloud/resources/routers.py +157 -0
  18. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_client.py +3 -0
  19. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_http.py +6 -1
  20. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_networks.py +47 -0
  21. mtn_cloud-0.4.0/tests/test_routers.py +139 -0
  22. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/.gitignore +0 -0
  23. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/LICENSE +0 -0
  24. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/advanced-cookbook.md +0 -0
  25. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/archive-buckets.md +0 -0
  26. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/backups.md +0 -0
  27. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/clouds.md +0 -0
  28. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/groups.md +0 -0
  29. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/instance-types.md +0 -0
  30. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/instances.md +0 -0
  31. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/plans.md +0 -0
  32. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/security-groups.md +0 -0
  33. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/storage-buckets.md +0 -0
  34. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/virtual-images.md +0 -0
  35. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/backups.md +0 -0
  36. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/index.md +0 -0
  37. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/instances.md +0 -0
  38. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/quickstart.md +0 -0
  39. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/recipes.md +0 -0
  40. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/security-groups.md +0 -0
  41. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/storage.md +0 -0
  42. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/stylesheets/extra.css +0 -0
  43. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/virtual-images.md +0 -0
  44. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/pyproject.toml +0 -0
  45. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/__init__.py +0 -0
  46. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/exceptions.py +0 -0
  47. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/archive.py +0 -0
  48. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/backup.py +0 -0
  49. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/base.py +0 -0
  50. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/cloud.py +0 -0
  51. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/group.py +0 -0
  52. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/instance.py +0 -0
  53. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/instance_type.py +0 -0
  54. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/network.py +0 -0
  55. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/plan.py +0 -0
  56. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/resource_pool.py +0 -0
  57. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/security_group.py +0 -0
  58. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/snapshot.py +0 -0
  59. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/storage_bucket.py +0 -0
  60. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/user.py +0 -0
  61. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/virtual_image.py +0 -0
  62. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/volume.py +0 -0
  63. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/py.typed +0 -0
  64. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/archive_buckets.py +0 -0
  65. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/backups.py +0 -0
  66. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/base.py +0 -0
  67. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/clouds.py +0 -0
  68. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/groups.py +0 -0
  69. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/instance_types.py +0 -0
  70. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/instances.py +0 -0
  71. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/plans.py +0 -0
  72. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/security_groups.py +0 -0
  73. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/storage_buckets.py +0 -0
  74. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/virtual_images.py +0 -0
  75. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/__init__.py +0 -0
  76. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/conftest.py +0 -0
  77. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_archive_buckets.py +0 -0
  78. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_backups.py +0 -0
  79. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_clouds.py +0 -0
  80. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_config.py +0 -0
  81. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_exceptions.py +0 -0
  82. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_groups.py +0 -0
  83. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_instance_types.py +0 -0
  84. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_instances.py +0 -0
  85. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_plans.py +0 -0
  86. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_security_groups.py +0 -0
  87. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_storage_buckets.py +0 -0
  88. {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_virtual_images.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mtn-cloud
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Community Python SDK for MTN Cloud - Deploy and manage cloud resources with ease
5
5
  Project-URL: Homepage, https://github.com/mahveotm/mtn-cloud-python
6
6
  Project-URL: Documentation, https://github.com/mahveotm/mtn-cloud-python#readme
@@ -230,6 +230,12 @@ subnets = cloud.networks.list_subnets(new_network.id)
230
230
  print(f"Subnets: {len(subnets)}")
231
231
  ```
232
232
 
233
+ Create routers with existing public networks and internal subnets using
234
+ `cloud.routers.create(...)`, then confirm provisioning with
235
+ `cloud.routers.wait_until_ready(router.id)`. See the
236
+ [Networking guide](docs/networking.md#create-an-mtn-router) and
237
+ [router example](examples/create_router.py) for resource discovery and cleanup.
238
+
233
239
  ### 5. Manage Security Groups
234
240
 
235
241
  ```python
@@ -359,7 +365,6 @@ Storage vs archive model:
359
365
  - `cloud.archive_buckets`: logical file container attached to a provider
360
366
  - file operations happen through archive APIs
361
367
 
362
-
363
368
  ## Error Handling
364
369
 
365
370
  ```python
@@ -462,6 +467,7 @@ MIT License. See [LICENSE](LICENSE) for details.
462
467
 
463
468
  - [MTN Cloud Console](https://console.cloud.mtn.ng)
464
469
  - [MTN Cloud Guide](https://cloud.mtn.ng/documentation)
470
+ - [MTN Cloud Pricing](https://cloud.mtn.ng/pricing/pricing-calculator)
465
471
  - [Morpheus API Documentation (supplementary)](https://apidocs.morpheusdata.com/)
466
472
  - [GitHub Repository](https://github.com/mahveotm/mtn-cloud-python)
467
473
  - [PyPI Package](https://pypi.org/project/mtn-cloud/)
@@ -188,6 +188,12 @@ subnets = cloud.networks.list_subnets(new_network.id)
188
188
  print(f"Subnets: {len(subnets)}")
189
189
  ```
190
190
 
191
+ Create routers with existing public networks and internal subnets using
192
+ `cloud.routers.create(...)`, then confirm provisioning with
193
+ `cloud.routers.wait_until_ready(router.id)`. See the
194
+ [Networking guide](docs/networking.md#create-an-mtn-router) and
195
+ [router example](examples/create_router.py) for resource discovery and cleanup.
196
+
191
197
  ### 5. Manage Security Groups
192
198
 
193
199
  ```python
@@ -317,7 +323,6 @@ Storage vs archive model:
317
323
  - `cloud.archive_buckets`: logical file container attached to a provider
318
324
  - file operations happen through archive APIs
319
325
 
320
-
321
326
  ## Error Handling
322
327
 
323
328
  ```python
@@ -420,6 +425,7 @@ MIT License. See [LICENSE](LICENSE) for details.
420
425
 
421
426
  - [MTN Cloud Console](https://console.cloud.mtn.ng)
422
427
  - [MTN Cloud Guide](https://cloud.mtn.ng/documentation)
428
+ - [MTN Cloud Pricing](https://cloud.mtn.ng/pricing/pricing-calculator)
423
429
  - [Morpheus API Documentation (supplementary)](https://apidocs.morpheusdata.com/)
424
430
  - [GitHub Repository](https://github.com/mahveotm/mtn-cloud-python)
425
431
  - [PyPI Package](https://pypi.org/project/mtn-cloud/)
@@ -14,6 +14,7 @@ from mtn_cloud import MTNCloud
14
14
  - `instances`
15
15
  - `instance_types`
16
16
  - `networks`
17
+ - `routers`
17
18
  - `clouds`
18
19
  - `groups`
19
20
  - `plans`
@@ -22,6 +22,9 @@ Tokens and passwords are stored as masked secret values. Use
22
22
  integration needs the underlying value; normal SDK usage never needs to reveal
23
23
  them.
24
24
 
25
+ `cloud.routers` exposes router discovery, MTN router creation/deletion, and explicit
26
+ readiness/deletion waits. See the [Routers API](./routers.md).
27
+
25
28
  The transport:
26
29
 
27
30
  - preserves the `mtn-cloud-python/<version>` user-agent identity required by the
@@ -8,6 +8,7 @@ This section documents the public SDK surface in endpoint-first format.
8
8
  - [Instances (`cloud.instances`)](./instances.md)
9
9
  - [Instance Types (`cloud.instance_types`)](./instance-types.md)
10
10
  - [Networks (`cloud.networks`)](./networks.md)
11
+ - [Routers (`cloud.routers`)](./routers.md)
11
12
  - [Clouds (`cloud.clouds`)](./clouds.md)
12
13
  - [Groups (`cloud.groups`)](./groups.md)
13
14
  - [Plans (`cloud.plans`)](./plans.md)
@@ -19,12 +19,13 @@
19
19
  ### `get_by_name(name: str, cloud_id: int | None = None) -> Network`
20
20
 
21
21
  - Endpoint sequence:
22
- - `GET /api/networks?name=<name>&max=1`
22
+ - `GET /api/networks?name=<name>&max=100`, paging to check for duplicate exact names
23
23
  - Adds `zoneId=<cloud_id>` when provided
24
24
  - Returns: `Network`
25
25
  - Raises:
26
26
  - common API exceptions
27
- - `NotFoundError` when no match
27
+ - `NotFoundError` when no exact match
28
+ - `ValueError` when multiple exact names match in the selected cloud; use a unique name or `get(id)`
28
29
 
29
30
  ### `list_by_cloud(cloud_id: int) -> list[Network]`
30
31
 
@@ -118,5 +119,5 @@
118
119
  - Returns: `list[NetworkPoolIp]`
119
120
  - Raises: common API exceptions
120
121
 
121
- > **Note:** Standalone floating-IP endpoints (`/api/networks/floating-ips`) are restricted (HTTP 403) on MTN Cloud tenant accounts and are not exposed by the SDK. Assign external/public connectivity at provisioning time via the instance's `os_external_network_id` instead.
122
+ > **Note:** Standalone floating-IP endpoints (`/api/networks/floating-ips`) are restricted (HTTP 403) on MTN Cloud tenant accounts and are not exposed by the SDK. Use an [MTN router](./routers.md) for a network gateway, or the instance's `os_external_network_id` for public connectivity during provisioning.
122
123
 
@@ -0,0 +1,65 @@
1
+ # Routers: `cloud.routers`
2
+
3
+ Access via `cloud.routers`. List and get inspect router records. Creation and
4
+ deletion support only MTN's `neutronRouter` type; the type is resolved automatically.
5
+
6
+ ### `list(max_results=None, offset=0, sort=None, direction=None, phrase=None, **filters) -> list[Router]`
7
+
8
+ - Endpoint: `GET /api/networks/routers`
9
+ - Parameters: shared list query arguments; see [API conventions](./index.md).
10
+ - Returns: router records visible to the account, including their type and status.
11
+ - Raises: common API exceptions.
12
+
13
+ ### `get(resource_id: int) -> Router`
14
+
15
+ - Endpoint: `GET /api/networks/routers/{id}`
16
+ - Parameters: numeric Morpheus router ID.
17
+ - Returns: `Router`, including `status`, `type`, `site`, `zone`, `interfaces`,
18
+ `external_network`, `external_ip`, and `external_id` when available.
19
+ - Raises: `NotFoundError` when absent; common API exceptions.
20
+
21
+ ### `create(name: str, *, group_id: int, cloud_id: int, resource_pool_id: int, internal_subnet_ids: list[int], external_network_id: int | None = None, enabled: bool = True) -> Router`
22
+
23
+ - Endpoint: `POST /api/networks/routers`, followed by a router read.
24
+ - Parameters:
25
+ - `name`: nonempty router name.
26
+ - `group_id`, `cloud_id`: group and its selected cloud.
27
+ - `resource_pool_id`: numeric project ID from `instances.get_resource_pool(...).id`.
28
+ - `internal_subnet_ids`: nonempty list of positive Morpheus subnet IDs from
29
+ `networks.list_subnets(...)`. Subnets must belong to the selected project.
30
+ - `external_network_id`: optional public network ID from `networks.get_by_name(...).id`.
31
+ - `enabled`: whether the router is enabled; defaults to `True`.
32
+ - Returns: initial `Router` record. Provisioning may still be in progress.
33
+ - Raises: Pydantic `ValidationError` for invalid inputs; `ValueError` when the MTN
34
+ router type is unavailable; common API exceptions. A failed read after successful
35
+ submission raises `MTNCloudError` with `response["router_id"]` for cleanup.
36
+
37
+ Call `wait_until_ready(router.id)` to confirm backend readiness.
38
+
39
+ ### `delete(router_id: int) -> bool`
40
+
41
+ - Endpoints: reads the router to check its type, then `DELETE /api/networks/routers/{id}`.
42
+ - Returns: `True` when deletion is submitted or the router is already absent.
43
+ - Raises: `ValueError` for a non-MTN router; common API exceptions.
44
+
45
+ Call `wait_until_deleted(router_id)` to confirm removal. Existing networks and
46
+ resource pools are not deleted.
47
+
48
+ ### `wait_until_ready(router_id: int, timeout: float = 300, poll_interval: float = 5) -> Router`
49
+
50
+ - Endpoint: polls `GET /api/networks/routers/{id}`.
51
+ - Parameters: router ID and positive timeout/poll interval in seconds.
52
+ - Returns: router with status `ok`, `available`, or `active`.
53
+ - Raises: `MTNCloudError` for `failed`/`error` status, SDK `TimeoutError` on timeout,
54
+ `ValueError` for invalid timing or a non-MTN router, and common API exceptions.
55
+ Provisioning failure and polling timeout errors include `response["router_id"]`.
56
+
57
+ ### `wait_until_deleted(router_id: int, timeout: float = 300, poll_interval: float = 5) -> bool`
58
+
59
+ - Endpoint: polls `GET /api/networks/routers/{id}` until it returns 404.
60
+ - Returns: `True` after removal.
61
+ - Raises: SDK `TimeoutError` with `response["router_id"]`, `ValueError` for invalid
62
+ timing, and common API exceptions. Authorization and server errors are not
63
+ treated as successful deletion.
64
+
65
+ See the [Networking guide](../networking.md#create-an-mtn-router) for a complete example.
@@ -5,7 +5,7 @@ Use this guide for creating and managing MTN Cloud networks via the SDK.
5
5
  ## Before You Start
6
6
 
7
7
  - You need an existing Resource Pool (Project). Create one from **Provisioning → Catalog → Create Project** if you haven't already.
8
- - MTN Cloud networking runs on OpenStack Neutron. Networks you create are private by default — VMs get private IPs automatically. Public internet access is configured at provisioning time via the instance's external network (`os_external_network_id`), not through a standalone floating-IP API.
8
+ - MTN Cloud networking runs on OpenStack Neutron. Networks you create are private by default — VMs get private IPs automatically. Public connectivity can be configured through an MTN router with a public network gateway, or through the instance's external network (`os_external_network_id`) at provisioning time. Router gateways and instance public IP assignment are separate operations.
9
9
  - Use `list_types(openstack_only=True)` to discover the valid network type IDs for your account.
10
10
 
11
11
  ## Typical Networking Flow
@@ -76,6 +76,65 @@ print(f"Subnets: {len(subnets)}")
76
76
  cloud.networks.delete(new_network.id)
77
77
  ```
78
78
 
79
+ ## Create an MTN Router
80
+
81
+ Use an existing resource pool, a public network, and an internal network in that
82
+ pool. Discover their IDs instead of copying them from the console. Resource pools
83
+ (projects) are distinct from the network IP pools described below.
84
+
85
+ ```python
86
+ from mtn_cloud import MTNCloud
87
+
88
+ with MTNCloud() as cloud: # Reads MTN_CLOUD_TOKEN
89
+ group = cloud.groups.get_by_name("MTNNG_CLOUD_AZ_1")
90
+ if len(group.cloud_ids) != 1:
91
+ raise ValueError("Select a cloud explicitly for this group.")
92
+ cloud_id = group.cloud_ids[0]
93
+
94
+ pool = cloud.instances.get_resource_pool(
95
+ "my-project", group_id=group.id, cloud_id=cloud_id
96
+ )
97
+ public = cloud.networks.get_by_name("public-network-01", cloud_id=cloud_id)
98
+ internal = cloud.networks.get_by_name("app-net", cloud_id=cloud_id)
99
+ internal = cloud.networks.get(internal.id)
100
+ if internal.zone_pool is None or internal.zone_pool.id != pool.id:
101
+ raise ValueError("The internal network must belong to the selected resource pool.")
102
+ subnets = cloud.networks.list_subnets(internal.id)
103
+
104
+ router = cloud.routers.create(
105
+ name="app-router",
106
+ group_id=group.id,
107
+ cloud_id=cloud_id,
108
+ resource_pool_id=pool.id,
109
+ external_network_id=public.id,
110
+ internal_subnet_ids=[subnet.id for subnet in subnets],
111
+ )
112
+ print("Submitted router:", router.id)
113
+ router = cloud.routers.wait_until_ready(router.id)
114
+ print(router.id, router.status, router.external_ip)
115
+
116
+ # When no longer needed:
117
+ # cloud.routers.delete(router.id)
118
+ # cloud.routers.wait_until_deleted(router.id)
119
+ ```
120
+
121
+ This attaches all subnets returned for the internal network. To attach only one,
122
+ select it explicitly by name or CIDR. The IDs must be Morpheus subnet IDs, not
123
+ network IDs or OpenStack UUIDs. At least one subnet is required.
124
+
125
+ Only MTN's `neutronRouter` type is supported for creation and deletion. The SDK
126
+ selects the type automatically. `create()` submits provisioning and reads the
127
+ initial router record; use `wait_until_ready()` to confirm that it succeeded.
128
+ Both wait methods default to a five-minute timeout and five-second polling.
129
+
130
+ A provisioning failure raises `MTNCloudError`; waiting too long raises the SDK's
131
+ `TimeoutError`. These errors include the router ID in `error.response["router_id"]`.
132
+ A failed initial read after creation also preserves the ID there. Inspect or delete
133
+ that router explicitly; the SDK does not automatically remove failed routers.
134
+ Deleting a router leaves its existing networks and resource pool intact.
135
+
136
+ See the [Routers API reference](./api-reference/routers.md) for method details.
137
+
79
138
  ## Network Pools
80
139
 
81
140
  Network pools are managed ranges of IP addresses used for static assignment. Inspect them and the individual addresses they track:
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.4.0"
@@ -13,6 +13,7 @@ from mtn_cloud.resources.instance_types import InstanceTypesResource
13
13
  from mtn_cloud.resources.instances import InstancesResource
14
14
  from mtn_cloud.resources.networks import NetworksResource
15
15
  from mtn_cloud.resources.plans import PlansResource
16
+ from mtn_cloud.resources.routers import RoutersResource
16
17
  from mtn_cloud.resources.security_groups import SecurityGroupsResource
17
18
  from mtn_cloud.resources.storage_buckets import StorageBucketsResource
18
19
  from mtn_cloud.resources.virtual_images import VirtualImagesResource
@@ -60,6 +61,7 @@ class MTNCloud:
60
61
  instances: Manage compute instances
61
62
  instance_types: Discover available instance types
62
63
  networks: Manage networks
64
+ routers: Manage MTN routers
63
65
  clouds: Manage clouds/zones
64
66
  groups: Manage groups (sites)
65
67
  plans: Manage service plans
@@ -123,6 +125,7 @@ class MTNCloud:
123
125
  self._instances: InstancesResource | None = None
124
126
  self._instance_types: InstanceTypesResource | None = None
125
127
  self._networks: NetworksResource | None = None
128
+ self._routers: RoutersResource | None = None
126
129
  self._clouds: CloudsResource | None = None
127
130
  self._groups: GroupsResource | None = None
128
131
  self._plans: PlansResource | None = None
@@ -190,6 +193,13 @@ class MTNCloud:
190
193
  self._networks = NetworksResource(self._http)
191
194
  return self._networks
192
195
 
196
+ @property
197
+ def routers(self) -> RoutersResource:
198
+ """Access MTN router discovery, creation, deletion, and readiness checks."""
199
+ if self._routers is None:
200
+ self._routers = RoutersResource(self._http)
201
+ return self._routers
202
+
193
203
  @property
194
204
  def groups(self) -> GroupsResource:
195
205
  """
@@ -1,4 +1,4 @@
1
- """Configuration models and defaults for the MTN Cloud SDK."""
1
+ """Configuration models and defaults for MTN Cloud SDK."""
2
2
 
3
3
  from pydantic import Field, SecretStr, field_validator
4
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -62,9 +62,11 @@ def _redact_sensitive(value: Any, *, known_secrets: Sequence[str] = ()) -> Any:
62
62
  return REDACTED
63
63
  if isinstance(value, Mapping):
64
64
  return {
65
- key: REDACTED
66
- if _is_sensitive_key(key)
67
- else _redact_sensitive(item, known_secrets=known_secrets)
65
+ key: (
66
+ REDACTED
67
+ if _is_sensitive_key(key)
68
+ else _redact_sensitive(item, known_secrets=known_secrets)
69
+ )
68
70
  for key, item in value.items()
69
71
  }
70
72
  if isinstance(value, list | tuple | set | frozenset):
@@ -40,6 +40,7 @@ from mtn_cloud.models.network import (
40
40
  )
41
41
  from mtn_cloud.models.plan import ServicePlan
42
42
  from mtn_cloud.models.resource_pool import ResourcePool
43
+ from mtn_cloud.models.router import Router, RouterCreate, RouterInterface
43
44
  from mtn_cloud.models.security_group import (
44
45
  SecurityGroup,
45
46
  SecurityGroupCreate,
@@ -74,6 +75,10 @@ __all__ = [
74
75
  # Instance Type
75
76
  "InstanceType",
76
77
  "InstanceTypeLayout",
78
+ # Router
79
+ "Router",
80
+ "RouterCreate",
81
+ "RouterInterface",
77
82
  # Network
78
83
  "Network",
79
84
  "NetworkType",
@@ -0,0 +1,63 @@
1
+ """Models for MTN Cloud routers."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from pydantic import Field
6
+
7
+ from mtn_cloud.models.base import BaseModel, Resource
8
+ from mtn_cloud.models.network import ResourceReference
9
+
10
+ PositiveID = Annotated[int, Field(gt=0, strict=True)]
11
+
12
+
13
+ class RouterInterface(BaseModel):
14
+ """An internal network attached to a router."""
15
+
16
+ cidr: str | None = None
17
+ network: ResourceReference | None = None
18
+
19
+
20
+ class Router(Resource):
21
+ """A router returned by MTN Cloud."""
22
+
23
+ status: str | None = None
24
+ enabled: bool = True
25
+ type: ResourceReference | None = None
26
+ site: ResourceReference | None = None
27
+ zone: ResourceReference | None = None
28
+ external_network: ResourceReference | None = Field(default=None, alias="externalNetwork")
29
+ external_ip: str | None = Field(default=None, alias="externalIp")
30
+ external_id: str | None = Field(default=None, alias="externalId")
31
+ interfaces: list[RouterInterface] = Field(default_factory=list)
32
+
33
+
34
+ class RouterCreate(BaseModel):
35
+ """Typed inputs for creating an MTN neutronRouter."""
36
+
37
+ name: str = Field(min_length=1)
38
+ group_id: PositiveID
39
+ cloud_id: PositiveID
40
+ resource_pool_id: PositiveID
41
+ internal_subnet_ids: list[PositiveID] = Field(min_length=1)
42
+ external_network_id: PositiveID | None = None
43
+ enabled: bool = True
44
+
45
+ def to_api_payload(self, type_id: int) -> dict[str, Any]:
46
+ """Build the MTN REST request using the resolved neutronRouter type ID."""
47
+ pool_code = f"pool-{self.resource_pool_id}"
48
+ router: dict[str, Any] = {
49
+ "name": self.name,
50
+ "site": {"id": self.group_id},
51
+ "zone": {"id": self.cloud_id},
52
+ "type": {"id": type_id},
53
+ "poolId": pool_code,
54
+ "config": {"poolId": pool_code},
55
+ "enabled": self.enabled,
56
+ "interfaces": [
57
+ {"networkSubnet": {"id": subnet_id}}
58
+ for subnet_id in dict.fromkeys(self.internal_subnet_ids)
59
+ ],
60
+ }
61
+ if self.external_network_id is not None:
62
+ router["externalNetwork"] = {"id": self.external_network_id}
63
+ return {"networkRouter": router}
@@ -9,6 +9,7 @@ from mtn_cloud.resources.instance_types import InstanceTypesResource
9
9
  from mtn_cloud.resources.instances import InstancesResource
10
10
  from mtn_cloud.resources.networks import NetworksResource
11
11
  from mtn_cloud.resources.plans import PlansResource
12
+ from mtn_cloud.resources.routers import RoutersResource
12
13
  from mtn_cloud.resources.security_groups import SecurityGroupsResource
13
14
  from mtn_cloud.resources.storage_buckets import StorageBucketsResource
14
15
  from mtn_cloud.resources.virtual_images import VirtualImagesResource
@@ -18,6 +19,7 @@ __all__ = [
18
19
  "InstancesResource",
19
20
  "InstanceTypesResource",
20
21
  "NetworksResource",
22
+ "RoutersResource",
21
23
  "GroupsResource",
22
24
  "CloudsResource",
23
25
  "PlansResource",
@@ -107,8 +107,16 @@ class NetworksResource(BaseResource[Network]):
107
107
 
108
108
  Raises:
109
109
  NotFoundError: If network not found
110
+ ValueError: If multiple networks have the exact name in the selected cloud
110
111
  """
111
- networks = self.list(name=name, cloud_id=cloud_id, max_results=1)
112
+ networks = []
113
+ for network in self.iter_all(name=name, cloud_id=cloud_id):
114
+ if network.name == name:
115
+ networks.append(network)
116
+ if len(networks) > 1:
117
+ raise ValueError(
118
+ f"Network name '{name}' is ambiguous; use a unique name or get(id)."
119
+ )
112
120
  if not networks:
113
121
  raise NotFoundError(
114
122
  resource_type="Network",
@@ -0,0 +1,157 @@
1
+ """Resource manager for MTN Cloud routers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from mtn_cloud.exceptions import MTNCloudError, NotFoundError, TimeoutError
9
+ from mtn_cloud.models.router import Router, RouterCreate
10
+ from mtn_cloud.resources.base import BaseResource
11
+
12
+
13
+ class RoutersResource(BaseResource[Router]):
14
+ """Inspect routers and create/delete MTN neutronRouter resources."""
15
+
16
+ _path = "/networks/routers"
17
+ _model = Router
18
+ _name = "router"
19
+ _list_key = "networkRouters"
20
+ _item_key = "networkRouter"
21
+
22
+ def _type_id(self) -> int:
23
+ matches: list[dict[str, Any]] = []
24
+ offset = 0
25
+ while True:
26
+ response = self._http.get(
27
+ "/network-router-types", params={"max": 100, "offset": offset}
28
+ )
29
+ page = response.get("networkRouterTypes", [])
30
+ matches.extend(item for item in page if item.get("code") == "neutronRouter")
31
+ if len(page) < 100:
32
+ break
33
+ offset += len(page)
34
+ if len(matches) != 1:
35
+ raise ValueError("Expected exactly one available MTN neutronRouter type.")
36
+ type_id = int(matches[0]["id"])
37
+ detail = self._http.get(f"/network-router-types/{type_id}")["networkRouterType"]
38
+ if (
39
+ detail.get("code") != "neutronRouter"
40
+ or not detail.get("creatable")
41
+ or detail.get("hasNetworkServer")
42
+ ):
43
+ raise ValueError("The MTN neutronRouter type is not available for creation.")
44
+ return type_id
45
+
46
+ def create(
47
+ self,
48
+ name: str,
49
+ *,
50
+ group_id: int,
51
+ cloud_id: int,
52
+ resource_pool_id: int,
53
+ internal_subnet_ids: list[int],
54
+ external_network_id: int | None = None,
55
+ enabled: bool = True,
56
+ ) -> Router:
57
+ """Submit an MTN router and fetch its initial state.
58
+
59
+ Use numeric Morpheus IDs, including the resource pool's numeric ID.
60
+ Internal subnets must belong to that pool. Call wait_until_ready() to
61
+ confirm provisioning; successful submission alone does not mean ready.
62
+ Errors after creation include the router ID in response["router_id"].
63
+ """
64
+ request = RouterCreate(
65
+ name=name,
66
+ group_id=group_id,
67
+ cloud_id=cloud_id,
68
+ resource_pool_id=resource_pool_id,
69
+ internal_subnet_ids=internal_subnet_ids,
70
+ external_network_id=external_network_id,
71
+ enabled=enabled,
72
+ )
73
+ response = self._http.post(self._path, json=request.to_api_payload(self._type_id()))
74
+ router_id = response.get("id")
75
+ if response.get("success") is not True or type(router_id) is not int or router_id <= 0:
76
+ raise MTNCloudError(
77
+ "Router creation did not return success and a valid ID.",
78
+ response=response,
79
+ )
80
+ try:
81
+ return self.get(router_id)
82
+ except Exception as exc:
83
+ raise MTNCloudError(
84
+ f"Router {router_id} was submitted but could not be read; inspect or delete it by ID.",
85
+ response={"router_id": router_id},
86
+ ) from exc
87
+
88
+ def delete(self, router_id: int) -> bool:
89
+ """Submit deletion of an MTN router; an already absent router is success.
90
+
91
+ Call wait_until_deleted() to confirm removal. Other router types cannot
92
+ be deleted through this manager.
93
+ """
94
+ try:
95
+ router = self.get(router_id)
96
+ except NotFoundError:
97
+ return True
98
+ self._require_mtn(router)
99
+ try:
100
+ return self._delete(router_id)
101
+ except NotFoundError:
102
+ return True
103
+
104
+ @staticmethod
105
+ def _require_mtn(router: Router) -> None:
106
+ if router.type is None or router.type.code != "neutronRouter":
107
+ raise ValueError(f"Router {router.id} is not an MTN neutronRouter.")
108
+
109
+ @staticmethod
110
+ def _deadline(timeout: float, poll_interval: float) -> float:
111
+ if timeout <= 0 or poll_interval <= 0:
112
+ raise ValueError("timeout and poll_interval must be positive.")
113
+ return time.monotonic() + timeout
114
+
115
+ def wait_until_ready(
116
+ self, router_id: int, timeout: float = 300, poll_interval: float = 5
117
+ ) -> Router:
118
+ """Wait for ok/available/active; raise on provisioning failure or timeout."""
119
+ deadline = self._deadline(timeout, poll_interval)
120
+ while True:
121
+ router = self.get(router_id)
122
+ self._require_mtn(router)
123
+ status = (router.status or "").lower()
124
+ if status in {"ok", "available", "active"}:
125
+ return router
126
+ if status in {"failed", "error"}:
127
+ raise MTNCloudError(
128
+ f"Router {router_id} provisioning failed: {status}.",
129
+ response={"router_id": router_id, "status": status},
130
+ )
131
+ remaining = deadline - time.monotonic()
132
+ if remaining <= 0:
133
+ raise TimeoutError(
134
+ f"Router {router_id} did not become ready (status: {status}).",
135
+ timeout=timeout,
136
+ response={"router_id": router_id, "status": status},
137
+ )
138
+ time.sleep(min(poll_interval, remaining))
139
+
140
+ def wait_until_deleted(
141
+ self, router_id: int, timeout: float = 300, poll_interval: float = 5
142
+ ) -> bool:
143
+ """Wait until the router endpoint returns 404; other API errors propagate."""
144
+ deadline = self._deadline(timeout, poll_interval)
145
+ while True:
146
+ try:
147
+ self.get(router_id)
148
+ except NotFoundError:
149
+ return True
150
+ remaining = deadline - time.monotonic()
151
+ if remaining <= 0:
152
+ raise TimeoutError(
153
+ f"Router {router_id} was not deleted.",
154
+ timeout=timeout,
155
+ response={"router_id": router_id},
156
+ )
157
+ time.sleep(min(poll_interval, remaining))
@@ -15,6 +15,7 @@ from mtn_cloud.resources.instance_types import InstanceTypesResource
15
15
  from mtn_cloud.resources.instances import InstancesResource
16
16
  from mtn_cloud.resources.networks import NetworksResource
17
17
  from mtn_cloud.resources.plans import PlansResource
18
+ from mtn_cloud.resources.routers import RoutersResource
18
19
  from mtn_cloud.resources.security_groups import SecurityGroupsResource
19
20
  from mtn_cloud.resources.storage_buckets import StorageBucketsResource
20
21
  from mtn_cloud.resources.virtual_images import VirtualImagesResource
@@ -124,6 +125,7 @@ class TestMTNCloudResources:
124
125
  ("instances", InstancesResource),
125
126
  ("instance_types", InstanceTypesResource),
126
127
  ("networks", NetworksResource),
128
+ ("routers", RoutersResource),
127
129
  ("groups", GroupsResource),
128
130
  ("clouds", CloudsResource),
129
131
  ("plans", PlansResource),
@@ -151,6 +153,7 @@ class TestMTNCloudResources:
151
153
  "instances",
152
154
  "instance_types",
153
155
  "networks",
156
+ "routers",
154
157
  "groups",
155
158
  "clouds",
156
159
  "plans",
@@ -261,7 +261,12 @@ class TestResponseHandling:
261
261
  client, _ = client_with_response(
262
262
  response(
263
263
  402,
264
- {"message": "quota exceeded", "quotaType": "instances", "current": 5, "limit": 5},
264
+ {
265
+ "message": "quota exceeded",
266
+ "quotaType": "instances",
267
+ "current": 5,
268
+ "limit": 5,
269
+ },
265
270
  )
266
271
  )
267
272
 
@@ -451,3 +451,50 @@ class TestNetworksResource:
451
451
 
452
452
  def _call_data(call_args: Any) -> dict[str, Any]:
453
453
  return {"args": list(call_args.args), "kwargs": call_args.kwargs}
454
+
455
+
456
+ def test_get_by_name_rejects_ambiguous_networks(mock_http: MagicMock) -> None:
457
+ mock_http.get.return_value = {
458
+ "networks": [
459
+ {"id": 1, "name": "app"},
460
+ {"id": 2, "name": "app"},
461
+ ]
462
+ }
463
+ with pytest.raises(ValueError, match="ambiguous"):
464
+ NetworksResource(mock_http).get_by_name("app", cloud_id=4)
465
+
466
+
467
+ def test_get_by_name_uses_exact_match(mock_http: MagicMock) -> None:
468
+ mock_http.get.return_value = {
469
+ "networks": [
470
+ {"id": 1, "name": "app-other"},
471
+ {"id": 2, "name": "app"},
472
+ ]
473
+ }
474
+ assert NetworksResource(mock_http).get_by_name("app", cloud_id=4).id == 2
475
+ assert mock_http.get.call_args.kwargs["params"]["zoneId"] == 4
476
+
477
+
478
+ def test_get_by_name_rejects_ambiguity_across_pages(mock_http: MagicMock) -> None:
479
+ first_page = [{"id": 1, "name": "app"}] + [
480
+ {"id": network_id, "name": f"app-{network_id}"} for network_id in range(2, 101)
481
+ ]
482
+ mock_http.get.side_effect = [
483
+ {"networks": first_page},
484
+ {"networks": [{"id": 101, "name": "app"}]},
485
+ ]
486
+
487
+ with pytest.raises(ValueError, match="ambiguous"):
488
+ NetworksResource(mock_http).get_by_name("app", cloud_id=4)
489
+
490
+ assert mock_http.get.call_count == 2
491
+ first_request, second_request = mock_http.get.call_args_list
492
+ assert first_request.args == ("/networks",)
493
+ assert first_request.kwargs["params"] == {"max": 100, "name": "app", "zoneId": 4}
494
+ assert second_request.args == ("/networks",)
495
+ assert second_request.kwargs["params"] == {
496
+ "max": 100,
497
+ "offset": 100,
498
+ "name": "app",
499
+ "zoneId": 4,
500
+ }
@@ -0,0 +1,139 @@
1
+ """Focused router lifecycle tests using the shared mocked HTTP client."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from mtn_cloud.exceptions import MTNCloudError, NotFoundError, TimeoutError
8
+ from mtn_cloud.resources.routers import RoutersResource
9
+
10
+ ROUTER = {
11
+ "id": 42,
12
+ "name": "app",
13
+ "status": "ok",
14
+ "type": {"id": 1, "code": "neutronRouter"},
15
+ }
16
+ CREATE = {
17
+ "name": "app",
18
+ "group_id": 621,
19
+ "cloud_id": 4,
20
+ "resource_pool_id": 213,
21
+ "internal_subnet_ids": [4112],
22
+ "external_network_id": 34,
23
+ }
24
+
25
+
26
+ def prepare_create(http: MagicMock) -> RoutersResource:
27
+ http.get.side_effect = [
28
+ {
29
+ "networkRouterTypes": [
30
+ {"id": 9, "code": "other"},
31
+ {"id": 1, "code": "neutronRouter"},
32
+ ]
33
+ },
34
+ {
35
+ "networkRouterType": {
36
+ "id": 1,
37
+ "code": "neutronRouter",
38
+ "creatable": True,
39
+ "hasNetworkServer": False,
40
+ }
41
+ },
42
+ {"networkRouter": ROUTER},
43
+ ]
44
+ http.post.return_value = {"success": True, "id": 42}
45
+ return RoutersResource(http)
46
+
47
+
48
+ def test_create_resolves_mtn_type_and_reads_id_only_response(
49
+ mock_http: MagicMock,
50
+ ) -> None:
51
+ routers = prepare_create(mock_http)
52
+ assert routers.create(**CREATE).id == 42
53
+ mock_http.post.assert_called_once_with(
54
+ "/networks/routers",
55
+ json={
56
+ "networkRouter": {
57
+ "name": "app",
58
+ "site": {"id": 621},
59
+ "zone": {"id": 4},
60
+ "type": {"id": 1},
61
+ "poolId": "pool-213",
62
+ "config": {"poolId": "pool-213"},
63
+ "enabled": True,
64
+ "externalNetwork": {"id": 34},
65
+ "interfaces": [{"networkSubnet": {"id": 4112}}],
66
+ }
67
+ },
68
+ )
69
+ mock_http.get.assert_called_with("/networks/routers/42")
70
+
71
+
72
+ def test_post_create_read_error_preserves_cleanup_id(mock_http: MagicMock) -> None:
73
+ routers = prepare_create(mock_http)
74
+ responses = list(mock_http.get.side_effect)
75
+ responses[-1] = NotFoundError(resource_type="Router", resource_id=42)
76
+ mock_http.get.side_effect = responses
77
+ with pytest.raises(MTNCloudError) as exc:
78
+ routers.create(**CREATE)
79
+ assert exc.value.response == {"router_id": 42}
80
+
81
+
82
+ def test_rejects_invalid_subnets_before_requests(mock_http: MagicMock) -> None:
83
+ with pytest.raises(ValueError):
84
+ RoutersResource(mock_http).create(**{**CREATE, "internal_subnet_ids": []})
85
+ mock_http.get.assert_not_called()
86
+ mock_http.post.assert_not_called()
87
+
88
+
89
+ def test_list_and_delete_lifecycle(mock_http: MagicMock) -> None:
90
+ routers = RoutersResource(mock_http)
91
+ mock_http.get.return_value = {"networkRouters": [ROUTER]}
92
+ assert routers.list()[0].id == 42
93
+ mock_http.get.return_value = {"networkRouter": ROUTER}
94
+ assert routers.delete(42)
95
+ mock_http.delete.assert_called_once_with("/networks/routers/42", params=None)
96
+ mock_http.get.side_effect = [{"networkRouter": ROUTER}, NotFoundError()]
97
+ with patch("mtn_cloud.resources.routers.time.sleep"):
98
+ assert routers.wait_until_deleted(42)
99
+ mock_http.get.side_effect = NotFoundError()
100
+ assert routers.delete(42)
101
+
102
+
103
+ def test_delete_rejects_other_router_types(mock_http: MagicMock) -> None:
104
+ mock_http.get.return_value = {"networkRouter": {**ROUTER, "type": {"id": 9, "code": "other"}}}
105
+ with pytest.raises(ValueError, match="not an MTN"):
106
+ RoutersResource(mock_http).delete(42)
107
+ mock_http.delete.assert_not_called()
108
+
109
+
110
+ def test_wait_until_ready(mock_http: MagicMock) -> None:
111
+ mock_http.get.side_effect = [
112
+ {"networkRouter": {**ROUTER, "status": "provisioning"}},
113
+ {"networkRouter": ROUTER},
114
+ ]
115
+ with patch("mtn_cloud.resources.routers.time.sleep"):
116
+ assert RoutersResource(mock_http).wait_until_ready(42).status == "ok"
117
+
118
+
119
+ def test_failed_provisioning_keeps_router_id(mock_http: MagicMock) -> None:
120
+ mock_http.get.return_value = {"networkRouter": {**ROUTER, "status": "failed"}}
121
+ with pytest.raises(MTNCloudError, match="provisioning failed") as exc:
122
+ RoutersResource(mock_http).wait_until_ready(42)
123
+ assert exc.value.response["router_id"] == 42
124
+
125
+
126
+ @pytest.mark.parametrize("method", ["wait_until_ready", "wait_until_deleted"])
127
+ def test_wait_timeout(mock_http: MagicMock, method: str) -> None:
128
+ mock_http.get.return_value = {"networkRouter": {**ROUTER, "status": "provisioning"}}
129
+ with patch("mtn_cloud.resources.routers.time.monotonic", side_effect=[0, 301]):
130
+ with pytest.raises(TimeoutError) as exc:
131
+ getattr(RoutersResource(mock_http), method)(42)
132
+ assert exc.value.response["router_id"] == 42
133
+
134
+
135
+ def test_create_requires_mtn_type(mock_http: MagicMock) -> None:
136
+ mock_http.get.return_value = {"networkRouterTypes": [{"id": 9, "code": "other"}]}
137
+ with pytest.raises(ValueError, match="neutronRouter"):
138
+ RoutersResource(mock_http).create(**CREATE)
139
+ mock_http.post.assert_not_called()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes