--- /dev/null
+# -*- coding: utf-8 -*-
+
+from typing import Optional
+
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.exception import handle_orchestrator_error
+from ..services.orchestrator import OrchClient, OrchFeature
+from . import APIDoc, APIRouter, RESTController
+from ._version import APIVersion
+from .orchestrator import raise_if_no_orchestrator
+
+
+@APIRouter('/daemon', Scope.HOSTS)
+@APIDoc("Perform actions on daemons", "Daemon")
+class Daemon(RESTController):
+ @raise_if_no_orchestrator([OrchFeature.DAEMON_ACTION])
+ @handle_orchestrator_error('daemon')
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def set(self, daemon_name: str, action: str = '',
+ container_image: Optional[str] = None):
+
+ if action not in ['start', 'stop', 'restart', 'redeploy']:
+ raise DashboardException(
+ code='invalid_daemon_action',
+ msg=f'Daemon action "{action}" is either not valid or not supported.')
+ # non 'None' container_images change need a redeploy
+ if container_image == '' and action != 'redeploy':
+ container_image = None
+
+ orch = OrchClient.instance()
+ res = orch.daemons.action(action=action, daemon_name=daemon_name, image=container_image)
+ return res
});
}
- checkServiceStatus(daemon: string) {
+ checkServiceStatus(daemon: string, expectedStatus = 'running') {
cy.get('cd-service-daemon-list').within(() => {
this.getTableCell(this.serviceDetailColumnIndex.daemonType, daemon)
.parent()
.find(`datatable-body-cell:nth-child(${this.serviceDetailColumnIndex.status}) .badge`)
.should(($ele) => {
const status = $ele.toArray().map((v) => v.innerText);
- expect(status).to.include('running');
+ expect(status).to.include(expectedStatus);
});
});
}
cy.get('cd-modal').should('not.exist');
this.checkExist(serviceName, false);
}
+
+ daemonAction(daemon: string, action: string) {
+ cy.get('cd-service-daemon-list').within(() => {
+ this.getTableRow(daemon).click();
+ this.clickActionButton(action);
+ });
+ }
}
services.checkExist('rgw.foo', true);
});
- it('should create and delete an mds service', () => {
+ it('should create an mds service', () => {
services.navigateTo('create');
services.addService('mds', false);
services.checkExist('mds.test', true);
cy.get('cd-service-details').within(() => {
services.checkServiceStatus('mds');
});
+ });
+
+ it('should stop a daemon', () => {
+ services.clickServiceTab('mds.test', 'Details');
+ services.checkServiceStatus('mds');
+
+ services.daemonAction('mds', 'stop');
+ services.checkServiceStatus('mds', 'stopped');
+ });
+
+ it('should restart a daemon', () => {
+ services.checkExist('mds.test', true);
+ services.clickServiceTab('mds.test', 'Details');
+ services.daemonAction('mds', 'restart');
+ services.checkServiceStatus('mds', 'running');
+ });
+
+ it('should redeploy a daemon', () => {
+ services.checkExist('mds.test', true);
+ services.clickServiceTab('mds.test', 'Details');
+
+ services.daemonAction('mds', 'stop');
+ services.checkServiceStatus('mds', 'stopped');
+ services.daemonAction('mds', 'redeploy');
+ services.checkServiceStatus('mds', 'running');
+ });
+
+ it('should start a daemon', () => {
+ services.checkExist('mds.test', true);
+ services.clickServiceTab('mds.test', 'Details');
+
+ services.daemonAction('mds', 'stop');
+ services.checkServiceStatus('mds', 'stopped');
+ services.daemonAction('mds', 'start');
+ services.checkServiceStatus('mds', 'running');
+ });
+ it('should delete an mds service', () => {
services.deleteService('mds.test');
});
});
<cd-table *ngIf="hasOrchestrator"
#daemonsTable
[data]="daemons"
+ selectionType="single"
[columns]="columns"
columnMode="flex"
- (fetchData)="getDaemons($event)">
+ identifier="daemon_id"
+ (fetchData)="getDaemons($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions id="service-daemon-list-actions"
+ class="table-actions"
+ [selection]="selection"
+ [permission]="permissions.hosts"
+ [tableActions]="tableActions">
+ </cd-table-actions>
</cd-table>
</ng-template>
</li>
import _ from 'lodash';
import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
import { of } from 'rxjs';
import { CephModule } from '~/app/ceph/ceph.module';
container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
daemon_id: '3',
daemon_type: 'osd',
+ daemon_name: 'osd.3',
version: '15.1.0-1174-g16a11f7',
status: 1,
status_desc: 'running',
- last_refresh: '2020-02-25T04:33:26.465699'
+ last_refresh: '2020-02-25T04:33:26.465699',
+ events: [
+ { created: '2020-02-24T04:33:26.465699' },
+ { created: '2020-02-25T04:33:26.465699' },
+ { created: '2020-02-26T04:33:26.465699' }
+ ]
},
{
hostname: 'osd0',
container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
daemon_id: '4',
daemon_type: 'osd',
+ daemon_name: 'osd.4',
version: '15.1.0-1174-g16a11f7',
status: 1,
status_desc: 'running',
- last_refresh: '2020-02-25T04:33:26.465822'
+ last_refresh: '2020-02-25T04:33:26.465822',
+ events: []
},
{
hostname: 'osd0',
container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
daemon_id: '5',
daemon_type: 'osd',
+ daemon_name: 'osd.5',
version: '15.1.0-1174-g16a11f7',
status: 1,
status_desc: 'running',
- last_refresh: '2020-02-25T04:33:26.465886'
+ last_refresh: '2020-02-25T04:33:26.465886',
+ events: []
},
{
hostname: 'mon0',
container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
daemon_id: 'a',
+ daemon_name: 'mon.a',
daemon_type: 'mon',
version: '15.1.0-1174-g16a11f7',
status: 1,
status_desc: 'running',
- last_refresh: '2020-02-25T04:33:26.465886'
+ last_refresh: '2020-02-25T04:33:26.465886',
+ events: []
}
];
};
configureTestBed({
- imports: [HttpClientTestingModule, CephModule, CoreModule, NgxPipeFunctionModule, SharedModule]
+ imports: [
+ HttpClientTestingModule,
+ CephModule,
+ CoreModule,
+ NgxPipeFunctionModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
});
beforeEach(() => {
it('should not display doc panel if orchestrator is available', () => {
expect(component.showDocPanel).toBeFalsy();
});
+
+ it('should call daemon action', () => {
+ const daemon = daemons[0];
+ component.selection.selected = [daemon];
+ component['daemonService'].action = jest.fn(() => of());
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ component.daemonAction(action);
+ expect(component['daemonService'].action).toHaveBeenCalledWith(daemon.daemon_name, action);
+ }
+ });
+
+ it('should disable daemon actions', () => {
+ const daemon = {
+ daemon_type: 'osd',
+ status_desc: 'running'
+ };
+
+ const states = {
+ start: true,
+ stop: false,
+ restart: false,
+ redeploy: false
+ };
+ const expectBool = (toExpect: boolean, arg: boolean) => {
+ if (toExpect === true) {
+ expect(arg).toBeTruthy();
+ } else {
+ expect(arg).toBeFalsy();
+ }
+ };
+
+ component.selection.selected = [daemon];
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expectBool(states[action], component.actionDisabled(action));
+ }
+
+ daemon.status_desc = 'stopped';
+ states.start = false;
+ states.stop = true;
+ component.selection.selected = [daemon];
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expectBool(states[action], component.actionDisabled(action));
+ }
+ });
+
+ it('should disable daemon actions in mgr and mon daemon', () => {
+ const daemon = {
+ daemon_type: 'mgr',
+ status_desc: 'running'
+ };
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expect(component.actionDisabled(action)).toBeTruthy();
+ }
+ daemon.daemon_type = 'mon';
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expect(component.actionDisabled(action)).toBeTruthy();
+ }
+ });
+
+ it('should disable daemon actions if no selection', () => {
+ component.selection.selected = [];
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expect(component.actionDisabled(action)).toBeTruthy();
+ }
+ });
+
+ it('should sort daemons events', () => {
+ component.sortDaemonEvents();
+ const daemon = daemons[0];
+ for (let i = 1; i < daemon.events.length; i++) {
+ const t1 = new Date(daemon.events[i - 1].created).getTime();
+ const t2 = new Date(daemon.events[i].created).getTime();
+ expect(t1 >= t2).toBeTruthy();
+ }
+ });
});
import _ from 'lodash';
import { Observable, Subscription } from 'rxjs';
+import { take } from 'rxjs/operators';
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { DaemonService } from '~/app/shared/api/daemon.service';
import { HostService } from '~/app/shared/api/host.service';
import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+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';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { Daemon } from '~/app/shared/models/daemon.interface';
+import { Permissions } from '~/app/shared/models/permissions';
import { CephServiceSpec } from '~/app/shared/models/service.interface';
import { RelativeDatePipe } from '~/app/shared/pipes/relative-date.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
@Component({
selector: 'cd-service-daemon-list',
services: Array<CephServiceSpec> = [];
columns: CdTableColumn[] = [];
serviceColumns: CdTableColumn[] = [];
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permissions: Permissions;
hasOrchestrator = false;
showDocPanel = false;
private hostService: HostService,
private cephServiceService: CephServiceService,
private orchService: OrchestratorService,
- private relativeDatePipe: RelativeDatePipe
+ private relativeDatePipe: RelativeDatePipe,
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private daemonService: DaemonService,
+ private notificationService: NotificationService
) {}
ngOnInit() {
+ this.permissions = this.authStorageService.getPermissions();
+ this.tableActions = [
+ {
+ permission: 'update',
+ icon: Icons.start,
+ click: () => this.daemonAction('start'),
+ name: this.actionLabels.START,
+ disable: () => this.actionDisabled('start')
+ },
+ {
+ permission: 'update',
+ icon: Icons.stop,
+ click: () => this.daemonAction('stop'),
+ name: this.actionLabels.STOP,
+ disable: () => this.actionDisabled('stop')
+ },
+ {
+ permission: 'update',
+ icon: Icons.restart,
+ click: () => this.daemonAction('restart'),
+ name: this.actionLabels.RESTART,
+ disable: () => this.actionDisabled('restart')
+ },
+ {
+ permission: 'update',
+ icon: Icons.deploy,
+ click: () => this.daemonAction('redeploy'),
+ name: this.actionLabels.REDEPLOY,
+ disable: () => this.actionDisabled('redeploy')
+ }
+ ];
this.columns = [
{
name: $localize`Hostname`,
observable.subscribe(
(daemons: Daemon[]) => {
this.daemons = daemons;
+ this.sortDaemonEvents();
},
() => {
this.daemons = [];
);
}
+ sortDaemonEvents() {
+ this.daemons.forEach((daemon: any) => {
+ daemon.events?.sort((event1: any, event2: any) => {
+ return new Date(event2.created).getTime() - new Date(event1.created).getTime();
+ });
+ });
+ }
getServices(context: CdTableFetchDataContext) {
this.serviceSub = this.cephServiceService.list(this.serviceName).subscribe(
(services: CephServiceSpec[]) => {
trackByFn(_index: any, item: any) {
return item.created;
}
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ daemonAction(actionType: string) {
+ this.daemonService
+ .action(this.selection.first()?.daemon_name, actionType)
+ .pipe(take(1))
+ .subscribe({
+ next: (resp) => {
+ this.notificationService.show(
+ NotificationType.success,
+ `Daemon ${actionType} scheduled`,
+ resp.body.toString()
+ );
+ },
+ error: (resp) => {
+ this.notificationService.show(
+ NotificationType.error,
+ 'Daemon action failed',
+ resp.body.toString()
+ );
+ }
+ });
+ }
+
+ actionDisabled(actionType: string) {
+ if (this.selection?.hasSelection) {
+ const daemon = this.selection.selected[0];
+ if (daemon.daemon_type === 'mon' || daemon.daemon_type === 'mgr') {
+ return true; // don't allow actions on mon and mgr, dashboard requires them.
+ }
+ switch (actionType) {
+ case 'start':
+ if (daemon.status_desc === 'running') {
+ return true;
+ }
+ break;
+ case 'stop':
+ if (daemon.status_desc === 'stopped') {
+ return true;
+ }
+ break;
+ }
+ return false;
+ }
+ return true; // if no selection then disable everything
+ }
}
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { SummaryService } from '~/app/shared/services/summary.service';
RouterTestingModule,
SharedModule,
NgbNavModule,
- NgxPipeFunctionModule
+ NgxPipeFunctionModule,
+ ToastrModule.forRoot()
],
declarations: [ServiceDetailsComponent, ServiceDaemonListComponent],
providers: [{ provide: SummaryService, useValue: { subscribeOnce: jest.fn() } }]
--- /dev/null
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DaemonService } from './daemon.service';
+
+describe('DaemonService', () => {
+ let service: DaemonService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [DaemonService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(DaemonService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call action', () => {
+ const put_data: any = {
+ action: 'start',
+ container_image: null
+ };
+ service.action('osd.1', 'start').subscribe();
+ const req = httpTesting.expectOne('api/daemon/osd.1');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(put_data);
+ });
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class DaemonService {
+ private url = 'api/daemon';
+
+ constructor(private http: HttpClient) {}
+
+ action(daemonName: string, actionType: string) {
+ return this.http.put(
+ `${this.url}/${daemonName}`,
+ {
+ action: actionType,
+ container_image: null
+ },
+ {
+ headers: { Accept: 'application/vnd.ceph.api.v0.1+json' },
+ observe: 'response'
+ }
+ );
+ }
+}
/* Prometheus wording */
RECREATE = 'recreate',
- EXPIRE = 'expire'
+ EXPIRE = 'expire',
+
+ /* Daemons */
+ RESTART = 'Restart'
}
export enum ActionLabels {
/* Prometheus wording */
RECREATE = 'Recreate',
- EXPIRE = 'Expire'
+ EXPIRE = 'Expire',
+
+ /* Daemons */
+ START = 'Start',
+ STOP = 'Stop',
+ REDEPLOY = 'Redeploy',
+ RESTART = 'Restart'
}
@Injectable({
EXIT_MAINTENANCE: string;
START_DRAIN: string;
STOP_DRAIN: string;
+ START: string;
+ STOP: string;
+ REDEPLOY: string;
+ RESTART: string;
constructor() {
/* Create a new item */
/* Prometheus wording */
this.RECREATE = $localize`Recreate`;
this.EXPIRE = $localize`Expire`;
+
+ this.START = $localize`Start`;
+ this.STOP = $localize`Stop`;
+ this.REDEPLOY = $localize`Redeploy`;
+ this.RESTART = $localize`Restart`;
}
}
CHANGE: string;
RECREATED: string;
EXPIRED: string;
+ MOVE: string;
+ START: string;
+ STOP: string;
+ REDEPLOY: string;
+ RESTART: string;
constructor() {
/* Create a new item */
/* Prometheus wording */
this.RECREATED = $localize`Recreated`;
this.EXPIRED = $localize`Expired`;
+
+ this.START = $localize`Start`;
+ this.STOP = $localize`Stop`;
+ this.REDEPLOY = $localize`Redeploy`;
+ this.RESTART = $localize`Restart`;
}
}
[routerLink]="useRouterLink(action)"
[preserveFragment]="action.preserveFragment ? '' : null"
[disabled]="disableSelectionAction(action)">
- <i [ngClass]="[action.icon]"></i>
+ <i [ngClass]="[action.icon, 'action-icon']"></i>
<span>{{ action.name }}</span>
</button>
</ng-container>
cursor: default !important;
pointer-events: auto;
}
+
+.action-icon {
+ padding-right: 1.5rem;
+}
wrench = 'fa fa-wrench', // Configuration Error
enter = 'fa fa-sign-in', // Enter
exit = 'fa fa-sign-out', // Exit
+ restart = 'fa fa-history', // Restart
+ deploy = 'fa fa-cube', // Deploy, Redeploy
/* Icons for special effect */
large = 'fa fa-lg', // icon becomes 33% larger
- jwt: []
tags:
- CrushRule
+ /api/daemon/{daemon_name}:
+ put:
+ parameters:
+ - in: path
+ name: daemon_name
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ action:
+ default: ''
+ type: string
+ container_image:
+ type: string
+ type: object
+ responses:
+ '200':
+ content:
+ application/vnd.ceph.api.v0.1+json:
+ type: object
+ description: Resource updated.
+ '202':
+ content:
+ application/vnd.ceph.api.v0.1+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:
+ - Daemon
/api/erasure_code_profile:
get:
parameters: []
name: ClusterConfiguration
- description: Crush Rule Management API
name: CrushRule
+- description: Perform actions on daemons
+ name: Daemon
- description: Erasure Code Profile Management API
name: ErasureCodeProfile
- description: Manage Features API
return self.api.remove_osds_status()
+class DaemonManager(ResourceManager):
+ @wait_api_result
+ def action(self, daemon_name='', action='', image=None):
+ return self.api.daemon_action(daemon_name=daemon_name, action=action, image=image)
+
+
class OrchClient(object):
_instance = None
self.inventory = InventoryManager(self.api)
self.services = ServiceManager(self.api)
self.osds = OsdManager(self.api)
+ self.daemons = DaemonManager(self.api)
def available(self, features: Optional[List[str]] = None) -> bool:
available = self.status()['available']
DEVICE_LIST = 'get_inventory'
DEVICE_BLINK_LIGHT = 'blink_device_light'
+
+ DAEMON_ACTION = 'daemon_action'
# -*- coding: utf-8 -*-
# pylint: disable=too-many-arguments
+import contextlib
import json
import logging
import threading
import time
-from typing import Any, Dict
+from typing import Any, Dict, List, Optional
+from unittest import mock
from unittest.mock import Mock
import cherrypy
from cherrypy._cptools import HandlerWrapperTool
from cherrypy.test import helper
from mgr_module import HandleCommandResult
+from orchestrator import HostSpec, InventoryHost
from pyfakefs import fake_filesystem
from .. import mgr
running = False
self.res_task = task
self.ev.set()
+
+
+@contextlib.contextmanager
+def patch_orch(available: bool, missing_features: Optional[List[str]] = None,
+ hosts: Optional[List[HostSpec]] = None,
+ inventory: Optional[List[dict]] = None):
+ with mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') as instance:
+ fake_client = mock.Mock()
+ fake_client.available.return_value = available
+ fake_client.get_missing_features.return_value = missing_features
+
+ if hosts is not None:
+ fake_client.hosts.list.return_value = hosts
+
+ if inventory is not None:
+ def _list_inventory(hosts=None, refresh=False): # pylint: disable=unused-argument
+ inv_hosts = []
+ for inv_host in inventory:
+ if hosts is None or inv_host['name'] in hosts:
+ inv_hosts.append(InventoryHost.from_json(inv_host))
+ return inv_hosts
+ fake_client.inventory.list.side_effect = _list_inventory
+
+ instance.return_value = fake_client
+ yield fake_client
--- /dev/null
+# -*- coding: utf-8 -*-
+
+from ..controllers._version import APIVersion
+from ..controllers.daemon import Daemon
+from ..tests import ControllerTestCase, patch_orch
+
+
+class DaemonTest(ControllerTestCase):
+
+ URL_DAEMON = '/api/daemon'
+
+ @classmethod
+ def setup_server(cls):
+ cls.setup_controllers([Daemon])
+
+ def test_daemon_action(self):
+ msg = "Scheduled to stop crash.b78cd1164a1b on host 'hostname'"
+
+ with patch_orch(True) as fake_client:
+ fake_client.daemons.action.return_value = msg
+ payload = {
+ 'action': 'restart',
+ 'container_image': None
+ }
+ self._put(f'{self.URL_DAEMON}/crash.b78cd1164a1b', payload, version=APIVersion(0, 1))
+ self.assertJsonBody(msg)
+ self.assertStatus(200)
+
+ def test_daemon_invalid_action(self):
+ payload = {
+ 'action': 'invalid',
+ 'container_image': None
+ }
+ with patch_orch(True):
+ self._put(f'{self.URL_DAEMON}/crash.b78cd1164a1b', payload, version=APIVersion(0, 1))
+ self.assertJsonBody({
+ 'detail': 'Daemon action "invalid" is either not valid or not supported.',
+ 'code': 'invalid_daemon_action',
+ 'component': None
+ })
+ self.assertStatus(400)
-import contextlib
import unittest
-from typing import List, Optional
from unittest import mock
-from orchestrator import HostSpec, InventoryHost
+from orchestrator import HostSpec
from .. import mgr
from ..controllers._version import APIVersion
from ..controllers.host import Host, HostUi, get_device_osd_map, get_hosts, get_inventories
-from ..tests import ControllerTestCase
+from ..tests import ControllerTestCase, patch_orch
from ..tools import NotificationQueue, TaskManager
-@contextlib.contextmanager
-def patch_orch(available: bool, missing_features: Optional[List[str]] = None,
- hosts: Optional[List[HostSpec]] = None,
- inventory: Optional[List[dict]] = None):
- with mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') as instance:
- fake_client = mock.Mock()
- fake_client.available.return_value = available
- fake_client.get_missing_features.return_value = missing_features
-
- if hosts is not None:
- fake_client.hosts.list.return_value = hosts
-
- if inventory is not None:
- def _list_inventory(hosts=None, refresh=False): # pylint: disable=unused-argument
- inv_hosts = []
- for inv_host in inventory:
- if hosts is None or inv_host['name'] in hosts:
- inv_hosts.append(InventoryHost.from_json(inv_host))
- return inv_hosts
- fake_client.inventory.list.side_effect = _list_inventory
-
- instance.return_value = fake_client
- yield fake_client
-
-
class HostControllerTest(ControllerTestCase):
URL_HOST = '/api/host'