Skip to content

Commit 11dccfa

Browse files
committed
feat : create image tool (#48)
* feat : create image tool * feat : create_image remove type docstring * feat: remove unnecessary ImageFactory class
1 parent beb13f4 commit 11dccfa

File tree

5 files changed

+295
-1
lines changed

5 files changed

+295
-1
lines changed

src/openstack_mcp_server/tools/image_tools.py

Lines changed: 64 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,62 @@ 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+
:return: An Image object representing the created image.
56+
"""
57+
conn = get_openstack_conn()
58+
59+
if image_data.volume:
60+
created_image = conn.block_storage.create_image(
61+
name=image_data.name,
62+
volume=image_data.volume,
63+
allow_duplicates=image_data.allow_duplicates,
64+
container_format=image_data.container_format,
65+
disk_format=image_data.disk_format,
66+
wait=False,
67+
timeout=3600,
68+
)
69+
else:
70+
# Create an image with Image imports
71+
# First, Creates a catalog record for an operating system disk image.
72+
created_image = conn.image.create_image(
73+
name=image_data.name,
74+
container=image_data.container,
75+
container_format=image_data.container_format,
76+
disk_format=image_data.disk_format,
77+
min_disk=image_data.min_disk,
78+
min_ram=image_data.min_ram,
79+
tags=image_data.tags,
80+
protected=image_data.protected,
81+
visibility=image_data.visibility,
82+
allow_duplicates=image_data.allow_duplicates,
83+
)
84+
85+
# Then, import the image data
86+
conn.image.import_image(
87+
image=created_image,
88+
method=image_data.import_options.import_method,
89+
uri=image_data.import_options.uri,
90+
stores=image_data.import_options.stores,
91+
remote_region=image_data.import_options.glance_region,
92+
remote_image_id=image_data.import_options.glance_image_id,
93+
remote_service_interface=image_data.import_options.glance_service_interface,
94+
)
95+
96+
image = conn.get_image(created_image.id)
97+
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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
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
48

59

610
class TestImageTools:
711
"""Test cases for ImageTools class."""
812

13+
@staticmethod
14+
def image_factory(**overrides):
15+
defaults = {
16+
"id": str(uuid.uuid4()),
17+
"name": "test-image",
18+
"checksum": "abc123",
19+
"container_format": "bare",
20+
"disk_format": "qcow2",
21+
"file": None,
22+
"min_disk": 1,
23+
"min_ram": 512,
24+
"os_hash_algo": "sha512",
25+
"os_hash_value": "hash123",
26+
"size": 1073741824,
27+
"virtual_size": None,
28+
"owner": str(uuid.uuid4()),
29+
"visibility": "public",
30+
"hw_rng_model": None,
31+
"status": "active",
32+
"schema": "/v2/schemas/image",
33+
"protected": False,
34+
"os_hidden": False,
35+
"tags": [],
36+
"properties": None,
37+
"created_at": "2025-01-01T00:00:00Z",
38+
"updated_at": "2025-01-01T00:00:00Z",
39+
"owner_specified.openstack.md5": "a1b2c3d4e5f6",
40+
"owner_specified.openstack.sha256": "a1b2c3d",
41+
"owner_specified.openstack.object": "image",
42+
}
43+
for key, value in overrides.items():
44+
if value is not None:
45+
defaults[key] = value
46+
47+
return defaults
48+
949
def test_get_image_images_success(self, mock_get_openstack_conn_image):
1050
"""Test getting image images successfully."""
1151
mock_conn = mock_get_openstack_conn_image
@@ -80,3 +120,113 @@ def test_get_image_images_with_empty_name(
80120
assert " (img-empty-name) - Status: active" in result # Empty name
81121

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

0 commit comments

Comments
 (0)