hecks 3.0.5 → 3.1.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.
Files changed (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +24 -12
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  12. data/lib/hecks/cli/doc_coverage.rb +1 -1
  13. data/lib/hecks/cli/domain_stub.rb +128 -0
  14. data/lib/hecks/cli/interview_draft.rb +246 -0
  15. data/lib/hecks/cli/model_check.rb +1 -1
  16. data/lib/hecks/cli/project_cli.rb +51 -29
  17. data/lib/hecks/cli/reference.rb +2 -1
  18. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  19. data/lib/hecks/cli/seed_semantics_corpus.rb +3 -3
  20. data/lib/hecks/cli/spec_example.rb +1 -1
  21. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  22. data/lib/hecks/corpus.rb +11 -8
  23. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  24. data/lib/hecks/deploy/oidc.json +5 -0
  25. data/lib/hecks/doc/reference.rb +4 -3
  26. data/lib/hecks/doors/cli_door.rb +13 -13
  27. data/lib/hecks/doors/cli_runner.rb +52 -32
  28. data/lib/hecks/doors/launcher_options.rb +5 -5
  29. data/lib/hecks/doors/mcp_door.rb +40 -18
  30. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  31. data/lib/hecks/doors.rb +1 -1
  32. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  33. data/lib/hecks/forms/banking_presentation.rb +1 -1
  34. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  35. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  37. data/lib/hecks/framework.rb +1 -1
  38. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  39. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  40. data/lib/hecks/fuzzing/differential.rb +4 -2
  41. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  42. data/lib/hecks/fuzzing/form_census.rb +2 -2
  43. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  44. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  45. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  46. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  47. data/lib/hecks/gate/stages.yml +5 -5
  48. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  49. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  50. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  51. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  52. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  54. data/lib/hecks/hecks/codebase.bluebook +4 -3
  55. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  56. data/lib/hecks/hecks/custodian.bluebook +45 -3
  57. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  58. data/lib/hecks/hecks/hecks.world +6 -6
  59. data/lib/hecks/hecks/site.behaviors +17 -0
  60. data/lib/hecks/hecks/site.world +10 -0
  61. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  62. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  63. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  64. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  65. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  66. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  67. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  68. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  69. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  70. data/lib/hecks/projections/deploy/box.rb +387 -0
  71. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  72. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  73. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  74. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  75. data/lib/hecks/projections/diagrams.rb +2 -2
  76. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  77. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  78. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  79. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  80. data/lib/hecks/projections/site/edge.rb +136 -0
  81. data/lib/hecks/projections/site/regions.rb +44 -0
  82. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  83. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  84. data/lib/hecks/projections/site/table/checks.rb +124 -0
  85. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  86. data/lib/hecks/projections/site/table.rb +98 -0
  87. data/lib/hecks/projections.rb +2 -0
  88. data/lib/hecks/projector/cli_projector.rb +122 -104
  89. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  90. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  91. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  92. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  93. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  95. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  96. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  97. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  99. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  100. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  101. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  102. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  103. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  104. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  105. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  106. data/lib/hecks/runtime/registry/verification.rb +15 -29
  107. data/lib/hecks/runtime/registry.rb +14 -20
  108. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  109. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  110. data/lib/hecks/rust_build/domain_name.rb +26 -0
  111. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  112. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  113. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  114. data/lib/hecks/rust_build/project_rust.rb +57 -24
  115. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  116. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  117. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  118. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  119. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  120. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  121. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  122. data/lib/hecks/storehouse.rb +37 -7
  123. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  124. data/lib/hecks/three_zero/forms.yml +88 -88
  125. data/lib/hecks/tools/ci_gates.rb +1 -1
  126. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  127. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  128. data/lib/hecks/tools/sequence_script.rb +2 -2
  129. data/lib/hecks/tools/site_routes.rb +152 -0
  130. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  131. data/lib/hecks/tools.rb +1 -0
  132. data/lib/hecks/version.rb +1 -1
  133. data/rust/build/src/cargo_sync.rs +1 -1
  134. data/rust/build/src/pipeline.rs +23 -16
  135. data/rust/build/src/resolve.rs +39 -6
  136. data/rust/host/HECKS_RELEASE +1 -1
  137. data/rust/host/src/api.rs +1 -1
  138. data/rust/host/src/dispatch.rs +87 -7
  139. data/rust/host/src/needs.rs +111 -0
  140. data/rust/parser/src/keywords.rs +7 -6
  141. data/rust/parser/src/main.rs +4 -3
  142. data/rust/parser/src/parse/chapter.rs +1 -1
  143. data/rust/parser/src/parse/hecksagon.rs +25 -10
  144. data/rust/project/mutations.rb +4 -30
  145. data/rust/project/naming.rb +8 -28
  146. data/rust/project/write_if_changed.rb +4 -80
  147. data/rust/project_rust_pipeline.rb +16 -12
  148. data/rust/src/kernel/cli.rs +2 -0
  149. data/rust/src/kernel/needs.rs +74 -1
  150. metadata +37 -1
@@ -0,0 +1,165 @@
1
+ AWSTemplateFormatVersion: "2010-09-09"
2
+ Description: >
3
+ Plain RDS Postgres for @@STACK@@, read by the app box (box.yaml). Private subnets only; the box's own
4
+ stack admits itself on 5432, so nothing else can reach it. The master password is managed by RDS in
5
+ Secrets Manager ({username, password}), which the box reads at deploy time.
6
+
7
+ Parameters:
8
+ VpcId:
9
+ Type: AWS::EC2::VPC::Id
10
+ PrivateSubnetIds:
11
+ Type: List<AWS::EC2::Subnet::Id>
12
+ Description: At least two subnets in different availability zones (an RDS subnet group needs them).
13
+ InstanceClass:
14
+ Type: String
15
+ Default: @@DB_CLASS@@
16
+ AllocatedStorageGb:
17
+ Type: Number
18
+ Default: @@STORAGE_GB@@
19
+ DatabaseName:
20
+ Type: String
21
+ Default: @@DB_NAME@@
22
+ EngineVersion:
23
+ Type: String
24
+ Default: "@@ENGINE_VERSION@@"
25
+ Description: A major version ("16") lets RDS pick its current minor release.
26
+ BackupRetentionDays:
27
+ Type: Number
28
+ Default: @@BACKUP_DAYS@@
29
+ ExistingAlertTopicArn:
30
+ Type: String
31
+ Default: ""
32
+ Description: An SNS topic that already has subscribers. Blank creates a new topic.
33
+ AlertEmail:
34
+ Type: String
35
+ Default: ""
36
+ Description: Subscribed to a new topic (AWS sends one confirmation link). Ignored when ExistingAlertTopicArn is set.
37
+ Rehearsal:
38
+ Type: String
39
+ Default: "false"
40
+ AllowedValues: ["true", "false"]
41
+ Description: >
42
+ true is a throwaway copy (no deletion protection, no final snapshot). false is the real
43
+ database: deletion protection on and a snapshot taken on delete.
44
+
45
+ Conditions:
46
+ NewTopic: !Equals [!Ref ExistingAlertTopicArn, ""]
47
+ NewTopicWithEmail: !And [!Condition NewTopic, !Not [!Equals [!Ref AlertEmail, ""]]]
48
+ IsRehearsal: !Equals [!Ref Rehearsal, "true"]
49
+
50
+ Resources:
51
+ DbSecurityGroup:
52
+ Type: AWS::EC2::SecurityGroup
53
+ Properties:
54
+ GroupDescription: @@STACK@@ RDS, 5432 from the app box only (the box stack adds the rule)
55
+ VpcId: !Ref VpcId
56
+
57
+ DbSubnetGroup:
58
+ Type: AWS::RDS::DBSubnetGroup
59
+ Properties:
60
+ DBSubnetGroupDescription: @@STACK@@ RDS subnets
61
+ SubnetIds: !Ref PrivateSubnetIds
62
+
63
+ Database:
64
+ Type: AWS::RDS::DBInstance
65
+ DeletionPolicy: !If [IsRehearsal, Delete, Snapshot]
66
+ UpdateReplacePolicy: !If [IsRehearsal, Delete, Snapshot]
67
+ Properties:
68
+ Engine: postgres
69
+ EngineVersion: !Ref EngineVersion
70
+ DBInstanceClass: !Ref InstanceClass
71
+ AllocatedStorage: !Ref AllocatedStorageGb
72
+ StorageType: gp3
73
+ StorageEncrypted: true
74
+ DBName: !Ref DatabaseName
75
+ MasterUsername: postgres
76
+ ManageMasterUserPassword: true
77
+ DBSubnetGroupName: !Ref DbSubnetGroup
78
+ VPCSecurityGroups: [!Ref DbSecurityGroup]
79
+ PubliclyAccessible: false
80
+ MultiAZ: false
81
+ BackupRetentionPeriod: !Ref BackupRetentionDays
82
+ DeletionProtection: !If [IsRehearsal, false, true]
83
+ AutoMinorVersionUpgrade: true
84
+ CopyTagsToSnapshot: true
85
+ Tags:
86
+ - Key: Name
87
+ Value: @@STACK@@-rds
88
+
89
+ AlertTopic:
90
+ Type: AWS::SNS::Topic
91
+ Condition: NewTopic
92
+ Properties:
93
+ DisplayName: @@STACK@@-alerts
94
+
95
+ AlertSubscription:
96
+ Type: AWS::SNS::Subscription
97
+ Condition: NewTopicWithEmail
98
+ Properties:
99
+ TopicArn: !Ref AlertTopic
100
+ Protocol: email
101
+ Endpoint: !Ref AlertEmail
102
+
103
+ # The journal only grows, and RDS stops accepting writes when storage is full.
104
+ StorageLowAlarm:
105
+ Type: AWS::CloudWatch::Alarm
106
+ Properties:
107
+ AlarmDescription: RDS free storage under 3 GB
108
+ Namespace: AWS/RDS
109
+ MetricName: FreeStorageSpace
110
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
111
+ Statistic: Minimum
112
+ Period: 300
113
+ EvaluationPeriods: 3
114
+ Threshold: 3221225472
115
+ ComparisonOperator: LessThanThreshold
116
+ TreatMissingData: breaching
117
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
118
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
119
+
120
+ CpuHighAlarm:
121
+ Type: AWS::CloudWatch::Alarm
122
+ Properties:
123
+ AlarmDescription: "RDS CPU above 80% for 15 minutes (a burstable class may be out of credits)"
124
+ Namespace: AWS/RDS
125
+ MetricName: CPUUtilization
126
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
127
+ Statistic: Average
128
+ Period: 300
129
+ EvaluationPeriods: 3
130
+ Threshold: 80
131
+ ComparisonOperator: GreaterThanThreshold
132
+ TreatMissingData: notBreaching
133
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
134
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
135
+
136
+ MemoryLowAlarm:
137
+ Type: AWS::CloudWatch::Alarm
138
+ Properties:
139
+ AlarmDescription: RDS freeable memory under 100 MB for 15 minutes
140
+ Namespace: AWS/RDS
141
+ MetricName: FreeableMemory
142
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
143
+ Statistic: Minimum
144
+ Period: 300
145
+ EvaluationPeriods: 3
146
+ Threshold: 104857600
147
+ ComparisonOperator: LessThanThreshold
148
+ TreatMissingData: notBreaching
149
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
150
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
151
+
152
+ Outputs:
153
+ AlertTopicArn:
154
+ Description: Pass as AlertTopicArn to the box stack.
155
+ Value: !If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]
156
+ DbEndpoint:
157
+ Value: !GetAtt Database.Endpoint.Address
158
+ DbName:
159
+ Value: !Ref DatabaseName
160
+ DbSecretArn:
161
+ Description: RDS-managed master secret ({username, password}).
162
+ Value: !GetAtt Database.MasterUserSecret.SecretArn
163
+ DbSecurityGroupId:
164
+ Description: Pass as DbSecurityGroupId to the box stack.
165
+ Value: !Ref DbSecurityGroup
@@ -0,0 +1,43 @@
1
+ #!/bin/bash
2
+ # Render compose.json and secrets.json for @@STACK@@'s app box from services.json.
3
+ #
4
+ # render-compose.sh <db-host> <db-secret-arn> [name=tag ...]
5
+ #
6
+ # Tags default to "latest". Every container gets DB_HOST, DB_NAME, DB_SECRET_ARN and PORT, and runs on the
7
+ # box's own network, so the proxy reaches each one on 127.0.0.1:<port>. Secrets named in services.json
8
+ # are written to secrets.json for fetch-secrets.sh to resolve on the box.
9
+ set -euo pipefail
10
+ HERE=$(cd "$(dirname "$0")" && pwd)
11
+ DB_HOST=${1:?rds endpoint}
12
+ DB_SECRET=${2:?rds secret arn}
13
+ shift 2
14
+
15
+ REGION=@@REGION@@
16
+ ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
17
+ ECR="$ACCOUNT.dkr.ecr.$REGION.amazonaws.com"
18
+ TAGS=$(printf '%s\n' "$@" | jq -R 'select(length > 0) | split("=") | {(.[0]): .[1]}' | jq -s 'add // {}')
19
+
20
+ jq --arg ecr "$ECR" --argjson tags "$TAGS" --arg host "$DB_HOST" --arg secret "$DB_SECRET" --arg db "@@DB_NAME@@" '
21
+ def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
22
+ . as $in
23
+ | {services: (
24
+ ($in.services | with_entries(.value |= (
25
+ {image: ($ecr + "/" + .repository + ":" + ($tags[.name] // "latest")),
26
+ network_mode: "host", restart: "unless-stopped", logging: log,
27
+ environment: ({PORT: (.port | tostring), DB_HOST: $host, DB_NAME: $db, DB_SECRET_ARN: $secret} + .env)}
28
+ + (if (.secrets | length) > 0 then {env_file: [(.name + ".secrets.env")]} else {} end))))
29
+ + {caddy: ({image: "@@PROXY_IMAGE@@", network_mode: "host", restart: "unless-stopped",
30
+ volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro"], logging: log}
31
+ + (if $in.origin then {env_file: ["caddy.secrets.env"]} else {} end))}
32
+ + (if $in.tunnel then {cloudflared: {image: $in.tunnel.image, network_mode: "host", restart: "unless-stopped",
33
+ command: ["tunnel", "--no-autoupdate", "--url", $in.tunnel.url, "run"],
34
+ env_file: ["cloudflared.secrets.env"], logging: log}} else {} end))}' \
35
+ "$HERE/services.json" > compose.json
36
+
37
+ jq '. as $in
38
+ | [ ($in.services[] | . as $v | ($v.secrets | to_entries[]) | {service: $v.name, name: .key, valueFrom: .value}),
39
+ (if $in.origin then {service: "caddy", name: "ORIGIN_SECRET", valueFrom: $in.origin.secret} else empty end),
40
+ (if $in.tunnel then {service: "cloudflared", name: "TUNNEL_TOKEN", valueFrom: $in.tunnel.token_secret} else empty end) ]' \
41
+ "$HERE/services.json" > secrets.json
42
+
43
+ echo "rendered compose.json and secrets.json ($(jq '.services | length' compose.json) services)"
@@ -0,0 +1,387 @@
1
+ require "json"
2
+ require_relative "../../projector"
3
+ require_relative "box/settings"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Deploy
8
+ # The AWS single-box deploy target for `deployed_to("AwsBox")`: one EC2 instance that runs the
9
+ # domain's containers with Docker Compose behind Caddy, reading a plain RDS Postgres instance.
10
+ # It renders two CloudFormation stacks (the database, the box), the proxy's `Caddyfile`, the
11
+ # `services.json` compose is rendered from, and the scripts that roll the box.
12
+ module Box
13
+ extend Projector::Target
14
+
15
+ projects_as :aws_box, needs_world: true, emits: :files
16
+
17
+ TEMPLATE_DIR = File.join(__dir__, "box", "templates").freeze
18
+
19
+ # Region suffixes of an Elastic address's public DNS name; us-east-1 is the only region with
20
+ # the legacy `compute-1` form.
21
+ LEGACY_COMPUTE_REGION = "us-east-1".freeze
22
+
23
+ module_function
24
+
25
+ # Generates `rds.yaml`, `box.yaml`, `Caddyfile`, `services.json`, `render-compose.sh`,
26
+ # `fetch-secrets.sh`, `deploy-box.sh` and a `Makefile`.
27
+ #
28
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
29
+ # @param options [Hash] generation options; same shape as `Fargate.call`'s
30
+ # @return [Hash{String => String}] the generated file contents, keyed by filename
31
+ # @raise [ArgumentError] if the domain's deploy settings are invalid or conflict
32
+ def call(bluebook:, options: {})
33
+ world, domain, world_file = options.values_at(:world, :domain_dir, :world_file)
34
+ tenant = options[:tenant] || {}
35
+ settings = tenant_settings(world.for_verb("deployed_to"), tenant, File.basename(domain))
36
+ infra_name = (settings[:stack_name] || File.basename(domain)).to_s.tr("_", "-").downcase
37
+
38
+ target = declare(world.domain, settings, world_file)
39
+ plan = Settings.resolve(deploy_settings: settings, target: target, infra_name: infra_name)
40
+ render_all(plan, target.state[:region].value)
41
+ end
42
+
43
+ # Applies the `--tenant` suffix to the stack name, as `Fargate.call` does.
44
+ #
45
+ # @param settings [Hash{Symbol => Object}] the world's `deployed_to` settings
46
+ # @param tenant [Hash] the `--tenant` and `--schema` options
47
+ # @param domain_name [String] the domain directory's name
48
+ # @return [Hash{Symbol => Object}] the settings with the tenant's stack name
49
+ def tenant_settings(settings, tenant, domain_name)
50
+ return settings unless tenant[:tenant]
51
+
52
+ settings.merge(stack_name: "#{settings[:stack_name] || domain_name}-#{tenant[:tenant]}")
53
+ end
54
+
55
+ # Validates the target through the Deploy bluebook's own `BoxTarget.Declare`.
56
+ #
57
+ # @param domain [String] the world's domain name
58
+ # @param settings [Hash{Symbol => Object}] the world's `deployed_to` settings
59
+ # @param world_file [String] the `.world` file, named in a refusal
60
+ # @return [Object] the declared target aggregate
61
+ # @raise [ArgumentError] when the bluebook refuses a value
62
+ def declare(domain, settings, world_file)
63
+ dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
64
+ dispatcher.dispatch(
65
+ "Deploy::BoxTarget.Declare",
66
+ with: {
67
+ domain: { value: domain }, region: { value: settings[:region] },
68
+ instance_type: { value: settings.fetch(:instance_type, "t4g.medium") },
69
+ volume_gb: { value: settings.fetch(:volume_gb, 30) },
70
+ database_class: { value: settings.fetch(:database_class, "db.t4g.small") },
71
+ storage_gb: { value: settings.fetch(:storage_gb, 20) }
72
+ }
73
+ ).instance
74
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
75
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsBox\") is invalid: #{e.message}"
76
+ end
77
+
78
+ # @param plan [Settings::Plan] the resolved settings
79
+ # @param region [String] the validated region
80
+ # @return [Hash{String => String}] every generated file
81
+ def render_all(plan, region)
82
+ {
83
+ "rds.yaml" => rds_yaml(plan), "box.yaml" => box_yaml(plan, region),
84
+ "Caddyfile" => caddyfile(plan), "services.json" => services_json(plan),
85
+ "render-compose.sh" => template("render-compose.sh.tmpl", "STACK" => plan.infra_name, "REGION" => region,
86
+ "DB_NAME" => plan.database_name,
87
+ "PROXY_IMAGE" => plan.proxy_image),
88
+ "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
89
+ "deploy-box.sh" => deploy_box_sh(plan, region),
90
+ "Makefile" => makefile(plan)
91
+ }
92
+ end
93
+
94
+ # @param plan [Settings::Plan] the resolved settings
95
+ # @return [String] the database stack
96
+ def rds_yaml(plan)
97
+ template("rds.yaml.tmpl", "STACK" => plan.infra_name, "DB_CLASS" => plan.database_class,
98
+ "STORAGE_GB" => plan.storage_gb.to_s, "DB_NAME" => plan.database_name,
99
+ "ENGINE_VERSION" => plan.engine_version, "BACKUP_DAYS" => plan.backup_days.to_s)
100
+ end
101
+
102
+ # @param plan [Settings::Plan] the resolved settings
103
+ # @param region [String] the validated region
104
+ # @return [String] the box stack
105
+ def box_yaml(plan, region)
106
+ template("box.yaml.tmpl",
107
+ "STACK" => plan.infra_name, "INSTANCE_TYPE" => plan.instance_type,
108
+ "VOLUME_GB" => plan.volume_gb.to_s, "SNAPSHOTS_KEEP" => plan.snapshots_keep.to_s,
109
+ "AMI_PARAMETER" => ami_parameter(plan.instance_type),
110
+ "COMPUTE_DOMAIN" => compute_domain(region),
111
+ "SECRET_RESOURCES" => secret_resources(plan), "TUNNEL_EGRESS" => tunnel_egress(plan),
112
+ "SWAP_COMMANDS" => swap_commands(plan), "ECR_REPOSITORIES" => ecr_repositories(plan),
113
+ "ECR_OUTPUTS" => ecr_outputs(plan))
114
+ end
115
+
116
+ # Graviton families end their generation digit with `g` (`t4g`, `m7gd`, `c6gn`).
117
+ #
118
+ # @param instance_type [String] an EC2 instance type
119
+ # @return [String] the SSM parameter of the matching Amazon Linux image
120
+ def ami_parameter(instance_type)
121
+ arch = instance_type.split(".").first.match?(/\d+g[a-z]*\z/) ? "arm64" : "x86_64"
122
+ "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-#{arch}"
123
+ end
124
+
125
+ # @param region [String] the AWS region
126
+ # @return [String] the DNS suffix an Elastic address's public name ends with
127
+ def compute_domain(region)
128
+ region == LEGACY_COMPUTE_REGION ? "compute-1.amazonaws.com" : "#{region}.compute.amazonaws.com"
129
+ end
130
+
131
+ # One Secrets Manager statement resource per prefix. A name without a trailing `*` gets `-*`
132
+ # for the random suffix AWS appends to every secret ARN.
133
+ #
134
+ # @param plan [Settings::Plan] the resolved settings
135
+ # @return [String] YAML list items, one per prefix, ending in a newline
136
+ def secret_resources(plan)
137
+ named = [plan.origin_secret, plan.tunnel_service&.token_secret].compact
138
+ arns = (plan.secret_prefixes + named).map { |name| name.end_with?("*") ? name : "#{name}-*" }
139
+ arns.uniq.map do |pattern|
140
+ " - !Sub \"arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{pattern}\"\n"
141
+ end.join.chomp
142
+ end
143
+
144
+ # @param plan [Settings::Plan] the resolved settings
145
+ # @return [String] the outbound rules a Cloudflare Tunnel sidecar needs, or nothing
146
+ def tunnel_egress(plan)
147
+ return "" unless plan.tunnel
148
+
149
+ <<~YAML
150
+ #{' ' * 8}# cloudflared dials out to the tunnel edge on 7844. Outbound only: nothing opens inbound.
151
+ #{' ' * 8}- IpProtocol: tcp
152
+ #{' ' * 8} FromPort: 7844
153
+ #{' ' * 8} ToPort: 7844
154
+ #{' ' * 8} CidrIp: 0.0.0.0/0
155
+ #{' ' * 8} Description: tunnel to the edge
156
+ #{' ' * 8}- IpProtocol: udp
157
+ #{' ' * 8} FromPort: 7844
158
+ #{' ' * 8} ToPort: 7844
159
+ #{' ' * 8} CidrIp: 0.0.0.0/0
160
+ #{' ' * 8} Description: tunnel to the edge (QUIC)
161
+ YAML
162
+ end
163
+
164
+ # @param plan [Settings::Plan] the resolved settings
165
+ # @return [String] the user-data lines that add swap, or nothing when `swap_gb` is 0
166
+ def swap_commands(plan)
167
+ return "" if plan.swap_gb.zero?
168
+
169
+ pad = " " * 10
170
+ <<~SH.lines.map { |line| "#{pad}#{line}" }.join
171
+ # swap so a memory spike at boot cannot take the box down
172
+ fallocate -l #{plan.swap_gb}G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
173
+ echo '/swapfile none swap sw 0 0' >> /etc/fstab
174
+ SH
175
+ end
176
+
177
+ # One ECR repository per container, keeping the newest 30 images.
178
+ #
179
+ # @param plan [Settings::Plan] the resolved settings
180
+ # @return [String] CloudFormation resources, each preceded by a blank line
181
+ def ecr_repositories(plan)
182
+ plan.containers.map do |container|
183
+ id = "#{logical(container.name)}Repository"
184
+ <<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
185
+
186
+ #{id}:
187
+ Type: AWS::ECR::Repository
188
+ Properties:
189
+ RepositoryName: #{container.repository}
190
+ ImageScanningConfiguration:
191
+ ScanOnPush: true
192
+ LifecyclePolicy:
193
+ LifecyclePolicyText: '{"rules":[{"rulePriority":1,"description":"keep the newest 30","selection":{"tagStatus":"any","countType":"imageCountMoreThan","countNumber":30},"action":{"type":"expire"}}]}'
194
+ YAML
195
+ end.join.chomp
196
+ end
197
+
198
+ # @param plan [Settings::Plan] the resolved settings
199
+ # @return [String] one repository URI output per container
200
+ def ecr_outputs(plan)
201
+ plan.containers.map do |container|
202
+ " #{logical(container.name)}RepositoryUri:\n Value: !GetAtt #{logical(container.name)}Repository.RepositoryUri\n"
203
+ end.join.chomp
204
+ end
205
+
206
+ # @param name [String] a container name such as `web-app`
207
+ # @return [String] a CloudFormation logical id fragment, such as `WebApp`
208
+ def logical(name)
209
+ name.split("-").map(&:capitalize).join
210
+ end
211
+
212
+ # The proxy's configuration: an optional origin-secret guard, one block per route and a
213
+ # default for everything else.
214
+ #
215
+ # @param plan [Settings::Plan] the resolved settings
216
+ # @return [String] the Caddyfile
217
+ def caddyfile(plan)
218
+ guarded = !plan.origin_header.nil?
219
+ site =
220
+ if guarded
221
+ "\t@origin header #{plan.origin_header} {$ORIGIN_SECRET}\n\n\thandle @origin {\n" \
222
+ "#{indent(indent(route_blocks(plan)))}\t}\n\n\thandle {\n\t\trespond \"Forbidden\" 403\n\t}\n"
223
+ else
224
+ indent(route_blocks(plan))
225
+ end
226
+ "#{caddy_header(plan)}#{caddy_global(guarded)}\n:80 {\n#{site}}\n"
227
+ end
228
+
229
+ # @param plan [Settings::Plan] the resolved settings
230
+ # @return [String] the `handle` blocks for each route, then the default
231
+ def route_blocks(plan)
232
+ routes = plan.routes.each_with_index.map do |route, i|
233
+ port = plan.containers.find { |c| c.name == route.container }.port
234
+ "@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n\treverse_proxy 127.0.0.1:#{port}\n}\n\n"
235
+ end
236
+ "#{routes.join}handle {\n\treverse_proxy 127.0.0.1:#{plan.default.port}\n}\n"
237
+ end
238
+
239
+ # @param plan [Settings::Plan] the resolved settings
240
+ # @return [String] the comment that opens the Caddyfile
241
+ def caddy_header(plan)
242
+ origin =
243
+ if plan.origin_header
244
+ "Only requests that carry #{plan.origin_header} with the origin secret are proxied;\n" \
245
+ "# everything else gets a flat 403.\n"
246
+ else
247
+ "Every request is proxied.\n"
248
+ end
249
+ "# #{plan.infra_name}'s proxy. #{origin}"
250
+ end
251
+
252
+ # With a guard, the proxy trusts every source: a request that reaches a `handle` block has
253
+ # already proved it came through the CDN by presenting the secret, and everything else is
254
+ # refused. Caddy then keeps the client address the CDN put in X-Forwarded-For.
255
+ #
256
+ # @param guarded [Boolean] whether an origin secret guards the site
257
+ # @return [String] the global options block
258
+ def caddy_global(guarded)
259
+ return "{\n\tauto_https off\n\tadmin off\n}\n" unless guarded
260
+
261
+ "{\n\tauto_https off\n\tadmin off\n\tservers {\n\t\ttrusted_proxies static 0.0.0.0/0 ::/0\n\t}\n}\n"
262
+ end
263
+
264
+ # @param text [String] lines to indent one level with a tab
265
+ # @return [String] the text, each non-blank line prefixed with a tab
266
+ def indent(text)
267
+ text.lines.map { |line| line.strip.empty? ? line : "\t#{line}" }.join
268
+ end
269
+
270
+ # The compose source: one entry per container, plus the origin secret the proxy checks.
271
+ #
272
+ # @param plan [Settings::Plan] the resolved settings
273
+ # @return [String] `services.json`
274
+ def services_json(plan)
275
+ services = plan.containers.to_h do |c|
276
+ [c.name, { "name" => c.name, "repository" => c.repository, "port" => c.port,
277
+ "env" => c.env, "secrets" => c.secrets }]
278
+ end
279
+ origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
280
+ document = { "services" => services, "origin" => origin }
281
+ document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
282
+ json = JSON.pretty_generate(document)
283
+ # An empty object prints as `{}` or `{` newline `}`, depending on the json gem.
284
+ "#{json.gsub(/\{\s*\}/, '{}')}\n"
285
+ end
286
+
287
+ # @param tunnel [Settings::Tunnel] the declared tunnel service
288
+ # @return [Hash{String => Object}] its entry in `services.json`
289
+ def tunnel_entry(tunnel)
290
+ { "url" => "http://127.0.0.1:#{tunnel.port}", "token_secret" => tunnel.token_secret, "image" => tunnel.image }
291
+ end
292
+
293
+ # @param plan [Settings::Plan] the resolved settings
294
+ # @param region [String] the validated region
295
+ # @return [String] `deploy-box.sh`
296
+ def deploy_box_sh(plan, region)
297
+ template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
298
+ "RDS_STACK" => plan.rds_stack, "REGION" => region,
299
+ "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan))
300
+ end
301
+
302
+ # The probes the post-roll check runs on the box, against the proxy on localhost.
303
+ #
304
+ # @param plan [Settings::Plan] the resolved settings
305
+ # @return [String] bash lines that set `BAD=1` on a failed probe
306
+ def health_checks(plan)
307
+ write_out = '-w "%{http_code}"' # rubocop:disable Style/FormatStringToken -- curl's own token
308
+ probe = lambda do |label, args, wanted|
309
+ <<~SH.chomp
310
+ code=$(curl -s -o /dev/null #{write_out} --max-time 40 localhost/#{args})
311
+ #{wanted} && echo "ok #{label} -> $code" || { echo "FAIL #{label} -> $code"; BAD=1; }
312
+ SH
313
+ end
314
+ probes =
315
+ if plan.origin_header
316
+ [
317
+ "S=$(grep ^ORIGIN_SECRET= caddy.secrets.env | cut -d= -f2-)",
318
+ probe.call("a request without the origin secret is refused", "", '[ "$code" = 403 ]'),
319
+ probe.call("a request with the origin secret is served", %( -H "#{plan.origin_header}: $S"),
320
+ '[ "${code#5}" = "$code" ]')
321
+ ]
322
+ else
323
+ [probe.call("the proxy serves /", "", '[ "${code#5}" = "$code" ]')]
324
+ end
325
+ (probes + tunnel_probe(plan)).join("\n")
326
+ end
327
+
328
+ # @param plan [Settings::Plan] the resolved settings
329
+ # @return [Array<String>] the probe that waits for the tunnel to register, when one runs
330
+ def tunnel_probe(plan)
331
+ return [] unless plan.tunnel_service
332
+
333
+ [<<~SH.chomp]
334
+ for _ in 1 2 3 4 5 6; do
335
+ n=$(docker compose -f compose.json logs --no-color cloudflared 2>&1 | grep -ci "registered tunnel connection" || true)
336
+ [ "$n" -gt 0 ] && break
337
+ sleep 5
338
+ done
339
+ [ "$n" -gt 0 ] && echo "ok the tunnel registered $n connections" || { echo "FAIL the tunnel has no connection"; BAD=1; }
340
+ SH
341
+ end
342
+
343
+ # @param plan [Settings::Plan] the resolved settings
344
+ # @return [String] a Makefile whose targets create the two stacks and roll the box
345
+ def makefile(plan)
346
+ <<~MAKE
347
+ # #{plan.infra_name}: one app box and one RDS instance.
348
+ # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c
349
+ # make deploy [TAGS="web=20260101 worker=20260101"]
350
+ RDS_STACK = #{plan.rds_stack}
351
+ BOX_STACK = #{plan.box_stack}
352
+
353
+ .PHONY: stacks deploy
354
+ stacks:
355
+ \taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\
356
+ \t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS)
357
+ \taws cloudformation deploy --template-file box.yaml --stack-name $(BOX_STACK) --capabilities CAPABILITY_IAM \\
358
+ \t\t--parameter-overrides VpcId=$(VPC) SubnetId=$(PUBLIC_SUBNET) \\
359
+ \t\tDbSecurityGroupId=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecurityGroupId'].OutputValue" --output text) \\
360
+ \t\tDbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue" --output text) \\
361
+ \t\tAlertTopicArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" --output text)
362
+
363
+ deploy:
364
+ \tbash ./deploy-box.sh $(TAGS)
365
+ MAKE
366
+ end
367
+
368
+ # Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
369
+ # so an empty value leaves no blank line behind; one inside a line is replaced in place.
370
+ #
371
+ # @param file [String] the template file's name in `TEMPLATE_DIR`
372
+ # @param values [Hash{String => String}] marker name => text
373
+ # @return [String] the rendered file
374
+ def template(file, values)
375
+ values.reduce(File.read(File.join(TEMPLATE_DIR, file))) do |text, (marker, value)|
376
+ own_line = /^@@#{marker}@@\n/
377
+ if text.match?(own_line)
378
+ text.sub(own_line) { value.empty? || value.end_with?("\n") ? value : "#{value}\n" }
379
+ else
380
+ text.gsub("@@#{marker}@@") { value }
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
386
+ end
387
+ end
@@ -241,12 +241,21 @@ module Hecks
241
241
  end
242
242
  private_class_method :s3_origin_lines
243
243
 
244
+ # Renders one cache behaviour: the default one under `key`, or a list item led by its
245
+ # path.
246
+ #
247
+ # @param key [String, nil] `"DefaultCacheBehavior"`, or nil for a `CacheBehaviors` item
248
+ # @param entry [Hash{Symbol => Object}] the behaviour as `normalize` checks it (`:origin`,
249
+ # `:path` for an item, `:viewer_protocol`, `:methods`, `:compress`, `:cache_policy`,
250
+ # `:origin_request_policy`), and optionally `:response_headers_policy`, each policy as
251
+ # `[id_or_intrinsic, comment_or_nil]`
252
+ # @param base [String] the indentation of `key`, or of the list item's dash
253
+ # @return [Array<String>] the lines
244
254
  def behavior_lines(key, entry, base)
245
255
  pad = "#{base} "
246
256
  head = key ? "#{base}#{key}:" : "#{base}- PathPattern: #{Yaml.string(entry[:path])}"
247
257
  [head, "#{pad}TargetOriginId: #{entry[:origin]}", *behavior_property_lines(entry, pad)]
248
258
  end
249
- private_class_method :behavior_lines
250
259
 
251
260
  def behavior_property_lines(entry, pad)
252
261
  lines = ["#{pad}ViewerProtocolPolicy: #{entry[:viewer_protocol]}",
@@ -255,6 +264,9 @@ module Hecks
255
264
  lines << "#{pad}Compress: #{entry[:compress]}" if entry.key?(:compress)
256
265
  lines << policy_line(pad, "CachePolicyId", entry[:cache_policy])
257
266
  lines << policy_line(pad, "OriginRequestPolicyId", entry[:origin_request_policy]) if entry[:origin_request_policy]
267
+ if entry[:response_headers_policy]
268
+ lines << policy_line(pad, "ResponseHeadersPolicyId", entry[:response_headers_policy])
269
+ end
258
270
  lines
259
271
  end
260
272
  private_class_method :behavior_property_lines
@@ -634,7 +634,7 @@ module Hecks
634
634
  # generator the Lambda deploy path already uses to produce
635
635
  # rust/dist/#{domain_name}.wasm/.ir.json from this domain's own
636
636
  # .bluebook.
637
- \tcd $(ROOT) && HECKS_ENVIRONMENT=memory ruby exe/hecks build_wasm domain=$(DOMAIN) --wait
637
+ \tcd $(ROOT) && HECKS_ENVIRONMENT=memory ruby exe/hecks build.build_wasm domain=$(DOMAIN) --wait
638
638
  \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
639
639
  \tcp $(ROOT)/rust/host/target/aarch64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
640
640
  \tcp $(ROOT)/rust/dist/#{domain_name}.wasm #{domain_name}.wasm