Validations Across Types and Values

Click here to return to the Advanced Ludwig table of contents.

Nodes and the NodeStream

When Ludwig is compiled, every value of a sum type becomes a node. For example, an EC2.Instance, an S3.Bucket, and an AWS.Tag each compile down to a node, while basic types like Int, String, records or tuples do not. A node is represented by a unique ID for a particular tag and value. And all of the nodes in a composition form the nodestream, which holds the entire output of the compiler. For example, say you have this simple composition:

type T:
    | T Int

myValue: T(124)

This composition declares a type, T, which takes an Int value. It also declares a binding, myValue, which gives a T element the value 124. You can compile it with the -s simple option (and pipe it into jq so it displays nicely):

lwc NodeExample.lw -s simple | jq .

You’ll see this:

{
  "metanodes": [
    {
      "tag": "T",
      "references": [],
      "mutables": [],
      "weights": {}
    }
  ],
  "version": "0.36.0",
  "nodes": [
    {
      "tag": "T",
      "value": 124,
      "metadata": {
        "annotations": [],
        "composition": "",
        "name": "myValue",
        "errors": []
      },
      "id": "0e17b2c3-16b6-507e-b8a2-941ccce6a4cd"
    }
  ],
  "make_paths": []
}

The id field says that this node’s ID is 0e17b2c3-16b6-507e-b8a2-941ccce6a4cd. It represents the tag T and the value 124. Tags are a concatenation of the node’s module name and type name – the fully qualified name. In this case, it’s T, but another example might be Fugue.Core.AWS.EC2.Subnet.

Note that if you compile it again and again, you’ll see the same ID every time. If you change something about the myValue binding – by changing the value to 125 or changing the name to newValue – you’ll get a new ID, because the checksum is different.

The NodeStream Module

The Ludwig.NodeStream module was designed for validations. Since the module gives you access to the entire nodestream, it allows you to write validations across types and values. This would otherwise be impossible. The module provides several handy functions for validations, such as lookupNodeByValue and unsafeGetNodesByTag. These functions are evaluated after the composition has been compiled into nodes, which is how Ludwig.NodeStream enables validation against different types and values.

For example, let’s say you wanted to impose an EC2 instance cap on compositions. With Ludwig.NodeStream, you can write a validation that makes a list of all EC2 instance node IDs and counts them up. Without the module, there would be no way to count the instances in aggregate. You can check individual instances for a particular property (for example, instance type), but you can’t validate across several different nodes without the nodestream.

Let’s walk through an example validation that demonstrates an EC2 instance cap.

NodeStream Validation: Limit EC2 Instances

The validation in this module uses a NodeStream function to find all of the nodes tagged Fugue.Core.AWS.EC2.Instance, then it puts them in a List. If that list’s length is greater than 1, the validation fails with a message stating the maximum number of instances and the amount actually declared.

Let’s walk through it line by line. If you need a refresher on validations first, check out Design-Time Validations. If you want to brush up on functions, see Functions Tutorial.

Download this validation module here.

The Validation

import Fugue.AWS.EC2 as EC2
import Ludwig.NodeStream as Node

First, we import the Fugue.AWS.EC2 and Ludwig.NodeStream libraries.

fun checkInstanceCap(ns: NodeStream) -> Validation:

Next, we declare a function called checkInstanceCap, which takes a NodeStream and returns a validation.

By the way, the NodeStream type is opaque, which means you can’t see inside it – but you don’t need to. You can access what’s inside it with functions.

let List<Node.Node<EC2.Instance>> allEC2Instances: Node.unsafeGetNodesByTag(ns, "Fugue.Core.AWS.EC2.Instance")

The first thing our function does is set a local variable, allEC2Instances, which has the type <List<Node.Node<EC2.Instance>>. This means it’s a list of nodes that are EC2 instances.

The Node.unsafeGetNodesByTag function searches for all nodes in the nodestream that have the tag "Fugue.Core.AWS.EC2.Instance", and returns a list of nodes. This list becomes the value of allEC2Instances. (Curious about the “unsafe” in unsafeGetNodesByTag? See this note.)

if List.length(allEC2Instances) > 1 then
    Validation.failure("Error: Too many EC2 instances. Limit: 1. Declared: " ++ Int.toString(List.length(allEC2Instances)))
else
    Validation.success

The second thing our function does is use an if/then/else statement to determine whether the validation is successful or not. If there’s more than 1 element in the allEC2Instances list, throw an error. Otherwise, success!

To make the error message more informative, the Validation.failure function retrieves the number of elements in the allEC2Instances list, converts that number to a string with Int.toString, and prints it in the error message.

validate checkInstanceCap

Finally, we register the validation. And now we’re ready to test it out!

The Composition

We’ve got a composition, InstanceCap.lw, that imports InstanceCapValidation.lw and declares two EC2 instances, as you can see below. You can download it here.

composition

import Fugue.AWS as AWS
import Fugue.AWS.EC2 as EC2
import InstanceCapValidation as .

#########################
# EC2 Instance
#########################
smallInstance: EC2.Instance.new {
  subnet: exampleSubnet,
  blockDeviceMappings: [
    EC2.InstanceBlockDeviceMapping.new {
      deviceName: "/dev/sdz",
      ebs: EC2.EbsInstanceBlockDevice.new {
        volume: EC2.Volume.new {
          size: 1,
          availabilityZone: exampleSubnetAZ
        },
      },
    }
  ],
  image: "ami-a9d276c9",
  securityGroups: [exampleSecurityGroup],
  instanceType: EC2.T2_micro
}

largerInstance: EC2.Instance.new {
  subnet: exampleSubnet,
  blockDeviceMappings: [
    EC2.InstanceBlockDeviceMapping.new {
      deviceName: "/dev/sdz",
      ebs: EC2.EbsInstanceBlockDevice.new {
        volume: EC2.Volume.new {
          size: 1,
          availabilityZone: exampleSubnetAZ
        },
      },
    }
  ],
  image: "ami-a9d276c9",
  securityGroups: [exampleSecurityGroup],
  instanceType: EC2.M4_large
}


#########################
### PREREQUISITES
#########################

exampleVpc: EC2.Vpc.new {
  cidrBlock: "10.0.0.0/16",
  region: AWS.Us-west-2,
}

exampleSubnet: EC2.Subnet.new {
  cidrBlock: '10.0.1.0/24',
  vpc: exampleVpc,
  availabilityZone: exampleSubnetAZ
}

exampleSubnetAZ: AWS.A

sgHTTP: EC2.IpPermission.http(EC2.IpPermission.Target.all)

sgHTTPS: EC2.IpPermission.https(EC2.IpPermission.Target.all)

exampleSecurityGroup: EC2.SecurityGroup.new {
  description: "Allow http/s traffic from the Internet",
  vpc: exampleVpc,
  ipPermissions: [sgHTTP, sgHTTPS]
}

If you compile this composition with lwc:

lwc InstanceCap.lw

You’ll see the following output:

ludwig (validation error):
  Validations failed:

    - Error: Too many EC2 instances. Limit: 1. Declared: 2
      (from InstanceCapValidation.checkInstanceCap)

Now, if you comment out one of the EC2 instance declarations and re-run lwc, you’ll see that the validation succeeds and the composition compiles successfully.

NodeStream Validation: Whitelisting

The NodeStream module allows whitelisting, which is a type of validation where you define a set of approved tags (like "Fugue.Core.AWS.EC2.Vpc", "Fugue.Core.AWS.S3.Bucket", or even custom types) and compare the tags in the nodestream against that list, and if any tag isn’t whitelisted, the validation fails.

For example, you can use whitelisting to write a validation that ensures only VPCs, subnets, security groups, and associated resources are allowed.

Download this validation module here.

The Validation

List<String> whitelist: [
    "Fugue.Core.AWS.EC2.Vpc",
    "Fugue.Core.AWS.EC2.Subnet",
    "Fugue.Core.AWS.EC2.DhcpOptions",
    "Fugue.Core.AWS.Common.AvailabilityZone",
    "Fugue.Core.AWS.EC2.SecurityGroup",
    "Fugue.Core.AWS.EC2.IpPermission",
    "Fugue.Core.AWS.EC2.IpPermissionTarget"
]

The first thing we do is set up a list, which we call whitelist. Each element of the list is the fully qualified name of a type we want to whitelist (as a string). We’ve listed several common AWS resources, but note that "Fugue.Core.AWS.EC2.Instance" is not included.

checkWhitelist: fun(ns):
    let offending: [
        tag for tag in NodeStream.getTags(ns)
        if !(List.any(fun(x): x == tag, whitelist))
    ]
    if List.isEmpty(offending) then
        Validation.success
    else
        Validation.failure("Prohibited tags: " ++ String.join(", ", offending))

validate checkWhitelist

The validation function, checkWhitelist, sets a local variable, offending. offending is a list populated by the tags returned by the NodeStream.getTags function if those tags are not in whitelist.

If there’s nothing in the offending list, the validation succeeds. Otherwise, the validation fails and lists the tags in offending. This allows you to see exactly which of the resources you declared in your composition weren’t whitelisted.

Let’s try compiling a composition that fails the above validation, because it declares an EC2 instance – which is not on the whitelist.

Download this composition here.

The Composition

import Fugue.AWS as AWS
import Fugue.AWS.EC2 as EC2
import ResourceWhitelistValidation as .

prohibitedInstance: EC2.Instance.new {
  subnet: exampleSubnet,
  blockDeviceMappings: [
    EC2.InstanceBlockDeviceMapping.new {
      deviceName: "/dev/sdz",
      ebs: EC2.EbsInstanceBlockDevice.new {
        volume: EC2.Volume.new {
          size: 1,
          availabilityZone: exampleSubnetAZ
        },
      },
    }
  ],
  image: "ami-a9d276c9",
  securityGroups: [exampleSecurityGroup],
  instanceType: EC2.M4_large
}


#########################
### PREREQUISITES
#########################

exampleVpc: EC2.Vpc.new {
  cidrBlock: "10.0.0.0/16",
  region: AWS.Us-west-2,
}

exampleSubnet: EC2.Subnet.new {
  cidrBlock: '10.0.1.0/24',
  vpc: exampleVpc,
  availabilityZone: exampleSubnetAZ
}

exampleSubnetAZ: AWS.A

sgHTTP: EC2.IpPermission.http(EC2.IpPermission.Target.all)

sgHTTPS: EC2.IpPermission.https(EC2.IpPermission.Target.all)

exampleSecurityGroup: EC2.SecurityGroup.new {
  description: "Allow http/s traffic from the Internet",
  vpc: exampleVpc,
  ipPermissions: [sgHTTP, sgHTTPS]
}

If you compile the composition with lwc ResourceWhitelist.lw, you’ll see the following output:

ludwig (validation error):
  Validations failed:

    - Prohibited tags: Fugue.Core.AWS.EC2.PrimaryInstanceNetworkInterface, Fugue.Core.AWS.EC2.Instance, Ludwig.Builtin.Optional
      (from ResourceWhitelistValidation.checkWhitelist)

Advanced Whitelisting

This is a more advanced validation that ensures multiple route tables are not associated to a single subnet. You can download the example composition here.

composition

import Ludwig.NodeStream as .
import Ludwig.Int as Int

# Example use-case:
# Don't allow multiple route tables to be associated to a single Subnet

## types ##

type Subnet:
    | Subnet
        cidrBlock: String
        vpc: Vpc

type Vpc:
    | Vpc
        cidrBlock: String
        region: Region

type Region:
    | Us-east-1
    | Us-west-1

type RouteTable:
    | RouteTable
        associations: Optional<List<Subnet>>
        vpc: Vpc

## values ##

Subnet subnet1:
    Subnet
        { cidrBlock: "10.0.0.0/16"
        , vpc: my-vpc
        }

RouteTable rt1:
    RouteTable
        { associations: [subnet1]
        , vpc: my-vpc
        }

fun makeIt(x: Int) -> RouteTable:
    if x == 0 then
        RouteTable
            { associations: [subnet1]
            , vpc: my-vpc
            }
    else
        makeIt(x - 1)

RouteTable rt2: makeIt(10)

my-vpc:
    Vpc
        { cidrBlock: "10.0.0.0/16"
        , region: Us-east-1
        }

Vpc my-vpc2: my-vpc with
    { Vpc.region: Us-west-1
    , Vpc.cidrBlock: "10.0.0.1/16"
    }

## validation ##

fun anyTrue(xs: List<Bool>) -> Bool:
    List.any(fun(bool): bool, xs)

fun checkOverlap(xs: List<Subnet>, ys: List<Subnet>) -> Bool:
    anyTrue(
        [valuesEqual(x, y) for (x, y) in List.cartesian-product(xs, ys)])

fun check-route-subnet-associations(ns: NodeStream) -> Validation:
    let fun checkPair(r1: RouteTable, r2: RouteTable) -> Validation:
                let subs1: Optional.unpack([], r1.RouteTable.associations)
                let subs2: Optional.unpack([], r2.RouteTable.associations)
                if checkOverlap(subs1, subs2) then
                    Validation.failure(
                        "RouteTables associate to the same Subnet:\n" ++
                        StackTrace.prettyTrace{value: r1, header: "- First value", indent: 6} ++ "\n\n" ++
                        StackTrace.prettyTrace{value: r2, header: "- Second value", indent: 6}
                    )
                else
                    Validation.success
    let List<Node<RouteTable>> routeTables: unsafeGetNodesByTag(ns, "RouteTable")
    if List.length(routeTables) > 0 then
        Validation.join(
            [ checkPair(r1.value, r2.value)
                for (r1, r2) in List.cartesian-product(routeTables, routeTables)
                if r1.id != r2.id
            ])
    else
        error("Expected some RouteTables but got none")

validate check-route-subnet-associations

If you compile this with lwc MultipleRouteTables.lw, you’ll see this output:

ludwig (validation error):
  Validations failed:

    - RouteTables associate to the same Subnet:
        - First value:

          39|     RouteTable
          40|         { associations: [subnet1]
          41|         , vpc: my-vpc
          42|         }

        In call to RouteTable at "MultipleRouteTables.lw" (line 39, column 5)

        - Second value:

          46|         RouteTable
          47|             { associations: [subnet1]
          48|             , vpc: my-vpc
          49|             }

        In call to RouteTable at "MultipleRouteTables.lw" (line 46, column 9)
        In call to makeIt at "MultipleRouteTables.lw" (line 51, column 9)
        In call to makeIt at "MultipleRouteTables.lw" (line 53, column 17)
      (from check-route-subnet-associations)

A Note On “Unsafe” Functions

A few Ludwig.NodeStream functions have the word “unsafe” in their names. This refers to type safety. We can certainly use these functions, but the burden is on the users to ensure we’re passing the right types – the compiler can only take us at our word.

For instance, here are two functions that illustrate unexpected behavior. Note the comments in each function.

fun check(ns: NodeStream) -> Validation:
  let List<Node<EC2.Instance>> allTheInstances: unsafeGetNodesByTag(ns, "Fugue.Core.AWS.S3.Bucket")

  # will fail when validation is evaluated because we take values of type
  # S3.Bucket and mistakenly treat them as EC2.Instance
  if List.any(fun(x): x.value.(EC2.Instance).instanceType == Optional(EC2.T1_micro), allTheInstances) then
    Validation.success
  else
    Validation.failure("")

This one will throw an error because we try to look at the the contents of the value as if it was EC2.Instance, but we actually have S3.Bucket:

ludwig (evaluation error):
  "composition/comp.lw" (line 14, column 23):
  Non-exhaustive patterns in case expression:

    14|   if List.any(fun(x): x.value.(EC2.Instance).instanceType == Optional(EC2.T1_micro), allTheInstances) then
                              ^^^^^^^^^^^^^^^^^^^^^^

  Couldn't match: Bucket {name: "my-bucket-123"
                         ,region: Us-east-1
                         ,tags: None
                         ,acl: None
                         ,policy: None
                         ,corsConfiguration: None
                         ,websiteConfiguration: None
                         ,loggingConfiguration: None
                         ,notificationConfiguration: None
                         ,lifecycleConfiguration: None
                         ,replicationConfiguration: None
                         ,versioningConfiguration: None
                         ,accelerateConfiguration: None
                         ,requestPaymentConfiguration: None}

  Stack trace:
    In call to test at "... lw/Ludwig/List.lw" (line 332, column 30)
    In call to any at "composition/comp.lw" (line 14, column 6)
    In call to check at "primitive" (line 1, column 1)

This one is trickier:

fun check(ns: NodeStream) -> Validation:
  let List<Node<EC2.Instance>> allTheInstances: unsafeGetNodesByTag(ns, "Fugue.Core.AWS.S3.Bucket")

  # will not fail at runtime (silent error!) because we only count the values
  # without examining them.
  if List.length(allTheInstances) > 0 then
    Validation.failure("Error: No EC2 instances allowed")
  else
    Validation.success

Here we are only counting the values, so the code will succeed. But we’re counting the wrong values! So, that’s a logic error.

To sum up: With unsafe functions, the compiler can’t enforce that the nodes returned really represent what you say they are. Make sure the string you specify matches the type you’re seeking.