textus 0.20.0 → 0.22.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 +157 -0
- data/README.md +7 -4
- data/SPEC.md +77 -5
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/application/policy/promotion.rb +6 -11
- data/lib/textus/application/reads/audit.rb +40 -15
- data/lib/textus/application/reads/pulse.rb +63 -0
- data/lib/textus/application/reads/validator.rb +3 -1
- data/lib/textus/application/writes/accept.rb +5 -1
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/materializer.rb +1 -1
- data/lib/textus/application/writes/publish.rb +25 -106
- data/lib/textus/application/writes/reject.rb +5 -1
- data/lib/textus/{intro.rb → boot.rb} +71 -25
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/verb/audit.rb +2 -0
- data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +2 -3
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/manifest/entry/base.rb +43 -6
- data/lib/textus/manifest/entry/derived.rb +40 -4
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +9 -51
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/resolver.rb +8 -5
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +63 -5
- data/lib/textus/manifest.rb +31 -1
- data/lib/textus/operations.rb +8 -1
- data/lib/textus/schema/tools.rb +8 -1
- data/lib/textus/store.rb +5 -1
- data/lib/textus/version.rb +1 -1
- metadata +9 -10
- data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
- data/lib/textus/application/tools/migrate_keys.rb +0 -191
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
- data/lib/textus/cli/verb/key_normalize.rb +0 -48
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/manifest/resolution.rb +0 -5
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "time"
|
|
3
4
|
|
|
4
5
|
module Textus
|
|
5
6
|
module Infra
|
|
6
7
|
class AuditLog
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
DEFAULT_MAX_SIZE = 10_485_760
|
|
9
|
+
DEFAULT_KEEP = 5
|
|
10
|
+
|
|
11
|
+
def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
12
|
+
@root = root
|
|
13
|
+
@path = File.join(root, "audit.log")
|
|
14
|
+
@max_size = max_size
|
|
15
|
+
@keep = keep
|
|
9
16
|
end
|
|
10
17
|
|
|
11
18
|
def last_writer_for(key)
|
|
@@ -23,27 +30,38 @@ module Textus
|
|
|
23
30
|
last_role
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
"ts" => Time.now.utc.iso8601,
|
|
29
|
-
"role" => role,
|
|
30
|
-
"verb" => verb,
|
|
31
|
-
"key" => key,
|
|
32
|
-
"etag_before" => etag_before,
|
|
33
|
-
"etag_after" => etag_after,
|
|
34
|
-
}
|
|
33
|
+
def latest_seq
|
|
34
|
+
return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# Active log is empty/missing — consult the most recent rotated file's sidecar.
|
|
37
|
+
meta = read_meta(1)
|
|
38
|
+
return meta["max_seq"] if meta
|
|
39
|
+
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def min_available_seq
|
|
44
|
+
rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
|
|
45
|
+
if rotated_metas.any?
|
|
46
|
+
rotated_metas.map { |m| m["min_seq"] }.min
|
|
47
|
+
elsif File.exist?(@path)
|
|
48
|
+
File.foreach(@path) do |line|
|
|
49
|
+
parsed = parse_row(line.chomp)
|
|
50
|
+
return parsed["seq"] if parsed && parsed["seq"]
|
|
40
51
|
end
|
|
41
|
-
|
|
52
|
+
nil
|
|
42
53
|
end
|
|
54
|
+
end
|
|
43
55
|
|
|
56
|
+
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
44
57
|
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
45
58
|
f.flock(File::LOCK_EX)
|
|
59
|
+
next_seq = current_max_seq_unlocked + 1
|
|
60
|
+
row = assemble_row(next_seq, { role: role, verb: verb, key: key,
|
|
61
|
+
etag_before: etag_before, etag_after: etag_after }, extras)
|
|
46
62
|
f.write(JSON.generate(row) + "\n")
|
|
63
|
+
f.flush
|
|
64
|
+
rotate!(f) if f.size > @max_size
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
|
|
@@ -63,6 +81,98 @@ module Textus
|
|
|
63
81
|
|
|
64
82
|
private
|
|
65
83
|
|
|
84
|
+
# Caller holds the flock. Returns the highest seq across the active log,
|
|
85
|
+
# OR the most-recent rotated file's max_seq if the active log is empty.
|
|
86
|
+
def current_max_seq_unlocked
|
|
87
|
+
return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
|
|
88
|
+
|
|
89
|
+
meta = read_meta(1)
|
|
90
|
+
meta ? meta["max_seq"] : 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scan_max_seq(file)
|
|
94
|
+
last = 0
|
|
95
|
+
File.foreach(file) do |line|
|
|
96
|
+
parsed = parse_row(line.chomp)
|
|
97
|
+
last = parsed["seq"] if parsed && parsed["seq"].is_a?(Integer)
|
|
98
|
+
end
|
|
99
|
+
last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def scan_seq_range(file)
|
|
103
|
+
min = nil
|
|
104
|
+
max = 0
|
|
105
|
+
File.foreach(file) do |line|
|
|
106
|
+
parsed = parse_row(line.chomp)
|
|
107
|
+
next unless parsed && parsed["seq"]
|
|
108
|
+
|
|
109
|
+
min ||= parsed["seq"]
|
|
110
|
+
max = parsed["seq"]
|
|
111
|
+
end
|
|
112
|
+
[min, max]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_meta(n)
|
|
116
|
+
path = File.join(@root, "audit.log.#{n}.meta.json")
|
|
117
|
+
return nil unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
JSON.parse(File.read(path))
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def assemble_row(seq, fields, extras = nil)
|
|
125
|
+
row = {
|
|
126
|
+
"seq" => seq,
|
|
127
|
+
"ts" => Time.now.utc.iso8601,
|
|
128
|
+
"role" => fields[:role],
|
|
129
|
+
"verb" => fields[:verb],
|
|
130
|
+
"key" => fields[:key],
|
|
131
|
+
"etag_before" => fields[:etag_before],
|
|
132
|
+
"etag_after" => fields[:etag_after],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if extras.is_a?(Hash) && !extras.empty?
|
|
136
|
+
extras = extras.dup
|
|
137
|
+
%w[from_key to_key uid].each do |k|
|
|
138
|
+
row[k] = extras.delete(k) if extras.key?(k)
|
|
139
|
+
end
|
|
140
|
+
row["extras"] = extras unless extras.empty?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
row
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Called inside the flock, after a successful write that pushed size over max.
|
|
147
|
+
# Renames audit.log → audit.log.1 (shifting older files), writes sidecar meta.
|
|
148
|
+
def rotate!(open_file)
|
|
149
|
+
open_file.flush
|
|
150
|
+
min_seq, max_seq = scan_seq_range(@path)
|
|
151
|
+
meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
|
|
152
|
+
|
|
153
|
+
# Drop the file that would be shifted past @keep.
|
|
154
|
+
oldest = File.join(@root, "audit.log.#{@keep}")
|
|
155
|
+
oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
|
|
156
|
+
FileUtils.rm_f(oldest)
|
|
157
|
+
FileUtils.rm_f(oldest_meta)
|
|
158
|
+
|
|
159
|
+
# Shift .N → .(N+1) for N = keep-1 down to 1.
|
|
160
|
+
(@keep - 1).downto(1) do |n|
|
|
161
|
+
src = File.join(@root, "audit.log.#{n}")
|
|
162
|
+
dst = File.join(@root, "audit.log.#{n + 1}")
|
|
163
|
+
File.rename(src, dst) if File.exist?(src)
|
|
164
|
+
|
|
165
|
+
src_meta = File.join(@root, "audit.log.#{n}.meta.json")
|
|
166
|
+
dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
|
|
167
|
+
File.rename(src_meta, dst_meta) if File.exist?(src_meta)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Active log → .1
|
|
171
|
+
File.rename(@path, File.join(@root, "audit.log.1"))
|
|
172
|
+
File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
|
|
173
|
+
# Next append will create a fresh audit.log via File::CREAT.
|
|
174
|
+
end
|
|
175
|
+
|
|
66
176
|
def parse_row(line)
|
|
67
177
|
return nil if line.empty?
|
|
68
178
|
|
|
@@ -2,10 +2,10 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Base < Entry
|
|
5
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
-
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
|
|
8
|
+
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
|
|
9
9
|
@manifest = manifest
|
|
10
10
|
@raw = raw
|
|
11
11
|
@key = key
|
|
@@ -14,24 +14,61 @@ module Textus
|
|
|
14
14
|
@schema = schema
|
|
15
15
|
@owner = owner
|
|
16
16
|
@format = format
|
|
17
|
+
@publish_to = Array(publish_to)
|
|
17
18
|
end
|
|
18
19
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
20
|
|
|
20
|
-
def kind = self.class.name.split("::").last.downcase.to_sym
|
|
21
|
-
|
|
22
21
|
def zone_writers
|
|
23
22
|
@manifest.zone_writers(@zone)
|
|
24
23
|
rescue UsageError => e
|
|
25
24
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
def in_generator_zone? =
|
|
29
|
-
def in_proposal_zone? =
|
|
27
|
+
def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
|
|
28
|
+
def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
|
|
30
29
|
|
|
31
30
|
def nested? = false
|
|
32
31
|
def derived? = false
|
|
33
32
|
def intake? = false
|
|
34
33
|
def leaf? = false
|
|
34
|
+
|
|
35
|
+
# Nil stubs for cross-cutting optional attrs. Subclasses override the
|
|
36
|
+
# ones they own. Validators and serializers can call these directly
|
|
37
|
+
# without `respond_to?` guards.
|
|
38
|
+
def template = nil
|
|
39
|
+
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
40
|
+
def events = {}
|
|
41
|
+
def publish_each = nil
|
|
42
|
+
def index_filename = nil
|
|
43
|
+
|
|
44
|
+
PublishContext = Struct.new(
|
|
45
|
+
:repo_root, :manifest, :file_store, :root, :store, :ctx, :bus, :hook_context,
|
|
46
|
+
:reader, :emit, # callables: reader.call(key) → envelope; emit.call(event, **payload)
|
|
47
|
+
keyword_init: true
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Subclasses override to customize publish behavior.
|
|
51
|
+
# Default: copy the stored file to each publish_to target.
|
|
52
|
+
# Returns: { kind: :built|:leaves, value: ... } to be accumulated by
|
|
53
|
+
# Publish#call, or nil to skip.
|
|
54
|
+
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
55
|
+
return nil if Array(publish_to).empty?
|
|
56
|
+
|
|
57
|
+
source_path = pctx.manifest.resolver.resolve(@key).path
|
|
58
|
+
envelope = pctx.reader.call(@key)
|
|
59
|
+
|
|
60
|
+
publish_to.each do |rel|
|
|
61
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
62
|
+
Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
63
|
+
pctx.emit.call(:file_published,
|
|
64
|
+
key: @key,
|
|
65
|
+
envelope: envelope,
|
|
66
|
+
source: source_path,
|
|
67
|
+
target: target_abs)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
|
|
71
|
+
end
|
|
35
72
|
end
|
|
36
73
|
end
|
|
37
74
|
end
|
|
@@ -5,20 +5,56 @@ module Textus
|
|
|
5
5
|
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
6
|
External = Data.define(:sources, :runner)
|
|
7
7
|
|
|
8
|
-
attr_reader :source, :template, :
|
|
8
|
+
attr_reader :source, :template, :inject_boot, :events
|
|
9
9
|
|
|
10
|
-
def initialize(source:, template: nil,
|
|
10
|
+
def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
|
|
11
11
|
super(**rest)
|
|
12
12
|
@source = source
|
|
13
13
|
@template = template
|
|
14
|
-
@
|
|
15
|
-
@publish_to = Array(publish_to)
|
|
14
|
+
@inject_boot = inject_boot
|
|
16
15
|
@events = events || {}
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def derived? = true
|
|
20
19
|
def projection? = @source.is_a?(Projection)
|
|
21
20
|
def external? = @source.is_a?(External)
|
|
21
|
+
|
|
22
|
+
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
return nil unless in_generator_zone?
|
|
24
|
+
|
|
25
|
+
target_path = Textus::Application::Writes::Materializer.new(
|
|
26
|
+
ctx: pctx.ctx, manifest: pctx.manifest, file_store: pctx.file_store,
|
|
27
|
+
bus: pctx.bus, root: pctx.root, store: pctx.store
|
|
28
|
+
).run(self)
|
|
29
|
+
|
|
30
|
+
envelope = pctx.reader.call(@key)
|
|
31
|
+
Array(publish_to).each do |rel|
|
|
32
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
33
|
+
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
|
|
34
|
+
pctx.emit.call(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
src = @source
|
|
38
|
+
selects = src.is_a?(Projection) ? Array(src.select).compact : []
|
|
39
|
+
pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
|
|
40
|
+
|
|
41
|
+
{ kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
KIND = :derived
|
|
45
|
+
|
|
46
|
+
def self.from_raw(common, raw)
|
|
47
|
+
source = Parser.parse_source(raw, common[:key])
|
|
48
|
+
new(
|
|
49
|
+
source: source,
|
|
50
|
+
template: raw["template"],
|
|
51
|
+
inject_boot: raw["inject_boot"] == true,
|
|
52
|
+
events: raw["events"] || {},
|
|
53
|
+
**common,
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Entry::REGISTRY[KIND] = self
|
|
22
58
|
end
|
|
23
59
|
end
|
|
24
60
|
end
|
|
@@ -2,17 +2,29 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Intake < Base
|
|
5
|
-
attr_reader :handler, :config, :events
|
|
5
|
+
attr_reader :handler, :config, :events
|
|
6
6
|
|
|
7
7
|
def initialize(handler:, config: {}, events: {}, **rest)
|
|
8
8
|
super(**rest)
|
|
9
9
|
@handler = handler
|
|
10
10
|
@config = config || {}
|
|
11
11
|
@events = events || {}
|
|
12
|
-
@publish_to = []
|
|
13
12
|
end
|
|
14
13
|
|
|
15
|
-
def intake?
|
|
14
|
+
def intake? = true
|
|
15
|
+
def nested? = !!@raw["nested"]
|
|
16
|
+
|
|
17
|
+
KIND = :intake
|
|
18
|
+
|
|
19
|
+
def self.from_raw(common, raw)
|
|
20
|
+
intake = raw["intake"] || {}
|
|
21
|
+
handler = intake["handler"] || raw["intake_handler"] or
|
|
22
|
+
raise UsageError.new("intake entry '#{common[:key]}' missing handler")
|
|
23
|
+
config = intake["config"] || raw["intake_config"] || {}
|
|
24
|
+
new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Entry::REGISTRY[KIND] = self
|
|
16
28
|
end
|
|
17
29
|
end
|
|
18
30
|
end
|
|
@@ -2,14 +2,15 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Leaf < Base
|
|
5
|
-
|
|
5
|
+
KIND = :leaf
|
|
6
6
|
|
|
7
|
-
def
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
def leaf? = true
|
|
8
|
+
|
|
9
|
+
def self.from_raw(common, _raw)
|
|
10
|
+
new(**common)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Entry::REGISTRY[KIND] = self
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -7,13 +7,12 @@ module Textus
|
|
|
7
7
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
8
8
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
9
|
|
|
10
|
-
attr_reader :index_filename, :publish_each
|
|
10
|
+
attr_reader :index_filename, :publish_each
|
|
11
11
|
|
|
12
|
-
def initialize(index_filename: nil, publish_each: nil,
|
|
12
|
+
def initialize(index_filename: nil, publish_each: nil, **rest)
|
|
13
13
|
super(**rest)
|
|
14
14
|
@index_filename = index_filename
|
|
15
15
|
@publish_each = publish_each
|
|
16
|
-
@publish_to = Array(publish_to)
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def nested? = true
|
|
@@ -33,6 +32,46 @@ module Textus
|
|
|
33
32
|
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
34
33
|
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
35
34
|
end
|
|
35
|
+
|
|
36
|
+
def publish_via(pctx, prefix: nil)
|
|
37
|
+
return nil if @publish_each.nil?
|
|
38
|
+
|
|
39
|
+
leaves = []
|
|
40
|
+
@manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
41
|
+
next unless row[:manifest_entry].equal?(self)
|
|
42
|
+
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
43
|
+
|
|
44
|
+
target_rel = publish_target_for(row[:key])
|
|
45
|
+
target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
|
|
46
|
+
unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
47
|
+
raise Textus::PublishError.new(
|
|
48
|
+
"entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
53
|
+
pctx.emit.call(:file_published,
|
|
54
|
+
key: row[:key],
|
|
55
|
+
envelope: pctx.reader.call(row[:key]),
|
|
56
|
+
source: row[:path],
|
|
57
|
+
target: target_abs)
|
|
58
|
+
leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ kind: :leaves, value: leaves }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
KIND = :nested
|
|
65
|
+
|
|
66
|
+
def self.from_raw(common, raw)
|
|
67
|
+
new(
|
|
68
|
+
index_filename: raw["index_filename"],
|
|
69
|
+
publish_each: raw["publish_each"],
|
|
70
|
+
**common,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Entry::REGISTRY[KIND] = self
|
|
36
75
|
end
|
|
37
76
|
end
|
|
38
77
|
end
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
10
10
|
zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
|
|
11
11
|
|
|
12
|
-
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (
|
|
12
|
+
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
13
13
|
kind = raw_kind.to_sym
|
|
14
14
|
format = resolve_format(raw, path)
|
|
15
15
|
|
|
@@ -17,60 +17,18 @@ module Textus
|
|
|
17
17
|
manifest: manifest, raw: raw,
|
|
18
18
|
key: key, path: path, zone: zone,
|
|
19
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
|
-
format: format
|
|
20
|
+
format: format,
|
|
21
|
+
publish_to: raw["publish_to"]
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
when :derived then build_derived(common, raw, key)
|
|
27
|
-
when :intake then build_intake(common, raw, key)
|
|
28
|
-
else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def self.build_leaf(common, raw)
|
|
33
|
-
Leaf.new(publish_to: raw["publish_to"], **common)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def self.build_nested(common, raw)
|
|
37
|
-
Nested.new(
|
|
38
|
-
index_filename: raw["index_filename"],
|
|
39
|
-
publish_each: raw["publish_each"],
|
|
40
|
-
publish_to: raw["publish_to"],
|
|
41
|
-
**common,
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.build_derived(common, raw, key)
|
|
46
|
-
source = parse_source(raw, key)
|
|
47
|
-
Derived.new(
|
|
48
|
-
source: source,
|
|
49
|
-
template: raw["template"],
|
|
50
|
-
inject_intro: raw["inject_intro"] == true,
|
|
51
|
-
publish_to: raw["publish_to"],
|
|
52
|
-
events: raw["events"] || {},
|
|
53
|
-
**common,
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def self.build_intake(common, raw, key)
|
|
58
|
-
intake = raw["intake"] || {}
|
|
59
|
-
handler = intake["handler"] || raw["intake_handler"] or
|
|
60
|
-
raise UsageError.new("intake entry '#{key}' missing handler")
|
|
61
|
-
config = intake["config"] || raw["intake_config"] || {}
|
|
62
|
-
Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
24
|
+
klass = Entry::REGISTRY[kind] or
|
|
25
|
+
raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect} (known: #{Entry::REGISTRY.keys.join(", ")})")
|
|
26
|
+
klass.from_raw(common, raw)
|
|
63
27
|
end
|
|
64
28
|
|
|
65
29
|
def self.parse_source(raw, key)
|
|
66
30
|
compute = raw["compute"]
|
|
67
|
-
if compute.nil?
|
|
68
|
-
# Tolerate legacy derived entries with bare template (no compute block):
|
|
69
|
-
# treat as projection with no select.
|
|
70
|
-
return Derived::Projection.new(select: nil, pluck: nil, sort_by: nil, transform: nil) if raw["template"]
|
|
71
|
-
|
|
72
|
-
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
|
|
73
|
-
end
|
|
31
|
+
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
|
|
74
32
|
|
|
75
33
|
unless COMPUTE_KINDS.include?(compute["kind"])
|
|
76
34
|
raise BadManifest.new(
|
|
@@ -79,14 +37,14 @@ module Textus
|
|
|
79
37
|
end
|
|
80
38
|
|
|
81
39
|
if compute["kind"] == "projection"
|
|
82
|
-
Derived::Projection.new(
|
|
40
|
+
Entry::Derived::Projection.new(
|
|
83
41
|
select: compute["select"],
|
|
84
42
|
pluck: compute["pluck"],
|
|
85
43
|
sort_by: compute["sort_by"],
|
|
86
44
|
transform: compute["transform"],
|
|
87
45
|
)
|
|
88
46
|
else
|
|
89
|
-
Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
47
|
+
Entry::Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
90
48
|
end
|
|
91
49
|
end
|
|
92
50
|
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
7
|
pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
|
|
8
|
-
events = entry.
|
|
8
|
+
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
11
11
|
|
|
@@ -14,12 +14,13 @@ module Textus
|
|
|
14
14
|
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
has_template =
|
|
18
|
-
is_external
|
|
19
|
-
|
|
17
|
+
has_template = !entry.template.nil?
|
|
18
|
+
is_external = entry.derived? && entry.external?
|
|
19
|
+
is_intake = entry.intake?
|
|
20
|
+
return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
|
|
20
21
|
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
21
22
|
|
|
22
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
23
|
+
raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -4,7 +4,8 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
8
|
+
index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
|
|
8
9
|
return if index_filename.nil?
|
|
9
10
|
|
|
10
11
|
check_shape!(entry, index_filename)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
module InjectBoot
|
|
6
|
+
def self.call(entry)
|
|
7
|
+
return unless entry.inject_boot
|
|
8
|
+
|
|
9
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
|
|
10
|
+
|
|
11
|
+
return unless entry.template.nil?
|
|
12
|
+
|
|
13
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: requires a template:")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -7,13 +7,14 @@ module Textus
|
|
|
7
7
|
VAR_RE = /\{([a-z]+)\}/
|
|
8
8
|
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
9
9
|
|
|
10
|
-
def self.call(entry)
|
|
11
|
-
|
|
10
|
+
def self.call(entry)
|
|
11
|
+
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
12
|
+
publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
|
|
12
13
|
return if publish_each.nil?
|
|
13
14
|
|
|
14
15
|
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
|
|
15
16
|
|
|
16
|
-
publish_to = entry.
|
|
17
|
+
publish_to = entry.publish_to
|
|
17
18
|
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
|
|
18
19
|
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
19
20
|
|
|
@@ -5,6 +5,9 @@ module Textus
|
|
|
5
5
|
# constants on Entry. Canonical source is the PublishEach validator.
|
|
6
6
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
7
7
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
8
|
+
|
|
9
|
+
# Populated by each Entry::* subclass at load time.
|
|
10
|
+
REGISTRY = {}
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|