]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Create Ceph services via Orchestrator by using ServiceSpec 38888/head
authorVolker Theile <vtheile@suse.com>
Mon, 20 Jul 2020 12:45:36 +0000 (14:45 +0200)
committerVolker Theile <vtheile@suse.com>
Thu, 14 Jan 2021 14:16:42 +0000 (15:16 +0100)
Fixes: https://tracker.ceph.com/issues/44831
Signed-off-by: Volker Theile <vtheile@suse.com>
(cherry picked from commit dc5e5a5980456cffa468f88bc0d51cbb3c32dd06)

Conflicts:
- src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts
  * Use i18n instead of $localize
  * Remove CdForm class
  * Adapt code to ngx-bootstrap. Typeahead works a little bit different than in Pacific/ng-bootstrap.
  * Adapt to older TypeScript version
- src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts
  * Adapt code to ngx-bootstrap.
- src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts
  * Use i18n instead of $localize
  * Replace ModalService by BsModalService
- src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
  * Use i18n instead of $localize
- src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss
  * Relocate changes to src/pybind/mgr/dashboard/frontend/src/styles.scss

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.scss
src/pybind/mgr/dashboard/services/orchestrator.py

index 509a2147a685a841b65829e00b6ddb0397bc9d95..86c774d145964c11cf41455476558d9b64b318db 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=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 41f4e239ad32f4592796fcb951e37fe3d9e20100..91af17f809bcb07b0c29fca85b21b20834ffe950 100644 (file)
@@ -18,6 +18,7 @@ import { OsdFormComponent } from './ceph/cluster/osd/osd-form/osd-form.component
 import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component';
 import { MonitoringListComponent } from './ceph/cluster/prometheus/monitoring-list/monitoring-list.component';
 import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component';
+import { ServiceFormComponent } from './ceph/cluster/services/service-form/service-form.component';
 import { ServicesComponent } from './ceph/cluster/services/services.component';
 import { TelemetryComponent } from './ceph/cluster/telemetry/telemetry.component';
 import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
@@ -102,8 +103,15 @@ const routes: Routes = [
       },
       {
         path: 'services',
-        component: ServicesComponent,
-        data: { breadcrumbs: 'Cluster/Services' }
+        data: { breadcrumbs: 'Cluster/Services' },
+        children: [
+          { path: '', component: ServicesComponent },
+          {
+            path: URLVerbs.CREATE,
+            component: ServiceFormComponent,
+            data: { breadcrumbs: ActionLabels.CREATE }
+          }
+        ]
       },
       {
         path: 'inventory',
index 141af9d8bf2db930023524e73247f15a47d194c6..d380f42197fe89375d536733e34b84731d6f8b6e 100644 (file)
@@ -51,6 +51,7 @@ import { SilenceListComponent } from './prometheus/silence-list/silence-list.com
 import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component';
 import { ServiceDaemonListComponent } from './services/service-daemon-list/service-daemon-list.component';
 import { ServiceDetailsComponent } from './services/service-details/service-details.component';
+import { ServiceFormComponent } from './services/service-form/service-form.component';
 import { ServicesComponent } from './services/services.component';
 import { TelemetryComponent } from './telemetry/telemetry.component';
 
@@ -127,7 +128,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component';
     ServiceDetailsComponent,
     ServiceDaemonListComponent,
     TelemetryComponent,
-    OsdFlagsIndivModalComponent
+    OsdFlagsIndivModalComponent,
+    ServiceFormComponent
   ]
 })
 export class ClusterModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html
new file mode 100644 (file)
index 0000000..63e0054
--- /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"
+                   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>
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..33a05de
--- /dev/null
@@ -0,0 +1,340 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import * as _ from 'lodash';
+import { TypeaheadModule } from 'ngx-bootstrap/typeahead';
+import { ToastrModule } from 'ngx-toastr';
+
+import {
+  configureTestBed,
+  FormHelper,
+  i18nProviders
+} from '../../../../../testing/unit-test-helper';
+import { CephServiceService } from '../../../../shared/api/ceph-service.service';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+import { SharedModule } from '../../../../shared/shared.module';
+import { ServiceFormComponent } from './service-form.component';
+
+describe('ServiceFormComponent', () => {
+  let component: ServiceFormComponent;
+  let fixture: ComponentFixture<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');
+      });
+    });
+  });
+});
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..f52c9d3
--- /dev/null
@@ -0,0 +1,328 @@
+import { Component, OnInit } from '@angular/core';
+import { AbstractControl, Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { CephServiceService } from '../../../../shared/api/ceph-service.service';
+import { HostService } from '../../../../shared/api/host.service';
+import { PoolService } from '../../../../shared/api/pool.service';
+import { SelectMessages } from '../../../../shared/components/select/select-messages.model';
+import { SelectOption } from '../../../../shared/components/select/select-option.model';
+import { ActionLabelsI18n, URLVerbs } from '../../../../shared/constants/app.constants';
+import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../../shared/forms/cd-validators';
+import { FinishedTask } from '../../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service';
+
+@Component({
+  selector: 'cd-service-form',
+  templateUrl: './service-form.component.html',
+  styleUrls: ['./service-form.component.scss']
+})
+export class ServiceFormComponent implements OnInit {
+  serviceForm: CdFormGroup;
+  action: string;
+  resource: string;
+  serviceTypes: string[] = [];
+  hosts: any;
+  labels: string[];
+  pools: Array<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();
+        }
+      });
+  }
+}
index 11305cfd6e1846aff06fa77420f679e7a41012ef..45269d3bd39dc9c52c1a6cb6270355ddb3f88c54 100644 (file)
@@ -9,7 +9,13 @@
             [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">
index 71740ef836777c6f5f670b64df496b2cbce9bfda..37369d68a4efe16c04861fc71e151e40a0f39acc 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, i18nProviders } from '../../../../testing/unit-test-helper';
@@ -58,7 +59,8 @@ describe('ServicesComponent', () => {
       CoreModule,
       SharedModule,
       HttpClientTestingModule,
-      RouterTestingModule
+      RouterTestingModule,
+      ToastrModule.forRoot()
     ],
     providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }, i18nProviders],
     declarations: []
index ecccb9031e2b4ac352cb23c04687edca488fc8d7..b27647e5bc8010759023450acca4e6f90e941c36 100644 (file)
@@ -1,22 +1,35 @@
 import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
 import { I18n } from '@ngx-translate/i18n-polyfill';
 
+import { BsModalService } from 'ngx-bootstrap/modal';
+import { delay, finalize } from 'rxjs/operators';
+
 import { CephServiceService } from '../../../shared/api/ceph-service.service';
 import { OrchestratorService } from '../../../shared/api/orchestrator.service';
 import { ListWithDetails } from '../../../shared/classes/list-with-details.class';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n, URLVerbs } from '../../../shared/constants/app.constants';
 import { TableComponent } from '../../../shared/datatable/table/table.component';
 import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { Icons } from '../../../shared/enum/icons.enum';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { FinishedTask } from '../../../shared/models/finished-task';
 import { Permissions } from '../../../shared/models/permissions';
 import { CephServiceSpec } from '../../../shared/models/service.interface';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { URLBuilderService } from '../../../shared/services/url-builder.service';
+
+const BASE_URL = 'services';
 
 @Component({
   selector: 'cd-services',
   templateUrl: './services.component.html',
-  styleUrls: ['./services.component.scss']
+  styleUrls: ['./services.component.scss'],
+  providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
 })
 export class ServicesComponent extends ListWithDetails implements OnChanges, OnInit {
   @ViewChild(TableComponent, { static: false })
@@ -29,6 +42,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
 
   permissions: Permissions;
   showDocPanel = false;
+  tableActions: CdTableAction[];
 
   checkingOrchestrator = true;
   hasOrchestrator = false;
@@ -36,16 +50,36 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
   columns: Array<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() {
@@ -72,9 +106,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
       {
         name: this.i18n('Running'),
         prop: 'status.running',
-        flexGrow: 1,
-        cellClass: 'text-center',
-        cellTransformation: CellTemplate.checkIcon
+        flexGrow: 1
       },
       {
         name: this.i18n('Size'),
@@ -122,4 +154,39 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
       }
     );
   }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  deleteAction() {
+    const service = this.selection.first();
+    this.bsModalService.show(CriticalConfirmationModalComponent, {
+      initialState: {
+        itemDescription: this.i18n('Service'),
+        itemNames: [service.service_name],
+        actionDescription: 'delete',
+        submitActionObservable: () =>
+          this.taskWrapperService
+            .wrapTaskAroundCall({
+              task: new FinishedTask(`service/${URLVerbs.DELETE}`, {
+                service_name: service.service_name
+              }),
+              call: this.cephServiceService.delete(service.service_name)
+            })
+            .pipe(
+              // Delay closing the dialog, otherwise the datatable still
+              // shows the deleted service after forcing a reload.
+              // Showing the dialog while delaying is done to increase
+              // the user experience.
+              delay(2000),
+              finalize(() => {
+                // Force reloading the data table content because it is
+                // auto-reloaded only every 60s.
+                this.table.refreshBtn();
+              })
+            )
+      }
+    });
+  }
 }
index 3793dabc462e9b606f3bca03f8f42f6a3d1632e0..580b0365a597364d65bae7a0cf318109078470d9 100644 (file)
@@ -43,7 +43,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 08d1c1c75e571aa05811ea3553ba11e14e612adb..c8b67224f839e8df938f68ffa0bdb2cf715a69d2 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 f1e4003b11b9340e6952efbfefed9f1aa3048c98..2a857dcaadb8475fcbf4d7fcd33e92d39cd3bb1d 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';
@@ -25,4 +26,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 4d296db7835ca46e0b9c86636ed31964345cbb0e..cfe53305d431852976a721d993ec753daee0fb82 100644 (file)
@@ -18,8 +18,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 b0cd8133edad204d66ca8e813a813a6513c900e2..6edfb163690492f27e47cb00d0be6a75c5dccf88 100644 (file)
@@ -36,10 +36,10 @@ export class CdValidators {
   /**
    * Validator function in order to validate IP addresses.
    * @param {number} version determines the protocol version. It needs to be set to 4 for IPv4 and
-   * to 6 for IPv6 validation. For any other number (it's also the default case) it will return a
-   * function to validate the input string against IPv4 OR IPv6.
+   *   to 6 for IPv6 validation. For any other number (it's also the default case) it will return a
+   *   function to validate the input string against IPv4 OR IPv6.
    * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
-   * if the validation failed, otherwise `null`.
+   *   if the validation check fails, otherwise `null`.
    */
   static ip(version: number = 0): ValidatorFn {
     // prettier-ignore
@@ -59,7 +59,7 @@ export class CdValidators {
   /**
    * Validator function in order to validate numbers.
    * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
-   * if the validation failed, otherwise `null`.
+   *   if the validation check fails, otherwise `null`.
    */
   static number(allowsNegative: boolean = true): ValidatorFn {
     if (allowsNegative) {
@@ -72,7 +72,7 @@ export class CdValidators {
   /**
    * Validator function in order to validate decimal numbers.
    * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
-   * if the validation failed, otherwise `null`.
+   *   if the validation check fails, otherwise `null`.
    */
   static decimalNumber(allowsNegative: boolean = true): ValidatorFn {
     if (allowsNegative) {
@@ -82,12 +82,43 @@ export class CdValidators {
     }
   }
 
+  /**
+   * Validator that performs SSL certificate validation.
+   * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+   *   if the validation check fails, otherwise `null`.
+   */
+  static sslCert(): ValidatorFn {
+    return Validators.pattern(
+      /^-----BEGIN CERTIFICATE-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END CERTIFICATE-----[\n\r\f]*$/
+    );
+  }
+
+  /**
+   * Validator that performs SSL private key validation.
+   * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+   *   if the validation check fails, otherwise `null`.
+   */
+  static sslPrivKey(): ValidatorFn {
+    return Validators.pattern(
+      /^-----BEGIN RSA PRIVATE KEY-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END RSA PRIVATE KEY-----[\n\r\f]*$/
+    );
+  }
+
   /**
    * Validator that requires controls to fulfill the specified condition if
    * the specified prerequisites matches. If the prerequisites are fulfilled,
    * then the given function is executed and if it succeeds, the 'required'
    * validation error will be returned, otherwise null.
    * @param {Object} prerequisites An object containing the prerequisites.
+   *   To do additional checks rather than checking for equality you can
+   *   use the extended prerequisite syntax:
+   *     'field_name': { 'op': '<OPERATOR>', arg1: '<OPERATOR_ARGUMENT>' }
+   *   The following operators are supported:
+   *   * empty
+   *   * !empty
+   *   * equal
+   *   * !equal
+   *   * minLength
    *   ### Example
    *   ```typescript
    *   {
@@ -95,6 +126,13 @@ export class CdValidators {
    *     'username': 'Max Mustermann'
    *   }
    *   ```
+   *   ### Example - Extended prerequisites
+   *   ```typescript
+   *   {
+   *     'generate_key': { 'op': 'equal', 'arg1': true },
+   *     'username': { 'op': 'minLength', 'arg1': 5 }
+   *   }
+   *   ```
    *   Only if all prerequisites are fulfilled, then the validation of the
    *   control will be triggered.
    * @param {Function | undefined} condition The function to be executed when all
@@ -120,7 +158,35 @@ export class CdValidators {
       // Check if all prerequisites met.
       if (
         !Object.keys(prerequisites).every((key) => {
-          return control.parent && control.parent.get(key).value === prerequisites[key];
+          if (!control.parent) {
+            return false;
+          }
+          const value = control.parent.get(key).value;
+          const prerequisite = prerequisites[key];
+          if (_.isObjectLike(prerequisite)) {
+            let result = false;
+            switch (prerequisite['op']) {
+              case 'empty':
+                result = _.isEmpty(value);
+                break;
+              case '!empty':
+                result = !_.isEmpty(value);
+                break;
+              case 'equal':
+                result = value === prerequisite['arg1'];
+                break;
+              case '!equal':
+                result = value !== prerequisite['arg1'];
+                break;
+              case 'minLength':
+                if (_.isString(value)) {
+                  result = value.length >= prerequisite['arg1'];
+                }
+                break;
+            }
+            return result;
+          }
+          return value === prerequisite;
         })
       ) {
         return null;
index 3047a287db329ad297e8b6c63eb3cbb54fb62233..8e7c01ac65f3cf0e0cfbf17a7c0272b9222faeef 100644 (file)
@@ -429,6 +429,13 @@ export class TaskMessageService {
         this.i18n('Identified')
       ),
       (metadata) => this.i18n(`device '{{device}}' on host '{{hostname}}'`, metadata)
+    ),
+    // Service tasks
+    'service/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+      this.service(metadata)
+    ),
+    'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+      this.service(metadata)
     )
   };
 
@@ -476,6 +483,10 @@ export class TaskMessageService {
     });
   }
 
+  service(metadata: any) {
+    return this.i18n(`Service '{{service_name}}'`, { service_name: metadata.service_name });
+  }
+
   _getTaskTitle(task: Task) {
     if (task.name && task.name.startsWith('progress/')) {
       // we don't fill the failure string because, at least for now, all
index 212d10c92f173f6c2f7422ccc443d0269903b50d..0517101c0401a2c308cb5fe7efd3669c45afa5b8 100644 (file)
@@ -117,9 +117,12 @@ option {
 .text-monospace {
   font-family: monospace;
 }
-.text-pre {
+.text-pre-wrap {
   white-space: pre-wrap;
 }
+.text-pre {
+  white-space: pre;
+}
 
 /* Buttons */
 .btn-light {
index a1f22a9a0673a6190c1e3fd81134a364986bb4d2..af68e01dbf27b264eea7fbd5cd9f9a5d2f44a9cb 100644 (file)
@@ -2,14 +2,15 @@
 from __future__ import absolute_import
 import logging
 
-from typing import List, Optional
+from functools import wraps
+from typing import List, Optional, Dict
 
+from ceph.deployment.service_spec import ServiceSpec
 from orchestrator import InventoryFilter, DeviceLightLoc, Completion
 from orchestrator import ServiceDescription, DaemonDescription
 from orchestrator import OrchestratorClientMixin, raise_if_exception, OrchestratorError
 from orchestrator import HostSpec
 from .. import mgr
-from ..tools import wraps
 
 logger = logging.getLogger('orchestrator')
 
@@ -115,6 +116,15 @@ class ServiceManager(ResourceManager):
         for c in completion_list:
             raise_if_exception(c)
 
+    @wait_api_result
+    def apply(self, service_spec: Dict) -> Completion:
+        spec = ServiceSpec.from_json(service_spec)
+        return self.api.apply([spec])
+
+    @wait_api_result
+    def remove(self, service_name: str) -> List[str]:
+        return self.api.remove_service(service_name)
+
 
 class OsdManager(ResourceManager):
     @wait_api_result