diff --git a/instances/templates/instance.html b/instances/templates/instance.html index d5d9261e..c93fc0c7 100644 --- a/instances/templates/instance.html +++ b/instances/templates/instance.html @@ -2,6 +2,7 @@ {% load static %} {% load vm_states %} {% load i18n %} +{% load operations %} {% load bootstrap_icons %} {% block title %}{% trans "Instance" %} - {{ instance.name }}{% endblock %} @@ -682,14 +683,7 @@ return true; }); - - +{% job_panel_script job_resource %} + diff --git a/operations/templatetags/operations.py b/operations/templatetags/operations.py index 65f4d048..2a41b11d 100644 --- a/operations/templatetags/operations.py +++ b/operations/templatetags/operations.py @@ -1,3 +1,4 @@ +import json import uuid from django import template @@ -5,6 +6,7 @@ from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe from django.utils.translation import pgettext_lazy +from operations.jobs import take_remembered from operations.models import Operation register = template.Library() @@ -53,3 +55,10 @@ def state_label(state): def wrap_at_slash(label): """A "/" label that wraps after its slash only, never inside a name.""" return format_html_join(mark_safe("/"), '{}', ((part,) for part in label.split("/"))) + + +@register.inclusion_tag("operations/job_panel_script.html", takes_context=True) +def job_panel_script(context, resource): + """The job panel's script for the page of resource.""" + return {"resource": resource, "csrf_token": context.get("csrf_token"), + "remembered": json.dumps(take_remembered(context["request"], resource))} diff --git a/storages/jobs.py b/storages/jobs.py new file mode 100644 index 00000000..03d0c838 --- /dev/null +++ b/storages/jobs.py @@ -0,0 +1,63 @@ +"""Jobs of the storages app (operations framework).""" + +from django.core.exceptions import PermissionDenied +from django.utils.translation import gettext_noop +from vrtManager import util +from vrtManager.storage import wvmStorage + +from computes.models import Compute +from operations import claims +from operations.jobs import Kind, Outcome, Plan, register +from operations.models import Claim, Operation + + +def _storage(params): + c = Compute.objects.get(pk=params["compute"]) + return c, wvmStorage(c.hostname, c.login, c.password, c.type, params["pool"]) + + +@register +class CloneVolume(Kind): + name = "volume.clone" + label = gettext_noop("Clone volume") + time_limit = 12 * 3600 # a copy cannot be stopped: over time is only noted + + def normalize(self, raw): + fmt = str(raw.get("format") or "") if raw.get("convert") else "" + return {"compute": int(raw["compute"]), "pool": str(raw["pool"]), "image": str(raw["image"]), + "name": str(raw["name"]), "format": fmt, "prealloc": fmt == "qcow2" and bool(raw.get("meta_prealloc"))} + + def validate(self, user, params): + if not user.is_superuser: + raise PermissionDenied + + def plan(self, user, params): + 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']}") + 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") + 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}, + resource=pool, label=f"{compute.name}/{params['pool']}", intent={"target": target}) + + def run(self, op, ctx): + _compute, stg = _storage(op.params) + ctx.record_intent() + ctx.set_phase(gettext_noop("Copying the volume")) + created = stg.clone_volume(op.params["image"], op.params["name"], op.params["format"] or None, + op.params["prealloc"]) + ctx.record_intent(created=created) + return {} + + def reconcile(self, op): + if op.intent.get("created"): + 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") diff --git a/storages/templates/storage.html b/storages/templates/storage.html index e82fe939..9b0375f1 100644 --- a/storages/templates/storage.html +++ b/storages/templates/storage.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load i18n %} +{% load operations %} {% load static %} {% load bootstrap_icons %} {% block title %}{{ compute }} - {% trans "Storage" %}: {{ pool }}{% endblock %} @@ -13,6 +14,7 @@ {% block content %} {% include 'pleasewaitdialog.html' %} + @@ -119,7 +121,7 @@ {% trans "Clone image" %} {{ volume.name }} - {% csrf_token %} + {% csrf_token %}{% submission_key %} {% trans "Name" %} @@ -153,7 +155,7 @@ @@ -193,6 +195,8 @@ {% endblock %} {% block script %} + + {% job_panel_script job_resource %}