textus 0.26.0 → 0.30.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 +118 -68
- data/CHANGELOG.md +132 -0
- data/README.md +61 -19
- data/SPEC.md +107 -46
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +18 -12
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/verb/audit.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +1 -1
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +19 -23
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +57 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +10 -8
- data/lib/textus/doctor/check.rb +15 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- 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 +18 -10
- data/lib/textus/domain/staleness.rb +3 -3
- data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- 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 +9 -4
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +6 -6
- 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 +1 -1
- 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 +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +4 -8
- data/lib/textus/mcp/server.rb +2 -11
- data/lib/textus/mcp/session.rb +13 -20
- data/lib/textus/mcp/tools.rb +2 -2
- data/lib/textus/mcp.rb +1 -1
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
- 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 +42 -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/retainable.rb +17 -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 +50 -0
- data/lib/textus/schema/tools.rb +3 -3
- data/lib/textus/store.rb +16 -7
- 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 +40 -0
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +113 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +45 -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 +124 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus/write/retention_sweep.rb +55 -0
- data/lib/textus.rb +1 -2
- metadata +62 -50
- data/lib/textus/application/caps.rb +0 -49
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
- data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
- data/lib/textus/application/maintenance/migrate.rb +0 -59
- data/lib/textus/application/maintenance/rule_lint.rb +0 -65
- data/lib/textus/application/maintenance/zone_mv.rb +0 -60
- data/lib/textus/application/maintenance.rb +0 -17
- data/lib/textus/application/projection.rb +0 -93
- data/lib/textus/application/read/audit.rb +0 -106
- data/lib/textus/application/read/blame.rb +0 -91
- data/lib/textus/application/read/deps.rb +0 -34
- data/lib/textus/application/read/freshness.rb +0 -110
- data/lib/textus/application/read/get.rb +0 -75
- data/lib/textus/application/read/get_or_refresh.rb +0 -63
- data/lib/textus/application/read/list.rb +0 -25
- data/lib/textus/application/read/policy_explain.rb +0 -47
- data/lib/textus/application/read/published.rb +0 -25
- data/lib/textus/application/read/pulse.rb +0 -101
- data/lib/textus/application/read/rdeps.rb +0 -35
- data/lib/textus/application/read/schema_envelope.rb +0 -26
- data/lib/textus/application/read/stale.rb +0 -23
- data/lib/textus/application/read/uid.rb +0 -30
- data/lib/textus/application/read/validate_all.rb +0 -32
- data/lib/textus/application/read/validator.rb +0 -86
- data/lib/textus/application/read/where.rb +0 -26
- data/lib/textus/application/use_case.rb +0 -22
- data/lib/textus/application/write/accept.rb +0 -102
- data/lib/textus/application/write/authority_gate.rb +0 -26
- data/lib/textus/application/write/delete.rb +0 -45
- data/lib/textus/application/write/materializer.rb +0 -49
- data/lib/textus/application/write/mv.rb +0 -118
- data/lib/textus/application/write/publish.rb +0 -96
- data/lib/textus/application/write/put.rb +0 -49
- data/lib/textus/application/write/refresh_all.rb +0 -63
- data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
- data/lib/textus/application/write/refresh_worker.rb +0 -134
- data/lib/textus/application/write/reject.rb +0 -62
- data/lib/textus/session.rb +0 -84
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
|
|
3
|
-
module
|
|
2
|
+
class Envelope
|
|
3
|
+
module IO
|
|
4
4
|
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
5
5
|
# bytes, parses them via the format strategy, and hands back an
|
|
6
6
|
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
@@ -8,6 +8,10 @@ module Textus
|
|
|
8
8
|
#
|
|
9
9
|
# No audit, no events, no permission checks — those live one layer up.
|
|
10
10
|
class Reader
|
|
11
|
+
def self.from(container:)
|
|
12
|
+
new(file_store: container.file_store, manifest: container.manifest)
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def initialize(file_store:, manifest:)
|
|
12
16
|
@file_store = file_store
|
|
13
17
|
@manifest = manifest
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
|
|
5
|
-
module
|
|
4
|
+
class Envelope
|
|
5
|
+
module IO
|
|
6
6
|
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
7
7
|
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
8
8
|
# Reader for the existing-uid lookup.
|
|
@@ -10,16 +10,24 @@ module Textus
|
|
|
10
10
|
# Invariant: every public method's final action is @audit_log.append(...).
|
|
11
11
|
#
|
|
12
12
|
# No permission check, no event firing — those belong to the caller
|
|
13
|
-
# (
|
|
13
|
+
# (Write::Put / ::Delete / ::Mv).
|
|
14
14
|
class Writer
|
|
15
15
|
Payload = Data.define(:meta, :body, :content)
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def self.from(container:, call:)
|
|
18
|
+
new(
|
|
19
|
+
file_store: container.file_store, manifest: container.manifest,
|
|
20
|
+
schemas: container.schemas, audit_log: container.audit_log,
|
|
21
|
+
call: call, reader: Reader.from(container: container)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
18
26
|
@file_store = file_store
|
|
19
27
|
@manifest = manifest
|
|
20
28
|
@schemas = schemas
|
|
21
29
|
@audit_log = audit_log
|
|
22
|
-
@
|
|
30
|
+
@call = call
|
|
23
31
|
@reader = reader
|
|
24
32
|
end
|
|
25
33
|
|
|
@@ -56,9 +64,9 @@ module Textus
|
|
|
56
64
|
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
57
65
|
)
|
|
58
66
|
@audit_log.append(
|
|
59
|
-
role: @
|
|
67
|
+
role: @call.role, verb: "put", key: key,
|
|
60
68
|
etag_before: etag_before, etag_after: etag_after,
|
|
61
|
-
extras: @
|
|
69
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
62
70
|
)
|
|
63
71
|
envelope
|
|
64
72
|
end
|
|
@@ -75,9 +83,9 @@ module Textus
|
|
|
75
83
|
|
|
76
84
|
@file_store.delete(path)
|
|
77
85
|
@audit_log.append(
|
|
78
|
-
role: @
|
|
86
|
+
role: @call.role, verb: "delete", key: key,
|
|
79
87
|
etag_before: etag_before, etag_after: nil,
|
|
80
|
-
extras: @
|
|
88
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
81
89
|
)
|
|
82
90
|
end
|
|
83
91
|
|
|
@@ -108,10 +116,10 @@ module Textus
|
|
|
108
116
|
"from_path" => from_path, "to_path" => to_path,
|
|
109
117
|
"uid" => envelope.uid
|
|
110
118
|
}
|
|
111
|
-
extras["correlation_id"] = @
|
|
119
|
+
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
112
120
|
|
|
113
121
|
@audit_log.append(
|
|
114
|
-
role: @
|
|
122
|
+
role: @call.role, verb: "mv", key: to_key,
|
|
115
123
|
etag_before: etag_before, etag_after: etag_after,
|
|
116
124
|
extras: extras
|
|
117
125
|
)
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -3,31 +3,48 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
5
|
# A narrow handle passed to user hooks in place of the raw Store.
|
|
6
|
-
# All writes route back through the
|
|
6
|
+
# All writes route back through the RoleScope so authorization, audit
|
|
7
7
|
# logging, and schema validation always fire.
|
|
8
8
|
class Context
|
|
9
9
|
attr_reader :role, :correlation_id
|
|
10
10
|
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
def self.for(container:, call:)
|
|
12
|
+
scope = Textus::RoleScope.new(
|
|
13
|
+
container: container,
|
|
14
|
+
role: call.role,
|
|
15
|
+
correlation_id: call.correlation_id,
|
|
16
|
+
dry_run: call.dry_run,
|
|
17
|
+
)
|
|
18
|
+
new(scope: scope)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(scope:)
|
|
22
|
+
@scope = scope
|
|
23
|
+
@role = scope.role
|
|
24
|
+
@correlation_id = scope.correlation_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def backend
|
|
28
|
+
@scope
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
# read
|
|
18
|
-
def get(key) = @
|
|
19
|
-
def list(**) = @
|
|
20
|
-
def deps(key) = @
|
|
21
|
-
def freshness(key) = @
|
|
32
|
+
def get(key) = @scope.get(key)
|
|
33
|
+
def list(**) = @scope.list(**)
|
|
34
|
+
def deps(key) = @scope.deps(key)
|
|
35
|
+
def freshness(key) = @scope.freshness(key)
|
|
22
36
|
|
|
23
37
|
# write (authorized + audited)
|
|
24
|
-
def put(key, **) = @
|
|
25
|
-
def delete(key, **) = @
|
|
26
|
-
|
|
38
|
+
def put(key, **) = @scope.put(key, **)
|
|
39
|
+
def delete(key, **) = @scope.delete(key, **)
|
|
40
|
+
|
|
41
|
+
def audit(verb, key:, **)
|
|
42
|
+
@scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
43
|
+
end
|
|
27
44
|
|
|
28
45
|
# fan-out
|
|
29
46
|
def publish_followup(event, **)
|
|
30
|
-
@
|
|
47
|
+
@scope.container.events.publish(event, ctx: self, **)
|
|
31
48
|
end
|
|
32
49
|
|
|
33
50
|
def inspect
|
|
@@ -39,7 +39,12 @@ module Textus
|
|
|
39
39
|
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
|
|
40
40
|
|
|
41
41
|
required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
-
|
|
42
|
+
sig = Signature.new(blk)
|
|
43
|
+
missing = sig.missing(required)
|
|
44
|
+
if missing.any?
|
|
45
|
+
raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
46
|
+
end
|
|
47
|
+
|
|
43
48
|
name = name.to_sym
|
|
44
49
|
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
45
50
|
|
|
@@ -79,8 +84,9 @@ module Textus
|
|
|
79
84
|
private
|
|
80
85
|
|
|
81
86
|
def invoke(event, sub, key, kwargs)
|
|
82
|
-
accepted =
|
|
87
|
+
accepted = Signature.new(sub[:callable]).filter(kwargs)
|
|
83
88
|
error = nil
|
|
89
|
+
# Thread#kill is unsafe in general but bounded here: post-commit, isolated, only a runaway user hook is affected.
|
|
84
90
|
thread = Thread.new do
|
|
85
91
|
sub[:callable].call(**accepted)
|
|
86
92
|
rescue StandardError => e
|
|
@@ -115,24 +121,6 @@ module Textus
|
|
|
115
121
|
end
|
|
116
122
|
end
|
|
117
123
|
|
|
118
|
-
def filter_kwargs(callable, kwargs)
|
|
119
|
-
params = callable.parameters
|
|
120
|
-
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
121
|
-
|
|
122
|
-
accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
123
|
-
kwargs.slice(*accepted)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def shape_check!(event, required, blk)
|
|
127
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
128
|
-
return if provided.any? { |t, _| t == :keyrest }
|
|
129
|
-
|
|
130
|
-
missing = required - provided.map { |_, n| n }
|
|
131
|
-
return if missing.empty?
|
|
132
|
-
|
|
133
|
-
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
124
|
def match?(globs, key)
|
|
137
125
|
return true if globs.nil?
|
|
138
126
|
|
|
@@ -20,7 +20,10 @@ module Textus
|
|
|
20
20
|
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
|
|
21
21
|
|
|
22
22
|
required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
|
-
|
|
23
|
+
sig = Signature.new(blk)
|
|
24
|
+
missing = sig.missing(required)
|
|
25
|
+
raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
|
|
26
|
+
|
|
24
27
|
name = name.to_sym
|
|
25
28
|
raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
|
|
26
29
|
|
|
@@ -33,45 +36,16 @@ module Textus
|
|
|
33
36
|
@table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
# Invoke a registered callable, injecting `caps:`
|
|
37
|
-
#
|
|
39
|
+
# Invoke a registered callable, injecting `caps:` only if the callable
|
|
40
|
+
# declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
|
|
41
|
+
# `caps:`-alternative) are rejected at registration time, not here.
|
|
38
42
|
def invoke(event, name, caps:, **other)
|
|
39
43
|
blk = callable(event, name)
|
|
40
|
-
|
|
41
|
-
accepts_keyrest = params.any? { |t, _| t == :keyrest }
|
|
42
|
-
declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
43
|
-
|
|
44
|
-
if declared.include?(:store)
|
|
45
|
-
raise UsageError.new(
|
|
46
|
-
"RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
|
|
47
|
-
"(Textus::Application::ReadCaps / WriteCaps)",
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
44
|
+
sig = Signature.new(blk)
|
|
51
45
|
kwargs = other.dup
|
|
52
|
-
kwargs[:caps] = caps if accepts_keyrest ||
|
|
46
|
+
kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
|
|
53
47
|
blk.call(**kwargs)
|
|
54
48
|
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def shape_check!(event, required, blk)
|
|
59
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
60
|
-
return if provided.any? { |t, _| t == :keyrest }
|
|
61
|
-
|
|
62
|
-
param_names = provided.map { |_, n| n }
|
|
63
|
-
# Allow `store:` as a stand-in for `caps:` so registration succeeds;
|
|
64
|
-
# invoke will raise UsageError when the callable is actually called.
|
|
65
|
-
effective_required = if param_names.include?(:store)
|
|
66
|
-
required.map { |r| r == :caps ? :store : r }
|
|
67
|
-
else
|
|
68
|
-
required
|
|
69
|
-
end
|
|
70
|
-
missing = effective_required - param_names
|
|
71
|
-
return if missing.empty?
|
|
72
|
-
|
|
73
|
-
raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
74
|
-
end
|
|
75
49
|
end
|
|
76
50
|
end
|
|
77
51
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class Signature
|
|
6
|
+
def initialize(callable)
|
|
7
|
+
@params = callable.parameters
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def accepts_keyrest?
|
|
11
|
+
@params.any? { |type, _| type == :keyrest }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def declared_keys
|
|
15
|
+
@params.each_with_object([]) { |(t, n), acc| acc << n if %i[keyreq key].include?(t) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def missing(required)
|
|
19
|
+
return [] if accepts_keyrest?
|
|
20
|
+
|
|
21
|
+
required - declared_keys
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def filter(kwargs)
|
|
25
|
+
return kwargs if accepts_keyrest?
|
|
26
|
+
|
|
27
|
+
kwargs.slice(*declared_keys)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -7,14 +7,15 @@ module Textus
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/3
|
|
9
9
|
zones:
|
|
10
|
-
- { name: identity, write_policy: [human],
|
|
11
|
-
- { name: working, write_policy: [human
|
|
12
|
-
- { name: intake, write_policy: [runner],
|
|
13
|
-
- { name: review, write_policy: [agent, human],
|
|
14
|
-
- { name: output, write_policy: [builder],
|
|
10
|
+
- { name: identity, kind: origin, write_policy: [human], read_policy: [all] }
|
|
11
|
+
- { name: working, kind: origin, write_policy: [human], read_policy: [all] }
|
|
12
|
+
- { name: intake, kind: quarantine, write_policy: [runner], read_policy: [all] }
|
|
13
|
+
- { name: review, kind: queue, write_policy: [agent, human], read_policy: [all] }
|
|
14
|
+
- { name: output, kind: derived, write_policy: [builder], read_policy: [all] }
|
|
15
15
|
entries:
|
|
16
16
|
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
|
|
17
17
|
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
|
|
18
|
+
- { key: review.notes, path: review/notes, zone: review, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
18
19
|
YAML
|
|
19
20
|
|
|
20
21
|
HOOKS_README = <<~MD
|
|
@@ -34,7 +35,7 @@ module Textus
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
37
|
-
reg.on(:validate, :my_check) { |
|
|
38
|
+
reg.on(:validate, :my_check) { |caps:, **| [] }
|
|
38
39
|
reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
|
|
39
40
|
|
|
40
41
|
# Run a side-effect every time textus writes a file to your repo:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Maintenance
|
|
3
|
+
# Bulk-delete every leaf key under `prefix`.
|
|
4
|
+
class KeyDeletePrefix
|
|
5
|
+
def initialize(container:, call:)
|
|
6
|
+
@container = container
|
|
7
|
+
@call = call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(prefix:, dry_run: false)
|
|
11
|
+
raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
|
|
12
|
+
|
|
13
|
+
leaves = Read::List.new(container: @container)
|
|
14
|
+
.call(prefix: prefix)
|
|
15
|
+
.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
16
|
+
|
|
17
|
+
warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
|
|
18
|
+
steps = leaves.map { |k| { "op" => "delete", "key" => k } }
|
|
19
|
+
|
|
20
|
+
plan = Plan.new(steps: steps, warnings: warnings)
|
|
21
|
+
return plan if dry_run
|
|
22
|
+
|
|
23
|
+
steps.each do |s|
|
|
24
|
+
delete.call(s["key"])
|
|
25
|
+
end
|
|
26
|
+
plan
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def delete
|
|
32
|
+
Write::Delete.new(container: @container, call: @call)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -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
|
data/lib/textus/manifest/data.rb
CHANGED
|
@@ -11,7 +11,8 @@ module Textus
|
|
|
11
11
|
class Data
|
|
12
12
|
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
13
13
|
|
|
14
|
-
attr_reader :raw, :root, :entries, :zones, :zone_readers, :
|
|
14
|
+
attr_reader :raw, :root, :entries, :zones, :zone_readers, :declared_zone_kinds,
|
|
15
|
+
:audit_config, :role_mapping, :policy
|
|
15
16
|
|
|
16
17
|
def self.validate_key!(key)
|
|
17
18
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
@@ -38,10 +39,14 @@ module Textus
|
|
|
38
39
|
rp = z["read_policy"]
|
|
39
40
|
[z["name"], rp.nil? ? :all : Array(rp)]
|
|
40
41
|
end
|
|
42
|
+
@declared_zone_kinds = Array(raw["zones"]).to_h do |z|
|
|
43
|
+
[z["name"], z["kind"]&.to_sym]
|
|
44
|
+
end
|
|
41
45
|
@audit_config = build_audit_config(raw)
|
|
42
46
|
@role_mapping = RoleKinds.resolve(raw["roles"])
|
|
43
47
|
# Policy is constructed before entries because Entry validators
|
|
44
|
-
# call `entry.in_generator_zone
|
|
48
|
+
# call `entry.in_generator_zone?(policy)` and similar helpers
|
|
49
|
+
# that take Policy as an argument.
|
|
45
50
|
@policy = Policy.new(self)
|
|
46
51
|
@entries = build_entries(raw)
|
|
47
52
|
validate_declared_keys!
|
|
@@ -60,8 +65,8 @@ module Textus
|
|
|
60
65
|
|
|
61
66
|
def build_entries(raw)
|
|
62
67
|
Array(raw["entries"]).map do |e|
|
|
63
|
-
entry = Manifest::Entry::Parser.call(
|
|
64
|
-
Manifest::Entry::Validators.run_all(entry)
|
|
68
|
+
entry = Manifest::Entry::Parser.call(e)
|
|
69
|
+
Manifest::Entry::Validators.run_all(entry, policy: @policy)
|
|
65
70
|
entry
|
|
66
71
|
end.freeze
|
|
67
72
|
end
|