textus 0.20.2 → 0.26.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 +148 -45
- data/CHANGELOG.md +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- 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 +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- data/lib/textus/cli/verb/build.rb +2 -2
- 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 +17 -0
- 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 +4 -4
- data/lib/textus/cli.rb +1 -1
- 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 +2 -2
- 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 +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- 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/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -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/infra/audit_log.rb +126 -16
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -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/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- 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/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/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/operations.rb +0 -169
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "time"
|
|
3
4
|
|
|
4
5
|
module Textus
|
|
5
6
|
module Infra
|
|
6
7
|
class AuditLog
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
DEFAULT_MAX_SIZE = 10_485_760
|
|
9
|
+
DEFAULT_KEEP = 5
|
|
10
|
+
|
|
11
|
+
def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
12
|
+
@root = root
|
|
13
|
+
@path = File.join(root, "audit.log")
|
|
14
|
+
@max_size = max_size
|
|
15
|
+
@keep = keep
|
|
9
16
|
end
|
|
10
17
|
|
|
11
18
|
def last_writer_for(key)
|
|
@@ -23,27 +30,38 @@ module Textus
|
|
|
23
30
|
last_role
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
"ts" => Time.now.utc.iso8601,
|
|
29
|
-
"role" => role,
|
|
30
|
-
"verb" => verb,
|
|
31
|
-
"key" => key,
|
|
32
|
-
"etag_before" => etag_before,
|
|
33
|
-
"etag_after" => etag_after,
|
|
34
|
-
}
|
|
33
|
+
def latest_seq
|
|
34
|
+
return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# Active log is empty/missing — consult the most recent rotated file's sidecar.
|
|
37
|
+
meta = read_meta(1)
|
|
38
|
+
return meta["max_seq"] if meta
|
|
39
|
+
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def min_available_seq
|
|
44
|
+
rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
|
|
45
|
+
if rotated_metas.any?
|
|
46
|
+
rotated_metas.map { |m| m["min_seq"] }.min
|
|
47
|
+
elsif File.exist?(@path)
|
|
48
|
+
File.foreach(@path) do |line|
|
|
49
|
+
parsed = parse_row(line.chomp)
|
|
50
|
+
return parsed["seq"] if parsed && parsed["seq"]
|
|
40
51
|
end
|
|
41
|
-
|
|
52
|
+
nil
|
|
42
53
|
end
|
|
54
|
+
end
|
|
43
55
|
|
|
56
|
+
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
44
57
|
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
45
58
|
f.flock(File::LOCK_EX)
|
|
59
|
+
next_seq = current_max_seq_unlocked + 1
|
|
60
|
+
row = assemble_row(next_seq, { role: role, verb: verb, key: key,
|
|
61
|
+
etag_before: etag_before, etag_after: etag_after }, extras)
|
|
46
62
|
f.write(JSON.generate(row) + "\n")
|
|
63
|
+
f.flush
|
|
64
|
+
rotate!(f) if f.size > @max_size
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
|
|
@@ -63,6 +81,98 @@ module Textus
|
|
|
63
81
|
|
|
64
82
|
private
|
|
65
83
|
|
|
84
|
+
# Caller holds the flock. Returns the highest seq across the active log,
|
|
85
|
+
# OR the most-recent rotated file's max_seq if the active log is empty.
|
|
86
|
+
def current_max_seq_unlocked
|
|
87
|
+
return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
|
|
88
|
+
|
|
89
|
+
meta = read_meta(1)
|
|
90
|
+
meta ? meta["max_seq"] : 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scan_max_seq(file)
|
|
94
|
+
last = 0
|
|
95
|
+
File.foreach(file) do |line|
|
|
96
|
+
parsed = parse_row(line.chomp)
|
|
97
|
+
last = parsed["seq"] if parsed && parsed["seq"].is_a?(Integer)
|
|
98
|
+
end
|
|
99
|
+
last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def scan_seq_range(file)
|
|
103
|
+
min = nil
|
|
104
|
+
max = 0
|
|
105
|
+
File.foreach(file) do |line|
|
|
106
|
+
parsed = parse_row(line.chomp)
|
|
107
|
+
next unless parsed && parsed["seq"]
|
|
108
|
+
|
|
109
|
+
min ||= parsed["seq"]
|
|
110
|
+
max = parsed["seq"]
|
|
111
|
+
end
|
|
112
|
+
[min, max]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_meta(n)
|
|
116
|
+
path = File.join(@root, "audit.log.#{n}.meta.json")
|
|
117
|
+
return nil unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
JSON.parse(File.read(path))
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def assemble_row(seq, fields, extras = nil)
|
|
125
|
+
row = {
|
|
126
|
+
"seq" => seq,
|
|
127
|
+
"ts" => Time.now.utc.iso8601,
|
|
128
|
+
"role" => fields[:role],
|
|
129
|
+
"verb" => fields[:verb],
|
|
130
|
+
"key" => fields[:key],
|
|
131
|
+
"etag_before" => fields[:etag_before],
|
|
132
|
+
"etag_after" => fields[:etag_after],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if extras.is_a?(Hash) && !extras.empty?
|
|
136
|
+
extras = extras.dup
|
|
137
|
+
%w[from_key to_key uid].each do |k|
|
|
138
|
+
row[k] = extras.delete(k) if extras.key?(k)
|
|
139
|
+
end
|
|
140
|
+
row["extras"] = extras unless extras.empty?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
row
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Called inside the flock, after a successful write that pushed size over max.
|
|
147
|
+
# Renames audit.log → audit.log.1 (shifting older files), writes sidecar meta.
|
|
148
|
+
def rotate!(open_file)
|
|
149
|
+
open_file.flush
|
|
150
|
+
min_seq, max_seq = scan_seq_range(@path)
|
|
151
|
+
meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
|
|
152
|
+
|
|
153
|
+
# Drop the file that would be shifted past @keep.
|
|
154
|
+
oldest = File.join(@root, "audit.log.#{@keep}")
|
|
155
|
+
oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
|
|
156
|
+
FileUtils.rm_f(oldest)
|
|
157
|
+
FileUtils.rm_f(oldest_meta)
|
|
158
|
+
|
|
159
|
+
# Shift .N → .(N+1) for N = keep-1 down to 1.
|
|
160
|
+
(@keep - 1).downto(1) do |n|
|
|
161
|
+
src = File.join(@root, "audit.log.#{n}")
|
|
162
|
+
dst = File.join(@root, "audit.log.#{n + 1}")
|
|
163
|
+
File.rename(src, dst) if File.exist?(src)
|
|
164
|
+
|
|
165
|
+
src_meta = File.join(@root, "audit.log.#{n}.meta.json")
|
|
166
|
+
dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
|
|
167
|
+
File.rename(src_meta, dst_meta) if File.exist?(src_meta)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Active log → .1
|
|
171
|
+
File.rename(@path, File.join(@root, "audit.log.1"))
|
|
172
|
+
File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
|
|
173
|
+
# Next append will create a fresh audit.log via File::CREAT.
|
|
174
|
+
end
|
|
175
|
+
|
|
66
176
|
def parse_row(line)
|
|
67
177
|
return nil if line.empty?
|
|
68
178
|
|
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Infra
|
|
5
5
|
# Writes an "event_error" audit row when a user hook raises during
|
|
6
|
-
# Hooks::
|
|
6
|
+
# Hooks::EventBus publish. Attached at Store boot.
|
|
7
7
|
#
|
|
8
|
-
# Integration: uses Hooks::
|
|
8
|
+
# Integration: uses Hooks::EventBus#on_error callback (chosen over a
|
|
9
9
|
# synthetic :hook_error event because the bus already owns the
|
|
10
10
|
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
11
|
# event subscribers should be able to filter by key glob).
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# that is properly a 0.18 port-extraction concern.
|
|
13
|
+
# Lifecycle audit rows for verb: "put" / "delete" / "rename" are written
|
|
14
|
+
# by Application::Envelope::Writer directly (it owns the
|
|
15
|
+
# audit-append-as-final-step invariant); this subscriber covers the
|
|
16
|
+
# hook-failure case the writer never sees.
|
|
18
17
|
class AuditSubscriber
|
|
19
18
|
def initialize(audit_log)
|
|
20
19
|
@audit_log = audit_log
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
|
|
24
|
+
store.session(role: "runner").refresh(key)
|
|
25
25
|
rescue StandardError
|
|
26
26
|
# Already logged via :refresh_failed; exit cleanly.
|
|
27
27
|
ensure
|
data/lib/textus/key/path.rb
CHANGED
|
@@ -4,12 +4,16 @@ module Textus
|
|
|
4
4
|
# Returns the absolute filesystem path for a manifest entry (the leaf file,
|
|
5
5
|
# not a nested directory). Adds the format's primary extension when the
|
|
6
6
|
# manifest entry's `path:` is extensionless.
|
|
7
|
-
|
|
7
|
+
#
|
|
8
|
+
# The first argument is a Manifest::Data (or anything responding to .root);
|
|
9
|
+
# callers historically passed the whole Manifest but should now pass
|
|
10
|
+
# `manifest.data`.
|
|
11
|
+
def self.resolve(data, mentry)
|
|
8
12
|
primary_ext = Entry.for_format(mentry.format).extensions.first
|
|
9
13
|
if File.extname(mentry.path) == ""
|
|
10
|
-
File.join(
|
|
14
|
+
File.join(data.root, "zones", mentry.path + primary_ext)
|
|
11
15
|
else
|
|
12
|
-
File.join(
|
|
16
|
+
File.join(data.root, "zones", mentry.path)
|
|
13
17
|
end
|
|
14
18
|
end
|
|
15
19
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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?` which routes through Policy.
|
|
45
|
+
@policy = Policy.new(self)
|
|
46
|
+
@entries = build_entries(raw)
|
|
47
|
+
validate_declared_keys!
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def build_audit_config(raw)
|
|
54
|
+
a = raw["audit"] || {}
|
|
55
|
+
{
|
|
56
|
+
max_size: a["max_size"] || AUDIT_DEFAULTS[:max_size],
|
|
57
|
+
keep: a["keep"] || AUDIT_DEFAULTS[:keep],
|
|
58
|
+
}.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_entries(raw)
|
|
62
|
+
Array(raw["entries"]).map do |e|
|
|
63
|
+
entry = Manifest::Entry::Parser.call(self, e)
|
|
64
|
+
Manifest::Entry::Validators.run_all(entry)
|
|
65
|
+
entry
|
|
66
|
+
end.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_declared_keys!
|
|
70
|
+
@entries.each do |e|
|
|
71
|
+
raise UsageError.new("empty key") if e.key.nil? || e.key.empty?
|
|
72
|
+
|
|
73
|
+
Key::Grammar.validate!(e.key)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -2,10 +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, :manifest
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
-
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
|
|
8
|
+
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
|
|
9
9
|
@manifest = manifest
|
|
10
10
|
@raw = raw
|
|
11
11
|
@key = key
|
|
@@ -14,24 +14,61 @@ module Textus
|
|
|
14
14
|
@schema = schema
|
|
15
15
|
@owner = owner
|
|
16
16
|
@format = format
|
|
17
|
+
@publish_to = Array(publish_to)
|
|
17
18
|
end
|
|
18
19
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
20
|
|
|
20
|
-
def kind = self.class.name.split("::").last.downcase.to_sym
|
|
21
|
-
|
|
22
21
|
def zone_writers
|
|
23
|
-
@manifest.zone_writers(@zone)
|
|
22
|
+
@manifest.policy.zone_writers(@zone)
|
|
24
23
|
rescue UsageError => e
|
|
25
24
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
|
|
29
|
-
def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
|
|
27
|
+
def in_generator_zone? = @manifest.policy.zone_kinds(@zone).include?(:generator)
|
|
28
|
+
def in_proposal_zone? = @manifest.policy.zone_kinds(@zone).include?(:proposer)
|
|
30
29
|
|
|
31
30
|
def nested? = false
|
|
32
31
|
def derived? = false
|
|
33
32
|
def intake? = false
|
|
34
33
|
def leaf? = false
|
|
34
|
+
|
|
35
|
+
# Nil stubs for cross-cutting optional attrs. Subclasses override the
|
|
36
|
+
# ones they own. Validators and serializers can call these directly
|
|
37
|
+
# without `respond_to?` guards.
|
|
38
|
+
def template = nil
|
|
39
|
+
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
40
|
+
def events = {}
|
|
41
|
+
def publish_each = nil
|
|
42
|
+
def index_filename = nil
|
|
43
|
+
|
|
44
|
+
PublishContext = Struct.new(
|
|
45
|
+
:repo_root, :manifest, :file_store, :root, :caps, :rpc, :session, :ctx, :bus, :hook_context,
|
|
46
|
+
:reader, :emit, # callables: reader.call(key) → envelope; emit.call(event, **payload)
|
|
47
|
+
keyword_init: true
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Subclasses override to customize publish behavior.
|
|
51
|
+
# Default: copy the stored file to each publish_to target.
|
|
52
|
+
# Returns: { kind: :built|:leaves, value: ... } to be accumulated by
|
|
53
|
+
# Publish#call, or nil to skip.
|
|
54
|
+
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
55
|
+
return nil if Array(publish_to).empty?
|
|
56
|
+
|
|
57
|
+
source_path = pctx.manifest.resolver.resolve(@key).path
|
|
58
|
+
envelope = pctx.reader.call(@key)
|
|
59
|
+
|
|
60
|
+
publish_to.each do |rel|
|
|
61
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
62
|
+
Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
63
|
+
pctx.emit.call(:file_published,
|
|
64
|
+
key: @key,
|
|
65
|
+
envelope: envelope,
|
|
66
|
+
source: source_path,
|
|
67
|
+
target: target_abs)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
|
|
71
|
+
end
|
|
35
72
|
end
|
|
36
73
|
end
|
|
37
74
|
end
|
|
@@ -2,23 +2,58 @@ 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
|
-
attr_reader :source, :template, :
|
|
8
|
+
attr_reader :source, :template, :inject_boot, :events
|
|
9
9
|
|
|
10
|
-
def initialize(source:, template: nil,
|
|
10
|
+
def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
|
|
11
11
|
super(**rest)
|
|
12
12
|
@source = source
|
|
13
13
|
@template = template
|
|
14
|
-
@
|
|
15
|
-
@publish_to = Array(publish_to)
|
|
14
|
+
@inject_boot = inject_boot
|
|
16
15
|
@events = events || {}
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def derived? = true
|
|
20
19
|
def projection? = @source.is_a?(Projection)
|
|
21
20
|
def external? = @source.is_a?(External)
|
|
21
|
+
|
|
22
|
+
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
return nil unless in_generator_zone?
|
|
24
|
+
|
|
25
|
+
target_path = Textus::Application::Write::Materializer.new(
|
|
26
|
+
ctx: pctx.ctx, caps: pctx.caps, rpc: pctx.rpc, session: pctx.session,
|
|
27
|
+
).run(self)
|
|
28
|
+
|
|
29
|
+
envelope = pctx.reader.call(@key)
|
|
30
|
+
Array(publish_to).each do |rel|
|
|
31
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
32
|
+
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
|
|
33
|
+
pctx.emit.call(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
src = @source
|
|
37
|
+
selects = src.is_a?(Projection) ? Array(src.select).compact : []
|
|
38
|
+
pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
|
|
39
|
+
|
|
40
|
+
{ kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
KIND = :derived
|
|
44
|
+
|
|
45
|
+
def self.from_raw(common, raw)
|
|
46
|
+
source = Parser.parse_source(raw, common[:key])
|
|
47
|
+
new(
|
|
48
|
+
source: source,
|
|
49
|
+
template: raw["template"],
|
|
50
|
+
inject_boot: raw["inject_boot"] == true,
|
|
51
|
+
events: raw["events"] || {},
|
|
52
|
+
**common,
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Entry::REGISTRY[KIND] = self
|
|
22
57
|
end
|
|
23
58
|
end
|
|
24
59
|
end
|
|
@@ -2,17 +2,29 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Intake < Base
|
|
5
|
-
attr_reader :handler, :config, :events
|
|
5
|
+
attr_reader :handler, :config, :events
|
|
6
6
|
|
|
7
7
|
def initialize(handler:, config: {}, events: {}, **rest)
|
|
8
8
|
super(**rest)
|
|
9
9
|
@handler = handler
|
|
10
10
|
@config = config || {}
|
|
11
11
|
@events = events || {}
|
|
12
|
-
@publish_to = []
|
|
13
12
|
end
|
|
14
13
|
|
|
15
|
-
def intake?
|
|
14
|
+
def intake? = true
|
|
15
|
+
def nested? = !!@raw["nested"]
|
|
16
|
+
|
|
17
|
+
KIND = :intake
|
|
18
|
+
|
|
19
|
+
def self.from_raw(common, raw)
|
|
20
|
+
intake = raw["intake"] || {}
|
|
21
|
+
handler = intake["handler"] || raw["intake_handler"] or
|
|
22
|
+
raise UsageError.new("intake entry '#{common[:key]}' missing handler")
|
|
23
|
+
config = intake["config"] || raw["intake_config"] || {}
|
|
24
|
+
new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Entry::REGISTRY[KIND] = self
|
|
16
28
|
end
|
|
17
29
|
end
|
|
18
30
|
end
|
|
@@ -2,14 +2,15 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Leaf < Base
|
|
5
|
-
|
|
5
|
+
KIND = :leaf
|
|
6
6
|
|
|
7
|
-
def
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
def leaf? = true
|
|
8
|
+
|
|
9
|
+
def self.from_raw(common, _raw)
|
|
10
|
+
new(**common)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Entry::REGISTRY[KIND] = self
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -7,13 +7,12 @@ module Textus
|
|
|
7
7
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
8
8
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
9
|
|
|
10
|
-
attr_reader :index_filename, :publish_each
|
|
10
|
+
attr_reader :index_filename, :publish_each
|
|
11
11
|
|
|
12
|
-
def initialize(index_filename: nil, publish_each: nil,
|
|
12
|
+
def initialize(index_filename: nil, publish_each: nil, **rest)
|
|
13
13
|
super(**rest)
|
|
14
14
|
@index_filename = index_filename
|
|
15
15
|
@publish_each = publish_each
|
|
16
|
-
@publish_to = Array(publish_to)
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def nested? = true
|
|
@@ -33,6 +32,46 @@ module Textus
|
|
|
33
32
|
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
34
33
|
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
35
34
|
end
|
|
35
|
+
|
|
36
|
+
def publish_via(pctx, prefix: nil)
|
|
37
|
+
return nil if @publish_each.nil?
|
|
38
|
+
|
|
39
|
+
leaves = []
|
|
40
|
+
@manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
41
|
+
next unless row[:manifest_entry].equal?(self)
|
|
42
|
+
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
43
|
+
|
|
44
|
+
target_rel = publish_target_for(row[:key])
|
|
45
|
+
target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
|
|
46
|
+
unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
47
|
+
raise Textus::PublishError.new(
|
|
48
|
+
"entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
53
|
+
pctx.emit.call(:file_published,
|
|
54
|
+
key: row[:key],
|
|
55
|
+
envelope: pctx.reader.call(row[:key]),
|
|
56
|
+
source: row[:path],
|
|
57
|
+
target: target_abs)
|
|
58
|
+
leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ kind: :leaves, value: leaves }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
KIND = :nested
|
|
65
|
+
|
|
66
|
+
def self.from_raw(common, raw)
|
|
67
|
+
new(
|
|
68
|
+
index_filename: raw["index_filename"],
|
|
69
|
+
publish_each: raw["publish_each"],
|
|
70
|
+
**common,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Entry::REGISTRY[KIND] = self
|
|
36
75
|
end
|
|
37
76
|
end
|
|
38
77
|
end
|
|
@@ -9,7 +9,7 @@ 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
|
-
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (
|
|
12
|
+
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
13
13
|
kind = raw_kind.to_sym
|
|
14
14
|
format = resolve_format(raw, path)
|
|
15
15
|
|
|
@@ -17,49 +17,13 @@ module Textus
|
|
|
17
17
|
manifest: manifest, raw: raw,
|
|
18
18
|
key: key, path: path, zone: zone,
|
|
19
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
|
-
format: format
|
|
20
|
+
format: format,
|
|
21
|
+
publish_to: raw["publish_to"]
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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"],
|
|
39
|
-
publish_each: raw["publish_each"],
|
|
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"],
|
|
50
|
-
inject_intro: raw["inject_intro"] == true,
|
|
51
|
-
publish_to: raw["publish_to"],
|
|
52
|
-
events: raw["events"] || {},
|
|
53
|
-
**common,
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
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)
|
|
24
|
+
klass = Entry::REGISTRY[kind] or
|
|
25
|
+
raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect} (known: #{Entry::REGISTRY.keys.join(", ")})")
|
|
26
|
+
klass.from_raw(common, raw)
|
|
63
27
|
end
|
|
64
28
|
|
|
65
29
|
def self.parse_source(raw, key)
|
|
@@ -73,14 +37,14 @@ module Textus
|
|
|
73
37
|
end
|
|
74
38
|
|
|
75
39
|
if compute["kind"] == "projection"
|
|
76
|
-
Derived::Projection.new(
|
|
40
|
+
Entry::Derived::Projection.new(
|
|
77
41
|
select: compute["select"],
|
|
78
42
|
pluck: compute["pluck"],
|
|
79
43
|
sort_by: compute["sort_by"],
|
|
80
44
|
transform: compute["transform"],
|
|
81
45
|
)
|
|
82
46
|
else
|
|
83
|
-
Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
47
|
+
Entry::Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
84
48
|
end
|
|
85
49
|
end
|
|
86
50
|
|
|
@@ -4,8 +4,8 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
8
|
-
events = entry.
|
|
7
|
+
pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
|
|
8
|
+
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
11
11
|
|