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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +2 -1
- 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 (
|
|
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
|
|
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
|
-
"
|
|
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` (
|
|
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
|
|
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 (
|
|
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 —
|
|
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.
|
|
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
|
|
204
|
-
# `.world` files, so the boot needs no settings
|
|
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
|
|
223
|
-
|
|
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
|
-
|
|
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,
|
|
8
|
-
attribute :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. `"
|
|
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
|
|
27
|
-
|
|
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
|
-
# @
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|