hecks 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,913 @@
1
+ require_relative "../../projector"
2
+ require_relative "shared"
3
+
4
+ module Hecks
5
+ module Projections
6
+ module Deploy
7
+ # The AWS Fargate deploy target. An export
8
+ # (`Projector::Target#projects_as`'s own `needs_world: true`), the
9
+ # same shape `Lambda` is — it reads a domain's own
10
+ # `deployed_to("AwsFargate")` `.world` settings, not only its
11
+ # declaration.
12
+ #
13
+ # `bin/project_deploy` finds and boots the domain's own chapter and
14
+ # its `.world`/`.hecksagon` bindings, calls this through
15
+ # `Projector.call(:aws_fargate, bluebook:, options:, world:)`, and
16
+ # writes the returned tree.
17
+ #
18
+ # ## What this generates
19
+ #
20
+ # A plain CloudFormation stack — an `AWS::ECR::Repository`, an
21
+ # `AWS::ECS::TaskDefinition` (`RequiresCompatibilities: [FARGATE]`,
22
+ # `NetworkMode: awsvpc`) running one container built from the
23
+ # domain's own `rust/host`, an `AWS::ECS::Service` behind an
24
+ # Application Load Balancer fronted by an `AWS::CloudFront::
25
+ # Distribution` (real HTTPS, and a `DefaultCacheBehavior` pinned to
26
+ # Managed-CachingDisabled — see that resource's own comment for
27
+ # why nothing more permissive is a safe default here), and
28
+ # least-privilege task execution/task roles — plus the same private
29
+ # VPC/RDS-or-Aurora instance and temporary era-minting bastion
30
+ # `Lambda` generates, via `Shared`.
31
+ #
32
+ # No SAM: this is deployed with plain `aws cloudformation deploy`,
33
+ # never `sam deploy`, so there is no `samconfig.toml` here. A
34
+ # `Dockerfile` packages `rust/host`'s own compiled binary — built by
35
+ # the generated Makefile before `docker build` ever runs, the same
36
+ # "build outside the container, ship the artifact" shape
37
+ # `lifeadelics/domain/Dockerfile` already uses for a tebako-pressed
38
+ # Ruby binary.
39
+ #
40
+ # ## What this assumes, and does not build
41
+ #
42
+ # `rust/host` runs as a long-lived HTTP server on this domain's own
43
+ # `port` here, not as a Lambda custom-runtime process (`bootstrap`,
44
+ # `Lambda`'s own binary): `HECKS_SERVE_MODE: "1"` (below, in
45
+ # `ContainerDefinitions[0].Environment`) is `rust/host/src/main.rs`'s
46
+ # own top-of-`main` switch into `server.rs`'s axum-based server,
47
+ # which answers this stack's own `GET /` health check with a bare,
48
+ # dispatch-free `200` and routes every other request through the
49
+ # same per-invocation dispatch logic the Lambda target's `bootstrap`
50
+ # binary already runs — see `server.rs`'s own header for the
51
+ # concurrency reasoning (the boot-time Postgres client is already
52
+ # `Arc<Mutex<...>>`-shared, and already anticipated exactly this,
53
+ # per `dispatch.rs`'s own comment on `handle`'s locking).
54
+ #
55
+ # Sidecars and ARM64 are generated, not assumed: the Dockerfile
56
+ # COPYs `#{domain_name}.wasm`/`.ir.json` next to the host binary,
57
+ # the task sets `HECKS_WASM_PATH`/`HECKS_IR_PATH`, RuntimePlatform
58
+ # is ARM64, and the Makefile cross-compiles with
59
+ # aarch64-unknown-linux-gnu (plus the GNU cross-linker on macOS).
60
+ # A Shared-mode ALB sits in OwningPublicSubnetAId/BId — the private
61
+ # pair is unreachable from the internet (found live,
62
+ # lifeadelics-platform). SessionSecret is always minted: HECKS_SERVE_MODE
63
+ # always runs web.rs, which panics on an empty SESSION_SECRET.
64
+ module Fargate
65
+ extend Projector::Target
66
+
67
+ projects_as :aws_fargate, needs_world: true, emits: :files
68
+
69
+ module_function
70
+
71
+ # Generates `template.yaml`, `Makefile`, `Dockerfile`, and (unless
72
+ # this domain borrows another domain's RDS instance)
73
+ # `bastion.yaml` for one domain's `deployed_to("AwsFargate")`
74
+ # deploy target.
75
+ #
76
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter;
77
+ # establishes admission, see `Lambda.call`'s own comment on why generation
78
+ # itself reads `options[:cross_domain_registry]` instead
79
+ # @param options [Hash] generation options — see `Lambda.call`'s own `@option`
80
+ # tags; identical shape, this target reads the same keys
81
+ # @return [Hash{String => String}] `"template.yaml"`, `"Makefile"`, `"Dockerfile"`,
82
+ # and — unless this domain declares `database "Shared"` — `"bastion.yaml"`
83
+ # @raise [ArgumentError] if the domain's own deploy settings conflict, or
84
+ # `deploy.bluebook`'s own `FargateTarget.Declare` refuses them
85
+ def call(bluebook:, options: {})
86
+ world = options.fetch(:world)
87
+ domain = options.fetch(:domain_dir)
88
+ root = options.fetch(:root)
89
+ world_file = options.fetch(:world_file)
90
+ cross_domain_registry = options.fetch(:cross_domain_registry)
91
+ tenant_options = options[:tenant] || {}
92
+
93
+ domain_name = File.basename(domain)
94
+ declared_domain_name = world.domain
95
+
96
+ deploy_settings = world.for_verb("deployed_to")
97
+
98
+ # Same override `Lambda.call` applies, for the same reason — see
99
+ # that method's own comment.
100
+ if tenant_options[:tenant]
101
+ base_stack_name = deploy_settings[:stack_name] || domain_name
102
+ deploy_settings = deploy_settings.merge(stack_name: "#{base_stack_name}-#{tenant_options[:tenant]}",
103
+ schema: tenant_options[:schema] || tenant_options[:tenant])
104
+ end
105
+
106
+ infra_name = deploy_settings[:stack_name] || domain_name
107
+ db_name = infra_name.gsub(/[^a-zA-Z0-9]/, "")
108
+
109
+ # Validated the same way `Lambda.call` validates its own target —
110
+ # `deploy.bluebook`'s own `FargateTarget.Declare`, not a
111
+ # hand-checked `fetch(:cpu) { raise ... }` chain.
112
+ deploy_dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
113
+ begin
114
+ target = deploy_dispatcher.dispatch(
115
+ "Deploy::FargateTarget.Declare",
116
+ with: {
117
+ domain: { value: declared_domain_name },
118
+ region: { value: deploy_settings[:region] },
119
+ cpu: { value: deploy_settings.fetch(:cpu, 256) },
120
+ memory: { value: deploy_settings.fetch(:memory, 512) },
121
+ database: { value: deploy_settings.fetch(:database, "Postgres") },
122
+ web: { value: deploy_settings.fetch(:web, "None") },
123
+ port: { value: deploy_settings.fetch(:port, 8080) }
124
+ }
125
+ ).instance
126
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
127
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsFargate\") is invalid: #{e.message}"
128
+ end
129
+
130
+ region = target.state[:region].value
131
+ cpu = target.state[:cpu].value
132
+ memory = target.state[:memory].value
133
+ database = target.state[:database].value
134
+ port = target.state[:port].value
135
+ aurora = database == "Aurora"
136
+ shared = database == "Shared"
137
+ rust_web = target.state[:web].value == "Rust"
138
+ # Same file-presence convention Lambda uses: `.env.local` is the
139
+ # domain's own gitignored secrets file; `make sync-google-oauth`
140
+ # (Lambda) owns the secret's lifecycle. Fargate only needs to
141
+ # *declare* GOOGLE_OAUTH_SECRET_ID + a redirect URI parameter —
142
+ # the secret itself is never a stack resource.
143
+ google_oauth_present = rust_web &&
144
+ File.exist?(File.join(domain, ".env.local")) &&
145
+ File.read(File.join(domain, ".env.local")).match?(/^GOOGLE_CLIENT_ID=\S/)
146
+
147
+ # Every policy in every loaded chapter — see `Lambda.call`'s own
148
+ # comment on why this reads the whole registry, not only this
149
+ # domain's own top-level list.
150
+ cross_domain_fargate_targets = cross_domain_registry.bluebooks.flat_map { |_name, chapter|
151
+ chapter.policies.select(&:target_domain).map(&:target_domain)
152
+ }.uniq.sort
153
+
154
+ # **The storehouse** — identical borrowing `Lambda.call` supports for
155
+ # `database "Shared"`; see that method's own comment for the full
156
+ # reasoning. `owner`/`owner_stack` stay Ruby-level `deploy_settings`
157
+ # reads, never validated `FargateTarget` attributes, for the same
158
+ # reason: which domain owns the shared instance is a deploy-time
159
+ # wiring fact, not a business invariant.
160
+ if shared
161
+ owner_domain_name = deploy_settings[:owner] or raise ArgumentError, <<~MSG
162
+ #{world_file}'s deployed_to("AwsFargate") declares database "Shared" but no owner. Add one, e.g.:
163
+
164
+ deployed_to("AwsFargate") do
165
+ ...
166
+ database "Shared"
167
+ owner "Embryonaut"
168
+ end
169
+
170
+ naming the already-deployed domain whose Postgres instance this one borrows.
171
+ MSG
172
+ owner_stack_name = deploy_settings[:owner_stack] || "hecks-#{owner_domain_name.downcase}"
173
+ owner_db_name = owner_domain_name.downcase
174
+ end
175
+
176
+ hecks_schema = shared ? infra_name : deploy_settings[:schema]
177
+
178
+ # `infra_name` is already alphanumeric-only + hyphen-friendly for
179
+ # CloudFormation's own logical-id character set, matching
180
+ # `Lambda.call`'s own `logical_id` convention.
181
+ logical_id = "#{infra_name.split(/[_-]/).map(&:capitalize).join}Service"
182
+ stack_prefix = deploy_settings[:stack_prefix] || "hecks"
183
+ stack_name = "#{stack_prefix}-#{infra_name}"
184
+ desired_count = deploy_settings.fetch(:desired_count, 1)
185
+
186
+ db_id = "#{logical_id.sub(/Service\z/, '')}Db"
187
+ db_ref_id = aurora ? "#{db_id}Cluster" : db_id
188
+ secret_sub = aurora ? "#{db_id}Secret" : "#{db_ref_id}.MasterUserSecret.SecretArn"
189
+ secret_intrinsic = aurora ? "!Ref #{db_id}Secret" : "!GetAtt #{db_ref_id}.MasterUserSecret.SecretArn"
190
+ db_secret_ref = shared ? "OwningDatabaseSecretArn" : secret_sub
191
+
192
+ # A Fargate service is reached through an Application Load
193
+ # Balancer sitting in a public subnet, not invoked directly the
194
+ # way a Lambda Function URL is — this domain always needs real
195
+ # internet-facing infrastructure (the `ALB`'s own ingress, and
196
+ # egress for an `ECR` image pull/CloudWatch Logs/Secrets Manager),
197
+ # unlike `Lambda`'s own NAT Gateway, which is opt-in
198
+ # (`google_oauth_present`) because a plain dispatch Lambda needs
199
+ # no internet access at all. `Shared.vpc_and_database_yaml`'s
200
+ # `google_oauth_present:` parameter is exactly this "does this
201
+ # domain need its own NAT Gateway/public subnet" question,
202
+ # unconditionally true here.
203
+ network_needs_internet = true
204
+
205
+ # Never created at all when `shared` — the compute-side security
206
+ # group `Shared.vpc_and_database_yaml` would otherwise declare is
207
+ # skipped along with the rest of this domain's own VPC (same as
208
+ # `Lambda.call`'s own Shared-mode `VpcConfig`); the `ECS` task and
209
+ # the `ALB`-ingress rule both reach through the borrowed owner's
210
+ # own security group instead.
211
+ compute_security_group_ref = shared ? "!Ref OwningSecurityGroupId" : "!Ref #{logical_id}SecurityGroup"
212
+
213
+ ecr_repository_id = "#{logical_id}Repository"
214
+ cluster_id = "#{logical_id}Cluster"
215
+ task_definition_id = "#{logical_id}TaskDefinition"
216
+ execution_role_id = "#{logical_id}ExecutionRole"
217
+ task_role_id = "#{logical_id}TaskRole"
218
+ log_group_id = "#{logical_id}LogGroup"
219
+ target_group_id = "#{logical_id}TargetGroup"
220
+ alb_id = "#{logical_id}Alb"
221
+ alb_sg_id = "#{logical_id}AlbSecurityGroup"
222
+ listener_id = "#{logical_id}Listener"
223
+ distribution_id = "#{logical_id}Distribution"
224
+ session_secret_id = "#{logical_id}SessionSecret"
225
+
226
+ stack_outputs = Shared.stack_outputs(
227
+ shared: shared, db_id: db_id, db_ref_id: db_ref_id, secret_intrinsic: secret_intrinsic,
228
+ compute_security_group_ref: compute_security_group_ref, google_oauth_present: network_needs_internet
229
+ )
230
+ bastion_parameters = Shared.bastion_parameters(shared: shared, google_oauth_present: network_needs_internet)
231
+ Shared.check_bastion_parameters!(bastion_parameters, stack_outputs)
232
+
233
+ always_params_yaml = <<~ALWAYSPARAMS.rstrip
234
+ ImageTag:
235
+ Type: String
236
+ Default: latest
237
+ Description: ECR image tag this task pulls — never hardcode latest in the TaskDefinition; a first deploy and a later rollout share this one parameter.
238
+ ALWAYSPARAMS
239
+ oauth_params_yaml = google_oauth_present ? <<~OAUTHPARAMS.rstrip : ""
240
+ # Same chicken-egg as Lambda's WebRedirectBaseUrl — CloudFront's
241
+ # hostname does not exist until this stack does. Empty on a true
242
+ # first deploy; `make deploy` looks up Outputs.CloudFrontDomain
243
+ # after and self-heals.
244
+ WebRedirectBaseUrl:
245
+ Type: String
246
+ Default: ""
247
+ OAUTHPARAMS
248
+ # Built outside the template heredoc so Layout/HeredocIndentation
249
+ # cannot re-indent YAML that must match SessionSecretRead / env.
250
+ # First interpolated line sits at the `\#{...}` column; later lines
251
+ # get that same left pad (lambda.rb's own OAUTHPOLICY pattern).
252
+ oauth_task_policy_yaml = google_oauth_present ? <<~OAUTHPOLICY.rstrip : ""
253
+ - PolicyName: GoogleOauthSecretRead
254
+ PolicyDocument:
255
+ Version: '2012-10-17'
256
+ Statement:
257
+ - Effect: Allow
258
+ Action: secretsmanager:GetSecretValue
259
+ Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
260
+ OAUTHPOLICY
261
+ oauth_task_env_yaml = google_oauth_present ? <<~OAUTHENV.rstrip : ""
262
+ - Name: GOOGLE_OAUTH_SECRET_ID
263
+ Value: #{stack_name}-web-google-oauth
264
+ - Name: GOOGLE_REDIRECT_URI
265
+ Value: !Sub "${WebRedirectBaseUrl}/auth/google/callback"
266
+ OAUTHENV
267
+ owning_params_yaml = shared ? <<~SHAREDPARAMS.rstrip : ""
268
+ # The storehouse — #{owner_domain_name}'s own live stack Outputs,
269
+ # looked up at deploy time (the generated Makefile's own `deploy:`
270
+ # target) via `aws cloudformation describe-stacks`, the identical
271
+ # pattern `Lambda`'s own generated Makefile already uses for a
272
+ # Shared-mode domain.
273
+ OwningVpcId:
274
+ Type: AWS::EC2::VPC::Id
275
+ OwningSubnetAId:
276
+ Type: AWS::EC2::Subnet::Id
277
+ OwningSubnetBId:
278
+ Type: AWS::EC2::Subnet::Id
279
+ # PUBLIC subnets, NOT OwningSubnetAId/OwningSubnetBId above — a
280
+ # real, live deploy (lifeadelics-platform) found the ALB placed in
281
+ # the private pair creates successfully and reports its target
282
+ # health as healthy (health checks run from inside the VPC), yet
283
+ # is completely unreachable from outside it:
284
+ # OwningSubnetAId/OwningSubnetBId have MapPublicIpOnLaunch: false
285
+ # and no Internet Gateway route. These two — resolved from the
286
+ # owner stack's own PublicSubnetId/BastionSubnetId Outputs,
287
+ # MapPublicIpOnLaunch: true with a real 0.0.0.0/0 -> igw route —
288
+ # are the pair that actually works for an internet-facing ALB.
289
+ # Service.NetworkConfiguration below still (correctly) uses the
290
+ # private pair for the tasks themselves — only the ALB moves.
291
+ OwningPublicSubnetAId:
292
+ Type: AWS::EC2::Subnet::Id
293
+ OwningPublicSubnetBId:
294
+ Type: AWS::EC2::Subnet::Id
295
+ OwningSecurityGroupId:
296
+ Type: AWS::EC2::SecurityGroup::Id
297
+ OwningDatabaseEndpoint:
298
+ Type: String
299
+ OwningDatabaseSecretArn:
300
+ Type: String
301
+ SHAREDPARAMS
302
+ parameters_yaml = [always_params_yaml, oauth_params_yaml, owning_params_yaml].reject(&:empty?).join("\n")
303
+
304
+ template_yaml = <<~YAML
305
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
306
+ # this file rather than hand-editing. Source: #{world_file}'s own
307
+ # deployed_to("AwsFargate") block.
308
+ #
309
+ # Plain CloudFormation, not SAM — deployed with
310
+ # `aws cloudformation deploy`, never `sam deploy`. Self-contained
311
+ # the same way `Lambda`'s own template is: this stack owns its own
312
+ # private VPC, subnets, and RDS Postgres instance (unless
313
+ # database "Shared"), not just the ECS service.
314
+ AWSTemplateFormatVersion: '2010-09-09'
315
+ Description: >
316
+ #{infra_name} — dispatched through hecks's rust/host, running as a
317
+ long-lived container on AWS Fargate, backed by its own private RDS
318
+ Postgres instance.
319
+ #{parameters_yaml.empty? ? "" : "Parameters:\n" + parameters_yaml.each_line.map { |l| " #{l}" }.join}
320
+ Resources:
321
+ #{shared ? "" : Shared.vpc_and_database_yaml(
322
+ db_id: db_id, db_name: db_name, infra_name: infra_name, aurora: aurora,
323
+ google_oauth_present: network_needs_internet, compute_logical_id: logical_id,
324
+ compute_description: "#{logical_id} - inbound from #{alb_sg_id} only, egress rules attached separately below"
325
+ ).each_line.with_index.map { |l, i| (i.zero? ? "" : " ") + l }.join.rstrip}
326
+
327
+ #{alb_sg_id}:
328
+ Type: AWS::EC2::SecurityGroup
329
+ Properties:
330
+ VpcId: #{shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"}
331
+ GroupDescription: #{alb_sg_id} - HTTP ingress from CloudFront only, forwarded to #{logical_id} only
332
+ SecurityGroupIngress:
333
+ # pl-3b927c52 — com.amazonaws.global.cloudfront.origin-
334
+ # facing, AWS's own global, account-agnostic managed
335
+ # prefix list (confirmed: `aws ec2 describe-managed-
336
+ # prefix-lists`, OwnerId "AWS", same id in every
337
+ # account/region). NOT 0.0.0.0/0 — the whole reason
338
+ # #{distribution_id} above exists is Managed-
339
+ # CachingDisabled on every session-cookie-driven
340
+ # route; leaving the ALB itself open to the public
341
+ # internet on this same port would let anyone bypass
342
+ # that distribution (and its HTTPS) entirely and hit
343
+ # the plain-HTTP origin directly — the exact gap a
344
+ # code review caught the first time this resource was
345
+ # added.
346
+ - IpProtocol: tcp
347
+ FromPort: 80
348
+ ToPort: 80
349
+ SourcePrefixListId: pl-3b927c52
350
+
351
+ #{logical_id}IngressFromAlb:
352
+ Type: AWS::EC2::SecurityGroupIngress
353
+ Properties:
354
+ GroupId: #{compute_security_group_ref}
355
+ IpProtocol: tcp
356
+ FromPort: #{port}
357
+ ToPort: #{port}
358
+ SourceSecurityGroupId: !Ref #{alb_sg_id}
359
+
360
+ #{ecr_repository_id}:
361
+ Type: AWS::ECR::Repository
362
+ Properties:
363
+ RepositoryName: #{infra_name}
364
+ ImageScanningConfiguration:
365
+ ScanOnPush: true
366
+
367
+ #{cluster_id}:
368
+ Type: AWS::ECS::Cluster
369
+ Properties:
370
+ ClusterName: #{stack_name}
371
+
372
+ #{log_group_id}:
373
+ Type: AWS::Logs::LogGroup
374
+ Properties:
375
+ LogGroupName: /ecs/#{stack_name}
376
+ RetentionInDays: 30
377
+
378
+ # Always minted — HECKS_SERVE_MODE always runs web.rs, which
379
+ # panics on an empty SESSION_SECRET (found live: hecksagain-pizzas
380
+ # 502 after the Aurora cutover). rust/host fetches this at cold
381
+ # start (secrets.rs), never a plain env var.
382
+ #{session_secret_id}:
383
+ Type: AWS::SecretsManager::Secret
384
+ Properties:
385
+ GenerateSecretString:
386
+ SecretStringTemplate: '{}'
387
+ GenerateStringKey: session_secret
388
+ PasswordLength: 64
389
+ ExcludePunctuation: true
390
+
391
+ # Pulls the image and writes CloudWatch Logs — AWS's own managed
392
+ # AmazonECSTaskExecutionRolePolicy already covers both (ECR auth
393
+ # + GetDownloadUrlForLayer, and logs:CreateLogStream/PutLogEvents);
394
+ # the one grant that policy does NOT cover is fetching this
395
+ # domain's own database secret, added below, least-privilege,
396
+ # scoped to the single secret ARN this stack itself depends on.
397
+ #{execution_role_id}:
398
+ Type: AWS::IAM::Role
399
+ Properties:
400
+ AssumeRolePolicyDocument:
401
+ Version: '2012-10-17'
402
+ Statement:
403
+ - Effect: Allow
404
+ Principal: { Service: ecs-tasks.amazonaws.com }
405
+ Action: sts:AssumeRole
406
+ ManagedPolicyArns:
407
+ - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
408
+ Policies:
409
+ - PolicyName: DbSecretRead
410
+ PolicyDocument:
411
+ Version: '2012-10-17'
412
+ Statement:
413
+ - Effect: Allow
414
+ Action: secretsmanager:GetSecretValue
415
+ Resource: !Sub "${#{db_secret_ref}}"
416
+
417
+ # The container's OWN runtime permissions — rust/host fetches
418
+ # DB_SECRET_ARN itself, over the AWS SDK, the same "never let
419
+ # CloudFormation/ECS configuration see it resolved" posture
420
+ # `Lambda`'s own generated template already holds to (a
421
+ # `Secrets:` ContainerDefinition property would resolve it
422
+ # into a plain environment variable instead).
423
+ #{task_role_id}:
424
+ Type: AWS::IAM::Role
425
+ Properties:
426
+ AssumeRolePolicyDocument:
427
+ Version: '2012-10-17'
428
+ Statement:
429
+ - Effect: Allow
430
+ Principal: { Service: ecs-tasks.amazonaws.com }
431
+ Action: sts:AssumeRole
432
+ Policies:
433
+ - PolicyName: DbSecretRead
434
+ PolicyDocument:
435
+ Version: '2012-10-17'
436
+ Statement:
437
+ - Effect: Allow
438
+ Action: secretsmanager:GetSecretValue
439
+ Resource: !Sub "${#{db_secret_ref}}"
440
+ - PolicyName: SessionSecretRead
441
+ PolicyDocument:
442
+ Version: '2012-10-17'
443
+ Statement:
444
+ - Effect: Allow
445
+ Action: secretsmanager:GetSecretValue
446
+ Resource: !Ref #{session_secret_id}
447
+ #{oauth_task_policy_yaml.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join}
448
+ # TMPL:cross_domain_fargate_policies
449
+
450
+ #{task_definition_id}:
451
+ Type: AWS::ECS::TaskDefinition
452
+ Properties:
453
+ Family: #{infra_name}
454
+ RequiresCompatibilities: [FARGATE]
455
+ NetworkMode: awsvpc
456
+ # ARM64, not Fargate's own x86_64 default — matching the
457
+ # generated Makefile's own aarch64-unknown-linux-gnu build
458
+ # (below) and Lambda's own arm64 toolchain this reuses; a
459
+ # container built for the wrong arch fails at task start,
460
+ # not at build time, so this has to agree with the image
461
+ # docker-build actually pushes.
462
+ RuntimePlatform:
463
+ CpuArchitecture: ARM64
464
+ OperatingSystemFamily: LINUX
465
+ Cpu: "#{cpu}"
466
+ Memory: "#{memory}"
467
+ ExecutionRoleArn: !GetAtt #{execution_role_id}.Arn
468
+ TaskRoleArn: !GetAtt #{task_role_id}.Arn
469
+ ContainerDefinitions:
470
+ - Name: #{infra_name}
471
+ Image: !Sub "${#{ecr_repository_id}.RepositoryUri}:${ImageTag}"
472
+ PortMappings:
473
+ - ContainerPort: #{port}
474
+ LogConfiguration:
475
+ LogDriver: awslogs
476
+ Options:
477
+ awslogs-group: !Ref #{log_group_id}
478
+ awslogs-region: !Ref AWS::Region
479
+ awslogs-stream-prefix: #{infra_name}
480
+ Environment:
481
+ - Name: HECKS_DOMAIN
482
+ Value: #{declared_domain_name}
483
+ - Name: HECKS_ERA
484
+ Value: "1"
485
+ - Name: PORT
486
+ Value: "#{port}"
487
+ # `rust/host/src/server.rs`'s own top-of-`main`
488
+ # switch — without it, this container runs as the
489
+ # Lambda custom-runtime process `Lambda`'s own
490
+ # generated binary always has, which blocks
491
+ # forever polling a Runtime API that doesn't exist
492
+ # here, never answering the health check or
493
+ # anything else on `port`.
494
+ - Name: HECKS_SERVE_MODE
495
+ Value: "1"
496
+ # `web "Rust"` vs `web "None"` is otherwise inert
497
+ # here today — both modes generate the identical
498
+ # task/service/target-group shape, since a Fargate
499
+ # task always answers HTTP on `port` for dispatch
500
+ # requests either way. Passed through so
501
+ # `rust/host`'s own server loop (server.rs) can
502
+ # read it and decide whether to also serve the
503
+ # public web UI in-process, the same `web`-shaped
504
+ # choice `Lambda`'s own `rust_web` already makes
505
+ # for the Lambda path.
506
+ - Name: HECKS_WEB
507
+ Value: #{target.state[:web].value}
508
+ # HECKS_WASM_PATH/HECKS_IR_PATH — main.rs requires
509
+ # both unconditionally at boot (ir::ir().ok_or(...)?,
510
+ # no fallback, and HECKS_WASM_PATH for every
511
+ # dispatch) regardless of `web`/HECKS_SERVE_MODE. A
512
+ # container built with neither set crashes before
513
+ # ever reaching its own serve loop — found live
514
+ # deploying lifeadelics-platform's own domain
515
+ # container, fixed here so every Fargate domain ships
516
+ # both sidecars by default. Paths match the
517
+ # Dockerfile's own COPY destinations, below.
518
+ - Name: HECKS_WASM_PATH
519
+ Value: /usr/local/bin/#{domain_name}.wasm
520
+ - Name: HECKS_IR_PATH
521
+ Value: /usr/local/bin/#{domain_name}.ir.json
522
+ - Name: SESSION_SECRET_ARN
523
+ Value: !Ref #{session_secret_id}
524
+ - Name: HECKS_CHECKOUT_DOMAIN
525
+ Value: #{declared_domain_name}
526
+ #{oauth_task_env_yaml.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join}
527
+ # TMPL:db_env
528
+
529
+ #{target_group_id}:
530
+ Type: AWS::ElasticLoadBalancingV2::TargetGroup
531
+ Properties:
532
+ TargetType: ip
533
+ Port: #{port}
534
+ Protocol: HTTP
535
+ VpcId: #{shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"}
536
+ HealthCheckPath: /
537
+ HealthCheckPort: "#{port}"
538
+
539
+ #{alb_id}:
540
+ Type: AWS::ElasticLoadBalancingV2::LoadBalancer
541
+ Properties:
542
+ Name: #{stack_name}-alb
543
+ Scheme: internet-facing
544
+ Type: application
545
+ SecurityGroups: [!Ref #{alb_sg_id}]
546
+ Subnets: #{shared ? "[!Ref OwningPublicSubnetAId, !Ref OwningPublicSubnetBId]" : "[!Ref #{db_id}PublicSubnet, !Ref #{db_id}BastionPublicSubnet]"}
547
+
548
+ #{listener_id}:
549
+ Type: AWS::ElasticLoadBalancingV2::Listener
550
+ Properties:
551
+ LoadBalancerArn: !Ref #{alb_id}
552
+ Port: 80
553
+ Protocol: HTTP
554
+ DefaultActions:
555
+ - Type: forward
556
+ TargetGroupArn: !Ref #{target_group_id}
557
+
558
+ #{logical_id}:
559
+ Type: AWS::ECS::Service
560
+ DependsOn: #{listener_id}
561
+ Properties:
562
+ ServiceName: #{stack_name}
563
+ Cluster: !Ref #{cluster_id}
564
+ TaskDefinition: !Ref #{task_definition_id}
565
+ DesiredCount: #{desired_count}
566
+ LaunchType: FARGATE
567
+ NetworkConfiguration:
568
+ AwsvpcConfiguration:
569
+ AssignPublicIp: DISABLED
570
+ Subnets: #{shared ? "[!Ref OwningSubnetAId, !Ref OwningSubnetBId]" : "[!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]"}
571
+ SecurityGroups: [#{compute_security_group_ref}]
572
+ LoadBalancers:
573
+ - ContainerName: #{infra_name}
574
+ ContainerPort: #{port}
575
+ TargetGroupArn: !Ref #{target_group_id}
576
+
577
+ # Real HTTPS (the ALB's own Listener above is HTTP-only —
578
+ # nothing else in this stack terminates TLS) and, just as
579
+ # important, the ONE safe default this generator can offer
580
+ # for caching it has no way to reason about: Managed-
581
+ # CachingDisabled. This domain's own routes — including
582
+ # every hecks-native /login, /logout, /auth/google(/callback),
583
+ # /admin/members request (web.rs's own auth_gate/auth_route,
584
+ # generic across every domain, not just this one's own
585
+ # dispatch commands) — are all session-cookie-driven, and
586
+ # this generator has no way to tell which of a domain's own
587
+ # paths would ever be safe to cache. Found live, the hard
588
+ # way (lifeadelics, 2026-09-21): a hand-authored CloudFront
589
+ # stack applied the OPPOSITE default — a custom, cookie-
590
+ # blind cache policy with a 90-120s TTL — and it served one
591
+ # signed-in session's own response (a short-lived SSO
592
+ # handoff token among them) back to a different, unrelated
593
+ # request within that window. A domain that DOES know one
594
+ # of its own paths is genuinely safe to cache (a public,
595
+ # non-personalized page) adds its own more specific
596
+ # CacheBehavior by hand, the same way lifeadelics's own
597
+ # hand-extended three-container stack already does for
598
+ # /_astro/*, /videos/*, and friends — never by loosening
599
+ # this one.
600
+ #{distribution_id}:
601
+ Type: AWS::CloudFront::Distribution
602
+ Properties:
603
+ DistributionConfig:
604
+ Enabled: true
605
+ HttpVersion: http2
606
+ # No ACM/custom domain here — this generator has no
607
+ # notion of one (deploy.bluebook's own FargateTarget
608
+ # declares no `domain` attribute for it) and CloudFront
609
+ # requires an ACM cert in us-east-1 specifically to
610
+ # attach a custom Aliases entry, a real cross-region
611
+ # dependency this generator can't assume. CloudFront's
612
+ # own default *.cloudfront.net certificate/hostname
613
+ # are what Outputs.CloudFrontDomain below reports;
614
+ # point a real domain's DNS at it by hand, same
615
+ # "generated, extend by hand" posture this whole file
616
+ # already has for anything past its own baseline.
617
+ ViewerCertificate:
618
+ CloudFrontDefaultCertificate: true
619
+ Origins:
620
+ - Id: #{alb_id}Origin
621
+ DomainName: !GetAtt #{alb_id}.DNSName
622
+ CustomOriginConfig:
623
+ OriginProtocolPolicy: http-only
624
+ HTTPPort: 80
625
+ HTTPSPort: 443
626
+ DefaultCacheBehavior:
627
+ TargetOriginId: #{alb_id}Origin
628
+ ViewerProtocolPolicy: redirect-to-https
629
+ Compress: true
630
+ AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
631
+ CachedMethods: [GET, HEAD]
632
+ # Managed-CachingDisabled — see this resource's own
633
+ # header comment for why nothing else is safe here
634
+ # by default.
635
+ CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
636
+ # Managed-AllViewer — forwards every cookie/header/
637
+ # query string through uncached, so rust/host's own
638
+ # session-cookie-based auth sees the real request
639
+ # exactly as the browser sent it.
640
+ OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3
641
+
642
+ Outputs:
643
+ ServiceUrl:
644
+ Value: !Sub "http://${#{alb_id}.DNSName}"
645
+ CloudFrontDomain:
646
+ Value: !GetAtt #{distribution_id}.DomainName
647
+ #{stack_outputs.map { |o| "#{o[:key]}:\n Value: #{o[:ref]}" }.join("\n ")}
648
+ YAML
649
+
650
+ # Spliced in after the heredoc renders, not interpolated inside
651
+ # it — `Lambda.call`'s own comment on `# TMPL:cross_domain_lambda_policies`
652
+ # explains why: a `<<~` heredoc's own dedent is computed from its
653
+ # raw source, before any `#{...}` evaluates, so a multi-line
654
+ # value substituted in at runtime is not reindented by the
655
+ # enclosing heredoc a second time — hand-computing a matching
656
+ # prefix in advance drifts out of sync the moment anything
657
+ # upstream shifts this template's own baseline indentation
658
+ # (confirmed the hard way, writing this: the first version
659
+ # hardcoded the raw source column instead of the marker's own
660
+ # actual rendered one, and every subsequent line landed twice as
661
+ # deep as it should have). A plain `String#sub` after the fact,
662
+ # capturing the marker's own real indentation, has no such
663
+ # interaction with the text it replaces into.
664
+ template_yaml = template_yaml.sub(/^([ \t]*)# TMPL:db_env\n/) { db_env_yaml(shared: shared, owner_db_name: owner_db_name, db_ref_id: db_ref_id, db_name: db_name, secret_sub: secret_sub, hecks_schema: hecks_schema, base: $1) }
665
+ template_yaml = template_yaml.sub(/^([ \t]*)# TMPL:cross_domain_fargate_policies\n/) {
666
+ cross_domain_fargate_targets.empty? ? "" : cross_domain_fargate_policy_yaml(cross_domain_fargate_targets, $1)
667
+ }
668
+
669
+ bastion_yaml = shared ? nil : Shared.bastion_yaml(
670
+ domain: domain, infra_name: infra_name, stack_name: stack_name, db_id: db_id,
671
+ google_oauth_present: network_needs_internet, bastion_parameters: bastion_parameters
672
+ )
673
+
674
+ dockerfile = <<~DOCKERFILE
675
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh this
676
+ # file rather than hand-editing. Modeled on lifeadelics/domain/Dockerfile's
677
+ # own shape: build the binary outside the image (this directory's own
678
+ # Makefile, before `docker build` runs), ship only the result — one COPY,
679
+ # not a from-scratch toolchain install on every deploy.
680
+ FROM debian:bookworm-slim
681
+
682
+ # ca-certificates — real outbound TLS (Secrets Manager, any third-party
683
+ # API this domain calls) needs a real system CA bundle.
684
+ # libpq5 — Adapters::PostgresEra's own native `tokio_postgres`/libpq
685
+ # linkage needs the actual shared library present at runtime, the same
686
+ # reason Lambda's own generated Makefile patches libpq.so onto that
687
+ # package's Ruby-side equivalent.
688
+ RUN apt-get update -qq && apt-get install -y --no-install-recommends -qq ca-certificates libpq5 \\
689
+ && rm -rf /var/lib/apt/lists/*
690
+
691
+ COPY #{domain_name}-host /usr/local/bin/#{domain_name}-host
692
+ # The .wasm/.ir.json sidecars main.rs requires at boot —
693
+ # HECKS_WASM_PATH/HECKS_IR_PATH (template.yaml's own
694
+ # ContainerDefinitions Environment) point at these exact paths.
695
+ COPY #{domain_name}.wasm /usr/local/bin/#{domain_name}.wasm
696
+ COPY #{domain_name}.ir.json /usr/local/bin/#{domain_name}.ir.json
697
+
698
+ ENV PORT=#{port}
699
+ ENV BIND=0.0.0.0
700
+ EXPOSE #{port}
701
+
702
+ CMD ["#{domain_name}-host"]
703
+ DOCKERFILE
704
+
705
+ makefile_content = <<~MAKE
706
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
707
+ # this file rather than hand-editing.
708
+ #
709
+ # `make deploy` — builds rust/host for this domain, pushes it to this
710
+ # stack's own ECR repository, and deploys the CloudFormation stack.
711
+ # No SAM anywhere in this path.
712
+
713
+ ROOT := #{root}
714
+ DOMAIN := #{domain}
715
+ STACK := #{stack_name}
716
+ BASTION_STACK := #{stack_name}-bastion
717
+ REGION := #{region}
718
+ IMAGE_TAG := latest
719
+
720
+ build:
721
+ # aarch64, not x86_64 — matches template.yaml's own
722
+ # RuntimePlatform: ARM64 (this Makefile has to build the same
723
+ # architecture the task definition declares, or the container
724
+ # fails at task start, not at build time), and reuses the same
725
+ # working aarch64-unknown-linux-gnu toolchain the Lambda deploy
726
+ # path already depends on, rather than standing up a second,
727
+ # x86_64-only one.
728
+ \t@rustup target list --installed 2>/dev/null | grep -qx aarch64-unknown-linux-gnu || rustup target add aarch64-unknown-linux-gnu
729
+ # GNU cross-linker, not Apple clang — rustc's aarch64-unknown-linux-gnu
730
+ # target emits `-Wl,--fix-cortex-a53-843419`, which macOS ld rejects
731
+ # (found live building lifeadelics-platform's domain image). Same
732
+ # toolchain Lambda's generated Makefile already documents.
733
+ \t@command -v aarch64-linux-gnu-gcc >/dev/null 2>&1 || { echo "aarch64-linux-gnu-gcc isn't on PATH. Install once with: brew tap messense/macos-cross-toolchains && brew install aarch64-unknown-linux-gnu"; exit 1; }
734
+ # The .wasm/.ir.json sidecars main.rs requires at boot,
735
+ # unconditionally — see template.yaml's own HECKS_WASM_PATH/
736
+ # HECKS_IR_PATH comment for why. bin/project_wasm is the same
737
+ # generator the Lambda deploy path already uses to produce
738
+ # rust/dist/#{domain_name}.wasm/.ir.json from this domain's own
739
+ # .bluebook.
740
+ \tcd $(ROOT) && bin/project_wasm $(DOMAIN)
741
+ \tcd $(ROOT)/rust/host && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc rustup run stable cargo build --release --target aarch64-unknown-linux-gnu --bin bootstrap
742
+ \tcp $(ROOT)/rust/host/target/aarch64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
743
+ \tcp $(ROOT)/rust/dist/#{domain_name}.wasm #{domain_name}.wasm
744
+ \tcp $(ROOT)/rust/dist/#{domain_name}.ir.json #{domain_name}.ir.json
745
+
746
+ .PHONY: ecr-login
747
+ ecr-login:
748
+ \taws ecr get-login-password --region $(REGION) | docker login --username AWS --password-stdin $$(aws sts get-caller-identity --query Account --output text).dkr.ecr.$(REGION).amazonaws.com
749
+
750
+ .PHONY: docker-build
751
+ docker-build: build
752
+ # linux/arm64, not the generator's old amd64 default — has to
753
+ # match RuntimePlatform/the binary this Makefile's own build:
754
+ # step just cross-compiled, above.
755
+ \tdocker build --platform linux/arm64 -t #{infra_name}:$(IMAGE_TAG) .
756
+
757
+ .PHONY: docker-push
758
+ docker-push: ecr-login
759
+ \tACCOUNT_ID=$$(aws sts get-caller-identity --query Account --output text); \\
760
+ \t\tdocker tag #{infra_name}:$(IMAGE_TAG) $$ACCOUNT_ID.dkr.ecr.$(REGION).amazonaws.com/#{infra_name}:$(IMAGE_TAG); \\
761
+ \t\tdocker push $$ACCOUNT_ID.dkr.ecr.$(REGION).amazonaws.com/#{infra_name}:$(IMAGE_TAG)
762
+
763
+ .PHONY: deploy
764
+ deploy: docker-build docker-push
765
+ #{shared ? "\t@echo \"Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/PublicSubnetId/BastionSubnetId/FunctionSecurityGroupId/DatabaseEndpoint/DatabaseSecretArn outputs to pass as $(STACK)'s Owning* parameters...\"\n\tOWNER_VPC_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetAId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetBId'].OutputValue\" --output text); \\\n\t\tOWNER_PUBLIC_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PublicSubnetId'].OutputValue\" --output text); \\\n\t\tOWNER_PUBLIC_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='BastionSubnetId'].OutputValue\" --output text); \\\n\t\tOWNER_SG_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='FunctionSecurityGroupId'].OutputValue\" --output text); \\\n\t\tOWNER_DB_HOST=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseEndpoint'].OutputValue\" --output text); \\\n\t\tOWNER_DB_SECRET_ARN=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseSecretArn'].OutputValue\" --output text); \\\n\t\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM \\\n\t\t\t--parameter-overrides ImageTag=$(IMAGE_TAG) OwningVpcId=$$OWNER_VPC_ID OwningSubnetAId=$$OWNER_SUBNET_A_ID OwningSubnetBId=$$OWNER_SUBNET_B_ID OwningPublicSubnetAId=$$OWNER_PUBLIC_SUBNET_A_ID OwningPublicSubnetBId=$$OWNER_PUBLIC_SUBNET_B_ID OwningSecurityGroupId=$$OWNER_SG_ID OwningDatabaseEndpoint=$$OWNER_DB_HOST OwningDatabaseSecretArn=$$OWNER_DB_SECRET_ARN" : "\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM --parameter-overrides ImageTag=$(IMAGE_TAG)"}
766
+ \t$(MAKE) mint-era
767
+
768
+ #{shared ? <<~SHAREDMINT.rstrip : <<~OWNMINT.rstrip
769
+ # mint-era isn't automated yet for a Shared-mode domain (database
770
+ # "Shared") — see #{owner_domain_name}'s own deploy directory for
771
+ # the manual tunnel path. Exits 0 (not 1) so a genuinely successful
772
+ # `make deploy` still reports success.
773
+ .PHONY: mint-era
774
+ mint-era:
775
+ \t@echo "mint-era isn't automated yet for a Shared-mode domain (database \\"Shared\\") -- see #{owner_domain_name}'s own deploy directory's tunnel path. This is NOT a failure."; \\
776
+ \texit 0
777
+ SHAREDMINT
778
+ # `make mint-era` — the same bastion/tunnel/retry/teardown chain
779
+ # `Lambda`'s own generated Makefile uses (this directory's own
780
+ # bastion.yaml, stood up and torn down for the one boot that mints
781
+ # era 1), against this stack's own VPC/RDS instead of a Lambda's.
782
+ .PHONY: mint-era
783
+ mint-era:
784
+ \t@echo "Looking up $(STACK)'s VPC/security group..."
785
+ \t#{stack_outputs.map { |o| %($(eval #{o[:var]} := $(shell aws cloudformation describe-stacks --stack-name $(STACK) --query "Stacks[0].Outputs[?OutputKey=='#{o[:key]}'].OutputValue" --output text))) }.join("\n\t")}
786
+ \t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
787
+ \taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
788
+ \t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
789
+ \t\t--capabilities CAPABILITY_IAM
790
+ \t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
791
+ \t\techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
792
+ \t\tfor i in $$(seq 1 30); do \\
793
+ \t\t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
794
+ \t\t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
795
+ \t\t\tsleep 5; \\
796
+ \t\tdone; \\
797
+ \t\tDB_PASS=$$(aws secretsmanager get-secret-value --secret-id $(DB_SECRET_ARN) --query SecretString --output text | ruby -rjson -e 'print JSON.parse(STDIN.read)["password"]'); \\
798
+ \t\tDB_PASS_URLENC=$$(ruby -rerb -e 'print ERB::Util.url_encode(ARGV[0])' "$$DB_PASS"); \\
799
+ \t\tBOOT_STATUS=1; \\
800
+ \t\tfor attempt in 1 2 3 4 5; do \\
801
+ \t\t\tkill $$TUNNEL_PID 2>/dev/null; \\
802
+ \t\t\techo "Opening an SSM tunnel to $(DB_HOST):5432 and minting era 1 (attempt $$attempt/5)..."; \\
803
+ \t\t\taws ssm start-session --target $$INSTANCE_ID \\
804
+ \t\t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
805
+ \t\t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
806
+ \t\t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
807
+ \t\t\tTUNNEL_PID=$$!; \\
808
+ \t\t\tfor i in $$(seq 1 15); do \\
809
+ \t\t\t\tnc -z localhost 15432 2>/dev/null && break; \\
810
+ \t\t\t\tsleep 1; \\
811
+ \t\t\tdone; \\
812
+ \t\t\tcd $(ROOT) && DATABASE_URL="postgres://postgres:$$DB_PASS_URLENC@localhost:15432/#{db_name}" ruby -Ilib -e 'require "hecks"; require "hecks/ports/persistence/plugins/era"; loading = Hecks::Ports::Loading.bootstrap; directory = loading.bluebook_directory(ARGV[0]); root = loading.shared_root(nil, directory); registry = Hecks::Runtime::Registry.new(root: File.dirname(directory)); Hecks.with_registry(registry) { loading.load_library; loading.load_project(root); loading.load_domain(directory) }; bluebook = registry.bluebooks[#{declared_domain_name.inspect}] or abort "no #{declared_domain_name} bluebook loaded"; current_text = Hecks::Runtime::EraCheck.source_text_for(bluebook, directory); Hecks::Adapters::PostgresEra::LineageManager.check!(registry: registry, bluebook: bluebook, current_text: current_text, settings: { database: ENV["DATABASE_URL"]#{hecks_schema ? ", schema: #{hecks_schema.inspect}" : ""} }); db = Hecks::Adapters::PostgresEra.connect_for(bluebook.name, { database: ENV["DATABASE_URL"]#{hecks_schema ? ", schema: #{hecks_schema.inspect}" : ""} }); lineage = Hecks::Adapters::PostgresEra::Lineage.new(db, bluebook.name); (registry.bluebooks.values - [bluebook]).each { |other| other.aggregates.each { |aggregate| lineage.ensure_first_head!(aggregate.storage_name) } }; db.close; puts "booted OK -- era resolution ran"' $(DOMAIN) && { BOOT_STATUS=0; break; }; \\
813
+ \t\t\tBOOT_STATUS=$$?; \\
814
+ \t\t\techo "boot check attempt $$attempt/5 failed (exit $$BOOT_STATUS) -- restarting the tunnel and retrying in 3s..."; \\
815
+ \t\t\tsleep 3; \\
816
+ \t\tdone; \\
817
+ \t\tkill $$TUNNEL_PID 2>/dev/null; \\
818
+ \t\techo "Tearing down the temporary bastion stack..."; \\
819
+ \t\taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
820
+ \t\taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
821
+ \t\texit $$BOOT_STATUS
822
+ OWNMINT
823
+ }
824
+ MAKE
825
+
826
+ files = { "template.yaml" => template_yaml }
827
+ files["bastion.yaml"] = bastion_yaml if bastion_yaml
828
+ files["Dockerfile"] = dockerfile
829
+ files["Makefile"] = makefile_content
830
+ files
831
+ end
832
+
833
+ # Renders the container's own `DB_HOST`/`DB_NAME`/`DB_SECRET_ARN`
834
+ # (and, when set, `HECKS_SCHEMA`) `Environment` entries — the
835
+ # borrowed-owner values for a Shared-mode domain, this domain's
836
+ # own RDS/Aurora endpoint otherwise. See `call`'s own comment on
837
+ # `# TMPL:db_env` for why this is spliced in after the enclosing
838
+ # template renders, not interpolated inline.
839
+ #
840
+ # @param shared [Boolean] whether this domain borrows another domain's
841
+ # RDS instance
842
+ # @param owner_db_name [String, nil] the owning domain's own database name,
843
+ # used only when `shared`
844
+ # @param db_ref_id [String] the logical id `.Endpoint` resolves against
845
+ # @param db_name [String] this domain's own database identifier
846
+ # @param secret_sub [String] the `${...}`-ready identifier for this domain's
847
+ # own database secret
848
+ # @param hecks_schema [String, nil] the Postgres schema to set, or nil for none
849
+ # @param base [String] the marker line's own rendered indentation whitespace
850
+ # @return [String] the rendered `ContainerDefinitions[0].Environment` entries,
851
+ # ending in exactly one trailing newline
852
+ def db_env_yaml(shared:, owner_db_name:, db_ref_id:, db_name:, secret_sub:, hecks_schema:, base:)
853
+ lines =
854
+ if shared
855
+ [
856
+ "- Name: DB_HOST",
857
+ " Value: !Ref OwningDatabaseEndpoint",
858
+ "- Name: DB_NAME",
859
+ " Value: #{owner_db_name}",
860
+ "- Name: DB_SECRET_ARN",
861
+ " Value: !Sub \"${OwningDatabaseSecretArn}\"",
862
+ ]
863
+ else
864
+ [
865
+ "- Name: DB_HOST",
866
+ " Value: !GetAtt #{db_ref_id}.Endpoint.Address",
867
+ "- Name: DB_NAME",
868
+ " Value: #{db_name}",
869
+ "- Name: DB_SECRET_ARN",
870
+ " Value: !Sub \"${#{secret_sub}}\"",
871
+ ]
872
+ end
873
+ lines += ["- Name: HECKS_SCHEMA", " Value: #{hecks_schema}"] if hecks_schema
874
+
875
+ lines.map { |line| "#{base}#{line}" }.join("\n") + "\n"
876
+ end
877
+
878
+ # Renders the cross-domain policy's own least-privilege invoke
879
+ # grant as a plain `AWS::IAM::Role` `Policies` list entry — the
880
+ # `PolicyName`/`PolicyDocument` shape that resource type requires,
881
+ # unlike the bare `{Statement: [...]}` shorthand
882
+ # `Shared.cross_domain_invoke_policy_yaml` renders for SAM's own
883
+ # `AWS::Serverless::Function.Policies` property. Not called at all
884
+ # when `targets` is empty — see `call`'s own `# TMPL:cross_domain_fargate_policies`
885
+ # splice.
886
+ #
887
+ # @param targets [Array<String>] the domain names this stack's `across:` targets
888
+ # declare
889
+ # @param base [String] the marker line's own rendered indentation whitespace
890
+ # @return [String] one `Policies` list entry, ending in exactly one trailing newline
891
+ def cross_domain_fargate_policy_yaml(targets, base)
892
+ resources = targets.map { |target|
893
+ "#{base} - !Sub \"arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:hecks-#{target.downcase}\""
894
+ }.join("\n")
895
+
896
+ [
897
+ "#{base}# Least-privilege, one ARN per declared `across:` target — see",
898
+ "#{base}# Shared.cross_domain_invoke_policy_yaml's own comment for the full",
899
+ "#{base}# reasoning; this domain's own task role needs the identical grant.",
900
+ "#{base}- PolicyName: CrossDomainInvoke",
901
+ "#{base} PolicyDocument:",
902
+ "#{base} Version: '2012-10-17'",
903
+ "#{base} Statement:",
904
+ "#{base} - Effect: Allow",
905
+ "#{base} Action: lambda:InvokeFunction",
906
+ "#{base} Resource:",
907
+ resources,
908
+ ].join("\n") + "\n"
909
+ end
910
+ end
911
+ end
912
+ end
913
+ end