From f5a9f6142c40b21ff8a7ad6ffc04dce4534c4a39 Mon Sep 17 00:00:00 2001 From: Hutch Date: Wed, 9 Sep 2026 08:31:57 -0400 Subject: [PATCH 1/4] feat(agent_harness_service): remove legacy alb --- .../coding_agent_worker/default.macrod.toml | 2 +- crates/coding_agent_worker/src/config/test.rs | 4 +- .../src/tui/config_form.rs | 2 +- .../src/tui/config_form/test.rs | 18 +- .../packages/shared/src/gateway_priorities.ts | 2 + infra/packages/shared/src/service_urls.ts | 5 + .../agent_harness_service.ts | 194 +--------- infra/stacks/agent-harness-service/index.ts | 1 - .../agent-harness-service/legacy-alb.test.ts | 352 ++++++++++++++++++ services/agent_harness_service/src/api.rs | 22 +- .../agent_harness_service/src/api/test.rs | 177 ++++++++- services/agent_harness_service/src/config.rs | 9 +- 12 files changed, 574 insertions(+), 214 deletions(-) create mode 100644 infra/stacks/agent-harness-service/legacy-alb.test.ts diff --git a/crates/coding_agent_worker/default.macrod.toml b/crates/coding_agent_worker/default.macrod.toml index 4b92508036c..0ac449bc72a 100644 --- a/crates/coding_agent_worker/default.macrod.toml +++ b/crates/coding_agent_worker/default.macrod.toml @@ -1,7 +1,7 @@ # macrod configuration. Pairing adds a bearer token here; keep this file private. [macro] -api_url = "https://agent-harness.macro.com" +api_url = "https://gateway.macro.com/agent-harness" storage_url = "https://gateway.macro.com/dss" web_url = "https://macro.com/app" diff --git a/crates/coding_agent_worker/src/config/test.rs b/crates/coding_agent_worker/src/config/test.rs index ca48b68fdc4..c3b04b60838 100644 --- a/crates/coding_agent_worker/src/config/test.rs +++ b/crates/coding_agent_worker/src/config/test.rs @@ -97,13 +97,13 @@ fn the_gateway_url_is_the_api_base_with_a_websocket_scheme() { ); let secure = MacroApi { - api_url: "https://agent-harness.macro.com/".to_owned(), + api_url: "https://gateway.macro.com/agent-harness/".to_owned(), storage_url: "https://gateway.macro.com/dss".to_owned(), web_url: "https://macro.com/app/".to_owned(), }; assert_eq!( secure.gateway_url(), - "wss://agent-harness.macro.com/runtime/ws", + "wss://gateway.macro.com/agent-harness/runtime/ws", ); assert_eq!( secure.pairing_approval_url("KX7M-4QHD"), diff --git a/crates/coding_agent_worker/src/tui/config_form.rs b/crates/coding_agent_worker/src/tui/config_form.rs index 3e82dd14b84..11054dbd79c 100644 --- a/crates/coding_agent_worker/src/tui/config_form.rs +++ b/crates/coding_agent_worker/src/tui/config_form.rs @@ -18,7 +18,7 @@ mod environment { } const DEFAULT_CONFIG: &str = include_str!("../../default.macrod.toml"); -const DEV_API_URL: &str = "https://agent-harness-dev.macro.com"; +const DEV_API_URL: &str = "https://dev-gateway.macro.com/agent-harness"; const DEV_STORAGE_URL: &str = "https://dev-gateway.macro.com/dss"; const DEV_WEB_URL: &str = "https://dev.macro.com/app"; diff --git a/crates/coding_agent_worker/src/tui/config_form/test.rs b/crates/coding_agent_worker/src/tui/config_form/test.rs index 758ba94ebb7..795fbbe1e56 100644 --- a/crates/coding_agent_worker/src/tui/config_form/test.rs +++ b/crates/coding_agent_worker/src/tui/config_form/test.rs @@ -26,7 +26,14 @@ fn creates_a_valid_production_config() { assert_eq!(config.harness.command, "hermes"); assert_eq!(config.harness.args, ["acp"]); assert_eq!(config.workspace.path, directory.path()); - assert_eq!(config.macro_api.api_url, "https://agent-harness.macro.com"); + assert_eq!( + config.macro_api.api_url, + "https://gateway.macro.com/agent-harness" + ); + assert_eq!( + config.macro_api.gateway_url(), + "wss://gateway.macro.com/agent-harness/runtime/ws" + ); assert_eq!( config.macro_api.storage_url, "https://gateway.macro.com/dss" @@ -45,7 +52,14 @@ fn creates_a_dev_config_when_dev_mode_is_set() { .expect("create config"); let config = Config::load(&path).expect("load generated config"); - assert_eq!(config.macro_api.api_url, DEV_API_URL); + assert_eq!( + config.macro_api.api_url, + "https://dev-gateway.macro.com/agent-harness" + ); + assert_eq!( + config.macro_api.gateway_url(), + "wss://dev-gateway.macro.com/agent-harness/runtime/ws" + ); assert_eq!(config.macro_api.storage_url, DEV_STORAGE_URL); assert_eq!(config.macro_api.web_url, DEV_WEB_URL); } diff --git a/infra/packages/shared/src/gateway_priorities.ts b/infra/packages/shared/src/gateway_priorities.ts index 1ae2cb8de9d..4d37aa9d621 100644 --- a/infra/packages/shared/src/gateway_priorities.ts +++ b/infra/packages/shared/src/gateway_priorities.ts @@ -11,6 +11,7 @@ export enum GatewayService { SEARCH_PROCESSING_SERVICE = 'SEARCH_PROCESSING_SERVICE', IMAGE_PROXY_SERVICE = 'IMAGE_PROXY_SERVICE', AGENT_HARNESS_SERVICE = 'AGENT_HARNESS_SERVICE', + AGENT_HARNESS_EGRESS = 'AGENT_HARNESS_EGRESS', AGENT_SCHEDULE_SERVICE = 'AGENT_SCHEDULE_SERVICE', CONNECTION_GATEWAY = 'CONNECTION_GATEWAY', AUTHENTICATION_SERVICE = 'AUTHENTICATION_SERVICE', @@ -36,6 +37,7 @@ export const GATEWAY_PRIORITIES: GatewayPriorityMap = { [GatewayService.SEARCH_PROCESSING_SERVICE]: 50, [GatewayService.IMAGE_PROXY_SERVICE]: 60, [GatewayService.AGENT_HARNESS_SERVICE]: 70, + [GatewayService.AGENT_HARNESS_EGRESS]: 75, [GatewayService.AGENT_SCHEDULE_SERVICE]: 80, [GatewayService.CONNECTION_GATEWAY]: 90, [GatewayService.AUTHENTICATION_SERVICE]: 100, diff --git a/infra/packages/shared/src/service_urls.ts b/infra/packages/shared/src/service_urls.ts index a98c78b55b1..076bc86cd96 100644 --- a/infra/packages/shared/src/service_urls.ts +++ b/infra/packages/shared/src/service_urls.ts @@ -15,6 +15,7 @@ export enum ServiceUrl { LEXICAL_SERVICE_URL = 'LEXICAL_SERVICE_URL', UNFURL_SERVICE_URL = 'UNFURL_SERVICE_URL', AGENT_HARNESS_SERVICE_URL = 'AGENT_HARNESS_SERVICE_URL', + AGENT_HARNESS_EGRESS_URL = 'AGENT_HARNESS_EGRESS_URL', MCP_SERVER_URL = 'MCP_SERVER_URL', } @@ -48,6 +49,8 @@ const DEV_SERVICE_URLS: ServiceUrlMap = { [ServiceUrl.UNFURL_SERVICE_URL]: 'https://dev-gateway.macro.com/unfurl', [ServiceUrl.AGENT_HARNESS_SERVICE_URL]: 'https://dev-gateway.macro.com/agent-harness', + [ServiceUrl.AGENT_HARNESS_EGRESS_URL]: + 'https://dev-gateway.macro.com/agent-harness-egress', [ServiceUrl.MCP_SERVER_URL]: 'https://dev-gateway.macro.com/mcp', }; @@ -72,6 +75,8 @@ const PROD_SERVICE_URLS: ServiceUrlMap = { [ServiceUrl.UNFURL_SERVICE_URL]: 'https://gateway.macro.com/unfurl', [ServiceUrl.AGENT_HARNESS_SERVICE_URL]: 'https://gateway.macro.com/agent-harness', + [ServiceUrl.AGENT_HARNESS_EGRESS_URL]: + 'https://gateway.macro.com/agent-harness-egress', [ServiceUrl.MCP_SERVER_URL]: 'https://gateway.macro.com/mcp', }; diff --git a/infra/stacks/agent-harness-service/agent_harness_service.ts b/infra/stacks/agent-harness-service/agent_harness_service.ts index 2d20f443478..84245853f88 100644 --- a/infra/stacks/agent-harness-service/agent_harness_service.ts +++ b/infra/stacks/agent-harness-service/agent_harness_service.ts @@ -8,16 +8,16 @@ import { ServiceTargetGroup, datadogAgentContainer, fargateLogRouterSidecarContainer, - serviceLoadBalancer, } from '../../packages/resources'; import { EcrImage } from '../../packages/service'; import { - BASE_DOMAIN, CLOUD_TRAIL_SNS_TOPIC_ARN, DopplerEcsEnvironment, getGatewayAlb, getKafkaClusterPolicy, + getServiceUrl, GatewayService, + ServiceUrl, stack, } from '../../packages/shared'; @@ -26,23 +26,9 @@ const gatewayLoadBalancer = getGatewayAlb(); const BASE_NAME = pulumi.getProject(); const REPO_ROOT = '../../..'; -export const SERVICE_DOMAIN_NAME = `agent-harness${ - stack === 'prod' ? '' : `-${stack}` -}.${BASE_DOMAIN}`; - -// The sandbox-facing egress proxy gets its own hostname rather than paths -// carved out of the control API's: the control routes authenticate Macro -// users, the egress routes authenticate session tokens presented by -// model-authored code, and a separate host keeps the two trust domains -// separable at the load balancer. -export const EGRESS_DOMAIN_NAME = `agent-harness-egress${ - stack === 'prod' ? '' : `-${stack}` -}.${BASE_DOMAIN}`; - type Args = { vpc: { vpcId: pulumi.Output | string; - publicSubnetIds: pulumi.Output | string[]; privateSubnetIds: pulumi.Output | string[]; }; tags: { [key: string]: string }; @@ -52,7 +38,6 @@ type Args = { /** Container port of the sandbox-facing egress proxy listener. */ egressContainerPort: number; healthCheckPath: string; - isPrivate?: boolean; ecsClusterArn: pulumi.Output | string; cloudStorageClusterName: pulumi.Output | string; secretKeyArns: (pulumi.Output | string)[]; @@ -74,14 +59,11 @@ type Args = { export class AgentHarnessService extends pulumi.ComponentResource { public role: aws.iam.Role; public ecr: awsx.ecr.Repository; - public serviceAlbSg: aws.ec2.SecurityGroup; public serviceSg: aws.ec2.SecurityGroup; public domain: string; public egressDomain: string; public targetGroup: aws.lb.TargetGroup; public egressTargetGroup: aws.lb.TargetGroup; - public lb: aws.lb.LoadBalancer; - public listener: aws.lb.Listener; public service: awsx.ecs.FargateService; public cloudStorageClusterName: pulumi.Output | string; public tags: { [key: string]: string }; @@ -99,7 +81,6 @@ export class AgentHarnessService extends pulumi.ComponentResource { serviceContainerPort, egressContainerPort, healthCheckPath, - isPrivate, ecsClusterArn, cloudStorageClusterName, containerEnvVars, @@ -108,8 +89,8 @@ export class AgentHarnessService extends pulumi.ComponentResource { bucketArns, } = args; - this.domain = `https://${SERVICE_DOMAIN_NAME}`; - this.egressDomain = `https://${EGRESS_DOMAIN_NAME}`; + this.domain = getServiceUrl(ServiceUrl.AGENT_HARNESS_SERVICE_URL); + this.egressDomain = getServiceUrl(ServiceUrl.AGENT_HARNESS_EGRESS_URL); this.cloudStorageClusterName = cloudStorageClusterName; this.tags = tags; @@ -246,11 +227,7 @@ export class AgentHarnessService extends pulumi.ComponentResource { ); this.ecr = image.ecr; - const { serviceAlbSg, serviceSg } = this.initializeSecurityGroups({ - vpcId: vpc.vpcId, - serviceContainerPort, - }); - this.serviceAlbSg = serviceAlbSg; + const serviceSg = this.initializeSecurityGroups({ vpcId: vpc.vpcId }); this.serviceSg = serviceSg; const gatewayTargetGroup = new ServiceTargetGroup( @@ -269,43 +246,23 @@ export class AgentHarnessService extends pulumi.ComponentResource { { parent: this } ); - const { targetGroup, lb, listener } = serviceLoadBalancer(this, { - serviceName: BASE_NAME, - serviceContainerPort, - healthCheckPath, - vpc, - albSecurityGroupId: serviceAlbSg.id, - isPrivate, - // The egress proxy carries MCP event streams and git pack negotiation, - // both of which sit idle past the ALB's 60s default. Same value the - // other streaming hosts (mcp-server, connection-gateway) use. - idleTimeout: 3600, - tags, - }); - this.targetGroup = targetGroup; - this.lb = lb; - this.listener = listener; + this.targetGroup = gatewayTargetGroup.target_group; - // Egress stays host-routed on this dedicated ALB, not the shared - // gateway. Control API authenticates Macro users; egress authenticates - // sandbox session tokens on a second container port. Mixing those - // trust domains onto the gateway (or under `/agent-harness`) would - // collapse that boundary. This rule's priority 10 is scoped to this - // listener and does not collide with the gateway's DSS priority 10. - // Not `${BASE_NAME}-egress`: with the helper's `-tg` suffix that is 36 - // chars, and target group names cap at 32. + // Forward the egress prefix unchanged to its own listener. Session-token + // Authorization headers are handled by the egress router as before. + // Use a new target group: AWS cannot attach one to two ALBs during cutover. + // Keep the name (including the helper's -tg suffix) below 32 characters. const egress = new ServiceTargetGroup( - `agent-harness-egress-${stack}`, + `ah-egress-gateway-${stack}`, { - listenerArn: listener.arn, + listenerArn: gatewayLoadBalancer.httpsListenerArn, vpcId: vpc.vpcId, containerPort: egressContainerPort, healthCheckPath, - hostHeaders: [EGRESS_DOMAIN_NAME], - // The only rule on this listener; any future rule must pick another. - priority: 10, + pathPatterns: ['/agent-harness-egress', '/agent-harness-egress/*'], + service: GatewayService.AGENT_HARNESS_EGRESS, serviceSecurityGroupId: serviceSg.id, - albSecurityGroupId: serviceAlbSg.id, + albSecurityGroupId: gatewayLoadBalancer.albSecurityGroupId, tags, }, { parent: this } @@ -344,17 +301,9 @@ export class AgentHarnessService extends pulumi.ComponentResource { // and JWT secrets, so a 0s grace period trips the circuit breaker // before the replacement binds. Ignore those checks until then. healthCheckGracePeriodSeconds: 120, - // Register the control port in both the dedicated ALB and the - // gateway target group during cutover. An explicit `loadBalancers` - // replaces the list awsx derives from `portMappings.targetGroup`, - // so the legacy control entry and the egress entry must be listed - // here too. + // Both listeners use the shared gateway, with separate target groups + // for the control API and sandbox egress proxy. loadBalancers: [ - { - targetGroupArn: targetGroup.arn, - containerName: 'service', - containerPort: serviceContainerPort, - }, { targetGroupArn: gatewayTargetGroup.target_group.arn, containerName: 'service', @@ -410,7 +359,7 @@ export class AgentHarnessService extends pulumi.ComponentResource { name: `${BASE_NAME}-tcp-${stack}`, hostPort: serviceContainerPort, containerPort: serviceContainerPort, - targetGroup, + targetGroup: this.targetGroup, }, { appProtocol: 'http', @@ -436,65 +385,18 @@ export class AgentHarnessService extends pulumi.ComponentResource { // ECS refuses a service whose target group is not yet associated // with a load balancer; it is the listener rule that creates that // association. - dependsOn: [gatewayTargetGroup.listener_rule], + dependsOn: [gatewayTargetGroup.listener_rule, egress.listener_rule], } ); this.setupServiceAlarms(); - - const zone = aws.route53.getZoneOutput({ name: BASE_DOMAIN }); - new aws.route53.Record( - `${BASE_NAME}-domain-record`, - { - name: SERVICE_DOMAIN_NAME, - type: 'A', - zoneId: zone.zoneId, - aliases: [ - { - evaluateTargetHealth: false, - name: this.lb.dnsName, - zoneId: this.lb.zoneId, - }, - ], - }, - { parent: this } - ); - new aws.route53.Record( - `${BASE_NAME}-egress-domain-record`, - { - name: EGRESS_DOMAIN_NAME, - type: 'A', - zoneId: zone.zoneId, - aliases: [ - { - evaluateTargetHealth: false, - name: this.lb.dnsName, - zoneId: this.lb.zoneId, - }, - ], - }, - { parent: this } - ); } private initializeSecurityGroups({ vpcId, - serviceContainerPort, }: { vpcId: pulumi.Output | string; - serviceContainerPort: number; }) { - const serviceAlbSg = new aws.ec2.SecurityGroup( - `${BASE_NAME}-alb-sg-${stack}`, - { - name: `${BASE_NAME}-alb-sg-${stack}`, - description: `${BASE_NAME} application load balancer security group`, - vpcId, - tags: this.tags, - }, - { parent: this } - ); - const serviceSg = new aws.ec2.SecurityGroup( `${BASE_NAME}-sg-${stack}`, { @@ -506,20 +408,6 @@ export class AgentHarnessService extends pulumi.ComponentResource { { parent: this } ); - new aws.vpc.SecurityGroupIngressRule( - `${BASE_NAME}-alb-in`, - { - securityGroupId: serviceSg.id, - description: 'Allow inbound traffic from the service ALB', - referencedSecurityGroupId: serviceAlbSg.id, - fromPort: serviceContainerPort, - toPort: serviceContainerPort, - ipProtocol: 'tcp', - tags: this.tags, - }, - { parent: this } - ); - new aws.vpc.SecurityGroupEgressRule( `${BASE_NAME}-service-out`, { @@ -532,49 +420,7 @@ export class AgentHarnessService extends pulumi.ComponentResource { { parent: this } ); - new aws.vpc.SecurityGroupIngressRule( - `${BASE_NAME}-http`, - { - securityGroupId: serviceAlbSg.id, - description: 'Allow inbound HTTP traffic', - cidrIpv4: '0.0.0.0/0', - fromPort: 80, - toPort: 80, - ipProtocol: 'tcp', - tags: this.tags, - }, - { parent: this } - ); - - new aws.vpc.SecurityGroupIngressRule( - `${BASE_NAME}-https`, - { - securityGroupId: serviceAlbSg.id, - description: 'Allow inbound HTTPS traffic', - cidrIpv4: '0.0.0.0/0', - fromPort: 443, - toPort: 443, - ipProtocol: 'tcp', - tags: this.tags, - }, - { parent: this } - ); - - new aws.vpc.SecurityGroupEgressRule( - `${BASE_NAME}-alb-out`, - { - securityGroupId: serviceAlbSg.id, - description: 'Allow traffic to the service security group', - referencedSecurityGroupId: serviceSg.id, - fromPort: serviceContainerPort, - toPort: serviceContainerPort, - ipProtocol: 'tcp', - tags: this.tags, - }, - { parent: this } - ); - - return { serviceAlbSg, serviceSg }; + return serviceSg; } private setupServiceAlarms() { diff --git a/infra/stacks/agent-harness-service/index.ts b/infra/stacks/agent-harness-service/index.ts index 205802fe070..bee08b65e62 100644 --- a/infra/stacks/agent-harness-service/index.ts +++ b/infra/stacks/agent-harness-service/index.ts @@ -89,7 +89,6 @@ const service = new AgentHarnessService(`agent-harness-service-${stack}`, { serviceContainerPort: 8101, egressContainerPort: 8102, healthCheckPath: '/health', - isPrivate: false, ecsClusterArn: cloudStorageClusterArn, cloudStorageClusterName, secretKeyArns: [ diff --git a/infra/stacks/agent-harness-service/legacy-alb.test.ts b/infra/stacks/agent-harness-service/legacy-alb.test.ts new file mode 100644 index 00000000000..44158067b69 --- /dev/null +++ b/infra/stacks/agent-harness-service/legacy-alb.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; +import { + GATEWAY_PRIORITIES, + GatewayService, +} from '../../packages/shared/src/gateway_priorities'; + +// Inspect declarations without running stack lookups, secrets, or image builds. +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); +} + +const service = parse('./agent_harness_service.ts'); +const index = parse('./index.ts'); + +function nodes( + root: ts.Node, + guard: (node: ts.Node) => node is T +): T[] { + const matches: T[] = []; + function visit(node: ts.Node): void { + if (guard(node)) matches.push(node); + ts.forEachChild(node, visit); + } + visit(root); + return matches; +} + +function resource( + source: ts.SourceFile, + constructorName: string, + name?: string +): ts.NewExpression { + const matches = nodes(source, ts.isNewExpression).filter( + (node) => + node.expression.getText() === constructorName && + (name === undefined || node.arguments?.[0].getText() === name) + ); + expect(matches).toHaveLength(1); + return matches[0]; +} + +function property(node: ts.Node, ...path: string[]): ts.Node { + let current = node; + for (const key of path) { + if (!ts.isObjectLiteralExpression(current)) + throw new Error('Expected object'); + const member = current.properties.find( + (entry) => entry.name?.getText() === key + ); + if (!member) throw new Error(`Missing ${key}`); + if (ts.isPropertyAssignment(member)) current = member.initializer; + else if (ts.isShorthandPropertyAssignment(member)) current = member.name; + else throw new Error(`Unsupported ${key}`); + } + return current; +} + +type Shape = string | Shape[] | { [key: string]: Shape }; + +function shape(node: ts.Node): Shape { + if (ts.isObjectLiteralExpression(node)) { + return Object.fromEntries( + node.properties.map((member) => { + const key = member.name?.getText(); + if (!key) throw new Error('Expected named property'); + return [key, shape(property(node, key))]; + }) + ); + } + if (ts.isArrayLiteralExpression(node)) return node.elements.map(shape); + return node.getText(); +} + +function assignment(name: string): string { + const matches = nodes(service, ts.isBinaryExpression).filter( + (node) => node.left.getText() === name + ); + expect(matches).toHaveLength(1); + return matches[0].right.getText(); +} + +function variable(source: ts.SourceFile, name: string): ts.Expression { + const matches = nodes(source, ts.isVariableDeclaration).filter( + (node) => node.name.getText() === name + ); + expect(matches).toHaveLength(1); + const value = matches[0].initializer; + if (!value) throw new Error(`Missing initializer for ${name}`); + return value; +} + +describe('agent-harness shared gateway migration', () => { + test('removes the dedicated ALB, both DNS records, and obsolete component fields', () => { + const identifiers = [service, index].flatMap((source) => + nodes(source, ts.isIdentifier).map((node) => node.text) + ); + for (const removed of [ + 'serviceLoadBalancer', + 'MacroApplicationLoadBalancer', + 'SERVICE_DOMAIN_NAME', + 'EGRESS_DOMAIN_NAME', + 'BASE_DOMAIN', + 'hostHeaders', + 'serviceAlbSg', + 'isPrivate', + 'publicSubnetIds', + 'listener', + ]) + expect(identifiers).not.toContain(removed); + expect( + nodes(service, ts.isPropertyDeclaration).map((node) => + node.name.getText() + ) + ).not.toContain('lb'); + expect( + nodes(service, ts.isNewExpression).some((node) => + /^aws\.(lb|alb|route53)\./.test(node.expression.getText()) + ) + ).toBe(false); + }); + + test('preserves the existing control target identity, paths, and security group pairing', () => { + const control = resource( + service, + 'ServiceTargetGroup', + '`${stack}-${BASE_NAME}`' + ); + expect(shape(control.arguments![1])).toEqual({ + tags: 'this.tags', + listenerArn: 'gatewayLoadBalancer.httpsListenerArn', + vpcId: 'vpc.vpcId', + containerPort: 'serviceContainerPort', + service: 'GatewayService.AGENT_HARNESS_SERVICE', + healthCheckPath: 'healthCheckPath', + pathPatterns: ["'/agent-harness'", "'/agent-harness/*'"], + serviceSecurityGroupId: 'serviceSg.id', + albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', + }); + expect(shape(control.arguments![2])).toEqual({ parent: 'this' }); + expect(assignment('this.targetGroup')).toBe( + 'gatewayTargetGroup.target_group' + ); + expect(GATEWAY_PRIORITIES[GatewayService.AGENT_HARNESS_SERVICE]).toBe(70); + }); + + test('routes the egress prefix to a new gateway target with a unique priority', () => { + const egress = resource( + service, + 'ServiceTargetGroup', + '`ah-egress-gateway-${stack}`' + ); + expect(shape(egress.arguments![1])).toEqual({ + listenerArn: 'gatewayLoadBalancer.httpsListenerArn', + vpcId: 'vpc.vpcId', + containerPort: 'egressContainerPort', + healthCheckPath: 'healthCheckPath', + pathPatterns: ["'/agent-harness-egress'", "'/agent-harness-egress/*'"], + service: 'GatewayService.AGENT_HARNESS_EGRESS', + serviceSecurityGroupId: 'serviceSg.id', + albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', + tags: 'tags', + }); + expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); + expect(assignment('this.egressTargetGroup')).toBe('egress.target_group'); + expect(GATEWAY_PRIORITIES[GatewayService.AGENT_HARNESS_EGRESS]).toBe(75); + expect(new Set(Object.values(GATEWAY_PRIORITIES)).size).toBe( + Object.keys(GATEWAY_PRIORITIES).length + ); + for (const stack of ['dev', 'prod']) + expect(`ah-egress-gateway-${stack}-tg`.length).toBeLessThanOrEqual(32); + }); + + test('registers exactly both gateway targets and waits for both listener associations', () => { + const ecs = resource(service, 'awsx.ecs.FargateService'); + expect(ecs.arguments![0].getText()).toBe('`${BASE_NAME}`'); + expect(shape(property(ecs.arguments![1], 'loadBalancers'))).toEqual([ + { + targetGroupArn: 'gatewayTargetGroup.target_group.arn', + containerName: "'service'", + containerPort: 'serviceContainerPort', + }, + { + targetGroupArn: 'this.egressTargetGroup.arn', + containerName: "'service'", + containerPort: 'egressContainerPort', + }, + ]); + expect(shape(ecs.arguments![2])).toEqual({ + parent: 'this', + dependsOn: ['gatewayTargetGroup.listener_rule', 'egress.listener_rule'], + }); + const container = property( + ecs.arguments![1], + 'taskDefinitionArgs', + 'containers', + 'service' + ); + expect(shape(property(container, 'portMappings'))).toEqual([ + { + appProtocol: "'http'", + name: '`${BASE_NAME}-tcp-${stack}`', + hostPort: 'serviceContainerPort', + containerPort: 'serviceContainerPort', + targetGroup: 'this.targetGroup', + }, + { + appProtocol: "'http'", + name: '`${BASE_NAME}-egress-tcp-${stack}`', + hostPort: 'egressContainerPort', + containerPort: 'egressContainerPort', + targetGroup: 'this.egressTargetGroup', + }, + ]); + for (const [key, value] of [ + ['stopTimeout', '120'], + ['cpu', '1024'], + ['memory', '2048'], + ]) { + expect(property(container, key).getText()).toBe(value); + } + for (const [key, value] of [ + ['desiredCount', '2'], + ['healthCheckGracePeriodSeconds', '120'], + ['deploymentMinimumHealthyPercent', '100'], + ['deploymentMaximumPercent', '200'], + ]) { + expect(property(ecs.arguments![1], key).getText()).toBe(value); + } + expect( + shape(property(ecs.arguments![1], 'deploymentCircuitBreaker')) + ).toEqual({ enable: 'true', rollback: 'true' }); + expect(shape(property(ecs.arguments![1], 'networkConfiguration'))).toEqual({ + subnets: 'vpc.privateSubnetIds', + securityGroups: ['serviceSg.id'], + }); + const caller = resource(index, 'AgentHarnessService'); + for (const [key, value] of [ + ['serviceContainerPort', '8101'], + ['egressContainerPort', '8102'], + ['healthCheckPath', "'/health'"], + ]) { + expect(property(caller.arguments![1], key).getText()).toBe(value); + } + }); + + test('preserves the task security group and outbound rule without legacy ALB rules', () => { + const sg = resource(service, 'aws.ec2.SecurityGroup'); + expect(sg.arguments![0].getText()).toBe('`${BASE_NAME}-sg-${stack}`'); + expect(shape(sg.arguments![1])).toEqual({ + name: '`${BASE_NAME}-sg-${stack}`', + vpcId: 'vpcId', + description: '`${BASE_NAME} service security group`', + tags: 'this.tags', + }); + expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); + const outbound = resource(service, 'aws.vpc.SecurityGroupEgressRule'); + expect(outbound.arguments![0].getText()).toBe('`${BASE_NAME}-service-out`'); + expect(shape(outbound.arguments![1])).toEqual({ + securityGroupId: 'serviceSg.id', + description: "'Allow all outbound traffic'", + cidrIpv4: "'0.0.0.0/0'", + ipProtocol: "'-1'", + tags: 'this.tags', + }); + expect( + nodes(service, ts.isNewExpression).filter( + (node) => + node.expression.getText() === 'aws.vpc.SecurityGroupIngressRule' + ) + ).toHaveLength(0); + }); + + test('exports the prefixed egress URL in dev and prod without a dedicated hostname', () => { + expect(assignment('this.egressDomain')).toBe( + 'getServiceUrl(ServiceUrl.AGENT_HARNESS_EGRESS_URL)' + ); + expect(variable(index, 'agentHarnessEgressUrl').getText()).toBe( + 'pulumi.interpolate`${service.egressDomain}`' + ); + const urls = parse('../../packages/shared/src/service_urls.ts'); + for (const [map, host] of [ + ['DEV_SERVICE_URLS', 'dev-gateway'], + ['PROD_SERVICE_URLS', 'gateway'], + ]) { + expect( + property( + variable(urls, map), + '[ServiceUrl.AGENT_HARNESS_EGRESS_URL]' + ).getText() + ).toBe(`'https://${host}.macro.com/agent-harness-egress'`); + } + }); + + test('keeps the gateway URL and role outputs and uses the canonical URL for BASE_URL', () => { + expect(assignment('this.domain')).toBe( + 'getServiceUrl(ServiceUrl.AGENT_HARNESS_SERVICE_URL)' + ); + const output = variable(index, 'agentHarnessServiceUrl'); + expect(ts.isCallExpression(output)).toBe(true); + expect((output as ts.CallExpression).expression.getText()).toBe( + 'getServiceUrl' + ); + expect((output as ts.CallExpression).arguments[0].getText()).toBe( + 'ServiceUrl.AGENT_HARNESS_SERVICE_URL' + ); + expect(variable(index, 'agentHarnessServiceRoleArn').getText()).toBe( + 'service.role.arn' + ); + const urls = parse('../../packages/shared/src/service_urls.ts'); + for (const [map, host] of [ + ['DEV_SERVICE_URLS', 'dev-gateway'], + ['PROD_SERVICE_URLS', 'gateway'], + ]) { + expect( + property( + variable(urls, map), + '[ServiceUrl.AGENT_HARNESS_SERVICE_URL]' + ).getText() + ).toBe(`'https://${host}.macro.com/agent-harness'`); + } + const ecs = resource(service, 'awsx.ecs.FargateService'); + const env = property( + ecs.arguments![1], + 'taskDefinitionArgs', + 'containers', + 'service', + 'environment' + ); + expect(nodes(env, ts.isObjectLiteralExpression).map(shape)).toContainEqual({ + name: "'BASE_URL'", + value: 'this.domain', + }); + }); + + test('keeps the streaming idle timeout at 3600 seconds on the shared gateway', () => { + const gateway = resource( + parse('../gateway/index.ts'), + 'MacroApplicationLoadBalancer' + ); + expect(property(gateway.arguments![1], 'idleTimeout').getText()).toBe( + '3600' + ); + }); +}); diff --git a/services/agent_harness_service/src/api.rs b/services/agent_harness_service/src/api.rs index 59dde380b2d..a8edbdf3ac8 100644 --- a/services/agent_harness_service/src/api.rs +++ b/services/agent_harness_service/src/api.rs @@ -33,14 +33,20 @@ pub mod swagger; #[cfg(test)] mod test; -/// Path prefix the shared gateway ALB forwards unmodified. Dual-mounted -/// alongside `/` so the dedicated ALB keeps working during cutover. +/// Path prefixes the shared gateway ALB forwards unmodified. const GATEWAY_PATH_PREFIX: &str = "/agent-harness"; +const EGRESS_GATEWAY_PATH_PREFIX: &str = "/agent-harness-egress"; -fn mount_at_root_and_prefix(inner: Router) -> Router { - Router::new() - .merge(inner.clone()) - .nest(GATEWAY_PATH_PREFIX, inner) +// Keep root mounts for direct health checks, local ingress, and cutover. +fn mount_at_root_and_prefix(inner: Router, prefix: &str) -> Router { + Router::new().merge(inner.clone()).nest(prefix, inner) +} + +fn egress_app(state: EgressRouterState) -> Router +where + Service: EgressService + 'static, +{ + mount_at_root_and_prefix(egress_router(state), EGRESS_GATEWAY_PATH_PREFIX) } fn health_router(ready: tokio::sync::watch::Receiver) -> Router { @@ -59,7 +65,7 @@ pub async fn serve_egress( where Service: EgressService + 'static, { - let app = egress_router(EgressRouterState::new(service)); + let app = egress_app(EgressRouterState::new(service)); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) .await @@ -95,7 +101,7 @@ where .layer(MacroRequestIdAndTracingLayer::new(Duration::from_millis(200)).into_inner()) .merge(health_router(runtime_commands_ready)) .layer(macro_cors::cors_layer()); - let app = mount_at_root_and_prefix(inner) + let app = mount_at_root_and_prefix(inner, GATEWAY_PATH_PREFIX) .merge(SwaggerUi::new("/docs").url("/api-doc/openapi.json", swagger::ApiDoc::openapi())) .merge(SwaggerUi::new("/agent-harness/docs").url( "/agent-harness/api-doc/openapi.json", diff --git a/services/agent_harness_service/src/api/test.rs b/services/agent_harness_service/src/api/test.rs index c97928b7193..cff1cc34250 100644 --- a/services/agent_harness_service/src/api/test.rs +++ b/services/agent_harness_service/src/api/test.rs @@ -1,34 +1,28 @@ -use super::{health_router, mount_at_root_and_prefix}; +use super::{GATEWAY_PATH_PREFIX, egress_app, health_router, mount_at_root_and_prefix}; +use agent_egress::domain::error::EgressError; +use agent_egress::domain::model::{ + EgressTarget, GitEndpoint, GitService, McpDestination, McpServerSlug, ProxyRequest, + ProxyResponse, SessionToken, +}; +use agent_egress::domain::service::EgressService; +use agent_egress::inbound::axum_router::EgressRouterState; use axum::{ body::Body, http::{Request, StatusCode}, }; +use std::sync::{Arc, Mutex}; use tower::ServiceExt; #[tokio::test] async fn health_is_reachable_at_root_and_gateway_prefix() { for path in ["/health", "/agent-harness/health"] { - let response = mount_at_root_and_prefix(health_router(tokio::sync::watch::channel(true).1)) - .oneshot( - Request::builder() - .uri(path) - .method("GET") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK, "{path}"); - } -} - -#[tokio::test] -async fn unprefixed_unknown_path_is_not_rewritten_onto_the_prefix() { - let response = mount_at_root_and_prefix(health_router(tokio::sync::watch::channel(true).1)) + let response = mount_at_root_and_prefix( + health_router(tokio::sync::watch::channel(true).1), + GATEWAY_PATH_PREFIX, + ) .oneshot( Request::builder() - .uri("/missing") + .uri(path) .method("GET") .body(Body::empty()) .unwrap(), @@ -36,6 +30,26 @@ async fn unprefixed_unknown_path_is_not_rewritten_onto_the_prefix() { .await .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } +} + +#[tokio::test] +async fn unprefixed_unknown_path_is_not_rewritten_onto_the_prefix() { + let response = mount_at_root_and_prefix( + health_router(tokio::sync::watch::channel(true).1), + GATEWAY_PATH_PREFIX, + ) + .oneshot( + Request::builder() + .uri("/missing") + .method("GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -54,3 +68,126 @@ async fn health_fails_while_the_command_bus_is_disconnected() { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } + +#[derive(Default)] +struct EgressSpy { + seen: Mutex>, +} + +impl EgressService for EgressSpy { + async fn proxy( + &self, + token: &SessionToken, + target: EgressTarget, + request: ProxyRequest, + ) -> Result { + self.seen.lock().unwrap().push(( + token.as_str().to_owned(), + target, + request.uri().to_string(), + )); + // Distinguish a request dispatched to the service from a routing or + // authentication refusal without needing an upstream HTTP client. + Err(EgressError::RequestTooLarge) + } +} + +#[tokio::test] +async fn egress_health_is_reachable_directly_and_through_the_gateway() { + let service = Arc::new(EgressSpy::default()); + let app = egress_app(EgressRouterState::new(Arc::clone(&service))); + for path in ["/health", "/agent-harness-egress/health"] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } + assert!(service.seen.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn gateway_egress_preserves_tokens_targets_and_git_query_strings() { + for (method, path, authorization, target) in [ + ( + "POST", + "/mcp/datadog", + "Bearer session", + EgressTarget::McpServer(McpDestination::Connected( + McpServerSlug::parse("datadog").unwrap(), + )), + ), + ( + "DELETE", + "/mcp-macro", + "Bearer session", + EgressTarget::McpServer(McpDestination::Macro), + ), + ( + "GET", + "/git/info/refs?service=git-upload-pack", + // Basic x:session, as sent by Git's credential helper. + "Basic eDpzZXNzaW9u", + EgressTarget::GitHubGit { + endpoint: GitEndpoint::InfoRefs { + service: GitService::UploadPack, + }, + }, + ), + ( + "POST", + "/git/git-receive-pack", + "Basic eDpzZXNzaW9u", + EgressTarget::GitHubGit { + endpoint: GitEndpoint::ReceivePack, + }, + ), + ] { + for prefix in ["", "/agent-harness-egress"] { + let service = Arc::new(EgressSpy::default()); + let response = egress_app(EgressRouterState::new(Arc::clone(&service))) + .oneshot( + Request::builder() + .method(method) + .uri(format!("{prefix}{path}")) + .header("authorization", authorization) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + *service.seen.lock().unwrap(), + [("session".to_owned(), target.clone(), path.to_owned())] + ); + } + } +} + +#[tokio::test] +async fn gateway_egress_keeps_authentication_and_git_basic_challenge() { + let service = Arc::new(EgressSpy::default()); + for path in [ + "/mcp/datadog", + "/mcp-macro", + "/git/info/refs?service=git-upload-pack", + ] { + let response = egress_app(EgressRouterState::new(Arc::clone(&service))) + .oneshot( + Request::builder() + .uri(format!("/agent-harness-egress{path}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers().contains_key("www-authenticate"), + path.starts_with("/git/") + ); + } + assert!(service.seen.lock().unwrap().is_empty()); +} diff --git a/services/agent_harness_service/src/config.rs b/services/agent_harness_service/src/config.rs index c1c9080ebc7..28981012349 100644 --- a/services/agent_harness_service/src/config.rs +++ b/services/agent_harness_service/src/config.rs @@ -140,17 +140,16 @@ pub struct Config { pub port: u16, /// Port the sandbox-facing egress proxy is served on. /// - /// A second listener rather than more routes on `port`: the control routes - /// are authenticated as Macro users and reached from inside the platform, - /// and the egress routes are authenticated by session token and reached - /// from a sandbox running model-authored code. Separate ports keep the two - /// separable at the network as well as in the code. + /// The shared gateway forwards `/agent-harness-egress/*` to this listener. + /// The egress router reads sandbox session tokens from `Authorization`. #[macro_config_default(8102)] pub egress_port: u16, /// Where a sandbox should dial the egress proxy. /// /// Not derivable from `egress_port`: the sandbox reaches this through /// whatever ingress fronts the deployment, not on the container's own port. + /// Production uses `https://gateway.macro.com/agent-harness-egress`; + /// development uses `https://dev-gateway.macro.com/agent-harness-egress`. pub egress_base_url: String, /// OAuth client ID for the Pipedream API. pub pipedream_client_id: PipedreamClientId, From f9300ee4ea28fe4945b51fc8cf6281c105a57f86 Mon Sep 17 00:00:00 2001 From: Hutch Date: Wed, 9 Sep 2026 09:02:33 -0400 Subject: [PATCH 2/4] chore: stop using config service urls --- crates/macro_service_urls/src/lib.rs | 8 +++ crates/macro_service_urls/src/test.rs | 58 +++++++++++++++++++ services/agent_harness_service/src/config.rs | 7 --- services/agent_harness_service/src/main.rs | 7 ++- tooling/xtask/crates/xtask_local/src/local.rs | 4 +- .../crates/xtask_local/src/local/cf_tunnel.rs | 8 +-- .../xtask/crates/xtask_local/src/local/cli.rs | 2 +- .../crates/xtask_local/src/local/local_env.rs | 9 ++- .../xtask_local/src/local/local_env/test.rs | 21 ++++++- .../crates/xtask_local/src/local/summary.rs | 7 +-- 10 files changed, 106 insertions(+), 25 deletions(-) diff --git a/crates/macro_service_urls/src/lib.rs b/crates/macro_service_urls/src/lib.rs index 33d4f6cecad..9442eda99c8 100644 --- a/crates/macro_service_urls/src/lib.rs +++ b/crates/macro_service_urls/src/lib.rs @@ -561,6 +561,14 @@ service_url! { dev: "https://dev-gateway.macro.com/agent-harness", prod: "https://gateway.macro.com/agent-harness", }, + /// Sandbox-facing agent harness egress proxy URL. + /// Override the local default when sandbox clients need a Docker-network + /// address or a public tunnel rather than the host's loopback address. + pub AgentHarnessEgressUrl { + local: "http://localhost:8102", + dev: "https://dev-gateway.macro.com/agent-harness-egress", + prod: "https://gateway.macro.com/agent-harness-egress", + }, /// Link unfurl service API URL. pub UnfurlServiceUrl { local: "http://localhost:8095", diff --git a/crates/macro_service_urls/src/test.rs b/crates/macro_service_urls/src/test.rs index fbfa7693009..e2757876086 100644 --- a/crates/macro_service_urls/src/test.rs +++ b/crates/macro_service_urls/src/test.rs @@ -161,6 +161,48 @@ fn agent_harness_service_url_has_no_trailing_slash() { } } +#[test] +fn agent_harness_egress_url_parses() { + assert_parses_for_all_environments(AgentHarnessEgressUrl::default_for_environment); +} + +#[test] +fn agent_harness_egress_url_selects_defaults_without_a_required_config_value() { + with_mock_override_env(missing_override, || { + for (environment, expected) in [ + (Environment::Local, "http://localhost:8102"), + ( + Environment::Develop, + "https://dev-gateway.macro.com/agent-harness-egress", + ), + ( + Environment::Production, + "https://gateway.macro.com/agent-harness-egress", + ), + ] { + let url = AgentHarnessEgressUrl::new_for_environment(environment).unwrap(); + assert_eq!(url.as_str(), expected); + assert!(!url.as_str().ends_with('/')); + } + }); +} + +#[test] +fn agent_harness_egress_url_honors_the_standard_override_for_tunnels() { + with_mock_override_env( + |name| { + assert_eq!(name, "OVERRIDE_AGENT_HARNESS_EGRESS_URL"); + Ok("https://egress-test.trycloudflare.com".to_owned()) + }, + || { + for environment in ENVS { + let url = AgentHarnessEgressUrl::new_for_environment(environment).unwrap(); + assert_eq!(url.as_str(), "https://egress-test.trycloudflare.com"); + } + }, + ); +} + #[test] fn unfurl_service_url_parses() { assert_parses_for_all_environments(UnfurlServiceUrl::default_for_environment); @@ -413,6 +455,10 @@ fn exported_service_urls_match_local_values() { service_urls.static_file_service_url.as_ref(), "http://localhost:8100", ); + assert_eq!( + service_urls.agent_harness_egress_url.as_ref(), + "http://localhost:8102", + ); assert_eq!( service_urls.unfurl_service_url.as_ref(), "http://localhost:8095" @@ -487,6 +533,10 @@ fn exported_service_urls_match_dev_values() { service_urls.agent_harness_service_url.as_ref(), "https://dev-gateway.macro.com/agent-harness", ); + assert_eq!( + service_urls.agent_harness_egress_url.as_ref(), + "https://dev-gateway.macro.com/agent-harness-egress", + ); assert_eq!( service_urls.unfurl_service_url.as_ref(), "https://dev-gateway.macro.com/unfurl", @@ -558,6 +608,10 @@ fn exported_service_urls_match_prod_values() { service_urls.agent_harness_service_url.as_ref(), "https://gateway.macro.com/agent-harness", ); + assert_eq!( + service_urls.agent_harness_egress_url.as_ref(), + "https://gateway.macro.com/agent-harness-egress", + ); assert_eq!( service_urls.unfurl_service_url.as_ref(), "https://gateway.macro.com/unfurl", @@ -626,6 +680,10 @@ fn exported_service_url_override_names_are_derived_from_env_var_names() { StaticFileServiceUrl::local().override_env_var_name(), "OVERRIDE_STATIC_FILE_SERVICE_URL", ); + assert_eq!( + AgentHarnessEgressUrl::local().override_env_var_name(), + "OVERRIDE_AGENT_HARNESS_EGRESS_URL", + ); assert_eq!( UnfurlServiceUrl::local().override_env_var_name(), "OVERRIDE_UNFURL_SERVICE_URL", diff --git a/services/agent_harness_service/src/config.rs b/services/agent_harness_service/src/config.rs index 28981012349..5ac144a0559 100644 --- a/services/agent_harness_service/src/config.rs +++ b/services/agent_harness_service/src/config.rs @@ -144,13 +144,6 @@ pub struct Config { /// The egress router reads sandbox session tokens from `Authorization`. #[macro_config_default(8102)] pub egress_port: u16, - /// Where a sandbox should dial the egress proxy. - /// - /// Not derivable from `egress_port`: the sandbox reaches this through - /// whatever ingress fronts the deployment, not on the container's own port. - /// Production uses `https://gateway.macro.com/agent-harness-egress`; - /// development uses `https://dev-gateway.macro.com/agent-harness-egress`. - pub egress_base_url: String, /// OAuth client ID for the Pipedream API. pub pipedream_client_id: PipedreamClientId, /// OAuth client secret for the Pipedream API. diff --git a/services/agent_harness_service/src/main.rs b/services/agent_harness_service/src/main.rs index ee94ad1395a..5cc24e63102 100644 --- a/services/agent_harness_service/src/main.rs +++ b/services/agent_harness_service/src/main.rs @@ -98,7 +98,7 @@ use macro_event_broker::{ KafkaConsumerAdapter, KafkaEventPublisher, MacroEvent as _, MacroEventBrokerService, MacroEventCollection as _, MacroEventConsumerService, }; -use macro_service_urls::{ConnectionGatewayUrl, LexicalServiceUrl}; +use macro_service_urls::{AgentHarnessEgressUrl, ConnectionGatewayUrl, LexicalServiceUrl}; use pipedream_mcp::outbound::api::{PipedreamClient, PipedreamConfig}; use pipedream_mcp::outbound::pg_connection_repo::PgConnectionRepo; use rdkafka::consumer::CommitMode; @@ -513,7 +513,10 @@ async fn run() -> anyhow::Result<()> { HarnessKeyedConnections::new(PgHarnessBindings::new(pool.clone()), Arc::clone(&runtimes)), prompt_context, prompt_composer, - EgressProvisioner::new(Arc::clone(&mcp_connections), config.egress_base_url.clone()), + EgressProvisioner::new( + Arc::clone(&mcp_connections), + AgentHarnessEgressUrl::new()?.to_string(), + ), RedisCommandForwarder::new(redis.clone()), defaults, )); diff --git a/tooling/xtask/crates/xtask_local/src/local.rs b/tooling/xtask/crates/xtask_local/src/local.rs index c37f592ff83..bbce9e81a79 100644 --- a/tooling/xtask/crates/xtask_local/src/local.rs +++ b/tooling/xtask/crates/xtask_local/src/local.rs @@ -245,7 +245,7 @@ pub fn run_stack(mode: Mode, args: &cli::RunArgs) -> Result<()> { } // The Cursor egress tunnel, before env resolution because the minted - // hostname is written into `EGRESS_BASE_URL`. Best-effort with a loud + // hostname overrides `AgentHarnessEgressUrl`. Best-effort with a loud // downgrade: a laptop with no route to Cloudflare should still get a // working stack, minus the one thing that needs public ingress - // `@cursor` sessions reaching local MCP servers. @@ -258,7 +258,7 @@ pub fn run_stack(mode: Mode, args: &cli::RunArgs) -> Result<()> { } Err(error) => { stage.note(&format!( - "WARNING: no cursor egress tunnel ({error:#}); EGRESS_BASE_URL stays \ + "WARNING: no cursor egress tunnel ({error:#}); the egress URL stays \ in-network, so @cursor sessions cannot reach this stack's MCP servers" )); None diff --git a/tooling/xtask/crates/xtask_local/src/local/cf_tunnel.rs b/tooling/xtask/crates/xtask_local/src/local/cf_tunnel.rs index cbaa5ec23eb..410cc04ea11 100644 --- a/tooling/xtask/crates/xtask_local/src/local/cf_tunnel.rs +++ b/tooling/xtask/crates/xtask_local/src/local/cf_tunnel.rs @@ -5,11 +5,11 @@ //! //! - **egress**: a `@cursor` session runs on cursor.com, not in the compose //! network, and the MCP servers the harness hands it point at -//! `EGRESS_BASE_URL`. In-network that URL is +//! `AgentHarnessEgressUrl`. In-network that URL is //! `http://agent-harness-service:8102`, which means nothing to Cursor's VM — -//! so the tunnel targets the instance's published egress port and -//! `EGRESS_BASE_URL` resolves to the minted `https://….trycloudflare.com` -//! hostname instead. +//! so the tunnel targets the instance's published egress port and sets +//! `OVERRIDE_AGENT_HARNESS_EGRESS_URL` to the minted +//! `https://….trycloudflare.com` hostname instead. //! - **app**: the single-origin reverse proxy (Caddy), so the whole running //! product can be shared with someone who is not on this machine. The proxy //! — not the Vite dev server — is the only target that works remotely: the diff --git a/tooling/xtask/crates/xtask_local/src/local/cli.rs b/tooling/xtask/crates/xtask_local/src/local/cli.rs index a73d6cfc51e..a3d767a3c76 100644 --- a/tooling/xtask/crates/xtask_local/src/local/cli.rs +++ b/tooling/xtask/crates/xtask_local/src/local/cli.rs @@ -132,7 +132,7 @@ pub struct RunArgs { #[arg(long)] pub with_chrome: bool, /// Open Cloudflare quick tunnels into this stack: one for `@cursor` - /// sessions (a public `EGRESS_BASE_URL`) and one sharing the app itself + /// sessions (a public egress service URL) and one sharing the app itself /// through the reverse proxy. Off by default — nothing dials out and the /// stack stays localhost-only. `run_local` only. #[arg(long)] diff --git a/tooling/xtask/crates/xtask_local/src/local/local_env.rs b/tooling/xtask/crates/xtask_local/src/local/local_env.rs index e54ac247812..cce46c460d5 100644 --- a/tooling/xtask/crates/xtask_local/src/local/local_env.rs +++ b/tooling/xtask/crates/xtask_local/src/local/local_env.rs @@ -312,7 +312,7 @@ struct AgentHarnessEnv { network: String, /// The egress proxy as its clients dial it: the run's Cursor egress /// tunnel when one opened, otherwise the in-network address. - egress_base_url: String, + egress_url: String, /// Macro's own MCP server as the egress proxy dials it. In-network and /// cleartext, which the proxy permits only under `ENVIRONMENT=local`: /// this hop never leaves the compose bridge. @@ -337,7 +337,7 @@ impl AgentHarnessEnv { // `credential..helper`, so an underscore here means the // scoped credential helper never fires and the clone prompts for // a password it has no terminal to read. - egress_base_url: egress_public_url + egress_url: egress_public_url .unwrap_or("http://agent-harness-service:8102") .to_owned(), macro_mcp_url: "http://mcp-service:8080/mcp", @@ -351,7 +351,10 @@ impl AgentHarnessEnv { env.insert("DEV_DANGEROUS_LOCAL_CONTAINERS".into(), "true".into()); env.insert("LOCAL_CONTAINER_IMAGE".into(), self.image.into()); env.insert("LOCAL_CONTAINER_NETWORK".into(), self.network.clone()); - env.insert("EGRESS_BASE_URL".into(), self.egress_base_url.clone()); + env.insert( + "OVERRIDE_AGENT_HARNESS_EGRESS_URL".into(), + self.egress_url.clone(), + ); env.insert("MACRO_MCP_URL".into(), self.macro_mcp_url.into()); } } diff --git a/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs b/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs index c9d6d88db84..3a4dabb545b 100644 --- a/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs +++ b/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs @@ -353,12 +353,29 @@ fn mcp_public_url_uses_the_proxy_cognition_route() { /// matching `credential..helper`, so the compose service name would leave /// the scoped helper silently unfired. #[test] -fn the_egress_base_url_is_the_hyphenated_in_network_alias() { +fn the_egress_url_override_is_the_hyphenated_in_network_alias() { let named = Instance::derive(Some("2508"), None).unwrap(); let named_env = LocalEnv::for_instance(Mode::Local, &named, true, None).to_env(); assert_eq!( - named_env.get("EGRESS_BASE_URL").map(String::as_str), + named_env + .get("OVERRIDE_AGENT_HARNESS_EGRESS_URL") + .map(String::as_str), Some("http://agent-harness-service:8102") ); + assert!(!named_env.contains_key("EGRESS_BASE_URL")); +} + +#[test] +fn the_public_tunnel_overrides_the_egress_service_url() { + let instance = Instance::derive(Some("2508"), None).unwrap(); + let url = "https://egress-test.trycloudflare.com"; + let env = LocalEnv::for_instance(Mode::Local, &instance, true, Some(url)).to_env(); + + assert_eq!( + env.get("OVERRIDE_AGENT_HARNESS_EGRESS_URL") + .map(String::as_str), + Some(url) + ); + assert!(!env.contains_key("EGRESS_BASE_URL")); } diff --git a/tooling/xtask/crates/xtask_local/src/local/summary.rs b/tooling/xtask/crates/xtask_local/src/local/summary.rs index 7571a7d776d..f9840f81799 100644 --- a/tooling/xtask/crates/xtask_local/src/local/summary.rs +++ b/tooling/xtask/crates/xtask_local/src/local/summary.rs @@ -150,10 +150,9 @@ pub fn print( ); row("Receive webhooks at", sdk_webhook::relay_url().to_string()); } - // The Cursor egress tunnel, when one opened this run: a public - // `EGRESS_BASE_URL` is always a tunnel, and the in-network default is not - // worth a row. - if let Some(url) = env.merged.get("EGRESS_BASE_URL") + // Show the public egress override (normally this run's Cursor tunnel), + // but not the default in-network address. + if let Some(url) = env.merged.get("OVERRIDE_AGENT_HARNESS_EGRESS_URL") && url.starts_with("https://") { row("cursor egress", url.clone()); From a81b5a96fe83e2fdc261456a614019385aa67cfe Mon Sep 17 00:00:00 2001 From: Hutch Date: Wed, 9 Sep 2026 09:14:41 -0400 Subject: [PATCH 3/4] chore: stop using config service urls for mcp --- crates/macro_service_urls/src/lib.rs | 6 +++ crates/macro_service_urls/src/test.rs | 50 +++++++++++++++++++ docker/docker-compose.yml | 2 +- services/agent_harness_service/src/config.rs | 5 -- services/agent_harness_service/src/main.rs | 14 +++++- services/agent_harness_service/src/test.rs | 41 +++++++++++++++ .../crates/xtask_local/src/local/local_env.rs | 13 +++-- .../xtask_local/src/local/local_env/test.rs | 10 ++++ 8 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 services/agent_harness_service/src/test.rs diff --git a/crates/macro_service_urls/src/lib.rs b/crates/macro_service_urls/src/lib.rs index 9442eda99c8..2f1fa041296 100644 --- a/crates/macro_service_urls/src/lib.rs +++ b/crates/macro_service_urls/src/lib.rs @@ -569,6 +569,12 @@ service_url! { dev: "https://dev-gateway.macro.com/agent-harness-egress", prod: "https://gateway.macro.com/agent-harness-egress", }, + /// Macro MCP service base URL. Append `/mcp` for its transport endpoint. + pub McpServiceUrl { + local: "http://localhost:8080", + dev: "https://dev-gateway.macro.com/mcp", + prod: "https://gateway.macro.com/mcp", + }, /// Link unfurl service API URL. pub UnfurlServiceUrl { local: "http://localhost:8095", diff --git a/crates/macro_service_urls/src/test.rs b/crates/macro_service_urls/src/test.rs index e2757876086..35cf15862c2 100644 --- a/crates/macro_service_urls/src/test.rs +++ b/crates/macro_service_urls/src/test.rs @@ -203,6 +203,40 @@ fn agent_harness_egress_url_honors_the_standard_override_for_tunnels() { ); } +#[test] +fn mcp_service_url_parses() { + assert_parses_for_all_environments(McpServiceUrl::default_for_environment); +} + +#[test] +fn mcp_service_url_defaults_are_bases_without_a_trailing_slash() { + with_mock_override_env(missing_override, || { + for (environment, expected) in [ + (Environment::Local, "http://localhost:8080"), + (Environment::Develop, "https://dev-gateway.macro.com/mcp"), + (Environment::Production, "https://gateway.macro.com/mcp"), + ] { + let url = McpServiceUrl::new_for_environment(environment).unwrap(); + assert_eq!(url.as_str(), expected); + assert!(!url.as_str().ends_with('/')); + } + }); +} + +#[test] +fn mcp_service_url_honors_the_standard_override() { + with_mock_override_env( + |name| { + assert_eq!(name, "OVERRIDE_MCP_SERVICE_URL"); + Ok("http://mcp-service:8080".to_owned()) + }, + || { + let url = McpServiceUrl::new_for_environment(Environment::Local).unwrap(); + assert_eq!(url.as_str(), "http://mcp-service:8080"); + }, + ); +} + #[test] fn unfurl_service_url_parses() { assert_parses_for_all_environments(UnfurlServiceUrl::default_for_environment); @@ -459,6 +493,10 @@ fn exported_service_urls_match_local_values() { service_urls.agent_harness_egress_url.as_ref(), "http://localhost:8102", ); + assert_eq!( + service_urls.mcp_service_url.as_ref(), + "http://localhost:8080" + ); assert_eq!( service_urls.unfurl_service_url.as_ref(), "http://localhost:8095" @@ -537,6 +575,10 @@ fn exported_service_urls_match_dev_values() { service_urls.agent_harness_egress_url.as_ref(), "https://dev-gateway.macro.com/agent-harness-egress", ); + assert_eq!( + service_urls.mcp_service_url.as_ref(), + "https://dev-gateway.macro.com/mcp", + ); assert_eq!( service_urls.unfurl_service_url.as_ref(), "https://dev-gateway.macro.com/unfurl", @@ -612,6 +654,10 @@ fn exported_service_urls_match_prod_values() { service_urls.agent_harness_egress_url.as_ref(), "https://gateway.macro.com/agent-harness-egress", ); + assert_eq!( + service_urls.mcp_service_url.as_ref(), + "https://gateway.macro.com/mcp", + ); assert_eq!( service_urls.unfurl_service_url.as_ref(), "https://gateway.macro.com/unfurl", @@ -684,6 +730,10 @@ fn exported_service_url_override_names_are_derived_from_env_var_names() { AgentHarnessEgressUrl::local().override_env_var_name(), "OVERRIDE_AGENT_HARNESS_EGRESS_URL", ); + assert_eq!( + McpServiceUrl::local().override_env_var_name(), + "OVERRIDE_MCP_SERVICE_URL", + ); assert_eq!( UnfurlServiceUrl::local().override_env_var_name(), "OVERRIDE_UNFURL_SERVICE_URL", diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6e3094e50b7..fef8fc60e9a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -398,7 +398,7 @@ services: services: aliases: # Hyphenated for the same reason as the harness alias above, and - # because it is the host the egress proxy's MACRO_MCP_URL names. + # because it is the host OVERRIDE_MCP_SERVICE_URL names. - mcp-service # ============================================================================ diff --git a/services/agent_harness_service/src/config.rs b/services/agent_harness_service/src/config.rs index 5ac144a0559..eabf559a8fa 100644 --- a/services/agent_harness_service/src/config.rs +++ b/services/agent_harness_service/src/config.rs @@ -159,11 +159,6 @@ pub struct Config { /// URL of Pipedream's remote MCP server. #[macro_config_default(String::from(pipedream_mcp::outbound::api::DEFAULT_MCP_URL))] pub pipedream_mcp_url: String, - /// Where the egress proxy reaches Macro's own MCP server (`mcp_service`), - /// endpoint path included - e.g. `https://mcp.macro.com/mcp`, or the - /// in-network `http://mcp-service:8080/mcp` on a local stack. Cleartext is - /// refused at boot unless `ENVIRONMENT=local`. - pub macro_mcp_url: String, /// RSA key Macro API tokens are signed with. pub macro_api_token_private_secret_key: LocalOrRemoteSecret, /// Issuer stamped into minted Macro API tokens. diff --git a/services/agent_harness_service/src/main.rs b/services/agent_harness_service/src/main.rs index 5cc24e63102..417996a9087 100644 --- a/services/agent_harness_service/src/main.rs +++ b/services/agent_harness_service/src/main.rs @@ -16,6 +16,9 @@ mod harness_bindings; mod runtime_commands; mod trigger; +#[cfg(test)] +mod test; + use std::{future::Future, pin::Pin, sync::Arc}; use agent_egress::domain::service::EgressServiceImpl; @@ -98,7 +101,9 @@ use macro_event_broker::{ KafkaConsumerAdapter, KafkaEventPublisher, MacroEvent as _, MacroEventBrokerService, MacroEventCollection as _, MacroEventConsumerService, }; -use macro_service_urls::{AgentHarnessEgressUrl, ConnectionGatewayUrl, LexicalServiceUrl}; +use macro_service_urls::{ + AgentHarnessEgressUrl, ConnectionGatewayUrl, LexicalServiceUrl, McpServiceUrl, +}; use pipedream_mcp::outbound::api::{PipedreamClient, PipedreamConfig}; use pipedream_mcp::outbound::pg_connection_repo::PgConnectionRepo; use rdkafka::consumer::CommitMode; @@ -152,6 +157,11 @@ async fn main() -> anyhow::Result<()> { result } +fn macro_mcp_endpoint(base_url: &McpServiceUrl) -> Result { + // Append rather than Url::join("/mcp"), which would discard the gateway prefix. + url::Url::parse(&format!("{}/mcp", base_url.trim_end_matches('/'))) +} + async fn run() -> anyhow::Result<()> { agent_harness::install_tls_provider(); // AWS first, because the config's secrets resolve through Secrets Manager. @@ -302,7 +312,7 @@ async fn run() -> anyhow::Result<()> { config.macro_api_token_issuer.as_ref(), config.macro_api_token_private_secret_key.as_ref(), ), - url::Url::parse(&config.macro_mcp_url).context("MACRO_MCP_URL is not a url")?, + macro_mcp_endpoint(&McpServiceUrl::new()?).context("MCP service endpoint is not a URL")?, // The one gate on cleartext: a local stack's mcp-service is dialed // across the compose bridge, where TLS would be theater. Everywhere // else, an http URL refuses to boot. diff --git a/services/agent_harness_service/src/test.rs b/services/agent_harness_service/src/test.rs new file mode 100644 index 00000000000..dd07bebdfe1 --- /dev/null +++ b/services/agent_harness_service/src/test.rs @@ -0,0 +1,41 @@ +use super::macro_mcp_endpoint; +use macro_service_urls::McpServiceUrl; + +#[test] +fn macro_mcp_endpoint_preserves_the_gateway_prefix() { + for (base, expected) in [ + (McpServiceUrl::local(), "http://localhost:8080/mcp"), + ( + McpServiceUrl::dev(), + "https://dev-gateway.macro.com/mcp/mcp", + ), + (McpServiceUrl::prod(), "https://gateway.macro.com/mcp/mcp"), + ] { + assert_eq!(macro_mcp_endpoint(&base).unwrap().as_str(), expected); + } +} + +#[test] +fn macro_mcp_endpoint_appends_to_overridden_bases_with_or_without_a_trailing_slash() { + for base in ["http://mcp-service:8080", "http://mcp-service:8080/"] { + assert_eq!( + macro_mcp_endpoint(&McpServiceUrl::from_static(base)) + .unwrap() + .as_str(), + "http://mcp-service:8080/mcp" + ); + } + assert_eq!( + macro_mcp_endpoint(&McpServiceUrl::from_static( + "https://example.com/proxy/mcp/" + )) + .unwrap() + .as_str(), + "https://example.com/proxy/mcp/mcp" + ); +} + +#[test] +fn macro_mcp_endpoint_rejects_an_invalid_base_url() { + assert!(macro_mcp_endpoint(&McpServiceUrl::from_static("not a url")).is_err()); +} diff --git a/tooling/xtask/crates/xtask_local/src/local/local_env.rs b/tooling/xtask/crates/xtask_local/src/local/local_env.rs index cce46c460d5..e0b92d5f1c7 100644 --- a/tooling/xtask/crates/xtask_local/src/local/local_env.rs +++ b/tooling/xtask/crates/xtask_local/src/local/local_env.rs @@ -313,10 +313,10 @@ struct AgentHarnessEnv { /// The egress proxy as its clients dial it: the run's Cursor egress /// tunnel when one opened, otherwise the in-network address. egress_url: String, - /// Macro's own MCP server as the egress proxy dials it. In-network and - /// cleartext, which the proxy permits only under `ENVIRONMENT=local`: + /// Macro's MCP service base URL, without its `/mcp` transport endpoint. + /// In-network and cleartext, which the proxy permits only locally: /// this hop never leaves the compose bridge. - macro_mcp_url: &'static str, + mcp_service_url: &'static str, } impl AgentHarnessEnv { @@ -340,7 +340,7 @@ impl AgentHarnessEnv { egress_url: egress_public_url .unwrap_or("http://agent-harness-service:8102") .to_owned(), - macro_mcp_url: "http://mcp-service:8080/mcp", + mcp_service_url: "http://mcp-service:8080", } } @@ -355,7 +355,10 @@ impl AgentHarnessEnv { "OVERRIDE_AGENT_HARNESS_EGRESS_URL".into(), self.egress_url.clone(), ); - env.insert("MACRO_MCP_URL".into(), self.macro_mcp_url.into()); + env.insert( + "OVERRIDE_MCP_SERVICE_URL".into(), + self.mcp_service_url.into(), + ); } } diff --git a/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs b/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs index 3a4dabb545b..99b271a045e 100644 --- a/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs +++ b/tooling/xtask/crates/xtask_local/src/local/local_env/test.rs @@ -366,6 +366,16 @@ fn the_egress_url_override_is_the_hyphenated_in_network_alias() { assert!(!named_env.contains_key("EGRESS_BASE_URL")); } +#[test] +fn the_mcp_service_override_is_an_in_network_base_url() { + let env = local_env(); + assert_eq!( + env.get("OVERRIDE_MCP_SERVICE_URL").map(String::as_str), + Some("http://mcp-service:8080") + ); + assert!(!env.contains_key("MACRO_MCP_URL")); +} + #[test] fn the_public_tunnel_overrides_the_egress_service_url() { let instance = Instance::derive(Some("2508"), None).unwrap(); From fe64475198a65c7d8177e04f9c7ed04b697a7f4e Mon Sep 17 00:00:00 2001 From: Hutch Date: Wed, 9 Sep 2026 10:57:44 -0400 Subject: [PATCH 4/4] chore: remove crappy tests --- .../agent-harness-service/legacy-alb.test.ts | 352 ---------------- .../agent-schedule-service/legacy-alb.test.ts | 310 -------------- .../connection-gateway/legacy-alb.test.ts | 346 ---------------- .../contacts-service/legacy-alb.test.ts | 308 -------------- .../stacks/convert-service/legacy-alb.test.ts | 382 ------------------ infra/stacks/email-service/legacy-alb.test.ts | 302 -------------- .../image-proxy-service/legacy-alb.test.ts | 380 ----------------- .../legacy-alb.test.ts | 313 -------------- .../stacks/unfurl-service/legacy-alb.test.ts | 326 --------------- 9 files changed, 3019 deletions(-) delete mode 100644 infra/stacks/agent-harness-service/legacy-alb.test.ts delete mode 100644 infra/stacks/agent-schedule-service/legacy-alb.test.ts delete mode 100644 infra/stacks/connection-gateway/legacy-alb.test.ts delete mode 100644 infra/stacks/contacts-service/legacy-alb.test.ts delete mode 100644 infra/stacks/convert-service/legacy-alb.test.ts delete mode 100644 infra/stacks/email-service/legacy-alb.test.ts delete mode 100644 infra/stacks/image-proxy-service/legacy-alb.test.ts delete mode 100644 infra/stacks/search-processing-service/legacy-alb.test.ts delete mode 100644 infra/stacks/unfurl-service/legacy-alb.test.ts diff --git a/infra/stacks/agent-harness-service/legacy-alb.test.ts b/infra/stacks/agent-harness-service/legacy-alb.test.ts deleted file mode 100644 index 44158067b69..00000000000 --- a/infra/stacks/agent-harness-service/legacy-alb.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; -import { - GATEWAY_PRIORITIES, - GatewayService, -} from '../../packages/shared/src/gateway_priorities'; - -// Inspect declarations without running stack lookups, secrets, or image builds. -function parse(path: string): ts.SourceFile { - return ts.createSourceFile( - path, - readFileSync(new URL(path, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = parse('./agent_harness_service.ts'); -const index = parse('./index.ts'); - -function nodes( - root: ts.Node, - guard: (node: ts.Node) => node is T -): T[] { - const matches: T[] = []; - function visit(node: ts.Node): void { - if (guard(node)) matches.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return matches; -} - -function resource( - source: ts.SourceFile, - constructorName: string, - name?: string -): ts.NewExpression { - const matches = nodes(source, ts.isNewExpression).filter( - (node) => - node.expression.getText() === constructorName && - (name === undefined || node.arguments?.[0].getText() === name) - ); - expect(matches).toHaveLength(1); - return matches[0]; -} - -function property(node: ts.Node, ...path: string[]): ts.Node { - let current = node; - for (const key of path) { - if (!ts.isObjectLiteralExpression(current)) - throw new Error('Expected object'); - const member = current.properties.find( - (entry) => entry.name?.getText() === key - ); - if (!member) throw new Error(`Missing ${key}`); - if (ts.isPropertyAssignment(member)) current = member.initializer; - else if (ts.isShorthandPropertyAssignment(member)) current = member.name; - else throw new Error(`Unsupported ${key}`); - } - return current; -} - -type Shape = string | Shape[] | { [key: string]: Shape }; - -function shape(node: ts.Node): Shape { - if (ts.isObjectLiteralExpression(node)) { - return Object.fromEntries( - node.properties.map((member) => { - const key = member.name?.getText(); - if (!key) throw new Error('Expected named property'); - return [key, shape(property(node, key))]; - }) - ); - } - if (ts.isArrayLiteralExpression(node)) return node.elements.map(shape); - return node.getText(); -} - -function assignment(name: string): string { - const matches = nodes(service, ts.isBinaryExpression).filter( - (node) => node.left.getText() === name - ); - expect(matches).toHaveLength(1); - return matches[0].right.getText(); -} - -function variable(source: ts.SourceFile, name: string): ts.Expression { - const matches = nodes(source, ts.isVariableDeclaration).filter( - (node) => node.name.getText() === name - ); - expect(matches).toHaveLength(1); - const value = matches[0].initializer; - if (!value) throw new Error(`Missing initializer for ${name}`); - return value; -} - -describe('agent-harness shared gateway migration', () => { - test('removes the dedicated ALB, both DNS records, and obsolete component fields', () => { - const identifiers = [service, index].flatMap((source) => - nodes(source, ts.isIdentifier).map((node) => node.text) - ); - for (const removed of [ - 'serviceLoadBalancer', - 'MacroApplicationLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'EGRESS_DOMAIN_NAME', - 'BASE_DOMAIN', - 'hostHeaders', - 'serviceAlbSg', - 'isPrivate', - 'publicSubnetIds', - 'listener', - ]) - expect(identifiers).not.toContain(removed); - expect( - nodes(service, ts.isPropertyDeclaration).map((node) => - node.name.getText() - ) - ).not.toContain('lb'); - expect( - nodes(service, ts.isNewExpression).some((node) => - /^aws\.(lb|alb|route53)\./.test(node.expression.getText()) - ) - ).toBe(false); - }); - - test('preserves the existing control target identity, paths, and security group pairing', () => { - const control = resource( - service, - 'ServiceTargetGroup', - '`${stack}-${BASE_NAME}`' - ); - expect(shape(control.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.AGENT_HARNESS_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: ["'/agent-harness'", "'/agent-harness/*'"], - serviceSecurityGroupId: 'serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - }); - expect(shape(control.arguments![2])).toEqual({ parent: 'this' }); - expect(assignment('this.targetGroup')).toBe( - 'gatewayTargetGroup.target_group' - ); - expect(GATEWAY_PRIORITIES[GatewayService.AGENT_HARNESS_SERVICE]).toBe(70); - }); - - test('routes the egress prefix to a new gateway target with a unique priority', () => { - const egress = resource( - service, - 'ServiceTargetGroup', - '`ah-egress-gateway-${stack}`' - ); - expect(shape(egress.arguments![1])).toEqual({ - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'egressContainerPort', - healthCheckPath: 'healthCheckPath', - pathPatterns: ["'/agent-harness-egress'", "'/agent-harness-egress/*'"], - service: 'GatewayService.AGENT_HARNESS_EGRESS', - serviceSecurityGroupId: 'serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - tags: 'tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); - expect(assignment('this.egressTargetGroup')).toBe('egress.target_group'); - expect(GATEWAY_PRIORITIES[GatewayService.AGENT_HARNESS_EGRESS]).toBe(75); - expect(new Set(Object.values(GATEWAY_PRIORITIES)).size).toBe( - Object.keys(GATEWAY_PRIORITIES).length - ); - for (const stack of ['dev', 'prod']) - expect(`ah-egress-gateway-${stack}-tg`.length).toBeLessThanOrEqual(32); - }); - - test('registers exactly both gateway targets and waits for both listener associations', () => { - const ecs = resource(service, 'awsx.ecs.FargateService'); - expect(ecs.arguments![0].getText()).toBe('`${BASE_NAME}`'); - expect(shape(property(ecs.arguments![1], 'loadBalancers'))).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - { - targetGroupArn: 'this.egressTargetGroup.arn', - containerName: "'service'", - containerPort: 'egressContainerPort', - }, - ]); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: ['gatewayTargetGroup.listener_rule', 'egress.listener_rule'], - }); - const container = property( - ecs.arguments![1], - 'taskDefinitionArgs', - 'containers', - 'service' - ); - expect(shape(property(container, 'portMappings'))).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }, - { - appProtocol: "'http'", - name: '`${BASE_NAME}-egress-tcp-${stack}`', - hostPort: 'egressContainerPort', - containerPort: 'egressContainerPort', - targetGroup: 'this.egressTargetGroup', - }, - ]); - for (const [key, value] of [ - ['stopTimeout', '120'], - ['cpu', '1024'], - ['memory', '2048'], - ]) { - expect(property(container, key).getText()).toBe(value); - } - for (const [key, value] of [ - ['desiredCount', '2'], - ['healthCheckGracePeriodSeconds', '120'], - ['deploymentMinimumHealthyPercent', '100'], - ['deploymentMaximumPercent', '200'], - ]) { - expect(property(ecs.arguments![1], key).getText()).toBe(value); - } - expect( - shape(property(ecs.arguments![1], 'deploymentCircuitBreaker')) - ).toEqual({ enable: 'true', rollback: 'true' }); - expect(shape(property(ecs.arguments![1], 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: ['serviceSg.id'], - }); - const caller = resource(index, 'AgentHarnessService'); - for (const [key, value] of [ - ['serviceContainerPort', '8101'], - ['egressContainerPort', '8102'], - ['healthCheckPath', "'/health'"], - ]) { - expect(property(caller.arguments![1], key).getText()).toBe(value); - } - }); - - test('preserves the task security group and outbound rule without legacy ALB rules', () => { - const sg = resource(service, 'aws.ec2.SecurityGroup'); - expect(sg.arguments![0].getText()).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(shape(sg.arguments![1])).toEqual({ - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - description: '`${BASE_NAME} service security group`', - tags: 'this.tags', - }); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const outbound = resource(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(outbound.arguments![0].getText()).toBe('`${BASE_NAME}-service-out`'); - expect(shape(outbound.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allow all outbound traffic'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect( - nodes(service, ts.isNewExpression).filter( - (node) => - node.expression.getText() === 'aws.vpc.SecurityGroupIngressRule' - ) - ).toHaveLength(0); - }); - - test('exports the prefixed egress URL in dev and prod without a dedicated hostname', () => { - expect(assignment('this.egressDomain')).toBe( - 'getServiceUrl(ServiceUrl.AGENT_HARNESS_EGRESS_URL)' - ); - expect(variable(index, 'agentHarnessEgressUrl').getText()).toBe( - 'pulumi.interpolate`${service.egressDomain}`' - ); - const urls = parse('../../packages/shared/src/service_urls.ts'); - for (const [map, host] of [ - ['DEV_SERVICE_URLS', 'dev-gateway'], - ['PROD_SERVICE_URLS', 'gateway'], - ]) { - expect( - property( - variable(urls, map), - '[ServiceUrl.AGENT_HARNESS_EGRESS_URL]' - ).getText() - ).toBe(`'https://${host}.macro.com/agent-harness-egress'`); - } - }); - - test('keeps the gateway URL and role outputs and uses the canonical URL for BASE_URL', () => { - expect(assignment('this.domain')).toBe( - 'getServiceUrl(ServiceUrl.AGENT_HARNESS_SERVICE_URL)' - ); - const output = variable(index, 'agentHarnessServiceUrl'); - expect(ts.isCallExpression(output)).toBe(true); - expect((output as ts.CallExpression).expression.getText()).toBe( - 'getServiceUrl' - ); - expect((output as ts.CallExpression).arguments[0].getText()).toBe( - 'ServiceUrl.AGENT_HARNESS_SERVICE_URL' - ); - expect(variable(index, 'agentHarnessServiceRoleArn').getText()).toBe( - 'service.role.arn' - ); - const urls = parse('../../packages/shared/src/service_urls.ts'); - for (const [map, host] of [ - ['DEV_SERVICE_URLS', 'dev-gateway'], - ['PROD_SERVICE_URLS', 'gateway'], - ]) { - expect( - property( - variable(urls, map), - '[ServiceUrl.AGENT_HARNESS_SERVICE_URL]' - ).getText() - ).toBe(`'https://${host}.macro.com/agent-harness'`); - } - const ecs = resource(service, 'awsx.ecs.FargateService'); - const env = property( - ecs.arguments![1], - 'taskDefinitionArgs', - 'containers', - 'service', - 'environment' - ); - expect(nodes(env, ts.isObjectLiteralExpression).map(shape)).toContainEqual({ - name: "'BASE_URL'", - value: 'this.domain', - }); - }); - - test('keeps the streaming idle timeout at 3600 seconds on the shared gateway', () => { - const gateway = resource( - parse('../gateway/index.ts'), - 'MacroApplicationLoadBalancer' - ); - expect(property(gateway.arguments![1], 'idleTimeout').getText()).toBe( - '3600' - ); - }); -}); diff --git a/infra/stacks/agent-schedule-service/legacy-alb.test.ts b/infra/stacks/agent-schedule-service/legacy-alb.test.ts deleted file mode 100644 index 45745b90119..00000000000 --- a/infra/stacks/agent-schedule-service/legacy-alb.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -// Parse source only: importing the stack would perform cloud lookups and builds. -function parse(file: string): ts.SourceFile { - return ts.createSourceFile( - file, - readFileSync(new URL(file, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = parse('./service.ts'); -const index = parse('./index.ts'); - -function nodes( - root: ts.Node, - predicate: (node: ts.Node) => node is T -): T[] { - const result: T[] = []; - function visit(node: ts.Node): void { - if (predicate(node)) result.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return result; -} - -function text(node: ts.Node): string { - return node.getText().replace(/\s+/g, ''); -} - -function constructors( - type: string, - root: ts.Node = service -): ts.NewExpression[] { - return nodes(root, ts.isNewExpression).filter( - (node) => text(node.expression) === type - ); -} - -function resource(type: string, root: ts.Node = service): ts.NewExpression { - const matches = constructors(type, root); - expect(matches).toHaveLength(1); - return matches[0]; -} - -function property(node: ts.Node, key: string): ts.Expression { - if (!ts.isObjectLiteralExpression(node)) throw new Error('Expected object'); - const member = node.properties.find((entry) => entry.name?.getText() === key); - if (member && ts.isPropertyAssignment(member)) return member.initializer; - if (member && ts.isShorthandPropertyAssignment(member)) return member.name; - throw new Error(`Missing property ${key}`); -} - -function shape(node: ts.Node): Record { - if (!ts.isObjectLiteralExpression(node)) throw new Error('Expected object'); - return Object.fromEntries( - node.properties.map((entry) => { - const key = entry.name?.getText(); - if (!key) throw new Error('Expected named property'); - return [key, text(property(node, key))]; - }) - ); -} - -function elements(node: ts.Node): ts.Expression[] { - if (!ts.isArrayLiteralExpression(node)) throw new Error('Expected array'); - return [...node.elements]; -} - -function variable(file: ts.SourceFile, name: string): ts.Expression { - const matches = nodes(file, ts.isVariableDeclaration).filter( - (node) => text(node.name) === name - ); - expect(matches).toHaveLength(1); - if (!matches[0].initializer) throw new Error(`Missing initializer: ${name}`); - return matches[0].initializer; -} - -const ecs = resource('awsx.ecs.FargateService'); -const ecsArgs = ecs.arguments![1]; -const caller = resource('AgentScheduleService', index); -const container = property( - property(property(ecsArgs, 'taskDefinitionArgs'), 'containers'), - 'service' -); - -test('removes dedicated ALB, DNS, listeners, and legacy security-group surface', () => { - for (const file of [service, index]) { - const identifiers = nodes(file, ts.isIdentifier).map((node) => node.text); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'serviceAlbSg', - 'isPrivate', - 'publicSubnetIds', - ]) { - expect(identifiers).not.toContain(removed); - } - for (const type of [ - 'aws.lb.LoadBalancer', - 'aws.lb.Listener', - 'aws.lb.ListenerRule', - 'aws.lb.TargetGroup', - 'aws.route53.Record', - 'aws.vpc.SecurityGroupIngressRule', - ]) { - expect(constructors(type, file)).toHaveLength(0); - } - } - const fields = nodes(service, ts.isPropertyDeclaration).map((node) => - text(node.name) - ); - for (const field of ['lb', 'listener', 'targetGroup']) { - expect(fields).not.toContain(field); - } -}); - -test('retains scheduled-action gateway identity, paired routes, health and port', () => { - expect(text(variable(service, 'BASE_NAME'))).toBe('pulumi.getProject()'); - expect(text(variable(service, 'GATEWAY_PATH_PREFIX'))).toBe( - "'/scheduled-action'" - ); - const component = nodes(service, ts.isCallExpression).find( - (node) => node.expression.kind === ts.SyntaxKind.SuperKeyword - ); - expect(component!.arguments.map(text)).toEqual([ - "'my:components:AgentScheduleService'", - 'name', - '{}', - 'opts', - ]); - expect(text(caller.arguments![0])).toBe('`agent-schedule-service-${stack}`'); - expect(text(ecs.arguments![0])).toBe('`${BASE_NAME}`'); - const gateway = resource('ServiceTargetGroup'); - expect(text(gateway.arguments![0])).toBe('`${stack}-${BASE_NAME}`'); - expect(shape(gateway.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.AGENT_SCHEDULE_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: '[GATEWAY_PATH_PREFIX,`${GATEWAY_PATH_PREFIX}/*`]', - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - }); - expect(shape(gateway.arguments![2])).toEqual({ parent: 'this' }); - expect(text(property(caller.arguments![1], 'serviceContainerPort'))).toBe( - '8080' - ); - expect(text(property(caller.arguments![1], 'healthCheckPath'))).toBe( - "'/health'" - ); -}); - -test('registers ECS only with the gateway and retains listener dependency', () => { - expect(elements(property(ecsArgs, 'loadBalancers')).map(shape)).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - ]); - expect(elements(property(container, 'portMappings')).map(shape)).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'gatewayTargetGroup.target_group', - }, - ]); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: '[gatewayTargetGroup.listener_rule]', - }); - expect(shape(property(ecsArgs, 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: '[this.serviceSg.id]', - }); -}); - -test('preserves the service security group and unrestricted outbound rule', () => { - const sg = resource('aws.ec2.SecurityGroup'); - expect(text(sg.arguments![0])).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(shape(sg.arguments![1])).toEqual({ - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - description: '`${BASE_NAME}servicesecuritygroup`', - tags: 'this.tags', - }); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const egress = resource('aws.vpc.SecurityGroupEgressRule'); - expect(text(egress.arguments![0])).toBe('`${BASE_NAME}-service-out`'); - expect(shape(egress.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allowalloutboundtraffic'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); -}); - -test('preserves BASE_URL and Output-shaped dev/prod scheduled-action URL', () => { - expect(text(variable(service, 'GATEWAY_DOMAIN_NAME'))).toBe( - "`${stack==='prod'?'gateway':'dev-gateway'}.${BASE_DOMAIN}`" - ); - const assignments = nodes(service, ts.isBinaryExpression).filter( - (node) => text(node.left) === 'this.domain' - ); - expect(assignments).toHaveLength(1); - expect(text(assignments[0].right)).toBe( - '`https://${GATEWAY_DOMAIN_NAME}${GATEWAY_PATH_PREFIX}`' - ); - const baseUrl = elements(property(container, 'environment')).find((node) => - ts.isObjectLiteralExpression(node) - ); - expect(shape(baseUrl!)).toEqual({ name: "'BASE_URL'", value: 'this.domain' }); - expect(text(variable(index, 'agentScheduleServiceUrl'))).toBe( - 'pulumi.interpolate`${service.domain}`' - ); - expect(text(variable(index, 'agentScheduleServiceRoleArn'))).toBe( - 'service.role.arn' - ); -}); - -test('preserves CPU-only scaling and capacity/deployment settings', () => { - const target = resource('aws.appautoscaling.Target'); - expect(text(target.arguments![0])).toBe( - '`${BASE_NAME}-service-scalable-target-${stack}`' - ); - expect(shape(target.arguments![1])).toEqual({ - maxCapacity: "stack==='prod'?3:2", - minCapacity: '1', - resourceId: - 'pulumi.interpolate`service/${this.cloudStorageClusterName}/${this.service.service.name}`', - scalableDimension: "'ecs:service:DesiredCount'", - serviceNamespace: "'ecs'", - tags: 'this.tags', - }); - expect(shape(target.arguments![2])).toEqual({ parent: 'this' }); - const policy = resource('aws.appautoscaling.Policy'); - expect(text(policy.arguments![0])).toBe( - '`${BASE_NAME}-scaling-policy-cpu-${stack}`' - ); - expect(shape(policy.arguments![1])).toEqual({ - policyType: "'TargetTrackingScaling'", - resourceId: 'target.resourceId', - scalableDimension: 'target.scalableDimension', - serviceNamespace: 'target.serviceNamespace', - targetTrackingScalingPolicyConfiguration: - "{targetValue:60,predefinedMetricSpecification:{predefinedMetricType:'ECSServiceAverageCPUUtilization',},scaleInCooldown:60,scaleOutCooldown:120,}", - }); - expect(shape(policy.arguments![2])).toEqual({ parent: 'this' }); - expect(shape(container)).toMatchObject({ - cpu: '256', - memory: '512', - stopTimeout: '10', - }); - expect(text(property(ecsArgs, 'desiredCount'))).toBe('1'); - expect(text(property(ecsArgs, 'continueBeforeSteadyState'))).toBe( - 'DEFAULT_CONTINUE_BEFORE_STEADY_STATE' - ); - expect(shape(property(ecsArgs, 'deploymentCircuitBreaker'))).toEqual({ - enable: 'true', - rollback: 'true', - }); - expect( - shape(property(resource('EcrImage').arguments![1], 'buildArgs')) - ).toEqual({ - SERVICE_NAME: "'service'", - }); -}); - -test('retains exactly the existing CPU and deployment alarms', () => { - const alarm = resource('aws.cloudwatch.MetricAlarm'); - expect(text(alarm.arguments![0])).toBe('`${BASE_NAME}-service-cpu-alarm`'); - expect(shape(alarm.arguments![1])).toEqual({ - name: '`${BASE_NAME}-service-cpu-${stack}`', - alarmDescription: '`Alarmwhen${BASE_NAME}CPUstayselevated`', - namespace: "'AWS/ECS'", - metricName: "'CPUUtilization'", - statistic: "'Average'", - period: '300', - evaluationPeriods: '2', - threshold: '90', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - dimensions: - '{ClusterName:pulumi.interpolate`${this.cloudStorageClusterName}`,ServiceName:this.service.service.name,}', - alarmActions: '[CLOUD_TRAIL_SNS_TOPIC_ARN]', - tags: 'this.tags', - }); - expect(shape(alarm.arguments![2])).toEqual({ parent: 'this' }); - const deployment = resource('EcsDeploymentFailureAlarm'); - expect(text(deployment.arguments![0])).toBe( - '`${BASE_NAME}-deployment-failure-alarm`' - ); - expect(shape(deployment.arguments![1])).toEqual({ - serviceName: 'BASE_NAME', - serviceArn: 'this.service.service.arn', - tags: 'this.tags', - }); - expect(shape(deployment.arguments![2])).toEqual({ parent: 'this' }); -}); diff --git a/infra/stacks/connection-gateway/legacy-alb.test.ts b/infra/stacks/connection-gateway/legacy-alb.test.ts deleted file mode 100644 index 52711c18199..00000000000 --- a/infra/stacks/connection-gateway/legacy-alb.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -// Parse source only: importing a stack would run Pulumi lookups and image builds. -function parse(relativePath: string): ts.SourceFile { - return ts.createSourceFile( - relativePath, - readFileSync(new URL(relativePath, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = parse('./connection_gateway.ts'); -const index = parse('./index.ts'); - -function nodes( - root: ts.Node, - guard: (node: ts.Node) => node is T -): T[] { - const matches: T[] = []; - function visit(node: ts.Node): void { - if (guard(node)) matches.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return matches; -} - -function resource( - source: ts.SourceFile, - constructorName: string, - name?: string -): ts.NewExpression { - const matches = nodes(source, ts.isNewExpression).filter( - (node) => - node.expression.getText() === constructorName && - (name === undefined || node.arguments?.[0].getText() === name) - ); - expect(matches).toHaveLength(1); - return matches[0]; -} - -function variable(source: ts.SourceFile, name: string): ts.Expression { - const matches = nodes(source, ts.isVariableDeclaration).filter( - (node) => node.name.getText() === name - ); - expect(matches).toHaveLength(1); - const initializer = matches[0].initializer; - if (!initializer) throw new Error(`Missing initializer for ${name}`); - return initializer; -} - -function property(node: ts.Node, ...path: string[]): ts.Node { - let current = node; - for (const key of path) { - if (!ts.isObjectLiteralExpression(current)) { - throw new Error(`Expected object for ${key}`); - } - const member = current.properties.find( - (entry) => entry.name?.getText() === key - ); - if (!member) throw new Error(`Missing property ${key}`); - if (ts.isPropertyAssignment(member)) current = member.initializer; - else if (ts.isShorthandPropertyAssignment(member)) current = member.name; - else throw new Error(`Unsupported property ${key}`); - } - return current; -} - -type Shape = string | Shape[] | { [key: string]: Shape }; - -function shape(node: ts.Node): Shape { - if (ts.isObjectLiteralExpression(node)) { - return Object.fromEntries( - node.properties.map((member) => { - const key = member.name?.getText(); - if (!key) throw new Error('Expected named property'); - return [key, shape(property(node, key))]; - }) - ); - } - if (ts.isArrayLiteralExpression(node)) return node.elements.map(shape); - return node.getText(); -} - -describe('connection-gateway shared gateway migration', () => { - test('removes dedicated load balancing, DNS, and obsolete API fields', () => { - const identifiers = [service, index].flatMap((source) => - nodes(source, ts.isIdentifier).map((node) => node.text) - ); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'BASE_DOMAIN', - 'serviceAlbSg', - 'connectionGatewayAlbSgId', - 'isPrivate', - 'publicSubnetIds', - 'listener', - 'lbPortion', - 'tgPortion', - ]) { - expect(identifiers).not.toContain(removed); - } - expect( - nodes(service, ts.isPropertyDeclaration).map((node) => - node.name.getText() - ) - ).not.toContain('lb'); - const constructors = nodes(service, ts.isNewExpression).map((node) => - node.expression.getText() - ); - expect(constructors).not.toContain('MacroApplicationLoadBalancer'); - expect( - constructors.some((name) => /^aws\.(lb|alb|route53)\./.test(name)) - ).toBe(false); - }); - - test('preserves websocket routes, gateway identity, health check, and 30-second drain', () => { - const gateway = resource(service, 'ServiceTargetGroup'); - expect(gateway.arguments![0].getText()).toBe('`${stack}-${BASE_NAME}`'); - expect(shape(gateway.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.CONNECTION_GATEWAY', - healthCheckPath: 'healthCheckPath', - pathPatterns: ["'/connection-gateway'", "'/connection-gateway/*'"], - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - deregistrationDelay: '30', - }); - expect(shape(gateway.arguments![2])).toEqual({ parent: 'this' }); - const caller = resource(index, 'ConnectionGateway'); - expect(caller.arguments![0].getText()).toBe( - '`connection-gateway-${stack}`' - ); - expect( - property(caller.arguments![1], 'serviceContainerPort').getText() - ).toBe('8080'); - expect(property(caller.arguments![1], 'healthCheckPath').getText()).toBe( - "'/health'" - ); - const assignments = nodes(service, ts.isBinaryExpression).filter( - (node) => node.left.getText() === 'this.targetGroup' - ); - expect(assignments).toHaveLength(1); - expect(assignments[0].right.getText()).toBe( - 'gatewayTargetGroup.target_group' - ); - }); - - test('registers ECS only with the gateway and retains its listener dependency and capacity', () => { - const ecs = resource(service, 'awsx.ecs.FargateService'); - expect(ecs.arguments![0].getText()).toBe('`${BASE_NAME}`'); - expect(shape(property(ecs.arguments![1], 'loadBalancers'))).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - ]); - const container = property( - ecs.arguments![1], - 'taskDefinitionArgs', - 'containers', - 'service' - ); - expect(shape(property(container, 'portMappings'))).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }, - ]); - for (const [key, value] of [ - ['stopTimeout', '10'], - ['cpu', '4096'], - ['memory', '8192'], - ]) { - expect(property(container, key).getText()).toBe(value); - } - expect(property(ecs.arguments![1], 'desiredCount').getText()).toBe( - "stack === 'prod' ? 3 : 1" - ); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: ['gatewayTargetGroup.listener_rule'], - }); - expect(shape(property(ecs.arguments![1], 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: ['this.serviceSg.id'], - }); - }); - - test('retains only the service security group and unrestricted outbound rule', () => { - const sg = resource(service, 'aws.ec2.SecurityGroup'); - expect(sg.arguments![0].getText()).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(shape(sg.arguments![1])).toEqual({ - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - description: - '`${BASE_NAME} security group that is attached directly to the service`', - tags: 'this.tags', - }); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const egress = resource(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(egress.arguments![0].getText()).toBe('`${BASE_NAME}-all-out`'); - expect(shape(egress.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allow all outbound'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); - expect( - nodes(service, ts.isNewExpression).filter( - (node) => - node.expression.getText() === 'aws.vpc.SecurityGroupIngressRule' - ) - ).toHaveLength(0); - }); - - test('exports the prefixed HTTPS API URL as an Output in dev and prod', () => { - const assignments = nodes(service, ts.isBinaryExpression).filter( - (node) => node.left.getText() === 'this.domain' - ); - expect(assignments).toHaveLength(1); - expect(assignments[0].right.getText()).toBe( - 'getServiceUrl(ServiceUrl.CONNECTION_GATEWAY_URL)' - ); - expect(variable(index, 'connectionGatewayUrl').getText()).toBe( - 'pulumi.interpolate`${connectionGateway.domain}`' - ); - expect(variable(index, 'connectionGatewaySgId').getText()).toBe( - 'connectionGateway.serviceSg.id' - ); - const urls = parse('../../packages/shared/src/service_urls.ts'); - for (const [map, url] of [ - ['DEV_SERVICE_URLS', 'https://dev-gateway.macro.com/connection-gateway'], - ['PROD_SERVICE_URLS', 'https://gateway.macro.com/connection-gateway'], - ]) { - expect( - property( - variable(urls, map), - '[ServiceUrl.CONNECTION_GATEWAY_URL]' - ).getText() - ).toBe(`'${url}'`); - } - }); - - test('uses gateway ARN suffixes for HTTP request scaling without retuning policies', () => { - // Request counts measure HTTP requests, not websocket messages or disconnects. - expect(variable(service, 'resourceLabel').getText()).toBe( - 'pulumi.interpolate`${gatewayLoadBalancer.albArnSuffix}/${this.targetGroup.arnSuffix}`' - ); - const target = resource(service, 'aws.appautoscaling.Target'); - expect(target.arguments![0].getText()).toBe( - '`${BASE_NAME}-service-scalable-target-${stack}`' - ); - expect(shape(target.arguments![1])).toEqual({ - maxCapacity: "stack === 'prod' ? 15 : 3", - minCapacity: "stack === 'prod' ? 3 : 3", - resourceId: - 'pulumi.interpolate`service/${this.cloudStorageClusterName}/${this.service.service.name}`', - scalableDimension: "'ecs:service:DesiredCount'", - serviceNamespace: "'ecs'", - tags: 'this.tags', - }); - for (const [suffix, metric, value, scaleIn, scaleOut] of [ - ['request-count', 'ALBRequestCountPerTarget', '1000', '60', '120'], - ['cpu', 'ECSServiceAverageCPUUtilization', '70.0', '100', '300'], - ['memory', 'ECSServiceAverageMemoryUtilization', '70.0', '100', '300'], - ]) { - const policy = resource( - service, - 'aws.appautoscaling.Policy', - `\`\${BASE_NAME}-scaling-policy-${suffix}-\${stack}\`` - ); - expect(shape(policy.arguments![1])).toEqual({ - policyType: "'TargetTrackingScaling'", - resourceId: 'serviceScalableTarget.resourceId', - scalableDimension: 'serviceScalableTarget.scalableDimension', - serviceNamespace: 'serviceScalableTarget.serviceNamespace', - targetTrackingScalingPolicyConfiguration: { - targetValue: value, - predefinedMetricSpecification: { - predefinedMetricType: `'${metric}'`, - ...(suffix === 'request-count' - ? { resourceLabel: 'resourceLabel' } - : {}), - }, - scaleInCooldown: scaleIn, - scaleOutCooldown: scaleOut, - }, - }); - expect(shape(policy.arguments![2])).toEqual({ parent: 'this' }); - } - }); - - test('scopes the existing 5xx alarm to target HTTP failures without policy changes', () => { - // Target 5xx is not websocket disconnection monitoring; ALB failures remain centralized. - const alarm = resource( - service, - 'aws.cloudwatch.MetricAlarm', - '`${BASE_NAME}-http-5xx-alarm`' - ); - expect(shape(alarm.arguments![1])).toEqual({ - name: '`${BASE_NAME}-http-5xx-${stack}`', - metricName: "'HTTPCode_Target_5XX_Count'", - namespace: "'AWS/ApplicationELB'", - statistic: "'Sum'", - period: '180', - evaluationPeriods: '1', - threshold: '25', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - dimensions: { - LoadBalancer: 'gatewayLoadBalancer.albArnSuffix', - TargetGroup: 'this.targetGroup.arnSuffix', - }, - alarmDescription: - '`High HTTP 5XX count alarm for ${BASE_NAME} gateway target group.`', - actionsEnabled: 'true', - alarmActions: ['CLOUD_TRAIL_SNS_TOPIC_ARN'], - tags: 'this.tags', - }); - expect(shape(alarm.arguments![2])).toEqual({ parent: 'this' }); - }); - - test('relies on the existing shared gateway 3600-second idle timeout', () => { - const gateway = resource( - parse('../gateway/index.ts'), - 'MacroApplicationLoadBalancer' - ); - expect(property(gateway.arguments![1], 'idleTimeout').getText()).toBe( - '3600' - ); - }); -}); diff --git a/infra/stacks/contacts-service/legacy-alb.test.ts b/infra/stacks/contacts-service/legacy-alb.test.ts deleted file mode 100644 index 8fa8721d96e..00000000000 --- a/infra/stacks/contacts-service/legacy-alb.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import ts from 'typescript'; - -function source(file: string): ts.SourceFile { - return ts.createSourceFile( - file, - readFileSync(new URL(file, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true - ); -} - -const service = source('service.ts'); -const index = source('index.ts'); -const printer = ts.createPrinter({ removeComments: true }); - -function text(node: ts.Node): string { - return printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile()); -} - -function nodes( - root: ts.Node, - predicate: (node: ts.Node) => node is T -): T[] { - const result: T[] = []; - function visit(node: ts.Node): void { - if (predicate(node)) result.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return result; -} - -function only(items: T[]): T { - expect(items).toHaveLength(1); - return items[0]; -} - -function construction( - root: ts.Node, - type: string, - name?: string -): ts.NewExpression { - return only( - nodes(root, ts.isNewExpression).filter( - (node) => - text(node.expression) === type && - (name === undefined || text(node.arguments![0]) === name) - ) - ); -} - -function property(node: ts.Node, name: string): ts.Expression { - if (!ts.isObjectLiteralExpression(node)) { - throw new Error(`Expected object: ${text(node)}`); - } - const member = only( - node.properties.filter((entry) => entry.name?.getText() === name) - ); - if (ts.isPropertyAssignment(member)) return member.initializer; - if (ts.isShorthandPropertyAssignment(member)) return member.name; - throw new Error(`Expected property: ${name}`); -} - -function elements(node: ts.Node): ts.NodeArray { - if (!ts.isArrayLiteralExpression(node)) { - throw new Error(`Expected array: ${text(node)}`); - } - return node.elements; -} - -function properties(node: ts.Node, expected: Record): void { - for (const [name, value] of Object.entries(expected)) { - expect(text(property(node, name))).toBe(value); - } -} - -function variable(root: ts.Node, name: string): ts.Expression { - return only( - nodes(root, ts.isVariableDeclaration).filter( - (node) => text(node.name) === name - ) - ).initializer!; -} - -const ecs = construction(service, 'awsx.ecs.FargateService'); -const ecsArgs = ecs.arguments![1]; -const container = property( - property(property(ecsArgs, 'taskDefinitionArgs'), 'containers'), - 'service' -); - -test('removes dedicated ALB, DNS, security group, and obsolete arguments', () => { - for (const file of [service, index]) { - const identifiers = nodes(file, ts.isIdentifier).map((node) => node.text); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'serviceAlbSg', - 'isPrivate', - 'publicSubnetIds', - ]) { - expect(identifiers).not.toContain(removed); - } - const constructors = nodes(file, ts.isNewExpression).map((node) => - text(node.expression) - ); - for (const removed of [ - 'aws.lb.LoadBalancer', - 'aws.lb.Listener', - 'aws.lb.ListenerRule', - 'aws.lb.TargetGroup', - 'aws.route53.Record', - 'aws.vpc.SecurityGroupIngressRule', - ]) { - expect(constructors).not.toContain(removed); - } - expect(file.text).not.toContain('aws.route53'); - } - const fields = nodes(service, ts.isPropertyDeclaration).map((node) => - text(node.name) - ); - expect(fields).not.toContain('lb'); - expect(fields).not.toContain('listener'); -}); - -test('preserves gateway identity, routing, health checks, and service security', () => { - const gateway = construction(service, 'ServiceTargetGroup'); - expect(text(gateway.arguments![0])).toBe('`${stack}-${BASE_NAME}`'); - properties(gateway.arguments![1], { - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.CONTACTS_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: "['/contacts', '/contacts/*']", - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - tags: 'this.tags', - }); - expect(text(gateway.arguments![1])).not.toContain('priority:'); - properties(gateway.arguments![2], { parent: 'this' }); - const caller = construction(index, 'ContactsService'); - expect(text(caller.arguments![0])).toBe("'contacts-service'"); - properties(caller.arguments![1], { - serviceContainerPort: '8080', - healthCheckPath: "'/health'", - contactsQueueArn: 'contactsQueueArn', - }); - const superCall = only( - nodes(service, ts.isCallExpression).filter( - (node) => node.expression.kind === ts.SyntaxKind.SuperKeyword - ) - ); - expect(text(superCall.arguments[0])).toBe( - "'my:components:CloudStorageService'" - ); - const sg = construction(service, 'aws.ec2.SecurityGroup'); - expect(text(sg.arguments![0])).toBe('`${BASE_NAME}-sg-${stack}`'); - properties(sg.arguments![1], { - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - tags: 'this.tags', - }); - properties(sg.arguments![2], { parent: 'this' }); - const outbound = construction(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(text(outbound.arguments![0])).toBe('`${BASE_NAME}-all-out`'); - properties(outbound.arguments![1], { - securityGroupId: 'serviceSg.id', - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - properties(outbound.arguments![2], { parent: 'this' }); - properties(property(ecsArgs, 'networkConfiguration'), { - securityGroups: '[this.serviceSg.id]', - subnets: 'vpc.privateSubnetIds', - }); -}); - -test('registers ECS only with the gateway and retains listener dependency', () => { - const assignment = only( - nodes(service, ts.isBinaryExpression).filter( - (node) => text(node.left) === 'this.targetGroup' - ) - ); - expect(text(assignment.right)).toBe('gatewayTargetGroup.target_group'); - expect(text(ecs.arguments![0])).toBe('`${BASE_NAME}`'); - properties(only([...elements(property(ecsArgs, 'loadBalancers'))]), { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }); - properties(only([...elements(property(container, 'portMappings'))]), { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }); - properties(ecs.arguments![2], { - parent: 'this', - dependsOn: '[gatewayTargetGroup.listener_rule]', - }); -}); - -test('BASE_URL and exported URL agree for prod and non-prod; queues stay stable', () => { - const domain = only( - nodes(service, ts.isBinaryExpression).filter( - (node) => text(node.left) === 'this.domain' - ) - ).right; - const exportedUrl = variable(index, 'contactsServiceUrl'); - for (const stack of ['prod', 'dev', 'staging']) { - const context = { stack, BASE_DOMAIN: 'macro.com' }; - const expected = `https://${stack === 'prod' ? '' : `${stack}-`}gateway.macro.com/contacts`; - // Evaluate only the isolated URL expressions, never the Pulumi modules. - expect(runInNewContext(text(domain), context)).toBe(expected); - expect(runInNewContext(text(exportedUrl), context)).toBe(expected); - } - const baseUrl = only( - elements(property(container, 'environment')).filter( - (node) => - ts.isObjectLiteralExpression(node) && - text(property(node, 'name')) === "'BASE_URL'" - ) - ); - properties(baseUrl, { value: 'this.domain' }); - expect(text(construction(index, 'Queue').arguments![0])).toBe("'contacts'"); - expect(text(variable(index, 'contactsQueueArn'))).toBe( - 'contactsQueue.queue.arn' - ); - expect(text(variable(index, 'contactsQueueName'))).toBe( - 'contactsQueue.queue.name' - ); -}); - -test('preserves gateway request scaling and CPU/memory policy settings', () => { - expect(text(variable(service, 'resourceLabel'))).toBe( - 'pulumi.interpolate `${gatewayAlbArnSuffix}/${gatewayTargetGroup.arnSuffix}`' - ); - const setup = only( - nodes(service, ts.isCallExpression).filter( - (node) => text(node.expression) === 'this.setupAutoScaling' - ) - ); - properties(setup.arguments[0], { - gatewayAlbArnSuffix: 'gatewayLoadBalancer.albArnSuffix', - gatewayTargetGroup: 'gatewayTargetGroup.target_group', - }); - for (const [suffix, metric, target, scaleIn, scaleOut] of [ - ['request-count', 'ALBRequestCountPerTarget', '1000', '60', '120'], - ['cpu', 'ECSServiceAverageCPUUtilization', '70.0', '100', '300'], - ['memory', 'ECSServiceAverageMemoryUtilization', '70.0', '100', '300'], - ]) { - const policy = construction( - service, - 'aws.appautoscaling.Policy', - `\`\${BASE_NAME}-scaling-policy-${suffix}-\${stack}\`` - ); - const tracking = property( - policy.arguments![1], - 'targetTrackingScalingPolicyConfiguration' - ); - properties(tracking, { - targetValue: target, - scaleInCooldown: scaleIn, - scaleOutCooldown: scaleOut, - }); - properties(property(tracking, 'predefinedMetricSpecification'), { - predefinedMetricType: `'${metric}'`, - ...(suffix === 'request-count' ? { resourceLabel: 'resourceLabel' } : {}), - }); - } -}); - -test('scopes the existing 5xx alarm to gateway target errors without policy changes', () => { - const alarm = construction( - service, - 'aws.cloudwatch.MetricAlarm', - '`${BASE_NAME}-http-5xx-alarm`' - ); - properties(alarm.arguments![1], { - name: '`${BASE_NAME}-http-5xx-${stack}`', - metricName: "'HTTPCode_Target_5XX_Count'", - namespace: "'AWS/ApplicationELB'", - statistic: "'Sum'", - period: '180', - evaluationPeriods: '1', - threshold: '25', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - actionsEnabled: 'true', - alarmActions: '[CLOUD_TRAIL_SNS_TOPIC_ARN]', - tags: 'this.tags', - alarmDescription: - '`High HTTP 5XX count alarm for ${BASE_NAME} gateway target group.`', - }); - const dimensions = property(alarm.arguments![1], 'dimensions'); - properties(dimensions, { - LoadBalancer: 'gatewayLoadBalancer.albArnSuffix', - TargetGroup: 'this.targetGroup.arnSuffix', - }); - expect((dimensions as ts.ObjectLiteralExpression).properties).toHaveLength(2); - expect(text(alarm.arguments![1])).not.toContain('treatMissingData'); - properties(alarm.arguments![2], { parent: 'this' }); -}); diff --git a/infra/stacks/convert-service/legacy-alb.test.ts b/infra/stacks/convert-service/legacy-alb.test.ts deleted file mode 100644 index add4b8114e3..00000000000 --- a/infra/stacks/convert-service/legacy-alb.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import ts from 'typescript'; - -function source(file: string): ts.SourceFile { - return ts.createSourceFile( - file, - readFileSync(new URL(file, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true - ); -} - -const service = source('service.ts'); -const index = source('index.ts'); -const printer = ts.createPrinter({ removeComments: true }); - -function text(node: ts.Node): string { - return printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile()); -} - -function nodes( - root: ts.Node, - predicate: (node: ts.Node) => node is T -): T[] { - const result: T[] = []; - function visit(node: ts.Node): void { - if (predicate(node)) result.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return result; -} - -function only(items: readonly T[]): T { - expect(items).toHaveLength(1); - return items[0]; -} - -function construction( - root: ts.Node, - type: string, - name?: string -): ts.NewExpression { - return only( - nodes(root, ts.isNewExpression).filter( - (node) => - text(node.expression) === type && - (name === undefined || text(node.arguments![0]) === name) - ) - ); -} - -function property(node: ts.Node, name: string): ts.Expression { - if (!ts.isObjectLiteralExpression(node)) { - throw new Error(`Expected object: ${text(node)}`); - } - const member = only( - node.properties.filter((entry) => entry.name?.getText() === name) - ); - if (ts.isPropertyAssignment(member)) return member.initializer; - if (ts.isShorthandPropertyAssignment(member)) return member.name; - throw new Error(`Expected property: ${name}`); -} - -function elements(node: ts.Node): ts.NodeArray { - if (!ts.isArrayLiteralExpression(node)) - throw new Error(`Expected array: ${text(node)}`); - return node.elements; -} - -function properties(node: ts.Node, expected: Record): void { - for (const [name, value] of Object.entries(expected)) { - expect(text(property(node, name))).toBe(value); - } -} - -function variable(root: ts.Node, name: string): ts.Expression { - return only( - nodes(root, ts.isVariableDeclaration).filter( - (node) => text(node.name) === name - ) - ).initializer!; -} - -function assignment(name: string): ts.Expression { - return only( - nodes(service, ts.isBinaryExpression).filter( - (node) => text(node.left) === name - ) - ).right; -} - -const ecs = construction(service, 'awsx.ecs.FargateService'); -const ecsArgs = ecs.arguments![1]; -const container = property( - property(property(ecsArgs, 'taskDefinitionArgs'), 'containers'), - 'service' -); - -test('removes dedicated ALB, DNS, security group, and obsolete arguments', () => { - for (const file of [service, index]) { - const identifiers = nodes(file, ts.isIdentifier).map((node) => node.text); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'serviceAlbSg', - 'isPrivate', - 'publicSubnetIds', - ]) { - expect(identifiers).not.toContain(removed); - } - const constructors = nodes(file, ts.isNewExpression).map((node) => - text(node.expression) - ); - for (const removed of [ - 'aws.lb.LoadBalancer', - 'aws.lb.Listener', - 'aws.lb.ListenerRule', - 'aws.lb.TargetGroup', - 'aws.route53.Record', - 'aws.vpc.SecurityGroupIngressRule', - ]) { - expect(constructors).not.toContain(removed); - } - expect(file.text).not.toContain('aws.route53'); - } - const fields = nodes(service, ts.isPropertyDeclaration).map((node) => - text(node.name) - ); - expect(fields).not.toContain('lb'); - expect(fields).not.toContain('listener'); -}); - -test('preserves gateway identity, paired paths, health checks, and service security', () => { - const gateway = construction(service, 'ServiceTargetGroup'); - expect(text(gateway.arguments![0])).toBe('`${stack}-${BASE_NAME}`'); - properties(gateway.arguments![1], { - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.CONVERT_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: "['/convert', '/convert/*']", - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - tags: 'this.tags', - }); - expect(text(gateway.arguments![1])).not.toContain('priority:'); - properties(gateway.arguments![2], { parent: 'this' }); - const registry = variable( - source('../../packages/shared/src/gateway_priorities.ts'), - 'GATEWAY_PRIORITIES' - ); - properties(registry, { '[GatewayService.CONVERT_SERVICE]': '3000' }); - const caller = construction(index, 'ConvertService'); - expect(text(caller.arguments![0])).toBe("'convert-service'"); - properties(caller.arguments![1], { - serviceContainerPort: '8080', - healthCheckPath: "'/health'", - convertQueueArn: 'convertQueueArn', - jobUpdateHandlerLambdaArn: 'jobUpdateHandlerLambdaArn', - }); - const superCall = only( - nodes(service, ts.isCallExpression).filter( - (node) => node.expression.kind === ts.SyntaxKind.SuperKeyword - ) - ); - expect(text(superCall.arguments[0])).toBe( - "'my:components:CloudStorageService'" - ); - const sg = construction(service, 'aws.ec2.SecurityGroup'); - expect(text(sg.arguments![0])).toBe('`${BASE_NAME}-sg-${stack}`'); - properties(sg.arguments![1], { - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - tags: 'this.tags', - }); - properties(sg.arguments![2], { parent: 'this' }); - const outbound = construction(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(text(outbound.arguments![0])).toBe('`${BASE_NAME}-all-out`'); - properties(outbound.arguments![1], { - securityGroupId: 'serviceSg.id', - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - properties(outbound.arguments![2], { parent: 'this' }); - properties(property(ecsArgs, 'networkConfiguration'), { - securityGroups: '[this.serviceSg.id]', - subnets: 'vpc.privateSubnetIds', - }); -}); - -test('registers ECS only with the gateway and retains listener dependency', () => { - expect(text(assignment('this.targetGroup'))).toBe( - 'gatewayTargetGroup.target_group' - ); - expect(text(ecs.arguments![0])).toBe('`${BASE_NAME}`'); - properties(only(elements(property(ecsArgs, 'loadBalancers'))), { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }); - properties(only(elements(property(container, 'portMappings'))), { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }); - properties(ecs.arguments![2], { - parent: 'this', - dependsOn: '[gatewayTargetGroup.listener_rule]', - }); -}); - -test('BASE_URL and exported URL agree for prod and non-prod', () => { - for (const stack of ['prod', 'dev', 'staging']) { - const context = { stack, BASE_DOMAIN: 'macro.com' }; - const expected = `https://${stack === 'prod' ? '' : `${stack}-`}gateway.macro.com/convert`; - // Evaluate isolated URL expressions without executing Pulumi modules. - expect(runInNewContext(text(assignment('this.domain')), context)).toBe( - expected - ); - expect( - runInNewContext(text(variable(index, 'convertServiceUrl')), context) - ).toBe(expected); - } - const baseUrl = only( - elements(property(container, 'environment')).filter( - (node) => - ts.isObjectLiteralExpression(node) && - text(property(node, 'name')) === "'BASE_URL'" - ) - ); - properties(baseUrl, { value: 'this.domain' }); -}); - -test('preserves asynchronous queue, Lambda permissions, role, and specialized image', () => { - const queue = construction(index, 'Queue'); - expect(text(queue.arguments![0])).toBe("'convert-service'"); - properties(queue.arguments![1], { tags: 'tags', maxReceiveCount: '2' }); - expect(text(variable(index, 'convertQueueArn'))).toBe( - 'convertQueue.queue.arn' - ); - expect(text(variable(index, 'convertQueueName'))).toBe( - 'convertQueue.queue.name' - ); - const websocket = construction( - index, - 'pulumi.StackReference', - "'websocket-connection-stack'" - ); - properties(websocket.arguments![1], { - name: '`macro-inc/websocket-connection/${stack}`', - }); - expect( - text(variable(index, 'jobUpdateHandlerLambdaArn')).replace(/\s+/g, '') - ).toBe( - "websocketConnectionStack.getOutput('jobUpdateHandlerLambda').apply((jobUpdateHandlerLambda)=>jobUpdateHandlerLambda.arnasstring)" - ); - expect( - text(variable(index, 'jobUpdateHandlerLambdaName')).replace(/\s+/g, '') - ).toBe( - "jobUpdateHandlerLambdaArn.apply((arn)=>{constjobUpdateHandlerLambdaArnSplit=arn.split(':');returnjobUpdateHandlerLambdaArnSplit[jobUpdateHandlerLambdaArnSplit.length-1];})" - ); - for (const [name, action, resource] of [ - [ - '`${BASE_NAME}-sqs-policy`', - "['sqs:*']", - '[pulumi.interpolate `${convertQueueArn}`]', - ], - [ - '`${BASE_NAME}-lambda-invoke-policy-${stack}`', - "['lambda:InvokeFunction']", - '[jobUpdateHandlerLambdaArn]', - ], - ]) { - const policy = construction(service, 'aws.iam.Policy', name); - properties( - only( - elements( - property(property(policy.arguments![1], 'policy'), 'Statement') - ) - ), - { Action: action, Resource: resource, Effect: "'Allow'" } - ); - properties(policy.arguments![2], { parent: 'this' }); - } - properties(construction(service, 'aws.iam.Role').arguments![1], { - managedPolicyArns: '[queuePolicy.arn, lambdaInvokePolicy.arn]', - }); - expect(text(variable(index, 'convertServiceRoleArn'))).toBe( - 'pulumi.interpolate `${convertService.role.arn}`' - ); - const image = construction(service, 'EcrImage'); - properties(image.arguments![1], { - dockerfile: "'docker/Dockerfile.convert_service'", - imagePath: 'REPO_ROOT', - platform: 'platform', - }); - properties(property(image.arguments![1], 'buildArgs'), { - SERVICE_NAME: "'convert_service'", - }); - properties(container, { cpu: '4096', memory: '8192', stopTimeout: '10' }); -}); - -test('preserves gateway request scaling and CPU/memory policy settings', () => { - expect(text(variable(service, 'resourceLabel'))).toBe( - 'pulumi.interpolate `${gatewayAlbArnSuffix}/${gatewayTargetGroup.arnSuffix}`' - ); - const setup = only( - nodes(service, ts.isCallExpression).filter( - (node) => text(node.expression) === 'this.setupAutoScaling' - ) - ); - properties(setup.arguments[0], { - gatewayAlbArnSuffix: 'gatewayLoadBalancer.albArnSuffix', - gatewayTargetGroup: 'gatewayTargetGroup.target_group', - }); - properties(construction(service, 'aws.appautoscaling.Target').arguments![1], { - maxCapacity: "stack === 'prod' ? 10 : 3", - minCapacity: '1', - }); - for (const [suffix, metric, target, scaleIn, scaleOut] of [ - ['request-count', 'ALBRequestCountPerTarget', '1000', '60', '120'], - ['cpu', 'ECSServiceAverageCPUUtilization', '70.0', '100', '300'], - ['memory', 'ECSServiceAverageMemoryUtilization', '70.0', '100', '300'], - ]) { - const policy = construction( - service, - 'aws.appautoscaling.Policy', - `\`\${BASE_NAME}-scaling-policy-${suffix}-\${stack}\`` - ); - const tracking = property( - policy.arguments![1], - 'targetTrackingScalingPolicyConfiguration' - ); - properties(tracking, { - targetValue: target, - scaleInCooldown: scaleIn, - scaleOutCooldown: scaleOut, - }); - properties(property(tracking, 'predefinedMetricSpecification'), { - predefinedMetricType: `'${metric}'`, - ...(suffix === 'request-count' ? { resourceLabel: 'resourceLabel' } : {}), - }); - } -}); - -test('scopes the existing 5xx alarm to gateway targets without policy changes', () => { - const alarm = construction( - service, - 'aws.cloudwatch.MetricAlarm', - '`${BASE_NAME}-http-5xx-alarm`' - ); - properties(alarm.arguments![1], { - name: '`${BASE_NAME}-http-5xx-${stack}`', - metricName: "'HTTPCode_Target_5XX_Count'", - namespace: "'AWS/ApplicationELB'", - statistic: "'Sum'", - period: '180', - evaluationPeriods: '1', - threshold: '25', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - actionsEnabled: 'true', - alarmActions: '[CLOUD_TRAIL_SNS_TOPIC_ARN]', - tags: 'this.tags', - alarmDescription: - '`High HTTP 5XX count alarm for ${BASE_NAME} gateway target group.`', - }); - const dimensions = property(alarm.arguments![1], 'dimensions'); - properties(dimensions, { - LoadBalancer: 'gatewayLoadBalancer.albArnSuffix', - TargetGroup: 'this.targetGroup.arnSuffix', - }); - expect((dimensions as ts.ObjectLiteralExpression).properties).toHaveLength(2); - expect(text(alarm.arguments![1])).not.toContain('treatMissingData'); - properties(alarm.arguments![2], { parent: 'this' }); -}); diff --git a/infra/stacks/email-service/legacy-alb.test.ts b/infra/stacks/email-service/legacy-alb.test.ts deleted file mode 100644 index 3b6f61fd765..00000000000 --- a/infra/stacks/email-service/legacy-alb.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -// Parse source only: importing a stack would run cloud lookups and image builds. -function readSource(path: string): ts.SourceFile { - return ts.createSourceFile( - path, - readFileSync(new URL(path, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = readSource('./service.ts'); -const index = readSource('./index.ts'); -const urls = readSource('../../packages/shared/src/service_urls.ts'); - -function nodes( - root: ts.Node, - predicate: (node: ts.Node) => node is T -): T[] { - const matches: T[] = []; - function visit(node: ts.Node): void { - if (predicate(node)) matches.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return matches; -} - -function only(matches: T[]): T { - expect(matches).toHaveLength(1); - return matches[0]; -} - -function resource( - root: ts.Node, - constructorName: string, - name?: string -): ts.NewExpression { - return only( - nodes(root, ts.isNewExpression).filter( - (node) => - node.expression.getText() === constructorName && - (name === undefined || node.arguments?.[0].getText() === name) - ) - ); -} - -function property(root: ts.Node, name: string): ts.Expression { - if (!ts.isObjectLiteralExpression(root)) { - throw new Error(`Expected object for ${name}: ${root.getText()}`); - } - const member = only( - root.properties.filter((node) => node.name?.getText() === name) - ); - if (ts.isPropertyAssignment(member)) return member.initializer; - if (ts.isShorthandPropertyAssignment(member)) return member.name; - throw new Error(`Expected property assignment: ${member.getText()}`); -} - -function variable(root: ts.Node, name: string): ts.Expression { - const declaration = only( - nodes(root, ts.isVariableDeclaration).filter( - (node) => node.name.getText() === name - ) - ); - if (!declaration.initializer) throw new Error(`Missing initializer: ${name}`); - return declaration.initializer; -} - -function assignment(name: string): string { - return only( - nodes(service, ts.isBinaryExpression).filter( - (node) => - node.operatorToken.kind === ts.SyntaxKind.EqualsToken && - node.left.getText() === name - ) - ).right.getText(); -} - -type Shape = string | Shape[] | { [key: string]: Shape }; - -function shape(node: ts.Node): Shape { - if (ts.isObjectLiteralExpression(node)) { - return Object.fromEntries( - node.properties.map((member) => { - if (!member.name) throw new Error('Unexpected spread'); - return [ - member.name.getText(), - shape(property(node, member.name.getText())), - ]; - }) - ); - } - if (ts.isArrayLiteralExpression(node)) return node.elements.map(shape); - return node.getText(); -} - -const ecs = resource(service, 'awsx.ecs.FargateService'); -const gateway = resource(service, 'ServiceTargetGroup'); - -test('removes dedicated ALB, DNS, security groups and obsolete arguments', () => { - const identifiers = nodes(service, ts.isIdentifier).map((node) => node.text); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'BASE_DOMAIN', - 'serviceAlbSg', - 'listener', - 'isPrivate', - 'publicSubnetIds', - 'route53', - ]) { - expect(identifiers).not.toContain(removed); - } - expect( - nodes(service, ts.isPropertyDeclaration).map((node) => node.name.getText()) - ).not.toContain('lb'); - expect( - nodes(service, ts.isPropertyAccessExpression).map((node) => node.getText()) - ).not.toContain('this.lb'); - const constructors = nodes(service, ts.isNewExpression).map((node) => - node.expression.getText() - ); - for (const removed of [ - 'aws.lb.LoadBalancer', - 'aws.lb.Listener', - 'aws.lb.ListenerRule', - 'aws.lb.TargetGroup', - 'aws.route53.Record', - 'aws.vpc.SecurityGroupIngressRule', - ]) { - expect(constructors).not.toContain(removed); - } - expect(nodes(index, ts.isIdentifier).map((node) => node.text)).not.toContain( - 'isPrivate' - ); - expect(index.text).not.toContain('serviceAlbSg'); -}); - -test('preserves gateway identity, routing, health checks and security wiring', () => { - expect(variable(service, 'BASE_NAME').getText()).toBe("'email-service'"); - expect(variable(service, 'gatewayLoadBalancer').getText()).toBe( - 'getGatewayAlb()' - ); - expect(gateway.arguments?.[0].getText()).toBe('`${stack}-${BASE_NAME}`'); - expect(shape(gateway.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.EMAIL_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: ["'/email'", "'/email/*'"], - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - }); - expect(shape(gateway.arguments![2])).toEqual({ parent: 'this' }); - const api = resource(index, 'EmailService'); - expect(api.arguments?.[0].getText()).toBe("'email-service'"); - expect(property(api.arguments![1], 'serviceContainerPort').getText()).toBe( - '8080' - ); - expect(property(api.arguments![1], 'healthCheckPath').getText()).toBe( - "'/health'" - ); - - const sg = resource(service, 'aws.ec2.SecurityGroup'); - expect(sg.arguments?.[0].getText()).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(shape(sg.arguments![1])).toEqual({ - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - description: - '`${BASE_NAME} security group that is attached directly to the service`', - tags: 'this.tags', - }); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const egress = resource(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(egress.arguments?.[0].getText()).toBe('`${BASE_NAME}-all-out`'); - expect(shape(egress.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allow all outbound'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); - expect(shape(property(ecs.arguments![1], 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: ['this.serviceSg.id'], - }); -}); - -test('registers ECS and its port mapping only with the existing gateway target', () => { - expect(assignment('this.targetGroup')).toBe( - 'gatewayTargetGroup.target_group' - ); - expect(ecs.arguments?.[0].getText()).toBe('`${BASE_NAME}`'); - expect(shape(property(ecs.arguments![1], 'loadBalancers'))).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - ]); - const task = property(ecs.arguments![1], 'taskDefinitionArgs'); - const container = property(property(task, 'containers'), 'service'); - expect(shape(property(container, 'portMappings'))).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }, - ]); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: ['gatewayTargetGroup.listener_rule'], - }); -}); - -test('exports the gateway email URL as a Pulumi Output in dev and prod', () => { - expect(assignment('this.domain')).toBe( - 'getServiceUrl(ServiceUrl.EMAIL_SERVICE_URL)' - ); - expect(variable(index, 'emailServiceUrl').getText()).toBe( - 'pulumi.interpolate`${emailService.domain}`' - ); - for (const [map, url] of [ - ['DEV_SERVICE_URLS', 'https://dev-gateway.macro.com/email'], - ['PROD_SERVICE_URLS', 'https://gateway.macro.com/email'], - ]) { - expect( - property(variable(urls, map), '[ServiceUrl.EMAIL_SERVICE_URL]').getText() - ).toBe(`'${url}'`); - } -}); - -test('preserves gateway request autoscaling', () => { - expect(variable(service, 'resourceLabel').getText()).toBe( - 'pulumi.interpolate`${gatewayAlbArnSuffix}/${gatewayTargetGroup.arnSuffix}`' - ); - const setup = only( - nodes(service, ts.isCallExpression).filter( - (node) => node.expression.getText() === 'this.setupAutoScaling' - ) - ); - expect(shape(setup.arguments[0])).toEqual({ - gatewayAlbArnSuffix: 'gatewayLoadBalancer.albArnSuffix', - gatewayTargetGroup: 'gatewayTargetGroup.target_group', - }); - const policy = resource( - service, - 'aws.appautoscaling.Policy', - '`${BASE_NAME}-scaling-policy-request-count-${stack}`' - ); - expect( - shape( - property(policy.arguments![1], 'targetTrackingScalingPolicyConfiguration') - ) - ).toEqual({ - targetValue: '1000', - predefinedMetricSpecification: { - predefinedMetricType: "'ALBRequestCountPerTarget'", - resourceLabel: 'resourceLabel', - }, - scaleInCooldown: '60', - scaleOutCooldown: '120', - }); -}); - -test('scopes the existing 5xx alarm to email targets without changing alarm policy', () => { - const alarm = resource( - service, - 'aws.cloudwatch.MetricAlarm', - '`${BASE_NAME}-http-5xx-alarm`' - ); - expect(shape(alarm.arguments![1])).toEqual({ - name: '`${BASE_NAME}-http-5xx-${stack}`', - metricName: "'HTTPCode_Target_5XX_Count'", - namespace: "'AWS/ApplicationELB'", - statistic: "'Sum'", - period: '180', - evaluationPeriods: '1', - threshold: '25', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - dimensions: { - LoadBalancer: 'gatewayLoadBalancer.albArnSuffix', - TargetGroup: 'this.targetGroup.arnSuffix', - }, - alarmDescription: - '`High HTTP 5XX count alarm for ${BASE_NAME} gateway target group.`', - actionsEnabled: 'true', - alarmActions: ['CLOUD_TRAIL_SNS_TOPIC_ARN'], - tags: 'this.tags', - }); - expect(shape(alarm.arguments![2])).toEqual({ parent: 'this' }); -}); diff --git a/infra/stacks/image-proxy-service/legacy-alb.test.ts b/infra/stacks/image-proxy-service/legacy-alb.test.ts deleted file mode 100644 index 40b8b9446f5..00000000000 --- a/infra/stacks/image-proxy-service/legacy-alb.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -// Parse source only: importing a stack would run Pulumi lookups and image builds. -function parse(relativePath: string): ts.SourceFile { - return ts.createSourceFile( - relativePath, - readFileSync(new URL(relativePath, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = parse('./image-proxy-service.ts'); -const index = parse('./index.ts'); - -function nodes( - root: ts.Node, - guard: (node: ts.Node) => node is T -): T[] { - const matches: T[] = []; - function visit(node: ts.Node): void { - if (guard(node)) matches.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return matches; -} - -function resource( - source: ts.SourceFile, - constructorName: string, - name?: string -): ts.NewExpression { - const matches = nodes(source, ts.isNewExpression).filter( - (node) => - node.expression.getText() === constructorName && - (name === undefined || node.arguments?.[0].getText() === name) - ); - expect(matches).toHaveLength(1); - return matches[0]; -} - -function variable(source: ts.SourceFile, name: string): ts.Expression { - const matches = nodes(source, ts.isVariableDeclaration).filter( - (node) => node.name.getText() === name - ); - expect(matches).toHaveLength(1); - const initializer = matches[0].initializer; - if (!initializer) throw new Error(`Missing initializer for ${name}`); - return initializer; -} - -function property(node: ts.Node, ...path: string[]): ts.Node { - let current = node; - for (const key of path) { - if (!ts.isObjectLiteralExpression(current)) { - throw new Error(`Expected object for ${key}`); - } - const member = current.properties.find( - (entry) => entry.name?.getText() === key - ); - if (!member) throw new Error(`Missing property ${key}`); - if (ts.isPropertyAssignment(member)) current = member.initializer; - else if (ts.isShorthandPropertyAssignment(member)) current = member.name; - else throw new Error(`Unsupported property ${key}`); - } - return current; -} - -type Shape = string | Shape[] | { [key: string]: Shape }; - -function shape(node: ts.Node): Shape { - if (ts.isObjectLiteralExpression(node)) { - return Object.fromEntries( - node.properties.map((member) => { - const key = member.name?.getText(); - if (!key) throw new Error('Expected named property'); - return [key, shape(property(node, key))]; - }) - ); - } - if (ts.isArrayLiteralExpression(node)) return node.elements.map(shape); - return node.getText(); -} - -describe('image proxy shared gateway migration', () => { - test('removes dedicated load balancing, DNS, and obsolete API fields', () => { - const identifiers = [service, index].flatMap((source) => - nodes(source, ts.isIdentifier).map((node) => node.text) - ); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'serviceAlbSg', - 'imageProxyServiceAlbSgId', - 'isPrivate', - 'publicSubnetIds', - 'domain', - 'listener', - ]) { - expect(identifiers).not.toContain(removed); - } - expect( - nodes(service, ts.isPropertyDeclaration).map((node) => - node.name.getText() - ) - ).not.toContain('lb'); - expect( - nodes(service, ts.isIdentifier).map((node) => node.text) - ).not.toContain('BASE_DOMAIN'); - const constructors = nodes(service, ts.isNewExpression).map((node) => - node.expression.getText() - ); - expect(constructors).not.toContain('MacroApplicationLoadBalancer'); - expect( - constructors.some((name) => /^aws\.(lb|alb|route53)\./.test(name)) - ).toBe(false); - }); - - test('preserves gateway identity, routes, health check, and security pairing', () => { - const gateway = resource(service, 'ServiceTargetGroup'); - expect(gateway.arguments?.[0].getText()).toBe('`${stack}-${BASE_NAME}`'); - expect(shape(gateway.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.IMAGE_PROXY_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: ["'/image-proxy'", "'/image-proxy/*'"], - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - }); - expect(shape(gateway.arguments![2])).toEqual({ parent: 'this' }); - const caller = resource(index, 'ImageProxyService'); - expect( - property(caller.arguments![1], 'serviceContainerPort').getText() - ).toBe('8080'); - expect(property(caller.arguments![1], 'healthCheckPath').getText()).toBe( - "'/health'" - ); - const assignments = nodes(service, ts.isBinaryExpression).filter( - (node) => node.left.getText() === 'this.targetGroup' - ); - expect(assignments).toHaveLength(1); - expect(assignments[0].right.getText()).toBe( - 'gatewayTargetGroup.target_group' - ); - }); - - test('registers ECS only with the gateway and retains its listener dependency', () => { - const ecs = resource(service, 'awsx.ecs.FargateService'); - expect(ecs.arguments![0].getText()).toBe('`${BASE_NAME}`'); - expect(shape(property(ecs.arguments![1], 'loadBalancers'))).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - ]); - const container = property( - ecs.arguments![1], - 'taskDefinitionArgs', - 'containers', - 'service' - ); - expect(shape(property(container, 'portMappings'))).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }, - ]); - for (const [key, value] of [ - ['cpu', '512'], - ['memory', '1024'], - ['stopTimeout', '10'], - ]) { - expect(property(container, key).getText()).toBe(value); - } - expect(property(ecs.arguments![1], 'desiredCount').getText()).toBe('1'); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: ['gatewayTargetGroup.listener_rule'], - }); - expect(shape(property(ecs.arguments![1], 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: ['this.serviceSg.id'], - }); - }); - - test('retains only the service security group and unrestricted outbound rule', () => { - const sg = resource(service, 'aws.ec2.SecurityGroup'); - expect(sg.arguments![0].getText()).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(shape(sg.arguments![1])).toEqual({ - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - description: - '`${BASE_NAME} security group that is attached directly to the service`', - tags: 'this.tags', - }); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const egress = resource(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(egress.arguments![0].getText()).toBe('`${BASE_NAME}-all-out`'); - expect(shape(egress.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allow all outbound'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); - expect( - nodes(service, ts.isNewExpression).filter( - (node) => - node.expression.getText() === 'aws.vpc.SecurityGroupIngressRule' - ) - ).toHaveLength(0); - }); - - test('retains the dev/prod gateway URL expression and service SG export', () => { - expect(variable(index, 'imageProxyServiceSgId').getText()).toBe( - 'imageProxyService.serviceSg.id' - ); - expect(variable(index, 'imageProxyServiceUrl').getText()).toBe( - "`https://${\n stack === 'prod' ? '' : `${stack}-`\n}gateway.${BASE_DOMAIN}/image-proxy`" - ); - }); - - test('uses gateway ARN suffixes for request scaling without retuning policies', () => { - const calls = nodes(service, ts.isCallExpression).filter( - (node) => node.expression.getText() === 'this.setupAutoScaling' - ); - expect(calls).toHaveLength(1); - expect(shape(calls[0].arguments[0])).toEqual({ - gatewayAlbArnSuffix: 'gatewayLoadBalancer.albArnSuffix', - gatewayTargetGroup: 'gatewayTargetGroup.target_group', - }); - expect(variable(service, 'resourceLabel').getText()).toBe( - 'pulumi.interpolate`${gatewayAlbArnSuffix}/${gatewayTargetGroup.arnSuffix}`' - ); - const target = resource(service, 'aws.appautoscaling.Target'); - expect(target.arguments![0].getText()).toBe( - '`${BASE_NAME}-service-scalable-target-${stack}`' - ); - expect(shape(target.arguments![1])).toEqual({ - maxCapacity: "stack === 'prod' ? 15 : 3", - minCapacity: '1', - resourceId: - 'pulumi.interpolate`service/${this.cloudStorageClusterName}/${this.service.service.name}`', - scalableDimension: "'ecs:service:DesiredCount'", - serviceNamespace: "'ecs'", - tags: 'this.tags', - }); - for (const [suffix, metric, value, scaleIn, scaleOut] of [ - ['request-count', 'ALBRequestCountPerTarget', '1000', '60', '120'], - ['cpu', 'ECSServiceAverageCPUUtilization', '70.0', '100', '300'], - ['memory', 'ECSServiceAverageMemoryUtilization', '70.0', '100', '300'], - ]) { - const policy = resource( - service, - 'aws.appautoscaling.Policy', - `\`\${BASE_NAME}-scaling-policy-${suffix}-\${stack}\`` - ); - expect(shape(policy.arguments![1])).toEqual({ - policyType: "'TargetTrackingScaling'", - resourceId: 'serviceScalableTarget.resourceId', - scalableDimension: 'serviceScalableTarget.scalableDimension', - serviceNamespace: 'serviceScalableTarget.serviceNamespace', - targetTrackingScalingPolicyConfiguration: { - targetValue: value, - predefinedMetricSpecification: { - predefinedMetricType: `'${metric}'`, - ...(suffix === 'request-count' - ? { resourceLabel: 'resourceLabel' } - : {}), - }, - scaleInCooldown: scaleIn, - scaleOutCooldown: scaleOut, - }, - }); - expect(shape(policy.arguments![2])).toEqual({ parent: 'this' }); - } - }); - - test('scopes the existing 5xx alarm to image proxy targets without policy changes', () => { - const alarm = resource( - service, - 'aws.cloudwatch.MetricAlarm', - '`${BASE_NAME}-http-5xx-alarm`' - ); - expect(shape(alarm.arguments![1])).toEqual({ - name: '`${BASE_NAME}-http-5xx-${stack}`', - metricName: "'HTTPCode_Target_5XX_Count'", - namespace: "'AWS/ApplicationELB'", - statistic: "'Sum'", - period: '180', - evaluationPeriods: '1', - threshold: '25', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - dimensions: { - LoadBalancer: 'gatewayLoadBalancer.albArnSuffix', - TargetGroup: 'this.targetGroup.arnSuffix', - }, - alarmDescription: - '`High HTTP 5XX count alarm for ${BASE_NAME} gateway target group.`', - actionsEnabled: 'true', - alarmActions: ['CLOUD_TRAIL_SNS_TOPIC_ARN'], - tags: 'this.tags', - }); - expect(shape(alarm.arguments![2])).toEqual({ parent: 'this' }); - }); - - test('retains JWT secret permissions and their role attachment', () => { - const policy = resource( - service, - 'aws.iam.Policy', - '`${BASE_NAME}-secrets-policy`' - ); - expect(shape(policy.arguments![1])).toEqual({ - policy: { - Version: "'2012-10-17'", - Statement: [ - { - Action: ["'secretsmanager:GetSecretValue'"], - Resource: ['...secretKeyArns'], - Effect: "'Allow'", - }, - ], - }, - tags: 'this.tags', - }); - const attachment = resource(service, 'aws.iam.RolePolicyAttachment'); - expect(attachment.arguments![0].getText()).toBe( - '`${BASE_NAME}-secrets-policy-attachment`' - ); - expect(shape(attachment.arguments![1])).toEqual({ - role: 'this.role.name', - policyArn: 'secretsPolicy.arn', - }); - expect(shape(variable(index, 'secretKeyArns'))).toEqual([ - 'pulumi.interpolate`${jwtSecretKeyArn}`', - 'pulumi.interpolate`${MACRO_API_TOKENS.macroApiTokenPublicKeyArn}`', - ]); - }); - - test('keeps the implicit 15-second gateway drain and 3600-second idle timeout', () => { - const gatewayTarget = resource(service, 'ServiceTargetGroup'); - expect(shape(gatewayTarget.arguments![1])).not.toHaveProperty( - 'deregistrationDelay' - ); - const sharedTarget = resource( - parse('../../packages/resources/src/resources/service_target_group.ts'), - 'aws.lb.TargetGroup' - ); - expect( - property(sharedTarget.arguments![1], 'deregistrationDelay').getText() - ).toBe('args.deregistrationDelay ?? DEFAULT_DEREGISTRATION_DELAY_SECONDS'); - expect( - variable( - parse( - '../../packages/resources/src/resources/ecs_deployment_defaults.ts' - ), - 'DEFAULT_DEREGISTRATION_DELAY_SECONDS' - ).getText() - ).toBe('15'); - const gateway = resource( - parse('../gateway/index.ts'), - 'MacroApplicationLoadBalancer' - ); - expect(property(gateway.arguments![1], 'idleTimeout').getText()).toBe( - '3600' - ); - }); -}); diff --git a/infra/stacks/search-processing-service/legacy-alb.test.ts b/infra/stacks/search-processing-service/legacy-alb.test.ts deleted file mode 100644 index f0e427fcfa0..00000000000 --- a/infra/stacks/search-processing-service/legacy-alb.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -// Parse source only: importing the stack would perform cloud lookups and builds. -function parse(file: string): ts.SourceFile { - return ts.createSourceFile( - file, - readFileSync(new URL(file, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = parse('./service.ts'); -const index = parse('./index.ts'); - -function nodes( - root: ts.Node, - predicate: (node: ts.Node) => node is T -): T[] { - const result: T[] = []; - function visit(node: ts.Node): void { - if (predicate(node)) result.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return result; -} - -function text(node: ts.Node): string { - return node.getText().replace(/\s+/g, ''); -} - -function constructors( - type: string, - root: ts.Node = service -): ts.NewExpression[] { - return nodes(root, ts.isNewExpression).filter( - (node) => text(node.expression) === type - ); -} - -function resource(type: string, root: ts.Node = service): ts.NewExpression { - const matches = constructors(type, root); - expect(matches).toHaveLength(1); - return matches[0]; -} - -function property(node: ts.Node, key: string): ts.Expression { - if (!ts.isObjectLiteralExpression(node)) { - throw new Error(`Expected object, got ${node.getText()}`); - } - const member = node.properties.find((entry) => entry.name?.getText() === key); - if (member && ts.isPropertyAssignment(member)) return member.initializer; - if (member && ts.isShorthandPropertyAssignment(member)) return member.name; - throw new Error(`Missing property ${key}`); -} - -function shape(node: ts.Node): Record { - if (!ts.isObjectLiteralExpression(node)) throw new Error('Expected object'); - return Object.fromEntries( - node.properties.map((entry) => { - const key = entry.name?.getText(); - if (!key) throw new Error('Expected named property'); - return [key, text(property(node, key))]; - }) - ); -} - -function elements(node: ts.Node): ts.Expression[] { - if (!ts.isArrayLiteralExpression(node)) throw new Error('Expected array'); - return [...node.elements]; -} - -function variable(file: ts.SourceFile, name: string): ts.Expression { - const matches = nodes(file, ts.isVariableDeclaration).filter( - (node) => text(node.name) === name - ); - expect(matches).toHaveLength(1); - if (!matches[0].initializer) throw new Error(`Missing initializer: ${name}`); - return matches[0].initializer; -} - -const ecs = resource('awsx.ecs.FargateService'); -const ecsArgs = ecs.arguments![1]; -const caller = resource('SearchProcessingService', index); -const container = property( - property(property(ecsArgs, 'taskDefinitionArgs'), 'containers'), - 'service' -); - -test('removes dedicated ALB, DNS, listeners, and legacy security-group surface', () => { - for (const file of [service, index]) { - const identifiers = nodes(file, ts.isIdentifier).map((node) => node.text); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'serviceAlbSg', - 'isPrivate', - 'publicSubnetIds', - ]) { - expect(identifiers).not.toContain(removed); - } - for (const type of [ - 'aws.lb.LoadBalancer', - 'aws.lb.Listener', - 'aws.lb.ListenerRule', - 'aws.lb.TargetGroup', - 'aws.route53.Record', - 'aws.vpc.SecurityGroupIngressRule', - ]) { - expect(constructors(type, file)).toHaveLength(0); - } - } - const fields = nodes(service, ts.isPropertyDeclaration).map((node) => - text(node.name) - ); - expect(fields).not.toContain('lb'); - expect(fields).not.toContain('listener'); -}); - -test('retains gateway identity, paired routes, direct health check and port', () => { - expect(text(variable(service, 'BASE_NAME'))).toBe("'search-processing'"); - expect(text(variable(index, 'BASE_NAME'))).toBe( - "'search-processing-service'" - ); - const component = nodes(service, ts.isCallExpression).find( - (node) => node.expression.kind === ts.SyntaxKind.SuperKeyword - ); - expect(component!.arguments.map(text)).toEqual([ - "'my:components:Service'", - 'name', - '{}', - 'opts', - ]); - expect(text(caller.arguments![0])).toBe('`${BASE_NAME}-${stack}`'); - expect(text(ecs.arguments![0])).toBe('`${BASE_NAME}`'); - const gateway = resource('ServiceTargetGroup'); - expect(text(gateway.arguments![0])).toBe('`${stack}-${BASE_NAME}`'); - expect(shape(gateway.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.SEARCH_PROCESSING_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: "['/search-processing','/search-processing/*']", - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - }); - expect(shape(gateway.arguments![2])).toEqual({ parent: 'this' }); - expect(text(property(caller.arguments![1], 'serviceContainerPort'))).toBe( - '8080' - ); - expect(text(property(caller.arguments![1], 'healthCheckPath'))).toBe( - "'/health'" - ); -}); - -test('registers ECS only with the gateway and retains listener dependency', () => { - expect(nodes(service, ts.isBinaryExpression).map(text)).toContain( - 'this.targetGroup=gatewayTargetGroup.target_group' - ); - expect(elements(property(ecsArgs, 'loadBalancers')).map(shape)).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - ]); - expect(elements(property(container, 'portMappings')).map(shape)).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }, - ]); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: '[gatewayTargetGroup.listener_rule]', - }); - expect(shape(property(ecsArgs, 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: '[this.serviceSg.id]', - }); -}); - -test('preserves the service security group and unrestricted outbound rule', () => { - const sg = resource('aws.ec2.SecurityGroup'); - expect(text(sg.arguments![0])).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(text(property(sg.arguments![1], 'name'))).toBe( - '`${BASE_NAME}-sg-${stack}`' - ); - expect(text(property(sg.arguments![1], 'vpcId'))).toBe('vpcId'); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const egress = resource('aws.vpc.SecurityGroupEgressRule'); - expect(text(egress.arguments![0])).toBe('`${BASE_NAME}-all-out`'); - expect(shape(egress.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allowalloutbound'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); -}); - -test('BASE_URL matches the exported dev/prod prefixed gateway URL', () => { - const assignments = nodes(service, ts.isBinaryExpression).filter( - (node) => text(node.left) === 'this.domain' - ); - expect(assignments).toHaveLength(1); - const url = variable(index, 'searchProcessingServiceUrl'); - expect(text(assignments[0].right)).toBe(text(url)); - expect(text(url)).toBe( - "`https://${stack==='prod'?'':`${stack}-`}gateway.${BASE_DOMAIN}/search-processing`" - ); - const baseUrl = elements(property(container, 'environment')).find((node) => - ts.isObjectLiteralExpression(node) - ); - expect(shape(baseUrl!)).toEqual({ name: "'BASE_URL'", value: 'this.domain' }); - expect(text(variable(index, 'searchProcessingServiceRoleArn'))).toBe( - 'searchProcessingService.role.arn' - ); -}); - -test('retains gateway request scaling and 60 percent CPU/memory targets', () => { - const call = nodes(service, ts.isCallExpression).filter( - (node) => text(node.expression) === 'this.setupAutoScaling' - ); - expect(call).toHaveLength(1); - expect(shape(call[0].arguments[0])).toEqual({ - gatewayAlbArnSuffix: 'gatewayLoadBalancer.albArnSuffix', - gatewayTargetGroup: 'gatewayTargetGroup.target_group', - }); - expect(text(variable(service, 'resourceLabel'))).toBe( - 'pulumi.interpolate`${gatewayAlbArnSuffix}/${gatewayTargetGroup.arnSuffix}`' - ); - const policies = constructors('aws.appautoscaling.Policy'); - expect(policies).toHaveLength(3); - for (const [i, kind, metric] of [ - [0, 'request-count', 'ALBRequestCountPerTarget'], - [1, 'cpu', 'ECSServiceAverageCPUUtilization'], - [2, 'memory', 'ECSServiceAverageMemoryUtilization'], - ] as const) { - const policy = policies[i]; - expect(text(policy.arguments![0])).toBe( - `\`\${BASE_NAME}-scaling-policy-${kind}-\${stack}\`` - ); - const config = property( - policy.arguments![1], - 'targetTrackingScalingPolicyConfiguration' - ); - expect(shape(config)).toMatchObject({ - targetValue: i === 0 ? '1000' : '60.0', - scaleInCooldown: i === 0 ? '60' : '100', - scaleOutCooldown: i === 0 ? '120' : '300', - }); - expect(shape(property(config, 'predefinedMetricSpecification'))).toEqual( - i === 0 - ? { - predefinedMetricType: `'${metric}'`, - resourceLabel: 'resourceLabel', - } - : { predefinedMetricType: `'${metric}'` } - ); - } -}); - -test('retains exactly the ECS CPU, memory, and deployment alarms', () => { - const alarms = constructors('aws.cloudwatch.MetricAlarm'); - expect(alarms).toHaveLength(2); - for (const [i, kind, metric, description] of [ - [0, 'cpu', 'CPUUtilization', 'CPU'], - [1, 'mem', 'MemoryUtilization', 'Memory'], - ] as const) { - const alarm = alarms[i]; - expect(text(alarm.arguments![0])).toBe( - `\`\${BASE_NAME}-high-${kind}-alarm\`` - ); - expect(shape(alarm.arguments![1])).toEqual({ - name: `\`\${BASE_NAME}-high-${kind}-alarm-\${stack}\``, - metricName: `'${metric}'`, - namespace: "'AWS/ECS'", - statistic: "'Average'", - period: '180', - evaluationPeriods: '1', - threshold: '80', - comparisonOperator: "'GreaterThanThreshold'", - dimensions: - '{ClusterName:this.clusterName,ServiceName:this.service.service.name,}', - alarmDescription: `\`High${description}usagealarmfor\${BASE_NAME}service.\``, - actionsEnabled: 'true', - alarmActions: '[CLOUD_TRAIL_SNS_TOPIC_ARN]', - tags: 'this.tags', - }); - expect(shape(alarm.arguments![2])).toEqual({ parent: 'this' }); - } - const deployment = resource('EcsDeploymentFailureAlarm'); - expect(text(deployment.arguments![0])).toBe( - '`${BASE_NAME}-deployment-failure-alarm`' - ); - expect(shape(deployment.arguments![1])).toEqual({ - serviceName: 'BASE_NAME', - serviceArn: 'this.service.service.arn', - tags: 'this.tags', - }); - expect(shape(deployment.arguments![2])).toEqual({ parent: 'this' }); -}); diff --git a/infra/stacks/unfurl-service/legacy-alb.test.ts b/infra/stacks/unfurl-service/legacy-alb.test.ts deleted file mode 100644 index bc7d8c600ac..00000000000 --- a/infra/stacks/unfurl-service/legacy-alb.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -// Parse source only: importing a stack would run Pulumi lookups and image builds. -function parse(relativePath: string): ts.SourceFile { - return ts.createSourceFile( - relativePath, - readFileSync(new URL(relativePath, import.meta.url), 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); -} - -const service = parse('./unfurl-service.ts'); -const index = parse('./index.ts'); - -function nodes( - root: ts.Node, - guard: (node: ts.Node) => node is T -): T[] { - const matches: T[] = []; - function visit(node: ts.Node): void { - if (guard(node)) matches.push(node); - ts.forEachChild(node, visit); - } - visit(root); - return matches; -} - -function resource( - source: ts.SourceFile, - constructorName: string, - name?: string -): ts.NewExpression { - const matches = nodes(source, ts.isNewExpression).filter( - (node) => - node.expression.getText() === constructorName && - (name === undefined || node.arguments?.[0].getText() === name) - ); - expect(matches).toHaveLength(1); - return matches[0]; -} - -function variable(source: ts.SourceFile, name: string): ts.Expression { - const matches = nodes(source, ts.isVariableDeclaration).filter( - (node) => node.name.getText() === name - ); - expect(matches).toHaveLength(1); - const initializer = matches[0].initializer; - if (!initializer) throw new Error(`Missing initializer for ${name}`); - return initializer; -} - -function property(node: ts.Node, ...path: string[]): ts.Node { - let current = node; - for (const key of path) { - if (!ts.isObjectLiteralExpression(current)) { - throw new Error(`Expected object for ${key}`); - } - const member = current.properties.find( - (entry) => entry.name?.getText() === key - ); - if (!member) throw new Error(`Missing property ${key}`); - if (ts.isPropertyAssignment(member)) current = member.initializer; - else if (ts.isShorthandPropertyAssignment(member)) current = member.name; - else throw new Error(`Unsupported property ${key}`); - } - return current; -} - -type Shape = string | Shape[] | { [key: string]: Shape }; - -function shape(node: ts.Node): Shape { - if (ts.isObjectLiteralExpression(node)) { - return Object.fromEntries( - node.properties.map((member) => { - const key = member.name?.getText(); - if (!key) throw new Error('Expected named property'); - return [key, shape(property(node, key))]; - }) - ); - } - if (ts.isArrayLiteralExpression(node)) return node.elements.map(shape); - return node.getText(); -} - -describe('unfurl shared gateway migration', () => { - test('removes dedicated load balancing, DNS, and obsolete API fields', () => { - const identifiers = [service, index].flatMap((source) => - nodes(source, ts.isIdentifier).map((node) => node.text) - ); - for (const removed of [ - 'serviceLoadBalancer', - 'SERVICE_DOMAIN_NAME', - 'BASE_DOMAIN', - 'serviceAlbSg', - 'unfurlServiceAlbSgId', - 'isPrivate', - 'publicSubnetIds', - 'domain', - 'listener', - ]) { - expect(identifiers).not.toContain(removed); - } - expect( - nodes(service, ts.isPropertyDeclaration).map((node) => - node.name.getText() - ) - ).not.toContain('lb'); - const constructors = nodes(service, ts.isNewExpression).map((node) => - node.expression.getText() - ); - expect(constructors).not.toContain('MacroApplicationLoadBalancer'); - expect( - constructors.some((name) => /^aws\.(lb|alb|route53)\./.test(name)) - ).toBe(false); - }); - - test('preserves gateway identity, routes, health check, and security pairing', () => { - const gateway = resource(service, 'ServiceTargetGroup'); - expect(gateway.arguments?.[0].getText()).toBe('`${stack}-${BASE_NAME}`'); - expect(shape(gateway.arguments![1])).toEqual({ - tags: 'this.tags', - listenerArn: 'gatewayLoadBalancer.httpsListenerArn', - vpcId: 'vpc.vpcId', - containerPort: 'serviceContainerPort', - service: 'GatewayService.UNFURL_SERVICE', - healthCheckPath: 'healthCheckPath', - pathPatterns: ["'/unfurl'", "'/unfurl/*'"], - serviceSecurityGroupId: 'this.serviceSg.id', - albSecurityGroupId: 'gatewayLoadBalancer.albSecurityGroupId', - }); - expect(shape(gateway.arguments![2])).toEqual({ parent: 'this' }); - const caller = resource(index, 'UnfurlService'); - expect( - property(caller.arguments![1], 'serviceContainerPort').getText() - ).toBe('8080'); - expect(property(caller.arguments![1], 'healthCheckPath').getText()).toBe( - "'/health'" - ); - const assignments = nodes(service, ts.isBinaryExpression).filter( - (node) => node.left.getText() === 'this.targetGroup' - ); - expect(assignments).toHaveLength(1); - expect(assignments[0].right.getText()).toBe( - 'gatewayTargetGroup.target_group' - ); - }); - - test('registers ECS only with the gateway and retains its listener dependency', () => { - const ecs = resource(service, 'awsx.ecs.FargateService'); - expect(ecs.arguments![0].getText()).toBe('`${BASE_NAME}`'); - expect(shape(property(ecs.arguments![1], 'loadBalancers'))).toEqual([ - { - targetGroupArn: 'gatewayTargetGroup.target_group.arn', - containerName: "'service'", - containerPort: 'serviceContainerPort', - }, - ]); - expect( - shape( - property( - ecs.arguments![1], - 'taskDefinitionArgs', - 'containers', - 'service', - 'portMappings' - ) - ) - ).toEqual([ - { - appProtocol: "'http'", - name: '`${BASE_NAME}-tcp-${stack}`', - hostPort: 'serviceContainerPort', - containerPort: 'serviceContainerPort', - targetGroup: 'this.targetGroup', - }, - ]); - expect(shape(ecs.arguments![2])).toEqual({ - parent: 'this', - dependsOn: ['gatewayTargetGroup.listener_rule'], - }); - expect(shape(property(ecs.arguments![1], 'networkConfiguration'))).toEqual({ - subnets: 'vpc.privateSubnetIds', - securityGroups: ['this.serviceSg.id'], - }); - }); - - test('retains only the service security group and unrestricted outbound rule', () => { - const sg = resource(service, 'aws.ec2.SecurityGroup'); - expect(sg.arguments![0].getText()).toBe('`${BASE_NAME}-sg-${stack}`'); - expect(shape(sg.arguments![1])).toEqual({ - name: '`${BASE_NAME}-sg-${stack}`', - vpcId: 'vpcId', - description: - '`${BASE_NAME} security group that is attached directly to the service`', - tags: 'this.tags', - }); - expect(shape(sg.arguments![2])).toEqual({ parent: 'this' }); - const egress = resource(service, 'aws.vpc.SecurityGroupEgressRule'); - expect(egress.arguments![0].getText()).toBe('`${BASE_NAME}-all-out`'); - expect(shape(egress.arguments![1])).toEqual({ - securityGroupId: 'serviceSg.id', - description: "'Allow all outbound'", - cidrIpv4: "'0.0.0.0/0'", - ipProtocol: "'-1'", - tags: 'this.tags', - }); - expect(shape(egress.arguments![2])).toEqual({ parent: 'this' }); - expect( - nodes(service, ts.isNewExpression).filter( - (node) => - node.expression.getText() === 'aws.vpc.SecurityGroupIngressRule' - ) - ).toHaveLength(0); - }); - - test('retains gateway URL and service SG exports for dev and prod', () => { - expect(variable(index, 'unfurlServiceSgId').getText()).toBe( - 'unfurlService.serviceSg.id' - ); - expect(variable(index, 'unfurlServiceUrl').getText()).toBe( - 'getServiceUrl(ServiceUrl.UNFURL_SERVICE_URL)' - ); - const urls = parse('../../packages/shared/src/service_urls.ts'); - for (const [map, url] of [ - ['DEV_SERVICE_URLS', 'https://dev-gateway.macro.com/unfurl'], - ['PROD_SERVICE_URLS', 'https://gateway.macro.com/unfurl'], - ]) { - expect( - property( - variable(urls, map), - '[ServiceUrl.UNFURL_SERVICE_URL]' - ).getText() - ).toBe(`'${url}'`); - } - }); - - test('uses gateway ARN suffixes for request scaling without retuning policies', () => { - expect(variable(service, 'resourceLabel').getText()).toBe( - 'pulumi.interpolate`${gatewayLoadBalancer.albArnSuffix}/${this.targetGroup.arnSuffix}`' - ); - const target = resource(service, 'aws.appautoscaling.Target'); - expect(target.arguments![0].getText()).toBe( - '`${BASE_NAME}-service-scalable-target-${stack}`' - ); - expect(shape(target.arguments![1])).toEqual({ - maxCapacity: "stack === 'prod' ? 15 : 3", - minCapacity: '1', - resourceId: - 'pulumi.interpolate`service/${this.cloudStorageClusterName}/${this.service.service.name}`', - scalableDimension: "'ecs:service:DesiredCount'", - serviceNamespace: "'ecs'", - tags: 'this.tags', - }); - for (const [suffix, metric, value, scaleIn, scaleOut] of [ - ['request-count', 'ALBRequestCountPerTarget', '1000', '60', '120'], - ['cpu', 'ECSServiceAverageCPUUtilization', '70.0', '100', '300'], - ['memory', 'ECSServiceAverageMemoryUtilization', '70.0', '100', '300'], - ]) { - const policy = resource( - service, - 'aws.appautoscaling.Policy', - `\`\${BASE_NAME}-scaling-policy-${suffix}-\${stack}\`` - ); - expect(shape(policy.arguments![1])).toEqual({ - policyType: "'TargetTrackingScaling'", - resourceId: 'serviceScalableTarget.resourceId', - scalableDimension: 'serviceScalableTarget.scalableDimension', - serviceNamespace: 'serviceScalableTarget.serviceNamespace', - targetTrackingScalingPolicyConfiguration: { - targetValue: value, - predefinedMetricSpecification: { - predefinedMetricType: `'${metric}'`, - ...(suffix === 'request-count' - ? { resourceLabel: 'resourceLabel' } - : {}), - }, - scaleInCooldown: scaleIn, - scaleOutCooldown: scaleOut, - }, - }); - expect(shape(policy.arguments![2])).toEqual({ parent: 'this' }); - } - }); - - test('scopes the existing 5xx alarm to unfurl targets without policy changes', () => { - const alarm = resource( - service, - 'aws.cloudwatch.MetricAlarm', - '`${BASE_NAME}-http-5xx-alarm`' - ); - expect(shape(alarm.arguments![1])).toEqual({ - name: '`${BASE_NAME}-http-5xx-${stack}`', - metricName: "'HTTPCode_Target_5XX_Count'", - namespace: "'AWS/ApplicationELB'", - statistic: "'Sum'", - period: '180', - evaluationPeriods: '1', - threshold: '25', - comparisonOperator: "'GreaterThanOrEqualToThreshold'", - dimensions: { - LoadBalancer: 'gatewayLoadBalancer.albArnSuffix', - TargetGroup: 'this.targetGroup.arnSuffix', - }, - alarmDescription: - '`High HTTP 5XX count alarm for ${BASE_NAME} gateway target group.`', - actionsEnabled: 'true', - alarmActions: ['CLOUD_TRAIL_SNS_TOPIC_ARN'], - tags: 'this.tags', - }); - expect(shape(alarm.arguments![2])).toEqual({ parent: 'this' }); - }); - - test('relies on the existing shared gateway 3600-second idle timeout', () => { - const gateway = resource( - parse('../gateway/index.ts'), - 'MacroApplicationLoadBalancer' - ); - expect(property(gateway.arguments![1], 'idleTimeout').getText()).toBe( - '3600' - ); - }); -});