textus 0.4.0 → 0.8.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 +147 -2
- data/README.md +38 -28
- data/SPEC.md +84 -147
- data/docs/architecture.md +82 -28
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli.rb +44 -385
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +29 -264
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +11 -5
- data/lib/textus/entry/markdown.rb +5 -5
- data/lib/textus/entry/text.rb +4 -4
- data/lib/textus/entry/yaml.rb +11 -5
- data/lib/textus/entry.rb +2 -7
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +16 -18
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +20 -254
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/proposal.rb +4 -4
- data/lib/textus/refresh.rb +17 -17
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +121 -0
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +133 -0
- data/lib/textus/store/validator.rb +56 -0
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +26 -527
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +14 -29
- metadata +78 -8
- data/lib/textus/audit_log.rb +0 -32
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store_view.rb +0 -27
data/lib/textus/refresh.rb
CHANGED
|
@@ -2,32 +2,32 @@ require "timeout"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Refresh
|
|
5
|
-
|
|
5
|
+
FETCH_TIMEOUT_SECONDS = 2
|
|
6
6
|
|
|
7
7
|
def self.call(store, key, as:)
|
|
8
8
|
mentry, path, = store.manifest.resolve(key)
|
|
9
|
-
raise UsageError.new("no
|
|
9
|
+
raise UsageError.new("no fetch declared for '#{key}'") unless mentry.fetch
|
|
10
10
|
|
|
11
11
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
12
|
-
callable = store.registry.
|
|
13
|
-
view =
|
|
12
|
+
callable = store.registry.rpc_callable(:fetch, mentry.fetch)
|
|
13
|
+
view = Store::View.new(store, writable: true, as: as)
|
|
14
14
|
result =
|
|
15
15
|
begin
|
|
16
|
-
Timeout.timeout(
|
|
17
|
-
callable.call(
|
|
16
|
+
Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
|
|
17
|
+
callable.call(store: view, config: mentry.fetch_config, args: {})
|
|
18
18
|
end
|
|
19
19
|
rescue Timeout::Error
|
|
20
|
-
raise UsageError.new("
|
|
20
|
+
raise UsageError.new("fetch '#{mentry.fetch}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
|
|
21
21
|
rescue Textus::Error
|
|
22
22
|
raise
|
|
23
23
|
rescue StandardError => e
|
|
24
|
-
raise UsageError.new("
|
|
24
|
+
raise UsageError.new("fetch '#{mentry.fetch}' raised: #{e.class}: #{e.message}")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
normalized = normalize_action_result(result, format: mentry.format)
|
|
28
28
|
envelope = store.put(
|
|
29
29
|
key,
|
|
30
|
-
|
|
30
|
+
meta: normalized[:meta],
|
|
31
31
|
body: normalized[:body],
|
|
32
32
|
content: normalized[:content],
|
|
33
33
|
as: as,
|
|
@@ -45,28 +45,28 @@ module Textus
|
|
|
45
45
|
envelope
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Normalize the three accepted
|
|
48
|
+
# Normalize the three accepted fetch return shapes into the store's
|
|
49
49
|
# internal {frontmatter, body, content} representation.
|
|
50
50
|
def self.normalize_action_result(res, format:)
|
|
51
51
|
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
52
52
|
res ||= {}
|
|
53
|
-
|
|
53
|
+
# Accept both legacy :frontmatter/:_meta key names from fetch hooks.
|
|
54
|
+
meta_val = res["_meta"] || res["frontmatter"]
|
|
54
55
|
body = res["body"]
|
|
55
56
|
content = res["content"]
|
|
56
57
|
|
|
57
58
|
case format
|
|
58
59
|
when "markdown"
|
|
59
|
-
{
|
|
60
|
+
{ meta: meta_val || {}, body: body.to_s, content: nil }
|
|
60
61
|
when "text"
|
|
61
|
-
{
|
|
62
|
+
{ meta: {}, body: body.to_s, content: nil }
|
|
62
63
|
when "json", "yaml"
|
|
63
64
|
if !content.nil?
|
|
64
|
-
meta
|
|
65
|
-
{ frontmatter: meta, body: nil, content: content }
|
|
65
|
+
{ meta: meta_val || {}, body: nil, content: content }
|
|
66
66
|
elsif !body.nil?
|
|
67
|
-
{
|
|
67
|
+
{ meta: {}, body: body.to_s, content: nil }
|
|
68
68
|
else
|
|
69
|
-
raise UsageError.new("
|
|
69
|
+
raise UsageError.new("fetch for #{format} returned neither content nor body")
|
|
70
70
|
end
|
|
71
71
|
else
|
|
72
72
|
raise UsageError.new("unknown format #{format.inspect}")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Schema
|
|
6
|
+
module Tools
|
|
7
|
+
# textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
|
|
8
|
+
def self.init(store, name:, from:)
|
|
9
|
+
env = store.get(from)
|
|
10
|
+
meta = env["_meta"]
|
|
11
|
+
schema = {
|
|
12
|
+
"name" => name,
|
|
13
|
+
"required" => meta.keys,
|
|
14
|
+
"optional" => [],
|
|
15
|
+
"fields" => meta.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
|
|
16
|
+
}
|
|
17
|
+
FileUtils.mkdir_p(File.join(store.root, "schemas"))
|
|
18
|
+
target = File.join(store.root, "schemas", "#{name}.yaml")
|
|
19
|
+
File.write(target, YAML.dump(schema))
|
|
20
|
+
{ "protocol" => PROTOCOL, "schema_name" => name, "path" => target }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# textus schema diff NAME → list keys whose frontmatter violates the schema
|
|
24
|
+
def self.diff(store, name:)
|
|
25
|
+
schema = load_schema(store, name)
|
|
26
|
+
drift = []
|
|
27
|
+
store.manifest.enumerate.each do |row|
|
|
28
|
+
env = store.get(row[:key])
|
|
29
|
+
begin
|
|
30
|
+
schema.validate!(env["_meta"])
|
|
31
|
+
rescue SchemaViolation => e
|
|
32
|
+
drift << { "key" => row[:key], "details" => e.details }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
{ "protocol" => PROTOCOL, "schema_name" => name, "drift" => drift }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# textus schema migrate NAME --rename=OLD:NEW → rewrites frontmatter across affected entries
|
|
39
|
+
# If --rename is omitted, falls back to schema.evolution.migrate_from.
|
|
40
|
+
def self.migrate(store, name:, rename: nil)
|
|
41
|
+
renames =
|
|
42
|
+
if rename
|
|
43
|
+
old_field, new_field = rename.split(":", 2)
|
|
44
|
+
raise UsageError.new("--rename=OLD:NEW") unless old_field && new_field && !new_field.empty?
|
|
45
|
+
|
|
46
|
+
{ old_field => new_field }
|
|
47
|
+
else
|
|
48
|
+
load_schema(store, name).evolution["migrate_from"] || {}
|
|
49
|
+
end
|
|
50
|
+
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
51
|
+
|
|
52
|
+
touched = []
|
|
53
|
+
store.manifest.enumerate.each do |row|
|
|
54
|
+
env = store.get(row[:key])
|
|
55
|
+
meta = env["_meta"]
|
|
56
|
+
changed = false
|
|
57
|
+
renames.each do |old, new|
|
|
58
|
+
if meta.key?(old)
|
|
59
|
+
meta[new] = meta.delete(old)
|
|
60
|
+
changed = true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
next unless changed
|
|
64
|
+
|
|
65
|
+
store.put(row[:key], meta: meta, body: env["body"], as: "human")
|
|
66
|
+
touched << row[:key]
|
|
67
|
+
end
|
|
68
|
+
{ "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.infer_type(value)
|
|
72
|
+
case value
|
|
73
|
+
when String then "string"
|
|
74
|
+
when Numeric then "number"
|
|
75
|
+
when true, false then "boolean"
|
|
76
|
+
when Array then "array"
|
|
77
|
+
when Hash then "object"
|
|
78
|
+
else "string"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.load_schema(store, name)
|
|
83
|
+
store.schema_for(name)
|
|
84
|
+
rescue IoError
|
|
85
|
+
raise UsageError.new("schema not found: #{name}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Store
|
|
6
|
+
class AuditLog
|
|
7
|
+
def initialize(root)
|
|
8
|
+
@path = File.join(root, "audit.log")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def last_writer_for(key)
|
|
12
|
+
return nil unless File.exist?(@path)
|
|
13
|
+
|
|
14
|
+
last_role = nil
|
|
15
|
+
File.foreach(@path) do |line|
|
|
16
|
+
parsed = parse_row(line.chomp)
|
|
17
|
+
next unless parsed
|
|
18
|
+
next unless parsed["key"] == key
|
|
19
|
+
next unless %w[put delete].include?(parsed["verb"])
|
|
20
|
+
|
|
21
|
+
last_role = parsed["role"]
|
|
22
|
+
end
|
|
23
|
+
last_role
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
27
|
+
row = {
|
|
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
|
+
}
|
|
35
|
+
|
|
36
|
+
if extras.is_a?(Hash) && !extras.empty?
|
|
37
|
+
extras = extras.dup
|
|
38
|
+
%w[from_key to_key uid].each do |k|
|
|
39
|
+
row[k] = extras.delete(k) if extras.key?(k)
|
|
40
|
+
end
|
|
41
|
+
row["extras"] = extras unless extras.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
45
|
+
f.flock(File::LOCK_EX)
|
|
46
|
+
f.write(JSON.generate(row) + "\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_row(line)
|
|
53
|
+
return nil if line.empty?
|
|
54
|
+
|
|
55
|
+
if line.start_with?("{")
|
|
56
|
+
JSON.parse(line)
|
|
57
|
+
else
|
|
58
|
+
# Legacy TSV (pre-0.5): read-only support retained for on-disk logs
|
|
59
|
+
# written by older textus versions. Never written by current code.
|
|
60
|
+
# Format: ts, role, verb, key, etag_before, etag_after [, json_extras]
|
|
61
|
+
fields = line.split("\t")
|
|
62
|
+
return nil if fields.length < 4
|
|
63
|
+
|
|
64
|
+
{ "ts" => fields[0], "role" => fields[1], "verb" => fields[2], "key" => fields[3] }
|
|
65
|
+
end
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
6
|
+
class Mover
|
|
7
|
+
def initialize(reader:, writer:, manifest:, audit_log:)
|
|
8
|
+
@reader = reader
|
|
9
|
+
@writer = writer
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
@audit_log = audit_log
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
|
|
15
|
+
@manifest.validate_key!(old_key)
|
|
16
|
+
@manifest.validate_key!(new_key)
|
|
17
|
+
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
18
|
+
|
|
19
|
+
old_mentry, old_path, = @manifest.resolve(old_key)
|
|
20
|
+
raise UnknownKey.new(old_key) unless File.exist?(old_path)
|
|
21
|
+
|
|
22
|
+
new_mentry, new_path, = @manifest.resolve(new_key)
|
|
23
|
+
|
|
24
|
+
if old_mentry.zone != new_mentry.zone
|
|
25
|
+
raise UsageError.new(
|
|
26
|
+
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
27
|
+
"Use put+delete for cross-zone moves.",
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
if old_mentry.format != new_mentry.format
|
|
31
|
+
raise UsageError.new(
|
|
32
|
+
"mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
writers = @manifest.zone_writers(old_mentry.zone)
|
|
37
|
+
raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
|
|
38
|
+
|
|
39
|
+
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
|
|
40
|
+
|
|
41
|
+
# Mint uid before the move so the audit row carries it.
|
|
42
|
+
pre_env = @reader.get(old_key)
|
|
43
|
+
current_uid = pre_env["uid"]
|
|
44
|
+
etag_before = pre_env["etag"]
|
|
45
|
+
|
|
46
|
+
if dry_run
|
|
47
|
+
return {
|
|
48
|
+
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
49
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
50
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
51
|
+
"uid" => current_uid
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if current_uid.nil?
|
|
56
|
+
# Write the uid in place first so the source file carries it before mv.
|
|
57
|
+
pre_env = @writer.put(old_key,
|
|
58
|
+
meta: pre_env["_meta"],
|
|
59
|
+
body: pre_env["body"],
|
|
60
|
+
content: pre_env["content"],
|
|
61
|
+
as: as,
|
|
62
|
+
suppress_events: true)
|
|
63
|
+
current_uid = pre_env["uid"]
|
|
64
|
+
etag_before = pre_env["etag"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
68
|
+
FileUtils.mv(old_path, new_path)
|
|
69
|
+
rewrite_name_for_mv!(new_mentry, new_path, new_key)
|
|
70
|
+
etag_after = Etag.for_file(new_path)
|
|
71
|
+
|
|
72
|
+
@audit_log.append(
|
|
73
|
+
role: as, verb: "mv", key: new_key,
|
|
74
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
75
|
+
extras: {
|
|
76
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
77
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
78
|
+
"uid" => current_uid
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
env = @reader.get(new_key)
|
|
83
|
+
{
|
|
84
|
+
"protocol" => PROTOCOL, "ok" => true,
|
|
85
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
86
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
87
|
+
"uid" => current_uid,
|
|
88
|
+
"envelope" => env
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# If the moved file carries a `name:` field (markdown) or `_meta.name`
|
|
95
|
+
# (json/yaml), rewrite it to the new basename so enforce_name_match! stays
|
|
96
|
+
# happy on the next read. Only touches the bytes when name actually changes.
|
|
97
|
+
def rewrite_name_for_mv!(mentry, new_path, new_key)
|
|
98
|
+
strategy = Entry.for_format(mentry.format)
|
|
99
|
+
raw = File.binread(new_path)
|
|
100
|
+
parsed = strategy.parse(raw, path: new_path)
|
|
101
|
+
basename = new_key.split(".").last
|
|
102
|
+
|
|
103
|
+
case mentry.format
|
|
104
|
+
when "markdown"
|
|
105
|
+
meta = parsed["_meta"] || {}
|
|
106
|
+
return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
107
|
+
|
|
108
|
+
meta = meta.merge("name" => basename)
|
|
109
|
+
File.binwrite(new_path, strategy.serialize(meta: meta, body: parsed["body"]))
|
|
110
|
+
when "json", "yaml"
|
|
111
|
+
meta = parsed["_meta"]
|
|
112
|
+
return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
113
|
+
|
|
114
|
+
new_meta = meta.merge("name" => basename)
|
|
115
|
+
File.binwrite(new_path, strategy.serialize(meta: new_meta, body: "", content: parsed["content"]))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
class Reader
|
|
4
|
+
def initialize(store)
|
|
5
|
+
@store = store
|
|
6
|
+
@manifest = store.manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def get(key)
|
|
10
|
+
mentry, path, = @manifest.resolve(key)
|
|
11
|
+
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
12
|
+
|
|
13
|
+
raw = File.binread(path)
|
|
14
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
15
|
+
meta = parsed["_meta"]
|
|
16
|
+
content = parsed["content"]
|
|
17
|
+
@store.writer.enforce_name_match!(path, meta, mentry.format)
|
|
18
|
+
schema = @store.schema_for(mentry.schema)
|
|
19
|
+
Entry.for_format(mentry.format).validate_against(schema, parsed) if schema
|
|
20
|
+
Envelope.build(
|
|
21
|
+
key: key, mentry: mentry, path: path,
|
|
22
|
+
meta: meta, body: parsed["body"],
|
|
23
|
+
etag: Etag.for_bytes(raw), content: content
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list(prefix: nil, zone: nil)
|
|
28
|
+
rows = @manifest.enumerate(prefix: prefix)
|
|
29
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
30
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def where(key)
|
|
34
|
+
mentry, path, = @manifest.resolve(key)
|
|
35
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def schema_envelope(key)
|
|
39
|
+
mentry, = @manifest.resolve(key)
|
|
40
|
+
schema = @store.schema_for(mentry.schema)
|
|
41
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the Textus UID for a key (or nil if the entry has none yet).
|
|
45
|
+
# Raises UnknownKey if the key doesn't resolve to a real file.
|
|
46
|
+
def uid(key)
|
|
47
|
+
get(key)["uid"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deps(key) = Dependencies.deps_of(@manifest, key)
|
|
51
|
+
def rdeps(key) = Dependencies.rdeps_of(@manifest, key)
|
|
52
|
+
def published = Dependencies.published_of(@manifest)
|
|
53
|
+
|
|
54
|
+
def stale(prefix: nil, zone: nil)
|
|
55
|
+
Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_all
|
|
59
|
+
Validator.new(
|
|
60
|
+
reader: self, manifest: @manifest,
|
|
61
|
+
audit_log: @store.audit_log,
|
|
62
|
+
schema_for: ->(name) { @store.schema_for(name) }
|
|
63
|
+
).call
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
6
|
+
class Staleness
|
|
7
|
+
def initialize(manifest:)
|
|
8
|
+
@manifest = manifest
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(prefix: nil, zone: nil)
|
|
12
|
+
out = []
|
|
13
|
+
@manifest.entries.each do |mentry|
|
|
14
|
+
next unless mentry.zone == "derived"
|
|
15
|
+
next if zone && mentry.zone != zone
|
|
16
|
+
|
|
17
|
+
gen = mentry.generator
|
|
18
|
+
next unless gen
|
|
19
|
+
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
20
|
+
|
|
21
|
+
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
|
+
|
|
23
|
+
unless File.exist?(path)
|
|
24
|
+
out << stale_row(mentry, path, "derived entry has never been generated")
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raw = File.binread(path)
|
|
29
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
30
|
+
generated_at = parsed["_meta"].dig("generated", "at")
|
|
31
|
+
unless generated_at
|
|
32
|
+
out << stale_row(mentry, path, "missing generated.at frontmatter")
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
gen_time = begin
|
|
36
|
+
Time.parse(generated_at.to_s)
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
unless gen_time
|
|
41
|
+
out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
offender = newest_source_after(gen, gen_time)
|
|
46
|
+
out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@manifest.entries.each do |mentry|
|
|
50
|
+
next unless mentry.fetch
|
|
51
|
+
next if zone && mentry.zone != zone
|
|
52
|
+
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
53
|
+
|
|
54
|
+
ttl = parse_ttl(mentry.ttl)
|
|
55
|
+
next unless ttl
|
|
56
|
+
|
|
57
|
+
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
58
|
+
|
|
59
|
+
unless File.exist?(path)
|
|
60
|
+
out << intake_stale_row(mentry, path, "never refreshed")
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
|
|
65
|
+
last_str = meta["last_refreshed_at"]
|
|
66
|
+
if last_str.nil?
|
|
67
|
+
out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
last = begin
|
|
72
|
+
Time.parse(last_str.to_s)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
out
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def newest_source_after(gen, gen_time)
|
|
85
|
+
Array(gen["sources"]).each do |src|
|
|
86
|
+
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
87
|
+
@manifest.enumerate(prefix: src).each do |row|
|
|
88
|
+
return src if File.mtime(row[:path]) > gen_time
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
|
|
92
|
+
if File.directory?(abs)
|
|
93
|
+
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
94
|
+
next unless File.file?(fp)
|
|
95
|
+
return src if File.mtime(fp) > gen_time
|
|
96
|
+
end
|
|
97
|
+
elsif File.exist?(abs)
|
|
98
|
+
return src if File.mtime(abs) > gen_time
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_ttl(s)
|
|
106
|
+
return nil unless s
|
|
107
|
+
|
|
108
|
+
m = s.to_s.match(/\A(\d+)([smhd])\z/) or return nil
|
|
109
|
+
n = m[1].to_i
|
|
110
|
+
case m[2]
|
|
111
|
+
when "s" then n
|
|
112
|
+
when "m" then n * 60
|
|
113
|
+
when "h" then n * 3600
|
|
114
|
+
when "d" then n * 86_400
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def intake_stale_row(mentry, path, reason)
|
|
119
|
+
{ "key" => mentry.key, "path" => path, "fetch" => mentry.fetch, "reason" => reason }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def stale_row(mentry, path, reason)
|
|
123
|
+
{
|
|
124
|
+
"key" => mentry.key,
|
|
125
|
+
"path" => path,
|
|
126
|
+
"generator" => mentry.generator,
|
|
127
|
+
"reason" => reason,
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
class Validator
|
|
4
|
+
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
5
|
+
@reader = reader
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@audit_log = audit_log
|
|
8
|
+
@schema_for = schema_for
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
violations = []
|
|
13
|
+
@manifest.enumerate.each do |row|
|
|
14
|
+
begin
|
|
15
|
+
@reader.get(row[:key])
|
|
16
|
+
rescue Textus::Error => e
|
|
17
|
+
violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@manifest.enumerate.each do |row|
|
|
22
|
+
mentry = row[:manifest_entry]
|
|
23
|
+
next unless mentry.schema
|
|
24
|
+
|
|
25
|
+
schema = @schema_for.call(mentry.schema)
|
|
26
|
+
next unless schema
|
|
27
|
+
|
|
28
|
+
env = begin
|
|
29
|
+
@reader.get(row[:key])
|
|
30
|
+
rescue StandardError
|
|
31
|
+
next
|
|
32
|
+
end
|
|
33
|
+
last_writer = @audit_log.last_writer_for(row[:key])
|
|
34
|
+
next if last_writer.nil?
|
|
35
|
+
|
|
36
|
+
env["_meta"].each_key do |field|
|
|
37
|
+
owner = schema.maintained_by(field)
|
|
38
|
+
next if owner.nil?
|
|
39
|
+
next if last_writer == owner
|
|
40
|
+
next if last_writer == "human"
|
|
41
|
+
|
|
42
|
+
violations << {
|
|
43
|
+
"key" => row[:key],
|
|
44
|
+
"code" => "role_authority",
|
|
45
|
+
"field" => field,
|
|
46
|
+
"expected" => owner,
|
|
47
|
+
"last_writer" => last_writer,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
class View
|
|
4
|
+
READ_METHODS = %i[get list where schema_envelope deps rdeps published stale validate_all].freeze
|
|
5
|
+
WRITE_METHODS = %i[put delete accept].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(store, writable: false, as: nil)
|
|
8
|
+
raise UsageError.new("writable Store::View requires an as: role") if writable && (as.nil? || as.to_s.empty?)
|
|
9
|
+
|
|
10
|
+
@store = store
|
|
11
|
+
@writable = writable
|
|
12
|
+
@as = as
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
READ_METHODS.each do |m|
|
|
16
|
+
define_method(m) { |*args, **kw| @store.reader.public_send(m, *args, **kw) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
WRITE_METHODS.each do |m|
|
|
20
|
+
define_method(m) do |*args, **kw|
|
|
21
|
+
raise UsageError.new("Store::View is read-only") unless @writable
|
|
22
|
+
|
|
23
|
+
kw[:as] = @as unless kw.key?(:as)
|
|
24
|
+
@store.public_send(m, *args, **kw)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|