textus 0.55.1 → 0.55.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +9 -9
- data/SPEC.md +14 -13
- data/docs/architecture/README.md +3 -3
- data/docs/reference/conventions.md +5 -2
- data/lib/textus/boot.rb +64 -85
- data/lib/textus/{gate → dispatch}/binder.rb +8 -10
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/doctor/check.rb +8 -6
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +2 -0
- data/lib/textus/format/base.rb +36 -8
- data/lib/textus/format/json.rb +0 -21
- data/lib/textus/format/markdown.rb +0 -21
- data/lib/textus/format/yaml.rb +0 -21
- data/lib/textus/format.rb +16 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -5
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.rb +1 -1
- data/lib/textus/orchestration.rb +55 -0
- data/lib/textus/port/audit_log.rb +6 -6
- data/lib/textus/port/build_lock.rb +1 -1
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/port/sentinel_store.rb +3 -3
- data/lib/textus/port/storage/file_store.rb +23 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +58 -2
- data/lib/textus/port/watcher_lock.rb +2 -2
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +3 -10
- data/lib/textus/store/container.rb +140 -10
- data/lib/textus/store/cursor.rb +1 -1
- data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
- data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +5 -3
- data/lib/textus/store/jobs/planner.rb +27 -7
- data/lib/textus/store/jobs/queue.rb +9 -1
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +1 -43
- data/lib/textus/store/jobs/sweep.rb +2 -2
- data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
- data/lib/textus/store.rb +53 -30
- data/lib/textus/surface/cli/runner.rb +8 -9
- data/lib/textus/surface/cli/verb/doctor.rb +3 -2
- data/lib/textus/surface/cli/verb/get.rb +5 -3
- data/lib/textus/surface/cli/verb/put.rb +5 -3
- data/lib/textus/surface/mcp/catalog.rb +26 -62
- data/lib/textus/surface/mcp/errors.rb +0 -10
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/surface/mcp/server.rb +20 -31
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +5 -4
- data/lib/textus/value/etag.rb +1 -1
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +36 -16
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +1 -1
- data/lib/textus/workflow/runner.rb +10 -18
- data/lib/textus.rb +0 -64
- metadata +70 -70
- data/lib/textus/action/accept.rb +0 -46
- data/lib/textus/action/audit.rb +0 -94
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -79
- data/lib/textus/action/boot.rb +0 -15
- data/lib/textus/action/data_mv.rb +0 -58
- data/lib/textus/action/deps.rb +0 -19
- data/lib/textus/action/doctor.rb +0 -17
- data/lib/textus/action/drain.rb +0 -31
- data/lib/textus/action/enqueue.rb +0 -37
- data/lib/textus/action/get.rb +0 -34
- data/lib/textus/action/ingest.rb +0 -199
- data/lib/textus/action/jobs.rb +0 -27
- data/lib/textus/action/key_delete.rb +0 -26
- data/lib/textus/action/key_delete_prefix.rb +0 -35
- data/lib/textus/action/key_mv.rb +0 -122
- data/lib/textus/action/key_mv_prefix.rb +0 -48
- data/lib/textus/action/list.rb +0 -28
- data/lib/textus/action/propose.rb +0 -42
- data/lib/textus/action/published.rb +0 -22
- data/lib/textus/action/pulse.rb +0 -49
- data/lib/textus/action/put.rb +0 -38
- data/lib/textus/action/rdeps.rb +0 -24
- data/lib/textus/action/reject.rb +0 -28
- data/lib/textus/action/rule_explain.rb +0 -81
- data/lib/textus/action/rule_lint.rb +0 -62
- data/lib/textus/action/rule_list.rb +0 -38
- data/lib/textus/action/schema_envelope.rb +0 -22
- data/lib/textus/action/uid.rb +0 -19
- data/lib/textus/action/where.rb +0 -21
- data/lib/textus/contract/arg.rb +0 -10
- data/lib/textus/contract/dsl.rb +0 -88
- data/lib/textus/contract/spec.rb +0 -25
- data/lib/textus/contract.rb +0 -12
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/format/shared.rb +0 -17
- data/lib/textus/gate/auth.rb +0 -212
- data/lib/textus/gate.rb +0 -92
- data/lib/textus/meta.rb +0 -54
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/store/compositor.rb +0 -34
- data/lib/textus/store/session.rb +0 -37
- data/lib/textus/surface/projector.rb +0 -27
- data/lib/textus/surface/role_scope.rb +0 -34
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "date"
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Handlers
|
|
7
|
+
module Maintenance
|
|
8
|
+
class IngestEntry
|
|
9
|
+
SOURCE_KINDS = %w[url file asset].freeze
|
|
10
|
+
CONTENT_HASH_ALGO = "sha256"
|
|
11
|
+
|
|
12
|
+
def initialize(container:)
|
|
13
|
+
@container = container
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command, call)
|
|
17
|
+
unless SOURCE_KINDS.include?(command.kind)
|
|
18
|
+
return Value::Result.failure(:usage_error,
|
|
19
|
+
"ingest kind must be one of #{SOURCE_KINDS.join("|")}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
case command.kind
|
|
23
|
+
when "url" then return Value::Result.failure(:usage_error, "ingest url requires url") unless command.url
|
|
24
|
+
when "file" then return Value::Result.failure(:usage_error, "ingest file requires path") unless command.path
|
|
25
|
+
when "asset"
|
|
26
|
+
return Value::Result.failure(:usage_error, "ingest asset requires path") unless command.path
|
|
27
|
+
return Value::Result.failure(:usage_error, "ingest asset requires lane") unless command.lane
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
now = Time.now.utc
|
|
31
|
+
key = derive_key(now, command.kind, command.slug)
|
|
32
|
+
content_hash = compute_content_hash(command)
|
|
33
|
+
mentry = @container.manifest.resolver.resolve(key).entry
|
|
34
|
+
ts = now.iso8601
|
|
35
|
+
|
|
36
|
+
structured = build_structured(ts, now, content_hash, command)
|
|
37
|
+
store = @container.job_store
|
|
38
|
+
index = Textus::Store::Index::Lookup.new(store:)
|
|
39
|
+
|
|
40
|
+
duplicate_key = find_duplicate(index, content_hash, command)
|
|
41
|
+
|
|
42
|
+
env = if duplicate_key && duplicate_key != key
|
|
43
|
+
supersede_entry(duplicate_key, key, structured, call, store, command)
|
|
44
|
+
else
|
|
45
|
+
write_entry(key, structured, mentry, call)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
rebuild_index(store)
|
|
49
|
+
Value::Result.success(env)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def derive_key(now, kind, slug)
|
|
55
|
+
date = now.strftime("%Y.%m.%d")
|
|
56
|
+
"raw.#{date}.#{kind}-#{slug}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compute_content_hash(command)
|
|
60
|
+
digest = Digest::SHA256.new
|
|
61
|
+
case command.kind
|
|
62
|
+
when "url" then digest.update(command.url)
|
|
63
|
+
when "file", "asset" then digest.file(command.path)
|
|
64
|
+
end
|
|
65
|
+
"#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_structured(timestamp, now, content_hash, command)
|
|
69
|
+
base = { "ingested_at" => timestamp, "content_hash" => content_hash }
|
|
70
|
+
case command.kind
|
|
71
|
+
when "url"
|
|
72
|
+
base.merge("source" => { "kind" => "url", "url" => command.url,
|
|
73
|
+
"label" => command.label || command.url }, "body" => nil)
|
|
74
|
+
when "file"
|
|
75
|
+
base.merge("source" => { "kind" => "file", "path" => command.path,
|
|
76
|
+
"label" => command.label || File.basename(command.path) },
|
|
77
|
+
"body" => File.read(command.path))
|
|
78
|
+
when "asset"
|
|
79
|
+
asset_rel = copy_asset(now, command.path, command.lane)
|
|
80
|
+
base.merge("source" => { "kind" => "asset",
|
|
81
|
+
"label" => command.label || File.basename(command.path) },
|
|
82
|
+
"asset" => asset_rel, "body" => nil)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def copy_asset(now, path, lane)
|
|
87
|
+
date_path = now.strftime("%Y/%m/%d")
|
|
88
|
+
filename = File.basename(path)
|
|
89
|
+
assets_dir = @container.layout.asset_raw_dir(date_path, lane)
|
|
90
|
+
FileUtils.mkdir_p(assets_dir)
|
|
91
|
+
FileUtils.cp(path, File.join(assets_dir, filename))
|
|
92
|
+
sentinel = @container.layout.asset_sentinel_path
|
|
93
|
+
File.write(sentinel, "*\n") unless File.exist?(sentinel)
|
|
94
|
+
"raw/#{date_path}/#{lane}/#{filename}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def write_entry(key, structured, mentry, call)
|
|
98
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
99
|
+
writer.put(key, mentry: mentry,
|
|
100
|
+
payload: Textus::Value::Payload.new(meta: nil, body: nil, content: structured))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_duplicate(index, content_hash, command)
|
|
104
|
+
dup = index.find_by_hash(content_hash)
|
|
105
|
+
return dup if dup
|
|
106
|
+
return unless command.kind == "url"
|
|
107
|
+
|
|
108
|
+
index.find_by_url(command.url)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def supersede_entry(old_key, new_key, structured, call, store, command)
|
|
112
|
+
old_mentry = @container.manifest.resolver.resolve(old_key).entry
|
|
113
|
+
reader = Store::Entry::Reader.from(container: @container)
|
|
114
|
+
old_env = reader.read(old_key)
|
|
115
|
+
old_content = old_env&.content || {}
|
|
116
|
+
tombstone = {}
|
|
117
|
+
%w[ingested_at].each { |k| tombstone[k] = old_content[k] if old_content.key?(k) }
|
|
118
|
+
source_kind = old_content.dig("source", "kind")
|
|
119
|
+
tombstone["source"] = { "kind" => source_kind } if source_kind
|
|
120
|
+
tombstone["superseded_by"] = new_key
|
|
121
|
+
|
|
122
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
123
|
+
writer.put(old_key, mentry: old_mentry,
|
|
124
|
+
payload: Textus::Value::Payload.new(meta: nil, body: nil, content: tombstone))
|
|
125
|
+
|
|
126
|
+
structured["supersedes"] = old_key
|
|
127
|
+
env = write_entry(new_key, structured,
|
|
128
|
+
@container.manifest.resolver.resolve(new_key).entry, call)
|
|
129
|
+
|
|
130
|
+
move_asset(old_content["asset"], command.lane) if command.kind == "asset" && old_content["asset"]
|
|
131
|
+
|
|
132
|
+
rebuild_index(store)
|
|
133
|
+
env
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def move_asset(old_rel, lane)
|
|
137
|
+
old_path = @container.layout.asset_resolve(old_rel)
|
|
138
|
+
return unless File.exist?(old_path)
|
|
139
|
+
|
|
140
|
+
now = Time.now.utc
|
|
141
|
+
date_path = now.strftime("%Y/%m/%d")
|
|
142
|
+
filename = File.basename(old_path)
|
|
143
|
+
new_dir = @container.layout.asset_raw_dir(date_path, lane)
|
|
144
|
+
new_path = File.join(new_dir, filename)
|
|
145
|
+
return if old_path == new_path
|
|
146
|
+
|
|
147
|
+
FileUtils.mkdir_p(new_dir)
|
|
148
|
+
FileUtils.mv(old_path, new_path)
|
|
149
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
150
|
+
warn "[textus ingest] could not move asset #{old_rel}: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def rebuild_index(store)
|
|
154
|
+
Textus::Store::Index::Builder.new(store:).rebuild!(resolver: @container.manifest.resolver)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class JobsAction
|
|
5
|
+
def initialize(job_store:)
|
|
6
|
+
@job_store = job_store
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
queue = Textus::Store::Jobs::Queue.new(store: @job_store)
|
|
11
|
+
case command.action
|
|
12
|
+
when "retry" then queue.retry_failed(command.job_id)
|
|
13
|
+
when "purge" then queue.purge(command.state)
|
|
14
|
+
end
|
|
15
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true,
|
|
16
|
+
"state" => command.state, "jobs" => queue.list(command.state))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class PublishedEntries
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(_command, _call)
|
|
10
|
+
Value::Result.success(@manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
|
|
11
|
+
{ "key" => entry.key, "publish_to" => entry.publish_to }
|
|
12
|
+
end)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class RuleExplain
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
key = command.key
|
|
11
|
+
result = if command.detail
|
|
12
|
+
explain(key)
|
|
13
|
+
else
|
|
14
|
+
effective(key)
|
|
15
|
+
end
|
|
16
|
+
Value::Result.success(result)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
LEAN_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
20
|
+
.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
|
|
21
|
+
DETAIL_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
22
|
+
.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
|
|
23
|
+
EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| Textus::Manifest::Schema::FIELD_REGISTRY[f][:policy_class] }.freeze
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def effective(key)
|
|
28
|
+
set = @manifest.rules.for(key)
|
|
29
|
+
LEAN_FIELDS.each_with_object({}) do |field, out|
|
|
30
|
+
value = set.public_send(field)
|
|
31
|
+
out[field.to_s] = lean_value(field, value) unless value.nil?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lean_value(field, value)
|
|
36
|
+
case field
|
|
37
|
+
when :retention then retention_hash(value, string_keys: true)
|
|
38
|
+
when :react then value.to_h
|
|
39
|
+
else value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def explain(key)
|
|
44
|
+
matching = @manifest.rules.explain(key)
|
|
45
|
+
winners = @manifest.rules.for(key)
|
|
46
|
+
{
|
|
47
|
+
key: key,
|
|
48
|
+
matched_blocks: matching.map do |block|
|
|
49
|
+
{ match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
|
|
50
|
+
end,
|
|
51
|
+
effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
|
|
52
|
+
guards: Textus::Manifest::Policy::Predicates::FLOOR.keys.to_h do |action|
|
|
53
|
+
floor = Textus::Manifest::Policy::Predicates::FLOOR.fetch(action, [])
|
|
54
|
+
rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
|
|
55
|
+
[action, { floor: floor, rule: rule }]
|
|
56
|
+
end,
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def effective_value(field, value)
|
|
61
|
+
return nil if value.nil?
|
|
62
|
+
|
|
63
|
+
case field
|
|
64
|
+
when :retention then retention_hash(value, string_keys: false)
|
|
65
|
+
when :react then value.to_h
|
|
66
|
+
else value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def retention_hash(retention, string_keys:)
|
|
71
|
+
h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
|
|
72
|
+
string_keys ? h.transform_keys(&:to_s) : h
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Handlers
|
|
5
|
+
module Maintenance
|
|
6
|
+
class RuleLint
|
|
7
|
+
def initialize(manifest:)
|
|
8
|
+
@manifest = manifest
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(command, _call)
|
|
12
|
+
root = @manifest.data.root
|
|
13
|
+
live_rules = current_rules(root)
|
|
14
|
+
candidate_result = parse_candidate(command.candidate_yaml)
|
|
15
|
+
return candidate_result if candidate_result.is_a?(Value::Result) && candidate_result.failure?
|
|
16
|
+
|
|
17
|
+
candidate_rules = candidate_result
|
|
18
|
+
live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
|
|
19
|
+
candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
|
|
20
|
+
|
|
21
|
+
steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
|
|
22
|
+
{ "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
|
|
23
|
+
end
|
|
24
|
+
(live_by_match.keys - candidate_by_match.keys).each do |match|
|
|
25
|
+
steps << { "op" => "remove_rule", "match" => match }
|
|
26
|
+
end
|
|
27
|
+
(live_by_match.keys & candidate_by_match.keys).each do |match|
|
|
28
|
+
next if live_by_match[match] == candidate_by_match[match]
|
|
29
|
+
|
|
30
|
+
steps << { "op" => "change_rule", "match" => match, "from" => live_by_match[match], "to" => candidate_by_match[match] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Value::Result.success(Textus::Store::Jobs::Plan.new(steps: steps, warnings: []))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def current_rules(root)
|
|
39
|
+
raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
|
|
40
|
+
Array(raw["rules"])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_candidate(yaml_text)
|
|
44
|
+
raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
|
|
45
|
+
return Value::Result.failure(:usage_error, "candidate is not a YAML mapping") unless raw.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
Array(raw["rules"])
|
|
48
|
+
rescue Psych::Exception => e
|
|
49
|
+
Value::Result.failure(:usage_error, "candidate YAML parse error: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class RuleList
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(_command, _call)
|
|
10
|
+
Value::Result.success(@manifest.rules.blocks.map do |block|
|
|
11
|
+
row = { "match" => block.match }
|
|
12
|
+
LIST_FIELDS.each do |field|
|
|
13
|
+
value = block.public_send(field)
|
|
14
|
+
row[field.to_s] = serialize(field, value) unless value.nil?
|
|
15
|
+
end
|
|
16
|
+
row
|
|
17
|
+
end)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
|
|
21
|
+
|
|
22
|
+
def serialize(field, value)
|
|
23
|
+
case field
|
|
24
|
+
when :retention then { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
|
|
25
|
+
when :react then value.to_h
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class SchemaEnvelope
|
|
5
|
+
def initialize(manifest:, schemas:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@schemas = schemas
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
mentry = @manifest.resolver.resolve(command.key).entry
|
|
12
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
13
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
|
|
14
|
+
"schema_ref" => mentry.schema, "schema" => schema&.to_h)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class AuditEntries
|
|
5
|
+
def initialize(manifest:, audit_log:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@audit_log = audit_log
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
cursor_check = check_cursor_expiry(command.seq_since)
|
|
12
|
+
return cursor_check if cursor_check
|
|
13
|
+
|
|
14
|
+
rows = @audit_log.scan(
|
|
15
|
+
seq_since: command.seq_since,
|
|
16
|
+
key: command.key, role: command.role, verb: command.verb,
|
|
17
|
+
correlation_id: command.correlation_id, limit: command.limit
|
|
18
|
+
).select do |row|
|
|
19
|
+
next false if command.lane && !key_in_lane?(row["key"], command.lane)
|
|
20
|
+
next false if command.since && (row["ts"].nil? || Time.parse(row["ts"]) < command.since)
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
Value::Result.success(rows)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def check_cursor_expiry(seq_since)
|
|
30
|
+
return unless seq_since
|
|
31
|
+
|
|
32
|
+
min = @audit_log.min_available_seq
|
|
33
|
+
return unless min && seq_since < min - 1
|
|
34
|
+
|
|
35
|
+
Value::Result.failure(:cursor_expired, "requested seq #{seq_since} is below minimum available #{min}",
|
|
36
|
+
details: { requested: seq_since, min_available: min })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def key_in_lane?(key, lane)
|
|
40
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
41
|
+
mentry && mentry.lane == lane
|
|
42
|
+
rescue Textus::Error
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class BlameEntry
|
|
5
|
+
def initialize(manifest:, orchestration:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@orchestration = orchestration
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, call)
|
|
11
|
+
root = @manifest.data.root
|
|
12
|
+
audit = @orchestration.audit_entries(key: command.key, limit: command.limit, call: call)
|
|
13
|
+
return audit if audit.failure?
|
|
14
|
+
|
|
15
|
+
audit_rows = audit.value.fetch("rows")
|
|
16
|
+
|
|
17
|
+
path = resolve_path(command.key)
|
|
18
|
+
return Value::Result.success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
|
|
19
|
+
|
|
20
|
+
Value::Result.success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def resolve_path(key)
|
|
26
|
+
res = @manifest.resolver.resolve(key)
|
|
27
|
+
path = res.path
|
|
28
|
+
path || Textus::Key::Path.resolve(@manifest.data, res.entry)
|
|
29
|
+
rescue Textus::Error
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def git_tracked?(path, root:)
|
|
34
|
+
return false if path.nil? || !File.exist?(path) || !git_repo?(root)
|
|
35
|
+
|
|
36
|
+
_out, _err, status = Open3.capture3("git", "ls-files", "--error-unmatch", path, chdir: root)
|
|
37
|
+
status.success?
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def git_repo?(root)
|
|
43
|
+
dir = root
|
|
44
|
+
loop do
|
|
45
|
+
return true if File.directory?(File.join(dir, ".git"))
|
|
46
|
+
|
|
47
|
+
parent = File.dirname(dir)
|
|
48
|
+
return false if parent == dir
|
|
49
|
+
|
|
50
|
+
dir = parent
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def git_commit_at(path, timestamp:, root:)
|
|
55
|
+
args = ["git", "log", "-1"]
|
|
56
|
+
args << "--before=#{timestamp}" if timestamp
|
|
57
|
+
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
58
|
+
out, _err, status = Open3.capture3(*args, chdir: root)
|
|
59
|
+
return nil unless status.success?
|
|
60
|
+
|
|
61
|
+
sha, author, date, subject = out.strip.split("\t", 4)
|
|
62
|
+
return nil if sha.nil? || sha.empty?
|
|
63
|
+
|
|
64
|
+
{ "sha" => sha, "author" => author, "date" => date, "subject" => subject }
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class DepsEntry
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
entry = @manifest.data.entries.find { |e| e.key == command.key }
|
|
11
|
+
deps = entry&.external? ? Array(entry.source&.sources).compact : []
|
|
12
|
+
Value::Result.success("key" => command.key, "deps" => deps.uniq)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class GetEntry
|
|
5
|
+
def initialize(container:, freshness_evaluator:)
|
|
6
|
+
@container = container
|
|
7
|
+
@freshness_evaluator = freshness_evaluator
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
envelope = Store::Entry::Reader.from(container: @container).read(command.key)
|
|
12
|
+
return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
|
|
13
|
+
|
|
14
|
+
envelope = expand_sources(envelope, depth: 0)
|
|
15
|
+
Value::Result.success(envelope.with(freshness: @freshness_evaluator.verdict(resolve_entry(command.key))))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
MAX_SOURCE_DEPTH = 5
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def expand_sources(envelope, depth:)
|
|
23
|
+
return envelope if depth >= MAX_SOURCE_DEPTH
|
|
24
|
+
|
|
25
|
+
raw_sources = Array(envelope.meta["sources"])
|
|
26
|
+
return envelope if raw_sources.empty?
|
|
27
|
+
|
|
28
|
+
expanded = raw_sources.map { |src| expand_one_source(src, depth: depth) }
|
|
29
|
+
envelope.with(sources: expanded)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def expand_one_source(src, depth:)
|
|
33
|
+
src = { "key" => src } if src.is_a?(String)
|
|
34
|
+
return src unless src.is_a?(Hash) && src["key"].is_a?(String)
|
|
35
|
+
|
|
36
|
+
key = src["key"]
|
|
37
|
+
stored_etag = src["etag"]
|
|
38
|
+
current_etag = resolve_current_etag(key)
|
|
39
|
+
suspended = stored_etag && current_etag ? stored_etag != current_etag : false
|
|
40
|
+
|
|
41
|
+
result = src.merge("suspended" => suspended)
|
|
42
|
+
|
|
43
|
+
child_env = @container.reader.read(key)
|
|
44
|
+
if child_env
|
|
45
|
+
child_expanded = expand_sources(child_env, depth: depth + 1)
|
|
46
|
+
child_sources = Array(child_expanded.sources)
|
|
47
|
+
result = result.merge("sources" => child_sources) unless child_sources.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_current_etag(key)
|
|
54
|
+
path = @container.manifest.resolver.resolve(key).path
|
|
55
|
+
return nil unless @container.file_store.exists?(path)
|
|
56
|
+
|
|
57
|
+
@container.file_store.etag(path)
|
|
58
|
+
rescue Textus::Error
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_entry(key)
|
|
63
|
+
@container.manifest.resolver.resolve(key).entry
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class ListKeys
|
|
5
|
+
def initialize(manifest:, job_store: nil)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@job_store = job_store
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
q = command.respond_to?(:q) ? command.q : nil
|
|
12
|
+
schema = command.respond_to?(:schema) ? command.schema : nil
|
|
13
|
+
|
|
14
|
+
return sqlite_list(q: q, schema: schema, lane: command.lane, prefix: command.prefix) if @job_store && (q || schema)
|
|
15
|
+
|
|
16
|
+
manifest_list(prefix: command.prefix, lane: command.lane)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def sqlite_list(q:, schema:, lane:, prefix:) # rubocop:disable Naming/MethodParameterName
|
|
22
|
+
rows = @job_store.search_entries(q: q, schema: schema, lane: lane, prefix: prefix)
|
|
23
|
+
Value::Result.success((rows || []).map { |r| { "key" => r["key"], "lane" => r["lane"] } })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def manifest_list(prefix:, lane:)
|
|
27
|
+
rows = @manifest.resolver.enumerate(prefix: prefix)
|
|
28
|
+
rows = rows.select { |row| row[:manifest_entry].lane == lane } if lane
|
|
29
|
+
Value::Result.success(rows.map do |row|
|
|
30
|
+
{ "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] }
|
|
31
|
+
end)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|