hecks 2.6.0 → 2.7.0

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  6. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  7. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  8. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  9. data/lib/hecks/bluebook/hexagon.rb +15 -7
  10. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  11. data/lib/hecks/bluebook/model_check.rb +1 -1
  12. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  13. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  14. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  15. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  16. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  17. data/lib/hecks/forms/value_object_shape.rb +1 -1
  18. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  19. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  20. data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
  21. data/lib/hecks/language/world/world.bluebook +32 -2
  22. data/lib/hecks/ports/key_vault.rb +1 -1
  23. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  24. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  25. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  26. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  27. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  28. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  29. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  30. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  31. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  32. data/lib/hecks/ports/persistence.rb +4 -0
  33. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  34. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  35. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  36. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  37. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  38. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  39. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  40. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  41. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  42. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  43. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  44. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  45. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  46. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  47. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  48. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  49. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  50. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  51. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  52. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  53. data/lib/hecks/projections/deploy/preview.rb +103 -0
  54. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  55. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  56. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  57. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  58. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  59. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  60. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  61. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  62. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  63. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  64. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  65. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  66. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  67. data/lib/hecks/projections.rb +2 -0
  68. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  69. data/lib/hecks/release/gem_pin.rb +112 -0
  70. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  71. data/lib/hecks/runtime/loader.rb +26 -2
  72. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  73. data/lib/hecks/runtime/registry/verification.rb +4 -2
  74. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  75. data/lib/hecks/runtime/registry.rb +14 -9
  76. data/lib/hecks/runtime/tenant_check.rb +1 -1
  77. data/lib/hecks/vendoring/git_environment.rb +35 -0
  78. data/lib/hecks/vendoring/git_source.rb +102 -0
  79. data/lib/hecks/vendoring.rb +102 -0
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks.rb +2 -1
  82. metadata +45 -1
@@ -13,10 +13,10 @@ module Hecks
13
13
  #
14
14
  # This module and its `uses_embryonaut_bluebook`
15
15
  # DSL word (hecksagon_builder.rb) were built on a prior commit of this
16
- # repo (933d1dd), vendored out to a real consumer (lifeadelics/domain,
16
+ # repo (933d1dd), vendored out to a real consumer (a client site's domain,
17
17
  # for embryonaut_bluebooks/payments), and then lost from this repo's own
18
18
  # reachable history — a hard reset or rebase left no branch containing
19
- # that commit. The lifeadelics vendor snapshot (a `git archive` of that
19
+ # that commit. The consumer's vendor snapshot (a `git archive` of that
20
20
  # commit, committed into their repo) was the only surviving copy; this
21
21
  # file is ported forward from it, checked against current `main`'s own
22
22
  # conventions rather than copied wholesale, since the two trees had
@@ -64,7 +64,18 @@ module Hecks
64
64
  # => "Payments"), not a separate ledger. A vendored package's directory
65
65
  # name and its declared `Hecks.bluebook` name are the one convention
66
66
  # this reuses from Framework rather than reinventing.
67
+ #
68
+ # ## Vendoring
69
+ #
70
+ # `vendor!` is the other direction: it pins a package from a local checkout
71
+ # of the registry into a consuming project's own `vendor/` tree, and is the
72
+ # one implementation behind `bin/vendor_bluebook` (see Vendoring and Vendor).
67
73
  module EmbryonautBluebook
74
+ autoload :Lock, File.join(__dir__, "embryonaut_bluebook/lock")
75
+ autoload :Shape, File.join(__dir__, "embryonaut_bluebook/shape")
76
+ autoload :Vendor, File.join(__dir__, "embryonaut_bluebook/vendor")
77
+ autoload :VendorCli, File.join(__dir__, "embryonaut_bluebook/vendor_cli")
78
+
68
79
  # Loads a vendored embryonaut bluebook package's `.bluebook` files, once
69
80
  # per registry.
70
81
  #
@@ -89,10 +100,25 @@ module Hecks
89
100
  if files.empty?
90
101
  raise Runtime::WiringError,
91
102
  "no vendored embryonaut bluebook named #{name.inspect} at #{dir} — " \
92
- "run bin/vendor_embryonaut_bluebooks #{name}"
103
+ "vendor it with bin/vendor_bluebook #{name}"
93
104
  end
94
105
 
95
106
  files.each { |file| Kernel.load(file) }
96
107
  end
108
+
109
+ # Pins one package of the registry into a project's `vendor/` tree.
110
+ #
111
+ # @param name [String, Symbol] the package's directory name, such as `"payments"`
112
+ # @param from [String] path of a local checkout of the registry repository
113
+ # @param ref [String, nil] a release version (`"1.2.0"`), a release tag name, any
114
+ # commit-ish, or nil for the newest release
115
+ # @param root [String] the consuming project's root
116
+ # @param allow_downgrade [Boolean] whether a release older than the vendored one is allowed
117
+ # @return [Vendor::Result] the commit, version and storage shape now vendored
118
+ # @raise [Vendoring::Error] if the source, release or files are unusable, or a
119
+ # version-policy refusal applies (see Vendor)
120
+ def self.vendor!(name, from:, root:, ref: nil, allow_downgrade: false)
121
+ Vendor.new(name, from: from, root: root, ref: ref, allow_downgrade: allow_downgrade).call
122
+ end
97
123
  end
98
124
  end
@@ -2,7 +2,7 @@ module Hecks
2
2
  module Forms
3
3
  # The value-object classification every consumer of a resolved VO
4
4
  # shape needs, spelled once — `field_shape.rb` (a command's own
5
- # form), `ui_schema.rb` (embryonaut_console's table/detail-panel
5
+ # form), `ui_schema.rb` (the console app's table/detail-panel
6
6
  # renderer, a separate app, not this repo), and
7
7
  # `adapters/driven/sql_query_builder.rb` (a query's own ORDER BY/
8
8
  # where compiler) each grew their own copy of "is this VO money-
@@ -1,5 +1,5 @@
1
1
  Hecks.bluebook "ConsoleSettings" do
2
- vision "A generic admin console's own curated UI facts — nav, columns, state tones, which rows a detail panel shows — as real dispatched data instead of a hand-rewritten YAML file. A UI opinion the DOMAIN it's pointed at has no way to answer, never a business record; see embryonaut_console's own presentation_config.rb for the fuller story."
2
+ vision "A generic admin console's own curated UI facts — nav, columns, state tones, which rows a detail panel shows — as real dispatched data instead of a hand-rewritten YAML file. A UI opinion the DOMAIN it's pointed at has no way to answer, never a business record; see the console app's own presentation_config.rb for the fuller story."
3
3
  supporting
4
4
 
5
5
  # A FRAMEWORK MEMBER LIKE IDENTITY/GOVERNANCE — attach with
@@ -7,7 +7,7 @@ Hecks.bluebook "ConsoleSettings" do
7
7
  # `.hecksagon`, plus a `Hecks.hecksagon "ConsoleSettings" do ... end`
8
8
  # persistence binding (Heki is the common case — a small, local,
9
9
  # per-app settings store, no shared database needed). This used to
10
- # boot in a SECOND, independent registry (embryonaut_console's own
10
+ # boot in a SECOND, independent registry (the console app's own
11
11
  # console_settings_domain.rb) specifically so no consuming app needed
12
12
  # any change at all — that property is real and now given up on
13
13
  # purpose, in favor of the one binding mechanism every other
@@ -30,7 +30,7 @@ Hecks.bluebook "ConsoleSettings" do
30
30
  # (Overview, below) and every `extra_json`/`identity_extra_json`
31
31
  # field throughout this chapter were BOTH originally scoped out on an
32
32
  # "confirmed unused in any real presentation.yml" basis that turned
33
- # out to be checked against the wrong file — embryonaut_console's own
33
+ # out to be checked against the wrong file — the console app's own
34
34
  # fixture, never this app's actual, live one. Migrating the real file
35
35
  # found both very much in use.
36
36
 
@@ -82,7 +82,7 @@ module Hecks
82
82
  return not_applicable("#{bluebook.name} is bound to #{adapter_name}, not PostgresEra")
83
83
  end
84
84
 
85
- settings = registry.world(bluebook.name)&.for_binding(Hecks::Ports::Persistence::VERB, adapter_name) || {}
85
+ settings = registry.binding_settings(bluebook.name, Hecks::Ports::Persistence::VERB, adapter_name)
86
86
  db = Hecks::Adapters::PostgresEra.connect_for(bluebook.name, settings)
87
87
  begin
88
88
  lineage = Hecks::Adapters::PostgresEra::Lineage.new(db, bluebook.name)
@@ -200,8 +200,8 @@ module Hecks
200
200
  end
201
201
  end
202
202
 
203
- # Rewrites every `.hecksagon` in the copy to bind through Memory and drops its
204
- # `.world` files, so the boot needs no settings at all.
203
+ # Rewrites every `.hecksagon` in the copy to bind through Memory and replaces its
204
+ # `.world` files with a `default_adapter "Memory"` one, so the boot needs no settings.
205
205
  #
206
206
  # @param copy [String] the isolated copy's root directory
207
207
  # @return [void]
@@ -219,8 +219,11 @@ module Hecks
219
219
  # `data/<table>.db` under `root:` when unset — the same
220
220
  # zero-config default Memory gets from having no `.world` at
221
221
  # all), so the simplest correct fix for either target is
222
- # dropping `.world` from the copy entirely.
223
- Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
222
+ # dropping the shipped `.world` from the copy. What replaces it is
223
+ # only a `default_adapter`, so an aggregate the hecksagon leaves
224
+ # unbound (the real world's `default_adapter` bound it) still lands on
225
+ # this mode's adapter — see `write_default_worlds!`.
226
+ write_default_worlds!(copy, "Memory")
224
227
  end
225
228
 
226
229
  # Same dance as memory, one adapter over — `Adapters::Sqlite#
@@ -237,7 +240,7 @@ module Hecks
237
240
  def rebind_to_sqlite!(copy)
238
241
  rewrite_bindings!(copy, "SqlitePersistence")
239
242
  strip_translations!(copy)
240
- Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
243
+ write_default_worlds!(copy, "SqlitePersistence")
241
244
  end
242
245
 
243
246
  # **The expensive one** — Postgres has no zero-config default the way
@@ -292,6 +295,7 @@ module Hecks
292
295
  write_worlds!(copy, "hecks_fuzz_postgres.world") do |name|
293
296
  <<~WORLD
294
297
  Hecks.world "#{name}" do
298
+ default_adapter "Postgres"
295
299
  persisted_by("Postgres") do
296
300
  database "#{database}"
297
301
  schema "#{schema}"
@@ -299,6 +303,7 @@ module Hecks
299
303
  end
300
304
  WORLD
301
305
  end
306
+ write_unbound_chapter_worlds!(copy)
302
307
  end
303
308
 
304
309
  # Admin connection lives outside the tmp copy entirely — same as
@@ -442,6 +447,7 @@ module Hecks
442
447
  write_worlds!(copy, "hecks_fuzz_postgres_era.world") do |name|
443
448
  <<~WORLD
444
449
  Hecks.world "#{name}" do
450
+ default_adapter "PostgresEra"
445
451
  persisted_by("PostgresEra") do
446
452
  database "#{database}"
447
453
  schema "#{schema}"
@@ -450,6 +456,54 @@ module Hecks
450
456
  end
451
457
  WORLD
452
458
  end
459
+ write_unbound_chapter_worlds!(copy)
460
+ end
461
+
462
+ # Replaces every `.world` in the copy with one that declares only `default_adapter`,
463
+ # so the copy needs no settings and an aggregate its hecksagon leaves unbound
464
+ # (a chapter whose real world bound it through `default_adapter`) persists through
465
+ # `adapter_name` like every bound one.
466
+ #
467
+ # @param copy [String] the isolated copy's root directory
468
+ # @param adapter_name [String] the persistence adapter every unbound aggregate falls
469
+ # back to, such as `"Memory"` or `"SqlitePersistence"`
470
+ # @return [void]
471
+ def write_default_worlds!(copy, adapter_name)
472
+ write_worlds!(copy, "hecks_fuzz_default.world") do |name|
473
+ <<~WORLD
474
+ Hecks.world "#{name}" do
475
+ default_adapter "#{adapter_name}"
476
+ end
477
+ WORLD
478
+ end
479
+ end
480
+
481
+ # Gives every chapter no hecksagon names a world of its own that falls back to Memory.
482
+ #
483
+ # A chapter with a bluebook and no hecksagon block (the framework's Privacy) has no
484
+ # bind, and the worlds `write_worlds!` wrote declare a `default_adapter` that needs a
485
+ # `database`: left alone, the chapter resolves that adapter through the project's
486
+ # world with no connection and the boot refuses (`WiringError ... needs a database
487
+ # connection`). It persisted through Memory before those worlds carried a
488
+ # `default_adapter`, and a chapter's own world's default wins over the project's, so
489
+ # this keeps it there.
490
+ #
491
+ # @param copy [String] the isolated copy's root directory
492
+ # @return [void]
493
+ def write_unbound_chapter_worlds!(copy)
494
+ named = Dir.glob(File.join(copy, "**", "*.hecksagon")).flat_map do |path|
495
+ File.read(path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
496
+ end
497
+ bluebooks = Dir.glob(File.join(copy, "**", "*.bluebook")).group_by { |path| File.dirname(path) }
498
+ bluebooks.each do |dir, files|
499
+ unbound = files.flat_map { |path| File.read(path).scan(/Hecks\.bluebook[\s(]+"([^"]+)"/).flatten }
500
+ .uniq - named
501
+ named.concat(unbound)
502
+ next if unbound.empty?
503
+
504
+ worlds = unbound.map { |name| %(Hecks.world "#{name}" do\n default_adapter "Memory"\nend\n) }
505
+ File.write(File.join(dir, "hecks_fuzz_unbound.world"), worlds.join("\n"))
506
+ end
453
507
  end
454
508
 
455
509
  # Writes one `.world` file per directory that holds a `.hecksagon`, naming every
@@ -4,8 +4,10 @@ Hecks.bluebook "World" do
4
4
 
5
5
  identified_by WorldName, as: :domain
6
6
 
7
- attribute :realm, Realm
8
- attribute :latest, Latest
7
+ attribute :realm, Realm
8
+ attribute :latest, Latest
9
+ attribute :default_database, DefaultDatabase
10
+ attribute :default_adapter, DefaultAdapter
9
11
 
10
12
  value_object "WorldName" do
11
13
  attribute :value, String
@@ -22,6 +24,22 @@ Hecks.bluebook "World" do
22
24
  invariant("a latest version says something") { !value.to_s.empty? }
23
25
  end
24
26
 
27
+ # The connection every chapter's database-taking persistence adapter
28
+ # uses unless that chapter's own world says otherwise — a world's
29
+ # per-chapter `persisted_by ... database` block, written once.
30
+ value_object "DefaultDatabase" do
31
+ attribute :value, String
32
+ invariant("a default database says something") { !value.to_s.empty? }
33
+ end
34
+
35
+ # The persistence adapter every aggregate binds to unless its own
36
+ # chapter's hecksagon binds it — between an explicit bind and the
37
+ # framework's in-memory fallback.
38
+ value_object "DefaultAdapter" do
39
+ attribute :value, String
40
+ invariant("a default adapter names an adapter") { !value.to_s.empty? }
41
+ end
42
+
25
43
  command "Declare" do
26
44
  role "Deployment"
27
45
  goal "Open a world for a domain"
@@ -29,10 +47,14 @@ Hecks.bluebook "World" do
29
47
  attribute :domain, WorldName
30
48
  attribute :realm, Realm, optional: true
31
49
  attribute :latest, Latest, optional: true
50
+ attribute :default_database, DefaultDatabase, optional: true
51
+ attribute :default_adapter, DefaultAdapter, optional: true
32
52
 
33
53
  sets :domain
34
54
  sets :realm
35
55
  sets :latest
56
+ sets :default_database
57
+ sets :default_adapter
36
58
 
37
59
  emits "WorldDeclared"
38
60
  end
@@ -55,6 +77,12 @@ Hecks.bluebook "World" do
55
77
  member word: "world", context: "File", body: "keywords", inner: "World", opens: "World", fills: ""
56
78
  member word: "realm", context: "World", body: "none", inner: "", opens: "", fills: "realm", calls: "realm_impl"
57
79
  member word: "latest", context: "World", body: "none", inner: "", opens: "", fills: "latest", calls: "latest_impl"
80
+ # THE TWO CROSS-CHAPTER DEFAULTS — the same connection string and the
81
+ # same persistence adapter every chapter of a project repeats, written
82
+ # once. A chapter's own world (`persisted_by ... database`) and its own
83
+ # hecksagon bind still win; see docs/implemented/reference/world.md.
84
+ member word: "default_database", context: "World", body: "none", inner: "", opens: "", fills: "default_database", calls: "default_database_impl"
85
+ member word: "default_adapter", context: "World", body: "none", inner: "", opens: "", fills: "default_adapter", calls: "default_adapter_impl"
58
86
 
59
87
  end
60
88
 
@@ -78,6 +106,8 @@ Hecks.bluebook "World" do
78
106
  member keyword: "world", context: "File", at: "1", named: "", kind: "text", required: "true", fills: "domain"
79
107
  member keyword: "realm", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "realm"
80
108
  member keyword: "latest", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "latest"
109
+ member keyword: "default_database", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "default_database"
110
+ member keyword: "default_adapter", context: "World", at: "1", named: "", kind: "text", required: "true", fills: "default_adapter"
81
111
 
82
112
  end
83
113
  end
@@ -47,7 +47,7 @@ module Hecks
47
47
  #
48
48
  # @param dispatcher [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted
49
49
  # dispatcher both the lookup query and the `Shred` dispatch run through
50
- # @param domain [String] the subject's own domain FQN, e.g. `"Lifeadelics::Registration"`
50
+ # @param domain [String] the subject's own domain FQN, e.g. `"Site::Registration"`
51
51
  # @param subject_id [String] the data subject to erase
52
52
  # @return [Boolean] true when a live key was found and shredded by this call; false
53
53
  # when no `SubjectKey` record exists for this subject, or it was already shredded
@@ -15,6 +15,13 @@ module Hecks
15
15
  # A domain with no hecksagon gets the default in-memory bind. A domain that has one
16
16
  # must bind the aggregate exactly once without a role.
17
17
  #
18
+ # A `default_adapter` a world declares (`Registry#default_adapter_for` — the
19
+ # chapter's own world, else the project's) stands in wherever the hecksagon binds
20
+ # nothing for the aggregate, and replaces the in-memory fallback for a domain
21
+ # with no hecksagon. It is never consulted once the hecksagon binds the aggregate
22
+ # or declares a domain-level default of its own, so an explicit chapter decision
23
+ # always wins.
24
+ #
18
25
  # @param registry [Runtime::Registry] the registry holding the domain's hecksagon
19
26
  # @param domain [String, Symbol] name of the domain the aggregate belongs to
20
27
  # @param aggregate [Bluebook::Aggregate] the aggregate whose binding is wanted
@@ -23,10 +30,12 @@ module Hecks
23
30
  # @raise [Runtime::WiringError] if the hecksagon has no `persisted_by` bind for the
24
31
  # aggregate, more or fewer than one bind without a role, or any bind with a role
25
32
  def resolve(registry, domain, aggregate)
26
- hexagon = registry.hecksagon(domain)
27
- return default_binding(aggregate) unless hexagon
33
+ hexagon = registry.hecksagon(domain)
34
+ declared = registry.default_adapter_for(domain)
35
+ return default_binding(aggregate, declared || DEFAULT_ADAPTER) unless hexagon
28
36
 
29
37
  bindings = hexagon.binds_for(aggregate.hecks_name, VERB)
38
+ bindings = [default_binding(aggregate, declared)] if bindings.empty? && declared
30
39
  raise missing_binding(domain, aggregate) if bindings.empty?
31
40
 
32
41
  authoritative = bindings.select { |bind| bind.role.nil? || bind.role.empty? }
@@ -39,9 +48,11 @@ module Hecks
39
48
  # Builds the bind an aggregate gets when its domain declares no hecksagon.
40
49
  #
41
50
  # @param aggregate [Bluebook::Aggregate] the aggregate to bind
42
- # @return [Bluebook::Bind] a roleless `persisted_by` bind to `DEFAULT_ADAPTER`
43
- def default_binding(aggregate)
44
- Bluebook::Bind.new(aggregate: aggregate.hecks_name, verb: VERB, adapter: DEFAULT_ADAPTER)
51
+ # @param adapter [String] the adapter to bind it to; the framework's in-memory
52
+ # default unless a world declared one
53
+ # @return [Bluebook::Bind] a roleless `persisted_by` bind to `adapter`
54
+ def default_binding(aggregate, adapter = DEFAULT_ADAPTER)
55
+ Bluebook::Bind.new(aggregate: aggregate.hecks_name, verb: VERB, adapter: adapter)
45
56
  end
46
57
 
47
58
  # Builds, without raising, the error for an aggregate a hecksagon leaves unbound.
@@ -4,6 +4,7 @@ require_relative "lineage"
4
4
  require_relative "../../../../naming"
5
5
  require_relative "../../../../framework"
6
6
  require_relative "../../../../runtime/registry"
7
+ require_relative "expected_era"
7
8
 
8
9
  module Hecks
9
10
  module Runtime
@@ -295,7 +296,7 @@ module Hecks
295
296
  "it could be found (checked #{directory.inspect} and the framework registry)"
296
297
  end
297
298
 
298
- settings = registry.world(bluebook.name)&.for_binding(Ports::Persistence::VERB, adapter_name) || {}
299
+ settings = registry.binding_settings(bluebook.name, Ports::Persistence::VERB, adapter_name)
299
300
  registry.adapter_class(adapter_name).era_check!(
300
301
  registry: registry, bluebook: bluebook, current_text: current_text, settings: settings,
301
302
  directory: directory
@@ -0,0 +1,132 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Hecks
6
+ module Runtime
7
+ module EraCheck
8
+ # The read side of the era gate, for a deploy operator rather than a boot:
9
+ # asks a running host which era it reports at `GET /version` and compares
10
+ # it with an allow-list file of the eras that should be running.
11
+ #
12
+ # `EraCheck.check!` decides at boot, against the store, whether a host may
13
+ # run on an era. This answers the question after a roll, from outside:
14
+ # did the host that came up report an era the operator expects? The two
15
+ # never share state. The host's own `GET /version` document
16
+ # (`rust/host/src/server.rs`, `version_body`) is the only input, so a
17
+ # rollback to an older image, or a fork of the journal back onto an older
18
+ # era, shows up here as an era missing from the list.
19
+ #
20
+ # ## The allow-list file
21
+ #
22
+ # One era id per line. Blank lines and lines starting with `#` are
23
+ # ignored, and so is a trailing ` # comment`. A file that lists no era
24
+ # means "only check that the host reports one", so a first roll can be
25
+ # checked before any era id is known. The generated `expected-era` file
26
+ # (`Deploy::Scripts`) has this shape.
27
+ #
28
+ # `bin/check_era` is the command-line wrapper.
29
+ module ExpectedEra
30
+ # The host could not be reached, or did not answer with a `200`.
31
+ class Unreachable < StandardError; end
32
+
33
+ # The host answered, but not with a `/version` document carrying an era.
34
+ class BadResponse < StandardError; end
35
+
36
+ # What a comparison concluded.
37
+ #
38
+ # @!attribute [r] status
39
+ # @return [Symbol] `:match`, `:unlisted` (no era on the list, one reported) or `:mismatch`
40
+ # @!attribute [r] era
41
+ # @return [String] the era the host reported
42
+ # @!attribute [r] allowed
43
+ # @return [Array<String>] the eras the list accepts
44
+ Verdict = Struct.new(:status, :era, :allowed) do
45
+ # Tells whether the roll is acceptable.
46
+ #
47
+ # @return [Boolean] true unless the reported era is off the list
48
+ def ok? = status != :mismatch
49
+ end
50
+
51
+ module_function
52
+
53
+ # Reads the eras out of an allow-list file's text.
54
+ #
55
+ # @param text [String] the file's contents
56
+ # @return [Array<String>] the era ids in file order, without comments or blanks
57
+ def parse(text)
58
+ text.each_line.filter_map do |line|
59
+ entry = line.strip.sub(/\s+#.*\z/, "")
60
+ entry unless entry.empty? || entry.start_with?("#")
61
+ end
62
+ end
63
+
64
+ # Builds the `/version` URL from what an operator gave.
65
+ #
66
+ # @param url [String] a host's base URL, or its `/version` URL itself
67
+ # @return [String] the URL to request
68
+ def version_url(url)
69
+ base = url.to_s.chomp("/")
70
+ base.end_with?("/version") ? base : "#{base}/version"
71
+ end
72
+
73
+ # Asks a host which era it reports.
74
+ #
75
+ # @param url [String] the host's base URL, or its `/version` URL
76
+ # @param timeout [Numeric] seconds allowed for connecting and for reading
77
+ # @return [String] the `era` the host's `/version` document carries
78
+ # @raise [Unreachable] if the request fails or the status is not `200`
79
+ # @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
80
+ def fetch_era(url, timeout: 10)
81
+ response = get(version_url(url), timeout)
82
+ raise Unreachable, "#{version_url(url)} answered #{response.code}" unless response.code == "200"
83
+
84
+ era = JSON.parse(response.body)["era"]
85
+ raise BadResponse, "#{version_url(url)} carries no era" unless era.is_a?(String) && !era.empty?
86
+
87
+ era
88
+ rescue JSON::ParserError, TypeError
89
+ raise BadResponse, "#{version_url(url)} did not answer a JSON version document"
90
+ end
91
+
92
+ # Compares a reported era with an allow-list.
93
+ #
94
+ # @param era [String] the era the host reported
95
+ # @param allowed [Array<String>] the eras the list accepts; empty accepts any
96
+ # @return [Verdict] `:unlisted` when the list is empty, else `:match` or `:mismatch`
97
+ def verdict(era, allowed)
98
+ status = if allowed.empty? then :unlisted
99
+ elsif allowed.include?(era) then :match
100
+ else :mismatch
101
+ end
102
+ Verdict.new(status, era, allowed)
103
+ end
104
+
105
+ # Fetches the host's era and compares it with an allow-list file.
106
+ #
107
+ # @param url [String] the host's base URL, or its `/version` URL
108
+ # @param file [String] path of the allow-list file
109
+ # @param timeout [Numeric] seconds allowed for connecting and for reading
110
+ # @return [Verdict] the comparison
111
+ # @raise [Errno::ENOENT] if the allow-list file does not exist
112
+ # @raise [Unreachable] see `fetch_era`
113
+ # @raise [BadResponse] see `fetch_era`
114
+ def check(url, file, timeout: 10)
115
+ allowed = parse(File.read(file))
116
+ verdict(fetch_era(url, timeout: timeout), allowed)
117
+ end
118
+
119
+ def get(url, timeout)
120
+ uri = URI.parse(url)
121
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
122
+ open_timeout: timeout, read_timeout: timeout) do |http|
123
+ http.request(Net::HTTP::Get.new(uri.request_uri, "Accept" => "application/json"))
124
+ end
125
+ rescue SystemCallError, SocketError, Timeout::Error, IOError, URI::InvalidURIError => e
126
+ raise Unreachable, "#{url} could not be reached: #{e.message}"
127
+ end
128
+ private_class_method :get
129
+ end
130
+ end
131
+ end
132
+ end
@@ -159,7 +159,7 @@ module Hecks
159
159
  # aware, same as `apply_drop` above (`apply_backfill`'s own
160
160
  # header) — a bare name was the only shape this ever needed
161
161
  # until a value object gained new required members with zero
162
- # source data of their own (found live: lifeadelics' Attendee
162
+ # source data of their own (found live: a client site's Attendee
163
163
  # redesign, commit 4326dcd).
164
164
  @backfills.each { |backfill| apply_backfill(state, backfill) }
165
165
  Entry.new(operation: entry.operation, id: entry.id, state: state, mirrors: entry.mirrors)
@@ -64,7 +64,7 @@ module Hecks
64
64
  # real, live gap for any new required value-object member with no
65
65
  # source data at all — `compute` cannot fill it either, since its
66
66
  # own guard requires a real, already-present field to consume
67
- # (found live: lifeadelics' Attendee redesign, commit 4326dcd,
67
+ # (found live: a client site's Attendee redesign, commit 4326dcd,
68
68
  # needed exactly this and had nothing that worked).
69
69
  declared.backfills.each do |backfill|
70
70
  expression = compile_backfill(expression, backfill)
@@ -12,10 +12,13 @@ module Hecks
12
12
  module Persistence
13
13
  module Plugins
14
14
  # ADR 0033 — requiring this file is installing the plugin. Nothing in
15
- # Hecks core requires it; an app that binds `PostgresEra`, or wants
16
- # schema-translation support at all, requires it explicitly — the
17
- # same shape every adapter-specific spec fixture already uses to
18
- # load one particular `.adapter` file rather than all of them.
15
+ # Hecks core requires it. A boot loads it on demand for a domain that
16
+ # binds `PostgresEra` (`Runtime::Loader.load_bound_adapters!` resolves the
17
+ # adapter, which autoloads this file, before the gates are collected). An
18
+ # app that wants schema-translation support without binding `PostgresEra`
19
+ # requires it explicitly, the same shape every adapter-specific spec
20
+ # fixture already uses to load one particular `.adapter` file rather than
21
+ # all of them.
19
22
  module Era
20
23
  module_function
21
24
 
@@ -0,0 +1,73 @@
1
+ require "uri"
2
+
3
+ module Hecks
4
+ module Ports
5
+ module Persistence
6
+ class PostgresDump
7
+ # A Postgres connection URL, split so a password reaches the tools run
8
+ # against it through their environment and never on a command line, where
9
+ # any user of the machine can read it from the process list.
10
+ class Connection
11
+ # @param url [String] a `postgres://` or `postgresql://` URL
12
+ # @raise [PostgresDump::Error] if `url` is not a Postgres URL
13
+ def initialize(url)
14
+ @uri = URI.parse(url)
15
+ raise Error, "not a postgres URL" unless %w[postgres postgresql].include?(@uri.scheme)
16
+ end
17
+
18
+ # Names the database the URL points at.
19
+ #
20
+ # @return [String] the database name, empty when the URL names none
21
+ def database
22
+ @uri.path.to_s.delete_prefix("/")
23
+ end
24
+
25
+ # Points the same server and credentials at another database.
26
+ #
27
+ # @param name [String] the database to connect to instead
28
+ # @return [Connection] a new connection; this one is unchanged
29
+ def with_database(name)
30
+ copy = @uri.dup
31
+ copy.path = "/#{name}"
32
+ self.class.new(copy.to_s)
33
+ end
34
+
35
+ # Lists the connection settings the URL carries, in `PG.connect` keyword form.
36
+ #
37
+ # @return [Hash{Symbol => String, Integer}] `host`, `port`, `user`, `password`,
38
+ # `dbname` and `sslmode`, each only when the URL gives one; the user and password
39
+ # are percent-decoded
40
+ def settings
41
+ {
42
+ host: (@uri.host unless @uri.host.to_s.empty?), port: @uri.port,
43
+ user: decode(@uri.user), password: decode(@uri.password),
44
+ dbname: (database unless database.empty?), sslmode: URI.decode_www_form(@uri.query.to_s).to_h["sslmode"]
45
+ }.compact
46
+ end
47
+
48
+ # Builds the libpq environment variables for a child process.
49
+ #
50
+ # @return [Hash{String => String}] `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`,
51
+ # `PGDATABASE` and `PGSSLMODE`, each only when `settings` has the matching key
52
+ def env
53
+ settings.to_h { |key, value| ["PG#{key.to_s.sub('dbname', 'database').upcase}", value.to_s] }
54
+ end
55
+
56
+ # Opens a connection with the `pg` gem.
57
+ #
58
+ # @return [PG::Connection] an open connection; the caller closes it
59
+ def connect
60
+ require "pg"
61
+ PG.connect(**settings)
62
+ end
63
+
64
+ private
65
+
66
+ def decode(value)
67
+ value && URI.decode_www_form_component(value)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end