Merge pull request #46527 from rhcs-dashboard/mirroring-workflow

mgr/dashboard: configure rbd mirroring

Reviewed-by: Avan Thakkar <athakkar@redhat.com>
Reviewed-by: Ernesto Puerta <epuertat@redhat.com>
Reviewed-by: Ilya Dryomov <idryomov@redhat.com>
Reviewed-by: Pere Diaz Bou <pdiazbou@redhat.com>
This commit is contained in:
Ernesto Puerta 2022-06-21 12:05:07 +02:00 committed by GitHub
commit e580e3c681
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 193 additions and 97 deletions

View File

@ -345,3 +345,9 @@ class PoolUi(Pool):
"used_profiles": used_profiles,
'nodes': mgr.get('osd_map_tree')['nodes']
}
class RBDPool(Pool):
def create(self, pool='rbd-mirror'): # pylint: disable=arguments-differ
super().create(pool, pg_num=1, pool_type='replicated',
rule_name='replicated_rule', application_metadata=['rbd'])

View File

@ -10,13 +10,17 @@ import cherrypy
import rbd
from .. import mgr
from ..controllers.pool import RBDPool
from ..controllers.service import Service
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.orchestrator import OrchClient
from ..services.rbd import rbd_call
from ..tools import ViewCache
from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
ReadPermission, RESTController, Task, UpdatePermission, allow_empty_body
from . import APIDoc, APIRouter, BaseController, CreatePermission, Endpoint, \
EndpointDoc, ReadPermission, RESTController, Task, UIRouter, \
UpdatePermission, allow_empty_body
logger = logging.getLogger('controllers.rbd_mirror')
@ -602,3 +606,41 @@ class RbdMirroringPoolPeer(RESTController):
rbd.RBD().mirror_peer_set_attributes(ioctx, peer_uuid, attributes)
_reset_view_cache()
@UIRouter('/block/mirroring', Scope.RBD_MIRRORING)
class RbdMirroringStatus(BaseController):
@EndpointDoc('Display RBD Mirroring Status')
@Endpoint()
@ReadPermission
def status(self):
status = {'available': True, 'message': None}
orch_status = OrchClient.instance().status()
# if the orch is not available we can't create the service
# using dashboard.
if not orch_status['available']:
return status
if not CephService.get_service_list('rbd-mirror') or not CephService.get_pool_list('rbd'):
status['available'] = False
status['message'] = 'RBD mirroring is not configured' # type: ignore
return status
@Endpoint('POST')
@EndpointDoc('Configure RBD Mirroring')
@CreatePermission
def configure(self):
rbd_pool = RBDPool()
service = Service()
service_spec = {
'service_type': 'rbd-mirror',
'placement': {},
'unmanaged': False
}
if not CephService.get_service_list('rbd-mirror'):
service.create(service_spec, 'rbd-mirror')
if not CephService.get_pool_list('rbd'):
rbd_pool.create()

View File

@ -149,8 +149,19 @@ const routes: Routes = [
{
path: 'mirroring',
component: RbdMirroringComponent,
canActivate: [FeatureTogglesGuardService],
data: { breadcrumbs: 'Mirroring' },
canActivate: [FeatureTogglesGuardService, ModuleStatusGuardService],
data: {
moduleStatusGuardConfig: {
uiApiPath: 'block/mirroring',
redirectTo: 'error',
header: $localize`RBD mirroring is not configured`,
button_name: $localize`Configure RBD Mirroring`,
button_title: $localize`This will create rbd-mirror service and a replicated RBD pool`,
component: 'RBD Mirroring',
uiConfig: true
},
breadcrumbs: 'Mirroring'
},
children: [
{
path: `${URLVerbs.EDIT}/:pool_name`,

View File

@ -2,42 +2,62 @@
<title>Error Page</title>
<base target="_blank">
</head>
<div class="dashboard row">
<div class="text-center content">
<br>
<div *ngIf="header && message; else elseBlock">
<i class="{{ icon }}"
aria-hidden="true"></i>
<br><br><br>
<h3><b>{{ header }}</b></h3>
<br>
<h4>{{ message }}</h4>
<div class="container h-75">
<div class="row h-100 justify-content-center align-items-center">
<div class="blank-page">
<div *ngIf="header && message; else elseBlock">
<i [ngClass]="icon"
class="mx-auto d-block"></i>
<div class="mt-4 text-center">
<h3><b>{{ header }}</b></h3>
<h4 class="mt-3"
*ngIf="header !== message">{{ message }}</h4>
<h4 *ngIf="section"
i18n>Please consult the <a href="{{ docUrl }}">documentation</a> on how to configure and enable
the {{ sectionInfo }} management functionality.
</h4>
</div>
</div>
<div class="mt-4">
<div class="text-center"
*ngIf="(buttonName && buttonRoute) || uiConfig; else dashboardButton">
<button class="btn btn-primary"
[routerLink]="buttonRoute"
*ngIf="!uiConfig; else configureButtonTpl"
i18n>{{ buttonName }}</button>
</div>
</div>
</div>
<ng-template #elseBlock>
<i class="fa fa-exclamation-triangle"
aria-hidden="true"></i>
<br><br><br>
<h3 i18n><b>Page not Found</b></h3>
<br>
<h4 i18n>Sorry, we couldnt find what you were looking for.
The page you requested may have been changed or moved.</h4>
</ng-template>
<div *ngIf="section">
<h4 i18n>Please consult the <a href="{{ docUrl }}">documentation</a> on how to configure and enable
the {{ section_info }} management functionality.</h4>
</div>
<br><br>
<div *ngIf="button_name && button_route; else dashboardButton">
<button class="btn btn-primary"
[routerLink]="button_route"
i18n>{{ button_name }}</button>
</div>
<ng-template #dashboardButton>
<div>
<button class="btn btn-primary"
[routerLink]="'/dashboard'"
i18n>Go To Dashboard</button>
</div>
</ng-template>
</div>
</div>
<ng-template #configureButtonTpl>
<button class="btn btn-primary"
(click)="doConfigure()"
[attr.title]="buttonTitle"
*ngIf="uiConfig"
i18n>{{ buttonName }}</button>
</ng-template>
<ng-template #elseBlock>
<i class="fa fa-exclamation-triangle mx-auto d-block text-danger"></i>
<div class="mt-4 text-center">
<h3 i18n><b>Page not Found</b></h3>
<h4 class="mt-4"
i18n>Sorry, we couldnt find what you were looking for.
The page you requested may have been changed or moved.</h4>
</div>
</ng-template>
<ng-template #dashboardButton>
<div class="mt-4 text-center">
<button class="btn btn-primary"
[routerLink]="'/dashboard'"
i18n>Go To Dashboard</button>
</div>
</ng-template>

View File

@ -9,35 +9,6 @@ i {
margin-top: 120px;
}
.text-center {
background-color: vv.$body-bg-alt;
}
.dashboard {
background-color: vv.$body-bg-alt;
height: 100%;
position: relative;
}
.content {
left: 50%;
position: absolute;
top: 40%;
transform: translate(-50%, -50%);
width: 100%;
}
.row {
display: block;
margin-left: -29px;
margin-right: -29px;
padding-top: 10em;
}
.fa-exclamation-triangle {
color: vv.$danger;
}
.fa-lock {
color: vv.$danger;
}

View File

@ -2,6 +2,8 @@ import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ToastrModule } from 'ngx-toastr';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
import { ErrorComponent } from './error.component';
@ -12,7 +14,7 @@ describe('ErrorComponent', () => {
configureTestBed({
declarations: [ErrorComponent],
imports: [HttpClientTestingModule, RouterTestingModule, SharedModule]
imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ToastrModule.forRoot()]
});
beforeEach(() => {

View File

@ -1,10 +1,13 @@
import { HttpClient } from '@angular/common/http';
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { NavigationEnd, Router, RouterEvent } from '@angular/router';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { DocService } from '~/app/shared/services/doc.service';
import { NotificationService } from '~/app/shared/services/notification.service';
@Component({
selector: 'cd-error',
@ -15,15 +18,24 @@ export class ErrorComponent implements OnDestroy, OnInit {
header: string;
message: string;
section: string;
section_info: string;
button_name: string;
button_route: string;
sectionInfo: string;
icon: string;
docUrl: string;
source: string;
routerSubscription: Subscription;
uiConfig: string;
uiApiPath: string;
buttonRoute: string;
buttonName: string;
buttonTitle: string;
component: string;
constructor(private router: Router, private docService: DocService) {}
constructor(
private router: Router,
private docService: DocService,
private http: HttpClient,
private notificationService: NotificationService
) {}
ngOnInit() {
this.fetchData();
@ -34,6 +46,23 @@ export class ErrorComponent implements OnDestroy, OnInit {
});
}
doConfigure() {
this.http.post(`ui-api/${this.uiApiPath}/configure`, {}).subscribe({
next: () => {
this.notificationService.show(NotificationType.info, `Configuring ${this.component}`);
},
error: (error: any) => {
this.notificationService.show(NotificationType.error, error);
},
complete: () => {
setTimeout(() => {
this.router.navigate([this.uiApiPath]);
this.notificationService.show(NotificationType.success, `Configured ${this.component}`);
}, 3000);
}
});
}
@HostListener('window:beforeunload', ['$event']) unloadHandler(event: Event) {
event.returnValue = false;
}
@ -44,11 +73,15 @@ export class ErrorComponent implements OnDestroy, OnInit {
this.message = history.state.message;
this.header = history.state.header;
this.section = history.state.section;
this.section_info = history.state.section_info;
this.button_name = history.state.button_name;
this.button_route = history.state.button_route;
this.sectionInfo = history.state.section_info;
this.icon = history.state.icon;
this.source = history.state.source;
this.uiConfig = history.state.uiConfig;
this.uiApiPath = history.state.uiApiPath;
this.buttonRoute = history.state.button_route;
this.buttonName = history.state.button_name;
this.buttonTitle = history.state.button_title;
this.component = history.state.component;
this.docUrl = this.docService.urlGenerator(this.section);
} catch (error) {
this.router.navigate(['/error']);

View File

@ -82,7 +82,11 @@ export class ModuleStatusGuardService implements CanActivate, CanActivateChild {
section_info: config.section_info,
button_name: config.button_name,
button_route: config.button_route,
icon: Icons.wrench
button_title: config.button_title,
uiConfig: config.uiConfig,
uiApiPath: config.uiApiPath,
icon: Icons.wrench,
component: config.component
}
});
}

View File

@ -7228,26 +7228,9 @@ paths:
application/json:
schema:
properties:
application_metadata:
type: string
configuration:
type: string
erasure_code_profile:
type: string
flags:
type: string
pg_num:
type: integer
pool:
default: rbd-mirror
type: string
pool_type:
type: string
rule_name:
type: string
required:
- pool
- pg_num
- pool_type
type: object
responses:
'201':

View File

@ -10,8 +10,10 @@ except ImportError:
import unittest.mock as mock
from .. import mgr
from ..controllers.orchestrator import Orchestrator
from ..controllers.rbd_mirroring import RbdMirroring, \
RbdMirroringPoolBootstrap, RbdMirroringSummary, get_daemons, get_pools
RbdMirroringPoolBootstrap, RbdMirroringStatus, RbdMirroringSummary, \
get_daemons, get_pools
from ..controllers.summary import Summary
from ..services import progress
from ..tests import ControllerTestCase
@ -279,3 +281,25 @@ class RbdMirroringSummaryControllerTest(ControllerTestCase):
summary = self.json_body()['rbd_mirroring']
self.assertEqual(summary, {'errors': 0, 'warnings': 1})
class RbdMirroringStatusControllerTest(ControllerTestCase):
@classmethod
def setup_server(cls):
cls.setup_controllers([RbdMirroringStatus, Orchestrator])
@mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
def test_status(self, instance):
status = {'available': False, 'description': ''}
fake_client = mock.Mock()
fake_client.status.return_value = status
instance.return_value = fake_client
self._get('/ui-api/block/mirroring/status')
self.assertStatus(200)
self.assertJsonBody({'available': True, 'message': None})
def test_configure(self):
self._post('/ui-api/block/mirroring/configure')
self.assertStatus(200)