textus 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -0
- data/README.md +7 -4
- data/SPEC.md +77 -5
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/application/policy/promotion.rb +6 -11
- data/lib/textus/application/reads/audit.rb +40 -15
- data/lib/textus/application/reads/pulse.rb +63 -0
- data/lib/textus/application/reads/validator.rb +3 -1
- data/lib/textus/application/writes/accept.rb +5 -1
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/materializer.rb +1 -1
- data/lib/textus/application/writes/publish.rb +25 -106
- data/lib/textus/application/writes/reject.rb +5 -1
- data/lib/textus/{intro.rb → boot.rb} +71 -25
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/verb/audit.rb +2 -0
- data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +2 -3
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/manifest/entry/base.rb +43 -6
- data/lib/textus/manifest/entry/derived.rb +40 -4
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +9 -51
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/resolver.rb +8 -5
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +63 -5
- data/lib/textus/manifest.rb +31 -1
- data/lib/textus/operations.rb +8 -1
- data/lib/textus/schema/tools.rb +8 -1
- data/lib/textus/store.rb +5 -1
- data/lib/textus/version.rb +1 -1
- metadata +9 -10
- data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
- data/lib/textus/application/tools/migrate_keys.rb +0 -191
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
- data/lib/textus/cli/verb/key_normalize.rb +0 -48
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/manifest/resolution.rb +0 -5
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Resolver
|
|
4
|
+
Resolution = Data.define(:entry, :path, :remaining)
|
|
5
|
+
|
|
4
6
|
def initialize(manifest)
|
|
5
7
|
@manifest = manifest
|
|
6
8
|
end
|
|
@@ -40,7 +42,7 @@ module Textus
|
|
|
40
42
|
# entry with nested: true in the raw YAML — e.g. Intake entries covering
|
|
41
43
|
# a directory of leaf files).
|
|
42
44
|
def nested_entry?(entry)
|
|
43
|
-
entry.
|
|
45
|
+
entry.nested?
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def build_resolution(entry, remaining, key)
|
|
@@ -49,7 +51,7 @@ module Textus
|
|
|
49
51
|
else
|
|
50
52
|
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
|
|
51
53
|
|
|
52
|
-
index_fn = entry.
|
|
54
|
+
index_fn = entry.index_filename
|
|
53
55
|
path = if index_fn
|
|
54
56
|
File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
|
|
55
57
|
else
|
|
@@ -69,21 +71,22 @@ module Textus
|
|
|
69
71
|
base = File.join(@manifest.root, "zones", entry.path)
|
|
70
72
|
return [] unless File.directory?(base)
|
|
71
73
|
|
|
72
|
-
entry_index_filename = entry.
|
|
74
|
+
entry_index_filename = entry.index_filename
|
|
73
75
|
glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
|
|
74
76
|
Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
def nested_row_for(entry, base, path)
|
|
78
80
|
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
79
|
-
entry_if = entry.
|
|
81
|
+
entry_if = entry.index_filename
|
|
80
82
|
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
81
83
|
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
82
84
|
return nil if segs.empty?
|
|
83
85
|
|
|
84
86
|
illegal = segs.find { |s| !valid_segment?(s) }
|
|
85
87
|
if illegal
|
|
86
|
-
warn("textus: skipping illegal key segment '#{illegal}' at #{path} —
|
|
88
|
+
warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
|
|
89
|
+
"rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
|
|
87
90
|
return nil
|
|
88
91
|
end
|
|
89
92
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
module RoleKinds
|
|
4
|
+
DEFAULT_MAPPING = {
|
|
5
|
+
"human" => :accept_authority,
|
|
6
|
+
"agent" => :proposer,
|
|
7
|
+
"builder" => :generator,
|
|
8
|
+
"runner" => :runner,
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
# Returns { role_name => kind_symbol }. When `roles:` is declared we use
|
|
12
|
+
# exactly that; defaults are *not* layered in (declaring roles is an opt-in
|
|
13
|
+
# to a fully user-defined vocabulary).
|
|
14
|
+
def self.resolve(raw_roles)
|
|
15
|
+
return DEFAULT_MAPPING if raw_roles.nil?
|
|
16
|
+
|
|
17
|
+
raw_roles.to_h { |r| [r["name"], r["kind"].to_sym] }.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
module Schema
|
|
4
|
-
ROOT_KEYS = %w[version zones entries rules].freeze
|
|
4
|
+
ROOT_KEYS = %w[version roles zones entries rules audit].freeze
|
|
5
|
+
ROLE_KEYS = %w[name kind].freeze
|
|
6
|
+
ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
|
|
5
7
|
ZONE_KEYS = %w[name write_policy read_policy].freeze
|
|
6
8
|
ENTRY_KEYS = %w[
|
|
7
9
|
key path zone kind schema owner nested format
|
|
8
10
|
compute template publish_to publish_each
|
|
9
|
-
intake events
|
|
11
|
+
intake events inject_boot index_filename
|
|
10
12
|
].freeze
|
|
11
13
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
12
14
|
INTAKE_KEYS = %w[handler config].freeze
|
|
@@ -14,21 +16,37 @@ module Textus
|
|
|
14
16
|
REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
|
|
15
17
|
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
16
18
|
PROMOTION_KEYS = %w[requires].freeze
|
|
19
|
+
AUDIT_KEYS = %w[max_size keep].freeze
|
|
17
20
|
|
|
18
21
|
def self.validate!(raw)
|
|
19
22
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
20
23
|
|
|
21
24
|
walk(raw, ROOT_KEYS, "$")
|
|
22
|
-
|
|
25
|
+
validate_roles!(raw["roles"])
|
|
26
|
+
validate_zones!(raw["zones"])
|
|
27
|
+
validate_entries!(raw["entries"])
|
|
28
|
+
validate_rules!(raw["rules"])
|
|
29
|
+
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
30
|
+
validate_zone_writers_declared!(raw)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_zones!(zones)
|
|
34
|
+
Array(zones).each_with_index do |z, i|
|
|
23
35
|
walk(z, ZONE_KEYS, "$.zones[#{i}]")
|
|
24
36
|
end
|
|
25
|
-
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.validate_entries!(entries)
|
|
40
|
+
Array(entries).each_with_index do |e, i|
|
|
26
41
|
path = "$.entries[#{i}]"
|
|
27
42
|
walk(e, ENTRY_KEYS, path)
|
|
28
43
|
walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
|
|
29
44
|
walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
|
|
30
45
|
end
|
|
31
|
-
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.validate_rules!(rules)
|
|
49
|
+
Array(rules).each_with_index do |r, i|
|
|
32
50
|
path = "$.rules[#{i}]"
|
|
33
51
|
walk(r, RULE_KEYS, path)
|
|
34
52
|
if r["refresh"].is_a?(Hash)
|
|
@@ -39,6 +57,46 @@ module Textus
|
|
|
39
57
|
end
|
|
40
58
|
end
|
|
41
59
|
|
|
60
|
+
def self.validate_zone_writers_declared!(raw)
|
|
61
|
+
return if raw["roles"].nil? # default mapping is permissive
|
|
62
|
+
|
|
63
|
+
declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
|
|
64
|
+
Array(raw["zones"]).each do |z|
|
|
65
|
+
Array(z["write_policy"]).each_with_index do |w, j|
|
|
66
|
+
next if declared.include?(w)
|
|
67
|
+
|
|
68
|
+
raise BadManifest.new(
|
|
69
|
+
"zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
|
|
70
|
+
"(declared roles: #{declared.to_a.join(", ")})",
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.validate_roles!(roles)
|
|
77
|
+
return if roles.nil?
|
|
78
|
+
raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
accept_authority_count = 0
|
|
81
|
+
roles.each_with_index do |r, i|
|
|
82
|
+
path = "$.roles[#{i}]"
|
|
83
|
+
walk(r, ROLE_KEYS, path)
|
|
84
|
+
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
85
|
+
kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
|
|
86
|
+
unless ROLE_KINDS.include?(kind)
|
|
87
|
+
raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
accept_authority_count += 1 if kind == "accept_authority"
|
|
91
|
+
end
|
|
92
|
+
return unless accept_authority_count > 1
|
|
93
|
+
|
|
94
|
+
raise BadManifest.new(
|
|
95
|
+
"manifest declares #{accept_authority_count} accept_authority roles; " \
|
|
96
|
+
"at most one accept_authority role is allowed",
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
42
100
|
def self.validate_fetch_timeout!(value, path)
|
|
43
101
|
return if value.nil?
|
|
44
102
|
return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
require_relative "manifest/schema"
|
|
3
|
-
require_relative "manifest/resolution"
|
|
4
3
|
require_relative "manifest/resolver"
|
|
4
|
+
require_relative "manifest/role_kinds"
|
|
5
5
|
|
|
6
6
|
module Textus
|
|
7
7
|
class Manifest
|
|
@@ -30,6 +30,36 @@ module Textus
|
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
34
|
+
|
|
35
|
+
def audit_config
|
|
36
|
+
raw = @raw["audit"] || {}
|
|
37
|
+
{
|
|
38
|
+
max_size: raw["max_size"] || AUDIT_DEFAULTS[:max_size],
|
|
39
|
+
keep: raw["keep"] || AUDIT_DEFAULTS[:keep],
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def role_mapping
|
|
44
|
+
@role_mapping ||= RoleKinds.resolve(@raw["roles"])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def role_kind(name)
|
|
48
|
+
role_mapping[name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def roles_with_kind(kind)
|
|
52
|
+
role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def zone_kinds(zone_name)
|
|
56
|
+
@zone_kinds_cache ||= {}
|
|
57
|
+
@zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
|
|
58
|
+
k = role_kind(w)
|
|
59
|
+
acc << k if k
|
|
60
|
+
end.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
33
63
|
def self.parse(yaml_text, root: ".")
|
|
34
64
|
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
35
65
|
check_version!(raw, "<string>")
|
data/lib/textus/operations.rb
CHANGED
|
@@ -115,11 +115,18 @@ module Textus
|
|
|
115
115
|
def rdeps(...) = Application::Reads::Rdeps.new(manifest: @manifest).call(...)
|
|
116
116
|
def published(...) = Application::Reads::Published.new(manifest: @manifest).call(...)
|
|
117
117
|
def stale(...) = Application::Reads::Stale.new(manifest: @manifest).call(...)
|
|
118
|
-
def audit(...) = Application::Reads::Audit.new(manifest: @manifest, root: @root).call(...)
|
|
118
|
+
def audit(...) = Application::Reads::Audit.new(manifest: @manifest, root: @root, audit_log: @audit_log).call(...)
|
|
119
119
|
def blame(...) = Application::Reads::Blame.new(manifest: @manifest, root: @root).call(...)
|
|
120
120
|
def policy_explain(...) = Application::Reads::PolicyExplain.new(manifest: @manifest).call(...)
|
|
121
121
|
def freshness(...) = Application::Reads::Freshness.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call(...)
|
|
122
122
|
|
|
123
|
+
def pulse(...)
|
|
124
|
+
Application::Reads::Pulse.new(
|
|
125
|
+
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
126
|
+
audit_log: @audit_log, root: @root, store: @store
|
|
127
|
+
).call(...)
|
|
128
|
+
end
|
|
129
|
+
|
|
123
130
|
def validate_all(...)
|
|
124
131
|
Application::Reads::ValidateAll.new(
|
|
125
132
|
ctx: @ctx, manifest: @manifest, file_store: @file_store, schemas: @schemas, audit_log: @audit_log,
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -49,7 +49,14 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
authority = store.manifest.roles_with_kind(:accept_authority).first
|
|
53
|
+
if authority.nil?
|
|
54
|
+
raise UsageError.new(
|
|
55
|
+
"schema migrate requires a role with kind :accept_authority in the manifest; " \
|
|
56
|
+
"none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
ops = Textus::Operations.for(store, role: authority)
|
|
53
60
|
touched = []
|
|
54
61
|
store.manifest.resolver.enumerate.each do |row|
|
|
55
62
|
env = ops.get(row[:key])
|
data/lib/textus/store.rb
CHANGED
|
@@ -33,7 +33,11 @@ module Textus
|
|
|
33
33
|
@manifest = Manifest.load(@root)
|
|
34
34
|
@schemas = Schemas.new(File.join(@root, "schemas"))
|
|
35
35
|
@file_store = Infra::Storage::FileStore.new
|
|
36
|
-
@audit_log = Infra::AuditLog.new(
|
|
36
|
+
@audit_log = Infra::AuditLog.new(
|
|
37
|
+
@root,
|
|
38
|
+
max_size: @manifest.audit_config[:max_size],
|
|
39
|
+
keep: @manifest.audit_config[:keep],
|
|
40
|
+
)
|
|
37
41
|
@bus = Hooks::Bus.new
|
|
38
42
|
Infra::AuditSubscriber.new(@audit_log).attach(@bus)
|
|
39
43
|
Hooks::Builtin.register_all(@bus)
|
data/lib/textus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -110,7 +110,7 @@ files:
|
|
|
110
110
|
- exe/textus
|
|
111
111
|
- lib/textus.rb
|
|
112
112
|
- lib/textus/application/context.rb
|
|
113
|
-
- lib/textus/application/policy/predicates/
|
|
113
|
+
- lib/textus/application/policy/predicates/accept_authority_signed.rb
|
|
114
114
|
- lib/textus/application/policy/predicates/schema_valid.rb
|
|
115
115
|
- lib/textus/application/policy/promotion.rb
|
|
116
116
|
- lib/textus/application/projection.rb
|
|
@@ -123,6 +123,7 @@ files:
|
|
|
123
123
|
- lib/textus/application/reads/list.rb
|
|
124
124
|
- lib/textus/application/reads/policy_explain.rb
|
|
125
125
|
- lib/textus/application/reads/published.rb
|
|
126
|
+
- lib/textus/application/reads/pulse.rb
|
|
126
127
|
- lib/textus/application/reads/rdeps.rb
|
|
127
128
|
- lib/textus/application/reads/schema_envelope.rb
|
|
128
129
|
- lib/textus/application/reads/stale.rb
|
|
@@ -133,9 +134,8 @@ files:
|
|
|
133
134
|
- lib/textus/application/refresh/all.rb
|
|
134
135
|
- lib/textus/application/refresh/orchestrator.rb
|
|
135
136
|
- lib/textus/application/refresh/worker.rb
|
|
136
|
-
- lib/textus/application/tools/migrate_keys.rb
|
|
137
|
-
- lib/textus/application/tools/migrate_manifest_to_kinds.rb
|
|
138
137
|
- lib/textus/application/writes/accept.rb
|
|
138
|
+
- lib/textus/application/writes/authority_gate.rb
|
|
139
139
|
- lib/textus/application/writes/delete.rb
|
|
140
140
|
- lib/textus/application/writes/envelope_io.rb
|
|
141
141
|
- lib/textus/application/writes/materializer.rb
|
|
@@ -143,6 +143,7 @@ files:
|
|
|
143
143
|
- lib/textus/application/writes/publish.rb
|
|
144
144
|
- lib/textus/application/writes/put.rb
|
|
145
145
|
- lib/textus/application/writes/reject.rb
|
|
146
|
+
- lib/textus/boot.rb
|
|
146
147
|
- lib/textus/builder/pipeline.rb
|
|
147
148
|
- lib/textus/builder/renderer.rb
|
|
148
149
|
- lib/textus/builder/renderer/json.rb
|
|
@@ -160,6 +161,7 @@ files:
|
|
|
160
161
|
- lib/textus/cli/verb/accept.rb
|
|
161
162
|
- lib/textus/cli/verb/audit.rb
|
|
162
163
|
- lib/textus/cli/verb/blame.rb
|
|
164
|
+
- lib/textus/cli/verb/boot.rb
|
|
163
165
|
- lib/textus/cli/verb/build.rb
|
|
164
166
|
- lib/textus/cli/verb/delete.rb
|
|
165
167
|
- lib/textus/cli/verb/deps.rb
|
|
@@ -169,11 +171,10 @@ files:
|
|
|
169
171
|
- lib/textus/cli/verb/hook_run.rb
|
|
170
172
|
- lib/textus/cli/verb/hooks.rb
|
|
171
173
|
- lib/textus/cli/verb/init.rb
|
|
172
|
-
- lib/textus/cli/verb/intro.rb
|
|
173
|
-
- lib/textus/cli/verb/key_normalize.rb
|
|
174
174
|
- lib/textus/cli/verb/list.rb
|
|
175
175
|
- lib/textus/cli/verb/mv.rb
|
|
176
176
|
- lib/textus/cli/verb/published.rb
|
|
177
|
+
- lib/textus/cli/verb/pulse.rb
|
|
177
178
|
- lib/textus/cli/verb/put.rb
|
|
178
179
|
- lib/textus/cli/verb/rdeps.rb
|
|
179
180
|
- lib/textus/cli/verb/refresh.rb
|
|
@@ -212,7 +213,6 @@ files:
|
|
|
212
213
|
- lib/textus/domain/freshness/verdict.rb
|
|
213
214
|
- lib/textus/domain/outcome.rb
|
|
214
215
|
- lib/textus/domain/permission.rb
|
|
215
|
-
- lib/textus/domain/policy.rb
|
|
216
216
|
- lib/textus/domain/policy/handler_allowlist.rb
|
|
217
217
|
- lib/textus/domain/policy/matcher.rb
|
|
218
218
|
- lib/textus/domain/policy/promote.rb
|
|
@@ -245,7 +245,6 @@ files:
|
|
|
245
245
|
- lib/textus/infra/refresh/lock.rb
|
|
246
246
|
- lib/textus/infra/storage/file_store.rb
|
|
247
247
|
- lib/textus/init.rb
|
|
248
|
-
- lib/textus/intro.rb
|
|
249
248
|
- lib/textus/key/distance.rb
|
|
250
249
|
- lib/textus/key/grammar.rb
|
|
251
250
|
- lib/textus/key/path.rb
|
|
@@ -261,10 +260,10 @@ files:
|
|
|
261
260
|
- lib/textus/manifest/entry/validators/events.rb
|
|
262
261
|
- lib/textus/manifest/entry/validators/format_matrix.rb
|
|
263
262
|
- lib/textus/manifest/entry/validators/index_filename.rb
|
|
264
|
-
- lib/textus/manifest/entry/validators/
|
|
263
|
+
- lib/textus/manifest/entry/validators/inject_boot.rb
|
|
265
264
|
- lib/textus/manifest/entry/validators/publish_each.rb
|
|
266
|
-
- lib/textus/manifest/resolution.rb
|
|
267
265
|
- lib/textus/manifest/resolver.rb
|
|
266
|
+
- lib/textus/manifest/role_kinds.rb
|
|
268
267
|
- lib/textus/manifest/rules.rb
|
|
269
268
|
- lib/textus/manifest/schema.rb
|
|
270
269
|
- lib/textus/mustache.rb
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Policy
|
|
4
|
-
module Predicates
|
|
5
|
-
class HumanAccept
|
|
6
|
-
attr_reader :reason
|
|
7
|
-
|
|
8
|
-
def name
|
|
9
|
-
"human_accept"
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# The role is passed explicitly. In practice, Accept already enforces
|
|
13
|
-
# role == "human" before reaching the promotion gate, so this predicate
|
|
14
|
-
# trivially passes. It documents intent and future-proofs multi-actor
|
|
15
|
-
# accept flows.
|
|
16
|
-
def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
-
role_str = role&.to_s
|
|
18
|
-
# If we cannot determine the role, trust that Accept has already
|
|
19
|
-
# checked — allow through.
|
|
20
|
-
return true if role_str.nil? || role_str.empty?
|
|
21
|
-
|
|
22
|
-
ok = (role_str == "human")
|
|
23
|
-
@reason = "current role is '#{role_str}', expected 'human'" unless ok
|
|
24
|
-
ok
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Tools
|
|
4
|
-
# Run-once helper that renames files/directories whose basenames don't
|
|
5
|
-
# conform to the strict key grammar (§3 of plan-1.2). Only walks
|
|
6
|
-
# nested: true manifest entries — leaf entries with illegal declared
|
|
7
|
-
# keys are caught by Manifest load and must be fixed by hand.
|
|
8
|
-
module MigrateKeys
|
|
9
|
-
SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
10
|
-
|
|
11
|
-
module_function
|
|
12
|
-
|
|
13
|
-
# Returns the envelope hash described in plan-1.2 §3.
|
|
14
|
-
def run(store, write: false)
|
|
15
|
-
plan = build_plan(store)
|
|
16
|
-
collisions = plan[:collisions]
|
|
17
|
-
renames = plan[:renames]
|
|
18
|
-
|
|
19
|
-
ok = collisions.empty?
|
|
20
|
-
apply!(store, renames) if write && ok
|
|
21
|
-
|
|
22
|
-
{
|
|
23
|
-
"protocol" => Textus::PROTOCOL,
|
|
24
|
-
"mode" => write ? "write" : "dry-run",
|
|
25
|
-
"renames" => renames.map { |r| envelope_rename(r) },
|
|
26
|
-
"collisions" => collisions.map { |c| envelope_collision(c) },
|
|
27
|
-
"ok" => ok,
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# ------------------------------------------------------------------
|
|
32
|
-
# Plan construction
|
|
33
|
-
# ------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
# Returns { renames: [...], collisions: [...] }
|
|
36
|
-
# Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
|
|
37
|
-
# Each collision: { target:, sources: [...] }
|
|
38
|
-
def build_plan(store) # rubocop:disable Metrics/AbcSize
|
|
39
|
-
renames = []
|
|
40
|
-
target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
|
|
41
|
-
|
|
42
|
-
store.manifest.entries.each do |entry|
|
|
43
|
-
next unless entry.nested?
|
|
44
|
-
|
|
45
|
-
base = File.join(store.root, "zones", entry.path)
|
|
46
|
-
next unless File.directory?(base)
|
|
47
|
-
|
|
48
|
-
# Walk depth-first. Order matters when computing the "new key"
|
|
49
|
-
# for files inside a renamed directory: we record renames bottom-up,
|
|
50
|
-
# so children are renamed before their parents on apply.
|
|
51
|
-
walk(base) do |abs_path, is_dir|
|
|
52
|
-
next if abs_path == base
|
|
53
|
-
|
|
54
|
-
basename = File.basename(abs_path)
|
|
55
|
-
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
56
|
-
next if stem.match?(SEGMENT)
|
|
57
|
-
|
|
58
|
-
new_stem = normalize(stem)
|
|
59
|
-
# Skip if normalization yields the same stem (e.g. already-legal
|
|
60
|
-
# under a different lens). In practice match?(SEGMENT) catches that
|
|
61
|
-
# above; this is a safety net.
|
|
62
|
-
next if new_stem == stem
|
|
63
|
-
|
|
64
|
-
new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
|
|
65
|
-
target = File.join(File.dirname(abs_path), new_basename)
|
|
66
|
-
target_buckets[target] << abs_path
|
|
67
|
-
|
|
68
|
-
renames << {
|
|
69
|
-
from: abs_path,
|
|
70
|
-
to: target,
|
|
71
|
-
kind: is_dir ? :dir : :file,
|
|
72
|
-
entry: entry,
|
|
73
|
-
base: base,
|
|
74
|
-
}
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
|
|
79
|
-
.map { |t, srcs| { target: t, sources: srcs.sort } }
|
|
80
|
-
|
|
81
|
-
# Drop colliding entries from renames (we won't apply any of them)
|
|
82
|
-
colliding_targets = collisions.to_set { |c| c[:target] }
|
|
83
|
-
renames.reject! { |r| colliding_targets.include?(r[:to]) }
|
|
84
|
-
|
|
85
|
-
# Sort renames bottom-up (deepest path first) so children move before parents.
|
|
86
|
-
renames.sort_by! { |r| -r[:from].count("/") }
|
|
87
|
-
|
|
88
|
-
{ renames: renames, collisions: collisions }
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Yields [absolute_path, is_dir] for every entry under root. Depth-first.
|
|
92
|
-
def walk(root, &block)
|
|
93
|
-
Dir.each_child(root) do |name|
|
|
94
|
-
abs = File.join(root, name)
|
|
95
|
-
if File.directory?(abs)
|
|
96
|
-
walk(abs, &block)
|
|
97
|
-
yield abs, true
|
|
98
|
-
else
|
|
99
|
-
yield abs, false
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Deterministic transform per plan §3.
|
|
105
|
-
def normalize(s)
|
|
106
|
-
s = s.downcase
|
|
107
|
-
s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
|
|
108
|
-
s = s.gsub(/-+/, "-")
|
|
109
|
-
s.sub(/\A-+/, "").sub(/-+\z/, "")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# ------------------------------------------------------------------
|
|
113
|
-
# Apply
|
|
114
|
-
# ------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
def apply!(store, renames)
|
|
117
|
-
audit = Textus::Infra::AuditLog.new(store.root)
|
|
118
|
-
renames.each do |r|
|
|
119
|
-
# Bottom-up order means a child's ancestors haven't moved yet, so
|
|
120
|
-
# `from`/`to` are valid as-recorded. The audit `key` reflects the
|
|
121
|
-
# eventual full key once every rename in this batch has applied.
|
|
122
|
-
from = r[:from]
|
|
123
|
-
to = r[:to]
|
|
124
|
-
File.rename(from, to)
|
|
125
|
-
new_key = compute_new_key(r, renames)
|
|
126
|
-
audit.append(
|
|
127
|
-
role: "runner",
|
|
128
|
-
verb: "migrate-keys",
|
|
129
|
-
key: new_key,
|
|
130
|
-
etag_before: nil,
|
|
131
|
-
etag_after: nil,
|
|
132
|
-
extras: { "from" => from, "to" => to },
|
|
133
|
-
)
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
|
|
138
|
-
def resolve_current_path(path, renames)
|
|
139
|
-
out = path
|
|
140
|
-
renames.each do |r|
|
|
141
|
-
prefix = r[:from] + "/"
|
|
142
|
-
out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
|
|
143
|
-
end
|
|
144
|
-
out
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# New full key after applying all renames up through this one.
|
|
148
|
-
def compute_new_key(rename, renames)
|
|
149
|
-
base = rename[:base]
|
|
150
|
-
entry = rename[:entry]
|
|
151
|
-
new_to = resolve_current_path(rename[:to], renames)
|
|
152
|
-
|
|
153
|
-
rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
154
|
-
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
|
|
155
|
-
stripped ||= rel
|
|
156
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
157
|
-
(entry.key.split(".") + segs).join(".")
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# ------------------------------------------------------------------
|
|
161
|
-
# Envelope helpers
|
|
162
|
-
# ------------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
def envelope_rename(r)
|
|
165
|
-
{
|
|
166
|
-
"from" => r[:from],
|
|
167
|
-
"to" => r[:to],
|
|
168
|
-
"old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
|
|
169
|
-
"new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
|
|
170
|
-
}
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def envelope_collision(col)
|
|
174
|
-
{ "target" => col[:target], "sources" => col[:sources] }
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def path_to_key(path, base, entry, kind)
|
|
178
|
-
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
179
|
-
stripped =
|
|
180
|
-
if kind == :dir
|
|
181
|
-
rel
|
|
182
|
-
else
|
|
183
|
-
rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
184
|
-
end
|
|
185
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
186
|
-
(entry.key.split(".") + segs).join(".")
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
require "yaml"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Application
|
|
5
|
-
module Tools
|
|
6
|
-
module MigrateManifestToKinds
|
|
7
|
-
module_function
|
|
8
|
-
|
|
9
|
-
def upgrade_yaml(yaml_text)
|
|
10
|
-
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
11
|
-
raw["entries"] = Array(raw["entries"]).map { |row| upgrade_row(row) }
|
|
12
|
-
YAML.dump(raw)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def upgrade_row(row)
|
|
16
|
-
return row if row["kind"]
|
|
17
|
-
|
|
18
|
-
row.merge("kind" => infer_kind(row))
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def infer_kind(row)
|
|
22
|
-
return "intake" if row["intake"].is_a?(Hash) || row["intake_handler"]
|
|
23
|
-
return "derived" if row["template"] || row["compute"] || row["generator"] || row["projection"]
|
|
24
|
-
return "nested" if row["nested"] == true
|
|
25
|
-
|
|
26
|
-
"leaf"
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class CLI
|
|
3
|
-
class Verb
|
|
4
|
-
class KeyNormalize < Verb
|
|
5
|
-
command_name "normalize"
|
|
6
|
-
parent_group Group::Key
|
|
7
|
-
|
|
8
|
-
option :write, "--write"
|
|
9
|
-
option :dry_run, "--dry-run"
|
|
10
|
-
option :upgrade_manifest, "--upgrade-manifest"
|
|
11
|
-
|
|
12
|
-
def call(store)
|
|
13
|
-
if upgrade_manifest
|
|
14
|
-
run_upgrade_manifest(store)
|
|
15
|
-
else
|
|
16
|
-
effective_write = write && !dry_run
|
|
17
|
-
res = Textus::Application::Tools::MigrateKeys.run(store, write: effective_write || false)
|
|
18
|
-
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def run_upgrade_manifest(store)
|
|
25
|
-
manifest_path = File.join(store.root, "manifest.yaml")
|
|
26
|
-
orig = File.read(manifest_path)
|
|
27
|
-
new_yaml = Textus::Application::Tools::MigrateManifestToKinds.upgrade_yaml(orig)
|
|
28
|
-
|
|
29
|
-
if dry_run
|
|
30
|
-
diff_lines = unified_diff(orig, new_yaml, manifest_path)
|
|
31
|
-
emit({ "protocol" => PROTOCOL, "dry_run" => true, "diff" => diff_lines, "ok" => true }, exit_code: 0)
|
|
32
|
-
else
|
|
33
|
-
File.write(manifest_path, new_yaml)
|
|
34
|
-
puts "upgraded manifest at #{manifest_path}"
|
|
35
|
-
emit({ "protocol" => PROTOCOL, "upgraded" => manifest_path, "ok" => true }, exit_code: 0)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def unified_diff(before, after, _path)
|
|
40
|
-
before.lines.zip(after.lines).each_with_object([]) do |(a, b), acc|
|
|
41
|
-
acc << "- #{a.chomp}" if a && a != b
|
|
42
|
-
acc << "+ #{b.chomp}" if b && a != b
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|