hecks 3.6.0 → 3.9.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  5. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  6. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  7. data/lib/hecks/cache_dir.rb +10 -0
  8. data/lib/hecks/corpus/routes.rb +1 -1
  9. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  10. data/lib/hecks/deploy/oidc.json +10 -0
  11. data/lib/hecks/doors/usage_cache.rb +19 -9
  12. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  13. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  14. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  15. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  16. data/lib/hecks/fuzzing/form_census.rb +28 -52
  17. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  18. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  19. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  20. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  21. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  22. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  23. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  24. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  25. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  26. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  27. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  28. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  29. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  30. data/lib/hecks/fuzzing/mutation.rb +12 -0
  31. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  32. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  33. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  34. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  35. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  36. data/lib/hecks/fuzzing/properties.rb +7 -0
  37. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  38. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  39. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  40. data/lib/hecks/fuzzing/replay.rb +1 -0
  41. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  42. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  43. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  44. data/lib/hecks/fuzzing.rb +3 -0
  45. data/lib/hecks/gate/stages.yml +12 -2
  46. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  47. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  48. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  49. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  50. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  51. data/lib/hecks/hecks/custodian.behaviors +6 -0
  52. data/lib/hecks/hecks/custodian.bluebook +37 -0
  53. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  54. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
  55. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  56. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  57. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  58. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  59. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  60. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  61. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  62. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  63. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  64. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  65. data/lib/hecks/projections/deploy/box.rb +14 -2
  66. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  67. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  68. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  69. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  70. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  71. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  72. data/lib/hecks/projections/site/probe.rb +131 -0
  73. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  74. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  75. data/lib/hecks/projections.rb +2 -0
  76. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  77. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  78. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  79. data/lib/hecks/runtime/value/validation.rb +34 -3
  80. data/lib/hecks/rust_build/native_build.rb +29 -1
  81. data/lib/hecks/rust_build/wasm.rb +16 -3
  82. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  83. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  84. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  85. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  86. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  87. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  88. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  89. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  90. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  91. data/lib/hecks/tools/lanes/files.rb +2 -4
  92. data/lib/hecks/tools/lanes/live.rb +3 -4
  93. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  94. data/lib/hecks/tools/lanes.rb +12 -1
  95. data/lib/hecks/tools/mutation_run.rb +73 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +3 -1
  99. data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
  100. data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
  101. data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
  102. data/qa/settings.yml +1 -0
  103. data/rust/codegen/src/fielded.rs +3 -3
  104. data/rust/codegen/src/json_codec.rs +2 -2
  105. data/rust/codegen/src/naming.rs +20 -0
  106. data/rust/host/Cargo.toml +6 -2
  107. data/rust/host/HECKS_RELEASE +1 -1
  108. data/rust/host/src/api.rs +28 -5
  109. data/rust/host/src/auth.rs +11 -1
  110. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  111. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  112. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  113. data/rust/host/src/boundary_fuzz/ir.rs +79 -0
  114. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  115. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  116. data/rust/host/src/commerce_ir.rs +459 -0
  117. data/rust/host/src/extension.rs +100 -0
  118. data/rust/host/src/fuzz_support.rs +165 -0
  119. data/rust/host/src/ir.rs +4 -448
  120. data/rust/host/src/journal.rs +1 -1
  121. data/rust/host/src/lib.rs +39 -0
  122. data/rust/host/src/main.rs +10 -44
  123. data/rust/host/src/mint.rs +1 -1
  124. data/rust/host/src/payments/tests.rs +8 -8
  125. data/rust/host/src/payments.rs +10 -10
  126. data/rust/host/src/rate_limit.rs +27 -41
  127. data/rust/host/src/secrets.rs +44 -0
  128. data/rust/host/src/server.rs +6 -1
  129. data/rust/host/src/web/commerce.rs +113 -0
  130. data/rust/host/src/web/newsletter.rs +2 -1
  131. data/rust/host/src/web/newsletter_send.rs +1 -1
  132. data/rust/host/src/web/registration_receipt.rs +2 -2
  133. data/rust/host/src/web/registrations.rs +49 -49
  134. data/rust/host/src/web/resend_webhook.rs +299 -0
  135. data/rust/host/src/web.rs +55 -49
  136. data/rust/parser/fuzz/.gitignore +4 -0
  137. data/rust/parser/fuzz/Cargo.lock +89 -0
  138. data/rust/parser/fuzz/Cargo.toml +27 -0
  139. data/rust/parser/fuzz/README.md +19 -0
  140. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  141. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  142. data/rust/parser/src/parse/command.rs +14 -2
  143. data/rust/parser/src/parse/mod.rs +25 -0
  144. data/rust/parser/src/ruby_value.rs +95 -6
  145. data/rust/src/kernel/json.rs +34 -3
  146. metadata +58 -2
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require_relative "../../../projections/site/probe"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ class SiteToolchain
10
+ # Sends a route table's probe requests to a running site and reports each answer.
11
+ #
12
+ # Every request is anonymous and follows no redirect, so a probe changes nothing on the site.
13
+ class Prober
14
+ # Seconds to wait to connect to the site, and again for its answer.
15
+ TIMEOUT = 10
16
+
17
+ # The failures of the network that fail one check rather than the whole run.
18
+ NETWORK = [SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError].freeze
19
+
20
+ # @param rows [Array<Projections::Site::Table::Row>] the checked rows of the route table
21
+ # @param run [String] the run's name
22
+ # @param base [String] the site's scheme, host and port
23
+ def initialize(rows, run:, base:)
24
+ @probe = Projections::Site::Probe.new(rows, run: run)
25
+ @base = base
26
+ end
27
+
28
+ # @return [Array<(String, Integer)>] the report, one line per check ending in the count,
29
+ # and how many checks failed
30
+ def call
31
+ lines = @probe.checks.map { |check| judged(check) }
32
+ failed = lines.count { |_, wrong| wrong }
33
+ [lines.map(&:first).append("#{lines.size} checks, #{failed} failed").join("\n"), failed]
34
+ end
35
+
36
+ private
37
+
38
+ # One check's line of the report: its label and `ok`, or the reason it failed.
39
+ def judged(check)
40
+ why = @probe.verdict(check, answer_to(check))
41
+ [" #{check.label}... #{why ? "FAILED: #{why}" : "ok"}", !why.nil?]
42
+ rescue *NETWORK => e
43
+ [" #{check.label}... FAILED: #{e.class}: #{e.message}", true]
44
+ end
45
+
46
+ def answer_to(check)
47
+ uri = URI.join(@base, check.path)
48
+ request = Net::HTTP.const_get(check.verb.capitalize).new(uri)
49
+ request["Origin"] = @base
50
+ response = connection(uri).request(request)
51
+ Projections::Site::Probe::Response.new(status: response.code.to_i, body: response.body,
52
+ headers: response.to_hash.transform_values(&:first))
53
+ end
54
+
55
+ def connection(uri)
56
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
57
+ http.use_ssl = uri.scheme == "https"
58
+ http.open_timeout = http.read_timeout = TIMEOUT
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -8,6 +8,7 @@ require_relative "codebase/ruby_child"
8
8
  require "hecks/tools"
9
9
  require "hecks/tools/site_routes"
10
10
  require "hecks/projections/site/live_edge"
11
+ require_relative "site_toolchain/prober"
11
12
 
12
13
  module Hecks
13
14
  module Adapters
@@ -66,8 +67,35 @@ module Hecks
66
67
  { output: { value: comparison.to_s } }
67
68
  end
68
69
 
70
+ # Asks a running site what its route table says it must answer, and judges each answer. It
71
+ # sends anonymous requests only, follows no redirect, and changes nothing on the site: an
72
+ # admin route is asked without a session and must refuse.
73
+ #
74
+ # @param held [Hash] the `SiteProjection` record: `domain`, `run` and `url`, the site's base
75
+ # address
76
+ # @return [Hash{Symbol => Hash}] `output:` one line per check, ending in the count
77
+ # @raise [ConsoleCapture::Failure] when the address is not http(s) or an answer is wrong; the
78
+ # message is the report
79
+ def probe(**held)
80
+ rows = table_of(located(:domain, held[:domain])).rows
81
+ report, failed = Prober.new(rows, run: plain(held[:run]).to_s, base: base_url(plain(held[:url]))).call
82
+ raise ConsoleCapture::Failure, report if failed.positive?
83
+
84
+ { output: { value: report } }
85
+ end
86
+
69
87
  private
70
88
 
89
+ # The scheme, host and port of an http(s) address.
90
+ def base_url(url)
91
+ uri = URI.parse(url.to_s)
92
+ raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) && uri.host
93
+
94
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
95
+ rescue URI::InvalidURIError
96
+ raise ConsoleCapture::Failure, "url must be an http(s) address, got #{url.inspect}"
97
+ end
98
+
71
99
  # The flags `project_site` is run with: each the record names, and `--check` when asked.
72
100
  def project_flags(held)
73
101
  flags = FLAGS.filter_map { |flag, key| "--#{flag}=#{located(key, held[key])}" unless plain(held[key]).nil? }
@@ -84,15 +112,23 @@ module Hecks
84
112
 
85
113
  # The project's checked edge, read the way `project_site` reads it.
86
114
  def edge_of(root, template = nil)
115
+ registry, chapter, table = read_table(root)
116
+ site = Projections::Site
117
+ site::Edge.read(chapter, table: table, template: template, vocabulary: site::Table.vocabulary(registry)) ||
118
+ raise(ConsoleCapture::Failure, "#{root} declares no Edge rows")
119
+ end
120
+
121
+ # The project's checked route table.
122
+ def table_of(root) = read_table(root).last
123
+
124
+ # The registry, chapter and checked table of the project at `root`.
125
+ def read_table(root)
87
126
  registry = nil
88
127
  outcome = ConsoleCapture.capture { registry = Tools::SiteRoutes.registry_for(root) }
89
128
  raise ConsoleCapture::Failure, outcome.output.strip unless outcome.ok?
90
129
 
91
- site = Projections::Site
92
- chapter = site::Table.chapter(registry)
93
- table = site::Table.read(chapter, registry: registry)
94
- site::Edge.read(chapter, table: table, template: template, vocabulary: site::Table.vocabulary(registry)) ||
95
- raise(ConsoleCapture::Failure, "#{root} declares no Edge rows")
130
+ chapter = Projections::Site::Table.chapter(registry)
131
+ [registry, chapter, Projections::Site::Table.read(chapter, registry: registry)]
96
132
  rescue Projections::Site::Table::Invalid => e
97
133
  raise ConsoleCapture::Failure, e.message
98
134
  end
@@ -147,6 +147,12 @@ Hecks.behaviors "Custodian" do
147
147
  expect refused: "a count is positive"
148
148
  end
149
149
 
150
+ test "Mutate refuses a budget that is not positive" do
151
+ tests "Mutate", on: "FuzzRun"
152
+ input run: { value: "mutants" }, domain: { value: "examples/pizzas" }, budget: { value: 0 }
153
+ expect refused: "a count is positive"
154
+ end
155
+
150
156
  test "Fuzz refuses a persistence it does not know" do
151
157
  tests "Fuzz", on: "FuzzRun"
152
158
  input run: { value: "sweep" }, adapter: { value: "cassandra" }
@@ -1963,7 +1963,9 @@ policy "RecordTheEra" do
1963
1963
  attribute :seeds, Count, optional: true
1964
1964
  attribute :steps, Count, optional: true
1965
1965
  attribute :workers, Count, optional: true
1966
+ attribute :budget, Count, optional: true
1966
1967
  attribute :adapter, Persistence, optional: true
1968
+ attribute :persist_regressions, RegressionSwitch, optional: true
1967
1969
  attribute :domains, NameList, optional: true
1968
1970
  attribute :targets, NameList, optional: true
1969
1971
  attribute :iterations, Count, optional: true
@@ -2006,6 +2008,11 @@ policy "RecordTheEra" do
2006
2008
  attribute :value, String, pattern: '^(memory|sqlite|postgres)$'
2007
2009
  end
2008
2010
 
2011
+ # Whether each distinct finding's minimized repro is also kept under `spec/corpus/regressions/`.
2012
+ value_object "RegressionSwitch" do
2013
+ attribute :value, Boolean
2014
+ end
2015
+
2009
2016
  # Names, comma separated: benchmark domains, or the targets to measure.
2010
2017
  value_object "NameList" do
2011
2018
  attribute :value, String, pattern: '^[^ \t\n\r,]+(,[^ \t\n\r,]+)*$'
@@ -2047,6 +2054,7 @@ policy "RecordTheEra" do
2047
2054
  attribute :steps, Count, optional: true
2048
2055
  attribute :workers, Count, optional: true
2049
2056
  attribute :adapter, Persistence, optional: true
2057
+ attribute :persist_regressions, RegressionSwitch, optional: true
2050
2058
 
2051
2059
  sets :domain
2052
2060
  sets :run
@@ -2054,10 +2062,34 @@ policy "RecordTheEra" do
2054
2062
  sets :steps
2055
2063
  sets :workers
2056
2064
  sets :adapter
2065
+ sets :persist_regressions
2057
2066
 
2058
2067
  emits FuzzRequested
2059
2068
  end
2060
2069
 
2070
+ # Mutation-tests a domain's own checks: changes its bluebooks in small ways and keeps which
2071
+ # changes the fuzz properties, the corpus script and the domain's behaviors tests let through.
2072
+ # A survivor is a hole in those checks. `budget` caps how many mutants are tried. The run is a
2073
+ # child process started through the ProcessPool adapter, as a sweep is.
2074
+ command "Mutate" do
2075
+ role "Operator"
2076
+ goal "Mutate a domain's rules and keep whether its checks noticed each change"
2077
+
2078
+ attribute :domain, DomainPath
2079
+ attribute :run, FuzzKey
2080
+ attribute :seeds, Count, optional: true
2081
+ attribute :steps, Count, optional: true
2082
+ attribute :budget, Count, optional: true
2083
+
2084
+ sets :domain
2085
+ sets :run
2086
+ sets :seeds
2087
+ sets :steps
2088
+ sets :budget
2089
+
2090
+ emits MutateRequested
2091
+ end
2092
+
2061
2093
  # Measures each persistence adapter, and the Rust binary when there is one to build or use.
2062
2094
  # The report is kept in this record, which the Hecks domain binds to Memory: keeping a
2063
2095
  # domain's benchmark history beside it, in Sqlite under the project's `.hecks/`, is decided
@@ -2148,6 +2180,11 @@ policy "RecordTheEra" do
2148
2180
  trigger FuzzRun::ProcessPool::Sweep, with: { run: :run }
2149
2181
  end
2150
2182
 
2183
+ policy "MutateWhenRequested" do
2184
+ on "FuzzRun.MutateRequested"
2185
+ trigger FuzzRun::ProcessPool::Probe, with: { run: :run }
2186
+ end
2187
+
2151
2188
  policy "MeasureWhenRequested" do
2152
2189
  on "FuzzRun.BenchRequested"
2153
2190
  trigger FuzzRun::RustToolchain::Measure, with: { run: :run }
@@ -188,6 +188,12 @@ Hecks.hecksagon "Hecks" do
188
188
  answers "FuzzAnswered"
189
189
  refuses "FuzzRefused"
190
190
  end
191
+
192
+ asks "Probe", to: FuzzRun do
193
+ attribute :run, FuzzKey
194
+ answers "FuzzAnswered"
195
+ refuses "FuzzRefused"
196
+ end
191
197
  end
192
198
 
193
199
  Hecks::FuzzRun.port "RustToolchain" do
@@ -626,7 +626,7 @@ Hecks.bluebook "Bluebook" do
626
626
  label: "does this change touch lib/hecks/runtime/**?"
627
627
  member name: "postgres_io_relevant_changed", workflow: "ci-postgres-io-parallel.yml", mode: "skips_unless",
628
628
  pattern: '^(docs/|editors/|release/|deploy/|\.claude/|\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests)/|\.rubocop\.yml$|\.rubocop_todo\.yml$|\.mcp\.json$|\.rspec-local\.example$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|SECURITY\.md$|LICENSE$|\.gitignore$)',
629
- push: "before_sha",
629
+ push: "skip",
630
630
  label: "does this change touch anything rspec_postgres_io_parallel covers?"
631
631
  end
632
632
 
@@ -706,6 +706,8 @@ Hecks.bluebook "Bluebook" do
706
706
  meaning: "Fraction (0.0..1.0) of seeds that start from a prefix of an earlier seed's new coverage. Off by default: measured, it trades breadth for depth (banking reached 1.41x the deep-state tuples, chess lost 0.12x of all tuples), while favoring rare verbs alone never lost."
707
707
  member name: "favor_rare_verbs", type: "Integer",
708
708
  meaning: "How many of the least-hit verbs the next seed weights up; 0 turns the lever off."
709
+ member name: "runtime_coverage_feedback", type: "Boolean",
710
+ meaning: "Whether guided generation also admits a seed to the corpus for reaching a new line or branch of the Ruby runtime (`Hecks::Fuzzing::RuntimeCoverage`), not only a new (verb, kind, state, mutation, outcome) tuple. Off by default: tracing every line slows each seed."
709
711
  member name: "self_consistency_checks", type: "Boolean",
710
712
  meaning: "Whether `hecks quality_control query sweep.run` also runs the self-consistency pass (cold rehydration, replay idempotency, value-object JSON round trip) on the same generated sequence."
711
713
  member name: "shrink_budget", type: "Integer",
@@ -802,6 +804,7 @@ Hecks.bluebook "Bluebook" do
802
804
  member script: "fuzz", section: "Custodian", aggregate: "FuzzRun", verb: "fuzz"
803
805
  member script: "generate", section: "Custodian", aggregate: "FuzzRun", verb: "generate_sequence"
804
806
  member script: "bench", section: "Custodian", aggregate: "FuzzRun", verb: "bench"
807
+ member script: "(new)", section: "Custodian", aggregate: "FuzzRun", verb: "mutate"
805
808
  member script: "(new)", section: "Custodian", aggregate: "Era", verb: "hold_first"
806
809
  member script: "project_deploy", section: "Deploy", aggregate: "Recipe", verb: "project"
807
810
  member script: "lint_deploy_recipes", section: "Deploy", aggregate: "MakefileCheck", verb: "lint"
@@ -44,7 +44,8 @@ module Hecks
44
44
  template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
45
45
  "RDS_STACK" => plan.rds_stack, "REGION" => region,
46
46
  "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan),
47
- "SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan))
47
+ "SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan),
48
+ "DB_SECRET_LINE" => database_secret_line(plan))
48
49
  end
49
50
 
50
51
  # @param plan [Settings::Plan] the resolved settings
@@ -96,9 +97,10 @@ module Hecks
96
97
  # @return [String] a Makefile whose targets create the two stacks and roll the box
97
98
  def makefile(plan)
98
99
  hint = plan.task_definition ? "[TASKDEF=family:revision]" : '[TAGS="web=20260101 worker=20260101"]'
99
- base = template("Makefile.tmpl", "STACK" => plan.infra_name, "DEPLOY_HINT" => hint,
100
- "RDS_STACK" => plan.rds_stack, "BOX_STACK" => plan.box_stack,
101
- "DEPLOY_RECIPE" => RollRecipe.deploy(plan))
100
+ base = template("Makefile.tmpl", { "STACK" => plan.infra_name, "DEPLOY_HINT" => hint,
101
+ "RDS_STACK" => plan.rds_stack, "BOX_STACK" => plan.box_stack,
102
+ "DEPLOY_RECIPE" => RollRecipe.deploy(plan) }
103
+ .merge(database_makefile_values(plan)))
102
104
  plan.hosting ? "#{base}\ninclude hosting.mk\n" : base
103
105
  end
104
106
 
@@ -23,6 +23,10 @@ module Hecks
23
23
  S3_BUCKET = /\A[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\z/
24
24
  S3_SHAPE = "s3_access: a list of `{ bucket: \"name\", write: true }` hashes (write is optional)".freeze
25
25
  FROM_TASKDEF = %i[env secrets repository].freeze
26
+
27
+ # The settings that size an RDS instance: with `shared_database` they are the shared
28
+ # instance's.
29
+ SHARED_INSTANCE_SETTINGS = %i[database_class storage_gb engine_version backup_days].freeze
26
30
  # Default images, each a version tag plus the digest of its multi-architecture index,
27
31
  # so a rebuilt box pulls the same bytes.
28
32
  TUNNEL_IMAGE = "cloudflare/cloudflared:2026.9.3" \
@@ -51,10 +51,14 @@ module Hecks
51
51
  :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
52
52
  :containers, :routes, :default_container, :origin_header, :origin_secret,
53
53
  :secret_prefixes, :writable_secrets, :origin_env, :tunnel, :tunnel_service, :proxy_image,
54
- :task_definition, :migration, :s3_buckets, :hosting, keyword_init: true
54
+ :task_definition, :migration, :s3_buckets, :hosting, :shared_database, keyword_init: true
55
55
  ) do
56
- # @return [String] the CloudFormation stack that holds the database
57
- def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
56
+ # @return [String] the CloudFormation stack that holds the database: the shared
57
+ # instance's when the world names one, else this site's own
58
+ def rds_stack = shared_database || "#{stack_prefix}-#{infra_name}-rds"
59
+
60
+ # @return [Boolean] whether the database lives on an instance shared with other sites
61
+ def shared? = !shared_database.nil?
58
62
 
59
63
  # @return [String] the CloudFormation stack that holds the box
60
64
  def box_stack = "#{stack_prefix}-#{infra_name}-box"
@@ -47,6 +47,7 @@ module Hecks
47
47
  def extra_fields(settings, task_definition, database_name)
48
48
  {
49
49
  task_definition: task_definition, migration: read_migration(settings[:migration], database_name),
50
+ shared_database: read_shared_database(settings),
50
51
  s3_buckets: read_s3_access(settings.fetch(:s3_access, [])),
51
52
  hosting: HostingSettings.read(settings, task_definition)
52
53
  }
@@ -129,6 +130,23 @@ module Hecks
129
130
  end.uniq(&:name)
130
131
  end
131
132
 
133
+ # The stack of the RDS instance this site shares with others. The instance's size,
134
+ # storage, engine and backups are the instance's, so a world that sets them is refused.
135
+ #
136
+ # @param settings [Hash{Symbol => Object}] the world's `AwsBox` settings
137
+ # @return [String, nil] the shared stack's name, or nil for a database of its own
138
+ # @raise [ArgumentError] when the name is malformed or the world also sizes the instance
139
+ def read_shared_database(settings)
140
+ return nil unless settings.key?(:shared_database)
141
+
142
+ clash = SHARED_INSTANCE_SETTINGS & settings.keys
143
+ unless clash.empty?
144
+ raise ArgumentError, "shared_database: #{clash.join(", ")} size the instance, which belongs to the shared " \
145
+ "stack #{settings[:shared_database]}; drop #{clash.size == 1 ? "it" : "them"}"
146
+ end
147
+ check(:shared_database, settings[:shared_database], NAME)
148
+ end
149
+
132
150
  def read_database_name(settings, infra_name)
133
151
  check(:database_name, settings.fetch(:database_name, infra_name.gsub(/[^a-zA-Z0-9]/, "")), DB_NAME)
134
152
  end
@@ -0,0 +1,69 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Box
5
+ # The parts of a box deploy that change when its database is one of several on a shared RDS
6
+ # instance, each client in its own database with its own login role (ADR 0092). Mixed into
7
+ # `Box`. Everything here returns the dedicated-instance text unchanged when the plan has no
8
+ # `shared_database`, so a site with its own instance generates the same files as before.
9
+ module SharedDatabase
10
+ # @param plan [Settings::Plan] the resolved settings
11
+ # @return [Hash{String => String}] the Makefile markers that depend on where the database
12
+ # lives
13
+ def database_makefile_values(plan)
14
+ return dedicated_makefile_values unless plan.shared?
15
+
16
+ { "WHAT" => "one app box and a database on the shared instance #{plan.rds_stack}",
17
+ "REHEARSAL_NOTE" => "# true gives the box no Elastic IP. It never deletes the shared instance\n" \
18
+ "# or this site's database.\n",
19
+ "RDS_DEPLOY" => "", "DB_SECRET_PARAM" => "\t\tDbSecretArn=$$(aws secretsmanager describe-secret --secret-id " \
20
+ "#{plan.infra_name}/database --query ARN --output text) \\",
21
+ "PHONY" => ".PHONY: stacks deploy provision", "PROVISION" => provision_target(plan) }
22
+ end
23
+
24
+ # @param plan [Settings::Plan] the resolved settings
25
+ # @return [String] the line of `deploy-box.sh` that finds the secret the containers read
26
+ def database_secret_line(plan)
27
+ return 'DB_SECRET=$(out "$RDS_STACK" DbSecretArn)' unless plan.shared?
28
+
29
+ "DB_SECRET=$(aws secretsmanager describe-secret --secret-id #{plan.infra_name}/database --query ARN --output text)"
30
+ end
31
+
32
+ # @param plan [Settings::Plan] the resolved settings
33
+ # @return [String] `provision-database.sh`, which creates the site's database, role and
34
+ # secret
35
+ def provision_database_sh(plan)
36
+ template("provision-database.sh.tmpl", "STACK" => plan.infra_name, "RDS_STACK" => plan.rds_stack,
37
+ "DATABASE" => plan.database_name)
38
+ end
39
+
40
+ private
41
+
42
+ def dedicated_makefile_values
43
+ { "WHAT" => "one app box and one RDS instance",
44
+ "REHEARSAL_NOTE" => "# true makes a throwaway pair: the database is deleted with its stack and the box has no\n" \
45
+ "# Elastic IP. The default is a production pair, with deletion protection.\n",
46
+ "RDS_DEPLOY" => rds_deploy_lines, "DB_SECRET_PARAM" => dedicated_secret_param,
47
+ "PHONY" => ".PHONY: stacks deploy", "PROVISION" => "" }
48
+ end
49
+
50
+ def rds_deploy_lines
51
+ "\taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\\n" \
52
+ "\t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)\n"
53
+ end
54
+
55
+ def dedicated_secret_param
56
+ "\t\tDbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) " \
57
+ "--query \"Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue\" --output text) \\"
58
+ end
59
+
60
+ def provision_target(plan)
61
+ "# Creates #{plan.infra_name}'s database, role and secret on #{plan.rds_stack}. Run it once; run it again with\n" \
62
+ "# ROTATE=--rotate to change the role's password. make provision BASTION=i-... [ROTATE=--rotate]\n" \
63
+ "provision:\n\tbash ./provision-database.sh $(BASTION) $(ROTATE)\n\n"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,24 +1,23 @@
1
- # @@STACK@@: one app box and one RDS instance.
1
+ # @@STACK@@: @@WHAT@@.
2
2
  # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c [REHEARSAL=true]
3
3
  # make deploy @@DEPLOY_HINT@@
4
4
  RDS_STACK = @@RDS_STACK@@
5
5
  BOX_STACK = @@BOX_STACK@@
6
- # true makes a throwaway pair: the database is deleted with its stack and the box has no
7
- # Elastic IP. The default is a production pair, with deletion protection.
6
+ @@REHEARSAL_NOTE@@
8
7
  REHEARSAL ?= false
9
8
 
10
9
  HECKS ?= hecks
11
10
 
12
- .PHONY: stacks deploy
11
+ @@PHONY@@
13
12
  stacks:
14
- aws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \
15
- --parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)
13
+ @@RDS_DEPLOY@@
16
14
  aws cloudformation deploy --template-file box.yaml --stack-name $(BOX_STACK) --capabilities CAPABILITY_IAM \
17
15
  --parameter-overrides VpcId=$(VPC) SubnetId=$(PUBLIC_SUBNET) \
18
16
  DbSecurityGroupId=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecurityGroupId'].OutputValue" --output text) \
19
- DbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue" --output text) \
17
+ @@DB_SECRET_PARAM@@
20
18
  AlertTopicArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" --output text) \
21
19
  Rehearsal=$(REHEARSAL)
22
20
 
21
+ @@PROVISION@@
23
22
  deploy:
24
23
  @@DEPLOY_RECIPE@@
@@ -26,7 +26,7 @@ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].
26
26
 
27
27
  BOX=$(out "$BOX_STACK" InstanceId)
28
28
  DB_HOST=$(out "$RDS_STACK" DbEndpoint)
29
- DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
29
+ @@DB_SECRET_LINE@@
30
30
  [ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit 40; }
31
31
  ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
32
32
 
@@ -0,0 +1,97 @@
1
+ #!/bin/bash
2
+ # Create @@STACK@@'s database and login role on the shared RDS instance (stack @@RDS_STACK@@), and the
3
+ # secret @@STACK@@/database that the box's containers read. Run once for a new site, from a machine with
4
+ # AWS credentials that may read the instance's master secret and start SSM sessions, through a bastion
5
+ # that can reach the instance.
6
+ #
7
+ # provision-database.sh <bastion-instance-id> [--rotate]
8
+ #
9
+ # The role owns only the database @@DATABASE@@, is not a superuser, and no other role may connect to that
10
+ # database. The secret holds {username, password, host, port, dbname}; the box's role reads that secret
11
+ # and never the instance's master secret.
12
+ #
13
+ # Safe to run again: a role and a database that exist are left alone, and a secret that exists keeps
14
+ # its password. --rotate gives the role a new password and writes it to the secret; roll the box after it.
15
+ #
16
+ # Ends 70 (the shared stack has no instance).
17
+ #
18
+ # Needs psql, jq, openssl, the SSM plugin and AWS credentials.
19
+ set -euo pipefail
20
+
21
+ BASTION=${1:?bastion instance id}
22
+ ROTATE=${2:-}
23
+ [ -z "$ROTATE" ] || [ "$ROTATE" = --rotate ] || { echo "usage: provision-database.sh <bastion-instance-id> [--rotate]" >&2; exit 2; }
24
+ RDS_STACK=@@RDS_STACK@@
25
+ DB=@@DATABASE@@
26
+ ROLE=@@DATABASE@@
27
+ SECRET=@@STACK@@/database
28
+ PORT=15434
29
+ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].Outputs[?OutputKey==\`$2\`].OutputValue" --output text; }
30
+
31
+ HOST=$(out "$RDS_STACK" DbEndpoint)
32
+ MASTER=$(out "$RDS_STACK" DbSecretArn)
33
+ [ -n "$HOST" ] && [ "$HOST" != None ] || { echo "no instance in stack $RDS_STACK" >&2; exit 70; }
34
+
35
+ WORK=$(mktemp -d)
36
+ PIDS=()
37
+ cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null || true; done; rm -rf "$WORK"; }
38
+ trap cleanup EXIT
39
+
40
+ master_field() { aws secretsmanager get-secret-value --secret-id "$MASTER" --query SecretString --output text | jq -r "$1"; }
41
+ MASTER_USER=$(master_field '.username // "postgres"')
42
+ MASTER_PW=$(master_field .password)
43
+
44
+ aws ssm start-session --target "$BASTION" --document-name AWS-StartPortForwardingSessionToRemoteHost \
45
+ --parameters "{\"host\":[\"$HOST\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"$PORT\"]}" >"$WORK/tunnel.log" 2>&1 &
46
+ PIDS+=($!)
47
+ disown
48
+ for _ in $(seq 1 20); do grep -q "Waiting for connections" "$WORK/tunnel.log" 2>/dev/null && break; sleep 1; done
49
+
50
+ # One statement, one answer. The master connects to the maintenance database.
51
+ M() { PGPASSWORD=$MASTER_PW psql -h localhost -p $PORT -U "$MASTER_USER" -d postgres -v ON_ERROR_STOP=1 -Atc "$1"; }
52
+ # A script on stdin, with the new password passed as a variable so it is never in a command line.
53
+ MS() { PGPASSWORD=$MASTER_PW psql -h localhost -p $PORT -U "$MASTER_USER" -d postgres -v ON_ERROR_STOP=1 -v pw="$PW" -q -f -; }
54
+
55
+ HAVE_ROLE=$(M "select count(*) from pg_roles where rolname='$ROLE'")
56
+ HAVE_SECRET=no
57
+ aws secretsmanager describe-secret --secret-id "$SECRET" >/dev/null 2>&1 && HAVE_SECRET=yes
58
+
59
+ # Letters and digits only: the host puts the password in a URL without percent-encoding it.
60
+ PW=""
61
+ if [ "$HAVE_ROLE" = 0 ]; then
62
+ PW=$(openssl rand -hex 16)
63
+ MS <<SQL
64
+ create role "$ROLE" login password :'pw' nosuperuser nocreatedb nocreaterole noinherit;
65
+ SQL
66
+ echo "created role $ROLE"
67
+ elif [ "$ROTATE" = --rotate ] || [ "$HAVE_SECRET" = no ]; then
68
+ PW=$(openssl rand -hex 16)
69
+ MS <<SQL
70
+ alter role "$ROLE" password :'pw';
71
+ SQL
72
+ echo "set a new password for role $ROLE"
73
+ else
74
+ echo "role $ROLE exists and so does $SECRET; kept"
75
+ fi
76
+
77
+ # RDS's master is not a true superuser: it may create a database for a role only as a member of that role.
78
+ if [ "$(M "select count(*) from pg_database where datname='$DB'")" = 0 ]; then
79
+ M "grant \"$ROLE\" to \"$MASTER_USER\"" >/dev/null
80
+ M "create database \"$DB\" owner \"$ROLE\"" >/dev/null
81
+ echo "created database $DB"
82
+ else
83
+ echo "database $DB exists"
84
+ fi
85
+ # Nobody but the owner (and the master) may connect to it.
86
+ M "revoke all on database \"$DB\" from public" >/dev/null
87
+
88
+ if [ -n "$PW" ]; then
89
+ BODY=$(jq -n --arg u "$ROLE" --arg p "$PW" --arg h "$HOST" --arg d "$DB" '{username: $u, password: $p, host: $h, port: 5432, dbname: $d}')
90
+ if [ "$HAVE_SECRET" = yes ]; then
91
+ aws secretsmanager put-secret-value --secret-id "$SECRET" --secret-string "$BODY" >/dev/null
92
+ else
93
+ aws secretsmanager create-secret --name "$SECRET" --description "@@STACK@@'s database login on @@RDS_STACK@@" --secret-string "$BODY" >/dev/null
94
+ fi
95
+ echo "wrote secret $SECRET"
96
+ fi
97
+ echo "ok $DB on $HOST, role $ROLE, secret $SECRET"
@@ -7,6 +7,8 @@
7
7
  #
8
8
  # FORCE=1 drop the target schemas first: a re-load before cutover, or a rollback copy in the other
9
9
  # direction (swap the hosts and secrets, and SRC_DB and DST_DB)
10
+ # SCHEMAS the schemas to copy, space separated (default @@SCHEMAS@@); another database's schemas, such as
11
+ # an analytics database's `public`, are copied by naming them here with SRC_DB and DST_DB
10
12
  # SRC_DB the source database (default @@SOURCE_DATABASE@@)
11
13
  # DST_DB the target database (default @@DATABASE@@)
12
14
  # VERIFY_BY_COMMAND=1 end after the copy and leave the comparison to `hecks deploy data_copy.restore`
@@ -28,7 +30,7 @@ HERE=$(cd "$(dirname "$0")" && pwd)
28
30
 
29
31
  BASTION=${1:?bastion instance id}; SRC_HOST=${2:?source host}; SRC_SECRET=${3:?source secret arn}
30
32
  DST_HOST=${4:?target host}; DST_SECRET=${5:?target secret arn}
31
- SCHEMAS="@@SCHEMAS@@"
33
+ SCHEMAS=${SCHEMAS:-"@@SCHEMAS@@"}
32
34
  SRC_DB=${SRC_DB:-@@SOURCE_DATABASE@@}; DST_DB=${DST_DB:-@@DATABASE@@}
33
35
  SRC_PORT=15432; DST_PORT=15433
34
36
 
@@ -93,4 +95,4 @@ done
93
95
 
94
96
  # Same structure, same exact row counts in every table, nothing unpopulated.
95
97
  [ -z "${VERIFY_BY_COMMAND:-}" ] || exit 0
96
- A_DB=$SRC_DB B_DB=$DST_DB bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"
98
+ A_DB=$SRC_DB B_DB=$DST_DB COMPARE_SCHEMAS=$(echo "$SCHEMAS" | tr ' ' ',') bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"
@@ -6,13 +6,14 @@
6
6
  # verify-copy.sh <bastion-instance-id> <host-a> <secret-arn-a> <host-b> <secret-arn-b>
7
7
  #
8
8
  # A_DB, B_DB the database on each side (default @@DATABASE@@)
9
+ # COMPARE_SCHEMAS the schemas to compare, comma separated (default @@SCHEMAS_CSV@@)
9
10
  #
10
11
  # Schemas compared: @@SCHEMAS@@
11
12
  # Exit 0 only if both are identical and no materialized view is unpopulated in either; 50 when they differ.
12
13
  set -euo pipefail
13
14
 
14
15
  BASTION=${1:?bastion instance id}; A_HOST=${2:?}; A_SECRET=${3:?}; B_HOST=${4:?}; B_SECRET=${5:?}
15
- SCHEMAS="@@SCHEMAS_CSV@@"
16
+ SCHEMAS=${COMPARE_SCHEMAS:-"@@SCHEMAS_CSV@@"}
16
17
  A_DB=${A_DB:-@@DATABASE@@}; B_DB=${B_DB:-@@DATABASE@@}
17
18
  A_PORT=15434; B_PORT=15435
18
19
 
@@ -7,6 +7,7 @@ require_relative "box/caddy"
7
7
  require_relative "box/access"
8
8
  require_relative "box/stack_yaml"
9
9
  require_relative "box/compose"
10
+ require_relative "box/shared_database"
10
11
  require_relative "box/roll_scripts"
11
12
 
12
13
  module Hecks
@@ -22,6 +23,7 @@ module Hecks
22
23
  extend Access
23
24
  extend StackYaml
24
25
  extend Compose
26
+ extend SharedDatabase
25
27
  extend RollScripts
26
28
 
27
29
  projects_as :aws_box, needs_world: true, emits: :files
@@ -129,13 +131,23 @@ module Hecks
129
131
  # @return [Hash{String => String}] every generated file
130
132
  def render_all(plan, region)
131
133
  {
132
- "rds.yaml" => rds_yaml(plan), "box.yaml" => box_yaml(plan, region),
134
+ "box.yaml" => box_yaml(plan, region),
133
135
  "Caddyfile" => caddyfile(plan), "services.json" => services_json(plan),
134
136
  "render-compose.sh" => render_compose_sh(plan, region),
135
137
  "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
136
138
  "deploy-box.sh" => deploy_box_sh(plan, region),
137
139
  "Makefile" => makefile(plan)
138
- }.merge(migration_files(plan)).then { |files| Hosting.extend_files(files, plan: plan, region: region) }
140
+ }.merge(database_files(plan)).merge(migration_files(plan))
141
+ .then { |files| Hosting.extend_files(files, plan: plan, region: region) }
142
+ end
143
+
144
+ # @param plan [Settings::Plan] the resolved settings
145
+ # @return [Hash{String => String}] the database stack, or for a database on a shared
146
+ # instance the script that provisions it; the shared stack is not this site's to generate
147
+ def database_files(plan)
148
+ return { "rds.yaml" => rds_yaml(plan) } unless plan.shared?
149
+
150
+ { "provision-database.sh" => provision_database_sh(plan) }
139
151
  end
140
152
 
141
153
  # Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,