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