Module: Textus::Manifest::Schema

Defined in:
lib/textus/manifest/schema.rb

Constant Summary collapse

ROOT_KEYS =
%w[version roles zones entries rules audit].freeze
ROLE_KEYS =
%w[name kind].freeze
ROLE_KINDS =
%w[accept_authority generator proposer runner].freeze
ZONE_KEYS =
%w[name kind write_policy read_policy].freeze
ZONE_KINDS =
%w[origin quarantine queue derived].freeze
KIND_REQUIRES_ROLE_KIND =
{
  "derived" => "generator",
  "queue" => "proposer",
  "quarantine" => "runner",
}.freeze
ENTRY_KEYS =
%w[
  key path zone kind schema owner nested format
  compute template publish_to publish_each
  intake events inject_boot index_filename
].freeze
COMPUTE_KEYS =
%w[kind select pluck sort_by limit transform command sources].freeze
INTAKE_KEYS =
%w[handler config].freeze
RULE_KEYS =
%w[match refresh intake_handler_allowlist promotion retention].freeze
REFRESH_KEYS =
%w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
FETCH_TIMEOUT_SECONDS_CEILING =
3600
PROMOTION_KEYS =
%w[requires].freeze
RETENTION_KEYS =
%w[expire_after archive_after].freeze
AUDIT_KEYS =
%w[max_size keep].freeze

Class Method Summary collapse

Class Method Details

.role_kind_mapping(raw) ⇒ Object

name => kind string, honouring an explicit roles: block or the default mapping.



161
162
163
164
165
166
167
# File 'lib/textus/manifest/schema.rb', line 161

def self.role_kind_mapping(raw)
  if raw["roles"].nil?
    RoleKinds::DEFAULT_MAPPING.transform_values(&:to_s)
  else
    Array(raw["roles"]).to_h { |r| [r["name"], r["kind"]] }
  end
end

.validate!(raw) ⇒ Object

Raises:



28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/textus/manifest/schema.rb', line 28

def self.validate!(raw)
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)

  walk(raw, ROOT_KEYS, "$")
  validate_roles!(raw["roles"])
  validate_zones!(raw["zones"])
  validate_entries!(raw["entries"])
  validate_rules!(raw["rules"])
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
  validate_zone_writers_declared!(raw)
  validate_single_queue!(raw)
  validate_zone_kind_consistency!(raw)
end

.validate_entries!(entries) ⇒ Object



56
57
58
59
60
61
62
63
# File 'lib/textus/manifest/schema.rb', line 56

def self.validate_entries!(entries)
  Array(entries).each_with_index do |e, i|
    path = "$.entries[#{i}]"
    walk(e, ENTRY_KEYS, path)
    walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
    walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
  end
end

.validate_fetch_timeout!(value, path) ⇒ Object

Raises:



118
119
120
121
122
123
124
125
# File 'lib/textus/manifest/schema.rb', line 118

def self.validate_fetch_timeout!(value, path)
  return if value.nil?
  return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING

  raise BadManifest.new(
    "fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
  )
end

.validate_roles!(roles) ⇒ Object

Raises:



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/textus/manifest/schema.rb', line 94

def self.validate_roles!(roles)
  return if roles.nil?
  raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)

  accept_authority_count = 0
  roles.each_with_index do |r, i|
    path = "$.roles[#{i}]"
    walk(r, ROLE_KEYS, path)
    name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
    kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
    unless ROLE_KINDS.include?(kind)
      raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
    end

    accept_authority_count += 1 if kind == "accept_authority"
  end
  return unless accept_authority_count > 1

  raise BadManifest.new(
    "manifest declares #{accept_authority_count} accept_authority roles; " \
    "at most one accept_authority role is allowed",
  )
end

.validate_rules!(rules) ⇒ Object



65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/textus/manifest/schema.rb', line 65

def self.validate_rules!(rules)
  Array(rules).each_with_index do |r, i|
    path = "$.rules[#{i}]"
    walk(r, RULE_KEYS, path)
    if r["refresh"].is_a?(Hash)
      walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh")
      validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
    end
    walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
    walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
  end
end

.validate_single_queue!(raw) ⇒ Object

Raises:



137
138
139
140
141
142
143
144
# File 'lib/textus/manifest/schema.rb', line 137

def self.validate_single_queue!(raw)
  queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
  return if queues.size <= 1

  raise BadManifest.new(
    "at most one zone may declare kind: queue (found: #{queues.join(", ")})",
  )
end

.validate_zone_kind_consistency!(raw) ⇒ Object



146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/textus/manifest/schema.rb', line 146

def self.validate_zone_kind_consistency!(raw)
  mapping = role_kind_mapping(raw)
  Array(raw["zones"]).each do |z|
    required = KIND_REQUIRES_ROLE_KIND[z["kind"]] or next
    writers  = Array(z["write_policy"])
    next if writers.any? { |w| mapping[w] == required }

    raise BadManifest.new(
      "zone '#{z["name"]}' declares kind: #{z["kind"]} but no writer is a #{required} " \
      "(writers: #{writers.join(", ")})",
    )
  end
end

.validate_zone_writers_declared!(raw) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/textus/manifest/schema.rb', line 78

def self.validate_zone_writers_declared!(raw)
  return if raw["roles"].nil? # default mapping is permissive

  declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
  Array(raw["zones"]).each do |z|
    Array(z["write_policy"]).each_with_index do |w, j|
      next if declared.include?(w)

      raise BadManifest.new(
        "zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
        "(declared roles: #{declared.to_a.join(", ")})",
      )
    end
  end
end

.validate_zones!(zones) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/textus/manifest/schema.rb', line 42

def self.validate_zones!(zones)
  Array(zones).each_with_index do |z, i|
    walk(z, ZONE_KEYS, "$.zones[#{i}]")
    if z["kind"].nil?
      raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
    end
    next if ZONE_KINDS.include?(z["kind"])

    raise BadManifest.new(
      "unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
    )
  end
end

.walk(hash, allowed, path) ⇒ Object



127
128
129
130
131
132
133
134
135
# File 'lib/textus/manifest/schema.rb', line 127

def self.walk(hash, allowed, path)
  return unless hash.is_a?(Hash)

  hash.each_key do |k|
    next if allowed.include?(k)

    raise BadManifest.new("unknown key '#{k}' at '#{path}'")
  end
end