Skip to content

Commit

Permalink
Add construct to bootstrap an entire project
Browse files Browse the repository at this point in the history
  • Loading branch information
chokoswitch committed Aug 9, 2024
1 parent 48fe6ba commit fb0de85
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 10 deletions.
149 changes: 149 additions & 0 deletions src/bootstrap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { RepositoryConfig } from "@cdktf/provider-github/lib/repository/index.js";
import { Team } from "@cdktf/provider-github/lib/team/index.js";
import { DataGoogleBillingAccount } from "@cdktf/provider-google/lib/data-google-billing-account/index.js";
import { DataGoogleOrganization } from "@cdktf/provider-google/lib/data-google-organization/index.js";
import { DnsManagedZone } from "@cdktf/provider-google/lib/dns-managed-zone/index.js";
import { DnsRecordSet } from "@cdktf/provider-google/lib/dns-record-set/index.js";
import { Fn, type TerraformProvider } from "cdktf";
import { Construct } from "constructs";
import { GcpProject } from "../gcp-project/index.js";
import { GitHubRepository } from "../github-repository/index.js";

export interface BootstrapConfig {
/** The name of the project being bootstrapped. */
name: string;

/** The ID of the GCP organization to provision projects in. Can be fetched by domain using {@link DataGoogleOrganization}. */
organizationId: string;

/** The ID of the billing account to provision projects in. Can be fetched by name using {@link DataGoogleBillingAccount}. */
billingAccountId: string;

/** The name of the GitHub organization to create repositories in. */
githubOrg: string;

/** The domain to serve the application on. */
domain?: string;

/** Custom configuration to override {@link GitHubRepository} defaults for app repo. */
appRepositoryConfig?: Omit<RepositoryConfig, "name">;

/** Custom configuration to override {@link GitHubRepository} defaults for infra repo. */
infraRepositoryConfig?: Omit<RepositoryConfig, "name">;

/** The google-beta provider to use to provision beta configuration of GCP projects. */
googleBeta: TerraformProvider;
}

/**
* Bootstrapping for a new project to satisfy curiosity with best practices.
*
* This resource creates
* - 3 GCP projects, all prefixed with the project name and hyphen
* - sysadmin: holds the bootstrapping configuration and shared resources like domains
* - dev: development environment for application
* - prod: production environment for application
* - 2 GitHub repositories
* - monorepo: Same name as project, holds all application code
* - infra: prefixed with the project name and hyphen, holds infrastructure configuration
* - 1 GitHub team
* - admins: prefixed with the project name and hyphen, has admin access to all repositories
*/
export class Bootstrap extends Construct {
/** The sysadmin GCP project. */
public readonly sysadminProject: GcpProject;

/** The dev GCP project. */
public readonly devProject: GcpProject;

/** The prod GCP project. */
public readonly prodProject: GcpProject;

/** The application monorepo. */
public readonly apprepo: GitHubRepository;

/** The infrastructure repository. */
public readonly infraRepo: GitHubRepository;

/** The github admins team. */
public readonly githubAdmins: Team;

constructor(scope: Construct, config: BootstrapConfig) {
super(scope, config.name);

this.sysadminProject = new GcpProject(this, {
projectId: `${config.name}-sysadmin`,
organizationId: config.organizationId,
billingAccountId: config.billingAccountId,
githubInfraRepo: `${config.githubOrg}/${config.name}-infra`,
githubEnvironment: "prod",
googleBeta: config.googleBeta,
});

this.devProject = new GcpProject(this, {
projectId: `${config.name}-dev`,
organizationId: config.organizationId,
billingAccountId: config.billingAccountId,
githubInfraRepo: `${config.githubOrg}/${config.name}-infra`,
githubEnvironment: "dev",
googleBeta: config.googleBeta,
});

this.prodProject = new GcpProject(this, {
projectId: `${config.name}-prod`,
organizationId: config.organizationId,
billingAccountId: config.billingAccountId,
githubInfraRepo: `${config.githubOrg}/${config.name}-infra`,
githubEnvironment: "prod",
googleBeta: config.googleBeta,
});

this.githubAdmins = new Team(this, "github-admins", {
name: `${config.name}-admins`,
description: `Administrators for the ${config.name} project`,
privacy: "closed",
});

this.apprepo = new GitHubRepository(this, {
name: config.name,
adminTeam: this.githubAdmins.id,
repositoryConfig: config.appRepositoryConfig,
});

this.infraRepo = new GitHubRepository(this, {
name: `${config.name}-infra`,
adminTeam: this.githubAdmins.id,
repositoryConfig: config.infraRepositoryConfig,
prodEnvironmentConfig: {
reviewers: [
{
teams: [Fn.tonumber(this.githubAdmins.id)],
},
],
},
});

if (config.domain) {
const alphaDnsZone = new DnsManagedZone(this, "alpha-dns-zone", {
project: this.devProject.project.projectId,
name: `alpha-${config.domain.replace(".", "-")}`,
dnsName: `alpha.${config.domain}.`,
});

const prodDnsZone = new DnsManagedZone(this, "prod-dns-zone", {
project: this.prodProject.project.projectId,
name: config.domain.replace(".", "-"),
dnsName: `${config.domain}.`,
});

new DnsRecordSet(this, "prod-alpha-ns-delegate", {
project: this.prodProject.project.projectId,
managedZone: prodDnsZone.name,
name: `alpha.${config.domain}.`,
type: "NS",
rrdatas: alphaDnsZone.nameServers,
ttl: 21600,
});
}
}
}
16 changes: 13 additions & 3 deletions src/gcp-project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ export class GcpProject extends Construct {
/** The created {@link Project}. */
public readonly project: Project;

/**
* The GCS bucket name to hold Terraform state for this project.
* Note, there is no way to automatically provision this bucket with a remote backend,
* so we always first use local state and then migrate. This bucket name is returned as
* a raw string for convenience but is not meant to be a dependency - it only is useful
* as the value to {@link GcsBackend}.
*/
public readonly tfstateBucketName: string;

/** The created {@link IamWorkloadIdentityPool} for authenticating from GitHub actions. */
public readonly githubIdentityPool: IamWorkloadIdentityPool;

Expand Down Expand Up @@ -111,7 +120,7 @@ export class GcpProject extends Construct {
provider: config.googleBeta,
});

const tfState = new StorageBucket(this, "tfstate", {
const tfstateBucket = new StorageBucket(this, "tfstate", {
project: this.project.projectId,
name: `${this.project.projectId}-tfstate`,
location: config.terraformStateLocation ?? "US",
Expand All @@ -120,6 +129,7 @@ export class GcpProject extends Construct {
enabled: true,
},
});
this.tfstateBucketName = `${config.projectId}-tfstate`;

// Commonly needed for executing certain Terraform actions with
// a user account.
Expand Down Expand Up @@ -168,7 +178,7 @@ export class GcpProject extends Construct {
},
);

new TerraformOutput(this, "github-identity-provider", {
new TerraformOutput(this, `github-identity-provider-${config.projectId}`, {
staticId: true,
value: idProvider.name,
});
Expand Down Expand Up @@ -254,7 +264,7 @@ export class GcpProject extends Construct {
// there is no such option. Generally we use permissions to protect against access to the infrastructure
// itself and not the state so this is probably acceptable.
new StorageBucketIamMember(this, "terraform-viewer-tfstate", {
bucket: tfState.name,
bucket: tfstateBucket.name,
role: "roles/storage.objectUser",
member: this.terraformViewerServiceAccount.member,
});
Expand Down
9 changes: 2 additions & 7 deletions src/github-repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export class GitHubRepository extends Construct {
allowMergeCommit: false,
allowSquashMerge: true,
allowRebaseMerge: false,
squashMergeCommitTitle: "PR_TITLE",
squashMergeCommitMessage: "BLANK",

...config.repositoryConfig,
});
Expand Down Expand Up @@ -191,13 +193,6 @@ export class GitHubRepository extends Construct {
protectedBranches: false,
customBranchPolicies: true,
},
reviewers: config.adminTeam
? [
{
teams: [Fn.tonumber(config.adminTeam)],
},
]
: undefined,
...config.prodEnvironmentConfig,
});

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./bootstrap/index.js";
export * from "./gcp-project/index.js";
export * from "./github-repository/index.js";

0 comments on commit fb0de85

Please sign in to comment.