From: Kiefer Chang Date: Fri, 19 Jul 2019 10:09:49 +0000 (+0800) Subject: mgr/dashboard: orchestrator integration initial works X-Git-Tag: v15.1.0~1634^2~3 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=7e20b5c734014b2bfa07e2508aef230a83975fc5;p=ceph.git mgr/dashboard: orchestrator integration initial works - Display hosts, inventory, and services from orchestrator - Allow adding/removing hosts Fixes: https://tracker.ceph.com/issues/40337 Fixes: https://tracker.ceph.com/issues/40336 Fixes: https://tracker.ceph.com/issues/38233 Signed-off-by: Kiefer Chang --- diff --git a/src/pybind/mgr/dashboard/controllers/health.py b/src/pybind/mgr/dashboard/controllers/health.py index ecb771cd01f3..2795d89ce50f 100644 --- a/src/pybind/mgr/dashboard/controllers/health.py +++ b/src/pybind/mgr/dashboard/controllers/health.py @@ -12,6 +12,7 @@ from ..services.ceph_service import CephService from ..services.iscsi_cli import IscsiGatewaysConfig from ..services.iscsi_client import IscsiClient from ..tools import partial_dict +from .host import get_hosts class HealthData(object): @@ -117,7 +118,7 @@ class HealthData(object): return fs_map def host_count(self): - return len(mgr.list_servers()) + return len(get_hosts()) def iscsi_daemons(self): up_counter = 0 diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index e8518a14c921..26bff7bc8d4b 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -1,12 +1,104 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +import copy -from . import ApiController, RESTController +from mgr_util import merge_dicts +from . import ApiController, RESTController, Task from .. import mgr +from ..exceptions import DashboardException from ..security import Scope +from ..services.orchestrator import OrchClient +from ..services.exception import handle_orchestrator_error + + +def host_task(name, metadata, wait_for=10.0): + return Task("host/{}".format(name), metadata, wait_for) + + +def merge_hosts_by_hostname(ceph_hosts, orch_hosts): + """Merge Ceph hosts with orchestrator hosts by hostnames. + + :param mgr_hosts: hosts returned from mgr + :type mgr_hosts: list of dict + :param orch_hosts: hosts returned from ochestrator + :type orch_hosts: list of InventoryNode + :return list of dict + """ + _ceph_hosts = copy.deepcopy(ceph_hosts) + orch_hostnames = {host.name for host in orch_hosts} + + # hosts in both Ceph and Orchestrator + for ceph_host in _ceph_hosts: + if ceph_host['hostname'] in orch_hostnames: + ceph_host['sources']['orchestrator'] = True + orch_hostnames.remove(ceph_host['hostname']) + + # Hosts only in Orchestrator + orch_sources = {'ceph': False, 'orchestrator': True} + orch_hosts = [dict(hostname=hostname, ceph_version='', services=[], sources=orch_sources) + for hostname in orch_hostnames] + _ceph_hosts.extend(orch_hosts) + return _ceph_hosts + + +def get_hosts(from_ceph=True, from_orchestrator=True): + """get hosts from various sources""" + ceph_hosts = [] + if from_ceph: + ceph_hosts = [merge_dicts(server, {'sources': {'ceph': True, 'orchestrator': False}}) + for server in mgr.list_servers()] + if from_orchestrator: + orch = OrchClient.instance() + if orch.available(): + return merge_hosts_by_hostname(ceph_hosts, orch.hosts.list()) + return ceph_hosts @ApiController('/host', Scope.HOSTS) class Host(RESTController): - def list(self): - return mgr.list_servers() + + def list(self, sources=None): + if sources is None: + return get_hosts() + _sources = sources.split(',') + from_ceph = 'ceph' in _sources + from_orchestrator = 'orchestrator' in _sources + return get_hosts(from_ceph, from_orchestrator) + + @host_task('add', {'hostname': '{hostname}'}) + @handle_orchestrator_error('host') + def create(self, hostname): + orch_client = OrchClient.instance() + self._check_orchestrator_host_op(orch_client, hostname, True) + orch_client.hosts.add(hostname) + + @host_task('remove', {'hostname': '{hostname}'}) + @handle_orchestrator_error('host') + def delete(self, hostname): + orch_client = OrchClient.instance() + self._check_orchestrator_host_op(orch_client, hostname, False) + orch_client.hosts.remove(hostname) + + def _check_orchestrator_host_op(self, orch_client, hostname, add_host=True): + """Check if we can adding or removing a host with orchestrator + + :param orch_client: Orchestrator client + :param add: True for adding host operation, False for removing host + :raise DashboardException + """ + if not orch_client.available(): + raise DashboardException(code='orchestrator_status_unavailable', + msg='Orchestrator is unavailable', + component='orchestrator') + host = orch_client.hosts.get(hostname) + if add_host and host: + raise DashboardException( + code='orchestrator_add_existed_host', + msg='{} is already in orchestrator'.format(hostname), + component='orchestrator') + if not add_host and not host: + raise DashboardException( + code='orchestrator_remove_nonexistent_host', + msg='Remove a non-existent host {} from orchestrator'.format( + hostname), + component='orchestrator') diff --git a/src/pybind/mgr/dashboard/controllers/orchestrator.py b/src/pybind/mgr/dashboard/controllers/orchestrator.py new file mode 100644 index 000000000000..1f0839cc4d8b --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/orchestrator.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from . import ApiController, Endpoint, ReadPermission +from . import RESTController, Task +from ..security import Scope +from ..services.orchestrator import OrchClient + + +def orchestrator_task(name, metadata, wait_for=2.0): + return Task("orchestrator/{}".format(name), metadata, wait_for) + + +@ApiController('/orchestrator') +class Orchestrator(RESTController): + + @Endpoint() + @ReadPermission + def status(self): + return OrchClient.instance().status() + + +@ApiController('/orchestrator/inventory', Scope.HOSTS) +class OrchestratorInventory(RESTController): + + def list(self, hostname=None): + orch = OrchClient.instance() + result = [] + + if orch.available(): + hosts = [hostname] if hostname else None + inventory_nodes = orch.inventory.list(hosts) + result = [node.to_json() for node in inventory_nodes] + return result + + +@ApiController('/orchestrator/service', Scope.HOSTS) +class OrchestratorService(RESTController): + def list(self, service_type=None, service_id=None, hostname=None): + orch = OrchClient.instance() + services = [] + + if orch.available(): + services = [service.to_json() for service in orch.services.list( + service_type, service_id, hostname)] + return services diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index f8b0ec94a455..875ca13f5fbc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -7,7 +7,9 @@ import { CephfsListComponent } from './ceph/cephfs/cephfs-list/cephfs-list.compo import { ConfigurationFormComponent } from './ceph/cluster/configuration/configuration-form/configuration-form.component'; import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component'; import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component'; +import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component'; import { HostsComponent } from './ceph/cluster/hosts/hosts.component'; +import { InventoryComponent } from './ceph/cluster/inventory/inventory.component'; import { LogsComponent } from './ceph/cluster/logs/logs.component'; import { MgrModuleFormComponent } from './ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component'; import { MgrModuleListComponent } from './ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component'; @@ -16,6 +18,7 @@ import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component import { AlertListComponent } from './ceph/cluster/prometheus/alert-list/alert-list.component'; import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component'; import { SilenceListComponent } from './ceph/cluster/prometheus/silence-list/silence-list.component'; +import { ServicesComponent } from './ceph/cluster/services/services.component'; import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component'; import { Nfs501Component } from './ceph/nfs/nfs-501/nfs-501.component'; import { NfsFormComponent } from './ceph/nfs/nfs-form/nfs-form.component'; @@ -70,9 +73,16 @@ const routes: Routes = [ // Cluster { path: 'hosts', - component: HostsComponent, canActivate: [AuthGuardService], - data: { breadcrumbs: 'Cluster/Hosts' } + data: { breadcrumbs: 'Cluster/Hosts' }, + children: [ + { path: '', component: HostsComponent }, + { + path: URLVerbs.ADD, + component: HostFormComponent, + data: { breadcrumbs: ActionLabels.ADD } + } + ] }, { path: 'monitor', @@ -80,6 +90,18 @@ const routes: Routes = [ canActivate: [AuthGuardService], data: { breadcrumbs: 'Cluster/Monitors' } }, + { + path: 'services', + component: ServicesComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/Services' } + }, + { + path: 'inventory', + component: InventoryComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/Inventory' } + }, { path: 'osd', canActivate: [AuthGuardService], diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index f38476e1d1c6..ee7719eaad65 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -21,7 +21,9 @@ import { ConfigurationFormComponent } from './configuration/configuration-form/c import { ConfigurationComponent } from './configuration/configuration.component'; import { CrushmapComponent } from './crushmap/crushmap.component'; import { HostDetailsComponent } from './hosts/host-details/host-details.component'; +import { HostFormComponent } from './hosts/host-form/host-form.component'; import { HostsComponent } from './hosts/hosts.component'; +import { InventoryComponent } from './inventory/inventory.component'; import { LogsComponent } from './logs/logs.component'; import { MgrModulesModule } from './mgr-modules/mgr-modules.module'; import { MonitorComponent } from './monitor/monitor.component'; @@ -38,6 +40,7 @@ import { PrometheusTabsComponent } from './prometheus/prometheus-tabs/prometheus import { SilenceFormComponent } from './prometheus/silence-form/silence-form.component'; import { SilenceListComponent } from './prometheus/silence-list/silence-list.component'; import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component'; +import { ServicesComponent } from './services/services.component'; @NgModule({ entryComponents: [ @@ -92,7 +95,10 @@ import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal SilenceFormComponent, SilenceListComponent, PrometheusTabsComponent, - SilenceMatcherModalComponent + SilenceMatcherModalComponent, + ServicesComponent, + InventoryComponent, + HostFormComponent ] }) export class ClusterModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html index 005a87497a65..9cd20068f8bd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html @@ -1,6 +1,21 @@ - + + heading="Inventory" + *ngIf="hostsPermission.read"> + + + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts index 0a37e609704f..99b396c55d38 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts @@ -7,6 +7,8 @@ import { TabsModule } from 'ngx-bootstrap/tabs'; import { configureTestBed } from '../../../../../testing/unit-test-helper'; import { CdTableSelection } from '../../../../shared/models/cd-table-selection'; import { SharedModule } from '../../../../shared/shared.module'; +import { InventoryComponent } from '../../inventory/inventory.component'; +import { ServicesComponent } from '../../services/services.component'; import { HostDetailsComponent } from './host-details.component'; describe('HostDetailsComponent', () => { @@ -20,7 +22,7 @@ describe('HostDetailsComponent', () => { BsDropdownModule.forRoot(), SharedModule ], - declarations: [HostDetailsComponent] + declarations: [HostDetailsComponent, InventoryComponent, ServicesComponent] }); beforeEach(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts index dfd3c16f31d5..1c4bf2c0b6d3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts @@ -11,12 +11,15 @@ import { AuthStorageService } from '../../../../shared/services/auth-storage.ser }) export class HostDetailsComponent implements OnChanges { grafanaPermission: Permission; + hostsPermission: Permission; + @Input() selection: CdTableSelection; host: any; constructor(private authStorageService: AuthStorageService) { this.grafanaPermission = this.authStorageService.getPermissions().grafana; + this.hostsPermission = this.authStorageService.getPermissions().hosts; } ngOnChanges() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html new file mode 100644 index 000000000000..037178526372 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html @@ -0,0 +1,51 @@ +Loading... + +
+
+
+
{{ action | titlecase }} {{ resource | upperFirst }}
+ +
+ + +
+ +
+ + This field is required. + The chosen hostname is already in use. +
+
+
+ + +
+
+
\ No newline at end of file diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts new file mode 100644 index 000000000000..4ed2e3879f84 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { ToastrModule } from 'ngx-toastr'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../../shared/shared.module'; +import { HostFormComponent } from './host-form.component'; + +describe('HostFormComponent', () => { + let component: HostFormComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [ + SharedModule, + HttpClientTestingModule, + RouterTestingModule, + ReactiveFormsModule, + ToastrModule.forRoot() + ], + providers: [i18nProviders], + declarations: [HostFormComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HostFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts new file mode 100644 index 000000000000..59473514e4e7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts @@ -0,0 +1,77 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { I18n } from '@ngx-translate/i18n-polyfill'; +import { HostService } from '../../../../shared/api/host.service'; +import { ActionLabelsI18n, URLVerbs } from '../../../../shared/constants/app.constants'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { CdValidators } from '../../../../shared/forms/cd-validators'; +import { FinishedTask } from '../../../../shared/models/finished-task'; +import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service'; + +@Component({ + selector: 'cd-host-form', + templateUrl: './host-form.component.html', + styleUrls: ['./host-form.component.scss'] +}) +export class HostFormComponent implements OnInit { + hostForm: CdFormGroup; + action: string; + resource: string; + loading = true; + hostnames: string[]; + + constructor( + private router: Router, + private i18n: I18n, + private actionLabels: ActionLabelsI18n, + private hostService: HostService, + private taskWrapper: TaskWrapperService + ) { + this.resource = this.i18n('host'); + this.action = this.actionLabels.ADD; + this.createForm(); + } + + ngOnInit() { + this.hostService.list().subscribe((resp: any[]) => { + this.hostnames = resp.map((host) => { + return host['hostname']; + }); + this.loading = false; + }); + } + + private createForm() { + this.hostForm = new CdFormGroup({ + hostname: new FormControl('', { + validators: [ + Validators.required, + CdValidators.custom('uniqueName', (hostname: string) => { + return this.hostnames && this.hostnames.indexOf(hostname) !== -1; + }) + ] + }) + }); + } + + submit() { + const hostname = this.hostForm.get('hostname').value; + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('host/' + URLVerbs.ADD, { + hostname: hostname + }), + call: this.hostService.add(hostname) + }) + .subscribe( + undefined, + () => { + this.hostForm.setErrors({ cdSubmitButton: true }); + }, + () => { + this.router.navigate(['/hosts']); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html index 799af3e471e3..1cd4ff468252 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html @@ -7,6 +7,14 @@ (fetchData)="getHosts($event)" selectionType="single" (updateSelection)="updateSelection($event)"> +
+ + +
{ HttpClientTestingModule, TabsModule.forRoot(), BsDropdownModule.forRoot(), - RouterTestingModule + RouterTestingModule, + ToastrModule.forRoot() ], providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }, i18nProviders], - declarations: [HostsComponent, HostDetailsComponent] + declarations: [HostsComponent, HostDetailsComponent, InventoryComponent, ServicesComponent] }); beforeEach(() => { @@ -70,7 +75,7 @@ describe('HostsComponent', () => { } ]; - hostListSpy.and.returnValue(Promise.resolve(payload)); + hostListSpy.and.callFake(() => of(payload)); fixture.whenStable().then(() => { fixture.detectChanges(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index fb663a5ca53a..1767c7be76ef 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -1,27 +1,42 @@ import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { I18n } from '@ngx-translate/i18n-polyfill'; +import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import { HostService } from '../../../shared/api/host.service'; +import { OrchestratorService } from '../../../shared/api/orchestrator.service'; +import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { ActionLabelsI18n } from '../../../shared/constants/app.constants'; +import { Icons } from '../../../shared/enum/icons.enum'; +import { CdTableAction } from '../../../shared/models/cd-table-action'; import { CdTableColumn } from '../../../shared/models/cd-table-column'; import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context'; import { CdTableSelection } from '../../../shared/models/cd-table-selection'; +import { FinishedTask } from '../../../shared/models/finished-task'; import { Permissions } from '../../../shared/models/permissions'; import { CephShortVersionPipe } from '../../../shared/pipes/ceph-short-version.pipe'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { URLBuilderService } from '../../../shared/services/url-builder.service'; + +const BASE_URL = 'hosts'; @Component({ selector: 'cd-hosts', templateUrl: './hosts.component.html', - styleUrls: ['./hosts.component.scss'] + styleUrls: ['./hosts.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] }) export class HostsComponent implements OnInit { permissions: Permissions; columns: Array = []; hosts: Array = []; isLoadingHosts = false; + orchestratorAvailable = false; cdParams = { fromLink: '/hosts' }; + tableActions: CdTableAction[]; selection = new CdTableSelection(); + modalRef: BsModalRef; @ViewChild('servicesTpl', { static: true }) public servicesTpl: TemplateRef; @@ -30,9 +45,32 @@ export class HostsComponent implements OnInit { private authStorageService: AuthStorageService, private hostService: HostService, private cephShortVersionPipe: CephShortVersionPipe, - private i18n: I18n + private i18n: I18n, + private urlBuilder: URLBuilderService, + private actionLabels: ActionLabelsI18n, + private modalService: BsModalService, + private taskWrapper: TaskWrapperService, + private orchService: OrchestratorService ) { this.permissions = this.authStorageService.getPermissions(); + this.tableActions = [ + { + name: this.actionLabels.ADD, + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getAdd(), + disable: () => !this.orchestratorAvailable, + disableDesc: () => this.getDisableDesc() + }, + { + name: this.actionLabels.REMOVE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteHostModal(), + disable: () => !this.orchestratorAvailable || !this.selection.hasSelection, + disableDesc: () => this.getDisableDesc() + } + ]; } ngOnInit() { @@ -55,12 +93,31 @@ export class HostsComponent implements OnInit { pipe: this.cephShortVersionPipe } ]; + + this.orchService.status().subscribe((data: { available: boolean }) => { + this.orchestratorAvailable = data.available; + }); } updateSelection(selection: CdTableSelection) { this.selection = selection; } + deleteHostModal() { + const hostname = this.selection.first().hostname; + this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { + initialState: { + itemDescription: 'Host', + actionDescription: 'remove', + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('host/remove', { hostname: hostname }), + call: this.hostService.remove(hostname) + }) + } + }); + } + getHosts(context: CdTableFetchDataContext) { if (this.isLoadingHosts) { return; @@ -75,9 +132,8 @@ export class HostsComponent implements OnInit { 'tcmu-runner': 'iscsi' }; this.isLoadingHosts = true; - this.hostService - .list() - .then((resp) => { + this.hostService.list().subscribe( + (resp: any[]) => { resp.map((host) => { host.services.map((service) => { service.cdLink = `/perf_counters/${service.type}/${encodeURIComponent(service.id)}`; @@ -89,10 +145,17 @@ export class HostsComponent implements OnInit { }); this.hosts = resp; this.isLoadingHosts = false; - }) - .catch(() => { + }, + () => { this.isLoadingHosts = false; context.error(); - }); + } + ); + } + + getDisableDesc() { + if (!this.orchestratorAvailable) { + return this.i18n('Host operation is disabled because orchestrator is unavailable'); + } } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html new file mode 100644 index 000000000000..3d001956f4cc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html @@ -0,0 +1,21 @@ +Please consult the + documentation on how to + configure and enable the orchestrator functionality. + + + Devices +
+
+ + +
+
+
\ No newline at end of file diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts new file mode 100644 index 000000000000..9336ff8c3794 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts @@ -0,0 +1,28 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../shared/shared.module'; +import { InventoryComponent } from './inventory.component'; + +describe('InventoryComponent', () => { + let component: InventoryComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [SharedModule, HttpClientTestingModule, RouterTestingModule], + providers: [i18nProviders], + declarations: [InventoryComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(InventoryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts new file mode 100644 index 000000000000..b598b08505a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts @@ -0,0 +1,136 @@ +import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; +import { I18n } from '@ngx-translate/i18n-polyfill'; + +import { OrchestratorService } from '../../../shared/api/orchestrator.service'; +import { TableComponent } from '../../../shared/datatable/table/table.component'; +import { CdTableColumn } from '../../../shared/models/cd-table-column'; +import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context'; +import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe'; +import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe'; +import { SummaryService } from '../../../shared/services/summary.service'; +import { Device, InventoryNode } from './inventory.model'; + +@Component({ + selector: 'cd-inventory', + templateUrl: './inventory.component.html', + styleUrls: ['./inventory.component.scss'] +}) +export class InventoryComponent implements OnChanges, OnInit { + @ViewChild(TableComponent) + table: TableComponent; + + @Input() hostname = ''; + + checkingOrchestrator = true; + orchestratorExist = false; + docsUrl: string; + + columns: Array = []; + devices: Array = []; + isLoadingDevices = false; + + constructor( + private cephReleaseNamePipe: CephReleaseNamePipe, + private dimlessBinary: DimlessBinaryPipe, + private i18n: I18n, + private orchService: OrchestratorService, + private summaryService: SummaryService + ) {} + + ngOnInit() { + this.columns = [ + { + name: this.i18n('Device path'), + prop: 'id', + flexGrow: 1 + }, + { + name: this.i18n('Type'), + prop: 'type', + flexGrow: 1 + }, + { + name: this.i18n('Size'), + prop: 'size', + flexGrow: 1, + pipe: this.dimlessBinary + }, + { + name: this.i18n('Rotates'), + prop: 'rotates', + flexGrow: 1 + }, + { + name: this.i18n('Available'), + prop: 'available', + flexGrow: 1 + }, + { + name: this.i18n('Model'), + prop: 'model', + flexGrow: 1 + } + ]; + + if (!this.hostname) { + const hostColumn = { + name: this.i18n('Hostname'), + prop: 'hostname', + flexGrow: 1 + }; + this.columns.splice(0, 0, hostColumn); + } + + // duplicated code with grafana + const subs = this.summaryService.subscribe((summary: any) => { + if (!summary) { + return; + } + + const releaseName = this.cephReleaseNamePipe.transform(summary.version); + this.docsUrl = `http://docs.ceph.com/docs/${releaseName}/mgr/orchestrator_cli/`; + + setTimeout(() => { + subs.unsubscribe(); + }, 0); + }); + + this.orchService.status().subscribe((data: { available: boolean }) => { + this.orchestratorExist = data.available; + this.checkingOrchestrator = false; + }); + } + + ngOnChanges() { + if (this.orchestratorExist) { + this.devices = []; + this.table.reloadData(); + } + } + + getInventory(context: CdTableFetchDataContext) { + if (this.isLoadingDevices) { + return; + } + this.isLoadingDevices = true; + this.orchService.inventoryList(this.hostname).subscribe( + (data: InventoryNode[]) => { + const devices: Device[] = []; + data.forEach((node: InventoryNode) => { + node.devices.forEach((device: Device) => { + device.hostname = node.name; + device.uid = `${node.name}-${device.id}`; + devices.push(device); + }); + }); + this.devices = devices; + this.isLoadingDevices = false; + }, + () => { + this.isLoadingDevices = false; + this.devices = []; + context.error(); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts new file mode 100644 index 000000000000..50b833fe38b4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts @@ -0,0 +1,10 @@ +export interface Device { + id: string; + hostname: string; + uid: string; +} + +export interface InventoryNode { + name: string; + devices: Device[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html new file mode 100644 index 000000000000..dc897b6541b3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html @@ -0,0 +1,16 @@ +Please consult the + documentation on how to + configure and enable the orchestrator functionality. + + + + + \ No newline at end of file diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts new file mode 100644 index 000000000000..4faddc317b65 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts @@ -0,0 +1,28 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../shared/shared.module'; +import { ServicesComponent } from './services.component'; + +describe('ServicesComponent', () => { + let component: ServicesComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [SharedModule, HttpClientTestingModule, RouterTestingModule], + providers: [i18nProviders], + declarations: [ServicesComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ServicesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts new file mode 100644 index 000000000000..3e0849dd5b64 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts @@ -0,0 +1,147 @@ +import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; +import { I18n } from '@ngx-translate/i18n-polyfill'; + +import { OrchestratorService } from '../../../shared/api/orchestrator.service'; +import { TableComponent } from '../../../shared/datatable/table/table.component'; +import { CdTableColumn } from '../../../shared/models/cd-table-column'; +import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context'; +import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe'; +import { SummaryService } from '../../../shared/services/summary.service'; +import { Service } from './services.model'; + +@Component({ + selector: 'cd-services', + templateUrl: './services.component.html', + styleUrls: ['./services.component.scss'] +}) +export class ServicesComponent implements OnChanges, OnInit { + @ViewChild(TableComponent) + table: TableComponent; + + @Input() hostname = ''; + + checkingOrchestrator = true; + orchestratorExist = false; + docsUrl: string; + + columns: Array = []; + services: Array = []; + isLoadingServices = false; + + constructor( + private cephReleaseNamePipe: CephReleaseNamePipe, + private i18n: I18n, + private orchService: OrchestratorService, + private summaryService: SummaryService + ) {} + + ngOnInit() { + this.columns = [ + { + name: this.i18n('Service type'), + prop: 'service_type', + flexGrow: 1 + }, + { + name: this.i18n('Service'), + prop: 'service', + flexGrow: 1 + }, + { + name: this.i18n('Service instance'), + prop: 'service_instance', + flexGrow: 1 + }, + { + name: this.i18n('Container id'), + prop: 'container_id', + flexGrow: 3 + }, + { + name: this.i18n('Version'), + prop: 'version', + flexGrow: 1 + }, + { + name: this.i18n('Rados config location'), + prop: 'rados_config_location', + flexGrow: 1 + }, + { + name: this.i18n('Service URL'), + prop: 'service_url', + flexGrow: 2 + }, + { + name: this.i18n('Status'), + prop: 'status', + flexGrow: 1 + }, + { + name: this.i18n('Status Description'), + prop: 'status_desc', + flexGrow: 1 + } + ]; + + if (!this.hostname) { + const hostnameColumn = { + name: this.i18n('Hostname'), + prop: 'nodename', + flexGrow: 2 + }; + this.columns.splice(0, 0, hostnameColumn); + } + + // duplicated code with grafana + const subs = this.summaryService.subscribe((summary: any) => { + if (!summary) { + return; + } + + const releaseName = this.cephReleaseNamePipe.transform(summary.version); + this.docsUrl = `http://docs.ceph.com/docs/${releaseName}/mgr/orchestrator_cli/`; + + setTimeout(() => { + subs.unsubscribe(); + }, 0); + }); + + this.orchService.status().subscribe((data: { available: boolean }) => { + this.orchestratorExist = data.available; + this.checkingOrchestrator = false; + }); + } + + ngOnChanges() { + if (this.orchestratorExist) { + this.services = []; + this.table.reloadData(); + } + } + + getServices(context: CdTableFetchDataContext) { + if (this.isLoadingServices) { + return; + } + this.isLoadingServices = true; + this.orchService.serviceList(this.hostname).subscribe( + (data: Service[]) => { + const services: Service[] = []; + data.forEach((service: Service) => { + service.uid = `${service.nodename}-${service.service_type}-${service.service}-${ + service.service_instance + }`; + services.push(service); + }); + this.services = services; + this.isLoadingServices = false; + }, + () => { + this.isLoadingServices = false; + this.services = []; + context.error(); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.model.ts new file mode 100644 index 000000000000..72b8b7592e87 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.model.ts @@ -0,0 +1,7 @@ +export interface Service { + uid: string; + nodename: string; + service_type: string; + service: string; + service_instance: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index 42db3ecb8d2b..1e7ef31b58b3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -92,6 +92,13 @@ class="dropdown-item" routerLink="/hosts">Hosts +
  • + Inventory +
  • @@ -99,6 +106,13 @@ class="dropdown-item" routerLink="/monitor/">Monitors
  • +
  • + Services +
  • diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts index a9354ce54f7b..8be0befc1d69 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts @@ -28,7 +28,7 @@ describe('HostService', () => { it('should call list', fakeAsync(() => { let result; - service.list().then((resp) => (result = resp)); + service.list().subscribe((resp) => (result = resp)); const req = httpTesting.expectOne('api/host'); expect(req.request.method).toBe('GET'); req.flush(['foo', 'bar']); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts index e0338609b69f..453de9587e8d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -7,14 +7,19 @@ import { ApiModule } from './api.module'; providedIn: ApiModule }) export class HostService { + baseURL = 'api/host'; + constructor(private http: HttpClient) {} list() { - return this.http - .get('api/host') - .toPromise() - .then((resp: any) => { - return resp; - }); + return this.http.get(this.baseURL); + } + + add(hostname) { + return this.http.post(this.baseURL, { hostname: hostname }, { observe: 'response' }); + } + + remove(hostname) { + return this.http.delete(`${this.baseURL}/${hostname}`, { observe: 'response' }); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts new file mode 100644 index 000000000000..993202244835 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts @@ -0,0 +1,19 @@ +import { TestBed } from '@angular/core/testing'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper'; +import { OrchestratorService } from './orchestrator.service'; + +describe('OrchestratorService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + configureTestBed({ + providers: [OrchestratorService, i18nProviders], + imports: [HttpClientTestingModule] + }); + + it('should be created', () => { + const service: OrchestratorService = TestBed.get(OrchestratorService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts new file mode 100644 index 000000000000..7123e7258e83 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts @@ -0,0 +1,28 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ApiModule } from './api.module'; + +@Injectable({ + providedIn: ApiModule +}) +export class OrchestratorService { + statusURL = 'api/orchestrator/status'; + inventoryURL = 'api/orchestrator/inventory'; + serviceURL = 'api/orchestrator/service'; + + constructor(private http: HttpClient) {} + + status() { + return this.http.get(this.statusURL); + } + + inventoryList(hostname: string) { + const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {}; + return this.http.get(this.inventoryURL, options); + } + + serviceList(hostname: string) { + const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {}; + return this.http.get(this.serviceURL, options); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index c2190432ca02..e8e5b6d4e3a8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -87,6 +87,12 @@ export class TaskMessageService { this.i18n('Deleting'), this.i18n('delete'), this.i18n('Deleted') + ), + add: new TaskMessageOperation(this.i18n('Adding'), this.i18n('add'), this.i18n('Added')), + remove: new TaskMessageOperation( + this.i18n('Removing'), + this.i18n('remove'), + this.i18n('Removed') ) }; @@ -125,6 +131,11 @@ export class TaskMessageService { }; messages = { + // Host tasks + 'host/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.host(metadata)), + 'host/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => + this.host(metadata) + ), // Pool tasks 'pool/create': this.newTaskMessage( this.commonOperations.create, @@ -348,6 +359,12 @@ export class TaskMessageService { return new TaskMessage(this.i18n, operation, involves, errors); } + host(metadata) { + return this.i18n(`host '{{hostname}}'`, { + hostname: metadata.hostname + }); + } + pool(metadata) { return this.i18n(`pool '{{pool_name}}'`, { pool_name: metadata.pool_name diff --git a/src/pybind/mgr/dashboard/services/exception.py b/src/pybind/mgr/dashboard/services/exception.py index 8db88bd1f617..66ba96cfb7ac 100644 --- a/src/pybind/mgr/dashboard/services/exception.py +++ b/src/pybind/mgr/dashboard/services/exception.py @@ -120,3 +120,12 @@ def handle_send_command_error(component): yield except SendCommandError as e: raise DashboardException(e, component=component) + + +@contextmanager +def handle_orchestrator_error(component): + try: + yield + except RuntimeError as e: + # how to catch remote error e.g. NotImplementedError ? + raise DashboardException(e, component=component) diff --git a/src/pybind/mgr/dashboard/services/ganesha.py b/src/pybind/mgr/dashboard/services/ganesha.py index e8b96cd6ba20..bdc2fcbe22ce 100644 --- a/src/pybind/mgr/dashboard/services/ganesha.py +++ b/src/pybind/mgr/dashboard/services/ganesha.py @@ -69,7 +69,7 @@ class Ganesha(object): @staticmethod def _get_orch_nfs_instances(): try: - return OrchClient().list_service_info("nfs") + return OrchClient.instance().services.list("nfs") except (RuntimeError, OrchestratorError, ImportError): return [] @@ -129,7 +129,7 @@ class Ganesha(object): @classmethod def reload_daemons(cls, cluster_id, daemons_id): logger.debug("[NFS] issued reload of daemons: %s", daemons_id) - if not OrchClient().available(): + if not OrchClient.instance().available(): logger.debug("[NFS] orchestrator not available") return reload_list = [] @@ -142,7 +142,7 @@ class Ganesha(object): continue if daemons[cluster_id][daemon_id] == 1: reload_list.append((cluster_id, daemon_id)) - OrchClient().reload_service("nfs", reload_list) + OrchClient.instance().reload_service("nfs", reload_list) @classmethod def fsals_available(cls): diff --git a/src/pybind/mgr/dashboard/services/iscsi_config.py b/src/pybind/mgr/dashboard/services/iscsi_config.py index c8cded19ab04..8a4fd7f71a21 100644 --- a/src/pybind/mgr/dashboard/services/iscsi_config.py +++ b/src/pybind/mgr/dashboard/services/iscsi_config.py @@ -86,7 +86,7 @@ class IscsiGatewaysConfig(object): def _load_config_from_orchestrator(): config = {'gateways': {}} try: - instances = OrchClient().list_service_info("iscsi") + instances = OrchClient.instance().services.list("iscsi") for instance in instances: config['gateways'][instance.nodename] = { 'service_url': instance.service_url diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index 366ff7de735c..22746e8abd61 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -1,38 +1,110 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +from orchestrator import InventoryFilter from orchestrator import OrchestratorClientMixin, raise_if_exception, OrchestratorError from .. import mgr, logger +from ..tools import wraps # pylint: disable=abstract-method -class OrchClient(OrchestratorClientMixin): +class OrchestratorAPI(OrchestratorClientMixin): def __init__(self): - super(OrchClient, self).__init__() + super(OrchestratorAPI, self).__init__() self.set_mgr(mgr) - def list_service_info(self, service_type): - # type: (str) -> list - completion = self.describe_service(service_type, None, None) - self._orchestrator_wait([completion]) - raise_if_exception(completion) - return completion.result - - def available(self): + def status(self): try: - status, desc = super(OrchClient, self).available() + status, desc = super(OrchestratorAPI, self).available() logger.info("[ORCH] is orchestrator available: %s, %s", status, desc) - return status + return dict(available=status, description=desc) except (RuntimeError, OrchestratorError, ImportError): - return False + return dict(available=False, + description='Orchestrator is unavailable for unknown reason') + + def orchestrator_wait(self, completions): + return self._orchestrator_wait(completions) + + +def wait_api_result(method): + @wraps(method) + def inner(self, *args, **kwargs): + completion = method(self, *args, **kwargs) + self.api.orchestrator_wait([completion]) + raise_if_exception(completion) + return completion.result + return inner + + +class ResourceManager(object): + def __init__(self, api): + self.api = api + + +class HostManger(ResourceManager): + + @wait_api_result + def list(self): + return self.api.get_hosts() + + def get(self, hostname): + hosts = [host for host in self.list() if host.name == hostname] + return hosts[0] if hosts else None + + @wait_api_result + def add(self, hostname): + return self.api.add_host(hostname) + + @wait_api_result + def remove(self, hostname): + return self.api.remove_host(hostname) - def reload_service(self, service_type, service_ids): + +class InventoryManager(ResourceManager): + + @wait_api_result + def list(self, hosts=None, refresh=False): + node_filter = InventoryFilter(nodes=hosts) if hosts else None + return self.api.get_inventory(node_filter=node_filter, refresh=refresh) + + +class ServiceManager(ResourceManager): + + @wait_api_result + def list(self, service_type=None, service_id=None, node_name=None): + return self.api.describe_service(service_type, service_id, node_name) + + def reload(self, service_type, service_ids): if not isinstance(service_ids, list): service_ids = [service_ids] - completion_list = [self.service_action('reload', service_type, - service_name, service_id) + completion_list = [self.api.service_action('reload', service_type, + service_name, service_id) for service_name, service_id in service_ids] - self._orchestrator_wait(completion_list) + self.api.orchestrator_wait(completion_list) for c in completion_list: raise_if_exception(c) + + +class OrchClient(object): + + _instance = None + + @classmethod + def instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self.api = OrchestratorAPI() + + self.hosts = HostManger(self.api) + self.inventory = InventoryManager(self.api) + self.services = ServiceManager(self.api) + + def available(self): + return self.status()['available'] + + def status(self): + return self.api.status() diff --git a/src/pybind/mgr/dashboard/tests/test_iscsi.py b/src/pybind/mgr/dashboard/tests/test_iscsi.py index d4e23c0f575f..21b458522c53 100644 --- a/src/pybind/mgr/dashboard/tests/test_iscsi.py +++ b/src/pybind/mgr/dashboard/tests/test_iscsi.py @@ -21,7 +21,7 @@ class IscsiTest(ControllerTestCase, CLICommandTestMixin): @classmethod def setup_server(cls): - OrchClient().available = lambda: False + OrchClient.instance().available = lambda: False mgr.rados.side_effect = None # pylint: disable=protected-access Iscsi._cp_config['tools.authenticate.on'] = False