...

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

Documentation: github.com/emissary-ingress/emissary/v3/python/ambassador/ir

     1from typing import TYPE_CHECKING, List, Optional, Union
     2
     3from ..config import Config
     4from ..utils import SavedSecret, dump_json
     5from .irresource import IRResource
     6from .irtlscontext import IRTLSContext
     7from .irutils import disable_strict_selectors, hostglob_matches, selector_matches
     8
     9if TYPE_CHECKING:
    10    from .ir import IR  # pragma: no cover
    11    from .irhttpmappinggroup import IRHTTPMappingGroup
    12
    13
    14class IRHost(IRResource):
    15    AllowedKeys = {
    16        "acmeProvider",
    17        "hostname",
    18        "mappingSelector",
    19        "metadata_labels",
    20        "requestPolicy",
    21        "selector",
    22        "tlsSecret",
    23        "tlsContext",
    24        "tls",
    25    }
    26
    27    hostname: str
    28    sni: str
    29    secure_action: str
    30    insecure_action: str
    31    insecure_addl_port: Optional[int]
    32
    33    def __init__(
    34        self,
    35        ir: "IR",
    36        aconf: Config,
    37        rkey: str,  # REQUIRED
    38        name: str,  # REQUIRED
    39        location: str,  # REQUIRED
    40        namespace: Optional[str] = None,
    41        kind: str = "IRHost",
    42        apiVersion: str = "getambassador.io/v3alpha1",  # Not a typo! See below.
    43        **kwargs,
    44    ) -> None:
    45
    46        new_args = {x: kwargs[x] for x in kwargs.keys() if x in IRHost.AllowedKeys}
    47
    48        self.context: Optional[IRTLSContext] = None
    49
    50        super().__init__(
    51            ir=ir,
    52            aconf=aconf,
    53            rkey=rkey,
    54            location=location,
    55            kind=kind,
    56            name=name,
    57            namespace=namespace,
    58            apiVersion=apiVersion,
    59            **new_args,
    60        )
    61
    62    def setup(self, ir: "IR", aconf: Config) -> bool:
    63        ir.logger.debug(f"Host {self.name} setting up")
    64
    65        if not self.get("hostname", None):
    66            self.hostname = "*"
    67
    68        self.sni = self.hostname.rsplit(":", 1)[0]
    69
    70        tls_ss: Optional[SavedSecret] = None
    71        pkey_ss: Optional[SavedSecret] = None
    72
    73        # Go ahead and cache some things to make life easier later.
    74        request_policy = self.get("requestPolicy", {})
    75
    76        # XXX This will change later!!
    77        self.secure_action = "Route"
    78
    79        insecure_policy = request_policy.get("insecure", {})
    80        self.insecure_action = insecure_policy.get("action", "Redirect")
    81        self.insecure_addl_port: Optional[int] = insecure_policy.get("additionalPort", None)
    82
    83        # If we have no mappingSelector, check for selector.
    84        mapsel = self.get("mappingSelector", None)
    85
    86        if not mapsel:
    87            mapsel = self.get("selector", None)
    88
    89            if mapsel:
    90                self.mappingSelector = mapsel
    91                del self["selector"]
    92
    93        if self.get("tlsSecret", None):
    94            tls_secret = self.tlsSecret
    95            tls_name = tls_secret.get("name", None)
    96            tls_namespace = tls_secret.get("namespace", None)
    97
    98            if tls_name:
    99                # Either take the name of the secret, or add on the namespace if it exists.
   100                # This is important for how the implicit TLSContext handles secret names
   101                tls_full_name = tls_name
   102                if tls_namespace:
   103                    ir.logger.debug(
   104                        f"Host {self.name}: resolving spec.tlsSecret.name.namespace: {tls_name}.{tls_namespace}"
   105                    )
   106                    tls_full_name = tls_full_name + "." + tls_namespace
   107                else:
   108                    ir.logger.debug(f"Host {self.name}: resolving spec.tlsSecret.name: {tls_name}")
   109
   110                tls_ss = self.resolve(ir=ir, secret_name=tls_name, secret_namespace=tls_namespace)
   111
   112                if tls_ss:
   113                    # OK, we have a TLS secret! Fire up a TLS context for it, if one doesn't
   114                    # already exist.
   115
   116                    ctx_name = f"{self.name}-context"
   117
   118                    implicit_tls_exists = ir.has_tls_context(ctx_name)
   119                    self.logger.debug(
   120                        f"Host {self.name}: implicit TLSContext {ctx_name} {'exists' if implicit_tls_exists else 'missing'}"
   121                    )
   122
   123                    host_tls_context_obj = self.get("tlsContext", {})
   124                    host_tls_context_name = host_tls_context_obj.get("name", None)
   125                    self.logger.debug(f"Host {self.name}: spec.tlsContext: {host_tls_context_name}")
   126
   127                    host_tls_config = self.get("tls", None)
   128                    self.logger.debug(f"Host {self.name}: spec.tls: {host_tls_config}")
   129
   130                    # Choose explicit TLS configuration over implicit TLSContext name
   131                    if implicit_tls_exists and (host_tls_context_name or host_tls_config):
   132                        self.logger.info(
   133                            f"Host {self.name}: even though TLSContext {ctx_name} exists in the cluster,"
   134                            f"it will be ignored in favor of 'tls'/'tlsConfig' specified in the Host."
   135                        )
   136
   137                    # Even though this is unlikely because we have a oneOf is proto definitions, but just in case the
   138                    # objects have a different source :shrug:
   139                    if host_tls_context_name and host_tls_config:
   140                        self.post_error(
   141                            f"Host {self.name}: both TLSContext name and TLS config specified, ignoring "
   142                            f"Host..."
   143                        )
   144                        return False
   145
   146                    if host_tls_context_name:
   147                        # They named a TLSContext, so try to use that. self.save_context will check the
   148                        # context to make sure it works for us, and save it if so.
   149                        ir.logger.debug(
   150                            f"Host {self.name}: resolving spec.tlsContext: {host_tls_context_name}"
   151                        )
   152
   153                        if not self.save_context(ir, host_tls_context_name, tls_ss, tls_full_name):
   154                            return False
   155
   156                    elif host_tls_config:
   157                        # They defined a TLSContext inline, so go set that up if we can.
   158                        ir.logger.debug(f"Host {self.name}: examining spec.tls {host_tls_config}")
   159
   160                        camel_snake_map = {
   161                            "alpnProtocols": "alpn_protocols",
   162                            "cipherSuites": "cipher_suites",
   163                            "ecdhCurves": "ecdh_curves",
   164                            "redirectCleartextFrom": "redirect_cleartext_from",
   165                            "certRequired": "cert_required",
   166                            "minTlsVersion": "min_tls_version",
   167                            "maxTlsVersion": "max_tls_version",
   168                            "certChainFile": "cert_chain_file",
   169                            "privateKeyFile": "private_key_file",
   170                            "cacertChainFile": "cacert_chain_file",
   171                            "crlSecret": "crl_secret",
   172                            "crlFile": "crl_file",
   173                            "caSecret": "ca_secret",
   174                            # 'sni': 'sni' (this field is not required in snake-camel but adding for completeness)
   175                        }
   176
   177                        # We don't need any camel case in our generated TLSContext
   178                        for camel, snake in camel_snake_map.items():
   179                            if camel in host_tls_config:
   180                                # We use .pop() to actually replace the camelCase name with snake case
   181                                host_tls_config[snake] = host_tls_config.pop(camel)
   182
   183                        if "min_tls_version" in host_tls_config:
   184                            if (
   185                                host_tls_config["min_tls_version"]
   186                                not in IRTLSContext.AllowedTLSVersions
   187                            ):
   188                                self.post_error(
   189                                    f"Host {self.name}: Invalid min_tls_version set in Host.tls: "
   190                                    f"{host_tls_config['min_tls_version']}"
   191                                )
   192                                return False
   193
   194                        if "max_tls_version" in host_tls_config:
   195                            if (
   196                                host_tls_config["max_tls_version"]
   197                                not in IRTLSContext.AllowedTLSVersions
   198                            ):
   199                                self.post_error(
   200                                    f"Host {self.name}: Invalid max_tls_version set in Host.tls: "
   201                                    f"{host_tls_config['max_tls_version']}"
   202                                )
   203                                return False
   204
   205                        tls_context_init = dict(
   206                            rkey=self.rkey,
   207                            name=ctx_name,
   208                            namespace=self.namespace,
   209                            location=self.location,
   210                            hosts=[self.hostname],
   211                            secret=tls_full_name,
   212                        )
   213
   214                        # Ensure that we handle the secret properly even if tls_secret_namespacing is False in the Module
   215                        if tls_namespace:
   216                            tls_context_init["secret_namespacing"] = True
   217
   218                        tls_config_context = IRTLSContext(
   219                            ir, aconf, **tls_context_init, **host_tls_config
   220                        )
   221
   222                        # This code was here because, while 'selector' was controlling things to be watched
   223                        # for, we figured we should update the labels on this generated TLSContext so that
   224                        # it would actually match the 'selector'. Nothing was actually using that, though, so
   225                        # we're not doing that any more.
   226                        #
   227                        # -----------------------------------------------------------------------------------
   228                        # # XXX This seems kind of pointless -- nothing looks at the context's labels?
   229                        # match_labels = self.get('selector', {}).get('matchLabels')
   230
   231                        # if match_labels:
   232                        #     tls_config_context['metadata_labels'] = match_labels
   233
   234                        if tls_config_context.is_active():
   235                            self.context = tls_config_context
   236                            tls_config_context.referenced_by(self)
   237                            tls_config_context.sourced_by(self)
   238
   239                            ir.save_tls_context(tls_config_context)
   240                        else:
   241                            self.post_error(
   242                                f"Host {self.name}: generated TLSContext {tls_config_context.name} from "
   243                                f"Host.tls is not valid"
   244                            )
   245                            return False
   246
   247                    elif implicit_tls_exists:
   248                        # They didn't say anything explicitly, but it happens that a context with the
   249                        # correct name for this Host already exists. Save that, if it works out for us.
   250                        ir.logger.debug(f"Host {self.name}: TLSContext {ctx_name} already exists")
   251
   252                        if not self.save_context(ir, ctx_name, tls_ss, tls_full_name):
   253                            return False
   254                    else:
   255                        ir.logger.debug(f"Host {self.name}: creating TLSContext {ctx_name}")
   256
   257                        new_ctx = dict(
   258                            rkey=self.rkey,
   259                            name=ctx_name,
   260                            namespace=self.namespace,
   261                            location=self.location,
   262                            hosts=[self.hostname],
   263                            secret=tls_full_name,
   264                        )
   265
   266                        # Ensure that we handle the secret properly even if tls_secret_namespacing is False in the Module
   267                        if tls_namespace:
   268                            new_ctx["secret_namespacing"] = True
   269
   270                        ctx = IRTLSContext(ir, aconf, **new_ctx)
   271
   272                        match_labels = self.get("matchLabels")
   273
   274                        if not match_labels:
   275                            match_labels = self.get("match_labels")
   276
   277                        if match_labels:
   278                            ctx["metadata_labels"] = match_labels
   279
   280                        if ctx.is_active():
   281                            self.context = ctx
   282                            ctx.referenced_by(self)
   283                            ctx.sourced_by(self)
   284
   285                            ir.save_tls_context(ctx)
   286                        else:
   287                            ir.logger.error(
   288                                f"Host {self.name}: new TLSContext {ctx_name} is not valid"
   289                            )
   290                else:
   291                    ir.logger.error(
   292                        f"Host {self.name}: invalid TLS secret {tls_full_name}, marking inactive"
   293                    )
   294                    return False
   295
   296        if self.get("acmeProvider", None):
   297            acme = self.acmeProvider
   298
   299            # The ACME client is disabled if we're running as an intercept agent.
   300            if ir.edge_stack_allowed and not ir.agent_active:
   301                authority = acme.get("authority", None)
   302
   303                if authority and (authority.lower() != "none"):
   304                    # ACME is active, which means that we must have an insecure_addl_port.
   305                    # Make sure we do -- if no port is set at all, just silently default it,
   306                    # but if for some reason they tried to force it disabled, be noisy.
   307
   308                    override_insecure = False
   309
   310                    if self.insecure_addl_port is None:
   311                        # Override silently, since it's not set at all.
   312                        override_insecure = True
   313                    elif self.insecure_addl_port < 0:
   314                        # Override noisily, since they tried to explicitly disable it.
   315                        self.post_error(
   316                            "ACME requires insecure.additionalPort to function; forcing to 8080"
   317                        )
   318                        override_insecure = True
   319
   320                    if override_insecure:
   321                        # Force self.insecure_addl_port...
   322                        self.insecure_addl_port = 8080
   323
   324                        # ...but also update the actual policy dict, too.
   325                        insecure_policy["additionalPort"] = 8080
   326
   327                        if "action" not in insecure_policy:
   328                            # No action when we're overriding the additionalPort already means that we
   329                            # default the action to Reject (the hole-puncher will do the right thing).
   330                            insecure_policy["action"] = "Reject"
   331
   332                        request_policy["insecure"] = insecure_policy
   333                        self["requestPolicy"] = request_policy
   334
   335            pkey_secret = acme.get("privateKeySecret", None)
   336
   337            if pkey_secret:
   338                pkey_name = pkey_secret.get("name", None)
   339
   340                if pkey_name:
   341                    ir.logger.debug(f"Host {self.name}: ACME private key name is {pkey_name}")
   342
   343                    pkey_ss = self.resolve(ir=ir, secret_name=pkey_name, secret_namespace=None)
   344
   345                    if not pkey_ss:
   346                        ir.logger.error(
   347                            f"Host {self.name}: continuing with invalid private key secret {pkey_name}; ACME will not be able to renew this certificate"
   348                        )
   349                        self.post_error(
   350                            f"continuing with invalid ACME private key secret {pkey_name}; ACME will not be able to renew this certificate"
   351                        )
   352
   353        ir.logger.debug(f"Host setup OK: {self}")
   354        return True
   355
   356    # Check a TLSContext name, and save the linked TLSContext if it'll work for us.
   357    def save_context(self, ir: "IR", ctx_name: str, tls_ss: SavedSecret, tls_name: str):
   358        # First obvious thing: does a TLSContext with the right name even exist?
   359        if not ir.has_tls_context(ctx_name):
   360            self.post_error(
   361                "Host %s: Specified TLSContext does not exist: %s" % (self.name, ctx_name)
   362            )
   363            return False
   364
   365        ctx = ir.get_tls_context(ctx_name)
   366        assert ctx  # For mypy -- we checked above to be sure it exists.
   367
   368        # Make sure that the TLSContext is "compatible" i.e. it at least has the same cert related
   369        # configuration as the one in this Host AND hosts are same as well.
   370
   371        if ctx.has_secret():
   372            secret_name = ctx.secret_name()
   373            assert secret_name  # For mypy -- if has_secret() is true, secret_name() will be there.
   374
   375            # This is a little weird. Basically we're going to resolve the secret (which should just
   376            # be a cache lookup here) so that we can use SavedSecret.__str__() as a serializer to
   377            # compare the configurations.
   378            context_ss = self.resolve(ir, secret_name, None)
   379
   380            self.logger.debug(
   381                f"Host {self.name}, ctx {ctx.name}, secret {secret_name}, resolved {context_ss}"
   382            )
   383
   384            if str(context_ss) != str(tls_ss):
   385                self.post_error(
   386                    "Secret info mismatch between Host %s (secret: %s) and TLSContext %s: (secret: %s)"
   387                    % (self.name, tls_name, ctx_name, secret_name)
   388                )
   389                return False
   390        else:
   391            # This will often be a no-op.
   392            ctx.set_secret_name(tls_name)
   393
   394        # TLS config is good, let's make sure the hosts line up too.
   395        context_hosts = ctx.get("hosts")
   396
   397        # Technically a user can set the TLSContext.hosts to include a Host.name thus allowing it to pass
   398        # the validation and saved as the context for this Host. Although, this is not intended
   399        # or documented behavior it, removing it could break users so we need to mark it as deprecated.
   400        # @deprecated - validating TLSContext.hosts against Host.name will be removed, use hostname for matching instead.
   401        host_hosts = [self.hostname, self.name]
   402
   403        if context_hosts:
   404            is_valid_hosts = False
   405
   406            # we exact match here, which requires users being explicit about whether a TLSContext can attach
   407            # to a Host. Note: this does not do hostname glob matching.
   408            for host_tc in context_hosts:
   409                if host_tc in host_hosts:
   410                    is_valid_hosts = True
   411
   412            if not is_valid_hosts:
   413                self.post_error(
   414                    "Hosts mismatch between Host %s (accepted hosts: %s) and TLSContext %s (hosts: %s)"
   415                    % (self.name, host_hosts, ctx_name, context_hosts)
   416                )
   417                # XXX Shouldn't we return false here?
   418        else:
   419            ctx["hosts"] = [self.hostname]
   420
   421        self.logger.debug(f"Host {self.name}, final ctx {ctx.name}: {ctx.as_json()}")
   422
   423        # All seems good, this context belongs to self now!
   424        self.context = ctx
   425
   426        return True
   427
   428    def matches_httpgroup(self, group: "IRHTTPMappingGroup") -> bool:
   429        """
   430        Make sure a given IRHTTPMappingGroup is a match for this Host, meaning
   431        that at least one of the following is true:
   432
   433        - The Host specifies mappingSelector.matchLabels, and the group has matching labels
   434        - The group specifies a host glob, and the Host has a matching domain.
   435
   436        A Mapping that specifies no host can never match a Host that specifies no
   437        mappingSelector.
   438        """
   439
   440        groupName = group.get("name") or "None"
   441
   442        # The synthetic Mappings for diagnostics, readiness, and liveness probes always match all Hosts.
   443        # They can all still be disabled if desired via the Ambassador Module resource
   444        if groupName in [
   445            "GROUP: internal_readiness_probe_mapping",
   446            "GROUP: internal_liveness_probe_mapping",
   447            "GROUP: internal_diagnostics_probe_mapping",
   448        ]:
   449            return True
   450
   451        has_hostname = False
   452        host_match = False
   453        sel_match = False
   454
   455        group_regex = group.get("host_regex") or False
   456
   457        if group_regex:
   458            # It matches.
   459            has_hostname = True
   460            host_match = True
   461            self.logger.debug("-- hostname %s group regex => %s", self.hostname, host_match)
   462        else:
   463            # It is NOT A TYPO that we use group.get("host") here -- whether the Mapping supplies
   464            # "hostname" or "host", the Mapping code normalizes to "host" internally.
   465
   466            # It's possible for group.host_redirect to be None instead of missing, and it's also
   467            # conceivably possible for group.host_redirect.host to be "", which we'd rather be
   468            # None. Hence we do this two-line dance to massage the various cases.
   469            host_redirect = (group.get("host_redirect") or {}).get("host")
   470            group_glob = group.get("host") or host_redirect  # NOT A TYPO: see above.
   471
   472            if group_glob:
   473                has_hostname = True
   474                host_match = hostglob_matches(self.hostname, group_glob)
   475                self.logger.debug(
   476                    "-- hostname %s group glob %s => %s", self.hostname, group_glob, host_match
   477                )
   478
   479        mapsel = self.get("mappingSelector")
   480
   481        if mapsel:
   482            sel_match = selector_matches(self.logger, mapsel, group.get("metadata_labels", {}))
   483            self.logger.debug(
   484                "-- host sel %s group labels %s => %s",
   485                dump_json(mapsel),
   486                dump_json(group.get("metadata_labels")),
   487                sel_match,
   488            )
   489
   490        if disable_strict_selectors():
   491            # User opted-in to existing deprecated behavior (not-recommmended)
   492            return host_match or sel_match
   493        elif mapsel and has_hostname:
   494            # If both mappingSelector and hostname are present, then they must both be a match
   495            return host_match and sel_match
   496        elif mapsel:
   497            # If the Mapping does not provide a hostname, then only the mappingSelector must match
   498            return sel_match
   499
   500        elif has_hostname:
   501            # If there is no mappingSelector then it must only match the provided hostname
   502            return host_match
   503        else:
   504            # If there is no mappingSelector or hostname then it cannot match anything
   505            return False
   506
   507    def __str__(self) -> str:
   508        request_policy = self.get("requestPolicy", {})
   509        insecure_policy = request_policy.get("insecure", {})
   510        insecure_action = insecure_policy.get("action", "Redirect")
   511        insecure_addl_port = insecure_policy.get("additionalPort", None)
   512
   513        ctx_name = self.context.name if self.context else "-none-"
   514        return "<Host %s for %s ns %s ctx %s ia %s iap %s>" % (
   515            self.name,
   516            self.hostname or "*",
   517            self.namespace,
   518            ctx_name,
   519            insecure_action,
   520            insecure_addl_port,
   521        )
   522
   523    def resolve(
   524        self, ir: "IR", secret_name: str, secret_namespace: Union[str, None]
   525    ) -> SavedSecret:
   526        if secret_namespace:
   527            return ir.resolve_secret(self, secret_name, secret_namespace)
   528
   529        # Try to use our namespace for secret resolution. If we somehow have no
   530        # namespace, fall back to the Ambassador's namespace.
   531        namespace = self.namespace or ir.ambassador_namespace
   532
   533        return ir.resolve_secret(self, secret_name, namespace)
   534
   535
   536class HostFactory:
   537    @classmethod
   538    def load_all(cls, ir: "IR", aconf: Config) -> None:
   539        assert ir
   540
   541        hosts = aconf.get_config("hosts")
   542
   543        if hosts:
   544            for config in hosts.values():
   545                ir.logger.debug("HostFactory: creating host for %s" % repr(config.as_dict()))
   546
   547                host = IRHost(ir, aconf, **config)
   548
   549                if host.is_active():
   550                    host.referenced_by(config)
   551                    host.sourced_by(config)
   552
   553                    ir.logger.debug(f"HostFactory: saving host {host}")
   554                    ir.save_host(host)
   555                else:
   556                    ir.logger.debug(f"HostFactory: not saving inactive host {host}")
   557
   558    @classmethod
   559    def finalize(cls, ir: "IR", aconf: Config) -> None:
   560        # First up: how many Hosts do we have?
   561        host_count = len(ir.get_hosts() or [])
   562
   563        # Do we have any termination contexts for TLS? (If not, we'll need to bring the
   564        # fallback cert into play.)
   565        #
   566        # (This empty_contexts stuff mypy silliness to deal with the fact that ir.get_tls_contexts()
   567        # returns a ValuesView[IRTLSContext] rather than a List[IRTLSContext].
   568        empty_contexts: List[IRTLSContext] = []
   569        contexts: List[IRTLSContext] = list(ir.get_tls_contexts()) or empty_contexts
   570
   571        found_termination_context = False
   572        for ctx in contexts:
   573            if ctx.get("hosts"):  # not None and not the empty list
   574                found_termination_context = True
   575                break
   576
   577        ir.logger.debug(
   578            f"HostFactory: Host count %d, %s TLS termination contexts"
   579            % (host_count, "with" if found_termination_context else "no")
   580        )
   581
   582        # OK, do we have any Hosts?
   583        if host_count == 0:
   584            # Nope. First up, scream if we _do_ have termination contexts...
   585            if found_termination_context:
   586                ir.post_error(
   587                    "No Hosts defined, but TLSContexts exist that terminate TLS. The TLSContexts are being ignored."
   588                )
   589
   590            # If we don't have a fallback secret, don't try to use it.
   591            #
   592            # We use the Ambassador's namespace here because we'll be creating the
   593            # fallback Host in the Ambassador's namespace.
   594            fallback_ss = ir.resolve_secret(
   595                ir.ambassador_module, "fallback-self-signed-cert", ir.ambassador_namespace
   596            )
   597
   598            host: IRHost
   599
   600            if not fallback_ss:
   601                ir.aconf.post_notice(
   602                    "No TLS termination and no fallback cert -- defaulting to cleartext-only."
   603                )
   604                ir.logger.debug("HostFactory: creating cleartext-only default host")
   605
   606                host = IRHost(
   607                    ir,
   608                    aconf,
   609                    rkey="-internal",
   610                    name="default-host",
   611                    location="-internal-",
   612                    hostname="*",
   613                    requestPolicy={"insecure": {"action": "Route"}},
   614                )
   615            else:
   616                ir.logger.debug(f"HostFactory: creating TLS-enabled default Host")
   617
   618                host = IRHost(
   619                    ir,
   620                    aconf,
   621                    rkey="-internal",
   622                    name="default-host",
   623                    location="-internal-",
   624                    hostname="*",
   625                    tlsSecret={"name": "fallback-self-signed-cert"},
   626                )
   627
   628            if not host.is_active():
   629                ir.post_error(
   630                    "Synthesized default host is inactive? %s" % dump_json(host.as_dict())
   631                )
   632            else:
   633                host.referenced_by(ir.ambassador_module)
   634                host.sourced_by(ir.ambassador_module)
   635
   636                ir.logger.debug(f"HostFactory: saving host {host}")
   637                ir.save_host(host)

View as plain text