textus 0.8.1 → 0.9.2
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/CHANGELOG.md +224 -0
- data/README.md +50 -22
- data/SPEC.md +194 -63
- data/docs/architecture.md +22 -4
- data/docs/conventions.md +24 -17
- data/lib/textus/application/context.rb +68 -0
- data/lib/textus/application/reads/audit.rb +69 -0
- data/lib/textus/application/reads/blame.rb +79 -0
- data/lib/textus/application/reads/freshness.rb +77 -0
- data/lib/textus/application/reads/get.rb +62 -0
- data/lib/textus/application/reads/policy_explain.rb +39 -0
- data/lib/textus/application/refresh/all.rb +41 -0
- data/lib/textus/application/refresh/orchestrator.rb +68 -0
- data/lib/textus/application/refresh/worker.rb +79 -0
- data/lib/textus/application/writes/accept.rb +43 -0
- data/lib/textus/application/writes/build.rb +24 -0
- data/lib/textus/application/writes/delete.rb +37 -0
- data/lib/textus/application/writes/publish.rb +25 -0
- data/lib/textus/application/writes/put.rb +44 -0
- data/lib/textus/builder.rb +27 -14
- data/lib/textus/cli/group/policy.rb +11 -0
- data/lib/textus/cli/verb/accept.rb +2 -1
- data/lib/textus/cli/verb/audit.rb +31 -0
- data/lib/textus/cli/verb/blame.rb +17 -0
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/delete.rb +2 -1
- data/lib/textus/cli/verb/freshness.rb +17 -0
- data/lib/textus/cli/verb/get.rb +8 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -3
- data/lib/textus/cli/verb/policy_explain.rb +15 -0
- data/lib/textus/cli/verb/policy_list.rb +25 -0
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/refresh.rb +2 -1
- data/lib/textus/cli/verb/refresh_stale.rb +19 -0
- data/lib/textus/cli/verb/reject.rb +15 -0
- data/lib/textus/cli.rb +16 -2
- data/lib/textus/composition.rb +71 -0
- data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
- data/lib/textus/doctor/check/intake_registration.rb +46 -0
- data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
- data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
- data/lib/textus/doctor.rb +4 -0
- data/lib/textus/domain/action.rb +9 -0
- data/lib/textus/domain/freshness/evaluator.rb +30 -0
- data/lib/textus/domain/freshness/policy.rb +18 -0
- data/lib/textus/domain/freshness/verdict.rb +12 -0
- data/lib/textus/domain/outcome.rb +10 -0
- data/lib/textus/domain/permission.rb +15 -0
- data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
- data/lib/textus/domain/policy/matcher.rb +51 -0
- data/lib/textus/domain/policy/promote.rb +24 -0
- data/lib/textus/domain/policy/refresh.rb +48 -0
- data/lib/textus/domain/policy.rb +7 -0
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +15 -1
- data/lib/textus/hooks/dsl.rb +18 -0
- data/lib/textus/hooks/registry.rb +12 -5
- data/lib/textus/infra/clock.rb +9 -0
- data/lib/textus/infra/event_bus.rb +27 -0
- data/lib/textus/infra/publisher.rb +73 -0
- data/lib/textus/infra/refresh/detached.rb +38 -0
- data/lib/textus/infra/refresh/lock.rb +44 -0
- data/lib/textus/init.rb +71 -28
- data/lib/textus/intro.rb +19 -11
- data/lib/textus/manifest/entry.rb +18 -9
- data/lib/textus/manifest/policies.rb +83 -0
- data/lib/textus/manifest.rb +30 -0
- data/lib/textus/proposal.rb +4 -21
- data/lib/textus/publisher.rb +4 -69
- data/lib/textus/refresh.rb +9 -44
- data/lib/textus/store/mover.rb +14 -9
- data/lib/textus/store/reader.rb +10 -8
- data/lib/textus/store/staleness.rb +4 -16
- data/lib/textus/store/validator.rb +46 -20
- data/lib/textus/store/view.rb +8 -19
- data/lib/textus/store/writer.rb +51 -14
- data/lib/textus/store.rb +29 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +1 -0
- metadata +46 -2
- data/lib/textus/cli/verb/stale.rb +0 -14
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Doctor
|
|
5
|
+
class Check
|
|
6
|
+
# Scans the raw manifest YAML for entry-level intake.ttl /
|
|
7
|
+
# intake.on_stale / intake.sync_budget_ms keys. Manifest parsing
|
|
8
|
+
# already raises on these in 0.9.2 — this check exists for the case
|
|
9
|
+
# where doctor is run against a problem manifest separately (e.g. CI
|
|
10
|
+
# lint or an exploration session that loads the YAML directly).
|
|
11
|
+
class LegacyIntakeFields < Check
|
|
12
|
+
LEGACY_KEYS = %w[ttl on_stale sync_budget_ms].freeze
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
out = []
|
|
16
|
+
path = File.join(store.root, "manifest.yaml")
|
|
17
|
+
return out unless File.exist?(path)
|
|
18
|
+
|
|
19
|
+
raw = safe_load(path)
|
|
20
|
+
return out unless raw.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
Array(raw["entries"]).each do |entry|
|
|
23
|
+
next unless entry.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
intake = entry["intake"]
|
|
26
|
+
next unless intake.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
offending = LEGACY_KEYS.select { |k| intake.key?(k) }
|
|
29
|
+
next if offending.empty?
|
|
30
|
+
|
|
31
|
+
out << issue_for(entry["key"], offending)
|
|
32
|
+
end
|
|
33
|
+
out
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def safe_load(path)
|
|
39
|
+
YAML.load_file(path)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def issue_for(key, fields)
|
|
45
|
+
{
|
|
46
|
+
"code" => "manifest.legacy_intake_fields",
|
|
47
|
+
"level" => "error",
|
|
48
|
+
"subject" => key.to_s,
|
|
49
|
+
"message" => "entry '#{key}' carries legacy intake.#{fields.join(", intake.")} " \
|
|
50
|
+
"(removed in 0.9.2 — freshness lives in top-level policies:)",
|
|
51
|
+
"fix" => "hand-edit these into a top-level policies: block (see CHANGELOG migration recipe)",
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# Flags entries whose key is matched by two or more policy blocks of the
|
|
5
|
+
# SAME specificity in the same slot (refresh / handler_allowlist /
|
|
6
|
+
# promote). Ties are non-deterministic in the parser's pick step, so
|
|
7
|
+
# they're a configuration smell — surface them.
|
|
8
|
+
class PolicyAmbiguity < Check
|
|
9
|
+
SLOTS = %i[refresh handler_allowlist promote].freeze
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
out = []
|
|
13
|
+
policies = store.manifest.policies
|
|
14
|
+
store.manifest.entries.each do |mentry|
|
|
15
|
+
matches = policies.explain(mentry.key)
|
|
16
|
+
next if matches.length < 2
|
|
17
|
+
|
|
18
|
+
SLOTS.each { |slot| out.concat(ambiguities_for(mentry, slot, matches)) }
|
|
19
|
+
end
|
|
20
|
+
out
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def ambiguities_for(mentry, slot, matches)
|
|
26
|
+
carriers = matches.select { |b| b.public_send(slot) }
|
|
27
|
+
return [] if carriers.length < 2
|
|
28
|
+
|
|
29
|
+
by_specificity = carriers.group_by { |b| Textus::Domain::Policy::Matcher.specificity(b.match) }
|
|
30
|
+
tied = by_specificity.values.select { |group| group.length > 1 }
|
|
31
|
+
tied.map { |group| issue_for(mentry, slot, group) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def issue_for(mentry, slot, group)
|
|
35
|
+
globs = group.map(&:match).sort
|
|
36
|
+
{
|
|
37
|
+
"code" => "policy.ambiguity",
|
|
38
|
+
"level" => "warning",
|
|
39
|
+
"subject" => mentry.key,
|
|
40
|
+
"message" => "entry '#{mentry.key}' matches #{group.length} policy blocks at the same " \
|
|
41
|
+
"specificity for #{slot}: #{globs.join(", ")}",
|
|
42
|
+
"fix" => "narrow one of the conflicting match: globs in .textus/manifest.yaml so a single " \
|
|
43
|
+
"block wins for this key",
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -13,11 +13,15 @@ module Textus
|
|
|
13
13
|
Check::Schemas,
|
|
14
14
|
Check::Templates,
|
|
15
15
|
Check::Hooks,
|
|
16
|
+
Check::IntakeRegistration,
|
|
16
17
|
Check::IllegalKeys,
|
|
17
18
|
Check::Sentinels,
|
|
18
19
|
Check::AuditLog,
|
|
19
20
|
Check::UnownedSchemaFields,
|
|
20
21
|
Check::SchemaViolations,
|
|
22
|
+
Check::PolicyAmbiguity,
|
|
23
|
+
Check::HandlerAllowlist,
|
|
24
|
+
Check::LegacyIntakeFields,
|
|
21
25
|
].freeze
|
|
22
26
|
|
|
23
27
|
ALL_CHECKS = CHECKS.map(&:name_key).freeze
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Freshness
|
|
6
|
+
module Evaluator
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(policy, envelope, now:)
|
|
10
|
+
return Verdict.fresh if policy.ttl_seconds.nil?
|
|
11
|
+
|
|
12
|
+
last_str = envelope.dig("_meta", "last_refreshed_at")
|
|
13
|
+
return Verdict.stale("never refreshed") if last_str.nil?
|
|
14
|
+
|
|
15
|
+
last = begin
|
|
16
|
+
Time.parse(last_str.to_s)
|
|
17
|
+
rescue ArgumentError, TypeError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
return Verdict.stale("unparseable last_refreshed_at: #{last_str.inspect}") if last.nil?
|
|
21
|
+
|
|
22
|
+
age = now - last
|
|
23
|
+
return Verdict.fresh if age <= policy.ttl_seconds
|
|
24
|
+
|
|
25
|
+
Verdict.stale("ttl exceeded (age=#{age.to_i}s, ttl=#{policy.ttl_seconds}s)")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Freshness
|
|
4
|
+
Policy = Data.define(:ttl_seconds, :on_stale, :sync_budget_ms) do
|
|
5
|
+
def decide(verdict)
|
|
6
|
+
return Action::Return.new if verdict.fresh?
|
|
7
|
+
|
|
8
|
+
case on_stale
|
|
9
|
+
when :warn then Action::Return.new
|
|
10
|
+
when :sync then Action::RefreshSync.new
|
|
11
|
+
when :timed_sync then Action::RefreshTimed.new(budget_ms: sync_budget_ms)
|
|
12
|
+
else Action::Return.new
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Freshness
|
|
4
|
+
Verdict = Data.define(:fresh, :reason) do
|
|
5
|
+
def self.fresh = new(fresh: true, reason: nil)
|
|
6
|
+
def self.stale(reason) = new(fresh: false, reason: reason)
|
|
7
|
+
def fresh? = fresh
|
|
8
|
+
def stale? = !fresh
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
Permission = Data.define(:zone, :writable_by, :readable_by) do
|
|
4
|
+
def allows_write?(role)
|
|
5
|
+
writable_by.include?(role.to_s)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def allows_read?(role)
|
|
9
|
+
return true if readable_by == :all
|
|
10
|
+
|
|
11
|
+
readable_by.include?(role.to_s)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
class HandlerAllowlist
|
|
5
|
+
attr_reader :handlers
|
|
6
|
+
|
|
7
|
+
def initialize(handlers:)
|
|
8
|
+
@handlers = Array(handlers).map(&:to_s).freeze
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def allows?(handler)
|
|
12
|
+
@handlers.include?(handler.to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
module Matcher
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def matches?(glob, key)
|
|
8
|
+
glob_segs = glob.split(".")
|
|
9
|
+
key_segs = key.split(".")
|
|
10
|
+
consume(glob_segs, key_segs)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def specificity(glob)
|
|
14
|
+
glob.split(".").reduce(0) do |s, seg|
|
|
15
|
+
s + case seg
|
|
16
|
+
when "**" then 0
|
|
17
|
+
when "*" then 1
|
|
18
|
+
else 10
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pick_most_specific(globs, key:)
|
|
24
|
+
matching = globs.select { |g| matches?(g, key) }
|
|
25
|
+
return nil if matching.empty?
|
|
26
|
+
|
|
27
|
+
matching.max_by { |g| [specificity(g), -g.length, g] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.consume(glob_segs, key_segs)
|
|
31
|
+
return key_segs.empty? if glob_segs.empty?
|
|
32
|
+
|
|
33
|
+
head = glob_segs.first
|
|
34
|
+
rest = glob_segs[1..]
|
|
35
|
+
|
|
36
|
+
if head == "**"
|
|
37
|
+
return true if rest.empty?
|
|
38
|
+
|
|
39
|
+
(0..key_segs.length).any? { |i| consume(rest, key_segs[i..]) }
|
|
40
|
+
elsif key_segs.empty?
|
|
41
|
+
false
|
|
42
|
+
elsif head == "*" || head == key_segs.first
|
|
43
|
+
consume(rest, key_segs[1..])
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
class Promote
|
|
5
|
+
KNOWN = %i[schema_valid human_accept].freeze
|
|
6
|
+
attr_reader :requires
|
|
7
|
+
|
|
8
|
+
def initialize(requires:)
|
|
9
|
+
syms = Array(requires).map { |r| r.to_s.to_sym }
|
|
10
|
+
unknown = syms - KNOWN
|
|
11
|
+
unless unknown.empty?
|
|
12
|
+
raise Textus::UsageError.new("unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@requires = syms
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def demands?(req)
|
|
19
|
+
@requires.include?(req)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
class Refresh
|
|
5
|
+
attr_reader :ttl, :on_stale, :sync_budget_ms
|
|
6
|
+
|
|
7
|
+
def initialize(ttl:, on_stale:, sync_budget_ms:)
|
|
8
|
+
on_stale_sym = on_stale.is_a?(Symbol) ? on_stale : on_stale.to_s.to_sym
|
|
9
|
+
unless ALLOWED_ON_STALE.include?(on_stale_sym)
|
|
10
|
+
raise Textus::UsageError.new(
|
|
11
|
+
"on_stale must be one of #{ALLOWED_ON_STALE.join(", ")} (got #{on_stale.inspect})",
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@ttl = ttl
|
|
16
|
+
@on_stale = on_stale_sym
|
|
17
|
+
@sync_budget_ms = sync_budget_ms
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ttl_seconds
|
|
21
|
+
return nil if @ttl.nil?
|
|
22
|
+
|
|
23
|
+
str = @ttl.to_s.strip
|
|
24
|
+
return str.to_i if str.match?(/\A\d+\z/)
|
|
25
|
+
|
|
26
|
+
m = str.match(/\A(\d+)\s*([smhd])\z/)
|
|
27
|
+
return nil unless m
|
|
28
|
+
|
|
29
|
+
n = m[1].to_i
|
|
30
|
+
case m[2]
|
|
31
|
+
when "s" then n
|
|
32
|
+
when "m" then n * 60
|
|
33
|
+
when "h" then n * 3600
|
|
34
|
+
when "d" then n * 86_400
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_freshness_policy
|
|
39
|
+
Textus::Domain::Freshness::Policy.new(
|
|
40
|
+
ttl_seconds: ttl_seconds,
|
|
41
|
+
on_stale: @on_stale,
|
|
42
|
+
sync_budget_ms: @sync_budget_ms,
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -8,21 +8,21 @@ module Textus
|
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
10
|
def self.register_all
|
|
11
|
-
Textus.hook(:
|
|
11
|
+
Textus.hook(:intake, :json) do |store:, config:, args:|
|
|
12
12
|
_ = store
|
|
13
13
|
_ = args
|
|
14
14
|
data = JSON.parse(config["bytes"].to_s)
|
|
15
15
|
{ _meta: {}, body: YAML.dump(data) }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
Textus.hook(:
|
|
18
|
+
Textus.hook(:intake, :csv) do |store:, config:, args:|
|
|
19
19
|
_ = store
|
|
20
20
|
_ = args
|
|
21
21
|
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
22
|
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
Textus.hook(:
|
|
25
|
+
Textus.hook(:intake, :"markdown-links") do |store:, config:, args:|
|
|
26
26
|
_ = store
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
Textus.hook(:
|
|
34
|
+
Textus.hook(:intake, :"ical-events") do |store:, config:, args:|
|
|
35
35
|
_ = store
|
|
36
36
|
_ = args
|
|
37
37
|
events = []
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
{ _meta: {}, body: YAML.dump(events) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
Textus.hook(:
|
|
53
|
+
Textus.hook(:intake, :rss) do |store:, config:, args:|
|
|
54
54
|
_ = store
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
@@ -28,7 +28,8 @@ module Textus
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
30
|
def invoke(event, sub, key, kwargs)
|
|
31
|
-
|
|
31
|
+
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
32
|
+
Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**accepted) }
|
|
32
33
|
rescue StandardError => e
|
|
33
34
|
extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.message}" }
|
|
34
35
|
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
@@ -39,6 +40,19 @@ module Textus
|
|
|
39
40
|
)
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# Passes only the kwargs a hook block declares. Lets us extend event
|
|
44
|
+
# payloads (e.g., correlation_id) without breaking hooks written against
|
|
45
|
+
# the old signature.
|
|
46
|
+
def filter_kwargs(callable, kwargs)
|
|
47
|
+
params = callable.parameters
|
|
48
|
+
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
49
|
+
|
|
50
|
+
accepted = params.each_with_object([]) do |(type, name), acc|
|
|
51
|
+
acc << name if %i[key keyreq].include?(type)
|
|
52
|
+
end
|
|
53
|
+
kwargs.slice(*accepted)
|
|
54
|
+
end
|
|
55
|
+
|
|
42
56
|
def match?(globs, key)
|
|
43
57
|
return true if globs.nil?
|
|
44
58
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Hooks
|
|
3
|
+
module Dsl
|
|
4
|
+
EVENTS = %i[
|
|
5
|
+
intake reduce check
|
|
6
|
+
put deleted refreshed built published accepted
|
|
7
|
+
mv reject loaded
|
|
8
|
+
refresh_began refresh_failed refresh_detached
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
EVENTS.each do |event|
|
|
12
|
+
define_method(event) do |name, **opts, &blk|
|
|
13
|
+
Loader.current_registry.register(event, name, **opts, &blk)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -3,16 +3,23 @@ module Textus
|
|
|
3
3
|
class Registry
|
|
4
4
|
EVENTS = {
|
|
5
5
|
# RPC: exactly 1 handler per name; return value flows into store; failure aborts.
|
|
6
|
-
|
|
6
|
+
intake: { mode: :rpc, args: %i[store config args] },
|
|
7
7
|
reduce: { mode: :rpc, args: %i[store rows config] },
|
|
8
8
|
check: { mode: :rpc, args: %i[store] },
|
|
9
9
|
|
|
10
10
|
# Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
|
|
11
11
|
put: { mode: :pubsub, args: %i[store key envelope] },
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
deleted: { mode: :pubsub, args: %i[store key] },
|
|
13
|
+
refreshed: { mode: :pubsub, args: %i[store key envelope change] },
|
|
14
|
+
built: { mode: :pubsub, args: %i[store key envelope sources] },
|
|
15
|
+
accepted: { mode: :pubsub, args: %i[store key target_key] },
|
|
16
|
+
published: { mode: :pubsub, args: %i[store key envelope source target] },
|
|
17
|
+
mv: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
|
|
18
|
+
reject: { mode: :pubsub, args: %i[store key target_key] },
|
|
19
|
+
loaded: { mode: :pubsub, args: %i[store] },
|
|
20
|
+
refresh_began: { mode: :pubsub, args: %i[store key mode] },
|
|
21
|
+
refresh_failed: { mode: :pubsub, args: %i[store key error_class error_message] },
|
|
22
|
+
refresh_detached: { mode: :pubsub, args: %i[store key started_at budget_ms] },
|
|
16
23
|
}.freeze
|
|
17
24
|
|
|
18
25
|
def initialize(dispatcher: nil)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
class EventBus
|
|
4
|
+
def initialize(registry:)
|
|
5
|
+
@registry = registry
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def publish(event, **payload)
|
|
9
|
+
@registry.pubsub_handlers(event).each do |entry|
|
|
10
|
+
next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
|
|
11
|
+
|
|
12
|
+
entry[:callable].call(**payload)
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
warn "[textus] pub-sub handler #{entry[:name].inspect} for #{event.inspect} failed: #{e.class}: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def matches?(globs, key)
|
|
21
|
+
return true if key.nil?
|
|
22
|
+
|
|
23
|
+
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Infra
|
|
7
|
+
# Publishes built artifacts from the store to repo-relative consumer paths.
|
|
8
|
+
# Publish = copy + sentinel. The in-store file is already the consumer-shaped
|
|
9
|
+
# artifact; no parsing or stripping. Sentinels live under
|
|
10
|
+
# `<store_root>/sentinels/` and mirror the target's repo-relative layout so
|
|
11
|
+
# consumer directories aren't polluted with `.textus-managed.json` siblings.
|
|
12
|
+
module Publisher
|
|
13
|
+
SENTINEL_SUFFIX = ".textus-managed.json".freeze
|
|
14
|
+
SENTINEL_DIR = "sentinels".freeze
|
|
15
|
+
|
|
16
|
+
def self.publish(source:, target:, store_root:)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
18
|
+
refuse_if_unmanaged(target, store_root)
|
|
19
|
+
File.delete(target) if File.symlink?(target)
|
|
20
|
+
FileUtils.cp(source, target)
|
|
21
|
+
write_sentinel(target, store_root: store_root, source: source)
|
|
22
|
+
cleanup_legacy_sentinel(target)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.refuse_if_unmanaged(target, store_root)
|
|
26
|
+
return unless File.exist?(target) || File.symlink?(target)
|
|
27
|
+
return if managed?(target, store_root)
|
|
28
|
+
|
|
29
|
+
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.managed?(target, store_root)
|
|
33
|
+
File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.write_sentinel(target, store_root:, source:)
|
|
37
|
+
path = sentinel_path(target, store_root)
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
39
|
+
File.write(path, JSON.generate(
|
|
40
|
+
"source" => source,
|
|
41
|
+
"target" => target,
|
|
42
|
+
"sha256" => Digest::SHA256.hexdigest(File.binread(target)),
|
|
43
|
+
"mode" => "copy",
|
|
44
|
+
))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
|
|
48
|
+
# The full target extension is preserved so a marketplace.json and
|
|
49
|
+
# marketplace.yaml don't collide.
|
|
50
|
+
def self.sentinel_path(target, store_root)
|
|
51
|
+
repo_root = File.dirname(store_root)
|
|
52
|
+
rel = relative_to(target, repo_root) || File.basename(target)
|
|
53
|
+
File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.legacy_sentinel_path(target)
|
|
57
|
+
target + SENTINEL_SUFFIX
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.cleanup_legacy_sentinel(target)
|
|
61
|
+
FileUtils.rm_f(legacy_sentinel_path(target))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.relative_to(path, base)
|
|
65
|
+
path = File.expand_path(path)
|
|
66
|
+
base = File.expand_path(base)
|
|
67
|
+
return nil unless path.start_with?(base + File::SEPARATOR)
|
|
68
|
+
|
|
69
|
+
path[(base.length + 1)..]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
module Refresh
|
|
4
|
+
module Detached
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def supported?
|
|
8
|
+
Process.respond_to?(:fork)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def spawn(store_root:, key:)
|
|
12
|
+
return nil unless supported?
|
|
13
|
+
|
|
14
|
+
pid = Process.fork do
|
|
15
|
+
$stdin.close
|
|
16
|
+
$stdout.reopen(File::NULL, "w")
|
|
17
|
+
$stderr.reopen(File::NULL, "w")
|
|
18
|
+
|
|
19
|
+
lock = Textus::Infra::Refresh::Lock.new(root: store_root, key: key)
|
|
20
|
+
exit(0) unless lock.try_acquire
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
store = Textus::Store.new(store_root)
|
|
24
|
+
Textus::Refresh.call(store, key, as: "script")
|
|
25
|
+
rescue StandardError
|
|
26
|
+
# Already logged via :refresh_failed; exit cleanly.
|
|
27
|
+
ensure
|
|
28
|
+
lock.release
|
|
29
|
+
exit(0)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
Process.detach(pid)
|
|
33
|
+
pid
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|