Skip to content

Commit 412b18f

Browse files
authored
Merge pull request #3926 from allegro/add-cpus-memory-and-disk-to-cloudhost
Add cpus memory and disk to cloudhost
2 parents f29fb97 + e4bb112 commit 412b18f

File tree

9 files changed

+180
-79
lines changed

9 files changed

+180
-79
lines changed

mise.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ run = "ruff format"
108108
description = "Format the code"
109109

110110
[tasks.clean]
111-
run = ["find . -name '*.py[cod]' -delete", "rm .coverage || :"]
111+
run = ["find . -name '*.py[cod]' -delete", "rm -f .coverage"]
112112
hide = true
113113

114114
[tasks.check-missing-migrations]

src/ralph/assets/models/components.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@
2222
mac_validator = RegexValidator(regex=MAC_RE, message=MAC_ERROR_MSG)
2323

2424

25-
# TODO(xor-xor): As discussed with @mkurek, this class should be removed,
26-
# but since it is used in some cloud-related functionality, it will be
27-
# removed later.
2825
class ComponentModel(
2926
AdminAbsoluteUrlMixin, AutocompleteTooltipMixin, NamedMixin, models.Model
3027
):

src/ralph/virtual/admin.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,11 +341,11 @@ class CloudHostAdmin(
341341
"Components",
342342
{
343343
"fields": [
344-
"cloudflavor_name",
345344
"get_cpu",
346345
"get_memory",
347346
"get_disk",
348347
"image_name",
348+
"cloudflavor_name",
349349
]
350350
},
351351
),
@@ -395,7 +395,15 @@ def get_hypervisor(self, obj):
395395

396396
@mark_safe
397397
def cloudflavor_name(self, obj):
398-
return '<a href="{}">{}</a>'.format(
398+
tooltip = "\n".join(
399+
[
400+
f"Cores: {obj.cloudflavor.cores}",
401+
f"RAM size: {obj.cloudflavor.memory}MiB",
402+
f"Disk size: {obj.cloudflavor.disk / 1024}GiB",
403+
]
404+
)
405+
return '<a title="{}" href="{}">{}</a>'.format(
406+
tooltip,
399407
reverse("admin:virtual_cloudflavor_change", args=(obj.cloudflavor.id,)),
400408
obj.cloudflavor.name,
401409
)
@@ -457,17 +465,17 @@ def get_configuration_path(self, obj):
457465
get_configuration_path._permission_field = "configuration_path"
458466

459467
def get_cpu(self, obj):
460-
return obj.cloudflavor.cores
468+
return obj.cores
461469

462470
get_cpu.short_description = _("vCPU cores")
463471

464472
def get_memory(self, obj):
465-
return obj.cloudflavor.memory
473+
return obj.memory
466474

467475
get_memory.short_description = _("RAM size (MiB)")
468476

469477
def get_disk(self, obj):
470-
return obj.cloudflavor.disk / 1024 if obj.cloudflavor.disk else None
478+
return obj.disk / 1024 if obj.disk is not None else None
471479

472480
get_disk.short_description = _("Disk size (GiB)")
473481

src/ralph/virtual/api.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,20 @@ class Meta:
8484
class SaveCloudHostSerializer(RalphAPISaveSerializer):
8585
_validate_using_model_clean = False
8686
ip_addresses = serializers.ListField()
87+
cores = serializers.IntegerField(required=False)
88+
memory = serializers.IntegerField(required=False)
89+
disk = serializers.IntegerField(required=False)
8790

8891
def create(self, validated_data):
8992
ip_addresses = validated_data.pop("ip_addresses")
93+
cores = validated_data.pop("cores", None)
94+
memory = validated_data.pop("memory", None)
95+
disk = validated_data.pop("disk", None)
9096
instance = super().create(validated_data)
9197
instance.ip_addresses = ip_addresses
98+
instance.cores = cores
99+
instance.memory = memory
100+
instance.disk = disk
92101
return instance
93102

94103
class Meta:
@@ -112,6 +121,9 @@ class CloudHostSerializer(NetworkComponentSerializerMixin, BaseObjectSerializer)
112121
parent = CloudProjectSimpleSerializer(source="cloudproject")
113122
cloudflavor = CloudFlavorSimpleSerializer()
114123
service_env = ServiceEnvironmentSimpleSerializer()
124+
cores = serializers.IntegerField()
125+
memory = serializers.IntegerField()
126+
disk = serializers.IntegerField()
115127

116128
class Meta(BaseObjectSerializer.Meta):
117129
model = CloudHost
@@ -251,6 +263,7 @@ class CloudHostViewSet(BaseObjectViewSetMixin, RalphAPIViewSet):
251263
prefetch_related = base_object_descendant_prefetch_related + [
252264
"tags",
253265
"cloudflavor__virtualcomponent_set__model",
266+
"virtualcomponent_set__model",
254267
"licences",
255268
Prefetch("ethernet_set", queryset=Ethernet.objects.select_related("ipaddress")),
256269
]

src/ralph/virtual/management/commands/openstack_sync.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ def _add_server(self, openstack_server, server_id, project_id):
237237
new_server.tags.add(openstack_server["tag"])
238238
with transaction.atomic(), revisions.create_revision():
239239
new_server.ip_addresses = openstack_server["ips"]
240+
new_server.disk = flavor.disk
241+
new_server.cores = flavor.cores
242+
new_server.memory = flavor.memory
240243
revisions.set_comment("Assign ip addresses to a host")
241244

242245
def _update_server(self, openstack_server, server_id, ralph_server):
@@ -266,6 +269,9 @@ def _update_server(self, openstack_server, server_id, ralph_server):
266269
if obj.cloudflavor != flavor:
267270
logger.info("Updating flavor ({}) for {}".format(flavor, server_id))
268271
obj.cloudflavor = flavor
272+
obj.disk = flavor.disk
273+
obj.cores = flavor.cores
274+
obj.memory = flavor.memory
269275
self._save_object(obj, "Modify cloudflavor")
270276
modified = True
271277

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.25 on 2025-12-19 14:08
2+
3+
from django.db import migrations
4+
5+
6+
7+
def populate_cores_memory_disk_in_cloud_hosts(apps, schema_editor):
8+
# we don't use apps.get_model here because we wouldn't be able to utilize setters from models
9+
# and there is no schema change – the database had supported this already before
10+
from ralph.virtual.models import CloudHost
11+
for ch in CloudHost.polymorphic_objects.all():
12+
ch.disk = ch.cloudflavor.disk
13+
ch.memory = ch.cloudflavor.memory
14+
ch.cores = ch.cloudflavor.cores
15+
ch.save()
16+
17+
18+
class Migration(migrations.Migration):
19+
20+
dependencies = [
21+
('virtual', '0018_alter_cloudflavor_name'),
22+
]
23+
24+
operations = [
25+
migrations.RunPython(populate_cores_memory_disk_in_cloud_hosts),
26+
]
27+
28+

src/ralph/virtual/models.py

Lines changed: 115 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
import abc
23
import logging
34
from collections import OrderedDict
45

@@ -50,18 +51,40 @@ class Meta:
5051
)
5152

5253

53-
class CloudFlavor(AdminAbsoluteUrlMixin, BaseObject):
54-
name = models.CharField(_("name"), max_length=255, db_index=True)
55-
cloudprovider = models.ForeignKey(CloudProvider, on_delete=models.CASCADE)
56-
cloudprovider._autocomplete = False
54+
class VirtualComponentDescriptor(metaclass=abc.ABCMeta):
55+
"""
56+
Base descriptor for virtual component fields (cores, memory, disk).
57+
e.g. CloudHost -[through VirtualComponent]-> ComponentModel
58+
Then it gets weird because in ComponentModel if it's CPU cores we read column 'cores'
59+
but if it's memory or disk we read column 'size'
60+
That's why we have field_path
61+
Also, disk is stored in MiB in ComponentModel, but we show it in GiB
62+
"""
5763

58-
flavor_id = models.CharField(unique=True, max_length=100)
64+
def __init__(self):
65+
self.name = None
5966

60-
def __str__(self):
61-
return self.name
67+
def __set_name__(self, owner, name):
68+
self.name = name
69+
70+
def _get_component(self, instance):
71+
"""Get component value from the instance."""
72+
try:
73+
components = instance._prefetched_objects_cache["virtualcomponent_set"]
74+
except (KeyError, AttributeError):
75+
return (
76+
instance.virtualcomponent_set.filter(model__type=self.component_type)
77+
.values_list(self.field_path, flat=True)
78+
.first()
79+
)
80+
else:
81+
for component in components:
82+
if component.model.type == self.component_type:
83+
return get_value_by_relation_path(component, self.field_path)
84+
return None
6285

63-
def _set_component(self, model_args):
64-
"""create/modify component cpu, mem or disk"""
86+
def _set_component(self, instance, model_args):
87+
"""Create/modify component."""
6588
try:
6689
model = ComponentModel.objects.get(name=model_args["name"])
6790
except ObjectDoesNotExist:
@@ -70,77 +93,97 @@ def _set_component(self, model_args):
7093
setattr(model, key, value)
7194
model.save()
7295
try:
73-
VirtualComponent.objects.get(base_object=self, model=model)
96+
VirtualComponent.objects.get(base_object=instance, model=model)
7497
except ObjectDoesNotExist:
75-
for component in self.virtualcomponent_set.filter(
98+
for component in instance.virtualcomponent_set.filter(
7699
model__type=model_args["type"]
77100
):
78101
component.delete()
102+
VirtualComponent(base_object=instance, model=model).save()
79103

80-
VirtualComponent(base_object=self, model=model).save()
81-
82-
def _get_component(self, model_type, field_path):
83-
# use cached components if already prefetched (using prefetch_related)
84-
# otherwise, perform regular SQL query
85-
try:
86-
components = self._prefetched_objects_cache["virtualcomponent_set"]
87-
except (KeyError, AttributeError):
88-
return (
89-
self.virtualcomponent_set.filter(model__type=model_type)
90-
.values_list(field_path, flat=True)
91-
.first()
92-
)
93-
else:
94-
for component in components:
95-
if component.model.type == model_type:
96-
return get_value_by_relation_path(component, field_path)
97-
return None
104+
def __get__(self, instance, owner):
105+
if instance is None:
106+
return self
107+
return self._get_component(instance)
98108

99109
@property
100-
def cores(self):
101-
"""Number of cores"""
102-
return self._get_component(ComponentType.processor, "model__cores")
103-
104-
@cores.setter
105-
def cores(self, new_cores):
106-
cpu = {
107-
"name": "{} cores vCPU".format(new_cores),
108-
"cores": new_cores,
109-
"family": "vcpu",
110-
"type": ComponentType.processor,
111-
}
112-
if self.cores != new_cores:
113-
self._set_component(cpu)
110+
@abc.abstractmethod
111+
def field_path(self) -> str:
112+
raise NotImplementedError("Subclasses must implement field_path")
114113

115114
@property
116-
def memory(self):
117-
"""RAM memory size in MiB"""
118-
return self._get_component(ComponentType.memory, "model__size")
119-
120-
@memory.setter
121-
def memory(self, new_memory):
122-
ram = {
123-
"name": "{} MiB vMEM".format(new_memory),
124-
"size": new_memory,
125-
"type": ComponentType.memory,
126-
}
127-
if self.memory != new_memory:
128-
self._set_component(ram)
115+
@abc.abstractmethod
116+
def component_type(self) -> ComponentType:
117+
raise NotImplementedError("Subclasses must implement component_type")
129118

130-
@property
131-
def disk(self):
132-
"""Disk size in MiB"""
133-
return self._get_component(ComponentType.disk, "model__size")
119+
@abc.abstractmethod
120+
def __set__(self, instance, value):
121+
raise NotImplementedError("Subclasses must implement __set__")
122+
123+
124+
class CoresVirtualComponent(VirtualComponentDescriptor):
125+
"""Descriptor for CPU cores virtual component."""
126+
127+
component_type = ComponentType.processor
128+
field_path = "model__cores"
129+
130+
def __set__(self, instance, new_cores):
131+
if self.__get__(instance, type(instance)) != new_cores:
132+
cpu = {
133+
"name": "{} cores vCPU".format(new_cores),
134+
"cores": new_cores,
135+
"family": "vcpu",
136+
"type": ComponentType.processor,
137+
}
138+
self._set_component(instance, cpu)
139+
140+
141+
class MemoryVirtualComponent(VirtualComponentDescriptor):
142+
"""Descriptor for RAM memory virtual component (size in MiB)."""
143+
144+
component_type = ComponentType.memory
145+
field_path = "model__size"
146+
147+
def __set__(self, instance, new_memory):
148+
if self.__get__(instance, type(instance)) != new_memory:
149+
ram = {
150+
"name": "{} MiB vMEM".format(new_memory),
151+
"size": new_memory,
152+
"type": ComponentType.memory,
153+
}
154+
self._set_component(instance, ram)
155+
156+
157+
class DiskVirtualComponent(VirtualComponentDescriptor):
158+
"""Descriptor for disk virtual component (size in MiB)."""
134159

135-
@disk.setter
136-
def disk(self, new_disk):
137-
disk = {
138-
"name": "{} GiB vHDD".format(int(new_disk / 1024)),
139-
"size": new_disk,
140-
"type": ComponentType.disk,
141-
}
142-
if self.disk != new_disk:
143-
self._set_component(disk)
160+
component_type = ComponentType.disk
161+
field_path = "model__size"
162+
163+
def __set__(self, instance, new_disk):
164+
if self.__get__(instance, type(instance)) != new_disk:
165+
disk = {
166+
"name": "{} GiB vHDD".format(
167+
int(new_disk / 1024) if new_disk is not None else None
168+
),
169+
"size": new_disk,
170+
"type": ComponentType.disk,
171+
}
172+
self._set_component(instance, disk)
173+
174+
175+
class CloudFlavor(AdminAbsoluteUrlMixin, BaseObject):
176+
name = models.CharField(_("name"), max_length=255, db_index=True)
177+
cores = CoresVirtualComponent()
178+
memory = MemoryVirtualComponent()
179+
disk = DiskVirtualComponent()
180+
cloudprovider = models.ForeignKey(CloudProvider, on_delete=models.CASCADE)
181+
cloudprovider._autocomplete = False
182+
183+
flavor_id = models.CharField(unique=True, max_length=100)
184+
185+
def __str__(self):
186+
return self.name
144187

145188

146189
class CloudProject(PreviousStateMixin, AdminAbsoluteUrlMixin, BaseObject):
@@ -211,6 +254,9 @@ def save(self, *args, **kwargs):
211254
DataCenterAsset, blank=True, null=True, on_delete=models.CASCADE
212255
)
213256
image_name = models.CharField(max_length=255, null=True, blank=True)
257+
cores = CoresVirtualComponent()
258+
memory = MemoryVirtualComponent()
259+
disk = DiskVirtualComponent()
214260

215261
class Meta:
216262
verbose_name = _("Cloud host")

src/ralph/virtual/tests/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ def setUp(self):
419419
def test_get_virtual_server_list(self):
420420
VirtualServerFullFactory.create_batch(20)
421421
url = reverse("virtualserver-list") + "?limit=100"
422-
with self.assertNumQueries(13):
422+
with self.assertQueriesMoreOrLess(13, plus_minus=2):
423423
response = self.client.get(url, format="json")
424424
self.assertEqual(response.status_code, status.HTTP_200_OK)
425425
self.assertEqual(response.data["count"], 22)

0 commit comments

Comments
 (0)