]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/HACKING.rst
import new upstream nautilus stable release 14.2.8
[ceph.git] / ceph / src / pybind / mgr / dashboard / HACKING.rst
CommitLineData
11fdf7f2
TL
1Ceph Dashboard Developer Documentation
2======================================
31f18b77 3
11fdf7f2 4.. contents:: Table of Contents
31f18b77 5
11fdf7f2
TL
6Frontend Development
7--------------------
31f18b77 8
11fdf7f2
TL
9Before you can start the dashboard from within a development environment, you
10will need to generate the frontend code and either use a compiled and running
11Ceph cluster (e.g. started by ``vstart.sh``) or the standalone development web
12server.
31f18b77 13
11fdf7f2
TL
14The build process is based on `Node.js <https://nodejs.org/>`_ and requires the
15`Node Package Manager <https://www.npmjs.com/>`_ ``npm`` to be installed.
31f18b77 16
11fdf7f2
TL
17Prerequisites
18~~~~~~~~~~~~~
31f18b77 19
11fdf7f2
TL
20 * Node 8.9.0 or higher
21 * NPM 5.7.0 or higher
31f18b77 22
11fdf7f2
TL
23nodeenv:
24 During Ceph's build we create a virtualenv with ``node`` and ``npm``
25 installed, which can be used as an alternative to installing node/npm in your
26 system.
31f18b77 27
11fdf7f2
TL
28 If you want to use the node installed in the virtualenv you just need to
29 activate the virtualenv before you run any npm commands. To activate it run
30 ``. build/src/pybind/mgr/dashboard/node-env/bin/activate``.
31f18b77 31
11fdf7f2 32 Once you finish, you can simply run ``deactivate`` and exit the virtualenv.
31f18b77 33
11fdf7f2
TL
34Angular CLI:
35 If you do not have the `Angular CLI <https://github.com/angular/angular-cli>`_
36 installed globally, then you need to execute ``ng`` commands with an
37 additional ``npm run`` before it.
31f18b77 38
11fdf7f2
TL
39Package installation
40~~~~~~~~~~~~~~~~~~~~
31f18b77 41
11fdf7f2
TL
42Run ``npm install`` in directory ``src/pybind/mgr/dashboard/frontend`` to
43install the required packages locally.
31f18b77 44
11fdf7f2
TL
45Setting up a Development Server
46~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
31f18b77 47
11fdf7f2 48Create the ``proxy.conf.json`` file based on ``proxy.conf.json.sample``.
31f18b77 49
11fdf7f2
TL
50Run ``npm start`` for a dev server.
51Navigate to ``http://localhost:4200/``. The app will automatically
52reload if you change any of the source files.
31f18b77 53
11fdf7f2
TL
54Code Scaffolding
55~~~~~~~~~~~~~~~~
56
57Run ``ng generate component component-name`` to generate a new
58component. You can also use
59``ng generate directive|pipe|service|class|guard|interface|enum|module``.
60
61Build the Project
62~~~~~~~~~~~~~~~~~
63
64Run ``npm run build`` to build the project. The build artifacts will be
65stored in the ``dist/`` directory. Use the ``-prod`` flag for a
66production build. Navigate to ``https://localhost:8443``.
67
81eedcae
TL
68Build the Code Documentation
69~~~~~~~~~~~~~~~~~~~~~~~~~~~~
70
71Run ``npm run doc-build`` to generate code docs in the ``documentation/``
72directory. To make them accesible locally for a web browser, run
73``npm run doc-serve`` and they will become available at ``http://localhost:8444``.
74With ``npm run compodoc -- <opts>`` you may
75`fully configure it https://compodoc.app/guides/usage.html`_.
76
11fdf7f2
TL
77Code linting and formatting
78~~~~~~~~~~~~~~~~~~~~~~~~~~~~
79
80We use the following tools to lint and format the code in all our TS, SCSS and
81HTML files:
82
83- `codelyzer <http://codelyzer.com/>`_
84- `html-linter <https://github.com/chinchiheather/html-linter>`_
85- `Prettier <https://prettier.io/>`_
86- `TSLint <https://palantir.github.io/tslint/>`_
87
88We added 2 npm scripts to help run these tools:
89
90- ``npm run lint``, will check frontend files against all linters
91- ``npm run fix``, will try to fix all the detected linting errors
92
93Writing Unit Tests
94~~~~~~~~~~~~~~~~~~
95
96To write unit tests most efficient we have a small collection of tools,
97we use within test suites.
98
99Those tools can be found under
100``src/pybind/mgr/dashboard/frontend/src/testing/``, especially take
101a look at ``unit-test-helper.ts``.
102
103There you will be able to find:
104
105``configureTestBed`` that replaces the initial ``TestBed``
106methods. It takes the same arguments as ``TestBed.configureTestingModule``.
107Using it will run your tests a lot faster in development, as it doesn't
108recreate everything from scratch on every test. To use the default behaviour
109pass ``true`` as the second argument.
110
111``PermissionHelper`` to help determine if
112the correct actions are shown based on the current permissions and selection
113in a list.
114
115``FormHelper`` which makes testing a form a lot easier
116with a few simple methods. It allows you to set a control or multiple
117controls, expect if a control is valid or has an error or just do both with
118one method. Additional you can expect a template element or multiple elements
119to be visible in the rendered template.
120
121Running Unit Tests
122~~~~~~~~~~~~~~~~~~
123
124Create ``unit-test-configuration.ts`` file based on
125``unit-test-configuration.ts.sample`` in directory
126``src/pybind/mgr/dashboard/frontend/src``.
127
128Run ``npm run test`` to execute the unit tests via `Jest
129<https://facebook.github.io/jest/>`_.
130
131If you get errors on all tests, it could be because `Jest
132<https://facebook.github.io/jest/>`_ or something else was updated.
133There are a few ways how you can try to resolve this:
134
135- Remove all modules with ``rm -rf dist node_modules`` and run ``npm install``
136 again in order to reinstall them
137- Clear the cache of jest by running ``npx jest --clearCache``
138
139Running End-to-End Tests
140~~~~~~~~~~~~~~~~~~~~~~~~
141
142We use `Protractor <http://www.protractortest.org/>`__ to run our frontend e2e
143tests.
144
145Our ``run-frontend-e2e-tests.sh`` script will check if Chrome or Docker is
146installed and run the tests if either is found.
147
148Start all frontend e2e tests by running::
149
150 $ ./run-frontend-e2e-tests.sh
31f18b77 151
11fdf7f2
TL
152Device:
153 You can force the script to use a specific device with the ``-d`` flag::
31f18b77 154
11fdf7f2 155 $ ./run-frontend-e2e-tests.sh -d <chrome|docker>
31f18b77 156
11fdf7f2
TL
157Remote:
158 If you want to run the tests outside the ceph environment, you will need to
159 manually define the dashboard url using ``-r``::
160
161 $ ./run-frontend-e2e-tests.sh -r <DASHBOARD_URL>
162
163Note:
164 When using docker, as your device, you might need to run the script with sudo
165 permissions.
166
167Writing End-to-End Tests
168~~~~~~~~~~~~~~~~~~~~~~~~
169
170When writing e2e tests you don't want to recompile every time from scratch to
171try out if your test has succeeded. As usual you have your development server
172open (``npm start``) which already has compiled all files. To attach
173`Protractor <http://www.protractortest.org/>`__ to this process, instead of
174spinning up it's own server, you can use ``npm run e2e -- --dev-server-target``
175or just ``npm run e2e:dev`` which is equivalent.
176
177Further Help
178~~~~~~~~~~~~
179
180To get more help on the Angular CLI use ``ng help`` or go check out the
181`Angular CLI
182README <https://github.com/angular/angular-cli/blob/master/README.md>`__.
183
184Example of a Generator
185~~~~~~~~~~~~~~~~~~~~~~
31f18b77
FG
186
187::
188
11fdf7f2
TL
189 # Create module 'Core'
190 src/app> ng generate module core -m=app --routing
191
192 # Create module 'Auth' under module 'Core'
193 src/app/core> ng generate module auth -m=core --routing
194 or, alternatively:
195 src/app> ng generate module core/auth -m=core --routing
196
197 # Create component 'Login' under module 'Auth'
198 src/app/core/auth> ng generate component login -m=core/auth
199 or, alternatively:
200 src/app> ng generate component core/auth/login -m=core/auth
201
202Frontend Typescript Code Style Guide Recommendations
203~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
204
205Group the imports based on its source and separate them with a blank
206line.
207
208The source groups can be either from Angular, external or internal.
209
210Example:
211
212.. code:: javascript
213
214 import { Component } from '@angular/core';
215 import { Router } from '@angular/router';
216
494da23a 217 import { ToastrManager } from 'ngx-toastr';
11fdf7f2
TL
218
219 import { Credentials } from '../../../shared/models/credentials.model';
220 import { HostService } from './services/host.service';
221
222Frontend components
223~~~~~~~~~~~~~~~~~~~
224
225There are several components that can be reused on different pages.
226This components are declared on the components module:
227`src/pybind/mgr/dashboard/frontend/src/app/shared/components`.
228
229Helper
230......
231
232This component should be used to provide additional information to the user.
233
234Example:
235
236.. code:: html
237
238 <cd-helper>
239 Some <strong>helper</strong> html text
240 </cd-helper>
241
242Terminology and wording
243~~~~~~~~~~~~~~~~~~~~~~~
244
245Instead of using the Ceph component names, the approach
246suggested is to use the logical/generic names (Block over RBD, Filesystem over
247CephFS, Object over RGW). Nevertheless, as Ceph-Dashboard cannot completely hide
248the Ceph internals, some Ceph-specific names might remain visible.
249
250Regarding the wording for action labels and other textual elements (form titles,
251buttons, etc.), the chosen approach is to follow `these guidelines
252<https://www.patternfly.org/styles/terminology-and-wording/#terminology-and-wording-for-action-labels>`_.
253As a rule of thumb, 'Create' and 'Delete' are the proper wording for most forms,
254instead of 'Add' and 'Remove', unless some already created item is either added
255or removed to/from a set of items (e.g.: 'Add permission' to a user vs. 'Create
256(new) permission').
31f18b77 257
11fdf7f2
TL
258In order to enforce the use of this wording, a service ``ActionLabelsI18n`` has
259been created, which provides translated labels for use in UI elements.
494da23a 260
11fdf7f2
TL
261Frontend branding
262~~~~~~~~~~~~~~~~~
31f18b77 263
11fdf7f2
TL
264Every vendor can customize the 'Ceph dashboard' to his needs. No matter if
265logo, HTML-Template or TypeScript, every file inside the frontend folder can be
266replaced.
31f18b77 267
11fdf7f2
TL
268To replace files, open ``./frontend/angular.json`` and scroll to the section
269``fileReplacements`` inside the production configuration. Here you can add the
270files you wish to brand. We recommend to place the branded version of a file in
271the same directory as the original one and to add a ``.brand`` to the file
272name, right in front of the file extension. A ``fileReplacement`` could for
273example look like this:
31f18b77 274
11fdf7f2 275.. code:: javascript
31f18b77 276
11fdf7f2
TL
277 {
278 "replace": "src/app/core/auth/login/login.component.html",
279 "with": "src/app/core/auth/login/login.component.brand.html"
280 }
281
282To serve or build the branded user interface run:
283
284 $ npm run start -- --prod
285
286or
287
288 $ npm run build -- --prod
289
290Unfortunately it's currently not possible to use multiple configurations when
291serving or building the UI at the same time. That means a configuration just
292for the branding ``fileReplacements`` is not an option, because you want to use
293the production configuration anyway
294(https://github.com/angular/angular-cli/issues/10612).
295Furthermore it's also not possible to use glob expressions for
296``fileReplacements``. As long as the feature hasn't been implemented, you have
297to add the file replacements manually to the angular.json file
298(https://github.com/angular/angular-cli/issues/12354).
299
300Nevertheless you should stick to the suggested naming scheme because it makes
301it easier for you to use glob expressions once it's supported in the future.
302
303To change the variable defaults you can overwrite them in the file
304``./frontend/src/vendor.variables.scss``. Just reassign the variable you want
305to change, for example ``$color-primary: teal;``
306To overwrite or extend the default CSS, you can add your own styles in
307``./frontend/src/vendor.overrides.scss``.
308
309I18N
31f18b77
FG
310----
311
11fdf7f2
TL
312How to extract messages from source code?
313~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
314
315To extract the I18N messages from the templates and the TypeScript files just
316run the following command in ``src/pybind/mgr/dashboard/frontend``::
317
eafe8130 318 $ npm run i18n:extract
11fdf7f2
TL
319
320This will extract all marked messages from the HTML templates first and then
321add all marked strings from the TypeScript files to the translation template.
322Since the extraction from TypeScript files is still not supported by Angular
323itself, we are using the
324`ngx-translator <https://github.com/ngx-translate/i18n-polyfill>`_ extractor to
325parse the TypeScript files.
326
327When the command ran successfully, it should have created or updated the file
328``src/locale/messages.xlf``.
329
81eedcae
TL
330The file isn't tracked by git, you can just use it to start with the
331translation offline or add/update the resource files on transifex.
11fdf7f2
TL
332
333Supported languages
334~~~~~~~~~~~~~~~~~~~
335
81eedcae
TL
336All our supported languages should be registered in both exports in
337``supported-languages.enum.ts`` and have a corresponding test in
338``language-selector.component.spec.ts``.
339
340The ``SupportedLanguages`` enum will provide the list for the default language selection.
341
342The ``languageBootstrapMapping`` variable will provide the
343`language support <https://github.com/valor-software/ngx-bootstrap/tree/development/src/chronos/i18n>`_
344for ngx-bootstrap components like the
345`date picker <https://valor-software.com/ngx-bootstrap/#/datepicker#locales>`_.
11fdf7f2
TL
346
347Translating process
348~~~~~~~~~~~~~~~~~~~
349
350To facilitate the translation process of the dashboard we are using a web tool
351called `transifex <https://www.transifex.com/>`_.
352
353If you wish to help translating to any language just go to our `transifex
354project page <https://www.transifex.com/ceph/ceph-dashboard/>`_, join the
355project and you can start translating immediately.
356
357All translations will then be reviewed and later pushed upstream.
358
359Updating translated messages
360~~~~~~~~~~~~~~~~~~~~~~~~~~~~
361
362Any time there are new messages translated and reviewed in a specific language
363we should update the translation file upstream.
364
eafe8130
TL
365To do that, check the settings in the i18n config file
366``src/pybind/mgr/dashboard/frontend/i18n.config.json``:: and make sure that the
367organization is *ceph*, the project is *ceph-dashboard* and the resource is
368the one you want to pull from and push to e.g. *Master:master*. To find a list
369of avaiable resources visit ``https://www.transifex.com/ceph/ceph-dashboard/content/``::
370
371After you checked the config go to the directory ``src/pybind/mgr/dashboard/frontend``:: and run
372
373 $ npm run i18n
374
375This command will extract all marked messages from the HTML templates and
376TypeScript files. Once the source file has been created it will push it to
377transifex and pull the latest translations. It will also fill all the
378untranslated strings with the source string.
379The tool will ask you for an api token, unless you added it by running:
380
381 $ npm run i18n:token
382
383To create a transifex api token visit ``https://www.transifex.com/user/settings/api/``::
11fdf7f2 384
eafe8130
TL
385After the command ran successfully, build the UI and check if everything is
386working as expected. You also might want to run the frontend tests.
11fdf7f2
TL
387
388Suggestions
389~~~~~~~~~~~
390
391Strings need to start and end in the same line as the element:
392
393.. code-block:: xml
394
395 <!-- avoid -->
396 <span i18n>
397 Foo
398 </span>
399
400 <!-- recommended -->
401 <span i18n>Foo</span>
402
403
404 <!-- avoid -->
405 <span i18n>
406 Foo bar baz.
407 Foo bar baz.
408 </span>
409
410 <!-- recommended -->
411 <span i18n>Foo bar baz.
412 Foo bar baz.</span>
413
414Isolated interpolations should not be translated:
415
416.. code-block:: xml
417
418 <!-- avoid -->
419 <span i18n>{{ foo }}</span>
420
421 <!-- recommended -->
422 <span>{{ foo }}</span>
423
424Interpolations used in a sentence should be kept in the translation:
425
426.. code-block:: xml
427
428 <!-- recommended -->
429 <span i18n>There are {{ x }} OSDs.</span>
430
431Remove elements that are outside the context of the translation:
432
433.. code-block:: xml
434
435 <!-- avoid -->
436 <label i18n>
437 Profile
438 <span class="required"></span>
439 </label>
440
441 <!-- recommended -->
442 <label>
443 <ng-container i18n>Profile<ng-container>
444 <span class="required"></span>
445 </label>
446
447Keep elements that affect the sentence:
448
449.. code-block:: xml
450
451 <!-- recommended -->
452 <span i18n>Profile <b>foo</b> will be removed.</span>
453
454Backend Development
455-------------------
456
457The Python backend code of this module requires a number of Python modules to be
458installed. They are listed in file ``requirements.txt``. Using `pip
459<https://pypi.python.org/pypi/pip>`_ you may install all required dependencies
460by issuing ``pip install -r requirements.txt`` in directory
461``src/pybind/mgr/dashboard``.
462
463If you're using the `ceph-dev-docker development environment
464<https://github.com/ricardoasmarques/ceph-dev-docker/>`_, simply run
465``./install_deps.sh`` from the toplevel directory to install them.
466
467Unit Testing
468~~~~~~~~~~~~
469
470In dashboard we have two different kinds of backend tests:
471
4721. Unit tests based on ``tox``
4732. API tests based on Teuthology.
474
475Unit tests based on tox
476~~~~~~~~~~~~~~~~~~~~~~~~
477
478We included a ``tox`` configuration file that will run the unit tests under
479Python 2 or 3, as well as linting tools to guarantee the uniformity of code.
480
481You need to install ``tox`` and ``coverage`` before running it. To install the
482packages in your system, either install it via your operating system's package
483management tools, e.g. by running ``dnf install python-tox python-coverage`` on
484Fedora Linux.
485
486Alternatively, you can use Python's native package installation method::
487
488 $ pip install tox
489 $ pip install coverage
490
491To run the tests, run ``run-tox.sh`` in the dashboard directory (where
492``tox.ini`` is located)::
493
494 ## Run Python 2+3 tests+lint commands:
495 $ ./run-tox.sh
496
497 ## Run Python 3 tests+lint commands:
498 $ WITH_PYTHON2=OFF ./run-tox.sh
499
500 ## Run Python 3 arbitrary command (e.g. 1 single test):
501 $ WITH_PYTHON2=OFF ./run-tox.sh pytest tests/test_rgw_client.py::RgwClientTest::test_ssl_verify
502
503You can also run tox instead of ``run-tox.sh``::
504
505 ## Run Python 3 tests command:
506 $ CEPH_BUILD_DIR=.tox tox -e py3-cov
507
508 ## Run Python 3 arbitrary command (e.g. 1 single test):
509 $ CEPH_BUILD_DIR=.tox tox -e py3-run pytest tests/test_rgw_client.py::RgwClientTest::test_ssl_verify
510
511We also collect coverage information from the backend code when you run tests. You can check the
512coverage information provided by the tox output, or by running the following
513command after tox has finished successfully::
514
515 $ coverage html
516
517This command will create a directory ``htmlcov`` with an HTML representation of
518the code coverage of the backend.
519
520API tests based on Teuthology
521~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
522
523How to run existing API tests:
524 To run the API tests against a real Ceph cluster, we leverage the Teuthology
525 framework. This has the advantage of catching bugs originated from changes in
526 the internal Ceph code.
527
528 Our ``run-backend-api-tests.sh`` script will start a ``vstart`` Ceph cluster
529 before running the Teuthology tests, and then it stops the cluster after the
530 tests are run. Of course this implies that you have built/compiled Ceph
531 previously.
532
533 Start all dashboard tests by running::
534
535 $ ./run-backend-api-tests.sh
536
537 Or, start one or multiple specific tests by specifying the test name::
538
539 $ ./run-backend-api-tests.sh tasks.mgr.dashboard.test_pool.PoolTest
540
541 Or, ``source`` the script and run the tests manually::
542
543 $ source run-backend-api-tests.sh
544 $ run_teuthology_tests [tests]...
545 $ cleanup_teuthology
546
547How to write your own tests:
548 There are two possible ways to write your own API tests:
549
550 The first is by extending one of the existing test classes in the
551 ``qa/tasks/mgr/dashboard`` directory.
552
553 The second way is by adding your own API test module if you're creating a new
554 controller for example. To do so you'll just need to add the file containing
555 your new test class to the ``qa/tasks/mgr/dashboard`` directory and implement
556 all your tests here.
557
558 .. note:: Don't forget to add the path of the newly created module to
559 ``modules`` section in ``qa/suites/rados/mgr/tasks/dashboard.yaml``.
560
561 Short example: Let's assume you created a new controller called
562 ``my_new_controller.py`` and the related test module
563 ``test_my_new_controller.py``. You'll need to add
564 ``tasks.mgr.dashboard.test_my_new_controller`` to the ``modules`` section in
565 the ``dashboard.yaml`` file.
566
567 Also, if you're removing test modules please keep in mind to remove the
568 related section. Otherwise the Teuthology test run will fail.
569
570 Please run your API tests on your dev environment (as explained above)
571 before submitting a pull request. Also make sure that a full QA run in
572 Teuthology/sepia lab (based on your changes) has completed successfully
573 before it gets merged. You don't need to schedule the QA run yourself, just
574 add the 'needs-qa' label to your pull request as soon as you think it's ready
575 for merging (e.g. make check was successful, the pull request is approved and
576 all comments have been addressed). One of the developers who has access to
577 Teuthology/the sepia lab will take care of it and report the result back to
578 you.
579
580
581How to add a new controller?
582~~~~~~~~~~~~~~~~~~~~~~~~~~~~
583
584A controller is a Python class that extends from the ``BaseController`` class
585and is decorated with either the ``@Controller``, ``@ApiController`` or
586``@UiApiController`` decorators. The Python class must be stored inside a Python
587file located under the ``controllers`` directory. The Dashboard module will
588automatically load your new controller upon start.
589
590``@ApiController`` and ``@UiApiController`` are both specializations of the
591``@Controller`` decorator.
592
593The ``@ApiController`` should be used for controllers that provide an API-like
594REST interface and the ``@UiApiController`` should be used for endpoints consumed
595by the UI but that are not part of the 'public' API. For any other kinds of
596controllers the ``@Controller`` decorator should be used.
597
598A controller has a URL prefix path associated that is specified in the
599controller decorator, and all endpoints exposed by the controller will share
600the same URL prefix path.
601
602A controller's endpoint is exposed by implementing a method on the controller
603class decorated with the ``@Endpoint`` decorator.
604
605For example create a file ``ping.py`` under ``controllers`` directory with the
606following code:
607
608.. code-block:: python
609
610 from ..tools import Controller, ApiController, UiApiController, BaseController, Endpoint
611
612 @Controller('/ping')
613 class Ping(BaseController):
614 @Endpoint()
615 def hello(self):
616 return {'msg': "Hello"}
617
618 @ApiController('/ping')
619 class ApiPing(BaseController):
620 @Endpoint()
621 def hello(self):
622 return {'msg': "Hello"}
623
624 @UiApiController('/ping')
625 class UiApiPing(BaseController):
626 @Endpoint()
627 def hello(self):
628 return {'msg': "Hello"}
629
630The ``hello`` endpoint of the ``Ping`` controller can be reached by the
631following URL: https://mgr_hostname:8443/ping/hello using HTTP GET requests.
632As you can see the controller URL path ``/ping`` is concatenated to the
633method name ``hello`` to generate the endpoint's URL.
634
635In the case of the ``ApiPing`` controller, the ``hello`` endpoint can be
636reached by the following URL: https://mgr_hostname:8443/api/ping/hello using a
637HTTP GET request.
638The API controller URL path ``/ping`` is prefixed by the ``/api`` path and then
639concatenated to the method name ``hello`` to generate the endpoint's URL.
640Internally, the ``@ApiController`` is actually calling the ``@Controller``
641decorator by passing an additional decorator parameter called ``base_url``::
642
643 @ApiController('/ping') <=> @Controller('/ping', base_url="/api")
644
645``UiApiPing`` works in a similar way than the ``ApiPing``, but the URL will be
646prefixed by ``/ui-api``: https://mgr_hostname:8443/ui-api/ping/hello. ``UiApiPing`` is
647also a ``@Controller`` extension::
648
649 @UiApiController('/ping') <=> @Controller('/ping', base_url="/ui-api")
650
651The ``@Endpoint`` decorator also supports many parameters to customize the
652endpoint:
653
654* ``method="GET"``: the HTTP method allowed to access this endpoint.
655* ``path="/<method_name>"``: the URL path of the endpoint, excluding the
656 controller URL path prefix.
657* ``path_params=[]``: list of method parameter names that correspond to URL
658 path parameters. Can only be used when ``method in ['POST', 'PUT']``.
659* ``query_params=[]``: list of method parameter names that correspond to URL
660 query parameters.
661* ``json_response=True``: indicates if the endpoint response should be
662 serialized in JSON format.
663* ``proxy=False``: indicates if the endpoint should be used as a proxy.
664
665An endpoint method may have parameters declared. Depending on the HTTP method
666defined for the endpoint the method parameters might be considered either
667path parameters, query parameters, or body parameters.
668
669For ``GET`` and ``DELETE`` methods, the method's non-optional parameters are
670considered path parameters by default. Optional parameters are considered
671query parameters. By specifying the ``query_parameters`` in the endpoint
672decorator it is possible to make a non-optional parameter to be a query
673parameter.
674
675For ``POST`` and ``PUT`` methods, all method parameters are considered
676body parameters by default. To override this default, one can use the
677``path_params`` and ``query_params`` to specify which method parameters are
678path and query parameters respectivelly.
679Body parameters are decoded from the request body, either from a form format, or
680from a dictionary in JSON format.
681
682Let's use an example to better understand the possible ways to customize an
683endpoint:
684
685.. code-block:: python
686
687 from ..tools import Controller, BaseController, Endpoint
688
689 @Controller('/ping')
690 class Ping(BaseController):
691
692 # URL: /ping/{key}?opt1=...&opt2=...
693 @Endpoint(path="/", query_params=['opt1'])
694 def index(self, key, opt1, opt2=None):
695 # ...
696
697 # URL: /ping/{key}?opt1=...&opt2=...
698 @Endpoint(query_params=['opt1'])
699 def __call__(self, key, opt1, opt2=None):
700 # ...
701
702 # URL: /ping/post/{key1}/{key2}
703 @Endpoint('POST', path_params=['key1', 'key2'])
704 def post(self, key1, key2, data1, data2=None):
705 # ...
706
707
708In the above example we see how the ``path`` option can be used to override the
709generated endpoint URL in order to not use the method's name in the URL. In the
710``index`` method we set the ``path`` to ``"/"`` to generate an endpoint that is
711accessible by the root URL of the controller.
712
713An alternative approach to generate an endpoint that is accessible through just
714the controller's path URL is by using the ``__call__`` method, as we show in
715the above example.
716
717From the third method you can see that the path parameters are collected from
718the URL by parsing the list of values separated by slashes ``/`` that come
719after the URL path ``/ping`` for ``index`` method case, and ``/ping/post`` for
720the ``post`` method case.
721
722Defining path parameters in endpoints's URLs using python methods's parameters
723is very easy but it is still a bit strict with respect to the position of these
724parameters in the URL structure.
725Sometimes we may want to explicitly define a URL scheme that
726contains path parameters mixed with static parts of the URL.
727Our controller infrastructure also supports the declaration of URL paths with
728explicit path parameters at both the controller level and method level.
729
730Consider the following example:
731
732.. code-block:: python
733
734 from ..tools import Controller, BaseController, Endpoint
735
736 @Controller('/ping/{node}/stats')
737 class Ping(BaseController):
738
739 # URL: /ping/{node}/stats/{date}/latency?unit=...
740 @Endpoint(path="/{date}/latency")
741 def latency(self, node, date, unit="ms"):
742 # ...
743
744In this example we explicitly declare a path parameter ``{node}`` in the
745controller URL path, and a path parameter ``{date}`` in the ``latency``
746method. The endpoint for the ``latency`` method is then accessible through
747the URL: https://mgr_hostname:8443/ping/{node}/stats/{date}/latency .
748
749For a full set of examples on how to use the ``@Endpoint``
750decorator please check the unit test file: ``tests/test_controllers.py``.
751There you will find many examples of how to customize endpoint methods.
752
753
754Implementing Proxy Controller
755~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
756
757Sometimes you might need to relay some requests from the Dashboard frontend
758directly to an external service.
759For that purpose we provide a decorator called ``@Proxy``.
760(As a concrete example, check the ``controllers/rgw.py`` file where we
761implemented an RGW Admin Ops proxy.)
762
763
764The ``@Proxy`` decorator is a wrapper of the ``@Endpoint`` decorator that
765already customizes the endpoint for working as a proxy.
766A proxy endpoint works by capturing the URL path that follows the controller
767URL prefix path, and does not do any decoding of the request body.
768
769Example:
770
771.. code-block:: python
772
773 from ..tools import Controller, BaseController, Proxy
774
775 @Controller('/foo/proxy')
776 class FooServiceProxy(BaseController):
777
778 @Proxy()
779 def proxy(self, path, **params):
780 # if requested URL is "/foo/proxy/access/service?opt=1"
781 # then path is "access/service" and params is {'opt': '1'}
782 # ...
783
784
785How does the RESTController work?
786~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
787
788We also provide a simple mechanism to create REST based controllers using the
789``RESTController`` class. Any class which inherits from ``RESTController`` will,
790by default, return JSON.
791
792The ``RESTController`` is basically an additional abstraction layer which eases
793and unifies the work with collections. A collection is just an array of objects
794with a specific type. ``RESTController`` enables some default mappings of
795request types and given parameters to specific method names. This may sound
796complicated at first, but it's fairly easy. Lets have look at the following
797example:
798
799.. code-block:: python
800
801 import cherrypy
802 from ..tools import ApiController, RESTController
803
804 @ApiController('ping')
805 class Ping(RESTController):
806 def list(self):
807 return {"msg": "Hello"}
808
809 def get(self, id):
810 return self.objects[id]
811
812In this case, the ``list`` method is automatically used for all requests to
813``api/ping`` where no additional argument is given and where the request type
814is ``GET``. If the request is given an additional argument, the ID in our
815case, it won't map to ``list`` anymore but to ``get`` and return the element
816with the given ID (assuming that ``self.objects`` has been filled before). The
817same applies to other request types:
818
819+--------------+------------+----------------+-------------+
820| Request type | Arguments | Method | Status Code |
821+==============+============+================+=============+
822| GET | No | list | 200 |
823+--------------+------------+----------------+-------------+
824| PUT | No | bulk_set | 200 |
825+--------------+------------+----------------+-------------+
826| POST | No | create | 201 |
827+--------------+------------+----------------+-------------+
828| DELETE | No | bulk_delete | 204 |
829+--------------+------------+----------------+-------------+
830| GET | Yes | get | 200 |
831+--------------+------------+----------------+-------------+
832| PUT | Yes | set | 200 |
833+--------------+------------+----------------+-------------+
834| DELETE | Yes | delete | 204 |
835+--------------+------------+----------------+-------------+
836
837How to use a custom API endpoint in a RESTController?
838~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
839
840If you don't have any access restriction you can use ``@Endpoint``. If you
841have set a permission scope to restrict access to your endpoints,
842``@Endpoint`` will fail, as it doesn't know which permission property should be
843used. To use a custom endpoint inside a restricted ``RESTController`` use
844``@RESTController.Collection`` instead. You can also choose
845``@RESTController.Resource`` if you have set a ``RESOURCE_ID`` in your
846``RESTController`` class.
847
848.. code-block:: python
849
850 import cherrypy
851 from ..tools import ApiController, RESTController
852
853 @ApiController('ping', Scope.Ping)
854 class Ping(RESTController):
855 RESOURCE_ID = 'ping'
856
857 @RESTController.Resource('GET')
858 def some_get_endpoint(self):
859 return {"msg": "Hello"}
860
861 @RESTController.Collection('POST')
862 def some_post_endpoint(self, **data):
863 return {"msg": data}
864
865Both decorators also support four parameters to customize the
866endpoint:
867
868* ``method="GET"``: the HTTP method allowed to access this endpoint.
869* ``path="/<method_name>"``: the URL path of the endpoint, excluding the
870 controller URL path prefix.
871* ``status=200``: set the HTTP status response code
872* ``query_params=[]``: list of method parameter names that correspond to URL
873 query parameters.
874
875How to restrict access to a controller?
876~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
877
878All controllers require authentication by default.
879If you require that the controller can be accessed without authentication,
880then you can add the parameter ``secure=False`` to the controller decorator.
881
882Example:
883
884.. code-block:: python
885
886 import cherrypy
887 from . import ApiController, RESTController
888
889
890 @ApiController('ping', secure=False)
891 class Ping(RESTController):
892 def list(self):
893 return {"msg": "Hello"}
894
895
896How to access the manager module instance from a controller?
897~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
898
899We provide the manager module instance as a global variable that can be
900imported in any module. We also provide a logger instance in the same way.
901
902Example:
903
904.. code-block:: python
905
906 import cherrypy
907 from .. import logger, mgr
908 from ..tools import ApiController, RESTController
909
910
911 @ApiController('servers')
912 class Servers(RESTController):
913 def list(self):
914 logger.debug('Listing available servers')
915 return {'servers': mgr.list_servers()}
916
917
918How to write a unit test for a controller?
919~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
920
921We provide a test helper class called ``ControllerTestCase`` to easily create
922unit tests for your controller.
923
924If we want to write a unit test for the above ``Ping`` controller, create a
925``test_ping.py`` file under the ``tests`` directory with the following code:
926
927.. code-block:: python
928
929 from .helper import ControllerTestCase
930 from .controllers.ping import Ping
931
932
933 class PingTest(ControllerTestCase):
934 @classmethod
935 def setup_test(cls):
936 Ping._cp_config['tools.authenticate.on'] = False
937 cls.setup_controllers([Ping])
938
939 def test_ping(self):
940 self._get("/api/ping")
941 self.assertStatus(200)
942 self.assertJsonBody({'msg': 'Hello'})
943
944The ``ControllerTestCase`` class starts by initializing a CherryPy webserver.
945Then it will call the ``setup_test()`` class method where we can explicitly
946load the controllers that we want to test. In the above example we are only
947loading the ``Ping`` controller. We can also disable authentication of a
948controller at this stage, as depicted in the example.
949
950
951How to listen for manager notifications in a controller?
952~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
953
954The manager notifies the modules of several types of cluster events, such
955as cluster logging event, etc...
956
957Each module has a "global" handler function called ``notify`` that the manager
958calls to notify the module. But this handler function must not block or spend
959too much time processing the event notification.
960For this reason we provide a notification queue that controllers can register
961themselves with to receive cluster notifications.
962
963The example below represents a controller that implements a very simple live
964log viewer page:
965
966.. code-block:: python
967
968 from __future__ import absolute_import
969
970 import collections
971
972 import cherrypy
973
974 from ..tools import ApiController, BaseController, NotificationQueue
975
976
977 @ApiController('livelog')
978 class LiveLog(BaseController):
979 log_buffer = collections.deque(maxlen=1000)
980
981 def __init__(self):
982 super(LiveLog, self).__init__()
983 NotificationQueue.register(self.log, 'clog')
984
985 def log(self, log_struct):
986 self.log_buffer.appendleft(log_struct)
987
988 @cherrypy.expose
989 def default(self):
990 ret = '<html><meta http-equiv="refresh" content="2" /><body>'
991 for l in self.log_buffer:
992 ret += "{}<br>".format(l)
993 ret += "</body></html>"
994 return ret
995
996As you can see above, the ``NotificationQueue`` class provides a register
997method that receives the function as its first argument, and receives the
998"notification type" as the second argument.
999You can omit the second argument of the ``register`` method, and in that case
1000you are registering to listen all notifications of any type.
1001
1002Here is an list of notification types (these might change in the future) that
1003can be used:
1004
1005* ``clog``: cluster log notifications
1006* ``command``: notification when a command issued by ``MgrModule.send_command``
1007 completes
1008* ``perf_schema_update``: perf counters schema update
1009* ``mon_map``: monitor map update
1010* ``fs_map``: cephfs map update
1011* ``osd_map``: OSD map update
1012* ``service_map``: services (RGW, RBD-Mirror, etc.) map update
1013* ``mon_status``: monitor status regular update
1014* ``health``: health status regular update
1015* ``pg_summary``: regular update of PG status information
1016
1017
1018How to write a unit test when a controller accesses a Ceph module?
1019~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1020
1021Consider the following example that implements a controller that retrieves the
1022list of RBD images of the ``rbd`` pool:
1023
1024.. code-block:: python
1025
1026 import rbd
1027 from .. import mgr
1028 from ..tools import ApiController, RESTController
1029
1030
1031 @ApiController('rbdimages')
1032 class RbdImages(RESTController):
1033 def __init__(self):
1034 self.ioctx = mgr.rados.open_ioctx('rbd')
1035 self.rbd = rbd.RBD()
1036
1037 def list(self):
1038 return [{'name': n} for n in self.rbd.list(self.ioctx)]
1039
1040In the example above, we want to mock the return value of the ``rbd.list``
1041function, so that we can test the JSON response of the controller.
1042
1043The unit test code will look like the following:
1044
1045.. code-block:: python
1046
1047 import mock
1048 from .helper import ControllerTestCase
1049
1050
1051 class RbdImagesTest(ControllerTestCase):
1052 @mock.patch('rbd.RBD.list')
1053 def test_list(self, rbd_list_mock):
1054 rbd_list_mock.return_value = ['img1', 'img2']
1055 self._get('/api/rbdimages')
1056 self.assertJsonBody([{'name': 'img1'}, {'name': 'img2'}])
1057
1058
1059
1060How to add a new configuration setting?
1061~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1062
1063If you need to store some configuration setting for a new feature, we already
1064provide an easy mechanism for you to specify/use the new config setting.
1065
1066For instance, if you want to add a new configuration setting to hold the
1067email address of the dashboard admin, just add a setting name as a class
1068attribute to the ``Options`` class in the ``settings.py`` file::
1069
1070 # ...
1071 class Options(object):
1072 # ...
1073
1074 ADMIN_EMAIL_ADDRESS = ('admin@admin.com', str)
1075
1076The value of the class attribute is a pair composed by the default value for that
1077setting, and the python type of the value.
1078
1079By declaring the ``ADMIN_EMAIL_ADDRESS`` class attribute, when you restart the
1080dashboard module, you will automatically gain two additional CLI commands to
1081get and set that setting::
1082
1083 $ ceph dashboard get-admin-email-address
1084 $ ceph dashboard set-admin-email-address <value>
1085
1086To access, or modify the config setting value from your Python code, either
1087inside a controller or anywhere else, you just need to import the ``Settings``
1088class and access it like this:
1089
1090.. code-block:: python
1091
1092 from settings import Settings
1093
1094 # ...
1095 tmp_var = Settings.ADMIN_EMAIL_ADDRESS
1096
1097 # ....
1098 Settings.ADMIN_EMAIL_ADDRESS = 'myemail@admin.com'
1099
1100The settings management implementation will make sure that if you change a
1101setting value from the Python code you will see that change when accessing
1102that setting from the CLI and vice-versa.
1103
1104
1105How to run a controller read-write operation asynchronously?
1106~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1107
1108Some controllers might need to execute operations that alter the state of the
1109Ceph cluster. These operations might take some time to execute and to maintain
1110a good user experience in the Web UI, we need to run those operations
1111asynchronously and return immediately to frontend some information that the
1112operations are running in the background.
1113
1114To help in the development of the above scenario we added the support for
1115asynchronous tasks. To trigger the execution of an asynchronous task we must
1116use the following class method of the ``TaskManager`` class::
1117
1118 from ..tools import TaskManager
1119 # ...
1120 TaskManager.run(name, metadata, func, args, kwargs)
1121
1122* ``name`` is a string that can be used to group tasks. For instance
1123 for RBD image creation tasks we could specify ``"rbd/create"`` as the
1124 name, or similarly ``"rbd/remove"`` for RBD image removal tasks.
1125
1126* ``metadata`` is a dictionary where we can store key-value pairs that
1127 characterize the task. For instance, when creating a task for creating
1128 RBD images we can specify the metadata argument as
1129 ``{'pool_name': "rbd", image_name': "test-img"}``.
1130
1131* ``func`` is the python function that implements the operation code, which
1132 will be executed asynchronously.
1133
1134* ``args`` and ``kwargs`` are the positional and named arguments that will be
1135 passed to ``func`` when the task manager starts its execution.
1136
1137The ``TaskManager.run`` method triggers the asynchronous execution of function
1138``func`` and returns a ``Task`` object.
1139The ``Task`` provides the public method ``Task.wait(timeout)``, which can be
1140used to wait for the task to complete up to a timeout defined in seconds and
1141provided as an argument. If no argument is provided the ``wait`` method
1142blocks until the task is finished.
1143
1144The ``Task.wait`` is very useful for tasks that usually are fast to execute but
1145that sometimes may take a long time to run.
1146The return value of the ``Task.wait`` method is a pair ``(state, value)``
1147where ``state`` is a string with following possible values:
1148
1149* ``VALUE_DONE = "done"``
1150* ``VALUE_EXECUTING = "executing"``
1151
1152The ``value`` will store the result of the execution of function ``func`` if
1153``state == VALUE_DONE``. If ``state == VALUE_EXECUTING`` then
1154``value == None``.
1155
1156The pair ``(name, metadata)`` should unequivocally identify the task being
1157run, which means that if you try to trigger a new task that matches the same
1158``(name, metadata)`` pair of the currently running task, then the new task
1159is not created and you get the task object of the current running task.
1160
1161For instance, consider the following example:
1162
1163.. code-block:: python
1164
1165 task1 = TaskManager.run("dummy/task", {'attr': 2}, func)
1166 task2 = TaskManager.run("dummy/task", {'attr': 2}, func)
1167
1168If the second call to ``TaskManager.run`` executes while the first task is
1169still executing then it will return the same task object:
1170``assert task1 == task2``.
1171
1172
1173How to get the list of executing and finished asynchronous tasks?
1174~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1175
1176The list of executing and finished tasks is included in the ``Summary``
1177controller, which is already polled every 5 seconds by the dashboard frontend.
1178But we also provide a dedicated controller to get the same list of executing
1179and finished tasks.
1180
1181The ``Task`` controller exposes the ``/api/task`` endpoint that returns the
1182list of executing and finished tasks. This endpoint accepts the ``name``
1183parameter that accepts a glob expression as its value.
1184For instance, an HTTP GET request of the URL ``/api/task?name=rbd/*``
1185will return all executing and finished tasks which name starts with ``rbd/``.
1186
1187To prevent the finished tasks list from growing unbounded, we will always
1188maintain the 10 most recent finished tasks, and the remaining older finished
1189tasks will be removed when reaching a TTL of 1 minute. The TTL is calculated
1190using the timestamp when the task finished its execution. After a minute, when
1191the finished task information is retrieved, either by the summary controller or
1192by the task controller, it is automatically deleted from the list and it will
1193not be included in further task queries.
1194
1195Each executing task is represented by the following dictionary::
1196
1197 {
1198 'name': "name", # str
1199 'metadata': { }, # dict
1200 'begin_time': "2018-03-14T15:31:38.423605Z", # str (ISO 8601 format)
1201 'progress': 0 # int (percentage)
1202 }
1203
1204Each finished task is represented by the following dictionary::
1205
1206 {
1207 'name': "name", # str
1208 'metadata': { }, # dict
1209 'begin_time': "2018-03-14T15:31:38.423605Z", # str (ISO 8601 format)
1210 'end_time': "2018-03-14T15:31:39.423605Z", # str (ISO 8601 format)
1211 'duration': 0.0, # float
1212 'progress': 0 # int (percentage)
1213 'success': True, # bool
1214 'ret_value': None, # object, populated only if 'success' == True
1215 'exception': None, # str, populated only if 'success' == False
1216 }
1217
1218
1219How to use asynchronous APIs with asynchronous tasks?
1220~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1221
1222The ``TaskManager.run`` method as described in a previous section, is well
1223suited for calling blocking functions, as it runs the function inside a newly
1224created thread. But sometimes we want to call some function of an API that is
1225already asynchronous by nature.
1226
1227For these cases we want to avoid creating a new thread for just running a
1228non-blocking function, and want to leverage the asynchronous nature of the
1229function. The ``TaskManager.run`` is already prepared to be used with
1230non-blocking functions by passing an object of the type ``TaskExecutor`` as an
1231additional parameter called ``executor``. The full method signature of
1232``TaskManager.run``::
1233
1234 TaskManager.run(name, metadata, func, args=None, kwargs=None, executor=None)
1235
1236
1237The ``TaskExecutor`` class is responsible for code that executes a given task
1238function, and defines three methods that can be overridden by
1239subclasses::
1240
1241 def init(self, task)
1242 def start(self)
1243 def finish(self, ret_value, exception)
1244
1245The ``init`` method is called before the running the task function, and
1246receives the task object (of class ``Task``).
1247
1248The ``start`` method runs the task function. The default implementation is to
1249run the task function in the current thread context.
1250
1251The ``finish`` method should be called when the task function finishes with
1252either the ``ret_value`` populated with the result of the execution, or with
1253an exception object in the case that execution raised an exception.
1254
1255To leverage the asynchronous nature of a non-blocking function, the developer
1256should implement a custom executor by creating a subclass of the
1257``TaskExecutor`` class, and provide an instance of the custom executor class
1258as the ``executor`` parameter of the ``TaskManager.run``.
1259
1260To better understand the expressive power of executors, we write a full example
1261of use a custom executor to execute the ``MgrModule.send_command`` asynchronous
1262function:
1263
1264.. code-block:: python
1265
1266 import json
1267 from mgr_module import CommandResult
1268 from .. import mgr
1269 from ..tools import ApiController, RESTController, NotificationQueue, \
1270 TaskManager, TaskExecutor
1271
1272
1273 class SendCommandExecutor(TaskExecutor):
1274 def __init__(self):
1275 super(SendCommandExecutor, self).__init__()
1276 self.tag = None
1277 self.result = None
1278
1279 def init(self, task):
1280 super(SendCommandExecutor, self).init(task)
1281
1282 # we need to listen for 'command' events to know when the command
1283 # finishes
1284 NotificationQueue.register(self._handler, 'command')
1285
1286 # store the CommandResult object to retrieve the results
1287 self.result = self.task.fn_args[0]
1288 if len(self.task.fn_args) > 4:
1289 # the user specified a tag for the command, so let's use it
1290 self.tag = self.task.fn_args[4]
1291 else:
1292 # let's generate a unique tag for the command
1293 self.tag = 'send_command_{}'.format(id(self))
1294 self.task.fn_args.append(self.tag)
1295
1296 def _handler(self, data):
1297 if data == self.tag:
1298 # the command has finished, notifying the task with the result
1299 self.finish(self.result.wait(), None)
1300 # deregister listener to avoid memory leaks
1301 NotificationQueue.deregister(self._handler, 'command')
1302
1303
1304 @ApiController('test')
1305 class Test(RESTController):
1306
1307 def _run_task(self, osd_id):
1308 task = TaskManager.run("test/task", {}, mgr.send_command,
1309 [CommandResult(''), 'osd', osd_id,
1310 json.dumps({'prefix': 'perf histogram dump'})],
1311 executor=SendCommandExecutor())
1312 return task.wait(1.0)
1313
1314 def get(self, osd_id):
1315 status, value = self._run_task(osd_id)
1316 return {'status': status, 'value': value}
1317
1318
1319The above ``SendCommandExecutor`` executor class can be used for any call to
1320``MgrModule.send_command``. This means that we should need just one custom
1321executor class implementation for each non-blocking API that we use in our
1322controllers.
1323
1324The default executor, used when no executor object is passed to
1325``TaskManager.run``, is the ``ThreadedExecutor``. You can check its
1326implementation in the ``tools.py`` file.
1327
1328
1329How to update the execution progress of an asynchronous task?
1330~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1331
1332The asynchronous tasks infrastructure provides support for updating the
1333execution progress of an executing task.
1334The progress can be updated from within the code the task is executing, which
1335usually is the place where we have the progress information available.
1336
1337To update the progress from within the task code, the ``TaskManager`` class
1338provides a method to retrieve the current task object::
1339
1340 TaskManager.current_task()
1341
1342The above method is only available when using the default executor
1343``ThreadedExecutor`` for executing the task.
1344The ``current_task()`` method returns the current ``Task`` object. The
1345``Task`` object provides two public methods to update the execution progress
1346value: the ``set_progress(percentage)``, and the ``inc_progress(delta)``
1347methods.
1348
1349The ``set_progress`` method receives as argument an integer value representing
1350the absolute percentage that we want to set to the task.
1351
1352The ``inc_progress`` method receives as argument an integer value representing
1353the delta we want to increment to the current execution progress percentage.
1354
1355Take the following example of a controller that triggers a new task and
1356updates its progress:
1357
1358.. code-block:: python
1359
1360 from __future__ import absolute_import
1361 import random
1362 import time
1363 import cherrypy
1364 from ..tools import TaskManager, ApiController, BaseController
1365
1366
1367 @ApiController('dummy_task')
1368 class DummyTask(BaseController):
1369 def _dummy(self):
1370 top = random.randrange(100)
1371 for i in range(top):
1372 TaskManager.current_task().set_progress(i*100/top)
1373 # or TaskManager.current_task().inc_progress(100/top)
1374 time.sleep(1)
1375 return "finished"
1376
1377 @cherrypy.expose
1378 @cherrypy.tools.json_out()
1379 def default(self):
1380 task = TaskManager.run("dummy/task", {}, self._dummy)
1381 return task.wait(5) # wait for five seconds
1382
1383
1384How to deal with asynchronous tasks in the front-end?
1385~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1386
1387All executing and most recently finished asynchronous tasks are displayed on
1388"Background-Tasks" and if finished on "Recent-Notifications" in the menu bar.
1389For each task a operation name for three states (running, success and failure),
1390a function that tells who is involved and error descriptions, if any, have to
1391be provided. This can be achieved by appending
1392``TaskManagerMessageService.messages``. This has to be done to achieve
1393consistency among all tasks and states.
1394
1395Operation Object
1396 Ensures consistency among all tasks. It consists of three verbs for each
1397 different state f.e.
1398 ``{running: 'Creating', failure: 'create', success: 'Created'}``.
1399
1400#. Put running operations in present participle f.e. ``'Updating'``.
1401#. Failed messages always start with ``'Failed to '`` and should be continued
1402 with the operation in present tense f.e. ``'update'``.
1403#. Put successful operations in past tense f.e. ``'Updated'``.
1404
1405Involves Function
1406 Ensures consistency among all messages of a task, it resembles who's
1407 involved by the operation. It's a function that returns a string which
1408 takes the metadata from the task to return f.e.
1409 ``"RBD 'somePool/someImage'"``.
1410
1411Both combined create the following messages:
1412
1413* Failure => ``"Failed to create RBD 'somePool/someImage'"``
1414* Running => ``"Creating RBD 'somePool/someImage'"``
1415* Success => ``"Created RBD 'somePool/someImage'"``
1416
1417For automatic task handling use ``TaskWrapperService.wrapTaskAroundCall``.
1418
1419If for some reason ``wrapTaskAroundCall`` is not working for you,
1420you have to subscribe to your asynchronous task manually through
1421``TaskManagerService.subscribe``, and provide it with a callback,
1422in case of a success to notify the user. A notification can
1423be triggered with ``NotificationService.notifyTask``. It will use
1424``TaskManagerMessageService.messages`` to display a message based on the state
1425of a task.
1426
1427Notifications of API errors are handled by ``ApiInterceptorService``.
1428
1429Usage example:
1430
1431.. code-block:: javascript
1432
1433 export class TaskManagerMessageService {
1434 // ...
1435 messages = {
1436 // Messages for task 'rbd/create'
1437 'rbd/create': new TaskManagerMessage(
1438 // Message prefixes
1439 ['create', 'Creating', 'Created'],
1440 // Message suffix
1441 (metadata) => `RBD '${metadata.pool_name}/${metadata.image_name}'`,
1442 (metadata) => ({
1443 // Error code and description
1444 '17': `Name is already used by RBD '${metadata.pool_name}/${
1445 metadata.image_name}'.`
1446 })
1447 ),
1448 // ...
1449 };
1450 // ...
1451 }
1452
1453 export class RBDFormComponent {
1454 // ...
1455 createAction() {
1456 const request = this.createRequest();
1457 // Subscribes to 'call' with submitted 'task' and handles notifications
1458 return this.taskWrapper.wrapTaskAroundCall({
1459 task: new FinishedTask('rbd/create', {
1460 pool_name: request.pool_name,
1461 image_name: request.name
1462 }),
1463 call: this.rbdService.create(request)
1464 });
1465 }
1466 // ...
1467 }
1468
1469
1470REST API documentation
1471~~~~~~~~~~~~~~~~~~~~~~
1472There is an automatically generated Swagger UI page for documentation of the REST
1473API endpoints.However, by default it is not very detailed. There are two
1474decorators that can be used to add more information:
1475
1476* ``@EndpointDoc()`` for documentation of endpoints. It has four optional arguments
1477 (explained below): ``description``, ``group``, ``parameters`` and``responses``.
494da23a 1478* ``@ControllerDoc()`` for documentation of controller or group associated with
11fdf7f2
TL
1479 the endpoints. It only takes the two first arguments: ``description`` and``group``.
1480
1481
1482``description``: A a string with a short (1-2 sentences) description of the object.
1483
1484
1485``group``: By default, an endpoint is grouped together with other endpoints
1486within the same controller class. ``group`` is a string that can be used to
494da23a 1487assign an endpoint or all endpoints in a class to another controller or a
11fdf7f2
TL
1488conceived group name.
1489
1490
1491``parameters``: A dict used to describe path, query or request body parameters.
1492By default, all parameters for an endpoint are listed on the Swagger UI page,
1493including information of whether the parameter is optional/required and default
1494values. However, there will be no description of the parameter and the parameter
1495type will only be displayed in some cases.
1496When adding information, each parameters should be described as in the example
1497below. Note that the parameter type should be expressed as a built-in python
1498type and not as a string. Allowed values are ``str``, ``int``, ``bool``, ``float``.
1499
1500.. code-block:: python
1501
1502 @EndpointDoc(parameters={'my_string': (str, 'Description of my_string')})
1503
1504For body parameters, more complex cases are possible. If the parameter is a
1505dictionary, the type should be replaced with a ``dict`` containing its nested
1506parameters. When describing nested parameters, the same format as other
1507parameters is used. However, all nested parameters are set as required by default.
1508If the nested parameter is optional this must be specified as for ``item2`` in
1509the example below. If a nested parameters is set to optional, it is also
1510possible to specify the default value (this will not be provided automatically
1511for nested parameters).
1512
1513.. code-block:: python
1514
1515 @EndpointDoc(parameters={
1516 'my_dictionary': ({
1517 'item1': (str, 'Description of item1'),
1518 'item2': (str, 'Description of item2', True), # item2 is optional
1519 'item3': (str, 'Description of item3', True, 'foo'), # item3 is optional with 'foo' as default value
1520 }, 'Description of my_dictionary')})
494da23a 1521
11fdf7f2
TL
1522If the parameter is a ``list`` of primitive types, the type should be
1523surrounded with square brackets.
1524
1525.. code-block:: python
1526
1527 @EndpointDoc(parameters={'my_list': ([int], 'Description of my_list')})
1528
1529If the parameter is a ``list`` with nested parameters, the nested parameters
1530should be placed in a dictionary and surrounded with square brackets.
1531
1532.. code-block:: python
1533
1534 @EndpointDoc(parameters={
1535 'my_list': ([{
1536 'list_item': (str, 'Description of list_item'),
1537 'list_item2': (str, 'Description of list_item2')
1538 }], 'Description of my_list')})
1539
1540
1541``responses``: A dict used for describing responses. Rules for describing
1542responses are the same as for request body parameters, with one difference:
1543responses also needs to be assigned to the related response code as in the
1544example below:
1545
1546.. code-block:: python
1547
1548 @EndpointDoc(responses={
1549 '400':{'my_response': (str, 'Description of my_response')}
1550
1551
1552Error Handling in Python
1553~~~~~~~~~~~~~~~~~~~~~~~~
1554
1555Good error handling is a key requirement in creating a good user experience
1556and providing a good API.
1557
1558Dashboard code should not duplicate C++ code. Thus, if error handling in C++
1559is sufficient to provide good feedback, a new wrapper to catch these errors
1560is not necessary. On the other hand, input validation is the best place to
1561catch errors and generate the best error messages. If required, generate
1562errors as soon as possible.
1563
1564The backend provides few standard ways of returning errors.
1565
1566First, there is a generic Internal Server Error::
1567
1568 Status Code: 500
1569 {
1570 "version": <cherrypy version, e.g. 13.1.0>,
1571 "detail": "The server encountered an unexpected condition which prevented it from fulfilling the request.",
1572 }
1573
1574
1575For errors generated by the backend, we provide a standard error
1576format::
1577
1578 Status Code: 400
1579 {
1580 "detail": str(e), # E.g. "[errno -42] <some error message>"
1581 "component": "rbd", # this can be null to represent a global error code
1582 "code": "3", # Or a error name, e.g. "code": "some_error_key"
1583 }
1584
1585
1586In case, the API Endpoints uses @ViewCache to temporarily cache results,
1587the error looks like so::
1588
1589 Status Code 400
1590 {
1591 "detail": str(e), # E.g. "[errno -42] <some error message>"
1592 "component": "rbd", # this can be null to represent a global error code
1593 "code": "3", # Or a error name, e.g. "code": "some_error_key"
1594 'status': 3, # Indicating the @ViewCache error status
1595 }
1596
1597In case, the API Endpoints uses a task the error looks like so::
1598
1599 Status Code 400
1600 {
1601 "detail": str(e), # E.g. "[errno -42] <some error message>"
1602 "component": "rbd", # this can be null to represent a global error code
1603 "code": "3", # Or a error name, e.g. "code": "some_error_key"
1604 "task": { # Information about the task itself
1605 "name": "taskname",
1606 "metadata": {...}
1607 }
1608 }
1609
1610
1611Our WebUI should show errors generated by the API to the user. Especially
1612field-related errors in wizards and dialogs or show non-intrusive notifications.
1613
1614Handling exceptions in Python should be an exception. In general, we
1615should have few exception handlers in our project. Per default, propagate
1616errors to the API, as it will take care of all exceptions anyway. In general,
1617log the exception by adding ``logger.exception()`` with a description to the
1618handler.
1619
1620We need to distinguish between user errors from internal errors and
1621programming errors. Using different exception types will ease the
1622task for the API layer and for the user interface:
1623
1624Standard Python errors, like ``SystemError``, ``ValueError`` or ``KeyError``
1625will end up as internal server errors in the API.
1626
1627In general, do not ``return`` error responses in the REST API. They will be
1628returned by the error handler. Instead, raise the appropriate exception.
1629
1630Plug-ins
1631~~~~~~~~
1632
1633New functionality can be provided by means of a plug-in architecture. Among the
1634benefits this approach brings in, loosely coupled development is one of the most
1635notable. As the Ceph Dashboard grows in feature richness, its code-base becomes
1636more and more complex. The hook-based nature of a plug-in architecture allows to
1637extend functionality in a controlled manner, and isolate the scope of the
1638changes.
1639
1640Ceph Dashboard relies on `Pluggy <https://pluggy.readthedocs.io>`_ to provide
1641for plug-ing support. On top of pluggy, an interface-based approach has been
1642implemented, with some safety checks (method override and abstract method
1643checks).
1644
1645In order to create a new plugin, the following steps are required:
1646
1647#. Add a new file under ``src/pybind/mgr/dashboard/plugins``.
1648#. Import the ``PLUGIN_MANAGER`` instance and the ``Interfaces``.
92f5a8d4
TL
1649#. Create a class extending the desired interfaces. The plug-in library will
1650 check if all the methods of the interfaces have been properly overridden.
11fdf7f2 1651#. Register the plugin in the ``PLUGIN_MANAGER`` instance.
92f5a8d4
TL
1652#. Import the plug-in from within the Ceph Dashboard ``module.py`` (currently no
1653 dynamic loading is implemented).
11fdf7f2 1654
92f5a8d4 1655The available Mixins (helpers) are:
11fdf7f2
TL
1656
1657- ``CanMgr``: provides the plug-in with access to the ``mgr`` instance under ``self.mgr``.
1658- ``CanLog``: provides the plug-in with access to the Ceph Dashboard logger under ``self.log``.
92f5a8d4
TL
1659
1660The available Interfaces are:
1661
1662- ``Initializable``: requires overriding ``init()`` hook. This method is run at
1663 the very beginning of the dashboard module, right after all imports have been
1664 performed.
1665- ``Setupable``: requires overriding ``setup()`` hook. This method is run in the
1666 Ceph Dashboard ``serve()`` method, right after CherryPy has been configured,
1667 but before it is started. It's a placeholder for the plug-in initialization
1668 logic.
1669- ``HasOptions``: requires overriding ``get_options()`` hook by returning a list
1670 of ``Options()``. The options returned here are added to the
1671 ``MODULE_OPTIONS``.
1672- ``HasCommands``: requires overriding ``register_commands()`` hook by defining
1673 the commands the plug-in can handle and decorating them with ``@CLICommand``.
1674 The commands can be optionally returned, so that they can be invoked
1675 externally (which makes unit testing easier).
1676- ``HasControllers``: requires overriding ``get_controllers()`` hook by defining
1677 and returning the controllers as usual.
1678- ``FilterRequest.BeforeHandler``: requires overriding
1679 ``filter_request_before_handler()`` hook. This method receives a
1680 ``cherrypy.request`` object for processing. A usual implementation of this
1681 method will allow some requests to pass or will raise a ``cherrypy.HTTPError`
1682 based on the ``request`` metadata and other conditions.
11fdf7f2
TL
1683
1684New interfaces and hooks should be added as soon as they are required to
1685implement new functionality. The above list only comprises the hooks needed for
1686the existing plugins.
1687
1688A sample plugin implementation would look like this:
1689
1690.. code-block:: python
1691
1692 # src/pybind/mgr/dashboard/plugins/mute.py
1693
1694 from . import PLUGIN_MANAGER as PM
1695 from . import interfaces as I
1696
1697 from mgr_module import CLICommand, Option
1698 import cherrypy
1699
1700 @PM.add_plugin
1701 class Mute(I.CanMgr, I.CanLog, I.Setupable, I.HasOptions,
1702 I.HasCommands, I.FilterRequest.BeforeHandler,
1703 I.HasControllers):
1704 @PM.add_hook
1705 def get_options(self):
1706 return [Option('mute', default=False, type='bool')]
1707
1708 @PM.add_hook
1709 def setup(self):
92f5a8d4 1710 self.mute = self.mgr.get_module_option('mute')
11fdf7f2
TL
1711
1712 @PM.add_hook
1713 def register_commands(self):
1714 @CLICommand("dashboard mute")
1715 def _(mgr):
1716 self.mute = True
92f5a8d4 1717 self.mgr.set_module_option('mute', True)
11fdf7f2
TL
1718 return 0
1719
1720 @PM.add_hook
1721 def filter_request_before_handler(self, request):
1722 if self.mute:
1723 raise cherrypy.HTTPError(500, "I'm muted :-x")
31f18b77 1724
11fdf7f2
TL
1725 @PM.add_hook
1726 def get_controllers(self):
1727 from ..controllers import ApiController, RESTController
31f18b77 1728
11fdf7f2
TL
1729 @ApiController('/mute')
1730 class MuteController(RESTController):
1731 def get(_):
1732 return self.mute
31f18b77 1733
92f5a8d4
TL
1734 return [MuteController]
1735
1736
1737Additionally, a helper for creating plugins ``SimplePlugin`` is provided. It
1738facilitates the basic tasks (Options, Commands, and common Mixins). The previous
1739plugin could be rewritten like this:
1740
1741.. code-block:: python
1742
1743 from . import PLUGIN_MANAGER as PM
1744 from . import interfaces as I
1745 from .plugin import SimplePlugin as SP
1746
1747 import cherrypy
1748
1749 @PM.add_plugin
1750 class Mute(SP, I.Setupable, I.FilterRequest.BeforeHandler, I.HasControllers):
1751 OPTIONS = [
1752 SP.Option('mute', default=False, type='bool')
1753 ]
1754
1755 def shut_up(self):
1756 self.set_option('mute', True)
1757 self.mute = True
1758 return 0
1759
1760 COMMANDS = [
1761 SP.Command("dashboard mute", handler=shut_up)
1762 ]
1763
1764 @PM.add_hook
1765 def setup(self):
1766 self.mute = self.get_option('mute')
1767
1768 @PM.add_hook
1769 def filter_request_before_handler(self, request):
1770 if self.mute:
1771 raise cherrypy.HTTPError(500, "I'm muted :-x")
1772
1773 @PM.add_hook
1774 def get_controllers(self):
1775 from ..controllers import ApiController, RESTController
1776
1777 @ApiController('/mute')
1778 class MuteController(RESTController):
1779 def get(_):
1780 return self.mute
1781
1782 return [MuteController]
1783
1784