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 4736eb82..e854198e 100644 Binary files a/locale/tr/LC_MESSAGES/django.mo and b/locale/tr/LC_MESSAGES/django.mo differ diff --git a/locale/tr/LC_MESSAGES/django.po b/locale/tr/LC_MESSAGES/django.po index 2674c57d..c653da1d 100644 --- a/locale/tr/LC_MESSAGES/django.po +++ b/locale/tr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 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: 2020-11-04 12:00+0000\n" "Last-Translator: catborise , 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 b0b09b55..06264dc3 100644 Binary files a/locale/zh/LC_MESSAGES/django.mo and b/locale/zh/LC_MESSAGES/django.mo differ 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)