hecks 3.1.0 → 3.1.2
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 +87 -4
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +18 -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 +216 -20
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
- data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
- 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/commands.rs +4 -32
- data/rust/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/mutations.rs +74 -5
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/web/registration_receipt/tests.rs +39 -0
- data/rust/host/src/web/registration_receipt.rs +122 -0
- data/rust/host/src/web/registrations.rs +14 -2
- data/rust/host/src/web.rs +1 -0
- metadata +17 -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, :writable_secrets, :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,25 @@ 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),
|
|
122
|
+
writable_secrets: read_writable_secrets(s),
|
|
99
123
|
tunnel: tunnel, tunnel_service: tunnel_service,
|
|
100
|
-
proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE)
|
|
124
|
+
proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
|
|
125
|
+
task_definition: task_definition, migration: read_migration(s[:migration], database_name),
|
|
126
|
+
s3_buckets: read_s3_access(s.fetch(:s3_access, []))
|
|
101
127
|
)
|
|
102
128
|
end
|
|
103
129
|
|
|
@@ -117,10 +143,61 @@ module Hecks
|
|
|
117
143
|
}
|
|
118
144
|
end
|
|
119
145
|
|
|
146
|
+
# @param value [Hash{Symbol => Object}, nil] the world's `migration` setting
|
|
147
|
+
# @param database_name [String] the RDS database, which the schemas move into by default
|
|
148
|
+
# @return [Migration, nil] the data to move, or nil when the world declares none
|
|
149
|
+
# @raise [ArgumentError] when the setting is not a hash with a non-empty list of schemas
|
|
150
|
+
def read_migration(value, database_name)
|
|
151
|
+
return nil if value.nil?
|
|
152
|
+
raise ArgumentError, MIGRATION_SHAPE unless value.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
listed = value.fetch(:schemas) { raise ArgumentError, MIGRATION_SHAPE }
|
|
155
|
+
raise ArgumentError, MIGRATION_SHAPE unless listed.is_a?(Array) && !listed.empty?
|
|
156
|
+
|
|
157
|
+
database = check(:migration_database, value.fetch(:database, database_name), DB_NAME)
|
|
158
|
+
Migration.new(schemas: listed.map { |name| check(:migration_schemas, name, SCHEMA) }.uniq, database: database,
|
|
159
|
+
source_database: check(:migration_source_database, value.fetch(:source_database, database), DB_NAME))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @param list [Array<Hash>] the world's `s3_access` setting
|
|
163
|
+
# @return [Array<Bucket>] the buckets, each readable and optionally writable in production
|
|
164
|
+
# @raise [ArgumentError] when the setting is not a list of bucket hashes
|
|
165
|
+
def read_s3_access(list)
|
|
166
|
+
raise ArgumentError, S3_SHAPE unless list.is_a?(Array)
|
|
167
|
+
|
|
168
|
+
list.map do |spec|
|
|
169
|
+
raise ArgumentError, S3_SHAPE unless spec.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
name = check(:s3_bucket, spec.fetch(:bucket) { raise ArgumentError, S3_SHAPE }, S3_BUCKET)
|
|
172
|
+
Bucket.new(name: name, write: boolean(:s3_write, spec.fetch(:write, false)))
|
|
173
|
+
end.uniq(&:name)
|
|
174
|
+
end
|
|
175
|
+
|
|
120
176
|
def read_database_name(settings, infra_name)
|
|
121
177
|
check(:database_name, settings.fetch(:database_name, infra_name.gsub(/[^a-zA-Z0-9]/, "")), DB_NAME)
|
|
122
178
|
end
|
|
123
179
|
|
|
180
|
+
# With a task definition, the images, environment and secrets are read from it at deploy
|
|
181
|
+
# time, so a container that also sets them is ambiguous and refused.
|
|
182
|
+
#
|
|
183
|
+
# @param family [String, nil] the ECS task definition family the world names
|
|
184
|
+
# @param listed [Array<Hash>] the containers as the world wrote them
|
|
185
|
+
# @return [String, nil] the checked family, or nil when the world does not use one
|
|
186
|
+
# @raise [ArgumentError] when the family is malformed or a container sets what it supplies
|
|
187
|
+
def read_task_definition(family, listed)
|
|
188
|
+
return nil if family.nil?
|
|
189
|
+
|
|
190
|
+
check(:task_definition, family, TASKDEF)
|
|
191
|
+
Array(listed).each do |spec|
|
|
192
|
+
clash = spec.is_a?(Hash) ? FROM_TASKDEF & spec.keys : []
|
|
193
|
+
next if clash.empty?
|
|
194
|
+
|
|
195
|
+
raise ArgumentError, "containers: #{spec[:name]} sets #{clash.join(', ')}, which the task definition " \
|
|
196
|
+
"#{family} supplies; drop #{clash.size == 1 ? 'it' : 'them'} or drop task_definition"
|
|
197
|
+
end
|
|
198
|
+
family
|
|
199
|
+
end
|
|
200
|
+
|
|
124
201
|
def read_containers(list, infra_name)
|
|
125
202
|
raise ArgumentError, missing_containers unless list.is_a?(Array) && !list.empty?
|
|
126
203
|
|
|
@@ -207,6 +284,12 @@ module Hecks
|
|
|
207
284
|
list.map { |p| check(:secret_prefixes, p, SECRET_NAME) }
|
|
208
285
|
end
|
|
209
286
|
|
|
287
|
+
# Secrets the box may overwrite, such as one an admin page stores a key in. Production
|
|
288
|
+
# only: a rehearsal box never changes a secret.
|
|
289
|
+
def read_writable_secrets(settings)
|
|
290
|
+
Array(settings.fetch(:writable_secrets, [])).map { |name| check(:writable_secrets, name, SECRET_NAME) }
|
|
291
|
+
end
|
|
292
|
+
|
|
210
293
|
def string_map(key, value, key_pattern, value_pattern)
|
|
211
294
|
raise ArgumentError, "#{key}: expected a hash" unless value.is_a?(Hash)
|
|
212
295
|
|
|
@@ -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@@
|
|
@@ -21,6 +18,19 @@ DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
|
|
|
21
18
|
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
|
|
22
19
|
|
|
23
20
|
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
|
21
|
+
|
|
22
|
+
# SMOKE_LISTENER=1 (a rehearsal box only) mounts a loopback HTTPS site on 127.0.0.1:8443 that adds the
|
|
23
|
+
# origin secret, so a browser-driven smoke run can reach the box through an SSM port-forward without the
|
|
24
|
+
# CDN. TLS because a site that treats a request carrying the origin secret as https expects an https
|
|
25
|
+
# Origin on every form post.
|
|
26
|
+
cat > "$WORK/smoke.caddy" <<'SMOKE_EOF'
|
|
27
|
+
https://127.0.0.1:8443 {
|
|
28
|
+
tls internal
|
|
29
|
+
reverse_proxy 127.0.0.1:80 {
|
|
30
|
+
@@SMOKE_HEADER@@
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
SMOKE_EOF
|
|
24
34
|
( cd "$WORK" && bash "$HERE/render-compose.sh" "$DB_HOST" "$DB_SECRET" "$@" )
|
|
25
35
|
|
|
26
36
|
# Run a script on the box over SSM, wait for it, print its output, return its status.
|
|
@@ -41,11 +51,14 @@ run_on_box() {
|
|
|
41
51
|
B64() { base64 < "$1" | tr -d '\n'; }
|
|
42
52
|
ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
|
|
43
53
|
--arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
|
|
54
|
+
--arg smokefile "$(B64 "$WORK/smoke.caddy")" --arg smoke "${SMOKE_LISTENER:-}" \
|
|
44
55
|
--arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
|
|
45
|
-
{commands: ["
|
|
56
|
+
{commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
|
|
57
|
+
"set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
|
|
46
58
|
"echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
|
|
47
59
|
"echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
|
|
48
60
|
"bash fetch-secrets.sh",
|
|
61
|
+
(if $smoke == "1" then "echo \($smokefile) | base64 -d > caddy-extra/smoke.caddy" else "rm -f caddy-extra/smoke.caddy" end),
|
|
49
62
|
"aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
|
|
50
63
|
"docker compose -f compose.json pull --quiet",
|
|
51
64
|
"docker compose -f compose.json up -d --remove-orphans",
|
|
@@ -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"],
|