mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +00:00
test: each app keeps its tests in a tests package
131 test modules sat next to the code they test; instances alone had 51 of them beside views.py and utils.py. They move into a tests/ package per app, and the JavaScript checks into webvirtcloud/tests/js. Each non-empty tests.py becomes tests/test_views.py; the empty generated stubs are removed. Imports of the app's own modules become absolute, paths built from __file__ go one level up, the live helper is instances.tests.livetest, and coverage omits */tests/*. The suite discovers the same 960 tests as before.
This commit is contained in:
parent
0906838193
commit
c55f52f025
154 changed files with 27 additions and 39 deletions
0
computes/tests/__init__.py
Normal file
0
computes/tests/__init__.py
Normal file
189
computes/tests/test_api_security.py
Normal file
189
computes/tests/test_api_security.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
from unittest.mock import patch
|
||||
from accounts.models import UserInstance
|
||||
from computes.api.serializers import ComputeSerializer
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from instances.models import Flavor, Instance
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class APISecurityTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.admin = User.objects.create_superuser(
|
||||
username="admin_api_test",
|
||||
email="admin_api@example.com",
|
||||
password="adminpassword",
|
||||
)
|
||||
self.regular_user = User.objects.create_user(
|
||||
username="regular_api_user",
|
||||
email="user@example.com",
|
||||
password="userpassword",
|
||||
)
|
||||
self.staff_user = User.objects.create_user(
|
||||
username="staff_api_user",
|
||||
email="staff@example.com",
|
||||
password="staffpassword",
|
||||
is_staff=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
self.compute = Compute.objects.create(
|
||||
name="api-test-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="libvirt_user",
|
||||
password="hv-secret-pw",
|
||||
type=1,
|
||||
)
|
||||
self.empty_compute = Compute.objects.create(
|
||||
name="empty-test-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="libvirt_user",
|
||||
password="hv-secret-pw",
|
||||
type=1,
|
||||
)
|
||||
self.flavor = Flavor.objects.create(
|
||||
label="test-flavor",
|
||||
vcpu=2,
|
||||
memory=2048,
|
||||
disk=20,
|
||||
)
|
||||
self.instance1 = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="inst-user-owned",
|
||||
uuid="11111111-1111-1111-1111-111111111111",
|
||||
)
|
||||
self.instance2 = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="inst-other-owned",
|
||||
uuid="22222222-2222-2222-2222-222222222222",
|
||||
)
|
||||
# Assign instance1 to regular_user
|
||||
UserInstance.objects.create(user=self.regular_user, instance=self.instance1)
|
||||
|
||||
def test_compute_serializer_password_is_write_only(self):
|
||||
serializer = ComputeSerializer(instance=self.compute)
|
||||
self.assertNotIn("password", serializer.data)
|
||||
self.assertEqual(serializer.data["name"], "api-test-compute")
|
||||
self.assertEqual(serializer.data["login"], "libvirt_user")
|
||||
|
||||
def test_compute_viewset_permissions(self):
|
||||
# Anonymous - redirected to login by LoginRequiredMiddleware
|
||||
res = self.client.get("/api/v1/computes/")
|
||||
self.assertIn(res.status_code, [302, 401, 403])
|
||||
|
||||
# Regular user - forbidden by IsSuperUser
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.get("/api/v1/computes/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
# Staff user (not superuser) - forbidden by IsSuperUser
|
||||
self.client.force_login(self.staff_user)
|
||||
res = self.client.get("/api/v1/computes/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
# Admin user - allowed
|
||||
self.client.force_login(self.admin)
|
||||
res = self.client.get("/api/v1/computes/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
@patch("computes.utils.refresh_instance_database")
|
||||
def test_compute_instance_list_isolation(self, mock_refresh):
|
||||
# Regular user should only see instance1 on compute
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/instances/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
names = [item["name"] for item in res.data]
|
||||
self.assertIn("inst-user-owned", names)
|
||||
self.assertNotIn("inst-other-owned", names)
|
||||
mock_refresh.assert_called_once_with(self.compute)
|
||||
|
||||
# Regular user accessing compute where they have NO instances:
|
||||
# returns empty list without calling refresh_instance_database!
|
||||
mock_refresh.reset_mock()
|
||||
res_empty = self.client.get(f"/api/v1/computes/{self.empty_compute.id}/instances/")
|
||||
self.assertEqual(res_empty.status_code, 200)
|
||||
self.assertEqual(res_empty.data, [])
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
# Admin user should see both instances
|
||||
self.client.force_login(self.admin)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/instances/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
names = [item["name"] for item in res.data]
|
||||
self.assertIn("inst-user-owned", names)
|
||||
self.assertIn("inst-other-owned", names)
|
||||
|
||||
def test_flavor_permissions(self):
|
||||
# Regular user can read flavors
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.get("/api/v1/flavor/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
# Regular user cannot mutate flavors
|
||||
res = self.client.post(
|
||||
"/api/v1/flavor/",
|
||||
{"label": "malicious", "vcpu": 4, "memory": 4096, "disk": 40},
|
||||
)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
# Staff user (not superuser) cannot mutate flavors
|
||||
self.client.force_login(self.staff_user)
|
||||
res = self.client.post(
|
||||
"/api/v1/flavor/",
|
||||
{"label": "staffmutate", "vcpu": 4, "memory": 4096, "disk": 40},
|
||||
)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
res = self.client.delete(f"/api/v1/flavor/{self.flavor.id}/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
def test_migrate_viewset_permissions(self):
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.post("/api/v1/migrate/", {})
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
self.client.force_login(self.staff_user)
|
||||
res = self.client.post("/api/v1/migrate/", {})
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
def test_network_viewset_permissions(self):
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/networks/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
self.client.force_login(self.staff_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/networks/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
def test_interface_viewset_permissions(self):
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/interfaces/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
self.client.force_login(self.staff_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/interfaces/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
def test_storage_viewset_permissions(self):
|
||||
self.client.force_login(self.regular_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/storages/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
self.client.force_login(self.staff_user)
|
||||
res = self.client.get(f"/api/v1/computes/{self.compute.id}/storages/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
def test_busy_instance_api_does_not_report_success(self):
|
||||
self.client.force_login(self.regular_user)
|
||||
with patch("instances.views.libvirt_instance_lock", side_effect=TimeoutError("busy")), patch(
|
||||
"instances.models.wvmInstance"
|
||||
) as proxy:
|
||||
response = self.client.post(
|
||||
f"/api/v1/computes/{self.compute.pk}/instances/{self.instance1.pk}/poweron/", {}
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertIn("busy", response.json()["detail"])
|
||||
proxy.assert_not_called()
|
||||
28
computes/tests/test_compute_password.py
Normal file
28
computes/tests/test_compute_password.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""The compute form never shows the stored password: a password left empty
|
||||
on an edit keeps it, a new one replaces it."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from computes.models import Compute
|
||||
|
||||
|
||||
class ComputePasswordTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("cp-admin", "cp@example.com", "pw"))
|
||||
|
||||
def edit(self, compute, password):
|
||||
data = {"name": compute.name, "hostname": compute.hostname, "login": "admin", "password": password,
|
||||
"type": compute.type, "details": ""}
|
||||
self.client.post(reverse("compute_update", args=[compute.id]), data)
|
||||
compute.refresh_from_db()
|
||||
return compute.password
|
||||
|
||||
def test_an_empty_password_keeps_the_stored_one(self):
|
||||
for conn_type in (1, 3): # TCP, TLS
|
||||
with self.subTest(type=conn_type):
|
||||
compute = Compute.objects.create(name=f"cp{conn_type}", hostname="10.0.0.7", login="admin",
|
||||
password="secret", type=conn_type)
|
||||
self.assertEqual(self.edit(compute, ""), "secret")
|
||||
self.assertEqual(self.edit(compute, "other"), "other")
|
||||
167
computes/tests/test_cpu_graph.py
Normal file
167
computes/tests/test_cpu_graph.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""The host CPU graph: usage is taken between the sample of the graph's
|
||||
previous answer and now, without waiting, when that sample is recent and
|
||||
unaltered; otherwise over one second as before."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import TestCase as UnitTestCase
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import signing
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from libvirt import libvirtError
|
||||
from lxml import html
|
||||
|
||||
from computes.models import Compute
|
||||
from computes.views import CPU_SAMPLE_MAX_AGE, CPU_SAMPLE_SIGNER
|
||||
from vrtManager.hostdetails import cpu_percent, wvmHostDetails
|
||||
|
||||
|
||||
def host(*readings):
|
||||
"""A wvmHostDetails whose getCPUStats returns readings, (idle, busy) each."""
|
||||
h = wvmHostDetails.__new__(wvmHostDetails)
|
||||
h.wvm = MagicMock()
|
||||
h.wvm.getCPUStats.side_effect = [
|
||||
{"kernel": busy // 2, "user": busy - busy // 2, "idle": idle, "iowait": 0, "guest": busy // 3, "utilization": 99}
|
||||
for idle, busy in readings
|
||||
]
|
||||
return h
|
||||
|
||||
|
||||
class CpuPercentTestCase(UnitTestCase):
|
||||
def test_busy_share_between_two_samples(self):
|
||||
self.assertEqual(cpu_percent((100, 200), (130, 300)), 70.0)
|
||||
self.assertEqual(cpu_percent((0, 0), (1, 3)), 66.7)
|
||||
|
||||
def test_samples_that_do_not_follow_on(self):
|
||||
for before, after in (((100, 200), (100, 200)), ((100, 200), (50, 100)), ((100, 200), (200, 250))):
|
||||
with self.subTest(before=before, after=after):
|
||||
self.assertIsNone(cpu_percent(before, after))
|
||||
|
||||
|
||||
@patch("vrtManager.hostdetails.time")
|
||||
class CpuUsageTestCase(UnitTestCase):
|
||||
def test_since_a_previous_sample_without_waiting(self, clock):
|
||||
clock.time.return_value = 105.0
|
||||
h = host((130, 70))
|
||||
usage = h.get_cpu_usage(previous=(100, 100, 100.0))
|
||||
clock.sleep.assert_not_called()
|
||||
# total = kernel + user + idle + iowait; guest (inside user) and utilization are left out
|
||||
self.assertEqual(usage, {"usage": 70.0, "sample": (130, 200, 105.0), "window": 5.0})
|
||||
|
||||
def test_over_one_second_without_a_sample(self, clock):
|
||||
clock.time.side_effect = [10.0, 11.0]
|
||||
h = host((100, 100), (110, 190))
|
||||
self.assertEqual(h.get_cpu_usage(), {"usage": 90.0, "sample": (110, 300, 11.0), "window": 1.0})
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
|
||||
def test_over_one_second_after_the_host_restarted(self, clock):
|
||||
clock.time.side_effect = [10.0, 11.0]
|
||||
h = host((5, 5), (6, 9))
|
||||
usage = h.get_cpu_usage(previous=(1000, 5000, 5.0))
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
self.assertEqual((usage["usage"], usage["window"]), (80.0, 1.0))
|
||||
|
||||
|
||||
class GraphTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.compute = Compute.objects.create(name="graph", hostname="192.0.2.1", login="u", password="p", type=1)
|
||||
self.other = Compute.objects.create(name="other", hostname="192.0.2.2", login="u", password="p", type=1)
|
||||
admin = get_user_model().objects.create_superuser("graph-admin", "graph@example.com", "pw")
|
||||
self.client.force_login(admin)
|
||||
for target in ("computes.views.wvmConnect", "computes.views.wvmHostDetails"):
|
||||
patcher = patch(target)
|
||||
self.addCleanup(patcher.stop)
|
||||
self.conn = patcher.start().return_value # the graph reads through wvmHostDetails
|
||||
self.conn.get_memory_usage.return_value = {"total": 4, "usage": 1, "percent": 25}
|
||||
self.conn.get_cpu_usage.return_value = {"usage": 12.5, "sample": (10, 20, 100.0), "window": 5.04}
|
||||
|
||||
def graph(self, compute=None, **params):
|
||||
response = self.client.get(reverse("compute_graph", args=[(compute or self.compute).id]), params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return json.loads(response.content)
|
||||
|
||||
def previous(self):
|
||||
return self.conn.get_cpu_usage.call_args.kwargs["previous"]
|
||||
|
||||
def test_the_answer_carries_the_sample_for_the_next_poll(self):
|
||||
data = self.graph()
|
||||
self.assertIsNone(self.previous())
|
||||
self.assertEqual((data["cpudata"], data["window"], data["memdata"]["percent"]), (12.5, 5.0, 25))
|
||||
self.graph(cpu_sample=data["cpu_sample"])
|
||||
self.assertEqual(self.previous(), (10, 20, 100.0))
|
||||
|
||||
def test_an_altered_old_or_foreign_sample_is_not_used(self):
|
||||
token = self.graph()["cpu_sample"]
|
||||
foreign = self.graph(self.other)["cpu_sample"]
|
||||
altered = CPU_SAMPLE_SIGNER.sign_object({"compute": self.compute.id, "idle": 0, "total": 1, "time": 0})[:-2] + "xx"
|
||||
for name, sample in (("altered", altered), ("garbage", "x"), ("other compute", foreign)):
|
||||
with self.subTest(name):
|
||||
self.graph(cpu_sample=sample)
|
||||
self.assertIsNone(self.previous())
|
||||
with patch("django.core.signing.time.time", return_value=signing.time.time() + CPU_SAMPLE_MAX_AGE + 1):
|
||||
self.graph(cpu_sample=token)
|
||||
self.assertIsNone(self.previous())
|
||||
|
||||
def test_an_unreachable_host_gives_no_cpu_value_and_no_sample(self):
|
||||
self.conn.get_cpu_usage.side_effect = libvirtError("unreachable")
|
||||
data = self.graph()
|
||||
self.assertEqual((data["cpudata"], data["memdata"]["total"]), (None, None))
|
||||
self.assertNotIn("cpu_sample", data)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
class GraphScriptTestCase(TestCase):
|
||||
"""The overview page's script sends each answer's sample with the next poll."""
|
||||
|
||||
def test_the_next_poll_sends_the_previous_sample(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("gs-admin", "gs@example.com", "pw"))
|
||||
compute = Compute.objects.create(name="gs", hostname="192.0.2.1", login="u", password="p", type=1)
|
||||
with patch("computes.views.wvmHostDetails") as details, patch.object(
|
||||
Compute, "status", new_callable=PropertyMock, return_value=True
|
||||
):
|
||||
details.return_value.get_node_info.return_value = ("h", "x86_64", 1024, 2, "cpu", "qemu+tcp://h/system")
|
||||
details.return_value.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50}
|
||||
page = self.client.get(reverse("overview", args=[compute.id])).content.decode()
|
||||
script = next(s for s in html.fromstring(page).xpath("//script[not(@src)]/text()") if "pollWhileVisible(" in s)
|
||||
js = """
|
||||
const vm = require("vm");
|
||||
const charts = [], requests = [];
|
||||
let poll;
|
||||
const page = {
|
||||
document: {getElementById: () => ({getContext: () => ({})})},
|
||||
// Chart.js adds the lists a config leaves out
|
||||
Chart: function (ctx, config) {
|
||||
this.data = Object.assign({labels: []}, config.data);
|
||||
this.data.datasets.forEach((set) => { set.data = set.data || []; });
|
||||
this.options = config.options;
|
||||
this.update = () => {};
|
||||
charts.push(this);
|
||||
},
|
||||
$: {getJSON: (url, params) => { requests.push(params); return {}; }},
|
||||
pollWhileVisible: (interval, request, onData) => { poll = {request, onData}; },
|
||||
};
|
||||
vm.runInNewContext(SCRIPT, vm.createContext(page));
|
||||
const answer = (cpu, sample) => ({cpudata: cpu, window: 5.1, cpu_sample: sample, timeline: "t",
|
||||
memdata: {total: 1048576, usage: 0}});
|
||||
poll.request();
|
||||
poll.onData(answer(3, "token-1"));
|
||||
poll.request();
|
||||
poll.onData({cpudata: null, memdata: {total: null, usage: null}, timeline: "t"});
|
||||
poll.request();
|
||||
const label = charts[0].options.tooltips.callbacks.label({datasetIndex: 0, index: 0, yLabel: 3}, charts[0].data);
|
||||
console.log(JSON.stringify({requests, label, cpu: charts[0].data.datasets[0].data,
|
||||
mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.yAxes[0].ticks.max}));
|
||||
""".replace("SCRIPT", json.dumps(script))
|
||||
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
result = json.loads(out.stdout)
|
||||
self.assertEqual(result["requests"], [{}, {"cpu_sample": "token-1"}, {}])
|
||||
self.assertEqual(result["cpu"], [3, None])
|
||||
# a host that could not be read leaves a gap and keeps the memory axis
|
||||
self.assertEqual((result["mem"], result["memMax"]), ([0, None], 1))
|
||||
self.assertTrue(result["label"].endswith(": 3 % (average over 5.1 s)"), result["label"])
|
||||
57
computes/tests/test_migration_address.py
Normal file
57
computes/tests/test_migration_address.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""A compute's migration address: where other hosts send a migrating VM's
|
||||
memory. Empty means libvirt's default (the destination's own hostname)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from computes.models import Compute
|
||||
from computes.validators import validate_migration_address
|
||||
from instances.models import Instance
|
||||
from instances.utils import migrate_instance
|
||||
|
||||
|
||||
class MigrationAddressTests(TestCase):
|
||||
def test_accepted_and_refused_values(self):
|
||||
for value in ("192.0.2.12", "kvm102.example.org", "kvm-102", "2001:db8::12",
|
||||
"kvm102.example.org."):
|
||||
with self.subTest(value=value):
|
||||
validate_migration_address(value)
|
||||
for value in ("tcp://10.0.0.1", "10.0.0.1:49152", "[2001:db8::12]", "host name", "-bad", "a..b", "10.0.0.1/24", "x;y",
|
||||
"2001:db8::12%eth0", "2001:db8::12%bad]:49152", "a.."):
|
||||
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||
validate_migration_address(value)
|
||||
|
||||
def test_migration_uri(self):
|
||||
cases = {"": None, "192.0.2.12": "tcp://192.0.2.12", "kvm102.example.org": "tcp://kvm102.example.org",
|
||||
"2001:db8::12": "tcp://[2001:db8::12]"}
|
||||
for address, uri in cases.items():
|
||||
with self.subTest(address=address):
|
||||
self.assertEqual(Compute(migration_address=address).migration_uri, uri)
|
||||
|
||||
def test_the_compute_form_saves_it(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("ma-admin", "ma@example.com", "pw"))
|
||||
compute = Compute.objects.create(name="ma", hostname="10.0.0.9", login="root", password="", type=2)
|
||||
data = {"name": "ma", "hostname": "10.0.0.9", "login": "root", "type": 2, "details": "", "migration_address": "198.51.100.9"}
|
||||
self.client.post(reverse("compute_update", args=[compute.id]), data)
|
||||
compute.refresh_from_db()
|
||||
self.assertEqual(compute.migration_address, "198.51.100.9")
|
||||
self.client.post(reverse("compute_update", args=[compute.id]), {**data, "migration_address": "tcp://x"})
|
||||
compute.refresh_from_db()
|
||||
self.assertEqual(compute.migration_address, "198.51.100.9")
|
||||
|
||||
def test_a_migration_uses_the_destination_address(self):
|
||||
source = Compute.objects.create(name="ma-src", hostname="10.0.0.8", login="root", password="", type=2)
|
||||
dest = Compute.objects.create(name="ma-dst", hostname="kvm9", login="root", password="", type=2,
|
||||
migration_address="198.51.100.9")
|
||||
vm = Instance.objects.create(compute=source, name="vm", uuid="u-ma")
|
||||
user = get_user_model().objects.create_superuser("ma-mig", "mm@example.com", "pw")
|
||||
with patch.object(Compute, "status", True), \
|
||||
patch("instances.utils.wvmInstances") as instances, \
|
||||
patch("instances.utils.wvmInstance"), \
|
||||
patch.object(Instance, "proxy", MagicMock()):
|
||||
migrate_instance(dest, vm, user, live=True)
|
||||
self.assertEqual(instances.return_value.moveto.call_args.kwargs["uri"], "tcp://198.51.100.9")
|
||||
60
computes/tests/test_operation_locks.py
Normal file
60
computes/tests/test_operation_locks.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from computes.utils import libvirt_compute_lock, libvirt_instance_lock
|
||||
|
||||
|
||||
class OperationLocksTestCase(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.compute = SimpleNamespace(pk=987654321)
|
||||
self.first = SimpleNamespace(pk=987654321, compute=self.compute)
|
||||
self.second = SimpleNamespace(pk=987654322, compute=self.compute)
|
||||
|
||||
def test_sibling_vm_can_run_while_same_vm_and_reconcile_are_excluded(self):
|
||||
acquired = threading.Event()
|
||||
release = threading.Event()
|
||||
errors = []
|
||||
|
||||
def hold_first_vm():
|
||||
try:
|
||||
with libvirt_instance_lock(self.first):
|
||||
acquired.set()
|
||||
if not release.wait(5):
|
||||
raise TimeoutError("Test did not release the VM lock")
|
||||
except Exception as err:
|
||||
errors.append(err)
|
||||
|
||||
worker = threading.Thread(target=hold_first_vm)
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(acquired.wait(2))
|
||||
with libvirt_instance_lock(self.second, timeout=0.1):
|
||||
pass
|
||||
with self.assertRaises(TimeoutError):
|
||||
with libvirt_instance_lock(self.first, timeout=0.05):
|
||||
pass
|
||||
with self.assertRaises(TimeoutError):
|
||||
with libvirt_compute_lock(self.compute, timeout=0.05):
|
||||
pass
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(5)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(errors, [])
|
||||
with libvirt_compute_lock(self.compute, timeout=0.1):
|
||||
pass
|
||||
|
||||
def test_instance_lock_is_reentrant_without_releasing_the_outer_lock(self):
|
||||
with libvirt_instance_lock(self.first):
|
||||
with libvirt_instance_lock(self.first):
|
||||
pass
|
||||
with self.assertRaisesRegex(RuntimeError, "Cannot upgrade"):
|
||||
with libvirt_compute_lock(self.compute, timeout=0.05):
|
||||
pass
|
||||
|
||||
def test_compute_exclusive_owner_can_take_an_instance_lock(self):
|
||||
with libvirt_compute_lock(self.compute):
|
||||
with libvirt_instance_lock(self.first):
|
||||
pass
|
||||
37
computes/tests/test_overview_status.py
Normal file
37
computes/tests/test_overview_status.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from computes.models import Compute
|
||||
|
||||
ERROR = "Connection Failed: Host key verification failed."
|
||||
|
||||
|
||||
class OverviewStatusTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(
|
||||
get_user_model().objects.create_superuser(username="ov-admin", password="pwd", email="ov@example.com")
|
||||
)
|
||||
# an ssh_config alias: not a name a TCP probe can reach
|
||||
self.compute = Compute.objects.create(name="ov-alias", hostname="wvc-alias", login="root", password="", type=2)
|
||||
|
||||
@patch("vrtManager.connection.connection_manager.host_is_up", return_value=False)
|
||||
@patch("computes.views.wvmHostDetails")
|
||||
def test_overview_state_comes_from_the_connection(self, host_details, host_is_up):
|
||||
conn = host_details.return_value
|
||||
conn.get_node_info.return_value = ("host", "x86_64", 1024, 2, "cpu", "qemu+ssh://root@wvc-alias/system")
|
||||
conn.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50}
|
||||
with patch.object(Compute, "status", new_callable=PropertyMock, return_value=True):
|
||||
response = self.client.get(reverse("overview", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["status"], "true")
|
||||
|
||||
def test_compute_list_shows_why_a_host_is_not_connected(self):
|
||||
with patch.object(Compute, "status", new_callable=PropertyMock, return_value=False), patch.object(
|
||||
Compute, "connection_error", new_callable=PropertyMock, return_value=ERROR
|
||||
):
|
||||
response = self.client.get(reverse("computes"))
|
||||
self.assertContains(response, "Not Connected")
|
||||
self.assertContains(response, ERROR)
|
||||
113
computes/tests/test_ownership.py
Normal file
113
computes/tests/test_ownership.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Ownership survives a VM's temporary disappearance or an out-of-band move."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from computes.utils import refresh_instance_database
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from instances.models import Instance, InstanceTombstone
|
||||
|
||||
UUID = "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
|
||||
class Domain:
|
||||
def __init__(self, name, uuid):
|
||||
self._name, self._uuid = name, uuid
|
||||
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
def UUIDString(self):
|
||||
return self._uuid
|
||||
|
||||
|
||||
class OwnershipTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = get_user_model().objects.create_user("owner", password="pw")
|
||||
self.host_a = self.compute("host-a")
|
||||
self.host_b = self.compute("host-b")
|
||||
|
||||
def compute(self, name):
|
||||
compute = Compute.objects.create(name=name, hostname=f"{name}.example", login="", password="", type=1)
|
||||
compute.__dict__["proxy"] = MagicMock()
|
||||
compute.__dict__["status"] = True # reachable
|
||||
self.on(compute, [])
|
||||
return compute
|
||||
|
||||
def on(self, compute, domains):
|
||||
compute.proxy.wvm.listAllDomains.return_value = [Domain(n, u) for n, u in domains]
|
||||
|
||||
def owned_vm(self, compute):
|
||||
vm = Instance.objects.create(compute=compute, name="vm", uuid=UUID, is_template=True)
|
||||
UserInstance.objects.create(instance=vm, user=self.owner, is_change=True, is_vnc=True)
|
||||
return vm
|
||||
|
||||
def ownership(self, compute):
|
||||
vm = Instance.objects.get(compute=compute, uuid=UUID)
|
||||
return vm.is_template, [(ui.user, ui.is_change, ui.is_delete, ui.is_vnc) for ui in vm.userinstance_set.all()]
|
||||
|
||||
def test_vm_that_disappears_and_returns_keeps_its_owners(self):
|
||||
self.owned_vm(self.host_a)
|
||||
refresh_instance_database(self.host_a) # undefined for a while
|
||||
self.assertFalse(Instance.objects.filter(uuid=UUID).exists())
|
||||
|
||||
self.on(self.host_a, [("vm", UUID)]) # defined again
|
||||
refresh_instance_database(self.host_a)
|
||||
|
||||
self.assertEqual(self.ownership(self.host_a), (True, [(self.owner, True, False, True)]))
|
||||
self.assertFalse(InstanceTombstone.objects.exists())
|
||||
|
||||
def test_move_seen_on_the_new_host_first(self):
|
||||
self.owned_vm(self.host_a)
|
||||
self.on(self.host_a, [("vm", UUID)])
|
||||
self.on(self.host_b, [("vm", UUID)])
|
||||
refresh_instance_database(self.host_b) # still listed on A: a copy for now
|
||||
self.on(self.host_a, [])
|
||||
refresh_instance_database(self.host_a) # gone from A: it moved to B
|
||||
|
||||
self.assertEqual(self.ownership(self.host_b), (True, [(self.owner, True, False, True)]))
|
||||
self.assertFalse(Instance.objects.filter(compute=self.host_a).exists())
|
||||
|
||||
def test_move_seen_on_the_old_host_first(self):
|
||||
self.owned_vm(self.host_a)
|
||||
refresh_instance_database(self.host_a)
|
||||
self.on(self.host_b, [("vm", UUID)])
|
||||
refresh_instance_database(self.host_b)
|
||||
|
||||
self.assertEqual(self.ownership(self.host_b), (True, [(self.owner, True, False, True)]))
|
||||
|
||||
def test_copied_uuid_grants_nothing(self):
|
||||
self.owned_vm(self.host_a)
|
||||
self.on(self.host_a, [("vm", UUID)])
|
||||
self.on(self.host_b, [("vm-copy", UUID)])
|
||||
refresh_instance_database(self.host_a)
|
||||
refresh_instance_database(self.host_b)
|
||||
|
||||
self.assertEqual(self.ownership(self.host_a), (True, [(self.owner, True, False, True)]))
|
||||
self.assertEqual(self.ownership(self.host_b), (False, []))
|
||||
|
||||
@override_settings(INSTANCE_OWNERSHIP_RETENTION_DAYS=30)
|
||||
def test_ownership_is_kept_for_a_bounded_time(self):
|
||||
self.owned_vm(self.host_a)
|
||||
refresh_instance_database(self.host_a)
|
||||
InstanceTombstone.objects.update(removed=timezone.now() - timedelta(days=31))
|
||||
|
||||
self.on(self.host_a, [("vm", UUID)])
|
||||
refresh_instance_database(self.host_a)
|
||||
|
||||
self.assertEqual(self.ownership(self.host_a), (False, []))
|
||||
self.assertFalse(InstanceTombstone.objects.exists())
|
||||
|
||||
def test_deleted_users_are_skipped(self):
|
||||
self.owned_vm(self.host_a)
|
||||
refresh_instance_database(self.host_a)
|
||||
self.owner.delete()
|
||||
|
||||
self.on(self.host_a, [("vm", UUID)])
|
||||
refresh_instance_database(self.host_a)
|
||||
|
||||
self.assertEqual(self.ownership(self.host_a), (True, []))
|
||||
26
computes/tests/test_quota_down_host.py
Normal file
26
computes/tests/test_quota_down_host.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import socket
|
||||
|
||||
from accounts.models import UserAttributes, UserInstance
|
||||
from appsettings.settings import get_settings
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from instances.models import Instance
|
||||
from instances.utils import QUOTA_UNVERIFIED, check_user_quota
|
||||
|
||||
|
||||
class QuotaWithDownHostTestCase(TestCase):
|
||||
def test_vm_on_an_unreachable_host_makes_the_quota_unverified(self):
|
||||
# its usage is unknown, so an increase is refused (fail closed), without an error page
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
user = get_user_model().objects.create_user("quota_down", password="pw")
|
||||
UserAttributes.objects.create(user=user, max_instances=5, max_cpus=8, max_memory=8192, max_disk_size=100)
|
||||
compute = Compute.objects.create(name="down", hostname=f"127.0.0.1:{port}", login="", password="", type=1)
|
||||
vm = Instance.objects.create(compute=compute, name="vm", uuid="55555555-5555-5555-5555-555555555555")
|
||||
UserInstance.objects.create(user=user, instance=vm)
|
||||
get_settings() # app settings are normally loaded by middleware
|
||||
|
||||
self.assertEqual(check_user_quota(user, 1, 1, 128, 1), QUOTA_UNVERIFIED)
|
||||
183
computes/tests/test_reconciliation.py
Normal file
183
computes/tests/test_reconciliation.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from computes.utils import refresh_instance_database
|
||||
from instances.models import Instance
|
||||
from instances.utils import refr
|
||||
|
||||
|
||||
class MockDomain:
|
||||
def __init__(self, name, uuid_str):
|
||||
self._name = name
|
||||
self._uuid_str = uuid_str
|
||||
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
def UUIDString(self):
|
||||
return self._uuid_str
|
||||
|
||||
|
||||
class InstanceReconciliationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username="test_reconcile_user", password="password")
|
||||
self.compute = Compute.objects.create(
|
||||
name="reconcile-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
|
||||
def test_new_domain_created_in_libvirt_is_added_to_db(self):
|
||||
dom1 = MockDomain(name="vm-alpha", uuid_str="11111111-1111-1111-1111-111111111111")
|
||||
dom2 = MockDomain(name="vm-beta", uuid_str="22222222-2222-2222-2222-222222222222")
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.return_value = [dom1, dom2]
|
||||
self.compute.__dict__["proxy"] = mock_proxy
|
||||
self.compute.__dict__["status"] = True # reachable
|
||||
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
instances = Instance.objects.filter(compute=self.compute).order_by("name")
|
||||
self.assertEqual(instances.count(), 2)
|
||||
self.assertEqual(instances[0].name, "vm-alpha")
|
||||
self.assertEqual(instances[0].uuid, "11111111-1111-1111-1111-111111111111")
|
||||
self.assertEqual(instances[1].name, "vm-beta")
|
||||
self.assertEqual(instances[1].uuid, "22222222-2222-2222-2222-222222222222")
|
||||
|
||||
def test_domain_deleted_in_libvirt_is_deleted_from_db(self):
|
||||
inst = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="vm-to-delete",
|
||||
uuid="33333333-3333-3333-3333-333333333333",
|
||||
)
|
||||
UserInstance.objects.create(instance=inst, user=self.user)
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.return_value = []
|
||||
self.compute.__dict__["proxy"] = mock_proxy
|
||||
self.compute.__dict__["status"] = True # reachable
|
||||
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
self.assertFalse(Instance.objects.filter(compute=self.compute, uuid="33333333-3333-3333-3333-333333333333").exists())
|
||||
self.assertFalse(UserInstance.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_domain_renamed_in_libvirt_preserves_userinstance_and_id(self):
|
||||
uuid = "44444444-4444-4444-4444-444444444444"
|
||||
inst = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="vm-original-name",
|
||||
uuid=uuid,
|
||||
)
|
||||
original_inst_id = inst.id
|
||||
user_inst = UserInstance.objects.create(instance=inst, user=self.user, is_change=True)
|
||||
|
||||
dom_renamed = MockDomain(name="vm-new-name", uuid_str=uuid)
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.return_value = [dom_renamed]
|
||||
self.compute.__dict__["proxy"] = mock_proxy
|
||||
self.compute.__dict__["status"] = True # reachable
|
||||
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
inst.refresh_from_db()
|
||||
self.assertEqual(inst.id, original_inst_id)
|
||||
self.assertEqual(inst.name, "vm-new-name")
|
||||
self.assertEqual(inst.uuid, uuid)
|
||||
|
||||
# Verify UserInstance still exists and still points to the same instance!
|
||||
user_inst.refresh_from_db()
|
||||
self.assertEqual(user_inst.instance_id, original_inst_id)
|
||||
self.assertTrue(user_inst.is_change)
|
||||
|
||||
def test_libvirt_connection_error_leaves_db_untouched(self):
|
||||
inst = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="vm-persistent",
|
||||
uuid="55555555-5555-5555-5555-555555555555",
|
||||
)
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.side_effect = Exception("Libvirt socket connection refused")
|
||||
self.compute.__dict__["proxy"] = mock_proxy
|
||||
self.compute.__dict__["status"] = True # reachable
|
||||
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
# Instance should NOT be deleted!
|
||||
self.assertTrue(Instance.objects.filter(id=inst.id).exists())
|
||||
|
||||
def test_broken_domain_metadata_aborts_sync_safely(self):
|
||||
inst = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="vm-persistent-2",
|
||||
uuid="66666666-6666-6666-6666-666666666666",
|
||||
)
|
||||
|
||||
broken_dom = MagicMock()
|
||||
broken_dom.UUIDString.side_effect = Exception("Corrupted domain metadata")
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.return_value = [broken_dom]
|
||||
self.compute.__dict__["proxy"] = mock_proxy
|
||||
self.compute.__dict__["status"] = True # reachable
|
||||
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
self.assertTrue(Instance.objects.filter(id=inst.id).exists())
|
||||
|
||||
def test_refr_invokes_refresh_instance_database(self):
|
||||
with patch("computes.utils.refresh_instance_database") as mock_refresh:
|
||||
refr(self.compute)
|
||||
mock_refresh.assert_called_once_with(self.compute)
|
||||
|
||||
|
||||
class ReconciliationTransactionTestCase(TransactionTestCase):
|
||||
"""libvirt is asked outside the DB transaction: on SQLite an open transaction
|
||||
makes every other write of the app fail while a silent host is waited on."""
|
||||
|
||||
def setUp(self):
|
||||
self.compute = Compute.objects.create(name="tx-compute", hostname="127.0.0.1", login="root", password="", type=1)
|
||||
|
||||
def proxy(self, list_domains):
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.side_effect = list_domains
|
||||
self.compute.__dict__["proxy"] = mock_proxy
|
||||
|
||||
def test_libvirt_is_called_outside_the_transaction(self):
|
||||
seen = {}
|
||||
|
||||
def status():
|
||||
seen["status"] = connection.in_atomic_block
|
||||
return True
|
||||
|
||||
def list_domains():
|
||||
seen["list"] = connection.in_atomic_block
|
||||
return [MockDomain(name="vm-tx", uuid_str="77777777-7777-7777-7777-777777777777")]
|
||||
|
||||
self.proxy(list_domains)
|
||||
with patch.object(Compute, "status", new_callable=PropertyMock, side_effect=status):
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
self.assertEqual(seen, {"status": False, "list": False})
|
||||
self.assertTrue(Instance.objects.filter(compute=self.compute, name="vm-tx").exists())
|
||||
|
||||
def test_compute_deleted_while_listing_gets_no_instances(self):
|
||||
def list_domains():
|
||||
Compute.objects.filter(pk=self.compute.pk).delete()
|
||||
return [MockDomain(name="vm-orphan", uuid_str="88888888-8888-8888-8888-888888888888")]
|
||||
|
||||
self.proxy(list_domains)
|
||||
self.compute.__dict__["status"] = True
|
||||
refresh_instance_database(self.compute)
|
||||
|
||||
self.assertFalse(Instance.objects.filter(uuid="88888888-8888-8888-8888-888888888888").exists())
|
||||
53
computes/tests/test_validators.py
Normal file
53
computes/tests/test_validators.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""A compute's hostname and login go unquoted into libvirt connection URIs
|
||||
and an ssh command line; they are validated so neither can inject a URI
|
||||
parameter (e.g. ?command=, ?no_verify=) or an ssh option."""
|
||||
|
||||
import unittest
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from computes.validators import validate_hostname, validate_login, validate_name
|
||||
|
||||
|
||||
class HostnameValidatorTestCase(unittest.TestCase):
|
||||
def test_accepts_ipv4_and_host_names(self):
|
||||
for value in ("192.0.2.10", "localhost", "host.example.com", "a-b.example.", "host1"):
|
||||
with self.subTest(value=value):
|
||||
validate_hostname(value)
|
||||
|
||||
def test_rejects_uri_and_option_injection(self):
|
||||
for value in (
|
||||
"-x",
|
||||
"a b;c",
|
||||
"host:2222",
|
||||
"192.0.2.1/system?command=/evil.sh&a=",
|
||||
"host/system?no_verify=1",
|
||||
"qemu+ssh://host/system",
|
||||
"2001:db8::1",
|
||||
"",
|
||||
"host name",
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_hostname(value)
|
||||
|
||||
|
||||
class LoginValidatorTestCase(unittest.TestCase):
|
||||
def test_accepts_plain_logins_and_empty(self):
|
||||
# empty: TCP falls back to server auth, the socket transport ignores it
|
||||
for value in ("", "root", "libvirt.user", "user-1", "_svc"):
|
||||
with self.subTest(value=value):
|
||||
validate_login(value)
|
||||
|
||||
def test_rejects_option_and_delimiter_injection(self):
|
||||
for value in ("-oProxyCommand=x", "u@realm", "a/b", "a b", "a\\b", "a%b"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_login(value)
|
||||
|
||||
|
||||
class NameValidatorTestCase(unittest.TestCase):
|
||||
def test_checks_the_value(self):
|
||||
validate_name("good-name_1.2")
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_name("bad name!")
|
||||
359
computes/tests/test_views.py
Normal file
359
computes/tests/test_views.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import os
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.shortcuts import reverse
|
||||
from django.test import RequestFactory, TestCase, TransactionTestCase
|
||||
|
||||
from vrtManager.connection import CONN_SOCKET, connection_manager
|
||||
from computes.models import Compute
|
||||
|
||||
TEST_COMPUTE_HOST = os.environ.get("TEST_LIBVIRT_HOST", "localhost")
|
||||
TEST_COMPUTE_LOGIN = os.environ.get("TEST_LIBVIRT_LOGIN", "")
|
||||
TEST_COMPUTE_PASSWORD = os.environ.get("TEST_LIBVIRT_PASSWORD", "")
|
||||
TEST_COMPUTE_TYPE = int(os.environ.get("TEST_LIBVIRT_TYPE", CONN_SOCKET))
|
||||
|
||||
|
||||
class ComputesTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client.login(request=RequestFactory().get("/"), username="admin", password="admin")
|
||||
self.compute, _ = Compute.objects.get_or_create(
|
||||
name="computes-test-compute",
|
||||
defaults={
|
||||
"hostname": TEST_COMPUTE_HOST,
|
||||
"login": TEST_COMPUTE_LOGIN,
|
||||
"password": TEST_COMPUTE_PASSWORD,
|
||||
"details": "test",
|
||||
"type": TEST_COMPUTE_TYPE,
|
||||
},
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
Compute.objects.filter(name="computes-test-compute").delete()
|
||||
super().tearDown()
|
||||
|
||||
def _require_live_compute(self):
|
||||
try:
|
||||
conn = connection_manager.get_connection(
|
||||
self.compute.hostname,
|
||||
self.compute.login,
|
||||
self.compute.password,
|
||||
self.compute.type,
|
||||
)
|
||||
if not (conn and conn.isAlive()):
|
||||
self.skipTest("Live libvirt compute host not available")
|
||||
except Exception:
|
||||
self.skipTest("Live libvirt compute host not available")
|
||||
|
||||
def test_index(self):
|
||||
response = self.client.get(reverse("computes"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_create_update_delete(self):
|
||||
response = self.client.get(reverse("add_socket_host"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("add_socket_host"),
|
||||
{
|
||||
"name": "l1",
|
||||
"details": "Created",
|
||||
"hostname": "localhost",
|
||||
"type": 4,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("computes"))
|
||||
|
||||
compute = Compute.objects.get(name="l1")
|
||||
self.assertEqual(compute.name, "l1")
|
||||
self.assertEqual(compute.details, "Created")
|
||||
created_id = compute.id
|
||||
|
||||
response = self.client.get(reverse("compute_update", args=[created_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("compute_update", args=[created_id]),
|
||||
{
|
||||
"name": "l2",
|
||||
"details": "Updated",
|
||||
"hostname": "localhost",
|
||||
"type": 4,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("computes"))
|
||||
|
||||
compute = Compute.objects.get(id=created_id)
|
||||
self.assertEqual(compute.name, "l2")
|
||||
self.assertEqual(compute.details, "Updated")
|
||||
|
||||
response = self.client.get(reverse("compute_delete", args=[created_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("compute_delete", args=[created_id]))
|
||||
self.assertRedirects(response, reverse("computes"))
|
||||
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
Compute.objects.get(id=created_id)
|
||||
|
||||
def test_overview(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("overview", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_graph(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("compute_graph", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_instances(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("instances", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_storages(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("storages", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_default_storage_volumes(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(
|
||||
reverse("volumes", kwargs={"compute_id": self.compute.id, "pool": "default"})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_default_storage(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(
|
||||
reverse("storage", kwargs={"compute_id": self.compute.id, "pool": "default"})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_networks(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("networks", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_default_network(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(
|
||||
reverse("network", kwargs={"compute_id": self.compute.id, "pool": "default"})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_interfaces(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("interfaces", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_nwfilters(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("nwfilters", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_secrets(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(reverse("virtsecrets", args=[self.compute.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_machines(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(
|
||||
reverse("machines", kwargs={"compute_id": self.compute.id, "arch": "x86_64"})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_compute_disk_buses(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"buses",
|
||||
kwargs={
|
||||
"compute_id": self.compute.id,
|
||||
"arch": "x86_64",
|
||||
"machine": "pc",
|
||||
"disk": "disk",
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_dom_capabilities(self):
|
||||
self._require_live_compute()
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"domcaps", kwargs={"compute_id": self.compute.id, "arch": "x86_64", "machine": "pc"}
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ComputeConcurrencyTestCase(TransactionTestCase):
|
||||
def test_concurrent_refresh_instance_database(self):
|
||||
import threading
|
||||
from django.db import connection
|
||||
from unittest.mock import MagicMock
|
||||
from computes.utils import refresh_instance_database
|
||||
from instances.models import Instance
|
||||
|
||||
compute = Compute.objects.create(
|
||||
name="concurrent-test",
|
||||
hostname="127.0.0.1",
|
||||
login="test",
|
||||
password="pwd",
|
||||
type=CONN_SOCKET,
|
||||
)
|
||||
|
||||
mock_dom1 = MagicMock()
|
||||
mock_dom1.UUIDString.return_value = "11111111-2222-3333-4444-555555555555"
|
||||
mock_dom1.name.return_value = "vm-concurrent-1"
|
||||
|
||||
mock_dom2 = MagicMock()
|
||||
mock_dom2.UUIDString.return_value = "22222222-3333-4444-5555-666666666666"
|
||||
mock_dom2.name.return_value = "vm-concurrent-2"
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.return_value = [mock_dom1, mock_dom2]
|
||||
compute.proxy = mock_proxy
|
||||
compute.status = True # reachable
|
||||
|
||||
exceptions = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
refresh_instance_database(compute)
|
||||
except Exception as e:
|
||||
exceptions.append(e)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
connection.close()
|
||||
threads = [threading.Thread(target=worker) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(exceptions, [])
|
||||
self.assertEqual(Instance.objects.filter(compute=compute).count(), 2)
|
||||
|
||||
def test_refresh_fails_closed_on_lock_error(self):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from computes.utils import refresh_instance_database
|
||||
from instances.models import Instance
|
||||
|
||||
compute = Compute.objects.create(
|
||||
name="lock-fail-test",
|
||||
hostname="127.0.0.1",
|
||||
login="test",
|
||||
password="pwd",
|
||||
type=CONN_SOCKET,
|
||||
)
|
||||
Instance.objects.create(compute=compute, name="existing-vm", uuid="existing-uuid-1234")
|
||||
|
||||
# Libvirt returns empty list - if it did not fail closed, existing-vm would be deleted!
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.wvm.listAllDomains.return_value = []
|
||||
compute.proxy = mock_proxy
|
||||
compute.status = True # reachable
|
||||
|
||||
# 1. When lock directory cannot be acquired
|
||||
with patch("computes.utils._get_lock_directory", return_value=None):
|
||||
refresh_instance_database(compute)
|
||||
self.assertEqual(Instance.objects.filter(compute=compute).count(), 1)
|
||||
|
||||
# 2. When lock file is a symlink
|
||||
with patch("os.path.islink", return_value=True):
|
||||
refresh_instance_database(compute)
|
||||
self.assertEqual(Instance.objects.filter(compute=compute).count(), 1)
|
||||
|
||||
# 3. When lock file cannot be opened
|
||||
with patch("os.open", side_effect=OSError("Permission denied")):
|
||||
refresh_instance_database(compute)
|
||||
self.assertEqual(Instance.objects.filter(compute=compute).count(), 1)
|
||||
|
||||
# 4. When flock times out
|
||||
curr_time = [0.0]
|
||||
def advance_time():
|
||||
curr_time[0] += 20.0
|
||||
return curr_time[0]
|
||||
|
||||
with patch("fcntl.flock", side_effect=BlockingIOError("Resource temporarily unavailable")):
|
||||
with patch("time.monotonic", side_effect=advance_time):
|
||||
refresh_instance_database(compute)
|
||||
self.assertEqual(Instance.objects.filter(compute=compute).count(), 1)
|
||||
|
||||
def test_libvirt_compute_lock_thread_timeout(self):
|
||||
from computes.utils import libvirt_compute_lock, _get_compute_thread_lock
|
||||
compute = Compute.objects.create(
|
||||
name="thread-lock-test",
|
||||
hostname="127.0.0.1",
|
||||
login="test",
|
||||
password="pwd",
|
||||
type=CONN_SOCKET,
|
||||
)
|
||||
tlock = _get_compute_thread_lock(compute.id)
|
||||
tlock.acquire()
|
||||
try:
|
||||
with self.assertRaises(TimeoutError):
|
||||
with libvirt_compute_lock(compute, timeout=0.05):
|
||||
pass
|
||||
finally:
|
||||
tlock.release()
|
||||
|
||||
def test_libvirt_compute_lock_reentrant(self):
|
||||
from computes.utils import libvirt_compute_lock
|
||||
compute = Compute.objects.create(
|
||||
name="reentrant-lock-test",
|
||||
hostname="127.0.0.1",
|
||||
login="test",
|
||||
password="pwd",
|
||||
type=CONN_SOCKET,
|
||||
)
|
||||
executed = []
|
||||
with libvirt_compute_lock(compute, timeout=1.0):
|
||||
executed.append("outer")
|
||||
with libvirt_compute_lock(compute, timeout=1.0):
|
||||
executed.append("inner")
|
||||
with libvirt_compute_lock(compute, timeout=1.0):
|
||||
executed.append("deep")
|
||||
self.assertEqual(executed, ["outer", "inner", "deep"])
|
||||
|
||||
|
||||
class ComputeInputValidationTestCase(TestCase):
|
||||
"""hostname and login reach libvirt URIs and ssh unquoted; the form and
|
||||
the API reject values that would inject a URI parameter or an ssh option."""
|
||||
|
||||
def test_form_rejects_injecting_hostname(self):
|
||||
from computes.forms import SshComputeForm
|
||||
|
||||
form = SshComputeForm(
|
||||
data={"name": "c", "hostname": "192.0.2.1/system?command=/evil", "login": "root", "type": 2}
|
||||
)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn("hostname", form.errors)
|
||||
|
||||
def test_api_rejects_injecting_hostname_and_login(self):
|
||||
from computes.api.serializers import ComputeSerializer
|
||||
|
||||
for field, data in (
|
||||
("hostname", {"name": "c", "hostname": "192.0.2.1/system?command=/evil&a=", "login": "root", "type": 2}),
|
||||
("login", {"name": "c", "hostname": "192.0.2.1", "login": "-oProxyCommand=x", "type": 2}),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
serializer = ComputeSerializer(data=data)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn(field, serializer.errors)
|
||||
|
||||
def test_api_accepts_valid_values(self):
|
||||
from computes.api.serializers import ComputeSerializer
|
||||
|
||||
serializer = ComputeSerializer(
|
||||
data={"name": "c", "hostname": "host.example.com", "login": "root", "type": 2}
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
def test_api_accepts_empty_login_for_tcp(self):
|
||||
from computes.api.serializers import ComputeSerializer
|
||||
|
||||
serializer = ComputeSerializer(data={"name": "c", "hostname": "192.0.2.1", "login": "", "type": 1})
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
Loading…
Add table
Add a link
Reference in a new issue