hecks 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bench/cli.rb +74 -0
  6. data/lib/hecks/bench/environment.rb +100 -0
  7. data/lib/hecks/bench/postgres_probe.rb +41 -0
  8. data/lib/hecks/bench/report.rb +116 -0
  9. data/lib/hecks/bench/ruby_runner.rb +151 -0
  10. data/lib/hecks/bench/run.rb +55 -0
  11. data/lib/hecks/bench/rust_runner.rb +165 -0
  12. data/lib/hecks/bench/stats.rb +75 -0
  13. data/lib/hecks/bench/suite.rb +172 -0
  14. data/lib/hecks/bench/workload.rb +137 -0
  15. data/lib/hecks/bench.rb +28 -0
  16. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  17. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  18. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  20. data/lib/hecks/bluebook/hexagon.rb +15 -7
  21. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  22. data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
  23. data/lib/hecks/bluebook/model_check.rb +21 -2
  24. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  25. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  26. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  27. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  28. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  29. data/lib/hecks/forms/value_object_shape.rb +1 -1
  30. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  31. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  32. data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
  33. data/lib/hecks/language/world/world.bluebook +32 -2
  34. data/lib/hecks/mcp_stdio_guard.rb +160 -0
  35. data/lib/hecks/ports/key_vault.rb +1 -1
  36. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  37. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  38. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  39. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  40. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  41. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  42. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  43. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  44. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  45. data/lib/hecks/ports/persistence.rb +4 -0
  46. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  47. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  48. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  49. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  50. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  51. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  52. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  53. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  54. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  55. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  56. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  57. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  58. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  59. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  60. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  61. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  62. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  63. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  64. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  65. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  66. data/lib/hecks/projections/deploy/preview.rb +103 -0
  67. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  68. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  69. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  70. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  71. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  72. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  73. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  74. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  75. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  76. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  77. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  78. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  79. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  80. data/lib/hecks/projections.rb +2 -0
  81. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  82. data/lib/hecks/release/gem_pin.rb +112 -0
  83. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  84. data/lib/hecks/runtime/loader.rb +26 -2
  85. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  86. data/lib/hecks/runtime/registry/verification.rb +4 -2
  87. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  88. data/lib/hecks/runtime/registry.rb +14 -9
  89. data/lib/hecks/runtime/tenant_check.rb +1 -1
  90. data/lib/hecks/vendoring/git_environment.rb +35 -0
  91. data/lib/hecks/vendoring/git_source.rb +102 -0
  92. data/lib/hecks/vendoring.rb +102 -0
  93. data/lib/hecks/version.rb +1 -1
  94. data/lib/hecks.rb +3 -1
  95. metadata +59 -2
@@ -0,0 +1,172 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Fargate
5
+ # Validation for the optional settings a `deployed_to("AwsFargate")`
6
+ # block accepts beyond the required ones.
7
+ #
8
+ # `FargateTarget.Declare` in deploy.bluebook owns the baseline
9
+ # settings (`cpu`, `memory`, `port`, `database`, `web`). The keys added
10
+ # for multi-container stacks are nested shapes (lists of hashes, maps),
11
+ # which that aggregate's flat attributes cannot describe, so they are
12
+ # checked here. Every check raises `ArgumentError` naming the setting
13
+ # it was reading, so a mistake in a `.world` file points at its line.
14
+ module Check
15
+ module_function
16
+
17
+ LOGICAL_ID = /\A[A-Za-z][A-Za-z0-9]*\z/
18
+ RESOURCE_NAME = %r{\A[A-Za-z0-9][A-Za-z0-9_./-]*\z}
19
+
20
+ # Checks that a value is a hash with only known keys and returns it symbol-keyed.
21
+ #
22
+ # @param value [Object] the setting as written in the world file
23
+ # @param where [String] the setting's name, used in the error message
24
+ # @param allowed [Array<Symbol>] every key the hash may carry
25
+ # @param required [Array<Symbol>] the keys the hash must carry
26
+ # @return [Hash{Symbol => Object}] the hash with its keys as symbols
27
+ # @raise [ArgumentError] if the value is not a hash, names an unknown key, or lacks a
28
+ # required one
29
+ def hash!(value, where, allowed:, required: [])
30
+ raise ArgumentError, "#{where} must be a hash, got #{value.inspect}" unless value.is_a?(Hash)
31
+
32
+ found = value.transform_keys(&:to_sym)
33
+ unknown = found.keys - allowed
34
+ unless unknown.empty?
35
+ raise ArgumentError,
36
+ "#{where} has unknown key(s) #{unknown.join(', ')}; allowed: #{allowed.join(', ')}"
37
+ end
38
+
39
+ missing = required.reject { |key| found.key?(key) }
40
+ raise ArgumentError, "#{where} needs #{missing.join(', ')}" unless missing.empty?
41
+
42
+ found
43
+ end
44
+
45
+ # Checks that a value is a list of hashes, each valid for `hash!`.
46
+ #
47
+ # @param value [Object] the setting as written in the world file
48
+ # @param where [String] the setting's name, used in the error message
49
+ # @param allowed [Array<Symbol>] every key an entry may carry
50
+ # @param required [Array<Symbol>] the keys each entry must carry
51
+ # @return [Array<Hash{Symbol => Object}>] the entries with symbol keys
52
+ # @raise [ArgumentError] if the value is not an array or an entry is invalid
53
+ def hashes!(value, where, allowed:, required: [])
54
+ raise ArgumentError, "#{where} must be a list of hashes, got #{value.inspect}" unless value.is_a?(Array)
55
+
56
+ value.each_with_index.map { |entry, index| hash!(entry, "#{where}[#{index}]", allowed: allowed, required: required) }
57
+ end
58
+
59
+ # Checks that a value is a CloudFormation logical id.
60
+ #
61
+ # @param value [Object] the id as written in the world file
62
+ # @param where [String] the setting's name, used in the error message
63
+ # @return [String] the id
64
+ # @raise [ArgumentError] if the id has anything but letters and digits, or starts with a
65
+ # digit
66
+ def logical_id!(value, where)
67
+ text = value.to_s
68
+ return text if LOGICAL_ID.match?(text)
69
+
70
+ raise ArgumentError, "#{where} must be a CloudFormation logical id " \
71
+ "(letters and digits, starting with a letter), got #{value.inspect}"
72
+ end
73
+
74
+ # Checks that a value is a name AWS accepts for a container, repository or stack resource.
75
+ #
76
+ # @param value [Object] the name as written in the world file
77
+ # @param where [String] the setting's name, used in the error message
78
+ # @return [String] the name
79
+ # @raise [ArgumentError] if the name has characters outside letters, digits and `_ . / -`
80
+ def resource_name!(value, where)
81
+ text = value.to_s
82
+ return text if RESOURCE_NAME.match?(text)
83
+
84
+ raise ArgumentError, "#{where} must be a name of letters, digits, and _ . / - characters, got #{value.inspect}"
85
+ end
86
+
87
+ # Checks that a value is a whole number in a range.
88
+ #
89
+ # @param value [Object] the number as written in the world file
90
+ # @param where [String] the setting's name, used in the error message
91
+ # @param range [Range<Integer>] the accepted values
92
+ # @return [Integer] the number
93
+ # @raise [ArgumentError] if the value is not an integer inside `range`
94
+ def integer!(value, where, range:)
95
+ return value if value.is_a?(Integer) && range.cover?(value)
96
+
97
+ raise ArgumentError, "#{where} must be a whole number from #{range.min} to #{range.max}, got #{value.inspect}"
98
+ end
99
+
100
+ # Checks that a value is true or false.
101
+ #
102
+ # @param value [Object] the flag as written in the world file
103
+ # @param where [String] the setting's name, used in the error message
104
+ # @return [Boolean] the flag
105
+ # @raise [ArgumentError] if the value is neither `true` nor `false`
106
+ def boolean!(value, where)
107
+ return value if [true, false].include?(value)
108
+
109
+ raise ArgumentError, "#{where} must be true or false, got #{value.inspect}"
110
+ end
111
+
112
+ # Checks that a value is a list of non-empty strings.
113
+ #
114
+ # @param value [Object] the list as written in the world file
115
+ # @param where [String] the setting's name, used in the error message
116
+ # @param min [Integer] the fewest entries allowed
117
+ # @param max [Integer, nil] the most entries allowed, or nil for no limit
118
+ # @return [Array<String>] the entries as strings
119
+ # @raise [ArgumentError] if the value is not a list of strings or its size is out of range
120
+ def strings!(value, where, min: 0, max: nil)
121
+ list = value.is_a?(Array) ? value : nil
122
+ ok = list&.all? { |item| (item.is_a?(String) || item.is_a?(Symbol)) && !item.to_s.empty? }
123
+ raise ArgumentError, "#{where} must be a list of strings, got #{value.inspect}" unless ok
124
+ raise ArgumentError, "#{where} needs at least #{min} entr#{min == 1 ? 'y' : 'ies'}" if list.size < min
125
+ raise ArgumentError, "#{where} takes at most #{max} entries, got #{list.size}" if max && list.size > max
126
+
127
+ list.map(&:to_s)
128
+ end
129
+
130
+ # Checks that a value is a map of names to strings and returns it string-keyed.
131
+ #
132
+ # A `nil` value is kept: for the domain container's environment it
133
+ # removes a default variable.
134
+ #
135
+ # @param value [Object] the map as written in the world file
136
+ # @param where [String] the setting's name, used in the error message
137
+ # @param nil_ok [Boolean] whether a `nil` value is accepted
138
+ # @return [Hash{String => Object}] the map with string keys
139
+ # @raise [ArgumentError] if the value is not a hash or a key is not an
140
+ # environment-variable style name
141
+ def map!(value, where, nil_ok: false)
142
+ raise ArgumentError, "#{where} must be a hash, got #{value.inspect}" unless value.is_a?(Hash)
143
+
144
+ value.to_h do |key, item|
145
+ name = key.to_s
146
+ unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
147
+ raise ArgumentError,
148
+ "#{where} key #{key.inspect} must be a name of letters, digits and underscores"
149
+ end
150
+ raise ArgumentError, "#{where}.#{name} needs a value" if item.nil? && !nil_ok
151
+
152
+ [name, item]
153
+ end
154
+ end
155
+
156
+ # Checks that a value is one of a fixed set.
157
+ #
158
+ # @param value [Object] the value as written in the world file
159
+ # @param where [String] the setting's name, used in the error message
160
+ # @param choices [Array<String>] the accepted values
161
+ # @return [String] the value as a string
162
+ # @raise [ArgumentError] if the value is not among `choices`
163
+ def one_of!(value, where, choices)
164
+ return value.to_s if choices.include?(value.to_s)
165
+
166
+ raise ArgumentError, "#{where} must be one of #{choices.join(', ')}, got #{value.inspect}"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,412 @@
1
+ require_relative "check"
2
+ require_relative "yaml"
3
+
4
+ module Hecks
5
+ module Projections
6
+ module Deploy
7
+ module Fargate
8
+ # The containers of one Fargate task and how the load balancer reaches them.
9
+ #
10
+ # A stack always has the domain container: `rust/host` built from the
11
+ # domain itself. The world's `containers` setting adds more containers
12
+ # to the same task (a website, a content admin), each with its own ECR
13
+ # repository, image-tag parameter, target group and health check.
14
+ # `routes` sends URL path patterns to a container through listener
15
+ # rules on the one load balancer, and `default_container` names the
16
+ # container that answers everything else.
17
+ #
18
+ # ## Settings
19
+ #
20
+ # `domain_container` renames the domain container's own pieces:
21
+ # `name`, `repository`, `image_tag_parameter` and `health_path`. Its `essential`
22
+ # writes an explicit `Essential` flag; the container is essential either way,
23
+ # since that is `ECS`'s default.
24
+ #
25
+ # Each `containers` entry takes `name` and `repository` (required),
26
+ # and `port`, `health_path`, `env`, `secrets`, `image_tag_parameter`,
27
+ # `essential`, `cpu`, `memory`, `repository_id` and `target_group_id`.
28
+ # A container without a `port` receives no load-balancer traffic.
29
+ # `env` values and `secrets` values may be CloudFormation intrinsics
30
+ # written as strings (`"!Ref Name"`, `"!Sub \"...\""`).
31
+ #
32
+ # Each `routes` entry takes `container`, `paths` (one to five path
33
+ # patterns, since a listener rule condition holds at most five),
34
+ # `priority` and an optional `id`.
35
+ module Containers
36
+ module_function
37
+
38
+ Container = Struct.new(
39
+ :name, :repository_id, :repository_name, :tag_parameter, :port, :health_path,
40
+ :env, :secrets, :cpu, :memory, :essential, :target_group_id,
41
+ keyword_init: true
42
+ )
43
+ Route = Struct.new(:id, :container, :paths, :priority, keyword_init: true)
44
+ Layout = Struct.new(:domain, :extras, :routes, :default_container, keyword_init: true) do
45
+ # Lists the domain container followed by every added container.
46
+ #
47
+ # @return [Array<Container>] every container of the task
48
+ def all = [domain, *extras]
49
+
50
+ # Lists the containers that receive load-balancer traffic.
51
+ #
52
+ # @return [Array<Container>] the containers with a port
53
+ def balanced = all.select(&:port)
54
+
55
+ # Tells whether the stack has any container besides the domain's own.
56
+ #
57
+ # @return [Boolean] true when `containers` added at least one
58
+ def multi? = !extras.empty?
59
+
60
+ # Finds the container the listener forwards to by default.
61
+ #
62
+ # @return [Container] the default container
63
+ def default = all.find { |container| container.name == default_container }
64
+ end
65
+
66
+ DOMAIN_KEYS = [:name, :repository, :image_tag_parameter, :health_path, :essential].freeze
67
+ CONTAINER_KEYS = [
68
+ :name, :repository, :port, :health_path, :env, :secrets, :image_tag_parameter, :essential,
69
+ :cpu, :memory, :repository_id, :target_group_id
70
+ ].freeze
71
+ ROUTE_KEYS = [:container, :paths, :priority, :id].freeze
72
+
73
+ # Reads the container-related settings into a `Layout`.
74
+ #
75
+ # @param settings [Hash{Symbol => Object}] the world's `deployed_to("AwsFargate")`
76
+ # settings
77
+ # @param infra_name [String] the domain's own AWS-facing name, the default container name
78
+ # @param port [Integer] the validated port of the domain container
79
+ # @param ids [Hash{Symbol => String}] the resolved logical ids, for the domain container's
80
+ # own
81
+ # @return [Layout] the containers, routes and default container
82
+ # @raise [ArgumentError] if a container, route or the default container is invalid
83
+ def normalize(settings, infra_name:, port:, ids:)
84
+ domain = domain_container(settings[:domain_container], infra_name: infra_name, port: port, ids: ids)
85
+ extras = Check.hashes!(settings.fetch(:containers, []), "containers", allowed: CONTAINER_KEYS,
86
+ required: [:name, :repository])
87
+ .map do |entry|
88
+ extra_container(entry)
89
+ end
90
+ layout = Layout.new(domain: domain, extras: extras, routes: [], default_container: domain.name)
91
+ layout.default_container = default_name(settings, layout)
92
+ layout.routes = routes(settings.fetch(:routes, []), layout)
93
+ check_unique!(layout)
94
+ check_reachable!(layout)
95
+ layout
96
+ end
97
+
98
+ # Renders the ECR repository of every added container.
99
+ #
100
+ # @param layout [Layout] the stack's containers
101
+ # @return [String] flush-left resources separated by blank lines, or empty
102
+ def repositories_yaml(layout)
103
+ layout.extras.map do |container|
104
+ <<~REPOSITORY.rstrip
105
+ #{container.repository_id}:
106
+ Type: AWS::ECR::Repository
107
+ Properties:
108
+ RepositoryName: #{container.repository_name}
109
+ ImageScanningConfiguration:
110
+ ScanOnPush: true
111
+ REPOSITORY
112
+ end.join("\n\n")
113
+ end
114
+
115
+ # Renders the image-tag parameter of every added container.
116
+ #
117
+ # @param layout [Layout] the stack's containers
118
+ # @return [String] flush-left parameters, each ending in a newline, or empty
119
+ def parameters_yaml(layout)
120
+ layout.extras.map do |container|
121
+ <<~PARAMETER
122
+ #{container.tag_parameter}:
123
+ Type: String
124
+ Default: latest
125
+ Description: ECR image tag the #{container.name} container pulls.
126
+ PARAMETER
127
+ end.join
128
+ end
129
+
130
+ # Renders the added containers as `ContainerDefinitions` list entries.
131
+ #
132
+ # @param layout [Layout] the stack's containers
133
+ # @param log_group_id [String] the logical id of the shared log group
134
+ # @return [String] flush-left list entries separated by blank lines, or empty
135
+ def definitions_yaml(layout, log_group_id)
136
+ layout.extras.map { |container| definition_yaml(container, log_group_id) }.join("\n\n")
137
+ end
138
+
139
+ # Renders the target group of every added container.
140
+ #
141
+ # @param layout [Layout] the stack's containers
142
+ # @param vpc_ref [String] the `!Ref` expression for the VPC the targets live in
143
+ # @param deregistration_delay [Integer, nil] seconds the load balancer drains a target, or
144
+ # nil for the default
145
+ # @return [String] flush-left resources separated by blank lines, or empty
146
+ def target_groups_yaml(layout, vpc_ref, deregistration_delay)
147
+ layout.extras.select(&:port).map do |container|
148
+ [
149
+ "#{container.target_group_id}:",
150
+ " Type: AWS::ElasticLoadBalancingV2::TargetGroup",
151
+ " Properties:",
152
+ " TargetType: ip",
153
+ " Port: #{container.port}",
154
+ " Protocol: HTTP",
155
+ " VpcId: #{vpc_ref}",
156
+ " HealthCheckPath: #{container.health_path}",
157
+ " HealthCheckPort: \"#{container.port}\"",
158
+ target_group_attributes_yaml(deregistration_delay).gsub(/^/, " ")
159
+ ].join("\n").rstrip
160
+ end.join("\n\n")
161
+ end
162
+
163
+ # Renders the `TargetGroupAttributes` property for a drain delay.
164
+ #
165
+ # @param deregistration_delay [Integer, nil] seconds the load balancer drains a target
166
+ # @return [String] flush-left property lines ending in a newline, or empty for nil
167
+ def target_group_attributes_yaml(deregistration_delay)
168
+ return "" unless deregistration_delay
169
+
170
+ <<~ATTRIBUTES
171
+ TargetGroupAttributes:
172
+ - Key: deregistration_delay.timeout_seconds
173
+ Value: "#{deregistration_delay}"
174
+ ATTRIBUTES
175
+ end
176
+
177
+ # Renders one listener rule per route.
178
+ #
179
+ # @param layout [Layout] the stack's containers and routes
180
+ # @param listener_id [String] the logical id of the HTTP listener
181
+ # @return [String] flush-left resources separated by blank lines, or empty
182
+ def listener_rules_yaml(layout, listener_id)
183
+ layout.routes.map do |route|
184
+ target = layout.all.find { |container| container.name == route.container }
185
+ <<~RULE.rstrip
186
+ #{route.id}:
187
+ Type: AWS::ElasticLoadBalancingV2::ListenerRule
188
+ Properties:
189
+ ListenerArn: !Ref #{listener_id}
190
+ Priority: #{route.priority}
191
+ Conditions:
192
+ - Field: path-pattern
193
+ Values: #{Yaml.flow_list(route.paths)}
194
+ Actions:
195
+ - Type: forward
196
+ TargetGroupArn: !Ref #{target.target_group_id}
197
+ RULE
198
+ end.join("\n\n")
199
+ end
200
+
201
+ # Renders the service's `LoadBalancers` entries for the added containers.
202
+ #
203
+ # @param layout [Layout] the stack's containers
204
+ # @return [String] flush-left list entries, or empty
205
+ def load_balancers_yaml(layout)
206
+ layout.extras.select(&:port).map do |container|
207
+ <<~ENTRY
208
+ - ContainerName: #{container.name}
209
+ ContainerPort: #{container.port}
210
+ TargetGroupArn: !Ref #{container.target_group_id}
211
+ ENTRY
212
+ end.join
213
+ end
214
+
215
+ # Writes the `DependsOn` value that holds the service until the listener and its rules
216
+ # exist.
217
+ #
218
+ # @param layout [Layout] the stack's containers and routes
219
+ # @param listener_id [String] the logical id of the HTTP listener
220
+ # @return [String] a single id, or a flow list of ids
221
+ def depends_on(layout, listener_id)
222
+ return listener_id if layout.routes.empty?
223
+
224
+ Yaml.flow_list([listener_id, *layout.routes.map(&:id)])
225
+ end
226
+
227
+ # Finds the first and last container port the load balancer must reach.
228
+ #
229
+ # @param layout [Layout] the stack's containers
230
+ # @return [Range<Integer>] the security-group ingress port range
231
+ def port_range(layout)
232
+ ports = layout.balanced.map(&:port)
233
+ ports.min..ports.max
234
+ end
235
+
236
+ def domain_container(setting, infra_name:, port:, ids:)
237
+ given = setting ? Check.hash!(setting, "domain_container", allowed: DOMAIN_KEYS) : {}
238
+ name = Check.resource_name!(given.fetch(:name, infra_name), "domain_container name")
239
+ repository = Check.resource_name!(given.fetch(:repository, infra_name), "domain_container repository")
240
+ Container.new(
241
+ name: name, repository_id: ids.fetch(:ecr_repository), repository_name: repository,
242
+ tag_parameter: Check.logical_id!(given.fetch(:image_tag_parameter, "ImageTag"),
243
+ "domain_container image_tag_parameter"),
244
+ port: port, health_path: health_path(given, "domain_container"), env: {}, secrets: {},
245
+ essential: given.key?(:essential) ? Check.boolean!(given[:essential], "domain_container essential") : nil,
246
+ target_group_id: ids.fetch(:target_group)
247
+ )
248
+ end
249
+ private_class_method :domain_container
250
+
251
+ def extra_container(entry)
252
+ name = Check.resource_name!(entry.fetch(:name), "containers name")
253
+ where = "containers[#{name}]"
254
+ base = Yaml.camel(name)
255
+ Container.new(
256
+ name: name, repository_name: Check.resource_name!(entry.fetch(:repository), "#{where} repository"),
257
+ repository_id: Check.logical_id!(entry.fetch(:repository_id, "#{base}Repository"), "#{where} repository_id"),
258
+ target_group_id: Check.logical_id!(entry.fetch(:target_group_id, "#{base}TargetGroup"), "#{where} target_group_id"),
259
+ tag_parameter: Check.logical_id!(entry.fetch(:image_tag_parameter, "#{base}ImageTag"),
260
+ "#{where} image_tag_parameter"),
261
+ port: optional_port(entry, where), health_path: health_path(entry, where),
262
+ env: Check.map!(entry.fetch(:env, {}), "#{where} env"),
263
+ secrets: Check.map!(entry.fetch(:secrets, {}), "#{where} secrets"),
264
+ cpu: optional_size(entry, :cpu, where), memory: optional_size(entry, :memory, where),
265
+ essential: Check.boolean!(entry.fetch(:essential, true), "#{where} essential")
266
+ )
267
+ end
268
+ private_class_method :extra_container
269
+
270
+ def optional_port(entry, where)
271
+ entry.key?(:port) ? Check.integer!(entry[:port], "#{where} port", range: 1..65_535) : nil
272
+ end
273
+ private_class_method :optional_port
274
+
275
+ def optional_size(entry, key, where)
276
+ entry.key?(key) ? Check.integer!(entry[key], "#{where} #{key}", range: 1..1_000_000) : nil
277
+ end
278
+ private_class_method :optional_size
279
+
280
+ def health_path(entry, where)
281
+ path = entry.fetch(:health_path, "/").to_s
282
+ raise ArgumentError, "#{where} health_path must start with /, got #{path.inspect}" unless path.start_with?("/")
283
+
284
+ path
285
+ end
286
+ private_class_method :health_path
287
+
288
+ def default_name(settings, layout)
289
+ name = settings.fetch(:default_container, layout.domain.name).to_s
290
+ target = layout.all.find { |container| container.name == name }
291
+ unless target
292
+ raise ArgumentError,
293
+ "default_container #{name.inspect} is not a container; have #{layout.all.map(&:name).join(', ')}"
294
+ end
295
+ unless target.port
296
+ raise ArgumentError,
297
+ "default_container #{name.inspect} has no port, so the listener cannot forward to it"
298
+ end
299
+
300
+ name
301
+ end
302
+ private_class_method :default_name
303
+
304
+ def routes(entries, layout)
305
+ counts = Hash.new(0)
306
+ Check.hashes!(entries, "routes", allowed: ROUTE_KEYS, required: [:container, :paths, :priority]).map do |entry|
307
+ container = entry.fetch(:container).to_s
308
+ counts[container] += 1
309
+ route(entry, container, counts[container], layout)
310
+ end
311
+ end
312
+ private_class_method :routes
313
+
314
+ def route(entry, container, nth, layout)
315
+ target = layout.balanced.find { |candidate| candidate.name == container }
316
+ raise ArgumentError, "routes name container #{container.inspect}, which is not a container with a port" unless target
317
+
318
+ suffix = nth == 1 ? "" : nth.to_s
319
+ Route.new(
320
+ id: Check.logical_id!(entry.fetch(:id, "ListenerRule#{Yaml.camel(container)}#{suffix}"), "routes id"),
321
+ container: container, priority: Check.integer!(entry.fetch(:priority), "routes priority", range: 1..50_000),
322
+ paths: route_paths(entry.fetch(:paths), container)
323
+ )
324
+ end
325
+ private_class_method :route
326
+
327
+ def route_paths(value, container)
328
+ paths = Check.strings!(value, "routes[#{container}] paths", min: 1, max: 5)
329
+ bad = paths.reject { |path| path.start_with?("/", "*") }
330
+ raise ArgumentError, "routes[#{container}] paths must start with / or *, got #{bad.join(', ')}" unless bad.empty?
331
+
332
+ paths
333
+ end
334
+ private_class_method :route_paths
335
+
336
+ def check_unique!(layout)
337
+ {
338
+ "container names" => layout.all.map(&:name),
339
+ "repository names" => layout.all.map(&:repository_name),
340
+ "image tag parameters" => layout.all.map(&:tag_parameter),
341
+ "repository ids" => layout.all.map(&:repository_id),
342
+ "target group ids" => layout.balanced.map(&:target_group_id),
343
+ "container ports" => layout.balanced.map(&:port),
344
+ "route ids" => layout.routes.map(&:id),
345
+ "route priorities" => layout.routes.map(&:priority)
346
+ }.each do |what, values|
347
+ repeated = values.tally.select { |_value, count| count > 1 }.keys
348
+ raise ArgumentError, "#{what} must be unique; repeated: #{repeated.join(', ')}" unless repeated.empty?
349
+ end
350
+ end
351
+ private_class_method :check_unique!
352
+
353
+ def check_reachable!(layout)
354
+ routed = layout.routes.map(&:container) + [layout.default_container]
355
+ stranded = layout.balanced.map(&:name) - routed
356
+ return if stranded.empty?
357
+
358
+ raise ArgumentError, "container(s) #{stranded.join(', ')} have a port but no route and are not the " \
359
+ "default_container, so the load balancer never reaches them; add a routes entry or drop the port"
360
+ end
361
+ private_class_method :check_reachable!
362
+
363
+ # Renders one added container as a `ContainerDefinitions` entry.
364
+ def definition_yaml(container, log_group_id)
365
+ lines = [
366
+ "- Name: #{container.name}",
367
+ " Image: !Sub \"${#{container.repository_id}.RepositoryUri}:${#{container.tag_parameter}}\"",
368
+ " Essential: #{container.essential}"
369
+ ]
370
+ lines << " Cpu: #{container.cpu}" if container.cpu
371
+ lines << " Memory: #{container.memory}" if container.memory
372
+ lines.concat(port_lines(container), log_lines(container, log_group_id), env_lines(container), secret_lines(container))
373
+ lines.join("\n")
374
+ end
375
+ private_class_method :definition_yaml
376
+
377
+ def port_lines(container)
378
+ container.port ? [" PortMappings:", " - ContainerPort: #{container.port}"] : []
379
+ end
380
+ private_class_method :port_lines
381
+
382
+ def log_lines(container, log_group_id)
383
+ [
384
+ " LogConfiguration:", " LogDriver: awslogs", " Options:",
385
+ " awslogs-group: !Ref #{log_group_id}", " awslogs-region: !Ref AWS::Region",
386
+ " awslogs-stream-prefix: #{container.name}"
387
+ ]
388
+ end
389
+ private_class_method :log_lines
390
+
391
+ def env_lines(container)
392
+ return [] if container.env.empty?
393
+
394
+ [" Environment:"] + container.env.flat_map do |name, value|
395
+ [" - Name: #{name}", " Value: #{Yaml.string(value)}"]
396
+ end
397
+ end
398
+ private_class_method :env_lines
399
+
400
+ def secret_lines(container)
401
+ return [] if container.secrets.empty?
402
+
403
+ [" Secrets:"] + container.secrets.flat_map do |name, value|
404
+ [" - Name: #{name}", " ValueFrom: #{Yaml.string(value)}"]
405
+ end
406
+ end
407
+ private_class_method :secret_lines
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end