In Cluster -> Hosts, I've added additional button to put the selected host on maintenance or exit out of the maintenance mode. Also for some hosts the ok-to-stop tests may trigger some warnings which requires a --force command to pass along with the maintenance enter command to enter a host into maintenance. In UI this is achieved using a confirmation Modal. In addition to this if the check error is It is NOT safe to stop the host then the host wont be able to put into maintenance mode.
Fixes: https://tracker.ceph.com/issues/49101
Signed-off-by: Nizamudeen A <nia@redhat.com>
"""
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';
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
}
];
}
class: 'badge-dark'
}
},
+ {
+ 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)