diff --git a/instances/templates/create_instance_w1.html b/instances/templates/create_instance_w1.html index 002e5bc7..47e461b3 100644 --- a/instances/templates/create_instance_w1.html +++ b/instances/templates/create_instance_w1.html @@ -28,7 +28,8 @@
- +
{% trans "The host's machine types for this architecture." %}
@@ -37,7 +38,8 @@
{% trans "Cancel" %} - +
@@ -49,12 +51,12 @@
-
{% csrf_token %} + {% csrf_token %} {% if xml_error %}{% endif %}
-
@@ -65,50 +67,6 @@
{% endblock %} {% block script %} - - - + {% endblock %} diff --git a/instances/templates/create_instance_w2.html b/instances/templates/create_instance_w2.html index 62350a4a..a90d4c66 100644 --- a/instances/templates/create_instance_w2.html +++ b/instances/templates/create_instance_w2.html @@ -27,7 +27,10 @@ {% if vm_form %} + {% if template_restore %}data-template-pool="{{ template_restore.pool }}" data-template="{{ template_restore.template }}"{% endif %} + data-volumes-url="{% url 'volumes' compute_id '__pool__' %}" data-random-mac-url="{% url 'instances:random_mac_address' %}" + data-buses-url="{% url 'buses' compute_id arch machine '__disk__' %}" + data-add-volume="{% trans "Add volume" %}" data-add-network="{% trans "Add network" %}"> {% csrf_token %}{% submission_key %} {% if submitted_lists %}{{ submitted_lists|json_script:"submitted-lists" }}{% endif %} {% include "instances/form_errors.html" with form=vm_form %} @@ -371,250 +374,5 @@ {% block script %} - + {% endblock %} diff --git a/instances/tests/test_create_page_js.py b/instances/tests/test_create_page_js.py index b9309b7a..c5544207 100644 --- a/instances/tests/test_create_page_js.py +++ b/instances/tests/test_create_page_js.py @@ -8,6 +8,7 @@ import subprocess import unittest from unittest.mock import patch +from django.conf import settings from django.test import TestCase from django.urls import reverse from instances.forms import NewVMForm @@ -58,13 +59,16 @@ class TomSelect { } """ -ROWS = ("make_pickers", "new_row", "add_disk_row", "add_network_row", "row_for", "remove_row", "sync_lists", +# the page's script, with the form's data in a stand-in #create-vm-form +SCRIPT = (settings.BASE_DIR / "static/js/create_instance.js").read_text() + +ROWS = ("form_data", "make_pickers", "new_row", "add_disk_row", "add_network_row", "row_for", "remove_row", "sync_lists", "random_mac", "load_buses") -def functions(page, *names): - """The page's script functions of these names, in this order.""" - return "".join(re.search(rf"\n function {name}\(.*?\n }}\n", page, re.S).group(0) for name in names) +def functions(*names): + """The page script's functions of these names, in this order.""" + return "".join(re.search(rf"\nfunction {name}\(.*?\n}}\n", SCRIPT, re.S).group(0) for name in names) def node(script): @@ -84,19 +88,20 @@ class CreatePageScriptTests(TestCase): @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_the_disk_bus_list_is_asked_for_the_page_architecture_and_machine(self): compute, page = self.page() - out = node(f"const COMPUTE = {compute.id}; let asked; const $ = {{getJSON: url => {{ asked = url; }}}};" - + functions(page, "load_list", "load_buses") + buses_url = html.fromstring(page).get_element_by_id("create-vm-form").get("data-buses-url") + out = node(f"const document = {{querySelector: () => ({{dataset: {{busesUrl: {json.dumps(buses_url)}}}}})}};" + "let asked; const $ = {getJSON: url => { asked = url; }};" + + functions("form_data", "load_list", "load_buses") + "load_buses({replaceChildren() {}}, 'cdrom'); console.log(JSON.stringify(asked));") self.assertEqual(out, reverse("buses", args=[compute.id, "i686", "pc", "cdrom"])) @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_a_list_is_empty_while_it_loads_and_a_newer_load_aborts_the_older(self): - compute, page = self.page() - out = node(f"const COMPUTE = {compute.id}; const requests = [];" + out = node("const document = {querySelector: () => ({dataset: {busesUrl: '/__disk__/'}})}; const requests = [];" "const $ = {getJSON: (url, fill) => { const r = {fill, abort() { r.aborted = true; }}; requests.push(r); return r; }};" "const Option = function (text) { this.text = text; };" "const bus = {children: ['ide'], replaceChildren(...items) { this.children = items; }};" - + functions(page, "load_list", "load_buses") + + functions("form_data", "load_list", "load_buses") + "load_buses(bus, 'disk'); const loading = bus.children; load_buses(bus, 'cdrom');" "requests[1].fill({bus: ['sata']});" "console.log(JSON.stringify({loading, aborted: requests.map(r => !!r.aborted), filled: bus.children.map(o => o.text)}));") @@ -123,8 +128,7 @@ class CreatePageScriptTests(TestCase): def run_rows(self, test): """Run the page's row script against the fake DOM, then test.""" - _, page = self.page() - return node(FAKE_DOM + "let images_control, networks_control;" + functions(page, *ROWS) + return node(FAKE_DOM + "let images_control, networks_control;" + functions(*ROWS) + "make_pickers();" + test) @unittest.skipUnless(shutil.which("node"), "node is not installed") @@ -199,9 +203,7 @@ const pressed = () => presets.map(b => [b.attrs["aria-pressed"], [...b.classes]. """ def run_script(self, test): - _, response = render_create_page(self) - page = response.content.decode() - return node(self.HARNESS + functions(page, "show_source", "apply_preset", "clear_presets") + test) + return node(self.HARNESS + functions("show_source", "apply_preset", "clear_presets") + test) @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_only_the_chosen_source_is_shown_and_sent(self): @@ -238,10 +240,8 @@ class NewFormScriptTests(TestCase): template's pool unless the user picks another.""" def script(self, test): - _, response = render_create_page(self) - page = response.content.decode() - return node(FAKE_DOM + "let images_control, networks_control;" + functions(page, *ROWS) - + functions(page, "add_first_network", "select_value", "template_pool_chosen") + "make_pickers();" + test) + return node(FAKE_DOM + "let images_control, networks_control;" + functions(*ROWS) + + functions("add_first_network", "select_value", "template_pool_chosen") + "make_pickers();" + test) @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_a_new_form_starts_on_default_if_the_host_has_it(self): @@ -273,10 +273,8 @@ class RestoreScriptTests(TestCase): def run_restore(self, test, lists=LISTS): """Run restore_submitted against the fake DOM, with loaders that answer when the test calls answer().""" - _, response = render_create_page(self) - page = response.content.decode() harness = FAKE_DOM + "let images_control, networks_control;" \ - + functions(page, *ROWS[:-1], "select_value", "restore_submitted") + """ + + functions(*ROWS[:-1], "select_value", "restore_submitted") + """ templates["submitted-lists"] = {textContent: %s}; $.when = (...loading) => Promise.all(loading); const log = [], answers = []; diff --git a/instances/tests/test_create_wizard_url.py b/instances/tests/test_create_wizard_url.py index 6a140565..4d6b4cb2 100644 --- a/instances/tests/test_create_wizard_url.py +++ b/instances/tests/test_create_wizard_url.py @@ -8,23 +8,33 @@ import shutil import unittest from computes.models import Compute +from django.conf import settings from django.contrib.auth import get_user_model from django.template.loader import render_to_string from django.test import RequestFactory, TestCase +from lxml import html + +SCRIPT = (settings.BASE_DIR / "static/js/create_machine.js").read_text() + + +def function(name): + """The page script's function of this name.""" + return re.search(rf"\nfunction {name}\(.*?\n}}\n", SCRIPT, re.S).group(0) class CreateWizardUrlTests(TestCase): - def render_script(self): + def render_page(self): compute = Compute.objects.create(name="cw", hostname="10.0.0.6", login="u", password="p", type=1) request = RequestFactory().get("/") request.user = get_user_model().objects.create_superuser("cw-admin", "cw@example.com", "pw") - page = render_to_string("create_instance_w1.html", {"compute": compute}, request=request) - # the function that builds the URL from arch and machine - return re.search(r"function create_url\(arch, machine\) \{.*?\n \}", page, re.S).group(0) + return compute, html.fromstring(render_to_string("create_instance_w1.html", {"compute": compute}, request=request)) @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_arch_and_machine_names_land_in_their_segments(self): - script = self.render_script() + _, page = self.render_page() + create_url = page.get_element_by_id("next").get("data-create-url") + script = ("const document = {querySelector: () => ({dataset: {createUrl: %s}})};" % json.dumps(create_url) + + function("create_url")) cases = { ("x86_64", "q35"): "x86_64/q35/", ("ppc64le", "pseries"): "ppc64le/pseries/", # contains "pc" @@ -38,33 +48,25 @@ class CreateWizardUrlTests(TestCase): @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_next_waits_for_a_machine_type(self): - compute = Compute.objects.create(name="cw2", hostname="10.0.0.7", login="u", password="p", type=1) - request = RequestFactory().get("/") - request.user = get_user_model().objects.create_superuser("cw2-admin", "cw2@example.com", "pw") - page = render_to_string("create_instance_w1.html", {"compute": compute, "compute_id": compute.id}, request=request) - function = re.search(r"function load_machines\(arch\) \{.*?\n \}", page, re.S).group(0) + load_machines = function("load_machines") for machines, disabled in (([], True), (["q35"], False)): - js = ("const next = {disabled: false}, select = {replaceChildren() {}};" + js = ("const next = {disabled: false}, select = {dataset: {machinesUrl: '/__arch__/'}, replaceChildren() {}};" "const document = {querySelector: sel => sel === '#next' ? next : select};" "const Option = function () {};" f"const $ = {{getJSON: (url, f) => f({{machines: {machines!r}}})}};" - f"{function} load_machines('x86_64'); console.log(next.disabled);") + f"{load_machines} load_machines('x86_64'); console.log(next.disabled);") out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30) with self.subTest(machines=machines): self.assertEqual(out.stdout.strip(), str(disabled).lower(), out.stderr) @unittest.skipUnless(shutil.which("node"), "node is not installed") def test_a_newer_architecture_aborts_the_older_load(self): - compute = Compute.objects.create(name="cw3", hostname="10.0.0.8", login="u", password="p", type=1) - request = RequestFactory().get("/") - request.user = get_user_model().objects.create_superuser("cw3-admin", "cw3@example.com", "pw") - page = render_to_string("create_instance_w1.html", {"compute": compute, "compute_id": compute.id}, request=request) - function = re.search(r"function load_machines\(arch\) \{.*?\n \}", page, re.S).group(0) - js = ("const next = {}, select = {children: ['pc'], replaceChildren(...items) { this.children = items; }};" + load_machines = function("load_machines") + js = ("const next = {}, select = {dataset: {machinesUrl: '/__arch__/'}, children: ['pc'], replaceChildren(...items) { this.children = items; }};" "const document = {querySelector: sel => sel === '#next' ? next : select};" "const Option = function (text) { this.text = text; }; const requests = [];" "const $ = {getJSON: (url, fill) => { const r = {fill, abort() { r.aborted = true; }}; requests.push(r); return r; }};" - f"{function} load_machines('x86_64'); const loading = select.children; load_machines('aarch64');" + f"{load_machines} load_machines('x86_64'); const loading = select.children; load_machines('aarch64');" "requests[1].fill({machines: ['virt']});" "console.log(JSON.stringify({loading, aborted: requests.map(r => !!r.aborted), filled: select.children.map(o => o.text)}));") out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30) diff --git a/instances/tests/test_xss.py b/instances/tests/test_xss.py index 9c4fc990..459d8e0d 100644 --- a/instances/tests/test_xss.py +++ b/instances/tests/test_xss.py @@ -121,11 +121,11 @@ class TemplateHtmlConcatenationTestCase(TestCase): self.assertNotIn('pool + " document.querySelector(sel).replaceWith(parts.querySelector(sel))); + document.querySelector("#flavors .is-invalid, #flavors input:not([type=hidden])").focus(); +} + +// Disk and network rows: one per chosen volume or network, cloned from +// the page's row templates; the fields the server reads are derived +// from them in sync_lists. +function new_row(list, template, item) { + const row = document.getElementById(template).content.firstElementChild.cloneNode(true); + row.dataset.item = item; + row.querySelector(".row-item").textContent = item; + document.querySelector(list).append(row); + return row; +} + +function add_disk_row(volume) { + const row = new_row("#img-list", "disk-row", volume); + const bus = row.querySelector(".disk-bus"); + row.querySelector(".disk-device").addEventListener("change", function () { + load_buses(bus, this.value); + }); + row.querySelector(".row-remove").addEventListener("click", function () { images_control.removeItem(volume); }); + sync_lists(); + return row; +} + +// a network row without a MAC gets a random one +function add_network_row(network, mac) { + const row = new_row("#net-list", "network-row", network); + const input = row.querySelector("input"); + input.value = mac; + input.addEventListener("input", sync_lists); + row.querySelector(".row-remove").addEventListener("click", function () { networks_control.removeItem(network); }); + if (!mac) random_mac(input); + sync_lists(); + return row; +} + +function row_for(list, item) { + return [...document.querySelector(list).children].find(row => row.dataset.item === item); +} + +function remove_row(list, item) { + row_for(list, item)?.remove(); + sync_lists(); +} + +// volumes, networks and their MACs in row order; device and bus +// belong to the i-th volume +function sync_lists() { + const disks = [...document.querySelector("#img-list").children]; + const nics = [...document.querySelector("#net-list").children]; + disks.forEach(function (row, i) { + row.querySelector(".row-label").textContent = "hdd" + i; + row.querySelector(".disk-device").name = "device" + i; + row.querySelector(".disk-bus").name = "bus" + i; + }); + nics.forEach(function (row, i) { + row.querySelector(".row-label").textContent = "eth" + i; + }); + document.querySelector("#images").value = disks.map(row => row.dataset.item).join(","); + document.querySelector("#networks").value = nics.map(row => row.dataset.item).join(","); + document.querySelector("#vm_mac").value = nics.map(row => row.querySelector("input").value).join(","); +} + +// a MAC typed while the request was out stays +function random_mac(input) { + $.getJSON(form_data().randomMacUrl, function (data) { + if (!input.value) input.value = data['mac']; + sync_lists(); + }); +} + +function volumes_url(pool) { + return form_data().volumesUrl.replace("__pool__", encodeURIComponent(pool)); +} + +// A dependent list is empty while it loads, and a newer load aborts the +// older one: an answer for the previous choice never fills it. +function load_list(list, url, fill) { + if (list.request) list.request.abort(); + list.request = $.getJSON(url, fill); + return list.request; +} + +function load_volumes(pool) { + images_control.clearOptions(); // keeps the chosen volumes + return load_list(images_control, volumes_url(pool), function (data) { + data['vols'].forEach(item => images_control.addOption({value: item, text: item})); + }); +} + +// the copy goes into the template's pool unless the user picks another +function template_pool_chosen(pool) { + select_value(document.querySelector("#template_storage"), pool); + return load_templates(pool); +} + +function load_templates(pool) { + const list = document.querySelector("#template"); + list.replaceChildren(); + return load_list(list, volumes_url(pool), function (data) { + list.replaceChildren(...data['vols'].map(item => new Option(item, item))); + }); +} + +function load_buses(bus_select, disk_type) { + const url = form_data().busesUrl.replace("__disk__", encodeURIComponent(disk_type)); + bus_select.replaceChildren(); + return load_list(bus_select, url, function (data) { + bus_select.replaceChildren(...data['bus'].map(item => new Option(item, item))); + }); +} + +// a value the loaded list no longer has is kept, not swapped for the first +function select_value(select, value) { + if (![...select.options].some(option => option.value === value)) select.add(new Option(value, value)); + select.value = value; +} + +// A refused form comes back as it was sent: its networks, volumes and +// template loaded first, any Advanced field with an error shown, then +// the focus on the first error. +function restore_submitted() { + const form = document.querySelector("#create-vm-form"); + const element = document.getElementById("submitted-lists"); + const lists = element ? JSON.parse(element.textContent) : {networks: [], disks: [], pool: ""}; + lists.networks.forEach(function (nic) { + networks_control.addOption({value: nic.name, text: nic.name}); + add_network_row(nic.name, nic.mac); + networks_control.addItem(nic.name, true); + }); + const loads = []; + if (lists.pool) { + select_value(document.querySelector("#storage-control"), lists.pool); + loads.push(load_volumes(lists.pool).then(function () { + return $.when(...lists.disks.map(function (disk) { + images_control.addOption({value: disk.volume, text: disk.volume}); + const row = add_disk_row(disk.volume); + images_control.addItem(disk.volume, true); + const bus = row.querySelector(".disk-bus"); + select_value(row.querySelector(".disk-device"), disk.device); + return load_buses(bus, disk.device).then(() => select_value(bus, disk.bus)); + })); + })); + } + if (form.dataset.templatePool) { + select_value(document.querySelector("#template_pool"), form.dataset.templatePool); + loads.push(load_templates(form.dataset.templatePool).then(function () { + select_value(document.querySelector("#template"), form.dataset.template); + })); + } + const advanced = document.querySelector("#advanced"); + if (advanced.querySelector(".is-invalid")) bootstrap.Collapse.getOrCreateInstance(advanced, {toggle: false}).show(); + $.when(...loads).then(focusFirstError); +} + +// without storage on the host there is no form to set up +if (document.querySelector("#create-vm-form")) { + make_pickers(); + document.querySelectorAll('input[name="disk_source"]').forEach(function (radio) { + radio.addEventListener("change", function () { + show_source(this.value); + if (this.value !== "new") clear_presets(); // its size is not sent + }); + }); + show_source(document.querySelector('input[name="disk_source"]:checked').value); + $("#create-vm-form").on("click", ".preset", function () { apply_preset(this); }); + $("#flavors-dialog").on("submit", "form", function (event) { + event.preventDefault(); + $.post(this.action, $(this).serialize(), show_flavors).fail(() => this.submit()); + }); + ["#vm_vcpu", "#vm_memory", "#vm_hdd_size"].forEach(function (sel) { + document.querySelector(sel).addEventListener("input", clear_presets); + }); + document.querySelector("#storage-control").addEventListener("change", function () { load_volumes(this.value); }); + document.querySelector("#template_pool").addEventListener("change", function () { template_pool_chosen(this.value); }); + if (document.body.dataset.refused) restore_submitted(); + else add_first_network(); +} diff --git a/static/js/create_machine.js b/static/js/create_machine.js new file mode 100644 index 00000000..fc5fb18a --- /dev/null +++ b/static/js/create_machine.js @@ -0,0 +1,43 @@ +// The create wizard's first page (create_instance_w1.html): the machine +// types of the chosen architecture, Next to the form for them, and a VM +// defined from XML instead. + +// the machine types of the chosen architecture, the default one selected; +// the list is empty while it loads, and a newer load aborts the older one +function load_machines(arch) { + const next = document.querySelector("#next"), list = document.querySelector("#select_chipset"); + next.disabled = true; + list.replaceChildren(); + if (list.request) list.request.abort(); + list.request = $.getJSON(list.dataset.machinesUrl.replace("__arch__", encodeURIComponent(arch)), function (data) { + list.replaceChildren(...data['machines'].map( + item => new Option(item, item, false, item === list.dataset.default))); + next.disabled = !data['machines'].length; + }); +} + +// placeholders, not "x86_64"/"pc": an architecture such as ppc64 contains "pc" +function create_url(arch, machine) { + return document.querySelector("#next").dataset.createUrl + .replace("__arch__", encodeURIComponent(arch)).replace("__machine__", encodeURIComponent(machine)); +} + +function goto_create() { + window.location.href = create_url(document.querySelector("#select_archs").value, + document.querySelector("#select_chipset").value); +} + +const archs = document.querySelector("#select_archs"); +archs.addEventListener("change", () => load_machines(archs.value)); +document.querySelector("#next").addEventListener("click", goto_create); +load_machines(archs.value); + +// the XML editor fills the form's field +const editor = ace.edit("editor"); +editor.getSession().setMode("ace/mode/xml"); +const xml_input = document.querySelector('input[name="dom_xml"]'); +editor.getSession().on("change", function () { + xml_input.value = editor.getSession().getValue(); +}); +// the editor measures itself when its section is shown +document.querySelector("#from-xml").addEventListener("shown.bs.collapse", () => editor.resize());