]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/cephadm: Add snmp-gateway service support
authorPaul Cuzner <pcuzner@redhat.com>
Fri, 12 Nov 2021 03:16:59 +0000 (16:16 +1300)
committerSebastian Wagner <sewagner@redhat.com>
Tue, 18 Jan 2022 10:42:49 +0000 (11:42 +0100)
Add a new snmp-gateway service to provide a bridge between
Prometheus and an SNMP management platform. The gateway
service uses https://github.com/maxwo/snmp_notifier to provide
an SNMP v2c and SNMP V3 support.

The SNMP V3 support mandates at least authentication, and also
offers authentication and privacy (encryption).

Fixes: https://tracker.ceph.com/issues/52920
Signed-off-by: Paul Cuzner <pcuzner@redhat.com>
(cherry picked from commit c2f5e105ca4870b2cb124db662537c20e6daadae)

Conflicts:
src/pybind/mgr/cephadm/module.py
src/pybind/mgr/orchestrator/_interface.py
src/pybind/mgr/orchestrator/module.py
src/python-common/ceph/deployment/service_spec.py

src/cephadm/cephadm
src/pybind/mgr/cephadm/module.py
src/pybind/mgr/cephadm/services/monitoring.py
src/pybind/mgr/cephadm/templates/services/alertmanager/alertmanager.yml.j2
src/pybind/mgr/orchestrator/_interface.py
src/pybind/mgr/orchestrator/module.py
src/python-common/ceph/deployment/service_spec.py
src/python-common/ceph/deployment/utils.py
src/python-common/ceph/utils.py

index ba4c77791fe3bbaa11b47b3eeb2da3793f9bfb32..4889ec5e1eb9d796611f9b2d4948dcffb93ee994 100755 (executable)
@@ -278,8 +278,9 @@ class OSD(object):
 class SNMPGateway:
     """Defines an SNMP gateway between Prometheus and SNMP monitoring Frameworks"""
     daemon_type = 'snmp-gateway'
-    supported_versions = ['V2c']
+    SUPPORTED_VERSIONS = ['V2c', 'V3']
     default_image = DEFAULT_SNMP_GATEWAY_IMAGE
+    DEFAULT_PORT = 9464
     env_filename = 'snmp-gateway.conf'
 
     def __init__(self,
@@ -287,7 +288,7 @@ class SNMPGateway:
                  fsid: str,
                  daemon_id: Union[int, str],
                  config_json: Dict[str, Any],
-                 image: Optional[str] = None ) -> None:
+                 image: Optional[str] = None) -> None:
         self.ctx = ctx
         self.fsid = fsid
         self.daemon_id = daemon_id
@@ -295,16 +296,17 @@ class SNMPGateway:
 
         self.uid = config_json.get('uid', 0)
         self.gid = config_json.get('gid', 0)
-        self.ports = list([config_json.get('listen_port', 9464)])
+
         self.destination = config_json.get('destination', '')
         self.snmp_version = config_json.get('snmp_version', 'V2c')
         self.snmp_community = config_json.get('snmp_community', 'public')
         self.log_level = config_json.get('log_level', 'info')
-        self.snmp_v3_auth_user = config_json.get('snmp_v3_auth_user', '')
+        self.snmp_v3_auth_username = config_json.get('snmp_v3_auth_username', '')
         self.snmp_v3_auth_password = config_json.get('snmp_v3_auth_password', '')
+        self.snmp_v3_auth_protocol = config_json.get('snmp_v3_auth_protocol', '')
+        self.snmp_v3_priv_protocol = config_json.get('snmp_v3_priv_protocol', '')
         self.snmp_v3_priv_password = config_json.get('snmp_v3_priv_password', '')
-
-        # TODO Add SNMP V3 parameters
+        self.snmp_v3_engine_id = config_json.get('snmp_v3_engine_id', '')
 
         self.validate()
 
@@ -316,9 +318,9 @@ class SNMPGateway:
                    get_parm(ctx.config_json), ctx.image)
 
     @staticmethod
-    def get_version(ctx: CephadmContext, fsid, daemon_id: str) -> Optional[str]:
+    def get_version(ctx: CephadmContext, fsid: str, daemon_id: str) -> Optional[str]:
         """Return the version of the notifer from it's http endpoint"""
-        path = os.path.join(ctx.data_dir, fsid, f"snmp-gateway.{daemon_id}", 'unit.meta')
+        path = os.path.join(ctx.data_dir, fsid, f'snmp-gateway.{daemon_id}', 'unit.meta')
         try:
             with open(path, 'r') as env:
                 metadata = json.loads(env.read())
@@ -330,7 +332,7 @@ class SNMPGateway:
             return None
 
         try:
-            with urlopen(f"http://0.0.0.0:{ports[0]}/") as r:
+            with urlopen(f'http://127.0.0.1:{ports[0]}/') as r:
                 html = r.read().decode('utf-8').split('\n')
         except (HTTPError, URLError):
             return None
@@ -344,37 +346,62 @@ class SNMPGateway:
 
         return None
 
-    def get_daemon_args(self):
+    @property
+    def port(self) -> int:
+        if not self.ctx.tcp_ports:
+            return self.DEFAULT_PORT
+        else:
+            if len(self.ctx.tcp_ports) > 0:
+                return int(self.ctx.tcp_ports.split()[0])
+            else:
+                return self.DEFAULT_PORT
 
-        args = [
-            f'--web.listen-address=:{self.ports[0]}',
+    def get_daemon_args(self) -> List[str]:
+        v3_args = []
+        base_args = [
+            f'--web.listen-address=:{self.port}',
             f'--snmp.destination={self.destination}',
             f'--snmp.version={self.snmp_version}',
             f'--log.level={self.log_level}',
             '--snmp.trap-description-template=/etc/snmp_notifier/description-template.tpl'
         ]
 
-        return args
+        if self.snmp_version == 'V3':
+            # common auth settings
+            v3_args.extend([
+                '--snmp.authentication-enabled',
+                f'--snmp.authentication-protocol={self.snmp_v3_auth_protocol}',
+                f'--snmp.security-engine-id={self.snmp_v3_engine_id}'
+            ])
+            # authPriv setting is applied if we have a privacy protocol setting
+            if self.snmp_v3_priv_protocol:
+                v3_args.extend([
+                    '--snmp.private-enabled',
+                    f'--snmp.private-protocol={self.snmp_v3_priv_protocol}'
+                ])
+
+        return base_args + v3_args
 
     @property
     def data_dir(self) -> str:
-        return os.path.join(self.ctx.data_dir, self.ctx.fsid, f"{self.daemon_type}.{self.daemon_id}")
+        return os.path.join(self.ctx.data_dir, self.ctx.fsid, f'{self.daemon_type}.{self.daemon_id}')
 
     @property
     def conf_file_path(self) -> str:
         return os.path.join(self.data_dir, self.env_filename)
 
-    def create_daemon_conf(self):
+    def create_daemon_conf(self) -> None:
         """Creates the environment file holding 'secrets' passed to the snmp-notifier daemon"""
         with open(os.open(self.conf_file_path, os.O_CREAT | os.O_WRONLY, 0o600), 'w') as f:
-            f.write(f"PORT={self.ports[0]}\n")
             if self.snmp_version == 'V2c':
-                f.write(f"SNMP_NOTIFIER_COMMUNITY={self.snmp_community}\n")
+                f.write(f'SNMP_NOTIFIER_COMMUNITY={self.snmp_community}\n')
             else:
-                # add snmp v3 settings here
-                pass
+                f.write(f'SNMP_NOTIFIER_AUTH_USERNAME={self.snmp_v3_auth_username}\n')
+                f.write(f'SNMP_NOTIFIER_AUTH_PASSWORD={self.snmp_v3_auth_password}\n')
+                if self.snmp_v3_priv_password:
+                    f.write(f'SNMP_NOTIFIER_PRIV_PASSWORD={self.snmp_v3_priv_password}\n')
 
-    def validate(self):
+    def validate(self) -> None:
         """Validate the settings
 
         Raises:
@@ -385,8 +412,8 @@ class SNMPGateway:
         if not is_fsid(self.fsid):
             raise Error(f'not a valid fsid: {self.fsid}')
 
-        if self.snmp_version not in SNMPGateway.supported_versions:
-            raise Error(f'not a valid snmp version: {self.fsid}')
+        if self.snmp_version not in SNMPGateway.SUPPORTED_VERSIONS:
+            raise Error(f'not a valid snmp version: {self.snmp_version}')
 
         if not self.destination:
             raise Error('config is missing destination attribute(<ip>:<port>) of the target SNMP listener')
@@ -2700,7 +2727,7 @@ def get_container(ctx: CephadmContext,
     elif daemon_type == SNMPGateway.daemon_type:
         sg = SNMPGateway.init(ctx, fsid, daemon_id)
         container_args.append(
-            f"--env-file={sg.conf_file_path}"
+            f'--env-file={sg.conf_file_path}'
         )
 
     # if using podman, set -d, --conmon-pidfile & --cidfile flags
@@ -4828,7 +4855,7 @@ def command_deploy(ctx):
         c = get_container(ctx, ctx.fsid, daemon_type, daemon_id)
         deploy_daemon(ctx, ctx.fsid, daemon_type, daemon_id, c,
                       sc.uid, sc.gid,
-                      ports=sc.ports)
+                      ports=daemon_ports)
 
     else:
         raise Error('daemon type {} not implemented in command_deploy function'
index 09a50ed5c62f5d2805bc64330ede7b42238224ed..5234433e0eac1d60f48ec83626d0a0b0fa205d66 100644 (file)
@@ -53,7 +53,7 @@ from .services.iscsi import IscsiService
 from .services.nfs import NFSService
 from .services.osd import OSDRemovalQueue, OSDService, OSD, NotFoundError
 from .services.monitoring import GrafanaService, AlertmanagerService, PrometheusService, \
-    NodeExporterService
+    NodeExporterService, SNMPGatewayService
 from .services.exporter import CephadmExporter, CephadmExporterConfig
 from .schedule import HostAssignment
 from .inventory import Inventory, SpecStore, HostCache, EventStore, ClientKeyringStore, ClientKeyringSpec
@@ -98,6 +98,7 @@ DEFAULT_ALERT_MANAGER_IMAGE = 'quay.io/prometheus/alertmanager:v0.20.0'
 DEFAULT_GRAFANA_IMAGE = 'quay.io/ceph/ceph-grafana:6.7.4'
 DEFAULT_HAPROXY_IMAGE = 'docker.io/library/haproxy:2.3'
 DEFAULT_KEEPALIVED_IMAGE = 'docker.io/arcts/keepalived'
+DEFAULT_SNMP_GATEWAY_IMAGE = 'docker.io/maxwo/snmp-notifier:v1.2.1'
 # ------------------------------------------------------------------------------
 
 
@@ -218,6 +219,11 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule,
             default=DEFAULT_KEEPALIVED_IMAGE,
             desc='Keepalived container image',
         ),
+        Option(
+            'container_image_snmp_gateway',
+            default=DEFAULT_SNMP_GATEWAY_IMAGE,
+            desc='SNMP Gateway container image',
+        ),
         Option(
             'warn_on_stray_hosts',
             type='bool',
@@ -389,6 +395,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule,
             self.container_image_node_exporter = ''
             self.container_image_haproxy = ''
             self.container_image_keepalived = ''
+            self.container_image_snmp_gateway = ''
             self.warn_on_stray_hosts = True
             self.warn_on_stray_daemons = True
             self.warn_on_failed_host_check = True
@@ -470,7 +477,8 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule,
             OSDService, NFSService, MonService, MgrService, MdsService,
             RgwService, RbdMirrorService, GrafanaService, AlertmanagerService,
             PrometheusService, NodeExporterService, CrashService, IscsiService,
-            IngressService, CustomContainerService, CephadmExporter, CephfsMirrorService
+            IngressService, CustomContainerService, CephadmExporter, CephfsMirrorService,
+            SNMPGatewayService,
         ]
 
         # https://github.com/python/mypy/issues/8993
@@ -607,7 +615,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule,
         suffix = daemon_type not in [
             'mon', 'crash',
             'prometheus', 'node-exporter', 'grafana', 'alertmanager',
-            'container', 'cephadm-exporter',
+            'container', 'cephadm-exporter', 'snmp-gateway'
         ]
         if forcename:
             if len([d for d in existing if d.daemon_id == forcename]):
@@ -1390,6 +1398,8 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule,
             # is only available when a container is deployed (given
             # via spec).
             image = None
+        elif daemon_type == 'snmp-gateway':
+            image = self.container_image_snmp_gateway
         else:
             assert False, daemon_type
 
@@ -2363,7 +2373,7 @@ Then run the following:
             need = {
                 'prometheus': ['mgr', 'alertmanager', 'node-exporter', 'ingress'],
                 'grafana': ['prometheus'],
-                'alertmanager': ['mgr', 'alertmanager'],
+                'alertmanager': ['mgr', 'alertmanager', 'snmp-gateway'],
             }
             for dep_type in need.get(daemon_type, []):
                 for dd in self.cache.get_daemons_by_type(dep_type):
@@ -2545,6 +2555,7 @@ Then run the following:
                 'crash': PlacementSpec(host_pattern='*'),
                 'container': PlacementSpec(count=1),
                 'cephadm-exporter': PlacementSpec(host_pattern='*'),
+                'snmp-gateway': PlacementSpec(count=1),
             }
             spec.placement = defaults[spec.service_type]
         elif spec.service_type in ['mon', 'mgr'] and \
@@ -2657,6 +2668,10 @@ Then run the following:
     def apply_container(self, spec: ServiceSpec) -> str:
         return self._apply(spec)
 
+    @handle_orch_error
+    def apply_snmp_gateway(self, spec: ServiceSpec) -> str:
+        return self._apply(spec)
+
     @handle_orch_error
     def apply_cephadm_exporter(self, spec: ServiceSpec) -> str:
         return self._apply(spec)
index 627673d4dd65b1e09f2f798d923aff8a6f913236..c252462c212eb04ea0e95755fabf00d26448517c 100644 (file)
@@ -7,7 +7,7 @@ from urllib.parse import urlparse
 from mgr_module import HandleCommandResult
 
 from orchestrator import DaemonDescription
-from ceph.deployment.service_spec import AlertManagerSpec, GrafanaSpec, ServiceSpec
+from ceph.deployment.service_spec import AlertManagerSpec, GrafanaSpec, ServiceSpec, SNMPGatewaySpec
 from cephadm.services.cephadmservice import CephadmService, CephadmDaemonDeploySpec
 from cephadm.services.ingress import IngressSpec
 from mgr_util import verify_tls, ServerConfigException, create_self_signed_cert, build_url
@@ -129,6 +129,7 @@ class AlertmanagerService(CephadmService):
 
         # dashboard(s)
         dashboard_urls: List[str] = []
+        snmp_gateway_urls: List[str] = []
         mgr_map = self.mgr.get('mgr_map')
         port = None
         proto = None  # http: or https:
@@ -152,9 +153,17 @@ class AlertmanagerService(CephadmService):
             addr = self.mgr.inventory.get_addr(dd.hostname)
             dashboard_urls.append(build_url(scheme=proto, host=addr, port=port))
 
+        for dd in self.mgr.cache.get_daemons_by_service('snmp-gateway'):
+            assert dd.hostname is not None
+            assert dd.ports
+            addr = dd.ip if dd.ip else self._inventory_get_addr(dd.hostname)
+            deps.append(dd.name())
+            snmp_gateway_urls.append(f"http://{addr}:{dd.ports[0]}/alerts")
+
         context = {
             'dashboard_urls': dashboard_urls,
-            'default_webhook_urls': default_webhook_urls
+            'default_webhook_urls': default_webhook_urls,
+            'snmp_gateway_urls': snmp_gateway_urls,
         }
         yml = self.mgr.template.render('services/alertmanager/alertmanager.yml.j2', context)
 
@@ -165,6 +174,7 @@ class AlertmanagerService(CephadmService):
             deps.append(dd.name())
             addr = self.mgr.inventory.get_addr(dd.hostname)
             peers.append(build_url(host=addr, port=port).lstrip('/'))
+
         return {
             "files": {
                 "alertmanager.yml": yml
@@ -364,3 +374,59 @@ class NodeExporterService(CephadmService):
         names = [f'{self.TYPE}.{d_id}' for d_id in daemon_ids]
         out = f'It is presumed safe to stop {names}'
         return HandleCommandResult(0, out, '')
+
+
+class SNMPGatewayService(CephadmService):
+    TYPE = 'snmp-gateway'
+
+    def prepare_create(self, daemon_spec: CephadmDaemonDeploySpec) -> CephadmDaemonDeploySpec:
+        assert self.TYPE == daemon_spec.daemon_type
+        daemon_spec.final_config, daemon_spec.deps = self.generate_config(daemon_spec)
+        return daemon_spec
+
+    def generate_config(self, daemon_spec: CephadmDaemonDeploySpec) -> Tuple[Dict[str, Any], List[str]]:
+        assert self.TYPE == daemon_spec.daemon_type
+        deps: List[str] = []
+
+        spec = cast(SNMPGatewaySpec, self.mgr.spec_store[daemon_spec.service_name].spec)
+        config = {
+            "destination": spec.snmp_destination,
+            "snmp_version": spec.snmp_version,
+        }
+        if spec.snmp_version == 'V2c':
+            community = spec.credentials.get('snmp_community', None)
+            assert community is not None
+
+            config.update({
+                "snmp_community": community
+            })
+        else:
+            # SNMP v3 settings can be either authNoPriv or authPriv
+            auth_protocol = 'SHA' if not spec.auth_protocol else spec.auth_protocol
+
+            auth_username = spec.credentials.get('snmp_v3_auth_username', None)
+            auth_password = spec.credentials.get('snmp_v3_auth_password', None)
+            assert auth_username is not None
+            assert auth_password is not None
+            assert spec.engine_id is not None
+
+            config.update({
+                "snmp_v3_auth_protocol": auth_protocol,
+                "snmp_v3_auth_username": auth_username,
+                "snmp_v3_auth_password": auth_password,
+                "snmp_v3_engine_id": spec.engine_id,
+            })
+            # authPriv adds encryption
+            if spec.privacy_protocol:
+                priv_password = spec.credentials.get('snmp_v3_priv_password', None)
+                assert priv_password is not None
+
+                config.update({
+                    "snmp_v3_priv_protocol": spec.privacy_protocol,
+                    "snmp_v3_priv_password": priv_password,
+                })
+
+        logger.debug(
+            f"Generated configuration for '{self.TYPE}' service. Dependencies={deps}")
+
+        return config, sorted(deps)
index 6ce27cdf728bebc0f3a9c2f2fcc7e8caaa40bea3..4a8f313a71af2ca3c498739360d4cc1e9ab956f1 100644 (file)
@@ -12,6 +12,15 @@ route:
       group_interval: 10s
       repeat_interval: 1h
       receiver: 'ceph-dashboard'
+{% if snmp_gateway_urls %}
+      continue: true
+    - receiver: 'snmp-gateway'
+      repeat_interval: 1h
+      group_interval: 10s
+      group_by: ['alertname']
+      match_re:
+        oid: "(1.3.6.1.4.1.50495.).*"
+{% endif %}
 
 receivers:
 - name: 'default'
@@ -24,3 +33,10 @@ receivers:
 {% for url in dashboard_urls %}
   - url: '{{ url }}/api/prometheus_receiver'
 {% endfor %}
+{% if snmp_gateway_urls %}
+- name: 'snmp-gateway'
+  webhook_configs:
+{% for url in snmp_gateway_urls %}
+  - url: '{{ url }}'
+{% endfor %}
+{% endif %}
index 149a7dd72c4f9c17001ab70cf414587537c83ac3..ee06e2cf1cbbfc7245850321ac2db157250a9058 100644 (file)
@@ -31,7 +31,7 @@ import yaml
 
 from ceph.deployment import inventory
 from ceph.deployment.service_spec import ServiceSpec, NFSServiceSpec, RGWSpec, \
-    IscsiServiceSpec, IngressSpec
+    IscsiServiceSpec, IngressSpec, SNMPGatewaySpec
 from ceph.deployment.drive_group import DriveGroupSpec
 from ceph.deployment.hostspec import HostSpec, SpecValidationError
 from ceph.utils import datetime_to_str, str_to_datetime
@@ -466,6 +466,7 @@ class Orchestrator(object):
             'rbd-mirror': self.apply_rbd_mirror,
             'rgw': self.apply_rgw,
             'ingress': self.apply_ingress,
+            'snmp-gateway': self.apply_snmp_gateway,
             'host': self.add_host,
             'cephadm-exporter': self.apply_cephadm_exporter,
         }
@@ -649,6 +650,10 @@ class Orchestrator(object):
         """Update an existing AlertManager daemon(s)"""
         raise NotImplementedError()
 
+    def apply_snmp_gateway(self, spec: SNMPGatewaySpec) -> OrchResult[str]:
+        """Update an existing snmp gateway service"""
+        raise NotImplementedError()
+
     def apply_cephadm_exporter(self, spec: ServiceSpec) -> OrchResult[str]:
         """Update an existing cephadm exporter daemon"""
         raise NotImplementedError()
@@ -721,6 +726,7 @@ def daemon_type_to_service(dtype: str) -> str:
         'crashcollector': 'crash',  # Specific Rook Daemon
         'container': 'container',
         'cephadm-exporter': 'cephadm-exporter',
+        'snmp-gateway': 'snmp-gateway',
     }
     return mapping[dtype]
 
@@ -744,6 +750,7 @@ def service_to_daemon_types(stype: str) -> List[str]:
         'crash': ['crash'],
         'container': ['container'],
         'cephadm-exporter': ['cephadm-exporter'],
+        'snmp-gateway': ['snmp-gateway'],
     }
     return mapping[stype]
 
index 94c560984e775a8b950b436b576a9e36dffc3719..7d04c2d4e743eaa8a615f3272cb10bb6d2f1ba6a 100644 (file)
@@ -10,7 +10,8 @@ from prettytable import PrettyTable
 
 from ceph.deployment.inventory import Device
 from ceph.deployment.drive_group import DriveGroupSpec, DeviceSelection
-from ceph.deployment.service_spec import PlacementSpec, ServiceSpec
+from ceph.deployment.service_spec import PlacementSpec, ServiceSpec, \
+    SNMPGatewaySpec, SNMPVersion, SNMPAuthType, SNMPPrivacyType
 from ceph.deployment.hostspec import SpecValidationError
 from ceph.utils import datetime_now
 
@@ -60,6 +61,7 @@ class ServiceType(enum.Enum):
     nfs = 'nfs'
     iscsi = 'iscsi'
     cephadm_exporter = 'cephadm-exporter'
+    snmp_gateway = 'snmp-gateway'
 
 
 class ServiceAction(enum.Enum):
@@ -1157,6 +1159,52 @@ Usage:
 
         return self._apply_misc([spec], dry_run, format, no_overwrite)
 
+    @_cli_write_command('orch apply snmp-gateway')
+    def _apply_snmp_gateway(self,
+                            snmp_version: SNMPVersion,
+                            destination: str,
+                            port: int = 9464,
+                            engine_id: Optional[str] = None,
+                            auth_protocol: Optional[SNMPAuthType] = None,
+                            privacy_protocol: Optional[SNMPPrivacyType] = None,
+                            placement: Optional[str] = None,
+                            unmanaged: bool = False,
+                            dry_run: bool = False,
+                            format: Format = Format.plain,
+                            no_overwrite: bool = False,
+                            inbuf: Optional[str] = None) -> HandleCommandResult:
+        """Add a Prometheus to SNMP gateway service (cephadm only)"""
+
+        if not inbuf:
+            raise OrchestratorValidationError(
+                'missing credential configuration file. Retry with -i <filename>')
+
+        try:
+            # load inbuf
+            credentials = yaml.safe_load(inbuf)
+        except (OSError, yaml.YAMLError):
+            raise OrchestratorValidationError('credentials file must be valid YAML')
+
+        auth = None if not auth_protocol else auth_protocol.value
+        priv = None if not privacy_protocol else privacy_protocol.value
+
+        spec = SNMPGatewaySpec(
+            snmp_version=snmp_version.value,
+            port=port,
+            credentials=credentials,
+            snmp_destination=destination,
+            engine_id=engine_id,
+            auth_protocol=auth,
+            privacy_protocol=priv,
+            placement=PlacementSpec.from_string(placement),
+            unmanaged=unmanaged,
+            preview_only=dry_run
+        )
+
+        spec.validate()  # force any validation exceptions to be caught correctly
+
+        return self._apply_misc([spec], dry_run, format, no_overwrite)
+
     @_cli_write_command('orch set backend')
     def _set_backend(self, module_name: Optional[str] = None) -> HandleCommandResult:
         """
index ea5f1106a32b5fa1466df66dbb3f4ed2c42abd05..124b48889795a6ff72c809c2e7283ee9a2b57fbd 100644 (file)
@@ -1,5 +1,6 @@
 import fnmatch
 import re
+import enum
 from collections import OrderedDict
 from functools import wraps
 from ipaddress import ip_network, ip_address
@@ -9,11 +10,26 @@ from typing import Optional, Dict, Any, List, Union, Callable, Iterable, Type, T
 import yaml
 
 from ceph.deployment.hostspec import HostSpec, SpecValidationError
-from ceph.deployment.utils import unwrap_ipv6
+from ceph.deployment.utils import unwrap_ipv6, valid_addr
+from ceph.utils import is_hex
 
 ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec')
 FuncT = TypeVar('FuncT', bound=Callable)
 
+class SNMPVersion(enum.Enum):
+    V2c = 'V2c'
+    V3 = 'V3'
+
+
+class SNMPAuthType(enum.Enum):
+    MD5 = 'MD5'
+    SHA = 'SHA'
+
+
+class SNMPPrivacyType(enum.Enum):
+    DES = 'DES'
+    AES = 'AES'
+
 
 def assert_valid_host(name: str) -> None:
     p = re.compile('^[a-zA-Z0-9-]+$')
@@ -428,7 +444,7 @@ class ServiceSpec(object):
     """
     KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi mds mgr mon nfs ' \
                           'node-exporter osd prometheus rbd-mirror rgw ' \
-                          'container cephadm-exporter ingress cephfs-mirror'.split()
+                          'container cephadm-exporter ingress cephfs-mirror snmp-gateway'.split()
     REQUIRES_SERVICE_ID = 'iscsi mds nfs osd rgw container ingress '.split()
     MANAGED_CONFIG_OPTIONS = [
         'mds_join_fs',
@@ -449,6 +465,7 @@ class ServiceSpec(object):
             'grafana': GrafanaSpec,
             'node-exporter': MonitoringSpec,
             'prometheus': MonitoringSpec,
+            'snmp-gateway': SNMPGatewaySpec,
         }.get(service_type, cls)
         if ret == ServiceSpec and not service_type:
             raise SpecValidationError('Spec needs a "service_type" key.')
@@ -1131,3 +1148,129 @@ class GrafanaSpec(MonitoringSpec):
 
 
 yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer)
+
+
+class SNMPGatewaySpec(ServiceSpec):
+    valid_destination_types = [
+        'Name:Port',
+        'IPv4:Port'
+    ]
+
+    def __init__(self,
+                 service_type: str = 'snmp-gateway',
+                 snmp_version: Optional[str] = None,
+                 snmp_destination: str = '',
+                 credentials: Dict[str, str] = {},
+                 engine_id: Optional[str] = None,
+                 auth_protocol: Optional[str] = None,
+                 privacy_protocol: Optional[str] = None,
+                 placement: Optional[PlacementSpec] = None,
+                 unmanaged: bool = False,
+                 preview_only: bool = False,
+                 port: int = 9464,
+                 ):
+        assert service_type == 'snmp-gateway'
+
+        super(SNMPGatewaySpec, self).__init__(
+            service_type,
+            placement=placement,
+            unmanaged=unmanaged,
+            preview_only=preview_only)
+
+        self.service_type = service_type
+        self.snmp_version = snmp_version
+        self.snmp_destination = snmp_destination
+        self.port = port
+        self.credentials = credentials
+        self.engine_id = engine_id
+        self.auth_protocol = auth_protocol
+        self.privacy_protocol = privacy_protocol
+
+    @property
+    def ports(self) -> List[int]:
+        return [self.port]
+
+    def get_port_start(self) -> List[int]:
+        return self.ports
+
+    def validate(self) -> None:
+        super(SNMPGatewaySpec, self).validate()
+
+        def _check_type(name: str, value: Optional[str], options: List[str]) -> None:
+            if not value:
+                return
+            if value not in options:
+                raise SpecValidationError(
+                    f'{name} unsupported. Must be one of {", ".join(sorted(options))}'
+                )
+
+        if not self.credentials:
+            raise SpecValidationError(
+                'Missing authentication information (credentials). '
+                'SNMP V2c and V3 require credential information'
+            )
+        elif not self.snmp_version:
+            raise SpecValidationError(
+                'Missing SNMP version (snmp_version)'
+            )
+
+        _check_type('snmp_version',
+                    self.snmp_version,
+                    list(set(opt.value for opt in SNMPVersion)))
+        _check_type('auth_protocol',
+                    self.auth_protocol,
+                    list(set(opt.value for opt in SNMPAuthType)))
+        _check_type('privacy_protocol',
+                    self.privacy_protocol,
+                    list(set(opt.value for opt in SNMPPrivacyType)))
+
+        creds_requirement = {
+            'V2c': ['snmp_community'],
+            'V3': ['snmp_v3_auth_username', 'snmp_v3_auth_password']
+        }
+        if self.privacy_protocol:
+            creds_requirement['V3'].append('snmp_v3_priv_password')
+
+        missing = [parm for parm in creds_requirement[self.snmp_version]
+                   if parm not in self.credentials]
+        # check that credentials are correct for the version
+        if missing:
+            raise SpecValidationError(
+                f'SNMP {self.snmp_version} credentials are incomplete. Missing {", ".join(missing)}'
+            )
+
+        if self.engine_id:
+            if 10 <= len(self.engine_id) <= 64 and \
+               is_hex(self.engine_id) and \
+               len(self.engine_id) % 2 == 0:
+                pass
+            else:
+                raise SpecValidationError(
+                    'engine_id must be a string containing 10-64 hex characters. '
+                    'Its length must be divisible by 2'
+                )
+
+        else:
+            if self.snmp_version == 'V3':
+                raise SpecValidationError(
+                    'Must provide an engine_id for SNMP V3 notifications'
+                )
+
+        if not self.snmp_destination:
+            raise SpecValidationError(
+                'SNMP destination (snmp_destination) must be provided'
+            )
+        else:
+            valid, description = valid_addr(self.snmp_destination)
+            if not valid:
+                raise SpecValidationError(
+                    f'SNMP destination (snmp_destination) is invalid: {description}'
+                )
+            if description not in self.valid_destination_types:
+                raise SpecValidationError(
+                    f'SNMP destination (snmp_destination) type ({description}) is invalid. '
+                    f'Must be either: {", ".join(sorted(self.valid_destination_types))}'
+                )
+
+
+yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer)
index 075aa8fe72591d12a099eb673f755cd1687c57c6..6aad15b75b6baffe777b8a2ca65d905326197280 100644 (file)
@@ -1,4 +1,7 @@
 import ipaddress
+import socket
+from typing import Tuple, Optional
+from urllib.parse import urlparse
 
 
 def unwrap_ipv6(address):
@@ -30,3 +33,70 @@ def is_ipv6(address):
         return ipaddress.ip_address(address).version == 6
     except ValueError:
         return False
+
+
+def valid_addr(addr: str) -> Tuple[bool, str]:
+    """check that an address string is valid
+    Valid in this case means that a name is resolvable, or the
+    IP address string is a correctly formed IPv4 or IPv6 address,
+    with or without a port
+
+    Args:
+        addr (str): address
+
+    Returns:
+        Tuple[bool, str]: Validity of the address, either
+                          True, address type (IPv4[:Port], IPv6[:Port], Name[:Port])
+                          False, <error description>
+    """
+
+    def _dns_lookup(addr: str, port: Optional[int]) -> Tuple[bool, str]:
+        try:
+            socket.getaddrinfo(addr, None)
+        except socket.gaierror:
+            # not resolvable
+            return False, 'DNS lookup failed'
+        return True, 'Name:Port' if port else 'Name'
+
+    def _ip_lookup(addr: str, port: Optional[int]) -> Tuple[bool, str]:
+        unwrapped = unwrap_ipv6(addr)
+        try:
+            ip_addr = ipaddress.ip_address(unwrapped)
+        except ValueError:
+            return False, 'Invalid IP v4 or v6 address format'
+        return True, f'IPv{ip_addr.version}:Port' if port else f'IPv{ip_addr.version}'
+
+    dots = addr.count('.')
+    colons = addr.count(':')
+    addr_as_url = f'http://{addr}'
+
+    try:
+        res = urlparse(addr_as_url)
+    except ValueError as e:
+        if str(e) == 'Invalid IPv6 URL':
+            return False, 'Address has incorrect/incomplete use of enclosing brackets'
+        return False, f'Unknown urlparse error {str(e)} for {addr_as_url}'
+
+    addr = res.netloc
+    port = None
+    try:
+        port = res.port
+        if port:
+            addr = addr[:-len(f':{port}')]
+    except ValueError:
+        if colons == 1:
+            return False, 'Port must be numeric'
+        elif ']:' in addr:
+            return False, 'Port must be numeric'
+
+    if addr.startswith('[') and dots:
+        return False, "IPv4 address wrapped in brackets is invalid"
+
+    # catch partial address like 10.8 which would be valid IPaddress schemes
+    # but are classed as invalid here since they're not usable
+    if dots and addr[0].isdigit() and dots != 3:
+        return False, 'Invalid partial IPv4 address'
+
+    if addr[0].isalpha() and '.' in addr:
+        return _dns_lookup(addr, port)
+    return _ip_lookup(addr, port)
index f5a85c4d3b7be1b79c263e193ae3b9b4f2227633..643be06580b6194d3c03e3b660569598520bec0e 100644 (file)
@@ -1,5 +1,6 @@
 import datetime
 import re
+import string
 
 from typing import Optional
 
@@ -105,3 +106,18 @@ def parse_timedelta(delta: str) -> Optional[datetime.timedelta]:
     parts = parts.groupdict()
     args = {name: int(param) for name, param in parts.items() if param}
     return datetime.timedelta(**args)
+
+
+def is_hex(s: str, strict: bool = True) -> bool:
+    """Simple check that a string contains only hex chars"""
+    try:
+        int(s, 16)
+    except ValueError:
+        return False
+
+    # s is multiple chars, but we should catch a '+/-' prefix too.
+    if strict:
+        if s[0] not in string.hexdigits:
+            return False
+
+    return True