-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()
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)
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';
},
{
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',
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';
ServiceDetailsComponent,
ServiceDaemonListComponent,
TelemetryComponent,
- OsdFlagsIndivModalComponent
+ OsdFlagsIndivModalComponent,
+ ServiceFormComponent
]
})
export class ClusterModule {}
--- /dev/null
+<div class="cd-col-form">
+ <form #frm="ngForm"
+ [formGroup]="serviceForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title|Example: Create Pool@@formTitle"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- Service type -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="service_type"
+ i18n>Type</label>
+ <div class="cd-col-form-input">
+ <select id="service_type"
+ class="form-control custom-select"
+ formControlName="service_type">
+ <option i18n
+ [ngValue]="null">-- Select a service type --</option>
+ <option *ngFor="let serviceType of serviceTypes"
+ [value]="serviceType">
+ {{ serviceType }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_type', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Service id -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['mds', 'rgw', 'nfs', 'iscsi'].includes(serviceForm.controls.service_type.value)}"
+ for="service_id">Id</label>
+ <div class="cd-col-form-input">
+ <input id="service_id"
+ class="form-control"
+ type="text"
+ formControlName="service_id">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_id', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_id', frm, 'rgwPattern')"
+ i18n>The value does not match the pattern <strong><realm_name>.<zone_name>[.<subcluster>]</strong>.</span>
+ </div>
+ </div>
+
+ <!-- unmanaged -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="unmanaged"
+ type="checkbox"
+ formControlName="unmanaged">
+ <label class="custom-control-label"
+ for="unmanaged"
+ i18n>Unmanaged</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Placement -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="placement"
+ i18n>Placement</label>
+ <div class="cd-col-form-input">
+ <select id="placement"
+ class="form-control custom-select"
+ formControlName="placement">
+ <option i18n
+ value="hosts">Hosts</option>
+ <option i18n
+ value="label">Label</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Label -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.placement.value === 'label'"
+ class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="label">Label</label>
+ <div class="cd-col-form-input">
+ <input id="label"
+ class="form-control"
+ type="text"
+ formControlName="label"
+ autocomplete="off"
+ [typeahead]="searchLabels"
+ typeaheadWaitMs="200">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('label', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Hosts -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.placement.value === 'hosts'"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="hosts"
+ i18n>Hosts</label>
+ <div class="cd-col-form-input">
+ <cd-select-badges id="hosts"
+ [data]="serviceForm.controls.hosts.value"
+ [options]="hosts.options"
+ [messages]="hosts.messages">
+ </cd-select-badges>
+ </div>
+ </div>
+
+ <!-- count -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="count">
+ <span i18n>Count</span>
+ <cd-helper i18n>Only that number of daemons will be created.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="count"
+ class="form-control"
+ type="number"
+ formControlName="count"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('count', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('count', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ </div>
+ </div>
+
+ <!-- NFS -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'nfs'">
+ <!-- pool -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label required"
+ for="pool">Pool</label>
+ <div class="cd-col-form-input">
+ <select id="pool"
+ name="pool"
+ class="form-control custom-select"
+ formControlName="pool">
+ <option *ngIf="pools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="pools !== null && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No pools available --</option>
+ <option *ngIf="pools !== null && pools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a pool --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('pool', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- namespace -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="namespace">Namespace</label>
+ <div class="cd-col-form-input">
+ <input id="namespace"
+ class="form-control"
+ type="text"
+ formControlName="namespace">
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- RGW -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'rgw'">
+ <!-- rgw_frontend_port -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="rgw_frontend_port">Port</label>
+ <div class="cd-col-form-input">
+ <input id="rgw_frontend_port"
+ class="form-control"
+ type="number"
+ formControlName="rgw_frontend_port"
+ min="1"
+ max="65535">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('rgw_frontend_port', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('rgw_frontend_port', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('rgw_frontend_port', frm, 'max')"
+ i18n>The value cannot exceed 65535.</span>
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- iSCSI -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'iscsi'">
+ <!-- pool -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label required"
+ for="pool">Pool</label>
+ <div class="cd-col-form-input">
+ <select id="pool"
+ name="pool"
+ class="form-control custom-select"
+ formControlName="pool">
+ <option *ngIf="pools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="pools !== null && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No pools available --</option>
+ <option *ngIf="pools !== null && pools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a pool --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('pool', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- trusted_ip_list -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="trusted_ip_list">
+ <span i18n>Trusted IPs</span>
+ <cd-helper>
+ <span i18n>Comma separated list of IP addresses.</span>
+ <br>
+ <span i18n>Please add the <b>Ceph Manager</b> IP addresses here, otherwise the iSCSI gateways can't be reached.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="trusted_ip_list"
+ class="form-control"
+ type="text"
+ formControlName="trusted_ip_list">
+ </div>
+ </div>
+
+ <!-- api_port -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="api_port">Port</label>
+ <div class="cd-col-form-input">
+ <input id="api_port"
+ class="form-control"
+ type="number"
+ formControlName="api_port"
+ min="1"
+ max="65535">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_port', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_port', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_port', frm, 'max')"
+ i18n>The value cannot exceed 65535.</span>
+ </div>
+ </div>
+
+ <!-- api_user -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['iscsi'].includes(serviceForm.controls.service_type.value)}"
+ for="api_user">User</label>
+ <div class="cd-col-form-input">
+ <input id="api_user"
+ class="form-control"
+ type="text"
+ formControlName="api_user">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_user', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- api_password -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['iscsi'].includes(serviceForm.controls.service_type.value)}"
+ for="api_password">Password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="api_password"
+ class="form-control"
+ type="password"
+ autocomplete="new-password"
+ formControlName="api_password">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="api_password">
+ </button>
+ <button type="button"
+ class="btn btn-light"
+ cdCopy2ClipboardButton="api_password">
+ </button>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_password', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- RGW & iSCSI -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && ['rgw', 'iscsi'].includes(serviceForm.controls.service_type.value)">
+ <!-- ssl -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="ssl"
+ type="checkbox"
+ formControlName="ssl">
+ <label class="custom-control-label"
+ for="ssl"
+ i18n>SSL</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- ssl_cert -->
+ <div *ngIf="serviceForm.controls.ssl.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="ssl_cert">
+ <span i18n>Certificate</span>
+ <cd-helper i18n>The SSL certificate in PEM format.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <textarea id="ssl_cert"
+ class="form-control resize-vertical text-monospace text-pre"
+ formControlName="ssl_cert"
+ rows="5">
+ </textarea>
+ <input type="file"
+ (change)="fileUpload($event.target.files, 'ssl_cert')">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_cert', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_cert', frm, 'pattern')"
+ i18n>Invalid SSL certificate.</span>
+ </div>
+ </div>
+
+ <!-- ssl_key -->
+ <div *ngIf="serviceForm.controls.ssl.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="ssl_key">
+ <span i18n>Private key</span>
+ <cd-helper i18n>The SSL private key in PEM format.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <textarea id="ssl_key"
+ class="form-control resize-vertical text-monospace text-pre"
+ formControlName="ssl_key"
+ rows="5">
+ </textarea>
+ <input type="file"
+ (change)="fileUpload($event.target.files,'ssl_key')">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_key', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_key', frm, 'pattern')"
+ i18n>Invalid SSL private key.</span>
+ </div>
+ </div>
+ </ng-container>
+ </div>
+
+ <div class="card-footer">
+ <div class="text-right">
+ <cd-submit-button (submitAction)="onSubmit()"
+ i18n="form action button|Example: Create Pool@@formActionButton"
+ [form]="serviceForm">{{ action | titlecase }} {{ resource | upperFirst }}
+ </cd-submit-button>
+ <cd-back-button></cd-back-button>
+ </div>
+ </div>
+ </div>
+ </form>
+</div>
--- /dev/null
+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<ServiceFormComponent>;
+ 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');
+ });
+ });
+ });
+});
--- /dev/null
+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<object>;
+
+ searchLabels: Observable<any> = 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<string>) => {
+ // 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<object>) => {
+ 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();
+ }
+ });
+ }
+}
[autoReload]="5000"
(fetchData)="getServices($event)"
[hasDetails]="true"
- (setExpandedRow)="setExpandedRow($event)">
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permissions.hosts"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
<cd-service-details cdTableDetail
[permissions]="permissions"
[selection]="expandedRow">
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';
CoreModule,
SharedModule,
HttpClientTestingModule,
- RouterTestingModule
+ RouterTestingModule,
+ ToastrModule.forRoot()
],
providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }, i18nProviders],
declarations: []
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 })
permissions: Permissions;
showDocPanel = false;
+ tableActions: CdTableAction[];
checkingOrchestrator = true;
hasOrchestrator = false;
columns: Array<CdTableColumn> = [];
services: Array<CephServiceSpec> = [];
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() {
{
name: this.i18n('Running'),
prop: 'status.running',
- flexGrow: 1,
- cellClass: 'text-center',
- cellTransformation: CellTemplate.checkIcon
+ flexGrow: 1
},
{
name: this.i18n('Size'),
}
);
}
+
+ 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();
+ })
+ )
+ }
+ });
+ }
}
for="password">
<ng-container i18n>Password</ng-container>
<cd-helper *ngIf="passwordPolicyHelpText.length > 0"
- class="text-pre"
+ class="text-pre-wrap"
html="{{ passwordPolicyHelpText }}">
</cd-helper>
</label>
[ngClass]="{'required': pwdExpirationSettings.pwdExpirationSpan > 0}"
for="pwdExpirationDate">
<ng-container i18n>Password expiration date</ng-container>
- <cd-helper class="text-pre"
+ <cd-helper class="text-pre-wrap"
*ngIf="pwdExpirationSettings.pwdExpirationSpan == 0">
<p>
The Dashboard setting defining the expiration interval of
<span class="required"
i18n>New password</span>
<cd-helper *ngIf="passwordPolicyHelpText.length > 0"
- class="text-pre"
+ class="text-pre-wrap"
html="{{ passwordPolicyHelpText }}">
</cd-helper>
</label>
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';
getDaemons(serviceName?: string): Observable<Daemon[]> {
return this.http.get<Daemon[]>(`${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<string[]> {
+ return this.http.get<string[]>(`${this.url}/known_types`);
+ }
}
constructor(private http: HttpClient, private deviceService: DeviceService) {}
- list() {
- return this.http.get(this.baseURL);
+ list(): Observable<object[]> {
+ return this.http.get<object[]>(this.baseURL);
}
create(hostname: string) {
describe('requiredIf', () => {
beforeEach(() => {
form = new CdFormGroup({
+ a: new FormControl(''),
+ b: new FormControl('xyz'),
x: new FormControl(true),
y: new FormControl('abc'),
z: new FormControl('')
);
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', () => {
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'
+ );
+ });
+ });
});
});
/**
* 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
/**
* 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) {
/**
* 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) {
}
}
+ /**
+ * 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': '<OPERATOR>', arg1: '<OPERATOR_ARGUMENT>' }
+ * The following operators are supported:
+ * * empty
+ * * !empty
+ * * equal
+ * * !equal
+ * * minLength
* ### Example
* ```typescript
* {
* '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
// 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;
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)
)
};
});
}
+ 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
.text-monospace {
font-family: monospace;
}
-.text-pre {
+.text-pre-wrap {
white-space: pre-wrap;
}
+.text-pre {
+ white-space: pre;
+}
/* Buttons */
.btn-light {
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')
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