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.
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/PKG-INFO +8 -2
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/README.md +7 -1
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-overview.md +1 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/client.md +3 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/index.md +1 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/networks.md +4 -3
- mtn_cloud-0.4.0/docs/api-reference/routers.md +65 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/networking.md +60 -1
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/_version.py +1 -1
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/client.py +10 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/config.py +1 -1
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/http.py +5 -3
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/__init__.py +5 -0
- mtn_cloud-0.4.0/src/mtn_cloud/models/router.py +63 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/__init__.py +2 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/networks.py +9 -1
- mtn_cloud-0.4.0/src/mtn_cloud/resources/routers.py +157 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_client.py +3 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_http.py +6 -1
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_networks.py +47 -0
- mtn_cloud-0.4.0/tests/test_routers.py +139 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/.gitignore +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/LICENSE +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/advanced-cookbook.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/archive-buckets.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/backups.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/clouds.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/groups.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/instance-types.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/instances.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/plans.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/security-groups.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/storage-buckets.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/api-reference/virtual-images.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/backups.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/index.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/instances.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/quickstart.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/recipes.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/security-groups.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/storage.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/stylesheets/extra.css +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/docs/virtual-images.md +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/pyproject.toml +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/__init__.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/exceptions.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/archive.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/backup.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/base.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/cloud.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/group.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/instance.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/instance_type.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/network.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/plan.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/resource_pool.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/security_group.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/snapshot.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/storage_bucket.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/user.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/virtual_image.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/models/volume.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/py.typed +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/archive_buckets.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/backups.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/base.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/clouds.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/groups.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/instance_types.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/instances.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/plans.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/security_groups.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/storage_buckets.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/src/mtn_cloud/resources/virtual_images.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/__init__.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/conftest.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_archive_buckets.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_backups.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_clouds.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_config.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_exceptions.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_groups.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_instance_types.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_instances.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_plans.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_security_groups.py +0 -0
- {mtn_cloud-0.3.3 → mtn_cloud-0.4.0}/tests/test_storage_buckets.py +0 -0
- {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
|
+
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/)
|
|
@@ -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=
|
|
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.
|
|
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
|
|
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:
|
|
@@ -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
|
"""
|
|
@@ -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:
|
|
66
|
-
|
|
67
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|