diff --git a/src/components/web-server/README.md b/src/components/web-server/README.md index a8884f45..167ee671 100644 --- a/src/components/web-server/README.md +++ b/src/components/web-server/README.md @@ -2,7 +2,7 @@ The web-server module supplies components to expose ECS services via an internet-facing ALB, using a fluent builder interface for easy assembly. -Use it when a workload needs package-standard public HTTP/HTTPS ingress, load-balancer security groups, target-group wiring, health checks, and optional TLS/Route53 custom-domain support on top of `EcsService`. +Use it when a workload needs package-standard public HTTP/HTTPS ingress, load-balancer security groups, target-group wiring, configurable health checks, and optional TLS/Route53 custom-domain support on top of `EcsService`. ## Usage examples @@ -76,6 +76,12 @@ const webServer = new studion.WebServer('platform-api', { hostedZoneId: 'Z1234567890', domain: 'api.example.com', healthCheckPath: '/readyz', + healthCheckConfig: { + healthyThreshold: 5, + unhealthyThreshold: 3, + interval: 15, + timeout: 3, + }, loadBalancingAlgorithmType: 'round_robin', taskRoleInlinePolicies: [taskRolePolicy], volumes: [{ name: 'shared-data' }], @@ -132,7 +138,8 @@ export const ecsServiceArn = webServer.service.apply( - Current implementation note: task-role policy computation starts from `taskExecutionRoleInlinePolicies` and then appends OTEL task-role policy fragments before passing the result as `taskRoleInlinePolicies`; `taskRoleInlinePolicies` supplied directly to `WebServer` are not part of this merge, so review generated task-role policies when supplying custom execution-role policies. - The nested ECS service disables service discovery, sets `assignPublicIp: true`, and registers the main container with the load balancer target group. - `WebServerLoadBalancer` creates an internet-facing application load balancer in public subnets and defaults `healthCheckPath` to `'/healthcheck'`. -- Target group health checks always use HTTP with `healthyThreshold: 3`, `unhealthyThreshold: 2`, `interval: 60`, and `timeout: 5`; only the path and load-balancing algorithm are configurable. +- Target-group health checks use `healthCheckPath` for `healthCheck.path` and merge `healthCheckConfig` into the remaining target-group health-check settings. The component default config is `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. +- `healthCheckConfig` is shallow-merged through the component defaults; supplying it replaces the default health-check config object, so include every non-path health-check value you want to control explicitly. - If a certificate is provided to `WebServerLoadBalancer`, port `80` redirects to HTTPS and a TLS listener on port `443` is created with `ELBSecurityPolicy-TLS13-1-2-2021-06`. Without a certificate, port `80` forwards directly to the target group. - The load balancer security group always allows inbound TCP on ports `80` and `443` from `0.0.0.0/0` and allows all outbound traffic. - The web-server service security group allows all protocols and ports from the load balancer security group and allows all outbound traffic. @@ -167,34 +174,35 @@ class WebServer extends pulumi.ComponentResource { Direct constructor input: `args: WebServer.Args` -| Name | Type | Required | Default | Description | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------------------------------------------------------------------- | -| `image` | `pulumi.Input` | Yes | none | Main application container image. | -| `port` | `pulumi.Input` | Yes | none | Main application container port. | -| `environment` | `pulumi.Input` | No | none | Static environment variables for the main container. | -| `secrets` | `pulumi.Input` | No | none | ECS secret references for the main container. | -| `mountPoints` | `EcsService.PersistentStorageMountPoint[]` | No | none | Persistent storage mounts for the main container. | -| `cluster` | `pulumi.Input` | Yes | none | ECS cluster used by the nested `EcsService`. | -| `vpc` | `pulumi.Input` | Yes | none | Source of public subnets for the ALB and network data for ECS. | -| `volumes` | `pulumi.Input[]>` | No | `[]` | Logical ECS volumes passed into the nested `EcsService`. | -| `name` | `pulumi.Input` | No | `EcsService` default | Optional ECS service name override forwarded to `EcsService`. | -| `deploymentController` | `'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | No | `EcsService` default | Optional ECS deployment controller. | -| `desiredCount` | `pulumi.Input` | No | `EcsService` default | Desired task count for the nested ECS service. | -| `autoscaling` | `pulumi.Input<{ enabled: pulumi.Input; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | No | `EcsService` default | ECS target-tracking autoscaling configuration. | -| `family` | `pulumi.Input` | No | `EcsService` default | Optional task definition family override. | -| `size` | `pulumi.Input` | No | `EcsService` default | ECS CPU/memory preset or explicit size object. | -| `logGroupNamePrefix` | `pulumi.Input` | No | `EcsService` default | CloudWatch log group name prefix forwarded to `EcsService`. | -| `taskExecutionRoleInlinePolicies` | `pulumi.Input[]>` | No | none | Extra execution-role inline policies. | -| `taskRoleInlinePolicies` | `pulumi.Input[]>` | No | none | Extra task-role inline policies. | -| `tags` | `pulumi.Input<{ [key: string]: pulumi.Input }>` | No | none | Extra tags forwarded to nested ECS resources. | -| `domain` | `pulumi.Input` | No | none | Custom DNS name for the ALB endpoint. | -| `certificate` | `pulumi.Input` | No | none | Existing ACM certificate for TLS termination. | -| `hostedZoneId` | `pulumi.Input` | Conditionally required | none | Required whenever `domain` or `certificate` is provided. | -| `healthCheckPath` | `pulumi.Input` | No | `'/healthcheck'` | ALB target-group health-check path. | -| `loadBalancingAlgorithmType` | `pulumi.Input` | No | AWS default | Forwarded directly to the ALB target group. | -| `initContainers` | `pulumi.Input[]>` | No | `[]` | Additional init containers. | -| `sidecarContainers` | `pulumi.Input[]>` | No | `[]` | Additional sidecars. | -| `otelCollector` | `pulumi.Input` | No | none | Collector integration that contributes containers, volumes, and IAM policy fragments. | +| Name | Type | Required | Default | Description | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `image` | `pulumi.Input` | Yes | none | Main application container image. | +| `port` | `pulumi.Input` | Yes | none | Main application container port. | +| `environment` | `pulumi.Input` | No | none | Static environment variables for the main container. | +| `secrets` | `pulumi.Input` | No | none | ECS secret references for the main container. | +| `mountPoints` | `EcsService.PersistentStorageMountPoint[]` | No | none | Persistent storage mounts for the main container. | +| `cluster` | `pulumi.Input` | Yes | none | ECS cluster used by the nested `EcsService`. | +| `vpc` | `pulumi.Input` | Yes | none | Source of public subnets for the ALB and network data for ECS. | +| `volumes` | `pulumi.Input[]>` | No | `[]` | Logical ECS volumes passed into the nested `EcsService`. | +| `name` | `pulumi.Input` | No | `EcsService` default | Optional ECS service name override forwarded to `EcsService`. | +| `deploymentController` | `'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | No | `EcsService` default | Optional ECS deployment controller. | +| `desiredCount` | `pulumi.Input` | No | `EcsService` default | Desired task count for the nested ECS service. | +| `autoscaling` | `pulumi.Input<{ enabled: pulumi.Input; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | No | `EcsService` default | ECS target-tracking autoscaling configuration. | +| `family` | `pulumi.Input` | No | `EcsService` default | Optional task definition family override. | +| `size` | `pulumi.Input` | No | `EcsService` default | ECS CPU/memory preset or explicit size object. | +| `logGroupNamePrefix` | `pulumi.Input` | No | `EcsService` default | CloudWatch log group name prefix forwarded to `EcsService`. | +| `taskExecutionRoleInlinePolicies` | `pulumi.Input[]>` | No | none | Extra execution-role inline policies. | +| `taskRoleInlinePolicies` | `pulumi.Input[]>` | No | none | Extra task-role inline policies. | +| `tags` | `pulumi.Input<{ [key: string]: pulumi.Input }>` | No | none | Extra tags forwarded to nested ECS resources. | +| `domain` | `pulumi.Input` | No | none | Custom DNS name for the ALB endpoint. | +| `certificate` | `pulumi.Input` | No | none | Existing ACM certificate for TLS termination. | +| `hostedZoneId` | `pulumi.Input` | Conditionally required | none | Required whenever `domain` or `certificate` is provided. | +| `healthCheckPath` | `pulumi.Input` | No | `'/healthcheck'` | ALB target-group health-check path. | +| `healthCheckConfig` | `Omit` | No | `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }` | Target-group health-check settings other than `path`; `path` is controlled by `healthCheckPath`. | +| `loadBalancingAlgorithmType` | `pulumi.Input` | No | AWS default | Forwarded directly to the ALB target group. | +| `initContainers` | `pulumi.Input[]>` | No | `[]` | Additional init containers. | +| `sidecarContainers` | `pulumi.Input[]>` | No | `[]` | Additional sidecars. | +| `otelCollector` | `pulumi.Input` | No | none | Collector integration that contributes containers, volumes, and IAM policy fragments. | **Outputs** @@ -288,20 +296,20 @@ class WebServerBuilder { **Builder Methods** -| Method | Parameters | Description | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| `withContainer` | `image: WebServer.Container['image'], port: WebServer.Container['port'], config: Omit = {}` | Stores the main application container. | -| `withEcsConfig` | `config: WebServerBuilder.EcsConfig` | Stores ECS cluster and service configuration. | -| `withVpc` | `vpc: pulumi.Input` | Stores the required VPC. | -| `withVolume` | `volume: EcsService.PersistentStorageVolume` | Adds one logical ECS volume. | -| `withCustomDomain` | `domain: pulumi.Input, hostedZoneId: pulumi.Input` | Stores custom-domain settings and enables managed ACM flow. | -| `withCertificate` | `certificate: WebServer.Args['certificate'], hostedZoneId: pulumi.Input, domain?: pulumi.Input` | Stores an existing certificate and hosted zone configuration. | -| `addInitContainer` | `container: WebServer.InitContainer` | Adds one init container. | -| `addSidecarContainer` | `container: WebServer.SidecarContainer` | Adds one sidecar container. | -| `withOtelCollector` | `collector: OtelCollector` | Attaches collector-provided containers, volume, and IAM policy fragments. | -| `withCustomHealthCheckPath` | `path: WebServer.Args['healthCheckPath']` | Overrides the ALB health-check path. | -| `withLoadBalancingAlgorithm` | `algorithm: pulumi.Input` | Stores the target-group load-balancing algorithm. | -| `build` | `opts?: pulumi.ComponentResourceOptions` | Validates collected state and returns a `WebServer`. | +| Method | Parameters | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `withContainer` | `image: WebServer.Container['image'], port: WebServer.Container['port'], config: Omit = {}` | Stores the main application container. | +| `withEcsConfig` | `config: WebServerBuilder.EcsConfig` | Stores ECS cluster and service configuration. | +| `withVpc` | `vpc: pulumi.Input` | Stores the required VPC. | +| `withVolume` | `volume: EcsService.PersistentStorageVolume` | Adds one logical ECS volume. | +| `withCustomDomain` | `domain: pulumi.Input, hostedZoneId: pulumi.Input` | Stores custom-domain settings and enables managed ACM flow. | +| `withCertificate` | `certificate: WebServer.Args['certificate'], hostedZoneId: pulumi.Input, domain?: pulumi.Input` | Stores an existing certificate and hosted zone configuration. | +| `addInitContainer` | `container: WebServer.InitContainer` | Adds one init container. | +| `addSidecarContainer` | `container: WebServer.SidecarContainer` | Adds one sidecar container. | +| `withOtelCollector` | `collector: OtelCollector` | Attaches collector-provided containers, volume, and IAM policy fragments. | +| `withHealthCheck` | `path: WebServer.Args['healthCheckPath'], config?: WebServer.Args['healthCheckConfig']` | Stores the ALB health-check path and optional target-group health-check settings. | +| `withLoadBalancingAlgorithm` | `algorithm: pulumi.Input` | Stores the target-group load-balancing algorithm. | +| `build` | `opts?: pulumi.ComponentResourceOptions` | Validates collected state and returns a `WebServer`. | **Build Result** @@ -352,13 +360,14 @@ class WebServerLoadBalancer extends pulumi.ComponentResource { Direct constructor input: `args: WebServerLoadBalancer.Args` -| Name | Type | Required | Default | Description | -| ---------------------------- | ----------------------------------- | -------- | ---------------- | -------------------------------------------------- | -| `vpc` | `pulumi.Input` | Yes | none | VPC whose public subnets host the ALB. | -| `port` | `pulumi.Input` | Yes | none | Target-group port. | -| `certificate` | `pulumi.Input` | No | none | Enables a TLS listener and HTTP-to-HTTPS redirect. | -| `healthCheckPath` | `pulumi.Input` | No | `'/healthcheck'` | Target-group health-check path. | -| `loadBalancingAlgorithmType` | `pulumi.Input` | No | AWS default | Forwarded directly to the target group. | +| Name | Type | Required | Default | Description | +| ---------------------------- | --------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | ----------------------------------------------------- | +| `vpc` | `pulumi.Input` | Yes | none | VPC whose public subnets host the ALB. | +| `port` | `pulumi.Input` | Yes | none | Target-group port. | +| `certificate` | `pulumi.Input` | No | none | Enables a TLS listener and HTTP-to-HTTPS redirect. | +| `healthCheckPath` | `pulumi.Input` | No | `'/healthcheck'` | Target-group health-check path. | +| `healthCheckConfig` | `Omit` | No | `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }` | Target-group health-check settings other than `path`. | +| `loadBalancingAlgorithmType` | `pulumi.Input` | No | AWS default | Forwarded directly to the target group. | **Outputs** diff --git a/src/components/web-server/builder.ts b/src/components/web-server/builder.ts index ba0bbee5..1f45d5d3 100644 --- a/src/components/web-server/builder.ts +++ b/src/components/web-server/builder.ts @@ -18,6 +18,7 @@ export class WebServerBuilder { private _hostedZoneId?: pulumi.Input; private _certificate?: pulumi.Input; private _healthCheckPath?: pulumi.Input; + private _healthCheckConfig?: WebServer.Args['healthCheckConfig']; private _loadBalancingAlgorithmType?: pulumi.Input; private _otelCollector?: pulumi.Input; private _initContainers: pulumi.Input[] = []; @@ -112,10 +113,12 @@ export class WebServerBuilder { return this; } - public withCustomHealthCheckPath( + public withHealthCheck( path: WebServer.Args['healthCheckPath'], + config?: WebServer.Args['healthCheckConfig'], ): this { this._healthCheckPath = path; + this._healthCheckConfig = config; return this; } @@ -154,6 +157,7 @@ export class WebServerBuilder { hostedZoneId: this._hostedZoneId, certificate: this._certificate, healthCheckPath: this._healthCheckPath, + healthCheckConfig: this._healthCheckConfig, loadBalancingAlgorithmType: this._loadBalancingAlgorithmType, otelCollector: this._otelCollector, initContainers: this._initContainers, diff --git a/src/components/web-server/index.ts b/src/components/web-server/index.ts index 14c0c43b..f51f90e9 100644 --- a/src/components/web-server/index.ts +++ b/src/components/web-server/index.ts @@ -76,6 +76,10 @@ export namespace WebServer { * "/healthcheck" */ healthCheckPath?: pulumi.Input; + healthCheckConfig?: Omit< + aws.types.input.lb.TargetGroupHealthCheck, + 'path' + >; loadBalancingAlgorithmType?: pulumi.Input; initContainers?: pulumi.Input[]>; sidecarContainers?: pulumi.Input< @@ -133,6 +137,7 @@ export class WebServer extends pulumi.ComponentResource { port: args.port, certificate: certificate ?? this.acmCertificate?.certificate, healthCheckPath: args.healthCheckPath, + healthCheckConfig: args.healthCheckConfig, loadBalancingAlgorithmType: args.loadBalancingAlgorithmType, }, { diff --git a/src/components/web-server/load-balancer.ts b/src/components/web-server/load-balancer.ts index 33d15556..bc4bed92 100644 --- a/src/components/web-server/load-balancer.ts +++ b/src/components/web-server/load-balancer.ts @@ -10,6 +10,7 @@ export namespace WebServerLoadBalancer { port: pulumi.Input; certificate?: pulumi.Input; healthCheckPath?: pulumi.Input; + healthCheckConfig?: Omit; loadBalancingAlgorithmType?: pulumi.Input; }; } @@ -41,6 +42,12 @@ const webServerLoadBalancerNetworkConfig = { const defaults = { healthCheckPath: '/healthcheck', + healthCheckConfig: { + healthyThreshold: 3, + unhealthyThreshold: 2, + interval: 60, + timeout: 5, + }, }; export class WebServerLoadBalancer extends pulumi.ComponentResource { @@ -61,8 +68,13 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { this.name = name; const vpc = pulumi.output(args.vpc); const argsWithDefaults = mergeWithDefaults(defaults, args); - const { port, certificate, healthCheckPath, loadBalancingAlgorithmType } = - argsWithDefaults; + const { + port, + certificate, + healthCheckPath, + healthCheckConfig, + loadBalancingAlgorithmType, + } = argsWithDefaults; this.securityGroup = this.createLbSecurityGroup(vpc.vpcId); @@ -84,6 +96,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { port, vpc.vpcId, healthCheckPath, + healthCheckConfig, loadBalancingAlgorithmType, ); this.httpListener = this.createLbHttpListener( @@ -163,6 +176,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { port: pulumi.Input, vpcId: awsx.ec2.Vpc['vpcId'], healthCheckPath: pulumi.Input, + healthCheckConfig: WebServerLoadBalancer.Args['healthCheckConfig'], loadBalancingAlgorithmType?: pulumi.Input, ): aws.lb.TargetGroup { return new aws.lb.TargetGroup( @@ -175,10 +189,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { vpcId, loadBalancingAlgorithmType, healthCheck: { - healthyThreshold: 3, - unhealthyThreshold: 2, - interval: 60, - timeout: 5, + ...healthCheckConfig, path: healthCheckPath, }, tags: { ...commonTags, Name: `${this.name}-target-group` }, diff --git a/tests/build/index.tst.ts b/tests/build/index.tst.ts index bf05e490..778d413d 100644 --- a/tests/build/index.tst.ts +++ b/tests/build/index.tst.ts @@ -91,8 +91,8 @@ describe('Build output', () => { ); }); - it('should have withCustomHealthCheckPath method', () => { - expect(builder.withCustomHealthCheckPath).type.toBeCallableWith( + it('should have withHealthCheck method', () => { + expect(builder.withHealthCheck).type.toBeCallableWith( '/custom/healthCheck/path', ); }); diff --git a/tests/web-server/index.test.ts b/tests/web-server/index.test.ts index efff6fe9..e569f73c 100644 --- a/tests/web-server/index.test.ts +++ b/tests/web-server/index.test.ts @@ -131,6 +131,16 @@ describe('Web server component deployment', () => { ctx.config.healthCheckPath, 'Target group should have correct health check path', ); + assert.deepStrictEqual( + { + healthyThreshold: tg.HealthyThresholdCount, + unhealthyThreshold: tg.UnhealthyThresholdCount, + interval: tg.HealthCheckIntervalSeconds, + timeout: tg.HealthCheckTimeoutSeconds, + }, + ctx.config.healthCheckConfig, + 'Target group should have correct health check configuration', + ); const attributesCommand = new DescribeTargetGroupAttributesCommand({ TargetGroupArn: webServer.lb.targetGroup.arn, diff --git a/tests/web-server/infrastructure/config.ts b/tests/web-server/infrastructure/config.ts index 40f47ca2..75fc17b4 100644 --- a/tests/web-server/infrastructure/config.ts +++ b/tests/web-server/infrastructure/config.ts @@ -1,9 +1,18 @@ export const webServerName = 'web-server-test'; -export const healthCheckPath = '/healthcheck'; export const webServerImageName = 'nginxdemos/nginx-hello:plain-text'; + export const webServerPort = 8080; +export const healthCheckPath = '/healthcheck'; + +export const healthCheckConfig = { + healthyThreshold: 5, + unhealthyThreshold: 3, + interval: 15, + timeout: 3, +}; + const baseDomain = `ws.${process.env.ICB_DOMAIN_NAME!}`; export const webServerWithDomainConfig = { diff --git a/tests/web-server/infrastructure/index.ts b/tests/web-server/infrastructure/index.ts index 796872f0..4eb8501b 100644 --- a/tests/web-server/infrastructure/index.ts +++ b/tests/web-server/infrastructure/index.ts @@ -5,6 +5,7 @@ import * as util from '../../util'; import { webServerName, healthCheckPath, + healthCheckConfig, webServerWithDomainConfig, webServerWithSanCertificateConfig, webServerWithCertificateConfig, @@ -68,7 +69,7 @@ const webServer = new studion.WebServerBuilder(webServerName) .addSidecarContainer(sidecar) .withVpc(vpc.vpc) .withOtelCollector(otelCollector) - .withCustomHealthCheckPath(healthCheckPath) + .withHealthCheck(healthCheckPath, healthCheckConfig) .withLoadBalancingAlgorithm('least_outstanding_requests') .build({ parent: cluster }); @@ -81,7 +82,7 @@ const webServerWithDomain = new studion.WebServerBuilder(`web-server-domain`) .withContainer(webServerImageName, 8080) .withEcsConfig(ecs) .withVpc(vpc.vpc) - .withCustomHealthCheckPath(healthCheckPath) + .withHealthCheck(healthCheckPath) .withCustomDomain(webServerWithDomainConfig.primary, hostedZone.zoneId) .build({ parent: cluster }); @@ -100,7 +101,7 @@ const webServerWithSanCertificate = new studion.WebServerBuilder( .withContainer(webServerImageName, 8080) .withEcsConfig(ecs) .withVpc(vpc.vpc) - .withCustomHealthCheckPath(healthCheckPath) + .withHealthCheck(healthCheckPath) .withCertificate(sanWebServerCert.certificate, hostedZone.zoneId) .build({ parent: cluster, @@ -120,7 +121,7 @@ const webServerWithCertificate = new studion.WebServerBuilder(`web-server-cert`) .withContainer(webServerImageName, 8080) .withEcsConfig(ecs) .withVpc(vpc.vpc) - .withCustomHealthCheckPath(healthCheckPath) + .withHealthCheck(healthCheckPath) .withCertificate( certWebServer.certificate, hostedZone.zoneId, diff --git a/tests/web-server/test-context.ts b/tests/web-server/test-context.ts index 4086204f..57958e96 100644 --- a/tests/web-server/test-context.ts +++ b/tests/web-server/test-context.ts @@ -8,6 +8,12 @@ import { Route53Client } from '@aws-sdk/client-route-53'; interface WebServerTestConfig { webServerName: string; healthCheckPath: string; + healthCheckConfig: { + healthyThreshold: number; + unhealthyThreshold: number; + interval: number; + timeout: number; + }; webServerImageName: string; webServerPort: number; webServerWithDomainConfig: { @@ -42,6 +48,4 @@ interface AwsContext { } export interface WebServerTestContext - extends ConfigContext, - PulumiProgramContext, - AwsContext {} + extends ConfigContext, PulumiProgramContext, AwsContext {}