hecks 3.4.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b47e9f61f073e7c8512365b3bb5112f4e214c26cec46e08a323c6f02c509d555
4
- data.tar.gz: d84b642199556cc1b9bbcbc6635c2e248f3f4b63232bb0ca6abee589f85661ca
3
+ metadata.gz: 1d33b33e869c53f5b2c5825c2c9ec8a1a4a2b6dd6fad676b3e9403f314f4bf33
4
+ data.tar.gz: bc9b6f6509c6e31f6708254ba98e3d2346238ea6d487f25897a1ae5dac2968f0
5
5
  SHA512:
6
- metadata.gz: e2280fdb747d6d8a299bd58f4ab261e71a0d977ed805e9fcfe86ae108cc248724c96960614b9af6796d4617ef9102be1343107a91c6044123a97f298ba6002f1
7
- data.tar.gz: 6558a4e9698ca6e01a665ab3d9aa435da6990e20a69ef06b0c0cd91bc0edcedc771a976dc664c4501d56883ebca1435adb534d3516a20d8fd55dca3c90ff40ff
6
+ metadata.gz: c53a6ea1c22f5f87116d1a9cf7f432a402cabeb1b2f8c7a9d199bc7e1adfc46bcb0f3d391435138f1d20423e10d1047fba5d673fc9d00087d36b44fa5218e956
7
+ data.tar.gz: a9945ea4ddb39bfb0876eecf5d2b699c34f77e565333b60435fe626d492b9befedbd0fc7aaa81c91b6c48cd74ace82d884e482a535697cd0c256eafd896ef924
@@ -9,9 +9,12 @@ module Hecks
9
9
  # Lets a Memory save of a growing `list_of` copy and hydrate only the elements it has not
10
10
  # seen. A hydrated composite element is frozen, so its codec copy (the journal form) and
11
11
  # its hydrated form are each computed once, frozen, and shared by every journal entry and
12
- # record that holds it. Shared nodes are immutable, so a caller cannot reach the journal
13
- # through a returned instance. Everything else in the state goes through `StateCodec`
14
- # whole. Both tables are weak and identity-keyed: a cache, never the only holder.
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.
15
18
  class SharedElements
16
19
  # @param aggregate [Bluebook::Aggregate] the aggregate whose state this shares elements of
17
20
  def initialize(aggregate)
@@ -25,6 +28,7 @@ module Hecks
25
28
  def reset!
26
29
  @journal = ObjectSpace::WeakMap.new # live element => frozen decoded copy
27
30
  @live = ObjectSpace::WeakMap.new # frozen decoded copy => hydrated element
31
+ @copies = ObjectSpace::WeakMap.new # frozen value-object copy this table built => true
28
32
  self
29
33
  end
30
34
 
@@ -36,11 +40,13 @@ module Hecks
36
40
  # are frozen and shared with every other copy that holds them, nothing else is shared
37
41
  # with `state`
38
42
  def journal_state(state)
39
- lists = shareable_lists(state)
40
- return Ports::Persistence::StateCodec.copy(@aggregate, state) if lists.empty?
43
+ lists = shareable_lists(state)
44
+ values = journal_values(state)
45
+ return Ports::Persistence::StateCodec.copy(@aggregate, state) if lists.empty? && values.empty?
41
46
 
42
- copied = Ports::Persistence::StateCodec.copy(@aggregate, placeholders(state, lists))
47
+ copied = Ports::Persistence::StateCodec.copy(@aggregate, placeholders(state, lists, values))
43
48
  lists.each { |name, (attribute, elements)| copied[name] = journal_list(attribute, elements) }
49
+ values.each { |name, (attribute, value)| copied[name] = journal_value(attribute, value) }
44
50
  copied
45
51
  end
46
52
 
@@ -48,12 +54,14 @@ module Hecks
48
54
  #
49
55
  # @param decoded [Hash] a fresh state in the shape `journal_state` returns; its non-shared
50
56
  # values are hydrated in place of copies, so it must not be a journal entry's own state
51
- # @return [Hash{Symbol => Object}] hydrated state, defaults filled; the lists it shares
52
- # are frozen arrays of frozen elements, and nothing mutable is shared with `decoded`
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`
53
59
  def live_state(decoded)
54
- lists = shareable_lists(decoded)
55
- hydrated = Runtime::Instance.hydrate_with_defaults(@aggregate, placeholders(decoded, lists))
60
+ lists = shareable_lists(decoded)
61
+ values = live_values(decoded)
62
+ hydrated = Runtime::Instance.hydrate_with_defaults(@aggregate, placeholders(decoded, lists, values))
56
63
  lists.each { |name, (attribute, elements)| hydrated[name] = live_list(attribute, elements) }
64
+ values.each { |name, (attribute, copy)| hydrated[name] = live_value(attribute, copy) }
57
65
  hydrated
58
66
  end
59
67
 
@@ -70,26 +78,122 @@ module Hecks
70
78
  end
71
79
  end
72
80
 
73
- def composite_lists
74
- @composite_lists ||= @aggregate.attributes.each_with_object({}) do |attribute, found|
81
+ def composite_lists = list_attributes(@aggregate)
82
+
83
+ def list_attributes(owner)
84
+ owner.attributes.each_with_object({}) do |attribute, found|
75
85
  next unless attribute.list? && !attribute.reference? && composite?(attribute)
76
86
 
77
87
  found[attribute.name.to_sym] = attribute
78
88
  end
79
89
  end
80
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
+
81
145
  def composite?(attribute)
82
146
  type = attribute.type.to_s
83
147
  !Runtime::Value.find_entity(@aggregate, type).nil? ||
84
148
  (@aggregate.respond_to?(:value_object) && !Runtime::Value.value_object_for(@aggregate, type).nil?)
85
149
  end
86
150
 
87
- # `state` with each shared list replaced by nil, which the codec leaves alone and
88
- # which holds the list's place in the key order.
89
- def placeholders(state, lists)
90
- return state if lists.empty?
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)
91
194
 
92
- state.merge(lists.transform_values { nil })
195
+ @journal[live] = copy
196
+ @live[copy] = live
93
197
  end
94
198
 
95
199
  def journal_list(attribute, elements)
@@ -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
@@ -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
- if LauncherOptions.failed?(aggregate, state, launcher)
255
- field = aggregate.lifecycle.field
256
- why += [failure_sentence(aggregate, state, field)]
257
- end
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` was
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. They write into a hecks checkout, so outside one the
16
- # ask is refused with "needs a hecks checkout". A comparison needs no 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 (the template, scripts and Makefile) from its declared
33
- # `deployed_to` target, through `hecks deploy recipe.project`.
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 tree is not a checkout, or the generator refuses
39
- # (no `deployed_to` target, conflicting settings, a schema with no tenant)
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
- flags = GENERATE_FLAGS.filter_map { |flag, key| "#{flag}=#{plain(held[key])}" unless plain(held[key]).nil? }
42
- child(:generate, [*flags, plain(held[:domain])])
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, "this domain attaches no chapter that provides \"membership\""
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 `expect_new` (path patterns, comma separated) and `refs` (`!Ref Name=id` words,
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
@@ -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",
@@ -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
@@ -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" => plan.containers.first.name,
37
- "URL" => plan.hosting.public_url.to_s)
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
- bash ./smoke-after-deploy.sh
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#{'TASKDEF="$(TASKDEF)" ' if plan.task_definition}bash ./smoke-after-deploy.sh\n\ninclude hosting.mk\n"
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,
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.4.1".freeze
4
+ VERSION = "3.4.2".freeze
5
5
  end
@@ -1 +1 @@
1
- 3.4.1
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.1
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-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism