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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
  3. data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
  4. data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
  5. data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
  6. data/examples/pizzas/bluebook/pizzas.world +3 -0
  7. data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
  8. data/examples/pizzas/oidc.json +24 -0
  9. data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
  10. data/lib/hecks/cli/console.rb +5 -2
  11. data/lib/hecks/doors/cli_runner.rb +6 -0
  12. data/lib/hecks/projections/deploy/box/settings.rb +8 -1
  13. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
  14. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
  15. data/lib/hecks/projections/deploy/box.rb +32 -2
  16. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
  17. data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
  18. data/lib/hecks/projector/cli_projector.rb +51 -22
  19. data/lib/hecks/quality_control/adapters/agent.rb +63 -7
  20. data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
  21. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
  22. data/lib/hecks/version.rb +1 -1
  23. data/rust/codegen/src/commands.rs +4 -32
  24. data/rust/codegen/src/mutations.rs +74 -5
  25. data/rust/host/HECKS_RELEASE +1 -1
  26. data/rust/host/src/web/registration_receipt/tests.rs +39 -0
  27. data/rust/host/src/web/registration_receipt.rs +122 -0
  28. data/rust/host/src/web/registrations.rs +14 -2
  29. data/rust/host/src/web.rs +1 -0
  30. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8bfb40ad9e9760533d77e179154821128fee28930877e35118b5e87c5c32731
4
- data.tar.gz: 49fd0e562e846d8633c11101d7b11c6d751089c8ca0f281907f1c669e1d19438
3
+ metadata.gz: 88b11874249737158a9451d66aa0842ef88fb3f1ff047cced68d97b2ce9fdc5f
4
+ data.tar.gz: 2872e381ce8e7fe5d46ef7a7623134dc23d923e57bdf194758effedaa1cc4671
5
5
  SHA512:
6
- metadata.gz: e584bed9d83942763f583530d44910d6a988c7b55af6a4177f257ba017562957f373ea7ea726340b7ae845e2811d7217665ab6815991ae9cffb0e6cb437928c2
7
- data.tar.gz: 6cac69c95c6dedc28736d5401d64a6597327c27b8d3ed1909c225ad6191c4cb0cac929ac9f3896f5c648c890d75b10d6d4b3d5431498e82e560bd682f2dc312d
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,3 @@
1
+ Hecks.world "Pizzas" do
2
+ realm "Examples"
3
+ end
@@ -0,0 +1,6 @@
1
+ Hecks.data_translation "Pizzas", from: "e3f3d7", to: "77625c" do
2
+ aggregate "Order", was: "Pizza" do
3
+ move :price_cents, to: "pizza.price_cents"
4
+ move :size, to: "pizza.size"
5
+ end
6
+ 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
@@ -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
- " #{aggregate.name}: #{commands}"
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
 
@@ -104,6 +104,7 @@ Resources:
104
104
  Resource:
105
105
  - !Ref DbSecretArn
106
106
  @@SECRET_RESOURCES@@
107
+ @@WRITABLE_SECRETS@@
107
108
  @@S3_POLICY@@
108
109
 
109
110
  BoxProfile:
@@ -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), "TUNNEL_EGRESS" => tunnel_egress(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 "v$(HECKS_VERSION)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
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" != "v$(HECKS_VERSION)" ]; then \
36
- echo "$(HECKS_ROOT) is at '$$tag', not v$(HECKS_VERSION). Remove it and re-run, or set HECKS_ROOT."; \
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