textus 0.22.0 → 0.29.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 +195 -48
- data/CHANGELOG.md +178 -0
- data/README.md +55 -13
- data/SPEC.md +79 -42
- data/docs/conventions.md +10 -0
- data/lib/textus/boot.rb +31 -29
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +3 -3
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- 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 +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +7 -7
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +11 -9
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +12 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +6 -6
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +20 -12
- data/lib/textus/domain/staleness.rb +4 -4
- data/lib/textus/envelope/io/reader.rb +44 -0
- data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +79 -0
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +8 -9
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +47 -110
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +126 -0
- data/lib/textus/mcp/session.rb +40 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/store.rb +25 -11
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +7 -1
- metadata +75 -46
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/projection.rb +0 -91
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/validator.rb +0 -86
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/orchestrator.rb +0 -78
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/authority_gate.rb +0 -26
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/materializer.rb +0 -50
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Maintenance
|
|
3
|
+
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
|
+
# Calls Write::Mv directly for each entry — emits one audit row per file moved.
|
|
5
|
+
class KeyMvPrefix
|
|
6
|
+
def initialize(container:, call:)
|
|
7
|
+
@container = container
|
|
8
|
+
@call = call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(from_prefix:, to_prefix:, dry_run: false)
|
|
12
|
+
raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
|
|
13
|
+
|
|
14
|
+
leaves = list_leaves_under(from_prefix)
|
|
15
|
+
warnings = []
|
|
16
|
+
warnings << "no keys under #{from_prefix}" if leaves.empty?
|
|
17
|
+
|
|
18
|
+
steps = leaves.map do |old_key|
|
|
19
|
+
tail = old_key.delete_prefix("#{from_prefix}.")
|
|
20
|
+
new_key = "#{to_prefix}.#{tail}"
|
|
21
|
+
{ "op" => "mv", "from" => old_key, "to" => new_key }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
plan = Plan.new(steps: steps, warnings: warnings)
|
|
25
|
+
return plan if dry_run
|
|
26
|
+
|
|
27
|
+
steps.each do |s|
|
|
28
|
+
mv.call(s["from"], s["to"], dry_run: false)
|
|
29
|
+
end
|
|
30
|
+
plan
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def list_leaves_under(prefix)
|
|
36
|
+
Read::List.new(container: @container)
|
|
37
|
+
.call(prefix: prefix)
|
|
38
|
+
.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mv
|
|
42
|
+
Write::Mv.new(container: @container, call: @call)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Maintenance
|
|
5
|
+
# Loads a YAML migration plan and dispatches each op to the
|
|
6
|
+
# appropriate Maintenance use case. Concatenates resulting Plans.
|
|
7
|
+
class Migrate
|
|
8
|
+
def initialize(container:, call:)
|
|
9
|
+
@container = container
|
|
10
|
+
@call = call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(plan_yaml:, dry_run: false)
|
|
14
|
+
raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
|
|
15
|
+
raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
ops = Array(raw["operations"])
|
|
18
|
+
all_steps = []
|
|
19
|
+
warnings = []
|
|
20
|
+
|
|
21
|
+
ops.each do |op_hash|
|
|
22
|
+
op_name = op_hash["op"]
|
|
23
|
+
sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
|
|
24
|
+
all_steps.concat(sub_plan.steps)
|
|
25
|
+
warnings.concat(sub_plan.warnings)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Plan.new(steps: all_steps, warnings: warnings)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def invoke_op(op_name, op_hash, dry_run:)
|
|
34
|
+
kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
|
|
35
|
+
klass = op_class(op_name)
|
|
36
|
+
klass.new(
|
|
37
|
+
container: @container, call: @call,
|
|
38
|
+
).call(**kwargs)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def op_class(op_name)
|
|
42
|
+
case op_name
|
|
43
|
+
when "key_mv_prefix" then KeyMvPrefix
|
|
44
|
+
when "key_delete_prefix" then KeyDeletePrefix
|
|
45
|
+
when "zone_mv" then ZoneMv
|
|
46
|
+
else raise UsageError.new("unknown op: #{op_name}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Maintenance
|
|
5
|
+
# Compare the live manifest's `rules:` block against a candidate
|
|
6
|
+
# YAML string. Returns a Plan describing rule additions/removals/
|
|
7
|
+
# changes. Does NOT write anything.
|
|
8
|
+
class RuleLint
|
|
9
|
+
def initialize(container:, call:)
|
|
10
|
+
@container = container
|
|
11
|
+
@call = call
|
|
12
|
+
@root = container.root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(candidate_yaml:)
|
|
16
|
+
live_rules = current_rules
|
|
17
|
+
candidate_rules = parse_candidate(candidate_yaml)
|
|
18
|
+
|
|
19
|
+
live_by_match = live_rules.to_h { |r| [r["match"], r] }
|
|
20
|
+
candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
|
|
21
|
+
|
|
22
|
+
steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
|
|
23
|
+
{ "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
|
|
24
|
+
end
|
|
25
|
+
(live_by_match.keys - candidate_by_match.keys).each do |m|
|
|
26
|
+
steps << { "op" => "remove_rule", "match" => m }
|
|
27
|
+
end
|
|
28
|
+
(live_by_match.keys & candidate_by_match.keys).each do |m|
|
|
29
|
+
next if live_by_match[m] == candidate_by_match[m]
|
|
30
|
+
|
|
31
|
+
steps << { "op" => "change_rule", "match" => m,
|
|
32
|
+
"from" => live_by_match[m], "to" => candidate_by_match[m] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Plan.new(steps: steps, warnings: [])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def current_rules
|
|
41
|
+
raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
|
|
42
|
+
permitted_classes: [Symbol], aliases: false)
|
|
43
|
+
Array(raw["rules"])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_candidate(yaml_text)
|
|
47
|
+
raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
|
|
48
|
+
raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
Array(raw["rules"])
|
|
51
|
+
rescue Psych::Exception => e
|
|
52
|
+
raise UsageError.new("candidate YAML parse error: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Maintenance
|
|
5
|
+
# Rename a zone — rewrites the manifest's zones[] entry, rewrites
|
|
6
|
+
# the `zone:` field on every entry under the old zone, and moves
|
|
7
|
+
# every file from zones/<old>/ to zones/<new>/.
|
|
8
|
+
class ZoneMv
|
|
9
|
+
def initialize(container:, call:)
|
|
10
|
+
@container = container
|
|
11
|
+
@call = call
|
|
12
|
+
@manifest = container.manifest
|
|
13
|
+
@root = container.root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(from:, to:, dry_run: false)
|
|
17
|
+
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
18
|
+
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
|
|
19
|
+
|
|
20
|
+
dest_dir = File.join(@root, "zones", to)
|
|
21
|
+
raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
|
|
22
|
+
|
|
23
|
+
affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
|
|
24
|
+
|
|
25
|
+
steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
|
|
26
|
+
steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
|
|
27
|
+
|
|
28
|
+
plan = Plan.new(steps: steps, warnings: [])
|
|
29
|
+
return plan if dry_run
|
|
30
|
+
|
|
31
|
+
rewrite_manifest!(from, to)
|
|
32
|
+
FileUtils.mv(File.join(@root, "zones", from), dest_dir)
|
|
33
|
+
plan
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def rewrite_manifest!(from, to)
|
|
39
|
+
path = File.join(@root, "manifest.yaml")
|
|
40
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
41
|
+
raw["zones"].each { |z| z["name"] = to if z["name"] == from }
|
|
42
|
+
raw["entries"].each do |e|
|
|
43
|
+
e["zone"] = to if e["zone"] == from
|
|
44
|
+
e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
|
|
45
|
+
e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
|
|
46
|
+
end
|
|
47
|
+
File.write(path, YAML.dump(raw))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Bulk and structural changes to a textus store. Each use case returns
|
|
3
|
+
# a Plan when called with dry_run: true, and applies the plan when
|
|
4
|
+
# called with dry_run: false.
|
|
5
|
+
module Maintenance
|
|
6
|
+
# A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
|
|
7
|
+
# use case knows how to apply. Warnings are strings surfaced to
|
|
8
|
+
# the operator (skipped keys, ambiguities).
|
|
9
|
+
Plan = Data.define(:steps, :warnings) do
|
|
10
|
+
def to_h
|
|
11
|
+
{ "steps" => steps, "warnings" => warnings }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require_relative "schema"
|
|
2
|
+
require_relative "role_kinds"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Manifest
|
|
6
|
+
# Immutable, parsed view of a manifest YAML document.
|
|
7
|
+
#
|
|
8
|
+
# Holds raw structural data (zones, entries, audit_config, role_mapping)
|
|
9
|
+
# but no behaviour beyond accessors. Behaviour (zone authority, key
|
|
10
|
+
# resolution, rules) lives on Manifest::Policy / Resolver / Rules.
|
|
11
|
+
class Data
|
|
12
|
+
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
|
|
15
|
+
|
|
16
|
+
def self.validate_key!(key)
|
|
17
|
+
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
18
|
+
|
|
19
|
+
Key::Grammar.validate!(key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Forwarder used by Resolver and Entry classes that received a Data
|
|
23
|
+
# but were written against the historical Manifest API.
|
|
24
|
+
def validate_key!(key) = self.class.validate_key!(key)
|
|
25
|
+
|
|
26
|
+
def self.parse(raw, root:)
|
|
27
|
+
raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
|
|
28
|
+
|
|
29
|
+
Schema.validate!(raw)
|
|
30
|
+
new(raw: raw, root: root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(raw:, root:)
|
|
34
|
+
@raw = raw
|
|
35
|
+
@root = root
|
|
36
|
+
@zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
|
|
37
|
+
@zone_readers = Array(raw["zones"]).to_h do |z|
|
|
38
|
+
rp = z["read_policy"]
|
|
39
|
+
[z["name"], rp.nil? ? :all : Array(rp)]
|
|
40
|
+
end
|
|
41
|
+
@audit_config = build_audit_config(raw)
|
|
42
|
+
@role_mapping = RoleKinds.resolve(raw["roles"])
|
|
43
|
+
# Policy is constructed before entries because Entry validators
|
|
44
|
+
# call `entry.in_generator_zone?(policy)` and similar helpers
|
|
45
|
+
# that take Policy as an argument.
|
|
46
|
+
@policy = Policy.new(self)
|
|
47
|
+
@entries = build_entries(raw)
|
|
48
|
+
validate_declared_keys!
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_audit_config(raw)
|
|
55
|
+
a = raw["audit"] || {}
|
|
56
|
+
{
|
|
57
|
+
max_size: a["max_size"] || AUDIT_DEFAULTS[:max_size],
|
|
58
|
+
keep: a["keep"] || AUDIT_DEFAULTS[:keep],
|
|
59
|
+
}.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_entries(raw)
|
|
63
|
+
Array(raw["entries"]).map do |e|
|
|
64
|
+
entry = Manifest::Entry::Parser.call(e)
|
|
65
|
+
Manifest::Entry::Validators.run_all(entry, policy: @policy)
|
|
66
|
+
entry
|
|
67
|
+
end.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_declared_keys!
|
|
71
|
+
@entries.each do |e|
|
|
72
|
+
raise UsageError.new("empty key") if e.key.nil? || e.key.empty?
|
|
73
|
+
|
|
74
|
+
Key::Grammar.validate!(e.key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -2,11 +2,10 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Base < Entry
|
|
5
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_to
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
-
def initialize(
|
|
9
|
-
@manifest = manifest
|
|
8
|
+
def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
|
|
10
9
|
@raw = raw
|
|
11
10
|
@key = key
|
|
12
11
|
@path = path
|
|
@@ -18,14 +17,14 @@ module Textus
|
|
|
18
17
|
end
|
|
19
18
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
20
19
|
|
|
21
|
-
def zone_writers
|
|
22
|
-
|
|
20
|
+
def zone_writers(policy)
|
|
21
|
+
policy.zone_writers(@zone)
|
|
23
22
|
rescue UsageError => e
|
|
24
23
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def in_generator_zone? =
|
|
28
|
-
def in_proposal_zone? =
|
|
26
|
+
def in_generator_zone?(policy) = policy.zone_kinds(@zone).include?(:generator)
|
|
27
|
+
def in_proposal_zone?(policy) = policy.zone_kinds(@zone).include?(:proposer)
|
|
29
28
|
|
|
30
29
|
def nested? = false
|
|
31
30
|
def derived? = false
|
|
@@ -41,11 +40,32 @@ module Textus
|
|
|
41
40
|
def publish_each = nil
|
|
42
41
|
def index_filename = nil
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
43
|
+
# Minimal context object passed into entry `publish_via` hooks.
|
|
44
|
+
# Everything beyond the three primitives is derived. Data.define
|
|
45
|
+
# instances are frozen, so we recompute per-call rather than
|
|
46
|
+
# memoizing — RoleScope/Hooks::Context construction is cheap.
|
|
47
|
+
PublishContext = ::Data.define(:container, :call, :reader) do
|
|
48
|
+
def manifest = container.manifest
|
|
49
|
+
def root = container.root
|
|
50
|
+
def repo_root = File.dirname(container.root)
|
|
51
|
+
def events = container.events
|
|
52
|
+
|
|
53
|
+
def hook_context
|
|
54
|
+
Textus::Hooks::Context.new(scope: scope_for_hooks)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def emit(event, **payload)
|
|
58
|
+
events.publish(event, ctx: hook_context, **payload)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def scope_for_hooks
|
|
64
|
+
Textus::RoleScope.new(
|
|
65
|
+
container: container, role: call.role, dry_run: call.dry_run,
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
49
69
|
|
|
50
70
|
# Subclasses override to customize publish behavior.
|
|
51
71
|
# Default: copy the stored file to each publish_to target.
|
|
@@ -59,12 +79,12 @@ module Textus
|
|
|
59
79
|
|
|
60
80
|
publish_to.each do |rel|
|
|
61
81
|
target_abs = File.join(pctx.repo_root, rel)
|
|
62
|
-
Textus::
|
|
63
|
-
pctx.emit
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
83
|
+
pctx.emit(:file_published,
|
|
84
|
+
key: @key,
|
|
85
|
+
envelope: envelope,
|
|
86
|
+
source: source_path,
|
|
87
|
+
target: target_abs)
|
|
68
88
|
end
|
|
69
89
|
|
|
70
90
|
{ kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
|
|
@@ -2,8 +2,8 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Derived < Base
|
|
5
|
-
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
-
External = Data.define(:sources, :runner)
|
|
5
|
+
Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
+
External = ::Data.define(:sources, :runner)
|
|
7
7
|
|
|
8
8
|
attr_reader :source, :template, :inject_boot, :events
|
|
9
9
|
|
|
@@ -20,23 +20,22 @@ module Textus
|
|
|
20
20
|
def external? = @source.is_a?(External)
|
|
21
21
|
|
|
22
22
|
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
-
return nil unless in_generator_zone?
|
|
23
|
+
return nil unless in_generator_zone?(pctx.manifest.policy)
|
|
24
24
|
|
|
25
|
-
target_path = Textus::
|
|
26
|
-
|
|
27
|
-
bus: pctx.bus, root: pctx.root, store: pctx.store
|
|
25
|
+
target_path = Textus::Write::Materializer.new(
|
|
26
|
+
container: pctx.container, call: pctx.call,
|
|
28
27
|
).run(self)
|
|
29
28
|
|
|
30
29
|
envelope = pctx.reader.call(@key)
|
|
31
30
|
Array(publish_to).each do |rel|
|
|
32
31
|
target_abs = File.join(pctx.repo_root, rel)
|
|
33
|
-
Textus::
|
|
34
|
-
pctx.emit
|
|
32
|
+
Textus::Ports::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
|
|
33
|
+
pctx.emit(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
36
|
src = @source
|
|
38
37
|
selects = src.is_a?(Projection) ? Array(src.select).compact : []
|
|
39
|
-
pctx.emit
|
|
38
|
+
pctx.emit(:build_completed, key: @key, envelope: envelope, sources: selects)
|
|
40
39
|
|
|
41
40
|
{ kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
|
|
42
41
|
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require_relative "validators/publish_each"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
class Manifest
|
|
5
3
|
class Entry
|
|
@@ -37,7 +35,7 @@ module Textus
|
|
|
37
35
|
return nil if @publish_each.nil?
|
|
38
36
|
|
|
39
37
|
leaves = []
|
|
40
|
-
|
|
38
|
+
pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
41
39
|
next unless row[:manifest_entry].equal?(self)
|
|
42
40
|
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
43
41
|
|
|
@@ -49,12 +47,12 @@ module Textus
|
|
|
49
47
|
)
|
|
50
48
|
end
|
|
51
49
|
|
|
52
|
-
Textus::
|
|
53
|
-
pctx.emit
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
51
|
+
pctx.emit(:file_published,
|
|
52
|
+
key: row[:key],
|
|
53
|
+
envelope: pctx.reader.call(row[:key]),
|
|
54
|
+
source: row[:path],
|
|
55
|
+
target: target_abs)
|
|
58
56
|
leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
59
57
|
end
|
|
60
58
|
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
module Parser
|
|
5
5
|
COMPUTE_KINDS = %w[projection external].freeze
|
|
6
6
|
|
|
7
|
-
def self.call(
|
|
7
|
+
def self.call(raw)
|
|
8
8
|
key = raw["key"] or raise UsageError.new("manifest entry missing key")
|
|
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")
|
|
@@ -14,7 +14,7 @@ module Textus
|
|
|
14
14
|
format = resolve_format(raw, path)
|
|
15
15
|
|
|
16
16
|
common = {
|
|
17
|
-
|
|
17
|
+
raw: raw,
|
|
18
18
|
key: key, path: path, zone: zone,
|
|
19
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
20
|
format: format,
|
|
@@ -3,8 +3,8 @@ module Textus
|
|
|
3
3
|
class Entry
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
|
-
def self.call(entry)
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
6
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
+
pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
|
|
8
8
|
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
class Entry
|
|
4
4
|
module Validators
|
|
5
5
|
module FormatMatrix
|
|
6
|
-
def self.call(entry)
|
|
6
|
+
def self.call(entry, policy:)
|
|
7
7
|
begin
|
|
8
8
|
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
|
|
9
9
|
rescue UsageError => e
|
|
@@ -17,7 +17,7 @@ module Textus
|
|
|
17
17
|
has_template = !entry.template.nil?
|
|
18
18
|
is_external = entry.derived? && entry.external?
|
|
19
19
|
is_intake = entry.intake?
|
|
20
|
-
return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
|
|
20
|
+
return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
|
|
21
21
|
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
22
22
|
|
|
23
23
|
raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
|
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
class Entry
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
|
-
def self.call(entry)
|
|
6
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
7
|
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
8
8
|
index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
|
|
9
9
|
return if index_filename.nil?
|
|
@@ -3,10 +3,12 @@ module Textus
|
|
|
3
3
|
class Entry
|
|
4
4
|
module Validators
|
|
5
5
|
module InjectBoot
|
|
6
|
-
def self.call(entry)
|
|
6
|
+
def self.call(entry, policy:)
|
|
7
7
|
return unless entry.inject_boot
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
unless entry.in_generator_zone?(policy)
|
|
10
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries")
|
|
11
|
+
end
|
|
10
12
|
|
|
11
13
|
return unless entry.template.nil?
|
|
12
14
|
|
|
@@ -7,7 +7,7 @@ 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)
|
|
10
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
11
|
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
12
12
|
publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
|
|
13
13
|
return if publish_each.nil?
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
|
-
# Re-exported for backward compatibility with callers that referenced these
|
|
5
|
-
# constants on Entry. Canonical source is the PublishEach validator.
|
|
6
|
-
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
7
|
-
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
8
|
-
|
|
9
4
|
# Populated by each Entry::* subclass at load time.
|
|
10
5
|
REGISTRY = {}
|
|
11
6
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
# Authority over zones and roles derived from a Manifest::Data snapshot.
|
|
4
|
+
# Encapsulates the lookups previously living on Manifest itself
|
|
5
|
+
# (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
|
|
6
|
+
class Policy
|
|
7
|
+
def initialize(data)
|
|
8
|
+
@data = data
|
|
9
|
+
@zone_kinds_cache = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def zone_writers(zone_name)
|
|
13
|
+
@data.zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def zone_readers
|
|
17
|
+
@data.zone_readers
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def permission_for(zone_name)
|
|
21
|
+
Textus::Domain::Permission.new(
|
|
22
|
+
zone: zone_name,
|
|
23
|
+
write_policy: zone_writers(zone_name),
|
|
24
|
+
read_policy: @data.zone_readers[zone_name] || :all,
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def zone_kinds(zone_name)
|
|
29
|
+
@zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
|
|
30
|
+
k = role_kind(w)
|
|
31
|
+
acc << k if k
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def role_mapping
|
|
36
|
+
@data.role_mapping
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def role_kind(name)
|
|
40
|
+
@data.role_mapping[name]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def roles_with_kind(kind)
|
|
44
|
+
@data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|