hecks 1.5.0 → 1.5.1

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,666 @@
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, and least-privilege task execution/task
25
+ # roles — plus the same private VPC/RDS-or-Aurora instance and
26
+ # temporary era-minting bastion `Lambda` generates, via `Shared`.
27
+ #
28
+ # No SAM: this is deployed with plain `aws cloudformation deploy`,
29
+ # never `sam deploy`, so there is no `samconfig.toml` here. A
30
+ # `Dockerfile` packages `rust/host`'s own compiled binary — built by
31
+ # the generated Makefile before `docker build` ever runs, the same
32
+ # "build outside the container, ship the artifact" shape
33
+ # `lifeadelics/domain/Dockerfile` already uses for a tebako-pressed
34
+ # Ruby binary.
35
+ #
36
+ # ## What this assumes, and does not build
37
+ #
38
+ # `rust/host` running as a long-lived HTTP server on this domain's
39
+ # own `port`, rather than as a Lambda custom-runtime process
40
+ # (`bootstrap`, `Lambda`'s own binary), is a real, separate
41
+ # capability this target assumes exists — not one this generator
42
+ # builds. The generated `ContainerDefinitions` and target group both
43
+ # assume the container answers plain HTTP on `port`; wiring that
44
+ # serve loop into `rust/host/src/main.rs` is future, undone work.
45
+ module Fargate
46
+ extend Projector::Target
47
+
48
+ projects_as :aws_fargate, needs_world: true, emits: :files
49
+
50
+ module_function
51
+
52
+ # Generates `template.yaml`, `Makefile`, `Dockerfile`, and (unless
53
+ # this domain borrows another domain's RDS instance)
54
+ # `bastion.yaml` for one domain's `deployed_to("AwsFargate")`
55
+ # deploy target.
56
+ #
57
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter;
58
+ # establishes admission, see `Lambda.call`'s own comment on why generation
59
+ # itself reads `options[:cross_domain_registry]` instead
60
+ # @param options [Hash] generation options — see `Lambda.call`'s own `@option`
61
+ # tags; identical shape, this target reads the same keys
62
+ # @return [Hash{String => String}] `"template.yaml"`, `"Makefile"`, `"Dockerfile"`,
63
+ # and — unless this domain declares `database "Shared"` — `"bastion.yaml"`
64
+ # @raise [ArgumentError] if the domain's own deploy settings conflict, or
65
+ # `deploy.bluebook`'s own `FargateTarget.Declare` refuses them
66
+ def call(bluebook:, options: {})
67
+ world = options.fetch(:world)
68
+ domain = options.fetch(:domain_dir)
69
+ root = options.fetch(:root)
70
+ world_file = options.fetch(:world_file)
71
+ cross_domain_registry = options.fetch(:cross_domain_registry)
72
+ tenant_options = options[:tenant] || {}
73
+
74
+ domain_name = File.basename(domain)
75
+ declared_domain_name = world.domain
76
+
77
+ deploy_settings = world.for_verb("deployed_to")
78
+
79
+ # Same override `Lambda.call` applies, for the same reason — see
80
+ # that method's own comment.
81
+ if tenant_options[:tenant]
82
+ base_stack_name = deploy_settings[:stack_name] || domain_name
83
+ deploy_settings = deploy_settings.merge(stack_name: "#{base_stack_name}-#{tenant_options[:tenant]}",
84
+ schema: tenant_options[:schema] || tenant_options[:tenant])
85
+ end
86
+
87
+ infra_name = deploy_settings[:stack_name] || domain_name
88
+ db_name = infra_name.gsub(/[^a-zA-Z0-9]/, "")
89
+
90
+ # Validated the same way `Lambda.call` validates its own target —
91
+ # `deploy.bluebook`'s own `FargateTarget.Declare`, not a
92
+ # hand-checked `fetch(:cpu) { raise ... }` chain.
93
+ deploy_dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
94
+ begin
95
+ target = deploy_dispatcher.dispatch(
96
+ "Deploy::FargateTarget.Declare",
97
+ with: {
98
+ domain: { value: declared_domain_name },
99
+ region: { value: deploy_settings[:region] },
100
+ cpu: { value: deploy_settings.fetch(:cpu, 256) },
101
+ memory: { value: deploy_settings.fetch(:memory, 512) },
102
+ database: { value: deploy_settings.fetch(:database, "Postgres") },
103
+ web: { value: deploy_settings.fetch(:web, "None") },
104
+ port: { value: deploy_settings.fetch(:port, 8080) }
105
+ }
106
+ ).instance
107
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
108
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsFargate\") is invalid: #{e.message}"
109
+ end
110
+
111
+ region = target.state[:region].value
112
+ cpu = target.state[:cpu].value
113
+ memory = target.state[:memory].value
114
+ database = target.state[:database].value
115
+ port = target.state[:port].value
116
+ aurora = database == "Aurora"
117
+ shared = database == "Shared"
118
+
119
+ # Every policy in every loaded chapter — see `Lambda.call`'s own
120
+ # comment on why this reads the whole registry, not only this
121
+ # domain's own top-level list.
122
+ cross_domain_fargate_targets = cross_domain_registry.bluebooks.flat_map { |_name, chapter|
123
+ chapter.policies.select(&:target_domain).map(&:target_domain)
124
+ }.uniq.sort
125
+
126
+ # **The storehouse** — identical borrowing `Lambda.call` supports for
127
+ # `database "Shared"`; see that method's own comment for the full
128
+ # reasoning. `owner`/`owner_stack` stay Ruby-level `deploy_settings`
129
+ # reads, never validated `FargateTarget` attributes, for the same
130
+ # reason: which domain owns the shared instance is a deploy-time
131
+ # wiring fact, not a business invariant.
132
+ if shared
133
+ owner_domain_name = deploy_settings[:owner] or raise ArgumentError, <<~MSG
134
+ #{world_file}'s deployed_to("AwsFargate") declares database "Shared" but no owner. Add one, e.g.:
135
+
136
+ deployed_to("AwsFargate") do
137
+ ...
138
+ database "Shared"
139
+ owner "Embryonaut"
140
+ end
141
+
142
+ naming the already-deployed domain whose Postgres instance this one borrows.
143
+ MSG
144
+ owner_stack_name = deploy_settings[:owner_stack] || "hecks-#{owner_domain_name.downcase}"
145
+ owner_db_name = owner_domain_name.downcase
146
+ end
147
+
148
+ hecks_schema = shared ? infra_name : deploy_settings[:schema]
149
+
150
+ # `infra_name` is already alphanumeric-only + hyphen-friendly for
151
+ # CloudFormation's own logical-id character set, matching
152
+ # `Lambda.call`'s own `logical_id` convention.
153
+ logical_id = "#{infra_name.split(/[_-]/).map(&:capitalize).join}Service"
154
+ stack_prefix = deploy_settings[:stack_prefix] || "hecks"
155
+ stack_name = "#{stack_prefix}-#{infra_name}"
156
+ desired_count = deploy_settings.fetch(:desired_count, 1)
157
+
158
+ db_id = "#{logical_id.sub(/Service\z/, '')}Db"
159
+ db_ref_id = aurora ? "#{db_id}Cluster" : db_id
160
+ secret_sub = aurora ? "#{db_id}Secret" : "#{db_ref_id}.MasterUserSecret.SecretArn"
161
+ secret_intrinsic = aurora ? "!Ref #{db_id}Secret" : "!GetAtt #{db_ref_id}.MasterUserSecret.SecretArn"
162
+ db_secret_ref = shared ? "OwningDatabaseSecretArn" : secret_sub
163
+
164
+ # A Fargate service is reached through an Application Load
165
+ # Balancer sitting in a public subnet, not invoked directly the
166
+ # way a Lambda Function URL is — this domain always needs real
167
+ # internet-facing infrastructure (the `ALB`'s own ingress, and
168
+ # egress for an `ECR` image pull/CloudWatch Logs/Secrets Manager),
169
+ # unlike `Lambda`'s own NAT Gateway, which is opt-in
170
+ # (`google_oauth_present`) because a plain dispatch Lambda needs
171
+ # no internet access at all. `Shared.vpc_and_database_yaml`'s
172
+ # `google_oauth_present:` parameter is exactly this "does this
173
+ # domain need its own NAT Gateway/public subnet" question,
174
+ # unconditionally true here.
175
+ network_needs_internet = true
176
+
177
+ # Never created at all when `shared` — the compute-side security
178
+ # group `Shared.vpc_and_database_yaml` would otherwise declare is
179
+ # skipped along with the rest of this domain's own VPC (same as
180
+ # `Lambda.call`'s own Shared-mode `VpcConfig`); the `ECS` task and
181
+ # the `ALB`-ingress rule both reach through the borrowed owner's
182
+ # own security group instead.
183
+ compute_security_group_ref = shared ? "!Ref OwningSecurityGroupId" : "!Ref #{logical_id}SecurityGroup"
184
+
185
+ ecr_repository_id = "#{logical_id}Repository"
186
+ cluster_id = "#{logical_id}Cluster"
187
+ task_definition_id = "#{logical_id}TaskDefinition"
188
+ execution_role_id = "#{logical_id}ExecutionRole"
189
+ task_role_id = "#{logical_id}TaskRole"
190
+ log_group_id = "#{logical_id}LogGroup"
191
+ target_group_id = "#{logical_id}TargetGroup"
192
+ alb_id = "#{logical_id}Alb"
193
+ alb_sg_id = "#{logical_id}AlbSecurityGroup"
194
+ listener_id = "#{logical_id}Listener"
195
+
196
+ stack_outputs = Shared.stack_outputs(
197
+ shared: shared, db_id: db_id, db_ref_id: db_ref_id, secret_intrinsic: secret_intrinsic,
198
+ compute_security_group_ref: compute_security_group_ref, google_oauth_present: network_needs_internet
199
+ )
200
+ bastion_parameters = Shared.bastion_parameters(shared: shared, google_oauth_present: network_needs_internet)
201
+ Shared.check_bastion_parameters!(bastion_parameters, stack_outputs)
202
+
203
+ owning_params_yaml = shared ? <<~SHAREDPARAMS.rstrip : ""
204
+ # The storehouse — #{owner_domain_name}'s own live stack Outputs,
205
+ # looked up at deploy time (the generated Makefile's own `deploy:`
206
+ # target) via `aws cloudformation describe-stacks`, the identical
207
+ # pattern `Lambda`'s own generated Makefile already uses for a
208
+ # Shared-mode domain.
209
+ OwningVpcId:
210
+ Type: AWS::EC2::VPC::Id
211
+ OwningSubnetAId:
212
+ Type: AWS::EC2::Subnet::Id
213
+ OwningSubnetBId:
214
+ Type: AWS::EC2::Subnet::Id
215
+ OwningSecurityGroupId:
216
+ Type: AWS::EC2::SecurityGroup::Id
217
+ OwningDatabaseEndpoint:
218
+ Type: String
219
+ OwningDatabaseSecretArn:
220
+ Type: String
221
+ SHAREDPARAMS
222
+
223
+ template_yaml = <<~YAML
224
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
225
+ # this file rather than hand-editing. Source: #{world_file}'s own
226
+ # deployed_to("AwsFargate") block.
227
+ #
228
+ # Plain CloudFormation, not SAM — deployed with
229
+ # `aws cloudformation deploy`, never `sam deploy`. Self-contained
230
+ # the same way `Lambda`'s own template is: this stack owns its own
231
+ # private VPC, subnets, and RDS Postgres instance (unless
232
+ # database "Shared"), not just the ECS service.
233
+ AWSTemplateFormatVersion: '2010-09-09'
234
+ Description: >
235
+ #{infra_name} — dispatched through hecks's rust/host, running as a
236
+ long-lived container on AWS Fargate, backed by its own private RDS
237
+ Postgres instance.
238
+ #{owning_params_yaml.empty? ? "" : "Parameters:\n" + owning_params_yaml.each_line.map { |l| " #{l}" }.join}
239
+ Resources:
240
+ #{shared ? "" : Shared.vpc_and_database_yaml(
241
+ db_id: db_id, db_name: db_name, infra_name: infra_name, aurora: aurora,
242
+ google_oauth_present: network_needs_internet, compute_logical_id: logical_id,
243
+ compute_description: "#{logical_id} - inbound from #{alb_sg_id} only, egress rules attached separately below"
244
+ ).each_line.with_index.map { |l, i| (i.zero? ? "" : " ") + l }.join.rstrip}
245
+
246
+ #{alb_sg_id}:
247
+ Type: AWS::EC2::SecurityGroup
248
+ Properties:
249
+ VpcId: #{shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"}
250
+ GroupDescription: #{alb_sg_id} - public HTTP ingress, forwarded to #{logical_id} only
251
+ SecurityGroupIngress:
252
+ - IpProtocol: tcp
253
+ FromPort: 80
254
+ ToPort: 80
255
+ CidrIp: 0.0.0.0/0
256
+
257
+ #{logical_id}IngressFromAlb:
258
+ Type: AWS::EC2::SecurityGroupIngress
259
+ Properties:
260
+ GroupId: #{compute_security_group_ref}
261
+ IpProtocol: tcp
262
+ FromPort: #{port}
263
+ ToPort: #{port}
264
+ SourceSecurityGroupId: !Ref #{alb_sg_id}
265
+
266
+ #{ecr_repository_id}:
267
+ Type: AWS::ECR::Repository
268
+ Properties:
269
+ RepositoryName: #{infra_name}
270
+ ImageScanningConfiguration:
271
+ ScanOnPush: true
272
+
273
+ #{cluster_id}:
274
+ Type: AWS::ECS::Cluster
275
+ Properties:
276
+ ClusterName: #{stack_name}
277
+
278
+ #{log_group_id}:
279
+ Type: AWS::Logs::LogGroup
280
+ Properties:
281
+ LogGroupName: /ecs/#{stack_name}
282
+ RetentionInDays: 30
283
+
284
+ # Pulls the image and writes CloudWatch Logs — AWS's own managed
285
+ # AmazonECSTaskExecutionRolePolicy already covers both (ECR auth
286
+ # + GetDownloadUrlForLayer, and logs:CreateLogStream/PutLogEvents);
287
+ # the one grant that policy does NOT cover is fetching this
288
+ # domain's own database secret, added below, least-privilege,
289
+ # scoped to the single secret ARN this stack itself depends on.
290
+ #{execution_role_id}:
291
+ Type: AWS::IAM::Role
292
+ Properties:
293
+ AssumeRolePolicyDocument:
294
+ Version: '2012-10-17'
295
+ Statement:
296
+ - Effect: Allow
297
+ Principal: { Service: ecs-tasks.amazonaws.com }
298
+ Action: sts:AssumeRole
299
+ ManagedPolicyArns:
300
+ - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
301
+ Policies:
302
+ - PolicyName: DbSecretRead
303
+ PolicyDocument:
304
+ Version: '2012-10-17'
305
+ Statement:
306
+ - Effect: Allow
307
+ Action: secretsmanager:GetSecretValue
308
+ Resource: !Sub "${#{db_secret_ref}}"
309
+
310
+ # The container's OWN runtime permissions — rust/host fetches
311
+ # DB_SECRET_ARN itself, over the AWS SDK, the same "never let
312
+ # CloudFormation/ECS configuration see it resolved" posture
313
+ # `Lambda`'s own generated template already holds to (a
314
+ # `Secrets:` ContainerDefinition property would resolve it
315
+ # into a plain environment variable instead).
316
+ #{task_role_id}:
317
+ Type: AWS::IAM::Role
318
+ Properties:
319
+ AssumeRolePolicyDocument:
320
+ Version: '2012-10-17'
321
+ Statement:
322
+ - Effect: Allow
323
+ Principal: { Service: ecs-tasks.amazonaws.com }
324
+ Action: sts:AssumeRole
325
+ Policies:
326
+ - PolicyName: DbSecretRead
327
+ PolicyDocument:
328
+ Version: '2012-10-17'
329
+ Statement:
330
+ - Effect: Allow
331
+ Action: secretsmanager:GetSecretValue
332
+ Resource: !Sub "${#{db_secret_ref}}"
333
+ # TMPL:cross_domain_fargate_policies
334
+
335
+ #{task_definition_id}:
336
+ Type: AWS::ECS::TaskDefinition
337
+ Properties:
338
+ Family: #{infra_name}
339
+ RequiresCompatibilities: [FARGATE]
340
+ NetworkMode: awsvpc
341
+ Cpu: "#{cpu}"
342
+ Memory: "#{memory}"
343
+ ExecutionRoleArn: !GetAtt #{execution_role_id}.Arn
344
+ TaskRoleArn: !GetAtt #{task_role_id}.Arn
345
+ ContainerDefinitions:
346
+ - Name: #{infra_name}
347
+ Image: !Sub "${#{ecr_repository_id}.RepositoryUri}:latest"
348
+ PortMappings:
349
+ - ContainerPort: #{port}
350
+ LogConfiguration:
351
+ LogDriver: awslogs
352
+ Options:
353
+ awslogs-group: !Ref #{log_group_id}
354
+ awslogs-region: !Ref AWS::Region
355
+ awslogs-stream-prefix: #{infra_name}
356
+ Environment:
357
+ - Name: HECKS_DOMAIN
358
+ Value: #{declared_domain_name}
359
+ - Name: HECKS_ERA
360
+ Value: "1"
361
+ - Name: PORT
362
+ Value: "#{port}"
363
+ # `web "Rust"` vs `web "None"` is otherwise inert
364
+ # here today — both modes generate the identical
365
+ # task/service/target-group shape, since a Fargate
366
+ # task always answers HTTP on `port` for dispatch
367
+ # requests either way. Passed through so
368
+ # `rust/host`'s own future long-lived server loop
369
+ # (this module's own header names the gap) can read
370
+ # it and decide whether to also serve the public
371
+ # web UI in-process, the same `web`-shaped choice
372
+ # `Lambda`'s own `rust_web` already makes for the
373
+ # Lambda path.
374
+ - Name: HECKS_WEB
375
+ Value: #{target.state[:web].value}
376
+ # TMPL:db_env
377
+
378
+ #{target_group_id}:
379
+ Type: AWS::ElasticLoadBalancingV2::TargetGroup
380
+ Properties:
381
+ TargetType: ip
382
+ Port: #{port}
383
+ Protocol: HTTP
384
+ VpcId: #{shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"}
385
+ HealthCheckPath: /
386
+ HealthCheckPort: "#{port}"
387
+
388
+ #{alb_id}:
389
+ Type: AWS::ElasticLoadBalancingV2::LoadBalancer
390
+ Properties:
391
+ Name: #{stack_name}-alb
392
+ Scheme: internet-facing
393
+ Type: application
394
+ SecurityGroups: [!Ref #{alb_sg_id}]
395
+ Subnets: #{shared ? "[!Ref OwningSubnetAId, !Ref OwningSubnetBId]" : "[!Ref #{db_id}PublicSubnet, !Ref #{db_id}BastionPublicSubnet]"}
396
+
397
+ #{listener_id}:
398
+ Type: AWS::ElasticLoadBalancingV2::Listener
399
+ Properties:
400
+ LoadBalancerArn: !Ref #{alb_id}
401
+ Port: 80
402
+ Protocol: HTTP
403
+ DefaultActions:
404
+ - Type: forward
405
+ TargetGroupArn: !Ref #{target_group_id}
406
+
407
+ #{logical_id}:
408
+ Type: AWS::ECS::Service
409
+ DependsOn: #{listener_id}
410
+ Properties:
411
+ ServiceName: #{stack_name}
412
+ Cluster: !Ref #{cluster_id}
413
+ TaskDefinition: !Ref #{task_definition_id}
414
+ DesiredCount: #{desired_count}
415
+ LaunchType: FARGATE
416
+ NetworkConfiguration:
417
+ AwsvpcConfiguration:
418
+ AssignPublicIp: DISABLED
419
+ Subnets: #{shared ? "[!Ref OwningSubnetAId, !Ref OwningSubnetBId]" : "[!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]"}
420
+ SecurityGroups: [#{compute_security_group_ref}]
421
+ LoadBalancers:
422
+ - ContainerName: #{infra_name}
423
+ ContainerPort: #{port}
424
+ TargetGroupArn: !Ref #{target_group_id}
425
+
426
+ Outputs:
427
+ ServiceUrl:
428
+ Value: !Sub "http://${#{alb_id}.DNSName}"
429
+ #{stack_outputs.map { |o| "#{o[:key]}:\n Value: #{o[:ref]}" }.join("\n ")}
430
+ YAML
431
+
432
+ # Spliced in after the heredoc renders, not interpolated inside
433
+ # it — `Lambda.call`'s own comment on `# TMPL:cross_domain_lambda_policies`
434
+ # explains why: a `<<~` heredoc's own dedent is computed from its
435
+ # raw source, before any `#{...}` evaluates, so a multi-line
436
+ # value substituted in at runtime is not reindented by the
437
+ # enclosing heredoc a second time — hand-computing a matching
438
+ # prefix in advance drifts out of sync the moment anything
439
+ # upstream shifts this template's own baseline indentation
440
+ # (confirmed the hard way, writing this: the first version
441
+ # hardcoded the raw source column instead of the marker's own
442
+ # actual rendered one, and every subsequent line landed twice as
443
+ # deep as it should have). A plain `String#sub` after the fact,
444
+ # capturing the marker's own real indentation, has no such
445
+ # interaction with the text it replaces into.
446
+ 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) }
447
+ template_yaml = template_yaml.sub(/^([ \t]*)# TMPL:cross_domain_fargate_policies\n/) {
448
+ cross_domain_fargate_targets.empty? ? "" : cross_domain_fargate_policy_yaml(cross_domain_fargate_targets, $1)
449
+ }
450
+
451
+ bastion_yaml = shared ? nil : Shared.bastion_yaml(
452
+ domain: domain, infra_name: infra_name, stack_name: stack_name, db_id: db_id,
453
+ google_oauth_present: network_needs_internet, bastion_parameters: bastion_parameters
454
+ )
455
+
456
+ dockerfile = <<~DOCKERFILE
457
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh this
458
+ # file rather than hand-editing. Modeled on lifeadelics/domain/Dockerfile's
459
+ # own shape: build the binary outside the image (this directory's own
460
+ # Makefile, before `docker build` runs), ship only the result — one COPY,
461
+ # not a from-scratch toolchain install on every deploy.
462
+ FROM debian:bookworm-slim
463
+
464
+ # ca-certificates — real outbound TLS (Secrets Manager, any third-party
465
+ # API this domain calls) needs a real system CA bundle.
466
+ # libpq5 — Adapters::PostgresEra's own native `tokio_postgres`/libpq
467
+ # linkage needs the actual shared library present at runtime, the same
468
+ # reason Lambda's own generated Makefile patches libpq.so onto that
469
+ # package's Ruby-side equivalent.
470
+ RUN apt-get update -qq && apt-get install -y --no-install-recommends -qq ca-certificates libpq5 \\
471
+ && rm -rf /var/lib/apt/lists/*
472
+
473
+ COPY #{domain_name}-host /usr/local/bin/#{domain_name}-host
474
+
475
+ ENV PORT=#{port}
476
+ ENV BIND=0.0.0.0
477
+ EXPOSE #{port}
478
+
479
+ CMD ["#{domain_name}-host"]
480
+ DOCKERFILE
481
+
482
+ makefile_content = <<~MAKE
483
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
484
+ # this file rather than hand-editing.
485
+ #
486
+ # `make deploy` — builds rust/host for this domain, pushes it to this
487
+ # stack's own ECR repository, and deploys the CloudFormation stack.
488
+ # No SAM anywhere in this path.
489
+
490
+ ROOT := #{root}
491
+ DOMAIN := #{domain}
492
+ STACK := #{stack_name}
493
+ BASTION_STACK := #{stack_name}-bastion
494
+ REGION := #{region}
495
+ IMAGE_TAG := latest
496
+
497
+ build:
498
+ \t@rustup target list --installed 2>/dev/null | grep -qx x86_64-unknown-linux-gnu || rustup target add x86_64-unknown-linux-gnu
499
+ \tcd $(ROOT)/rust/host && rustup run stable cargo build --release --target x86_64-unknown-linux-gnu
500
+ \tcp $(ROOT)/rust/host/target/x86_64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
501
+
502
+ .PHONY: ecr-login
503
+ ecr-login:
504
+ \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
505
+
506
+ .PHONY: docker-build
507
+ docker-build: build
508
+ \tdocker build --platform linux/amd64 -t #{infra_name}:$(IMAGE_TAG) .
509
+
510
+ .PHONY: docker-push
511
+ docker-push: ecr-login
512
+ \tACCOUNT_ID=$$(aws sts get-caller-identity --query Account --output text); \\
513
+ \t\tdocker tag #{infra_name}:$(IMAGE_TAG) $$ACCOUNT_ID.dkr.ecr.$(REGION).amazonaws.com/#{infra_name}:$(IMAGE_TAG); \\
514
+ \t\tdocker push $$ACCOUNT_ID.dkr.ecr.$(REGION).amazonaws.com/#{infra_name}:$(IMAGE_TAG)
515
+
516
+ .PHONY: deploy
517
+ deploy: docker-build docker-push
518
+ #{shared ? "\t@echo \"Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/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_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 OwningVpcId=$$OWNER_VPC_ID OwningSubnetAId=$$OWNER_SUBNET_A_ID OwningSubnetBId=$$OWNER_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"}
519
+ \t$(MAKE) mint-era
520
+
521
+ #{shared ? <<~SHAREDMINT.rstrip : <<~OWNMINT.rstrip
522
+ # mint-era isn't automated yet for a Shared-mode domain (database
523
+ # "Shared") — see #{owner_domain_name}'s own deploy directory for
524
+ # the manual tunnel path. Exits 0 (not 1) so a genuinely successful
525
+ # `make deploy` still reports success.
526
+ .PHONY: mint-era
527
+ mint-era:
528
+ \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."; \\
529
+ \texit 0
530
+ SHAREDMINT
531
+ # `make mint-era` — the same bastion/tunnel/retry/teardown chain
532
+ # `Lambda`'s own generated Makefile uses (this directory's own
533
+ # bastion.yaml, stood up and torn down for the one boot that mints
534
+ # era 1), against this stack's own VPC/RDS instead of a Lambda's.
535
+ .PHONY: mint-era
536
+ mint-era:
537
+ \t@echo "Looking up $(STACK)'s VPC/security group..."
538
+ \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")}
539
+ \t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
540
+ \taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
541
+ \t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
542
+ \t\t--capabilities CAPABILITY_IAM
543
+ \t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
544
+ \t\techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
545
+ \t\tfor i in $$(seq 1 30); do \\
546
+ \t\t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
547
+ \t\t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
548
+ \t\t\tsleep 5; \\
549
+ \t\tdone; \\
550
+ \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"]'); \\
551
+ \t\tDB_PASS_URLENC=$$(ruby -rerb -e 'print ERB::Util.url_encode(ARGV[0])' "$$DB_PASS"); \\
552
+ \t\tBOOT_STATUS=1; \\
553
+ \t\tfor attempt in 1 2 3 4 5; do \\
554
+ \t\t\tkill $$TUNNEL_PID 2>/dev/null; \\
555
+ \t\t\techo "Opening an SSM tunnel to $(DB_HOST):5432 and minting era 1 (attempt $$attempt/5)..."; \\
556
+ \t\t\taws ssm start-session --target $$INSTANCE_ID \\
557
+ \t\t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
558
+ \t\t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
559
+ \t\t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
560
+ \t\t\tTUNNEL_PID=$$!; \\
561
+ \t\t\tfor i in $$(seq 1 15); do \\
562
+ \t\t\t\tnc -z localhost 15432 2>/dev/null && break; \\
563
+ \t\t\t\tsleep 1; \\
564
+ \t\t\tdone; \\
565
+ \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; }; \\
566
+ \t\t\tBOOT_STATUS=$$?; \\
567
+ \t\t\techo "boot check attempt $$attempt/5 failed (exit $$BOOT_STATUS) -- restarting the tunnel and retrying in 3s..."; \\
568
+ \t\t\tsleep 3; \\
569
+ \t\tdone; \\
570
+ \t\tkill $$TUNNEL_PID 2>/dev/null; \\
571
+ \t\techo "Tearing down the temporary bastion stack..."; \\
572
+ \t\taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
573
+ \t\taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
574
+ \t\texit $$BOOT_STATUS
575
+ OWNMINT
576
+ }
577
+ MAKE
578
+
579
+ files = { "template.yaml" => template_yaml }
580
+ files["bastion.yaml"] = bastion_yaml if bastion_yaml
581
+ files["Dockerfile"] = dockerfile
582
+ files["Makefile"] = makefile_content
583
+ files
584
+ end
585
+
586
+ # Renders the container's own `DB_HOST`/`DB_NAME`/`DB_SECRET_ARN`
587
+ # (and, when set, `HECKS_SCHEMA`) `Environment` entries — the
588
+ # borrowed-owner values for a Shared-mode domain, this domain's
589
+ # own RDS/Aurora endpoint otherwise. See `call`'s own comment on
590
+ # `# TMPL:db_env` for why this is spliced in after the enclosing
591
+ # template renders, not interpolated inline.
592
+ #
593
+ # @param shared [Boolean] whether this domain borrows another domain's
594
+ # RDS instance
595
+ # @param owner_db_name [String, nil] the owning domain's own database name,
596
+ # used only when `shared`
597
+ # @param db_ref_id [String] the logical id `.Endpoint` resolves against
598
+ # @param db_name [String] this domain's own database identifier
599
+ # @param secret_sub [String] the `${...}`-ready identifier for this domain's
600
+ # own database secret
601
+ # @param hecks_schema [String, nil] the Postgres schema to set, or nil for none
602
+ # @param base [String] the marker line's own rendered indentation whitespace
603
+ # @return [String] the rendered `ContainerDefinitions[0].Environment` entries,
604
+ # ending in exactly one trailing newline
605
+ def db_env_yaml(shared:, owner_db_name:, db_ref_id:, db_name:, secret_sub:, hecks_schema:, base:)
606
+ lines =
607
+ if shared
608
+ [
609
+ "- Name: DB_HOST",
610
+ " Value: !Ref OwningDatabaseEndpoint",
611
+ "- Name: DB_NAME",
612
+ " Value: #{owner_db_name}",
613
+ "- Name: DB_SECRET_ARN",
614
+ " Value: !Sub \"${OwningDatabaseSecretArn}\"",
615
+ ]
616
+ else
617
+ [
618
+ "- Name: DB_HOST",
619
+ " Value: !GetAtt #{db_ref_id}.Endpoint.Address",
620
+ "- Name: DB_NAME",
621
+ " Value: #{db_name}",
622
+ "- Name: DB_SECRET_ARN",
623
+ " Value: !Sub \"${#{secret_sub}}\"",
624
+ ]
625
+ end
626
+ lines += ["- Name: HECKS_SCHEMA", " Value: #{hecks_schema}"] if hecks_schema
627
+
628
+ lines.map { |line| "#{base}#{line}" }.join("\n") + "\n"
629
+ end
630
+
631
+ # Renders the cross-domain policy's own least-privilege invoke
632
+ # grant as a plain `AWS::IAM::Role` `Policies` list entry — the
633
+ # `PolicyName`/`PolicyDocument` shape that resource type requires,
634
+ # unlike the bare `{Statement: [...]}` shorthand
635
+ # `Shared.cross_domain_invoke_policy_yaml` renders for SAM's own
636
+ # `AWS::Serverless::Function.Policies` property. Not called at all
637
+ # when `targets` is empty — see `call`'s own `# TMPL:cross_domain_fargate_policies`
638
+ # splice.
639
+ #
640
+ # @param targets [Array<String>] the domain names this stack's `across:` targets
641
+ # declare
642
+ # @param base [String] the marker line's own rendered indentation whitespace
643
+ # @return [String] one `Policies` list entry, ending in exactly one trailing newline
644
+ def cross_domain_fargate_policy_yaml(targets, base)
645
+ resources = targets.map { |target|
646
+ "#{base} - !Sub \"arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:hecks-#{target.downcase}\""
647
+ }.join("\n")
648
+
649
+ [
650
+ "#{base}# Least-privilege, one ARN per declared `across:` target — see",
651
+ "#{base}# Shared.cross_domain_invoke_policy_yaml's own comment for the full",
652
+ "#{base}# reasoning; this domain's own task role needs the identical grant.",
653
+ "#{base}- PolicyName: CrossDomainInvoke",
654
+ "#{base} PolicyDocument:",
655
+ "#{base} Version: '2012-10-17'",
656
+ "#{base} Statement:",
657
+ "#{base} - Effect: Allow",
658
+ "#{base} Action: lambda:InvokeFunction",
659
+ "#{base} Resource:",
660
+ resources,
661
+ ].join("\n") + "\n"
662
+ end
663
+ end
664
+ end
665
+ end
666
+ end