From 1e66367bb4667e1e6259e8174c5be59bc4dbd4e2 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 2 Nov 2026 23:49:45 +0300 Subject: [PATCH] feat(operations): a job's error and warning show in the reader's language A job stored its error and its result's warning as English text, much of it built with the values in it (volume paths, a disk, libvirt's error), so no catalog could translate it, and the job pages and panel showed English whatever the language. A job now stores them as messages: our English text, marked for translation, and its values; libvirt's own text is a message without values and is shown as it is. The jobs API and pages put them in the reader's language. OperationError keeps its text and values apart for the same reason, so a refusal raised while a job is submitted is shown translated too. Messages one after another still read as sentences. Errors stored before are kept as one message each, shown as they were. --- instances/jobs.py | 166 ++++---- instances/tests/test_clone_job.py | 21 +- instances/tests/test_cloud_init_create.py | 3 +- instances/tests/test_disk_move_job.py | 3 +- instances/tests/test_snapshot_delete_job.py | 9 +- locale/tr/LC_MESSAGES/django.mo | Bin 159122 -> 165862 bytes locale/tr/LC_MESSAGES/django.po | 391 +++++++++++++++---- locale/zh/LC_MESSAGES/django.mo | Bin 148923 -> 155154 bytes locale/zh/LC_MESSAGES/django.po | 372 ++++++++++++++---- operations/api/serializers.py | 13 +- operations/jobs.py | 18 +- operations/migrations/0002_error_messages.py | 27 ++ operations/models.py | 2 +- operations/templates/operations/job.html | 6 +- operations/templates/operations/jobs.html | 2 +- operations/tests/test_api.py | 12 + operations/tests/test_recovery.py | 33 +- operations/tests/test_service.py | 13 +- operations/tests/test_views.py | 13 +- operations/tests/test_worker.py | 7 +- operations/utils.py | 36 +- operations/views.py | 5 +- operations/worker.py | 27 +- storages/jobs.py | 14 +- storages/tests/test_upload_job.py | 13 +- storages/upload.py | 32 +- vrtManager/tests/test_util.py | 9 + vrtManager/util.py | 9 +- webvirtcloud/middleware.py | 4 +- 29 files changed, 927 insertions(+), 333 deletions(-) create mode 100644 operations/migrations/0002_error_messages.py diff --git a/instances/jobs.py b/instances/jobs.py index e7f4a6ae..b3dd5599 100644 --- a/instances/jobs.py +++ b/instances/jobs.py @@ -27,6 +27,7 @@ from computes.models import Compute from instances import utils from instances.models import Instance from operations import claims +from operations.utils import safe_text, say from operations.jobs import Cancelled, Kind, Outcome, Plan, register from operations.models import Claim, Operation @@ -64,11 +65,14 @@ def _vm_plan(params): ) +MOVED = _("The VM moved to another compute after the job was queued") + + def _checked_proxy(op): """In the worker: the user may still change the VM, and it did not move.""" instance = _authorized(op.user, op.params["instance"]) if instance.compute_id != op.intent["compute"]: - raise util.OperationError("The VM moved to another compute after the job was queued") + raise util.OperationError(MOVED) return _proxy(instance) @@ -129,11 +133,12 @@ def clone_data(user, instance, fields): # the clone copies the persistent definition, with the size the form gives charged = {} - for key, source, label in (("vcpu", instance.config_vcpu, _("vCPUs")), - ("memory", instance.config_memory, _("memory (MiB)"))): + for key, source, refusal in (("vcpu", instance.config_vcpu, _("Enter a whole number of vCPUs for the clone")), + ("memory", instance.config_memory, + _("Enter a whole number of memory (MiB) for the clone"))): value = fields.get(f"clone-{key}") or str(source) if not value.isdigit() or int(value) < 1: - raise util.OperationError(_("Enter a whole number of %(what)s for the clone") % {"what": label}) + raise util.OperationError(refusal) # None: as the VM, nothing to change after the copy data[key] = int(value) if int(value) != source else None # the larger: a resize that fails after the copy leaves the clone at the VM's size @@ -185,17 +190,17 @@ def clone_data(user, instance, fields): if not user.is_superuser and quota_msg: raise util.OperationError(quota_refused(quota_msg, _( "User '%(quota_msg)s' quota reached, cannot create '%(clone_name)s'!" - ) % {"quota_msg": quota_msg, "clone_name": data["name"]})) + )), quota_msg=quota_msg, clone_name=data["name"]) if check_instance: - raise util.OperationError(_("Instance '%(clone_name)s' already exists!") % {"clone_name": data["name"]}) + raise util.OperationError(_("Instance '%(clone_name)s' already exists!"), clone_name=data["name"]) if not re.match(r"^[a-zA-Z0-9-]+$", data["name"]): - raise util.OperationError( - _("Instance name '%(clone_name)s' contains invalid characters!") % {"clone_name": data["name"]}) + raise util.OperationError(_("Instance name '%(clone_name)s' contains invalid characters!"), + clone_name=data["name"]) if invalid_macs: - raise util.OperationError(_("Instance MAC '%(clone_mac)s' invalid format!") % {"clone_mac": ", ".join(invalid_macs)}) + raise util.OperationError(_("Instance MAC '%(clone_mac)s' invalid format!"), clone_mac=", ".join(invalid_macs)) if invalid_disks: - raise util.OperationError( - _("Disk name '%(disk_name)s' contains invalid characters!") % {"disk_name": ", ".join(str(v) for v in invalid_disks)}) + raise util.OperationError(_("Disk name '%(disk_name)s' contains invalid characters!"), + disk_name=", ".join(str(v) for v in invalid_disks)) return data, quota @@ -205,7 +210,7 @@ def _finish_clone(op, clone, warning): asked. It is made either way: a step that fails is a warning of the job.""" c = Compute.objects.get(pk=op.intent["compute"]) proxy = wvmInstance(c.hostname, c.login, c.password, c.type, op.intent["name"], uuid=op.intent["uuid"]) - notes = [warning] if warning else [] + notes = list(warning) try: if clone.get("vcpu"): proxy.resize_cpu(str(clone["vcpu"]), str(clone["vcpu"])) @@ -214,8 +219,8 @@ def _finish_clone(op, clone, warning): if clone.get("start"): proxy.start() except libvirtError as err: - notes.append(_("The clone is made, but not as asked: %(error)s") % {"error": err}) - return {"warning": " ".join(notes)} if notes else {} + notes += say(_("The clone is made, but not as asked: %(error)s"), error=err) + return {"warning": notes} if notes else {} def _clone_made(op, result): @@ -262,9 +267,9 @@ def vm_outcome(op, owner, volumes): held.update(pool.listVolumes()) left = sorted(held & set(volumes)) if not left: - return Outcome(Operation.FAILED, error="The VM was not defined and no volume of it was left.") - return Outcome(Operation.UNCERTAIN, - error=f"The VM was not defined; check these volumes, then resolve this job: {', '.join(left)}") + return Outcome(Operation.FAILED, error=say(_("The VM was not defined and no volume of it was left."))) + return Outcome(Operation.UNCERTAIN, error=say( + _("The VM was not defined; check these volumes, then resolve this job: %(volumes)s"), volumes=", ".join(left))) record_vm(op, owner) return Outcome(Operation.SUCCEEDED) @@ -295,7 +300,7 @@ class DeleteExternalSnapshot(Kind): def validate(self, user, params): instance = _authorized(user, params["instance"]) if params["name"] not in _proxy(instance).instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL): - raise util.OperationError(f"The VM has no external snapshot named {params['name']}") + raise util.OperationError(_("The VM has no external snapshot named %(name)s"), name=params["name"]) def plan(self, user, params): return _vm_plan(params) @@ -318,7 +323,7 @@ class DeleteExternalSnapshot(Kind): def reconcile(self, op): instance = Instance.objects.select_related("compute").filter(pk=op.params["instance"]).first() if instance is None or instance.compute_id != op.intent.get("compute"): - return Outcome(Operation.UNCERTAIN, error="The VM is no longer on the compute the job ran on") + return Outcome(Operation.UNCERTAIN, error=say(GONE)) try: proxy = _proxy(instance) listed = op.params["name"] in proxy.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL) @@ -327,7 +332,7 @@ class DeleteExternalSnapshot(Kind): raise # unreachable: recovery waits and retries except libvirtError as err: # OperationError is a libvirtError too if err.get_error_code() == VIR_ERR_NO_DOMAIN: - return Outcome(Operation.UNCERTAIN, error="The VM is no longer on its compute") + return Outcome(Operation.UNCERTAIN, error=say(_("The VM is no longer on its compute"))) raise if jobs: return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still merging the snapshot"), @@ -337,15 +342,15 @@ class DeleteExternalSnapshot(Kind): before = op.intent.get("before") # recorded with effects_started; absent only if run never began left = proxy.left_behind_since(before) if before else [] return Outcome(Operation.SUCCEEDED, result={"left_behind": left}) - return Outcome(Operation.UNCERTAIN, - error="The snapshot is still listed and no merge runs. It cannot be told whether the disks " - "changed; check the VM before deleting the snapshot again or reverting to it.") + return Outcome(Operation.UNCERTAIN, error=say(_( + "The snapshot is still listed and no merge runs. It cannot be told whether the disks changed; " + "check the VM before deleting the snapshot again or reverting to it."))) def log_entry(self, op, state): return _log(op, state, f"delete external snapshot {op.params['name']}") -GONE = "The VM is no longer on the compute the job ran on" +GONE = _("The VM is no longer on the compute the job ran on") @register @@ -363,7 +368,7 @@ class CreateSnapshot(Kind): check_snapshot_name(params["name"]) # recovery counts a listed snapshot as taken by the job if params["name"] in _proxy(_authorized(user, params["instance"])).instance.snapshotListNames(0): - raise util.OperationError(f"A snapshot named {params['name']} already exists") + raise util.OperationError(_("A snapshot named %(name)s already exists"), name=params["name"]) def plan(self, user, params): return _vm_plan(params) @@ -386,12 +391,12 @@ class CreateSnapshot(Kind): def reconcile(self, op): proxy = _recovery_proxy(op) if proxy is None: - return Outcome(Operation.UNCERTAIN, error=GONE) + return Outcome(Operation.UNCERTAIN, error=say(GONE)) if _libvirt_busy(proxy.instance): return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still taking the snapshot")) if op.params["name"] in proxy.instance.snapshotListNames(0): return Outcome(Operation.SUCCEEDED) - return Outcome(Operation.FAILED, error="The snapshot was not taken") + return Outcome(Operation.FAILED, error=say(_("The snapshot was not taken"))) def log_entry(self, op, state): kind = "external snapshot" if op.params["external"] else "snapshot" @@ -452,13 +457,13 @@ class RevertSnapshot(Kind): def reconcile(self, op): proxy = _recovery_proxy(op) if proxy is None: - return Outcome(Operation.UNCERTAIN, error=GONE) + return Outcome(Operation.UNCERTAIN, error=say(GONE)) if _libvirt_busy(proxy.instance): return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still reverting the VM")) if _current(proxy) == op.params["name"] != op.intent.get("was_current"): return Outcome(Operation.SUCCEEDED) - return Outcome(Operation.UNCERTAIN, error="It cannot be told whether the VM was reverted; " - "check it, then revert again or resolve this job.") + return Outcome(Operation.UNCERTAIN, error=say(_( + "It cannot be told whether the VM was reverted; check it, then revert again or resolve this job."))) def log_entry(self, op, state): kind = "external snapshot" if op.params["external"] else "snapshot" @@ -475,8 +480,8 @@ def _delete_created(proxy, op): raise -CLONE_STATE_REFUSED = "The VM must be running, paused or shut off to be cloned" -THAW_FAILED = "The guest's file systems could not be thawed ({error}); thaw them with: virsh domfsthaw {vm}" +CLONE_STATE_REFUSED = _("The VM must be running, paused or shut off to be cloned") +THAW_FAILED = _("The guest's file systems could not be thawed (%(error)s); thaw them with: virsh domfsthaw %(vm)s") def _check_clone_state(proxy, data): @@ -521,10 +526,10 @@ class CloneInstance(Kind): def precheck(self, op, ctx): instance = _clonable(op.user, op.params["instance"]) if instance.compute_id != op.intent["compute"]: - raise util.OperationError("The VM moved to another compute after the job was queued") + raise util.OperationError(MOVED) # a VM found on a host since the job was queued may have taken the name if Instance.objects.filter(name=op.intent["name"]).exists(): - raise util.OperationError(_("Instance '%(clone_name)s' already exists!") % {"clone_name": op.intent["name"]}) + raise util.OperationError(_("Instance '%(clone_name)s' already exists!"), clone_name=op.intent["name"]) _check_clone_state(instance.proxy, op.intent["clone"]) def run(self, op, ctx): @@ -560,7 +565,7 @@ class CloneInstance(Kind): raise Cancelled freeze = status == 1 and proxy.is_agent_ready() - warning = "" + thaw_error = None try: try: if freeze: @@ -572,13 +577,13 @@ class CloneInstance(Kind): try: proxy.fs_thaw() except libvirtError as err: # frozen stays recorded: recovery thaws again - warning = THAW_FAILED.format(error=err, vm=instance.name) + thaw_error = err else: ctx.record_intent(frozen=False) except libvirtError as err: # no backup of ours runs: the copy failed (if this read fails, recovery decides) if proxy.backup_targets() != {c["path"] for c in copies}: - ctx.record_intent(copy_result=[False, str(err)]) + ctx.record_intent(copy_result=[False, safe_text(err)]) raise op.refresh_from_db(fields=["cancel_requested"]) if op.cancel_requested: # the cancel hook may have run before the backup began @@ -590,18 +595,24 @@ class CloneInstance(Kind): while proxy.backup_progress() is not None: time.sleep(2) result = proxy.backup_result() - ctx.record_intent(copy_result=list(result) if result else None) + # libvirt's error text, like every stored text, without secrets + ctx.record_intent(copy_result=[result[0], safe_text(result[1] or "")] if result else None) if result is None: - raise util.OperationError("libvirt has no result of the copy") # recovery decides + raise util.OperationError(_("libvirt has no result of the copy")) # recovery decides success, errmsg = result if success: proxy.define_clone(clone, uuid, {c["dev"]: c["path"] for c in copies}) - return _clone_made(op, _finish_clone(op, clone, warning)) + thawed = say(THAW_FAILED, error=thaw_error, vm=instance.name) if thaw_error else [] + return _clone_made(op, _finish_clone(op, clone, thawed)) _delete_created(proxy, op) op.refresh_from_db(fields=["cancel_requested"]) if op.cancel_requested: raise Cancelled - raise util.OperationError(" ".join(filter(None, [errmsg or "The copy failed", warning]))) + if thaw_error: + raise util.OperationError(_("The copy failed: %(error)s. The guest's file systems could not be thawed " + "(%(thaw)s); thaw them with: virsh domfsthaw %(vm)s"), + error=errmsg or "", thaw=thaw_error, vm=instance.name) + raise util.OperationError(errmsg or _("The copy failed")) def progress(self, op): return _proxy(_instance(op.params["instance"])).backup_progress() if op.intent.get("live") else None @@ -625,15 +636,17 @@ class CloneInstance(Kind): proxy = _recovery_proxy(op) if proxy is None: created = ", ".join(c["path"] for c in op.intent.get("created", [])) - return Outcome(Operation.UNCERTAIN, error=f"{GONE}; check these volumes, then resolve this job: {created}") - warning = "" + return Outcome(Operation.UNCERTAIN, error=say(_( + "The VM is no longer on the compute the job ran on; check these volumes, then resolve this job: " + "%(volumes)s"), volumes=created)) + thawed = [] if op.intent.get("frozen") and proxy.get_status() == 1: try: proxy.fs_thaw() except libvirtError as err: - warning = " " + THAW_FAILED.format(error=err, vm=op.resource_label.rpartition("/")[2]) + thawed = say(THAW_FAILED, error=err, vm=op.resource_label.rpartition("/")[2]) outcome = self._live_outcome(proxy, op) - outcome.error += warning + outcome.error += thawed return outcome def _live_outcome(self, proxy, op): @@ -656,12 +669,14 @@ class CloneInstance(Kind): raise else: paths.append(t["path"]) - check = f"check these volumes, then resolve this job: {', '.join(paths)}" + volumes = ", ".join(paths) if len(paths) > len(created): - return Outcome(Operation.UNCERTAIN, error=f"A volume of the clone was being created; {check}") + return Outcome(Operation.UNCERTAIN, error=say(_( + "A volume of the clone was being created; check these volumes, then resolve this job: %(volumes)s"), + volumes=volumes)) if not op.intent.get("copy_started"): _delete_created(proxy, op) - return Outcome(ended, error="The job stopped before the copy started") + return Outcome(ended, error=say(_("The job stopped before the copy started"))) # recorded only once this job's backup ended or never started result = op.intent.get("copy_result") if result is None: @@ -670,16 +685,20 @@ class CloneInstance(Kind): if targets == set(paths): return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still copying the disks"), progress=proxy.backup_progress()) - return Outcome(Operation.UNCERTAIN, error=f"Another backup runs on the VM; {check}") + return Outcome(Operation.UNCERTAIN, error=say(_( + "Another backup runs on the VM; check these volumes, then resolve this job: %(volumes)s"), + volumes=volumes)) result = proxy.backup_result() if result is None: - return Outcome(Operation.UNCERTAIN, error=f"libvirt has no result of the copy; {check}") + return Outcome(Operation.UNCERTAIN, error=say(_( + "libvirt has no result of the copy; check these volumes, then resolve this job: %(volumes)s"), + volumes=volumes)) success, errmsg = result if not success: _delete_created(proxy, op) - return Outcome(ended, error=errmsg or "The copy failed") + return Outcome(ended, error=say(errmsg or _("The copy failed"))) proxy.define_clone(op.intent["clone"], op.intent["uuid"], {c["dev"]: c["path"] for c in created}) - return Outcome(Operation.SUCCEEDED, result=_clone_made(op, _finish_clone(op, op.intent["clone"], ""))) + return Outcome(Operation.SUCCEEDED, result=_clone_made(op, _finish_clone(op, op.intent["clone"], []))) def log_entry(self, op, state): source = op.resource_label.rpartition("/")[2] @@ -736,9 +755,9 @@ class CreateInstance(Kind): if data["cache_mode"] not in conn.get_cache_modes(): raise util.OperationError(_("Invalid cache mode")) if data["vcpu_mode"] not in cpu_choices(conn.get_dom_capabilities(data["arch"], data["machine"])): - raise util.OperationError(_("This host offers no CPU mode or model %(mode)s") % {"mode": data["vcpu_mode"]}) + raise util.OperationError(_("This host offers no CPU mode or model %(mode)s"), mode=data["vcpu_mode"]) if data["add_cdrom"] != "None" and data["add_cdrom"] not in cdrom_buses: - raise util.OperationError(_("A CD-ROM cannot use the %(bus)s bus") % {"bus": data["add_cdrom"]}) + raise util.OperationError(_("A CD-ROM cannot use the %(bus)s bus"), bus=data["add_cdrom"]) if data["cloud_init"]: if data["add_cdrom"] == "None": raise util.OperationError(_("The cloud-init seed is read from a CD-ROM: choose its bus")) @@ -750,7 +769,7 @@ class CreateInstance(Kind): except ValueError as err: raise util.OperationError(str(err)) if data["firmware"] and data["firmware"] not in conn.get_firmwares(data["arch"], data["machine"]): - raise util.OperationError(_("This host offers no firmware %(firmware)s") % {"firmware": data["firmware"]}) + raise util.OperationError(_("This host offers no firmware %(firmware)s"), firmware=data["firmware"]) if data["tpm"] and not conn.get_tpm_model(data["arch"], data["machine"]): raise util.OperationError(_("This host offers no TPM 2.0: install swtpm on it")) @@ -843,7 +862,8 @@ class CreateInstance(Kind): except libvirtError: leftovers.append(path) if leftovers: - raise util.OperationError(f"{err}. Remove these volumes by hand: {', '.join(leftovers)}") from err + raise util.OperationError(_("%(error)s. Remove these volumes by hand: %(volumes)s"), + error=err, volumes=", ".join(leftovers)) from err raise record_vm(op, owner=False) return {} @@ -867,11 +887,11 @@ def migration_target(instance, target_pk): another record of its host, or it cannot be reached.""" target = Compute.objects.get(pk=target_pk) if target.pk == instance.compute_id: - raise util.OperationError(_("The instance is already on %(compute)s") % {"compute": target.name}) + raise util.OperationError(_("The instance is already on %(compute)s"), compute=target.name) if not target.status: - raise util.OperationError(_("%(compute)s is not reachable") % {"compute": target.name}) + raise util.OperationError(_("%(compute)s is not reachable"), compute=target.name) if target.proxy.host_uuid() == instance.proxy.host_uuid(): - raise util.OperationError(_("%(compute)s is the VM's own host under another name") % {"compute": target.name}) + raise util.OperationError(_("%(compute)s is the VM's own host under another name"), compute=target.name) return target @@ -926,7 +946,7 @@ class MigrateInstance(Kind): raise PermissionDenied instance = _instance(op.params["instance"]) if instance.compute_id != op.intent["compute"]: - raise util.OperationError("The VM moved to another compute after the job was queued") + raise util.OperationError(MOVED) migration_target(instance, op.params["target"]) def run(self, op, ctx): @@ -944,7 +964,10 @@ class MigrateInstance(Kind): if op.cancel_requested: raise Cancelled from None # a superuser's job: libvirt's words, which a host the source cannot - # reach for the migration would hide behind "cannot be reached" + # reach for the migration would hide behind "cannot be reached"; + # ours keep their text and values + if isinstance(err, util.OperationError): + raise raise util.OperationError(str(err)) from err return {} @@ -974,9 +997,9 @@ class MigrateInstance(Kind): utils.record_migration(instance, Compute.objects.get(pk=op.params["target"])) return Outcome(Operation.SUCCEEDED) if source is not None and dest is None: - return Outcome(Operation.FAILED, error="The VM stayed on the source host") - return Outcome(Operation.UNCERTAIN, error="The VM is on both computes or on neither; " - "check them, then resolve this job.") + return Outcome(Operation.FAILED, error=say(_("The VM stayed on the source host"))) + return Outcome(Operation.UNCERTAIN, error=say(_( + "The VM is on both computes or on neither; check them, then resolve this job."))) DISK_DEV = re.compile(r"[a-z][a-z0-9]*") @@ -1015,7 +1038,7 @@ class MoveDisk(Kind): def normalize(self, raw): dev = str(raw.get("dev", "")) if not DISK_DEV.fullmatch(dev): - raise util.OperationError("Choose a disk of the VM") + raise util.OperationError(_("Choose a disk of the VM")) return {"instance": int(raw["instance"]), "dev": dev, "pool": str(raw.get("pool", ""))} def validate(self, user, params): @@ -1033,7 +1056,7 @@ class MoveDisk(Kind): def precheck(self, op, ctx): instance, _move = _movable(op.user, op.params) if instance.compute_id != op.intent["compute"]: - raise util.OperationError("The VM moved to another compute after the job was queued") + raise util.OperationError(MOVED) def run(self, op, ctx): instance, move = _movable(op.user, op.params) @@ -1075,22 +1098,23 @@ class MoveDisk(Kind): def reconcile(self, op): proxy = _recovery_proxy(op) if proxy is None: - return Outcome(Operation.UNCERTAIN, error=GONE) + return Outcome(Operation.UNCERTAIN, error=say(GONE)) dev, path, dest = op.params["dev"], op.intent.get("path"), op.intent.get("dest") dom = proxy.instance # a copy no worker waits for mirrors forever: it ends here if dest and dom.isActive() and dom.blockJobInfo(dev, 0): mirror = etree.fromstring(dom.XMLDesc(0)).xpath(f"devices/disk[target/@dev='{dev}']/mirror/source/@file") if mirror != [dest]: - return Outcome(Operation.UNCERTAIN, error=f"Another block job runs on {dev}; check the VM") + return Outcome(Operation.UNCERTAIN, error=say(_("Another block job runs on %(dev)s; check the VM"), dev=dev)) dom.blockJobAbort(dev, 0) named = proxy.disk_named(dev) if named == {dest}: return Outcome(Operation.SUCCEEDED, result={"left_behind": [path]}) if named == {path}: - return Outcome(Operation.FAILED, error=f"The disk stayed in {path}; remove {dest} if it is there") - return Outcome(Operation.UNCERTAIN, error=f"The definitions of {dev} name {', '.join(sorted(map(str, named)))}; " - "check the VM") + return Outcome(Operation.FAILED, error=say(_("The disk stayed in %(path)s; remove %(dest)s if it is there"), + path=path, dest=dest)) + return Outcome(Operation.UNCERTAIN, error=say(_("The definitions of %(dev)s name %(paths)s; check the VM"), + dev=dev, paths=", ".join(sorted(map(str, named))))) def log_entry(self, op, state): return _log(op, state, f"move disk {op.params['dev']} to pool {op.params['pool']}") diff --git a/instances/tests/test_clone_job.py b/instances/tests/test_clone_job.py index 7409cc4c..2a800231 100644 --- a/instances/tests/test_clone_job.py +++ b/instances/tests/test_clone_job.py @@ -15,6 +15,7 @@ from appsettings.settings import get_settings from computes.models import Compute from instances.jobs import CloneInstance from instances.models import Instance +from operations.utils import words from operations.jobs import Cancelled, Context from vrtManager.util import OperationError from operations.models import Claim, Operation, acquire @@ -119,7 +120,7 @@ class CloneJobTestCase(TransactionTestCase): self.proxy.start.side_effect = libvirt.libvirtError("no memory") op, result = self.clone_with(**{"clone-start": "true"}) self.assertTrue(Instance.objects.filter(uuid=op.intent["uuid"]).exists()) - self.assertIn("The clone is made, but not as asked", result["warning"]) + self.assertIn("The clone is made, but not as asked", words(result["warning"])) def queued(self): self.client.post(reverse("instances:clone", args=[self.source.pk]), @@ -155,7 +156,7 @@ class CloneJobTestCase(TransactionTestCase): conn.return_value.wvm.listAllStoragePools.return_value = [pool(["copy.qcow2", "other.qcow2"])] outcome = CloneInstance().reconcile(op) self.assertEqual(outcome.state, Operation.UNCERTAIN) - self.assertIn("copy.qcow2", outcome.error) + self.assertIn("copy.qcow2", words(outcome.error)) @patch("instances.jobs.wvmConnect") def test_a_clone_that_left_nothing_failed(self, conn): @@ -360,7 +361,7 @@ class LiveCloneJobTestCase(TransactionTestCase): self.assertLess(self.events.index("discard_backup_result"), self.events.index(("intent", "copy_started"))) self.assertEqual(self.deleted(), []) # recovery deletes them outcome = self.recover(op) - self.assertEqual((outcome.state, outcome.error), (Operation.FAILED, "libvirt error")) + self.assertEqual((outcome.state, words(outcome.error)), (Operation.FAILED, "libvirt error")) self.assertNotIn("define_clone", self.events) self.assertEqual(self.deleted(), ["/real/copy-vda.qcow2", "/real/copy-vdb.qcow2"]) @@ -386,7 +387,7 @@ class LiveCloneJobTestCase(TransactionTestCase): outcome = self.recover(op) self.assertEqual(self.events[0], "fs_thaw") self.assertEqual(outcome.state, Operation.FAILED) - self.assertIn("virsh domfsthaw src", outcome.error) + self.assertIn("virsh domfsthaw src", words(outcome.error)) def test_a_lost_connection_while_the_backup_starts_is_recoverys(self): self.track("begin_clone_backup", libvirt_error(libvirt.VIR_ERR_INTERNAL_ERROR)) @@ -418,8 +419,8 @@ class LiveCloneJobTestCase(TransactionTestCase): def test_a_thaw_error_is_a_warning_of_the_result(self): self.track("fs_thaw", libvirt_error(libvirt.VIR_ERR_AGENT_UNRESPONSIVE)) result = self.run_job(self.op()) - self.assertIn("could not be thawed", result["warning"]) - self.assertIn("thaw them with: virsh domfsthaw src", result["warning"]) + self.assertIn("could not be thawed", words(result["warning"])) + self.assertIn("thaw them with: virsh domfsthaw src", words(result["warning"])) self.proxy.define_clone.assert_called_once() def test_a_failed_copy_deletes_only_what_the_job_created(self): @@ -528,7 +529,7 @@ class LiveCloneJobTestCase(TransactionTestCase): outcome, _op = self.recovered(copy_started=None, created=[], planned=[{"dev": "vda", "path": "/pool/copy-vda.qcow2"}]) self.assertEqual(outcome.state, Operation.UNCERTAIN) - self.assertIn("/pool/copy-vda.qcow2", outcome.error) + self.assertIn("/pool/copy-vda.qcow2", words(outcome.error)) self.proxy.wvm.storagePoolLookupByName.assert_called_with("default") self.proxy.wvm.storagePoolLookupByName.return_value.storageVolLookupByName.assert_called_with("copy-vda.qcow2") self.proxy.get_volume_by_path.return_value.delete.assert_not_called() @@ -581,7 +582,7 @@ class LiveCloneJobTestCase(TransactionTestCase): def test_recovery_of_a_failed_copy_deletes_what_was_created(self): outcome, _op = self.recovered(copy_result=[False, "No space left on device"]) self.assertEqual(outcome.state, Operation.FAILED) - self.assertIn("No space left on device", outcome.error) + self.assertIn("No space left on device", words(outcome.error)) self.assertEqual(self.deleted(), ["/real/copy-vda.qcow2"]) outcome, _op = self.recovered(copy_result=[False, "Operation canceled"], cancel=True) self.assertEqual(outcome.state, Operation.CANCELLED) @@ -589,7 +590,7 @@ class LiveCloneJobTestCase(TransactionTestCase): def test_a_recorded_failure_wins_over_another_tools_backup(self): self.proxy.backup_targets.return_value = {"/other/x.qcow2"} outcome, _op = self.recovered(copy_result=[False, "another backup runs"]) - self.assertEqual((outcome.state, outcome.error), (Operation.FAILED, "another backup runs")) + self.assertEqual((outcome.state, words(outcome.error)), (Operation.FAILED, "another backup runs")) self.assertEqual(self.deleted(), ["/real/copy-vda.qcow2"]) outcome, _op = self.recovered(copy_result=[False, "another backup runs"], cancel=True) self.assertEqual(outcome.state, Operation.CANCELLED) @@ -598,7 +599,7 @@ class LiveCloneJobTestCase(TransactionTestCase): self.proxy.backup_result.return_value = None outcome, _op = self.recovered() self.assertEqual(outcome.state, Operation.UNCERTAIN) - self.assertIn("/real/copy-vda.qcow2", outcome.error) + self.assertIn("/real/copy-vda.qcow2", words(outcome.error)) self.assertEqual(self.deleted(), []) def test_recovery_thaws_a_guest_left_frozen_first(self): diff --git a/instances/tests/test_cloud_init_create.py b/instances/tests/test_cloud_init_create.py index 33999f77..6df3bada 100644 --- a/instances/tests/test_cloud_init_create.py +++ b/instances/tests/test_cloud_init_create.py @@ -11,6 +11,7 @@ from computes.models import Compute from django.contrib.auth import get_user_model from django.test import TransactionTestCase from instances.jobs import CreateInstance +from operations.utils import words from operations.jobs import Context from operations.models import Operation, acquire from vrtManager.util import OperationError @@ -97,4 +98,4 @@ class CloudInitCreateTestCase(TransactionTestCase): SimpleNamespace(listVolumes=lambda: ["cidata-abababab-0000-1111-2222-333333333333.iso"])] outcome = CreateInstance().reconcile(op) self.assertEqual(outcome.state, Operation.UNCERTAIN) - self.assertIn("cidata-abababab-0000-1111-2222-333333333333.iso", outcome.error) + self.assertIn("cidata-abababab-0000-1111-2222-333333333333.iso", words(outcome.error)) diff --git a/instances/tests/test_disk_move_job.py b/instances/tests/test_disk_move_job.py index 354798ef..6841d6f6 100644 --- a/instances/tests/test_disk_move_job.py +++ b/instances/tests/test_disk_move_job.py @@ -14,6 +14,7 @@ from appsettings.settings import get_settings from computes.models import Compute from instances.jobs import MoveDisk from instances.models import Instance +from operations.utils import words from operations.jobs import Cancelled, Context, get_kind from operations.models import Claim, Operation, acquire @@ -116,7 +117,7 @@ class MoveDiskJobTestCase(TransactionTestCase): def test_recovery_reads_the_definitions(self): self.assertEqual(self.reconcile({NEW}).state, Operation.SUCCEEDED) failed = self.reconcile({OLD}) - self.assertEqual((failed.state, failed.error), (Operation.FAILED, f"The disk stayed in {OLD}; remove {NEW} " + self.assertEqual((failed.state, words(failed.error)), (Operation.FAILED, f"The disk stayed in {OLD}; remove {NEW} " "if it is there")) self.assertEqual(self.reconcile({OLD, NEW}).state, Operation.UNCERTAIN) self.proxy.instance.blockJobAbort.assert_not_called() diff --git a/instances/tests/test_snapshot_delete_job.py b/instances/tests/test_snapshot_delete_job.py index 3eccfe33..e88d2121 100644 --- a/instances/tests/test_snapshot_delete_job.py +++ b/instances/tests/test_snapshot_delete_job.py @@ -15,6 +15,7 @@ from computes.models import Compute from instances.jobs import DeleteExternalSnapshot from instances.models import Instance from logs.models import Logs +from operations.utils import say, words from operations import jobs from operations.jobs import Context, Outcome from operations.models import Claim, Operation, acquire @@ -109,7 +110,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase): Logs.objects.all().delete() Operation.objects.all().delete() op = self.submitted() - jobs.finish(op, Outcome(state, error="x")) + jobs.finish(op, Outcome(state, error=say("x"))) self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", f"VM job-vm: delete external snapshot one ({state})")]) Logs.objects.all().delete() @@ -125,7 +126,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase): def test_a_resolved_job_is_logged_on_its_vm(self): op = self.submitted() - jobs.finish(op, Outcome(Operation.UNCERTAIN, error="x")) + jobs.finish(op, Outcome(Operation.UNCERTAIN, error=say("x"))) Logs.objects.all().delete() jobs.resolve(op.pk, self.admin, Operation.FAILED, "disks checked") self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", @@ -134,7 +135,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase): def test_retention_keeps_the_log(self): op = self.submitted() - jobs.finish(op, Outcome(Operation.FAILED, error="x")) + jobs.finish(op, Outcome(Operation.FAILED, error=say("x"))) Operation.objects.filter(pk=op.pk).update(finished_at=timezone.now() - timedelta(days=400)) housekeeping() self.assertFalse(Operation.objects.filter(pk=op.pk).exists()) @@ -171,7 +172,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase): interrupted = self.reconcile() self.assertEqual(interrupted.state, Operation.UNCERTAIN) self.assertIn("It cannot be told whether the disks changed; check the VM before deleting the snapshot again", - interrupted.error) + words(interrupted.error)) def test_reconcile_reports_a_merge_that_stays_at_its_end_as_stalled(self): at_end = self.reconcile(jobs=[{"dev": "vda", "cur": 2, "end": 2}, {"dev": "vdb", "cur": 5, "end": 5}]) diff --git a/locale/tr/LC_MESSAGES/django.mo b/locale/tr/LC_MESSAGES/django.mo index 4736eb82c4605a6e4d03f410f8a4cbddaa08fa5f..e854198eba2d8ca7c63eb4d8c32ffa6facb50d7a 100644 GIT binary patch delta 44507 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 z zWw?9r{L||^@y#~RS;{T9;R!UbmpTHt%W(f?XJ7_lU4K*VHyd6=nd+MVOe8)gV^s=2 zA%33>hly9juX&c5Th|5(M1L}Zox~p#Pe#7Eg!j>rIE34q?9O7sX>B-?XI|Tx+NAHX z^|*s5s4JQ~E17;J(>(4sWSWD1G@gzZI2`$&PVw&LzQuVM2mPcu5)pL}%) zmnOYB_a^R^)WL@T_bN!*6Vi54Pe;OODW@w)>%YP_c+@tc2W@S5ISp>&PD-KnV&m%s&ursQ3RyTR}%MSE1my z_+OVDN?e|W@n9VlCRXFzyD9rCb;Mq~2_zx@%66bJ9o#~Fd5OO!znVJ6AF~MmM%W!_ z3$G(nN-~!rV;3HLPJAYr24fDaL%zEd9DpUcUy>(;XW!r?;%^A+noQn#)R6#nUE$eI z${ixSnfn{kFY^2_`B&)s|213iGamFHe1eD7uo%8mcezU0JesYlHZ6s1pd8P#(ec=; zI^jM<;*vg)Ix3StBjsYRKS+DTZ7$aTBM)EL413UH8<=df^dxN|cW&Z;aTlfVkGAps zwqreQnXZ_O=RL_cmAf1F3G)7ly2f#<=s4=zPrlmv{a+CRGpQ&a_W+(|AVYHWBhz@E zbs^6H+dx0!vw6M@)8hi3W#HL&xRNq=@O$E4^Gw$O@>RuLq-P?%Eca)GXOgbVT|l9Y z1jgEgPBeOe47KUZ5Hcqv+#N3ypGe*#gy(Z_AiXJRs|atWY&6ewmB9DZ@#!^@=V?hF zLV8)s<)w_5viJ11Zn>;MagDE70Q$aYEAye$NoIn(5vnaIP zCT1h9>puC4Q+^iVKd?JK(Y%~y?bL?o0S0&j;Cx+EHdb_XgqvF*lvqM&3`a9XvZmS|bvV zQvQMMpz^#V&gD8GWY}pR3?St+;T;%_m$~b4=cIv&+y%KqYzI_2_KKmh6x>az%a1%2 z>C80KfU}TH8%T@2B0uS5V*UKrp9a3S84eOINx@R&j%zdaqrs1)>sm#chu85a(mIot z$d)_Cvp^cX$32Jg&29Q2;<}C#5B#Lz>x5^iPTF^pk?A5CLJ7Cw*0uSQ%)JS}vmJO# zzAUzK<@4umOJ|p zjW#DOCwF1u-S7&ouz4=fPyzgT+xgz)$x6J9jq})d9p=v*+^6I6 z4vR+xTgW(r3TAVEMdqw=*8lKrP~76dk&zLR0a1m$&4a@t`UiWXdj&@Yd;3R(#)Jh& zdAkkr_6iISD(}sm&n5*#txquHbc)>JfqkQTMMQhU1H*!Ycq{{=yn&&S!GS@8yukxQ zqN1bLpIw?hP8VC7H=>7X_jV7B2oLrS2&DGlkno<~?v#iQ4yxeo-Yd9!pa1SaK}Ey8 zk-3_H29ubkg!Fyx+hDHPid3%I}heY*Kx725ws7?n3hX&JK{?9-n zcoOOD7TCQ{OkZzgOn8(xBHWj|b)*01VA?R9|C`bLr{2&AW`n{0TXpVyLBah4qCTnH z+q%*E9%~yXN){9n)hBj-TQ{1rV(XODQ8C@S2L}f+xQIw^kHC=7;Gp%TwiQg5G>nCg z9OTW{D5QG8l#ml8r?@B5CG#?VU*CPxwtm`)q-i^L4-D5tc4J;5LW8^mdIkFitodl& z$hUxz!Tp0HKUuqw=zoW(2j0M*fg#~)|9>uE;q`NF|CuB+OTc=Vo%Qe5p+rnn@cMqw z-zW735)SW9RjNmGL7&tiTVK%nULRv}u-T%6Bf|qjy;1+~yK=@+uVw% zYIM6BJ1(|UY&PFe!h=J!DxYkMuz%O!zYoo(4LLkd{i^De2eG2et=Yh_qYUi9EdD!I zZS{UJ!7=P~J40bHQQ8bsKF-PVnaZ;a=8Vmllgks3F)BK6kT#n+0#Ok$k==v6y&|Hb zXKc^qDeU_8i?&lxFh`4wyjD1;v5U_(2#NOP2@UQMT{yN)_2QF4pN#T9FRA~_aR2Ka z;cU8{%hM!YO5c+Enz6@tQ{nucjvhCnZ|p0pptoC0^uMzZ6rxT>d!vGRuSDCILToQS z+3o*5)292~RjE06zTtclSKt19@iM)xw`WXnRCL}bS_lpHMh%LJ4i3{6jp5v>eVxHx zfdiPfe7W-}6c81lH&fsMbv4X8fK5=|+dm{Ss+Tt?BCJQ0?-4KUuz;xlG#)#fVSzzr z-*=CRj0_Hs_HLS7z?0ciOz*ttkO;lVdeB4P+s3>obLZV)z!8DbSm6Q`xefYw@$hj>z{h`aeD+Hzlv)SrpHWeN{0dQPClx zq25qFG5>Qe!h$3DEYqh-VQ)P?&HnH2V!iv>D|}A({I5?@y`=5uns2k~vsSZcUXo?l zRiC_`^}60CMDd7NYcD|qY=Xsy!nLUu;w@s!QY&>Bac6jqB`~F2WLQ)4-NjJbKE>U$6Ltjwj zT1GJ>2wAm>_Nxe@_m2T)paRD=v16H1DvD1s!GVESL`M9|8pv!$ujWV_aa;YSs%ra zupp{nJP;kofqI^yVDfb~`}j^644PhuoDKCvHYbj6=y>EG$f)k)} zmYl*oC&wbv`&VQ%gq}Vi8D>YzN|e`GmDQ4~KtBoUGVcQdDhGrLqC}gtUa61rKiVL5 z-Z&>pWuPF@4gn0SpmhqD1_HC!c6A+i#8db=>aFF>NISe}u+ivOgWXv_+@{5aYrZxm zU0JdWS87yhts*UHFX8p*TZfJHY+1CQdaGgU0vp2%=ULDCDx9SaVsWGYF0!xIOpl@A zK7>@lx6#>B!-Ou>J>T^$KX6>#FK7$(&WlaQ_FTu(3_xp0t2nlc(|t`Y)TI{B#q-s; z*%i{wOUzyOMCwORw;Gy>OD$oSL^bNW%zkFI$+&4+2F%Zt^x&QmAx;#!irXchdK!ak zNmrKzEy&iYw5ae_KW>QfcAr|T;CG2XDUn4m;o)cE9u~2U{kkfq`9e>c2a+wyy z%_^D!KJ2`9%5uU}=h@cYN=IaY)H*8{-Tj1}+1T3|I|fcgP4tCP4NqQSU-pk68{xJZ zh+YeXMioiFsk9o^=-w6fO7AYz#xh!{zp}8JCrVmH1lZwN!WCwCDq{PGsMDH^=Wxph zN5$-PDA4Tl)_RU?GTc;43IrW-dcG)vOOQv^h#Bvo}g^UM$u z7`CLo!f(os1MDoRE&vVZMil0>H_HUq3CQ_$Q8VNDay0xCduP+G5dte9g5VQKo4y4| zsX`%$=;J@x?D@5l#^nVSfIe46hJ zKi$q}U&8#h8YWa2v-sE`M}Nr<6y0y;+|DIUV|faYXZjsU7yAru>UPq@FIuMg{eJU=+3={{g=2~^)I z(HN(Zo1#p20_G+9H$2g~jS|`1W=7ohM?5b z2%ew{D)eZ!Y^lw}^Da%=s#;?Y=hS(YC@m!mlu7rOd)5agQ4g26@G%HOtM1$3`0uRe zq~dj=Xdu7SaYGxRAC|}_3)H`Y=}wYdR2Nm;sJMquu+fZ80i0H&1kyr-+76r2hY>`V zh`Kuw)y=852KYZ~C<*fB&%j|5+X2-H1PX-tiooO4fa^{r5GeyHF^R73<+s_;sN2*Aq`7-ZGiViXZP`OwzgfZk*;bj`tDViEBbDjSMtxR z0ZRx@;UPy~3}He6n0ABd10fkxqWjT-1AOq2J^;U)(^7~d>gYs)PpqpQwlOrOLKigK zi)DtPP%^(?9OkzUDnkKVErds| z$FHKR6$lrm-9Zu=H^5U+KTCK~OaPILXEZs&53<@x<;{oGOyWh2ym$hMg}MO%rn#z$ zQ20rxsuiSRRFHNN!Cz$6Q|lfO*gX^(wZuVF^w7kTf~s_YgA7kjAC;MsWUWvMwiIjP zFo-UGgGmT*S+Xuc1EbH6@anofidI0l7BOH4OUtqc2Zhn50k1~*-+CI`_Pv3i@W#2Q zI+l`p{8Fil1PSUzJP=jzchnR-$kzs7?G-kWnUVk~B*7%}?khcic#c&*J9=S;|G>sm z&pm+fJMmf}2bRtanIjAoIZBOqk~EN9kO29>my$pXf^=;HvK?Oj1;05(hxlXrVc(U< ST^%g#@LrJ@qcf5}wf29b_bZG5 delta 38279 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, 2020\n" "Language-Team: Turkish (https://www.transifex.com/catborise/teams/110663/" @@ -2125,8 +2125,7 @@ msgid "of %(memory)s" msgstr "/ %(memory)s" #: computes/templates/computes/list.html:58 computes/templates/overview.html:58 -#: instances/forms.py:15 instances/jobs.py:132 -#: instances/templates/allinstances_index_grouped.html:16 +#: instances/forms.py:15 instances/templates/allinstances_index_grouped.html:16 #: instances/templates/allinstances_index_nongrouped.html:14 #: instances/templates/create_instance_w2.html:63 #: instances/templates/instances/settings_tab.html:852 @@ -2654,201 +2653,364 @@ msgstr "" msgid "The name of the virtual machine must not contain any special characters" msgstr "Sanal makine adı özel karakterler içeremez" -#: instances/jobs.py:133 -msgid "memory (MiB)" -msgstr "bellek (MiB)" +#: instances/jobs.py:68 +msgid "The VM moved to another compute after the job was queued" +msgstr "İş kuyruğa alındıktan sonra sanal makine başka bir sunucuya taşındı" #: instances/jobs.py:136 -#, python-format -msgid "Enter a whole number of %(what)s for the clone" -msgstr "Klon için %(what)s olarak bir tam sayı girin" +msgid "Enter a whole number of vCPUs for the clone" +msgstr "Klon için vCPU sayısını tam sayı olarak girin" -#: instances/jobs.py:160 +#: instances/jobs.py:138 +msgid "Enter a whole number of memory (MiB) for the clone" +msgstr "Klon için belleği (MiB) tam sayı olarak girin" + +#: instances/jobs.py:165 msgid "Choose the clone's name from the list of free names" msgstr "Klonun adını boş adlar listesinden seçin" -#: instances/jobs.py:187 +#: instances/jobs.py:192 #, python-format msgid "User '%(quota_msg)s' quota reached, cannot create '%(clone_name)s'!" msgstr "" "%(quota_msg)s' Kullanıcı kotası doldu, '%(clone_name)s' in CPU su yeniden " "boyutlandırılamıyor!" -#: instances/jobs.py:190 instances/jobs.py:527 +#: instances/jobs.py:195 instances/jobs.py:532 #, python-format msgid "Instance '%(clone_name)s' already exists!" msgstr "'%(clone_name)s' sanal makinesi zaten mevcut!" -#: instances/jobs.py:193 +#: instances/jobs.py:197 #, python-format msgid "Instance name '%(clone_name)s' contains invalid characters!" msgstr "Sanal makine adı '%(clone_name)s' geçersiz karakterler içermektedir!" -#: instances/jobs.py:195 +#: instances/jobs.py:200 #, python-format msgid "Instance MAC '%(clone_mac)s' invalid format!" msgstr "Sanal makine MAC adresi '%(clone_mac)s' geçersiz biçemde belirtilmiş!" -#: instances/jobs.py:198 +#: instances/jobs.py:202 #, python-format msgid "Disk name '%(disk_name)s' contains invalid characters!" msgstr "Disk adı '%(disk_name)s' geçersiz karakterler içeriyor!" -#: instances/jobs.py:217 +#: instances/jobs.py:222 #, python-format msgid "The clone is made, but not as asked: %(error)s" msgstr "Klon oluşturuldu, ama istendiği gibi değil: %(error)s" -#: instances/jobs.py:288 +#: instances/jobs.py:270 +msgid "The VM was not defined and no volume of it was left." +msgstr "Sanal makine tanımlanmadı ve ondan geriye birim kalmadı." + +#: instances/jobs.py:272 +#, python-format +msgid "" +"The VM was not defined; check these volumes, then resolve this job: " +"%(volumes)s" +msgstr "" +"Sanal makine tanımlanmadı; şu birimleri kontrol edin, sonra bu işi çözün: " +"%(volumes)s" + +#: instances/jobs.py:293 msgid "Delete snapshot" msgstr "Anlık görüntüyü sil" -#: instances/jobs.py:311 +#: instances/jobs.py:303 +#, python-format +msgid "The VM has no external snapshot named %(name)s" +msgstr "Sanal makinede %(name)s adında harici anlık görüntü yok" + +#: instances/jobs.py:316 msgid "Merging the snapshot" msgstr "Anlık görüntü birleştiriliyor" -#: instances/jobs.py:333 +#: instances/jobs.py:335 +msgid "The VM is no longer on its compute" +msgstr "Sanal makine artık kendi sunucusunda değil" + +#: instances/jobs.py:338 msgid "libvirt is still merging the snapshot" msgstr "libvirt anlık görüntüyü hâlâ birleştiriyor" -#: instances/jobs.py:354 +#: instances/jobs.py:346 +msgid "" +"The snapshot is still listed and no merge runs. It cannot be told whether " +"the disks changed; check the VM before deleting the snapshot again or " +"reverting to it." +msgstr "" +"Anlık görüntü hâlâ listede ve çalışan bir birleştirme yok. Disklerin değişip " +"değişmediği bilinemiyor; anlık görüntüyü yeniden silmeden ya da ona dönmeden " +"önce sanal makineyi kontrol edin." + +#: instances/jobs.py:353 +msgid "The VM is no longer on the compute the job ran on" +msgstr "Sanal makine artık işin çalıştığı sunucuda değil" + +#: instances/jobs.py:359 msgid "Take snapshot" msgstr "Anlık görüntü al" -#: instances/jobs.py:378 +#: instances/jobs.py:371 +#, python-format +msgid "A snapshot named %(name)s already exists" +msgstr "%(name)s adında bir anlık görüntü zaten var" + +#: instances/jobs.py:383 msgid "Taking the snapshot" msgstr "Anlık görüntü alınıyor" -#: instances/jobs.py:391 +#: instances/jobs.py:396 msgid "libvirt is still taking the snapshot" msgstr "libvirt anlık görüntüyü hâlâ alıyor" -#: instances/jobs.py:417 +#: instances/jobs.py:399 +msgid "The snapshot was not taken" +msgstr "Anlık görüntü alınmadı" + +#: instances/jobs.py:422 msgid "Revert snapshot" msgstr "Snapshota dön" -#: instances/jobs.py:446 +#: instances/jobs.py:451 msgid "Reverting the VM" msgstr "Sanal makine geri döndürülüyor" -#: instances/jobs.py:457 +#: instances/jobs.py:462 msgid "libvirt is still reverting the VM" msgstr "libvirt sanal makineyi hâlâ geri döndürüyor" -#: instances/jobs.py:498 +#: instances/jobs.py:466 +msgid "" +"It cannot be told whether the VM was reverted; check it, then revert again " +"or resolve this job." +msgstr "" +"Sanal makinenin geri döndürülüp döndürülmediği bilinemiyor; kontrol edin, " +"sonra yeniden geri döndürün ya da bu işi çözün." + +#: instances/jobs.py:483 +msgid "The VM must be running, paused or shut off to be cloned" +msgstr "" +"Klonlanmak için sanal makine çalışıyor, duraklatılmış ya da kapalı olmalı" + +#: instances/jobs.py:484 +#, python-format +msgid "" +"The guest's file systems could not be thawed (%(error)s); thaw them with: " +"virsh domfsthaw %(vm)s" +msgstr "" +"Konuğun dosya sistemleri çözülemedi (%(error)s); şununla çözün: virsh " +"domfsthaw %(vm)s" + +#: instances/jobs.py:503 msgid "Clone VM" msgstr "Sanal makineyi klonla" -#: instances/jobs.py:537 instances/jobs.py:544 instances/jobs.py:787 +#: instances/jobs.py:542 instances/jobs.py:549 instances/jobs.py:805 msgid "Copying disks" msgstr "Diskler kopyalanıyor" -#: instances/jobs.py:671 +#: instances/jobs.py:600 +msgid "libvirt has no result of the copy" +msgstr "libvirt'te kopyanın sonucu yok" + +#: instances/jobs.py:611 +#, python-format +msgid "" +"The copy failed: %(error)s. The guest's file systems could not be thawed " +"(%(thaw)s); thaw them with: virsh domfsthaw %(vm)s" +msgstr "" +"Kopyalama başarısız oldu: %(error)s. Konuğun dosya sistemleri çözülemedi " +"(%(thaw)s); şununla çözün: virsh domfsthaw %(vm)s" + +#: instances/jobs.py:614 instances/jobs.py:698 +msgid "The copy failed" +msgstr "Kopyalama başarısız oldu" + +#: instances/jobs.py:639 +#, python-format +msgid "" +"The VM is no longer on the compute the job ran on; check these volumes, then " +"resolve this job: %(volumes)s" +msgstr "" +"Sanal makine artık işin çalıştığı sunucuda değil; şu birimleri kontrol edin, " +"sonra bu işi çözün: %(volumes)s" + +#: instances/jobs.py:674 +#, python-format +msgid "" +"A volume of the clone was being created; check these volumes, then resolve " +"this job: %(volumes)s" +msgstr "" +"Klonun bir birimi oluşturuluyordu; şu birimleri kontrol edin, sonra bu işi " +"çözün: %(volumes)s" + +#: instances/jobs.py:678 +msgid "The job stopped before the copy started" +msgstr "İş, kopyalama başlamadan durdu" + +#: instances/jobs.py:685 msgid "libvirt is still copying the disks" msgstr "libvirt diskleri hâlâ kopyalıyor" -#: instances/jobs.py:703 instances/templates/create_instance_w1.html:60 +#: instances/jobs.py:688 +#, python-format +msgid "" +"Another backup runs on the VM; check these volumes, then resolve this job: " +"%(volumes)s" +msgstr "" +"Sanal makinede başka bir yedekleme çalışıyor; şu birimleri kontrol edin, " +"sonra bu işi çözün: %(volumes)s" + +#: instances/jobs.py:693 +#, python-format +msgid "" +"libvirt has no result of the copy; check these volumes, then resolve this " +"job: %(volumes)s" +msgstr "" +"libvirt'te kopyanın sonucu yok; şu birimleri kontrol edin, sonra bu işi " +"çözün: %(volumes)s" + +#: instances/jobs.py:721 instances/templates/create_instance_w1.html:60 #: instances/templates/create_instance_w2.html:356 msgid "Create VM" msgstr "Sanal makine oluştur" -#: instances/jobs.py:726 instances/views.py:2477 +#: instances/jobs.py:744 instances/views.py:2477 msgid "A virtual machine with this name already exists" msgstr "Bu isimde bir sanal makine mevcut" -#: instances/jobs.py:728 +#: instances/jobs.py:746 msgid "There is an instance with same name. Remove it and try again!" msgstr "Aynı isimde başka bir sanal makine var. Onu silip yeniden deneyin!" -#: instances/jobs.py:731 +#: instances/jobs.py:749 msgid "First you need to create or select an image" msgstr "Öncelikle bir imaj oluşturun ya da mevcut bir imajı seçin" -#: instances/jobs.py:733 +#: instances/jobs.py:751 msgid "Choose one disk source: a new disk, existing volumes or a template" msgstr "" "Tek bir disk kaynağı seçin: yeni bir disk, mevcut birimler ya da bir şablon" -#: instances/jobs.py:735 +#: instances/jobs.py:753 msgid "Image has already exist. Please check volumes or change instance name" msgstr "" "İmaj zaten mevcut. Lütfen ya makine adını değişin ya da disk alanlarını " "kontrol edin" -#: instances/jobs.py:737 +#: instances/jobs.py:755 msgid "Invalid cache mode" msgstr "Geçersiz önbellek modu" -#: instances/jobs.py:739 +#: instances/jobs.py:757 #, python-format msgid "This host offers no CPU mode or model %(mode)s" msgstr "Bu sunucu %(mode)s CPU modunu veya modelini sunmuyor" -#: instances/jobs.py:741 +#: instances/jobs.py:759 #, python-format msgid "A CD-ROM cannot use the %(bus)s bus" msgstr "CD-ROM %(bus)s veri yolunu kullanamaz" -#: instances/jobs.py:744 +#: instances/jobs.py:762 msgid "The cloud-init seed is read from a CD-ROM: choose its bus" msgstr "cloud-init tohumu bir CD-ROM'dan okunur: veri yolunu seçin" -#: instances/jobs.py:747 +#: instances/jobs.py:765 msgid "The cloud-init seed needs a directory storage pool for the disk" msgstr "cloud-init tohumu, disk için bir dizin depolama havuzu gerektirir" -#: instances/jobs.py:753 +#: instances/jobs.py:771 #, python-format msgid "This host offers no firmware %(firmware)s" msgstr "Bu sunucu %(firmware)s firmware'ini sunmuyor" -#: instances/jobs.py:755 +#: instances/jobs.py:773 msgid "This host offers no TPM 2.0: install swtpm on it" msgstr "Bu sunucu TPM 2.0 sunmuyor: üzerine swtpm kurun" -#: instances/jobs.py:806 +#: instances/jobs.py:824 msgid "Writing the cloud-init seed" msgstr "cloud-init tohumu yazılıyor" -#: instances/jobs.py:813 +#: instances/jobs.py:831 msgid "Defining the VM" msgstr "Sanal makine tanımlanıyor" -#: instances/jobs.py:870 +#: instances/jobs.py:864 +#, python-format +msgid "%(error)s. Remove these volumes by hand: %(volumes)s" +msgstr "%(error)s. Şu birimleri elle silin: %(volumes)s" + +#: instances/jobs.py:889 #, python-format msgid "The instance is already on %(compute)s" msgstr "Sanal makine zaten %(compute)s üzerinde" -#: instances/jobs.py:872 +#: instances/jobs.py:891 #, python-format msgid "%(compute)s is not reachable" msgstr "%(compute)s sunucusuna ulaşılamıyor" -#: instances/jobs.py:874 +#: instances/jobs.py:893 #, python-format msgid "%(compute)s is the VM's own host under another name" msgstr "%(compute)s, sanal makinenin kendi sunucusunun başka bir adı" -#: instances/jobs.py:892 +#: instances/jobs.py:911 msgid "Migrate VM" msgstr "Sanal makineyi taşı" -#: instances/jobs.py:936 +#: instances/jobs.py:955 msgid "Migrating the VM" msgstr "Sanal makine taşınıyor" -#: instances/jobs.py:970 +#: instances/jobs.py:989 msgid "libvirt is still migrating the VM" msgstr "libvirt sanal makineyi hâlâ taşıyor" -#: instances/jobs.py:1011 +#: instances/jobs.py:996 +msgid "The VM stayed on the source host" +msgstr "Sanal makine kaynak sunucuda kaldı" + +#: instances/jobs.py:998 +msgid "" +"The VM is on both computes or on neither; check them, then resolve this job." +msgstr "" +"Sanal makine ya iki sunucuda da var ya da hiçbirinde yok; kontrol edin, " +"sonra bu işi çözün." + +#: instances/jobs.py:1030 msgid "Move disk" msgstr "Diski taşı" -#: instances/jobs.py:1042 +#: instances/jobs.py:1037 +msgid "Choose a disk of the VM" +msgstr "Sanal makinenin bir diskini seçin" + +#: instances/jobs.py:1061 msgid "Copying the disk" msgstr "Disk kopyalanıyor" +#: instances/jobs.py:1104 +#, python-format +msgid "Another block job runs on %(dev)s; check the VM" +msgstr "" +"%(dev)s üzerinde başka bir blok işi çalışıyor; sanal makineyi kontrol edin" + +#: instances/jobs.py:1110 +#, python-format +msgid "The disk stayed in %(path)s; remove %(dest)s if it is there" +msgstr "Disk %(path)s içinde kaldı; varsa %(dest)s dosyasını silin" + +#: instances/jobs.py:1112 +#, python-format +msgid "The definitions of %(dev)s name %(paths)s; check the VM" +msgstr "%(dev)s tanımları %(paths)s gösteriyor; sanal makineyi kontrol edin" + #: instances/models.py:18 msgid "label" msgstr "etiket" @@ -7438,11 +7600,35 @@ msgstr "" "Harfler, rakamlar, nokta, tire ve alt çizgi. Kopya, filtrenin kurallarını ve " "başvurularını içerir." -#: operations/api/serializers.py:35 +#: operations/api/serializers.py:37 #, python-format msgid "%(phase)s (over its time limit)" msgstr "%(phase)s (süre sınırını aştı)" +#: operations/jobs.py:157 +msgid "The form has no valid submission key; reload the page" +msgstr "Formun geçerli bir gönderim anahtarı yok; sayfayı yeniden yükleyin" + +#: operations/jobs.py:203 +msgid "This job cannot be cancelled once it has started" +msgstr "Bu iş başladıktan sonra iptal edilemez" + +#: operations/jobs.py:206 +msgid "The job has already ended" +msgstr "İş zaten bitti" + +#: operations/jobs.py:254 +msgid "Choose succeeded or failed" +msgstr "Başarılı ya da başarısız seçin" + +#: operations/jobs.py:257 +msgid "Write a note on what was checked" +msgstr "Neyin kontrol edildiğine dair bir not yazın" + +#: operations/jobs.py:261 +msgid "Only an uncertain job can be resolved" +msgstr "Yalnızca belirsiz bir iş çözülebilir" + #: operations/templates/operations/job.html:11 #: operations/templates/operations/jobs.html:7 #: operations/templates/operations/jobs.html:9 @@ -7629,12 +7815,12 @@ msgstr "İptal edilemedi: sunucuya ulaşılamadı." msgid "The host of this virtual machine cannot be reached right now." msgstr "Bu sanal makinenin sunucusuna şu anda ulaşılamıyor." -#: operations/utils.py:120 +#: operations/utils.py:146 msgid "" "A short operation is in progress on this resource; try again in a moment." msgstr "Bu kaynakta kısa bir işlem sürüyor; birazdan yeniden deneyin." -#: operations/utils.py:123 +#: operations/utils.py:149 #, python-format msgid "" "%(label)s is %(state)s on this resource (started by %(user)s, %(since)s " @@ -7643,15 +7829,36 @@ msgstr "" "%(label)s bu kaynakta %(state)s (%(user)s başlattı, %(since)s önce); İşler " "sayfasına bakın." -#: operations/utils.py:125 +#: operations/utils.py:151 #, python-format msgid "%(label)s is %(state)s on this resource (since %(since)s)." msgstr "%(label)s bu kaynakta %(state)s (%(since)s beri)." +#: operations/worker.py:38 +msgid "" +"libvirt's merge finished copying but never completed (for example after a " +"libvirtd restart); check the VM before resolving" +msgstr "" +"libvirt'in birleştirmesi kopyalamayı bitirdi ama hiç tamamlanmadı (örneğin " +"libvirtd yeniden başlatıldıktan sonra); çözmeden önce sanal makineyi kontrol " +"edin" + +#: operations/worker.py:40 +msgid "" +"The worker that ran this job stopped responding; an administrator must make " +"sure it is no longer running, then resolve this job" +msgstr "" +"Bu işi çalıştıran worker yanıt vermeyi bıraktı; bir yönetici artık " +"çalışmadığından emin olmalı, sonra bu işi çözmeli" + #: operations/worker.py:42 msgid "Waiting for recovery" msgstr "Kurtarma bekleniyor" +#: operations/worker.py:393 +msgid "The worker stopped before the job changed anything" +msgstr "Worker, iş herhangi bir şeyi değiştirmeden önce durdu" + #: storages/forms.py:30 storages/templates/storage.html:102 #: storages/templates/storages.html:43 msgid "Path" @@ -7718,10 +7925,34 @@ msgstr "İmaj adı 120 karakterden fazla olamaz" msgid "Clone volume" msgstr "Birimi klonla" +#: storages/jobs.py:58 +#, python-format +msgid "The pool has no volume named %(name)s" +msgstr "Havuzda %(name)s adında birim yok" + +#: storages/jobs.py:60 +msgid "An ISO image is not cloned" +msgstr "ISO imajı klonlanmaz" + +#: storages/jobs.py:63 +msgid "Name of volume already in use" +msgstr "Birim adı zaten kullanılıyor" + #: storages/jobs.py:72 msgid "Copying the volume" msgstr "Birim kopyalanıyor" +#: storages/jobs.py:84 +msgid "" +"The copy exists, but the job did not see it finish; check it, then resolve " +"this job." +msgstr "" +"Kopya var, ancak iş bittiğini görmedi; kontrol edin, sonra bu işi çözün." + +#: storages/jobs.py:85 +msgid "The copy was not made" +msgstr "Kopya oluşturulmadı" + #: storages/jobs.py:96 msgid "Upload ISO image" msgstr "ISO imajı yükle" @@ -8102,77 +8333,91 @@ msgstr "Boyut yok" msgid "Space unavailable while stopped" msgstr "Durdurulmuşken alan bilgisi yok" -#: storages/upload.py:132 storages/upload.py:153 storages/upload.py:181 +#: storages/upload.py:27 +msgid "The upload was idle for over an hour; its partial file was deleted" +msgstr "Yükleme bir saatten uzun süre boşta kaldı; yarım dosyası silindi" + +#: storages/upload.py:134 storages/upload.py:155 storages/upload.py:183 msgid "Destination file already exists" msgstr "Hedef dosya zaten mevcut" -#: storages/upload.py:155 +#: storages/upload.py:157 msgid "Filesystem does not support atomic no-replace finalization: {}" msgstr "Dosya sistemi, üzerine yazmadan atomik sonlandırmayı desteklemiyor: {}" -#: storages/upload.py:212 +#: storages/upload.py:214 msgid "Unsupported connection type for file upload." msgstr "Dosya yükleme için desteklenmeyen bağlantı türü." -#: storages/upload.py:224 +#: storages/upload.py:226 msgid "Security issues with file uploading: path traversal detected" msgstr "" "Dosya yüklemede güvenlik sorunu: dizin geçişi (path traversal) algılandı" -#: storages/upload.py:280 +#: storages/upload.py:282 msgid "The SFTP session did not start in time" msgstr "SFTP oturumu zamanında başlamadı" -#: storages/upload.py:303 +#: storages/upload.py:305 msgid "Security issues with file uploading: symlink detected" msgstr "Dosya yüklemede güvenlik sorunu: sembolik bağlantı algılandı" -#: storages/upload.py:395 +#: storages/upload.py:397 msgid "Libvirt stream stopped during upload" msgstr "Libvirt akışı yükleme sırasında durdu" -#: storages/upload.py:399 +#: storages/upload.py:401 msgid "Upload chunk size changed during transfer" msgstr "Yükleme parça boyutu aktarım sırasında değişti" -#: storages/upload.py:431 storages/upload.py:526 +#: storages/upload.py:433 storages/upload.py:528 msgid "The upload has ended" msgstr "Yükleme sona erdi" -#: storages/upload.py:444 +#: storages/upload.py:445 storages/upload.py:446 msgid "The uploaded image is missing" msgstr "Yüklenen imaj bulunamadı" -#: storages/upload.py:515 +#: storages/upload.py:450 +msgid "" +"An image of this name appeared before the upload finished; the upload was " +"deleted" +msgstr "Yükleme bitmeden bu adda bir imaj belirdi; yükleme silindi" + +#: storages/upload.py:484 +msgid "The compute was deleted" +msgstr "Sunucu silindi" + +#: storages/upload.py:517 msgid "Upload session not found or expired" msgstr "Yükleme oturumu bulunamadı veya süresi doldu" -#: storages/upload.py:517 +#: storages/upload.py:519 msgid "Upload session parameters changed" msgstr "Yükleme oturumu parametreleri değişti" -#: storages/upload.py:531 storages/upload.py:563 +#: storages/upload.py:533 storages/upload.py:565 msgid "The upload was cancelled" msgstr "Yükleme iptal edildi" -#: storages/upload.py:533 +#: storages/upload.py:535 #, python-format msgid "Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d" msgstr "Geçersiz parça sırası. Beklenen parça %(exp)d, gelen %(got)d" -#: storages/upload.py:543 +#: storages/upload.py:545 msgid "Empty upload chunk" msgstr "Boş yükleme parçası" -#: storages/upload.py:545 +#: storages/upload.py:547 msgid "Upload exceeds declared file size" msgstr "Yükleme, bildirilen dosya boyutunu aşıyor" -#: storages/upload.py:547 +#: storages/upload.py:549 msgid "Final chunk does not match declared file size" msgstr "Son parça bildirilen dosya boyutuyla eşleşmiyor" -#: storages/upload.py:553 +#: storages/upload.py:554 storages/upload.py:555 msgid "The partial upload is missing" msgstr "Kısmi yükleme bulunamadı" diff --git a/locale/zh/LC_MESSAGES/django.mo b/locale/zh/LC_MESSAGES/django.mo index b0b09b558c61dc835b372f33c3b2fd46026723ba..06264dc338980e4f1867fe274432ddb654b8067a 100644 GIT binary patch delta 44313 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 OYrc39^PBmHM*2TwubQ&} diff --git a/locale/zh/LC_MESSAGES/django.po b/locale/zh/LC_MESSAGES/django.po index 52be3c82..8aa01419 100644 --- a/locale/zh/LC_MESSAGES/django.po +++ b/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-02 20:32+0000\n" +"POT-Creation-Date: 2026-11-02 20:42+0000\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -2064,8 +2064,7 @@ msgid "of %(memory)s" msgstr "共 %(memory)s" #: computes/templates/computes/list.html:58 computes/templates/overview.html:58 -#: instances/forms.py:15 instances/jobs.py:132 -#: instances/templates/allinstances_index_grouped.html:16 +#: instances/forms.py:15 instances/templates/allinstances_index_grouped.html:16 #: instances/templates/allinstances_index_nongrouped.html:14 #: instances/templates/create_instance_w2.html:63 #: instances/templates/instances/settings_tab.html:852 @@ -2573,196 +2572,343 @@ msgstr "控制台监听的不只是 localhost;只有管理员可以移除其 msgid "The name of the virtual machine must not contain any special characters" msgstr "虚拟机名称不能包含任何特殊字符" -#: instances/jobs.py:133 -msgid "memory (MiB)" -msgstr "内存(MiB)" +#: instances/jobs.py:68 +msgid "The VM moved to another compute after the job was queued" +msgstr "任务排队后,虚拟机已移到另一个计算节点" #: instances/jobs.py:136 -#, python-format -msgid "Enter a whole number of %(what)s for the clone" -msgstr "请为克隆输入整数的%(what)s" +msgid "Enter a whole number of vCPUs for the clone" +msgstr "请为克隆输入整数个 vCPU" -#: instances/jobs.py:160 +#: instances/jobs.py:138 +msgid "Enter a whole number of memory (MiB) for the clone" +msgstr "请为克隆输入整数的内存(MiB)" + +#: instances/jobs.py:165 msgid "Choose the clone's name from the list of free names" msgstr "请从可用名称列表中选择克隆的名称" -#: instances/jobs.py:187 +#: instances/jobs.py:192 #, python-format msgid "User '%(quota_msg)s' quota reached, cannot create '%(clone_name)s'!" msgstr "用户 '%(quota_msg)s' 配额已满,无法创建 '%(clone_name)s'!" -#: instances/jobs.py:190 instances/jobs.py:527 +#: instances/jobs.py:195 instances/jobs.py:532 #, python-format msgid "Instance '%(clone_name)s' already exists!" msgstr "虚拟机 '%(clone_name)s' 已存在!" -#: instances/jobs.py:193 +#: instances/jobs.py:197 #, python-format msgid "Instance name '%(clone_name)s' contains invalid characters!" msgstr "虚拟机名称 '%(clone_name)s' 包含无效字符!" -#: instances/jobs.py:195 +#: instances/jobs.py:200 #, python-format msgid "Instance MAC '%(clone_mac)s' invalid format!" msgstr "虚拟机 MAC '%(clone_mac)s' 格式无效!" -#: instances/jobs.py:198 +#: instances/jobs.py:202 #, python-format msgid "Disk name '%(disk_name)s' contains invalid characters!" msgstr "磁盘名称 '%(disk_name)s' 包含无效字符!" -#: instances/jobs.py:217 +#: instances/jobs.py:222 #, python-format msgid "The clone is made, but not as asked: %(error)s" msgstr "克隆已创建,但与要求不符:%(error)s" -#: instances/jobs.py:288 +#: instances/jobs.py:270 +msgid "The VM was not defined and no volume of it was left." +msgstr "虚拟机未定义,也没有留下它的卷。" + +#: instances/jobs.py:272 +#, python-format +msgid "" +"The VM was not defined; check these volumes, then resolve this job: " +"%(volumes)s" +msgstr "虚拟机未定义;请检查这些卷,然后处理此任务:%(volumes)s" + +#: instances/jobs.py:293 msgid "Delete snapshot" msgstr "删除快照" -#: instances/jobs.py:311 +#: instances/jobs.py:303 +#, python-format +msgid "The VM has no external snapshot named %(name)s" +msgstr "虚拟机没有名为 %(name)s 的外部快照" + +#: instances/jobs.py:316 msgid "Merging the snapshot" msgstr "正在合并快照" -#: instances/jobs.py:333 +#: instances/jobs.py:335 +msgid "The VM is no longer on its compute" +msgstr "虚拟机已不在其计算节点上" + +#: instances/jobs.py:338 msgid "libvirt is still merging the snapshot" msgstr "libvirt 仍在合并快照" -#: instances/jobs.py:354 +#: instances/jobs.py:346 +msgid "" +"The snapshot is still listed and no merge runs. It cannot be told whether " +"the disks changed; check the VM before deleting the snapshot again or " +"reverting to it." +msgstr "" +"快照仍在列表中,且没有正在运行的合并。无法判断磁盘是否已更改;再次删除该快照" +"或恢复到它之前,请检查虚拟机。" + +#: instances/jobs.py:353 +msgid "The VM is no longer on the compute the job ran on" +msgstr "虚拟机已不在运行该任务的计算节点上" + +#: instances/jobs.py:359 msgid "Take snapshot" msgstr "创建快照" -#: instances/jobs.py:378 +#: instances/jobs.py:371 +#, python-format +msgid "A snapshot named %(name)s already exists" +msgstr "名为 %(name)s 的快照已存在" + +#: instances/jobs.py:383 msgid "Taking the snapshot" msgstr "正在创建快照" -#: instances/jobs.py:391 +#: instances/jobs.py:396 msgid "libvirt is still taking the snapshot" msgstr "libvirt 仍在创建快照" -#: instances/jobs.py:417 +#: instances/jobs.py:399 +msgid "The snapshot was not taken" +msgstr "未创建快照" + +#: instances/jobs.py:422 msgid "Revert snapshot" msgstr "恢复快照" -#: instances/jobs.py:446 +#: instances/jobs.py:451 msgid "Reverting the VM" msgstr "正在恢复虚拟机" -#: instances/jobs.py:457 +#: instances/jobs.py:462 msgid "libvirt is still reverting the VM" msgstr "libvirt 仍在恢复虚拟机" -#: instances/jobs.py:498 +#: instances/jobs.py:466 +msgid "" +"It cannot be told whether the VM was reverted; check it, then revert again " +"or resolve this job." +msgstr "无法判断虚拟机是否已恢复;请检查后再次恢复,或处理此任务。" + +#: instances/jobs.py:483 +msgid "The VM must be running, paused or shut off to be cloned" +msgstr "虚拟机必须处于运行、暂停或关机状态才能克隆" + +#: instances/jobs.py:484 +#, python-format +msgid "" +"The guest's file systems could not be thawed (%(error)s); thaw them with: " +"virsh domfsthaw %(vm)s" +msgstr "" +"无法解冻客户机的文件系统(%(error)s);请用以下命令解冻:virsh domfsthaw " +"%(vm)s" + +#: instances/jobs.py:503 msgid "Clone VM" msgstr "克隆虚拟机" -#: instances/jobs.py:537 instances/jobs.py:544 instances/jobs.py:787 +#: instances/jobs.py:542 instances/jobs.py:549 instances/jobs.py:805 msgid "Copying disks" msgstr "正在复制磁盘" -#: instances/jobs.py:671 +#: instances/jobs.py:600 +msgid "libvirt has no result of the copy" +msgstr "libvirt 没有复制的结果" + +#: instances/jobs.py:611 +#, python-format +msgid "" +"The copy failed: %(error)s. The guest's file systems could not be thawed " +"(%(thaw)s); thaw them with: virsh domfsthaw %(vm)s" +msgstr "" +"复制失败:%(error)s。无法解冻客户机的文件系统(%(thaw)s);请用以下命令解冻:" +"virsh domfsthaw %(vm)s" + +#: instances/jobs.py:614 instances/jobs.py:698 +msgid "The copy failed" +msgstr "复制失败" + +#: instances/jobs.py:639 +#, python-format +msgid "" +"The VM is no longer on the compute the job ran on; check these volumes, then " +"resolve this job: %(volumes)s" +msgstr "" +"虚拟机已不在运行该任务的计算节点上;请检查这些卷,然后处理此任务:%(volumes)s" + +#: instances/jobs.py:674 +#, python-format +msgid "" +"A volume of the clone was being created; check these volumes, then resolve " +"this job: %(volumes)s" +msgstr "克隆的一个卷正在创建中;请检查这些卷,然后处理此任务:%(volumes)s" + +#: instances/jobs.py:678 +msgid "The job stopped before the copy started" +msgstr "任务在复制开始前停止" + +#: instances/jobs.py:685 msgid "libvirt is still copying the disks" msgstr "libvirt 仍在复制磁盘" -#: instances/jobs.py:703 instances/templates/create_instance_w1.html:60 +#: instances/jobs.py:688 +#, python-format +msgid "" +"Another backup runs on the VM; check these volumes, then resolve this job: " +"%(volumes)s" +msgstr "虚拟机上正在运行另一个备份;请检查这些卷,然后处理此任务:%(volumes)s" + +#: instances/jobs.py:693 +#, python-format +msgid "" +"libvirt has no result of the copy; check these volumes, then resolve this " +"job: %(volumes)s" +msgstr "libvirt 没有复制的结果;请检查这些卷,然后处理此任务:%(volumes)s" + +#: instances/jobs.py:721 instances/templates/create_instance_w1.html:60 #: instances/templates/create_instance_w2.html:356 msgid "Create VM" msgstr "创建虚拟机" -#: instances/jobs.py:726 instances/views.py:2477 +#: instances/jobs.py:744 instances/views.py:2477 msgid "A virtual machine with this name already exists" msgstr "同名虚拟机已存在" -#: instances/jobs.py:728 +#: instances/jobs.py:746 msgid "There is an instance with same name. Remove it and try again!" msgstr "已存在同名虚拟机。请删除后重试!" -#: instances/jobs.py:731 +#: instances/jobs.py:749 msgid "First you need to create or select an image" msgstr "请先创建或选择一个镜像" -#: instances/jobs.py:733 +#: instances/jobs.py:751 msgid "Choose one disk source: a new disk, existing volumes or a template" msgstr "请选择一种磁盘来源:新磁盘、现有卷或模板" -#: instances/jobs.py:735 +#: instances/jobs.py:753 msgid "Image has already exist. Please check volumes or change instance name" msgstr "镜像已存在。请检查卷或更改虚拟机名称" -#: instances/jobs.py:737 +#: instances/jobs.py:755 msgid "Invalid cache mode" msgstr "无效的缓存模式" -#: instances/jobs.py:739 +#: instances/jobs.py:757 #, python-format msgid "This host offers no CPU mode or model %(mode)s" msgstr "此主机不提供 CPU 模式或型号 %(mode)s" -#: instances/jobs.py:741 +#: instances/jobs.py:759 #, python-format msgid "A CD-ROM cannot use the %(bus)s bus" msgstr "CD-ROM 不能使用 %(bus)s 总线" -#: instances/jobs.py:744 +#: instances/jobs.py:762 msgid "The cloud-init seed is read from a CD-ROM: choose its bus" msgstr "cloud-init 种子从 CD-ROM 读取:请选择其总线" -#: instances/jobs.py:747 +#: instances/jobs.py:765 msgid "The cloud-init seed needs a directory storage pool for the disk" msgstr "cloud-init 种子需要为磁盘使用目录类型的存储池" -#: instances/jobs.py:753 +#: instances/jobs.py:771 #, python-format msgid "This host offers no firmware %(firmware)s" msgstr "此主机不提供固件 %(firmware)s" -#: instances/jobs.py:755 +#: instances/jobs.py:773 msgid "This host offers no TPM 2.0: install swtpm on it" msgstr "此主机不提供 TPM 2.0:请在主机上安装 swtpm" -#: instances/jobs.py:806 +#: instances/jobs.py:824 msgid "Writing the cloud-init seed" msgstr "正在写入 cloud-init 种子" -#: instances/jobs.py:813 +#: instances/jobs.py:831 msgid "Defining the VM" msgstr "正在定义虚拟机" -#: instances/jobs.py:870 +#: instances/jobs.py:864 +#, python-format +msgid "%(error)s. Remove these volumes by hand: %(volumes)s" +msgstr "%(error)s。请手动删除这些卷:%(volumes)s" + +#: instances/jobs.py:889 #, python-format msgid "The instance is already on %(compute)s" msgstr "虚拟机已在 %(compute)s 上" -#: instances/jobs.py:872 +#: instances/jobs.py:891 #, python-format msgid "%(compute)s is not reachable" msgstr "无法连接 %(compute)s" -#: instances/jobs.py:874 +#: instances/jobs.py:893 #, python-format msgid "%(compute)s is the VM's own host under another name" msgstr "%(compute)s 是该虚拟机所在主机的另一个名称" -#: instances/jobs.py:892 +#: instances/jobs.py:911 msgid "Migrate VM" msgstr "迁移虚拟机" -#: instances/jobs.py:936 +#: instances/jobs.py:955 msgid "Migrating the VM" msgstr "正在迁移虚拟机" -#: instances/jobs.py:970 +#: instances/jobs.py:989 msgid "libvirt is still migrating the VM" msgstr "libvirt 仍在迁移虚拟机" -#: instances/jobs.py:1011 +#: instances/jobs.py:996 +msgid "The VM stayed on the source host" +msgstr "虚拟机留在了源主机上" + +#: instances/jobs.py:998 +msgid "" +"The VM is on both computes or on neither; check them, then resolve this job." +msgstr "虚拟机同时位于两个计算节点上,或都不在;请检查后处理此任务。" + +#: instances/jobs.py:1030 msgid "Move disk" msgstr "移动磁盘" -#: instances/jobs.py:1042 +#: instances/jobs.py:1037 +msgid "Choose a disk of the VM" +msgstr "请选择虚拟机的一个磁盘" + +#: instances/jobs.py:1061 msgid "Copying the disk" msgstr "正在复制磁盘" +#: instances/jobs.py:1104 +#, python-format +msgid "Another block job runs on %(dev)s; check the VM" +msgstr "%(dev)s 上正在运行另一个块任务;请检查虚拟机" + +#: instances/jobs.py:1110 +#, python-format +msgid "The disk stayed in %(path)s; remove %(dest)s if it is there" +msgstr "磁盘仍在 %(path)s;如果 %(dest)s 存在,请将其删除" + +#: instances/jobs.py:1112 +#, python-format +msgid "The definitions of %(dev)s name %(paths)s; check the VM" +msgstr "%(dev)s 的定义指向 %(paths)s;请检查虚拟机" + #: instances/models.py:18 msgid "label" msgstr "标签" @@ -7123,11 +7269,35 @@ msgid "" "rules and references." msgstr "字母、数字、点、短横线和下划线。副本包含该过滤器的规则和引用。" -#: operations/api/serializers.py:35 +#: operations/api/serializers.py:37 #, python-format msgid "%(phase)s (over its time limit)" msgstr "%(phase)s(已超出时间限制)" +#: operations/jobs.py:157 +msgid "The form has no valid submission key; reload the page" +msgstr "表单没有有效的提交密钥;请重新加载页面" + +#: operations/jobs.py:203 +msgid "This job cannot be cancelled once it has started" +msgstr "此任务开始后无法取消" + +#: operations/jobs.py:206 +msgid "The job has already ended" +msgstr "任务已经结束" + +#: operations/jobs.py:254 +msgid "Choose succeeded or failed" +msgstr "请选择成功或失败" + +#: operations/jobs.py:257 +msgid "Write a note on what was checked" +msgstr "请写下检查了哪些内容" + +#: operations/jobs.py:261 +msgid "Only an uncertain job can be resolved" +msgstr "只能处理状态不确定的任务" + #: operations/templates/operations/job.html:11 #: operations/templates/operations/jobs.html:7 #: operations/templates/operations/jobs.html:9 @@ -7311,12 +7481,12 @@ msgstr "取消失败:无法连接到服务器。" msgid "The host of this virtual machine cannot be reached right now." msgstr "当前无法连接此虚拟机所在的主机。" -#: operations/utils.py:120 +#: operations/utils.py:146 msgid "" "A short operation is in progress on this resource; try again in a moment." msgstr "此资源上正在进行一项短时操作,请稍后重试。" -#: operations/utils.py:123 +#: operations/utils.py:149 #, python-format msgid "" "%(label)s is %(state)s on this resource (started by %(user)s, %(since)s " @@ -7325,15 +7495,34 @@ msgstr "" "此资源上的%(label)s%(state)s(由 %(user)s 于 %(since)s前发起),请查看任务页" "面。" -#: operations/utils.py:125 +#: operations/utils.py:151 #, python-format msgid "%(label)s is %(state)s on this resource (since %(since)s)." msgstr "此资源上的%(label)s%(state)s(自 %(since)s起)。" +#: operations/worker.py:38 +msgid "" +"libvirt's merge finished copying but never completed (for example after a " +"libvirtd restart); check the VM before resolving" +msgstr "" +"libvirt 的合并已完成复制但从未结束(例如 libvirtd 重启后);处理前请检查虚拟" +"机" + +#: operations/worker.py:40 +msgid "" +"The worker that ran this job stopped responding; an administrator must make " +"sure it is no longer running, then resolve this job" +msgstr "" +"运行此任务的工作进程已停止响应;管理员必须确认它已不再运行,然后处理此任务" + #: operations/worker.py:42 msgid "Waiting for recovery" msgstr "等待恢复" +#: operations/worker.py:393 +msgid "The worker stopped before the job changed anything" +msgstr "工作进程在任务做出任何更改之前停止" + #: storages/forms.py:30 storages/templates/storage.html:102 #: storages/templates/storages.html:43 msgid "Path" @@ -7400,10 +7589,33 @@ msgstr "镜像名称不能超过 120 个字符" msgid "Clone volume" msgstr "克隆卷" +#: storages/jobs.py:58 +#, python-format +msgid "The pool has no volume named %(name)s" +msgstr "存储池中没有名为 %(name)s 的卷" + +#: storages/jobs.py:60 +msgid "An ISO image is not cloned" +msgstr "ISO 镜像不会被克隆" + +#: storages/jobs.py:63 +msgid "Name of volume already in use" +msgstr "卷名已被使用" + #: storages/jobs.py:72 msgid "Copying the volume" msgstr "正在复制卷" +#: storages/jobs.py:84 +msgid "" +"The copy exists, but the job did not see it finish; check it, then resolve " +"this job." +msgstr "副本已存在,但任务未看到其完成;请检查后处理此任务。" + +#: storages/jobs.py:85 +msgid "The copy was not made" +msgstr "未创建副本" + #: storages/jobs.py:96 msgid "Upload ISO image" msgstr "上传 ISO 镜像" @@ -7772,76 +7984,90 @@ msgstr "无大小" msgid "Space unavailable while stopped" msgstr "停止时空间不可用" -#: storages/upload.py:132 storages/upload.py:153 storages/upload.py:181 +#: storages/upload.py:27 +msgid "The upload was idle for over an hour; its partial file was deleted" +msgstr "上传空闲超过一小时;其部分文件已删除" + +#: storages/upload.py:134 storages/upload.py:155 storages/upload.py:183 msgid "Destination file already exists" msgstr "目标文件已存在" -#: storages/upload.py:155 +#: storages/upload.py:157 msgid "Filesystem does not support atomic no-replace finalization: {}" msgstr "文件系统不支持原子性的不替换式完成:{}" -#: storages/upload.py:212 +#: storages/upload.py:214 msgid "Unsupported connection type for file upload." msgstr "不支持用于文件上传的连接类型。" -#: storages/upload.py:224 +#: storages/upload.py:226 msgid "Security issues with file uploading: path traversal detected" msgstr "文件上传存在安全问题:检测到路径遍历" -#: storages/upload.py:280 +#: storages/upload.py:282 msgid "The SFTP session did not start in time" msgstr "SFTP 会话未能及时启动" -#: storages/upload.py:303 +#: storages/upload.py:305 msgid "Security issues with file uploading: symlink detected" msgstr "文件上传存在安全问题:检测到符号链接" -#: storages/upload.py:395 +#: storages/upload.py:397 msgid "Libvirt stream stopped during upload" msgstr "上传期间 libvirt 流已停止" -#: storages/upload.py:399 +#: storages/upload.py:401 msgid "Upload chunk size changed during transfer" msgstr "传输期间上传分块大小发生了变化" -#: storages/upload.py:431 storages/upload.py:526 +#: storages/upload.py:433 storages/upload.py:528 msgid "The upload has ended" msgstr "上传已结束" -#: storages/upload.py:444 +#: storages/upload.py:445 storages/upload.py:446 msgid "The uploaded image is missing" msgstr "上传的镜像不存在" -#: storages/upload.py:515 +#: storages/upload.py:450 +msgid "" +"An image of this name appeared before the upload finished; the upload was " +"deleted" +msgstr "上传完成前出现了同名镜像;上传已删除" + +#: storages/upload.py:484 +msgid "The compute was deleted" +msgstr "计算节点已删除" + +#: storages/upload.py:517 msgid "Upload session not found or expired" msgstr "上传会话不存在或已过期" -#: storages/upload.py:517 +#: storages/upload.py:519 msgid "Upload session parameters changed" msgstr "上传会话参数已变更" -#: storages/upload.py:531 storages/upload.py:563 +#: storages/upload.py:533 storages/upload.py:565 msgid "The upload was cancelled" msgstr "上传已取消" -#: storages/upload.py:533 +#: storages/upload.py:535 #, python-format msgid "Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d" msgstr "分块顺序无效。应为第 %(exp)d 块,收到第 %(got)d 块" -#: storages/upload.py:543 +#: storages/upload.py:545 msgid "Empty upload chunk" msgstr "上传分块为空" -#: storages/upload.py:545 +#: storages/upload.py:547 msgid "Upload exceeds declared file size" msgstr "上传内容超过声明的文件大小" -#: storages/upload.py:547 +#: storages/upload.py:549 msgid "Final chunk does not match declared file size" msgstr "最后一个分块与声明的文件大小不符" -#: storages/upload.py:553 +#: storages/upload.py:554 storages/upload.py:555 msgid "The partial upload is missing" msgstr "部分上传的文件不存在" diff --git a/operations/api/serializers.py b/operations/api/serializers.py index 3e175ea1..a033a414 100644 --- a/operations/api/serializers.py +++ b/operations/api/serializers.py @@ -6,12 +6,14 @@ from rest_framework import serializers from operations.jobs import KINDS, label_of from operations.models import Operation -from operations.utils import shown_label +from operations.utils import shown_label, words class OperationSerializer(serializers.ModelSerializer): label = serializers.SerializerMethodField() phase = serializers.SerializerMethodField() + error = serializers.SerializerMethodField() + result = serializers.SerializerMethodField() resource_label = serializers.SerializerMethodField() cancellable = serializers.SerializerMethodField() in_request = serializers.SerializerMethodField() @@ -35,6 +37,15 @@ class OperationSerializer(serializers.ModelSerializer): return (_("%(phase)s (over its time limit)") % {"phase": phase}).strip() return phase + def get_error(self, op) -> str: + return words(op.error) + + def get_result(self, op) -> dict: + """What the job made; its warning in the viewer's language.""" + if op.result.get("warning"): + return {**op.result, "warning": words(op.result["warning"])} + return op.result + def get_resource_label(self, op) -> str: return shown_label(op, self.context["request"].user) diff --git a/operations/jobs.py b/operations/jobs.py index 69c30aa6..6bc056ce 100644 --- a/operations/jobs.py +++ b/operations/jobs.py @@ -13,6 +13,7 @@ from django.db import transaction from django.db.models import F, Value from django.db.models.functions import Coalesce from django.utils import timezone +from django.utils.translation import gettext_noop from logs.audit import write from vrtManager.util import OperationError @@ -44,7 +45,7 @@ class Outcome: state: str result: dict = field(default_factory=dict) - error: str = "" + error: list = field(default_factory=list) # messages (utils.say) phase: str = "" stalled: bool = False progress: tuple | None = None @@ -153,7 +154,7 @@ def _existing(user, key, kind_name, params): def submit(kind_name, user, raw_params, submission_key): if not KEY_RE.fullmatch(submission_key or ""): - raise OperationError("The form has no valid submission key; reload the page") + raise OperationError(gettext_noop("The form has no valid submission key; reload the page")) kind = get_kind(kind_name) params = kind.normalize(raw_params) utils.check_params(params) @@ -199,10 +200,10 @@ def cancel(op_id, user): _log(op.username, *entry, None) elif op.state == Operation.RUNNING: if not get_kind(op.kind).cancellable: - raise OperationError("This job cannot be cancelled once it has started") + raise OperationError(gettext_noop("This job cannot be cancelled once it has started")) Operation.objects.filter(pk=op.pk).update(state=Operation.CANCELLING, cancel_requested=True) elif op.state != Operation.CANCELLING: - raise OperationError("The job has already ended") + raise OperationError(gettext_noop("The job has already ended")) op.refresh_from_db() if request_job: # no worker signals it: the request stops it get_kind(op.kind).cancel(op) @@ -226,14 +227,13 @@ def _log(user, host, instance, uuid, message, success): def finish(op, outcome, *, worker=None): """Terminal state, claims and history together, once. With a worker, only while it holds the lease and owns the job. Uncertain keeps the claims.""" - error = utils.safe_text(outcome.error) with fenced(worker.identity) if worker is not None else transaction.atomic(): q = Operation.objects.filter(pk=op.pk, state__in=Operation.ACTIVE) if worker is not None: q = q.filter(worker=worker.identity) # success clears the phase and completes the progress; another end keeps the last ones done = outcome.state == Operation.SUCCEEDED - if not q.update(state=outcome.state, result=outcome.result, error=error, + if not q.update(state=outcome.state, result=outcome.result, error=outcome.error, phase=Value(outcome.phase[:255]) if outcome.phase else ("" if done else F("phase")), progress_done=Coalesce(F("progress_total"), F("progress_done")) if done else F("progress_done"), finished_at=timezone.now()): @@ -251,14 +251,14 @@ def resolve(op_id, user, outcome_state, note): if not user.is_superuser: raise PermissionDenied if outcome_state not in (Operation.SUCCEEDED, Operation.FAILED): - raise OperationError("Choose succeeded or failed") + raise OperationError(gettext_noop("Choose succeeded or failed")) note = note.strip() if not note: - raise OperationError("Write a note on what was checked") + raise OperationError(gettext_noop("Write a note on what was checked")) with transaction.atomic(): op = Operation.objects.select_for_update().get(pk=op_id) if op.state != Operation.UNCERTAIN: - raise OperationError("Only an uncertain job can be resolved") + raise OperationError(gettext_noop("Only an uncertain job can be resolved")) Operation.objects.filter(pk=op.pk).update(state=outcome_state, resolution=outcome_state, resolution_note=note, resolved_by=user, resolved_at=timezone.now()) Claim.objects.filter(operation=op).delete() diff --git a/operations/migrations/0002_error_messages.py b/operations/migrations/0002_error_messages.py new file mode 100644 index 00000000..7e452219 --- /dev/null +++ b/operations/migrations/0002_error_messages.py @@ -0,0 +1,27 @@ +"""A job's error and its result's warning are lists of messages +(operations.utils.say), so that they are shown in the language of whoever +reads them. Those stored before are one message each, without values: shown +as they were.""" + +from django.db import migrations, models + + +def to_messages(apps, schema_editor): + Operation = apps.get_model("operations", "Operation") + for op in Operation.objects.exclude(error_text="").only("pk", "error_text"): + Operation.objects.filter(pk=op.pk).update(error=[{"text": op.error_text, "values": {}}]) + for op in Operation.objects.only("pk", "result"): + if isinstance(op.result.get("warning"), str): + Operation.objects.filter(pk=op.pk).update( + result={**op.result, "warning": [{"text": op.result["warning"], "values": {}}]}) + + +class Migration(migrations.Migration): + dependencies = [("operations", "0001_initial")] + + operations = [ + migrations.RenameField("operation", "error", "error_text"), + migrations.AddField("operation", "error", models.JSONField(default=list)), + migrations.RunPython(to_messages, migrations.RunPython.noop), + migrations.RemoveField("operation", "error_text"), + ] diff --git a/operations/models.py b/operations/models.py index 78e46365..d1e88e76 100644 --- a/operations/models.py +++ b/operations/models.py @@ -45,7 +45,7 @@ class Operation(models.Model): progress_done = models.BigIntegerField(null=True) progress_total = models.BigIntegerField(null=True) result = models.JSONField(default=dict) - error = models.TextField(blank=True) + error = models.JSONField(default=list) # messages (operations.utils.say), shown in the reader's language created_at = models.DateTimeField(auto_now_add=True, db_index=True) started_at = models.DateTimeField(null=True) finished_at = models.DateTimeField(null=True) diff --git a/operations/templates/operations/job.html b/operations/templates/operations/job.html index 9c615320..a5d78e26 100644 --- a/operations/templates/operations/job.html +++ b/operations/templates/operations/job.html @@ -20,7 +20,7 @@

{% trans "User" %}: {{ job.username }}
- {% if job.phase or op.progress_total or job.cancellable or op.error or job.result.links or job.result.warning or job.result.left_behind or job.resolution %} + {% if job.phase or op.progress_total or job.cancellable or job.error or job.result.links or job.result.warning or job.result.left_behind or job.resolution %}
{% if job.phase or job.cancellable %}
@@ -45,9 +45,9 @@
{% endif %} - {% if op.error %} + {% if job.error %} {% endif %} {% if job.result.links %} diff --git a/operations/templates/operations/jobs.html b/operations/templates/operations/jobs.html index b920801d..c756344a 100644 --- a/operations/templates/operations/jobs.html +++ b/operations/templates/operations/jobs.html @@ -84,7 +84,7 @@
{% endif %} {% elif op.state == "failed" or op.state == "uncertain" %} -
{{ op.error|truncatechars:80 }}
+
{{ row.error|truncatechars:80 }}
{% endif %}
{{ op.created_at|date:"Y-m-d H:i:s T" }}{% if request.user.is_superuser %} · {{ row.username }}{% endif %}
{# below md the hidden columns show here #} diff --git a/operations/tests/test_api.py b/operations/tests/test_api.py index a8d7d44b..a23cdc91 100644 --- a/operations/tests/test_api.py +++ b/operations/tests/test_api.py @@ -12,6 +12,7 @@ from instances.models import Instance from operations import claims, jobs from operations.models import Claim, Operation from operations.tests import fakes +from operations.utils import say class OperationsApiTestCase(TransactionTestCase): @@ -35,6 +36,17 @@ class OperationsApiTestCase(TransactionTestCase): Operation.objects.filter(pk=self.op.pk).update(time_limit=60, started_at=timezone.now() - timedelta(seconds=61)) self.assertEqual(self.client.get(url).json()["phase"], "Merging the snapshot (over its time limit)") + def test_the_error_and_warning_come_in_the_users_language_with_their_values(self): + # ours with its values; libvirt's text as it is + Operation.objects.filter(pk=self.op.pk).update( + state=Operation.FAILED, + error=say("The pool has no volume named %(name)s", name="disk.qcow2") + say("unable to open: busy"), + result={"warning": say("The copy was not made")}) + self.client.force_login(self.owner) + job = self.client.get(f"/api/v1/operations/{self.op.pk}/", HTTP_ACCEPT_LANGUAGE="tr").json() + self.assertEqual(job["error"], "Havuzda disk.qcow2 adında birim yok. unable to open: busy") + self.assertEqual(job["result"]["warning"], "Kopya oluşturulmadı") + def test_users_list_only_their_own_jobs(self): self.client.force_login(self.other) self.assertEqual(self.client.get("/api/v1/operations/").json()["count"], 0) diff --git a/operations/tests/test_recovery.py b/operations/tests/test_recovery.py index 986bc4bc..775f4ca3 100644 --- a/operations/tests/test_recovery.py +++ b/operations/tests/test_recovery.py @@ -4,6 +4,7 @@ from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TransactionTestCase +from operations.utils import say, words from operations import jobs from operations.jobs import Outcome from operations.models import Claim, Operation, WorkerLease, acquire @@ -55,20 +56,20 @@ class RecoveryTestCase(TransactionTestCase): def test_a_proven_success_does_not_carry_the_raised_error(self): op = self.orphan({}, worker=self.worker.identity) - self.scheduler.inspect_now(op.pk, error="failed after effects") + self.scheduler.inspect_now(op.pk, error=say("failed after effects")) with patch.object(fakes.ScriptedKind, "reconcile", return_value=Outcome(Operation.SUCCEEDED)): self.run_due() op = self.reload(op) - self.assertEqual((op.state, op.error), (Operation.SUCCEEDED, "")) + self.assertEqual((op.state, words(op.error)), (Operation.SUCCEEDED, "")) def test_an_uncertain_outcome_keeps_the_error_the_job_raised(self): op = self.orphan({}, worker=self.worker.identity) - self.scheduler.inspect_now(op.pk, error="resource busy: X") + self.scheduler.inspect_now(op.pk, error=say("resource busy: X")) with patch.object(fakes.ScriptedKind, "reconcile", - return_value=Outcome(Operation.UNCERTAIN, error="check the VM")): + return_value=Outcome(Operation.UNCERTAIN, error=say("check the VM"))): self.run_due() op = self.reload(op) - self.assertEqual((op.state, op.error), (Operation.UNCERTAIN, "resource busy: X. check the VM")) + self.assertEqual((op.state, words(op.error)), (Operation.UNCERTAIN, "resource busy: X. check the VM")) def test_uncertain_keeps_the_claims(self): op = self.orphan({"outcome": "uncertain"}) @@ -116,31 +117,31 @@ class RecoveryTestCase(TransactionTestCase): self.run_at(1000, Outcome(Operation.RUNNING, stalled=True)) self.run_at(1000 + STALL_LIMIT, Outcome(Operation.RUNNING, stalled=True)) op = self.reload(op) - self.assertEqual((op.state, op.error), (Operation.UNCERTAIN, STALLED)) + self.assertEqual((op.state, words(op.error)), (Operation.UNCERTAIN, STALLED)) self.assertTrue(Claim.objects.filter(operation=op).exists()) self.assertNotIn(op.pk, self.scheduler.stalled_since) def test_a_stalled_merge_keeps_the_error_the_job_raised(self): op = self.orphan({}, worker=self.worker.identity) - self.scheduler.inspect_now(op.pk, error="resource busy: X") + self.scheduler.inspect_now(op.pk, error=say("resource busy: X")) self.run_at(1000, Outcome(Operation.RUNNING, stalled=True)) self.scheduler.next_try[op.pk] = 0 self.run_at(1000 + STALL_LIMIT, Outcome(Operation.RUNNING, stalled=True)) - self.assertEqual(self.reload(op).error, f"resource busy: X. {STALLED}") + self.assertEqual(words(self.reload(op).error), f"resource busy: X. {STALLED}") def test_a_raised_error_ending_with_a_dot_is_not_doubled(self): op = self.orphan({}, worker=self.worker.identity) - self.scheduler.inspect_now(op.pk, error="refused.") - with patch.object(fakes.ScriptedKind, "reconcile", return_value=Outcome(Operation.UNCERTAIN, error="check")): + self.scheduler.inspect_now(op.pk, error=say("refused.")) + with patch.object(fakes.ScriptedKind, "reconcile", return_value=Outcome(Operation.UNCERTAIN, error=say("check"))): self.run_due() - self.assertEqual(self.reload(op).error, "refused. check") + self.assertEqual(words(self.reload(op).error), "refused. check") def test_an_outcome_without_an_error_stores_the_raised_one(self): op = self.orphan({}, worker=self.worker.identity) - self.scheduler.inspect_now(op.pk, error="resource busy: X") + self.scheduler.inspect_now(op.pk, error=say("resource busy: X")) with patch.object(fakes.ScriptedKind, "reconcile", return_value=Outcome(Operation.FAILED)): self.run_due() - self.assertEqual(self.reload(op).error, "resource busy: X") + self.assertEqual(words(self.reload(op).error), "resource busy: X") def test_progress_resets_the_stall_timer(self): op = self.orphan({}) @@ -171,8 +172,8 @@ class RecoveryTestCase(TransactionTestCase): reconcile.assert_not_called() op = self.reload(op) # the stored error is shown to the job's owner: the host goes to the worker log only - self.assertEqual((op.state, op.error), (Operation.UNCERTAIN, ABANDONED)) - self.assertNotIn("other-host", op.error) + self.assertEqual((op.state, words(op.error)), (Operation.UNCERTAIN, ABANDONED)) + self.assertNotIn("other-host", words(op.error)) self.assertIn("other-host", "\n".join(logs.output)) self.assertTrue(Claim.objects.filter(operation=op).exists()) @@ -196,7 +197,7 @@ class RecoveryTestCase(TransactionTestCase): def test_an_own_job_that_raised_before_its_effects_fails_and_releases(self): op = self.orphan({"outcome": "uncertain"}, worker=self.worker.identity, effects=False) - self.scheduler.inspect_now(op.pk, error="refused before effects") + self.scheduler.inspect_now(op.pk, error=say("refused before effects")) self.run_due() op = self.reload(op) self.assertEqual(op.state, Operation.FAILED) diff --git a/operations/tests/test_service.py b/operations/tests/test_service.py index daaa6226..62878e85 100644 --- a/operations/tests/test_service.py +++ b/operations/tests/test_service.py @@ -9,6 +9,7 @@ from django.utils import timezone from vrtManager.util import OperationError from logs.models import Logs +from operations.utils import say, words from operations import claims, jobs from operations.jobs import Outcome from operations.models import Claim, LeaseLost, Operation, WorkerLease, acquire @@ -153,7 +154,7 @@ class CancelFinishResolveTestCase(TransactionTestCase): def test_a_failure_keeps_the_phase_stored_after_the_job_was_loaded(self): op = jobs.submit("test.noop", self.user, {}, KEY) Operation.objects.filter(pk=op.pk).update(phase="merging") # op in memory still has "" - jobs.finish(op, Outcome(Operation.FAILED, error="x")) + jobs.finish(op, Outcome(Operation.FAILED, error=say("x"))) op.refresh_from_db() self.assertEqual(op.phase, "merging") @@ -161,14 +162,14 @@ class CancelFinishResolveTestCase(TransactionTestCase): op = jobs.submit("test.noop", self.user, {}, KEY) Operation.objects.filter(pk=op.pk).update(phase="merging", progress_done=5, progress_total=10) op.refresh_from_db() - jobs.finish(op, Outcome(Operation.FAILED, error="x")) + jobs.finish(op, Outcome(Operation.FAILED, error=say("x"))) op.refresh_from_db() self.assertEqual((op.phase, op.progress_done), ("merging", 5)) def test_finish_happens_once(self): op = jobs.submit("test.noop", self.user, {}, KEY) self.assertTrue(jobs.finish(op, Outcome(Operation.SUCCEEDED, result={"x": 1}))) - self.assertFalse(jobs.finish(op, Outcome(Operation.FAILED, error="late"))) + self.assertFalse(jobs.finish(op, Outcome(Operation.FAILED, error=say("late")))) op.refresh_from_db() self.assertEqual((op.state, op.result), (Operation.SUCCEEDED, {"x": 1})) self.assertEqual(Logs.objects.count(), 1) @@ -187,14 +188,14 @@ class CancelFinishResolveTestCase(TransactionTestCase): op = jobs.submit("test.noop", self.user, {}, KEY) Operation.objects.filter(pk=op.pk).update(username="u" * 150) op.refresh_from_db() - jobs.finish(op, Outcome(Operation.FAILED, error="qemu+tcp://admin:s3cret@192.0.2.5/system failed")) + jobs.finish(op, Outcome(Operation.FAILED, error=say("qemu+tcp://admin:s3cret@192.0.2.5/system failed"))) op.refresh_from_db() - self.assertNotIn("s3cret", op.error) + self.assertNotIn("s3cret", words(op.error)) self.assertLessEqual(len(Logs.objects.get().user), 150) def test_an_uncertain_job_keeps_its_claims_until_resolved(self): op = jobs.submit("test.noop", self.user, {}, KEY) - jobs.finish(op, Outcome(Operation.UNCERTAIN, error="check the disks")) + jobs.finish(op, Outcome(Operation.UNCERTAIN, error=say("check the disks"))) self.assertTrue(Claim.objects.filter(operation=op).exists()) with self.assertRaises(PermissionDenied): jobs.resolve(op.pk, self.user, Operation.FAILED, "checked") diff --git a/operations/tests/test_views.py b/operations/tests/test_views.py index 1a47cacf..dc3605c1 100644 --- a/operations/tests/test_views.py +++ b/operations/tests/test_views.py @@ -14,6 +14,7 @@ from operations.jobs import Outcome from operations.models import Claim, Operation, WorkerLease from operations.templatetags.operations import STATE_COLORS, STATE_LABELS, state_color, state_label from operations.tests import fakes +from operations.utils import say class BusyMessageTestCase(SimpleTestCase): @@ -74,7 +75,7 @@ class JobsPageTestCase(TransactionTestCase): self.assertNotContains(self.client.get(reverse("operations:jobs"), {"username": "other"}), link) def test_resolve_needs_a_superuser_an_outcome_and_a_note(self): - jobs.finish(self.op, Outcome(Operation.UNCERTAIN, error="check")) + jobs.finish(self.op, Outcome(Operation.UNCERTAIN, error=say("check"))) self.client.force_login(self.user) self.assertEqual(self.client.post(reverse("operations:resolve", args=[self.op.pk]), {"outcome": "failed", "note": "x"}).status_code, 403) @@ -85,7 +86,7 @@ class JobsPageTestCase(TransactionTestCase): self.assertFalse(Claim.objects.filter(operation=self.op).exists()) def test_the_detail_shows_what_recovery_saw_escaped(self): - Operation.objects.filter(pk=self.op.pk).update(error="", phase="Waiting for recovery", + Operation.objects.filter(pk=self.op.pk).update(error=say(""), phase="Waiting for recovery", state=Operation.UNCERTAIN) self.client.force_login(self.user) response = self.client.get(reverse("operations:job", args=[self.op.pk])) @@ -93,7 +94,7 @@ class JobsPageTestCase(TransactionTestCase): self.assertContains(response, "Waiting for recovery") def test_the_detail_shows_the_results_warning_escaped(self): - Operation.objects.filter(pk=self.op.pk).update(result={"warning": "not thawed"}, + Operation.objects.filter(pk=self.op.pk).update(result={"warning": say("not thawed")}, state=Operation.SUCCEEDED) self.client.force_login(self.user) response = self.client.get(reverse("operations:job", args=[self.op.pk])) @@ -169,7 +170,7 @@ class JobsPageTestCase(TransactionTestCase): self.assertNotContains(self.client.get(url), 'id="follow-active-jobs"') def test_list_and_detail_show_a_state_badge(self): - Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error="disk busy") + Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error=say("disk busy")) self.client.force_login(self.admin) for response in (self.client.get(reverse("operations:jobs")), self.client.get(reverse("operations:job", args=[self.op.pk]))): @@ -178,12 +179,12 @@ class JobsPageTestCase(TransactionTestCase): self.assertContains(detail, 'class="alert alert-danger"') def test_the_list_shows_the_phase_while_active_and_the_error_after_a_failure(self): - Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Copying", error="") + Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Copying", error=say("")) self.client.force_login(self.admin) self.assertContains(self.client.get(reverse("operations:jobs")), "Copying") Operation.objects.filter(pk=self.op.pk).update(state=Operation.SUCCEEDED) self.assertNotContains(self.client.get(reverse("operations:jobs")), "Copying") - Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error="x" * 200) + Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error=say("x" * 200)) response = self.client.get(reverse("operations:jobs")) self.assertNotContains(response, "Copying") self.assertContains(response, "x" * 79 + "\u2026") diff --git a/operations/tests/test_worker.py b/operations/tests/test_worker.py index d52e4b62..8ad15a84 100644 --- a/operations/tests/test_worker.py +++ b/operations/tests/test_worker.py @@ -17,6 +17,7 @@ from django.utils import timezone from appsettings.models import AppSettings from appsettings.settings import app_settings +from operations.utils import words from operations import jobs from operations.models import Claim, Operation, WorkerLease from operations.tests import fakes @@ -77,7 +78,7 @@ class WorkerTestCase(TransactionTestCase): self.start() self.assertTrue(wait_for(lambda: state(op) in Operation.TERMINAL)) op.refresh_from_db() - self.assertEqual((op.state, op.result), (Operation.SUCCEEDED, {"value": "42"}), op.error) + self.assertEqual((op.state, op.result), (Operation.SUCCEEDED, {"value": "42"}), words(op.error)) def test_an_error_before_effects_fails_the_job(self): op = self.submit("test.scripted", {"fail_before": True}, "key-00000001") @@ -90,7 +91,7 @@ class WorkerTestCase(TransactionTestCase): with patch.object(fakes.NoopKind, "precheck", side_effect=failure): self.start() self.assertTrue(wait_for(lambda: state(op) == Operation.FAILED)) - error = Operation.objects.get(pk=op.pk).error + error = words(Operation.objects.get(pk=op.pk).error) self.assertEqual(error, "The host of this virtual machine cannot be reached right now.") self.assertNotIn("example.com", error) @@ -221,7 +222,7 @@ class WorkerTestCase(TransactionTestCase): with patch("operations.jobs.Context", side_effect=RuntimeError("no context")): self.start() self.assertTrue(wait_for(lambda: state(op) == Operation.FAILED)) - self.assertIn("no context", Operation.objects.get(pk=op.pk).error) + self.assertIn("no context", words(Operation.objects.get(pk=op.pk).error)) self.assertFalse(Claim.objects.filter(operation=op).exists()) def test_a_failing_manager_loop_ends_the_process(self): diff --git a/operations/utils.py b/operations/utils.py index 28693ace..cd7c1108 100644 --- a/operations/utils.py +++ b/operations/utils.py @@ -13,13 +13,13 @@ from django.utils import timezone from django.utils.timesince import timesince from django.utils.translation import gettext as _ from django.utils.translation import pgettext -from django.utils.translation import gettext_lazy +from django.utils.translation import gettext_noop from libvirt import libvirtError from accounts.models import UserInstance from appsettings.models import AppSettings from instances.models import Instance -from vrtManager.util import ConnectionFailed +from vrtManager.util import ConnectionFailed, OperationError from . import jobs @@ -49,7 +49,7 @@ TRANSPORT_ERROR_CODES = { libvirt.VIR_ERR_SSH, libvirt.VIR_ERR_LIBSSH, } -UNREACHABLE = gettext_lazy("The host of this virtual machine cannot be reached right now.") +UNREACHABLE = gettext_noop("The host of this virtual machine cannot be reached right now.") BOUNDS = {"WORKER_CONCURRENCY": (1, 16, 4), "JOB_RETENTION_DAYS": (1, 3650, 30)} @@ -61,7 +61,29 @@ def safe_text(text): return text -def public_error_text(exception): +# A text a job keeps for later (its error, its result's warning) is a list of +# messages, each our English text, marked with gettext_noop, and its values: +# words() puts them in the language of whoever reads them. A text that is not +# ours (libvirt's) is a message without values and stays as it is. +def say(text, **values): + return [{"text": safe_text(text), "values": {key: safe_text(value) for key, value in values.items()}}] + + +def said(exception): + """The messages of an exception: an OperationError's text and values, else its text.""" + if isinstance(exception, OperationError): + return say(exception.text, **exception.values) + return say(exception) + + +def words(messages): + """messages in the active language, each a sentence of its own.""" + parts = [_(m["text"]) % m["values"] if m["values"] else _(m["text"]) for m in messages] + return " ".join(part if last or part.endswith((".", "。", "!", "?")) else part + "." + for last, part in ((i == len(parts) - 1, part) for i, part in enumerate(parts))) + + +def public_error(exception): """The error for users who do not see the hosts: a connection error names the host and how it is reached, so they learn only that it cannot be reached.""" connection_error = isinstance(exception, ConnectionFailed) or ( @@ -69,7 +91,11 @@ def public_error_text(exception): and exception.get_error_domain() in TRANSPORT_ERROR_DOMAINS and exception.get_error_code() in TRANSPORT_ERROR_CODES ) - return str(UNREACHABLE) if connection_error else str(exception) + return say(UNREACHABLE) if connection_error else said(exception) + + +def public_error_text(exception): + return words(public_error(exception)) def check_params(params): diff --git a/operations/views.py b/operations/views.py index 5097e4bc..84701fdd 100644 --- a/operations/views.py +++ b/operations/views.py @@ -14,6 +14,7 @@ from vrtManager.util import OperationError from . import jobs as jobs_module from .api.serializers import OperationSerializer from .models import Operation, last_seen +from .utils import said, words def _visible(user): @@ -71,7 +72,7 @@ def cancel(request, pk): try: jobs_module.cancel(op.pk, request.user) except OperationError as err: - messages.error(request, str(err)) + messages.error(request, words(said(err))) return redirect("operations:job", pk=op.pk) @@ -83,5 +84,5 @@ def resolve(request, pk): try: jobs_module.resolve(op.pk, request.user, request.POST.get("outcome", ""), request.POST.get("note", "")) except OperationError as err: - messages.error(request, str(err)) + messages.error(request, words(said(err))) return redirect("operations:job", pk=op.pk) diff --git a/operations/worker.py b/operations/worker.py index 33a1650b..ca42b109 100644 --- a/operations/worker.py +++ b/operations/worker.py @@ -35,10 +35,10 @@ HOUSEKEEPING_EVERY = 600.0 BACKOFF = (30, 60, 120, 300, 600) WATCH_EVERY = 15 STALL_LIMIT = 600 # a block job left at cur == end after a libvirtd restart never ends -STALLED = ("libvirt's merge finished copying but never completed (for example after a libvirtd restart); " - "check the VM before resolving") -ABANDONED = ("The worker that ran this job stopped responding; an administrator must make sure it is no longer " - "running, then resolve this job") +STALLED = gettext_noop("libvirt's merge finished copying but never completed (for example after a " + "libvirtd restart); check the VM before resolving") +ABANDONED = gettext_noop("The worker that ran this job stopped responding; an administrator must make sure it " + "is no longer running, then resolve this job") WAITING = gettext_noop("Waiting for recovery") ACTIVE = (Operation.RUNNING, Operation.CANCELLING) @@ -197,7 +197,7 @@ class Worker: if (err := future.exception()) is not None: # execute raised before it could finish the job itself logger.error("job %s ended unfinished: %s", op_id, utils.safe_text(err)) - self.control.submit(_guarded, self.recovery.inspect_now, op_id, utils.public_error_text(err)) + self.control.submit(_guarded, self.recovery.inspect_now, op_id, utils.public_error(err)) def _slots(self): return (self.fixed_concurrency or utils.concurrency()) - len(self.jobs) @@ -288,9 +288,9 @@ def execute(worker, op_id): try: # jobs have no request: the error users see never names a host if ctx.effects_started: - worker.recovery.inspect_now(op.pk, error=utils.public_error_text(err)) + worker.recovery.inspect_now(op.pk, error=utils.public_error(err)) else: - jobs.finish(op, jobs.Outcome(Operation.FAILED, error=utils.public_error_text(err)), worker=worker) + jobs.finish(op, jobs.Outcome(Operation.FAILED, error=utils.public_error(err)), worker=worker) except LeaseLost: pass finally: @@ -330,7 +330,7 @@ class Scheduler: .values_list("pk", flat=True)): self.next_try.setdefault(op_id, 0) - def inspect_now(self, op_id, error=""): + def inspect_now(self, op_id, error=()): self.errors[op_id] = error self.next_try[op_id] = 0 @@ -358,7 +358,7 @@ class Scheduler: op.refresh_from_db() if previous_host != self.worker.host: # the error is shown to the job's owner: the host goes to the worker log only - self._finish(op, jobs.Outcome(Operation.UNCERTAIN, error=ABANDONED)) + self._finish(op, jobs.Outcome(Operation.UNCERTAIN, error=utils.say(ABANDONED))) logger.warning("job %s ended uncertain: its worker %s stopped responding", op.pk, previous) return False return True @@ -369,8 +369,7 @@ class Scheduler: def _error(self, op_id, finding): """What an unproven end stores: the error the job raised, then what libvirt shows.""" - raised = self.errors.get(op_id, "") - return f"{raised.rstrip('.')}. {finding}" if raised and finding else finding or raised + return [*self.errors.get(op_id, ()), *finding] def _phase(self, op_id, text, progress=None): fields = {"phase": utils.safe_text(text)[:255]} if text else {} @@ -390,8 +389,8 @@ class Scheduler: if op.worker != self.worker.identity and not self._take_over(op): return if not op.effects_started: - self._finish(op, jobs.Outcome(Operation.FAILED, error=self.errors.get(op_id) - or "The worker stopped before the job changed anything")) + self._finish(op, jobs.Outcome(Operation.FAILED, error=list(self.errors.get(op_id, ())) + or utils.say(gettext_noop("The worker stopped before the job changed anything")))) return try: outcome = jobs.get_kind(op.kind).reconcile(op) @@ -407,7 +406,7 @@ class Scheduler: if not outcome.stalled: self.stalled_since.pop(op_id, None) elif now - self.stalled_since.setdefault(op_id, now) >= STALL_LIMIT: - self._finish(op, jobs.Outcome(Operation.UNCERTAIN, error=self._error(op_id, STALLED))) + self._finish(op, jobs.Outcome(Operation.UNCERTAIN, error=self._error(op_id, utils.say(STALLED)))) return self.next_try[op_id] = now + WATCH_EVERY self._phase(op_id, outcome.phase, outcome.progress) diff --git a/storages/jobs.py b/storages/jobs.py index 166dd921..c4ec1d3d 100644 --- a/storages/jobs.py +++ b/storages/jobs.py @@ -55,12 +55,12 @@ class CloneVolume(Kind): compute, stg = _storage(params) volumes = stg.get_volumes() if params["image"] not in volumes: - raise util.OperationError(f"The pool has no volume named {params['image']}") + raise util.OperationError(gettext_noop("The pool has no volume named %(name)s"), name=params["image"]) if is_iso(params["image"]): - raise util.OperationError("An ISO image is not cloned") + raise util.OperationError(gettext_noop("An ISO image is not cloned")) target = stg.clone_name(params["name"], params["format"] or stg.get_volume_format_type(params["image"])) if target in volumes: - raise util.OperationError("Name of volume already in use") + raise util.OperationError(gettext_noop("Name of volume already in use")) pool = claims.pool_key(compute.pk, params["pool"]) return Plan(claims={claims.compute_key(compute.pk): Claim.SHARED, pool: Claim.SHARED, claims.volume_key(compute.pk, params["pool"], target): Claim.EXCLUSIVE}, @@ -80,9 +80,9 @@ class CloneVolume(Kind): return Outcome(Operation.SUCCEEDED) _compute, stg = _storage(op.params) # unreachable: recovery waits and retries if op.intent["target"] in stg.get_volumes(): - return Outcome(Operation.UNCERTAIN, error="The copy exists, but the job did not see it finish; " - "check it, then resolve this job.") - return Outcome(Operation.FAILED, error="The copy was not made") + return Outcome(Operation.UNCERTAIN, error=utils.say(gettext_noop( + "The copy exists, but the job did not see it finish; check it, then resolve this job."))) + return Outcome(Operation.FAILED, error=utils.say(gettext_noop("The copy was not made"))) def log_entry(self, op, state): return _log(op, state, f"clone volume {op.params['image']} as {op.intent['target']}") @@ -134,7 +134,7 @@ class UploadVolume(Kind): logger.error("cancelling upload %s failed: %s", op.pk, utils.safe_text(err)) def expire(self, op): - upload.end(op, Operation.FAILED, upload.IDLE) + upload.end(op, Operation.FAILED, utils.say(upload.IDLE)) def log_entry(self, op, state): return _log(op, state, f"upload volume {op.params['name']}") diff --git a/storages/tests/test_upload_job.py b/storages/tests/test_upload_job.py index fa231cff..ebac695d 100644 --- a/storages/tests/test_upload_job.py +++ b/storages/tests/test_upload_job.py @@ -18,6 +18,7 @@ from django.utils import timezone from libvirt import VIR_ERR_NO_STORAGE_VOL, VIR_ERR_SYSTEM_ERROR, libvirtError from computes.models import Compute +from operations.utils import words from operations import claims, jobs from operations.models import Claim, Operation from operations.worker import housekeeping @@ -137,7 +138,7 @@ class LocalUploadTests(LocalPool, UploadTestCase): with self.assertRaisesRegex(ValueError, "The uploaded image is missing"): self.send(1, b"bbb", total=2, size=6) op.refresh_from_db() - self.assertEqual((op.state, op.error), (Operation.FAILED, "The uploaded image is missing")) + self.assertEqual((op.state, words(op.error)), (Operation.FAILED, "The uploaded image is missing")) self.assertFalse(Claim.objects.exists()) def test_the_first_write_syncs_the_pool_directory_before_the_commit(self): @@ -315,7 +316,7 @@ class LocalUploadTests(LocalPool, UploadTestCase): self.compute.delete() Compute.objects.filter(pk=self.compute.pk).delete() # the row gone without Compute.delete() op = jobs.cancel(op.pk, self.admin) - self.assertEqual((op.state, op.error), (Operation.FAILED, "The compute was deleted")) + self.assertEqual((op.state, words(op.error)), (Operation.FAILED, "The compute was deleted")) def test_ending_before_anything_was_created_needs_no_connection(self): op = jobs.submit("volume.upload", self.admin, {"compute": self.compute.pk, "pool": "default", @@ -338,7 +339,7 @@ class LocalUploadTests(LocalPool, UploadTestCase): Operation.objects.filter(pk=op.pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601)) housekeeping() op.refresh_from_db() - self.assertEqual((op.state, op.error), (Operation.FAILED, IDLE)) + self.assertEqual((op.state, words(op.error)), (Operation.FAILED, IDLE)) self.assertFalse(os.path.exists(self.part(op))) self.assertFalse(Claim.objects.exists()) @@ -348,7 +349,7 @@ class LocalUploadTests(LocalPool, UploadTestCase): heartbeat_at=timezone.now() - timedelta(seconds=3601)) housekeeping() op.refresh_from_db() - self.assertEqual((op.state, op.error), (Operation.CANCELLED, "")) + self.assertEqual((op.state, words(op.error)), (Operation.CANCELLED, "")) self.assertFalse(os.path.exists(self.part(op))) def test_a_chunk_after_the_idle_timeout_ends_the_job(self): @@ -357,7 +358,7 @@ class LocalUploadTests(LocalPool, UploadTestCase): with self.assertRaisesRegex(ValueError, "idle"): self.send(1, b"bbb") op.refresh_from_db() - self.assertEqual((op.state, op.error), (Operation.FAILED, IDLE)) + self.assertEqual((op.state, words(op.error)), (Operation.FAILED, IDLE)) self.assertFalse(os.path.exists(self.part(op))) def test_a_symlinked_part_file_is_refused(self): @@ -583,7 +584,7 @@ class LibvirtStreamUploadTests(UploadTestCase): del self.volumes["disk.iso"] Operation.objects.filter(pk=self.job().pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601)) housekeeping() - self.assertEqual((self.job().state, self.job().error), (Operation.FAILED, "The uploaded image is missing")) + self.assertEqual((self.job().state, words(self.job().error)), (Operation.FAILED, "The uploaded image is missing")) def test_a_failed_volume_lookup_is_raised(self): error = libvirtError("connection lost") diff --git a/storages/upload.py b/storages/upload.py index dbd8b8d8..1f7a6f73 100644 --- a/storages/upload.py +++ b/storages/upload.py @@ -13,16 +13,18 @@ from xml.sax.saxutils import escape import paramiko from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_noop from libvirt import VIR_ERR_NO_STORAGE_VOL, libvirtError from computes.models import Compute from logs.audit import audit from operations import claims, jobs from operations.models import Claim, Operation +from operations.utils import say, words from vrtManager.connection import CONN_SSH, CONN_SOCKET, CONN_TCP, CONN_TLS from vrtManager.storage import wvmStorage -IDLE = "The upload was idle for over an hour; its partial file was deleted" +IDLE = gettext_noop("The upload was idle for over an hour; its partial file was deleted") IDLE_SECONDS = 3600 SSH_CONNECT_TIMEOUT_SECONDS = 10 SSH_IO_TIMEOUT_SECONDS = 30 @@ -440,29 +442,29 @@ def _complete(target, op): if target.present(): target.finalize() elif not target.done(): - jobs.finish(op, jobs.Outcome(Operation.FAILED, error="The uploaded image is missing")) + jobs.finish(op, jobs.Outcome(Operation.FAILED, error=say(gettext_noop("The uploaded image is missing")))) raise ValueError(_("The uploaded image is missing")) except FileExistsError: target.remove() - jobs.finish(op, jobs.Outcome(Operation.FAILED, error="An image of this name appeared before the upload " - "finished; the upload was deleted")) + jobs.finish(op, jobs.Outcome(Operation.FAILED, error=say(gettext_noop( + "An image of this name appeared before the upload finished; the upload was deleted")))) raise jobs.finish(op, jobs.Outcome(Operation.SUCCEEDED)) -def _end(target, op, state, error=""): +def _end(target, op, state, error=()): if op.intent.get("created"): target.remove() - jobs.finish(op, jobs.Outcome(state, error=error)) + jobs.finish(op, jobs.Outcome(state, error=list(error))) -def _refuse(target, op, state, message, error=""): +def _refuse(target, op, state, message, error=()): """End the upload and refuse the chunk.""" _end(target, op, state, error) raise ValueError(message) -def end(op, state, error=""): +def end(op, state, error=()): """Delete what the upload made and end it, unless a chunk request holds it: that request sees the cancel, or the next expiry pass retries. A requested cancel ends it cancelled. An upload whose every chunk is in is @@ -470,16 +472,16 @@ def end(op, state, error=""): try: with claims.hold({_session_key(op): Claim.EXCLUSIVE}): op.refresh_from_db() - if op.state not in Operation.ACTIVE or (error == IDLE and not _idle(op)): + if op.state not in Operation.ACTIVE or (error == say(IDLE) and not _idle(op)): return # ended already, or expiry of an upload that is not idle if op.cancel_requested: - state, error = Operation.CANCELLED, "" + state, error = Operation.CANCELLED, () if not op.intent.get("created"): - jobs.finish(op, jobs.Outcome(state, error=error)) + jobs.finish(op, jobs.Outcome(state, error=list(error))) return compute = Compute.objects.filter(pk=op.params["compute"]).first() if compute is None: - jobs.finish(op, jobs.Outcome(Operation.FAILED, error="The compute was deleted")) + jobs.finish(op, jobs.Outcome(Operation.FAILED, error=say(gettext_noop("The compute was deleted")))) return conn = wvmStorage(compute.hostname, compute.login, compute.password, compute.type, op.params["pool"]) try: @@ -523,7 +525,7 @@ def write_chunk(request, conn, compute, pool, upload_id, raw, chunk_index, chunk if op.state == Operation.SUCCEEDED and last: return op # the answer to the last chunk was lost if op.state not in Operation.ACTIVE: - raise ValueError(op.error or _("The upload has ended")) + raise ValueError(words(op.error) or _("The upload has ended")) with _target(conn, op) as target: expected = op.intent["chunk"] # once every chunk is in, a cancel comes too late: a retry completes it below @@ -537,7 +539,7 @@ def write_chunk(request, conn, compute, pool, upload_id, raw, chunk_index, chunk _complete(target, op) return op if _idle(op): - _refuse(target, op, Operation.FAILED, IDLE, IDLE) + _refuse(target, op, Operation.FAILED, _(IDLE), say(IDLE)) offset = op.progress_done or 0 if chunk.size <= 0: raise ValueError(_("Empty upload chunk")) @@ -549,7 +551,7 @@ def write_chunk(request, conn, compute, pool, upload_id, raw, chunk_index, chunk # a first write that failed before creating it is retried as new new = not (op.intent["created"] and present) if new and offset: - jobs.finish(op, jobs.Outcome(Operation.FAILED, error="The partial upload is missing")) + jobs.finish(op, jobs.Outcome(Operation.FAILED, error=say(gettext_noop("The partial upload is missing")))) raise ValueError(_("The partial upload is missing")) if not op.intent["created"]: _commit(op, intent={"chunk": expected, "created": True}) diff --git a/vrtManager/tests/test_util.py b/vrtManager/tests/test_util.py index 137e076d..08089c7b 100644 --- a/vrtManager/tests/test_util.py +++ b/vrtManager/tests/test_util.py @@ -152,6 +152,15 @@ class OperationErrorTestCase(unittest.TestCase): self.assertEqual(str(error), "own message") self.assertIsInstance(error, libvirt.libvirtError) + def test_keeps_its_text_and_values_apart_for_translation(self): + from vrtManager.util import OperationError + + error = OperationError("The pool has no volume named %(name)s", name="a.qcow2") + self.assertEqual(str(error), "The pool has no volume named a.qcow2") + self.assertEqual((error.text, error.values), ("The pool has no volume named %(name)s", {"name": "a.qcow2"})) + # a text without values is not formatted: libvirt's may hold a % + self.assertEqual(str(OperationError("100% used")), "100% used") + if __name__ == "__main__": unittest.main() diff --git a/vrtManager/util.py b/vrtManager/util.py index 15aaf55e..f23c4548 100644 --- a/vrtManager/util.py +++ b/vrtManager/util.py @@ -12,14 +12,17 @@ from django.conf import settings class OperationError(libvirt.libvirtError): - """An error with WebVirtCloud's own message. + """An error with WebVirtCloud's own message: text, with %(name)s for each + of values. str() is the English message; a page or a job shows text + translated, with the values (operations.utils.said). libvirtError(msg) reports the thread's last libvirt error instead of msg when there is one, so an earlier, unrelated failure would be shown. """ - def __init__(self, msg): - Exception.__init__(self, msg) + def __init__(self, text, **values): + Exception.__init__(self, text % values if values else text) + self.text, self.values = str(text), values self.err = None diff --git a/webvirtcloud/middleware.py b/webvirtcloud/middleware.py index 1f653b1f..dddf29d2 100644 --- a/webvirtcloud/middleware.py +++ b/webvirtcloud/middleware.py @@ -5,7 +5,7 @@ from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext_lazy as _ from libvirt import libvirtError from operations import claims, jobs -from operations.utils import busy_message, public_error_text, summary +from operations.utils import busy_message, public_error_text, said, summary, words API_PREFIX = "/api/" # scripts only from this site: no inline script or event handler runs (inline styles still do); @@ -34,7 +34,7 @@ def error_text(request, exception): """ user = getattr(request, "user", None) if user and (user.is_superuser or user.has_perm("instances.view_instances")): - return str(exception) + return words(said(exception)) return public_error_text(exception)