hecks 3.1.1 → 3.1.3
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/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
- data/examples/pizzas/bluebook/pizzas.world +3 -0
- data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
- data/examples/pizzas/oidc.json +24 -0
- data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
- data/lib/hecks/cli/console.rb +5 -2
- data/lib/hecks/doors/cli_runner.rb +6 -0
- data/lib/hecks/projections/deploy/box/settings.rb +8 -1
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
- data/lib/hecks/projections/deploy/box.rb +32 -2
- 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 +51 -22
- data/lib/hecks/quality_control/adapters/agent.rb +63 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
- data/lib/hecks/version.rb +1 -1
- data/rust/codegen/src/commands.rs +4 -32
- data/rust/codegen/src/mutations.rs +74 -5
- 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 +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88b11874249737158a9451d66aa0842ef88fb3f1ff047cced68d97b2ce9fdc5f
|
|
4
|
+
data.tar.gz: 2872e381ce8e7fe5d46ef7a7623134dc23d923e57bdf194758effedaa1cc4671
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76a39ab361adfb4e07bb558cb917d46bf3d7b6bb21340ff9789cb7b6c10be47a3197df4f394e8da73129feeb5b8439d9401f5e83b8620e0a512fce966c891377
|
|
7
|
+
data.tar.gz: 58f1514450a234f3807897401daf7db410739463a8603bf58b7d73e548f14424211b4d1c5f3af1dc7421b769f39582e15c6f22188932af3de054961eed2cb641
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Driving adapter standing in for a Stripe webhook handler: boots Pizzas and calls
|
|
4
|
+
# Dispatcher#dispatch_port, which translates the payment into PizzaPaymentReceived.
|
|
5
|
+
#
|
|
6
|
+
# examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb
|
|
7
|
+
|
|
8
|
+
$LOAD_PATH.unshift File.expand_path("../../../../lib", __dir__)
|
|
9
|
+
|
|
10
|
+
require "hecks"
|
|
11
|
+
|
|
12
|
+
DOMAIN = File.expand_path("../..", __dir__)
|
|
13
|
+
RUNTIME = Hecks.boot(DOMAIN)
|
|
14
|
+
|
|
15
|
+
# Unique per run: identity is never minted, so a repeated name would be refused as AlreadyExists.
|
|
16
|
+
NAME = "StripeDemoMargherita-#{Process.pid}-#{rand(10_000)}".freeze
|
|
17
|
+
|
|
18
|
+
# Set the stage: an order Purchase's `given` rules will accept.
|
|
19
|
+
order = Order.create_pizza(name: { value: NAME }, pizza: { price_cents: { cents: 1200 }, size: { value: "large" } })
|
|
20
|
+
order.add_topping(topping: { value: "Basil" }, amount: { value: 3 })
|
|
21
|
+
|
|
22
|
+
puts "Before payment: #{order.status}, customer=#{order.customer_name.inspect}"
|
|
23
|
+
|
|
24
|
+
# The webhook: a real handler would verify a Stripe signature; a reference is always a bare id.
|
|
25
|
+
RUNTIME.dispatch_port(
|
|
26
|
+
"Pizzas", "Order", "PaymentGateway", "Receive",
|
|
27
|
+
flat: {
|
|
28
|
+
name: NAME,
|
|
29
|
+
customer_name: { value: "Chris" },
|
|
30
|
+
amount: { cents: 1200 }
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
sold = Order.find(NAME)
|
|
35
|
+
puts "After payment: #{sold.status}, customer=#{sold.customer_name.to_h}"
|
|
36
|
+
puts "Events: #{sold.events.map(&:name).join(', ')}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Hecks.behaviors "Pizzas" do
|
|
2
|
+
vision "Examples of how a pizza is built, sold, and found through this domain."
|
|
3
|
+
|
|
4
|
+
# `pizzas_behaviors.hecksagon` is a Memory-persisted sibling of
|
|
5
|
+
# `pizzas.hecksagon` that lives OUTSIDE bluebook/, so an ordinary
|
|
6
|
+
# directory boot of examples/pizzas never globs it up alongside the
|
|
7
|
+
# real one.
|
|
8
|
+
loads "pizzas.bluebook", "../pizzas_behaviors.hecksagon"
|
|
9
|
+
|
|
10
|
+
test "CreatePizza puts a fresh pizza on the menu, available for sale" do
|
|
11
|
+
tests "CreatePizza", on: "Order"
|
|
12
|
+
input name: "Margherita", pizza: { price_cents: { cents: 1200 }, size: "large" }
|
|
13
|
+
expect status: "available", customer_name: nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test "AddTopping refuses more than ten toppings" do
|
|
17
|
+
tests "AddTopping", on: "Order"
|
|
18
|
+
setup "CreatePizza", name: "Loaded", pizza: { price_cents: { cents: 1500 }, size: "large" }
|
|
19
|
+
setup "AddTopping", name: "Loaded", topping: "Basil", amount: 1
|
|
20
|
+
setup "AddTopping", name: "Loaded", topping: "Olives", amount: 1
|
|
21
|
+
setup "AddTopping", name: "Loaded", topping: "Mushroom", amount: 1
|
|
22
|
+
setup "AddTopping", name: "Loaded", topping: "Onion", amount: 1
|
|
23
|
+
setup "AddTopping", name: "Loaded", topping: "Pepper", amount: 1
|
|
24
|
+
setup "AddTopping", name: "Loaded", topping: "Sausage", amount: 1
|
|
25
|
+
setup "AddTopping", name: "Loaded", topping: "Bacon", amount: 1
|
|
26
|
+
setup "AddTopping", name: "Loaded", topping: "Pineapple", amount: 1
|
|
27
|
+
setup "AddTopping", name: "Loaded", topping: "Anchovy", amount: 1
|
|
28
|
+
setup "AddTopping", name: "Loaded", topping: "Jalapeno", amount: 1
|
|
29
|
+
input name: "Loaded", topping: "Extra Cheese", amount: 1
|
|
30
|
+
expect refused: "at most 10 toppings"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test "Purchase refuses a pizza with no toppings on it" do
|
|
34
|
+
tests "Purchase", on: "Order"
|
|
35
|
+
setup "CreatePizza", name: "Bare", pizza: { price_cents: { cents: 900 }, size: "small" }
|
|
36
|
+
input name: "Bare", customer_name: "Chris", amount: { cents: 900 }
|
|
37
|
+
expect refused: "at least one topping"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "Purchase sells the pizza and closes its lifecycle" do
|
|
41
|
+
tests "Purchase", on: "Order"
|
|
42
|
+
setup "CreatePizza", name: "Sold Out", pizza: { price_cents: { cents: 1200 }, size: "large" }
|
|
43
|
+
setup "AddTopping", name: "Sold Out", topping: "Basil", amount: 2
|
|
44
|
+
input name: "Sold Out", customer_name: "Chris", amount: { cents: 1200 }
|
|
45
|
+
expect emits: ["PizzaPurchased"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
test "a sold pizza is no longer Available" do
|
|
49
|
+
tests "Available", on: "Order", kind: :query
|
|
50
|
+
setup "CreatePizza", name: "Fresh", pizza: { price_cents: { cents: 1000 }, size: "small" }
|
|
51
|
+
setup "CreatePizza", name: "Also Sold", pizza: { price_cents: { cents: 1100 }, size: "small" }
|
|
52
|
+
setup "AddTopping", name: "Also Sold", topping: "Basil", amount: 1
|
|
53
|
+
setup "Purchase", name: "Also Sold", customer_name: "Chris", amount: { cents: 1100 }
|
|
54
|
+
expect count: 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test "CostingLessThan finds a pizza under the ceiling named" do
|
|
58
|
+
tests "CostingLessThan", on: "Order", kind: :query
|
|
59
|
+
setup "CreatePizza", name: "Bargain", pizza: { price_cents: { cents: 800 }, size: "small" }
|
|
60
|
+
setup "CreatePizza", name: "Splurge", pizza: { price_cents: { cents: 1800 }, size: "large" }
|
|
61
|
+
input ceiling: { cents: 1000 }
|
|
62
|
+
expect count: 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "Available's own row can be checked directly, not just counted" do
|
|
66
|
+
tests "Available", on: "Order", kind: :query
|
|
67
|
+
setup "CreatePizza", name: "Only One", pizza: { price_cents: { cents: 950 }, size: "small" }
|
|
68
|
+
expect status: "available", name: { value: "Only One" }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Hecks.bluebook "Pizzas" do
|
|
2
|
+
vision "Put toppings on a pizza and sell it to a customer."
|
|
3
|
+
supporting
|
|
4
|
+
|
|
5
|
+
aggregate "Order" do
|
|
6
|
+
description "An order that gathers toppings on a pizza and is eventually sold to a customer."
|
|
7
|
+
|
|
8
|
+
attribute :name, PizzaName
|
|
9
|
+
attribute :pizza, Pizza
|
|
10
|
+
attribute :toppings, list_of(Topping)
|
|
11
|
+
# Optional: unknown until `Purchase` sets it.
|
|
12
|
+
attribute :customer_name, CustomerName, optional: true
|
|
13
|
+
|
|
14
|
+
identified_by :name
|
|
15
|
+
|
|
16
|
+
value_object "PizzaName" do
|
|
17
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
18
|
+
|
|
19
|
+
invariant("a pizza is named") { !value.to_s.empty? }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
value_object "Price" do
|
|
23
|
+
attribute :cents, Integer
|
|
24
|
+
|
|
25
|
+
invariant("a price is never negative") { cents >= 0 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
value_object "CustomerName" do
|
|
29
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
30
|
+
|
|
31
|
+
invariant("a customer is named") { !value.to_s.empty? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
value_object "ToppingName" do
|
|
35
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
36
|
+
|
|
37
|
+
invariant("a topping is named") { !value.to_s.empty? }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
value_object "ToppingAmount" do
|
|
41
|
+
attribute :value, Integer
|
|
42
|
+
|
|
43
|
+
invariant("an amount is positive") { value.positive? }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
value_object "Topping" do
|
|
47
|
+
attribute :name, String, pattern: '[^ \t\n\r]'
|
|
48
|
+
attribute :amount, Integer
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A named, reusable closed set as a sibling value object; the inline `one_of(...)` shorthand
|
|
52
|
+
# cannot be spelled inside a `value_object` block.
|
|
53
|
+
value_object "Size" do
|
|
54
|
+
attribute :value, String, one_of: ["small", "large"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What was ordered; who ordered it and payment state live on Order.
|
|
58
|
+
value_object "Pizza" do
|
|
59
|
+
attribute :price_cents, Price
|
|
60
|
+
attribute :size, Size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
lifecycle :status, default: "available" do
|
|
64
|
+
transition "Purchase" => "sold", from: "available"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
query "Available" do
|
|
68
|
+
where(status: "available")
|
|
69
|
+
order_by :name
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Both price queries reach through the nested Pizza value object with a dotted path.
|
|
73
|
+
query "CostingLessThan" do
|
|
74
|
+
attribute :ceiling, Price
|
|
75
|
+
where(:"pizza.price_cents.cents" => { lt: :ceiling })
|
|
76
|
+
order_by :name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A literal, not a kwarg: exercises the path where the where-clause number is stringified.
|
|
80
|
+
query "Expensive" do
|
|
81
|
+
where(:"pizza.price_cents.cents" => { gt: 1000 })
|
|
82
|
+
order_by :name
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
command "CreatePizza" do
|
|
86
|
+
role "Chef"
|
|
87
|
+
goal "Put a new pizza on the menu"
|
|
88
|
+
|
|
89
|
+
attribute :name, PizzaName
|
|
90
|
+
attribute :pizza, Pizza
|
|
91
|
+
|
|
92
|
+
sets :name
|
|
93
|
+
sets :pizza
|
|
94
|
+
|
|
95
|
+
emits PizzaCreated
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# AddTopping has no transition, so `from:` is its only lifecycle guard.
|
|
99
|
+
command "AddTopping", from: "available" do
|
|
100
|
+
role "Chef"
|
|
101
|
+
goal "Customize a pizza with an ingredient"
|
|
102
|
+
|
|
103
|
+
reference_to Order
|
|
104
|
+
attribute :topping, ToppingName
|
|
105
|
+
attribute :amount, ToppingAmount
|
|
106
|
+
|
|
107
|
+
given("at most 10 toppings") { toppings.size < 10 }
|
|
108
|
+
|
|
109
|
+
sets :toppings, append: { name: :topping, amount: :amount }
|
|
110
|
+
|
|
111
|
+
emits ToppingAdded
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
command "Purchase", from: "available" do
|
|
115
|
+
role "Customer"
|
|
116
|
+
goal "Buy the pizza"
|
|
117
|
+
|
|
118
|
+
reference_to Order
|
|
119
|
+
attribute :amount, Price
|
|
120
|
+
|
|
121
|
+
given("a pizza needs at least one topping") { toppings.size.positive? }
|
|
122
|
+
given("a payment was actually made") { amount.cents.positive? }
|
|
123
|
+
# Checks the payment itself is plausible (e.g. an underpayment), not whether one happened.
|
|
124
|
+
given("the payment covers the price") { amount.cents >= pizza.price_cents.cents }
|
|
125
|
+
|
|
126
|
+
# The lifecycle transition sets status; a `sets` on the lifecycle field is refused at build.
|
|
127
|
+
sets :customer_name
|
|
128
|
+
|
|
129
|
+
emits PizzaPurchased
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A payment arriving is an external fact; the business rules stay on Purchase.
|
|
134
|
+
policy "OnPizzaPaymentReceived" do
|
|
135
|
+
on Order::PizzaPaymentReceived
|
|
136
|
+
trigger Order::Purchase
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Hecks.hecksagon "Pizzas" do
|
|
2
|
+
# Role declarations only enforce access once Governance can check them; this standalone
|
|
3
|
+
# deploy attaches and binds Governance itself.
|
|
4
|
+
attaches "Governance"
|
|
5
|
+
Pizzas::Order.persisted_by("Memory")
|
|
6
|
+
|
|
7
|
+
# The primary port: an outside adapter (a payment webhook) translates an external fact into
|
|
8
|
+
# our vocabulary; business rules stay on Purchase, reached via OnPizzaPaymentReceived.
|
|
9
|
+
Pizzas::Order.port "PaymentGateway" do
|
|
10
|
+
operation "Receive" do
|
|
11
|
+
attribute :customer_name, CustomerName
|
|
12
|
+
attribute :amount, Price
|
|
13
|
+
emits "PizzaPaymentReceived"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Hecks.hecksagon "Governance" do
|
|
19
|
+
Governance::RoleAssignment.persisted_by("Memory")
|
|
20
|
+
Governance::RoleTransition.persisted_by("Memory")
|
|
21
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"audience": "Pizzas",
|
|
3
|
+
"scopes": [
|
|
4
|
+
{
|
|
5
|
+
"scope": "pizzas:order.add_topping",
|
|
6
|
+
"verb": "Pizzas::Order.AddTopping",
|
|
7
|
+
"role": "Chef"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"scope": "pizzas:order.create_pizza",
|
|
11
|
+
"verb": "Pizzas::Order.CreatePizza",
|
|
12
|
+
"role": "Chef"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"scope": "pizzas:order.purchase",
|
|
16
|
+
"verb": "Pizzas::Order.Purchase",
|
|
17
|
+
"role": "Customer"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"roles": [
|
|
21
|
+
"Chef",
|
|
22
|
+
"Customer"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Memory-bound sibling of pizzas.hecksagon: `pizzas.behaviors` boots a fresh runtime per
|
|
2
|
+
# test, so it loads this sibling instead of the one beside the bluebook.
|
|
3
|
+
Hecks.hecksagon "Pizzas" do
|
|
4
|
+
attaches "Governance"
|
|
5
|
+
Pizzas::Order.persisted_by("Memory")
|
|
6
|
+
|
|
7
|
+
Pizzas::Order.port "PaymentGateway" do
|
|
8
|
+
operation "Receive" do
|
|
9
|
+
attribute :customer_name, CustomerName
|
|
10
|
+
attribute :amount, Price
|
|
11
|
+
emits "PizzaPaymentReceived"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Hecks.hecksagon "Governance" do
|
|
17
|
+
Governance::RoleAssignment.persisted_by("Memory")
|
|
18
|
+
Governance::RoleTransition.persisted_by("Memory")
|
|
19
|
+
end
|
data/lib/hecks/cli/console.rb
CHANGED
|
@@ -47,12 +47,15 @@ module Hecks
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# @param runtime [Runtime] a booted domain
|
|
50
|
-
# @return [String] each chapter's vision and its aggregates' commands
|
|
50
|
+
# @return [String] each chapter's vision, and its aggregates' descriptions and commands
|
|
51
51
|
def overview(runtime)
|
|
52
52
|
runtime.registry.bluebooks.each_value.map do |bluebook|
|
|
53
53
|
aggregates = bluebook.aggregates.map do |aggregate|
|
|
54
54
|
commands = aggregate.commands.map { |command| "#{Naming.snake(command.hecks_name)}!" }.sort.join(", ")
|
|
55
|
-
|
|
55
|
+
description = aggregate.description.to_s.strip
|
|
56
|
+
lines = [" #{aggregate.name}: #{commands}"]
|
|
57
|
+
lines << " #{description}" unless description.empty?
|
|
58
|
+
lines.join("\n")
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
"\n #{bluebook.name} — #{bluebook.vision}\n#{aggregates.join("\n")}\n"
|
|
@@ -120,6 +120,7 @@ module Hecks
|
|
|
120
120
|
|
|
121
121
|
name = argv.first
|
|
122
122
|
return { answer: [cli[:usage], 0] } if name.nil? || %w[--help -h help].include?(name)
|
|
123
|
+
return { answer: [all_usage(bluebook, options), 0] } if name == "--all"
|
|
123
124
|
|
|
124
125
|
asking, name, argv = entry_word(cli, argv)
|
|
125
126
|
return { answer: [cli[:usage], 1] } if name.nil?
|
|
@@ -141,6 +142,11 @@ module Hecks
|
|
|
141
142
|
launcher: launcher }
|
|
142
143
|
end
|
|
143
144
|
|
|
145
|
+
# The usage with the internal commands and queries listed too, which `--all` asks for.
|
|
146
|
+
def all_usage(bluebook, options)
|
|
147
|
+
Projector.call(:cli, bluebook: bluebook, options: options.merge(all: true))[:usage]
|
|
148
|
+
end
|
|
149
|
+
|
|
144
150
|
# Reads the entry words of a line: whether it asks a query, the bare name, and the words
|
|
145
151
|
# from the name on.
|
|
146
152
|
#
|
|
@@ -79,7 +79,7 @@ module Hecks
|
|
|
79
79
|
:infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
|
|
80
80
|
:storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
|
|
81
81
|
:containers, :routes, :default_container, :origin_header, :origin_secret,
|
|
82
|
-
:secret_prefixes, :tunnel, :tunnel_service, :proxy_image, :task_definition,
|
|
82
|
+
:secret_prefixes, :writable_secrets, :tunnel, :tunnel_service, :proxy_image, :task_definition,
|
|
83
83
|
:migration, :s3_buckets, keyword_init: true
|
|
84
84
|
) do
|
|
85
85
|
# @return [String] the CloudFormation stack that holds the database
|
|
@@ -119,6 +119,7 @@ module Hecks
|
|
|
119
119
|
containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
|
|
120
120
|
default_container: read_default(s[:default_container], containers), origin_header: header,
|
|
121
121
|
origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
|
|
122
|
+
writable_secrets: read_writable_secrets(s),
|
|
122
123
|
tunnel: tunnel, tunnel_service: tunnel_service,
|
|
123
124
|
proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
|
|
124
125
|
task_definition: task_definition, migration: read_migration(s[:migration], database_name),
|
|
@@ -283,6 +284,12 @@ module Hecks
|
|
|
283
284
|
list.map { |p| check(:secret_prefixes, p, SECRET_NAME) }
|
|
284
285
|
end
|
|
285
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
|
+
|
|
286
293
|
def string_map(key, value, key_pattern, value_pattern)
|
|
287
294
|
raise ArgumentError, "#{key}: expected a hash" unless value.is_a?(Hash)
|
|
288
295
|
|
|
@@ -18,6 +18,19 @@ DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
|
|
|
18
18
|
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
|
|
19
19
|
|
|
20
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
|
|
21
34
|
( cd "$WORK" && bash "$HERE/render-compose.sh" "$DB_HOST" "$DB_SECRET" "$@" )
|
|
22
35
|
|
|
23
36
|
# Run a script on the box over SSM, wait for it, print its output, return its status.
|
|
@@ -38,12 +51,14 @@ run_on_box() {
|
|
|
38
51
|
B64() { base64 < "$1" | tr -d '\n'; }
|
|
39
52
|
ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
|
|
40
53
|
--arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
|
|
54
|
+
--arg smokefile "$(B64 "$WORK/smoke.caddy")" --arg smoke "${SMOKE_LISTENER:-}" \
|
|
41
55
|
--arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
|
|
42
56
|
{commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
|
|
43
57
|
"set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
|
|
44
58
|
"echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
|
|
45
59
|
"echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
|
|
46
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),
|
|
47
62
|
"aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
|
|
48
63
|
"docker compose -f compose.json pull --quiet",
|
|
49
64
|
"docker compose -f compose.json up -d --remove-orphans",
|
|
@@ -196,7 +196,8 @@ module Hecks
|
|
|
196
196
|
"VOLUME_GB" => plan.volume_gb.to_s, "SNAPSHOTS_KEEP" => plan.snapshots_keep.to_s,
|
|
197
197
|
"AMI_PARAMETER" => ami_parameter(plan.instance_type),
|
|
198
198
|
"COMPUTE_DOMAIN" => compute_domain(region),
|
|
199
|
-
"SECRET_RESOURCES" => secret_resources(plan), "
|
|
199
|
+
"SECRET_RESOURCES" => secret_resources(plan), "WRITABLE_SECRETS" => writable_secrets(plan),
|
|
200
|
+
"TUNNEL_EGRESS" => tunnel_egress(plan),
|
|
200
201
|
"SWAP_COMMANDS" => swap_commands(plan), "ECR_REPOSITORIES" => ecr_repositories(plan),
|
|
201
202
|
"ECR_OUTPUTS" => ecr_outputs(plan), "S3_POLICY" => s3_policy(plan))
|
|
202
203
|
end
|
|
@@ -261,6 +262,28 @@ module Hecks
|
|
|
261
262
|
end.join.chomp
|
|
262
263
|
end
|
|
263
264
|
|
|
265
|
+
# The statement that lets a production box overwrite the secrets named as writable.
|
|
266
|
+
#
|
|
267
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
268
|
+
# @return [String] a policy statement for the role, or nothing when none are declared
|
|
269
|
+
def writable_secrets(plan)
|
|
270
|
+
return "" if plan.writable_secrets.empty?
|
|
271
|
+
|
|
272
|
+
arns = plan.writable_secrets.map { |name| name.end_with?("*") ? name : "#{name}-*" }.uniq
|
|
273
|
+
resources = arns.map do |pattern|
|
|
274
|
+
"#{' ' * 20}- !Sub \"arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{pattern}\"\n"
|
|
275
|
+
end.join
|
|
276
|
+
<<~YAML.chomp
|
|
277
|
+
#{' ' * 14}- !If
|
|
278
|
+
#{' ' * 16}- IsProduction
|
|
279
|
+
#{' ' * 16}- Effect: Allow
|
|
280
|
+
#{' ' * 18}Action: secretsmanager:PutSecretValue
|
|
281
|
+
#{' ' * 18}Resource:
|
|
282
|
+
#{resources.chomp}
|
|
283
|
+
#{' ' * 16}- !Ref AWS::NoValue
|
|
284
|
+
YAML
|
|
285
|
+
end
|
|
286
|
+
|
|
264
287
|
# @param plan [Settings::Plan] the resolved settings
|
|
265
288
|
# @return [String] the outbound rules a Cloudflare Tunnel sidecar needs, or nothing
|
|
266
289
|
def tunnel_egress(plan)
|
|
@@ -437,7 +460,14 @@ module Hecks
|
|
|
437
460
|
template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
|
|
438
461
|
"RDS_STACK" => plan.rds_stack, "REGION" => region,
|
|
439
462
|
"DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan),
|
|
440
|
-
"USAGE" => deploy_usage(plan))
|
|
463
|
+
"SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan))
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
467
|
+
# @return [String] the line that adds the origin secret to the smoke listener's requests, or
|
|
468
|
+
# nothing when the world has no origin guard
|
|
469
|
+
def smoke_header(plan)
|
|
470
|
+
plan.origin_header ? "\t\theader_up #{plan.origin_header} {$ORIGIN_SECRET}" : ""
|
|
441
471
|
end
|
|
442
472
|
|
|
443
473
|
# @param plan [Settings::Plan] the resolved settings
|
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
# machine. It replaces any path to a working copy: ROOT below points the
|
|
9
9
|
# Makefile's build and mint-era steps at it. Override HECKS_VERSION to build
|
|
10
10
|
# from another release, or HECKS_ROOT to use a checkout you manage yourself.
|
|
11
|
+
#
|
|
12
|
+
# HECKS_VERSION=edge is the one exception to "pinned": it follows the newest commit on main,
|
|
13
|
+
# is fetched afresh on every build, and prints the commit it got.
|
|
11
14
|
|
|
12
15
|
HECKS_VERSION ?= @@RELEASE@@
|
|
13
16
|
HECKS_SOURCE ?= @@SOURCE@@
|
|
14
17
|
HECKS_CACHE_DIR ?= @@CACHE_DIR@@
|
|
15
18
|
HECKS_ROOT ?= $(HECKS_CACHE_DIR)/hecks-$(HECKS_VERSION)
|
|
19
|
+
HECKS_TAG := $(if $(filter edge,$(HECKS_VERSION)),edge,v$(HECKS_VERSION))
|
|
16
20
|
ROOT := $(HECKS_ROOT)
|
|
17
21
|
SERVICE ?= @@SERVICE@@
|
|
18
22
|
URL ?= @@URL@@
|
|
@@ -22,20 +26,32 @@ URL ?= @@URL@@
|
|
|
22
26
|
build: hecks-release
|
|
23
27
|
|
|
24
28
|
# Fetches the pinned release's tag into HECKS_ROOT unless it is already there,
|
|
25
|
-
# and refuses a checkout that is not exactly that tag.
|
|
29
|
+
# and refuses a checkout that is not exactly that tag. The edge tag moves, so it is
|
|
30
|
+
# fetched afresh on every build and its commit is printed, not checked.
|
|
26
31
|
hecks-release:
|
|
32
|
+
ifeq ($(HECKS_VERSION),edge)
|
|
33
|
+
@mkdir -p "$(HECKS_CACHE_DIR)"; \
|
|
34
|
+
if [ -d "$(HECKS_ROOT)/.git" ]; then \
|
|
35
|
+
git -C "$(HECKS_ROOT)" fetch --quiet --depth 1 origin "+refs/tags/edge:refs/tags/edge" && \
|
|
36
|
+
git -C "$(HECKS_ROOT)" checkout --quiet --detach edge; \
|
|
37
|
+
else \
|
|
38
|
+
git clone --quiet --depth 1 --branch edge "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
|
|
39
|
+
fi; \
|
|
40
|
+
echo "hecks edge at $$(git -C "$(HECKS_ROOT)" rev-parse --short HEAD) in $(HECKS_ROOT)"
|
|
41
|
+
else
|
|
27
42
|
@if [ -f "$(HECKS_ROOT)/exe/hecks" ]; then \
|
|
28
43
|
echo "hecks v$(HECKS_VERSION) is already at $(HECKS_ROOT)"; \
|
|
29
44
|
else \
|
|
30
45
|
echo "Fetching hecks v$(HECKS_VERSION) from $(HECKS_SOURCE) into $(HECKS_ROOT)..."; \
|
|
31
46
|
mkdir -p "$(HECKS_CACHE_DIR)" && \
|
|
32
|
-
git clone --quiet --depth 1 --branch "
|
|
47
|
+
git clone --quiet --depth 1 --branch "$(HECKS_TAG)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
|
|
33
48
|
fi
|
|
34
49
|
@tag="$$(git -C "$(HECKS_ROOT)" describe --tags --exact-match 2>/dev/null)"; \
|
|
35
|
-
if [ "$$tag" != "
|
|
36
|
-
echo "$(HECKS_ROOT) is at '$$tag', not
|
|
50
|
+
if [ "$$tag" != "$(HECKS_TAG)" ]; then \
|
|
51
|
+
echo "$(HECKS_ROOT) is at '$$tag', not $(HECKS_TAG). Remove it and re-run, or set HECKS_ROOT."; \
|
|
37
52
|
exit 1; \
|
|
38
53
|
fi
|
|
54
|
+
endif
|
|
39
55
|
|
|
40
56
|
# Pushes one container's already-built local image under a fresh tag and rolls
|
|
41
57
|
# it out. Build the image first (docker-build); see deploy-service.sh.
|
|
@@ -12,6 +12,8 @@ module Hecks
|
|
|
12
12
|
REF = %r{\A[\w./-]+\z}
|
|
13
13
|
ERA = /\A[0-9a-zA-Z._-]+\z/
|
|
14
14
|
RELEASE = /\Av?\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?\z/
|
|
15
|
+
# The moving tag on main; the one release name that is not a version.
|
|
16
|
+
EDGE = "edge".freeze
|
|
15
17
|
SOURCE = %r{\A[\w.@:/~-]+\z}
|
|
16
18
|
MAKE_PATH = /\A[^\s#]+\z/
|
|
17
19
|
|
|
@@ -43,6 +45,8 @@ module Hecks
|
|
|
43
45
|
release = fetch(:hecks_release, nil)
|
|
44
46
|
raise ArgumentError, missing_release_message unless release
|
|
45
47
|
|
|
48
|
+
return EDGE if release.to_s == EDGE
|
|
49
|
+
|
|
46
50
|
check(:hecks_release, release.to_s, RELEASE).delete_prefix("v")
|
|
47
51
|
end
|
|
48
52
|
|
|
@@ -63,6 +67,8 @@ module Hecks
|
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
so the domain image is built from a fixed Hecks release, not from a checkout on one machine.
|
|
70
|
+
`hecks_release "edge"` follows the newest commit on main instead: each build fetches it
|
|
71
|
+
afresh and prints the commit it got, so a build is not reproducible from the name alone.
|
|
66
72
|
MSG
|
|
67
73
|
end
|
|
68
74
|
|