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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Doctor
|
|
6
|
+
class Check
|
|
7
|
+
class Sentinels < Check
|
|
8
|
+
def call
|
|
9
|
+
out = []
|
|
10
|
+
dir = File.join(store.root, "sentinels")
|
|
11
|
+
return out unless File.directory?(dir)
|
|
12
|
+
|
|
13
|
+
Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
|
|
14
|
+
begin
|
|
15
|
+
data = JSON.parse(File.read(sp))
|
|
16
|
+
rescue JSON::ParserError => e
|
|
17
|
+
out << {
|
|
18
|
+
"code" => "sentinel.parse_error",
|
|
19
|
+
"level" => "warning",
|
|
20
|
+
"subject" => sp,
|
|
21
|
+
"message" => "sentinel is not valid JSON: #{e.message}",
|
|
22
|
+
"fix" => "delete #{sp} and re-run 'textus build' to regenerate",
|
|
23
|
+
}
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
target = data["target"]
|
|
28
|
+
recorded_sha = data["sha256"]
|
|
29
|
+
|
|
30
|
+
if target.nil? || !File.exist?(target)
|
|
31
|
+
out << {
|
|
32
|
+
"code" => "sentinel.orphan",
|
|
33
|
+
"level" => "warning",
|
|
34
|
+
"subject" => sp,
|
|
35
|
+
"message" => "sentinel target #{target.inspect} no longer exists",
|
|
36
|
+
"fix" => "delete #{sp} (the published file is gone) or restore the target",
|
|
37
|
+
}
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
current_sha = Digest::SHA256.hexdigest(File.binread(target))
|
|
42
|
+
next if recorded_sha.nil? || current_sha == recorded_sha
|
|
43
|
+
|
|
44
|
+
out << {
|
|
45
|
+
"code" => "sentinel.drift",
|
|
46
|
+
"level" => "warning",
|
|
47
|
+
"subject" => target,
|
|
48
|
+
"message" => "published file at #{target} was modified out-of-band",
|
|
49
|
+
"fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
out
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
class Templates < Check
|
|
5
|
+
def call
|
|
6
|
+
out = []
|
|
7
|
+
store.manifest.entries.each do |entry|
|
|
8
|
+
next if entry.template.nil?
|
|
9
|
+
|
|
10
|
+
tp = File.join(store.root, "templates", entry.template)
|
|
11
|
+
next if File.exist?(tp)
|
|
12
|
+
|
|
13
|
+
out << {
|
|
14
|
+
"code" => "template.missing",
|
|
15
|
+
"level" => "error",
|
|
16
|
+
"subject" => entry.key,
|
|
17
|
+
"message" => "template '#{entry.template}' not found at #{tp}",
|
|
18
|
+
"fix" => "create the file at #{tp} or update the entry's template: field",
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
out
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
class UnownedSchemaFields < Check
|
|
5
|
+
def call
|
|
6
|
+
out = []
|
|
7
|
+
dir = File.join(store.root, "schemas")
|
|
8
|
+
return out unless File.directory?(dir)
|
|
9
|
+
|
|
10
|
+
Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
|
|
11
|
+
schema = begin
|
|
12
|
+
Schema.load(sp)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
next
|
|
15
|
+
end
|
|
16
|
+
unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
|
|
17
|
+
acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
|
|
18
|
+
end
|
|
19
|
+
next if unowned.empty?
|
|
20
|
+
|
|
21
|
+
out << {
|
|
22
|
+
"code" => "schema.unowned_fields",
|
|
23
|
+
"level" => "info",
|
|
24
|
+
"subject" => schema.name || File.basename(sp, ".yaml"),
|
|
25
|
+
"message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
|
|
26
|
+
"fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
out
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
# Abstract base for a single doctor check. Each concrete check inspects
|
|
4
|
+
# one slice of store health and returns an array of issue hashes:
|
|
5
|
+
# { "code" => String, "level" => "error"|"warning"|"info",
|
|
6
|
+
# "subject" => String, "message" => String, "fix" => String (optional) }
|
|
7
|
+
class Check
|
|
8
|
+
# Snake-case name used in --checks flag and ALL_CHECKS list. Default
|
|
9
|
+
# derives from the class name; override if the SPEC name diverges.
|
|
10
|
+
def self.name_key
|
|
11
|
+
@name_key ||= name.split("::").last
|
|
12
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
13
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
14
|
+
.downcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(store)
|
|
18
|
+
@store = store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
raise NotImplementedError.new("#{self.class.name}#call not implemented")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
attr_reader :store
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
|
-
require "digest"
|
|
2
|
-
require "json"
|
|
3
1
|
require "timeout"
|
|
4
2
|
|
|
5
3
|
module Textus
|
|
6
4
|
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
7
5
|
# with an `issues` array and a summary. Each issue is a Hash with
|
|
8
6
|
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
9
|
-
module Doctor
|
|
7
|
+
module Doctor
|
|
10
8
|
LEVELS = %w[error warning info].freeze
|
|
11
9
|
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
12
10
|
|
|
11
|
+
CHECKS = [
|
|
12
|
+
Check::ManifestFiles,
|
|
13
|
+
Check::Schemas,
|
|
14
|
+
Check::Templates,
|
|
15
|
+
Check::Hooks,
|
|
16
|
+
Check::IllegalKeys,
|
|
17
|
+
Check::Sentinels,
|
|
18
|
+
Check::AuditLog,
|
|
19
|
+
Check::UnownedSchemaFields,
|
|
20
|
+
Check::SchemaViolations,
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
ALL_CHECKS = CHECKS.map(&:name_key).freeze
|
|
24
|
+
|
|
13
25
|
module_function
|
|
14
26
|
|
|
15
|
-
def run(store)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
def run(store, checks: nil)
|
|
28
|
+
selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
29
|
+
unknown = selected_keys - ALL_CHECKS
|
|
30
|
+
unless unknown.empty?
|
|
31
|
+
raise UsageError.new(
|
|
32
|
+
"unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
|
|
37
|
+
issues = selected.flat_map { |c| c.new(store).call }
|
|
25
38
|
issues.concat(run_registered_checks(store))
|
|
26
39
|
|
|
27
40
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
@@ -33,236 +46,11 @@ module Textus
|
|
|
33
46
|
}
|
|
34
47
|
end
|
|
35
48
|
|
|
36
|
-
# --- Checks -----------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
def check_manifest_files(store)
|
|
39
|
-
out = []
|
|
40
|
-
store.manifest.entries.each do |entry|
|
|
41
|
-
next if entry.nested
|
|
42
|
-
|
|
43
|
-
path = leaf_path_for(store, entry)
|
|
44
|
-
next if File.exist?(path)
|
|
45
|
-
|
|
46
|
-
out << {
|
|
47
|
-
"code" => "manifest.missing_file",
|
|
48
|
-
"level" => "info",
|
|
49
|
-
"subject" => entry.key,
|
|
50
|
-
"message" => "declared entry has no file on disk at #{path}",
|
|
51
|
-
"fix" => "create the entry with 'textus put #{entry.key} --stdin --as=<role>' " \
|
|
52
|
-
"(or leave empty if not yet authored)",
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
out
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def check_schemas(store)
|
|
59
|
-
out = []
|
|
60
|
-
store.manifest.entries.each do |entry|
|
|
61
|
-
next if entry.schema.nil?
|
|
62
|
-
|
|
63
|
-
sp = File.join(store.root, "schemas", "#{entry.schema}.yaml")
|
|
64
|
-
next if File.exist?(sp)
|
|
65
|
-
|
|
66
|
-
out << {
|
|
67
|
-
"code" => "schema.missing",
|
|
68
|
-
"level" => "error",
|
|
69
|
-
"subject" => entry.key,
|
|
70
|
-
"message" => "schema '#{entry.schema}' not found at #{sp}",
|
|
71
|
-
"fix" => "create the schema file or run 'textus schema-init #{entry.schema} --from=<key>'",
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
out
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def check_templates(store)
|
|
78
|
-
out = []
|
|
79
|
-
store.manifest.entries.each do |entry|
|
|
80
|
-
next if entry.template.nil?
|
|
81
|
-
|
|
82
|
-
tp = File.join(store.root, "templates", entry.template)
|
|
83
|
-
next if File.exist?(tp)
|
|
84
|
-
|
|
85
|
-
out << {
|
|
86
|
-
"code" => "template.missing",
|
|
87
|
-
"level" => "error",
|
|
88
|
-
"subject" => entry.key,
|
|
89
|
-
"message" => "template '#{entry.template}' not found at #{tp}",
|
|
90
|
-
"fix" => "create the file at #{tp} or update the entry's template: field",
|
|
91
|
-
}
|
|
92
|
-
end
|
|
93
|
-
out
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def check_extensions(store)
|
|
97
|
-
out = []
|
|
98
|
-
dir = File.join(store.root, "extensions")
|
|
99
|
-
return out unless File.directory?(dir)
|
|
100
|
-
|
|
101
|
-
Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
102
|
-
registry = ExtensionRegistry.new
|
|
103
|
-
Textus.with_registry(registry) do
|
|
104
|
-
load(f)
|
|
105
|
-
end
|
|
106
|
-
rescue StandardError, ScriptError => e
|
|
107
|
-
out << {
|
|
108
|
-
"code" => "extension.load_failed",
|
|
109
|
-
"level" => "error",
|
|
110
|
-
"subject" => File.basename(f),
|
|
111
|
-
"message" => "#{e.class}: #{e.message}",
|
|
112
|
-
"fix" => "open #{f} and fix the syntax/load error",
|
|
113
|
-
}
|
|
114
|
-
end
|
|
115
|
-
out
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def check_illegal_keys(store)
|
|
119
|
-
out = []
|
|
120
|
-
store.manifest.entries.each do |entry|
|
|
121
|
-
next unless entry.nested
|
|
122
|
-
|
|
123
|
-
base = File.join(store.root, "zones", entry.path)
|
|
124
|
-
next unless File.directory?(base)
|
|
125
|
-
|
|
126
|
-
walk_nested(base) do |abs_path, is_dir|
|
|
127
|
-
basename = File.basename(abs_path)
|
|
128
|
-
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
129
|
-
next if stem.match?(Manifest::KEY_SEGMENT)
|
|
130
|
-
|
|
131
|
-
proposed = Textus::MigrateKeys.normalize(stem)
|
|
132
|
-
out << {
|
|
133
|
-
"code" => "key.illegal",
|
|
134
|
-
"level" => "error",
|
|
135
|
-
"subject" => abs_path,
|
|
136
|
-
"path" => abs_path,
|
|
137
|
-
"proposed_key" => proposed,
|
|
138
|
-
"message" => "illegal key segment '#{stem}' at #{abs_path}",
|
|
139
|
-
"fix" => "run 'textus migrate-keys --dry-run' then '--write' to rename to '#{proposed}'",
|
|
140
|
-
}
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
out
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def check_sentinels(store)
|
|
147
|
-
out = []
|
|
148
|
-
dir = File.join(store.root, "sentinels")
|
|
149
|
-
return out unless File.directory?(dir)
|
|
150
|
-
|
|
151
|
-
Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
|
|
152
|
-
begin
|
|
153
|
-
data = JSON.parse(File.read(sp))
|
|
154
|
-
rescue JSON::ParserError => e
|
|
155
|
-
out << {
|
|
156
|
-
"code" => "sentinel.parse_error",
|
|
157
|
-
"level" => "warning",
|
|
158
|
-
"subject" => sp,
|
|
159
|
-
"message" => "sentinel is not valid JSON: #{e.message}",
|
|
160
|
-
"fix" => "delete #{sp} and re-run 'textus build' to regenerate",
|
|
161
|
-
}
|
|
162
|
-
next
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
target = data["target"]
|
|
166
|
-
recorded_sha = data["sha256"]
|
|
167
|
-
|
|
168
|
-
if target.nil? || !File.exist?(target)
|
|
169
|
-
out << {
|
|
170
|
-
"code" => "sentinel.orphan",
|
|
171
|
-
"level" => "warning",
|
|
172
|
-
"subject" => sp,
|
|
173
|
-
"message" => "sentinel target #{target.inspect} no longer exists",
|
|
174
|
-
"fix" => "delete #{sp} (the published file is gone) or restore the target",
|
|
175
|
-
}
|
|
176
|
-
next
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
current_sha = Digest::SHA256.hexdigest(File.binread(target))
|
|
180
|
-
next if recorded_sha.nil? || current_sha == recorded_sha
|
|
181
|
-
|
|
182
|
-
out << {
|
|
183
|
-
"code" => "sentinel.drift",
|
|
184
|
-
"level" => "warning",
|
|
185
|
-
"subject" => target,
|
|
186
|
-
"message" => "published file at #{target} was modified out-of-band",
|
|
187
|
-
"fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
|
|
188
|
-
}
|
|
189
|
-
end
|
|
190
|
-
out
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def check_audit_log(store)
|
|
194
|
-
out = []
|
|
195
|
-
path = File.join(store.root, "audit.log")
|
|
196
|
-
return out unless File.exist?(path)
|
|
197
|
-
|
|
198
|
-
File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
|
|
199
|
-
stripped = line.chomp
|
|
200
|
-
next if stripped.empty?
|
|
201
|
-
|
|
202
|
-
# Audit log is TSV, not NDJSON. Treat as malformed if it doesn't have
|
|
203
|
-
# at least 6 tab-separated fields (timestamp, role, verb, key, etag_before, etag_after).
|
|
204
|
-
fields = stripped.split("\t")
|
|
205
|
-
if fields.length < 6
|
|
206
|
-
out << {
|
|
207
|
-
"code" => "audit.parse_error",
|
|
208
|
-
"level" => "warning",
|
|
209
|
-
"subject" => "#{path}:#{lineno}",
|
|
210
|
-
"message" => "audit log line #{lineno} has #{fields.length} fields (expected >=6)",
|
|
211
|
-
"fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
|
|
212
|
-
}
|
|
213
|
-
next
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
extras = fields[6]
|
|
217
|
-
next if extras.nil? || extras.empty?
|
|
218
|
-
|
|
219
|
-
begin
|
|
220
|
-
JSON.parse(extras)
|
|
221
|
-
rescue JSON::ParserError => e
|
|
222
|
-
out << {
|
|
223
|
-
"code" => "audit.parse_error",
|
|
224
|
-
"level" => "warning",
|
|
225
|
-
"subject" => "#{path}:#{lineno}",
|
|
226
|
-
"message" => "audit log line #{lineno} extras JSON malformed: #{e.message}",
|
|
227
|
-
"fix" => "inspect #{path} at line #{lineno} and fix the JSON in the last column",
|
|
228
|
-
}
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
out
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def check_unowned_schema_fields(store)
|
|
235
|
-
out = []
|
|
236
|
-
dir = File.join(store.root, "schemas")
|
|
237
|
-
return out unless File.directory?(dir)
|
|
238
|
-
|
|
239
|
-
Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
|
|
240
|
-
schema = begin
|
|
241
|
-
Schema.load(sp)
|
|
242
|
-
rescue StandardError
|
|
243
|
-
next
|
|
244
|
-
end
|
|
245
|
-
unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
|
|
246
|
-
acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
|
|
247
|
-
end
|
|
248
|
-
next if unowned.empty?
|
|
249
|
-
|
|
250
|
-
out << {
|
|
251
|
-
"code" => "schema.unowned_fields",
|
|
252
|
-
"level" => "info",
|
|
253
|
-
"subject" => schema.name || File.basename(sp, ".yaml"),
|
|
254
|
-
"message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
|
|
255
|
-
"fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
|
|
256
|
-
}
|
|
257
|
-
end
|
|
258
|
-
out
|
|
259
|
-
end
|
|
260
|
-
|
|
261
49
|
def run_registered_checks(store)
|
|
262
50
|
out = []
|
|
263
|
-
view =
|
|
264
|
-
store.registry.
|
|
265
|
-
callable = store.registry.
|
|
51
|
+
view = Store::View.new(store)
|
|
52
|
+
store.registry.rpc_names(:check).each do |name|
|
|
53
|
+
callable = store.registry.rpc_callable(:check, name)
|
|
266
54
|
begin
|
|
267
55
|
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
|
|
268
56
|
if result.is_a?(Array)
|
|
@@ -294,28 +82,5 @@ module Textus
|
|
|
294
82
|
"fix" => fix,
|
|
295
83
|
}
|
|
296
84
|
end
|
|
297
|
-
|
|
298
|
-
# --- Helpers ----------------------------------------------------------
|
|
299
|
-
|
|
300
|
-
def leaf_path_for(store, entry)
|
|
301
|
-
primary_ext = Entry.for_format(entry.format).extensions.first
|
|
302
|
-
if File.extname(entry.path) == ""
|
|
303
|
-
File.join(store.root, "zones", entry.path + primary_ext)
|
|
304
|
-
else
|
|
305
|
-
File.join(store.root, "zones", entry.path)
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def walk_nested(root, &block)
|
|
310
|
-
Dir.each_child(root) do |name|
|
|
311
|
-
abs = File.join(root, name)
|
|
312
|
-
if File.directory?(abs)
|
|
313
|
-
walk_nested(abs, &block)
|
|
314
|
-
yield abs, true
|
|
315
|
-
else
|
|
316
|
-
yield abs, false
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
85
|
end
|
|
321
86
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Entry
|
|
3
|
+
# Abstract base for entry format strategies. Each concrete strategy
|
|
4
|
+
# owns parsing, serialization, file-extension claims, and schema
|
|
5
|
+
# validation for entries declared with its format.
|
|
6
|
+
class Base
|
|
7
|
+
def self.parse(_raw, path: nil)
|
|
8
|
+
_ = path
|
|
9
|
+
raise NotImplementedError.new("#{name}.parse not implemented")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.serialize(meta: {}, body: "", content: nil)
|
|
13
|
+
_ = meta
|
|
14
|
+
_ = body
|
|
15
|
+
_ = content
|
|
16
|
+
raise NotImplementedError.new("#{name}.serialize not implemented")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.extensions
|
|
20
|
+
raise NotImplementedError.new("#{name}.extensions not implemented")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Default: validate the meta hash. Overridden by formats that put the
|
|
24
|
+
# validatable payload elsewhere (json/yaml put it under "content").
|
|
25
|
+
def self.validate_against(schema, parsed)
|
|
26
|
+
schema.validate!(parsed["_meta"] || {})
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -3,7 +3,7 @@ require "json"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Entry
|
|
5
5
|
# JSON entry storage. Top-level must be an object so we can carry _meta.
|
|
6
|
-
|
|
6
|
+
class Json < Base
|
|
7
7
|
def self.parse(raw, path: nil)
|
|
8
8
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
9
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
@@ -17,13 +17,15 @@ module Textus
|
|
|
17
17
|
|
|
18
18
|
meta = parsed["_meta"]
|
|
19
19
|
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
-
|
|
20
|
+
content_without_meta = parsed.except("_meta")
|
|
21
|
+
{ "_meta" => fm, "body" => raw, "content" => content_without_meta }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def self.serialize(
|
|
24
|
-
_ = frontmatter
|
|
24
|
+
def self.serialize(meta:, body:, content: nil)
|
|
25
25
|
if content.is_a?(Hash)
|
|
26
|
-
|
|
26
|
+
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
|
+
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
|
+
out = ::JSON.pretty_generate(on_disk)
|
|
27
29
|
out += "\n" unless out.end_with?("\n")
|
|
28
30
|
out
|
|
29
31
|
elsif body && !body.to_s.empty?
|
|
@@ -35,6 +37,10 @@ module Textus
|
|
|
35
37
|
end
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
def self.validate_against(schema, parsed)
|
|
41
|
+
schema.validate!(parsed["content"] || {})
|
|
42
|
+
end
|
|
43
|
+
|
|
38
44
|
def self.extensions = [".json"]
|
|
39
45
|
end
|
|
40
46
|
end
|
|
@@ -3,11 +3,11 @@ require "yaml"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Entry
|
|
5
5
|
# Markdown with YAML frontmatter. Original Entry implementation.
|
|
6
|
-
|
|
6
|
+
class Markdown < Base
|
|
7
7
|
def self.parse(raw, path: nil)
|
|
8
8
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
9
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
10
|
-
return { "
|
|
10
|
+
return { "_meta" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
|
|
11
11
|
|
|
12
12
|
lines = raw.split(/\r?\n/, -1)
|
|
13
13
|
close_idx = lines[1..].index("---")
|
|
@@ -22,12 +22,12 @@ module Textus
|
|
|
22
22
|
raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
|
|
23
23
|
end
|
|
24
24
|
fm = {} unless fm.is_a?(Hash)
|
|
25
|
-
{ "
|
|
25
|
+
{ "_meta" => fm, "body" => body, "content" => nil }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def self.serialize(
|
|
28
|
+
def self.serialize(meta:, body:, content: nil)
|
|
29
29
|
_ = content # markdown ignores content
|
|
30
|
-
fm_yaml =
|
|
30
|
+
fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
|
|
31
31
|
body = body.to_s
|
|
32
32
|
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
33
33
|
"---\n#{fm_yaml}---\n#{body}"
|
data/lib/textus/entry/text.rb
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Entry
|
|
3
3
|
# Plain-text entry storage. No frontmatter or structured content.
|
|
4
|
-
|
|
4
|
+
class Text < Base
|
|
5
5
|
def self.parse(raw, path: nil)
|
|
6
6
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
7
7
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
8
8
|
|
|
9
|
-
{ "
|
|
9
|
+
{ "_meta" => {}, "body" => raw, "content" => nil }
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def self.serialize(
|
|
13
|
-
_ =
|
|
12
|
+
def self.serialize(meta:, body:, content: nil)
|
|
13
|
+
_ = meta
|
|
14
14
|
_ = content
|
|
15
15
|
b = body.to_s
|
|
16
16
|
b += "\n" unless b.empty? || b.end_with?("\n")
|
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -3,7 +3,7 @@ require "yaml"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Entry
|
|
5
5
|
# YAML entry storage. Top-level must be a mapping so we can carry _meta.
|
|
6
|
-
|
|
6
|
+
class Yaml < Base
|
|
7
7
|
def self.parse(raw, path: nil)
|
|
8
8
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
9
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
@@ -17,13 +17,15 @@ module Textus
|
|
|
17
17
|
|
|
18
18
|
meta = parsed["_meta"]
|
|
19
19
|
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
-
|
|
20
|
+
content_without_meta = parsed.except("_meta")
|
|
21
|
+
{ "_meta" => fm, "body" => raw, "content" => content_without_meta }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def self.serialize(
|
|
24
|
-
_ = frontmatter
|
|
24
|
+
def self.serialize(meta:, body:, content: nil)
|
|
25
25
|
if content.is_a?(Hash)
|
|
26
|
-
|
|
26
|
+
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
|
+
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
|
+
::YAML.dump(on_disk).sub(/\A---\n/, "")
|
|
27
29
|
elsif body && !body.to_s.empty?
|
|
28
30
|
b = body.to_s
|
|
29
31
|
b += "\n" unless b.end_with?("\n")
|
|
@@ -33,6 +35,10 @@ module Textus
|
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
def self.validate_against(schema, parsed)
|
|
39
|
+
schema.validate!(parsed["content"] || {})
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
def self.extensions = [".yaml", ".yml"]
|
|
37
43
|
end
|
|
38
44
|
end
|
data/lib/textus/entry.rb
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
require_relative "entry/markdown"
|
|
2
|
-
require_relative "entry/json"
|
|
3
|
-
require_relative "entry/yaml"
|
|
4
|
-
require_relative "entry/text"
|
|
5
|
-
|
|
6
1
|
module Textus
|
|
7
2
|
# Public entry-format dispatcher.
|
|
8
3
|
module Entry
|
|
@@ -23,8 +18,8 @@ module Textus
|
|
|
23
18
|
for_format(format).parse(raw, path: path)
|
|
24
19
|
end
|
|
25
20
|
|
|
26
|
-
def self.serialize(
|
|
27
|
-
for_format(format).serialize(
|
|
21
|
+
def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
|
|
22
|
+
for_format(format).serialize(meta: meta, body: body, content: content)
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|