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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/identity_registry.rb +7 -1
- data/lib/hecks/bluebook/capabilities.rb +27 -0
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +26 -1
- data/lib/hecks/bluebook/hexagon.rb +18 -3
- data/lib/hecks/corpus.rb +123 -9
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/framework/bluebook/compliance.bluebook +221 -0
- data/lib/hecks/framework/bluebook/identity.bluebook +11 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +9 -2
- data/lib/hecks/fuzzing/isolated_boot.rb +12 -2
- data/lib/hecks/language/bluebook/bluebook.bluebook +10 -1
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +6 -0
- data/lib/hecks/ports/key_vault.rb +27 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +32 -2
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +41 -0
- data/lib/hecks/projections/deploy/fargate.rb +913 -0
- data/lib/hecks/projections/deploy/lambda.rb +2440 -0
- data/lib/hecks/projections/deploy/shared.rb +624 -0
- data/lib/hecks/projections.rb +3 -0
- data/lib/hecks/projector/exporter.rb +48 -0
- data/lib/hecks/projector/target.rb +18 -5
- data/lib/hecks/projector.rb +31 -17
- data/lib/hecks/router/namespace_installer.rb +10 -0
- data/lib/hecks/runtime/registry/verification.rb +84 -2
- data/lib/hecks/runtime/registry.rb +94 -3
- data/lib/hecks/version.rb +1 -1
- metadata +5 -2
- data/lib/hecks/framework/bluebook/compliance.bluebook +0 -1
|
@@ -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
|