diff --git a/dist/cdk.js b/dist/cdk.js index daeadea..1a292c5 100644 --- a/dist/cdk.js +++ b/dist/cdk.js @@ -59,1457 +59,868 @@ var import_fs2 = require('fs'); var import_aws_cdk_lib16 = require('aws-cdk-lib'); var import_yn = __toESM(require('yn')); -// types/CacclDbEngine.ts -var CacclDbEngine = /* @__PURE__ */ ((CacclDbEngine2) => { - CacclDbEngine2['DocDB'] = 'docdb'; - CacclDbEngine2['MySQL'] = 'mysql'; - return CacclDbEngine2; -})(CacclDbEngine || {}); -var CacclDbEngine_default = CacclDbEngine; - // cdk/lib/classes/CacclDeployStack.ts var import_aws_cdk_lib15 = require('aws-cdk-lib'); -// cdk/lib/classes/CacclDocDb.ts -var import_aws_cdk_lib2 = require('aws-cdk-lib'); - -// cdk/lib/constants/DEFAULT_DB_INSTANCE_TYPE.ts -var DEFAULT_DB_INSTANCE_TYPE = 't3.medium'; -var DEFAULT_DB_INSTANCE_TYPE_default = DEFAULT_DB_INSTANCE_TYPE; - -// cdk/lib/constants/DEFAULT_DOCDB_ENGINE_VERSION.ts -var DEFAULT_DOCDB_ENGINE_VERSION = '3.6'; -var DEFAULT_DOCDB_ENGINE_VERSION_default = DEFAULT_DOCDB_ENGINE_VERSION; - -// cdk/lib/constants/DEFAULT_DOCDB_PARAM_GROUP_FAMILY.ts -var DEFAULT_DOCDB_PARAM_GROUP_FAMILY = 'docdb3.6'; -var DEFAULT_DOCDB_PARAM_GROUP_FAMILY_default = DEFAULT_DOCDB_PARAM_GROUP_FAMILY; - -// cdk/lib/classes/CacclDbBase.ts +// cdk/lib/classes/CacclAppEnvironment.ts var import_aws_cdk_lib = require('aws-cdk-lib'); var import_constructs = require('constructs'); +var CacclAppEnvironment = class extends import_constructs.Construct { + constructor(scope, id, props) { + super(scope, id); + this.env = { + PORT: '8080', + NODE_ENV: 'production', + }; + this.secrets = {}; + Object.entries(props.envVars).forEach(([name, value]) => { + if (value.toString().toLowerCase().startsWith('arn:aws:secretsmanager')) { + const varSecret = + import_aws_cdk_lib.aws_secretsmanager.Secret.fromSecretCompleteArn( + this, + `${name}SecretArn`, + value, + ); + this.secrets[name] = + import_aws_cdk_lib.aws_ecs.Secret.fromSecretsManager(varSecret); + } else { + this.env[name] = value; + } + }); + } + addEnvironmentVar(k, v) { + this.env[k] = v; + } + addSecret(k, secret) { + this.secrets[k] = secret; + } +}; +var CacclAppEnvironment_default = CacclAppEnvironment; -// cdk/lib/constants/DEFAULT_REMOVAL_POLICY.ts -var DEFAULT_REMOVAL_POLICY = 'DESTROY'; -var DEFAULT_REMOVAL_POLICY_default = DEFAULT_REMOVAL_POLICY; - -// cdk/lib/classes/CacclDbBase.ts -var CacclDbBase = class extends import_constructs.Construct { - // TODO: JSDoc for constructor +// cdk/lib/classes/CacclCache.ts +var import_aws_cdk_lib2 = require('aws-cdk-lib'); +var import_constructs2 = require('constructs'); +var CacclCache = class extends import_constructs2.Construct { constructor(scope, id, props) { super(scope, id); - // overrides that get set in the cluster-level parameter group, - // e.g. enabling performance monitoring - this.clusterParameterGroupParams = {}; - // overrides for the instance-level param group - // e.g. turning on slow query logging - this.instanceParameterGroupParams = {}; - const { vpc } = props; - const { removalPolicy = DEFAULT_REMOVAL_POLICY_default } = props.options; - this.removalPolicy = import_aws_cdk_lib.RemovalPolicy[removalPolicy]; - this.etcRemovalPolicy = - this.removalPolicy === import_aws_cdk_lib.RemovalPolicy.RETAIN - ? import_aws_cdk_lib.RemovalPolicy.RETAIN - : import_aws_cdk_lib.RemovalPolicy.DESTROY; - this.dbPasswordSecret = new import_aws_cdk_lib.aws_secretsmanager.Secret( + const { vpc, appEnv } = props; + const { + engine = 'redis', + numCacheNodes = 1, + cacheNodeType = 'cache.t3.medium', + } = props.options; + const subnetGroup = new import_aws_cdk_lib2.aws_elasticache.CfnSubnetGroup( this, - 'DbPasswordSecret', + 'CacheSubnetGroup', { - description: `docdb master user password for ${ - import_aws_cdk_lib.Stack.of(this).stackName + description: `List of subnets for ${ + import_aws_cdk_lib2.Stack.of(this).stackName }`, - generateSecretString: { - passwordLength: 16, - excludePunctuation: true, - }, + subnetIds: vpc.privateSubnets.map((subnet) => { + return subnet.subnetId; + }), }, ); - this.dbPasswordSecret.applyRemovalPolicy(this.etcRemovalPolicy); - this.dbSg = new import_aws_cdk_lib.aws_ec2.SecurityGroup( + this.cacheSg = new import_aws_cdk_lib2.aws_ec2.SecurityGroup( this, - 'DbSecurityGroup', + 'CacheSecurityGroup', { vpc, - description: 'security group for the db cluster', + description: 'security group for the elasticache cluster', allowAllOutbound: false, }, ); - this.dbSg.applyRemovalPolicy(this.etcRemovalPolicy); - this.dbSg.addEgressRule( - import_aws_cdk_lib.aws_ec2.Peer.anyIpv4(), - import_aws_cdk_lib.aws_ec2.Port.allTcp(), + this.cacheSg.addIngressRule( + import_aws_cdk_lib2.aws_ec2.Peer.ipv4(vpc.vpcCidrBlock), + import_aws_cdk_lib2.aws_ec2.Port.tcp(6379), + 'allow from internal network', ); - } - // FIXME: doesn't do anything? - createOutputs() { - new import_aws_cdk_lib.CfnOutput(this, 'DbClusterEndpoint', { - exportName: `${ - import_aws_cdk_lib.Stack.of(this).stackName - }-db-cluster-endpoint`, - value: `${this.host}:${this.port}`, - }); - new import_aws_cdk_lib.CfnOutput(this, 'DbSecretArn', { + this.cacheSg.addEgressRule( + import_aws_cdk_lib2.aws_ec2.Peer.anyIpv4(), + import_aws_cdk_lib2.aws_ec2.Port.allTcp(), + ); + this.cache = new import_aws_cdk_lib2.aws_elasticache.CfnCacheCluster( + this, + 'CacheCluster', + { + engine, + numCacheNodes, + cacheNodeType, + cacheSubnetGroupName: subnetGroup.ref, + vpcSecurityGroupIds: [this.cacheSg.securityGroupId], + }, + ); + appEnv.addEnvironmentVar('REDIS_HOST', this.cache.attrRedisEndpointAddress); + appEnv.addEnvironmentVar('REDIS_PORT', this.cache.attrRedisEndpointPort); + new import_aws_cdk_lib2.CfnOutput(this, 'CacheClusterEndpoint', { exportName: `${ - import_aws_cdk_lib.Stack.of(this).stackName - }-db-password-secret-arn`, - value: this.dbPasswordSecret.secretArn, + import_aws_cdk_lib2.Stack.of(this).stackName + }-cache-endpoint`, + value: `${this.cache.attrRedisEndpointAddress}:6379`, }); } - addSecurityGroupIngress(vpcCidrBlock) { - this.dbCluster.connections.allowDefaultPortInternally(); - this.dbCluster.connections.allowDefaultPortFrom( - import_aws_cdk_lib.aws_ec2.Peer.ipv4(vpcCidrBlock), - ); - } }; -var CacclDbBase_default = CacclDbBase; +var CacclCache_default = CacclCache; -// cdk/lib/classes/CacclDocDb.ts -var CacclDocDb = class extends CacclDbBase_default { +// cdk/lib/classes/CacclLoadBalancer.ts +var import_aws_cdk_lib3 = require('aws-cdk-lib'); +var import_constructs3 = require('constructs'); +var CacclLoadBalancer = class extends import_constructs3.Construct { constructor(scope, id, props) { - super(scope, id, props); - this.metricNamespace = 'AWS/DocDB'; - const { vpc, appEnv } = props; + var _a2, _b2; + super(scope, id); const { - instanceCount = 1, - instanceType = DEFAULT_DB_INSTANCE_TYPE_default, - engineVersion = DEFAULT_DOCDB_ENGINE_VERSION_default, - parameterGroupFamily = DEFAULT_DOCDB_PARAM_GROUP_FAMILY_default, - profiler = false, - } = props.options; - if (profiler) { - this.clusterParameterGroupParams.profiler = 'enabled'; - this.clusterParameterGroupParams.profiler_threshold_ms = '500'; - } - const parameterGroup = - new import_aws_cdk_lib2.aws_docdb.ClusterParameterGroup( + vpc, + securityGroups, + certificateArn, + loadBalancerTarget, + albLogBucketName: albLogBucketName2, + // includes targetDeregistrationDelay & healthCheckPath which are applied to the ApplicationTargetGroup below + extraOptions, + } = props; + const targetDeregistrationDelay = + (_a2 = + extraOptions == null + ? void 0 + : extraOptions.targetDeregistrationDelay) != null + ? _a2 + : 30; + const healthCheckPath = + (_b2 = extraOptions == null ? void 0 : extraOptions.healthCheckPath) != + null + ? _b2 + : '/'; + this.loadBalancer = + new import_aws_cdk_lib3.aws_elasticloadbalancingv2.ApplicationLoadBalancer( this, - 'ClusterParameterGroup', + 'LoadBalancer', { - dbClusterParameterGroupName: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - }-param-group`, - family: parameterGroupFamily, - description: `Cluster parameter group for ${ - import_aws_cdk_lib2.Stack.of(this).stackName - }`, - parameters: this.clusterParameterGroupParams, + vpc, + securityGroup: securityGroups.primary, + internetFacing: true, }, ); - this.dbCluster = new import_aws_cdk_lib2.aws_docdb.DatabaseCluster( + if (securityGroups.misc) { + this.loadBalancer.addSecurityGroup(securityGroups.misc); + } + if (albLogBucketName2 !== void 0) { + const bucket = import_aws_cdk_lib3.aws_s3.Bucket.fromBucketName( + this, + 'AlbLogBucket', + albLogBucketName2, + ); + const objPrefix = import_aws_cdk_lib3.Stack.of(this).stackName; + this.loadBalancer.logAccessLogs(bucket, objPrefix); + } + new import_aws_cdk_lib3.aws_elasticloadbalancingv2.CfnListener( this, - 'DocDbCluster', + 'HttpRedirect', { - masterUser: { - username: 'root', - password: import_aws_cdk_lib2.SecretValue.secretsManager( - this.dbPasswordSecret.secretArn, - ), - }, - parameterGroup, - engineVersion, - instances: instanceCount, - vpc, - instanceType: new import_aws_cdk_lib2.aws_ec2.InstanceType( - instanceType, - ), - vpcSubnets: { - subnetType: - import_aws_cdk_lib2.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - securityGroup: this.dbSg, - backup: { - retention: import_aws_cdk_lib2.Duration.days(14), - }, - removalPolicy: this.removalPolicy, + loadBalancerArn: this.loadBalancer.loadBalancerArn, + protocol: + import_aws_cdk_lib3.aws_elasticloadbalancingv2.ApplicationProtocol + .HTTP, + port: 80, + defaultActions: [ + { + type: 'redirect', + redirectConfig: { + statusCode: 'HTTP_301', + port: '443', + protocol: 'HTTPS', + host: '#{host}', + path: '/#{path}', + query: '#{query}', + }, + }, + ], }, ); - parameterGroup.applyRemovalPolicy(this.etcRemovalPolicy); - this.host = this.dbCluster.clusterEndpoint.hostname; - this.port = this.dbCluster.clusterEndpoint.portAsString(); - appEnv.addEnvironmentVar('MONGO_USER', 'root'); - appEnv.addEnvironmentVar('MONGO_HOST', `${this.host}:${this.port}`); - appEnv.addEnvironmentVar( - 'MONGO_OPTIONS', - 'tls=true&tlsAllowInvalidCertificates=true', - ); - appEnv.addSecret( - 'MONGO_PASS', - import_aws_cdk_lib2.aws_ecs.Secret.fromSecretsManager( - this.dbPasswordSecret, - ), - ); - this.createMetricsAndAlarms(); - this.createOutputs(); - this.addSecurityGroupIngress(vpc.vpcCidrBlock); - } - makeDocDbMetric(metricName, extraProps = {}) { - const metric = new import_aws_cdk_lib2.aws_cloudwatch.Metric( - __spreadValues( + const httpsListener = + new import_aws_cdk_lib3.aws_elasticloadbalancingv2.ApplicationListener( + this, + 'HttpsListener', { - metricName, - namespace: this.metricNamespace, - dimensionsMap: { - DBClusterIdentifier: this.dbCluster.clusterIdentifier, - }, - }, - extraProps, - ), - ).with({ period: import_aws_cdk_lib2.Duration.minutes(1) }); - return metric.attachTo(this.dbCluster); - } - createMetricsAndAlarms() { + loadBalancer: this.loadBalancer, + certificates: certificateArn ? [{ certificateArn }] : [], + port: 443, + protocol: + import_aws_cdk_lib3.aws_elasticloadbalancingv2.ApplicationProtocol + .HTTPS, + /** + * if we don't make this false the listener construct will add rules + * to our security group that we don't want/need + */ + open: false, + }, + ); + const atgProps = { + vpc, + port: 443, + protocol: + import_aws_cdk_lib3.aws_elasticloadbalancingv2.ApplicationProtocol + .HTTPS, + // setting this duration value enables the lb stickiness; 1 day is the default + stickinessCookieDuration: import_aws_cdk_lib3.Duration.seconds(86400), + targetType: import_aws_cdk_lib3.aws_elasticloadbalancingv2.TargetType.IP, + targets: [loadBalancerTarget], + deregistrationDelay: import_aws_cdk_lib3.Duration.seconds( + targetDeregistrationDelay, + ), + healthCheck: { + // allow a redirect to indicate service is operational + healthyHttpCodes: '200,302', + }, + }; + if (healthCheckPath !== void 0 && healthCheckPath !== '/') { + atgProps.healthCheck = __spreadValues( + __spreadValues({}, atgProps.healthCheck), + { path: healthCheckPath }, + ); + } + const appTargetGroup = + new import_aws_cdk_lib3.aws_elasticloadbalancingv2.ApplicationTargetGroup( + this, + 'TargetGroup', + atgProps, + ); + httpsListener.addTargetGroups('AppTargetGroup', { + targetGroups: [appTargetGroup], + }); this.metrics = { - ReadIOPS: [this.makeDocDbMetric('ReadIOPS')], - WriteIOPS: [this.makeDocDbMetric('WriteIOPS')], - CPUUtilization: [ - this.makeDocDbMetric('CPUUtilization', { - unit: import_aws_cdk_lib2.aws_cloudwatch.Unit.PERCENT, - }), - ], - FreeableMemory: [this.makeDocDbMetric('FreeableMemory')], - BufferCacheHitRatio: [ - this.makeDocDbMetric('BufferCacheHitRatio', { - unit: import_aws_cdk_lib2.aws_cloudwatch.Unit.PERCENT, - }), - ], - DatabaseConnections: [this.makeDocDbMetric('DatabaseConnections')], - DiskQueueDepth: [this.makeDocDbMetric('DiskQueueDepth')], - ReadLatency: [ - this.makeDocDbMetric('ReadLatency', { - unit: import_aws_cdk_lib2.aws_cloudwatch.Unit.MILLISECONDS, - }), - ], - WriteLatency: [ - this.makeDocDbMetric('WriteLatency', { - unit: import_aws_cdk_lib2.aws_cloudwatch.Unit.MILLISECONDS, - }), - ], - DatabaseCursorsTimedOut: [ - this.makeDocDbMetric('DatabaseCursorsTimedOut', { statistic: 'sum' }), - ], - Transactions: [this.makeDocDbMetric('TransactionsOpen')], - Queries: [this.makeDocDbMetric('OpcountersQuery')], + RequestCount: this.loadBalancer.metricRequestCount(), + NewConnectionCount: this.loadBalancer.metricNewConnectionCount(), + ActiveConnectionCount: this.loadBalancer.metricActiveConnectionCount(), + TargetResponseTime: this.loadBalancer + .metricTargetResponseTime({ + period: import_aws_cdk_lib3.Duration.minutes(1), + unit: import_aws_cdk_lib3.aws_cloudwatch.Unit.MILLISECONDS, + statistic: 'avg', + }) + .with({ period: import_aws_cdk_lib3.Duration.minutes(1) }), + RejectedConnectionCount: this.loadBalancer + .metricRejectedConnectionCount({ + period: import_aws_cdk_lib3.Duration.minutes(1), + statistic: 'sum', + }) + .with({ period: import_aws_cdk_lib3.Duration.minutes(1) }), + UnHealthyHostCount: appTargetGroup + .metricUnhealthyHostCount({ + period: import_aws_cdk_lib3.Duration.minutes(1), + statistic: 'sum', + }) + .with({ period: import_aws_cdk_lib3.Duration.minutes(1) }), }; this.alarms = [ - new import_aws_cdk_lib2.aws_cloudwatch.Alarm( + new import_aws_cdk_lib3.aws_cloudwatch.Alarm( this, - 'CPUUtilizationAlarm', + 'TargetResponseTimeAlarm', { - metric: this.metrics.CPUUtilization[0].with({ - period: import_aws_cdk_lib2.Duration.minutes(5), - }), - threshold: 50, + metric: this.metrics.TargetResponseTime, + threshold: 1, evaluationPeriods: 3, + treatMissingData: + import_aws_cdk_lib3.aws_cloudwatch.TreatMissingData.IGNORE, alarmDescription: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - } docdb cpu utilization alarm`, + import_aws_cdk_lib3.Stack.of(this).stackName + } load balancer target response time (TargetResponseTime)`, }, ), - new import_aws_cdk_lib2.aws_cloudwatch.Alarm( + new import_aws_cdk_lib3.aws_cloudwatch.Alarm( this, - 'BufferCacheHitRatioAlarm', + 'RejectedConnectionsAlarm', { - metric: this.metrics.BufferCacheHitRatio[0], - threshold: 90, - evaluationPeriods: 3, - comparisonOperator: - import_aws_cdk_lib2.aws_cloudwatch.ComparisonOperator - .LESS_THAN_OR_EQUAL_TO_THRESHOLD, + metric: this.metrics.RejectedConnectionCount, + threshold: 1, + evaluationPeriods: 1, + treatMissingData: + import_aws_cdk_lib3.aws_cloudwatch.TreatMissingData.IGNORE, alarmDescription: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - } docdb buffer cache hit ratio alarm`, + import_aws_cdk_lib3.Stack.of(this).stackName + } load balancer rejected connections (RejectedConnectionCount)`, }, ), - new import_aws_cdk_lib2.aws_cloudwatch.Alarm(this, 'DiskQueueDepth', { - metric: this.metrics.DiskQueueDepth[0], + new import_aws_cdk_lib3.aws_cloudwatch.Alarm(this, 'UnhealthHostAlarm', { + metric: this.metrics.UnHealthyHostCount, threshold: 1, evaluationPeriods: 3, - alarmDescription: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - } docdb disk queue depth`, - }), - new import_aws_cdk_lib2.aws_cloudwatch.Alarm(this, 'ReadLatency', { - metric: this.metrics.ReadLatency[0], - threshold: 20, - evaluationPeriods: 3, - treatMissingData: - import_aws_cdk_lib2.aws_cloudwatch.TreatMissingData.IGNORE, - alarmDescription: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - } docdb read latency alarm`, - }), - new import_aws_cdk_lib2.aws_cloudwatch.Alarm(this, 'WriteLatency', { - metric: this.metrics.WriteLatency[0], - threshold: 100, - evaluationPeriods: 3, treatMissingData: - import_aws_cdk_lib2.aws_cloudwatch.TreatMissingData.IGNORE, + import_aws_cdk_lib3.aws_cloudwatch.TreatMissingData.IGNORE, alarmDescription: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - } docdb write latency alarm`, + import_aws_cdk_lib3.Stack.of(this).stackName + } target group unhealthy host count (UnHealthyHostCount)`, }), - new import_aws_cdk_lib2.aws_cloudwatch.Alarm( + ]; + new import_aws_cdk_lib3.CfnOutput(this, 'LoadBalancerHostname', { + exportName: `${ + import_aws_cdk_lib3.Stack.of(this).stackName + }-load-balancer-hostname`, + value: this.loadBalancer.loadBalancerDnsName, + }); + if (securityGroups.primary) { + new import_aws_cdk_lib3.CfnOutput( this, - 'DatabaseCursorsTimedOutAlarm', + 'LoadBalancerPrimarySecurityGroup', { - metric: this.metrics.DatabaseCursorsTimedOut[0].with({ - period: import_aws_cdk_lib2.Duration.minutes(5), - }), - threshold: 5, - evaluationPeriods: 3, - alarmDescription: `${ - import_aws_cdk_lib2.Stack.of(this).stackName - } docdb cursors timed out alarm`, + exportName: `${ + import_aws_cdk_lib3.Stack.of(this).stackName + }-primary-security-group`, + value: securityGroups.primary.securityGroupId, }, - ), - ]; - } - getDashboardLink() { - const { region } = import_aws_cdk_lib2.Stack.of(this); - const dbClusterId = this.dbCluster.clusterIdentifier; - return `https://console.aws.amazon.com/docdb/home?region=${region}#cluster-details/${dbClusterId}`; + ); + } + if (securityGroups.misc) { + new import_aws_cdk_lib3.CfnOutput(this, 'LoadBalancerMiscSecurityGroup', { + exportName: `${ + import_aws_cdk_lib3.Stack.of(this).stackName + }-misc-security-group`, + value: securityGroups.misc.securityGroupId, + }); + } } }; -var CacclDocDb_default = CacclDocDb; - -// cdk/lib/classes/CacclRdsDb.ts -var import_aws_cdk_lib3 = require('aws-cdk-lib'); - -// cdk/lib/constants/DEFAULT_AURORA_MYSQL_ENGINE_VERSION.ts -var DEFAULT_AURORA_MYSQL_ENGINE_VERSION = '5.7.mysql_aurora.2.11.2'; -var DEFAULT_AURORA_MYSQL_ENGINE_VERSION_default = - DEFAULT_AURORA_MYSQL_ENGINE_VERSION; +var CacclLoadBalancer_default = CacclLoadBalancer; -// cdk/lib/classes/CacclRdsDb.ts -var CacclRdsDb = class extends CacclDbBase_default { +// cdk/lib/classes/CacclMonitoring.ts +var import_aws_cdk_lib4 = require('aws-cdk-lib'); +var import_constructs4 = require('constructs'); +var CacclMonitoring = class extends import_constructs4.Construct { constructor(scope, id, props) { - super(scope, id, props); - this.metricNamespace = 'AWS/RDS'; - const { vpc, appEnv } = props; - const { - instanceCount = 1, - instanceType = DEFAULT_DB_INSTANCE_TYPE_default, - engineVersion = DEFAULT_AURORA_MYSQL_ENGINE_VERSION_default, - databaseName, - } = props.options; - const majorVersion = engineVersion.substring(0, 3); - const auroraMysqlEngineVersion = - import_aws_cdk_lib3.aws_rds.DatabaseClusterEngine.auroraMysql({ - version: import_aws_cdk_lib3.aws_rds.AuroraMysqlEngineVersion.of( - engineVersion, - majorVersion, - ), - }); - const enablePerformanceInsights = !instanceType.startsWith('t3'); - this.clusterParameterGroupParams.lower_case_table_names = '1'; - if (parseInt(majorVersion, 10) < 8) { - this.clusterParameterGroupParams.aurora_enable_repl_bin_log_filtering = - '1'; - } - const clusterParameterGroup = - new import_aws_cdk_lib3.aws_rds.ParameterGroup( - this, - 'ClusterParameterGroup', - { - engine: auroraMysqlEngineVersion, - description: `RDS parameter group for ${ - import_aws_cdk_lib3.Stack.of(this).stackName - }`, - parameters: this.clusterParameterGroupParams, - }, - ); - this.instanceParameterGroupParams.slow_query_log = '1'; - this.instanceParameterGroupParams.log_output = 'TABLE'; - this.instanceParameterGroupParams.long_query_time = '3'; - this.instanceParameterGroupParams.sql_mode = 'STRICT_ALL_TABLES'; - this.instanceParameterGroupParams.innodb_monitor_enable = 'all'; - const instanceParameterGroup = - new import_aws_cdk_lib3.aws_rds.ParameterGroup( - this, - 'InstanceParameterGroup', - { - engine: auroraMysqlEngineVersion, - description: `RDS instance parameter group for ${ - import_aws_cdk_lib3.Stack.of(this).stackName - }`, - parameters: this.instanceParameterGroupParams, - }, - ); - this.dbCluster = new import_aws_cdk_lib3.aws_rds.DatabaseCluster( + super(scope, id); + const { stackName: stackName2 } = import_aws_cdk_lib4.Stack.of(this); + const { cacclLoadBalancer, cacclService } = props; + const { loadBalancer } = cacclLoadBalancer; + const { ecsService } = cacclService; + const dashboardName = `${stackName2}-metrics`; + this.dashboard = new import_aws_cdk_lib4.aws_cloudwatch.Dashboard( this, - 'RdsDbCluster', + 'Dashboard', { - engine: auroraMysqlEngineVersion, - clusterIdentifier: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - }-db-cluster`, - credentials: { - username: 'root', - password: import_aws_cdk_lib3.SecretValue.secretsManager( - this.dbPasswordSecret.secretArn, - ), - }, - parameterGroup: clusterParameterGroup, - instances: instanceCount, - defaultDatabaseName: databaseName, - instanceProps: { - vpc, - instanceType: new import_aws_cdk_lib3.aws_ec2.InstanceType( - instanceType, - ), - enablePerformanceInsights, - parameterGroup: instanceParameterGroup, - vpcSubnets: { - subnetType: - import_aws_cdk_lib3.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - securityGroups: [this.dbSg], - }, - backup: { - retention: import_aws_cdk_lib3.Duration.days(14), - }, - removalPolicy: this.removalPolicy, + dashboardName, }, ); - clusterParameterGroup.applyRemovalPolicy(this.etcRemovalPolicy); - instanceParameterGroup.applyRemovalPolicy(this.etcRemovalPolicy); - this.host = this.dbCluster.clusterEndpoint.hostname; - this.port = '3306'; - appEnv.addEnvironmentVar('DATABASE_USER', 'root'); - appEnv.addEnvironmentVar('DATABASE_PORT', this.port); - appEnv.addEnvironmentVar('DATABASE_HOST', this.host); - appEnv.addEnvironmentVar( - 'DATABASE_NAME', - databaseName != null ? databaseName : '', + this.region = import_aws_cdk_lib4.Stack.of(this).region; + const dashboardLink = `https://console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=${dashboardName}`; + new import_aws_cdk_lib4.CfnOutput(this, 'DashboardLink', { + value: dashboardLink, + exportName: `${stackName2}-cloudwatch-dashboard-link`, + }); + const lbLink = `https://console.aws.amazon.com/ec2/v2/home?region=${this.region}#LoadBalancers:tag:caccl_deploy_stack_name=${stackName2}`; + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.TextWidget({ + markdown: [ + `### Load Balancer: [${loadBalancer.loadBalancerName}](${lbLink})`, + '[Explanation of Metrics](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-cloudwatch-metrics.html)', + ].join(' | '), + width: 24, + height: 1, + }), ); - appEnv.addSecret( - 'DATABASE_PASSWORD', - import_aws_cdk_lib3.aws_ecs.Secret.fromSecretsManager( - this.dbPasswordSecret, - ), + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'RequestCount', + left: [cacclLoadBalancer.metrics.RequestCount], + width: 12, + height: 6, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'TargetResponseTime', + left: [cacclLoadBalancer.metrics.TargetResponseTime], + width: 12, + height: 6, + }), ); - this.createMetricsAndAlarms(); - this.createOutputs(); - this.addSecurityGroupIngress(vpc.vpcCidrBlock); - } - makeInstanceMetrics(metricName, extraProps = {}) { - return this.dbCluster.instanceIdentifiers.map((id) => { - const metric = new import_aws_cdk_lib3.aws_cloudwatch.Metric( + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.AlarmStatusWidget({ + alarms: cacclLoadBalancer.alarms, + height: 6, + width: 8, + title: 'Load Balancer Alarm States', + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'NewConnectionCount', + left: [cacclLoadBalancer.metrics.NewConnectionCount], + width: 8, + height: 6, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'ActiveConnectionCount', + left: [cacclLoadBalancer.metrics.ActiveConnectionCount], + width: 8, + height: 6, + }), + ); + const httpCodeWidgets = ['2', '3', '4', '5'].map((i) => { + const metricName = `HTTP ${i}xx Count`; + const httpCode = `TARGET_${i}XX_COUNT`; + return new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: metricName, + left: [ + loadBalancer.metricHttpCodeTarget( + import_aws_cdk_lib4.aws_elasticloadbalancingv2.HttpCodeTarget[ + httpCode + ], + ), + ], + }); + }); + this.dashboard.addWidgets(...httpCodeWidgets); + const serviceLink = `https://console.aws.amazon.com/ecs/home?region=${this.region}#/clusters/${ecsService.cluster.clusterName}/services/${ecsService.serviceName}/details`; + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.TextWidget({ + markdown: `### ECS Service: [${ecsService.serviceName}](${serviceLink})`, + width: 24, + height: 1, + }), + ); + const makeCIMetric = (metricName, extraProps = {}) => { + const metric = new import_aws_cdk_lib4.aws_cloudwatch.Metric( __spreadValues( { metricName, - namespace: this.metricNamespace, - dimensionsMap: { DBInstanceIdentifier: id }, - label: id, + namespace: 'ECS/ContainerInsights', + dimensionsMap: { + ClusterName: ecsService.cluster.clusterName, + ServiceName: ecsService.serviceName, + }, }, extraProps, ), - ).with({ period: import_aws_cdk_lib3.Duration.minutes(1) }); - return metric.attachTo(this.dbCluster); - }); - } - createMetricsAndAlarms() { - this.metrics = { - ReadIOPS: this.makeInstanceMetrics('ReadIOPS'), - WriteIOPS: this.makeInstanceMetrics('WriteIOPS'), - CPUUtilization: this.makeInstanceMetrics('CPUUtilization', { - unit: import_aws_cdk_lib3.aws_cloudwatch.Unit.PERCENT, + ); + metric.attachTo(ecsService); + return metric; + }; + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'CPUUtilization', + left: [ + makeCIMetric('CpuUtilized', { + unit: import_aws_cdk_lib4.aws_cloudwatch.Unit.PERCENT, + }), + makeCIMetric('CpuReserved', { + unit: import_aws_cdk_lib4.aws_cloudwatch.Unit.PERCENT, + }), + ], + width: 12, + height: 6, }), - FreeableMemory: this.makeInstanceMetrics('FreeableMemory'), - BufferCacheHitRatio: this.makeInstanceMetrics('BufferCacheHitRatio', { - unit: import_aws_cdk_lib3.aws_cloudwatch.Unit.PERCENT, + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'MemoryUtilization', + left: [makeCIMetric('MemoryUtilized'), makeCIMetric('MemoryReserved')], + width: 12, + height: 6, }), - DatabaseConnections: this.makeInstanceMetrics('DatabaseConnections'), - DiskQueueDepth: this.makeInstanceMetrics('DiskQueueDepth'), - ReadLatency: this.makeInstanceMetrics('ReadLatency', { - unit: import_aws_cdk_lib3.aws_cloudwatch.Unit.MILLISECONDS, + ); + const servcieAlarmWidget = []; + if (cacclService.alarms.length) { + servcieAlarmWidget.push( + new import_aws_cdk_lib4.aws_cloudwatch.AlarmStatusWidget({ + alarms: cacclService.alarms, + width: 8, + height: 6, + title: 'ECS Service Alarm States', + }), + ); + } + this.dashboard.addWidgets( + ...servcieAlarmWidget, + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Storage Read/Write Bytes', + left: [makeCIMetric('StorageReadBytes')], + right: [makeCIMetric('StorageWriteBytes')], + width: 12, + height: 6, }), - WriteLatency: this.makeInstanceMetrics('WriteLatency', { - unit: import_aws_cdk_lib3.aws_cloudwatch.Unit.MILLISECONDS, + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Tasks & Deployments', + left: [ + makeCIMetric('DesiredTaskCount'), + makeCIMetric('PendingTaskCount'), + makeCIMetric('RunningTaskCount'), + ], + right: [ecsService.metric('DeploymentCount')], + width: 12, + height: 6, }), - DatabaseCursorsTimedOut: this.makeInstanceMetrics( - 'DatabaseCursorsTimedOut', - { statistic: 'sum' }, - ), - Transactions: this.makeInstanceMetrics('ActiveTransactions'), - Queries: this.makeInstanceMetrics('Queries'), + ); + const makeLogLink = (logGroup) => { + const escapedLg = logGroup.split('/').join('$252F'); + return `* [${logGroup}](https://console.aws.amazon.com/cloudwatch/home?region=${this.region}#logsV2:log-groups/log-group/${escapedLg})`; }; - this.alarms = [ - ...this.metrics.ReadIOPS.map((metric, idx) => { - return new import_aws_cdk_lib3.aws_cloudwatch.Alarm( - this, - `CPUUtilizationAlarm-${idx}`, - { - metric, - threshold: 50, - evaluationPeriods: 3, - alarmDescription: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - } ${metric.label} cpu utilization alarm`, - }, - ); - }), - ...this.metrics.BufferCacheHitRatio.map((metric, idx) => { - return new import_aws_cdk_lib3.aws_cloudwatch.Alarm( - this, - `BufferCacheHitRatioAlarm-${idx}`, - { - metric, - threshold: 90, - evaluationPeriods: 3, - comparisonOperator: - import_aws_cdk_lib3.aws_cloudwatch.ComparisonOperator - .LESS_THAN_OR_EQUAL_TO_THRESHOLD, - alarmDescription: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - } ${metric.label} buffer cache hit ratio alarm`, - }, - ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.TextWidget({ + markdown: [ + '### Logs\n', + makeLogLink(`/${stackName2}/app`), + makeLogLink(`/${stackName2}/proxy`), + ].join('\n'), + width: 24, + height: 4, }), - ...this.metrics.DiskQueueDepth.map((metric, idx) => { - return new import_aws_cdk_lib3.aws_cloudwatch.Alarm( - this, - `DiskQueueDepth-${idx}`, - { - metric, - threshold: 1, - evaluationPeriods: 3, - alarmDescription: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - } ${metric.label} disk queue depth`, - }, - ); + ); + } + addDbSection(db) { + const { dbCluster } = db; + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.TextWidget({ + markdown: `### Database Cluster: [${ + dbCluster.clusterIdentifier + }](${db.getDashboardLink()})`, + width: 24, + height: 1, }), - ...this.metrics.ReadLatency.map((metric, idx) => { - return new import_aws_cdk_lib3.aws_cloudwatch.Alarm( - this, - `ReadLatency-${idx}`, - { - metric, - threshold: 20, - evaluationPeriods: 3, - treatMissingData: - import_aws_cdk_lib3.aws_cloudwatch.TreatMissingData.IGNORE, - alarmDescription: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - } ${metric.label} read latency alarm`, - }, - ); + ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Read/Write IOPS', + left: db.metrics.ReadIOPS, + right: db.metrics.WriteIOPS, + width: 12, + height: 6, }), - ...this.metrics.WriteLatency.map((metric, idx) => { - return new import_aws_cdk_lib3.aws_cloudwatch.Alarm( - this, - `WriteLatency-${idx}`, - { - metric, - threshold: 100, - evaluationPeriods: 3, - treatMissingData: - import_aws_cdk_lib3.aws_cloudwatch.TreatMissingData.IGNORE, - alarmDescription: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - } ${metric.label} write latency alarm`, - }, - ); + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'CPU & Memory', + left: db.metrics.CPUUtilization, + right: db.metrics.FreeableMemory, + width: 12, + height: 6, }), - ...this.metrics.DatabaseCursorsTimedOut.map((metric, idx) => { - return new import_aws_cdk_lib3.aws_cloudwatch.Alarm( - this, - `DatabaseCursorsTimedOutAlarm-${idx}`, - { - metric, - threshold: 1, - evaluationPeriods: 1, - alarmDescription: `${ - import_aws_cdk_lib3.Stack.of(this).stackName - } ${metric.label} cursors timed out alarm`, - }, - ); + ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Read/Write Latency', + left: db.metrics.ReadLatency, + right: db.metrics.WriteLatency, + width: 12, + height: 6, }), - ]; - } - getDashboardLink() { - const { region } = import_aws_cdk_lib3.Stack.of(this); - const dbClusterId = this.dbCluster.clusterIdentifier; - return `https://console.aws.amazon.com/rds/home?region=${region}#database:id=${dbClusterId};is-cluster=true`; - } -}; -var CacclRdsDb_default = CacclRdsDb; - -// cdk/lib/helpers/createDbConstruct.ts -var createDbConstruct = (scope, props) => { - const { options } = props; - switch (options.engine.toLowerCase()) { - case 'docdb': - return new CacclDocDb_default(scope, 'DocDb', props); - case 'mysql': - return new CacclRdsDb_default(scope, 'RdsDb', props); - default: - throw Error(`Invalid dbOptions.engine value: ${options.engine}`); - } -}; -var createDbConstruct_default = createDbConstruct; - -// cdk/lib/classes/CacclAppEnvironment.ts -var import_aws_cdk_lib4 = require('aws-cdk-lib'); -var import_constructs2 = require('constructs'); -var CacclAppEnvironment = class extends import_constructs2.Construct { - constructor(scope, id, props) { - super(scope, id); - this.env = { - PORT: '8080', - NODE_ENV: 'production', - }; - this.secrets = {}; - Object.entries(props.envVars).forEach(([name, value]) => { - if (value.toString().toLowerCase().startsWith('arn:aws:secretsmanager')) { - const varSecret = - import_aws_cdk_lib4.aws_secretsmanager.Secret.fromSecretCompleteArn( - this, - `${name}SecretArn`, - value, - ); - this.secrets[name] = - import_aws_cdk_lib4.aws_ecs.Secret.fromSecretsManager(varSecret); - } else { - this.env[name] = value; - } - }); - } - addEnvironmentVar(k, v) { - this.env[k] = v; + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Transactions/Queries', + left: db.metrics.Transactions, + right: db.metrics.Queries, + width: 12, + height: 6, + }), + ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.AlarmStatusWidget({ + alarms: db.alarms, + width: 24, + height: 6, + title: 'Database Alarm States', + }), + ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + left: db.metrics.BufferCacheHitRatio, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + left: db.metrics.DatabaseConnections, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + left: db.metrics.DiskQueueDepth, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + left: db.metrics.DatabaseCursorsTimedOut, + }), + ); } - addSecret(k, secret) { - this.secrets[k] = secret; + addScheduledTasksSection(scheduledTasks) { + const func = scheduledTasks.taskExecFunction; + const functionUrl = `https://console.aws.amazon.com/lambda/home?region=${this.region}#/functions/${func.functionName}`; + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.TextWidget({ + markdown: `### Scheduled Tasks Function: [${func.functionName}](${functionUrl})`, + width: 24, + height: 1, + }), + ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Duration', + left: [func.metricDuration()], + width: 8, + height: 6, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Invocations', + left: [func.metricInvocations()], + width: 8, + height: 6, + }), + new import_aws_cdk_lib4.aws_cloudwatch.GraphWidget({ + title: 'Errors', + left: [func.metricErrors()], + width: 8, + height: 6, + }), + ); + this.dashboard.addWidgets( + new import_aws_cdk_lib4.aws_cloudwatch.AlarmStatusWidget({ + alarms: scheduledTasks.alarms, + width: 24, + height: 6, + title: 'Scheduled Tasks Function Alarm States', + }), + ); } }; -var CacclAppEnvironment_default = CacclAppEnvironment; +var CacclMonitoring_default = CacclMonitoring; -// cdk/lib/classes/CacclCache.ts +// cdk/lib/classes/CacclNotifications.ts +var import_path = __toESM(require('path')); var import_aws_cdk_lib5 = require('aws-cdk-lib'); -var import_constructs3 = require('constructs'); -var CacclCache = class extends import_constructs3.Construct { +var import_constructs5 = require('constructs'); +var CacclNotifications = class extends import_constructs5.Construct { constructor(scope, id, props) { super(scope, id); - const { vpc, appEnv } = props; - const { - engine = 'redis', - numCacheNodes = 1, - cacheNodeType = 'cache.t3.medium', - } = props.options; - const subnetGroup = new import_aws_cdk_lib5.aws_elasticache.CfnSubnetGroup( + const email = typeof props.email === 'string' ? [props.email] : props.email; + const { slack, service, loadBalancer, db } = props; + this.topic = new import_aws_cdk_lib5.aws_sns.Topic( this, - 'CacheSubnetGroup', + 'NotificationTopic', { - description: `List of subnets for ${ + displayName: `${ import_aws_cdk_lib5.Stack.of(this).stackName - }`, - subnetIds: vpc.privateSubnets.map((subnet) => { - return subnet.subnetId; - }), - }, - ); - this.cacheSg = new import_aws_cdk_lib5.aws_ec2.SecurityGroup( - this, - 'CacheSecurityGroup', - { - vpc, - description: 'security group for the elasticache cluster', - allowAllOutbound: false, - }, - ); - this.cacheSg.addIngressRule( - import_aws_cdk_lib5.aws_ec2.Peer.ipv4(vpc.vpcCidrBlock), - import_aws_cdk_lib5.aws_ec2.Port.tcp(6379), - 'allow from internal network', - ); - this.cacheSg.addEgressRule( - import_aws_cdk_lib5.aws_ec2.Peer.anyIpv4(), - import_aws_cdk_lib5.aws_ec2.Port.allTcp(), - ); - this.cache = new import_aws_cdk_lib5.aws_elasticache.CfnCacheCluster( - this, - 'CacheCluster', - { - engine, - numCacheNodes, - cacheNodeType, - cacheSubnetGroupName: subnetGroup.ref, - vpcSecurityGroupIds: [this.cacheSg.securityGroupId], + }-notifications`, }, ); - appEnv.addEnvironmentVar('REDIS_HOST', this.cache.attrRedisEndpointAddress); - appEnv.addEnvironmentVar('REDIS_PORT', this.cache.attrRedisEndpointPort); - new import_aws_cdk_lib5.CfnOutput(this, 'CacheClusterEndpoint', { + this.topic.grantPublish({ + grantPrincipal: new import_aws_cdk_lib5.aws_iam.ServicePrincipal( + 'cloudwatch.amazonaws.com', + ), + }); + if (email) { + email.forEach((emailAddr, idx) => { + new import_aws_cdk_lib5.aws_sns.Subscription( + this, + `email-subscription-${idx}`, + { + topic: this.topic, + protocol: import_aws_cdk_lib5.aws_sns.SubscriptionProtocol.EMAIL, + endpoint: emailAddr, + }, + ); + }); + } + if (slack !== void 0) { + const slackFunction = new import_aws_cdk_lib5.aws_lambda.Function( + this, + 'SlackFunction', + { + functionName: `${ + import_aws_cdk_lib5.Stack.of(this).stackName + }-slack-notify`, + runtime: import_aws_cdk_lib5.aws_lambda.Runtime.PYTHON_3_8, + handler: 'notify.handler', + code: import_aws_cdk_lib5.aws_lambda.Code.fromAsset( + import_path.default.join(__dirname, 'assets/slack_notify'), + ), + environment: { + SLACK_WEBHOOK_URL: slack, + }, + }, + ); + this.topic.addSubscription( + new import_aws_cdk_lib5.aws_sns_subscriptions.LambdaSubscription( + slackFunction, + ), + ); + } + loadBalancer.alarms.forEach((alarm) => { + alarm.addAlarmAction( + new import_aws_cdk_lib5.aws_cloudwatch_actions.SnsAction(this.topic), + ); + }); + service.alarms.forEach((alarm) => { + alarm.addAlarmAction( + new import_aws_cdk_lib5.aws_cloudwatch_actions.SnsAction(this.topic), + ); + }); + db == null + ? void 0 + : db.alarms.forEach((alarm) => { + alarm.addAlarmAction( + new import_aws_cdk_lib5.aws_cloudwatch_actions.SnsAction( + this.topic, + ), + ); + }); + new import_aws_cdk_lib5.CfnOutput(this, 'TopicName', { exportName: `${ import_aws_cdk_lib5.Stack.of(this).stackName - }-cache-endpoint`, - value: `${this.cache.attrRedisEndpointAddress}:6379`, + }-sns-topic-name`, + value: this.topic.topicName, + }); + new import_aws_cdk_lib5.CfnOutput(this, 'TopicArn', { + exportName: `${ + import_aws_cdk_lib5.Stack.of(this).stackName + }-sns-topic-arn`, + value: this.topic.topicArn, }); } }; -var CacclCache_default = CacclCache; +var CacclNotifications_default = CacclNotifications; -// cdk/lib/classes/CacclLoadBalancer.ts +// cdk/lib/classes/CacclScheduledTasks.ts +var import_path2 = __toESM(require('path')); var import_aws_cdk_lib6 = require('aws-cdk-lib'); -var import_constructs4 = require('constructs'); -var CacclLoadBalancer = class extends import_constructs4.Construct { +var import_constructs6 = require('constructs'); +var CacclScheduledTasks = class extends import_constructs6.Construct { constructor(scope, id, props) { - var _a2, _b2; super(scope, id); + this.eventRules = []; + this.alarms = []; const { - vpc, - securityGroups, - certificateArn, - loadBalancerTarget, - albLogBucketName: albLogBucketName2, - // includes targetDeregistrationDelay & healthCheckPath which are applied to the ApplicationTargetGroup below - extraOptions, - } = props; - const targetDeregistrationDelay = - (_a2 = - extraOptions == null - ? void 0 - : extraOptions.targetDeregistrationDelay) != null - ? _a2 - : 30; - const healthCheckPath = - (_b2 = extraOptions == null ? void 0 : extraOptions.healthCheckPath) != - null - ? _b2 - : '/'; - this.loadBalancer = - new import_aws_cdk_lib6.aws_elasticloadbalancingv2.ApplicationLoadBalancer( - this, - 'LoadBalancer', - { - vpc, - securityGroup: securityGroups.primary, - internetFacing: true, - }, - ); - if (securityGroups.misc) { - this.loadBalancer.addSecurityGroup(securityGroups.misc); - } - if (albLogBucketName2 !== void 0) { - const bucket = import_aws_cdk_lib6.aws_s3.Bucket.fromBucketName( - this, - 'AlbLogBucket', - albLogBucketName2, - ); - const objPrefix = import_aws_cdk_lib6.Stack.of(this).stackName; - this.loadBalancer.logAccessLogs(bucket, objPrefix); - } - new import_aws_cdk_lib6.aws_elasticloadbalancingv2.CfnListener( + stackName: stackName2, + region, + account, + } = import_aws_cdk_lib6.Stack.of(this); + const { clusterName, serviceName, taskDefinition, vpc, scheduledTasks } = + props; + this.taskExecFunction = new import_aws_cdk_lib6.aws_lambda.Function( this, - 'HttpRedirect', + 'ScheduledTaskExecFunction', { - loadBalancerArn: this.loadBalancer.loadBalancerArn, - protocol: - import_aws_cdk_lib6.aws_elasticloadbalancingv2.ApplicationProtocol - .HTTP, - port: 80, - defaultActions: [ - { - type: 'redirect', - redirectConfig: { - statusCode: 'HTTP_301', - port: '443', - protocol: 'HTTPS', - host: '#{host}', - path: '/#{path}', - query: '#{query}', - }, - }, - ], + functionName: `${stackName2}-scheduled-task-exec`, + runtime: import_aws_cdk_lib6.aws_lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: import_aws_cdk_lib6.aws_lambda.Code.fromAsset( + import_path2.default.join(__dirname, 'assets/scheduled_task_exec'), + ), + vpc, + vpcSubnets: { + subnetType: + import_aws_cdk_lib6.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + environment: { + ECS_CLUSTER: clusterName, + ECS_SERVICE: serviceName, + ECS_TASK_DEFINITION: taskDefinition.family, + }, }, ); - const httpsListener = - new import_aws_cdk_lib6.aws_elasticloadbalancingv2.ApplicationListener( + Object.keys(scheduledTasks).forEach((scheduledTaskId) => { + const scheduledTask = scheduledTasks[scheduledTaskId]; + const eventTarget = + new import_aws_cdk_lib6.aws_events_targets.LambdaFunction( + this.taskExecFunction, + { + // this is the json event object that the lambda function receives + event: import_aws_cdk_lib6.aws_events.RuleTargetInput.fromObject({ + execCommand: scheduledTask.command, + }), + }, + ); + const schedule = import_aws_cdk_lib6.aws_events.Schedule.expression( + `cron(${scheduledTask.schedule})`, + ); + const ruleName = `${import_aws_cdk_lib6.Stack.of( this, - 'HttpsListener', + )}-scheduled-task-${scheduledTaskId}`; + const eventRule = new import_aws_cdk_lib6.aws_events.Rule( + this, + `ScheduledTaskEventRule${scheduledTaskId}`, { - loadBalancer: this.loadBalancer, - certificates: [{ certificateArn }], - port: 443, - protocol: - import_aws_cdk_lib6.aws_elasticloadbalancingv2.ApplicationProtocol - .HTTPS, - /** - * if we don't make this false the listener construct will add rules - * to our security group that we don't want/need - */ - open: false, + ruleName, + schedule, + targets: [eventTarget], + description: scheduledTask.description, }, ); - const atgProps = { - vpc, - port: 443, - protocol: - import_aws_cdk_lib6.aws_elasticloadbalancingv2.ApplicationProtocol - .HTTPS, - // setting this duration value enables the lb stickiness; 1 day is the default - stickinessCookieDuration: import_aws_cdk_lib6.Duration.seconds(86400), - targetType: import_aws_cdk_lib6.aws_elasticloadbalancingv2.TargetType.IP, - targets: [loadBalancerTarget], - deregistrationDelay: import_aws_cdk_lib6.Duration.seconds( - targetDeregistrationDelay, - ), - healthCheck: { - // allow a redirect to indicate service is operational - healthyHttpCodes: '200,302', - }, - }; - if (healthCheckPath !== void 0 && healthCheckPath !== '/') { - atgProps.healthCheck = __spreadValues( - __spreadValues({}, atgProps.healthCheck), - { path: healthCheckPath }, - ); - } - const appTargetGroup = - new import_aws_cdk_lib6.aws_elasticloadbalancingv2.ApplicationTargetGroup( - this, - 'TargetGroup', - atgProps, - ); - httpsListener.addTargetGroups('AppTargetGroup', { - targetGroups: [appTargetGroup], + this.eventRules.push(eventRule); }); - this.metrics = { - RequestCount: this.loadBalancer.metricRequestCount(), - NewConnectionCount: this.loadBalancer.metricNewConnectionCount(), - ActiveConnectionCount: this.loadBalancer.metricActiveConnectionCount(), - TargetResponseTime: this.loadBalancer - .metricTargetResponseTime({ - period: import_aws_cdk_lib6.Duration.minutes(1), - unit: import_aws_cdk_lib6.aws_cloudwatch.Unit.MILLISECONDS, - statistic: 'avg', - }) - .with({ period: import_aws_cdk_lib6.Duration.minutes(1) }), - RejectedConnectionCount: this.loadBalancer - .metricRejectedConnectionCount({ - period: import_aws_cdk_lib6.Duration.minutes(1), - statistic: 'sum', - }) - .with({ period: import_aws_cdk_lib6.Duration.minutes(1) }), - UnHealthyHostCount: appTargetGroup - .metricUnhealthyHostCount({ - period: import_aws_cdk_lib6.Duration.minutes(1), - statistic: 'sum', - }) - .with({ period: import_aws_cdk_lib6.Duration.minutes(1) }), - }; + this.taskExecFunction.addToRolePolicy( + new import_aws_cdk_lib6.aws_iam.PolicyStatement({ + effect: import_aws_cdk_lib6.aws_iam.Effect.ALLOW, + actions: ['ecs:Describe*', 'ecs:List*'], + resources: ['*'], + }), + ); + this.taskExecFunction.addToRolePolicy( + new import_aws_cdk_lib6.aws_iam.PolicyStatement({ + effect: import_aws_cdk_lib6.aws_iam.Effect.ALLOW, + actions: ['ecs:RunTask'], + resources: [ + `arn:aws:ecs:${region}:${account}:task-definition/${taskDefinition.family}`, + ], + }), + ); + const passRoleArns = [taskDefinition.taskRole.roleArn]; + if (taskDefinition.executionRole) { + passRoleArns.push(taskDefinition.executionRole.roleArn); + } + this.taskExecFunction.addToRolePolicy( + new import_aws_cdk_lib6.aws_iam.PolicyStatement({ + effect: import_aws_cdk_lib6.aws_iam.Effect.ALLOW, + actions: ['iam:PassRole'], + resources: passRoleArns, + }), + ); this.alarms = [ - new import_aws_cdk_lib6.aws_cloudwatch.Alarm( - this, - 'TargetResponseTimeAlarm', - { - metric: this.metrics.TargetResponseTime, - threshold: 1, - evaluationPeriods: 3, - treatMissingData: - import_aws_cdk_lib6.aws_cloudwatch.TreatMissingData.IGNORE, - alarmDescription: `${ - import_aws_cdk_lib6.Stack.of(this).stackName - } load balancer target response time (TargetResponseTime)`, - }, - ), - new import_aws_cdk_lib6.aws_cloudwatch.Alarm( - this, - 'RejectedConnectionsAlarm', - { - metric: this.metrics.RejectedConnectionCount, - threshold: 1, - evaluationPeriods: 1, - treatMissingData: - import_aws_cdk_lib6.aws_cloudwatch.TreatMissingData.IGNORE, - alarmDescription: `${ - import_aws_cdk_lib6.Stack.of(this).stackName - } load balancer rejected connections (RejectedConnectionCount)`, - }, - ), - new import_aws_cdk_lib6.aws_cloudwatch.Alarm(this, 'UnhealthHostAlarm', { - metric: this.metrics.UnHealthyHostCount, + // alarm on any function errors + new import_aws_cdk_lib6.aws_cloudwatch.Alarm(this, 'ErrorAlarm', { + metric: this.taskExecFunction + .metricErrors() + .with({ period: import_aws_cdk_lib6.Duration.minutes(5) }), threshold: 1, - evaluationPeriods: 3, - treatMissingData: - import_aws_cdk_lib6.aws_cloudwatch.TreatMissingData.IGNORE, - alarmDescription: `${ - import_aws_cdk_lib6.Stack.of(this).stackName - } target group unhealthy host count (UnHealthyHostCount)`, + evaluationPeriods: 1, + alarmDescription: `${stackName2} scheduled task execution error alarm`, + }), + // alarm if function isn't invoked at least once per day + new import_aws_cdk_lib6.aws_cloudwatch.Alarm(this, 'InvocationsAlarm', { + metric: this.taskExecFunction + .metricInvocations() + .with({ period: import_aws_cdk_lib6.Duration.days(1) }), + threshold: 1, + evaluationPeriods: 1, + alarmDescription: `${stackName2} no invocations alarm`, + comparisonOperator: + import_aws_cdk_lib6.aws_cloudwatch.ComparisonOperator + .LESS_THAN_THRESHOLD, }), ]; - new import_aws_cdk_lib6.CfnOutput(this, 'LoadBalancerHostname', { - exportName: `${ - import_aws_cdk_lib6.Stack.of(this).stackName - }-load-balancer-hostname`, - value: this.loadBalancer.loadBalancerDnsName, + new import_aws_cdk_lib6.CfnOutput(this, 'DeployConfigHash', { + exportName: `${stackName2}-scheduled-tasks-function-name`, + value: this.taskExecFunction.functionName, }); - if (securityGroups.primary) { - new import_aws_cdk_lib6.CfnOutput( - this, - 'LoadBalancerPrimarySecurityGroup', - { - exportName: `${ - import_aws_cdk_lib6.Stack.of(this).stackName - }-primary-security-group`, - value: securityGroups.primary.securityGroupId, - }, - ); - } - if (securityGroups.misc) { - new import_aws_cdk_lib6.CfnOutput(this, 'LoadBalancerMiscSecurityGroup', { - exportName: `${ - import_aws_cdk_lib6.Stack.of(this).stackName - }-misc-security-group`, - value: securityGroups.misc.securityGroupId, - }); - } } }; -var CacclLoadBalancer_default = CacclLoadBalancer; +var CacclScheduledTasks_default = CacclScheduledTasks; -// cdk/lib/classes/CacclMonitoring.ts +// cdk/lib/classes/CacclService.ts var import_aws_cdk_lib7 = require('aws-cdk-lib'); -var import_constructs5 = require('constructs'); -var CacclMonitoring = class extends import_constructs5.Construct { +var import_constructs7 = require('constructs'); +var CacclService = class extends import_constructs7.Construct { constructor(scope, id, props) { super(scope, id); - const { stackName: stackName2 } = import_aws_cdk_lib7.Stack.of(this); - const { cacclLoadBalancer, cacclService } = props; - const { loadBalancer } = cacclLoadBalancer; - const { ecsService } = cacclService; - const dashboardName = `${stackName2}-metrics`; - this.dashboard = new import_aws_cdk_lib7.aws_cloudwatch.Dashboard( + const { + cluster, + taskDef, + taskCount, + loadBalancerSg, + enableExecuteCommand = false, + } = props; + const serviceSg = new import_aws_cdk_lib7.aws_ec2.SecurityGroup( this, - 'Dashboard', + 'SecurityGroup', { - dashboardName, + vpc: cluster.vpc, + description: 'ecs service security group', }, ); - this.region = import_aws_cdk_lib7.Stack.of(this).region; - const dashboardLink = `https://console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=${dashboardName}`; - new import_aws_cdk_lib7.CfnOutput(this, 'DashboardLink', { - value: dashboardLink, - exportName: `${stackName2}-cloudwatch-dashboard-link`, - }); - const lbLink = `https://console.aws.amazon.com/ec2/v2/home?region=${this.region}#LoadBalancers:tag:caccl_deploy_stack_name=${stackName2}`; - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.TextWidget({ - markdown: [ - `### Load Balancer: [${loadBalancer.loadBalancerName}](${lbLink})`, - '[Explanation of Metrics](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-cloudwatch-metrics.html)', - ].join(' | '), - width: 24, - height: 1, - }), + serviceSg.connections.allowFrom( + loadBalancerSg, + import_aws_cdk_lib7.aws_ec2.Port.tcp(443), ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'RequestCount', - left: [cacclLoadBalancer.metrics.RequestCount], - width: 12, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'TargetResponseTime', - left: [cacclLoadBalancer.metrics.TargetResponseTime], - width: 12, - height: 6, - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.AlarmStatusWidget({ - alarms: cacclLoadBalancer.alarms, - height: 6, - width: 8, - title: 'Load Balancer Alarm States', - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'NewConnectionCount', - left: [cacclLoadBalancer.metrics.NewConnectionCount], - width: 8, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'ActiveConnectionCount', - left: [cacclLoadBalancer.metrics.ActiveConnectionCount], - width: 8, - height: 6, - }), - ); - const httpCodeWidgets = ['2', '3', '4', '5'].map((i) => { - const metricName = `HTTP ${i}xx Count`; - const httpCode = `TARGET_${i}XX_COUNT`; - return new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: metricName, - left: [ - loadBalancer.metricHttpCodeTarget( - import_aws_cdk_lib7.aws_elasticloadbalancingv2.HttpCodeTarget[ - httpCode - ], - ), - ], - }); - }); - this.dashboard.addWidgets(...httpCodeWidgets); - const serviceLink = `https://console.aws.amazon.com/ecs/home?region=${this.region}#/clusters/${ecsService.cluster.clusterName}/services/${ecsService.serviceName}/details`; - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.TextWidget({ - markdown: `### ECS Service: [${ecsService.serviceName}](${serviceLink})`, - width: 24, - height: 1, - }), - ); - const makeCIMetric = (metricName, extraProps = {}) => { - const metric = new import_aws_cdk_lib7.aws_cloudwatch.Metric( - __spreadValues( - { - metricName, - namespace: 'ECS/ContainerInsights', - dimensionsMap: { - ClusterName: ecsService.cluster.clusterName, - ServiceName: ecsService.serviceName, - }, - }, - extraProps, - ), - ); - metric.attachTo(ecsService); - return metric; - }; - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'CPUUtilization', - left: [ - makeCIMetric('CpuUtilized', { - unit: import_aws_cdk_lib7.aws_cloudwatch.Unit.PERCENT, - }), - makeCIMetric('CpuReserved', { - unit: import_aws_cdk_lib7.aws_cloudwatch.Unit.PERCENT, - }), - ], - width: 12, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'MemoryUtilization', - left: [makeCIMetric('MemoryUtilized'), makeCIMetric('MemoryReserved')], - width: 12, - height: 6, - }), - ); - const servcieAlarmWidget = []; - if (cacclService.alarms.length) { - servcieAlarmWidget.push( - new import_aws_cdk_lib7.aws_cloudwatch.AlarmStatusWidget({ - alarms: cacclService.alarms, - width: 8, - height: 6, - title: 'ECS Service Alarm States', - }), - ); - } - this.dashboard.addWidgets( - ...servcieAlarmWidget, - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Storage Read/Write Bytes', - left: [makeCIMetric('StorageReadBytes')], - right: [makeCIMetric('StorageWriteBytes')], - width: 12, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Tasks & Deployments', - left: [ - makeCIMetric('DesiredTaskCount'), - makeCIMetric('PendingTaskCount'), - makeCIMetric('RunningTaskCount'), - ], - right: [ecsService.metric('DeploymentCount')], - width: 12, - height: 6, - }), - ); - const makeLogLink = (logGroup) => { - const escapedLg = logGroup.split('/').join('$252F'); - return `* [${logGroup}](https://console.aws.amazon.com/cloudwatch/home?region=${this.region}#logsV2:log-groups/log-group/${escapedLg})`; - }; - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.TextWidget({ - markdown: [ - '### Logs\n', - makeLogLink(`/${stackName2}/app`), - makeLogLink(`/${stackName2}/proxy`), - ].join('\n'), - width: 24, - height: 4, - }), - ); - } - addDbSection(db) { - const { dbCluster } = db; - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.TextWidget({ - markdown: `### Database Cluster: [${ - dbCluster.clusterIdentifier - }](${db.getDashboardLink()})`, - width: 24, - height: 1, - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Read/Write IOPS', - left: db.metrics.ReadIOPS, - right: db.metrics.WriteIOPS, - width: 12, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'CPU & Memory', - left: db.metrics.CPUUtilization, - right: db.metrics.FreeableMemory, - width: 12, - height: 6, - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Read/Write Latency', - left: db.metrics.ReadLatency, - right: db.metrics.WriteLatency, - width: 12, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Transactions/Queries', - left: db.metrics.Transactions, - right: db.metrics.Queries, - width: 12, - height: 6, - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.AlarmStatusWidget({ - alarms: db.alarms, - width: 24, - height: 6, - title: 'Database Alarm States', - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - left: db.metrics.BufferCacheHitRatio, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - left: db.metrics.DatabaseConnections, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - left: db.metrics.DiskQueueDepth, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - left: db.metrics.DatabaseCursorsTimedOut, - }), - ); - } - addScheduledTasksSection(scheduledTasks) { - const func = scheduledTasks.taskExecFunction; - const functionUrl = `https://console.aws.amazon.com/lambda/home?region=${this.region}#/functions/${func.functionName}`; - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.TextWidget({ - markdown: `### Scheduled Tasks Function: [${func.functionName}](${functionUrl})`, - width: 24, - height: 1, - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Duration', - left: [func.metricDuration()], - width: 8, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Invocations', - left: [func.metricInvocations()], - width: 8, - height: 6, - }), - new import_aws_cdk_lib7.aws_cloudwatch.GraphWidget({ - title: 'Errors', - left: [func.metricErrors()], - width: 8, - height: 6, - }), - ); - this.dashboard.addWidgets( - new import_aws_cdk_lib7.aws_cloudwatch.AlarmStatusWidget({ - alarms: scheduledTasks.alarms, - width: 24, - height: 6, - title: 'Scheduled Tasks Function Alarm States', - }), - ); - } -}; -var CacclMonitoring_default = CacclMonitoring; - -// cdk/lib/classes/CacclNotifications.ts -var import_path = __toESM(require('path')); -var import_aws_cdk_lib8 = require('aws-cdk-lib'); -var import_constructs6 = require('constructs'); -var CacclNotifications = class extends import_constructs6.Construct { - constructor(scope, id, props) { - super(scope, id); - const email = typeof props.email === 'string' ? [props.email] : props.email; - const { slack, service, loadBalancer, db } = props; - this.topic = new import_aws_cdk_lib8.aws_sns.Topic( - this, - 'NotificationTopic', - { - displayName: `${ - import_aws_cdk_lib8.Stack.of(this).stackName - }-notifications`, - }, - ); - this.topic.grantPublish({ - grantPrincipal: new import_aws_cdk_lib8.aws_iam.ServicePrincipal( - 'cloudwatch.amazonaws.com', - ), - }); - if (email) { - email.forEach((emailAddr, idx) => { - new import_aws_cdk_lib8.aws_sns.Subscription( - this, - `email-subscription-${idx}`, - { - topic: this.topic, - protocol: import_aws_cdk_lib8.aws_sns.SubscriptionProtocol.EMAIL, - endpoint: emailAddr, - }, - ); - }); - } - if (slack !== void 0) { - const slackFunction = new import_aws_cdk_lib8.aws_lambda.Function( - this, - 'SlackFunction', - { - functionName: `${ - import_aws_cdk_lib8.Stack.of(this).stackName - }-slack-notify`, - runtime: import_aws_cdk_lib8.aws_lambda.Runtime.PYTHON_3_8, - handler: 'notify.handler', - code: import_aws_cdk_lib8.aws_lambda.Code.fromAsset( - import_path.default.join(__dirname, 'assets/slack_notify'), - ), - environment: { - SLACK_WEBHOOK_URL: slack, - }, - }, - ); - this.topic.addSubscription( - new import_aws_cdk_lib8.aws_sns_subscriptions.LambdaSubscription( - slackFunction, - ), - ); - } - loadBalancer.alarms.forEach((alarm) => { - alarm.addAlarmAction( - new import_aws_cdk_lib8.aws_cloudwatch_actions.SnsAction(this.topic), - ); - }); - service.alarms.forEach((alarm) => { - alarm.addAlarmAction( - new import_aws_cdk_lib8.aws_cloudwatch_actions.SnsAction(this.topic), - ); - }); - db == null - ? void 0 - : db.alarms.forEach((alarm) => { - alarm.addAlarmAction( - new import_aws_cdk_lib8.aws_cloudwatch_actions.SnsAction( - this.topic, - ), - ); - }); - new import_aws_cdk_lib8.CfnOutput(this, 'TopicName', { - exportName: `${ - import_aws_cdk_lib8.Stack.of(this).stackName - }-sns-topic-name`, - value: this.topic.topicName, - }); - new import_aws_cdk_lib8.CfnOutput(this, 'TopicArn', { - exportName: `${ - import_aws_cdk_lib8.Stack.of(this).stackName - }-sns-topic-arn`, - value: this.topic.topicArn, - }); - } -}; -var CacclNotifications_default = CacclNotifications; - -// cdk/lib/classes/CacclScheduledTasks.ts -var import_path2 = __toESM(require('path')); -var import_aws_cdk_lib9 = require('aws-cdk-lib'); -var import_constructs7 = require('constructs'); -var CacclScheduledTasks = class extends import_constructs7.Construct { - constructor(scope, id, props) { - super(scope, id); - this.eventRules = []; - this.alarms = []; - const { - stackName: stackName2, - region, - account, - } = import_aws_cdk_lib9.Stack.of(this); - const { clusterName, serviceName, taskDefinition, vpc, scheduledTasks } = - props; - this.taskExecFunction = new import_aws_cdk_lib9.aws_lambda.Function( - this, - 'ScheduledTaskExecFunction', - { - functionName: `${stackName2}-scheduled-task-exec`, - runtime: import_aws_cdk_lib9.aws_lambda.Runtime.NODEJS_12_X, - handler: 'index.handler', - code: import_aws_cdk_lib9.aws_lambda.Code.fromAsset( - import_path2.default.join(__dirname, 'assets/scheduled_task_exec'), - ), - vpc, - vpcSubnets: { - subnetType: - import_aws_cdk_lib9.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - environment: { - ECS_CLUSTER: clusterName, - ECS_SERVICE: serviceName, - ECS_TASK_DEFINITION: taskDefinition.family, - }, - }, - ); - Object.keys(scheduledTasks).forEach((scheduledTaskId) => { - const scheduledTask = scheduledTasks[scheduledTaskId]; - const eventTarget = - new import_aws_cdk_lib9.aws_events_targets.LambdaFunction( - this.taskExecFunction, - { - // this is the json event object that the lambda function receives - event: import_aws_cdk_lib9.aws_events.RuleTargetInput.fromObject({ - execCommand: scheduledTask.command, - }), - }, - ); - const schedule = import_aws_cdk_lib9.aws_events.Schedule.expression( - `cron(${scheduledTask.schedule})`, - ); - const ruleName = `${import_aws_cdk_lib9.Stack.of( - this, - )}-scheduled-task-${scheduledTaskId}`; - const eventRule = new import_aws_cdk_lib9.aws_events.Rule( - this, - `ScheduledTaskEventRule${scheduledTaskId}`, - { - ruleName, - schedule, - targets: [eventTarget], - description: scheduledTask.description, - }, - ); - this.eventRules.push(eventRule); - }); - this.taskExecFunction.addToRolePolicy( - new import_aws_cdk_lib9.aws_iam.PolicyStatement({ - effect: import_aws_cdk_lib9.aws_iam.Effect.ALLOW, - actions: ['ecs:Describe*', 'ecs:List*'], - resources: ['*'], - }), - ); - this.taskExecFunction.addToRolePolicy( - new import_aws_cdk_lib9.aws_iam.PolicyStatement({ - effect: import_aws_cdk_lib9.aws_iam.Effect.ALLOW, - actions: ['ecs:RunTask'], - resources: [ - `arn:aws:ecs:${region}:${account}:task-definition/${taskDefinition.family}`, - ], - }), - ); - const passRoleArns = [taskDefinition.taskRole.roleArn]; - if (taskDefinition.executionRole) { - passRoleArns.push(taskDefinition.executionRole.roleArn); - } - this.taskExecFunction.addToRolePolicy( - new import_aws_cdk_lib9.aws_iam.PolicyStatement({ - effect: import_aws_cdk_lib9.aws_iam.Effect.ALLOW, - actions: ['iam:PassRole'], - resources: passRoleArns, - }), - ); - this.alarms = [ - // alarm on any function errors - new import_aws_cdk_lib9.aws_cloudwatch.Alarm(this, 'ErrorAlarm', { - metric: this.taskExecFunction - .metricErrors() - .with({ period: import_aws_cdk_lib9.Duration.minutes(5) }), - threshold: 1, - evaluationPeriods: 1, - alarmDescription: `${stackName2} scheduled task execution error alarm`, - }), - // alarm if function isn't invoked at least once per day - new import_aws_cdk_lib9.aws_cloudwatch.Alarm(this, 'InvocationsAlarm', { - metric: this.taskExecFunction - .metricInvocations() - .with({ period: import_aws_cdk_lib9.Duration.days(1) }), - threshold: 1, - evaluationPeriods: 1, - alarmDescription: `${stackName2} no invocations alarm`, - comparisonOperator: - import_aws_cdk_lib9.aws_cloudwatch.ComparisonOperator - .LESS_THAN_THRESHOLD, - }), - ]; - new import_aws_cdk_lib9.CfnOutput(this, 'DeployConfigHash', { - exportName: `${stackName2}-scheduled-tasks-function-name`, - value: this.taskExecFunction.functionName, - }); - } -}; -var CacclScheduledTasks_default = CacclScheduledTasks; - -// cdk/lib/classes/CacclService.ts -var import_aws_cdk_lib10 = require('aws-cdk-lib'); -var import_constructs8 = require('constructs'); -var CacclService = class extends import_constructs8.Construct { - constructor(scope, id, props) { - super(scope, id); - const { - cluster, - taskDef, - taskCount, - loadBalancerSg, - enableExecuteCommand = false, - } = props; - const serviceSg = new import_aws_cdk_lib10.aws_ec2.SecurityGroup( - this, - 'SecurityGroup', - { - vpc: cluster.vpc, - description: 'ecs service security group', - }, - ); - serviceSg.connections.allowFrom( - loadBalancerSg, - import_aws_cdk_lib10.aws_ec2.Port.tcp(443), - ); - this.ecsService = new import_aws_cdk_lib10.aws_ecs.FargateService( + this.ecsService = new import_aws_cdk_lib7.aws_ecs.FargateService( this, 'FargateService', { cluster, securityGroups: [serviceSg], platformVersion: - import_aws_cdk_lib10.aws_ecs.FargatePlatformVersion.VERSION1_4, + import_aws_cdk_lib7.aws_ecs.FargatePlatformVersion.VERSION1_4, taskDefinition: taskDef.taskDef, desiredCount: taskCount, minHealthyPercent: 100, @@ -1517,7 +928,7 @@ var CacclService = class extends import_constructs8.Construct { circuitBreaker: { rollback: true, }, - propagateTags: import_aws_cdk_lib10.aws_ecs.PropagatedTagSource.SERVICE, + propagateTags: import_aws_cdk_lib7.aws_ecs.PropagatedTagSource.SERVICE, enableExecuteCommand, }, ); @@ -1526,15 +937,15 @@ var CacclService = class extends import_constructs8.Construct { containerPort: 443, }); this.alarms = []; - new import_aws_cdk_lib10.CfnOutput(this, 'ClusterName', { + new import_aws_cdk_lib7.CfnOutput(this, 'ClusterName', { exportName: `${ - import_aws_cdk_lib10.Stack.of(this).stackName + import_aws_cdk_lib7.Stack.of(this).stackName }-cluster-name`, value: cluster.clusterName, }); - new import_aws_cdk_lib10.CfnOutput(this, 'ServiceName', { + new import_aws_cdk_lib7.CfnOutput(this, 'ServiceName', { exportName: `${ - import_aws_cdk_lib10.Stack.of(this).stackName + import_aws_cdk_lib7.Stack.of(this).stackName }-service-name`, value: this.ecsService.serviceName, }); @@ -1543,8 +954,8 @@ var CacclService = class extends import_constructs8.Construct { var CacclService_default = CacclService; // cdk/lib/classes/CacclSshBastion.ts -var import_aws_cdk_lib11 = require('aws-cdk-lib'); -var import_constructs9 = require('constructs'); +var import_aws_cdk_lib8 = require('aws-cdk-lib'); +var import_constructs8 = require('constructs'); // cdk/lib/constants/DEFAULT_AMI_MAP.ts var DEFAULT_AMI_MAP = { @@ -1556,48 +967,46 @@ var DEFAULT_AMI_MAP = { var DEFAULT_AMI_MAP_default = DEFAULT_AMI_MAP; // cdk/lib/classes/CacclSshBastion.ts -var CacclSshBastion = class extends import_constructs9.Construct { +var CacclSshBastion = class extends import_constructs8.Construct { constructor(scope, id, props) { super(scope, id); const { vpc, sg } = props; - this.instance = new import_aws_cdk_lib11.aws_ec2.BastionHostLinux( + this.instance = new import_aws_cdk_lib8.aws_ec2.BastionHostLinux( this, 'SshBastionHost', { vpc, subnetSelection: { - subnetType: import_aws_cdk_lib11.aws_ec2.SubnetType.PUBLIC, + subnetType: import_aws_cdk_lib8.aws_ec2.SubnetType.PUBLIC, }, - instanceName: `${ - import_aws_cdk_lib11.Stack.of(this).stackName - }-bastion`, + instanceName: `${import_aws_cdk_lib8.Stack.of(this).stackName}-bastion`, securityGroup: sg, - machineImage: import_aws_cdk_lib11.aws_ec2.MachineImage.genericLinux( + machineImage: import_aws_cdk_lib8.aws_ec2.MachineImage.genericLinux( DEFAULT_AMI_MAP_default, ), }, ); - new import_aws_cdk_lib11.CfnOutput(this, 'DbBastionHostIp', { + new import_aws_cdk_lib8.CfnOutput(this, 'DbBastionHostIp', { exportName: `${ - import_aws_cdk_lib11.Stack.of(this).stackName + import_aws_cdk_lib8.Stack.of(this).stackName }-bastion-host-ip`, value: this.instance.instancePublicIp, }); - new import_aws_cdk_lib11.CfnOutput(this, 'DbBastionHostId', { + new import_aws_cdk_lib8.CfnOutput(this, 'DbBastionHostId', { exportName: `${ - import_aws_cdk_lib11.Stack.of(this).stackName + import_aws_cdk_lib8.Stack.of(this).stackName }-bastion-host-id`, value: this.instance.instanceId, }); - new import_aws_cdk_lib11.CfnOutput(this, 'DbBastionHostAZ', { + new import_aws_cdk_lib8.CfnOutput(this, 'DbBastionHostAZ', { exportName: `${ - import_aws_cdk_lib11.Stack.of(this).stackName + import_aws_cdk_lib8.Stack.of(this).stackName }-bastion-host-az`, value: this.instance.instanceAvailabilityZone, }); - new import_aws_cdk_lib11.CfnOutput(this, 'DbBastionSecurityGroupId', { + new import_aws_cdk_lib8.CfnOutput(this, 'DbBastionSecurityGroupId', { exportName: `${ - import_aws_cdk_lib11.Stack.of(this).stackName + import_aws_cdk_lib8.Stack.of(this).stackName }-bastion-security-group-id`, value: sg.securityGroupId, }); @@ -1606,19 +1015,15 @@ var CacclSshBastion = class extends import_constructs9.Construct { var CacclSshBastion_default = CacclSshBastion; // cdk/lib/classes/CacclTaskDef.ts -var import_aws_cdk_lib14 = require('aws-cdk-lib'); -var import_constructs12 = require('constructs'); - -// cdk/lib/constants/DEFAULT_PROXY_REPO_NAME.ts -var DEFAULT_PROXY_REPO_NAME = 'hdce/nginx-ssl-proxy'; -var DEFAULT_PROXY_REPO_NAME_default = DEFAULT_PROXY_REPO_NAME; +var import_aws_cdk_lib11 = require('aws-cdk-lib'); +var import_constructs11 = require('constructs'); // cdk/lib/classes/CacclContainerImage.ts var import_fs = __toESM(require('fs')); var import_path3 = __toESM(require('path')); -var import_aws_cdk_lib12 = require('aws-cdk-lib'); -var import_constructs10 = require('constructs'); -var CacclContainerImage = class extends import_constructs10.Construct { +var import_aws_cdk_lib9 = require('aws-cdk-lib'); +var import_constructs9 = require('constructs'); +var CacclContainerImage = class extends import_constructs9.Construct { constructor(scope, id, props) { super(scope, id); const { appImage, buildPath = process.env.APP_DIR } = props; @@ -1633,19 +1038,19 @@ var CacclContainerImage = class extends import_constructs10.Construct { } else { repoArn = appImage; } - const repo = import_aws_cdk_lib12.aws_ecr.Repository.fromRepositoryArn( + const repo = import_aws_cdk_lib9.aws_ecr.Repository.fromRepositoryArn( this, 'ContainerImageRepo', repoArn, ); this.image = - import_aws_cdk_lib12.aws_ecs.ContainerImage.fromEcrRepository( + import_aws_cdk_lib9.aws_ecs.ContainerImage.fromEcrRepository( repo, repoTag, ); } else { this.image = - import_aws_cdk_lib12.aws_ecs.ContainerImage.fromRegistry(appImage); + import_aws_cdk_lib9.aws_ecs.ContainerImage.fromRegistry(appImage); } } else if (buildPath !== void 0) { if ( @@ -1657,7 +1062,7 @@ var CacclContainerImage = class extends import_constructs10.Construct { process.exit(1); } this.image = - import_aws_cdk_lib12.aws_ecs.ContainerImage.fromAsset(buildPath); + import_aws_cdk_lib9.aws_ecs.ContainerImage.fromAsset(buildPath); } else { console.error('Missing configuration options for building the app image'); console.error('At least one of the following must be defined:'); @@ -1671,8 +1076,8 @@ var CacclContainerImage = class extends import_constructs10.Construct { var CacclContainerImage_default = CacclContainerImage; // cdk/lib/classes/CacclGitRepoVolumeContainer.ts -var import_aws_cdk_lib13 = require('aws-cdk-lib'); -var import_constructs11 = require('constructs'); +var import_aws_cdk_lib10 = require('aws-cdk-lib'); +var import_constructs10 = require('constructs'); // cdk/lib/constants/VOLUME_CONTAINER_MOUNT_PATH.ts var VOLUME_CONTAINER_MOUNT_PATH = '/var/gitrepo'; @@ -1683,26 +1088,26 @@ var VOLUME_NAME = 'gitrepovolume'; var VOLUME_NAME_default = VOLUME_NAME; // cdk/lib/classes/CacclGitRepoVolumeContainer.ts -var CacclGitRepoVolumeContainer = class extends import_constructs11.Construct { +var CacclGitRepoVolumeContainer = class extends import_constructs10.Construct { constructor(scope, id, props) { super(scope, id); const { taskDefinition, appContainer, repoUrlSecretArn, appContainerPath } = props; taskDefinition.addVolume({ name: VOLUME_NAME_default }); const repoUrlSecret = - import_aws_cdk_lib13.aws_ecs.Secret.fromSecretsManager( - import_aws_cdk_lib13.aws_secretsmanager.Secret.fromSecretCompleteArn( + import_aws_cdk_lib10.aws_ecs.Secret.fromSecretsManager( + import_aws_cdk_lib10.aws_secretsmanager.Secret.fromSecretCompleteArn( this, 'RepoUrlSecret', repoUrlSecretArn, ), ); - this.container = new import_aws_cdk_lib13.aws_ecs.ContainerDefinition( + this.container = new import_aws_cdk_lib10.aws_ecs.ContainerDefinition( this, 'GitRepoVolumeContainer', { image: - import_aws_cdk_lib13.aws_ecs.ContainerImage.fromRegistry( + import_aws_cdk_lib10.aws_ecs.ContainerImage.fromRegistry( 'alpine/git', ), command: ['git clone --branch master $GIT_REPO_URL /var/gitrepo'], @@ -1727,14 +1132,18 @@ var CacclGitRepoVolumeContainer = class extends import_constructs11.Construct { appContainer.addContainerDependencies({ container: this.container, condition: - import_aws_cdk_lib13.aws_ecs.ContainerDependencyCondition.SUCCESS, + import_aws_cdk_lib10.aws_ecs.ContainerDependencyCondition.SUCCESS, }); } }; var CacclGitRepoVolumeContainer_default = CacclGitRepoVolumeContainer; +// cdk/lib/constants/DEFAULT_PROXY_REPO_NAME.ts +var DEFAULT_PROXY_REPO_NAME = 'hdce/nginx-ssl-proxy'; +var DEFAULT_PROXY_REPO_NAME_default = DEFAULT_PROXY_REPO_NAME; + // cdk/lib/classes/CacclTaskDef.ts -var CacclTaskDef = class extends import_constructs12.Construct { +var CacclTaskDef = class extends import_constructs11.Construct { constructor(scope, id, props) { super(scope, id); const { @@ -1754,7 +1163,7 @@ var CacclTaskDef = class extends import_constructs12.Construct { appImage, }, ); - this.taskDef = new import_aws_cdk_lib14.aws_ecs.FargateTaskDefinition( + this.taskDef = new import_aws_cdk_lib11.aws_ecs.FargateTaskDefinition( this, 'Task', { @@ -1763,135 +1172,716 @@ var CacclTaskDef = class extends import_constructs12.Construct { }, ); this.appOnlyTaskDef = - new import_aws_cdk_lib14.aws_ecs.FargateTaskDefinition( + new import_aws_cdk_lib11.aws_ecs.FargateTaskDefinition( + this, + 'AppOnlyTask', + { + cpu: taskCpu, + memoryLimitMiB: taskMemory, + }, + ); + const appContainerParams = { + image: appContainerImage.image, + taskDefinition: this.taskDef, + // using the standard task def + essential: true, + environment: appEnvironment == null ? void 0 : appEnvironment.env, + secrets: appEnvironment == null ? void 0 : appEnvironment.secrets, + logging: import_aws_cdk_lib11.aws_ecs.LogDriver.awsLogs({ + streamPrefix: 'app', + logGroup: new import_aws_cdk_lib11.aws_logs.LogGroup( + this, + 'AppLogGroup', + { + logGroupName: `/${ + import_aws_cdk_lib11.Stack.of(this).stackName + }/app`, + removalPolicy: import_aws_cdk_lib11.RemovalPolicy.DESTROY, + retention: logRetentionDays, + }, + ), + }), + }; + this.appContainer = new import_aws_cdk_lib11.aws_ecs.ContainerDefinition( + this, + 'AppContainer', + appContainerParams, + ); + this.appContainer.addPortMappings({ + containerPort: 8080, + hostPort: 8080, + }); + const appOnlyContainerParams = __spreadProps( + __spreadValues({}, appContainerParams), + { + taskDefinition: this.appOnlyTaskDef, + }, + ); + new import_aws_cdk_lib11.aws_ecs.ContainerDefinition( + this, + 'AppOnlyContainer', + appOnlyContainerParams, + ); + const proxyContainerImage = new CacclContainerImage_default( + this, + 'ProxyImage', + { + appImage: proxyImage, + }, + ); + const environment = { + APP_PORT: '8080', + }; + if (props.vpcCidrBlock !== void 0) { + environment.VPC_CIDR = props.vpcCidrBlock; + } else { + throw new Error('proxy contianer environment needs the vpc cidr!'); + } + this.proxyContainer = new import_aws_cdk_lib11.aws_ecs.ContainerDefinition( + this, + 'ProxyContainer', + { + image: proxyContainerImage.image, + environment, + essential: true, + taskDefinition: this.taskDef, + logging: import_aws_cdk_lib11.aws_ecs.LogDriver.awsLogs({ + streamPrefix: 'proxy', + logGroup: new import_aws_cdk_lib11.aws_logs.LogGroup( + this, + 'ProxyLogGroup', + { + logGroupName: `/${ + import_aws_cdk_lib11.Stack.of(this).stackName + }/proxy`, + removalPolicy: import_aws_cdk_lib11.RemovalPolicy.DESTROY, + retention: logRetentionDays, + }, + ), + }), + }, + ); + this.proxyContainer.addPortMappings({ + containerPort: 443, + hostPort: 443, + }); + new import_aws_cdk_lib11.CfnOutput(this, 'TaskDefinitionArn', { + exportName: `${ + import_aws_cdk_lib11.Stack.of(this).stackName + }-task-def-name`, + // "family" is synonymous with "name", or at least aws frequently treats it that way + value: this.taskDef.family, + }); + new import_aws_cdk_lib11.CfnOutput(this, 'AppOnlyTaskDefinitionArn', { + exportName: `${ + import_aws_cdk_lib11.Stack.of(this).stackName + }-app-only-task-def-name`, + // "family" is synonymous with "name", or at least aws frequently treats it that way + value: this.appOnlyTaskDef.family, + }); + if (props.gitRepoVolume) { + const { repoUrlSecretArn, appContainerPath } = props.gitRepoVolume; + if (repoUrlSecretArn === void 0) { + throw new Error( + 'You must provide the ARN of a SecretsManager secret containing the git repo url as `deployConfig.gitRepoVolume.repoUrlSecretArn!`', + ); + } + if (appContainerPath === void 0) { + throw new Error( + 'You must set `deployConfig.gitRepoVolume.appContainerPath` to the path you want the git repo volume to be mounted in your app', + ); + } + new CacclGitRepoVolumeContainer_default(this, 'VolumeContainer', { + repoUrlSecretArn, + appContainerPath, + taskDefinition: this.taskDef, + appContainer: this.appContainer, + }); + } + } +}; +var CacclTaskDef_default = CacclTaskDef; + +// cdk/lib/classes/CacclDocDb.ts +var import_aws_cdk_lib13 = require('aws-cdk-lib'); + +// cdk/lib/classes/CacclDbBase.ts +var import_aws_cdk_lib12 = require('aws-cdk-lib'); +var import_constructs12 = require('constructs'); + +// cdk/lib/constants/DEFAULT_REMOVAL_POLICY.ts +var DEFAULT_REMOVAL_POLICY = 'DESTROY'; +var DEFAULT_REMOVAL_POLICY_default = DEFAULT_REMOVAL_POLICY; + +// cdk/lib/classes/CacclDbBase.ts +var CacclDbBase = class extends import_constructs12.Construct { + // TODO: JSDoc for constructor + constructor(scope, id, props) { + super(scope, id); + // overrides that get set in the cluster-level parameter group, + // e.g. enabling performance monitoring + this.clusterParameterGroupParams = {}; + // overrides for the instance-level param group + // e.g. turning on slow query logging + this.instanceParameterGroupParams = {}; + const { vpc } = props; + const { removalPolicy = DEFAULT_REMOVAL_POLICY_default } = props.options; + this.removalPolicy = import_aws_cdk_lib12.RemovalPolicy[removalPolicy]; + this.etcRemovalPolicy = + this.removalPolicy === import_aws_cdk_lib12.RemovalPolicy.RETAIN + ? import_aws_cdk_lib12.RemovalPolicy.RETAIN + : import_aws_cdk_lib12.RemovalPolicy.DESTROY; + this.dbPasswordSecret = new import_aws_cdk_lib12.aws_secretsmanager.Secret( + this, + 'DbPasswordSecret', + { + description: `docdb master user password for ${ + import_aws_cdk_lib12.Stack.of(this).stackName + }`, + generateSecretString: { + passwordLength: 16, + excludePunctuation: true, + }, + }, + ); + this.dbPasswordSecret.applyRemovalPolicy(this.etcRemovalPolicy); + this.dbSg = new import_aws_cdk_lib12.aws_ec2.SecurityGroup( + this, + 'DbSecurityGroup', + { + vpc, + description: 'security group for the db cluster', + allowAllOutbound: false, + }, + ); + this.dbSg.applyRemovalPolicy(this.etcRemovalPolicy); + this.dbSg.addEgressRule( + import_aws_cdk_lib12.aws_ec2.Peer.anyIpv4(), + import_aws_cdk_lib12.aws_ec2.Port.allTcp(), + ); + } + // FIXME: doesn't do anything? + createOutputs() { + new import_aws_cdk_lib12.CfnOutput(this, 'DbClusterEndpoint', { + exportName: `${ + import_aws_cdk_lib12.Stack.of(this).stackName + }-db-cluster-endpoint`, + value: `${this.host}:${this.port}`, + }); + new import_aws_cdk_lib12.CfnOutput(this, 'DbSecretArn', { + exportName: `${ + import_aws_cdk_lib12.Stack.of(this).stackName + }-db-password-secret-arn`, + value: this.dbPasswordSecret.secretArn, + }); + } + addSecurityGroupIngress(vpcCidrBlock) { + this.dbCluster.connections.allowDefaultPortInternally(); + this.dbCluster.connections.allowDefaultPortFrom( + import_aws_cdk_lib12.aws_ec2.Peer.ipv4(vpcCidrBlock), + ); + } +}; +var CacclDbBase_default = CacclDbBase; + +// cdk/lib/constants/DEFAULT_DB_INSTANCE_TYPE.ts +var DEFAULT_DB_INSTANCE_TYPE = 't3.medium'; +var DEFAULT_DB_INSTANCE_TYPE_default = DEFAULT_DB_INSTANCE_TYPE; + +// cdk/lib/constants/DEFAULT_DOCDB_ENGINE_VERSION.ts +var DEFAULT_DOCDB_ENGINE_VERSION = '3.6'; +var DEFAULT_DOCDB_ENGINE_VERSION_default = DEFAULT_DOCDB_ENGINE_VERSION; + +// cdk/lib/constants/DEFAULT_DOCDB_PARAM_GROUP_FAMILY.ts +var DEFAULT_DOCDB_PARAM_GROUP_FAMILY = 'docdb3.6'; +var DEFAULT_DOCDB_PARAM_GROUP_FAMILY_default = DEFAULT_DOCDB_PARAM_GROUP_FAMILY; + +// cdk/lib/classes/CacclDocDb.ts +var CacclDocDb = class extends CacclDbBase_default { + constructor(scope, id, props) { + super(scope, id, props); + this.metricNamespace = 'AWS/DocDB'; + const { vpc, appEnv } = props; + const { + instanceCount = 1, + instanceType = DEFAULT_DB_INSTANCE_TYPE_default, + engineVersion = DEFAULT_DOCDB_ENGINE_VERSION_default, + parameterGroupFamily = DEFAULT_DOCDB_PARAM_GROUP_FAMILY_default, + profiler = false, + } = props.options; + if (profiler) { + this.clusterParameterGroupParams.profiler = 'enabled'; + this.clusterParameterGroupParams.profiler_threshold_ms = '500'; + } + const parameterGroup = + new import_aws_cdk_lib13.aws_docdb.ClusterParameterGroup( this, - 'AppOnlyTask', + 'ClusterParameterGroup', { - cpu: taskCpu, - memoryLimitMiB: taskMemory, + dbClusterParameterGroupName: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + }-param-group`, + family: parameterGroupFamily, + description: `Cluster parameter group for ${ + import_aws_cdk_lib13.Stack.of(this).stackName + }`, + parameters: this.clusterParameterGroupParams, }, ); - const appContainerParams = { - image: appContainerImage.image, - taskDefinition: this.taskDef, - // using the standard task def - essential: true, - environment: appEnvironment == null ? void 0 : appEnvironment.env, - secrets: appEnvironment == null ? void 0 : appEnvironment.secrets, - logging: import_aws_cdk_lib14.aws_ecs.LogDriver.awsLogs({ - streamPrefix: 'app', - logGroup: new import_aws_cdk_lib14.aws_logs.LogGroup( - this, - 'AppLogGroup', - { - logGroupName: `/${ - import_aws_cdk_lib14.Stack.of(this).stackName - }/app`, - removalPolicy: import_aws_cdk_lib14.RemovalPolicy.DESTROY, - retention: logRetentionDays, - }, - ), - }), - }; - this.appContainer = new import_aws_cdk_lib14.aws_ecs.ContainerDefinition( + this.dbCluster = new import_aws_cdk_lib13.aws_docdb.DatabaseCluster( this, - 'AppContainer', - appContainerParams, - ); - this.appContainer.addPortMappings({ - containerPort: 8080, - hostPort: 8080, - }); - const appOnlyContainerParams = __spreadProps( - __spreadValues({}, appContainerParams), + 'DocDbCluster', { - taskDefinition: this.appOnlyTaskDef, + masterUser: { + username: 'root', + password: import_aws_cdk_lib13.SecretValue.secretsManager( + this.dbPasswordSecret.secretArn, + ), + }, + parameterGroup, + engineVersion, + instances: instanceCount, + vpc, + instanceType: new import_aws_cdk_lib13.aws_ec2.InstanceType( + instanceType, + ), + vpcSubnets: { + subnetType: + import_aws_cdk_lib13.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + securityGroup: this.dbSg, + backup: { + retention: import_aws_cdk_lib13.Duration.days(14), + }, + removalPolicy: this.removalPolicy, }, ); - new import_aws_cdk_lib14.aws_ecs.ContainerDefinition( - this, - 'AppOnlyContainer', - appOnlyContainerParams, + parameterGroup.applyRemovalPolicy(this.etcRemovalPolicy); + this.host = this.dbCluster.clusterEndpoint.hostname; + this.port = this.dbCluster.clusterEndpoint.portAsString(); + appEnv.addEnvironmentVar('MONGO_USER', 'root'); + appEnv.addEnvironmentVar('MONGO_HOST', `${this.host}:${this.port}`); + appEnv.addEnvironmentVar( + 'MONGO_OPTIONS', + 'tls=true&tlsAllowInvalidCertificates=true', ); - const proxyContainerImage = new CacclContainerImage_default( - this, - 'ProxyImage', - { - appImage: proxyImage, - }, + appEnv.addSecret( + 'MONGO_PASS', + import_aws_cdk_lib13.aws_ecs.Secret.fromSecretsManager( + this.dbPasswordSecret, + ), ); - const environment = { - APP_PORT: '8080', + this.createMetricsAndAlarms(); + this.createOutputs(); + this.addSecurityGroupIngress(vpc.vpcCidrBlock); + } + makeDocDbMetric(metricName, extraProps = {}) { + const metric = new import_aws_cdk_lib13.aws_cloudwatch.Metric( + __spreadValues( + { + metricName, + namespace: this.metricNamespace, + dimensionsMap: { + DBClusterIdentifier: this.dbCluster.clusterIdentifier, + }, + }, + extraProps, + ), + ).with({ period: import_aws_cdk_lib13.Duration.minutes(1) }); + return metric.attachTo(this.dbCluster); + } + createMetricsAndAlarms() { + this.metrics = { + ReadIOPS: [this.makeDocDbMetric('ReadIOPS')], + WriteIOPS: [this.makeDocDbMetric('WriteIOPS')], + CPUUtilization: [ + this.makeDocDbMetric('CPUUtilization', { + unit: import_aws_cdk_lib13.aws_cloudwatch.Unit.PERCENT, + }), + ], + FreeableMemory: [this.makeDocDbMetric('FreeableMemory')], + BufferCacheHitRatio: [ + this.makeDocDbMetric('BufferCacheHitRatio', { + unit: import_aws_cdk_lib13.aws_cloudwatch.Unit.PERCENT, + }), + ], + DatabaseConnections: [this.makeDocDbMetric('DatabaseConnections')], + DiskQueueDepth: [this.makeDocDbMetric('DiskQueueDepth')], + ReadLatency: [ + this.makeDocDbMetric('ReadLatency', { + unit: import_aws_cdk_lib13.aws_cloudwatch.Unit.MILLISECONDS, + }), + ], + WriteLatency: [ + this.makeDocDbMetric('WriteLatency', { + unit: import_aws_cdk_lib13.aws_cloudwatch.Unit.MILLISECONDS, + }), + ], + DatabaseCursorsTimedOut: [ + this.makeDocDbMetric('DatabaseCursorsTimedOut', { statistic: 'sum' }), + ], + Transactions: [this.makeDocDbMetric('TransactionsOpen')], + Queries: [this.makeDocDbMetric('OpcountersQuery')], }; - if (props.vpcCidrBlock !== void 0) { - environment.VPC_CIDR = props.vpcCidrBlock; - } else { - throw new Error('proxy contianer environment needs the vpc cidr!'); + this.alarms = [ + new import_aws_cdk_lib13.aws_cloudwatch.Alarm( + this, + 'CPUUtilizationAlarm', + { + metric: this.metrics.CPUUtilization[0].with({ + period: import_aws_cdk_lib13.Duration.minutes(5), + }), + threshold: 50, + evaluationPeriods: 3, + alarmDescription: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + } docdb cpu utilization alarm`, + }, + ), + new import_aws_cdk_lib13.aws_cloudwatch.Alarm( + this, + 'BufferCacheHitRatioAlarm', + { + metric: this.metrics.BufferCacheHitRatio[0], + threshold: 90, + evaluationPeriods: 3, + comparisonOperator: + import_aws_cdk_lib13.aws_cloudwatch.ComparisonOperator + .LESS_THAN_OR_EQUAL_TO_THRESHOLD, + alarmDescription: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + } docdb buffer cache hit ratio alarm`, + }, + ), + new import_aws_cdk_lib13.aws_cloudwatch.Alarm(this, 'DiskQueueDepth', { + metric: this.metrics.DiskQueueDepth[0], + threshold: 1, + evaluationPeriods: 3, + alarmDescription: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + } docdb disk queue depth`, + }), + new import_aws_cdk_lib13.aws_cloudwatch.Alarm(this, 'ReadLatency', { + metric: this.metrics.ReadLatency[0], + threshold: 20, + evaluationPeriods: 3, + treatMissingData: + import_aws_cdk_lib13.aws_cloudwatch.TreatMissingData.IGNORE, + alarmDescription: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + } docdb read latency alarm`, + }), + new import_aws_cdk_lib13.aws_cloudwatch.Alarm(this, 'WriteLatency', { + metric: this.metrics.WriteLatency[0], + threshold: 100, + evaluationPeriods: 3, + treatMissingData: + import_aws_cdk_lib13.aws_cloudwatch.TreatMissingData.IGNORE, + alarmDescription: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + } docdb write latency alarm`, + }), + new import_aws_cdk_lib13.aws_cloudwatch.Alarm( + this, + 'DatabaseCursorsTimedOutAlarm', + { + metric: this.metrics.DatabaseCursorsTimedOut[0].with({ + period: import_aws_cdk_lib13.Duration.minutes(5), + }), + threshold: 5, + evaluationPeriods: 3, + alarmDescription: `${ + import_aws_cdk_lib13.Stack.of(this).stackName + } docdb cursors timed out alarm`, + }, + ), + ]; + } + getDashboardLink() { + const { region } = import_aws_cdk_lib13.Stack.of(this); + const dbClusterId = this.dbCluster.clusterIdentifier; + return `https://console.aws.amazon.com/docdb/home?region=${region}#cluster-details/${dbClusterId}`; + } +}; +var CacclDocDb_default = CacclDocDb; + +// cdk/lib/classes/CacclRdsDb.ts +var import_aws_cdk_lib14 = require('aws-cdk-lib'); + +// cdk/lib/constants/DEFAULT_AURORA_MYSQL_ENGINE_VERSION.ts +var DEFAULT_AURORA_MYSQL_ENGINE_VERSION = '5.7.mysql_aurora.2.11.2'; +var DEFAULT_AURORA_MYSQL_ENGINE_VERSION_default = + DEFAULT_AURORA_MYSQL_ENGINE_VERSION; + +// cdk/lib/classes/CacclRdsDb.ts +var CacclRdsDb = class extends CacclDbBase_default { + constructor(scope, id, props) { + super(scope, id, props); + this.metricNamespace = 'AWS/RDS'; + const { vpc, appEnv } = props; + const { + instanceCount = 1, + instanceType = DEFAULT_DB_INSTANCE_TYPE_default, + engineVersion = DEFAULT_AURORA_MYSQL_ENGINE_VERSION_default, + databaseName, + } = props.options; + const majorVersion = engineVersion.substring(0, 3); + const auroraMysqlEngineVersion = + import_aws_cdk_lib14.aws_rds.DatabaseClusterEngine.auroraMysql({ + version: import_aws_cdk_lib14.aws_rds.AuroraMysqlEngineVersion.of( + engineVersion, + majorVersion, + ), + }); + const enablePerformanceInsights = !instanceType.startsWith('t3'); + this.clusterParameterGroupParams.lower_case_table_names = '1'; + if (parseInt(majorVersion, 10) < 8) { + this.clusterParameterGroupParams.aurora_enable_repl_bin_log_filtering = + '1'; } - this.proxyContainer = new import_aws_cdk_lib14.aws_ecs.ContainerDefinition( + const clusterParameterGroup = + new import_aws_cdk_lib14.aws_rds.ParameterGroup( + this, + 'ClusterParameterGroup', + { + engine: auroraMysqlEngineVersion, + description: `RDS parameter group for ${ + import_aws_cdk_lib14.Stack.of(this).stackName + }`, + parameters: this.clusterParameterGroupParams, + }, + ); + this.instanceParameterGroupParams.slow_query_log = '1'; + this.instanceParameterGroupParams.log_output = 'TABLE'; + this.instanceParameterGroupParams.long_query_time = '3'; + this.instanceParameterGroupParams.sql_mode = 'STRICT_ALL_TABLES'; + this.instanceParameterGroupParams.innodb_monitor_enable = 'all'; + const instanceParameterGroup = + new import_aws_cdk_lib14.aws_rds.ParameterGroup( + this, + 'InstanceParameterGroup', + { + engine: auroraMysqlEngineVersion, + description: `RDS instance parameter group for ${ + import_aws_cdk_lib14.Stack.of(this).stackName + }`, + parameters: this.instanceParameterGroupParams, + }, + ); + this.dbCluster = new import_aws_cdk_lib14.aws_rds.DatabaseCluster( this, - 'ProxyContainer', + 'RdsDbCluster', { - image: proxyContainerImage.image, - environment, - essential: true, - taskDefinition: this.taskDef, - logging: import_aws_cdk_lib14.aws_ecs.LogDriver.awsLogs({ - streamPrefix: 'proxy', - logGroup: new import_aws_cdk_lib14.aws_logs.LogGroup( - this, - 'ProxyLogGroup', - { - logGroupName: `/${ - import_aws_cdk_lib14.Stack.of(this).stackName - }/proxy`, - removalPolicy: import_aws_cdk_lib14.RemovalPolicy.DESTROY, - retention: logRetentionDays, - }, + engine: auroraMysqlEngineVersion, + clusterIdentifier: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + }-db-cluster`, + credentials: { + username: 'root', + password: import_aws_cdk_lib14.SecretValue.secretsManager( + this.dbPasswordSecret.secretArn, ), - }), + }, + parameterGroup: clusterParameterGroup, + instances: instanceCount, + defaultDatabaseName: databaseName, + instanceProps: { + vpc, + instanceType: new import_aws_cdk_lib14.aws_ec2.InstanceType( + instanceType, + ), + enablePerformanceInsights, + parameterGroup: instanceParameterGroup, + vpcSubnets: { + subnetType: + import_aws_cdk_lib14.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + securityGroups: [this.dbSg], + }, + backup: { + retention: import_aws_cdk_lib14.Duration.days(14), + }, + removalPolicy: this.removalPolicy, }, ); - this.proxyContainer.addPortMappings({ - containerPort: 443, - hostPort: 443, - }); - new import_aws_cdk_lib14.CfnOutput(this, 'TaskDefinitionArn', { - exportName: `${ - import_aws_cdk_lib14.Stack.of(this).stackName - }-task-def-name`, - // "family" is synonymous with "name", or at least aws frequently treats it that way - value: this.taskDef.family, - }); - new import_aws_cdk_lib14.CfnOutput(this, 'AppOnlyTaskDefinitionArn', { - exportName: `${ - import_aws_cdk_lib14.Stack.of(this).stackName - }-app-only-task-def-name`, - // "family" is synonymous with "name", or at least aws frequently treats it that way - value: this.appOnlyTaskDef.family, + clusterParameterGroup.applyRemovalPolicy(this.etcRemovalPolicy); + instanceParameterGroup.applyRemovalPolicy(this.etcRemovalPolicy); + this.host = this.dbCluster.clusterEndpoint.hostname; + this.port = '3306'; + appEnv.addEnvironmentVar('DATABASE_USER', 'root'); + appEnv.addEnvironmentVar('DATABASE_PORT', this.port); + appEnv.addEnvironmentVar('DATABASE_HOST', this.host); + appEnv.addEnvironmentVar( + 'DATABASE_NAME', + databaseName != null ? databaseName : '', + ); + appEnv.addSecret( + 'DATABASE_PASSWORD', + import_aws_cdk_lib14.aws_ecs.Secret.fromSecretsManager( + this.dbPasswordSecret, + ), + ); + this.createMetricsAndAlarms(); + this.createOutputs(); + this.addSecurityGroupIngress(vpc.vpcCidrBlock); + } + makeInstanceMetrics(metricName, extraProps = {}) { + return this.dbCluster.instanceIdentifiers.map((id) => { + const metric = new import_aws_cdk_lib14.aws_cloudwatch.Metric( + __spreadValues( + { + metricName, + namespace: this.metricNamespace, + dimensionsMap: { DBInstanceIdentifier: id }, + label: id, + }, + extraProps, + ), + ).with({ period: import_aws_cdk_lib14.Duration.minutes(1) }); + return metric.attachTo(this.dbCluster); }); - if (props.gitRepoVolume) { - const { repoUrlSecretArn, appContainerPath } = props.gitRepoVolume; - if (repoUrlSecretArn === void 0) { - throw new Error( - 'You must provide the ARN of a SecretsManager secret containing the git repo url as `deployConfig.gitRepoVolume.repoUrlSecretArn!`', + } + createMetricsAndAlarms() { + this.metrics = { + ReadIOPS: this.makeInstanceMetrics('ReadIOPS'), + WriteIOPS: this.makeInstanceMetrics('WriteIOPS'), + CPUUtilization: this.makeInstanceMetrics('CPUUtilization', { + unit: import_aws_cdk_lib14.aws_cloudwatch.Unit.PERCENT, + }), + FreeableMemory: this.makeInstanceMetrics('FreeableMemory'), + BufferCacheHitRatio: this.makeInstanceMetrics('BufferCacheHitRatio', { + unit: import_aws_cdk_lib14.aws_cloudwatch.Unit.PERCENT, + }), + DatabaseConnections: this.makeInstanceMetrics('DatabaseConnections'), + DiskQueueDepth: this.makeInstanceMetrics('DiskQueueDepth'), + ReadLatency: this.makeInstanceMetrics('ReadLatency', { + unit: import_aws_cdk_lib14.aws_cloudwatch.Unit.MILLISECONDS, + }), + WriteLatency: this.makeInstanceMetrics('WriteLatency', { + unit: import_aws_cdk_lib14.aws_cloudwatch.Unit.MILLISECONDS, + }), + DatabaseCursorsTimedOut: this.makeInstanceMetrics( + 'DatabaseCursorsTimedOut', + { statistic: 'sum' }, + ), + Transactions: this.makeInstanceMetrics('ActiveTransactions'), + Queries: this.makeInstanceMetrics('Queries'), + }; + this.alarms = [ + ...this.metrics.ReadIOPS.map((metric, idx) => { + return new import_aws_cdk_lib14.aws_cloudwatch.Alarm( + this, + `CPUUtilizationAlarm-${idx}`, + { + metric, + threshold: 50, + evaluationPeriods: 3, + alarmDescription: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + } ${metric.label} cpu utilization alarm`, + }, ); - } - if (appContainerPath === void 0) { - throw new Error( - 'You must set `deployConfig.gitRepoVolume.appContainerPath` to the path you want the git repo volume to be mounted in your app', + }), + ...this.metrics.BufferCacheHitRatio.map((metric, idx) => { + return new import_aws_cdk_lib14.aws_cloudwatch.Alarm( + this, + `BufferCacheHitRatioAlarm-${idx}`, + { + metric, + threshold: 90, + evaluationPeriods: 3, + comparisonOperator: + import_aws_cdk_lib14.aws_cloudwatch.ComparisonOperator + .LESS_THAN_OR_EQUAL_TO_THRESHOLD, + alarmDescription: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + } ${metric.label} buffer cache hit ratio alarm`, + }, ); - } - new CacclGitRepoVolumeContainer_default(this, 'VolumeContainer', { - repoUrlSecretArn, - appContainerPath, - taskDefinition: this.taskDef, - appContainer: this.appContainer, - }); - } + }), + ...this.metrics.DiskQueueDepth.map((metric, idx) => { + return new import_aws_cdk_lib14.aws_cloudwatch.Alarm( + this, + `DiskQueueDepth-${idx}`, + { + metric, + threshold: 1, + evaluationPeriods: 3, + alarmDescription: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + } ${metric.label} disk queue depth`, + }, + ); + }), + ...this.metrics.ReadLatency.map((metric, idx) => { + return new import_aws_cdk_lib14.aws_cloudwatch.Alarm( + this, + `ReadLatency-${idx}`, + { + metric, + threshold: 20, + evaluationPeriods: 3, + treatMissingData: + import_aws_cdk_lib14.aws_cloudwatch.TreatMissingData.IGNORE, + alarmDescription: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + } ${metric.label} read latency alarm`, + }, + ); + }), + ...this.metrics.WriteLatency.map((metric, idx) => { + return new import_aws_cdk_lib14.aws_cloudwatch.Alarm( + this, + `WriteLatency-${idx}`, + { + metric, + threshold: 100, + evaluationPeriods: 3, + treatMissingData: + import_aws_cdk_lib14.aws_cloudwatch.TreatMissingData.IGNORE, + alarmDescription: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + } ${metric.label} write latency alarm`, + }, + ); + }), + ...this.metrics.DatabaseCursorsTimedOut.map((metric, idx) => { + return new import_aws_cdk_lib14.aws_cloudwatch.Alarm( + this, + `DatabaseCursorsTimedOutAlarm-${idx}`, + { + metric, + threshold: 1, + evaluationPeriods: 1, + alarmDescription: `${ + import_aws_cdk_lib14.Stack.of(this).stackName + } ${metric.label} cursors timed out alarm`, + }, + ); + }), + ]; + } + getDashboardLink() { + const { region } = import_aws_cdk_lib14.Stack.of(this); + const dbClusterId = this.dbCluster.clusterIdentifier; + return `https://console.aws.amazon.com/rds/home?region=${region}#database:id=${dbClusterId};is-cluster=true`; } }; -var CacclTaskDef_default = CacclTaskDef; +var CacclRdsDb_default = CacclRdsDb; + +// cdk/lib/helpers/createDbConstruct.ts +var createDbConstruct = (scope, props) => { + const { options } = props; + switch (options.engine.toLowerCase()) { + case 'docdb': + return new CacclDocDb_default(scope, 'DocDb', props); + case 'mysql': + return new CacclRdsDb_default(scope, 'RdsDb', props); + default: + throw Error(`Invalid dbOptions.engine value: ${options.engine}`); + } +}; +var createDbConstruct_default = createDbConstruct; // cdk/lib/classes/CacclDeployStack.ts var CacclDeployStack = class extends import_aws_cdk_lib15.Stack { @@ -2068,12 +2058,139 @@ var CacclDeployStack = class extends import_aws_cdk_lib15.Stack { }; var CacclDeployStack_default = CacclDeployStack; +// types/CacclCacheOptions.ts +var import_zod = require('zod'); +var CacclCacheOptions = import_zod.z.object({ + engine: import_zod.z.string(), + numCacheNodes: import_zod.z.number().optional(), + cacheNodeType: import_zod.z.string().optional(), +}); +var CacclCacheOptions_default = CacclCacheOptions; + +// types/CacclDbEngine.ts +var import_zod2 = require('zod'); +var CacclDbEngine = import_zod2.z.enum(['docdb', 'mysql']); +var CacclDbEngine_default = CacclDbEngine; + +// types/CacclDbOptions.ts +var import_zod3 = require('zod'); +var CacclDbOptions = import_zod3.z.object({ + // currently either 'docdb' or 'mysql' + engine: CacclDbEngine_default, + // see the aws docs for supported types + instanceType: import_zod3.z.string().optional(), + // > 1 will get you multi-az + instanceCount: import_zod3.z.number().optional(), + // use a non-default engine version (shouldn't be necessary) + engineVersion: import_zod3.z.string().optional(), + // use a non-default parameter group family (also unnecessary) + parameterGroupFamily: import_zod3.z.string().optional(), + // only used by docdb, turns on extra profiling + profiler: import_zod3.z.boolean().optional(), + // only used by mysql, provisioning will create the named database + databaseName: import_zod3.z.string().optional(), + // removal policy controls what happens to the db if it's replaced or otherwise stops being managed by CloudFormation + removalPolicy: import_zod3.z.string().optional(), +}); +var CacclDbOptions_default = CacclDbOptions; + +// types/CacclDeployStackPropsData.ts +var import_zod8 = require('zod'); + +// types/DeployConfigData.ts +var import_zod7 = require('zod'); + +// types/CacclLoadBalancerExtraOptions.ts +var import_zod4 = require('zod'); +var CacclLoadBalancerExtraOptions = import_zod4.z.object({ + healthCheckPath: import_zod4.z.string().optional(), + targetDeregistrationDelay: import_zod4.z.number().optional(), +}); +var CacclLoadBalancerExtraOptions_default = CacclLoadBalancerExtraOptions; + +// types/CacclNotificationsProps.ts +var import_zod5 = require('zod'); +var CacclNotificationsProps = import_zod5.z.object({ + email: import_zod5.z + .union([import_zod5.z.string(), import_zod5.z.string().array()]) + .optional(), + slack: import_zod5.z.string().optional(), +}); +var CacclNotificationsProps_default = CacclNotificationsProps; + +// types/CacclScheduledTask.ts +var import_zod6 = require('zod'); +var CacclScheduledTask = import_zod6.z.object({ + description: import_zod6.z.string().optional(), + schedule: import_zod6.z.string(), + command: import_zod6.z.string(), +}); +var CacclScheduledTask_default = CacclScheduledTask; + +// types/DeployConfigData.ts +var DeployConfigData = import_zod7.z.object({ + // + appImage: import_zod7.z.string(), + proxyImage: import_zod7.z.string().optional(), + taskCpu: import_zod7.z.number().optional(), + taskMemory: import_zod7.z.number().optional(), + logRetentionDays: import_zod7.z.number().optional(), + gitRepoVolume: import_zod7.z + .object({}) + .catchall(import_zod7.z.string()) + .optional(), + // CloudFormation infrastructure stack name + infraStackName: import_zod7.z.string(), + // Container image ARN + notifications: CacclNotificationsProps_default.optional(), + certificateArn: import_zod7.z.string().optional(), + appEnvironment: import_zod7.z + .object({}) + .catchall(import_zod7.z.string()) + .optional(), + tags: import_zod7.z.object({}).catchall(import_zod7.z.string()).optional(), + scheduledTasks: import_zod7.z + .object({}) + .catchall(CacclScheduledTask_default) + .optional(), + taskCount: import_zod7.z.string(), + firewallSgId: import_zod7.z.string().optional(), + lbOptions: CacclLoadBalancerExtraOptions_default.optional(), + cacheOptions: CacclCacheOptions_default.optional(), + dbOptions: CacclDbOptions_default.optional(), + enableExecuteCommand: import_zod7.z + .union([import_zod7.z.string(), import_zod7.z.boolean()]) + .optional(), + // DEPRECATED: + docDb: import_zod7.z.any(), + docDbInstanceCount: import_zod7.z.number().optional(), + docDbInstanceType: import_zod7.z.string().optional(), + docDbProfiler: import_zod7.z.boolean().optional(), +}); +var DeployConfigData_default = DeployConfigData; + +// types/CacclDeployStackPropsData.ts +var CacclDeployStackPropsData = import_zod8.z.object({ + stackName: import_zod8.z.string(), + vpcId: import_zod8.z.string().optional(), + ecsClusterName: import_zod8.z.string().optional(), + albLogBucketName: import_zod8.z.string().optional(), + awsRegion: import_zod8.z.string().optional(), + awsAccountId: import_zod8.z.string().optional(), + cacclDeployVersion: import_zod8.z.string(), + deployConfigHash: import_zod8.z.string(), + deployConfig: DeployConfigData_default, +}); +var CacclDeployStackPropsData_default = CacclDeployStackPropsData; + // cdk/cdk.ts if (process.env.CDK_STACK_PROPS_FILE_PATH === void 0) { throw new Error(); } -var stackPropsData = JSON.parse( - (0, import_fs2.readFileSync)(process.env.CDK_STACK_PROPS_FILE_PATH, 'utf8'), +var stackPropsData = CacclDeployStackPropsData_default.parse( + JSON.parse( + (0, import_fs2.readFileSync)(process.env.CDK_STACK_PROPS_FILE_PATH, 'utf8'), + ), ); var { stackName, @@ -2139,7 +2256,7 @@ var stackProps = { }; if ((0, import_yn.default)(deployConfig.docDb)) { stackProps.dbOptions = { - engine: CacclDbEngine_default.DocDB, + engine: 'docdb', instanceCount: deployConfig.docDbInstanceCount, instanceType: deployConfig.docDbInstanceType, profiler: deployConfig.docDbProfiler, diff --git a/dist/cli.js b/dist/cli.js new file mode 100644 index 0000000..02521b7 --- /dev/null +++ b/dist/cli.js @@ -0,0 +1,3984 @@ +'use strict'; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __pow = Math.pow; +var __defNormalProp = (obj, key, value) => + key in obj + ? __defProp(obj, key, { + enumerable: true, + configurable: true, + writable: true, + value, + }) + : (obj[key] = value); +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __commonJS = (cb, mod) => + function __require() { + return ( + mod || + (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), + mod.exports + ); + }; +var __copyProps = (to, from, except, desc) => { + if ((from && typeof from === 'object') || typeof from === 'function') { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: () => from[key], + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable, + }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => ( + (target = mod != null ? __create(__getProtoOf(mod)) : {}), + __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule + ? __defProp(target, 'default', { value: mod, enumerable: true }) + : target, + mod, + ) +); +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => + x.done + ? resolve(x.value) + : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; + +// lib/errors.js +var require_errors = __commonJS({ + 'lib/errors.js'(exports2, module2) { + 'use strict'; + var CacclDeployError2 = class extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + } + }; + var AwsProfileNotFound2 = class extends CacclDeployError2 {}; + var AppNotFound2 = class extends CacclDeployError2 {}; + var ExistingSecretWontDelete2 = class extends CacclDeployError2 {}; + var CfnStackNotFound2 = class extends CacclDeployError2 {}; + var UserCancel2 = class extends CacclDeployError2 {}; + var NoPromptChoices2 = class extends CacclDeployError2 {}; + module2.exports = { + AwsProfileNotFound: AwsProfileNotFound2, + AppNotFound: AppNotFound2, + ExistingSecretWontDelete: ExistingSecretWontDelete2, + CfnStackNotFound: CfnStackNotFound2, + UserCancel: UserCancel2, + NoPromptChoices: NoPromptChoices2, + }; + }, +}); + +// lib/helpers.js +var require_helpers = __commonJS({ + 'lib/helpers.js'(exports2, module2) { + 'use strict'; + var fs2 = require('fs'); + var path2 = require('path'); + var semver2 = require('semver'); + var LOOKS_LIKE_SEMVER_REGEX2 = new RegExp( + [ + '(?0|(?:[1-9]\\d*))', + '(?:\\.(?0|(?:[1-9]\\d*))', + '(?:\\.(?0|(?:[1-9]\\d*))))', + ].join(''), + ); + module2.exports = { + readJson: (filePath) => { + return JSON.parse(fs2.readFileSync(path2.resolve(filePath), 'utf8')); + }, + readFile: (filePath) => { + return fs2.readFileSync(require.resolve(filePath), 'utf8'); + }, + tagsForAws: (tags = {}) => { + return Object.entries(tags).map(([k, v]) => { + return { Key: k, Value: v }; + }); + }, + sleep: (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }, + looksLikeSemver: (s) => { + return LOOKS_LIKE_SEMVER_REGEX2.test(s); + }, + validSSMParamName: (name) => { + return /^([a-z0-9:/_-]+)$/i.test(name); + }, + warnAboutVersionDiff: (versionString1, versionString2) => { + let v1; + let v2; + if ( + [versionString1, versionString2].filter((v) => { + return v.includes('branch='); + }).length === 1 + ) { + return true; + } + try { + v1 = versionString1.match(new RegExp('^package=(?[^:]+)')) + .groups.version; + v2 = versionString2.match(new RegExp('^package=(?[^:]+)')) + .groups.version; + } catch (err) { + if (err instanceof TypeError) { + return true; + } + throw err; + } + if (v1 === v2) return false; + if (!semver2.valid(v1) || !semver2.valid(v2)) { + return true; + } + return !semver2.satisfies(v1, `${v2.slice(0, -1)}x`); + }, + }; + }, +}); + +// lib/aws.js +var require_aws = __commonJS({ + 'lib/aws.js'(exports2, module2) { + 'use strict'; + var SharedIniFile2 = require('aws-sdk/lib/shared-ini').iniLoader; + var { camelCase: camelCase2 } = require('camel-case'); + var { + ExistingSecretWontDelete: ExistingSecretWontDelete2, + CfnStackNotFound: CfnStackNotFound2, + AwsProfileNotFound: AwsProfileNotFound2, + } = require_errors(); + var { + sleep: sleep2, + looksLikeSemver: looksLikeSemver2, + readFile: readFile2, + } = require_helpers(); + var AWS26; + var assumedRoleArn; + var assumedRoleCredentials; + try { + process.env.AWS_SDK_LOAD_CONFIG = 1; + AWS26 = require('aws-sdk'); + } catch (err) { + if ( + process.env.NODE_ENV !== 'test' && + (err.code !== 'ENOENT' || !err.message.includes('.aws/credentials')) + ) { + throw err; + } + } + var aws = { + EC2_INSTANCE_CONNECT_USER: 'ec2-user', + /** + * checks that the AWS package interface has the configuration it needs + * @returns {boolean} + */ + isConfigured: () => { + try { + return [AWS26, AWS26.config.credentials, AWS26.config.region].every( + (thing) => { + return thing !== void 0 && thing !== null; + }, + ); + } catch (err) { + return false; + } + }, + /** + * Split an ECR ARN value into parts. For example the ARN + * "arn:aws:ecr:us-east-1:12345678901:repository/foo/tool:1.0.0" + * would return + * { + * service: ecr, + * region: us-east-1, + * account: 12345678901, + * repoName: foo/tool, + * imageTag: 1.0.0 + * } + * @param {string} arn - an ECR ARN value + * @returns {object} an object representing the parsed ECR image ARN + */ + parseEcrArn: (arn) => { + const parts = arn.split(':'); + const [relativeId, imageTag] = parts.slice(-2); + const repoName = relativeId.replace('repository/', ''); + return { + service: parts[2], + region: parts[3], + account: parts[4], + repoName, + imageTag, + }; + }, + /** + * Transforms an ECR ARN value into it's URI form + * for example, this: + * arn:aws:ecr:us-east-1:12345678901:repository/foo/tool:1.0.0 + * becomes this: + * 12345678901.dkr.ecr.us-east-1.amazonaws.com/foo/toool + * @param {string} arn - an ECR ARN value + * @returns {string} and ECR image URI + */ + ecrArnToImageId: (arn) => { + const parsedArn = aws.parseEcrArn(arn); + const host = [ + parsedArn.account, + 'dkr.ecr', + parsedArn.region, + 'amazonaws.com', + ].join('.'); + return `${host}/${parsedArn.repoName}:${parsedArn.imageTag}`; + }, + /** + * Reassembles the result of `parseEcrArn` into a string + * @param {object} arnObj + * @returns {string} an ECR image ARN + */ + createEcrArn: (arnObj) => { + return [ + 'arn:aws:ecr', + arnObj.region, + arnObj.account, + `repository/${arnObj.repoName}`, + arnObj.imageTag, + ].join(':'); + }, + /** + * Initialize the aws-sdk library with credentials from a + * specific profile. + * @param {string} profileName + */ + initProfile: (profileName) => { + const awsCredentials = SharedIniFile2.loadFrom(); + if (awsCredentials[profileName] === void 0) { + throw new AwsProfileNotFound2( + `Tried to init a non-existent profile: '${profileName}'`, + ); + } + const profileCreds = awsCredentials[profileName]; + AWS26.config.update({ + credentials: new AWS26.Credentials({ + accessKeyId: profileCreds.aws_access_key_id, + secretAccessKey: profileCreds.aws_secret_access_key, + }), + }); + }, + /** + * Set an IAM role for AWS clients to assume + * @param {string} roleArn + */ + setAssumedRoleArn: (roleArn) => { + assumedRoleArn = roleArn; + }, + /** + * @returns {string} the AWS account id of the current user + */ + getAccountId: () => + __async(exports2, null, function* () { + const sts = new AWS26.STS(); + const identity = yield sts.getCallerIdentity({}).promise(); + return identity.Account; + }), + /** + * Returns the configured region. + * The region can be set in a couple of ways: + * - the usual env vars, AWS_REGION, etc + * - a region configured in the user's AWS profile/credentials + * @returns {string} + */ + getCurrentRegion: () => { + return AWS26.config.region; + }, + /** + * Returns a list of available infrastructure stacks. It assumes + * any CloudFormation stack with an output named `InfraStackName` + * is a compatible stack. + * @returns {string[]} + */ + getInfraStackList: () => + __async(exports2, null, function* () { + const cfn = new AWS26.CloudFormation(); + const stackList = []; + const stacks = yield getPaginatedResponse2( + cfn.describeStacks.bind(cfn), + {}, + 'Stacks', + ); + stacks.forEach((stack) => { + if (stack.Outputs) { + const ouptutKeys = stack.Outputs.map((o) => { + return o.OutputKey; + }); + if (ouptutKeys.indexOf('InfraStackName') >= 0) { + stackList.push(stack.StackName); + } + } + }); + return stackList; + }), + /** + * Return all the unqique app parameter namespaces, i.e., all the + * distinct values that come after `/[prefix]` in the hierarchy. + * + * The SSM API doesn't have a great way to search/filter for parameter + * store entries + * + * @param {string} prefix - name prefix used by the app CloudFormation stacks + * @returns {string[]} + */ + getAppList: (prefix) => + __async(exports2, null, function* () { + const ssm = new AWS26.SSM(); + const searchParams = { + MaxResults: 50, + // lord i hope we never have this many apps + ParameterFilters: [ + { + Key: 'Name', + Option: 'Contains', + // making an assumption that all configurations will include this + Values: ['/infraStackName'], + }, + ], + }; + const paramEntries = yield getPaginatedResponse2( + ssm.describeParameters.bind(ssm), + searchParams, + 'Parameters', + ); + const filtered = paramEntries.filter((p) => { + return p.Name.startsWith(prefix); + }); + return filtered.map((p) => { + return p.Name.split('/')[2]; + }); + }), + /** + * @returns {string[]} - array of ECR repository names + */ + getRepoList: () => + __async(exports2, null, function* () { + const ecr = yield getAssumedRoleClient(AWS26.ECR); + const edtechAppRepos = []; + const repos = yield getPaginatedResponse2( + ecr.describeRepositories.bind(ecr), + {}, + 'repositories', + ); + for (let i = 0; i < repos.length; i += 1) { + const r = repos[i]; + const tagResp = yield ecr + .listTagsForResource({ + resourceArn: r.repositoryArn, + }) + .promise(); + const isAnEdtechAppRepo = tagResp.tags.some((t) => { + return t.Key === 'product' && t.Value === 'edtech-apps'; + }); + if (isAnEdtechAppRepo) { + edtechAppRepos.push(r.repositoryName); + } + } + return edtechAppRepos; + }), + /** + * @param {string} repo - ECR repository name, e.g. 'hdce/fooapp' + * @param {boolean} all - return all tags; don't filter for master, stage, + * tags that look like semver, etc + * @returns {object[]} + */ + getRepoImageList: (repo, all) => + __async(exports2, null, function* () { + const ecr = yield getAssumedRoleClient(AWS26.ECR); + const images = yield getPaginatedResponse2( + ecr.describeImages.bind(ecr), + { + repositoryName: repo, + maxResults: 1e3, + filter: { + tagStatus: 'TAGGED', + }, + }, + 'imageDetails', + ); + images.sort((a, b) => { + if (a.imagePushedAt < b.imagePushedAt) { + return 1; + } + if (a.imagePushedAt > b.imagePushedAt) { + return -1; + } + return 0; + }); + if (!all) { + return images.filter((i) => { + return i.imageTags.some((t) => { + return ( + looksLikeSemver2(t) || ['main', 'master', 'stage'].includes(t) + ); + }); + }); + } + return images; + }), + /** + * Confirms that a repo/tag combo exists + * @param {string} repoName - ECR repository name + * @param {string} tag - ECR image tag + * @returns {boolean} + */ + imageTagExists: (repoName, tag) => + __async(exports2, null, function* () { + const imageList = yield aws.getRepoImageList(repoName, true); + return imageList.some((i) => { + return i.imageTags.includes(tag); + }); + }), + /** + * Confirms that a tag is the latest for a repo + * @param {string} repoName + * @param {string} tag + * @returns {boolean} + */ + isLatestTag: (repoName, tag) => + __async(exports2, null, function* () { + const imageList = yield aws.getRepoImageList(repoName); + return imageList.length && imageList[0].imageTags.includes(tag); + }), + /** + * Confirm that a secretsmanager entry exists + * @param {string} secretName + * @returns {boolean} + */ + secretExists: (secretName) => + __async(exports2, null, function* () { + const sm = new AWS26.SecretsManager(); + const params = { + Filters: [ + { + Key: 'name', + Values: [secretName], + }, + ], + }; + const resp = yield sm.listSecrets(params).promise(); + return resp.SecretList.length > 0; + }), + /** + * Fetch the secret value for a secretsmanager entry + * @param {string} secretArn + * @returns {string} + */ + resolveSecret: (secretArn) => + __async(exports2, null, function* () { + const sm = new AWS26.SecretsManager(); + const resp = yield sm + .getSecretValue({ + SecretId: secretArn, + }) + .promise(); + return resp.SecretString; + }), + /** + * creates or updates a secrets manager entry + * NOTE: the update + tagging operation is NOT atomic! I wish the + * sdk made this easier + * @param {object} [secretOpts={}] - secret entry options + * @param {string} [secretOpts.Name] - name of the secrets manager entry + * @param {string} [secretOpts.Description] - description of the entry + * @param {string} [secretOpts.SecretString] - value of the secret + * @param {array} [tags=[]] - aws tags [{ Name: '...', Value: '...'}] + * @returns {string} - the secretsmanager entry ARN + */ + putSecret: (secretOpts, tags, retries = 0) => + __async(exports2, null, function* () { + const sm = new AWS26.SecretsManager(); + const { Name: SecretId, Description, SecretString } = secretOpts; + let secretResp; + try { + const exists = yield aws.secretExists(SecretId); + if (exists) { + secretResp = yield sm + .updateSecret({ + SecretId, + Description, + SecretString, + }) + .promise(); + console.log(`secretsmanager entry ${SecretId} updated`); + if (tags.length) { + yield sm + .tagResource({ + SecretId, + Tags: tags, + }) + .promise(); + console.log(`secretsmanager entry ${SecretId} tagged`); + } + } else { + secretResp = yield sm + .createSecret({ + Name: SecretId, + Description, + SecretString, + Tags: tags, + }) + .promise(); + console.log(`secretsmanager entry ${SecretId} created`); + } + } catch (err) { + if (err.message.includes('already scheduled for deletion')) { + if (retries < 5) { + retries += 1; + yield sleep2(__pow(2, retries) * 1e3); + return aws.putSecret(secretOpts, tags, retries); + } + console.error('putSecret failed after 5 retries'); + throw new ExistingSecretWontDelete2( + `Failed to overwrite existing secret ${SecretId}`, + ); + } + throw err; + } + return secretResp.ARN; + }), + /** + * delete one or more secretsmanager entries + * @param {string[]} secretArns + */ + deleteSecrets: (secretArns) => + __async(exports2, null, function* () { + const sm = new AWS26.SecretsManager(); + for (let i = 0; i < secretArns.length; i += 1) { + yield sm + .deleteSecret({ + SecretId: secretArns[i], + ForceDeleteWithoutRecovery: true, + }) + .promise(); + console.log(`secret ${secretArns[i]} deleted`); + } + }), + /** + * @param {object} opts - the parameter details, name, value, etc + * @param {object[]} tags - aws resource tags + * @returns {object} + */ + putSsmParameter: (_0, ..._1) => + __async(exports2, [_0, ..._1], function* (opts, tags = []) { + const ssm = new AWS26.SSM(); + const paramOptions = __spreadValues({}, opts); + const paramResp = yield ssm.putParameter(paramOptions).promise(); + if (tags.length) { + yield ssm + .addTagsToResource({ + ResourceId: paramOptions.Name, + ResourceType: 'Parameter', + Tags: tags, + }) + .promise(); + } + return paramResp; + }), + /** + * Delete one or more parameter store entries + * @param {string[]} paramNames + */ + deleteSsmParameters: (paramNames) => + __async(exports2, null, function* () { + const ssm = new AWS26.SSM(); + const maxParams = 10; + let idx = 0; + while (idx < paramNames.length) { + const paramNamesSlice = paramNames.slice(idx, maxParams + idx); + idx += maxParams; + yield ssm + .deleteParameters({ + Names: paramNamesSlice, + }) + .promise(); + paramNamesSlice.forEach((name) => { + console.log(`ssm parameter ${name} deleted`); + }); + } + }), + /** + * Fetch a set of parameter store entries based on a name prefix, + * e.g. `/caccl-deploy/foo-app` + * @param {string} prefix + * @returns {object[]} + */ + getSsmParametersByPrefix: (prefix) => + __async(exports2, null, function* () { + const ssm = new AWS26.SSM(); + return getPaginatedResponse2( + ssm.getParametersByPath.bind(ssm), + { + Path: prefix, + Recursive: true, + }, + 'Parameters', + ); + }), + /** + * Fetch a single parameter store entry + * @param {string} paramName + * @returns {object} + */ + getSsmParameter: (paramName) => + __async(exports2, null, function* () { + const ssm = new AWS26.SSM(); + return ssm + .getParameter({ + Name: paramName, + }) + .promise(); + }), + /** + * Confirm that a CloudFormation stack exists + * @param {string} stackName + * @return {boolean} + */ + cfnStackExists: (stackName) => + __async(exports2, null, function* () { + try { + yield aws.getCfnStackExports(stackName); + return true; + } catch (err) { + if (!(err instanceof CfnStackNotFound2)) { + throw err; + } + } + return false; + }), + /** + * Return a list of Cloudformation stacks with names matching a prefix + * @param {string} stackPrefix + * @returns {string[]} + */ + getCfnStacks: (stackPrefix) => + __async(exports2, null, function* () { + const cfn = new AWS26.CloudFormation(); + const resp = yield getPaginatedResponse2( + cfn.describeStacks.bind(cfn), + {}, + 'Stacks', + ); + return resp.filter((s) => { + return s.StackName.startsWith(stackPrefix); + }); + }), + /** + * Returns an array of objects representing a Cloudformation stack's exports + * @param {string} stackName + * @returns {object[]} + */ + getCfnStackExports: (stackName) => + __async(exports2, null, function* () { + const cnf = new AWS26.CloudFormation(); + let exports3; + try { + const resp = yield cnf + .describeStacks({ + StackName: stackName, + }) + .promise(); + if (resp.Stacks === void 0 || !resp.Stacks.length) { + throw new CfnStackNotFound2(`Unable to find stack ${stackName}`); + } + exports3 = resp.Stacks[0].Outputs.reduce((obj, output) => { + if (output.ExportName === void 0) { + return __spreadValues({}, obj); + } + const outputKey = camelCase2( + output.ExportName.replace(`${stackName}-`, ''), + ); + return __spreadProps(__spreadValues({}, obj), { + [outputKey]: output.OutputValue, + }); + }, {}); + } catch (err) { + if (err.message.includes('does not exist')) { + throw new CfnStackNotFound2( + `Cloudformation stack ${stackName} does not exist`, + ); + } + throw err; + } + return exports3; + }), + /** + * Fetch data on available ACM certificates + * @returns {object[]} + */ + getAcmCertList: () => + __async(exports2, null, function* () { + const acm = new AWS26.ACM(); + return getPaginatedResponse2( + acm.listCertificates.bind(acm), + {}, + 'CertificateSummaryList', + ); + }), + /** + * + * @param {string} cluster + * @param {string} serviceName + * @param {string} taskDefName + * @param {string} execOptions.command - the command to be executed in + * the app container context + * @param {number} execOptions.timeout - number of seconds to allow the + * task to complete + * @param {array} execOptions.environment - an array of environment + * variable additions or overrides in the form + * { name: , value: } + * @returns {string} - the arn of the started task + */ + execTask: (execOptions) => + __async(exports2, null, function* () { + const ecs = new AWS26.ECS(); + const { + clusterName, + serviceName, + taskDefName, + command, + environment = [], + } = execOptions; + const service = yield aws.getService(clusterName, serviceName); + const { networkConfiguration } = service; + const execResp = yield ecs + .runTask({ + cluster: clusterName, + taskDefinition: taskDefName, + networkConfiguration, + launchType: 'FARGATE', + platformVersion: '1.3.0', + overrides: { + containerOverrides: [ + { + name: 'AppOnlyContainer', + command: ['/bin/sh', '-c', command], + environment, + }, + ], + }, + }) + .promise(); + return execResp.tasks[0].taskArn; + }), + /** + * Fetches the data for an ECS service + * @param {string} cluster + * @param {string} service + * @returns {object} + */ + getService: (cluster, service) => + __async(exports2, null, function* () { + const ecs = new AWS26.ECS(); + const resp = yield ecs + .describeServices({ + cluster, + services: [service], + }) + .promise(); + if (!resp.services) { + throw new Error(`service ${service} not found`); + } + return resp.services[0]; + }), + /** + * Fetches the data for an ECS task definition + * @param {string} taskDefName + * @returns {string} + */ + getTaskDefinition: (taskDefName) => + __async(exports2, null, function* () { + const ecs = new AWS26.ECS(); + const resp = yield ecs + .describeTaskDefinition({ + taskDefinition: taskDefName, + }) + .promise(); + if (resp.taskDefinition === void 0) { + throw new Error(`task def ${taskDefName} not found`); + } + return resp.taskDefinition; + }), + /** + * Updates a Fargate task definition, replacing the app container's + * ECR image URI value + * @param {string} taskDefName + * @param {string} imageArn + * @returns {string} - the full ARN (incl family:revision) of the newly + * registered task definition + */ + updateTaskDefAppImage: (taskDefName, imageArn, containerDefName) => + __async(exports2, null, function* () { + const ecs = new AWS26.ECS(); + const taskDefinition = yield aws.getTaskDefinition(taskDefName); + const tagResp = yield ecs + .listTagsForResource({ + resourceArn: taskDefinition.taskDefinitionArn, + }) + .promise(); + const containerIdx = taskDefinition.containerDefinitions.findIndex( + (cd) => { + return cd.name === containerDefName; + }, + ); + const newImageId = aws.ecrArnToImageId(imageArn); + const newTaskDef = JSON.parse(JSON.stringify(taskDefinition)); + newTaskDef.containerDefinitions[containerIdx].image = newImageId; + newTaskDef.tags = tagResp.tags; + const registerTaskDefinitionParams = [ + 'containerDefinitions', + 'cpu', + 'executionRoleArn', + 'family', + 'memory', + 'networkMode', + 'placementConstraints', + 'requiresCompatibilities', + 'taskRoleArn', + 'volumes', + ]; + Object.keys(newTaskDef).forEach((k) => { + if (!registerTaskDefinitionParams.includes(k)) { + delete newTaskDef[k]; + } + }); + const registerResp = yield ecs + .registerTaskDefinition(newTaskDef) + .promise(); + console.log('done'); + return registerResp.taskDefinition.taskDefinitionArn; + }), + /** + * Restart an app's ECS service + * @param {string} cluster + * @param {string} service + * @param {boolean} wait + */ + restartEcsService: (cluster, service, restartOpts) => + __async(exports2, null, function* () { + const { newTaskDefArn, wait } = restartOpts; + const ecs = new AWS26.ECS(); + console.log( + [ + 'Console link for monitoring: ', + `https://console.aws.amazon.com/ecs/home?region=${aws.getCurrentRegion()}`, + `#/clusters/${cluster}/`, + `services/${service}/tasks`, + ].join(''), + ); + const updateServiceParams = { + cluster, + service, + forceNewDeployment: true, + }; + if (newTaskDefArn) { + updateServiceParams.taskDefinition = newTaskDefArn; + } + yield ecs.updateService(updateServiceParams).promise(); + if (!wait) { + return; + } + let allDone = false; + ecs + .waitFor('servicesStable', { + cluster, + services: [service], + }) + .promise() + .then(() => { + allDone = true; + }); + let counter = 0; + while (!allDone) { + console.log('Waiting for deployment to stablize...'); + counter += 1; + yield sleep2(__pow(2, counter) * 1e3); + } + console.log('all done!'); + }), + sendSSHPublicKey: (opts) => + __async(exports2, null, function* () { + const { instanceAz, instanceId, sshKeyPath } = opts; + const ec2ic = new AWS26.EC2InstanceConnect(); + const resp = yield ec2ic + .sendSSHPublicKey({ + AvailabilityZone: instanceAz, + InstanceId: instanceId, + InstanceOSUser: aws.EC2_INSTANCE_CONNECT_USER, + SSHPublicKey: readFile2(sshKeyPath), + }) + .promise(); + return resp; + }), + }; + var getAssumedRoleClient = (ClientClass) => + __async(exports2, null, function* () { + const client = new ClientClass(); + if ( + assumedRoleArn === void 0 || + assumedRoleArn.includes(yield aws.getAccountId()) + ) { + return client; + } + if (assumedRoleCredentials === void 0) { + const sts = new AWS26.STS(); + const resp = yield sts + .assumeRole({ + RoleArn: assumedRoleArn, + RoleSessionName: 'caccl-deploy-assume-role-session', + }) + .promise(); + assumedRoleCredentials = resp.Credentials; + } + client.config.update({ + accessKeyId: assumedRoleCredentials.AccessKeyId, + secretAccessKey: assumedRoleCredentials.SecretAccessKey, + sessionToken: assumedRoleCredentials.SessionToken, + }); + return client; + }); + var getPaginatedResponse2 = (func, params, itemKey) => + __async(exports2, null, function* () { + const items = []; + function getItems(nextTokenArg) { + return __async(this, null, function* () { + const paramsCopy = __spreadValues({}, params); + if (nextTokenArg !== void 0) { + paramsCopy.NextToken = nextTokenArg; + } + const resp = yield func(paramsCopy).promise(); + if (itemKey in resp) { + items.push(...resp[itemKey]); + } + if (resp.NextToken !== void 0) { + yield getItems(resp.NextToken); + } + }); + } + yield getItems(); + return items; + }); + module2.exports = aws; + }, +}); + +// src/cli.ts +var import_chalk4 = __toESM(require('chalk')); +var import_figlet3 = __toESM(require('figlet')); + +// src/aws/classes/AssumedRole.ts +var import_aws_sdk2 = __toESM(require('aws-sdk')); + +// src/aws/helpers/getAccountId.ts +var import_aws_sdk = __toESM(require('aws-sdk')); + +// src/shared/errors/CacclDeployError.ts +var CacclDeployError = class extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + } +}; +var CacclDeployError_default = CacclDeployError; + +// src/shared/errors/AwsAccountNotFound.ts +var AwsAccountNotFound = class extends CacclDeployError_default {}; +var AwsAccountNotFound_default = AwsAccountNotFound; + +// src/aws/helpers/getAccountId.ts +var getAccountId = () => + __async(void 0, null, function* () { + const sts = new import_aws_sdk.default.STS(); + const identity = yield sts.getCallerIdentity({}).promise(); + const accountId = identity.Account; + if (!accountId) { + throw new AwsAccountNotFound_default( + 'Could not retrieve users account ID.', + ); + } + return accountId; + }); +var getAccountId_default = getAccountId; + +// src/aws/classes/AssumedRole.ts +var AssumedRole = class { + constructor() { + this.assumedRoleArn = void 0; + } + /** + * Set an IAM role for AWS clients to assume + * @param {string} roleArn + */ + setAssumedRoleArn(roleArn) { + this.assumedRoleArn = roleArn; + } + /** + * Returns an AWS service client that has been reconfigured with + * temporary credentials from assuming an IAM role + * @param {class} ClientClass + * @returns {object} + */ + getAssumedRoleClient(ClientClass) { + return __async(this, null, function* () { + const client = new ClientClass(); + if ( + this.assumedRoleArn === void 0 || + this.assumedRoleArn.includes(yield getAccountId_default()) + ) { + return client; + } + if (this.assumedRoleCredentials === void 0) { + const sts = new import_aws_sdk2.default.STS(); + const resp = yield sts + .assumeRole({ + RoleArn: this.assumedRoleArn, + RoleSessionName: 'caccl-deploy-assume-role-session', + }) + .promise(); + const credentials = resp.Credentials; + if (!credentials) { + throw new Error( + `Could not retrieve credentials for assumed role: ${this.assumedRoleArn}`, + ); + } + this.assumedRoleCredentials = credentials; + } + client.config.update({ + accessKeyId: this.assumedRoleCredentials.AccessKeyId, + secretAccessKey: this.assumedRoleCredentials.SecretAccessKey, + sessionToken: this.assumedRoleCredentials.SessionToken, + }); + return client; + }); + } +}; +var AssumedRole_default = AssumedRole; + +// src/aws/constants/EC2_INSTANCE_CONNECT_USER.ts +var EC2_INSTANCE_CONNECT_USER = 'ec2-user'; +var EC2_INSTANCE_CONNECT_USER_default = EC2_INSTANCE_CONNECT_USER; + +// src/aws/helpers/getCfnStackExports.ts +var import_aws_sdk3 = __toESM(require('aws-sdk')); +var import_camel_case = require('camel-case'); + +// src/shared/errors/CfnStackNotFound.ts +var CfnStackNotFound = class extends CacclDeployError_default {}; +var CfnStackNotFound_default = CfnStackNotFound; + +// src/aws/helpers/getCfnStackExports.ts +var getCfnStackExports = (stackName) => + __async(void 0, null, function* () { + const cnf = new import_aws_sdk3.default.CloudFormation(); + let exports2 = {}; + try { + const resp = yield cnf + .describeStacks({ + StackName: stackName, + }) + .promise(); + if ( + resp.Stacks === void 0 || + !resp.Stacks.length || + !resp.Stacks[0].Outputs + ) { + throw new CfnStackNotFound_default(`Unable to find stack ${stackName}`); + } + exports2 = resp.Stacks[0].Outputs.reduce((obj, output) => { + if (!output.ExportName || !output.OutputValue) { + return __spreadValues({}, obj); + } + const outputKey = (0, import_camel_case.camelCase)( + output.ExportName.replace(`${stackName}-`, ''), + ); + return __spreadProps(__spreadValues({}, obj), { + [outputKey]: output.OutputValue, + }); + }, {}); + } catch (err) { + if (err instanceof Error && err.message.includes('does not exist')) { + throw new CfnStackNotFound_default( + `Cloudformation stack ${stackName} does not exist`, + ); + } + throw err; + } + return exports2; + }); +var getCfnStackExports_default = getCfnStackExports; + +// src/aws/helpers/cfnStackExists.ts +var cfnStackExists = (stackName) => + __async(void 0, null, function* () { + try { + yield getCfnStackExports_default(stackName); + return true; + } catch (err) { + if (!(err instanceof CfnStackNotFound_default)) { + throw err; + } + } + return false; + }); +var cfnStackExists_default = cfnStackExists; + +// src/aws/helpers/createEcrArn.ts +var createEcrArn = (ecrImage) => { + return [ + 'arn:aws:ecr', + ecrImage.region, + ecrImage.account, + `repository/${ecrImage.repoName}`, + ecrImage.imageTag, + ].join(':'); +}; +var createEcrArn_default = createEcrArn; + +// src/aws/helpers/deleteSecrets.ts +var import_aws_sdk4 = __toESM(require('aws-sdk')); +var deleteSecrets = (secretArns) => + __async(void 0, null, function* () { + const sm = new import_aws_sdk4.default.SecretsManager(); + for (let i = 0; i < secretArns.length; i += 1) { + yield sm + .deleteSecret({ + SecretId: secretArns[i], + ForceDeleteWithoutRecovery: true, + }) + .promise(); + console.log(`secret ${secretArns[i]} deleted`); + } + }); +var deleteSecrets_default = deleteSecrets; + +// src/aws/helpers/deleteSsmParameters.ts +var import_aws_sdk5 = __toESM(require('aws-sdk')); +var deleteSsmParameters = (paramNames) => + __async(void 0, null, function* () { + const ssm = new import_aws_sdk5.default.SSM(); + const maxParams = 10; + let idx = 0; + while (idx < paramNames.length) { + const paramNamesSlice = paramNames.slice(idx, maxParams + idx); + idx += maxParams; + yield ssm + .deleteParameters({ + Names: paramNamesSlice, + }) + .promise(); + paramNamesSlice.forEach((name) => { + console.log(`ssm parameter ${name} deleted`); + }); + } + }); +var deleteSsmParameters_default = deleteSsmParameters; + +// src/aws/helpers/parseEcrArn.ts +var parseEcrArn = (arn) => { + const parts = arn.split(':'); + const [relativeId, imageTag] = parts.slice(-2); + const repoName = relativeId.replace('repository/', ''); + return { + service: parts[2], + region: parts[3], + account: parts[4], + repoName, + imageTag, + }; +}; +var parseEcrArn_default = parseEcrArn; + +// src/aws/helpers/ecrArnToImageId.ts +var ecrArnToImageId = (arn) => { + const parsedArn = parseEcrArn_default(arn); + const host = [ + parsedArn.account, + 'dkr.ecr', + parsedArn.region, + 'amazonaws.com', + ].join('.'); + return `${host}/${parsedArn.repoName}:${parsedArn.imageTag}`; +}; +var ecrArnToImageId_default = ecrArnToImageId; + +// src/aws/helpers/execTask.ts +var import_aws_sdk7 = __toESM(require('aws-sdk')); + +// src/aws/helpers/getService.ts +var import_aws_sdk6 = __toESM(require('aws-sdk')); +var getService = (cluster, service) => + __async(void 0, null, function* () { + const ecs = new import_aws_sdk6.default.ECS(); + const resp = yield ecs + .describeServices({ + cluster, + services: [service], + }) + .promise(); + if (!resp.services) { + throw new Error(`service ${service} not found`); + } + return resp.services[0]; + }); +var getService_default = getService; + +// src/aws/helpers/execTask.ts +var execTask = (execOptions) => + __async(void 0, null, function* () { + const ecs = new import_aws_sdk7.default.ECS(); + const { + clusterName, + serviceName, + taskDefName, + command, + environment = [], + } = execOptions; + const service = yield getService_default(clusterName, serviceName); + const { networkConfiguration } = service; + const execResp = yield ecs + .runTask({ + cluster: clusterName, + taskDefinition: taskDefName, + networkConfiguration, + launchType: 'FARGATE', + platformVersion: '1.3.0', + overrides: { + containerOverrides: [ + { + name: 'AppOnlyContainer', + command: ['/bin/sh', '-c', command], + environment, + }, + ], + }, + }) + .promise(); + if (!execResp.tasks) return void 0; + return execResp.tasks[0].taskArn; + }); +var execTask_default = execTask; + +// src/aws/helpers/getAcmCertList.ts +var import_aws_sdk8 = __toESM(require('aws-sdk')); + +// src/aws/helpers/getPaginatedResponse.ts +var getPaginatedResponse = (func, params, itemKey) => + __async(void 0, null, function* () { + const items = []; + function getItems(nextTokenArg) { + return __async(this, null, function* () { + const paramsCopy = __spreadValues({}, params); + if (nextTokenArg !== void 0) { + paramsCopy.NextToken = nextTokenArg; + } + const resp = yield func(paramsCopy).promise(); + if (itemKey in resp) { + items.push(...resp[itemKey]); + } + if (resp.NextToken !== void 0) { + yield getItems(resp.NextToken); + } + }); + } + yield getItems(); + return items; + }); +var getPaginatedResponse_default = getPaginatedResponse; + +// src/aws/helpers/getAcmCertList.ts +var getAcmCertList = () => + __async(void 0, null, function* () { + const acm = new import_aws_sdk8.default.ACM(); + return getPaginatedResponse_default( + acm.listCertificates.bind(acm), + {}, + 'CertificateSummaryList', + ); + }); +var getAcmCertList_default = getAcmCertList; + +// src/aws/helpers/getAppList.ts +var import_aws_sdk9 = __toESM(require('aws-sdk')); +var getAppList = (prefix) => + __async(void 0, null, function* () { + const ssm = new import_aws_sdk9.default.SSM(); + const searchParams = { + MaxResults: 50, + // lord i hope we never have this many apps + ParameterFilters: [ + { + Key: 'Name', + Option: 'Contains', + // making an assumption that all configurations will include this + Values: ['/infraStackName'], + }, + ], + }; + const paramEntries = yield getPaginatedResponse_default( + ssm.describeParameters.bind(ssm), + searchParams, + 'Parameters', + ); + return paramEntries.flatMap((param) => { + if (!param.Name || param.Name.startsWith(prefix)) return []; + return param.Name.split('/')[2]; + }); + }); +var getAppList_default = getAppList; + +// src/aws/helpers/getCfnStacks.ts +var import_aws_sdk10 = __toESM(require('aws-sdk')); +var getCfnStacks = (stackPrefix) => + __async(void 0, null, function* () { + const cfn = new import_aws_sdk10.default.CloudFormation(); + const resp = yield getPaginatedResponse_default( + cfn.describeStacks.bind(cfn), + {}, + 'Stacks', + ); + return resp.filter((s) => { + return s.StackName.startsWith(stackPrefix); + }); + }); +var getCfnStacks_default = getCfnStacks; + +// src/aws/helpers/getCurrentRegion.ts +var import_aws_sdk11 = __toESM(require('aws-sdk')); +var getCurrentRegion = () => { + const { region } = import_aws_sdk11.default.config; + if (!region) { + throw new Error('Could not get current AWS region.'); + } + return region; +}; +var getCurrentRegion_default = getCurrentRegion; + +// src/aws/helpers/getInfraStackList.ts +var import_aws_sdk12 = __toESM(require('aws-sdk')); +var getInfraStackList = () => + __async(void 0, null, function* () { + const cfn = new import_aws_sdk12.default.CloudFormation(); + const stacks = yield getPaginatedResponse_default( + cfn.describeStacks.bind(cfn), + {}, + 'Stacks', + ); + return stacks.flatMap((stack) => { + if (stack.Outputs) { + const outputKeys = stack.Outputs.map((output) => { + return output.OutputKey; + }); + if (outputKeys.indexOf('InfraStackName') >= 0) { + return stack.StackName; + } + } + return []; + }); + }); +var getInfraStackList_default = getInfraStackList; + +// src/aws/helpers/getRepoImageList.ts +var import_aws_sdk13 = __toESM(require('aws-sdk')); + +// src/shared/helpers/looksLikeSemver.ts +var LOOKS_LIKE_SEMVER_REGEX = new RegExp( + [ + '(?0|(?:[1-9]\\d*))', + '(?:\\.(?0|(?:[1-9]\\d*))', + '(?:\\.(?0|(?:[1-9]\\d*))))', + ].join(''), +); +var looksLikeSemver = (str) => { + return LOOKS_LIKE_SEMVER_REGEX.test(str); +}; +var looksLikeSemver_default = looksLikeSemver; + +// src/aws/helpers/getRepoImageList.ts +var getRepoImageList = (assumedRole, repo, all) => + __async(void 0, null, function* () { + const ecr = yield assumedRole.getAssumedRoleClient( + import_aws_sdk13.default.ECR, + ); + const images = yield getPaginatedResponse_default( + ecr.describeImages.bind(ecr), + { + repositoryName: repo, + maxResults: 1e3, + filter: { + tagStatus: 'TAGGED', + }, + }, + 'imageDetails', + ); + images.sort((a, b) => { + if (!a.imagePushedAt) return 1; + if (!b.imagePushedAt) return -1; + if (a.imagePushedAt < b.imagePushedAt) { + return 1; + } + if (a.imagePushedAt > b.imagePushedAt) { + return -1; + } + return 0; + }); + if (!all) { + return images.filter((i) => { + if (!i.imageTags) return false; + return i.imageTags.some((t) => { + return ( + looksLikeSemver_default(t) || + ['main', 'master', 'stage'].includes(t) + ); + }); + }); + } + return images; + }); +var getRepoImageList_default = getRepoImageList; + +// src/aws/helpers/getRepoList.ts +var import_aws_sdk14 = __toESM(require('aws-sdk')); +var getRepoList = (assumedRole) => + __async(void 0, null, function* () { + const ecr = yield assumedRole.getAssumedRoleClient( + import_aws_sdk14.default.ECR, + ); + const repos = yield getPaginatedResponse_default( + ecr.describeRepositories.bind(ecr), + {}, + 'repositories', + ); + const unflattenedRes = yield Promise.all( + repos.map((repo) => + __async(void 0, null, function* () { + const emptyArr = []; + if (!repo.repositoryArn) return emptyArr; + const tagResp = yield ecr + .listTagsForResource({ + resourceArn: repo.repositoryArn, + }) + .promise(); + if (!tagResp.tags) return emptyArr; + const isAnEdtechAppRepo = tagResp.tags.some((t) => { + return t.Key === 'product' && t.Value === 'edtech-apps'; + }); + if (isAnEdtechAppRepo && repo.repositoryName) { + return [repo.repositoryName]; + } + return emptyArr; + }), + ), + ); + return unflattenedRes.flat(); + }); +var getRepoList_default = getRepoList; + +// src/aws/helpers/getSsmParametersByPrefix.ts +var import_aws_sdk15 = __toESM(require('aws-sdk')); +var getSsmParametersByPrefix = (prefix) => + __async(void 0, null, function* () { + const ssm = new import_aws_sdk15.default.SSM(); + yield ssm.getParametersByPath().promise(); + return getPaginatedResponse_default( + ssm.getParametersByPath.bind(ssm), + { + Path: prefix, + Recursive: true, + }, + 'Parameters', + ); + }); +var getSsmParametersByPrefix_default = getSsmParametersByPrefix; + +// src/aws/helpers/getTaskDefinition.ts +var import_aws_sdk16 = __toESM(require('aws-sdk')); +var getTaskDefinition = (taskDefName) => + __async(void 0, null, function* () { + const ecs = new import_aws_sdk16.default.ECS(); + const resp = yield ecs + .describeTaskDefinition({ + taskDefinition: taskDefName, + }) + .promise(); + if (resp.taskDefinition === void 0) { + throw new Error(`task def ${taskDefName} not found`); + } + return resp.taskDefinition; + }); +var getTaskDefinition_default = getTaskDefinition; + +// src/aws/helpers/imageTagExists.ts +var imageTagExists = (assumedRole, repoName, tag) => + __async(void 0, null, function* () { + const imageList = yield getRepoImageList_default( + assumedRole, + repoName, + true, + ); + return imageList.some((i) => { + if (!i.imageTags) return false; + return i.imageTags.includes(tag); + }); + }); +var imageTagExists_default = imageTagExists; + +// src/aws/helpers/initProfile.ts +var import_aws_sdk17 = __toESM(require('aws-sdk')); +var import_shared_ini = require('aws-sdk/lib/shared-ini'); + +// src/shared/errors/AwsProfileNotFound.ts +var AwsProfileNotFound = class extends CacclDeployError_default {}; +var AwsProfileNotFound_default = AwsProfileNotFound; + +// src/aws/helpers/initProfile.ts +var initProfile = (profileName) => { + const awsCredentials = import_shared_ini.iniLoader.loadFrom({}); + if (awsCredentials[profileName] === void 0) { + throw new AwsProfileNotFound_default( + `Tried to init a non-existent profile: '${profileName}'`, + ); + } + const profileCreds = awsCredentials[profileName]; + import_aws_sdk17.default.config.update({ + credentials: new import_aws_sdk17.default.Credentials({ + accessKeyId: profileCreds.aws_access_key_id, + secretAccessKey: profileCreds.aws_secret_access_key, + }), + }); +}; +var initProfile_default = initProfile; + +// src/aws/helpers/isConfigured.ts +var import_aws_sdk18 = __toESM(require('aws-sdk')); +var isConfigured = () => { + try { + return [ + import_aws_sdk18.default, + import_aws_sdk18.default.config.credentials, + import_aws_sdk18.default.config.region, + ].every((thing) => { + return thing !== void 0 && thing !== null; + }); + } catch (err) { + return false; + } +}; +var isConfigured_default = isConfigured; + +// src/aws/helpers/isLatestTag.ts +var isLatestTag = (assumedRole, repoName, tag) => + __async(void 0, null, function* () { + const imageList = yield getRepoImageList_default(assumedRole, repoName); + return ( + !!imageList.length && + !!imageList[0].imageTags && + imageList[0].imageTags.includes(tag) + ); + }); +var isLatestTag_default = isLatestTag; + +// src/aws/helpers/putSecret.ts +var import_aws_sdk20 = __toESM(require('aws-sdk')); + +// src/aws/helpers/secretExists.ts +var import_aws_sdk19 = __toESM(require('aws-sdk')); +var secretExists = (secretName) => + __async(void 0, null, function* () { + const sm = new import_aws_sdk19.default.SecretsManager(); + const params = { + Filters: [ + { + Key: 'name', + Values: [secretName], + }, + ], + }; + const resp = yield sm.listSecrets(params).promise(); + return !!resp.SecretList && resp.SecretList.length > 0; + }); +var secretExists_default = secretExists; + +// src/shared/errors/ExistingSecretWontDelete.ts +var ExistingSecretWontDelete = class extends CacclDeployError_default {}; +var ExistingSecretWontDelete_default = ExistingSecretWontDelete; + +// src/shared/errors/SecretNotCreated.ts +var SecretNotCreated = class extends CacclDeployError_default {}; +var SecretNotCreated_default = SecretNotCreated; + +// src/shared/helpers/sleep.ts +var sleep = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; +var sleep_default = sleep; + +// src/aws/helpers/putSecret.ts +var putSecret = (secretOpts, tags, retries = 0) => + __async(void 0, null, function* () { + const sm = new import_aws_sdk20.default.SecretsManager(); + const { Name: SecretId, Description, SecretString } = secretOpts; + let secretResp; + try { + const exists = yield secretExists_default(SecretId); + if (exists) { + secretResp = yield sm + .updateSecret({ + SecretId, + Description, + SecretString, + }) + .promise(); + console.log(`secretsmanager entry ${SecretId} updated`); + if (tags.length) { + yield sm + .tagResource({ + SecretId, + Tags: tags, + }) + .promise(); + console.log(`secretsmanager entry ${SecretId} tagged`); + } + } else { + secretResp = yield sm + .createSecret({ + Name: SecretId, + Description, + SecretString, + Tags: tags, + }) + .promise(); + console.log(`secretsmanager entry ${SecretId} created`); + } + } catch (err) { + if (!(err instanceof Error)) throw err; + if (err.message.includes('already scheduled for deletion')) { + if (retries < 5) { + retries += 1; + yield sleep_default(__pow(2, retries) * 1e3); + return putSecret(secretOpts, tags, retries); + } + console.error('putSecret failed after 5 retries'); + throw new ExistingSecretWontDelete_default( + `Failed to overwrite existing secret ${SecretId}`, + ); + } + throw err; + } + if (!secretResp.ARN) + throw new SecretNotCreated_default(`Could not create secret ${SecretId}`); + return secretResp.ARN; + }); +var putSecret_default = putSecret; + +// src/aws/helpers/putSsmParameter.ts +var import_aws_sdk21 = __toESM(require('aws-sdk')); +var putSsmParameter = (_0, ..._1) => + __async(void 0, [_0, ..._1], function* (opts, tags = []) { + const ssm = new import_aws_sdk21.default.SSM(); + const paramOptions = __spreadValues({}, opts); + const paramResp = yield ssm.putParameter(paramOptions).promise(); + if (tags.length) { + yield ssm + .addTagsToResource({ + ResourceId: paramOptions.Name, + ResourceType: 'Parameter', + Tags: tags, + }) + .promise(); + } + return paramResp; + }); +var putSsmParameter_default = putSsmParameter; + +// src/aws/helpers/resolveSecret.ts +var import_aws_sdk22 = __toESM(require('aws-sdk')); + +// src/shared/errors/SecretNotFound.ts +var SecretNotFound = class extends CacclDeployError_default {}; +var SecretNotFound_default = SecretNotFound; + +// src/aws/helpers/resolveSecret.ts +var resolveSecret = (secretArn) => + __async(void 0, null, function* () { + const sm = new import_aws_sdk22.default.SecretsManager(); + const resp = yield sm + .getSecretValue({ + SecretId: secretArn, + }) + .promise(); + if (!resp.SecretString) + throw new SecretNotFound_default( + `Could not find value for secret: arn=${secretArn}`, + ); + return resp.SecretString; + }); +var resolveSecret_default = resolveSecret; + +// src/aws/helpers/restartEcsService.ts +var import_aws_sdk23 = __toESM(require('aws-sdk')); +var restartEcsService = (restartOpts) => + __async(void 0, null, function* () { + const { cluster, service, newTaskDefArn, wait } = restartOpts; + const ecs = new import_aws_sdk23.default.ECS(); + console.log( + [ + 'Console link for monitoring: ', + `https://console.aws.amazon.com/ecs/home?region=${getCurrentRegion_default()}`, + `#/clusters/${cluster}/`, + `services/${service}/tasks`, + ].join(''), + ); + const updateServiceParams = { + cluster, + service, + forceNewDeployment: true, + }; + if (newTaskDefArn) { + updateServiceParams.taskDefinition = newTaskDefArn; + } + yield ecs.updateService(updateServiceParams).promise(); + if (!wait) { + return; + } + let allDone = false; + yield ecs + .waitFor('servicesStable', { + cluster, + services: [service], + }) + .promise() + .then(() => { + allDone = true; + }); + let counter = 0; + while (!allDone) { + console.log('Waiting for deployment to stablize...'); + counter += 1; + yield sleep_default(__pow(2, counter) * 1e3); + } + console.log('all done!'); + }); +var restartEcsService_default = restartEcsService; + +// src/aws/helpers/sendSSHPublicKey.ts +var import_aws_sdk24 = __toESM(require('aws-sdk')); + +// src/shared/helpers/readFile.ts +var import_fs = __toESM(require('fs')); +var import_path = __toESM(require('path')); +var readFile = (filePath) => { + const resolvedPath = import_path.default.resolve(filePath); + return import_fs.default.readFileSync(resolvedPath, 'utf8'); +}; +var readFile_default = readFile; + +// src/aws/helpers/sendSSHPublicKey.ts +var sendSSHPublicKey = (opts) => + __async(void 0, null, function* () { + const { instanceAz, instanceId, sshKeyPath } = opts; + const ec2ic = new import_aws_sdk24.default.EC2InstanceConnect(); + const resp = yield ec2ic + .sendSSHPublicKey({ + AvailabilityZone: instanceAz, + InstanceId: instanceId, + InstanceOSUser: EC2_INSTANCE_CONNECT_USER_default, + SSHPublicKey: readFile_default(sshKeyPath), + }) + .promise(); + return resp; + }); +var sendSSHPublicKey_default = sendSSHPublicKey; + +// src/aws/helpers/updateTaskDefAppImage.ts +var import_aws_sdk25 = __toESM(require('aws-sdk')); +var updateTaskDefAppImage = (taskDefName, imageArn, containerDefName) => + __async(void 0, null, function* () { + var _a; + const ecs = new import_aws_sdk25.default.ECS(); + const taskDefinition = yield getTaskDefinition_default(taskDefName); + if (!taskDefinition.taskDefinitionArn) + throw new Error('Could not get task definition ARN'); + const tagResp = yield ecs + .listTagsForResource({ + resourceArn: taskDefinition.taskDefinitionArn, + }) + .promise(); + if (!taskDefinition.containerDefinitions) + throw new Error('Could not retrieve container definitions'); + const containerIdx = taskDefinition.containerDefinitions.findIndex((cd) => { + return cd.name === containerDefName; + }); + const newImageId = ecrArnToImageId_default(imageArn); + const newTaskDef = JSON.parse(JSON.stringify(taskDefinition)); + newTaskDef.containerDefinitions[containerIdx].image = newImageId; + newTaskDef.tags = tagResp.tags; + const registerTaskDefinitionParams = [ + 'containerDefinitions', + 'cpu', + 'executionRoleArn', + 'family', + 'memory', + 'networkMode', + 'placementConstraints', + 'requiresCompatibilities', + 'taskRoleArn', + 'volumes', + ]; + Object.keys(newTaskDef).forEach((k) => { + if (!registerTaskDefinitionParams.includes(k)) { + delete newTaskDef[k]; + } + }); + const registerResp = yield ecs.registerTaskDefinition(newTaskDef).promise(); + console.log('done'); + return (_a = registerResp.taskDefinition) == null + ? void 0 + : _a.taskDefinitionArn; + }); +var updateTaskDefAppImage_default = updateTaskDefAppImage; + +// src/aws/index.ts +process.env.AWS_SDK_LOAD_CONFIG = '1'; + +// src/commands/operations/appsOperation.ts +var import_table = require('table'); + +// src/deployConfig/index.ts +var import_flat2 = __toESM(require('flat')); +var import_object_hash = require('object-hash'); + +// types/CacclCacheOptions.ts +var import_zod = require('zod'); +var CacclCacheOptions = import_zod.z.object({ + engine: import_zod.z.string(), + numCacheNodes: import_zod.z.number().optional(), + cacheNodeType: import_zod.z.string().optional(), +}); +var CacclCacheOptions_default = CacclCacheOptions; + +// types/CacclDbEngine.ts +var import_zod2 = require('zod'); +var CacclDbEngine = import_zod2.z.enum(['docdb', 'mysql']); +var CacclDbEngine_default = CacclDbEngine; + +// types/CacclDbOptions.ts +var import_zod3 = require('zod'); +var CacclDbOptions = import_zod3.z.object({ + // currently either 'docdb' or 'mysql' + engine: CacclDbEngine_default, + // see the aws docs for supported types + instanceType: import_zod3.z.string().optional(), + // > 1 will get you multi-az + instanceCount: import_zod3.z.number().optional(), + // use a non-default engine version (shouldn't be necessary) + engineVersion: import_zod3.z.string().optional(), + // use a non-default parameter group family (also unnecessary) + parameterGroupFamily: import_zod3.z.string().optional(), + // only used by docdb, turns on extra profiling + profiler: import_zod3.z.boolean().optional(), + // only used by mysql, provisioning will create the named database + databaseName: import_zod3.z.string().optional(), + // removal policy controls what happens to the db if it's replaced or otherwise stops being managed by CloudFormation + removalPolicy: import_zod3.z.string().optional(), +}); +var CacclDbOptions_default = CacclDbOptions; + +// types/CacclDeployStackPropsData.ts +var import_zod8 = require('zod'); + +// types/DeployConfigData.ts +var import_zod7 = require('zod'); + +// types/CacclLoadBalancerExtraOptions.ts +var import_zod4 = require('zod'); +var CacclLoadBalancerExtraOptions = import_zod4.z.object({ + healthCheckPath: import_zod4.z.string().optional(), + targetDeregistrationDelay: import_zod4.z.number().optional(), +}); +var CacclLoadBalancerExtraOptions_default = CacclLoadBalancerExtraOptions; + +// types/CacclNotificationsProps.ts +var import_zod5 = require('zod'); +var CacclNotificationsProps = import_zod5.z.object({ + email: import_zod5.z + .union([import_zod5.z.string(), import_zod5.z.string().array()]) + .optional(), + slack: import_zod5.z.string().optional(), +}); +var CacclNotificationsProps_default = CacclNotificationsProps; + +// types/CacclScheduledTask.ts +var import_zod6 = require('zod'); +var CacclScheduledTask = import_zod6.z.object({ + description: import_zod6.z.string().optional(), + schedule: import_zod6.z.string(), + command: import_zod6.z.string(), +}); +var CacclScheduledTask_default = CacclScheduledTask; + +// types/DeployConfigData.ts +var DeployConfigData = import_zod7.z.object({ + // + appImage: import_zod7.z.string(), + proxyImage: import_zod7.z.string().optional(), + taskCpu: import_zod7.z.number().optional(), + taskMemory: import_zod7.z.number().optional(), + logRetentionDays: import_zod7.z.number().optional(), + gitRepoVolume: import_zod7.z + .object({}) + .catchall(import_zod7.z.string()) + .optional(), + // CloudFormation infrastructure stack name + infraStackName: import_zod7.z.string(), + // Container image ARN + notifications: CacclNotificationsProps_default.optional(), + certificateArn: import_zod7.z.string().optional(), + appEnvironment: import_zod7.z + .object({}) + .catchall(import_zod7.z.string()) + .optional(), + tags: import_zod7.z.object({}).catchall(import_zod7.z.string()).optional(), + scheduledTasks: import_zod7.z + .object({}) + .catchall(CacclScheduledTask_default) + .optional(), + taskCount: import_zod7.z.string(), + firewallSgId: import_zod7.z.string().optional(), + lbOptions: CacclLoadBalancerExtraOptions_default.optional(), + cacheOptions: CacclCacheOptions_default.optional(), + dbOptions: CacclDbOptions_default.optional(), + enableExecuteCommand: import_zod7.z + .union([import_zod7.z.string(), import_zod7.z.boolean()]) + .optional(), + // DEPRECATED: + docDb: import_zod7.z.any(), + docDbInstanceCount: import_zod7.z.number().optional(), + docDbInstanceType: import_zod7.z.string().optional(), + docDbProfiler: import_zod7.z.boolean().optional(), +}); +var DeployConfigData_default = DeployConfigData; + +// types/CacclDeployStackPropsData.ts +var CacclDeployStackPropsData = import_zod8.z.object({ + stackName: import_zod8.z.string(), + vpcId: import_zod8.z.string().optional(), + ecsClusterName: import_zod8.z.string().optional(), + albLogBucketName: import_zod8.z.string().optional(), + awsRegion: import_zod8.z.string().optional(), + awsAccountId: import_zod8.z.string().optional(), + cacclDeployVersion: import_zod8.z.string(), + deployConfigHash: import_zod8.z.string(), + deployConfig: DeployConfigData_default, +}); + +// src/deployConfig/helpers/create.ts +var create = (data) => { + return DeployConfigData_default.parse(data); +}; +var create_default = create; + +// src/deployConfig/helpers/fromFlattened.ts +var import_flat = __toESM(require('flat')); +var fromFlattened = (flattenedData) => { + const unflattened = import_flat.default.unflatten(flattenedData, { + delimiter: '/', + }); + return create_default(unflattened); +}; +var fromFlattened_default = fromFlattened; + +// src/deployConfig/helpers/wipeConfig.ts +var wipeConfig = (ssmPrefix, flattenedConfig) => + __async(void 0, null, function* () { + const paramsToDelete = Object.keys(flattenedConfig).map((k) => { + return `${ssmPrefix}/${k}`; + }); + const secretsToDelete = Object.values(flattenedConfig).reduce((arns, v) => { + if (v.toString().startsWith('arn:aws:secretsmanager')) { + arns.push(v); + } + return arns; + }, []); + yield deleteSsmParameters_default(paramsToDelete); + yield deleteSecrets_default(secretsToDelete); + }); +var wipeConfig_default = wipeConfig; + +// src/configPrompts/prompt.ts +var import_prompts = __toESM(require('prompts')); + +// src/shared/errors/UserCancel.ts +var UserCancel = class extends CacclDeployError_default {}; +var UserCancel_default = UserCancel; + +// src/configPrompts/prompt.ts +var prompt = (question, continueOnCancel) => + __async(void 0, null, function* () { + return (0, import_prompts.default)(question, { + onCancel: () => { + if (!continueOnCancel) { + throw new UserCancel_default(''); + } + }, + }); + }); +var prompt_default = prompt; + +// src/configPrompts/confirm.ts +var confirm = (message, defaultsToYes) => + __async(void 0, null, function* () { + const response = yield prompt_default({ + type: 'confirm', + name: 'yesorno', + initial: defaultsToYes, + message, + }); + return response.yesorno; + }); +var confirm_default = confirm; + +// src/configPrompts/confirmProductionOp.ts +var import_chalk = __toESM(require('chalk')); +var import_figlet = __toESM(require('figlet')); + +// src/conf.ts +var import_conf = __toESM(require('conf')); +var confOpts = { + schema: { + ssmRootPrefix: { + type: 'string', + }, + ecrAccessRoleArn: { + type: 'string', + }, + cfnStackPrefix: { + type: 'string', + }, + productionAccounts: { + type: 'array', + }, + }, +}; +if (process.env.CACCL_DEPLOY_CONF_DIR !== void 0) { + confOpts.cwd = process.env.CACCL_DEPLOY_CONF_DIR; +} +var conf = new import_conf.default(confOpts); +var configDefaults = { + ssmRootPrefix: '/caccl-deploy', + cfnStackPrefix: 'CacclDeploy-', + productionAccounts: [], +}; +var setConfigDefaults = () => { + Object.entries(configDefaults).forEach(([k, v]) => { + conf.set(k, v); + }); +}; + +// src/configPrompts/confirmProductionOp.ts +var confirmProductionOp = (yes) => + __async(void 0, null, function* () { + if (yes) { + return true; + } + const prodAccounts = conf.get('productionAccounts'); + if (prodAccounts === void 0 || !prodAccounts.length) { + return true; + } + const accountId = yield getAccountId_default(); + if (!prodAccounts.includes(accountId)) { + return true; + } + console.log( + import_chalk.default.redBright( + import_figlet.default.textSync('Production Account!'), + ), + ); + try { + const ok = yield confirm_default( + '\nPlease confirm you wish to proceed\n', + ); + return ok; + } catch (err) { + if (err instanceof UserCancel_default) { + return false; + } + throw err; + } + }); +var confirmProductionOp_default = confirmProductionOp; + +// src/shared/errors/NoPromptChoices.ts +var NoPromptChoices = class extends CacclDeployError_default {}; +var NoPromptChoices_default = NoPromptChoices; + +// src/configPrompts/promptAppImage.ts +var promptAppImage = (assumedRole) => + __async(void 0, null, function* () { + const inputType = yield prompt_default({ + type: 'select', + name: 'value', + message: 'How would you like to select your image?', + choices: [ + { + title: 'Select from a list of ECR repos', + value: 'select', + }, + { + title: 'Enter image id string', + value: 'string', + }, + ], + }); + let appImage; + switch (inputType.value) { + case 'string': { + const inputString = yield prompt_default({ + type: 'text', + name: 'value', + message: 'Enter the image id', + }); + appImage = inputString.value; + break; + } + case 'select': { + const repoList = yield getRepoList_default(assumedRole); + const repoChoices = repoList.flatMap((value) => { + if (!value) return []; + return { + title: value, + value, + }; + }); + if (!repoChoices.length) { + throw new NoPromptChoices_default('No ECR repositories'); + } + const repoChoice = yield prompt_default({ + type: 'select', + name: 'value', + message: 'Select the ECR repo', + choices: repoChoices, + }); + const images = yield getRepoImageList_default( + assumedRole, + repoChoice.value, + ); + const imageTagsChoices = images.reduce((choices, image) => { + const releaseTag = + image.imageTags && + image.imageTags.find((tag) => { + return looksLikeSemver_default(tag); + }); + if (!releaseTag) return choices; + if (!image.registryId) { + throw new Error('Could not get ECR image registry ID.'); + } + const appImageValue = createEcrArn_default({ + region: getCurrentRegion_default(), + account: image.registryId, + repoName: repoChoice.value, + imageTag: releaseTag, + }); + if (releaseTag) { + choices.push({ + title: releaseTag, + value: appImageValue, + }); + } + return choices; + }, []); + if (!imageTagsChoices.length) { + throw new NoPromptChoices_default( + 'No valid image tags to choose from', + ); + } + const imageTagChoice = yield prompt_default({ + type: 'select', + name: 'value', + message: 'Select a release tag', + choices: imageTagsChoices, + }); + appImage = imageTagChoice.value; + break; + } + default: + break; + } + return appImage; + }); +var promptAppImage_default = promptAppImage; + +// src/shared/helpers/validSSMParamName.ts +var validSSMParamName = (name) => { + return /^([a-z0-9:/_-]+)$/i.test(name); +}; +var validSSMParamName_default = validSSMParamName; + +// src/configPrompts/promptAppName.ts +var promptAppName = () => + __async(void 0, null, function* () { + const appName = yield prompt_default({ + type: 'text', + name: 'value', + message: 'Enter a name for your app', + validate: (v) => { + return !validSSMParamName_default(v) + ? 'app name can only contain alphanumeric and/or the characters ".-_"' + : true; + }, + }); + return appName.value; + }); +var promptAppName_default = promptAppName; + +// src/configPrompts/promptCertificateArn.ts +var promptCertificateArn = () => + __async(void 0, null, function* () { + const certList = yield getAcmCertList_default(); + const certChoices = certList.flatMap((cert) => { + if (!cert.DomainName || !cert.CertificateArn) return []; + return { + title: cert.DomainName, + value: cert.CertificateArn, + }; + }); + if (!certChoices.length) { + throw new NoPromptChoices_default('No ACM certificates to choose from'); + } + const certificateArn = yield prompt_default({ + type: 'select', + name: 'value', + message: 'Select the hostname associated with your ACM certificate', + choices: certChoices, + }); + return certificateArn.value; + }); +var promptCertificateArn_default = promptCertificateArn; + +// src/configPrompts/promptInfraStackName.ts +var promptInfraStackName = () => + __async(void 0, null, function* () { + const infraStacks = yield getInfraStackList_default(); + if (infraStacks.length === 1) { + return infraStacks[0]; + } + const infraStackChoices = infraStacks.map((value) => { + return { + title: value, + value, + }; + }); + if (!infraStackChoices.length) { + throw new NoPromptChoices_default('No infrastructure stacks'); + } + const infraStackName = yield prompt_default({ + type: 'select', + name: 'value', + message: 'Select a base infrastructure stack to deploy to', + choices: infraStackChoices, + }); + return infraStackName.value; + }); +var promptInfraStackName_default = promptInfraStackName; + +// src/configPrompts/promptKeyValuePairs.ts +var promptKeyValuePairs = (_0, _1, ..._2) => + __async(void 0, [_0, _1, ..._2], function* (label, example, current = {}) { + const pairs = __spreadValues({}, current); + const displayList = []; + Object.entries(pairs).forEach(([k, v]) => { + displayList.push(`${k}=${v}`); + }); + console.log(`Current ${label}(s): +${displayList.join('\n')}`); + const newEntry = yield prompt_default({ + type: 'text', + name: 'value', + message: `Enter a new ${label}, e.g. ${example}. Leave empty to continue.`, + validate: (v) => { + return v !== '' && v.split('=').length !== 2 + ? 'invalid entry format' + : true; + }, + }); + if (newEntry.value !== '') { + const [newKey, newValue] = newEntry.value.split('='); + pairs[newKey] = newValue; + return promptKeyValuePairs(label, example, pairs); + } + return pairs; + }); +var promptKeyValuePairs_default = promptKeyValuePairs; + +// src/shared/errors/AppNotFound.ts +var AppNotFound = class extends CacclDeployError_default {}; +var AppNotFound_default = AppNotFound; + +// src/shared/helpers/readJson.ts +var readJson = (filePath) => { + return JSON.parse(readFile_default(filePath)); +}; +var readJson_default = readJson; + +// src/deployConfig/index.ts +var DeployConfig; +((DeployConfig2) => { + DeployConfig2.fromFile = (file) => { + const configData = readJson_default(file); + delete configData.appName; + return create_default(configData); + }; + DeployConfig2.fromUrl = (url) => + __async(void 0, null, function* () { + const resp = yield fetch(url); + const configData = yield resp.json(); + return create_default(configData); + }); + DeployConfig2.fromSsmParams = (appPrefix, keepSecretArns) => + __async(void 0, null, function* () { + const ssmParams = yield getSsmParametersByPrefix_default(appPrefix); + if (!ssmParams.length) { + throw new AppNotFound_default( + `No configuration found using app prefix ${appPrefix}`, + ); + } + const flattened = {}; + for (let i = 0; i < ssmParams.length; i += 1) { + const param = ssmParams[i]; + if (!param.Name || !param.Value) continue; + const paramName = param.Name.split('/').slice(3).join('/'); + const value = + keepSecretArns || !param.Value.startsWith('arn:aws:secretsmanager') + ? param.Value + : yield resolveSecret_default(param.Value); + flattened[paramName] = value; + } + return fromFlattened_default(flattened); + }); + DeployConfig2.generate = (_0, ..._1) => + __async(void 0, [_0, ..._1], function* (assumedRole, baseConfig = {}) { + const newConfig = __spreadValues({}, baseConfig); + if (newConfig.infraStackName === void 0) { + newConfig.infraStackName = yield promptInfraStackName_default(); + } + if (newConfig.certificateArn === void 0) { + newConfig.certificateArn = yield promptCertificateArn_default(); + } + if (newConfig.appImage === void 0) { + newConfig.appImage = yield promptAppImage_default(assumedRole); + } + newConfig.tags = yield promptKeyValuePairs_default( + 'tag', + 'foo=bar', + newConfig.tags, + ); + newConfig.appEnvironment = yield promptKeyValuePairs_default( + 'env var', + 'FOOBAR=baz', + newConfig.appEnvironment, + ); + console.log('\nYour new config:\n'); + console.log(JSON.stringify(newConfig, null, 2)); + console.log('\n'); + return create_default(newConfig); + }); + DeployConfig2.flatten = (deployConfig) => { + return (0, import_flat2.default)(deployConfig, { + delimiter: '/', + safe: false, + }); + }; + DeployConfig2.toString = (deployConfig, pretty, flattened) => { + const output = flattened + ? (0, DeployConfig2.flatten)(deployConfig) + : deployConfig; + return JSON.stringify(output, null, pretty ? ' ' : ''); + }; + DeployConfig2.toHash = (deployConfig) => { + return (0, import_object_hash.sha1)(deployConfig); + }; + DeployConfig2.tagsForAws = (deployConfig) => { + if ( + deployConfig.tags === void 0 || + !Object.keys(deployConfig.tags).length + ) { + return []; + } + return Object.entries(deployConfig.tags).map(([Key, Value]) => { + return { Key, Value }; + }); + }; + DeployConfig2.syncToSsm = (deployConfig, appPrefix, params) => + __async(void 0, null, function* () { + const flattened = + params === void 0 ? (0, DeployConfig2.flatten)(deployConfig) : params; + const paramEntries = Object.entries(flattened); + const awsTags = (0, DeployConfig2.tagsForAws)(deployConfig); + for (let i = 0; i < paramEntries.length; i += 1) { + const [flattenedName, rawValue] = paramEntries[i]; + if (!rawValue || typeof rawValue === 'object') { + continue; + } + const paramName = `${appPrefix}/${flattenedName}`; + let paramValue = rawValue.toString(); + let isSecret = false; + if ( + flattenedName.startsWith('appEnvironment') && + typeof rawValue === 'string' && + !rawValue.startsWith('arn:aws:secretsmanager') + ) { + try { + paramValue = yield putSecret_default( + { + Name: paramName, + SecretString: rawValue, + Description: 'Created and managed by caccl-deploy.', + }, + awsTags, + ); + } catch (err) { + if (err instanceof ExistingSecretWontDelete_default) { + console.log(err.message); + console.log('Aborting import and cleaning up.'); + yield wipeConfig_default(appPrefix, flattened); + return; + } + } + isSecret = true; + } + const paramDescription = [ + 'Created and managed by caccl-deploy.', + isSecret ? 'ARN value references a secretsmanager entry' : '', + ].join(' '); + const paramOpts = { + Name: paramName, + Value: paramValue, + Type: 'String', + Overwrite: true, + Description: paramDescription, + }; + yield putSsmParameter_default(paramOpts, awsTags); + console.log(`ssm parameter ${paramName} created`); + } + }); + DeployConfig2.update = (opts) => + __async(void 0, null, function* () { + const { deployConfig, appPrefix, param, value } = opts; + const newDeployConfig = DeployConfigData_default.parse( + __spreadProps(__spreadValues({}, deployConfig), { + [param]: value, + }), + ); + yield (0, DeployConfig2.syncToSsm)(deployConfig, appPrefix, { + [param]: value, + }); + return newDeployConfig; + }); + DeployConfig2.deleteParam = (deployConfig, appPrefix, param) => + __async(void 0, null, function* () { + const value = (0, DeployConfig2.flatten)(deployConfig)[param]; + if (value === void 0) { + throw new Error(`${param} doesn't exist`); + } + if (value.startsWith('arn:aws:secretsmanager')) { + yield deleteSecrets_default([value]); + } + const paramPath = [appPrefix, param].join('/'); + yield deleteSsmParameters_default([paramPath]); + }); + DeployConfig2.wipeExisting = (ssmPrefix, ignoreMissing = true) => + __async(void 0, null, function* () { + let existingConfig; + try { + existingConfig = yield (0, DeployConfig2.fromSsmParams)( + ssmPrefix, + true, + ); + } catch (err) { + if (err instanceof AppNotFound_default) { + if (ignoreMissing) { + return; + } + throw new AppNotFound_default( + `No configuration found using prefix ${ssmPrefix}`, + ); + } else { + throw err; + } + } + const flattened = (0, DeployConfig2.flatten)(existingConfig); + yield wipeConfig_default(ssmPrefix, flattened); + }); +})(DeployConfig || (DeployConfig = {})); +var deployConfig_default = DeployConfig; + +// src/commands/helpers/bye.ts +var bye = (msg = 'bye!', exitCode = 0) => { + console.log(msg); + process.exit(exitCode); +}; +var bye_default = bye; + +// src/commands/helpers/exitWithError.ts +var exitWithError = (msg) => { + bye_default(msg, 1); +}; +var exitWithError_default = exitWithError; + +// src/commands/helpers/exitWithSuccess.ts +var exitWithSuccess = (msg) => { + bye_default(msg); +}; +var exitWithSuccess_default = exitWithSuccess; + +// src/commands/operations/appsOperation.ts +var appsOperation = (cmd) => + __async(void 0, null, function* () { + var _a; + const apps = yield getAppList_default(cmd.ssmRootPrefix); + if (!apps.length) { + exitWithError_default( + `No app configurations found using ssm root prefix ${cmd.ssmRootPrefix}`, + ); + } + const appData = {}; + const tableColumns = ['App']; + for (let i = 0; i < apps.length; i++) { + const app = apps[i]; + appData[app] = []; + } + if (cmd.fullStatus) { + tableColumns.push( + 'Infra Stack', + 'Stack Status', + 'Config Drift', + 'caccl-deploy Version', + ); + const cfnStacks = yield getCfnStacks_default(cmd.cfnStackPrefix); + for (let i = 0; i < apps.length; i++) { + const app = apps[i]; + const cfnStackName = cmd.getCfnStackName(app); + const appPrefix = cmd.getAppPrefix(app); + const deployConfig = yield deployConfig_default.fromSsmParams( + appPrefix, + ); + appData[app].push(deployConfig.infraStackName); + const cfnStack = cfnStacks.find((s) => { + return ( + s.StackName === cfnStackName && s.StackStatus !== 'DELETE_COMPLETE' + ); + }); + if (!cfnStack || !cfnStack.Outputs) { + appData[app].push('', '', ''); + continue; + } + let configDrift = '?'; + const cfnStackDeployConfigHashOutput = cfnStack.Outputs.find((o) => { + return o.OutputKey && o.OutputKey.startsWith('DeployConfigHash'); + }); + if (cfnStackDeployConfigHashOutput) { + const deployConfigHash = deployConfig_default.toHash(deployConfig); + const cfnOutputValue = cfnStackDeployConfigHashOutput.OutputValue; + configDrift = cfnOutputValue !== deployConfigHash ? 'yes' : 'no'; + } + appData[app].push(cfnStack.StackStatus, configDrift); + const cfnStackCacclDeployVersion = cfnStack.Outputs.find((o) => { + return o.OutputKey && o.OutputKey.startsWith('CacclDeployVersion'); + }); + appData[app].push( + (_a = + cfnStackCacclDeployVersion == null + ? void 0 + : cfnStackCacclDeployVersion.OutputValue) != null + ? _a + : 'N/A', + ); + } + } + const tableData = Object.keys(appData).map((app) => { + return [app, ...appData[app]]; + }); + exitWithSuccess_default( + (0, import_table.table)([tableColumns, ...tableData]), + ); + }); +var appsOperation_default = appsOperation; + +// src/commands/addAppsCommand.ts +var addAppsCommand = (cli) => { + return cli + .command('apps') + .option( + '--full-status', + 'show the full status of each app including CFN stack and config state', + ) + .description('list available app configurations') + .action(appsOperation_default); +}; +var addAppsCommand_default = addAppsCommand; + +// src/commands/addConnectCommand.ts +var import_untildify = __toESM(require('untildify')); + +// src/commands/operations/connectOperation.ts +var import_yn = __toESM(require('yn')); +var connectOperation = (cmd) => + __async(void 0, null, function* () { + const opts = cmd.opts(); + const assumedRole = cmd.getAssumedRole(); + if (!opts.list && !opts.service) { + exitWithError_default('One of `--list` or `--service` is required'); + } + const deployConfig = yield cmd.getDeployConfig(assumedRole); + const services = /* @__PURE__ */ new Set(); + ['dbOptions', 'cacheOptions'].forEach((optsKey) => { + const serviceOptions = deployConfig[optsKey]; + if (serviceOptions) { + services.add(serviceOptions.engine); + } + }); + if ((0, import_yn.default)(deployConfig.docDb)) { + exitWithError_default( + [ + 'Deployment configuration is out-of-date', + 'Replace `docDb*` with `dbOptions: {...}`', + ].join('\n'), + ); + } + if (opts.list) { + exitWithSuccess_default( + ['Valid `--service=` options:', ...services].join('\n '), + ); + } + if (!services.has(opts.service)) { + exitWithError_default(`'${opts.service}' is not a valid option`); + } + const cfnStackName = cmd.getCfnStackName(); + const cfnStackExports = yield getCfnStackExports_default(cfnStackName); + const { bastionHostAz, bastionHostId, bastionHostIp, dbPasswordSecretArn } = + cfnStackExports; + try { + yield sendSSHPublicKey_default({ + instanceAz: bastionHostAz, + instanceId: bastionHostId, + sshKeyPath: opts.publicKey, + }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : `Could not send SSH public key: ${err}`; + exitWithError_default(message); + } + let endpoint; + let localPort; + let clientCommand; + if (['mysql', 'docdb'].includes(opts.service)) { + endpoint = cfnStackExports.dbClusterEndpoint; + const password = yield resolveSecret_default(dbPasswordSecretArn); + if (opts.service === 'mysql') { + localPort = opts.localPort || '3306'; + clientCommand = `mysql -uroot -p${password} --port ${localPort} -h 127.0.0.1`; + } else { + localPort = opts.localPort || '27017'; + const tlsOpts = + '--ssl --sslAllowInvalidHostnames --sslAllowInvalidCertificates'; + clientCommand = `mongo ${tlsOpts} --username root --password ${password} --port ${localPort}`; + } + } else if (opts.service === 'redis') { + endpoint = cfnStackExports.cacheEndpoint; + localPort = opts.localPort || '6379'; + clientCommand = `redis-cli -p ${localPort}`; + } else { + exitWithError_default(`not sure what to do with ${opts.service}`); + } + const tunnelCommand = [ + 'ssh -f -L', + `${opts.localPort || localPort}:${endpoint}`, + '-o StrictHostKeyChecking=no', + `${EC2_INSTANCE_CONNECT_USER_default}@${bastionHostIp}`, + `sleep ${opts.sleep}`, + ].join(' '); + if (opts.quiet) { + exitWithSuccess_default(tunnelCommand); + } + exitWithSuccess_default( + [ + `Your public key, ${opts.publicKey}, has temporarily been placed on the bastion instance`, + 'You have ~60s to establish the ssh tunnel', + '', + `# tunnel command: +${tunnelCommand}`, + `# ${opts.service} client command: +${clientCommand}`, + ].join('\n'), + ); + }); +var connectOperation_default = connectOperation; + +// src/commands/addConnectCommand.ts +var addConnectCommand = (cli) => { + return cli + .command('connect') + .description("connect to an app's peripheral services (db, redis, etc)") + .appOption() + .option('-l, --list', 'list the things to connect to') + .option( + '-s, --service ', + 'service to connect to; use `--list` to see what is available', + ) + .option( + '-k, --public-key ', + 'path to the ssh public key file to use', + (0, import_untildify.default)('~/.ssh/id_rsa.pub'), + ) + .option( + '--local-port ', + 'attach tunnel to a non-default local port', + ) + .option('-q, --quiet', 'output only the ssh tunnel command') + .option( + '-S, --sleep ', + 'keep the tunnel alive for this long without activity', + '60', + ) + .action(connectOperation_default); +}; +var addConnectCommand_default = addConnectCommand; + +// src/commands/operations/deleteOperation.ts +var deleteOperation = (cmd) => + __async(void 0, null, function* () { + const cfnStackName = cmd.getCfnStackName(); + if (yield cfnStackExists_default(cfnStackName)) { + exitWithError_default( + [ + `You must first run "caccl-deploy stack -a ${cmd.app} destroy" to delete`, + `the deployed ${cfnStackName} CloudFormation stack before deleting it's config.`, + ].join('\n'), + ); + } + try { + console.log( + `This will delete all deployment configuation for ${cmd.app}`, + ); + if (!(cmd.yes || (yield confirm_default('Are you sure?')))) { + exitWithSuccess_default(); + } + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + yield deployConfig_default.wipeExisting(cmd.getAppPrefix(), false); + exitWithSuccess_default(`${cmd.app} configuration deleted`); + } catch (err) { + if (err instanceof AppNotFound_default) { + exitWithError_default(`${cmd.app} app configuration not found!`); + } + } + }); +var deleteOperation_default = deleteOperation; + +// src/commands/addDeleteCommand.ts +var addDeleteCommand = (cli) => { + return cli + .command('delete') + .description('delete an app configuration') + .appOption() + .action(deleteOperation_default); +}; +var addDeleteCommand_default = addDeleteCommand; + +// src/commands/operations/execOperation.ts +var execOperation = (cmd) => + __async(void 0, null, function* () { + const cfnStackName = cmd.getCfnStackName(); + const { appOnlyTaskDefName, clusterName, serviceName } = + yield getCfnStackExports_default(cfnStackName); + if (!cmd.yes && !(yield cmd.stackVersionDiffCheck())) { + exitWithSuccess_default(); + } + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + console.log( + `Running command '${ + cmd.opts().command + }' on service ${serviceName} using task def ${appOnlyTaskDefName}`, + ); + const taskArn = yield execTask_default({ + clusterName, + serviceName, + taskDefName: appOnlyTaskDefName, + command: cmd.opts().command, + environment: cmd.opts().env, + }); + exitWithSuccess_default(`Task ${taskArn} started`); + }); +var execOperation_default = execOperation; + +// src/commands/addExecCommand.ts +var addExecCommand = (cli) => { + return cli + .command('exec') + .description('execute a one-off task using the app image') + .appOption() + .requiredOption('-c, --command ', 'the app task command to run') + .option( + '-e, --env ', + 'add or override container environment variables', + (e, collected) => { + const [k, v] = e.split('='); + return collected.concat([ + { + name: k, + value: v, + }, + ]); + }, + [], + ) + .action(execOperation_default); +}; +var addExecCommand_default = addExecCommand; + +// src/commands/operations/imagesOperation.ts +var import_moment = __toESM(require('moment')); +var import_table2 = require('table'); +var imagesOperation = (cmd) => + __async(void 0, null, function* () { + const assumedRole = cmd.getAssumedRole(); + const opts = cmd.opts(); + if (cmd.ecrAccessRoleArn !== void 0) { + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); + } + const images = yield getRepoImageList_default( + assumedRole, + opts.repo, + opts.all, + ); + const region = getCurrentRegion_default(); + const includeThisTag = (tag) => { + return ( + opts.all || + looksLikeSemver_default(tag) || + ['master', 'stage'].includes(tag) + ); + }; + const data = images + .filter((image) => { + return !!image.imageTags && !!image.registryId; + }) + .map((image) => { + const tags = image.imageTags; + const account = image.registryId; + const imageTags = tags.filter(includeThisTag).join('\n'); + const imageArns = tags + .reduce((collect, t) => { + if (includeThisTag(t)) { + collect.push( + createEcrArn_default({ + repoName: opts.repo, + imageTag: t, + account, + region, + }), + ); + } + return collect; + }, []) + .join('\n'); + return [ + (0, import_moment.default)(image.imagePushedAt).format(), + imageTags, + imageArns, + ]; + }); + if (data.length) { + const tableOutput = (0, import_table2.table)([ + ['Pushed On', 'Tags', 'ARNs'], + ...data, + ]); + exitWithSuccess_default(tableOutput); + } + exitWithError_default('No images found'); + }); +var imagesOperation_default = imagesOperation; + +// src/commands/addImagesCommand.ts +var addImagesCommand = (cli) => { + return cli + .command('images') + .description('list the most recent available ECR images for an app') + .requiredOption( + '-r --repo ', + 'the name of the ECR repo; use `caccl-deploy app repos` for available repos', + ) + .option( + '-A --all', + 'show all images; default is to show only semver-tagged releases', + ) + .action(imagesOperation_default); +}; +var addImagesCommand_default = addImagesCommand; + +// src/commands/operations/newOperation.ts +var import_chalk2 = __toESM(require('chalk')); +var import_figlet2 = __toESM(require('figlet')); +var newOperation = (cmd) => + __async(void 0, null, function* () { + const opts = cmd.opts(); + const assumedRole = cmd.getAssumedRole(); + if (cmd.ecrAccessRoleArn !== void 0) { + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); + } + const existingApps = yield getAppList_default(cmd.ssmRootPrefix); + let appName; + try { + appName = cmd.app || (yield promptAppName_default()); + } catch (err) { + if (err instanceof UserCancel_default) { + exitWithSuccess_default(); + } + throw err; + } + const appPrefix = cmd.getAppPrefix(appName); + if (existingApps.includes(appName)) { + const cfnStackName = cmd.getCfnStackName(appName); + if (yield cfnStackExists_default(cfnStackName)) { + exitWithError_default('A deployed app with that name already exists'); + } else { + console.log(`Configuration for ${cmd.app} already exists`); + } + if (cmd.yes || (yield confirm_default('Overwrite?'))) { + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + yield deployConfig_default.wipeExisting(appPrefix); + } else { + exitWithSuccess_default(); + } + } + let importedConfig; + if (opts.import !== void 0) { + importedConfig = /^http(s):\//.test(opts.import) + ? yield deployConfig_default.fromUrl(opts.import) + : deployConfig_default.fromFile(opts.import); + } + let deployConfig; + try { + deployConfig = yield deployConfig_default.generate( + assumedRole, + importedConfig, + ); + } catch (err) { + if (err instanceof UserCancel_default) { + exitWithSuccess_default(); + } else if (err instanceof NoPromptChoices_default) { + exitWithError_default( + [ + 'Something went wrong trying to generate your config: ', + err.message, + ].join('\n'), + ); + } + throw err; + } + yield deployConfig_default.syncToSsm(deployConfig, appPrefix); + exitWithSuccess_default( + [ + import_chalk2.default.yellowBright( + import_figlet2.default.textSync(`${appName}!`), + ), + '', + 'Your new app deployment configuration is created!', + 'Next steps:', + ` * modify or add settings with 'caccl-deploy update -a ${appName} [...]'`, + ` * deploy the app stack with 'caccl-deploy stack -a ${appName} deploy'`, + '', + ].join('\n'), + ); + }); +var newOperation_default = newOperation; + +// src/commands/addNewCommand.ts +var addNewCommand = (cli) => { + return cli + .command('new') + .description('create a new app deploy config via import and/or prompts') + .appOption(true) + .option( + '-i --import ', + 'import new deploy config from a json file or URL', + ) + .description('create a new app configuration') + .action(newOperation_default); +}; +var addNewCommand_default = addNewCommand; + +// src/commands/operations/releaseOperation.ts +var releaseOperation = (cmd) => + __async(void 0, null, function* () { + const assumedRole = cmd.getAssumedRole(); + if (cmd.ecrAccessRoleArn !== void 0) { + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); + } + const deployConfig = yield cmd.getDeployConfig(assumedRole); + const cfnStackName = cmd.getCfnStackName(); + let cfnExports; + try { + cfnExports = yield getCfnStackExports_default(cfnStackName); + ['taskDefName', 'clusterName', 'serviceName'].forEach((exportValue) => { + if (cfnExports[exportValue] === void 0) { + throw new Error(`Incomplete app stack: missing ${exportValue}`); + } + }); + } catch (err) { + if ( + err instanceof Error && + (err instanceof CfnStackNotFound_default || + err.message.includes('Incomplete')) + ) { + exitWithError_default(err.message); + } + throw err; + } + const repoArn = parseEcrArn_default(deployConfig.appImage); + if (repoArn.imageTag === cmd.imageTag && !cmd.yes) { + const confirmMsg = `${cmd.app} is already using image tag ${cmd.imageTag}`; + (yield confirm_default(`${confirmMsg}. Proceed?`)) || + exitWithSuccess_default(); + } + console.log(`Checking that an image exists with the tag ${cmd.imageTag}`); + const imageTagExists2 = yield imageTagExists_default( + assumedRole, + repoArn.repoName, + cmd.imageTag, + ); + if (!imageTagExists2) { + exitWithError_default( + `No image with tag ${cmd.imageTag} in ${repoArn.repoName}`, + ); + } + console.log(`Checking ${cmd.imageTag} is the latest tag`); + const isLatestTag2 = yield isLatestTag_default( + assumedRole, + repoArn.repoName, + cmd.imageTag, + ); + if (!isLatestTag2 && !cmd.yes) { + console.log(`${cmd.imageTag} is not the most recent release`); + (yield confirm_default('Proceed?')) || exitWithSuccess_default(); + } + const newAppImage = createEcrArn_default( + __spreadProps(__spreadValues({}, repoArn), { + imageTag: cmd.imageTag, + }), + ); + const { taskDefName, appOnlyTaskDefName, clusterName, serviceName } = + cfnExports; + if (!cmd.yes && !(yield cmd.stackVersionDiffCheck())) { + exitWithSuccess_default(); + } + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + console.log(`Updating ${cmd.app} task definitions to use ${newAppImage}`); + const newTaskDefArn = yield updateTaskDefAppImage_default( + taskDefName, + newAppImage, + 'AppContainer', + ); + yield updateTaskDefAppImage_default( + appOnlyTaskDefName, + newAppImage, + 'AppOnlyContainer', + ); + console.log('Updating stored deployment configuration'); + yield deployConfig_default.update({ + deployConfig, + appPrefix: cmd.getAppPrefix(), + param: 'appImage', + value: newAppImage, + }); + if (cmd.deploy) { + console.log(`Restarting the ${serviceName} service...`); + yield restartEcsService_default({ + cluster: clusterName, + service: serviceName, + newTaskDefArn, + wait: true, + }); + exitWithSuccess_default('done.'); + } + exitWithSuccess_default( + [ + 'Redployment skipped', + 'WARNING: service is out-of-sync with stored deployment configuration', + ].join('\n'), + ); + }); +var releaseOperation_default = releaseOperation; + +// src/commands/addReleaseCommand.ts +var addReleaseCommand = (cli) => { + return cli + .command('release') + .description('release a new version of an app') + .appOption() + .requiredOption( + '-i --image-tag ', + 'the docker image version tag to release', + ) + .option( + '--no-deploy', + "Update the Fargate Task Definition but don't restart the service", + ) + .action(releaseOperation_default); +}; +var addReleaseCommand_default = addReleaseCommand; + +// src/commands/operations/reposOperation.ts +var import_table3 = require('table'); +var reposOperation = (cmd) => + __async(void 0, null, function* () { + const assumedRole = cmd.getAssumedRole(); + if (cmd.ecrAccessRoleArn !== void 0) { + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); + } + const repos = yield getRepoList_default(assumedRole); + const data = repos.map((r) => { + return [r]; + }); + if (data.length) { + const tableOutput = (0, import_table3.table)([ + ['Respository Name'], + ...data, + ]); + exitWithSuccess_default(tableOutput); + } + exitWithError_default('No ECR repositories found'); + }); +var reposOperation_default = reposOperation; + +// src/commands/addReposCommand.ts +var addReposCommand = (cli) => { + return cli + .command('repos') + .description('list the available ECR repositories') + .action(reposOperation_default); +}; +var addReposCommand_default = addReposCommand; + +// src/commands/operations/restartOperation.ts +var restartOperation = (cmd) => + __async(void 0, null, function* () { + const cfnStackName = cmd.getCfnStackName(); + let cfnExports; + try { + cfnExports = yield getCfnStackExports_default(cfnStackName); + } catch (err) { + if (err instanceof CfnStackNotFound_default) { + exitWithError_default(err.message); + } + throw err; + } + const { clusterName, serviceName } = cfnExports; + console.log(`Restarting service ${serviceName} on cluster ${clusterName}`); + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + yield restartEcsService_default({ + cluster: clusterName, + service: serviceName, + wait: true, + }); + exitWithSuccess_default('done'); + }); +var restartOperation_default = restartOperation; + +// src/commands/addRestartCommand.ts +var addRestartCommand = (cli) => { + return cli + .command('restart') + .description('no changes; just force a restart') + .appOption() + .action(restartOperation_default); +}; +var addRestartCommand_default = addRestartCommand; + +// src/commands/operations/scheduleOperation.ts +var import_table4 = require('table'); +var scheduleOperation = (cmd) => + __async(void 0, null, function* () { + const opts = cmd.opts(); + const assumedRole = cmd.getAssumedRole(); + const deployConfig = yield cmd.getDeployConfig(assumedRole); + const existingTasks = deployConfig.scheduledTasks || {}; + const existingTaskIds = Object.keys(existingTasks); + if (opts.list) { + if (existingTaskIds.length) { + const tableRows = existingTaskIds.map((id) => { + const taskSettings = existingTasks[id]; + const { command, schedule, description } = taskSettings; + return [id, schedule, command, description]; + }); + const tableOutput = (0, import_table4.table)([ + ['ID', 'Schedule', 'Command', 'Description'], + ...tableRows, + ]); + exitWithSuccess_default(tableOutput); + } + exitWithSuccess_default('No scheduled tasks configured'); + } else if (opts.delete) { + if (!existingTaskIds.includes(cmd.delete)) { + exitWithError_default(`No scheduled task with id ${cmd.delete}`); + } + const existingTask = existingTasks[cmd.delete]; + if ( + !( + cmd.yes || + (yield confirm_default(`Delete scheduled task ${opts.delete}?`)) + ) + ) { + exitWithSuccess_default(); + } + const existingTaskParams = Object.keys(existingTask); + for (let i = 0; i < existingTaskParams.length; i++) { + yield deployConfig_default.deleteParam( + deployConfig, + cmd.getAppPrefix(), + `scheduledTasks/${opts.delete}/${existingTaskParams[i]}`, + ); + } + exitWithSuccess_default(`Scheduled task ${opts.delete} deleted`); + } else if (!(opts.taskSchedule && opts.taskCommand)) { + exitWithError_default('Invalid options. See `--help` output'); + } + const taskId = opts.taskId || Math.random().toString(36).substring(2, 16); + const taskDescription = opts.taskDescription || ''; + const { taskCommand, taskSchedule } = opts; + if (!validSSMParamName_default(taskId)) { + exitWithError_default( + `Invalid ${taskId} value; '/^([a-z0-9:/_-]+)$/i' allowed only`, + ); + } + if ( + existingTaskIds.some((t) => { + return t === taskId; + }) + ) { + exitWithError_default( + `A schedule task with id ${taskId} already exists for ${opts.app}`, + ); + } + const params = { + [`scheduledTasks/${taskId}/description`]: taskDescription, + [`scheduledTasks/${taskId}/schedule`]: taskSchedule, + [`scheduledTasks/${taskId}/command`]: taskCommand, + }; + yield deployConfig_default.syncToSsm( + deployConfig, + cmd.getAppPrefix(), + params, + ); + exitWithSuccess_default('task scheduled'); + }); +var scheduleOperation_default = scheduleOperation; + +// src/commands/addScheduleCommand.ts +var addScheduleCommand = (cli) => { + return cli + .command('schedule') + .description( + 'create a scheduled task that executes the app image with a custom command', + ) + .appOption() + .option('-l, --list', 'list the existing scheduled tasks') + .option( + '-t, --task-id ', + 'give the task a string id; by default one will be generated', + ) + .option( + '-d, --task-description ', + 'description of what the task does', + ) + .option('-D, --delete ', 'delete a scheduled task') + .option( + '-s, --task-schedule ', + 'a cron expression, e.g. "0 4 * * *"', + ) + .option('-c, --task-command ', 'the app task command to run') + .action(scheduleOperation_default); +}; +var addScheduleCommand_default = addScheduleCommand; + +// src/commands/operations/showOperation.ts +var showOperation = (cmd) => + __async(void 0, null, function* () { + if (cmd.sha) { + exitWithSuccess_default((yield cmd.getDeployConfig()).toHash()); + } + exitWithSuccess_default( + (yield cmd.getDeployConfig(cmd.keepSecretArns)).toString(true, cmd.flat), + ); + }); +var showOperation_default = showOperation; + +// src/commands/addShowCommand.ts +var addShowCommand = (cli) => { + return cli + .command('show') + .description("display an app's current configuration") + .appOption() + .option('-f --flat', 'display the flattened, key: value form of the config') + .option('-s --sha', 'output a sha1 hash of the current configuration') + .option( + '--keep-secret-arns', + 'show app environment secret value ARNs instead of dereferencing', + ) + .action(showOperation_default); +}; +var addShowCommand_default = addShowCommand; + +// src/commands/operations/stackOperation.ts +var import_child_process2 = require('child_process'); +var import_tempy = __toESM(require('tempy')); + +// src/shared/helpers/generateVersion.ts +var import_child_process = require('child_process'); + +// package.json +var package_default = { + name: 'caccl-deploy', + version: '0.15.0', + description: 'A cli tool for managing ECS/Fargate app deployments', + files: ['dist', 'cdk.json'], + main: './dist/index.js', + module: './dist/index.mjs', + exports: { + require: './dist/index.js', + import: './dist/index.mjs', + }, + types: './dist/index.d.ts', + bin: { + 'caccl-deploy': 'dist/cli.js', + }, + scripts: { + test: 'jest', + prettier: 'npx prettier --write --ignore-path .gitignore "**/*.{ts,js}"', + prepare: 'husky install', + 'build-lib': 'tsup src/index.ts --format cjs,esm --dts-resolve', + 'build-cdk': + 'tsup cdk/cdk.ts && rsync -r cdk/assets dist/ && rsync cdk/.npmignore dist/ && rsync cdk/cdk.json dist/', + 'build-cli': 'tsup src/cli.ts', + build: 'npm run build-lib && npm run build-cdk && npm run build-cli', + }, + keywords: ['DCE', 'CLI', 'AWS', 'ECS'], + 'lint-staged': { + '**/*.{ts,js}': [ + 'npx prettier --write --ignore-path .gitignore', + 'npx eslint --fix', + ], + }, + author: 'Jay Luker', + license: 'ISC', + dependencies: { + 'aws-cdk': '~2.41.0', + 'aws-cdk-lib': '~2.41.0', + 'aws-sdk': '^2.1156.0', + 'camel-case': '^4.1.2', + chalk: '4.1.2', + commander: '6.2.1', + conf: '^10.2.0', + constructs: '~10.1.97', + figlet: '^1.5.2', + flat: '^5.0.2', + moment: '^2.29.3', + 'node-fetch': '2.6.7', + 'object-hash': '^3.0.0', + prompts: '^2.4.2', + semver: '^7.3.7', + 'source-map-support': '^0.5.21', + table: '^6.8.0', + tempy: '1.0.1', + 'ts-node': '^10.9.1', + untildify: '^4.0.0', + yargs: '^17.7.2', + yn: '4.0.0', + zod: '^3.22.4', + }, + devDependencies: { + '@types/figlet': '^1.5.8', + '@types/flat': '^5.0.5', + '@types/node': '^18.7.16', + '@types/object-hash': '^2.2.1', + '@types/prompts': '^2.4.9', + '@types/semver': '^7.5.8', + '@typescript-eslint/eslint-plugin': '^5.36.2', + '@typescript-eslint/parser': '^5.36.2', + eslint: '^8.23.0', + 'eslint-config-airbnb-base': '^15.0.0', + 'eslint-config-prettier': '^8.5.0', + 'eslint-formatter-table': '^7.32.1', + 'eslint-import-resolver-typescript': '^3.5.1', + 'eslint-plugin-cdk': '^1.8.0', + 'eslint-plugin-import': '^2.26.0', + 'eslint-plugin-jest': '^27.0.2', + husky: '^8.0.1', + jest: '^28.1.3', + 'lint-staged': '^13.0.2', + prettier: '^2.7.1', + tsup: '^8.0.2', + typescript: '^4.8.3', + }, + jest: { + verbose: true, + testMatch: ['**/test/**/*.jest.js'], + }, +}; + +// src/shared/helpers/generateVersion.ts +var getCommandResult = (cmd) => { + return (0, import_child_process.execSync)(cmd, { + stdio: 'pipe', + cwd: __dirname, + }) + .toString() + .trim(); +}; +var generateVersion = () => { + const packageVersion = package_default.version; + if (process.env.CACCL_DEPLOY_VERSION !== void 0) { + return process.env.CACCL_DEPLOY_VERSION; + } + const version = [`package=${packageVersion}`]; + let inGitRepo = false; + try { + const gitLsThisFile = getCommandResult(`git ls-files ${__filename}`); + inGitRepo = gitLsThisFile !== ''; + } catch (err) { + if ( + err instanceof Error && + !err.message.toLowerCase().includes('not a git repository') + ) { + console.log(err); + } + } + if (inGitRepo) { + try { + const gitTag = getCommandResult('git describe --exact-match --abbrev=0'); + version.push(`tag=${gitTag}`); + } catch (err) { + if ( + err instanceof Error && + !err.message.includes('no tag exactly matches') + ) { + console.log(err); + } + } + try { + const gitBranch = getCommandResult('git branch --show-current'); + if (gitBranch.length > 0) { + version.unshift(`branch=${gitBranch}`); + } + } catch (err) { + console.log(err); + } + } + return version.join(':'); +}; +var generateVersion_default = generateVersion; + +// src/commands/constants/CACCL_DEPLOY_VERSION.ts +var CACCL_DEPLOY_VERSION = generateVersion_default(); +var CACCL_DEPLOY_VERSION_default = CACCL_DEPLOY_VERSION; + +// src/commands/helpers/isProdAccount.ts +var import_aws16 = __toESM(require_aws()); +var isProdAccount = () => + __async(void 0, null, function* () { + const prodAccounts = conf.get('productionAccounts'); + const accountId = yield (0, import_aws16.getAccountId)(); + return prodAccounts && prodAccounts.includes(accountId); + }); +var isProdAccount_default = isProdAccount; + +// src/commands/operations/stackOperation.ts +var stackOperation = (cmd) => + __async(void 0, null, function* () { + const deployConfig = yield cmd.getDeployConfig(true); + const deployConfigHash = (yield cmd.getDeployConfig()).toHash(); + const cfnStackName = cmd.getCfnStackName(); + const stackExists = yield cfnStackExists_default(cfnStackName); + const { vpcId, ecsClusterName, albLogBucketName } = + yield getCfnStackExports_default(deployConfig.infraStackName); + const cdkStackProps = { + vpcId, + ecsClusterName, + albLogBucketName, + cacclDeployVersion: CACCL_DEPLOY_VERSION_default, + deployConfigHash, + stackName: cfnStackName, + awsAccountId: yield getAccountId_default(), + awsRegion: process.env.AWS_REGION || 'us-east-1', + deployConfig, + }; + const envAdditions = { + AWS_REGION: process.env.AWS_REGION || 'us-east-1', + CDK_DISABLE_VERSION_CHECK: 'true', + }; + const cdkArgs = [...cmd.args]; + if (!cdkArgs.length) { + cdkArgs.push('list'); + } else if (cdkArgs[0] === 'dump') { + exitWithSuccess_default(JSON.stringify(cdkStackProps, null, ' ')); + } else if (cdkArgs[0] === 'info') { + if (!stackExists) { + exitWithError_default( + `Stack ${cfnStackName} has not been deployed yet`, + ); + } + const stackExports = yield getCfnStackExports_default(cfnStackName); + exitWithSuccess_default(JSON.stringify(stackExports, null, ' ')); + } else if (cdkArgs[0] === 'changeset') { + cdkArgs.shift(); + cdkArgs.unshift('deploy', '--no-execute'); + } + if (cmd.profile !== void 0) { + cdkArgs.push('--profile', cmd.profile); + envAdditions.AWS_PROFILE = cmd.profile; + } + if ( + cmd.yes && + (cdkArgs.includes('deploy') || cdkArgs.includes('changeset')) + ) { + cdkArgs.push('--require-approval-never'); + } + if ( + ['deploy', 'destroy', 'changeset'].some((c) => { + return cdkArgs.includes(c); + }) + ) { + if (stackExists && !cmd.yes && !(yield cmd.stackVersionDiffCheck())) { + exitWithSuccess_default(); + } + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + } + if ( + cdkStackProps.deployConfig.dbOptions && + !cdkStackProps.deployConfig.dbOptions.removalPolicy + ) { + cdkStackProps.deployConfig.dbOptions.removalPolicy = + (yield isProdAccount_default()) ? 'RETAIN' : 'DESTROY'; + } + yield import_tempy.default.write.task( + JSON.stringify(cdkStackProps, null, 2), + (tempPath) => + __async(void 0, null, function* () { + envAdditions.CDK_STACK_PROPS_FILE_PATH = tempPath; + const execOpts = { + stdio: 'inherit', + // exec the cdk process in the cdk directory + cwd: __dirname, + // path.join(__dirname, 'cdk'), + // inject our additional env vars + env: __spreadValues(__spreadValues({}, process.env), envAdditions), + }; + try { + (0, + import_child_process2.execSync)(['node_modules/.bin/cdk', ...cdkArgs].join(' '), execOpts); + exitWithSuccess_default('done!'); + } catch (err) { + const message = + err instanceof Error + ? err.message + : `Error while executing CDK: ${err}`; + exitWithError_default(message); + } + }), + ); + }); +var stackOperation_default = stackOperation; + +// src/commands/addStackCommand.ts +var addStackCommand = (cli) => { + return cli + .command('stack') + .description("diff, deploy, or delete the app's AWS resources") + .appOption() + .action(stackOperation_default); +}; +var addStackCommand_default = addStackCommand; + +// src/commands/operations/updateOperation.ts +var updateOperation = (cmd) => + __async(void 0, null, function* () { + const deployConfig = yield cmd.getDeployConfig(true); + if (!(yield confirmProductionOp_default(cmd.yes))) { + exitWithSuccess_default(); + } + if (cmd.args.length > 2) { + exitWithError_default('Too many arguments!'); + } + try { + if (cmd.delete) { + const [param] = cmd.args; + yield deployConfig.delete(cmd.getAppPrefix(), param); + } else { + const [param, value] = cmd.args; + if (!validSSMParamName_default(param)) { + throw new Error(`Invalid param name: '${param}'`); + } + yield deployConfig.update(cmd.getAppPrefix(), param, value); + } + } catch (err) { + const message = err instanceof Error ? err.message : `${err}`; + exitWithError_default(`Something went wrong: ${message}`); + } + }); +var updateOperation_default = updateOperation; + +// src/commands/addUpdateCommand.ts +var addUpdateCommand = (cli) => { + return cli + .command('update') + .description('update (or delete) a single deploy config setting') + .appOption() + .option( + '-D --delete', + 'delete the named parameter instead of creating/updating', + ) + .action(updateOperation_default); +}; +var addUpdateCommand_default = addUpdateCommand; + +// src/commands/classes/CacclDeployCommander.ts +var import_chalk3 = __toESM(require('chalk')); +var import_commander = require('commander'); +var import_yn2 = __toESM(require('yn')); + +// src/shared/helpers/warnAboutVersionDiff.ts +var import_semver = __toESM(require('semver')); +var warnAboutVersionDiff = (versionString1, versionString2) => { + var _a, _b, _c, _d; + if ( + [versionString1, versionString2].filter((v) => { + return v.includes('branch='); + }).length === 1 + ) { + return true; + } + const v1 = + (_b = + (_a = versionString1.match(new RegExp('^package=(?[^:]+)'))) == + null + ? void 0 + : _a.groups) == null + ? void 0 + : _b.version; + const v2 = + (_d = + (_c = versionString2.match(new RegExp('^package=(?[^:]+)'))) == + null + ? void 0 + : _c.groups) == null + ? void 0 + : _d.version; + if (!v1 || !v2) return true; + if (v1 === v2) return false; + if (!import_semver.default.valid(v1) || !import_semver.default.valid(v2)) { + return true; + } + return !import_semver.default.satisfies(v1, `${v2.slice(0, -1)}x`); +}; +var warnAboutVersionDiff_default = warnAboutVersionDiff; + +// src/commands/constants/CACCL_DEPLOY_NON_INTERACTIVE.ts +var { CACCL_DEPLOY_NON_INTERACTIVE = false } = process.env; +var CACCL_DEPLOY_NON_INTERACTIVE_default = CACCL_DEPLOY_NON_INTERACTIVE; + +// src/commands/helpers/initAwsProfile.ts +var initAwsProfile = (profile) => { + try { + initProfile_default(profile); + return profile; + } catch (err) { + if (err instanceof AwsProfileNotFound_default) { + exitWithError_default(err.message); + } else { + throw err; + } + } + return profile; +}; +var initAwsProfile_default = initAwsProfile; + +// src/commands/classes/CacclDeployCommander.ts +var CacclDeployCommander = class _CacclDeployCommander extends import_commander.Command { + /** + * custom command creator + * @param {string} name + */ + createCommand(name) { + const cmd = new _CacclDeployCommander(name) + .passCommandToAction() + .storeOptionsAsProperties() + .commonOptions(); + return cmd; + } + /** + * Convenience method for getting the combined root prefix plus app name + * used for the SSM Paramter Store parameter names + * @param {string} appName + */ + getAppPrefix(appName) { + if ( + this.ssmRootPrefix === void 0 || + (this.app === void 0 && appName === void 0) + ) { + throw Error('Attempted to make an ssm prefix with undefined values'); + } + return `${this.ssmRootPrefix}/${appName || this.app}`; + } + /** + * Convenience method for getting the name of the app's CloudFormation stack + * @param {string} appName + */ + getCfnStackName(appName) { + if ( + this.cfnStackPrefix === void 0 || + (this.app === void 0 && appName === void 0) + ) { + throw Error( + 'Attempted to make a cloudformation stack name with undefined values', + ); + } + return `${this.cfnStackPrefix}${appName || this.app}`; + } + /** + * Retruns the DeployConfig object representing the subcommand's + * + * @param {boolean} keepSecretArns - if true, for any parameter store values + * that reference secretsmanager entries, preserve the secretsmanager ARN + * value rather than dereferencing + */ + getDeployConfig(assumedRole, keepSecretArns) { + return __async(this, null, function* () { + const appPrefix = this.getAppPrefix(); + try { + const deployConfig = yield deployConfig_default.fromSsmParams( + appPrefix, + keepSecretArns, + ); + return deployConfig; + } catch (err) { + if (err instanceof AppNotFound_default) { + exitWithError_default(`${this.app} app configuration not found!`); + } + } + return deployConfig_default.generate(assumedRole); + }); + } + /** + * Will add another confirm prompt that warns if the deployed stack's + * version is more than a patch version different from the cli tool. + * + * @return {CacclDeployCommander} + * @memberof CacclDeployCommander + */ + stackVersionDiffCheck() { + return __async(this, null, function* () { + const cfnStackName = this.getCfnStackName(); + const cfnExports = yield getCfnStackExports_default(cfnStackName); + const stackVersion = cfnExports.cacclDeployVersion; + const cliVersion = CACCL_DEPLOY_VERSION_default; + if ( + cliVersion === stackVersion || + !warnAboutVersionDiff_default(stackVersion, cliVersion) + ) { + return true; + } + const confirmMsg = `Stack deployed with ${import_chalk3.default.redBright( + stackVersion, + )}; you are using ${import_chalk3.default.redBright( + cliVersion, + )}. Proceed?`; + return confirm_default(confirmMsg, false); + }); + } + /** + * For assigning some common options to all commands + * + * @return {CacclDeployCommander} + * @memberof CacclDeployCommander + */ + commonOptions() { + return this.option( + '--profile ', + 'activate a specific aws config/credential profile', + initAwsProfile_default, + ) + .option( + '--ecr-access-role-arn ', + 'IAM role ARN for cross account ECR repo access', + conf.get('ecrAccessRoleArn'), + ) + .requiredOption( + '--ssm-root-prefix ', + 'The root prefix for ssm parameter store entries', + conf.get('ssmRootPrefix'), + ) + .requiredOption( + '--cfn-stack-prefix ', + 'cloudformation stack name prefix, e.g. "CacclDeploy-"', + conf.get('cfnStackPrefix'), + ) + .option( + '-y --yes', + 'non-interactive, yes to everything, overwrite existing, etc', + (0, import_yn2.default)(CACCL_DEPLOY_NON_INTERACTIVE_default), + ); + } + /** + * Add the `--app` option to a command + * + * @param {boolean} optional - unless true the resulting command option + * will be required + * @return {CacclDeployCommander} + * @memberof CacclDeployCommander + */ + appOption(optional) { + return optional + ? this.option('-a --app ', 'name of the app to work with') + : this.requiredOption( + '-a --app ', + 'name of the app to work with', + ); + } + getAssumedRole() { + if (!this.assumedRole) { + this.assumedRole = new AssumedRole_default(); + } + return this.assumedRole; + } +}; +var CacclDeployCommander_default = CacclDeployCommander; + +// src/commands/helpers/byeWithCredentialsError.ts +var byeWithCredentialsError = () => { + exitWithError_default( + [ + 'Looks like there is a problem with your AWS credentials configuration.', + 'Did you run `aws configure`? Did you set a region? Default profile?', + ].join('\n'), + ); +}; +var byeWithCredentialsError_default = byeWithCredentialsError; + +// src/cli.ts +var main = () => + __async(exports, null, function* () { + if (!isConfigured_default() && process.env.NODE_ENV !== 'test') { + byeWithCredentialsError_default(); + } + const { description: packageDescription } = package_default; + if (!conf.get('ssmRootPrefix')) { + console.log( + import_chalk4.default.greenBright( + import_figlet3.default.textSync('Caccl-Deploy!'), + ), + ); + console.log( + [ + 'It looks like this is your first time running caccl-deploy. ', + `A preferences file has been created at ${import_chalk4.default.yellow( + conf.path, + )}`, + 'with the following default values:', + '', + ...Object.entries(configDefaults).map(([k, v]) => { + return ` - ${import_chalk4.default.yellow( + k, + )}: ${import_chalk4.default.bold(JSON.stringify(v))}`; + }), + '', + 'Please see the docs for explanations of these settings', + ].join('\n'), + ); + CACCL_DEPLOY_NON_INTERACTIVE_default || + (yield confirm_default('Continue?', true)) || + exitWithSuccess_default(); + setConfigDefaults(); + } + const cli = new CacclDeployCommander_default() + .version(CACCL_DEPLOY_VERSION_default) + .description([packageDescription, `config: ${conf.path}`].join('\n')); + addAppsCommand_default(cli); + addDeleteCommand_default(cli); + addNewCommand_default(cli); + addScheduleCommand_default(cli); + addConnectCommand_default(cli); + addExecCommand_default(cli); + addImagesCommand_default(cli); + addReleaseCommand_default(cli); + addShowCommand_default(cli); + addUpdateCommand_default(cli); + addReposCommand_default(cli); + addRestartCommand_default(cli); + addStackCommand_default(cli); + }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/dist/index.d.mts b/dist/index.d.mts new file mode 100644 index 0000000..223e65e --- /dev/null +++ b/dist/index.d.mts @@ -0,0 +1,2 @@ + +export { } diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..ad9a93a --- /dev/null +++ b/dist/index.js @@ -0,0 +1 @@ +'use strict'; diff --git a/dist/index.mjs b/dist/index.mjs new file mode 100644 index 0000000..e69de29 diff --git a/src/aws/helpers/createEcrArn.ts b/src/aws/helpers/createEcrArn.ts index 1f2c5fd..fad64fc 100644 --- a/src/aws/helpers/createEcrArn.ts +++ b/src/aws/helpers/createEcrArn.ts @@ -7,7 +7,7 @@ import EcrImage from '../../shared/types/EcrImage'; * @param {EcrImage} ecrImage * @returns {string} an ECR image ARN */ -const createEcrArn = (ecrImage: EcrImage): string => { +const createEcrArn = (ecrImage: Omit): string => { return [ 'arn:aws:ecr', ecrImage.region, diff --git a/src/aws/helpers/ecrArnToImageId.ts b/src/aws/helpers/ecrArnToImageId.ts index f690335..fb007a6 100644 --- a/src/aws/helpers/ecrArnToImageId.ts +++ b/src/aws/helpers/ecrArnToImageId.ts @@ -1,3 +1,6 @@ +// Import helpers +import parseEcrArn from './parseEcrArn'; + /** * Transforms an ECR ARN value into it's URI form * for example, this: @@ -9,7 +12,7 @@ * @returns {string} and ECR image URI */ const ecrArnToImageId = (arn: string): string => { - const parsedArn = aws.parseEcrArn(arn); + const parsedArn = parseEcrArn(arn); const host = [ parsedArn.account, 'dkr.ecr', diff --git a/src/aws/helpers/execTask.ts b/src/aws/helpers/execTask.ts index 89cfaac..386a026 100644 --- a/src/aws/helpers/execTask.ts +++ b/src/aws/helpers/execTask.ts @@ -4,6 +4,7 @@ import AWS from 'aws-sdk'; // Import helpers import getService from './getService'; +// Types export type EnvVariable = { name: string; value: string; diff --git a/src/aws/helpers/getCfnStackExports.ts b/src/aws/helpers/getCfnStackExports.ts index 432d22b..8c54ca8 100644 --- a/src/aws/helpers/getCfnStackExports.ts +++ b/src/aws/helpers/getCfnStackExports.ts @@ -7,16 +7,14 @@ import { camelCase } from 'camel-case'; // Import shared errors import CfnStackNotFound from '../../shared/errors/CfnStackNotFound'; -// Import - -type Output = CloudFormation.Output; - /** * Returns an array of objects representing a Cloudformation stack's exports * @param {string} stackName - * @returns {object[]} + * @returns {Record} */ -const getCfnStackExports = async (stackName: string) => { +const getCfnStackExports = async ( + stackName: string, +): Promise> => { const cnf = new AWS.CloudFormation(); // TODO: better typing let exports: Record = {}; @@ -34,7 +32,7 @@ const getCfnStackExports = async (stackName: string) => { throw new CfnStackNotFound(`Unable to find stack ${stackName}`); } exports = resp.Stacks[0].Outputs.reduce( - (obj: Record, output: Output) => { + (obj: Record, output: CloudFormation.Output) => { if (!output.ExportName || !output.OutputValue) { return { ...obj }; } diff --git a/src/aws/helpers/getCurrentRegion.ts b/src/aws/helpers/getCurrentRegion.ts index e299508..d811ba7 100644 --- a/src/aws/helpers/getCurrentRegion.ts +++ b/src/aws/helpers/getCurrentRegion.ts @@ -9,8 +9,13 @@ import AWS from 'aws-sdk'; * @author Jay Luker * @returns {string} */ -const getCurrentRegion = () => { - return AWS.config.region; +const getCurrentRegion = (): string => { + const { region } = AWS.config; + if (!region) { + // TODO: better error type + throw new Error('Could not get current AWS region.'); + } + return region; }; export default getCurrentRegion; diff --git a/src/aws/helpers/getRepoImageList.ts b/src/aws/helpers/getRepoImageList.ts index f157cb2..96b4ec5 100644 --- a/src/aws/helpers/getRepoImageList.ts +++ b/src/aws/helpers/getRepoImageList.ts @@ -8,8 +8,6 @@ import looksLikeSemver from '../../shared/helpers/looksLikeSemver'; // Import classes import AssumedRole from '../classes/AssumedRole'; -// Import helpers - /** * @author Jay Luker * @param {string} repo - ECR repository name, e.g. 'hdce/fooapp' @@ -18,10 +16,9 @@ import AssumedRole from '../classes/AssumedRole'; * @returns {object[]} */ const getRepoImageList = async ( - repo: string, - // TODO: make `all` optional - all: boolean, assumedRole: AssumedRole, + repo: string, + all?: boolean, ) => { const ecr = await assumedRole.getAssumedRoleClient(AWS.ECR); const images = await getPaginatedResponse( diff --git a/src/aws/helpers/getRepoList.ts b/src/aws/helpers/getRepoList.ts index 3c39da6..bc768b6 100644 --- a/src/aws/helpers/getRepoList.ts +++ b/src/aws/helpers/getRepoList.ts @@ -5,8 +5,6 @@ import AWS from 'aws-sdk'; import getPaginatedResponse from './getPaginatedResponse'; import AssumedRole from '../classes/AssumedRole'; -// Import helpers - /** * @author Jay Luker * @returns {string[]} - array of ECR repository names diff --git a/src/aws/helpers/getSsmParametersByPrefix.ts b/src/aws/helpers/getSsmParametersByPrefix.ts index b11483f..6221c67 100644 --- a/src/aws/helpers/getSsmParametersByPrefix.ts +++ b/src/aws/helpers/getSsmParametersByPrefix.ts @@ -7,6 +7,7 @@ import getPaginatedResponse from './getPaginatedResponse'; /** * Fetch a set of parameter store entries based on a name prefix, * e.g. `/caccl-deploy/foo-app` + * @author Jay Luker * @param {string} prefix * @returns {object[]} */ diff --git a/src/aws/helpers/getTaskDefinition.ts b/src/aws/helpers/getTaskDefinition.ts index b9b9e5c..3ae70ae 100644 --- a/src/aws/helpers/getTaskDefinition.ts +++ b/src/aws/helpers/getTaskDefinition.ts @@ -3,6 +3,7 @@ import AWS, { ECS } from 'aws-sdk'; /** * Fetches the data for an ECS task definition + * @author Jay Luker * @param {string} taskDefName * @returns {string} */ diff --git a/src/aws/helpers/imageTagExists.ts b/src/aws/helpers/imageTagExists.ts index dbc2cb6..1698d7c 100644 --- a/src/aws/helpers/imageTagExists.ts +++ b/src/aws/helpers/imageTagExists.ts @@ -1,15 +1,22 @@ // Import helpers import getRepoImageList from './getRepoImageList'; +// Import classes +import AssumedRole from '../classes/AssumedRole'; + /** * Confirms that a repo/tag combo exists + * @author Jay Luker * @param {string} repoName - ECR repository name * @param {string} tag - ECR image tag * @returns {boolean} */ -const imageTagExists = async (repoName: string, tag: string) => { - // FIXME: need assumed role (only used for ECR functions?) - const imageList = await getRepoImageList(repoName, true); +const imageTagExists = async ( + assumedRole: AssumedRole, + repoName: string, + tag: string, +) => { + const imageList = await getRepoImageList(assumedRole, repoName, true); return imageList.some((i) => { if (!i.imageTags) return false; return i.imageTags.includes(tag); diff --git a/src/aws/helpers/initProfile.ts b/src/aws/helpers/initProfile.ts index 52cad8d..3e7862a 100644 --- a/src/aws/helpers/initProfile.ts +++ b/src/aws/helpers/initProfile.ts @@ -2,7 +2,7 @@ import AWS from 'aws-sdk'; import { iniLoader as SharedIniFile } from 'aws-sdk/lib/shared-ini'; -// Import errrors +// Import errors import AwsProfileNotFound from '../../shared/errors/AwsProfileNotFound'; /** diff --git a/src/aws/helpers/isLatestTag.ts b/src/aws/helpers/isLatestTag.ts index 684b531..4f86385 100644 --- a/src/aws/helpers/isLatestTag.ts +++ b/src/aws/helpers/isLatestTag.ts @@ -1,15 +1,22 @@ // Import helpers import getRepoImageList from './getRepoImageList'; +// Import classes +import AssumedRole from '../classes/AssumedRole'; + /** * Confirms that a tag is the latest for a repo + * @author Jay Luker * @param {string} repoName * @param {string} tag * @returns {boolean} */ -const isLatestTag = async (repoName: string, tag: string): Promise => { - // FIXME: change getRepoImageList arguments - const imageList = await getRepoImageList(repoName); +const isLatestTag = async ( + assumedRole: AssumedRole, + repoName: string, + tag: string, +): Promise => { + const imageList = await getRepoImageList(assumedRole, repoName); return ( !!imageList.length && !!imageList[0].imageTags && diff --git a/src/aws/helpers/putSsmParameter.ts b/src/aws/helpers/putSsmParameter.ts index 2585b5e..2a5d802 100644 --- a/src/aws/helpers/putSsmParameter.ts +++ b/src/aws/helpers/putSsmParameter.ts @@ -3,6 +3,8 @@ import AWS, { SSM } from 'aws-sdk'; import AwsTag from '../../shared/types/AwsTag'; /** + * Add parameters to SSM + * @author Jay Luker * @param {SSM.PutParameterRequest} opts - the parameter details, name, value, etc * @param {object[]} tags - aws resource tags * @returns {object} diff --git a/src/aws/helpers/restartEcsService.ts b/src/aws/helpers/restartEcsService.ts index 73fc63d..a4603bd 100644 --- a/src/aws/helpers/restartEcsService.ts +++ b/src/aws/helpers/restartEcsService.ts @@ -15,6 +15,7 @@ export type RestartOpts = { /** * Restart an app's ECS service + * @author Jay Luker * @param {string} cluster * @param {string} service * @param {boolean} wait diff --git a/src/aws/helpers/secretExists.ts b/src/aws/helpers/secretExists.ts index 18bc227..45df6cb 100644 --- a/src/aws/helpers/secretExists.ts +++ b/src/aws/helpers/secretExists.ts @@ -3,6 +3,7 @@ import AWS from 'aws-sdk'; /** * Confirm that a secretsmanager entry exists + * @author Jay Luker * @param {string} secretName * @returns {boolean} */ diff --git a/src/aws/helpers/sendSSHPublicKey.ts b/src/aws/helpers/sendSSHPublicKey.ts index 7ff49d0..4498c94 100644 --- a/src/aws/helpers/sendSSHPublicKey.ts +++ b/src/aws/helpers/sendSSHPublicKey.ts @@ -4,12 +4,21 @@ import AWS from 'aws-sdk'; // Import shared helpers import readFile from '../../shared/helpers/readFile'; +// Import constants +import EC2_INSTANCE_CONNECT_USER from '../constants/EC2_INSTANCE_CONNECT_USER'; + export type SendSSHPublicKeyOpts = { instanceAz?: string; instanceId: string; sshKeyPath: string; }; +/** + * Send SSH public key to a remote server. + * @author Jay Luker + * @param opts + * @returns + */ const sendSSHPublicKey = async (opts: SendSSHPublicKeyOpts) => { // Destructure opts const { instanceAz, instanceId, sshKeyPath } = opts; @@ -19,7 +28,7 @@ const sendSSHPublicKey = async (opts: SendSSHPublicKeyOpts) => { .sendSSHPublicKey({ AvailabilityZone: instanceAz, InstanceId: instanceId, - InstanceOSUser: aws.EC2_INSTANCE_CONNECT_USER, + InstanceOSUser: EC2_INSTANCE_CONNECT_USER, SSHPublicKey: readFile(sshKeyPath), }) .promise(); diff --git a/src/aws/helpers/updateTaskDefAppImage.ts b/src/aws/helpers/updateTaskDefAppImage.ts index abb7f73..ad872be 100644 --- a/src/aws/helpers/updateTaskDefAppImage.ts +++ b/src/aws/helpers/updateTaskDefAppImage.ts @@ -8,6 +8,7 @@ import getTaskDefinition from './getTaskDefinition'; /** * Updates a Fargate task definition, replacing the app container's * ECR image URI value + * @author Jay Luker * @param {string} taskDefName * @param {string} imageArn * @returns {string} - the full ARN (incl family:revision) of the newly diff --git a/src/cli.ts b/src/cli.ts index b7403c0..43ca4d6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,9 +27,12 @@ import addShowCommand from './commands/addShowCommand'; import addStackCommand from './commands/addStackCommand'; import addUpdateCommand from './commands/addUpdateCommand'; -// Import constants +// Import classes import CacclDeployCommander from './commands/classes/CacclDeployCommander'; + +// Import constants import CACCL_DEPLOY_NON_INTERACTIVE from './commands/constants/CACCL_DEPLOY_NON_INTERACTIVE'; +import CACCL_DEPLOY_VERSION from './commands/constants/CACCL_DEPLOY_VERSION'; // Import helpers import byeWithCredentialsError from './commands/helpers/byeWithCredentialsError'; @@ -40,7 +43,6 @@ import { conf, configDefaults, setConfigDefaults } from './conf'; // Import prompts import { confirm } from './configPrompts'; -import generateVersion from './shared/helpers/generateVersion'; import packageJson from '../package.json'; /** @@ -53,7 +55,6 @@ const main = async () => { byeWithCredentialsError(); } - const cacclDeployVersion = generateVersion(); const { description: packageDescription } = packageJson; /* @@ -83,7 +84,7 @@ const main = async () => { } const cli = new CacclDeployCommander() - .version(cacclDeployVersion) + .version(CACCL_DEPLOY_VERSION) .description([packageDescription, `config: ${conf.path}`].join('\n')); addAppsCommand(cli); diff --git a/src/commands/addScheduleCommand.ts b/src/commands/addScheduleCommand.ts index 2889dc3..4ba9610 100644 --- a/src/commands/addScheduleCommand.ts +++ b/src/commands/addScheduleCommand.ts @@ -14,7 +14,7 @@ const addScheduleCommand = ( .option('-l, --list', 'list the existing scheduled tasks') .option( '-t, --task-id ', - 'give the taska a string id; by default one will be generated', + 'give the task a string id; by default one will be generated', ) .option( '-d, --task-description ', diff --git a/src/commands/classes/CacclDeployCommander.ts b/src/commands/classes/CacclDeployCommander.ts index 6791123..c91678e 100644 --- a/src/commands/classes/CacclDeployCommander.ts +++ b/src/commands/classes/CacclDeployCommander.ts @@ -11,7 +11,7 @@ import yn from 'yn'; import { DeployConfigData } from '../../../types'; // Import aws -import { getCfnStackExports } from '../../aws'; +import { AssumedRole, getCfnStackExports } from '../../aws'; // Import conf import { conf } from '../../conf'; @@ -30,6 +30,7 @@ import warnAboutVersionDiff from '../../shared/helpers/warnAboutVersionDiff'; // Import constants import CACCL_DEPLOY_NON_INTERACTIVE from '../constants/CACCL_DEPLOY_NON_INTERACTIVE'; +import CACCL_DEPLOY_VERSION from '../constants/CACCL_DEPLOY_VERSION'; // Import helpers import exitWithError from '../helpers/exitWithError'; @@ -41,6 +42,10 @@ import initAwsProfile from '../helpers/initAwsProfile'; * @extends Command */ class CacclDeployCommander extends Command { + private assumedRole?: AssumedRole; + + public ecrAccessRoleArn?: string; + /** * custom command creator * @param {string} name @@ -99,7 +104,10 @@ class CacclDeployCommander extends Command { * that reference secretsmanager entries, preserve the secretsmanager ARN * value rather than dereferencing */ - async getDeployConfig(keepSecretArns?: boolean): Promise { + async getDeployConfig( + assumedRole: AssumedRole, + keepSecretArns?: boolean, + ): Promise { const appPrefix = this.getAppPrefix(); try { const deployConfig = await DeployConfig.fromSsmParams( @@ -113,7 +121,7 @@ class CacclDeployCommander extends Command { exitWithError(`${this.app} app configuration not found!`); } } - return DeployConfig.generate(); + return DeployConfig.generate(assumedRole); } /** @@ -127,7 +135,7 @@ class CacclDeployCommander extends Command { const cfnStackName = this.getCfnStackName(); const cfnExports = await getCfnStackExports(cfnStackName); const stackVersion = cfnExports.cacclDeployVersion; - const cliVersion = cacclDeployVersion; + const cliVersion = CACCL_DEPLOY_VERSION; if ( cliVersion === stackVersion || !warnAboutVersionDiff(stackVersion, cliVersion) @@ -190,6 +198,13 @@ class CacclDeployCommander extends Command { 'name of the app to work with', ); } + + public getAssumedRole(): AssumedRole { + if (!this.assumedRole) { + this.assumedRole = new AssumedRole(); + } + return this.assumedRole; + } } export default CacclDeployCommander; diff --git a/src/commands/constants/CACCL_DEPLOY_VERSION.ts b/src/commands/constants/CACCL_DEPLOY_VERSION.ts new file mode 100644 index 0000000..739b6a2 --- /dev/null +++ b/src/commands/constants/CACCL_DEPLOY_VERSION.ts @@ -0,0 +1,9 @@ +import generateVersion from '../../shared/helpers/generateVersion'; + +/** + * caccl-deploy version, pulled from package.json + * @author Benedikt Arnarsson + */ +const CACCL_DEPLOY_VERSION = generateVersion(); + +export default CACCL_DEPLOY_VERSION; diff --git a/src/commands/helpers/isProdAccount.ts b/src/commands/helpers/isProdAccount.ts index 4c19dd7..dd1b459 100644 --- a/src/commands/helpers/isProdAccount.ts +++ b/src/commands/helpers/isProdAccount.ts @@ -4,6 +4,11 @@ import { getAccountId } from '../../../lib/aws'; // Import config import { conf } from '../../conf'; +/** + * Check whether the current AWS account is a production account. + * @author Jay Luker + * @returns boolean indicating whether it is a prod account. + */ const isProdAccount = async () => { const prodAccounts = conf.get('productionAccounts'); const accountId = await getAccountId(); diff --git a/src/commands/operations/appsOperation.ts b/src/commands/operations/appsOperation.ts index c0feb2d..a1d6da9 100644 --- a/src/commands/operations/appsOperation.ts +++ b/src/commands/operations/appsOperation.ts @@ -23,7 +23,7 @@ const appsOperation = async (cmd: CacclDeployCommander) => { ); } - const appData = {}; + const appData: Record = {}; const tableColumns = ['App']; for (let i = 0; i < apps.length; i++) { @@ -53,7 +53,7 @@ const appsOperation = async (cmd: CacclDeployCommander) => { s.StackName === cfnStackName && s.StackStatus !== 'DELETE_COMPLETE' ); }); - if (!cfnStack) { + if (!cfnStack || !cfnStack.Outputs) { // config exists but cfn stack not deployed yet (or was destroyed) appData[app].push('', '', ''); continue; @@ -65,7 +65,7 @@ const appsOperation = async (cmd: CacclDeployCommander) => { */ let configDrift = '?'; const cfnStackDeployConfigHashOutput = cfnStack.Outputs.find((o) => { - return o.OutputKey.startsWith('DeployConfigHash'); + return o.OutputKey && o.OutputKey.startsWith('DeployConfigHash'); }); if (cfnStackDeployConfigHashOutput) { @@ -76,9 +76,9 @@ const appsOperation = async (cmd: CacclDeployCommander) => { appData[app].push(cfnStack.StackStatus, configDrift); const cfnStackCacclDeployVersion = cfnStack.Outputs.find((o) => { - return o.OutputKey.startsWith('CacclDeployVersion'); + return o.OutputKey && o.OutputKey.startsWith('CacclDeployVersion'); }); - appData[app].push(cfnStackCacclDeployVersion.OutputValue); + appData[app].push(cfnStackCacclDeployVersion?.OutputValue ?? 'N/A'); } } const tableData = Object.keys(appData).map((app) => { diff --git a/src/commands/operations/connectOperation.ts b/src/commands/operations/connectOperation.ts index 5fbdd48..f471519 100644 --- a/src/commands/operations/connectOperation.ts +++ b/src/commands/operations/connectOperation.ts @@ -1,3 +1,5 @@ +import yn from 'yn'; + import { EC2_INSTANCE_CONNECT_USER, getCfnStackExports, @@ -11,16 +13,20 @@ import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; const connectOperation = async (cmd: CacclDeployCommander) => { - if (!cmd.list && !cmd.service) { + const opts = cmd.opts(); + const assumedRole = cmd.getAssumedRole(); + + if (!opts.list && !opts.service) { exitWithError('One of `--list` or `--service` is required'); } - const deployConfig = await cmd.getDeployConfig(); + const deployConfig = await cmd.getDeployConfig(assumedRole); const services = new Set(); - ['dbOptions', 'cacheOptions'].forEach((optsKey) => { - if (deployConfig[optsKey]) { - services.add(deployConfig[optsKey].engine); + ['dbOptions' as const, 'cacheOptions' as const].forEach((optsKey) => { + const serviceOptions = deployConfig[optsKey]; + if (serviceOptions) { + services.add(serviceOptions.engine); } }); if (yn(deployConfig.docDb)) { @@ -32,12 +38,12 @@ const connectOperation = async (cmd: CacclDeployCommander) => { ); } - if (cmd.list) { + if (opts.list) { exitWithSuccess(['Valid `--service=` options:', ...services].join('\n ')); } - if (!services.has(cmd.service)) { - exitWithError(`'${cmd.service}' is not a valid option`); + if (!services.has(opts.service)) { + exitWithError(`'${opts.service}' is not a valid option`); } const cfnStackName = cmd.getCfnStackName(); @@ -50,55 +56,59 @@ const connectOperation = async (cmd: CacclDeployCommander) => { await sendSSHPublicKey({ instanceAz: bastionHostAz, instanceId: bastionHostId, - sshKeyPath: cmd.publicKey, + sshKeyPath: opts.publicKey, }); } catch (err) { - exitWithError(err.message); + const message = + err instanceof Error + ? err.message + : `Could not send SSH public key: ${err}`; + exitWithError(message); } let endpoint; let localPort; let clientCommand; - if (['mysql', 'docdb'].includes(cmd.service)) { + if (['mysql', 'docdb'].includes(opts.service)) { endpoint = cfnStackExports.dbClusterEndpoint; const password = await resolveSecret(dbPasswordSecretArn); - if (cmd.service === 'mysql') { - localPort = cmd.localPort || '3306'; + if (opts.service === 'mysql') { + localPort = opts.localPort || '3306'; clientCommand = `mysql -uroot -p${password} --port ${localPort} -h 127.0.0.1`; } else { - localPort = cmd.localPort || '27017'; + localPort = opts.localPort || '27017'; const tlsOpts = '--ssl --sslAllowInvalidHostnames --sslAllowInvalidCertificates'; clientCommand = `mongo ${tlsOpts} --username root --password ${password} --port ${localPort}`; } - } else if (cmd.service === 'redis') { + } else if (opts.service === 'redis') { endpoint = cfnStackExports.cacheEndpoint; - localPort = cmd.localPort || '6379'; + localPort = opts.localPort || '6379'; clientCommand = `redis-cli -p ${localPort}`; } else { - exitWithError(`not sure what to do with ${cmd.service}`); + exitWithError(`not sure what to do with ${opts.service}`); } const tunnelCommand = [ 'ssh -f -L', - `${cmd.localPort || localPort}:${endpoint}`, + `${opts.localPort || localPort}:${endpoint}`, '-o StrictHostKeyChecking=no', `${EC2_INSTANCE_CONNECT_USER}@${bastionHostIp}`, - `sleep ${cmd.sleep}`, + `sleep ${opts.sleep}`, ].join(' '); - if (cmd.quiet) { + if (opts.quiet) { exitWithSuccess(tunnelCommand); } exitWithSuccess( [ - `Your public key, ${cmd.publicKey}, has temporarily been placed on the bastion instance`, + `Your public key, ${opts.publicKey}, has temporarily been placed on the bastion instance`, 'You have ~60s to establish the ssh tunnel', '', `# tunnel command:\n${tunnelCommand}`, - `# ${cmd.service} client command:\n${clientCommand}`, + `# ${opts.service} client command:\n${clientCommand}`, ].join('\n'), ); }; diff --git a/src/commands/operations/execOperation.ts b/src/commands/operations/execOperation.ts index 48c56cf..d25a3fc 100644 --- a/src/commands/operations/execOperation.ts +++ b/src/commands/operations/execOperation.ts @@ -21,14 +21,16 @@ const execOperation = async (cmd: CacclDeployCommander) => { } console.log( - `Running command '${cmd.command}' on service ${serviceName} using task def ${appOnlyTaskDefName}`, + `Running command '${ + cmd.opts().command + }' on service ${serviceName} using task def ${appOnlyTaskDefName}`, ); const taskArn = await execTask({ clusterName, serviceName, taskDefName: appOnlyTaskDefName, command: cmd.opts().command, - environment: cmd.env, + environment: cmd.opts().env, }); exitWithSuccess(`Task ${taskArn} started`); }; diff --git a/src/commands/operations/imagesOperation.ts b/src/commands/operations/imagesOperation.ts index e0696d0..cc9a452 100644 --- a/src/commands/operations/imagesOperation.ts +++ b/src/commands/operations/imagesOperation.ts @@ -1,26 +1,26 @@ +import { ECR } from 'aws-sdk'; + import moment from 'moment'; import { table } from 'table'; -import { - createEcrArn, - getCurrentRegion, - getRepoImageList, - // setAssumedRoleArn, -} from '../../aws'; +import { createEcrArn, getCurrentRegion, getRepoImageList } from '../../aws'; import looksLikeSemver from '../../shared/helpers/looksLikeSemver'; +import CacclDeployCommander from '../classes/CacclDeployCommander'; + import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; -const imagesOperation = async (cmd) => { +const imagesOperation = async (cmd: CacclDeployCommander) => { + const assumedRole = cmd.getAssumedRole(); + const opts = cmd.opts(); // see the README section on cross-account ECR access if (cmd.ecrAccessRoleArn !== undefined) { - // FIXME: - // setAssumedRoleArn(cmd.ecrAccessRoleArn); + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); } - const images = await getRepoImageList(cmd.repo, cmd.all); + const images = await getRepoImageList(assumedRole, opts.repo, opts.all); const region = getCurrentRegion(); /** @@ -29,35 +29,43 @@ const imagesOperation = async (cmd) => { * Otherwise only tags that look like e.g. "1.1.1" or master/stage * will be included. */ - const includeThisTag = (t) => { - return cmd.all || looksLikeSemver(t) || ['master', 'stage'].includes(t); + const includeThisTag = (tag: string): boolean => { + return ( + opts.all || looksLikeSemver(tag) || ['master', 'stage'].includes(tag) + ); }; - const data = images.map((i) => { - const imageTags = i.imageTags.filter(includeThisTag).join('\n'); + const data = images + .filter((image) => { + return !!image.imageTags && !!image.registryId; + }) + .map((image) => { + const tags = image.imageTags as ECR.ImageTagList; + const account = image.registryId as string; + const imageTags = tags.filter(includeThisTag).join('\n'); - /** - * Filter then list of image ids for just the ones that correspond - * to the image tags we want to include - */ - const imageArns = i.imageTags - .reduce((collect: string[], t) => { - if (includeThisTag(t)) { - collect.push( - createEcrArn({ - repoName: cmd.repo, - imageTag: t, - account: i.registryId, - region, - }), - ); - } - return collect; - }, []) - .join('\n'); + /** + * Filter then list of image ids for just the ones that correspond + * to the image tags we want to include + */ + const imageArns = tags + .reduce((collect: string[], t) => { + if (includeThisTag(t)) { + collect.push( + createEcrArn({ + repoName: opts.repo, + imageTag: t, + account, + region, + }), + ); + } + return collect; + }, []) + .join('\n'); - return [moment(i.imagePushedAt).format(), imageTags, imageArns]; - }); + return [moment(image.imagePushedAt).format(), imageTags, imageArns]; + }); if (data.length) { const tableOutput = table([['Pushed On', 'Tags', 'ARNs'], ...data]); exitWithSuccess(tableOutput); diff --git a/src/commands/operations/newOperation.ts b/src/commands/operations/newOperation.ts index 96a7770..d1c4e9f 100644 --- a/src/commands/operations/newOperation.ts +++ b/src/commands/operations/newOperation.ts @@ -29,8 +29,11 @@ import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; const newOperation = async (cmd: CacclDeployCommander) => { + const opts = cmd.opts(); + + const assumedRole = cmd.getAssumedRole(); if (cmd.ecrAccessRoleArn !== undefined) { - // setAssumedRoleArn(cmd.ecrAccessRoleArn); + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); } const existingApps = await getAppList(cmd.ssmRootPrefix); @@ -70,15 +73,15 @@ const newOperation = async (cmd: CacclDeployCommander) => { * operation to complete any missing settings */ let importedConfig; - if (cmd.import !== undefined) { - importedConfig = /^http(s):\//.test(cmd.import) - ? await DeployConfig.fromUrl(cmd.import) - : DeployConfig.fromFile(cmd.import); + if (opts.import !== undefined) { + importedConfig = /^http(s):\//.test(opts.import) + ? await DeployConfig.fromUrl(opts.import) + : DeployConfig.fromFile(opts.import); } let deployConfig; try { - deployConfig = await DeployConfig.generate(importedConfig); + deployConfig = await DeployConfig.generate(assumedRole, importedConfig); } catch (err) { if (err instanceof UserCancel) { exitWithSuccess(); diff --git a/src/commands/operations/releaseOperation.ts b/src/commands/operations/releaseOperation.ts index 5a83fb3..2abb2c9 100644 --- a/src/commands/operations/releaseOperation.ts +++ b/src/commands/operations/releaseOperation.ts @@ -5,28 +5,31 @@ import { imageTagExists as getImageTagExists, parseEcrArn, restartEcsService, - // setAssumedRoleArn, updateTaskDefAppImage, } from '../../aws'; import { confirm, confirmProductionOp } from '../../configPrompts'; +import DeployConfig from '../../deployConfig'; + import CfnStackNotFound from '../../shared/errors/CfnStackNotFound'; +import CacclDeployCommander from '../classes/CacclDeployCommander'; + import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; -const releaseOperation = async (cmd: any) => { +const releaseOperation = async (cmd: CacclDeployCommander) => { + const assumedRole = cmd.getAssumedRole(); // see the README section on cross-account ECR access if (cmd.ecrAccessRoleArn !== undefined) { - // FIXME: - // setAssumedRoleArn(cmd.ecrAccessRoleArn); + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); } - const deployConfig = await cmd.getDeployConfig(); + const deployConfig = await cmd.getDeployConfig(assumedRole); const cfnStackName = cmd.getCfnStackName(); - let cfnExports; + let cfnExports: Record; try { cfnExports = await getCfnStackExports(cfnStackName); ['taskDefName', 'clusterName', 'serviceName'].forEach((exportValue) => { @@ -35,7 +38,10 @@ const releaseOperation = async (cmd: any) => { } }); } catch (err) { - if (err instanceof CfnStackNotFound || err.message.includes('Incomplete')) { + if ( + err instanceof Error && + (err instanceof CfnStackNotFound || err.message.includes('Incomplete')) + ) { exitWithError(err.message); } throw err; @@ -56,6 +62,7 @@ const releaseOperation = async (cmd: any) => { // check that the specified image tag is legit console.log(`Checking that an image exists with the tag ${cmd.imageTag}`); const imageTagExists = await getImageTagExists( + assumedRole, repoArn.repoName, cmd.imageTag, ); @@ -65,7 +72,11 @@ const releaseOperation = async (cmd: any) => { // check if it's the latest release and prompt if not console.log(`Checking ${cmd.imageTag} is the latest tag`); - const isLatestTag = await getIsLatestTag(repoArn.repoName, cmd.imageTag); + const isLatestTag = await getIsLatestTag( + assumedRole, + repoArn.repoName, + cmd.imageTag, + ); if (!isLatestTag && !cmd.yes) { console.log(`${cmd.imageTag} is not the most recent release`); (await confirm('Proceed?')) || exitWithSuccess(); @@ -112,14 +123,19 @@ const releaseOperation = async (cmd: any) => { // update the ssm parameter console.log('Updating stored deployment configuration'); - await deployConfig.update(cmd.getAppPrefix(), 'appImage', newAppImage); + await DeployConfig.update({ + deployConfig, + appPrefix: cmd.getAppPrefix(), + param: 'appImage', + value: newAppImage, + }); // restart the service if (cmd.deploy) { console.log(`Restarting the ${serviceName} service...`); await restartEcsService({ - clusterName, - serviceName, + cluster: clusterName, + service: serviceName, newTaskDefArn, wait: true, }); diff --git a/src/commands/operations/reposOperation.ts b/src/commands/operations/reposOperation.ts index ff34e71..347f8e2 100644 --- a/src/commands/operations/reposOperation.ts +++ b/src/commands/operations/reposOperation.ts @@ -5,15 +5,18 @@ import { // setAssumedRoleArn, } from '../../aws'; +import CacclDeployCommander from '../classes/CacclDeployCommander'; + import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; -const reposOperation = async (cmd) => { +const reposOperation = async (cmd: CacclDeployCommander) => { + const assumedRole = cmd.getAssumedRole(); // see the README section on cross-account ECR access if (cmd.ecrAccessRoleArn !== undefined) { - // setAssumedRoleArn(cmd.ecrAccessRoleArn); + assumedRole.setAssumedRoleArn(cmd.ecrAccessRoleArn); } - const repos = await getRepoList(); + const repos = await getRepoList(assumedRole); const data = repos.map((r) => { return [r]; }); diff --git a/src/commands/operations/scheduleOperation.ts b/src/commands/operations/scheduleOperation.ts index 43f66f3..095a21c 100644 --- a/src/commands/operations/scheduleOperation.ts +++ b/src/commands/operations/scheduleOperation.ts @@ -12,11 +12,14 @@ import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; const scheduleOperation = async (cmd: CacclDeployCommander) => { - const deployConfig = await cmd.getDeployConfig(); + const opts = cmd.opts(); + const assumedRole = cmd.getAssumedRole(); + + const deployConfig = await cmd.getDeployConfig(assumedRole); const existingTasks = deployConfig.scheduledTasks || {}; const existingTaskIds = Object.keys(existingTasks); - if (cmd.list) { + if (opts.list) { // format existing as a table and exitWithSuccess if (existingTaskIds.length) { const tableRows = existingTaskIds.map((id) => { @@ -31,13 +34,15 @@ const scheduleOperation = async (cmd: CacclDeployCommander) => { exitWithSuccess(tableOutput); } exitWithSuccess('No scheduled tasks configured'); - } else if (cmd.delete) { + } else if (opts.delete) { // delete the existing entry if (!existingTaskIds.includes(cmd.delete)) { exitWithError(`No scheduled task with id ${cmd.delete}`); } const existingTask = existingTasks[cmd.delete]; - if (!(cmd.yes || (await confirm(`Delete scheduled task ${cmd.delete}?`)))) { + if ( + !(cmd.yes || (await confirm(`Delete scheduled task ${opts.delete}?`))) + ) { exitWithSuccess(); } const existingTaskParams = Object.keys(existingTask); @@ -45,18 +50,17 @@ const scheduleOperation = async (cmd: CacclDeployCommander) => { await DeployConfig.deleteParam( deployConfig, cmd.getAppPrefix(), - `scheduledTasks/${cmd.delete}/${existingTaskParams[i]}`, + `scheduledTasks/${opts.delete}/${existingTaskParams[i]}`, ); } - exitWithSuccess(`Scheduled task ${cmd.delete} deleted`); - } else if (!(cmd.taskSchedule && cmd.taskCommand)) { + exitWithSuccess(`Scheduled task ${opts.delete} deleted`); + } else if (!(opts.taskSchedule && opts.taskCommand)) { exitWithError('Invalid options. See `--help` output'); } - const taskId = cmd.taskId || Math.random().toString(36).substr(2, 16); - const taskDescription = cmd.taskDescription || ''; - const { taskSchedule } = cmd; - const taskComman = cmd.taskCommand; + const taskId = opts.taskId || Math.random().toString(36).substring(2, 16); + const taskDescription = opts.taskDescription || ''; + const { taskCommand, taskSchedule } = opts; if (!validSSMParamName(taskId)) { exitWithError( @@ -70,14 +74,14 @@ const scheduleOperation = async (cmd: CacclDeployCommander) => { }) ) { exitWithError( - `A schedule task with id ${taskId} already exists for ${cmd.app}`, + `A schedule task with id ${taskId} already exists for ${opts.app}`, ); } const params = { [`scheduledTasks/${taskId}/description`]: taskDescription, [`scheduledTasks/${taskId}/schedule`]: taskSchedule, - [`scheduledTasks/${taskId}/command`]: taskComman, + [`scheduledTasks/${taskId}/command`]: taskCommand, }; await DeployConfig.syncToSsm(deployConfig, cmd.getAppPrefix(), params); diff --git a/src/commands/operations/stackOperation.ts b/src/commands/operations/stackOperation.ts index d7c236e..7b93674 100644 --- a/src/commands/operations/stackOperation.ts +++ b/src/commands/operations/stackOperation.ts @@ -1,13 +1,24 @@ +import { execSync } from 'child_process'; + import tempy from 'tempy'; import { cfnStackExists, getAccountId, getCfnStackExports } from '../../aws'; import { confirmProductionOp } from '../../configPrompts'; +import CACCL_DEPLOY_VERSION from '../constants/CACCL_DEPLOY_VERSION'; + import exitWithError from '../helpers/exitWithError'; import exitWithSuccess from '../helpers/exitWithSuccess'; import isProdAccount from '../helpers/isProdAccount'; +type EnvAdditions = { + AWS_REGION: string; + CDK_DISABLE_VERSION_CHECK: string; + AWS_PROFILE?: string; + CDK_STACK_PROPS_FILE_PATH?: string; +}; + const stackOperation = async (cmd: any) => { // get this without resolved secrets for passing to cdk const deployConfig = await cmd.getDeployConfig(true); @@ -35,7 +46,7 @@ const stackOperation = async (cmd: any) => { vpcId, ecsClusterName, albLogBucketName, - cacclDeployVersion, + cacclDeployVersion: CACCL_DEPLOY_VERSION, deployConfigHash, stackName: cfnStackName, awsAccountId: await getAccountId(), @@ -43,9 +54,9 @@ const stackOperation = async (cmd: any) => { deployConfig, }; - const envAdditions = { + const envAdditions: EnvAdditions = { AWS_REGION: process.env.AWS_REGION || 'us-east-1', - CDK_DISABLE_VERSION_CHECK: true, + CDK_DISABLE_VERSION_CHECK: 'true', }; // all args/options following the `stack` subcommand get passed to cdk @@ -60,7 +71,7 @@ const stackOperation = async (cmd: any) => { if (!stackExists) { exitWithError(`Stack ${cfnStackName} has not been deployed yet`); } - const stackExports = await aws.getCfnStackExports(cfnStackName); + const stackExports = await getCfnStackExports(cfnStackName); exitWithSuccess(JSON.stringify(stackExports, null, ' ')); } else if (cdkArgs[0] === 'changeset') { cdkArgs.shift(); @@ -117,7 +128,7 @@ const stackOperation = async (cmd: any) => { envAdditions.CDK_STACK_PROPS_FILE_PATH = tempPath; const execOpts = { - stdio: 'inherit', + stdio: 'inherit' as const, // exec the cdk process in the cdk directory cwd: __dirname, // path.join(__dirname, 'cdk'), // inject our additional env vars @@ -128,7 +139,11 @@ const stackOperation = async (cmd: any) => { execSync(['node_modules/.bin/cdk', ...cdkArgs].join(' '), execOpts); exitWithSuccess('done!'); } catch (err) { - exitWithError(err.msg); + const message = + err instanceof Error + ? err.message + : `Error while executing CDK: ${err}`; + exitWithError(message); } }, ); diff --git a/src/commands/operations/updateOperation.ts b/src/commands/operations/updateOperation.ts index 13f7dfc..9fbae3d 100644 --- a/src/commands/operations/updateOperation.ts +++ b/src/commands/operations/updateOperation.ts @@ -28,7 +28,8 @@ const updateOperation = async (cmd: any) => { await deployConfig.update(cmd.getAppPrefix(), param, value); } } catch (err) { - exitWithError(`Something went wrong: ${err.message}`); + const message = err instanceof Error ? err.message : `${err}`; + exitWithError(`Something went wrong: ${message}`); } }; diff --git a/src/configPrompts/promptAppImage.ts b/src/configPrompts/promptAppImage.ts index a3d9011..15a5d53 100644 --- a/src/configPrompts/promptAppImage.ts +++ b/src/configPrompts/promptAppImage.ts @@ -4,6 +4,7 @@ import { Choice } from 'prompts'; // Import aws import prompt from './prompt'; import { + AssumedRole, createEcrArn, getCurrentRegion, getRepoImageList, @@ -18,7 +19,7 @@ import looksLikeSemver from '../shared/helpers/looksLikeSemver'; // Import helpers -const promptAppImage = async () => { +const promptAppImage = async (assumedRole: AssumedRole) => { const inputType = await prompt({ type: 'select', name: 'value', @@ -47,7 +48,7 @@ const promptAppImage = async () => { } case 'select': { // TODO: deal with AssumedRole - const repoList = await getRepoList(); + const repoList = await getRepoList(assumedRole); const repoChoices = repoList.flatMap((value) => { if (!value) return []; return { @@ -67,7 +68,7 @@ const promptAppImage = async () => { choices: repoChoices, }); - const images = await getRepoImageList(repoChoice.value); + const images = await getRepoImageList(assumedRole, repoChoice.value); const imageTagsChoices = images.reduce((choices: Choice[], image) => { const releaseTag = image.imageTags && @@ -77,6 +78,10 @@ const promptAppImage = async () => { if (!releaseTag) return choices; + if (!image.registryId) { + throw new Error('Could not get ECR image registry ID.'); + } + const appImageValue = createEcrArn({ region: getCurrentRegion(), account: image.registryId, diff --git a/src/deployConfig/index.ts b/src/deployConfig/index.ts index 635649d..7220ccc 100644 --- a/src/deployConfig/index.ts +++ b/src/deployConfig/index.ts @@ -13,6 +13,7 @@ import { DeployConfigData } from '../../types'; // Import from aws import { + AssumedRole, deleteSecrets, deleteSsmParameters, getSsmParametersByPrefix, @@ -92,6 +93,7 @@ namespace DeployConfig { }; export const generate = async ( + assumedRole: AssumedRole, baseConfig: Record = {}, ): Promise => { const newConfig = { ...baseConfig }; @@ -105,7 +107,7 @@ namespace DeployConfig { } if (newConfig.appImage === undefined) { - newConfig.appImage = await promptAppImage(); + newConfig.appImage = await promptAppImage(assumedRole); } newConfig.tags = await promptKeyValuePairs(