textus 0.53.0 → 0.54.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/CHANGELOG.md +34 -0
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +33 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +1 -1
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +2 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/errors.rb +8 -4
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +12 -75
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/validator.rb +3 -11
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +18 -0
- data/lib/textus/session.rb +1 -1
- data/lib/textus/store.rb +1 -10
- data/lib/textus/surfaces/mcp/catalog.rb +1 -1
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +101 -62
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +33 -61
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Background
|
|
5
|
-
module Job
|
|
6
|
-
@registry = {}
|
|
7
|
-
|
|
8
|
-
def self.registry = @registry
|
|
9
|
-
|
|
10
|
-
def self.register(klass)
|
|
11
|
-
@registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def self.fetch(type)
|
|
15
|
-
@registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Background
|
|
5
|
-
module Planner
|
|
6
|
-
class Plan
|
|
7
|
-
ACTIONS_BY_TRIGGER = {
|
|
8
|
-
"convergence" => %w[materialize refresh sweep],
|
|
9
|
-
"entry.written" => %w[materialize],
|
|
10
|
-
"entry.deleted" => %w[materialize],
|
|
11
|
-
"entry.moved" => %w[materialize],
|
|
12
|
-
"proposal.accepted" => %w[materialize],
|
|
13
|
-
"proposal.rejected" => %w[materialize],
|
|
14
|
-
}.freeze
|
|
15
|
-
|
|
16
|
-
SCOPE_RESOLVERS = {
|
|
17
|
-
"materialize" => :producible_keys,
|
|
18
|
-
"refresh" => :stale_intake_keys,
|
|
19
|
-
"sweep" => :lane_keys,
|
|
20
|
-
}.freeze
|
|
21
|
-
|
|
22
|
-
def self.seed(container:, queue:, role:)
|
|
23
|
-
jobs = new(container: container).plan(
|
|
24
|
-
trigger: { "type" => "convergence" },
|
|
25
|
-
role: role,
|
|
26
|
-
)
|
|
27
|
-
jobs.each { |j| queue.enqueue(j) }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def initialize(container:)
|
|
31
|
-
@container = container
|
|
32
|
-
@manifest = container.manifest
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def plan(trigger:, role:)
|
|
36
|
-
type = trigger["type"] || trigger[:type]
|
|
37
|
-
trigger["target"] || trigger[:target]
|
|
38
|
-
return [] if type.nil?
|
|
39
|
-
|
|
40
|
-
blocks_with_react = @manifest.rules.blocks.select(&:react)
|
|
41
|
-
if blocks_with_react.any?
|
|
42
|
-
plan_from_rules(blocks_with_react, type, role)
|
|
43
|
-
else
|
|
44
|
-
plan_from_defaults(type, role)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
def plan_from_rules(blocks, type, role)
|
|
51
|
-
jobs = []
|
|
52
|
-
blocks
|
|
53
|
-
.select { |b| matches_trigger?(b.react, type) }
|
|
54
|
-
.each do |block|
|
|
55
|
-
do_action = block.react.raw["do"]
|
|
56
|
-
Array(do_action).each do |action|
|
|
57
|
-
if action == "sweep"
|
|
58
|
-
jobs << Textus::Ports::Queue::Job.new(
|
|
59
|
-
type: "sweep", args: { "scope" => {} }, enqueued_by: role,
|
|
60
|
-
)
|
|
61
|
-
else
|
|
62
|
-
resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
|
|
63
|
-
keys = send(resolver, nil)
|
|
64
|
-
keys.each { |key| jobs << job(action, key, role) }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
jobs
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def plan_from_defaults(type, role)
|
|
72
|
-
actions = ACTIONS_BY_TRIGGER.fetch(type, [])
|
|
73
|
-
jobs = []
|
|
74
|
-
producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
|
|
75
|
-
stale_intake_keys(nil).each { |k| jobs << job("refresh", k, role) } if actions.include?("refresh")
|
|
76
|
-
if actions.include?("sweep")
|
|
77
|
-
jobs << Textus::Ports::Queue::Job.new(
|
|
78
|
-
type: "sweep", args: { "scope" => {} }, enqueued_by: role,
|
|
79
|
-
)
|
|
80
|
-
end
|
|
81
|
-
jobs
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def matches_trigger?(react, type)
|
|
85
|
-
on = react.raw["on"]
|
|
86
|
-
Array(on).include?(type)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def job(type, key, enqueued_by)
|
|
90
|
-
Textus::Ports::Queue::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def producible_keys(_target)
|
|
94
|
-
@manifest.data.entries
|
|
95
|
-
.select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
96
|
-
.map(&:key)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def stale_intake_keys(_target)
|
|
100
|
-
Textus::Core::Freshness::Evaluator.new(
|
|
101
|
-
manifest: @manifest,
|
|
102
|
-
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
103
|
-
clock: Textus::Ports::Clock.new,
|
|
104
|
-
).stale_intake_keys(prefix: nil, lane: nil)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def lane_keys(_target)
|
|
108
|
-
@manifest.data.entries.map(&:key)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Background
|
|
5
|
-
module Retention
|
|
6
|
-
class Apply
|
|
7
|
-
def initialize(container:, call:)
|
|
8
|
-
@container = container
|
|
9
|
-
@call = call
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def call(rows)
|
|
13
|
-
out = { dropped: [], archived: [], failed: [] }
|
|
14
|
-
rows.each do |row|
|
|
15
|
-
key = row["key"]
|
|
16
|
-
begin
|
|
17
|
-
case row["action"]
|
|
18
|
-
when "drop"
|
|
19
|
-
delete(key)
|
|
20
|
-
out[:dropped] << key
|
|
21
|
-
when "archive"
|
|
22
|
-
archive_leaf(row)
|
|
23
|
-
delete(key)
|
|
24
|
-
out[:archived] << key
|
|
25
|
-
end
|
|
26
|
-
rescue Textus::Error => e
|
|
27
|
-
out[:failed] << { "key" => key, "error" => e.message }
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
out
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def archive_leaf(row)
|
|
36
|
-
src = row["path"]
|
|
37
|
-
root = @container.root.to_s
|
|
38
|
-
rel = src.delete_prefix("#{root}/")
|
|
39
|
-
dest = File.join(root, "archive", rel)
|
|
40
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
41
|
-
FileUtils.cp(src, dest)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def delete(key)
|
|
45
|
-
Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Doctor
|
|
3
|
-
class Check
|
|
4
|
-
# For every entry with a fetch handler, look up its handler_permit policy
|
|
5
|
-
# (if any) and verify the declared handler is permitted. Emits a failure
|
|
6
|
-
# when the handler is rejected by policy.
|
|
7
|
-
class HandlerPermit < Check
|
|
8
|
-
def call
|
|
9
|
-
out = []
|
|
10
|
-
manifest.data.entries.each do |mentry|
|
|
11
|
-
next unless mentry.intake?
|
|
12
|
-
|
|
13
|
-
handler = mentry.handler
|
|
14
|
-
|
|
15
|
-
permit = manifest.rules.for(mentry.key).handler_permit
|
|
16
|
-
next if permit.nil?
|
|
17
|
-
next if permit.permits?(handler)
|
|
18
|
-
|
|
19
|
-
out << {
|
|
20
|
-
"code" => "policy.handler_not_permitted",
|
|
21
|
-
"level" => "error",
|
|
22
|
-
"subject" => mentry.key,
|
|
23
|
-
"message" => "entry '#{mentry.key}' declares source.handler='#{handler}' but " \
|
|
24
|
-
"handler_permit allows only: #{permit.handlers.join(", ")}",
|
|
25
|
-
"fix" => "change handler to one of [#{permit.handlers.join(", ")}] or " \
|
|
26
|
-
"extend handler_permit in .textus/manifest.yaml",
|
|
27
|
-
}
|
|
28
|
-
end
|
|
29
|
-
out
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Doctor
|
|
3
|
-
class Check
|
|
4
|
-
class Hooks < Check
|
|
5
|
-
def call
|
|
6
|
-
out = []
|
|
7
|
-
dir = File.join(root, "steps")
|
|
8
|
-
return out unless File.directory?(dir)
|
|
9
|
-
|
|
10
|
-
Textus::Step::Loader.new(registry: Textus::Step::RegistryStore.new).load_dir(dir)
|
|
11
|
-
out
|
|
12
|
-
rescue Textus::UsageError => e
|
|
13
|
-
out << {
|
|
14
|
-
"code" => "step.load_failed",
|
|
15
|
-
"level" => "error",
|
|
16
|
-
"subject" => "steps",
|
|
17
|
-
"message" => e.message,
|
|
18
|
-
"fix" => "open the named step file under .textus/steps/ and fix the error",
|
|
19
|
-
}
|
|
20
|
-
out
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Doctor
|
|
3
|
-
class Check
|
|
4
|
-
class IntakeRegistration < Check
|
|
5
|
-
BUILTIN = %i[json csv markdown-links ical-events rss].freeze
|
|
6
|
-
|
|
7
|
-
def call
|
|
8
|
-
declared = collect_declared_handlers
|
|
9
|
-
registered = steps.names(:fetch).to_set
|
|
10
|
-
|
|
11
|
-
out = (declared - registered).map do |name|
|
|
12
|
-
{
|
|
13
|
-
"code" => "intake.handler_missing",
|
|
14
|
-
"level" => "error",
|
|
15
|
-
"subject" => name.to_s,
|
|
16
|
-
"message" => "manifest references intake handler '#{name}' but no fetch step for '#{name}' is registered",
|
|
17
|
-
"fix" => "create .textus/steps/fetch/#{name}.rb with `class #{name.to_s.split(/[-_]/).map(&:capitalize).join}Fetch < Textus::Step::Fetch`",
|
|
18
|
-
}
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
(registered - declared - BUILTIN.to_set).each do |name|
|
|
22
|
-
out << {
|
|
23
|
-
"code" => "intake.handler_orphan",
|
|
24
|
-
"level" => "warning",
|
|
25
|
-
"subject" => name.to_s,
|
|
26
|
-
"message" => "fetch step '#{name}' is registered but no manifest entry references it",
|
|
27
|
-
"fix" => "remove the unused step, or add an entry with `intake.handler: #{name}`",
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
out
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def collect_declared_handlers
|
|
37
|
-
set = Set.new
|
|
38
|
-
manifest.data.entries.each do |mentry|
|
|
39
|
-
set << mentry.handler.to_sym if mentry.intake?
|
|
40
|
-
end
|
|
41
|
-
set
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
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 self.from(container:)
|
|
12
|
-
new(file_store: container.file_store, manifest: container.manifest)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def initialize(file_store:, manifest:)
|
|
16
|
-
@file_store = file_store
|
|
17
|
-
@manifest = manifest
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def read(key)
|
|
21
|
-
res = @manifest.resolver.resolve(key)
|
|
22
|
-
path = res.path
|
|
23
|
-
return nil unless @file_store.exists?(path)
|
|
24
|
-
|
|
25
|
-
mentry = res.entry
|
|
26
|
-
raw = @file_store.read(path)
|
|
27
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
28
|
-
Textus::Envelope.build(
|
|
29
|
-
key: key, mentry: mentry, path: path,
|
|
30
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
31
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
32
|
-
)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def existing_uid(key)
|
|
36
|
-
env = read(key)
|
|
37
|
-
env&.uid
|
|
38
|
-
rescue StandardError
|
|
39
|
-
nil
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def exists?(key)
|
|
43
|
-
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Envelope
|
|
5
|
-
module IO
|
|
6
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
7
|
-
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
8
|
-
# Reader for the existing-uid lookup.
|
|
9
|
-
#
|
|
10
|
-
# Invariant: every public method's final action is @audit_log.append(...).
|
|
11
|
-
#
|
|
12
|
-
# No permission check, no event firing — those belong to the caller
|
|
13
|
-
# (Write::Put / ::Delete / ::Mv).
|
|
14
|
-
class Writer
|
|
15
|
-
Payload = Data.define(:meta, :body, :content)
|
|
16
|
-
|
|
17
|
-
def self.from(container:, call:)
|
|
18
|
-
new(
|
|
19
|
-
file_store: container.file_store, manifest: container.manifest,
|
|
20
|
-
schemas: container.schemas, audit_log: container.audit_log,
|
|
21
|
-
call: call, reader: Reader.from(container: container)
|
|
22
|
-
)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
26
|
-
@file_store = file_store
|
|
27
|
-
@manifest = manifest
|
|
28
|
-
@schemas = schemas
|
|
29
|
-
@audit_log = audit_log
|
|
30
|
-
@call = call
|
|
31
|
-
@reader = reader
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def put(key, mentry:, payload:, if_etag: nil)
|
|
35
|
-
path = resolve_path(key)
|
|
36
|
-
meta, content = prepare_uid(mentry, payload, key)
|
|
37
|
-
bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
|
|
38
|
-
enforce_name_match!(path, eff_meta, mentry.format)
|
|
39
|
-
validate_schema(mentry, eff_meta, eff_content)
|
|
40
|
-
etag_before = check_etag!(path, key, if_etag)
|
|
41
|
-
write_bytes(path, bytes)
|
|
42
|
-
envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
43
|
-
audit_put(key, etag_before, envelope.etag)
|
|
44
|
-
envelope
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
48
|
-
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
49
|
-
# leave room for future format-specific delete hooks; no field
|
|
50
|
-
# on it is needed today.
|
|
51
|
-
path = @manifest.resolver.resolve(key).path
|
|
52
|
-
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
53
|
-
|
|
54
|
-
etag_before = @file_store.etag(path)
|
|
55
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
56
|
-
|
|
57
|
-
@file_store.delete(path)
|
|
58
|
-
prune_empty_parents(path)
|
|
59
|
-
@audit_log.append(
|
|
60
|
-
role: @call.role, verb: "key_delete", key: key,
|
|
61
|
-
etag_before: etag_before, etag_after: nil,
|
|
62
|
-
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
63
|
-
)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
67
|
-
from_path = @manifest.resolver.resolve(from_key).path
|
|
68
|
-
to_path = @manifest.resolver.resolve(to_key).path
|
|
69
|
-
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
70
|
-
|
|
71
|
-
etag_before = @file_store.etag(from_path)
|
|
72
|
-
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
73
|
-
|
|
74
|
-
FileUtils.mkdir_p(File.dirname(to_path))
|
|
75
|
-
FileUtils.mv(from_path, to_path)
|
|
76
|
-
prune_empty_parents(from_path)
|
|
77
|
-
basename = to_key.split(".").last
|
|
78
|
-
Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
|
|
79
|
-
etag_after = Etag.for_file(to_path)
|
|
80
|
-
|
|
81
|
-
raw = @file_store.read(to_path)
|
|
82
|
-
parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
|
|
83
|
-
envelope = Textus::Envelope.build(
|
|
84
|
-
key: to_key, mentry: new_mentry, path: to_path,
|
|
85
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
86
|
-
etag: etag_after, content: parsed["content"]
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
extras = {
|
|
90
|
-
"from_key" => from_key, "to_key" => to_key,
|
|
91
|
-
"from_path" => from_path, "to_path" => to_path,
|
|
92
|
-
"uid" => envelope.uid
|
|
93
|
-
}
|
|
94
|
-
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
95
|
-
|
|
96
|
-
@audit_log.append(
|
|
97
|
-
role: @call.role, verb: "key_mv", key: to_key,
|
|
98
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
99
|
-
extras: extras
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
envelope
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
# After a file leaves a directory (delete or move-source), remove any
|
|
108
|
-
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
109
|
-
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
110
|
-
# declared, first-class container, so its own dir is preserved even when
|
|
111
|
-
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
112
|
-
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
113
|
-
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
114
|
-
# non-empty dir is silently fine, never fatal to the write.
|
|
115
|
-
def prune_empty_parents(path)
|
|
116
|
-
floor = zone_floor(path)
|
|
117
|
-
return unless floor
|
|
118
|
-
|
|
119
|
-
dir = File.dirname(path)
|
|
120
|
-
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
121
|
-
Dir.rmdir(dir)
|
|
122
|
-
dir = File.dirname(dir)
|
|
123
|
-
end
|
|
124
|
-
rescue SystemCallError
|
|
125
|
-
nil
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
129
|
-
# or nil if `path` is not under the store's zones tree.
|
|
130
|
-
def zone_floor(path)
|
|
131
|
-
zones_root = File.join(@manifest.data.root, "data")
|
|
132
|
-
prefix = "#{zones_root}/"
|
|
133
|
-
return nil unless path.start_with?(prefix)
|
|
134
|
-
|
|
135
|
-
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
136
|
-
zone_seg && File.join(zones_root, zone_seg)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def ensure_uid(format, meta, content, existing_uid)
|
|
140
|
-
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def enforce_name_match!(path, meta, format)
|
|
144
|
-
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
148
|
-
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
149
|
-
meta: meta, body: body, content: content, path: path,
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def resolve_path(key)
|
|
154
|
-
@manifest.resolver.resolve(key).path
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def prepare_uid(mentry, payload, key)
|
|
158
|
-
meta = payload.meta || {}
|
|
159
|
-
existing_uid = @reader.existing_uid(key)
|
|
160
|
-
ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def serialize_entry(mentry, path, meta, payload, content)
|
|
164
|
-
serialize_for_put(
|
|
165
|
-
mentry: mentry, path: path,
|
|
166
|
-
meta: meta, body: payload.body, content: content
|
|
167
|
-
)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def validate_schema(mentry, eff_meta, eff_content)
|
|
171
|
-
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
172
|
-
return unless schema
|
|
173
|
-
|
|
174
|
-
Entry.for_format(mentry.format).validate_against(
|
|
175
|
-
schema,
|
|
176
|
-
{ "_meta" => eff_meta, "content" => eff_content },
|
|
177
|
-
)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def check_etag!(path, key, if_etag)
|
|
181
|
-
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
182
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
183
|
-
|
|
184
|
-
etag_before
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def write_bytes(path, bytes)
|
|
188
|
-
@file_store.write(path, bytes)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
192
|
-
Textus::Envelope.build(
|
|
193
|
-
key: key, mentry: mentry, path: path,
|
|
194
|
-
meta: eff_meta, body: eff_body,
|
|
195
|
-
etag: Etag.for_bytes(@file_store.read(path)),
|
|
196
|
-
content: eff_content
|
|
197
|
-
)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def audit_put(key, etag_before, etag_after)
|
|
201
|
-
extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
202
|
-
@audit_log.append(
|
|
203
|
-
role: @call.role, verb: "put", key: key,
|
|
204
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
205
|
-
extras: extras
|
|
206
|
-
)
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
data/lib/textus/events.rb
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Events
|
|
3
|
-
ENTRY_WRITTEN = "entry.written"
|
|
4
|
-
ENTRY_DELETED = "entry.deleted"
|
|
5
|
-
ENTRY_RENAMED = "entry.renamed"
|
|
6
|
-
ENTRY_FETCHED = "entry.fetched"
|
|
7
|
-
ENTRY_DERIVED = "entry.derived"
|
|
8
|
-
ENTRY_VALIDATED = "entry.validated"
|
|
9
|
-
ENTRY_PUBLISHED = "entry.published"
|
|
10
|
-
PIPELINE_FAILED = "pipeline.failed"
|
|
11
|
-
|
|
12
|
-
STEP_FETCH_COMPLETE = "step.fetch.complete"
|
|
13
|
-
STEP_TRANSFORM_COMPLETE = "step.transform.complete"
|
|
14
|
-
STEP_VALIDATE_PASSED = "step.validate.passed"
|
|
15
|
-
STEP_VALIDATE_FAILED = "step.validate.failed"
|
|
16
|
-
|
|
17
|
-
STORE_LOADED = "store.loaded"
|
|
18
|
-
SESSION_OPENED = "session.opened"
|
|
19
|
-
SESSION_CLOSED = "session.closed"
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# .textus/steps/fetch/machine_intake.rb
|
|
2
|
-
# Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
|
|
3
|
-
# entry from manifest.yaml if you don't want it.
|
|
4
|
-
# Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
|
|
5
|
-
# (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
|
|
6
|
-
# `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
|
|
7
|
-
# (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
|
|
8
|
-
# ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
|
|
9
|
-
module Textus
|
|
10
|
-
module Step
|
|
11
|
-
class MachineIntakeFetch < Fetch
|
|
12
|
-
def call(config:, args:, caps:, **)
|
|
13
|
-
machine = args[:leaf_segments].first or
|
|
14
|
-
raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
|
|
15
|
-
spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
|
|
16
|
-
unless (spec["via"] || "local").to_s == "local"
|
|
17
|
-
raise "machine #{machine}: only `via: local` is scaffolded — see " \
|
|
18
|
-
"docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
|
|
22
|
-
ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
|
|
23
|
-
count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
|
|
24
|
-
{ content: {
|
|
25
|
-
# git_* describe THIS repo on the control host — only meaningful for `local`.
|
|
26
|
-
"git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
|
|
27
|
-
"git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
|
|
28
|
-
"git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
|
|
29
|
-
"repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
|
|
30
|
-
"captured_at" => Time.now.utc.iso8601,
|
|
31
|
-
"os" => RbConfig::CONFIG["host_os"],
|
|
32
|
-
"arch" => RbConfig::CONFIG["host_cpu"],
|
|
33
|
-
"ruby_version" => RUBY_VERSION,
|
|
34
|
-
"runtimes" => { # versions only; nil when not installed
|
|
35
|
-
"node" => ver.call("node --version"),
|
|
36
|
-
"python" => ver.call("python3 --version"),
|
|
37
|
-
"go" => ver.call("go version"),
|
|
38
|
-
},
|
|
39
|
-
"packages" => { # COUNTS only — never the list (size/secrets)
|
|
40
|
-
"brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
|
|
41
|
-
"apt" => count.call("dpkg-query -f '.\n' -W"),
|
|
42
|
-
},
|
|
43
|
-
"textus_version" => Textus::VERSION,
|
|
44
|
-
"protocol" => Textus::PROTOCOL,
|
|
45
|
-
} }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Reducer that reshapes the raw projection rows into the keys the
|
|
2
|
-
# orientation.mustache template references. Without this, the template
|
|
3
|
-
# would only have access to the flat rows list.
|
|
4
|
-
module Textus
|
|
5
|
-
module Step
|
|
6
|
-
class OrientationTransform < Transform
|
|
7
|
-
def call(rows:, config:, **)
|
|
8
|
-
project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
|
|
9
|
-
runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
"project" => {
|
|
13
|
-
"name" => project_row["name"],
|
|
14
|
-
"description" => project_row["description"]
|
|
15
|
-
},
|
|
16
|
-
"runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
|
|
17
|
-
}
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Validators
|
|
5
|
-
module Events
|
|
6
|
-
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
-
pubsub_events = Textus::Step::Catalog::PUBSUB.keys
|
|
8
|
-
events = entry.events
|
|
9
|
-
events.each_key do |evt|
|
|
10
|
-
next if pubsub_events.include?(evt.to_sym)
|
|
11
|
-
|
|
12
|
-
raise UsageError.new(
|
|
13
|
-
"entry '#{entry.key}': unknown event '#{evt}' in events: block. " \
|
|
14
|
-
"Known events: #{pubsub_events.join(", ")}.",
|
|
15
|
-
)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Policy
|
|
4
|
-
class HandlerPermit
|
|
5
|
-
attr_reader :handlers
|
|
6
|
-
|
|
7
|
-
def initialize(handlers:)
|
|
8
|
-
@handlers = Array(handlers).map(&:to_s).freeze
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def permits?(handler)
|
|
12
|
-
@handlers.include?(handler.to_s)
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|