jets 3.1.4 → 3.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50e281f1856a1a82508629d805afe52156c44dc360f38d49528ca67e586ae8a4
4
- data.tar.gz: ee5c33fe1a4f6929d1b285ba6a16acbfedcba1f229f34501f45c0983d4c71d6f
3
+ metadata.gz: e3693cb1f3b4143b90f9f41b3325d12c86a5095615c7fe843aa7a044561fa535
4
+ data.tar.gz: 0650ebb86666eac0779c167e4f926076c88b05481d8379f8ea7adaa2d6e35130
5
5
  SHA512:
6
- metadata.gz: 531f6ccbc1f750cd08b4ac8575017c12de6f21104d014b983a14270e9b6a2760e097a03c5672de4ec989cbbefb52cf6f25d37a2a2459743c2c49e72c1b715985
7
- data.tar.gz: 116483cc99bb26c464de5665a7abfaba09c95710a298026a25acfbcb4c42bfce0df48a6efba6f937031fb54db7b91485f83e18b0c048ec4c10837682c89d8076
6
+ metadata.gz: b8887e1a8af4cac8f4617aa422bc64daa52300d14abecfb130c074e27a45d35bafd4ba3090490d75de3cc091b4580450b03f39f27f815439d7cd31f34cb91ae7
7
+ data.tar.gz: 0c463af7c1675eb7658689eaec0d4026341db788e0c31c82f078b80963cce7a8939914bcf59f7c4434552bb7433536756375c1b8d582dc1166f29f3b337f9bca
data/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  This project *loosely tries* to adhere to [Semantic Versioning](http://semver.org/).
5
5
 
6
+ ## [3.2.0] - 2022-12-03
7
+ - [#631](https://github.com/boltops-tools/jets/pull/631) Implement vpc_endpoint_ids configuration
8
+ - [#634](https://github.com/boltops-tools/jets/pull/634) Custom domain messaging
9
+ - [#635](https://github.com/boltops-tools/jets/pull/635) smarter apigw routes paging calculation
10
+ - Introduce the storage of APIGW routes state in the jets managed s3 bucket under path jets/state/apigw/pages.json and jets/state/apigw/routes.json
11
+ - This speeds up deployment for apps with a large number of routes. Depending on the internet connection this can be minutes faster.
12
+ - Update internationalization.md
13
+ - fix domain.name check when APIGW changes
14
+ - improve change apigw endpoint change messaging
15
+
16
+ ## [3.1.5] - 2022-08-08
17
+ - [#628](https://github.com/boltops-tools/jets/pull/628) Multiple Databases
18
+ - [#629](https://github.com/boltops-tools/jets/pull/629) loosen zeitwerk dependency in generated project Gemfile
19
+ - [#630](https://github.com/boltops-tools/jets/pull/630) compile assets with WEBPACKER_ASSET_HOST as s3 endpoint url
20
+
6
21
  ## [3.1.4] - 2022-07-02
7
22
  - [#627](https://github.com/boltops-tools/jets/pull/627) use >= for most gem dependencies
8
23
 
@@ -77,10 +77,11 @@ class Jets::Application
77
77
  config.inflections.irregular = {}
78
78
 
79
79
  config.assets = ActiveSupport::OrderedOptions.new
80
- config.assets.folders = %w[assets images packs]
81
80
  config.assets.base_url = nil # IE: https://cloudfront.com/my/base/path
82
- config.assets.max_age = 3600
83
81
  config.assets.cache_control = nil # IE: public, max-age=3600 , max_age is a shorter way to set cache_control.
82
+ config.assets.folders = %w[assets images packs]
83
+ config.assets.max_age = 3600
84
+ config.assets.webpacker_asset_host = "s3_endpoint" # true = use conventional by default
84
85
 
85
86
  config.ruby = ActiveSupport::OrderedOptions.new
86
87
 
@@ -98,6 +99,7 @@ class Jets::Application
98
99
  config.api.cors_authorization_type = nil # nil so ApiGateway::Cors#cors_authorization_type handles
99
100
  config.api.endpoint_policy = nil # required when endpoint_type is EDGE
100
101
  config.api.endpoint_type = 'EDGE' # PRIVATE, EDGE, REGIONAL
102
+ config.api.vpc_endpoint_ids = nil
101
103
 
102
104
  config.api.authorizers = ActiveSupport::OrderedOptions.new
103
105
  config.api.authorizers.default_token_source = "Auth" # method.request.header.Auth
@@ -86,10 +86,18 @@ module Jets::AwsServices
86
86
  retry_limit: 7, # default: 3
87
87
  retry_base_delay: 0.6, # default: 0.3
88
88
  }
89
+ # See debug logger. Noisy.
90
+ # Example:
91
+ # D, [2022-12-02T13:18:55.298788 #26182] DEBUG -- : [Aws::APIGateway::Client 200 0.030837 0 retries] get_method(rest_api_id:"mke40eh6l0",resource_id:"zf8w2m",http_method:"GET")
89
92
  options.merge!(
90
93
  log_level: :debug,
91
94
  logger: Logger.new($stdout),
92
95
  ) if ENV['JETS_DEBUG_AWS_SDK']
96
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/debugging.html to enable http_wire_trace
97
+ # See the HTTP headers and JSON responses. Super noisy.
98
+ options.merge!(
99
+ http_wire_trace: true,
100
+ ) if ENV['JETS_DEBUG_AWS_SDK_HTTP_WIRE_TRACE']
93
101
  options
94
102
  end
95
103
  end
data/lib/jets/booter.rb CHANGED
@@ -94,11 +94,23 @@ class Jets::Booter
94
94
  # `show processlist` until after a query. Have confirmed that the connection is reused and the connection count stays
95
95
  # the same.
96
96
  def connect_db
97
- primary_hash_config = ActiveRecord::Base.configurations.configs_for(env_name: Jets.env).find { |hash_config|
98
- hash_config.name == "primary"
99
- }
100
- primary_config = primary_hash_config.configuration_hash # configuration_hash is a normal Ruby Hash
101
- ActiveRecord::Base.establish_connection(primary_config)
97
+ if ActiveRecord::Base.legacy_connection_handling
98
+ primary_hash_config = ActiveRecord::Base.configurations.configs_for(env_name: Jets.env).find { |hash_config|
99
+ hash_config.name == "primary"
100
+ }
101
+
102
+ primary_config = primary_hash_config.configuration_hash # configuration_hash is a normal Ruby Hash
103
+
104
+ ActiveRecord::Base.establish_connection(primary_config)
105
+ else
106
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Jets.env, include_replicas: true)
107
+
108
+ databases = { }
109
+ databases[:writing] = :primary if configs.any? { |config| config.name == "primary" }
110
+ databases[:reading] = :primary_replica if configs.any? { |config| config.name == "primary_replica" }
111
+
112
+ ActiveRecord::Base.connects_to database: databases
113
+ end
102
114
  end
103
115
 
104
116
  def load_internal_turbines
@@ -205,12 +205,45 @@ module Jets::Builders
205
205
  return unless webpacker_included?
206
206
 
207
207
  sh("yarn install")
208
+
209
+ ENV['WEBPACKER_ASSET_HOST'] = webpacker_asset_host if Jets.config.assets.webpacker_asset_host
208
210
  webpack_command = File.exist?("#{Jets.root}/bin/webpack") ?
209
211
  "bin/webpack" :
210
212
  `which webpack`.strip
211
213
  sh "JETS_ENV=#{Jets.env} #{webpack_command}"
212
214
  end
213
215
 
216
+ # Different url for these. Examples:
217
+ #
218
+ # webpacker_asset_host https://demo-dev-s3bucket-lw5vq7ht8ip4.s3.us-west-2.amazonaws.com/jets/public/packs/media/images/boltops-0dd1c6bd.png
219
+ # s3_base_url https://s3-us-west-2.amazonaws.com/demo-dev-s3bucket-lw5vq7ht8ip4/jets/packs/media/images/boltops-0dd1c6bd.png
220
+ #
221
+ # Interesting: webpacker_asset_host works but s3_base_url does not for CORs. IE: reactjs or vuejs requests
222
+ # Thinking AWS configures the non-subdomain url endpoint to be more restrictive.
223
+ #
224
+ def webpacker_asset_host
225
+ # Allow user to set assets.webpacker_asset_host
226
+ #
227
+ # Jets.application.configure do
228
+ # config.assets.webpacker_asset_host = "https://cloudfront.com/my/base/path"
229
+ # end
230
+ #
231
+ assets = Jets.config.assets
232
+ return assets.webpacker_asset_host if assets.webpacker_asset_host && assets.webpacker_asset_host != "s3_endpoint"
233
+ return assets.base_url if assets.base_url
234
+
235
+ # By default, will use the s3 url endpoint directly by convention
236
+ return unless assets.webpacker_asset_host == "s3_endpoint"
237
+
238
+ region = Jets.aws.region
239
+
240
+ asset_base_url = region == 'us-east-1' ?
241
+ "https://#{s3_bucket}.s3.amazonaws.com" :
242
+ "https://#{s3_bucket}.s3.#{region}.amazonaws.com"
243
+
244
+ "#{asset_base_url}/jets/public" # s3_base_url
245
+ end
246
+
214
247
  def webpacker_included?
215
248
  # Old code, leaving around for now:
216
249
  # Thanks: https://stackoverflow.com/questions/4195735/get-list-of-gems-being-used-by-a-bundler-project
@@ -49,7 +49,7 @@ module Jets::Cfn::Builders
49
49
 
50
50
  def add_route53_dns
51
51
  dns = Jets::Resource::Route53::RecordSet.new
52
- if !existing_domain_name?(dns.domain_name) or existing_dns_record_on_stack?
52
+ if !existing_domain_name?(dns.domain_name) or existing_dns_record_on_stack?
53
53
  add_resource(dns)
54
54
  add_outputs(dns.outputs)
55
55
  end
@@ -57,11 +57,11 @@ module Jets::Cfn::Builders
57
57
 
58
58
  def create_domain_name()
59
59
  resource = Jets::Resource::ApiGateway::DomainName.new
60
-
60
+
61
61
  return {
62
62
  "DomainName" => resource.domain_name
63
63
  } if (existing_domain_name?(resource) and !existing_domain_name_on_stack?)
64
-
64
+
65
65
  add_resource(resource)
66
66
  return resource.outputs
67
67
  end
@@ -114,8 +114,9 @@ module Jets::Cfn::Builders
114
114
  def add_gateway_routes
115
115
  # Reject homepage. Otherwise we have 200 - 1 resources on the first page.
116
116
  # There's a next call in ApiResources.add_gateway_resources to skip the homepage.
117
- all_paths = Jets::Router.all_paths.reject { |p| p == '' }
118
- all_paths.each_slice(PAGE_LIMIT).each_with_index do |paths, i|
117
+ page_builder = Jets::Cfn::Builders::PageBuilder.new
118
+ pages = page_builder.build
119
+ pages.each_with_index do |paths, i|
119
120
  ApiResourcesBuilder.new(@options, paths, i+1).build
120
121
  end
121
122
  end
@@ -0,0 +1,80 @@
1
+ module Jets::Cfn::Builders
2
+ class PageBuilder
3
+ extend Memoist
4
+ cattr_reader :pages
5
+
6
+ # Build page slices
7
+ def build
8
+ map = build_map
9
+ pages = []
10
+ map.each do |path, existing_page|
11
+ if existing_page
12
+ pages[existing_page] ||= []
13
+ pages[existing_page] << path
14
+ end
15
+ end
16
+
17
+ # Remove existing paths from map. Leave behind new paths
18
+ pages.each do |page|
19
+ page.each do |i|
20
+ map.delete(i)
21
+ end
22
+ end
23
+
24
+ # Fill up available space in each page so all existing pages are full
25
+ keys = map.keys
26
+ pages.each do |page|
27
+ break if keys.empty?
28
+ while page.size < page_limit
29
+ path = keys.shift
30
+ break if path.nil?
31
+ page << path
32
+ end
33
+ end
34
+
35
+ # Add remaining slices to new additional pages
36
+ pages += keys.each_slice(page_limit).to_a
37
+
38
+ @@pages = pages
39
+
40
+ pages
41
+ end
42
+
43
+ # Build map that has paths as keys and page number as value
44
+ # Example: {"a1"=>0, "a2"=>0, "b1"=>1, "b2"=>1, "c1"=>2, "c2"=>2}
45
+ def build_map
46
+ map = {}
47
+ new_paths.each do |path|
48
+ map[path] = find_page_index(path)
49
+ end
50
+ map
51
+ end
52
+
53
+ def find_page_index(new_path)
54
+ pages = old_pages || []
55
+ pages.each_with_index do |slice, i|
56
+ slice.find do |old_path|
57
+ return i if old_path == new_path
58
+ end
59
+ end
60
+ nil
61
+ end
62
+
63
+ def old_pages
64
+ state = Jets::Router::State.new
65
+ state.load("pages")
66
+ end
67
+ memoize :old_pages
68
+
69
+ def new_paths
70
+ Jets::Router.all_paths.reject { |p| p == '' }
71
+ end
72
+
73
+ # Relevant is CloudFormation Outputs limit is 200
74
+ # JETS_APIGW_PAGE_LIMIT is based on that
75
+ # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cloudformation-limits.html
76
+ def page_limit
77
+ Integer(ENV['JETS_APIGW_PAGE_LIMIT'] || 200)
78
+ end
79
+ end
80
+ end
data/lib/jets/cfn/ship.rb CHANGED
@@ -42,6 +42,7 @@ module Jets::Cfn
42
42
  exit 1
43
43
  end
44
44
 
45
+ save_apigw_state
45
46
  prewarm
46
47
  clean_deploy_logs
47
48
  show_api_endpoint
@@ -88,6 +89,12 @@ module Jets::Cfn
88
89
  Jets::Cfn::Status.new(@options).wait
89
90
  end
90
91
 
92
+ def save_apigw_state
93
+ state = Jets::Router::State.new
94
+ state.save("pages", Jets::Cfn::Builders::PageBuilder.pages)
95
+ state.save("routes", Jets::Router.routes)
96
+ end
97
+
91
98
  def prewarm
92
99
  if ENV['SKIP_PREWARMING']
93
100
  puts "Skipping prewarming" # useful for testing
@@ -18,7 +18,7 @@ gem "mysql2", "~> 0.5.3"
18
18
  <% unless options[:mode] == 'job' -%>
19
19
  gem "dynomite"
20
20
  <% end -%>
21
- gem "zeitwerk", "~> 2.5.0"
21
+ gem "zeitwerk", ">= 2.5.0"
22
22
 
23
23
  # development and test groups are not bundled as part of the deployment
24
24
  group :development, :test do
@@ -8,8 +8,7 @@ class Jets::Resource::ApiGateway::RestApi::LogicalId
8
8
  end
9
9
 
10
10
  def custom_domain
11
- api = Jets::Resource::ApiGateway::DomainName.new
12
- domain_name = api.domain_name
11
+ domain_name = Jets.config.domain.name
13
12
  if domain_name
14
13
  <<~EOL
15
14
  It looks like you have already set up a custom domain.
@@ -25,7 +24,18 @@ class Jets::Resource::ApiGateway::RestApi::LogicalId
25
24
  More info: custom domain docs: https://rubyonjets.com/docs/routing/custom-domain/
26
25
  EOL
27
26
  else
28
- "Please set up a custom domain https://rubyonjets.com/docs/routing/custom-domain/"
27
+ <<~EOL
28
+ To avoid this prompt in the future, you can configure:
29
+
30
+ config/application.rb
31
+
32
+ config.api.auto_replace = true
33
+
34
+ However, you should also set up a custom domain for a "stable" endpoint.
35
+
36
+ https://rubyonjets.com/docs/routing/custom-domain/
37
+
38
+ EOL
29
39
  end
30
40
  end
31
41
 
@@ -7,51 +7,22 @@ class Jets::Resource::ApiGateway::RestApi::Routes::Change
7
7
  new.changed?
8
8
  end
9
9
 
10
- # Build up deployed routes from the existing CloudFormation resources.
10
+ # Recreate routes from previously deployed stored state in s3
11
11
  def deployed_routes
12
- routes = []
13
-
14
- resources, position = [], true
15
- while position
16
- position = nil if position == true # start of loop
17
- resp = apigateway.get_resources(
18
- rest_api_id: rest_api_id,
19
- position: position,
20
- limit: 500, # default: 25 max: 500
12
+ state = Jets::Router::State.new
13
+ data = state.load("routes")
14
+ return [] if data.nil?
15
+
16
+ data.map do |item|
17
+ Jets::Router::Route.new(
18
+ path: item['path'],
19
+ method: item['options']['method'],
20
+ to: item['to'],
21
21
  )
22
- resources += resp.items
23
- position = resp.position
24
- end
25
-
26
- resources.each do |resource|
27
- resource_methods = resource.resource_methods
28
- next if resource_methods.nil?
29
-
30
- resource_methods.each do |http_verb, resource_method|
31
- # puts "#{http_verb} #{resource.path} | resource.id #{resource.id}"
32
- # puts to(resource.id, http_verb)
33
-
34
- # Test changing config.cors and CloudFormation does an in-place update
35
- # on the resource. So no need to do bluegreen deployments for OPTIONS.
36
- next if http_verb == "OPTIONS"
37
-
38
- path = recreate_path(resource.path)
39
- method = http_verb.downcase.to_sym
40
- to = to(resource.id, http_verb)
41
- route = Jets::Router::Route.new(path: path, method: method, to: to)
42
- routes << route
43
- end
44
22
  end
45
- routes
46
23
  end
47
24
  memoize :deployed_routes
48
25
 
49
- def recreate_path(path)
50
- path = path.gsub(%r{^/},'')
51
- path = path.gsub(/{([^}]*)\+}/, '*\1')
52
- path.gsub(/{([^}]*)}/, ':\1')
53
- end
54
-
55
26
  def to(resource_id, http_method)
56
27
  uri = method_uri(resource_id, http_method)
57
28
  recreate_to(uri) unless uri.nil?
@@ -5,6 +5,7 @@ module Jets::Resource::ApiGateway
5
5
  name: Jets::Naming.gateway_api_name,
6
6
  endpoint_configuration: { types: endpoint_types }
7
7
  }
8
+ properties[:endpoint_configuration][:vpc_endpoint_ids] = vpce_ids if vpce_ids
8
9
  properties[:binary_media_types] = binary_media_types if binary_media_types
9
10
  properties[:policy] = endpoint_policy if endpoint_policy
10
11
 
@@ -60,5 +61,14 @@ module Jets::Resource::ApiGateway
60
61
 
61
62
  endpoint_policy
62
63
  end
64
+
65
+ private
66
+
67
+ def vpce_ids
68
+ ids = Jets.config.api.vpc_endpoint_ids
69
+ return nil if ids.nil? || ids.empty?
70
+
71
+ ids
72
+ end
63
73
  end
64
- end
74
+ end
@@ -191,6 +191,10 @@ class Jets::Router
191
191
  @options[:mount_class]
192
192
  end
193
193
 
194
+ def to_h
195
+ JSON.load(to_json)
196
+ end
197
+
194
198
  private
195
199
  def ensure_jets_format(path)
196
200
  path.split('/').map do |s|
@@ -0,0 +1,47 @@
1
+ class Jets::Router
2
+ class State
3
+ extend Memoist
4
+ include Jets::AwsServices
5
+
6
+ def load(filename)
7
+ resp = s3.get_object(
8
+ bucket: Jets.aws.s3_bucket,
9
+ key: s3_storage_path(filename),
10
+ )
11
+ text = resp.body.read
12
+ JSON.load(text)
13
+ rescue
14
+ end
15
+ memoize :load
16
+
17
+ # Save previously deployed APIGW routes state
18
+ def save(filename, data)
19
+ # body = Jets::Router.routes.to_json
20
+ # body = JSON.generate(Jets::Cfn::Builders::PageBuilder.pages)
21
+ body = data.respond_to?(:to_json) ? data.to_json : JSON.generate(data)
22
+ s3.put_object(
23
+ body: body,
24
+ bucket: Jets.aws.s3_bucket,
25
+ key: s3_storage_path(filename),
26
+ )
27
+ end
28
+
29
+ # Examples:
30
+ #
31
+ # jets/state/apigw/pages.json
32
+ # jets/state/apigw/routes.json
33
+ #
34
+ # Fetch or loaded in:
35
+ #
36
+ # pages.json: Jets::Cfn::Builders::PageBuilder#old_pages
37
+ # routes.json: Jets::Resource::ApiGateway::RestApi::Routes::Change::Base#deployed_routes
38
+ #
39
+ # Saved in:
40
+ #
41
+ # Jets::Cfn::Ship#save_apigw_state
42
+ #
43
+ def s3_storage_path(filename)
44
+ "jets/state/apigw/#{filename}.json"
45
+ end
46
+ end
47
+ end
data/lib/jets/router.rb CHANGED
@@ -134,6 +134,18 @@ module Jets
134
134
  drawn_router.routes
135
135
  end
136
136
 
137
+ # So we can save state in s3 post deploy. Example of structure.
138
+ #
139
+ # [
140
+ # {"scope"=>{"options"=>{"as"=>"posts", "prefix"=>"posts", "param"=>nil, "from"=>"resources"}, "parent"=>{"options"=>{}, "parent"=>nil, "level"=>1}, "level"=>2}, "options"=>{"to"=>"posts#index", "from_scope"=>true, "path"=>"posts", "method"=>"get"}, "path"=>"posts", "to"=>"posts#index", "as"=>"posts"},
141
+ # {"scope"=>{"options"=>{"as"=>"posts", "prefix"=>"posts", "param"=>nil, "from"=>"resources"}, "parent"=>{"options"=>{}, "parent"=>nil, "level"=>1}, "level"=>2}, "options"=>{"to"=>"posts#new", "from_scope"=>true, "path"=>"posts/new", "method"=>"get"}, "path"=>"posts/new", "to"=>"posts#new", "as"=>"new_post"},
142
+ # ...
143
+ # ]
144
+ #
145
+ def to_json
146
+ JSON.dump(routes.map(&:to_h))
147
+ end
148
+
137
149
  # Returns all paths including subpaths.
138
150
  # Example:
139
151
  # Input: ["posts/:id/edit"]
data/lib/jets/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Jets
2
- VERSION = "3.1.4"
2
+ VERSION = "3.2.0"
3
3
  end
data/lib/jets.rb CHANGED
@@ -17,6 +17,7 @@ require "active_support/ordered_options"
17
17
  require "cfn_camelizer"
18
18
  require "cfn_status"
19
19
  require "fileutils"
20
+ require "json"
20
21
  require "memoist"
21
22
  require "rainbow/ext/string"
22
23
  require "serverlessgems"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jets
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.4
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tung Nguyen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-07-02 00:00:00.000000000 Z
11
+ date: 2022-12-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionmailer
@@ -661,6 +661,7 @@ files:
661
661
  - lib/jets/cfn/builders/function_builder.rb
662
662
  - lib/jets/cfn/builders/interface.rb
663
663
  - lib/jets/cfn/builders/job_builder.rb
664
+ - lib/jets/cfn/builders/page_builder.rb
664
665
  - lib/jets/cfn/builders/parent_builder.rb
665
666
  - lib/jets/cfn/builders/parent_builder/stagger.rb
666
667
  - lib/jets/cfn/builders/rule_builder.rb
@@ -986,6 +987,7 @@ files:
986
987
  - lib/jets/router/route.rb
987
988
  - lib/jets/router/route/authorizer.rb
988
989
  - lib/jets/router/scope.rb
990
+ - lib/jets/router/state.rb
989
991
  - lib/jets/router/util.rb
990
992
  - lib/jets/rule/base.rb
991
993
  - lib/jets/rule/dsl.rb