jazari 0.3.0 → 0.4.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 +47 -0
- data/lib/jazari/recipe_files.rb +174 -0
- data/lib/jazari/version.rb +1 -1
- data/lib/jazari.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3e1d3f594584f56c5ea72c8aacd01ce1aa9fc6ddd12b2b1f9c160072dfd9c63
|
|
4
|
+
data.tar.gz: f5d622adb6252f7465adb0aeab696c9876598bef6a368bf1845dd8a2c896b252
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dfc06b9155a1f706488fa36ee9661c92fb419a27c76cfe6f1e912be31a365ceb6249e24805a92f7e1b9d025511e0559332d6f391f604dbe24e569f46ab886cc1
|
|
7
|
+
data.tar.gz: f5f37bdc359b9626221055c908a5a0ab59f713ab2238ff5df99a82da9c787e403966ef591e65d25fab45ebd2d9dadb78e2a1fd8af860627c4d8661b589356668
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,53 @@ codes, the resolved-value shape, how revisions are computed, and the schema the
|
|
|
8
8
|
generator emits — changes to any of those are breaking even when the method
|
|
9
9
|
signatures do not move.
|
|
10
10
|
|
|
11
|
+
## [0.4.0] - 2026-08-11
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`Jazari::RecipeFiles` — recipes as YAML or JSON, without files silently
|
|
16
|
+
overwriting operators.**
|
|
17
|
+
|
|
18
|
+
Nothing ever forbade files: `RecipeRegistry.seed!` takes plain hashes, so
|
|
19
|
+
`seed!(YAML.load_file(…))` already worked. What was missing was everything
|
|
20
|
+
that makes it *safe*.
|
|
21
|
+
|
|
22
|
+
- **`load(path)`** — a file or a directory of `.yml` / `.yaml` / `.json`.
|
|
23
|
+
Accepts one recipe, a list, or a list under a `recipes:` key. It validates
|
|
24
|
+
at LOAD, which is the whole reason it exists rather than "just call
|
|
25
|
+
`YAML.load_file`": an unknown key is a typo, and a typo that loads silently
|
|
26
|
+
becomes a recipe resolving to something nobody wrote. Unknown keys, missing
|
|
27
|
+
topics, bad `run_policy`, duplicate ids and malformed YAML all raise, naming
|
|
28
|
+
the file.
|
|
29
|
+
- **`dump(dir)`** — writes what is actually stored back out, one file per
|
|
30
|
+
recipe. Without this the loop never closes: an operator's runtime fix could
|
|
31
|
+
not be reviewed or committed, so runtime editing would quietly become the
|
|
32
|
+
thing you avoid rather than the thing the design is built around.
|
|
33
|
+
- **`drift(entries)`** — which stored recipes disagree with their file, and in
|
|
34
|
+
which fields. **Reported, never applied.**
|
|
35
|
+
|
|
36
|
+
**Files seed; they do not sync.** `seed!` stays create-if-missing, so a file
|
|
37
|
+
never overwrites a row an operator edited. That is the same rule the runbook
|
|
38
|
+
layer already follows — a customisation diverges rather than rebasing, because
|
|
39
|
+
silently overwriting a deliberate edit with a change nobody saw is the worst
|
|
40
|
+
available outcome. Applying files on every deploy would do exactly that, one
|
|
41
|
+
layer up. The cost is drift, so drift is made visible instead of resolved.
|
|
42
|
+
|
|
43
|
+
One place the loader is deliberately **stricter than the API**: a malformed
|
|
44
|
+
checklist id is an error rather than a fixup. `Checklist.normalize` replaces an
|
|
45
|
+
unusable id with a generated one, which is right when an id is absent and
|
|
46
|
+
opaque — but in a file someone wrote it, MCP addresses the step by it, and
|
|
47
|
+
documentation quotes it. Swapping it for a random token would create exactly
|
|
48
|
+
the file-versus-row disagreement this loader exists to prevent.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **The boundary check read heredoc bodies as code.** It stripped `"…"` and
|
|
53
|
+
`'…'` on the stated principle that a capitalised word inside a string is data,
|
|
54
|
+
but a fixture written as `<<~YML` had its own prose reported as constant
|
|
55
|
+
references. Heredoc bodies are now skipped. Verified still catching a planted
|
|
56
|
+
breach.
|
|
57
|
+
|
|
11
58
|
## [0.3.0] - 2026-08-11
|
|
12
59
|
|
|
13
60
|
### Added
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Jazari
|
|
7
|
+
# Recipes as FILES — the version-controlled, reviewable form of the same data.
|
|
8
|
+
#
|
|
9
|
+
# This does not make files a second source of truth. `seed!` has always been
|
|
10
|
+
# create-if-missing, so a file is a SEED, not a sync: the row wins once it
|
|
11
|
+
# exists, because an operator editing a procedure at runtime is the whole
|
|
12
|
+
# reason recipes are data rather than code.
|
|
13
|
+
#
|
|
14
|
+
# That is deliberate and it is the same rule the runbook layer already follows —
|
|
15
|
+
# a customised runbook diverges permanently rather than rebasing, because
|
|
16
|
+
# silently overwriting a deliberate edit with a change nobody saw is the worst
|
|
17
|
+
# available outcome. Applying files on every deploy would do exactly that, one
|
|
18
|
+
# layer up.
|
|
19
|
+
#
|
|
20
|
+
# The cost of that choice is drift: a file and a row can disagree and nothing
|
|
21
|
+
# says so. So drift is REPORTED (`drift`) rather than resolved, and there is a
|
|
22
|
+
# way back out (`dump`) — edit at runtime, export, review the diff in a pull
|
|
23
|
+
# request, commit. The loop closes without anyone's work being overwritten.
|
|
24
|
+
module RecipeFiles
|
|
25
|
+
EXTENSIONS = %w[.yml .yaml .json].freeze
|
|
26
|
+
|
|
27
|
+
# Keys a recipe file may carry. Anything else is a typo, and a typo that
|
|
28
|
+
# loads silently becomes a recipe that resolves to something nobody wrote.
|
|
29
|
+
KEYS = %i[id version topic description checklist run_policy].freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Reads one file or every recipe file in a directory. Returns plain hashes,
|
|
34
|
+
# ready for `RecipeRegistry.seed!` — which is why this is a loader and not a
|
|
35
|
+
# registry: producing the data and storing it are separate concerns.
|
|
36
|
+
def load(path)
|
|
37
|
+
entries = Array(paths_for(path)).flat_map { |file| parse(file) }
|
|
38
|
+
entries.each { |entry| validate!(entry) }
|
|
39
|
+
ids = entries.map { |entry| entry[:id] }
|
|
40
|
+
duplicated = ids.tally.select { |_, count| count > 1 }.keys
|
|
41
|
+
raise InvalidRunbook, "duplicate recipe ids: #{duplicated.join(', ')}" if duplicated.any?
|
|
42
|
+
|
|
43
|
+
entries
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Writes what is actually stored back out as YAML, one file per recipe.
|
|
47
|
+
# This is the half that makes runtime editing safe to allow: whatever an
|
|
48
|
+
# operator changed can be exported, diffed and committed.
|
|
49
|
+
def dump(directory, recipes: RecipeRecord.order(:recipe_id))
|
|
50
|
+
dir = File.expand_path(directory.to_s)
|
|
51
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
52
|
+
recipes.map do |record|
|
|
53
|
+
file = File.join(dir, "#{record.recipe_id}.yml")
|
|
54
|
+
File.write(file, YAML.dump(stringify(to_entry(record))))
|
|
55
|
+
file
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Which stored recipes disagree with their file definition, and how.
|
|
60
|
+
#
|
|
61
|
+
# Reported, never applied. A host decides what a difference means: on one
|
|
62
|
+
# fleet a file is the reviewed truth and a divergent row is an incident; on
|
|
63
|
+
# another the row is an operator's fix and the file is simply stale.
|
|
64
|
+
def drift(entries)
|
|
65
|
+
Array(entries).filter_map do |entry|
|
|
66
|
+
attributes = normalize(entry)
|
|
67
|
+
record = RecipeRecord.find_by(recipe_id: attributes[:id].to_s)
|
|
68
|
+
next { id: attributes[:id], state: :missing } if record.nil?
|
|
69
|
+
|
|
70
|
+
differing = KEYS.reject { |key| same?(key, attributes, record) }
|
|
71
|
+
next if differing.empty?
|
|
72
|
+
|
|
73
|
+
{ id: attributes[:id], state: :differs, fields: differing }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# -- internals ---------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def paths_for(path)
|
|
80
|
+
expanded = File.expand_path(path.to_s)
|
|
81
|
+
return [ expanded ] if File.file?(expanded)
|
|
82
|
+
raise InvalidRunbook, "no such recipe path: #{path}" unless File.directory?(expanded)
|
|
83
|
+
|
|
84
|
+
Dir.children(expanded).sort
|
|
85
|
+
.select { |name| EXTENSIONS.include?(File.extname(name)) }
|
|
86
|
+
.map { |name| File.join(expanded, name) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse(file)
|
|
90
|
+
raw = File.read(file)
|
|
91
|
+
data = if File.extname(file) == ".json"
|
|
92
|
+
JSON.parse(raw)
|
|
93
|
+
else
|
|
94
|
+
# safe_load: a recipe file is operational content, never a place to
|
|
95
|
+
# instantiate arbitrary objects.
|
|
96
|
+
YAML.safe_load(raw, permitted_classes: [], aliases: false)
|
|
97
|
+
end
|
|
98
|
+
# A file is either one recipe, a list of them, or a list under a `recipes:`
|
|
99
|
+
# key. `Array(hash)` would explode a single recipe into key/value pairs, so
|
|
100
|
+
# the Hash cases are named rather than coerced.
|
|
101
|
+
entries = if data.is_a?(Hash)
|
|
102
|
+
data.key?("recipes") ? Array(data["recipes"]) : [ data ]
|
|
103
|
+
else
|
|
104
|
+
Array(data)
|
|
105
|
+
end
|
|
106
|
+
entries.map { |entry| normalize(entry) }
|
|
107
|
+
rescue JSON::ParserError, Psych::SyntaxError => error
|
|
108
|
+
raise InvalidRunbook, "#{File.basename(file)}: #{error.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize(entry)
|
|
112
|
+
entry.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate!(entry)
|
|
116
|
+
unknown = entry.keys - KEYS
|
|
117
|
+
raise InvalidRunbook, "unknown recipe keys: #{unknown.join(', ')}" if unknown.any?
|
|
118
|
+
raise InvalidRunbook, "recipe id is required" if entry[:id].to_s.empty?
|
|
119
|
+
raise InvalidRunbook, "recipe #{entry[:id]} has no topic" if entry[:topic].to_s.empty?
|
|
120
|
+
|
|
121
|
+
policy = entry.fetch(:run_policy, RunPolicy::UNRESTRICTED).to_s
|
|
122
|
+
unless RunPolicy::ALL.include?(policy)
|
|
123
|
+
raise InvalidRunbook, "recipe #{entry[:id]} has unknown run_policy #{policy}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Reuse the one checklist validator rather than writing a second, laxer
|
|
127
|
+
# one here — a file must not be able to store an item the API would reject.
|
|
128
|
+
items = entry.fetch(:checklist, [])
|
|
129
|
+
Checklist.normalize(items)
|
|
130
|
+
|
|
131
|
+
# STRICTER than the API on one point, deliberately. `normalize` REPLACES an
|
|
132
|
+
# unusable id with a generated one, which is right when an id is absent and
|
|
133
|
+
# opaque. In a file it is neither: someone wrote it, MCP addresses the step
|
|
134
|
+
# by it, and documentation quotes it. Silently swapping it for a random
|
|
135
|
+
# token would put the file and the row into exactly the disagreement this
|
|
136
|
+
# loader exists to prevent — so a malformed id is an error, not a fixup.
|
|
137
|
+
Array(items).each do |item|
|
|
138
|
+
id = (item[:id] || item["id"]).to_s
|
|
139
|
+
next if id.empty? || id.match?(Checklist::ID_FORMAT)
|
|
140
|
+
|
|
141
|
+
raise InvalidRunbook, "recipe #{entry[:id]}: checklist id #{id.inspect} is not a valid token"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
entry
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_entry(record)
|
|
148
|
+
{ id: record.recipe_id, version: record.version, topic: record.topic,
|
|
149
|
+
description: record.description, run_policy: record.run_policy,
|
|
150
|
+
checklist: Checklist.normalize(record.checklist) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stringify(value)
|
|
154
|
+
case value
|
|
155
|
+
when Hash then value.to_h { |key, inner| [ key.to_s, stringify(inner) ] }
|
|
156
|
+
when Array then value.map { |inner| stringify(inner) }
|
|
157
|
+
when Symbol then value.to_s
|
|
158
|
+
else value
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def same?(key, attributes, record)
|
|
163
|
+
stored = case key
|
|
164
|
+
when :id then record.recipe_id
|
|
165
|
+
when :checklist then Checklist.normalize(record.checklist)
|
|
166
|
+
else record.public_send(key)
|
|
167
|
+
end
|
|
168
|
+
expected = key == :checklist ? Checklist.normalize(attributes.fetch(key, [])) : attributes[key]
|
|
169
|
+
return true if expected.nil? && key != :id
|
|
170
|
+
|
|
171
|
+
stringify(stored) == stringify(expected)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/jazari/version.rb
CHANGED
data/lib/jazari.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "jazari/targets"
|
|
|
8
8
|
require "jazari/anchors"
|
|
9
9
|
require "jazari/resolved_runbook"
|
|
10
10
|
require "jazari/recipe_registry"
|
|
11
|
+
require "jazari/recipe_files"
|
|
11
12
|
require "jazari/runs"
|
|
12
13
|
require "jazari/operations"
|
|
13
14
|
# The MCP layer is OPTIONAL. Descriptors are cheap and a host may want them to
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jazari
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/jazari/operations.rb
|
|
68
68
|
- lib/jazari/railtie.rb
|
|
69
69
|
- lib/jazari/recipe.rb
|
|
70
|
+
- lib/jazari/recipe_files.rb
|
|
70
71
|
- lib/jazari/recipe_registry.rb
|
|
71
72
|
- lib/jazari/resolved_runbook.rb
|
|
72
73
|
- lib/jazari/runs.rb
|