eco-helpers 3.2.16 → 3.2.17
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 +117 -0
- data/eco-helpers.gemspec +2 -2
- data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
- data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
- data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
- data/lib/eco/api/usecases/graphql/compat.rb +5 -0
- data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
- data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
- data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
- data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
- data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
- data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
- data/lib/eco/api/usecases/graphql/samples/pages.rb +2 -0
- data/lib/eco/version.rb +1 -1
- metadata +34 -5
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require 'csv'
|
|
2
|
+
|
|
3
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
4
|
+
module CsvBuild
|
|
5
|
+
# Parse a columnar CSV describing ONE template into an ordered, grouped intermediate structure
|
|
6
|
+
# (stages → sections → fields) that the `Builder` turns into a WorkflowCommand batch.
|
|
7
|
+
#
|
|
8
|
+
# Format specifics live ENTIRELY in `FormatMap` (see its TODO about the ~mid-July format). The
|
|
9
|
+
# parser only groups RowSpecs by their declared hierarchy while PRESERVING first-seen order, so
|
|
10
|
+
# the emitted command batch is deterministic and dependency-safe.
|
|
11
|
+
#
|
|
12
|
+
# parser = Parser.new(csv_string) # or Parser.from_file(path)
|
|
13
|
+
# tree = parser.tree
|
|
14
|
+
# tree # => [ { name:, ordering:, sections: [ { heading:, layout:, ordering:, fields: [...] } ] } ]
|
|
15
|
+
class Parser
|
|
16
|
+
# Parsed field, ready for the builder (already format-agnostic).
|
|
17
|
+
Field = Struct.new(:label, :type, :required, :column, :description, :options, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def self.from_file(path)
|
|
20
|
+
new(File.read(path))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param source [String] CSV text (with a header row).
|
|
24
|
+
def initialize(source)
|
|
25
|
+
@source = source.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The grouped hierarchy: stages (first-seen order) → sections → fields.
|
|
29
|
+
def tree
|
|
30
|
+
@tree ||= build_tree
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Flat list of RowSpecs (post FormatMap mapping), skipping blank / header-only rows.
|
|
34
|
+
def row_specs
|
|
35
|
+
@row_specs ||= rows.map { |row| FormatMap.row_spec(row) }.
|
|
36
|
+
reject { |rs| rs.stage.nil? && rs.field_label.nil? }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def rows
|
|
42
|
+
table = CSV.parse(@source, headers: true)
|
|
43
|
+
table.map(&:to_h)
|
|
44
|
+
rescue CSV::MalformedCSVError => e
|
|
45
|
+
raise ArgumentError, "malformed template CSV: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_tree
|
|
49
|
+
stages = {}
|
|
50
|
+
stage_order = []
|
|
51
|
+
section_order = Hash.new { |h, k| h[k] = [] }
|
|
52
|
+
|
|
53
|
+
row_specs.each do |rs|
|
|
54
|
+
next unless rs.stage # a field row must carry its stage (denormalised format)
|
|
55
|
+
|
|
56
|
+
stage = (stages[rs.stage] ||= new_stage(rs, stage_order))
|
|
57
|
+
section = ensure_section(stage, rs, section_order)
|
|
58
|
+
section[:fields] << build_field(rs) if rs.field_label
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
stage_order.map { |name| finalize_stage(stages[name]) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def new_stage(row_spec, stage_order)
|
|
65
|
+
stage_order << row_spec.stage
|
|
66
|
+
{ name: row_spec.stage, ordering: row_spec.stage_ordering, sections: {}, section_order: [] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ensure_section(stage, row_spec, _section_order)
|
|
70
|
+
heading = row_spec.section
|
|
71
|
+
key = heading.to_s
|
|
72
|
+
stage[:sections][key] ||= begin
|
|
73
|
+
stage[:section_order] << key
|
|
74
|
+
{ heading: heading, layout: row_spec.section_layout, ordering: row_spec.section_ordering, fields: [] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_field(row_spec)
|
|
79
|
+
Field.new(
|
|
80
|
+
label: row_spec.field_label,
|
|
81
|
+
type: row_spec.field_type,
|
|
82
|
+
required: row_spec.field_required,
|
|
83
|
+
column: row_spec.field_column,
|
|
84
|
+
description: row_spec.field_description,
|
|
85
|
+
options: row_spec.field_options
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def finalize_stage(stage)
|
|
90
|
+
{
|
|
91
|
+
name: stage[:name],
|
|
92
|
+
ordering: stage[:ordering],
|
|
93
|
+
sections: stage[:section_order].map { |key| stage[:sections][key] }
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
# CSV → template BUILD pipeline.
|
|
3
|
+
#
|
|
4
|
+
# Parse a columnar CSV describing a template (stages / sections / fields / types / options), emit the
|
|
5
|
+
# ordered `WorkflowCommand` batch via the EXISTING `Template::CommandEmitter`, and build it via the
|
|
6
|
+
# released gem's `Builder::Template#create(commands:)`. Offline / dry-run by default; live creation
|
|
7
|
+
# (`Builder#create!`) needs credentials.
|
|
8
|
+
#
|
|
9
|
+
# The exact ~300-template columnar CSV format is due ~mid-July 2026 and is isolated in
|
|
10
|
+
# `CsvBuild::FormatMap` (see its TODO) so only that file changes when the real format lands.
|
|
11
|
+
module CsvBuild
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative 'csv_build/format_map'
|
|
16
|
+
require_relative 'csv_build/parser'
|
|
17
|
+
require_relative 'csv_build/builder'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
module Deploy
|
|
3
|
+
# Thin, offline-safe wrapper around a gem `Diff::Deploy` plan.
|
|
4
|
+
#
|
|
5
|
+
# The gem's `Diff::Deploy` is INERT until `execute!` is called with an explicit executor — this
|
|
6
|
+
# applier keeps that safety and adds a **dry-run default**: nothing hits the API unless the caller
|
|
7
|
+
# opts in with `commit: true` AND supplies a live executor. The dry-run path records the batch it
|
|
8
|
+
# WOULD send (so the pre/post-diff and the specs can inspect it) and never calls the executor.
|
|
9
|
+
#
|
|
10
|
+
# An "executor" is anything responding to `execute_workflow_commands(model_or_id, commands:)` —
|
|
11
|
+
# in production the gem's `graphql.page` / `Builder::Template` update facade. For dry-run + specs a
|
|
12
|
+
# capturing stand-in is enough; see `Deploy::RecordingExecutor`.
|
|
13
|
+
#
|
|
14
|
+
# plan = Ecoportal::API::GraphQL::Diff::Deploy.from_versions(before, after, target_doc: prod)
|
|
15
|
+
# applier = Applier.new(plan, target: prod_template)
|
|
16
|
+
# outcome = applier.apply # dry-run: records commands, no API call
|
|
17
|
+
# outcome = applier.apply(commit: true, executor: graphql.page) # live apply
|
|
18
|
+
#
|
|
19
|
+
# outcome.committed? # => false for dry-run
|
|
20
|
+
# outcome.commands # => the ordered batch that was (or would be) sent
|
|
21
|
+
# outcome.unsupported # => [Change, ...] the plan could not synthesise (needs a human)
|
|
22
|
+
class Applier
|
|
23
|
+
# Result of an apply attempt. `committed?` is only true when the batch was actually sent live.
|
|
24
|
+
Outcome = Struct.new(:committed, :commands, :unsupported, :response, :skipped_reason,
|
|
25
|
+
keyword_init: true) do
|
|
26
|
+
def committed?
|
|
27
|
+
committed ? true : false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def applied?
|
|
31
|
+
committed? || dry_run?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dry_run?
|
|
35
|
+
!committed? && skipped_reason.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
committed: committed?,
|
|
41
|
+
commands: commands,
|
|
42
|
+
unsupported: Array(unsupported).map { |c| c.respond_to?(:to_h) ? c.to_h : c },
|
|
43
|
+
skipped_reason: skipped_reason
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :plan, :target
|
|
49
|
+
|
|
50
|
+
# @param plan [Ecoportal::API::GraphQL::Diff::Deploy] the gem deploy plan.
|
|
51
|
+
# @param target [#id, nil] the template/page model the batch applies to (needed for a live
|
|
52
|
+
# executor that reads id/patchVer). Optional for dry-run.
|
|
53
|
+
def initialize(plan, target: nil)
|
|
54
|
+
@plan = plan
|
|
55
|
+
@target = target
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Apply the plan's command batch.
|
|
59
|
+
#
|
|
60
|
+
# @param commit [Boolean] false (default) = dry-run, records the batch and returns without
|
|
61
|
+
# touching the API. true = live apply (requires an executor).
|
|
62
|
+
# @param executor [#execute_workflow_commands, nil] the live mutation facade. Ignored on dry-run.
|
|
63
|
+
# @param allow_partial [Boolean] forwarded to the gem plan: apply even with unsupported changes.
|
|
64
|
+
# @return [Outcome]
|
|
65
|
+
def apply(commit: false, executor: nil, allow_partial: false)
|
|
66
|
+
return blocked_outcome unless deployable?(allow_partial)
|
|
67
|
+
|
|
68
|
+
return dry_run_outcome unless commit
|
|
69
|
+
|
|
70
|
+
commit_outcome(executor, allow_partial: allow_partial)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The ordered command batch (delegates to the gem plan).
|
|
74
|
+
def commands
|
|
75
|
+
plan.commands
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Changes the gem plan could not synthesise — surfaced so a human gates the deploy.
|
|
79
|
+
def unsupported
|
|
80
|
+
plan.unsupported
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fully_supported?
|
|
84
|
+
plan.fully_supported?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# A deploy is refused (never silently no-ops into a live call) when it is not fully supported
|
|
90
|
+
# and the caller did not explicitly opt into a partial apply.
|
|
91
|
+
def deployable?(allow_partial)
|
|
92
|
+
fully_supported? || allow_partial
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def blocked_outcome
|
|
96
|
+
Outcome.new(
|
|
97
|
+
committed: false,
|
|
98
|
+
commands: [],
|
|
99
|
+
unsupported: unsupported,
|
|
100
|
+
skipped_reason: "#{unsupported.size} unsupported change(s); review before deploy"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def dry_run_outcome
|
|
105
|
+
Outcome.new(committed: false, commands: commands, unsupported: unsupported)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def commit_outcome(executor, allow_partial:)
|
|
109
|
+
raise ArgumentError, 'commit: true requires an executor (execute_workflow_commands)' unless executor
|
|
110
|
+
|
|
111
|
+
response = plan.execute!(executor_facade(executor), allow_partial: allow_partial)
|
|
112
|
+
Outcome.new(committed: true, commands: commands, unsupported: unsupported, response: response)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The gem plan calls `executor.execute_workflow_commands(commands)`. The gem's `Builder::Page`
|
|
116
|
+
# facade signature is `execute_workflow_commands(model, commands:)`, so bind the target model
|
|
117
|
+
# here. If the given executor already matches the plan's positional-commands contract, it is
|
|
118
|
+
# used as-is.
|
|
119
|
+
def executor_facade(executor)
|
|
120
|
+
return executor if executor.respond_to?(:arity_ok_for_positional_commands?)
|
|
121
|
+
|
|
122
|
+
TargetBoundExecutor.new(executor, target)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Adapts the gem `Builder::Page`-style facade (`execute_workflow_commands(model, commands:)`) to
|
|
126
|
+
# the positional-commands contract the gem `Diff::Deploy#execute!` expects.
|
|
127
|
+
class TargetBoundExecutor
|
|
128
|
+
def initialize(facade, target)
|
|
129
|
+
@facade = facade
|
|
130
|
+
@target = target
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def execute_workflow_commands(commands)
|
|
134
|
+
raise ArgumentError, 'a target model is required for a live apply' unless @target
|
|
135
|
+
|
|
136
|
+
@facade.execute_workflow_commands(@target, commands: commands)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
module Deploy
|
|
3
|
+
# Post-deploy verification: did the applied delta == the intended delta?
|
|
4
|
+
#
|
|
5
|
+
# After a deploy, re-read the target template and run a **self-version diff** (gem
|
|
6
|
+
# `Diff::VersionDiff`) between the PRE-deploy snapshot and the POST-deploy snapshot. That "applied
|
|
7
|
+
# delta" is then compared, op/kind/attribute-wise, against the "intended delta" (the changelog of
|
|
8
|
+
# the diff that produced the deploy batch).
|
|
9
|
+
#
|
|
10
|
+
# We compare on a normalised signature per change (op + kind + attribute + before/after) rather
|
|
11
|
+
# than on Mongo ids, because a cross-object deploy re-homes ids on the target — the SHAPE of the
|
|
12
|
+
# change is what must match. Any change present in one side but not the other is reported honestly
|
|
13
|
+
# as drift; nothing is hand-waved as "close enough".
|
|
14
|
+
#
|
|
15
|
+
# report = DriftReport.new(
|
|
16
|
+
# intended: source_diff, # the VersionDiff the deploy batch came from
|
|
17
|
+
# applied: Diff::VersionDiff.new(pre, post) # pre/post self-diff of the target
|
|
18
|
+
# )
|
|
19
|
+
# report.match? # => true when applied delta == intended delta (shape-wise)
|
|
20
|
+
# report.missing # => intended changes NOT observed on the target (under-applied)
|
|
21
|
+
# report.unexpected # => changes observed on the target that were NOT intended (over/side-effect)
|
|
22
|
+
# report.report # => human summary for a ticket / log
|
|
23
|
+
class DriftReport
|
|
24
|
+
attr_reader :intended, :applied
|
|
25
|
+
|
|
26
|
+
# @param intended [#changes] the source diff the deploy batch was synthesised from.
|
|
27
|
+
# @param applied [#changes] the pre/post self-version diff of the deployed target.
|
|
28
|
+
def initialize(intended:, applied:)
|
|
29
|
+
@intended = intended
|
|
30
|
+
@applied = applied
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Signatures present in the intended delta but missing from the applied delta.
|
|
34
|
+
def missing
|
|
35
|
+
@missing ||= (intended_signatures.keys - applied_signatures.keys).map { |sig| intended_signatures[sig] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Signatures present in the applied delta but not intended (side effects / over-apply).
|
|
39
|
+
def unexpected
|
|
40
|
+
@unexpected ||= (applied_signatures.keys - intended_signatures.keys).map { |sig| applied_signatures[sig] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True when the applied delta exactly matches the intended delta (no drift either way).
|
|
44
|
+
def match?
|
|
45
|
+
missing.empty? && unexpected.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
match: match?,
|
|
51
|
+
intended: intended_signatures.size,
|
|
52
|
+
applied: applied_signatures.size,
|
|
53
|
+
missing: missing.map { |c| describe(c) },
|
|
54
|
+
unexpected: unexpected.map { |c| describe(c) }
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def report
|
|
59
|
+
return 'DEPLOY VERIFY OK — applied delta matches intended delta.' if match?
|
|
60
|
+
|
|
61
|
+
lines = ['DEPLOY DRIFT detected:']
|
|
62
|
+
lines << " Missing (intended but not applied): #{missing.size}"
|
|
63
|
+
missing.each { |c| lines << " - #{describe(c)}" }
|
|
64
|
+
lines << " Unexpected (applied but not intended): #{unexpected.size}"
|
|
65
|
+
unexpected.each { |c| lines << " + #{describe(c)}" }
|
|
66
|
+
lines.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def intended_signatures
|
|
72
|
+
@intended_signatures ||= signatures(@intended)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def applied_signatures
|
|
76
|
+
@applied_signatures ||= signatures(@applied)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Index changes by a shape signature. Duplicate signatures (unlikely across a single template
|
|
80
|
+
# delta) collapse — acceptable for a drift verdict, which is set-membership, not count.
|
|
81
|
+
def signatures(diff)
|
|
82
|
+
Array(diff.changes).to_h do |change|
|
|
83
|
+
[signature(change), change]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# id-free shape signature: what changed, not which Mongo doc it landed on.
|
|
88
|
+
def signature(change)
|
|
89
|
+
[change.op, change.kind, change.attribute, norm(change.before), norm(change.after),
|
|
90
|
+
change.respond_to?(:label) ? change.label : nil]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def norm(value)
|
|
94
|
+
value.nil? ? nil : value
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def describe(change)
|
|
98
|
+
return change.description if change.respond_to?(:description)
|
|
99
|
+
|
|
100
|
+
change.to_h.inspect
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
module Deploy
|
|
3
|
+
# Deploy → verify → monitor orchestration (Phase 5).
|
|
4
|
+
#
|
|
5
|
+
# Ties the four offline-safe pieces into one pass:
|
|
6
|
+
# 1. APPLY — hand a gem `Diff::Deploy` plan to the `Applier` (dry-run by DEFAULT; live only
|
|
7
|
+
# behind an explicit `commit: true` + executor).
|
|
8
|
+
# 2. DRIFT — re-read the target and run a pre/post self-version diff, then compare the applied
|
|
9
|
+
# delta to the intended delta (`DriftReport`). Reports drift honestly.
|
|
10
|
+
# 3. VERIFY — run the post-deploy template doc through a `Verifier` (ecoportal-qa when present,
|
|
11
|
+
# else the null verifier).
|
|
12
|
+
# 4. MONITOR — optional sync-readiness scan of a register subset against the post-deploy template.
|
|
13
|
+
#
|
|
14
|
+
# Everything is OFFLINE against fixtures. The two seams that would touch a live API — applying the
|
|
15
|
+
# batch and re-reading the target — are injected as callables, so specs (and dry-runs) drive them
|
|
16
|
+
# with fixtures and a `RecordingExecutor`:
|
|
17
|
+
#
|
|
18
|
+
# loop = Deploy::Loop.new(
|
|
19
|
+
# plan: gem_deploy_plan,
|
|
20
|
+
# intended: source_version_diff, # the diff the plan was built from
|
|
21
|
+
# pre_doc: target_before_doc, # the target template BEFORE apply
|
|
22
|
+
# reread: -> { recording_executor.doc } # how to fetch the target AFTER apply (post-deploy doc)
|
|
23
|
+
# )
|
|
24
|
+
# result = loop.run(commit: false, executor: recording_executor, entries: register_entries)
|
|
25
|
+
#
|
|
26
|
+
# result.applied # => Applier::Outcome
|
|
27
|
+
# result.drift # => DriftReport (nil if pre_doc/reread not supplied)
|
|
28
|
+
# result.verdict # => Verifier::Verdict
|
|
29
|
+
# result.sync_readiness # => SyncReadiness (nil if no entries)
|
|
30
|
+
# result.ok? # => apply not blocked && drift match && verify pass
|
|
31
|
+
# result.report # => aggregate human summary
|
|
32
|
+
class Loop
|
|
33
|
+
# Aggregate outcome of one deploy→verify→monitor pass.
|
|
34
|
+
Result = Struct.new(:applied, :drift, :verdict, :sync_readiness, keyword_init: true) do
|
|
35
|
+
# Overall gate: the apply was not blocked, the drift (if checked) matched, and verify passed.
|
|
36
|
+
def ok?
|
|
37
|
+
applied_ok? && drift_ok? && verify_ok?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def applied_ok?
|
|
41
|
+
applied && applied.skipped_reason.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def drift_ok?
|
|
45
|
+
drift.nil? || drift.match?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def verify_ok?
|
|
49
|
+
verdict.nil? || verdict.pass?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
{
|
|
54
|
+
ok: ok?,
|
|
55
|
+
applied: applied&.to_h,
|
|
56
|
+
drift: drift&.to_h,
|
|
57
|
+
verdict: verdict&.to_h,
|
|
58
|
+
sync_readiness: sync_readiness&.to_h
|
|
59
|
+
}.compact
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def report
|
|
63
|
+
[
|
|
64
|
+
"DEPLOY LOOP: #{ok? ? 'OK' : 'ATTENTION NEEDED'}",
|
|
65
|
+
applied_line,
|
|
66
|
+
drift&.report,
|
|
67
|
+
verdict&.report,
|
|
68
|
+
sync_readiness&.report
|
|
69
|
+
].compact.join("\n\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def applied_line
|
|
75
|
+
if applied.skipped_reason
|
|
76
|
+
"APPLY: BLOCKED — #{applied.skipped_reason}"
|
|
77
|
+
elsif applied.committed?
|
|
78
|
+
"APPLY: committed #{applied.commands.size} command(s)"
|
|
79
|
+
else
|
|
80
|
+
"APPLY: dry-run — #{applied.commands.size} command(s) would be sent"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param plan [Ecoportal::API::GraphQL::Diff::Deploy] the gem deploy plan.
|
|
86
|
+
# @param intended [#changes, nil] the source diff the plan was built from (for drift check).
|
|
87
|
+
# @param pre_doc [Hash, nil] the target template doc BEFORE apply (for drift check).
|
|
88
|
+
# @param reread [#call, nil] returns the target template doc AFTER apply (for drift + verify).
|
|
89
|
+
# @param target [#id, nil] the target model, forwarded to the Applier for a live apply.
|
|
90
|
+
# @param verifier [Verifier, nil] injected verifier; defaults to Verifier.default.
|
|
91
|
+
# @param diff_builder [#call, nil] `diff_builder.call(pre_doc, post_doc)` returns the applied
|
|
92
|
+
# self-version diff (must expose `#changes`). Defaults to the gem's `Diff::VersionDiff` — which
|
|
93
|
+
# lives in the (currently unreleased) gem Diff module, so it is resolved LAZILY at drift time,
|
|
94
|
+
# never at load. Injectable so offline callers/specs can supply their own diff builder.
|
|
95
|
+
def initialize(plan:, intended: nil, pre_doc: nil, reread: nil, target: nil, verifier: nil,
|
|
96
|
+
diff_builder: nil)
|
|
97
|
+
@plan = plan
|
|
98
|
+
@intended = intended
|
|
99
|
+
@pre_doc = pre_doc
|
|
100
|
+
@reread = reread
|
|
101
|
+
@target = target
|
|
102
|
+
@verifier = verifier || Verifier.default
|
|
103
|
+
@diff_builder = diff_builder
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run the full pass.
|
|
107
|
+
#
|
|
108
|
+
# @param commit [Boolean] false = dry-run (default). true = live apply.
|
|
109
|
+
# @param executor [#execute_workflow_commands, nil] the mutation facade (live) or a
|
|
110
|
+
# RecordingExecutor (offline). Required for both commit AND for an offline drift check
|
|
111
|
+
# (the RecordingExecutor replays the batch to produce the post-deploy doc).
|
|
112
|
+
# @param allow_partial [Boolean] forwarded to the Applier / gem plan.
|
|
113
|
+
# @param entries [Array, nil] register subset for the sync-readiness monitor (optional).
|
|
114
|
+
# @return [Result]
|
|
115
|
+
def run(commit: false, executor: nil, allow_partial: false, entries: nil)
|
|
116
|
+
applied = Applier.new(@plan, target: @target).apply(
|
|
117
|
+
commit: commit, executor: executor, allow_partial: allow_partial
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
post_doc = applied.skipped_reason.nil? ? reread_post_doc : nil
|
|
121
|
+
drift = build_drift(post_doc)
|
|
122
|
+
verdict = post_doc ? @verifier.verify(post_doc) : nil
|
|
123
|
+
monitor = build_monitor(post_doc, entries)
|
|
124
|
+
|
|
125
|
+
Result.new(applied: applied, drift: drift, verdict: verdict, sync_readiness: monitor)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def reread_post_doc
|
|
131
|
+
@reread&.call
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_drift(post_doc)
|
|
135
|
+
return nil unless @intended && @pre_doc && post_doc
|
|
136
|
+
|
|
137
|
+
DriftReport.new(intended: @intended, applied: diff_builder.call(@pre_doc, post_doc))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The gem's Diff::VersionDiff is the default applied-diff builder. Resolved lazily so this file
|
|
141
|
+
# never forces the (unreleased) gem Diff module at load time.
|
|
142
|
+
def diff_builder
|
|
143
|
+
@diff_builder ||= lambda do |pre, post|
|
|
144
|
+
Ecoportal::API::GraphQL::Diff::VersionDiff.new(pre, post)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_monitor(post_doc, entries)
|
|
149
|
+
return nil if entries.nil? || Array(entries).empty?
|
|
150
|
+
|
|
151
|
+
SyncReadiness.new(template_doc: post_doc || @pre_doc, entries: entries)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
module Deploy
|
|
3
|
+
# Offline stand-in for a live workflow-command executor.
|
|
4
|
+
#
|
|
5
|
+
# Records every batch it is handed and (optionally) applies each command to an in-memory template
|
|
6
|
+
# doc via a caller-supplied `mutator`, so a deploy loop can compute a faithful POST-deploy snapshot
|
|
7
|
+
# WITHOUT any API call. This is what makes the pre/post self-version drift check runnable offline:
|
|
8
|
+
# the applied delta is derived from replaying the batch onto the pre-deploy doc.
|
|
9
|
+
#
|
|
10
|
+
# It satisfies BOTH executor contracts the gem may call:
|
|
11
|
+
# * `execute_workflow_commands(commands)` (gem Diff::Deploy#execute! positional)
|
|
12
|
+
# * `execute_workflow_commands(model, commands:)` (gem Builder::Page facade)
|
|
13
|
+
#
|
|
14
|
+
# rec = RecordingExecutor.new(doc: before_doc.dup, mutator: my_replayer)
|
|
15
|
+
# plan.execute!(rec)
|
|
16
|
+
# rec.batches # => [[cmd, cmd, ...]]
|
|
17
|
+
# rec.doc # => the mutated post-deploy doc (if a mutator was given)
|
|
18
|
+
class RecordingExecutor
|
|
19
|
+
attr_reader :batches, :doc
|
|
20
|
+
|
|
21
|
+
# @param doc [Hash, nil] a template/page doc to mutate as commands are replayed.
|
|
22
|
+
# @param mutator [#call, nil] `mutator.call(doc, command)` applies one command to `doc`
|
|
23
|
+
# in place (or returns the new doc). When nil, the doc is left untouched (record-only).
|
|
24
|
+
def initialize(doc: nil, mutator: nil)
|
|
25
|
+
@doc = doc
|
|
26
|
+
@mutator = mutator
|
|
27
|
+
@batches = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute_workflow_commands(model_or_commands, commands: nil)
|
|
31
|
+
batch = commands || model_or_commands
|
|
32
|
+
@batches << batch
|
|
33
|
+
replay(batch) if @mutator
|
|
34
|
+
record_response(batch)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Marker so the applier does not double-wrap this executor.
|
|
38
|
+
def arity_ok_for_positional_commands?
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Every command across all recorded batches, flattened, in order.
|
|
43
|
+
def all_commands
|
|
44
|
+
@batches.flatten(1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def replay(batch)
|
|
50
|
+
Array(batch).each { |command| @doc = @mutator.call(@doc, command) || @doc }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_response(batch)
|
|
54
|
+
{ recorded: batch.size }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|