@go-to-k/cdkd 0.102.7 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/cli.js +522 -2
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -609,6 +609,7 @@ types:
|
|
|
609
609
|
| `AWS::Neptune::DBCluster` | `DeletionProtection` |
|
|
610
610
|
| `AWS::Neptune::DBInstance` | `DeletionProtection` |
|
|
611
611
|
| `AWS::DynamoDB::Table` | `DeletionProtectionEnabled` |
|
|
612
|
+
| `AWS::DynamoDB::GlobalTable` | `DeletionProtectionEnabled` (CDK v2 `dynamodb.TableV2`) |
|
|
612
613
|
| `AWS::EC2::Instance` | `DisableApiTermination` |
|
|
613
614
|
| `AWS::ElasticLoadBalancingV2::LoadBalancer` | attribute `deletion_protection.enabled` |
|
|
614
615
|
| `AWS::Cognito::UserPool` | `DeletionProtection` (`ACTIVE` / `INACTIVE`) |
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ import { CreateTopicCommand, DeleteTopicCommand, GetSubscriptionAttributesComman
|
|
|
9
9
|
import { AddPermissionCommand, CreateEventSourceMappingCommand, CreateFunctionCommand, CreateFunctionUrlConfigCommand, DeleteEventSourceMappingCommand, DeleteFunctionCommand, DeleteFunctionUrlConfigCommand, DeleteLayerVersionCommand, GetEventSourceMappingCommand, GetFunctionCommand, GetFunctionUrlConfigCommand, GetLayerVersionByArnCommand, GetPolicyCommand, LambdaClient, ListFunctionsCommand, ListLayersCommand, ListTagsCommand, PublishLayerVersionCommand, RemovePermissionCommand, ResourceNotFoundException, TagResourceCommand as TagResourceCommand$1, UntagResourceCommand as UntagResourceCommand$1, UpdateEventSourceMappingCommand, UpdateFunctionCodeCommand, UpdateFunctionConfigurationCommand, UpdateFunctionUrlConfigCommand, waitUntilFunctionUpdatedV2 } from "@aws-sdk/client-lambda";
|
|
10
10
|
import { AssumeRoleCommand, GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
|
|
11
11
|
import { AssociateRouteTableCommand, AttachInternetGatewayCommand, AuthorizeSecurityGroupEgressCommand, AuthorizeSecurityGroupIngressCommand, CreateInternetGatewayCommand, CreateNatGatewayCommand, CreateNetworkAclCommand, CreateNetworkAclEntryCommand, CreateRouteCommand, CreateRouteTableCommand, CreateSecurityGroupCommand, CreateSubnetCommand, CreateTagsCommand, CreateVpcCommand, DeleteInternetGatewayCommand, DeleteNatGatewayCommand, DeleteNetworkAclCommand, DeleteNetworkAclEntryCommand, DeleteNetworkInterfaceCommand, DeleteRouteCommand, DeleteRouteTableCommand, DeleteSecurityGroupCommand, DeleteSubnetCommand, DeleteTagsCommand, DeleteVpcCommand, DescribeAvailabilityZonesCommand, DescribeInstanceAttributeCommand, DescribeInstancesCommand, DescribeInternetGatewaysCommand, DescribeNatGatewaysCommand, DescribeNetworkAclsCommand, DescribeNetworkInterfacesCommand, DescribeRouteTablesCommand, DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVolumesCommand, DescribeVpcAttributeCommand, DescribeVpcsCommand, DetachInternetGatewayCommand, DisassociateRouteTableCommand, EC2Client, ModifyInstanceAttributeCommand, ModifySubnetAttributeCommand, ModifyVpcAttributeCommand, ReplaceNetworkAclAssociationCommand, RevokeSecurityGroupEgressCommand, RevokeSecurityGroupIngressCommand, RunInstancesCommand, TerminateInstancesCommand, waitUntilInstanceRunning, waitUntilInstanceTerminated, waitUntilNatGatewayAvailable, waitUntilNatGatewayDeleted } from "@aws-sdk/client-ec2";
|
|
12
|
-
import { CreateTableCommand, DeleteTableCommand, DescribeTableCommand, DynamoDBClient, ListTablesCommand, ListTagsOfResourceCommand, ResourceNotFoundException as ResourceNotFoundException$1, TagResourceCommand as TagResourceCommand$2, UntagResourceCommand as UntagResourceCommand$2, UpdateTableCommand } from "@aws-sdk/client-dynamodb";
|
|
12
|
+
import { CreateTableCommand, DeleteTableCommand, DescribeTableCommand, DynamoDBClient, ListTablesCommand, ListTagsOfResourceCommand, ResourceNotFoundException as ResourceNotFoundException$1, TagResourceCommand as TagResourceCommand$2, UntagResourceCommand as UntagResourceCommand$2, UpdateTableCommand, UpdateTimeToLiveCommand } from "@aws-sdk/client-dynamodb";
|
|
13
13
|
import { CloudFormationClient, CreateChangeSetCommand, DeleteChangeSetCommand, DeleteStackCommand, DescribeChangeSetCommand, DescribeStackEventsCommand, DescribeStackResourcesCommand, DescribeStacksCommand, DescribeTypeCommand, ExecuteChangeSetCommand, GetTemplateCommand, UpdateStackCommand, waitUntilChangeSetCreateComplete, waitUntilStackDeleteComplete, waitUntilStackImportComplete, waitUntilStackUpdateComplete } from "@aws-sdk/client-cloudformation";
|
|
14
14
|
import { APIGatewayClient, CreateAuthorizerCommand, CreateDeploymentCommand, CreateResourceCommand, CreateStageCommand, DeleteAuthorizerCommand, DeleteDeploymentCommand, DeleteMethodCommand, DeleteResourceCommand, DeleteStageCommand, GetAccountCommand, GetAuthorizerCommand, GetDeploymentCommand, GetMethodCommand, GetResourceCommand, GetStageCommand, NotFoundException as NotFoundException$1, PutIntegrationCommand, PutIntegrationResponseCommand, PutMethodCommand, PutMethodResponseCommand, TagResourceCommand as TagResourceCommand$3, UntagResourceCommand as UntagResourceCommand$3, UpdateAccountCommand, UpdateAuthorizerCommand, UpdateMethodCommand, UpdateStageCommand } from "@aws-sdk/client-api-gateway";
|
|
15
15
|
import { CreateEventBusCommand, DeleteEventBusCommand, DeleteRuleCommand, DescribeEventBusCommand, DescribeRuleCommand, EventBridgeClient, ListEventBusesCommand, ListRulesCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$1, ListTargetsByRuleCommand, PutRuleCommand, PutTargetsCommand, RemoveTargetsCommand, ResourceNotFoundException as ResourceNotFoundException$2, TagResourceCommand as TagResourceCommand$4, UntagResourceCommand as UntagResourceCommand$4, UpdateEventBusCommand } from "@aws-sdk/client-eventbridge";
|
|
@@ -7839,6 +7839,524 @@ var DynamoDBTableProvider = class {
|
|
|
7839
7839
|
}
|
|
7840
7840
|
};
|
|
7841
7841
|
|
|
7842
|
+
//#endregion
|
|
7843
|
+
//#region src/provisioning/providers/dynamodb-globaltable-provider.ts
|
|
7844
|
+
/**
|
|
7845
|
+
* AWS DynamoDB GlobalTable Provider
|
|
7846
|
+
*
|
|
7847
|
+
* Implements resource provisioning for AWS::DynamoDB::GlobalTable using the
|
|
7848
|
+
* standard DynamoDB SDK (2019.11.21 API generation, NOT the legacy
|
|
7849
|
+
* 2017.11.29 endpoints). CDK v2's `dynamodb.TableV2` construct synthesizes
|
|
7850
|
+
* as this type.
|
|
7851
|
+
*
|
|
7852
|
+
* WHY a dedicated SDK provider:
|
|
7853
|
+
* - Pre-PR the type fell through to Cloud Control API which did not pass
|
|
7854
|
+
* a `TableName` field, so AWS auto-generated random names like
|
|
7855
|
+
* `yq2phLewTEUtzr4sy2gYFRU4I-1OGJ0UFLOKOOV` instead of the cdkd
|
|
7856
|
+
* `${stackName}-X<hash>` shape (Issue #383).
|
|
7857
|
+
* - Per memory rule `feedback_dedicated_provider_over_special_case.md`,
|
|
7858
|
+
* the consistent fix is a dedicated SDK Provider rather than adding
|
|
7859
|
+
* the type to `FALLBACK_NAME_RULES`.
|
|
7860
|
+
*
|
|
7861
|
+
* **CRITICAL**: do NOT use the legacy 2017.11.29 endpoints
|
|
7862
|
+
* (`CreateGlobalTableCommand` / `UpdateGlobalTableCommand` /
|
|
7863
|
+
* `DescribeGlobalTableCommand`). The CFn type `AWS::DynamoDB::GlobalTable`
|
|
7864
|
+
* is the 2019.11.21 generation, which uses the regular DynamoDB CRUD API
|
|
7865
|
+
* (`CreateTableCommand` + `UpdateTableCommand` with `ReplicaUpdates`).
|
|
7866
|
+
*
|
|
7867
|
+
* MVP scope:
|
|
7868
|
+
* - `update()` throws `ResourceUpdateNotSupportedError`. In-place GlobalTable
|
|
7869
|
+
* updates (replica add/remove, GSI add/remove, BillingMode flip, throughput
|
|
7870
|
+
* rewrites) are out of scope and a follow-up PR.
|
|
7871
|
+
* - `getDriftUnknownPaths` declares TTL + throughput-settings paths because
|
|
7872
|
+
* cdkd's create/update flows surface them but the read-side reverse
|
|
7873
|
+
* mapping is non-trivial and would fire false drift.
|
|
7874
|
+
* - Per-replica drift (`ContributorInsightsSpecification` /
|
|
7875
|
+
* `PointInTimeRecoverySpecification` / `KinesisStreamSpecification`) is
|
|
7876
|
+
* out of scope for v1.
|
|
7877
|
+
*/
|
|
7878
|
+
var DynamoDBGlobalTableProvider = class {
|
|
7879
|
+
dynamoDBClient;
|
|
7880
|
+
logger = getLogger().child("DynamoDBGlobalTableProvider");
|
|
7881
|
+
attributeCache = /* @__PURE__ */ new Map();
|
|
7882
|
+
handledProperties = new Map([["AWS::DynamoDB::GlobalTable", new Set([
|
|
7883
|
+
"TableName",
|
|
7884
|
+
"KeySchema",
|
|
7885
|
+
"AttributeDefinitions",
|
|
7886
|
+
"BillingMode",
|
|
7887
|
+
"StreamSpecification",
|
|
7888
|
+
"GlobalSecondaryIndexes",
|
|
7889
|
+
"LocalSecondaryIndexes",
|
|
7890
|
+
"SSESpecification",
|
|
7891
|
+
"Replicas",
|
|
7892
|
+
"TableClass",
|
|
7893
|
+
"TimeToLiveSpecification",
|
|
7894
|
+
"WriteProvisionedThroughputSettings",
|
|
7895
|
+
"WriteOnDemandThroughputSettings",
|
|
7896
|
+
"DeletionProtectionEnabled",
|
|
7897
|
+
"Tags"
|
|
7898
|
+
])]]);
|
|
7899
|
+
constructor() {
|
|
7900
|
+
const awsClients = getAwsClients();
|
|
7901
|
+
this.dynamoDBClient = awsClients.dynamoDB;
|
|
7902
|
+
}
|
|
7903
|
+
/**
|
|
7904
|
+
* Create a DynamoDB Global Table (CDK TableV2).
|
|
7905
|
+
*
|
|
7906
|
+
* GlobalTable is built on the regular DynamoDB Table primitive: cdkd issues
|
|
7907
|
+
* `CreateTableCommand` first (which only creates the table in the local
|
|
7908
|
+
* region), waits for `ACTIVE`, then issues one `UpdateTableCommand` per
|
|
7909
|
+
* additional replica region via `ReplicaUpdates: [{ Create: {...} }]`.
|
|
7910
|
+
*
|
|
7911
|
+
* Streams must be enabled with `NEW_AND_OLD_IMAGES` when the table has any
|
|
7912
|
+
* cross-region replicas — AWS rejects the replica-add otherwise. cdkd
|
|
7913
|
+
* auto-enables them with an info log when the template omits it.
|
|
7914
|
+
*
|
|
7915
|
+
* Partial-create cleanup (PR #374-class): if any post-`CreateTableCommand`
|
|
7916
|
+
* wiring (wait ACTIVE → replica adds → TTL → Tags) throws, cdkd issues a
|
|
7917
|
+
* best-effort `DeleteTableCommand` so AWS is not left holding a billing
|
|
7918
|
+
* orphan with no cdkd state record. Cleanup failures escalate to WARN
|
|
7919
|
+
* with the exact `aws dynamodb delete-table --table-name <id>` recovery
|
|
7920
|
+
* command.
|
|
7921
|
+
*/
|
|
7922
|
+
async create(logicalId, resourceType, properties) {
|
|
7923
|
+
this.logger.debug(`Creating DynamoDB GlobalTable ${logicalId}`);
|
|
7924
|
+
const tableName = properties["TableName"] || generateResourceName(logicalId, { maxLength: 255 });
|
|
7925
|
+
const keySchema = properties["KeySchema"];
|
|
7926
|
+
const attributeDefinitions = properties["AttributeDefinitions"];
|
|
7927
|
+
if (!keySchema) throw new ProvisioningError(`KeySchema is required for DynamoDB GlobalTable ${logicalId}`, resourceType, logicalId);
|
|
7928
|
+
if (!attributeDefinitions) throw new ProvisioningError(`AttributeDefinitions is required for DynamoDB GlobalTable ${logicalId}`, resourceType, logicalId);
|
|
7929
|
+
const billingMode = properties["BillingMode"] ?? "PAY_PER_REQUEST";
|
|
7930
|
+
const currentRegion = await this.dynamoDBClient.config.region() ?? "";
|
|
7931
|
+
const replicas = properties["Replicas"] ?? [];
|
|
7932
|
+
const createParams = {
|
|
7933
|
+
TableName: tableName,
|
|
7934
|
+
KeySchema: keySchema,
|
|
7935
|
+
AttributeDefinitions: attributeDefinitions,
|
|
7936
|
+
BillingMode: billingMode
|
|
7937
|
+
};
|
|
7938
|
+
if (billingMode === "PROVISIONED") {
|
|
7939
|
+
const writeAutoScaling = properties["WriteProvisionedThroughputSettings"]?.["WriteCapacityAutoScalingSettings"];
|
|
7940
|
+
const writeCapacity = Number(writeAutoScaling?.["MinCapacity"] ?? 5);
|
|
7941
|
+
const readAutoScaling = (replicas.find((r) => r["Region"] === currentRegion)?.["ReadProvisionedThroughputSettings"])?.["ReadCapacityAutoScalingSettings"];
|
|
7942
|
+
createParams.ProvisionedThroughput = {
|
|
7943
|
+
ReadCapacityUnits: Number(readAutoScaling?.["MinCapacity"] ?? 5),
|
|
7944
|
+
WriteCapacityUnits: writeCapacity
|
|
7945
|
+
};
|
|
7946
|
+
}
|
|
7947
|
+
const streamSpecInput = properties["StreamSpecification"];
|
|
7948
|
+
const needsStream = replicas.some((r) => r["Region"] !== currentRegion) || replicas.length > 1;
|
|
7949
|
+
if (streamSpecInput) createParams.StreamSpecification = {
|
|
7950
|
+
StreamEnabled: true,
|
|
7951
|
+
StreamViewType: streamSpecInput["StreamViewType"] ?? "NEW_AND_OLD_IMAGES"
|
|
7952
|
+
};
|
|
7953
|
+
else if (needsStream) {
|
|
7954
|
+
this.logger.info(`Auto-enabling streams (NEW_AND_OLD_IMAGES) on ${logicalId} — required for cross-region replication`);
|
|
7955
|
+
createParams.StreamSpecification = {
|
|
7956
|
+
StreamEnabled: true,
|
|
7957
|
+
StreamViewType: "NEW_AND_OLD_IMAGES"
|
|
7958
|
+
};
|
|
7959
|
+
}
|
|
7960
|
+
if (properties["GlobalSecondaryIndexes"]) createParams.GlobalSecondaryIndexes = properties["GlobalSecondaryIndexes"];
|
|
7961
|
+
if (properties["LocalSecondaryIndexes"]) createParams.LocalSecondaryIndexes = properties["LocalSecondaryIndexes"];
|
|
7962
|
+
if (properties["SSESpecification"]) {
|
|
7963
|
+
const sse = properties["SSESpecification"];
|
|
7964
|
+
const sseInput = { Enabled: sse["SSEEnabled"] !== void 0 ? Boolean(sse["SSEEnabled"]) : true };
|
|
7965
|
+
if (sse["SSEType"]) sseInput.SSEType = sse["SSEType"];
|
|
7966
|
+
createParams.SSESpecification = sseInput;
|
|
7967
|
+
}
|
|
7968
|
+
if (properties["DeletionProtectionEnabled"] !== void 0) createParams.DeletionProtectionEnabled = properties["DeletionProtectionEnabled"];
|
|
7969
|
+
if (properties["TableClass"]) createParams.TableClass = properties["TableClass"];
|
|
7970
|
+
const wodts = properties["WriteOnDemandThroughputSettings"];
|
|
7971
|
+
if (wodts?.["MaxWriteRequestUnits"] !== void 0) createParams.OnDemandThroughput = { MaxWriteRequestUnits: Number(wodts["MaxWriteRequestUnits"]) };
|
|
7972
|
+
if (properties["Tags"]) createParams.Tags = properties["Tags"];
|
|
7973
|
+
try {
|
|
7974
|
+
await this.dynamoDBClient.send(new CreateTableCommand(createParams));
|
|
7975
|
+
this.logger.debug(`CreateTable initiated for ${tableName}, waiting for ACTIVE`);
|
|
7976
|
+
} catch (error) {
|
|
7977
|
+
const cause = error instanceof Error ? error : void 0;
|
|
7978
|
+
throw new ProvisioningError(`Failed to create DynamoDB GlobalTable ${logicalId}: ${error instanceof Error ? error.message : String(error)}`, resourceType, logicalId, tableName, cause);
|
|
7979
|
+
}
|
|
7980
|
+
try {
|
|
7981
|
+
const tableInfo = await this.waitForTableActive(tableName);
|
|
7982
|
+
for (const replica of replicas) {
|
|
7983
|
+
const region = replica["Region"];
|
|
7984
|
+
if (!region || region === currentRegion) continue;
|
|
7985
|
+
await this.addReplica(tableName, replica, region);
|
|
7986
|
+
}
|
|
7987
|
+
if (properties["TimeToLiveSpecification"]) {
|
|
7988
|
+
const ttl = properties["TimeToLiveSpecification"];
|
|
7989
|
+
const attributeName = ttl["AttributeName"];
|
|
7990
|
+
const enabled = ttl["Enabled"] !== void 0 ? Boolean(ttl["Enabled"]) : true;
|
|
7991
|
+
if (attributeName) await this.dynamoDBClient.send(new UpdateTimeToLiveCommand({
|
|
7992
|
+
TableName: tableName,
|
|
7993
|
+
TimeToLiveSpecification: {
|
|
7994
|
+
Enabled: enabled,
|
|
7995
|
+
AttributeName: attributeName
|
|
7996
|
+
}
|
|
7997
|
+
}));
|
|
7998
|
+
}
|
|
7999
|
+
this.logger.debug(`Successfully created DynamoDB GlobalTable ${logicalId}: ${tableName}`);
|
|
8000
|
+
return {
|
|
8001
|
+
physicalId: tableName,
|
|
8002
|
+
attributes: {
|
|
8003
|
+
Arn: tableInfo.tableArn,
|
|
8004
|
+
TableId: tableInfo.tableId,
|
|
8005
|
+
StreamArn: tableInfo.streamArn,
|
|
8006
|
+
TableName: tableName
|
|
8007
|
+
}
|
|
8008
|
+
};
|
|
8009
|
+
} catch (wiringError) {
|
|
8010
|
+
this.logger.warn(`Wiring failed after CreateTable for ${tableName}; attempting best-effort cleanup`);
|
|
8011
|
+
try {
|
|
8012
|
+
await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: tableName }));
|
|
8013
|
+
} catch (cleanupErr) {
|
|
8014
|
+
const cleanupMsg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
|
8015
|
+
this.logger.warn(`Partial-create cleanup failed for ${tableName}: ${cleanupMsg}. Run: aws dynamodb delete-table --table-name ${tableName} to remove the orphaned AWS-side table.`);
|
|
8016
|
+
}
|
|
8017
|
+
const cause = wiringError instanceof Error ? wiringError : void 0;
|
|
8018
|
+
throw new ProvisioningError(`Failed to create DynamoDB GlobalTable ${logicalId}: ${wiringError instanceof Error ? wiringError.message : String(wiringError)}`, resourceType, logicalId, tableName, cause);
|
|
8019
|
+
}
|
|
8020
|
+
}
|
|
8021
|
+
/**
|
|
8022
|
+
* Add a single replica region. Issues one `UpdateTableCommand` with
|
|
8023
|
+
* `ReplicaUpdates: [{ Create: { RegionName, ... } }]` and polls
|
|
8024
|
+
* `DescribeTable` until the replica's `ReplicaStatus` flips to ACTIVE.
|
|
8025
|
+
* Capped at 10 minutes per replica (AWS Replica provisioning typically
|
|
8026
|
+
* takes 1–5 min).
|
|
8027
|
+
*/
|
|
8028
|
+
async addReplica(tableName, replica, region) {
|
|
8029
|
+
const create = { RegionName: region };
|
|
8030
|
+
if (replica["KMSMasterKeyId"]) create.KMSMasterKeyId = replica["KMSMasterKeyId"];
|
|
8031
|
+
if (replica["GlobalSecondaryIndexes"]) create.GlobalSecondaryIndexes = replica["GlobalSecondaryIndexes"];
|
|
8032
|
+
if (replica["TableClassOverride"]) create.TableClassOverride = replica["TableClassOverride"];
|
|
8033
|
+
const replicaUpdates = [{ Create: create }];
|
|
8034
|
+
await this.dynamoDBClient.send(new UpdateTableCommand({
|
|
8035
|
+
TableName: tableName,
|
|
8036
|
+
ReplicaUpdates: replicaUpdates
|
|
8037
|
+
}));
|
|
8038
|
+
await this.waitForReplicaActive(tableName, region);
|
|
8039
|
+
}
|
|
8040
|
+
/**
|
|
8041
|
+
* Update a DynamoDB Global Table.
|
|
8042
|
+
*
|
|
8043
|
+
* MVP: in-place updates are out of scope — replica add/remove, GSI
|
|
8044
|
+
* add/remove, BillingMode flip, and throughput rewrites each have
|
|
8045
|
+
* distinct UpdateTable shapes and ordering rules. `cdkd drift --revert`
|
|
8046
|
+
* surfaces this as `ResourceUpdateNotSupportedError` (exit code 2);
|
|
8047
|
+
* the user falls back to `cdkd deploy --replace` or destroy + redeploy.
|
|
8048
|
+
*/
|
|
8049
|
+
async update(logicalId, _physicalId, resourceType, _properties, _previousProperties) {
|
|
8050
|
+
throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "GlobalTable in-place updates are not yet supported; use 'cdkd deploy --replace' or destroy + redeploy");
|
|
8051
|
+
}
|
|
8052
|
+
/**
|
|
8053
|
+
* Delete a DynamoDB Global Table.
|
|
8054
|
+
*
|
|
8055
|
+
* Order is load-bearing:
|
|
8056
|
+
* 1. Optional `--remove-protection` flip-off (idempotent).
|
|
8057
|
+
* 2. `DescribeTable` → drop every non-local replica via UpdateTable
|
|
8058
|
+
* `ReplicaUpdates: [{ Delete: { RegionName } }]`, one at a time
|
|
8059
|
+
* (AWS rejects multiple replica updates per call). Each delete
|
|
8060
|
+
* polls until the replica disappears from `Replicas[]`.
|
|
8061
|
+
* 3. `DeleteTableCommand` on the local region only.
|
|
8062
|
+
* 4. Wait for `ResourceNotFoundException` on subsequent DescribeTable.
|
|
8063
|
+
*
|
|
8064
|
+
* DELETE idempotency: a `ResourceNotFoundException` is treated as
|
|
8065
|
+
* success ONLY when the client region matches the state region
|
|
8066
|
+
* (`assertRegionMatch`). A mismatched destroy would otherwise silently
|
|
8067
|
+
* strip a still-existing resource from state.
|
|
8068
|
+
*/
|
|
8069
|
+
async delete(logicalId, physicalId, resourceType, _properties, context) {
|
|
8070
|
+
this.logger.debug(`Deleting DynamoDB GlobalTable ${logicalId}: ${physicalId}`);
|
|
8071
|
+
if (context?.removeProtection === true) try {
|
|
8072
|
+
await this.dynamoDBClient.send(new UpdateTableCommand({
|
|
8073
|
+
TableName: physicalId,
|
|
8074
|
+
DeletionProtectionEnabled: false
|
|
8075
|
+
}));
|
|
8076
|
+
this.logger.debug(`Disabled DeletionProtectionEnabled on ${logicalId}, waiting for ACTIVE`);
|
|
8077
|
+
try {
|
|
8078
|
+
await this.waitForTableActiveAfterUpdate(physicalId);
|
|
8079
|
+
} catch (waitErr) {
|
|
8080
|
+
this.logger.debug(`Could not wait for table ${physicalId} ACTIVE after protection flip: ${waitErr instanceof Error ? waitErr.message : String(waitErr)}`);
|
|
8081
|
+
}
|
|
8082
|
+
} catch (flipError) {
|
|
8083
|
+
if (!(flipError instanceof ResourceNotFoundException$1)) this.logger.debug(`Could not disable DeletionProtectionEnabled on ${physicalId}: ${flipError instanceof Error ? flipError.message : String(flipError)}`);
|
|
8084
|
+
}
|
|
8085
|
+
let currentRegion;
|
|
8086
|
+
try {
|
|
8087
|
+
currentRegion = await this.dynamoDBClient.config.region();
|
|
8088
|
+
} catch (regionErr) {
|
|
8089
|
+
const cause = regionErr instanceof Error ? regionErr : void 0;
|
|
8090
|
+
throw new ProvisioningError(`Could not resolve client region for DynamoDB GlobalTable ${logicalId} delete — would risk dropping the local replica`, resourceType, logicalId, physicalId, cause);
|
|
8091
|
+
}
|
|
8092
|
+
try {
|
|
8093
|
+
const replicas = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: physicalId }))).Table?.Replicas ?? [];
|
|
8094
|
+
for (const replica of replicas) {
|
|
8095
|
+
const region = replica.RegionName;
|
|
8096
|
+
if (!region || region === currentRegion) continue;
|
|
8097
|
+
try {
|
|
8098
|
+
await this.dynamoDBClient.send(new UpdateTableCommand({
|
|
8099
|
+
TableName: physicalId,
|
|
8100
|
+
ReplicaUpdates: [{ Delete: { RegionName: region } }]
|
|
8101
|
+
}));
|
|
8102
|
+
await this.waitForReplicaGone(physicalId, region);
|
|
8103
|
+
} catch (replicaErr) {
|
|
8104
|
+
if (!(replicaErr instanceof ResourceNotFoundException$1)) throw replicaErr;
|
|
8105
|
+
}
|
|
8106
|
+
}
|
|
8107
|
+
} catch (describeErr) {
|
|
8108
|
+
if (!(describeErr instanceof ResourceNotFoundException$1)) {
|
|
8109
|
+
const cause = describeErr instanceof Error ? describeErr : void 0;
|
|
8110
|
+
throw new ProvisioningError(`Failed to describe DynamoDB GlobalTable ${logicalId} before delete: ${describeErr instanceof Error ? describeErr.message : String(describeErr)}`, resourceType, logicalId, physicalId, cause);
|
|
8111
|
+
}
|
|
8112
|
+
}
|
|
8113
|
+
try {
|
|
8114
|
+
await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
|
|
8115
|
+
await this.waitForTableGone(physicalId);
|
|
8116
|
+
this.logger.debug(`Successfully deleted DynamoDB GlobalTable ${logicalId}`);
|
|
8117
|
+
} catch (error) {
|
|
8118
|
+
if (error instanceof ResourceNotFoundException$1) {
|
|
8119
|
+
assertRegionMatch(await this.dynamoDBClient.config.region(), context?.expectedRegion, resourceType, logicalId, physicalId);
|
|
8120
|
+
this.logger.debug(`DynamoDB GlobalTable ${physicalId} does not exist, skipping`);
|
|
8121
|
+
return;
|
|
8122
|
+
}
|
|
8123
|
+
const cause = error instanceof Error ? error : void 0;
|
|
8124
|
+
throw new ProvisioningError(`Failed to delete DynamoDB GlobalTable ${logicalId}: ${error instanceof Error ? error.message : String(error)}`, resourceType, logicalId, physicalId, cause);
|
|
8125
|
+
}
|
|
8126
|
+
}
|
|
8127
|
+
/**
|
|
8128
|
+
* Resolve a single `Fn::GetAtt` attribute for an existing DynamoDB GlobalTable.
|
|
8129
|
+
*
|
|
8130
|
+
* Cached per `(physicalId, attribute)` for the deploy run to avoid
|
|
8131
|
+
* repeated `DescribeTable` calls.
|
|
8132
|
+
*/
|
|
8133
|
+
async getAttribute(physicalId, _resourceType, attributeName) {
|
|
8134
|
+
const cacheKey = `${physicalId}::${attributeName}`;
|
|
8135
|
+
if (this.attributeCache.has(cacheKey)) return this.attributeCache.get(cacheKey);
|
|
8136
|
+
try {
|
|
8137
|
+
const resp = await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: physicalId }));
|
|
8138
|
+
let value;
|
|
8139
|
+
switch (attributeName) {
|
|
8140
|
+
case "Arn":
|
|
8141
|
+
value = resp.Table?.TableArn;
|
|
8142
|
+
break;
|
|
8143
|
+
case "StreamArn":
|
|
8144
|
+
value = resp.Table?.LatestStreamArn;
|
|
8145
|
+
break;
|
|
8146
|
+
case "TableId":
|
|
8147
|
+
value = resp.Table?.TableId;
|
|
8148
|
+
break;
|
|
8149
|
+
default: value = void 0;
|
|
8150
|
+
}
|
|
8151
|
+
this.attributeCache.set(cacheKey, value);
|
|
8152
|
+
return value;
|
|
8153
|
+
} catch (err) {
|
|
8154
|
+
if (err instanceof ResourceNotFoundException$1) return void 0;
|
|
8155
|
+
throw err;
|
|
8156
|
+
}
|
|
8157
|
+
}
|
|
8158
|
+
/**
|
|
8159
|
+
* Read the AWS-current DynamoDB GlobalTable configuration in CFn-property shape.
|
|
8160
|
+
*
|
|
8161
|
+
* Reverse-maps `DescribeTable` + `ListTagsOfResource` into the
|
|
8162
|
+
* `AWS::DynamoDB::GlobalTable` property set.
|
|
8163
|
+
*
|
|
8164
|
+
* Type-discriminator gating (memory rule
|
|
8165
|
+
* `feedback_always_emit_check_type_discriminator.md`):
|
|
8166
|
+
* - `ProvisionedThroughput`-bearing fields are only surfaced when
|
|
8167
|
+
* `BillingMode === 'PROVISIONED'`. Emitting placeholders on
|
|
8168
|
+
* PAY_PER_REQUEST tables (or vice versa) would fire false drift on
|
|
8169
|
+
* every clean run.
|
|
8170
|
+
* - StreamSpecification / SSESpecification follow the existing
|
|
8171
|
+
* DynamoDB::Table provider's Class 1 guard: only surfaced when AWS
|
|
8172
|
+
* reports the feature actually enabled.
|
|
8173
|
+
*
|
|
8174
|
+
* `getDriftUnknownPaths` declares TTL + write-throughput-settings —
|
|
8175
|
+
* those round-trip in the create path but the reverse-mapping is not
|
|
8176
|
+
* yet implemented and would fire guaranteed false drift.
|
|
8177
|
+
*/
|
|
8178
|
+
async readCurrentState(physicalId, _logicalId, _resourceType) {
|
|
8179
|
+
try {
|
|
8180
|
+
const table = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: physicalId }))).Table;
|
|
8181
|
+
if (!table) return void 0;
|
|
8182
|
+
const result = {};
|
|
8183
|
+
if (table.TableName !== void 0) result["TableName"] = table.TableName;
|
|
8184
|
+
if (table.KeySchema) result["KeySchema"] = table.KeySchema;
|
|
8185
|
+
if (table.AttributeDefinitions) result["AttributeDefinitions"] = table.AttributeDefinitions;
|
|
8186
|
+
const billingMode = table.BillingModeSummary?.BillingMode;
|
|
8187
|
+
if (billingMode) result["BillingMode"] = billingMode;
|
|
8188
|
+
if (table.GlobalSecondaryIndexes && table.GlobalSecondaryIndexes.length > 0) result["GlobalSecondaryIndexes"] = table.GlobalSecondaryIndexes;
|
|
8189
|
+
if (table.LocalSecondaryIndexes && table.LocalSecondaryIndexes.length > 0) result["LocalSecondaryIndexes"] = table.LocalSecondaryIndexes;
|
|
8190
|
+
if (table.StreamSpecification?.StreamEnabled && table.StreamSpecification.StreamViewType) result["StreamSpecification"] = {
|
|
8191
|
+
StreamEnabled: true,
|
|
8192
|
+
StreamViewType: table.StreamSpecification.StreamViewType
|
|
8193
|
+
};
|
|
8194
|
+
if (table.SSEDescription?.Status === "ENABLED") {
|
|
8195
|
+
const sse = { SSEEnabled: true };
|
|
8196
|
+
if (table.SSEDescription.SSEType !== void 0) sse["SSEType"] = table.SSEDescription.SSEType;
|
|
8197
|
+
result["SSESpecification"] = sse;
|
|
8198
|
+
}
|
|
8199
|
+
result["Replicas"] = (table.Replicas ?? []).map((r) => ({
|
|
8200
|
+
Region: r.RegionName,
|
|
8201
|
+
...r.KMSMasterKeyId !== void 0 && { KMSMasterKeyId: r.KMSMasterKeyId }
|
|
8202
|
+
}));
|
|
8203
|
+
if (table.TableClassSummary?.TableClass) result["TableClass"] = table.TableClassSummary.TableClass;
|
|
8204
|
+
if (table.DeletionProtectionEnabled !== void 0) result["DeletionProtectionEnabled"] = table.DeletionProtectionEnabled;
|
|
8205
|
+
if (table.TableArn) try {
|
|
8206
|
+
result["Tags"] = normalizeAwsTagsToCfn((await this.dynamoDBClient.send(new ListTagsOfResourceCommand({ ResourceArn: table.TableArn }))).Tags);
|
|
8207
|
+
} catch (err) {
|
|
8208
|
+
if (err instanceof ResourceNotFoundException$1) return void 0;
|
|
8209
|
+
result["Tags"] = [];
|
|
8210
|
+
}
|
|
8211
|
+
else result["Tags"] = [];
|
|
8212
|
+
return result;
|
|
8213
|
+
} catch (err) {
|
|
8214
|
+
if (err instanceof ResourceNotFoundException$1) return void 0;
|
|
8215
|
+
throw err;
|
|
8216
|
+
}
|
|
8217
|
+
}
|
|
8218
|
+
/**
|
|
8219
|
+
* State property paths cdkd's GlobalTable readCurrentState cannot (yet)
|
|
8220
|
+
* reverse-map. The drift comparator skips these so a templated value
|
|
8221
|
+
* doesn't fire guaranteed false drift on every clean run.
|
|
8222
|
+
*
|
|
8223
|
+
* - `TimeToLiveSpecification`: cdkd's create() applies it via
|
|
8224
|
+
* UpdateTimeToLive, but the reverse-mapping needs a separate
|
|
8225
|
+
* DescribeTimeToLive call (not yet implemented).
|
|
8226
|
+
* - `WriteProvisionedThroughputSettings` /
|
|
8227
|
+
* `WriteOnDemandThroughputSettings`: CFn's shapes wrap
|
|
8228
|
+
* auto-scaling / on-demand max-RU settings whose reverse-mapping
|
|
8229
|
+
* from `DescribeTable.ProvisionedThroughput` / `OnDemandThroughput`
|
|
8230
|
+
* is non-trivial and would fire false drift in v1.
|
|
8231
|
+
*/
|
|
8232
|
+
getDriftUnknownPaths(_resourceType) {
|
|
8233
|
+
return [
|
|
8234
|
+
"TimeToLiveSpecification",
|
|
8235
|
+
"WriteProvisionedThroughputSettings",
|
|
8236
|
+
"WriteOnDemandThroughputSettings"
|
|
8237
|
+
];
|
|
8238
|
+
}
|
|
8239
|
+
/**
|
|
8240
|
+
* Adopt an existing DynamoDB GlobalTable into cdkd state.
|
|
8241
|
+
*
|
|
8242
|
+
* Lookup order:
|
|
8243
|
+
* 1. `--resource` override or `Properties.TableName` → verify via DescribeTable.
|
|
8244
|
+
* 2. `ListTables` + `ListTagsOfResource`, match `aws:cdk:path` tag.
|
|
8245
|
+
*
|
|
8246
|
+
* Same shape as `DynamoDBTableProvider.import()`. The provider returns
|
|
8247
|
+
* the physical id only; cdkd's import flow does the attribute capture
|
|
8248
|
+
* separately.
|
|
8249
|
+
*/
|
|
8250
|
+
async import(input) {
|
|
8251
|
+
const explicit = resolveExplicitPhysicalId(input, "TableName");
|
|
8252
|
+
if (explicit) try {
|
|
8253
|
+
await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: explicit }));
|
|
8254
|
+
return {
|
|
8255
|
+
physicalId: explicit,
|
|
8256
|
+
attributes: {}
|
|
8257
|
+
};
|
|
8258
|
+
} catch (err) {
|
|
8259
|
+
if (err instanceof ResourceNotFoundException$1) return null;
|
|
8260
|
+
throw err;
|
|
8261
|
+
}
|
|
8262
|
+
if (!input.cdkPath) return null;
|
|
8263
|
+
let exclusiveStartTableName;
|
|
8264
|
+
do {
|
|
8265
|
+
const list = await this.dynamoDBClient.send(new ListTablesCommand({ ...exclusiveStartTableName && { ExclusiveStartTableName: exclusiveStartTableName } }));
|
|
8266
|
+
for (const name of list.TableNames ?? []) try {
|
|
8267
|
+
const arn = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: name }))).Table?.TableArn;
|
|
8268
|
+
if (!arn) continue;
|
|
8269
|
+
if (matchesCdkPath((await this.dynamoDBClient.send(new ListTagsOfResourceCommand({ ResourceArn: arn }))).Tags, input.cdkPath)) return {
|
|
8270
|
+
physicalId: name,
|
|
8271
|
+
attributes: {}
|
|
8272
|
+
};
|
|
8273
|
+
} catch (err) {
|
|
8274
|
+
if (err instanceof ResourceNotFoundException$1) continue;
|
|
8275
|
+
throw err;
|
|
8276
|
+
}
|
|
8277
|
+
exclusiveStartTableName = list.LastEvaluatedTableName;
|
|
8278
|
+
} while (exclusiveStartTableName);
|
|
8279
|
+
return null;
|
|
8280
|
+
}
|
|
8281
|
+
async waitForTableActive(tableName, maxAttempts = 120) {
|
|
8282
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
8283
|
+
const response = await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }));
|
|
8284
|
+
const status = response.Table?.TableStatus;
|
|
8285
|
+
this.logger.debug(`Table ${tableName} status: ${status} (attempt ${attempt}/${maxAttempts})`);
|
|
8286
|
+
if (status === "ACTIVE") return {
|
|
8287
|
+
tableArn: response.Table?.TableArn,
|
|
8288
|
+
tableId: response.Table?.TableId,
|
|
8289
|
+
streamArn: response.Table?.LatestStreamArn
|
|
8290
|
+
};
|
|
8291
|
+
if (status !== "CREATING" && status !== "UPDATING") throw new Error(`Unexpected table status: ${status}`);
|
|
8292
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
8293
|
+
}
|
|
8294
|
+
throw new Error(`Table ${tableName} did not reach ACTIVE within ${maxAttempts}s`);
|
|
8295
|
+
}
|
|
8296
|
+
/**
|
|
8297
|
+
* Wait for the table to reach ACTIVE after an UpdateTable call. Unlike
|
|
8298
|
+
* `waitForTableActive`, this tolerates any non-terminal status — the
|
|
8299
|
+
* table may already be ACTIVE on the no-op path (already-disabled
|
|
8300
|
+
* protection) or transition through UPDATING.
|
|
8301
|
+
*/
|
|
8302
|
+
async waitForTableActiveAfterUpdate(tableName, maxAttempts = 120) {
|
|
8303
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
8304
|
+
if ((await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.TableStatus === "ACTIVE") return;
|
|
8305
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
8306
|
+
}
|
|
8307
|
+
throw new Error(`Table ${tableName} did not reach ACTIVE within ${maxAttempts}s after UpdateTable`);
|
|
8308
|
+
}
|
|
8309
|
+
/**
|
|
8310
|
+
* Wait until a specific replica's `ReplicaStatus` flips to ACTIVE.
|
|
8311
|
+
* Replica provisioning typically takes 1–5 min; cap at 10 min.
|
|
8312
|
+
*/
|
|
8313
|
+
async waitForReplicaActive(tableName, region, maxAttempts = 600) {
|
|
8314
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
8315
|
+
const replica = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.Replicas?.find((r) => r.RegionName === region);
|
|
8316
|
+
if (replica?.ReplicaStatus === "ACTIVE") return;
|
|
8317
|
+
this.logger.debug(`Replica ${region} status: ${replica?.ReplicaStatus} (attempt ${attempt}/${maxAttempts})`);
|
|
8318
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
8319
|
+
}
|
|
8320
|
+
throw new Error(`Replica ${region} for table ${tableName} did not reach ACTIVE within ${maxAttempts}s`);
|
|
8321
|
+
}
|
|
8322
|
+
/**
|
|
8323
|
+
* Wait until a specific replica disappears from `Replicas[]` after a
|
|
8324
|
+
* Delete replica update. Replica deletion typically takes 1–5 min;
|
|
8325
|
+
* cap at 10 min.
|
|
8326
|
+
*/
|
|
8327
|
+
async waitForReplicaGone(tableName, region, maxAttempts = 600) {
|
|
8328
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
|
|
8329
|
+
if (!(await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.Replicas?.find((r) => r.RegionName === region)) return;
|
|
8330
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
8331
|
+
} catch (err) {
|
|
8332
|
+
if (err instanceof ResourceNotFoundException$1) return;
|
|
8333
|
+
throw err;
|
|
8334
|
+
}
|
|
8335
|
+
throw new Error(`Replica ${region} for table ${tableName} did not disappear within ${maxAttempts}s`);
|
|
8336
|
+
}
|
|
8337
|
+
/**
|
|
8338
|
+
* Wait for `DescribeTable` to return `ResourceNotFoundException`,
|
|
8339
|
+
* confirming the table has actually been removed. `DeleteTable` is
|
|
8340
|
+
* async — the call returns immediately with `TableStatus: DELETING`
|
|
8341
|
+
* and AWS only removes the table some seconds later. Without this
|
|
8342
|
+
* wait, downstream observers (siblings deleted in the same destroy
|
|
8343
|
+
* run, integ scripts that re-check via `aws dynamodb describe-table`)
|
|
8344
|
+
* see "destroy succeeded" but the table is still listed by AWS.
|
|
8345
|
+
* Typical small-table delete completes in 5–30s; cap at 10 min for
|
|
8346
|
+
* worst-case large-table / replica-cascade scenarios.
|
|
8347
|
+
*/
|
|
8348
|
+
async waitForTableGone(tableName, maxAttempts = 600) {
|
|
8349
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
|
|
8350
|
+
await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }));
|
|
8351
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
8352
|
+
} catch (err) {
|
|
8353
|
+
if (err instanceof ResourceNotFoundException$1) return;
|
|
8354
|
+
throw err;
|
|
8355
|
+
}
|
|
8356
|
+
throw new Error(`Table ${tableName} did not disappear within ${maxAttempts}s`);
|
|
8357
|
+
}
|
|
8358
|
+
};
|
|
8359
|
+
|
|
7842
8360
|
//#endregion
|
|
7843
8361
|
//#region src/provisioning/providers/logs-loggroup-provider.ts
|
|
7844
8362
|
/**
|
|
@@ -27905,6 +28423,7 @@ function registerAllProviders(registry) {
|
|
|
27905
28423
|
registry.register("AWS::Lambda::EventSourceMapping", new LambdaEventSourceMappingProvider());
|
|
27906
28424
|
registry.register("AWS::Lambda::LayerVersion", new LambdaLayerVersionProvider());
|
|
27907
28425
|
registry.register("AWS::DynamoDB::Table", new DynamoDBTableProvider());
|
|
28426
|
+
registry.register("AWS::DynamoDB::GlobalTable", new DynamoDBGlobalTableProvider());
|
|
27908
28427
|
registry.register("AWS::Logs::LogGroup", new LogsLogGroupProvider());
|
|
27909
28428
|
registry.register("AWS::CloudWatch::Alarm", new CloudWatchAlarmProvider());
|
|
27910
28429
|
registry.register("AWS::SecretsManager::Secret", new SecretsManagerSecretProvider());
|
|
@@ -29427,6 +29946,7 @@ const PROTECTION_PROPERTY_BY_TYPE = {
|
|
|
29427
29946
|
"AWS::Neptune::DBCluster": "DeletionProtection",
|
|
29428
29947
|
"AWS::Neptune::DBInstance": "DeletionProtection",
|
|
29429
29948
|
"AWS::DynamoDB::Table": "DeletionProtectionEnabled",
|
|
29949
|
+
"AWS::DynamoDB::GlobalTable": "DeletionProtectionEnabled",
|
|
29430
29950
|
"AWS::EC2::Instance": "DisableApiTermination",
|
|
29431
29951
|
"AWS::Cognito::UserPool": "DeletionProtection",
|
|
29432
29952
|
"AWS::AutoScaling::AutoScalingGroup": "DeletionProtection"
|
|
@@ -42932,7 +43452,7 @@ function reorderArgs(argv) {
|
|
|
42932
43452
|
*/
|
|
42933
43453
|
async function main() {
|
|
42934
43454
|
const program = new Command();
|
|
42935
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
43455
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.103.0");
|
|
42936
43456
|
program.addCommand(createBootstrapCommand());
|
|
42937
43457
|
program.addCommand(createSynthCommand());
|
|
42938
43458
|
program.addCommand(createListCommand());
|