jazari 0.2.1 → 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 +91 -0
- data/lib/generators/jazari/install/templates/create_jazari_tables.rb +6 -0
- data/lib/generators/jazari/upgrade/templates/add_jazari_runbook_origin.rb +10 -0
- data/lib/generators/jazari/upgrade/upgrade_generator.rb +31 -0
- data/lib/jazari/operations.rb +24 -9
- data/lib/jazari/recipe_files.rb +174 -0
- data/lib/jazari/resolved_runbook.rb +17 -3
- data/lib/jazari/version.rb +1 -1
- data/lib/jazari.rb +4 -2
- metadata +4 -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,97 @@ 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
|
+
|
|
58
|
+
## [0.3.0] - 2026-08-11
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`origin` on the runbook — provenance, so divergence means something.**
|
|
63
|
+
`custom?` answers "does a row exist", which a host adopting jazari cannot use
|
|
64
|
+
as a divergence signal: a backfill materializes a row for every subject at
|
|
65
|
+
once, so the morning after a migration everything reads as diverged and the
|
|
66
|
+
signal carries no information. Comparing content against the recipe does not
|
|
67
|
+
separate them either — a backfilled runbook *genuinely* differs, because it
|
|
68
|
+
carries the steps that subject actually had. Only provenance can.
|
|
69
|
+
|
|
70
|
+
`origin` is a nullable, host-defined string saying **why the row exists**.
|
|
71
|
+
`ResolvedRunbook` gains `#origin`, plus `#inherited?` (a row something claims
|
|
72
|
+
to have manufactured) and `#diverged?` (a row nobody claims, i.e. someone
|
|
73
|
+
decided it). `Jazari.customize` takes an optional `origin:`.
|
|
74
|
+
|
|
75
|
+
Two behaviours make the marker honest rather than decorative:
|
|
76
|
+
|
|
77
|
+
- **`customize` restates it, defaulting to nil.** Rewriting a procedure is a
|
|
78
|
+
decision, so an operator edit clears an inherited marker — the claim about
|
|
79
|
+
how the row came to exist stops being true the moment someone edits it.
|
|
80
|
+
- **Item operations preserve it.** `check_item`, `add_item` and `remove_item`
|
|
81
|
+
leave `origin` untouched, because performing a procedure is not rewriting
|
|
82
|
+
it. Without this, the first person to tick a box would silently convert a
|
|
83
|
+
migration artifact into a deliberate divergence.
|
|
84
|
+
|
|
85
|
+
Raised by a host adoption, where the backfill would otherwise have made six
|
|
86
|
+
of six subjects read as diverged on day one.
|
|
87
|
+
|
|
88
|
+
### Changed
|
|
89
|
+
|
|
90
|
+
- **`ResolvedRunbook` carries a new member (`origin`).** Positional
|
|
91
|
+
construction and exhaustive destructuring break; keyword construction and
|
|
92
|
+
member access do not. It defaults to nil, so hosts that ignore it are
|
|
93
|
+
unaffected.
|
|
94
|
+
|
|
95
|
+
### Migration
|
|
96
|
+
|
|
97
|
+
Already installed? `rails g jazari:upgrade` copies the one additive, nullable
|
|
98
|
+
column. It is safe to run ahead of any code that writes it — NULL is truthful
|
|
99
|
+
for every existing row, meaning "this predates provenance". New installs get
|
|
100
|
+
the column from `jazari:install`.
|
|
101
|
+
|
|
11
102
|
## [0.2.1] - 2026-08-10
|
|
12
103
|
|
|
13
104
|
### Fixed
|
|
@@ -31,6 +31,12 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
|
|
|
31
31
|
t.string :topic, null: false
|
|
32
32
|
t.text :description, null: false, default: ""
|
|
33
33
|
t.jsonb :checklist, null: false, default: []
|
|
34
|
+
# Provenance, host-defined and nullable. A runbook that exists because a
|
|
35
|
+
# backfill created it is a migration artifact; one that exists because an
|
|
36
|
+
# operator edited it is a decision. Both differ from the canon, so
|
|
37
|
+
# comparing content cannot tell them apart — only provenance can, and NULL
|
|
38
|
+
# honestly means "we did not record why".
|
|
39
|
+
t.string :origin
|
|
34
40
|
t.integer :lock_version, null: false, default: 0
|
|
35
41
|
t.timestamps
|
|
36
42
|
t.index %i[runbookable_type runbookable_id], unique: true
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# jazari 0.3.0 — provenance on the runbook.
|
|
2
|
+
#
|
|
3
|
+
# Nullable and additive, in one deploy, because nothing reads it yet and NULL is
|
|
4
|
+
# a truthful value: it means "this row predates provenance", which is exactly
|
|
5
|
+
# what every existing row is.
|
|
6
|
+
class AddJazariRunbookOrigin < ActiveRecord::Migration[7.1]
|
|
7
|
+
def change
|
|
8
|
+
add_column :jazari_runbooks, :origin, :string
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Jazari
|
|
7
|
+
module Generators
|
|
8
|
+
# For hosts that already installed the tables. Schema changes are copied,
|
|
9
|
+
# never applied on the gem's own initiative, for the same reason install is
|
|
10
|
+
# explicit: a shared operations table changing shape inside someone else's
|
|
11
|
+
# `db:migrate` is not a change they agreed to.
|
|
12
|
+
class UpgradeGenerator < ::Rails::Generators::Base
|
|
13
|
+
include ::ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
desc "Copies the schema changes an already-installed host needs. PostgreSQL only."
|
|
18
|
+
|
|
19
|
+
def copy_migration
|
|
20
|
+
migration_template "add_jazari_runbook_origin.rb", "db/migrate/add_jazari_runbook_origin.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def report
|
|
24
|
+
say ""
|
|
25
|
+
say "jazari: upgrade migration copied. It is additive and nullable, so it"
|
|
26
|
+
say "is safe to run ahead of the code that writes the column."
|
|
27
|
+
say ""
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/jazari/operations.rb
CHANGED
|
@@ -23,11 +23,14 @@ module Jazari
|
|
|
23
23
|
default_value(recipe, target, last)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
# `origin` records WHY this row exists, for hosts that materialize runbooks
|
|
27
|
+
# themselves — a backfill, an import, a template. Leave it nil for an operator
|
|
28
|
+
# edit, which is the default because that is the ordinary case.
|
|
29
|
+
def customize(target:, expected_revision:, topic:, description:, checklist:, origin: nil)
|
|
27
30
|
writable!(target)
|
|
28
31
|
validate_document!(topic, description)
|
|
29
32
|
items = Checklist.normalize(checklist)
|
|
30
|
-
write(target, expected_revision) do |current|
|
|
33
|
+
write(target, expected_revision, origin: origin) do |current|
|
|
31
34
|
current.merge(topic: topic, description: description, checklist: items)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
@@ -112,25 +115,35 @@ module Jazari
|
|
|
112
115
|
end
|
|
113
116
|
private_class_method :writable!
|
|
114
117
|
|
|
115
|
-
|
|
118
|
+
# `origin` distinguishes REWRITING the procedure from PERFORMING it, so the
|
|
119
|
+
# sentinel is not cosmetic. Rewriting it (customize) restates why the row
|
|
120
|
+
# exists, and passing nil there is an operator claiming it as their own.
|
|
121
|
+
# Ticking an item is doing the work the row already describes, and must
|
|
122
|
+
# leave that claim alone — otherwise the first person to check a box
|
|
123
|
+
# silently converts a migration artifact into a deliberate divergence.
|
|
124
|
+
KEEP_ORIGIN = :keep
|
|
125
|
+
|
|
126
|
+
def write(target, expected_revision, origin: KEEP_ORIGIN)
|
|
116
127
|
record = find_runbook(target)
|
|
117
128
|
if record
|
|
118
129
|
record.with_lock do
|
|
119
130
|
verify_custom_revision!(record, expected_revision)
|
|
120
131
|
document = yield(current_document(record))
|
|
121
|
-
|
|
132
|
+
attributes = {
|
|
122
133
|
topic: document[:topic], description: document[:description],
|
|
123
134
|
checklist: store_items(document[:checklist])
|
|
124
|
-
|
|
135
|
+
}
|
|
136
|
+
attributes[:origin] = origin unless origin == KEEP_ORIGIN
|
|
137
|
+
record.update!(attributes)
|
|
125
138
|
end
|
|
126
139
|
else
|
|
127
|
-
create_custom(target, expected_revision) { |current| yield(current) }
|
|
140
|
+
create_custom(target, expected_revision, origin: origin) { |current| yield(current) }
|
|
128
141
|
end
|
|
129
142
|
resolve(target: target)
|
|
130
143
|
end
|
|
131
144
|
private_class_method :write
|
|
132
145
|
|
|
133
|
-
def create_custom(target, expected_revision)
|
|
146
|
+
def create_custom(target, expected_revision, origin: KEEP_ORIGIN)
|
|
134
147
|
verify_default_revision!(target, expected_revision)
|
|
135
148
|
recipe = RecipeRegistry.fetch(target.recipe_id)
|
|
136
149
|
document = yield(
|
|
@@ -141,7 +154,9 @@ module Jazari
|
|
|
141
154
|
runbookable: runbookable_for(target),
|
|
142
155
|
recipe_id: recipe.id,
|
|
143
156
|
topic: document[:topic], description: document[:description],
|
|
144
|
-
checklist: store_items(document[:checklist])
|
|
157
|
+
checklist: store_items(document[:checklist]),
|
|
158
|
+
# A brand-new row has no prior claim to keep, so the sentinel means nil.
|
|
159
|
+
origin: (origin == KEEP_ORIGIN ? nil : origin)
|
|
145
160
|
)
|
|
146
161
|
rescue ActiveRecord::RecordNotUnique
|
|
147
162
|
raise RevisionConflict, "another writer materialized this runbook first"
|
|
@@ -219,7 +234,7 @@ module Jazari
|
|
|
219
234
|
state: "custom", revision: record.lock_version, topic: record.topic,
|
|
220
235
|
description: record.description, checklist: stored_items(record.checklist),
|
|
221
236
|
target_reference: target.public_reference, recipe: recipe.provenance,
|
|
222
|
-
last_run: run_summary(last)
|
|
237
|
+
last_run: run_summary(last), origin: record.origin
|
|
223
238
|
)
|
|
224
239
|
end
|
|
225
240
|
private_class_method :custom_value
|
|
@@ -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
|
|
@@ -4,21 +4,35 @@ module Jazari
|
|
|
4
4
|
# Every operation returns this. Never an unsaved ActiveRecord object.
|
|
5
5
|
ResolvedRunbook = Data.define(
|
|
6
6
|
:state, :revision, :topic, :description, :checklist, :progress,
|
|
7
|
-
:target_reference, :recipe, :last_run
|
|
7
|
+
:target_reference, :recipe, :last_run, :origin
|
|
8
8
|
) do
|
|
9
9
|
def initialize(state:, revision:, topic:, description:, checklist:,
|
|
10
|
-
target_reference:, recipe:, last_run: nil)
|
|
10
|
+
target_reference:, recipe:, last_run: nil, origin: nil)
|
|
11
11
|
items = Checklist.freeze_items(checklist)
|
|
12
12
|
super(
|
|
13
13
|
state: state.freeze, revision: revision, topic: topic.freeze,
|
|
14
14
|
description: description.freeze, checklist: items,
|
|
15
15
|
progress: Checklist.progress(items).freeze,
|
|
16
16
|
target_reference: target_reference.freeze, recipe: recipe.freeze,
|
|
17
|
-
last_run: last_run.freeze
|
|
17
|
+
last_run: last_run.freeze, origin: origin&.freeze
|
|
18
18
|
)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def default? = state == "default"
|
|
22
22
|
def custom? = state == "custom"
|
|
23
|
+
|
|
24
|
+
# `custom?` answers "does a row exist", which is not the same question as
|
|
25
|
+
# "did someone decide this". A backfill materializes a row for every subject
|
|
26
|
+
# it touches, so a host reading `custom?` as divergence sees 100% divergence
|
|
27
|
+
# the morning after a migration and learns nothing from it.
|
|
28
|
+
#
|
|
29
|
+
# Comparing content against the canon does not separate them either: a
|
|
30
|
+
# backfilled runbook genuinely differs, because it carries the steps the
|
|
31
|
+
# subject actually had. Only provenance can, so only provenance is asked.
|
|
32
|
+
def inherited? = custom? && !origin.nil?
|
|
33
|
+
|
|
34
|
+
# Divergence someone chose: a row that exists, with nothing claiming to have
|
|
35
|
+
# manufactured it.
|
|
36
|
+
def diverged? = custom? && origin.nil?
|
|
23
37
|
end
|
|
24
38
|
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
|
|
@@ -103,9 +104,10 @@ module Jazari
|
|
|
103
104
|
|
|
104
105
|
def resolve(target:) = Operations.resolve(target: target)
|
|
105
106
|
|
|
106
|
-
def customize(target:, expected_revision:, topic:, description:, checklist:)
|
|
107
|
+
def customize(target:, expected_revision:, topic:, description:, checklist:, origin: nil)
|
|
107
108
|
Operations.customize(target: target, expected_revision: expected_revision,
|
|
108
|
-
topic: topic, description: description, checklist: checklist
|
|
109
|
+
topic: topic, description: description, checklist: checklist,
|
|
110
|
+
origin: origin)
|
|
109
111
|
end
|
|
110
112
|
|
|
111
113
|
def add_item(target:, expected_revision:, text:, required: true)
|
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
|
|
@@ -56,6 +56,8 @@ files:
|
|
|
56
56
|
- app/models/jazari/runbook.rb
|
|
57
57
|
- lib/generators/jazari/install/install_generator.rb
|
|
58
58
|
- lib/generators/jazari/install/templates/create_jazari_tables.rb
|
|
59
|
+
- lib/generators/jazari/upgrade/templates/add_jazari_runbook_origin.rb
|
|
60
|
+
- lib/generators/jazari/upgrade/upgrade_generator.rb
|
|
59
61
|
- lib/jazari.rb
|
|
60
62
|
- lib/jazari/anchors.rb
|
|
61
63
|
- lib/jazari/checklist.rb
|
|
@@ -65,6 +67,7 @@ files:
|
|
|
65
67
|
- lib/jazari/operations.rb
|
|
66
68
|
- lib/jazari/railtie.rb
|
|
67
69
|
- lib/jazari/recipe.rb
|
|
70
|
+
- lib/jazari/recipe_files.rb
|
|
68
71
|
- lib/jazari/recipe_registry.rb
|
|
69
72
|
- lib/jazari/resolved_runbook.rb
|
|
70
73
|
- lib/jazari/runs.rb
|