diff --git a/cterasdk/edge/__init__.py b/cterasdk/edge/__init__.py index be39ec2b..0b2d9c3c 100644 --- a/cterasdk/edge/__init__.py +++ b/cterasdk/edge/__init__.py @@ -30,6 +30,7 @@ 'shares', 'shell', 'smb', + 'stats', 'support', 'sync', 'syslog', diff --git a/cterasdk/edge/stats.py b/cterasdk/edge/stats.py new file mode 100644 index 00000000..7f1d553e --- /dev/null +++ b/cterasdk/edge/stats.py @@ -0,0 +1,65 @@ +import logging + +from ..common import parse_base_object_ref +from ..exceptions import CTERAException +from .base_command import BaseCommand + + +logger = logging.getLogger('cterasdk.edge') + + +VALID_STAT_TYPES = ('cpu', 'memory', 'cache', 'volume', 'connections', 'local_io', 'disk_io', 'cloud_io') +VALID_INTERVALS = ('hour', 'day', 'week', 'month', 'year', 'last') + + +class Stats(BaseCommand): + """ + Edge Filer statistics via the HTTP Relay channel. + + Uses the portal's RemoteDeviceServlet (/devices//) which forwards + all HTTP headers — including cookies — to the device, unlike the binary + API channel (/devicecmdnew/) which cannot carry cookies. + """ + + def __init__(self, edge, relay): + super().__init__(edge) + self._relay = relay + self._authenticated = False + + def get(self, stat_type, interval='hour'): + """ + Get device statistics + + :param str stat_type: Statistic type. + Options: ``cpu``, ``memory``, ``cache``, ``volume``, ``connections``, ``local_io``, ``disk_io``, ``cloud_io`` + :param str,optional interval: Time interval, defaults to ``hour``. + Options: ``hour``, ``day``, ``week``, ``month``, ``year``, ``last`` + :returns: Statistics data + """ + if stat_type not in VALID_STAT_TYPES: + raise ValueError(f'Invalid stat_type {stat_type!r}. Valid: {VALID_STAT_TYPES}') + if interval not in VALID_INTERVALS: + raise ValueError(f'Invalid interval {interval!r}. Valid: {VALID_INTERVALS}') + self._ensure_session() + return self._relay.get(f'/stats/{stat_type}', params={'interval': interval}) + + def _ensure_session(self): + """ + Authenticate to the device through the relay channel on first use. + + Retrieves an SSO token from the portal and uses it to log in to the + device via the relay tunnel. The device responds with a Set-Cookie + header (_cteraSessionId_) which the shared HTTP session stores + automatically, scoped to the relay path. + """ + if not self._authenticated: + Portal = self._edge._Portal # pylint: disable=protected-access + tenant = parse_base_object_ref(self._edge.portal).name + device_name = self._edge.name + logger.debug('Retrieving SSO ticket for relay session. %s', {'tenant': tenant, 'device': device_name}) + token = Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') + if not token: + raise CTERAException('Failed to retrieve SSO ticket for relay session.') + self._relay.get('/ssologin', params={'ticket': token}) + self._authenticated = True + logger.debug('Relay session established. %s', {'tenant': tenant, 'device': device_name}) diff --git a/cterasdk/objects/synchronous/edge.py b/cterasdk/objects/synchronous/edge.py index dbe2c9ba..904fa82a 100644 --- a/cterasdk/objects/synchronous/edge.py +++ b/cterasdk/objects/synchronous/edge.py @@ -11,7 +11,7 @@ afp, aio, antivirus, array, audit, backup, cache, cli, config, connection, ctera_migrate, dedup, directoryservice, drive, files, firmware, ftp, groups, licenses, login, logs, mail, network, nfs, ntp, power, remote, rsync, ransom_protect, services, - shares, shell, smb, snmp, ssh, ssl, support, sync, syslog, tasks, telnet, + shares, shell, smb, snmp, ssh, ssl, stats, support, sync, syslog, tasks, telnet, timezone, users, volumes, ) @@ -19,6 +19,9 @@ class Clients: def __init__(self, edge, Portal): + self._edge = edge + self._Portal = Portal + self._stats = None if Portal: edge._Portal = Portal edge.default.close() @@ -29,6 +32,14 @@ def __init__(self, edge, Portal): self.api = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api')) self.io = IO(edge) + @property + def stats(self): + if self._stats is None and self._Portal: + relay_base = EndpointBuilder.new(f'{self._Portal.ctera.baseurl}/devices/{self._edge.name}') + relay = self._Portal.default.clone(clients.API, relay_base, authenticator=lambda *_: True) + self._stats = stats.Stats(self._edge, relay) + return self._stats + class IO: @@ -133,6 +144,10 @@ def api(self): def io(self): return self.clients.io + @property + def stats(self): + return self.clients.stats + @property def _session_id_key(self): return '_cteraSessionId_' @@ -164,5 +179,5 @@ def _omit_fields(self): return super()._omit_fields + ['afp', 'aio', 'array', 'audit', 'antivirus', 'backup', 'cache', 'cli', 'config', 'ctera_migrate', 'dedup', 'directoryservice', 'drive', 'files', 'firmware', 'ftp', 'groups', 'licenses', 'logs', 'mail', 'network', 'nfs', 'ntp', 'power', 'ransom_protect', 'rsync', 'services', 'shares', 'shell', - 'smb', 'snmp', 'ssh', 'ssl', 'support', 'sync', 'syslog', 'tasks', 'telnet', 'timezone', + 'smb', 'snmp', 'ssh', 'ssl', 'stats', 'support', 'sync', 'syslog', 'tasks', 'telnet', 'timezone', 'users', 'volumes'] diff --git a/tests/ut/edge/test_stats.py b/tests/ut/edge/test_stats.py new file mode 100644 index 00000000..20dd3af6 --- /dev/null +++ b/tests/ut/edge/test_stats.py @@ -0,0 +1,71 @@ +from unittest import mock + +from cterasdk.edge import stats +from cterasdk.exceptions import CTERAException +from tests.ut.edge import base_edge + + +class TestEdgeStats(base_edge.BaseEdgeTest): + + def setUp(self): + super().setUp() + self._relay = mock.MagicMock() + self._portal = mock.MagicMock() + self._filer._Portal = self._portal + self._filer.portal = 'objs/21//TeamPortal/test_tenant' + self._filer.name = 'my-filer' + self._sso_token = 'sso-token-abc123' + self._portal.api.execute.return_value = self._sso_token + self._stats = stats.Stats(self._filer, self._relay) + + def test_get_cpu_default_interval(self): + self._stats.get('cpu') + self._relay.get.assert_called_with('/stats/cpu', params={'interval': 'hour'}) + + def test_get_memory_with_interval(self): + self._stats.get('memory', interval='day') + self._relay.get.assert_called_with('/stats/memory', params={'interval': 'day'}) + + def test_get_all_stat_types(self): + for stat_type in stats.VALID_STAT_TYPES: + self._relay.reset_mock() + self._stats._authenticated = False + self._stats.get(stat_type, interval='hour') + self._relay.get.assert_called_with(f'/stats/{stat_type}', params={'interval': 'hour'}) + + def test_get_all_intervals(self): + for interval in stats.VALID_INTERVALS: + self._relay.reset_mock() + self._stats._authenticated = False + self._stats.get('cpu', interval=interval) + self._relay.get.assert_called_with('/stats/cpu', params={'interval': interval}) + + def test_invalid_stat_type_raises_value_error(self): + with self.assertRaises(ValueError): + self._stats.get('invalid_type') + + def test_invalid_interval_raises_value_error(self): + with self.assertRaises(ValueError): + self._stats.get('cpu', interval='invalid_interval') + + def test_ensure_session_retrieves_sso_token(self): + self._stats.get('cpu') + self._portal.api.execute.assert_called_once_with('/portals/test_tenant/devices/my-filer', 'singleSignOn') + + def test_ensure_session_calls_ssologin(self): + self._stats.get('cpu') + self._relay.get.assert_any_call('/ssologin', params={'ticket': self._sso_token}) + + def test_ensure_session_called_once(self): + self._stats.get('cpu') + self._stats.get('memory', interval='day') + self._portal.api.execute.assert_called_once() + + def test_ensure_session_raises_on_empty_token(self): + self._portal.api.execute.return_value = None + with self.assertRaises(CTERAException): + self._stats.get('cpu') + + def test_stats_not_available_locally(self): + local_filer = base_edge.Edge("") + self.assertIsNone(local_filer.stats)