hecks 3.0.4 → 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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +25 -7
  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/console.rb +24 -8
  12. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  13. data/lib/hecks/cli/doc_coverage.rb +1 -1
  14. data/lib/hecks/cli/domain_stub.rb +128 -0
  15. data/lib/hecks/cli/interview_draft.rb +246 -0
  16. data/lib/hecks/cli/model_check.rb +1 -1
  17. data/lib/hecks/cli/project_cli.rb +69 -18
  18. data/lib/hecks/cli/reference.rb +2 -1
  19. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  20. data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
  21. data/lib/hecks/cli/spec_example.rb +1 -1
  22. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  23. data/lib/hecks/corpus.rb +11 -8
  24. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  25. data/lib/hecks/deploy/oidc.json +5 -0
  26. data/lib/hecks/doc/reference.rb +4 -3
  27. data/lib/hecks/doors/cli_door.rb +13 -13
  28. data/lib/hecks/doors/cli_runner.rb +52 -32
  29. data/lib/hecks/doors/launcher_options.rb +23 -5
  30. data/lib/hecks/doors/mcp_door.rb +40 -18
  31. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  32. data/lib/hecks/doors.rb +1 -1
  33. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  34. data/lib/hecks/forms/banking_presentation.rb +1 -1
  35. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  37. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  38. data/lib/hecks/framework.rb +1 -1
  39. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  40. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  41. data/lib/hecks/fuzzing/differential.rb +4 -2
  42. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  43. data/lib/hecks/fuzzing/form_census.rb +2 -2
  44. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  45. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  46. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  47. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  48. data/lib/hecks/gate/stages.yml +8 -4
  49. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  50. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  51. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  52. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  54. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  55. data/lib/hecks/hecks/codebase.bluebook +4 -3
  56. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  57. data/lib/hecks/hecks/custodian.bluebook +45 -3
  58. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  59. data/lib/hecks/hecks/hecks.world +7 -4
  60. data/lib/hecks/hecks/site.behaviors +17 -0
  61. data/lib/hecks/hecks/site.world +10 -0
  62. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  63. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  64. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  65. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  66. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  67. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  68. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  69. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  70. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  71. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  72. data/lib/hecks/projections/deploy/box.rb +387 -0
  73. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  74. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  75. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  76. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  77. data/lib/hecks/projections/diagrams.rb +2 -2
  78. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  79. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  80. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  81. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  82. data/lib/hecks/projections/site/edge.rb +136 -0
  83. data/lib/hecks/projections/site/regions.rb +44 -0
  84. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  85. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  86. data/lib/hecks/projections/site/table/checks.rb +124 -0
  87. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  88. data/lib/hecks/projections/site/table.rb +98 -0
  89. data/lib/hecks/projections.rb +2 -0
  90. data/lib/hecks/projector/cli_projector.rb +172 -95
  91. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  92. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  93. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  95. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  96. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  97. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  99. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  100. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  101. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  102. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  103. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  104. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  105. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  106. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  107. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  108. data/lib/hecks/runtime/command_interpreter.rb +2 -0
  109. data/lib/hecks/runtime/interpreting.rb +21 -0
  110. data/lib/hecks/runtime/invocation.rb +5 -3
  111. data/lib/hecks/runtime/registry/verification.rb +15 -29
  112. data/lib/hecks/runtime/registry.rb +14 -20
  113. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  114. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  115. data/lib/hecks/rust_build/domain_name.rb +26 -0
  116. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  117. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  118. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  119. data/lib/hecks/rust_build/project_rust.rb +57 -24
  120. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  121. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  122. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  123. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  124. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  125. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  126. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  127. data/lib/hecks/storehouse.rb +37 -7
  128. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  129. data/lib/hecks/three_zero/forms.yml +88 -88
  130. data/lib/hecks/tools/ci_gates.rb +1 -1
  131. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  132. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  133. data/lib/hecks/tools/sequence_script.rb +2 -2
  134. data/lib/hecks/tools/site_routes.rb +152 -0
  135. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  136. data/lib/hecks/tools.rb +1 -0
  137. data/lib/hecks/version.rb +1 -1
  138. data/rust/build/src/cargo_sync.rs +1 -1
  139. data/rust/build/src/pipeline.rs +23 -16
  140. data/rust/build/src/resolve.rs +39 -6
  141. data/rust/host/HECKS_RELEASE +1 -1
  142. data/rust/host/src/api.rs +1 -1
  143. data/rust/host/src/dispatch.rs +87 -7
  144. data/rust/host/src/needs.rs +111 -0
  145. data/rust/host/src/web/newsletter/template.rs +69 -0
  146. data/rust/host/src/web/newsletter/tests.rs +43 -0
  147. data/rust/host/src/web/newsletter.rs +6 -1
  148. data/rust/host/src/web/newsletter_send.rs +1 -1
  149. data/rust/parser/src/keywords.rs +7 -6
  150. data/rust/parser/src/main.rs +4 -3
  151. data/rust/parser/src/parse/chapter.rs +1 -1
  152. data/rust/parser/src/parse/hecksagon.rs +25 -10
  153. data/rust/project/mutations.rb +4 -30
  154. data/rust/project/naming.rb +8 -28
  155. data/rust/project/write_if_changed.rb +4 -80
  156. data/rust/project_rust_pipeline.rb +16 -12
  157. data/rust/src/kernel/cli.rs +2 -0
  158. data/rust/src/kernel/needs.rs +74 -1
  159. metadata +39 -2
@@ -16,7 +16,7 @@ module Hecks
16
16
 
17
17
  # Runs both halves of the era gate over every bluebook in a registry.
18
18
  #
19
- # Looks up each bluebook's own source separately: `uses_framework` can share a boot between
19
+ # Looks up each bluebook's own source separately: `attaches` can share a boot between
20
20
  # the domain's own bluebook and a differently-sourced one, so one file read once and reused
21
21
  # for every bluebook would attribute the wrong text.
22
22
  #
@@ -84,7 +84,7 @@ module Hecks
84
84
  # Reads the source text one bluebook was declared in, wherever that source lives.
85
85
  #
86
86
  # Matched by declared name, not position: a domain directory using
87
- # `uses_framework` may hold more than one file, and a framework
87
+ # `attaches` may hold more than one file, and a framework
88
88
  # member or vendored bluebook is excluded from the single-file
89
89
  # fallback so its own real source is read, not a sibling's.
90
90
  #
@@ -150,11 +150,11 @@ module Hecks
150
150
  #
151
151
  # @param registry [Runtime::Registry] the registry whose hecksagons are searched
152
152
  # @param bluebook_name [String] the bluebook's declared Pascal-case name
153
- # @return [String, nil] the package name as written in `uses_embryonaut_bluebook`; nil
153
+ # @return [String, nil] the package name as written in `attaches ... from: :vendor`; nil
154
154
  # when no hecksagon vendored a package whose name Pascal-cases to `bluebook_name`
155
155
  def vendored_bluebook_name_for(registry, bluebook_name)
156
156
  registry.hecksagons.each_value do |hecksagon|
157
- match = hecksagon.vendored_bluebooks.find { |name| Naming.pascal(name) == bluebook_name }
157
+ match = hecksagon.vendored_packages.find { |name| Naming.pascal(name) == bluebook_name }
158
158
  return match if match
159
159
  end
160
160
  nil
@@ -123,7 +123,8 @@ module Hecks
123
123
  raise unless defined?(PG::Error) && e.is_a?(PG::Error)
124
124
 
125
125
  raise Runtime::WiringError,
126
- "cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
126
+ "cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip} " \
127
+ "-- to run without a database, set HECKS_ENVIRONMENT=memory (a domain needs an environments/memory overlay)"
127
128
  end
128
129
 
129
130
  # Joins the process's shared connection for the declared database and schema, so every
@@ -0,0 +1,248 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Box
5
+ # Resolves a world's `deployed_to("AwsBox")` settings into the checked plan the box
6
+ # generator renders from.
7
+ #
8
+ # Every string that reaches a template or a script is matched against a conservative
9
+ # pattern first, so a world file cannot splice shell, YAML or Caddy syntax into the output.
10
+ # A setting the world leaves out resolves to the default the golden stack was built with.
11
+ module Settings
12
+ NAME = /\A[a-z][a-z0-9-]{0,40}\z/
13
+ REPOSITORY = %r{\A[a-z0-9][a-z0-9._/-]{1,100}\z}
14
+ URL_PATH = %r{\A/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*\z}
15
+ SECRET_NAME = %r{\A[A-Za-z0-9/_+=.@-]{1,256}\*?\z}
16
+ ENV_KEY = /\A[A-Za-z_][A-Za-z0-9_]*\z/
17
+ ENV_VALUE = /\A[^\x00-\x1f\x7f]*\z/
18
+ HEADER = /\A[A-Za-z][A-Za-z0-9-]{0,63}\z/
19
+ DB_NAME = /\A[a-zA-Z][a-zA-Z0-9]{0,62}\z/
20
+ ENGINE = /\A\d{2}(\.\d{1,2})?\z/
21
+ PREFIX = /\A[a-z][a-z0-9-]{0,20}\z/
22
+ IMAGE = %r{\A[a-z0-9][a-z0-9._/:@-]{1,200}\z}
23
+ # Default images, each a version tag plus the digest of its multi-architecture index,
24
+ # so a rebuilt box pulls the same bytes.
25
+ TUNNEL_IMAGE = "cloudflare/cloudflared:2026.9.3" \
26
+ "@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c".freeze
27
+ PROXY_IMAGE = "public.ecr.aws/docker/library/caddy:2.8" \
28
+ "@sha256:226d1f059b75399fe19182893c7184591c07b97afc8dfcf44eeb80c9a77a530f".freeze
29
+ TUNNEL_SHAPE = "tunnel: a hash needs `to` (the container it forwards to) and `token_secret` " \
30
+ "(the secret holding the tunnel token)".freeze
31
+ ORIGIN_PAIR = "origin_header and origin_secret go together: the header a CDN sends, " \
32
+ "and the secret that holds its value".freeze
33
+
34
+ # One container the box runs: its image repository, its port and its settings.
35
+ #
36
+ # @!attribute [r] name [String] the compose service name
37
+ # @!attribute [r] repository [String] the ECR repository holding its images
38
+ # @!attribute [r] port [Integer] the port it listens on, on the box's own network
39
+ # @!attribute [r] env [Hash{String => String}] plain environment variables
40
+ # @!attribute [r] secrets [Hash{String => String}] environment variable => secret name,
41
+ # resolved on the box at deploy time and never written into a template
42
+ Container = Struct.new(:name, :repository, :port, :env, :secrets, keyword_init: true)
43
+
44
+ # A set of URL paths one container serves.
45
+ #
46
+ # @!attribute [r] container [String] the container's name
47
+ # @!attribute [r] paths [Array<String>] Caddy path patterns, such as "/cms/*"
48
+ Route = Struct.new(:container, :paths, keyword_init: true)
49
+
50
+ # A Cloudflare tunnel the box runs as a service, pointed at one container.
51
+ #
52
+ # @!attribute [r] container [String] the container the tunnel forwards to
53
+ # @!attribute [r] port [Integer] that container's port
54
+ # @!attribute [r] token_secret [String] the secret holding the tunnel token
55
+ # @!attribute [r] image [String] the cloudflared image
56
+ Tunnel = Struct.new(:container, :port, :token_secret, :image, keyword_init: true)
57
+
58
+ # Everything the generator reads, checked.
59
+ Plan = Struct.new(
60
+ :infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
61
+ :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
62
+ :containers, :routes, :default_container, :origin_header, :origin_secret,
63
+ :secret_prefixes, :tunnel, :tunnel_service, :proxy_image, keyword_init: true
64
+ ) do
65
+ # @return [String] the CloudFormation stack that holds the database
66
+ def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
67
+
68
+ # @return [String] the CloudFormation stack that holds the box
69
+ def box_stack = "#{stack_prefix}-#{infra_name}-box"
70
+
71
+ # @return [Container] the container that answers every path no route claims
72
+ def default
73
+ containers.find { |c| c.name == default_container }
74
+ end
75
+ end
76
+
77
+ module_function
78
+
79
+ # @param deploy_settings [Hash{Symbol => Object}] the world's `AwsBox` settings
80
+ # @param target [Object] the validated `BoxTarget` (its instance type, volume, database
81
+ # class and storage, already checked by `Declare`)
82
+ # @param infra_name [String] the stack name the world or the domain gives
83
+ # @return [Plan] the resolved plan
84
+ # @raise [ArgumentError] when a setting is missing, malformed or inconsistent
85
+ def resolve(deploy_settings:, target:, infra_name:)
86
+ s = deploy_settings
87
+ check(:stack_name, infra_name, NAME)
88
+ containers = read_containers(s.fetch(:containers) { raise ArgumentError, missing_containers }, infra_name)
89
+ header, secret = read_origin(s)
90
+ tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
91
+
92
+ Plan.new(
93
+ infra_name: infra_name, stack_prefix: check(:stack_prefix, s.fetch(:stack_prefix, "hecks"), PREFIX),
94
+ **declared_sizes(target), **read_sizes(s), database_name: read_database_name(s, infra_name),
95
+ engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
96
+ containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
97
+ default_container: read_default(s[:default_container], containers), origin_header: header,
98
+ origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
99
+ tunnel: tunnel, tunnel_service: tunnel_service,
100
+ proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE)
101
+ )
102
+ end
103
+
104
+ # @param target [Object] the declared `BoxTarget`
105
+ # @return [Hash{Symbol => Object}] the sizes `Declare` has already validated
106
+ def declared_sizes(target)
107
+ %i[instance_type volume_gb database_class storage_gb].to_h { |key| [key, target.state[key].value] }
108
+ end
109
+
110
+ # @param settings [Hash{Symbol => Object}] the world's settings
111
+ # @return [Hash{Symbol => Integer}] swap, backup retention and snapshot retention, bounded
112
+ def read_sizes(settings)
113
+ {
114
+ swap_gb: integer(:swap_gb, settings.fetch(:swap_gb, 2), 0, 64),
115
+ backup_days: integer(:backup_days, settings.fetch(:backup_days, 7), 1, 35),
116
+ snapshots_keep: integer(:snapshots_keep, settings.fetch(:snapshots_keep, 7), 1, 1000)
117
+ }
118
+ end
119
+
120
+ def read_database_name(settings, infra_name)
121
+ check(:database_name, settings.fetch(:database_name, infra_name.gsub(/[^a-zA-Z0-9]/, "")), DB_NAME)
122
+ end
123
+
124
+ def read_containers(list, infra_name)
125
+ raise ArgumentError, missing_containers unless list.is_a?(Array) && !list.empty?
126
+
127
+ containers = list.map { |c| read_container(c, infra_name) }
128
+ names = containers.map(&:name)
129
+ dup = names.find { |n| names.count(n) > 1 }
130
+ raise ArgumentError, "containers: two containers are named #{dup.inspect}" if dup
131
+
132
+ ports = containers.map(&:port)
133
+ clash = ports.find { |p| ports.count(p) > 1 }
134
+ raise ArgumentError, "containers: two containers listen on port #{clash}; they share the box's network" if clash
135
+
136
+ containers
137
+ end
138
+
139
+ def read_container(spec, infra_name)
140
+ raise ArgumentError, "containers: each container is a hash, got #{spec.inspect}" unless spec.is_a?(Hash)
141
+
142
+ name = check(:container_name, spec.fetch(:name) { raise ArgumentError, "containers: a container has no name" }, NAME)
143
+ Container.new(
144
+ name: name, repository: check(:repository, spec.fetch(:repository, "#{infra_name}-#{name}"), REPOSITORY),
145
+ port: integer(:port, spec.fetch(:port) { raise ArgumentError, "containers: #{name} has no port" }, 1, 65_535),
146
+ env: string_map(:env, spec.fetch(:env, {}), ENV_KEY, ENV_VALUE),
147
+ secrets: string_map(:secrets, spec.fetch(:secrets, {}), ENV_KEY, SECRET_NAME)
148
+ )
149
+ end
150
+
151
+ def read_routes(list, containers)
152
+ raise ArgumentError, "routes: expected a list" unless list.is_a?(Array)
153
+
154
+ list.map { |spec| read_route(spec, containers) }
155
+ end
156
+
157
+ def read_route(spec, containers)
158
+ container = spec.fetch(:container) { raise ArgumentError, "routes: a route has no container" }
159
+ name = check(:route_container, container, NAME)
160
+ known_container!(:routes, name, containers)
161
+ paths = Array(spec.fetch(:paths) { raise ArgumentError, "routes: the route for #{name} has no paths" })
162
+ raise ArgumentError, "routes: the route for #{name} has no paths" if paths.empty?
163
+
164
+ Route.new(container: name, paths: paths.map { |p| check(:path, p, URL_PATH) })
165
+ end
166
+
167
+ def read_default(name, containers)
168
+ return containers.first.name if name.nil? && containers.size == 1
169
+ raise ArgumentError, "default_container: name the container that answers every other path" if name.nil?
170
+
171
+ known_container!(:default_container, check(:default_container, name, NAME), containers)
172
+ end
173
+
174
+ def known_container!(key, name, containers)
175
+ return name if containers.any? { |c| c.name == name }
176
+
177
+ raise ArgumentError, "#{key}: #{name.inspect} is not a declared container"
178
+ end
179
+
180
+ def read_origin(settings)
181
+ header = settings[:origin_header]
182
+ secret = settings[:origin_secret]
183
+ return [nil, nil] if header.nil? && secret.nil?
184
+ raise ArgumentError, ORIGIN_PAIR if header.nil? || secret.nil?
185
+
186
+ [check(:origin_header, header, HEADER), check(:origin_secret, secret, SECRET_NAME)]
187
+ end
188
+
189
+ # `tunnel true` only opens the outbound port; a hash also runs cloudflared as a service.
190
+ #
191
+ # @param value [Boolean, Hash{Symbol => Object}] the world's `tunnel` setting
192
+ # @param containers [Array<Container>] the declared containers
193
+ # @return [Array(Boolean, Tunnel)] whether the egress opens, and the service if one runs
194
+ def read_tunnel(value, containers)
195
+ return [boolean(:tunnel, value), nil] unless value.is_a?(Hash)
196
+
197
+ to = value.fetch(:to) { raise ArgumentError, TUNNEL_SHAPE }
198
+ target = known_container!(:tunnel, check(:tunnel_to, to, NAME), containers)
199
+ token = check(:tunnel_token_secret, value.fetch(:token_secret) { raise ArgumentError, TUNNEL_SHAPE }, SECRET_NAME)
200
+ image = check(:tunnel_image, value.fetch(:image, TUNNEL_IMAGE), IMAGE)
201
+ port = containers.find { |c| c.name == target }.port
202
+ [true, Tunnel.new(container: target, port: port, token_secret: token, image: image)]
203
+ end
204
+
205
+ def read_prefixes(settings, infra_name)
206
+ list = Array(settings.fetch(:secret_prefixes, ["#{infra_name}/*"]))
207
+ list.map { |p| check(:secret_prefixes, p, SECRET_NAME) }
208
+ end
209
+
210
+ def string_map(key, value, key_pattern, value_pattern)
211
+ raise ArgumentError, "#{key}: expected a hash" unless value.is_a?(Hash)
212
+
213
+ value.to_h { |k, v| [check(key, k.to_s, key_pattern), check(key, v.to_s, value_pattern)] }
214
+ end
215
+
216
+ def check(key, value, pattern)
217
+ return value if value.is_a?(String) && value.match?(pattern)
218
+
219
+ raise ArgumentError, "#{key}: #{value.inspect} does not match #{pattern.inspect}"
220
+ end
221
+
222
+ def integer(key, value, low, high)
223
+ return value if value.is_a?(Integer) && value.between?(low, high)
224
+
225
+ raise ArgumentError, "#{key}: #{value.inspect} must be an integer from #{low} to #{high}"
226
+ end
227
+
228
+ def boolean(key, value)
229
+ return value if [true, false].include?(value)
230
+
231
+ raise ArgumentError, "#{key}: #{value.inspect} must be true or false"
232
+ end
233
+
234
+ def missing_containers
235
+ <<~MSG
236
+ deployed_to("AwsBox") needs at least one container. Add one, e.g.:
237
+
238
+ deployed_to("AwsBox") do
239
+ region "us-east-1"
240
+ containers [{ name: "web", port: 8080 }]
241
+ end
242
+ MSG
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,267 @@
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
+
108
+ BoxProfile:
109
+ Type: AWS::IAM::InstanceProfile
110
+ Properties:
111
+ Roles: [!Ref BoxRole]
112
+
113
+ Box:
114
+ Type: AWS::EC2::Instance
115
+ Properties:
116
+ ImageId: !Ref AmiId
117
+ InstanceType: !Ref InstanceType
118
+ SubnetId: !Ref SubnetId
119
+ SecurityGroupIds: [!Ref BoxSecurityGroup]
120
+ IamInstanceProfile: !Ref BoxProfile
121
+ BlockDeviceMappings:
122
+ - DeviceName: /dev/xvda
123
+ Ebs:
124
+ VolumeType: gp3
125
+ VolumeSize: !Ref RootVolumeGb
126
+ Encrypted: true
127
+ DeleteOnTermination: true
128
+ MetadataOptions:
129
+ HttpTokens: required
130
+ HttpPutResponseHopLimit: 2
131
+ Tags:
132
+ - Key: Name
133
+ Value: @@STACK@@-box
134
+ - Key: Backup
135
+ Value: @@STACK@@-box
136
+ UserData:
137
+ Fn::Base64: |
138
+ #!/bin/bash
139
+ set -eux
140
+ dnf install -y docker jq
141
+ systemctl enable --now docker
142
+ mkdir -p /usr/local/lib/docker/cli-plugins
143
+ curl -fsSL https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-$(uname -m) \
144
+ -o /usr/local/lib/docker/cli-plugins/docker-compose
145
+ chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
146
+ @@SWAP_COMMANDS@@ mkdir -p /opt/@@STACK@@
147
+
148
+ # Production only. A CDN needs an origin DNS name that survives a stop and start; an Elastic IP
149
+ # keeps the instance's public DNS name stable.
150
+ BoxAddress:
151
+ Type: AWS::EC2::EIP
152
+ Condition: IsProduction
153
+ Properties:
154
+ Domain: vpc
155
+ Tags:
156
+ - Key: Name
157
+ Value: @@STACK@@-box
158
+
159
+ BoxAddressAssociation:
160
+ Type: AWS::EC2::EIPAssociation
161
+ Condition: IsProduction
162
+ Properties:
163
+ AllocationId: !GetAtt BoxAddress.AllocationId
164
+ InstanceId: !Ref Box
165
+
166
+ # Daily snapshot of every volume on the instance. The database has its own RDS backups; this makes
167
+ # rebuilding the box fast.
168
+ SnapshotRole:
169
+ Type: AWS::IAM::Role
170
+ Properties:
171
+ AssumeRolePolicyDocument:
172
+ Version: "2012-10-17"
173
+ Statement:
174
+ - Effect: Allow
175
+ Principal: { Service: dlm.amazonaws.com }
176
+ Action: sts:AssumeRole
177
+ ManagedPolicyArns:
178
+ - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole
179
+
180
+ SnapshotPolicy:
181
+ Type: AWS::DLM::LifecyclePolicy
182
+ Properties:
183
+ # DLM allows only letters, digits, spaces, underscores and hyphens here.
184
+ Description: Daily snapshots of the @@STACK@@ app box - keep @@SNAPSHOTS_KEEP@@
185
+ State: ENABLED
186
+ ExecutionRoleArn: !GetAtt SnapshotRole.Arn
187
+ PolicyDetails:
188
+ ResourceTypes: [INSTANCE]
189
+ TargetTags:
190
+ - Key: Backup
191
+ Value: @@STACK@@-box
192
+ Schedules:
193
+ - Name: daily
194
+ CopyTags: true
195
+ CreateRule:
196
+ Interval: 24
197
+ IntervalUnit: HOURS
198
+ Times: ["07:00"]
199
+ RetainRule:
200
+ Count: @@SNAPSHOTS_KEEP@@
201
+
202
+ # A hardware fault on the host: EC2 moves the instance to healthy hardware (same disk, same
203
+ # address) and the topic is told.
204
+ SystemStatusAlarm:
205
+ Type: AWS::CloudWatch::Alarm
206
+ Properties:
207
+ AlarmDescription: EC2 system status check failing, recovering the instance
208
+ Namespace: AWS/EC2
209
+ MetricName: StatusCheckFailed_System
210
+ Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
211
+ Statistic: Maximum
212
+ Period: 60
213
+ EvaluationPeriods: 2
214
+ Threshold: 0
215
+ ComparisonOperator: GreaterThanThreshold
216
+ TreatMissingData: notBreaching
217
+ AlarmActions: !If
218
+ - HasAlerts
219
+ - [!Ref AlertTopicArn, !Sub "arn:${AWS::Partition}:automate:${AWS::Region}:ec2:recover"]
220
+ - [!Sub "arn:${AWS::Partition}:automate:${AWS::Region}:ec2:recover"]
221
+
222
+ # The OS or networking inside the instance is unhealthy. Not auto-fixed: a reboot can lose state
223
+ # worth looking at.
224
+ InstanceStatusAlarm:
225
+ Type: AWS::CloudWatch::Alarm
226
+ Condition: HasAlerts
227
+ Properties:
228
+ AlarmDescription: EC2 instance status check failing for 5 minutes
229
+ Namespace: AWS/EC2
230
+ MetricName: StatusCheckFailed_Instance
231
+ Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
232
+ Statistic: Maximum
233
+ Period: 60
234
+ EvaluationPeriods: 5
235
+ Threshold: 0
236
+ ComparisonOperator: GreaterThanThreshold
237
+ TreatMissingData: notBreaching
238
+ AlarmActions: [!Ref AlertTopicArn]
239
+ OKActions: [!Ref AlertTopicArn]
240
+
241
+ CpuHighAlarm:
242
+ Type: AWS::CloudWatch::Alarm
243
+ Condition: HasAlerts
244
+ Properties:
245
+ AlarmDescription: "Box CPU above 80% for 30 minutes"
246
+ Namespace: AWS/EC2
247
+ MetricName: CPUUtilization
248
+ Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
249
+ Statistic: Average
250
+ Period: 300
251
+ EvaluationPeriods: 6
252
+ Threshold: 80
253
+ ComparisonOperator: GreaterThanThreshold
254
+ TreatMissingData: notBreaching
255
+ AlarmActions: [!Ref AlertTopicArn]
256
+ OKActions: [!Ref AlertTopicArn]
257
+ @@ECR_REPOSITORIES@@
258
+ Outputs:
259
+ InstanceId:
260
+ Value: !Ref Box
261
+ AppOriginDomain:
262
+ Condition: IsProduction
263
+ Description: The box's stable public DNS name, for a CDN's origin.
264
+ Value: !Join ["", ["ec2-", !Join ["-", !Split [".", !Ref BoxAddress]], ".@@COMPUTE_DOMAIN@@"]]
265
+ BoxSecurityGroupId:
266
+ Value: !Ref BoxSecurityGroup
267
+ @@ECR_OUTPUTS@@
@@ -0,0 +1,70 @@
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
+ # deploy-box.sh [name=tag ...]
6
+ #
7
+ # A container named without a tag runs its "latest" image. Secret values are resolved on the box by
8
+ # fetch-secrets.sh and never pass through the SSM command.
9
+ set -euo pipefail
10
+ HERE=$(cd "$(dirname "$0")" && pwd)
11
+ BOX_STACK=@@BOX_STACK@@
12
+ RDS_STACK=@@RDS_STACK@@
13
+ REGION=@@REGION@@
14
+ DIR=/opt/@@STACK@@
15
+ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].Outputs[?OutputKey==\`$2\`].OutputValue" --output text; }
16
+
17
+ BOX=$(out "$BOX_STACK" InstanceId)
18
+ DB_HOST=$(out "$RDS_STACK" DbEndpoint)
19
+ DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
20
+ [ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit 1; }
21
+ ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
22
+
23
+ WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
24
+ ( cd "$WORK" && bash "$HERE/render-compose.sh" "$DB_HOST" "$DB_SECRET" "$@" )
25
+
26
+ # Run a script on the box over SSM, wait for it, print its output, return its status.
27
+ run_on_box() {
28
+ local cid st i
29
+ cid=$(aws ssm send-command --instance-ids "$BOX" --document-name AWS-RunShellScript --timeout-seconds 900 \
30
+ --parameters "$1" --query 'Command.CommandId' --output text)
31
+ sleep 3
32
+ for i in $(seq 1 150); do
33
+ st=$(aws ssm get-command-invocation --command-id "$cid" --instance-id "$BOX" --query Status --output text)
34
+ [ "$st" != InProgress ] && [ "$st" != Pending ] && break
35
+ sleep 5
36
+ done
37
+ aws ssm get-command-invocation --command-id "$cid" --instance-id "$BOX" --query StandardOutputContent --output text
38
+ [ "$st" = Success ]
39
+ }
40
+
41
+ B64() { base64 < "$1" | tr -d '\n'; }
42
+ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
43
+ --arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
44
+ --arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
45
+ {commands: ["set -e", "mkdir -p \($dir) && cd \($dir)", "umask 077",
46
+ "echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
47
+ "echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
48
+ "bash fetch-secrets.sh",
49
+ "aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
50
+ "docker compose -f compose.json pull --quiet",
51
+ "docker compose -f compose.json up -d --remove-orphans",
52
+ "sleep 20; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
53
+ executionTimeout: ["900"]}')
54
+
55
+ echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
56
+ run_on_box "$ROLL" || { echo "==> the roll did not succeed" >&2; exit 1; }
57
+
58
+ echo "==> checking the box"
59
+ CHECK=$(cat <<'CHECK_EOF'
60
+ cd @@DIR@@
61
+ BAD=0
62
+ @@HEALTH_CHECKS@@
63
+ DOWN=$(docker compose -f compose.json ps --format "{{.Service}} {{.Status}}" | grep -vE " Up " || true)
64
+ if [ -z "$DOWN" ]; then echo "ok every container is up"; else echo "FAIL containers not up: $DOWN"; BAD=1; fi
65
+ [ "$BAD" = 0 ]
66
+ CHECK_EOF
67
+ )
68
+ CHECK_JSON=$(jq -n --arg c "$(printf '%s' "$CHECK" | base64 | tr -d '\n')" '{commands: ["echo \($c) | base64 -d | bash"]}')
69
+ run_on_box "$CHECK_JSON" || { echo "==> the box is NOT healthy after the roll" >&2; exit 1; }
70
+ 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