textus 0.2.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 +7 -0
- data/CHANGELOG.md +163 -0
- data/README.md +200 -0
- data/SPEC.md +720 -0
- data/docs/architecture.md +57 -0
- data/docs/conventions.md +85 -0
- data/exe/textus +4 -0
- data/lib/textus/audit_log.rb +32 -0
- data/lib/textus/builder.rb +191 -0
- data/lib/textus/builtin_fetchers.rb +63 -0
- data/lib/textus/cli.rb +394 -0
- data/lib/textus/dependencies.rb +23 -0
- data/lib/textus/doctor.rb +281 -0
- data/lib/textus/entry/json.rb +41 -0
- data/lib/textus/entry/markdown.rb +39 -0
- data/lib/textus/entry/text.rb +23 -0
- data/lib/textus/entry/yaml.rb +39 -0
- data/lib/textus/entry.rb +30 -0
- data/lib/textus/errors.rb +168 -0
- data/lib/textus/etag.rb +13 -0
- data/lib/textus/extension_registry.rb +48 -0
- data/lib/textus/extensions.rb +29 -0
- data/lib/textus/init.rb +51 -0
- data/lib/textus/intro.rb +104 -0
- data/lib/textus/key_distance.rb +53 -0
- data/lib/textus/manifest.rb +394 -0
- data/lib/textus/migrate_keys.rb +187 -0
- data/lib/textus/mustache.rb +117 -0
- data/lib/textus/projection.rb +80 -0
- data/lib/textus/proposal.rb +27 -0
- data/lib/textus/publisher.rb +71 -0
- data/lib/textus/refresh.rb +75 -0
- data/lib/textus/role.rb +20 -0
- data/lib/textus/schema.rb +90 -0
- data/lib/textus/schema_tools.rb +87 -0
- data/lib/textus/store.rb +607 -0
- data/lib/textus/store_view.rb +18 -0
- data/lib/textus/version.rb +4 -0
- data/lib/textus.rb +31 -0
- metadata +156 -0
data/lib/textus/cli.rb
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "time"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Textus
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
|
+
class CLI
|
|
10
|
+
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
|
|
11
|
+
new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(stdin:, stdout:, stderr:, cwd:)
|
|
15
|
+
@stdin = stdin
|
|
16
|
+
@stdout = stdout
|
|
17
|
+
@stderr = stderr
|
|
18
|
+
@cwd = cwd
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(argv) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
22
|
+
verb = argv.shift
|
|
23
|
+
raise UsageError.new("missing verb") if verb.nil?
|
|
24
|
+
|
|
25
|
+
case verb
|
|
26
|
+
when "list" then verb_list(argv)
|
|
27
|
+
when "where" then verb_where(argv)
|
|
28
|
+
when "get" then verb_get(argv)
|
|
29
|
+
when "put" then verb_put(argv)
|
|
30
|
+
when "schema" then verb_schema(argv)
|
|
31
|
+
when "stale" then verb_stale(argv)
|
|
32
|
+
when "delete" then verb_delete(argv)
|
|
33
|
+
when "validate-all" then verb_validate_all(argv)
|
|
34
|
+
when "build" then verb_build(argv)
|
|
35
|
+
when "deps" then verb_deps(argv)
|
|
36
|
+
when "rdeps" then verb_rdeps(argv)
|
|
37
|
+
when "published" then verb_published(argv)
|
|
38
|
+
when "accept" then verb_accept(argv)
|
|
39
|
+
when "init" then verb_init(argv)
|
|
40
|
+
when "schema-init" then verb_schema_init(argv)
|
|
41
|
+
when "schema-diff" then verb_schema_diff(argv)
|
|
42
|
+
when "schema-migrate" then verb_schema_migrate(argv)
|
|
43
|
+
when "refresh" then verb_refresh(argv)
|
|
44
|
+
when "extensions" then verb_extensions(argv)
|
|
45
|
+
when "migrate-keys" then verb_migrate_keys(argv)
|
|
46
|
+
when "mv" then verb_mv(argv)
|
|
47
|
+
when "uid" then verb_uid(argv)
|
|
48
|
+
when "doctor" then verb_doctor(argv)
|
|
49
|
+
when "intro" then verb_intro(argv)
|
|
50
|
+
when "--version", "-v" then @stdout.puts(VERSION)
|
|
51
|
+
0
|
|
52
|
+
when "--help", "-h" then print_help
|
|
53
|
+
0
|
|
54
|
+
else raise UsageError.new("unknown verb: #{verb}")
|
|
55
|
+
end
|
|
56
|
+
rescue Textus::Error => e
|
|
57
|
+
emit_error(e)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def store
|
|
63
|
+
@store ||= Store.discover(@cwd)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_format!(argv)
|
|
67
|
+
fmt = "text"
|
|
68
|
+
OptionParser.new do |o|
|
|
69
|
+
o.on("--format=FMT") { |v| fmt = v }
|
|
70
|
+
end.permute!(argv)
|
|
71
|
+
raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
|
|
72
|
+
|
|
73
|
+
fmt
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_prefix!(argv)
|
|
77
|
+
prefix = nil
|
|
78
|
+
OptionParser.new do |o|
|
|
79
|
+
o.on("--prefix=KEY") { |v| prefix = v }
|
|
80
|
+
o.on("--zone=Z") {}
|
|
81
|
+
o.on("--format=FMT") {}
|
|
82
|
+
end.permute!(argv)
|
|
83
|
+
prefix
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_prefix_and_zone!(argv)
|
|
87
|
+
prefix = nil
|
|
88
|
+
zone = nil
|
|
89
|
+
fmt = "text"
|
|
90
|
+
OptionParser.new do |o|
|
|
91
|
+
o.on("--prefix=KEY") { |v| prefix = v }
|
|
92
|
+
o.on("--zone=Z") { |v| zone = v }
|
|
93
|
+
o.on("--format=FMT") { |v| fmt = v }
|
|
94
|
+
end.permute!(argv)
|
|
95
|
+
raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
|
|
96
|
+
|
|
97
|
+
[prefix, zone]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def verb_list(argv)
|
|
101
|
+
prefix, zone = parse_prefix_and_zone!(argv)
|
|
102
|
+
emit({ "protocol" => PROTOCOL, "entries" => store.list(prefix: prefix, zone: zone) })
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def verb_where(argv)
|
|
106
|
+
key = argv.shift or raise UsageError.new("where requires a key")
|
|
107
|
+
parse_format!(argv)
|
|
108
|
+
emit(store.where(key))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verb_get(argv)
|
|
112
|
+
key = argv.shift or raise UsageError.new("get requires a key")
|
|
113
|
+
parse_format!(argv)
|
|
114
|
+
emit(store.get(key))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def verb_schema(argv)
|
|
118
|
+
key = argv.shift or raise UsageError.new("schema requires a key")
|
|
119
|
+
parse_format!(argv)
|
|
120
|
+
emit(store.schema_envelope(key))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def verb_stale(argv)
|
|
124
|
+
prefix, zone = parse_prefix_and_zone!(argv)
|
|
125
|
+
emit(store.stale(prefix: prefix, zone: zone))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def verb_put(argv) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
129
|
+
key = argv.shift or raise UsageError.new("put requires a key")
|
|
130
|
+
as_flag = nil
|
|
131
|
+
use_stdin = false
|
|
132
|
+
fetcher_name = nil
|
|
133
|
+
OptionParser.new do |o|
|
|
134
|
+
o.on("--stdin") { use_stdin = true }
|
|
135
|
+
o.on("--as=ROLE") { |v| as_flag = v }
|
|
136
|
+
o.on("--fetcher=NAME") { |v| fetcher_name = v }
|
|
137
|
+
o.on("--format=FMT") {}
|
|
138
|
+
end.permute!(argv)
|
|
139
|
+
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
140
|
+
|
|
141
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
142
|
+
|
|
143
|
+
raw = @stdin.read
|
|
144
|
+
payload =
|
|
145
|
+
if fetcher_name
|
|
146
|
+
callable = store.registry.fetcher(fetcher_name)
|
|
147
|
+
result =
|
|
148
|
+
begin
|
|
149
|
+
Timeout.timeout(Textus::Refresh::FETCHER_TIMEOUT_SECONDS) do
|
|
150
|
+
callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store))
|
|
151
|
+
end
|
|
152
|
+
rescue Timeout::Error
|
|
153
|
+
raise UsageError.new(
|
|
154
|
+
"fetcher '#{fetcher_name}' exceeded #{Textus::Refresh::FETCHER_TIMEOUT_SECONDS}s timeout",
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
basename = key.split(".").last
|
|
158
|
+
{
|
|
159
|
+
"frontmatter" => {
|
|
160
|
+
"name" => basename,
|
|
161
|
+
"last_refreshed_at" => Time.now.utc.iso8601,
|
|
162
|
+
"fetched_with" => fetcher_name,
|
|
163
|
+
}.merge(result[:frontmatter] || result["frontmatter"] || {}),
|
|
164
|
+
"body" => result[:body] || result["body"] || "",
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
JSON.parse(raw)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
fm = payload["frontmatter"] || {}
|
|
171
|
+
body = payload["body"] || ""
|
|
172
|
+
if_etag = payload["if_etag"]
|
|
173
|
+
emit(store.put(key, frontmatter: fm, body: body, if_etag: if_etag, as: role))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def verb_delete(argv)
|
|
177
|
+
key = argv.shift or raise UsageError.new("delete requires a key")
|
|
178
|
+
as_flag = nil
|
|
179
|
+
if_etag = nil
|
|
180
|
+
OptionParser.new do |o|
|
|
181
|
+
o.on("--as=ROLE") { |v| as_flag = v }
|
|
182
|
+
o.on("--if-etag=E") { |v| if_etag = v }
|
|
183
|
+
o.on("--format=FMT") {}
|
|
184
|
+
end.permute!(argv)
|
|
185
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
186
|
+
emit(store.delete(key, if_etag: if_etag, as: role))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def verb_validate_all(argv)
|
|
190
|
+
parse_format!(argv)
|
|
191
|
+
res = store.validate_all
|
|
192
|
+
@stdout.puts(JSON.generate(res))
|
|
193
|
+
res["ok"] ? 0 : 1
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def verb_build(argv)
|
|
197
|
+
prefix = nil
|
|
198
|
+
OptionParser.new do |o|
|
|
199
|
+
o.on("--prefix=K") { |v| prefix = v }
|
|
200
|
+
o.on("--format=FMT") {}
|
|
201
|
+
end.permute!(argv)
|
|
202
|
+
res = Textus::Builder.new(store).build(prefix: prefix)
|
|
203
|
+
@stdout.puts(JSON.generate(res))
|
|
204
|
+
0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def verb_deps(argv)
|
|
208
|
+
key = argv.shift or raise UsageError.new("deps requires a key")
|
|
209
|
+
parse_format!(argv)
|
|
210
|
+
emit({ "protocol" => Textus::PROTOCOL, "key" => key, "deps" => store.deps(key) })
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def verb_rdeps(argv)
|
|
214
|
+
key = argv.shift or raise UsageError.new("rdeps requires a key")
|
|
215
|
+
parse_format!(argv)
|
|
216
|
+
emit({ "protocol" => Textus::PROTOCOL, "key" => key, "rdeps" => store.rdeps(key) })
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def verb_schema_init(argv)
|
|
220
|
+
name = argv.shift or raise UsageError.new("schema-init NAME")
|
|
221
|
+
from_key = nil
|
|
222
|
+
OptionParser.new do |o|
|
|
223
|
+
o.on("--from=KEY") { |v| from_key = v }
|
|
224
|
+
o.on("--format=FMT") {}
|
|
225
|
+
end.permute!(argv)
|
|
226
|
+
raise UsageError.new("schema-init requires --from=KEY") unless from_key
|
|
227
|
+
|
|
228
|
+
emit(Textus::SchemaTools.init(store, name: name, from: from_key))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def verb_schema_diff(argv)
|
|
232
|
+
name = argv.shift or raise UsageError.new("schema-diff NAME")
|
|
233
|
+
parse_format!(argv)
|
|
234
|
+
emit(Textus::SchemaTools.diff(store, name: name))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def verb_schema_migrate(argv)
|
|
238
|
+
name = argv.shift or raise UsageError.new("schema-migrate NAME")
|
|
239
|
+
rename = nil
|
|
240
|
+
OptionParser.new do |o|
|
|
241
|
+
o.on("--rename=O:N") { |v| rename = v }
|
|
242
|
+
o.on("--format=FMT") {}
|
|
243
|
+
end.permute!(argv)
|
|
244
|
+
raise UsageError.new("schema-migrate requires --rename=OLD:NEW") unless rename
|
|
245
|
+
|
|
246
|
+
emit(Textus::SchemaTools.migrate(store, name: name, rename: rename))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def verb_init(argv)
|
|
250
|
+
OptionParser.new do |o|
|
|
251
|
+
o.on("--format=FMT") {}
|
|
252
|
+
end.permute!(argv)
|
|
253
|
+
target = File.join(@cwd, ".textus")
|
|
254
|
+
res = Textus::Init.run(target)
|
|
255
|
+
@stdout.puts(JSON.generate(res))
|
|
256
|
+
0
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def verb_accept(argv)
|
|
260
|
+
key = argv.shift or raise UsageError.new("accept requires a key")
|
|
261
|
+
as_flag = nil
|
|
262
|
+
OptionParser.new do |o|
|
|
263
|
+
o.on("--as=ROLE") { |v| as_flag = v }
|
|
264
|
+
o.on("--format=FMT") {}
|
|
265
|
+
end.permute!(argv)
|
|
266
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
267
|
+
emit(store.accept(key, as: role))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def verb_refresh(argv)
|
|
271
|
+
key = argv.shift or raise UsageError.new("refresh requires a key")
|
|
272
|
+
as_flag = nil
|
|
273
|
+
OptionParser.new do |o|
|
|
274
|
+
o.on("--as=ROLE") { |v| as_flag = v }
|
|
275
|
+
o.on("--format=FMT") {}
|
|
276
|
+
end.permute!(argv)
|
|
277
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
278
|
+
emit(Textus::Refresh.call(store, key, as: role))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def verb_extensions(argv) # rubocop:disable Metrics/AbcSize
|
|
282
|
+
subcommand = argv.shift
|
|
283
|
+
raise UsageError.new("extensions requires 'list'") unless subcommand == "list"
|
|
284
|
+
|
|
285
|
+
kind = nil
|
|
286
|
+
OptionParser.new do |o|
|
|
287
|
+
o.on("--kind=K") { |v| kind = v }
|
|
288
|
+
o.on("--format=FMT") {}
|
|
289
|
+
end.permute!(argv)
|
|
290
|
+
|
|
291
|
+
rows = []
|
|
292
|
+
rows += store.registry.fetcher_names.map { |n| { "kind" => "fetcher", "name" => n.to_s } }
|
|
293
|
+
rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
|
|
294
|
+
store.registry.hook_events.each do |evt|
|
|
295
|
+
store.registry.hooks(evt).each do |h|
|
|
296
|
+
rows << { "kind" => "hook", "event" => evt.to_s, "name" => h[:name].to_s }
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
store.manifest.entries.each do |e|
|
|
300
|
+
e.events.each do |evt, defs|
|
|
301
|
+
Array(defs).each do |defn|
|
|
302
|
+
next unless defn["exec"]
|
|
303
|
+
|
|
304
|
+
rows << {
|
|
305
|
+
"kind" => "hook", "event" => evt.to_s, "exec" => defn["exec"],
|
|
306
|
+
"key" => e.key, "as" => defn["as"] || "script"
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
rows.select! { |r| r["kind"] == kind } if kind
|
|
312
|
+
|
|
313
|
+
emit({ "protocol" => Textus::PROTOCOL, "extensions" => rows })
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def verb_migrate_keys(argv)
|
|
317
|
+
write = false
|
|
318
|
+
OptionParser.new do |o|
|
|
319
|
+
o.on("--dry-run") { write = false }
|
|
320
|
+
o.on("--write") { write = true }
|
|
321
|
+
o.on("--format=FMT") {}
|
|
322
|
+
end.permute!(argv)
|
|
323
|
+
res = Textus::MigrateKeys.run(store, write: write)
|
|
324
|
+
@stdout.puts(JSON.generate(res))
|
|
325
|
+
res["ok"] ? 0 : 1
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def verb_mv(argv)
|
|
329
|
+
old_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
330
|
+
new_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
331
|
+
as_flag = nil
|
|
332
|
+
dry_run = false
|
|
333
|
+
OptionParser.new do |o|
|
|
334
|
+
o.on("--as=ROLE") { |v| as_flag = v }
|
|
335
|
+
o.on("--dry-run") { dry_run = true }
|
|
336
|
+
o.on("--format=FMT") {}
|
|
337
|
+
end.permute!(argv)
|
|
338
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
339
|
+
emit(store.mv(old_key, new_key, as: role, dry_run: dry_run))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def verb_uid(argv)
|
|
343
|
+
key = argv.shift or raise UsageError.new("uid requires a key")
|
|
344
|
+
parse_format!(argv)
|
|
345
|
+
emit({ "protocol" => PROTOCOL, "key" => key, "uid" => store.uid(key) })
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def verb_intro(argv)
|
|
349
|
+
parse_format!(argv)
|
|
350
|
+
emit(Textus::Intro.run(store))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def verb_doctor(argv)
|
|
354
|
+
OptionParser.new do |o|
|
|
355
|
+
o.on("--format=FMT") {}
|
|
356
|
+
end.permute!(argv)
|
|
357
|
+
res = Textus::Doctor.run(store)
|
|
358
|
+
@stdout.puts(JSON.generate(res))
|
|
359
|
+
res["ok"] ? 0 : 1
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def verb_published(argv)
|
|
363
|
+
parse_format!(argv)
|
|
364
|
+
emit({ "protocol" => Textus::PROTOCOL, "published" => store.published })
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def emit(obj)
|
|
368
|
+
@stdout.puts(JSON.generate(obj))
|
|
369
|
+
0
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def emit_error(err)
|
|
373
|
+
@stdout.puts(JSON.generate(err.to_envelope))
|
|
374
|
+
@stderr.puts("#{err.code}: #{err.message}")
|
|
375
|
+
@stderr.puts(" → #{err.hint}") if err.hint
|
|
376
|
+
err.exit_code
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def print_help
|
|
380
|
+
@stdout.puts <<~HELP
|
|
381
|
+
textus #{VERSION} — reference implementation of #{PROTOCOL}
|
|
382
|
+
|
|
383
|
+
Usage:
|
|
384
|
+
textus list [--prefix=KEY] --format=json
|
|
385
|
+
textus where KEY --format=json
|
|
386
|
+
textus get KEY --format=json
|
|
387
|
+
textus put KEY --stdin --format=json
|
|
388
|
+
textus schema KEY --format=json
|
|
389
|
+
textus stale [--prefix=KEY] --format=json
|
|
390
|
+
HELP
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
# rubocop:enable Metrics/ClassLength
|
|
394
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Dependencies
|
|
3
|
+
def self.deps_of(manifest, key)
|
|
4
|
+
entry = manifest.entries.find { |e| e.key == key } or return []
|
|
5
|
+
result = Array(entry.projection&.fetch("select", nil)).map { |s| s }
|
|
6
|
+
Array(entry.generator&.fetch("sources", nil)).each { |s| result << s }
|
|
7
|
+
result.uniq
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.rdeps_of(manifest, key)
|
|
11
|
+
manifest.entries.each_with_object([]) do |e, acc|
|
|
12
|
+
sources = Array(e.projection&.fetch("select", nil)) + Array(e.generator&.fetch("sources", nil))
|
|
13
|
+
acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.published_of(manifest)
|
|
18
|
+
manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
|
|
19
|
+
{ "key" => e.key, "publish_to" => e.publish_to }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
6
|
+
# with an `issues` array and a summary. Each issue is a Hash with
|
|
7
|
+
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
8
|
+
module Doctor
|
|
9
|
+
LEVELS = %w[error warning info].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def run(store)
|
|
14
|
+
issues = []
|
|
15
|
+
issues.concat(check_manifest_files(store))
|
|
16
|
+
issues.concat(check_schemas(store))
|
|
17
|
+
issues.concat(check_templates(store))
|
|
18
|
+
issues.concat(check_extensions(store))
|
|
19
|
+
issues.concat(check_illegal_keys(store))
|
|
20
|
+
issues.concat(check_sentinels(store))
|
|
21
|
+
issues.concat(check_audit_log(store))
|
|
22
|
+
issues.concat(check_unowned_schema_fields(store))
|
|
23
|
+
|
|
24
|
+
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
25
|
+
{
|
|
26
|
+
"protocol" => Textus::PROTOCOL,
|
|
27
|
+
"ok" => summary["error"].zero?,
|
|
28
|
+
"issues" => issues,
|
|
29
|
+
"summary" => summary,
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# --- Checks -----------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def check_manifest_files(store)
|
|
36
|
+
out = []
|
|
37
|
+
store.manifest.entries.each do |entry|
|
|
38
|
+
next if entry.nested
|
|
39
|
+
|
|
40
|
+
path = leaf_path_for(store, entry)
|
|
41
|
+
next if File.exist?(path)
|
|
42
|
+
|
|
43
|
+
out << {
|
|
44
|
+
"code" => "manifest.missing_file",
|
|
45
|
+
"level" => "info",
|
|
46
|
+
"subject" => entry.key,
|
|
47
|
+
"message" => "declared entry has no file on disk at #{path}",
|
|
48
|
+
"fix" => "create the entry with 'textus put #{entry.key} --stdin --as=<role>' " \
|
|
49
|
+
"(or leave empty if not yet authored)",
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
out
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check_schemas(store)
|
|
56
|
+
out = []
|
|
57
|
+
store.manifest.entries.each do |entry|
|
|
58
|
+
next if entry.schema.nil?
|
|
59
|
+
|
|
60
|
+
sp = File.join(store.root, "schemas", "#{entry.schema}.yaml")
|
|
61
|
+
next if File.exist?(sp)
|
|
62
|
+
|
|
63
|
+
out << {
|
|
64
|
+
"code" => "schema.missing",
|
|
65
|
+
"level" => "error",
|
|
66
|
+
"subject" => entry.key,
|
|
67
|
+
"message" => "schema '#{entry.schema}' not found at #{sp}",
|
|
68
|
+
"fix" => "create the schema file or run 'textus schema-init #{entry.schema} --from=<key>'",
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
out
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def check_templates(store)
|
|
75
|
+
out = []
|
|
76
|
+
store.manifest.entries.each do |entry|
|
|
77
|
+
next if entry.template.nil?
|
|
78
|
+
|
|
79
|
+
tp = File.join(store.root, "templates", entry.template)
|
|
80
|
+
next if File.exist?(tp)
|
|
81
|
+
|
|
82
|
+
out << {
|
|
83
|
+
"code" => "template.missing",
|
|
84
|
+
"level" => "error",
|
|
85
|
+
"subject" => entry.key,
|
|
86
|
+
"message" => "template '#{entry.template}' not found at #{tp}",
|
|
87
|
+
"fix" => "create the file at #{tp} or update the entry's template: field",
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
out
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_extensions(store)
|
|
94
|
+
out = []
|
|
95
|
+
dir = File.join(store.root, "extensions")
|
|
96
|
+
return out unless File.directory?(dir)
|
|
97
|
+
|
|
98
|
+
Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
99
|
+
registry = ExtensionRegistry.new
|
|
100
|
+
Textus.with_registry(registry) do
|
|
101
|
+
load(f)
|
|
102
|
+
end
|
|
103
|
+
rescue StandardError, ScriptError => e
|
|
104
|
+
out << {
|
|
105
|
+
"code" => "extension.load_failed",
|
|
106
|
+
"level" => "error",
|
|
107
|
+
"subject" => File.basename(f),
|
|
108
|
+
"message" => "#{e.class}: #{e.message}",
|
|
109
|
+
"fix" => "open #{f} and fix the syntax/load error",
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
out
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_illegal_keys(store)
|
|
116
|
+
out = []
|
|
117
|
+
store.manifest.entries.each do |entry|
|
|
118
|
+
next unless entry.nested
|
|
119
|
+
|
|
120
|
+
base = File.join(store.root, "zones", entry.path)
|
|
121
|
+
next unless File.directory?(base)
|
|
122
|
+
|
|
123
|
+
walk_nested(base) do |abs_path, is_dir|
|
|
124
|
+
basename = File.basename(abs_path)
|
|
125
|
+
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
126
|
+
next if stem.match?(Manifest::KEY_SEGMENT)
|
|
127
|
+
|
|
128
|
+
proposed = Textus::MigrateKeys.normalize(stem)
|
|
129
|
+
out << {
|
|
130
|
+
"code" => "key.illegal",
|
|
131
|
+
"level" => "error",
|
|
132
|
+
"subject" => abs_path,
|
|
133
|
+
"path" => abs_path,
|
|
134
|
+
"proposed_key" => proposed,
|
|
135
|
+
"message" => "illegal key segment '#{stem}' at #{abs_path}",
|
|
136
|
+
"fix" => "run 'textus migrate-keys --dry-run' then '--write' to rename to '#{proposed}'",
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
out
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def check_sentinels(store)
|
|
144
|
+
out = []
|
|
145
|
+
dir = File.join(store.root, "sentinels")
|
|
146
|
+
return out unless File.directory?(dir)
|
|
147
|
+
|
|
148
|
+
Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
|
|
149
|
+
begin
|
|
150
|
+
data = JSON.parse(File.read(sp))
|
|
151
|
+
rescue JSON::ParserError => e
|
|
152
|
+
out << {
|
|
153
|
+
"code" => "sentinel.parse_error",
|
|
154
|
+
"level" => "warning",
|
|
155
|
+
"subject" => sp,
|
|
156
|
+
"message" => "sentinel is not valid JSON: #{e.message}",
|
|
157
|
+
"fix" => "delete #{sp} and re-run 'textus build' to regenerate",
|
|
158
|
+
}
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
target = data["target"]
|
|
163
|
+
recorded_sha = data["sha256"]
|
|
164
|
+
|
|
165
|
+
if target.nil? || !File.exist?(target)
|
|
166
|
+
out << {
|
|
167
|
+
"code" => "sentinel.orphan",
|
|
168
|
+
"level" => "warning",
|
|
169
|
+
"subject" => sp,
|
|
170
|
+
"message" => "sentinel target #{target.inspect} no longer exists",
|
|
171
|
+
"fix" => "delete #{sp} (the published file is gone) or restore the target",
|
|
172
|
+
}
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
current_sha = Digest::SHA256.hexdigest(File.binread(target))
|
|
177
|
+
next if recorded_sha.nil? || current_sha == recorded_sha
|
|
178
|
+
|
|
179
|
+
out << {
|
|
180
|
+
"code" => "sentinel.drift",
|
|
181
|
+
"level" => "warning",
|
|
182
|
+
"subject" => target,
|
|
183
|
+
"message" => "published file at #{target} was modified out-of-band",
|
|
184
|
+
"fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
out
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def check_audit_log(store)
|
|
191
|
+
out = []
|
|
192
|
+
path = File.join(store.root, "audit.log")
|
|
193
|
+
return out unless File.exist?(path)
|
|
194
|
+
|
|
195
|
+
File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
|
|
196
|
+
stripped = line.chomp
|
|
197
|
+
next if stripped.empty?
|
|
198
|
+
|
|
199
|
+
# Audit log is TSV, not NDJSON. Treat as malformed if it doesn't have
|
|
200
|
+
# at least 6 tab-separated fields (timestamp, role, verb, key, etag_before, etag_after).
|
|
201
|
+
fields = stripped.split("\t")
|
|
202
|
+
if fields.length < 6
|
|
203
|
+
out << {
|
|
204
|
+
"code" => "audit.parse_error",
|
|
205
|
+
"level" => "warning",
|
|
206
|
+
"subject" => "#{path}:#{lineno}",
|
|
207
|
+
"message" => "audit log line #{lineno} has #{fields.length} fields (expected >=6)",
|
|
208
|
+
"fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
|
|
209
|
+
}
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
extras = fields[6]
|
|
214
|
+
next if extras.nil? || extras.empty?
|
|
215
|
+
|
|
216
|
+
begin
|
|
217
|
+
JSON.parse(extras)
|
|
218
|
+
rescue JSON::ParserError => e
|
|
219
|
+
out << {
|
|
220
|
+
"code" => "audit.parse_error",
|
|
221
|
+
"level" => "warning",
|
|
222
|
+
"subject" => "#{path}:#{lineno}",
|
|
223
|
+
"message" => "audit log line #{lineno} extras JSON malformed: #{e.message}",
|
|
224
|
+
"fix" => "inspect #{path} at line #{lineno} and fix the JSON in the last column",
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
out
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def check_unowned_schema_fields(store)
|
|
232
|
+
out = []
|
|
233
|
+
dir = File.join(store.root, "schemas")
|
|
234
|
+
return out unless File.directory?(dir)
|
|
235
|
+
|
|
236
|
+
Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
|
|
237
|
+
schema = begin
|
|
238
|
+
Schema.load(sp)
|
|
239
|
+
rescue StandardError
|
|
240
|
+
next
|
|
241
|
+
end
|
|
242
|
+
unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
|
|
243
|
+
acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
|
|
244
|
+
end
|
|
245
|
+
next if unowned.empty?
|
|
246
|
+
|
|
247
|
+
out << {
|
|
248
|
+
"code" => "schema.unowned_fields",
|
|
249
|
+
"level" => "info",
|
|
250
|
+
"subject" => schema.name || File.basename(sp, ".yaml"),
|
|
251
|
+
"message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
|
|
252
|
+
"fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
out
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# --- Helpers ----------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def leaf_path_for(store, entry)
|
|
261
|
+
primary_ext = Entry.for_format(entry.format).extensions.first
|
|
262
|
+
if File.extname(entry.path) == ""
|
|
263
|
+
File.join(store.root, "zones", entry.path + primary_ext)
|
|
264
|
+
else
|
|
265
|
+
File.join(store.root, "zones", entry.path)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def walk_nested(root, &block)
|
|
270
|
+
Dir.each_child(root) do |name|
|
|
271
|
+
abs = File.join(root, name)
|
|
272
|
+
if File.directory?(abs)
|
|
273
|
+
walk_nested(abs, &block)
|
|
274
|
+
yield abs, true
|
|
275
|
+
else
|
|
276
|
+
yield abs, false
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|