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.
Files changed (83) hide show
  1. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/PKG-INFO +17 -16
  2. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/README.md +15 -12
  3. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-overview.md +5 -4
  4. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/instances.md +25 -0
  5. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/index.md +8 -6
  6. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/instances.md +35 -6
  7. mtn_cloud-0.3.2/docs/recipes.md +110 -0
  8. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/pyproject.toml +3 -4
  9. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/_version.py +1 -1
  10. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/http.py +2 -1
  11. mtn_cloud-0.3.2/src/mtn_cloud/py.typed +0 -0
  12. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/instances.py +185 -1
  13. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_instances.py +110 -0
  14. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/.gitignore +0 -0
  15. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/LICENSE +0 -0
  16. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/advanced-cookbook.md +0 -0
  17. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/archive-buckets.md +0 -0
  18. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/backups.md +0 -0
  19. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/client.md +0 -0
  20. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/clouds.md +0 -0
  21. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/groups.md +0 -0
  22. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/index.md +0 -0
  23. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/instance-types.md +0 -0
  24. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/networks.md +0 -0
  25. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/plans.md +0 -0
  26. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/security-groups.md +0 -0
  27. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/storage-buckets.md +0 -0
  28. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/api-reference/virtual-images.md +0 -0
  29. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/backups.md +0 -0
  30. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/networking.md +0 -0
  31. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/quickstart.md +0 -0
  32. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/security-groups.md +0 -0
  33. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/storage.md +0 -0
  34. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/stylesheets/extra.css +0 -0
  35. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/docs/virtual-images.md +0 -0
  36. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/__init__.py +0 -0
  37. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/client.py +0 -0
  38. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/config.py +0 -0
  39. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/exceptions.py +0 -0
  40. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/__init__.py +0 -0
  41. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/archive.py +0 -0
  42. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/backup.py +0 -0
  43. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/base.py +0 -0
  44. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/cloud.py +0 -0
  45. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/group.py +0 -0
  46. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/instance.py +0 -0
  47. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/instance_type.py +0 -0
  48. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/network.py +0 -0
  49. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/plan.py +0 -0
  50. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/resource_pool.py +0 -0
  51. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/security_group.py +0 -0
  52. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/snapshot.py +0 -0
  53. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/storage_bucket.py +0 -0
  54. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/user.py +0 -0
  55. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/virtual_image.py +0 -0
  56. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/models/volume.py +0 -0
  57. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/__init__.py +0 -0
  58. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/archive_buckets.py +0 -0
  59. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/backups.py +0 -0
  60. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/base.py +0 -0
  61. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/clouds.py +0 -0
  62. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/groups.py +0 -0
  63. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/instance_types.py +0 -0
  64. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/networks.py +0 -0
  65. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/plans.py +0 -0
  66. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/security_groups.py +0 -0
  67. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/storage_buckets.py +0 -0
  68. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/src/mtn_cloud/resources/virtual_images.py +0 -0
  69. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/__init__.py +0 -0
  70. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/conftest.py +0 -0
  71. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_archive_buckets.py +0 -0
  72. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_backups.py +0 -0
  73. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_client.py +0 -0
  74. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_clouds.py +0 -0
  75. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_config.py +0 -0
  76. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_exceptions.py +0 -0
  77. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_groups.py +0 -0
  78. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_instance_types.py +0 -0
  79. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_networks.py +0 -0
  80. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_plans.py +0 -0
  81. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_security_groups.py +0 -0
  82. {mtn_cloud-0.3.1 → mtn_cloud-0.3.2}/tests/test_storage_buckets.py +0 -0
  83. {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.1
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. Discover it by group name (the cloud/zone is resolved for you), or fetch one directly by name:
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
- pool = cloud.instances.get_resource_pool(
157
- "my-project-name",
158
- group="MTNNG_CLOUD_AZ_1",
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(pool.code) # e.g. "pool-214" — pass this as resource_pool_id
165
+ print(instance.id, instance.status, instance.primary_ip)
161
166
  ```
162
167
 
163
- ### 2. Create an Instance
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="my-server",
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=pool.code, # "pool-214" — or pass the numeric ID 214
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. Discover it by group name (the cloud/zone is resolved for you), or fetch one directly by name:
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
- pool = cloud.instances.get_resource_pool(
113
- "my-project-name",
114
- group="MTNNG_CLOUD_AZ_1",
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(pool.code) # e.g. "pool-214" — pass this as resource_pool_id
123
+ print(instance.id, instance.status, instance.primary_ip)
117
124
  ```
118
125
 
119
- ### 2. Create an Instance
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="my-server",
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=pool.code, # "pool-214" — or pass the numeric ID 214
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
- # Discover the resource pool an instance will be hosted in
46
- pool = cloud.instances.get_resource_pool("my-project", group="MTNNG_CLOUD_AZ_1")
47
-
48
- cloud.instances.create(..., resource_pool_id=pool.code)
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. [Advanced Cookbook](./advanced-cookbook.md)
19
- 9. [API Overview](./api-overview.md)
20
- 10. [API Reference](./api-reference/index.md)
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. API Overview
45
- 9. API Reference
46
- 10. Advanced Cookbook
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
- ## Create an Instance
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
- # Resolve the resource pool once, then use its code directly
113
- pool = cloud.instances.get_resource_pool(
114
- "my-project-Marv-Osuolale",
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", # security group name from the console
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"
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -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