]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: snapshot mirroring from dashboard
authorPere Diaz Bou <pdiazbou@redhat.com>
Fri, 13 May 2022 15:15:33 +0000 (17:15 +0200)
committerNizamudeen A <nia@redhat.com>
Tue, 16 Aug 2022 07:30:35 +0000 (13:00 +0530)
Resolves: rhbz#1891012

Enable snapshot mirroring from the Pools -> Image

Also show the mirror-snapshot in the image where snapshot is enabled

When parsing images if an image has the snapshot mode enabled, it will
try to  run commands that don't work with that mode. The solution was
not running those for now and appending the mode in the get call.

Fixes: https://tracker.ceph.com/issues/55648
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
Signed-off-by: Avan Thakkar <athakkar@redhat.com>
(cherry picked from commit 489a385a95d6ffa5dbd4c5f9c53c1f80ea179142)
(cherry picked from commit 3ca9ca7e215562912daf00ca3cca40dbc5d560b5)

26 files changed:
qa/tasks/mgr/dashboard/test_rbd.py
src/pybind/mgr/dashboard/__init__.py
src/pybind/mgr/dashboard/controllers/rbd.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rbd.py

index b8df472a69254ddeef99466e819d98b5227ab934..4e0367fd5be77b299394faa66a40e1a8d7f220f0 100644 (file)
@@ -205,6 +205,7 @@ class RbdTest(DashboardTestCase):
         {
             "size": 1073741824,
             "obj_size": 4194304,
+            "mirror_mode": "journal",
             "num_objs": 256,
             "order": 22,
             "block_name_prefix": "rbd_data.10ae2ae8944a",
@@ -245,6 +246,7 @@ class RbdTest(DashboardTestCase):
                 'source': JLeaf(int),
                 'value': JLeaf(str),
             })),
+            'mirror_mode': JLeaf(str),
         })
         self.assertSchema(img, schema)
 
index 653474c6304dba59240d46618b6baffa677d03c4..d2eab9751fe07ea4cee4b0f126c38df416652844 100644 (file)
@@ -48,5 +48,13 @@ else:
         os.path.dirname(__file__),
         'frontend/dist'))
 
+    import rbd
+
+    # Api tests do not mock rbd as opposed to dashboard unit tests. Both
+    # use UNITTEST env variable.
+    if isinstance(rbd, mock.Mock):
+        rbd.RBD_MIRROR_IMAGE_MODE_JOURNAL = 0
+        rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT = 1
+
 # DO NOT REMOVE: required for ceph-mgr to load a module
 from .module import Module, StandbyModule  # noqa: F401
index c065c6d1403e59f9a866fb8804ec6d106cd188a6..2c360333f5ce6e027672635b7fcd2de11cc017e4 100644 (file)
@@ -5,7 +5,6 @@
 import logging
 import math
 from datetime import datetime
-from enum import Enum
 from functools import partial
 
 import rbd
@@ -15,9 +14,10 @@ from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.ceph_service import CephService
 from ..services.exception import handle_rados_error, handle_rbd_error, serialize_dashboard_exception
-from ..services.rbd import RbdConfiguration, RbdMirroringService, RbdService, \
-    RbdSnapshotService, format_bitmask, format_features, parse_image_spec, \
-    rbd_call, rbd_image_call
+from ..services.rbd import MIRROR_IMAGE_MODE, RbdConfiguration, \
+    RbdMirroringService, RbdService, RbdSnapshotService, format_bitmask, \
+    format_features, get_image_spec, parse_image_spec, rbd_call, \
+    rbd_image_call
 from ..tools import ViewCache, str_to_bool
 from . import APIDoc, APIRouter, CreatePermission, DeletePermission, \
     EndpointDoc, RESTController, Task, UpdatePermission, allow_empty_body
@@ -77,10 +77,6 @@ class Rbd(RESTController):
     ALLOW_DISABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "deep-flatten",
                               "journaling"}
 
-    class MIRROR_IMAGE_MODE(Enum):
-        journal = rbd.RBD_MIRROR_IMAGE_MODE_JOURNAL
-        snapshot = rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT
-
     def _rbd_list(self, pool_name=None):
         if pool_name:
             pools = [pool_name]
@@ -114,8 +110,9 @@ class Rbd(RESTController):
 
     @RbdTask('create',
              {'pool_name': '{pool_name}', 'namespace': '{namespace}', 'image_name': '{name}'}, 2.0)
-    def create(self, name, pool_name, size, namespace=None, obj_size=None, features=None,
-               stripe_unit=None, stripe_count=None, data_pool=None, configuration=None):
+    def create(self, name, pool_name, size, namespace=None, schedule_interval='',
+               obj_size=None, features=None, stripe_unit=None, stripe_count=None,
+               data_pool=None, configuration=None, mirror_mode=None):
 
         size = int(size)
 
@@ -137,6 +134,13 @@ class Rbd(RESTController):
                              image_name=name).set_configuration(configuration)
 
         rbd_call(pool_name, namespace, _create)
+        if mirror_mode:
+            RbdMirroringService.enable_image(name, pool_name, namespace,
+                                             MIRROR_IMAGE_MODE[mirror_mode])
+
+        if schedule_interval:
+            image_spec = get_image_spec(pool_name, namespace, name)
+            RbdMirroringService.snapshot_schedule_add(image_spec, schedule_interval)
 
     @RbdTask('delete', ['{image_spec}'], 2.0)
     def delete(self, image_spec):
@@ -153,7 +157,9 @@ class Rbd(RESTController):
     @RbdTask('edit', ['{image_spec}', '{name}'], 4.0)
     def set(self, image_spec, name=None, size=None, features=None,
             configuration=None, enable_mirror=None, primary=None,
-            resync=False, mirror_mode=None, schedule_interval='', start_time=''):
+            resync=False, mirror_mode=None, schedule_interval='',
+            remove_scheduling=False):
+
         pool_name, namespace, image_name = parse_image_spec(image_spec)
 
         def _edit(ioctx, image):
@@ -193,7 +199,7 @@ class Rbd(RESTController):
             if enable_mirror and mirror_image_info['state'] == rbd.RBD_MIRROR_IMAGE_DISABLED:
                 RbdMirroringService.enable_image(
                     image_name, pool_name, namespace,
-                    self.MIRROR_IMAGE_MODE[mirror_mode].value)
+                    MIRROR_IMAGE_MODE[mirror_mode])
             elif (enable_mirror is False
                   and mirror_image_info['state'] == rbd.RBD_MIRROR_IMAGE_ENABLED):
                 RbdMirroringService.disable_image(
@@ -210,7 +216,10 @@ class Rbd(RESTController):
                 RbdMirroringService.resync_image(image_name, pool_name, namespace)
 
             if schedule_interval:
-                RbdMirroringService.snapshot_schedule(image_spec, schedule_interval, start_time)
+                RbdMirroringService.snapshot_schedule_add(image_spec, schedule_interval)
+
+            if remove_scheduling:
+                RbdMirroringService.snapshot_schedule_remove(image_spec)
 
         return rbd_image_call(pool_name, namespace, image_name, _edit)
 
index 472fe37f19fa60fe67f552d94eb7fa4ff2140471..ae569aab449da910cc9f3be23fa3d848b165a52c 100644 (file)
@@ -21,6 +21,7 @@ import { IscsiTargetListComponent } from './iscsi-target-list/iscsi-target-list.
 import { IscsiComponent } from './iscsi/iscsi.component';
 import { MirroringModule } from './mirroring/mirroring.module';
 import { OverviewComponent as RbdMirroringComponent } from './mirroring/overview/overview.component';
+import { PoolEditModeModalComponent } from './mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component';
 import { RbdConfigurationFormComponent } from './rbd-configuration-form/rbd-configuration-form.component';
 import { RbdConfigurationListComponent } from './rbd-configuration-list/rbd-configuration-list.component';
 import { RbdDetailsComponent } from './rbd-details/rbd-details.component';
@@ -139,7 +140,14 @@ const routes: Routes = [
     path: 'mirroring',
     component: RbdMirroringComponent,
     canActivate: [FeatureTogglesGuardService],
-    data: { breadcrumbs: 'Mirroring' }
+    data: { breadcrumbs: 'Mirroring' },
+    children: [
+      {
+        path: `${URLVerbs.EDIT}/:pool_name`,
+        component: PoolEditModeModalComponent,
+        outlet: 'modal'
+      }
+    ]
   },
   // iSCSI
   {
index 03b49a8030d658f578ffa2a1099af886e2450cda..00fe92b32b734ecf507ab2c0080f0b2e169ba577 100644 (file)
@@ -1,4 +1,5 @@
-<cd-modal [modalRef]="activeModal">
+<cd-modal [modalRef]="activeModal"
+          pageURL="mirroring">
   <ng-container i18n
                 class="modal-title">Edit pool mirror mode</ng-container>
 
index a22a60d5e736c0fb631ce975d4d0226c53d08bcc..11ba12334f38cd082c3e868585fc05159b0c40f0 100644 (file)
@@ -1,6 +1,7 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
 
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@@ -10,6 +11,7 @@ import { of } from 'rxjs';
 import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { SharedModule } from '~/app/shared/shared.module';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
 import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
 import { PoolEditModeModalComponent } from './pool-edit-mode-modal.component';
 
@@ -19,6 +21,7 @@ describe('PoolEditModeModalComponent', () => {
   let notificationService: NotificationService;
   let rbdMirroringService: RbdMirroringService;
   let formHelper: FormHelper;
+  let activatedRoute: ActivatedRouteStub;
 
   configureTestBed({
     declarations: [PoolEditModeModalComponent],
@@ -29,7 +32,13 @@ describe('PoolEditModeModalComponent', () => {
       SharedModule,
       ToastrModule.forRoot()
     ],
-    providers: [NgbActiveModal]
+    providers: [
+      NgbActiveModal,
+      {
+        provide: ActivatedRoute,
+        useValue: new ActivatedRouteStub({ pool_name: 'somePool' })
+      }
+    ]
   });
 
   beforeEach(() => {
@@ -41,6 +50,7 @@ describe('PoolEditModeModalComponent', () => {
     spyOn(notificationService, 'show').and.stub();
 
     rbdMirroringService = TestBed.inject(RbdMirroringService);
+    activatedRoute = <ActivatedRouteStub>TestBed.inject(ActivatedRoute);
 
     formHelper = new FormHelper(component.editModeForm);
     fixture.detectChanges();
@@ -55,11 +65,8 @@ describe('PoolEditModeModalComponent', () => {
       spyOn(component.activeModal, 'close').and.callThrough();
     });
 
-    afterEach(() => {
-      expect(component.activeModal.close).toHaveBeenCalledTimes(1);
-    });
-
     it('should call updatePool', () => {
+      activatedRoute.setParams({ pool_name: 'somePool' });
       spyOn(rbdMirroringService, 'updatePool').and.callFake(() => of(''));
 
       component.editModeForm.patchValue({ mirrorMode: 'disabled' });
index 137e787174d7a0e545382746cf5fbf9cf182afcc..ef30c888c8ba6207be4ff0a0ec9f2756d3d477fc 100644 (file)
@@ -1,5 +1,7 @@
+import { Location } from '@angular/common';
 import { Component, OnDestroy, OnInit } from '@angular/core';
 import { AbstractControl, FormControl, Validators } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
 
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 import { Subscription } from 'rxjs';
@@ -40,7 +42,9 @@ export class PoolEditModeModalComponent implements OnInit, OnDestroy {
     public activeModal: NgbActiveModal,
     public actionLabels: ActionLabelsI18n,
     private rbdMirroringService: RbdMirroringService,
-    private taskWrapper: TaskWrapperService
+    private taskWrapper: TaskWrapperService,
+    private route: ActivatedRoute,
+    private location: Location
   ) {
     this.createForm();
   }
@@ -54,6 +58,9 @@ export class PoolEditModeModalComponent implements OnInit, OnDestroy {
   }
 
   ngOnInit() {
+    this.route.params.subscribe((params: { pool_name: string }) => {
+      this.poolName = params.pool_name;
+    });
     this.pattern = `${this.poolName}`;
     this.rbdMirroringService.getPool(this.poolName).subscribe((resp: PoolEditModeResponseModel) => {
       this.setResponse(resp);
@@ -97,7 +104,7 @@ export class PoolEditModeModalComponent implements OnInit, OnDestroy {
       error: () => this.editModeForm.setErrors({ cdSubmitButton: true }),
       complete: () => {
         this.rbdMirroringService.refresh();
-        this.activeModal.close();
+        this.location.back();
       }
     });
   }
index b6081b8e03d634e371f564e887ca6162019f74ce..1e4e72df19651aff478a10effefbe5164891a810 100644 (file)
@@ -20,3 +20,4 @@
              let-value="value">
   <span [ngClass]="row.health_color | mirrorHealthColor">{{ value }}</span>
 </ng-template>
+<router-outlet name="modal"></router-outlet>
index 29b435900df6f22ed3f0085cf38ef6daaac080e1..a5e1c9e4b959ba7dc8ccf334bbfbf10483c6b218 100644 (file)
@@ -1,4 +1,5 @@
 import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
 
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
 import { Observable, Subscriber, Subscription } from 'rxjs';
@@ -6,6 +7,7 @@ import { Observable, Subscriber, Subscription } from 'rxjs';
 import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
 import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
 import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { URLVerbs } from '~/app/shared/constants/app.constants';
 import { Icons } from '~/app/shared/enum/icons.enum';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
@@ -14,9 +16,9 @@ import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { ModalService } from '~/app/shared/services/modal.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { PoolEditModeModalComponent } from '../pool-edit-mode-modal/pool-edit-mode-modal.component';
 import { PoolEditPeerModalComponent } from '../pool-edit-peer-modal/pool-edit-peer-modal.component';
 
+const BASE_URL = '/block/mirroring';
 @Component({
   selector: 'cd-mirroring-pools',
   templateUrl: './pool-list.component.html',
@@ -43,7 +45,8 @@ export class PoolListComponent implements OnInit, OnDestroy {
     private authStorageService: AuthStorageService,
     private rbdMirroringService: RbdMirroringService,
     private modalService: ModalService,
-    private taskWrapper: TaskWrapperService
+    private taskWrapper: TaskWrapperService,
+    private router: Router
   ) {
     this.data = [];
     this.permission = this.authStorageService.getPermissions().rbdMirroring;
@@ -111,10 +114,10 @@ export class PoolListComponent implements OnInit, OnDestroy {
   }
 
   editModeModal() {
-    const initialState = {
-      poolName: this.selection.first().name
-    };
-    this.modalRef = this.modalService.show(PoolEditModeModalComponent, initialState);
+    this.router.navigate([
+      BASE_URL,
+      { outlets: { modal: [URLVerbs.EDIT, this.selection.first().name] } }
+    ]);
   }
 
   editPeersModal(mode: string) {
index 7503863e0fb2c256165b2b0ee793e83b6ad62792..ab9454cbc15ebb5b0efabf262d8d81ffb2c8c8d6 100644 (file)
                               [featuresName]="selection.features_name"
                               [poolName]="selection.pool_name"
                               [namespace]="selection.namespace"
+                              [mirroring]="selection.mirror_mode"
                               [rbdName]="selection.name"></cd-rbd-snapshot-list>
       </ng-template>
     </li>
index 9187b024d9d017fcb742452ad66c23ed609cd745..6d1f8c7d3c9fd561eddae9041b3a5ee296aa5d95 100644 (file)
@@ -5,4 +5,9 @@ export class RbdFormEditRequestModel {
   size: number;
   features: Array<string> = [];
   configuration: RbdConfigurationEntry[];
+
+  enable_mirror?: boolean;
+  mirror_mode?: string;
+  schedule_interval: string;
+  remove_scheduling? = false;
 }
index 668556fa0e24dfff1c3a91fd9175956794ecdce6..ad55b26ff7a0b4fc46cc38b08584dc9a6a1e76bf 100644 (file)
@@ -68,7 +68,8 @@
                     name="pool"
                     class="form-control"
                     formControlName="pool"
-                    *ngIf="mode !== 'editing' && poolPermission.read">
+                    *ngIf="mode !== 'editing' && poolPermission.read"
+                    (change)="setPoolMirrorMode()">
               <option *ngIf="pools === null"
                       [ngValue]="null"
                       i18n>Loading...</option>
           </div>
         </div>
 
+        <!-- Mirroring -->
+        <div class="form-group row">
+          <div class="cd-col-form-offset">
+            <div class="custom-control custom-checkbox">
+              <input type="checkbox"
+                     class="custom-control-input"
+                     id="mirroring"
+                     name="mirroring"
+                     (change)="setMirrorMode()"
+                     formControlName="mirroring">
+              <label class="custom-control-label"
+                     for="mirroring">Mirroring</label>
+              <cd-helper *ngIf="mirroring === false && this.currentPoolName">
+                <span i18n>You need to enable a <b>mirror mode</b> in the selected pool. Please <a [routerLink]="['/block/mirroring', {outlets: {modal: ['edit', currentPoolName]}}]">click here to select a mode and enable it in this pool.</a></span>
+              </cd-helper>
+            </div>
+            <div *ngIf="mirroring">
+              <div class="custom-control custom-radio ml-2"
+                   *ngFor="let option of mirroringOptions">
+                <input type="radio"
+                       class="custom-control-input"
+                       [id]="option"
+                       [value]="option"
+                       name="mirroringMode"
+                       (change)="setExclusiveLock()"
+                       formControlName="mirroringMode"
+                       [attr.disabled]="(poolMirrorMode === 'pool' && option === 'snapshot') ? true : null">
+                <label class="custom-control-label"
+                       [for]="option">{{ option | titlecase }}</label>
+                <cd-helper *ngIf="poolMirrorMode === 'pool' && option === 'snapshot'">
+                  <span i18n>You need to enable <b>image mirror mode</b> in the selected pool. Please <a [routerLink]="['/block/mirroring', {outlets: {modal: ['edit', currentPoolName]}}]">click here to select a mode and enable it in this pool.</a></span>
+                </cd-helper>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="form-group row"
+             *ngIf="rbdForm.getValue('mirroringMode') === 'snapshot' && mirroring">
+          <label class="cd-col-form-label"
+                 i18n>Schedule Interval
+          <cd-helper i18n-html
+                     html="Create Mirror-Snapshots automatically on a periodic basis. The interval can be specified in days, hours, or minutes using d, h, m suffix respectively.">
+          </cd-helper></label>
+          <div class="cd-col-form-input">
+            <input id="schedule"
+                   name="schedule"
+                   class="form-control"
+                   type="text"
+                   formControlName="schedule"
+                   i18n-placeholder
+                   placeholder="e.g., 12h or 1d or 10m"
+                   [attr.disabled]="(mode === rbdFormMode.editing) ? true : null">
+          </div>
+        </div>
+
         <!-- Advanced -->
         <div class="row">
           <div class="col-sm-12">
index 8c00c7460ab6a5fc2bccfcbca58cfebcbdc6cec9..5df73d93fe1ee4142c022dc854f564f4fdcdbaa6 100644 (file)
@@ -304,12 +304,7 @@ describe('RbdFormComponent', () => {
   });
 
   describe('tests for feature flags', () => {
-    let deepFlatten: any,
-      layering: any,
-      exclusiveLock: any,
-      objectMap: any,
-      journaling: any,
-      fastDiff: any;
+    let deepFlatten: any, layering: any, exclusiveLock: any, objectMap: any, fastDiff: any;
     const defaultFeatures = [
       // Supposed to be enabled by default
       'deep-flatten',
@@ -323,7 +318,6 @@ describe('RbdFormComponent', () => {
       'layering',
       'exclusive-lock',
       'object-map',
-      'journaling',
       'fast-diff'
     ];
     const setFeatures = (features: Record<string, RbdImageFeature>) => {
@@ -359,14 +353,7 @@ describe('RbdFormComponent', () => {
         spyOn(rbdService, 'defaultFeatures').and.returnValue(of(defaultFeatures));
         setRouterUrl('edit', pool, image);
         fixture.detectChanges();
-        [
-          deepFlatten,
-          layering,
-          exclusiveLock,
-          objectMap,
-          journaling,
-          fastDiff
-        ] = getFeatureNativeElements();
+        [deepFlatten, layering, exclusiveLock, objectMap, fastDiff] = getFeatureNativeElements();
       };
 
       it('should have the interlock feature for flags disabled, if one feature is not set', () => {
@@ -409,14 +396,7 @@ describe('RbdFormComponent', () => {
         spyOn(rbdService, 'defaultFeatures').and.returnValue(of(defaultFeatures));
         setRouterUrl('create');
         fixture.detectChanges();
-        [
-          deepFlatten,
-          layering,
-          exclusiveLock,
-          objectMap,
-          journaling,
-          fastDiff
-        ] = getFeatureNativeElements();
+        [deepFlatten, layering, exclusiveLock, objectMap, fastDiff] = getFeatureNativeElements();
       });
 
       it('should initialize the checkboxes correctly', () => {
@@ -424,21 +404,18 @@ describe('RbdFormComponent', () => {
         expect(layering.disabled).toBe(false);
         expect(exclusiveLock.disabled).toBe(false);
         expect(objectMap.disabled).toBe(false);
-        expect(journaling.disabled).toBe(false);
         expect(fastDiff.disabled).toBe(false);
 
         expect(deepFlatten.checked).toBe(true);
         expect(layering.checked).toBe(true);
         expect(exclusiveLock.checked).toBe(true);
         expect(objectMap.checked).toBe(true);
-        expect(journaling.checked).toBe(false);
         expect(fastDiff.checked).toBe(true);
       });
 
       it('should disable features if their requirements are not met (exclusive-lock)', () => {
         exclusiveLock.click(); // unchecks exclusive-lock
         expect(objectMap.disabled).toBe(true);
-        expect(journaling.disabled).toBe(true);
         expect(fastDiff.disabled).toBe(true);
       });
 
@@ -447,5 +424,39 @@ describe('RbdFormComponent', () => {
         expect(fastDiff.disabled).toBe(true);
       });
     });
+
+    describe('test mirroring options', () => {
+      beforeEach(() => {
+        component.ngOnInit();
+        fixture.detectChanges();
+        const mirroring = fixture.debugElement.query(By.css('#mirroring')).nativeElement;
+        mirroring.click();
+        fixture.detectChanges();
+      });
+
+      it('should verify two mirroring options are shown', () => {
+        const journal = fixture.debugElement.query(By.css('#journal')).nativeElement;
+        const snapshot = fixture.debugElement.query(By.css('#snapshot')).nativeElement;
+        expect(journal).not.toBeNull();
+        expect(snapshot).not.toBeNull();
+      });
+
+      it('should verify only snapshot is disabled for pools that are in pool mirror mode', () => {
+        component.poolMirrorMode = 'pool';
+        fixture.detectChanges();
+        const journal = fixture.debugElement.query(By.css('#journal')).nativeElement;
+        const snapshot = fixture.debugElement.query(By.css('#snapshot')).nativeElement;
+        expect(journal.disabled).toBe(false);
+        expect(snapshot.disabled).toBe(true);
+      });
+
+      it('should set and disable exclusive-lock only for the journal mode', () => {
+        component.poolMirrorMode = 'pool';
+        fixture.detectChanges();
+        const exclusiveLocks = fixture.debugElement.query(By.css('#exclusive-lock')).nativeElement;
+        expect(exclusiveLocks.checked).toBe(true);
+        expect(exclusiveLocks.disabled).toBe(true);
+      });
+    });
   });
 });
index 1dc1df0dd3aba8da08c4025e85018572d3cd0df2..6b6058468f46e715942df0aafb7088e4bac6fc8e 100644 (file)
@@ -8,6 +8,7 @@ import { first, switchMap } from 'rxjs/operators';
 
 import { Pool } from '~/app/ceph/pool/pool';
 import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
 import { RbdService } from '~/app/shared/api/rbd.service';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { Icons } from '~/app/shared/enum/icons.enum';
@@ -77,6 +78,11 @@ export class RbdFormComponent extends CdForm implements OnInit {
 
   defaultObjectSize = '4 MiB';
 
+  mirroringOptions = ['journal', 'snapshot'];
+  poolMirrorMode: string;
+  mirroring = false;
+  currentPoolName = '';
+
   objectSizes: Array<string> = [
     '4 KiB',
     '8 KiB',
@@ -109,7 +115,8 @@ export class RbdFormComponent extends CdForm implements OnInit {
     private taskWrapper: TaskWrapperService,
     private dimlessBinaryPipe: DimlessBinaryPipe,
     public actionLabels: ActionLabelsI18n,
-    private router: Router
+    private router: Router,
+    private rbdMirroringService: RbdMirroringService
   ) {
     super();
     this.routerUrl = this.router.url;
@@ -141,13 +148,6 @@ export class RbdFormComponent extends CdForm implements OnInit {
         allowDisable: true,
         initDisabled: true
       },
-      journaling: {
-        desc: $localize`Journaling (requires exclusive-lock)`,
-        requires: 'exclusive-lock',
-        allowEnable: true,
-        allowDisable: true,
-        initDisabled: true
-      },
       'fast-diff': {
         desc: $localize`Fast diff (interlocked with object-map)`,
         requires: 'object-map',
@@ -188,6 +188,11 @@ export class RbdFormComponent extends CdForm implements OnInit {
             return acc;
           }, {})
         ),
+        mirroring: new FormControl(false),
+        schedule: new FormControl('', {
+          validators: [Validators.pattern(/^([0-9]+)d|([0-9]+)h|([0-9]+)m$/)] // check schedule interval to be in format - 1d or 1h or 1m
+        }),
+        mirroringMode: new FormControl(this.mirroringOptions[0]),
         stripingUnit: new FormControl(null),
         stripingCount: new FormControl(null, {
           updateOn: 'blur'
@@ -232,6 +237,48 @@ export class RbdFormComponent extends CdForm implements OnInit {
     this.gatherNeededData().subscribe(this.handleExternalData.bind(this));
   }
 
+  setExclusiveLock() {
+    if (this.mirroring && this.rbdForm.get('mirroringMode').value === 'journal') {
+      this.rbdForm.get('exclusive-lock').setValue(true);
+      this.rbdForm.get('exclusive-lock').disable();
+    } else {
+      this.rbdForm.get('exclusive-lock').enable();
+      if (this.poolMirrorMode === 'pool') {
+        this.rbdForm.get('mirroringMode').setValue(this.mirroringOptions[0]);
+      }
+    }
+  }
+
+  setMirrorMode() {
+    this.mirroring = !this.mirroring;
+    this.setExclusiveLock();
+  }
+
+  setPoolMirrorMode() {
+    this.currentPoolName =
+      this.mode === this.rbdFormMode.editing
+        ? this.response?.pool_name
+        : this.rbdForm.getValue('pool');
+    if (this.currentPoolName) {
+      this.rbdMirroringService.refresh();
+      this.rbdMirroringService.subscribeSummary((data) => {
+        const pool = data.content_data.pools.find((o: any) => o.name === this.currentPoolName);
+        this.poolMirrorMode = pool.mirror_mode;
+
+        if (pool.mirror_mode === 'disabled') {
+          this.mirroring = false;
+          this.rbdForm.get('mirroring').setValue(this.mirroring);
+          this.rbdForm.get('mirroring').disable();
+        } else if (this.mode !== this.rbdFormMode.editing) {
+          this.rbdForm.get('mirroring').enable();
+          this.mirroring = true;
+          this.rbdForm.get('mirroring').setValue(this.mirroring);
+        }
+      });
+    }
+    this.setExclusiveLock();
+  }
+
   private prepareFormForAction() {
     const url = this.routerUrl;
     if (url.startsWith('/block/rbd/edit')) {
@@ -285,6 +332,7 @@ export class RbdFormComponent extends CdForm implements OnInit {
 
   private handleExternalData(data: ExternalData) {
     this.handlePoolData(data.pools);
+    this.setPoolMirrorMode();
 
     if (data.defaultFeatures) {
       // Fetched only during creation
@@ -543,6 +591,16 @@ export class RbdFormComponent extends CdForm implements OnInit {
     }
     if (this.mode === this.rbdFormMode.editing) {
       this.rbdForm.get('name').setValue(response.name);
+      if (response?.mirror_mode === 'snapshot' || response.features_name.includes('journaling')) {
+        this.mirroring = true;
+        this.rbdForm.get('mirroring').setValue(this.mirroring);
+        this.rbdForm.get('mirroringMode').setValue(response?.mirror_mode);
+        this.rbdForm.get('schedule').setValue(response?.schedule_interval);
+      } else {
+        this.mirroring = false;
+        this.rbdForm.get('mirroring').setValue(this.mirroring);
+      }
+      this.setPoolMirrorMode();
     }
     this.rbdForm.get('pool').setValue(response.pool_name);
     this.onPoolChange(response.pool_name);
@@ -570,7 +628,11 @@ export class RbdFormComponent extends CdForm implements OnInit {
     request.pool_name = this.rbdForm.getValue('pool');
     request.namespace = this.rbdForm.getValue('namespace');
     request.name = this.rbdForm.getValue('name');
+    request.schedule_interval = this.rbdForm.getValue('schedule');
     request.size = this.formatter.toBytes(this.rbdForm.getValue('size'));
+    if (this.poolMirrorMode === 'image') {
+      request.mirror_mode = this.rbdForm.getValue('mirroringMode');
+    }
     this.addObjectSizeAndStripingToRequest(request);
     request.configuration = this.getDirtyConfigurationValues();
     return request;
@@ -586,6 +648,10 @@ export class RbdFormComponent extends CdForm implements OnInit {
       }
     });
 
+    if (this.mirroring && this.rbdForm.getValue('mirroringMode') === 'journal') {
+      request.features.push('journaling');
+    }
+
     /* Striping */
     request.stripe_unit = this.formatter.toBytes(this.rbdForm.getValue('stripingUnit'));
     request.stripe_count = this.rbdForm.getValue('stripingCount');
@@ -598,7 +664,9 @@ export class RbdFormComponent extends CdForm implements OnInit {
       task: new FinishedTask('rbd/create', {
         pool_name: request.pool_name,
         namespace: request.namespace,
-        image_name: request.name
+        image_name: request.name,
+        schedule_interval: request.schedule_interval,
+        start_time: request.start_time
       }),
       call: this.rbdService.create(request)
     });
@@ -607,12 +675,29 @@ export class RbdFormComponent extends CdForm implements OnInit {
   editRequest() {
     const request = new RbdFormEditRequestModel();
     request.name = this.rbdForm.getValue('name');
+    request.schedule_interval = this.rbdForm.getValue('schedule');
+    request.name = this.rbdForm.getValue('name');
     request.size = this.formatter.toBytes(this.rbdForm.getValue('size'));
     _.forIn(this.features, (feature) => {
       if (this.rbdForm.getValue(feature.key)) {
         request.features.push(feature.key);
       }
     });
+    request.enable_mirror = this.rbdForm.getValue('mirroring');
+    if (this.poolMirrorMode === 'image') {
+      if (request.enable_mirror) {
+        request.mirror_mode = this.rbdForm.getValue('mirroringMode');
+      }
+    } else {
+      if (request.enable_mirror) {
+        request.features.push('journaling');
+      } else {
+        const index = request.features.indexOf('journaling', 0);
+        if (index > -1) {
+          request.features.splice(index, 1);
+        }
+      }
+    }
     request.configuration = this.getDirtyConfigurationValues();
     return request;
   }
index 36c7b5ea86950b41ee1a766fc7668b27fa59d23a..262d79c95baec30e3398582c40f83700577cadf9 100644 (file)
@@ -17,4 +17,10 @@ export class RbdFormModel {
 
   /* Deletion process */
   source?: string;
+
+  enable_mirror?: boolean;
+  mirror_mode?: string;
+
+  schedule_interval: string;
+  start_time: string;
 }
index ea2bd668fbaa9c3eeb57717dcfe9f833156ce680..824e05f8034f3df7db15664475bc8fbe4164e9df 100644 (file)
@@ -24,7 +24,7 @@
   </cd-rbd-details>
 </cd-table>
 
-<ng-template #usageNotAvailableTooltipTpl>
+<ng-template #scheduleStatus>
   <div i18n
        [innerHtml]="'Only available for RBD images with <strong>fast-diff</strong> enabled'"></div>
 </ng-template>
   <span *ngIf="!value">-</span>
 </ng-template>
 
+<ng-template #mirroringTpl
+             let-value="value">
+  <span *ngIf="value.length === 3; else probb"
+        class="badge badge-info">{{ value[0] }}</span>&nbsp;
+  <span *ngIf="value.length === 3"
+        class="badge badge-info"
+        [ngbTooltip]="'Next scheduled snapshot on' + ' ' + (value[2] | cdDate)">{{ value[1] }}</span>
+  <ng-template #probb>
+    <span class="badge badge-info">{{ value }}</span>
+  </ng-template>
+</ng-template>
+
 <ng-template #flattenTpl
              let-value>
   You are about to flatten
index 02cf636ac89e67cf9dadeaaf0bcc0718b6ce38a0..a63ac2379fd59677be3b3fb93e5a9e9084ad2bd4 100644 (file)
@@ -314,11 +314,19 @@ describe('RbdListComponent', () => {
 
     expect(tableActions).toEqual({
       'create,update,delete': {
-        actions: ['Create', 'Edit', 'Copy', 'Flatten', 'Delete', 'Move to Trash'],
+        actions: [
+          'Create',
+          'Edit',
+          'Copy',
+          'Flatten',
+          'Delete',
+          'Move to Trash',
+          'Remove Scheduling'
+        ],
         primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
       },
       'create,update': {
-        actions: ['Create', 'Edit', 'Copy', 'Flatten'],
+        actions: ['Create', 'Edit', 'Copy', 'Flatten', 'Remove Scheduling'],
         primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
       },
       'create,delete': {
@@ -330,11 +338,11 @@ describe('RbdListComponent', () => {
         primary: { multiple: 'Create', executing: 'Copy', single: 'Copy', no: 'Create' }
       },
       'update,delete': {
-        actions: ['Edit', 'Flatten', 'Delete', 'Move to Trash'],
+        actions: ['Edit', 'Flatten', 'Delete', 'Move to Trash', 'Remove Scheduling'],
         primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
       },
       update: {
-        actions: ['Edit', 'Flatten'],
+        actions: ['Edit', 'Flatten', 'Remove Scheduling'],
         primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
       },
       delete: {
index 0ffb25695c8d2e5e3e54c93e42778ac2cb7764a5..c77a627324eedd255c224fde284cf418985775d3 100644 (file)
@@ -2,6 +2,7 @@ import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
 import _ from 'lodash';
+import { Observable, Subscriber } from 'rxjs';
 
 import { RbdService } from '~/app/shared/api/rbd.service';
 import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
@@ -26,6 +27,7 @@ import { ModalService } from '~/app/shared/services/modal.service';
 import { TaskListService } from '~/app/shared/services/task-list.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { RbdFormEditRequestModel } from '../rbd-form/rbd-form-edit-request.model';
 import { RbdParentModel } from '../rbd-form/rbd-parent.model';
 import { RbdTrashMoveModalComponent } from '../rbd-trash-move-modal/rbd-trash-move-modal.component';
 import { RBDImageFormat, RbdModel } from './rbd-model';
@@ -50,6 +52,8 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
   parentTpl: TemplateRef<any>;
   @ViewChild('nameTpl')
   nameTpl: TemplateRef<any>;
+  @ViewChild('mirroringTpl', { static: true })
+  mirroringTpl: TemplateRef<any>;
   @ViewChild('flattenTpl', { static: true })
   flattenTpl: TemplateRef<any>;
   @ViewChild('deleteTpl', { static: true })
@@ -89,6 +93,7 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
         metadata['dest_image_name']
       )
   };
+  remove_scheduling: boolean;
 
   private createRbdFromTaskImageSpec(imageSpecStr: string): RbdModel {
     const imageSpec = ImageSpec.fromString(imageSpecStr);
@@ -180,13 +185,24 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
         this.getInvalidNameDisable(selection) ||
         selection.first().image_format === RBDImageFormat.V1
     };
+    const removeSchedulingAction: CdTableAction = {
+      permission: 'update',
+      icon: Icons.edit,
+      click: () => this.removeSchedulingModal(),
+      name: this.actionLabels.REMOVE_SCHEDULING,
+      disable: (selection: CdTableSelection) =>
+        this.getRemovingStatusDesc(selection) ||
+        this.getInvalidNameDisable(selection) ||
+        selection.first().schedule_info === undefined
+    };
     this.tableActions = [
       addAction,
       editAction,
       copyAction,
       flattenAction,
       deleteAction,
-      moveAction
+      moveAction,
+      removeSchedulingAction
     ];
   }
 
@@ -250,6 +266,12 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
         prop: 'parent',
         flexGrow: 2,
         cellTemplate: this.parentTpl
+      },
+      {
+        name: $localize`Mirroring`,
+        prop: 'mirror_mode',
+        flexGrow: 3,
+        cellTemplate: this.mirroringTpl
       }
     ];
 
@@ -345,6 +367,19 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
       this.tableStatus = new TableStatusViewCache();
     }
 
+    images.forEach((image) => {
+      if (image.schedule_info !== undefined) {
+        let scheduling: any[] = [];
+        const scheduleStatus = 'scheduled';
+        let nextSnapshotDate = +new Date(image.schedule_info.schedule_time);
+        const offset = new Date().getTimezoneOffset();
+        nextSnapshotDate = nextSnapshotDate + Math.abs(offset) * 60000;
+        scheduling.push(image.mirror_mode, scheduleStatus, nextSnapshotDate);
+        image.mirror_mode = scheduling;
+        scheduling = [];
+      }
+    });
+
     return images;
   }
 
@@ -429,6 +464,44 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
     this.modalRef = this.modalService.show(ConfirmationModalComponent, initialState);
   }
 
+  editRequest() {
+    const request = new RbdFormEditRequestModel();
+    request.remove_scheduling = !request.remove_scheduling;
+    return request;
+  }
+
+  removeSchedulingModal() {
+    const imageName = this.selection.first().name;
+
+    const imageSpec = new ImageSpec(
+      this.selection.first().pool_name,
+      this.selection.first().namespace,
+      this.selection.first().name
+    );
+
+    this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+      actionDescription: 'remove scheduling on',
+      itemDescription: $localize`image`,
+      itemNames: [`${imageName}`],
+      submitActionObservable: () =>
+        new Observable((observer: Subscriber<any>) => {
+          this.taskWrapper
+            .wrapTaskAroundCall({
+              task: new FinishedTask('rbd/edit', {
+                image_spec: imageSpec.toString()
+              }),
+              call: this.rbdService.update(imageSpec, this.editRequest())
+            })
+            .subscribe({
+              error: (resp) => observer.error(resp),
+              complete: () => {
+                this.modalRef.close();
+              }
+            });
+        })
+    });
+  }
+
   hasSnapshots() {
     const snapshots = this.selection.first()['snapshots'] || [];
     return snapshots.length > 0;
index da8a2cd25b505993c264ae01d499d3941e8e6115..598e3fd3843ecf27d6e1b9edc44e27c61cbc99f8 100644 (file)
                    placeholder="Snapshot name..."
                    id="snapshotName"
                    name="snapshotName"
+                   [attr.disabled]="(mirroring === 'snapshot') ? true : null"
                    formControlName="snapshotName"
                    autofocus>
             <span class="invalid-feedback"
                   *ngIf="snapshotForm.showError('snapshotName', formDir, 'required')"
-                  i18n>This field is required.</span>
+                  i18n>This field is required.</span><br><br>
+            <span *ngIf="mirroring === 'snapshot'"
+                  i18n>Snapshot mode is enabled on image <b>{{ imageName }}</b>: snapshot names are auto generated</span>
           </div>
         </div>
       </div>
index 83296866c1af7a48be7da44969422493c719873f..d5861163f198b836cbb4221bb91075248c929a0e 100644 (file)
@@ -22,6 +22,7 @@ export class RbdSnapshotFormModalComponent {
   namespace: string;
   imageName: string;
   snapName: string;
+  mirroring: string;
 
   snapshotForm: CdFormGroup;
 
@@ -53,7 +54,11 @@ export class RbdSnapshotFormModalComponent {
 
   setSnapName(snapName: string) {
     this.snapName = snapName;
-    this.snapshotForm.get('snapshotName').setValue(snapName);
+    if (this.mirroring !== 'snapshot') {
+      this.snapshotForm.get('snapshotName').setValue(snapName);
+    } else {
+      this.snapshotForm.get('snapshotName').clearValidators();
+    }
   }
 
   /**
index 01ea8cbdcdb049326b62212c05ad2194ae5c31b7..cc0d61f91aa0720d63e933059b108cbe92390628 100644 (file)
@@ -34,27 +34,31 @@ export class RbdSnapshotActionsModel {
     this.rename = {
       permission: 'update',
       icon: Icons.edit,
-      name: actionLabels.RENAME
+      name: actionLabels.RENAME,
+      disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
     };
     this.protect = {
       permission: 'update',
       icon: Icons.lock,
       visible: (selection: CdTableSelection) =>
         selection.hasSingleSelection && !selection.first().is_protected,
-      name: actionLabels.PROTECT
+      name: actionLabels.PROTECT,
+      disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
     };
     this.unprotect = {
       permission: 'update',
       icon: Icons.unlock,
       visible: (selection: CdTableSelection) =>
         selection.hasSingleSelection && selection.first().is_protected,
-      name: actionLabels.UNPROTECT
+      name: actionLabels.UNPROTECT,
+      disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
     };
     this.clone = {
       permission: 'create',
       canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
       disable: (selection: CdTableSelection) =>
-        this.getCloneDisableDesc(selection, this.featuresName),
+        this.getCloneDisableDesc(selection, this.featuresName) ||
+        this.disableForMirrorSnapshot(selection),
       icon: Icons.clone,
       name: actionLabels.CLONE
     };
@@ -62,21 +66,29 @@ export class RbdSnapshotActionsModel {
       permission: 'create',
       canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
       disable: (selection: CdTableSelection) =>
-        !selection.hasSingleSelection || selection.first().cdExecuting,
+        !selection.hasSingleSelection ||
+        selection.first().cdExecuting ||
+        this.disableForMirrorSnapshot(selection),
       icon: Icons.copy,
       name: actionLabels.COPY
     };
     this.rollback = {
       permission: 'update',
       icon: Icons.undo,
-      name: actionLabels.ROLLBACK
+      name: actionLabels.ROLLBACK,
+      disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
     };
     this.deleteSnap = {
       permission: 'delete',
       icon: Icons.destroy,
       disable: (selection: CdTableSelection) => {
         const first = selection.first();
-        return !selection.hasSingleSelection || first.cdExecuting || first.is_protected;
+        return (
+          !selection.hasSingleSelection ||
+          first.cdExecuting ||
+          first.is_protected ||
+          this.disableForMirrorSnapshot(selection)
+        );
       },
       name: actionLabels.DELETE
     };
@@ -108,4 +120,12 @@ export class RbdSnapshotActionsModel {
 
     return true;
   }
+
+  disableForMirrorSnapshot(selection: CdTableSelection) {
+    return (
+      selection.hasSingleSelection &&
+      selection.first().mirror_mode === 'snapshot' &&
+      selection.first().name.includes('.mirror.')
+    );
+  }
 }
index 6a8be5b134a25b45d391047255c2b2ac159bf62f..df66b0e8842ae01dd96e61d657e635cbbc8626c4 100644 (file)
@@ -56,6 +56,8 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
   @Input()
   namespace: string;
   @Input()
+  mirroring: string;
+  @Input()
   rbdName: string;
   @ViewChild('nameTpl')
   nameTpl: TemplateRef<any>;
@@ -210,7 +212,10 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
   }
 
   private openSnapshotModal(taskName: string, snapName: string = null) {
-    this.modalRef = this.modalService.show(RbdSnapshotFormModalComponent);
+    const modalVariables = {
+      mirroring: this.mirroring
+    };
+    this.modalRef = this.modalService.show(RbdSnapshotFormModalComponent, modalVariables);
     this.modalRef.componentInstance.poolName = this.poolName;
     this.modalRef.componentInstance.imageName = this.rbdName;
     this.modalRef.componentInstance.namespace = this.namespace;
index e19d007c4a60784c0a0fdd002f1da7383bed13fd..dfa25475bc72c076255f17576d97d4ab9eb68fa2 100644 (file)
@@ -130,6 +130,7 @@ export class ActionLabelsI18n {
   FLAGS: string;
   ENTER_MAINTENANCE: string;
   EXIT_MAINTENANCE: string;
+  REMOVE_SCHEDULING: string;
   START_DRAIN: string;
   STOP_DRAIN: string;
   START: string;
@@ -189,6 +190,7 @@ export class ActionLabelsI18n {
     this.FLAGS = $localize`Flags`;
     this.ENTER_MAINTENANCE = $localize`Enter Maintenance`;
     this.EXIT_MAINTENANCE = $localize`Exit Maintenance`;
+
     this.START_DRAIN = $localize`Start Drain`;
     this.STOP_DRAIN = $localize`Stop Drain`;
 
@@ -200,6 +202,8 @@ export class ActionLabelsI18n {
     this.STOP = $localize`Stop`;
     this.REDEPLOY = $localize`Redeploy`;
     this.RESTART = $localize`Restart`;
+
+    this.REMOVE_SCHEDULING = $localize`Remove Scheduling`;
   }
 }
 
index 3c6ddbf80c998b714a26c6ce8974499c5104f527..1d84895c0dd75027e1ca324f0e380283bc7c61c7 100644 (file)
   padding-top: 7px;
 }
 
+.custom-radio {
+  padding-top: 5px;
+}
+
 .cd-col-form {
   @extend .col-12;
   @extend .col-lg-8;
index 5918c81c17bc007ba66bf6816627d2d87fc254b9..4c7ce89121411d2b0539c385cd5b8d843080d060 100644 (file)
@@ -206,6 +206,8 @@ paths:
                   type: string
                 features:
                   type: string
+                mirror_mode:
+                  type: string
                 name:
                   type: string
                 namespace:
@@ -214,6 +216,9 @@ paths:
                   type: integer
                 pool_name:
                   type: string
+                schedule_interval:
+                  default: ''
+                  type: string
                 size:
                   type: integer
                 stripe_count:
@@ -549,6 +554,9 @@ paths:
                   type: string
                 primary:
                   type: string
+                remove_scheduling:
+                  default: false
+                  type: boolean
                 resync:
                   default: false
                   type: boolean
@@ -557,9 +565,6 @@ paths:
                   type: string
                 size:
                   type: integer
-                start_time:
-                  default: ''
-                  type: string
               type: object
       responses:
         '200':
index 31160e893d5cdee8c2aa548c3289a6f52430c3e3..f9203b2418eb6fb19272e85f4db378e6c799fa72 100644 (file)
@@ -1,11 +1,14 @@
 # -*- coding: utf-8 -*-
 # pylint: disable=unused-argument
 import errno
+import json
+from enum import IntEnum
 
 import cherrypy
 import rbd
 
 from .. import mgr
+from ..exceptions import DashboardException
 from ..tools import ViewCache
 from .ceph_service import CephService
 
@@ -28,6 +31,20 @@ RBD_FEATURES_NAME_MAPPING = {
 }
 
 
+class MIRROR_IMAGE_MODE(IntEnum):
+    journal = rbd.RBD_MIRROR_IMAGE_MODE_JOURNAL
+    snapshot = rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT
+
+
+def _rbd_support_remote(method_name: str, *args, **kwargs):
+    try:
+        return mgr.remote('rbd_support', method_name, *args, **kwargs)
+    except ImportError as ie:
+        raise DashboardException(f'rbd_support module not found {ie}')
+    except RuntimeError as ie:
+        raise DashboardException(f'rbd_support.{method_name} error: {ie}')
+
+
 def format_bitmask(features):
     """
     Formats the bitmask:
@@ -245,10 +262,22 @@ class RbdService(object):
         return total_used_size, snap_map
 
     @classmethod
-    def _rbd_image(cls, ioctx, pool_name, namespace, image_name):
+    def _rbd_image(cls, ioctx, pool_name, namespace, image_name):  # pylint: disable=R0912
         with rbd.Image(ioctx, image_name) as img:
-
             stat = img.stat()
+            mirror_mode = img.mirror_image_get_mode()
+            if mirror_mode == rbd.RBD_MIRROR_IMAGE_MODE_JOURNAL:
+                stat['mirror_mode'] = 'journal'
+            elif mirror_mode == rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT:
+                stat['mirror_mode'] = 'snapshot'
+                schedule_status = json.loads(_rbd_support_remote(
+                    'mirror_snapshot_schedule_status')[1])
+                for scheduled_image in schedule_status['scheduled_images']:
+                    if scheduled_image['image'] == get_image_spec(pool_name, namespace, image_name):
+                        stat['schedule_info'] = scheduled_image
+            else:
+                stat['mirror_mode'] = 'unknown'
+
             stat['name'] = image_name
             if img.old_format():
                 stat['unique_id'] = get_image_spec(pool_name, namespace, stat['block_name_prefix'])
@@ -290,23 +319,34 @@ class RbdService(object):
             # snapshots
             stat['snapshots'] = []
             for snap in img.list_snaps():
+                try:
+                    snap['mirror_mode'] = MIRROR_IMAGE_MODE(img.mirror_image_get_mode()).name
+                except ValueError as ex:
+                    raise DashboardException(f'Unknown RBD Mirror mode: {ex}')
+
                 snap['timestamp'] = "{}Z".format(
                     img.get_snap_timestamp(snap['id']).isoformat())
-                snap['is_protected'] = img.is_protected_snap(snap['name'])
+
+                snap['is_protected'] = None
+                if mirror_mode != rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT:
+                    snap['is_protected'] = img.is_protected_snap(snap['name'])
                 snap['used_bytes'] = None
                 snap['children'] = []
-                img.set_snap(snap['name'])
-                for child_pool_name, child_image_name in img.list_children():
-                    snap['children'].append({
-                        'pool_name': child_pool_name,
-                        'image_name': child_image_name
-                    })
+
+                if mirror_mode != rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT:
+                    img.set_snap(snap['name'])
+                    for child_pool_name, child_image_name in img.list_children():
+                        snap['children'].append({
+                            'pool_name': child_pool_name,
+                            'image_name': child_image_name
+                        })
                 stat['snapshots'].append(snap)
 
             # disk usage
             img_flags = img.flags()
             if 'fast-diff' in stat['features_name'] and \
-                    not rbd.RBD_FLAG_FAST_DIFF_INVALID & img_flags:
+                    not rbd.RBD_FLAG_FAST_DIFF_INVALID & img_flags and \
+                    mirror_mode != rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT:
                 snaps = [(s['id'], s['size'], s['name'])
                          for s in stat['snapshots']]
                 snaps.sort(key=lambda s: s[0])
@@ -425,7 +465,7 @@ class RBDSchedulerInterval:
 class RbdMirroringService:
 
     @classmethod
-    def enable_image(cls, image_name: str, pool_name: str, namespace: str, mode: str):
+    def enable_image(cls, image_name: str, pool_name: str, namespace: str, mode: MIRROR_IMAGE_MODE):
         rbd_image_call(pool_name, namespace, image_name,
                        lambda ioctx, image: image.mirror_image_enable(mode))
 
@@ -450,6 +490,10 @@ class RbdMirroringService:
                        lambda ioctx, image: image.mirror_image_resync())
 
     @classmethod
-    def snapshot_schedule(cls, image_spec: str, interval: str, start_time: str = ''):
-        mgr.remote('rbd_support', 'mirror_snapshot_schedule_add', image_spec,
-                   str(RBDSchedulerInterval(interval)), start_time)
+    def snapshot_schedule_add(cls, image_spec: str, interval: str):
+        _rbd_support_remote('mirror_snapshot_schedule_add', image_spec,
+                            str(RBDSchedulerInterval(interval)))
+
+    @classmethod
+    def snapshot_schedule_remove(cls, image_spec: str):
+        _rbd_support_remote('mirror_snapshot_schedule_remove', image_spec)