"""
return get_host(hostname)
- @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD, OrchFeature.HOST_LABEL_REMOVE])
+ @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD,
+ OrchFeature.HOST_LABEL_REMOVE,
+ OrchFeature.HOST_MAINTENANCE_ENTER,
+ OrchFeature.HOST_MAINTENANCE_EXIT])
@handle_orchestrator_error('host')
@EndpointDoc('',
parameters={
'hostname': (str, 'Hostname'),
+ 'update_labels': (bool, 'Update Labels'),
'labels': ([str], 'Host Labels'),
+ 'maintenance': (bool, 'Enter/Exit Maintenance'),
+ 'force': (bool, 'Force Enter Maintenance')
},
responses={200: None, 204: None})
- def set(self, hostname: str, labels: List[str]):
+ def set(self, hostname: str, update_labels: bool = False,
+ labels: List[str] = None, maintenance: bool = False,
+ force: bool = False):
"""
Update the specified host.
Note, this is only supported when Ceph Orchestrator is enabled.
:param hostname: The name of the host to be processed.
+ :param update_labels: To update the labels.
:param labels: List of labels.
+ :param maintenance: Enter/Exit maintenance mode.
+ :param force: Force enter maintenance mode.
"""
orch = OrchClient.instance()
host = get_host(hostname)
- # only allow List[str] type for labels
- if not isinstance(labels, list):
- raise DashboardException(
- msg='Expected list of labels. Please check API documentation.',
- http_status_code=400,
- component='orchestrator')
- current_labels = set(host['labels'])
- # Remove labels.
- remove_labels = list(current_labels.difference(set(labels)))
- for label in remove_labels:
- orch.hosts.remove_label(hostname, label)
- # Add labels.
- add_labels = list(set(labels).difference(current_labels))
- for label in add_labels:
- orch.hosts.add_label(hostname, label)
+
+ if maintenance:
+ status = host['status']
+ if status != 'maintenance':
+ orch.hosts.enter_maintenance(hostname, force)
+
+ if status == 'maintenance':
+ orch.hosts.exit_maintenance(hostname)
+
+ if update_labels:
+ # only allow List[str] type for labels
+ if not isinstance(labels, list):
+ raise DashboardException(
+ msg='Expected list of labels. Please check API documentation.',
+ http_status_code=400,
+ component='orchestrator')
+ current_labels = set(host['labels'])
+ # Remove labels.
+ remove_labels = list(current_labels.difference(set(labels)))
+ for label in remove_labels:
+ orch.hosts.remove_label(hostname, label)
+ # Add labels.
+ add_labels = list(set(labels).difference(current_labels))
+ for label in add_labels:
+ orch.hosts.add_label(hostname, label)
@UiApiController('/host', Scope.HOSTS)
columnIndex = {
hostname: 2,
- labels: 4
+ services: 3,
+ labels: 4,
+ status: 5
};
check_for_host() {
}
});
}
+
+ @PageHelper.restrictTo(pages.index.url)
+ maintenance(hostname: string, exit = false) {
+ let services: string[];
+ let runTest = false;
+ this.getTableCell(this.columnIndex.hostname, hostname)
+ .parent()
+ .find(`datatable-body-cell:nth-child(${this.columnIndex.services}) a`)
+ .should(($el) => {
+ services = $el.text().split(', ');
+ if (services.length < 2 && services[0].includes('osd')) {
+ runTest = true;
+ }
+ });
+ if (runTest) {
+ this.getTableCell(this.columnIndex.hostname, hostname).click();
+ if (exit) {
+ this.clickActionButton('exit-maintenance');
+
+ this.getTableCell(this.columnIndex.hostname, hostname)
+ .parent()
+ .find(`datatable-body-cell:nth-child(${this.columnIndex.status}) .badge`)
+ .should(($ele) => {
+ const status = $ele.toArray().map((v) => v.innerText);
+ expect(status).to.not.include('maintenance');
+ });
+ } else {
+ this.clickActionButton('enter-maintenance');
+
+ this.getTableCell(this.columnIndex.hostname, hostname)
+ .parent()
+ .find(`datatable-body-cell:nth-child(${this.columnIndex.status}) .badge`)
+ .should(($ele) => {
+ const status = $ele.toArray().map((v) => v.innerText);
+ expect(status).to.include('maintenance');
+ });
+ }
+ }
+ }
}
hosts.editLabels(hostname, labels, true);
hosts.editLabels(hostname, labels, false);
});
+
+ it('should enter host into maintenance', function () {
+ const hostname = Cypress._.sample(this.hosts).name;
+ hosts.maintenance(hostname);
+ });
+
+ it('should exit host from maintenance', function () {
+ const hostname = Cypress._.sample(this.hosts).name;
+ hosts.maintenance(hostname, true);
+ });
});
});
<ng-container *ngIf="!isLast">, </ng-container>
</span>
</ng-template>
+
+<ng-template #maintenanceConfirmTpl>
+ <div *ngFor="let msg of errorMessage; let last=last">
+ <ul *ngIf="!last || errorMessage.length == '1'">
+ <li i18n>{{ msg }}</li>
+ </ul>
+ </div>
+ <ng-container i18n
+ *ngIf="showSubmit">Are you sure you want to continue?</ng-container>
+</ng-template>
import { HostService } from '~/app/shared/api/host.service';
import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
table: TableComponent;
@ViewChild('servicesTpl', { static: true })
public servicesTpl: TemplateRef<any>;
+ @ViewChild('maintenanceConfirmTpl', { static: true })
+ maintenanceConfirmTpl: TemplateRef<any>;
permissions: Permissions;
columns: Array<CdTableColumn> = [];
tableActions: CdTableAction[];
selection = new CdTableSelection();
modalRef: NgbModalRef;
+ isExecuting = false;
+ errorMessage: string;
+ enableButton: boolean;
+
+ icons = Icons;
messages = {
nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.`
actionOrchFeatures = {
create: [OrchestratorFeature.HOST_CREATE],
edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE],
- delete: [OrchestratorFeature.HOST_DELETE]
+ delete: [OrchestratorFeature.HOST_DELETE],
+ maintenance: [
+ OrchestratorFeature.HOST_MAINTENANCE_ENTER,
+ OrchestratorFeature.HOST_MAINTENANCE_EXIT
+ ]
};
constructor(
icon: Icons.destroy,
click: () => this.deleteAction(),
disable: (selection: CdTableSelection) => this.getDisable('delete', selection)
+ },
+ {
+ name: this.actionLabels.ENTER_MAINTENANCE,
+ permission: 'update',
+ icon: Icons.enter,
+ click: () => this.hostMaintenance(),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable('maintenance', selection) || this.isExecuting || this.enableButton
+ },
+ {
+ name: this.actionLabels.EXIT_MAINTENANCE,
+ permission: 'update',
+ icon: Icons.exit,
+ click: () => this.hostMaintenance(),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable('maintenance', selection) || this.isExecuting || !this.enableButton
}
];
}
flexGrow: 1,
pipe: this.joinPipe
},
+ {
+ name: $localize`Status`,
+ prop: 'status',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ maintenance: { class: 'badge-warning' }
+ }
+ }
+ },
{
name: $localize`Version`,
prop: 'ceph_version',
updateSelection(selection: CdTableSelection) {
this.selection = selection;
+ this.enableButton = false;
+ if (this.selection.hasSelection) {
+ if (this.selection.first().status === 'maintenance') {
+ this.enableButton = true;
+ }
+ }
}
editAction() {
],
submitButtonText: $localize`Edit Host`,
onSubmit: (values: any) => {
- this.hostService.update(host['hostname'], values.labels).subscribe(() => {
+ this.hostService.update(host['hostname'], true, values.labels).subscribe(() => {
this.notificationService.show(
NotificationType.success,
$localize`Updated Host "${host.hostname}"`
});
}
- getDisable(action: 'create' | 'edit' | 'delete', selection: CdTableSelection): boolean | string {
- if (action === 'delete' || action === 'edit') {
+ hostMaintenance() {
+ this.isExecuting = true;
+ const host = this.selection.first();
+ if (host['status'] !== 'maintenance') {
+ this.hostService.update(host['hostname'], false, [], true).subscribe(
+ () => {
+ this.isExecuting = false;
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`"${host.hostname}" moved to maintenance`
+ );
+ this.table.refreshBtn();
+ },
+ (error) => {
+ this.isExecuting = false;
+ this.errorMessage = error.error['detail'].split(/\n/);
+ error.preventDefault();
+ if (
+ error.error['detail'].includes('WARNING') &&
+ !error.error['detail'].includes('It is NOT safe to stop') &&
+ !error.error['detail'].includes('ALERT')
+ ) {
+ const modalVarialbes = {
+ titleText: $localize`Warning`,
+ buttonText: $localize`Continue`,
+ warning: true,
+ bodyTpl: this.maintenanceConfirmTpl,
+ showSubmit: true,
+ onSubmit: () => {
+ this.hostService.update(host['hostname'], false, [], true, true).subscribe(
+ () => {
+ this.modalRef.close();
+ },
+ () => this.modalRef.close()
+ );
+ }
+ };
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVarialbes);
+ } else {
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`"${host.hostname}" cannot be put into maintenance`,
+ $localize`${error.error['detail']}`
+ );
+ }
+ }
+ );
+ } else {
+ this.hostService.update(host['hostname'], false, [], true).subscribe(() => {
+ this.isExecuting = false;
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`"${host.hostname}" has exited maintenance`
+ );
+ this.table.refreshBtn();
+ });
+ }
+ }
+
+ getDisable(
+ action: 'create' | 'edit' | 'delete' | 'maintenance',
+ selection: CdTableSelection
+ ): boolean | string {
+ if (action === 'delete' || action === 'edit' || action === 'maintenance') {
if (!selection?.hasSingleSelection) {
return true;
}
});
it('should update host', fakeAsync(() => {
- service.update('mon0', ['foo', 'bar']).subscribe();
+ service.update('mon0', true, ['foo', 'bar']).subscribe();
const req = httpTesting.expectOne('api/host/mon0');
expect(req.request.method).toBe('PUT');
- expect(req.request.body).toEqual({ labels: ['foo', 'bar'] });
+ expect(req.request.body).toEqual({
+ force: false,
+ labels: ['foo', 'bar'],
+ maintenance: false,
+ update_labels: true
+ });
}));
it('should call getInventory', () => {
return this.http.get<string[]>(`${this.baseUIURL}/labels`);
}
- update(hostname: string, labels: string[]) {
- return this.http.put(`${this.baseURL}/${hostname}`, { labels: labels });
+ update(
+ hostname: string,
+ updateLabels = false,
+ labels: string[] = [],
+ maintenance = false,
+ force = false
+ ) {
+ return this.http.put(`${this.baseURL}/${hostname}`, {
+ update_labels: updateLabels,
+ labels: labels,
+ maintenance: maintenance,
+ force: force
+ });
}
identifyDevice(hostname: string, device: string, duration: number) {
<cd-modal (hide)="cancel()">
- <ng-container class="modal-title">{{ titleText }}</ng-container>
+ <ng-container class="modal-title">
+ <span class="text-warning"
+ *ngIf="warning">
+ <i class="fa fa-exclamation-triangle fa-1x"></i>
+ </span>{{ titleText }}</ng-container>
<ng-container class="modal-content">
<form name="confirmationForm"
#formDir="ngForm"
<cd-form-button-panel (submitActionEvent)="onSubmit(confirmationForm.value)"
(backActionEvent)="boundCancel()"
[form]="confirmationForm"
- [submitText]="buttonText"></cd-form-button-panel>
+ [submitText]="buttonText"
+ [showSubmit]="showSubmit"></cd-form-button-panel>
</div>
</form>
</ng-container>
description?: TemplateRef<any>;
// Optional
+ warning = false;
bodyData?: object;
onCancel?: Function;
bodyContext?: object;
+ showSubmit = true;
// Component only
boundCancel = this.cancel.bind(this);
margin-bottom: 2px;
margin-top: 2px;
}
+
+.card-text {
+ margin-right: 15px;
+}
UNSET: string;
UPDATE: string;
FLAGS: string;
+ ENTER_MAINTENANCE: string;
+ EXIT_MAINTENANCE: string;
constructor() {
/* Create a new item */
this.UNPROTECT = $localize`Unprotect`;
this.CHANGE = $localize`Change`;
this.FLAGS = $localize`Flags`;
+ this.ENTER_MAINTENANCE = $localize`Enter Maintenance`;
+ this.EXIT_MAINTENANCE = $localize`Exit Maintenance`;
/* Prometheus wording */
this.RECREATE = $localize`Recreate`;
</ng-container>
<div class="btn-group"
ngbDropdown
- placement="bottom-right"
role="group"
*ngIf="dropDownActions.length > 1"
aria-label="Button group with nested dropdown">
json = 'fa fa-file-code-o', // JSON file
text = 'fa fa-file-text', // Text file
wrench = 'fa fa-wrench', // Configuration Error
+ enter = 'fa fa-sign-in', // Enter
+ exit = 'fa fa-sign-out', // Exit
/* Icons for special effect */
large = 'fa fa-lg', // icon becomes 33% larger
HOST_DELETE = 'remove_host',
HOST_LABEL_ADD = 'add_host_label',
HOST_LABEL_REMOVE = 'remove_host_label',
+ HOST_MAINTENANCE_ENTER = 'enter_host_maintenance',
+ HOST_MAINTENANCE_EXIT = 'exit_host_maintenance',
SERVICE_LIST = 'describe_service',
SERVICE_CREATE = 'apply',
put:
description: "\n Update the specified host.\n Note, this is only\
\ supported when Ceph Orchestrator is enabled.\n :param hostname: The\
- \ name of the host to be processed.\n :param labels: List of labels.\n\
- \ "
+ \ name of the host to be processed.\n :param update_labels: To update\
+ \ the labels.\n :param labels: List of labels.\n :param maintenance:\
+ \ Enter/Exit maintenance mode.\n :param force: Force enter maintenance\
+ \ mode.\n "
parameters:
- description: Hostname
in: path
application/json:
schema:
properties:
+ force:
+ default: false
+ description: Force Enter Maintenance
+ type: boolean
labels:
description: Host Labels
items:
type: string
type: array
- required:
- - labels
+ maintenance:
+ default: false
+ description: Enter/Exit Maintenance
+ type: boolean
+ update_labels:
+ default: false
+ description: Update Labels
+ type: boolean
type: object
responses:
'200':
def list(self) -> List[HostSpec]:
return self.api.get_hosts()
+ @wait_api_result
+ def enter_maintenance(self, hostname: str, force: bool = False):
+ return self.api.enter_host_maintenance(hostname, force)
+
+ @wait_api_result
+ def exit_maintenance(self, hostname: str):
+ return self.api.exit_host_maintenance(hostname)
+
def get(self, hostname: str) -> Optional[HostSpec]:
hosts = [host for host in self.list() if host.hostname == hostname]
return hosts[0] if hosts else None
HOST_DELETE = 'remove_host'
HOST_LABEL_ADD = 'add_host_label'
HOST_LABEL_REMOVE = 'remove_host_label'
+ HOST_MAINTENANCE_ENTER = 'enter_host_maintenance'
+ HOST_MAINTENANCE_EXIT = 'exit_host_maintenance'
SERVICE_LIST = 'describe_service'
SERVICE_CREATE = 'apply'
fake_client.hosts.remove_label = mock.Mock()
fake_client.hosts.add_label = mock.Mock()
- self._put('{}/node0'.format(self.URL_HOST), {'labels': ['bbb', 'ccc']})
+ payload = {'update_labels': True, 'labels': ['bbb', 'ccc']}
+ self._put('{}/node0'.format(self.URL_HOST), payload)
self.assertStatus(200)
fake_client.hosts.remove_label.assert_called_once_with('node0', 'aaa')
fake_client.hosts.add_label.assert_called_once_with('node0', 'ccc')
# return 400 if type other than List[str]
- self._put('{}/node0'.format(self.URL_HOST), {'labels': 'ddd'})
+ self._put('{}/node0'.format(self.URL_HOST), {'update_labels': True,
+ 'labels': 'ddd'})
self.assertStatus(400)
+ def test_host_maintenance(self):
+ mgr.list_servers.return_value = []
+ orch_hosts = [
+ HostSpec('node0'),
+ HostSpec('node1')
+ ]
+ with patch_orch(True, hosts=orch_hosts):
+ # enter maintenance mode
+ self._put('{}/node0'.format(self.URL_HOST), {'maintenance': True})
+ self.assertStatus(200)
+
+ # force enter maintenance mode
+ self._put('{}/node1'.format(self.URL_HOST), {'maintenance': True, 'force': True})
+ self.assertStatus(200)
+
+ # exit maintenance mode
+ self._put('{}/node0'.format(self.URL_HOST), {'maintenance': True})
+ self.assertStatus(200)
+ self._put('{}/node1'.format(self.URL_HOST), {'maintenance': True})
+ self.assertStatus(200)
+
+ # maintenance without orchestrator service
+ with patch_orch(False):
+ self._put('{}/node0'.format(self.URL_HOST), {'maintenance': True})
+ self.assertStatus(503)
+
@mock.patch('dashboard.controllers.host.time')
def test_identify_device(self, mock_time):
url = '{}/host-0/identify_device'.format(self.URL_HOST)