From: Pedro Gonzalez Gomez Date: Thu, 8 Jan 2026 19:28:18 +0000 (+0100) Subject: mgr/dashboard: add CephFS mirror endpoints X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F66846%2Fhead;p=ceph.git mgr/dashboard: add CephFS mirror endpoints For: create peers, delete peers, create token, and check module status Fixes: https://tracker.ceph.com/issues/74362 Signed-off-by: Pedro Gonzalez Gomez --- diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 11bbea4d7bcf..92c3f3911b49 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -16,8 +16,8 @@ from ..services.ceph_service import CephService from ..services.cephfs import CephFS as CephFS_ from ..services.exception import handle_cephfs_error from ..tools import ViewCache, str_to_bool -from . import APIDoc, APIRouter, DeletePermission, Endpoint, EndpointDoc, \ - ReadPermission, RESTController, UIRouter, UpdatePermission, \ +from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \ + EndpointDoc, ReadPermission, RESTController, UIRouter, UpdatePermission, \ allow_empty_body GET_QUOTAS_SCHEMA = { @@ -38,6 +38,10 @@ LIST_PEERS_SCHEMA = [{ }, 'Peer ID'), }] +BOOTSTRAP_PEERS_SCHEMA = { + 'token': (str, 'Bootstrap token'), +} + DAEMON_STATUS_SCHEMA = [{ 'daemon_id': (int, 'Daemon ID'), 'filesystems': ([{ @@ -1256,6 +1260,60 @@ class CephFSMirror(RESTController): ) return json.loads(out) + @EndpointDoc("Create bootstrap token", + parameters={ + 'fs_name': (str, 'File system name'), + 'client_name': (str, 'Client entity\'s name'), + 'site_name': (str, 'Site name'), + }, + responses={200: BOOTSTRAP_PEERS_SCHEMA}) + @Endpoint('POST') + @CreatePermission + def token(self, fs_name: str, client_name: str, site_name: str): + error_code, out, err = mgr.remote( + 'mirroring', 'snapshot_mirror_peer_bootstrap_create', fs_name, client_name, site_name) + if error_code != 0: + raise DashboardException( + msg=f'Failed to create bootstrap token: {err}', + code=error_code, + component='cephfs.mirror' + ) + return json.loads(out) + + @EndpointDoc("Create bootstrap peer", + parameters={ + 'fs_name': (str, 'File system name'), + 'token': (str, 'Bootstrap token'), + }, + responses={200: {}}) + @CreatePermission + def create(self, fs_name: str, token: str): + error_code, out, err = mgr.remote( + 'mirroring', 'snapshot_mirror_peer_bootstrap_import', fs_name, token) + if error_code != 0: + raise DashboardException( + msg=f'Failed to import the token to create bootstrap peer: {err}', + code=error_code, + component='cephfs.mirror' + ) + return json.loads(out) + + @EndpointDoc("Delete peer", + parameters={ + 'fs_name': (str, 'File system name'), + 'peer_uuid': (str, 'Peer UUID'), + }) + @DeletePermission + def delete(self, fs_name: str, peer_uuid: str): + error_code, _, err = mgr.remote( + 'mirroring', 'snapshot_mirror_peer_remove', fs_name, peer_uuid) + if error_code != 0: + raise DashboardException( + msg=f'Failed to delete peer: {err}', + code=error_code, + component='cephfs.mirror' + ) + @EndpointDoc("Get mirror daemon and peers information", responses={200: DAEMON_STATUS_SCHEMA}) @Endpoint('GET', path='/daemon-status') @@ -1269,3 +1327,18 @@ class CephFSMirror(RESTController): component='cephfs.mirror' ) return json.loads(out) + + +@UIRouter('/cephfs/mirror') +class CephFSMirrorStatus(RESTController): + @ReadPermission + @EndpointDoc("Get Cephfs mirror status") + @Endpoint() + def status(self): + status: Dict[str, Any] = {'available': False, 'message': None} + try: + mgr.remote('mirroring', 'snapshot_mirror_daemon_status') + status['available'] = True + except (ImportError, RuntimeError): + status['message'] = 'Cephfs mirror module is not enabled' + return status diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index dc17794bb8d4..221e494e90d8 100755 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2404,43 +2404,34 @@ paths: tags: - Cephfs /api/cephfs/mirror: - get: - parameters: - - description: File system name - in: query - name: fs_name - required: true - schema: - type: string + post: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + fs_name: + description: File system name + type: string + token: + description: Bootstrap token + type: string + required: + - fs_name + - token + type: object responses: - '200': + '201': content: application/vnd.ceph.api.v1.0+json: - schema: - items: - properties: - uuid: - description: Peer ID - properties: - client_name: - description: Ceph client name - type: string - fs_name: - description: File system name - type: string - site_name: - description: Remote site name - type: string - required: - - client_name - - site_name - - fs_name - type: object - type: object - required: - - uuid - type: array - description: OK + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+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': @@ -2452,7 +2443,7 @@ paths: trace. security: - jwt: [] - summary: Get peers + summary: Create bootstrap peer tags: - CephfsMirror /api/cephfs/mirror/daemon-status: @@ -2551,6 +2542,145 @@ paths: summary: Get mirror daemon and peers information tags: - CephfsMirror + /api/cephfs/mirror/token: + post: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + client_name: + description: Client entity's name + type: string + fs_name: + description: File system name + type: string + site_name: + description: Site name + type: string + required: + - fs_name + - client_name + - site_name + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+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: [] + summary: Create bootstrap token + tags: + - CephfsMirror + /api/cephfs/mirror/{fs_name}: + get: + parameters: + - description: File system name + in: path + name: fs_name + required: true + schema: + type: string + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + schema: + items: + properties: + uuid: + description: Peer ID + properties: + client_name: + description: Ceph client name + type: string + fs_name: + description: File system name + type: string + site_name: + description: Remote site name + type: string + required: + - client_name + - site_name + - fs_name + type: object + type: object + required: + - uuid + type: array + 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: [] + summary: Get peers + tags: + - CephfsMirror + /api/cephfs/mirror/{fs_name}/{peer_uuid}: + delete: + parameters: + - description: File system name + in: path + name: fs_name + required: true + schema: + type: string + - description: Peer UUID + in: path + name: peer_uuid + required: true + schema: + type: string + responses: + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '204': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource deleted. + '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: [] + summary: Delete peer + tags: + - CephfsMirror /api/cephfs/remove/{name}: delete: parameters: diff --git a/src/pybind/mgr/dashboard/tests/test_cephfs.py b/src/pybind/mgr/dashboard/tests/test_cephfs.py index e8c29239fbe8..a4252f05d320 100644 --- a/src/pybind/mgr/dashboard/tests/test_cephfs.py +++ b/src/pybind/mgr/dashboard/tests/test_cephfs.py @@ -8,7 +8,7 @@ except ImportError: from unittest.mock import patch, Mock from .. import mgr -from ..controllers.cephfs import CephFS, CephFSMirror +from ..controllers.cephfs import CephFS, CephFSMirror, CephFSMirrorStatus from ..tests import ControllerTestCase @@ -64,7 +64,7 @@ class CephFSMirrorTest(ControllerTestCase): mock_output = json.dumps(expected_peers) mgr.remote = Mock(return_value=(0, mock_output, '')) - self._get(f'/api/cephfs/mirror?fs_name={fs_name}') + self._get(f'/api/cephfs/mirror/{fs_name}') self.assertStatus(200) self.assertJsonBody(expected_peers) mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_list', fs_name) @@ -74,13 +74,108 @@ class CephFSMirrorTest(ControllerTestCase): error_message = 'Failed to connect to remote' mgr.remote = Mock(return_value=(1, '', error_message)) - self._get(f'/api/cephfs/mirror?fs_name={fs_name}') + self._get(f'/api/cephfs/mirror/{fs_name}') self.assertStatus(400) response = self.json_body() self.assertIn('Failed to get Cephfs mirror peers', response.get('detail', '')) self.assertIn(error_message, response.get('detail', '')) mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_list', fs_name) + def test_token_success(self): + fs_name = 'test_fs' + client_name = 'client.mirror' + site_name = 'remote-site' + expected_token = {'token': 'bootstrap-token-12345'} + mock_output = json.dumps(expected_token) + mgr.remote = Mock(return_value=(0, mock_output, '')) + + self._post('/api/cephfs/mirror/token', { + 'fs_name': fs_name, + 'client_name': client_name, + 'site_name': site_name + }) + self.assertStatus(200) + self.assertJsonBody(expected_token) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_bootstrap_create', + fs_name, client_name, site_name) + + def test_token_error(self): + fs_name = 'test_fs' + client_name = 'client.mirror' + site_name = 'remote-site' + error_message = 'Failed to create bootstrap token' + mgr.remote = Mock(return_value=(1, '', error_message)) + + self._post('/api/cephfs/mirror/token', { + 'fs_name': fs_name, + 'client_name': client_name, + 'site_name': site_name + }) + self.assertStatus(400) + response = self.json_body() + self.assertIn('Failed to create bootstrap token', response.get('detail', '')) + self.assertIn(error_message, response.get('detail', '')) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_bootstrap_create', + fs_name, client_name, site_name) + + def test_create_success(self): + fs_name = 'test_fs' + token = 'bootstrap-token-12345' + expected_result = {'peer_uuid': 'peer-uuid-123'} + mock_output = json.dumps(expected_result) + mgr.remote = Mock(return_value=(0, mock_output, '')) + + self._post('/api/cephfs/mirror', { + 'fs_name': fs_name, + 'token': token + }) + self.assertStatus(201) + self.assertJsonBody(expected_result) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_bootstrap_import', + fs_name, token) + + def test_import_token_error(self): + fs_name = 'test_fs' + token = 'invalid-token' + error_message = 'Invalid bootstrap token' + mgr.remote = Mock(return_value=(1, '', error_message)) + + self._post('/api/cephfs/mirror', { + 'fs_name': fs_name, + 'token': token + }) + self.assertStatus(400) + response = self.json_body() + self.assertIn('Failed to import the token to create bootstrap peer', + response.get('detail', '')) + self.assertIn(error_message, response.get('detail', '')) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_bootstrap_import', + fs_name, token) + + def test_delete_success(self): + fs_name = 'test_fs' + peer_uuid = 'peer-uuid-123' + mgr.remote = Mock(return_value=(0, '', '')) + + self._delete(f'/api/cephfs/mirror/{fs_name}/{peer_uuid}') + self.assertStatus(204) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_remove', + fs_name, peer_uuid) + + def test_delete_error(self): + fs_name = 'test_fs' + peer_uuid = 'peer-uuid-123' + error_message = 'Peer not found' + mgr.remote = Mock(return_value=(1, '', error_message)) + + self._delete(f'/api/cephfs/mirror/{fs_name}/{peer_uuid}') + self.assertStatus(400) + response = self.json_body() + self.assertIn('Failed to delete peer', response.get('detail', '')) + self.assertIn(error_message, response.get('detail', '')) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_remove', + fs_name, peer_uuid) + def test_daemon_status_success(self): expected_status = [ { @@ -126,3 +221,36 @@ class CephFSMirrorTest(ControllerTestCase): self.assertIn('Failed to get Cephfs mirror daemon status', response.get('detail', '')) self.assertIn(error_message, response.get('detail', '')) mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_daemon_status') + + +class CephFSMirrorStatusTest(ControllerTestCase): + + @classmethod + def setup_server(cls): + cls.setup_controllers([CephFSMirrorStatus]) + + def test_status_available(self): + mgr.remote = Mock(return_value=(0, '', '')) + + self._get('/ui-api/cephfs/mirror/status') + self.assertStatus(200) + response = self.json_body() + self.assertTrue(response.get('available')) + self.assertIsNone(response.get('message')) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_daemon_status') + + def test_status_unavailable_import_error(self): + with patch.object(mgr, 'remote', side_effect=ImportError('Module not found')): + self._get('/ui-api/cephfs/mirror/status') + self.assertStatus(200) + response = self.json_body() + self.assertFalse(response.get('available')) + self.assertIn('Cephfs mirror module is not enabled', response.get('message', '')) + + def test_status_unavailable_runtime_error(self): + with patch.object(mgr, 'remote', side_effect=RuntimeError('Module error')): + self._get('/ui-api/cephfs/mirror/status') + self.assertStatus(200) + response = self.json_body() + self.assertFalse(response.get('available')) + self.assertIn('Cephfs mirror module is not enabled', response.get('message', ''))