mtn-cloud 0.3.1__tar.gz → 0.3.2__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.1 → mtn_cloud-0.3.2}/PKG-INFO +17 -16
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/README.md +15 -12
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-overview.md +5 -4
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/instances.md +25 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/index.md +8 -6
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/instances.md +35 -6
- mtn_cloud-0.3.2/docs/recipes.md +110 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/pyproject.toml +3 -4
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/_version.py +1 -1
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/http.py +2 -1
- mtn_cloud-0.3.2/src/mtn_cloud/py.typed +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/instances.py +185 -1
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_instances.py +110 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/.gitignore +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/LICENSE +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/advanced-cookbook.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/archive-buckets.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/backups.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/client.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/clouds.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/groups.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/index.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/instance-types.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/networks.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/plans.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/security-groups.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/storage-buckets.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/virtual-images.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/backups.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/networking.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/quickstart.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/security-groups.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/storage.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/stylesheets/extra.css +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/virtual-images.md +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/__init__.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/client.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/config.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/exceptions.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/__init__.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/archive.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/backup.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/base.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/cloud.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/group.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/instance.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/instance_type.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/network.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/plan.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/resource_pool.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/security_group.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/snapshot.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/storage_bucket.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/user.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/virtual_image.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/volume.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/__init__.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/archive_buckets.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/backups.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/base.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/clouds.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/groups.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/instance_types.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/networks.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/plans.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/security_groups.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/storage_buckets.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/virtual_images.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/__init__.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/conftest.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_archive_buckets.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_backups.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_client.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_clouds.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_config.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_exceptions.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_groups.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_instance_types.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_networks.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_plans.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_security_groups.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_storage_buckets.py +0 -0
- {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_virtual_images.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mtn-cloud
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -10,7 +10,7 @@ Author-email: Marvellous Osuolale <m@rvellous.com>
|
|
|
10
10
|
Maintainer-email: Marvellous Osuolale <m@rvellous.com>
|
|
11
11
|
License: MIT
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Keywords: api,cloud,devops,infrastructure,mtn,mtn-cloud,sdk
|
|
13
|
+
Keywords: api,cloud,cloud-management,devops,infrastructure,mtn,mtn-cloud,mtn-cloud-python,sdk
|
|
14
14
|
Classifier: Development Status :: 4 - Beta
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -31,10 +31,8 @@ Requires-Dist: typing-extensions>=4.5.0
|
|
|
31
31
|
Requires-Dist: urllib3>=2.0.0
|
|
32
32
|
Provides-Extra: dev
|
|
33
33
|
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
34
|
-
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
35
34
|
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
36
35
|
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
37
|
-
Requires-Dist: respx>=0.20.0; extra == 'dev'
|
|
38
36
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
39
37
|
Requires-Dist: types-requests>=2.28.0; extra == 'dev'
|
|
40
38
|
Provides-Extra: docs
|
|
@@ -150,33 +148,36 @@ for plan in cloud.instances.list_service_plans(
|
|
|
150
148
|
print(plan["id"], plan["name"])
|
|
151
149
|
```
|
|
152
150
|
|
|
153
|
-
A **resource pool is required** to create an instance — it's where the VM is hosted.
|
|
151
|
+
A **resource pool is required** to create an instance — it's where the VM is hosted. `provision()` (below) discovers it for you; or fetch one yourself with `cloud.instances.get_resource_pool("my-project", group="MTNNG_CLOUD_AZ_1")`.
|
|
152
|
+
|
|
153
|
+
### 2. Create an Instance
|
|
154
|
+
|
|
155
|
+
The guided `provision()` resolves layout, plan, and resource pool from names — the fastest path:
|
|
154
156
|
|
|
155
157
|
```python
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
|
|
158
|
+
instance = cloud.instances.provision(
|
|
159
|
+
name="web-01",
|
|
160
|
+
type="MTN-CS10", # type code
|
|
161
|
+
group="MTNNG_CLOUD_AZ_1", # also used as the cloud/zone
|
|
162
|
+
plan="G2S4", # plan name (or numeric id)
|
|
163
|
+
resource_pool="my-project", # pool name; auto-selected if the group has only one
|
|
159
164
|
)
|
|
160
|
-
print(
|
|
165
|
+
print(instance.id, instance.status, instance.primary_ip)
|
|
161
166
|
```
|
|
162
167
|
|
|
163
|
-
|
|
168
|
+
For full control over every ID, use `create()` directly:
|
|
164
169
|
|
|
165
170
|
```python
|
|
166
|
-
pool = cloud.instances.get_resource_pool("my-project-name", group="MTNNG_CLOUD_AZ_1")
|
|
167
|
-
|
|
168
171
|
instance = cloud.instances.create(
|
|
169
|
-
name="
|
|
172
|
+
name="web-01",
|
|
170
173
|
cloud="MTNNG_CLOUD_AZ_1",
|
|
171
174
|
type="MTN-CS10",
|
|
172
175
|
group="MTNNG_CLOUD_AZ_1",
|
|
173
176
|
layout=327,
|
|
174
177
|
plan=6776,
|
|
175
|
-
resource_pool_id=
|
|
178
|
+
resource_pool_id="pool-214", # or the numeric ID 214
|
|
176
179
|
labels=["production", "web"],
|
|
177
180
|
)
|
|
178
|
-
|
|
179
|
-
print(f"Created: {instance.id} {instance.name} ({instance.status})")
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
### 3. Manage an Instance
|
|
@@ -106,33 +106,36 @@ for plan in cloud.instances.list_service_plans(
|
|
|
106
106
|
print(plan["id"], plan["name"])
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
A **resource pool is required** to create an instance — it's where the VM is hosted.
|
|
109
|
+
A **resource pool is required** to create an instance — it's where the VM is hosted. `provision()` (below) discovers it for you; or fetch one yourself with `cloud.instances.get_resource_pool("my-project", group="MTNNG_CLOUD_AZ_1")`.
|
|
110
|
+
|
|
111
|
+
### 2. Create an Instance
|
|
112
|
+
|
|
113
|
+
The guided `provision()` resolves layout, plan, and resource pool from names — the fastest path:
|
|
110
114
|
|
|
111
115
|
```python
|
|
112
|
-
|
|
113
|
-
"
|
|
114
|
-
|
|
116
|
+
instance = cloud.instances.provision(
|
|
117
|
+
name="web-01",
|
|
118
|
+
type="MTN-CS10", # type code
|
|
119
|
+
group="MTNNG_CLOUD_AZ_1", # also used as the cloud/zone
|
|
120
|
+
plan="G2S4", # plan name (or numeric id)
|
|
121
|
+
resource_pool="my-project", # pool name; auto-selected if the group has only one
|
|
115
122
|
)
|
|
116
|
-
print(
|
|
123
|
+
print(instance.id, instance.status, instance.primary_ip)
|
|
117
124
|
```
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
For full control over every ID, use `create()` directly:
|
|
120
127
|
|
|
121
128
|
```python
|
|
122
|
-
pool = cloud.instances.get_resource_pool("my-project-name", group="MTNNG_CLOUD_AZ_1")
|
|
123
|
-
|
|
124
129
|
instance = cloud.instances.create(
|
|
125
|
-
name="
|
|
130
|
+
name="web-01",
|
|
126
131
|
cloud="MTNNG_CLOUD_AZ_1",
|
|
127
132
|
type="MTN-CS10",
|
|
128
133
|
group="MTNNG_CLOUD_AZ_1",
|
|
129
134
|
layout=327,
|
|
130
135
|
plan=6776,
|
|
131
|
-
resource_pool_id=
|
|
136
|
+
resource_pool_id="pool-214", # or the numeric ID 214
|
|
132
137
|
labels=["production", "web"],
|
|
133
138
|
)
|
|
134
|
-
|
|
135
|
-
print(f"Created: {instance.id} {instance.name} ({instance.status})")
|
|
136
139
|
```
|
|
137
140
|
|
|
138
141
|
### 3. Manage an Instance
|
|
@@ -42,10 +42,11 @@ cloud.instance_types.get_by_code("MTN-CS10")
|
|
|
42
42
|
## Common Write Operations
|
|
43
43
|
|
|
44
44
|
```python
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
# Guided: resolves layout, plan, and resource pool from names
|
|
46
|
+
cloud.instances.provision(name="web-01", type="MTN-CS10",
|
|
47
|
+
group="MTNNG_CLOUD_AZ_1", plan="G2S4", resource_pool="my-project")
|
|
48
|
+
# Explicit: full control over each ID
|
|
49
|
+
cloud.instances.create(..., resource_pool_id="pool-214")
|
|
49
50
|
cloud.instances.update(instance_id=123, name="new-name")
|
|
50
51
|
cloud.instances.delete(123)
|
|
51
52
|
|
|
@@ -61,6 +61,31 @@
|
|
|
61
61
|
- common API exceptions
|
|
62
62
|
- `NotFoundError` when `group` cannot be resolved
|
|
63
63
|
|
|
64
|
+
### `provision(name: str, *, type: str, group: str, plan: str | int, resource_pool=None, cloud=None, availability_zone=None, security_group="default", wait=True, timeout=600, dry_run=False, **create_kwargs) -> Instance | dict`
|
|
65
|
+
|
|
66
|
+
Guided wrapper over `create()` that resolves IDs from names.
|
|
67
|
+
|
|
68
|
+
- Endpoint sequence:
|
|
69
|
+
- `GET /api/groups?name=<group>` (resolve group → `cloud_ids`)
|
|
70
|
+
- `GET /api/instance-types?code=<type>` (resolve `layout` via `default_layout_id`)
|
|
71
|
+
- `GET /api/instances/service-plans` (resolve `plan` name → ID, unless `plan` is an int)
|
|
72
|
+
- `GET /api/options/zonePools` (resolve/auto-select `resource_pool`, unless given as int)
|
|
73
|
+
- `POST /api/instances`, then polls `GET /api/instances/{id}` when `wait=True`
|
|
74
|
+
- Parameters:
|
|
75
|
+
- `type`: instance type code; its `default_layout_id` becomes `layout`
|
|
76
|
+
- `group`: group name; also used as `cloud` unless `cloud` is given
|
|
77
|
+
- `plan`: plan name (resolved against the live list) or numeric ID
|
|
78
|
+
- `resource_pool`: pool name, code, or numeric ID. If omitted, the group's single pool is used; if it has several, a `ValidationError` lists them
|
|
79
|
+
- `cloud`: cloud/zone name; defaults to `group`
|
|
80
|
+
- `wait`: block until running (default `True`)
|
|
81
|
+
- `timeout`: max seconds to wait when `wait=True`
|
|
82
|
+
- `dry_run`: return the resolved config dict instead of creating
|
|
83
|
+
- `**create_kwargs`: forwarded to `create()` (labels, tags, volumes, network_interfaces, ports, …)
|
|
84
|
+
- Returns: the created `Instance` (running if `wait=True`), or a config `dict` when `dry_run=True`
|
|
85
|
+
- Raises:
|
|
86
|
+
- `NotFoundError` when the group or instance type can't be resolved
|
|
87
|
+
- `ValidationError` when the plan or resource pool can't be resolved
|
|
88
|
+
|
|
64
89
|
### `update(instance_id: int, name=None, description=None, labels=None) -> Instance`
|
|
65
90
|
|
|
66
91
|
- Endpoint: `PUT /api/instances/{instance_id}`
|
|
@@ -15,9 +15,10 @@ Use this guide in two ways:
|
|
|
15
15
|
5. [Security Groups](./security-groups.md)
|
|
16
16
|
6. [Backups](./backups.md)
|
|
17
17
|
7. [Virtual Images](./virtual-images.md)
|
|
18
|
-
8. [
|
|
19
|
-
9. [
|
|
20
|
-
10. [API
|
|
18
|
+
8. [Recipes](./recipes.md)
|
|
19
|
+
9. [Advanced Cookbook](./advanced-cookbook.md)
|
|
20
|
+
10. [API Overview](./api-overview.md)
|
|
21
|
+
11. [API Reference](./api-reference/index.md)
|
|
21
22
|
|
|
22
23
|
## Who This Is For
|
|
23
24
|
|
|
@@ -41,6 +42,7 @@ Use this guide in two ways:
|
|
|
41
42
|
5. Security Groups
|
|
42
43
|
6. Backups
|
|
43
44
|
7. Virtual Images
|
|
44
|
-
8.
|
|
45
|
-
9. API
|
|
46
|
-
10.
|
|
45
|
+
8. Recipes
|
|
46
|
+
9. API Overview
|
|
47
|
+
10. API Reference
|
|
48
|
+
11. Advanced Cookbook
|
|
@@ -106,15 +106,44 @@ If you already know the cloud and group IDs, pass them explicitly to skip the gr
|
|
|
106
106
|
pools = cloud.instances.list_resource_pools(cloud_id=4, group_id=621)
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
##
|
|
109
|
+
## Provision an Instance (guided)
|
|
110
|
+
|
|
111
|
+
`provision()` is the fastest way to create an instance: it takes the names you already know and resolves the numeric IDs for you — the layout from the type code, the plan from its name, and the resource pool from its name (or auto-selects it when the group has exactly one).
|
|
110
112
|
|
|
111
113
|
```python
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
group="MTNNG_CLOUD_AZ_1",
|
|
114
|
+
instance = cloud.instances.provision(
|
|
115
|
+
name="app-server-01",
|
|
116
|
+
type="MTN-CS10", # type code -> resolves the layout
|
|
117
|
+
group="MTNNG_CLOUD_AZ_1", # also used as the cloud/zone
|
|
118
|
+
plan="G2S4", # plan name (or pass a numeric plan ID)
|
|
119
|
+
resource_pool="my-project", # pool name; omit if the group has one pool
|
|
120
|
+
availability_zone="Lagos-AZ-1-fd1",
|
|
121
|
+
security_group="default",
|
|
116
122
|
)
|
|
117
123
|
|
|
124
|
+
# wait=True by default, so the instance is already running here
|
|
125
|
+
print(instance.id, instance.name, instance.status, instance.primary_ip)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
If a name can't be resolved, the error tells you what's available (e.g. the valid plan names, or the pools to choose from). Set `wait=False` to return immediately, or `dry_run=True` to get the resolved config back without creating anything:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
config = cloud.instances.provision(
|
|
132
|
+
name="app-server-01", type="MTN-CS10", group="MTNNG_CLOUD_AZ_1",
|
|
133
|
+
plan="G2S4", resource_pool="my-project", dry_run=True,
|
|
134
|
+
)
|
|
135
|
+
# {"layout": 327, "plan": 6776, "resource_pool_id": "pool-214", ...}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Any extra keyword (labels, tags, volumes, network_interfaces, ports, …) passes straight through to `create()`.
|
|
139
|
+
|
|
140
|
+
## Create an Instance (explicit)
|
|
141
|
+
|
|
142
|
+
When you want full control over each ID, call `create()` directly. Resolve the inputs yourself first (see [Discover Required IDs](#discover-required-ids-and-codes)):
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
pool = cloud.instances.get_resource_pool("my-project", group="MTNNG_CLOUD_AZ_1")
|
|
146
|
+
|
|
118
147
|
instance = cloud.instances.create(
|
|
119
148
|
name="app-server-01",
|
|
120
149
|
cloud="MTNNG_CLOUD_AZ_1",
|
|
@@ -124,7 +153,7 @@ instance = cloud.instances.create(
|
|
|
124
153
|
plan=6776, # use a plan ID from list_service_plans()
|
|
125
154
|
resource_pool_id=pool.code, # e.g. "pool-214" (or pass 214 directly)
|
|
126
155
|
availability_zone="Lagos-AZ-1-fd1",
|
|
127
|
-
security_group="default",
|
|
156
|
+
security_group="default",
|
|
128
157
|
)
|
|
129
158
|
|
|
130
159
|
print(instance.id, instance.name, instance.status, instance.primary_ip)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Recipes
|
|
2
|
+
|
|
3
|
+
Three complete, real-world workflows. Each assumes an authenticated client:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from mtn_cloud import MTNCloud
|
|
7
|
+
|
|
8
|
+
cloud = MTNCloud(token="your-api-token")
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For isolated patterns (retries, pagination, idempotency), see the [Advanced Cookbook](./advanced-cookbook.md).
|
|
12
|
+
|
|
13
|
+
## Provision a Linux VM with SSH
|
|
14
|
+
|
|
15
|
+
Discover the inputs, open SSH in a security group, provision, and wait until it's reachable.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
group = cloud.groups.get_by_name("MTNNG_CLOUD_AZ_1")
|
|
19
|
+
itype = cloud.instance_types.get_by_code("MTN-CS10")
|
|
20
|
+
pool = cloud.instances.get_resource_pool("my-project", group=group.name)
|
|
21
|
+
plans = cloud.instances.list_service_plans(
|
|
22
|
+
zone_id=group.cloud_ids[0], layout_id=itype.default_layout_id, group_id=group.id,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
sg = cloud.security_groups.create(name="linux-ssh", description="SSH access")
|
|
26
|
+
cloud.security_groups.create_rule(
|
|
27
|
+
sg.id, name="allow-ssh", direction="ingress", protocol="tcp",
|
|
28
|
+
port_range="22", source_type="cidr", source="200.200.113.15/32",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
instance = cloud.instances.create(
|
|
32
|
+
name="linux-web-01",
|
|
33
|
+
cloud=group.name,
|
|
34
|
+
type=itype.code,
|
|
35
|
+
group=group.name,
|
|
36
|
+
layout=itype.default_layout_id,
|
|
37
|
+
plan=plans[0]["id"],
|
|
38
|
+
resource_pool_id=pool.code,
|
|
39
|
+
availability_zone="Lagos-AZ-1-fd1",
|
|
40
|
+
security_group=sg.name,
|
|
41
|
+
create_user=True,
|
|
42
|
+
)
|
|
43
|
+
instance = cloud.instances.wait_until_running(instance.id, timeout=600)
|
|
44
|
+
print("ready:", instance.primary_ip)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
SSH **keys** are uploaded once in the console (**User Settings → SSH Keys**); the SDK controls the firewall rule and user creation. Then: `ssh <your-user>@<instance.primary_ip>`.
|
|
48
|
+
|
|
49
|
+
## Back up a folder to MTN Object Storage
|
|
50
|
+
|
|
51
|
+
Register the provider and bucket idempotently, preview with a dry run, then upload.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from mtn_cloud import NotFoundError
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
storage = cloud.storage_buckets.get_by_name("my-s3-storage")
|
|
58
|
+
except NotFoundError:
|
|
59
|
+
storage = cloud.storage_buckets.create_s3(
|
|
60
|
+
name="my-s3-storage",
|
|
61
|
+
bucket_name="my-app-objects",
|
|
62
|
+
access_key="your-access-key", # from MOS email
|
|
63
|
+
secret_key="your-secret-key", # from MOS email
|
|
64
|
+
endpoint="https://ps1csp-s3.ict.mtn.com.ng:9021",
|
|
65
|
+
create_bucket=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
archive = cloud.archive_buckets.get_by_name("my-app-archives")
|
|
70
|
+
except NotFoundError:
|
|
71
|
+
archive = cloud.archive_buckets.create(
|
|
72
|
+
name="my-app-archives", storage_provider_id=storage.id, visibility="private",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
preview = cloud.archive_buckets.upload_directory(
|
|
76
|
+
bucket_name=archive.name, remote_path="/backups/",
|
|
77
|
+
local_directory="./data", recursive=True, dry_run=True,
|
|
78
|
+
)
|
|
79
|
+
if preview.skipped_count:
|
|
80
|
+
raise RuntimeError(f"{preview.skipped_count} files would be skipped")
|
|
81
|
+
|
|
82
|
+
result = cloud.archive_buckets.upload_directory(
|
|
83
|
+
bucket_name=archive.name, remote_path="/backups/",
|
|
84
|
+
local_directory="./data", recursive=True, strict=True,
|
|
85
|
+
)
|
|
86
|
+
print(f"uploaded={result.uploaded_count} failed={result.failed_count}")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Tear down demo resources
|
|
90
|
+
|
|
91
|
+
Delete in dependency order — instances first, then the firewall and network they used. Deletes are best-effort so one failure doesn't block the rest.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from mtn_cloud import MTNCloudError
|
|
95
|
+
|
|
96
|
+
def cleanup(manager, items, label):
|
|
97
|
+
for item in items:
|
|
98
|
+
if (item.name or "").startswith("demo-"):
|
|
99
|
+
try:
|
|
100
|
+
manager.delete(item.id)
|
|
101
|
+
print("deleted", label, item.id)
|
|
102
|
+
except MTNCloudError as exc:
|
|
103
|
+
print("skip", label, item.id, exc)
|
|
104
|
+
|
|
105
|
+
cleanup(cloud.instances, cloud.instances.list(max_results=100), "instance")
|
|
106
|
+
cleanup(cloud.security_groups, cloud.security_groups.list(), "security-group")
|
|
107
|
+
cleanup(cloud.networks, cloud.networks.list(), "network")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A network or security group can't be deleted while an instance still references it — always remove instances first and let them finish.
|
|
@@ -21,7 +21,9 @@ keywords = [
|
|
|
21
21
|
"sdk",
|
|
22
22
|
"api",
|
|
23
23
|
"infrastructure",
|
|
24
|
-
"devops"
|
|
24
|
+
"devops",
|
|
25
|
+
"cloud-management",
|
|
26
|
+
"mtn-cloud-python"
|
|
25
27
|
]
|
|
26
28
|
classifiers = [
|
|
27
29
|
"Development Status :: 4 - Beta",
|
|
@@ -50,8 +52,6 @@ dependencies = [
|
|
|
50
52
|
dev = [
|
|
51
53
|
"pytest>=7.0.0",
|
|
52
54
|
"pytest-cov>=4.0.0",
|
|
53
|
-
"pytest-asyncio>=0.21.0",
|
|
54
|
-
"respx>=0.20.0",
|
|
55
55
|
"ruff>=0.1.0",
|
|
56
56
|
"mypy>=1.0.0",
|
|
57
57
|
"types-requests>=2.28.0",
|
|
@@ -121,4 +121,3 @@ ignore_missing_imports = true
|
|
|
121
121
|
|
|
122
122
|
[tool.pytest.ini_options]
|
|
123
123
|
testpaths = ["tests"]
|
|
124
|
-
asyncio_mode = "auto"
|
|
@@ -69,8 +69,9 @@ class HTTPClient:
|
|
|
69
69
|
retry_strategy = Retry(
|
|
70
70
|
total=self.config.max_retries,
|
|
71
71
|
backoff_factor=self.config.retry_delay,
|
|
72
|
-
status_forcelist=[500, 502, 503, 504],
|
|
72
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
73
73
|
allowed_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
74
|
+
respect_retry_after_header=True,
|
|
74
75
|
raise_on_status=False,
|
|
75
76
|
)
|
|
76
77
|
|
|
File without changes
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import time
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Dict, List
|
|
7
7
|
|
|
8
|
-
from mtn_cloud.exceptions import NotFoundError, TimeoutError
|
|
8
|
+
from mtn_cloud.exceptions import NotFoundError, TimeoutError, ValidationError
|
|
9
9
|
from mtn_cloud.models.group import Group
|
|
10
10
|
from mtn_cloud.models.instance import (
|
|
11
11
|
Instance,
|
|
@@ -305,6 +305,190 @@ class InstancesResource(BaseResource[Instance]):
|
|
|
305
305
|
payload = create_model.to_api_payload()
|
|
306
306
|
return self._create(payload)
|
|
307
307
|
|
|
308
|
+
def provision(
|
|
309
|
+
self,
|
|
310
|
+
name: str,
|
|
311
|
+
*,
|
|
312
|
+
type: str,
|
|
313
|
+
group: str,
|
|
314
|
+
plan: str | int,
|
|
315
|
+
resource_pool: str | int | None = None,
|
|
316
|
+
cloud: str | None = None,
|
|
317
|
+
availability_zone: str | None = None,
|
|
318
|
+
security_group: str = "default",
|
|
319
|
+
wait: bool = True,
|
|
320
|
+
timeout: int = 600,
|
|
321
|
+
dry_run: bool = False,
|
|
322
|
+
description: str | None = None,
|
|
323
|
+
environment: str | None = None,
|
|
324
|
+
labels: List[str] | None = None,
|
|
325
|
+
tags: Dict[str, str] | None = None,
|
|
326
|
+
copies: int = 1,
|
|
327
|
+
layout_size: int = 1,
|
|
328
|
+
os_external_network_id: str | None = None,
|
|
329
|
+
create_user: bool = True,
|
|
330
|
+
workflow_id: int | None = None,
|
|
331
|
+
shutdown_days: int | None = None,
|
|
332
|
+
expire_days: int | None = None,
|
|
333
|
+
create_backup: bool | None = None,
|
|
334
|
+
security_groups: List[str] | None = None,
|
|
335
|
+
ports: List[Dict[str, Any]] | None = None,
|
|
336
|
+
volumes: List[InstanceVolume | Dict[str, Any]] | None = None,
|
|
337
|
+
network_interfaces: List[InstanceNetwork | Dict[str, Any]] | None = None,
|
|
338
|
+
options: Dict[str, Any] | None = None,
|
|
339
|
+
) -> Instance | Dict[str, Any]:
|
|
340
|
+
"""
|
|
341
|
+
Provision an instance from human-friendly names.
|
|
342
|
+
|
|
343
|
+
A guided wrapper over :meth:`create` that resolves the IDs an instance
|
|
344
|
+
needs from names you already know:
|
|
345
|
+
|
|
346
|
+
- ``layout`` from the instance type ``code``
|
|
347
|
+
- ``plan`` ID from a plan name (or pass the numeric ID directly)
|
|
348
|
+
- ``resource_pool_id`` from a pool name (or auto-selected when the group
|
|
349
|
+
has exactly one pool)
|
|
350
|
+
- ``cloud`` defaults to ``group`` (the MTN convention)
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
name: Instance name
|
|
354
|
+
type: Instance type code (e.g. ``"MTN-CS10"``)
|
|
355
|
+
group: Group name; also used as the cloud/zone unless ``cloud`` is set
|
|
356
|
+
plan: Service plan name (resolved) or numeric ID
|
|
357
|
+
resource_pool: Pool name, code, or numeric ID. If omitted, the group's
|
|
358
|
+
single pool is used; if the group has several, an error lists them.
|
|
359
|
+
cloud: Cloud/zone name; defaults to ``group``
|
|
360
|
+
availability_zone: Availability zone (e.g. ``"Lagos-AZ-1-fd1"``)
|
|
361
|
+
security_group: Security group name (default ``"default"``)
|
|
362
|
+
wait: Block until the instance is running (default True)
|
|
363
|
+
timeout: Max seconds to wait when ``wait`` is True
|
|
364
|
+
dry_run: Return the resolved config without creating anything
|
|
365
|
+
**: Remaining options pass straight through to :meth:`create`
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
The created :class:`Instance` (running if ``wait``), or the resolved
|
|
369
|
+
config dict when ``dry_run`` is True.
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
NotFoundError: If the group or instance type cannot be resolved
|
|
373
|
+
ValidationError: If the plan or resource pool cannot be resolved
|
|
374
|
+
|
|
375
|
+
Example:
|
|
376
|
+
instance = cloud.instances.provision(
|
|
377
|
+
name="web-01",
|
|
378
|
+
type="MTN-CS10",
|
|
379
|
+
group="MTNNG_CLOUD_AZ_1",
|
|
380
|
+
plan="G2S4",
|
|
381
|
+
resource_pool="my-project",
|
|
382
|
+
)
|
|
383
|
+
"""
|
|
384
|
+
grp = self._resolve_group(group)
|
|
385
|
+
if not grp.cloud_ids:
|
|
386
|
+
raise ValidationError(f"Group '{group}' has no associated cloud.")
|
|
387
|
+
zone_id = grp.cloud_ids[0]
|
|
388
|
+
group_name = grp.name or group
|
|
389
|
+
cloud_name = cloud or group_name
|
|
390
|
+
|
|
391
|
+
from mtn_cloud.resources.instance_types import InstanceTypesResource
|
|
392
|
+
|
|
393
|
+
instance_type = InstanceTypesResource(self._http).get_by_code(type)
|
|
394
|
+
layout_id = instance_type.default_layout_id
|
|
395
|
+
if layout_id is None:
|
|
396
|
+
raise ValidationError(f"Instance type '{type}' has no available layout.")
|
|
397
|
+
|
|
398
|
+
plan_id = self._resolve_plan_id(plan, zone_id=zone_id, layout_id=layout_id, group_id=grp.id)
|
|
399
|
+
pool_code = self._resolve_provision_pool(resource_pool, grp)
|
|
400
|
+
|
|
401
|
+
if dry_run:
|
|
402
|
+
return {
|
|
403
|
+
"name": name,
|
|
404
|
+
"cloud": cloud_name,
|
|
405
|
+
"type": type,
|
|
406
|
+
"group": group_name,
|
|
407
|
+
"layout": layout_id,
|
|
408
|
+
"plan": plan_id,
|
|
409
|
+
"resource_pool_id": pool_code,
|
|
410
|
+
"availability_zone": availability_zone,
|
|
411
|
+
"security_group": security_group,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
instance = self.create(
|
|
415
|
+
name=name,
|
|
416
|
+
cloud=cloud_name,
|
|
417
|
+
type=type,
|
|
418
|
+
group=group_name,
|
|
419
|
+
layout=layout_id,
|
|
420
|
+
plan=plan_id,
|
|
421
|
+
resource_pool_id=pool_code,
|
|
422
|
+
availability_zone=availability_zone,
|
|
423
|
+
security_group=security_group,
|
|
424
|
+
description=description,
|
|
425
|
+
environment=environment,
|
|
426
|
+
labels=labels,
|
|
427
|
+
tags=tags,
|
|
428
|
+
copies=copies,
|
|
429
|
+
layout_size=layout_size,
|
|
430
|
+
os_external_network_id=os_external_network_id,
|
|
431
|
+
create_user=create_user,
|
|
432
|
+
workflow_id=workflow_id,
|
|
433
|
+
shutdown_days=shutdown_days,
|
|
434
|
+
expire_days=expire_days,
|
|
435
|
+
create_backup=create_backup,
|
|
436
|
+
security_groups=security_groups,
|
|
437
|
+
ports=ports,
|
|
438
|
+
volumes=volumes,
|
|
439
|
+
network_interfaces=network_interfaces,
|
|
440
|
+
options=options,
|
|
441
|
+
)
|
|
442
|
+
if wait:
|
|
443
|
+
return self.wait_until_running(instance.id, timeout=timeout)
|
|
444
|
+
return instance
|
|
445
|
+
|
|
446
|
+
def _resolve_plan_id(
|
|
447
|
+
self,
|
|
448
|
+
plan: str | int,
|
|
449
|
+
*,
|
|
450
|
+
zone_id: int,
|
|
451
|
+
layout_id: int,
|
|
452
|
+
group_id: int,
|
|
453
|
+
) -> int:
|
|
454
|
+
"""Resolve a plan name to its ID; pass numeric IDs through unchanged."""
|
|
455
|
+
if isinstance(plan, int):
|
|
456
|
+
return plan
|
|
457
|
+
if plan.isdigit():
|
|
458
|
+
return int(plan)
|
|
459
|
+
|
|
460
|
+
plans = self.list_service_plans(zone_id=zone_id, layout_id=layout_id, group_id=group_id)
|
|
461
|
+
for candidate in plans:
|
|
462
|
+
if candidate.get("name") == plan or candidate.get("code") == plan:
|
|
463
|
+
return int(candidate["id"])
|
|
464
|
+
|
|
465
|
+
available = ", ".join(sorted(str(p.get("name")) for p in plans)) or "none"
|
|
466
|
+
raise ValidationError(
|
|
467
|
+
f"Service plan {plan!r} not found for this type/zone. Available: {available}"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _resolve_provision_pool(self, resource_pool: str | int | None, group: Group) -> str:
|
|
471
|
+
"""Resolve the resource pool code, auto-selecting the only pool if needed."""
|
|
472
|
+
if isinstance(resource_pool, int):
|
|
473
|
+
return self._normalize_resource_pool_id(resource_pool)
|
|
474
|
+
if resource_pool is not None:
|
|
475
|
+
return self.get_resource_pool(
|
|
476
|
+
resource_pool, cloud_id=group.cloud_ids[0], group_id=group.id
|
|
477
|
+
).code
|
|
478
|
+
|
|
479
|
+
pools = self.list_resource_pools(cloud_id=group.cloud_ids[0], group_id=group.id)
|
|
480
|
+
if not pools:
|
|
481
|
+
raise ValidationError(
|
|
482
|
+
f"Group '{group.name}' has no resource pool. Create a project first."
|
|
483
|
+
)
|
|
484
|
+
if len(pools) > 1:
|
|
485
|
+
names = ", ".join(sorted(p.name or p.code for p in pools))
|
|
486
|
+
raise ValidationError(
|
|
487
|
+
f"Group '{group.name}' has multiple resource pools; "
|
|
488
|
+
f"pass resource_pool=. Available: {names}"
|
|
489
|
+
)
|
|
490
|
+
return pools[0].code
|
|
491
|
+
|
|
308
492
|
def _resolve_group(self, group: str | int) -> Group:
|
|
309
493
|
"""
|
|
310
494
|
Resolve a group name or ID to a full Group object.
|
|
@@ -5,6 +5,8 @@ from unittest.mock import MagicMock
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
|
+
from mtn_cloud.exceptions import ValidationError
|
|
9
|
+
from mtn_cloud.models.group import Group
|
|
8
10
|
from mtn_cloud.models.instance import (
|
|
9
11
|
Instance,
|
|
10
12
|
InstanceCreate,
|
|
@@ -547,3 +549,111 @@ class TestInstancesResource:
|
|
|
547
549
|
return pages.get(offset, {"instances": []})
|
|
548
550
|
|
|
549
551
|
mock_http.get.side_effect = get_side_effect
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _provision_group() -> Group:
|
|
555
|
+
"""A group with one cloud/zone, for provision helper tests."""
|
|
556
|
+
return Group.model_validate({"id": 621, "name": "MTNNG_CLOUD_AZ_1", "zones": [{"id": 4}]})
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class TestProvision:
|
|
560
|
+
"""Tests for the guided provision() helper and its resolvers."""
|
|
561
|
+
|
|
562
|
+
def test_resolve_plan_id_passes_int_through(self, resource: InstancesResource) -> None:
|
|
563
|
+
"""A numeric plan is used directly without a lookup."""
|
|
564
|
+
assert resource._resolve_plan_id(6776, zone_id=4, layout_id=327, group_id=621) == 6776
|
|
565
|
+
|
|
566
|
+
def test_resolve_plan_id_by_name(
|
|
567
|
+
self, resource: InstancesResource, mock_http: MagicMock
|
|
568
|
+
) -> None:
|
|
569
|
+
"""A plan name resolves to its ID from the live plan list."""
|
|
570
|
+
mock_http.get.return_value = {
|
|
571
|
+
"plans": [{"id": 1, "name": "G1S1"}, {"id": 6776, "name": "G2S4"}]
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
assert resource._resolve_plan_id("G2S4", zone_id=4, layout_id=327, group_id=621) == 6776
|
|
575
|
+
|
|
576
|
+
def test_resolve_plan_id_unknown_lists_available(
|
|
577
|
+
self, resource: InstancesResource, mock_http: MagicMock
|
|
578
|
+
) -> None:
|
|
579
|
+
"""An unknown plan name raises with the available names."""
|
|
580
|
+
mock_http.get.return_value = {"plans": [{"id": 1, "name": "G1S1"}]}
|
|
581
|
+
|
|
582
|
+
with pytest.raises(ValidationError, match="G1S1"):
|
|
583
|
+
resource._resolve_plan_id("G99", zone_id=4, layout_id=327, group_id=621)
|
|
584
|
+
|
|
585
|
+
def test_resolve_pool_int_is_normalized(self, resource: InstancesResource) -> None:
|
|
586
|
+
"""A numeric pool ID is normalized to its code with no lookup."""
|
|
587
|
+
assert resource._resolve_provision_pool(214, _provision_group()) == "pool-214"
|
|
588
|
+
|
|
589
|
+
def test_resolve_pool_auto_selects_single(
|
|
590
|
+
self, resource: InstancesResource, mock_http: MagicMock
|
|
591
|
+
) -> None:
|
|
592
|
+
"""With exactly one pool, it is auto-selected when none is given."""
|
|
593
|
+
mock_http.get.return_value = {
|
|
594
|
+
"data": [{"id": 230, "value": "pool-230", "name": "only-project"}]
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
assert resource._resolve_provision_pool(None, _provision_group()) == "pool-230"
|
|
598
|
+
|
|
599
|
+
def test_resolve_pool_multiple_requires_choice(
|
|
600
|
+
self, resource: InstancesResource, mock_http: MagicMock
|
|
601
|
+
) -> None:
|
|
602
|
+
"""With several pools and none given, the error lists them."""
|
|
603
|
+
mock_http.get.return_value = {
|
|
604
|
+
"data": [
|
|
605
|
+
{"id": 1, "value": "pool-1", "name": "alpha"},
|
|
606
|
+
{"id": 2, "value": "pool-2", "name": "beta"},
|
|
607
|
+
]
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
with pytest.raises(ValidationError, match="alpha"):
|
|
611
|
+
resource._resolve_provision_pool(None, _provision_group())
|
|
612
|
+
|
|
613
|
+
def test_provision_dry_run_resolves_config(
|
|
614
|
+
self, resource: InstancesResource, mock_http: MagicMock
|
|
615
|
+
) -> None:
|
|
616
|
+
"""dry_run returns the resolved config without creating anything."""
|
|
617
|
+
|
|
618
|
+
def route(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
619
|
+
if path.startswith("/groups"):
|
|
620
|
+
return {"groups": [{"id": 621, "name": "MTNNG_CLOUD_AZ_1", "zones": [{"id": 4}]}]}
|
|
621
|
+
if path.startswith("/instance-types"):
|
|
622
|
+
return {
|
|
623
|
+
"instanceTypes": [
|
|
624
|
+
{
|
|
625
|
+
"id": 1,
|
|
626
|
+
"name": "CS10",
|
|
627
|
+
"code": "MTN-CS10",
|
|
628
|
+
"instanceTypeLayouts": [{"id": 327, "name": "single"}],
|
|
629
|
+
}
|
|
630
|
+
]
|
|
631
|
+
}
|
|
632
|
+
if "service-plans" in path:
|
|
633
|
+
return {"plans": [{"id": 6776, "name": "G2S4"}]}
|
|
634
|
+
if "zonePools" in path:
|
|
635
|
+
return {"data": [{"id": 230, "value": "pool-230", "name": "my-project"}]}
|
|
636
|
+
raise AssertionError(f"Unexpected path: {path}")
|
|
637
|
+
|
|
638
|
+
mock_http.get.side_effect = route
|
|
639
|
+
|
|
640
|
+
config = resource.provision(
|
|
641
|
+
name="web-01",
|
|
642
|
+
type="MTN-CS10",
|
|
643
|
+
group="MTNNG_CLOUD_AZ_1",
|
|
644
|
+
plan="G2S4",
|
|
645
|
+
dry_run=True,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
assert config == {
|
|
649
|
+
"name": "web-01",
|
|
650
|
+
"cloud": "MTNNG_CLOUD_AZ_1",
|
|
651
|
+
"type": "MTN-CS10",
|
|
652
|
+
"group": "MTNNG_CLOUD_AZ_1",
|
|
653
|
+
"layout": 327,
|
|
654
|
+
"plan": 6776,
|
|
655
|
+
"resource_pool_id": "pool-230",
|
|
656
|
+
"availability_zone": None,
|
|
657
|
+
"security_group": "default",
|
|
658
|
+
}
|
|
659
|
+
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|