hecks 1.5.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/identity_registry.rb +7 -1
- data/lib/hecks/bluebook/capabilities.rb +27 -0
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +26 -1
- data/lib/hecks/bluebook/hexagon.rb +18 -3
- data/lib/hecks/corpus.rb +123 -9
- data/lib/hecks/framework/bluebook/identity.bluebook +11 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +9 -2
- data/lib/hecks/fuzzing/isolated_boot.rb +12 -2
- data/lib/hecks/language/bluebook/bluebook.bluebook +10 -1
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +6 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +32 -2
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +41 -0
- data/lib/hecks/projections/deploy/fargate.rb +273 -26
- data/lib/hecks/projections/deploy/lambda.rb +21 -4
- data/lib/hecks/projector/exporter.rb +48 -0
- data/lib/hecks/router/namespace_installer.rb +10 -0
- data/lib/hecks/runtime/registry/verification.rb +84 -2
- data/lib/hecks/runtime/registry.rb +94 -3
- data/lib/hecks/version.rb +1 -1
- metadata +2 -2
|
@@ -21,9 +21,13 @@ module Hecks
|
|
|
21
21
|
# `AWS::ECS::TaskDefinition` (`RequiresCompatibilities: [FARGATE]`,
|
|
22
22
|
# `NetworkMode: awsvpc`) running one container built from the
|
|
23
23
|
# domain's own `rust/host`, an `AWS::ECS::Service` behind an
|
|
24
|
-
# Application Load Balancer
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# Application Load Balancer fronted by an `AWS::CloudFront::
|
|
25
|
+
# Distribution` (real HTTPS, and a `DefaultCacheBehavior` pinned to
|
|
26
|
+
# Managed-CachingDisabled — see that resource's own comment for
|
|
27
|
+
# why nothing more permissive is a safe default here), and
|
|
28
|
+
# least-privilege task execution/task roles — plus the same private
|
|
29
|
+
# VPC/RDS-or-Aurora instance and temporary era-minting bastion
|
|
30
|
+
# `Lambda` generates, via `Shared`.
|
|
27
31
|
#
|
|
28
32
|
# No SAM: this is deployed with plain `aws cloudformation deploy`,
|
|
29
33
|
# never `sam deploy`, so there is no `samconfig.toml` here. A
|
|
@@ -35,13 +39,28 @@ module Hecks
|
|
|
35
39
|
#
|
|
36
40
|
# ## What this assumes, and does not build
|
|
37
41
|
#
|
|
38
|
-
# `rust/host`
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
42
|
+
# `rust/host` runs as a long-lived HTTP server on this domain's own
|
|
43
|
+
# `port` here, not as a Lambda custom-runtime process (`bootstrap`,
|
|
44
|
+
# `Lambda`'s own binary): `HECKS_SERVE_MODE: "1"` (below, in
|
|
45
|
+
# `ContainerDefinitions[0].Environment`) is `rust/host/src/main.rs`'s
|
|
46
|
+
# own top-of-`main` switch into `server.rs`'s axum-based server,
|
|
47
|
+
# which answers this stack's own `GET /` health check with a bare,
|
|
48
|
+
# dispatch-free `200` and routes every other request through the
|
|
49
|
+
# same per-invocation dispatch logic the Lambda target's `bootstrap`
|
|
50
|
+
# binary already runs — see `server.rs`'s own header for the
|
|
51
|
+
# concurrency reasoning (the boot-time Postgres client is already
|
|
52
|
+
# `Arc<Mutex<...>>`-shared, and already anticipated exactly this,
|
|
53
|
+
# per `dispatch.rs`'s own comment on `handle`'s locking).
|
|
54
|
+
#
|
|
55
|
+
# Sidecars and ARM64 are generated, not assumed: the Dockerfile
|
|
56
|
+
# COPYs `#{domain_name}.wasm`/`.ir.json` next to the host binary,
|
|
57
|
+
# the task sets `HECKS_WASM_PATH`/`HECKS_IR_PATH`, RuntimePlatform
|
|
58
|
+
# is ARM64, and the Makefile cross-compiles with
|
|
59
|
+
# aarch64-unknown-linux-gnu (plus the GNU cross-linker on macOS).
|
|
60
|
+
# A Shared-mode ALB sits in OwningPublicSubnetAId/BId — the private
|
|
61
|
+
# pair is unreachable from the internet (found live,
|
|
62
|
+
# lifeadelics-platform). SessionSecret is always minted: HECKS_SERVE_MODE
|
|
63
|
+
# always runs web.rs, which panics on an empty SESSION_SECRET.
|
|
45
64
|
module Fargate
|
|
46
65
|
extend Projector::Target
|
|
47
66
|
|
|
@@ -115,6 +134,15 @@ module Hecks
|
|
|
115
134
|
port = target.state[:port].value
|
|
116
135
|
aurora = database == "Aurora"
|
|
117
136
|
shared = database == "Shared"
|
|
137
|
+
rust_web = target.state[:web].value == "Rust"
|
|
138
|
+
# Same file-presence convention Lambda uses: `.env.local` is the
|
|
139
|
+
# domain's own gitignored secrets file; `make sync-google-oauth`
|
|
140
|
+
# (Lambda) owns the secret's lifecycle. Fargate only needs to
|
|
141
|
+
# *declare* GOOGLE_OAUTH_SECRET_ID + a redirect URI parameter —
|
|
142
|
+
# the secret itself is never a stack resource.
|
|
143
|
+
google_oauth_present = rust_web &&
|
|
144
|
+
File.exist?(File.join(domain, ".env.local")) &&
|
|
145
|
+
File.read(File.join(domain, ".env.local")).match?(/^GOOGLE_CLIENT_ID=\S/)
|
|
118
146
|
|
|
119
147
|
# Every policy in every loaded chapter — see `Lambda.call`'s own
|
|
120
148
|
# comment on why this reads the whole registry, not only this
|
|
@@ -192,6 +220,8 @@ module Hecks
|
|
|
192
220
|
alb_id = "#{logical_id}Alb"
|
|
193
221
|
alb_sg_id = "#{logical_id}AlbSecurityGroup"
|
|
194
222
|
listener_id = "#{logical_id}Listener"
|
|
223
|
+
distribution_id = "#{logical_id}Distribution"
|
|
224
|
+
session_secret_id = "#{logical_id}SessionSecret"
|
|
195
225
|
|
|
196
226
|
stack_outputs = Shared.stack_outputs(
|
|
197
227
|
shared: shared, db_id: db_id, db_ref_id: db_ref_id, secret_intrinsic: secret_intrinsic,
|
|
@@ -200,6 +230,40 @@ module Hecks
|
|
|
200
230
|
bastion_parameters = Shared.bastion_parameters(shared: shared, google_oauth_present: network_needs_internet)
|
|
201
231
|
Shared.check_bastion_parameters!(bastion_parameters, stack_outputs)
|
|
202
232
|
|
|
233
|
+
always_params_yaml = <<~ALWAYSPARAMS.rstrip
|
|
234
|
+
ImageTag:
|
|
235
|
+
Type: String
|
|
236
|
+
Default: latest
|
|
237
|
+
Description: ECR image tag this task pulls — never hardcode latest in the TaskDefinition; a first deploy and a later rollout share this one parameter.
|
|
238
|
+
ALWAYSPARAMS
|
|
239
|
+
oauth_params_yaml = google_oauth_present ? <<~OAUTHPARAMS.rstrip : ""
|
|
240
|
+
# Same chicken-egg as Lambda's WebRedirectBaseUrl — CloudFront's
|
|
241
|
+
# hostname does not exist until this stack does. Empty on a true
|
|
242
|
+
# first deploy; `make deploy` looks up Outputs.CloudFrontDomain
|
|
243
|
+
# after and self-heals.
|
|
244
|
+
WebRedirectBaseUrl:
|
|
245
|
+
Type: String
|
|
246
|
+
Default: ""
|
|
247
|
+
OAUTHPARAMS
|
|
248
|
+
# Built outside the template heredoc so Layout/HeredocIndentation
|
|
249
|
+
# cannot re-indent YAML that must match SessionSecretRead / env.
|
|
250
|
+
# First interpolated line sits at the `\#{...}` column; later lines
|
|
251
|
+
# get that same left pad (lambda.rb's own OAUTHPOLICY pattern).
|
|
252
|
+
oauth_task_policy_yaml = google_oauth_present ? <<~OAUTHPOLICY.rstrip : ""
|
|
253
|
+
- PolicyName: GoogleOauthSecretRead
|
|
254
|
+
PolicyDocument:
|
|
255
|
+
Version: '2012-10-17'
|
|
256
|
+
Statement:
|
|
257
|
+
- Effect: Allow
|
|
258
|
+
Action: secretsmanager:GetSecretValue
|
|
259
|
+
Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
|
|
260
|
+
OAUTHPOLICY
|
|
261
|
+
oauth_task_env_yaml = google_oauth_present ? <<~OAUTHENV.rstrip : ""
|
|
262
|
+
- Name: GOOGLE_OAUTH_SECRET_ID
|
|
263
|
+
Value: #{stack_name}-web-google-oauth
|
|
264
|
+
- Name: GOOGLE_REDIRECT_URI
|
|
265
|
+
Value: !Sub "${WebRedirectBaseUrl}/auth/google/callback"
|
|
266
|
+
OAUTHENV
|
|
203
267
|
owning_params_yaml = shared ? <<~SHAREDPARAMS.rstrip : ""
|
|
204
268
|
# The storehouse — #{owner_domain_name}'s own live stack Outputs,
|
|
205
269
|
# looked up at deploy time (the generated Makefile's own `deploy:`
|
|
@@ -212,6 +276,22 @@ module Hecks
|
|
|
212
276
|
Type: AWS::EC2::Subnet::Id
|
|
213
277
|
OwningSubnetBId:
|
|
214
278
|
Type: AWS::EC2::Subnet::Id
|
|
279
|
+
# PUBLIC subnets, NOT OwningSubnetAId/OwningSubnetBId above — a
|
|
280
|
+
# real, live deploy (lifeadelics-platform) found the ALB placed in
|
|
281
|
+
# the private pair creates successfully and reports its target
|
|
282
|
+
# health as healthy (health checks run from inside the VPC), yet
|
|
283
|
+
# is completely unreachable from outside it:
|
|
284
|
+
# OwningSubnetAId/OwningSubnetBId have MapPublicIpOnLaunch: false
|
|
285
|
+
# and no Internet Gateway route. These two — resolved from the
|
|
286
|
+
# owner stack's own PublicSubnetId/BastionSubnetId Outputs,
|
|
287
|
+
# MapPublicIpOnLaunch: true with a real 0.0.0.0/0 -> igw route —
|
|
288
|
+
# are the pair that actually works for an internet-facing ALB.
|
|
289
|
+
# Service.NetworkConfiguration below still (correctly) uses the
|
|
290
|
+
# private pair for the tasks themselves — only the ALB moves.
|
|
291
|
+
OwningPublicSubnetAId:
|
|
292
|
+
Type: AWS::EC2::Subnet::Id
|
|
293
|
+
OwningPublicSubnetBId:
|
|
294
|
+
Type: AWS::EC2::Subnet::Id
|
|
215
295
|
OwningSecurityGroupId:
|
|
216
296
|
Type: AWS::EC2::SecurityGroup::Id
|
|
217
297
|
OwningDatabaseEndpoint:
|
|
@@ -219,6 +299,7 @@ module Hecks
|
|
|
219
299
|
OwningDatabaseSecretArn:
|
|
220
300
|
Type: String
|
|
221
301
|
SHAREDPARAMS
|
|
302
|
+
parameters_yaml = [always_params_yaml, oauth_params_yaml, owning_params_yaml].reject(&:empty?).join("\n")
|
|
222
303
|
|
|
223
304
|
template_yaml = <<~YAML
|
|
224
305
|
# GENERATED by bin/project_deploy #{domain} — re-run it to refresh
|
|
@@ -235,7 +316,7 @@ module Hecks
|
|
|
235
316
|
#{infra_name} — dispatched through hecks's rust/host, running as a
|
|
236
317
|
long-lived container on AWS Fargate, backed by its own private RDS
|
|
237
318
|
Postgres instance.
|
|
238
|
-
#{
|
|
319
|
+
#{parameters_yaml.empty? ? "" : "Parameters:\n" + parameters_yaml.each_line.map { |l| " #{l}" }.join}
|
|
239
320
|
Resources:
|
|
240
321
|
#{shared ? "" : Shared.vpc_and_database_yaml(
|
|
241
322
|
db_id: db_id, db_name: db_name, infra_name: infra_name, aurora: aurora,
|
|
@@ -247,12 +328,25 @@ module Hecks
|
|
|
247
328
|
Type: AWS::EC2::SecurityGroup
|
|
248
329
|
Properties:
|
|
249
330
|
VpcId: #{shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"}
|
|
250
|
-
GroupDescription: #{alb_sg_id} -
|
|
331
|
+
GroupDescription: #{alb_sg_id} - HTTP ingress from CloudFront only, forwarded to #{logical_id} only
|
|
251
332
|
SecurityGroupIngress:
|
|
333
|
+
# pl-3b927c52 — com.amazonaws.global.cloudfront.origin-
|
|
334
|
+
# facing, AWS's own global, account-agnostic managed
|
|
335
|
+
# prefix list (confirmed: `aws ec2 describe-managed-
|
|
336
|
+
# prefix-lists`, OwnerId "AWS", same id in every
|
|
337
|
+
# account/region). NOT 0.0.0.0/0 — the whole reason
|
|
338
|
+
# #{distribution_id} above exists is Managed-
|
|
339
|
+
# CachingDisabled on every session-cookie-driven
|
|
340
|
+
# route; leaving the ALB itself open to the public
|
|
341
|
+
# internet on this same port would let anyone bypass
|
|
342
|
+
# that distribution (and its HTTPS) entirely and hit
|
|
343
|
+
# the plain-HTTP origin directly — the exact gap a
|
|
344
|
+
# code review caught the first time this resource was
|
|
345
|
+
# added.
|
|
252
346
|
- IpProtocol: tcp
|
|
253
347
|
FromPort: 80
|
|
254
348
|
ToPort: 80
|
|
255
|
-
|
|
349
|
+
SourcePrefixListId: pl-3b927c52
|
|
256
350
|
|
|
257
351
|
#{logical_id}IngressFromAlb:
|
|
258
352
|
Type: AWS::EC2::SecurityGroupIngress
|
|
@@ -281,6 +375,19 @@ module Hecks
|
|
|
281
375
|
LogGroupName: /ecs/#{stack_name}
|
|
282
376
|
RetentionInDays: 30
|
|
283
377
|
|
|
378
|
+
# Always minted — HECKS_SERVE_MODE always runs web.rs, which
|
|
379
|
+
# panics on an empty SESSION_SECRET (found live: hecksagain-pizzas
|
|
380
|
+
# 502 after the Aurora cutover). rust/host fetches this at cold
|
|
381
|
+
# start (secrets.rs), never a plain env var.
|
|
382
|
+
#{session_secret_id}:
|
|
383
|
+
Type: AWS::SecretsManager::Secret
|
|
384
|
+
Properties:
|
|
385
|
+
GenerateSecretString:
|
|
386
|
+
SecretStringTemplate: '{}'
|
|
387
|
+
GenerateStringKey: session_secret
|
|
388
|
+
PasswordLength: 64
|
|
389
|
+
ExcludePunctuation: true
|
|
390
|
+
|
|
284
391
|
# Pulls the image and writes CloudWatch Logs — AWS's own managed
|
|
285
392
|
# AmazonECSTaskExecutionRolePolicy already covers both (ECR auth
|
|
286
393
|
# + GetDownloadUrlForLayer, and logs:CreateLogStream/PutLogEvents);
|
|
@@ -330,6 +437,14 @@ module Hecks
|
|
|
330
437
|
- Effect: Allow
|
|
331
438
|
Action: secretsmanager:GetSecretValue
|
|
332
439
|
Resource: !Sub "${#{db_secret_ref}}"
|
|
440
|
+
- PolicyName: SessionSecretRead
|
|
441
|
+
PolicyDocument:
|
|
442
|
+
Version: '2012-10-17'
|
|
443
|
+
Statement:
|
|
444
|
+
- Effect: Allow
|
|
445
|
+
Action: secretsmanager:GetSecretValue
|
|
446
|
+
Resource: !Ref #{session_secret_id}
|
|
447
|
+
#{oauth_task_policy_yaml.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join}
|
|
333
448
|
# TMPL:cross_domain_fargate_policies
|
|
334
449
|
|
|
335
450
|
#{task_definition_id}:
|
|
@@ -338,13 +453,22 @@ module Hecks
|
|
|
338
453
|
Family: #{infra_name}
|
|
339
454
|
RequiresCompatibilities: [FARGATE]
|
|
340
455
|
NetworkMode: awsvpc
|
|
456
|
+
# ARM64, not Fargate's own x86_64 default — matching the
|
|
457
|
+
# generated Makefile's own aarch64-unknown-linux-gnu build
|
|
458
|
+
# (below) and Lambda's own arm64 toolchain this reuses; a
|
|
459
|
+
# container built for the wrong arch fails at task start,
|
|
460
|
+
# not at build time, so this has to agree with the image
|
|
461
|
+
# docker-build actually pushes.
|
|
462
|
+
RuntimePlatform:
|
|
463
|
+
CpuArchitecture: ARM64
|
|
464
|
+
OperatingSystemFamily: LINUX
|
|
341
465
|
Cpu: "#{cpu}"
|
|
342
466
|
Memory: "#{memory}"
|
|
343
467
|
ExecutionRoleArn: !GetAtt #{execution_role_id}.Arn
|
|
344
468
|
TaskRoleArn: !GetAtt #{task_role_id}.Arn
|
|
345
469
|
ContainerDefinitions:
|
|
346
470
|
- Name: #{infra_name}
|
|
347
|
-
Image: !Sub "${#{ecr_repository_id}.RepositoryUri}
|
|
471
|
+
Image: !Sub "${#{ecr_repository_id}.RepositoryUri}:${ImageTag}"
|
|
348
472
|
PortMappings:
|
|
349
473
|
- ContainerPort: #{port}
|
|
350
474
|
LogConfiguration:
|
|
@@ -360,19 +484,46 @@ module Hecks
|
|
|
360
484
|
Value: "1"
|
|
361
485
|
- Name: PORT
|
|
362
486
|
Value: "#{port}"
|
|
487
|
+
# `rust/host/src/server.rs`'s own top-of-`main`
|
|
488
|
+
# switch — without it, this container runs as the
|
|
489
|
+
# Lambda custom-runtime process `Lambda`'s own
|
|
490
|
+
# generated binary always has, which blocks
|
|
491
|
+
# forever polling a Runtime API that doesn't exist
|
|
492
|
+
# here, never answering the health check or
|
|
493
|
+
# anything else on `port`.
|
|
494
|
+
- Name: HECKS_SERVE_MODE
|
|
495
|
+
Value: "1"
|
|
363
496
|
# `web "Rust"` vs `web "None"` is otherwise inert
|
|
364
497
|
# here today — both modes generate the identical
|
|
365
498
|
# task/service/target-group shape, since a Fargate
|
|
366
499
|
# task always answers HTTP on `port` for dispatch
|
|
367
500
|
# requests either way. Passed through so
|
|
368
|
-
# `rust/host`'s own
|
|
369
|
-
#
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
# Lambda path.
|
|
501
|
+
# `rust/host`'s own server loop (server.rs) can
|
|
502
|
+
# read it and decide whether to also serve the
|
|
503
|
+
# public web UI in-process, the same `web`-shaped
|
|
504
|
+
# choice `Lambda`'s own `rust_web` already makes
|
|
505
|
+
# for the Lambda path.
|
|
374
506
|
- Name: HECKS_WEB
|
|
375
507
|
Value: #{target.state[:web].value}
|
|
508
|
+
# HECKS_WASM_PATH/HECKS_IR_PATH — main.rs requires
|
|
509
|
+
# both unconditionally at boot (ir::ir().ok_or(...)?,
|
|
510
|
+
# no fallback, and HECKS_WASM_PATH for every
|
|
511
|
+
# dispatch) regardless of `web`/HECKS_SERVE_MODE. A
|
|
512
|
+
# container built with neither set crashes before
|
|
513
|
+
# ever reaching its own serve loop — found live
|
|
514
|
+
# deploying lifeadelics-platform's own domain
|
|
515
|
+
# container, fixed here so every Fargate domain ships
|
|
516
|
+
# both sidecars by default. Paths match the
|
|
517
|
+
# Dockerfile's own COPY destinations, below.
|
|
518
|
+
- Name: HECKS_WASM_PATH
|
|
519
|
+
Value: /usr/local/bin/#{domain_name}.wasm
|
|
520
|
+
- Name: HECKS_IR_PATH
|
|
521
|
+
Value: /usr/local/bin/#{domain_name}.ir.json
|
|
522
|
+
- Name: SESSION_SECRET_ARN
|
|
523
|
+
Value: !Ref #{session_secret_id}
|
|
524
|
+
- Name: HECKS_CHECKOUT_DOMAIN
|
|
525
|
+
Value: #{declared_domain_name}
|
|
526
|
+
#{oauth_task_env_yaml.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join}
|
|
376
527
|
# TMPL:db_env
|
|
377
528
|
|
|
378
529
|
#{target_group_id}:
|
|
@@ -392,7 +543,7 @@ module Hecks
|
|
|
392
543
|
Scheme: internet-facing
|
|
393
544
|
Type: application
|
|
394
545
|
SecurityGroups: [!Ref #{alb_sg_id}]
|
|
395
|
-
Subnets: #{shared ? "[!Ref
|
|
546
|
+
Subnets: #{shared ? "[!Ref OwningPublicSubnetAId, !Ref OwningPublicSubnetBId]" : "[!Ref #{db_id}PublicSubnet, !Ref #{db_id}BastionPublicSubnet]"}
|
|
396
547
|
|
|
397
548
|
#{listener_id}:
|
|
398
549
|
Type: AWS::ElasticLoadBalancingV2::Listener
|
|
@@ -423,9 +574,76 @@ module Hecks
|
|
|
423
574
|
ContainerPort: #{port}
|
|
424
575
|
TargetGroupArn: !Ref #{target_group_id}
|
|
425
576
|
|
|
577
|
+
# Real HTTPS (the ALB's own Listener above is HTTP-only —
|
|
578
|
+
# nothing else in this stack terminates TLS) and, just as
|
|
579
|
+
# important, the ONE safe default this generator can offer
|
|
580
|
+
# for caching it has no way to reason about: Managed-
|
|
581
|
+
# CachingDisabled. This domain's own routes — including
|
|
582
|
+
# every hecks-native /login, /logout, /auth/google(/callback),
|
|
583
|
+
# /admin/members request (web.rs's own auth_gate/auth_route,
|
|
584
|
+
# generic across every domain, not just this one's own
|
|
585
|
+
# dispatch commands) — are all session-cookie-driven, and
|
|
586
|
+
# this generator has no way to tell which of a domain's own
|
|
587
|
+
# paths would ever be safe to cache. Found live, the hard
|
|
588
|
+
# way (lifeadelics, 2026-09-21): a hand-authored CloudFront
|
|
589
|
+
# stack applied the OPPOSITE default — a custom, cookie-
|
|
590
|
+
# blind cache policy with a 90-120s TTL — and it served one
|
|
591
|
+
# signed-in session's own response (a short-lived SSO
|
|
592
|
+
# handoff token among them) back to a different, unrelated
|
|
593
|
+
# request within that window. A domain that DOES know one
|
|
594
|
+
# of its own paths is genuinely safe to cache (a public,
|
|
595
|
+
# non-personalized page) adds its own more specific
|
|
596
|
+
# CacheBehavior by hand, the same way lifeadelics's own
|
|
597
|
+
# hand-extended three-container stack already does for
|
|
598
|
+
# /_astro/*, /videos/*, and friends — never by loosening
|
|
599
|
+
# this one.
|
|
600
|
+
#{distribution_id}:
|
|
601
|
+
Type: AWS::CloudFront::Distribution
|
|
602
|
+
Properties:
|
|
603
|
+
DistributionConfig:
|
|
604
|
+
Enabled: true
|
|
605
|
+
HttpVersion: http2
|
|
606
|
+
# No ACM/custom domain here — this generator has no
|
|
607
|
+
# notion of one (deploy.bluebook's own FargateTarget
|
|
608
|
+
# declares no `domain` attribute for it) and CloudFront
|
|
609
|
+
# requires an ACM cert in us-east-1 specifically to
|
|
610
|
+
# attach a custom Aliases entry, a real cross-region
|
|
611
|
+
# dependency this generator can't assume. CloudFront's
|
|
612
|
+
# own default *.cloudfront.net certificate/hostname
|
|
613
|
+
# are what Outputs.CloudFrontDomain below reports;
|
|
614
|
+
# point a real domain's DNS at it by hand, same
|
|
615
|
+
# "generated, extend by hand" posture this whole file
|
|
616
|
+
# already has for anything past its own baseline.
|
|
617
|
+
ViewerCertificate:
|
|
618
|
+
CloudFrontDefaultCertificate: true
|
|
619
|
+
Origins:
|
|
620
|
+
- Id: #{alb_id}Origin
|
|
621
|
+
DomainName: !GetAtt #{alb_id}.DNSName
|
|
622
|
+
CustomOriginConfig:
|
|
623
|
+
OriginProtocolPolicy: http-only
|
|
624
|
+
HTTPPort: 80
|
|
625
|
+
HTTPSPort: 443
|
|
626
|
+
DefaultCacheBehavior:
|
|
627
|
+
TargetOriginId: #{alb_id}Origin
|
|
628
|
+
ViewerProtocolPolicy: redirect-to-https
|
|
629
|
+
Compress: true
|
|
630
|
+
AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
|
|
631
|
+
CachedMethods: [GET, HEAD]
|
|
632
|
+
# Managed-CachingDisabled — see this resource's own
|
|
633
|
+
# header comment for why nothing else is safe here
|
|
634
|
+
# by default.
|
|
635
|
+
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
|
636
|
+
# Managed-AllViewer — forwards every cookie/header/
|
|
637
|
+
# query string through uncached, so rust/host's own
|
|
638
|
+
# session-cookie-based auth sees the real request
|
|
639
|
+
# exactly as the browser sent it.
|
|
640
|
+
OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3
|
|
641
|
+
|
|
426
642
|
Outputs:
|
|
427
643
|
ServiceUrl:
|
|
428
644
|
Value: !Sub "http://${#{alb_id}.DNSName}"
|
|
645
|
+
CloudFrontDomain:
|
|
646
|
+
Value: !GetAtt #{distribution_id}.DomainName
|
|
429
647
|
#{stack_outputs.map { |o| "#{o[:key]}:\n Value: #{o[:ref]}" }.join("\n ")}
|
|
430
648
|
YAML
|
|
431
649
|
|
|
@@ -471,6 +689,11 @@ module Hecks
|
|
|
471
689
|
&& rm -rf /var/lib/apt/lists/*
|
|
472
690
|
|
|
473
691
|
COPY #{domain_name}-host /usr/local/bin/#{domain_name}-host
|
|
692
|
+
# The .wasm/.ir.json sidecars main.rs requires at boot —
|
|
693
|
+
# HECKS_WASM_PATH/HECKS_IR_PATH (template.yaml's own
|
|
694
|
+
# ContainerDefinitions Environment) point at these exact paths.
|
|
695
|
+
COPY #{domain_name}.wasm /usr/local/bin/#{domain_name}.wasm
|
|
696
|
+
COPY #{domain_name}.ir.json /usr/local/bin/#{domain_name}.ir.json
|
|
474
697
|
|
|
475
698
|
ENV PORT=#{port}
|
|
476
699
|
ENV BIND=0.0.0.0
|
|
@@ -495,9 +718,30 @@ module Hecks
|
|
|
495
718
|
IMAGE_TAG := latest
|
|
496
719
|
|
|
497
720
|
build:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
721
|
+
# aarch64, not x86_64 — matches template.yaml's own
|
|
722
|
+
# RuntimePlatform: ARM64 (this Makefile has to build the same
|
|
723
|
+
# architecture the task definition declares, or the container
|
|
724
|
+
# fails at task start, not at build time), and reuses the same
|
|
725
|
+
# working aarch64-unknown-linux-gnu toolchain the Lambda deploy
|
|
726
|
+
# path already depends on, rather than standing up a second,
|
|
727
|
+
# x86_64-only one.
|
|
728
|
+
\t@rustup target list --installed 2>/dev/null | grep -qx aarch64-unknown-linux-gnu || rustup target add aarch64-unknown-linux-gnu
|
|
729
|
+
# GNU cross-linker, not Apple clang — rustc's aarch64-unknown-linux-gnu
|
|
730
|
+
# target emits `-Wl,--fix-cortex-a53-843419`, which macOS ld rejects
|
|
731
|
+
# (found live building lifeadelics-platform's domain image). Same
|
|
732
|
+
# toolchain Lambda's generated Makefile already documents.
|
|
733
|
+
\t@command -v aarch64-linux-gnu-gcc >/dev/null 2>&1 || { echo "aarch64-linux-gnu-gcc isn't on PATH. Install once with: brew tap messense/macos-cross-toolchains && brew install aarch64-unknown-linux-gnu"; exit 1; }
|
|
734
|
+
# The .wasm/.ir.json sidecars main.rs requires at boot,
|
|
735
|
+
# unconditionally — see template.yaml's own HECKS_WASM_PATH/
|
|
736
|
+
# HECKS_IR_PATH comment for why. bin/project_wasm is the same
|
|
737
|
+
# generator the Lambda deploy path already uses to produce
|
|
738
|
+
# rust/dist/#{domain_name}.wasm/.ir.json from this domain's own
|
|
739
|
+
# .bluebook.
|
|
740
|
+
\tcd $(ROOT) && bin/project_wasm $(DOMAIN)
|
|
741
|
+
\tcd $(ROOT)/rust/host && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc rustup run stable cargo build --release --target aarch64-unknown-linux-gnu --bin bootstrap
|
|
742
|
+
\tcp $(ROOT)/rust/host/target/aarch64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
|
|
743
|
+
\tcp $(ROOT)/rust/dist/#{domain_name}.wasm #{domain_name}.wasm
|
|
744
|
+
\tcp $(ROOT)/rust/dist/#{domain_name}.ir.json #{domain_name}.ir.json
|
|
501
745
|
|
|
502
746
|
.PHONY: ecr-login
|
|
503
747
|
ecr-login:
|
|
@@ -505,7 +749,10 @@ module Hecks
|
|
|
505
749
|
|
|
506
750
|
.PHONY: docker-build
|
|
507
751
|
docker-build: build
|
|
508
|
-
|
|
752
|
+
# linux/arm64, not the generator's old amd64 default — has to
|
|
753
|
+
# match RuntimePlatform/the binary this Makefile's own build:
|
|
754
|
+
# step just cross-compiled, above.
|
|
755
|
+
\tdocker build --platform linux/arm64 -t #{infra_name}:$(IMAGE_TAG) .
|
|
509
756
|
|
|
510
757
|
.PHONY: docker-push
|
|
511
758
|
docker-push: ecr-login
|
|
@@ -515,7 +762,7 @@ module Hecks
|
|
|
515
762
|
|
|
516
763
|
.PHONY: deploy
|
|
517
764
|
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"}
|
|
765
|
+
#{shared ? "\t@echo \"Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/PublicSubnetId/BastionSubnetId/FunctionSecurityGroupId/DatabaseEndpoint/DatabaseSecretArn outputs to pass as $(STACK)'s Owning* parameters...\"\n\tOWNER_VPC_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetAId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetBId'].OutputValue\" --output text); \\\n\t\tOWNER_PUBLIC_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PublicSubnetId'].OutputValue\" --output text); \\\n\t\tOWNER_PUBLIC_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='BastionSubnetId'].OutputValue\" --output text); \\\n\t\tOWNER_SG_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='FunctionSecurityGroupId'].OutputValue\" --output text); \\\n\t\tOWNER_DB_HOST=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseEndpoint'].OutputValue\" --output text); \\\n\t\tOWNER_DB_SECRET_ARN=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseSecretArn'].OutputValue\" --output text); \\\n\t\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM \\\n\t\t\t--parameter-overrides ImageTag=$(IMAGE_TAG) OwningVpcId=$$OWNER_VPC_ID OwningSubnetAId=$$OWNER_SUBNET_A_ID OwningSubnetBId=$$OWNER_SUBNET_B_ID OwningPublicSubnetAId=$$OWNER_PUBLIC_SUBNET_A_ID OwningPublicSubnetBId=$$OWNER_PUBLIC_SUBNET_B_ID OwningSecurityGroupId=$$OWNER_SG_ID OwningDatabaseEndpoint=$$OWNER_DB_HOST OwningDatabaseSecretArn=$$OWNER_DB_SECRET_ARN" : "\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM --parameter-overrides ImageTag=$(IMAGE_TAG)"}
|
|
519
766
|
\t$(MAKE) mint-era
|
|
520
767
|
|
|
521
768
|
#{shared ? <<~SHAREDMINT.rstrip : <<~OWNMINT.rstrip
|
|
@@ -108,7 +108,7 @@ module Hecks
|
|
|
108
108
|
# + extraction + memory + prism only) than the one `pending_privacy_
|
|
109
109
|
# markings` was verified against; kept separate rather than assumed
|
|
110
110
|
# equivalent.
|
|
111
|
-
pii_registry = Hecks::Runtime::Registry.new
|
|
111
|
+
pii_registry = Hecks::Runtime::Registry.new(root: File.expand_path(domain))
|
|
112
112
|
Hecks.with_registry(pii_registry) do
|
|
113
113
|
bootstrap = Hecks::Ports::Loading.bootstrap
|
|
114
114
|
bluebook_dir = File.join(domain, "bluebook")
|
|
@@ -841,7 +841,16 @@ module Hecks
|
|
|
841
841
|
# triggers that first boot; a LATER real schema evolution
|
|
842
842
|
# (era 2+) is a separate, later re-generation, not this one.
|
|
843
843
|
HECKS_DOMAIN: #{declared_domain_name}
|
|
844
|
-
|
|
844
|
+
# UNCONDITIONAL, not gated on `rust_web` — `rust/host/src/main.rs`'s
|
|
845
|
+
# own boot sequence reads `ir::ir().ok_or(...)?` for every domain
|
|
846
|
+
# regardless of web mode (era-lineage bookkeeping needs the IR, not
|
|
847
|
+
# just the optional in-process web UI), so a Shared-mode domain with
|
|
848
|
+
# no web layer still needs this sidecar path to boot at all. Previously
|
|
849
|
+
# gated on `rust_web`, which left every such domain's Lambda crashing
|
|
850
|
+
# on cold start — checkout.rs's own header flagged this exact
|
|
851
|
+
# contradiction, confirmed live against Banking's committed template.
|
|
852
|
+
HECKS_IR_PATH: !Sub "/var/task/#{domain_name}.ir.json"
|
|
853
|
+
HECKS_ERA: "1"#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}#{rust_web && google_oauth_present ? <<~RUSTOAUTH.each_line.with_index.map { |l, i| i.zero? ? "\n " + l : " " + l }.join.rstrip : ""}
|
|
845
854
|
# NOT `{{resolve:secretsmanager:...}}` composing
|
|
846
855
|
# GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/SESSION_SECRET
|
|
847
856
|
# directly anymore -- the identical GetFunctionConfiguration
|
|
@@ -1893,7 +1902,14 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
|
|
|
1893
1902
|
# Lambda) the same day this comment was written.
|
|
1894
1903
|
\tcd $(HOST_DIR) && rustup run stable cargo lambda build --release --arm64
|
|
1895
1904
|
\tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap $(ARTIFACTS_DIR)/bootstrap
|
|
1896
|
-
|
|
1905
|
+
# UNCONDITIONAL, not gated on rust_web -- same reason HECKS_IR_PATH
|
|
1906
|
+
# itself is unconditional above: main.rs's own boot sequence reads
|
|
1907
|
+
# the IR for every domain regardless of web mode, so the sidecar
|
|
1908
|
+
# file this env var points to has to actually be in the package
|
|
1909
|
+
# too, or the env var alone just changes the crash from "not set"
|
|
1910
|
+
# to "not found".
|
|
1911
|
+
\tcp $(WASM) $(ARTIFACTS_DIR)/#{domain_name}.wasm
|
|
1912
|
+
\tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} $(ARTIFACTS_DIR)/#{domain_name}.ir.json
|
|
1897
1913
|
|
|
1898
1914
|
# `sam build <resource>` WIPES .aws-sam/build/ entirely before
|
|
1899
1915
|
# building just the one resource named -- confirmed live: building
|
|
@@ -1917,7 +1933,8 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
|
|
|
1917
1933
|
restore-#{logical_id}-build:
|
|
1918
1934
|
\t@mkdir -p .aws-sam/build/#{logical_id}
|
|
1919
1935
|
\tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap .aws-sam/build/#{logical_id}/bootstrap
|
|
1920
|
-
\tcp $(WASM) .aws-sam/build/#{logical_id}/#{domain_name}.wasm
|
|
1936
|
+
\tcp $(WASM) .aws-sam/build/#{logical_id}/#{domain_name}.wasm
|
|
1937
|
+
\tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} .aws-sam/build/#{logical_id}/#{domain_name}.ir.json
|
|
1921
1938
|
\truby -e 'lines = File.readlines(".aws-sam/build/template.yaml"); start = lines.index { |l| l.strip == "#{logical_id}:" } or raise "restore-#{logical_id}-build: #{logical_id} resource not found in built template"; idx = (start+1...lines.length).find { |i| lines[i] =~ /CodeUri:/ } or raise "restore-#{logical_id}-build: no CodeUri line found under #{logical_id}"; lines[idx] = lines[idx].sub(/CodeUri:.*/, "CodeUri: #{logical_id}"); File.write(".aws-sam/build/template.yaml", lines.join)'
|
|
1922
1939
|
|
|
1923
1940
|
# `make verify-parity-#{logical_id}` — closes the exact gap the
|
|
@@ -126,6 +126,54 @@ module Hecks
|
|
|
126
126
|
}
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
# Same seam as `authorization` — which chapter answers "who may
|
|
130
|
+
# sign in" (`Registry#membership_provider_for`), with its declared
|
|
131
|
+
# verbs qualified and the membership aggregate named off `admit`.
|
|
132
|
+
# rust/host reads this instead of HECKS_MEMBERSHIP_AGGREGATE.
|
|
133
|
+
# `{}` when nothing this domain attaches provides membership.
|
|
134
|
+
# @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
|
|
135
|
+
# @param domain_name [String] the domain to export the membership binding for
|
|
136
|
+
# @return [Hash{Symbol => String, nil}] `:provider` (name), `:admit`, `:grant`,
|
|
137
|
+
# `:people` (qualified verbs), and `:aggregate` (`:admit`'s own leading
|
|
138
|
+
# aggregate name); `{}` if nothing this domain attaches provides membership
|
|
139
|
+
def membership(registry, domain_name)
|
|
140
|
+
provider = registry.membership_provider_for(domain_name)
|
|
141
|
+
return {} unless provider
|
|
142
|
+
|
|
143
|
+
capability = Bluebook::Capabilities::MEMBERSHIP
|
|
144
|
+
admit = provider.provided_verb(capability, :admit)
|
|
145
|
+
{
|
|
146
|
+
provider: provider.name,
|
|
147
|
+
admit: admit,
|
|
148
|
+
grant: provider.provided_verb(capability, :grant),
|
|
149
|
+
people: provider.provided_verb(capability, :people),
|
|
150
|
+
aggregate: admit&.split(".")&.first
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Same seam as `authorization` — which chapter answers "who is this
|
|
155
|
+
# authenticated pair" (`Registry#identity_provider_for`), with its
|
|
156
|
+
# declared verbs qualified. rust/host reads this instead of naming
|
|
157
|
+
# Identity::Identity.Register / ExternalIdentifier.Link. `{}` when
|
|
158
|
+
# nothing this domain attaches provides identity. Breaking in 2.0:
|
|
159
|
+
# Link's reference field is `identity`, never `identity_id`.
|
|
160
|
+
# @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
|
|
161
|
+
# @param domain_name [String] the domain to export the identity binding for
|
|
162
|
+
# @return [Hash{Symbol => String, nil}] `:provider` (name), `:register`, `:link`,
|
|
163
|
+
# `:resolve` (qualified verbs); `{}` if nothing this domain attaches provides identity
|
|
164
|
+
def identity(registry, domain_name)
|
|
165
|
+
provider = registry.identity_provider_for(domain_name)
|
|
166
|
+
return {} unless provider
|
|
167
|
+
|
|
168
|
+
capability = Bluebook::Capabilities::IDENTITY
|
|
169
|
+
{
|
|
170
|
+
provider: provider.name,
|
|
171
|
+
register: provider.provided_verb(capability, :register),
|
|
172
|
+
link: provider.provided_verb(capability, :link),
|
|
173
|
+
resolve: provider.provided_verb(capability, :resolve)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
129
177
|
# Translation IR, always as an array, with each aggregate's
|
|
130
178
|
# precompiled SQL attached (`compiled_translation_aggregate`) —
|
|
131
179
|
# this is the export a consumer embeds (`ir.json`'s `translations`
|
|
@@ -138,12 +138,22 @@ module Hecks
|
|
|
138
138
|
current_entries.reject { |entry| entry.fqn.aggregate.nil? }
|
|
139
139
|
.group_by { |entry| [entry.fqn.aggregate, entry.fqn.verb] }
|
|
140
140
|
.each do |(aggregate, verb), candidates|
|
|
141
|
+
# Bounded chapters wrap in their own module (`Domain::Aggregate`)
|
|
142
|
+
# so two BCs can both declare `Person` without colliding on
|
|
143
|
+
# Object::Person. Folder-spread files of the SAME chapter still
|
|
144
|
+
# get the shortcut — they are not BCs.
|
|
145
|
+
next if bounded_chapter?(candidates)
|
|
146
|
+
|
|
141
147
|
shortcut_target(aggregate).define_singleton_method(verb) do |**args|
|
|
142
148
|
installer.send(:dispatch_short, candidates, **args)
|
|
143
149
|
end
|
|
144
150
|
end
|
|
145
151
|
end
|
|
146
152
|
|
|
153
|
+
def bounded_chapter?(candidates)
|
|
154
|
+
candidates.any? { |entry| entry.dispatcher.registry.bounded?(entry.fqn.domain) }
|
|
155
|
+
end
|
|
156
|
+
|
|
147
157
|
def dispatch_short(candidates, **args)
|
|
148
158
|
if candidates.length > 1
|
|
149
159
|
shown = candidates.map { |entry| entry.fqn.to_s }.sort.join(", ")
|