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