Module: Jazari::RecipeFiles
- Defined in:
- lib/jazari/recipe_files.rb
Overview
Recipes as FILES — the version-controlled, reviewable form of the same data.
This does not make files a second source of truth. seed! has always been
create-if-missing, so a file is a SEED, not a sync: the row wins once it
exists, because an operator editing a procedure at runtime is the whole
reason recipes are data rather than code.
That is deliberate and it is the same rule the runbook layer already follows — a customised runbook diverges permanently rather than rebasing, because silently overwriting a deliberate edit with a change nobody saw is the worst available outcome. Applying files on every deploy would do exactly that, one layer up.
The cost of that choice is drift: a file and a row can disagree and nothing
says so. So drift is REPORTED (drift) rather than resolved, and there is a
way back out (dump) — edit at runtime, export, review the diff in a pull
request, commit. The loop closes without anyone's work being overwritten.
Constant Summary collapse
- EXTENSIONS =
%w[.yml .yaml .json].freeze
- KEYS =
Keys a recipe file may carry. Anything else is a typo, and a typo that loads silently becomes a recipe that resolves to something nobody wrote.
%i[id version topic description checklist run_policy].freeze
Class Method Summary collapse
-
.drift(entries) ⇒ Object
Which stored recipes disagree with their file definition, and how.
-
.dump(directory, recipes: RecipeRecord.order(:recipe_id)) ⇒ Object
Writes what is actually stored back out as YAML, one file per recipe.
-
.load(path) ⇒ Object
Reads one file or every recipe file in a directory.
- .normalize(entry) ⇒ Object
- .parse(file) ⇒ Object
-
.paths_for(path) ⇒ Object
-- internals ---------------------------------------------------------.
- .same?(key, attributes, record) ⇒ Boolean
- .stringify(value) ⇒ Object
- .to_entry(record) ⇒ Object
- .validate!(entry) ⇒ Object
Class Method Details
.drift(entries) ⇒ Object
Which stored recipes disagree with their file definition, and how.
Reported, never applied. A host decides what a difference means: on one fleet a file is the reviewed truth and a divergent row is an incident; on another the row is an operator's fix and the file is simply stale.
64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/jazari/recipe_files.rb', line 64 def drift(entries) Array(entries).filter_map do |entry| attributes = normalize(entry) record = RecipeRecord.find_by(recipe_id: attributes[:id].to_s) next { id: attributes[:id], state: :missing } if record.nil? differing = KEYS.reject { |key| same?(key, attributes, record) } next if differing.empty? { id: attributes[:id], state: :differs, fields: differing } end end |
.dump(directory, recipes: RecipeRecord.order(:recipe_id)) ⇒ Object
Writes what is actually stored back out as YAML, one file per recipe. This is the half that makes runtime editing safe to allow: whatever an operator changed can be exported, diffed and committed.
49 50 51 52 53 54 55 56 57 |
# File 'lib/jazari/recipe_files.rb', line 49 def dump(directory, recipes: RecipeRecord.order(:recipe_id)) dir = File.(directory.to_s) Dir.mkdir(dir) unless Dir.exist?(dir) recipes.map do |record| file = File.join(dir, "#{record.recipe_id}.yml") File.write(file, YAML.dump(stringify(to_entry(record)))) file end end |
.load(path) ⇒ Object
Reads one file or every recipe file in a directory. Returns plain hashes,
ready for RecipeRegistry.seed! — which is why this is a loader and not a
registry: producing the data and storing it are separate concerns.
36 37 38 39 40 41 42 43 44 |
# File 'lib/jazari/recipe_files.rb', line 36 def load(path) entries = Array(paths_for(path)).flat_map { |file| parse(file) } entries.each { |entry| validate!(entry) } ids = entries.map { |entry| entry[:id] } duplicated = ids.tally.select { |_, count| count > 1 }.keys raise InvalidRunbook, "duplicate recipe ids: #{duplicated.join(', ')}" if duplicated.any? entries end |
.normalize(entry) ⇒ Object
111 112 113 |
# File 'lib/jazari/recipe_files.rb', line 111 def normalize(entry) entry.to_h.transform_keys { |key| key.to_s.to_sym } end |
.parse(file) ⇒ Object
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/jazari/recipe_files.rb', line 89 def parse(file) raw = File.read(file) data = if File.extname(file) == ".json" JSON.parse(raw) else # safe_load: a recipe file is operational content, never a place to # instantiate arbitrary objects. YAML.safe_load(raw, permitted_classes: [], aliases: false) end # A file is either one recipe, a list of them, or a list under a `recipes:` # key. `Array(hash)` would explode a single recipe into key/value pairs, so # the Hash cases are named rather than coerced. entries = if data.is_a?(Hash) data.key?("recipes") ? Array(data["recipes"]) : [ data ] else Array(data) end entries.map { |entry| normalize(entry) } rescue JSON::ParserError, Psych::SyntaxError => error raise InvalidRunbook, "#{File.basename(file)}: #{error.}" end |
.paths_for(path) ⇒ Object
-- internals ---------------------------------------------------------
79 80 81 82 83 84 85 86 87 |
# File 'lib/jazari/recipe_files.rb', line 79 def paths_for(path) = File.(path.to_s) return [ ] if File.file?() raise InvalidRunbook, "no such recipe path: #{path}" unless File.directory?() Dir.children().sort .select { |name| EXTENSIONS.include?(File.extname(name)) } .map { |name| File.join(, name) } end |
.same?(key, attributes, record) ⇒ Boolean
162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/jazari/recipe_files.rb', line 162 def same?(key, attributes, record) stored = case key when :id then record.recipe_id when :checklist then Checklist.normalize(record.checklist) else record.public_send(key) end expected = key == :checklist ? Checklist.normalize(attributes.fetch(key, [])) : attributes[key] return true if expected.nil? && key != :id stringify(stored) == stringify(expected) end |
.stringify(value) ⇒ Object
153 154 155 156 157 158 159 160 |
# File 'lib/jazari/recipe_files.rb', line 153 def stringify(value) case value when Hash then value.to_h { |key, inner| [ key.to_s, stringify(inner) ] } when Array then value.map { |inner| stringify(inner) } when Symbol then value.to_s else value end end |
.to_entry(record) ⇒ Object
147 148 149 150 151 |
# File 'lib/jazari/recipe_files.rb', line 147 def to_entry(record) { id: record.recipe_id, version: record.version, topic: record.topic, description: record.description, run_policy: record.run_policy, checklist: Checklist.normalize(record.checklist) } end |
.validate!(entry) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/jazari/recipe_files.rb', line 115 def validate!(entry) unknown = entry.keys - KEYS raise InvalidRunbook, "unknown recipe keys: #{unknown.join(', ')}" if unknown.any? raise InvalidRunbook, "recipe id is required" if entry[:id].to_s.empty? raise InvalidRunbook, "recipe #{entry[:id]} has no topic" if entry[:topic].to_s.empty? policy = entry.fetch(:run_policy, RunPolicy::UNRESTRICTED).to_s unless RunPolicy::ALL.include?(policy) raise InvalidRunbook, "recipe #{entry[:id]} has unknown run_policy #{policy}" end # Reuse the one checklist validator rather than writing a second, laxer # one here — a file must not be able to store an item the API would reject. items = entry.fetch(:checklist, []) Checklist.normalize(items) # STRICTER than the API on one point, deliberately. `normalize` REPLACES an # unusable id with a generated one, which is right when an id is absent and # opaque. In a file it is neither: someone wrote it, MCP addresses the step # by it, and documentation quotes it. Silently swapping it for a random # token would put the file and the row into exactly the disagreement this # loader exists to prevent — so a malformed id is an error, not a fixup. Array(items).each do |item| id = (item[:id] || item["id"]).to_s next if id.empty? || id.match?(Checklist::ID_FORMAT) raise InvalidRunbook, "recipe #{entry[:id]}: checklist id #{id.inspect} is not a valid token" end entry end |