hecks 3.4.0 → 3.4.2
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/lib/hecks/adapters/driven/memory/shared_elements.rb +241 -0
- data/lib/hecks/adapters/driven/memory.rb +12 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +123 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +15 -0
- data/lib/hecks/doors/cli_runner.rb +13 -4
- data/lib/hecks/doors/launcher_options.rb +14 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +98 -8
- data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
- data/lib/hecks/hecks/adapters/git.rb +29 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +5 -5
- data/lib/hecks/hecks/custodian.bluebook +11 -0
- data/lib/hecks/hecks/hecks.hecksagon +9 -0
- data/lib/hecks/hecks/hecks.world +2 -1
- data/lib/hecks/hecks/site.world +4 -2
- data/lib/hecks/ports/persistence/state_codec.rb +13 -0
- data/lib/hecks/projections/deploy/box/hosting.rb +3 -2
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +5 -2
- data/lib/hecks/projections/deploy/box.rb +20 -1
- data/lib/hecks/runtime/instance.rb +5 -2
- data/lib/hecks/runtime/value.rb +7 -0
- data/lib/hecks/site/bluebook/site.bluebook +6 -1
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d33b33e869c53f5b2c5825c2c9ec8a1a4a2b6dd6fad676b3e9403f314f4bf33
|
|
4
|
+
data.tar.gz: bc9b6f6509c6e31f6708254ba98e3d2346238ea6d487f25897a1ae5dac2968f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c53a6ea1c22f5f87116d1a9cf7f432a402cabeb1b2f8c7a9d199bc7e1adfc46bcb0f3d391435138f1d20423e10d1047fba5d673fc9d00087d36b44fa5218e956
|
|
7
|
+
data.tar.gz: a9945ea4ddb39bfb0876eecf5d2b699c34f77e565333b60435fe626d492b9befedbd0fc7aaa81c91b6c48cd74ace82d884e482a535697cd0c256eafd896ef924
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
require "objspace"
|
|
2
|
+
require_relative "../../../freezer"
|
|
3
|
+
require_relative "../../../ports/persistence/state_codec"
|
|
4
|
+
require_relative "../../../runtime/value"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Adapters
|
|
8
|
+
class Memory
|
|
9
|
+
# Lets a Memory save of a growing `list_of` copy and hydrate only the elements it has not
|
|
10
|
+
# seen. A hydrated composite element is frozen, so its codec copy (the journal form) and
|
|
11
|
+
# its hydrated form are each computed once, frozen, and shared by every journal entry and
|
|
12
|
+
# record that holds it. A single value-object attribute that holds a list, directly or
|
|
13
|
+
# through a nested value object, shares the same way: the hydrated `Runtime::Value` is
|
|
14
|
+
# copied once, and a later version of it reuses the copies of the list elements it still
|
|
15
|
+
# holds. Shared nodes are immutable, so a caller cannot reach the journal through a
|
|
16
|
+
# returned instance. Everything else in the state goes through `StateCodec` whole. The
|
|
17
|
+
# tables are weak and identity-keyed: a cache, never the only holder.
|
|
18
|
+
class SharedElements
|
|
19
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate whose state this shares elements of
|
|
20
|
+
def initialize(aggregate)
|
|
21
|
+
@aggregate = aggregate
|
|
22
|
+
reset!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Forgets every remembered element.
|
|
26
|
+
#
|
|
27
|
+
# @return [SharedElements] self, now empty
|
|
28
|
+
def reset!
|
|
29
|
+
@journal = ObjectSpace::WeakMap.new # live element => frozen decoded copy
|
|
30
|
+
@live = ObjectSpace::WeakMap.new # frozen decoded copy => hydrated element
|
|
31
|
+
@copies = ObjectSpace::WeakMap.new # frozen value-object copy this table built => true
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Copies state into the shape the journal holds: `StateCodec.copy`, with every
|
|
36
|
+
# shareable list element copied once and reused.
|
|
37
|
+
#
|
|
38
|
+
# @param state [Hash, nil] live state, such as `Instance#state`; nil for a delete entry
|
|
39
|
+
# @return [Hash{Symbol => Object}, nil] the decoded copy; its shareable list elements
|
|
40
|
+
# are frozen and shared with every other copy that holds them, nothing else is shared
|
|
41
|
+
# with `state`
|
|
42
|
+
def journal_state(state)
|
|
43
|
+
lists = shareable_lists(state)
|
|
44
|
+
values = journal_values(state)
|
|
45
|
+
return Ports::Persistence::StateCodec.copy(@aggregate, state) if lists.empty? && values.empty?
|
|
46
|
+
|
|
47
|
+
copied = Ports::Persistence::StateCodec.copy(@aggregate, placeholders(state, lists, values))
|
|
48
|
+
lists.each { |name, (attribute, elements)| copied[name] = journal_list(attribute, elements) }
|
|
49
|
+
values.each { |name, (attribute, value)| copied[name] = journal_value(attribute, value) }
|
|
50
|
+
copied
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Hydrates journal-shaped state into the state a record holds.
|
|
54
|
+
#
|
|
55
|
+
# @param decoded [Hash] a fresh state in the shape `journal_state` returns; its non-shared
|
|
56
|
+
# values are hydrated in place of copies, so it must not be a journal entry's own state
|
|
57
|
+
# @return [Hash{Symbol => Object}] hydrated state, defaults filled; the lists and value
|
|
58
|
+
# objects it shares are frozen, and nothing mutable is shared with `decoded`
|
|
59
|
+
def live_state(decoded)
|
|
60
|
+
lists = shareable_lists(decoded)
|
|
61
|
+
values = live_values(decoded)
|
|
62
|
+
hydrated = Runtime::Instance.hydrate_with_defaults(@aggregate, placeholders(decoded, lists, values))
|
|
63
|
+
lists.each { |name, (attribute, elements)| hydrated[name] = live_list(attribute, elements) }
|
|
64
|
+
values.each { |name, (attribute, copy)| hydrated[name] = live_value(attribute, copy) }
|
|
65
|
+
hydrated
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# The list attributes of `state` whose elements can be shared: composite, not a
|
|
71
|
+
# reference, and actually held as an Array.
|
|
72
|
+
def shareable_lists(state)
|
|
73
|
+
return {} unless state.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
composite_lists.each_with_object({}) do |(name, attribute), found|
|
|
76
|
+
elements = state[name]
|
|
77
|
+
found[name] = [attribute, elements] if elements.is_a?(Array)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def composite_lists = list_attributes(@aggregate)
|
|
82
|
+
|
|
83
|
+
def list_attributes(owner)
|
|
84
|
+
owner.attributes.each_with_object({}) do |attribute, found|
|
|
85
|
+
next unless attribute.list? && !attribute.reference? && composite?(attribute)
|
|
86
|
+
|
|
87
|
+
found[attribute.name.to_sym] = attribute
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The attributes of the aggregate that hold one value object able to hold a list, by
|
|
92
|
+
# name. A value object with no list in it is small and is left to the codec.
|
|
93
|
+
def growable_values
|
|
94
|
+
@growable_values ||= @aggregate.attributes.each_with_object({}) do |attribute, found|
|
|
95
|
+
next if attribute.list? || attribute.reference?
|
|
96
|
+
|
|
97
|
+
shape = value_object_of(attribute)
|
|
98
|
+
found[attribute.name.to_sym] = attribute if shape && growable?(shape, [])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def value_object_of(attribute)
|
|
103
|
+
return nil unless @aggregate.respond_to?(:value_object)
|
|
104
|
+
|
|
105
|
+
Runtime::Value.value_object_for(@aggregate, attribute.type.to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def growable?(shape, seen)
|
|
109
|
+
return false if seen.include?(shape)
|
|
110
|
+
|
|
111
|
+
shape.attributes.any? do |attribute|
|
|
112
|
+
next false if attribute.reference?
|
|
113
|
+
next true if attribute.list?
|
|
114
|
+
|
|
115
|
+
nested = value_object_of(attribute)
|
|
116
|
+
nested ? growable?(nested, seen + [shape]) : false
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The growable value-object attributes of `state` holding a frozen value of their type.
|
|
121
|
+
def journal_values(state)
|
|
122
|
+
return {} unless state.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
growable_values.each_with_object({}) do |(name, attribute), found|
|
|
125
|
+
value = state[name]
|
|
126
|
+
found[name] = [attribute, value] if value.is_a?(Runtime::Value) && sharable_value?(attribute, value)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sharable_value?(attribute, value)
|
|
131
|
+
value.frozen? && value.type_name == value_object_of(attribute).hecks_name
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The growable value-object attributes of `decoded` holding a frozen Hash: a copy this
|
|
135
|
+
# table made, or one a caller froze.
|
|
136
|
+
def live_values(decoded)
|
|
137
|
+
return {} unless decoded.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
growable_values.each_with_object({}) do |(name, attribute), found|
|
|
140
|
+
copy = decoded[name]
|
|
141
|
+
found[name] = [attribute, copy] if copy.is_a?(Hash) && copy.frozen?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def composite?(attribute)
|
|
146
|
+
type = attribute.type.to_s
|
|
147
|
+
!Runtime::Value.find_entity(@aggregate, type).nil? ||
|
|
148
|
+
(@aggregate.respond_to?(:value_object) && !Runtime::Value.value_object_for(@aggregate, type).nil?)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `state` with each shared list and value object replaced by nil, which the codec
|
|
152
|
+
# leaves alone and which holds its place in the key order.
|
|
153
|
+
def placeholders(state, lists, values)
|
|
154
|
+
return state if lists.empty? && values.empty?
|
|
155
|
+
|
|
156
|
+
state.merge(lists.merge(values).transform_values { nil })
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The frozen codec copy of one value object. A list it holds is rebuilt from the copies
|
|
160
|
+
# of its elements, so a new version of the value costs the new elements, not all of them.
|
|
161
|
+
def journal_value(attribute, value)
|
|
162
|
+
known = @journal[value]
|
|
163
|
+
return known if known
|
|
164
|
+
|
|
165
|
+
fields = value.raw_fields
|
|
166
|
+
lists = value_lists(attribute, fields)
|
|
167
|
+
held = fields.merge(lists.transform_values { nil })
|
|
168
|
+
copy = Ports::Persistence::StateCodec.copy_list_element(@aggregate, attribute, held)
|
|
169
|
+
copy.each_value { |inner| Freezer.deep(inner) }
|
|
170
|
+
lists.each { |name, (list_attribute, elements)| copy[name] = journal_list(list_attribute, elements) }
|
|
171
|
+
@copies[copy.freeze] = true
|
|
172
|
+
@journal[value] = copy
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def value_lists(attribute, fields)
|
|
176
|
+
list_attributes(value_object_of(attribute)).each_with_object({}) do |(name, list_attribute), found|
|
|
177
|
+
elements = fields[name]
|
|
178
|
+
found[name] = [list_attribute, elements] if elements.is_a?(Array)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The hydrated value for one frozen copy, its lists rebuilt from the hydrated elements
|
|
183
|
+
# already held.
|
|
184
|
+
def live_value(attribute, copy)
|
|
185
|
+
known = @live[copy]
|
|
186
|
+
return known if known
|
|
187
|
+
|
|
188
|
+
lists = value_lists(attribute, copy)
|
|
189
|
+
fields = copy.merge(lists.to_h { |name, (list_attribute, elements)| [name, live_list(list_attribute, elements)] })
|
|
190
|
+
live = Runtime::Value.trusting_stored_state do
|
|
191
|
+
Runtime::Value.for_attribute(@aggregate, attribute, fields)
|
|
192
|
+
end
|
|
193
|
+
return live unless @copies.key?(copy)
|
|
194
|
+
|
|
195
|
+
@journal[live] = copy
|
|
196
|
+
@live[copy] = live
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def journal_list(attribute, elements)
|
|
200
|
+
elements.map { |element| journal_element(attribute, element) }.freeze
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def journal_element(attribute, element)
|
|
204
|
+
known = @journal[element]
|
|
205
|
+
return known if known
|
|
206
|
+
return element if @live.key?(element)
|
|
207
|
+
|
|
208
|
+
copy = Ports::Persistence::StateCodec.copy_list_element(@aggregate, attribute, element)
|
|
209
|
+
return copy unless immutable?(element)
|
|
210
|
+
|
|
211
|
+
@journal[element] = Freezer.deep(copy)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def live_list(attribute, elements)
|
|
215
|
+
elements.map { |element| live_element(attribute, element) }.freeze
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def live_element(attribute, element)
|
|
219
|
+
known = @live[element]
|
|
220
|
+
return known if known
|
|
221
|
+
|
|
222
|
+
live = Runtime::Value.trusting_stored_state do
|
|
223
|
+
Runtime::Value.hydrate_entity_list(@aggregate, attribute, [element]).first
|
|
224
|
+
end
|
|
225
|
+
return live unless element.is_a?(Hash) && immutable?(element)
|
|
226
|
+
|
|
227
|
+
@journal[live] = element
|
|
228
|
+
@live[element] = live
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def immutable?(element)
|
|
232
|
+
case element
|
|
233
|
+
when Runtime::Value then element.frozen?
|
|
234
|
+
when Hash then Freezer.deeply_frozen?(element)
|
|
235
|
+
else false
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -2,6 +2,7 @@ require_relative "../../ports/persistence/append_only"
|
|
|
2
2
|
require_relative "../../ports/query/in_memory"
|
|
3
3
|
require_relative "in_memory_ordering"
|
|
4
4
|
require_relative "../../runtime/instance"
|
|
5
|
+
require_relative "memory/shared_elements"
|
|
5
6
|
|
|
6
7
|
module Hecks
|
|
7
8
|
module Adapters
|
|
@@ -30,6 +31,7 @@ module Hecks
|
|
|
30
31
|
# constructor shape and ignored
|
|
31
32
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
32
33
|
@aggregate = aggregate
|
|
34
|
+
@elements = SharedElements.new(aggregate)
|
|
33
35
|
@records = {}
|
|
34
36
|
@events = []
|
|
35
37
|
@entries = []
|
|
@@ -105,7 +107,7 @@ module Hecks
|
|
|
105
107
|
# @param instance [Runtime::Instance] the instance to store
|
|
106
108
|
# @return [Runtime::Instance] the stored record, a fresh instance over a copy of the state
|
|
107
109
|
def save(instance)
|
|
108
|
-
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state:
|
|
110
|
+
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state)
|
|
109
111
|
append(entry)
|
|
110
112
|
project(entry)
|
|
111
113
|
end
|
|
@@ -164,6 +166,7 @@ module Hecks
|
|
|
164
166
|
@entries = []
|
|
165
167
|
@outbox = []
|
|
166
168
|
@outbox_deliveries = {}
|
|
169
|
+
@elements.reset!
|
|
167
170
|
self
|
|
168
171
|
end
|
|
169
172
|
|
|
@@ -260,15 +263,20 @@ module Hecks
|
|
|
260
263
|
def copy(state)
|
|
261
264
|
return state.dup if bootstrap_fast_path?(state)
|
|
262
265
|
|
|
263
|
-
|
|
266
|
+
@elements.journal_state(state)
|
|
264
267
|
end
|
|
265
268
|
|
|
266
269
|
def build_instance(entry)
|
|
267
270
|
if bootstrap_fast_path?(entry.state)
|
|
268
271
|
Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state.dup, hydrate: false)
|
|
269
272
|
else
|
|
270
|
-
decoded =
|
|
271
|
-
|
|
273
|
+
decoded = @elements.journal_state(entry.state)
|
|
274
|
+
# The state is decoded by construction, so the boundary's re-walk of every shared
|
|
275
|
+
# list element would only repeat the copy that just built it.
|
|
276
|
+
Ports::Persistence::CodecBoundary.outside do
|
|
277
|
+
Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: decoded,
|
|
278
|
+
hydrate_with: @elements.method(:live_state))
|
|
279
|
+
end
|
|
272
280
|
end
|
|
273
281
|
end
|
|
274
282
|
end
|
|
@@ -770,6 +770,129 @@ Hecks.bluebook "Deploy" do
|
|
|
770
770
|
trigger CostCheck::Flag, with: { refusal: :refusal }
|
|
771
771
|
end
|
|
772
772
|
|
|
773
|
+
aggregate "SmokeRun" do
|
|
774
|
+
description "One post-deploy smoke: the generated smoke-after-deploy script run, and how it ended."
|
|
775
|
+
|
|
776
|
+
attribute :project, Location
|
|
777
|
+
attribute :script, Location, optional: true
|
|
778
|
+
attribute :run, RunKey
|
|
779
|
+
attribute :taskdef, TaskDefinition, optional: true
|
|
780
|
+
attribute :skip, Switch, optional: true
|
|
781
|
+
attribute :async, Switch, optional: true
|
|
782
|
+
attribute :dry_run, Switch, optional: true
|
|
783
|
+
attribute :report, Report, optional: true
|
|
784
|
+
attribute :refusal, Report, optional: true
|
|
785
|
+
identified_by :run
|
|
786
|
+
|
|
787
|
+
value_object "RunKey" do
|
|
788
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
789
|
+
|
|
790
|
+
invariant("a run is named") { !value.to_s.empty? }
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
value_object "Location" do
|
|
794
|
+
attribute :value, String, pattern: '[^\t\n\r]'
|
|
795
|
+
|
|
796
|
+
invariant("a project or script is named by its path") { !value.to_s.empty? }
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# A task definition family, or family:revision, as the generated script takes it in TASKDEF.
|
|
800
|
+
value_object "TaskDefinition" do
|
|
801
|
+
attribute :value, String, pattern: '^[A-Za-z0-9_-]+(:[0-9]+)?$'
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
value_object "Switch" do
|
|
805
|
+
attribute :value, Boolean
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
value_object "Report" do
|
|
809
|
+
attribute :value, String
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
lifecycle :status, default: "requested" do
|
|
813
|
+
transition "Pass" => "passed", from: "requested"
|
|
814
|
+
transition "Flag" => "flagged", from: "requested"
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# `hecks deploy smoke_run.run <project> [script=<path>] [taskdef=<family[:rev]>] [skip=true]
|
|
818
|
+
# [async=true] [dry_run=true]`. Finds the `smoke-after-deploy.sh` the project's AwsBox projection
|
|
819
|
+
# generated (`script=` names it when the project holds more than one) and runs it once a roll
|
|
820
|
+
# has settled: the script waits for the box, dispatches the smoke workflow and follows it.
|
|
821
|
+
# It only reads AWS; the smoke itself runs on GitHub. Exit 1 when it did not pass, with the
|
|
822
|
+
# script's own status (20 unsettled, 21 not run, 22 failed, 23 unknown) in the reason.
|
|
823
|
+
command "Run" do
|
|
824
|
+
goal "Run a project's generated post-deploy smoke and record whether the live site passed"
|
|
825
|
+
|
|
826
|
+
attribute :project, Location
|
|
827
|
+
attribute :script, Location, optional: true
|
|
828
|
+
attribute :run, RunKey
|
|
829
|
+
attribute :taskdef, TaskDefinition, optional: true
|
|
830
|
+
attribute :skip, Switch, optional: true
|
|
831
|
+
attribute :async, Switch, optional: true
|
|
832
|
+
attribute :dry_run, Switch, optional: true
|
|
833
|
+
|
|
834
|
+
sets :project
|
|
835
|
+
sets :script
|
|
836
|
+
sets :run
|
|
837
|
+
sets :taskdef
|
|
838
|
+
sets :skip
|
|
839
|
+
sets :async
|
|
840
|
+
sets :dry_run
|
|
841
|
+
|
|
842
|
+
emits "SmokeRequested"
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
command "Pass" do
|
|
846
|
+
goal "Record that the smoke passed, was skipped on purpose, or was left running, and what it said"
|
|
847
|
+
|
|
848
|
+
reference_to SmokeRun
|
|
849
|
+
attribute :report, Report
|
|
850
|
+
|
|
851
|
+
sets :report
|
|
852
|
+
|
|
853
|
+
emits "SmokePassed"
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
command "Flag" do
|
|
857
|
+
goal "Record that the smoke failed, did not run or could not be read, and what was said"
|
|
858
|
+
|
|
859
|
+
reference_to SmokeRun
|
|
860
|
+
attribute :refusal, Report
|
|
861
|
+
|
|
862
|
+
sets :refusal
|
|
863
|
+
|
|
864
|
+
emits "SmokeFlagged"
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
query "Verdict" do
|
|
868
|
+
description "What one smoke found: its status, and the report or the reason it was flagged."
|
|
869
|
+
attribute :run, RunKey
|
|
870
|
+
where(run: :run)
|
|
871
|
+
order_by :run
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
query "Flagged" do
|
|
875
|
+
description "Every smoke that failed, did not run or could not be read."
|
|
876
|
+
where(status: "flagged")
|
|
877
|
+
order_by :run
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
policy "SmokeWhenRequested" do
|
|
882
|
+
on "SmokeRun.SmokeRequested"
|
|
883
|
+
trigger SmokeRun::DeployToolchain::Smoke, with: { run: :run }
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
policy "RecordThePassedSmoke" do
|
|
887
|
+
on "SmokeRun.SmokeAnswered"
|
|
888
|
+
trigger SmokeRun::Pass, with: { report: :report }
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
policy "RecordTheFlaggedSmoke" do
|
|
892
|
+
on "SmokeRun.SmokeRefused"
|
|
893
|
+
trigger SmokeRun::Flag, with: { refusal: :refusal }
|
|
894
|
+
end
|
|
895
|
+
|
|
773
896
|
aggregate "TemplateComparison" do
|
|
774
897
|
description "One offline comparison of two CloudFormation templates, by logical id."
|
|
775
898
|
|
|
@@ -43,6 +43,14 @@ Hecks.hecksagon "Deploy" do
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
Deploy::SmokeRun.port "DeployToolchain" do
|
|
47
|
+
asks "Smoke", to: SmokeRun do
|
|
48
|
+
attribute :run, RunKey
|
|
49
|
+
answers "SmokeAnswered"
|
|
50
|
+
refuses "SmokeRefused"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
46
54
|
Deploy::TemplateComparison.port "DeployToolchain" do
|
|
47
55
|
asks "Compare", to: TemplateComparison do
|
|
48
56
|
attribute :run, RunKey
|
data/lib/hecks/deploy/oidc.json
CHANGED
|
@@ -76,6 +76,21 @@
|
|
|
76
76
|
"verb": "Deploy::Recipe.Project",
|
|
77
77
|
"role": null
|
|
78
78
|
},
|
|
79
|
+
{
|
|
80
|
+
"scope": "deploy:smoke_run.flag",
|
|
81
|
+
"verb": "Deploy::SmokeRun.Flag",
|
|
82
|
+
"role": null
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"scope": "deploy:smoke_run.pass",
|
|
86
|
+
"verb": "Deploy::SmokeRun.Pass",
|
|
87
|
+
"role": null
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"scope": "deploy:smoke_run.run",
|
|
91
|
+
"verb": "Deploy::SmokeRun.Run",
|
|
92
|
+
"role": null
|
|
93
|
+
},
|
|
79
94
|
{
|
|
80
95
|
"scope": "deploy:template_comparison.diff",
|
|
81
96
|
"verb": "Deploy::TemplateComparison.Diff",
|
|
@@ -251,13 +251,22 @@ module Hecks
|
|
|
251
251
|
events = runtime.events.select { |event| event.aggregate == fqn && event.id == handle.id }
|
|
252
252
|
answer = { id: handle.id, state: JsonDoor.materialize(state),
|
|
253
253
|
events: (events.empty? ? handle.events : events).map(&:name) }.merge(extra)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
failed = LauncherOptions.failed?(aggregate, state, launcher)
|
|
255
|
+
why += [failure_sentence(aggregate, state, aggregate.lifecycle.field)] if failed
|
|
256
|
+
return report_of(answer[:state], why) if LauncherOptions.report?(launcher, spec)
|
|
257
|
+
|
|
258
258
|
finish(JSON.pretty_generate(answer), why)
|
|
259
259
|
end
|
|
260
260
|
|
|
261
|
+
# The answer of a command the launcher prints as its report: the text it recorded, with
|
|
262
|
+
# the status 1 when the run failed, and nothing else.
|
|
263
|
+
def report_of(state, why)
|
|
264
|
+
text = state[why.empty? ? :output : :refusal]
|
|
265
|
+
text = text[:value] if text.is_a?(Hash)
|
|
266
|
+
text = why.join("\n") if text.to_s.strip.empty?
|
|
267
|
+
[text.to_s, why.empty? ? 0 : 1]
|
|
268
|
+
end
|
|
269
|
+
|
|
261
270
|
# The sentence for a record that ended in a failure state: the state, then the record's own
|
|
262
271
|
# `refusal` when it keeps one, without the class name an adapter's failure carries.
|
|
263
272
|
def failure_sentence(aggregate, state, field)
|
|
@@ -12,8 +12,9 @@ module Hecks
|
|
|
12
12
|
#
|
|
13
13
|
# - **run_keys** mints the `run` key of a creating command that was given none.
|
|
14
14
|
# - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
|
|
15
|
-
# - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait`
|
|
16
|
-
# given, so a script gets their outcome in its exit status.
|
|
15
|
+
# - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait`
|
|
16
|
+
# was given, so a script gets their outcome in its exit status.
|
|
17
|
+
# - **report** lists the settled commands whose answer is the report they recorded, as text.
|
|
17
18
|
# - **names** maps a launcher name to the command it stands for (see `CliProjector`).
|
|
18
19
|
# - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
|
|
19
20
|
module LauncherOptions
|
|
@@ -97,6 +98,17 @@ module Hecks
|
|
|
97
98
|
Array(launcher[:settled]).map(&:to_s).include?(spec[:short].to_s)
|
|
98
99
|
end
|
|
99
100
|
|
|
101
|
+
# Whether a command prints the report it recorded instead of its whole record.
|
|
102
|
+
#
|
|
103
|
+
# @param launcher [Hash, nil] the chapter's `launcher` setting
|
|
104
|
+
# @param spec [Hash] the command's projected spec
|
|
105
|
+
# @return [Boolean] true for a command the setting lists under `report`
|
|
106
|
+
def report?(launcher, spec)
|
|
107
|
+
return false unless launcher && spec[:kind] == :command
|
|
108
|
+
|
|
109
|
+
Array(launcher[:report]).map(&:to_s).include?(spec[:short].to_s)
|
|
110
|
+
end
|
|
111
|
+
|
|
100
112
|
# Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
|
|
101
113
|
# under `streams`, by the question's own name.
|
|
102
114
|
#
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "console_capture"
|
|
4
|
+
require_relative "shell"
|
|
4
5
|
require_relative "codebase/tree"
|
|
5
6
|
require_relative "codebase/ruby_child"
|
|
6
7
|
require "hecks/projections/deploy/template_diff"
|
|
@@ -12,8 +13,10 @@ module Hecks
|
|
|
12
13
|
#
|
|
13
14
|
# The recipe, the lint and the manifests run the `Hecks::Tools` tool that already does the work,
|
|
14
15
|
# in this process with its printing and exit status captured: a tool that ends non-zero is a
|
|
15
|
-
# refusal whose reason is what it printed.
|
|
16
|
-
# ask is refused with "needs a hecks checkout".
|
|
16
|
+
# refusal whose reason is what it printed. The lint and the manifests write into a hecks
|
|
17
|
+
# checkout, so outside one the ask is refused with "needs a hecks checkout". The recipe reads
|
|
18
|
+
# the project it is given, wherever that is, so it needs no checkout, and neither does a
|
|
19
|
+
# comparison.
|
|
17
20
|
class DeployToolchain
|
|
18
21
|
# The `Hecks::Tools` tool an ask runs, by ask.
|
|
19
22
|
SCRIPTS = { generate: "project_deploy", lint: "lint_deploy_recipes", manifest: "project_oidc" }.freeze
|
|
@@ -22,6 +25,13 @@ module Hecks
|
|
|
22
25
|
GENERATE_FLAGS = { "--tenant" => :tenant, "--schema" => :schema, "--out" => :out,
|
|
23
26
|
"--environment" => :environment }.freeze
|
|
24
27
|
|
|
28
|
+
# The file name the AwsBox projection gives the post-deploy smoke.
|
|
29
|
+
SMOKE_SCRIPT = "smoke-after-deploy.sh"
|
|
30
|
+
|
|
31
|
+
# What each status of `smoke-after-deploy.sh` means, for the reason a refusal gives.
|
|
32
|
+
SMOKE_STATUS = { 20 => "the roll did not settle", 21 => "gh missing or no repository, smoke not run",
|
|
33
|
+
22 => "the smoke failed", 23 => "the smoke's result is unknown" }.freeze
|
|
34
|
+
|
|
25
35
|
# Accepts the arguments every driven adapter is built with and keeps none of them.
|
|
26
36
|
#
|
|
27
37
|
# @param aggregate [Object, nil] unused
|
|
@@ -29,17 +39,29 @@ module Hecks
|
|
|
29
39
|
# @param root [String, nil] unused
|
|
30
40
|
def initialize(aggregate: nil, settings: {}, root: nil); end
|
|
31
41
|
|
|
32
|
-
# Writes a domain's deploy recipe
|
|
33
|
-
# `
|
|
42
|
+
# Writes a domain's deploy recipe from its declared `deployed_to` target, through
|
|
43
|
+
# `hecks deploy recipe.project`.
|
|
44
|
+
#
|
|
45
|
+
# The domain and `out` are paths relative to where the command runs, or absolute, so a
|
|
46
|
+
# project outside the hecks checkout (an installed gem has none) works. The Makefiles name
|
|
47
|
+
# the checkout as their root when the tree is one, else the directory the command runs in.
|
|
34
48
|
#
|
|
35
49
|
# @param held [Hash] the `Recipe` record: `domain`, and `tenant`, `schema`, `out` and
|
|
36
50
|
# `environment` when set
|
|
37
51
|
# @return [Hash{Symbol => Hash}] `output:` one line per file written
|
|
38
|
-
# @raise [ConsoleCapture::Failure] when the
|
|
39
|
-
#
|
|
52
|
+
# @raise [ConsoleCapture::Failure] when the generator refuses (no `deployed_to` target,
|
|
53
|
+
# conflicting settings, a schema with no tenant)
|
|
40
54
|
def generate(**held)
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
tree = Codebase::Tree.new
|
|
56
|
+
root = tree.checkout? ? tree.root : Dir.pwd
|
|
57
|
+
flags = GENERATE_FLAGS.filter_map do |flag, key|
|
|
58
|
+
value = plain(held[key])
|
|
59
|
+
next if value.nil?
|
|
60
|
+
|
|
61
|
+
"#{flag}=#{key == :out ? File.expand_path(value) : value}"
|
|
62
|
+
end
|
|
63
|
+
domain = project_path(plain(held[:domain]), root)
|
|
64
|
+
run(Codebase::Tree.new(root: root), :generate, [*flags, domain])
|
|
43
65
|
end
|
|
44
66
|
|
|
45
67
|
# Lints generated deploy Makefiles for prod-touching recipes that hide failure, through
|
|
@@ -86,17 +108,85 @@ module Hecks
|
|
|
86
108
|
raise ConsoleCapture::Failure, e.message
|
|
87
109
|
end
|
|
88
110
|
|
|
111
|
+
# Runs a project's generated `smoke-after-deploy.sh`, through `hecks deploy smoke_run.run`.
|
|
112
|
+
#
|
|
113
|
+
# The script is the one the AwsBox projection writes, found beside the Makefile or, failing
|
|
114
|
+
# that, the only one under the project (`script` names it otherwise). It waits for the roll
|
|
115
|
+
# to settle, then dispatches and follows the smoke workflow, and only ever reads AWS. Its
|
|
116
|
+
# options travel in the environment variables it documents.
|
|
117
|
+
#
|
|
118
|
+
# @param held [Hash] the `SmokeRun` record: `project`, and `script`, `taskdef`, `skip`,
|
|
119
|
+
# `async` and `dry_run` when set
|
|
120
|
+
# @return [Hash{Symbol => Hash}] `report:` what the script printed
|
|
121
|
+
# @raise [ConsoleCapture::Failure] when no script, or more than one, is found, or it ends
|
|
122
|
+
# non-zero; the message names its status and what it printed
|
|
123
|
+
def smoke(**held)
|
|
124
|
+
script = smoke_script(plain(held[:project]), plain(held[:script]))
|
|
125
|
+
|
|
126
|
+
result = Shell.new.capture("bash", script, env: smoke_env(held), chdir: File.dirname(script))
|
|
127
|
+
report = [result.out, result.err].map(&:strip).reject(&:empty?).join("\n")
|
|
128
|
+
return { report: { value: report } } if result.ok?
|
|
129
|
+
|
|
130
|
+
code = result.status.exitstatus
|
|
131
|
+
raise ConsoleCapture::Failure, "smoke ended #{code} (#{SMOKE_STATUS.fetch(code, 'unexpected')})\n#{report}"
|
|
132
|
+
end
|
|
133
|
+
|
|
89
134
|
private
|
|
90
135
|
|
|
136
|
+
# The script a project's smoke runs: the override, else `smoke-after-deploy.sh` in the project
|
|
137
|
+
# itself, else the only one beneath it (not under `node_modules`, `vendor` or `.git`).
|
|
138
|
+
def smoke_script(project, override)
|
|
139
|
+
return existing_script(override) if override
|
|
140
|
+
|
|
141
|
+
root = File.expand_path(project.to_s)
|
|
142
|
+
beside = File.join(root, SMOKE_SCRIPT)
|
|
143
|
+
return beside if File.file?(beside)
|
|
144
|
+
|
|
145
|
+
found = Dir.glob(File.join(root, "**", SMOKE_SCRIPT)).grep_v(%r{/(node_modules|vendor|\.git)/})
|
|
146
|
+
return found.first if found.one?
|
|
147
|
+
|
|
148
|
+
raise ConsoleCapture::Failure, ambiguity(root, found.sort)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ambiguity(root, found)
|
|
152
|
+
return "no #{SMOKE_SCRIPT} under #{root}; generate the AwsBox recipe or pass script=<path>" if found.empty?
|
|
153
|
+
|
|
154
|
+
"#{found.size} #{SMOKE_SCRIPT} files under #{root}; pass script=<path>: #{found.join(', ')}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def existing_script(path)
|
|
158
|
+
script = File.expand_path(path.to_s)
|
|
159
|
+
File.file?(script) ? script : raise(ConsoleCapture::Failure, "no such script: #{script}")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The variables the generated script reads, set only when the record asks for them.
|
|
163
|
+
def smoke_env(held)
|
|
164
|
+
{ "TASKDEF" => plain(held[:taskdef]), "SKIP_POST_DEPLOY_SMOKE" => flag(held[:skip]),
|
|
165
|
+
"SMOKE_ASYNC" => flag(held[:async]), "DRY_RUN" => flag(held[:dry_run]) }.compact
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def flag(argument) = plain(argument) == true ? "1" : nil
|
|
169
|
+
|
|
91
170
|
def child(ask, argv)
|
|
92
171
|
tree = Codebase::Tree.new
|
|
93
172
|
tree.require_checkout!
|
|
173
|
+
run(tree, ask, argv)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def run(tree, ask, argv)
|
|
94
177
|
result = Codebase::RubyChild.new(tree).capture(SCRIPTS.fetch(ask), *argv)
|
|
95
178
|
raise ConsoleCapture::Failure, message_of(result) unless result.ok?
|
|
96
179
|
|
|
97
180
|
{ output: { value: result.out } }
|
|
98
181
|
end
|
|
99
182
|
|
|
183
|
+
# A project path the tool can read from `root`: relative to it when it lies beneath it, so a
|
|
184
|
+
# recipe made in a checkout names `examples/pizzas`, else absolute.
|
|
185
|
+
def project_path(path, root)
|
|
186
|
+
absolute = File.expand_path(path)
|
|
187
|
+
absolute.start_with?("#{root}/") ? absolute.delete_prefix("#{root}/") : absolute
|
|
188
|
+
end
|
|
189
|
+
|
|
100
190
|
def message_of(result)
|
|
101
191
|
text = [result.err, result.out].map(&:strip).reject(&:empty?).join("\n")
|
|
102
192
|
text.empty? ? "the tool ended with status #{result.status.exitstatus}" : text
|
|
@@ -17,6 +17,13 @@ module Hecks
|
|
|
17
17
|
# The roles that count as an administrator besides the one the grant command is gated to.
|
|
18
18
|
OWNER_ROLES = %w[Owner].freeze
|
|
19
19
|
|
|
20
|
+
# What the refusal says when no chapter provides membership: the line to add to one.
|
|
21
|
+
NO_MEMBERSHIP = "this domain attaches no chapter that provides \"membership\". Add a line like " \
|
|
22
|
+
"`provides \"membership\", admit: \"Person.Admit\", grant: \"Person.GrantAccess\", " \
|
|
23
|
+
"people: \"Person.All\"` to the chapter that keeps who may sign in: admit and grant " \
|
|
24
|
+
"name its commands (the grant command is gated to the administrator role), people " \
|
|
25
|
+
"names the query that lists everyone (docs/running-a-rules-service.md)"
|
|
26
|
+
|
|
20
27
|
# What a bootstrap did.
|
|
21
28
|
#
|
|
22
29
|
# @!attribute [r] email
|
|
@@ -64,7 +71,7 @@ module Hecks
|
|
|
64
71
|
def membership_provider
|
|
65
72
|
domain = @registry.bluebooks.values.first&.name
|
|
66
73
|
@registry.membership_provider_for(domain) or
|
|
67
|
-
raise Runtime::NotFound,
|
|
74
|
+
raise Runtime::NotFound, NO_MEMBERSHIP
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
def verbs_of(provider)
|
|
@@ -79,6 +79,28 @@ module Hecks
|
|
|
79
79
|
raise Runtime::NotFound, e.message
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Prints the content digest and shape labels of one package, as its `bluebook.lock` records
|
|
83
|
+
# them: `Lock.digest_of` over the package's `bluebook/*.bluebook` files alone, and the labels
|
|
84
|
+
# `Shape.labels` gives the same files. The package is read from `<root>/<package>/bluebook`
|
|
85
|
+
# (a registry), else from `<root>/vendor/embryonaut_bluebooks/<package>/bluebook` (a project
|
|
86
|
+
# that vendors it).
|
|
87
|
+
#
|
|
88
|
+
# @param held [Hash] the `Registry` query's arguments: `package`, and `root` (the current
|
|
89
|
+
# directory when absent)
|
|
90
|
+
# @return [Hash{Symbol => String}] `text:` a `digest:` line, then a `shape:` line each
|
|
91
|
+
# @raise [Runtime::NotFound] when the package has no `bluebook/` with `*.bluebook` files, or
|
|
92
|
+
# they do not load
|
|
93
|
+
def digest(**held)
|
|
94
|
+
name = plain(held[:package])
|
|
95
|
+
dir = package_bluebook_dir(File.expand_path(plain(held[:root]) || Dir.pwd), name)
|
|
96
|
+
digest = EmbryonautBluebook::Lock.digest_of(dir) or
|
|
97
|
+
raise Runtime::NotFound, "#{dir} holds no *.bluebook files"
|
|
98
|
+
lines = ["digest: #{digest}", *EmbryonautBluebook::Shape.labels(dir).map { |label| "shape: #{label}" }]
|
|
99
|
+
{ text: "#{lines.join("\n")}\n" }
|
|
100
|
+
rescue Vendoring::Error => e
|
|
101
|
+
raise Runtime::NotFound, e.message
|
|
102
|
+
end
|
|
103
|
+
|
|
82
104
|
# Judges every package of a bluebook registry against its latest release, through
|
|
83
105
|
# `Hecks::EmbryonautBluebook::Registry#check`.
|
|
84
106
|
#
|
|
@@ -136,6 +158,13 @@ module Hecks
|
|
|
136
158
|
|
|
137
159
|
private
|
|
138
160
|
|
|
161
|
+
def package_bluebook_dir(root, name)
|
|
162
|
+
places = [File.join(name, "bluebook"), File.join("vendor", "embryonaut_bluebooks", name, "bluebook")]
|
|
163
|
+
found = places.map { |place| File.join(root, place) }.find { |dir| File.directory?(dir) }
|
|
164
|
+
found or raise Runtime::NotFound,
|
|
165
|
+
"#{name} has no bluebook/ directory in #{root} (looked in #{places.join(' and ')})"
|
|
166
|
+
end
|
|
167
|
+
|
|
139
168
|
def registry(held)
|
|
140
169
|
EmbryonautBluebook::Registry.new(File.expand_path(plain(held[:root]) || Dir.pwd))
|
|
141
170
|
end
|
|
@@ -55,13 +55,13 @@ module Hecks
|
|
|
55
55
|
# changed on either side.
|
|
56
56
|
#
|
|
57
57
|
# @param held [Hash] the `SiteProjection` record: `domain`, and `live` or `distribution`,
|
|
58
|
-
# with `
|
|
59
|
-
# comma separated) when set
|
|
58
|
+
# with `template` (stands in for the Edge row's own; never read), `expect_new` (path
|
|
59
|
+
# patterns, comma separated) and `refs` (`!Ref Name=id` words, comma separated) when set
|
|
60
60
|
# @return [Hash{Symbol => Hash}] `output:` the report of a distribution that matches
|
|
61
61
|
# @raise [ConsoleCapture::Failure] when the project has no edge, the live configuration
|
|
62
62
|
# cannot be read, or the two differ; the message is the report
|
|
63
63
|
def compare(**held)
|
|
64
|
-
edge = edge_of(located(:domain, held[:domain]))
|
|
64
|
+
edge = edge_of(located(:domain, held[:domain]), located(:template, held[:template]))
|
|
65
65
|
live = live_configuration(held)
|
|
66
66
|
live_edge = Projections::Site::LiveEdge
|
|
67
67
|
comparison = live_edge.new(edge, live, expect_new: words(held[:expect_new]),
|
|
@@ -74,7 +74,7 @@ module Hecks
|
|
|
74
74
|
private
|
|
75
75
|
|
|
76
76
|
# The project's checked edge, read the way `project_site` reads it.
|
|
77
|
-
def edge_of(root)
|
|
77
|
+
def edge_of(root, template = nil)
|
|
78
78
|
registry = nil
|
|
79
79
|
outcome = ConsoleCapture.capture { registry = Tools::SiteRoutes.registry_for(root) }
|
|
80
80
|
raise ConsoleCapture::Failure, outcome.output.strip unless outcome.ok?
|
|
@@ -82,7 +82,7 @@ module Hecks
|
|
|
82
82
|
site = Projections::Site
|
|
83
83
|
chapter = site::Table.chapter(registry)
|
|
84
84
|
table = site::Table.read(chapter, registry: registry)
|
|
85
|
-
site::Edge.read(chapter, table: table, vocabulary: site::Table.vocabulary(registry)) ||
|
|
85
|
+
site::Edge.read(chapter, table: table, template: template, vocabulary: site::Table.vocabulary(registry)) ||
|
|
86
86
|
raise(ConsoleCapture::Failure, "#{root} declares no Edge rows")
|
|
87
87
|
rescue Projections::Site::Table::Invalid => e
|
|
88
88
|
raise ConsoleCapture::Failure, e.message
|
|
@@ -1420,6 +1420,17 @@ policy "RecordTheEra" do
|
|
|
1420
1420
|
returns Document
|
|
1421
1421
|
end
|
|
1422
1422
|
|
|
1423
|
+
# `hecks package.digest <package> [root=path]`: a read of the package's files, so it keeps no
|
|
1424
|
+
# record. The digest is `Lock.digest_of` over `<package>/bluebook/*.bluebook` only (the
|
|
1425
|
+
# `bluebook.lock` digest line), then the package's shape labels; `root` is the registry, or a
|
|
1426
|
+
# project that vendors the package.
|
|
1427
|
+
query "Digest" do
|
|
1428
|
+
description "Print a package's content digest and shape labels, as its bluebook.lock records them."
|
|
1429
|
+
attribute :package, PackageName
|
|
1430
|
+
attribute :root, Location, optional: true
|
|
1431
|
+
returns Document
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1423
1434
|
query "Releasing" do
|
|
1424
1435
|
description "What releasing one run did: its status, and the report or the refusal."
|
|
1425
1436
|
attribute :run, RegistryKey
|
|
@@ -105,6 +105,7 @@ Hecks.hecksagon "Hecks" do
|
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
answers_query "Check"
|
|
108
|
+
answers_query "Digest"
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
Hecks::Door.port "Workspace" do
|
|
@@ -400,6 +401,14 @@ Hecks.hecksagon "Hecks" do
|
|
|
400
401
|
end
|
|
401
402
|
end
|
|
402
403
|
|
|
404
|
+
Deploy::SmokeRun.port "DeployToolchain" do
|
|
405
|
+
asks "Smoke", to: SmokeRun do
|
|
406
|
+
attribute :run, RunKey
|
|
407
|
+
answers "SmokeAnswered"
|
|
408
|
+
refuses "SmokeRefused"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
403
412
|
Deploy::TemplateComparison.port "DeployToolchain" do
|
|
404
413
|
asks "Compare", to: TemplateComparison do
|
|
405
414
|
attribute :run, RunKey
|
data/lib/hecks/hecks/hecks.world
CHANGED
|
@@ -40,9 +40,10 @@ Hecks.world "Hecks" do
|
|
|
40
40
|
failure_states: %w[flagged failed drifted unreachable refused faulted halted stopped
|
|
41
41
|
abandoned red needs_fix],
|
|
42
42
|
settled: %w[package.vendor package.revendor package.release
|
|
43
|
-
operation.bootstrap_admin],
|
|
43
|
+
operation.bootstrap_admin smoke_run.run],
|
|
44
44
|
names: { "mcp" => "door.serve_mcp", "console" => "operation.open_console", "init" => "door.init",
|
|
45
45
|
"interview" => "door.interview", "package.check" => "registry.check",
|
|
46
|
+
"package.digest" => "registry.digest",
|
|
46
47
|
"package.release" => "registry.release" },
|
|
47
48
|
streams: %w[Follow],
|
|
48
49
|
executable: "exe/hecks",
|
data/lib/hecks/hecks/site.world
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
# `run_keys` mints the `run` key of a projection, and `--wait` reports a faulted projection (a
|
|
6
6
|
# routes.ts out of date under `--check`, or a route table the projector refused) as exit 1, and
|
|
7
7
|
# `settled` makes `site_projection.check_live` wait for its reactions without the flag, so a live
|
|
8
|
-
# distribution that differs from the project is an exit status.
|
|
8
|
+
# distribution that differs from the project is an exit status. `report` prints that command's
|
|
9
|
+
# answer as the report alone: the differences, or the line saying the distribution matches.
|
|
9
10
|
Hecks.world "Site" do
|
|
10
11
|
launcher "Launcher", run_keys: true,
|
|
11
12
|
failure_states: %w[faulted refused halted stopped abandoned red needs_fix],
|
|
12
|
-
settled: %w[site_projection.check_live]
|
|
13
|
+
settled: %w[site_projection.check_live],
|
|
14
|
+
report: %w[site_projection.check_live]
|
|
13
15
|
end
|
|
@@ -46,6 +46,19 @@ module Hecks
|
|
|
46
46
|
# Array with `state`; nil when `state` is nil
|
|
47
47
|
def copy(aggregate, state) = decode(aggregate, encode(aggregate, state))
|
|
48
48
|
|
|
49
|
+
# Deep-copies one element of a `list_of` composite attribute, exactly as `copy` would
|
|
50
|
+
# copy it inside its list.
|
|
51
|
+
#
|
|
52
|
+
# @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct that declares
|
|
53
|
+
# the list attribute
|
|
54
|
+
# @param attribute [Bluebook::Attribute] a list attribute of `aggregate`
|
|
55
|
+
# @param element [Hash, Runtime::Value, Object] one element of the live list
|
|
56
|
+
# @return [Hash, Object] a new decoded Hash sharing nothing with `element`; a leaf
|
|
57
|
+
# element is returned as its JSON round trip
|
|
58
|
+
def copy_list_element(aggregate, attribute, element)
|
|
59
|
+
decode_composite(aggregate, attribute.type.to_s, encode_value(element))
|
|
60
|
+
end
|
|
61
|
+
|
|
49
62
|
# Maps every declared top-level field name to its attribute.
|
|
50
63
|
#
|
|
51
64
|
# An attribute that shares a name with the lifecycle field or a projected field keeps
|
|
@@ -33,8 +33,9 @@ module Hecks
|
|
|
33
33
|
# @param plan [Settings::Plan] the resolved settings
|
|
34
34
|
# @return [String] the Makefile fragment the box `Makefile` includes
|
|
35
35
|
def hosting_mk(plan)
|
|
36
|
-
Box.template("hosting.mk.tmpl", "SERVICE"
|
|
37
|
-
"URL"
|
|
36
|
+
Box.template("hosting.mk.tmpl", "SERVICE" => plan.containers.first.name,
|
|
37
|
+
"URL" => plan.hosting.public_url.to_s,
|
|
38
|
+
"SMOKE_RECIPE" => Box::SMOKE_RECIPE)
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# @param plan [Settings::Plan] the resolved settings
|
|
@@ -13,10 +13,13 @@ URL ?= @@URL@@
|
|
|
13
13
|
deploy-service:
|
|
14
14
|
bash ./deploy-service.sh $(SERVICE)
|
|
15
15
|
|
|
16
|
-
# Waits for the last roll to settle, then dispatches and follows the smoke.
|
|
16
|
+
# Waits for the last roll to settle, then dispatches and follows the smoke, as the smoke_run.run
|
|
17
|
+
# command, which runs smoke-after-deploy.sh and records how it ended in the Hecks database
|
|
18
|
+
# (HECKS_DATABASE, default postgres://hecks@localhost/hecks). Without that database the smoke still
|
|
19
|
+
# runs and its result is printed, and the target exits non-zero (24 if the smoke passed).
|
|
17
20
|
# SKIP_POST_DEPLOY_SMOKE=1 opts out, DRY_RUN=1 dispatches nothing.
|
|
18
21
|
smoke-after-deploy:
|
|
19
|
-
|
|
22
|
+
@@SMOKE_RECIPE@@
|
|
20
23
|
|
|
21
24
|
# Compares the era a live host reports at GET /version with expected-era.
|
|
22
25
|
check-era:
|
|
@@ -28,6 +28,25 @@ module Hecks
|
|
|
28
28
|
"# after adding one: the admin API is off, so a reload cannot reach it.\n" \
|
|
29
29
|
"import /etc/caddy/extra/*\n".freeze
|
|
30
30
|
|
|
31
|
+
# The `smoke-after-deploy` recipe: the smoke runs as `smoke_run.run`, which finds
|
|
32
|
+
# `smoke-after-deploy.sh` beside the Makefile and records how it ended in the Hecks
|
|
33
|
+
# database. When that database cannot be opened the command never starts, so the recipe
|
|
34
|
+
# runs the script itself, states the database error apart from the smoke's result, and
|
|
35
|
+
# exits non-zero (24 when the smoke passed).
|
|
36
|
+
SMOKE_RECIPE = [
|
|
37
|
+
"@err=$$(mktemp); \\",
|
|
38
|
+
'$(HECKS) deploy smoke_run.run project="$(CURDIR)" --wait $(if $(TASKDEF),taskdef=$(TASKDEF)) 2>"$$err"; rc=$$?; \\',
|
|
39
|
+
'if [ $$rc -ne 0 ] && grep -q \'^cannot open Hecks\' "$$err"; then \\',
|
|
40
|
+
' echo "==> the deploy record was NOT written: $$(grep -m1 \'^cannot open Hecks\' "$$err")" >&2; \\',
|
|
41
|
+
' echo " one-time setup on this machine: createdb hecks, or set HECKS_DATABASE to a Postgres URL" >&2; \\',
|
|
42
|
+
' echo " running the smoke anyway so its result is not lost" >&2; \\',
|
|
43
|
+
' rm -f "$$err"; TASKDEF="$(TASKDEF)" bash ./smoke-after-deploy.sh; rc=$$?; \\',
|
|
44
|
+
' echo "==> smoke exit $$rc. The deploy record was NOT written: the database is unavailable (see above)." >&2; \\',
|
|
45
|
+
" [ $$rc -ne 0 ] || rc=24; exit $$rc; \\",
|
|
46
|
+
"fi; \\",
|
|
47
|
+
'cat "$$err" >&2; rm -f "$$err"; exit $$rc'
|
|
48
|
+
].join("\n\t").freeze
|
|
49
|
+
|
|
31
50
|
module_function
|
|
32
51
|
|
|
33
52
|
# Generates `rds.yaml`, `box.yaml`, `Caddyfile`, `services.json`, `render-compose.sh`,
|
|
@@ -582,7 +601,7 @@ module Hecks
|
|
|
582
601
|
def hosting_makefile_tail(plan)
|
|
583
602
|
return "" unless plan.hosting
|
|
584
603
|
|
|
585
|
-
"\t
|
|
604
|
+
"\t$(MAKE) smoke-after-deploy\n\ninclude hosting.mk\n"
|
|
586
605
|
end
|
|
587
606
|
|
|
588
607
|
# Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
|
|
@@ -16,7 +16,10 @@ module Hecks
|
|
|
16
16
|
# `args` is the creation payload, used only to fill a composite identity's heads.
|
|
17
17
|
# `hydrate: false` skips the default re-walk for an already-hydrated, validated state,
|
|
18
18
|
# keeping a `list_of` entity's Nth save O(1) rather than O(N).
|
|
19
|
-
|
|
19
|
+
# `hydrate_with:` replaces the default hydration with a callable that takes the given
|
|
20
|
+
# state and answers the hydrated one (defaults filled), for an adapter that can reuse
|
|
21
|
+
# work across saves.
|
|
22
|
+
def initialize(aggregate:, id:, state: nil, args: nil, hydrate: true, hydrate_with: nil)
|
|
20
23
|
@aggregate = aggregate
|
|
21
24
|
@id = id
|
|
22
25
|
# Inside a persistence adapter call this refuses undecoded stored state.
|
|
@@ -24,7 +27,7 @@ module Hecks
|
|
|
24
27
|
@state = if !hydrate
|
|
25
28
|
state || self.class.defaults(aggregate)
|
|
26
29
|
elsif state
|
|
27
|
-
self.class.hydrate_with_defaults(aggregate, state)
|
|
30
|
+
hydrate_with ? hydrate_with.call(state) : self.class.hydrate_with_defaults(aggregate, state)
|
|
28
31
|
else
|
|
29
32
|
self.class.defaults(aggregate)
|
|
30
33
|
end
|
data/lib/hecks/runtime/value.rb
CHANGED
|
@@ -47,6 +47,13 @@ module Hecks
|
|
|
47
47
|
# @param field [String, Symbol] the field name; `:value` resolves the same way `[]` does
|
|
48
48
|
# @return [Boolean] true when the field is held
|
|
49
49
|
def key?(field) = @fields.key?(resolve_field(field))
|
|
50
|
+
|
|
51
|
+
# Reads the held fields as they are stored, without materializing nested values.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash{Symbol => Object}] the frozen, Symbol-keyed field Hash, in storage order;
|
|
54
|
+
# a nested `Runtime::Value` stays a `Runtime::Value`
|
|
55
|
+
def raw_fields = @fields
|
|
56
|
+
|
|
50
57
|
def to_h = @fields.transform_values { |value| self.class.materialize(value) }
|
|
51
58
|
|
|
52
59
|
# Renders this value object as JSON, through the same shape `to_h` builds.
|
|
@@ -186,14 +186,18 @@ Hecks.bluebook "Site" do
|
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
# `hecks site site_projection.check_live <project> live=<saved-config.json> | distribution=<id>
|
|
189
|
-
# [expect_new=<paths>] [refs=<!Ref Name=id,...>]`. Compares the project's generated CloudFront
|
|
189
|
+
# [template=<file>] [expect_new=<paths>] [refs=<!Ref Name=id,...>]`. Compares the project's generated CloudFront
|
|
190
190
|
# behaviours with a live distribution's, changing neither. `live` is a saved answer of
|
|
191
191
|
# `aws cloudfront get-distribution-config`; `distribution` fetches it with that one read-only call.
|
|
192
|
+
# `template` stands in for the `Edge` row's `template:` as it does for `project_site`; the check
|
|
193
|
+
# reads no template, so the file need not exist. It prints the report alone and exits 1 on a
|
|
194
|
+
# difference.
|
|
192
195
|
command "CheckLive" do
|
|
193
196
|
goal "Compare a project's generated CloudFront behaviours with a live distribution's"
|
|
194
197
|
|
|
195
198
|
attribute :domain, DomainPath
|
|
196
199
|
attribute :run, RunKey
|
|
200
|
+
attribute :template, Location, optional: true
|
|
197
201
|
attribute :live, Location, optional: true
|
|
198
202
|
attribute :distribution, DistributionId, optional: true
|
|
199
203
|
attribute :expect_new, PathList, optional: true
|
|
@@ -205,6 +209,7 @@ Hecks.bluebook "Site" do
|
|
|
205
209
|
|
|
206
210
|
sets :domain
|
|
207
211
|
sets :run
|
|
212
|
+
sets :template
|
|
208
213
|
sets :live
|
|
209
214
|
sets :distribution
|
|
210
215
|
sets :expect_new
|
data/lib/hecks/version.rb
CHANGED
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.4.
|
|
1
|
+
3.4.2
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.4.
|
|
4
|
+
version: 3.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- lib/hecks/adapters/driven/local_storage.rb
|
|
75
75
|
- lib/hecks/adapters/driven/memory.adapter
|
|
76
76
|
- lib/hecks/adapters/driven/memory.rb
|
|
77
|
+
- lib/hecks/adapters/driven/memory/shared_elements.rb
|
|
77
78
|
- lib/hecks/adapters/driven/mock_stripe_adapter.adapter
|
|
78
79
|
- lib/hecks/adapters/driven/mock_stripe_adapter.rb
|
|
79
80
|
- lib/hecks/adapters/driven/postgres.adapter
|