]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Create Ceph services via Orchestrator by using ServiceSpec 36119/head
authorVolker Theile <vtheile@suse.com>
Mon, 20 Jul 2020 12:45:36 +0000 (14:45 +0200)
committerVolker Theile <vtheile@suse.com>
Fri, 31 Jul 2020 10:19:52 +0000 (12:19 +0200)
Fixes: https://tracker.ceph.com/issues/44831
Signed-off-by: Volker Theile <vtheile@suse.com>
19 files changed:
src/pybind/mgr/dashboard/controllers/service.py
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss
src/pybind/mgr/dashboard/services/orchestrator.py

index 9f1e70bb94dd8688a00d9cc9eccf27b9b28fade7..baacff080ddb205f9677ad38394b3330f9d444e9 100644 (file)
@@ -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)
index 5e686424f2b5428d32baa6b5adcbc5ba37f687e5..12330c96b319fab45cc23ed21ffacfb3405092ed 100644 (file)
@@ -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',
index 5b805d71ec305980b23dad8aa9df06f334f2396f..909cec861cf58349d0d9932a767fb989e1171658 100644 (file)
@@ -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 (file)
index 0000000..0591879
--- /dev/null
@@ -0,0 +1,415 @@
+<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>&lt;realm_name&gt;.&lt;zone_name&gt;[.&lt;subcluster&gt;]</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"
+                   [ngbTypeahead]="searchLabels"
+                   (focus)="labelFocus.next($any($event).target.value)"
+                   (click)="labelClick.next($any($event).target.value)">
+            <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>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..50d76cc
--- /dev/null
@@ -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<ServiceFormComponent>;
+  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 (file)
index 0000000..4bf6d36
--- /dev/null
@@ -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<string>();
+  labelFocus = new Subject<string>();
+  pools: Array<object>;
+
+  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<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']);
+  }
+
+  searchLabels = (text$: Observable<string>) => {
+    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<FileReader>) => {
+      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();
+        }
+      });
+  }
+}
index 11174d3849d045ba7f03cf1b593f10c4b5d984a5..64ec411aca6392deea81789b3dc745e616f9e6f9 100644 (file)
@@ -9,7 +9,13 @@
             [autoReload]="60000"
             (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">
index b007f1ee097bc3a9261f128e9f0aa87d0dbe397a..8fa09bb0616e85b0a1662e2eed0cb0f2013637ce 100644 (file)
@@ -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 }]
   });
index 48f5acc293d49cff206fec014d9a9aa5c2912f7d..a753c8c3760ea5f32a90831a13ca600f14246390 100644 (file)
@@ -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<CdTableColumn> = [];
   services: Array<CephServiceSpec> = [];
   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();
+            })
+          )
+    });
+  }
 }
index 9825cb25bca44d124d041f70298d6844b3aff1c2..5ad0cbf5ded0b1de5b80b3793b1b0aed5dd2b943 100644 (file)
@@ -38,7 +38,7 @@
                  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
index cd1819af071a489b21f9adc4d97ab5d087e91c61..cbde4f7d6552c20f177feae05c45be566def60ff 100644 (file)
@@ -43,7 +43,7 @@
             <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>
index 87d67d8cd49007590c80f4efc8f72fd9ce3d1add..367a081f6f1a8e647bd8c649937d144c99b5583e 100644 (file)
@@ -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<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`);
+  }
 }
index f016f2c11da6b5b7915871803add578c4646a3ef..3b9e7068e7768e29aae6e9bee623ecd2c3d68956 100644 (file)
@@ -17,8 +17,8 @@ export class HostService {
 
   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) {
index 1c85540d78b89d47ba30d571e703f3522f6d791c..7af3890913da01dc66912e6de2ceba6f12532eff 100644 (file)
@@ -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'
+        );
+      });
+    });
   });
 });
index bcc5629a9e00a2525f435075c79c522c86c0020a..d72bcdc7c1ef30860d32cd90c890305a20ab9281 100644 (file)
@@ -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': '<OPERATOR>', arg1: '<OPERATOR_ARGUMENT>' }
+   *   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;
index 026765a14ce8f5c10cb44589ad0d2b0e1e16a097..528ad82c6b0244bc37f3ac0bf8131f065300ff35 100644 (file)
@@ -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
index 2de197a9e925870cac068a92f5cabe74073a5519..3e60bd81d6c21ea3f55fb1aff6e0cb7ec64c7716 100644 (file)
@@ -66,6 +66,10 @@ option {
   font-family: monospace;
 }
 
-.text-pre {
+.text-pre-wrap {
   white-space: pre-wrap;
 }
+
+.text-pre {
+  white-space: pre;
+}
index b5cf7142e8a4d24b99239700cd2bf950331e6cad..282674ee159646baf886f1af0143836273efeb0c 100644 (file)
@@ -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