From: Volker Theile Date: Mon, 20 Jul 2020 12:45:36 +0000 (+0200) Subject: mgr/dashboard: Create Ceph services via Orchestrator by using ServiceSpec X-Git-Tag: v16.1.0~1443^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=dc5e5a5980456cffa468f88bc0d51cbb3c32dd06;p=ceph.git mgr/dashboard: Create Ceph services via Orchestrator by using ServiceSpec Fixes: https://tracker.ceph.com/issues/44831 Signed-off-by: Volker Theile --- diff --git a/src/pybind/mgr/dashboard/controllers/service.py b/src/pybind/mgr/dashboard/controllers/service.py index 9f1e70bb94dd..baacff080ddb 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) 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 5e686424f2b5..12330c96b319 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 @@ -20,6 +20,7 @@ import { ActiveAlertListComponent } from './ceph/cluster/prometheus/active-alert import { RulesListComponent } from './ceph/cluster/prometheus/rules-list/rules-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 { 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'; @@ -106,8 +107,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 5b805d71ec30..909cec861cf5 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 @@ -49,6 +49,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'; @@ -106,7 +107,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; ServiceDetailsComponent, ServiceDaemonListComponent, TelemetryComponent, - PrometheusTabsComponent + PrometheusTabsComponent, + 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 000000000000..05918798d1b4 --- /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 000000000000..e69de29bb2d1 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 000000000000..50d76cca905d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts @@ -0,0 +1,335 @@ +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 { NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; +import * as _ from 'lodash'; +import { ToastrModule } from 'ngx-toastr'; + +import { configureTestBed, FormHelper } 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, + NgbTypeaheadModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + ToastrModule.forRoot() + ] + }); + + 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.inject(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 000000000000..4bf6d36143a1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -0,0 +1,334 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { AbstractControl, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import * as _ from 'lodash'; +import { merge, Observable, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, 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 { CdForm } from '../../../../shared/forms/cd-form'; +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 extends CdForm implements OnInit { + @ViewChild(NgbTypeahead, { static: false }) + typeahead: NgbTypeahead; + + serviceForm: CdFormGroup; + action: string; + resource: string; + serviceTypes: string[] = []; + hosts: any; + labels: string[]; + labelClick = new Subject(); + labelFocus = new Subject(); + pools: Array; + + constructor( + public actionLabels: ActionLabelsI18n, + private cephServiceService: CephServiceService, + private formBuilder: CdFormBuilder, + private hostService: HostService, + private poolService: PoolService, + private router: Router, + private taskWrapperService: TaskWrapperService + ) { + super(); + this.resource = $localize`service`; + this.hosts = { + options: [], + messages: new SelectMessages({ + empty: $localize`There are no hosts.`, + filter: $localize`Filter hosts` + }) + }; + 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']); + } + + searchLabels = (text$: Observable) => { + return merge( + text$.pipe(debounceTime(200), distinctUntilChanged()), + this.labelFocus, + this.labelClick.pipe(filter(() => !this.typeahead.isPopupOpen())) + ).pipe( + map((value) => + this.labels + .filter((label: string) => label.toLowerCase().indexOf(value.toLowerCase()) > -1) + .slice(0, 10) + ) + ); + }; + + 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.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 11174d3849d0..64ec411aca63 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]="60000" (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 b007f1ee097b..8fa09bb0616e 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 } from '../../../../testing/unit-test-helper'; @@ -58,7 +59,8 @@ describe('ServicesComponent', () => { CoreModule, SharedModule, HttpClientTestingModule, - RouterTestingModule + RouterTestingModule, + ToastrModule.forRoot() ], providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }] }); 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 48f5acc293d4..a753c8c3760e 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,21 +1,34 @@ import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; +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 { ModalService } from '../../../shared/services/modal.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: true }) @@ -27,6 +40,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI @Input() hiddenColumns: string[] = []; permissions: Permissions; + tableActions: CdTableAction[]; checkingOrchestrator = true; hasOrchestrator = false; @@ -35,15 +49,35 @@ 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 modalService: ModalService, 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() { @@ -70,9 +104,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI { name: $localize`Running`, prop: 'status.running', - flexGrow: 1, - cellClass: 'text-center', - cellTransformation: CellTemplate.checkIcon + flexGrow: 1 }, { name: $localize`Size`, @@ -119,4 +151,37 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI } ); } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteAction() { + const service = this.selection.first(); + this.modalService.show(CriticalConfirmationModalComponent, { + itemDescription: $localize`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 9825cb25bca4..5ad0cbf5ded0 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 @@ -38,7 +38,7 @@ for="password"> Password @@ -110,7 +110,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 cd1819af071a..cbde4f7d6552 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 87d67d8cd490..367a081f6f1a 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'; @@ -24,4 +25,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 f016f2c11da6..3b9e7068e776 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 @@ -17,8 +17,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 1c85540d78b8..7af3890913da 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 bcc5629a9e00..d72bcdc7c1ef 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 @@ -35,10 +35,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 @@ -58,7 +58,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) { @@ -71,7 +71,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) { @@ -81,12 +81,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 * { @@ -94,6 +125,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 @@ -119,7 +157,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 026765a14ce8..528ad82c6b02 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 @@ -332,6 +332,13 @@ export class TaskMessageService { 'orchestrator/identify_device': this.newTaskMessage( new TaskMessageOperation($localize`Identifying`, $localize`identify`, $localize`Identified`), (metadata) => $localize`device '${metadata.device}' on host '${metadata.hostname}'` + ), + // Service tasks + 'service/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.service(metadata) + ), + 'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.service(metadata) ) }; @@ -373,6 +380,10 @@ export class TaskMessageService { }'`; } + service(metadata: any) { + return $localize`Service '${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/ceph-custom/_basics.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss index 2de197a9e925..3e60bd81d6c2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss @@ -66,6 +66,10 @@ option { font-family: monospace; } -.text-pre { +.text-pre-wrap { white-space: pre-wrap; } + +.text-pre { + white-space: pre; +} diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index b5cf7142e8a4..282674ee1596 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -3,8 +3,9 @@ from __future__ import absolute_import import logging from functools import wraps -from typing import List, Optional +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 @@ -110,6 +111,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