hecks 3.9.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  4. data/lib/hecks/deploy/oidc.json +16 -3
  5. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  6. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  8. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  9. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  10. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  11. data/lib/hecks/hecks/hecks.world +2 -1
  12. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  13. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  14. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  15. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  16. data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
  17. data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
  18. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  19. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  20. data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
  21. data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
  22. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
  23. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
  24. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
  25. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  26. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  27. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
  28. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
  29. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
  30. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
  31. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  32. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  33. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  34. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
  35. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  36. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
  37. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  38. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
  39. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
  40. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
  41. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
  42. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
  43. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
  44. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
  45. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
  46. data/lib/hecks/projections/site/cms_editor.rb +107 -0
  47. data/lib/hecks/projections/site/role_probe.rb +72 -0
  48. data/lib/hecks/site/bluebook/site.bluebook +28 -1
  49. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  50. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  51. data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
  52. data/lib/hecks/tools/site_routes.rb +14 -7
  53. data/lib/hecks/version.rb +1 -1
  54. data/prebuilt/{verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
  55. data/rust/Cargo.toml +3 -1
  56. data/rust/codegen/src/bridging.rs +1 -1
  57. data/rust/codegen/src/domain_generator.rs +9 -9
  58. data/rust/codegen/src/main.rs +1 -1
  59. data/rust/codegen/src/mutations.rs +46 -1
  60. data/rust/codegen/src/naming.rs +30 -0
  61. data/rust/host/HECKS_RELEASE +1 -1
  62. data/rust/src/kernel/expr.rs +7 -1
  63. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  64. metadata +41 -5
  65. /data/prebuilt/{6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
  66. /data/prebuilt/{0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d303c552c29899c933f31445d71ea42063a1b7cc5233cdef4a1d7392aa10201c
4
- data.tar.gz: caa19c0f1639d2ca386465977a1c099336ee2c1e598399ee3b88122206d3c2b7
3
+ metadata.gz: 5ab2b4b582c53edd23595354a8db9d84a3a8ec4572d044a77ccfea2ac113c073
4
+ data.tar.gz: 80af1db86bd574227fc6a03e7d19b9fa1ede9d4fbf54c4934da9211c67a892a2
5
5
  SHA512:
6
- metadata.gz: 3b2ee56f395214eee7e018c8ba28650b8f5f9ec97cc683b705037e19e37c96246fa4a380bbedb2aec9189ccb0361f3ef5f32e7af07cd5808531c1891e6e3011c
7
- data.tar.gz: deb31a80d9d10f30898074608b3158e2e916989160d69cb632777c14dae4305dd2fe43d5da8561024625c0e24175fab7fc7998469854e00f16561ef1e2dd59cf
6
+ metadata.gz: d28b07fb0b22a96aa8a2dbe2481010c668555a18f256572ee67dc95afb5c517d833cc749718ca804cac68a1ce34e5268e755347ad2d32668ec264d8519d81b08
7
+ data.tar.gz: 6e823533f842634ad3771ccb994feda57439c9f027aa189e0ff36ba049024fe3c845fd96e3543ce720cb96d6bcaf159604a1279db640f4137ac1e72677d436da
@@ -680,6 +680,112 @@ Hecks.bluebook "Deploy" do
680
680
  trigger Recipe::Fault, with: { refusal: :refusal }
681
681
  end
682
682
 
683
+ aggregate "Handover" do
684
+ description "One clearing of a domain's deployment settings: the body of every deployed_to block in its .world emptied, so a project handed to a client holds none of the host's own region, stacks, secrets or domain."
685
+
686
+ attribute :domain, DomainPath
687
+ attribute :run, RunKey
688
+ attribute :out, Location, optional: true
689
+ attribute :output, Report, optional: true
690
+ attribute :refusal, Report, optional: true
691
+ identified_by :run
692
+
693
+ value_object "RunKey" do
694
+ attribute :value, String, pattern: '[^ \t\n\r]'
695
+
696
+ invariant("a run is named") { !value.to_s.empty? }
697
+ end
698
+
699
+ # A domain directory, whose `bluebook/` holds the `.world` that declares `deployed_to`.
700
+ value_object "DomainPath" do
701
+ attribute :value, String, pattern: '[^\t\n\r]'
702
+
703
+ invariant("a domain is named by its path") { !value.to_s.empty? }
704
+ end
705
+
706
+ # A directory the cleared worlds are written to, at their paths below `bluebook/`, instead of
707
+ # over the originals.
708
+ value_object "Location" do
709
+ attribute :value, String, pattern: '[^\t\n\r]'
710
+ end
711
+
712
+ value_object "Report" do
713
+ attribute :value, String
714
+ end
715
+
716
+ lifecycle :status, default: "requested" do
717
+ transition "Complete" => "cleared", from: "requested"
718
+ transition "Fault" => "faulted", from: "requested"
719
+ end
720
+
721
+ # `hecks deploy handover.clear <domain> [out=]`. The rest of the file is left as it was, a world
722
+ # with nothing to clear is answered as cleared with nothing removed, and a domain with no
723
+ # `.world` file is faulted with the sentence saying so.
724
+ command "Clear" do
725
+ goal "Empty the body of every deployed_to block in a domain's .world, keeping the settings' names in a comment"
726
+
727
+ attribute :domain, DomainPath
728
+ attribute :run, RunKey
729
+ attribute :out, Location, optional: true
730
+
731
+ sets :domain
732
+ sets :run
733
+ sets :out
734
+
735
+ emits "ClearingRequested"
736
+ end
737
+
738
+ command "Complete" do
739
+ goal "Record that the deployment settings were cleared, from which file and which settings"
740
+
741
+ reference_to Handover
742
+ attribute :output, Report
743
+
744
+ sets :output
745
+
746
+ emits "DeploymentCleared"
747
+ end
748
+
749
+ command "Fault" do
750
+ goal "Record that the deployment settings could not be cleared, and why"
751
+
752
+ reference_to Handover
753
+ attribute :refusal, Report
754
+
755
+ sets :refusal
756
+
757
+ emits "ClearingFaulted"
758
+ end
759
+
760
+ query "Outcome" do
761
+ description "What one clearing did: its status, and the file and settings removed or the reason it stopped."
762
+ attribute :run, RunKey
763
+ where(run: :run)
764
+ order_by :run
765
+ end
766
+
767
+ query "Faulted" do
768
+ description "Every clearing that could not be done."
769
+ where(status: "faulted")
770
+ order_by :run
771
+ end
772
+ end
773
+
774
+ policy "ClearWhenRequested" do
775
+ on "Handover.ClearingRequested"
776
+ trigger Handover::DeployToolchain::ClearDeployment, with: { run: :run }
777
+ end
778
+
779
+ policy "RecordTheClearing" do
780
+ on "Handover.HandoverAnswered"
781
+ trigger Handover::Complete, with: { output: :output }
782
+ end
783
+
784
+ policy "RecordTheFaultedClearing" do
785
+ on "Handover.HandoverRefused"
786
+ trigger Handover::Fault, with: { refusal: :refusal }
787
+ end
788
+
683
789
  aggregate "MakefileCheck" do
684
790
  description "One lint of generated deploy Makefiles for prod-touching recipes that hide failure."
685
791
 
@@ -27,6 +27,14 @@ Hecks.hecksagon "Deploy" do
27
27
  end
28
28
  end
29
29
 
30
+ Deploy::Handover.port "DeployToolchain" do
31
+ asks "ClearDeployment", to: Handover do
32
+ attribute :run, RunKey
33
+ answers "HandoverAnswered"
34
+ refuses "HandoverRefused"
35
+ end
36
+ end
37
+
30
38
  Deploy::MakefileCheck.port "DeployToolchain" do
31
39
  asks "Scan", to: MakefileCheck do
32
40
  attribute :run, RunKey
@@ -156,6 +156,21 @@
156
156
  "verb": "Deploy::FargateTarget.Declare",
157
157
  "role": null
158
158
  },
159
+ {
160
+ "scope": "deploy:handover.clear",
161
+ "verb": "Deploy::Handover.Clear",
162
+ "role": null
163
+ },
164
+ {
165
+ "scope": "deploy:handover.complete",
166
+ "verb": "Deploy::Handover.Complete",
167
+ "role": null
168
+ },
169
+ {
170
+ "scope": "deploy:handover.fault",
171
+ "verb": "Deploy::Handover.Fault",
172
+ "role": null
173
+ },
159
174
  {
160
175
  "scope": "deploy:lambda_target.declare",
161
176
  "verb": "Deploy::LambdaTarget.Declare",
@@ -367,7 +382,5 @@
367
382
  "role": null
368
383
  }
369
384
  ],
370
- "roles": [
371
-
372
- ]
385
+ "roles": []
373
386
  }
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../console_capture"
5
+ require_relative "../world_clearing"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ class DeployToolchain
10
+ # The `ClearDeployment` ask: empties the `deployed_to` blocks of a domain's `.world` files, in
11
+ # place or into a directory, and says which files and settings it removed.
12
+ module Clearing
13
+ # Empties every `deployed_to` block under `<domain>/bluebook/**/*.world`, through
14
+ # `hecks deploy handover.clear`.
15
+ #
16
+ # With `out`, the cleared files are written there at their paths below `<domain>/bluebook/`
17
+ # and the originals are left alone; without it they are rewritten in place. A world with
18
+ # nothing left to clear is not rewritten and not listed.
19
+ #
20
+ # @param held [Hash] the `Handover` record: `domain`, and `out` when set
21
+ # @return [Hash{Symbol => Hash}] `output:` one line per file cleared with its settings
22
+ # @raise [ConsoleCapture::Failure] when the domain holds no `.world` file
23
+ def clear_deployment(**held)
24
+ bluebook = File.join(File.expand_path(plain(held[:domain])), "bluebook")
25
+ out = plain(held[:out])
26
+ lines = worlds_of(bluebook).filter_map { |path| clear_world(path, bluebook, out && File.expand_path(out)) }
27
+ { output: { value: lines.empty? ? "no deployed_to settings to clear" : lines.join("\n") } }
28
+ end
29
+
30
+ private
31
+
32
+ def worlds_of(bluebook)
33
+ worlds = Dir.glob(File.join(bluebook, "**", "*.world"))
34
+ raise ConsoleCapture::Failure, "#{bluebook} holds no .world file to clear" if worlds.empty?
35
+
36
+ worlds
37
+ end
38
+
39
+ def clear_world(path, bluebook, out)
40
+ cleared = WorldClearing.call(File.read(path))
41
+ return unless cleared.changed?
42
+
43
+ target = out ? File.join(out, path.delete_prefix("#{bluebook}/")) : path
44
+ FileUtils.mkdir_p(File.dirname(target))
45
+ File.write(target, cleared.text)
46
+ "#{target}: cleared #{cleared.settings.join(", ")}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -10,6 +10,7 @@ require_relative "deploy_plans"
10
10
  require_relative "deploy_toolchain/statuses"
11
11
  require_relative "deploy_toolchain/scripts"
12
12
  require_relative "deploy_toolchain/answers"
13
+ require_relative "deploy_toolchain/clearing"
13
14
 
14
15
  module Hecks
15
16
  module Adapters
@@ -26,6 +27,7 @@ module Hecks
26
27
  include Statuses
27
28
  include Scripts
28
29
  include Answers
30
+ include Clearing
29
31
 
30
32
  # Accepts the arguments every driven adapter is built with and keeps none of them.
31
33
  #
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require_relative "../../../projections/site/role_probe"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ class SiteToolchain
10
+ # Dispatches each role-declaring command to a host with no role and no actor, and reports
11
+ # whether the host refused the caller.
12
+ #
13
+ # A host that does not enforce roles would run the command, so this talks only to a host on
14
+ # this machine: the one a spec or a CI job started for the purpose.
15
+ class RoleProber
16
+ # The addresses that name this machine.
17
+ LOOPBACK = %w[localhost 127.0.0.1 ::1 [::1]].freeze
18
+
19
+ # Seconds to wait to connect to the host, and again for its answer.
20
+ TIMEOUT = 10
21
+
22
+ # The failures of the network that fail one check rather than the whole run.
23
+ NETWORK = [SystemCallError, Timeout::Error, SocketError, JSON::ParserError].freeze
24
+
25
+ # @param checks [Array<Projections::Site::RoleProbe::Check>] the commands to dispatch
26
+ # @param base [String] the host's scheme, host and port
27
+ # @raise [ConsoleCapture::Failure] when the host is not on this machine
28
+ def initialize(checks, base:)
29
+ @checks = checks
30
+ @uri = URI.parse(base)
31
+ return if LOOPBACK.include?(@uri.host)
32
+
33
+ raise ConsoleCapture::Failure, "roles are checked against a host on this machine, not #{@uri.host}: " \
34
+ "a host that does not enforce roles would run each command"
35
+ end
36
+
37
+ # @return [Array<(String, Integer)>] the report, one line per command ending in the count,
38
+ # and how many failed; a command whose synthesized arguments the host refused is counted
39
+ # as unchecked, not failed
40
+ def call
41
+ lines = @checks.map { |check| judged(check) }
42
+ failed = lines.count { |_, state| state == :failed }
43
+ unchecked = lines.count { |_, state| state == :unchecked }
44
+ summary = "#{lines.size} commands, #{failed} failed, #{unchecked} unchecked"
45
+ [lines.map(&:first).append(summary).join("\n"), failed]
46
+ end
47
+
48
+ private
49
+
50
+ def judged(check)
51
+ answer = answer_to(check)
52
+ return [line(check, "unchecked: the host refused the arguments before the role"), :unchecked] if unchecked?(answer)
53
+
54
+ why = Projections::Site::RoleProbe.verdict(check, answer)
55
+ [line(check, why ? "FAILED: #{why}" : "ok"), why ? :failed : :ok]
56
+ rescue *NETWORK => e
57
+ [line(check, "FAILED: #{e.class}: #{e.message}"), :failed]
58
+ end
59
+
60
+ def unchecked?(answer) = Projections::Site::RoleProbe.unchecked?(answer)
61
+
62
+ def line(check, outcome) = " #{check.verb} (#{check.role}) is refused with no role... #{outcome}"
63
+
64
+ def answer_to(check)
65
+ request = Net::HTTP::Post.new(URI.join(@uri.to_s, "/dispatch"), "Content-Type" => "application/json")
66
+ request.body = JSON.generate(verb: check.verb, with: check.arguments)
67
+ JSON.parse(connection.request(request).body)
68
+ end
69
+
70
+ def connection
71
+ Net::HTTP.new(@uri.host, @uri.port).tap do |http|
72
+ http.open_timeout = http.read_timeout = TIMEOUT
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -9,6 +9,7 @@ require "hecks/tools"
9
9
  require "hecks/tools/site_routes"
10
10
  require "hecks/projections/site/live_edge"
11
11
  require_relative "site_toolchain/prober"
12
+ require_relative "site_toolchain/role_prober"
12
13
 
13
14
  module Hecks
14
15
  module Adapters
@@ -23,7 +24,8 @@ module Hecks
23
24
  SCRIPTS = { project: "project_site" }.freeze
24
25
 
25
26
  # The tool's flag for each `SiteProjection` field that takes a value.
26
- FLAGS = { "out" => :out, "template" => :template, "cms" => :cms, "root" => :root, "extension" => :extension }.freeze
27
+ FLAGS = { "out" => :out, "template" => :template, "cms" => :cms, "root" => :root, "editor" => :editor,
28
+ "extension" => :extension }.freeze
27
29
 
28
30
  # Accepts the arguments every driven adapter is built with and keeps none of them.
29
31
  #
@@ -35,7 +37,7 @@ module Hecks
35
37
  # Writes a project's `routes.ts` from its declared route table, or with `check` only compares.
36
38
  #
37
39
  # @param held [Hash] the `SiteProjection` record: `domain`, and `out`, `template`, `cms`,
38
- # `root`, `extension` and `check` when set
40
+ # `root`, `editor`, `extension` and `check` when set
39
41
  # @return [Hash{Symbol => Hash}] `output:` one line per file written or found current
40
42
  # @raise [ConsoleCapture::Failure] when the route table is refused (an unknown cache class,
41
43
  # a duplicate path), a path or the extension is refused, or under `check` a file is out
@@ -84,6 +86,23 @@ module Hecks
84
86
  { output: { value: report } }
85
87
  end
86
88
 
89
+ # Dispatches every command a project declares a role for to a host with no role and no actor,
90
+ # and checks the host refuses each as `Unauthorized`. The host must be on this machine and
91
+ # enforcing roles (`HECKS_ROLE_ENFORCEMENT=enforce`); one that is not would run the commands.
92
+ #
93
+ # @param held [Hash] the `SiteProjection` record: `domain`, the project that declares the
94
+ # commands, and `url`, the host's base address
95
+ # @return [Hash{Symbol => Hash}] `output:` one line per command, ending in the count
96
+ # @raise [ConsoleCapture::Failure] when the host is not on this machine, or a command is not
97
+ # refused for its role; the message is the report
98
+ def roles(**held)
99
+ checks = Projections::Site::RoleProbe.checks(registry_of(located(:domain, held[:domain])))
100
+ report, failed = RoleProber.new(checks, base: base_url(plain(held[:url]))).call
101
+ raise ConsoleCapture::Failure, report if failed.positive?
102
+
103
+ { output: { value: report } }
104
+ end
105
+
87
106
  private
88
107
 
89
108
  # The scheme, host and port of an http(s) address.
@@ -121,12 +140,18 @@ module Hecks
121
140
  # The project's checked route table.
122
141
  def table_of(root) = read_table(root).last
123
142
 
124
- # The registry, chapter and checked table of the project at `root`.
125
- def read_table(root)
143
+ # The registry the project at `root` boots into.
144
+ def registry_of(root)
126
145
  registry = nil
127
146
  outcome = ConsoleCapture.capture { registry = Tools::SiteRoutes.registry_for(root) }
128
147
  raise ConsoleCapture::Failure, outcome.output.strip unless outcome.ok?
129
148
 
149
+ registry
150
+ end
151
+
152
+ # The registry, chapter and checked table of the project at `root`.
153
+ def read_table(root)
154
+ registry = registry_of(root)
130
155
  chapter = Projections::Site::Table.chapter(registry)
131
156
  [registry, chapter, Projections::Site::Table.read(chapter, registry: registry)]
132
157
  rescue Projections::Site::Table::Invalid => e
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Adapters
5
+ # Empties the `deployed_to(...) do ... end` blocks of a `.world` file's text: the region,
6
+ # stacks, secrets, domain and buckets a host deploys with, which a project handed to a client
7
+ # must not carry. Each block keeps its opening and closing lines and holds one comment naming
8
+ # the settings it held, so the file still loads and shows what to set.
9
+ #
10
+ # A block closes at the first `end` at its own indent, and a setting is a line one indent
11
+ # step inside it that starts with its name. A block that holds only comments is already clear
12
+ # and is left as it is, so clearing twice changes nothing the second time.
13
+ class WorldClearing
14
+ BLOCK = /^(?<indent>[ \t]*)deployed_to\b[^\n]*[ \t]do\b[^\n]*\n(?<body>.*?)^\k<indent>end\b[^\n]*\n/m
15
+
16
+ # What clearing one world's text produced.
17
+ #
18
+ # @!attribute [r] text
19
+ # @return [String] the text with every block emptied
20
+ # @!attribute [r] settings
21
+ # @return [Array<String>] the setting names removed, in order, without repeats
22
+ Result = Struct.new(:text, :settings) do
23
+ # @return [Boolean] whether any block was emptied
24
+ def changed? = !settings.empty?
25
+ end
26
+
27
+ # @param text [String] the contents of a `.world` file
28
+ # @return [Result] the cleared text and the settings that were in the blocks
29
+ def self.call(text)
30
+ names = []
31
+ cleared = text.gsub(BLOCK) { |block| empty(Regexp.last_match, block, names) }
32
+ Result.new(cleared, names.uniq)
33
+ end
34
+
35
+ # @param match [MatchData] one `deployed_to` block
36
+ # @param block [String] the block's text
37
+ # @param names [Array<String>] collects the setting names removed
38
+ # @return [String] the block's replacement
39
+ def self.empty(match, block, names)
40
+ indent = match[:indent]
41
+ held = match[:body].scan(/^#{indent} {2}([a-z_]\w*)/).flatten
42
+ return block if held.empty?
43
+
44
+ names.concat(held)
45
+ "#{block.lines.first}#{comment(indent, note(held.uniq))}#{indent}end\n"
46
+ end
47
+
48
+ # @param held [Array<String>] the setting names the block held
49
+ # @return [String] the comment that stands where the settings were
50
+ def self.note(held)
51
+ "The host's deployment settings were removed from this handoff. Set your own: #{held.join(", ")}."
52
+ end
53
+
54
+ # @param indent [String] the block's indent
55
+ # @param note [String] the sentence to put in the block
56
+ # @return [String] the sentence as comment lines inside the block, folded under 100 columns
57
+ def self.comment(indent, note)
58
+ width = 96 - indent.size
59
+ note.scan(/(.{1,#{width}})(?:\s+|\z)/).flatten.map { |line| "#{indent} # #{line}\n" }.join
60
+ end
61
+
62
+ private_class_method :empty, :note, :comment
63
+ end
64
+ end
65
+ end
@@ -407,6 +407,14 @@ Hecks.hecksagon "Hecks" do
407
407
  end
408
408
  end
409
409
 
410
+ Deploy::Handover.port "DeployToolchain" do
411
+ asks "ClearDeployment", to: Handover do
412
+ attribute :run, RunKey
413
+ answers "HandoverAnswered"
414
+ refuses "HandoverRefused"
415
+ end
416
+ end
417
+
410
418
  Deploy::MakefileCheck.port "DeployToolchain" do
411
419
  asks "Scan", to: MakefileCheck do
412
420
  attribute :run, RunKey
@@ -47,7 +47,8 @@ Hecks.world "Hecks" do
47
47
  service_roll.run box_roll.run data_copy.restore
48
48
  data_copy.verify bluebook_diff.run preview_run.name
49
49
  preview_run.url preview_run.list preview_run.deploy
50
- preview_run.destroy preview_run.login companion_roll.run],
50
+ preview_run.destroy preview_run.login companion_roll.run
51
+ handover.clear],
51
52
  names: { "mcp" => "door.serve_mcp", "console" => "operation.open_console", "init" => "door.init",
52
53
  "interview" => "door.interview", "package.check" => "registry.check",
53
54
  "package.digest" => "registry.digest",
@@ -38,6 +38,7 @@ module Hecks
38
38
  plan.domain_env = Check.map!(settings.fetch(:domain_env, {}), "domain_env", nil_ok: true)
39
39
  plan.install_dir = install_dir(settings.fetch(:install_dir, DEFAULT_INSTALL_DIR))
40
40
  plan.build_context_dir = build_context_dir(settings.fetch(:build_context_dir, ""))
41
+ plan.host_dir = host_dir(settings.fetch(:host_crate, DEFAULT_HOST_DIR))
41
42
  end
42
43
 
43
44
  def check_execution_grant!(plan)
@@ -62,6 +63,20 @@ module Hecks
62
63
  raise ArgumentError, "install_dir must be an absolute path without a trailing slash, got #{value.inspect}"
63
64
  end
64
65
 
66
+ # The directory of the Cargo package the Makefile builds `bootstrap` from: an absolute
67
+ # path, or one relative to the Makefile's `ROOT`. Only path characters are accepted,
68
+ # since the value is written into a Makefile that shell lines expand.
69
+ def host_dir(value)
70
+ text = value.to_s
71
+ return text if text == DEFAULT_HOST_DIR
72
+ return text if text.match?(%r{\A/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*\z})
73
+ return "$(ROOT)/#{text}" if text.match?(%r{\A[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*\z})
74
+
75
+ raise ArgumentError,
76
+ "host_crate must be a directory path of letters, digits, . _ - and /, absolute or relative to the " \
77
+ "Makefile's ROOT, got #{value.inspect}"
78
+ end
79
+
65
80
  def build_context_dir(value)
66
81
  text = value.to_s
67
82
  return text if text.empty? || text.match?(%r{\A[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*/\z})
@@ -19,7 +19,7 @@ module Hecks
19
19
  Plan = Struct.new(
20
20
  :ids, :names, :layout, :cdn, :alerts, :extras, :execute_command, :health_check_grace_period,
21
21
  :deregistration_delay, :desired_count_parameter, :domain_env, :install_dir, :build_context_dir,
22
- :db_name_parameter, :execution_database_grant,
22
+ :db_name_parameter, :execution_database_grant, :host_dir,
23
23
  keyword_init: true
24
24
  )
25
25
 
@@ -37,6 +37,8 @@ module Hecks
37
37
  }.freeze
38
38
  NAME_ROLES = [:cluster, :log_group, :service, :alb, :family, :alb_security_group_description, :db_secret_policy].freeze
39
39
  DEFAULT_INSTALL_DIR = "/usr/local/bin".freeze
40
+ # Where the generated Makefile builds the host binary unless `host_crate` names another.
41
+ DEFAULT_HOST_DIR = "$(ROOT)/rust/host".freeze
40
42
 
41
43
  extend Ids
42
44
  extend Tuning
@@ -65,6 +65,7 @@ module Hecks
65
65
  def vpc_ref = shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"
66
66
  def sidecar_dir = plan.install_dir
67
67
  def build_context_dir = plan.build_context_dir
68
+ def host_dir = plan.host_dir
68
69
  def cmd_binary = sidecar_dir == Settings::DEFAULT_INSTALL_DIR ? domain_name : "#{sidecar_dir}/#{domain_name}"
69
70
  def desired_count_value = plan.desired_count_parameter ? "!Ref #{plan.desired_count_parameter}" : desired_count
70
71
  def declared_domain_literal = declared_domain_name.inspect
@@ -1,8 +1,9 @@
1
1
  # GENERATED by hecks deploy project @@domain@@ — re-run it to refresh
2
2
  # this file rather than hand-editing.
3
3
  #
4
- # `make deploy` — builds rust/host for this domain, pushes it to this
5
- # stack's own ECR repository, and deploys the CloudFormation stack.
4
+ # `make deploy` — builds the host for this domain (rust/host, or the
5
+ # directory `host_crate` names), pushes it to this stack's own ECR
6
+ # repository, and deploys the CloudFormation stack.
6
7
  # No SAM anywhere in this path.
7
8
 
8
9
  ROOT := @@root@@
@@ -11,6 +12,8 @@ STACK := @@stack_name@@
11
12
  BASTION_STACK := @@stack_name@@-bastion
12
13
  REGION := @@region@@
13
14
  IMAGE_TAG := latest
15
+ # The Cargo package `bootstrap` is built from; `make HOST_DIR=<dir>` builds another.
16
+ HOST_DIR ?= @@host_dir@@
14
17
 
15
18
  build:
16
19
  # aarch64, not x86_64 — matches template.yaml's own
@@ -33,8 +36,8 @@ build:
33
36
  # rust/dist/@@domain_name@@.wasm/.ir.json from this domain's own
34
37
  # .bluebook.
35
38
  cd $(ROOT) && HECKS_ENVIRONMENT=memory ruby exe/hecks build.build_wasm domain=$(DOMAIN) --wait
36
- cd $(ROOT)/rust/host && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc rustup run stable cargo build --release --target aarch64-unknown-linux-gnu --bin bootstrap
37
- cp $(ROOT)/rust/host/target/aarch64-unknown-linux-gnu/release/bootstrap @@domain_name@@-host
39
+ cd $(HOST_DIR) && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc rustup run stable cargo build --release --target aarch64-unknown-linux-gnu --bin bootstrap
40
+ cp $(HOST_DIR)/target/aarch64-unknown-linux-gnu/release/bootstrap @@domain_name@@-host
38
41
  cp $(ROOT)/rust/dist/@@domain_name@@.wasm @@domain_name@@.wasm
39
42
  cp $(ROOT)/rust/dist/@@domain_name@@.ir.json @@domain_name@@.ir.json
40
43
 
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ module CmsEditor
7
+ # One attribute as the editor reads it: its name, the type as the bluebook spells it, how a
8
+ # value of it is entered (`kind`), and whether it is optional or a list.
9
+ #
10
+ # A type that names one of the aggregate's value objects is an `object`; a reference is a
11
+ # `reference`; the primitives are `integer`, `number`, `boolean` and `text`, and any other
12
+ # name is entered as text.
13
+ #
14
+ # An attribute whose value object has the shape of a structured document, a `blocks`
15
+ # list of a value object that has `kind` and `spans`, carries `widget: "body"` and is
16
+ # edited with the rich-text widget. The shape decides it, never a name.
17
+ class Attributes
18
+ # The parts a value object needs for its list of blocks to be a document body's blocks.
19
+ BLOCK_PARTS = %w[kind spans].freeze
20
+
21
+ # The kind a primitive's name enters as; any other name is text.
22
+ KINDS = { "Integer" => "integer", "Float" => "number", "Boolean" => "boolean", "TrueClass" => "boolean",
23
+ "FalseClass" => "boolean" }.freeze
24
+
25
+ # @param objects [Array<Bluebook::ValueObject>] the aggregate's value objects
26
+ def initialize(objects)
27
+ @by_name = objects.to_h { |object| [object.hecks_name, object] }
28
+ @objects = @by_name.keys
29
+ end
30
+
31
+ # @param attribute [Bluebook::Attribute] a declared attribute
32
+ # @return [Hash{String => Object}] the attribute as the editor reads it
33
+ def of(attribute)
34
+ type = attribute.type
35
+ return reference(attribute, type.target_name) if type.is_a?(Bluebook::Reference)
36
+
37
+ entry = base(attribute, type.to_s, kind(type.to_s))
38
+ body?(attribute) ? entry.merge("widget" => "body") : entry
39
+ end
40
+
41
+ private
42
+
43
+ # A single (not listed) attribute whose value object holds a list of document blocks.
44
+ def body?(attribute)
45
+ return false if attribute.list?
46
+
47
+ block = block_object(@by_name[attribute.type.to_s])
48
+ !block.nil? && (BLOCK_PARTS - block.attributes.map { |part| part.name.to_s }).empty?
49
+ end
50
+
51
+ # @return [Bluebook::ValueObject, nil] the value object `object`'s `blocks` list holds
52
+ def block_object(object)
53
+ blocks = object&.attributes&.find { |part| part.name.to_s == "blocks" && part.list? }
54
+ blocks && @by_name[blocks.type.to_s]
55
+ end
56
+
57
+ def reference(attribute, target)
58
+ base(attribute, target, "reference").merge("target" => target)
59
+ end
60
+
61
+ def base(attribute, type, kind)
62
+ { "name" => attribute.name.to_s, "type" => type, "kind" => kind,
63
+ "optional" => attribute.optional?, "list" => attribute.list? }
64
+ end
65
+
66
+ def kind(type)
67
+ return "object" if @objects.include?(type)
68
+
69
+ KINDS.fetch(type, "text")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end