hecks 2.5.1 → 2.7.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bench/cli.rb +74 -0
  6. data/lib/hecks/bench/environment.rb +100 -0
  7. data/lib/hecks/bench/postgres_probe.rb +41 -0
  8. data/lib/hecks/bench/report.rb +116 -0
  9. data/lib/hecks/bench/ruby_runner.rb +151 -0
  10. data/lib/hecks/bench/run.rb +55 -0
  11. data/lib/hecks/bench/rust_runner.rb +165 -0
  12. data/lib/hecks/bench/stats.rb +75 -0
  13. data/lib/hecks/bench/suite.rb +172 -0
  14. data/lib/hecks/bench/workload.rb +137 -0
  15. data/lib/hecks/bench.rb +28 -0
  16. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  17. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  18. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  20. data/lib/hecks/bluebook/hexagon.rb +15 -7
  21. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  22. data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
  23. data/lib/hecks/bluebook/model_check.rb +21 -2
  24. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  25. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  26. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  27. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  28. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  29. data/lib/hecks/forms/value_object_shape.rb +1 -1
  30. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  31. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  32. data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
  33. data/lib/hecks/language/world/world.bluebook +32 -2
  34. data/lib/hecks/mcp_stdio_guard.rb +160 -0
  35. data/lib/hecks/ports/key_vault.rb +1 -1
  36. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  37. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  38. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  39. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  40. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  41. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  42. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  43. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  44. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  45. data/lib/hecks/ports/persistence.rb +4 -0
  46. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  47. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  48. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  49. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  50. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  51. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  52. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  53. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  54. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  55. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  56. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  57. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  58. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  59. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  60. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  61. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  62. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  63. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  64. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  65. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  66. data/lib/hecks/projections/deploy/preview.rb +103 -0
  67. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  68. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  69. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  70. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  71. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  72. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  73. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  74. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  75. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  76. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  77. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  78. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  79. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  80. data/lib/hecks/projections.rb +2 -0
  81. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  82. data/lib/hecks/release/gem_pin.rb +112 -0
  83. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  84. data/lib/hecks/runtime/loader.rb +26 -2
  85. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  86. data/lib/hecks/runtime/registry/verification.rb +4 -2
  87. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  88. data/lib/hecks/runtime/registry.rb +14 -9
  89. data/lib/hecks/runtime/tenant_check.rb +1 -1
  90. data/lib/hecks/vendoring/git_environment.rb +35 -0
  91. data/lib/hecks/vendoring/git_source.rb +102 -0
  92. data/lib/hecks/vendoring.rb +102 -0
  93. data/lib/hecks/version.rb +1 -1
  94. data/lib/hecks.rb +3 -1
  95. metadata +59 -2
@@ -0,0 +1,231 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module TemplateDiff
5
+ # Compares two loaded CloudFormation templates.
6
+ #
7
+ # Resources, parameters, outputs, conditions and mappings are matched
8
+ # by name, then compared property by property. A list of mappings that
9
+ # each carry a `Name`, `Id`, `Key` or similar field (container
10
+ # definitions, environment variables, tags, origins) is matched by that
11
+ # field, not by position, so inserting one entry reports one addition
12
+ # instead of a cascade of shifted values. Cache behaviors are matched
13
+ # the same way but their order is also checked, because CloudFront
14
+ # takes the first pattern that matches.
15
+ #
16
+ # Two values that differ only in how they are written (a number and
17
+ # the same digits as text, a `!Sub` with nothing but one `${Name}` and
18
+ # a `!Ref Name`) are equal after normalization or marked `cosmetic`.
19
+ module Comparer
20
+ module_function
21
+
22
+ Change = Struct.new(:path, :kind, :before, :after, :cosmetic, keyword_init: true)
23
+ Entity = Struct.new(:name, :type, :changes, :replaced, keyword_init: true)
24
+ SectionDiff = Struct.new(:added, :removed, :changed, keyword_init: true) do
25
+ # Tells whether nothing in the section differs.
26
+ #
27
+ # @return [Boolean] true when no entity was added, removed or changed
28
+ def empty? = added.empty? && removed.empty? && changed.empty?
29
+ end
30
+
31
+ ENTITY_SECTIONS = %w[Parameters Resources Outputs Conditions Mappings].freeze
32
+ IDENTITY_KEYS = %w[Name Id Key PolicyName PathPattern HeaderName ContainerName Sid Field].freeze
33
+ ORDERED_BY = %w[PathPattern].freeze
34
+
35
+ # Compares two templates.
36
+ #
37
+ # @param before [Hash{String => Object}] the template as `Loader.load` returns it
38
+ # @param after [Hash{String => Object}] the template to compare it with
39
+ # @return [Hash{String => SectionDiff, Array<Change>}] one `SectionDiff` per entity
40
+ # section
41
+ # that either template has, and `"Template"` mapped to the changes in every other
42
+ # top-level key such as `Description`
43
+ def compare(before, after)
44
+ before = normalize(before)
45
+ after = normalize(after)
46
+ diff = ENTITY_SECTIONS.each_with_object({}) do |section, sections|
47
+ next unless before.key?(section) || after.key?(section)
48
+
49
+ sections[section] = section_diff(section, before[section] || {}, after[section] || {})
50
+ end
51
+ diff["Template"] = template_changes(before, after)
52
+ diff
53
+ end
54
+
55
+ def normalize(value)
56
+ case value
57
+ when Hash then normalize_hash(value)
58
+ when Array then value.map { |item| normalize(item) }
59
+ else value
60
+ end
61
+ end
62
+ private_class_method :normalize
63
+
64
+ def normalize_hash(hash)
65
+ result = hash.to_h { |key, item| [key, key == "DependsOn" ? depends_on(item) : normalize(item)] }
66
+ substitution?(result) ? simplify_sub(result) : result
67
+ end
68
+ private_class_method :normalize_hash
69
+
70
+ def depends_on(value)
71
+ Array(value).map(&:to_s).sort
72
+ end
73
+ private_class_method :depends_on
74
+
75
+ def substitution?(hash)
76
+ hash.size == 1 && hash["Fn::Sub"].is_a?(String)
77
+ end
78
+ private_class_method :substitution?
79
+
80
+ # `!Sub "${Name}"` is `!Ref Name`, `!Sub "${A.B}"` is `!GetAtt A.B`, and a `!Sub` with no
81
+ # variable is the plain string.
82
+ def simplify_sub(hash)
83
+ text = hash["Fn::Sub"]
84
+ return text unless text.include?("${")
85
+
86
+ match = text.match(/\A\$\{([^}!]+)\}\z/)
87
+ return hash unless match
88
+
89
+ name = match[1]
90
+ name.include?(".") && !name.start_with?("AWS::") ? { "Fn::GetAtt" => name.split(".", 2) } : { "Ref" => name }
91
+ end
92
+ private_class_method :simplify_sub
93
+
94
+ def section_diff(section, before, after)
95
+ SectionDiff.new(
96
+ added: (after.keys - before.keys).sort.map { |name| [name, type_of(section, after[name])] },
97
+ removed: (before.keys - after.keys).sort.map { |name| [name, type_of(section, before[name])] },
98
+ changed: (before.keys & after.keys).sort.filter_map { |name| entity_diff(section, name, before[name], after[name]) }
99
+ )
100
+ end
101
+ private_class_method :section_diff
102
+
103
+ def type_of(section, entity)
104
+ entity.is_a?(Hash) && %w[Resources Parameters].include?(section) ? entity["Type"] : nil
105
+ end
106
+ private_class_method :type_of
107
+
108
+ def entity_diff(section, name, before, after)
109
+ return nil if before == after
110
+
111
+ changes = []
112
+ diff_values("", before, after, changes)
113
+ return nil if changes.empty?
114
+
115
+ replaced = section == "Resources" && before.is_a?(Hash) && after.is_a?(Hash) && before["Type"] != after["Type"]
116
+ Entity.new(name: name, type: type_of(section, after), changes: changes, replaced: replaced)
117
+ end
118
+ private_class_method :entity_diff
119
+
120
+ def template_changes(before, after)
121
+ changes = []
122
+ others = (before.keys | after.keys) - ENTITY_SECTIONS
123
+ others.sort.each { |key| diff_values(key, before[key], after[key], changes) unless before[key] == after[key] }
124
+ changes
125
+ end
126
+ private_class_method :template_changes
127
+
128
+ def diff_values(path, before, after, changes)
129
+ return if before == after
130
+
131
+ if before.is_a?(Hash) && after.is_a?(Hash)
132
+ diff_hashes(path, before, after, changes)
133
+ elsif before.is_a?(Array) && after.is_a?(Array)
134
+ diff_arrays(path, before, after, changes)
135
+ else
136
+ changes << Change.new(path: path, kind: :changed, before: before, after: after, cosmetic: same_text?(before, after))
137
+ end
138
+ end
139
+ private_class_method :diff_values
140
+
141
+ def diff_hashes(path, before, after, changes)
142
+ (before.keys | after.keys).sort.each do |key|
143
+ child = path.empty? ? key : "#{path}.#{key}"
144
+ if !after.key?(key)
145
+ changes << Change.new(path: child, kind: :removed, before: before[key])
146
+ elsif !before.key?(key)
147
+ changes << Change.new(path: child, kind: :added, after: after[key])
148
+ else
149
+ diff_values(child, before[key], after[key], changes)
150
+ end
151
+ end
152
+ end
153
+ private_class_method :diff_hashes
154
+
155
+ def diff_arrays(path, before, after, changes)
156
+ key = identity_key(before, after)
157
+ return diff_keyed(path, key, before, after, changes) if key
158
+ if before.sort_by(&:to_s) == after.sort_by(&:to_s)
159
+ return changes << Change.new(path: path, kind: :reordered, before: before, after: after,
160
+ cosmetic: true)
161
+ end
162
+
163
+ (0...[before.size, after.size].max).each do |index|
164
+ child = "#{path}[#{index}]"
165
+ if index >= after.size then changes << Change.new(path: child, kind: :removed, before: before[index])
166
+ elsif index >= before.size then changes << Change.new(path: child, kind: :added, after: after[index])
167
+ else diff_values(child, before[index], after[index], changes)
168
+ end
169
+ end
170
+ end
171
+ private_class_method :diff_arrays
172
+
173
+ def diff_keyed(path, key, before, after, changes)
174
+ left = before.to_h { |item| [item[key].to_s, item] }
175
+ right = after.to_h { |item| [item[key].to_s, item] }
176
+ (left.keys | right.keys).each do |name|
177
+ child = "#{path}[#{key}=#{name}]"
178
+ if !right.key?(name) then changes << Change.new(path: child, kind: :removed, before: left[name])
179
+ elsif !left.key?(name) then changes << Change.new(path: child, kind: :added, after: right[name])
180
+ else diff_values(child, left[name], right[name], changes)
181
+ end
182
+ end
183
+ check_order(path, key, left.keys, right.keys, changes)
184
+ end
185
+ private_class_method :diff_keyed
186
+
187
+ def check_order(path, key, left, right, changes)
188
+ return unless ORDERED_BY.include?(key)
189
+
190
+ common = left & right
191
+ return if left.select { |name| common.include?(name) } == right.select { |name| common.include?(name) }
192
+
193
+ changes << Change.new(path: path, kind: :reordered, before: left, after: right, cosmetic: false)
194
+ end
195
+ private_class_method :check_order
196
+
197
+ # A list of mappings is matched by a field only when every entry in both lists has it and
198
+ # no two entries share its value, so a match is never ambiguous.
199
+ def identity_key(before, after)
200
+ lists = [before, after]
201
+ return nil if lists.any? { |list| list.empty? || !list.all?(Hash) }
202
+
203
+ IDENTITY_KEYS.find do |key|
204
+ lists.all? do |list|
205
+ list.all? { |item| scalar?(item[key]) } && list.map do |item|
206
+ item[key].to_s
207
+ end.uniq.size == list.size
208
+ end
209
+ end
210
+ end
211
+ private_class_method :identity_key
212
+
213
+ def scalar?(value)
214
+ value.is_a?(String) || value.is_a?(Integer)
215
+ end
216
+ private_class_method :scalar?
217
+
218
+ def same_text?(before, after)
219
+ scalars = [before, after].all? do |value|
220
+ [String, Integer, Float, TrueClass, FalseClass].any? do |type|
221
+ value.is_a?(type)
222
+ end
223
+ end
224
+ scalars && before.to_s == after.to_s
225
+ end
226
+ private_class_method :same_text?
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,105 @@
1
+ require "psych"
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Deploy
6
+ module TemplateDiff
7
+ # Reads a CloudFormation template in YAML into plain Ruby data.
8
+ #
9
+ # CloudFormation's short forms (`!Ref Name`, `!GetAtt A.B`, `!Sub "..."`)
10
+ # are YAML tags that a stock loader refuses or drops. This loader walks
11
+ # the parsed document and turns each into the long form CloudFormation
12
+ # itself reads (`{"Ref" => "Name"}`, `{"Fn::GetAtt" => ["A", "B"]}`), so
13
+ # a template written with short forms and one written with long forms
14
+ # load to the same data. Comments and key order do not survive loading,
15
+ # which is what a comparison of two templates wants.
16
+ #
17
+ # Nothing here evaluates a template or contacts AWS.
18
+ module Loader
19
+ module_function
20
+
21
+ # Turns template text into data.
22
+ #
23
+ # @param text [String] a CloudFormation template written in YAML
24
+ # @return [Hash{String => Object}] the template, with intrinsic tags in their long form
25
+ # @raise [ArgumentError] if the text is not one YAML mapping, or uses an alias
26
+ def load(text)
27
+ document = Psych.parse(text)
28
+ raise ArgumentError, "the template is empty" unless document
29
+
30
+ value = convert(document.root)
31
+ raise ArgumentError, "a CloudFormation template is a mapping at the top level" unless value.is_a?(Hash)
32
+
33
+ value
34
+ rescue Psych::SyntaxError => e
35
+ raise ArgumentError, "the template is not valid YAML: #{e.message}"
36
+ end
37
+
38
+ # Reads a template from a file.
39
+ #
40
+ # @param path [String] the path of a YAML template
41
+ # @return [Hash{String => Object}] the template, as `load` returns it
42
+ # @raise [ArgumentError] if the file is missing or is not a template
43
+ def load_file(path)
44
+ raise ArgumentError, "#{path} does not exist" unless File.file?(path)
45
+
46
+ load(File.read(path))
47
+ rescue ArgumentError => e
48
+ raise if e.message.start_with?(path)
49
+
50
+ raise ArgumentError, "#{path}: #{e.message}"
51
+ end
52
+
53
+ def convert(node)
54
+ raise ArgumentError, "YAML aliases are not supported in a template" if node.is_a?(Psych::Nodes::Alias)
55
+
56
+ value =
57
+ case node
58
+ when Psych::Nodes::Mapping then mapping(node)
59
+ when Psych::Nodes::Sequence then node.children.map { |child| convert(child) }
60
+ else scalar(node)
61
+ end
62
+ intrinsic?(node.tag) ? intrinsic(node.tag, value) : value
63
+ end
64
+ private_class_method :convert
65
+
66
+ def mapping(node)
67
+ node.children.each_slice(2).to_h { |key, value| [convert(key).to_s, convert(value)] }
68
+ end
69
+ private_class_method :mapping
70
+
71
+ # A plain scalar is typed the way YAML types it (`8080` is a number, `true` a boolean); a
72
+ # quoted or block scalar is always text.
73
+ def scalar(node)
74
+ return node.value unless node.plain && node.tag.nil?
75
+
76
+ Psych::ScalarScanner.new(Psych::ClassLoader::Restricted.new([], [])).tokenize(node.value)
77
+ end
78
+ private_class_method :scalar
79
+
80
+ def intrinsic?(tag)
81
+ tag&.start_with?("!") && !tag.start_with?("!!")
82
+ end
83
+ private_class_method :intrinsic?
84
+
85
+ def intrinsic(tag, value)
86
+ name = tag.delete_prefix("!")
87
+ case name
88
+ when "Ref", "Condition" then { name => value }
89
+ when "GetAtt" then { "Fn::GetAtt" => get_att(value) }
90
+ else { "Fn::#{name}" => value }
91
+ end
92
+ end
93
+ private_class_method :intrinsic
94
+
95
+ # `!GetAtt A.B` is the short form of `["A", "B"]`; only the first dot separates the
96
+ # resource from the attribute, since an attribute may itself contain dots.
97
+ def get_att(value)
98
+ value.is_a?(String) ? value.split(".", 2) : value
99
+ end
100
+ private_class_method :get_att
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,173 @@
1
+ require "json"
2
+ require_relative "template_diff/loader"
3
+ require_relative "template_diff/comparer"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Deploy
8
+ # An offline comparison of two CloudFormation templates.
9
+ #
10
+ # It answers "what would change if this template replaced that one"
11
+ # from the two files alone, the offline half of checking that a
12
+ # generated template leaves a live stack untouched: when the generated
13
+ # template and the one already deployed differ in nothing but comments,
14
+ # ordering and spelling, a change set against the live stack should be
15
+ # empty too. It reads no AWS state and changes nothing.
16
+ #
17
+ # ## What it reports
18
+ #
19
+ # Resources, parameters, outputs, conditions and mappings that were
20
+ # added, removed or changed, by logical id, and for a changed one every
21
+ # property that differs, with the value before and after. Other
22
+ # top-level keys, such as `Description`, are compared as values.
23
+ # Comments and the order of keys do not count, and the short and long
24
+ # forms of an intrinsic (`!Ref Name`, `{"Ref": "Name"}`) are the same
25
+ # thing. A change to a resource's `Type` is marked as a replacement.
26
+ # Differences that are only in how a value is written are marked
27
+ # cosmetic and do not count as a difference unless `strict` is set.
28
+ #
29
+ # ## What it does not do
30
+ #
31
+ # It does not know which property changes CloudFormation applies in
32
+ # place and which replace the resource beyond a change of `Type`, so a
33
+ # changed immutable property is listed as a change, not flagged.
34
+ module TemplateDiff
35
+ module_function
36
+
37
+ Report = Struct.new(:sections, :strict, keyword_init: true) do
38
+ # Tells whether the two templates differ in a way that counts.
39
+ #
40
+ # @return [Boolean] false when every difference is cosmetic and the report is not strict
41
+ def different?
42
+ sections.any? do |name, diff|
43
+ if name == "Template"
44
+ counted(diff).any?
45
+ else
46
+ !diff.added.empty? || !diff.removed.empty? || diff.changed.any? { |entity| counted(entity.changes).any? }
47
+ end
48
+ end
49
+ end
50
+
51
+ # Keeps the changes that count towards a difference.
52
+ #
53
+ # @param changes [Array<Comparer::Change>] the changes to filter
54
+ # @return [Array<Comparer::Change>] every change, or only the non-cosmetic ones unless
55
+ # strict
56
+ def counted(changes) = strict ? changes : changes.reject(&:cosmetic)
57
+ end
58
+
59
+ # Compares two template texts.
60
+ #
61
+ # @param before [String] the first template, YAML
62
+ # @param after [String] the second template, YAML
63
+ # @param strict [Boolean] whether cosmetic differences count as differences
64
+ # @return [Report] the differences, by section
65
+ # @raise [ArgumentError] if either text is not a valid template
66
+ def diff(before, after, strict: false)
67
+ Report.new(sections: Comparer.compare(Loader.load(before), Loader.load(after)), strict: strict)
68
+ end
69
+
70
+ # Compares two template files.
71
+ #
72
+ # @param before_path [String] the path of the first template
73
+ # @param after_path [String] the path of the template to compare it with
74
+ # @param strict [Boolean] whether cosmetic differences count as differences
75
+ # @return [Report] the differences, by section
76
+ # @raise [ArgumentError] if either file is missing or is not a valid template
77
+ def diff_files(before_path, after_path, strict: false)
78
+ Report.new(
79
+ sections: Comparer.compare(Loader.load_file(before_path), Loader.load_file(after_path)),
80
+ strict: strict
81
+ )
82
+ end
83
+
84
+ # Writes a report as text for a person to read.
85
+ #
86
+ # @param report [Report] the differences to describe
87
+ # @return [String] one section per kind of entity, each entity on a `+`, `-` or `~` line,
88
+ # or `no differences` when nothing differs
89
+ def render(report)
90
+ lines = report.sections.flat_map do |name, diff|
91
+ name == "Template" ? template_lines(report, diff) : section_lines(report, name, diff)
92
+ end
93
+ return "no differences\n" if lines.empty?
94
+
95
+ note = report.different? ? [] : ["", "only cosmetic differences; --strict counts them"]
96
+ "#{(lines + note).join("\n")}\n"
97
+ end
98
+
99
+ # Writes a report as JSON for a program to read.
100
+ #
101
+ # @param report [Report] the differences to describe
102
+ # @return [String] a JSON document with `different` and one entry per section
103
+ def render_json(report)
104
+ sections = report.sections.to_h do |name, diff|
105
+ [name, name == "Template" ? diff.map { |change| change_hash(change) } : section_hash(diff)]
106
+ end
107
+ JSON.pretty_generate("different" => report.different?, "sections" => sections)
108
+ end
109
+
110
+ def template_lines(report, changes)
111
+ return [] if changes.empty?
112
+
113
+ ["Template"] + changes.flat_map { |change| change_lines(change, report, " ") }
114
+ end
115
+ private_class_method :template_lines
116
+
117
+ def section_lines(report, name, diff)
118
+ return [] if diff.empty?
119
+
120
+ [name] +
121
+ diff.added.map { |id, type| " + #{id}#{" (#{type})" if type}" } +
122
+ diff.removed.map { |id, type| " - #{id}#{" (#{type})" if type}" } +
123
+ diff.changed.flat_map { |entity| entity_lines(entity, report) }
124
+ end
125
+ private_class_method :section_lines
126
+
127
+ def entity_lines(entity, report)
128
+ head = " ~ #{entity.name}#{" (#{entity.type})" if entity.type}#{' REPLACEMENT: the type changed' if entity.replaced}"
129
+ [head] + entity.changes.flat_map { |change| change_lines(change, report, " ") }
130
+ end
131
+ private_class_method :entity_lines
132
+
133
+ def change_lines(change, report, indent)
134
+ marker = change.cosmetic && !report.strict ? " (cosmetic)" : ""
135
+ case change.kind
136
+ when :added then ["#{indent}+ #{change.path}: #{show(change.after)}"]
137
+ when :removed then ["#{indent}- #{change.path}: #{show(change.before)}"]
138
+ when :reordered then ["#{indent}~ #{change.path}: order #{show(change.before)} -> #{show(change.after)}#{marker}"]
139
+ else ["#{indent}~ #{change.path}: #{show(change.before)} -> #{show(change.after)}#{marker}"]
140
+ end
141
+ end
142
+ private_class_method :change_lines
143
+
144
+ def show(value)
145
+ text = value.is_a?(String) ? value.inspect : JSON.generate(value)
146
+ text.length > 140 ? "#{text[0, 137]}..." : text
147
+ end
148
+ private_class_method :show
149
+
150
+ def section_hash(diff)
151
+ {
152
+ "added" => diff.added.map { |id, type| { "id" => id, "type" => type }.compact },
153
+ "removed" => diff.removed.map { |id, type| { "id" => id, "type" => type }.compact },
154
+ "changed" => diff.changed.map { |entity| entity_hash(entity) }
155
+ }
156
+ end
157
+ private_class_method :section_hash
158
+
159
+ def entity_hash(entity)
160
+ changes = entity.changes.map { |change| change_hash(change) }
161
+ { "id" => entity.name, "type" => entity.type, "replaced" => entity.replaced, "changes" => changes }.compact
162
+ end
163
+ private_class_method :entity_hash
164
+
165
+ def change_hash(change)
166
+ { "path" => change.path, "kind" => change.kind.to_s, "before" => change.before, "after" => change.after,
167
+ "cosmetic" => change.cosmetic }.compact
168
+ end
169
+ private_class_method :change_hash
170
+ end
171
+ end
172
+ end
173
+ end
@@ -7,7 +7,7 @@ module Hecks
7
7
  # deployment declares apart from the domain (`Privacy::Marking`, or a
8
8
  # `has_phi(readable_by:)` chain in a `.hecksagon`) and handed in as
9
9
  # `options[:markings]`. A marking names an aggregate by its qualified
10
- # name (`"Lifeadelics::Registration"`) and a field by its dotted path
10
+ # name (`"Site::Registration"`) and a field by its dotted path
11
11
  # (`"attendee.medications"`). Its category is spoken as the acronym it
12
12
  # is, uppercased and never reworded, because the category vocabulary is
13
13
  # the deployment's own and open-ended.
@@ -36,3 +36,5 @@ require_relative "projections/glossary"
36
36
  require_relative "projections/deploy/shared"
37
37
  require_relative "projections/deploy/lambda"
38
38
  require_relative "projections/deploy/fargate"
39
+ require_relative "projections/deploy/smoke"
40
+ require_relative "projections/deploy/template_diff"
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Hecks
6
+ module Release
7
+ class GemPin
8
+ # Raised when a pin cannot be resolved or is refused.
9
+ class Error < StandardError; end
10
+
11
+ # The hecks releases RubyGems publishes, fetched on first use.
12
+ #
13
+ # `GemPin` takes any object answering `include?` and `newest_satisfying`,
14
+ # so a caller with no network, or a spec, hands it a stand-in and this
15
+ # class is never asked to dial out.
16
+ class PublishedVersions
17
+ URL = URI("https://rubygems.org/api/v1/versions/hecks.json")
18
+
19
+ # Says whether RubyGems publishes a version.
20
+ #
21
+ # @param version [String, Gem::Version] the version to look for
22
+ # @return [Boolean] true when it is in the published list, prereleases included
23
+ # @raise [GemPin::Error] if RubyGems cannot be reached or does not answer with the list
24
+ def include?(version)
25
+ versions.include?(Gem::Version.new(version))
26
+ end
27
+
28
+ # Finds the newest stable release a requirement allows.
29
+ #
30
+ # @param requirement [Gem::Requirement] the constraint to satisfy
31
+ # @return [Gem::Version, nil] the newest non-prerelease version that satisfies it, or nil
32
+ # @raise [GemPin::Error] if RubyGems cannot be reached or does not answer with the list
33
+ def newest_satisfying(requirement)
34
+ versions.reject(&:prerelease?).select { |version| requirement.satisfied_by?(version) }.max
35
+ end
36
+
37
+ private
38
+
39
+ def versions
40
+ @versions ||= fetch
41
+ end
42
+
43
+ def fetch
44
+ response = Net::HTTP.start(URL.host, URL.port, use_ssl: true, open_timeout: 10, read_timeout: 20) do |http|
45
+ http.get(URL.request_uri)
46
+ end
47
+ unless response.is_a?(Net::HTTPSuccess)
48
+ raise Error, "could not list published hecks versions (RubyGems answered #{response.code})"
49
+ end
50
+
51
+ JSON.parse(response.body).map { |entry| Gem::Version.new(entry.fetch("number")) }
52
+ rescue SocketError, Timeout::Error, SystemCallError => e
53
+ raise Error, "could not reach RubyGems to check the hecks version (#{e.class})"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end