Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ require (
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/backup v1.60.0 // indirect
github.com/aws/aws-sdk-go-v2/service/codeartifact v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.9 h1:QoVH26Oz0
github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.9/go.mod h1:cEODDbhXiLzTqklqGNKe/VQWW4F551+Jo6BEfL1dYQc=
github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0 h1:1KzQVZi7OTixxaVJ8fWaJAUBjme+iQ3zBOCZhE4RgxQ=
github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0/go.mod h1:I1+/2m+IhnK5qEbhS3CrzjeiVloo9sItE/2K+so0fkU=
github.com/aws/aws-sdk-go-v2/service/backup v1.60.0 h1:VLP4QZIlp/WkUfKCiju0vxHd0bhQwRf9EF6Hi3+v4gc=
github.com/aws/aws-sdk-go-v2/service/backup v1.60.0/go.mod h1:QLSwdpaVsnD2HFCdLq3EGh5Op3M2BloVkFqjh5SOmDg=
github.com/aws/aws-sdk-go-v2/service/batch v1.68.2 h1:Ngy4smx6Fl429OyEdB7cpUI4C6ZKngmY6P3kKcGWVWo=
github.com/aws/aws-sdk-go-v2/service/batch v1.68.2/go.mod h1:g5szqfCT3pGgkAS2risOA5p5ocpIss7ykS2OuiZdhJg=
github.com/aws/aws-sdk-go-v2/service/cloudfront v1.58.3 h1:/nyo0QD97D5VQQL/UE+rKGNKz+BesiqJgjdmp0qtTOQ=
Expand Down
4 changes: 4 additions & 0 deletions integ/aws/storage/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ redshift.cluster: ## Test Redshift Cluster L2 (live single-node ra3.large cluste
bucket-notifications: ## Test S3 Bucket with EventBridge Notifications
go test -v -count 1 -timeout 15m ./... -run ^TestBucketNotifications$
.PHONY: bucket-notifications

backup.plan: ## Test Backup Plan/Vault/Selection L2s (live plan over a DynamoDB table)
go test -v -count 1 -timeout 30m ./... -run ^TestBackupPlan$
.PHONY: backup.plan
77 changes: 77 additions & 0 deletions integ/aws/storage/apps/backup.plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Live test for the storage.backup L2s: a real BackupVault + BackupPlan (daily
// rule via the static factory + a rule added AFTER construction — the
// block-typed-Lazy footgun proven live) + BackupSelection over a DynamoDB
// table and a tag condition. Validates the aws_backup_plan rule rendering,
// the standalone vault split-off resources, and the selection's IAM
// role/resource-ARN wiring against live AWS.
import { App, LocalBackend, TerraformOutput } from "cdktn";
import { aws } from "../../../../src";

const environmentName = process.env.ENVIRONMENT_NAME ?? "test";
const region = process.env.AWS_REGION ?? "us-east-1";
const outdir = process.env.OUT_DIR ?? "cdktf.out";
const stackName = process.env.STACK_NAME ?? "backup.plan";

const app = new App({
outdir,
});

const stack = new aws.AwsStack(app, stackName, {
gridUUID: "gdddddddd-dddd",
environmentName,
providerConfig: {
region,
},
});
new LocalBackend(stack, {
path: `${stackName}.tfstate`,
});

const table = new aws.storage.Table(stack, "Table", {
partitionKey: { name: "pkey", type: aws.storage.AttributeType.STRING },
});

const vault = new aws.storage.backup.BackupVault(stack, "Vault", {
// Terraform-native replacement for upstream removalPolicy: allow clean destroy.
forceDestroy: true,
});

// Static factory (daily rule at construction) ...
const plan = aws.storage.backup.BackupPlan.daily35DayRetention(
stack,
"Plan",
vault,
);
// ... plus a rule added AFTER construction: the live proof that the
// Lazy.anyValue rule-block design resolves post-construction accumulation.
plan.addRule(aws.storage.backup.BackupPlanRule.weekly());

const selection = plan.addSelection("Selection", {
resources: [
aws.storage.backup.BackupResource.fromDynamoDbTable(table),
aws.storage.backup.BackupResource.fromTag("stage", "prod"),
],
});

new TerraformOutput(stack, "backup_plan_id", {
value: plan.backupPlanId,
staticId: true,
});
new TerraformOutput(stack, "backup_vault_name", {
value: vault.backupVaultName,
staticId: true,
});
new TerraformOutput(stack, "backup_vault_arn", {
value: vault.backupVaultArn,
staticId: true,
});
new TerraformOutput(stack, "selection_id", {
value: selection.selectionId,
staticId: true,
});
new TerraformOutput(stack, "table_arn", {
value: table.tableArn,
staticId: true,
});

app.synth();
84 changes: 84 additions & 0 deletions integ/aws/storage/backup_plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package test

import (
"context"
"testing"

awsbackup "github.com/aws/aws-sdk-go-v2/service/backup"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/gruntwork-io/terratest/modules/terraform"
test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
"github.com/stretchr/testify/require"
)

// Run the apps/backup.plan.ts integration test: a real BackupVault +
// BackupPlan + BackupSelection over a DynamoDB table through the
// storage.backup L2s. Validates the plan's rules read-back (incl. the rule
// added AFTER construction — the block-typed-Lazy design), the vault, the
// selection's resources/tag conditions, and the post-apply drift oracle.
func TestBackupPlan(t *testing.T) {
runStorageIntegrationTest(t, "backup.plan", "us-east-1", validateBackupPlan)
}

func validateBackupPlan(t *testing.T, tfWorkingDir string, awsRegion string) {
terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir)
outputs := terraform.OutputAll(t, terraformOptions)

planID := outputs["backup_plan_id"].(string)
vaultName := outputs["backup_vault_name"].(string)
vaultArn := outputs["backup_vault_arn"].(string)
selectionID := outputs["selection_id"].(string)
tableArn := outputs["table_arn"].(string)

ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion))
require.NoError(t, err)
client := awsbackup.NewFromConfig(cfg)

// --- 1. Plan read-back: BOTH rules land (daily from the static factory,
// weekly from post-construction addRule() — the Lazy rule-block proof). ---
gp, err := client.GetBackupPlan(ctx, &awsbackup.GetBackupPlanInput{
BackupPlanId: &planID,
})
require.NoError(t, err)
require.Equal(t, "Plan", *gp.BackupPlan.BackupPlanName)
require.Len(t, gp.BackupPlan.Rules, 2)
retentionByRule := map[string]int64{}
for _, r := range gp.BackupPlan.Rules {
require.Equal(t, vaultName, *r.TargetBackupVaultName,
"every rule must target the fixture vault")
require.NotNil(t, r.Lifecycle)
retentionByRule[*r.RuleName] = *r.Lifecycle.DeleteAfterDays
}
require.Equal(t, int64(35), retentionByRule["Daily"], "constructor-path rule must reach AWS")
require.Equal(t, int64(90), retentionByRule["Weekly"], "post-construction addRule() rule must reach AWS (Lazy rule-block design)")
t.Logf("backup-plan: %s has rules Daily(35d)+Weekly(90d) targeting vault %s", planID, vaultName)

// --- 2. Vault read-back. ---
dv, err := client.DescribeBackupVault(ctx, &awsbackup.DescribeBackupVaultInput{
BackupVaultName: &vaultName,
})
require.NoError(t, err)
require.Equal(t, vaultArn, *dv.BackupVaultArn)
t.Logf("backup-plan: vault %s exists (%s)", vaultName, vaultArn)

// --- 3. Selection read-back: table ARN + tag condition + IAM role. ---
gs, err := client.GetBackupSelection(ctx, &awsbackup.GetBackupSelectionInput{
BackupPlanId: &planID,
SelectionId: &selectionID,
})
require.NoError(t, err)
require.Contains(t, gs.BackupSelection.Resources, tableArn,
"fromDynamoDbTable must render the table ARN into the selection")
require.Len(t, gs.BackupSelection.ListOfTags, 1)
require.Equal(t, "stage", *gs.BackupSelection.ListOfTags[0].ConditionKey)
require.Equal(t, "prod", *gs.BackupSelection.ListOfTags[0].ConditionValue)
require.NotEmpty(t, *gs.BackupSelection.IamRoleArn)
t.Logf("backup-plan: selection %s covers table %s + tag stage=prod via role %s",
selectionID, tableArn, *gs.BackupSelection.IamRoleArn)

// --- Drift oracle: re-planning the already-applied stack must show zero changes. ---
planExitCode := terraform.PlanExitCode(t, terraformOptions)
require.Equal(t, terraform.DefaultSuccessExitCode, planExitCode,
"expected `tofu plan -detailed-exitcode` to report no drift after apply (got exit code %d)", planExitCode)
}
99 changes: 99 additions & 0 deletions src/aws/storage/backup/backupable-resources-collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/backupable-resources-collector.ts

import {
dbInstance,
dynamodbTable,
ebsVolume,
instance as ec2Instance,
rdsCluster,
} from "@cdktn/provider-aws";
import { IAspect } from "cdktn";
import { IConstruct } from "constructs";
import { ArnFormat } from "../../arn";
import { AwsStack } from "../../aws-stack";

/**
* TERRACONSTRUCTS DEVIATION: upstream walks the construct tree matching CloudFormation L1 types
* (`efs.CfnFileSystem`, `dynamodb.CfnTable`, `ec2.CfnInstance`, `ec2.CfnVolume`,
* `rds.CfnDBInstance`, `rds.CfnDBCluster`) via an `Aspect`. This repo has no CFN layer, so
* matching is done against the Terraform L1 resource classes from `@cdktn/provider-aws` instead
* (`dynamodbTable.DynamodbTable`, `instance.Instance`, `ebsVolume.EbsVolume`,
* `dbInstance.DbInstance`, `rdsCluster.RdsCluster`).
*
* `rds.CfnDBInstance`'s "only add an ARN if this instance is not an Aurora cluster member"
* (`!dbInstance.dbClusterIdentifier`) guard has no Terraform equivalent to port: Aurora cluster
* member instances are provisioned via the entirely separate `aws_rds_cluster_instance` resource
* (`rdsClusterInstance.RdsClusterInstance`, used by `../rds/cluster.ts`) rather than
* `aws_db_instance` -- every `dbInstance.DbInstance` in the tree is by construction a standalone
* instance, matching `../rds/instance.ts`'s `DatabaseInstance`. `aws_rds_cluster_instance` itself
* is intentionally not matched here, mirroring upstream not emitting a separate ARN per cluster
* member (the cluster-level ARN from `rdsCluster.RdsCluster` already covers the whole cluster).
*
* TODO: omitted -- upstream also matches `efs.CfnFileSystem`. EFS (`aws-efs`) has not been ported
* to this repo yet (no `storage/efs` module exists) -- see the identical omission on
* `BackupResource.fromEfsFileSystem` in `./resource.ts` --
* https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/backupable-resources-collector.ts#L9-L14
*/
export class BackupableResourcesCollector implements IAspect {
public readonly resources: string[] = [];

public visit(node: IConstruct) {
if (node instanceof dynamodbTable.DynamodbTable) {
this.resources.push(
AwsStack.ofAwsConstruct(node).formatArn({
service: "dynamodb",
resource: "table",
resourceName: node.id,
}),
);
}

if (node instanceof ec2Instance.Instance) {
this.resources.push(
AwsStack.ofAwsConstruct(node).formatArn({
service: "ec2",
resource: "instance",
resourceName: node.id,
}),
);
}

if (node instanceof ebsVolume.EbsVolume) {
this.resources.push(
AwsStack.ofAwsConstruct(node).formatArn({
service: "ec2",
resource: "volume",
resourceName: node.id,
}),
);
}

if (node instanceof dbInstance.DbInstance) {
this.resources.push(
AwsStack.ofAwsConstruct(node).formatArn({
service: "rds",
resource: "db",
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
// TERRACONSTRUCTS DEVIATION: `aws_db_instance.id` is the RDS DBI resource ID
// (`db-ABCDEFGHIJK...`) in terraform-provider-aws v5+, not the DB instance
// identifier -- the identifier moved to the separate `identifier` attribute. Use
// `.identifier` here to match `../rds/instance.ts`'s `DatabaseInstance.instanceArn`
// (see `resourceName: instanceIdentifier` there), keeping both ARN-derivation paths
// consistent. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance
resourceName: node.identifier,
}),
);
}

if (node instanceof rdsCluster.RdsCluster) {
this.resources.push(
AwsStack.ofAwsConstruct(node).formatArn({
service: "rds",
resource: "cluster",
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
resourceName: node.id,
}),
);
}
}
}
14 changes: 14 additions & 0 deletions src/aws/storage/backup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/index.ts

export * from "./vault";
export * from "./plan";
export * from "./rule";
export * from "./selection";
export * from "./resource";

// TODO: omitted — upstream also re-exports the generated CFN L1 (`./backup.generated`, i.e.
// `CfnBackupPlan`/`CfnBackupSelection`/`CfnBackupVault`/...). This repo has no
// CloudFormation-generated L1 layer to re-export (Terraform L1s come from `@cdktn/provider-aws`
// instead, already consumed directly by `./vault.ts`/`./plan.ts`/`./selection.ts`) — identical
// omission to every other ported module in this repo (e.g. `../docdb/index.ts`) —
// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/index.ts#L6
Loading
Loading