textus 0.18.0 → 0.20.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/ARCHITECTURE.md +43 -48
- data/CHANGELOG.md +238 -0
- data/SPEC.md +35 -2
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
- 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 +5 -3
- 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/writes/accept.rb +43 -16
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- 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 +25 -12
- 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 +4 -6
- 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/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 +7 -7
- 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/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -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 +51 -27
- 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 +58 -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 +112 -0
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +46 -2
- data/lib/textus/manifest.rb +24 -101
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +10 -3
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -14
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/cli/verb/key_normalize.rb +0 -19
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/manifest/resolution.rb +0 -5
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Base < Entry
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
|
|
6
|
+
|
|
7
|
+
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
+
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
|
|
9
|
+
@manifest = manifest
|
|
10
|
+
@raw = raw
|
|
11
|
+
@key = key
|
|
12
|
+
@path = path
|
|
13
|
+
@zone = zone
|
|
14
|
+
@schema = schema
|
|
15
|
+
@owner = owner
|
|
16
|
+
@format = format
|
|
17
|
+
end
|
|
18
|
+
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
|
+
|
|
20
|
+
def kind = self.class.name.split("::").last.downcase.to_sym
|
|
21
|
+
|
|
22
|
+
def zone_writers
|
|
23
|
+
@manifest.zone_writers(@zone)
|
|
24
|
+
rescue UsageError => e
|
|
25
|
+
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
|
|
29
|
+
def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
|
|
30
|
+
|
|
31
|
+
def nested? = false
|
|
32
|
+
def derived? = false
|
|
33
|
+
def intake? = false
|
|
34
|
+
def leaf? = false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Derived < Base
|
|
5
|
+
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
+
External = Data.define(:sources, :runner)
|
|
7
|
+
|
|
8
|
+
attr_reader :source, :template, :inject_intro, :publish_to, :events
|
|
9
|
+
|
|
10
|
+
def initialize(source:, template: nil, inject_intro: false, publish_to: [], events: {}, **rest)
|
|
11
|
+
super(**rest)
|
|
12
|
+
@source = source
|
|
13
|
+
@template = template
|
|
14
|
+
@inject_intro = inject_intro
|
|
15
|
+
@publish_to = Array(publish_to)
|
|
16
|
+
@events = events || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def derived? = true
|
|
20
|
+
def projection? = @source.is_a?(Projection)
|
|
21
|
+
def external? = @source.is_a?(External)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Intake < Base
|
|
5
|
+
attr_reader :handler, :config, :events, :publish_to
|
|
6
|
+
|
|
7
|
+
def initialize(handler:, config: {}, events: {}, **rest)
|
|
8
|
+
super(**rest)
|
|
9
|
+
@handler = handler
|
|
10
|
+
@config = config || {}
|
|
11
|
+
@events = events || {}
|
|
12
|
+
@publish_to = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def intake? = true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "validators/publish_each"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Manifest
|
|
5
|
+
class Entry
|
|
6
|
+
class Nested < Base
|
|
7
|
+
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
8
|
+
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
|
+
|
|
10
|
+
attr_reader :index_filename, :publish_each, :publish_to
|
|
11
|
+
|
|
12
|
+
def initialize(index_filename: nil, publish_each: nil, publish_to: [], **rest)
|
|
13
|
+
super(**rest)
|
|
14
|
+
@index_filename = index_filename
|
|
15
|
+
@publish_each = publish_each
|
|
16
|
+
@publish_to = Array(publish_to)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def nested? = true
|
|
20
|
+
|
|
21
|
+
def publish_target_for(full_key)
|
|
22
|
+
return nil if @publish_each.nil?
|
|
23
|
+
|
|
24
|
+
entry_segs = @key.split(".")
|
|
25
|
+
key_segs = full_key.split(".")
|
|
26
|
+
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
27
|
+
|
|
28
|
+
remaining = key_segs[entry_segs.length..] || []
|
|
29
|
+
leaf = remaining.join("/")
|
|
30
|
+
basename = remaining.last || ""
|
|
31
|
+
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
32
|
+
|
|
33
|
+
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
34
|
+
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -9,61 +9,88 @@ module Textus
|
|
|
9
9
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
10
10
|
zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
format = resolve_format(raw, path, nested)
|
|
12
|
+
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (leaf|nested|derived|intake)")
|
|
13
|
+
kind = raw_kind.to_sym
|
|
14
|
+
format = resolve_format(raw, path)
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
common = {
|
|
18
17
|
manifest: manifest, raw: raw,
|
|
19
18
|
key: key, path: path, zone: zone,
|
|
20
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
format: format
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case kind
|
|
24
|
+
when :leaf then build_leaf(common, raw)
|
|
25
|
+
when :nested then build_nested(common, raw)
|
|
26
|
+
when :derived then build_derived(common, raw, key)
|
|
27
|
+
when :intake then build_intake(common, raw, key)
|
|
28
|
+
else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build_leaf(common, raw)
|
|
33
|
+
Leaf.new(publish_to: raw["publish_to"], **common)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.build_nested(common, raw)
|
|
37
|
+
Nested.new(
|
|
38
|
+
index_filename: raw["index_filename"],
|
|
24
39
|
publish_each: raw["publish_each"],
|
|
25
|
-
|
|
40
|
+
publish_to: raw["publish_to"],
|
|
41
|
+
**common,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.build_derived(common, raw, key)
|
|
46
|
+
source = parse_source(raw, key)
|
|
47
|
+
Derived.new(
|
|
48
|
+
source: source,
|
|
49
|
+
template: raw["template"],
|
|
26
50
|
inject_intro: raw["inject_intro"] == true,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
intake_handler: intake_handler, intake_config: intake_config
|
|
51
|
+
publish_to: raw["publish_to"],
|
|
52
|
+
events: raw["events"] || {},
|
|
53
|
+
**common,
|
|
31
54
|
)
|
|
32
55
|
end
|
|
33
56
|
|
|
34
|
-
def self.
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
def self.build_intake(common, raw, key)
|
|
58
|
+
intake = raw["intake"] || {}
|
|
59
|
+
handler = intake["handler"] || raw["intake_handler"] or
|
|
60
|
+
raise UsageError.new("intake entry '#{key}' missing handler")
|
|
61
|
+
config = intake["config"] || raw["intake_config"] || {}
|
|
62
|
+
Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.parse_source(raw, key)
|
|
66
|
+
compute = raw["compute"]
|
|
67
|
+
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
|
|
37
68
|
|
|
38
|
-
|
|
39
|
-
unless COMPUTE_KINDS.include?(kind)
|
|
69
|
+
unless COMPUTE_KINDS.include?(compute["kind"])
|
|
40
70
|
raise BadManifest.new(
|
|
41
|
-
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
|
|
71
|
+
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
|
|
42
72
|
)
|
|
43
73
|
end
|
|
44
74
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
75
|
+
if compute["kind"] == "projection"
|
|
76
|
+
Derived::Projection.new(
|
|
77
|
+
select: compute["select"],
|
|
78
|
+
pluck: compute["pluck"],
|
|
79
|
+
sort_by: compute["sort_by"],
|
|
80
|
+
transform: compute["transform"],
|
|
81
|
+
)
|
|
48
82
|
else
|
|
49
|
-
[
|
|
83
|
+
Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
50
84
|
end
|
|
51
85
|
end
|
|
52
86
|
|
|
53
|
-
def self.
|
|
54
|
-
src ||= {}
|
|
55
|
-
[src["handler"], src["config"] || {}]
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def self.resolve_format(raw, path, nested)
|
|
87
|
+
def self.resolve_format(raw, path)
|
|
59
88
|
declared = raw["format"]
|
|
60
89
|
ext = File.extname(path)
|
|
61
90
|
inferred = Textus::Entry.infer_from_extension(ext)
|
|
62
91
|
|
|
63
92
|
if declared.nil?
|
|
64
93
|
return inferred if inferred
|
|
65
|
-
return "markdown" if ext == "" && nested
|
|
66
|
-
return "markdown" if ext == ""
|
|
67
94
|
|
|
68
95
|
return "markdown"
|
|
69
96
|
end
|
|
@@ -4,8 +4,9 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
8
|
-
entry.events.
|
|
7
|
+
pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
|
|
8
|
+
events = entry.respond_to?(:events) ? entry.events : {}
|
|
9
|
+
events.each_key do |evt|
|
|
9
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
10
11
|
|
|
11
12
|
raise UsageError.new(
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
module FormatMatrix
|
|
6
6
|
def self.call(entry)
|
|
7
7
|
begin
|
|
8
|
-
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested)
|
|
8
|
+
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
|
|
9
9
|
rescue UsageError => e
|
|
10
10
|
raise UsageError.new("entry '#{entry.key}': #{e.message}")
|
|
11
11
|
end
|
|
@@ -14,8 +14,10 @@ module Textus
|
|
|
14
14
|
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
18
|
+
is_external = entry.derived? && entry.external?
|
|
19
|
+
return unless entry.in_generator_zone? && !has_template && !is_external &&
|
|
20
|
+
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
19
21
|
|
|
20
22
|
raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
|
|
21
23
|
end
|
|
@@ -4,31 +4,32 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : entry.raw["index_filename"]
|
|
8
|
+
return if index_filename.nil?
|
|
8
9
|
|
|
9
|
-
check_shape!(entry)
|
|
10
|
-
check_extension!(entry)
|
|
10
|
+
check_shape!(entry, index_filename)
|
|
11
|
+
check_extension!(entry, index_filename)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def self.check_shape!(entry)
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested
|
|
14
|
+
def self.check_shape!(entry, index_filename)
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested?
|
|
15
16
|
|
|
16
|
-
unless
|
|
17
|
+
unless index_filename.is_a?(String) && !index_filename.empty?
|
|
17
18
|
raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
return unless
|
|
21
|
+
return unless index_filename.include?("/") || File.basename(index_filename) != index_filename
|
|
21
22
|
|
|
22
23
|
raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def self.check_extension!(entry)
|
|
26
|
-
ext = File.extname(
|
|
26
|
+
def self.check_extension!(entry, index_filename)
|
|
27
|
+
ext = File.extname(index_filename)
|
|
27
28
|
inferred = Textus::Entry.infer_from_extension(ext)
|
|
28
29
|
|
|
29
30
|
if inferred.nil?
|
|
30
31
|
raise UsageError.new(
|
|
31
|
-
"entry '#{entry.key}': index_filename #{
|
|
32
|
+
"entry '#{entry.key}': index_filename #{index_filename.inspect} has unknown extension #{ext.inspect}",
|
|
32
33
|
)
|
|
33
34
|
end
|
|
34
35
|
return if inferred == entry.format
|
|
@@ -4,10 +4,13 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module InjectIntro
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
inject_intro = entry.respond_to?(:inject_intro) ? entry.inject_intro : false
|
|
8
|
+
return unless inject_intro
|
|
8
9
|
|
|
9
10
|
raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
13
|
+
return if has_template
|
|
11
14
|
|
|
12
15
|
raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
|
|
13
16
|
end
|
|
@@ -7,14 +7,17 @@ module Textus
|
|
|
7
7
|
VAR_RE = /\{([a-z]+)\}/
|
|
8
8
|
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
9
9
|
|
|
10
|
-
def self.call(entry)
|
|
11
|
-
|
|
10
|
+
def self.call(entry) # rubocop:disable Metrics/AbcSize
|
|
11
|
+
publish_each = entry.respond_to?(:publish_each) ? entry.publish_each : entry.raw["publish_each"]
|
|
12
|
+
return if publish_each.nil?
|
|
12
13
|
|
|
13
|
-
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless entry.publish_to.empty?
|
|
15
|
-
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless entry.publish_each.is_a?(String)
|
|
14
|
+
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
publish_to = entry.respond_to?(:publish_to) ? entry.publish_to : Array(entry.raw["publish_to"])
|
|
17
|
+
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
|
|
18
|
+
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
19
|
+
|
|
20
|
+
used_vars = publish_each.scan(VAR_RE).flatten
|
|
18
21
|
unknown = used_vars - KNOWN_VARS
|
|
19
22
|
unless unknown.empty?
|
|
20
23
|
raise UsageError.new(
|
|
@@ -5,78 +5,6 @@ module Textus
|
|
|
5
5
|
# constants on Entry. Canonical source is the PublishEach validator.
|
|
6
6
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
7
7
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
8
|
-
|
|
9
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :nested,
|
|
10
|
-
:template, :publish_to, :publish_each,
|
|
11
|
-
:events, :inject_intro, :index_filename, :format,
|
|
12
|
-
:compute, :projection, :generator,
|
|
13
|
-
:intake_handler, :intake_config
|
|
14
|
-
|
|
15
|
-
# rubocop:disable Metrics/ParameterLists
|
|
16
|
-
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, nested:,
|
|
17
|
-
template:, publish_to:, publish_each:, events:, inject_intro:,
|
|
18
|
-
index_filename:, format:, compute:, projection:, generator:,
|
|
19
|
-
intake_handler:, intake_config:)
|
|
20
|
-
@manifest = manifest
|
|
21
|
-
@raw = raw
|
|
22
|
-
@key = key
|
|
23
|
-
@path = path
|
|
24
|
-
@zone = zone
|
|
25
|
-
@schema = schema
|
|
26
|
-
@owner = owner
|
|
27
|
-
@nested = nested
|
|
28
|
-
@template = template
|
|
29
|
-
@publish_to = publish_to
|
|
30
|
-
@publish_each = publish_each
|
|
31
|
-
@events = events
|
|
32
|
-
@inject_intro = inject_intro
|
|
33
|
-
@index_filename = index_filename
|
|
34
|
-
@format = format
|
|
35
|
-
@compute = compute
|
|
36
|
-
@projection = projection
|
|
37
|
-
@generator = generator
|
|
38
|
-
@intake_handler = intake_handler
|
|
39
|
-
@intake_config = intake_config
|
|
40
|
-
end
|
|
41
|
-
# rubocop:enable Metrics/ParameterLists
|
|
42
|
-
|
|
43
|
-
# Resolves the per-leaf target path (relative to repo root) for a full
|
|
44
|
-
# dotted key under this entry's prefix. Returns nil if this entry has no
|
|
45
|
-
# publish_each template.
|
|
46
|
-
def publish_target_for(full_key)
|
|
47
|
-
return nil if @publish_each.nil?
|
|
48
|
-
|
|
49
|
-
entry_segs = @key.split(".")
|
|
50
|
-
key_segs = full_key.split(".")
|
|
51
|
-
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
52
|
-
|
|
53
|
-
remaining = key_segs[entry_segs.length..] || []
|
|
54
|
-
leaf = remaining.join("/")
|
|
55
|
-
basename = remaining.last || ""
|
|
56
|
-
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
57
|
-
|
|
58
|
-
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
59
|
-
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Signal-based zone-kind predicates: derive the "kind" of a zone from its
|
|
63
|
-
# write_policy signals rather than its literal name, so detection keeps
|
|
64
|
-
# working when users rename the default zones.
|
|
65
|
-
def in_generator_zone?
|
|
66
|
-
zone_writers.include?("builder")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def in_proposal_zone?
|
|
70
|
-
zone_writers.include?("agent")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
def zone_writers
|
|
76
|
-
@manifest.zone_writers(@zone)
|
|
77
|
-
rescue UsageError => e
|
|
78
|
-
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
79
|
-
end
|
|
80
8
|
end
|
|
81
9
|
end
|
|
82
10
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Resolver
|
|
4
|
+
Resolution = Data.define(:entry, :path, :remaining)
|
|
5
|
+
|
|
6
|
+
def initialize(manifest)
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def resolve(key)
|
|
11
|
+
@manifest.validate_key!(key)
|
|
12
|
+
segments = key.split(".")
|
|
13
|
+
candidates = @manifest.entries
|
|
14
|
+
.map { |e| [e, e.key.split(".")] }
|
|
15
|
+
.select { |(_, esegs)| esegs == segments[0, esegs.length] }
|
|
16
|
+
.sort_by { |(_, esegs)| -esegs.length }
|
|
17
|
+
raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
|
|
18
|
+
|
|
19
|
+
entry, esegs = candidates.first
|
|
20
|
+
remaining = segments[esegs.length..]
|
|
21
|
+
build_resolution(entry, remaining, key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def suggestions_for(key)
|
|
25
|
+
candidates = enumerate.map { |r| r[:key] }
|
|
26
|
+
candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
|
|
27
|
+
candidates.uniq!
|
|
28
|
+
Key::Distance.suggest(key, candidates, limit: 5)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enumerate(prefix: nil)
|
|
34
|
+
out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
|
|
35
|
+
out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
|
|
36
|
+
out.sort_by { |row| row[:key] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Returns true for entries that behave as nested (Nested subclass, or any
|
|
42
|
+
# entry with nested: true in the raw YAML — e.g. Intake entries covering
|
|
43
|
+
# a directory of leaf files).
|
|
44
|
+
def nested_entry?(entry)
|
|
45
|
+
entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_resolution(entry, remaining, key)
|
|
49
|
+
if remaining.empty?
|
|
50
|
+
Resolution.new(entry: entry, path: resolve_leaf_path(entry), remaining: [])
|
|
51
|
+
else
|
|
52
|
+
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
|
|
53
|
+
|
|
54
|
+
index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
55
|
+
path = if index_fn
|
|
56
|
+
File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
|
|
57
|
+
else
|
|
58
|
+
primary_ext = Textus::Entry.for_format(entry.format).extensions.first
|
|
59
|
+
File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
|
|
60
|
+
end
|
|
61
|
+
Resolution.new(entry: entry, path: path, remaining: remaining)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enumerate_leaf(entry)
|
|
66
|
+
fp = resolve_leaf_path(entry)
|
|
67
|
+
File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def enumerate_nested(entry)
|
|
71
|
+
base = File.join(@manifest.root, "zones", entry.path)
|
|
72
|
+
return [] unless File.directory?(base)
|
|
73
|
+
|
|
74
|
+
entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
75
|
+
glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
|
|
76
|
+
Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def nested_row_for(entry, base, path)
|
|
80
|
+
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
81
|
+
entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
82
|
+
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
83
|
+
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
84
|
+
return nil if segs.empty?
|
|
85
|
+
|
|
86
|
+
illegal = segs.find { |s| !valid_segment?(s) }
|
|
87
|
+
if illegal
|
|
88
|
+
warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
|
|
89
|
+
"rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
|
|
90
|
+
return nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{ key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def valid_segment?(seg)
|
|
97
|
+
return false if seg.nil? || seg.empty?
|
|
98
|
+
return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
|
|
99
|
+
|
|
100
|
+
seg.match?(Key::Grammar::SEGMENT)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve_leaf_path(entry)
|
|
104
|
+
Textus::Key::Path.resolve(@manifest, entry)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def nested_glob(format)
|
|
108
|
+
Textus::Entry.for_format(format).nested_glob
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
module RoleKinds
|
|
4
|
+
DEFAULT_MAPPING = {
|
|
5
|
+
"human" => :accept_authority,
|
|
6
|
+
"agent" => :proposer,
|
|
7
|
+
"builder" => :generator,
|
|
8
|
+
"runner" => :runner,
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
# Returns { role_name => kind_symbol }. When `roles:` is declared we use
|
|
12
|
+
# exactly that; defaults are *not* layered in (declaring roles is an opt-in
|
|
13
|
+
# to a fully user-defined vocabulary).
|
|
14
|
+
def self.resolve(raw_roles)
|
|
15
|
+
return DEFAULT_MAPPING if raw_roles.nil?
|
|
16
|
+
|
|
17
|
+
raw_roles.to_h { |r| [r["name"], r["kind"].to_sym] }.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
module Schema
|
|
4
|
-
ROOT_KEYS = %w[version zones entries rules].freeze
|
|
4
|
+
ROOT_KEYS = %w[version roles zones entries rules].freeze
|
|
5
|
+
ROLE_KEYS = %w[name kind].freeze
|
|
6
|
+
ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
|
|
5
7
|
ZONE_KEYS = %w[name write_policy read_policy].freeze
|
|
6
8
|
ENTRY_KEYS = %w[
|
|
7
|
-
key path zone schema owner nested format
|
|
9
|
+
key path zone kind schema owner nested format
|
|
8
10
|
compute template publish_to publish_each
|
|
9
11
|
intake events inject_intro index_filename
|
|
10
12
|
].freeze
|
|
@@ -19,6 +21,7 @@ module Textus
|
|
|
19
21
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
20
22
|
|
|
21
23
|
walk(raw, ROOT_KEYS, "$")
|
|
24
|
+
validate_roles!(raw["roles"])
|
|
22
25
|
Array(raw["zones"]).each_with_index do |z, i|
|
|
23
26
|
walk(z, ZONE_KEYS, "$.zones[#{i}]")
|
|
24
27
|
end
|
|
@@ -37,6 +40,47 @@ module Textus
|
|
|
37
40
|
end
|
|
38
41
|
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
39
42
|
end
|
|
43
|
+
validate_zone_writers_declared!(raw)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.validate_zone_writers_declared!(raw)
|
|
47
|
+
return if raw["roles"].nil? # default mapping is permissive
|
|
48
|
+
|
|
49
|
+
declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
|
|
50
|
+
Array(raw["zones"]).each do |z|
|
|
51
|
+
Array(z["write_policy"]).each_with_index do |w, j|
|
|
52
|
+
next if declared.include?(w)
|
|
53
|
+
|
|
54
|
+
raise BadManifest.new(
|
|
55
|
+
"zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
|
|
56
|
+
"(declared roles: #{declared.to_a.join(", ")})",
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.validate_roles!(roles)
|
|
63
|
+
return if roles.nil?
|
|
64
|
+
raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
|
|
65
|
+
|
|
66
|
+
accept_authority_count = 0
|
|
67
|
+
roles.each_with_index do |r, i|
|
|
68
|
+
path = "$.roles[#{i}]"
|
|
69
|
+
walk(r, ROLE_KEYS, path)
|
|
70
|
+
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
71
|
+
kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
|
|
72
|
+
unless ROLE_KINDS.include?(kind)
|
|
73
|
+
raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
accept_authority_count += 1 if kind == "accept_authority"
|
|
77
|
+
end
|
|
78
|
+
return unless accept_authority_count > 1
|
|
79
|
+
|
|
80
|
+
raise BadManifest.new(
|
|
81
|
+
"manifest declares #{accept_authority_count} accept_authority roles; " \
|
|
82
|
+
"at most one accept_authority role is allowed",
|
|
83
|
+
)
|
|
40
84
|
end
|
|
41
85
|
|
|
42
86
|
def self.validate_fetch_timeout!(value, path)
|