#!/usr/bin/python
#
# Copyright 2015 The Cluster-Insight Authors. All Rights Reserved
#
# 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.

"""Tests for collector/collector.py."""

# global imports
import json
import os
import re
import time
import types
import unittest

# local imports
import collector
import global_state
import utilities


# A regular expression that matches the 'timestamp' attribute and value
# in JSON data.
TIMESTAMP_REGEXP = r'"timestamp": "[-0-9:.TZ]+"'


class TestCollector(unittest.TestCase):
  """Test harness."""

  def setUp(self):
    os.environ['KUBERNETES_SERVICE_HOST'] = 'localhost'
    os.environ['KUBERNETES_SERVICE_PORT'] = '443'
    gs = global_state.GlobalState()
    gs.init_caches_and_synchronization()
    collector.app.context_graph_global_state = gs
    collector.app.testing = True
    self.app = collector.app.test_client()

  def compare_to_golden(self, ret_value, fname):
    """Compares the returned value to the golden (expected) value.

    The golden value is read from the file
    'testdata/<last element of fname>.output.json'.
    All timestamp attributes and their values are removed from the returned
    value and the golden value prior to comparing them.

    Args:
      ret_value: JSON output from the server.
      fname: the middle part of the file name containing the golden
        (expected) output from the server.
    Raises:
      AssertError if the sanitized golden data differs from the sanitized
      return value.
    """
    assert isinstance(ret_value, types.StringTypes)
    assert isinstance(fname, types.StringTypes)

    # Read the golden data (expected value).
    golden_fname = 'testdata/' + fname + '.output.json'
    f = open(golden_fname, 'r')
    golden_data = f.read()
    f.close()

    # Remove all timestamps from golden data and returned value.
    sanitized_golden_data = re.sub(TIMESTAMP_REGEXP, '', golden_data)
    sanitized_ret_value = re.sub(TIMESTAMP_REGEXP, '', ret_value)

    # Strip whitespaces of the sanitized strings, and replace multiple
    # whitespaces by a single space
    sanitized_golden_data = re.sub(r'\s+', ' ', sanitized_golden_data.strip())
    sanitized_ret_value = re.sub(r'\s+', ' ', sanitized_ret_value.strip())

    # Find the index of the first discrepancy between 'sanitized_golden_data'
    # and 'sanitized_ret_value'. If they are equal, the index will point at
    # the position after the last character in both strings.
    # DO NOT replace this code with:
    # self.assertEqual(sanitized_golden_data, sanitized_ret_value)
    # The current code prints the tail of the mismatched data, which helps
    # the human developer identify and comprehend the discrepancies.
    i = 0
    while (i < len(sanitized_golden_data)) and (i < len(sanitized_ret_value)):
      if sanitized_golden_data[i] != sanitized_ret_value[i]:
        break
      i += 1

    # The sanitized golden data must equal the sanitized
    # return value.
    self.assertEqual(sanitized_golden_data[i:], sanitized_ret_value[i:])

  def test_regexp(self):
    """Tests the TIMESTAMP_REGEXP against various timestamp formats."""
    self.assertEqual(
        '{}',
        re.sub(TIMESTAMP_REGEXP, '',
               '{"timestamp": "2015-03-17T02:00:41.918629"}'))
    self.assertEqual(
        '{}',
        re.sub(TIMESTAMP_REGEXP, '', '{"timestamp": "2015-02-23T03:13:29Z"}'))

  def test_home(self):
    ret_value = self.app.get('/')
    self.assertTrue('Returns this help message' in ret_value.data)

  def test_nodes(self):
    ret_value = self.app.get('/cluster/resources/nodes')
    self.compare_to_golden(ret_value.data, 'nodes')

  def test_pods(self):
    ret_value = self.app.get('/cluster/resources/pods')
    self.compare_to_golden(ret_value.data, 'pods')

  def test_services(self):
    ret_value = self.app.get('/cluster/resources/services')
    self.compare_to_golden(ret_value.data, 'services')

  def test_rcontrollers(self):
    ret_value = self.app.get('/cluster/resources/rcontrollers')
    self.compare_to_golden(ret_value.data, 'replicationcontrollers')

  def count_resources(self, output, type_name):
    assert isinstance(output, dict)
    assert isinstance(type_name, types.StringTypes)
    if not isinstance(output.get('resources'), list):
      return 0

    n = 0
    for r in output.get('resources'):
      assert utilities.is_wrapped_object(r)
      if r.get('type') == type_name:
        n += 1

    return n

  def count_relations(self, output, type_name,
                      source_type=None, target_type=None):
    """Count relations of the specified type (e.g., "contains").

    If the source type and/or target type is specified (e.g., "Node", "Pod",
    etc.), count only relations that additionally match that constraint.
    """
    assert isinstance(output, dict)
    assert isinstance(type_name, types.StringTypes)
    if not isinstance(output.get('relations'), list):
      return 0

    n = 0
    for r in output.get('relations'):
      assert isinstance(r, dict)
      if r['type'] != type_name:
        continue
      if source_type and r['source'].split(':')[0] != source_type:
        continue
      if target_type and r['target'].split(':')[0] != target_type:
        continue
      n += 1

    return n

  def verify_resources(self, result, start_time, end_time):
    assert isinstance(result, dict)
    assert utilities.valid_string(start_time)
    assert utilities.valid_string(end_time)
    self.assertEqual(1, self.count_resources(result, 'Cluster'))
    self.assertEqual(5, self.count_resources(result, 'Node'))
    self.assertEqual(6, self.count_resources(result, 'Service'))
    # TODO(eran): the pods count does not include the pods running in the
    # master. Fix the count once we include pods that run in the master node.
    self.assertEqual(14, self.count_resources(result, 'Pod'))
    self.assertEqual(16, self.count_resources(result, 'Container'))
    self.assertEqual(10, self.count_resources(result, 'Image'))
    self.assertEqual(3, self.count_resources(result, 'ReplicationController'))

    # Verify that all resources are valid wrapped objects.
    assert isinstance(result.get('resources'), list)
    for r in result['resources']:
      # all resources must be valid.
      assert utilities.is_wrapped_object(r)
      assert start_time <= r['timestamp'] <= end_time

  def test_resources(self):
    """Test the '/resources' endpoint."""
    start_time = utilities.now()
    ret_value = self.app.get('/cluster/resources')
    end_time = utilities.now()
    result = json.loads(ret_value.data)
    self.verify_resources(result, start_time, end_time)

    self.assertEqual(0, self.count_relations(result, 'contains'))
    self.assertEqual(0, self.count_relations(result, 'createdFrom'))
    self.assertEqual(0, self.count_relations(result, 'loadBalances'))
    self.assertEqual(0, self.count_relations(result, 'monitors'))
    self.assertEqual(0, self.count_relations(result, 'runs'))

    # The overall timestamp must be in the expected range.
    self.assertTrue(utilities.valid_string(result.get('timestamp')))
    self.assertTrue(start_time <= result['timestamp'] <= end_time)

  def test_cluster(self):
    """Test the '/cluster' endpoint."""
    start_time = utilities.now()
    end_time = None
    for _ in range(2):
      # Exercise the collector. Read data from golden files and compute
      # a context graph.
      # The second iteration should read from the cache.
      ret_value = self.app.get('/cluster')
      if end_time is None:
        end_time = utilities.now()
      result = json.loads(ret_value.data)
      # The timestamps of the second iteration should be the same as in the
      # first iteration, because the data of the 2nd iteration should be
      # fetched from the cache, and it did not change.
      # Even if fetching the data caused an explicit reading from the files
      # in the second iteration, the data did not change, so it should keep
      # its original timestamp.
      self.verify_resources(result, start_time, end_time)

      self.assertEqual(5, self.count_relations(
          result, 'contains', 'Cluster', 'Node'))
      self.assertEqual(6, self.count_relations(
          result, 'contains', 'Cluster', 'Service'))
      self.assertEqual(3, self.count_relations(
          result, 'contains', 'Cluster', 'ReplicationController'))
      self.assertEqual(16, self.count_relations(
          result, 'contains', 'Pod', 'Container'))

      self.assertEqual(30, self.count_relations(result, 'contains'))
      self.assertEqual(16, self.count_relations(result, 'createdFrom'))
      self.assertEqual(7, self.count_relations(result, 'loadBalances'))
      self.assertEqual(6, self.count_relations(result, 'monitors'))
      self.assertEqual(14, self.count_relations(result, 'runs'))

      # Verify that all relations contain a timestamp in the range
      # [start_time, end_time].
      self.assertTrue(isinstance(result.get('relations'), list))
      for r in result['relations']:
        self.assertTrue(isinstance(r, dict))
        timestamp = r.get('timestamp')
        self.assertTrue(utilities.valid_string(timestamp))
        self.assertTrue(start_time <= timestamp <= end_time)

      # The overall timestamp must be in the expected range.
      self.assertTrue(utilities.valid_string(result.get('timestamp')))
      self.assertTrue(start_time <= result['timestamp'] <= end_time)

      # Wait a little to ensure that the current time is greater than
      # end_time
      time.sleep(1)
      self.assertTrue(utilities.now() > end_time)

    # Change the timestamp of the nodes in the cache.
    timestamp_before_update = utilities.now()
    gs = collector.app.context_graph_global_state
    nodes, timestamp_seconds = gs.get_nodes_cache().lookup('')
    self.assertTrue(isinstance(nodes, list))
    self.assertTrue(start_time <=
                    utilities.seconds_to_timestamp(timestamp_seconds) <=
                    end_time)
    # Change the first node to force the timestamp in the cache to change.
    # We have to change both the properties of the first node and its
    # timestamp, so the cache will store the new value (including the new
    # timestamp).
    self.assertTrue(len(nodes) >= 1)
    self.assertTrue(utilities.is_wrapped_object(nodes[0], 'Node'))
    nodes[0]['properties']['newAttribute123'] = 'the quick brown fox jumps over'
    nodes[0]['timestamp'] = utilities.now()
    gs.get_nodes_cache().update('', nodes)
    timestamp_after_update = utilities.now()
    _, timestamp_seconds = gs.get_nodes_cache().lookup('')
    self.assertTrue(timestamp_before_update <=
                    utilities.seconds_to_timestamp(timestamp_seconds) <=
                    timestamp_after_update)

    # Build the context graph again.
    ret_value = self.app.get('/cluster')
    result = json.loads(ret_value.data)
    self.verify_resources(result, start_time, timestamp_after_update)

    # Verify that all relations contain a timestamp in the range
    # [start_time, end_time].
    self.assertTrue(isinstance(result.get('relations'), list))
    for r in result['relations']:
      self.assertTrue(isinstance(r, dict))
      timestamp = r.get('timestamp')
      self.assertTrue(utilities.valid_string(timestamp))
      self.assertTrue(start_time <= timestamp <= end_time)

    # The overall timestamp must be in the expected range.
    self.assertTrue(utilities.valid_string(result.get('timestamp')))
    self.assertTrue(timestamp_before_update <= result['timestamp'] <=
                    timestamp_after_update)

  def test_debug(self):
    """Test the '/debug' endpoint."""
    ret_value = self.app.get('/debug')
    self.compare_to_golden(ret_value.data, 'debug')

  def verify_empty_elapsed(self):
    """Verify that '/elapsed' endoint returns an empty list of elapsed times.
    """
    ret_value = self.app.get('/elapsed')
    result = json.loads(ret_value.data)
    self.assertTrue(result.get('success'))
    elapsed = result.get('elapsed')
    self.assertTrue(isinstance(elapsed, dict))
    self.assertEqual(0, elapsed.get('count'))
    self.assertTrue(elapsed.get('min') is None)
    self.assertTrue(elapsed.get('max') is None)
    self.assertTrue(elapsed.get('average') is None)
    self.assertTrue(isinstance(elapsed.get('items'), list))
    self.assertEqual([], elapsed.get('items'))

  def test_elapsed(self):
    """Test the '/elapsed' endpoint with and without calls to Kubernetes.
    """
    self.verify_empty_elapsed()

    # Issue a few requests to Kubernetes.
    self.app.get('/cluster/resources/nodes')
    self.app.get('/cluster/resources/services')
    self.app.get('/cluster/resources/rcontrollers')

    # Now we should have a few elapsed time records.
    ret_value = self.app.get('/elapsed')
    result = json.loads(ret_value.data)
    self.assertTrue(result.get('success'))
    elapsed = result.get('elapsed')
    self.assertTrue(isinstance(elapsed, dict))
    self.assertEqual(3, elapsed.get('count'))
    self.assertTrue(elapsed.get('min') > 0)
    self.assertTrue(elapsed.get('max') > 0)
    self.assertTrue(elapsed.get('min') <= elapsed.get('average') <=
                    elapsed.get('max'))
    self.assertTrue(isinstance(elapsed.get('items'), list))
    self.assertEqual(3, len(elapsed.get('items')))

    # The next call to '/elapsed' should return an empty list
    self.verify_empty_elapsed()

  def test_healthz(self):
    """Test the '/healthz' endpoint."""
    ret_value = self.app.get('/healthz')
    result = json.loads(ret_value.data)
    self.assertTrue(result.get('success'))
    health = result.get('health')
    self.assertTrue(isinstance(health, types.StringTypes))
    self.assertEqual('OK', health)


if __name__ == '__main__':
  unittest.main()