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,2440 @@
1
+ require_relative "../../projector"
2
+ require_relative "shared"
3
+
4
+ module Hecks
5
+ module Projections
6
+ module Deploy
7
+ # The AWS Lambda deploy target — docs/decisions/0018-rehydrate-replay-lambda-host.md.
8
+ # An export (`Projector::Target#projects_as`'s own `needs_world: true`),
9
+ # not an ordinary projection: it reads a domain's own
10
+ # `deployed_to("AwsLambda")` `.world` settings, not only its
11
+ # declaration, because a running system has to know how it is wired.
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_lambda, bluebook:, options:, world:)`, and
16
+ # writes the returned tree — see that script for the CLI-facing parts
17
+ # (ARGV parsing, `--tenant`/`--schema`, finding the `.world` file) this
18
+ # target never needs to know about.
19
+ #
20
+ # ## What this generates
21
+ #
22
+ # The SAM template and build Makefile for `rust/host` (the
23
+ # wasmtime+Postgres Lambda entry point) from a domain's own `.world`
24
+ # file, the same way `bin/project_wasm` generates the `.wasm` artifact
25
+ # from the domain's own `.bluebook` — no hand-authored deployment
26
+ # config for any given domain, only a generator run against whatever it
27
+ # declares:
28
+ #
29
+ # deployed_to("AwsLambda") { region "..."; memory 512; timeout 10 }
30
+ #
31
+ # is a verb in a domain's `.world` file, read through `Hecks.world`'s
32
+ # own generic settings bag
33
+ # (`lib/hecks/bluebook/dsl/world_builder.rb#method_missing`).
34
+ #
35
+ # Self-contained: the returned `template.yaml` owns its own private
36
+ # VPC, subnets, and RDS Postgres instance, not just the Lambda — no
37
+ # externally-supplied database, no secret anyone has to type (RDS's
38
+ # own `ManageMasterUserPassword` plus a CloudFormation dynamic
39
+ # reference compose `DATABASE_URL` at deploy time). `PackageType:
40
+ # Zip`, `Runtime: provided.al2023` — no container, no Docker, no ECR.
41
+ #
42
+ # `Shared` (`lib/hecks/projections/deploy/shared.rb`) carries the parts
43
+ # of this generator `Fargate` needs too — VPC/subnet/security-group
44
+ # resources, RDS/Aurora, `bastion.yaml`, and the era-minting/
45
+ # translation Make recipes — plain functions, not a registered target
46
+ # of their own.
47
+ module Lambda
48
+ extend Projector::Target
49
+
50
+ projects_as :aws_lambda, needs_world: true, emits: :files
51
+
52
+ module_function
53
+
54
+ # Generates `template.yaml`, `Makefile`, `samconfig.toml`, and
55
+ # (unless this domain borrows another domain's RDS instance)
56
+ # `bastion.yaml` for one domain's `deployed_to("AwsLambda")` deploy
57
+ # target.
58
+ #
59
+ # `bluebook:` establishes admission — a real chapter, not merely a
60
+ # directory — but generation itself reads from `options`: every
61
+ # loaded chapter (`options[:cross_domain_registry]`), not only this
62
+ # one, is what a cross-domain policy's own invoke grant needs.
63
+ #
64
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
65
+ # @param options [Hash] generation options
66
+ # @option options [Bluebook::World] :world the domain's `.world` settings,
67
+ # required — `Projector.call`'s own `world:` keyword merges this in
68
+ # @option options [String] :domain_dir the domain directory's path
69
+ # @option options [String] :root the hecks project root, for `rust/host`,
70
+ # `rust/dist`, and this project's own `Gemfile.lock`
71
+ # @option options [String] :world_file the `.world` file's own path, quoted in
72
+ # refusal messages
73
+ # @option options [Runtime::Registry] :cross_domain_registry every chapter this
74
+ # domain loads (its own, plus any framework attachments), for resolving
75
+ # cross-domain policy invoke targets
76
+ # @option options [Hash] :tenant `--tenant`/`--schema` overrides
77
+ # (`{tenant: "acme", schema: "acme"}`), or omitted for an ordinary deploy
78
+ # @return [Hash{String => String}] `"template.yaml"`, `"Makefile"`,
79
+ # `"samconfig.toml"`, and — unless this domain declares `database "Shared"` —
80
+ # `"bastion.yaml"`, each mapped to its rendered content
81
+ # @raise [ArgumentError] if the domain's own deploy settings conflict (both
82
+ # `web "Rust"` and a `lambda_handler.rb`, `database "Shared"` with no
83
+ # `owner`, and the other refusals `deploy.bluebook`'s own
84
+ # `LambdaTarget.Declare` and this generator raise)
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
+ # **`PII` detection** — structural only, no live boot: `load_bluebooks`/a
99
+ # bare `.hecksagon` `Kernel.load` populate `registry.pending_privacy_
100
+ # markings` (`AggregateDoor#mark_sensitive`/`BindingProxy#mark_sensitive`,
101
+ # lib/hecks/runtime/registry.rb) the same way `Runtime::Loader.boot`'s
102
+ # own first phase does, without that method's later `run_boot_gates!`/
103
+ # `dispatcher_for` steps — those need a live persistence adapter (a real
104
+ # Postgres connection for a PostgresEra-bound domain), which a static
105
+ # template generator must never require. A registry of its own, not
106
+ # `cross_domain_registry` — that one boots this domain's bluebook too,
107
+ # but through a different, hand-rolled port/adapter load (persistence
108
+ # + extraction + memory + prism only) than the one `pending_privacy_
109
+ # markings` was verified against; kept separate rather than assumed
110
+ # equivalent.
111
+ pii_registry = Hecks::Runtime::Registry.new(root: File.expand_path(domain))
112
+ Hecks.with_registry(pii_registry) do
113
+ bootstrap = Hecks::Ports::Loading.bootstrap
114
+ bluebook_dir = File.join(domain, "bluebook")
115
+ bootstrap.load_library
116
+ bootstrap.load_project(bootstrap.shared_root(nil, bluebook_dir))
117
+ bootstrap.load_bluebooks(bluebook_dir)
118
+ Dir.glob(File.join(bluebook_dir, "*.hecksagon")).each { |file| Kernel.load(file) }
119
+ end
120
+
121
+ # Only `category: "pii"` counts — `phi` or any other vocabulary a
122
+ # consuming domain's own `mark_sensitive` calls use stays a Governance/
123
+ # redaction concern (Privacy::Marking's own mechanism) without also
124
+ # provisioning CloudFront/WAF, which this generator has no way to know
125
+ # is warranted for, say, health data under a different compliance
126
+ # regime entirely. A domain wanting the same protection for a
127
+ # non-"pii" category marks it "pii" too — the category string is
128
+ # open-ended by design (privacy.bluebook's own header), not a closed
129
+ # enum this generator could instead enumerate.
130
+ pii_detected = pii_registry.pending_privacy_markings.any? { |marking| marking[:category].to_s == "pii" }
131
+
132
+ # **The tenant override itself** — see this file's own header comment on
133
+ # `--tenant`/`--schema` for the full reasoning. Applied here, before
134
+ # `infra_name`/`hecks_schema` are computed below, so both read it the
135
+ # same way they already read any other `deployed_to` setting.
136
+ if tenant_options[:tenant]
137
+ base_stack_name = deploy_settings[:stack_name] || domain_name
138
+ deploy_settings = deploy_settings.merge(
139
+ stack_name: "#{base_stack_name}-#{tenant_options[:tenant]}",
140
+ schema: tenant_options[:schema] || tenant_options[:tenant]
141
+ )
142
+ end
143
+
144
+ # **Every AWS-facing name below** — stack, Lambda logical id (and so the
145
+ # RDS logical id derived from it, `#{logical_id}Db`), S3 prefixes,
146
+ # bastion/secret names — reads this, not `domain_name`, from here on.
147
+ # Ordinarily they're the same string, and most domains never set
148
+ # `stack_name` at all. They diverge on purpose for a domain whose
149
+ # declared identity (`Hecks.bluebook`, hence `world.domain`, hence
150
+ # `.world`'s own filename — `domain_name` above, needed just to find
151
+ # that file) no longer matches its live AWS stack's own name: a
152
+ # CloudFormation stack cannot be renamed in place, and this domain's
153
+ # logical ids are already load-bearing on a real RDS instance other
154
+ # domains borrow from in Shared mode (see `owner_stack_name` below) —
155
+ # regenerating the template under a new stack/logical-id would not
156
+ # rename anything, it would stand up a second, empty, disconnected
157
+ # set of AWS resources next to the real one. `stack_name
158
+ # "embryonaut"` in `deployed_to("AwsLambda")` pins the original name
159
+ # forever, independent of any later `formerly_known_as`-style rename
160
+ # to the domain's own declared identity.
161
+ infra_name = deploy_settings[:stack_name] || domain_name
162
+
163
+ # **The actual Postgres/RDS identifier** — every domain before this one
164
+ # ever picked a single-word `stack_name` (or none at all, falling back
165
+ # to a single-word directory `domain_name`), so `infra_name` itself was
166
+ # already a valid database name and nothing ever needed a second
167
+ # variable. `stack_name "quality-control-webhook"` (a real, live,
168
+ # multi-word choice — the first one) broke that assumption for real:
169
+ # RDS's own DBName/DatabaseName parameter refuses it outright
170
+ # ("DBName must begin with a letter and contain only alphanumeric
171
+ # characters" — confirmed live, a genuine CREATE_FAILED before this
172
+ # existed), and `infra_name` unsanitized is also what every downstream
173
+ # consumer (WebFunction/#{logical_id}'s own DB_NAME Environment
174
+ # variable, every Makefile shell command that connects `-d
175
+ # #{infra_name}`) reads as the database to actually connect to — so
176
+ # every one of those has to agree on the same sanitized spelling, not
177
+ # just the one CloudFormation property that happened to refuse loudly.
178
+ # Stack names, logical ids, and S3 prefixes are unchanged (still read
179
+ # `infra_name` directly) — CloudFormation/S3 both allow hyphens fine;
180
+ # only the actual database identifier needed this at all.
181
+ db_name = infra_name.gsub(/[^a-zA-Z0-9]/, "")
182
+
183
+ # Validated, not hand-checked — dispatches into lib/hecks/deploy's
184
+ # own LambdaTarget.Declare (the same given/invariant machinery every
185
+ # other kind of bluebook mistake in this codebase is caught by),
186
+ # replacing a `fetch(:region) { abort ... }` chain with
187
+ # real, named, corpus-testable refusals: memory/timeout are actual
188
+ # Integers here (never silently stringified) and checked against
189
+ # Lambda's own real 128-10240 MB / 900s ceilings, not just "present or
190
+ # not." Only the default values stay Ruby's own concern —
191
+ # deploy.bluebook's own header explains why: a command-level `default:`
192
+ # is honored only at the JSON/Rust-codegen boundary, never by Ruby's
193
+ # own direct dispatch — so this validates a fully-resolved target,
194
+ # after defaulting, not instead of it.
195
+ deploy_dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
196
+ begin
197
+ target = deploy_dispatcher.dispatch(
198
+ "Deploy::LambdaTarget.Declare",
199
+ with: {
200
+ domain: { value: declared_domain_name },
201
+ region: { value: deploy_settings[:region] },
202
+ memory: { value: deploy_settings.fetch(:memory, 512) },
203
+ timeout: { value: deploy_settings.fetch(:timeout, 10) },
204
+ # "Postgres" — Embryonaut's own live choice, and every domain
205
+ # declared before this setting existed — unless a domain's own
206
+ # deployed_to("AwsLambda") names "Aurora" explicitly.
207
+ database: { value: deploy_settings.fetch(:database, "Postgres") },
208
+ # "None" — every domain declared before this setting existed,
209
+ # Embryonaut included (its own public web UI is the separate,
210
+ # pre-existing lambda_handler.rb-file-presence mechanism, not this
211
+ # attribute) — unless a domain names "Rust" explicitly.
212
+ web: { value: deploy_settings.fetch(:web, "None") }
213
+ }
214
+ ).instance
215
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
216
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsLambda\") is invalid: #{e.message}"
217
+ end
218
+
219
+ # `dispatch "None"` / `secret_env "..."` — deploy-time wiring facts, not
220
+ # business invariants `Deploy::LambdaTarget.Declare` itself needs to
221
+ # hold, read straight off `deploy_settings` the same way `owner`/
222
+ # `owner_stack`/`schema`/`stack_name` already are (this file's own
223
+ # comment on `infra_name`, above, has the full reasoning for why those
224
+ # stay Ruby-level rather than validated attributes).
225
+ #
226
+ # `dispatch "None"` decides whether the primary rust/host dispatch
227
+ # Lambda (`#{logical_id}`, computed further below) gets generated at
228
+ # all. Default (unset, every domain declared
229
+ # before this setting existed) keeps generating the rust/host wasmtime
230
+ # dispatch Lambda exactly as always — "None" is the new, opt-in case: no
231
+ # rust/host dispatch Lambda for this domain at all, because a domain
232
+ # with no `.bluebook`-shaped command surface of its own to dispatch
233
+ # through (QualityControl's own real first user: a driving GitHub
234
+ # webhook adapter, `lib/hecks/adapters/driving/github_webhook.rb`,
235
+ # calling straight into `QualityControl::Clearance` through Ruby, never
236
+ # through rust/host's wasmtime dispatch at all) has nothing for that
237
+ # Lambda to ever serve. Reuses the existing `lambda_handler.rb`-file-
238
+ # presence WebFunction mechanism wholesale (Runtime: ruby3.2, Function
239
+ # URL AuthType `NONE`, container build + patch-pg-native for `pg`'s native
240
+ # extension) rather than inventing a second Ruby-Lambda shape — the
241
+ # WebFunction path already proved itself for real (Embryonaut's own live
242
+ # stack) before this ever needed a second consumer; deploy.bluebook's
243
+ # own generator, "one generator reads what a domain declares," extends
244
+ # rather than forks.
245
+ dispatch_none = deploy_settings[:dispatch].to_s == "None"
246
+
247
+ # `secret_env "GITHUB_WEBHOOK_SECRET"` — names the env var
248
+ # WebFunction's own `lambda_handler.rb` should find the fetched secret's
249
+ # real plaintext under, once it resolves `#{secret_env}_ARN` (below,
250
+ # WebFunction's own Environment) via the AWS SDK the same cold-start
251
+ # pattern DATABASE_URL/SESSION_SECRET/GOOGLE_CLIENT_ID already use
252
+ # (WebFunction's own header comment: "fetch at runtime, over the SDK,
253
+ # never let CloudFormation/Lambda configuration see it at all"). Wired
254
+ # generically — any WebFunction-shaped domain declaring one gets its own
255
+ # auto-generated, never-typed-or-seen SecretsManager secret + least-
256
+ # privilege grant, not something specific to GitHub webhooks; this
257
+ # domain's own `qa/lambda_handler.rb` is simply the first real consumer.
258
+ webhook_secret_env = deploy_settings[:secret_env]
259
+
260
+ # `handler_module "QaWebhookLambdaHandler"` — the same name
261
+ # `lambda_handler.rb`'s own module actually defines, named here rather
262
+ # than assumed, since `dispatch "None"` is a generic mechanism (any
263
+ # future domain could adopt it, each with its own wrapper module name)
264
+ # — "WebLambdaHandler" stays the fixed, historical default for every
265
+ # domain that has never set this (Embryonaut's own, unchanged).
266
+ webhook_handler_module = deploy_settings.fetch(:handler_module, "WebLambdaHandler")
267
+ if dispatch_none && !deploy_settings.key?(:handler_module)
268
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsLambda\") declares dispatch \"None\" but no handler_module — add handler_module \"YourModuleName\" naming the module #{domain}/lambda_handler.rb defines."
269
+ end
270
+
271
+ # Every policy in every loaded chapter, not just the target domain's
272
+ # own top-level list — `uses_framework` (Compliance's own header:
273
+ # "genuinely supports either mode") could in principle attach a chapter
274
+ # that itself declares a cross-domain policy, and a policy nested inside
275
+ # `aggregate "X" do ... end` is exactly as real a cross-domain trigger as
276
+ # one declared at the bluebook's own top level (docs/implemented/guides/policies-
277
+ # and-process-managers.md: "A policy does not care where it was
278
+ # written"). `to_h`'s own `policies` already flattens aggregate-scoped
279
+ # and top-level policies into one list per bluebook — read the same way
280
+ # `rust/project/reactions.rb`'s `emit_cross_domain_policy_table` does.
281
+ cross_domain_lambda_targets = cross_domain_registry.bluebooks.flat_map { |chapter_name, bluebook|
282
+ bluebook.policies.select(&:target_domain).map(&:target_domain)
283
+ }.uniq.sort
284
+
285
+
286
+ region = target.state[:region].value
287
+ memory = target.state[:memory].value
288
+ timeout = target.state[:timeout].value
289
+ database = target.state[:database].value
290
+ aurora = database == "Aurora"
291
+ shared = database == "Shared"
292
+ rust_web = target.state[:web].value == "Rust"
293
+
294
+ # **`WAFv2` for CloudFront needs us-east-1** — not a preference, a hard
295
+ # AWS API constraint (`AWS::WAFv2::WebACL` with `Scope: CLOUDFRONT` is
296
+ # refused by CloudFormation outside us-east-1, independent of which
297
+ # region the distribution itself, being global, would otherwise
298
+ # suggest). Refused here, before writing a single file, the same
299
+ # discipline this generator already holds every other one of its own
300
+ # refusals to — a generated template that would only fail at deploy
301
+ # time, in a region a caller may not think to suspect, is a worse
302
+ # failure mode than refusing now with the fix named.
303
+ if pii_detected && region != "us-east-1"
304
+ raise ArgumentError, "#{world_file} marks a field \"pii\" but deployed_to(\"AwsLambda\") sets region " \
305
+ "#{region.inspect} — a CloudFront-scoped WAFv2 WebACL can only be created in " \
306
+ "us-east-1. Set region \"us-east-1\", or remove the pii marking if this domain " \
307
+ "genuinely holds none."
308
+ end
309
+
310
+ # Read straight off `deploy_settings` (the raw `WorldBuilder` bag), not
311
+ # through `target` — these two are optional and pii-only, unlike every
312
+ # `target.state[...]` field above, which `LambdaTarget` validates as
313
+ # always-present for every `AwsLambda` deployment regardless of pii.
314
+ # "none" (`RestrictionType: none`, `Locations: []`) is CloudFront's own
315
+ # default shape for "no restriction configured" — declaring one later
316
+ # is a `deployed_to` edit, not a template rewrite.
317
+ geo_restriction_type = deploy_settings[:geo_restriction] || "none"
318
+ geo_restriction_countries = deploy_settings[:geo_restriction_countries] || []
319
+
320
+ # **The storehouse** — this domain provisions no RDS/VPC of its own at all;
321
+ # it borrows another already-deployed domain's instance instead,
322
+ # isolated by a native Postgres schema (this domain's own lowercase
323
+ # name) rather than a separate database. `owner` is read the same way
324
+ # `pg_version`/`google_oauth_present` below are — a Ruby-level fact off
325
+ # `deploy_settings` directly, not one of `Deploy::LambdaTarget.Declare`'s
326
+ # own validated attributes (deploy.bluebook's own comment on why: which
327
+ # domain owns the shared instance is a deploy-time wiring fact, not a
328
+ # business invariant).
329
+ if shared
330
+ owner_domain_name = deploy_settings[:owner] or raise ArgumentError, <<~MSG
331
+ #{world_file}'s deployed_to("AwsLambda") declares database "Shared" but no owner. Add one, e.g.:
332
+
333
+ deployed_to("AwsLambda") do
334
+ ...
335
+ database "Shared"
336
+ owner "Embryonaut"
337
+ end
338
+
339
+ naming the already-deployed domain whose Postgres instance this one borrows.
340
+ MSG
341
+ # `hecks-<lowercase name>` / lowercase name-as-dbname — the same
342
+ # two conventions `stack_name`/`DBName: #{infra_name}` below already
343
+ # commit to for this domain, and the default for the owner too. Not
344
+ # always the owner's real stack name, though — a domain generated
345
+ # before this convention existed keeps whatever it was actually
346
+ # deployed as (real, live example: Embryonaut's own stack is
347
+ # "hecksagain-embryonaut", a legacy prefix, not "hecks-embryonaut" --
348
+ # `deploy:`'s own owner-outputs lookup below would look for a stack
349
+ # that has never existed and fail before this domain's own `sam
350
+ # deploy` ever ran). `owner_stack "hecksagain-embryonaut"` is the
351
+ # escape hatch, read the same deploy_settings-direct way `owner`
352
+ # itself is (deploy.bluebook's own comment on why neither lives in
353
+ # the validated LambdaTarget aggregate) — optional, defaults to the
354
+ # ordinary convention for every domain that doesn't need it.
355
+ owner_stack_name = deploy_settings[:owner_stack] || "hecks-#{owner_domain_name.downcase}"
356
+ owner_db_name = owner_domain_name.downcase
357
+ end
358
+
359
+ # `HECKS_SCHEMA` — rust/host's own optional counterpart to Ruby's
360
+ # `settings[:schema]` (postgres.rb's own `connect_for`). Automatic for a
361
+ # Shared-mode domain (this domain's own lowercase name — matches the
362
+ # schema the real migration creates for it, `CREATE SCHEMA
363
+ # #{infra_name}`, and needs no separate declaration since Shared mode
364
+ # already implies it). Optional and explicit otherwise
365
+ # (`deployed_to("AwsLambda") { schema "embryonaut" }`) — for an owning
366
+ # domain that later migrates itself onto a shared instance's own schema
367
+ # too (Embryonaut, in the real migration this exists for), without
368
+ # switching its own `database` setting away from where its real RDS
369
+ # instance already lives.
370
+ hecks_schema = shared ? infra_name : deploy_settings[:schema]
371
+
372
+ logical_id = "#{infra_name.split(/[_-]/).map(&:capitalize).join}Function"
373
+ # `stack_prefix` — the "hecks-" half of this domain's own stack name, a
374
+ # setting only for a domain whose live stack predates the convention
375
+ # (real, live example: Embryonaut's own stack, both its Lambda function
376
+ # names, and its Google OAuth secret are all "hecksagain-embryonaut*",
377
+ # generated by the pre-rename hecksagain fork). Same shape and
378
+ # reasoning as `owner_stack` above — read `deploy_settings`-direct, not
379
+ # validated by LambdaTarget, optional — but for this domain rather than
380
+ # the owner it borrows from. Everything downstream (stack, FunctionName
381
+ # for both Lambdas, the OAuth secret name and its IAM policy, the
382
+ # bastion stack, samconfig's stack_name/s3_prefix) reads `stack_name`,
383
+ # so one setting moves them all together; `infra_name` (logical ids,
384
+ # deploy/<dir>, DB name) is untouched — that is `stack_name`'s job.
385
+ # Defaults to "hecks", so every domain that doesn't set it regenerates
386
+ # byte-identically.
387
+ stack_prefix = deploy_settings[:stack_prefix] || "hecks"
388
+ stack_name = "#{stack_prefix}-#{infra_name}"
389
+
390
+ db_id = "#{logical_id.sub(/Function\z/, '')}Db"
391
+ # The Aurora shape splits the database in two (a DBCluster carrying
392
+ # the managed password/endpoint, plus at least one DBInstance inside
393
+ # it) — every `.Endpoint.Address`/`.MasterUserSecret.SecretArn`
394
+ # reference below has to point at the cluster, not `db_id` itself,
395
+ # when Aurora is chosen. The plain-RDS path is unaffected: `db_ref_id`
396
+ # equals `db_id` exactly, so every one of those references renders
397
+ # byte-identical to before this existed.
398
+ db_ref_id = aurora ? "#{db_id}Cluster" : db_id
399
+
400
+ # The Aurora path uses a self-managed secret (`#{db_id}Secret`, below —
401
+ # see its own comment for why: ManageMasterUserPassword's rotation
402
+ # breaks a Lambda's static Environment), not the plain-RDS path's own
403
+ # `db_ref_id.MasterUserSecret` attribute (which only exists for the
404
+ # managed-password feature at all). `secret_sub` is the identifier for
405
+ # use inside a `!Sub` "${...}" interpolation (dot-free for a plain
406
+ # `!Ref`, dotted for a `!GetAtt` attribute — CloudFormation's `${}`
407
+ # auto-detects either shape); `secret_intrinsic` is the same fact
408
+ # spelled as a bare YAML value, for the one site (stack_outputs) that
409
+ # isn't inside a `!Sub` string at all.
410
+ secret_sub = aurora ? "#{db_id}Secret" : "#{db_ref_id}.MasterUserSecret.SecretArn"
411
+ secret_intrinsic = aurora ? "!Ref #{db_id}Secret" : "!GetAtt #{db_ref_id}.MasterUserSecret.SecretArn"
412
+ # The same `${...}`-inside-`!Sub` identifier `secret_sub` above already
413
+ # is -- used here for #{logical_id}'s own runtime IAM grant instead of a
414
+ # deploy-time `{{resolve:secretsmanager:...}}` dynamic reference (see
415
+ # that Environment.Variables comment below for why DATABASE_URL moved
416
+ # off that mechanism entirely). Shared mode has no `secret_sub` of its
417
+ # own to reach for -- `OwningDatabaseSecretArn` (this template's own
418
+ # Parameter, filled from the owning stack's Output) already is the bare
419
+ # ARN string, not a Ref/GetAtt target inside this stack.
420
+ db_secret_ref = shared ? "OwningDatabaseSecretArn" : secret_sub
421
+
422
+ # **Opt-in, by file presence** — a domain that wants its own web app on
423
+ # Lambda too (not every domain has one; Banking doesn't) drops a
424
+ # `lambda_handler.rb` at its own root, the Rack-to-Function-URL-event
425
+ # adapter Sinatra runs through. No new .world verb for this: the file
426
+ # itself is the declaration, the same way a `.hecksagon` file's mere
427
+ # existence is what makes a domain framework-attached.
428
+ web_handler_present = File.exist?(File.join(domain, "lambda_handler.rb"))
429
+
430
+ # A domain declares one web story, not both — `web "Rust"` (rust/host
431
+ # serves its own web UI in-process) and a `lambda_handler.rb` (a
432
+ # separate Ruby WebFunction) are two different mechanisms for the same
433
+ # job; picking one silently when both are present would be exactly the
434
+ # kind of ambiguity this generator's own `deploy.bluebook` gate exists
435
+ # to refuse instead of guess at.
436
+ if rust_web && web_handler_present
437
+ raise ArgumentError, "#{domain} declares both web \"Rust\" (#{world_file}) and a lambda_handler.rb (#{File.join(domain, 'lambda_handler.rb')}) — pick one."
438
+ end
439
+
440
+ # Read straight out of the domain's own Gemfile.lock, not hardcoded —
441
+ # `patch-pg-native`'s build recipe below has to fetch the same pg
442
+ # version Bundler resolved, or its from-source extension and the
443
+ # gem's own Ruby wrapper (lib/pg.rb, autoloads, etc.) can drift apart.
444
+ # The plain, platform-less lock line ("pg (1.6.3)") is what this
445
+ # matches — platform-suffixed siblings ("pg (1.6.3-aarch64-linux)")
446
+ # fail the all-digits-and-dots capture on purpose.
447
+ #
448
+ # Optional, not required whenever a web app exists — Embryonaut's own
449
+ # Sinatra app talks to Postgres directly (hence `pg` in its
450
+ # Gemfile.lock) but that's that app's own choice, not a rule every
451
+ # WebFunction follows. A web app that only ever dispatches through a
452
+ # Lambda-routed domain (RemoteDispatcher/Adapters::Lambda, never a
453
+ # local Postgres connection — hecks_on_web's own apps, e.g.) has no
454
+ # `pg` gem and needs none of the native-extension patching below; the
455
+ # generated Makefile's own `deploy:` target branches on whether this
456
+ # is present, same convention `google_oauth_present` already uses for
457
+ # its own opt-in machinery.
458
+ pg_version = nil
459
+ if web_handler_present
460
+ # `dispatch "None"` reads this project's own root Gemfile.lock, not
461
+ # #{domain}'s — WebFunction's own CodeUri is `root` in that case
462
+ # (`web_code_uri`, below), not #{domain} itself, precisely because
463
+ # #{domain} (a bluebook directory inside this very repo, e.g. "qa")
464
+ # has no Gemfile of its own to bundle at all; it needs `lib/hecks`
465
+ # and this project's own Gemfile.lock, sitting one level up. Every
466
+ # other WebFunction (Embryonaut's own self-contained app checkout,
467
+ # vendoring its own copy of hecks) keeps reading its own, unchanged.
468
+ pg_version_path = File.join(dispatch_none ? root : domain, "Gemfile.lock")
469
+ pg_version = File.read(pg_version_path)[/^\s+pg \(([\d.]+)\)/, 1]
470
+ end
471
+ web_logical_id = "WebFunction"
472
+
473
+ # `dispatch "None"` — same reason `pg_version_path` reaches one
474
+ # directory further up, above: #{domain} alone has no Gemfile/lib/hecks
475
+ # of its own to zip, so WebFunction's CodeUri has to be this whole
476
+ # project's own root instead (the identical absolute-path shape
477
+ # Embryonaut's own real, live deploy already uses for a checkout that
478
+ # isn't colocated with `deploy/<stack>/` either — bin/project_deploy's
479
+ # own header: "Self-contained... no hand-authored deployment config").
480
+ # `web_handler_relpath` follows the same split: #{domain}/lambda_handler
481
+ # is only the right relative path once CodeUri stops being #{domain}
482
+ # itself — every existing WebFunction (CodeUri: #{domain}) still finds
483
+ # its own lambda_handler.rb at that directory's own root, unchanged.
484
+ web_code_uri = dispatch_none ? root : domain
485
+ web_handler_relpath = dispatch_none ? "#{domain}/lambda_handler" : "lambda_handler"
486
+
487
+ # Opt-in, by file presence + content — the same convention
488
+ # `web_handler_present` itself uses. `.env.local` is #{domain}'s own
489
+ # gitignored local-dev secrets file (never committed, never read by
490
+ # this tool for anything but detecting the key is there); the real
491
+ # value is synced into Secrets Manager by `make sync-google-oauth`
492
+ # below, straight from that same file, at deploy time — never baked
493
+ # into this generated, git-tracked template.yaml as plaintext.
494
+ # Also gates the NAT Gateway/public subnet template.yaml's own "no NAT
495
+ # gateway" comment describes — computed here, once, before
496
+ # stack_outputs/bastion_parameters below need to know whether
497
+ # #{db_id}PublicSubnet exists at all to pass along to bastion.yaml.
498
+ google_oauth_present = (web_handler_present || rust_web) &&
499
+ File.exist?(File.join(domain, ".env.local")) &&
500
+ File.read(File.join(domain, ".env.local")).match?(/^GOOGLE_CLIENT_ID=\S/)
501
+
502
+ # **The Ruby case stays refused** — a shared-instance Ruby WebFunction
503
+ # needs its own cross-stack DATABASE_URL wiring this generator doesn't
504
+ # build yet (web_handler_present's own Member-style direct-Postgres
505
+ # binding), real, unbuilt design, not a small addition. This check
506
+ # alone is why the google_oauth_present check below can assume
507
+ # web_handler_present is already false by the time it runs — it always
508
+ # aborts first when both are true.
509
+ if shared && web_handler_present
510
+ raise ArgumentError, "#{domain} declares both database \"Shared\" and a lambda_handler.rb — a shared-instance Ruby WebFunction isn't supported yet."
511
+ end
512
+
513
+ # `dispatch "None"` means WebFunction becomes the domain's only Lambda —
514
+ # there is nothing left to reach through a Function URL at all without a
515
+ # `lambda_handler.rb` to serve it, and (below) no rust_web dispatch
516
+ # Lambda whose own Function URL that role could fall back to either.
517
+ if dispatch_none && !web_handler_present
518
+ raise ArgumentError, "#{domain} declares dispatch \"None\" but has no lambda_handler.rb — dispatch \"None\" means no rust/host dispatch Lambda at all, so a WebFunction (lambda_handler.rb) has to exist to be the domain's only Lambda."
519
+ end
520
+ if dispatch_none && rust_web
521
+ raise ArgumentError, "#{domain} declares both dispatch \"None\" and web \"Rust\" — dispatch \"None\" already means there is no rust/host Lambda for rust_web's own in-process web UI to run inside."
522
+ end
523
+
524
+ # **The Rust case is not refused** — rust_web's own OAuth wiring (the main
525
+ # dispatch function's own Environment/VpcConfig, generated below) needs
526
+ # no NAT Gateway of its own in Shared mode: it already runs inside the
527
+ # owner's borrowed private subnets (OwningSubnetAId/OwningSubnetBId)
528
+ # and borrowed security group (OwningSecurityGroupId) — the same ones
529
+ # this domain's own dispatch traffic already uses — and the owner's own
530
+ # template already routes those subnets through its NAT Gateway and
531
+ # already permits 443-to-internet egress on that security group (added
532
+ # there for the owner's own WebFunction's real, live OAuth
533
+ # token-exchange bug — see that stack's own
534
+ # `#{owner_domain_name}FunctionEgressToInternet` resource). Nothing new
535
+ # to provision for this combination; the Parameters section below just
536
+ # has to declare both the Owning* set (Shared mode) and
537
+ # WebRedirectBaseUrl (OAuth) together, not as alternatives — see its
538
+ # own comment on why an if/elsif there would be wrong.
539
+ #
540
+ # Still a real gap, left refused: rust_web isn't checked here
541
+ # explicitly because shared && web_handler_present already aborted
542
+ # above whenever web_handler_present is true — so by construction, if
543
+ # `shared && google_oauth_present` is ever true at this point,
544
+ # web_handler_present must be false, meaning google_oauth_present's own
545
+ # `(web_handler_present || rust_web)` can only have been satisfied by
546
+ # rust_web. Nothing left to refuse here.
547
+
548
+ # The stack↔bastion contract, and the least-privilege cross-domain
549
+ # invoke grant — `Shared`'s own header explains why both are
550
+ # `Fargate`'s problem too, not only this target's.
551
+ stack_outputs = Shared.stack_outputs(
552
+ shared: shared, db_id: db_id, db_ref_id: db_ref_id, secret_intrinsic: secret_intrinsic,
553
+ compute_security_group_ref: "!Ref #{logical_id}SecurityGroup", google_oauth_present: google_oauth_present
554
+ )
555
+ bastion_parameters = Shared.bastion_parameters(shared: shared, google_oauth_present: google_oauth_present)
556
+ Shared.check_bastion_parameters!(bastion_parameters, stack_outputs)
557
+
558
+ # Computed here, as its own local, rather than as two heredocs
559
+ # opened side by side inside the big template heredoc's own
560
+ # interpolation below — a heredoc's body is read starting from
561
+ # the line after it opens, so two on one line only stay
562
+ # unambiguous as long as neither branch is ever edited to span
563
+ # the other's own territory. An ordinary `if`/`else`, each
564
+ # heredoc entirely inside its own branch, cannot develop that
565
+ # failure mode at all.
566
+ web_google_oauth_env_yaml =
567
+ if google_oauth_present
568
+ <<~GOOGLE.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join.rstrip
569
+ # BY NAME, not `!Ref`/`!GetAtt` -- #{web_logical_id}GoogleOauth
570
+ # is deliberately NOT declared as a CloudFormation resource
571
+ # here (would need either GenerateSecretString, which can't
572
+ # invent a real Google client_id/secret, or a value baked
573
+ # into this git-TRACKED template as plaintext). `make
574
+ # sync-google-oauth` owns this secret's whole lifecycle
575
+ # instead, straight from #{domain}'s own gitignored
576
+ # .env.local, outside CloudFormation entirely. GetSecretValue
577
+ # accepts a bare name directly, no ARN needed -- same
578
+ # generator-only caveat as DB_SECRET_ARN's own comment above.
579
+ GOOGLE_OAUTH_SECRET_ID: #{stack_name}-web-google-oauth
580
+ GOOGLE_REDIRECT_URI: !Sub "${WebRedirectBaseUrl}/auth/google/callback"
581
+ GOOGLE
582
+ else
583
+ <<~NOOAUTH.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join.rstrip
584
+ # GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_REDIRECT_URI
585
+ # deliberately NOT set here — #{domain}'s own .env.local has
586
+ # no real GOOGLE_CLIENT_ID yet, matching Fly's own current
587
+ # reality (fly secrets list -a embryonaut-founder-app: only
588
+ # DATABASE_URL and SESSION_SECRET) that nobody has configured
589
+ # OAuth for this app at all. Drop real credentials into
590
+ # .env.local and regenerate to wire this up.
591
+ NOOAUTH
592
+ end
593
+
594
+ template_yaml = <<~YAML
595
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
596
+ # this file rather than hand-editing. Source: #{world_file}'s own
597
+ # deployed_to("AwsLambda") block.
598
+ #
599
+ # SELF-CONTAINED, ON PURPOSE — this stack owns its own private VPC,
600
+ # subnets, security groups, and RDS Postgres instance, not just the
601
+ # Lambda. No externally-supplied VPC/database parameters, no
602
+ # DatabaseUrl secret anyone has to type: RDS's own
603
+ # ManageMasterUserPassword generates and stores the password in
604
+ # Secrets Manager, and DATABASE_URL is composed from it via a
605
+ # CloudFormation dynamic reference — resolved at deploy time, never
606
+ # visible in this template, a parameter, or any tool's output.
607
+ #
608
+ # NO NAT GATEWAY for #{logical_id} — deliberately. Its own subnets
609
+ # have no route to the internet at all, only to the RDS instance
610
+ # inside the same VPC: dispatch a command, read/write the journal.
611
+ # CloudWatch Logs delivery doesn't go through the function's own VPC
612
+ # networking, so it needs no route either.
613
+ #{google_oauth_present ? " #\n # #{web_logical_id} is the ONE exception -- real Google OAuth token\n # exchange (hecks's own GoogleAuthentication adapter posting to\n # https://oauth2.googleapis.com/token) is a genuine third-party HTTPS\n # call with no AWS PrivateLink/VPC Endpoint option (unlike\n # lambda:InvokeFunction, above), and #{web_logical_id} is ALSO\n # VPC-attached (Member's own permanent Postgres binding needs the\n # private RDS instance directly). A real, live 15-second Lambda\n # timeout caught this -- the token-exchange POST had nowhere to\n # route to and just hung. #{db_id}NatGateway below is the real,\n # ongoing-cost fix (one NAT Gateway, one public subnet) -- opt-in,\n # the SAME google_oauth_present signal that wires\n # GOOGLE_CLIENT_ID/SECRET, since nothing else in this stack needs\n # outbound internet at all." : " # Real outbound internet access from here (a future external API\n # call, say) would need a NAT Gateway added -- a real, ongoing cost\n # this template doesn't take on until something actually needs it."}
614
+ AWSTemplateFormatVersion: '2010-09-09'
615
+ Transform: AWS::Serverless-2016-10-31
616
+ Description: >
617
+ #{infra_name} — dispatched through hecks's rust/host, a
618
+ wasmtime-sandboxed Rust binary with a rehydrate-and-replay Postgres
619
+ journal (docs/decisions/0018), backed by its own private RDS
620
+ Postgres instance. PackageType Zip, no container.
621
+ #{
622
+ # Two independent reasons a Parameters section might be needed --
623
+ # google_oauth_present (any web mode) and shared (any domain
624
+ # borrowing an owner's VPC) -- are collected into one array and
625
+ # joined under a single `Parameters:` header (YAML permits exactly
626
+ # one), rather than treated as mutually exclusive alternatives via
627
+ # an if/elsif/else here. That shape breaks for the one combination
628
+ # both can be true at once (a Shared-mode rust_web domain with
629
+ # real Google OAuth): the elsif branch would never run, so
630
+ # OwningSubnetAId/OwningSecurityGroupId/etc. would never get declared as
631
+ # Parameters at all, even though VpcConfig below (`shared ?
632
+ # "SubnetIds: [!Ref OwningSubnetAId, ...]" : ...`) already
633
+ # references them unconditionally whenever `shared` is true --
634
+ # exactly the CloudFormation-references-an-undeclared-Parameter
635
+ # break bastion_parameters' own generation-time assertion (above)
636
+ # exists to catch for a different table. This fixes it for both
637
+ # the "either" and the "both" case.
638
+ param_blocks = []
639
+ if google_oauth_present
640
+ param_blocks << <<~OAUTHPARAMS.rstrip
641
+ # Lambda Function URLs get a random, un-derivable hostname at
642
+ # creation -- #{web_logical_id}'s own GOOGLE_REDIRECT_URI can't
643
+ # reference `!GetAtt #{web_logical_id}Url.FunctionUrl` from inside
644
+ # #{web_logical_id}'s OWN Environment (that's a real circular
645
+ # dependency CloudFormation rejects: the value doesn't exist until
646
+ # the function+URL do). Resolved OUTSIDE the stack instead --
647
+ # `make deploy`'s own recipe looks up the CURRENTLY deployed
648
+ # Function URL (empty on a true first deploy, before the URL
649
+ # exists at all) and passes it as `--parameter-overrides`; every
650
+ # deploy after the first self-heals this correctly.
651
+ WebRedirectBaseUrl:
652
+ Type: String
653
+ Default: ""
654
+ OAUTHPARAMS
655
+ end
656
+ if shared
657
+ param_blocks << <<~SHAREDPARAMS.rstrip
658
+ # THE STOREHOUSE — #{owner_domain_name}'s own live stack Outputs,
659
+ # looked up at deploy time (Makefile's own `deploy:`/`mint-era`
660
+ # targets, below) via the SAME `aws cloudformation describe-stacks`
661
+ # pattern `mint-era` already used for a bastion, one stack over
662
+ # instead of one sibling stack over. Never a CloudFormation
663
+ # Export/ImportValue -- this codebase uses none anywhere (bin/
664
+ # project_deploy's own header on why: a live shell lookup keeps
665
+ # this stack independently deployable/deletable, never coupled to
666
+ # #{owner_domain_name}'s own stack through CloudFormation itself).
667
+ OwningVpcId:
668
+ Type: AWS::EC2::VPC::Id
669
+ OwningSubnetAId:
670
+ Type: AWS::EC2::Subnet::Id
671
+ OwningSubnetBId:
672
+ Type: AWS::EC2::Subnet::Id
673
+ # #{owner_domain_name}'s own Lambda security group, NOT its
674
+ # DB-facing one -- reused whole, not copied, the same shape
675
+ # this codebase already trusts elsewhere (a WebFunction sharing
676
+ # its own dispatch Lambda's security group rather than minting
677
+ # a second one), one stack over instead of one resource over.
678
+ # #{owner_domain_name}'s own DB security group already permits
679
+ # ingress FROM this group (every member of it, regardless of
680
+ # which stack minted the member -- security group references
681
+ # match on GROUP MEMBERSHIP, not resource identity), so joining
682
+ # it is what actually grants this Lambda access, not a new
683
+ # ingress rule #{owner_domain_name}'s own stack would otherwise
684
+ # need to add.
685
+ OwningSecurityGroupId:
686
+ Type: AWS::EC2::SecurityGroup::Id
687
+ OwningDatabaseEndpoint:
688
+ Type: String
689
+ OwningDatabaseSecretArn:
690
+ Type: String
691
+ SHAREDPARAMS
692
+ end
693
+ if param_blocks.empty?
694
+ ""
695
+ else
696
+ # Every line indented 2, not just appended flush-left — each
697
+ # heredoc above squiggly-dedents to column 0 (its own
698
+ # least-indented line, matching every other heredoc's own
699
+ # convention in this file), but these are Parameters: children,
700
+ # which YAML requires indented under it. Confirmed the hard way:
701
+ # without this, `ruby -ryaml` parses the file without raising
702
+ # (it's still syntactically valid YAML) but
703
+ # `doc["Parameters"]["OwningVpcId"]` is nil — WebRedirectBaseUrl/
704
+ # OwningVpcId/etc. land as their own top-level document keys,
705
+ # siblings of Parameters/Resources, not children of Parameters
706
+ # at all.
707
+ "Parameters:\n" + param_blocks.join("\n").each_line.map { |l| " #{l}" }.join
708
+ end
709
+ }
710
+ Resources:
711
+ #{shared ? "" : Shared.vpc_and_database_yaml(
712
+ db_id: db_id, db_name: db_name, infra_name: infra_name, aurora: aurora,
713
+ google_oauth_present: google_oauth_present, compute_logical_id: logical_id,
714
+ compute_description: "#{logical_id} - no inbound (Lambda receives no traffic via its VPC ENI), egress rule attached separately below"
715
+ ).each_line.with_index.map { |l, i| (i.zero? ? "" : " ") + l }.join.rstrip}
716
+
717
+ #{rust_web && google_oauth_present ? <<~RUSTSECRET.each_line.with_index.map { |l, i| (i.zero? ? "" : " ") + l }.join.rstrip : ""}
718
+ # Auto-generated, never typed or seen -- rust/host's own
719
+ # auth.rs signs both the session cookie and the OAuth `state`
720
+ # token with this. Same ManageMasterUserPassword-style pattern
721
+ # #{db_id} already uses for its own password.
722
+ #{logical_id}SessionSecret:
723
+ Type: AWS::SecretsManager::Secret
724
+ Properties:
725
+ GenerateSecretString:
726
+ SecretStringTemplate: '{}'
727
+ GenerateStringKey: session_secret
728
+ PasswordLength: 64
729
+ ExcludePunctuation: true
730
+
731
+ RUSTSECRET
732
+ #{logical_id}:
733
+ Type: AWS::Serverless::Function#{aurora ? "\n # `{{resolve:secretsmanager:...}}` dynamic references do NOT\n # create an implicit CloudFormation dependency on the referenced\n # resource (documented AWS behavior) -- #{logical_id}'s own\n # Environment resolves one against #{db_id}Secret below. A plain\n # `DependsOn: #{db_id}Secret` alone (CREATE_COMPLETE before this\n # resource's own update starts) still hit a real, live, repeatable\n # \"Secrets Manager can't find the specified secret\n # (ResourceNotFoundException)\" -- CREATE_COMPLETE from\n # CloudFormation's own perspective doesn't guarantee the secret is\n # yet READABLE via a dynamic reference from a DIFFERENT resource's\n # own concurrent update (AWS-side eventual consistency, not a\n # CloudFormation ordering bug `DependsOn` alone can close).\n # Chaining through #{db_id}Cluster too -- which ALSO reads this\n # secret, and whose own RDS-side update takes real, substantial\n # wall-clock time to apply -- gives that propagation window time\n # to close before #{logical_id}'s own resolution is attempted.\n DependsOn: [#{db_id}Secret, #{db_id}Cluster]" : ""}
734
+ Metadata:
735
+ BuildMethod: makefile
736
+ Properties:
737
+ # PINNED, not SAM's own auto-suffixed default — a Ruby-side
738
+ # LambdaClient needs to know this function's name AHEAD OF a
739
+ # first deploy (there's no chicken-egg lookup step), the same
740
+ # "configured for me, in the projection" standard every other
741
+ # piece of generated deploy config already holds to. Matches
742
+ # `stack_name` above exactly — one name, two places it's read
743
+ # from, never out of sync since both derive from `infra_name`.
744
+ FunctionName: #{stack_name}
745
+ # EXPLICIT, not omitted -- `sam build EmbryonautFunction` and
746
+ # `sam build --use-container WebFunction` run as TWO SEPARATE
747
+ # single-resource invocations (deploy:'s own comment on why:
748
+ # cargo-lambda cross-compiles on this host directly, pg's
749
+ # native extension needs the container -- `--use-container` is
750
+ # a global sam build flag, can't be scoped per-resource). Each
751
+ # invocation regenerates .aws-sam/build/template.yaml from
752
+ # THIS source template for every resource, not just the one it
753
+ # builds -- a resource with no CodeUri here reverts to
754
+ # CodeUri-less after the SECOND invocation, and `sam deploy`
755
+ # then defaults a missing CodeUri to the template's own
756
+ # containing directory (deploy/#{infra_name}/ itself),
757
+ # zipping the whole project tree -- including WebFunction's
758
+ # own source -- as this function's code. A real, live deploy
759
+ # shipped exactly that broken artifact (confirmed via
760
+ # `aws lambda get-function` + downloading the actual deployed
761
+ # zip). Pinning CodeUri here survives that regeneration: it
762
+ # still resolves to `.aws-sam/build/#{logical_id}/`, where
763
+ # build-#{logical_id}'s own `cp ... $(ARTIFACTS_DIR)` already
764
+ # placed the real bootstrap+wasm, regardless of which of the
765
+ # two sam build calls ran most recently.
766
+ # "." NOT "#{logical_id}" -- SAM's Makefile build workflow
767
+ # requires the Makefile to live INSIDE CodeUri itself
768
+ # (confirmed live: "Makefile not found at
769
+ # .../EmbryonautFunction/Makefile" when CodeUri named a
770
+ # directory of its own). This project's one Makefile lives at
771
+ # the deploy directory's own root, alongside this template --
772
+ # "." is where it actually is.
773
+ CodeUri: .
774
+ PackageType: Zip
775
+ Runtime: provided.al2023
776
+ Architectures: [arm64]
777
+ Handler: bootstrap
778
+ MemorySize: #{memory}
779
+ Timeout: #{timeout}
780
+ Environment:
781
+ Variables:
782
+ # NOT a `{{resolve:secretsmanager:...}}`-composed DATABASE_URL
783
+ # anymore (the WebFunction handler below still uses that
784
+ # mechanism, unchanged) -- that dynamic reference genuinely
785
+ # IS resolved fresh at deploy time and never rendered into
786
+ # this template, this tool's output, or the CloudFormation
787
+ # console. But once it resolves INTO a Lambda's own
788
+ # Environment.Variables entry, the resolved PLAINTEXT is
789
+ # stored in the function's OWN configuration:
790
+ # `lambda:GetFunctionConfiguration` returns it decrypted,
791
+ # and the Lambda console's Configuration tab displays it, to
792
+ # ANY principal holding read-only account access (AWS's own
793
+ # managed ReadOnlyAccess policy included) -- caught
794
+ # reviewing this template, not live; AWS's own guidance is
795
+ # to fetch a secret from Secrets Manager at RUNTIME instead,
796
+ # precisely to avoid this. DB_HOST/DB_NAME/DB_SECRET_ARN
797
+ # below are not secrets themselves -- an endpoint address, a
798
+ # database name, and a Secrets Manager ARN authenticate
799
+ # nothing on their own -- so sitting here as plain
800
+ # Environment.Variables values costs nothing; main.rs itself
801
+ # fetches the actual password from Secrets Manager at cold
802
+ # start, over the AWS SDK, and never hands it back to
803
+ # CloudFormation or Lambda's own configuration at all.
804
+ # #{logical_id}'s own least-privilege Policies grant (below,
805
+ # sibling of VpcConfig) is the one runtime IAM permission
806
+ # that fetch needs, scoped to this one secret ARN.
807
+ #{if shared
808
+ # **The storehouse** — #{owner_domain_name}'s own live
809
+ # Endpoint/Secret, looked up at deploy time into
810
+ # OwningDatabaseEndpoint/OwningDatabaseSecretArn (this
811
+ # template's own Parameters, above), not a resource in
812
+ # this stack. Same dbname as #{owner_domain_name}'s own
813
+ # DBName -- one database, isolated by schema
814
+ # (HECKS_SCHEMA below), not a second database.
815
+ "DB_HOST: !Ref OwningDatabaseEndpoint\n" \
816
+ " DB_NAME: #{owner_db_name}\n" \
817
+ " DB_SECRET_ARN: !Sub \"${OwningDatabaseSecretArn}\""
818
+ else
819
+ "DB_HOST: !GetAtt #{db_ref_id}.Endpoint.Address\n" \
820
+ " DB_NAME: #{db_name}\n" \
821
+ " DB_SECRET_ARN: !Sub \"${#{secret_sub}}\""
822
+ end}
823
+ # `domain_name`, NOT `infra_name` — this names the actual
824
+ # .wasm FILE bin/project_wasm writes (rust/dist/#{domain_name}.wasm,
825
+ # its own target_mod_name convention, a Rust-build-pipeline
826
+ # concern unrelated to AWS resource identity) and what the
827
+ # Makefile below actually bundles into the Lambda package
828
+ # under. Confirmed the hard way: pinning this to infra_name
829
+ # alongside the real AWS-identity fields left it looking for
830
+ # a file bin/project_wasm never produces.
831
+ HECKS_WASM_PATH: !Sub "/var/task/#{domain_name}.wasm"
832
+ # STATIC, not a deploy-time parameter — HECKS_DOMAIN is this
833
+ # domain's own declared name, known at generation time the
834
+ # same way DBName/HECKS_WASM_PATH already are. HECKS_ERA is
835
+ # "1" because that's what a FRESH database always gets:
836
+ # PostgresEra::LineageManager::EraResolver#check! mints era 1
837
+ # unconditionally the first time any Ruby process boots
838
+ # against an empty `hecks_eras` (era_resolver.rb's own
839
+ # `held.empty?` branch) — deterministic, not guessed. `make
840
+ # mint-era` (this directory's own Makefile) is what actually
841
+ # triggers that first boot; a LATER real schema evolution
842
+ # (era 2+) is a separate, later re-generation, not this one.
843
+ HECKS_DOMAIN: #{declared_domain_name}
844
+ # UNCONDITIONAL, not gated on `rust_web` — `rust/host/src/main.rs`'s
845
+ # own boot sequence reads `ir::ir().ok_or(...)?` for every domain
846
+ # regardless of web mode (era-lineage bookkeeping needs the IR, not
847
+ # just the optional in-process web UI), so a Shared-mode domain with
848
+ # no web layer still needs this sidecar path to boot at all. Previously
849
+ # gated on `rust_web`, which left every such domain's Lambda crashing
850
+ # on cold start — checkout.rs's own header flagged this exact
851
+ # contradiction, confirmed live against Banking's committed template.
852
+ HECKS_IR_PATH: !Sub "/var/task/#{domain_name}.ir.json"
853
+ HECKS_ERA: "1"#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}#{rust_web && google_oauth_present ? <<~RUSTOAUTH.each_line.with_index.map { |l, i| i.zero? ? "\n " + l : " " + l }.join.rstrip : ""}
854
+ # NOT `{{resolve:secretsmanager:...}}` composing
855
+ # GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/SESSION_SECRET
856
+ # directly anymore -- the identical GetFunctionConfiguration
857
+ # exposure DATABASE_URL's own comment above documents
858
+ # applied here too. main.rs's own cold start fetches both
859
+ # secrets itself and `set_var`s the real GOOGLE_CLIENT_ID/
860
+ # GOOGLE_CLIENT_SECRET/SESSION_SECRET keys before anything
861
+ # else runs -- auth.rs/web.rs still read those exact keys,
862
+ # unchanged. GOOGLE_OAUTH_SECRET_ID is a bare NAME (`make
863
+ # sync-google-oauth` owns #{stack_name}-web-google-oauth's
864
+ # whole lifecycle outside CloudFormation, straight from
865
+ # #{domain}'s own gitignored .env.local, never baked into
866
+ # this generated, git-tracked template as plaintext) --
867
+ # GetSecretValue accepts a name directly, no ARN needed.
868
+ GOOGLE_OAUTH_SECRET_ID: #{stack_name}-web-google-oauth
869
+ GOOGLE_REDIRECT_URI: !Sub "${WebRedirectBaseUrl}/auth/google/callback"
870
+ SESSION_SECRET_ARN: !Sub "${#{logical_id}SessionSecret}"
871
+ RUSTOAUTH
872
+ # LEAST-PRIVILEGE -- #{logical_id}'s own execution role is
873
+ # otherwise SAM's bare default (logging only); this is the one
874
+ # runtime AWS permission the DB_SECRET_ARN handling above needs,
875
+ # scoped to the single secret ARN this stack itself depends on,
876
+ # never `Resource: "*"`.
877
+ Policies:
878
+ - Statement:
879
+ - Effect: Allow
880
+ Action: secretsmanager:GetSecretValue
881
+ Resource: !Sub "${#{db_secret_ref}}"
882
+ #{rust_web && google_oauth_present ? <<~OAUTHPOLICY.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join.rstrip : ""}
883
+ - Statement:
884
+ - Effect: Allow
885
+ Action: secretsmanager:GetSecretValue
886
+ # NAME-BASED, not `!Ref`/`!GetAtt` -- this secret is
887
+ # never a stack resource (RUSTOAUTH's own Environment
888
+ # comment, above, has the full story), so there is no
889
+ # exact ARN to point at. The trailing `-*` is AWS's
890
+ # own documented pattern for granting a secret known
891
+ # only by name: every real ARN Secrets Manager mints
892
+ # is this name plus a random 6-character suffix,
893
+ # which can't be predicted at template-render time.
894
+ Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
895
+ - Statement:
896
+ - Effect: Allow
897
+ Action: secretsmanager:GetSecretValue
898
+ Resource: !Sub "${#{logical_id}SessionSecret}"
899
+ OAUTHPOLICY
900
+ # TMPL:cross_domain_lambda_policies
901
+ VpcConfig:
902
+ #{if shared
903
+ "SubnetIds: [!Ref OwningSubnetAId, !Ref OwningSubnetBId]\n SecurityGroupIds: [!Ref OwningSecurityGroupId]"
904
+ else
905
+ "SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]\n SecurityGroupIds: [!Ref #{logical_id}SecurityGroup]"
906
+ end}
907
+ # AWS_IAM by default — this dispatches real domain commands
908
+ # (cap-table/governance-shaped data, for Embryonaut's own use);
909
+ # an unauthenticated public URL is the wrong default for that,
910
+ # even though it's the simpler one to demo with. NONE only when
911
+ # `web "Rust"` (rust/host/src/web.rs) makes this the public web
912
+ # UI itself — safe even though the SAME Function URL also
913
+ # carries the internal {"verb"}/{"read"} dispatch shapes,
914
+ # because a Function-URL HTTP event can never be crafted into
915
+ # that raw top-level shape (Function URLs always wrap the real
916
+ # HTTP body inside requestContext.http/rawPath/event.body,
917
+ # unconditionally); that raw shape is reachable only through
918
+ # the AWS SDK's own IAM-authenticated Invoke API, which never
919
+ # goes through a Function URL at all.
920
+ FunctionUrlConfig:
921
+ AuthType: #{rust_web ? "NONE" : "AWS_IAM"}
922
+ #{web_handler_present ? <<~WEB.each_line.map { |l| " " + l }.join : ""}
923
+ # THE WEB APP'S OWN LAMBDA — opt-in, see `web_handler_present` above.
924
+ # VPC-attached for the SAME reason #{logical_id} is (Member's own
925
+ # persistence needs the private RDS instance directly, permanently —
926
+ # embryonaut.hecksagon's own comment on why), sharing
927
+ # #{logical_id}SecurityGroup rather than minting a second one, since
928
+ # both need the identical egress-to-DB rule. That VPC attachment
929
+ # ALSO cuts off the public internet by default (this stack's own "NO
930
+ # NAT GATEWAY" design) — but this function has to reach
931
+ # #{logical_id} itself via `lambda:InvokeFunction`, a public AWS API
932
+ # call. #{web_logical_id}LambdaEndpoint below is the targeted fix: a
933
+ # VPC Interface Endpoint for the Lambda service ONLY (not a NAT
934
+ # Gateway) — a few dollars a month, not a general internet route.
935
+ #{db_id}LambdaEndpointSecurityGroup:
936
+ Type: AWS::EC2::SecurityGroup
937
+ Properties:
938
+ VpcId: !Ref #{db_id}Vpc
939
+ # EC2's own GroupDescription character set excludes apostrophes
940
+ # too, not just em-dashes -- "this stack's own" was a real,
941
+ # live rejection ("Invalid security group description").
942
+ GroupDescription: #{db_id}LambdaEndpoint - HTTPS from this stacks own Lambdas only
943
+
944
+ #{db_id}LambdaEndpointIngress:
945
+ Type: AWS::EC2::SecurityGroupIngress
946
+ Properties:
947
+ GroupId: !Ref #{db_id}LambdaEndpointSecurityGroup
948
+ IpProtocol: tcp
949
+ FromPort: 443
950
+ ToPort: 443
951
+ SourceSecurityGroupId: !Ref #{logical_id}SecurityGroup
952
+
953
+ #{db_id}LambdaEndpoint:
954
+ Type: AWS::EC2::VPCEndpoint
955
+ Properties:
956
+ VpcId: !Ref #{db_id}Vpc
957
+ ServiceName: !Sub "com.amazonaws.${AWS::Region}.lambda"
958
+ VpcEndpointType: Interface
959
+ PrivateDnsEnabled: true
960
+ SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]
961
+ SecurityGroupIds: [!Ref #{db_id}LambdaEndpointSecurityGroup]
962
+
963
+ # Auto-generated, never typed or seen — the SAME
964
+ # ManageMasterUserPassword pattern #{db_id} already uses for its own
965
+ # password, applied to Sinatra's own session-signing secret instead.
966
+ #{web_logical_id}SessionSecret:
967
+ Type: AWS::SecretsManager::Secret
968
+ Properties:
969
+ GenerateSecretString:
970
+ SecretStringTemplate: '{}'
971
+ GenerateStringKey: session_secret
972
+ PasswordLength: 64
973
+ ExcludePunctuation: true
974
+
975
+ #{webhook_secret_env ? <<-WEBHOOKSECRET : ""}
976
+ # Auto-generated, never typed or seen — the SAME
977
+ # ManageMasterUserPassword/SessionSecret pattern above, applied to
978
+ # `secret_env`'s own webhook secret instead (deploy_settings' own
979
+ # `secret_env`, read far above). #{web_logical_id}'s own
980
+ # `lambda_handler.rb` fetches this by ARN at cold start (the
981
+ # `#{webhook_secret_env}_ARN` Environment variable below) and exposes
982
+ # its plaintext under `ENV["#{webhook_secret_env}"]` itself — the
983
+ # SAME two-step indirection DB_SECRET_ARN/SESSION_SECRET_ARN already
984
+ # use, and for the identical reason (this Environment block's own
985
+ # header comment on why a resolved plaintext must never land in
986
+ # Lambda's own configuration).
987
+ #{web_logical_id}WebhookSecret:
988
+ Type: AWS::SecretsManager::Secret
989
+ Properties:
990
+ GenerateSecretString:
991
+ SecretStringTemplate: '{}'
992
+ GenerateStringKey: value
993
+ PasswordLength: 64
994
+ ExcludePunctuation: true
995
+
996
+ WEBHOOKSECRET
997
+ #{web_logical_id}:
998
+ Type: AWS::Serverless::Function
999
+ # NO Metadata.BuildMethod — "bundler" isn't a real SAM value (a
1000
+ # real, live UnsupportedBuilderException caught this); `Runtime:
1001
+ # ruby3.2` alone is enough for SAM to select its own built-in Ruby
1002
+ # bundler workflow automatically. #{logical_id}'s own `BuildMethod:
1003
+ # makefile` above is different: rust/host isn't a runtime SAM
1004
+ # knows how to build at all, so it needs an explicit custom
1005
+ # workflow — this function doesn't. STAYS `ruby3.2` deliberately,
1006
+ # even though `sam validate --lint` now flags it deprecated
1007
+ # (2026-03-31, creation disabled 2027-02-01, still comfortably
1008
+ # ahead of today) — `patch-pg-native`'s own build-ruby3.2 container
1009
+ # image and `lib/3.2/pg_ext.so` path glob (this Makefile, below)
1010
+ # are BOTH hardcoded to this exact runtime; bumping Runtime alone
1011
+ # without reworking that whole from-source pg build for a newer
1012
+ # image is a real, separate undertaking (untested container image
1013
+ # availability, a different vendor/bundle native-extension path)
1014
+ # out of scope here. Matches Embryonaut's own live, currently-
1015
+ # deployed choice exactly, so both consumers of this one shared
1016
+ # WebFunction code path stay on the same, PROVEN runtime.
1017
+ Properties:
1018
+ FunctionName: #{stack_name}-web
1019
+ Runtime: ruby3.2
1020
+ Architectures: [arm64]
1021
+ # THREE parts (file.Class.method), not two — aws-lambda-ric's own
1022
+ # 2-part form calls a plain TOP-LEVEL function via __send__, not
1023
+ # a module method; #{web_handler_relpath}.rb's own comment on
1024
+ # why its module is named the way it is explains the rest — a
1025
+ # real, live "LambdaHandler is not a module" caught both halves
1026
+ # of this the hard way. `web_handler_relpath` (not a bare
1027
+ # "lambda_handler") whenever `dispatch "None"` moved CodeUri off
1028
+ # #{domain} itself — see that variable's own comment, above.
1029
+ Handler: #{web_handler_relpath}.#{webhook_handler_module}.lambda_handler
1030
+ CodeUri: #{web_code_uri}
1031
+ MemorySize: 512
1032
+ Timeout: 15
1033
+ Environment:
1034
+ Variables:
1035
+ DOMAIN_ROOT: /var/task
1036
+ # NOT `declared_domain_name` — `Adapters::Lambda`/
1037
+ # `RemoteDispatcher` both read this to compute WHICH
1038
+ # deployed Lambda function to invoke (`hecks-\#{...}`,
1039
+ # matching this stack's own name), a question `infra_name`
1040
+ # answers and a domain's own business identity does not.
1041
+ # Real, live, and confirmed the hard way: this was still
1042
+ # wired to `declared_domain_name` when EmbryonautFoundersApp
1043
+ # first deployed, and WebFunction immediately threw
1044
+ # `ResourceNotFoundException: Function not found:
1045
+ # hecks-embryonautfoundersapp` on every read AND on the
1046
+ # Google OAuth callback itself — a real signed-in user hit
1047
+ # this within minutes of the deploy that introduced it.
1048
+ DOMAIN_NAME: #{infra_name}
1049
+ HECKS_LAMBDA_ROUTING: "true"
1050
+ HECKS_LAMBDA_REGION: #{region}#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}
1051
+ # SAME setting #{domain}'s own fly.toml/Dockerfile already
1052
+ # sets for the Fly deployment this one replaces — Sinatra's
1053
+ # own `host_authorization` default is only PERMISSIVE in
1054
+ # production mode (an empty `permitted_hosts` list means
1055
+ # "allow any host" — checked directly in rack-protection's
1056
+ # own source); its DEVELOPMENT default is the restrictive
1057
+ # one (only localhost/.test), which is what Sinatra falls
1058
+ # back to when NEITHER RACK_ENV nor APP_ENV is set. A real,
1059
+ # live "Host not permitted" 403 against this function's own
1060
+ # unpredictable *.lambda-url.*.on.aws hostname caught the
1061
+ # gap — this function's Environment never set either var.
1062
+ RACK_ENV: production
1063
+ # The precompiled `pg` gem's aarch64-linux native extension
1064
+ # needs GLIBC 2.29+; Lambda's ruby3.2 MANAGED runtime is
1065
+ # Amazon Linux 2 (glibc 2.26) -- a real, live "Init<NameError>:
1066
+ # uninitialized constant PG::Error" caught this (rescuing
1067
+ # PG::Error itself failed to resolve, because pg's own
1068
+ # require died before reaching the file that defines it).
1069
+ # `make deploy`'s own `patch-pg-native` step (this directory's
1070
+ # Makefile) replaces the broken precompiled extension with one
1071
+ # compiled from source, in-container, against a real
1072
+ # SSL-enabled libpq -- and drops that libpq's .so beside it at
1073
+ # /var/task/lib, found by the dynamic loader with NO explicit
1074
+ # LD_LIBRARY_PATH override needed: the base ruby3.2 image's
1075
+ # own baked-in default (`docker inspect
1076
+ # public.ecr.aws/lambda/ruby:3.2-arm64`) is already
1077
+ # "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib"
1078
+ # -- setting LD_LIBRARY_PATH here as a Lambda env var
1079
+ # REPLACES that whole list rather than extending it (a real,
1080
+ # live gotcha: Lambda env vars always fully override a
1081
+ # same-named baked-in image env, never merge with it), which
1082
+ # is exactly what broke Ruby's OWN interpreter startup
1083
+ # ("libcrypt.so.1: cannot open shared object file") the one
1084
+ # time this was set explicitly, before libpq.so.5.16 itself
1085
+ # ever got a chance to matter.
1086
+ # Member's own permanent Postgres connection — the SAME RDS
1087
+ # instance #{logical_id} itself writes rust/host's flat
1088
+ # journal into, different database engine role (Ruby's own
1089
+ # era/lineage schema, not rust/host's), never the real
1090
+ # Fly-hosted Postgres this whole effort is retiring away from.
1091
+ #
1092
+ # GENERATOR-ONLY FIX, NOT YET SAFE TO DEPLOY — DB_HOST/DB_NAME/
1093
+ # DB_SECRET_ARN/SESSION_SECRET_ARN/GOOGLE_OAUTH_SECRET_ID below
1094
+ # replace the SAME `{{resolve:secretsmanager:...}}`-into-
1095
+ # Environment.Variables shape rust/host's own main.rs (this
1096
+ # template's own #{logical_id}) used to have -- readable in
1097
+ # plaintext by any read-only account principal via
1098
+ # lambda:GetFunctionConfiguration, the exact exposure
1099
+ # #{logical_id}'s own comment above the Policies section
1100
+ # documents. But THIS function's consumer (`lambda_handler.rb`)
1101
+ # is not a file this generator writes or this repo carries --
1102
+ # it lives in the deploying app's own domain directory (real,
1103
+ # live example: Embryonaut's own Sinatra app), outside this
1104
+ # codebase entirely. Redeploying a domain with an existing
1105
+ # `lambda_handler.rb` BEFORE that handler is updated to fetch
1106
+ # these itself (`aws-sdk-secretsmanager`, not yet in this
1107
+ # project's own Gemfile) breaks it: it still expects
1108
+ # `ENV["DATABASE_URL"]`/`ENV["SESSION_SECRET"]`/
1109
+ # `ENV["GOOGLE_CLIENT_ID"]`/`ENV["GOOGLE_CLIENT_SECRET"]`
1110
+ # already resolved, and none of those keys exist below anymore.
1111
+ DB_HOST: !GetAtt #{db_ref_id}.Endpoint.Address
1112
+ DB_NAME: #{db_name}
1113
+ DB_SECRET_ARN: !Sub "${#{secret_sub}}"
1114
+ SESSION_SECRET_ARN: !Sub "${#{web_logical_id}SessionSecret}"#{webhook_secret_env ? %(\n #{webhook_secret_env}_ARN: !Sub "${#{web_logical_id}WebhookSecret}") : ""}
1115
+ #{web_google_oauth_env_yaml}
1116
+ VpcConfig:
1117
+ SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]
1118
+ SecurityGroupIds: [!Ref #{logical_id}SecurityGroup]
1119
+ # LEAST-PRIVILEGE, scoped to #{logical_id}'s own ARN specifically
1120
+ # (SAM's own LambdaInvokePolicy template) — WebFunctionRole is
1121
+ # otherwise SAM's bare default execution role (logging only), and
1122
+ # had NO lambda:InvokeFunction permission at all until this: a
1123
+ # real, live AccessDeniedException caught it, the same request
1124
+ # that also caught the wrong-function-name bug just above
1125
+ # (WebFunctionRole is not authorized... on resource
1126
+ # hecks-task — this stack has never had ANY policy granting
1127
+ # that action, so the fix isn't complete without adding one).
1128
+ # THE SAME secretsmanager:GetSecretValue GRANTS #{logical_id}'s
1129
+ # own Policies section (above) needed, once this function's own
1130
+ # `lambda_handler.rb` fetches DB_SECRET_ARN/SESSION_SECRET_ARN/
1131
+ # GOOGLE_OAUTH_SECRET_ID itself instead of relying on a resolved
1132
+ # Environment.Variables value -- see this Environment block's own
1133
+ # comment for why that consumer-side change isn't in this repo.
1134
+ Policies:
1135
+ - LambdaInvokePolicy:
1136
+ FunctionName: !Ref #{logical_id}
1137
+ - Statement:
1138
+ - Effect: Allow
1139
+ Action: secretsmanager:GetSecretValue
1140
+ Resource: !Sub "${#{secret_sub}}"
1141
+ - Statement:
1142
+ - Effect: Allow
1143
+ Action: secretsmanager:GetSecretValue
1144
+ Resource: !Sub "${#{web_logical_id}SessionSecret}"#{webhook_secret_env ? %(\n - Statement:\n - Effect: Allow\n Action: secretsmanager:GetSecretValue\n Resource: !Sub "${#{web_logical_id}WebhookSecret}") : ""}
1145
+ #{google_oauth_present ? <<~WEBOAUTHPOLICY.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join.rstrip : ""}
1146
+ - Statement:
1147
+ - Effect: Allow
1148
+ Action: secretsmanager:GetSecretValue
1149
+ Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
1150
+ WEBOAUTHPOLICY
1151
+ # NONE, not AWS_IAM — real users need to reach sign-in over
1152
+ # plain HTTPS, unlike #{logical_id}'s own AWS_IAM-protected URL
1153
+ # (this function is the ONLY thing meant to call that one).
1154
+ FunctionUrlConfig:
1155
+ AuthType: NONE
1156
+ WEB
1157
+
1158
+ Outputs:
1159
+ FunctionUrl:
1160
+ Value: !GetAtt #{logical_id}Url.FunctionUrl#{web_handler_present ? "\n WebFunctionUrl:\n Value: !GetAtt #{web_logical_id}Url.FunctionUrl" : ""}
1161
+ # stack_outputs (bin/project_deploy, above the heredocs) is the ONE
1162
+ # place these four names live — DatabaseEndpoint/DatabaseSecretArn
1163
+ # feed `make mint-era`'s own boot step directly; VpcId/
1164
+ # DbSecurityGroupId ALSO feed bastion.yaml's own Parameters (a
1165
+ # separate, sibling stack, created only for the few minutes a mint
1166
+ # takes and destroyed right after, attaching into the SAME private
1167
+ # network without this main template ever knowing a bastion
1168
+ # exists) and the Makefile's own --parameter-overrides. Rename a
1169
+ # key in that table and every consumer follows; reference one that
1170
+ # doesn't exist there and bin/project_deploy refuses before
1171
+ # writing a single file, instead of a bastion.yaml whose Parameter
1172
+ # nothing could ever fill. (`PrivateSubnetId` used to sit here too
1173
+ # — dropped, nothing ever consumed it; bastion.yaml mints its own
1174
+ # subnet instead.)
1175
+ #{stack_outputs.map { |o| "#{o[:key]}:\n Value: #{o[:ref]}" }.join("\n ")}
1176
+ YAML
1177
+
1178
+ # Spliced in after the heredoc renders, not interpolated inside it —
1179
+ # found live, the hard way: `<<~`'s own dedent strips whatever the
1180
+ # shallowest leading-whitespace line in the raw source text has, computed
1181
+ # before any `#{...}` interpolation runs. `cross_domain_lambda_policies`
1182
+ # is "" for the overwhelmingly common case (no cross-domain policies at
1183
+ # all), and a `#{...}` marker written at column 0 in the source (so it
1184
+ # renders flush when empty) is itself a zero-indent line by that same
1185
+ # raw-text measure — dragging the whole heredoc's computed dedent down to
1186
+ # zero and leaving every other line's original leading whitespace
1187
+ # un-stripped, a real, generated-then-caught-by-spec bug (`bin/
1188
+ # project_deploy`'s own contract spec: "Outputs only declares []" — not
1189
+ # actually empty, just re-indented into structural nonsense by this).
1190
+ # A plain, unconditional `String#sub` after the fact has no such
1191
+ # interaction with the text it's replacing into.
1192
+ template_yaml = template_yaml.sub(/^([ \t]*)# TMPL:cross_domain_lambda_policies\n/) { Shared.cross_domain_invoke_policy_yaml(cross_domain_lambda_targets, $1) }
1193
+
1194
+ # **The `PII` → CloudFront splice** — operates on the already-fully-rendered
1195
+ # string, the same reason the `dispatch_none` splice (below) does rather
1196
+ # than threading a third reindentation layer through the main heredoc
1197
+ # above: `pii_cloudfront_yaml` builds its own already-correctly-indented
1198
+ # text from scratch (its own `reindent` lambda), so there is nothing here
1199
+ # for a heredoc dedent computation to interact with badly.
1200
+ # `fronted_logical_id`/`use_oac` are resolved here, not earlier, because
1201
+ # both need `web_handler_present`/`web_logical_id`/`rust_web`/`logical_id`
1202
+ # — every one of which is computed after this generator's own pii
1203
+ # detection, above.
1204
+ if pii_detected
1205
+ fronted_logical_id = web_handler_present ? web_logical_id : logical_id
1206
+ use_oac = !web_handler_present && !rust_web
1207
+
1208
+ pii_resources = pii_cloudfront_yaml(
1209
+ fronted_logical_id: fronted_logical_id, use_oac: use_oac,
1210
+ geo_restriction_type: geo_restriction_type, geo_restriction_countries: geo_restriction_countries
1211
+ )
1212
+ template_yaml = template_yaml.sub(/^Outputs:\n/) { "#{pii_resources}Outputs:\n PiiDistributionDomainName:\n Value: !GetAtt PiiDistribution.DomainName\n" }
1213
+ end
1214
+
1215
+ # `dispatch "None"` — surgical removal, POST-render, rather than a
1216
+ # fourth reindentation layer threaded through the heredoc above.
1217
+ # Tried that first: wrapping `#{logical_id}`'s own resource block in a
1218
+ # conditional nested heredoc (matching `RUSTSECRET`/`WEB`/`OWNDB`'s own
1219
+ # established `<<~TAG.each_line.with_index.map { ... }` convention)
1220
+ # looked right, but broke several pre-existing embedded multi-line
1221
+ # `#{shared ? "a\nb" : "c"}`-shaped strings already living inside that
1222
+ # block (the DB_HOST/DB_NAME/DB_SECRET_ARN Environment lines among
1223
+ # them) — those assume they sit at exactly one reindentation layer deep
1224
+ # (the outer template heredoc's own margin), and adding a second layer
1225
+ # on top shifted every line their own hardcoded embedded-newline
1226
+ # indentation didn't already account for. Confirmed live, diffing a
1227
+ # real regenerated examples/banking (`dispatch` unset — the ordinary,
1228
+ # far more common path) against its own git-tracked output: real
1229
+ # indentation corruption, not a false alarm. Operating on the already-
1230
+ # fully-rendered, already-correctly-indented string instead sidesteps
1231
+ # that whole class of interaction — no new heredoc layer, so nothing
1232
+ # already living inside the old one has to change its own assumptions.
1233
+ if dispatch_none
1234
+ # The #{logical_id} resource itself — non-greedy through its own
1235
+ # `FunctionUrlConfig`/`AuthType` closing pair (the actual last
1236
+ # property this resource ever renders, confirmed above), so a second
1237
+ # occurrence of "AuthType:" later in the document (WebFunction's own,
1238
+ # always AuthType: `NONE`) can never be matched instead.
1239
+ template_yaml = template_yaml.sub(
1240
+ /^ #{Regexp.escape(logical_id)}:\n.*?\n AuthType: (?:NONE|AWS_IAM)\n/m, ""
1241
+ )
1242
+ # WebFunction's own Policies — `LambdaInvokePolicy: FunctionName: !Ref
1243
+ # #{logical_id}` grants invoking a function that, above, was just
1244
+ # removed from this template entirely; SAM refuses a `!Ref` to an
1245
+ # undeclared resource at package time, not silently.
1246
+ template_yaml = template_yaml.sub(
1247
+ /^ - LambdaInvokePolicy:\n FunctionName: !Ref #{Regexp.escape(logical_id)}\n/, ""
1248
+ )
1249
+ # Outputs — no `#{logical_id}Url` exists any more for `FunctionUrl` to
1250
+ # `!GetAtt`; WebFunction's own URL becomes the stack's one and only
1251
+ # "FunctionUrl" (never "WebFunctionUrl" — that name is reserved for
1252
+ # the two-URL case, a domain that also has a #{logical_id} of its
1253
+ # own to disambiguate from, which this one, by construction, never
1254
+ # does).
1255
+ template_yaml = template_yaml.sub(
1256
+ /^ FunctionUrl:\n Value: !GetAtt #{Regexp.escape(logical_id)}Url\.FunctionUrl\n WebFunctionUrl:\n Value: !GetAtt #{Regexp.escape(web_logical_id)}Url\.FunctionUrl\n/,
1257
+ " FunctionUrl:\n Value: !GetAtt #{web_logical_id}Url.FunctionUrl\n"
1258
+ )
1259
+ end
1260
+
1261
+
1262
+
1263
+ # **The ephemeral era-minting bastion** — a separate, sibling stack
1264
+ # (`#{stack_name}-bastion`), never merged into template.yaml itself.
1265
+ # The main stack stays exactly as minimal as its own header already
1266
+ # commits to (no NAT gateway, no bastion sitting there costing money
1267
+ # by default) — this template only ever exists for the few minutes
1268
+ # `make mint-era` (below) needs it, then gets deleted. SSM Session
1269
+ # Manager only, never SSH: no key pair, no inbound security group rule
1270
+ # at all (the instance's own SG declares zero Ingress) — the only way
1271
+ # in is `aws ssm start-session`, itself gated by the caller's own IAM
1272
+ # permissions, nothing this template opens to the internet.
1273
+ # Skipped entirely when `shared` — this domain provisions no RDS/VPC
1274
+ # of its own for a bastion to reach (bastion_parameters is empty for
1275
+ # the same reason, above); a bare `!Ref VpcId` with no declared
1276
+ # Parameter would just fail at deploy time. Era-minting for a
1277
+ # Shared-mode domain reuses its owner's own already-standing
1278
+ # infrastructure instead — see the Makefile's own `mint-era` comment
1279
+ # for the manual path this leaves until that's automated too.
1280
+ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1281
+ domain: domain, infra_name: infra_name, stack_name: stack_name, db_id: db_id,
1282
+ google_oauth_present: google_oauth_present, bastion_parameters: bastion_parameters
1283
+ )
1284
+
1285
+ # `mint-era`'s own recipe body — a top-level variable, not inlined at
1286
+ # its call site inside the Makefile heredoc below, on purpose: Make
1287
+ # recipe lines need a literal tab as their true first character (no
1288
+ # leading spaces at all), and nesting this heredoc a second level
1289
+ # deeper inside another `#{...}` interpolation (the way `OWNDB`/params
1290
+ # above handle conditional template.yaml content) would need its own
1291
+ # per-line re-indent pass — one that adds spaces before every line
1292
+ # including the already-tab-prefixed recipe ones, corrupting Make's
1293
+ # own recipe-line detection. A single top-level heredoc, each line
1294
+ # already at its own final indentation (recipe lines as " \t..." —
1295
+ # the same two-space-before-the-tab convention every other recipe
1296
+ # line in this Makefile already uses, stripped by the encolosing
1297
+ # `<<~MAKE` heredoc's own squiggly stripping below, same as them),
1298
+ # sidesteps that: nothing re-touches an already-correct line twice.
1299
+ mint_era_recipe =
1300
+ if shared
1301
+ <<~SHAREDMINT.rstrip
1302
+ # NOT AUTOMATED YET for a Shared-mode domain — it has no RDS/VPC
1303
+ # of its own to stand a temporary bastion next to (bastion.yaml
1304
+ # itself isn't even generated here — bin/project_deploy's own
1305
+ # comment on why). Minting era 1 for #{infra_name}'s own schema
1306
+ # needs a tunnel to #{owner_domain_name}'s ALREADY-EXISTING RDS
1307
+ # instance instead -- reuse #{owner_domain_name}'s own deploy
1308
+ # directory's `make mint-era` machinery (or an already-open
1309
+ # tunnel to it) to run this domain's own boot against
1310
+ # `postgres://...@<tunnel-host>:<port>/#{owner_db_name}` with
1311
+ # `schema: #{infra_name.inspect}` in LineageManager.check!'s own
1312
+ # settings -- the exact same call this target runs automatically
1313
+ # for a domain with its own dedicated instance.
1314
+ #
1315
+ # IF THIS DOMAIN VENDORS/ATTACHES ANOTHER BLUEBOOK (uses_embryonaut_
1316
+ # bluebook, uses_framework), `check!` alone leaves THAT chapter's own
1317
+ # aggregates with no snapshot table at all -- it only provisions the
1318
+ # ONE bluebook it's called with (era_resolver.rb's own `bluebook.
1319
+ # aggregates.each`), never the whole registry. Found live minting
1320
+ # lifeadelics' own era: `Payments::Payment.Initiate` refused with
1321
+ # "relation \\"payment_head_snapshot_1\\" does not exist" the first
1322
+ # time this domain's own checkout route ran for real. The automated
1323
+ # (non-Shared) recipe below now does this correctly for every OTHER
1324
+ # loaded bluebook, using ONE `Lineage` keyed by THIS domain's own
1325
+ # name (never each chapter's own -- a vendored chapter's aggregates
1326
+ # share THIS domain's single `hecks_journal_#{infra_name}` and
1327
+ # snapshot tables, they do not get a separate journal of their own).
1328
+ # Reproduce the same shape manually here: after `LineageManager.
1329
+ # check!` returns, `db = PostgresEra.connect_for(bluebook.name,
1330
+ # settings)`, `lineage = Lineage.new(db, bluebook.name)`, then for
1331
+ # every OTHER loaded bluebook's own aggregates, `lineage.
1332
+ # ensure_first_head!(aggregate.storage_name)`.
1333
+ #
1334
+ # EXIT 0, NOT 1 -- `deploy:`'s own last line (below) always chains
1335
+ # `$(MAKE) mint-era` unconditionally, even for a Shared-mode domain,
1336
+ # so a nonzero exit here used to mean a fully successful `sam
1337
+ # deploy` still left `make deploy` exiting nonzero -- confirmed
1338
+ # live: CI and any scripted caller read every Shared-mode deploy as
1339
+ # failed, which trains operators to ignore a red `make deploy` on
1340
+ # exactly the domains where a real failure most needs to stand out.
1341
+ # This step never fails at its own job (it has no automated job to
1342
+ # fail at -- it only reports that a manual step remains), so it
1343
+ # reports success and leaves the manual-step reminder in the echo
1344
+ # text above, not in the exit code. A human running `make mint-era`
1345
+ # directly still SEES the same message; they just don't get a
1346
+ # misleading "command failed" on top of it either.
1347
+ \t@echo "mint-era isn't automated yet for a Shared-mode domain (database \\"Shared\\") -- see this target's own comment in the generated Makefile for the manual path through #{owner_domain_name}'s own tunnel. This is NOT a failure -- exiting 0 so a genuinely successful \\"make deploy\\" still reports success; era-minting for this domain remains a separate manual step."; \\
1348
+ \texit 0
1349
+ SHAREDMINT
1350
+ else
1351
+ <<~OWNMINT.rstrip
1352
+ \t@echo "Looking up $(STACK)'s VPC/security group..."
1353
+ # stack_outputs/bastion_parameters (bin/project_deploy) are the ONE
1354
+ # place these OutputKey strings and parameter names live — this eval
1355
+ # chain and the --parameter-overrides line just below are both
1356
+ # generated from the SAME table template.yaml's Outputs and
1357
+ # bastion.yaml's Parameters already read from.
1358
+ \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")}
1359
+ \t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
1360
+ \taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
1361
+ \t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
1362
+ \t\t--capabilities CAPABILITY_IAM
1363
+ # ONE continuous shell invocation from here on (every line ends in
1364
+ # \\, joining it to the next) — INSTANCE_ID is a SHELL variable, not
1365
+ # a Make one: a Make-level $$(eval $$(shell ...)) expands at parse
1366
+ # time, BEFORE the `aws cloudformation deploy` line above ever runs,
1367
+ # which would make INSTANCE_ID permanently empty. It can only be
1368
+ # computed here, after the bastion stack genuinely exists.
1369
+ #
1370
+ # A COMMENT LINE MUST NEVER SIT BETWEEN TWO \\-CONTINUED RECIPE
1371
+ # LINES — confirmed live, the hard way: a `#`-prefixed line inserted
1372
+ # between `DB_PASS=...; \\` and the line that used it silently
1373
+ # corrupted Make's own assembly of this whole block into one shell
1374
+ # script, and `DB_PASS` came out empty on the far side despite
1375
+ # printing correctly one line earlier. Every explanatory comment for
1376
+ # this whole chain belongs HERE, before it starts, same as this one —
1377
+ # never spliced into the middle of it.
1378
+ #
1379
+ # BASTION TEARDOWN IS PART OF THIS SAME CHAIN, ALL THE WAY THROUGH —
1380
+ # a prior version of this recipe ended at `exit $$BOOT_STATUS` with
1381
+ # no trailing `\\`, which made Make treat the delete-stack lines
1382
+ # after it as a SEPARATE recipe line, reached only on a zero exit.
1383
+ # A failed boot (bad tunnel, bad `ruby -e` invocation, anything) left
1384
+ # the bastion stack standing forever — including a live 5432 ingress
1385
+ # rule punched into the PRODUCTION RDS security group. Teardown now
1386
+ # runs unconditionally, with BOOT_STATUS captured up front and
1387
+ # returned last, so `make mint-era`'s own exit code still reflects
1388
+ # whether era 1 actually got minted.
1389
+ #
1390
+ # `LineageManager.check!` directly below, NOT `Hecks.boot` — also
1391
+ # confirmed live (the very first real invoke: "relation
1392
+ # \\"hecks_eras\\" does not exist", despite this step itself
1393
+ # reporting success): `Hecks.boot` always dispatches persistence
1394
+ # through whatever the domain's OWN .world file declares
1395
+ # (`persisted_by("Heki")` for a domain that hasn't opted into
1396
+ # Postgres for local dev), by EXPLICIT design — loader.rb's own
1397
+ # comment: "persisted_by(\\"PostgresEra\\") is never inferred from
1398
+ # anything else." `EraCheck.check!` (what `Hecks.boot` runs) is
1399
+ # itself adapter-capability-gated on that same declaration and simply
1400
+ # no-ops for a non-lineage-capable adapter — it never touches
1401
+ # hecks_eras at all. The fix reuses the SAME loading pipeline
1402
+ # `Loader.boot` itself does (`Ports::Loading.bootstrap`/
1403
+ # `load_library`/`load_project`/`load_domain`) to build a real,
1404
+ # fully-loaded registry (bluebook + any uses_framework attachments),
1405
+ # then calls straight into PostgresEra's own `LineageManager.check!`
1406
+ # with THIS deploy's actual `DATABASE_URL` — bypassing the world
1407
+ # file's own adapter choice entirely, the same targeted pattern
1408
+ # spec/adapters/driven/postgres_era/lineage_spec.rb's own `check!` helper
1409
+ # already uses.
1410
+ #
1411
+ # THE WHOLE TUNNEL IS RESTARTED PER ATTEMPT, not just the boot
1412
+ # check retried through one long-lived tunnel — confirmed live,
1413
+ # the hard way: an SSM port-forwarding session can start and then
1414
+ # exit again within the first few seconds (visible in its own log
1415
+ # as a session ID that both starts AND exits before the retries
1416
+ # even finish), not merely "slow to become ready". Retrying a
1417
+ # Postgres connection five times against a tunnel process that has
1418
+ # already died is retrying against a corpse — it fails identically
1419
+ # every time, for a different reason than the one the retry was
1420
+ # built for. Each attempt below kills whatever tunnel the LAST
1421
+ # attempt opened (a no-op the first time) and opens a fresh one,
1422
+ # so a dead session gets a real replacement, not just more patience.
1423
+ #
1424
+ # DB_PASS_URLENC, DERIVED RIGHT AFTER DB_PASS BELOW, IS WHAT ACTUALLY
1425
+ # GOES INTO THE `postgres://` URL FURTHER DOWN — not DB_PASS itself.
1426
+ # RDS/Secrets-Manager-generated passwords are not guaranteed free of
1427
+ # `%`, and libpq's own connection-URI parser treats a bare `%` as an
1428
+ # invalid token (percent-encoding is the one escape libpq's URI form
1429
+ # actually documents and decodes; a raw `%` is not) -- roughly 29% of
1430
+ # 32-char generated passwords contain at least one `%`, so this was
1431
+ # silently unreachable until it wasn't. `ERB::Util.url_encode`
1432
+ # percent-encodes everything outside RFC 3986's unreserved set,
1433
+ # exactly what a URI password component needs -- a strict superset
1434
+ # of the ExcludeCharacters the Aurora path below already excludes
1435
+ # (a no-op there), and the actual fix for the plain-RDS
1436
+ # ManageMasterUserPassword path, whose generated character set this
1437
+ # template has no control over at all. This comment sits HERE,
1438
+ # before the chain starts, on purpose -- see the note further below
1439
+ # on why a comment line can never be spliced between two
1440
+ # `\`-continued recipe lines.
1441
+ \t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
1442
+ \techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
1443
+ \tfor i in $$(seq 1 30); do \\
1444
+ \t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
1445
+ \t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
1446
+ \t\tsleep 5; \\
1447
+ \tdone; \\
1448
+ \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"]'); \\
1449
+ \tDB_PASS_URLENC=$$(ruby -rerb -e 'print ERB::Util.url_encode(ARGV[0])' "$$DB_PASS"); \\
1450
+ \tBOOT_STATUS=1; \\
1451
+ \tfor attempt in 1 2 3 4 5; do \\
1452
+ \t\tkill $$TUNNEL_PID 2>/dev/null; \\
1453
+ \t\techo "Opening an SSM tunnel to $(DB_HOST):5432 and minting era 1 (attempt $$attempt/5)..."; \\
1454
+ \t\taws ssm start-session --target $$INSTANCE_ID \\
1455
+ \t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
1456
+ \t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
1457
+ \t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
1458
+ \t\tTUNNEL_PID=$$!; \\
1459
+ \t\tfor i in $$(seq 1 15); do \\
1460
+ \t\t\tnc -z localhost 15432 2>/dev/null && break; \\
1461
+ \t\t\tsleep 1; \\
1462
+ \t\tdone; \\
1463
+ \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; }; \\
1464
+ \t\tBOOT_STATUS=$$?; \\
1465
+ \t\techo "boot check attempt $$attempt/5 failed (exit $$BOOT_STATUS) -- restarting the tunnel and retrying in 3s..."; \\
1466
+ \t\tsleep 3; \\
1467
+ \tdone; \\
1468
+ \tkill $$TUNNEL_PID 2>/dev/null; \\
1469
+ \techo "Tearing down the temporary bastion stack..."; \\
1470
+ \taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
1471
+ \taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
1472
+ \techo "$(BASTION_STACK) deleted. Era 1 should now be held if BOOT_STATUS was 0 — verify with bin/console or a Postgres query against hecks_eras."; \\
1473
+ \texit $$BOOT_STATUS
1474
+ OWNMINT
1475
+ end
1476
+
1477
+ # `scaffold-translation`/`translation-audit` — the two-step fix
1478
+ # minter.rb's own refusal names ("run bin/scaffold_translation to write
1479
+ # the edge, check it with bin/translation_audit, then boot again") when
1480
+ # a deploy's own pre-flight boot check refuses a shape change with no
1481
+ # translation edge covering it. Same bastion/tunnel/retry/teardown
1482
+ # chain mint_era_recipe already uses — only the one thing done over the
1483
+ # tunnel differs: these run the scaffold/audit scripts (hecks's
1484
+ # own bin/, not a Ruby -e one-liner) instead of a boot check, since
1485
+ # both scripts already do their own registry-loading internally.
1486
+ #
1487
+ # HECKS_SCHEMA/DATABASE_URL below are not actually consumed by those
1488
+ # scripts, despite reading as if they were: grep-confirmed, neither
1489
+ # `bin/scaffold_translation` nor `bin/translation_audit` nor
1490
+ # `Hecks::Bluebook::Behaviour::World#for_binding` nor
1491
+ # `PostgresEra.connect_for` ever reads `ENV["DATABASE_URL"]` or
1492
+ # `ENV["HECKS_SCHEMA"]` — both scripts call
1493
+ # `registry.world(bluebook.name)&.for_binding(...)`, a pure hash lookup
1494
+ # against whatever literal `database "..."` string #{domain}'s own
1495
+ # `.world` file declares. Without `db_env_blind:` below, this whole
1496
+ # recipe would stand up a real bastion, punch a live 5432 ingress rule
1497
+ # into production's security group, open a real SSM tunnel to
1498
+ # #{stack_name}'s RDS instance, tear it all down again — and then
1499
+ # silently scaffold/audit the developer's local dev database the
1500
+ # entire time, reporting success. `db_env_blind:`
1501
+ # (below) is exactly this: true for scaffold-translation/
1502
+ # translation-audit (confirmed env-blind), left false for
1503
+ # migrate-console-settings (an app-owned script this generator doesn't
1504
+ # control the internals of — it may honor these vars; not asserting
1505
+ # either way here). A `db_env_blind` recipe refuses before ever
1506
+ # touching AWS unless `ALLOW_LOCAL_DB=1` is set, rather than silently
1507
+ # doing the wrong (but locally successful-looking) thing.
1508
+ # `cwd:`/`run_prefix:` — every existing caller runs a hecks-owned
1509
+ # script from hecks's own $(root) with `-Ilib` (source, not the
1510
+ # installed gem); `migrate_console_settings_recipe` below is the one
1511
+ # exception, an app-owned script that needs the app's own Gemfile
1512
+ # context instead — `cd $(DOMAIN) && bundle exec ruby`, not `-Ilib`.
1513
+ translation_recipe = lambda do |verb, script, extra_args = "", cwd: "$(ROOT)", run_prefix: "ruby -Ilib", db_env_blind: false|
1514
+ if shared
1515
+ <<~SHAREDTRANSLATION.rstrip
1516
+ \t@echo "#{verb} isn't automated yet for a Shared-mode domain (database \\"Shared\\") -- see mint-era's own comment in the generated Makefile for the manual path through #{owner_domain_name}'s own tunnel."; \\
1517
+ \texit 1
1518
+ SHAREDTRANSLATION
1519
+ else
1520
+ <<~OWNTRANSLATION.rstrip
1521
+ #{db_env_blind ? <<~ENVBLINDGUARD.rstrip
1522
+ \t@if [ -z "$$ALLOW_LOCAL_DB" ]; then \\
1523
+ \t\techo "REFUSING: #{script} resolves its OWN database connection from #{domain}'s .world file, NOT from DATABASE_URL/HECKS_SCHEMA -- opening a real tunnel to #{stack_name}'s production RDS instance below would silently scaffold/audit the LOCAL dev database instead and report success (see this recipe's own comment, above, for the confirmed root cause). Set ALLOW_LOCAL_DB=1 to run #{verb} against #{domain}'s local .world-declared database on purpose (e.g. to exercise the scaffold/audit logic itself); otherwise run this for real against production via the manual tunnel path mint-era's own comment describes."; \\
1524
+ \t\texit 1; \\
1525
+ \tfi
1526
+ ENVBLINDGUARD
1527
+ : ""}
1528
+ \t@echo "Looking up $(STACK)'s VPC/security group..."
1529
+ \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")}
1530
+ \t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
1531
+ \taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
1532
+ \t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
1533
+ \t\t--capabilities CAPABILITY_IAM
1534
+ # DB_PASS_URLENC -- same percent-encoding fix as mint_era_recipe's
1535
+ # own comment above the equivalent line in that recipe (libpq's URI
1536
+ # parser rejects a bare `%`, which a generated RDS password isn't
1537
+ # guaranteed to be free of); this recipe's DATABASE_URL below uses
1538
+ # the encoded form for the same reason, even though the script it
1539
+ # feeds doesn't currently read it (see db_env_blind above).
1540
+ \t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
1541
+ \techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
1542
+ \tfor i in $$(seq 1 30); do \\
1543
+ \t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
1544
+ \t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
1545
+ \t\tsleep 5; \\
1546
+ \tdone; \\
1547
+ \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"]'); \\
1548
+ \tDB_PASS_URLENC=$$(ruby -rerb -e 'print ERB::Util.url_encode(ARGV[0])' "$$DB_PASS"); \\
1549
+ \tRUN_STATUS=1; \\
1550
+ \tfor attempt in 1 2 3 4 5; do \\
1551
+ \t\tkill $$TUNNEL_PID 2>/dev/null; \\
1552
+ \t\techo "Opening an SSM tunnel to $(DB_HOST):5432 and running #{verb} (attempt $$attempt/5)..."; \\
1553
+ \t\taws ssm start-session --target $$INSTANCE_ID \\
1554
+ \t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
1555
+ \t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
1556
+ \t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
1557
+ \t\tTUNNEL_PID=$$!; \\
1558
+ \t\tfor i in $$(seq 1 15); do \\
1559
+ \t\t\tnc -z localhost 15432 2>/dev/null && break; \\
1560
+ \t\t\tsleep 1; \\
1561
+ \t\tdone; \\
1562
+ \t\tcd #{cwd} && DATABASE_URL="postgres://postgres:$$DB_PASS_URLENC@localhost:15432/#{db_name}" HECKS_SCHEMA="#{hecks_schema}" #{run_prefix} #{script} #{cwd == "$(ROOT)" ? "$(DOMAIN) " : ""}#{extra_args}&& { RUN_STATUS=0; break; }; \\
1563
+ \t\tRUN_STATUS=$$?; \\
1564
+ \t\techo "#{verb} attempt $$attempt/5 failed (exit $$RUN_STATUS) -- restarting the tunnel and retrying in 3s..."; \\
1565
+ \t\tsleep 3; \\
1566
+ \tdone; \\
1567
+ \tkill $$TUNNEL_PID 2>/dev/null; \\
1568
+ \techo "Tearing down the temporary bastion stack..."; \\
1569
+ \taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
1570
+ \taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
1571
+ \techo "$(BASTION_STACK) deleted."; \\
1572
+ \texit $$RUN_STATUS
1573
+ OWNTRANSLATION
1574
+ end
1575
+ end
1576
+
1577
+ scaffold_translation_recipe = translation_recipe.call("scaffold-translation", "bin/scaffold_translation", db_env_blind: true)
1578
+ translation_audit_recipe = translation_recipe.call("translation-audit", "bin/translation_audit", db_env_blind: true)
1579
+
1580
+ # `make migrate-console-settings` — the same bastion/tunnel/retry/
1581
+ # teardown chain as scaffold-translation/translation-audit, running an
1582
+ # app-owned one-time migration script instead of one of hecks's
1583
+ # own bin/ tools (see translation_recipe's own `cwd:`/`run_prefix:`
1584
+ # comment). Only meaningful for a domain that actually has one — most
1585
+ # domains don't, so this target is generated unconditionally but simply
1586
+ # has nothing to run for them; harmless (`bundle exec ruby` on a
1587
+ # missing file just fails loudly, same as any other missing script
1588
+ # would).
1589
+ migrate_console_settings_recipe = translation_recipe.call(
1590
+ "migrate-console-settings", "bin/migrate_console_settings", "",
1591
+ cwd: "$(DOMAIN)", run_prefix: "bundle exec ruby"
1592
+ )
1593
+
1594
+ # `rename-schema`'s own recipe body — same top-level-variable-not-inlined
1595
+ # reasoning as `mint_era_recipe` just above (recipe lines need a literal
1596
+ # leading tab; nesting this heredoc a level deeper would need its own
1597
+ # re-indent pass). Reuses the same bastion.yaml, the same stack/
1598
+ # BASTION_STACK Make variables mint-era already defines, and the same
1599
+ # stand-up/tunnel/teardown-no-matter-what shell chain — only the one
1600
+ # thing done over the tunnel differs: a schema rename instead of a
1601
+ # Ruby boot. `OLD`/`NEW` are Make command-line variables
1602
+ # (`make rename-schema OLD=old NEW=new`), not baked in here, so this
1603
+ # is genuinely reusable — the domain whose own schema this renames is
1604
+ # whichever one owns this stack's RDS instance (this generator's own
1605
+ # `#{infra_name}` database on it), not necessarily this domain forever.
1606
+ #
1607
+ # Idempotent by inspection, not by catching a Postgres error: checks
1608
+ # which of old/new actually exists as a schema before touching
1609
+ # anything, so a second run (or a run after a partial failure) reads
1610
+ # as a clear no-op message rather than a bare "schema already exists"
1611
+ # error with no context.
1612
+ rename_schema_recipe =
1613
+ if shared
1614
+ <<~SHAREDRENAME.rstrip
1615
+ # NOT AUTOMATED for a Shared-mode domain, same reason mint-era
1616
+ # isn't: there is no bastion.yaml here to stand up next to (this
1617
+ # domain has no RDS/VPC of its own). This domain's own schema
1618
+ # lives inside #{owner_domain_name}'s database — run this same
1619
+ # target from #{owner_domain_name}'s own deploy directory instead.
1620
+ \t@echo "rename-schema isn't automated for a Shared-mode domain (database \\"Shared\\") -- run it from #{owner_domain_name}'s own deploy directory instead, which owns the RDS instance this domain's schema actually lives on."; \\
1621
+ \texit 1
1622
+ SHAREDRENAME
1623
+ else
1624
+ <<~OWNRENAME.rstrip
1625
+ # THE WHOLE TUNNEL IS RESTARTED PER ATTEMPT, not just probed once
1626
+ # through a single long-lived session — confirmed live, the hard
1627
+ # way: an SSM port-forwarding session can start and then exit
1628
+ # again within seconds, not merely "slow to become ready". Probing
1629
+ # the same dead tunnel five times fails identically every time,
1630
+ # for a different reason than the retry was built for. Without
1631
+ # this restart, a still-dead tunnel also doesn't just fail loudly
1632
+ # — `EXISTS_OLD`/`EXISTS_NEW` would both come back empty, which the
1633
+ # branch below reads as "neither schema exists" and reports as if
1634
+ # that were the real answer, not a connectivity failure wearing
1635
+ # its name — which is exactly why `TUNNEL_READY` gates that branch
1636
+ # explicitly rather than trusting empty results at face value.
1637
+ #
1638
+ # OLD/NEW ARE ALLOWLISTED AS BARE IDENTIFIERS, not SQL-escaped, before
1639
+ # either one reaches a psql command line: both get interpolated
1640
+ # straight into the `nspname = '...'` lookups above and the
1641
+ # `ALTER SCHEMA "..." RENAME TO "..."` below, and a schema name can't
1642
+ # be bound as a parameter the way a value can — quoting/escaping an
1643
+ # identifier is exactly the kind of thing that's easy to get subtly
1644
+ # wrong, so instead of trying, this refuses anything that isn't
1645
+ # `^[A-Za-z_][A-Za-z0-9_]*$` outright. `make` runs this recipe as an
1646
+ # operator command, not user-facing web input, but it still executes
1647
+ # against production RDS, so a typo'd or copy-pasted `OLD`/`NEW`
1648
+ # containing quotes or `;` must fail loudly here, before either
1649
+ # tunnel or query, rather than run as-is.
1650
+ #
1651
+ # `$$OLD`/`$$NEW` HERE, NOT `$(OLD)`/`$(NEW)`: this check itself has
1652
+ # to read the value without Make ever splicing its raw text into the
1653
+ # recipe line, or a value containing `;`/`"` would break out of THIS
1654
+ # line's shell command and run before the pattern match ever sees it
1655
+ # — confirmed live, the same way the tunnel-restart behavior above
1656
+ # was. `$$OLD` instead reads the real shell environment variable
1657
+ # `make` already exports for command-line-assigned vars, so the
1658
+ # value is one opaque string to the shell no matter what characters
1659
+ # it contains. Once a value's passed this gate it's provably just
1660
+ # `[A-Za-z0-9_]`, so the existing `$(OLD)`/`$(NEW)` splices further
1661
+ # below (already written before this fix, reused as-is) are safe.
1662
+ \t@[ -n "$$OLD" ] && [ -n "$$NEW" ] || { echo "usage: make rename-schema OLD=<old-schema> NEW=<new-schema>"; exit 1; }
1663
+ \t@echo "$$OLD" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$$' || { echo "invalid OLD schema name -- schema names must match ^[A-Za-z_][A-Za-z0-9_]*$$, refusing to touch SQL"; exit 1; }
1664
+ \t@echo "$$NEW" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$$' || { echo "invalid NEW schema name -- schema names must match ^[A-Za-z_][A-Za-z0-9_]*$$, refusing to touch SQL"; exit 1; }
1665
+ \t@echo "Looking up $(STACK)'s VPC/security group..."
1666
+ \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")}
1667
+ \t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
1668
+ \taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
1669
+ \t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
1670
+ \t\t--capabilities CAPABILITY_IAM
1671
+ \t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
1672
+ \techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
1673
+ \tfor i in $$(seq 1 30); do \\
1674
+ \t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
1675
+ \t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
1676
+ \t\tsleep 5; \\
1677
+ \tdone; \\
1678
+ \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"]'); \\
1679
+ \tTUNNEL_READY=1; \\
1680
+ \tfor attempt in 1 2 3 4 5; do \\
1681
+ \t\tkill $$TUNNEL_PID 2>/dev/null; \\
1682
+ \t\techo "Opening an SSM tunnel to $(DB_HOST):5432 (attempt $$attempt/5)..."; \\
1683
+ \t\taws ssm start-session --target $$INSTANCE_ID \\
1684
+ \t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
1685
+ \t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
1686
+ \t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
1687
+ \t\tTUNNEL_PID=$$!; \\
1688
+ \t\tfor i in $$(seq 1 15); do \\
1689
+ \t\t\tnc -z localhost 15432 2>/dev/null && break; \\
1690
+ \t\t\tsleep 1; \\
1691
+ \t\tdone; \\
1692
+ \t\tPGPASSWORD=$$DB_PASS psql -h localhost -p 15432 -U postgres -d #{db_name} -tAc "SELECT 1" >/dev/null 2>&1 && { TUNNEL_READY=0; break; }; \\
1693
+ \t\techo "tunnel not carrying real traffic yet (attempt $$attempt/5) -- restarting the tunnel and retrying in 3s..."; \\
1694
+ \t\tsleep 3; \\
1695
+ \tdone; \\
1696
+ \tEXISTS_OLD=$$(PGPASSWORD=$$DB_PASS psql -h localhost -p 15432 -U postgres -d #{db_name} -tAc "SELECT 1 FROM pg_namespace WHERE nspname = '$(OLD)'"); \\
1697
+ \tEXISTS_NEW=$$(PGPASSWORD=$$DB_PASS psql -h localhost -p 15432 -U postgres -d #{db_name} -tAc "SELECT 1 FROM pg_namespace WHERE nspname = '$(NEW)'"); \\
1698
+ \tif [ "$$TUNNEL_READY" != "0" ]; then \\
1699
+ \t\techo "tunnel never carried a real connection -- aborting without touching either schema"; \\
1700
+ \t\tBOOT_STATUS=1; \\
1701
+ \telif [ "$$EXISTS_NEW" = "1" ]; then \\
1702
+ \t\techo "schema $(NEW) already exists -- already renamed, no-op"; \\
1703
+ \t\tBOOT_STATUS=0; \\
1704
+ \telif [ "$$EXISTS_OLD" = "1" ]; then \\
1705
+ \t\tPGPASSWORD=$$DB_PASS psql -h localhost -p 15432 -U postgres -d #{db_name} -v ON_ERROR_STOP=1 -c "ALTER SCHEMA \\"$(OLD)\\" RENAME TO \\"$(NEW)\\""; \\
1706
+ \t\tBOOT_STATUS=$$?; \\
1707
+ \t\techo "renamed schema $(OLD) to $(NEW) (exit $$BOOT_STATUS)"; \\
1708
+ \telse \\
1709
+ \t\techo "neither schema $(OLD) nor $(NEW) exists on #{db_name} -- nothing to rename"; \\
1710
+ \t\tBOOT_STATUS=1; \\
1711
+ \tfi; \\
1712
+ \tkill $$TUNNEL_PID 2>/dev/null; \\
1713
+ \techo "Tearing down the temporary bastion stack..."; \\
1714
+ \taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
1715
+ \taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
1716
+ \techo "$(BASTION_STACK) deleted."; \\
1717
+ \texit $$BOOT_STATUS
1718
+ OWNRENAME
1719
+ end
1720
+
1721
+ # `deploy:`'s own `sam deploy` call — `google_oauth_present` and
1722
+ # `shared` are collected together here rather than spelled as an
1723
+ # if/elsif/else, the same shape `Parameters:` above holds to: the
1724
+ # two are independent facts about a domain, not alternatives, and
1725
+ # lifeadelics is both — an elsif shape would silently drop the
1726
+ # Owning* overrides whenever OAuth is also present. Caught live
1727
+ # the first time a Shared-mode domain with real Google OAuth
1728
+ # actually ran `make deploy`: `sam deploy` refused
1729
+ # with "Parameters: [OwningVpcId, ...] must have values" because
1730
+ # nothing had ever passed them. WebRedirectBaseUrl stays a conditional
1731
+ # override (empty on a genuine first deploy, before the Function Url
1732
+ # exists — see that Parameter's own comment); Owning* is unconditional
1733
+ # whenever `shared`, in every branch that reaches `sam deploy` at all.
1734
+ # One continuous `\`-joined shell chain, however many pieces
1735
+ # contribute to it — `@` (Make's own "don't echo this line" prefix)
1736
+ # is only meaningful on the true first line of that chain; written on
1737
+ # any later line it stops being a Make directive at all and becomes
1738
+ # literal shell text (`@WEB_URL=...` parses as a bogus command, not
1739
+ # an assignment) the moment two independently-`@`-prefixed pieces
1740
+ # get concatenated. Built unprefixed here; `@` is added once, to
1741
+ # whichever piece actually ends up first, right before joining.
1742
+ owner_lookup_lines = shared ? [
1743
+ %(OWNER_VPC_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue" --output text); \\),
1744
+ %(OWNER_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='PrivateSubnetAId'].OutputValue" --output text); \\),
1745
+ %(OWNER_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='PrivateSubnetBId'].OutputValue" --output text); \\),
1746
+ %(OWNER_SG_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='FunctionSecurityGroupId'].OutputValue" --output text); \\),
1747
+ %(OWNER_DB_HOST=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='DatabaseEndpoint'].OutputValue" --output text); \\),
1748
+ %(OWNER_DB_SECRET_ARN=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='DatabaseSecretArn'].OutputValue" --output text); \\),
1749
+ ] : []
1750
+ owning_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"
1751
+
1752
+ sam_deploy_lines =
1753
+ if google_oauth_present && shared
1754
+ [
1755
+ %(WEB_URL=$$(aws lambda get-function-url-config --function-name #{rust_web ? stack_name : "#{stack_name}-web"} --query FunctionUrl --output text 2>/dev/null | sed 's:/$$::'); \\),
1756
+ "if [ -n \"$$WEB_URL\" ]; then \\",
1757
+ %(\tsam deploy --parameter-overrides WebRedirectBaseUrl="$$WEB_URL" #{owning_overrides}; \\),
1758
+ "else \\",
1759
+ "\tsam deploy --parameter-overrides #{owning_overrides}; \\",
1760
+ "fi",
1761
+ ]
1762
+ elsif google_oauth_present
1763
+ [
1764
+ %(WEB_URL=$$(aws lambda get-function-url-config --function-name #{rust_web ? stack_name : "#{stack_name}-web"} --query FunctionUrl --output text 2>/dev/null | sed 's:/$$::'); \\),
1765
+ "if [ -n \"$$WEB_URL\" ]; then \\",
1766
+ %(\tsam deploy --parameter-overrides WebRedirectBaseUrl="$$WEB_URL"; \\),
1767
+ "else \\",
1768
+ "\tsam deploy; \\",
1769
+ "fi",
1770
+ ]
1771
+ elsif shared
1772
+ ["sam deploy --parameter-overrides #{owning_overrides}"]
1773
+ else
1774
+ ["sam deploy"]
1775
+ end
1776
+
1777
+ deploy_shell_chain = owner_lookup_lines + sam_deploy_lines
1778
+ deploy_shell_chain = ["@#{deploy_shell_chain.first}"] + deploy_shell_chain.drop(1) if deploy_shell_chain.first&.match?(/=\$\$\(/)
1779
+
1780
+ # `deploy:`'s own pre-deploy `mint-era` bridge (below, inlined into
1781
+ # `PREDEPLOYBRIDGE`) — a plain Ruby string built here, not inlined
1782
+ # directly in that heredoc, for the same reason as everywhere else
1783
+ # comment: it needs its own `if/else` branch on `google_oauth_present`,
1784
+ # and Ruby heredoc-in-string-interpolation only reads cleanly one level
1785
+ # deep before it gets hard to follow.
1786
+ #
1787
+ # Google OAuth newly added to an existing stack deadlocks this bridge —
1788
+ # confirmed live: stack_outputs (above) only gains PublicSubnetId/
1789
+ # BastionSubnetId entries once `google_oauth_present` is true, but those
1790
+ # two CloudFormation resources (#{db_id}PublicSubnet/
1791
+ # #{db_id}BastionPublicSubnet) don't exist on a stack that predates
1792
+ # turning OAuth on — only the upcoming `sam deploy` (further below,
1793
+ # still ahead of us here) creates them. This pre-check's own `mint-era`
1794
+ # call evaluates stack_outputs against the currently live stack (an
1795
+ # `aws cloudformation describe-stacks` eval chain, same one mint-era's
1796
+ # own recipe uses), gets an empty string back for both, and hands
1797
+ # bastion.yaml an empty `AWS::EC2::Subnet::Id` — CloudFormation refuses
1798
+ # that outright. The pre-check fails before `sam deploy` ever runs, so
1799
+ # the one deploy that would actually create those outputs never gets
1800
+ # the chance to: a hard deadlock, `make deploy` can never get OAuth
1801
+ # provisioned on a stack that didn't have it already. Detected the same
1802
+ # way a genuine first deploy is detected just below (describe-stacks
1803
+ # succeeding or not) — here, describe-stacks succeeds (the stack
1804
+ # itself exists) but the specific output this deploy is newly adding
1805
+ # does not, yet. Skip the pre-deploy bridge in exactly that one case;
1806
+ # the unconditional `mint-era` call at the very end of `deploy:` still
1807
+ # covers it once `sam deploy` has actually created PublicSubnetId/
1808
+ # BastionSubnetId — the exact same "runs once, after the stack exists"
1809
+ # path a domain's true first deploy already takes below.
1810
+ predeploy_bridge_shell =
1811
+ if google_oauth_present
1812
+ <<~OAUTHBRIDGE.rstrip
1813
+ @echo "Checking whether $(STACK) already exists, and if so whether it already has Google OAuth's PublicSubnetId/BastionSubnetId outputs, before deciding whether to bridge era history now or let this deploy create them first..."
1814
+ @if aws cloudformation describe-stacks --stack-name $(STACK) >/dev/null 2>&1; then \\
1815
+ \tOAUTH_OUTPUTS_READY=$$(aws cloudformation describe-stacks --stack-name $(STACK) --query "Stacks[0].Outputs[?OutputKey=='PublicSubnetId'].OutputValue" --output text 2>/dev/null); \\
1816
+ \tif [ -z "$$OAUTH_OUTPUTS_READY" ]; then \\
1817
+ \t\techo "Existing stack found, but Google OAuth is newly being added -- PublicSubnetId/BastionSubnetId don't exist on it yet (sam deploy, below, is what creates them); skipping the pre-deploy bridge this one time so THIS deploy can actually run. mint-era still runs once, after sam deploy, same as a genuine first deploy."; \\
1818
+ \telse \\
1819
+ \t\techo "Existing stack found -- bridging era history before this deploy flips $(STACK) over, not after."; \\
1820
+ \t\t$(MAKE) mint-era || exit 1; \\
1821
+ \tfi; \\
1822
+ else \\
1823
+ \techo "No existing stack -- first deploy, nothing to bridge yet; mint-era runs once, below, after the stack (and its RDS instance) exist."; \\
1824
+ fi
1825
+ OAUTHBRIDGE
1826
+ else
1827
+ <<~PLAINBRIDGE.rstrip
1828
+ @echo "Checking whether $(STACK) already exists, to decide whether this deploy needs to bridge era history before it flips the stack over..."
1829
+ @if aws cloudformation describe-stacks --stack-name $(STACK) >/dev/null 2>&1; then \\
1830
+ \techo "Existing stack found -- bridging era history before this deploy flips $(STACK) over, not after."; \\
1831
+ \t$(MAKE) mint-era || exit 1; \\
1832
+ else \\
1833
+ \techo "No existing stack -- first deploy, nothing to bridge yet; mint-era runs once, below, after the stack (and its RDS instance) exist."; \\
1834
+ fi
1835
+ PLAINBRIDGE
1836
+ end
1837
+
1838
+ deploy_recipe_lines = (
1839
+ (google_oauth_present ? ["$(MAKE) sync-google-oauth"] : []) +
1840
+ (shared ? [%(@echo "Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/FunctionSecurityGroupId/DatabaseEndpoint/DatabaseSecretArn outputs to pass as $(STACK)'s Owning* parameters...")] : []) +
1841
+ deploy_shell_chain
1842
+ ).map { |l| "\t#{l}" }.join("\n")
1843
+
1844
+ makefile_content = <<~MAKE
1845
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
1846
+ # this file rather than hand-editing.
1847
+ #
1848
+ # `sam build` invokes the build-<LogicalId> target below (SAM's own
1849
+ # BuildMethod: makefile convention — see template.yaml's Metadata).
1850
+ # `cargo lambda` (https://www.cargo-lambda.info) cross-compiles
1851
+ # rust/host for arm64 Lambda without Docker or a hand-configured
1852
+ # cross-linker — install once with `pip3 install cargo-lambda` or
1853
+ # `brew install cargo-lambda`.
1854
+
1855
+ HOST_DIR := #{File.join(root, "rust", "host")}
1856
+ # `domain_name`, matching bin/project_wasm's own target_mod_name
1857
+ # (a Rust-build-pipeline naming convention, not AWS resource
1858
+ # identity — see HECKS_WASM_PATH's own comment above).
1859
+ WASM := #{File.join(root, "rust", "dist", "#{domain_name}.wasm")}
1860
+
1861
+ build-#{logical_id}:
1862
+ \t@command -v cargo-lambda >/dev/null 2>&1 || { \\
1863
+ \t\techo "cargo-lambda isn't installed. Install it once with: pip3 install cargo-lambda"; \\
1864
+ \t\texit 1; \\
1865
+ \t}
1866
+ # ALWAYS rebuilt, never `test -f $(WASM) || ...` — that guard only
1867
+ # checked EXISTENCE, not staleness, so a wasm built once at the
1868
+ # start of a work session silently kept getting deployed through
1869
+ # every later kernel change (a real, live bug: this cost a stale
1870
+ # deploy against a wasm that predated the kernel's own "mutations"
1871
+ # field). `bin/project_wasm` recompiles fast enough that "always
1872
+ # rebuild" is the safe default, matching rust/host's own bootstrap
1873
+ # build just below, which was never guarded this way to begin with.
1874
+ \tcd #{root} && bin/project_wasm #{domain}
1875
+ \t@rustup target list --installed 2>/dev/null | grep -qx aarch64-unknown-linux-gnu || rustup target add aarch64-unknown-linux-gnu
1876
+ # `rustup run stable`, not a bare `cargo lambda` — same reasoning
1877
+ # bin/project_wasm's own Makefile-equivalent line holds itself to: a
1878
+ # `cargo`/`rustc` earlier on PATH than rustup's own shims (Homebrew
1879
+ # installs one; common on this kind of machine) is a DIFFERENT
1880
+ # toolchain that never saw `rustup target add`, and fails with
1881
+ # "can't find crate for \`core\`" for the cross target even though
1882
+ # `rustup target list --installed` reports it present.
1883
+ # macOS -> Linux arm64 cross-compiling by default routes through
1884
+ # zig (cargo-lambda's own bundled cargo-zigbuild) — every zig
1885
+ # release checked so far (0.15.2, 0.16.0) rejects
1886
+ # `-Wl,--fix-cortex-a53-843419`, a flag rustc's own
1887
+ # aarch64-unknown-linux-gnu target emits unconditionally: "error:
1888
+ # unsupported linker arg: --fix-cortex-a53-843419", found live on
1889
+ # THIS domain's own very first real deploy attempt (upstream:
1890
+ # https://github.com/ziglang/zig/issues, no fix released yet as of
1891
+ # this comment). Not fixable by a flag here — it's zig's own `zig
1892
+ # cc` argument-translation layer refusing before rustc's link step
1893
+ # ever runs. If this build fails with that error, cargo-lambda has
1894
+ # a real, documented non-zig, non-Docker fallback: `-c cargo` (or
1895
+ # `CARGO_LAMBDA_COMPILER=cargo`) plus a real GNU cross-toolchain —
1896
+ # `brew tap messense/macos-cross-toolchains && brew install
1897
+ # aarch64-unknown-linux-gnu` (this flag has worked in real
1898
+ # binutils/LLD since 2018; only zig's own translation of it is the
1899
+ # gap), then set `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=
1900
+ # aarch64-linux-gnu-gcc` with that toolchain's own bin/ on PATH.
1901
+ # Confirmed working end to end (a real `sam deploy` against a real
1902
+ # Lambda) the same day this comment was written.
1903
+ \tcd $(HOST_DIR) && rustup run stable cargo lambda build --release --arm64
1904
+ \tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap $(ARTIFACTS_DIR)/bootstrap
1905
+ # UNCONDITIONAL, not gated on rust_web -- same reason HECKS_IR_PATH
1906
+ # itself is unconditional above: main.rs's own boot sequence reads
1907
+ # the IR for every domain regardless of web mode, so the sidecar
1908
+ # file this env var points to has to actually be in the package
1909
+ # too, or the env var alone just changes the crash from "not set"
1910
+ # to "not found".
1911
+ \tcp $(WASM) $(ARTIFACTS_DIR)/#{domain_name}.wasm
1912
+ \tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} $(ARTIFACTS_DIR)/#{domain_name}.ir.json
1913
+
1914
+ # `sam build <resource>` WIPES .aws-sam/build/ entirely before
1915
+ # building just the one resource named -- confirmed live: building
1916
+ # #{web_logical_id} second left NO #{logical_id}/ directory behind at
1917
+ # all, and reverted its OWN built CodeUri back to the raw, unbuilt
1918
+ # source value ("../.." -- CodeUri: . resolved two directories up
1919
+ # from .aws-sam/build/template.yaml). `sam deploy` then zips THAT
1920
+ # directory verbatim as #{logical_id}'s own code -- a real, live
1921
+ # deploy shipped #{web_logical_id}'s own source tree (Gemfile,
1922
+ # lambda_handler.rb, even .env.local) as #{logical_id}'s Lambda
1923
+ # package this way, caught by downloading the actually-deployed
1924
+ # code and finding #{web_logical_id}'s file tree inside it.
1925
+ # `deploy:`'s own two separate `sam build <resource>` calls can
1926
+ # never both survive in .aws-sam/build/ at once — this target
1927
+ # re-populates #{logical_id}'s build directory from the SAME
1928
+ # already-built bootstrap+wasm build-#{logical_id} just produced
1929
+ # (untouched by the second sam build call — only .aws-sam/build/
1930
+ # itself gets wiped, never $(HOST_DIR)/target or $(WASM)) and
1931
+ # repoints the built template's CodeUri back at it.
1932
+ .PHONY: restore-#{logical_id}-build
1933
+ restore-#{logical_id}-build:
1934
+ \t@mkdir -p .aws-sam/build/#{logical_id}
1935
+ \tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap .aws-sam/build/#{logical_id}/bootstrap
1936
+ \tcp $(WASM) .aws-sam/build/#{logical_id}/#{domain_name}.wasm
1937
+ \tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} .aws-sam/build/#{logical_id}/#{domain_name}.ir.json
1938
+ \truby -e 'lines = File.readlines(".aws-sam/build/template.yaml"); start = lines.index { |l| l.strip == "#{logical_id}:" } or raise "restore-#{logical_id}-build: #{logical_id} resource not found in built template"; idx = (start+1...lines.length).find { |i| lines[i] =~ /CodeUri:/ } or raise "restore-#{logical_id}-build: no CodeUri line found under #{logical_id}"; lines[idx] = lines[idx].sub(/CodeUri:.*/, "CodeUri: #{logical_id}"); File.write(".aws-sam/build/template.yaml", lines.join)'
1939
+
1940
+ # `make verify-parity-#{logical_id}` — closes the exact gap the
1941
+ # equivalence-gap plan's own Phase 8 named: parity was checked ONLY
1942
+ # against CI's fixed test corpus, completely decoupled from what a
1943
+ # real `sam deploy` actually ships — `bin/rust_conformance` (the
1944
+ # differential harness, docs/decisions/0010-ruby-is-the-reference-
1945
+ # implementation.md) had never once been run against a specific
1946
+ # compiled deploy artifact. Runs it here, for real, against
1947
+ # $(WASM) -- the EXACT file `build-#{logical_id}` (above) just built
1948
+ # and `deploy:` (below) is about to ship -- not a corpus-wide `cargo
1949
+ # build`'s own separate binary. `bin/rust_conformance` exits non-zero
1950
+ # on any mismatch, which this target lets propagate uncaught: a real
1951
+ # divergence between THIS artifact and Ruby's own reading of the
1952
+ # identical source blocks `make deploy` before `sam deploy` ever
1953
+ # runs, the same way a failing build already would.
1954
+ #
1955
+ # `spec/corpus/#{domain_name}.json` is this domain's OWN pinned
1956
+ # fuzzer-replay script (the same shape `bin/fuzz`/`bin/run` already
1957
+ # use) -- if a domain doesn't have one yet, this warns LOUDLY and
1958
+ # continues rather than either silently skipping (this project's own
1959
+ # standing rule against a silent gap reading as full coverage) or
1960
+ # blocking every deploy of a domain nobody has written one for yet.
1961
+ #
1962
+ # SECOND HALF, BELOW — `bin/rust_conformance_fuzz`, ADR 0037's own fuzz
1963
+ # bridge pointed at this exact $(WASM). A pinned script only proves
1964
+ # "matches Ruby on the cases we thought to write down" (ADR 0039's own
1965
+ # honest framing); the fuzz bridge is what actually FOUND Findings 1-6.
1966
+ # Deliberately WARN-ONLY (`-@`, not `@`) for now: ADR 0037 already
1967
+ # catalogues real, confirmed, not-yet-fixed divergences (Findings 3 and
1968
+ # 5) that fire on ordinary generated sequences for more than one real
1969
+ # domain today, so making this a hard blocker before those are closed
1970
+ # would turn every affected domain's `make deploy` red for a reason
1971
+ # this target can't yet point at a fix for. Flip `-@` to `@` once ADR
1972
+ # 0037's open findings are closed and this stops firing in practice.
1973
+ .PHONY: verify-parity-#{logical_id}
1974
+ verify-parity-#{logical_id}:
1975
+ \t@if [ -f #{root}/spec/corpus/#{domain_name}.json ]; then \\
1976
+ \t\tcd #{root} && bin/rust_conformance #{domain} spec/corpus/#{domain_name}.json $(WASM); \\
1977
+ \telse \\
1978
+ \t\techo "verify-parity-#{logical_id}: no spec/corpus/#{domain_name}.json -- SKIPPING the pre-deploy Ruby/Rust parity check, nothing to compare $(WASM) against. Write one (bin/fuzz/bin/run's own script shape) before this domain's next deploy."; \\
1979
+ \tfi
1980
+ \t@echo "verify-parity-#{logical_id}: fuzzing $(WASM) against generated sequences (ADR 0037's bridge, WARN-ONLY -- see this target's own comment)..."
1981
+ \t-@cd #{root} && bin/rust_conformance_fuzz #{domain} $(WASM)
1982
+
1983
+ # `make mint-era` — takes #{stack_name}'s RDS instance from freshly
1984
+ # created to "era 1 minted, ready for HECKS_DOMAIN/HECKS_ERA to
1985
+ # resolve" (template.yaml's own env vars already assume era 1 —
1986
+ # this is what makes that true). Run ONCE per fresh database, before
1987
+ # the first real `sam deploy` of the Lambda itself; safe to re-run
1988
+ # (a second boot against an already-held era 1 is a no-op — see
1989
+ # era_resolver.rb's own quiet-reboot branch). Stands up bastion.yaml
1990
+ # as its own sibling stack, uses it for one Ruby boot over an SSM
1991
+ # tunnel, then deletes it — nothing from this target is left running
1992
+ # afterward.
1993
+ ROOT := #{root}
1994
+ DOMAIN := #{domain}
1995
+ STACK := #{stack_name}
1996
+ BASTION_STACK := #{stack_name}-bastion
1997
+
1998
+ mint-era:
1999
+ #{mint_era_recipe}
2000
+
2001
+ # `make scaffold-translation` — run this when a deploy's own
2002
+ # pre-flight boot check (or `make mint-era` directly) refuses with
2003
+ # "the shape changed (era N) and no translation edge covers it".
2004
+ # Diffs the held era against the current bluebook over the same
2005
+ # bastion tunnel mint-era uses and WRITES a translations/*.bluebook
2006
+ # edge file into this domain's own repo — confident rules inline,
2007
+ # genuine ambiguities as `unresolved` lines a human has to resolve by
2008
+ # hand (a rename, a drop, a compute — never guessed). Review the
2009
+ # written file, then `make translation-audit`.
2010
+ scaffold-translation:
2011
+ #{scaffold_translation_recipe}
2012
+
2013
+ # `make translation-audit` — verifies a written (or still-pending)
2014
+ # translation edge over the same tunnel: every translated state
2015
+ # against the new era's own types/invariants/lifecycle, the compiled
2016
+ # SQL against the port's own reference transform, and a before/after
2017
+ # sample of real records printed for human review. Re-run
2018
+ # `make deploy` (or `make mint-era`) once this passes — that's what
2019
+ # actually applies the edge; this only checks it.
2020
+ translation-audit:
2021
+ #{translation_audit_recipe}
2022
+
2023
+ # `make migrate-console-settings` — runs this domain's own
2024
+ # bin/migrate_console_settings (if it has one) over the same bastion
2025
+ # tunnel every other Postgres-touching target here uses, against the
2026
+ # real deployed database instead of local dev. A one-time data
2027
+ # migration, not a repeatable ops procedure like mint-era/scaffold-
2028
+ # translation — generated the same way regardless, per this
2029
+ # project's own standing rule against hand-run ops steps.
2030
+ migrate-console-settings:
2031
+ #{migrate_console_settings_recipe}
2032
+
2033
+ # `make rename-schema OLD=<old> NEW=<new>` — a native `ALTER SCHEMA
2034
+ # ... RENAME TO ...` over the same bastion tunnel mint-era uses,
2035
+ # for when this domain's own declared identity changes
2036
+ # (`formerly_known_as`) and its storage schema should follow. Purely
2037
+ # a Postgres namespace rename: every table/row/sequence inside it is
2038
+ # untouched, so this is safe to run against a live, in-use database
2039
+ # — the only requirement is that nothing else names the schema by
2040
+ # its OLD name at the moment this runs (this domain's own deployed
2041
+ # Lambda's HECKS_SCHEMA env var, most directly — run `make deploy`
2042
+ # right before or right after this, not with a long gap either way).
2043
+ rename-schema:
2044
+ #{rename_schema_recipe}
2045
+
2046
+ #{pg_version ? <<~PGNATIVE : ""}
2047
+ # #{web_logical_id}'s own `pg` gem needs a native extension SAM's
2048
+ # default Ruby builder can't produce correctly: the precompiled
2049
+ # aarch64-linux binary needs GLIBC 2.29+, but Lambda's ruby3.2
2050
+ # MANAGED runtime is Amazon Linux 2 (glibc 2.26) — a real, live
2051
+ # "Init<NameError>: uninitialized constant PG::Error" caught this
2052
+ # (rescuing PG::Error itself failed to resolve, because pg's own
2053
+ # `require` died before ever reaching the file that defines it).
2054
+ # Built once, from source, in the SAME container `sam build
2055
+ # --use-container` itself uses (its glibc, not the host's, is what
2056
+ # has to match the deployed runtime) — then cached under
2057
+ # PG_NATIVE_DIR so every later `make deploy` skips straight to the
2058
+ # fast path. Delete that directory to force a clean rebuild.
2059
+ #
2060
+ # OpenSSL is built `no-shared` and linked STATICALLY into libpq, so
2061
+ # the libpq.so this produces carries no runtime dependency on
2062
+ # whatever OpenSSL (if any) happens to already be on the Lambda
2063
+ # execution image — sidesteps the AL2 package conflict between
2064
+ # `openssl-libs` and the build image's own pre-installed
2065
+ # `openssl-snapsafe-libs` entirely, rather than fighting it.
2066
+ PG_NATIVE_DIR := #{root}/tmp/pg-native-arm64-ruby3.2
2067
+
2068
+ $(PG_NATIVE_DIR)/pg_ext.so:
2069
+ \t@mkdir -p $(PG_NATIVE_DIR)
2070
+ \t@echo "Building #{web_logical_id}'s pg native extension from source (cached under $(PG_NATIVE_DIR) after this — a few minutes, one time only)..."
2071
+ \tdocker run --rm --platform linux/arm64 -v $(PG_NATIVE_DIR):/out \\
2072
+ \t\t--entrypoint /bin/bash public.ecr.aws/sam/build-ruby3.2:latest-arm64 -c ' \\
2073
+ \t\t\tset -e; \\
2074
+ \t\t\tyum install -y perl-IPC-Cmd >/dev/null; \\
2075
+ \t\t\tcd /tmp; \\
2076
+ \t\t\tcurl -sL https://www.openssl.org/source/openssl-3.0.15.tar.gz | tar xz; \\
2077
+ \t\t\tcd openssl-3.0.15; \\
2078
+ \t\t\t./Configure linux-aarch64 no-shared no-tests --prefix=/tmp/openssl-install >/dev/null; \\
2079
+ \t\t\tmake -j$$(nproc) >/dev/null; \\
2080
+ \t\t\tmake install_sw >/dev/null; \\
2081
+ \t\t\tcd /tmp; \\
2082
+ \t\t\tcurl -sL https://ftp.postgresql.org/pub/source/v16.4/postgresql-16.4.tar.gz | tar xz; \\
2083
+ \t\t\tcd postgresql-16.4; \\
2084
+ \t\t\tCPPFLAGS="-I/tmp/openssl-install/include" LDFLAGS="-L/tmp/openssl-install/lib" \\
2085
+ \t\t\t\t./configure --without-readline --without-zlib --without-icu --with-ssl=openssl --prefix=/tmp/pg-install >/dev/null; \\
2086
+ \t\t\tmake -C src/include install >/dev/null; \\
2087
+ \t\t\tmake -C src/interfaces/libpq -j$$(nproc) install >/dev/null; \\
2088
+ \t\t\tcd /tmp; \\
2089
+ \t\t\tgem fetch pg -v #{pg_version} --platform ruby >/dev/null; \\
2090
+ \t\t\tgem unpack pg-#{pg_version}.gem --target=/tmp/gemsrc >/dev/null; \\
2091
+ \t\t\tcd /tmp/gemsrc/pg-#{pg_version}/ext; \\
2092
+ \t\t\truby -I.. -I../lib extconf.rb --with-pg-include=/tmp/pg-install/include --with-pg-lib=/tmp/pg-install/lib >/dev/null; \\
2093
+ \t\t\tmake >/dev/null; \\
2094
+ \t\t\tcp pg_ext.so /out/pg_ext.so; \\
2095
+ \t\t\tcp /tmp/pg-install/lib/libpq.so.5.16 /out/libpq.so.5.16; \\
2096
+ \t\t'
2097
+
2098
+ # `find`, not a hardcoded gem-version path — survives #{domain}'s
2099
+ # own pg version bumping in its Gemfile.lock without this Makefile
2100
+ # needing regeneration to match. libpq.so.5.16 lands beside the
2101
+ # rest of #{web_logical_id}'s code at /var/task/lib — the ruby3.2
2102
+ # base image's OWN baked-in LD_LIBRARY_PATH default already searches
2103
+ # that exact path, so no rpath patching AND no explicit
2104
+ # LD_LIBRARY_PATH override belongs in template.yaml (setting one
2105
+ # there REPLACES the image's full default list instead of extending
2106
+ # it — a real, live "libcrypt.so.1: cannot open shared object file"
2107
+ # caught this — #{web_logical_id}'s own Environment no longer sets
2108
+ # it at all, on purpose).
2109
+ .PHONY: patch-pg-native
2110
+ patch-pg-native: $(PG_NATIVE_DIR)/pg_ext.so
2111
+ \t@PG_EXT=$$(find .aws-sam/build/#{web_logical_id}/vendor/bundle -path "*/pg-*/lib/3.2/pg_ext.so" | head -1); \\
2112
+ \t\ttest -n "$$PG_EXT" || { echo "patch-pg-native: no pg_ext.so found under .aws-sam/build/#{web_logical_id} — run sam build --use-container #{web_logical_id} first"; exit 1; }; \\
2113
+ \t\tcp $(PG_NATIVE_DIR)/pg_ext.so "$$PG_EXT"
2114
+ \t@mkdir -p .aws-sam/build/#{web_logical_id}/lib
2115
+ \tcp $(PG_NATIVE_DIR)/libpq.so.5.16 .aws-sam/build/#{web_logical_id}/lib/libpq.so.5.16
2116
+ \tln -sf libpq.so.5.16 .aws-sam/build/#{web_logical_id}/lib/libpq.so.5
2117
+
2118
+ PGNATIVE
2119
+ #{google_oauth_present ? <<~SYNCOAUTH : ""}
2120
+ # Owns #{stack_name}-web-google-oauth's WHOLE lifecycle (create on
2121
+ # first run, update every run after — idempotent, cheap, no reason
2122
+ # to cache the way patch-pg-native's own multi-minute build does) —
2123
+ # deliberately OUTSIDE CloudFormation, straight from #{domain}'s own
2124
+ # gitignored .env.local, so the real client_id/secret never lands in
2125
+ # this generated, git-TRACKED template.yaml as plaintext. Read by
2126
+ # name (`{{resolve:secretsmanager:#{stack_name}-web-google-oauth:...}}`,
2127
+ # template.yaml's own Environment — #{rust_web ? logical_id : web_logical_id}'s
2128
+ # own), not by `!Ref` — see that comment for why this secret isn't a
2129
+ # stack resource at all. NOT nested inside patch-pg-native's own
2130
+ # pg_version gate above -- a real, live "No rule to make target
2131
+ # `sync-google-oauth'" caught that this target used to only exist
2132
+ # when a Ruby WebFunction (with its own pg gem) was also present,
2133
+ # even though Google OAuth itself has nothing to do with pg at all.
2134
+ .PHONY: sync-google-oauth
2135
+ sync-google-oauth:
2136
+ \t@CLIENT_ID=$$(grep '^GOOGLE_CLIENT_ID=' #{domain}/.env.local | cut -d= -f2-); \\
2137
+ \t\tCLIENT_SECRET=$$(grep '^GOOGLE_CLIENT_SECRET=' #{domain}/.env.local | cut -d= -f2-); \\
2138
+ \t\ttest -n "$$CLIENT_ID" -a -n "$$CLIENT_SECRET" || { echo "sync-google-oauth: #{domain}/.env.local is missing GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET"; exit 1; }; \\
2139
+ \t\tSECRET_JSON=$$(ruby -rjson -e 'puts JSON.generate({client_id: ARGV[0], client_secret: ARGV[1]})' "$$CLIENT_ID" "$$CLIENT_SECRET"); \\
2140
+ \t\taws secretsmanager put-secret-value --secret-id #{stack_name}-web-google-oauth --secret-string "$$SECRET_JSON" >/dev/null 2>&1 || \\
2141
+ \t\taws secretsmanager create-secret --name #{stack_name}-web-google-oauth --secret-string "$$SECRET_JSON" >/dev/null
2142
+
2143
+ SYNCOAUTH
2144
+ # `make deploy` — THE one command. Chains `sam build`, `sam deploy`
2145
+ # (creates/updates the VPC+RDS+Lambda stack), then `mint-era` (safe
2146
+ # to run every time — a second boot against an already-held era 1
2147
+ # is a no-op, era_resolver.rb's own quiet-reboot branch) so a fresh
2148
+ # database always ends this command with era 1 actually held, not a
2149
+ # separate step someone has to remember. No hand-run AWS CLI
2150
+ # sequence anywhere in this path.
2151
+ #{google_oauth_present ? <<~OAUTHNOTE : ""}
2152
+ # deploy's own last step below looks up #{web_logical_id}'s
2153
+ # CURRENTLY deployed Function URL live (not tracked anywhere
2154
+ # generated) and passes it as WebRedirectBaseUrl -- see that
2155
+ # Parameter's own comment, above, for why GOOGLE_REDIRECT_URI can't
2156
+ # just be a CloudFormation intrinsic. Empty on a genuine first
2157
+ # deploy (before the Url exists at all); every deploy after that
2158
+ # finds the real, STABLE hostname (confirmed unchanged across every
2159
+ # redeploy this session) and self-heals GOOGLE_REDIRECT_URI to match.
2160
+ OAUTHNOTE
2161
+ #{shared ? <<~SHAREDNOTE : ""}
2162
+ # deploy's own last step below looks up #{owner_domain_name}'s live
2163
+ # stack Outputs (never tracked anywhere generated -- same reasoning
2164
+ # as WebRedirectBaseUrl's own live lookup above, and the SAME
2165
+ # `aws cloudformation describe-stacks` pattern mint-era's own eval
2166
+ # chain, below, already proves works against a sibling stack) and
2167
+ # passes them as this template's own Owning* Parameters.
2168
+ SHAREDNOTE
2169
+ .PHONY: deploy mint-era
2170
+ deploy:
2171
+ #{if dispatch_none
2172
+ "\t# ONE build only -- dispatch \"None\" means there is no\n\t# #{logical_id} of any kind (no cargo-lambda, no rust artifact, no\n\t# verify-parity: nothing rust/host-shaped for this domain at all),\n\t# so unlike every other WebFunction-carrying domain there is no\n\t# second build to protect from `sam build <resource>`'s own\n\t# WIPES-.aws-sam/build/-first behavior (build-#{logical_id}'s own\n\t# sibling comment on why that matters for domains that DO have one)\n\t# and nothing to restore afterward.\n\tsam build --use-container #{web_logical_id}\n\t$(MAKE) patch-pg-native"
2173
+ elsif pg_version
2174
+ "\t# TWO SEPARATE builds, not one `--use-container` run -- that flag is\n\t# global to `sam build`, but #{logical_id} deliberately builds on\n\t# THIS machine's own toolchain (cargo-lambda already cross-compiles\n\t# to arm64 without a container -- build-#{logical_id}'s own comment\n\t# on why), which a generic provided.al2023 container doesn't have\n\t# at all (a real, live \"Make Failed\" caught this). Only #{web_logical_id}\n\t# needs the container, to cross-compile pg's native extension for\n\t# Amazon Linux -- `sam build <resource>` WIPES .aws-sam/build/ before\n\t# building just the one named, so #{logical_id}'s own build gets\n\t# restored below (restore-#{logical_id}-build's own comment on why).\n\tsam build #{logical_id}\n\tsam build --use-container #{web_logical_id}\n\t$(MAKE) restore-#{logical_id}-build\n\t$(MAKE) patch-pg-native\n\t$(MAKE) verify-parity-#{logical_id}"
2175
+ elsif web_handler_present
2176
+ "\t# TWO SEPARATE builds -- #{web_logical_id} has no native extension to\n\t# cross-compile (no `pg` in #{domain}/Gemfile.lock), so a plain `sam\n\t# build` targets it fine; #{logical_id} still gets its own `sam build\n\t# <resource>` first, then restored below -- `sam build <resource>`\n\t# WIPES .aws-sam/build/ before building just the one named, so the\n\t# second call here would otherwise erase #{logical_id}'s own build\n\t# (restore-#{logical_id}-build's own comment on why).\n\tsam build #{logical_id}\n\tsam build #{web_logical_id}\n\t$(MAKE) restore-#{logical_id}-build\n\t$(MAKE) verify-parity-#{logical_id}"
2177
+ else
2178
+ "\tsam build\n\t$(MAKE) verify-parity-#{logical_id}"
2179
+ end}
2180
+ #{shared ? "" : <<~PREDEPLOYBRIDGE.each_line.map { |l| "\t" + l }.join.rstrip
2181
+ # THE TRANSACTION-SAFETY GAP, closed for the case that actually
2182
+ # matters: `sam deploy` below is what flips this Lambda's own
2183
+ # HECKS_DOMAIN/HECKS_SCHEMA env vars live — the INSTANT it
2184
+ # completes, real traffic can hit a domain/schema combination
2185
+ # whose era history hasn't been bridged yet if THIS domain was
2186
+ # just renamed (formerly_known_as) or its schema just moved.
2187
+ # `mint-era` already bridges that (era_resolver.rb's own
2188
+ # rename_domain! path, idempotent either way) — it only ran
2189
+ # AFTER `sam deploy` before this existed, which is exactly the
2190
+ # order that left a real live window open the one time this was
2191
+ # actually run in anger (an SSM tunnel flaked mid-bridge, after
2192
+ # the Lambda had already flipped over).
2193
+ #
2194
+ # STILL RUNS AFTER TOO (unchanged, below) — this pre-check
2195
+ # cannot replace that: a domain's VERY FIRST deploy has no
2196
+ # stack, hence no RDS, hence nothing to tunnel to yet, so
2197
+ # mint-era's real first run has to stay where it always was,
2198
+ # once `sam deploy` has just created that RDS instance. This
2199
+ # only ADDS a second, EARLIER run for a domain that already has
2200
+ # a live stack (a rename or an ordinary redeploy) — cheap and
2201
+ # safe either way, since mint-era's own quiet-reboot path is a
2202
+ # no-op the moment nothing has actually changed.
2203
+ #
2204
+ # See predeploy_bridge_shell's own comment (bin/project_deploy,
2205
+ # above, right before deploy_recipe_lines) for why this branches
2206
+ # on google_oauth_present at all -- the short version: adding
2207
+ # Google OAuth to an EXISTING stack would otherwise deadlock this
2208
+ # exact pre-check against outputs the upcoming sam deploy (not
2209
+ # yet run) hasn't created.
2210
+ #{predeploy_bridge_shell}
2211
+ PREDEPLOYBRIDGE
2212
+ }
2213
+ #{deploy_recipe_lines}
2214
+ \t$(MAKE) mint-era
2215
+ MAKE
2216
+
2217
+ # Everything `sam deploy --guided` asks interactively — stack name,
2218
+ # region, capabilities, rollback behavior — is already known once the
2219
+ # domain and its deployed_to("AwsLambda") block are: none of it needs
2220
+ # a human typing answers on every deploy. There's no secret to carry
2221
+ # here either now: DATABASE_URL is composed inside the template itself
2222
+ # from RDS's own auto-generated Secrets Manager password, so nothing
2223
+ # gets typed, saved, or passed as a --parameter-overrides flag at all.
2224
+ samconfig_toml = <<~TOML
2225
+ # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
2226
+ # this file rather than hand-editing.
2227
+ version = 0.1
2228
+
2229
+ [default.deploy.parameters]
2230
+ stack_name = "#{stack_name}"
2231
+ region = "#{region}"
2232
+ resolve_s3 = true
2233
+ s3_prefix = "#{stack_name}"
2234
+ capabilities = "CAPABILITY_IAM"
2235
+ confirm_changeset = false
2236
+ disable_rollback = false
2237
+ TOML
2238
+
2239
+ files = { "template.yaml" => template_yaml }
2240
+ files["bastion.yaml"] = bastion_yaml if bastion_yaml
2241
+ files["Makefile"] = makefile_content
2242
+ files["samconfig.toml"] = samconfig_toml
2243
+ files
2244
+ end
2245
+
2246
+ # Builds the CloudFront/WAFv2/logging resources a `PII`-marked domain gets fronted by.
2247
+ #
2248
+ # **`PII` → CloudFront** — once a domain marks a field "pii", its own public
2249
+ # surface (`WebFunction` when one exists, `#{logical_id}` otherwise —
2250
+ # `fronted_logical_id`, computed where both are known, in `call`) gets
2251
+ # fronted by a distribution carrying a WAFv2 WebACL (AWS managed rule
2252
+ # groups), security response headers, geo-restriction, and access
2253
+ # logging — every other domain's own template is untouched
2254
+ # (`pii_detected` false means `call` never invokes this at all).
2255
+ #
2256
+ # **`OAC` only for the AWS_IAM case** (`use_oac`) — the already-public
2257
+ # `WebFunction`/`rust_web` shape (`AuthType: NONE`) is left exactly as
2258
+ # reachable as it already was; CloudFront adds WAF/headers/geo/logging
2259
+ # on top of that, it does not change who could already call the
2260
+ # Function URL directly. `#{logical_id}` itself (the AWS_IAM,
2261
+ # internal-dispatch default) is the opposite: OAC lets CloudFront sign
2262
+ # requests to it via SigV4 while `PiiLambdaInvokePermission`'s own
2263
+ # `SourceArn` admits only this one distribution — direct, unsigned
2264
+ # access to the Function URL stays refused exactly as it was before
2265
+ # this ran.
2266
+ #
2267
+ # **Managed cache/origin-request policy ids** — not custom resources.
2268
+ # `4135ea2d-6df8-44a3-9df3-4b5a84be39ad`/`216adef6-5c7f-47e4-b989-
2269
+ # 5492eafa07d3` are AWS's own permanent, account-independent
2270
+ # `Managed-CachingDisabled`/`Managed-AllViewer` ids (the same ones the
2271
+ # console's own dropdown offers) — this fronts a Lambda dispatch
2272
+ # endpoint, not a static site; caching a response meant for exactly
2273
+ # one caller would be a real correctness bug, not a performance choice
2274
+ # made once here.
2275
+ #
2276
+ # @param fronted_logical_id [String] the Lambda resource this distribution fronts
2277
+ # @param use_oac [Boolean] true only when `fronted_logical_id`'s own FunctionUrlConfig is
2278
+ # AWS_IAM (never true for WebFunction/rust_web, both always `NONE`)
2279
+ # @param geo_restriction_type ["none", "allowlist", "blocklist"] `deployed_to`'s own
2280
+ # `geo_restriction` setting; "none" (no restriction, structurally present so a later
2281
+ # change is a one-line `deployed_to` edit, not a template rewrite) when unset
2282
+ # @param geo_restriction_countries [Array<String>] ISO 3166-1 alpha-2 codes; ignored when
2283
+ # `geo_restriction_type` is "none"
2284
+ # @return [String] the Resources entries to splice in before Outputs:, absolutely
2285
+ # indented to 2 spaces (this stack's own top-level Resources entry column)
2286
+ def pii_cloudfront_yaml(fronted_logical_id:, use_oac:, geo_restriction_type:, geo_restriction_countries:)
2287
+ reindent = ->(text) { text.each_line.map { |line| line.strip.empty? ? line : " #{line}" }.join }
2288
+
2289
+ # Not pre-reindented (unlike the return value as a whole, below) — each
2290
+ # is spliced back into the still-being-dedented RESOURCES heredoc via
2291
+ # `#{...}`, which the outer `reindent.call` already shifts by 2
2292
+ # spaces once; reindenting here too would double it.
2293
+ origin_access_control = use_oac ? <<~OAC : ""
2294
+ PiiOriginAccessControl:
2295
+ Type: AWS::CloudFront::OriginAccessControl
2296
+ Properties:
2297
+ OriginAccessControlConfig:
2298
+ Name: !Sub "${AWS::StackName}-pii-oac"
2299
+ OriginAccessControlOriginType: lambda
2300
+ SigningBehavior: always
2301
+ SigningProtocol: sigv4
2302
+ OAC
2303
+
2304
+ invoke_permission = <<~PERMISSION
2305
+ PiiLambdaInvokePermission:
2306
+ Type: AWS::Lambda::Permission
2307
+ Properties:
2308
+ Action: lambda:InvokeFunctionUrl
2309
+ FunctionName: !Ref #{fronted_logical_id}
2310
+ Principal: cloudfront.amazonaws.com
2311
+ SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${PiiDistribution}"
2312
+ FunctionUrlAuthType: #{use_oac ? "AWS_IAM" : "NONE"}
2313
+ PERMISSION
2314
+
2315
+ countries_yaml = geo_restriction_countries.map { |code| " - #{code}" }.join("\n")
2316
+
2317
+ reindent.call(<<~RESOURCES)
2318
+ PiiAccessLogsBucket:
2319
+ Type: AWS::S3::Bucket
2320
+ Properties:
2321
+ # `BucketOwnerPreferred`, not the newer `BucketOwnerEnforced`
2322
+ # default — CloudFront's own classic access-log delivery
2323
+ # (`Logging:`, on PiiDistribution below) still authorizes itself
2324
+ # via a canned ACL (`AccessControlTranslation` between accounts is
2325
+ # a distinct, newer mechanism this bucket has no other account to
2326
+ # need), which an ACLs-disabled bucket refuses outright.
2327
+ OwnershipControls:
2328
+ Rules:
2329
+ - ObjectOwnership: BucketOwnerPreferred
2330
+ AccessControl: LogDeliveryWrite
2331
+ LifecycleConfiguration:
2332
+ Rules:
2333
+ - Id: ExpirePiiAccessLogs
2334
+ Status: Enabled
2335
+ ExpirationInDays: 365
2336
+
2337
+ PiiResponseHeadersPolicy:
2338
+ Type: AWS::CloudFront::ResponseHeadersPolicy
2339
+ Properties:
2340
+ ResponseHeadersPolicyConfig:
2341
+ Name: !Sub "${AWS::StackName}-pii-headers"
2342
+ SecurityHeadersConfig:
2343
+ StrictTransportSecurity:
2344
+ AccessControlMaxAgeSec: 63072000
2345
+ IncludeSubdomains: true
2346
+ Override: true
2347
+ ContentTypeOptions:
2348
+ Override: true
2349
+ FrameOptions:
2350
+ FrameOption: DENY
2351
+ Override: true
2352
+ ReferrerPolicy:
2353
+ ReferrerPolicy: same-origin
2354
+ Override: true
2355
+ XSSProtection:
2356
+ ModeBlock: true
2357
+ Protection: true
2358
+ Override: true
2359
+
2360
+ PiiWebAcl:
2361
+ Type: AWS::WAFv2::WebACL
2362
+ Properties:
2363
+ Name: !Sub "${AWS::StackName}-pii-waf"
2364
+ Scope: CLOUDFRONT
2365
+ DefaultAction:
2366
+ Allow: {}
2367
+ VisibilityConfig:
2368
+ SampledRequestsEnabled: true
2369
+ CloudWatchMetricsEnabled: true
2370
+ MetricName: !Sub "${AWS::StackName}PiiWebAcl"
2371
+ Rules:
2372
+ - Name: AWSManagedRulesCommonRuleSet
2373
+ Priority: 0
2374
+ OverrideAction:
2375
+ None: {}
2376
+ Statement:
2377
+ ManagedRuleGroupStatement:
2378
+ VendorName: AWS
2379
+ Name: AWSManagedRulesCommonRuleSet
2380
+ VisibilityConfig:
2381
+ SampledRequestsEnabled: true
2382
+ CloudWatchMetricsEnabled: true
2383
+ MetricName: !Sub "${AWS::StackName}PiiCommonRuleSet"
2384
+ - Name: AWSManagedRulesKnownBadInputsRuleSet
2385
+ Priority: 1
2386
+ OverrideAction:
2387
+ None: {}
2388
+ Statement:
2389
+ ManagedRuleGroupStatement:
2390
+ VendorName: AWS
2391
+ Name: AWSManagedRulesKnownBadInputsRuleSet
2392
+ VisibilityConfig:
2393
+ SampledRequestsEnabled: true
2394
+ CloudWatchMetricsEnabled: true
2395
+ MetricName: !Sub "${AWS::StackName}PiiKnownBadInputs"
2396
+
2397
+ #{origin_access_control}
2398
+ PiiDistribution:
2399
+ Type: AWS::CloudFront::Distribution
2400
+ Properties:
2401
+ DistributionConfig:
2402
+ Enabled: true
2403
+ HttpVersion: http2
2404
+ WebACLId: !GetAtt PiiWebAcl.Arn
2405
+ Restrictions:
2406
+ GeoRestriction:
2407
+ RestrictionType: #{geo_restriction_type}
2408
+ Locations:#{geo_restriction_type == "none" ? " []" : "\n" + countries_yaml}
2409
+ Logging:
2410
+ Bucket: !GetAtt PiiAccessLogsBucket.RegionalDomainName
2411
+ IncludeCookies: false
2412
+ Prefix: cloudfront/
2413
+ Origins:
2414
+ - Id: PiiOrigin
2415
+ # `!Select [2, !Split ["/", ...]]` — the standard AWS-documented
2416
+ # way to pull the bare hostname out of a Function URL's own
2417
+ # `https://<id>.lambda-url.<region>.on.aws/` shape for use as a
2418
+ # CustomOriginConfig DomainName, which admits no scheme or path.
2419
+ DomainName: !Select [2, !Split ["/", !GetAtt #{fronted_logical_id}Url.FunctionUrl]]
2420
+ CustomOriginConfig:
2421
+ OriginProtocolPolicy: https-only
2422
+ OriginSSLProtocols: [TLSv1.2]
2423
+ #{use_oac ? " OriginAccessControlId: !Ref PiiOriginAccessControl" : ""}
2424
+ DefaultCacheBehavior:
2425
+ TargetOriginId: PiiOrigin
2426
+ ViewerProtocolPolicy: redirect-to-https
2427
+ AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
2428
+ CachedMethods: [GET, HEAD]
2429
+ CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
2430
+ OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3
2431
+ ResponseHeadersPolicyId: !Ref PiiResponseHeadersPolicy
2432
+
2433
+ #{invoke_permission}
2434
+ RESOURCES
2435
+ end
2436
+
2437
+ end
2438
+ end
2439
+ end
2440
+ end