hecks 2.5.1 → 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/bench/cli.rb +74 -0
- data/lib/hecks/bench/environment.rb +100 -0
- data/lib/hecks/bench/postgres_probe.rb +41 -0
- data/lib/hecks/bench/report.rb +116 -0
- data/lib/hecks/bench/ruby_runner.rb +151 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +165 -0
- data/lib/hecks/bench/stats.rb +75 -0
- data/lib/hecks/bench/suite.rb +172 -0
- data/lib/hecks/bench/workload.rb +137 -0
- data/lib/hecks/bench.rb +28 -0
- 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/client_profile.rb +153 -0
- data/lib/hecks/bluebook/model_check.rb +21 -2
- 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 +136 -69
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/mcp_stdio_guard.rb +160 -0
- 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 +3 -1
- metadata +59 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require_relative "gem_pin/published_versions"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Release
|
|
5
|
+
# Finds the hecks version a project runs on, and refuses one that a
|
|
6
|
+
# stranger could not install.
|
|
7
|
+
#
|
|
8
|
+
# ## Why it exists
|
|
9
|
+
#
|
|
10
|
+
# A project that is copied out of its own workspace (handed to someone who
|
|
11
|
+
# will `bundle install` it, or built in an image with no sibling checkout)
|
|
12
|
+
# has to name a hecks release RubyGems actually publishes. A `path:` or
|
|
13
|
+
# `git:` source only works where the path or remote is reachable, and a
|
|
14
|
+
# version that was never published resolves nowhere. This reads the
|
|
15
|
+
# project's Gemfiles and lockfiles, without running Bundler, and says which
|
|
16
|
+
# version each pins or refuses.
|
|
17
|
+
#
|
|
18
|
+
# ## Resolution
|
|
19
|
+
#
|
|
20
|
+
# For each `Gemfile` under the root (a project can hold several, such as a
|
|
21
|
+
# domain and a helper app) the pin is, in order: the version its
|
|
22
|
+
# `Gemfile.lock` resolved, or the newest stable release its version
|
|
23
|
+
# constraint allows. Each pin must be published; the Gemfiles need not
|
|
24
|
+
# agree.
|
|
25
|
+
#
|
|
26
|
+
# ## Injectable network
|
|
27
|
+
#
|
|
28
|
+
# The published list comes from `published:`, any object answering
|
|
29
|
+
# `include?(version)` and `newest_satisfying(requirement)`. The default asks
|
|
30
|
+
# RubyGems; a caller without network hands in its own.
|
|
31
|
+
class GemPin
|
|
32
|
+
GEM_LINE = /^\s*gem\s+["']hecks["'](?<rest>.*)$/
|
|
33
|
+
SOURCE_OPTION = /\b(?:path|git|github|gitlab|bitbucket|branch|ref|tag):/
|
|
34
|
+
LOCKED = /^ hecks \((?<version>[^)]+)\)/
|
|
35
|
+
|
|
36
|
+
# What `resolve` found.
|
|
37
|
+
#
|
|
38
|
+
# @!attribute [r] version
|
|
39
|
+
# @return [String] the newest pin
|
|
40
|
+
# @!attribute [r] pins
|
|
41
|
+
# @return [Hash{String => String}] each Gemfile that declares hecks, relative to the
|
|
42
|
+
# root, mapped to the version it runs on
|
|
43
|
+
Resolved = Struct.new(:version, :pins, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
# @param published [#include?, #newest_satisfying] the source of published versions
|
|
46
|
+
def initialize(published: PublishedVersions.new)
|
|
47
|
+
@published = published
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolves the hecks versions the project in `root` runs on.
|
|
51
|
+
#
|
|
52
|
+
# @param root [String] the project's root directory
|
|
53
|
+
# @return [Resolved] the newest pin and every pin
|
|
54
|
+
# @raise [Error] if no Gemfile declares hecks, one takes it from a source other than
|
|
55
|
+
# RubyGems, one pins a version RubyGems does not publish, or a constraint allows
|
|
56
|
+
# no published release
|
|
57
|
+
def resolve(root)
|
|
58
|
+
pins = gemfiles(root).filter_map { |gemfile| pin_for(gemfile, root) }.to_h
|
|
59
|
+
raise Error, "no Gemfile declares the hecks gem, so this is not a Hecks project" if pins.empty?
|
|
60
|
+
|
|
61
|
+
pins.each do |rel, version|
|
|
62
|
+
raise Error, "hecks #{version} is not published on RubyGems (#{rel})" unless @published.include?(version)
|
|
63
|
+
end
|
|
64
|
+
Resolved.new(version: pins.values.max_by { |version| Gem::Version.new(version) }, pins: pins)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def gemfiles(root)
|
|
70
|
+
Dir.glob("**/Gemfile", base: root).reject { |rel| rel.include?("node_modules/") }.sort
|
|
71
|
+
.map { |rel| File.join(root, rel) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pin_for(gemfile, root)
|
|
75
|
+
declaration = File.read(gemfile).match(GEM_LINE) or return nil
|
|
76
|
+
rel = gemfile.delete_prefix("#{root}/")
|
|
77
|
+
if (source = declaration[:rest][SOURCE_OPTION])
|
|
78
|
+
raise Error, "#{rel} takes hecks from `#{source}`, not from RubyGems; the project must use a published version"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
[rel, locked_version(gemfile, rel) || newest_allowed(declaration[:rest], rel)]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The version Gemfile.lock resolved, if there is a lockfile. A hecks
|
|
85
|
+
# entry under `PATH` or `GIT` means the lockfile is not installable either.
|
|
86
|
+
def locked_version(gemfile, rel)
|
|
87
|
+
lock = "#{gemfile}.lock"
|
|
88
|
+
return nil unless File.exist?(lock)
|
|
89
|
+
|
|
90
|
+
text = File.read(lock)
|
|
91
|
+
refuse_unpublished_source!(text, rel)
|
|
92
|
+
text.match(LOCKED)&.[](:version)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def refuse_unpublished_source!(lock_text, rel)
|
|
96
|
+
lock_text.split("\n\n").each do |section|
|
|
97
|
+
next unless section.start_with?("PATH", "GIT") && section.match?(LOCKED)
|
|
98
|
+
|
|
99
|
+
raise Error, "#{rel}.lock resolves hecks from a #{section.lines.first.strip} source, not from RubyGems"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def newest_allowed(rest, rel)
|
|
104
|
+
constraints = rest.scan(/["']([^"']+)["']/).flatten.grep(/\A\s*(?:[~><=!]+\s*)?\d/)
|
|
105
|
+
version = @published.newest_satisfying(Gem::Requirement.new(*constraints))
|
|
106
|
+
raise Error, "no published hecks release satisfies the constraint in #{rel}" unless version
|
|
107
|
+
|
|
108
|
+
version.to_s
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -123,7 +123,7 @@ module Hecks
|
|
|
123
123
|
amount = unwrap_single_numeric_field(amount) if amount.is_a?(Value)
|
|
124
124
|
|
|
125
125
|
# Widened from Integer to Numeric (migration plan task 4, i106):
|
|
126
|
-
#
|
|
126
|
+
# an external project's organ math increments a Float (`increment: 0.02`) --
|
|
127
127
|
# the raw, non-value-object path only ever mattered for Integer
|
|
128
128
|
# counters before this corpus existed. Integer stays the common
|
|
129
129
|
# case; Float is now accepted the same way.
|
|
@@ -192,7 +192,7 @@ module Hecks
|
|
|
192
192
|
amount_fields = amount.to_h
|
|
193
193
|
# Widened from Integer to Numeric -- see #arithmetic's own
|
|
194
194
|
# comment. A synthesised value-object wrapper around a bare
|
|
195
|
-
# Float attribute (
|
|
195
|
+
# Float attribute (an external project's Synapse#strength, auto-wrapped per
|
|
196
196
|
# Part 3a's "bare primitives forbidden" finding) lands here as
|
|
197
197
|
# a one-Float-field Value exactly the way a one-Integer-field
|
|
198
198
|
# Value already did.
|
data/lib/hecks/runtime/loader.rb
CHANGED
|
@@ -180,7 +180,8 @@ module Hecks
|
|
|
180
180
|
# ADR 0033 — this loader no longer names `EraCheck`, or any other
|
|
181
181
|
# era-specific class, at all. Every loaded persistence plugin
|
|
182
182
|
# (`Ports::Persistence.each_plugin` — nothing here if nothing was
|
|
183
|
-
# ever `require`d
|
|
183
|
+
# ever `require`d, or brought in by a bound adapter, see
|
|
184
|
+
# `load_bound_adapters!`) is asked to contribute its own `:pre_verify`/
|
|
184
185
|
# `:post_verify` gates generically; `:saga_rehydration` is the one
|
|
185
186
|
# gate core still registers directly, because ADR 0031 already
|
|
186
187
|
# proved it's not era-specific.
|
|
@@ -193,6 +194,7 @@ module Hecks
|
|
|
193
194
|
# a wiring problem
|
|
194
195
|
def self.run_boot_gates!(registry, directory)
|
|
195
196
|
gates = BootGates.new
|
|
197
|
+
load_bound_adapters!(registry)
|
|
196
198
|
Ports::Persistence.each_plugin { |plugin| plugin.contribute_boot_gates(registry, gates) }
|
|
197
199
|
check_compute_rules_backstop!(registry)
|
|
198
200
|
|
|
@@ -200,11 +202,33 @@ module Hecks
|
|
|
200
202
|
registry.verify!
|
|
201
203
|
|
|
202
204
|
gates.register(:saga_rehydration, ->(reg, _dir) { reg.rehydrate_sagas! }, phase: :post_verify) if
|
|
203
|
-
registry.
|
|
205
|
+
registry.saga_domains.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
|
|
204
206
|
gates.run!(:post_verify, registry, directory)
|
|
205
207
|
gates
|
|
206
208
|
end
|
|
207
209
|
|
|
210
|
+
# Resolves the Ruby implementation of every adapter a hecksagon binds.
|
|
211
|
+
#
|
|
212
|
+
# An adapter whose implementation loads on first mention (`PostgresEra`,
|
|
213
|
+
# through the era plugin) registers its persistence plugin as a side effect
|
|
214
|
+
# of that load. Resolving here, before the gates are collected, is what makes
|
|
215
|
+
# a domain that declares `persisted_by "PostgresEra"` boot with the plugin
|
|
216
|
+
# loaded and its era gates registered, without the app requiring the plugin
|
|
217
|
+
# itself. An adapter with no Ruby implementation is left for `verify!`, which
|
|
218
|
+
# refuses it with the bind it came from.
|
|
219
|
+
#
|
|
220
|
+
# @param registry [Runtime::Registry] the registry mid-boot, whose hecksagons name the binds
|
|
221
|
+
# @return [void]
|
|
222
|
+
def self.load_bound_adapters!(registry)
|
|
223
|
+
registry.hecksagons.each_value do |hexagon|
|
|
224
|
+
hexagon.binds.each do |bind|
|
|
225
|
+
registry.adapter_class(bind.adapter)
|
|
226
|
+
rescue WiringError
|
|
227
|
+
next
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
208
232
|
# The one piece of the old, era-owned `check_compute_rules!` core
|
|
209
233
|
# still carries — deliberately thinner. `registry.translations` is
|
|
210
234
|
# plain `Bluebook::Translation`/`TranslationAggregate`/
|
|
@@ -56,7 +56,13 @@ module Hecks
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
#
|
|
59
|
+
# Every domain whose sagas can persist somewhere: one with a hecksagon, or one a
|
|
60
|
+
# world's `default_adapter` binds though it has none.
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<String>] the domain names, hecksagon domains first
|
|
63
|
+
def saga_domains = @hecksagons.keys | @bluebooks.keys.select { |domain| default_adapter_for(domain) }
|
|
64
|
+
|
|
65
|
+
# Walks every loaded domain in `saga_domains`, repopulating `saga_instances` from
|
|
60
66
|
# whatever `saga_persistence(domain)` resolves to — a real store
|
|
61
67
|
# for a domain whose adapter answers the capability, `each_saga`
|
|
62
68
|
# yielding real rows; `NULL_SAGA_STORE`'s own `each_saga` for
|
|
@@ -79,7 +85,7 @@ module Hecks
|
|
|
79
85
|
#
|
|
80
86
|
# @return [Hecks::Runtime::Registry] self
|
|
81
87
|
def rehydrate_sagas!
|
|
82
|
-
|
|
88
|
+
saga_domains.each do |domain|
|
|
83
89
|
saga_persistence(domain).each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
|
|
84
90
|
pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
|
|
85
91
|
@saga_instances[process_manager][correlation] =
|
|
@@ -17,10 +17,12 @@ module Hecks
|
|
|
17
17
|
# @raise [Runtime::WiringError] if a bind names an undeclared aggregate, an
|
|
18
18
|
# adapter cannot satisfy its port's verb or declared `answers`, a world
|
|
19
19
|
# setting names a field its adapter does not declare, the default adapter
|
|
20
|
-
# is unusable, a
|
|
21
|
-
#
|
|
20
|
+
# is unusable, a world's `default_adapter` is not a persistence adapter, a
|
|
21
|
+
# command declares a role with no authorization provider attached, or a
|
|
22
|
+
# membership chapter is loaded with no identity chapter
|
|
22
23
|
def verify!
|
|
23
24
|
verify_default_adapter!
|
|
25
|
+
verify_world_defaults!
|
|
24
26
|
verify_singleton_port_answers!
|
|
25
27
|
refuse_cross_package_bluebook_merge!
|
|
26
28
|
refuse_membership_without_identity!
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Runtime
|
|
3
|
+
class Registry
|
|
4
|
+
# **World defaults** — the `default_database` and `default_adapter` words a
|
|
5
|
+
# `.world` can declare once for every chapter a boot loaded, resolved against
|
|
6
|
+
# the registry's own worlds. Included into Registry; the callers are the
|
|
7
|
+
# persistence binding policy (`default_adapter_for`), every reader of a bind's
|
|
8
|
+
# world settings (`binding_settings`), and `verify!` (`verify_world_defaults!`).
|
|
9
|
+
#
|
|
10
|
+
# **Resolution order**, most specific first — nothing here ever overrides a
|
|
11
|
+
# chapter's own explicit declaration:
|
|
12
|
+
#
|
|
13
|
+
# 1. the chapter's own `persisted_by(...) { database ... }` block, and the
|
|
14
|
+
# hecksagon bind (aggregate-specific, then domain-level) that names its adapter;
|
|
15
|
+
# 2. the default declared by the chapter's own world;
|
|
16
|
+
# 3. the default declared by the project's world — the world of the first
|
|
17
|
+
# chapter the boot loaded, the same convention `Loader#dispatcher_for` uses
|
|
18
|
+
# to find the domain a boot targets;
|
|
19
|
+
# 4. today's behavior: no database setting, and the framework's in-memory
|
|
20
|
+
# adapter for a chapter with no hecksagon.
|
|
21
|
+
#
|
|
22
|
+
# A default is resolved when a bind's settings or binding are asked for, never
|
|
23
|
+
# written into a chapter's world or hecksagon, so a chapter's own declarations
|
|
24
|
+
# stay exactly what its files say and a hecksagon-less chapter still trips the
|
|
25
|
+
# sibling-hecksagon gates the way it always did.
|
|
26
|
+
module WorldDefaults
|
|
27
|
+
# The settings a chapter's world gives one bind, with the project's default
|
|
28
|
+
# database filled in where the chapter left it out.
|
|
29
|
+
#
|
|
30
|
+
# The default database only ever reaches a persistence bind whose adapter
|
|
31
|
+
# declares a `database` field — a projection or an adapter without one is
|
|
32
|
+
# untouched. A `database` the chapter's own settings name wins; every other
|
|
33
|
+
# setting the chapter declared rides along unchanged.
|
|
34
|
+
#
|
|
35
|
+
# @param domain [String, Symbol] the domain whose world is asked
|
|
36
|
+
# @param verb [String, Symbol] the bind's port verb, such as `"persisted_by"`
|
|
37
|
+
# @param adapter [String, Symbol] the bind's adapter name
|
|
38
|
+
# @return [Hash{Symbol => Object}] the chapter's own settings for the bind, plus
|
|
39
|
+
# `:database` from the default when one applies; `{}` when there is nothing
|
|
40
|
+
def binding_settings(domain, verb, adapter)
|
|
41
|
+
declared = world(domain)&.for_binding(verb, adapter) || {}
|
|
42
|
+
database = default_database_for(domain)
|
|
43
|
+
return declared unless database && default_database_applies?(verb, adapter)
|
|
44
|
+
|
|
45
|
+
{ adapter: adapter.to_s, database: database }.merge(declared)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The persistence adapter a chapter's aggregates fall back to when their
|
|
49
|
+
# hecksagon does not bind them.
|
|
50
|
+
#
|
|
51
|
+
# @param domain [String, Symbol] the domain to resolve the default for
|
|
52
|
+
# @return [String, nil] the chapter's own world's `default_adapter`, else the
|
|
53
|
+
# project's; `nil` when neither declares one
|
|
54
|
+
def default_adapter_for(domain) = world_default(domain, :default_adapter)
|
|
55
|
+
|
|
56
|
+
# The database connection a chapter's database-taking persistence adapter uses
|
|
57
|
+
# when its own settings name none.
|
|
58
|
+
#
|
|
59
|
+
# @param domain [String, Symbol] the domain to resolve the default for
|
|
60
|
+
# @return [String, nil] the chapter's own world's `default_database`, else the
|
|
61
|
+
# project's; `nil` when neither declares one
|
|
62
|
+
def default_database_for(domain) = world_default(domain, :default_database)
|
|
63
|
+
|
|
64
|
+
# Refuses a `default_adapter` that no persistence adapter answers to.
|
|
65
|
+
#
|
|
66
|
+
# @return [Runtime::Registry] self
|
|
67
|
+
# @raise [Runtime::WiringError] if a world's `default_adapter` names an unknown
|
|
68
|
+
# adapter, one that is not a persistence adapter, or one with no Ruby
|
|
69
|
+
# implementation
|
|
70
|
+
def verify_world_defaults!
|
|
71
|
+
@worlds.each_value do |declared|
|
|
72
|
+
name = declared.default_adapter
|
|
73
|
+
next unless name
|
|
74
|
+
|
|
75
|
+
verify_default_adapter_named!(declared.domain, name)
|
|
76
|
+
end
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# The chapter's own world answers first; the project's world fills the gap.
|
|
83
|
+
def world_default(domain, field)
|
|
84
|
+
world(domain)&.public_send(field) || project_world&.public_send(field)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The world of the first chapter this registry loaded — the target of a boot, ahead of
|
|
88
|
+
# any framework member it attaches.
|
|
89
|
+
def project_world = world(@bluebooks.keys.first || @worlds.keys.first)
|
|
90
|
+
|
|
91
|
+
def default_database_applies?(verb, adapter)
|
|
92
|
+
verb.to_s == Ports::Persistence::VERB && @adapters[adapter.to_s]&.declares?(:database)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def verify_default_adapter_named!(domain, name)
|
|
96
|
+
check_verb(Bluebook::Bind.new(aggregate: "(default)", verb: Ports::Persistence::VERB, adapter: name))
|
|
97
|
+
adapter_class(name)
|
|
98
|
+
rescue WiringError => e
|
|
99
|
+
raise WiringError,
|
|
100
|
+
"#{domain}'s world declares default_adapter #{name.inspect}, which is not a usable " \
|
|
101
|
+
"persistence adapter: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "registry/verification"
|
|
2
2
|
require_relative "registry/saga_persistence"
|
|
3
|
+
require_relative "registry/world_defaults"
|
|
3
4
|
require_relative "outbox"
|
|
4
5
|
require_relative "../naming"
|
|
5
6
|
|
|
@@ -10,10 +11,12 @@ module Hecks
|
|
|
10
11
|
# The collections a boot gathers — bluebooks, hexagons, ports, adapters,
|
|
11
12
|
# worlds, the logs — and how a repository is resolved from them. The
|
|
12
13
|
# wiring gate lives in registry/verification.rb, saga persistence
|
|
13
|
-
# resolution in registry/saga_persistence.rb
|
|
14
|
+
# resolution in registry/saga_persistence.rb, and a `.world`'s project-wide
|
|
15
|
+
# defaults in registry/world_defaults.rb.
|
|
14
16
|
class Registry
|
|
15
17
|
include Verification
|
|
16
18
|
include SagaPersistence
|
|
19
|
+
include WorldDefaults
|
|
17
20
|
|
|
18
21
|
attr_reader :root, :bluebooks, :hecksagons, :ports, :adapters, :worlds, :event_log,
|
|
19
22
|
:reaction_log, :saga_log, :saga_instances, :translations, :saga_mutex,
|
|
@@ -257,7 +260,7 @@ module Hecks
|
|
|
257
260
|
# entry into a real `Privacy::Marking.Mark` once a dispatcher exists.
|
|
258
261
|
#
|
|
259
262
|
# @param domain [String] the marked attribute's own aggregate FQN, e.g.
|
|
260
|
-
# `"
|
|
263
|
+
# `"Site::Registration"`
|
|
261
264
|
# @param attribute_path [String] the dotted path within that aggregate, e.g.
|
|
262
265
|
# `"attendee.medications"`
|
|
263
266
|
# @param category [String] the marking's own sensitivity category, e.g. `"phi"`
|
|
@@ -593,7 +596,7 @@ module Hecks
|
|
|
593
596
|
# concatenated, `base` then `overlay`
|
|
594
597
|
def merge_hecksagons(base, overlay)
|
|
595
598
|
# Same-name blocks concatenate regardless of which file they came
|
|
596
|
-
# from (`
|
|
599
|
+
# from (`site.hecksagon`, `context_map.hecksagon`, an
|
|
597
600
|
# environment overlay). Order-independent: list facts uniq, so
|
|
598
601
|
# loading context_map before or after the domain file is the same
|
|
599
602
|
# merged hecksagon. Binds stay concatenated (BindingPolicy still
|
|
@@ -610,8 +613,8 @@ module Hecks
|
|
|
610
613
|
end
|
|
611
614
|
|
|
612
615
|
# Recovered and generalized — see `add_world`'s own comment. `realm`/
|
|
613
|
-
# `latest` are scalars, so the overlay's
|
|
614
|
-
# else the base's survives; `settings` is a shallow merge keyed by
|
|
616
|
+
# `latest`, `default_database` and `default_adapter` are scalars, so the overlay's
|
|
617
|
+
# value wins when present, else the base's survives; `settings` is a shallow merge keyed by
|
|
615
618
|
# verb (and `"verb:adapter"`) — an overlay entry for a key the base
|
|
616
619
|
# also declares replaces that key's whole resolved hash (the same
|
|
617
620
|
# all-or-nothing shape `WorldBuilder#method_missing` already builds
|
|
@@ -623,10 +626,12 @@ module Hecks
|
|
|
623
626
|
# present, and `settings` shallow-merged, `overlay`'s keys winning
|
|
624
627
|
def merge_worlds(base, overlay)
|
|
625
628
|
Bluebook::World.new(
|
|
626
|
-
domain:
|
|
627
|
-
realm:
|
|
628
|
-
latest:
|
|
629
|
-
settings:
|
|
629
|
+
domain: base.domain,
|
|
630
|
+
realm: overlay.realm || base.realm,
|
|
631
|
+
latest: overlay.latest || base.latest,
|
|
632
|
+
settings: base.settings.merge(overlay.settings),
|
|
633
|
+
default_database: overlay.default_database || base.default_database,
|
|
634
|
+
default_adapter: overlay.default_adapter || base.default_adapter
|
|
630
635
|
)
|
|
631
636
|
end
|
|
632
637
|
end
|
|
@@ -38,7 +38,7 @@ module Hecks
|
|
|
38
38
|
# search_path` — proven for real, not assumed, by
|
|
39
39
|
# tenant_isolation_spec.rb. Plain Postgres (no schema story) and D1
|
|
40
40
|
# (no schema-equivalent at all — see world.bluebook's own comment on
|
|
41
|
-
# the
|
|
41
|
+
# the D1 tradeoff) answer false, or don't answer at all,
|
|
42
42
|
# which this module treats identically to false.
|
|
43
43
|
module TenantCheck
|
|
44
44
|
module_function
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Vendoring
|
|
3
|
+
# The environment a `git` subprocess needs so it resolves its repository
|
|
4
|
+
# from the directory it is pointed at, not from whoever launched Ruby.
|
|
5
|
+
#
|
|
6
|
+
# Git exports `GIT_DIR`, `GIT_INDEX_FILE` and their kin to the hooks it
|
|
7
|
+
# runs (a push from a linked worktree runs `pre-push` this way). Every
|
|
8
|
+
# `git` that a hook's process tree starts inherits them, and they outrank
|
|
9
|
+
# both `git -C` and the working directory: a command aimed at a scratch
|
|
10
|
+
# repository would read, or commit into, the repository that was pushing.
|
|
11
|
+
module GitEnvironment
|
|
12
|
+
# The variables that pin git to one repository, work tree or index.
|
|
13
|
+
INHERITED = %w[
|
|
14
|
+
GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR
|
|
15
|
+
GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# An environment hash for `Open3`, `IO.popen` and `system` that unsets
|
|
19
|
+
# every repository-pinning variable and leaves the rest alone.
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash{String => nil}] each inherited variable mapped to nil
|
|
22
|
+
def self.clean
|
|
23
|
+
INHERITED.to_h { |name| [name, nil] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Removes the repository-pinning variables from this process's own
|
|
27
|
+
# environment, so every later `git` child starts clean.
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
def self.scrub!
|
|
31
|
+
INHERITED.each { |name| ENV.delete(name) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "git_environment"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Vendoring
|
|
7
|
+
# A local git repository read through the `git` executable, never through
|
|
8
|
+
# its working tree.
|
|
9
|
+
#
|
|
10
|
+
# Everything here names a commit and reads what that commit holds, so a
|
|
11
|
+
# shared checkout with uncommitted edits, or a different branch checked
|
|
12
|
+
# out, cannot leak into what gets vendored. Every command runs under
|
|
13
|
+
# {GitEnvironment.clean}, so a `GIT_DIR` inherited from a hook cannot
|
|
14
|
+
# redirect it to some other repository.
|
|
15
|
+
class GitSource
|
|
16
|
+
# @param path [String] a directory that is a git repository (or a
|
|
17
|
+
# worktree of one)
|
|
18
|
+
# @raise [Vendoring::Error] if `path` is not a directory
|
|
19
|
+
def initialize(path)
|
|
20
|
+
@path = File.expand_path(path.to_s)
|
|
21
|
+
raise Error, "no source repository at #{@path}" unless File.directory?(@path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] the absolute path of the repository
|
|
25
|
+
attr_reader :path
|
|
26
|
+
|
|
27
|
+
# Resolves a commit-ish to the full commit id it names.
|
|
28
|
+
#
|
|
29
|
+
# @param ref [String] a branch, tag, abbreviated or full commit id
|
|
30
|
+
# @return [String] the 40-character commit id
|
|
31
|
+
# @raise [Vendoring::Error] if `ref` names no commit in the repository
|
|
32
|
+
def commit(ref)
|
|
33
|
+
out, _err, status = git("rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
|
|
34
|
+
raise Error, "no commit #{ref.inspect} in #{@path}" unless status.success?
|
|
35
|
+
|
|
36
|
+
out.strip
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Lists tag names matching a shell pattern.
|
|
40
|
+
#
|
|
41
|
+
# @param pattern [String] a `git tag --list` pattern, such as `"payments-v*"`
|
|
42
|
+
# @return [Array<String>] the matching tag names, in git's own order
|
|
43
|
+
def tags(pattern)
|
|
44
|
+
out, _err, status = git("tag", "--list", pattern)
|
|
45
|
+
status.success? ? out.lines.map(&:chomp) : []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Reads one file as a commit holds it.
|
|
49
|
+
#
|
|
50
|
+
# @param ref [String] the commit-ish to read from
|
|
51
|
+
# @param file [String] a repository-relative path
|
|
52
|
+
# @return [String, nil] the file's content, or nil when the commit has no such file
|
|
53
|
+
def read(ref, file)
|
|
54
|
+
out, _err, status = git("show", "#{ref}:#{file}")
|
|
55
|
+
status.success? ? out : nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Lists the files directly inside a directory of a commit.
|
|
59
|
+
#
|
|
60
|
+
# Subdirectories are left out on purpose: a vendored package carries the
|
|
61
|
+
# top-level files of its subtree and nothing nested beneath.
|
|
62
|
+
#
|
|
63
|
+
# @param ref [String] the commit-ish to read from
|
|
64
|
+
# @param subtree [String] a repository-relative directory, such as `"payments/bluebook"`
|
|
65
|
+
# @param glob [String] a `File.fnmatch` pattern the file's own name must match
|
|
66
|
+
# @return [Array<String>] repository-relative paths, sorted
|
|
67
|
+
def files(ref, subtree, glob = "*")
|
|
68
|
+
out, _err, status = git("ls-tree", "-r", "--name-only", ref, "--", subtree)
|
|
69
|
+
return [] unless status.success?
|
|
70
|
+
|
|
71
|
+
out.lines.map(&:chomp).select do |file|
|
|
72
|
+
File.dirname(file) == subtree && File.fnmatch(glob, File.basename(file))
|
|
73
|
+
end.sort
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Extracts named files of a commit into a directory with `git archive`,
|
|
77
|
+
# keeping their repository-relative paths.
|
|
78
|
+
#
|
|
79
|
+
# @param ref [String] the commit-ish to export
|
|
80
|
+
# @param files [Array<String>] repository-relative paths to export
|
|
81
|
+
# @param into [String] an existing directory to extract into
|
|
82
|
+
# @return [void]
|
|
83
|
+
# @raise [Vendoring::Error] if `git archive` or `tar` fails
|
|
84
|
+
def export(ref, files, into)
|
|
85
|
+
Dir.mktmpdir("hecks-archive") do |scratch|
|
|
86
|
+
tarball = File.join(scratch, "export.tar")
|
|
87
|
+
run!("git archive", "git", "-C", @path, "archive", "--format=tar", "--output=#{tarball}", ref, "--", *files)
|
|
88
|
+
run!("tar", "tar", "-xf", tarball, "-C", into)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def git(*) = Open3.capture3(GitEnvironment.clean, "git", "-C", @path, *)
|
|
95
|
+
|
|
96
|
+
def run!(label, *command)
|
|
97
|
+
_out, err, status = Open3.capture3(GitEnvironment.clean, *command)
|
|
98
|
+
raise Error, "#{label} failed: #{err.lines.first.to_s.strip}" unless status.success?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "vendoring/git_environment"
|
|
4
|
+
require_relative "vendoring/git_source"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
# Pins an exact commit of one directory of another repository into a
|
|
8
|
+
# project's own tree.
|
|
9
|
+
#
|
|
10
|
+
# ## What it is
|
|
11
|
+
#
|
|
12
|
+
# A project that consumes a package from a private repository cannot clone
|
|
13
|
+
# that repository when its image builds, and a live pull would make the build
|
|
14
|
+
# depend on whatever is checked out. So the files are exported from one
|
|
15
|
+
# commit, committed into the consumer, and a marker file beside them records
|
|
16
|
+
# which commit they came from. Only the files directly inside the named
|
|
17
|
+
# subtree are taken; the source's own specs, ports, adapters and data stay
|
|
18
|
+
# where they are.
|
|
19
|
+
#
|
|
20
|
+
# ## Marker
|
|
21
|
+
#
|
|
22
|
+
# `VENDORED_COMMIT` holds the full 40-character commit id and a newline, so
|
|
23
|
+
# `git -C <source> show <id>:<subtree>` reproduces the vendored files. The
|
|
24
|
+
# marker sits in `into`, beside the exported directory.
|
|
25
|
+
#
|
|
26
|
+
# ## Whole-directory replacement
|
|
27
|
+
#
|
|
28
|
+
# A pin replaces `into` entirely, so a file the source dropped does not
|
|
29
|
+
# linger. Nothing is touched until the export succeeded and the optional
|
|
30
|
+
# block accepted it.
|
|
31
|
+
module Vendoring
|
|
32
|
+
# Raised when a pin cannot be made or is refused.
|
|
33
|
+
class Error < StandardError; end
|
|
34
|
+
|
|
35
|
+
# File name of the commit marker written into every pinned directory.
|
|
36
|
+
MARKER = "VENDORED_COMMIT".freeze
|
|
37
|
+
|
|
38
|
+
# What a pin produced.
|
|
39
|
+
#
|
|
40
|
+
# @!attribute [r] commit
|
|
41
|
+
# @return [String] the full commit id the files were exported from
|
|
42
|
+
# @!attribute [r] dir
|
|
43
|
+
# @return [String] the directory holding the exported subtree
|
|
44
|
+
# @!attribute [r] files
|
|
45
|
+
# @return [Array<String>] the exported files' names, sorted
|
|
46
|
+
Pin = Struct.new(:commit, :dir, :files, keyword_init: true)
|
|
47
|
+
|
|
48
|
+
# Exports the top-level files of one directory at one commit into `into`.
|
|
49
|
+
#
|
|
50
|
+
# The exported files land in `into/<last segment of subtree>/`; the marker
|
|
51
|
+
# lands in `into` itself. The optional block sees the staged files before
|
|
52
|
+
# anything is replaced and may raise to refuse the pin.
|
|
53
|
+
#
|
|
54
|
+
# @param from [String] path of the local source repository
|
|
55
|
+
# @param ref [String] the commit-ish to export (branch, tag or commit id)
|
|
56
|
+
# @param subtree [String] repository-relative directory to export, such as
|
|
57
|
+
# `"payments/bluebook"`
|
|
58
|
+
# @param into [String] the directory to replace with the export
|
|
59
|
+
# @param glob [String] `File.fnmatch` pattern each exported file's name must match
|
|
60
|
+
# @param marker [String] name of the file recording the commit
|
|
61
|
+
# @yield [staged, commit] the staged files, before `into` is touched
|
|
62
|
+
# @yieldparam staged [String] directory holding the exported files
|
|
63
|
+
# @yieldparam commit [String] the resolved commit id
|
|
64
|
+
# @yieldreturn [Hash{String => String}, nil] extra files to write into `into`,
|
|
65
|
+
# keyed by file name; nil for none
|
|
66
|
+
# @return [Pin] the commit and where the files landed
|
|
67
|
+
# @raise [Vendoring::Error] if the source is missing, `ref` names no commit,
|
|
68
|
+
# the subtree holds no matching file, or the block refuses
|
|
69
|
+
def self.pin(from:, ref:, subtree:, into:, glob: "*", marker: MARKER)
|
|
70
|
+
source = GitSource.new(from)
|
|
71
|
+
commit = source.commit(ref)
|
|
72
|
+
files = source.files(commit, subtree, glob)
|
|
73
|
+
raise Error, "no #{glob} files in #{subtree} at #{ref} in #{source.path}" if files.empty?
|
|
74
|
+
|
|
75
|
+
Dir.mktmpdir("hecks-vendoring") do |stage|
|
|
76
|
+
source.export(commit, files, stage)
|
|
77
|
+
staged = File.join(stage, subtree)
|
|
78
|
+
extras = block_given? ? yield(staged, commit) : nil
|
|
79
|
+
install(staged, into, marker, commit, extras || {})
|
|
80
|
+
Pin.new(commit: commit, dir: File.join(into, File.basename(subtree)),
|
|
81
|
+
files: files.map { |file| File.basename(file) })
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Replaces `into` with the staged files, the marker and any extras.
|
|
86
|
+
#
|
|
87
|
+
# @param staged [String] directory holding the exported files
|
|
88
|
+
# @param into [String] the directory to replace
|
|
89
|
+
# @param marker [String] marker file name
|
|
90
|
+
# @param commit [String] commit id the marker records
|
|
91
|
+
# @param extras [Hash{String => String}] more files to write into `into`
|
|
92
|
+
# @return [void]
|
|
93
|
+
def self.install(staged, into, marker, commit, extras)
|
|
94
|
+
FileUtils.rm_rf(into)
|
|
95
|
+
FileUtils.mkdir_p(into)
|
|
96
|
+
FileUtils.cp_r(staged, File.join(into, File.basename(staged)))
|
|
97
|
+
File.write(File.join(into, marker), "#{commit}\n")
|
|
98
|
+
extras.each { |name, text| File.write(File.join(into, name), text) }
|
|
99
|
+
end
|
|
100
|
+
private_class_method :install
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/hecks/version.rb
CHANGED