lex-node 0.2.0 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +40 -10
  5. data/CHANGELOG.md +43 -0
  6. data/CLAUDE.md +109 -0
  7. data/Dockerfile +1 -1
  8. data/Gemfile +2 -0
  9. data/README.md +65 -5
  10. data/docker_deploy.rb +1 -0
  11. data/lex-node.gemspec +6 -1
  12. data/lib/legion/extensions/node/actors/beat.rb +27 -19
  13. data/lib/legion/extensions/node/actors/crypt.rb +12 -4
  14. data/lib/legion/extensions/node/actors/push_key.rb +27 -19
  15. data/lib/legion/extensions/node/actors/vault.rb +27 -19
  16. data/lib/legion/extensions/node/actors/vault_token_request.rb +27 -19
  17. data/lib/legion/extensions/node/data_test/migrations/001_nodes_table.rb +2 -0
  18. data/lib/legion/extensions/node/data_test/migrations/002_node_history_table.rb +2 -1
  19. data/lib/legion/extensions/node/data_test/migrations/003_legion_version_colume.rb +2 -0
  20. data/lib/legion/extensions/node/data_test/migrations/004_node_extensions.rb +2 -1
  21. data/lib/legion/extensions/node/runners/beat.rb +17 -9
  22. data/lib/legion/extensions/node/runners/node.rb +132 -52
  23. data/lib/legion/extensions/node/runners/vault.rb +52 -36
  24. data/lib/legion/extensions/node/transport/exchanges/node.rb +12 -6
  25. data/lib/legion/extensions/node/transport/messages/beat.rb +82 -22
  26. data/lib/legion/extensions/node/transport/messages/public_key.rb +24 -14
  27. data/lib/legion/extensions/node/transport/messages/push_cluster_secret.rb +34 -24
  28. data/lib/legion/extensions/node/transport/messages/push_vault_token.rb +34 -24
  29. data/lib/legion/extensions/node/transport/messages/request_cluster_secret.rb +26 -16
  30. data/lib/legion/extensions/node/transport/messages/request_public_keys.rb +23 -13
  31. data/lib/legion/extensions/node/transport/messages/request_vault_token.rb +33 -21
  32. data/lib/legion/extensions/node/transport/messages/update_result.rb +36 -0
  33. data/lib/legion/extensions/node/transport/queues/crypt.rb +14 -4
  34. data/lib/legion/extensions/node/transport/queues/health.rb +14 -4
  35. data/lib/legion/extensions/node/transport/queues/node.rb +17 -8
  36. data/lib/legion/extensions/node/transport/queues/vault.rb +14 -4
  37. data/lib/legion/extensions/node/transport.rb +17 -8
  38. data/lib/legion/extensions/node/version.rb +3 -1
  39. data/lib/legion/extensions/node.rb +2 -0
  40. metadata +24 -11
  41. data/.github/workflows/rspec.yml +0 -69
  42. data/.github/workflows/rubocop.yml +0 -28
  43. data/lib/legion/extensions/node/runners/crypt.rb +0 -61
@@ -1,13 +1,21 @@
1
- module Legion::Extensions::Node::Runners
2
- module Beat
3
- include Legion::Extensions::Helpers::Transport
1
+ # frozen_string_literal: true
4
2
 
5
- def beat(status: 'active', **opts)
6
- log.debug 'sending hearbeat'
7
- messages::Beat.new(status: status).publish
8
- { success: true, status: status, version: Legion::VERSION || nil, **opts }
9
- end
3
+ module Legion
4
+ module Extensions
5
+ module Node
6
+ module Runners
7
+ module Beat
8
+ include Legion::Extensions::Helpers::Transport
9
+
10
+ def beat(status: 'active', **opts)
11
+ log.debug 'sending heartbeat'
12
+ messages::Beat.new(status: status).publish
13
+ { success: true, status: status, version: defined?(Legion::VERSION) ? Legion::VERSION : nil, **opts }
14
+ end
10
15
 
11
- include Legion::Extensions::Helpers::Lex
16
+ include Legion::Extensions::Helpers::Lex
17
+ end
18
+ end
19
+ end
12
20
  end
13
21
  end
@@ -1,61 +1,141 @@
1
- module Legion::Extensions::Node::Runners
2
- module Node
3
- def message(_options = {}, **hash)
4
- log.debug 'message'
5
- hash.each do |k, v|
6
- raise 'Cannot override base setting that doesn\'t exist' if Legion::Settings[k].nil?
7
-
8
- case v
9
- when String
10
- Legion::Settings[k] = v
11
- when Hash
12
- v.each do |key, value|
13
- Legion::Settings[k][key] = value
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Node
8
+ module Runners
9
+ module Node
10
+ def message(_options = {}, **hash)
11
+ log.debug 'message'
12
+ hash.each do |k, v|
13
+ raise 'Cannot override base setting that doesn\'t exist' if Legion::Settings[k].nil?
14
+
15
+ case v
16
+ when String
17
+ Legion::Settings[k] = v
18
+ when Hash
19
+ v.each do |key, value|
20
+ Legion::Settings[k][key] = value
21
+ end
22
+ end
23
+ end
14
24
  end
15
- end
16
- end
17
- end
18
25
 
19
- def push_public_key(**_opts)
20
- log.debug 'push_public_key'
21
- message_hash = { function: 'update_public_key',
22
- public_key: Legion::Crypt.public_key.to_s,
23
- **Legion::Settings[:client] }
24
- Legion::Extensions::Node::Transport::Messages::PublicKey.new(**message_hash).publish
25
- {}
26
- end
26
+ def update_gem(extension:, version: nil, reload: true, **_opts)
27
+ name = extension.to_s.delete_prefix('lex-')
28
+ gem_name = "lex-#{name}"
29
+ log.debug "update_gem: installing #{gem_name} #{version || 'latest'}"
27
30
 
28
- def update_public_key(name:, public_key:, **_opts)
29
- log.debug 'update_public_key'
30
- Legion::Settings[:cluster][:public_keys][name] = public_key
31
- {}
32
- end
31
+ Gem.install(gem_name, version)
32
+ Legion.reload if reload
33
33
 
34
- def push_cluster_secret(public_key:, queue_name:, **_opts)
35
- log.debug 'push_cluster_secret'
36
- return {} unless Legion::Settings[:crypt][:cs_encrypt_ready]
37
-
38
- encrypted = Legion::Crypt.encrypt_from_keypair(pub_key: public_key,
39
- message: Legion::Settings[:crypt][:cluster_secret].to_s)
40
- legion = Legion::Crypt.encrypt('legion')
41
- Legion::Extensions::Node::Transport::Messages::PushClusterSecret.new(message: encrypted,
42
- queue_name: queue_name,
43
- validation_string: 'legion',
44
- encrypted_string: legion).publish
45
- {}
46
- end
34
+ publish_update_result(action: 'update_gem', status: 'success', detail: "#{gem_name} #{version || 'latest'}")
35
+ rescue StandardError => e
36
+ log.error "update_gem failed: #{e.message}"
37
+ publish_update_result(action: 'update_gem', status: 'failed', detail: gem_name, error: e.message)
38
+ end
47
39
 
48
- def receive_cluster_secret(message:, **_opts)
49
- log.debug 'receive_cluster_secret'
50
- Legion::Settings[:crypt][:cluster_secret] = Legion::Crypt.decrypt_from_keypair(message: message)
51
- {}
52
- end
40
+ def update_settings(settings:, restart: false, **_opts)
41
+ log.debug "update_settings: merging #{settings.keys.join(', ')}"
53
42
 
54
- def receive_vault_token(message:, routing_key:, public_key:, **)
55
- Legion::Extensions::Node::Runners::Vault.receive_vault_token(message: message, routing_key: routing_key,
56
- public_key: public_key)
57
- end
43
+ settings.each do |k, v|
44
+ case v
45
+ when Hash
46
+ Legion::Settings[k] ||= {}
47
+ v.each { |key, value| Legion::Settings[k][key] = value }
48
+ else
49
+ Legion::Settings[k] = v
50
+ end
51
+ end
52
+
53
+ Legion.reload if restart
54
+
55
+ publish_update_result(action: 'update_settings', status: 'success',
56
+ detail: "keys: #{settings.keys.join(', ')}")
57
+ rescue StandardError => e
58
+ log.error "update_settings failed: #{e.message}"
59
+ publish_update_result(action: 'update_settings', status: 'failed', error: e.message)
60
+ end
61
+
62
+ def push_public_key(**_opts)
63
+ log.debug 'push_public_key'
64
+ message_hash = { function: 'update_public_key',
65
+ public_key: Base64.encode64(Legion::Crypt.public_key),
66
+ **Legion::Settings[:client] }
67
+ Legion::Extensions::Node::Transport::Messages::PublicKey.new(message_hash).publish
68
+ {}
69
+ end
70
+
71
+ def update_public_key(name:, public_key:, **_opts)
72
+ log.debug 'update_public_key'
73
+ Legion::Settings[:cluster][:public_keys][name] = public_key
74
+ {}
75
+ end
58
76
 
59
- include Legion::Extensions::Helpers::Lex
77
+ def delete_public_key(name:, **_opts)
78
+ log.debug 'delete_public_key'
79
+ Legion::Settings[:cluster][:public_keys].delete(name)
80
+ {}
81
+ end
82
+
83
+ def request_public_keys(**_opts)
84
+ log.debug 'request_public_keys'
85
+ message_hash = { function: 'push_public_key' }
86
+ Legion::Extensions::Node::Transport::Messages::RequestPublicKeys.new(**message_hash).publish
87
+ {}
88
+ end
89
+
90
+ def request_cluster_secret(**_opts)
91
+ log.debug 'request_cluster_secret'
92
+ Legion::Extensions::Node::Transport::Messages::RequestClusterSecret.new.publish
93
+ {}
94
+ end
95
+
96
+ def push_cluster_secret(public_key:, queue_name:, **_opts)
97
+ log.debug 'push_cluster_secret'
98
+ return {} unless Legion::Settings[:crypt][:cs_encrypt_ready]
99
+
100
+ encrypted = Legion::Crypt.encrypt_from_keypair(pub_key: public_key,
101
+ message: Legion::Settings[:crypt][:cluster_secret].to_s)
102
+ legion = Legion::Crypt.encrypt('legion')
103
+ Legion::Extensions::Node::Transport::Messages::PushClusterSecret.new(message: encrypted,
104
+ queue_name: queue_name,
105
+ validation_string: 'legion',
106
+ encrypted_string: legion).publish
107
+ {}
108
+ end
109
+
110
+ def receive_cluster_secret(message:, **opts)
111
+ log.debug 'receive_cluster_secret'
112
+ Legion::Settings[:crypt][:cluster_secret] = Legion::Crypt.decrypt_from_keypair(message: message)
113
+ Legion::Settings[:crypt][:encrypted_string] = opts[:encrypted_string]
114
+ Legion::Settings[:crypt][:validation_string] = opts[:validation_string]
115
+ {}
116
+ end
117
+
118
+ def receive_vault_token(message:, routing_key:, public_key:, **)
119
+ Legion::Extensions::Node::Runners::Vault.receive_vault_token(message: message, routing_key: routing_key,
120
+ public_key: public_key)
121
+ end
122
+
123
+ private
124
+
125
+ def publish_update_result(action:, status:, detail: nil, error: nil)
126
+ Legion::Extensions::Node::Transport::Messages::UpdateResult.new(
127
+ action: action,
128
+ status: status,
129
+ detail: detail,
130
+ error: error,
131
+ node: Legion::Settings[:client][:name],
132
+ timestamp: Time.now.utc.iso8601
133
+ ).publish
134
+ end
135
+
136
+ include Legion::Extensions::Helpers::Lex
137
+ end
138
+ end
139
+ end
60
140
  end
61
141
  end
@@ -1,40 +1,56 @@
1
- module Legion::Extensions::Node::Runners
2
- module Vault
3
- def request_token(**)
4
- return {} if Legion::Settings[:crypt][:vault][:connected]
5
- return {} unless Legion::Settings[:crypt][:vault][:enabled]
6
-
7
- request_vault_token
8
- end
9
-
10
- def request_vault_token(**)
11
- Legion::Extensions::Node::Transport::Messages::RequestVaultToken.new.publish
12
- {}
13
- end
14
-
15
- def receive_vault_token(message:, **opts) # rubocop:disable Metrics/AbcSize
16
- return if Legion::Settings[:crypt][:vault][:connected]
17
-
18
- Legion::Settings[:crypt][:vault][:token] = Legion::Crypt.decrypt_from_keypair(message: message)
19
- %i[protocol address port].each do |setting|
20
- next unless opts.key? setting
21
- next unless Legion::Settings[:crypt][:vault][setting].nil?
22
-
23
- Legion::Settings[:crypt][:vault][setting] = opts[setting]
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Node
6
+ module Runners
7
+ module Vault
8
+ def request_token(**)
9
+ return {} if Legion::Settings[:crypt][:vault][:connected]
10
+ return {} unless Legion::Settings[:crypt][:vault][:enabled]
11
+
12
+ request_vault_token
13
+ end
14
+
15
+ def request_vault_token(**)
16
+ Legion::Extensions::Node::Transport::Messages::RequestVaultToken.new.publish
17
+ {}
18
+ end
19
+
20
+ def receive_vault_token(message: nil, token: nil, cluster_name: nil, **opts)
21
+ return { success: false, already_connected: true } if Legion::Settings[:crypt][:vault][:connected]
22
+
23
+ token ||= Legion::Crypt.decrypt_from_keypair(message: message)
24
+ clusters = Legion::Settings[:crypt][:vault][:clusters]
25
+ if cluster_name && clusters.is_a?(Hash) && clusters[cluster_name.to_sym]
26
+ clusters[cluster_name.to_sym][:token] = token
27
+ clusters[cluster_name.to_sym][:connected] = true
28
+ return { success: true }
29
+ end
30
+
31
+ Legion::Settings[:crypt][:vault][:token] = token
32
+ %i[protocol address port].each do |setting|
33
+ next unless opts.key? setting
34
+ next unless Legion::Settings[:crypt][:vault][setting].nil?
35
+
36
+ Legion::Settings[:crypt][:vault][setting] = opts[setting]
37
+ end
38
+ Legion::Crypt.connect_vault
39
+ {}
40
+ end
41
+
42
+ def push_vault_token(public_key:, node_name:, **)
43
+ return {} unless Legion::Settings[:crypt][:vault][:token]
44
+
45
+ encrypted = Legion::Crypt.encrypt_from_keypair(message: Legion::Settings[:crypt][:vault][:token],
46
+ pub_key: public_key)
47
+ Legion::Extensions::Node::Transport::Messages::PushVaultToken.new(token: encrypted, queue_name: node_name).publish
48
+ {}
49
+ end
50
+
51
+ include Legion::Extensions::Helpers::Lex
52
+ end
24
53
  end
25
- Legion::Crypt.connect_vault
26
- {}
27
54
  end
28
-
29
- def push_vault_token(public_key:, node_name:, **)
30
- return {} unless Legion::Settings[:crypt][:vault][:token]
31
-
32
- encrypted = Legion::Crypt.encrypt_from_keypair(message: Legion::Settings[:crypt][:vault][:token],
33
- pub_key: public_key)
34
- Legion::Extensions::Node::Transport::Messages::PushVaultToken.new(token: encrypted, queue_name: node_name).publish
35
- {}
36
- end
37
-
38
- include Legion::Extensions::Helpers::Lex
39
55
  end
40
56
  end
@@ -1,11 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'legion/transport/exchanges/node'
2
4
 
3
- module Legion::Extensions::Node
4
- module Transport
5
- module Exchanges
6
- class Node < Legion::Transport::Exchanges::Node
7
- def exchange_name
8
- 'node'
5
+ module Legion
6
+ module Extensions
7
+ module Node
8
+ module Transport
9
+ module Exchanges
10
+ class Node < Legion::Transport::Exchanges::Node
11
+ def exchange_name
12
+ 'node'
13
+ end
14
+ end
9
15
  end
10
16
  end
11
17
  end
@@ -1,31 +1,91 @@
1
- module Legion::Extensions::Node::Transport::Messages
2
- class Beat < Legion::Transport::Message
3
- def routing_key
4
- 'status'
5
- end
1
+ # frozen_string_literal: true
6
2
 
7
- def type
8
- 'heartbeat'
9
- end
3
+ module Legion
4
+ module Extensions
5
+ module Node
6
+ module Transport
7
+ module Messages
8
+ class Beat < Legion::Transport::Message
9
+ BOOT_TIME = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
10
+ def routing_key
11
+ 'status'
12
+ end
10
13
 
11
- def expiration
12
- 5000
13
- end
14
+ def type
15
+ 'heartbeat'
16
+ end
14
17
 
15
- def encrypt?
16
- false
17
- end
18
+ def expiration
19
+ 5000
20
+ end
18
21
 
19
- def message
20
- hash = { name: Legion::Settings[:client][:hostname], pid: Process.pid, timestamp: Time.now }
21
- hash[:status] = @options[:status].nil? ? 'healthy' : @options[:status]
22
- hash
23
- end
22
+ def encrypt?
23
+ false
24
+ end
25
+
26
+ def message
27
+ hash = {
28
+ name: Legion::Settings[:client][:name],
29
+ pid: ::Process.pid,
30
+ timestamp: Time.now,
31
+ status: @options[:status].nil? ? 'healthy' : @options[:status]
32
+ }
33
+ hash[:version] = Legion::VERSION if defined?(Legion::VERSION)
34
+ hash[:metrics] = collect_metrics
35
+ hash[:hosted_worker_ids] = collect_worker_ids
36
+ hash
37
+ end
38
+
39
+ def validate
40
+ raise 'status should be a string' unless @options[:status].is_a?(String) || @options[:status].nil?
41
+
42
+ @valid = true
43
+ end
44
+
45
+ private
46
+
47
+ def collect_metrics
48
+ times = ::Process.times
49
+ {
50
+ memory_rss_mb: rss_mb,
51
+ cpu_user_seconds: times.utime.round(2),
52
+ cpu_system_seconds: times.stime.round(2),
53
+ thread_count: Thread.list.count,
54
+ loaded_extensions: loaded_extension_count,
55
+ uptime_seconds: uptime_seconds
56
+ }
57
+ end
58
+
59
+ def rss_mb
60
+ if RUBY_PLATFORM.include?('darwin')
61
+ `ps -o rss= -p #{::Process.pid}`.strip.to_i / 1024.0
62
+ else
63
+ File.read("/proc/#{::Process.pid}/statm").split[1].to_i * (4096.0 / 1_048_576)
64
+ end
65
+ rescue StandardError
66
+ 0.0
67
+ end
68
+
69
+ def loaded_extension_count
70
+ return 0 unless defined?(Legion::Extensions)
71
+
72
+ Legion::Extensions.respond_to?(:loaded_extensions) ? Legion::Extensions.loaded_extensions.count : 0
73
+ end
74
+
75
+ def uptime_seconds
76
+ (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - BOOT_TIME).round(0)
77
+ end
24
78
 
25
- def validate
26
- raise 'status should be a string' unless @options[:status].is_a?(String) || @options[:status].nil?
79
+ def collect_worker_ids
80
+ return [] unless defined?(Legion::DigitalWorker)
27
81
 
28
- @valid = true
82
+ Legion::DigitalWorker.active_local_ids
83
+ rescue StandardError
84
+ []
85
+ end
86
+ end
87
+ end
88
+ end
29
89
  end
30
90
  end
31
91
  end
@@ -1,21 +1,31 @@
1
- module Legion::Extensions::Node::Transport::Messages
2
- class PublicKey < Legion::Transport::Message
3
- def routing_key
4
- 'node.crypt.update_public_key'
5
- end
1
+ # frozen_string_literal: true
6
2
 
7
- def type
8
- 'task'
9
- end
3
+ module Legion
4
+ module Extensions
5
+ module Node
6
+ module Transport
7
+ module Messages
8
+ class PublicKey < Legion::Transport::Message
9
+ def routing_key
10
+ 'node.crypt.update_public_key'
11
+ end
10
12
 
11
- def encrypt?
12
- false
13
- end
13
+ def type
14
+ 'task'
15
+ end
16
+
17
+ def encrypt?
18
+ false
19
+ end
14
20
 
15
- def validate
16
- raise 'public_key should be a string' unless @options[:public_key].is_a?(String)
21
+ def validate
22
+ raise 'public_key should be a string' unless @options[:public_key].is_a?(String)
17
23
 
18
- @valid = true
24
+ @valid = true
25
+ end
26
+ end
27
+ end
28
+ end
19
29
  end
20
30
  end
21
31
  end
@@ -1,32 +1,42 @@
1
- module Legion::Extensions::Node::Transport::Messages
2
- class PushClusterSecret < Legion::Transport::Message
3
- def routing_key
4
- @options[:queue_name]
5
- end
1
+ # frozen_string_literal: true
6
2
 
7
- def exchange
8
- Legion::Transport::Exchanges::Node
9
- end
3
+ module Legion
4
+ module Extensions
5
+ module Node
6
+ module Transport
7
+ module Messages
8
+ class PushClusterSecret < Legion::Transport::Message
9
+ def routing_key
10
+ @options[:queue_name]
11
+ end
10
12
 
11
- def message
12
- { function: 'receive_cluster_secret',
13
- runner_class: 'Legion::Extensions::Node::Runners::Crypt',
14
- message: @options[:message],
15
- validation_string: @options[:validation_string] || nil,
16
- encrypted_string: @options[:encrypted_string] || nil,
17
- public_key: Base64.encode64(Legion::Crypt.public_key) }
18
- end
13
+ def exchange
14
+ Legion::Transport::Exchanges::Node
15
+ end
19
16
 
20
- def type
21
- 'task'
22
- end
17
+ def message
18
+ { function: 'receive_cluster_secret',
19
+ runner_class: 'Legion::Extensions::Node::Runners::Crypt',
20
+ message: @options[:message],
21
+ validation_string: @options[:validation_string],
22
+ encrypted_string: @options[:encrypted_string],
23
+ public_key: Base64.encode64(Legion::Crypt.public_key) }
24
+ end
23
25
 
24
- def encrypt?
25
- false
26
- end
26
+ def type
27
+ 'task'
28
+ end
29
+
30
+ def encrypt?
31
+ false
32
+ end
27
33
 
28
- def validate
29
- @valid = true
34
+ def validate
35
+ @valid = true
36
+ end
37
+ end
38
+ end
39
+ end
30
40
  end
31
41
  end
32
42
  end
@@ -1,32 +1,42 @@
1
- module Legion::Extensions::Node::Transport::Messages
2
- class PushVaultToken < Legion::Transport::Message
3
- def routing_key
4
- "node.#{@options[:queue_name]}"
5
- end
1
+ # frozen_string_literal: true
6
2
 
7
- def exchange
8
- Legion::Transport::Exchanges::Node
9
- end
3
+ module Legion
4
+ module Extensions
5
+ module Node
6
+ module Transport
7
+ module Messages
8
+ class PushVaultToken < Legion::Transport::Message
9
+ def routing_key
10
+ "node.#{@options[:queue_name]}"
11
+ end
10
12
 
11
- def message
12
- {
13
- function: 'receive_vault_token',
14
- runner_class: 'Legion::Extensions::Node::Runners::Vault',
15
- message: @options[:token],
16
- public_key: Base64.encode64(Legion::Crypt.public_key)
17
- }
18
- end
13
+ def exchange
14
+ Legion::Transport::Exchanges::Node
15
+ end
19
16
 
20
- def type
21
- 'task'
22
- end
17
+ def message
18
+ {
19
+ function: 'receive_vault_token',
20
+ runner_class: 'Legion::Extensions::Node::Runners::Vault',
21
+ message: @options[:token],
22
+ public_key: Base64.encode64(Legion::Crypt.public_key)
23
+ }
24
+ end
23
25
 
24
- def encrypt?
25
- false
26
- end
26
+ def type
27
+ 'task'
28
+ end
29
+
30
+ def encrypt?
31
+ false
32
+ end
27
33
 
28
- def validate
29
- @valid = true
34
+ def validate
35
+ @valid = true
36
+ end
37
+ end
38
+ end
39
+ end
30
40
  end
31
41
  end
32
42
  end