From eba99168b38367217c1c07811a48ca0b03f0f1b1 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Fri, 7 Aug 2026 09:40:43 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(aws):=20storage.backup=20=E2=80=94=20c?= =?UTF-8?q?omplete=20aws-backup=20port=20at=20v2.263.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full file-for-file port of aws-cdk-lib/aws-backup into the storage.backup sub-namespace: BackupPlan (+ static retention factories), BackupPlanRule, BackupResource, BackupSelection, BackupVault, backupable-resources-collector. - plan.ts: rule blocks fed via Lazy.anyValue mapping the addRule() accumulator through backupPlanRuleToTerraform at synth (4th block-typed-Lazy footgun instance — table.ts GSI idiom; regression test proves post-construction addRule() lands in synthesized JSON), omitEmptyArray per house idiom - vault.ts: accessPolicy/notifications/lockConfiguration split onto standalone aws_backup_vault_policy / _notifications / _lock_configuration resources (provider 6.x shape — documented deviations); encryptionKey → kms_key_arn - rule.ts: events.Schedule → notify.Schedule; upstream validation set verbatim - resource.ts: fromDynamoDbTable/fromRdsDatabaseInstance/fromRdsDatabaseCluster/ fromRdsServerlessCluster/fromEc2Instance/fromTag/fromArn/fromConstruct; fromEfsFileSystem TODO-omitted (EFS not ported) - collector adapted from Cfn resource types to TerraformResource types via Aspects; unported types TODO-omitted, none silently dropped - 59 unit tests across plan/vault/selection suites --- .../backup/backupable-resources-collector.ts | 99 +++ src/aws/storage/backup/index.ts | 14 + src/aws/storage/backup/plan.ts | 343 ++++++++++ src/aws/storage/backup/resource.ts | 164 +++++ src/aws/storage/backup/rule.ts | 286 +++++++++ src/aws/storage/backup/selection.ts | 205 ++++++ src/aws/storage/backup/vault.ts | 555 ++++++++++++++++ src/aws/storage/index.ts | 3 + test/aws/storage/backup/plan.test.ts | 603 ++++++++++++++++++ test/aws/storage/backup/selection.test.ts | 422 ++++++++++++ test/aws/storage/backup/vault.test.ts | 598 +++++++++++++++++ 11 files changed, 3292 insertions(+) create mode 100644 src/aws/storage/backup/backupable-resources-collector.ts create mode 100644 src/aws/storage/backup/index.ts create mode 100644 src/aws/storage/backup/plan.ts create mode 100644 src/aws/storage/backup/resource.ts create mode 100644 src/aws/storage/backup/rule.ts create mode 100644 src/aws/storage/backup/selection.ts create mode 100644 src/aws/storage/backup/vault.ts create mode 100644 test/aws/storage/backup/plan.test.ts create mode 100644 test/aws/storage/backup/selection.test.ts create mode 100644 test/aws/storage/backup/vault.test.ts diff --git a/src/aws/storage/backup/backupable-resources-collector.ts b/src/aws/storage/backup/backupable-resources-collector.ts new file mode 100644 index 00000000..9533dc69 --- /dev/null +++ b/src/aws/storage/backup/backupable-resources-collector.ts @@ -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, + }), + ); + } + } +} diff --git a/src/aws/storage/backup/index.ts b/src/aws/storage/backup/index.ts new file mode 100644 index 00000000..96688975 --- /dev/null +++ b/src/aws/storage/backup/index.ts @@ -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 diff --git a/src/aws/storage/backup/plan.ts b/src/aws/storage/backup/plan.ts new file mode 100644 index 00000000..c94e4ecb --- /dev/null +++ b/src/aws/storage/backup/plan.ts @@ -0,0 +1,343 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/plan.ts + +import { backupPlan } from "@cdktn/provider-aws"; +import { Lazy } from "cdktn"; +import { Construct } from "constructs"; +import type { BackupPlanCopyActionProps } from "./rule"; +import { BackupPlanRule } from "./rule"; +import type { BackupSelectionOptions } from "./selection"; +import { BackupSelection } from "./selection"; +import type { IBackupVault } from "./vault"; +import { BackupVault } from "./vault"; +import { ValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; + +/** + * A backup plan + */ +export interface IBackupPlan extends IAwsConstruct { + /** + * The identifier of the backup plan. + * + * @attribute + */ + readonly backupPlanId: string; + + /** + * The ARN of the backup plan + * + * @attribute + */ + readonly backupPlanArn: string; +} + +/** + * Properties for a BackupPlan + */ +export interface BackupPlanProps extends AwsConstructProps { + /** + * The display name of the backup plan. + * + * @default - A CDK generated name + */ + readonly backupPlanName?: string; + + /** + * The backup vault where backups are stored + * + * @default - use the vault defined at the rule level. If not defined a new + * common vault for the plan will be created + */ + readonly backupVault?: IBackupVault; + + /** + * Rules for the backup plan. Use `addRule()` to add rules after + * instantiation. + * + * @default - use `addRule()` to add rules + */ + readonly backupPlanRules?: BackupPlanRule[]; + + /** + * Enable Windows VSS backup. + * + * @see https://docs.aws.amazon.com/aws-backup/latest/devguide/windows-backups.html + * + * @default false + */ + readonly windowsVss?: boolean; +} + +/** + * A backup plan + */ +export class BackupPlan extends AwsConstructBase implements IBackupPlan { + /** + * Import an existing backup plan + */ + public static fromBackupPlanId( + scope: Construct, + id: string, + backupPlanId: string, + ): IBackupPlan { + class Import extends AwsConstructBase implements IBackupPlan { + public readonly backupPlanId = backupPlanId; + public get backupPlanArn(): string { + return this.stack.formatArn({ + service: "backup", + resource: "backup-plan", + resourceName: this.backupPlanId, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + } + public get outputs(): Record { + return { + backupPlanId: this.backupPlanId, + backupPlanArn: this.backupPlanArn, + }; + } + } + return new Import(scope, id); + } + + /** + * Daily with 35 day retention + */ + public static daily35DayRetention( + scope: Construct, + id: string, + backupVault?: IBackupVault, + ) { + const plan = new BackupPlan(scope, id, { backupVault }); + plan.addRule(BackupPlanRule.daily()); + return plan; + } + + /** + * Daily and monthly with 1 year retention + */ + public static dailyMonthly1YearRetention( + scope: Construct, + id: string, + backupVault?: IBackupVault, + ) { + const plan = new BackupPlan(scope, id, { backupVault }); + plan.addRule(BackupPlanRule.daily()); + plan.addRule(BackupPlanRule.monthly1Year()); + return plan; + } + + /** + * Daily, weekly and monthly with 5 year retention + */ + public static dailyWeeklyMonthly5YearRetention( + scope: Construct, + id: string, + backupVault?: IBackupVault, + ) { + const plan = new BackupPlan(scope, id, { backupVault }); + plan.addRule(BackupPlanRule.daily()); + plan.addRule(BackupPlanRule.weekly()); + plan.addRule(BackupPlanRule.monthly5Year()); + return plan; + } + + /** + * Daily, weekly and monthly with 7 year retention + */ + public static dailyWeeklyMonthly7YearRetention( + scope: Construct, + id: string, + backupVault?: IBackupVault, + ) { + const plan = new BackupPlan(scope, id, { backupVault }); + plan.addRule(BackupPlanRule.daily()); + plan.addRule(BackupPlanRule.weekly()); + plan.addRule(BackupPlanRule.monthly7Year()); + return plan; + } + + public readonly backupPlanId: string; + + /** + * The ARN of the backup plan + * + * @attribute + */ + public readonly backupPlanArn: string; + + /** + * Version Id + * + * @attribute + */ + public readonly versionId: string; + + public get outputs(): Record { + return { + backupPlanId: this.backupPlanId, + backupPlanArn: this.backupPlanArn, + versionId: this.versionId, + }; + } + + private readonly resource: backupPlan.BackupPlan; + + /** + * Accumulator for rules added via `addRule()`, rendered into the `rule` block lazily at synth + * time -- see the TERRACONSTRUCTS DEVIATION note below. + */ + private readonly rules = new Array(); + + private _backupVault?: IBackupVault; + + constructor(scope: Construct, id: string, props: BackupPlanProps = {}) { + super(scope, id, props); + + // TERRACONSTRUCTS DEVIATION (4th instance in this repo -- see e.g. + // `applyServerlessV2ScalingConfigurationOverride` in `../rds/cluster.ts`): `aws_backup_plan`'s + // `rule` config is block-typed (`BackupPlanRule[] | cdktn.IResolvable` in + // `@cdktn/provider-aws`'s `backup-plan/index.d.ts`), and `addRule()` below accumulates rules + // *after* this constructor returns. Passing a typed `BackupPlanRule[]` here would freeze the + // rule list at construction time; routing individual rules through the L1's own + // `.putRule()`/`ComplexList` machinery afterwards would silently drop any unresolved `Lazy` + // tokens nested in a rule (`internalValue` on `ComplexObject` never re-resolves). Instead, + // `rule` is fed a `Lazy.anyValue` that maps the accumulator through the L1's own + // `backupPlanRuleToTerraform` renderer at synth time -- this *does* run every nested value + // through the resolver. This mirrors the identical `globalSecondaryIndex`/ + // `addGlobalSecondaryIndex()` pattern already used by `../table.ts` for the same + // "block-typed L1 arg, accumulated after construction" shape; see + // `PlanTest > adding a rule after construction resolves Lazy tokens` for the regression test. + this.resource = new backupPlan.BackupPlan(this, "Resource", { + name: props.backupPlanName || id, + advancedBackupSetting: this.advancedBackupSettings(props), + rule: Lazy.anyValue( + { + produce: () => + this.rules.map((rule) => + backupPlan.backupPlanRuleToTerraform(rule), + ), + }, + // Matches the `../table.ts` idiom; only reachable on Testing.synth paths since + // `validatePlan()` rejects an empty rule list on real synth. + { omitEmptyArray: true }, + ), + }); + + this.backupPlanId = this.resource.id; + this.backupPlanArn = this.resource.arn; + this.versionId = this.resource.version; + + this._backupVault = props.backupVault; + + for (const rule of props.backupPlanRules || []) { + this.addRule(rule); + } + + this.node.addValidation({ validate: () => this.validatePlan() }); + } + + private advancedBackupSettings( + props: BackupPlanProps, + ): backupPlan.BackupPlanAdvancedBackupSetting[] | undefined { + if (!props.windowsVss) { + return undefined; + } + return [ + { + backupOptions: { + WindowsVSS: "enabled", + }, + resourceType: "EC2", + }, + ]; + } + + /** + * Adds a rule to a plan + * + * @param rule the rule to add + */ + public addRule(rule: BackupPlanRule) { + let vault: IBackupVault; + if (rule.props.backupVault) { + vault = rule.props.backupVault; + } else if (this._backupVault) { + vault = this._backupVault; + } else { + this._backupVault = new BackupVault(this, "Vault"); + vault = this._backupVault; + } + + this.rules.push({ + completionWindow: rule.props.completionWindow?.toMinutes(), + lifecycle: (rule.props.deleteAfter || + rule.props.moveToColdStorageAfter) && { + deleteAfter: rule.props.deleteAfter?.toDays(), + coldStorageAfter: rule.props.moveToColdStorageAfter?.toDays(), + }, + ruleName: + rule.props.ruleName ?? `${this.node.id}Rule${this.rules.length}`, + schedule: rule.props.scheduleExpression?.expressionString, + scheduleExpressionTimezone: + rule.props.scheduleExpressionTimezone?.timezoneName, + startWindow: rule.props.startWindow?.toMinutes(), + enableContinuousBackup: rule.props.enableContinuousBackup, + targetVaultName: vault.backupVaultName, + copyAction: rule.props.copyActions?.map(this.planCopyActions), + recoveryPointTags: rule.props.recoveryPointTags, + }); + } + + private planCopyActions( + this: void, + props: BackupPlanCopyActionProps, + ): backupPlan.BackupPlanRuleCopyAction { + return { + destinationVaultArn: props.destinationBackupVault.backupVaultArn, + lifecycle: (props.deleteAfter || props.moveToColdStorageAfter) && { + deleteAfter: props.deleteAfter?.toDays(), + coldStorageAfter: props.moveToColdStorageAfter?.toDays(), + }, + }; + } + + /** + * The backup vault where backups are stored if not defined at + * the rule level + */ + public get backupVault(): IBackupVault { + if (!this._backupVault) { + // This cannot happen but is here to make TypeScript happy + throw new ValidationError("No backup vault!", this); + } + + return this._backupVault; + } + + /** + * Adds a selection to this plan + */ + public addSelection( + id: string, + options: BackupSelectionOptions, + ): BackupSelection { + return new BackupSelection(this, id, { + backupPlan: this, + ...options, + }); + } + + private validatePlan(): string[] { + if (this.rules.length === 0) { + return ["A backup plan must have at least 1 rule."]; + } + + return []; + } +} diff --git a/src/aws/storage/backup/resource.ts b/src/aws/storage/backup/resource.ts new file mode 100644 index 00000000..2c1a3ca1 --- /dev/null +++ b/src/aws/storage/backup/resource.ts @@ -0,0 +1,164 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/resource.ts + +import { Construct } from "constructs"; +import { AwsStack } from "../../aws-stack"; +import type * as compute from "../../compute"; +import type * as rds from "../rds"; +import type { ITable } from "../shared"; + +/** + * An operation that is applied to a key-value pair + */ +export enum TagOperation { + /** + * StringEquals + */ + STRING_EQUALS = "STRINGEQUALS", + + /** + * Dummy member + */ + DUMMY = "dummy", +} + +/** + * A tag condition + */ +export interface TagCondition { + /** + * The key in a key-value pair. + * + * For example, in `"ec2:ResourceTag/Department": "accounting"`, + * `ec2:ResourceTag/Department` is the key. + */ + readonly key: string; + + /** + * An operation that is applied to a key-value pair used to filter + * resources in a selection. + * + * @default STRING_EQUALS + */ + readonly operation?: TagOperation; + + /** + * The value in a key-value pair. + * + * For example, in `"ec2:ResourceTag/Department": "accounting"`, + * `accounting` is the value. + */ + readonly value: string; +} + +/** + * A resource to backup + */ +export class BackupResource { + /** + * Adds all supported resources in a construct + * + * @param construct The construct containing resources to backup + */ + public static fromConstruct(construct: Construct) { + return new BackupResource(undefined, undefined, construct); + } + + /** + * A DynamoDB table + */ + public static fromDynamoDbTable(table: ITable) { + return BackupResource.fromArn(table.tableArn); + } + + /** + * An EC2 instance + */ + public static fromEc2Instance(instance: compute.IInstance) { + // TERRACONSTRUCTS DEVIATION: unlike `fromRdsDatabaseInstance`/`fromRdsDatabaseCluster` below, + // `compute.IInstance` (`../../compute/instance.ts`) does not expose a pre-formatted + // `instanceArn` -- it is built here the same way upstream builds it, via `formatArn`. + return BackupResource.fromArn( + AwsStack.ofAwsConstruct(instance).formatArn({ + service: "ec2", + resource: "instance", + resourceName: instance.instanceId, + }), + ); + } + + // TODO: omitted -- upstream's `fromEfsFileSystem(fileSystem: efs.IFileSystem)`. EFS + // (`aws-efs`) has not been ported to this repo yet (no `storage/efs` module exists), so there + // is no `IFileSystem`-equivalent type to accept here -- identical omission to the EFS-shaped + // gaps documented in `./backupable-resources-collector.ts` -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/resource.ts#L84 + + /** + * A RDS database instance + */ + public static fromRdsDatabaseInstance(instance: rds.IDatabaseInstance) { + return BackupResource.fromArn(instance.instanceArn); + } + + /** + * A RDS database cluster + */ + public static fromRdsDatabaseCluster(cluster: rds.IDatabaseCluster) { + // TERRACONSTRUCTS DEVIATION: upstream hand-builds the ARN via `Stack.formatArn` with + // COLON_RESOURCE_NAME (`arn:...:rds:...:cluster:`); this repo's + // `rds.IDatabaseCluster.clusterArn` already renders that exact shape, so the manual + // reconstruction is unnecessary and the emitted ARN is byte-identical. + return BackupResource.fromArn(cluster.clusterArn); + } + + /** + * An Aurora database instance + */ + public static fromRdsServerlessCluster(cluster: rds.IServerlessCluster) { + // TERRACONSTRUCTS DEVIATION: same `clusterArn` substitution as `fromRdsDatabaseCluster` above. + return BackupResource.fromArn(cluster.clusterArn); + } + + /** + * A list of ARNs or match patterns such as + * `arn:aws:ec2:us-east-1:123456789012:volume/*` + */ + public static fromArn(arn: string) { + return new BackupResource(arn); + } + + /** + * A tag condition + */ + public static fromTag(key: string, value: string, operation?: TagOperation) { + return new BackupResource(undefined, { + key, + value, + operation, + }); + } + + /** + * A resource + */ + public readonly resource?: string; + + /** + * A condition on a tag + */ + public readonly tagCondition?: TagCondition; + + /** + * A construct + */ + public readonly construct?: Construct; + + constructor( + resource?: string, + tagCondition?: TagCondition, + construct?: Construct, + ) { + this.resource = resource; + this.tagCondition = tagCondition; + this.construct = construct; + } +} diff --git a/src/aws/storage/backup/rule.ts b/src/aws/storage/backup/rule.ts new file mode 100644 index 00000000..17b8ee10 --- /dev/null +++ b/src/aws/storage/backup/rule.ts @@ -0,0 +1,286 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/rule.ts + +import type { IBackupVault } from "./vault"; +import { Duration } from "../../../duration"; +import { UnscopedValidationError } from "../../../errors"; +import type { TimeZone } from "../../../time-zone"; +import * as notify from "../../notify"; + +/** + * Properties for a BackupPlanRule + */ +export interface BackupPlanRuleProps { + /** + * The duration after a backup job is successfully started before it must be + * completed or it is canceled by AWS Backup. + * + * @default - 7 days + */ + readonly completionWindow?: Duration; + + /** + * Specifies the duration after creation that a recovery point is deleted. + * Must be greater than `moveToColdStorageAfter`. + * + * @default - recovery point is never deleted + */ + readonly deleteAfter?: Duration; + + /** + * Specifies the duration after creation that a recovery point is moved to cold + * storage. + * + * @default - recovery point is never moved to cold storage + */ + readonly moveToColdStorageAfter?: Duration; + + /** + * A display name for the backup rule. + * + * @default - a CDK generated name + */ + readonly ruleName?: string; + + /** + * A CRON expression specifying when AWS Backup initiates a backup job. + * + * @default - no schedule + */ + readonly scheduleExpression?: notify.Schedule; + + /** + * The timezone in which the schedule expression is set. + * + * @default - UTC + */ + readonly scheduleExpressionTimezone?: TimeZone; + + /** + * The duration after a backup is scheduled before a job is canceled if it doesn't start successfully. + * + * @default - 8 hours + */ + readonly startWindow?: Duration; + + /** + * The backup vault where backups are + * + * @default - use the vault defined at the plan level. If not defined a new + * common vault for the plan will be created + */ + readonly backupVault?: IBackupVault; + + /** + * Enables continuous backup and point-in-time restores (PITR). + * + * Property `deleteAfter` defines the retention period for the backup. It is mandatory if PITR is enabled. + * If no value is specified, the retention period is set to 35 days which is the maximum retention period supported by PITR. + * + * Property `moveToColdStorageAfter` must not be specified because PITR does not support this option. + * + * @default false + */ + readonly enableContinuousBackup?: boolean; + + /** + * Copy operations to perform on recovery points created by this rule + * + * @default - no copy actions + */ + readonly copyActions?: BackupPlanCopyActionProps[]; + + /** + * To help organize your resources, you can assign your own metadata to the resources that you create. Each tag is a key-value pair. + * + * @default - no recovery point tags. + */ + readonly recoveryPointTags?: { [key: string]: string }; +} + +/** + * Properties for a BackupPlanCopyAction + */ +export interface BackupPlanCopyActionProps { + /** + * Destination Vault for recovery points to be copied into + */ + readonly destinationBackupVault: IBackupVault; + + /** + * Specifies the duration after creation that a copied recovery point is deleted from the destination vault. + * Must be at least 90 days greater than `moveToColdStorageAfter`, if specified. + * + * @default - recovery point is never deleted + */ + readonly deleteAfter?: Duration; + + /** + * Specifies the duration after creation that a copied recovery point is moved to cold storage. + * + * @default - recovery point is never moved to cold storage + */ + readonly moveToColdStorageAfter?: Duration; +} + +/** + * A backup plan rule + */ +export class BackupPlanRule { + /** + * Daily with 35 days retention + */ + public static daily(backupVault?: IBackupVault) { + return new BackupPlanRule({ + backupVault, + ruleName: "Daily", + scheduleExpression: notify.Schedule.cron({ + hour: "5", + minute: "0", + }), + deleteAfter: Duration.days(35), + }); + } + + /** + * Weekly with 3 months retention + */ + public static weekly(backupVault?: IBackupVault) { + return new BackupPlanRule({ + backupVault, + ruleName: "Weekly", + scheduleExpression: notify.Schedule.cron({ + hour: "5", + minute: "0", + weekDay: "SAT", + }), + deleteAfter: Duration.days(30 * 3), + }); + } + + /** + * Monthly 1 year retention, move to cold storage after 1 month + */ + public static monthly1Year(backupVault?: IBackupVault) { + return new BackupPlanRule({ + backupVault, + ruleName: "Monthly1Year", + scheduleExpression: notify.Schedule.cron({ + day: "1", + hour: "5", + minute: "0", + }), + moveToColdStorageAfter: Duration.days(30), + deleteAfter: Duration.days(365), + }); + } + + /** + * Monthly 5 year retention, move to cold storage after 3 months + */ + public static monthly5Year(backupVault?: IBackupVault) { + return new BackupPlanRule({ + backupVault, + ruleName: "Monthly5Year", + scheduleExpression: notify.Schedule.cron({ + day: "1", + hour: "5", + minute: "0", + }), + moveToColdStorageAfter: Duration.days(30 * 3), + deleteAfter: Duration.days(365 * 5), + }); + } + + /** + * Monthly 7 year retention, move to cold storage after 3 months + */ + public static monthly7Year(backupVault?: IBackupVault) { + return new BackupPlanRule({ + backupVault, + ruleName: "Monthly7Year", + scheduleExpression: notify.Schedule.cron({ + day: "1", + hour: "5", + minute: "0", + }), + moveToColdStorageAfter: Duration.days(30 * 3), + deleteAfter: Duration.days(365 * 7), + }); + } + + /** + * Properties of BackupPlanRule + */ + public readonly props: BackupPlanRuleProps; + + /** @param props Rule properties */ + constructor(props: BackupPlanRuleProps) { + if ( + props.deleteAfter && + !props.deleteAfter.isUnresolved() && + props.moveToColdStorageAfter && + !props.moveToColdStorageAfter.isUnresolved() && + props.deleteAfter.toDays() < props.moveToColdStorageAfter.toDays() + ) { + throw new UnscopedValidationError( + "`deleteAfter` must be greater than `moveToColdStorageAfter`", + ); + } + + if ( + props.scheduleExpression && + !/^cron/.test(props.scheduleExpression.expressionString) + ) { + throw new UnscopedValidationError( + "`scheduleExpression` must be of type `cron`", + ); + } + + const deleteAfter = + props.enableContinuousBackup && !props.deleteAfter + ? Duration.days(35) + : props.deleteAfter; + + if (props.enableContinuousBackup && props.moveToColdStorageAfter) { + throw new UnscopedValidationError( + "`moveToColdStorageAfter` must not be specified if `enableContinuousBackup` is enabled", + ); + } + + if ( + props.enableContinuousBackup && + props.deleteAfter && + !props.deleteAfter.isUnresolved() && + (props.deleteAfter.toDays() < 1 || props.deleteAfter.toDays() > 35) + ) { + throw new UnscopedValidationError( + `'deleteAfter' must be between 1 and 35 days if 'enableContinuousBackup' is enabled, but got ${props.deleteAfter.toHumanString()}`, + ); + } + + if (props.copyActions && props.copyActions.length > 0) { + props.copyActions.forEach((copyAction) => { + if ( + copyAction.deleteAfter && + !copyAction.deleteAfter.isUnresolved() && + copyAction.moveToColdStorageAfter && + !copyAction.moveToColdStorageAfter.isUnresolved() && + copyAction.deleteAfter.toDays() < + copyAction.moveToColdStorageAfter.toDays() + 90 + ) { + throw new UnscopedValidationError( + [ + "'deleteAfter' must at least 90 days later than corresponding 'moveToColdStorageAfter'", + `received 'deleteAfter: ${copyAction.deleteAfter.toDays()}' and 'moveToColdStorageAfter: ${copyAction.moveToColdStorageAfter.toDays()}'`, + ].join("\n"), + ); + } + }); + } + + this.props = { + ...props, + deleteAfter, + }; + } +} diff --git a/src/aws/storage/backup/selection.ts b/src/aws/storage/backup/selection.ts new file mode 100644 index 00000000..ddf9fc13 --- /dev/null +++ b/src/aws/storage/backup/selection.ts @@ -0,0 +1,205 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/selection.ts + +import { backupSelection } from "@cdktn/provider-aws"; +import { Aspects, Lazy } from "cdktn"; +import { Construct } from "constructs"; +import { BackupableResourcesCollector } from "./backupable-resources-collector"; +import type { IBackupPlan } from "./plan"; +import type { BackupResource } from "./resource"; +import { TagOperation } from "./resource"; +import { AwsConstructBase, AwsConstructProps } from "../../aws-construct"; +import * as iam from "../../iam"; + +/** + * Options for a BackupSelection + */ +export interface BackupSelectionOptions { + /** + * The resources to backup. + * Use the helper static methods defined on `BackupResource`. + */ + readonly resources: BackupResource[]; + + /** + * The name for this selection + * + * @default - a CDK generated name + */ + readonly backupSelectionName?: string; + + /** + * The role that AWS Backup uses to authenticate when backuping or restoring + * the resources. The `AWSBackupServiceRolePolicyForBackup` managed policy + * will be attached to this role unless `disableDefaultBackupPolicy` + * is set to `true`. + * + * @default - a new role will be created + */ + readonly role?: iam.IRole; + + /** + * Whether to disable automatically assigning default backup permissions to the role + * that AWS Backup uses. + * If `false`, the `AWSBackupServiceRolePolicyForBackup` managed policy will be + * attached to the role. + * + * @default false + */ + readonly disableDefaultBackupPolicy?: boolean; + + /** + * Whether to automatically give restores permissions to the role that AWS + * Backup uses. If `true`, the `AWSBackupServiceRolePolicyForRestores` managed + * policy will be attached to the role. + * + * @default false + */ + readonly allowRestores?: boolean; +} + +/** + * Properties for a BackupSelection + */ +export interface BackupSelectionProps + extends BackupSelectionOptions, + AwsConstructProps { + /** + * The backup plan for this selection + */ + readonly backupPlan: IBackupPlan; +} + +/** + * A backup selection + */ +export class BackupSelection + extends AwsConstructBase + implements iam.IGrantable +{ + /** + * The identifier of the backup plan. + * + * @attribute + */ + public readonly backupPlanId: string; + + /** + * The identifier of the backup selection. + * + * @attribute + */ + public readonly selectionId: string; + + /** + * The principal to grant permissions to + */ + public readonly grantPrincipal: iam.IPrincipal; + + public get outputs(): Record { + return { + backupPlanId: this.backupPlanId, + selectionId: this.selectionId, + }; + } + + // TERRACONSTRUCTS DEVIATION (same block-typed-L1-arg-accumulated-after-construction footgun as + // `BackupPlan.addRule()` in `./plan.ts` -- see the note there): `selection_tag` on + // `aws_backup_selection` is block-typed (`BackupSelectionSelectionTag[] | cdktn.IResolvable`), + // and tag conditions on constructs reached via `BackupResource.fromConstruct()` are only + // resolved by the `BackupableResourcesCollector` Aspect during synth, well after this + // constructor returns. `selectionTag` is therefore fed a `Lazy.anyValue` that maps this + // accumulator through the L1's own `backupSelectionSelectionTagToTerraform` renderer, mirroring + // `../table.ts`'s `addGlobalSecondaryIndex()` pattern. + private readonly tags = + new Array(); + + // Plain string list -- `resources` on `aws_backup_selection` is NOT block-typed, so a + // `Lazy.listValue` combining this accumulator with the Aspect-collected resources below + // resolves without the ComplexObject/internalValue footgun (see `TagCondition` note above). + private readonly resourceArns = new Array(); + private readonly backupableResourcesCollector = + new BackupableResourcesCollector(); + + constructor(scope: Construct, id: string, props: BackupSelectionProps) { + super(scope, id, props); + + const role = + props.role || + new iam.Role(this, "Role", { + assumedBy: new iam.ServicePrincipal("backup.amazonaws.com"), + }); + if (!props.disableDefaultBackupPolicy) { + role.addManagedPolicy( + iam.ManagedPolicy.fromAwsManagedPolicyName( + this, + "BackupPolicy", + "service-role/AWSBackupServiceRolePolicyForBackup", + ), + ); + } + if (props.allowRestores) { + role.addManagedPolicy( + iam.ManagedPolicy.fromAwsManagedPolicyName( + this, + "RestoresPolicy", + "service-role/AWSBackupServiceRolePolicyForRestores", + ), + ); + } + this.grantPrincipal = role; + + const selection = new backupSelection.BackupSelection(this, "Resource", { + planId: props.backupPlan.backupPlanId, + name: props.backupSelectionName || this.node.id, + iamRoleArn: role.roleArn, + resources: Lazy.listValue( + { + produce: () => [ + ...this.resourceArns, + ...this.backupableResourcesCollector.resources, + ], + }, + { omitEmpty: true }, + ), + selectionTag: Lazy.anyValue( + { + produce: () => + this.tags.map((tag) => + backupSelection.backupSelectionSelectionTagToTerraform(tag), + ), + }, + { omitEmptyArray: true }, + ), + }); + + this.backupPlanId = selection.planId; + this.selectionId = selection.id; + + for (const resource of props.resources) { + this.addResource(resource); + } + } + + private addResource(resource: BackupResource) { + if (resource.tagCondition) { + this.tags.push({ + key: resource.tagCondition.key, + type: resource.tagCondition.operation || TagOperation.STRING_EQUALS, + value: resource.tagCondition.value, + }); + } + + if (resource.resource) { + this.resourceArns.push(resource.resource); + } + + if (resource.construct) { + // Cannot push `this.backupableResourcesCollector.resources` to `this.resourceArns` here + // because it has not been evaluated yet (the Aspect only runs during synth). Concatenated + // to `this.resourceArns` in the `Lazy.listValue` producer above instead. + // TERRACONSTRUCTS DEVIATION: upstream passes `{ priority: mutatingAspectPrio32333(...) }`; + // cdktn's `Aspects.add()` takes no priority/options argument, so there is no analog to port. + Aspects.of(resource.construct).add(this.backupableResourcesCollector); + } + } +} diff --git a/src/aws/storage/backup/vault.ts b/src/aws/storage/backup/vault.ts new file mode 100644 index 00000000..65ea360c --- /dev/null +++ b/src/aws/storage/backup/vault.ts @@ -0,0 +1,555 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/vault.ts + +import { + backupVault, + backupVaultLockConfiguration, + backupVaultNotifications, + backupVaultPolicy, +} from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import type { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import { AwsStack } from "../../aws-stack"; +import type * as encryption from "../../encryption"; +import * as iam from "../../iam"; +import type * as sns from "../../notify"; + +/** + * A backup vault + * + * TODO: omitted — upstream also extends `aws_backup.IBackupVaultRef`, a CloudFormation + * cross-stack "Reference" marker interface generated from the CFN resource spec. + * TerraConstructs has no equivalent generated-reference layer (identical omission to + * `dbClusterRef` on `IDatabaseCluster` in `../docdb/cluster-ref.ts`), so `backupVaultRef` is + * dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/vault.ts#L14-L19 + */ +export interface IBackupVault extends IAwsConstruct { + /** + * The name of a logical container where backups are stored. + * + * @attribute + */ + readonly backupVaultName: string; + + /** + * The ARN of the backup vault. + * + * @attribute + */ + readonly backupVaultArn: string; + + /** + * Grant the actions defined in actions to the given grantee + * on this backup vault. + */ + grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant; +} + +/** + * Properties for a BackupVault + */ +export interface BackupVaultProps extends AwsConstructProps { + /** + * The name of a logical container where backups are stored. Backup vaults + * are identified by names that are unique to the account used to create + * them and the AWS Region where they are created. + * + * @default - A CDK generated name + */ + readonly backupVaultName?: string; + + /** + * A resource-based policy that is used to manage access permissions on the + * backup vault. + * + * TERRACONSTRUCTS DEVIATION: unlike upstream's CFN `AWS::Backup::BackupVault.AccessPolicy` + * (an inline property always rendered, even as an empty document), the Terraform provider + * splits vault access policy off into a standalone `aws_backup_vault_policy` resource — see + * `node_modules/@cdktn/provider-aws/lib/backup-vault-policy/index.d.ts`. This port creates + * that resource lazily: the first of `accessPolicy`, `blockRecoveryPointDeletion: true`, or a + * later `addToAccessPolicy()`/`blockRecoveryPointDeletion()` call materializes it. A vault that + * never needs an access policy has no access policy resource at all (matching the "access is + * not restricted" default). + * + * @default - access is not restricted + */ + readonly accessPolicy?: iam.PolicyDocument; + + /** + * The server-side encryption key to use to protect your backups. + * + * ID-vs-ARN AUDIT: `kms_key_arn` on `aws_backup_vault` takes an ARN, so this feeds + * `encryptionKey.keyArn` (not `keyId`). + * + * @default - an Amazon managed KMS key + */ + readonly encryptionKey?: encryption.IKey; + + /** + * A SNS topic to send vault events to. + * + * TERRACONSTRUCTS DEVIATION: unlike upstream's CFN `AWS::Backup::BackupVault.Notifications` + * (an inline property), the Terraform provider splits vault notifications off into a standalone + * `aws_backup_vault_notifications` resource -- see + * `node_modules/@cdktn/provider-aws/lib/backup-vault-notifications/index.d.ts`. This port creates + * that resource only when `notificationTopic` is set; a vault without `notificationTopic` + * synthesizes no notifications resource at all (matching the "no notifications" default). + * + * @see https://docs.aws.amazon.com/aws-backup/latest/devguide/sns-notifications.html + * + * @default - no notifications + */ + readonly notificationTopic?: sns.ITopic; + + /** + * The vault events to send. + * + * TERRACONSTRUCTS DEVIATION: rendered onto the same standalone `aws_backup_vault_notifications` + * resource as `notificationTopic` above -- see that prop's deviation note. Ignored (no resource + * synthesized) unless `notificationTopic` is also set. + * + * @see https://docs.aws.amazon.com/aws-backup/latest/devguide/sns-notifications.html + * + * @default - all vault events if `notificationTopic` is defined + */ + readonly notificationEvents?: BackupVaultEvents[]; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`). + // `core.RemovalPolicy` is not ported in this repo (identical omission to every other ported + // module's `removalPolicy` props, e.g. `storage.rds`'s `DatabaseInstanceNewProps.removalPolicy`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/lib/vault.ts#L90 + // readonly removalPolicy?: RemovalPolicy; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — native Terraform replacement for upstream's + * `removalPolicy` (see the TODO above). `aws_backup_vault` has no CFN-DeletionPolicy analog; + * instead it exposes `force_destroy`, which controls whether Terraform is allowed to delete all + * recovery points still stored in the vault so the vault itself can be destroyed. When `false` + * (the default -- mirrors upstream's `RemovalPolicy.RETAIN` default), `terraform destroy`/replace + * on a vault that still contains recovery points FAILS at apply-time with an AWS API error + * (native `aws_backup_vault` behavior, not enforced here at synth time). Mirrors + * `DatabaseClusterProps.skipFinalSnapshot` in `../rds/cluster.ts` and `../docdb/cluster.ts`. + * + * @default false (a non-empty vault cannot be destroyed unless this is `true`) + */ + readonly forceDestroy?: boolean; + + /** + * Whether to add statements to the vault access policy that prevents anyone + * from deleting a recovery point. + * + * @default false + */ + readonly blockRecoveryPointDeletion?: boolean; + + /** + * Configuration for AWS Backup Vault Lock + * + * TERRACONSTRUCTS DEVIATION: unlike upstream's CFN `AWS::Backup::BackupVault.LockConfiguration` + * (an inline property), the Terraform provider splits vault lock configuration off into a + * standalone `aws_backup_vault_lock_configuration` resource -- see + * `node_modules/@cdktn/provider-aws/lib/backup-vault-lock-configuration/index.d.ts`. This port + * creates that resource only when `lockConfiguration` is set; a vault without `lockConfiguration` + * synthesizes no lock-configuration resource at all (matching the "Vault Lock is disabled" + * default). + * + * @see https://docs.aws.amazon.com/aws-backup/latest/devguide/vault-lock.html + * + * @default - AWS Backup Vault Lock is disabled + */ + readonly lockConfiguration?: LockConfiguration; +} + +/** + * Backup vault events. Some events are no longer supported and will not return + * statuses or notifications. + * + * @see https://docs.aws.amazon.com/aws-backup/latest/devguide/API_PutBackupVaultNotifications.html#API_PutBackupVaultNotifications_RequestBody + */ +export enum BackupVaultEvents { + /** BACKUP_JOB_STARTED */ + BACKUP_JOB_STARTED = "BACKUP_JOB_STARTED", + /** BACKUP_JOB_COMPLETED */ + BACKUP_JOB_COMPLETED = "BACKUP_JOB_COMPLETED", + /** BACKUP_JOB_SUCCESSFUL */ + BACKUP_JOB_SUCCESSFUL = "BACKUP_JOB_SUCCESSFUL", + /** BACKUP_JOB_FAILED */ + BACKUP_JOB_FAILED = "BACKUP_JOB_FAILED", + /** BACKUP_JOB_EXPIRED */ + BACKUP_JOB_EXPIRED = "BACKUP_JOB_EXPIRED", + /** RESTORE_JOB_STARTED */ + RESTORE_JOB_STARTED = "RESTORE_JOB_STARTED", + /** RESTORE_JOB_COMPLETED */ + RESTORE_JOB_COMPLETED = "RESTORE_JOB_COMPLETED", + /** RESTORE_JOB_SUCCESSFUL */ + RESTORE_JOB_SUCCESSFUL = "RESTORE_JOB_SUCCESSFUL", + /** RESTORE_JOB_FAILED */ + RESTORE_JOB_FAILED = "RESTORE_JOB_FAILED", + /** COPY_JOB_STARTED */ + COPY_JOB_STARTED = "COPY_JOB_STARTED", + /** COPY_JOB_SUCCESSFUL */ + COPY_JOB_SUCCESSFUL = "COPY_JOB_SUCCESSFUL", + /** COPY_JOB_FAILED */ + COPY_JOB_FAILED = "COPY_JOB_FAILED", + /** RECOVERY_POINT_MODIFIED */ + RECOVERY_POINT_MODIFIED = "RECOVERY_POINT_MODIFIED", + /** BACKUP_PLAN_CREATED */ + BACKUP_PLAN_CREATED = "BACKUP_PLAN_CREATED", + /** BACKUP_PLAN_MODIFIED */ + BACKUP_PLAN_MODIFIED = "BACKUP_PLAN_MODIFIED", + /** S3_BACKUP_OBJECT_FAILED */ + S3_BACKUP_OBJECT_FAILED = "S3_BACKUP_OBJECT_FAILED", + /** S3_RESTORE_OBJECT_FAILED */ + S3_RESTORE_OBJECT_FAILED = "S3_RESTORE_OBJECT_FAILED", +} + +/** + * Configuration for AWS Backup Vault Lock + * + * @see https://docs.aws.amazon.com/aws-backup/latest/devguide/vault-lock.html + */ +export interface LockConfiguration { + /** + * The minimum retention period that the vault retains its recovery points. + * + * If this parameter is specified, any backup or copy job to the vault must + * have a lifecycle policy with a retention period equal to or longer than + * the minimum retention period. If the job's retention period is shorter than + * that minimum retention period, then the vault fails that backup or copy job, + * and you should either modify your lifecycle settings or use a different + * vault. Recovery points already saved in the vault prior to Vault Lock are + * not affected. + */ + readonly minRetention: Duration; + + /** + * The maximum retention period that the vault retains its recovery points. + * + * If this parameter is specified, any backup or copy job to the vault must + * have a lifecycle policy with a retention period equal to or shorter than + * the maximum retention period. If the job's retention period is longer than + * that maximum retention period, then the vault fails the backup or copy job, + * and you should either modify your lifecycle settings or use a different + * vault. Recovery points already saved in the vault prior to Vault Lock are + * not affected. + * + * @default - Vault Lock does not enforce a maximum retention period + */ + readonly maxRetention?: Duration; + + /** + * The duration before the lock date. + * + * AWS Backup enforces a 72-hour cooling-off period before Vault Lock takes + * effect and becomes immutable. + * + * Before the lock date, you can delete Vault Lock from the vault or change + * the Vault Lock configuration. On and after the lock date, the Vault Lock + * becomes immutable and cannot be changed or deleted. + * + * @default - Vault Lock can be deleted or changed at any time + */ + readonly changeableFor?: Duration; +} + +abstract class BackupVaultBase + extends AwsConstructBase + implements IBackupVault +{ + public abstract readonly backupVaultName: string; + public abstract readonly backupVaultArn: string; + + public get outputs(): Record { + return { + backupVaultName: this.backupVaultName, + backupVaultArn: this.backupVaultArn, + }; + } + + /** + * Grant the actions defined in actions to the given grantee + * on this Backup Vault resource. + * + * @param grantee Principal to grant right to + * @param actions The actions to grant + */ + public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { + for (const action of actions) { + if (action.indexOf("*") >= 0) { + throw new ValidationError( + "AWS Backup access policies don't support a wildcard in the Action key.", + this, + ); + } + } + + return iam.Grant.addToPrincipal({ + grantee: grantee, + actions: actions, + resourceArns: [this.backupVaultArn], + }); + } +} + +/** + * A backup vault + */ +export class BackupVault extends BackupVaultBase { + /** + * Import an existing backup vault by name + */ + public static fromBackupVaultName( + scope: Construct, + id: string, + backupVaultName: string, + ): IBackupVault { + const backupVaultArn = AwsStack.ofAwsConstruct(scope).formatArn({ + service: "backup", + resource: "backup-vault", + resourceName: backupVaultName, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + + return BackupVault.fromBackupVaultArn(scope, id, backupVaultArn); + } + + /** + * Import an existing backup vault by arn + */ + public static fromBackupVaultArn( + scope: Construct, + id: string, + backupVaultArn: string, + ): IBackupVault { + const stack = AwsStack.ofAwsConstruct(scope); + const parsedArn = stack.splitArn( + backupVaultArn, + ArnFormat.COLON_RESOURCE_NAME, + ); + + if (parsedArn.arnFormat !== ArnFormat.COLON_RESOURCE_NAME) { + throw new ValidationError( + `Backup Vault Arn ${backupVaultArn} has the wrong format, expected ${ArnFormat.COLON_RESOURCE_NAME}.`, + scope, + ); + } + if (!parsedArn.resourceName) { + throw new ValidationError( + `Backup Vault Arn ${backupVaultArn} does not have a resource name.`, + scope, + ); + } + + class Import extends BackupVaultBase { + public readonly backupVaultName = parsedArn.resourceName!; + public readonly backupVaultArn = backupVaultArn; + } + + return new Import(scope, id, { + account: parsedArn.account, + region: parsedArn.region, + }); + } + + public readonly backupVaultName: string; + public readonly backupVaultArn: string; + + private readonly resource: backupVault.BackupVault; + + /** + * The access policy document for this vault. Lazily created (alongside the standalone + * `aws_backup_vault_policy` resource in `ensureAccessPolicyDocument()` below) on first use -- + * either from `accessPolicy`/`blockRecoveryPointDeletion` at construction time, or from a + * later `addToAccessPolicy()`/`blockRecoveryPointDeletion()` call. A vault that never needs an + * access policy therefore never synthesizes an `aws_backup_vault_policy` resource (nor its + * backing `data.aws_iam_policy_document`) at all -- see the TERRACONSTRUCTS DEVIATION note on + * `BackupVaultProps.accessPolicy`. Mirrors the identical lazy-`defaultPolicy` idiom on + * `Role.addToPrincipalPolicy` in `../../iam/role.ts`. + */ + private _accessPolicyDocument?: iam.PolicyDocument; + + /** The standalone `aws_backup_vault_policy` resource, created lazily alongside the document above. */ + private _accessPolicyResource?: backupVaultPolicy.BackupVaultPolicy; + + constructor(scope: Construct, id: string, props: BackupVaultProps = {}) { + super(scope, id, props); + + if ( + props.backupVaultName && + !Token.isUnresolved(props.backupVaultName) && + !/^[a-zA-Z0-9\-_]{2,50}$/.test(props.backupVaultName) + ) { + throw new ValidationError( + "Expected vault name to match pattern `^[a-zA-Z0-9\\-_]{2,50}$`", + this, + ); + } + + this.resource = new backupVault.BackupVault(this, "Resource", { + name: props.backupVaultName || this.uniqueVaultName(), + kmsKeyArn: props.encryptionKey?.keyArn, + forceDestroy: props.forceDestroy, + }); + + this.backupVaultName = this.resource.name; + this.backupVaultArn = this.resource.arn; + + if (props.accessPolicy) { + this._accessPolicyDocument = props.accessPolicy; + this.ensureAccessPolicyDocument(); + } + if (props.blockRecoveryPointDeletion) { + this.blockRecoveryPointDeletion(); + } + + if (props.notificationTopic) { + new backupVaultNotifications.BackupVaultNotifications( + this, + "Notifications", + { + backupVaultName: this.backupVaultName, + backupVaultEvents: + props.notificationEvents || Object.values(BackupVaultEvents), + snsTopicArn: props.notificationTopic.topicArn, + }, + ); + props.notificationTopic.grantPublish( + new iam.ServicePrincipal("backup.amazonaws.com"), + ); + } + + if (props.lockConfiguration) { + const rendered = renderLockConfiguration(this, props.lockConfiguration); + new backupVaultLockConfiguration.BackupVaultLockConfiguration( + this, + "LockConfiguration", + { + backupVaultName: this.backupVaultName, + minRetentionDays: rendered.minRetentionDays, + maxRetentionDays: rendered.maxRetentionDays, + changeableForDays: rendered.changeableForDays, + }, + ); + } + } + + /** + * Adds a statement to the vault access policy + */ + public addToAccessPolicy(statement: iam.PolicyStatement) { + this.ensureAccessPolicyDocument().addStatements(statement); + } + + /** + * Adds a statement to the vault access policy that prevents anyone + * from deleting a recovery point. + */ + public blockRecoveryPointDeletion() { + this.addToAccessPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + actions: [ + "backup:DeleteRecoveryPoint", + "backup:UpdateRecoveryPointLifecycle", + ], + principals: [new iam.AnyPrincipal()], + resources: ["*"], + }), + ); + } + + /** + * Returns the access policy document, creating it (and the standalone + * `aws_backup_vault_policy` resource that renders it) on first use -- see the + * TERRACONSTRUCTS DEVIATION note on `BackupVaultProps.accessPolicy`. + */ + private ensureAccessPolicyDocument(): iam.PolicyDocument { + if (!this._accessPolicyDocument) { + this._accessPolicyDocument = new iam.PolicyDocument( + this, + "AccessPolicyDocument", + ); + } + if (!this._accessPolicyResource) { + this._accessPolicyResource = new backupVaultPolicy.BackupVaultPolicy( + this, + "AccessPolicy", + { + backupVaultName: this.backupVaultName, + policy: this._accessPolicyDocument.json, + }, + ); + } + return this._accessPolicyDocument; + } + + private uniqueVaultName() { + // TERRACONSTRUCTS DEVIATION: upstream lowercases nothing here either -- `aws_backup_vault` + // names allow mixed case (`^[a-zA-Z0-9\-_]{2,50}$`, validated above), unlike e.g. + // `docdb`/`rds` identifiers which the provider forces to lowercase. Max length of 50 chars, + // matching upstream's `Names.uniqueId(this)` truncation. + return this.stack.uniqueResourceName(this, { maxLength: 50 }); + } +} + +function renderLockConfiguration( + scope: Construct, + config: LockConfiguration, +): { + minRetentionDays: number; + maxRetentionDays?: number; + changeableForDays?: number; +} { + if ( + config.changeableFor && + !config.changeableFor.isUnresolved() && + config.changeableFor.toHours() < 72 + ) { + throw new ValidationError( + `AWS Backup enforces a 72-hour cooling-off period before Vault Lock takes effect and becomes immutable, got ${config.changeableFor.toHours()} hours`, + scope, + ); + } + + if (config.maxRetention && !config.maxRetention.isUnresolved()) { + if (config.maxRetention.toDays() > 36500) { + throw new ValidationError( + `The longest maximum retention period you can specify is 36500 days, got ${config.maxRetention.toDays()} days`, + scope, + ); + } + if ( + !config.minRetention.isUnresolved() && + config.maxRetention.toDays() <= config.minRetention.toDays() + ) { + throw new ValidationError( + `The maximum retention period (${config.maxRetention.toDays()} days) must be greater than the minimum retention period (${config.minRetention.toDays()} days)`, + scope, + ); + } + } + + if ( + !config.minRetention.isUnresolved() && + config.minRetention.toHours() < 24 + ) { + throw new ValidationError( + `The shortest minimum retention period you can specify is 1 day, got ${config.minRetention.toHours()} hours`, + scope, + ); + } + + return { + minRetentionDays: config.minRetention.toDays(), + maxRetentionDays: config.maxRetention?.toDays(), + changeableForDays: config.changeableFor?.toDays(), + }; +} diff --git a/src/aws/storage/index.ts b/src/aws/storage/index.ts index b8d066e3..a7191328 100644 --- a/src/aws/storage/index.ts +++ b/src/aws/storage/index.ts @@ -51,3 +51,6 @@ export * as neptune from "./neptune"; // aws-redshift-alpha export * as redshift from "./redshift"; + +// aws-backup +export * as backup from "./backup"; diff --git a/test/aws/storage/backup/plan.test.ts b/test/aws/storage/backup/plan.test.ts new file mode 100644 index 00000000..d843eeff --- /dev/null +++ b/test/aws/storage/backup/plan.test.ts @@ -0,0 +1,603 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/test/plan.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { backupPlan, backupVault } from "@cdktn/provider-aws"; +import { App, Lazy, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as notify from "../../../../src/aws/notify"; +import * as backup from "../../../../src/aws/storage/backup"; +import { Duration } from "../../../../src/duration"; +import { TimeZone } from "../../../../src/time-zone"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +// TERRACONSTRUCTS DEVIATION: cross-references between sibling resources (e.g. a rule's +// `target_vault_name` pointing at its vault's `.name` attribute) render as CDKTF interpolation +// strings (`${aws_backup_vault..name}`) whose `` includes a construct-path +// hash. Rather than hardcoding that hash (an implementation detail of `uniqueResourceName`/ +// `Names`-equivalent hashing, not part of this port's behavior contract), assertions below match +// the interpolation shape with a regex -- mirrors the same adaptation in e.g. +// `../assets/image-asset.test.ts`. +const vaultNameRef = () => + expect.stringMatching(/^\$\{aws_backup_vault\.\w+\.name\}$/); +const vaultArnRef = () => + expect.stringMatching(/^\$\{aws_backup_vault\.\w+\.arn\}$/); + +describe("BackupPlan", () => { + test("create a plan and add rules", () => { + // GIVEN + const stack = testStack(); + const vault = new backup.BackupVault(stack, "Vault"); + const otherVault = new backup.BackupVault(stack, "OtherVault"); + + // WHEN + const plan = new backup.BackupPlan(stack, "Plan", { + backupVault: vault, + backupPlanRules: [ + new backup.BackupPlanRule({ + completionWindow: Duration.hours(2), + startWindow: Duration.hours(1), + scheduleExpression: notify.Schedule.cron({ + day: "15", + hour: "3", + minute: "30", + }), + scheduleExpressionTimezone: TimeZone.ETC_UTC, + moveToColdStorageAfter: Duration.days(30), + }), + ], + }); + plan.addRule(backup.BackupPlanRule.monthly5Year(otherVault)); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "Plan", + rule: [ + { + completion_window: 120, + lifecycle: { + cold_storage_after: 30, + }, + rule_name: "PlanRule0", + schedule: "cron(30 3 15 * ? *)", + schedule_expression_timezone: "Etc/UTC", + start_window: 60, + target_vault_name: vaultNameRef(), + }, + { + lifecycle: { + delete_after: 1825, + cold_storage_after: 90, + }, + rule_name: "Monthly5Year", + schedule: "cron(0 5 1 * ? *)", + target_vault_name: vaultNameRef(), + }, + ], + }); + }); + + test("create a plan with continuous backup option", () => { + // GIVEN + const stack = testStack(); + const vault = new backup.BackupVault(stack, "Vault"); + + // WHEN + new backup.BackupPlan(stack, "Plan", { + backupVault: vault, + backupPlanRules: [ + new backup.BackupPlanRule({ + enableContinuousBackup: true, + }), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "Plan", + rule: [ + { + enable_continuous_backup: true, + lifecycle: { + delete_after: 35, + }, + rule_name: "PlanRule0", + target_vault_name: vaultNameRef(), + }, + ], + }); + }); + + test("create a plan with continuous backup option and specify deleteAfter", () => { + // GIVEN + const stack = testStack(); + const vault = new backup.BackupVault(stack, "Vault"); + + // WHEN + new backup.BackupPlan(stack, "Plan", { + backupVault: vault, + backupPlanRules: [ + new backup.BackupPlanRule({ + enableContinuousBackup: true, + deleteAfter: Duration.days(1), + }), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "Plan", + rule: [ + { + enable_continuous_backup: true, + lifecycle: { + delete_after: 1, + }, + rule_name: "PlanRule0", + target_vault_name: vaultNameRef(), + }, + ], + }); + }); + + test("create a plan and add rules - add BackupPlan.AdvancedBackupSettings.BackupOptions", () => { + const stack = testStack(); + const vault = new backup.BackupVault(stack, "Vault"); + const otherVault = new backup.BackupVault(stack, "OtherVault"); + + // WHEN + const plan = new backup.BackupPlan(stack, "Plan", { + windowsVss: true, + backupVault: vault, + backupPlanRules: [ + new backup.BackupPlanRule({ + completionWindow: Duration.hours(2), + startWindow: Duration.hours(1), + scheduleExpression: notify.Schedule.cron({ + day: "15", + hour: "3", + minute: "30", + }), + moveToColdStorageAfter: Duration.days(30), + }), + ], + }); + plan.addRule(backup.BackupPlanRule.monthly5Year(otherVault)); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + advanced_backup_setting: [ + { backup_options: { WindowsVSS: "enabled" }, resource_type: "EC2" }, + ], + }); + }); + + test("daily35DayRetention", () => { + // WHEN + const stack = testStack(); + backup.BackupPlan.daily35DayRetention(stack, "D35"); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "D35", + rule: [ + { + lifecycle: { + delete_after: 35, + }, + rule_name: "Daily", + schedule: "cron(0 5 * * ? *)", + target_vault_name: vaultNameRef(), + }, + ], + }); + }); + + test("dailyWeeklyMonthly7YearRetention", () => { + // WHEN + const stack = testStack(); + backup.BackupPlan.dailyWeeklyMonthly7YearRetention(stack, "DWM7"); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "DWM7", + rule: [ + { + lifecycle: { delete_after: 35 }, + rule_name: "Daily", + schedule: "cron(0 5 * * ? *)", + target_vault_name: vaultNameRef(), + }, + { + lifecycle: { delete_after: 90 }, + rule_name: "Weekly", + schedule: "cron(0 5 ? * SAT *)", + target_vault_name: vaultNameRef(), + }, + { + lifecycle: { delete_after: 2555, cold_storage_after: 90 }, + rule_name: "Monthly7Year", + schedule: "cron(0 5 1 * ? *)", + target_vault_name: vaultNameRef(), + }, + ], + }); + // all three rules share the same auto-created vault + const t2 = new Template(stack); + t2.resourceCountIs(backupVault.BackupVault, 1); + }); + + test("automatically creates a new vault", () => { + // GIVEN + const stack = testStack(); + const plan = new backup.BackupPlan(stack, "Plan"); + + // WHEN + plan.addRule(backup.BackupPlanRule.daily()); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "Plan", + rule: [ + { + lifecycle: { delete_after: 35 }, + rule_name: "Daily", + schedule: "cron(0 5 * * ? *)", + target_vault_name: vaultNameRef(), + }, + ], + }); + }); + + test("create a plan and add rule to copy to a different vault", () => { + // GIVEN + const stack = testStack(); + const primaryVault = new backup.BackupVault(stack, "PrimaryVault"); + const secondaryVault = new backup.BackupVault(stack, "SecondaryVault"); + + // WHEN + new backup.BackupPlan(stack, "Plan", { + backupVault: primaryVault, + backupPlanRules: [ + new backup.BackupPlanRule({ + copyActions: [ + { + destinationBackupVault: secondaryVault, + deleteAfter: Duration.days(120), + moveToColdStorageAfter: Duration.days(30), + }, + ], + }), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "Plan", + rule: [ + { + rule_name: "PlanRule0", + target_vault_name: vaultNameRef(), + copy_action: [ + { + destination_vault_arn: vaultArnRef(), + lifecycle: { + delete_after: 120, + cold_storage_after: 30, + }, + }, + ], + }, + ], + }); + }); + + test("create a plan and add rule with recoveryPointTags", () => { + // GIVEN + const stack = testStack(); + const tags = { + key: "value", + }; + + // WHEN + new backup.BackupPlan(stack, "Plan", { + backupPlanRules: [ + new backup.BackupPlanRule({ + recoveryPointTags: tags, + }), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + name: "Plan", + rule: [ + { + rule_name: "PlanRule0", + target_vault_name: vaultNameRef(), + recovery_point_tags: { + key: "value", + }, + }, + ], + }); + }); + + test("throws when deleteAfter is not greater than moveToColdStorageAfter", () => { + expect( + () => + new backup.BackupPlanRule({ + deleteAfter: Duration.days(5), + moveToColdStorageAfter: Duration.days(6), + }), + ).toThrow(/`deleteAfter` must be greater than `moveToColdStorageAfter`/); + }); + + test("throws when scheduleExpression is not of type cron", () => { + expect( + () => + new backup.BackupPlanRule({ + scheduleExpression: notify.Schedule.rate(Duration.hours(5)), + }), + ).toThrow(/`scheduleExpression` must be of type `cron`/); + }); + + test("synth fails when plan has no rules", () => { + // GIVEN + const app = Testing.app(); + const myStack = testStack(app, "Stack"); + + // WHEN + new backup.BackupPlan(myStack, "Plan"); + + expect(() => app.synth()).toThrow( + /A backup plan must have at least 1 rule/, + ); + }); + + test("throws when moveToColdStorageAfter is used with enableContinuousBackup", () => { + expect( + () => + new backup.BackupPlanRule({ + enableContinuousBackup: true, + deleteAfter: Duration.days(30), + moveToColdStorageAfter: Duration.days(10), + }), + ).toThrow( + /`moveToColdStorageAfter` must not be specified if `enableContinuousBackup` is enabled/, + ); + }); + + test("throws when deleteAfter is less than 1 in combination with enableContinuousBackup", () => { + expect( + () => + new backup.BackupPlanRule({ + enableContinuousBackup: true, + deleteAfter: Duration.days(0), + }), + ).toThrow( + /'deleteAfter' must be between 1 and 35 days if 'enableContinuousBackup' is enabled, but got 0 days/, + ); + }); + + test("throws when deleteAfter is greater than 35 in combination with enableContinuousBackup", () => { + expect( + () => + new backup.BackupPlanRule({ + enableContinuousBackup: true, + deleteAfter: Duration.days(36), + }), + ).toThrow( + /'deleteAfter' must be between 1 and 35 days if 'enableContinuousBackup' is enabled, but got 36 days/, + ); + }); + + test("throws when deleteAfter is not greater than moveToColdStorageAfter in a copy action", () => { + const stack = testStack(); + expect( + () => + new backup.BackupPlanRule({ + copyActions: [ + { + destinationBackupVault: new backup.BackupVault(stack, "Vault"), + deleteAfter: Duration.days(5), + moveToColdStorageAfter: Duration.days(6), + }, + ], + }), + ).toThrow( + /deleteAfter' must at least 90 days later than corresponding 'moveToColdStorageAfter'\nreceived 'deleteAfter: 5' and 'moveToColdStorageAfter: 6'/, + ); + }); + + test("throws when deleteAfter is not greater than 90 days past moveToColdStorageAfter parameter in a copy action", () => { + const stack = testStack(); + expect( + () => + new backup.BackupPlanRule({ + copyActions: [ + { + destinationBackupVault: new backup.BackupVault(stack, "Vault"), + deleteAfter: Duration.days(45), + moveToColdStorageAfter: Duration.days(30), + }, + ], + }), + ).toThrow( + /'deleteAfter' must at least 90 days later than corresponding 'moveToColdStorageAfter'\nreceived 'deleteAfter: 45' and 'moveToColdStorageAfter: 30'/, + ); + }); + + test("does not throw when deleteAfter is a token and moveToColdStorageAfter is set", () => { + expect( + () => + new backup.BackupPlanRule({ + deleteAfter: Duration.days(Lazy.numberValue({ produce: () => 365 })), + moveToColdStorageAfter: Duration.days(30), + }), + ).not.toThrow(); + }); + + test("does not throw when moveToColdStorageAfter is a token", () => { + expect( + () => + new backup.BackupPlanRule({ + deleteAfter: Duration.days(365), + moveToColdStorageAfter: Duration.days( + Lazy.numberValue({ produce: () => 30 }), + ), + }), + ).not.toThrow(); + }); + + test("does not throw when deleteAfter is a token in combination with enableContinuousBackup", () => { + expect( + () => + new backup.BackupPlanRule({ + enableContinuousBackup: true, + deleteAfter: Duration.days(Lazy.numberValue({ produce: () => 14 })), + }), + ).not.toThrow(); + }); + + test("does not throw when copy action durations are tokens, regardless of token creation order", () => { + const stack = testStack(); + const moveToColdStorageAfter = Duration.days( + Lazy.numberValue({ produce: () => 30 }), + ); + const deleteAfter = Duration.days(Lazy.numberValue({ produce: () => 365 })); + expect( + () => + new backup.BackupPlanRule({ + copyActions: [ + { + destinationBackupVault: new backup.BackupVault(stack, "Vault"), + deleteAfter, + moveToColdStorageAfter, + }, + ], + }), + ).not.toThrow(); + }); + + test("renders a lifecycle that references a Terraform variable", () => { + // GIVEN + // TERRACONSTRUCTS DEVIATION: upstream uses `CfnParameter` (a CloudFormation template + // parameter, `Ref: 'RetentionDays'`); the Terraform analog exercised here is + // `TerraformVariable`, whose value renders as an interpolation reference + // (`${var.RetentionDays}`) instead of a CFN `{ Ref: ... }` object. + const stack = testStack(); + const retentionDays = new TerraformVariable(stack, "RetentionDays", { + type: "number", + default: 365, + }); + + // WHEN + new backup.BackupPlan(stack, "Plan", { + backupPlanRules: [ + new backup.BackupPlanRule({ + deleteAfter: Duration.days(retentionDays.numberValue), + moveToColdStorageAfter: Duration.days(30), + scheduleExpression: notify.Schedule.cron({ hour: "5", minute: "0" }), + }), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + rule: [ + expect.objectContaining({ + lifecycle: { + delete_after: "${var.RetentionDays}", + cold_storage_after: 30, + }, + }), + ], + }); + }); + + // Required regression test for the block-typed-L1-arg-accumulated-after-construction footgun + // documented at length in `../../../../src/aws/storage/backup/plan.ts` (`addRule()`). + test("adding a rule after construction resolves Lazy tokens in the synthesized JSON", () => { + // GIVEN + const stack = testStack(); + const plan = new backup.BackupPlan(stack, "Plan", { + backupPlanRules: [ + new backup.BackupPlanRule({ + deleteAfter: Duration.days(Lazy.numberValue({ produce: () => 7 })), + }), + ], + }); + + // WHEN -- addRule() called well after the constructor (and thus after the `aws_backup_plan` + // L1 resource) returned, with a rule whose `deleteAfter` is itself an unresolved Lazy token. + plan.addRule( + new backup.BackupPlanRule({ + ruleName: "AddedAfterConstruction", + deleteAfter: Duration.days(Lazy.numberValue({ produce: () => 42 })), + }), + ); + + // THEN -- both rules (including the Lazy `deleteAfter` values) land in the synthesized JSON + // as concrete numbers, not `[object Object]`/dropped/empty blocks. + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupPlan.BackupPlan, { + rule: [ + expect.objectContaining({ + rule_name: "PlanRule0", + lifecycle: { delete_after: 7 }, + }), + expect.objectContaining({ + rule_name: "AddedAfterConstruction", + lifecycle: { delete_after: 42 }, + }), + ], + }); + }); + + test("fromBackupPlanId renders a colon-separated ARN", () => { + // GIVEN + const stack = testStack(); + + // WHEN + const imported = backup.BackupPlan.fromBackupPlanId( + stack, + "Imported", + "id", + ); + + // THEN -- `backup-plan:`, matching upstream v2.263.0 plan.ts's + // `arnFormat: ArnFormat.COLON_RESOURCE_NAME`, not the default `backup-plan/` slash format. + expect(imported.backupPlanArn).toMatch(/:backup-plan:id$/); + }); +}); diff --git a/test/aws/storage/backup/selection.test.ts b/test/aws/storage/backup/selection.test.ts new file mode 100644 index 00000000..98a24c7b --- /dev/null +++ b/test/aws/storage/backup/selection.test.ts @@ -0,0 +1,422 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/test/selection.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { backupSelection, iamRolePolicyAttachment } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { Construct } from "constructs"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as backup from "../../../../src/aws/storage/backup"; +import * as rds from "../../../../src/aws/storage/rds"; +import { AttributeType } from "../../../../src/aws/storage/shared"; +import { Table } from "../../../../src/aws/storage/table"; +import { Size } from "../../../../src/size"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +// See the identical adaptation note in `./plan.test.ts` -- cross-references render as CDKTF +// interpolation strings with a construct-path-hashed logical id, so a shape-only regex is used +// instead of hardcoding the hash. +const arnRefMatching = (fragment: string) => expect.stringContaining(fragment); + +describe("BackupSelection", () => { + let stack: AwsStack; + let plan: backup.BackupPlan; + beforeEach(() => { + stack = testStack(); + plan = backup.BackupPlan.dailyWeeklyMonthly5YearRetention(stack, "Plan"); + }); + + test("create a selection", () => { + // WHEN + new backup.BackupSelection(stack, "Selection", { + backupPlan: plan, + resources: [ + backup.BackupResource.fromArn("arn1"), + backup.BackupResource.fromArn("arn2"), + backup.BackupResource.fromTag("stage", "prod"), + backup.BackupResource.fromTag("cost center", "cloud"), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + plan_id: arnRefMatching("aws_backup_plan"), + name: "Selection", + iam_role_arn: arnRefMatching("aws_iam_role"), + selection_tag: [ + { + key: "stage", + type: "STRINGEQUALS", + value: "prod", + }, + { + key: "cost center", + type: "STRINGEQUALS", + value: "cloud", + }, + ], + resources: ["arn1", "arn2"], + }); + + t.expect.toHaveResourceWithProperties( + iamRolePolicyAttachment.IamRolePolicyAttachment, + { + policy_arn: arnRefMatching( + ":iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup", + ), + }, + ); + }); + + test("no policy is attached if disableDefaultBackupPolicy is true", () => { + // WHEN + new backup.BackupSelection(stack, "Selection", { + backupPlan: plan, + resources: [backup.BackupResource.fromArn("arn1")], + disableDefaultBackupPolicy: true, + }); + + // THEN + const t = new Template(stack); + t.resourceCountIs(iamRolePolicyAttachment.IamRolePolicyAttachment, 0); + }); + + test("allow restores", () => { + // WHEN + new backup.BackupSelection(stack, "Selection", { + backupPlan: plan, + resources: [backup.BackupResource.fromArn("arn1")], + allowRestores: true, + }); + + // THEN + const t = new Template(stack); + t.resourceCountIs(iamRolePolicyAttachment.IamRolePolicyAttachment, 2); + t.expect.toHaveResourceWithProperties( + iamRolePolicyAttachment.IamRolePolicyAttachment, + { + policy_arn: arnRefMatching( + ":iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup", + ), + }, + ); + t.expect.toHaveResourceWithProperties( + iamRolePolicyAttachment.IamRolePolicyAttachment, + { + policy_arn: arnRefMatching( + ":iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores", + ), + }, + ); + }); + + // TODO: omitted -- upstream's `fromConstruct` test also exercises `efs.CfnFileSystem` / + // `BackupResource.fromEfsFileSystem`. EFS has not been ported to this repo -- see the identical + // omission notes in `../../../../src/aws/storage/backup/resource.ts` and + // `backupable-resources-collector.ts`. + test("fromConstruct", () => { + // GIVEN + class MyConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + new Table(this, "Table", { + partitionKey: { + name: "id", + type: AttributeType.STRING, + }, + }); + + const vpc = new compute.Vpc(this, "Vpc"); + + new rds.DatabaseInstance(this, "DatabaseInstance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_39, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + new rds.DatabaseCluster(this, "DatabaseCluster", { + engine: rds.DatabaseClusterEngine.auroraMysql({ + version: rds.AuroraMysqlEngineVersion.VER_3_04_0, + }), + credentials: rds.Credentials.fromGeneratedSecret("clusteradmin"), + instanceProps: { + vpc, + }, + }); + + new rds.ServerlessCluster(this, "ServerlessCluster", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + this, + "ParameterGroup", + "default.aurora-postgresql11", + ), + vpc, + }); + + // `ec2Instance.Instance` / `ebsVolume.EbsVolume` collector branches -- previously + // exercised only via the dedicated `fromEc2Instance` test (a different code path that + // never runs the `BackupableResourcesCollector` Aspect); included here too so the + // Aspect-driven collection of both is asserted, mirroring upstream's `fromConstruct` + // test omitting only `CfnFileSystem` (EFS, not ported -- see TODO above). + new compute.Instance(this, "Instance", { + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.T3, + compute.InstanceSize.NANO, + ), + machineImage: new compute.AmazonLinuxImage({ + generation: compute.AmazonLinuxGeneration.AMAZON_LINUX_2, + }), + }); + + new compute.Volume(this, "Volume", { + availabilityZone: "us-east-1a", + size: Size.gibibytes(8), + }); + } + } + const myConstruct = new MyConstruct(stack, "MyConstruct"); + + // WHEN + plan.addSelection("Selection", { + resources: [backup.BackupResource.fromConstruct(myConstruct)], + }); + + // THEN + const t = new Template(stack); + // `rds.ServerlessCluster` (Aurora Serverless v1) is provisioned via the same `aws_rds_cluster` + // Terraform resource as `rds.DatabaseCluster` -- see the identical note on the + // `fromRdsServerlessCluster` test below -- so the collector emits a second `aws_rds_cluster` + // match here. + // + // Full-shape regexes (rather than the looser `arnRefMatching` substring helper) pin down the + // exact Terraform attribute each ARN is built from -- catching, e.g., the difference between + // `aws_db_instance`'s `.id` (RDS DBI resource ID, wrong) and `.identifier` (correct) that a + // substring-only match on the resource type would miss. Mirrors the style already used in the + // `fromRdsDatabaseInstance`/`fromRdsDatabaseCluster` tests below. + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + name: "Selection", + resources: [ + expect.stringMatching(/:table\/\$\{aws_dynamodb_table\.\w+\.id\}$/), + expect.stringMatching(/:db:\$\{aws_db_instance\.\w+\.identifier\}$/), + expect.stringMatching(/:cluster:\$\{aws_rds_cluster\.\w+\.id\}$/), + expect.stringMatching(/:cluster:\$\{aws_rds_cluster\.\w+\.id\}$/), + expect.stringMatching(/:instance\/\$\{aws_instance\.\w+\.id\}$/), + expect.stringMatching(/:volume\/\$\{aws_ebs_volume\.\w+\.id\}$/), + ], + }); + }); + + test("fromEc2Instance", () => { + // GIVEN + const vpc = new compute.Vpc(stack, "Vpc"); + const instance = new compute.Instance(stack, "Instance", { + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.T3, + compute.InstanceSize.NANO, + ), + machineImage: new compute.AmazonLinuxImage({ + generation: compute.AmazonLinuxGeneration.AMAZON_LINUX_2, + }), + }); + + // WHEN + plan.addSelection("Selection", { + resources: [backup.BackupResource.fromEc2Instance(instance)], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + name: "Selection", + resources: [arnRefMatching("aws_instance")], + }); + }); + + test("fromDynamoDbTable", () => { + // GIVEN + const newTable = new Table(stack, "New", { + partitionKey: { + name: "id", + type: AttributeType.STRING, + }, + }); + const existingTable = Table.fromTableArn( + stack, + "Existing", + "arn:aws:dynamodb:eu-west-1:123456789012:table/existing", + ); + + // WHEN + plan.addSelection("Selection", { + resources: [ + backup.BackupResource.fromDynamoDbTable(newTable), + backup.BackupResource.fromDynamoDbTable(existingTable), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + name: "Selection", + resources: [ + arnRefMatching("aws_dynamodb_table"), + "arn:aws:dynamodb:eu-west-1:123456789012:table/existing", + ], + }); + }); + + test("fromRdsDatabaseInstance", () => { + // GIVEN + const vpc = new compute.Vpc(stack, "Vpc"); + const newInstance = new rds.DatabaseInstance(stack, "New", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_39, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.SMALL, + ), + vpc, + }); + const existingInstance = + rds.DatabaseInstance.fromDatabaseInstanceAttributes(stack, "Existing", { + instanceEndpointAddress: "address", + instanceIdentifier: "existing-instance", + port: 3306, + securityGroups: [], + }); + + // WHEN + plan.addSelection("Selection", { + resources: [ + backup.BackupResource.fromRdsDatabaseInstance(newInstance), + backup.BackupResource.fromRdsDatabaseInstance(existingInstance), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + name: "Selection", + resources: [ + expect.stringMatching(/:db:\$\{aws_db_instance\.\w+\.identifier\}$/), + expect.stringMatching(/:db:existing-instance$/), + ], + }); + }); + + test("fromRdsDatabaseCluster", () => { + // GIVEN + const vpc = new compute.Vpc(stack, "Vpc"); + const newCluster = new rds.DatabaseCluster(stack, "New", { + engine: rds.DatabaseClusterEngine.auroraMysql({ + version: rds.AuroraMysqlEngineVersion.VER_3_04_0, + }), + credentials: rds.Credentials.fromGeneratedSecret("clusteradmin"), + instanceProps: { + vpc, + }, + }); + const existingCluster = rds.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Existing", + { + clusterIdentifier: "existing-cluster", + }, + ); + + // WHEN + plan.addSelection("Selection", { + resources: [ + backup.BackupResource.fromRdsDatabaseCluster(newCluster), + backup.BackupResource.fromRdsDatabaseCluster(existingCluster), + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + name: "Selection", + resources: [ + expect.stringMatching( + /:cluster:\$\{aws_rds_cluster\.\w+\.cluster_identifier\}$/, + ), + expect.stringMatching(/:cluster:existing-cluster$/), + ], + }); + }); + + test("fromRdsServerlessCluster", () => { + // GIVEN + const vpc = new compute.Vpc(stack, "Vpc"); + const newCluster = new rds.ServerlessCluster(stack, "New", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + stack, + "ParameterGroup", + "default.aurora-postgresql11", + ), + vpc, + }); + const existingCluster = + rds.ServerlessCluster.fromServerlessClusterAttributes(stack, "Existing", { + clusterIdentifier: "existing-cluster", + }); + + // WHEN + plan.addSelection("Selection", { + resources: [ + backup.BackupResource.fromRdsServerlessCluster(newCluster), + backup.BackupResource.fromRdsServerlessCluster(existingCluster), + ], + }); + + // THEN + const t = new Template(stack); + // TERRACONSTRUCTS DEVIATION: `rds.ServerlessCluster` (Aurora Serverless v1, deprecation-kept + // per `../rds/serverless-cluster.ts`) is provisioned via the same `aws_rds_cluster` Terraform + // resource as `rds.DatabaseCluster` -- so `fromRdsServerlessCluster`'s rendered ARN has the + // same `:cluster:` shape as `fromRdsDatabaseCluster` above, not a distinct one. + t.expect.toHaveResourceWithProperties(backupSelection.BackupSelection, { + name: "Selection", + resources: [ + expect.stringMatching( + /:cluster:\$\{aws_rds_cluster\.\w+\.cluster_identifier\}$/, + ), + expect.stringMatching(/:cluster:existing-cluster$/), + ], + }); + }); +}); diff --git a/test/aws/storage/backup/vault.test.ts b/test/aws/storage/backup/vault.test.ts new file mode 100644 index 00000000..7a2fd86f --- /dev/null +++ b/test/aws/storage/backup/vault.test.ts @@ -0,0 +1,598 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-backup/test/vault.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note -- see the identical notes in `../../../../src/aws/storage/backup/vault.ts`. + +import { + backupVault, + backupVaultLockConfiguration, + backupVaultNotifications, + backupVaultPolicy, + dataAwsIamPolicyDocument, + iamRolePolicy, +} from "@cdktn/provider-aws"; +import { App, Lazy, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import { ArnFormat } from "../../../../src/aws/arn"; +import * as encryption from "../../../../src/aws/encryption"; +import * as iam from "../../../../src/aws/iam"; +import * as sns from "../../../../src/aws/notify"; +import * as backup from "../../../../src/aws/storage/backup/vault"; +import { Duration } from "../../../../src/duration"; +import { Fn } from "../../../../src/terra-func"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; +beforeEach(() => { + stack = testStack(); +}); + +test("create a vault", () => { + // WHEN + new backup.BackupVault(stack, "Vault"); + + // THEN + // TERRACONSTRUCTS DEVIATION: upstream asserts the literal logical-id-derived name `'Vault'` + // (CFN's default `BackupVaultName` fallback via `Names.uniqueId`). This repo's + // `uniqueVaultName()` uses the gridUUID-scoped `uniqueResourceName` idiom instead (see the note + // on that method), so an omitted `backupVaultName` synthesizes to a longer, stack-path-derived + // name -- assert existence/shape rather than upstream's literal. + const t = new Template(stack); + t.resourceCountIs(backupVault.BackupVault, 1); + const [vaultResource] = t.resourceTypeArray(backupVault.BackupVault) as { + name: string; + }[]; + expect(vaultResource.name).toMatch(/^[a-zA-Z0-9\-_]{2,50}$/); + // TERRACONSTRUCTS DEVIATION: unlike upstream (which always renders an `AccessPolicy` property, + // just an empty/undefined one), a vault created without `accessPolicy`/ + // `blockRecoveryPointDeletion` never synthesizes the standalone `aws_backup_vault_policy` + // resource (nor its backing `data.aws_iam_policy_document`) at all -- see the note on + // `BackupVaultProps.accessPolicy`. + t.resourceCountIs(backupVaultPolicy.BackupVaultPolicy, 0); + t.dataSourceCountIs(dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, 0); +}); + +test("with access policy", () => { + // GIVEN + const accessPolicy = new iam.PolicyDocument(stack, "VaultAccessPolicy"); + accessPolicy.addStatements( + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + principals: [new iam.AnyPrincipal()], + actions: ["backup:DeleteRecoveryPoint"], + resources: ["*"], + condition: [ + { + test: "StringNotLike", + variable: "aws:userId", + values: ["user-arn"], + }, + ], + }), + ); + + // WHEN + new backup.BackupVault(stack, "Vault", { + accessPolicy, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + effect: "Deny", + principals: [{ type: "AWS", identifiers: ["*"] }], + actions: ["backup:DeleteRecoveryPoint"], + resources: ["*"], + condition: [ + { + test: "StringNotLike", + variable: "aws:userId", + values: ["user-arn"], + }, + ], + }, + ], + }, + ); + t.resourceCountIs(backupVaultPolicy.BackupVaultPolicy, 1); +}); + +test("with blockRecoveryPointDeletion", () => { + // WHEN + new backup.BackupVault(stack, "Vault", { + blockRecoveryPointDeletion: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + effect: "Deny", + principals: [{ type: "AWS", identifiers: ["*"] }], + actions: [ + "backup:DeleteRecoveryPoint", + "backup:UpdateRecoveryPointLifecycle", + ], + resources: ["*"], + }, + ], + }, + ); +}); + +test("merges statements from accessPolicy and blockRecoveryPointDeletion", () => { + // GIVEN + const accessPolicy = new iam.PolicyDocument(stack, "VaultAccessPolicy"); + accessPolicy.addStatements( + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + principals: [ + new iam.ArnPrincipal("arn:aws:iam::123456789012:role/MyRole"), + ], + actions: ["backup:StartRestoreJob"], + }), + ); + + // WHEN + new backup.BackupVault(stack, "Vault", { + accessPolicy, + blockRecoveryPointDeletion: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["backup:StartRestoreJob"], + effect: "Deny", + principals: [ + { + type: "AWS", + identifiers: ["arn:aws:iam::123456789012:role/MyRole"], + }, + ], + }, + { + effect: "Deny", + principals: [{ type: "AWS", identifiers: ["*"] }], + actions: [ + "backup:DeleteRecoveryPoint", + "backup:UpdateRecoveryPointLifecycle", + ], + resources: ["*"], + }, + ], + }, + ); +}); + +test("addToAccessPolicy()", () => { + // GIVEN + const vault = new backup.BackupVault(stack, "Vault"); + + // WHEN + vault.addToAccessPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + principals: [ + new iam.ArnPrincipal("arn:aws:iam::123456789012:role/MyRole"), + ], + actions: ["backup:StartRestoreJob"], + }), + ); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["backup:StartRestoreJob"], + effect: "Deny", + principals: [ + { + type: "AWS", + identifiers: ["arn:aws:iam::123456789012:role/MyRole"], + }, + ], + }, + ], + }, + ); + t.resourceCountIs(backupVaultPolicy.BackupVaultPolicy, 1); +}); + +test("blockRecoveryPointDeletion()", () => { + // GIVEN + const vault = new backup.BackupVault(stack, "Vault"); + + // WHEN + vault.blockRecoveryPointDeletion(); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + effect: "Deny", + principals: [{ type: "AWS", identifiers: ["*"] }], + actions: [ + "backup:DeleteRecoveryPoint", + "backup:UpdateRecoveryPointLifecycle", + ], + resources: ["*"], + }, + ], + }, + ); +}); + +test("with encryption key", () => { + // GIVEN + const encryptionKey = new encryption.Key(stack, "Key"); + + // WHEN + new backup.BackupVault(stack, "Vault", { + encryptionKey, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupVault.BackupVault, { + kms_key_arn: stack.resolve(encryptionKey.keyArn), + }); +}); + +test("with forceDestroy", () => { + // TERRACONSTRUCTS DEVIATION: `forceDestroy` is the native `aws_backup_vault` replacement for + // upstream's dropped `removalPolicy` -- see the note on `BackupVaultProps.forceDestroy`. + // WHEN + new backup.BackupVault(stack, "Vault", { + forceDestroy: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupVault.BackupVault, { + force_destroy: true, + }); +}); + +test("with notifications", () => { + // GIVEN + const topic = new sns.Topic(stack, "Topic"); + + // WHEN + new backup.BackupVault(stack, "Vault", { + notificationTopic: topic, + notificationEvents: [ + backup.BackupVaultEvents.BACKUP_JOB_COMPLETED, + backup.BackupVaultEvents.COPY_JOB_FAILED, + ], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + backupVaultNotifications.BackupVaultNotifications, + { + backup_vault_events: ["BACKUP_JOB_COMPLETED", "COPY_JOB_FAILED"], + sns_topic_arn: stack.resolve(topic.topicArn), + }, + ); +}); + +test("defaults to all notifications", () => { + // GIVEN + const topic = new sns.Topic(stack, "Topic"); + + // WHEN + new backup.BackupVault(stack, "Vault", { + notificationTopic: topic, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + backupVaultNotifications.BackupVaultNotifications, + { + backup_vault_events: Object.values(backup.BackupVaultEvents), + sns_topic_arn: stack.resolve(topic.topicArn), + }, + ); +}); + +test("import from arn", () => { + // WHEN + const vaultArn = stack.formatArn({ + service: "backup", + resource: "backup-vault", + resourceName: "myVaultName", + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + const vault = backup.BackupVault.fromBackupVaultArn(stack, "Vault", vaultArn); + + // THEN + expect(vault.backupVaultName).toEqual("myVaultName"); + expect(vault.backupVaultArn).toEqual(vaultArn); +}); + +test("import from arn should throw if arn format is incorrect", () => { + // WHEN + const vaultArn = stack.formatArn({ + service: "backup", + resource: "backup-vault", + resourceName: "myVaultName", + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + + expect(() => + backup.BackupVault.fromBackupVaultArn(stack, "Vault", vaultArn), + ).toThrow( + /has the wrong format, expected arn:aws:service:region:account:resource:resourceName/, + ); +}); + +test("import from name", () => { + // WHEN + const vaultName = "myVaultName"; + const vault = backup.BackupVault.fromBackupVaultName( + stack, + "Vault", + vaultName, + ); + + // THEN + expect(vault.backupVaultName).toEqual(vaultName); + expect(vault.backupVaultArn).toEqual( + stack.formatArn({ + service: "backup", + resource: "backup-vault", + resourceName: "myVaultName", + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }), + ); +}); + +test("specify imported value as vault name", () => { + // WHEN + const vaultName = Fn.importValue(stack, "VaultName"); + new backup.BackupVault(stack, "Vault", { + backupVaultName: vaultName, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(backupVault.BackupVault, { + name: "${var.VaultName}", + }); +}); + +test("grant action", () => { + // GIVEN + const vaultName = "myVaultName"; + const vault = backup.BackupVault.fromBackupVaultName( + stack, + "Vault", + vaultName, + ); + const role = new iam.Role(stack, "role", { + assumedBy: new iam.ServicePrincipal("lambda"), + }); + + // WHEN + vault.grant(role, "backup:StartBackupJob"); + + // THEN + const t = new Template(stack); + t.resourceCountIs(iamRolePolicy.IamRolePolicy, 1); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["backup:StartBackupJob"], + effect: "Allow", + resources: [stack.resolve(vault.backupVaultArn)], + }, + ], + }, + ); +}); + +test("throw when grant action includes wildcard", () => { + // GIVEN + const vaultName = "myVaultName"; + const vault = backup.BackupVault.fromBackupVaultName( + stack, + "Vault", + vaultName, + ); + const role = new iam.Role(stack, "role", { + assumedBy: new iam.ServicePrincipal("lambda"), + }); + + // WHEN / THEN + expect(() => vault.grant(role, "backup:*")).toThrow( + /AWS Backup access policies don't support a wildcard in the Action key\./, + ); +}); + +test("throws with invalid name", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + backupVaultName: "Hello!Inv@lid", + }), + ).toThrow(/Expected vault name to match pattern/); +}); + +test("throws with whitespace in name", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + backupVaultName: "Hello Invalid", + }), + ).toThrow(/Expected vault name to match pattern/); +}); + +test("throws with too short name", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + backupVaultName: "x", + }), + ).toThrow(/Expected vault name to match pattern/); +}); + +test("with lock configuration", () => { + // WHEN + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.days(30), + maxRetention: Duration.days(365), + changeableFor: Duration.days(7), + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + backupVaultLockConfiguration.BackupVaultLockConfiguration, + { + changeable_for_days: 7, + max_retention_days: 365, + min_retention_days: 30, + }, + ); +}); + +test("throws with incorrect lock configuration - min retention", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.hours(12), + }, + }), + ).toThrow(/The shortest minimum retention period you can specify is 1 day/); +}); + +test("throws with incorrect lock configuration - max retention", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.days(7), + maxRetention: Duration.days(40000), + }, + }), + ).toThrow( + /The longest maximum retention period you can specify is 36500 days/, + ); +}); + +test("throws with incorrect lock configuration - max and min retention", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.days(7), + maxRetention: Duration.days(4), + }, + }), + ).toThrow( + /The maximum retention period \(4 days\) must be greater than the minimum retention period \(7 days\)/, + ); +}); + +test("throws with incorrect lock configuration - changeable for", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.days(7), + changeableFor: Duration.days(1), + }, + }), + ).toThrow( + /AWS Backup enforces a 72-hour cooling-off period before Vault Lock takes effect and becomes immutable/, + ); +}); + +test("lock configuration with tokenized minRetention renders a reference", () => { + // GIVEN + // TERRACONSTRUCTS DEVIATION: upstream uses `CfnParameter`, this repo's `TerraformVariable` + // (used the same way -- an unresolved Token at synth time) is the TerraConstructs-native + // stand-in (mirrors `../docdb/cluster.test.ts`'s equivalent adaptation). + const minRetentionDays = new TerraformVariable(stack, "MinRetentionDays", { + type: "number", + default: 30, + }); + + // WHEN + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.days(minRetentionDays.numberValue), + maxRetention: Duration.days(365), + changeableFor: Duration.days(7), + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + backupVaultLockConfiguration.BackupVaultLockConfiguration, + { + changeable_for_days: 7, + max_retention_days: 365, + min_retention_days: "${var.MinRetentionDays}", + }, + ); +}); + +test("does not throw when maxRetention and changeableFor are tokens", () => { + expect( + () => + new backup.BackupVault(stack, "Vault", { + lockConfiguration: { + minRetention: Duration.days(7), + maxRetention: Duration.days(Lazy.numberValue({ produce: () => 365 })), + changeableFor: Duration.days(Lazy.numberValue({ produce: () => 7 })), + }, + }), + ).not.toThrow(); +}); From 133592b9c151caaeef23fdd52420517027d95d92 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Fri, 7 Aug 2026 09:43:01 +0700 Subject: [PATCH 2/2] =?UTF-8?q?test(integ):=20live=20backup.plan=20?= =?UTF-8?q?=E2=80=94=20vault=20+=20plan=20+=20selection=20over=20a=20Dynam?= =?UTF-8?q?oDB=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real BackupVault (force_destroy) + BackupPlan with the daily static-factory rule AND a weekly rule added after construction (live proof of the Lazy.anyValue rule-block design) + BackupSelection over the table ARN and a stage=prod tag condition. Read-backs: GetBackupPlan rules with per-rule retention (Daily 35d / Weekly 90d), DescribeBackupVault, GetBackupSelection resources/tags/role, post-apply drift oracle. Adds aws-sdk-go-v2/service/backup v1.60.0. --- go.mod | 1 + go.sum | 2 + integ/aws/storage/Makefile | 4 ++ integ/aws/storage/apps/backup.plan.ts | 77 ++++++++++++++++++++++++ integ/aws/storage/backup_plan_test.go | 84 +++++++++++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 integ/aws/storage/apps/backup.plan.ts create mode 100644 integ/aws/storage/backup_plan_test.go diff --git a/go.mod b/go.mod index fc5b8a3b..95573479 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 47a3ed74..04e9fddc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 1e62b9f8..56e32fe4 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -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 diff --git a/integ/aws/storage/apps/backup.plan.ts b/integ/aws/storage/apps/backup.plan.ts new file mode 100644 index 00000000..3f980106 --- /dev/null +++ b/integ/aws/storage/apps/backup.plan.ts @@ -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(); diff --git a/integ/aws/storage/backup_plan_test.go b/integ/aws/storage/backup_plan_test.go new file mode 100644 index 00000000..eaa1efeb --- /dev/null +++ b/integ/aws/storage/backup_plan_test.go @@ -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) +}