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
data/lib/textus/cli.rb
CHANGED
|
@@ -14,13 +14,6 @@ module Textus
|
|
|
14
14
|
.to_h { |k| [k.command_name, k] }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
# Backward-compat constant; callers should prefer `CLI.verbs`.
|
|
18
|
-
def self.const_missing(name)
|
|
19
|
-
return verbs.freeze if name == :VERBS
|
|
20
|
-
|
|
21
|
-
super
|
|
22
|
-
end
|
|
23
|
-
|
|
24
17
|
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
|
|
25
18
|
new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
|
|
26
19
|
end
|
|
@@ -34,22 +27,25 @@ module Textus
|
|
|
34
27
|
end
|
|
35
28
|
|
|
36
29
|
def run(argv)
|
|
37
|
-
|
|
30
|
+
# Define --version/--help ourselves so OptionParser doesn't intercept them
|
|
31
|
+
# with its built-in handlers (which print "version unknown" and a bare usage
|
|
32
|
+
# line, then exit before we ever reach the verb dispatch below).
|
|
33
|
+
show_version = false
|
|
34
|
+
show_help = false
|
|
35
|
+
OptionParser.new do |o|
|
|
36
|
+
o.on("--root=PATH") { |v| @root_arg = v }
|
|
37
|
+
o.on("--version", "-v") { show_version = true }
|
|
38
|
+
o.on("--help", "-h") { show_help = true }
|
|
39
|
+
end.order!(argv)
|
|
40
|
+
|
|
41
|
+
return @stdout.puts(VERSION) || 0 if show_version
|
|
42
|
+
return print_help || 0 if show_help
|
|
43
|
+
|
|
38
44
|
verb = argv.shift
|
|
39
45
|
raise UsageError.new("missing verb") if verb.nil?
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
when "--version", "-v" then @stdout.puts(VERSION)
|
|
44
|
-
0
|
|
45
|
-
when "--help", "-h" then print_help
|
|
46
|
-
0
|
|
47
|
-
else
|
|
48
|
-
klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
49
|
-
dispatch(klass, argv)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
coerce_exit_code(result)
|
|
47
|
+
klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
48
|
+
coerce_exit_code(dispatch(klass, argv))
|
|
53
49
|
rescue Textus::Error => e
|
|
54
50
|
emit_error(e)
|
|
55
51
|
end
|
|
@@ -101,9 +97,9 @@ module Textus
|
|
|
101
97
|
textus doctor
|
|
102
98
|
textus boot
|
|
103
99
|
|
|
104
|
-
textus key {mv,uid
|
|
105
|
-
textus rule {list
|
|
106
|
-
textus schema {
|
|
100
|
+
textus key {delete,mv,uid}
|
|
101
|
+
textus rule {explain,lint,list}
|
|
102
|
+
textus schema {diff,init,migrate,show}
|
|
107
103
|
textus hook {list,run}
|
|
108
104
|
HELP
|
|
109
105
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Single capability record handed to every use case. Replaces the
|
|
3
|
+
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
|
|
4
|
+
Container = Data.define(
|
|
5
|
+
:manifest, :file_store, :schemas, :root,
|
|
6
|
+
:audit_log, :events, :rpc, :authorizer
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
class Container
|
|
10
|
+
def self.from_store(store)
|
|
11
|
+
new(
|
|
12
|
+
manifest: store.manifest,
|
|
13
|
+
file_store: store.file_store,
|
|
14
|
+
schemas: store.schemas,
|
|
15
|
+
root: store.root,
|
|
16
|
+
audit_log: store.audit_log,
|
|
17
|
+
events: store.events,
|
|
18
|
+
rpc: store.rpc,
|
|
19
|
+
authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Static verb → use-case map. Canonical lookup as of 0.27.0; replaces the
|
|
3
|
+
# Application::UseCase registry whose entries were populated by file-load
|
|
4
|
+
# side effects in 0.26.x.
|
|
5
|
+
module Dispatcher
|
|
6
|
+
VERBS = {
|
|
7
|
+
# Write
|
|
8
|
+
put: Textus::Write::Put,
|
|
9
|
+
delete: Textus::Write::Delete,
|
|
10
|
+
mv: Textus::Write::Mv,
|
|
11
|
+
accept: Textus::Write::Accept,
|
|
12
|
+
reject: Textus::Write::Reject,
|
|
13
|
+
publish: Textus::Write::Publish,
|
|
14
|
+
refresh: Textus::Write::RefreshWorker,
|
|
15
|
+
refresh_all: Textus::Write::RefreshAll,
|
|
16
|
+
retention_sweep: Textus::Write::RetentionSweep,
|
|
17
|
+
|
|
18
|
+
# Read
|
|
19
|
+
get: Textus::Read::Get,
|
|
20
|
+
get_or_refresh: Textus::Read::GetOrRefresh,
|
|
21
|
+
list: Textus::Read::List,
|
|
22
|
+
where: Textus::Read::Where,
|
|
23
|
+
uid: Textus::Read::Uid,
|
|
24
|
+
blame: Textus::Read::Blame,
|
|
25
|
+
audit: Textus::Read::Audit,
|
|
26
|
+
freshness: Textus::Read::Freshness,
|
|
27
|
+
stale: Textus::Read::Stale,
|
|
28
|
+
deps: Textus::Read::Deps,
|
|
29
|
+
rdeps: Textus::Read::Rdeps,
|
|
30
|
+
pulse: Textus::Read::Pulse,
|
|
31
|
+
policy_explain: Textus::Read::PolicyExplain,
|
|
32
|
+
published: Textus::Read::Published,
|
|
33
|
+
schema_envelope: Textus::Read::SchemaEnvelope,
|
|
34
|
+
validate_all: Textus::Read::ValidateAll,
|
|
35
|
+
doctor: Textus::Read::Doctor,
|
|
36
|
+
boot: Textus::Read::Boot,
|
|
37
|
+
retainable: Textus::Read::Retainable,
|
|
38
|
+
|
|
39
|
+
# Maintenance
|
|
40
|
+
migrate: Textus::Maintenance::Migrate,
|
|
41
|
+
zone_mv: Textus::Maintenance::ZoneMv,
|
|
42
|
+
key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
|
|
43
|
+
key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
|
|
44
|
+
rule_lint: Textus::Maintenance::RuleLint,
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def self.fetch(verb)
|
|
48
|
+
VERBS.fetch(verb.to_sym) { raise UsageError.new("unknown verb: #{verb.inspect}") }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Single home for the uniform use-case invocation protocol (ADR 0023):
|
|
52
|
+
# look up the verb, construct on (container:, call:), and invoke #call.
|
|
53
|
+
def self.invoke(verb, container:, call:, args: [], kwargs: {})
|
|
54
|
+
fetch(verb).new(container: container, call: call).call(*args, **kwargs)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
class AuditLog < Check
|
|
5
5
|
def call
|
|
6
6
|
path = File.join(root, "audit.log")
|
|
7
|
-
Textus::
|
|
7
|
+
Textus::Ports::AuditLog.new(root).verify_integrity.map do |v|
|
|
8
8
|
{
|
|
9
9
|
"code" => "audit.parse_error",
|
|
10
10
|
"level" => "warning",
|
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
class Check
|
|
4
4
|
class SchemaViolations < Check
|
|
5
5
|
def call
|
|
6
|
-
res =
|
|
6
|
+
res = dispatch(:validate_all)
|
|
7
7
|
res["violations"].map do |v|
|
|
8
8
|
fix = v["expected"] &&
|
|
9
9
|
"field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
|
|
@@ -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,19 @@ 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 single use-case invocation seam (ADR 0026).
|
|
32
|
+
def dispatch(verb, *args, **kwargs)
|
|
33
|
+
Textus::Dispatcher.invoke(
|
|
34
|
+
verb,
|
|
35
|
+
container: @container,
|
|
36
|
+
call: Textus::Call.build(role: Textus::Role::DEFAULT),
|
|
37
|
+
args: args, kwargs: kwargs
|
|
38
|
+
)
|
|
39
|
+
end
|
|
30
40
|
end
|
|
31
41
|
end
|
|
32
42
|
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
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
# Parses a duration value into whole seconds. Accepts a bare integer (or
|
|
4
|
+
# integer-string) of seconds, or `<n><unit>` with unit s/m/h/d. Returns
|
|
5
|
+
# nil for nil or any unparseable value.
|
|
6
|
+
module Duration
|
|
7
|
+
UNIT_SECONDS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
8
|
+
|
|
9
|
+
def self.seconds(value)
|
|
10
|
+
return nil if value.nil?
|
|
11
|
+
|
|
12
|
+
str = value.to_s.strip
|
|
13
|
+
return str.to_i if str.match?(/\A\d+\z/)
|
|
14
|
+
|
|
15
|
+
m = str.match(/\A(\d+)\s*([smhd])\z/)
|
|
16
|
+
return nil unless m
|
|
17
|
+
|
|
18
|
+
m[1].to_i * UNIT_SECONDS.fetch(m[2])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -21,21 +21,7 @@ module Textus
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def ttl_seconds
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
str = @ttl.to_s.strip
|
|
27
|
-
return str.to_i if str.match?(/\A\d+\z/)
|
|
28
|
-
|
|
29
|
-
m = str.match(/\A(\d+)\s*([smhd])\z/)
|
|
30
|
-
return nil unless m
|
|
31
|
-
|
|
32
|
-
n = m[1].to_i
|
|
33
|
-
case m[2]
|
|
34
|
-
when "s" then n
|
|
35
|
-
when "m" then n * 60
|
|
36
|
-
when "h" then n * 3600
|
|
37
|
-
when "d" then n * 86_400
|
|
38
|
-
end
|
|
24
|
+
Textus::Domain::Duration.seconds(@ttl)
|
|
39
25
|
end
|
|
40
26
|
|
|
41
27
|
def to_freshness_policy
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
# Lifetime policy for queue/quarantine leaves. Both windows are optional
|
|
5
|
+
# durations (see Domain::Duration). `expire_after` deletes; `archive_after`
|
|
6
|
+
# moves the leaf aside. When both are set, expire wins once its (longer)
|
|
7
|
+
# window is exceeded.
|
|
8
|
+
class Retention
|
|
9
|
+
attr_reader :expire_after, :archive_after
|
|
10
|
+
|
|
11
|
+
def initialize(expire_after: nil, archive_after: nil)
|
|
12
|
+
@expire_after = Textus::Domain::Duration.seconds(expire_after)
|
|
13
|
+
@archive_after = Textus::Domain::Duration.seconds(archive_after)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# :expire | :archive | nil for a leaf of the given age (seconds).
|
|
17
|
+
def action_for(age_seconds)
|
|
18
|
+
return :expire if @expire_after && age_seconds > @expire_after
|
|
19
|
+
return :archive if @archive_after && age_seconds > @archive_after
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
# Reports leaves whose age (now - file mtime) exceeds a retention window.
|
|
4
|
+
# Each row is { "key", "path", "action" => "expire"|"archive", "age_seconds" }.
|
|
5
|
+
class Retention
|
|
6
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@file_stat = file_stat
|
|
9
|
+
@clock = clock
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(prefix: nil, zone: nil)
|
|
13
|
+
@manifest.data.entries
|
|
14
|
+
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
15
|
+
.flat_map { |m| rows_for(m) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def rows_for(mentry)
|
|
21
|
+
policy = @manifest.rules.for(mentry.key).retention
|
|
22
|
+
return [] if policy.nil?
|
|
23
|
+
|
|
24
|
+
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
25
|
+
path = row[:path]
|
|
26
|
+
next unless @file_stat.exists?(path)
|
|
27
|
+
|
|
28
|
+
age = (@clock.now - @file_stat.mtime(path)).to_i
|
|
29
|
+
action = policy.action_for(age)
|
|
30
|
+
next if action.nil?
|
|
31
|
+
|
|
32
|
+
{ "key" => row[:key], "path" => path, "action" => action.to_s, "age_seconds" => age }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def entry_matches?(mentry, prefix:, zone:)
|
|
37
|
+
return false if zone && mentry.zone != zone
|
|
38
|
+
return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
39
|
+
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -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)
|