Skip to content

Commit 0e6a884

Browse files
committed
feat : create image tool
1 parent efd7095 commit 0e6a884

File tree

5 files changed

+299
-1
lines changed

5 files changed

+299
-1
lines changed

src/openstack_mcp_server/tools/image_tools.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from fastmcp import FastMCP
22

3+
from openstack_mcp_server.tools.request.image import CreateImage
4+
from openstack_mcp_server.tools.response.image import Image
5+
36
from .base import get_openstack_conn
47

58

69
class ImageTools:
710
"""
8-
A class to encapsulate Compute-related tools and utilities.
11+
A class to encapsulate Image-related tools and utilities.
912
"""
1013

1114
def register_tools(self, mcp: FastMCP):
@@ -14,6 +17,7 @@ def register_tools(self, mcp: FastMCP):
1417
"""
1518

1619
mcp.tool()(self.get_image_images)
20+
mcp.tool()(self.create_image)
1721

1822
def get_image_images(self) -> str:
1923
"""
@@ -32,3 +36,64 @@ def get_image_images(self) -> str:
3236
)
3337

3438
return "\n".join(image_list)
39+
40+
def create_image(self, image_data: CreateImage) -> Image:
41+
"""Create a new Openstack image.
42+
This method handles both cases of image creation:
43+
1. If a volume is provided, it creates an image from the volume.
44+
2. If no volume is provided, it creates an image using the Image imports method
45+
import_options field is required for this method.
46+
Following import methods are supported:
47+
- glance-direct: The image data is made available to the Image service via the Stage binary
48+
- web-download: The image data is made available to the Image service by being posted to an accessible location with a URL that you know.
49+
- must provide a URI to the image data.
50+
- copy-image: The image data is made available to the Image service by copying existing image
51+
- glance-download: The image data is made available to the Image service by fetching an image accessible from another glance service specified by a region name and an image id that you know.
52+
- must provide a glance_region and glance_image_id.
53+
54+
:param image_data: An instance of CreateImage containing the image details.
55+
:type image: CreateImage
56+
:return: An Image object representing the created image.
57+
:rtype: Image
58+
"""
59+
conn = get_openstack_conn()
60+
61+
if image_data.volume:
62+
created_image = conn.block_storage.create_image(
63+
name=image_data.name,
64+
volume=image_data.volume,
65+
allow_duplicates=image_data.allow_duplicates,
66+
container_format=image_data.container_format,
67+
disk_format=image_data.disk_format,
68+
wait=False,
69+
timeout=3600,
70+
)
71+
else:
72+
# Create an image with Image imports
73+
# First, Creates a catalog record for an operating system disk image.
74+
created_image = conn.image.create_image(
75+
name=image_data.name,
76+
container=image_data.container,
77+
container_format=image_data.container_format,
78+
disk_format=image_data.disk_format,
79+
min_disk=image_data.min_disk,
80+
min_ram=image_data.min_ram,
81+
tags=image_data.tags,
82+
protected=image_data.protected,
83+
visibility=image_data.visibility,
84+
allow_duplicates=image_data.allow_duplicates,
85+
)
86+
87+
# Then, import the image data
88+
conn.image.import_image(
89+
image=created_image,
90+
method=image_data.import_options.import_method,
91+
uri=image_data.import_options.uri,
92+
stores=image_data.import_options.stores,
93+
remote_region=image_data.import_options.glance_region,
94+
remote_image_id=image_data.import_options.glance_image_id,
95+
remote_service_interface=image_data.import_options.glance_service_interface,
96+
)
97+
98+
image = conn.get_image(created_image.id)
99+
return Image(**image)

src/openstack_mcp_server/tools/request/__init__.py

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class CreateImage(BaseModel):
7+
"""OpenStack Glance Image Creation Request Pydantic Model"""
8+
9+
id: str | None = Field(default=None)
10+
volume: str | None = Field(default=None)
11+
name: str | None = Field(default=None)
12+
container: str | None = Field(default=None)
13+
container_format: str | None = Field(default=None)
14+
allow_duplicates: bool = Field(default=False)
15+
disk_format: str | None = Field(default=None)
16+
min_disk: int | None = Field(default=None)
17+
min_ram: int | None = Field(default=None)
18+
tags: list[str] | None = Field(default=[])
19+
protected: bool | None = Field(default=False)
20+
visibility: str | None = Field(default="public")
21+
import_options: ImportOptions | None = Field(default=None)
22+
23+
class ImportOptions(BaseModel):
24+
"""Options for image import"""
25+
26+
import_method: str | None = Field(default=None)
27+
stores: list[str] | None = Field(default=None)
28+
uri: str | None = Field(default=None)
29+
glance_region: str | None = Field(default=None)
30+
glance_image_id: str | None = Field(default=None)
31+
glance_service_interface: str | None = Field(default=None)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from pydantic import BaseModel, ConfigDict, Field
2+
3+
4+
class OwnerSpecified(BaseModel):
5+
"""Owner specified metadata for OpenStack images"""
6+
7+
openstack_object: str | None = Field(
8+
default=None,
9+
alias="owner_specified.openstack.object",
10+
)
11+
openstack_sha256: str | None = Field(
12+
default=None,
13+
alias="'owner_specified.openstack.sha256'",
14+
)
15+
openstack_md5: str | None = Field(
16+
default=None,
17+
alias="'owner_specified.openstack.md5'",
18+
)
19+
20+
model_config = ConfigDict(validate_by_name=True)
21+
22+
23+
class Image(BaseModel):
24+
"""OpenStack Glance Image Pydantic Model"""
25+
26+
id: str
27+
name: str | None = Field(default=None)
28+
checksum: str | None = Field(default=None)
29+
container_format: str | None = Field(default=None)
30+
disk_format: str | None = Field(default=None)
31+
file: str | None = Field(default=None)
32+
min_disk: int | None = Field(default=None)
33+
min_ram: int | None = Field(default=None)
34+
os_hash_algo: str | None = Field(default=None)
35+
os_hash_value: str | None = Field(default=None)
36+
size: int | None = Field(default=None)
37+
virtual_size: int | None = Field(default=None)
38+
owner: str | None = Field(default=None)
39+
visibility: str | None = Field(default=None)
40+
hw_rng_model: str | None = Field(default=None)
41+
status: str | None = Field(default=None)
42+
schema_: str | None = Field(default=None, alias="schema")
43+
protected: bool | None = Field(default=None)
44+
os_hidden: bool | None = Field(default=None)
45+
tags: list[str] | None = Field(default=None)
46+
properties: OwnerSpecified | None = Field(default=None)
47+
model_config = ConfigDict(validate_by_name=True)
48+
49+
created_at: str | None = Field(default=None)
50+
updated_at: str | None = Field(default=None)

tests/tools/test_image_tools.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,48 @@
1+
import uuid
2+
13
from unittest.mock import Mock
24

35
from openstack_mcp_server.tools.image_tools import ImageTools
6+
from openstack_mcp_server.tools.request.image import CreateImage
7+
from openstack_mcp_server.tools.response.image import Image
8+
9+
10+
class ImageMockFactory:
11+
@staticmethod
12+
def image(**overrides):
13+
defaults = {
14+
"id": str(uuid.uuid4()),
15+
"name": "test-image",
16+
"checksum": "abc123",
17+
"container_format": "bare",
18+
"disk_format": "qcow2",
19+
"file": None,
20+
"min_disk": 1,
21+
"min_ram": 512,
22+
"os_hash_algo": "sha512",
23+
"os_hash_value": "hash123",
24+
"size": 1073741824,
25+
"virtual_size": None,
26+
"owner": str(uuid.uuid4()),
27+
"visibility": "public",
28+
"hw_rng_model": None,
29+
"status": "active",
30+
"schema": "/v2/schemas/image",
31+
"protected": False,
32+
"os_hidden": False,
33+
"tags": [],
34+
"properties": None,
35+
"created_at": "2025-01-01T00:00:00Z",
36+
"updated_at": "2025-01-01T00:00:00Z",
37+
"owner_specified.openstack.md5": "a1b2c3d4e5f6",
38+
"owner_specified.openstack.sha256": "a1b2c3d",
39+
"owner_specified.openstack.object": "image",
40+
}
41+
for key, value in overrides.items():
42+
if value is not None:
43+
defaults[key] = value
44+
45+
return defaults
446

547

648
class TestImageTools:
@@ -80,3 +122,113 @@ def test_get_image_images_with_empty_name(
80122
assert " (img-empty-name) - Status: active" in result # Empty name
81123

82124
mock_conn.image.images.assert_called_once()
125+
126+
def test_create_image_success_with_volume_id(
127+
self,
128+
mock_get_openstack_conn_image,
129+
):
130+
"""Test creating an image from a volume ID."""
131+
volume_id = "6cf57d8d-00ca-43ff-ae6f-56912b69528a" # Example volume ID
132+
133+
mock_image = ImageMockFactory.image()
134+
mock_get_openstack_conn_image.block_storage.create_image.return_value = Mock(
135+
id=mock_image["id"],
136+
)
137+
mock_get_openstack_conn_image.get_image.return_value = mock_image
138+
139+
# Create an instance with volume ID
140+
image_tools = ImageTools()
141+
image_data = CreateImage(
142+
name=mock_image["name"],
143+
volume=volume_id,
144+
allow_duplicates=False,
145+
container=mock_image["container_format"],
146+
disk_format=mock_image["disk_format"],
147+
container_format=mock_image["container_format"],
148+
min_disk=mock_image["min_disk"],
149+
)
150+
151+
expected_output = Image(**mock_image)
152+
153+
created_image = image_tools.create_image(image_data)
154+
155+
# Verify the created image
156+
assert created_image == expected_output
157+
assert mock_get_openstack_conn_image.block_storage.create_image.called_once_with(
158+
name=mock_image["name"],
159+
volume=volume_id,
160+
allow_duplicates=False,
161+
container=mock_image["container_format"],
162+
disk_format=mock_image["disk_format"],
163+
wait=False,
164+
timeout=3600,
165+
)
166+
167+
assert mock_get_openstack_conn_image.get_image.called_once_with(
168+
mock_image["id"],
169+
)
170+
171+
def test_create_image_success_with_import_options(
172+
self,
173+
mock_get_openstack_conn_image,
174+
):
175+
"""Test creating an image with import options."""
176+
create_image_data = CreateImage(
177+
name="example_image",
178+
container="bare",
179+
disk_format="qcow2",
180+
container_format="bare",
181+
min_disk=10,
182+
min_ram=512,
183+
tags=["example", "test"],
184+
import_options=CreateImage.ImportOptions(
185+
import_method="web-download",
186+
uri="https://example.com/image.qcow2",
187+
),
188+
allow_duplicates=False,
189+
)
190+
191+
mock_image = ImageMockFactory.image(**create_image_data.__dict__)
192+
mock_create_image = Mock(id=mock_image["id"])
193+
194+
mock_get_openstack_conn_image.image.create_image.return_value = (
195+
mock_create_image
196+
)
197+
mock_get_openstack_conn_image.image.import_image.return_value = None
198+
mock_get_openstack_conn_image.get_image.return_value = mock_image
199+
200+
# Create an instance with import options
201+
image_tools = ImageTools()
202+
203+
expected_output = Image(**mock_image)
204+
205+
created_image = image_tools.create_image(create_image_data)
206+
207+
# Verify the created image
208+
assert created_image == expected_output
209+
assert (
210+
mock_get_openstack_conn_image.image.create_image.called_once_with(
211+
name=create_image_data.name,
212+
container=create_image_data.container,
213+
container_format=create_image_data.container_format,
214+
disk_format=create_image_data.disk_format,
215+
min_disk=create_image_data.min_disk,
216+
min_ram=create_image_data.min_ram,
217+
tags=create_image_data.tags,
218+
protected=create_image_data.protected,
219+
visibility=create_image_data.visibility,
220+
allow_duplicates=create_image_data.allow_duplicates,
221+
)
222+
)
223+
assert mock_get_openstack_conn_image.image.import_image.called_once_with(
224+
image=mock_create_image,
225+
method=create_image_data.import_options.import_method,
226+
uri=create_image_data.import_options.uri,
227+
stores=create_image_data.import_options.stores,
228+
remote_region=create_image_data.import_options.glance_region,
229+
remote_image_id=create_image_data.import_options.glance_image_id,
230+
remote_service_interface=create_image_data.import_options.glance_service_interface,
231+
)
232+
assert mock_get_openstack_conn_image.get_image.called_once_with(
233+
mock_image["id"],
234+
)

0 commit comments

Comments
 (0)