textus 0.22.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +195 -48
- data/CHANGELOG.md +178 -0
- data/README.md +55 -13
- data/SPEC.md +79 -42
- data/docs/conventions.md +10 -0
- data/lib/textus/boot.rb +31 -29
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +3 -3
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +7 -7
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +11 -9
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +12 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +6 -6
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +20 -12
- data/lib/textus/domain/staleness.rb +4 -4
- data/lib/textus/envelope/io/reader.rb +44 -0
- data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +79 -0
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +8 -9
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +47 -110
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +126 -0
- data/lib/textus/mcp/session.rb +40 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/store.rb +25 -11
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +7 -1
- metadata +75 -46
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/projection.rb +0 -91
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/validator.rb +0 -86
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/orchestrator.rb +0 -78
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/authority_gate.rb +0 -26
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/materializer.rb +0 -50
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
|
@@ -4,11 +4,11 @@ module Textus
|
|
|
4
4
|
class Templates < Check
|
|
5
5
|
def call
|
|
6
6
|
out = []
|
|
7
|
-
|
|
7
|
+
manifest.data.entries.each do |entry|
|
|
8
8
|
template = entry.respond_to?(:template) ? entry.template : nil
|
|
9
9
|
next if template.nil?
|
|
10
10
|
|
|
11
|
-
tp = File.join(
|
|
11
|
+
tp = File.join(root, "templates", template)
|
|
12
12
|
next if File.exist?(tp)
|
|
13
13
|
|
|
14
14
|
out << {
|
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,7 +24,16 @@ module Textus
|
|
|
24
24
|
|
|
25
25
|
protected
|
|
26
26
|
|
|
27
|
-
|
|
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
|
|
28
37
|
end
|
|
29
38
|
end
|
|
30
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,30 +52,27 @@ module Textus
|
|
|
52
52
|
}
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
def run_registered_checks(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
63
|
-
else
|
|
64
|
-
out << fail_issue(name, code: "doctor_check.bad_return",
|
|
65
|
-
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
66
|
-
fix: "return an array of issue hashes from the doctor_check block")
|
|
67
|
-
end
|
|
68
|
-
rescue Timeout::Error
|
|
69
|
-
out << fail_issue(name, code: "doctor_check.timeout",
|
|
70
|
-
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
71
|
-
fix: "shorten the check or split it into smaller checks")
|
|
72
|
-
rescue StandardError => e
|
|
73
|
-
out << fail_issue(name, code: "doctor_check.failed",
|
|
74
|
-
message: "#{e.class}: #{e.message}",
|
|
75
|
-
fix: "fix the :validate hook in .textus/hooks/")
|
|
76
|
-
end
|
|
55
|
+
def run_registered_checks(container)
|
|
56
|
+
container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invoke_registered_check(container, name)
|
|
60
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
|
|
61
|
+
container.rpc.invoke(:validate, name, caps: container)
|
|
77
62
|
end
|
|
78
|
-
|
|
63
|
+
return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
[fail_issue(name, code: "doctor_check.bad_return",
|
|
66
|
+
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
67
|
+
fix: "return an array of issue hashes from the doctor_check block")]
|
|
68
|
+
rescue Timeout::Error
|
|
69
|
+
[fail_issue(name, code: "doctor_check.timeout",
|
|
70
|
+
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
71
|
+
fix: "shorten the check or split it into smaller checks")]
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
[fail_issue(name, code: "doctor_check.failed",
|
|
74
|
+
message: "#{e.class}: #{e.message}",
|
|
75
|
+
fix: "fix the :validate hook in .textus/hooks/")]
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
def fail_issue(name, code:, message:, fix:)
|
|
@@ -88,6 +85,6 @@ module Textus
|
|
|
88
85
|
}
|
|
89
86
|
end
|
|
90
87
|
|
|
91
|
-
private_class_method :run_registered_checks, :fail_issue
|
|
88
|
+
private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
|
|
92
89
|
end
|
|
93
90
|
end
|
|
@@ -3,32 +3,32 @@
|
|
|
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
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def can_write?(zone, role:)
|
|
14
|
-
@manifest.permission_for(zone.to_s).allows_write?(role)
|
|
14
|
+
@manifest.policy.permission_for(zone.to_s).allows_write?(role)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def can_read?(zone, role:)
|
|
18
|
-
@manifest.permission_for(zone.to_s).allows_read?(role)
|
|
18
|
+
@manifest.policy.permission_for(zone.to_s).allows_read?(role)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def authorize_write!(mentry, role:)
|
|
22
22
|
return if can_write?(mentry.zone, role: role)
|
|
23
23
|
|
|
24
|
-
writers = @manifest.zone_writers(mentry.zone)
|
|
24
|
+
writers = @manifest.policy.zone_writers(mentry.zone)
|
|
25
25
|
raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def authorize_read!(mentry, role:)
|
|
29
29
|
return if can_read?(mentry.zone, role: role)
|
|
30
30
|
|
|
31
|
-
readers = @manifest.zone_readers[mentry.zone]
|
|
31
|
+
readers = @manifest.policy.zone_readers[mentry.zone]
|
|
32
32
|
readers = nil if readers == :all
|
|
33
33
|
raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
|
|
34
34
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
module Predicates
|
|
5
5
|
# Promotion predicate: the role driving the promotion must have
|
|
@@ -20,7 +20,7 @@ module Textus
|
|
|
20
20
|
role_str = role&.to_s
|
|
21
21
|
return true if role_str.nil? || role_str.empty?
|
|
22
22
|
|
|
23
|
-
kind = manifest.role_kind(role_str)
|
|
23
|
+
kind = manifest.policy.role_kind(role_str)
|
|
24
24
|
return true if kind == :accept_authority
|
|
25
25
|
|
|
26
26
|
@reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
|
|
@@ -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)
|
|
17
|
+
return [] unless applicable?(mentry)
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
20
|
+
reason = stale_reason(mentry, path)
|
|
21
|
+
reason ? [stale_row(mentry, path, reason)] : []
|
|
22
|
+
end
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
|
|
24
|
+
private
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
return
|
|
32
|
+
def stale_reason(mentry, path)
|
|
33
|
+
return "derived entry has never been generated" unless @file_stat.exists?(path)
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
return
|
|
35
|
+
generated_at = generated_at_of(mentry, path)
|
|
36
|
+
return "missing generated.at frontmatter" unless generated_at
|
|
34
37
|
|
|
35
|
-
|
|
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,30 +6,38 @@ 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)
|
|
14
16
|
return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
15
17
|
|
|
16
|
-
ttl = @manifest.
|
|
18
|
+
ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
|
|
17
19
|
return [] unless ttl
|
|
18
20
|
|
|
19
|
-
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
20
|
-
|
|
21
|
+
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
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,14 +1,14 @@
|
|
|
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)
|
|
11
|
-
@manifest.entries
|
|
11
|
+
@manifest.data.entries
|
|
12
12
|
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
13
13
|
.flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
|
|
14
14
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Envelope
|
|
3
|
+
module IO
|
|
4
|
+
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
5
|
+
# bytes, parses them via the format strategy, and hands back an
|
|
6
|
+
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
7
|
+
# (existing-uid lookup for the uid-preservation step in #put).
|
|
8
|
+
#
|
|
9
|
+
# No audit, no events, no permission checks — those live one layer up.
|
|
10
|
+
class Reader
|
|
11
|
+
def initialize(file_store:, manifest:)
|
|
12
|
+
@file_store = file_store
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(key)
|
|
17
|
+
res = @manifest.resolver.resolve(key)
|
|
18
|
+
path = res.path
|
|
19
|
+
return nil unless @file_store.exists?(path)
|
|
20
|
+
|
|
21
|
+
mentry = res.entry
|
|
22
|
+
raw = @file_store.read(path)
|
|
23
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
24
|
+
Textus::Envelope.build(
|
|
25
|
+
key: key, mentry: mentry, path: path,
|
|
26
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
27
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def existing_uid(key)
|
|
32
|
+
env = read(key)
|
|
33
|
+
env&.uid
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exists?(key)
|
|
39
|
+
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|