Repository: emissary-ingress/emissary

Issue:
Ambassador should warn about collisions in mappings.
If you accidentally give mappings the same name, or the same prefix, strange things happen. Ambassador should detect this and refuse to start.



Discussion:

Resolving patch:
diff --git a/ambassador/AmbassadorConfig.py b/ambassador/AmbassadorConfig.py
--- a/ambassador/AmbassadorConfig.py
+++ b/ambassador/AmbassadorConfig.py
@@ -23,7 +23,42 @@ def __init__(self, config_dir_path,
         self.envoy_config = {}
         self.envoy_clusters = {}
 
-        for dirpath, dirnames, filenames in os.walk(self.config_dir_path):
+        self.default_liveness_probe = {
+            "enabled": True,
+            "prefix": "/ambassador/v0/check_alive",
+            "rewrite": "/server_info"
+            # "service" gets added later
+        }
+
+        self.liveness_probe = { "enabled": True }
+        # self.liveness_probe.update(self.default_liveness_probe)
+
+        self.default_readiness_probe = {
+            "enabled": True,
+            "prefix": "/ambassador/v0/check_ready",
+            "rewrite": "/server_info"
+            # "service" gets added later
+        }
+
+        self.readiness_probe = { "enabled": True }
+        # self.readiness_probe.update(self.default_readiness_probe)
+
+        self.tls_config = None
+
+        self.logger = logging.getLogger("ambassador.config")
+
+        for dirpath, dirnames, filenames in os.walk(self.config_dir_path, topdown=True):
+            # Modify dirnames in-place (dirs[:]) to remove any weird directories
+            # whose names start with '.' -- why? because my GKE cluster mounts my
+            # ConfigMap with a self-referential directory named 
+            # /etc/ambassador-config/..9989_25_09_15_43_06.922818753, and if we don't
+            # ignore that, we end up trying to read the same config files twice, which
+            # triggers the collision checks. Sigh.
+
+            dirnames[:] = [ d for d in dirnames if not d.startswith('.') ]
+
+            # self.logger.debug("WALK %s: dirs %s, files %s" % (dirpath, dirnames, filenames))
+
             for filename in [ x for x in filenames if x.endswith(".yaml") ]:
                 self.filename = filename
 
@@ -32,7 +67,7 @@ def __init__(self, config_dir_path,
                 try:
                     objects = yaml.safe_load_all(open(filepath, "r"))
                 except Exception as e:
-                    logging.error("%s: could not parse YAML: %s" % (filepath, e))
+                    self.logger.error("%s: could not parse YAML: %s" % (filepath, e))
                     continue
 
                 self.ocount = 0
@@ -46,27 +81,32 @@ def __init__(self, config_dir_path,
     def process_object(self, obj):
         # OK. What is this thing?
         obj_kind, obj_version = self.validate_object(obj)
+        obj_name = obj['name']
 
         handler_name = "handle_%s" % obj_kind.lower()
         handler = getattr(self, handler_name, None)
 
         if not handler:
             handler = self.save_object
-            logging.warning("%s[%d]: no handler for %s, just saving" % (self.filename, self.ocount, obj_kind))
+            self.logger.warning("%s[%d]: no handler for %s, just saving" %
+                                (self.filename, self.ocount, obj_kind))
         else:
-            logging.debug("%s[%d]: handling %s..." % (self.filename, self.ocount, obj_kind))
+            self.logger.debug("%s[%d]: handling %s..." %
+                              (self.filename, self.ocount, obj_kind))
 
-        handler(obj, obj_kind, obj_version)
+        handler(obj, obj_name, obj_kind, obj_version)
 
     def validate_object(self, obj):
         # Each object must be a dict, and must include "apiVersion"
         # and "type" at toplevel.
 
         if not isinstance(obj, collections.Mapping):
-            raise Exception("%s[%d]: not a dictionary" % (self.filename, self.ocount))
+            raise Exception("%s[%d]: not a dictionary" %
+                            (self.filename, self.ocount))
 
         if not (("apiVersion" in obj) and ("kind" in obj)):
-            raise Exception("%s[%d]: must have apiVersion and kind" % (self.filename, self.ocount))
+            raise Exception("%s[%d]: must have apiVersion and kind" %
+                            (self.filename, self.ocount))
 
         obj_version = obj['apiVersion']
         obj_kind = obj['kind']
@@ -74,21 +114,24 @@ def validate_object(self, obj):
         if obj_version.startswith("ambassador/"):
             obj_version = obj_version.split('/')[1]
         else:
-            raise Exception("%s[%d]: apiVersion %s unsupported" % (self.filename, self.ocount, obj_version))
+            raise Exception("%s[%d]: apiVersion %s unsupported" %
+                            (self.filename, self.ocount, obj_version))
 
         schema_key = "%s-%s" % (obj_version, obj_kind)
 
         schema = self.schemas.get(schema_key, None)
 
         if not schema:
-            schema_path = os.path.join(self.schema_dir_path, obj_version, "%s.schema" % obj_kind)
+            schema_path = os.path.join(self.schema_dir_path, obj_version,
+                                       "%s.schema" % obj_kind)
 
             try:
                 schema = json.load(open(schema_path, "r"))
             except OSError:
-                logging.debug("no schema at %s, skipping" % schema_path)
+                self.logger.debug("no schema at %s, skipping" % schema_path)
             except json.decoder.JSONDecodeError as e:
-                logging.warning("corrupt schema at %s, skipping (%s)" % (schema_path, e))
+                self.logger.warning("corrupt schema at %s, skipping (%s)" %
+                                    (schema_path, e))
 
         if schema:
             self.schemas[schema_key] = schema
@@ -100,54 +143,50 @@ def validate_object(self, obj):
 
         return (obj_kind, obj_version)
 
-    def save_object(self, obj, obj_kind, obj_version):
-        logging.debug("%s[%d]: saving %s %s" %
-                      (self.filename, self.ocount, obj_kind,  obj['name']))
-        objects = self.config.setdefault(obj_kind, {})
-        objects[obj['name']] = obj
+    def safe_store(self, storage_name, obj_name, obj_kind, value, allow_log=True):
+        storage = self.config.setdefault(storage_name, {})
 
-    def handle_module(self, obj, obj_kind, obj_version):
-        logging.debug("%s[%d]: saving module %s" % (self.filename, self.ocount, obj['name']))
-        modules = self.config.setdefault("modules", {})
-        modules[obj['name']] = obj['config']
+        if obj_name in storage:
+            # Oooops.
+            raise Exception("%s[%d] defines %s %s, which is already present" % 
+                            (self.filename, self.ocount, obj_kind, obj_name))
 
-    def handle_mapping(self, obj, obj_kind, obj_version):
-        logging.debug("%s[%d]: saving mapping %s" % (self.filename, self.ocount, obj['name']))
-        mappings = self.config.setdefault("mappings", {})
-        mappings[obj['name']] = obj
+        if allow_log:
+            self.logger.debug("%s[%d]: saving %s %s" %
+                          (self.filename, self.ocount, obj_kind, obj_name))
 
-    def generate_envoy_config(self):
-        # First things first. Assume we'll listen on port 80.
-        service_port = 80
+        storage[obj_name] = value
 
-        # OK. Is TLS configured?
-        aconf = self.config.get('Ambassador', {})
+    def save_object(self, obj, obj_name, obj_kind, obj_version):
+        self.safe_store(obj_kind, obj_name, obj_kind, obj)
 
-        if 'tls' in aconf:
-            # Yes. Switch to port 443 by default...
-            service_port = 443
+    def handle_module(self, obj, obj_name, obj_kind, obj_version):
+        self.safe_store("modules", obj_name, obj_kind, obj['config'])
 
-            # ...and copy the TLS config to the envoy_config dict.
-            self.envoy_config['tls'] = aconf['tls']
+    def handle_mapping(self, obj, obj_name, obj_kind, obj_version):
+        method = obj.get("method", "GET")
+        mapping_key = "%s:%s" % (method, obj['prefix'])
 
-        # OK. Copy listener data into the envoy_config dict.
-        self.envoy_config['listeners'] = {
-            "service_port": service_port,
-            "admin_port": aconf.get('admin_port', 8001)
-        }
+        self.safe_store("mapping_prefixes", mapping_key, obj_kind, True)
+        self.safe_store("mappings", obj_name, obj_kind, obj)
+
+    def generate_envoy_config(self):
+        # First things first. Assume we'll listen on port 80, with an admin port
+        # on 8001.
+        self.service_port = 80
+        self.admin_port = 8001
 
         # Next up: let's define initial clusters, routes, and filters.
         #
-        # Our initial set of clusters is just the ambassador config interface.
-        # Note that we use a map for clusters, not a list -- the reason is that
-        # multiple mappings can use the same service, and we don't want multiple
-        # clusters.
+        # Our initial set of clusters is empty. Note that we use a map for
+        # clusters, not a list -- the reason is that multiple mappings can use the
+        # same service, and we don't want multiple clusters.
         self.envoy_clusters = {
-            "ambassador_config_cluster": {
-                "name": "ambassador_config_cluster",
-                "type": "static",
-                "urls": [ "tcp://127.0.0.1:8001" ]
-            }
+            # "cluster_ambassador_config": {
+            #     "name": "cluster_ambassador_config",
+            #     "type": "static",
+            #     "urls": [ "tcp://127.0.0.1:8001" ]
+            # }
         }
 
         # ...our initial set of routes is empty...
@@ -166,6 +205,11 @@ def generate_envoy_config(self):
             }
         ]
 
+        # For mappings, start with empty sets for everything.
+        mappings = self.config.get("mappings", {})
+        breakers = self.config.get("CircuitBreaker", {})
+        outliers = self.config.get("OutlierDetection", {})
+
         # OK. Given those initial sets, let's look over our global modules.
         modules = self.config.get('modules', {})
 
@@ -179,13 +223,49 @@ def generate_envoy_config(self):
 
             handler(module_name, modules[module_name])
 
-        # Once that's done, it's time to wrangle mappings. These must be sorted by prefix
-        # length...
-        mappings = self.config.get("mappings", [])
-        breakers = self.config.get("CircuitBreaker", {})
-        outliers = self.config.get("OutlierDetection", {})
+        # Once modules are handled, we can set up our listeners...
+        self.envoy_config['listeners'] = {
+            "service_port": self.service_port,
+            "admin_port": self.admin_port
+        }
+
+        self.default_liveness_probe['service'] = '127.0.0.1:%d' % self.admin_port
+        self.default_readiness_probe['service'] = '127.0.0.1:%d' % self.admin_port
+
+        self.logger.debug("LIVENESS: cur  %s" % json.dumps(self.liveness_probe))
+        self.logger.debug("LIVENESS: dflt %s" % json.dumps(self.default_liveness_probe))
+        self.logger.debug("READINESS: cur  %s" % json.dumps(self.readiness_probe))
+        self.logger.debug("READINESS: dflt %s" % json.dumps(self.default_readiness_probe))
+
+        # ...TLS config, if necessary...
+        if self.tls_config:
+            self.envoy_config['tls'] = self.tls_config
+
+        # ...and probes, if configured.
+        for name, cur, dflt in [ ("liveness", self.liveness_probe, self.default_liveness_probe), 
+                                 ("readiness", self.readiness_probe, self.default_readiness_probe) ]:
+            if cur and cur.get("enabled", False):
+                prefix = cur.get("prefix", dflt['prefix'])
+                rewrite = cur.get("rewrite", dflt['rewrite'])
+                service = cur.get("service", dflt['service'])
+
+                # Push a fake mapping to handle this.
+                name = "internal_%s_probe_mapping" % name
+
+                mappings[name] = {
+                    'name': name,
+                    'prefix': prefix,
+                    'rewrite': rewrite,
+                    'service': service
+                }
+
+                self.logger.debug("PROBE %s: %s -> %s%s" % (name, prefix, service, rewrite))
+
 
-        for mapping_name in reversed(sorted(mappings.keys(), key=lambda x: len(mappings[x]['prefix']))):
+        # OK! We have all the mappings we need. Process them sorted by decreasing
+        # length of prefix.
+        for mapping_name in reversed(sorted(mappings.keys(),
+                                            key=lambda x: len(mappings[x]['prefix']))):
             mapping = mappings[mapping_name]
 
             # OK. We need a cluster for this service. Derive it from the 
@@ -193,9 +273,7 @@ def generate_envoy_config(self):
             # detection settings.
             svc = mapping['service']
 
-            # Use just the DNS name for the cluster -- if a port was included, drop it.
-            svc_name_only = svc.split(':')[0]
-            cluster_name_fields =[ svc_name_only ]
+            cluster_name_fields =[ svc ]
 
             cb_name = mapping.get('circuit_breaker', None)
 
@@ -203,7 +281,7 @@ def generate_envoy_config(self):
                 if cb_name in breakers:
                     cluster_name_fields.append("cb_%s" % cb_name)
                 else:
-                    logging.error("CircuitBreaker %s is not defined (mapping %s)" %
+                    self.logger.error("CircuitBreaker %s is not defined (mapping %s)" %
                                   (cb_name, mapping_name))
 
             od_name = mapping.get('outlier_detection', None)
@@ -212,13 +290,13 @@ def generate_envoy_config(self):
                 if od_name in outliers:
                     cluster_name_fields.append("od_%s" % od_name)
                 else:
-                    logging.error("OutlierDetection %s is not defined (mapping %s)" %
+                    self.logger.error("OutlierDetection %s is not defined (mapping %s)" %
                                   (od_name, mapping_name))
 
-            cluster_name = '%s_cluster' % "_".join(cluster_name_fields)
+            cluster_name = 'cluster_%s' % "_".join(cluster_name_fields)
             cluster_name = re.sub(r'[^0-9A-Za-z_]', '_', cluster_name)
 
-            logging.debug("%s: svc %s -> cluster %s" % (mapping_name, svc, cluster_name))
+            self.logger.debug("%s: svc %s -> cluster %s" % (mapping_name, svc, cluster_name))
 
             if cluster_name not in self.envoy_clusters:
                 url = 'tcp://%s' % svc
@@ -261,12 +339,33 @@ def generate_envoy_config(self):
             self.envoy_clusters[name] for name in sorted(self.envoy_clusters.keys())
         ]
 
+    def module_config_ambassador(self, name, module):
+        # Toplevel Ambassador configuration. First up: is TLS configured?
+
+        if 'tls' in module:
+            # Yes. Switch to port 443 by default...
+            self.service_port = 443
+
+            # ...and save the TLS config.
+            self.tls_config = module['tls']
+
+        # After that, is a service port defined?
+        if 'service_port' in module:
+            # Yes. It overrides the default.
+            self.service_port = module['service_port']
+
+        if 'liveness_probe' in module:
+            self.liveness_probe.update(module['liveness_probe'])
+
+        if 'readiness_probe' in module:
+            self.readiness_probe.update(module['readiness_probe'])
+
     def module_config_authentication(self, name, module):
         filter = {
             "type": "decoder",
             "name": "extauth",
             "config": {
-                "cluster": "ext_auth_cluster",
+                "cluster": "cluster_ext_auth",
                 "timeout_ms": 5000
             }
         }
@@ -286,8 +385,8 @@ def module_config_authentication(self, name, module):
         if 'ext_auth_cluster' not in self.envoy_clusters:
             svc = module.get('auth_service', '127.0.0.1:5000')
 
-            self.envoy_clusters['ext_auth_cluster'] = {
-                "name": "ext_auth_cluster",
+            self.envoy_clusters['cluster_ext_auth'] = {
+                "name": "cluster_ext_auth",
                 "type": "logical_dns",
                 "connect_timeout_ms": 5000,
                 "lb_type": "random",
@@ -298,9 +397,14 @@ def pretty(self, obj, out=sys.stdout):
         json.dump(obj, out, indent=4, separators=(',',':'), sort_keys=True)
         out.write("\n")
 
-    def to_json(self, template=None, template_dir="templates"):
+    def to_json(self, template=None, template_dir=None):
+        template_paths = [ self.config_dir_path, self.template_dir_path ]
+
+        if template_dir:
+            template_paths.insert(0, template_dir)
+
         if not template:
-            env = Environment(loader=FileSystemLoader("templates"))
+            env = Environment(loader=FileSystemLoader(template_paths))
             template = env.get_template("envoy.j2")
 
         return(template.render(**self.envoy_config))
diff --git a/ambassador/ambassador.py b/ambassador/ambassador.py
--- a/ambassador/ambassador.py
+++ b/ambassador/ambassador.py
@@ -3,6 +3,7 @@
 import json
 import logging
 import os
+import semantic_version
 import time
 import traceback
 import uuid
@@ -27,7 +28,35 @@
 logger = logging.getLogger("ambassador")
 logger.setLevel(logging.DEBUG)
 
-scout_version = "+".join(__version__.split('-', 1))
+# Weird stuff. The build version looks like
+#
+# 0.12.0                    for a prod build, or
+# 0.12.1-b2.da5d895.DIRTY   for a dev build (in this case made from a dirty true)
+#
+# Now: 
+# - Scout needs a build number (semver "+something") to flag a non-prod release;
+#   but
+# - DockerHub cannot use a build number at all; but
+# - 0.12.1-b2 comes _before_ 0.12.1+b2 in SemVer land.
+# 
+# FFS.
+#
+# We cope with this by transforming e.g.
+#
+# 0.12.1-b2.da5d895.DIRTY into 0.12.1-b2+da5d895.DIRTY
+#
+# for Scout.
+
+scout_version = __version__
+
+if '-' in scout_version:
+    # Dev build!
+    v, p = scout_version.split('-')
+    p, b = p.split('.', 1)
+
+    scout_version = "%s-%s+%s" % (v, p, b)
+
+logging.debug("Scout version %s" % scout_version)
 
 scout = None
 
@@ -73,6 +102,7 @@ def publish(config_dir_path:Parameter.REQUIRED, output_config_map="ambassador-co
     :param config_dir_path: Configuration directory to scan for Ambassador YAML files
     :param output_config_map: Name of map to update
     """
+    logger.error("Publish is not yet implemented")
 
 def config(config_dir_path:Parameter.REQUIRED, output_json_path:Parameter.REQUIRED, *, check=False):
     """
@@ -121,21 +151,65 @@ def config(config_dir_path:Parameter.REQUIRED, output_json_path:Parameter.REQUIR
             if rc:
                 aconf.pretty(rc.envoy_config, out=open(output_json_path, "w"))   
             else:
-                logging.error("Could not generate new Envoy configuration: %s" % rc.error)
-                logging.error("Raw template output:")
-                logging.error("%s" % rc.raw)
+                logger.error("Could not generate new Envoy configuration: %s" % rc.error)
+                logger.error("Raw template output:")
+                logger.error("%s" % rc.raw)
 
             if scout:
                 result = scout.report(action="config", result=bool(rc), check=check, generated=(not output_exists))
             else:
                 result = {"scout": "inactive"}
 
-        logging.debug("Scout reports %s" % json.dumps(result))
+        logger.debug("Scout reports %s" % json.dumps(result))
+
+        if 'latest_version' in result:
+            latest_semver = get_semver("latest", result['latest_version'])
+
+            # Use scout_version here, not __version__, because the version 
+            # coming back from Scout will use build numbers for dev builds,
+            # but __version__ 
+            current_semver = get_semver("current", scout_version)
+
+            if latest_semver and current_semver:
+                logger.debug("Version check: cur %s, latest %s, out of date %s" % 
+                             (current_semver, latest_semver, latest_semver > current_semver))
+
+                if latest_semver > current_semver:
+                    logger.warning("Upgrade available! to Ambassador version %s" % latest_semver)
+
+        if 'notices' in result:
+            for notice in result['notices']:
+                try:
+                    if isinstance(notice, str):
+                        logger.warning(notice)
+                    else:
+                        lvl = notice['level']
+                        msg = notice['message']
+
+                        if isinstance(lvl, str):
+                            lvl = getattr(logging, lvl, logging.INFO)
+
+                        logger.log(lvl, "%s", msg)
+                except KeyError:
+                    logger.warning(json.dumps(notice))
+                except TypeError:
+                    logger.warning(str(notice))
     except Exception as e:
         # scout.report(action="WTFO?")
         handle_exception("EXCEPTION from config", e, 
                          config_dir_path=config_dir_path, output_json_path=output_json_path)
 
+def get_semver(what, version_string):
+    semver = None
+
+    try:
+        semver = semantic_version.Version(version_string)
+    except ValueError:
+        logger.warning("Could not perform version check: %s version (%s) is not valid" %
+                       (what, version_string))
+
+    return semver
+
 if __name__ == "__main__":
     clize.run([config, publish], alt=[version, showid],
               description="""
diff --git a/scripts/versioner.py b/scripts/versioner.py
--- a/scripts/versioner.py
+++ b/scripts/versioner.py
@@ -2,9 +2,12 @@
 
 import sys
 
+import json
 import logging
+import os
+import re
+import subprocess
 
-import subprocess, re, os
 from semantic_version import Version
 from git import Repo
 
@@ -382,6 +385,7 @@ def tag_version(self, version, commit):
         --since=<since-tag>        override the tag of the last release
         --map=<mappings>           override what kind of change given commits are (see below)
         --only-if-changes          don't build if there are no changes since last tag
+        --scout-json=<output>      write an app.json for Scout
 
     Mappings are commit=kind[,commit=kind[,...]] where commit is a unique SHA prefix
     and kind is FIX, MINOR, or MAJOR.
@@ -426,6 +430,15 @@ def tag_version(self, version, commit):
                                     reduced_zero=False,
                                     commit_map=commit_map)
 
+    if args['--scout-json']:
+        app_json = {
+          "application": "ambassador",
+          "latest_version": str(next_version),
+          "notices": []
+        }
+
+        json.dump(app_json, open(args['--scout-json'], "w"), indent=4, sort_keys=True)
+
     if next_version:
         print(next_version)
     else:


Test patch: