mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
fix(instances): a refused custom VM form comes back with its disks and networks
A refused custom create form came back with its image pool unselected and its disk and network lists empty: every disk's device and bus and every MAC had to be chosen again. Adding a disk or a network also reset the rows already there: each addition rebuilt all rows from an HTML string, so the earlier disks fell back to the default device and bus and every NIC got a new random MAC, a typed one included. The view sends the submitted pool, disks (volume, device, bus) and networks (name, MAC) back with json_script, built field by field from the POST (submitted_custom_lists). The rows are cloned from two <template> elements and filled with textContent and listeners; one function, sync_custom_lists, derives the images, networks and mac fields and the device<i>/bus<i> names from the rows in order. Adding or removing a row leaves the others as they are, and a random MAC that arrives after one was typed is dropped. On load the page selects the pool, waits for its volumes, rebuilds each disk with its device, waits for that device's bus list and selects its bus, rebuilds the networks with their MACs, and then focuses the first error. A submitted value the loaded lists no longer have stays selected. The bus and volume loaders return their requests so that the page can wait on them.
This commit is contained in:
parent
0d5014d9ef
commit
96141c3ef7
5 changed files with 349 additions and 111 deletions
|
|
@ -341,6 +341,7 @@
|
|||
<div>
|
||||
<form method="post" role="form" aria-label="Create custom instance form">{% csrf_token %}{% submission_key %}
|
||||
<input type="hidden" name="form_id" value="custom">
|
||||
{% if custom_lists %}{{ custom_lists|json_script:"custom-lists" }}{% endif %}
|
||||
{% include "instances/form_errors.html" with form=custom_form %}
|
||||
<div class="row">
|
||||
<label for="custom_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
||||
|
|
@ -415,6 +416,18 @@
|
|||
<ul id="img-list" class="row">
|
||||
<!-- populated from javascript -->
|
||||
</ul>
|
||||
<template id="disk-row">
|
||||
<li><span class="row-label"></span> -
|
||||
<select class="image-format disk-device" aria-label="{% trans "Device" %}">
|
||||
{% for dev in disk_devices %}<option value="{{ dev }}">{% trans dev %}</option>{% endfor %}
|
||||
</select>
|
||||
<select class="image-format disk-bus" aria-label="{% trans "Bus" %}">
|
||||
{% for bus in disk_buses %}<option value="{{ bus }}"{% if bus == default_bus %} selected{% endif %}>{% trans bus %}</option>{% endfor %}
|
||||
</select>
|
||||
-> <span class="row-item"></span>
|
||||
<button type="button" class="btn btn-link float-end row-remove" aria-label="{% trans "Remove" %}">X</button>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
|
@ -444,6 +457,12 @@
|
|||
<ul id="net-list">
|
||||
<!-- populated from javascript -->
|
||||
</ul>
|
||||
<template id="network-row">
|
||||
<li><input type="text" class="input-xs" maxlength="17" required pattern="[a-zA-Z0-9:]+" aria-label="{% trans "MAC address" %}">
|
||||
<span class="row-label"></span> -> <span class="row-item"></span>
|
||||
<button type="button" class="btn btn-link float-end row-remove" aria-label="{% trans "Remove" %}">X</button>
|
||||
</li>
|
||||
</template>
|
||||
<input id="networks" type="hidden" name="networks" value=""/>
|
||||
<input id="custom_mac" type="hidden" name="mac" value=""/>
|
||||
<select class="form-select" id="network-control" name="network-control" multiple="multiple">
|
||||
|
|
@ -882,116 +901,98 @@
|
|||
{% block script %}
|
||||
<script src="{% static 'js/tom-select.complete.min.js' %}"></script>
|
||||
<script>
|
||||
function toggleValue(string, updated_value, checked) {
|
||||
let result = '';
|
||||
if (checked) {
|
||||
result = string;
|
||||
if (result != '') result += ',';
|
||||
result += updated_value;
|
||||
} else {
|
||||
$.each(string.split(','), function (index, value) {
|
||||
if (value == updated_value) return;
|
||||
if (result != '') result += ',';
|
||||
result += value;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
var images_control, networks_control;
|
||||
|
||||
$(document).ready(function () {
|
||||
images_control = new TomSelect('#image-control', {
|
||||
images_control = new TomSelect('#image-control', {
|
||||
plugins: ['checkbox_options'],
|
||||
placeholder: 'Add image...',
|
||||
hidePlaceholder: false,
|
||||
onItemAdd: function (value) { update_images(value, true); },
|
||||
onItemRemove: function (value) { update_images(value, false); },
|
||||
});
|
||||
|
||||
function update_images(value, checked) {
|
||||
let input_value = toggleValue($('#images').val(), value, checked);
|
||||
$('#images').val(input_value);
|
||||
|
||||
let selected_list_html = '';
|
||||
let counter = 0;
|
||||
if (input_value != '') {
|
||||
$('#disk_list_div').removeClass('d-none');
|
||||
$.each(input_value.split(','), function (index, value) {
|
||||
var li = '<li>hdd' + counter + ' - ' +
|
||||
'<select name="device' + counter + '" class="image-format" onchange="get_disk_bus_choices({{ compute_id }},' + counter + ', value);">' +
|
||||
'{% for dev in disk_devices %}' +
|
||||
'<option value=' + '"{{ dev }}">' + '{% trans dev %}</option>' +
|
||||
'{% endfor %}' +
|
||||
'</select>' +
|
||||
'<select id="bus' + counter + '" name="bus' + counter + '" class="image-format">' +
|
||||
'{% for bus in disk_buses %}' +
|
||||
'<option value=' + '"{{ bus }}"' +
|
||||
'{% if bus == default_bus %}selected{% endif %}>' +
|
||||
'{% trans bus %}</option>' +
|
||||
'{% endfor %}' +
|
||||
'</select>' +
|
||||
' -> ' + value + ' ' +
|
||||
'<a class="btn-link float-end" onclick="images_control.removeItem(\'' + value + '\')"> X </a></li>';
|
||||
selected_list_html += li;
|
||||
counter++;
|
||||
});
|
||||
}else {
|
||||
$('#disk_list_div').addClass('d-none');
|
||||
}
|
||||
$('#img-list').html(selected_list_html);
|
||||
}
|
||||
|
||||
|
||||
onItemAdd: function (value) { if (!row_for("#img-list", value)) add_disk_row(value); },
|
||||
onItemRemove: function (value) { remove_row("#img-list", value); },
|
||||
});
|
||||
networks_control = new TomSelect('#network-control', {
|
||||
plugins: ['checkbox_options'],
|
||||
placeholder: 'Add network',
|
||||
hidePlaceholder: false,
|
||||
onItemAdd: function (value) { update_networks(value, true); },
|
||||
onItemRemove: function (value) { update_networks(value, false); },
|
||||
onItemAdd: function (value) { if (!row_for("#net-list", value)) add_network_row(value, ""); },
|
||||
onItemRemove: function (value) { remove_row("#net-list", value); },
|
||||
});
|
||||
|
||||
function update_networks(value, checked) {
|
||||
let input_value = toggleValue($('#networks').val(), value, checked);
|
||||
$('#networks').val(input_value);
|
||||
let selected_list_html = '';
|
||||
mac_array = [];
|
||||
$('#custom_mac').val('');
|
||||
net_counter = 0;
|
||||
if (input_value != '') {
|
||||
$.each(input_value.split(','), function (index, value) {
|
||||
let li = '<li><input type="text" class="input-xs" name="mac'+ net_counter +'" maxlength="17" required pattern="[a-zA-Z0-9:]+" onchange="populate_mac(' + net_counter +')">' +
|
||||
' eth' + net_counter +
|
||||
' -> ' + value + ' ' +
|
||||
'<a class="btn-link float-end" onclick="networks_control.removeItem(\'' + value + '\')"> X </a></li>';
|
||||
random_mac(net_counter);
|
||||
selected_list_html += li;
|
||||
net_counter++;
|
||||
});
|
||||
|
||||
}
|
||||
$('#net-list').html(selected_list_html);
|
||||
}
|
||||
});
|
||||
|
||||
var images_control, networks_control;
|
||||
|
||||
var mac_array = [];
|
||||
function random_mac(c) {
|
||||
$.getJSON("{% url 'instances:random_mac_address' %}", function (data) {
|
||||
mac_array[c] = data['mac'];
|
||||
$('input[name="mac' + c + '"').val(data['mac']);
|
||||
populate_mac(c);
|
||||
});
|
||||
// The custom form's disk and network rows: one per chosen image or
|
||||
// network, cloned from the page's row templates. The fields the server
|
||||
// reads are derived from them in sync_custom_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 populate_mac(c){
|
||||
mac_array[c] = $('input[name="mac' + c + '"').val();
|
||||
// the custom form's MACs only: the other create forms have their own field
|
||||
$('#custom_mac').val(mac_array.join(","));
|
||||
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 () {
|
||||
get_disk_bus_choices({{ compute_id }}, bus, this.value);
|
||||
});
|
||||
row.querySelector(".row-remove").addEventListener("click", function () { images_control.removeItem(volume); });
|
||||
sync_custom_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_custom_lists);
|
||||
row.querySelector(".row-remove").addEventListener("click", function () { networks_control.removeItem(network); });
|
||||
if (!mac) random_mac(input);
|
||||
sync_custom_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_custom_lists();
|
||||
}
|
||||
|
||||
// images, networks and their MACs in row order; device<i> and bus<i>
|
||||
// belong to the i-th image
|
||||
function sync_custom_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("#custom_mac").value = nics.map(row => row.querySelector("input").value).join(",");
|
||||
document.querySelector("#disk_list_div").classList.toggle("d-none", disks.length === 0);
|
||||
}
|
||||
|
||||
// a MAC typed while the request was out stays
|
||||
function random_mac(input) {
|
||||
$.getJSON("{% url 'instances:random_mac_address' %}", function (data) {
|
||||
if (!input.value) input.value = data['mac'];
|
||||
sync_custom_lists();
|
||||
});
|
||||
}
|
||||
|
||||
function get_cust_vols(compute_id, pool) {
|
||||
get_vol_url = "/computes/" + compute_id + "/storage/" + pool + "/volumes";
|
||||
$.getJSON(get_vol_url, function (data) {
|
||||
return $.getJSON(get_vol_url, function (data) {
|
||||
images_control.clearOptions();
|
||||
$.each(data['vols'], function(i, item) {
|
||||
images_control.addOption({value: item, text: item});
|
||||
|
|
@ -1028,7 +1029,7 @@
|
|||
bootstrap.Modal.getOrCreateInstance(holder).show();
|
||||
} else {
|
||||
bootstrap.Tab.getOrCreateInstance(document.querySelector(`[data-bs-target="#${holder.id}"]`)).show();
|
||||
restore_template_volumes().then(focusFirstError);
|
||||
$.when(restore_template_volumes(), restore_custom_lists()).then(focusFirstError);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1052,17 +1053,50 @@
|
|||
$("#storage").val(form.dataset.storage);
|
||||
});
|
||||
}
|
||||
|
||||
// The custom form's lists as submitted: its pool's volumes loaded first,
|
||||
// then each disk's bus once the bus list for its device is loaded
|
||||
function restore_custom_lists() {
|
||||
const element = document.getElementById("custom-lists");
|
||||
if (!element) return $.when();
|
||||
const lists = JSON.parse(element.textContent);
|
||||
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);
|
||||
});
|
||||
if (!lists.pool) return $.when();
|
||||
select_value(document.querySelector("#storage-control"), lists.pool);
|
||||
return get_cust_vols({{ compute_id }}, 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 get_disk_bus_choices({{ compute_id }}, bus, disk.device).then(function () {
|
||||
select_value(bus, disk.bus);
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
$(reopen_submitted_form);
|
||||
|
||||
function get_disk_bus_choices(compute_id, dev_idx, disk_type){
|
||||
function get_disk_bus_choices(compute_id, bus_select, disk_type){
|
||||
// the page's own architecture and machine type, from its URL
|
||||
let arch = "{{ arch|escapejs }}";
|
||||
let machine = "{{ machine|escapejs }}";
|
||||
get_diskBus_url = "/computes/" + compute_id + "/archs/" + arch + "/machines/" + machine + "/disks/" + disk_type + "/buses";
|
||||
$.getJSON(get_diskBus_url, function (data) {
|
||||
$("#bus" + dev_idx).find('option').remove();
|
||||
return $.getJSON(get_diskBus_url, function (data) {
|
||||
$(bus_select).find('option').remove();
|
||||
$.each(data['bus'], function(i, item) {
|
||||
$("#bus" + dev_idx).append($("<option>").val(item).text(item));
|
||||
$(bus_select).append($("<option>").val(item).text(item));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""A refused VM create form comes back bound and open, with what was
|
||||
entered; the other create forms keep their own defaults."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -179,3 +180,36 @@ class FlavorFormTests(TestCase):
|
|||
form = page.get_element_by_id(f"addVMflavor{self.first.id}").xpath(".//form")[0]
|
||||
self.assertEqual(len(form.xpath(".//*[@name='cache_mode']")), 1)
|
||||
self.assertEqual(form.xpath(".//input[@name='form_id']/@value"), [f"flavor{self.first.id}"])
|
||||
|
||||
|
||||
class CustomListsTests(TestCase):
|
||||
"""The custom form's disks and networks come back with a refusal."""
|
||||
|
||||
def custom_lists(self, response):
|
||||
"""The custom form's disks and networks the page restores, or None."""
|
||||
element = html.fromstring(response.content).get_element_by_id("custom-lists", None)
|
||||
return None if element is None else json.loads(element.text)
|
||||
|
||||
def test_a_refused_custom_form_sends_back_its_lists(self):
|
||||
_, response = render_create_page(self, valid(
|
||||
form_id="custom", name="bad name!", **{"storage-control": "default"}, images="a.qcow2,b.qcow2",
|
||||
device0="cdrom", bus0="sata", device1="disk", bus1="virtio",
|
||||
networks="default,isolated", mac="52:54:00:00:00:01"))
|
||||
self.assertEqual(self.custom_lists(response), {
|
||||
"pool": "default",
|
||||
"disks": [{"volume": "a.qcow2", "device": "cdrom", "bus": "sata"},
|
||||
{"volume": "b.qcow2", "device": "disk", "bus": "virtio"}],
|
||||
"networks": [{"name": "default", "mac": "52:54:00:00:00:01"}, {"name": "isolated", "mac": ""}],
|
||||
})
|
||||
|
||||
def test_a_hostile_volume_name_round_trips_through_the_lists(self):
|
||||
name = "x'\"><img src=x>"
|
||||
_, response = render_create_page(self, valid(form_id="custom", name="bad name!", images=name))
|
||||
self.assertEqual(self.custom_lists(response)["disks"][0]["volume"], name)
|
||||
self.assertNotIn(b"<img src=x>", response.content)
|
||||
|
||||
def test_only_a_refused_custom_form_sends_lists(self):
|
||||
_, response = render_create_page(self)
|
||||
self.assertIsNone(self.custom_lists(response))
|
||||
_, response = render_create_page(self, valid(form_id="template", name="bad name!", images="a.qcow2"))
|
||||
self.assertIsNone(self.custom_lists(response))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import re
|
|||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
|
@ -14,6 +15,46 @@ from instances.tests.create_page import render_create_page
|
|||
from lxml import html
|
||||
|
||||
|
||||
# Just enough DOM for the custom form's rows: elements reach their parts by
|
||||
# selector, a row template clones a fresh row, and writing HTML throws (rows
|
||||
# are built from text and nodes only). A random-MAC request waits in replies.
|
||||
FAKE_DOM = """
|
||||
class El {
|
||||
constructor(parts = {}) {
|
||||
Object.assign(this, {parts, dataset: {}, value: "", name: "", textContent: "", children: [], listeners: {},
|
||||
classes: new Set(), options: []});
|
||||
this.classList = {toggle: (c, on) => on ? this.classes.add(c) : this.classes.delete(c)};
|
||||
}
|
||||
set innerHTML(v) { throw new Error("HTML written: " + v); }
|
||||
part(sel) { return this.parts[sel]; }
|
||||
querySelector(sel) { return this.parts[sel]; }
|
||||
addEventListener(event, f) { this.listeners[event] = f; }
|
||||
append(child) { child.parent = this; this.children.push(child); }
|
||||
add(option) { this.options.push(option); }
|
||||
remove() { this.parent.children.splice(this.parent.children.indexOf(this), 1); }
|
||||
}
|
||||
const row_template = names => ({content: {firstElementChild: {cloneNode: () =>
|
||||
new El(Object.fromEntries(names.map(n => [n, new El()])))}}});
|
||||
const templates = {"disk-row": row_template([".row-label", ".disk-device", ".disk-bus", ".row-item", ".row-remove"]),
|
||||
"network-row": row_template(["input", ".row-label", ".row-item", ".row-remove"])};
|
||||
const fields = {};
|
||||
const field = sel => fields[sel] ??= new El();
|
||||
const rows = list => field(list).children;
|
||||
const document = {getElementById: id => templates[id], querySelector: field};
|
||||
const replies = [];
|
||||
const $ = Object.assign(() => ({ready: f => f()}), {getJSON: (url, f) => { replies.push(f); return {}; }});
|
||||
const Option = function (text, value) { return {text, value}; };
|
||||
// TomSelect: records its added options and items, and calls the page's
|
||||
// callbacks as TomSelect does (a silent addItem still calls onItemAdd)
|
||||
class TomSelect {
|
||||
constructor(sel, settings) { this.settings = settings; this.added = []; }
|
||||
addOption(o) { this.added.push("option " + o.value); }
|
||||
addItem(v) { this.added.push("item " + v); this.settings.onItemAdd(v); }
|
||||
removeItem(v) { this.settings.onItemRemove(v); }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CreatePageScriptTests(TestCase):
|
||||
def page(self):
|
||||
compute, response = render_create_page(self)
|
||||
|
|
@ -26,24 +67,77 @@ class CreatePageScriptTests(TestCase):
|
|||
function = re.search(r"function get_disk_bus_choices\(.*?\n }\n", page, re.S).group(0)
|
||||
script = "let asked; const $ = Object.assign(() => ({find: () => ({remove() {}}), append() {}, val() {}}), " \
|
||||
"{getJSON: url => { asked = url; }, each() {}});\n" + function + \
|
||||
f"get_disk_bus_choices({compute.id}, 0, 'cdrom'); console.log(asked);"
|
||||
f"get_disk_bus_choices({compute.id}, {{}}, 'cdrom'); console.log(asked);"
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), reverse("buses", args=[compute.id, "i686", "pc", "cdrom"]))
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_the_custom_form_sends_its_own_macs_and_leaves_the_others_alone(self):
|
||||
compute, page = self.page()
|
||||
def test_the_custom_form_has_its_own_mac_field(self):
|
||||
_, page = self.page()
|
||||
custom = html.fromstring(page).xpath("//form[@aria-label='Create custom instance form']")[0]
|
||||
self.assertEqual(custom.xpath(".//input[@name='mac']/@id"), ["custom_mac"])
|
||||
function = re.search(r"function populate_mac\(.*?\n }\n", page, re.S).group(0)
|
||||
# the stand-in $ records each write; a MAC field holds what its selector names
|
||||
script = "const writes = []; let mac_array = [];\n" \
|
||||
"const $ = sel => ({val: v => v === undefined ? sel.replace(/\\D/g, '') : writes.push([sel, v])});\n" + \
|
||||
function + "populate_mac(0); populate_mac(1); console.log(JSON.stringify(writes));"
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=30)
|
||||
|
||||
def test_the_row_templates_hold_what_the_rows_use(self):
|
||||
# the test driver reports no disk devices or buses
|
||||
with patch("vrtManager.connection.wvmConnect.get_disk_device_types", return_value=["disk", "cdrom"]), \
|
||||
patch("vrtManager.connection.wvmConnect.get_disk_bus_types", return_value=["sata", "virtio"]):
|
||||
_, page = self.page()
|
||||
disk = html.fromstring(page).get_element_by_id("disk-row")
|
||||
self.assertEqual(disk.xpath(".//select[contains(@class, 'disk-device')]/option/@value"), ["disk", "cdrom"])
|
||||
self.assertEqual(disk.xpath(".//select[contains(@class, 'disk-bus')]/option[@selected]/@value"), ["virtio"])
|
||||
network = html.fromstring(page).get_element_by_id("network-row")
|
||||
self.assertTrue(network.xpath(".//input[@required]"))
|
||||
for row in (disk, network):
|
||||
for part in ("row-label", "row-item", "row-remove"):
|
||||
self.assertTrue(row.xpath(f".//*[contains(@class, '{part}')]"), part)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_adding_a_disk_keeps_the_others(self):
|
||||
out = self.run_rows("""
|
||||
images_control.addItem("a");
|
||||
const a = rows("#img-list")[0];
|
||||
a.part(".disk-device").value = "cdrom"; a.part(".disk-bus").value = "sata";
|
||||
images_control.addItem("b");
|
||||
console.log(JSON.stringify([a.part(".disk-device").value, a.part(".disk-bus").value, field("#images").value,
|
||||
rows("#img-list").flatMap(r => [r.part(".disk-device").name, r.part(".disk-bus").name])]));""")
|
||||
self.assertEqual(out, ["cdrom", "sata", "a,b", ["device0", "bus0", "device1", "bus1"]])
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_removing_a_disk_renumbers_the_rest(self):
|
||||
out = self.run_rows("""
|
||||
images_control.addItem("a"); images_control.addItem("b"); images_control.addItem("c");
|
||||
images_control.removeItem("b");
|
||||
const c = rows("#img-list")[1];
|
||||
console.log(JSON.stringify([field("#images").value, c.part(".disk-device").name, c.part(".disk-bus").name,
|
||||
c.part(".row-label").textContent]));""")
|
||||
self.assertEqual(out, ["a,c", "device1", "bus1", "hdd1"])
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_adding_a_network_keeps_typed_macs(self):
|
||||
out = self.run_rows("""
|
||||
networks_control.addItem("default");
|
||||
const first = rows("#net-list")[0];
|
||||
first.part("input").value = "52:54:00:00:00:01"; first.part("input").listeners.input();
|
||||
networks_control.addItem("isolated");
|
||||
replies[0]({mac: "52:54:00:aa:aa:aa"}); replies[1]({mac: "52:54:00:bb:bb:bb"});
|
||||
console.log(JSON.stringify([field("#networks").value, field("#custom_mac").value]));""")
|
||||
self.assertEqual(out, ["default,isolated", "52:54:00:00:00:01,52:54:00:bb:bb:bb"])
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_a_hostile_volume_name_is_text(self):
|
||||
name = "x'\"><img src=x>"
|
||||
out = self.run_rows(f"console.log(JSON.stringify(add_disk_row({json.dumps(name)}).part('.row-item').textContent));")
|
||||
self.assertEqual(out, name)
|
||||
|
||||
def run_rows(self, test):
|
||||
"""Run the custom form's row script against a fake DOM, then test;
|
||||
returns what test printed, parsed as JSON."""
|
||||
_, page = self.page()
|
||||
functions = re.search(r" var images_control, networks_control;.*?\n function random_mac\(.*?\n }\n",
|
||||
page, re.S).group(0)
|
||||
result = subprocess.run(["node", "-e", FAKE_DOM + functions + test], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), [["#custom_mac", "0"], ["#custom_mac", "0,1"]])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_a_mac_per_nic_fits_the_form(self):
|
||||
macs = ",".join(f"52:54:00:00:00:{n:02x}" for n in range(8))
|
||||
|
|
@ -63,7 +157,7 @@ const holder = (id, kind) => ({id, listeners: [], classList: {contains: c => c =
|
|||
addEventListener(event, f) { this.listeners.push(f); }});
|
||||
const holders = {custom: holder("addCustom", "tab-pane"), template: holder("add-from-temp", "tab-pane"),
|
||||
flavor7: holder("addVMflavor7", "modal")};
|
||||
const document = {body: {dataset: {submittedForm: %s}}, querySelector: sel => {
|
||||
const document = {body: {dataset: {submittedForm: %s}}, getElementById: () => null, querySelector: sel => {
|
||||
const id = (sel.match(/data-form-id="([^"]*)"/) || [])[1];
|
||||
if (id !== undefined) return holders[id] || null;
|
||||
return sel.startsWith("form[") ? null : {name: sel}; // no refused template form here
|
||||
|
|
@ -87,3 +181,65 @@ reopen_submitted_form(); console.log(JSON.stringify(shown));
|
|||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), expected)
|
||||
|
||||
LISTS = {"pool": "default",
|
||||
"disks": [{"volume": "a.qcow2", "device": "cdrom", "bus": "sata"},
|
||||
{"volume": "b.qcow2", "device": "disk", "bus": "virtio"}],
|
||||
"networks": [{"name": "default", "mac": "52:54:00:00:00:01"}, {"name": "isolated", "mac": ""}]}
|
||||
|
||||
def run_restore(self, test):
|
||||
"""Run restore_custom_lists on LISTS against the fake DOM, with
|
||||
loaders that answer when the test calls answer(); returns what the
|
||||
test printed, parsed as JSON."""
|
||||
_, page = self.page()
|
||||
rows = re.search(r" var images_control, networks_control;.*?\n function random_mac\(.*?\n }\n", page, re.S)
|
||||
restore = re.search(r" // The custom form's lists as submitted.*?\n function select_value\(.*?\n }\n", page, re.S)
|
||||
harness = FAKE_DOM + rows.group(0) + restore.group(0) + """
|
||||
templates["custom-lists"] = {textContent: %s};
|
||||
$.when = (...loading) => Promise.all(loading);
|
||||
const log = [], answers = [];
|
||||
const later = name => { log.push(name); return new Promise(done => answers.push(() => { log.push("loaded " + name); done(); })); };
|
||||
const get_cust_vols = (compute, pool) => later("volumes " + pool);
|
||||
const get_disk_bus_choices = (compute, bus, device) => later("buses " + device);
|
||||
const settle = () => new Promise(done => setTimeout(done));
|
||||
const answer = async () => { await settle(); answers.shift()(); await settle(); };
|
||||
const disks = () => rows("#img-list").map(r => [r.dataset.item, r.part(".disk-device").value, r.part(".disk-bus").value]);
|
||||
""" % json.dumps(json.dumps(self.LISTS))
|
||||
result = subprocess.run(["node", "-e", harness + "(async () => {" + test + "})();"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_restoring_the_custom_form_rebuilds_its_rows(self):
|
||||
out = self.run_restore("""
|
||||
const restored = restore_custom_lists().then(() => log.push("restored"));
|
||||
await answer();
|
||||
const before = disks();
|
||||
await answer(); await answer(); await restored;
|
||||
const typed_before_reply = field("#custom_mac").value;
|
||||
replies[0]({mac: "52:54:00:00:00:bb"});
|
||||
console.log(JSON.stringify({log, before, after: disks(), networks: field("#networks").value,
|
||||
typed_before_reply, macs: field("#custom_mac").value, random_requests: replies.length}));""")
|
||||
self.assertEqual(out["log"], ["volumes default", "loaded volumes default", "buses cdrom", "buses disk",
|
||||
"loaded buses cdrom", "loaded buses disk", "restored"])
|
||||
self.assertEqual(out["before"], [["a.qcow2", "cdrom", ""], ["b.qcow2", "disk", ""]])
|
||||
self.assertEqual(out["after"], [["a.qcow2", "cdrom", "sata"], ["b.qcow2", "disk", "virtio"]])
|
||||
self.assertEqual(out["networks"], "default,isolated")
|
||||
self.assertEqual(out["typed_before_reply"], "52:54:00:00:00:01,")
|
||||
self.assertEqual(out["macs"], "52:54:00:00:00:01,52:54:00:00:00:bb")
|
||||
self.assertEqual(out["random_requests"], 1)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_a_value_gone_from_the_lists_is_kept(self):
|
||||
out = self.run_restore("""
|
||||
const restored = restore_custom_lists();
|
||||
await answer();
|
||||
const bus = rows("#img-list")[0].part(".disk-bus");
|
||||
bus.options.push({value: "virtio"}); // the loaded list has no sata
|
||||
await answer(); await answer(); await restored;
|
||||
console.log(JSON.stringify({volumes: images_control.added, bus: bus.value,
|
||||
options: bus.options.map(o => o.value)}));""")
|
||||
self.assertEqual(out["volumes"], ["option a.qcow2", "item a.qcow2", "option b.qcow2", "item b.qcow2"])
|
||||
self.assertEqual(out["bus"], "sata")
|
||||
self.assertEqual(out["options"], ["virtio", "sata"])
|
||||
|
|
|
|||
|
|
@ -137,8 +137,6 @@ HTML_SINK = re.compile(r"""\.html\(|innerHTML\s*=|append\(\s*['"]<""")
|
|||
ALLOWED_HTML_SINKS = {
|
||||
("accounts/templates/login.html", "$btn.html("): "static spinner markup",
|
||||
("accounts/templates/accounts/otp_login.html", "$btn.html("): "static spinner markup",
|
||||
("instances/templates/create_instance_w2.html", "$('#img-list').html(selected_list_html);"): "superuser-only; tracked as U-04",
|
||||
("instances/templates/create_instance_w2.html", "$('#net-list').html(selected_list_html);"): "superuser-only; tracked as U-04",
|
||||
("instances/templates/instance.html", "//sto_input.innerHTML = pool;"): "comment",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2024,6 +2024,21 @@ def create_forms(flavors, defaults, mac):
|
|||
return forms
|
||||
|
||||
|
||||
def submitted_custom_lists(post):
|
||||
"""The custom form's image pool, disks and networks as submitted, for the
|
||||
page to rebuild them."""
|
||||
images = post["images"].split(",") if post.get("images") else []
|
||||
networks = post["networks"].split(",") if post.get("networks") else []
|
||||
macs = post.get("mac", "").split(",")
|
||||
return {
|
||||
"pool": post.get("storage-control", ""),
|
||||
"disks": [{"volume": image, "device": post.get(f"device{i}", ""), "bus": post.get(f"bus{i}", "")}
|
||||
for i, image in enumerate(images)],
|
||||
"networks": [{"name": network, "mac": macs[i] if i < len(macs) else ""}
|
||||
for i, network in enumerate(networks)],
|
||||
}
|
||||
|
||||
|
||||
@superuser_only
|
||||
def create_instance(request, compute_id, arch, machine):
|
||||
"""
|
||||
|
|
@ -2156,6 +2171,7 @@ def create_instance(request, compute_id, arch, machine):
|
|||
messages.error(request, lib_err)
|
||||
|
||||
custom_form, template_form = vm_forms.get("custom"), vm_forms.get("template")
|
||||
custom_lists = submitted_custom_lists(request.POST) if submitted_form == "custom" else None
|
||||
flavor_forms = [(flavor, f"flavor{flavor.id}", vm_forms[f"flavor{flavor.id}"])
|
||||
for flavor in flavors if f"flavor{flavor.id}" in vm_forms]
|
||||
return render(request, "create_instance_w2.html", locals())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue