...

Text file src/github.com/emissary-ingress/emissary/v3/python/ambassador_cli/ambassador.py

Documentation: github.com/emissary-ingress/emissary/v3/python/ambassador_cli

     1# Copyright 2018-2020 Datawire. All rights reserved.
     2#
     3# Licensed under the Apache License, Version 2.0 (the "License");
     4# you may not use this file except in compliance with the License.
     5# You may obtain a copy of the License at
     6#
     7#     http://www.apache.org/licenses/LICENSE-2.0
     8#
     9# Unless required by applicable law or agreed to in writing, software
    10# distributed under the License is distributed on an "AS IS" BASIS,
    11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12# See the License for the specific language governing permissions and
    13# limitations under the License
    14
    15########
    16# This is the ambassador CLI. Despite the impression given by its name, it is actually
    17# primarily a debugging tool at this point: the most useful thing to do with it is to
    18# run "ambassador dump --watt path-to-watt-snapshot-file" and have it spit out the IR,
    19# etc.
    20########
    21
    22import cProfile
    23import json
    24import logging
    25import os
    26import sys
    27import traceback
    28from typing import TYPE_CHECKING, ClassVar, Optional, Set
    29from typing import cast as typecast
    30
    31import click
    32
    33from ambassador import IR, Config, Diagnostics, Scout, Version
    34from ambassador.envoy import EnvoyConfig, V3Config
    35from ambassador.fetch import ResourceFetcher
    36from ambassador.utils import (
    37    NullSecretHandler,
    38    RichStatus,
    39    SecretHandler,
    40    SecretInfo,
    41    Timer,
    42    dump_json,
    43    parse_json,
    44)
    45
    46if TYPE_CHECKING:
    47    from ambassador.ir import IRResource  # pragma: no cover
    48
    49__version__ = Version
    50
    51logging.basicConfig(
    52    level=logging.INFO,
    53    format="%%(asctime)s ambassador-cli %s %%(levelname)s: %%(message)s" % __version__,
    54    datefmt="%Y-%m-%d %H:%M:%S",
    55)
    56
    57logger = logging.getLogger("ambassador")
    58
    59
    60def handle_exception(what, e, **kwargs):
    61    tb = "\n".join(traceback.format_exception(*sys.exc_info()))
    62
    63    scout = Scout()
    64    result = scout.report(action=what, mode="cli", exception=str(e), traceback=tb, **kwargs)
    65
    66    logger.debug("Scout %s, result: %s" % ("enabled" if scout._scout else "disabled", result))
    67
    68    logger.error("%s: %s\n%s" % (what, e, tb))
    69
    70    show_notices(result)
    71
    72
    73def show_notices(result: dict, printer=logger.log):
    74    notices = result.get("notices", [])
    75
    76    for notice in notices:
    77        lvl = logging.getLevelName(notice.get("level", "ERROR"))
    78
    79        printer(lvl, notice.get("message", "?????"))
    80
    81
    82def stdout_printer(lvl, msg):
    83    print("%s: %s" % (logging.getLevelName(lvl), msg))
    84
    85
    86def version():
    87    """
    88    Show Ambassador's version
    89    """
    90
    91    print("Ambassador %s" % __version__)
    92
    93    scout = Scout()
    94
    95    print("Ambassador Scout version %s" % scout.version)
    96    print("Ambassador Scout semver  %s" % scout.get_semver(scout.version))
    97
    98    result = scout.report(action="version", mode="cli")
    99    show_notices(result, printer=stdout_printer)
   100
   101
   102def showid():
   103    """
   104    Show Ambassador's installation ID
   105    """
   106
   107    scout = Scout()
   108
   109    print("Ambassador Scout installation ID %s" % scout.install_id)
   110
   111    result = scout.report(action="showid", mode="cli")
   112    show_notices(result, printer=stdout_printer)
   113
   114
   115def file_checker(path: str) -> bool:
   116    logger.debug("CLI file checker: pretending %s exists" % path)
   117    return True
   118
   119
   120class CLISecretHandler(SecretHandler):
   121    # HOOK: if you're using dump and you need it to pretend that certain missing secrets
   122    # are present, add them to LoadableSecrets. At Some Point(tm) there will be a switch
   123    # to add these from the command line, but Flynn didn't actually need that for the
   124    # debugging he was doing...
   125
   126    LoadableSecrets: ClassVar[Set[str]] = set(
   127        # "ssl-certificate.mynamespace"
   128    )
   129
   130    def load_secret(
   131        self, resource: "IRResource", secret_name: str, namespace: str
   132    ) -> Optional[SecretInfo]:
   133        # Only allow a secret to be _loaded_ if it's marked Loadable.
   134
   135        key = f"{secret_name}.{namespace}"
   136
   137        if key in CLISecretHandler.LoadableSecrets:
   138            self.logger.info(f"CLISecretHandler: loading {key}")
   139            return SecretInfo(
   140                secret_name,
   141                namespace,
   142                "mocked-loadable-secret",
   143                "-mocked-cert-",
   144                "-mocked-key-",
   145                decode_b64=False,
   146            )
   147
   148        self.logger.debug(f"CLISecretHandler: cannot load {key}")
   149        return None
   150
   151
   152@click.command()
   153@click.argument("config_dir_path", type=click.Path())
   154@click.option("--secret-dir-path", type=click.Path(), help="Directory into which to save secrets")
   155@click.option("--watt", is_flag=True, help="If set, input must be a WATT snapshot")
   156@click.option("--debug", is_flag=True, help="If set, generate debugging output")
   157@click.option("--debug_scout", is_flag=True, help="If set, generate debugging output")
   158@click.option(
   159    "--k8s", is_flag=True, help="If set, assume configuration files are annotated K8s manifests"
   160)
   161@click.option(
   162    "--recurse", is_flag=True, help="If set, recurse into directories below config_dir_path"
   163)
   164@click.option("--stats", is_flag=True, help="If set, dump statistics to stderr")
   165@click.option("--nopretty", is_flag=True, help="If set, do not pretty print the dumped JSON")
   166@click.option("--aconf", is_flag=True, help="If set, dump the Ambassador config")
   167@click.option("--ir", is_flag=True, help="If set, dump the IR")
   168@click.option("--xds", is_flag=True, help="If set, dump the Envoy config")
   169@click.option("--diag", is_flag=True, help="If set, dump the Diagnostics overview")
   170@click.option("--everything", is_flag=True, help="If set, dump everything")
   171@click.option("--features", is_flag=True, help="If set, dump the feature set")
   172@click.option("--profile", is_flag=True, help="If set, profile with the cProfile module")
   173def dump(
   174    config_dir_path: str,
   175    *,
   176    secret_dir_path=None,
   177    watt=False,
   178    debug=False,
   179    debug_scout=False,
   180    k8s=False,
   181    recurse=False,
   182    stats=False,
   183    nopretty=False,
   184    everything=False,
   185    aconf=False,
   186    ir=False,
   187    xds=False,
   188    diag=False,
   189    features=False,
   190    profile=False,
   191):
   192    """
   193    Dump various forms of an Ambassador configuration for debugging
   194
   195    Use --aconf, --ir, and --envoy to control what gets dumped. If none are requested, the IR
   196    will be dumped.
   197
   198    :param config_dir_path: Configuration directory to scan for Ambassador YAML files
   199    """
   200
   201    if not secret_dir_path:
   202        secret_dir_path = "/tmp/cli-secrets"
   203
   204        if not os.path.isdir(secret_dir_path):
   205            secret_dir_path = os.path.dirname(secret_dir_path)
   206
   207    if debug:
   208        logger.setLevel(logging.DEBUG)
   209
   210    if debug_scout:
   211        logging.getLogger("ambassador.scout").setLevel(logging.DEBUG)
   212
   213    if everything:
   214        aconf = True
   215        ir = True
   216        xds = True
   217        diag = True
   218        features = True
   219    elif not (aconf or ir or xds or diag or features):
   220        aconf = True
   221        ir = True
   222        xds = True
   223        diag = False
   224        features = False
   225
   226    dump_aconf = aconf
   227    dump_ir = ir
   228    dump_xds = xds
   229    dump_diag = diag
   230    dump_features = features
   231
   232    od = {}
   233    diagconfig: Optional[EnvoyConfig] = None
   234
   235    _profile: Optional[cProfile.Profile] = None
   236    _rc = 0
   237
   238    if profile:
   239        _profile = cProfile.Profile()
   240        _profile.enable()
   241
   242    try:
   243        total_timer = Timer("total")
   244        total_timer.start()
   245
   246        fetch_timer = Timer("fetch resources")
   247        with fetch_timer:
   248            aconf = Config()
   249
   250            fetcher = ResourceFetcher(logger, aconf)
   251
   252            if watt:
   253                fetcher.parse_watt(open(config_dir_path, "r").read())
   254            else:
   255                fetcher.load_from_filesystem(config_dir_path, k8s=k8s, recurse=True)
   256
   257        load_timer = Timer("load fetched resources")
   258        with load_timer:
   259            aconf.load_all(fetcher.sorted())
   260
   261        # aconf.post_error("Error from string, boo yah")
   262        # aconf.post_error(RichStatus.fromError("Error from RichStatus"))
   263
   264        irgen_timer = Timer("ir generation")
   265        with irgen_timer:
   266            secret_handler = NullSecretHandler(logger, config_dir_path, secret_dir_path, "0")
   267
   268            ir = IR(aconf, file_checker=file_checker, secret_handler=secret_handler)
   269
   270        aconf_timer = Timer("aconf")
   271        with aconf_timer:
   272            if dump_aconf:
   273                od["aconf"] = aconf.as_dict()
   274
   275        ir_timer = Timer("ir")
   276        with ir_timer:
   277            if dump_ir:
   278                od["ir"] = ir.as_dict()
   279
   280        xds_timer = Timer("xds")
   281        with xds_timer:
   282            if dump_xds:
   283                config = V3Config(ir)
   284                diagconfig = config
   285                od["xds"] = config.as_dict()
   286        diag_timer = Timer("diag")
   287        with diag_timer:
   288            if dump_diag:
   289                if not diagconfig:
   290                    diagconfig = V3Config(ir)
   291                econf = typecast(EnvoyConfig, diagconfig)
   292                diag = Diagnostics(ir, econf)
   293                od["diag"] = diag.as_dict()
   294                od["elements"] = econf.elements
   295
   296        features_timer = Timer("features")
   297        with features_timer:
   298            if dump_features:
   299                od["features"] = ir.features()
   300
   301        # scout = Scout()
   302        # scout_args = {}
   303        #
   304        # if ir and not os.environ.get("AMBASSADOR_DISABLE_FEATURES", None):
   305        #     scout_args["features"] = ir.features()
   306        #
   307        # result = scout.report(action="dump", mode="cli", **scout_args)
   308        # show_notices(result)
   309
   310        dump_timer = Timer("dump JSON")
   311
   312        with dump_timer:
   313            js = dump_json(od, pretty=not nopretty)
   314            jslen = len(js)
   315
   316        write_timer = Timer("write JSON")
   317        with write_timer:
   318            sys.stdout.write(js)
   319            sys.stdout.write("\n")
   320
   321        total_timer.stop()
   322
   323        route_count = 0
   324        vhost_count = 0
   325        filter_chain_count = 0
   326        filter_count = 0
   327        if "xds" in od:
   328            for listener in od["xds"]["static_resources"]["listeners"]:
   329                for fc in listener["filter_chains"]:
   330                    filter_chain_count += 1
   331                    for f in fc["filters"]:
   332                        filter_count += 1
   333                        for vh in f["typed_config"]["route_config"]["virtual_hosts"]:
   334                            vhost_count += 1
   335                            route_count += len(vh["routes"])
   336
   337        if stats:
   338            sys.stderr.write("STATS:\n")
   339            sys.stderr.write("  config bytes:  %d\n" % jslen)
   340            sys.stderr.write("  vhosts:        %d\n" % vhost_count)
   341            sys.stderr.write("  filter chains: %d\n" % filter_chain_count)
   342            sys.stderr.write("  filters:       %d\n" % filter_count)
   343            sys.stderr.write("  routes:        %d\n" % route_count)
   344            sys.stderr.write(
   345                "  routes/vhosts: %.3f\n" % float(float(route_count) / float(vhost_count))
   346            )
   347            sys.stderr.write("TIMERS:\n")
   348            sys.stderr.write("  fetch resources:  %.3fs\n" % fetch_timer.average)
   349            sys.stderr.write("  load resources:   %.3fs\n" % load_timer.average)
   350            sys.stderr.write("  ir generation:    %.3fs\n" % irgen_timer.average)
   351            sys.stderr.write("  aconf:            %.3fs\n" % aconf_timer.average)
   352            sys.stderr.write("  envoy:            %.3fs\n" % xds_timer.average)
   353            sys.stderr.write("  diag:             %.3fs\n" % diag_timer.average)
   354            sys.stderr.write("  features:         %.3fs\n" % features_timer.average)
   355            sys.stderr.write("  dump json:        %.3fs\n" % dump_timer.average)
   356            sys.stderr.write("  write json:       %.3fs\n" % write_timer.average)
   357            sys.stderr.write("  ----------------------\n")
   358            sys.stderr.write("  total: %.3fs\n" % total_timer.average)
   359    except Exception as e:
   360        handle_exception("EXCEPTION from dump", e, config_dir_path=config_dir_path)
   361        _rc = 1
   362
   363    if _profile:
   364        _profile.disable()
   365        _profile.dump_stats("ambassador.profile")
   366
   367    sys.exit(_rc)
   368
   369
   370@click.command()
   371@click.argument("config_dir_path", type=click.Path())
   372def validate(config_dir_path: str):
   373    """
   374    Validate an Ambassador configuration. This is an extension of "config" that
   375    redirects output to devnull and always exits on error.
   376
   377    :param config_dir_path: Configuration directory to scan for Ambassador YAML files
   378    """
   379    config(config_dir_path, os.devnull, exit_on_error=True)
   380
   381
   382@click.command()
   383@click.argument("config_dir_path", type=click.Path())
   384@click.argument("output_json_path", type=click.Path())
   385@click.option("--debug", is_flag=True, help="If set, generate debugging output")
   386@click.option(
   387    "--debug-scout", is_flag=True, help="If set, generate debugging output when talking to Scout"
   388)
   389@click.option(
   390    "--check", is_flag=True, help="If set, generate configuration only if it doesn't already exist"
   391)
   392@click.option(
   393    "--k8s", is_flag=True, help="If set, assume configuration files are annotated K8s manifests"
   394)
   395@click.option(
   396    "--exit-on-error",
   397    is_flag=True,
   398    help="If set, will exit with status 1 on any configuration error",
   399)
   400@click.option(
   401    "--ir", type=click.Path(), help="Pathname to which to dump the IR (not dumped if not present)"
   402)
   403@click.option(
   404    "--aconf",
   405    type=click.Path(),
   406    help="Pathname to which to dump the aconf (not dumped if not present)",
   407)
   408def config(
   409    config_dir_path: str,
   410    output_json_path: str,
   411    *,
   412    debug=False,
   413    debug_scout=False,
   414    check=False,
   415    k8s=False,
   416    ir=None,
   417    aconf=None,
   418    exit_on_error=False,
   419):
   420    """
   421    Generate an Envoy configuration
   422
   423    :param config_dir_path: Configuration directory to scan for Ambassador YAML files
   424
   425    :param output_json_path: Path to output envoy.json
   426    """
   427
   428    if debug:
   429        logger.setLevel(logging.DEBUG)
   430
   431    if debug_scout:
   432        logging.getLogger("ambassador.scout").setLevel(logging.DEBUG)
   433
   434    try:
   435        logger.debug("CHECK MODE  %s" % check)
   436        logger.debug("CONFIG DIR  %s" % config_dir_path)
   437        logger.debug("OUTPUT PATH %s" % output_json_path)
   438
   439        dump_aconf: Optional[str] = aconf
   440        dump_ir: Optional[str] = ir
   441
   442        # Bypass the existence check...
   443        output_exists = False
   444
   445        if check:
   446            # ...oh no wait, they explicitly asked for the existence check!
   447            # Assume that the file exists (ie, we'll do nothing) unless we
   448            # determine otherwise.
   449            output_exists = True
   450
   451            try:
   452                parse_json(open(output_json_path, "r").read())
   453            except FileNotFoundError:
   454                logger.debug("output file does not exist")
   455                output_exists = False
   456            except OSError:
   457                logger.warning("output file is not sane?")
   458                output_exists = False
   459            except json.decoder.JSONDecodeError:
   460                logger.warning("output file is not valid JSON")
   461                output_exists = False
   462
   463            logger.info("Output file %s" % ("exists" if output_exists else "does not exist"))
   464
   465        rc = RichStatus.fromError("impossible error")
   466
   467        if not output_exists:
   468            # Either we didn't need to check, or the check didn't turn up
   469            # a valid config. Regenerate.
   470            logger.info("Generating new Envoy configuration...")
   471
   472            aconf = Config()
   473            fetcher = ResourceFetcher(logger, aconf)
   474            fetcher.load_from_filesystem(config_dir_path, k8s=k8s)
   475            aconf.load_all(fetcher.sorted())
   476
   477            if dump_aconf:
   478                with open(dump_aconf, "w") as output:
   479                    output.write(aconf.as_json())
   480                    output.write("\n")
   481
   482            # If exit_on_error is set, log _errors and exit with status 1
   483            if exit_on_error and aconf.errors:
   484                raise Exception("errors in: {0}".format(", ".join(aconf.errors.keys())))
   485
   486            secret_handler = NullSecretHandler(logger, config_dir_path, config_dir_path, "0")
   487
   488            ir = IR(aconf, file_checker=file_checker, secret_handler=secret_handler)
   489
   490            if dump_ir:
   491                with open(dump_ir, "w") as output:
   492                    output.write(ir.as_json())
   493                    output.write("\n")
   494
   495            logger.info("Writing envoy configuration")
   496            config = V3Config(ir)
   497            rc = RichStatus.OK(msg="huh_xds")
   498
   499            if rc:
   500                with open(output_json_path, "w") as output:
   501                    output.write(config.as_json())
   502                    output.write("\n")
   503            else:
   504                logger.error("Could not generate new Envoy configuration: %s" % rc.error)
   505
   506        scout = Scout()
   507        result = scout.report(action="config", mode="cli")
   508        show_notices(result)
   509    except Exception as e:
   510        handle_exception(
   511            "EXCEPTION from config",
   512            e,
   513            config_dir_path=config_dir_path,
   514            output_json_path=output_json_path,
   515        )
   516
   517        # This is fatal.
   518        sys.exit(1)
   519
   520
   521def version_callback(ctx: click.core.Context, param: click.Parameter, value: bool) -> None:
   522    if not value:
   523        return
   524    version()
   525    ctx.exit()
   526
   527
   528def showid_callback(ctx: click.core.Context, param: click.Parameter, value: bool) -> None:
   529    if not value:
   530        return
   531    showid()
   532    ctx.exit()
   533
   534
   535@click.group(
   536    no_args_is_help=False,
   537    commands=[config, dump, validate],
   538)
   539@click.option(
   540    "--version",
   541    is_flag=True,
   542    expose_value=False,
   543    callback=version_callback,
   544    help="Show the Emissary version number and exit.",
   545)
   546@click.option(
   547    "--showid",
   548    is_flag=True,
   549    expose_value=False,
   550    callback=showid_callback,
   551    help="Show the cluster ID and exit.",
   552)
   553def main():
   554    """Generate an Envoy config, or manage an Ambassador deployment. Use
   555
   556        ambassador.py command --help
   557
   558    for more help, or
   559
   560        ambassador.py --version
   561
   562    to see Ambassador's version.
   563    """
   564
   565
   566if __name__ == "__main__":
   567    main()

View as plain text