# -*- coding: utf-8 -*-
import http.cookies
+import json
import logging
import sys
+import cherrypy
+
from .. import mgr
from ..exceptions import InvalidCredentialsError, UserDoesNotExist
from ..services.auth import AuthManager, JwtManager
"""
Provide authenticates and returns JWT token.
"""
-
+ # pylint: disable=R0912
def create(self, username, password):
user_data = AuthManager.authenticate(username, password)
user_perms, pwd_expiration_date, pwd_update_required = None, None, None
max_attempt = Settings.ACCOUNT_LOCKOUT_ATTEMPTS
+ origin = cherrypy.request.headers.get('Origin', None)
+ try:
+ fsid = mgr.get('config')['fsid']
+ except KeyError:
+ fsid = ''
if max_attempt == 0 or mgr.ACCESS_CTRL_DB.get_attempt(username) < max_attempt:
if user_data:
user_perms = user_data.get('permissions')
pwd_expiration_date = user_data.get('pwdExpirationDate', None)
pwd_update_required = user_data.get('pwdUpdateRequired', False)
+ if isinstance(Settings.MULTICLUSTER_CONFIG, str):
+ try:
+ item_to_dict = json.loads(Settings.MULTICLUSTER_CONFIG)
+ except json.JSONDecodeError:
+ item_to_dict = {}
+ multicluster_config = item_to_dict.copy()
+ else:
+ multicluster_config = Settings.MULTICLUSTER_CONFIG.copy()
+ try:
+ if fsid in multicluster_config['config']:
+ existing_entries = multicluster_config['config'][fsid]
+ if not any(entry['user'] == username for entry in existing_entries):
+ existing_entries.append({
+ "name": fsid,
+ "url": origin,
+ "cluster_alias": "local-cluster",
+ "user": username
+ })
+ else:
+ multicluster_config['config'][fsid] = [{
+ "name": fsid,
+ "url": origin,
+ "cluster_alias": "local-cluster",
+ "user": username
+ }]
+
+ except KeyError:
+ multicluster_config = {
+ 'current_url': origin,
+ 'current_user': username,
+ 'hub_url': origin,
+ 'config': {
+ fsid: [
+ {
+ "name": fsid,
+ "url": origin,
+ "cluster_alias": "local-cluster",
+ "user": username
+ }
+ ]
+ }
+ }
+ Settings.MULTICLUSTER_CONFIG = multicluster_config
+
if user_perms is not None:
url_prefix = 'https' if mgr.get_localized_module_option('ssl') else 'http'
from ..settings import Settings
from ..tools import configure_cors
from . import APIDoc, APIRouter, CreatePermission, Endpoint, EndpointDoc, \
- RESTController, UIRouter, UpdatePermission
+ ReadPermission, RESTController, UIRouter, UpdatePermission
@APIRouter('/multi-cluster', Scope.CONFIG_OPT)
@APIDoc('Multi-cluster Management API', 'Multi-cluster')
class MultiCluster(RESTController):
- def _proxy(self, method, base_url, path, params=None, payload=None, verify=False, token=None):
+ def _proxy(self, method, base_url, path, params=None, payload=None, verify=False,
+ token=None):
try:
if token:
headers = {
@Endpoint('POST')
@CreatePermission
@EndpointDoc("Authenticate to a remote cluster")
- def auth(self, url: str, name: str, username=None, password=None, token=None, hub_url=None):
- multicluster_config = {}
+ def auth(self, url: str, cluster_alias: str, username=None,
+ password=None, token=None, hub_url=None):
- if isinstance(Settings.MULTICLUSTER_CONFIG, str):
- try:
- item_to_dict = json.loads(Settings.MULTICLUSTER_CONFIG)
- except json.JSONDecodeError:
- item_to_dict = {}
- multicluster_config = item_to_dict.copy()
- else:
- multicluster_config = Settings.MULTICLUSTER_CONFIG.copy()
-
- if 'hub_url' not in multicluster_config:
- multicluster_config['hub_url'] = hub_url
-
- if 'config' not in multicluster_config:
- multicluster_config['config'] = []
-
- if token:
- multicluster_config['config'].append({
- 'name': name,
- 'url': url,
- 'token': token
- })
+ multi_cluster_config = self.load_multi_cluster_config()
- Settings.MULTICLUSTER_CONFIG = multicluster_config
- return
+ if not url.endswith('/'):
+ url = url + '/'
if username and password:
payload = {
component='dashboard')
token = content['token']
- # Set CORS endpoint on remote cluster
+
+ if token:
self._proxy('PUT', url, 'ui-api/multi-cluster/set_cors_endpoint',
- payload={'url': multicluster_config['hub_url']}, token=token)
+ payload={'url': hub_url}, token=token)
+ fsid = self._proxy('GET', url, 'api/health/get_cluster_fsid', token=token)
+ content = self._proxy('POST', url, 'api/auth/check', payload={'token': token},
+ token=token)
+ if 'username' in content:
+ username = content['username']
+
+ if 'config' not in multi_cluster_config:
+ multi_cluster_config['config'] = {}
+
+ if fsid in multi_cluster_config['config']:
+ existing_entries = multi_cluster_config['config'][fsid]
+ if not any(entry['user'] == username for entry in existing_entries):
+ existing_entries.append({
+ "name": fsid,
+ "url": url,
+ "cluster_alias": cluster_alias,
+ "user": username,
+ "token": token,
+ })
+ else:
+ multi_cluster_config['current_user'] = username
+ multi_cluster_config['config'][fsid] = [{
+ "name": fsid,
+ "url": url,
+ "cluster_alias": cluster_alias,
+ "user": username,
+ "token": token,
+ }]
+
+ Settings.MULTICLUSTER_CONFIG = multi_cluster_config
+
+ def load_multi_cluster_config(self):
+ if isinstance(Settings.MULTICLUSTER_CONFIG, str):
+ try:
+ itemw_to_dict = json.loads(Settings.MULTICLUSTER_CONFIG)
+ except json.JSONDecodeError:
+ itemw_to_dict = {}
+ multi_cluster_config = itemw_to_dict.copy()
+ else:
+ multi_cluster_config = Settings.MULTICLUSTER_CONFIG.copy()
+
+ return multi_cluster_config
+
+ @Endpoint('PUT')
+ @UpdatePermission
+ def set_config(self, config: object):
+ multicluster_config = self.load_multi_cluster_config()
+ multicluster_config.update({'current_url': config['url']})
+ multicluster_config.update({'current_user': config['user']})
+ Settings.MULTICLUSTER_CONFIG = multicluster_config
+ return Settings.MULTICLUSTER_CONFIG
+
+ @Endpoint('POST')
+ @CreatePermission
+ # pylint: disable=R0911
+ def verify_connection(self, url: str, username=None, password=None, token=None):
+ if not url.endswith('/'):
+ url = url + '/'
- multicluster_config['config'].append({
- 'name': name,
- 'url': url,
- 'token': token
- })
+ if token:
+ try:
+ payload = {
+ 'token': token
+ }
+ content = self._proxy('POST', url, 'api/auth/check', payload=payload)
+ if 'permissions' not in content:
+ return content['detail']
+ user_content = self._proxy('GET', url, f'api/user/{username}',
+ token=content['token'])
+ if 'status' in user_content and user_content['status'] == '403 Forbidden':
+ return 'User is not an administrator'
+ except Exception as e: # pylint: disable=broad-except
+ if '[Errno 111] Connection refused' in str(e):
+ return 'Connection refused'
+ return 'Connection failed'
- Settings.MULTICLUSTER_CONFIG = multicluster_config
+ if username and password:
+ try:
+ payload = {
+ 'username': username,
+ 'password': password
+ }
+ content = self._proxy('POST', url, 'api/auth', payload=payload)
+ if 'token' not in content:
+ return content['detail']
+ user_content = self._proxy('GET', url, f'api/user/{username}',
+ token=content['token'])
+ if 'status' in user_content and user_content['status'] == '403 Forbidden':
+ return 'User is not an administrator'
+ except Exception as e: # pylint: disable=broad-except
+ if '[Errno 111] Connection refused' in str(e):
+ return 'Connection refused'
+ return 'Connection failed'
+ return 'Connection successful'
+
+ @Endpoint()
+ @ReadPermission
+ def get_config(self):
+ return Settings.MULTICLUSTER_CONFIG
@UIRouter('/multi-cluster', Scope.CONFIG_OPT)
import { UpgradeComponent } from './ceph/cluster/upgrade/upgrade.component';
import { CephfsVolumeFormComponent } from './ceph/cephfs/cephfs-form/cephfs-form.component';
import { UpgradeProgressComponent } from './ceph/cluster/upgrade/upgrade-progress/upgrade-progress.component';
+import { MultiClusterComponent } from './ceph/cluster/multi-cluster/multi-cluster.component';
@Injectable()
export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
}
]
},
+ {
+ path: 'multi-cluster',
+ component: MultiClusterComponent
+ },
{
path: 'inventory',
canActivate: [ModuleStatusGuardService],
import { UpgradeComponent } from './upgrade/upgrade.component';
import { UpgradeStartModalComponent } from './upgrade/upgrade-form/upgrade-start-modal.component';
import { UpgradeProgressComponent } from './upgrade/upgrade-progress/upgrade-progress.component';
+import { MultiClusterComponent } from './multi-cluster/multi-cluster.component';
+import { MultiClusterFormComponent } from './multi-cluster/multi-cluster-form/multi-cluster-form.component';
@NgModule({
imports: [
CreateClusterReviewComponent,
UpgradeComponent,
UpgradeStartModalComponent,
- UpgradeProgressComponent
+ UpgradeProgressComponent,
+ MultiClusterComponent,
+ MultiClusterFormComponent
],
providers: [NgbActiveModal]
})
--- /dev/null
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">Connect Cluster
+ </ng-container>
+ <ng-container class="modal-content">
+ <form name="remoteClusterForm"
+ #frm="ngForm"
+ [formGroup]="remoteClusterForm">
+ <div class="modal-body">
+ <cd-alert-panel *ngIf="connectionVerified !== undefined && !connectionVerified && connectionMessage !== 'Connection refused'"
+ type="error"
+ spacingClass="mb-3"
+ i18n>{{ connectionMessage }}
+ </cd-alert-panel>
+ <cd-alert-panel *ngIf="connectionVerified !== undefined && connectionVerified"
+ type="success"
+ spacingClass="mb-3"
+ i18n>{{ connectionMessage }}
+ </cd-alert-panel>
+ <cd-alert-panel type="info"
+ spacingClass="mb-3"
+ i18n
+ *ngIf="connectionVerified !== undefined && !connectionVerified && connectionMessage === 'Connection refused'">
+ <p>You need to set this cluster's url as the cross origin url in the remote cluster you are trying to connect.
+ You can do it by running this CLI command in your remote cluster and proceed with authentication via token.</p>
+ <cd-code-block [codes]="[crossOriginCmd]"></cd-code-block>
+ </cd-alert-panel>
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="remoteClusterUrl"
+ i18n>Cluster API URL
+ <cd-helper>Enter the Dashboard API URL. You can retrieve it from the CLI with: <b>ceph mgr services</b></cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="https://localhost:4202"
+ id="remoteClusterUrl"
+ name="remoteClusterUrl"
+ formControlName="remoteClusterUrl">
+ <span class="invalid-feedback"
+ *ngIf="remoteClusterForm.showError('remoteClusterUrl', frm, 'required')"
+ i18n>This field is required.
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="remoteClusterForm.showError('remoteClusterUrl', frm, 'endpoint')"
+ i18n>Please enter a valid URL.
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="clusterAlias"
+ i18n>Alias Name
+ </label>
+ <div class="cd-col-form-input">
+ <input id="clusterAlias"
+ name="clusterAlias"
+ class="form-control"
+ type="text"
+ placeholder="Name/Text to uniquely identify cluster"
+ formControlName="clusterAlias">
+ <span class="invalid-feedback"
+ *ngIf="remoteClusterForm.showError('clusterAlias', frm, 'required')"
+ i18n>This field is required.
+ </span>
+ </div>
+ </div>
+ <div class="form-group row"
+ *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError">
+ <label class="cd-col-form-label required"
+ for="apiToken"
+ i18n>Username
+ </label>
+ <div class="cd-col-form-input">
+ <input id="username"
+ name="username"
+ class="form-control"
+ type="text"
+ formControlName="username">
+ <span class="invalid-feedback"
+ *ngIf="remoteClusterForm.showError('username', frm, 'required')"
+ i18n>This field is required.
+ </span>
+ </div>
+ </div>
+ <div class="form-group row"
+ *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError">
+ <label class="cd-col-form-label required"
+ for="password"
+ i18n>Password
+ </label>
+ <div class="cd-col-form-input">
+ <input id="password"
+ name="password"
+ class="form-control"
+ type="password"
+ formControlName="password">
+ <span class="invalid-feedback"
+ *ngIf="remoteClusterForm.showError('password', frm, 'required')"
+ i18n>This field is required.
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="showToken"
+ type="checkbox"
+ (click)="showToken = !showToken"
+ formControlName="showToken"
+ [readonly]="true">
+ <label class="custom-control-label"
+ for="showToken"
+ i18n>Auth with token</label>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row"
+ *ngIf="remoteClusterForm.getValue('showToken')">
+ <label class="cd-col-form-label required"
+ for="apiToken"
+ i18n>Token
+ </label>
+ <div class="cd-col-form-input">
+ <input id="apiToken"
+ name="apiToken"
+ class="form-control"
+ type="text"
+ formControlName="apiToken">
+ <span class="invalid-feedback"
+ *ngIf="remoteClusterForm.showError('apiToken', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <div class="form-group row"
+ *ngIf="!showCrossOriginError">
+ <div class="cd-col-form-offset">
+ <div class="custom-control">
+ <button class="btn btn-primary"
+ type="button"
+ [disabled]="(remoteClusterForm.getValue('showToken') && remoteClusterForm.getValue('apiToken') === '') || (!remoteClusterForm.getValue('showToken') && (remoteClusterForm.getValue('username') === '' || remoteClusterForm.getValue('password') === ''))"
+ (click)="verifyConnection()">
+ Verify Connection
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [submitText]="actionLabels.CONNECT"
+ [disabled]="!connectionVerified && !showCrossOriginError"
+ [form]="remoteClusterForm">
+ </cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MultiClusterFormComponent } from './multi-cluster-form.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { DatePipe } from '@angular/common';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('MultiClusterFormComponent', () => {
+ let component: MultiClusterFormComponent;
+ let fixture: ComponentFixture<MultiClusterFormComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ SharedModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ HttpClientTestingModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [MultiClusterFormComponent],
+ providers: [NgbActiveModal, NotificationService, CdDatePipe, DatePipe]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MultiClusterFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-multi-cluster-form',
+ templateUrl: './multi-cluster-form.component.html',
+ styleUrls: ['./multi-cluster-form.component.scss']
+})
+export class MultiClusterFormComponent implements OnInit, OnDestroy {
+ readonly endpoints = /^((https?:\/\/)|(www.))(?:([a-zA-Z]+)|(\d+\.\d+.\d+.\d+)):\d{2,5}\/?$/;
+ readonly ipv4Rgx = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i;
+ readonly ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i;
+ remoteClusterForm: CdFormGroup;
+ showToken = false;
+ connectionVerified: boolean;
+ connectionMessage = '';
+ private subs = new Subscription();
+ showCrossOriginError = false;
+ crossOriginCmd: string;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ public notificationService: NotificationService,
+ private multiClusterService: MultiClusterService
+ ) {
+ this.createForm();
+ }
+ ngOnInit(): void {}
+
+ createForm() {
+ this.remoteClusterForm = new CdFormGroup({
+ showToken: new FormControl(false),
+ username: new FormControl('', [
+ CdValidators.requiredIf({
+ showToken: false
+ })
+ ]),
+ password: new FormControl('', [
+ CdValidators.requiredIf({
+ showToken: false
+ })
+ ]),
+ remoteClusterUrl: new FormControl(null, {
+ validators: [
+ CdValidators.custom('endpoint', (value: string) => {
+ if (_.isEmpty(value)) {
+ return false;
+ } else {
+ return (
+ !this.endpoints.test(value) &&
+ !this.ipv4Rgx.test(value) &&
+ !this.ipv6Rgx.test(value)
+ );
+ }
+ }),
+ Validators.required
+ ]
+ }),
+ apiToken: new FormControl('', [
+ CdValidators.requiredIf({
+ showToken: true
+ })
+ ]),
+ clusterAlias: new FormControl('', {
+ validators: [Validators.required]
+ })
+ });
+ }
+
+ ngOnDestroy() {
+ this.subs.unsubscribe();
+ }
+
+ onSubmit() {
+ const url = this.remoteClusterForm.getValue('remoteClusterUrl');
+ const clusterAlias = this.remoteClusterForm.getValue('clusterAlias');
+ const username = this.remoteClusterForm.getValue('username');
+ const password = this.remoteClusterForm.getValue('password');
+ const token = this.remoteClusterForm.getValue('apiToken');
+
+ this.subs.add(
+ this.multiClusterService
+ .addCluster(url, clusterAlias, username, password, token, window.location.origin)
+ .subscribe({
+ error: () => {
+ this.remoteClusterForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Cluster added successfully`
+ );
+ this.activeModal.close();
+ }
+ })
+ );
+ }
+
+ verifyConnection() {
+ const url = this.remoteClusterForm.getValue('remoteClusterUrl');
+ const username = this.remoteClusterForm.getValue('username');
+ const password = this.remoteClusterForm.getValue('password');
+ const token = this.remoteClusterForm.getValue('apiToken');
+
+ this.subs.add(
+ this.multiClusterService
+ .verifyConnection(url, username, password, token)
+ .subscribe((resp: string) => {
+ switch (resp) {
+ case 'Connection successful':
+ this.connectionVerified = true;
+ this.connectionMessage = 'Connection Verified Successfully';
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Connection Verified Successfully`
+ );
+ break;
+
+ case 'Connection refused':
+ this.connectionVerified = false;
+ this.showCrossOriginError = true;
+ this.connectionMessage = resp;
+ this.crossOriginCmd = `ceph config set mgr mgr/dashboard/cross_origin_url ${window.location.origin} `;
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Connection to the cluster failed`
+ );
+ break;
+
+ default:
+ this.connectionVerified = false;
+ this.connectionMessage = resp;
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Connection to the cluster failed`
+ );
+ break;
+ }
+ })
+ );
+ }
+}
--- /dev/null
+<ng-template #emptyCluster>
+ <div class="container h-75">
+ <div class="row h-100 justify-content-center align-items-center">
+ <div class="blank-page">
+ <i class="mx-auto d-block"
+ [ngClass]="icons.wrench">
+ </i>
+ <div class="mt-4 text-center">
+ <h3><b>Connect Cluster </b></h3>
+ <h4 class="mt-3">Upgrade your current cluster to a multi-cluster setup effortlessly.
+ Click on the "Connect Cluster" button to begin the process.</h4>
+ </div>
+ <div class="mt-4">
+ <div class="text-center">
+ <button class="btn btn-primary"
+ (click)="openRemoteClusterInfoModal()">
+ <i class="mx-auto"
+ [ngClass]="icons.add">
+ </i> Connect Cluster
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</ng-template>
+
+<ng-template #nametpl>
+ <div class="datatable-body-cell-label">
+ <span title="{{cluster}}">
+ <a href="#">
+ {{cluster}}
+ </a>
+ </span>
+ </div>
+</ng-template>
+
+<div class="container-fluid h-100 p-4">
+ <div *ngIf="dashboardClustersMap?.size === 1">
+ <ng-container *ngTemplateOutlet="emptyCluster"></ng-container>
+ </div>
+
+ <span *ngIf="loading"
+ class="d-flex justify-content-center">
+ <i [ngClass]="[icons.large3x, icons.spinner, icons.spin]"></i>
+ </span>
+ <div *ngIf="dashboardClustersMap?.size > 1">
+ <div *ngIf="!loading">
+ <div class="mt-4">
+ <div class="text-center">
+ <button class="btn btn-primary"
+ (click)="openRemoteClusterInfoModal()">
+ <i class="mx-auto"
+ [ngClass]="icons.add">
+ </i> Connect Cluster
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+@use '../../../../styles/vendor/variables' as vv;
+
+.fa-wrench {
+ color: vv.$info;
+ font-size: 6em;
+ margin-top: 200px;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { MultiClusterComponent } from './multi-cluster.component';
+
+describe('MultiClusterComponent', () => {
+ let component: MultiClusterComponent;
+ let fixture: ComponentFixture<MultiClusterComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ declarations: [MultiClusterComponent],
+ providers: [NgbActiveModal]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MultiClusterComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { MultiClusterFormComponent } from './multi-cluster-form/multi-cluster-form.component';
+
+@Component({
+ selector: 'cd-multi-cluster',
+ templateUrl: './multi-cluster.component.html',
+ styleUrls: ['./multi-cluster.component.scss']
+})
+export class MultiClusterComponent implements OnInit {
+ @ViewChild('nameTpl', { static: true })
+ nameTpl: any;
+
+ private subs = new Subscription();
+ dashboardClustersMap: Map<string, string> = new Map<string, string>();
+ icons = Icons;
+ loading = true;
+ bsModalRef: NgbModalRef;
+
+ constructor(
+ private multiClusterService: MultiClusterService,
+ private modalService: ModalService
+ ) {}
+
+ ngOnInit(): void {
+ this.subs.add(
+ this.multiClusterService.subscribe((resp: any) => {
+ const clustersConfig = resp['config'];
+ if (clustersConfig) {
+ Object.keys(clustersConfig).forEach((clusterKey: string) => {
+ const clusterDetailsList = clustersConfig[clusterKey];
+
+ clusterDetailsList.forEach((clusterDetails: any) => {
+ const clusterUrl = clusterDetails['url'];
+ const clusterName = clusterDetails['name'];
+ this.dashboardClustersMap.set(clusterUrl, clusterName);
+ });
+ });
+
+ if (this.dashboardClustersMap.size >= 1) {
+ this.loading = false;
+ }
+ }
+ })
+ );
+ }
+
+ openRemoteClusterInfoModal() {
+ this.bsModalRef = this.modalService.show(MultiClusterFormComponent, {
+ size: 'xl'
+ });
+ }
+}
}
login() {
+ localStorage.setItem('cluster_api_url', window.location.origin);
this.authService.login(this.model).subscribe(() => {
const urlPath = this.postInstalled ? '/' : '/expand-cluster';
let url = _.get(this.route.snapshot.queryParams, 'returnUrl', urlPath);
<block-ui>
<cd-navigation>
<div class="container-fluid h-100"
- [ngClass]="{'dashboard': (router.url == '/dashboard' || router.url == '/dashboard_3'), 'rgw-dashboard': (router.url == '/rgw/overview')}">
+ [ngClass]="{'dashboard': (router.url == '/dashboard' || router.url == '/dashboard_3' || router.url == '/multi-cluster'), 'rgw-dashboard': (router.url == '/rgw/overview')}">
<cd-context></cd-context>
<cd-breadcrumbs></cd-breadcrumbs>
<router-outlet></router-outlet>
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
import { FaviconService } from '~/app/shared/services/favicon.service';
import { SummaryService } from '~/app/shared/services/summary.service';
public router: Router,
private summaryService: SummaryService,
private taskManagerService: TaskManagerService,
+ private multiClusterService: MultiClusterService,
private faviconService: FaviconService
) {}
ngOnInit() {
+ this.subs.add(this.multiClusterService.startPolling());
this.subs.add(this.summaryService.startPolling());
this.subs.add(this.taskManagerService.init(this.summaryService));
this.faviconService.init();
<div class="collapse navbar-collapse"
[ngClass]="{'show': rightSidebarOpen}">
+ <ng-container *ngIf="clustersMap?.size > 1">
+ <div ngbDropdown
+ placement="bottom-left"
+ class="d-inline-block ms-5">
+ <button ngbDropdownToggle
+ class="btn btn-outline-light cd-context-bar"
+ i18n-title
+ title="Selected Cluster:">
+ <span class="dropdown-text"> {{ selectedCluster?.name }} </span>
+ <span>- {{ selectedCluster?.cluster_alias }} - {{ selectedCluster?.user }}</span>
+ </button>
+ <div ngbDropdownMenu>
+ <ng-container *ngFor="let cluster of clustersMap | keyvalue">
+ <button ngbDropdownItem
+ (click)="onClusterSelection(cluster.value)">
+ <div class="dropdown-text">{{ cluster.value.name }}</div>
+ <div *ngIf="cluster.value.cluster_alias"
+ class="text-secondary">{{ cluster.value.cluster_alias }} - {{ cluster.value.user }}</div>
+ </button>
+ </ng-container>
+ </div>
+ </div>
+ </ng-container>
<ul class="nav navbar-nav cd-navbar-utility my-2 my-md-0">
<ng-container *ngTemplateOutlet="cd_utilities"> </ng-container>
</ul>
</i>
</a>
</li>
-
+ <!-- Multi-cluster Dashboard -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_multi_cluster">
+ <a (click)="toggleSubMenu('multiCluster')"
+ class="nav-link dropdown-toggle"
+ [attr.aria-expanded]="displayedSubMenu.multiCluster"
+ aria-controls="multi-cluster-nav"
+ role="button">
+ <ng-container i18n>
+ <i [ngClass]="[icons.sitemap]"></i>
+ Multi-Cluster
+ </ng-container>
+ </a>
+ <ul class="list-unstyled"
+ id="multi-cluster-nav"
+ [ngbCollapse]="!displayedSubMenu.multiCluster">
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_multiCluster_overview">
+ <a i18n
+ routerLink="/multi-cluster">Overview</a>
+ </li>
+ </ul>
+ </li>
<!-- Cluster -->
<li routerLinkActive="active"
class="nav-item tc_menuitem_cluster"
background-color: vv.$primary;
}
}
+
+ .cd-context-bar {
+ background-color: vv.$white;
+ color: vv.$secondary;
+ cursor: pointer;
+ }
+
+ .dropdown-text {
+ font-size: small;
+ font-weight: 600;
+ }
}
/* ---------------------------------------------------
import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
import * as _ from 'lodash';
import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
import { Icons } from '~/app/shared/enum/icons.enum';
import { Permissions } from '~/app/shared/models/permissions';
})
export class NavigationComponent implements OnInit, OnDestroy {
notifications: string[] = [];
+ clusterDetails: any[] = [];
@HostBinding('class') get class(): string {
return 'top-notification-' + this.notifications.length;
}
displayedSubMenu = {};
private subs = new Subscription();
+ clustersMap: Map<string, any> = new Map<string, any>();
+ selectedCluster: object;
+
constructor(
private authStorageService: AuthStorageService,
+ private multiClusterService: MultiClusterService,
+ private router: Router,
private summaryService: SummaryService,
private featureToggles: FeatureTogglesService,
private telemetryNotificationService: TelemetryNotificationService,
}
ngOnInit() {
+ this.subs.add(
+ this.multiClusterService.subscribe((resp: any) => {
+ const clustersConfig = resp['config'];
+ if (clustersConfig) {
+ Object.keys(clustersConfig).forEach((clusterKey: string) => {
+ const clusterDetailsList = clustersConfig[clusterKey];
+ clusterDetailsList.forEach((clusterDetails: any) => {
+ const clusterName = clusterDetails['name'];
+ const clusterUser = clusterDetails['user'];
+ const clusterUrl = clusterDetails['url'];
+ const clusterUniqueKey = `${clusterUrl}-${clusterUser}`;
+ this.clustersMap.set(clusterUniqueKey, {
+ name: clusterName,
+ cluster_alias: clusterDetails['cluster_alias'],
+ user: clusterDetails['user'],
+ url: clusterUrl
+ });
+ });
+ });
+ this.selectedCluster =
+ this.clustersMap.get(`${resp['current_url']}-${resp['current_user']}`) || {};
+ }
+ })
+ );
+
this.subs.add(
this.summaryService.subscribe((summary) => {
this.summaryData = summary;
}
}
}
+
+ onClusterSelection(value: object) {
+ this.multiClusterService.setCluster(value).subscribe(
+ (resp: any) => {
+ localStorage.setItem('cluster_api_url', value['url']);
+ this.selectedCluster = this.clustersMap.get(`${value['url']}-${value['user']}`) || {};
+ const clustersConfig = resp['config'];
+ if (clustersConfig && typeof clustersConfig === 'object') {
+ Object.keys(clustersConfig).forEach((clusterKey: string) => {
+ const clusterDetailsList = clustersConfig[clusterKey];
+
+ clusterDetailsList.forEach((clusterDetails: any) => {
+ const clusterName = clusterDetails['name'];
+ const clusterToken = clusterDetails['token'];
+ const clusterUser = clusterDetails['user'];
+
+ if (
+ clusterName === this.selectedCluster['name'] &&
+ clusterUser === this.selectedCluster['user']
+ ) {
+ localStorage.setItem('token_of_selected_cluster', clusterToken);
+ }
+ });
+ });
+ }
+ },
+ () => {},
+ () => {
+ this.multiClusterService.refresh();
+ this.summaryService.refresh();
+ const currentRoute = this.router.url.split('?')[0];
+ if (currentRoute.includes('dashboard')) {
+ this.router.navigateByUrl('/pool', { skipLocationChange: true }).then(() => {
+ this.router.navigate([currentRoute]);
+ });
+ } else {
+ this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
+ this.router.navigate([currentRoute]);
+ });
+ }
+ }
+ );
+ }
}
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { MultiClusterService } from './multi-cluster.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+describe('MultiClusterService', () => {
+ let service: MultiClusterService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule]
+ });
+ service = TestBed.inject(MultiClusterService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Subscription } from 'rxjs';
+import { TimerService } from '../services/timer.service';
+import { filter } from 'rxjs/operators';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class MultiClusterService {
+ private msSource = new BehaviorSubject<any>(null);
+ msData$ = this.msSource.asObservable();
+ constructor(private http: HttpClient, private timerService: TimerService) {}
+
+ startPolling(): Subscription {
+ return this.timerService
+ .get(() => this.getCluster(), 5000)
+ .subscribe(this.getClusterObserver());
+ }
+
+ refresh(): Subscription {
+ return this.getCluster().subscribe(this.getClusterObserver());
+ }
+
+ subscribe(next: (data: any) => void, error?: (error: any) => void) {
+ return this.msData$.pipe(filter((value) => !!value)).subscribe(next, error);
+ }
+
+ setCluster(cluster: object) {
+ return this.http.put('api/multi-cluster/set_config', { config: cluster });
+ }
+
+ getCluster() {
+ return this.http.get('api/multi-cluster/get_config');
+ }
+
+ addCluster(
+ url: any,
+ clusterAlias: string,
+ username: string,
+ password: string,
+ token = '',
+ hub_url = ''
+ ) {
+ return this.http.post('api/multi-cluster/auth', {
+ url,
+ cluster_alias: clusterAlias,
+ username,
+ password,
+ token,
+ hub_url
+ });
+ }
+
+ verifyConnection(url: string, username: string, password: string, token = '') {
+ return this.http.post('api/multi-cluster/verify_connection', {
+ url,
+ username,
+ password,
+ token
+ });
+ }
+
+ private getClusterObserver() {
+ return (data: any) => {
+ this.msSource.next(data);
+ };
+ }
+}
overflow-x: hidden;
overflow-y: auto;
}
+
+ .modal-content {
+ display: table;
+ }
}
button.close {
IMPORT: any;
MIGRATE: string;
START_UPGRADE: string;
+ CONNECT: string;
constructor() {
/* Create a new item */
this.DEMOTE = $localize`Demote`;
this.START_UPGRADE = $localize`Start Upgrade`;
+
+ this.CONNECT = $localize`Connect`;
}
}
import { NotificationType } from '../enum/notification-type.enum';
import { CdNotificationConfig } from '../models/cd-notification';
import { FinishedTask } from '../models/finished-task';
-import { AuthStorageService } from './auth-storage.service';
import { NotificationService } from './notification.service';
+import { MultiClusterService } from '../api/multi-cluster.service';
+import { SummaryService } from './summary.service';
+import { AuthStorageService } from './auth-storage.service';
export class CdHttpErrorResponse extends HttpErrorResponse {
preventDefault: Function;
providedIn: 'root'
})
export class ApiInterceptorService implements HttpInterceptor {
+ localClusterDetails: object;
+ dashboardClustersMap: Map<string, string> = new Map<string, string>();
constructor(
private router: Router,
+ public notificationService: NotificationService,
+ private summaryService: SummaryService,
private authStorageService: AuthStorageService,
- public notificationService: NotificationService
- ) {}
+ private multiClusterService: MultiClusterService
+ ) {
+ this.multiClusterService.subscribe((resp: any) => {
+ const clustersConfig = resp['config'];
+ const hub_url = resp['hub_url'];
+ if (clustersConfig) {
+ Object.keys(clustersConfig).forEach((clusterKey: string) => {
+ const clusterDetailsList = clustersConfig[clusterKey];
+
+ clusterDetailsList.forEach((clusterDetails: any) => {
+ const clusterUrl = clusterDetails['url'];
+ const clusterName = clusterDetails['name'];
+
+ this.dashboardClustersMap.set(clusterUrl, clusterName);
+
+ if (clusterDetails['url'] === hub_url) {
+ this.localClusterDetails = clusterDetails;
+ }
+ });
+ });
+ }
+ });
+ }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const acceptHeader = request.headers.get('Accept');
let reqWithVersion: HttpRequest<any>;
+
+ const origin = window.location.origin;
if (acceptHeader && acceptHeader.startsWith('application/vnd.ceph.api.v')) {
reqWithVersion = request.clone();
} else {
}
});
}
+
+ const apiUrl = localStorage.getItem('cluster_api_url');
+ const currentRoute = this.router.url.split('?')[0];
+
+ const ALWAYS_TO_HUB_APIs = [
+ 'api/auth/login',
+ 'api/auth/logout',
+ 'api/multi-cluster/get_config',
+ 'api/multi-cluster/set_config',
+ 'api/multi-cluster/auth'
+ ];
+
+ const token = localStorage.getItem('token_of_selected_cluster');
+
+ if (
+ !currentRoute.includes('login') &&
+ !ALWAYS_TO_HUB_APIs.includes(request.url) &&
+ apiUrl &&
+ !apiUrl.includes(origin)
+ ) {
+ reqWithVersion = reqWithVersion.clone({
+ url: `${apiUrl}${reqWithVersion.url}`,
+ setHeaders: {
+ 'Access-Control-Allow-Origin': origin,
+ Authorization: `Bearer ${token}`
+ }
+ });
+ }
+
return next.handle(reqWithVersion).pipe(
catchError((resp: CdHttpErrorResponse) => {
if (resp instanceof HttpErrorResponse) {
timeoutId = this.notificationService.notifyTask(finishedTask);
break;
case 401:
- this.authStorageService.remove();
- this.router.navigate(['/login']);
+ if (this.dashboardClustersMap.size > 1) {
+ this.multiClusterService.setCluster(this.localClusterDetails).subscribe(() => {
+ localStorage.setItem('cluster_api_url', this.localClusterDetails['url']);
+ });
+ this.multiClusterService.refresh();
+ this.summaryService.refresh();
+ const currentRoute = this.router.url.split('?')[0];
+ if (currentRoute.includes('dashboard')) {
+ this.router.navigateByUrl('/pool', { skipLocationChange: true }).then(() => {
+ this.router.navigate([currentRoute]);
+ });
+ } else {
+ this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
+ this.router.navigate([currentRoute]);
+ });
+ }
+ } else {
+ this.authStorageService.remove();
+ this.router.navigate(['/login']);
+ }
break;
case 403:
this.router.navigate(['error'], {
application/json:
schema:
properties:
- hub_url:
+ cluster_alias:
type: string
- name:
+ hub_url:
type: string
password:
type: string
type: string
required:
- url
- - name
+ - cluster_alias
type: object
responses:
'201':
summary: Authenticate to a remote cluster
tags:
- Multi-cluster
+ /api/multi-cluster/get_config:
+ get:
+ parameters: []
+ responses:
+ '200':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: OK
+ '400':
+ description: Operation exception. Please check the response body for details.
+ '401':
+ description: Unauthenticated access. Please login first.
+ '403':
+ description: Unauthorized access. Please check your permissions.
+ '500':
+ description: Unexpected error. Please check the response body for the stack
+ trace.
+ security:
+ - jwt: []
+ tags:
+ - Multi-cluster
+ /api/multi-cluster/set_config:
+ put:
+ parameters: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ config:
+ type: string
+ required:
+ - config
+ type: object
+ responses:
+ '200':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: Resource updated.
+ '202':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: Operation is still executing. Please check the task queue.
+ '400':
+ description: Operation exception. Please check the response body for details.
+ '401':
+ description: Unauthenticated access. Please login first.
+ '403':
+ description: Unauthorized access. Please check your permissions.
+ '500':
+ description: Unexpected error. Please check the response body for the stack
+ trace.
+ security:
+ - jwt: []
+ tags:
+ - Multi-cluster
+ /api/multi-cluster/verify_connection:
+ post:
+ parameters: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ password:
+ type: string
+ token:
+ type: string
+ url:
+ type: string
+ username:
+ type: string
+ required:
+ - url
+ type: object
+ responses:
+ '201':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: Resource created.
+ '202':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: Operation is still executing. Please check the task queue.
+ '400':
+ description: Operation exception. Please check the response body for details.
+ '401':
+ description: Unauthenticated access. Please login first.
+ '403':
+ description: Unauthorized access. Please check your permissions.
+ '500':
+ description: Unexpected error. Please check the response body for the stack
+ trace.
+ security:
+ - jwt: []
+ tags:
+ - Multi-cluster
/api/nfs-ganesha/cluster:
get:
parameters: []
self.assertStatus(401)
@patch('dashboard.controllers.auth.JwtManager.gen_token', Mock(return_value='my-token'))
+ @patch('dashboard.mgr.get', Mock(return_value={
+ 'config': {
+ 'fsid': '943949f0-ce37-47ca-a33c-3413d46ee9ec'
+ }
+ }))
@patch('dashboard.controllers.auth.AuthManager.authenticate', Mock(return_value={
'permissions': {'rgw': ['read']},
'pwdExpirationDate': 1000000,