textus 0.26.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +111 -67
- data/CHANGELOG.md +76 -0
- data/README.md +55 -13
- data/SPEC.md +75 -38
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +14 -10
- 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 -2
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- data/lib/textus/doctor/check/audit_log.rb +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 +12 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- 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 +2 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +11 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/rpc_registry.rb +1 -1
- 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 +4 -3
- 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.rb +1 -6
- data/lib/textus/mcp/server.rb +1 -2
- data/lib/textus/mcp/session.rb +10 -1
- 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 +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +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 +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +1 -2
- metadata +54 -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
|
@@ -3,22 +3,24 @@ module Textus
|
|
|
3
3
|
class Check
|
|
4
4
|
class Sentinels < Check
|
|
5
5
|
def call
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
store = Textus::Ports::SentinelStore.new
|
|
7
|
+
file_stat = Textus::Ports::Storage::FileStat.new
|
|
8
|
+
dir = File.join(root, "sentinels")
|
|
9
|
+
return [] unless file_stat.directory?(dir)
|
|
8
10
|
|
|
9
11
|
repo_root = File.dirname(root)
|
|
10
|
-
|
|
11
|
-
inspect_sentinel(sentinel_path, repo_root)
|
|
12
|
+
file_stat.glob(File.join(dir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
|
|
13
|
+
inspect_sentinel(sentinel_path, repo_root, store, file_stat)
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
private
|
|
16
18
|
|
|
17
|
-
def inspect_sentinel(sentinel_path, repo_root)
|
|
18
|
-
sentinel =
|
|
19
|
+
def inspect_sentinel(sentinel_path, repo_root, store, file_stat)
|
|
20
|
+
sentinel = store.load(sentinel_path, repo_root)
|
|
19
21
|
return [parse_error_issue(sentinel_path)] if sentinel.nil?
|
|
20
|
-
return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
|
|
21
|
-
return [drift_issue(sentinel)] if sentinel.drift?
|
|
22
|
+
return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?(file_stat)
|
|
23
|
+
return [drift_issue(sentinel)] if sentinel.drift?(file_stat)
|
|
22
24
|
|
|
23
25
|
[]
|
|
24
26
|
end
|
data/lib/textus/doctor/check.rb
CHANGED
|
@@ -14,8 +14,8 @@ module Textus
|
|
|
14
14
|
.downcase
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def initialize(
|
|
18
|
-
@
|
|
17
|
+
def initialize(container)
|
|
18
|
+
@container = container
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def call
|
|
@@ -24,9 +24,16 @@ module Textus
|
|
|
24
24
|
|
|
25
25
|
protected
|
|
26
26
|
|
|
27
|
-
def root = @
|
|
28
|
-
def manifest = @
|
|
29
|
-
def rpc = @
|
|
27
|
+
def root = @container.root
|
|
28
|
+
def manifest = @container.manifest
|
|
29
|
+
def rpc = @container.rpc
|
|
30
|
+
|
|
31
|
+
# Dispatch a verb through the static Dispatcher table.
|
|
32
|
+
def dispatch(verb, *, **)
|
|
33
|
+
klass = Textus::Dispatcher.fetch(verb)
|
|
34
|
+
call_value = Textus::Call.build(role: Textus::Role::DEFAULT)
|
|
35
|
+
klass.new(container: @container, call: call_value).call(*, **)
|
|
36
|
+
end
|
|
30
37
|
end
|
|
31
38
|
end
|
|
32
39
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Textus
|
|
|
30
30
|
|
|
31
31
|
module_function
|
|
32
32
|
|
|
33
|
-
def
|
|
33
|
+
def build(container:, checks: nil)
|
|
34
34
|
selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
35
35
|
unknown = selected_keys - ALL_CHECKS
|
|
36
36
|
unless unknown.empty?
|
|
@@ -40,8 +40,8 @@ module Textus
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
|
|
43
|
-
issues = selected.flat_map { |c| c.new(
|
|
44
|
-
issues.concat(run_registered_checks(
|
|
43
|
+
issues = selected.flat_map { |c| c.new(container).call }
|
|
44
|
+
issues.concat(run_registered_checks(container))
|
|
45
45
|
|
|
46
46
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
47
47
|
{
|
|
@@ -52,13 +52,13 @@ module Textus
|
|
|
52
52
|
}
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
def run_registered_checks(
|
|
56
|
-
|
|
55
|
+
def run_registered_checks(container)
|
|
56
|
+
container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def invoke_registered_check(
|
|
59
|
+
def invoke_registered_check(container, name)
|
|
60
60
|
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
|
|
61
|
-
|
|
61
|
+
container.rpc.invoke(:validate, name, caps: container)
|
|
62
62
|
end
|
|
63
63
|
return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
|
|
64
64
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Domain
|
|
5
5
|
# Authorization service. Single source of truth for "given a manifest
|
|
6
|
-
# entry and a role, may this caller read/write?".
|
|
7
|
-
#
|
|
6
|
+
# entry and a role, may this caller read/write?". Lives in Domain
|
|
7
|
+
# alongside Permission.
|
|
8
8
|
class Authorizer
|
|
9
9
|
def initialize(manifest:)
|
|
10
10
|
@manifest = manifest
|
|
@@ -1,69 +1,15 @@
|
|
|
1
|
-
require "json"
|
|
2
1
|
require "digest"
|
|
3
|
-
require "fileutils"
|
|
4
2
|
|
|
5
3
|
module Textus
|
|
6
4
|
module Domain
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
5
|
+
# Pure value object representing a published-file sentinel. Holds the
|
|
6
|
+
# recorded target path, source path, sha256 checksum, and publish mode.
|
|
7
|
+
# Has no filesystem I/O — path layout and persistence live in
|
|
8
|
+
# Ports::SentinelStore; predicate methods accept a FileStat port for
|
|
9
|
+
# existence and content checks.
|
|
12
10
|
class Sentinel
|
|
13
|
-
SUFFIX = ".textus-managed.json".freeze
|
|
14
|
-
DIR = "sentinels".freeze
|
|
15
|
-
|
|
16
11
|
attr_reader :target, :source, :sha256, :mode
|
|
17
12
|
|
|
18
|
-
def self.write!(target:, source:, store_root:)
|
|
19
|
-
path = sentinel_path(target, store_root)
|
|
20
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
21
|
-
repo_root = File.dirname(store_root)
|
|
22
|
-
File.write(path, JSON.generate(
|
|
23
|
-
"source" => rel_or_abs(source, repo_root),
|
|
24
|
-
"target" => rel_or_abs(target, repo_root),
|
|
25
|
-
"sha256" => Digest::SHA256.hexdigest(File.binread(target)),
|
|
26
|
-
"mode" => "copy",
|
|
27
|
-
))
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def self.load(path, repo_root)
|
|
31
|
-
raw = JSON.parse(File.read(path))
|
|
32
|
-
new(
|
|
33
|
-
target: absolutize(raw["target"], repo_root),
|
|
34
|
-
source: absolutize(raw["source"], repo_root),
|
|
35
|
-
sha256: raw["sha256"],
|
|
36
|
-
mode: raw["mode"],
|
|
37
|
-
)
|
|
38
|
-
rescue JSON::ParserError, Errno::ENOENT
|
|
39
|
-
nil
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def self.sentinel_path(target, store_root)
|
|
43
|
-
repo_root = File.dirname(store_root)
|
|
44
|
-
rel = relative_to(target, repo_root) || File.basename(target)
|
|
45
|
-
File.join(store_root, DIR, rel + SUFFIX)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def self.rel_or_abs(path, repo_root)
|
|
49
|
-
relative_to(path, repo_root) || File.expand_path(path)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def self.relative_to(path, repo_root)
|
|
53
|
-
path = File.expand_path(path)
|
|
54
|
-
base = File.expand_path(repo_root)
|
|
55
|
-
return nil unless path.start_with?(base + File::SEPARATOR)
|
|
56
|
-
|
|
57
|
-
path[(base.length + 1)..]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def self.absolutize(path, repo_root)
|
|
61
|
-
return path if path.nil?
|
|
62
|
-
return path if File.absolute_path?(path)
|
|
63
|
-
|
|
64
|
-
File.expand_path(path, repo_root)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
13
|
def initialize(target:, source:, sha256:, mode:)
|
|
68
14
|
@target = target
|
|
69
15
|
@source = source
|
|
@@ -71,15 +17,13 @@ module Textus
|
|
|
71
17
|
@mode = mode
|
|
72
18
|
end
|
|
73
19
|
|
|
74
|
-
def orphan?
|
|
75
|
-
@target.nil? || !File.exist?(@target)
|
|
76
|
-
end
|
|
20
|
+
def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
|
|
77
21
|
|
|
78
|
-
def drift?
|
|
79
|
-
return false if orphan?
|
|
22
|
+
def drift?(file_stat)
|
|
23
|
+
return false if orphan?(file_stat)
|
|
80
24
|
return false if @sha256.nil?
|
|
81
25
|
|
|
82
|
-
Digest::SHA256.hexdigest(
|
|
26
|
+
Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
|
|
83
27
|
end
|
|
84
28
|
end
|
|
85
29
|
end
|
|
@@ -8,34 +8,43 @@ module Textus
|
|
|
8
8
|
# entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
|
|
9
9
|
# (possibly empty) per entry.
|
|
10
10
|
class GeneratorCheck
|
|
11
|
-
def initialize(manifest:)
|
|
12
|
-
@manifest
|
|
11
|
+
def initialize(manifest:, file_stat:)
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
@file_stat = file_stat
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def rows_for(mentry)
|
|
16
|
-
return [] unless mentry
|
|
17
|
-
return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
|
|
18
|
-
|
|
19
|
-
src = mentry.source
|
|
20
|
-
return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
17
|
+
return [] unless applicable?(mentry)
|
|
21
18
|
|
|
22
19
|
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
23
|
-
|
|
20
|
+
reason = stale_reason(mentry, path)
|
|
21
|
+
reason ? [stale_row(mentry, path, reason)] : []
|
|
22
|
+
end
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
generated_at = parsed["_meta"].dig("generated", "at")
|
|
27
|
-
return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
|
|
24
|
+
private
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
def applicable?(mentry)
|
|
27
|
+
mentry.in_generator_zone?(@manifest.policy) &&
|
|
28
|
+
mentry.is_a?(Textus::Manifest::Entry::Derived) &&
|
|
29
|
+
mentry.source.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
30
|
+
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
return
|
|
32
|
+
def stale_reason(mentry, path)
|
|
33
|
+
return "derived entry has never been generated" unless @file_stat.exists?(path)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
generated_at = generated_at_of(mentry, path)
|
|
36
|
+
return "missing generated.at frontmatter" unless generated_at
|
|
37
|
+
|
|
38
|
+
gen_time = parse_time(generated_at)
|
|
39
|
+
return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
|
|
40
|
+
|
|
41
|
+
offender = newest_source_after(mentry.source, gen_time)
|
|
42
|
+
"source '#{offender}' modified after generated.at" if offender
|
|
36
43
|
end
|
|
37
44
|
|
|
38
|
-
|
|
45
|
+
def generated_at_of(mentry, path)
|
|
46
|
+
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
|
|
47
|
+
end
|
|
39
48
|
|
|
40
49
|
def parse_time(str)
|
|
41
50
|
Time.parse(str.to_s)
|
|
@@ -54,7 +63,7 @@ module Textus
|
|
|
54
63
|
def check_source(src, gen_time)
|
|
55
64
|
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
56
65
|
@manifest.resolver.enumerate(prefix: src).each do |row|
|
|
57
|
-
return src if
|
|
66
|
+
return src if @file_stat.mtime(row[:path]) > gen_time
|
|
58
67
|
end
|
|
59
68
|
nil
|
|
60
69
|
else
|
|
@@ -63,18 +72,29 @@ module Textus
|
|
|
63
72
|
end
|
|
64
73
|
|
|
65
74
|
def check_filesystem_source(src, gen_time)
|
|
66
|
-
abs =
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return src if File.mtime(fp) > gen_time
|
|
71
|
-
end
|
|
72
|
-
nil
|
|
73
|
-
elsif File.exist?(abs) && File.mtime(abs) > gen_time
|
|
75
|
+
abs = absolutize_source(src)
|
|
76
|
+
if @file_stat.directory?(abs)
|
|
77
|
+
dir_has_newer_file?(abs, gen_time) ? src : nil
|
|
78
|
+
elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
|
|
74
79
|
src
|
|
75
80
|
end
|
|
76
81
|
end
|
|
77
82
|
|
|
83
|
+
def absolutize_source(src)
|
|
84
|
+
File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dir_has_newer_file?(abs, gen_time)
|
|
88
|
+
@file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
|
|
89
|
+
file?(fpath) && @file_stat.mtime(fpath) > gen_time
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# FileStat substitute for File.file?: excludes directories but treats
|
|
94
|
+
# special files (FIFOs/sockets/devices) as regular files — acceptable
|
|
95
|
+
# because a generator source tree won't contain them.
|
|
96
|
+
def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
|
|
97
|
+
|
|
78
98
|
def stale_row(mentry, path, reason)
|
|
79
99
|
{
|
|
80
100
|
"key" => mentry.key,
|
|
@@ -6,8 +6,10 @@ module Textus
|
|
|
6
6
|
# Reports TTL-exceeded staleness for intake-handler entries. Returns an
|
|
7
7
|
# Array of row hashes (possibly empty) per entry.
|
|
8
8
|
class IntakeCheck
|
|
9
|
-
def initialize(manifest:)
|
|
10
|
-
@manifest
|
|
9
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
@file_stat = file_stat
|
|
12
|
+
@clock = clock
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def rows_for(mentry)
|
|
@@ -17,19 +19,25 @@ module Textus
|
|
|
17
19
|
return [] unless ttl
|
|
18
20
|
|
|
19
21
|
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
20
|
-
|
|
22
|
+
reason = ttl_reason(mentry, path, ttl)
|
|
23
|
+
reason ? [row(mentry, path, reason)] : []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
|
|
28
|
+
def ttl_reason(mentry, path, ttl)
|
|
29
|
+
return "never refreshed" unless @file_stat.exists?(path)
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
return
|
|
31
|
+
last_str = last_refreshed_of(mentry, path)
|
|
32
|
+
return "never refreshed (no last_refreshed_at)" if last_str.nil?
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
last = parse_time(last_str)
|
|
35
|
+
"ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
def last_refreshed_of(mentry, path)
|
|
39
|
+
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
|
|
40
|
+
end
|
|
33
41
|
|
|
34
42
|
def parse_time(str)
|
|
35
43
|
Time.parse(str.to_s)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
3
|
class Staleness
|
|
4
|
-
def initialize(manifest:)
|
|
4
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
5
5
|
@manifest = manifest
|
|
6
|
-
@generator_check = GeneratorCheck.new(manifest: manifest)
|
|
7
|
-
@intake_check
|
|
6
|
+
@generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
|
|
7
|
+
@intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def call(prefix: nil, zone: nil)
|
|
@@ -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
|
|
@@ -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,16 @@ 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 initialize(file_store:, manifest:, schemas:, audit_log:,
|
|
17
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
18
18
|
@file_store = file_store
|
|
19
19
|
@manifest = manifest
|
|
20
20
|
@schemas = schemas
|
|
21
21
|
@audit_log = audit_log
|
|
22
|
-
@
|
|
22
|
+
@call = call
|
|
23
23
|
@reader = reader
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -56,9 +56,9 @@ module Textus
|
|
|
56
56
|
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
57
57
|
)
|
|
58
58
|
@audit_log.append(
|
|
59
|
-
role: @
|
|
59
|
+
role: @call.role, verb: "put", key: key,
|
|
60
60
|
etag_before: etag_before, etag_after: etag_after,
|
|
61
|
-
extras: @
|
|
61
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
62
62
|
)
|
|
63
63
|
envelope
|
|
64
64
|
end
|
|
@@ -75,9 +75,9 @@ module Textus
|
|
|
75
75
|
|
|
76
76
|
@file_store.delete(path)
|
|
77
77
|
@audit_log.append(
|
|
78
|
-
role: @
|
|
78
|
+
role: @call.role, verb: "delete", key: key,
|
|
79
79
|
etag_before: etag_before, etag_after: nil,
|
|
80
|
-
extras: @
|
|
80
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
81
81
|
)
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -108,10 +108,10 @@ module Textus
|
|
|
108
108
|
"from_path" => from_path, "to_path" => to_path,
|
|
109
109
|
"uid" => envelope.uid
|
|
110
110
|
}
|
|
111
|
-
extras["correlation_id"] = @
|
|
111
|
+
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
112
112
|
|
|
113
113
|
@audit_log.append(
|
|
114
|
-
role: @
|
|
114
|
+
role: @call.role, verb: "mv", key: to_key,
|
|
115
115
|
etag_before: etag_before, etag_after: etag_after,
|
|
116
116
|
extras: extras
|
|
117
117
|
)
|
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
|
|
@@ -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
|