awslabs / green-boost

Build Full Stack Cloud Native Web Apps on AWS Fast
https://awslabs.github.io/green-boost/
Apache License 2.0
70 stars 8 forks source link

RFC: infra workspace updates #172

Closed bestickley closed 1 year ago

bestickley commented 1 year ago

gboost create sets up an opinionated infra workspace (folder) which defines a CDK app. I'd like to propose a new alternative way based on this article which is based off CDK Best Practices. Please read those documents first before reading this proposal.

For folder structure, currently GB has:

Based off above resources I propose:

For configuration, currently GB assumes developers will have 3 stages: dev, test, and prod and that developers are ok with Green Boost deciding what configuration (within GB Constructs) is correct for these stages. This is an issue because developers often have more than 3 stages and they're often named differently. Even if we could use stage name as an abstraction point, we shouldn't because for developers to understand what the configuration is per GB construct, they'll have to hunt through source code. It's best to be explicit and let developers define configuration within their source code. We'll leverage the power of TypeScript intellisense to make this as easy as possible. Overall, this means stacks and constructs won't even need to know the stage name anymore because conditional infra config will be defined at the top level within a config/ folder. All configuration will be explicitly passed into stacks. This keeps in line with best practice

In general, environment variable lookups should be limited to the top level of an AWS CDK app. They should also be used to pass in information that’s needed for running in a development environment

The purpose of local-app.ts is so that developers can quickly synth and deploy their apps locally. This CDK App will reference app-stage.ts which abstracts how stacks are instantiated so that it local-app.ts and pipeline-app.ts can use stacks in the same way. cdk.json#app value will reference local-app.ts so developer can more easily deploy app. When deploying pipeline-app, we will utilize "app" CLI parameter to reference correct entrypoint.

I propose we generate a src/config folder for each gboost create template. Within this folder we'll have:

src/config/stage-config.ts

import { GbDefaults } from "gboost-infra";
import { Environment } from "aws-cdk-lib";

/**
* Each stage name corresponds to stage. Local is for developer to deploy locally where all other stages are pipeline stages.
*/
export enum StageName {
  Local = "Local",
  Dev = "Dev",
  Test = "Test",
  Prod = "Prod",
};

/**
* Configuration per stage.
*/
export class StageConfig {
  stageName: StageName;
  appId = "gb"; // comes from `gboost create` package scope question/answer
  account: string;
  region: string;
  env: Environment;
  isLocal: boolean;
  gbDefaults: GbDefaults

  constructor(params: StageConfig) {
    this.stageName = params.stageName;
    this.account = params.account;
    this.region = params.region;
    this.env = { account: this.account, region: this.region };
    this.isLocal = this.stageName === StageName.Local;
    const baseDefaults: GbDefaults = {
      function: {
        logRetention: RetentionDays.SIX_MONTHS,
        environment: {
          POWERTOOLS_SERVICE_NAME: "Green Boost",
          STAGE_NAME: this.stageName,
        }
      },
      bucket: {
        encryption: BucketEncryption.S3_MANAGED,
      },
    };
    this.gbDefaults = deepmerge(baseDefaults, params.gbDefaults);
  }

  getStackId(stackName: string) {
    const localStageName = process.env["STAGE_NAME"];
    if (this.isLocal && !localStageName) {
      throw new Error("Environment Variable STAGE_NAME required when StageName.Local");
    }
    const stageName = this.isLocal ? this.stageName || localStageName;
    return `${this.appId}-${this.stageName}-${stackName}`;
  }
}

Note, above we're using stageName instead of stage to refer to stage name. This is more explicit and will hopefully be less confusing because Stage is abstract application modeling unit in CDK.

src/config/local.ts

export const localConfig = new StageConfig({
  stageName: StageName.Local,
  account: String(process.env["CDK_DEFAULT_ACCOUNT"]),
  region: String(process.env["CDK_DEFAULT_REGION"]),
  gbDefaults: {
    function: {
      environment: {
        POWERTOOLS_LOGGER_LOG_EVENT:"true",
      },
      logRetention: RetentionDays.ONE_MONTH,
    },
    postgresCluster: {
      readInstances: 1,
    },
  },
  // ... any other infra config
});

src/config/test.ts

export const testConfig = new StageConfig({
  stageName: StageName.Test,
  account: "1234567890",
  region: "us-east-1",
  gbDefaults: {
    function: {
      logRetention: RetentionDays.TWO_MONTHS,
    },
  postgresCluster: {
    readInstances: 1,
  },
  // ... any other infra config
});

src/app-stage.ts

// imports ignored for brevity
export class AppStage extends Stage {
  constructor(scope: Construct, id: string, stageConfig: StageConfig) {
    super(scope, id, stageConfig);
    setGbDefaults(stageConfig.gbDefaults);
    const dataStack = new Data(this, "data", { env: stageConfig.env });
    const apiStack = new Api(this, "api", {
      env: stageConfig.env,
      table: dataStack.table,
    });
    new Ui(this, "ui", { env: stageConfig.env, api: apiStack.api });

    Tags.of(this).add("appId", stageConfig.appId);
    Aspects.of(this).add(new SuppressOkNags());
    Aspects.of(this).add(new AwsSolutionsChecks());
  }
}

src/local-app.ts

// imports ignored for brevity
const app = new App();
new AppStage(app, StageName.Local, localConfig);

src/pipeline/pipeline-app.ts

// imports ignored for brevity
const app = new App();
new PipelineStack(app, "gb-pipeline", { env: devConfig.env });

src/pipeline/pipeline-stack.ts

// imports ignored for brevity
export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);
    const pipeline = new CodePipeline(this, "CdkPipeline", {
      crossAccountKeys: true,
      selfMutation: true,
      synth: new ShellStep("ShellStep", {
        input: // TODO: make this dynamic based on CodeCommit or GitHub?
        installCommands: ["curl -fsSL https://get.pnpm.io/install.sh", "pnpm ci"],
        commands: ["cd infra", 'cdk --app "./node_modules/.bin/vite-node src/pipeline-app.ts" synth '],
        primaryOutputDirectory: "./infra/cdk.out",
    });

    const devStage = new AppStage(this, StageName.Dev, devConfig);
    pipeline.addStage(devStage); // TODO: add health check

    const testStage = new AppStage(this, StageName.Test, testConfig);
    pipeline.addStage(testStage); // TODO: add health check

    const prodStage = new AppStage(this, StageName.Prod, prodConfig);
    pipeline.addStage(prodStage); // TODO: add health check
  }
}

The benefit of generating is that developers can change this to fit their needs.

Outstanding Questions:

TODO:

bestickley commented 1 year ago

How would setGbDefaults work? Something like:

gb-defaults.ts

interface GbDefaults { ... }
export const gbDefaults = {};
export function setGbDefaults(defaults: GbDefaults) {
  deepmerge(gbDefaults, defaults);
}

function.ts

import { gbDefaults } from "./gb-defaults";
export class Function {
  constructor(scope: Construct, id: string, props: FunctionProps) {
    const newProps = deepmerge(gbDefaults, props);
    ...
  }
}
tom-dennis-aws commented 1 year ago

Outstanding Questions: