ext_backup 5.0.0.beta.2.1

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 (137) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +19 -0
  3. data/README.md +33 -0
  4. data/bin/backup +5 -0
  5. data/bin/docker_test +24 -0
  6. data/lib/backup.rb +140 -0
  7. data/lib/backup/archive.rb +169 -0
  8. data/lib/backup/binder.rb +18 -0
  9. data/lib/backup/cleaner.rb +112 -0
  10. data/lib/backup/cli.rb +370 -0
  11. data/lib/backup/cloud_io/base.rb +38 -0
  12. data/lib/backup/cloud_io/cloud_files.rb +296 -0
  13. data/lib/backup/cloud_io/s3.rb +253 -0
  14. data/lib/backup/compressor/base.rb +32 -0
  15. data/lib/backup/compressor/bzip2.rb +35 -0
  16. data/lib/backup/compressor/custom.rb +49 -0
  17. data/lib/backup/compressor/gzip.rb +73 -0
  18. data/lib/backup/config.rb +128 -0
  19. data/lib/backup/config/dsl.rb +102 -0
  20. data/lib/backup/config/helpers.rb +137 -0
  21. data/lib/backup/database/base.rb +86 -0
  22. data/lib/backup/database/mongodb.rb +186 -0
  23. data/lib/backup/database/mysql.rb +191 -0
  24. data/lib/backup/database/openldap.rb +93 -0
  25. data/lib/backup/database/postgresql.rb +132 -0
  26. data/lib/backup/database/redis.rb +176 -0
  27. data/lib/backup/database/riak.rb +79 -0
  28. data/lib/backup/database/sqlite.rb +55 -0
  29. data/lib/backup/encryptor/base.rb +27 -0
  30. data/lib/backup/encryptor/gpg.rb +737 -0
  31. data/lib/backup/encryptor/open_ssl.rb +74 -0
  32. data/lib/backup/errors.rb +53 -0
  33. data/lib/backup/logger.rb +197 -0
  34. data/lib/backup/logger/console.rb +48 -0
  35. data/lib/backup/logger/fog_adapter.rb +25 -0
  36. data/lib/backup/logger/logfile.rb +131 -0
  37. data/lib/backup/logger/syslog.rb +114 -0
  38. data/lib/backup/model.rb +472 -0
  39. data/lib/backup/notifier/base.rb +126 -0
  40. data/lib/backup/notifier/campfire.rb +61 -0
  41. data/lib/backup/notifier/command.rb +99 -0
  42. data/lib/backup/notifier/datadog.rb +104 -0
  43. data/lib/backup/notifier/flowdock.rb +99 -0
  44. data/lib/backup/notifier/hipchat.rb +116 -0
  45. data/lib/backup/notifier/http_post.rb +114 -0
  46. data/lib/backup/notifier/mail.rb +232 -0
  47. data/lib/backup/notifier/nagios.rb +65 -0
  48. data/lib/backup/notifier/pagerduty.rb +79 -0
  49. data/lib/backup/notifier/prowl.rb +68 -0
  50. data/lib/backup/notifier/pushover.rb +71 -0
  51. data/lib/backup/notifier/ses.rb +123 -0
  52. data/lib/backup/notifier/slack.rb +147 -0
  53. data/lib/backup/notifier/twitter.rb +55 -0
  54. data/lib/backup/notifier/zabbix.rb +60 -0
  55. data/lib/backup/package.rb +51 -0
  56. data/lib/backup/packager.rb +106 -0
  57. data/lib/backup/pipeline.rb +120 -0
  58. data/lib/backup/splitter.rb +73 -0
  59. data/lib/backup/storage/base.rb +66 -0
  60. data/lib/backup/storage/cloud_files.rb +156 -0
  61. data/lib/backup/storage/cycler.rb +70 -0
  62. data/lib/backup/storage/dropbox.rb +206 -0
  63. data/lib/backup/storage/ftp.rb +116 -0
  64. data/lib/backup/storage/local.rb +61 -0
  65. data/lib/backup/storage/qiniu.rb +65 -0
  66. data/lib/backup/storage/rsync.rb +246 -0
  67. data/lib/backup/storage/s3.rb +155 -0
  68. data/lib/backup/storage/scp.rb +65 -0
  69. data/lib/backup/storage/sftp.rb +80 -0
  70. data/lib/backup/syncer/base.rb +67 -0
  71. data/lib/backup/syncer/cloud/base.rb +176 -0
  72. data/lib/backup/syncer/cloud/cloud_files.rb +81 -0
  73. data/lib/backup/syncer/cloud/local_file.rb +97 -0
  74. data/lib/backup/syncer/cloud/s3.rb +109 -0
  75. data/lib/backup/syncer/rsync/base.rb +50 -0
  76. data/lib/backup/syncer/rsync/local.rb +27 -0
  77. data/lib/backup/syncer/rsync/pull.rb +47 -0
  78. data/lib/backup/syncer/rsync/push.rb +201 -0
  79. data/lib/backup/template.rb +41 -0
  80. data/lib/backup/utilities.rb +233 -0
  81. data/lib/backup/version.rb +3 -0
  82. data/lib/ext_backup.rb +5 -0
  83. data/lib/ext_backup/version.rb +5 -0
  84. data/templates/cli/archive +28 -0
  85. data/templates/cli/compressor/bzip2 +4 -0
  86. data/templates/cli/compressor/custom +7 -0
  87. data/templates/cli/compressor/gzip +4 -0
  88. data/templates/cli/config +123 -0
  89. data/templates/cli/databases/mongodb +15 -0
  90. data/templates/cli/databases/mysql +18 -0
  91. data/templates/cli/databases/openldap +24 -0
  92. data/templates/cli/databases/postgresql +16 -0
  93. data/templates/cli/databases/redis +16 -0
  94. data/templates/cli/databases/riak +17 -0
  95. data/templates/cli/databases/sqlite +11 -0
  96. data/templates/cli/encryptor/gpg +27 -0
  97. data/templates/cli/encryptor/openssl +9 -0
  98. data/templates/cli/model +26 -0
  99. data/templates/cli/notifier/zabbix +15 -0
  100. data/templates/cli/notifiers/campfire +12 -0
  101. data/templates/cli/notifiers/command +32 -0
  102. data/templates/cli/notifiers/datadog +57 -0
  103. data/templates/cli/notifiers/flowdock +16 -0
  104. data/templates/cli/notifiers/hipchat +16 -0
  105. data/templates/cli/notifiers/http_post +32 -0
  106. data/templates/cli/notifiers/mail +24 -0
  107. data/templates/cli/notifiers/nagios +13 -0
  108. data/templates/cli/notifiers/pagerduty +12 -0
  109. data/templates/cli/notifiers/prowl +11 -0
  110. data/templates/cli/notifiers/pushover +11 -0
  111. data/templates/cli/notifiers/ses +15 -0
  112. data/templates/cli/notifiers/slack +22 -0
  113. data/templates/cli/notifiers/twitter +13 -0
  114. data/templates/cli/splitter +7 -0
  115. data/templates/cli/storages/cloud_files +11 -0
  116. data/templates/cli/storages/dropbox +20 -0
  117. data/templates/cli/storages/ftp +13 -0
  118. data/templates/cli/storages/local +8 -0
  119. data/templates/cli/storages/qiniu +12 -0
  120. data/templates/cli/storages/rsync +17 -0
  121. data/templates/cli/storages/s3 +16 -0
  122. data/templates/cli/storages/scp +15 -0
  123. data/templates/cli/storages/sftp +15 -0
  124. data/templates/cli/syncers/cloud_files +22 -0
  125. data/templates/cli/syncers/rsync_local +20 -0
  126. data/templates/cli/syncers/rsync_pull +28 -0
  127. data/templates/cli/syncers/rsync_push +28 -0
  128. data/templates/cli/syncers/s3 +27 -0
  129. data/templates/general/links +3 -0
  130. data/templates/general/version.erb +2 -0
  131. data/templates/notifier/mail/failure.erb +16 -0
  132. data/templates/notifier/mail/success.erb +16 -0
  133. data/templates/notifier/mail/warning.erb +16 -0
  134. data/templates/storage/dropbox/authorization_url.erb +6 -0
  135. data/templates/storage/dropbox/authorized.erb +4 -0
  136. data/templates/storage/dropbox/cache_file_written.erb +10 -0
  137. metadata +506 -0
@@ -0,0 +1,116 @@
1
+ require "net/ftp"
2
+
3
+ module Backup
4
+ module Storage
5
+ class FTP < Base
6
+ include Storage::Cycler
7
+
8
+ ##
9
+ # Server credentials
10
+ attr_accessor :username, :password
11
+
12
+ ##
13
+ # Server IP Address and FTP port
14
+ attr_accessor :ip, :port
15
+
16
+ ##
17
+ # Use passive mode?
18
+ attr_accessor :passive_mode
19
+
20
+ ##
21
+ # Configure connection open and read timeouts.
22
+ # Net::FTP's open_timeout and read_timeout will both be configured using
23
+ # this setting.
24
+ # @!attribute [rw] timeout
25
+ # @param [Integer|Float]
26
+ # @return [Integer|Float]
27
+ attr_accessor :timeout
28
+
29
+ def initialize(model, storage_id = nil)
30
+ super
31
+
32
+ @port ||= 21
33
+ @path ||= "backups"
34
+ @passive_mode ||= false
35
+ @timeout ||= nil
36
+ path.sub!(/^~\//, "")
37
+ end
38
+
39
+ private
40
+
41
+ ##
42
+ # Establishes a connection to the remote server
43
+ #
44
+ # Note:
45
+ # Since the FTP port is defined as a constant in the Net::FTP class, and
46
+ # might be required to change by the user, we dynamically remove and
47
+ # re-add the constant with the provided port value
48
+ def connection
49
+ if Net::FTP.const_defined?(:FTP_PORT)
50
+ Net::FTP.send(:remove_const, :FTP_PORT)
51
+ end; Net::FTP.send(:const_set, :FTP_PORT, port)
52
+
53
+ # Ensure default passive mode to false.
54
+ # Note: The default passive setting changed between Ruby 2.2 and 2.3
55
+ if Net::FTP.respond_to?(:default_passive=)
56
+ Net::FTP.default_passive = false
57
+ end
58
+
59
+ Net::FTP.open(ip, username, password) do |ftp|
60
+ if timeout
61
+ ftp.open_timeout = timeout
62
+ ftp.read_timeout = timeout
63
+ end
64
+ ftp.passive = true if passive_mode
65
+ yield ftp
66
+ end
67
+ end
68
+
69
+ def transfer!
70
+ connection do |ftp|
71
+ create_remote_path(ftp)
72
+
73
+ package.filenames.each do |filename|
74
+ src = File.join(Config.tmp_path, filename)
75
+ dest = File.join(remote_path, filename)
76
+ Logger.info "Storing '#{ip}:#{dest}'..."
77
+ ftp.put(src, dest)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Called by the Cycler.
83
+ # Any error raised will be logged as a warning.
84
+ def remove!(package)
85
+ Logger.info "Removing backup package dated #{package.time}..."
86
+
87
+ remote_path = remote_path_for(package)
88
+ connection do |ftp|
89
+ package.filenames.each do |filename|
90
+ ftp.delete(File.join(remote_path, filename))
91
+ end
92
+
93
+ ftp.rmdir(remote_path)
94
+ end
95
+ end
96
+
97
+ ##
98
+ # Creates (if they don't exist yet) all the directories on the remote
99
+ # server in order to upload the backup file. Net::FTP does not support
100
+ # paths to directories that don't yet exist when creating new
101
+ # directories. Instead, we split the parts up in to an array (for each
102
+ # '/') and loop through that to create the directories one by one.
103
+ # Net::FTP raises an exception when the directory it's trying to create
104
+ # already exists, so we have rescue it
105
+ def create_remote_path(ftp)
106
+ path_parts = []
107
+ remote_path.split("/").each do |path_part|
108
+ path_parts << path_part
109
+ begin
110
+ ftp.mkdir(path_parts.join("/"))
111
+ rescue Net::FTPPermError; end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,61 @@
1
+ module Backup
2
+ module Storage
3
+ class Local < Base
4
+ include Storage::Cycler
5
+ class Error < Backup::Error; end
6
+
7
+ def initialize(model, storage_id = nil)
8
+ super
9
+
10
+ @path ||= "~/backups"
11
+ end
12
+
13
+ private
14
+
15
+ def transfer!
16
+ FileUtils.mkdir_p(remote_path)
17
+
18
+ transfer_method = package_movable? ? :mv : :cp
19
+ package.filenames.each do |filename|
20
+ src = File.join(Config.tmp_path, filename)
21
+ dest = File.join(remote_path, filename)
22
+ Logger.info "Storing '#{dest}'..."
23
+
24
+ FileUtils.send(transfer_method, src, dest)
25
+ end
26
+ end
27
+
28
+ # Called by the Cycler.
29
+ # Any error raised will be logged as a warning.
30
+ def remove!(package)
31
+ Logger.info "Removing backup package dated #{package.time}..."
32
+
33
+ FileUtils.rm_r(remote_path_for(package))
34
+ end
35
+
36
+ # expanded since this is a local path
37
+ def remote_path(pkg = package)
38
+ File.expand_path(super)
39
+ end
40
+ alias :remote_path_for :remote_path
41
+
42
+ ##
43
+ # If this Local Storage is not the last Storage for the Model,
44
+ # force the transfer to use a *copy* operation and issue a warning.
45
+ def package_movable?
46
+ if self == model.storages.last
47
+ true
48
+ else
49
+ Logger.warn Error.new(<<-EOS)
50
+ Local File Copy Warning!
51
+ The final backup file(s) for '#{model.label}' (#{model.trigger})
52
+ will be *copied* to '#{remote_path}'
53
+ To avoid this, when using more than one Storage, the 'Local' Storage
54
+ should be added *last* so the files may be *moved* to their destination.
55
+ EOS
56
+ false
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,65 @@
1
+ require "qiniu"
2
+
3
+ module Backup
4
+ module Storage
5
+ class Qiniu < Base
6
+ include Storage::Cycler
7
+ class Error < Backup::Error; end
8
+
9
+ ##
10
+ # Qiniu API credentials
11
+ attr_accessor :access_key, :secret_key
12
+
13
+ ##
14
+ # Qiniu bucket name
15
+ attr_accessor :bucket
16
+
17
+ def initialize(model, storage_id = nil)
18
+ super
19
+
20
+ @path ||= "backups"
21
+
22
+ check_configuration
23
+ config_credentials
24
+ end
25
+
26
+ private
27
+
28
+ def transfer!
29
+ package.filenames.each do |filename|
30
+ src = File.join(Config.tmp_path, filename)
31
+ dest = File.join(remote_path, filename)
32
+ Logger.info "Storing '#{dest}'..."
33
+
34
+ ::Qiniu.upload_file(uptoken: ::Qiniu.generate_upload_token,
35
+ bucket: bucket,
36
+ file: src,
37
+ key: dest)
38
+ end
39
+ end
40
+
41
+ # Called by the Cycler.
42
+ # Any error raised will be logged as a warning.
43
+ def remove!(package)
44
+ Logger.info "Removing backup package dated #{package.time}..."
45
+ remote_path = remote_path_for(package)
46
+ package.filenames.each do |filename|
47
+ ::Qiniu.delete(bucket, File.join(remote_path, filename))
48
+ end
49
+ end
50
+
51
+ def check_configuration
52
+ required = %w[access_key secret_key bucket]
53
+
54
+ raise Error, <<-EOS if required.map { |name| send(name) }.any?(&:nil?)
55
+ Configuration Error
56
+ #{required.map { |name| "##{name}" }.join(", ")} are all required
57
+ EOS
58
+ end
59
+
60
+ def config_credentials
61
+ ::Qiniu.establish_connection!(access_key: access_key, secret_key: secret_key)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,246 @@
1
+ module Backup
2
+ module Storage
3
+ class RSync < Base
4
+ include Utilities::Helpers
5
+
6
+ ##
7
+ # Mode of operation
8
+ #
9
+ # [:ssh (default)]
10
+ # Connects to the remote via SSH.
11
+ # Does not use an rsync daemon on the remote.
12
+ #
13
+ # [:ssh_daemon]
14
+ # Connects to the remote via SSH.
15
+ # Spawns a single-use daemon on the remote, which allows certain
16
+ # daemon features (like modules) to be used.
17
+ #
18
+ # [:rsync_daemon]
19
+ # Connects directly to an rsync daemon via TCP.
20
+ # Data transferred is not encrypted.
21
+ #
22
+ attr_accessor :mode
23
+
24
+ ##
25
+ # Server Address
26
+ #
27
+ # If not specified, the storage operation will be local.
28
+ attr_accessor :host
29
+
30
+ ##
31
+ # SSH or RSync port
32
+ #
33
+ # For `:ssh` or `:ssh_daemon` mode, this specifies the SSH port to use
34
+ # and defaults to 22.
35
+ #
36
+ # For `:rsync_daemon` mode, this specifies the TCP port to use
37
+ # and defaults to 873.
38
+ attr_accessor :port
39
+
40
+ ##
41
+ # SSH User
42
+ #
43
+ # If the user running the backup is not the same user that needs to
44
+ # authenticate with the remote server, specify the user here.
45
+ #
46
+ # The user must have SSH keys setup for passphrase-less access to the
47
+ # remote. If the SSH User does not have passphrase-less keys, or no
48
+ # default keys in their `~/.ssh` directory, you will need to use the
49
+ # `-i` option in `:additional_ssh_options` to specify the
50
+ # passphrase-less key to use.
51
+ #
52
+ # Used only for `:ssh` and `:ssh_daemon` modes.
53
+ attr_accessor :ssh_user
54
+
55
+ ##
56
+ # Additional SSH Options
57
+ #
58
+ # Used to supply a String or Array of options to be passed to the SSH
59
+ # command in `:ssh` and `:ssh_daemon` modes.
60
+ #
61
+ # For example, if you need to supply a specific SSH key for the `ssh_user`,
62
+ # you would set this to: "-i '/path/to/id_rsa'". Which would produce:
63
+ #
64
+ # rsync -e "ssh -p 22 -i '/path/to/id_rsa'"
65
+ #
66
+ # Arguments may be single-quoted, but should not contain any double-quotes.
67
+ #
68
+ # Used only for `:ssh` and `:ssh_daemon` modes.
69
+ attr_accessor :additional_ssh_options
70
+
71
+ ##
72
+ # RSync User
73
+ #
74
+ # If the user running the backup is not the same user that needs to
75
+ # authenticate with the rsync daemon, specify the user here.
76
+ #
77
+ # Used only for `:ssh_daemon` and `:rsync_daemon` modes.
78
+ attr_accessor :rsync_user
79
+
80
+ ##
81
+ # RSync Password
82
+ #
83
+ # If specified, Backup will write the password to a temporary file and
84
+ # use it with rsync's `--password-file` option for daemon authentication.
85
+ #
86
+ # Note that setting this will override `rsync_password_file`.
87
+ #
88
+ # Used only for `:ssh_daemon` and `:rsync_daemon` modes.
89
+ attr_accessor :rsync_password
90
+
91
+ ##
92
+ # RSync Password File
93
+ #
94
+ # If specified, this path will be passed to rsync's `--password-file`
95
+ # option for daemon authentication.
96
+ #
97
+ # Used only for `:ssh_daemon` and `:rsync_daemon` modes.
98
+ attr_accessor :rsync_password_file
99
+
100
+ ##
101
+ # Additional String or Array of options for the rsync cli
102
+ attr_accessor :additional_rsync_options
103
+
104
+ ##
105
+ # Flag for compressing (only compresses for the transfer)
106
+ attr_accessor :compress
107
+
108
+ ##
109
+ # Path to store the synced backup package file(s) to.
110
+ #
111
+ # If no +host+ is specified, then +path+ will be local, and the only
112
+ # other used option would be +additional_rsync_options+.
113
+ # +path+ will be expanded, so '~/my_path' will expand to '$HOME/my_path'.
114
+ #
115
+ # If a +host+ is specified, this will be a path on the host.
116
+ # If +mode+ is `:ssh` (default), then any relative path, or path starting
117
+ # with '~/' will be relative to the directory the ssh_user is logged
118
+ # into. For `:ssh_daemon` or `:rsync_daemon` modes, this would reference
119
+ # an rsync module/path.
120
+ #
121
+ # In :ssh_daemon and :rsync_daemon modes, +path+ (or path defined by
122
+ # your rsync module) must already exist.
123
+ #
124
+ # In :ssh mode or local operation (no +host+ specified), +path+ will
125
+ # be created if needed - either locally, or on the remote for :ssh mode.
126
+ attr_accessor :path
127
+
128
+ def initialize(model, storage_id = nil)
129
+ super
130
+
131
+ @mode ||= :ssh
132
+ @port ||= mode == :rsync_daemon ? 873 : 22
133
+ @compress ||= false
134
+ @path ||= "~/backups"
135
+ end
136
+
137
+ private
138
+
139
+ def transfer!
140
+ write_password_file
141
+ create_remote_path
142
+
143
+ package.filenames.each do |filename|
144
+ src = "'#{File.join(Config.tmp_path, filename)}'"
145
+ dest = "#{host_options}'#{File.join(remote_path, filename)}'"
146
+ Logger.info "Syncing to #{dest}..."
147
+ run("#{rsync_command} #{src} #{dest}")
148
+ end
149
+ ensure
150
+ remove_password_file
151
+ end
152
+
153
+ ##
154
+ # Other storages add an additional timestamp directory to this path.
155
+ # This is not desired here, since we need to transfer the package files
156
+ # to the same location each time.
157
+ def remote_path
158
+ @remote_path ||= begin
159
+ if host
160
+ path.sub(/^~\//, "").sub(/\/$/, "")
161
+ else
162
+ File.expand_path(path)
163
+ end
164
+ end
165
+ end
166
+
167
+ ##
168
+ # Runs a 'mkdir -p' command on the host (or locally) to ensure the
169
+ # dest_path exists. This is used because we're transferring a single
170
+ # file, and rsync won't attempt to create the intermediate directories.
171
+ #
172
+ # This is only applicable locally and in :ssh mode.
173
+ # In :ssh_daemon and :rsync_daemon modes the `path` would include a
174
+ # module name that must define a path on the remote that already exists.
175
+ def create_remote_path
176
+ if host
177
+ return unless mode == :ssh
178
+ run "#{utility(:ssh)} #{ssh_transport_args} #{host} " +
179
+ %("mkdir -p '#{remote_path}'")
180
+ else
181
+ FileUtils.mkdir_p(remote_path)
182
+ end
183
+ end
184
+
185
+ def host_options
186
+ @host_options ||= begin
187
+ if !host
188
+ ""
189
+ elsif mode == :ssh
190
+ "#{host}:"
191
+ else
192
+ user = "#{rsync_user}@" if rsync_user
193
+ "#{user}#{host}::"
194
+ end
195
+ end
196
+ end
197
+
198
+ def rsync_command
199
+ @rsync_command ||= begin
200
+ cmd = utility(:rsync) << " --archive" <<
201
+ " #{Array(additional_rsync_options).join(" ")}".rstrip
202
+ cmd << compress_option << password_option << transport_options if host
203
+ cmd
204
+ end
205
+ end
206
+
207
+ def compress_option
208
+ compress ? " --compress" : ""
209
+ end
210
+
211
+ def password_option
212
+ return "" if mode == :ssh
213
+
214
+ path = @password_file ? @password_file.path : rsync_password_file
215
+ path ? " --password-file='#{File.expand_path(path)}'" : ""
216
+ end
217
+
218
+ def transport_options
219
+ if mode == :rsync_daemon
220
+ " --port #{port}"
221
+ else
222
+ %( -e "#{utility(:ssh)} #{ssh_transport_args}")
223
+ end
224
+ end
225
+
226
+ def ssh_transport_args
227
+ args = "-p #{port} "
228
+ args << "-l #{ssh_user} " if ssh_user
229
+ args << Array(additional_ssh_options).join(" ")
230
+ args.rstrip
231
+ end
232
+
233
+ def write_password_file
234
+ return unless host && rsync_password && mode != :ssh
235
+
236
+ @password_file = Tempfile.new("backup-rsync-password")
237
+ @password_file.write(rsync_password)
238
+ @password_file.close
239
+ end
240
+
241
+ def remove_password_file
242
+ @password_file.delete if @password_file
243
+ end
244
+ end
245
+ end
246
+ end