Validation checks in AWS CDK constructs

/ Reading time: 13 minute/s

Working with quite a lot of AWS CDK for our prototypes, it's very important to be able to ensure our Stacks and Constructs are in a valid state at all times. When your infrastructure is layers upon layers of nested components, it's nice to have that guarantee at the back of your mind that your system is within expectations, and that none of our components are entering invalid configurations.

Yes we can write up unit and integration tests using something like Jest, but full-fledged tests can be a bit heavy-handed at times. Sometimes we just want to make sure our system is in an acceptable state right before deployment or synthesis. That's where validation checks can help best.


CDK constructs and .validate()

I surprisingly found very little on this topic from official documentation. It appears that all CDK constructs, when mapped in the construct tree, has a .validate() function, which the CDK uses to determine whether or not its in a valid state. This returns string[] which is a list of validation error messages --- if it's an empty list, then the construct is valid.

The .validate() function seems to be called by the CDK during stack synthesis (so, as part of a cdk synth operation). This also has the neat side effect that validation also occurs right before deployment actually happens on cdk deploy.

This lets us create some nice guardrails around our systems that prevents deployment if something is not as we'd expect, for example.

Adding validation checks

Of course, that's only useful if we can add validation checks in the first place. Turns out all CDK constructs have access to an .addValidation() hook through their .node accessor. .addValidation accepts an argument with the shape { validate: () => string[] }. The object's .validate() function is just a validation function that, again, returns a list of validation error messages. In a gist, construct.node.validate() calls the .validate() function of all validators added via .addValidation(), and collects the validation error messages. If there is at least one such error message, then the construct is in an invalid state (and synthesis ends with an error).

An example instead?

Let's create a construct that will trigger an event on a specified hour every day, just like a cron job. We'll use Amazon EventBridge to create a rule the fires on the specified hour --- we can then use this to, for example, invoke a Lambda function, or start a downstream process (like retraining an ML model).

We'll start with a simple scaffold:

import * as cdk from 'aws-cdk-lib'
import * as events from 'aws-cdk-lib/aws-events'
import { Construct } from 'constructs'

// :: ---

class ScheduleConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id)

    // :: This creates our daily rule.
    //    For now, this will trigger at 8:00 AM daily.
    const rule = new events.Rule(this, 'my-daily-rule', {
      schedule: events.Schedule.cron({
        hour: '8',
        minute: '0',
      }),
    })
  }
}

We could let this construct be configurable through props passed into it, so let's try that:

// ...

type ScheduleConstructProps {
  hour: string
}

class ScheduleConstruct extends Construct {
  constructor(scope: Construct, id: string) {
  constructor(scope: Construct, id: string, props: ScheduleConstructProps) {
    super(scope, id)

    // :: This creates our daily rule.
    //    For now, this will trigger at 8:00 AM daily.
    const rule = new events.Rule(this, 'my-daily-rule', {
      schedule: events.Schedule.cron({
        hour: '8',
        hour: props.hour,
        minute: '0'
      }),
    })
  }
}

Now, putting aside the possibility that events.Schedule.cron() does validation of its own (I haven't checked, to be honest), we'd want to ensure whatever we're passing into our construct is within acceptable bounds. Specifically, we want to make sure that props.hour is always gonna be an integer >= 0 and < 24, but in a string type. Otherwise, we'll have a construct trying to set a scheduled trigger at an unheard of time like 37:00 pm or something.

Let's add in validations

We can guard our construct from those cases by adding in validation checks:

// ...

class ScheduleConstruct extends Construct {
  constructor(scope: Construct, id: string, props: ScheduleConstructProps) {
    super(scope, id)

    // ...

    // :: Add our validation checks
    this.node.addValidation({
      validate: () => {
        const messages: string[] = []
        const hourInteger = Number.parseInt(props.hour)

        if (Number.isNaN(hourInteger)) messages.push('Hour provided is not a valid integer.')
        if (hourInteger < 0) messages.push('Hour must be non-negative.')
        if (hourInteger >= 24) messages.push('Hour must be less than 24.')

        return messages
      },
    })
  }
}

Now that our validation checks are in place, any time you try to run cdk synth or cdk deploy with an invalid provided hour value, you'll get an error message, and the CDK will refuse to progress.

For example, if I try:

new ScheduleConstruct(this, 'my-schedule', {
  hour: '30',
})
λ  cdk synth
# ...
Error: Validation failed with the following errors:
  [CdkTestStack/my-schedule] Hour must be less than 24.

And my mind is more at ease as a result.