textus 0.3.0 → 0.5.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 +79 -1
- data/README.md +22 -18
- data/SPEC.md +49 -35
- data/docs/architecture.md +63 -28
- data/lib/textus/audit_log.rb +46 -11
- data/lib/textus/builder.rb +3 -3
- data/lib/textus/{builtin_fetchers.rb → builtin_actions.rb} +16 -11
- data/lib/textus/cli/accept.rb +13 -0
- data/lib/textus/cli/action.rb +51 -0
- data/lib/textus/cli/build.rb +11 -0
- data/lib/textus/cli/delete.rb +14 -0
- data/lib/textus/cli/deprecated_alias.rb +31 -0
- data/lib/textus/cli/deps.rb +10 -0
- data/lib/textus/cli/doctor.rb +13 -0
- data/lib/textus/cli/extension_group.rb +9 -0
- data/lib/textus/cli/extensions.rb +49 -0
- data/lib/textus/cli/get.rb +10 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/init.rb +12 -0
- data/lib/textus/cli/intro.rb +9 -0
- data/lib/textus/cli/key_group.rb +10 -0
- data/lib/textus/cli/list.rb +12 -0
- data/lib/textus/cli/migrate.rb +41 -0
- data/lib/textus/cli/migrate_keys.rb +19 -0
- data/lib/textus/cli/mv.rb +20 -0
- data/lib/textus/cli/published.rb +9 -0
- data/lib/textus/cli/put.rb +48 -0
- data/lib/textus/cli/rdeps.rb +10 -0
- data/lib/textus/cli/refresh.rb +13 -0
- data/lib/textus/cli/schema.rb +10 -0
- data/lib/textus/cli/schema_diff.rb +15 -0
- data/lib/textus/cli/schema_group.rb +33 -0
- data/lib/textus/cli/schema_init.rb +19 -0
- data/lib/textus/cli/schema_migrate.rb +19 -0
- data/lib/textus/cli/stale.rb +12 -0
- data/lib/textus/cli/uid.rb +15 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli/where.rb +10 -0
- data/lib/textus/cli.rb +65 -347
- data/lib/textus/doctor.rb +103 -32
- data/lib/textus/entry/json.rb +6 -4
- data/lib/textus/entry/markdown.rb +4 -4
- data/lib/textus/entry/text.rb +3 -3
- data/lib/textus/entry/yaml.rb +6 -4
- data/lib/textus/entry.rb +2 -2
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/extension_registry.rb +22 -9
- data/lib/textus/extensions.rb +6 -2
- data/lib/textus/init.rb +6 -5
- data/lib/textus/intro.rb +11 -9
- data/lib/textus/manifest.rb +11 -215
- data/lib/textus/manifest_entry.rb +185 -0
- data/lib/textus/migrate_v2.rb +27 -0
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/proposal.rb +3 -3
- data/lib/textus/refresh.rb +21 -20
- data/lib/textus/schema_tools.rb +8 -8
- data/lib/textus/store/events.rb +31 -0
- data/lib/textus/store/mover.rb +118 -0
- data/lib/textus/store/staleness.rb +142 -0
- data/lib/textus/store/validator.rb +53 -0
- data/lib/textus/store.rb +50 -355
- data/lib/textus/store_view.rb +11 -2
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +39 -1
- metadata +39 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
6
|
+
class Mover
|
|
7
|
+
def initialize(store)
|
|
8
|
+
@store = store
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
|
|
12
|
+
@store.manifest.validate_key!(old_key)
|
|
13
|
+
@store.manifest.validate_key!(new_key)
|
|
14
|
+
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
15
|
+
|
|
16
|
+
old_mentry, old_path, = @store.manifest.resolve(old_key)
|
|
17
|
+
raise UnknownKey.new(old_key) unless File.exist?(old_path)
|
|
18
|
+
|
|
19
|
+
new_mentry, new_path, = @store.manifest.resolve(new_key)
|
|
20
|
+
|
|
21
|
+
if old_mentry.zone != new_mentry.zone
|
|
22
|
+
raise UsageError.new(
|
|
23
|
+
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
24
|
+
"Use put+delete for cross-zone moves.",
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
if old_mentry.format != new_mentry.format
|
|
28
|
+
raise UsageError.new(
|
|
29
|
+
"mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
writers = @store.manifest.zone_writers(old_mentry.zone)
|
|
34
|
+
raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
|
|
35
|
+
|
|
36
|
+
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
|
|
37
|
+
|
|
38
|
+
# Mint uid before the move so the audit row carries it.
|
|
39
|
+
pre_env = @store.get(old_key)
|
|
40
|
+
current_uid = pre_env["uid"]
|
|
41
|
+
etag_before = pre_env["etag"]
|
|
42
|
+
|
|
43
|
+
if dry_run
|
|
44
|
+
return {
|
|
45
|
+
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
46
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
47
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
48
|
+
"uid" => current_uid
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if current_uid.nil?
|
|
53
|
+
# Write the uid in place first so the source file carries it before mv.
|
|
54
|
+
pre_env = @store.put(old_key,
|
|
55
|
+
meta: pre_env["_meta"],
|
|
56
|
+
body: pre_env["body"],
|
|
57
|
+
content: pre_env["content"],
|
|
58
|
+
as: as,
|
|
59
|
+
suppress_events: true)
|
|
60
|
+
current_uid = pre_env["uid"]
|
|
61
|
+
etag_before = pre_env["etag"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
65
|
+
FileUtils.mv(old_path, new_path)
|
|
66
|
+
rewrite_name_for_mv!(new_mentry, new_path, new_key)
|
|
67
|
+
etag_after = Etag.for_file(new_path)
|
|
68
|
+
|
|
69
|
+
@store.audit_log.append(
|
|
70
|
+
role: as, verb: "mv", key: new_key,
|
|
71
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
72
|
+
extras: {
|
|
73
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
74
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
75
|
+
"uid" => current_uid
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
env = @store.get(new_key)
|
|
80
|
+
{
|
|
81
|
+
"protocol" => PROTOCOL, "ok" => true,
|
|
82
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
83
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
84
|
+
"uid" => current_uid,
|
|
85
|
+
"envelope" => env
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# If the moved file carries a `name:` field (markdown) or `_meta.name`
|
|
92
|
+
# (json/yaml), rewrite it to the new basename so enforce_name_match! stays
|
|
93
|
+
# happy on the next read. Only touches the bytes when name actually changes.
|
|
94
|
+
def rewrite_name_for_mv!(mentry, new_path, new_key)
|
|
95
|
+
strategy = Entry.for_format(mentry.format)
|
|
96
|
+
raw = File.binread(new_path)
|
|
97
|
+
parsed = strategy.parse(raw, path: new_path)
|
|
98
|
+
basename = new_key.split(".").last
|
|
99
|
+
|
|
100
|
+
case mentry.format
|
|
101
|
+
when "markdown"
|
|
102
|
+
meta = parsed["_meta"] || {}
|
|
103
|
+
return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
104
|
+
|
|
105
|
+
meta = meta.merge("name" => basename)
|
|
106
|
+
File.binwrite(new_path, strategy.serialize(meta: meta, body: parsed["body"]))
|
|
107
|
+
when "json", "yaml"
|
|
108
|
+
meta = parsed["_meta"]
|
|
109
|
+
return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
110
|
+
|
|
111
|
+
new_meta = meta.merge("name" => basename)
|
|
112
|
+
File.binwrite(new_path, strategy.serialize(meta: new_meta, body: "", content: parsed["content"]))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
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(store)
|
|
8
|
+
@store = store
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(prefix: nil, zone: nil)
|
|
12
|
+
out = []
|
|
13
|
+
@store.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 = path_for_entry(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
|
+
@store.manifest.entries.each do |mentry|
|
|
50
|
+
next unless mentry.action
|
|
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 = path_for_entry(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 path_for_entry(mentry)
|
|
85
|
+
primary_ext = Entry.for_format(mentry.format).extensions.first
|
|
86
|
+
if File.extname(mentry.path) == ""
|
|
87
|
+
File.join(@store.root, "zones", mentry.path + primary_ext)
|
|
88
|
+
else
|
|
89
|
+
File.join(@store.root, "zones", mentry.path)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def newest_source_after(gen, gen_time)
|
|
94
|
+
Array(gen["sources"]).each do |src|
|
|
95
|
+
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
96
|
+
@store.manifest.enumerate(prefix: src).each do |row|
|
|
97
|
+
return src if File.mtime(row[:path]) > gen_time
|
|
98
|
+
end
|
|
99
|
+
else
|
|
100
|
+
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@store.root), src)
|
|
101
|
+
if File.directory?(abs)
|
|
102
|
+
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
103
|
+
next unless File.file?(fp)
|
|
104
|
+
return src if File.mtime(fp) > gen_time
|
|
105
|
+
end
|
|
106
|
+
elsif File.exist?(abs)
|
|
107
|
+
return src if File.mtime(abs) > gen_time
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_ttl(s)
|
|
115
|
+
return nil unless s
|
|
116
|
+
|
|
117
|
+
m = s.to_s.match(/\A(\d+)([smhd])\z/) or return nil
|
|
118
|
+
n = m[1].to_i
|
|
119
|
+
case m[2]
|
|
120
|
+
when "s" then n
|
|
121
|
+
when "m" then n * 60
|
|
122
|
+
when "h" then n * 3600
|
|
123
|
+
when "d" then n * 86_400
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def intake_stale_row(mentry, path, reason)
|
|
128
|
+
{ "key" => mentry.key, "path" => path, "action" => mentry.action, "reason" => reason }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def stale_row(mentry, path, reason)
|
|
132
|
+
{
|
|
133
|
+
"key" => mentry.key,
|
|
134
|
+
"path" => path,
|
|
135
|
+
"generator" => mentry.generator,
|
|
136
|
+
"reason" => reason,
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
class Validator
|
|
4
|
+
def initialize(store)
|
|
5
|
+
@store = store
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
violations = []
|
|
10
|
+
@store.manifest.enumerate.each do |row|
|
|
11
|
+
begin
|
|
12
|
+
@store.get(row[:key])
|
|
13
|
+
rescue Textus::Error => e
|
|
14
|
+
violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@store.manifest.enumerate.each do |row|
|
|
19
|
+
mentry = row[:manifest_entry]
|
|
20
|
+
next unless mentry.schema
|
|
21
|
+
|
|
22
|
+
schema = @store.schema_for(mentry.schema)
|
|
23
|
+
next unless schema
|
|
24
|
+
|
|
25
|
+
env = begin
|
|
26
|
+
@store.get(row[:key])
|
|
27
|
+
rescue StandardError
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
last_writer = @store.audit_log.last_writer_for(row[:key])
|
|
31
|
+
next if last_writer.nil?
|
|
32
|
+
|
|
33
|
+
env["_meta"].each_key do |field|
|
|
34
|
+
owner = schema.maintained_by(field)
|
|
35
|
+
next if owner.nil?
|
|
36
|
+
next if last_writer == owner
|
|
37
|
+
next if last_writer == "human"
|
|
38
|
+
|
|
39
|
+
violations << {
|
|
40
|
+
"key" => row[:key],
|
|
41
|
+
"code" => "role_authority",
|
|
42
|
+
"field" => field,
|
|
43
|
+
"expected" => owner,
|
|
44
|
+
"last_writer" => last_writer,
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|