Chapter 17. Using Stacks as Components

A stack is usually the highest-level component in an infrastructure system. It’s the largest unit that can be defined, provisioned, and changed independently. The reusable stack pattern (see “Pattern: Reusable Stack”) encourages you to treat the stack as the main unit for sharing and reusing infrastructure.

Infrastructures composed of small stacks are more nimble than large stacks composed of modules and libraries. You can change a small stack more quickly, easily, and safely than a large stack. So this strategy supports the virtuous cycle of using speed of change to improve quality, and high quality to enable fast change.

Building a system from multiple stacks requires keeping each stack well-sized and well-designed, cohesive and loosely coupled. The advice in Chapter 15 is relevant to stacks as well as other types of infrastructure components. The specific challenge with stacks is implementing the integration between them without creating tight coupling.

Integration between stacks typically involves one stack managing a resource that another stack uses. There are many popular techniques to implement discovery and integration of resources between stacks, but many of them create tight coupling that makes changes more difficult. So this chapter explores different approaches from a viewpoint of how they affect coupling.

Discovering Dependencies Across Stacks

The ShopSpinner system includes a consumer stack, application-infrastructure-stack, that integrates with networking elements managed by another stack, shared-network-stack. The networking stack declares a VLAN:

vlan:
  name: "appserver_vlan"
  address_range: 10.2.0.0/8

The application stack defines an application server, which is assigned to the VLAN:

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: "appserver_vlan"

This example hardcodes the dependency between the two stacks, creating very tight coupling. It won’t be possible to test changes to the application stack code without an instance of the networking stack. Changes to the networking stack are constrained by the other stack’s dependence on the VLAN name.

If someone decides to add more VLANs for resiliency, they need consumers to change their code implementation in conjunction with the change to the consumer.1 Otherwise, they can leave the original name, adding messiness that makes the code and infrastructure harder to understand and maintain:

vlans:
  - name: "appserver_vlan"
    address_range: 10.2.0.0/8
  - name: "appserver_vlan_2"
    address_range: 10.2.1.0/8
  - name: "appserver_vlan_3"
    address_range: 10.2.2.0/8

Hardcoding integration points can make it harder to maintain multiple infrastructure instances, as for different environments. This might work out depending on the infrastructure platform’s API for the specific resources. For instance, perhaps you create infrastructure stack instances for each environment in a different cloud account, so you can use the same VLAN name in each. But more often, you need to integrate with different resource names for multiple environments.

So you should avoid hardcoding dependencies. Instead, consider using one of the following patterns for discovering dependencies.

Pattern: Resource Matching

A consumer stack uses resource matching to discover a dependency by looking for infrastructure resources that match names, tags, or other identifying characteristics. For example, a provider stack could name VLANs by the types of resources that belong in the VLAN and the environment of the VLAN (see Figure 17-1)

iac2 1701
Figure 17-1. Resource matching for discovering dependencies

In this example, vlan-appserver-staging is intended for application servers in the staging environment. The application-infrastructure-stack code finds this resource by matching the naming pattern:

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: "vlan-appserver-${ENVIRONMENT_NAME}"

A value for ENVIRONMENT_NAME is passed to the stack management tool when applying the code, as described in Chapter 7.

Motivation

Resource matching is straightforward to implement with most stack management tools and languages. The pattern mostly eliminates hardcoded dependencies, reducing coupling.

Resource matching also avoids coupling on tools. The provider infrastructure and consumer stack can be implemented using different tools.

Applicability

Use resource matching for discovering dependencies when the teams managing provider and consumer code both have a clear understanding of which resources should be used as dependencies. Consider switching to an alternative pattern if you experience issues with breaking dependencies between teams.

Resource matching is useful in larger organizations, or across organizations, where different teams may use different tooling to manage their infrastructure, but still need to integrate at the infrastructure level. Even where everyone currently uses a single tool, resource matching reduces lock-in to that tool, creating the option to use new tools for different parts of the system.

Consequences

As soon as a consumer stack implements resource matching to discover a resource from another stack, the matching pattern becomes a contract. If someone changes the naming pattern of the VLAN in the shared networking stack, the consumer’s dependency breaks.

So a consumer team should only discover dependencies by matching resources in ways that the provider team explicitly supports. Provider teams should clearly communicate what resource matching patterns they support, and be sure to maintain the integrity of those patterns as a contract.

Implementation

There are several ways to discover infrastructure resources by matching. The most straightforward method is to use variables in the name of the resource, as shown in the example code from earlier:

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: "vlan-appserver-${ENVIRONMENT_NAME}"

The string vlan-appserver-${ENVIRONMENT_NAME} will match the relevant VLAN for the environment.

Most stack languages have features to match other attributes than the resource name. Terraform has data sources and AWS CDK’s supports resource importing.

In this example (using pseudocode) the provider assigns tags to its VLANs:

vlans:
  - appserver_vlan
    address_range: 10.2.0.0/8
    tags:
      network_tier: "application_servers"
      environment: ${ENVIRONMENT_NAME}

The consumer code discovers the VLAN it needs using those tags:

external_resource:
  id: appserver_vlan
  match:
    tag: name == "network_tier" && value == "application_servers"
    tag: name == "environment" && value == ${ENVIRONMENT_NAME}

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: external_resource.appserver_vlan

Related patterns

The resource matching pattern is similar to the stack data lookup pattern. The main difference is that resource matching doesn’t depend on the implementation of the same stack tool across provider and consumer stacks.

Pattern: Stack Data Lookup

Also known as: remote statefile lookup, stack reference lookup, or stack resource lookup.

Stack data lookup finds provider resources using data structures maintained by the tool that manages the provider stack (Figure 17-2).

iac2 1702
Figure 17-2. Stack data lookup for discovering dependencies

Many stack management tools maintain data structures for each stack instance, which include values exported by the stack code. Examples include Terraform and Pulumi remote state files.

Motivation

Stack management tool vendors make it easy to use their stack data lookup features to integrate different projects. Most implementations of data sharing across stacks require the provider stack to explicitly declare which resources to publish for use by other stacks. Doing this discourages consumer stacks from creating dependencies on resources without the provider’s knowledge.

Applicability

Using stack data lookup functionality to discover dependencies across stacks works when all of the infrastructure in a system is managed using the same tool.

Consequences

Stack data lookup tends to lock you into a single stack management tool. It’s possible to use the pattern with different tools, as described in the implementation of this pattern. But this adds complexity to your implementation.

This pattern sometimes breaks across different versions of the same stack tool. An upgrade to the tool may involve changing the stack data structures. This can cause problems when upgrading a provider stack to the newer version of the tool. Until you upgrade the consumer stack to the new version of the tool as well, it may not be possible for the older version of the tool to extract the resource values from the upgraded provider stack. This stops you from rolling out stack tool upgrades incrementally across stacks, potentially forcing a disruptive coordinated upgrade across your estate.

Implementation

The implementation of stack data lookup uses functionality from your stack management tool and its definition language.

Terraform stores output values in a remote state file. Pulumi also stores resource details in a state file that can be referenced in a consumer stack using a StackReference. CloudFormation can export and import stack output values across stacks, which AWS CDK can also access.2

The provider usually explicitly declares the resources it provides to consumers:

stack:
  name: shared_network_stack
  environment: ${ENVIRONMENT_NAME}

vlans:
  - appserver_vlan
    address_range: 10.2.0.0/8

export:
  - appserver_vlan_id: appserver_vlan.id

The consumer declares a reference to the provider stack and uses this to refer to the VLAN identifier exported by that stack:

external_stack:
  name: shared_network_stack
  environment: ${ENVIRONMENT_NAME}

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: external_stack.shared_network_stack.appserver_vlan.id

This example embeds the reference to the external stack in the stack code. Another option is to use dependency injection (“Dependency Injection”) so that your stack code is less coupled to the dependency discovery. Your orchestration script looks up the output value from the provider stack and passes it to your stack code as a parameter.

Although stack data lookups are tied to the tool that manages the provider stack, you can usually extract these values in a script, so you can use them with other tools, as shown in Example 17-1.

Example 17-1. Using a stack tool to discover a resource ID from a stack instance’s data structure
#!/usr/bin/env bash

VLAN_ID=$(
  stack value \
    --stack_instance shared_network-staging \
    --export_name appserver_vlan_id
)

This code runs the fictional stack command, specifying the stack instance (shared_network-staging) to look in, and the exported variable to read and print (appserver_vlan_id). The shell command stores the command’s output, which is the ID of the VLAN, in a shell variable named VLAN_ID. The script can then use this variable in different ways.

Related patterns

The main alternative patterns are resource matching (“Pattern: Resource Matching”) and registry lookup.

Pattern: Integration Registry Lookup

Also known as: integration registry.

A consumer stack can use integration registry lookup to discover a resource published by a provider stack (Figure 17-3). Both stacks refer to a registry, using a known location to store and read values.

iac2 1703
Figure 17-3. Integration registry lookup for discovering dependencies

Many stack tools support storing and retrieving values from different types of registries within definition code. The shared-networking-stack code sets the value:

vlans:
  - appserver_vlan
    address_range: 10.2.0.0/8

registry:
  host: registry.shopspinner.xyz
  set:
    /${ENVIRONMENT_NAME}/shared-networking/appserver_vlan: appserver_vlan.id

The application-infrastructure-stack code then retrieves and uses the value:

registry:
  id: stack_registry
  host: registry.shopspinner.xyz
  values:
    appserver_vlan_id: /${ENVIRONMENT_NAME}/shared-networking/appserver_vlan

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: stack_registry.appserver_vlan_id

Motivation

Using a configuration registry decouples the stack management tools for different infrastructure stacks. Different teams can use different tools as long as they agree to use the same configuration registry service and naming conventions for storing values in it. This decoupling also makes it easier to upgrade and change tools incrementally, one stack at a time.

Using a configuration registry makes the integration points between stacks explicit. A consumer stack can only use values explicitly published by a provider stack, so the provider team can freely change how they implement their resources.

Applicability

The integration registry lookup pattern is useful for larger organizations, where different teams may use different technologies. It’s also useful for organizations concerned about lock-in to a tool.

If your system already uses a configuration registry (“Configuration Registry”), for instance, to provide configuration values to stack instances following the stack parameter registry pattern (“Pattern: Stack Parameter Registry”), it can make sense to use the same registry for integrating stacks.

Consequences

The configuration registry becomes a critical service when you adopt the integration registry lookup pattern. You may not be able to provision or recover resources when the registry is unavailable.

Implementation

The configuration registry is a prerequisite for using this pattern. See “Configuration Registry” in Chapter 7 for a discussion of registries. Some infrastructure tool vendors provide registry servers, as mentioned in “Infrastructure automation tool registries”. With any registry product, be sure it’s well-supported by the tools you use, and those you might consider using in the future.

It’s essential to establish a clear convention for naming parameters, especially when using the registry to integrate infrastructure across multiple teams. Many organizations use a hierarchical namespace, similar to a directory or folder structure, even when the registry product implements a simple key/value mechanism. The structure typically includes components for architectural units (such as services, applications, or products), environments, geography, or teams.

For example, ShopSpinner could use a hierarchical path based on the geographical region:

/infrastructure/
 ├── au/
 │   ├── shared-networking/
 │   │   └── appserver_vlan=
 │   └── application-infrastructure/
 │       └── appserver_ip_address=
 └── eu/
     ├── shared-networking/
     │   └── appserver_vlan=
     └── application-infrastructure/
         └── appserver_ip_address=

The IP address of the application server for the European region, in this example, is found at the location /infrastructure/eu/application-infrastructure/appserver​_ip_address.

Related patterns

Like this pattern, the stack data lookup pattern (see “Pattern: Stack Data Lookup”) stores and retrieves values from a registry. That pattern uses data structures specific to the stack management tool, whereas this pattern uses a general-purpose registry implementation. The parameter registry pattern (see “Pattern: Stack Parameter Registry”) is essentially the same as this pattern, in that a stack pulls values from a registry to use in a given stack instance. The only difference is that with this pattern, the value comes from another stack, and is used explicitly to integrate infrastructure resources between stacks.

Dependency Injection

These patterns describe strategies for a consumer to discover resources managed by a provider stack. Most stack management tools support directly using these patterns in your stack definition code. But there’s an argument for separating the code that defines a stack’s resources from code that discovers resources to integrate with.

Consider this snippet from the earlier implementation example for the dependency matching pattern (see “Pattern: Resource Matching”):

external_resource:
  id: appserver_vlan
  match:
    tag: name == "network_tier" && value == "application_servers"
    tag: name == "environment" && value == ${ENVIRONMENT_NAME}

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: external_resource.appserver_vlan

The essential part of this code is the declaration of the virtual machine. Everything else in the snippet is peripheral, implementation details for assembling configuration values for the virtual machine.

Issues with mixing dependency and definition code

Combining dependency discovery with stack definition code adds cognitive overhead to reading or working with the code. Although it doesn’t stop you from getting things done, the subtle friction adds up.

You can remove the cognitive overhead by splitting the code into separate files in your stack project. But another issue with including discovery code in a stack definition project is that it couples the stack to the dependency mechanism.

The type and depth of coupling to a dependency mechanism and the kind of impact that coupling has will vary for different mechanisms and how you implement them. You should avoid or minimize coupling with the provider stack, and with services like a configuration registry.

Coupling dependency management and definition can make it hard to create and test instances of the stack. Many approaches to testing use practices like ephemeral instances (“Pattern: Ephemeral Test Stack”) or test doubles (“Using Test Fixtures to Handle Dependencies”) to enable fast and frequent testing. This can be challenging if setting up dependencies involves too much work or time.

Hardcoding specific assumptions about dependency discovery into stack code can make it less reusable. For example, if you create a core application server infrastructure stack for other teams to use, different teams may want to use different methods to configure and manage their dependencies. Some teams may even want to swap out different provider stacks. For example, they might use different networking stacks for public-facing and internally facing applications.

Decoupling dependencies from their discovery

Dependency injection (DI) is a technique where a component receives its dependencies, rather than discovering them itself. An infrastructure stack project would declare the resources it depends on as parameters, the same as instance configuration parameters described in Chapter 7. A script or other tool that orchestrates the stack management tool (“Using Scripts to Wrap Infrastructure Tools”) would be responsible for discovering dependencies and passing them to the stack.

Consider how the application-infrastructure-stack example used to illustrate the dependency discovery patterns earlier in this chapter would look with DI:

parameters:
  - ENVIRONMENT_NAME
  - VLAN

virtual_machine:
  name: "appserver-${ENVIRONMENT_NAME}"
  vlan: ${VLAN}

The code declares two parameters that must be set when applying the code to an instance. The ENVIRONMENT_NAME parameter is a simple stack parameter, used for naming the application server virtual machine. The VLAN parameter is the identifier of the VLAN to assign the virtual machine to.

To manage an instance of this stack, you need to discover and provide a value for the VLAN parameter. Your orchestration script can do this, using any of the patterns described in this chapter. It might set the parameter based on tags, by finding the provider stack project’s outputs using the stack management tool, or it could look up the values in a registry.

An example script using stack data lookup (see “Pattern: Stack Data Lookup”) might use the stack tool to retrieve the VLAN ID from the provider stack instance, as in Example 17-1, and then pass that value to the stack command for the consumer stack instance:

#!/usr/bin/env bash

ENVIRONMENT_NAME=$1

VLAN_ID=$(
  stack value \
    --stack_instance shared_network-${ENVIRONMENT_NAME} \
    --export_name appserver_vlan_id
)

stack apply \
  --stack_instance application_infrastructure-${ENVIRONMENT_NAME} \
  --parameter application_server_vlan=${VLAN_ID}

The first command extracts the appserver_vlan_id value from the provider stack instance named shared_network-${ENVIRONMENT_NAME}, and then passes it as a parameter to the consumer stack application_infrastructure-${ENVIRONMENT_NAME}.

The benefit of this approach is that the stack definition code is simpler and can be used in different contexts. When you work on changes to the stack code on your laptop, you can pass whatever VLAN value you like. You can apply your code using a local API mock (see “Testing with a Mock API”), or to a personal instance on your infrastructure platform (see “Personal Infrastructure Instances”). The VLANs you provide in these situations may be very simple.

In more production-like environments, the VLAN may be part of a more comprehensive network stack. This ability to swap out different provider implementations makes it easier to implement progressive testing (see “Progressive Testing”), where earlier pipeline stages run quickly and test the consumer component in isolation, and later stages test a more comprehensively integrated system.

Origins of Dependency Injection

DI originated in the world of object-oriented (OO) software design in the early 2000s. Proponents of XP found that unit testing and TDD were much easier with software written for DI. Java frameworks like PicoContainer and the Spring framework pioneered DI. Martin Fowler’s 2004 article “Inversion of Control Containers and the Dependency Injection Pattern” explains the rationale for the approach for OO software design.

Conclusion

Composing infrastructure from well-designed, well-sized stacks that are loosely coupled makes it easier, faster, and safer to make changes to your system. Doing this requires following the general design guidance for modularizing infrastructure (as in Chapter 15). It also requires making sure that stacks don’t couple themselves too closely together when sharing and providing resources.

1 See “Changing Live Infrastructure” for less disruptive ways to manage this kind of change.

2 See the AWS CDK Developer Guide.