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.
- checksums.yaml +4 -4
- data/exe/hecks +1 -1
- data/lib/hecks/chapters.rb +4 -3
- data/lib/hecks/cli/domain_writer.rb +39 -0
- data/lib/hecks/cli/interview_agent.rb +59 -0
- data/lib/hecks/cli/interview_run.rb +87 -0
- data/lib/hecks/cli/interview_session.rb +299 -0
- data/lib/hecks/corpus.rb +1 -0
- data/lib/hecks/doors/cli_runner.rb +1 -1
- data/lib/hecks/doors/usage_cache.rb +123 -0
- data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
- data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
- data/lib/hecks/hecks/adapters/terminal.rb +15 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.bluebook +36 -0
- data/lib/hecks/hecks/hecks.bluebook +18 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -2
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/projections/deploy/box/settings.rb +80 -4
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -5
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
- data/lib/hecks/projections/deploy/box.rb +185 -19
- data/lib/hecks/projector/cli_projector.rb +38 -14
- data/lib/hecks/runtime/loader.rb +31 -8
- data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
- data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
- data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- data/rust/host/HECKS_RELEASE +1 -1
- 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
|
|
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
|
data/lib/hecks/hecks/hecks.world
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
|
@@ -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
|
-
|
|
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: ["
|
|
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
|
|
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"
|