+{# a pool's space; hide_name: the page names the pool next to it #}
+{% if not hide_name %}
{{ pool.name }}
{% endif %}
diff --git a/computes/tests/test_overview.py b/computes/tests/test_overview.py
index 3aaa67fe..31daac03 100644
--- a/computes/tests/test_overview.py
+++ b/computes/tests/test_overview.py
@@ -18,7 +18,8 @@ GIVEN = {"cpus": 8, "memory": 32 * GIB, "vcpus": {"all": 12, "running": 6},
def pool(name, size_gib, free_gib, status=1):
- return {"name": name, "status": status, "size": size_gib * GIB, "free": free_gib * GIB}
+ return {"name": name, "status": status, "size": size_gib * GIB, "free": free_gib * GIB,
+ "used_percent": (size_gib - free_gib) * 100 / size_gib}
def text(page, xpath):
diff --git a/computes/views.py b/computes/views.py
index 12d3c24b..3706456e 100644
--- a/computes/views.py
+++ b/computes/views.py
@@ -64,8 +64,6 @@ def host_pools(compute):
except libvirtError:
return None
pools.sort(key=lambda pool: (pool["name"] != "default", pool["name"]))
- for pool in pools:
- pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0
return {"pool": pools[0] if pools else None, "more_pools": pools[1:]}
diff --git a/instances/templates/create_instance_w2.html b/instances/templates/create_instance_w2.html
index 7f35cc87..23f066b6 100644
--- a/instances/templates/create_instance_w2.html
+++ b/instances/templates/create_instance_w2.html
@@ -13,10 +13,6 @@
{% endblock %}
diff --git a/static/css/webvirtcloud.css b/static/css/webvirtcloud.css
index 01855f3d..7c1265df 100644
--- a/static/css/webvirtcloud.css
+++ b/static/css/webvirtcloud.css
@@ -65,6 +65,23 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after {
}
}
+/* a choice shown as a card around its radio button (the VM's disk source,
+ a new pool's type): the chosen card by its border only, as the
+ Bootswatch dark themes keep light "subtle" backgrounds */
+.source-option {
+ cursor: pointer;
+}
+
+.source-option:has(input:checked) {
+ border-color: var(--bs-primary) !important;
+ box-shadow: inset 0 0 0 1px var(--bs-primary);
+}
+
+.source-option:has(input:focus-visible) {
+ outline: 2px solid var(--bs-primary);
+ outline-offset: 2px;
+}
+
.host-collapsed tr[host] {
display: none;
}
diff --git a/static/icon_cache/toggle-off_None_None_None.svg b/static/icon_cache/toggle-off_None_None_None.svg
new file mode 100644
index 00000000..b54ea042
--- /dev/null
+++ b/static/icon_cache/toggle-off_None_None_None.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/static/icon_cache/toggle-on_None_None_None.svg b/static/icon_cache/toggle-on_None_None_None.svg
new file mode 100644
index 00000000..9f9cb44e
--- /dev/null
+++ b/static/icon_cache/toggle-on_None_None_None.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/static/icon_cache/upload_None_None_None.svg b/static/icon_cache/upload_None_None_None.svg
new file mode 100644
index 00000000..603bce64
--- /dev/null
+++ b/static/icon_cache/upload_None_None_None.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/storages/forms.py b/storages/forms.py
index 05e10729..f6692812 100644
--- a/storages/forms.py
+++ b/storages/forms.py
@@ -11,61 +11,79 @@ POOL_PATH = re.compile(r"(?!-)(?:[/0-9a-zA-Z_.:]|(? 20:
- raise forms.ValidationError(
- _("The pool name must not exceed 20 characters")
- )
+ if any(pool["name"] == name for pool in self.pools):
+ raise forms.ValidationError(_("This host has a pool of that name"), code="taken")
return name
def clean_target(self):
- storage_type = self.cleaned_data["stg_type"]
target = self.cleaned_data["target"]
- have_symbol = POOL_PATH.fullmatch(target)
- if storage_type in ["dir", "netfs"] and not have_symbol:
+ if target and not POOL_PATH.fullmatch(target):
raise forms.ValidationError(
_("The target must not contain any special characters")
)
- if storage_type in ["dir", "netfs"] and not target:
- raise forms.ValidationError(_("No path has been entered"))
+ # an RBD pool's path is its Ceph pool, not a directory
+ used = next((pool["name"] for pool in self.pools
+ if pool["type"] != "rbd" and pool["path"] and pool["path"].rstrip("/") == target.rstrip("/")), None)
+ if target and used:
+ raise forms.ValidationError(_("Pool %(pool)s uses this path") % {"pool": used})
return target
def clean_source(self):
- storage_type = self.cleaned_data["stg_type"]
source = self.cleaned_data["source"]
- have_symbol = POOL_PATH.fullmatch(source)
- if storage_type in ["logical", "netfs"]:
- if not source:
- raise forms.ValidationError(_("No device or path has been entered"))
- if not have_symbol:
- raise forms.ValidationError(
- _("The disk source must not contain any special characters")
- )
+ if source and not POOL_PATH.fullmatch(source):
+ raise forms.ValidationError(
+ _("The disk source must not contain any special characters")
+ )
return source
+ def clean(self):
+ data = super().clean()
+ for field in self.NEEDS.get(data.get("stg_type"), ()):
+ if not data.get(field) and field not in self.errors:
+ self.add_error(field, _("This field is required."))
+ if data.get("stg_type") == "rbd" and data.get("secret") and data["secret"] not in self.ceph_secrets:
+ self.add_error("secret", _("Choose one of the host's Ceph secrets"))
+ return data
+
class CreateVolumeForm(forms.Form):
name = forms.CharField(max_length=120)
diff --git a/storages/templates/create_stg_block.html b/storages/templates/create_stg_block.html
index 51658437..71347b86 100644
--- a/storages/templates/create_stg_block.html
+++ b/storages/templates/create_stg_block.html
@@ -1,258 +1,248 @@
{% load i18n %}
{% load bootstrap_icons %}
+{% load form_fields %}
{% if request.user.is_superuser %}
-
- {% bs_icon 'plus-circle-fill' %}
-
+
-
-
-
-
+ {# one form: a card per pool type, the fields of the chosen one (the others hidden and disabled, so not sent) #}
+
+
+
+
+
+
{% endif %}
diff --git a/storages/templates/create_stg_vol_block.html b/storages/templates/create_stg_vol_block.html
index 46ca6ffd..3d4ad1dd 100644
--- a/storages/templates/create_stg_vol_block.html
+++ b/storages/templates/create_stg_vol_block.html
@@ -4,16 +4,16 @@
{% if request.user.is_superuser %}
{% if state != 0 %}
{% if pool == "iso" %}
-
- {% bs_icon 'plus-circle-fill' %}
-
+
{% trans "Unavailable while the pool is stopped." %}
+ {% elif size is None %}
+
{% trans "The pool's space could not be read." %}
+ {% elif not size %}
+
{% trans "The pool has no size." %}
+ {% else %}
+
{{ percent }} %
+
+ {% blocktrans with used=used|filesizeformat size=size|filesizeformat %}{{ used }} used of {{ size }}{% endblocktrans %}
+ {# what the space is the space of: other pools may share it #}
+ {% if type == "dir" or type == "fs" or type == "netfs" %}· {% trans "the filesystem of its path" %}
+ {% elif type == "logical" %}· {% trans "its volume group" %}
+ {% elif type == "rbd" %}· {% trans "the Ceph cluster" %}{% endif %}
+
+
+
+
+
{% blocktrans with free=free|filesizeformat %}{{ free }} free{% endblocktrans %}
{% endif %}
-
-
-
+
+
+
+
+
+
{% trans "Pool" %}
+
+
+ {% trans "State" %}
+
+ {% if state %}{% trans "Active" %}{% else %}{% trans "Stopped" %}{% endif %}
+ {% if status == "Running degraded" %}{% trans "Degraded" %}{% endif %}
+ {% if autostart %}{% trans "Autostart" %}{% endif %}
+
+
+
+ {% trans "Type" %}{{ type|default:"—" }}
+
+
+ {% trans "Path" %}
+ {{ path|default:"—" }}
+
+
+
-
{% if not state %}
{% bs_icon 'info-circle' %} {% trans "This pool is stopped: its volumes cannot be listed or managed until it is started. Their files stay." %}
{% elif volumes %}
-
- {% include 'search_block.html' %}
-
{% trans "Volumes" %}
+
+
{% trans "Volumes" %}
+
{% trans "Name" %}
-
{% trans "Allocated" %}
-
{% trans "Size" %}
{% trans "Format" %}
-
{% trans "Action" %}
+
{% trans "Capacity" %}
+
{% trans "Actions" %}
{% for volume in volumes %}
{{ volume.name }}
-
{{ volume.allocation|filesizeformat }}
-
{{ volume.size|filesizeformat }}
-
{{ volume.type }}
-
- {# an ISO image is uploaded, not cloned (storages.jobs.is_iso) #}
-
+
{{ volume.type }}
+ {# capacity: what the guest sees; allocated: what it takes on the storage now #}
+
+
{{ volume.size|filesizeformat }}
+
{% blocktrans with allocated=volume.allocation|filesizeformat %}{{ allocated }} allocated{% endblocktrans %}
-
-
{% csrf_token %}
-
-
-
+
+
+ {# an ISO image is uploaded, not cloned (storages.jobs.is_iso) #}
+
+
+ {# fixed: the menu would be cut off by the table's scrolling box #}
+
+
+
{% csrf_token %}
+
+
+
+
+
+
{% endfor %}
@@ -174,9 +218,7 @@
{% else %}
-
- {% bs_icon 'exclamation-triangle' %} {% trans "This pool has no volumes." %}
-
- {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "Hypervisor doesn't have any Storages" %}
+ {% if not storages %}
+
+ {% bs_icon 'exclamation-triangle' %} {% trans "This host has no storage pools." %}
+
+ {% else %}
+ {# a card per pool, as the host Overview's Hardware and Software cards #}
+
+ {% for pool in storages %}
+
+
+
+ {{ pool.name }}
+ {% if pool.status %}
+ {% trans "Active" %}
+ {% else %}
+ {% trans "Stopped" %}
+ {% endif %}
+ {% if pool.autostart %}{% trans "Autostart" %}{% endif %}
+
+
+
+ {% trans "Type" %}
+ {{ pool.type|upper }}
+
+
+ {% trans "Path" %}
+ {{ pool.path|default:"–" }}
+
+
+ {% trans "Volumes" %}
+ {% if pool.status %}{{ pool.volumes }}{% else %}–{% endif %}
+
+ {# the space of what the pool stores in: its filesystem, volume group or cluster, which other pools may share #}
+
+ {% if pool.status and pool.size %}
+ {% include "computes/pool_bar.html" with hide_name=True %}
+ {% else %}
+ {% if pool.status %}{% trans "No size" %}{% else %}{% trans "Space unavailable while stopped" %}{% endif %}
+ {% endif %}
+
+ {% endif %}
{% endblock %}
\ No newline at end of file
diff --git a/storages/templates/storages/pool_kind.html b/storages/templates/storages/pool_kind.html
new file mode 100644
index 00000000..e7b88f44
--- /dev/null
+++ b/storages/templates/storages/pool_kind.html
@@ -0,0 +1,7 @@
+{# a new pool's type as a radio card: kind, title, help; pool_kind is the chosen one #}
+
+
+
diff --git a/storages/tests/test_audit_rows.py b/storages/tests/test_audit_rows.py
index 3ae784d9..1163af15 100644
--- a/storages/tests/test_audit_rows.py
+++ b/storages/tests/test_audit_rows.py
@@ -167,13 +167,15 @@ class PoolAndVolumeRowTests(TransactionTestCase):
url = reverse("storages", args=[self.compute.pk])
self.client.post(url, {"create": "1", "name": "audit-pool", "stg_type": "dir", "target": "/srv/audit-pool"})
self.client.post(url, {"create": "1", "name": "bad name!", "stg_type": "dir", "target": "/srv/x"}) # invalid
- self.pools.get_storages_info.return_value = [{"name": "default"}]
+ self.pools.get_storages_info.return_value = [{"name": "default", "type": "dir", "path": "/var/lib/libvirt/images"}]
self.pools.create_storage.reset_mock()
self.client.post(url, {"create": "1", "name": "default", "stg_type": "dir", "target": "/srv/default"})
+ self.client.post(url, {"create": "1", "name": " default ", "stg_type": "dir", "target": "/srv/default"}) # trimmed
self.pools.create_storage.assert_not_called()
self.assertEqual(rows(), [
("Storage pool audit-pool: add", True),
("Storage pool default: add: name already in use", False),
+ ("Storage pool default: add: name already in use", False),
])
def test_volume_add_and_delete(self):
diff --git a/storages/tests/test_new_pool.py b/storages/tests/test_new_pool.py
new file mode 100644
index 00000000..3634f319
--- /dev/null
+++ b/storages/tests/test_new_pool.py
@@ -0,0 +1,129 @@
+"""The new pool dialog: each type needs its own fields, a pool takes no name or
+path another one has, an RBD pool only a Ceph secret of the host; a refused
+pool opens the dialog again with what was entered and why."""
+
+from unittest.mock import MagicMock, patch
+
+from computes.models import Compute
+from django.contrib.auth import get_user_model
+from django.test import SimpleTestCase, TestCase
+from django.urls import reverse
+from libvirt import VIR_SECRET_USAGE_TYPE_CEPH, VIR_SECRET_USAGE_TYPE_VOLUME, libvirtError
+from lxml import html
+
+from storages.forms import AddStgPool
+
+POOLS = [
+ {"name": "default", "type": "dir", "path": "/var/lib/libvirt/images"},
+ {"name": "ceph-vms", "type": "rbd", "path": "vms"}, # an RBD pool's path is its Ceph pool
+]
+SECRET = "00000000-0000-0000-0000-000000000001"
+
+
+def errors(data):
+ form = AddStgPool(data, pools=POOLS, ceph_secrets=[SECRET])
+ form.is_valid()
+ return sorted(form.errors)
+
+
+class FormTests(SimpleTestCase):
+ def test_each_type_needs_its_fields(self):
+ for stg_type, needed in (
+ ("dir", ["target"]),
+ ("logical", ["erase_device", "source"]),
+ ("rbd", ["ceph_host", "ceph_pool", "ceph_user", "secret"]),
+ ("netfs", ["netfs_host", "source", "target"]),
+ ):
+ with self.subTest(stg_type=stg_type):
+ self.assertEqual(errors({"name": "new", "stg_type": stg_type}), needed)
+
+ def test_a_pool_takes_no_name_or_path_of_another(self):
+ self.assertEqual(errors({"name": "default", "stg_type": "dir", "target": "/srv/new"}), ["name"])
+ self.assertEqual(errors({"name": "new", "stg_type": "dir", "target": "/var/lib/libvirt/images/"}), ["target"])
+ self.assertEqual(errors({"name": "new", "stg_type": "dir", "target": "vms"}), []) # not a directory
+ self.assertEqual(errors({"name": "..", "stg_type": "dir", "target": "/srv/new"}), ["name"])
+
+ def test_an_rbd_pool_takes_a_ceph_secret_of_the_host(self):
+ data = {"name": "new", "stg_type": "rbd", "ceph_host": "mon0.example.com", "ceph_pool": "vms", "ceph_user": "libvirt"}
+ self.assertEqual(errors({**data, "secret": SECRET}), [])
+ self.assertEqual(errors({**data, "secret": "00000000-0000-0000-0000-000000000002"}), ["secret"])
+
+ def test_an_lvm_pool_is_agreed_to_erase_its_device(self):
+ data = {"name": "vg-new", "stg_type": "logical", "source": "/dev/vdb"}
+ self.assertEqual(errors(data), ["erase_device"])
+ self.assertEqual(errors({**data, "erase_device": "true"}), [])
+
+
+class DialogTests(TestCase):
+ def setUp(self):
+ self.compute = Compute.objects.create(name="np", hostname="192.0.2.25", login="root", password="", type=1)
+ self.client.force_login(get_user_model().objects.create_superuser("np-admin", "np@example.com", "pw"))
+ patcher = patch("storages.views.wvmStorages")
+ self.conn = patcher.start().return_value
+ self.addCleanup(patcher.stop)
+ pools = [{**pool, "status": 1, "autostart": 1, "volumes": 0, "size": 1, "free": 1, "used_percent": 0} for pool in POOLS]
+ self.conn.get_storages_info.return_value = pools
+ secrets = {SECRET: (VIR_SECRET_USAGE_TYPE_CEPH, "client.libvirt"),
+ "00000000-0000-0000-0000-000000000003": (VIR_SECRET_USAGE_TYPE_VOLUME, "/var/lib/libvirt/images/a.img")}
+ self.conn.get_secrets.return_value = list(secrets)
+ self.conn.get_secret.side_effect = lambda uuid: MagicMock(**{"usageType.return_value": secrets[uuid][0],
+ "usageID.return_value": secrets[uuid][1]})
+ self.url = reverse("storages", args=[self.compute.pk])
+
+ def page(self, response):
+ self.assertEqual(response.status_code, 200)
+ return html.fromstring(response.content)
+
+ def test_only_ceph_secrets_are_offered_by_their_names(self):
+ page = self.page(self.client.get(self.url))
+ self.assertEqual(page.xpath("//select[@name='secret']/option/text()"), ["Choose…", "client.libvirt"])
+ self.assertIsNone(page.get_element_by_id("AddStgPool").get("data-open"))
+
+ def test_an_iso_pool_on_the_host_is_linked_not_offered(self):
+ self.assertTrue(self.page(self.client.get(self.url)).xpath("//input[@name='pool_kind'][@value='iso']"))
+ self.conn.get_storages_info.return_value += [{"name": "iso", "type": "dir", "path": "/srv/iso", "status": 1,
+ "autostart": 1, "volumes": 0, "size": 1, "free": 1, "used_percent": 0}]
+ page = self.page(self.client.get(self.url))
+ self.assertFalse(page.xpath("//input[@name='pool_kind'][@value='iso']"))
+ self.assertTrue(page.xpath(f"//div[@id='AddStgPool']//a[@href='{reverse('storage', args=[self.compute.pk, 'iso'])}']"))
+
+ def test_a_refused_pool_opens_the_dialog_again_with_what_was_entered(self):
+ page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "netfs", "stg_type": "netfs",
+ "name": "share", "netfs_host": "nfs.example.com",
+ "source": "/export", "target": "/var/lib/libvirt/images"}))
+ self.conn.create_storage_netfs.assert_not_called()
+ self.assertIsNotNone(page.get_element_by_id("AddStgPool").get("data-open"))
+ self.assertEqual(page.xpath("//input[@name='pool_kind'][@checked]/@value"), ["netfs"])
+ self.assertEqual(page.get_element_by_id("pool_netfs_host").get("value"), "nfs.example.com")
+ target = page.get_element_by_id("pool_netfs_target")
+ self.assertEqual(target.get("aria-invalid"), "true")
+ self.assertIn("Pool default uses this path", page.get_element_by_id("pool_netfs_target_error").text_content())
+
+ def test_a_refused_type_keeps_its_values_to_itself(self):
+ # target and source are fields of several types: the others keep their own defaults
+ page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "netfs", "stg_type": "netfs",
+ "name": "share", "netfs_host": "nfs.example.com",
+ "source": "/export", "target": "/var/lib/libvirt/images"}))
+ self.assertEqual(page.get_element_by_id("pool_netfs_source").get("value"), "/export")
+ self.assertIsNone(page.get_element_by_id("pool_lvm_source").get("value") or None)
+ self.assertIsNone(page.get_element_by_id("pool_dir_target").get("aria-invalid"))
+ self.assertEqual(page.get_element_by_id("pool_iso_target").get("value"), "/var/lib/libvirt/iso")
+
+ def test_an_lvm_pool_refused_for_its_acknowledgement_says_so_beside_it(self):
+ page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "logical", "stg_type": "logical",
+ "name": "vg-new", "source": "/dev/vdb"}))
+ self.conn.create_storage.assert_not_called()
+ self.assertEqual(page.get_element_by_id("pool_lvm_erase").get("aria-invalid"), "true")
+ self.assertTrue(page.get_element_by_id("pool_lvm_erase_error").text_content())
+
+ def test_a_pool_libvirt_refuses_says_why_in_the_dialog(self):
+ self.conn.create_storage.side_effect = libvirtError("cannot create directory '/srv/new': Permission denied")
+ page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "dir", "stg_type": "dir",
+ "name": "new", "target": "/srv/new"}))
+ self.assertIn("Permission denied", page.xpath("string(//div[@id='AddStgPool']//div[contains(@class,'form-errors')])"))
+
+ def test_a_pool_made(self):
+ response = self.client.post(self.url, {"create": "1", "pool_kind": "iso", "stg_type": "dir",
+ "name": "iso", "target": "/var/lib/libvirt/iso"})
+ self.assertRedirects(response, reverse("storage", args=[self.compute.pk, "iso"]), fetch_redirect_response=False)
+ self.conn.create_storage.assert_called_once_with("dir", "iso", "", "/var/lib/libvirt/iso")
diff --git a/storages/tests/test_pool_page.py b/storages/tests/test_pool_page.py
index 9ff83b02..fb623785 100644
--- a/storages/tests/test_pool_page.py
+++ b/storages/tests/test_pool_page.py
@@ -79,6 +79,18 @@ class PoolPageTests(TestCase):
self.assertEqual(clone, {"data.img": True, "install.iso": False, "Boot.ISO": False})
self.assertTrue(page.xpath("//div[@id='no-match'][@hidden]"))
+ def test_the_heading_offers_what_the_pool_state_allows(self):
+ # an active pool is stopped, a stopped one started or deleted; autostart either way
+ self.conn.get_size.return_value = (10, 4)
+ url = reverse("storage", args=[self.compute.pk, "default"])
+ for active, names in ((True, ["stop", "set_autostart"]), (False, ["start", "set_autostart", "delete"])):
+ with self.subTest(active=active):
+ self.conn.is_active.return_value = active
+ self.conn.get_autostart.return_value = 0
+ page = html.fromstring(self.client.get(url).content)
+ group = page.xpath("//div[@aria-label='Actions for pool default']")[0]
+ self.assertEqual(group.xpath(".//button[@type='submit']/@name"), names)
+
class PoolApiTests(TestCase):
"""The API's pool, like the page, is measured after its refresh; a stopped pool lists no volumes."""
@@ -110,3 +122,30 @@ class PoolApiTests(TestCase):
self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json()["volumes"])
self.conn.update_volumes.assert_not_called()
+
+class PoolListTests(TestCase):
+ """The pool list: a card per pool with its state, autostart, path and space; a stopped pool's space is not zero."""
+
+ def test_cards(self):
+ compute = Compute.objects.create(name="pl", hostname="192.0.2.23", login="root", password="", type=1)
+ self.client.force_login(get_user_model().objects.create_superuser("pl-admin", "pl@example.com", "pw"))
+ pools = [
+ {"name": "default", "status": 1, "autostart": 1, "type": "dir", "path": "/var/lib/libvirt/images",
+ "volumes": 3, "size": 100, "free": 60, "used_percent": 40.0},
+ {"name": "ceph-vms", "status": 1, "autostart": 0, "type": "rbd", "path": "vms",
+ "volumes": 9, "size": 100, "free": 5, "used_percent": 95.0},
+ {"name": "backup", "status": 0, "autostart": 0, "type": "dir", "path": "/srv/backup",
+ "volumes": None, "size": 0, "free": 0, "used_percent": 0},
+ ]
+ with patch("storages.views.wvmStorages") as storages:
+ storages.return_value.get_storages_info.return_value = pools
+ storages.return_value.get_secrets.return_value = []
+ page = html.fromstring(self.client.get(reverse("storages", args=[compute.pk])).content)
+ cards = [" ".join(" ".join(card.itertext()).split()) for card in page.xpath("//div[@class='card h-100']")]
+ self.assertEqual(cards, [
+ "default Active Autostart Type DIR Path /var/lib/libvirt/images Volumes 3 60 bytes free of 100 bytes",
+ "ceph-vms Active Type RBD Path vms Volumes 9 5 bytes free of 100 bytes",
+ "backup Stopped Type DIR Path /srv/backup Volumes – Space unavailable while stopped",
+ ])
+ self.assertEqual(page.xpath("//div[contains(@class,'progress-bar')]/@class"),
+ ["progress-bar ", "progress-bar bg-danger"])
diff --git a/storages/tests/test_pool_target.py b/storages/tests/test_pool_target.py
index bb6ff5de..4d202f00 100644
--- a/storages/tests/test_pool_target.py
+++ b/storages/tests/test_pool_target.py
@@ -67,11 +67,12 @@ class PoolTargetTests(SimpleTestCase):
def test_the_create_form_leaves_the_path_check_to_the_server(self):
# a stricter pattern or a shorter maxlength in the page refuses paths the form accepts
superuser = SimpleNamespace(user=SimpleNamespace(is_superuser=True))
- page = html.fromstring(render_to_string("create_stg_block.html", {"request": superuser}))
+ ceph = [{"uuid": "00000000-0000-0000-0000-000000000001", "usage": "client.libvirt"}]
+ page = html.fromstring(render_to_string("create_stg_block.html", {"request": superuser, "ceph_secrets": ceph}))
fields = page.xpath("//input[@name='target' or @name='source']")
- self.assertEqual(len(fields), 5)
+ self.assertEqual(len(fields), 5) # directory, LVM device, share, its mount point, ISO directory
for field in fields:
- with self.subTest(form=field.xpath("string(ancestor::form//input[@name='stg_type']/@value)"), name=field.name):
+ with self.subTest(id=field.get("id")):
self.assertIsNone(field.get("pattern"))
self.assertGreaterEqual(int(field.get("maxlength", 100)), 100)
for name in ("netfs_host", "ceph_host"): # a host name is up to 253 characters
diff --git a/storages/views.py b/storages/views.py
index 65270667..56f1eeb8 100644
--- a/storages/views.py
+++ b/storages/views.py
@@ -10,7 +10,7 @@ from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from libvirt import libvirtError
+from libvirt import VIR_SECRET_USAGE_TYPE_CEPH, libvirtError
from operations import claims, jobs
from operations.utils import busy_message
from logs.audit import audit, failed
@@ -68,71 +68,43 @@ def storages(request, compute_id):
"""
compute = get_object_or_404(Compute, pk=compute_id)
- errors = False
+ form = AddStgPool()
+ # the new pool dialog's type: its radio cards tell the ISO pool from another directory
+ pool_kind = request.POST.get("pool_kind", "dir")
try:
conn = wvmStorages(
compute.hostname, compute.login, compute.password, compute.type
)
storages = conn.get_storages_info()
- secrets = conn.get_secrets()
+ iso_pool = next((pool for pool in storages if pool["name"] == "iso"), None)
+ # the secrets an RBD pool can use, by the name they were given
+ ceph_secrets = []
+ for uuid in conn.get_secrets():
+ secret = conn.get_secret(uuid)
+ if secret.usageType() == VIR_SECRET_USAGE_TYPE_CEPH:
+ ceph_secrets.append({"uuid": uuid, "usage": secret.usageID()})
- if request.method == "POST":
- if "create" in request.POST:
- form = AddStgPool(request.POST)
- if form.is_valid():
- data = form.cleaned_data
- if data["name"] in [stg["name"] for stg in storages]:
- msg = _("Pool name already use")
- messages.error(request, msg)
- failed(request, compute, f"Storage pool {data['name']}", "add", "name already in use")
- errors = True
- if data["stg_type"] == "rbd":
- if not data["secret"]:
- msg = _("You need create secret for pool")
- messages.error(request, msg)
- errors = True
- if (
- not data["ceph_pool"]
- and not data["ceph_host"]
- and not data["ceph_user"]
- ):
- msg = _("You need input all fields for creating ceph pool")
- messages.error(request, msg)
- errors = True
- if not errors:
- with audit(request, compute, f"Storage pool {data['name']}", "add"):
- if data["stg_type"] == "rbd":
- conn.create_storage_ceph(
- data["stg_type"],
- data["name"],
- data["ceph_pool"],
- data["ceph_host"],
- data["ceph_user"],
- data["secret"],
- )
- elif data["stg_type"] == "netfs":
- conn.create_storage_netfs(
- data["stg_type"],
- data["name"],
- data["netfs_host"],
- data["source"],
- data["source_format"],
- data["target"],
- )
- else:
- conn.create_storage(
- data["stg_type"],
- data["name"],
- data["source"],
- data["target"],
- )
- return HttpResponseRedirect(
- reverse("storage", args=[compute_id, data["name"]])
- )
+ if request.method == "POST" and "create" in request.POST:
+ form = AddStgPool(request.POST, pools=storages, ceph_secrets=[s["uuid"] for s in ceph_secrets])
+ if form.is_valid():
+ data = form.cleaned_data
+ try:
+ with audit(request, compute, f"Storage pool {data['name']}", "add"):
+ if data["stg_type"] == "rbd":
+ conn.create_storage_ceph(data["stg_type"], data["name"], data["ceph_pool"],
+ data["ceph_host"], data["ceph_user"], data["secret"])
+ elif data["stg_type"] == "netfs":
+ conn.create_storage_netfs(data["stg_type"], data["name"], data["netfs_host"],
+ data["source"], data["source_format"], data["target"])
+ else:
+ conn.create_storage(data["stg_type"], data["name"], data["source"], data["target"])
+ except libvirtError as err: # shown in the dialog, which opens again
+ form.add_error(None, str(err))
else:
- for msg_err in form.errors.values():
- messages.error(request, msg_err.as_text())
+ return HttpResponseRedirect(reverse("storage", args=[compute_id, data["name"]]))
+ elif form.has_error("name", "taken"):
+ failed(request, compute, f"Storage pool {form.data['name'].strip()}", "add", "name already in use")
conn.close()
except libvirtError as lib_err:
messages.error(request, lib_err)
@@ -179,7 +151,7 @@ def storage(request, compute_id, pool):
except libvirtError: # both read the pool's info: the page says it could not be read
size = free = used = percent = status = None
- path = conn.get_target_path()
+ path = conn.get_target_path() or conn.get_source_name() # an RBD pool: its Ceph pool
type = conn.get_type()
autostart = conn.get_autostart()
diff --git a/vrtManager/storage.py b/vrtManager/storage.py
index 066c28bf..b8905a70 100644
--- a/vrtManager/storage.py
+++ b/vrtManager/storage.py
@@ -7,8 +7,9 @@ from vrtManager.connection import wvmConnect
class wvmStorages(wvmConnect):
def get_storages_info(self):
- """Each pool's state, type, volume count, size and free space. An active
- pool is refreshed first: libvirt measures its space only when it is
+ """Each pool's state, autostart, type, path (an RBD pool's Ceph pool),
+ volume count, size, free space and the percent used. An active pool
+ is refreshed first: libvirt measures its space only when it is
refreshed, so two hosts sharing a pool's storage told different free
space, each as of its own last refresh."""
get_storages = self.get_storages()
@@ -19,17 +20,20 @@ class wvmStorages(wvmConnect):
if stg_status:
with contextlib.suppress(libvirt.libvirtError): # refused while a volume is cloned in it
stg.refresh(0)
- stg_type = util.get_xml_path(stg.XMLDesc(0), "/pool/@type")
+ xml = stg.XMLDesc(0)
stg_vol = len(stg.listVolumes()) if stg_status else None
_state, stg_size, _allocation, stg_free = stg.info()
storages.append(
{
"name": pool,
"status": stg_status,
- "type": stg_type,
+ "autostart": stg.autostart(),
+ "type": util.get_xml_path(xml, "/pool/@type"),
+ "path": util.get_xml_path(xml, "/pool/target/path") or util.get_xml_path(xml, "/pool/source/name"),
"volumes": stg_vol,
"size": stg_size,
"free": stg_free,
+ "used_percent": (stg_size - stg_free) * 100 / stg_size if stg_size else 0,
}
)
return storages
diff --git a/vrtManager/tests/test_host_reads.py b/vrtManager/tests/test_host_reads.py
index 137d4355..c0f35bb0 100644
--- a/vrtManager/tests/test_host_reads.py
+++ b/vrtManager/tests/test_host_reads.py
@@ -195,7 +195,7 @@ class StoragesInfoTestCase(unittest.TestCase):
conn.get_storages = lambda: ["default"]
conn.get_storage = lambda name: pool
(info,) = conn.get_storages_info()
- self.assertEqual((info["name"], info["size"], info["free"]), ("default", 500, 120))
+ self.assertEqual((info["name"], info["size"], info["free"], info["used_percent"]), ("default", 500, 120, 76))
# measured now: libvirt keeps a pool's space as of its last refresh
self.assertEqual([c[0] for c in pool.mock_calls[:2]], ["isActive", "refresh"])
diff --git a/webvirtcloud/tests/test_status_contrast.py b/webvirtcloud/tests/test_status_contrast.py
index 37763f12..742021cd 100644
--- a/webvirtcloud/tests/test_status_contrast.py
+++ b/webvirtcloud/tests/test_status_contrast.py
@@ -27,7 +27,7 @@ class StatusContrastTests(TestCase):
return html.fromstring(response.content)
def test_card_headers_are_pale_with_dark_text(self):
- for name in ("storages", "networks", "interfaces"):
+ for name in ("networks", "interfaces"):
with self.subTest(page=name):
headers = self.page(name).xpath("//div[contains(@class, 'card-header')]")
self.assertTrue(headers)