From 97cf1714207d44d9a0817bcd383728692df5a1aa Mon Sep 17 00:00:00 2001 From: droguljic <1875821+droguljic@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:06:27 +0200 Subject: [PATCH] feat: deep merge plain objects in `mergeWithDefaults` Previously, plain objects passed as args would completely overwrite their corresponding defaults, causing any omitted keys to be lost. `mergeWithDefaults` now recursively merges plain objects, so default values for unspecified keys are preserved. --- src/components/ecs-service/README.md | 46 +++--- src/components/grafana/dashboards/README.md | 44 ++--- src/components/web-server/README.md | 79 +++++---- src/shared/merge-with-defaults.test.ts | 172 ++++++++++++++++++++ src/shared/merge-with-defaults.ts | 60 ++++++- 5 files changed, 308 insertions(+), 93 deletions(-) create mode 100644 src/shared/merge-with-defaults.test.ts diff --git a/src/components/ecs-service/README.md b/src/components/ecs-service/README.md index d8127730..e75a3afd 100644 --- a/src/components/ecs-service/README.md +++ b/src/components/ecs-service/README.md @@ -61,8 +61,8 @@ const service = new studion.EcsService('api', { desiredCount: 2, enableServiceAutoDiscovery: true, autoscaling: { + // Partial autoscaling config keeps default min/max counts enabled: true, - minCount: 2, maxCount: 6, }, volumes: [{ name: 'shared-data' }], @@ -103,7 +103,7 @@ export const persistentMountTargetIds = ecsService.persistentStorage.apply( - The CloudWatch log group always uses `retentionInDays: 14` and defaults `namePrefix` to `/ecs/${name}-` unless `logGroupNamePrefix` is provided. - The service always uses `launchType: 'FARGATE'` and `enableExecuteCommand: true`. - The task definition always uses `networkMode: 'awsvpc'` and `requiresCompatibilities: ['FARGATE']`. -- Default ECS settings are `deploymentController: 'ECS'`, `desiredCount: 1`, `size: 'small'`, `assignPublicIp: false`, service discovery disabled, and autoscaling disabled with min/max counts of `1`. +- Default ECS settings are `deploymentController: 'ECS'`, `desiredCount: 1`, `size: 'small'`, `assignPublicIp: false`, service discovery disabled, and autoscaling disabled with min/max counts of `1`. Partial `autoscaling` objects preserve omitted defaults, so `autoscaling: { enabled: true }` keeps `minCount: 1` and `maxCount: 1` unless you override them. - Network placement depends on `assignPublicIp`: public subnets are used when it is `true`, otherwise private subnets are used. - Autoscaling, when enabled, is fixed to target-tracking policies for `ECSServiceAverageMemoryUtilization` and `ECSServiceAverageCPUUtilization`, both with `targetValue: 70`. - Declaring any volumes creates one shared encrypted EFS file system, one access point rooted at `/data`, and exposes the resulting storage resources through `persistentStorage`; all logical ECS volumes map to that same backing storage. @@ -146,27 +146,27 @@ class EcsService extends pulumi.ComponentResource { Direct constructor input: `args: EcsService.Args` -| Property | Description | -| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| `cluster`\*
`pulumi.Input` | ECS cluster that will run the service. | -| `vpc`\*
`pulumi.Input` | Provides subnets, VPC ID, and CIDR-based security group rules. | -| `containers`\*
`EcsService.Container[]` | Full task container list. | -| `loadBalancers`
`pulumi.Input` | Optional ECS service-to-target-group registrations. | -| `volumes`
`pulumi.Input[]>` | Any non-empty value triggers creation of shared EFS-backed persistent storage. Default: `[]`. | -| `name`
`pulumi.Input` | ECS service name override. Default: component name. | -| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | ECS deployment controller type. Default: `'ECS'`. | -| `desiredCount`
`pulumi.Input` | Initial desired task count. Default: `1`. | -| `family`
`pulumi.Input` | Task definition family. Default: `-task-definition-`. | -| `size`
`pulumi.Input` | CPU/memory preset or explicit `{ cpu, memory }` object. Default: `'small'`. | -| `logGroupNamePrefix`
`pulumi.Input` | Passed to `aws.cloudwatch.LogGroup.namePrefix`. Default: `/ecs/-`. | -| `securityGroup`
`pulumi.Input` | If omitted, the component creates a VPC-wide internal service security group. Default: generated default SG. | -| `assignPublicIp`
`boolean` | Selects public subnets when `true`, otherwise private subnets. Default: `false`. | -| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated execution role. Default: `[]`. | -| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated task role. Default: `[]`. | -| `enableServiceAutoDiscovery`
`boolean` | Creates a private DNS namespace and Cloud Map service. Default: `false`. | -| `autoscaling`
`pulumi.Input<{ enabled: boolean; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Default: disabled. | -| `region`
`pulumi.Input` | AWS region used for region-specific ECS settings such as CloudWatch Logs. Default: active AWS provider's region. | -| `tags`
`pulumi.Input` | Additional tags merged with the package common tags. | +| Property | Description | +| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cluster`\*
`pulumi.Input` | ECS cluster that will run the service. | +| `vpc`\*
`pulumi.Input` | Provides subnets, VPC ID, and CIDR-based security group rules. | +| `containers`\*
`EcsService.Container[]` | Full task container list. | +| `loadBalancers`
`pulumi.Input` | Optional ECS service-to-target-group registrations. | +| `volumes`
`pulumi.Input[]>` | Any non-empty value triggers creation of shared EFS-backed persistent storage. Default: `[]`. | +| `name`
`pulumi.Input` | ECS service name override. Default: component name. | +| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | ECS deployment controller type. Default: `'ECS'`. | +| `desiredCount`
`pulumi.Input` | Initial desired task count. Default: `1`. | +| `family`
`pulumi.Input` | Task definition family. Default: `-task-definition-`. | +| `size`
`pulumi.Input` | CPU/memory preset or explicit `{ cpu, memory }` object. Default: `'small'`. | +| `logGroupNamePrefix`
`pulumi.Input` | Passed to `aws.cloudwatch.LogGroup.namePrefix`. Default: `/ecs/-`. | +| `securityGroup`
`pulumi.Input` | If omitted, the component creates a VPC-wide internal service security group. Default: generated default SG. | +| `assignPublicIp`
`boolean` | Selects public subnets when `true`, otherwise private subnets. Default: `false`. | +| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated execution role. Default: `[]`. | +| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated task role. Default: `[]`. | +| `enableServiceAutoDiscovery`
`boolean` | Creates a private DNS namespace and Cloud Map service. Default: `false`. | +| `autoscaling`
`pulumi.Input<{ enabled: boolean; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Partial configs preserve omitted nested defaults. Default: `{ enabled: false, minCount: 1, maxCount: 1 }`. | +| `region`
`pulumi.Input` | AWS region used for region-specific ECS settings such as CloudWatch Logs. Default: active AWS provider's region. | +| `tags`
`pulumi.Input` | Additional tags merged with the package common tags. | **Outputs** diff --git a/src/components/grafana/dashboards/README.md b/src/components/grafana/dashboards/README.md index d6d2abc5..72476c63 100644 --- a/src/components/grafana/dashboards/README.md +++ b/src/components/grafana/dashboards/README.md @@ -82,13 +82,13 @@ export const dashboardFactories = [logsAndTracesDashboard, customDashboard]; - `GrafanaDashboardBuilder.build()` throws unless both a title and at least one panel have been provided. - `GrafanaDashboardBuilder.withConfig()` overwrites the builder's stored dashboard configuration object; defaults are merged only during `build()`. - `build()` returns a factory function, not a dashboard resource. The factory creates `grafana.oss.Dashboard` named `${name}-dashboard` when called by `Grafana`, optionally assigning `folder.uid` to the dashboard. -- Default dashboard configuration is `timezone: 'browser'` and `refresh: '10s'`. +- Default dashboard configuration is `timezone: 'browser'` and `refresh: '10s'`. Partial dashboard config objects preserve omitted defaults. - Dashboard JSON is produced with `pulumi.jsonStringify({ title, timezone, refresh, panels, templating: { list: variables } })`, so panel objects and dashboard variables are passed through exactly as accumulated by `addPanel()` and `addVariable()`. - `createSloDashboard()` composes fixed SLO panels from `grafana.panels` helpers and Prometheus query helpers. - `createSloDashboard()` defaults to `target: 0.99`, `window: '30d'`, `shortWindow: '5m'`, `targetLatency: 250`, and no dashboard-config overrides. - The latency burn-rate panel created by `createSloDashboard()` follows the current `createLatencyBurnRatePanel()` configuration shape: it is namespace-wide and does not add the dashboard `filter`. Use a custom `DashboardBuilder` panel when you need route or label filtered latency burn-rate visualization. - `createLogsAndTracesDashboard()` composes dashboard variables, a logs table panel, and a traces panel. -- `createLogsAndTracesDashboard()` defaults the title to `'Logs & Traces'` and dashboard config to `{ refresh: '1m' }`, which overrides the builder's default `refresh: '10s'` while leaving other builder defaults, such as `timezone: 'browser'`, in place. +- `createLogsAndTracesDashboard()` defaults the title to `'Logs & Traces'` and dashboard config to `{ refresh: '1m' }`, which overrides the builder's default `refresh: '10s'` while leaving other builder defaults, such as `timezone: 'browser'`, in place. If you supply partial `dashboardConfig` values, omitted fields keep their defaults. ## API Reference @@ -178,18 +178,18 @@ function createSloDashboard( Direct function input: `config: SloDashboard.Args` -| Property | Description | -| ----------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. | -| `title`\*
`string` | Grafana dashboard title. | -| `ampNamespace`\*
`string` | Metric namespace prefix passed to the Prometheus query helpers. | -| `filter`\*
`string` | Prometheus label selector fragment used by most generated SLO queries. | -| `dataSourceName`\*
`string` | Grafana data source name referenced by the generated panels. | -| `target`
`number` | SLO target ratio. Default: `0.99`. | -| `window`
`promQ.TimeRange` | Long PromQL range used by summary panels. Default: `'30d'`. | -| `shortWindow`
`promQ.TimeRange` | Short PromQL range used by time-series panels. Default: `'5m'`. | -| `targetLatency`
`number` | Millisecond latency bucket threshold used by latency compliance queries. Default: `250`. | -| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Default: `{}`. | +| Property | Description | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. | +| `title`\*
`string` | Grafana dashboard title. | +| `ampNamespace`\*
`string` | Metric namespace prefix passed to the Prometheus query helpers. | +| `filter`\*
`string` | Prometheus label selector fragment used by most generated SLO queries. | +| `dataSourceName`\*
`string` | Grafana data source name referenced by the generated panels. | +| `target`
`number` | SLO target ratio. Default: `0.99`. | +| `window`
`promQ.TimeRange` | Long PromQL range used by summary panels. Default: `'30d'`. | +| `shortWindow`
`promQ.TimeRange` | Short PromQL range used by time-series panels. Default: `'5m'`. | +| `targetLatency`
`number` | Millisecond latency bucket threshold used by latency compliance queries. Default: `250`. | +| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Partial configs preserve omitted defaults. Default: `{}`. | **Return Value** @@ -211,14 +211,14 @@ function createLogsAndTracesDashboard( Direct function input: `config: LogsAndTracesDashboard.Args` -| Property | Description | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. | -| `title`
`string` | Grafana dashboard title. Default: `'Logs & Traces'`. | -| `logsDataSourceName`\*
`string` | Grafana CloudWatch Logs data source name referenced by the logs table panel. | -| `logGroupName`\*
`pulumi.Input` | CloudWatch log group name queried by the logs table panel. | -| `tracesDataSourceName`\*
`string` | Grafana X-Ray data source name referenced by trace links and the traces panel. | -| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Default: `{ refresh: '1m' }`. | +| Property | Description | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. | +| `title`
`string` | Grafana dashboard title. Default: `'Logs & Traces'`. | +| `logsDataSourceName`\*
`string` | Grafana CloudWatch Logs data source name referenced by the logs table panel. | +| `logGroupName`\*
`pulumi.Input` | CloudWatch log group name queried by the logs table panel. | +| `tracesDataSourceName`\*
`string` | Grafana X-Ray data source name referenced by trace links and the traces panel. | +| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Partial configs preserve omitted defaults. Default: `{ refresh: '1m' }`. | **Return Value** diff --git a/src/components/web-server/README.md b/src/components/web-server/README.md index e5104608..2ec6463c 100644 --- a/src/components/web-server/README.md +++ b/src/components/web-server/README.md @@ -76,8 +76,6 @@ const webServer = new studion.WebServer('platform-api', { hostedZoneId: hostedZone.zoneId, healthCheckPath: '/readyz', healthCheckConfig: { - healthyThreshold: 5, - unhealthyThreshold: 3, interval: 15, timeout: 3, }, @@ -137,8 +135,7 @@ export const ecsServiceArn = webServer.service.apply( - When `otelCollector` is provided, collector task-role policy fragments are combined with `taskRoleInlinePolicies` and passed to the nested `EcsService` as task-role inline policies. `taskExecutionRoleInlinePolicies` are forwarded separately as execution-role inline 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 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. +- Target-group health checks use `healthCheckPath` for `healthCheck.path` and recursively merge partial `healthCheckConfig` plain-object values into the remaining target-group health-check defaults. The component default config is `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`, so omitted fields keep those defaults. - 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. @@ -173,35 +170,35 @@ class WebServer extends pulumi.ComponentResource { Direct constructor input: `args: WebServer.Args` -| Property | Description | -| -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `image`\*
`pulumi.Input` | Main application container image. | -| `port`\*
`pulumi.Input` | Main application container port. | -| `environment`
`pulumi.Input` | Static environment variables for the main container. | -| `secrets`
`pulumi.Input` | ECS secret references for the main container. | -| `mountPoints`
`EcsService.PersistentStorageMountPoint[]` | Persistent storage mounts for the main container. | -| `cluster`\*
`pulumi.Input` | ECS cluster used by the nested `EcsService`. | -| `vpc`\*
`pulumi.Input` | Source of public subnets for the ALB and network data for ECS. | -| `volumes`
`pulumi.Input[]>` | Logical ECS volumes passed into the nested `EcsService`. Default: `[]`. | -| `name`
`pulumi.Input` | Optional ECS service name override forwarded to `EcsService`. Default: `EcsService` default. | -| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | Optional ECS deployment controller. Default: `EcsService` default. | -| `desiredCount`
`pulumi.Input` | Desired task count for the nested ECS service. Default: `EcsService` default. | -| `autoscaling`
`pulumi.Input<{ enabled: pulumi.Input; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Default: `EcsService` default. | -| `family`
`pulumi.Input` | Optional task definition family override. Default: `EcsService` default. | -| `size`
`pulumi.Input` | ECS CPU/memory preset or explicit size object. Default: `EcsService` default. | -| `logGroupNamePrefix`
`pulumi.Input` | CloudWatch log group name prefix forwarded to `EcsService`. Default: `EcsService` default. | -| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra execution-role inline policies forwarded to the nested `EcsService`. | -| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra task-role inline policies combined with OTEL collector policy fragments, when configured, and forwarded to the nested `EcsService`. | -| `tags`
`pulumi.Input<{ [key: string]: pulumi.Input }>` | Extra tags forwarded to nested ECS resources. | -| `domain`
`pulumi.Input` | Custom DNS name for the ALB endpoint. | -| `certificate`
`pulumi.Input` | Existing ACM certificate for TLS termination. | -| `hostedZoneId`
`pulumi.Input` | Required: whenever `domain` or `certificate` is provided. | -| `healthCheckPath`
`pulumi.Input` | ALB target-group health-check path. Default: `'/healthcheck'`. | -| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`; `path` is controlled by `healthCheckPath`. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. | -| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the ALB target group. Default: AWS default. | -| `initContainers`
`pulumi.Input[]>` | Additional init containers. Default: `[]`. | -| `sidecarContainers`
`pulumi.Input[]>` | Additional sidecars. Default: `[]`. | -| `otelCollector`
`pulumi.Input` | Collector integration that contributes containers, volumes, and IAM policy fragments. | +| Property | Description | +| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `image`\*
`pulumi.Input` | Main application container image. | +| `port`\*
`pulumi.Input` | Main application container port. | +| `environment`
`pulumi.Input` | Static environment variables for the main container. | +| `secrets`
`pulumi.Input` | ECS secret references for the main container. | +| `mountPoints`
`EcsService.PersistentStorageMountPoint[]` | Persistent storage mounts for the main container. | +| `cluster`\*
`pulumi.Input` | ECS cluster used by the nested `EcsService`. | +| `vpc`\*
`pulumi.Input` | Source of public subnets for the ALB and network data for ECS. | +| `volumes`
`pulumi.Input[]>` | Logical ECS volumes passed into the nested `EcsService`. Default: `[]`. | +| `name`
`pulumi.Input` | Optional ECS service name override forwarded to `EcsService`. Default: `EcsService` default. | +| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | Optional ECS deployment controller. Default: `EcsService` default. | +| `desiredCount`
`pulumi.Input` | Desired task count for the nested ECS service. Default: `EcsService` default. | +| `autoscaling`
`pulumi.Input<{ enabled: pulumi.Input; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Default: `EcsService` default. | +| `family`
`pulumi.Input` | Optional task definition family override. Default: `EcsService` default. | +| `size`
`pulumi.Input` | ECS CPU/memory preset or explicit size object. Default: `EcsService` default. | +| `logGroupNamePrefix`
`pulumi.Input` | CloudWatch log group name prefix forwarded to `EcsService`. Default: `EcsService` default. | +| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra execution-role inline policies forwarded to the nested `EcsService`. | +| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra task-role inline policies combined with OTEL collector policy fragments, when configured, and forwarded to the nested `EcsService`. | +| `tags`
`pulumi.Input<{ [key: string]: pulumi.Input }>` | Extra tags forwarded to nested ECS resources. | +| `domain`
`pulumi.Input` | Custom DNS name for the ALB endpoint. | +| `certificate`
`pulumi.Input` | Existing ACM certificate for TLS termination. | +| `hostedZoneId`
`pulumi.Input` | Required: whenever `domain` or `certificate` is provided. | +| `healthCheckPath`
`pulumi.Input` | ALB target-group health-check path. Default: `'/healthcheck'`. | +| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`; `path` is controlled by `healthCheckPath`. Partial configs preserve omitted defaults. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. | +| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the ALB target group. Default: AWS default. | +| `initContainers`
`pulumi.Input[]>` | Additional init containers. Default: `[]`. | +| `sidecarContainers`
`pulumi.Input[]>` | Additional sidecars. Default: `[]`. | +| `otelCollector`
`pulumi.Input` | Collector integration that contributes containers, volumes, and IAM policy fragments. | **Outputs** @@ -370,14 +367,14 @@ class WebServerLoadBalancer extends pulumi.ComponentResource { Direct constructor input: `args: WebServerLoadBalancer.Args` -| Property | Description | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `vpc`\*
`pulumi.Input` | VPC whose public subnets host the ALB. | -| `port`\*
`pulumi.Input` | Target-group port. | -| `certificate`
`pulumi.Input` | Enables a TLS listener and HTTP-to-HTTPS redirect. | -| `healthCheckPath`
`pulumi.Input` | Target-group health-check path. Default: `'/healthcheck'`. | -| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. | -| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the target group. Default: AWS default. | +| Property | Description | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vpc`\*
`pulumi.Input` | VPC whose public subnets host the ALB. | +| `port`\*
`pulumi.Input` | Target-group port. | +| `certificate`
`pulumi.Input` | Enables a TLS listener and HTTP-to-HTTPS redirect. | +| `healthCheckPath`
`pulumi.Input` | Target-group health-check path. Default: `'/healthcheck'`. | +| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`. Partial configs preserve omitted defaults. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. | +| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the target group. Default: AWS default. | **Outputs** diff --git a/src/shared/merge-with-defaults.test.ts b/src/shared/merge-with-defaults.test.ts new file mode 100644 index 00000000..a6a56981 --- /dev/null +++ b/src/shared/merge-with-defaults.test.ts @@ -0,0 +1,172 @@ +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { mergeWithDefaults } from './merge-with-defaults'; + +describe('mergeWithDefaults', () => { + it('should preserve flat merge behavior and ignore top-level undefined values', () => { + const result = mergeWithDefaults( + { hostname: 'localhost', port: 80 }, + { port: undefined, protocol: 'https' }, + ); + + assert.deepEqual(result, { + hostname: 'localhost', + port: 80, + protocol: 'https', + }); + }); + + it('should deeply merge nested plain objects', () => { + const result = mergeWithDefaults( + { + image: 'api:latest', + autoscaling: { + enabled: false, + minCount: 1, + maxCount: 1, + }, + }, + { + autoscaling: { + enabled: true, + }, + }, + ); + + assert.deepEqual(result, { + image: 'api:latest', + autoscaling: { + enabled: true, + minCount: 1, + maxCount: 1, + }, + }); + }); + + it('should ignore undefined values in nested objects', () => { + const result = mergeWithDefaults( + { + nested: { + enabled: false, + count: 1, + }, + }, + { + nested: { + enabled: undefined, + count: 2, + }, + }, + ); + + assert.deepEqual(result, { + nested: { + enabled: false, + count: 2, + }, + }); + }); + + it('should replace arrays instead of concatenating them', () => { + const result = mergeWithDefaults( + { + environment: ['DEFAULT_ENV'], + nested: { + secrets: ['DEFAULT_SECRET'], + }, + }, + { + environment: ['CUSTOM_ENV'], + nested: { + secrets: ['CUSTOM_SECRET'], + }, + }, + ); + + assert.deepEqual(result, { + environment: ['CUSTOM_ENV'], + nested: { + secrets: ['CUSTOM_SECRET'], + }, + }); + }); + + it('should treat null as an explicit override', () => { + const result = mergeWithDefaults( + { + nested: { + value: 'default', + } as { value: string } | null, + }, + { + nested: null, + }, + ); + + assert.deepEqual(result, { + nested: null, + }); + }); + + it('should treat non-plain objects as atomic replacement values', () => { + class Config { + constructor(readonly value: string) {} + } + + const defaultConfig = new Config('default'); + const customConfig = new Config('custom'); + + const result = mergeWithDefaults( + { + nested: { + config: defaultConfig, + enabled: false, + }, + }, + { + nested: { + config: customConfig, + }, + }, + ); + + assert.equal(result.nested.config, customConfig); + assert.deepEqual(result, { + nested: { + config: customConfig, + enabled: false, + }, + }); + }); + + it('should not mutate defaults or args while merging', () => { + const defaults = { + nested: { + enabled: false, + count: 1, + }, + }; + const args = { + nested: { + enabled: true, + }, + }; + + const result = mergeWithDefaults(defaults, args); + + assert.notEqual(result, defaults); + assert.notEqual(result.nested, defaults.nested); + assert.notEqual(result.nested, args.nested); + assert.deepEqual(defaults, { + nested: { + enabled: false, + count: 1, + }, + }); + assert.deepEqual(args, { + nested: { + enabled: true, + }, + }); + }); +}); diff --git a/src/shared/merge-with-defaults.ts b/src/shared/merge-with-defaults.ts index f6a20f8f..e8b68d21 100644 --- a/src/shared/merge-with-defaults.ts +++ b/src/shared/merge-with-defaults.ts @@ -1,10 +1,56 @@ +type UnknownRecord = Record; + +function isPlainObject(value: unknown): value is UnknownRecord { + if (value === null || typeof value !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; +} + +function mergeObjects( + defaults: UnknownRecord, + args: UnknownRecord, +): UnknownRecord { + const result: UnknownRecord = { ...defaults }; + + for (const [key, value] of Object.entries(args)) { + if (value === undefined) { + continue; + } + + const defaultValue = defaults[key]; + + result[key] = + isPlainObject(defaultValue) && isPlainObject(value) + ? mergeObjects(defaultValue, value) + : value; + } + + return result; +} + +/** + * Merges `args` into `defaults`, recursively merging plain-object values. + * + * `undefined` values in `args` are ignored, including inside nested objects. + * Arrays, `null`, and non-plain objects are treated as replacement values and + * are not recursively merged. + * + * @example + * const merged = mergeWithDefaults( + * { timeout: 3000, retry: { count: 3, delay: 500 } }, + * { retry: { count: 5 } }, + * ); + * + * console.log(merged); + * // { timeout: 3000, retry: { count: 5, delay: 500 } } + */ export function mergeWithDefaults< - T extends Record, - U extends Record, + T extends UnknownRecord, + U extends UnknownRecord, >(defaults: T, args: U): T & U { - const argsWithoutUndefined = Object.fromEntries( - Object.entries(args).filter(([, value]) => value !== undefined), - ) as U; - - return Object.assign({}, defaults, argsWithoutUndefined); + return mergeObjects(defaults, args) as T & U; }