open-constructs / aws-cdk-library

Community-Driven CDK Construct Library
Apache License 2.0
44 stars 6 forks source link

ConstructTreeSearch class #1

Closed michanto closed 1 week ago

michanto commented 3 months ago

The Construct Tree in the CDK is an underused asset of the framework. The CDK contains lots of code for dealing with RunTimeTypeInfo, searching the construct tree, storing data in the construct tree, etc. Much of this is hidden from the CDK user, and should be abstracted out into reusable classes that expose the full suite of techniques pioneered by the CDK team.

A good start is ConstructTreeSearch, which allows a user to navigate the construct tree the same way the CDK does.

import { IConstruct } from 'constructs';

/**
 * Generalized little-l lambda for a construct.
 */
export interface IConstructPredicate<T> {
  (scope: IConstruct): T;
}

/**
 * Defines where to stop when navigating the construct tree.
 * If not provided, we stop either at the top or bottom of the tree (depending
 * on search direction).
 */
export interface IStopCondition extends IConstructPredicate<boolean> {
}

/**
 * A construct predicate type assertion.
 *
 * Enables using CDK XXX.isXXX methods with ConstructTreeSearch and
 * IConstructPredicate.
 * Such as CfnElement.isCfnElement or Stack.isStack.
 *
 * Usage:
 */
export interface IConstuctTest<T extends IConstruct> {
  (x: IConstruct): x is T
}

/**
 * Searches the construct tree based on predicate and stopConditions.
 *
 * Three searches are supported: {@link searchSelf}, {@link searchDown}
 * and {@link searchUp}.
 *
 * QueryResult should either be, or contain (as a property), the construct itself,
 * so you know which construct to associate with the query result.
 */
export class ConstructTreeSearch<QueryResult> {
  constructor(readonly predicate: IConstructPredicate<QueryResult | undefined>) { }

  /**
   * Helper for finding constructs using ConstructTreeSearch with XXX.isXXX functions
   * (such as Stack.isStack and CfnElement.isCfnElement).  Returns a construct predicate
   * that itself returns only the construct, as opposed to ConstructService which returns
   * both the construct and the service.
   *
   * @param test Test to use when finding constructs.
   * @returns Construct predicate for the test.
   */
  static for<T extends IConstruct>(test: IConstuctTest<T>) {
    return new ConstructTreeSearch((scope: IConstruct) => test(scope) ? scope as T: undefined)
  }

  /**
   * Returns T or undefined for the scope, based on predicate.
   * @param scope
   */
  public searchSelf(scope: IConstruct) {
    return this.predicate(scope);
  }

  /**
   * Returns array of results based on predicate, searching the sub-tree
   * starting at scope.  This abstracts the logic from the cfnElements
   * function in Stack.ts (https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/stack.ts)
   *
   * @param scope Start for search.
   * @param stopCondition End for search (such as sub stack)
   * @param into Array of results.  Same as return value.
   */
  public searchDown(scope: IConstruct, stopCondition?: IStopCondition, into: QueryResult[] = []): QueryResult[] {
    let foundOne = this.searchSelf(scope);
    if (foundOne) {
      into.push(foundOne);
    }

    for (const child of scope.node.children) {
      if (stopCondition && stopCondition(child)) { continue; }

      // Include all descendents
      this.searchDown(child, stopCondition, into);
    }

    return into;
  }

  /**
   * Check the hierarchy to see if there is an ascendent object of scope
   * that matches the predicate.  This is how Stack.of works in the CDK.
   *
   * Uses stopCondition to decide where to stop the searchUp, defaults to root.
   */
  public searchUp(scope: IConstruct, stopCondition?: IStopCondition): QueryResult | undefined {
    let cache = this.searchSelf(scope);
    if (cache) {
      return cache;
    }

    if (stopCondition && stopCondition(scope)) {
      // We've reached the stop point and did not find it.  The stop point itself may have the service.
      return undefined;
    }

    const newScope: IConstruct | undefined = scope.node.scope;

    // Stop once we get to the top of the hierarchy.
    return newScope ? this.searchUp(newScope, stopCondition) : undefined;
  }
}

Tests for this class will ensue that Stack, CfnElement, and Resource classes can be found in the Construct tree using predicates and exercise the searchSelf, searchDown, and searchUp methods. They will also exercise the stopCondition for restricting searchUp to the current stack, and searchDown to exclude sub stacks.

michanto commented 3 months ago

Example usage: Find all stacks (including sub stacks) in the construct tree that are deployed to eu-west-1:

function printFoundUnder<T extends Construct>(
  search: ConstructTreeSearch<T>, scope: Construct, stopCondition?: IStopCondition) {

  let found = search.searchDown(scope, stopCondition)
  console.log(`Found ${found.length} under '${
    scope.node.path == undefined || scope.node.path.length == 0 ? "App" : scope.node.path}'`)
  found.forEach(c => console.log("  " + c.node.path))
}

export function isStackForRegion(region: string) {
  return (x: IConstruct ): x is Stack =>
    Stack.isStack(x) && x.region == region
}

let dubStacksSearch = ConstructTreeSearch.for(isStackForRegion("eu-west-1"))
printFoundUnder(dubStacksSearch, app)
mbonig commented 1 month ago

Do you for-see any usage for this directly in a CDK application?

michanto commented 1 week ago

This will be available in the cdk-orchestration package.