From ed2b4e7a569b26eb2eef56b6f331c4a7cda83a01 Mon Sep 17 00:00:00 2001 From: Avan Thakkar Date: Mon, 10 Jan 2022 14:52:26 +0530 Subject: [PATCH] mgr/dashboard: report ceph tracker bug/feature through GUI Fixes: https://tracker.ceph.com/issues/44851 Signed-off-by: Shreya Sharma Signed-off-by: Avan Thakkar --- qa/tasks/mgr/dashboard/test_feedback.py | 36 +++++ src/mon/MgrMonitor.cc | 1 + .../mgr/dashboard/controllers/feedback.py | 130 +++++++++++----- .../app/ceph/dashboard/dashboard.module.ts | 9 +- .../shared/feedback/feedback.component.html | 120 +++++++++++++++ .../shared/feedback/feedback.component.scss | 0 .../feedback/feedback.component.spec.ts | 73 +++++++++ .../shared/feedback/feedback.component.ts | 109 ++++++++++++++ .../dashboard-help.component.html | 3 + .../dashboard-help.component.ts | 6 + .../app/shared/api/feedback.service.spec.ts | 47 ++++++ .../src/app/shared/api/feedback.service.ts | 38 +++++ src/pybind/mgr/dashboard/module.py | 46 +----- src/pybind/mgr/dashboard/openapi.yaml | 111 ++++++++++++-- src/pybind/mgr/dashboard/services/feedback.py | 85 ----------- src/pybind/mgr/feedback/__init__.py | 2 + .../model/feedback.py => feedback/model.py} | 0 src/pybind/mgr/feedback/module.py | 139 ++++++++++++++++++ src/pybind/mgr/feedback/service.py | 49 ++++++ 19 files changed, 825 insertions(+), 179 deletions(-) create mode 100644 qa/tasks/mgr/dashboard/test_feedback.py create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts delete mode 100644 src/pybind/mgr/dashboard/services/feedback.py create mode 100644 src/pybind/mgr/feedback/__init__.py rename src/pybind/mgr/{dashboard/model/feedback.py => feedback/model.py} (100%) create mode 100644 src/pybind/mgr/feedback/module.py create mode 100644 src/pybind/mgr/feedback/service.py diff --git a/qa/tasks/mgr/dashboard/test_feedback.py b/qa/tasks/mgr/dashboard/test_feedback.py new file mode 100644 index 0000000000000..0ec5ac3188060 --- /dev/null +++ b/qa/tasks/mgr/dashboard/test_feedback.py @@ -0,0 +1,36 @@ +import time + +from .helper import DashboardTestCase + + +class FeedbackTest(DashboardTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._ceph_cmd(['mgr', 'module', 'enable', 'feedback']) + time.sleep(10) + + def test_create_api_key(self): + self._post('/api/feedback/api_key', {'api_key': 'testapikey'}, version='0.1') + self.assertStatus(201) + + def test_get_api_key(self): + response = self._get('/api/feedback/api_key', version='0.1') + self.assertStatus(200) + self.assertEqual(response, 'testapikey') + + def test_remove_api_key(self): + self._delete('/api/feedback/api_key', version='0.1') + self.assertStatus(204) + + def test_issue_tracker_create_with_invalid_key(self): + self._post('/api/feedback', {'api_key': 'invalidapikey', 'description': 'test', + 'project': 'dashboard', 'subject': 'sub', 'tracker': 'bug'}, + version='0.1') + self.assertStatus(400) + + def test_issue_tracker_create_with_invalid_params(self): + self._post('/api/feedback', {'api_key': '', 'description': 'test', 'project': 'xyz', + 'subject': 'testsub', 'tracker': 'invalid'}, version='0.1') + self.assertStatus(400) diff --git a/src/mon/MgrMonitor.cc b/src/mon/MgrMonitor.cc index ed44af17d0203..2b1b5cba3c196 100644 --- a/src/mon/MgrMonitor.cc +++ b/src/mon/MgrMonitor.cc @@ -106,6 +106,7 @@ const static std::map> always_on_modules = { "progress", "balancer", "devicehealth", + "feedback", "orchestrator", "rbd_support", "volumes", diff --git a/src/pybind/mgr/dashboard/controllers/feedback.py b/src/pybind/mgr/dashboard/controllers/feedback.py index e1a9eb31d6c20..3b3474d39ea47 100644 --- a/src/pybind/mgr/dashboard/controllers/feedback.py +++ b/src/pybind/mgr/dashboard/controllers/feedback.py @@ -1,58 +1,120 @@ # # -*- coding: utf-8 -*- +from .. import mgr from ..exceptions import DashboardException -from ..model.feedback import Feedback -from ..rest_client import RequestException from ..security import Scope -from ..services import feedback -from . import APIDoc, APIRouter, RESTController +from . import APIDoc, APIRouter, BaseController, Endpoint, ReadPermission, RESTController, UIRouter +from ._version import APIVersion @APIRouter('/feedback', Scope.CONFIG_OPT) -@APIDoc("Feedback API", "Report") +@APIDoc("Feedback", "Report") class FeedbackController(RESTController): - issueAPIkey = None - def __init__(self): # pragma: no cover - super().__init__() - self.tracker_client = feedback.CephTrackerClient() + @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL) + def list(self): + """ + List all issues details. + """ + try: + response = mgr.remote('feedback', 'get_issues') + except RuntimeError as error: + raise DashboardException(msg=f'Error in fetching issue list: {str(error)}', + http_status_code=error.status_code, + component='feedback') + return response - def create(self, project, tracker, subject, description): + @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL) + def create(self, project, tracker, subject, description, api_key=None): """ Create an issue. :param project: The affected ceph component. :param tracker: The tracker type. :param subject: The title of the issue. :param description: The description of the issue. + :param api_key: Ceph tracker api key. """ try: - new_issue = Feedback(Feedback.Project[project].value, - Feedback.TrackerType[tracker].value, subject, description) - except KeyError: - raise DashboardException(msg=f'{"Invalid arguments"}', component='feedback') - try: - return self.tracker_client.create_issue(new_issue) - except RequestException as error: - if error.status_code == 401: - raise DashboardException(msg=f'{"Invalid API key"}', - http_status_code=error.status_code, + response = mgr.remote('feedback', 'validate_and_create_issue', + project, tracker, subject, description, api_key) + except RuntimeError as error: + if "Invalid issue tracker API key" in str(error): + raise DashboardException(msg='Error in creating tracker issue: Invalid API key', + component='feedback') + if "KeyError" in str(error): + raise DashboardException(msg=f'Error in creating tracker issue: {error}', component='feedback') - raise error - except Exception: - raise DashboardException(msg=f'{"API key not set"}', - http_status_code=401, + raise DashboardException(msg=f'{error}', + http_status_code=500, component='feedback') - def get(self, issue_number): + return response + + +@APIRouter('/feedback/api_key', Scope.CONFIG_OPT) +@APIDoc("Feedback API", "Report") +class FeedbackApiController(RESTController): + + @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL) + def list(self): """ - Fetch issue details. - :param issueAPI: The issue tracker API access key. + Returns Ceph tracker API key. """ try: - return self.tracker_client.get_issues(issue_number) - except RequestException as error: - if error.status_code == 404: - raise DashboardException(msg=f'Issue {issue_number} not found', - http_status_code=error.status_code, - component='feedback') - raise error + api_key = mgr.remote('feedback', 'get_api_key') + except ImportError: + raise DashboardException(msg='Feedback module not found.', + http_status_code=404, + component='feedback') + except RuntimeError as error: + raise DashboardException(msg=f'{error}', + http_status_code=500, + component='feedback') + if api_key is None: + raise DashboardException(msg='Issue tracker API key is not set', + component='feedback') + return api_key + + @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL) + def create(self, api_key): + """ + Sets Ceph tracker API key. + :param api_key: The Ceph tracker API key. + """ + try: + response = mgr.remote('feedback', 'set_api_key', api_key) + except RuntimeError as error: + raise DashboardException(msg=f'{error}', + component='feedback') + return response + + @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL) + def bulk_delete(self): + """ + Deletes Ceph tracker API key. + """ + try: + response = mgr.remote('feedback', 'delete_api_key') + except RuntimeError as error: + raise DashboardException(msg=f'{error}', + http_status_code=500, + component='feedback') + return response + + +@UIRouter('/feedback/api_key', Scope.CONFIG_OPT) +class FeedbackUiController(BaseController): + @Endpoint() + @ReadPermission + def exist(self): + """ + Checks if Ceph tracker API key is stored. + """ + try: + response = mgr.remote('feedback', 'is_api_key_set') + except RuntimeError: + raise DashboardException(msg='Feedback module is not enabled', + http_status_code=404, + component='feedback') + + return response diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts index 1205de94dd6e0..4bdfd50a51bfd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { NgbNavModule, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; @@ -7,6 +8,7 @@ import { ChartsModule } from 'ng2-charts'; import { SharedModule } from '~/app/shared/shared.module'; import { CephSharedModule } from '../shared/ceph-shared.module'; +import { FeedbackComponent } from '../shared/feedback/feedback.component'; import { DashboardComponent } from './dashboard/dashboard.component'; import { HealthPieComponent } from './health-pie/health-pie.component'; import { HealthComponent } from './health/health.component'; @@ -25,7 +27,9 @@ import { OsdSummaryPipe } from './osd-summary.pipe'; SharedModule, ChartsModule, RouterModule, - NgbPopoverModule + NgbPopoverModule, + FormsModule, + ReactiveFormsModule ], declarations: [ @@ -37,7 +41,8 @@ import { OsdSummaryPipe } from './osd-summary.pipe'; MdsSummaryPipe, HealthPieComponent, InfoCardComponent, - InfoGroupComponent + InfoGroupComponent, + FeedbackComponent ] }) export class DashboardModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html new file mode 100644 index 0000000000000..57050d005205c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html @@ -0,0 +1,120 @@ + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts new file mode 100644 index 0000000000000..2deea36a74ce3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts @@ -0,0 +1,73 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { throwError } from 'rxjs'; + +import { FeedbackService } from '~/app/shared/api/feedback.service'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { configureTestBed, FormHelper } from '~/testing/unit-test-helper'; +import { FeedbackComponent } from './feedback.component'; + +describe('FeedbackComponent', () => { + let component: FeedbackComponent; + let fixture: ComponentFixture; + let feedbackService: FeedbackService; + let formHelper: FormHelper; + + configureTestBed({ + imports: [ + ComponentsModule, + HttpClientTestingModule, + RouterTestingModule, + ReactiveFormsModule, + ToastrModule.forRoot() + ], + declarations: [FeedbackComponent], + providers: [NgbActiveModal] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FeedbackComponent); + component = fixture.componentInstance; + feedbackService = TestBed.inject(FeedbackService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should open the form in a modal', () => { + const nativeEl = fixture.debugElement.nativeElement; + expect(nativeEl.querySelector('cd-modal')).not.toBe(null); + }); + + it('should redirect to mgr-modules if feedback module is not enabled', () => { + spyOn(feedbackService, 'isKeyExist').and.returnValue(throwError({ status: 400 })); + + component.ngOnInit(); + + expect(component.isFeedbackEnabled).toEqual(false); + expect(component.feedbackForm.disabled).toBeTruthy(); + }); + + it('should test invalid api-key', () => { + component.ngOnInit(); + formHelper = new FormHelper(component.feedbackForm); + + spyOn(feedbackService, 'createIssue').and.returnValue(throwError({ status: 400 })); + + formHelper.setValue('api_key', 'invalidkey'); + formHelper.setValue('project', 'dashboard'); + formHelper.setValue('tracker', 'bug'); + formHelper.setValue('subject', 'foo'); + formHelper.setValue('description', 'foo'); + component.onSubmit(); + + formHelper.expectError('api_key', 'invalidApiKey'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts new file mode 100644 index 0000000000000..91a49a1bd45b7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts @@ -0,0 +1,109 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { Subscription } from 'rxjs'; + +import { FeedbackService } from '~/app/shared/api/feedback.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 { NotificationService } from '~/app/shared/services/notification.service'; + +@Component({ + selector: 'cd-feedback', + templateUrl: './feedback.component.html', + styleUrls: ['./feedback.component.scss'] +}) +export class FeedbackComponent implements OnInit, OnDestroy { + title = 'Feedback'; + project: any = [ + 'dashboard', + 'block', + 'objects', + 'file_system', + 'ceph_manager', + 'orchestrator', + 'ceph_volume', + 'core_ceph' + ]; + tracker: string[] = ['bug', 'feature']; + api_key: string; + keySub: Subscription; + + feedbackForm: CdFormGroup; + isAPIKeySet = false; + isFeedbackEnabled = true; + + constructor( + private feedbackService: FeedbackService, + public activeModal: NgbActiveModal, + public actionLabels: ActionLabelsI18n, + public secondaryModal: NgbModal, + private notificationService: NotificationService, + private router: Router + ) {} + + ngOnInit() { + this.createForm(); + this.keySub = this.feedbackService.isKeyExist().subscribe({ + next: (data: boolean) => { + this.isAPIKeySet = data; + if (this.isAPIKeySet) { + this.feedbackForm.get('api_key').clearValidators(); + } + }, + error: () => { + this.isFeedbackEnabled = false; + this.feedbackForm.disable(); + } + }); + } + + private createForm() { + this.feedbackForm = new CdFormGroup({ + project: new FormControl('', Validators.required), + tracker: new FormControl('', Validators.required), + subject: new FormControl('', Validators.required), + description: new FormControl('', Validators.required), + api_key: new FormControl('', Validators.required) + }); + } + + ngOnDestroy() { + this.keySub.unsubscribe(); + } + + onSubmit() { + this.feedbackService + .createIssue( + this.feedbackForm.controls['project'].value, + this.feedbackForm.controls['tracker'].value, + this.feedbackForm.controls['subject'].value, + this.feedbackForm.controls['description'].value, + this.feedbackForm.controls['api_key'].value + ) + .subscribe({ + next: (result) => { + this.notificationService.show( + NotificationType.success, + $localize`Issue successfully created on Ceph Issue tracker`, + `Go to the tracker: ${result['message']['issue']['id']} ` + ); + }, + error: () => { + this.feedbackForm.get('api_key').setErrors({ invalidApiKey: true }); + this.feedbackForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.activeModal.close(); + } + }); + } + + redirect() { + this.activeModal.close(); + this.router.navigate(['/mgr-modules']); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html index 274ec71df78b1..ed5ad8f1535a7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html @@ -21,5 +21,8 @@ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts index 910a613335b2e..88da15472e3e5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { FeedbackComponent } from '~/app/ceph/shared/feedback/feedback.component'; import { Icons } from '~/app/shared/enum/icons.enum'; import { DocService } from '~/app/shared/services/doc.service'; import { ModalService } from '~/app/shared/services/modal.service'; @@ -16,6 +17,7 @@ export class DashboardHelpComponent implements OnInit { docsUrl: string; modalRef: NgbModalRef; icons = Icons; + bsModalRef: NgbModalRef; constructor(private modalService: ModalService, private docService: DocService) {} @@ -28,4 +30,8 @@ export class DashboardHelpComponent implements OnInit { openAboutModal() { this.modalRef = this.modalService.show(AboutComponent, null, { size: 'lg' }); } + + openFeedbackModal() { + this.bsModalRef = this.modalService.show(FeedbackComponent, null, { size: 'lg' }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.spec.ts new file mode 100644 index 0000000000000..ee0becd10ed72 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.spec.ts @@ -0,0 +1,47 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FeedbackService } from './feedback.service'; + +describe('FeedbackService', () => { + let service: FeedbackService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [FeedbackService] + }); + + beforeEach(() => { + service = TestBed.inject(FeedbackService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call checkAPIKey', () => { + service.isKeyExist().subscribe(); + const req = httpTesting.expectOne('ui-api/feedback/api_key/exist'); + expect(req.request.method).toBe('GET'); + }); + + it('should call createIssue to create issue tracker', () => { + service.createIssue('dashboard', 'bug', 'test', 'test', '').subscribe(); + const req = httpTesting.expectOne('api/feedback'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + api_key: '', + description: 'test', + project: 'dashboard', + subject: 'test', + tracker: 'bug' + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts new file mode 100644 index 0000000000000..c450bbe076f0c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts @@ -0,0 +1,38 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import * as _ from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class FeedbackService { + constructor(private http: HttpClient) {} + baseUIURL = 'api/feedback'; + + isKeyExist() { + return this.http.get('ui-api/feedback/api_key/exist'); + } + + createIssue( + project: string, + tracker: string, + subject: string, + description: string, + apiKey: string + ) { + return this.http.post( + 'api/feedback', + { + project: project, + tracker: tracker, + subject: subject, + description: description, + api_key: apiKey + }, + { + headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index 17e94b25b5337..e2cf3487d4c24 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -19,19 +19,16 @@ if TYPE_CHECKING: else: from typing_extensions import Literal -from mgr_module import CLICommand, CLIWriteCommand, HandleCommandResult, \ - MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key +from mgr_module import CLIWriteCommand, HandleCommandResult, MgrModule, \ + MgrStandbyModule, NotifyType, Option, _get_localized_key from mgr_util import ServerConfigException, build_url, \ create_self_signed_cert, get_default_addr, verify_tls_files from . import mgr from .controllers import Router, json_error_page from .grafana import push_local_dashboards -from .model.feedback import Feedback -from .rest_client import RequestException from .services.auth import AuthManager, AuthManagerTool, JwtManager from .services.exception import dashboard_exception_handler -from .services.feedback import CephTrackerClient from .services.rgw_client import configure_rgw_credentials from .services.sso import SSO_COMMANDS, handle_sso_command from .settings import handle_option_command, options_command_list, options_schema_list @@ -411,45 +408,6 @@ class Module(MgrModule, CherryPyConfig): return result return 0, 'Self-signed certificate created', '' - @CLICommand("dashboard get issue") - def get_issues_cli(self, issue_number: int): - try: - issue_number = int(issue_number) - except TypeError: - return -errno.EINVAL, '', f'Invalid issue number {issue_number}' - tracker_client = CephTrackerClient() - try: - response = tracker_client.get_issues(issue_number) - except RequestException as error: - if error.status_code == 404: - return -errno.EINVAL, '', f'Issue {issue_number} not found' - else: - return -errno.EREMOTEIO, '', f'Error: {str(error)}' - return 0, str(response), '' - - @CLICommand("dashboard create issue") - def report_issues_cli(self, project: str, tracker: str, subject: str, description: str): - ''' - Create an issue in the Ceph Issue tracker - Syntax: ceph dashboard create issue - ''' - try: - feedback = Feedback(Feedback.Project[project].value, - Feedback.TrackerType[tracker].value, subject, description) - except KeyError: - return -errno.EINVAL, '', 'Invalid arguments' - tracker_client = CephTrackerClient() - try: - response = tracker_client.create_issue(feedback) - except RequestException as error: - if error.status_code == 401: - return -errno.EINVAL, '', 'Invalid API Key' - else: - return -errno.EINVAL, '', f'Error: {str(error)}' - except Exception: - return -errno.EINVAL, '', 'Ceph Tracker API key not set' - return 0, str(response), '' - @CLIWriteCommand("dashboard set-rgw-credentials") def set_rgw_credentials(self): try: diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 46785f57c8376..43532585aa8e6 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2718,17 +2718,41 @@ paths: tags: - FeatureTogglesEndpoint /api/feedback: + get: + description: "\n List all issues details.\n " + parameters: [] + responses: + '200': + content: + application/vnd.ceph.api.v0.1+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: + - Report post: description: "\n Create an issue.\n :param project: The affected\ \ ceph component.\n :param tracker: The tracker type.\n :param\ \ subject: The title of the issue.\n :param description: The description\ - \ of the issue.\n " + \ of the issue.\n :param api_key: Ceph tracker api key.\n " parameters: [] requestBody: content: application/json: schema: properties: + api_key: + type: string description: type: string project: @@ -2746,14 +2770,42 @@ paths: responses: '201': content: - application/vnd.ceph.api.v1.0+json: + application/vnd.ceph.api.v0.1+json: type: object description: Resource created. '202': content: - application/vnd.ceph.api.v1.0+json: + 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: + - Report + /api/feedback/api_key: + delete: + description: "\n Deletes Ceph tracker API key.\n " + parameters: [] + responses: + '202': + content: + application/vnd.ceph.api.v0.1+json: type: object description: Operation is still executing. Please check the task queue. + '204': + content: + application/vnd.ceph.api.v0.1+json: + type: object + description: Resource deleted. '400': description: Operation exception. Please check the response body for details. '401': @@ -2767,20 +2819,13 @@ paths: - jwt: [] tags: - Report - /api/feedback/{issue_number}: get: - description: "\n Fetch issue details.\n :param issueAPI: The issue\ - \ tracker API access key.\n " - parameters: - - in: path - name: issue_number - required: true - schema: - type: integer + description: "\n Returns Ceph tracker API key.\n " + parameters: [] responses: '200': content: - application/vnd.ceph.api.v1.0+json: + application/vnd.ceph.api.v0.1+json: type: object description: OK '400': @@ -2796,6 +2841,44 @@ paths: - jwt: [] tags: - Report + post: + description: "\n Sets Ceph tracker API key.\n :param api_key:\ + \ The Ceph tracker API key.\n " + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + api_key: + type: string + required: + - api_key + type: object + responses: + '201': + content: + application/vnd.ceph.api.v0.1+json: + type: object + description: Resource created. + '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: + - Report /api/grafana/dashboards: post: parameters: [] @@ -10412,7 +10495,7 @@ tags: name: RbdSnapshot - description: RBD Trash Management API name: RbdTrash -- description: Feedback API +- description: Feedback name: Report - description: RGW Management API name: Rgw diff --git a/src/pybind/mgr/dashboard/services/feedback.py b/src/pybind/mgr/dashboard/services/feedback.py deleted file mode 100644 index 7e4c7ad70a9ea..0000000000000 --- a/src/pybind/mgr/dashboard/services/feedback.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- - -import json - -import requests -from requests.auth import AuthBase - -from ..model.feedback import Feedback -from ..rest_client import RequestException, RestClient -from ..settings import Settings - - -class config: - url = 'tracker.ceph.com' - port = 443 - - -class RedmineAuth(AuthBase): - def __init__(self): - try: - self.access_key = Settings.ISSUE_TRACKER_API_KEY - except KeyError: - self.access_key = None - - def __call__(self, r): - r.headers['X-Redmine-API-Key'] = self.access_key - return r - - -class CephTrackerClient(RestClient): - access_key = '' - - def __init__(self): - super().__init__(config.url, config.port, client_name='CephTracker', - ssl=True, auth=RedmineAuth(), ssl_verify=True) - - @staticmethod - def get_api_key(): - try: - access_key = Settings.ISSUE_TRACKER_API_KEY - except KeyError: - raise KeyError("Key not set") - if access_key == '': - raise KeyError("Empty key") - return access_key - - def get_issues(self, issue_number): - ''' - Fetch an issue from the Ceph Issue tracker - ''' - headers = { - 'Content-Type': 'application/json', - } - response = requests.get( - f'https://tracker.ceph.com/issues/{issue_number}.json', headers=headers) - if not response.ok: - raise RequestException( - "Request failed with status code {}\n" - .format(response.status_code), - self._handle_response_status_code(response.status_code), - response.content) - return {"message": response.text} - - def create_issue(self, feedback: Feedback): - ''' - Create an issue in the Ceph Issue tracker - ''' - try: - headers = { - 'Content-Type': 'application/json', - 'X-Redmine-API-Key': self.get_api_key(), - } - except KeyError: - raise Exception("Ceph Tracker API Key not set") - data = json.dumps(feedback.as_dict()) - response = requests.post( - f'https://tracker.ceph.com/projects/{feedback.project_id}/issues.json', - headers=headers, data=data) - if not response.ok: - raise RequestException( - "Request failed with status code {}\n" - .format(response.status_code), - self._handle_response_status_code(response.status_code), - response.content) - return {"message": response.text} diff --git a/src/pybind/mgr/feedback/__init__.py b/src/pybind/mgr/feedback/__init__.py new file mode 100644 index 0000000000000..0bc7059e7d45e --- /dev/null +++ b/src/pybind/mgr/feedback/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .module import FeedbackModule \ No newline at end of file diff --git a/src/pybind/mgr/dashboard/model/feedback.py b/src/pybind/mgr/feedback/model.py similarity index 100% rename from src/pybind/mgr/dashboard/model/feedback.py rename to src/pybind/mgr/feedback/model.py diff --git a/src/pybind/mgr/feedback/module.py b/src/pybind/mgr/feedback/module.py new file mode 100644 index 0000000000000..95683912c090b --- /dev/null +++ b/src/pybind/mgr/feedback/module.py @@ -0,0 +1,139 @@ + +""" +Feedback module + +See doc/mgr/feedback.rst for more info. +""" + +from requests.exceptions import RequestException + +from mgr_module import CLIReadCommand, HandleCommandResult, MgrModule +import errno + +from .service import CephTrackerClient +from .model import Feedback + + +class FeedbackModule(MgrModule): + + # there are CLI commands we implement + @CLIReadCommand('feedback set api-key') + def _cmd_feedback_set_api_key(self, key: str) -> HandleCommandResult: + """ + Set Ceph Issue Tracker API key + """ + try: + self.set_store('api_key', key) + except Exception as error: + return HandleCommandResult(stderr=f'Exception in setting API key : {error}') + return HandleCommandResult(stdout="Successfully updated API key") + + @CLIReadCommand('feedback delete api-key') + def _cmd_feedback_delete_api_key(self) -> HandleCommandResult: + """ + Delete Ceph Issue Tracker API key + """ + try: + self.set_store('api_key', None) + except Exception as error: + return HandleCommandResult(stderr=f'Exception in deleting API key : {error}') + return HandleCommandResult(stdout="Successfully deleted key") + + @CLIReadCommand('feedback get api-key') + def _cmd_feedback_get_api_key(self) -> HandleCommandResult: + """ + Get Ceph Issue Tracker API key + """ + try: + key = self.get_store('api_key') + if key is None: + return HandleCommandResult(stderr='Issue tracker key is not set. Set key with `ceph feedback api-key set `') + except Exception as error: + return HandleCommandResult(stderr=f'Error in retreiving issue tracker API key: {error}') + return HandleCommandResult(stdout=f'Your key: {key}') + + @CLIReadCommand('feedback issue list') + def _cmd_feedback_issue_list(self) -> HandleCommandResult: + """ + Fetch issue list + """ + tracker_client = CephTrackerClient() + try: + response = tracker_client.list_issues() + except Exception: + return HandleCommandResult(stderr="Error occurred. Try again later") + return HandleCommandResult(stdout=str(response)) + + @CLIReadCommand('feedback issue report') + def _cmd_feedback_issue_report(self, project: str, tracker: str, subject: str, description: str) -> HandleCommandResult: + """ + Create an issue + """ + try: + feedback = Feedback(Feedback.Project[project].value, + Feedback.TrackerType[tracker].value, subject, description) + except KeyError: + return -errno.EINVAL, '', 'Invalid arguments' + try: + current_api_key = self.get_store('api_key') + if current_api_key is None: + return HandleCommandResult(stderr='Issue tracker key is not set. Set key with `ceph set issue_key `') + except Exception as error: + return HandleCommandResult(stderr=f'Error in retreiving issue tracker API key: {error}') + tracker_client = CephTrackerClient() + try: + response = tracker_client.create_issue(feedback, current_api_key) + except RequestException as error: + return HandleCommandResult(stderr=f'Error in creating issue: {str(error)}. Please set valid API key.') + return HandleCommandResult(stdout=f'{str(response)}') + + def set_api_key(self, key: str): + try: + self.set_store('api_key', key) + except Exception as error: + raise RequestException(f'Exception in setting API key : {error}') + return 'Successfully updated API key' + + def get_api_key(self): + try: + key = self.get_store('api_key') + except Exception as error: + raise RequestException(f'Error in retreiving issue tracker API key : {error}') + return key + + def is_api_key_set(self): + try: + key = self.get_store('api_key') + except Exception as error: + raise RequestException(f'Error in retreiving issue tracker API key : {error}') + if key is None: + return False + return key != '' + + def delete_api_key(self): + try: + self.set_store('api_key', None) + except Exception as error: + raise RequestException(f'Exception in deleting API key : {error}') + return 'Successfully deleted API key' + + def get_issues(self): + tracker_client = CephTrackerClient() + return tracker_client.list_issues() + + def validate_and_create_issue(self, project: str, tracker: str, subject: str, description: str, api_key=None): + feedback = Feedback(Feedback.Project[project].value, + Feedback.TrackerType[tracker].value, subject, description) + tracker_client = CephTrackerClient() + stored_api_key = self.get_store('api_key') + try: + if api_key: + result = tracker_client.create_issue(feedback, api_key) + else: + result = tracker_client.create_issue(feedback, stored_api_key) + except RequestException: + self.set_store('api_key', None) + raise + if not stored_api_key: + self.set_store('api_key', api_key) + return result diff --git a/src/pybind/mgr/feedback/service.py b/src/pybind/mgr/feedback/service.py new file mode 100644 index 0000000000000..dc8c6b64a6d5a --- /dev/null +++ b/src/pybind/mgr/feedback/service.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +import json +import requests +from requests.exceptions import RequestException + +from .model import Feedback + +class config: + url = 'tracker.ceph.com' + port = 443 + +class CephTrackerClient(): + + def list_issues(self): + ''' + Fetch an issue from the Ceph Issue tracker + ''' + headers = { + 'Content-Type': 'application/json', + } + response = requests.get( + f'https://{config.url}/issues.json', headers=headers) + if not response.ok: + if response.status_code == 404: + raise FileNotFoundError + raise RequestException(response.status_code) + return {"message": response.json()} + + def create_issue(self, feedback: Feedback, api_key: str): + ''' + Create an issue in the Ceph Issue tracker + ''' + try: + headers = { + 'Content-Type': 'application/json', + 'X-Redmine-API-Key': api_key, + } + except KeyError: + raise Exception("Ceph Tracker API Key not set") + data = json.dumps(feedback.as_dict()) + response = requests.post( + f'https://{config.url}/projects/{feedback.project_id}/issues.json', + headers=headers, data=data) + if not response.ok: + if response.status_code == 401: + raise RequestException("Unauthorized. Invalid issue tracker API key") + raise RequestException(response.reason) + return {"message": response.json()} -- 2.39.5