hecks 3.0.5 → 3.1.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.
Files changed (170) 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 +32 -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/domain_writer.rb +39 -0
  15. data/lib/hecks/cli/interview_agent.rb +59 -0
  16. data/lib/hecks/cli/interview_draft.rb +246 -0
  17. data/lib/hecks/cli/interview_run.rb +87 -0
  18. data/lib/hecks/cli/interview_session.rb +299 -0
  19. data/lib/hecks/cli/model_check.rb +1 -1
  20. data/lib/hecks/cli/project_cli.rb +51 -29
  21. data/lib/hecks/cli/reference.rb +2 -1
  22. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  23. data/lib/hecks/cli/seed_semantics_corpus.rb +3 -3
  24. data/lib/hecks/cli/spec_example.rb +1 -1
  25. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  26. data/lib/hecks/corpus.rb +12 -8
  27. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  28. data/lib/hecks/deploy/oidc.json +5 -0
  29. data/lib/hecks/doc/reference.rb +4 -3
  30. data/lib/hecks/doors/cli_door.rb +13 -13
  31. data/lib/hecks/doors/cli_runner.rb +53 -33
  32. data/lib/hecks/doors/launcher_options.rb +5 -5
  33. data/lib/hecks/doors/mcp_door.rb +40 -18
  34. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  35. data/lib/hecks/doors/usage_cache.rb +123 -0
  36. data/lib/hecks/doors.rb +1 -1
  37. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  38. data/lib/hecks/forms/banking_presentation.rb +1 -1
  39. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  40. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  41. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  42. data/lib/hecks/framework.rb +1 -1
  43. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  44. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  45. data/lib/hecks/fuzzing/differential.rb +4 -2
  46. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  47. data/lib/hecks/fuzzing/form_census.rb +2 -2
  48. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  49. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  50. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  51. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  52. data/lib/hecks/gate/stages.yml +5 -5
  53. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  54. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  55. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  56. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  57. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  58. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  59. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  60. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  61. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  62. data/lib/hecks/hecks/codebase.bluebook +4 -3
  63. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  64. data/lib/hecks/hecks/custodian.bluebook +81 -3
  65. data/lib/hecks/hecks/hecks.bluebook +18 -0
  66. data/lib/hecks/hecks/hecks.hecksagon +16 -4
  67. data/lib/hecks/hecks/hecks.world +7 -6
  68. data/lib/hecks/hecks/site.behaviors +17 -0
  69. data/lib/hecks/hecks/site.world +10 -0
  70. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  71. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  72. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  73. data/lib/hecks/projections/deploy/box/settings.rb +324 -0
  74. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +268 -0
  75. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +68 -0
  76. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  77. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +181 -0
  78. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  79. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  80. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  81. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  82. data/lib/hecks/projections/deploy/box.rb +553 -0
  83. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  84. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  85. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  86. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  87. data/lib/hecks/projections/diagrams.rb +2 -2
  88. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  89. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  90. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  91. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  92. data/lib/hecks/projections/site/edge.rb +136 -0
  93. data/lib/hecks/projections/site/regions.rb +44 -0
  94. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  95. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  96. data/lib/hecks/projections/site/table/checks.rb +124 -0
  97. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  98. data/lib/hecks/projections/site/table.rb +98 -0
  99. data/lib/hecks/projections.rb +2 -0
  100. data/lib/hecks/projector/cli_projector.rb +153 -111
  101. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  102. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  103. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  104. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  105. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  106. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  107. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  108. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  109. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  110. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  111. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  112. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  113. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  114. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  115. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  116. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  117. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  118. data/lib/hecks/runtime/loader.rb +31 -8
  119. data/lib/hecks/runtime/registry/verification.rb +15 -29
  120. data/lib/hecks/runtime/registry.rb +14 -20
  121. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  122. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  123. data/lib/hecks/rust_build/domain_name.rb +26 -0
  124. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  125. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  126. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  127. data/lib/hecks/rust_build/project_rust.rb +57 -24
  128. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  129. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  130. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  131. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  132. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  133. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  134. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  135. data/lib/hecks/storehouse.rb +37 -7
  136. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  137. data/lib/hecks/three_zero/forms.yml +88 -88
  138. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  139. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  140. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  141. data/lib/hecks/tools/ci_gates.rb +1 -1
  142. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  143. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  144. data/lib/hecks/tools/sequence_script.rb +2 -2
  145. data/lib/hecks/tools/site_routes.rb +152 -0
  146. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  147. data/lib/hecks/tools.rb +1 -0
  148. data/lib/hecks/version.rb +1 -1
  149. data/lib/hecks.rb +1 -0
  150. data/rust/build/src/cargo_sync.rs +1 -1
  151. data/rust/build/src/pipeline.rs +23 -16
  152. data/rust/build/src/resolve.rs +39 -6
  153. data/rust/codegen/src/json_codec.rs +3 -0
  154. data/rust/codegen/src/naming.rs +4 -0
  155. data/rust/codegen/src/types.rs +45 -0
  156. data/rust/host/HECKS_RELEASE +1 -1
  157. data/rust/host/src/api.rs +1 -1
  158. data/rust/host/src/dispatch.rs +87 -7
  159. data/rust/host/src/needs.rs +111 -0
  160. data/rust/parser/src/keywords.rs +7 -6
  161. data/rust/parser/src/main.rs +4 -3
  162. data/rust/parser/src/parse/chapter.rs +1 -1
  163. data/rust/parser/src/parse/hecksagon.rs +25 -10
  164. data/rust/project/mutations.rb +4 -30
  165. data/rust/project/naming.rb +8 -28
  166. data/rust/project/write_if_changed.rb +4 -80
  167. data/rust/project_rust_pipeline.rb +16 -12
  168. data/rust/src/kernel/cli.rs +2 -0
  169. data/rust/src/kernel/needs.rs +74 -1
  170. metadata +51 -2
@@ -0,0 +1,268 @@
1
+ AWSTemplateFormatVersion: "2010-09-09"
2
+ Description: >
3
+ One EC2 box that runs @@STACK@@'s containers with Docker Compose behind Caddy, reading the RDS
4
+ instance from rds.yaml. No inbound rules but an optional CDN prefix list on port 80; use SSM Session
5
+ Manager to reach it. Secrets are read from Secrets Manager on the box at deploy time.
6
+
7
+ Parameters:
8
+ VpcId:
9
+ Type: AWS::EC2::VPC::Id
10
+ SubnetId:
11
+ Type: AWS::EC2::Subnet::Id
12
+ Description: A public subnet. The box needs a public IP to reach ECR and Secrets Manager.
13
+ DbSecurityGroupId:
14
+ Type: AWS::EC2::SecurityGroup::Id
15
+ Description: DbSecurityGroupId output of the RDS stack.
16
+ DbSecretArn:
17
+ Type: String
18
+ Description: DbSecretArn output of the RDS stack.
19
+ InstanceType:
20
+ Type: String
21
+ Default: @@INSTANCE_TYPE@@
22
+ AmiId:
23
+ Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
24
+ Default: @@AMI_PARAMETER@@
25
+ Description: Never re-resolve this on a live box, because a newer image replaces the instance.
26
+ RootVolumeGb:
27
+ Type: Number
28
+ Default: @@VOLUME_GB@@
29
+ AlertTopicArn:
30
+ Type: String
31
+ Default: ""
32
+ Description: AlertTopicArn output of the RDS stack. Blank creates the alarms with no notification target.
33
+ CdnPrefixListId:
34
+ Type: String
35
+ Default: ""
36
+ Description: >
37
+ A managed prefix list of the CDN's origin-facing addresses. When set, port 80 is open to it (the
38
+ proxy still refuses requests without the origin secret). Blank leaves the box with no inbound at all.
39
+ Rehearsal:
40
+ Type: String
41
+ Default: "false"
42
+ AllowedValues: ["true", "false"]
43
+ Description: true is a throwaway box with no Elastic IP.
44
+
45
+ Conditions:
46
+ HasPrefixList: !Not [!Equals [!Ref CdnPrefixListId, ""]]
47
+ HasAlerts: !Not [!Equals [!Ref AlertTopicArn, ""]]
48
+ IsProduction: !Equals [!Ref Rehearsal, "false"]
49
+
50
+ Resources:
51
+ BoxSecurityGroup:
52
+ Type: AWS::EC2::SecurityGroup
53
+ Properties:
54
+ GroupDescription: @@STACK@@ app box, port 80 from the CDN only, egress to RDS and 443
55
+ VpcId: !Ref VpcId
56
+ SecurityGroupIngress:
57
+ - !If
58
+ - HasPrefixList
59
+ - IpProtocol: tcp
60
+ FromPort: 80
61
+ ToPort: 80
62
+ SourcePrefixListId: !Ref CdnPrefixListId
63
+ Description: CDN origin-facing addresses
64
+ - !Ref AWS::NoValue
65
+ SecurityGroupEgress:
66
+ - IpProtocol: tcp
67
+ FromPort: 5432
68
+ ToPort: 5432
69
+ DestinationSecurityGroupId: !Ref DbSecurityGroupId
70
+ - IpProtocol: tcp
71
+ FromPort: 443
72
+ ToPort: 443
73
+ CidrIp: 0.0.0.0/0
74
+ @@TUNNEL_EGRESS@@
75
+ BoxToDbIngress:
76
+ Type: AWS::EC2::SecurityGroupIngress
77
+ Properties:
78
+ GroupId: !Ref DbSecurityGroupId
79
+ IpProtocol: tcp
80
+ FromPort: 5432
81
+ ToPort: 5432
82
+ SourceSecurityGroupId: !Ref BoxSecurityGroup
83
+ Description: App box
84
+
85
+ BoxRole:
86
+ Type: AWS::IAM::Role
87
+ Properties:
88
+ AssumeRolePolicyDocument:
89
+ Version: "2012-10-17"
90
+ Statement:
91
+ - Effect: Allow
92
+ Principal: { Service: ec2.amazonaws.com }
93
+ Action: sts:AssumeRole
94
+ ManagedPolicyArns:
95
+ - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore
96
+ - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
97
+ Policies:
98
+ - PolicyName: read-secrets
99
+ PolicyDocument:
100
+ Version: "2012-10-17"
101
+ Statement:
102
+ - Effect: Allow
103
+ Action: secretsmanager:GetSecretValue
104
+ Resource:
105
+ - !Ref DbSecretArn
106
+ @@SECRET_RESOURCES@@
107
+ @@S3_POLICY@@
108
+
109
+ BoxProfile:
110
+ Type: AWS::IAM::InstanceProfile
111
+ Properties:
112
+ Roles: [!Ref BoxRole]
113
+
114
+ Box:
115
+ Type: AWS::EC2::Instance
116
+ Properties:
117
+ ImageId: !Ref AmiId
118
+ InstanceType: !Ref InstanceType
119
+ SubnetId: !Ref SubnetId
120
+ SecurityGroupIds: [!Ref BoxSecurityGroup]
121
+ IamInstanceProfile: !Ref BoxProfile
122
+ BlockDeviceMappings:
123
+ - DeviceName: /dev/xvda
124
+ Ebs:
125
+ VolumeType: gp3
126
+ VolumeSize: !Ref RootVolumeGb
127
+ Encrypted: true
128
+ DeleteOnTermination: true
129
+ MetadataOptions:
130
+ HttpTokens: required
131
+ HttpPutResponseHopLimit: 2
132
+ Tags:
133
+ - Key: Name
134
+ Value: @@STACK@@-box
135
+ - Key: Backup
136
+ Value: @@STACK@@-box
137
+ UserData:
138
+ Fn::Base64: |
139
+ #!/bin/bash
140
+ set -eux
141
+ dnf install -y docker jq
142
+ systemctl enable --now docker
143
+ mkdir -p /usr/local/lib/docker/cli-plugins
144
+ curl -fsSL https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-$(uname -m) \
145
+ -o /usr/local/lib/docker/cli-plugins/docker-compose
146
+ chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
147
+ @@SWAP_COMMANDS@@ mkdir -p /opt/@@STACK@@
148
+
149
+ # Production only. A CDN needs an origin DNS name that survives a stop and start; an Elastic IP
150
+ # keeps the instance's public DNS name stable.
151
+ BoxAddress:
152
+ Type: AWS::EC2::EIP
153
+ Condition: IsProduction
154
+ Properties:
155
+ Domain: vpc
156
+ Tags:
157
+ - Key: Name
158
+ Value: @@STACK@@-box
159
+
160
+ BoxAddressAssociation:
161
+ Type: AWS::EC2::EIPAssociation
162
+ Condition: IsProduction
163
+ Properties:
164
+ AllocationId: !GetAtt BoxAddress.AllocationId
165
+ InstanceId: !Ref Box
166
+
167
+ # Daily snapshot of every volume on the instance. The database has its own RDS backups; this makes
168
+ # rebuilding the box fast.
169
+ SnapshotRole:
170
+ Type: AWS::IAM::Role
171
+ Properties:
172
+ AssumeRolePolicyDocument:
173
+ Version: "2012-10-17"
174
+ Statement:
175
+ - Effect: Allow
176
+ Principal: { Service: dlm.amazonaws.com }
177
+ Action: sts:AssumeRole
178
+ ManagedPolicyArns:
179
+ - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole
180
+
181
+ SnapshotPolicy:
182
+ Type: AWS::DLM::LifecyclePolicy
183
+ Properties:
184
+ # DLM allows only letters, digits, spaces, underscores and hyphens here.
185
+ Description: Daily snapshots of the @@STACK@@ app box - keep @@SNAPSHOTS_KEEP@@
186
+ State: ENABLED
187
+ ExecutionRoleArn: !GetAtt SnapshotRole.Arn
188
+ PolicyDetails:
189
+ ResourceTypes: [INSTANCE]
190
+ TargetTags:
191
+ - Key: Backup
192
+ Value: @@STACK@@-box
193
+ Schedules:
194
+ - Name: daily
195
+ CopyTags: true
196
+ CreateRule:
197
+ Interval: 24
198
+ IntervalUnit: HOURS
199
+ Times: ["07:00"]
200
+ RetainRule:
201
+ Count: @@SNAPSHOTS_KEEP@@
202
+
203
+ # A hardware fault on the host: EC2 moves the instance to healthy hardware (same disk, same
204
+ # address) and the topic is told.
205
+ SystemStatusAlarm:
206
+ Type: AWS::CloudWatch::Alarm
207
+ Properties:
208
+ AlarmDescription: EC2 system status check failing, recovering the instance
209
+ Namespace: AWS/EC2
210
+ MetricName: StatusCheckFailed_System
211
+ Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
212
+ Statistic: Maximum
213
+ Period: 60
214
+ EvaluationPeriods: 2
215
+ Threshold: 0
216
+ ComparisonOperator: GreaterThanThreshold
217
+ TreatMissingData: notBreaching
218
+ AlarmActions: !If
219
+ - HasAlerts
220
+ - [!Ref AlertTopicArn, !Sub "arn:${AWS::Partition}:automate:${AWS::Region}:ec2:recover"]
221
+ - [!Sub "arn:${AWS::Partition}:automate:${AWS::Region}:ec2:recover"]
222
+
223
+ # The OS or networking inside the instance is unhealthy. Not auto-fixed: a reboot can lose state
224
+ # worth looking at.
225
+ InstanceStatusAlarm:
226
+ Type: AWS::CloudWatch::Alarm
227
+ Condition: HasAlerts
228
+ Properties:
229
+ AlarmDescription: EC2 instance status check failing for 5 minutes
230
+ Namespace: AWS/EC2
231
+ MetricName: StatusCheckFailed_Instance
232
+ Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
233
+ Statistic: Maximum
234
+ Period: 60
235
+ EvaluationPeriods: 5
236
+ Threshold: 0
237
+ ComparisonOperator: GreaterThanThreshold
238
+ TreatMissingData: notBreaching
239
+ AlarmActions: [!Ref AlertTopicArn]
240
+ OKActions: [!Ref AlertTopicArn]
241
+
242
+ CpuHighAlarm:
243
+ Type: AWS::CloudWatch::Alarm
244
+ Condition: HasAlerts
245
+ Properties:
246
+ AlarmDescription: "Box CPU above 80% for 30 minutes"
247
+ Namespace: AWS/EC2
248
+ MetricName: CPUUtilization
249
+ Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
250
+ Statistic: Average
251
+ Period: 300
252
+ EvaluationPeriods: 6
253
+ Threshold: 80
254
+ ComparisonOperator: GreaterThanThreshold
255
+ TreatMissingData: notBreaching
256
+ AlarmActions: [!Ref AlertTopicArn]
257
+ OKActions: [!Ref AlertTopicArn]
258
+ @@ECR_REPOSITORIES@@
259
+ Outputs:
260
+ InstanceId:
261
+ Value: !Ref Box
262
+ AppOriginDomain:
263
+ Condition: IsProduction
264
+ Description: The box's stable public DNS name, for a CDN's origin.
265
+ Value: !Join ["", ["ec2-", !Join ["-", !Split [".", !Ref BoxAddress]], ".@@COMPUTE_DOMAIN@@"]]
266
+ BoxSecurityGroupId:
267
+ Value: !Ref BoxSecurityGroup
268
+ @@ECR_OUTPUTS@@
@@ -0,0 +1,68 @@
1
+ #!/bin/bash
2
+ # Roll @@STACK@@'s app box: render the compose files, push them over SSM, start the containers and check
3
+ # them. Exits non-zero if the roll fails or the box does not answer as expected.
4
+ #
5
+ @@USAGE@@
6
+ set -euo pipefail
7
+ HERE=$(cd "$(dirname "$0")" && pwd)
8
+ BOX_STACK=@@BOX_STACK@@
9
+ RDS_STACK=@@RDS_STACK@@
10
+ REGION=@@REGION@@
11
+ DIR=/opt/@@STACK@@
12
+ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].Outputs[?OutputKey==\`$2\`].OutputValue" --output text; }
13
+
14
+ BOX=$(out "$BOX_STACK" InstanceId)
15
+ DB_HOST=$(out "$RDS_STACK" DbEndpoint)
16
+ DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
17
+ [ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit 1; }
18
+ ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
19
+
20
+ WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
21
+ ( cd "$WORK" && bash "$HERE/render-compose.sh" "$DB_HOST" "$DB_SECRET" "$@" )
22
+
23
+ # Run a script on the box over SSM, wait for it, print its output, return its status.
24
+ run_on_box() {
25
+ local cid st i
26
+ cid=$(aws ssm send-command --instance-ids "$BOX" --document-name AWS-RunShellScript --timeout-seconds 900 \
27
+ --parameters "$1" --query 'Command.CommandId' --output text)
28
+ sleep 3
29
+ for i in $(seq 1 150); do
30
+ st=$(aws ssm get-command-invocation --command-id "$cid" --instance-id "$BOX" --query Status --output text)
31
+ [ "$st" != InProgress ] && [ "$st" != Pending ] && break
32
+ sleep 5
33
+ done
34
+ aws ssm get-command-invocation --command-id "$cid" --instance-id "$BOX" --query StandardOutputContent --output text
35
+ [ "$st" = Success ]
36
+ }
37
+
38
+ B64() { base64 < "$1" | tr -d '\n'; }
39
+ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
40
+ --arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
41
+ --arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
42
+ {commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
43
+ "set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
44
+ "echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
45
+ "echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
46
+ "bash fetch-secrets.sh",
47
+ "aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
48
+ "docker compose -f compose.json pull --quiet",
49
+ "docker compose -f compose.json up -d --remove-orphans",
50
+ "sleep 20; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
51
+ executionTimeout: ["900"]}')
52
+
53
+ echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
54
+ run_on_box "$ROLL" || { echo "==> the roll did not succeed" >&2; exit 1; }
55
+
56
+ echo "==> checking the box"
57
+ CHECK=$(cat <<'CHECK_EOF'
58
+ cd @@DIR@@
59
+ BAD=0
60
+ @@HEALTH_CHECKS@@
61
+ DOWN=$(docker compose -f compose.json ps --format "{{.Service}} {{.Status}}" | grep -vE " Up " || true)
62
+ if [ -z "$DOWN" ]; then echo "ok every container is up"; else echo "FAIL containers not up: $DOWN"; BAD=1; fi
63
+ [ "$BAD" = 0 ]
64
+ CHECK_EOF
65
+ )
66
+ CHECK_JSON=$(jq -n --arg c "$(printf '%s' "$CHECK" | base64 | tr -d '\n')" '{commands: ["echo \($c) | base64 -d | bash"]}')
67
+ run_on_box "$CHECK_JSON" || { echo "==> the box is NOT healthy after the roll" >&2; exit 1; }
68
+ echo "==> box roll done ($(date -u +%H:%M:%SZ))"
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ # Runs ON the box. Resolves secrets.json into per-service env files that compose reads, using the
3
+ # instance role, so secret values never pass through SSM command text.
4
+ #
5
+ # secrets.json: [{"service":"web","name":"AUTH_SECRET","valueFrom":"myapp/auth"}]
6
+ # valueFrom is a secret name (its whole string is the value) or a full ARN, optionally with a JSON
7
+ # key (arn:...:secret:NAME:jsonkey::) to pick one field of a JSON secret.
8
+ set -eu
9
+ cd "$(dirname "$0")"
10
+ umask 077
11
+ rm -f ./*.secrets.env
12
+ jq -r '.[] | [.service, .name, .valueFrom] | @tsv' secrets.json | while IFS=$'\t' read -r svc name from; do
13
+ case "$from" in
14
+ arn:*) id=$(echo "$from" | cut -d: -f1-7); region=$(echo "$from" | cut -d: -f4); jkey=$(echo "$from" | cut -d: -f8) ;;
15
+ *) id=$from; region=""; jkey="" ;;
16
+ esac
17
+ val=$(aws secretsmanager get-secret-value ${region:+--region "$region"} --secret-id "$id" --query SecretString --output text)
18
+ if [ -n "$jkey" ]; then val=$(printf %s "$val" | jq -r --arg k "$jkey" '.[$k]'); fi
19
+ [ -n "$val" ] && [ "$val" != null ] || { echo "empty secret for $svc/$name" >&2; exit 1; }
20
+ printf '%s=%s\n' "$name" "$val" >> "$svc.secrets.env"
21
+ done
@@ -0,0 +1,181 @@
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
+ BastionSecurityGroupId:
11
+ Type: String
12
+ Default: ""
13
+ Description: >
14
+ A security group allowed to reach the database on 5432, for the bastion that restore-to-rds.sh
15
+ tunnels through. Blank admits nothing but the app box.
16
+ PrivateSubnetIds:
17
+ Type: List<AWS::EC2::Subnet::Id>
18
+ Description: At least two subnets in different availability zones (an RDS subnet group needs them).
19
+ InstanceClass:
20
+ Type: String
21
+ Default: @@DB_CLASS@@
22
+ AllocatedStorageGb:
23
+ Type: Number
24
+ Default: @@STORAGE_GB@@
25
+ DatabaseName:
26
+ Type: String
27
+ Default: @@DB_NAME@@
28
+ EngineVersion:
29
+ Type: String
30
+ Default: "@@ENGINE_VERSION@@"
31
+ Description: A major version ("16") lets RDS pick its current minor release.
32
+ BackupRetentionDays:
33
+ Type: Number
34
+ Default: @@BACKUP_DAYS@@
35
+ ExistingAlertTopicArn:
36
+ Type: String
37
+ Default: ""
38
+ Description: An SNS topic that already has subscribers. Blank creates a new topic.
39
+ AlertEmail:
40
+ Type: String
41
+ Default: ""
42
+ Description: Subscribed to a new topic (AWS sends one confirmation link). Ignored when ExistingAlertTopicArn is set.
43
+ Rehearsal:
44
+ Type: String
45
+ Default: "false"
46
+ AllowedValues: ["true", "false"]
47
+ Description: >
48
+ true is a throwaway copy (no deletion protection, no final snapshot). false is the real
49
+ database: deletion protection on and a snapshot taken on delete.
50
+
51
+ Conditions:
52
+ NewTopic: !Equals [!Ref ExistingAlertTopicArn, ""]
53
+ NewTopicWithEmail: !And [!Condition NewTopic, !Not [!Equals [!Ref AlertEmail, ""]]]
54
+ IsRehearsal: !Equals [!Ref Rehearsal, "true"]
55
+ HasBastion: !Not [!Equals [!Ref BastionSecurityGroupId, ""]]
56
+
57
+ Resources:
58
+ DbSecurityGroup:
59
+ Type: AWS::EC2::SecurityGroup
60
+ Properties:
61
+ GroupDescription: @@STACK@@ RDS, 5432 from the app box (the box stack adds the rule) and a bastion if given
62
+ VpcId: !Ref VpcId
63
+ SecurityGroupIngress:
64
+ - !If
65
+ - HasBastion
66
+ - IpProtocol: tcp
67
+ FromPort: 5432
68
+ ToPort: 5432
69
+ SourceSecurityGroupId: !Ref BastionSecurityGroupId
70
+ Description: Bastion for restore-to-rds.sh
71
+ - !Ref AWS::NoValue
72
+
73
+ DbSubnetGroup:
74
+ Type: AWS::RDS::DBSubnetGroup
75
+ Properties:
76
+ DBSubnetGroupDescription: @@STACK@@ RDS subnets
77
+ SubnetIds: !Ref PrivateSubnetIds
78
+
79
+ Database:
80
+ Type: AWS::RDS::DBInstance
81
+ DeletionPolicy: !If [IsRehearsal, Delete, Snapshot]
82
+ UpdateReplacePolicy: !If [IsRehearsal, Delete, Snapshot]
83
+ Properties:
84
+ Engine: postgres
85
+ EngineVersion: !Ref EngineVersion
86
+ DBInstanceClass: !Ref InstanceClass
87
+ AllocatedStorage: !Ref AllocatedStorageGb
88
+ StorageType: gp3
89
+ StorageEncrypted: true
90
+ DBName: !Ref DatabaseName
91
+ MasterUsername: postgres
92
+ ManageMasterUserPassword: true
93
+ DBSubnetGroupName: !Ref DbSubnetGroup
94
+ VPCSecurityGroups: [!Ref DbSecurityGroup]
95
+ PubliclyAccessible: false
96
+ MultiAZ: false
97
+ BackupRetentionPeriod: !Ref BackupRetentionDays
98
+ DeletionProtection: !If [IsRehearsal, false, true]
99
+ AutoMinorVersionUpgrade: true
100
+ CopyTagsToSnapshot: true
101
+ Tags:
102
+ - Key: Name
103
+ Value: @@STACK@@-rds
104
+
105
+ AlertTopic:
106
+ Type: AWS::SNS::Topic
107
+ Condition: NewTopic
108
+ Properties:
109
+ DisplayName: @@STACK@@-alerts
110
+
111
+ AlertSubscription:
112
+ Type: AWS::SNS::Subscription
113
+ Condition: NewTopicWithEmail
114
+ Properties:
115
+ TopicArn: !Ref AlertTopic
116
+ Protocol: email
117
+ Endpoint: !Ref AlertEmail
118
+
119
+ # The journal only grows, and RDS stops accepting writes when storage is full.
120
+ StorageLowAlarm:
121
+ Type: AWS::CloudWatch::Alarm
122
+ Properties:
123
+ AlarmDescription: RDS free storage under 3 GB
124
+ Namespace: AWS/RDS
125
+ MetricName: FreeStorageSpace
126
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
127
+ Statistic: Minimum
128
+ Period: 300
129
+ EvaluationPeriods: 3
130
+ Threshold: 3221225472
131
+ ComparisonOperator: LessThanThreshold
132
+ TreatMissingData: breaching
133
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
134
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
135
+
136
+ CpuHighAlarm:
137
+ Type: AWS::CloudWatch::Alarm
138
+ Properties:
139
+ AlarmDescription: "RDS CPU above 80% for 15 minutes (a burstable class may be out of credits)"
140
+ Namespace: AWS/RDS
141
+ MetricName: CPUUtilization
142
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
143
+ Statistic: Average
144
+ Period: 300
145
+ EvaluationPeriods: 3
146
+ Threshold: 80
147
+ ComparisonOperator: GreaterThanThreshold
148
+ TreatMissingData: notBreaching
149
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
150
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
151
+
152
+ MemoryLowAlarm:
153
+ Type: AWS::CloudWatch::Alarm
154
+ Properties:
155
+ AlarmDescription: RDS freeable memory under 100 MB for 15 minutes
156
+ Namespace: AWS/RDS
157
+ MetricName: FreeableMemory
158
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
159
+ Statistic: Minimum
160
+ Period: 300
161
+ EvaluationPeriods: 3
162
+ Threshold: 104857600
163
+ ComparisonOperator: LessThanThreshold
164
+ TreatMissingData: notBreaching
165
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
166
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
167
+
168
+ Outputs:
169
+ AlertTopicArn:
170
+ Description: Pass as AlertTopicArn to the box stack.
171
+ Value: !If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]
172
+ DbEndpoint:
173
+ Value: !GetAtt Database.Endpoint.Address
174
+ DbName:
175
+ Value: !Ref DatabaseName
176
+ DbSecretArn:
177
+ Description: RDS-managed master secret ({username, password}).
178
+ Value: !GetAtt Database.MasterUserSecret.SecretArn
179
+ DbSecurityGroupId:
180
+ Description: Pass as DbSecurityGroupId to the box stack.
181
+ Value: !Ref DbSecurityGroup
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # Render compose.json and secrets.json for @@STACK@@'s app box from an ECS task definition.
3
+ #
4
+ # render-compose.sh <db-host> <db-secret-arn> [task-definition]
5
+ #
6
+ # The task definition (a family or family:revision; default: the latest active revision of @@FAMILY@@)
7
+ # is the source of truth for each container's image, environment and secrets, so the box starts with
8
+ # what the task would have. A container the box runs must be named in it. DB_HOST and DB_SECRET_ARN
9
+ # are replaced with the RDS stack's values where the task defines them, and every container runs on
10
+ # the box's own network, so the proxy reaches each one on 127.0.0.1:<port>. Secrets (name + valueFrom)
11
+ # are written to secrets.json for fetch-secrets.sh to resolve on the box.
12
+ set -euo pipefail
13
+ HERE=$(cd "$(dirname "$0")" && pwd)
14
+ DB_HOST=${1:?rds endpoint}
15
+ DB_SECRET=${2:?rds secret arn}
16
+ TD=${3:-@@FAMILY@@}
17
+
18
+ DEF=$(aws ecs describe-task-definition --task-definition "$TD" --query 'taskDefinition.containerDefinitions' --output json)
19
+ echo "rendering from task definition $TD" >&2
20
+
21
+ jq --argjson def "$DEF" --arg host "$DB_HOST" --arg secret "$DB_SECRET" '
22
+ def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
23
+ def env(c): ((c.environment // []) | map({(.name): .value}) | add // {})
24
+ | (if has("DB_HOST") then .DB_HOST = $host | .DB_SECRET_ARN = $secret else . end);
25
+ . as $in
26
+ | {services: (
27
+ ($in.services | with_entries(.value |= (. as $s
28
+ | ($def | map(select(.name == $s.name)) | first) as $c
29
+ | if $c == null then error("task definition has no container named " + $s.name) else
30
+ {image: $c.image, network_mode: "host", restart: "unless-stopped", logging: log,
31
+ environment: ({PORT: ($s.port | tostring)} + env($c))}
32
+ + (if (($c.secrets // []) | length) > 0 then {env_file: [($s.name + ".secrets.env")]} else {} end)
33
+ end)))
34
+ + {caddy: ({image: "@@PROXY_IMAGE@@", network_mode: "host", restart: "unless-stopped",
35
+ volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "./caddy-extra:/etc/caddy/extra:ro"], logging: log}
36
+ + (if $in.origin then {env_file: ["caddy.secrets.env"]} else {} end))}
37
+ + (if $in.tunnel then {cloudflared: {image: $in.tunnel.image, network_mode: "host", restart: "unless-stopped",
38
+ command: ["tunnel", "--no-autoupdate", "--url", $in.tunnel.url, "run"],
39
+ env_file: ["cloudflared.secrets.env"], logging: log}} else {} end))}' \
40
+ "$HERE/services.json" > compose.json
41
+
42
+ jq --argjson def "$DEF" '. as $in
43
+ | [ ($in.services | keys[]) as $n | $def[] | select(.name == $n) | (.secrets // [])[]
44
+ | {service: $n, name: .name, valueFrom: .valueFrom} ]
45
+ + [ (if $in.origin then {service: "caddy", name: "ORIGIN_SECRET", valueFrom: $in.origin.secret} else empty end),
46
+ (if $in.tunnel then {service: "cloudflared", name: "TUNNEL_TOKEN", valueFrom: $in.tunnel.token_secret} else empty end) ]' \
47
+ "$HERE/services.json" > secrets.json
48
+
49
+ echo "rendered compose.json and secrets.json ($(jq '.services | length' compose.json) services)"
@@ -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", "./caddy-extra:/etc/caddy/extra: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)"