From 2926db3bc201bf4be2e5bebed56332aa7fedbbe9 Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Mon, 20 Jul 2020 14:45:36 +0200 Subject: [PATCH] mgr/dashboard: Create Ceph services via Orchestrator by using ServiceSpec Fixes: https://tracker.ceph.com/issues/44831 Signed-off-by: Volker Theile (cherry picked from commit dc5e5a5980456cffa468f88bc0d51cbb3c32dd06) Conflicts: - src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts * Use i18n instead of $localize * Remove CdForm class * Adapt code to ngx-bootstrap. Typeahead works a little bit different than in Pacific/ng-bootstrap. * Adapt to older TypeScript version - src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts * Adapt code to ngx-bootstrap. - src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts * Use i18n instead of $localize * Replace ModalService by BsModalService - src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts * Use i18n instead of $localize - src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss * Relocate changes to src/pybind/mgr/dashboard/frontend/src/styles.scss --- .../mgr/dashboard/controllers/service.py | 49 ++- .../frontend/src/app/app-routing.module.ts | 12 +- .../src/app/ceph/cluster/cluster.module.ts | 4 +- .../service-form/service-form.component.html | 415 ++++++++++++++++++ .../service-form/service-form.component.scss | 0 .../service-form.component.spec.ts | 340 ++++++++++++++ .../service-form/service-form.component.ts | 328 ++++++++++++++ .../cluster/services/services.component.html | 8 +- .../services/services.component.spec.ts | 4 +- .../cluster/services/services.component.ts | 79 +++- .../auth/user-form/user-form.component.html | 4 +- .../user-password-form.component.html | 2 +- .../app/shared/api/ceph-service.service.ts | 23 + .../src/app/shared/api/host.service.ts | 4 +- .../app/shared/forms/cd-validators.spec.ts | 143 ++++++ .../src/app/shared/forms/cd-validators.ts | 78 +++- .../shared/services/task-message.service.ts | 11 + .../mgr/dashboard/frontend/src/styles.scss | 5 +- .../mgr/dashboard/services/orchestrator.py | 14 +- 19 files changed, 1496 insertions(+), 27 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/service.py b/src/pybind/mgr/dashboard/controllers/service.py index 509a2147a685a..86c774d145964 100644 --- a/src/pybind/mgr/dashboard/controllers/service.py +++ b/src/pybind/mgr/dashboard/controllers/service.py @@ -1,15 +1,32 @@ -from typing import List, Optional +from typing import List, Optional, Dict import cherrypy -from . import ApiController, RESTController +from ceph.deployment.service_spec import ServiceSpec +from . import ApiController, RESTController, Task, Endpoint, ReadPermission +from . import CreatePermission, DeletePermission from .orchestrator import raise_if_no_orchestrator +from ..exceptions import DashboardException from ..security import Scope from ..services.orchestrator import OrchClient +from ..services.exception import handle_orchestrator_error + + +def service_task(name, metadata, wait_for=2.0): + return Task("service/{}".format(name), metadata, wait_for) @ApiController('/service', Scope.HOSTS) class Service(RESTController): + @Endpoint() + @ReadPermission + def known_types(self) -> List[str]: + """ + Get a list of known service types, e.g. 'alertmanager', + 'node-exporter', 'osd' or 'rgw'. + """ + return ServiceSpec.KNOWN_SERVICE_TYPES + @raise_if_no_orchestrator def list(self, service_name: Optional[str] = None) -> List[dict]: orch = OrchClient.instance() @@ -29,3 +46,31 @@ class Service(RESTController): orch = OrchClient.instance() daemons = orch.services.list_daemons(service_name=service_name) return [d.to_json() for d in daemons] + + @CreatePermission + @raise_if_no_orchestrator + @handle_orchestrator_error('service') + @service_task('create', {'service_name': '{service_name}'}) + def create(self, service_spec: Dict, service_name: str): # pylint: disable=W0613 + """ + :param service_spec: The service specification as JSON. + :param service_name: The service name, e.g. 'alertmanager'. + :return: None + """ + try: + orch = OrchClient.instance() + orch.services.apply(service_spec) + except (ValueError, TypeError) as e: + raise DashboardException(e, component='service') + + @DeletePermission + @raise_if_no_orchestrator + @handle_orchestrator_error('service') + @service_task('delete', {'service_name': '{service_name}'}) + def delete(self, service_name: str): + """ + :param service_name: The service name, e.g. 'mds' or 'crash.foo'. + :return: None + """ + orch = OrchClient.instance() + orch.services.remove(service_name) 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 41f4e239ad32f..91af17f809bcb 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 @@ -18,6 +18,7 @@ import { OsdFormComponent } from './ceph/cluster/osd/osd-form/osd-form.component import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component'; import { MonitoringListComponent } from './ceph/cluster/prometheus/monitoring-list/monitoring-list.component'; import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component'; +import { ServiceFormComponent } from './ceph/cluster/services/service-form/service-form.component'; import { ServicesComponent } from './ceph/cluster/services/services.component'; import { TelemetryComponent } from './ceph/cluster/telemetry/telemetry.component'; import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component'; @@ -102,8 +103,15 @@ const routes: Routes = [ }, { path: 'services', - component: ServicesComponent, - data: { breadcrumbs: 'Cluster/Services' } + data: { breadcrumbs: 'Cluster/Services' }, + children: [ + { path: '', component: ServicesComponent }, + { + path: URLVerbs.CREATE, + component: ServiceFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + } + ] }, { path: 'inventory', 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 141af9d8bf2db..d380f42197fe8 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 @@ -51,6 +51,7 @@ import { SilenceListComponent } from './prometheus/silence-list/silence-list.com import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component'; import { ServiceDaemonListComponent } from './services/service-daemon-list/service-daemon-list.component'; import { ServiceDetailsComponent } from './services/service-details/service-details.component'; +import { ServiceFormComponent } from './services/service-form/service-form.component'; import { ServicesComponent } from './services/services.component'; import { TelemetryComponent } from './telemetry/telemetry.component'; @@ -127,7 +128,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; ServiceDetailsComponent, ServiceDaemonListComponent, TelemetryComponent, - OsdFlagsIndivModalComponent + OsdFlagsIndivModalComponent, + ServiceFormComponent ] }) export class ClusterModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html new file mode 100644 index 0000000000000..63e0054cf6ac4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html @@ -0,0 +1,415 @@ +
+
+
+
{{ action | titlecase }} {{ resource | upperFirst }}
+ +
+ +
+ +
+ + This field is required. +
+
+ + +
+ +
+ + This field is required. + The value does not match the pattern <realm_name>.<zone_name>[.<subcluster>]. +
+
+ + +
+
+
+ + +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + This field is required. +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + The value must be at least 1. + The entered value needs to be a number. +
+
+ + + + +
+ +
+ + This field is required. +
+
+ + +
+ +
+ +
+
+
+ + + + +
+ +
+ + The entered value needs to be a number. + The value must be at least 1. + The value cannot exceed 65535. +
+
+
+ + + + +
+ +
+ + This field is required. +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + The entered value needs to be a number. + The value must be at least 1. + The value cannot exceed 65535. +
+
+ + +
+ +
+ + This field is required. +
+
+ + +
+ +
+
+ + + + + + This field is required. +
+
+
+
+ + + + +
+
+
+ + +
+
+
+ + +
+ +
+ + + This field is required. + Invalid SSL certificate. +
+
+ + +
+ +
+ + + This field is required. + Invalid SSL private key. +
+
+
+
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts new file mode 100644 index 0000000000000..33a05dea25691 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts @@ -0,0 +1,340 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import * as _ from 'lodash'; +import { TypeaheadModule } from 'ngx-bootstrap/typeahead'; +import { ToastrModule } from 'ngx-toastr'; + +import { + configureTestBed, + FormHelper, + i18nProviders +} from '../../../../../testing/unit-test-helper'; +import { CephServiceService } from '../../../../shared/api/ceph-service.service'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { SharedModule } from '../../../../shared/shared.module'; +import { ServiceFormComponent } from './service-form.component'; + +describe('ServiceFormComponent', () => { + let component: ServiceFormComponent; + let fixture: ComponentFixture; + let cephServiceService: CephServiceService; + let form: CdFormGroup; + let formHelper: FormHelper; + + configureTestBed({ + declarations: [ServiceFormComponent], + imports: [ + HttpClientTestingModule, + TypeaheadModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + ToastrModule.forRoot() + ], + providers: [i18nProviders] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ServiceFormComponent); + component = fixture.componentInstance; + form = component.serviceForm; + formHelper = new FormHelper(form); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('should test form', () => { + beforeEach(() => { + cephServiceService = TestBed.get(CephServiceService); + spyOn(cephServiceService, 'create').and.stub(); + }); + + it('should test placement (host)', () => { + formHelper.setValue('service_type', 'crash'); + formHelper.setValue('placement', 'hosts'); + formHelper.setValue('hosts', ['mgr0', 'mon0', 'osd0']); + formHelper.setValue('count', 2); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'crash', + placement: { + hosts: ['mgr0', 'mon0', 'osd0'], + count: 2 + }, + unmanaged: false + }); + }); + + it('should test placement (label)', () => { + formHelper.setValue('service_type', 'mgr'); + formHelper.setValue('placement', 'label'); + formHelper.setValue('label', 'foo'); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'mgr', + placement: { + label: 'foo' + }, + unmanaged: false + }); + }); + + it('should submit valid count', () => { + formHelper.setValue('count', 1); + component.onSubmit(); + formHelper.expectValid('count'); + }); + + it('should submit invalid count (1)', () => { + formHelper.setValue('count', 0); + component.onSubmit(); + formHelper.expectError('count', 'min'); + }); + + it('should submit invalid count (2)', () => { + formHelper.setValue('count', 'abc'); + component.onSubmit(); + formHelper.expectError('count', 'pattern'); + }); + + it('should test unmanaged', () => { + formHelper.setValue('service_type', 'rgw'); + formHelper.setValue('placement', 'label'); + formHelper.setValue('label', 'bar'); + formHelper.setValue('rgw_frontend_port', 4567); + formHelper.setValue('unmanaged', true); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'rgw', + placement: {}, + unmanaged: true + }); + }); + + it('should test various services', () => { + _.forEach( + [ + 'alertmanager', + 'crash', + 'grafana', + 'mds', + 'mgr', + 'mon', + 'node-exporter', + 'prometheus', + 'rbd-mirror' + ], + (serviceType) => { + formHelper.setValue('service_type', serviceType); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: serviceType, + placement: {}, + unmanaged: false + }); + } + ); + }); + + describe('should test service nfs', () => { + beforeEach(() => { + formHelper.setValue('service_type', 'nfs'); + formHelper.setValue('pool', 'foo'); + }); + + it('should submit nfs with namespace', () => { + formHelper.setValue('namespace', 'bar'); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'nfs', + placement: {}, + unmanaged: false, + pool: 'foo', + namespace: 'bar' + }); + }); + + it('should submit nfs w/o namespace', () => { + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'nfs', + placement: {}, + unmanaged: false, + pool: 'foo' + }); + }); + }); + + describe('should test service rgw', () => { + beforeEach(() => { + formHelper.setValue('service_type', 'rgw'); + }); + + it('should test rgw valid service id', () => { + formHelper.setValue('service_id', 'foo.bar'); + formHelper.expectValid('service_id'); + formHelper.setValue('service_id', 'foo.bar.bas'); + formHelper.expectValid('service_id'); + }); + + it('should test rgw invalid service id', () => { + formHelper.setValue('service_id', 'foo'); + formHelper.expectError('service_id', 'rgwPattern'); + formHelper.setValue('service_id', 'foo.'); + formHelper.expectError('service_id', 'rgwPattern'); + formHelper.setValue('service_id', 'foo.bar.'); + formHelper.expectError('service_id', 'rgwPattern'); + formHelper.setValue('service_id', 'foo.bar.bas.'); + formHelper.expectError('service_id', 'rgwPattern'); + }); + + it('should submit rgw with port', () => { + formHelper.setValue('rgw_frontend_port', 1234); + formHelper.setValue('ssl', true); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'rgw', + placement: {}, + unmanaged: false, + rgw_frontend_port: 1234, + rgw_frontend_ssl_certificate: '', + rgw_frontend_ssl_key: '', + ssl: true + }); + }); + + it('should submit valid rgw port (1)', () => { + formHelper.setValue('rgw_frontend_port', 1); + component.onSubmit(); + formHelper.expectValid('rgw_frontend_port'); + }); + + it('should submit valid rgw port (2)', () => { + formHelper.setValue('rgw_frontend_port', 65535); + component.onSubmit(); + formHelper.expectValid('rgw_frontend_port'); + }); + + it('should submit invalid rgw port (1)', () => { + formHelper.setValue('rgw_frontend_port', 0); + component.onSubmit(); + formHelper.expectError('rgw_frontend_port', 'min'); + }); + + it('should submit invalid rgw port (2)', () => { + formHelper.setValue('rgw_frontend_port', 65536); + component.onSubmit(); + formHelper.expectError('rgw_frontend_port', 'max'); + }); + + it('should submit invalid rgw port (3)', () => { + formHelper.setValue('rgw_frontend_port', 'abc'); + component.onSubmit(); + formHelper.expectError('rgw_frontend_port', 'pattern'); + }); + + it('should submit rgw w/o port', () => { + formHelper.setValue('ssl', false); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'rgw', + placement: {}, + unmanaged: false, + ssl: false + }); + }); + }); + + describe('should test service iscsi', () => { + beforeEach(() => { + formHelper.setValue('service_type', 'iscsi'); + formHelper.setValue('pool', 'xyz'); + formHelper.setValue('api_user', 'user'); + formHelper.setValue('api_password', 'password'); + formHelper.setValue('ssl', false); + }); + + it('should submit iscsi', () => { + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'iscsi', + placement: {}, + unmanaged: false, + pool: 'xyz', + api_user: 'user', + api_password: 'password', + api_secure: false + }); + }); + + it('should submit iscsi with trusted ips', () => { + formHelper.setValue('ssl', true); + formHelper.setValue('trusted_ip_list', ' 172.16.0.5, 192.1.1.10 '); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'iscsi', + placement: {}, + unmanaged: false, + pool: 'xyz', + api_user: 'user', + api_password: 'password', + api_secure: true, + ssl_cert: '', + ssl_key: '', + trusted_ip_list: ['172.16.0.5', '192.1.1.10'] + }); + }); + + it('should submit iscsi with port', () => { + formHelper.setValue('api_port', 456); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'iscsi', + placement: {}, + unmanaged: false, + pool: 'xyz', + api_user: 'user', + api_password: 'password', + api_secure: false, + api_port: 456 + }); + }); + + it('should submit valid iscsi port (1)', () => { + formHelper.setValue('api_port', 1); + component.onSubmit(); + formHelper.expectValid('api_port'); + }); + + it('should submit valid iscsi port (2)', () => { + formHelper.setValue('api_port', 65535); + component.onSubmit(); + formHelper.expectValid('api_port'); + }); + + it('should submit invalid iscsi port (1)', () => { + formHelper.setValue('api_port', 0); + component.onSubmit(); + formHelper.expectError('api_port', 'min'); + }); + + it('should submit invalid iscsi port (2)', () => { + formHelper.setValue('api_port', 65536); + component.onSubmit(); + formHelper.expectError('api_port', 'max'); + }); + + it('should submit invalid iscsi port (3)', () => { + formHelper.setValue('api_port', 'abc'); + component.onSubmit(); + formHelper.expectError('api_port', 'pattern'); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts new file mode 100644 index 0000000000000..f52c9d3aa72eb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -0,0 +1,328 @@ +import { Component, OnInit } from '@angular/core'; +import { AbstractControl, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; +import * as _ from 'lodash'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { CephServiceService } from '../../../../shared/api/ceph-service.service'; +import { HostService } from '../../../../shared/api/host.service'; +import { PoolService } from '../../../../shared/api/pool.service'; +import { SelectMessages } from '../../../../shared/components/select/select-messages.model'; +import { SelectOption } from '../../../../shared/components/select/select-option.model'; +import { ActionLabelsI18n, URLVerbs } from '../../../../shared/constants/app.constants'; +import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder'; +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-service-form', + templateUrl: './service-form.component.html', + styleUrls: ['./service-form.component.scss'] +}) +export class ServiceFormComponent implements OnInit { + serviceForm: CdFormGroup; + action: string; + resource: string; + serviceTypes: string[] = []; + hosts: any; + labels: string[]; + pools: Array; + + searchLabels: Observable = new Observable((observer: any) => { + observer.next(this.serviceForm.getValue('label')); + }).pipe( + map((value: string) => { + const result = this.labels + .filter((label: string) => label.toLowerCase().indexOf(value.toLowerCase()) > -1) + .slice(0, 10); + return result; + }) + ); + + constructor( + public actionLabels: ActionLabelsI18n, + private cephServiceService: CephServiceService, + private formBuilder: CdFormBuilder, + private hostService: HostService, + private i18n: I18n, + private poolService: PoolService, + private router: Router, + private taskWrapperService: TaskWrapperService + ) { + this.resource = this.i18n(`service`); + this.hosts = { + options: [], + messages: new SelectMessages( + { + empty: this.i18n(`There are no hosts.`), + filter: this.i18n(`Filter hosts`) + }, + this.i18n + ) + }; + this.createForm(); + } + + createForm() { + this.serviceForm = this.formBuilder.group({ + // Global + service_type: [null, [Validators.required]], + service_id: [ + null, + [ + CdValidators.requiredIf({ + service_type: 'mds' + }), + CdValidators.requiredIf({ + service_type: 'nfs' + }), + CdValidators.requiredIf({ + service_type: 'iscsi' + }), + CdValidators.composeIf( + { + service_type: 'rgw' + }, + [ + Validators.required, + CdValidators.custom('rgwPattern', (value: string) => { + if (_.isEmpty(value)) { + return false; + } + return !/^[^.]+\.[^.]+(\.[^.]+)?$/.test(value); + }) + ] + ) + ] + ], + placement: ['hosts'], + label: [ + null, + [ + CdValidators.requiredIf({ + placement: 'label', + unmanaged: false + }) + ] + ], + hosts: [[]], + count: [null, [CdValidators.number(false), Validators.min(1)]], + unmanaged: [false], + // NFS & iSCSI + pool: [ + null, + [ + CdValidators.requiredIf({ + service_type: 'nfs', + unmanaged: false + }), + CdValidators.requiredIf({ + service_type: 'iscsi', + unmanaged: false + }) + ] + ], + // NFS + namespace: [null], + // RGW + rgw_frontend_port: [ + null, + [CdValidators.number(false), Validators.min(1), Validators.max(65535)] + ], + // iSCSI + trusted_ip_list: [null], + api_port: [null, [CdValidators.number(false), Validators.min(1), Validators.max(65535)]], + api_user: [ + null, + [ + CdValidators.requiredIf({ + service_type: 'iscsi', + unmanaged: false + }) + ] + ], + api_password: [ + null, + [ + CdValidators.requiredIf({ + service_type: 'iscsi', + unmanaged: false + }) + ] + ], + // RGW & iSCSI + ssl: [false], + ssl_cert: [ + '', + [ + CdValidators.composeIf( + { + service_type: 'rgw', + unmanaged: false, + ssl: true + }, + [Validators.required, CdValidators.sslCert()] + ), + CdValidators.composeIf( + { + service_type: 'iscsi', + unmanaged: false, + ssl: true + }, + [Validators.required, CdValidators.sslCert()] + ) + ] + ], + ssl_key: [ + '', + [ + CdValidators.composeIf( + { + service_type: 'rgw', + unmanaged: false, + ssl: true + }, + [Validators.required, CdValidators.sslPrivKey()] + ), + CdValidators.composeIf( + { + service_type: 'iscsi', + unmanaged: false, + ssl: true + }, + [Validators.required, CdValidators.sslPrivKey()] + ) + ] + ] + }); + } + + ngOnInit(): void { + this.action = this.actionLabels.CREATE; + this.cephServiceService.getKnownTypes().subscribe((resp: Array) => { + // Remove service type 'osd', this is deployed a different way. + this.serviceTypes = _.difference(resp, ['osd']).sort(); + }); + this.hostService.list().subscribe((resp: object[]) => { + const options: SelectOption[] = []; + _.forEach(resp, (host: object) => { + if (_.get(host, 'sources.orchestrator', false)) { + const option = new SelectOption(false, _.get(host, 'hostname'), ''); + options.push(option); + } + }); + this.hosts.options = [...options]; + }); + this.hostService.getLabels().subscribe((resp: string[]) => { + this.labels = resp; + }); + this.poolService.getList().subscribe((resp: Array) => { + this.pools = resp; + }); + } + + goToListView() { + this.router.navigate(['/services']); + } + + fileUpload(files: FileList, controlName: string) { + const file: File = files[0]; + const reader = new FileReader(); + reader.addEventListener('load', (event: ProgressEvent) => { + const control: AbstractControl = this.serviceForm.get(controlName); + control.setValue((event.target as FileReader).result); + control.markAsDirty(); + control.markAsTouched(); + control.updateValueAndValidity(); + }); + reader.readAsText(file, 'utf8'); + } + + onSubmit() { + const self = this; + const values: object = this.serviceForm.value; + const serviceId: string = values['service_id']; + const serviceType: string = values['service_type']; + const serviceSpec: object = { + service_type: serviceType, + placement: {}, + unmanaged: values['unmanaged'] + }; + let serviceName: string = serviceType; + if (_.isString(serviceId) && !_.isEmpty(serviceId)) { + serviceName = `${serviceType}.${serviceId}`; + serviceSpec['service_id'] = serviceId; + } + if (!values['unmanaged']) { + switch (values['placement']) { + case 'hosts': + if (values['hosts'].length > 0) { + serviceSpec['placement']['hosts'] = values['hosts']; + } + break; + case 'label': + serviceSpec['placement']['label'] = values['label']; + break; + } + if (_.isNumber(values['count']) && values['count'] > 0) { + serviceSpec['placement']['count'] = values['count']; + } + switch (serviceType) { + case 'nfs': + serviceSpec['pool'] = values['pool']; + if (_.isString(values['namespace']) && !_.isEmpty(values['namespace'])) { + serviceSpec['namespace'] = values['namespace']; + } + break; + case 'rgw': + if (_.isNumber(values['rgw_frontend_port']) && values['rgw_frontend_port'] > 0) { + serviceSpec['rgw_frontend_port'] = values['rgw_frontend_port']; + } + serviceSpec['ssl'] = values['ssl']; + if (values['ssl']) { + serviceSpec['rgw_frontend_ssl_certificate'] = values['ssl_cert'].trim(); + serviceSpec['rgw_frontend_ssl_key'] = values['ssl_key'].trim(); + } + break; + case 'iscsi': + serviceSpec['pool'] = values['pool']; + if (_.isString(values['trusted_ip_list']) && !_.isEmpty(values['trusted_ip_list'])) { + let parts = _.split(values['trusted_ip_list'], ','); + parts = _.map(parts, _.trim); + serviceSpec['trusted_ip_list'] = parts; + } + if (_.isNumber(values['api_port']) && values['api_port'] > 0) { + serviceSpec['api_port'] = values['api_port']; + } + serviceSpec['api_user'] = values['api_user']; + serviceSpec['api_password'] = values['api_password']; + serviceSpec['api_secure'] = values['ssl']; + if (values['ssl']) { + serviceSpec['ssl_cert'] = values['ssl_cert'].trim(); + serviceSpec['ssl_key'] = values['ssl_key'].trim(); + } + break; + } + } + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(`service/${URLVerbs.CREATE}`, { + service_name: serviceName + }), + call: this.cephServiceService.create(serviceSpec) + }) + .subscribe({ + error() { + self.serviceForm.setErrors({ cdSubmitButton: true }); + }, + complete() { + self.goToListView(); + } + }); + } +} 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 index 11305cfd6e184..45269d3bd39dc 100644 --- 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 @@ -9,7 +9,13 @@ [autoReload]="5000" (fetchData)="getServices($event)" [hasDetails]="true" - (setExpandedRow)="setExpandedRow($event)"> + (setExpandedRow)="setExpandedRow($event)" + (updateSelection)="updateSelection($event)"> + + 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 index 71740ef836777..37369d68a4efe 100644 --- 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 @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; +import { ToastrModule } from 'ngx-toastr'; import { of } from 'rxjs'; import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; @@ -58,7 +59,8 @@ describe('ServicesComponent', () => { CoreModule, SharedModule, HttpClientTestingModule, - RouterTestingModule + RouterTestingModule, + ToastrModule.forRoot() ], providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }, i18nProviders], declarations: [] 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 index ecccb9031e2b4..b27647e5bc801 100644 --- 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 @@ -1,22 +1,35 @@ import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; import { I18n } from '@ngx-translate/i18n-polyfill'; +import { BsModalService } from 'ngx-bootstrap/modal'; +import { delay, finalize } from 'rxjs/operators'; + import { CephServiceService } from '../../../shared/api/ceph-service.service'; import { OrchestratorService } from '../../../shared/api/orchestrator.service'; import { ListWithDetails } from '../../../shared/classes/list-with-details.class'; +import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { ActionLabelsI18n, URLVerbs } from '../../../shared/constants/app.constants'; import { TableComponent } from '../../../shared/datatable/table/table.component'; import { CellTemplate } from '../../../shared/enum/cell-template.enum'; +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 { CephServiceSpec } from '../../../shared/models/service.interface'; 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 = 'services'; @Component({ selector: 'cd-services', templateUrl: './services.component.html', - styleUrls: ['./services.component.scss'] + styleUrls: ['./services.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] }) export class ServicesComponent extends ListWithDetails implements OnChanges, OnInit { @ViewChild(TableComponent, { static: false }) @@ -29,6 +42,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI permissions: Permissions; showDocPanel = false; + tableActions: CdTableAction[]; checkingOrchestrator = true; hasOrchestrator = false; @@ -36,16 +50,36 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI columns: Array = []; services: Array = []; isLoadingServices = false; - selection = new CdTableSelection(); + selection: CdTableSelection = new CdTableSelection(); constructor( + private actionLabels: ActionLabelsI18n, private authStorageService: AuthStorageService, private i18n: I18n, + private bsModalService: BsModalService, private orchService: OrchestratorService, - private cephServiceService: CephServiceService + private cephServiceService: CephServiceService, + private taskWrapperService: TaskWrapperService, + private urlBuilder: URLBuilderService ) { super(); this.permissions = this.authStorageService.getPermissions(); + this.tableActions = [ + { + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreate(), + name: this.actionLabels.CREATE, + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteAction(), + disable: () => !this.selection.hasSingleSelection, + name: this.actionLabels.DELETE + } + ]; } ngOnInit() { @@ -72,9 +106,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI { name: this.i18n('Running'), prop: 'status.running', - flexGrow: 1, - cellClass: 'text-center', - cellTransformation: CellTemplate.checkIcon + flexGrow: 1 }, { name: this.i18n('Size'), @@ -122,4 +154,39 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI } ); } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteAction() { + const service = this.selection.first(); + this.bsModalService.show(CriticalConfirmationModalComponent, { + initialState: { + itemDescription: this.i18n('Service'), + itemNames: [service.service_name], + actionDescription: 'delete', + submitActionObservable: () => + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(`service/${URLVerbs.DELETE}`, { + service_name: service.service_name + }), + call: this.cephServiceService.delete(service.service_name) + }) + .pipe( + // Delay closing the dialog, otherwise the datatable still + // shows the deleted service after forcing a reload. + // Showing the dialog while delaying is done to increase + // the user experience. + delay(2000), + finalize(() => { + // Force reloading the data table content because it is + // auto-reloaded only every 60s. + this.table.refreshBtn(); + }) + ) + } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html index 3793dabc462e9..580b0365a5973 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html @@ -43,7 +43,7 @@ for="password"> Password @@ -117,7 +117,7 @@ [ngClass]="{'required': pwdExpirationSettings.pwdExpirationSpan > 0}" for="pwdExpirationDate"> Password expiration date -

The Dashboard setting defining the expiration interval of diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html index 08d1c1c75e571..c8b67224f839e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html @@ -43,7 +43,7 @@ New password diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts index f1e4003b11b93..2a857dcaadb84 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import * as _ from 'lodash'; import { Observable } from 'rxjs'; import { Daemon } from '../models/daemon.interface'; @@ -25,4 +26,26 @@ export class CephServiceService { getDaemons(serviceName?: string): Observable { return this.http.get(`${this.url}/${serviceName}/daemons`); } + + create(serviceSpec: { [key: string]: any }) { + const serviceName = serviceSpec['service_id'] + ? `${serviceSpec['service_type']}.${serviceSpec['service_id']}` + : serviceSpec['service_type']; + return this.http.post( + this.url, + { + service_name: serviceName, + service_spec: serviceSpec + }, + { observe: 'response' } + ); + } + + delete(serviceName: string) { + return this.http.delete(`${this.url}/${serviceName}`, { observe: 'response' }); + } + + getKnownTypes(): Observable { + return this.http.get(`${this.url}/known_types`); + } } 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 4d296db7835ca..cfe53305d4318 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 @@ -18,8 +18,8 @@ export class HostService { constructor(private http: HttpClient, private deviceService: DeviceService) {} - list() { - return this.http.get(this.baseURL); + list(): Observable { + return this.http.get(this.baseURL); } create(hostname: string) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts index 1c85540d78b89..7af3890913da0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts @@ -249,6 +249,8 @@ describe('CdValidators', () => { describe('requiredIf', () => { beforeEach(() => { form = new CdFormGroup({ + a: new FormControl(''), + b: new FormControl('xyz'), x: new FormControl(true), y: new FormControl('abc'), z: new FormControl('') @@ -316,6 +318,69 @@ describe('CdValidators', () => { ); expect(validatorFn(form.get('y'))).toEqual({ required: true }); }); + + it('should process extended prerequisites (1)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: '!empty' } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (2)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: '!empty' } + }); + expect(validatorFn(form.get('b'))).toBeNull(); + }); + + it('should process extended prerequisites (3)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: 'minLength', arg1: 2 } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (4)', () => { + const validatorFn = CdValidators.requiredIf({ + z: { op: 'empty' } + }); + expect(validatorFn(form.get('a'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (5)', () => { + const validatorFn = CdValidators.requiredIf({ + z: { op: 'empty' } + }); + expect(validatorFn(form.get('y'))).toBeNull(); + }); + + it('should process extended prerequisites (6)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: 'empty' } + }); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should process extended prerequisites (7)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: 'minLength', arg1: 4 } + }); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should process extended prerequisites (8)', () => { + const validatorFn = CdValidators.requiredIf({ + x: { op: 'equal', arg1: true } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (9)', () => { + const validatorFn = CdValidators.requiredIf({ + b: { op: '!equal', arg1: 'abc' } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); }); describe('custom validation', () => { @@ -611,5 +676,83 @@ describe('CdValidators', () => { tick(500); expect(callbackCalled).toBeTruthy(); })); + + describe('sslCert validator', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.sslCert()); + }); + + it('should not error because of empty input', () => { + expectValid(''); + }); + + it('should accept SSL certificate', () => { + expectValid( + '-----BEGIN CERTIFICATE-----\n' + + 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' + + '...\n' + + '3Ztorm2A5tFB\n' + + '-----END CERTIFICATE-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL certificate (1)', () => { + expectPatternError( + 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' + + '...\n' + + '3Ztorm2A5tFB\n' + + '-----END CERTIFICATE-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL certificate (2)', () => { + expectPatternError( + '-----BEGIN CERTIFICATE-----\n' + + 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' + ); + }); + }); + + describe('sslPrivKey validator', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.sslPrivKey()); + }); + + it('should not error because of empty input', () => { + expectValid(''); + }); + + it('should accept SSL private key', () => { + expectValid( + '-----BEGIN RSA PRIVATE KEY-----\n' + + 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' + + '...\n' + + 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' + + '-----END RSA PRIVATE KEY-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL private key (1)', () => { + expectPatternError( + 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' + + '...\n' + + 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' + + '-----END RSA PRIVATE KEY-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL private key (2)', () => { + expectPatternError( + '-----BEGIN RSA PRIVATE KEY-----\n' + + 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' + + '...\n' + + 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' + ); + }); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index b0cd8133edad2..6edfb16369049 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -36,10 +36,10 @@ export class CdValidators { /** * Validator function in order to validate IP addresses. * @param {number} version determines the protocol version. It needs to be set to 4 for IPv4 and - * to 6 for IPv6 validation. For any other number (it's also the default case) it will return a - * function to validate the input string against IPv4 OR IPv6. + * to 6 for IPv6 validation. For any other number (it's also the default case) it will return a + * function to validate the input string against IPv4 OR IPv6. * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` - * if the validation failed, otherwise `null`. + * if the validation check fails, otherwise `null`. */ static ip(version: number = 0): ValidatorFn { // prettier-ignore @@ -59,7 +59,7 @@ export class CdValidators { /** * Validator function in order to validate numbers. * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` - * if the validation failed, otherwise `null`. + * if the validation check fails, otherwise `null`. */ static number(allowsNegative: boolean = true): ValidatorFn { if (allowsNegative) { @@ -72,7 +72,7 @@ export class CdValidators { /** * Validator function in order to validate decimal numbers. * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` - * if the validation failed, otherwise `null`. + * if the validation check fails, otherwise `null`. */ static decimalNumber(allowsNegative: boolean = true): ValidatorFn { if (allowsNegative) { @@ -82,12 +82,43 @@ export class CdValidators { } } + /** + * Validator that performs SSL certificate validation. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static sslCert(): ValidatorFn { + return Validators.pattern( + /^-----BEGIN CERTIFICATE-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END CERTIFICATE-----[\n\r\f]*$/ + ); + } + + /** + * Validator that performs SSL private key validation. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static sslPrivKey(): ValidatorFn { + return Validators.pattern( + /^-----BEGIN RSA PRIVATE KEY-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END RSA PRIVATE KEY-----[\n\r\f]*$/ + ); + } + /** * Validator that requires controls to fulfill the specified condition if * the specified prerequisites matches. If the prerequisites are fulfilled, * then the given function is executed and if it succeeds, the 'required' * validation error will be returned, otherwise null. * @param {Object} prerequisites An object containing the prerequisites. + * To do additional checks rather than checking for equality you can + * use the extended prerequisite syntax: + * 'field_name': { 'op': '', arg1: '' } + * The following operators are supported: + * * empty + * * !empty + * * equal + * * !equal + * * minLength * ### Example * ```typescript * { @@ -95,6 +126,13 @@ export class CdValidators { * 'username': 'Max Mustermann' * } * ``` + * ### Example - Extended prerequisites + * ```typescript + * { + * 'generate_key': { 'op': 'equal', 'arg1': true }, + * 'username': { 'op': 'minLength', 'arg1': 5 } + * } + * ``` * Only if all prerequisites are fulfilled, then the validation of the * control will be triggered. * @param {Function | undefined} condition The function to be executed when all @@ -120,7 +158,35 @@ export class CdValidators { // Check if all prerequisites met. if ( !Object.keys(prerequisites).every((key) => { - return control.parent && control.parent.get(key).value === prerequisites[key]; + if (!control.parent) { + return false; + } + const value = control.parent.get(key).value; + const prerequisite = prerequisites[key]; + if (_.isObjectLike(prerequisite)) { + let result = false; + switch (prerequisite['op']) { + case 'empty': + result = _.isEmpty(value); + break; + case '!empty': + result = !_.isEmpty(value); + break; + case 'equal': + result = value === prerequisite['arg1']; + break; + case '!equal': + result = value !== prerequisite['arg1']; + break; + case 'minLength': + if (_.isString(value)) { + result = value.length >= prerequisite['arg1']; + } + break; + } + return result; + } + return value === prerequisite; }) ) { return null; 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 3047a287db329..8e7c01ac65f3c 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 @@ -429,6 +429,13 @@ export class TaskMessageService { this.i18n('Identified') ), (metadata) => this.i18n(`device '{{device}}' on host '{{hostname}}'`, metadata) + ), + // Service tasks + 'service/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.service(metadata) + ), + 'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.service(metadata) ) }; @@ -476,6 +483,10 @@ export class TaskMessageService { }); } + service(metadata: any) { + return this.i18n(`Service '{{service_name}}'`, { service_name: metadata.service_name }); + } + _getTaskTitle(task: Task) { if (task.name && task.name.startsWith('progress/')) { // we don't fill the failure string because, at least for now, all diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index 212d10c92f173..0517101c0401a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -117,9 +117,12 @@ option { .text-monospace { font-family: monospace; } -.text-pre { +.text-pre-wrap { white-space: pre-wrap; } +.text-pre { + white-space: pre; +} /* Buttons */ .btn-light { diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index a1f22a9a0673a..af68e01dbf27b 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -2,14 +2,15 @@ from __future__ import absolute_import import logging -from typing import List, Optional +from functools import wraps +from typing import List, Optional, Dict +from ceph.deployment.service_spec import ServiceSpec from orchestrator import InventoryFilter, DeviceLightLoc, Completion from orchestrator import ServiceDescription, DaemonDescription from orchestrator import OrchestratorClientMixin, raise_if_exception, OrchestratorError from orchestrator import HostSpec from .. import mgr -from ..tools import wraps logger = logging.getLogger('orchestrator') @@ -115,6 +116,15 @@ class ServiceManager(ResourceManager): for c in completion_list: raise_if_exception(c) + @wait_api_result + def apply(self, service_spec: Dict) -> Completion: + spec = ServiceSpec.from_json(service_spec) + return self.api.apply([spec]) + + @wait_api_result + def remove(self, service_name: str) -> List[str]: + return self.api.remove_service(service_name) + class OsdManager(ResourceManager): @wait_api_result -- 2.47.3