textus 0.15.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 +50 -55
- data/CHANGELOG.md +486 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +20 -34
- 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 +11 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +17 -6
- data/lib/textus/application/reads/get.rb +37 -11
- data/lib/textus/application/reads/get_or_refresh.rb +8 -8
- data/lib/textus/application/reads/list.rb +5 -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 +6 -3
- 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 +12 -3
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +6 -3
- data/lib/textus/application/refresh/all.rb +16 -5
- data/lib/textus/application/refresh/orchestrator.rb +9 -9
- data/lib/textus/application/refresh/worker.rb +59 -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 +36 -13
- data/lib/textus/application/writes/delete.rb +13 -15
- data/lib/textus/application/writes/envelope_io.rb +166 -0
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +56 -95
- data/lib/textus/application/writes/publish.rb +132 -27
- data/lib/textus/application/writes/put.rb +17 -20
- data/lib/textus/application/writes/reject.rb +18 -9
- 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/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -5
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +4 -2
- data/lib/textus/cli/verb/hook_run.rb +6 -4
- data/lib/textus/cli/verb/hooks.rb +8 -5
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +35 -3
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +4 -2
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +4 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- 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/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.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/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
- data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- 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 +20 -17
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +14 -11
- 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/resolution.rb +5 -0
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +4 -100
- data/lib/textus/operations.rb +147 -23
- data/lib/textus/schema/tools.rb +7 -7
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +31 -23
- data/lib/textus/application/writes/build.rb +0 -79
- 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 -63
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/hooks/registry.rb +0 -81
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
|
@@ -2,12 +2,14 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class List
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(prefix: nil, zone: nil)
|
|
10
|
-
@
|
|
10
|
+
rows = @manifest.resolver.enumerate(prefix: prefix)
|
|
11
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
12
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -4,12 +4,12 @@ module Textus
|
|
|
4
4
|
# For one key, surface every matching policy block along with the
|
|
5
5
|
# per-slot effective value (which loses ties win-by-specificity).
|
|
6
6
|
class PolicyExplain
|
|
7
|
-
def initialize(
|
|
8
|
-
@
|
|
7
|
+
def initialize(manifest:)
|
|
8
|
+
@manifest = manifest
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def call(key:)
|
|
12
|
-
policies = @
|
|
12
|
+
policies = @manifest.rules
|
|
13
13
|
matching = policies.explain(key)
|
|
14
14
|
winners = policies.for(key)
|
|
15
15
|
|
|
@@ -2,12 +2,14 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class Published
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call
|
|
10
|
-
@
|
|
10
|
+
@manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
|
|
11
|
+
{ "key" => e.key, "publish_to" => e.publish_to }
|
|
12
|
+
end
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -2,12 +2,24 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class Rdeps
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@
|
|
10
|
+
@manifest.entries.each_with_object([]) do |e, acc|
|
|
11
|
+
next unless e.is_a?(Textus::Manifest::Entry::Derived)
|
|
12
|
+
|
|
13
|
+
src = e.source
|
|
14
|
+
sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
15
|
+
Array(src.select).compact
|
|
16
|
+
elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
17
|
+
Array(src.sources).compact
|
|
18
|
+
else
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
|
|
22
|
+
end
|
|
11
23
|
end
|
|
12
24
|
end
|
|
13
25
|
end
|
|
@@ -2,12 +2,15 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class SchemaEnvelope
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:, schemas:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@schemas = schemas
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
def call(key)
|
|
10
|
-
@
|
|
11
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
12
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
13
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -2,12 +2,12 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class Stale
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(prefix: nil, zone: nil)
|
|
10
|
-
@
|
|
10
|
+
Textus::Domain::Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -2,12 +2,20 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class Uid
|
|
5
|
-
def initialize(ctx:)
|
|
6
|
-
@ctx
|
|
5
|
+
def initialize(ctx:, manifest:, file_store:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@file_store = file_store
|
|
7
9
|
end
|
|
8
10
|
|
|
9
11
|
def call(key)
|
|
10
|
-
|
|
12
|
+
get.get(key).uid
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def get
|
|
18
|
+
@get ||= Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store)
|
|
11
19
|
end
|
|
12
20
|
end
|
|
13
21
|
end
|
|
@@ -2,12 +2,21 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class ValidateAll
|
|
5
|
-
def initialize(ctx:)
|
|
6
|
-
@ctx
|
|
5
|
+
def initialize(ctx:, manifest:, file_store:, schemas:, audit_log:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@file_store = file_store
|
|
9
|
+
@schemas = schemas
|
|
10
|
+
@audit_log = audit_log
|
|
7
11
|
end
|
|
8
12
|
|
|
9
13
|
def call
|
|
10
|
-
|
|
14
|
+
Validator.new(
|
|
15
|
+
reader: Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store),
|
|
16
|
+
manifest: @manifest,
|
|
17
|
+
audit_log: @audit_log,
|
|
18
|
+
schema_for: ->(name) { @schemas.fetch_or_nil(name) },
|
|
19
|
+
).call
|
|
11
20
|
end
|
|
12
21
|
end
|
|
13
22
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
class Validator
|
|
5
|
+
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
6
|
+
@reader = reader
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@audit_log = audit_log
|
|
9
|
+
@schema_for = schema_for
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
violations = []
|
|
14
|
+
check_content_violations(violations)
|
|
15
|
+
check_role_authority_violations(violations)
|
|
16
|
+
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_content_violations(violations)
|
|
22
|
+
@manifest.resolver.enumerate.each do |row|
|
|
23
|
+
key = row[:key]
|
|
24
|
+
mentry = row[:manifest_entry]
|
|
25
|
+
env = fetch_envelope(key, violations) or next
|
|
26
|
+
schema = mentry.schema && @schema_for.call(mentry.schema)
|
|
27
|
+
next unless schema
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
validate_schema!(schema, env, mentry.format)
|
|
31
|
+
rescue Textus::Error => e
|
|
32
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_role_authority_violations(violations)
|
|
38
|
+
@manifest.resolver.enumerate.each do |row|
|
|
39
|
+
mentry = row[:manifest_entry]
|
|
40
|
+
next unless mentry.schema
|
|
41
|
+
|
|
42
|
+
schema = @schema_for.call(mentry.schema)
|
|
43
|
+
next unless schema
|
|
44
|
+
|
|
45
|
+
env = begin
|
|
46
|
+
@reader.get(row[:key])
|
|
47
|
+
rescue StandardError
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
append_authority_violations(violations, row[:key], env, schema)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append_authority_violations(violations, key, env, schema)
|
|
55
|
+
last_writer = @audit_log.last_writer_for(key)
|
|
56
|
+
return if last_writer.nil?
|
|
57
|
+
|
|
58
|
+
env.meta.each_key do |field|
|
|
59
|
+
owner = schema.maintained_by(field)
|
|
60
|
+
next if owner.nil? || last_writer == owner || last_writer == "human"
|
|
61
|
+
|
|
62
|
+
violations << { "key" => key, "code" => "role_authority",
|
|
63
|
+
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_envelope(key, violations)
|
|
68
|
+
@reader.get(key)
|
|
69
|
+
rescue Textus::Error => e
|
|
70
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_schema!(schema, envelope, format)
|
|
75
|
+
payload = case format
|
|
76
|
+
when "json", "yaml" then envelope.content || {}
|
|
77
|
+
else envelope.meta || {}
|
|
78
|
+
end
|
|
79
|
+
schema.validate!(payload)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -2,12 +2,15 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class Where
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@
|
|
10
|
+
res = @manifest.resolver.resolve(key)
|
|
11
|
+
mentry = res.entry
|
|
12
|
+
path = res.path
|
|
13
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Application
|
|
3
3
|
module Refresh
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
class All
|
|
5
|
+
def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@envelope_io = envelope_io
|
|
9
|
+
@bus = bus
|
|
10
|
+
@store = store
|
|
11
|
+
@authorizer = authorizer
|
|
12
|
+
@hook_context = hook_context
|
|
13
|
+
end
|
|
6
14
|
|
|
7
|
-
def call(
|
|
8
|
-
worker = Textus::Application::Refresh::Worker.new(
|
|
15
|
+
def call(prefix: nil, zone: nil)
|
|
16
|
+
worker = Textus::Application::Refresh::Worker.new(
|
|
17
|
+
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io, bus: @bus,
|
|
18
|
+
store: @store, authorizer: @authorizer, hook_context: @hook_context
|
|
19
|
+
)
|
|
9
20
|
|
|
10
|
-
stale_rows = Textus::Application::Reads::Stale.new(
|
|
21
|
+
stale_rows = Textus::Application::Reads::Stale.new(manifest: @manifest).call(prefix: prefix, zone: zone)
|
|
11
22
|
refreshed = []
|
|
12
23
|
failed = []
|
|
13
24
|
skipped = []
|
|
@@ -2,12 +2,13 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Refresh
|
|
4
4
|
class Orchestrator
|
|
5
|
-
def initialize(worker:,
|
|
6
|
-
@worker
|
|
7
|
-
@
|
|
8
|
-
@
|
|
9
|
-
@store
|
|
10
|
-
@
|
|
5
|
+
def initialize(worker:, store_root:, bus: nil, store: nil, ctx: nil, hook_context: nil, detached_spawner: nil) # rubocop:disable Metrics/ParameterLists
|
|
6
|
+
@worker = worker
|
|
7
|
+
@store_root = store_root
|
|
8
|
+
@bus = bus
|
|
9
|
+
@store = store
|
|
10
|
+
@ctx = ctx
|
|
11
|
+
@hook_context = hook_context
|
|
11
12
|
@detached_spawner = detached_spawner || default_spawner
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -56,10 +57,9 @@ module Textus
|
|
|
56
57
|
|
|
57
58
|
probe.release
|
|
58
59
|
|
|
59
|
-
store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
|
|
60
60
|
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
61
|
-
payload[:
|
|
62
|
-
@bus
|
|
61
|
+
payload[:ctx] = @hook_context if @hook_context
|
|
62
|
+
@bus&.publish(:refresh_backgrounded, **payload)
|
|
63
63
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
64
64
|
Textus::Domain::Outcome::Detached.new
|
|
65
65
|
elsif result.is_a?(Textus::Error)
|
|
@@ -6,14 +6,22 @@ module Textus
|
|
|
6
6
|
class Worker
|
|
7
7
|
FETCH_TIMEOUT_SECONDS = 30
|
|
8
8
|
|
|
9
|
-
def initialize(ctx:, bus:)
|
|
10
|
-
@ctx
|
|
11
|
-
@
|
|
9
|
+
def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
10
|
+
@ctx = ctx
|
|
11
|
+
@manifest = manifest
|
|
12
|
+
@envelope_io = envelope_io
|
|
13
|
+
@bus = bus
|
|
14
|
+
@store = store
|
|
15
|
+
@authorizer = authorizer
|
|
16
|
+
@hook_context = hook_context
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
def run(key)
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
res = @manifest.resolver.resolve(key)
|
|
21
|
+
mentry = res.entry
|
|
22
|
+
path = res.path
|
|
23
|
+
remaining = res.remaining
|
|
24
|
+
raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
17
25
|
|
|
18
26
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
19
27
|
result = fetch_with_bus(key, mentry, remaining)
|
|
@@ -22,19 +30,14 @@ module Textus
|
|
|
22
30
|
|
|
23
31
|
private
|
|
24
32
|
|
|
25
|
-
def read_view
|
|
26
|
-
Application::Context.new(store: @ctx.store, role: @ctx.role)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
33
|
def fetch_timeout_for(key)
|
|
30
|
-
rule = @
|
|
34
|
+
rule = @manifest.rules_for(key)
|
|
31
35
|
rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def fetch_with_bus(key, mentry, remaining)
|
|
35
|
-
callable = @
|
|
36
|
-
@bus.publish(:refresh_started,
|
|
37
|
-
correlation_id: @ctx.correlation_id)
|
|
39
|
+
callable = @bus.rpc_callable(:resolve_intake, mentry.handler)
|
|
40
|
+
@bus.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
|
|
38
41
|
call_intake(key, mentry, callable, remaining)
|
|
39
42
|
end
|
|
40
43
|
|
|
@@ -42,38 +45,38 @@ module Textus
|
|
|
42
45
|
timeout = fetch_timeout_for(key)
|
|
43
46
|
Timeout.timeout(timeout) do
|
|
44
47
|
callable.call(
|
|
45
|
-
store: @
|
|
46
|
-
config: mentry.
|
|
48
|
+
store: @store,
|
|
49
|
+
config: mentry.config,
|
|
47
50
|
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
48
51
|
)
|
|
49
52
|
end
|
|
50
53
|
rescue Timeout::Error
|
|
51
|
-
@bus.publish(:refresh_failed,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
raise UsageError.new("intake '#{mentry.
|
|
54
|
+
@bus.publish(:refresh_failed, ctx: @hook_context, key: key,
|
|
55
|
+
error_class: "Timeout::Error",
|
|
56
|
+
error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
|
|
57
|
+
raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
|
|
55
58
|
rescue Textus::Error => e
|
|
56
|
-
@bus.publish(:refresh_failed,
|
|
57
|
-
error_message: e.message
|
|
59
|
+
@bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
60
|
+
error_message: e.message)
|
|
58
61
|
raise
|
|
59
62
|
rescue StandardError => e
|
|
60
|
-
@bus.publish(:refresh_failed,
|
|
61
|
-
error_message: e.message
|
|
62
|
-
raise UsageError.new("intake '#{mentry.
|
|
63
|
+
@bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
64
|
+
error_message: e.message)
|
|
65
|
+
raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def persist_and_notify(key, mentry, result, before_etag)
|
|
66
|
-
normalized =
|
|
67
|
-
|
|
69
|
+
normalized = self.class.send(:normalize_action_result, result, format: mentry.format)
|
|
70
|
+
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
71
|
+
envelope = @envelope_io.write(
|
|
68
72
|
key,
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
mentry: mentry,
|
|
74
|
+
payload: Textus::Application::Writes::EnvelopeIO::Payload.new(
|
|
75
|
+
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
76
|
+
),
|
|
71
77
|
)
|
|
72
78
|
change = detect_change(before_etag, envelope)
|
|
73
|
-
unless change == :unchanged
|
|
74
|
-
@bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
75
|
-
correlation_id: @ctx.correlation_id)
|
|
76
|
-
end
|
|
79
|
+
@bus.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
77
80
|
envelope
|
|
78
81
|
end
|
|
79
82
|
|
|
@@ -83,6 +86,30 @@ module Textus
|
|
|
83
86
|
else :updated
|
|
84
87
|
end
|
|
85
88
|
end
|
|
89
|
+
|
|
90
|
+
def self.normalize_action_result(res, format:)
|
|
91
|
+
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
92
|
+
res ||= {}
|
|
93
|
+
meta_val = res["_meta"]
|
|
94
|
+
body = res["body"]
|
|
95
|
+
content = res["content"]
|
|
96
|
+
|
|
97
|
+
case format
|
|
98
|
+
when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
|
|
99
|
+
when "text" then { meta: {}, body: body.to_s, content: nil }
|
|
100
|
+
when "json", "yaml"
|
|
101
|
+
if !content.nil?
|
|
102
|
+
{ meta: meta_val || {}, body: nil, content: content }
|
|
103
|
+
elsif !body.nil?
|
|
104
|
+
{ meta: {}, body: body.to_s, content: nil }
|
|
105
|
+
else
|
|
106
|
+
raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
raise Textus::UsageError.new("unknown format #{format.inspect}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :normalize_action_result
|
|
86
113
|
end
|
|
87
114
|
end
|
|
88
115
|
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Tools
|
|
4
|
+
# Run-once helper that renames files/directories whose basenames don't
|
|
5
|
+
# conform to the strict key grammar (§3 of plan-1.2). Only walks
|
|
6
|
+
# nested: true manifest entries — leaf entries with illegal declared
|
|
7
|
+
# keys are caught by Manifest load and must be fixed by hand.
|
|
8
|
+
module MigrateKeys
|
|
9
|
+
SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns the envelope hash described in plan-1.2 §3.
|
|
14
|
+
def run(store, write: false)
|
|
15
|
+
plan = build_plan(store)
|
|
16
|
+
collisions = plan[:collisions]
|
|
17
|
+
renames = plan[:renames]
|
|
18
|
+
|
|
19
|
+
ok = collisions.empty?
|
|
20
|
+
apply!(store, renames) if write && ok
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"protocol" => Textus::PROTOCOL,
|
|
24
|
+
"mode" => write ? "write" : "dry-run",
|
|
25
|
+
"renames" => renames.map { |r| envelope_rename(r) },
|
|
26
|
+
"collisions" => collisions.map { |c| envelope_collision(c) },
|
|
27
|
+
"ok" => ok,
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Plan construction
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
# Returns { renames: [...], collisions: [...] }
|
|
36
|
+
# Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
|
|
37
|
+
# Each collision: { target:, sources: [...] }
|
|
38
|
+
def build_plan(store) # rubocop:disable Metrics/AbcSize
|
|
39
|
+
renames = []
|
|
40
|
+
target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
|
|
41
|
+
|
|
42
|
+
store.manifest.entries.each do |entry|
|
|
43
|
+
next unless entry.nested?
|
|
44
|
+
|
|
45
|
+
base = File.join(store.root, "zones", entry.path)
|
|
46
|
+
next unless File.directory?(base)
|
|
47
|
+
|
|
48
|
+
# Walk depth-first. Order matters when computing the "new key"
|
|
49
|
+
# for files inside a renamed directory: we record renames bottom-up,
|
|
50
|
+
# so children are renamed before their parents on apply.
|
|
51
|
+
walk(base) do |abs_path, is_dir|
|
|
52
|
+
next if abs_path == base
|
|
53
|
+
|
|
54
|
+
basename = File.basename(abs_path)
|
|
55
|
+
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
56
|
+
next if stem.match?(SEGMENT)
|
|
57
|
+
|
|
58
|
+
new_stem = normalize(stem)
|
|
59
|
+
# Skip if normalization yields the same stem (e.g. already-legal
|
|
60
|
+
# under a different lens). In practice match?(SEGMENT) catches that
|
|
61
|
+
# above; this is a safety net.
|
|
62
|
+
next if new_stem == stem
|
|
63
|
+
|
|
64
|
+
new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
|
|
65
|
+
target = File.join(File.dirname(abs_path), new_basename)
|
|
66
|
+
target_buckets[target] << abs_path
|
|
67
|
+
|
|
68
|
+
renames << {
|
|
69
|
+
from: abs_path,
|
|
70
|
+
to: target,
|
|
71
|
+
kind: is_dir ? :dir : :file,
|
|
72
|
+
entry: entry,
|
|
73
|
+
base: base,
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
|
|
79
|
+
.map { |t, srcs| { target: t, sources: srcs.sort } }
|
|
80
|
+
|
|
81
|
+
# Drop colliding entries from renames (we won't apply any of them)
|
|
82
|
+
colliding_targets = collisions.to_set { |c| c[:target] }
|
|
83
|
+
renames.reject! { |r| colliding_targets.include?(r[:to]) }
|
|
84
|
+
|
|
85
|
+
# Sort renames bottom-up (deepest path first) so children move before parents.
|
|
86
|
+
renames.sort_by! { |r| -r[:from].count("/") }
|
|
87
|
+
|
|
88
|
+
{ renames: renames, collisions: collisions }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Yields [absolute_path, is_dir] for every entry under root. Depth-first.
|
|
92
|
+
def walk(root, &block)
|
|
93
|
+
Dir.each_child(root) do |name|
|
|
94
|
+
abs = File.join(root, name)
|
|
95
|
+
if File.directory?(abs)
|
|
96
|
+
walk(abs, &block)
|
|
97
|
+
yield abs, true
|
|
98
|
+
else
|
|
99
|
+
yield abs, false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Deterministic transform per plan §3.
|
|
105
|
+
def normalize(s)
|
|
106
|
+
s = s.downcase
|
|
107
|
+
s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
|
|
108
|
+
s = s.gsub(/-+/, "-")
|
|
109
|
+
s.sub(/\A-+/, "").sub(/-+\z/, "")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Apply
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def apply!(store, renames)
|
|
117
|
+
audit = Textus::Infra::AuditLog.new(store.root)
|
|
118
|
+
renames.each do |r|
|
|
119
|
+
# Bottom-up order means a child's ancestors haven't moved yet, so
|
|
120
|
+
# `from`/`to` are valid as-recorded. The audit `key` reflects the
|
|
121
|
+
# eventual full key once every rename in this batch has applied.
|
|
122
|
+
from = r[:from]
|
|
123
|
+
to = r[:to]
|
|
124
|
+
File.rename(from, to)
|
|
125
|
+
new_key = compute_new_key(r, renames)
|
|
126
|
+
audit.append(
|
|
127
|
+
role: "runner",
|
|
128
|
+
verb: "migrate-keys",
|
|
129
|
+
key: new_key,
|
|
130
|
+
etag_before: nil,
|
|
131
|
+
etag_after: nil,
|
|
132
|
+
extras: { "from" => from, "to" => to },
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
|
|
138
|
+
def resolve_current_path(path, renames)
|
|
139
|
+
out = path
|
|
140
|
+
renames.each do |r|
|
|
141
|
+
prefix = r[:from] + "/"
|
|
142
|
+
out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
|
|
143
|
+
end
|
|
144
|
+
out
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# New full key after applying all renames up through this one.
|
|
148
|
+
def compute_new_key(rename, renames)
|
|
149
|
+
base = rename[:base]
|
|
150
|
+
entry = rename[:entry]
|
|
151
|
+
new_to = resolve_current_path(rename[:to], renames)
|
|
152
|
+
|
|
153
|
+
rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
154
|
+
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
|
|
155
|
+
stripped ||= rel
|
|
156
|
+
segs = stripped.split("/").reject(&:empty?)
|
|
157
|
+
(entry.key.split(".") + segs).join(".")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Envelope helpers
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def envelope_rename(r)
|
|
165
|
+
{
|
|
166
|
+
"from" => r[:from],
|
|
167
|
+
"to" => r[:to],
|
|
168
|
+
"old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
|
|
169
|
+
"new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def envelope_collision(col)
|
|
174
|
+
{ "target" => col[:target], "sources" => col[:sources] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def path_to_key(path, base, entry, kind)
|
|
178
|
+
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
179
|
+
stripped =
|
|
180
|
+
if kind == :dir
|
|
181
|
+
rel
|
|
182
|
+
else
|
|
183
|
+
rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
184
|
+
end
|
|
185
|
+
segs = stripped.split("/").reject(&:empty?)
|
|
186
|
+
(entry.key.split(".") + segs).join(".")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|