Apricot Framework

Usage

The catalogue schema, hosting types, roles, protocols and profile overrides in full.

The catalogue

services:                       # a map of service name to definition
  billing:                      # the name callers pass, and the hostname under docker and k8s
    namespace: payments         # Kubernetes namespace; ignored by every other hosting type
    domain: cluster.local       # Kubernetes cluster domain; ignored by every other hosting type
    ports:
      - protocol: https         # http or https — nothing else can produce a URL
        roles: [ 'api', 'ui' ]  # what this port serves; a port may serve several roles
        port: 11101
    overrides:
      istio:                    # a profile name, selected by configuration
        ports:
          - protocol: https
            roles: [ 'api' ]
            port: 443
FieldRequiredNotes
servicesnoA catalogue declaring none contributes nothing; it is not an error
namespacenoOnly read under k8s hosting; defaults to default
domainnoOnly read under k8s hosting; defaults to cluster.local
portsnoA service with none resolves to nothing
protocolnoMatched case-insensitively; a port without one never matches
rolesnoMatched case-sensitively; a port without any never matches
portnoAny int; the library does not police the range
overridesnoKeyed by profile name, matched case-sensitively

Service names are matched case-sensitively, so Billing does not find billing.

Note

A service named with no body — billing: on its own — parses to nothing at all and is dropped. It does not erase a definition of the same name from an earlier source.

Resolving a URL

A resolved URL is {scheme}://{host}:{port}. The three parts come from three different places:

  • scheme — the configured default protocol, lower-cased
  • host — the hosting type, and for Kubernetes the service's namespace and domain
  • port — the catalogue entry matching that protocol and the requested role
await discovery.GetApiBase("billing");                  // the api role
await discovery.GetApiUrl("billing", "/v1/invoices");   // the api role, with a path
await discovery.GetGrpcUrl("billing");                  // the grpc role
await discovery.GetBaseUrl("billing", "ui");            // any role
await discovery.GetUrl("billing", "ui", "/dashboard");  // any role, with a path

Every one of them answers null rather than throwing — for an unknown service, a role no port serves, a protocol that cannot appear in a URL, or a blank service name. A caller that cannot proceed without the URL should say so itself, where it has the context to write a useful message.

GetApiUrl and GetUrl join the path with exactly one slash however each side was written, so "/v1/invoices" and "v1/invoices" give the same result.

Hosting types

HostingTypeHostnameFor
localhostlocalhostEverything on one machine, told apart by port
dockerthe service nameA container network resolving service names
docker_localhosthost.docker.internalContainers and host processes side by side
k8sservice.namespace.svc.domainInside a Kubernetes cluster
unset, or anything elsethe service nameAny network where service names resolve

Under k8s, a definition naming neither a namespace nor a domain resolves to the bare service name — inside a cluster the pod's own DNS search domains complete it, which is what a caller in the same namespace wants. Naming either one produces the fully qualified form and fills the other from its default.

billing: { }                                     # -> billing
billing: { namespace: payments }                 # -> billing.payments.svc.cluster.local
billing: { domain: prod.internal }               # -> billing.default.svc.prod.internal
billing: { namespace: payments, domain: prod.internal }  # -> billing.payments.svc.prod.internal

Warning

An unrecognised hosting type resolves to the bare service name rather than failing. That is deliberate — it is the right answer on most networks, and it keeps a bespoke value usable when you build the client by hand. When you register through the ASP.NET Core package it is rejected at startup instead, because in a configuration file it is far more likely a typo. See ASP.NET Core.

To add a hosting type of your own, override GetHostname on BaseStaticDiscoveryClient and delegate to base for the cases you do not handle.

Roles and protocols

A role is a label the catalogue chooses. api, ui and grpc have constants on ServicePortRoles and the first and third have named accessors, but nothing stops a catalogue declaring metrics and a caller asking for it through GetBaseUrl.

Protocols and roles are matched differently, on purpose:

MatchingWhy
Protocolcase-insensitiveOne of two known values, so there is a canonical form to fold to
Rolecase-sensitiveAn arbitrary label, so there is no canonical form and folding would guess

gRPC uses its own protocol setting. DefaultGrpcProtocol is separate from DefaultProtocol because the two frequently differ on TLS: an ingress that terminates TLS for the HTTP API often passes gRPC through untouched, or the reverse. Every role other than grpc uses DefaultProtocol.

Only http and https can produce a URL. Any other value — grpc, h2, tcp — resolves every lookup to null, and the ASP.NET Core package rejects it at startup.

Profiles

A profile is a named deployment variant of the same topology. Selecting one merges its overrides into every definition that declares it:

  • namespace and domain — a value in the profile replaces the service's; a blank one leaves it
  • ports — the profile's are appended to the service's, and the last match wins

That appending rule is the whole mechanism, and it has a consequence worth knowing:

billing:
  ports:
    - { protocol: https, roles: [ 'api' ], port: 11101 }
    - { protocol: https, roles: [ 'ui' ], port: 11103 }
  overrides:
    istio:
      ports:
        - { protocol: https, roles: [ 'api' ], port: 443 }

Under the istio profile, api resolves to 443 — the override wins because it comes later. But ui, which the override says nothing about, still resolves to 11103. An override narrows what it names and leaves the rest alone; it is not a replacement for the port list.

A profile a service does not declare is not an error: the definition resolves as written. Profile names are matched case-sensitively, and overrides nest only one level deep — overrides of an override are ignored.

Sources

Definitions come from one or more IServiceDefinitionsSource instances, merged by service name in registration order. Where two declare the same service, the later one wins outright — its definition replaces the earlier one rather than merging with it.

// The shared catalogue, then one service restated for this deployment.
services.AddServiceDefinitionsSource<SharedCatalogue>();
services.AddServiceDefinitionsSource<LocalOverrides>();

Sources are read once, while the registry is constructed, so an expensive read costs one per host and a change to a catalogue file takes effect on the next start. To build a catalogue in code, hand a ServiceDefinitions to StaticServiceDefinitionsSource.

Without a container

var registry = new DefaultServiceRegistry([new StaticServiceDefinitionsSource(catalogue)]);

var discovery = new DefaultStaticDiscoveryClient(registry, new StaticDiscoveryOptions
{
    HostingType = StaticHostingTypes.Localhost,
    DefaultProtocol = ServiceProtocolTypes.Http,
});

Its settings are fixed at construction. For settings that follow configuration reloads, use the ASP.NET Core package.

On this page