architect-team/architect-cli

Generalized networking rules

Closed this issue · 3 comments

Right now the only networking rules that are configurable are the subdomains at which services are made accessible. Architect generates an API gateway to fulfill these traffic rules and act as the external load balancer for the application. These subdomains get configured from environment config files using the ingress.subdomain field as follows. The below will deploy the 10 services included in the hipster-shop demo along with an API gateway to fulfill traffic from shop.<env-name>.<acct-name>.arc.domains to the frontend webapp:

services:
  hipster-shop-demo/frontend:latest:
    ingress:
      subdomain: shop

This configuration, while useful to expose services safely to the outside world, does not provide as robust configurability as we would like for traffic between consumers and services. For this reason, we'd like to create a more generalized approach to configuring networking logic between services internally and externally.

Proposal

Instead of subdomains inside of service interfaces, we'd like to let operators setup N gateways that can enforce networking configuration rules both from external and internal sources. Some of the goals of this feature include:

  1. Allow operators to setup gateways that broker and apply rules to internal traffic (much like a service mesh)
  2. Allow gateways to be configured with advanced configuration options (e.g. rate limiting, circuit breaking, etc)
  3. Allow operators to apply different gateways to subsets of the services inside the environment (e.g. legacy vs. new code paths, and different protection rules)
  4. Allow operators to setup gateways that route to the same service for different purposes (e.g. different product lines need different auth).
# Environment config file
gateways:
  # The primary external gateway that makes architect/cloud available
  - external: true
    host_rewrites:
      - shop.hipster.com
      # Every environment is still assigned an Architect domain that can be used for routing.
      - shop.${architect.environment.domain}
    services:
      - hipster-shop-demo/frontend:*

    # Operators can provide their own SSL certs or configure rules for cert generation
    tls:
      certificate: file:./local/cert
      private_key:
        value_from:
          vault: architect
          key: ssl/architect.io

    # Operators can configure how additional rules for how connections are to be handled
    http:
      timeout: 10s
      retries: 3
      max_connections: 100

  # An internal service mesh that applies to all inter-process traffic
  - external: false
    host_rewrites:
      - ${service.name}.private.hipster.com
    services:
      - *
 
services:
  hipster-shop-demo/frontend: latest

Since this issue was created, we've decided to change the way a component spec is setup to allow it to include N services instead of forcibly holding a 1:1 relationship between a component and a service. See #224 for details.

This change also forces us to consider gateways and component networking in a more thoughtful way. Since components can contain N services, gateway and interface logic must be described by component creators to dictate which services can be accessed by others and the routing logic needed to resolve them. This change opens a great opportunity for us to add 1st class support for generalized API gateway rules where component creators can control the routing rules relevant to their own services.

Proposal

In order to democratize API gateways in our framework, we want to allow components to be registered with basic routing rules that dictate which services are available for consumption and how they can be resolved. This includes the registration of interfaces (each of which will be allocated a unique host/port combo), and the assertion of rules that map the interface to services inside the component. A simple example is as follows:

name: architect/test

parameters:
  ROOT_DB_USER:
    default: architect
  ROOT_DB_PASS:
    required: true
  API_DB_USER:
    default: ${ parameters.ROOT_DB_USER }
  API_DB_PASS:
    default: ${ parameters.ROOT_DB_PASS }
  API_DB_NAME:
    default: architect

interfaces:
  public:
    backend:
      host: ${ services.backend.host }
      port: ${ services.backend.ports.public }
  admin:
    backend:
      host: ${ services.backend.host }
      port: ${ services.backend.ports.admin }

services:
  db:
    image: postgres:11
    ports:
      postgres: 5432
    environment:
      POSTGRES_USER: ${ parameters.ROOT_DB_USER }
      POSTGRES_PASSWORD: ${ parameters.ROOT_DB_PASS }
  backend:
    image: architect/backend:latest
    ports:
      public: 8080
      admin: 8081
    environment:
      DB_ADDR: postgres://${ parameters.API_DB_USER }:${ parameters.API_DB_PASS }@${ services.db.interfaces.postgres.host }:${ services.db.interfaces.postgres.port }/${ parameters.API_DB_NAME }

We've outlined two interfaces for our component in the above example: 1) public and 2) admin. Each of these interfaces represent independently resolvable addresses, and each routes traffic to one of the two ports on the API service in our component. What is notably left off is an interface that connects to the component's DB. Since we don't want anyone else to touch it, we simply neglect to create an interface that maps to it and we've successfully ensured that the service is privately allocated and only accessible to my component.

Working with dependencies

To extend this example, let's create a service that depends on the component above:

name: architect/frontend
interfaces:
  webapp:
    backend:
      host: ${ services.webapp.host }
      port: ${ services.webapp.ports.web }
dependencies:
  architect/test: latest
services:
  webapp:
    image: architect/frontend:latest
    ports:
      web: 8080
    environment:
      API_ADDR: ${ dependencies['architect/test'].interfaces.public.url }

The above example cites a dependency on our architect/test component described earlier, but notably only connects to the public interface. Since there is only a reference to one of the two interfaces of architect/test, we only create a network policy allowing traffic between the frontend and the public interface of the test service.

As part of our work on our component spec, we've made another round of changes to the way interfaces are described so as to better conform to our expression syntax:

name: architect/test

parameters:
  ROOT_DB_USER:
    default: architect
  ROOT_DB_PASS:
    required: true
  API_DB_USER:
    default: ${ parameters.ROOT_DB_USER }
  API_DB_PASS:
    default: ${ parameters.ROOT_DB_PASS }
  API_DB_NAME:
    default: architect

interfaces:
  public: ${ services.backend.interfaces.public.url }
  admin:
    description: Exposes the admin interface to upstream traffic
    url: ${ services.backend.interfaces.admin.url }

services:
  db:
    image: postgres:11
    interfaces:
      postgres:
        port: 5432
        protocol: postgres
    environment:
      POSTGRES_USER: ${ parameters.ROOT_DB_USER }
      POSTGRES_PASSWORD: ${ parameters.ROOT_DB_PASS }
  backend:
    image: architect/backend:latest
    interfaces:
      public: 8080
      admin:
        port: 8081
        protocol: http
    environment:
      DB_ADDR: ${ services.db.interfaces.postgres.url }/${ parameters.API_DB_NAME }
      DB_USER: ${ parameters.API_DB_USER }
      DB_PASS: ${ parameters.API_DB_PASS }

This is going to be completed as part of #224. Stay tuned!