textus 0.18.0 → 0.20.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/ARCHITECTURE.md +43 -48
- data/CHANGELOG.md +173 -0
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +9 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +10 -8
- data/lib/textus/application/reads/get.rb +10 -8
- data/lib/textus/application/reads/get_or_refresh.rb +3 -3
- data/lib/textus/application/reads/list.rb +3 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +5 -4
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +10 -6
- data/lib/textus/application/reads/validator.rb +2 -2
- data/lib/textus/application/reads/where.rb +3 -3
- data/lib/textus/application/refresh/all.rb +15 -11
- data/lib/textus/application/refresh/orchestrator.rb +9 -8
- data/lib/textus/application/refresh/worker.rb +56 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +38 -15
- data/lib/textus/application/writes/delete.rb +13 -10
- data/lib/textus/application/writes/envelope_io.rb +64 -4
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +57 -94
- data/lib/textus/application/writes/publish.rb +132 -26
- data/lib/textus/application/writes/put.rb +15 -14
- data/lib/textus/application/writes/reject.rb +20 -11
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/verb/build.rb +2 -5
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +5 -5
- data/lib/textus/cli/verb/key_normalize.rb +32 -3
- data/lib/textus/cli/verb/put.rb +2 -3
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/staleness/generator_check.rb +8 -7
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +3 -3
- data/lib/textus/infra/audit_subscriber.rb +4 -4
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +3 -2
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +3 -100
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +2 -2
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -11
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
|
|
8
8
|
option :event_filter, "--event=E"
|
|
9
9
|
|
|
10
|
-
def call(store) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
10
|
+
def call(store) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
11
11
|
subcommand = positional.first
|
|
12
12
|
if subcommand
|
|
13
13
|
raise UsageError.new("hook requires 'list'") unless subcommand == "list"
|
|
@@ -16,15 +16,15 @@ module Textus
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
rows = []
|
|
19
|
-
Textus::Hooks::
|
|
19
|
+
Textus::Hooks::Bus::EVENTS.each do |event, spec|
|
|
20
20
|
mode = spec[:mode].to_s
|
|
21
21
|
case spec[:mode]
|
|
22
22
|
when :rpc
|
|
23
|
-
store.
|
|
23
|
+
store.bus.rpc_names(event).each do |name|
|
|
24
24
|
rows << { "event" => event.to_s, "mode" => mode, "name" => name.to_s }
|
|
25
25
|
end
|
|
26
26
|
when :pubsub
|
|
27
|
-
store.
|
|
27
|
+
store.bus.pubsub_handlers(event).each do |h|
|
|
28
28
|
row = { "event" => event.to_s, "mode" => mode, "name" => h[:name].to_s }
|
|
29
29
|
row["keys"] = Array(h[:keys]) if h[:keys]
|
|
30
30
|
rows << row
|
|
@@ -32,7 +32,7 @@ module Textus
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
store.manifest.entries.each do |e|
|
|
35
|
-
e.events.each do |evt, defs|
|
|
35
|
+
(e.respond_to?(:events) ? e.events : {}).each do |evt, defs|
|
|
36
36
|
Array(defs).each do |defn|
|
|
37
37
|
next unless defn["exec"]
|
|
38
38
|
|
|
@@ -7,11 +7,40 @@ module Textus
|
|
|
7
7
|
|
|
8
8
|
option :write, "--write"
|
|
9
9
|
option :dry_run, "--dry-run"
|
|
10
|
+
option :upgrade_manifest, "--upgrade-manifest"
|
|
10
11
|
|
|
11
12
|
def call(store)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
if upgrade_manifest
|
|
14
|
+
run_upgrade_manifest(store)
|
|
15
|
+
else
|
|
16
|
+
effective_write = write && !dry_run
|
|
17
|
+
res = Textus::Application::Tools::MigrateKeys.run(store, write: effective_write || false)
|
|
18
|
+
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def run_upgrade_manifest(store)
|
|
25
|
+
manifest_path = File.join(store.root, "manifest.yaml")
|
|
26
|
+
orig = File.read(manifest_path)
|
|
27
|
+
new_yaml = Textus::Application::Tools::MigrateManifestToKinds.upgrade_yaml(orig)
|
|
28
|
+
|
|
29
|
+
if dry_run
|
|
30
|
+
diff_lines = unified_diff(orig, new_yaml, manifest_path)
|
|
31
|
+
emit({ "protocol" => PROTOCOL, "dry_run" => true, "diff" => diff_lines, "ok" => true }, exit_code: 0)
|
|
32
|
+
else
|
|
33
|
+
File.write(manifest_path, new_yaml)
|
|
34
|
+
puts "upgraded manifest at #{manifest_path}"
|
|
35
|
+
emit({ "protocol" => PROTOCOL, "upgraded" => manifest_path, "ok" => true }, exit_code: 0)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def unified_diff(before, after, _path)
|
|
40
|
+
before.lines.zip(after.lines).each_with_object([]) do |(a, b), acc|
|
|
41
|
+
acc << "- #{a.chomp}" if a && a != b
|
|
42
|
+
acc << "+ #{b.chomp}" if b && a != b
|
|
43
|
+
end
|
|
15
44
|
end
|
|
16
45
|
end
|
|
17
46
|
end
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -17,12 +17,11 @@ module Textus
|
|
|
17
17
|
raw = @stdin.read
|
|
18
18
|
payload =
|
|
19
19
|
if fetch_name
|
|
20
|
-
callable = store.
|
|
20
|
+
callable = store.bus.rpc_callable(:resolve_intake, fetch_name)
|
|
21
21
|
result =
|
|
22
22
|
begin
|
|
23
23
|
Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
|
|
24
|
-
callable.call(config: { "bytes" => raw },
|
|
25
|
-
store: Textus::Application::Context.new(store: store, role: role), args: {})
|
|
24
|
+
callable.call(config: { "bytes" => raw }, store: store, args: {})
|
|
26
25
|
end
|
|
27
26
|
rescue Timeout::Error
|
|
28
27
|
raise UsageError.new(
|
|
@@ -10,8 +10,7 @@ module Textus
|
|
|
10
10
|
option :as_flag, "--as=ROLE"
|
|
11
11
|
|
|
12
12
|
def call(store)
|
|
13
|
-
|
|
14
|
-
result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
|
|
13
|
+
result = operations_for(store).refresh_all(prefix: prefix, zone: zone)
|
|
15
14
|
emit(result)
|
|
16
15
|
result["ok"] ? 0 : 1
|
|
17
16
|
end
|
|
@@ -8,8 +8,9 @@ module Textus
|
|
|
8
8
|
def call
|
|
9
9
|
out = []
|
|
10
10
|
store.manifest.entries.each do |mentry|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
next unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
12
|
+
|
|
13
|
+
handler = mentry.handler
|
|
13
14
|
|
|
14
15
|
allow = store.manifest.rules_for(mentry.key).handler_allowlist
|
|
15
16
|
next if allow.nil?
|
|
@@ -8,11 +8,11 @@ module Textus
|
|
|
8
8
|
return out unless File.directory?(dir)
|
|
9
9
|
|
|
10
10
|
Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
11
|
-
|
|
11
|
+
bus = Textus::Hooks::Bus.new
|
|
12
12
|
Textus.drain_hook_blocks
|
|
13
13
|
begin
|
|
14
14
|
load(f)
|
|
15
|
-
Textus.drain_hook_blocks.each { |b| b.call(
|
|
15
|
+
Textus.drain_hook_blocks.each { |b| b.call(bus) }
|
|
16
16
|
end
|
|
17
17
|
rescue StandardError, ScriptError => e
|
|
18
18
|
out << {
|
|
@@ -5,12 +5,13 @@ module Textus
|
|
|
5
5
|
def call
|
|
6
6
|
out = []
|
|
7
7
|
store.manifest.entries.each do |entry|
|
|
8
|
-
next unless entry.nested
|
|
8
|
+
next unless entry.nested?
|
|
9
9
|
|
|
10
10
|
base = File.join(store.root, "zones", entry.path)
|
|
11
11
|
next unless File.directory?(base)
|
|
12
12
|
|
|
13
|
-
entry.index_filename ?
|
|
13
|
+
index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
14
|
+
index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(base, out)
|
|
14
15
|
end
|
|
15
16
|
out
|
|
16
17
|
end
|
|
@@ -31,8 +32,8 @@ module Textus
|
|
|
31
32
|
# segments leading to each index file participate in keys. Sibling
|
|
32
33
|
# files and unrelated subtrees are not enumerated and must not be
|
|
33
34
|
# flagged. Each illegal segment is reported once per path.
|
|
34
|
-
def check_index_paths(
|
|
35
|
-
Dir.glob(File.join(base, "**",
|
|
35
|
+
def check_index_paths(_entry, index_fn, base, out)
|
|
36
|
+
Dir.glob(File.join(base, "**", index_fn)).each do |fp|
|
|
36
37
|
rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
37
38
|
File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
|
|
38
39
|
next if seg.match?(Key::Grammar::SEGMENT)
|
|
@@ -43,7 +44,7 @@ module Textus
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def issue(abs_path, stem)
|
|
46
|
-
proposed = Textus::MigrateKeys.normalize(stem)
|
|
47
|
+
proposed = Textus::Application::Tools::MigrateKeys.normalize(stem)
|
|
47
48
|
{
|
|
48
49
|
"code" => "key.illegal",
|
|
49
50
|
"level" => "error",
|
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call
|
|
8
8
|
declared = collect_declared_handlers
|
|
9
|
-
registered = store.
|
|
9
|
+
registered = store.bus.rpc_names(:resolve_intake).to_set
|
|
10
10
|
|
|
11
11
|
out = (declared - registered).map do |name|
|
|
12
12
|
{
|
|
@@ -36,7 +36,7 @@ module Textus
|
|
|
36
36
|
def collect_declared_handlers
|
|
37
37
|
set = Set.new
|
|
38
38
|
store.manifest.entries.each do |mentry|
|
|
39
|
-
set << mentry.
|
|
39
|
+
set << mentry.handler.to_sym if mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
40
40
|
end
|
|
41
41
|
set
|
|
42
42
|
end
|
|
@@ -19,7 +19,7 @@ module Textus
|
|
|
19
19
|
"code" => "protocol_mismatch",
|
|
20
20
|
"severity" => "error",
|
|
21
21
|
"message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
|
|
22
|
-
"hint" => "
|
|
22
|
+
"hint" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
|
|
23
23
|
}]
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -38,7 +38,7 @@ module Textus
|
|
|
38
38
|
"level" => "error",
|
|
39
39
|
"subject" => path,
|
|
40
40
|
"message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
|
|
41
|
-
"fix" => "
|
|
41
|
+
"fix" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
|
|
42
42
|
}]
|
|
43
43
|
end
|
|
44
44
|
end
|
|
@@ -5,16 +5,17 @@ module Textus
|
|
|
5
5
|
def call
|
|
6
6
|
out = []
|
|
7
7
|
store.manifest.entries.each do |entry|
|
|
8
|
-
|
|
8
|
+
template = entry.respond_to?(:template) ? entry.template : nil
|
|
9
|
+
next if template.nil?
|
|
9
10
|
|
|
10
|
-
tp = File.join(store.root, "templates",
|
|
11
|
+
tp = File.join(store.root, "templates", template)
|
|
11
12
|
next if File.exist?(tp)
|
|
12
13
|
|
|
13
14
|
out << {
|
|
14
15
|
"code" => "template.missing",
|
|
15
16
|
"level" => "error",
|
|
16
17
|
"subject" => entry.key,
|
|
17
|
-
"message" => "template '#{
|
|
18
|
+
"message" => "template '#{template}' not found at #{tp}",
|
|
18
19
|
"fix" => "create the file at #{tp} or update the entry's template: field",
|
|
19
20
|
}
|
|
20
21
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -54,11 +54,10 @@ module Textus
|
|
|
54
54
|
|
|
55
55
|
def run_registered_checks(store)
|
|
56
56
|
out = []
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
callable = store.registry.rpc_callable(:validate, name)
|
|
57
|
+
store.bus.rpc_names(:validate).each do |name|
|
|
58
|
+
callable = store.bus.rpc_callable(:validate, name)
|
|
60
59
|
begin
|
|
61
|
-
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store:
|
|
60
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
|
|
62
61
|
if result.is_a?(Array)
|
|
63
62
|
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
64
63
|
else
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Authorization service. Single source of truth for "given a manifest
|
|
6
|
+
# entry and a role, may this caller read/write?". Extracted from
|
|
7
|
+
# Application::Context so the rule lives in Domain alongside Permission.
|
|
8
|
+
class Authorizer
|
|
9
|
+
def initialize(manifest:)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def can_write?(zone, role:)
|
|
14
|
+
@manifest.permission_for(zone.to_s).allows_write?(role)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def can_read?(zone, role:)
|
|
18
|
+
@manifest.permission_for(zone.to_s).allows_read?(role)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authorize_write!(mentry, role:)
|
|
22
|
+
return if can_write?(mentry.zone, role: role)
|
|
23
|
+
|
|
24
|
+
writers = @manifest.zone_writers(mentry.zone)
|
|
25
|
+
raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorize_read!(mentry, role:)
|
|
29
|
+
return if can_read?(mentry.zone, role: role)
|
|
30
|
+
|
|
31
|
+
readers = @manifest.zone_readers[mentry.zone]
|
|
32
|
+
readers = nil if readers == :all
|
|
33
|
+
raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -14,9 +14,10 @@ module Textus
|
|
|
14
14
|
|
|
15
15
|
def rows_for(mentry)
|
|
16
16
|
return [] unless mentry.in_generator_zone?
|
|
17
|
+
return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
return [] unless
|
|
19
|
+
src = mentry.source
|
|
20
|
+
return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
20
21
|
|
|
21
22
|
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
23
|
return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
|
|
@@ -28,7 +29,7 @@ module Textus
|
|
|
28
29
|
gen_time = parse_time(generated_at)
|
|
29
30
|
return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
|
|
30
31
|
|
|
31
|
-
offender = newest_source_after(
|
|
32
|
+
offender = newest_source_after(src, gen_time)
|
|
32
33
|
return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
|
|
33
34
|
|
|
34
35
|
[]
|
|
@@ -42,8 +43,8 @@ module Textus
|
|
|
42
43
|
nil
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def newest_source_after(
|
|
46
|
-
Array(
|
|
46
|
+
def newest_source_after(external_src, gen_time)
|
|
47
|
+
Array(external_src.sources).each do |src|
|
|
47
48
|
offender = check_source(src, gen_time)
|
|
48
49
|
return offender if offender
|
|
49
50
|
end
|
|
@@ -52,7 +53,7 @@ module Textus
|
|
|
52
53
|
|
|
53
54
|
def check_source(src, gen_time)
|
|
54
55
|
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
55
|
-
@manifest.enumerate(prefix: src).each do |row|
|
|
56
|
+
@manifest.resolver.enumerate(prefix: src).each do |row|
|
|
56
57
|
return src if File.mtime(row[:path]) > gen_time
|
|
57
58
|
end
|
|
58
59
|
nil
|
|
@@ -78,7 +79,7 @@ module Textus
|
|
|
78
79
|
{
|
|
79
80
|
"key" => mentry.key,
|
|
80
81
|
"path" => path,
|
|
81
|
-
"generator" => mentry.
|
|
82
|
+
"generator" => mentry.raw["compute"],
|
|
82
83
|
"reason" => reason,
|
|
83
84
|
}
|
|
84
85
|
end
|
|
@@ -11,7 +11,7 @@ module Textus
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def rows_for(mentry)
|
|
14
|
-
return [] unless mentry.
|
|
14
|
+
return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
15
15
|
|
|
16
16
|
ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
|
|
17
17
|
return [] unless ttl
|
|
@@ -38,7 +38,7 @@ module Textus
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def row(mentry, path, reason)
|
|
41
|
-
{ "key" => mentry.key, "path" => path, "handler" => mentry.
|
|
41
|
+
{ "key" => mentry.key, "path" => path, "handler" => mentry.handler, "reason" => reason }
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
end
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -7,22 +7,22 @@ module Textus
|
|
|
7
7
|
module Hooks
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all(
|
|
11
|
-
|
|
10
|
+
def self.register_all(bus)
|
|
11
|
+
bus.on(:resolve_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
|
-
|
|
18
|
+
bus.on(:resolve_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
|
-
|
|
25
|
+
bus.on(:resolve_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
|
-
|
|
34
|
+
bus.on(:resolve_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
|
-
|
|
53
|
+
bus.on(:resolve_intake, :rss) do |store:, config:, args:|
|
|
54
54
|
_ = store
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class Bus
|
|
6
|
+
HOOK_TIMEOUT_SECONDS = 2
|
|
7
|
+
|
|
8
|
+
class HookTimeout < StandardError; end
|
|
9
|
+
|
|
10
|
+
EVENTS = {
|
|
11
|
+
# RPC events — gem-internal, keep :store
|
|
12
|
+
resolve_intake: { mode: :rpc, args: %i[store config args] },
|
|
13
|
+
transform_rows: { mode: :rpc, args: %i[store rows config] },
|
|
14
|
+
validate: { mode: :rpc, args: %i[store] },
|
|
15
|
+
|
|
16
|
+
# Pubsub events — ship :ctx (Hooks::Context) instead of raw store
|
|
17
|
+
entry_put: { mode: :pubsub, args: %i[ctx key envelope] },
|
|
18
|
+
entry_deleted: { mode: :pubsub, args: %i[ctx key] },
|
|
19
|
+
entry_refreshed: { mode: :pubsub, args: %i[ctx key envelope change] },
|
|
20
|
+
entry_renamed: { mode: :pubsub, args: %i[ctx key from_key to_key envelope] },
|
|
21
|
+
build_completed: { mode: :pubsub, args: %i[ctx key envelope sources] },
|
|
22
|
+
proposal_accepted: { mode: :pubsub, args: %i[ctx key target_key] },
|
|
23
|
+
proposal_rejected: { mode: :pubsub, args: %i[ctx key target_key] },
|
|
24
|
+
file_published: { mode: :pubsub, args: %i[ctx key envelope source target] },
|
|
25
|
+
store_loaded: { mode: :pubsub, args: %i[ctx] },
|
|
26
|
+
refresh_started: { mode: :pubsub, args: %i[ctx key mode] },
|
|
27
|
+
refresh_failed: { mode: :pubsub, args: %i[ctx key error_class error_message] },
|
|
28
|
+
refresh_backgrounded: { mode: :pubsub, args: %i[ctx key started_at budget_ms] },
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@rpc = Hash.new { |h, k| h[k] = {} }
|
|
33
|
+
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
34
|
+
@error_handlers = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
|
|
38
|
+
|
|
39
|
+
def register(event, name, keys: nil, &blk)
|
|
40
|
+
event_sym = event.to_sym
|
|
41
|
+
spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
+
shape_check!(event_sym, spec, blk)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
|
|
45
|
+
case spec[:mode]
|
|
46
|
+
when :rpc
|
|
47
|
+
raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
|
|
48
|
+
|
|
49
|
+
@rpc[event_sym][name] = blk
|
|
50
|
+
when :pubsub
|
|
51
|
+
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
52
|
+
|
|
53
|
+
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_error(&block) = @error_handlers << block
|
|
58
|
+
|
|
59
|
+
def rpc_callable(event, name)
|
|
60
|
+
@rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rpc_names(event) = @rpc[event.to_sym].keys
|
|
64
|
+
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
65
|
+
def listeners(event, key:) = @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
|
|
66
|
+
|
|
67
|
+
def publish(event, strict: false, **kwargs)
|
|
68
|
+
key = kwargs[:key] || "-"
|
|
69
|
+
fired = []
|
|
70
|
+
errored = []
|
|
71
|
+
timed_out = []
|
|
72
|
+
raised = nil
|
|
73
|
+
|
|
74
|
+
@pubsub[event.to_sym].each do |sub|
|
|
75
|
+
next unless match?(sub[:keys], key)
|
|
76
|
+
|
|
77
|
+
outcome, err = invoke(event, sub, key, kwargs)
|
|
78
|
+
case outcome
|
|
79
|
+
when :ok then fired << sub[:name]
|
|
80
|
+
when :errored then errored << sub[:name]
|
|
81
|
+
when :timed_out then timed_out << sub[:name]
|
|
82
|
+
end
|
|
83
|
+
raised ||= err if strict && err
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
raise raised if strict && raised
|
|
87
|
+
|
|
88
|
+
FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def invoke(event, sub, key, kwargs)
|
|
94
|
+
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
95
|
+
error = nil
|
|
96
|
+
|
|
97
|
+
thread = Thread.new do
|
|
98
|
+
sub[:callable].call(**accepted)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
error = e
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if thread.join(HOOK_TIMEOUT_SECONDS).nil?
|
|
104
|
+
thread.kill
|
|
105
|
+
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
106
|
+
notify_error(event, sub, key, kwargs, err)
|
|
107
|
+
return [:timed_out, err]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if error
|
|
111
|
+
notify_error(event, sub, key, kwargs, error)
|
|
112
|
+
return [:errored, error]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[:ok, nil]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def notify_error(event, sub, key, kwargs, error)
|
|
119
|
+
@error_handlers.each do |handler|
|
|
120
|
+
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
warn "[textus] error handler failed: #{e.class}: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def filter_kwargs(callable, kwargs)
|
|
127
|
+
params = callable.parameters
|
|
128
|
+
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
129
|
+
|
|
130
|
+
accepted = params.each_with_object([]) do |(type, name), acc|
|
|
131
|
+
acc << name if %i[key keyreq].include?(type)
|
|
132
|
+
end
|
|
133
|
+
kwargs.slice(*accepted)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def shape_check!(event, spec, blk)
|
|
137
|
+
required = spec[:args]
|
|
138
|
+
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
139
|
+
keyrest = provided.any? { |t, _| t == :keyrest }
|
|
140
|
+
missing = required - provided.map { |_, n| n }
|
|
141
|
+
return if keyrest || missing.empty?
|
|
142
|
+
|
|
143
|
+
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def match?(globs, key)
|
|
147
|
+
return true if globs.nil?
|
|
148
|
+
|
|
149
|
+
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def matches_any?(globs, key) = match?(globs, key)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# A narrow handle passed to user hooks in place of the raw Store.
|
|
6
|
+
# All writes route back through Operations so authorization, audit
|
|
7
|
+
# logging, and schema validation always fire.
|
|
8
|
+
class Context
|
|
9
|
+
attr_reader :role, :correlation_id
|
|
10
|
+
|
|
11
|
+
def initialize(ops:)
|
|
12
|
+
@ops = ops
|
|
13
|
+
@role = ops.ctx.role
|
|
14
|
+
@correlation_id = ops.ctx.correlation_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# read
|
|
18
|
+
def get(key) = @ops.get(key)
|
|
19
|
+
def list(**) = @ops.list(**)
|
|
20
|
+
def deps(key) = @ops.deps(key)
|
|
21
|
+
def freshness(key) = @ops.freshness(key)
|
|
22
|
+
|
|
23
|
+
# write (authorized + audited)
|
|
24
|
+
def put(key, **) = @ops.put(key, **)
|
|
25
|
+
def delete(key, **) = @ops.delete(key, **)
|
|
26
|
+
def audit(verb, key:, **) = @ops.store.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
27
|
+
|
|
28
|
+
# fan-out
|
|
29
|
+
def publish_followup(event, **)
|
|
30
|
+
@ops.store.bus.publish(event, ctx: self, **)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inspect
|
|
34
|
+
"#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# Outcome of a single Dispatcher#publish call.
|
|
6
|
+
#
|
|
7
|
+
# fired — hook names that ran to completion
|
|
8
|
+
# errored — hook names that raised
|
|
9
|
+
# timed_out — hook names whose worker thread exceeded the deadline
|
|
10
|
+
#
|
|
11
|
+
# Callers that care about hook health (tests, strict embedders) can
|
|
12
|
+
# check #ok? or inspect #failures. The dispatcher itself never raises
|
|
13
|
+
# on a hook failure unless strict: true was passed to #publish.
|
|
14
|
+
FireReport = Data.define(:fired, :errored, :timed_out) do
|
|
15
|
+
def initialize(fired:, errored:, timed_out:)
|
|
16
|
+
super(fired: fired.dup.freeze, errored: errored.dup.freeze, timed_out: timed_out.dup.freeze)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ok? = errored.empty? && timed_out.empty?
|
|
20
|
+
def failures = errored + timed_out
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
3
|
class Loader
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(bus:)
|
|
5
|
+
@bus = bus
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def load_dir(dir)
|
|
@@ -18,7 +18,7 @@ module Textus
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
Textus.drain_hook_blocks.each do |blk|
|
|
21
|
-
blk.call(@
|
|
21
|
+
blk.call(@bus)
|
|
22
22
|
rescue StandardError, ScriptError => e
|
|
23
23
|
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
24
24
|
end
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Infra
|
|
5
5
|
# Writes an "event_error" audit row when a user hook raises during
|
|
6
|
-
# Hooks::
|
|
6
|
+
# Hooks::Bus publish. Attached at Store boot.
|
|
7
7
|
#
|
|
8
|
-
# Integration: uses Hooks::
|
|
9
|
-
# synthetic :hook_error event because the
|
|
10
|
-
# rescue and the failure is a
|
|
8
|
+
# Integration: uses Hooks::Bus#on_error callback (chosen over a
|
|
9
|
+
# synthetic :hook_error event because the bus already owns the
|
|
10
|
+
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
11
|
# event subscribers should be able to filter by key glob).
|
|
12
12
|
#
|
|
13
13
|
# NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
|