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
@@ -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
- # miette's organ math increments a Float (`increment: 0.02`) --
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 (miette's Synapse#strength, auto-wrapped per
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.
@@ -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) is asked to contribute its own `:pre_verify`/
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.hecksagons.each_key.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
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
- # Walks every loaded domain, repopulating `saga_instances` from
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
- @hecksagons.each_key do |domain|
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 command declares a role with no authorization provider
21
- # attached, or a membership chapter is loaded with no identity chapter
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
- # `"Lifeadelics::Registration"`
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 (`lifeadelics.hecksagon`, `context_map.hecksagon`, an
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 value wins when present,
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: base.domain,
627
- realm: overlay.realm || base.realm,
628
- latest: overlay.latest || base.latest,
629
- settings: base.settings.merge(overlay.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 lifeadelics D1 tradeoff) answer false, or don't answer at all,
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
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "2.6.0".freeze
15
+ VERSION = "2.7.0".freeze
16
16
  end