textus 0.8.1 → 0.10.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +50 -22
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +44 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +69 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +44 -0
  17. data/lib/textus/application/writes/build.rb +116 -0
  18. data/lib/textus/application/writes/delete.rb +36 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +43 -0
  21. data/lib/textus/builder/pipeline.rb +1 -1
  22. data/lib/textus/builder/renderer/json.rb +1 -1
  23. data/lib/textus/builder/renderer/markdown.rb +1 -1
  24. data/lib/textus/builder/renderer/text.rb +1 -1
  25. data/lib/textus/builder/renderer/yaml.rb +1 -1
  26. data/lib/textus/builder/renderer.rb +1 -1
  27. data/lib/textus/cli/group/policy.rb +11 -0
  28. data/lib/textus/cli/verb/accept.rb +2 -2
  29. data/lib/textus/cli/verb/audit.rb +30 -0
  30. data/lib/textus/cli/verb/blame.rb +16 -0
  31. data/lib/textus/cli/verb/build.rb +2 -1
  32. data/lib/textus/cli/verb/delete.rb +2 -2
  33. data/lib/textus/cli/verb/freshness.rb +16 -0
  34. data/lib/textus/cli/verb/get.rb +7 -1
  35. data/lib/textus/cli/verb/hook_run.rb +4 -4
  36. data/lib/textus/cli/verb/mv.rb +1 -2
  37. data/lib/textus/cli/verb/policy_explain.rb +14 -0
  38. data/lib/textus/cli/verb/policy_list.rb +25 -0
  39. data/lib/textus/cli/verb/put.rb +10 -8
  40. data/lib/textus/cli/verb/refresh.rb +2 -2
  41. data/lib/textus/cli/verb/refresh_stale.rb +18 -0
  42. data/lib/textus/cli/verb/reject.rb +14 -0
  43. data/lib/textus/cli/verb.rb +14 -0
  44. data/lib/textus/cli.rb +16 -2
  45. data/lib/textus/composition.rb +72 -0
  46. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  47. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  48. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  49. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  50. data/lib/textus/doctor.rb +7 -1
  51. data/lib/textus/domain/action.rb +9 -0
  52. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  53. data/lib/textus/domain/freshness/policy.rb +18 -0
  54. data/lib/textus/domain/freshness/verdict.rb +12 -0
  55. data/lib/textus/domain/outcome.rb +10 -0
  56. data/lib/textus/domain/permission.rb +15 -0
  57. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  58. data/lib/textus/domain/policy/matcher.rb +51 -0
  59. data/lib/textus/domain/policy/promote.rb +24 -0
  60. data/lib/textus/domain/policy/refresh.rb +48 -0
  61. data/lib/textus/domain/policy.rb +7 -0
  62. data/lib/textus/hooks/builtin.rb +5 -5
  63. data/lib/textus/hooks/dispatcher.rb +15 -1
  64. data/lib/textus/hooks/dsl.rb +18 -0
  65. data/lib/textus/hooks/registry.rb +12 -5
  66. data/lib/textus/infra/clock.rb +9 -0
  67. data/lib/textus/infra/event_bus.rb +27 -0
  68. data/lib/textus/infra/publisher.rb +73 -0
  69. data/lib/textus/infra/refresh/detached.rb +38 -0
  70. data/lib/textus/infra/refresh/lock.rb +44 -0
  71. data/lib/textus/init.rb +71 -28
  72. data/lib/textus/intro.rb +17 -14
  73. data/lib/textus/manifest/entry.rb +39 -13
  74. data/lib/textus/manifest/policies.rb +83 -0
  75. data/lib/textus/manifest.rb +30 -11
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/proposal.rb +4 -21
  78. data/lib/textus/refresh.rb +9 -45
  79. data/lib/textus/store/mover.rb +14 -9
  80. data/lib/textus/store/reader.rb +10 -8
  81. data/lib/textus/store/staleness.rb +5 -17
  82. data/lib/textus/store/validator.rb +46 -20
  83. data/lib/textus/store/writer.rb +51 -14
  84. data/lib/textus/store.rb +30 -10
  85. data/lib/textus/version.rb +1 -1
  86. data/lib/textus.rb +1 -0
  87. metadata +46 -5
  88. data/lib/textus/builder.rb +0 -86
  89. data/lib/textus/cli/verb/stale.rb +0 -14
  90. data/lib/textus/publisher.rb +0 -71
  91. data/lib/textus/store/view.rb +0 -29
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Delete
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(key, if_etag: nil, suppress_events: false)
11
+ @ctx.store.manifest.validate_key!(key)
12
+ mentry, = @ctx.store.manifest.resolve(key)
13
+
14
+ unless @ctx.can_write?(mentry.zone)
15
+ raise WriteForbidden.new(key, mentry.zone,
16
+ writers: @ctx.store.manifest.zone_writers(mentry.zone))
17
+ end
18
+
19
+ @ctx.store.writer.delete_envelope_from_disk(
20
+ key, if_etag: if_etag, as: @ctx.role,
21
+ correlation_id: @ctx.correlation_id
22
+ )
23
+
24
+ unless suppress_events
25
+ @bus.publish(:deleted,
26
+ store: @ctx.with_role(@ctx.role),
27
+ key: key,
28
+ correlation_id: @ctx.correlation_id)
29
+ end
30
+
31
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Publish
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(source:, target:, key:)
11
+ Textus::Infra::Publisher.publish(
12
+ source: source,
13
+ target: target,
14
+ store_root: @ctx.store.root,
15
+ )
16
+ @bus.publish(:published,
17
+ key: key,
18
+ source: source,
19
+ target: target,
20
+ correlation_id: @ctx.correlation_id)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,43 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Put
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil, suppress_events: false)
11
+ @ctx.store.manifest.validate_key!(key)
12
+ mentry, = @ctx.store.manifest.resolve(key)
13
+
14
+ unless @ctx.can_write?(mentry.zone)
15
+ raise WriteForbidden.new(key, mentry.zone,
16
+ writers: @ctx.store.manifest.zone_writers(mentry.zone))
17
+ end
18
+
19
+ envelope = @ctx.store.writer.write_envelope_to_disk(
20
+ key,
21
+ mentry: mentry,
22
+ meta: meta,
23
+ body: body,
24
+ content: content,
25
+ if_etag: if_etag,
26
+ as: @ctx.role,
27
+ correlation_id: @ctx.correlation_id,
28
+ )
29
+
30
+ unless suppress_events
31
+ @bus.publish(:put,
32
+ store: @ctx.with_role(@ctx.role),
33
+ key: key,
34
+ envelope: envelope,
35
+ correlation_id: @ctx.correlation_id)
36
+ end
37
+
38
+ envelope
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,7 +2,7 @@ require "fileutils"
2
2
  require "time"
3
3
 
4
4
  module Textus
5
- class Builder
5
+ module Builder
6
6
  module InjectMeta
7
7
  # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
8
  def self.call(content_hash, mentry)
@@ -1,7 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Json < Renderer
7
7
  def call(mentry:, data:)
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Markdown < Renderer
7
7
  def call(mentry:, data:)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Builder
2
+ module Builder
3
3
  class Renderer
4
4
  class Text < Renderer
5
5
  def call(mentry:, data:)
@@ -1,7 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Yaml < Renderer
7
7
  def call(mentry:, data:)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Builder
2
+ module Builder
3
3
  # Abstract base for output renderers. Each concrete renderer owns
4
4
  # producing the bytes for one manifest format (markdown/json/yaml/text).
5
5
  class Renderer
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Policy < Group
5
+ self.cli_name = "policy"
6
+ subcommands["list"] = Verb::PolicyList
7
+ subcommands["explain"] = Verb::PolicyExplain
8
+ end
9
+ end
10
+ end
11
+ end
@@ -6,8 +6,8 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("accept requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- emit(store.accept(key, as: role))
9
+ ctx = context_for(store)
10
+ emit(Textus::Composition.writes_accept(ctx).call(key))
11
11
  end
12
12
  end
13
13
  end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Audit < Verb
5
+ option :key_filter, "--key=KEY"
6
+ option :zone, "--zone=Z"
7
+ option :role_filter, "--role=ROLE"
8
+ option :verb_filter, "--verb=V"
9
+ option :since, "--since=ISO8601|RELATIVE"
10
+ option :correlation_id, "--correlation-id=ID"
11
+ option :limit, "--limit=N"
12
+
13
+ def call(store)
14
+ ctx = context_for(store)
15
+ since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ctx.now)
16
+ rows = Textus::Composition.audit(ctx).call(
17
+ key: key_filter,
18
+ zone: zone,
19
+ role: role_filter,
20
+ verb: verb_filter,
21
+ since: since_time,
22
+ correlation_id: correlation_id,
23
+ limit: limit&.to_i,
24
+ )
25
+ emit({ "verb" => "audit", "rows" => rows })
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Blame < Verb
5
+ option :limit, "--limit=N"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("blame requires a key")
9
+ ctx = context_for(store)
10
+ rows = Textus::Composition.blame(ctx).call(key: key, limit: limit&.to_i)
11
+ emit({ "verb" => "blame", "key" => key, "rows" => rows })
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -5,7 +5,8 @@ module Textus
5
5
  option :prefix, "--prefix=K"
6
6
 
7
7
  def call(store)
8
- emit(Textus::Builder.new(store).build(prefix: prefix))
8
+ ctx = Textus::Composition.context(store, role: "build")
9
+ emit(Textus::Composition.writes_build(ctx).call(prefix: prefix))
9
10
  end
10
11
  end
11
12
  end
@@ -7,8 +7,8 @@ module Textus
7
7
 
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("delete requires a key")
10
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
- emit(store.delete(key, if_etag: if_etag, as: role))
10
+ ctx = context_for(store)
11
+ emit(Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag))
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Freshness < Verb
5
+ option :prefix, "--prefix=KEY"
6
+ option :zone, "--zone=Z"
7
+
8
+ def call(store)
9
+ ctx = context_for(store)
10
+ rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
11
+ emit({ "verb" => "freshness", "rows" => rows })
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -2,9 +2,15 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Get < Verb
5
+ option :as_flag, "--as=ROLE"
6
+
5
7
  def call(store)
6
8
  key = positional.shift or raise UsageError.new("get requires a key")
7
- emit(store.get(key))
9
+ ctx = context_for(store)
10
+ result = Textus::Composition.reads_get(ctx).call(key)
11
+ raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
12
+
13
+ emit(result)
8
14
  end
9
15
  end
10
16
  end
@@ -23,16 +23,16 @@ module Textus
23
23
  end
24
24
 
25
25
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
26
- callable = store.registry.rpc_callable(:fetch, name)
27
- view = Store::View.new(store, writable: true, as: role)
26
+ callable = store.registry.rpc_callable(:intake, name)
27
+ view = Application::Context.new(store: store, role: role)
28
28
 
29
29
  begin
30
- Timeout.timeout(Textus::Refresh::FETCH_TIMEOUT_SECONDS) do
30
+ Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
31
31
  callable.call(config: {}, store: view, args: args)
32
32
  end
33
33
  rescue Timeout::Error
34
34
  raise UsageError.new(
35
- "hook run '#{name}' exceeded #{Textus::Refresh::FETCH_TIMEOUT_SECONDS}s timeout",
35
+ "hook run '#{name}' exceeded #{Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS}s timeout",
36
36
  )
37
37
  rescue Textus::Error
38
38
  raise
@@ -8,8 +8,7 @@ module Textus
8
8
  def call(store)
9
9
  old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
10
10
  new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
11
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
12
- emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
11
+ emit(store.mv(old_key, new_key, as: resolved_role(store), dry_run: dry_run || false))
13
12
  end
14
13
  end
15
14
  end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class PolicyExplain < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("policy explain requires a KEY")
7
+ ctx = context_for(store)
8
+ result = Textus::Composition.policy_explain(ctx).call(key: key)
9
+ emit({ "verb" => "policy_explain" }.merge(result.transform_keys(&:to_s)))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class PolicyList < Verb
5
+ def call(store)
6
+ policies = store.manifest.policies.blocks.map do |b|
7
+ row = { "match" => b.match }
8
+ if b.refresh
9
+ row["refresh"] = {
10
+ "ttl_seconds" => b.refresh.ttl_seconds,
11
+ "on_stale" => b.refresh.on_stale,
12
+ "sync_budget_ms" => b.refresh.sync_budget_ms,
13
+ }
14
+ end
15
+ row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
16
+ row["promote_requires"] = b.promote.requires if b.promote
17
+ row["retention"] = b.retention if b.retention
18
+ row
19
+ end
20
+ emit({ "verb" => "policy_list", "policies" => policies })
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -10,20 +10,21 @@ module Textus
10
10
  key = positional.shift or raise UsageError.new("put requires a key")
11
11
  raise UsageError.new("put requires --stdin in v1") unless use_stdin
12
12
 
13
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
13
+ role = resolved_role(store)
14
14
 
15
15
  raw = @stdin.read
16
16
  payload =
17
17
  if fetch_name
18
- callable = store.registry.rpc_callable(:fetch, fetch_name)
18
+ callable = store.registry.rpc_callable(:intake, fetch_name)
19
19
  result =
20
20
  begin
21
- Timeout.timeout(Textus::Refresh::FETCH_TIMEOUT_SECONDS) do
22
- callable.call(config: { "bytes" => raw }, store: Textus::Store::View.new(store), args: {})
21
+ Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
22
+ callable.call(config: { "bytes" => raw },
23
+ store: Textus::Application::Context.new(store: store, role: role), args: {})
23
24
  end
24
25
  rescue Timeout::Error
25
26
  raise UsageError.new(
26
- "fetch '#{fetch_name}' exceeded #{Textus::Refresh::FETCH_TIMEOUT_SECONDS}s timeout",
27
+ "fetch '#{fetch_name}' exceeded #{Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS}s timeout",
27
28
  )
28
29
  end
29
30
  basename = key.split(".").last
@@ -32,17 +33,18 @@ module Textus
32
33
  "name" => basename,
33
34
  "last_refreshed_at" => Time.now.utc.iso8601,
34
35
  "fetched_with" => fetch_name,
35
- }.merge(result[:_meta] || result["_meta"] || result[:frontmatter] || result["frontmatter"] || {}),
36
+ }.merge(result[:_meta] || result["_meta"] || {}),
36
37
  "body" => result[:body] || result["body"] || "",
37
38
  }
38
39
  else
39
40
  JSON.parse(raw)
40
41
  end
41
42
 
42
- meta = payload["_meta"] || payload["frontmatter"] || {}
43
+ meta = payload["_meta"] || {}
43
44
  body = payload["body"] || ""
44
45
  if_etag = payload["if_etag"]
45
- emit(store.put(key, meta: meta, body: body, if_etag: if_etag, as: role))
46
+ ctx = Textus::Composition.context(store, role: role)
47
+ emit(Textus::Composition.writes_put(ctx).call(key, meta: meta, body: body, if_etag: if_etag))
46
48
  end
47
49
  end
48
50
  end
@@ -6,8 +6,8 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("refresh requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- emit(Textus::Refresh.call(store, key, as: role))
9
+ ctx = context_for(store)
10
+ emit(Textus::Composition.refresh_worker(ctx).run(key))
11
11
  end
12
12
  end
13
13
  end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class RefreshStale < Verb
5
+ option :prefix, "--prefix=KEY"
6
+ option :zone, "--zone=Z"
7
+ option :as_flag, "--as=ROLE"
8
+
9
+ def call(store)
10
+ ctx = context_for(store)
11
+ result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
12
+ emit(result)
13
+ exit(1) unless result["ok"]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Reject < Verb
5
+ option :as_flag, "--as=ROLE"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("reject requires a key")
9
+ emit(store.reject(key, as: resolved_role(store)))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -57,6 +57,20 @@ module Textus
57
57
  @stdout.puts(JSON.generate(payload))
58
58
  exit_code
59
59
  end
60
+
61
+ # Resolves the active role for this invocation. Honors the verb's
62
+ # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
63
+ def resolved_role(store)
64
+ flag = respond_to?(:as_flag) ? as_flag : nil
65
+ Role.resolve(flag: flag, env: ENV, root: store.root)
66
+ end
67
+
68
+ # Returns an Application::Context bound to the resolved role.
69
+ # Convenience for verbs whose only pre-call boilerplate is
70
+ # resolving the role and wrapping it in a context.
71
+ def context_for(store)
72
+ Textus::Composition.context(store, role: resolved_role(store))
73
+ end
60
74
  end
61
75
  end
62
76
  end
data/lib/textus/cli.rb CHANGED
@@ -7,22 +7,27 @@ module Textus
7
7
  # plus a new file under lib/textus/cli/.
8
8
  VERBS = {
9
9
  "accept" => Verb::Accept,
10
+ "audit" => Verb::Audit,
11
+ "blame" => Verb::Blame,
12
+ "reject" => Verb::Reject,
10
13
  "build" => Verb::Build,
11
14
  "delete" => Verb::Delete,
12
15
  "deps" => Verb::Deps,
13
16
  "doctor" => Verb::Doctor,
17
+ "freshness" => Verb::Freshness,
14
18
  "get" => Verb::Get,
15
19
  "hook" => Group::Hook,
16
20
  "init" => Verb::Init,
17
21
  "intro" => Verb::Intro,
18
22
  "key" => Group::Key,
19
23
  "list" => Verb::List,
24
+ "policy" => Group::Policy,
20
25
  "published" => Verb::Published,
21
26
  "put" => Verb::Put,
22
27
  "rdeps" => Verb::Rdeps,
23
28
  "refresh" => Verb::Refresh,
29
+ "refresh-stale" => Verb::RefreshStale,
24
30
  "schema" => Group::Schema,
25
- "stale" => Verb::Stale,
26
31
  "where" => Verb::Where,
27
32
  }.freeze
28
33
 
@@ -48,6 +53,10 @@ module Textus
48
53
  0
49
54
  when "--help", "-h" then print_help
50
55
  0
56
+ when "stale"
57
+ raise UsageError.new(
58
+ "textus stale was removed in 0.9.2 — use `textus freshness` instead",
59
+ )
51
60
  else
52
61
  klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
53
62
  dispatch(klass, argv)
@@ -84,13 +93,18 @@ module Textus
84
93
  textus where KEY
85
94
  textus get KEY
86
95
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
87
- textus stale [--prefix=KEY] [--zone=Z]
96
+ textus freshness [--prefix=KEY] [--zone=Z]
97
+ textus refresh-stale [--prefix=KEY] [--zone=Z]
98
+ textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
99
+ textus blame KEY [--limit=N]
88
100
  textus doctor
89
101
  textus intro
90
102
 
91
103
  textus key {mv,uid,migrate}
92
104
  textus schema {show,init,diff,migrate}
93
105
  textus hook {list,run}
106
+ textus policy {list,explain}
107
+ textus migrate {zones,policies}
94
108
  HELP
95
109
  end
96
110
  end
@@ -0,0 +1,72 @@
1
+ module Textus
2
+ module Composition
3
+ module_function
4
+
5
+ def context(store, role:, correlation_id: nil, dry_run: false)
6
+ Textus::Application::Context.new(
7
+ store: store,
8
+ role: role,
9
+ correlation_id: correlation_id,
10
+ dry_run: dry_run,
11
+ )
12
+ end
13
+
14
+ def reads_get(ctx)
15
+ Textus::Application::Reads::Get.new(ctx: ctx, orchestrator: refresh_orchestrator(ctx))
16
+ end
17
+
18
+ def freshness(ctx)
19
+ Textus::Application::Reads::Freshness.new(ctx: ctx)
20
+ end
21
+
22
+ def audit(ctx)
23
+ Textus::Application::Reads::Audit.new(ctx: ctx)
24
+ end
25
+
26
+ def blame(ctx)
27
+ Textus::Application::Reads::Blame.new(ctx: ctx)
28
+ end
29
+
30
+ def policy_explain(ctx)
31
+ Textus::Application::Reads::PolicyExplain.new(ctx: ctx)
32
+ end
33
+
34
+ def refresh_worker(ctx)
35
+ Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
36
+ end
37
+
38
+ def refresh_orchestrator(ctx)
39
+ Textus::Application::Refresh::Orchestrator.new(
40
+ worker: refresh_worker(ctx),
41
+ bus: ctx.store.bus,
42
+ store_root: ctx.store.root,
43
+ store: ctx.store,
44
+ role: ctx.role,
45
+ )
46
+ end
47
+
48
+ def writes_put(ctx)
49
+ Textus::Application::Writes::Put.new(ctx: ctx, bus: ctx.store.bus)
50
+ end
51
+
52
+ def writes_delete(ctx)
53
+ Textus::Application::Writes::Delete.new(ctx: ctx, bus: ctx.store.bus)
54
+ end
55
+
56
+ def writes_build(ctx)
57
+ Textus::Application::Writes::Build.new(ctx: ctx, bus: ctx.store.bus)
58
+ end
59
+
60
+ def writes_accept(ctx)
61
+ Textus::Application::Writes::Accept.new(ctx: ctx, bus: ctx.store.bus)
62
+ end
63
+
64
+ def writes_publish(ctx)
65
+ Textus::Application::Writes::Publish.new(ctx: ctx, bus: ctx.store.bus)
66
+ end
67
+
68
+ def event_bus(ctx)
69
+ Textus::Infra::EventBus.new(registry: ctx.store.registry)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # For every entry with an `intake.handler`, look up its handler_allowlist
5
+ # policy (if any) and verify the declared handler is allowed. Emits a
6
+ # failure when the handler is rejected by policy.
7
+ class HandlerAllowlist < Check
8
+ def call
9
+ out = []
10
+ store.manifest.entries.each do |mentry|
11
+ handler = mentry.intake_handler
12
+ next if handler.nil?
13
+
14
+ allow = store.manifest.policies_for(mentry.key).handler_allowlist
15
+ next if allow.nil?
16
+ next if allow.allows?(handler)
17
+
18
+ out << {
19
+ "code" => "policy.handler_not_allowed",
20
+ "level" => "error",
21
+ "subject" => mentry.key,
22
+ "message" => "entry '#{mentry.key}' declares intake.handler='#{handler}' but the " \
23
+ "handler_allowlist policy permits only: #{allow.handlers.join(", ")}",
24
+ "fix" => "either change intake.handler to one of [#{allow.handlers.join(", ")}], " \
25
+ "or extend the handler_allowlist policy in .textus/manifest.yaml",
26
+ }
27
+ end
28
+ out
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end