hecks 3.1.0 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +1 -1
  3. data/lib/hecks/chapters.rb +4 -3
  4. data/lib/hecks/cli/domain_writer.rb +39 -0
  5. data/lib/hecks/cli/interview_agent.rb +59 -0
  6. data/lib/hecks/cli/interview_run.rb +87 -0
  7. data/lib/hecks/cli/interview_session.rb +299 -0
  8. data/lib/hecks/corpus.rb +1 -0
  9. data/lib/hecks/doors/cli_runner.rb +1 -1
  10. data/lib/hecks/doors/usage_cache.rb +123 -0
  11. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  12. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  13. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  14. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  15. data/lib/hecks/hecks/custodian.bluebook +36 -0
  16. data/lib/hecks/hecks/hecks.bluebook +18 -0
  17. data/lib/hecks/hecks/hecks.hecksagon +8 -2
  18. data/lib/hecks/hecks/hecks.world +3 -2
  19. data/lib/hecks/projections/deploy/box/settings.rb +80 -4
  20. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -5
  22. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
  23. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  24. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
  25. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  26. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  27. data/lib/hecks/projections/deploy/box.rb +185 -19
  28. data/lib/hecks/projector/cli_projector.rb +38 -14
  29. data/lib/hecks/runtime/loader.rb +31 -8
  30. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  31. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  32. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  33. data/lib/hecks/version.rb +1 -1
  34. data/lib/hecks.rb +1 -0
  35. data/rust/codegen/src/json_codec.rs +3 -0
  36. data/rust/codegen/src/naming.rs +4 -0
  37. data/rust/codegen/src/types.rs +45 -0
  38. data/rust/host/HECKS_RELEASE +1 -1
  39. metadata +15 -2
@@ -0,0 +1,7 @@
1
+ # Required by name, not relative to this file: a fuzz or model check boots a copy of this directory,
2
+ # and the implementation must load once, from `lib/`, not from the copy.
3
+ require "hecks/hecks/adapters/finding_github"
4
+
5
+ Hecks.adapter "FindingGithub" do
6
+ port "GitHubIssues"
7
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # The `GitHubIssues` port's adapter: drives one GitHub issue from a `Tickets::Finding` with
8
+ # `gh`.
9
+ #
10
+ # Nothing is opened until a repository is named, by the `repository` setting or the
11
+ # `HECKS_FINDINGS_REPO` environment variable (`owner/name`); without one every ask is refused,
12
+ # and the runtime records the refusal on the finding. An ask that needs the issue before one
13
+ # was opened is refused the same way, so a finding never blocks on GitHub.
14
+ class FindingGithub
15
+ # The environment variable that names the repository when no setting does.
16
+ REPOSITORY_VARIABLE = "HECKS_FINDINGS_REPO"
17
+
18
+ # @param aggregate [Object, nil] unused
19
+ # @param settings [Hash] `repository:` names the `owner/name` to drive
20
+ # @param root [String, nil] unused
21
+ def initialize(aggregate: nil, settings: {}, root: nil)
22
+ @repository = settings[:repository]
23
+ end
24
+
25
+ # Opens an issue for the finding the runtime hands over (its whole held state).
26
+ #
27
+ # @param held [Hash] the finding's fields; `title` and `body` are value objects or strings
28
+ # @return [Hash{Symbol => Hash}] `issue_number:` of the new issue, the shape
29
+ # `Finding.RecordIssue` takes
30
+ # @raise [RuntimeError] when no repository is named, `gh` exits non-zero, or prints no
31
+ # issue URL
32
+ def open_issue(**held)
33
+ out = gh("issue", "create", "--title", plain(held[:title]).to_s, "--body", plain(held[:body]).to_s)
34
+ number = out.lines.last.to_s.strip[%r{/issues/(\d+)\z}, 1]
35
+ raise "gh issue create printed no issue URL: #{out.strip.inspect}" unless number
36
+
37
+ { issue_number: { value: number.to_i } }
38
+ end
39
+
40
+ # Puts the finding's kind and severity on its issue as labels.
41
+ #
42
+ # @param held [Hash] the finding's fields
43
+ # @return [Hash{Symbol => Hash}] `output:` the line that says what was done
44
+ def label_issue(**held)
45
+ labels = [["kind", held[:kind]], ["severity", held[:severity]]]
46
+ .map { |name, field| "#{name}:#{plain(field)}" unless plain(field).to_s.empty? }.compact
47
+ return said("no kind or severity to label") if labels.empty?
48
+
49
+ gh("issue", "edit", issue(held), "--add-label", labels.join(","))
50
+ said("labelled #{labels.join(', ')}")
51
+ end
52
+
53
+ # Comments on the issue with the pull request that fixes the finding.
54
+ #
55
+ # @param held [Hash] the finding's fields
56
+ # @return [Hash{Symbol => Hash}] `output:` the line that says what was done
57
+ def comment_on_issue(**held)
58
+ gh("issue", "comment", issue(held), "--body", "A fix is proposed in ##{plain(held[:fix_pr_number])}")
59
+ said("commented with the fix")
60
+ end
61
+
62
+ # Closes the issue: as completed for a resolved finding, as not planned for a dismissed one.
63
+ #
64
+ # @param held [Hash] the finding's fields, `status` among them
65
+ # @return [Hash{Symbol => Hash}] `output:` the line that says what was done
66
+ def close_issue(**held)
67
+ reason = plain(held[:status]) == "dismissed" ? "not planned" : "completed"
68
+ gh("issue", "close", issue(held), "--reason", reason)
69
+ said("closed as #{reason}")
70
+ end
71
+
72
+ # Reopens the issue of a finding taken back to triaged.
73
+ #
74
+ # @param held [Hash] the finding's fields
75
+ # @return [Hash{Symbol => Hash}] `output:` the line that says what was done
76
+ def reopen_issue(**held)
77
+ gh("issue", "reopen", issue(held))
78
+ said("reopened")
79
+ end
80
+
81
+ private
82
+
83
+ def gh(*arguments)
84
+ out, err, status = Open3.capture3("gh", *arguments, "--repo", repository)
85
+ raise "gh #{arguments.first(2).join(' ')} failed — #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
86
+
87
+ out
88
+ end
89
+
90
+ def repository
91
+ name = @repository || ENV.fetch(REPOSITORY_VARIABLE, nil)
92
+ return name unless name.to_s.strip.empty?
93
+
94
+ raise "no repository named: set the `repository` setting or #{REPOSITORY_VARIABLE} to owner/name"
95
+ end
96
+
97
+ def issue(held)
98
+ number = plain(held[:issue_number])
99
+ raise "the finding has no GitHub issue yet" if number.to_s.empty?
100
+
101
+ number.to_s
102
+ end
103
+
104
+ def said(line) = { output: { value: line } }
105
+
106
+ # A materialized value object (`{value: x}`, symbol or string keys) or the value itself.
107
+ def plain(field)
108
+ return field unless field.is_a?(Hash)
109
+
110
+ field.key?(:value) ? field[:value] : field["value"]
111
+ end
112
+ end
113
+ end
114
+ end
@@ -66,6 +66,21 @@ module Hecks
66
66
  raise ConsoleCapture::Failure, "the mcp door refused to start (status #{e.status}); see stderr"
67
67
  end
68
68
 
69
+ # Holds an interview at the terminal and writes the domain it drafts (ADR 0088). Asking is IO,
70
+ # so it happens here and nowhere else; the journal records that one was held, not its words.
71
+ #
72
+ # @param held [Hash] the `Door` record: `name`; optionally `adapter`, `dir`, `expert`, `no_ai`
73
+ # @return [Hash{Symbol => Hash}] `output:` what the interview wrote, or that it wrote nothing
74
+ # @raise [ConsoleCapture::Failure] when the name or adapter is refused, or a file is there
75
+ def converse(**held)
76
+ require "hecks/cli/interview_run"
77
+ report = CLI::InterviewRun.call(name: plain(held[:name]), adapter: plain(held[:adapter]), dir: plain(held[:dir]),
78
+ expert: plain(held[:expert]), use_ai: !plain(held[:no_ai]))
79
+ { output: { value: report.empty? ? "no interview was written" : report } }
80
+ rescue ArgumentError => e
81
+ raise ConsoleCapture::Failure, e.message
82
+ end
83
+
69
84
  private
70
85
 
71
86
  def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
@@ -8,6 +8,6 @@ end
8
8
 
9
9
  # The attached chapters (hecks.hecksagon `attaches`): each is a bounded context whose store comes
10
10
  # from hecks.world's `default_adapter`, so the memory overlay swaps them with the Hecks chapter.
11
- %w[Bluebook Hecksagon World Adapter Port Translation Expression Tenancy Deploy Site QualityControl].each do |chapter|
11
+ %w[Bluebook Hecksagon World Adapter Port Translation Expression Tenancy Deploy Site Tickets QualityControl].each do |chapter|
12
12
  Hecks.hecksagon(chapter) { attaches "Governance" }
13
13
  end
@@ -1280,6 +1280,8 @@ policy "RecordTheEra" do
1280
1280
  attribute :name, DomainName, optional: true
1281
1281
  attribute :adapter, AdapterName, optional: true
1282
1282
  attribute :dir, TargetDir, optional: true
1283
+ attribute :expert, ExpertName, optional: true
1284
+ attribute :no_ai, Switch, optional: true
1283
1285
  attribute :output, Note, optional: true
1284
1286
  attribute :refusal, Note, optional: true
1285
1287
  identified_by :run
@@ -1314,6 +1316,11 @@ policy "RecordTheEra" do
1314
1316
  attribute :value, String, pattern: '[^\t\n\r]'
1315
1317
  end
1316
1318
 
1319
+ # Who is interviewed; asked for at the terminal when absent.
1320
+ value_object "ExpertName" do
1321
+ attribute :value, String, pattern: '[^\t\n\r]'
1322
+ end
1323
+
1317
1324
  value_object "Note" do
1318
1325
  attribute :value, String
1319
1326
  end
@@ -1357,6 +1364,30 @@ policy "RecordTheEra" do
1357
1364
  emits StubRequested
1358
1365
  end
1359
1366
 
1367
+ # Holds an interview with a subject matter expert and drafts a first domain from it (ADR 0088).
1368
+ # The Terminal adapter runs the conversation, as it runs a console; the journal records that one
1369
+ # was held and how it ended, not what was said. `--no-ai` leaves the model out.
1370
+ command "Interview" do
1371
+ role "Operator"
1372
+ goal "Hold an interview with a subject matter expert and draft a first domain from it"
1373
+
1374
+ attribute :run, DoorKey
1375
+ attribute :name, DomainName
1376
+ attribute :adapter, AdapterName, optional: true
1377
+ attribute :dir, TargetDir, optional: true
1378
+ attribute :expert, ExpertName, optional: true
1379
+ attribute :no_ai, Switch, optional: true
1380
+
1381
+ sets :run
1382
+ sets :name
1383
+ sets :adapter
1384
+ sets :dir
1385
+ sets :expert
1386
+ sets :no_ai
1387
+
1388
+ emits InterviewRequested
1389
+ end
1390
+
1360
1391
  # A stdio server runs until its client closes stdin, so it cannot be asked and answered
1361
1392
  # quickly like the others. The command records that the door was started; the Terminal
1362
1393
  # adapter hands the process over to the door and answers when it closes, as OpenConsole
@@ -1423,6 +1454,11 @@ policy "RecordTheEra" do
1423
1454
  trigger Door::Workspace::Scaffold, with: { run: :run }
1424
1455
  end
1425
1456
 
1457
+ policy "ConverseWhenRequested" do
1458
+ on "Door.InterviewRequested"
1459
+ trigger Door::Terminal::Converse, with: { run: :run }
1460
+ end
1461
+
1426
1462
  policy "ServeWhenRequested" do
1427
1463
  on "Door.McpRequested"
1428
1464
  trigger Door::Terminal::Serve, with: { run: :run }
@@ -8,9 +8,15 @@ Hecks.bluebook "Hecks" do
8
8
  attribute :version, Version
9
9
  attribute :ir_version, Version
10
10
  attribute :ships_from, Checkout, optional: true
11
+ # The findings the version settled, by the key the Tickets chapter gives them.
12
+ attribute :closes, list_of(FindingKey), optional: true
11
13
 
12
14
  identified_by :version
13
15
 
16
+ value_object "FindingKey" do
17
+ attribute :value, String, pattern: '[^ \t\n\r]'
18
+ end
19
+
14
20
  value_object "Version" do
15
21
  attribute :value, String, pattern: '^[0-9]+[.][0-9]+[.][0-9]+'
16
22
 
@@ -61,6 +67,18 @@ Hecks.bluebook "Hecks" do
61
67
  emits Verified
62
68
  end
63
69
 
70
+ command "CloseFindings" do
71
+ role "Maintainer"
72
+ goal "Record the findings this version settled"
73
+
74
+ reference_to Release
75
+ attribute :closes, list_of(FindingKey)
76
+
77
+ sets :closes
78
+
79
+ emits FindingsClosed
80
+ end
81
+
64
82
  query "Shipped" do
65
83
  description "Every version that was tagged, published and verified."
66
84
  where(state: "verified")
@@ -2,8 +2,8 @@ Hecks.hecksagon "Hecks" do
2
2
  attaches "Governance"
3
3
 
4
4
  # The chapters the gem carries: the language declared in itself (Paging comes with Bluebook),
5
- # Expression, Tenancy, Deploy, Site and QualityControl (the last two bring their own ports).
6
- %w[Bluebook Hecksagon World Adapter Port Translation Expression Tenancy Deploy Site QualityControl]
5
+ # Expression, Tenancy, Deploy, Site, Tickets and QualityControl (the last three bring their own ports).
6
+ %w[Bluebook Hecksagon World Adapter Port Translation Expression Tenancy Deploy Site Tickets QualityControl]
7
7
  .each { |chapter| attaches chapter }
8
8
  # Persistence is not bound here: hecks.world's `default_adapter` binds every aggregate, so
9
9
  # environments/memory.world can swap the whole domain without a double bind.
@@ -109,6 +109,12 @@ Hecks.hecksagon "Hecks" do
109
109
  answers "DoorAnswered"
110
110
  refuses "DoorRefused"
111
111
  end
112
+
113
+ asks "Converse", to: Door do
114
+ attribute :run, DoorKey
115
+ answers "DoorAnswered"
116
+ refuses "DoorRefused"
117
+ end
112
118
  end
113
119
 
114
120
  Hecks::Build.port "RustToolchain" do
@@ -37,10 +37,11 @@ Hecks.world "Hecks" do
37
37
  launcher "Launcher", run_keys: true,
38
38
  failure_states: %w[flagged failed drifted unreachable refused faulted halted stopped
39
39
  abandoned red needs_fix],
40
- names: { "mcp" => "door.serve_mcp", "console" => "operation.open_console", "init" => "door.init" },
40
+ names: { "mcp" => "door.serve_mcp", "console" => "operation.open_console", "init" => "door.init",
41
+ "interview" => "door.interview" },
41
42
  streams: %w[Follow],
42
43
  executable: "exe/hecks",
43
- memory_commands: %w[console init],
44
+ memory_commands: %w[console init interview],
44
45
  legacy: %w[run docs narrate ir stores model_check smoke_test
45
46
  project_diagrams project_cli mcp]
46
47
  end
@@ -20,6 +20,12 @@ module Hecks
20
20
  ENGINE = /\A\d{2}(\.\d{1,2})?\z/
21
21
  PREFIX = /\A[a-z][a-z0-9-]{0,20}\z/
22
22
  IMAGE = %r{\A[a-z0-9][a-z0-9._/:@-]{1,200}\z}
23
+ TASKDEF = /\A[a-zA-Z0-9_-]{1,255}\z/
24
+ SCHEMA = /\A[a-z][a-z0-9_]{0,62}\z/
25
+ MIGRATION_SHAPE = "migration: a hash needs `schemas`, a list of the schema names to copy".freeze
26
+ S3_BUCKET = /\A[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\z/
27
+ S3_SHAPE = "s3_access: a list of `{ bucket: \"name\", write: true }` hashes (write is optional)".freeze
28
+ FROM_TASKDEF = %i[env secrets repository].freeze
23
29
  # Default images, each a version tag plus the digest of its multi-architecture index,
24
30
  # so a rebuilt box pulls the same bytes.
25
31
  TUNNEL_IMAGE = "cloudflare/cloudflared:2026.9.3" \
@@ -55,12 +61,26 @@ module Hecks
55
61
  # @!attribute [r] image [String] the cloudflared image
56
62
  Tunnel = Struct.new(:container, :port, :token_secret, :image, keyword_init: true)
57
63
 
64
+ # The data a project moves from its old database into the new RDS instance.
65
+ #
66
+ # @!attribute [r] schemas [Array<String>] the schemas to copy
67
+ # @!attribute [r] database [String] the database holding them on the RDS instance
68
+ # @!attribute [r] source_database [String] the database holding them on the old server
69
+ Migration = Struct.new(:schemas, :database, :source_database, keyword_init: true)
70
+
71
+ # An S3 bucket the box's role may read, and in production write.
72
+ #
73
+ # @!attribute [r] name [String] the bucket's name
74
+ # @!attribute [r] write [Boolean] whether a production box may also write and delete
75
+ Bucket = Struct.new(:name, :write, keyword_init: true)
76
+
58
77
  # Everything the generator reads, checked.
59
78
  Plan = Struct.new(
60
79
  :infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
61
80
  :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
62
81
  :containers, :routes, :default_container, :origin_header, :origin_secret,
63
- :secret_prefixes, :tunnel, :tunnel_service, :proxy_image, keyword_init: true
82
+ :secret_prefixes, :tunnel, :tunnel_service, :proxy_image, :task_definition,
83
+ :migration, :s3_buckets, keyword_init: true
64
84
  ) do
65
85
  # @return [String] the CloudFormation stack that holds the database
66
86
  def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
@@ -85,19 +105,24 @@ module Hecks
85
105
  def resolve(deploy_settings:, target:, infra_name:)
86
106
  s = deploy_settings
87
107
  check(:stack_name, infra_name, NAME)
88
- containers = read_containers(s.fetch(:containers) { raise ArgumentError, missing_containers }, infra_name)
108
+ database_name = read_database_name(s, infra_name)
109
+ listed = s.fetch(:containers) { raise ArgumentError, missing_containers }
110
+ task_definition = read_task_definition(s[:task_definition], listed)
111
+ containers = read_containers(listed, infra_name)
89
112
  header, secret = read_origin(s)
90
113
  tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
91
114
 
92
115
  Plan.new(
93
116
  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),
117
+ **declared_sizes(target), **read_sizes(s), database_name: database_name,
95
118
  engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
96
119
  containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
97
120
  default_container: read_default(s[:default_container], containers), origin_header: header,
98
121
  origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
99
122
  tunnel: tunnel, tunnel_service: tunnel_service,
100
- proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE)
123
+ proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
124
+ task_definition: task_definition, migration: read_migration(s[:migration], database_name),
125
+ s3_buckets: read_s3_access(s.fetch(:s3_access, []))
101
126
  )
102
127
  end
103
128
 
@@ -117,10 +142,61 @@ module Hecks
117
142
  }
118
143
  end
119
144
 
145
+ # @param value [Hash{Symbol => Object}, nil] the world's `migration` setting
146
+ # @param database_name [String] the RDS database, which the schemas move into by default
147
+ # @return [Migration, nil] the data to move, or nil when the world declares none
148
+ # @raise [ArgumentError] when the setting is not a hash with a non-empty list of schemas
149
+ def read_migration(value, database_name)
150
+ return nil if value.nil?
151
+ raise ArgumentError, MIGRATION_SHAPE unless value.is_a?(Hash)
152
+
153
+ listed = value.fetch(:schemas) { raise ArgumentError, MIGRATION_SHAPE }
154
+ raise ArgumentError, MIGRATION_SHAPE unless listed.is_a?(Array) && !listed.empty?
155
+
156
+ database = check(:migration_database, value.fetch(:database, database_name), DB_NAME)
157
+ Migration.new(schemas: listed.map { |name| check(:migration_schemas, name, SCHEMA) }.uniq, database: database,
158
+ source_database: check(:migration_source_database, value.fetch(:source_database, database), DB_NAME))
159
+ end
160
+
161
+ # @param list [Array<Hash>] the world's `s3_access` setting
162
+ # @return [Array<Bucket>] the buckets, each readable and optionally writable in production
163
+ # @raise [ArgumentError] when the setting is not a list of bucket hashes
164
+ def read_s3_access(list)
165
+ raise ArgumentError, S3_SHAPE unless list.is_a?(Array)
166
+
167
+ list.map do |spec|
168
+ raise ArgumentError, S3_SHAPE unless spec.is_a?(Hash)
169
+
170
+ name = check(:s3_bucket, spec.fetch(:bucket) { raise ArgumentError, S3_SHAPE }, S3_BUCKET)
171
+ Bucket.new(name: name, write: boolean(:s3_write, spec.fetch(:write, false)))
172
+ end.uniq(&:name)
173
+ end
174
+
120
175
  def read_database_name(settings, infra_name)
121
176
  check(:database_name, settings.fetch(:database_name, infra_name.gsub(/[^a-zA-Z0-9]/, "")), DB_NAME)
122
177
  end
123
178
 
179
+ # With a task definition, the images, environment and secrets are read from it at deploy
180
+ # time, so a container that also sets them is ambiguous and refused.
181
+ #
182
+ # @param family [String, nil] the ECS task definition family the world names
183
+ # @param listed [Array<Hash>] the containers as the world wrote them
184
+ # @return [String, nil] the checked family, or nil when the world does not use one
185
+ # @raise [ArgumentError] when the family is malformed or a container sets what it supplies
186
+ def read_task_definition(family, listed)
187
+ return nil if family.nil?
188
+
189
+ check(:task_definition, family, TASKDEF)
190
+ Array(listed).each do |spec|
191
+ clash = spec.is_a?(Hash) ? FROM_TASKDEF & spec.keys : []
192
+ next if clash.empty?
193
+
194
+ raise ArgumentError, "containers: #{spec[:name]} sets #{clash.join(', ')}, which the task definition " \
195
+ "#{family} supplies; drop #{clash.size == 1 ? 'it' : 'them'} or drop task_definition"
196
+ end
197
+ family
198
+ end
199
+
124
200
  def read_containers(list, infra_name)
125
201
  raise ArgumentError, missing_containers unless list.is_a?(Array) && !list.empty?
126
202
 
@@ -104,6 +104,7 @@ Resources:
104
104
  Resource:
105
105
  - !Ref DbSecretArn
106
106
  @@SECRET_RESOURCES@@
107
+ @@S3_POLICY@@
107
108
 
108
109
  BoxProfile:
109
110
  Type: AWS::IAM::InstanceProfile
@@ -2,10 +2,7 @@
2
2
  # Roll @@STACK@@'s app box: render the compose files, push them over SSM, start the containers and check
3
3
  # them. Exits non-zero if the roll fails or the box does not answer as expected.
4
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.
5
+ @@USAGE@@
9
6
  set -euo pipefail
10
7
  HERE=$(cd "$(dirname "$0")" && pwd)
11
8
  BOX_STACK=@@BOX_STACK@@
@@ -42,7 +39,8 @@ B64() { base64 < "$1" | tr -d '\n'; }
42
39
  ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
43
40
  --arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
44
41
  --arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
45
- {commands: ["set -e", "mkdir -p \($dir) && cd \($dir)", "umask 077",
42
+ {commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
43
+ "set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
46
44
  "echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
47
45
  "echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
48
46
  "bash fetch-secrets.sh",
@@ -7,6 +7,12 @@ Description: >
7
7
  Parameters:
8
8
  VpcId:
9
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.
10
16
  PrivateSubnetIds:
11
17
  Type: List<AWS::EC2::Subnet::Id>
12
18
  Description: At least two subnets in different availability zones (an RDS subnet group needs them).
@@ -46,13 +52,23 @@ Conditions:
46
52
  NewTopic: !Equals [!Ref ExistingAlertTopicArn, ""]
47
53
  NewTopicWithEmail: !And [!Condition NewTopic, !Not [!Equals [!Ref AlertEmail, ""]]]
48
54
  IsRehearsal: !Equals [!Ref Rehearsal, "true"]
55
+ HasBastion: !Not [!Equals [!Ref BastionSecurityGroupId, ""]]
49
56
 
50
57
  Resources:
51
58
  DbSecurityGroup:
52
59
  Type: AWS::EC2::SecurityGroup
53
60
  Properties:
54
- GroupDescription: @@STACK@@ RDS, 5432 from the app box only (the box stack adds the rule)
61
+ GroupDescription: @@STACK@@ RDS, 5432 from the app box (the box stack adds the rule) and a bastion if given
55
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
56
72
 
57
73
  DbSubnetGroup:
58
74
  Type: AWS::RDS::DBSubnetGroup
@@ -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)"
@@ -27,7 +27,7 @@ jq --arg ecr "$ECR" --argjson tags "$TAGS" --arg host "$DB_HOST" --arg secret "$
27
27
  environment: ({PORT: (.port | tostring), DB_HOST: $host, DB_NAME: $db, DB_SECRET_ARN: $secret} + .env)}
28
28
  + (if (.secrets | length) > 0 then {env_file: [(.name + ".secrets.env")]} else {} end))))
29
29
  + {caddy: ({image: "@@PROXY_IMAGE@@", network_mode: "host", restart: "unless-stopped",
30
- volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro"], logging: log}
30
+ volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "./caddy-extra:/etc/caddy/extra:ro"], logging: log}
31
31
  + (if $in.origin then {env_file: ["caddy.secrets.env"]} else {} end))}
32
32
  + (if $in.tunnel then {cloudflared: {image: $in.tunnel.image, network_mode: "host", restart: "unless-stopped",
33
33
  command: ["tunnel", "--no-autoupdate", "--url", $in.tunnel.url, "run"],
@@ -0,0 +1,91 @@
1
+ #!/bin/bash
2
+ # Copy @@STACK@@'s schemas from the old database into the new RDS instance through a bastion that can
3
+ # reach both, then verify the copy. Piped dump | restore, so nothing is written to disk. Never writes to
4
+ # the source.
5
+ #
6
+ # restore-to-rds.sh <bastion-instance-id> <source-host> <source-secret-arn> <rds-host> <rds-secret-arn>
7
+ #
8
+ # FORCE=1 drop the target schemas first: a re-load before cutover, or a rollback copy in the other
9
+ # direction (swap the hosts and secrets, and SRC_DB and DST_DB)
10
+ # SRC_DB the source database (default @@SOURCE_DATABASE@@)
11
+ # DST_DB the target database (default @@DATABASE@@)
12
+ #
13
+ # Schemas copied: @@SCHEMAS@@
14
+ #
15
+ # Why not a plain pg_restore: a Hecks schema's materialized views call hecks_tr_extract() unqualified, and
16
+ # pg_restore runs with an empty search_path, so the restore's REFRESH step errors on them. This restores
17
+ # tolerating exactly that error, then refreshes the views with the schema on the search_path, and fails on
18
+ # any other error.
19
+ #
20
+ # Needs pg_dump, pg_restore and psql 16 or newer, the SSM plugin, jq and AWS credentials that can read both
21
+ # secrets and start SSM sessions.
22
+ set -euo pipefail
23
+ HERE=$(cd "$(dirname "$0")" && pwd)
24
+
25
+ BASTION=${1:?bastion instance id}; SRC_HOST=${2:?source host}; SRC_SECRET=${3:?source secret arn}
26
+ DST_HOST=${4:?target host}; DST_SECRET=${5:?target secret arn}
27
+ SCHEMAS="@@SCHEMAS@@"
28
+ SRC_DB=${SRC_DB:-@@SOURCE_DATABASE@@}; DST_DB=${DST_DB:-@@DATABASE@@}
29
+ SRC_PORT=15432; DST_PORT=15433
30
+
31
+ pg_major() { "$1" --version 2>/dev/null | sed -E 's/.* ([0-9]+)[.].*/\1/'; }
32
+ # The default client on the PATH is often older than the server (Homebrew's is 14); prefer postgresql@16.
33
+ if [ "$(pg_major pg_dump || echo 0)" -lt 16 ] && [ -x /opt/homebrew/opt/postgresql@16/bin/pg_dump ]; then
34
+ export PATH=/opt/homebrew/opt/postgresql@16/bin:$PATH
35
+ fi
36
+ for t in pg_dump pg_restore psql; do
37
+ [ "$(pg_major $t || echo 0)" -ge 16 ] || { echo "need $t 16 or newer on the PATH" >&2; exit 1; }
38
+ done
39
+
40
+ WORK=$(mktemp -d)
41
+ PIDS=()
42
+ cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null || true; done; rm -rf "$WORK"; }
43
+ trap cleanup EXIT
44
+
45
+ secret_field() { aws secretsmanager get-secret-value --secret-id "$1" --query SecretString --output text | jq -r "$2"; }
46
+ SRC_PW=$(secret_field "$SRC_SECRET" .password); DST_PW=$(secret_field "$DST_SECRET" .password)
47
+ SRC_USER=$(secret_field "$SRC_SECRET" '.username // "postgres"'); DST_USER=$(secret_field "$DST_SECRET" '.username // "postgres"')
48
+
49
+ tunnel() { # host local-port
50
+ aws ssm start-session --target "$BASTION" --document-name AWS-StartPortForwardingSessionToRemoteHost \
51
+ --parameters "{\"host\":[\"$1\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"$2\"]}" >"$WORK/tunnel_$2.log" 2>&1 &
52
+ PIDS+=($!)
53
+ disown # so shutting the tunnel down at exit does not print a "Terminated" line
54
+ }
55
+ tunnel "$SRC_HOST" $SRC_PORT; tunnel "$DST_HOST" $DST_PORT
56
+ for i in $(seq 1 20); do
57
+ grep -q "Waiting for connections" "$WORK/tunnel_$SRC_PORT.log" 2>/dev/null && grep -q "Waiting for connections" "$WORK/tunnel_$DST_PORT.log" 2>/dev/null && break
58
+ sleep 1
59
+ done
60
+
61
+ SRC() { PGPASSWORD=$SRC_PW psql -h localhost -p $SRC_PORT -U "$SRC_USER" -d "$SRC_DB" -Atc "$1"; }
62
+ DST() { PGPASSWORD=$DST_PW psql -h localhost -p $DST_PORT -U "$DST_USER" -d "$DST_DB" -Atc "$1"; }
63
+ echo "source $(SRC 'show server_version'), target $(DST 'show server_version')"
64
+
65
+ # Refuse to clobber a target that already holds the schemas.
66
+ for S in $SCHEMAS; do
67
+ if [ "$(DST "select count(*) from pg_namespace where nspname='$S'")" != 0 ]; then
68
+ if [ "${FORCE:-}" = 1 ]; then DST "drop schema \"$S\" cascade" >/dev/null; echo "dropped target schema $S"
69
+ else echo "target already has schema $S; re-run with FORCE=1 to replace it" >&2; exit 1; fi
70
+ fi
71
+ done
72
+
73
+ for S in $SCHEMAS; do
74
+ echo "== copying schema $S"
75
+ PGPASSWORD=$SRC_PW pg_dump -h localhost -p $SRC_PORT -U "$SRC_USER" -d "$SRC_DB" --schema="$S" -Fc --no-owner --no-privileges \
76
+ | PGPASSWORD=$DST_PW pg_restore -h localhost -p $DST_PORT -U "$DST_USER" -d "$DST_DB" --no-owner --no-privileges 2>"$WORK/err_$S.txt" || true
77
+ TOTAL=$(grep -c 'error:' "$WORK/err_$S.txt" || true)
78
+ UNEXPECTED=$(grep 'error:' "$WORK/err_$S.txt" | grep -vc 'hecks_tr_extract' || true)
79
+ echo " restore errors: $TOTAL (unexpected: $UNEXPECTED)"
80
+ if [ "$UNEXPECTED" != 0 ]; then grep 'error:' "$WORK/err_$S.txt" | grep -v 'hecks_tr_extract' | head -5 >&2; exit 1; fi
81
+ done
82
+
83
+ # Refresh what pg_restore could not (with the schema on the search_path).
84
+ for S in $SCHEMAS; do
85
+ for MV in $(DST "select matviewname from pg_matviews where schemaname='$S' and not ispopulated"); do
86
+ PGPASSWORD=$DST_PW psql -h localhost -p $DST_PORT -U "$DST_USER" -d "$DST_DB" -qAtc "set search_path=\"$S\",public; refresh materialized view \"$S\".\"$MV\""
87
+ done
88
+ done
89
+
90
+ # Same structure, same exact row counts in every table, nothing unpopulated.
91
+ A_DB=$SRC_DB B_DB=$DST_DB bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"