# Copyright 2019 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from openstack import exceptions as os_exc
from oslo_config import cfg
from oslo_log import log as logging

from kuryr_kubernetes import clients
from kuryr_kubernetes import constants
from kuryr_kubernetes.controller.drivers import base as drivers
from kuryr_kubernetes.controller.drivers import utils as driver_utils
from kuryr_kubernetes import exceptions
from kuryr_kubernetes.handlers import k8s_base
from kuryr_kubernetes import utils

LOG = logging.getLogger(__name__)
CONF = cfg.CONF


class KuryrNetworkPolicyHandler(k8s_base.ResourceEventHandler):
    """Controller side of KuryrNetworkPolicy process for Kubernetes pods.

    `KuryrNetworkPolicyHandler` runs on the kuryr-controller and is
    responsible for creating and deleting SG and SG rules for `NetworkPolicy`.
    The `KuryrNetworkPolicy` objects are created by `NetworkPolicyHandler`.
    """
    OBJECT_KIND = constants.K8S_OBJ_KURYRNETWORKPOLICY
    OBJECT_WATCH_PATH = constants.K8S_API_CRD_KURYRNETWORKPOLICIES

    def __init__(self):
        super(KuryrNetworkPolicyHandler, self).__init__()
        self.os_net = clients.get_network_client()
        self.k8s = clients.get_kubernetes_client()
        self._drv_project = drivers.NetworkPolicyProjectDriver.get_instance()
        self._drv_policy = drivers.NetworkPolicyDriver.get_instance()
        self._drv_vif_pool = drivers.VIFPoolDriver.get_instance(
            specific_driver='multi_pool')
        self._drv_vif_pool.set_vif_driver()
        self._drv_pod_sg = drivers.PodSecurityGroupsDriver.get_instance()
        self._drv_svc_sg = drivers.ServiceSecurityGroupsDriver.get_instance()
        self._drv_lbaas = drivers.LBaaSDriver.get_instance()

    def _patch_kuryrnetworkpolicy_crd(self, knp, field, data,
                                      action='replace'):
        name = knp['metadata']['name']
        LOG.debug('Patching KuryrNet CRD %s', name)
        try:
            status = self.k8s.patch_crd(field, utils.get_res_link(knp),
                                        data, action=action)
        except exceptions.K8sResourceNotFound:
            LOG.debug('KuryrNetworkPolicy CRD not found %s', name)
            return None
        except exceptions.K8sClientException:
            LOG.exception('Error updating KuryrNetworkPolicy CRD %s', name)
            raise

        knp['status'] = status
        return knp

    def _get_networkpolicy(self, link):
        return self.k8s.get(link)

    def _compare_sgs(self, a, b):
        checked_props = ('direction', 'ethertype', 'port_range_max',
                         'port_range_min', 'protocol', 'remote_ip_prefix')

        for k in checked_props:
            if a.get(k) != b.get(k):
                return False
        return True

    def _find_sgs(self, a, rules):
        for r in rules:
            if self._compare_sgs(r, a):
                return True

        return False

    def on_present(self, knp, *args, **kwargs):
        uniq_name = utils.get_res_unique_name(knp)
        LOG.debug('on_present() for NP %s', uniq_name)
        project_id = self._drv_project.get_project(knp)
        if not knp['status'].get('securityGroupId'):
            LOG.debug('Creating SG for NP %s', uniq_name)
            # TODO(dulek): Do this right, why do we have a project driver per
            #              resource?! This one expects policy, not knp, but it
            #              ignores it anyway!
            sg_id = self._drv_policy.create_security_group(knp, project_id)
            knp = self._patch_kuryrnetworkpolicy_crd(
                knp, 'status', {'securityGroupId': sg_id})
            LOG.debug('Created SG %s for NP %s', sg_id, uniq_name)
        else:
            # TODO(dulek): Check if it really exists, recreate if not.
            sg_id = knp['status'].get('securityGroupId')

        # First update SG rules as we want to apply updated ones
        current = knp['status']['securityGroupRules']
        required = knp['spec']['ingressSgRules'] + knp['spec']['egressSgRules']
        required = [r['sgRule'] for r in required]

        # FIXME(dulek): This *might* be prone to race conditions if failure
        #               happens between SG rule is created/deleted and status
        #               is annotated. We don't however need to revert on failed
        #               K8s operations - creation, deletion of SG rules and
        #               attaching or detaching SG from ports are idempotent
        #               so we can repeat them. What worries me is losing track
        #               of an update due to restart. The only way to do it
        #               would be to periodically check if what's in `status`
        #               is the reality in OpenStack API. That should be just
        #               two Neutron API calls + possible resync.
        to_add = []
        to_remove = []
        for r in required:
            if not self._find_sgs(r, current):
                to_add.append(r)

        for i, c in enumerate(current):
            if not self._find_sgs(c, required):
                to_remove.append((i, c['id']))

        LOG.debug('SGs to add for NP %s: %s', uniq_name, to_add)

        for sg_rule in to_add:
            LOG.debug('Adding SG rule %s for NP %s', sg_rule, uniq_name)
            sg_rule['security_group_id'] = sg_id
            sgr_id = driver_utils.create_security_group_rule(sg_rule)
            sg_rule['id'] = sgr_id
            knp = self._patch_kuryrnetworkpolicy_crd(
                knp, 'status', {'securityGroupRules/-': sg_rule}, 'add')

        # We need to remove starting from the last one in order to maintain
        # indexes. Please note this will start to fail miserably if we start
        # to change status from multiple places.
        to_remove.reverse()

        LOG.debug('SGs to remove for NP %s: %s', uniq_name,
                  [x[1] for x in to_remove])

        for i, sg_rule_id in to_remove:
            LOG.debug('Removing SG rule %s as it is no longer part of NP %s',
                      sg_rule_id, uniq_name)
            driver_utils.delete_security_group_rule(sg_rule_id)
            knp = self._patch_kuryrnetworkpolicy_crd(
                knp, 'status/securityGroupRules', i, 'remove')

        pods_to_update = []

        previous_sel = knp['status'].get('podSelector', None)
        current_sel = knp['spec']['podSelector']
        if previous_sel is None:
            # Fresh NetworkPolicy that was never applied.
            pods_to_update.extend(self._drv_policy.namespaced_pods(knp))
        elif previous_sel != current_sel or previous_sel == {}:
            pods_to_update.extend(
                self._drv_policy.affected_pods(knp, previous_sel))

        matched_pods = self._drv_policy.affected_pods(knp)
        pods_to_update.extend(matched_pods)

        for pod in pods_to_update:
            if (driver_utils.is_host_network(pod) or
                    not driver_utils.is_pod_scheduled(pod)):
                continue
            pod_sgs = self._drv_pod_sg.get_security_groups(pod, project_id)
            try:
                self._drv_vif_pool.update_vif_sgs(pod, pod_sgs)
            except os_exc.NotFoundException:
                # Pod got deleted in the meanwhile, should be safe to ignore.
                pass

        # FIXME(dulek): We should not need this one day.
        policy = self._get_networkpolicy(knp['metadata']['annotations']
                                         ['networkPolicyLink'])
        if (pods_to_update and CONF.octavia_defaults.enforce_sg_rules and
                not self._is_egress_only_policy(policy)):
            # NOTE(ltomasbo): only need to change services if the pods that
            # they point to are updated
            services = driver_utils.get_services(knp['metadata']['namespace'])
            for service in services.get('items', []):
                # TODO(ltomasbo): Skip other services that are not affected
                #                 by the policy
                # NOTE(maysams): Network Policy is not enforced on Services
                # without selectors for Amphora Octavia provider.
                # NOTE(dulek): Skip services being deleted.
                if (not service['spec'].get('selector') or
                        service['metadata'].get('deletionTimestamp') or not
                        self._is_service_affected(service, pods_to_update)):
                    continue
                sgs = self._drv_svc_sg.get_security_groups(service, project_id)
                try:
                    self._drv_lbaas.update_lbaas_sg(service, sgs)
                except exceptions.ResourceNotReady:
                    # We can ignore LB that's being created - its SGs will get
                    # handled when members will be getting created.
                    pass

        self._patch_kuryrnetworkpolicy_crd(knp, 'status',
                                           {'podSelector': current_sel})

    def _is_service_affected(self, service, affected_pods):
        svc_namespace = service['metadata']['namespace']
        svc_selector = service['spec'].get('selector')
        svc_pods = driver_utils.get_pods({'selector': svc_selector},
                                         svc_namespace).get('items')
        return any(pod in svc_pods for pod in affected_pods)

    def _is_egress_only_policy(self, policy):
        policy_types = policy['spec'].get('policyTypes', [])
        return (policy_types == ['Egress'] or
                (policy['spec'].get('egress') and
                 not policy['spec'].get('ingress')))

    def _get_policy_net_id(self, knp):
        policy_ns = knp['metadata']['namespace']

        kubernetes = clients.get_kubernetes_client()
        try:
            path = (f'{constants.K8S_API_CRD_NAMESPACES}/{policy_ns}/'
                    f'kuryrnetworks/{policy_ns}')
            net_crd = kubernetes.get(path)
        except exceptions.K8sClientException:
            LOG.exception("Kubernetes Client Exception.")
            raise
        return net_crd['status']['netId']

    def on_finalize(self, knp, *args, **kwargs):
        LOG.debug("Finalizing KuryrNetworkPolicy %s", knp)
        project_id = self._drv_project.get_project(knp)
        pods_to_update = self._drv_policy.affected_pods(knp)
        crd_sg = knp['status'].get('securityGroupId')
        try:
            policy = self._get_networkpolicy(knp['metadata']['annotations']
                                             ['networkPolicyLink'])
        except exceptions.K8sResourceNotFound:
            # NP is already gone, let's just try to clean up.
            policy = None

        if crd_sg:
            for pod in pods_to_update:
                if (driver_utils.is_host_network(pod)
                        or not driver_utils.is_pod_scheduled(pod)):
                    continue
                pod_sgs = self._drv_pod_sg.get_security_groups(pod, project_id)
                if crd_sg in pod_sgs:
                    pod_sgs.remove(crd_sg)
                if not pod_sgs:
                    pod_sgs = CONF.neutron_defaults.pod_security_groups
                    if not pod_sgs:
                        raise cfg.RequiredOptError(
                            'pod_security_groups',
                            cfg.OptGroup('neutron_defaults'))
                try:
                    self._drv_vif_pool.update_vif_sgs(pod, pod_sgs)
                except os_exc.NotFoundException:
                    # Pod got deleted in the meanwhile, safe to ignore.
                    pass

            # ensure ports at the pool don't have the NP sg associated
            try:
                net_id = self._get_policy_net_id(knp)
                self._drv_vif_pool.remove_sg_from_pools(crd_sg, net_id)
            except exceptions.K8sResourceNotFound:
                # Probably the network got removed already, we can ignore it.
                pass

            self._drv_policy.delete_np_sg(crd_sg)

            if (CONF.octavia_defaults.enforce_sg_rules and policy and
                    not self._is_egress_only_policy(policy)):
                services = driver_utils.get_services(
                    knp['metadata']['namespace'])
                for svc in services.get('items'):
                    if (not svc['spec'].get('selector') or not
                            self._is_service_affected(svc, pods_to_update)):
                        continue

                    sgs = self._drv_svc_sg.get_security_groups(svc, project_id)

                    if crd_sg in sgs:
                        # Remove our crd_sg out of service groups since we
                        # don't have it anymore
                        sgs.remove(crd_sg)

                    try:
                        self._drv_lbaas.update_lbaas_sg(svc, sgs)
                    except exceptions.ResourceNotReady:
                        # We can ignore LB that's being created - its SGs will
                        # get handled when members will be getting created.
                        pass

        LOG.debug("Removing finalizers from KuryrNetworkPolicy and "
                  "NetworkPolicy.")
        if policy:
            self.k8s.remove_finalizer(policy,
                                      constants.NETWORKPOLICY_FINALIZER)
        self.k8s.remove_finalizer(knp, constants.NETWORKPOLICY_FINALIZER)
