diff --git a/docs/config.rst b/docs/config.rst index f613bb94..fa10d25d 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -540,3 +540,19 @@ Default: None Sets the URI to which an OAuth 2.0 server redirects the user after successful authentication and authorization. `oauth2_redirect_uri` option should be used with :ref:`auth`, :ref:`auth_provider`, :ref:`oauth2_key` and :ref:`oauth2_secret` options. + +.. _read_only: + +read_only +~~~~~~~~~ + +Default: False + +Enables read only mode, disabling all control operations in the UI and API. + +When read only mode is enabled, Flower will not allow any control operations to be performed on the system. + +Example:: + + $ celery flower --read_only + diff --git a/flower/api/control.py b/flower/api/control.py index 41f39c36..c8b7326b 100644 --- a/flower/api/control.py +++ b/flower/api/control.py @@ -51,8 +51,12 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -90,9 +94,12 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request -:statuscode 403: pool restart is not enabled (see CELERYD_POOL_RESTARTS) +:statuscode 403: pool restart is not enabled (see CELERYD_POOL_RESTARTS) or read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -139,10 +146,13 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request -:statuscode 403: failed to grow +:statuscode 403: failed to grow or read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -190,10 +200,13 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request -:statuscode 403: failed to shrink +:statuscode 403: failed to shrink or read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -243,9 +256,11 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request -:statuscode 403: autoscaling is not enabled (see CELERYD_AUTOSCALER) +:statuscode 403: autoscaling is not enabled (see CELERYD_AUTOSCALER) or read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -299,9 +314,12 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request -:statuscode 403: failed to add consumer +:statuscode 403: failed to add consumer or read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -352,9 +370,12 @@ def post(self, workername): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request -:statuscode 403: failed to cancel consumer +:statuscode 403: failed to cancel consumer or read only mode is enabled :statuscode 404: unknown worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") @@ -406,7 +427,11 @@ def post(self, taskid): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + logger.info("Revoking task '%s'", taskid) terminate = self.get_argument('terminate', default=False, type=bool) signal = self.get_argument('signal', default='SIGTERM', type=str) @@ -447,8 +472,12 @@ def post(self, taskname): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 404: unknown task/worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + workername = self.get_argument('workername') hard = self.get_argument('hard', default=None, type=float) soft = self.get_argument('soft', default=None, type=float) @@ -507,8 +536,12 @@ def post(self, taskname): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 404: unknown task/worker """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + workername = self.get_argument('workername') ratelimit = self.get_argument('ratelimit') diff --git a/flower/api/tasks.py b/flower/api/tasks.py index 730c290e..c08abf1d 100644 --- a/flower/api/tasks.py +++ b/flower/api/tasks.py @@ -117,8 +117,12 @@ async def post(self, taskname): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 404: unknown task """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + args, kwargs, options = self.get_task_args() logger.debug("Invoking a task '%s' with '%s' and '%s'", taskname, args, kwargs) @@ -192,8 +196,12 @@ def post(self, taskname): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 404: unknown task """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + args, kwargs, options = self.get_task_args() logger.debug("Invoking a task '%s' with '%s' and '%s'", taskname, args, kwargs) @@ -254,8 +262,12 @@ def post(self, taskname): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 404: unknown task """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + args, kwargs, options = self.get_task_args() logger.debug("Invoking task '%s' with '%s' and '%s'", taskname, args, kwargs) @@ -344,8 +356,12 @@ def post(self, taskid): :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request +:statuscode 403: read only mode is enabled :statuscode 503: result backend is not configured """ + if self.application.options.read_only: + raise web.HTTPError(403, "Read only mode is enabled") + logger.info("Aborting task '%s'", taskid) result = AbortableAsyncResult(taskid) diff --git a/flower/options.py b/flower/options.py index 083d4b5b..294f4ee0 100644 --- a/flower/options.py +++ b/flower/options.py @@ -68,6 +68,7 @@ define("url_prefix", type=str, help="base url prefix") define("task_runtime_metric_buckets", type=float, default=Histogram.DEFAULT_BUCKETS, multiple=True, help="histogram latency bucket value") - +define("read_only", type=bool, default=False, + help="enable read only mode, disabling all operations") default_options = options diff --git a/flower/templates/worker.html b/flower/templates/worker.html index 9dd64daf..fb646999 100644 --- a/flower/templates/worker.html +++ b/flower/templates/worker.html @@ -16,6 +16,7 @@

{{ worker['name'] }}

+ {% if not read_only %}
+ {% end %}
+ {% if not read_only %}
Pool size control @@ -103,6 +106,7 @@

{{ worker['name'] }}

+ {% end %} {% if worker['stats'].get('autoscaler', None) %} @@ -154,7 +158,9 @@

{{ worker['name'] }}

Queue arguments Binding arguments Auto delete + {% if not read_only %} + {% end %} @@ -170,12 +176,15 @@

{{ worker['name'] }}

{{ queue['queue_arguments'] }} {{ queue['binding_arguments'] }} {{ queue['auto_delete'] }} - + {% if not read_only %} + + {% end %} {% end %} + {% if not read_only %}
{{ worker['name'] }}
+ {% end %}
@@ -304,8 +314,10 @@

{{ worker['name'] }}

+ {% if not read_only %} + {% end %}
@@ -313,10 +325,12 @@

{{ worker['name'] }}

+ {% if not read_only %} + {% end %}
@@ -387,4 +401,5 @@

{{ worker['name'] }}

- {% end %} \ No newline at end of file + +{% end %} diff --git a/flower/views/workers.py b/flower/views/workers.py index defd0469..ca0a40b3 100644 --- a/flower/views/workers.py +++ b/flower/views/workers.py @@ -24,7 +24,11 @@ async def get(self, name): if 'stats' not in worker: raise web.HTTPError(404, f"Unable to get stats for '{name}' worker") - self.render("worker.html", worker=dict(worker, name=name)) + self.render( + "worker.html", + worker=dict(worker, name=name), + read_only=self.application.options.read_only, + ) class WorkersView(BaseHandler): diff --git a/tests/unit/api/test_control.py b/tests/unit/api/test_control.py index 4eb279be..b66d3092 100644 --- a/tests/unit/api/test_control.py +++ b/tests/unit/api/test_control.py @@ -31,6 +31,13 @@ def test_shutdown(self): self.assertEqual(200, r.code) celery.control.broadcast.assert_called_once_with('shutdown', destination=['test']) + def test_shutdown_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.broadcast = MagicMock() + r = self.post('/api/worker/shutdown/test', body={}) + self.assertEqual(403, r.code) + celery.control.broadcast.assert_not_called() def test_pool_restart(self): celery = self._app.capp @@ -44,6 +51,14 @@ def test_pool_restart(self): reply=True, ) + def test_pool_restart_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.broadcast = MagicMock() + r = self.post('/api/worker/pool/restart/test', body={}) + self.assertEqual(403, r.code) + celery.control.broadcast.assert_not_called() + def test_pool_grow(self): celery = self._app.capp celery.control.pool_grow = MagicMock(return_value=[{'test': 'ok'}]) @@ -52,6 +67,14 @@ def test_pool_grow(self): celery.control.pool_grow.assert_called_once_with( n=3, reply=True, destination=['test']) + def test_pool_grow_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.pool_grow = MagicMock() + r = self.post('/api/worker/pool/grow/test', body={'n': 3}) + self.assertEqual(403, r.code) + celery.control.pool_grow.assert_not_called() + def test_pool_shrink(self): celery = self._app.capp celery.control.pool_shrink = MagicMock(return_value=[{'test': 'ok'}]) @@ -60,6 +83,14 @@ def test_pool_shrink(self): celery.control.pool_shrink.assert_called_once_with( n=1, reply=True, destination=['test']) + def test_pool_shrink_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.pool_shrink = MagicMock() + r = self.post('/api/worker/pool/shrink/test', body={}) + self.assertEqual(403, r.code) + celery.control.pool_shrink.assert_not_called() + def test_pool_autoscale(self): celery = self._app.capp celery.control.broadcast = MagicMock(return_value=[{'test': 'ok'}]) @@ -70,6 +101,14 @@ def test_pool_autoscale(self): 'autoscale', reply=True, destination=['test'], arguments={'min': 2, 'max': 5}) + def test_pool_autoscale_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.broadcast = MagicMock() + r = self.post('/api/worker/pool/autoscale/test', + body={'min': 2, 'max': 5}) + self.assertEqual(403, r.code) + celery.control.broadcast.assert_not_called() def test_add_consumer(self): celery = self._app.capp @@ -83,6 +122,15 @@ def test_add_consumer(self): reply=True, destination=['test'], arguments={'queue': 'foo'}) + def test_add_consumer_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.broadcast = MagicMock() + r = self.post('/api/worker/queue/add-consumer/test', + body={'queue': 'foo'}) + self.assertEqual(403, r.code) + celery.control.broadcast.assert_not_called() + def test_cancel_consumer(self): celery = self._app.capp celery.control.broadcast = MagicMock( @@ -95,6 +143,15 @@ def test_cancel_consumer(self): reply=True, destination=['test'], arguments={'queue': 'foo'}) + def test_cancel_consumer_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.broadcast = MagicMock() + r = self.post('/api/worker/queue/cancel-consumer/test', + body={'queue': 'foo'}) + self.assertEqual(403, r.code) + celery.control.broadcast.assert_not_called() + def test_task_timeout(self): celery = self._app.capp celery.control.time_limit = MagicMock( @@ -109,6 +166,14 @@ def test_task_timeout(self): 'celery.map', hard=3.1, soft=1.2, destination=['foo'], reply=True) + def test_task_timeout_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.time_limit = MagicMock() + r = self.post('/api/task/timeout/celery.map', + body={'workername': 'foo', 'hard': 3.1, 'soft': 1.2}) + self.assertEqual(403, r.code) + celery.control.time_limit.assert_not_called() def test_task_timeout_failure_returns_worker_error_message(self): celery = self._app.capp celery.control.time_limit = MagicMock( @@ -132,6 +197,15 @@ def test_task_ratelimit(self): celery.control.rate_limit.assert_called_once_with( 'celery.map', '20', destination=['foo'], reply=True) + def test_task_ratelimit_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.rate_limit = MagicMock() + r = self.post('/api/task/rate-limit/celery.map', + body={'workername': 'foo', 'ratelimit': 20}) + self.assertEqual(403, r.code) + celery.control.rate_limit.assert_not_called() + def test_task_ratelimit_non_integer(self): celery = self._app.capp celery.control.rate_limit = MagicMock( @@ -176,6 +250,14 @@ def test_revoke(self): terminate=False, signal='SIGTERM') + def test_revoke_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.revoke = MagicMock() + r = self.post('/api/task/revoke/test', body={}) + self.assertEqual(403, r.code) + celery.control.revoke.assert_not_called() + def test_terminate(self): celery = self._app.capp celery.control.revoke = MagicMock() @@ -185,6 +267,14 @@ def test_terminate(self): terminate=True, signal='SIGTERM') + def test_terminate_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.control.revoke = MagicMock() + r = self.post('/api/task/revoke/test', body={'terminate': True}) + self.assertEqual(403, r.code) + celery.control.revoke.assert_not_called() + def test_terminate_signal(self): celery = self._app.capp celery.control.revoke = MagicMock() diff --git a/tests/unit/api/test_tasks.py b/tests/unit/api/test_tasks.py index 551957d7..66da4c2a 100644 --- a/tests/unit/api/test_tasks.py +++ b/tests/unit/api/test_tasks.py @@ -2,11 +2,12 @@ import time from collections import OrderedDict from datetime import datetime, timedelta -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import MagicMock, Mock, PropertyMock, patch import celery.states as states from celery.events import Event from celery.result import AsyncResult +from tornado.options import options from flower.events import EventsState from tests.unit.utils import task_succeeded_events @@ -35,6 +36,15 @@ def test_apply(self): self.assertEqual(result, json.loads(body)['result']) task.apply_async.assert_called_once_with(args=[], kwargs={}) + def test_apply_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.tasks['foo'] = Mock() + celery.tasks['foo'].apply_async = MagicMock() + r = self.post('/api/task/apply/foo', body='') + self.assertEqual(403, r.code) + celery.tasks['foo'].apply_async.assert_not_called() + class AsyncApplyTests(BaseApiTestCase): def test_async_apply(self): @@ -87,6 +97,15 @@ def test_async_apply_expires_datetime(self): task.apply_async.assert_called_once_with( args=[], kwargs={}, expires=tomorrow) + def test_async_apply_read_only(self): + with patch.object(options.mockable(), 'read_only', True): + celery = self._app.capp + celery.tasks['foo'] = Mock() + celery.tasks['foo'].apply_async = MagicMock() + r = self.post('/api/task/async-apply/foo', body={}) + self.assertEqual(403, r.code) + celery.tasks['foo'].apply_async.assert_not_called() + class MockTasks: