diff --git a/config/config.ts b/config/config.ts index 3280198..6684636 100644 --- a/config/config.ts +++ b/config/config.ts @@ -20,100 +20,88 @@ export interface Config { deployEnvironment: string; dbRetention: number; remsTokenSecretArn: string; - ampWorkspaceId: string; + ampWorkspaceId: string; monitoringAccountId: string; project?: string; owner?: string; - monitoringPrometheusRole?: string + monitoringPrometheusRole?: string; + requestorUrl: string; // URL of the Gen3 requestor service for REMS webhook callbacks } export function getConfig(): Config { - const deployEnv = process.env.DEPLOY_ENV || "dev"; - const isProd = deployEnv === "prod" || deployEnv === "production"; + const deployEnv = process.env.DEPLOY_ENV || "dev"; + const isProd = deployEnv === "prod" || deployEnv === "production"; - return { - project: process.env.PROJECT || "ACDC", - owner: process.env.OWNER || "biocloud", - deployEnvironment: deployEnv, - remsTokenSecretArn: process.env.REMS_TOKEN_ARN || "arn:aws:secretmanager:region:account:secret:rems-token-secret", - oidcClientSecretArn: - process.env.OIDC_SECRET_ARN || - "arn:aws:secretmanager:region:account:secret:rems-oidc-client-secret", - accountId: process.env.CDK_ACCOUNT_ID || "000000000000", - region: process.env.CDK_REGION || "ap-southeast-2", - vpcCidr: process.env.VPC_CIDR || "192.168.0.0/24", - publicUrl: process.env.PUBLIC_URL || "https://dev-rems.example.org/", - hostName: process.env.HOST_NAME || "dev-rems.example.org", - hostZone: process.env.HOST_ZONE || "example.org", - certificateArn: - process.env.CERTIFICATE_ARN || - "arn:aws:acm:region:account:certificate/dev", - containerImage: process.env.CONTAINER_IMAGE || "cscfi/rems:latest", - dbName: process.env.DB_NAME || "rems", - dbUser: process.env.DB_USER || "rems", - postgresVersion: getPostgresEngineVersion( - process.env.POSTGRES_VERSION || "17.4" - ), - dbInstanceSize: getDBInstanceSize( - process.env.DB_INSTANCE_SIZE || "micro" - ), - dbInstanceClass: getDBInstanceClass( - process.env.DB_INSTANCE_CLASS || "burstable3" - ), - dbRetention: deployEnv === "prod" || deployEnv === "production" ? 30 : 7, - natGatewayCount: - deployEnv === "prod" || deployEnv === "production" ? 3 : 1, - ampWorkspaceId: process.env.AMP_WORKSPACE_ID || "ws-XXXXXXXX", - monitoringAccountId: process.env.MONITORING_ACCOUNT_ID || "000000000000", - monitoringPrometheusRole: process.env.MONITORING_PROMETHEUS_ROLE || "arn:aws:iam::123456789012:role/MonitoringAccountPrometheusRole", - }; + return { + project: process.env.PROJECT || "ACDC", + owner: process.env.OWNER || "biocloud", + deployEnvironment: deployEnv, + remsTokenSecretArn: process.env.REMS_TOKEN_ARN || "arn:aws:secretmanager:region:account:secret:rems-token-secret", + oidcClientSecretArn: + process.env.OIDC_SECRET_ARN || + "arn:aws:secretmanager:region:account:secret:rems-oidc-client-secret", + accountId: process.env.CDK_ACCOUNT_ID || "000000000000", + region: process.env.CDK_REGION || "ap-southeast-2", + vpcCidr: process.env.VPC_CIDR || "192.168.0.0/24", + publicUrl: process.env.PUBLIC_URL || "https://dev-rems.example.org/", + hostName: process.env.HOST_NAME || "dev-rems.example.org", + hostZone: process.env.HOST_ZONE || "example.org", + certificateArn: + process.env.CERTIFICATE_ARN || + "arn:aws:acm:region:account:certificate/dev", + containerImage: process.env.CONTAINER_IMAGE || "cscfi/rems:latest", + dbName: process.env.DB_NAME || "rems", + dbUser: process.env.DB_USER || "rems", + postgresVersion: getPostgresEngineVersion( + process.env.POSTGRES_VERSION || "17.4" + ), + dbInstanceSize: getDBInstanceSize( + process.env.DB_INSTANCE_SIZE || "micro" + ), + dbInstanceClass: getDBInstanceClass( + process.env.DB_INSTANCE_CLASS || "burstable3" + ), + dbRetention: isProd ? 30 : 7, + natGatewayCount: isProd ? 3 : 1, + ampWorkspaceId: process.env.AMP_WORKSPACE_ID || "ws-XXXXXXXX", + monitoringAccountId: process.env.MONITORING_ACCOUNT_ID || "000000000000", + monitoringPrometheusRole: + process.env.MONITORING_PROMETHEUS_ROLE || + "arn:aws:iam::123456789012:role/MonitoringAccountPrometheusRole", + // REQUESTOR_URL env var — set per environment in CI/CD or .env: + // UAT: https://data.test.biocommons.org.au/requestor + // prod: https://commons.baker.edu.au/requestor + requestorUrl: + process.env.REQUESTOR_URL || "https://data.test.biocommons.org.au/requestor", + }; } - export function getPostgresEngineVersion(version: string): PostgresEngineVersion { switch (version) { - case "13.20": - return PostgresEngineVersion.VER_13_20; - case "14.9": - return PostgresEngineVersion.VER_14_9; - case "15.9": - return PostgresEngineVersion.VER_15_9; - case "16.8": - return PostgresEngineVersion.VER_16_8; - case "17.4": - return PostgresEngineVersion.VER_17_4; - default: - throw new Error(`Unsupported Postgres version: ${version}`); + case "13.20": return PostgresEngineVersion.VER_13_20; + case "14.9": return PostgresEngineVersion.VER_14_9; + case "15.9": return PostgresEngineVersion.VER_15_9; + case "16.8": return PostgresEngineVersion.VER_16_8; + case "17.4": return PostgresEngineVersion.VER_17_4; + default: throw new Error(`Unsupported Postgres version: ${version}`); } } -export function getDBInstanceSize( - size: string -): InstanceSize { +export function getDBInstanceSize(size: string): InstanceSize { switch (size.toLowerCase()) { - case "micro": - return InstanceSize.MICRO; - case "small": - return InstanceSize.SMALL; - case "medium": - return InstanceSize.MEDIUM; - case "large": - return InstanceSize.LARGE;; - default: - throw new Error(`Unsupported Postgres Instance size: ${size}`); + case "micro": return InstanceSize.MICRO; + case "small": return InstanceSize.SMALL; + case "medium": return InstanceSize.MEDIUM; + case "large": return InstanceSize.LARGE; + default: throw new Error(`Unsupported Postgres Instance size: ${size}`); } } export function getDBInstanceClass(cls: string): InstanceClass { switch (cls.toLowerCase()) { - case "burstable2": - return InstanceClass.BURSTABLE2; - case "burstable3": - return InstanceClass.BURSTABLE3; - case "memory": - return InstanceClass.MEMORY5; - default: - throw new Error(`Unsupported DB instance class: ${cls}`); + case "burstable2": return InstanceClass.BURSTABLE2; + case "burstable3": return InstanceClass.BURSTABLE3; + case "memory": return InstanceClass.MEMORY5; + default: throw new Error(`Unsupported DB instance class: ${cls}`); } -} - +} \ No newline at end of file diff --git a/lib/compute-stack.ts b/lib/compute-stack.ts index 9e9cc4c..e0d7e6c 100644 --- a/lib/compute-stack.ts +++ b/lib/compute-stack.ts @@ -42,13 +42,12 @@ export class ComputeStack extends Stack { const { vpc, config } = props; - const ampWorkspaceId = config.ampWorkspaceId; // AMP workspace ID (from monitoring account) - const monitoringAccountId = config.monitoringAccountId; // Monitoring account ID - const envName = config.deployEnvironment; // Environment name (prod/staging) + const ampWorkspaceId = config.ampWorkspaceId; + const monitoringAccountId = config.monitoringAccountId; + const envName = config.deployEnvironment; const region = this.region; const account = this.account; - // Parameter names for the configs we created const adotParam = `/rems/${envName}/adot-config`; const jmxParam = `/rems/${envName}/jmx-config`; @@ -57,87 +56,59 @@ export class ComputeStack extends Stack { console.log("env:", config.deployEnvironment, "AMP:", config.ampWorkspaceId, "MON:", config.monitoringAccountId); const validAmp = !!config.ampWorkspaceId && /^ws-[0-9a-f-]+$/i.test(config.ampWorkspaceId); - const validMonAcct = !!config.monitoringAccountId && /^[0-9]{12}$/.test(config.monitoringAccountId); if (isProd && (!validAmp || !validMonAcct)) { throw new Error("Prod requires valid ampWorkspaceId (ws-*) and monitoringAccountId (12 digits)."); } - this.cluster = new Cluster(this, "Cluster", { vpc, clusterName: "Rems" }); const dbSecretName = ssm.StringParameter.fromStringParameterAttributes( - this, - "DbSecretName", - { - parameterName: `/rems/${config.deployEnvironment}/db-secret-name`, - } + this, "DbSecretName", + { parameterName: `/rems/${config.deployEnvironment}/db-secret-name` } ); const smtpSecretName = ssm.StringParameter.fromStringParameterAttributes( - this, - "SmtpSecretName", - { - parameterName: `/rems/${config.deployEnvironment}/smtp-secret-name`, - } + this, "SmtpSecretName", + { parameterName: `/rems/${config.deployEnvironment}/smtp-secret-name` } ); const webAclArn = ssm.StringParameter.fromStringParameterAttributes( - this, - "webAclArn", - { - parameterName: `/rems/${config.deployEnvironment}/webAclArn`, - } + this, "webAclArn", + { parameterName: `/rems/${config.deployEnvironment}/webAclArn` } ); - const dbSecret = Secret.fromSecretNameV2( - this, - "DbSecret", - dbSecretName.stringValue - ); - - const smtpSecret = Secret.fromSecretNameV2( - this, - "SmtpSecret", - smtpSecretName.stringValue - ); + const dbSecret = Secret.fromSecretNameV2(this, "DbSecret", dbSecretName.stringValue); + const smtpSecret = Secret.fromSecretNameV2(this, "SmtpSecret", smtpSecretName.stringValue); const executionRole = new Role(this, "RemsExecutionRole", { assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com"), description: "Explicit execution role for REMS Fargate tasks", roleName: `${config.deployEnvironment}-rems-task-execution-role`, managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AmazonECSTaskExecutionRolePolicy" - ), + ManagedPolicy.fromAwsManagedPolicyName("service-role/AmazonECSTaskExecutionRolePolicy"), ], }); - const privateKeySecret = Secret.fromSecretNameV2( - this, - "PrivateKey", - "rems/visa/private-key.jwk" - ); - const publicKeySecret = Secret.fromSecretNameV2( - this, - "PublicKey", - "rems/visa/public-key.jwk" - ); + const privateKeySecret = Secret.fromSecretNameV2(this, "PrivateKey", "rems/visa/private-key.jwk"); + const publicKeySecret = Secret.fromSecretNameV2(this, "PublicKey", "rems/visa/public-key.jwk"); - const oidcSecret = Secret.fromSecretCompleteArn( - this, - "OidcSecret", - config.oidcClientSecretArn - ); + const oidcSecret = Secret.fromSecretCompleteArn(this, "OidcSecret", config.oidcClientSecretArn); + + // ── NEW: webhook shared secret ────────────────────────────────────────── + // Store the generated secret in Secrets Manager: + // aws secretsmanager create-secret \ + // --name "rems/webhook-secret" \ + // --secret-string "" \ + // --region ap-southeast-2 + const webhookSecret = Secret.fromSecretNameV2(this, "RemsWebhookSecret", "rems/webhook-secret"); + // ─────────────────────────────────────────────────────────────────────── executionRole.addToPolicy( new PolicyStatement({ effect: Effect.ALLOW, - actions: [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret", - ], + actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], resources: [ `arn:aws:secretsmanager:${this.region}:${this.account}:secret:rems/visa/*`, ], @@ -147,16 +118,25 @@ export class ComputeStack extends Stack { executionRole.addToPrincipalPolicy( new PolicyStatement({ effect: Effect.ALLOW, - actions: [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret", - ], + actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], resources: [ `arn:aws:secretsmanager:${this.region}:${this.account}:secret:rems-oidc-client-secret-??????`, ], }) ); + // ── NEW: grant executionRole access to webhook secret ─────────────────── + executionRole.addToPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], + resources: [ + `arn:aws:secretsmanager:${this.region}:${this.account}:secret:rems/*`, + ], + }) + ); + // ─────────────────────────────────────────────────────────────────────── + executionRole.addToPrincipalPolicy( new PolicyStatement({ effect: Effect.ALLOW, @@ -165,9 +145,7 @@ export class ComputeStack extends Stack { "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", ], - resources: [ - `arn:aws:ecr:${this.region}:${this.account}:repository/rems`, - ], + resources: [`arn:aws:ecr:${this.region}:${this.account}:repository/rems`], }) ); @@ -191,13 +169,11 @@ export class ComputeStack extends Stack { }) ); - // ---- TASK ROLE (app & collectors permissions AT RUNTIME) ---- const taskRole = new iam.Role(this, "RemsTaskRole", { assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"), description: "ECS task runtime role for REMS app + ADOT collector", }); - const taskDef = new FargateTaskDefinition(this, "TaskDef", { cpu: 512, memoryLimitMiB: 1024, @@ -205,16 +181,10 @@ export class ComputeStack extends Stack { executionRole, }); - // --- Main REMS application container --- - // Note: this is the main app container, not the ADOT collector - // It will run the REMS application itself - - // 1) Create a deterministic log group const logGroup = new LogGroup(this, "RemsLogGroup", { logGroupName: `/${config.project}/rems/${config.deployEnvironment}`, retention: RetentionDays.ONE_MONTH, - // In prod, prefer RETAIN to avoid losing logs on stack deletes/redeploys - removalPolicy: config.deployEnvironment === "prod" || config.deployEnvironment === "production" ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY, + removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY, }); const container = taskDef.addContainer("RemsContainer", { @@ -224,6 +194,8 @@ export class ComputeStack extends Stack { DB_USER: config.dbUser, PUBLIC_URL: config.publicUrl, CMD: "start", + REQUESTOR_URL: config.requestorUrl, + // ─────────────────────────────────────────────────────────────────── }, secrets: { DB_PASSWORD: ECSSecret.fromSecretsManager(dbSecret!, "password"), @@ -234,18 +206,11 @@ export class ComputeStack extends Stack { SMTP_USER: ECSSecret.fromSecretsManager(smtpSecret!, "username"), SMTP_PASSWORD: ECSSecret.fromSecretsManager(smtpSecret!, "password"), SMTP_SENDER: ECSSecret.fromSecretsManager(smtpSecret!, "sender"), - OIDC_METADATA_URL: ECSSecret.fromSecretsManager( - oidcSecret, - "oidc-metadata-url" - ), - OIDC_CLIENT_ID: ECSSecret.fromSecretsManager( - oidcSecret, - "oidc-client-id" - ), - OIDC_CLIENT_SECRET: ECSSecret.fromSecretsManager( - oidcSecret, - "oidc-client-secret" - ), + OIDC_METADATA_URL: ECSSecret.fromSecretsManager(oidcSecret, "oidc-metadata-url"), + OIDC_CLIENT_ID: ECSSecret.fromSecretsManager(oidcSecret, "oidc-client-id"), + OIDC_CLIENT_SECRET: ECSSecret.fromSecretsManager(oidcSecret, "oidc-client-secret"), + REMS_WEBHOOK_SECRET: ECSSecret.fromSecretsManager(webhookSecret), + // ─────────────────────────────────────────────────────────────────── }, portMappings: [{ containerPort: 3000 }], logging: LogDriver.awsLogs({ @@ -255,18 +220,15 @@ export class ComputeStack extends Stack { }), }); - // Shared volume for config files pulled from SSM const configVolumeName = "adot-config-vol"; if (isProd) { - // Allow ADOT to remote_write to AMP in the monitoring account taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["aps:RemoteWrite"], resources: [ `arn:aws:aps:${region}:${monitoringAccountId}:workspace/${ampWorkspaceId}`, ], })); - // Allow init container to read SSM params with configs taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["ssm:GetParameters", "ssm:GetParameter", "ssm:GetParametersByPath"], resources: [ @@ -274,7 +236,6 @@ export class ComputeStack extends Stack { `arn:aws:ssm:${region}:${account}:parameter${jmxParam}`, ], })); - taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["sts:AssumeRole"], resources: [config.monitoringPrometheusRole!], @@ -282,12 +243,10 @@ export class ComputeStack extends Stack { taskDef.addVolume({ name: configVolumeName }); - // Retrieve the ADOT config SSM parameter as an IParameter const adotConfigParam = ssm.StringParameter.fromStringParameterAttributes(this, "AdotConfigParam", { parameterName: adotParam, }); - // --- Init container: downloads configs from SSM → /config --- const configLoader = taskDef.addContainer("adot-config-loader", { image: ContainerImage.fromRegistry("amazon/aws-cli:2.15.47"), essential: false, @@ -301,7 +260,6 @@ export class ComputeStack extends Stack { `[ -s /opt/jmx/jmx_prometheus_javaagent.jar ]`, `echo "JMX config loaded to /config/jmx.yaml"`, `echo "JMX agent downloaded to /opt/jmx/jmx_prometheus_javaagent.jar"`, - // List contents for debugging "ls -l /config", "ls -l /opt/jmx", ].join(" && "), @@ -313,13 +271,12 @@ export class ComputeStack extends Stack { configLoader.addMountPoints({ containerPath: "/config", readOnly: false, sourceVolume: configVolumeName }); configLoader.addMountPoints({ containerPath: "/opt/jmx", readOnly: false, sourceVolume: configVolumeName }); - // --- ADOT collector sidecar (scrape → AMP) --- const adot = taskDef.addContainer("aws-otel-collector", { image: ContainerImage.fromRegistry("public.ecr.aws/aws-observability/aws-otel-collector:latest"), essential: true, environment: { AWS_REGION: region, - AMP_REGION: "ap-southeast-2", // Hardcoded for now + AMP_REGION: "ap-southeast-2", }, secrets: { AOT_CONFIG_CONTENT: ECSSecret.fromSsmParameter(adotConfigParam) @@ -328,15 +285,12 @@ export class ComputeStack extends Stack { }); adot.addMountPoints({ containerPath: "/config", readOnly: true, sourceVolume: configVolumeName }); - - // Ensure config is present before these start adot.addContainerDependencies({ container: configLoader, condition: ContainerDependencyCondition.SUCCESS }); container.addMountPoints( { containerPath: "/opt/jmx", sourceVolume: configVolumeName, readOnly: true }, { containerPath: "/config", readOnly: true, sourceVolume: configVolumeName } ); - container.addEnvironment("JAVA_TOOL_OPTIONS", "-javaagent:/opt/jmx/jmx_prometheus_javaagent.jar=9404:/config/jmx.yaml" ); @@ -346,16 +300,9 @@ export class ComputeStack extends Stack { }); } - container.addSecret( - "PRIVATE_KEY", - ECSSecret.fromSecretsManager(privateKeySecret) - ); - container.addSecret( - "PUBLIC_KEY", - ECSSecret.fromSecretsManager(publicKeySecret) - ); + container.addSecret("PRIVATE_KEY", ECSSecret.fromSecretsManager(privateKeySecret)); + container.addSecret("PUBLIC_KEY", ECSSecret.fromSecretsManager(publicKeySecret)); - // Create SG for Fargate const fargateSG = new SecurityGroup(this, "FargateSG", { vpc, allowAllOutbound: true, @@ -373,23 +320,15 @@ export class ComputeStack extends Stack { enableExecuteCommand: true, }); - const lb = new ApplicationLoadBalancer(this, "LB", { - vpc, - internetFacing: true, - }); + const lb = new ApplicationLoadBalancer(this, "LB", { vpc, internetFacing: true }); - const cert = Certificate.fromCertificateArn( - this, - "Cert", - config.certificateArn - ); + const cert = Certificate.fromCertificateArn(this, "Cert", config.certificateArn); const listener = lb.addListener("HttpsListener", { port: 443, certificates: [cert], }); - listener.addTargets("ECS", { port: 3000, protocol: ApplicationProtocol.HTTP, @@ -404,32 +343,25 @@ export class ComputeStack extends Stack { }, }); - - // Allow inbound from ALB on port 3000 fargateSG.addIngressRule( lb.connections.securityGroups[0], Port.tcp(3000), "Allow ALB to access REMS container" ); - // Associate WAF to alb new wafv2.CfnWebACLAssociation(this, "WafAssociation", { resourceArn: lb.loadBalancerArn, webAclArn: webAclArn.stringValue, }); - // Route 53 Alias Records const zone = route53.HostedZone.fromLookup(this, "HostedZone", { domainName: config.hostZone, }); new route53.ARecord(this, "RemsAliasRecord", { zone, - target: route53.RecordTarget.fromAlias( - new route53Targets.LoadBalancerTarget(lb) - ), + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(lb)), recordName: config.hostName, }); - } -} +} \ No newline at end of file