hecks 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/framework/bluebook/compliance.bluebook +221 -0
- data/lib/hecks/ports/key_vault.rb +27 -0
- data/lib/hecks/projections/deploy/fargate.rb +666 -0
- data/lib/hecks/projections/deploy/lambda.rb +2423 -0
- data/lib/hecks/projections/deploy/shared.rb +624 -0
- data/lib/hecks/projections.rb +3 -0
- data/lib/hecks/projector/target.rb +18 -5
- data/lib/hecks/projector.rb +31 -17
- data/lib/hecks/version.rb +1 -1
- metadata +4 -1
- data/lib/hecks/framework/bluebook/compliance.bluebook +0 -1
|
@@ -0,0 +1,2423 @@
|
|
|
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
|
|
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
|
+
HECKS_ERA: "1"#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}#{rust_web ? %(\n HECKS_IR_PATH: !Sub "/var/task/#{domain_name}.ir.json") : ""}#{rust_web && google_oauth_present ? <<~RUSTOAUTH.each_line.with_index.map { |l, i| i.zero? ? "\n " + l : " " + l }.join.rstrip : ""}
|
|
845
|
+
# NOT `{{resolve:secretsmanager:...}}` composing
|
|
846
|
+
# GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/SESSION_SECRET
|
|
847
|
+
# directly anymore -- the identical GetFunctionConfiguration
|
|
848
|
+
# exposure DATABASE_URL's own comment above documents
|
|
849
|
+
# applied here too. main.rs's own cold start fetches both
|
|
850
|
+
# secrets itself and `set_var`s the real GOOGLE_CLIENT_ID/
|
|
851
|
+
# GOOGLE_CLIENT_SECRET/SESSION_SECRET keys before anything
|
|
852
|
+
# else runs -- auth.rs/web.rs still read those exact keys,
|
|
853
|
+
# unchanged. GOOGLE_OAUTH_SECRET_ID is a bare NAME (`make
|
|
854
|
+
# sync-google-oauth` owns #{stack_name}-web-google-oauth's
|
|
855
|
+
# whole lifecycle outside CloudFormation, straight from
|
|
856
|
+
# #{domain}'s own gitignored .env.local, never baked into
|
|
857
|
+
# this generated, git-tracked template as plaintext) --
|
|
858
|
+
# GetSecretValue accepts a name directly, no ARN needed.
|
|
859
|
+
GOOGLE_OAUTH_SECRET_ID: #{stack_name}-web-google-oauth
|
|
860
|
+
GOOGLE_REDIRECT_URI: !Sub "${WebRedirectBaseUrl}/auth/google/callback"
|
|
861
|
+
SESSION_SECRET_ARN: !Sub "${#{logical_id}SessionSecret}"
|
|
862
|
+
RUSTOAUTH
|
|
863
|
+
# LEAST-PRIVILEGE -- #{logical_id}'s own execution role is
|
|
864
|
+
# otherwise SAM's bare default (logging only); this is the one
|
|
865
|
+
# runtime AWS permission the DB_SECRET_ARN handling above needs,
|
|
866
|
+
# scoped to the single secret ARN this stack itself depends on,
|
|
867
|
+
# never `Resource: "*"`.
|
|
868
|
+
Policies:
|
|
869
|
+
- Statement:
|
|
870
|
+
- Effect: Allow
|
|
871
|
+
Action: secretsmanager:GetSecretValue
|
|
872
|
+
Resource: !Sub "${#{db_secret_ref}}"
|
|
873
|
+
#{rust_web && google_oauth_present ? <<~OAUTHPOLICY.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join.rstrip : ""}
|
|
874
|
+
- Statement:
|
|
875
|
+
- Effect: Allow
|
|
876
|
+
Action: secretsmanager:GetSecretValue
|
|
877
|
+
# NAME-BASED, not `!Ref`/`!GetAtt` -- this secret is
|
|
878
|
+
# never a stack resource (RUSTOAUTH's own Environment
|
|
879
|
+
# comment, above, has the full story), so there is no
|
|
880
|
+
# exact ARN to point at. The trailing `-*` is AWS's
|
|
881
|
+
# own documented pattern for granting a secret known
|
|
882
|
+
# only by name: every real ARN Secrets Manager mints
|
|
883
|
+
# is this name plus a random 6-character suffix,
|
|
884
|
+
# which can't be predicted at template-render time.
|
|
885
|
+
Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
|
|
886
|
+
- Statement:
|
|
887
|
+
- Effect: Allow
|
|
888
|
+
Action: secretsmanager:GetSecretValue
|
|
889
|
+
Resource: !Sub "${#{logical_id}SessionSecret}"
|
|
890
|
+
OAUTHPOLICY
|
|
891
|
+
# TMPL:cross_domain_lambda_policies
|
|
892
|
+
VpcConfig:
|
|
893
|
+
#{if shared
|
|
894
|
+
"SubnetIds: [!Ref OwningSubnetAId, !Ref OwningSubnetBId]\n SecurityGroupIds: [!Ref OwningSecurityGroupId]"
|
|
895
|
+
else
|
|
896
|
+
"SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]\n SecurityGroupIds: [!Ref #{logical_id}SecurityGroup]"
|
|
897
|
+
end}
|
|
898
|
+
# AWS_IAM by default — this dispatches real domain commands
|
|
899
|
+
# (cap-table/governance-shaped data, for Embryonaut's own use);
|
|
900
|
+
# an unauthenticated public URL is the wrong default for that,
|
|
901
|
+
# even though it's the simpler one to demo with. NONE only when
|
|
902
|
+
# `web "Rust"` (rust/host/src/web.rs) makes this the public web
|
|
903
|
+
# UI itself — safe even though the SAME Function URL also
|
|
904
|
+
# carries the internal {"verb"}/{"read"} dispatch shapes,
|
|
905
|
+
# because a Function-URL HTTP event can never be crafted into
|
|
906
|
+
# that raw top-level shape (Function URLs always wrap the real
|
|
907
|
+
# HTTP body inside requestContext.http/rawPath/event.body,
|
|
908
|
+
# unconditionally); that raw shape is reachable only through
|
|
909
|
+
# the AWS SDK's own IAM-authenticated Invoke API, which never
|
|
910
|
+
# goes through a Function URL at all.
|
|
911
|
+
FunctionUrlConfig:
|
|
912
|
+
AuthType: #{rust_web ? "NONE" : "AWS_IAM"}
|
|
913
|
+
#{web_handler_present ? <<~WEB.each_line.map { |l| " " + l }.join : ""}
|
|
914
|
+
# THE WEB APP'S OWN LAMBDA — opt-in, see `web_handler_present` above.
|
|
915
|
+
# VPC-attached for the SAME reason #{logical_id} is (Member's own
|
|
916
|
+
# persistence needs the private RDS instance directly, permanently —
|
|
917
|
+
# embryonaut.hecksagon's own comment on why), sharing
|
|
918
|
+
# #{logical_id}SecurityGroup rather than minting a second one, since
|
|
919
|
+
# both need the identical egress-to-DB rule. That VPC attachment
|
|
920
|
+
# ALSO cuts off the public internet by default (this stack's own "NO
|
|
921
|
+
# NAT GATEWAY" design) — but this function has to reach
|
|
922
|
+
# #{logical_id} itself via `lambda:InvokeFunction`, a public AWS API
|
|
923
|
+
# call. #{web_logical_id}LambdaEndpoint below is the targeted fix: a
|
|
924
|
+
# VPC Interface Endpoint for the Lambda service ONLY (not a NAT
|
|
925
|
+
# Gateway) — a few dollars a month, not a general internet route.
|
|
926
|
+
#{db_id}LambdaEndpointSecurityGroup:
|
|
927
|
+
Type: AWS::EC2::SecurityGroup
|
|
928
|
+
Properties:
|
|
929
|
+
VpcId: !Ref #{db_id}Vpc
|
|
930
|
+
# EC2's own GroupDescription character set excludes apostrophes
|
|
931
|
+
# too, not just em-dashes -- "this stack's own" was a real,
|
|
932
|
+
# live rejection ("Invalid security group description").
|
|
933
|
+
GroupDescription: #{db_id}LambdaEndpoint - HTTPS from this stacks own Lambdas only
|
|
934
|
+
|
|
935
|
+
#{db_id}LambdaEndpointIngress:
|
|
936
|
+
Type: AWS::EC2::SecurityGroupIngress
|
|
937
|
+
Properties:
|
|
938
|
+
GroupId: !Ref #{db_id}LambdaEndpointSecurityGroup
|
|
939
|
+
IpProtocol: tcp
|
|
940
|
+
FromPort: 443
|
|
941
|
+
ToPort: 443
|
|
942
|
+
SourceSecurityGroupId: !Ref #{logical_id}SecurityGroup
|
|
943
|
+
|
|
944
|
+
#{db_id}LambdaEndpoint:
|
|
945
|
+
Type: AWS::EC2::VPCEndpoint
|
|
946
|
+
Properties:
|
|
947
|
+
VpcId: !Ref #{db_id}Vpc
|
|
948
|
+
ServiceName: !Sub "com.amazonaws.${AWS::Region}.lambda"
|
|
949
|
+
VpcEndpointType: Interface
|
|
950
|
+
PrivateDnsEnabled: true
|
|
951
|
+
SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]
|
|
952
|
+
SecurityGroupIds: [!Ref #{db_id}LambdaEndpointSecurityGroup]
|
|
953
|
+
|
|
954
|
+
# Auto-generated, never typed or seen — the SAME
|
|
955
|
+
# ManageMasterUserPassword pattern #{db_id} already uses for its own
|
|
956
|
+
# password, applied to Sinatra's own session-signing secret instead.
|
|
957
|
+
#{web_logical_id}SessionSecret:
|
|
958
|
+
Type: AWS::SecretsManager::Secret
|
|
959
|
+
Properties:
|
|
960
|
+
GenerateSecretString:
|
|
961
|
+
SecretStringTemplate: '{}'
|
|
962
|
+
GenerateStringKey: session_secret
|
|
963
|
+
PasswordLength: 64
|
|
964
|
+
ExcludePunctuation: true
|
|
965
|
+
|
|
966
|
+
#{webhook_secret_env ? <<-WEBHOOKSECRET : ""}
|
|
967
|
+
# Auto-generated, never typed or seen — the SAME
|
|
968
|
+
# ManageMasterUserPassword/SessionSecret pattern above, applied to
|
|
969
|
+
# `secret_env`'s own webhook secret instead (deploy_settings' own
|
|
970
|
+
# `secret_env`, read far above). #{web_logical_id}'s own
|
|
971
|
+
# `lambda_handler.rb` fetches this by ARN at cold start (the
|
|
972
|
+
# `#{webhook_secret_env}_ARN` Environment variable below) and exposes
|
|
973
|
+
# its plaintext under `ENV["#{webhook_secret_env}"]` itself — the
|
|
974
|
+
# SAME two-step indirection DB_SECRET_ARN/SESSION_SECRET_ARN already
|
|
975
|
+
# use, and for the identical reason (this Environment block's own
|
|
976
|
+
# header comment on why a resolved plaintext must never land in
|
|
977
|
+
# Lambda's own configuration).
|
|
978
|
+
#{web_logical_id}WebhookSecret:
|
|
979
|
+
Type: AWS::SecretsManager::Secret
|
|
980
|
+
Properties:
|
|
981
|
+
GenerateSecretString:
|
|
982
|
+
SecretStringTemplate: '{}'
|
|
983
|
+
GenerateStringKey: value
|
|
984
|
+
PasswordLength: 64
|
|
985
|
+
ExcludePunctuation: true
|
|
986
|
+
|
|
987
|
+
WEBHOOKSECRET
|
|
988
|
+
#{web_logical_id}:
|
|
989
|
+
Type: AWS::Serverless::Function
|
|
990
|
+
# NO Metadata.BuildMethod — "bundler" isn't a real SAM value (a
|
|
991
|
+
# real, live UnsupportedBuilderException caught this); `Runtime:
|
|
992
|
+
# ruby3.2` alone is enough for SAM to select its own built-in Ruby
|
|
993
|
+
# bundler workflow automatically. #{logical_id}'s own `BuildMethod:
|
|
994
|
+
# makefile` above is different: rust/host isn't a runtime SAM
|
|
995
|
+
# knows how to build at all, so it needs an explicit custom
|
|
996
|
+
# workflow — this function doesn't. STAYS `ruby3.2` deliberately,
|
|
997
|
+
# even though `sam validate --lint` now flags it deprecated
|
|
998
|
+
# (2026-03-31, creation disabled 2027-02-01, still comfortably
|
|
999
|
+
# ahead of today) — `patch-pg-native`'s own build-ruby3.2 container
|
|
1000
|
+
# image and `lib/3.2/pg_ext.so` path glob (this Makefile, below)
|
|
1001
|
+
# are BOTH hardcoded to this exact runtime; bumping Runtime alone
|
|
1002
|
+
# without reworking that whole from-source pg build for a newer
|
|
1003
|
+
# image is a real, separate undertaking (untested container image
|
|
1004
|
+
# availability, a different vendor/bundle native-extension path)
|
|
1005
|
+
# out of scope here. Matches Embryonaut's own live, currently-
|
|
1006
|
+
# deployed choice exactly, so both consumers of this one shared
|
|
1007
|
+
# WebFunction code path stay on the same, PROVEN runtime.
|
|
1008
|
+
Properties:
|
|
1009
|
+
FunctionName: #{stack_name}-web
|
|
1010
|
+
Runtime: ruby3.2
|
|
1011
|
+
Architectures: [arm64]
|
|
1012
|
+
# THREE parts (file.Class.method), not two — aws-lambda-ric's own
|
|
1013
|
+
# 2-part form calls a plain TOP-LEVEL function via __send__, not
|
|
1014
|
+
# a module method; #{web_handler_relpath}.rb's own comment on
|
|
1015
|
+
# why its module is named the way it is explains the rest — a
|
|
1016
|
+
# real, live "LambdaHandler is not a module" caught both halves
|
|
1017
|
+
# of this the hard way. `web_handler_relpath` (not a bare
|
|
1018
|
+
# "lambda_handler") whenever `dispatch "None"` moved CodeUri off
|
|
1019
|
+
# #{domain} itself — see that variable's own comment, above.
|
|
1020
|
+
Handler: #{web_handler_relpath}.#{webhook_handler_module}.lambda_handler
|
|
1021
|
+
CodeUri: #{web_code_uri}
|
|
1022
|
+
MemorySize: 512
|
|
1023
|
+
Timeout: 15
|
|
1024
|
+
Environment:
|
|
1025
|
+
Variables:
|
|
1026
|
+
DOMAIN_ROOT: /var/task
|
|
1027
|
+
# NOT `declared_domain_name` — `Adapters::Lambda`/
|
|
1028
|
+
# `RemoteDispatcher` both read this to compute WHICH
|
|
1029
|
+
# deployed Lambda function to invoke (`hecks-\#{...}`,
|
|
1030
|
+
# matching this stack's own name), a question `infra_name`
|
|
1031
|
+
# answers and a domain's own business identity does not.
|
|
1032
|
+
# Real, live, and confirmed the hard way: this was still
|
|
1033
|
+
# wired to `declared_domain_name` when EmbryonautFoundersApp
|
|
1034
|
+
# first deployed, and WebFunction immediately threw
|
|
1035
|
+
# `ResourceNotFoundException: Function not found:
|
|
1036
|
+
# hecks-embryonautfoundersapp` on every read AND on the
|
|
1037
|
+
# Google OAuth callback itself — a real signed-in user hit
|
|
1038
|
+
# this within minutes of the deploy that introduced it.
|
|
1039
|
+
DOMAIN_NAME: #{infra_name}
|
|
1040
|
+
HECKS_LAMBDA_ROUTING: "true"
|
|
1041
|
+
HECKS_LAMBDA_REGION: #{region}#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}
|
|
1042
|
+
# SAME setting #{domain}'s own fly.toml/Dockerfile already
|
|
1043
|
+
# sets for the Fly deployment this one replaces — Sinatra's
|
|
1044
|
+
# own `host_authorization` default is only PERMISSIVE in
|
|
1045
|
+
# production mode (an empty `permitted_hosts` list means
|
|
1046
|
+
# "allow any host" — checked directly in rack-protection's
|
|
1047
|
+
# own source); its DEVELOPMENT default is the restrictive
|
|
1048
|
+
# one (only localhost/.test), which is what Sinatra falls
|
|
1049
|
+
# back to when NEITHER RACK_ENV nor APP_ENV is set. A real,
|
|
1050
|
+
# live "Host not permitted" 403 against this function's own
|
|
1051
|
+
# unpredictable *.lambda-url.*.on.aws hostname caught the
|
|
1052
|
+
# gap — this function's Environment never set either var.
|
|
1053
|
+
RACK_ENV: production
|
|
1054
|
+
# The precompiled `pg` gem's aarch64-linux native extension
|
|
1055
|
+
# needs GLIBC 2.29+; Lambda's ruby3.2 MANAGED runtime is
|
|
1056
|
+
# Amazon Linux 2 (glibc 2.26) -- a real, live "Init<NameError>:
|
|
1057
|
+
# uninitialized constant PG::Error" caught this (rescuing
|
|
1058
|
+
# PG::Error itself failed to resolve, because pg's own
|
|
1059
|
+
# require died before reaching the file that defines it).
|
|
1060
|
+
# `make deploy`'s own `patch-pg-native` step (this directory's
|
|
1061
|
+
# Makefile) replaces the broken precompiled extension with one
|
|
1062
|
+
# compiled from source, in-container, against a real
|
|
1063
|
+
# SSL-enabled libpq -- and drops that libpq's .so beside it at
|
|
1064
|
+
# /var/task/lib, found by the dynamic loader with NO explicit
|
|
1065
|
+
# LD_LIBRARY_PATH override needed: the base ruby3.2 image's
|
|
1066
|
+
# own baked-in default (`docker inspect
|
|
1067
|
+
# public.ecr.aws/lambda/ruby:3.2-arm64`) is already
|
|
1068
|
+
# "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib"
|
|
1069
|
+
# -- setting LD_LIBRARY_PATH here as a Lambda env var
|
|
1070
|
+
# REPLACES that whole list rather than extending it (a real,
|
|
1071
|
+
# live gotcha: Lambda env vars always fully override a
|
|
1072
|
+
# same-named baked-in image env, never merge with it), which
|
|
1073
|
+
# is exactly what broke Ruby's OWN interpreter startup
|
|
1074
|
+
# ("libcrypt.so.1: cannot open shared object file") the one
|
|
1075
|
+
# time this was set explicitly, before libpq.so.5.16 itself
|
|
1076
|
+
# ever got a chance to matter.
|
|
1077
|
+
# Member's own permanent Postgres connection — the SAME RDS
|
|
1078
|
+
# instance #{logical_id} itself writes rust/host's flat
|
|
1079
|
+
# journal into, different database engine role (Ruby's own
|
|
1080
|
+
# era/lineage schema, not rust/host's), never the real
|
|
1081
|
+
# Fly-hosted Postgres this whole effort is retiring away from.
|
|
1082
|
+
#
|
|
1083
|
+
# GENERATOR-ONLY FIX, NOT YET SAFE TO DEPLOY — DB_HOST/DB_NAME/
|
|
1084
|
+
# DB_SECRET_ARN/SESSION_SECRET_ARN/GOOGLE_OAUTH_SECRET_ID below
|
|
1085
|
+
# replace the SAME `{{resolve:secretsmanager:...}}`-into-
|
|
1086
|
+
# Environment.Variables shape rust/host's own main.rs (this
|
|
1087
|
+
# template's own #{logical_id}) used to have -- readable in
|
|
1088
|
+
# plaintext by any read-only account principal via
|
|
1089
|
+
# lambda:GetFunctionConfiguration, the exact exposure
|
|
1090
|
+
# #{logical_id}'s own comment above the Policies section
|
|
1091
|
+
# documents. But THIS function's consumer (`lambda_handler.rb`)
|
|
1092
|
+
# is not a file this generator writes or this repo carries --
|
|
1093
|
+
# it lives in the deploying app's own domain directory (real,
|
|
1094
|
+
# live example: Embryonaut's own Sinatra app), outside this
|
|
1095
|
+
# codebase entirely. Redeploying a domain with an existing
|
|
1096
|
+
# `lambda_handler.rb` BEFORE that handler is updated to fetch
|
|
1097
|
+
# these itself (`aws-sdk-secretsmanager`, not yet in this
|
|
1098
|
+
# project's own Gemfile) breaks it: it still expects
|
|
1099
|
+
# `ENV["DATABASE_URL"]`/`ENV["SESSION_SECRET"]`/
|
|
1100
|
+
# `ENV["GOOGLE_CLIENT_ID"]`/`ENV["GOOGLE_CLIENT_SECRET"]`
|
|
1101
|
+
# already resolved, and none of those keys exist below anymore.
|
|
1102
|
+
DB_HOST: !GetAtt #{db_ref_id}.Endpoint.Address
|
|
1103
|
+
DB_NAME: #{db_name}
|
|
1104
|
+
DB_SECRET_ARN: !Sub "${#{secret_sub}}"
|
|
1105
|
+
SESSION_SECRET_ARN: !Sub "${#{web_logical_id}SessionSecret}"#{webhook_secret_env ? %(\n #{webhook_secret_env}_ARN: !Sub "${#{web_logical_id}WebhookSecret}") : ""}
|
|
1106
|
+
#{web_google_oauth_env_yaml}
|
|
1107
|
+
VpcConfig:
|
|
1108
|
+
SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]
|
|
1109
|
+
SecurityGroupIds: [!Ref #{logical_id}SecurityGroup]
|
|
1110
|
+
# LEAST-PRIVILEGE, scoped to #{logical_id}'s own ARN specifically
|
|
1111
|
+
# (SAM's own LambdaInvokePolicy template) — WebFunctionRole is
|
|
1112
|
+
# otherwise SAM's bare default execution role (logging only), and
|
|
1113
|
+
# had NO lambda:InvokeFunction permission at all until this: a
|
|
1114
|
+
# real, live AccessDeniedException caught it, the same request
|
|
1115
|
+
# that also caught the wrong-function-name bug just above
|
|
1116
|
+
# (WebFunctionRole is not authorized... on resource
|
|
1117
|
+
# hecks-task — this stack has never had ANY policy granting
|
|
1118
|
+
# that action, so the fix isn't complete without adding one).
|
|
1119
|
+
# THE SAME secretsmanager:GetSecretValue GRANTS #{logical_id}'s
|
|
1120
|
+
# own Policies section (above) needed, once this function's own
|
|
1121
|
+
# `lambda_handler.rb` fetches DB_SECRET_ARN/SESSION_SECRET_ARN/
|
|
1122
|
+
# GOOGLE_OAUTH_SECRET_ID itself instead of relying on a resolved
|
|
1123
|
+
# Environment.Variables value -- see this Environment block's own
|
|
1124
|
+
# comment for why that consumer-side change isn't in this repo.
|
|
1125
|
+
Policies:
|
|
1126
|
+
- LambdaInvokePolicy:
|
|
1127
|
+
FunctionName: !Ref #{logical_id}
|
|
1128
|
+
- Statement:
|
|
1129
|
+
- Effect: Allow
|
|
1130
|
+
Action: secretsmanager:GetSecretValue
|
|
1131
|
+
Resource: !Sub "${#{secret_sub}}"
|
|
1132
|
+
- Statement:
|
|
1133
|
+
- Effect: Allow
|
|
1134
|
+
Action: secretsmanager:GetSecretValue
|
|
1135
|
+
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}") : ""}
|
|
1136
|
+
#{google_oauth_present ? <<~WEBOAUTHPOLICY.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join.rstrip : ""}
|
|
1137
|
+
- Statement:
|
|
1138
|
+
- Effect: Allow
|
|
1139
|
+
Action: secretsmanager:GetSecretValue
|
|
1140
|
+
Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
|
|
1141
|
+
WEBOAUTHPOLICY
|
|
1142
|
+
# NONE, not AWS_IAM — real users need to reach sign-in over
|
|
1143
|
+
# plain HTTPS, unlike #{logical_id}'s own AWS_IAM-protected URL
|
|
1144
|
+
# (this function is the ONLY thing meant to call that one).
|
|
1145
|
+
FunctionUrlConfig:
|
|
1146
|
+
AuthType: NONE
|
|
1147
|
+
WEB
|
|
1148
|
+
|
|
1149
|
+
Outputs:
|
|
1150
|
+
FunctionUrl:
|
|
1151
|
+
Value: !GetAtt #{logical_id}Url.FunctionUrl#{web_handler_present ? "\n WebFunctionUrl:\n Value: !GetAtt #{web_logical_id}Url.FunctionUrl" : ""}
|
|
1152
|
+
# stack_outputs (bin/project_deploy, above the heredocs) is the ONE
|
|
1153
|
+
# place these four names live — DatabaseEndpoint/DatabaseSecretArn
|
|
1154
|
+
# feed `make mint-era`'s own boot step directly; VpcId/
|
|
1155
|
+
# DbSecurityGroupId ALSO feed bastion.yaml's own Parameters (a
|
|
1156
|
+
# separate, sibling stack, created only for the few minutes a mint
|
|
1157
|
+
# takes and destroyed right after, attaching into the SAME private
|
|
1158
|
+
# network without this main template ever knowing a bastion
|
|
1159
|
+
# exists) and the Makefile's own --parameter-overrides. Rename a
|
|
1160
|
+
# key in that table and every consumer follows; reference one that
|
|
1161
|
+
# doesn't exist there and bin/project_deploy refuses before
|
|
1162
|
+
# writing a single file, instead of a bastion.yaml whose Parameter
|
|
1163
|
+
# nothing could ever fill. (`PrivateSubnetId` used to sit here too
|
|
1164
|
+
# — dropped, nothing ever consumed it; bastion.yaml mints its own
|
|
1165
|
+
# subnet instead.)
|
|
1166
|
+
#{stack_outputs.map { |o| "#{o[:key]}:\n Value: #{o[:ref]}" }.join("\n ")}
|
|
1167
|
+
YAML
|
|
1168
|
+
|
|
1169
|
+
# Spliced in after the heredoc renders, not interpolated inside it —
|
|
1170
|
+
# found live, the hard way: `<<~`'s own dedent strips whatever the
|
|
1171
|
+
# shallowest leading-whitespace line in the raw source text has, computed
|
|
1172
|
+
# before any `#{...}` interpolation runs. `cross_domain_lambda_policies`
|
|
1173
|
+
# is "" for the overwhelmingly common case (no cross-domain policies at
|
|
1174
|
+
# all), and a `#{...}` marker written at column 0 in the source (so it
|
|
1175
|
+
# renders flush when empty) is itself a zero-indent line by that same
|
|
1176
|
+
# raw-text measure — dragging the whole heredoc's computed dedent down to
|
|
1177
|
+
# zero and leaving every other line's original leading whitespace
|
|
1178
|
+
# un-stripped, a real, generated-then-caught-by-spec bug (`bin/
|
|
1179
|
+
# project_deploy`'s own contract spec: "Outputs only declares []" — not
|
|
1180
|
+
# actually empty, just re-indented into structural nonsense by this).
|
|
1181
|
+
# A plain, unconditional `String#sub` after the fact has no such
|
|
1182
|
+
# interaction with the text it's replacing into.
|
|
1183
|
+
template_yaml = template_yaml.sub(/^([ \t]*)# TMPL:cross_domain_lambda_policies\n/) { Shared.cross_domain_invoke_policy_yaml(cross_domain_lambda_targets, $1) }
|
|
1184
|
+
|
|
1185
|
+
# **The `PII` → CloudFront splice** — operates on the already-fully-rendered
|
|
1186
|
+
# string, the same reason the `dispatch_none` splice (below) does rather
|
|
1187
|
+
# than threading a third reindentation layer through the main heredoc
|
|
1188
|
+
# above: `pii_cloudfront_yaml` builds its own already-correctly-indented
|
|
1189
|
+
# text from scratch (its own `reindent` lambda), so there is nothing here
|
|
1190
|
+
# for a heredoc dedent computation to interact with badly.
|
|
1191
|
+
# `fronted_logical_id`/`use_oac` are resolved here, not earlier, because
|
|
1192
|
+
# both need `web_handler_present`/`web_logical_id`/`rust_web`/`logical_id`
|
|
1193
|
+
# — every one of which is computed after this generator's own pii
|
|
1194
|
+
# detection, above.
|
|
1195
|
+
if pii_detected
|
|
1196
|
+
fronted_logical_id = web_handler_present ? web_logical_id : logical_id
|
|
1197
|
+
use_oac = !web_handler_present && !rust_web
|
|
1198
|
+
|
|
1199
|
+
pii_resources = pii_cloudfront_yaml(
|
|
1200
|
+
fronted_logical_id: fronted_logical_id, use_oac: use_oac,
|
|
1201
|
+
geo_restriction_type: geo_restriction_type, geo_restriction_countries: geo_restriction_countries
|
|
1202
|
+
)
|
|
1203
|
+
template_yaml = template_yaml.sub(/^Outputs:\n/) { "#{pii_resources}Outputs:\n PiiDistributionDomainName:\n Value: !GetAtt PiiDistribution.DomainName\n" }
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
# `dispatch "None"` — surgical removal, POST-render, rather than a
|
|
1207
|
+
# fourth reindentation layer threaded through the heredoc above.
|
|
1208
|
+
# Tried that first: wrapping `#{logical_id}`'s own resource block in a
|
|
1209
|
+
# conditional nested heredoc (matching `RUSTSECRET`/`WEB`/`OWNDB`'s own
|
|
1210
|
+
# established `<<~TAG.each_line.with_index.map { ... }` convention)
|
|
1211
|
+
# looked right, but broke several pre-existing embedded multi-line
|
|
1212
|
+
# `#{shared ? "a\nb" : "c"}`-shaped strings already living inside that
|
|
1213
|
+
# block (the DB_HOST/DB_NAME/DB_SECRET_ARN Environment lines among
|
|
1214
|
+
# them) — those assume they sit at exactly one reindentation layer deep
|
|
1215
|
+
# (the outer template heredoc's own margin), and adding a second layer
|
|
1216
|
+
# on top shifted every line their own hardcoded embedded-newline
|
|
1217
|
+
# indentation didn't already account for. Confirmed live, diffing a
|
|
1218
|
+
# real regenerated examples/banking (`dispatch` unset — the ordinary,
|
|
1219
|
+
# far more common path) against its own git-tracked output: real
|
|
1220
|
+
# indentation corruption, not a false alarm. Operating on the already-
|
|
1221
|
+
# fully-rendered, already-correctly-indented string instead sidesteps
|
|
1222
|
+
# that whole class of interaction — no new heredoc layer, so nothing
|
|
1223
|
+
# already living inside the old one has to change its own assumptions.
|
|
1224
|
+
if dispatch_none
|
|
1225
|
+
# The #{logical_id} resource itself — non-greedy through its own
|
|
1226
|
+
# `FunctionUrlConfig`/`AuthType` closing pair (the actual last
|
|
1227
|
+
# property this resource ever renders, confirmed above), so a second
|
|
1228
|
+
# occurrence of "AuthType:" later in the document (WebFunction's own,
|
|
1229
|
+
# always AuthType: `NONE`) can never be matched instead.
|
|
1230
|
+
template_yaml = template_yaml.sub(
|
|
1231
|
+
/^ #{Regexp.escape(logical_id)}:\n.*?\n AuthType: (?:NONE|AWS_IAM)\n/m, ""
|
|
1232
|
+
)
|
|
1233
|
+
# WebFunction's own Policies — `LambdaInvokePolicy: FunctionName: !Ref
|
|
1234
|
+
# #{logical_id}` grants invoking a function that, above, was just
|
|
1235
|
+
# removed from this template entirely; SAM refuses a `!Ref` to an
|
|
1236
|
+
# undeclared resource at package time, not silently.
|
|
1237
|
+
template_yaml = template_yaml.sub(
|
|
1238
|
+
/^ - LambdaInvokePolicy:\n FunctionName: !Ref #{Regexp.escape(logical_id)}\n/, ""
|
|
1239
|
+
)
|
|
1240
|
+
# Outputs — no `#{logical_id}Url` exists any more for `FunctionUrl` to
|
|
1241
|
+
# `!GetAtt`; WebFunction's own URL becomes the stack's one and only
|
|
1242
|
+
# "FunctionUrl" (never "WebFunctionUrl" — that name is reserved for
|
|
1243
|
+
# the two-URL case, a domain that also has a #{logical_id} of its
|
|
1244
|
+
# own to disambiguate from, which this one, by construction, never
|
|
1245
|
+
# does).
|
|
1246
|
+
template_yaml = template_yaml.sub(
|
|
1247
|
+
/^ FunctionUrl:\n Value: !GetAtt #{Regexp.escape(logical_id)}Url\.FunctionUrl\n WebFunctionUrl:\n Value: !GetAtt #{Regexp.escape(web_logical_id)}Url\.FunctionUrl\n/,
|
|
1248
|
+
" FunctionUrl:\n Value: !GetAtt #{web_logical_id}Url.FunctionUrl\n"
|
|
1249
|
+
)
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
# **The ephemeral era-minting bastion** — a separate, sibling stack
|
|
1255
|
+
# (`#{stack_name}-bastion`), never merged into template.yaml itself.
|
|
1256
|
+
# The main stack stays exactly as minimal as its own header already
|
|
1257
|
+
# commits to (no NAT gateway, no bastion sitting there costing money
|
|
1258
|
+
# by default) — this template only ever exists for the few minutes
|
|
1259
|
+
# `make mint-era` (below) needs it, then gets deleted. SSM Session
|
|
1260
|
+
# Manager only, never SSH: no key pair, no inbound security group rule
|
|
1261
|
+
# at all (the instance's own SG declares zero Ingress) — the only way
|
|
1262
|
+
# in is `aws ssm start-session`, itself gated by the caller's own IAM
|
|
1263
|
+
# permissions, nothing this template opens to the internet.
|
|
1264
|
+
# Skipped entirely when `shared` — this domain provisions no RDS/VPC
|
|
1265
|
+
# of its own for a bastion to reach (bastion_parameters is empty for
|
|
1266
|
+
# the same reason, above); a bare `!Ref VpcId` with no declared
|
|
1267
|
+
# Parameter would just fail at deploy time. Era-minting for a
|
|
1268
|
+
# Shared-mode domain reuses its owner's own already-standing
|
|
1269
|
+
# infrastructure instead — see the Makefile's own `mint-era` comment
|
|
1270
|
+
# for the manual path this leaves until that's automated too.
|
|
1271
|
+
bastion_yaml = shared ? nil : Shared.bastion_yaml(
|
|
1272
|
+
domain: domain, infra_name: infra_name, stack_name: stack_name, db_id: db_id,
|
|
1273
|
+
google_oauth_present: google_oauth_present, bastion_parameters: bastion_parameters
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
# `mint-era`'s own recipe body — a top-level variable, not inlined at
|
|
1277
|
+
# its call site inside the Makefile heredoc below, on purpose: Make
|
|
1278
|
+
# recipe lines need a literal tab as their true first character (no
|
|
1279
|
+
# leading spaces at all), and nesting this heredoc a second level
|
|
1280
|
+
# deeper inside another `#{...}` interpolation (the way `OWNDB`/params
|
|
1281
|
+
# above handle conditional template.yaml content) would need its own
|
|
1282
|
+
# per-line re-indent pass — one that adds spaces before every line
|
|
1283
|
+
# including the already-tab-prefixed recipe ones, corrupting Make's
|
|
1284
|
+
# own recipe-line detection. A single top-level heredoc, each line
|
|
1285
|
+
# already at its own final indentation (recipe lines as " \t..." —
|
|
1286
|
+
# the same two-space-before-the-tab convention every other recipe
|
|
1287
|
+
# line in this Makefile already uses, stripped by the encolosing
|
|
1288
|
+
# `<<~MAKE` heredoc's own squiggly stripping below, same as them),
|
|
1289
|
+
# sidesteps that: nothing re-touches an already-correct line twice.
|
|
1290
|
+
mint_era_recipe =
|
|
1291
|
+
if shared
|
|
1292
|
+
<<~SHAREDMINT.rstrip
|
|
1293
|
+
# NOT AUTOMATED YET for a Shared-mode domain — it has no RDS/VPC
|
|
1294
|
+
# of its own to stand a temporary bastion next to (bastion.yaml
|
|
1295
|
+
# itself isn't even generated here — bin/project_deploy's own
|
|
1296
|
+
# comment on why). Minting era 1 for #{infra_name}'s own schema
|
|
1297
|
+
# needs a tunnel to #{owner_domain_name}'s ALREADY-EXISTING RDS
|
|
1298
|
+
# instance instead -- reuse #{owner_domain_name}'s own deploy
|
|
1299
|
+
# directory's `make mint-era` machinery (or an already-open
|
|
1300
|
+
# tunnel to it) to run this domain's own boot against
|
|
1301
|
+
# `postgres://...@<tunnel-host>:<port>/#{owner_db_name}` with
|
|
1302
|
+
# `schema: #{infra_name.inspect}` in LineageManager.check!'s own
|
|
1303
|
+
# settings -- the exact same call this target runs automatically
|
|
1304
|
+
# for a domain with its own dedicated instance.
|
|
1305
|
+
#
|
|
1306
|
+
# IF THIS DOMAIN VENDORS/ATTACHES ANOTHER BLUEBOOK (uses_embryonaut_
|
|
1307
|
+
# bluebook, uses_framework), `check!` alone leaves THAT chapter's own
|
|
1308
|
+
# aggregates with no snapshot table at all -- it only provisions the
|
|
1309
|
+
# ONE bluebook it's called with (era_resolver.rb's own `bluebook.
|
|
1310
|
+
# aggregates.each`), never the whole registry. Found live minting
|
|
1311
|
+
# lifeadelics' own era: `Payments::Payment.Initiate` refused with
|
|
1312
|
+
# "relation \\"payment_head_snapshot_1\\" does not exist" the first
|
|
1313
|
+
# time this domain's own checkout route ran for real. The automated
|
|
1314
|
+
# (non-Shared) recipe below now does this correctly for every OTHER
|
|
1315
|
+
# loaded bluebook, using ONE `Lineage` keyed by THIS domain's own
|
|
1316
|
+
# name (never each chapter's own -- a vendored chapter's aggregates
|
|
1317
|
+
# share THIS domain's single `hecks_journal_#{infra_name}` and
|
|
1318
|
+
# snapshot tables, they do not get a separate journal of their own).
|
|
1319
|
+
# Reproduce the same shape manually here: after `LineageManager.
|
|
1320
|
+
# check!` returns, `db = PostgresEra.connect_for(bluebook.name,
|
|
1321
|
+
# settings)`, `lineage = Lineage.new(db, bluebook.name)`, then for
|
|
1322
|
+
# every OTHER loaded bluebook's own aggregates, `lineage.
|
|
1323
|
+
# ensure_first_head!(aggregate.storage_name)`.
|
|
1324
|
+
#
|
|
1325
|
+
# EXIT 0, NOT 1 -- `deploy:`'s own last line (below) always chains
|
|
1326
|
+
# `$(MAKE) mint-era` unconditionally, even for a Shared-mode domain,
|
|
1327
|
+
# so a nonzero exit here used to mean a fully successful `sam
|
|
1328
|
+
# deploy` still left `make deploy` exiting nonzero -- confirmed
|
|
1329
|
+
# live: CI and any scripted caller read every Shared-mode deploy as
|
|
1330
|
+
# failed, which trains operators to ignore a red `make deploy` on
|
|
1331
|
+
# exactly the domains where a real failure most needs to stand out.
|
|
1332
|
+
# This step never fails at its own job (it has no automated job to
|
|
1333
|
+
# fail at -- it only reports that a manual step remains), so it
|
|
1334
|
+
# reports success and leaves the manual-step reminder in the echo
|
|
1335
|
+
# text above, not in the exit code. A human running `make mint-era`
|
|
1336
|
+
# directly still SEES the same message; they just don't get a
|
|
1337
|
+
# misleading "command failed" on top of it either.
|
|
1338
|
+
\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."; \\
|
|
1339
|
+
\texit 0
|
|
1340
|
+
SHAREDMINT
|
|
1341
|
+
else
|
|
1342
|
+
<<~OWNMINT.rstrip
|
|
1343
|
+
\t@echo "Looking up $(STACK)'s VPC/security group..."
|
|
1344
|
+
# stack_outputs/bastion_parameters (bin/project_deploy) are the ONE
|
|
1345
|
+
# place these OutputKey strings and parameter names live — this eval
|
|
1346
|
+
# chain and the --parameter-overrides line just below are both
|
|
1347
|
+
# generated from the SAME table template.yaml's Outputs and
|
|
1348
|
+
# bastion.yaml's Parameters already read from.
|
|
1349
|
+
\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")}
|
|
1350
|
+
\t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
|
|
1351
|
+
\taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
|
|
1352
|
+
\t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
|
|
1353
|
+
\t\t--capabilities CAPABILITY_IAM
|
|
1354
|
+
# ONE continuous shell invocation from here on (every line ends in
|
|
1355
|
+
# \\, joining it to the next) — INSTANCE_ID is a SHELL variable, not
|
|
1356
|
+
# a Make one: a Make-level $$(eval $$(shell ...)) expands at parse
|
|
1357
|
+
# time, BEFORE the `aws cloudformation deploy` line above ever runs,
|
|
1358
|
+
# which would make INSTANCE_ID permanently empty. It can only be
|
|
1359
|
+
# computed here, after the bastion stack genuinely exists.
|
|
1360
|
+
#
|
|
1361
|
+
# A COMMENT LINE MUST NEVER SIT BETWEEN TWO \\-CONTINUED RECIPE
|
|
1362
|
+
# LINES — confirmed live, the hard way: a `#`-prefixed line inserted
|
|
1363
|
+
# between `DB_PASS=...; \\` and the line that used it silently
|
|
1364
|
+
# corrupted Make's own assembly of this whole block into one shell
|
|
1365
|
+
# script, and `DB_PASS` came out empty on the far side despite
|
|
1366
|
+
# printing correctly one line earlier. Every explanatory comment for
|
|
1367
|
+
# this whole chain belongs HERE, before it starts, same as this one —
|
|
1368
|
+
# never spliced into the middle of it.
|
|
1369
|
+
#
|
|
1370
|
+
# BASTION TEARDOWN IS PART OF THIS SAME CHAIN, ALL THE WAY THROUGH —
|
|
1371
|
+
# a prior version of this recipe ended at `exit $$BOOT_STATUS` with
|
|
1372
|
+
# no trailing `\\`, which made Make treat the delete-stack lines
|
|
1373
|
+
# after it as a SEPARATE recipe line, reached only on a zero exit.
|
|
1374
|
+
# A failed boot (bad tunnel, bad `ruby -e` invocation, anything) left
|
|
1375
|
+
# the bastion stack standing forever — including a live 5432 ingress
|
|
1376
|
+
# rule punched into the PRODUCTION RDS security group. Teardown now
|
|
1377
|
+
# runs unconditionally, with BOOT_STATUS captured up front and
|
|
1378
|
+
# returned last, so `make mint-era`'s own exit code still reflects
|
|
1379
|
+
# whether era 1 actually got minted.
|
|
1380
|
+
#
|
|
1381
|
+
# `LineageManager.check!` directly below, NOT `Hecks.boot` — also
|
|
1382
|
+
# confirmed live (the very first real invoke: "relation
|
|
1383
|
+
# \\"hecks_eras\\" does not exist", despite this step itself
|
|
1384
|
+
# reporting success): `Hecks.boot` always dispatches persistence
|
|
1385
|
+
# through whatever the domain's OWN .world file declares
|
|
1386
|
+
# (`persisted_by("Heki")` for a domain that hasn't opted into
|
|
1387
|
+
# Postgres for local dev), by EXPLICIT design — loader.rb's own
|
|
1388
|
+
# comment: "persisted_by(\\"PostgresEra\\") is never inferred from
|
|
1389
|
+
# anything else." `EraCheck.check!` (what `Hecks.boot` runs) is
|
|
1390
|
+
# itself adapter-capability-gated on that same declaration and simply
|
|
1391
|
+
# no-ops for a non-lineage-capable adapter — it never touches
|
|
1392
|
+
# hecks_eras at all. The fix reuses the SAME loading pipeline
|
|
1393
|
+
# `Loader.boot` itself does (`Ports::Loading.bootstrap`/
|
|
1394
|
+
# `load_library`/`load_project`/`load_domain`) to build a real,
|
|
1395
|
+
# fully-loaded registry (bluebook + any uses_framework attachments),
|
|
1396
|
+
# then calls straight into PostgresEra's own `LineageManager.check!`
|
|
1397
|
+
# with THIS deploy's actual `DATABASE_URL` — bypassing the world
|
|
1398
|
+
# file's own adapter choice entirely, the same targeted pattern
|
|
1399
|
+
# spec/adapters/driven/postgres_era/lineage_spec.rb's own `check!` helper
|
|
1400
|
+
# already uses.
|
|
1401
|
+
#
|
|
1402
|
+
# THE WHOLE TUNNEL IS RESTARTED PER ATTEMPT, not just the boot
|
|
1403
|
+
# check retried through one long-lived tunnel — confirmed live,
|
|
1404
|
+
# the hard way: an SSM port-forwarding session can start and then
|
|
1405
|
+
# exit again within the first few seconds (visible in its own log
|
|
1406
|
+
# as a session ID that both starts AND exits before the retries
|
|
1407
|
+
# even finish), not merely "slow to become ready". Retrying a
|
|
1408
|
+
# Postgres connection five times against a tunnel process that has
|
|
1409
|
+
# already died is retrying against a corpse — it fails identically
|
|
1410
|
+
# every time, for a different reason than the one the retry was
|
|
1411
|
+
# built for. Each attempt below kills whatever tunnel the LAST
|
|
1412
|
+
# attempt opened (a no-op the first time) and opens a fresh one,
|
|
1413
|
+
# so a dead session gets a real replacement, not just more patience.
|
|
1414
|
+
#
|
|
1415
|
+
# DB_PASS_URLENC, DERIVED RIGHT AFTER DB_PASS BELOW, IS WHAT ACTUALLY
|
|
1416
|
+
# GOES INTO THE `postgres://` URL FURTHER DOWN — not DB_PASS itself.
|
|
1417
|
+
# RDS/Secrets-Manager-generated passwords are not guaranteed free of
|
|
1418
|
+
# `%`, and libpq's own connection-URI parser treats a bare `%` as an
|
|
1419
|
+
# invalid token (percent-encoding is the one escape libpq's URI form
|
|
1420
|
+
# actually documents and decodes; a raw `%` is not) -- roughly 29% of
|
|
1421
|
+
# 32-char generated passwords contain at least one `%`, so this was
|
|
1422
|
+
# silently unreachable until it wasn't. `ERB::Util.url_encode`
|
|
1423
|
+
# percent-encodes everything outside RFC 3986's unreserved set,
|
|
1424
|
+
# exactly what a URI password component needs -- a strict superset
|
|
1425
|
+
# of the ExcludeCharacters the Aurora path below already excludes
|
|
1426
|
+
# (a no-op there), and the actual fix for the plain-RDS
|
|
1427
|
+
# ManageMasterUserPassword path, whose generated character set this
|
|
1428
|
+
# template has no control over at all. This comment sits HERE,
|
|
1429
|
+
# before the chain starts, on purpose -- see the note further below
|
|
1430
|
+
# on why a comment line can never be spliced between two
|
|
1431
|
+
# `\`-continued recipe lines.
|
|
1432
|
+
\t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
|
|
1433
|
+
\techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
|
|
1434
|
+
\tfor i in $$(seq 1 30); do \\
|
|
1435
|
+
\t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
|
|
1436
|
+
\t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
|
|
1437
|
+
\t\tsleep 5; \\
|
|
1438
|
+
\tdone; \\
|
|
1439
|
+
\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"]'); \\
|
|
1440
|
+
\tDB_PASS_URLENC=$$(ruby -rerb -e 'print ERB::Util.url_encode(ARGV[0])' "$$DB_PASS"); \\
|
|
1441
|
+
\tBOOT_STATUS=1; \\
|
|
1442
|
+
\tfor attempt in 1 2 3 4 5; do \\
|
|
1443
|
+
\t\tkill $$TUNNEL_PID 2>/dev/null; \\
|
|
1444
|
+
\t\techo "Opening an SSM tunnel to $(DB_HOST):5432 and minting era 1 (attempt $$attempt/5)..."; \\
|
|
1445
|
+
\t\taws ssm start-session --target $$INSTANCE_ID \\
|
|
1446
|
+
\t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
|
|
1447
|
+
\t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
|
|
1448
|
+
\t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
|
|
1449
|
+
\t\tTUNNEL_PID=$$!; \\
|
|
1450
|
+
\t\tfor i in $$(seq 1 15); do \\
|
|
1451
|
+
\t\t\tnc -z localhost 15432 2>/dev/null && break; \\
|
|
1452
|
+
\t\t\tsleep 1; \\
|
|
1453
|
+
\t\tdone; \\
|
|
1454
|
+
\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; }; \\
|
|
1455
|
+
\t\tBOOT_STATUS=$$?; \\
|
|
1456
|
+
\t\techo "boot check attempt $$attempt/5 failed (exit $$BOOT_STATUS) -- restarting the tunnel and retrying in 3s..."; \\
|
|
1457
|
+
\t\tsleep 3; \\
|
|
1458
|
+
\tdone; \\
|
|
1459
|
+
\tkill $$TUNNEL_PID 2>/dev/null; \\
|
|
1460
|
+
\techo "Tearing down the temporary bastion stack..."; \\
|
|
1461
|
+
\taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
|
|
1462
|
+
\taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
|
|
1463
|
+
\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."; \\
|
|
1464
|
+
\texit $$BOOT_STATUS
|
|
1465
|
+
OWNMINT
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
# `scaffold-translation`/`translation-audit` — the two-step fix
|
|
1469
|
+
# minter.rb's own refusal names ("run bin/scaffold_translation to write
|
|
1470
|
+
# the edge, check it with bin/translation_audit, then boot again") when
|
|
1471
|
+
# a deploy's own pre-flight boot check refuses a shape change with no
|
|
1472
|
+
# translation edge covering it. Same bastion/tunnel/retry/teardown
|
|
1473
|
+
# chain mint_era_recipe already uses — only the one thing done over the
|
|
1474
|
+
# tunnel differs: these run the scaffold/audit scripts (hecks's
|
|
1475
|
+
# own bin/, not a Ruby -e one-liner) instead of a boot check, since
|
|
1476
|
+
# both scripts already do their own registry-loading internally.
|
|
1477
|
+
#
|
|
1478
|
+
# HECKS_SCHEMA/DATABASE_URL below are not actually consumed by those
|
|
1479
|
+
# scripts, despite reading as if they were: grep-confirmed, neither
|
|
1480
|
+
# `bin/scaffold_translation` nor `bin/translation_audit` nor
|
|
1481
|
+
# `Hecks::Bluebook::Behaviour::World#for_binding` nor
|
|
1482
|
+
# `PostgresEra.connect_for` ever reads `ENV["DATABASE_URL"]` or
|
|
1483
|
+
# `ENV["HECKS_SCHEMA"]` — both scripts call
|
|
1484
|
+
# `registry.world(bluebook.name)&.for_binding(...)`, a pure hash lookup
|
|
1485
|
+
# against whatever literal `database "..."` string #{domain}'s own
|
|
1486
|
+
# `.world` file declares. Without `db_env_blind:` below, this whole
|
|
1487
|
+
# recipe would stand up a real bastion, punch a live 5432 ingress rule
|
|
1488
|
+
# into production's security group, open a real SSM tunnel to
|
|
1489
|
+
# #{stack_name}'s RDS instance, tear it all down again — and then
|
|
1490
|
+
# silently scaffold/audit the developer's local dev database the
|
|
1491
|
+
# entire time, reporting success. `db_env_blind:`
|
|
1492
|
+
# (below) is exactly this: true for scaffold-translation/
|
|
1493
|
+
# translation-audit (confirmed env-blind), left false for
|
|
1494
|
+
# migrate-console-settings (an app-owned script this generator doesn't
|
|
1495
|
+
# control the internals of — it may honor these vars; not asserting
|
|
1496
|
+
# either way here). A `db_env_blind` recipe refuses before ever
|
|
1497
|
+
# touching AWS unless `ALLOW_LOCAL_DB=1` is set, rather than silently
|
|
1498
|
+
# doing the wrong (but locally successful-looking) thing.
|
|
1499
|
+
# `cwd:`/`run_prefix:` — every existing caller runs a hecks-owned
|
|
1500
|
+
# script from hecks's own $(root) with `-Ilib` (source, not the
|
|
1501
|
+
# installed gem); `migrate_console_settings_recipe` below is the one
|
|
1502
|
+
# exception, an app-owned script that needs the app's own Gemfile
|
|
1503
|
+
# context instead — `cd $(DOMAIN) && bundle exec ruby`, not `-Ilib`.
|
|
1504
|
+
translation_recipe = lambda do |verb, script, extra_args = "", cwd: "$(ROOT)", run_prefix: "ruby -Ilib", db_env_blind: false|
|
|
1505
|
+
if shared
|
|
1506
|
+
<<~SHAREDTRANSLATION.rstrip
|
|
1507
|
+
\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."; \\
|
|
1508
|
+
\texit 1
|
|
1509
|
+
SHAREDTRANSLATION
|
|
1510
|
+
else
|
|
1511
|
+
<<~OWNTRANSLATION.rstrip
|
|
1512
|
+
#{db_env_blind ? <<~ENVBLINDGUARD.rstrip
|
|
1513
|
+
\t@if [ -z "$$ALLOW_LOCAL_DB" ]; then \\
|
|
1514
|
+
\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."; \\
|
|
1515
|
+
\t\texit 1; \\
|
|
1516
|
+
\tfi
|
|
1517
|
+
ENVBLINDGUARD
|
|
1518
|
+
: ""}
|
|
1519
|
+
\t@echo "Looking up $(STACK)'s VPC/security group..."
|
|
1520
|
+
\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")}
|
|
1521
|
+
\t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
|
|
1522
|
+
\taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
|
|
1523
|
+
\t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
|
|
1524
|
+
\t\t--capabilities CAPABILITY_IAM
|
|
1525
|
+
# DB_PASS_URLENC -- same percent-encoding fix as mint_era_recipe's
|
|
1526
|
+
# own comment above the equivalent line in that recipe (libpq's URI
|
|
1527
|
+
# parser rejects a bare `%`, which a generated RDS password isn't
|
|
1528
|
+
# guaranteed to be free of); this recipe's DATABASE_URL below uses
|
|
1529
|
+
# the encoded form for the same reason, even though the script it
|
|
1530
|
+
# feeds doesn't currently read it (see db_env_blind above).
|
|
1531
|
+
\t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
|
|
1532
|
+
\techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
|
|
1533
|
+
\tfor i in $$(seq 1 30); do \\
|
|
1534
|
+
\t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
|
|
1535
|
+
\t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
|
|
1536
|
+
\t\tsleep 5; \\
|
|
1537
|
+
\tdone; \\
|
|
1538
|
+
\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"]'); \\
|
|
1539
|
+
\tDB_PASS_URLENC=$$(ruby -rerb -e 'print ERB::Util.url_encode(ARGV[0])' "$$DB_PASS"); \\
|
|
1540
|
+
\tRUN_STATUS=1; \\
|
|
1541
|
+
\tfor attempt in 1 2 3 4 5; do \\
|
|
1542
|
+
\t\tkill $$TUNNEL_PID 2>/dev/null; \\
|
|
1543
|
+
\t\techo "Opening an SSM tunnel to $(DB_HOST):5432 and running #{verb} (attempt $$attempt/5)..."; \\
|
|
1544
|
+
\t\taws ssm start-session --target $$INSTANCE_ID \\
|
|
1545
|
+
\t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
|
|
1546
|
+
\t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
|
|
1547
|
+
\t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
|
|
1548
|
+
\t\tTUNNEL_PID=$$!; \\
|
|
1549
|
+
\t\tfor i in $$(seq 1 15); do \\
|
|
1550
|
+
\t\t\tnc -z localhost 15432 2>/dev/null && break; \\
|
|
1551
|
+
\t\t\tsleep 1; \\
|
|
1552
|
+
\t\tdone; \\
|
|
1553
|
+
\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; }; \\
|
|
1554
|
+
\t\tRUN_STATUS=$$?; \\
|
|
1555
|
+
\t\techo "#{verb} attempt $$attempt/5 failed (exit $$RUN_STATUS) -- restarting the tunnel and retrying in 3s..."; \\
|
|
1556
|
+
\t\tsleep 3; \\
|
|
1557
|
+
\tdone; \\
|
|
1558
|
+
\tkill $$TUNNEL_PID 2>/dev/null; \\
|
|
1559
|
+
\techo "Tearing down the temporary bastion stack..."; \\
|
|
1560
|
+
\taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
|
|
1561
|
+
\taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
|
|
1562
|
+
\techo "$(BASTION_STACK) deleted."; \\
|
|
1563
|
+
\texit $$RUN_STATUS
|
|
1564
|
+
OWNTRANSLATION
|
|
1565
|
+
end
|
|
1566
|
+
end
|
|
1567
|
+
|
|
1568
|
+
scaffold_translation_recipe = translation_recipe.call("scaffold-translation", "bin/scaffold_translation", db_env_blind: true)
|
|
1569
|
+
translation_audit_recipe = translation_recipe.call("translation-audit", "bin/translation_audit", db_env_blind: true)
|
|
1570
|
+
|
|
1571
|
+
# `make migrate-console-settings` — the same bastion/tunnel/retry/
|
|
1572
|
+
# teardown chain as scaffold-translation/translation-audit, running an
|
|
1573
|
+
# app-owned one-time migration script instead of one of hecks's
|
|
1574
|
+
# own bin/ tools (see translation_recipe's own `cwd:`/`run_prefix:`
|
|
1575
|
+
# comment). Only meaningful for a domain that actually has one — most
|
|
1576
|
+
# domains don't, so this target is generated unconditionally but simply
|
|
1577
|
+
# has nothing to run for them; harmless (`bundle exec ruby` on a
|
|
1578
|
+
# missing file just fails loudly, same as any other missing script
|
|
1579
|
+
# would).
|
|
1580
|
+
migrate_console_settings_recipe = translation_recipe.call(
|
|
1581
|
+
"migrate-console-settings", "bin/migrate_console_settings", "",
|
|
1582
|
+
cwd: "$(DOMAIN)", run_prefix: "bundle exec ruby"
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
# `rename-schema`'s own recipe body — same top-level-variable-not-inlined
|
|
1586
|
+
# reasoning as `mint_era_recipe` just above (recipe lines need a literal
|
|
1587
|
+
# leading tab; nesting this heredoc a level deeper would need its own
|
|
1588
|
+
# re-indent pass). Reuses the same bastion.yaml, the same stack/
|
|
1589
|
+
# BASTION_STACK Make variables mint-era already defines, and the same
|
|
1590
|
+
# stand-up/tunnel/teardown-no-matter-what shell chain — only the one
|
|
1591
|
+
# thing done over the tunnel differs: a schema rename instead of a
|
|
1592
|
+
# Ruby boot. `OLD`/`NEW` are Make command-line variables
|
|
1593
|
+
# (`make rename-schema OLD=old NEW=new`), not baked in here, so this
|
|
1594
|
+
# is genuinely reusable — the domain whose own schema this renames is
|
|
1595
|
+
# whichever one owns this stack's RDS instance (this generator's own
|
|
1596
|
+
# `#{infra_name}` database on it), not necessarily this domain forever.
|
|
1597
|
+
#
|
|
1598
|
+
# Idempotent by inspection, not by catching a Postgres error: checks
|
|
1599
|
+
# which of old/new actually exists as a schema before touching
|
|
1600
|
+
# anything, so a second run (or a run after a partial failure) reads
|
|
1601
|
+
# as a clear no-op message rather than a bare "schema already exists"
|
|
1602
|
+
# error with no context.
|
|
1603
|
+
rename_schema_recipe =
|
|
1604
|
+
if shared
|
|
1605
|
+
<<~SHAREDRENAME.rstrip
|
|
1606
|
+
# NOT AUTOMATED for a Shared-mode domain, same reason mint-era
|
|
1607
|
+
# isn't: there is no bastion.yaml here to stand up next to (this
|
|
1608
|
+
# domain has no RDS/VPC of its own). This domain's own schema
|
|
1609
|
+
# lives inside #{owner_domain_name}'s database — run this same
|
|
1610
|
+
# target from #{owner_domain_name}'s own deploy directory instead.
|
|
1611
|
+
\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."; \\
|
|
1612
|
+
\texit 1
|
|
1613
|
+
SHAREDRENAME
|
|
1614
|
+
else
|
|
1615
|
+
<<~OWNRENAME.rstrip
|
|
1616
|
+
# THE WHOLE TUNNEL IS RESTARTED PER ATTEMPT, not just probed once
|
|
1617
|
+
# through a single long-lived session — confirmed live, the hard
|
|
1618
|
+
# way: an SSM port-forwarding session can start and then exit
|
|
1619
|
+
# again within seconds, not merely "slow to become ready". Probing
|
|
1620
|
+
# the same dead tunnel five times fails identically every time,
|
|
1621
|
+
# for a different reason than the retry was built for. Without
|
|
1622
|
+
# this restart, a still-dead tunnel also doesn't just fail loudly
|
|
1623
|
+
# — `EXISTS_OLD`/`EXISTS_NEW` would both come back empty, which the
|
|
1624
|
+
# branch below reads as "neither schema exists" and reports as if
|
|
1625
|
+
# that were the real answer, not a connectivity failure wearing
|
|
1626
|
+
# its name — which is exactly why `TUNNEL_READY` gates that branch
|
|
1627
|
+
# explicitly rather than trusting empty results at face value.
|
|
1628
|
+
#
|
|
1629
|
+
# OLD/NEW ARE ALLOWLISTED AS BARE IDENTIFIERS, not SQL-escaped, before
|
|
1630
|
+
# either one reaches a psql command line: both get interpolated
|
|
1631
|
+
# straight into the `nspname = '...'` lookups above and the
|
|
1632
|
+
# `ALTER SCHEMA "..." RENAME TO "..."` below, and a schema name can't
|
|
1633
|
+
# be bound as a parameter the way a value can — quoting/escaping an
|
|
1634
|
+
# identifier is exactly the kind of thing that's easy to get subtly
|
|
1635
|
+
# wrong, so instead of trying, this refuses anything that isn't
|
|
1636
|
+
# `^[A-Za-z_][A-Za-z0-9_]*$` outright. `make` runs this recipe as an
|
|
1637
|
+
# operator command, not user-facing web input, but it still executes
|
|
1638
|
+
# against production RDS, so a typo'd or copy-pasted `OLD`/`NEW`
|
|
1639
|
+
# containing quotes or `;` must fail loudly here, before either
|
|
1640
|
+
# tunnel or query, rather than run as-is.
|
|
1641
|
+
#
|
|
1642
|
+
# `$$OLD`/`$$NEW` HERE, NOT `$(OLD)`/`$(NEW)`: this check itself has
|
|
1643
|
+
# to read the value without Make ever splicing its raw text into the
|
|
1644
|
+
# recipe line, or a value containing `;`/`"` would break out of THIS
|
|
1645
|
+
# line's shell command and run before the pattern match ever sees it
|
|
1646
|
+
# — confirmed live, the same way the tunnel-restart behavior above
|
|
1647
|
+
# was. `$$OLD` instead reads the real shell environment variable
|
|
1648
|
+
# `make` already exports for command-line-assigned vars, so the
|
|
1649
|
+
# value is one opaque string to the shell no matter what characters
|
|
1650
|
+
# it contains. Once a value's passed this gate it's provably just
|
|
1651
|
+
# `[A-Za-z0-9_]`, so the existing `$(OLD)`/`$(NEW)` splices further
|
|
1652
|
+
# below (already written before this fix, reused as-is) are safe.
|
|
1653
|
+
\t@[ -n "$$OLD" ] && [ -n "$$NEW" ] || { echo "usage: make rename-schema OLD=<old-schema> NEW=<new-schema>"; exit 1; }
|
|
1654
|
+
\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; }
|
|
1655
|
+
\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; }
|
|
1656
|
+
\t@echo "Looking up $(STACK)'s VPC/security group..."
|
|
1657
|
+
\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")}
|
|
1658
|
+
\t@echo "Deploying the temporary bastion stack $(BASTION_STACK)..."
|
|
1659
|
+
\taws cloudformation deploy --template-file bastion.yaml --stack-name $(BASTION_STACK) \\
|
|
1660
|
+
\t\t--parameter-overrides #{bastion_parameters.map { |p| "#{p[:name]}=$(#{stack_outputs.find { |o| o[:key] == p[:from_output] }[:var]})" }.join(" ")} \\
|
|
1661
|
+
\t\t--capabilities CAPABILITY_IAM
|
|
1662
|
+
\t@INSTANCE_ID=$$(aws cloudformation describe-stacks --stack-name $(BASTION_STACK) --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text); \\
|
|
1663
|
+
\techo "Waiting for $$INSTANCE_ID to register with SSM..."; \\
|
|
1664
|
+
\tfor i in $$(seq 1 30); do \\
|
|
1665
|
+
\t\tSTATUS=$$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$$INSTANCE_ID" --query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null); \\
|
|
1666
|
+
\t\tif [ "$$STATUS" = "Online" ]; then break; fi; \\
|
|
1667
|
+
\t\tsleep 5; \\
|
|
1668
|
+
\tdone; \\
|
|
1669
|
+
\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"]'); \\
|
|
1670
|
+
\tTUNNEL_READY=1; \\
|
|
1671
|
+
\tfor attempt in 1 2 3 4 5; do \\
|
|
1672
|
+
\t\tkill $$TUNNEL_PID 2>/dev/null; \\
|
|
1673
|
+
\t\techo "Opening an SSM tunnel to $(DB_HOST):5432 (attempt $$attempt/5)..."; \\
|
|
1674
|
+
\t\taws ssm start-session --target $$INSTANCE_ID \\
|
|
1675
|
+
\t\t\t--document-name AWS-StartPortForwardingSessionToRemoteHost \\
|
|
1676
|
+
\t\t\t--parameters "{\\"host\\":[\\"$(DB_HOST)\\"],\\"portNumber\\":[\\"5432\\"],\\"localPortNumber\\":[\\"15432\\"]}" \\
|
|
1677
|
+
\t\t\t>/tmp/$(BASTION_STACK)-tunnel-$$attempt.log 2>&1 & \\
|
|
1678
|
+
\t\tTUNNEL_PID=$$!; \\
|
|
1679
|
+
\t\tfor i in $$(seq 1 15); do \\
|
|
1680
|
+
\t\t\tnc -z localhost 15432 2>/dev/null && break; \\
|
|
1681
|
+
\t\t\tsleep 1; \\
|
|
1682
|
+
\t\tdone; \\
|
|
1683
|
+
\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; }; \\
|
|
1684
|
+
\t\techo "tunnel not carrying real traffic yet (attempt $$attempt/5) -- restarting the tunnel and retrying in 3s..."; \\
|
|
1685
|
+
\t\tsleep 3; \\
|
|
1686
|
+
\tdone; \\
|
|
1687
|
+
\tEXISTS_OLD=$$(PGPASSWORD=$$DB_PASS psql -h localhost -p 15432 -U postgres -d #{db_name} -tAc "SELECT 1 FROM pg_namespace WHERE nspname = '$(OLD)'"); \\
|
|
1688
|
+
\tEXISTS_NEW=$$(PGPASSWORD=$$DB_PASS psql -h localhost -p 15432 -U postgres -d #{db_name} -tAc "SELECT 1 FROM pg_namespace WHERE nspname = '$(NEW)'"); \\
|
|
1689
|
+
\tif [ "$$TUNNEL_READY" != "0" ]; then \\
|
|
1690
|
+
\t\techo "tunnel never carried a real connection -- aborting without touching either schema"; \\
|
|
1691
|
+
\t\tBOOT_STATUS=1; \\
|
|
1692
|
+
\telif [ "$$EXISTS_NEW" = "1" ]; then \\
|
|
1693
|
+
\t\techo "schema $(NEW) already exists -- already renamed, no-op"; \\
|
|
1694
|
+
\t\tBOOT_STATUS=0; \\
|
|
1695
|
+
\telif [ "$$EXISTS_OLD" = "1" ]; then \\
|
|
1696
|
+
\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)\\""; \\
|
|
1697
|
+
\t\tBOOT_STATUS=$$?; \\
|
|
1698
|
+
\t\techo "renamed schema $(OLD) to $(NEW) (exit $$BOOT_STATUS)"; \\
|
|
1699
|
+
\telse \\
|
|
1700
|
+
\t\techo "neither schema $(OLD) nor $(NEW) exists on #{db_name} -- nothing to rename"; \\
|
|
1701
|
+
\t\tBOOT_STATUS=1; \\
|
|
1702
|
+
\tfi; \\
|
|
1703
|
+
\tkill $$TUNNEL_PID 2>/dev/null; \\
|
|
1704
|
+
\techo "Tearing down the temporary bastion stack..."; \\
|
|
1705
|
+
\taws cloudformation delete-stack --stack-name $(BASTION_STACK); \\
|
|
1706
|
+
\taws cloudformation wait stack-delete-complete --stack-name $(BASTION_STACK); \\
|
|
1707
|
+
\techo "$(BASTION_STACK) deleted."; \\
|
|
1708
|
+
\texit $$BOOT_STATUS
|
|
1709
|
+
OWNRENAME
|
|
1710
|
+
end
|
|
1711
|
+
|
|
1712
|
+
# `deploy:`'s own `sam deploy` call — `google_oauth_present` and
|
|
1713
|
+
# `shared` are collected together here rather than spelled as an
|
|
1714
|
+
# if/elsif/else, the same shape `Parameters:` above holds to: the
|
|
1715
|
+
# two are independent facts about a domain, not alternatives, and
|
|
1716
|
+
# lifeadelics is both — an elsif shape would silently drop the
|
|
1717
|
+
# Owning* overrides whenever OAuth is also present. Caught live
|
|
1718
|
+
# the first time a Shared-mode domain with real Google OAuth
|
|
1719
|
+
# actually ran `make deploy`: `sam deploy` refused
|
|
1720
|
+
# with "Parameters: [OwningVpcId, ...] must have values" because
|
|
1721
|
+
# nothing had ever passed them. WebRedirectBaseUrl stays a conditional
|
|
1722
|
+
# override (empty on a genuine first deploy, before the Function Url
|
|
1723
|
+
# exists — see that Parameter's own comment); Owning* is unconditional
|
|
1724
|
+
# whenever `shared`, in every branch that reaches `sam deploy` at all.
|
|
1725
|
+
# One continuous `\`-joined shell chain, however many pieces
|
|
1726
|
+
# contribute to it — `@` (Make's own "don't echo this line" prefix)
|
|
1727
|
+
# is only meaningful on the true first line of that chain; written on
|
|
1728
|
+
# any later line it stops being a Make directive at all and becomes
|
|
1729
|
+
# literal shell text (`@WEB_URL=...` parses as a bogus command, not
|
|
1730
|
+
# an assignment) the moment two independently-`@`-prefixed pieces
|
|
1731
|
+
# get concatenated. Built unprefixed here; `@` is added once, to
|
|
1732
|
+
# whichever piece actually ends up first, right before joining.
|
|
1733
|
+
owner_lookup_lines = shared ? [
|
|
1734
|
+
%(OWNER_VPC_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue" --output text); \\),
|
|
1735
|
+
%(OWNER_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='PrivateSubnetAId'].OutputValue" --output text); \\),
|
|
1736
|
+
%(OWNER_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='PrivateSubnetBId'].OutputValue" --output text); \\),
|
|
1737
|
+
%(OWNER_SG_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='FunctionSecurityGroupId'].OutputValue" --output text); \\),
|
|
1738
|
+
%(OWNER_DB_HOST=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='DatabaseEndpoint'].OutputValue" --output text); \\),
|
|
1739
|
+
%(OWNER_DB_SECRET_ARN=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query "Stacks[0].Outputs[?OutputKey=='DatabaseSecretArn'].OutputValue" --output text); \\),
|
|
1740
|
+
] : []
|
|
1741
|
+
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"
|
|
1742
|
+
|
|
1743
|
+
sam_deploy_lines =
|
|
1744
|
+
if google_oauth_present && shared
|
|
1745
|
+
[
|
|
1746
|
+
%(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:/$$::'); \\),
|
|
1747
|
+
"if [ -n \"$$WEB_URL\" ]; then \\",
|
|
1748
|
+
%(\tsam deploy --parameter-overrides WebRedirectBaseUrl="$$WEB_URL" #{owning_overrides}; \\),
|
|
1749
|
+
"else \\",
|
|
1750
|
+
"\tsam deploy --parameter-overrides #{owning_overrides}; \\",
|
|
1751
|
+
"fi",
|
|
1752
|
+
]
|
|
1753
|
+
elsif google_oauth_present
|
|
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"; \\),
|
|
1758
|
+
"else \\",
|
|
1759
|
+
"\tsam deploy; \\",
|
|
1760
|
+
"fi",
|
|
1761
|
+
]
|
|
1762
|
+
elsif shared
|
|
1763
|
+
["sam deploy --parameter-overrides #{owning_overrides}"]
|
|
1764
|
+
else
|
|
1765
|
+
["sam deploy"]
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
deploy_shell_chain = owner_lookup_lines + sam_deploy_lines
|
|
1769
|
+
deploy_shell_chain = ["@#{deploy_shell_chain.first}"] + deploy_shell_chain.drop(1) if deploy_shell_chain.first&.match?(/=\$\$\(/)
|
|
1770
|
+
|
|
1771
|
+
# `deploy:`'s own pre-deploy `mint-era` bridge (below, inlined into
|
|
1772
|
+
# `PREDEPLOYBRIDGE`) — a plain Ruby string built here, not inlined
|
|
1773
|
+
# directly in that heredoc, for the same reason as everywhere else
|
|
1774
|
+
# comment: it needs its own `if/else` branch on `google_oauth_present`,
|
|
1775
|
+
# and Ruby heredoc-in-string-interpolation only reads cleanly one level
|
|
1776
|
+
# deep before it gets hard to follow.
|
|
1777
|
+
#
|
|
1778
|
+
# Google OAuth newly added to an existing stack deadlocks this bridge —
|
|
1779
|
+
# confirmed live: stack_outputs (above) only gains PublicSubnetId/
|
|
1780
|
+
# BastionSubnetId entries once `google_oauth_present` is true, but those
|
|
1781
|
+
# two CloudFormation resources (#{db_id}PublicSubnet/
|
|
1782
|
+
# #{db_id}BastionPublicSubnet) don't exist on a stack that predates
|
|
1783
|
+
# turning OAuth on — only the upcoming `sam deploy` (further below,
|
|
1784
|
+
# still ahead of us here) creates them. This pre-check's own `mint-era`
|
|
1785
|
+
# call evaluates stack_outputs against the currently live stack (an
|
|
1786
|
+
# `aws cloudformation describe-stacks` eval chain, same one mint-era's
|
|
1787
|
+
# own recipe uses), gets an empty string back for both, and hands
|
|
1788
|
+
# bastion.yaml an empty `AWS::EC2::Subnet::Id` — CloudFormation refuses
|
|
1789
|
+
# that outright. The pre-check fails before `sam deploy` ever runs, so
|
|
1790
|
+
# the one deploy that would actually create those outputs never gets
|
|
1791
|
+
# the chance to: a hard deadlock, `make deploy` can never get OAuth
|
|
1792
|
+
# provisioned on a stack that didn't have it already. Detected the same
|
|
1793
|
+
# way a genuine first deploy is detected just below (describe-stacks
|
|
1794
|
+
# succeeding or not) — here, describe-stacks succeeds (the stack
|
|
1795
|
+
# itself exists) but the specific output this deploy is newly adding
|
|
1796
|
+
# does not, yet. Skip the pre-deploy bridge in exactly that one case;
|
|
1797
|
+
# the unconditional `mint-era` call at the very end of `deploy:` still
|
|
1798
|
+
# covers it once `sam deploy` has actually created PublicSubnetId/
|
|
1799
|
+
# BastionSubnetId — the exact same "runs once, after the stack exists"
|
|
1800
|
+
# path a domain's true first deploy already takes below.
|
|
1801
|
+
predeploy_bridge_shell =
|
|
1802
|
+
if google_oauth_present
|
|
1803
|
+
<<~OAUTHBRIDGE.rstrip
|
|
1804
|
+
@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..."
|
|
1805
|
+
@if aws cloudformation describe-stacks --stack-name $(STACK) >/dev/null 2>&1; then \\
|
|
1806
|
+
\tOAUTH_OUTPUTS_READY=$$(aws cloudformation describe-stacks --stack-name $(STACK) --query "Stacks[0].Outputs[?OutputKey=='PublicSubnetId'].OutputValue" --output text 2>/dev/null); \\
|
|
1807
|
+
\tif [ -z "$$OAUTH_OUTPUTS_READY" ]; then \\
|
|
1808
|
+
\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."; \\
|
|
1809
|
+
\telse \\
|
|
1810
|
+
\t\techo "Existing stack found -- bridging era history before this deploy flips $(STACK) over, not after."; \\
|
|
1811
|
+
\t\t$(MAKE) mint-era || exit 1; \\
|
|
1812
|
+
\tfi; \\
|
|
1813
|
+
else \\
|
|
1814
|
+
\techo "No existing stack -- first deploy, nothing to bridge yet; mint-era runs once, below, after the stack (and its RDS instance) exist."; \\
|
|
1815
|
+
fi
|
|
1816
|
+
OAUTHBRIDGE
|
|
1817
|
+
else
|
|
1818
|
+
<<~PLAINBRIDGE.rstrip
|
|
1819
|
+
@echo "Checking whether $(STACK) already exists, to decide whether this deploy needs to bridge era history before it flips the stack over..."
|
|
1820
|
+
@if aws cloudformation describe-stacks --stack-name $(STACK) >/dev/null 2>&1; then \\
|
|
1821
|
+
\techo "Existing stack found -- bridging era history before this deploy flips $(STACK) over, not after."; \\
|
|
1822
|
+
\t$(MAKE) mint-era || exit 1; \\
|
|
1823
|
+
else \\
|
|
1824
|
+
\techo "No existing stack -- first deploy, nothing to bridge yet; mint-era runs once, below, after the stack (and its RDS instance) exist."; \\
|
|
1825
|
+
fi
|
|
1826
|
+
PLAINBRIDGE
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
deploy_recipe_lines = (
|
|
1830
|
+
(google_oauth_present ? ["$(MAKE) sync-google-oauth"] : []) +
|
|
1831
|
+
(shared ? [%(@echo "Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/FunctionSecurityGroupId/DatabaseEndpoint/DatabaseSecretArn outputs to pass as $(STACK)'s Owning* parameters...")] : []) +
|
|
1832
|
+
deploy_shell_chain
|
|
1833
|
+
).map { |l| "\t#{l}" }.join("\n")
|
|
1834
|
+
|
|
1835
|
+
makefile_content = <<~MAKE
|
|
1836
|
+
# GENERATED by bin/project_deploy #{domain} — re-run it to refresh
|
|
1837
|
+
# this file rather than hand-editing.
|
|
1838
|
+
#
|
|
1839
|
+
# `sam build` invokes the build-<LogicalId> target below (SAM's own
|
|
1840
|
+
# BuildMethod: makefile convention — see template.yaml's Metadata).
|
|
1841
|
+
# `cargo lambda` (https://www.cargo-lambda.info) cross-compiles
|
|
1842
|
+
# rust/host for arm64 Lambda without Docker or a hand-configured
|
|
1843
|
+
# cross-linker — install once with `pip3 install cargo-lambda` or
|
|
1844
|
+
# `brew install cargo-lambda`.
|
|
1845
|
+
|
|
1846
|
+
HOST_DIR := #{File.join(root, "rust", "host")}
|
|
1847
|
+
# `domain_name`, matching bin/project_wasm's own target_mod_name
|
|
1848
|
+
# (a Rust-build-pipeline naming convention, not AWS resource
|
|
1849
|
+
# identity — see HECKS_WASM_PATH's own comment above).
|
|
1850
|
+
WASM := #{File.join(root, "rust", "dist", "#{domain_name}.wasm")}
|
|
1851
|
+
|
|
1852
|
+
build-#{logical_id}:
|
|
1853
|
+
\t@command -v cargo-lambda >/dev/null 2>&1 || { \\
|
|
1854
|
+
\t\techo "cargo-lambda isn't installed. Install it once with: pip3 install cargo-lambda"; \\
|
|
1855
|
+
\t\texit 1; \\
|
|
1856
|
+
\t}
|
|
1857
|
+
# ALWAYS rebuilt, never `test -f $(WASM) || ...` — that guard only
|
|
1858
|
+
# checked EXISTENCE, not staleness, so a wasm built once at the
|
|
1859
|
+
# start of a work session silently kept getting deployed through
|
|
1860
|
+
# every later kernel change (a real, live bug: this cost a stale
|
|
1861
|
+
# deploy against a wasm that predated the kernel's own "mutations"
|
|
1862
|
+
# field). `bin/project_wasm` recompiles fast enough that "always
|
|
1863
|
+
# rebuild" is the safe default, matching rust/host's own bootstrap
|
|
1864
|
+
# build just below, which was never guarded this way to begin with.
|
|
1865
|
+
\tcd #{root} && bin/project_wasm #{domain}
|
|
1866
|
+
\t@rustup target list --installed 2>/dev/null | grep -qx aarch64-unknown-linux-gnu || rustup target add aarch64-unknown-linux-gnu
|
|
1867
|
+
# `rustup run stable`, not a bare `cargo lambda` — same reasoning
|
|
1868
|
+
# bin/project_wasm's own Makefile-equivalent line holds itself to: a
|
|
1869
|
+
# `cargo`/`rustc` earlier on PATH than rustup's own shims (Homebrew
|
|
1870
|
+
# installs one; common on this kind of machine) is a DIFFERENT
|
|
1871
|
+
# toolchain that never saw `rustup target add`, and fails with
|
|
1872
|
+
# "can't find crate for \`core\`" for the cross target even though
|
|
1873
|
+
# `rustup target list --installed` reports it present.
|
|
1874
|
+
# macOS -> Linux arm64 cross-compiling by default routes through
|
|
1875
|
+
# zig (cargo-lambda's own bundled cargo-zigbuild) — every zig
|
|
1876
|
+
# release checked so far (0.15.2, 0.16.0) rejects
|
|
1877
|
+
# `-Wl,--fix-cortex-a53-843419`, a flag rustc's own
|
|
1878
|
+
# aarch64-unknown-linux-gnu target emits unconditionally: "error:
|
|
1879
|
+
# unsupported linker arg: --fix-cortex-a53-843419", found live on
|
|
1880
|
+
# THIS domain's own very first real deploy attempt (upstream:
|
|
1881
|
+
# https://github.com/ziglang/zig/issues, no fix released yet as of
|
|
1882
|
+
# this comment). Not fixable by a flag here — it's zig's own `zig
|
|
1883
|
+
# cc` argument-translation layer refusing before rustc's link step
|
|
1884
|
+
# ever runs. If this build fails with that error, cargo-lambda has
|
|
1885
|
+
# a real, documented non-zig, non-Docker fallback: `-c cargo` (or
|
|
1886
|
+
# `CARGO_LAMBDA_COMPILER=cargo`) plus a real GNU cross-toolchain —
|
|
1887
|
+
# `brew tap messense/macos-cross-toolchains && brew install
|
|
1888
|
+
# aarch64-unknown-linux-gnu` (this flag has worked in real
|
|
1889
|
+
# binutils/LLD since 2018; only zig's own translation of it is the
|
|
1890
|
+
# gap), then set `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=
|
|
1891
|
+
# aarch64-linux-gnu-gcc` with that toolchain's own bin/ on PATH.
|
|
1892
|
+
# Confirmed working end to end (a real `sam deploy` against a real
|
|
1893
|
+
# Lambda) the same day this comment was written.
|
|
1894
|
+
\tcd $(HOST_DIR) && rustup run stable cargo lambda build --release --arm64
|
|
1895
|
+
\tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap $(ARTIFACTS_DIR)/bootstrap
|
|
1896
|
+
\tcp $(WASM) $(ARTIFACTS_DIR)/#{domain_name}.wasm#{rust_web ? %(\n\tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} $(ARTIFACTS_DIR)/#{domain_name}.ir.json) : ""}
|
|
1897
|
+
|
|
1898
|
+
# `sam build <resource>` WIPES .aws-sam/build/ entirely before
|
|
1899
|
+
# building just the one resource named -- confirmed live: building
|
|
1900
|
+
# #{web_logical_id} second left NO #{logical_id}/ directory behind at
|
|
1901
|
+
# all, and reverted its OWN built CodeUri back to the raw, unbuilt
|
|
1902
|
+
# source value ("../.." -- CodeUri: . resolved two directories up
|
|
1903
|
+
# from .aws-sam/build/template.yaml). `sam deploy` then zips THAT
|
|
1904
|
+
# directory verbatim as #{logical_id}'s own code -- a real, live
|
|
1905
|
+
# deploy shipped #{web_logical_id}'s own source tree (Gemfile,
|
|
1906
|
+
# lambda_handler.rb, even .env.local) as #{logical_id}'s Lambda
|
|
1907
|
+
# package this way, caught by downloading the actually-deployed
|
|
1908
|
+
# code and finding #{web_logical_id}'s file tree inside it.
|
|
1909
|
+
# `deploy:`'s own two separate `sam build <resource>` calls can
|
|
1910
|
+
# never both survive in .aws-sam/build/ at once — this target
|
|
1911
|
+
# re-populates #{logical_id}'s build directory from the SAME
|
|
1912
|
+
# already-built bootstrap+wasm build-#{logical_id} just produced
|
|
1913
|
+
# (untouched by the second sam build call — only .aws-sam/build/
|
|
1914
|
+
# itself gets wiped, never $(HOST_DIR)/target or $(WASM)) and
|
|
1915
|
+
# repoints the built template's CodeUri back at it.
|
|
1916
|
+
.PHONY: restore-#{logical_id}-build
|
|
1917
|
+
restore-#{logical_id}-build:
|
|
1918
|
+
\t@mkdir -p .aws-sam/build/#{logical_id}
|
|
1919
|
+
\tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap .aws-sam/build/#{logical_id}/bootstrap
|
|
1920
|
+
\tcp $(WASM) .aws-sam/build/#{logical_id}/#{domain_name}.wasm#{rust_web ? %(\n\tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} .aws-sam/build/#{logical_id}/#{domain_name}.ir.json) : ""}
|
|
1921
|
+
\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)'
|
|
1922
|
+
|
|
1923
|
+
# `make verify-parity-#{logical_id}` — closes the exact gap the
|
|
1924
|
+
# equivalence-gap plan's own Phase 8 named: parity was checked ONLY
|
|
1925
|
+
# against CI's fixed test corpus, completely decoupled from what a
|
|
1926
|
+
# real `sam deploy` actually ships — `bin/rust_conformance` (the
|
|
1927
|
+
# differential harness, docs/decisions/0010-ruby-is-the-reference-
|
|
1928
|
+
# implementation.md) had never once been run against a specific
|
|
1929
|
+
# compiled deploy artifact. Runs it here, for real, against
|
|
1930
|
+
# $(WASM) -- the EXACT file `build-#{logical_id}` (above) just built
|
|
1931
|
+
# and `deploy:` (below) is about to ship -- not a corpus-wide `cargo
|
|
1932
|
+
# build`'s own separate binary. `bin/rust_conformance` exits non-zero
|
|
1933
|
+
# on any mismatch, which this target lets propagate uncaught: a real
|
|
1934
|
+
# divergence between THIS artifact and Ruby's own reading of the
|
|
1935
|
+
# identical source blocks `make deploy` before `sam deploy` ever
|
|
1936
|
+
# runs, the same way a failing build already would.
|
|
1937
|
+
#
|
|
1938
|
+
# `spec/corpus/#{domain_name}.json` is this domain's OWN pinned
|
|
1939
|
+
# fuzzer-replay script (the same shape `bin/fuzz`/`bin/run` already
|
|
1940
|
+
# use) -- if a domain doesn't have one yet, this warns LOUDLY and
|
|
1941
|
+
# continues rather than either silently skipping (this project's own
|
|
1942
|
+
# standing rule against a silent gap reading as full coverage) or
|
|
1943
|
+
# blocking every deploy of a domain nobody has written one for yet.
|
|
1944
|
+
#
|
|
1945
|
+
# SECOND HALF, BELOW — `bin/rust_conformance_fuzz`, ADR 0037's own fuzz
|
|
1946
|
+
# bridge pointed at this exact $(WASM). A pinned script only proves
|
|
1947
|
+
# "matches Ruby on the cases we thought to write down" (ADR 0039's own
|
|
1948
|
+
# honest framing); the fuzz bridge is what actually FOUND Findings 1-6.
|
|
1949
|
+
# Deliberately WARN-ONLY (`-@`, not `@`) for now: ADR 0037 already
|
|
1950
|
+
# catalogues real, confirmed, not-yet-fixed divergences (Findings 3 and
|
|
1951
|
+
# 5) that fire on ordinary generated sequences for more than one real
|
|
1952
|
+
# domain today, so making this a hard blocker before those are closed
|
|
1953
|
+
# would turn every affected domain's `make deploy` red for a reason
|
|
1954
|
+
# this target can't yet point at a fix for. Flip `-@` to `@` once ADR
|
|
1955
|
+
# 0037's open findings are closed and this stops firing in practice.
|
|
1956
|
+
.PHONY: verify-parity-#{logical_id}
|
|
1957
|
+
verify-parity-#{logical_id}:
|
|
1958
|
+
\t@if [ -f #{root}/spec/corpus/#{domain_name}.json ]; then \\
|
|
1959
|
+
\t\tcd #{root} && bin/rust_conformance #{domain} spec/corpus/#{domain_name}.json $(WASM); \\
|
|
1960
|
+
\telse \\
|
|
1961
|
+
\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."; \\
|
|
1962
|
+
\tfi
|
|
1963
|
+
\t@echo "verify-parity-#{logical_id}: fuzzing $(WASM) against generated sequences (ADR 0037's bridge, WARN-ONLY -- see this target's own comment)..."
|
|
1964
|
+
\t-@cd #{root} && bin/rust_conformance_fuzz #{domain} $(WASM)
|
|
1965
|
+
|
|
1966
|
+
# `make mint-era` — takes #{stack_name}'s RDS instance from freshly
|
|
1967
|
+
# created to "era 1 minted, ready for HECKS_DOMAIN/HECKS_ERA to
|
|
1968
|
+
# resolve" (template.yaml's own env vars already assume era 1 —
|
|
1969
|
+
# this is what makes that true). Run ONCE per fresh database, before
|
|
1970
|
+
# the first real `sam deploy` of the Lambda itself; safe to re-run
|
|
1971
|
+
# (a second boot against an already-held era 1 is a no-op — see
|
|
1972
|
+
# era_resolver.rb's own quiet-reboot branch). Stands up bastion.yaml
|
|
1973
|
+
# as its own sibling stack, uses it for one Ruby boot over an SSM
|
|
1974
|
+
# tunnel, then deletes it — nothing from this target is left running
|
|
1975
|
+
# afterward.
|
|
1976
|
+
ROOT := #{root}
|
|
1977
|
+
DOMAIN := #{domain}
|
|
1978
|
+
STACK := #{stack_name}
|
|
1979
|
+
BASTION_STACK := #{stack_name}-bastion
|
|
1980
|
+
|
|
1981
|
+
mint-era:
|
|
1982
|
+
#{mint_era_recipe}
|
|
1983
|
+
|
|
1984
|
+
# `make scaffold-translation` — run this when a deploy's own
|
|
1985
|
+
# pre-flight boot check (or `make mint-era` directly) refuses with
|
|
1986
|
+
# "the shape changed (era N) and no translation edge covers it".
|
|
1987
|
+
# Diffs the held era against the current bluebook over the same
|
|
1988
|
+
# bastion tunnel mint-era uses and WRITES a translations/*.bluebook
|
|
1989
|
+
# edge file into this domain's own repo — confident rules inline,
|
|
1990
|
+
# genuine ambiguities as `unresolved` lines a human has to resolve by
|
|
1991
|
+
# hand (a rename, a drop, a compute — never guessed). Review the
|
|
1992
|
+
# written file, then `make translation-audit`.
|
|
1993
|
+
scaffold-translation:
|
|
1994
|
+
#{scaffold_translation_recipe}
|
|
1995
|
+
|
|
1996
|
+
# `make translation-audit` — verifies a written (or still-pending)
|
|
1997
|
+
# translation edge over the same tunnel: every translated state
|
|
1998
|
+
# against the new era's own types/invariants/lifecycle, the compiled
|
|
1999
|
+
# SQL against the port's own reference transform, and a before/after
|
|
2000
|
+
# sample of real records printed for human review. Re-run
|
|
2001
|
+
# `make deploy` (or `make mint-era`) once this passes — that's what
|
|
2002
|
+
# actually applies the edge; this only checks it.
|
|
2003
|
+
translation-audit:
|
|
2004
|
+
#{translation_audit_recipe}
|
|
2005
|
+
|
|
2006
|
+
# `make migrate-console-settings` — runs this domain's own
|
|
2007
|
+
# bin/migrate_console_settings (if it has one) over the same bastion
|
|
2008
|
+
# tunnel every other Postgres-touching target here uses, against the
|
|
2009
|
+
# real deployed database instead of local dev. A one-time data
|
|
2010
|
+
# migration, not a repeatable ops procedure like mint-era/scaffold-
|
|
2011
|
+
# translation — generated the same way regardless, per this
|
|
2012
|
+
# project's own standing rule against hand-run ops steps.
|
|
2013
|
+
migrate-console-settings:
|
|
2014
|
+
#{migrate_console_settings_recipe}
|
|
2015
|
+
|
|
2016
|
+
# `make rename-schema OLD=<old> NEW=<new>` — a native `ALTER SCHEMA
|
|
2017
|
+
# ... RENAME TO ...` over the same bastion tunnel mint-era uses,
|
|
2018
|
+
# for when this domain's own declared identity changes
|
|
2019
|
+
# (`formerly_known_as`) and its storage schema should follow. Purely
|
|
2020
|
+
# a Postgres namespace rename: every table/row/sequence inside it is
|
|
2021
|
+
# untouched, so this is safe to run against a live, in-use database
|
|
2022
|
+
# — the only requirement is that nothing else names the schema by
|
|
2023
|
+
# its OLD name at the moment this runs (this domain's own deployed
|
|
2024
|
+
# Lambda's HECKS_SCHEMA env var, most directly — run `make deploy`
|
|
2025
|
+
# right before or right after this, not with a long gap either way).
|
|
2026
|
+
rename-schema:
|
|
2027
|
+
#{rename_schema_recipe}
|
|
2028
|
+
|
|
2029
|
+
#{pg_version ? <<~PGNATIVE : ""}
|
|
2030
|
+
# #{web_logical_id}'s own `pg` gem needs a native extension SAM's
|
|
2031
|
+
# default Ruby builder can't produce correctly: the precompiled
|
|
2032
|
+
# aarch64-linux binary needs GLIBC 2.29+, but Lambda's ruby3.2
|
|
2033
|
+
# MANAGED runtime is Amazon Linux 2 (glibc 2.26) — a real, live
|
|
2034
|
+
# "Init<NameError>: uninitialized constant PG::Error" caught this
|
|
2035
|
+
# (rescuing PG::Error itself failed to resolve, because pg's own
|
|
2036
|
+
# `require` died before ever reaching the file that defines it).
|
|
2037
|
+
# Built once, from source, in the SAME container `sam build
|
|
2038
|
+
# --use-container` itself uses (its glibc, not the host's, is what
|
|
2039
|
+
# has to match the deployed runtime) — then cached under
|
|
2040
|
+
# PG_NATIVE_DIR so every later `make deploy` skips straight to the
|
|
2041
|
+
# fast path. Delete that directory to force a clean rebuild.
|
|
2042
|
+
#
|
|
2043
|
+
# OpenSSL is built `no-shared` and linked STATICALLY into libpq, so
|
|
2044
|
+
# the libpq.so this produces carries no runtime dependency on
|
|
2045
|
+
# whatever OpenSSL (if any) happens to already be on the Lambda
|
|
2046
|
+
# execution image — sidesteps the AL2 package conflict between
|
|
2047
|
+
# `openssl-libs` and the build image's own pre-installed
|
|
2048
|
+
# `openssl-snapsafe-libs` entirely, rather than fighting it.
|
|
2049
|
+
PG_NATIVE_DIR := #{root}/tmp/pg-native-arm64-ruby3.2
|
|
2050
|
+
|
|
2051
|
+
$(PG_NATIVE_DIR)/pg_ext.so:
|
|
2052
|
+
\t@mkdir -p $(PG_NATIVE_DIR)
|
|
2053
|
+
\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)..."
|
|
2054
|
+
\tdocker run --rm --platform linux/arm64 -v $(PG_NATIVE_DIR):/out \\
|
|
2055
|
+
\t\t--entrypoint /bin/bash public.ecr.aws/sam/build-ruby3.2:latest-arm64 -c ' \\
|
|
2056
|
+
\t\t\tset -e; \\
|
|
2057
|
+
\t\t\tyum install -y perl-IPC-Cmd >/dev/null; \\
|
|
2058
|
+
\t\t\tcd /tmp; \\
|
|
2059
|
+
\t\t\tcurl -sL https://www.openssl.org/source/openssl-3.0.15.tar.gz | tar xz; \\
|
|
2060
|
+
\t\t\tcd openssl-3.0.15; \\
|
|
2061
|
+
\t\t\t./Configure linux-aarch64 no-shared no-tests --prefix=/tmp/openssl-install >/dev/null; \\
|
|
2062
|
+
\t\t\tmake -j$$(nproc) >/dev/null; \\
|
|
2063
|
+
\t\t\tmake install_sw >/dev/null; \\
|
|
2064
|
+
\t\t\tcd /tmp; \\
|
|
2065
|
+
\t\t\tcurl -sL https://ftp.postgresql.org/pub/source/v16.4/postgresql-16.4.tar.gz | tar xz; \\
|
|
2066
|
+
\t\t\tcd postgresql-16.4; \\
|
|
2067
|
+
\t\t\tCPPFLAGS="-I/tmp/openssl-install/include" LDFLAGS="-L/tmp/openssl-install/lib" \\
|
|
2068
|
+
\t\t\t\t./configure --without-readline --without-zlib --without-icu --with-ssl=openssl --prefix=/tmp/pg-install >/dev/null; \\
|
|
2069
|
+
\t\t\tmake -C src/include install >/dev/null; \\
|
|
2070
|
+
\t\t\tmake -C src/interfaces/libpq -j$$(nproc) install >/dev/null; \\
|
|
2071
|
+
\t\t\tcd /tmp; \\
|
|
2072
|
+
\t\t\tgem fetch pg -v #{pg_version} --platform ruby >/dev/null; \\
|
|
2073
|
+
\t\t\tgem unpack pg-#{pg_version}.gem --target=/tmp/gemsrc >/dev/null; \\
|
|
2074
|
+
\t\t\tcd /tmp/gemsrc/pg-#{pg_version}/ext; \\
|
|
2075
|
+
\t\t\truby -I.. -I../lib extconf.rb --with-pg-include=/tmp/pg-install/include --with-pg-lib=/tmp/pg-install/lib >/dev/null; \\
|
|
2076
|
+
\t\t\tmake >/dev/null; \\
|
|
2077
|
+
\t\t\tcp pg_ext.so /out/pg_ext.so; \\
|
|
2078
|
+
\t\t\tcp /tmp/pg-install/lib/libpq.so.5.16 /out/libpq.so.5.16; \\
|
|
2079
|
+
\t\t'
|
|
2080
|
+
|
|
2081
|
+
# `find`, not a hardcoded gem-version path — survives #{domain}'s
|
|
2082
|
+
# own pg version bumping in its Gemfile.lock without this Makefile
|
|
2083
|
+
# needing regeneration to match. libpq.so.5.16 lands beside the
|
|
2084
|
+
# rest of #{web_logical_id}'s code at /var/task/lib — the ruby3.2
|
|
2085
|
+
# base image's OWN baked-in LD_LIBRARY_PATH default already searches
|
|
2086
|
+
# that exact path, so no rpath patching AND no explicit
|
|
2087
|
+
# LD_LIBRARY_PATH override belongs in template.yaml (setting one
|
|
2088
|
+
# there REPLACES the image's full default list instead of extending
|
|
2089
|
+
# it — a real, live "libcrypt.so.1: cannot open shared object file"
|
|
2090
|
+
# caught this — #{web_logical_id}'s own Environment no longer sets
|
|
2091
|
+
# it at all, on purpose).
|
|
2092
|
+
.PHONY: patch-pg-native
|
|
2093
|
+
patch-pg-native: $(PG_NATIVE_DIR)/pg_ext.so
|
|
2094
|
+
\t@PG_EXT=$$(find .aws-sam/build/#{web_logical_id}/vendor/bundle -path "*/pg-*/lib/3.2/pg_ext.so" | head -1); \\
|
|
2095
|
+
\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; }; \\
|
|
2096
|
+
\t\tcp $(PG_NATIVE_DIR)/pg_ext.so "$$PG_EXT"
|
|
2097
|
+
\t@mkdir -p .aws-sam/build/#{web_logical_id}/lib
|
|
2098
|
+
\tcp $(PG_NATIVE_DIR)/libpq.so.5.16 .aws-sam/build/#{web_logical_id}/lib/libpq.so.5.16
|
|
2099
|
+
\tln -sf libpq.so.5.16 .aws-sam/build/#{web_logical_id}/lib/libpq.so.5
|
|
2100
|
+
|
|
2101
|
+
PGNATIVE
|
|
2102
|
+
#{google_oauth_present ? <<~SYNCOAUTH : ""}
|
|
2103
|
+
# Owns #{stack_name}-web-google-oauth's WHOLE lifecycle (create on
|
|
2104
|
+
# first run, update every run after — idempotent, cheap, no reason
|
|
2105
|
+
# to cache the way patch-pg-native's own multi-minute build does) —
|
|
2106
|
+
# deliberately OUTSIDE CloudFormation, straight from #{domain}'s own
|
|
2107
|
+
# gitignored .env.local, so the real client_id/secret never lands in
|
|
2108
|
+
# this generated, git-TRACKED template.yaml as plaintext. Read by
|
|
2109
|
+
# name (`{{resolve:secretsmanager:#{stack_name}-web-google-oauth:...}}`,
|
|
2110
|
+
# template.yaml's own Environment — #{rust_web ? logical_id : web_logical_id}'s
|
|
2111
|
+
# own), not by `!Ref` — see that comment for why this secret isn't a
|
|
2112
|
+
# stack resource at all. NOT nested inside patch-pg-native's own
|
|
2113
|
+
# pg_version gate above -- a real, live "No rule to make target
|
|
2114
|
+
# `sync-google-oauth'" caught that this target used to only exist
|
|
2115
|
+
# when a Ruby WebFunction (with its own pg gem) was also present,
|
|
2116
|
+
# even though Google OAuth itself has nothing to do with pg at all.
|
|
2117
|
+
.PHONY: sync-google-oauth
|
|
2118
|
+
sync-google-oauth:
|
|
2119
|
+
\t@CLIENT_ID=$$(grep '^GOOGLE_CLIENT_ID=' #{domain}/.env.local | cut -d= -f2-); \\
|
|
2120
|
+
\t\tCLIENT_SECRET=$$(grep '^GOOGLE_CLIENT_SECRET=' #{domain}/.env.local | cut -d= -f2-); \\
|
|
2121
|
+
\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; }; \\
|
|
2122
|
+
\t\tSECRET_JSON=$$(ruby -rjson -e 'puts JSON.generate({client_id: ARGV[0], client_secret: ARGV[1]})' "$$CLIENT_ID" "$$CLIENT_SECRET"); \\
|
|
2123
|
+
\t\taws secretsmanager put-secret-value --secret-id #{stack_name}-web-google-oauth --secret-string "$$SECRET_JSON" >/dev/null 2>&1 || \\
|
|
2124
|
+
\t\taws secretsmanager create-secret --name #{stack_name}-web-google-oauth --secret-string "$$SECRET_JSON" >/dev/null
|
|
2125
|
+
|
|
2126
|
+
SYNCOAUTH
|
|
2127
|
+
# `make deploy` — THE one command. Chains `sam build`, `sam deploy`
|
|
2128
|
+
# (creates/updates the VPC+RDS+Lambda stack), then `mint-era` (safe
|
|
2129
|
+
# to run every time — a second boot against an already-held era 1
|
|
2130
|
+
# is a no-op, era_resolver.rb's own quiet-reboot branch) so a fresh
|
|
2131
|
+
# database always ends this command with era 1 actually held, not a
|
|
2132
|
+
# separate step someone has to remember. No hand-run AWS CLI
|
|
2133
|
+
# sequence anywhere in this path.
|
|
2134
|
+
#{google_oauth_present ? <<~OAUTHNOTE : ""}
|
|
2135
|
+
# deploy's own last step below looks up #{web_logical_id}'s
|
|
2136
|
+
# CURRENTLY deployed Function URL live (not tracked anywhere
|
|
2137
|
+
# generated) and passes it as WebRedirectBaseUrl -- see that
|
|
2138
|
+
# Parameter's own comment, above, for why GOOGLE_REDIRECT_URI can't
|
|
2139
|
+
# just be a CloudFormation intrinsic. Empty on a genuine first
|
|
2140
|
+
# deploy (before the Url exists at all); every deploy after that
|
|
2141
|
+
# finds the real, STABLE hostname (confirmed unchanged across every
|
|
2142
|
+
# redeploy this session) and self-heals GOOGLE_REDIRECT_URI to match.
|
|
2143
|
+
OAUTHNOTE
|
|
2144
|
+
#{shared ? <<~SHAREDNOTE : ""}
|
|
2145
|
+
# deploy's own last step below looks up #{owner_domain_name}'s live
|
|
2146
|
+
# stack Outputs (never tracked anywhere generated -- same reasoning
|
|
2147
|
+
# as WebRedirectBaseUrl's own live lookup above, and the SAME
|
|
2148
|
+
# `aws cloudformation describe-stacks` pattern mint-era's own eval
|
|
2149
|
+
# chain, below, already proves works against a sibling stack) and
|
|
2150
|
+
# passes them as this template's own Owning* Parameters.
|
|
2151
|
+
SHAREDNOTE
|
|
2152
|
+
.PHONY: deploy mint-era
|
|
2153
|
+
deploy:
|
|
2154
|
+
#{if dispatch_none
|
|
2155
|
+
"\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"
|
|
2156
|
+
elsif pg_version
|
|
2157
|
+
"\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}"
|
|
2158
|
+
elsif web_handler_present
|
|
2159
|
+
"\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}"
|
|
2160
|
+
else
|
|
2161
|
+
"\tsam build\n\t$(MAKE) verify-parity-#{logical_id}"
|
|
2162
|
+
end}
|
|
2163
|
+
#{shared ? "" : <<~PREDEPLOYBRIDGE.each_line.map { |l| "\t" + l }.join.rstrip
|
|
2164
|
+
# THE TRANSACTION-SAFETY GAP, closed for the case that actually
|
|
2165
|
+
# matters: `sam deploy` below is what flips this Lambda's own
|
|
2166
|
+
# HECKS_DOMAIN/HECKS_SCHEMA env vars live — the INSTANT it
|
|
2167
|
+
# completes, real traffic can hit a domain/schema combination
|
|
2168
|
+
# whose era history hasn't been bridged yet if THIS domain was
|
|
2169
|
+
# just renamed (formerly_known_as) or its schema just moved.
|
|
2170
|
+
# `mint-era` already bridges that (era_resolver.rb's own
|
|
2171
|
+
# rename_domain! path, idempotent either way) — it only ran
|
|
2172
|
+
# AFTER `sam deploy` before this existed, which is exactly the
|
|
2173
|
+
# order that left a real live window open the one time this was
|
|
2174
|
+
# actually run in anger (an SSM tunnel flaked mid-bridge, after
|
|
2175
|
+
# the Lambda had already flipped over).
|
|
2176
|
+
#
|
|
2177
|
+
# STILL RUNS AFTER TOO (unchanged, below) — this pre-check
|
|
2178
|
+
# cannot replace that: a domain's VERY FIRST deploy has no
|
|
2179
|
+
# stack, hence no RDS, hence nothing to tunnel to yet, so
|
|
2180
|
+
# mint-era's real first run has to stay where it always was,
|
|
2181
|
+
# once `sam deploy` has just created that RDS instance. This
|
|
2182
|
+
# only ADDS a second, EARLIER run for a domain that already has
|
|
2183
|
+
# a live stack (a rename or an ordinary redeploy) — cheap and
|
|
2184
|
+
# safe either way, since mint-era's own quiet-reboot path is a
|
|
2185
|
+
# no-op the moment nothing has actually changed.
|
|
2186
|
+
#
|
|
2187
|
+
# See predeploy_bridge_shell's own comment (bin/project_deploy,
|
|
2188
|
+
# above, right before deploy_recipe_lines) for why this branches
|
|
2189
|
+
# on google_oauth_present at all -- the short version: adding
|
|
2190
|
+
# Google OAuth to an EXISTING stack would otherwise deadlock this
|
|
2191
|
+
# exact pre-check against outputs the upcoming sam deploy (not
|
|
2192
|
+
# yet run) hasn't created.
|
|
2193
|
+
#{predeploy_bridge_shell}
|
|
2194
|
+
PREDEPLOYBRIDGE
|
|
2195
|
+
}
|
|
2196
|
+
#{deploy_recipe_lines}
|
|
2197
|
+
\t$(MAKE) mint-era
|
|
2198
|
+
MAKE
|
|
2199
|
+
|
|
2200
|
+
# Everything `sam deploy --guided` asks interactively — stack name,
|
|
2201
|
+
# region, capabilities, rollback behavior — is already known once the
|
|
2202
|
+
# domain and its deployed_to("AwsLambda") block are: none of it needs
|
|
2203
|
+
# a human typing answers on every deploy. There's no secret to carry
|
|
2204
|
+
# here either now: DATABASE_URL is composed inside the template itself
|
|
2205
|
+
# from RDS's own auto-generated Secrets Manager password, so nothing
|
|
2206
|
+
# gets typed, saved, or passed as a --parameter-overrides flag at all.
|
|
2207
|
+
samconfig_toml = <<~TOML
|
|
2208
|
+
# GENERATED by bin/project_deploy #{domain} — re-run it to refresh
|
|
2209
|
+
# this file rather than hand-editing.
|
|
2210
|
+
version = 0.1
|
|
2211
|
+
|
|
2212
|
+
[default.deploy.parameters]
|
|
2213
|
+
stack_name = "#{stack_name}"
|
|
2214
|
+
region = "#{region}"
|
|
2215
|
+
resolve_s3 = true
|
|
2216
|
+
s3_prefix = "#{stack_name}"
|
|
2217
|
+
capabilities = "CAPABILITY_IAM"
|
|
2218
|
+
confirm_changeset = false
|
|
2219
|
+
disable_rollback = false
|
|
2220
|
+
TOML
|
|
2221
|
+
|
|
2222
|
+
files = { "template.yaml" => template_yaml }
|
|
2223
|
+
files["bastion.yaml"] = bastion_yaml if bastion_yaml
|
|
2224
|
+
files["Makefile"] = makefile_content
|
|
2225
|
+
files["samconfig.toml"] = samconfig_toml
|
|
2226
|
+
files
|
|
2227
|
+
end
|
|
2228
|
+
|
|
2229
|
+
# Builds the CloudFront/WAFv2/logging resources a `PII`-marked domain gets fronted by.
|
|
2230
|
+
#
|
|
2231
|
+
# **`PII` → CloudFront** — once a domain marks a field "pii", its own public
|
|
2232
|
+
# surface (`WebFunction` when one exists, `#{logical_id}` otherwise —
|
|
2233
|
+
# `fronted_logical_id`, computed where both are known, in `call`) gets
|
|
2234
|
+
# fronted by a distribution carrying a WAFv2 WebACL (AWS managed rule
|
|
2235
|
+
# groups), security response headers, geo-restriction, and access
|
|
2236
|
+
# logging — every other domain's own template is untouched
|
|
2237
|
+
# (`pii_detected` false means `call` never invokes this at all).
|
|
2238
|
+
#
|
|
2239
|
+
# **`OAC` only for the AWS_IAM case** (`use_oac`) — the already-public
|
|
2240
|
+
# `WebFunction`/`rust_web` shape (`AuthType: NONE`) is left exactly as
|
|
2241
|
+
# reachable as it already was; CloudFront adds WAF/headers/geo/logging
|
|
2242
|
+
# on top of that, it does not change who could already call the
|
|
2243
|
+
# Function URL directly. `#{logical_id}` itself (the AWS_IAM,
|
|
2244
|
+
# internal-dispatch default) is the opposite: OAC lets CloudFront sign
|
|
2245
|
+
# requests to it via SigV4 while `PiiLambdaInvokePermission`'s own
|
|
2246
|
+
# `SourceArn` admits only this one distribution — direct, unsigned
|
|
2247
|
+
# access to the Function URL stays refused exactly as it was before
|
|
2248
|
+
# this ran.
|
|
2249
|
+
#
|
|
2250
|
+
# **Managed cache/origin-request policy ids** — not custom resources.
|
|
2251
|
+
# `4135ea2d-6df8-44a3-9df3-4b5a84be39ad`/`216adef6-5c7f-47e4-b989-
|
|
2252
|
+
# 5492eafa07d3` are AWS's own permanent, account-independent
|
|
2253
|
+
# `Managed-CachingDisabled`/`Managed-AllViewer` ids (the same ones the
|
|
2254
|
+
# console's own dropdown offers) — this fronts a Lambda dispatch
|
|
2255
|
+
# endpoint, not a static site; caching a response meant for exactly
|
|
2256
|
+
# one caller would be a real correctness bug, not a performance choice
|
|
2257
|
+
# made once here.
|
|
2258
|
+
#
|
|
2259
|
+
# @param fronted_logical_id [String] the Lambda resource this distribution fronts
|
|
2260
|
+
# @param use_oac [Boolean] true only when `fronted_logical_id`'s own FunctionUrlConfig is
|
|
2261
|
+
# AWS_IAM (never true for WebFunction/rust_web, both always `NONE`)
|
|
2262
|
+
# @param geo_restriction_type ["none", "allowlist", "blocklist"] `deployed_to`'s own
|
|
2263
|
+
# `geo_restriction` setting; "none" (no restriction, structurally present so a later
|
|
2264
|
+
# change is a one-line `deployed_to` edit, not a template rewrite) when unset
|
|
2265
|
+
# @param geo_restriction_countries [Array<String>] ISO 3166-1 alpha-2 codes; ignored when
|
|
2266
|
+
# `geo_restriction_type` is "none"
|
|
2267
|
+
# @return [String] the Resources entries to splice in before Outputs:, absolutely
|
|
2268
|
+
# indented to 2 spaces (this stack's own top-level Resources entry column)
|
|
2269
|
+
def pii_cloudfront_yaml(fronted_logical_id:, use_oac:, geo_restriction_type:, geo_restriction_countries:)
|
|
2270
|
+
reindent = ->(text) { text.each_line.map { |line| line.strip.empty? ? line : " #{line}" }.join }
|
|
2271
|
+
|
|
2272
|
+
# Not pre-reindented (unlike the return value as a whole, below) — each
|
|
2273
|
+
# is spliced back into the still-being-dedented RESOURCES heredoc via
|
|
2274
|
+
# `#{...}`, which the outer `reindent.call` already shifts by 2
|
|
2275
|
+
# spaces once; reindenting here too would double it.
|
|
2276
|
+
origin_access_control = use_oac ? <<~OAC : ""
|
|
2277
|
+
PiiOriginAccessControl:
|
|
2278
|
+
Type: AWS::CloudFront::OriginAccessControl
|
|
2279
|
+
Properties:
|
|
2280
|
+
OriginAccessControlConfig:
|
|
2281
|
+
Name: !Sub "${AWS::StackName}-pii-oac"
|
|
2282
|
+
OriginAccessControlOriginType: lambda
|
|
2283
|
+
SigningBehavior: always
|
|
2284
|
+
SigningProtocol: sigv4
|
|
2285
|
+
OAC
|
|
2286
|
+
|
|
2287
|
+
invoke_permission = <<~PERMISSION
|
|
2288
|
+
PiiLambdaInvokePermission:
|
|
2289
|
+
Type: AWS::Lambda::Permission
|
|
2290
|
+
Properties:
|
|
2291
|
+
Action: lambda:InvokeFunctionUrl
|
|
2292
|
+
FunctionName: !Ref #{fronted_logical_id}
|
|
2293
|
+
Principal: cloudfront.amazonaws.com
|
|
2294
|
+
SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${PiiDistribution}"
|
|
2295
|
+
FunctionUrlAuthType: #{use_oac ? "AWS_IAM" : "NONE"}
|
|
2296
|
+
PERMISSION
|
|
2297
|
+
|
|
2298
|
+
countries_yaml = geo_restriction_countries.map { |code| " - #{code}" }.join("\n")
|
|
2299
|
+
|
|
2300
|
+
reindent.call(<<~RESOURCES)
|
|
2301
|
+
PiiAccessLogsBucket:
|
|
2302
|
+
Type: AWS::S3::Bucket
|
|
2303
|
+
Properties:
|
|
2304
|
+
# `BucketOwnerPreferred`, not the newer `BucketOwnerEnforced`
|
|
2305
|
+
# default — CloudFront's own classic access-log delivery
|
|
2306
|
+
# (`Logging:`, on PiiDistribution below) still authorizes itself
|
|
2307
|
+
# via a canned ACL (`AccessControlTranslation` between accounts is
|
|
2308
|
+
# a distinct, newer mechanism this bucket has no other account to
|
|
2309
|
+
# need), which an ACLs-disabled bucket refuses outright.
|
|
2310
|
+
OwnershipControls:
|
|
2311
|
+
Rules:
|
|
2312
|
+
- ObjectOwnership: BucketOwnerPreferred
|
|
2313
|
+
AccessControl: LogDeliveryWrite
|
|
2314
|
+
LifecycleConfiguration:
|
|
2315
|
+
Rules:
|
|
2316
|
+
- Id: ExpirePiiAccessLogs
|
|
2317
|
+
Status: Enabled
|
|
2318
|
+
ExpirationInDays: 365
|
|
2319
|
+
|
|
2320
|
+
PiiResponseHeadersPolicy:
|
|
2321
|
+
Type: AWS::CloudFront::ResponseHeadersPolicy
|
|
2322
|
+
Properties:
|
|
2323
|
+
ResponseHeadersPolicyConfig:
|
|
2324
|
+
Name: !Sub "${AWS::StackName}-pii-headers"
|
|
2325
|
+
SecurityHeadersConfig:
|
|
2326
|
+
StrictTransportSecurity:
|
|
2327
|
+
AccessControlMaxAgeSec: 63072000
|
|
2328
|
+
IncludeSubdomains: true
|
|
2329
|
+
Override: true
|
|
2330
|
+
ContentTypeOptions:
|
|
2331
|
+
Override: true
|
|
2332
|
+
FrameOptions:
|
|
2333
|
+
FrameOption: DENY
|
|
2334
|
+
Override: true
|
|
2335
|
+
ReferrerPolicy:
|
|
2336
|
+
ReferrerPolicy: same-origin
|
|
2337
|
+
Override: true
|
|
2338
|
+
XSSProtection:
|
|
2339
|
+
ModeBlock: true
|
|
2340
|
+
Protection: true
|
|
2341
|
+
Override: true
|
|
2342
|
+
|
|
2343
|
+
PiiWebAcl:
|
|
2344
|
+
Type: AWS::WAFv2::WebACL
|
|
2345
|
+
Properties:
|
|
2346
|
+
Name: !Sub "${AWS::StackName}-pii-waf"
|
|
2347
|
+
Scope: CLOUDFRONT
|
|
2348
|
+
DefaultAction:
|
|
2349
|
+
Allow: {}
|
|
2350
|
+
VisibilityConfig:
|
|
2351
|
+
SampledRequestsEnabled: true
|
|
2352
|
+
CloudWatchMetricsEnabled: true
|
|
2353
|
+
MetricName: !Sub "${AWS::StackName}PiiWebAcl"
|
|
2354
|
+
Rules:
|
|
2355
|
+
- Name: AWSManagedRulesCommonRuleSet
|
|
2356
|
+
Priority: 0
|
|
2357
|
+
OverrideAction:
|
|
2358
|
+
None: {}
|
|
2359
|
+
Statement:
|
|
2360
|
+
ManagedRuleGroupStatement:
|
|
2361
|
+
VendorName: AWS
|
|
2362
|
+
Name: AWSManagedRulesCommonRuleSet
|
|
2363
|
+
VisibilityConfig:
|
|
2364
|
+
SampledRequestsEnabled: true
|
|
2365
|
+
CloudWatchMetricsEnabled: true
|
|
2366
|
+
MetricName: !Sub "${AWS::StackName}PiiCommonRuleSet"
|
|
2367
|
+
- Name: AWSManagedRulesKnownBadInputsRuleSet
|
|
2368
|
+
Priority: 1
|
|
2369
|
+
OverrideAction:
|
|
2370
|
+
None: {}
|
|
2371
|
+
Statement:
|
|
2372
|
+
ManagedRuleGroupStatement:
|
|
2373
|
+
VendorName: AWS
|
|
2374
|
+
Name: AWSManagedRulesKnownBadInputsRuleSet
|
|
2375
|
+
VisibilityConfig:
|
|
2376
|
+
SampledRequestsEnabled: true
|
|
2377
|
+
CloudWatchMetricsEnabled: true
|
|
2378
|
+
MetricName: !Sub "${AWS::StackName}PiiKnownBadInputs"
|
|
2379
|
+
|
|
2380
|
+
#{origin_access_control}
|
|
2381
|
+
PiiDistribution:
|
|
2382
|
+
Type: AWS::CloudFront::Distribution
|
|
2383
|
+
Properties:
|
|
2384
|
+
DistributionConfig:
|
|
2385
|
+
Enabled: true
|
|
2386
|
+
HttpVersion: http2
|
|
2387
|
+
WebACLId: !GetAtt PiiWebAcl.Arn
|
|
2388
|
+
Restrictions:
|
|
2389
|
+
GeoRestriction:
|
|
2390
|
+
RestrictionType: #{geo_restriction_type}
|
|
2391
|
+
Locations:#{geo_restriction_type == "none" ? " []" : "\n" + countries_yaml}
|
|
2392
|
+
Logging:
|
|
2393
|
+
Bucket: !GetAtt PiiAccessLogsBucket.RegionalDomainName
|
|
2394
|
+
IncludeCookies: false
|
|
2395
|
+
Prefix: cloudfront/
|
|
2396
|
+
Origins:
|
|
2397
|
+
- Id: PiiOrigin
|
|
2398
|
+
# `!Select [2, !Split ["/", ...]]` — the standard AWS-documented
|
|
2399
|
+
# way to pull the bare hostname out of a Function URL's own
|
|
2400
|
+
# `https://<id>.lambda-url.<region>.on.aws/` shape for use as a
|
|
2401
|
+
# CustomOriginConfig DomainName, which admits no scheme or path.
|
|
2402
|
+
DomainName: !Select [2, !Split ["/", !GetAtt #{fronted_logical_id}Url.FunctionUrl]]
|
|
2403
|
+
CustomOriginConfig:
|
|
2404
|
+
OriginProtocolPolicy: https-only
|
|
2405
|
+
OriginSSLProtocols: [TLSv1.2]
|
|
2406
|
+
#{use_oac ? " OriginAccessControlId: !Ref PiiOriginAccessControl" : ""}
|
|
2407
|
+
DefaultCacheBehavior:
|
|
2408
|
+
TargetOriginId: PiiOrigin
|
|
2409
|
+
ViewerProtocolPolicy: redirect-to-https
|
|
2410
|
+
AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
|
|
2411
|
+
CachedMethods: [GET, HEAD]
|
|
2412
|
+
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
|
2413
|
+
OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3
|
|
2414
|
+
ResponseHeadersPolicyId: !Ref PiiResponseHeadersPolicy
|
|
2415
|
+
|
|
2416
|
+
#{invoke_permission}
|
|
2417
|
+
RESOURCES
|
|
2418
|
+
end
|
|
2419
|
+
|
|
2420
|
+
end
|
|
2421
|
+
end
|
|
2422
|
+
end
|
|
2423
|
+
end
|