martilla 0.0.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1298cbc9a2e46470a324413da4f9e0c478d810f166c9088a8a512c1351d634a0
4
- data.tar.gz: 04ff757ae6fd6bca7b61803315f4938c9788edce5b3418d1a347529dd670b626
3
+ metadata.gz: 14bd7c780f6612c6adfbd67e932bf30db058e6af7148a7889d18ab0249e4d768
4
+ data.tar.gz: a4cd222e53ac9eefb6fdef35bbe91ff3a2b16f49ddbfefe221a8d5ab244ae2e7
5
5
  SHA512:
6
- metadata.gz: ce4b0564bd205c4d3dc51170f5f1715d67516a3676fbeca4d1baa72288300c1acab2c40c5f535c3cdb3b92042bcc0080b7995d66c8065fdb63e3fef942afb520
7
- data.tar.gz: 5f6f19da462b301af362e175642075edb456412eedbaae3154f62f9225e7f436b679d4b1c56ac98a40c564d9727a354fd14cb3446eb4803dbf045621796e5a21
6
+ metadata.gz: 9c6fa4a885951c8a07c88cf62973fa82c63e889b2a4b63080a21888f9a4d8363fdcec841b65b4117af32f8821cccd62798ee1b36271c32a3cf8a6f7d09694a82
7
+ data.tar.gz: 612b5bf6139d30935919e2989f9a87b2ebc58cc9f874b9e9a1b3b993f29e2069f1e13001d12696a563c0962cac17b6cba8f36c6b8bf57b404908bab9469adfaf
data/.gitignore CHANGED
@@ -9,3 +9,8 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+ .byebug_history
13
+
14
+ martilla.yml
15
+ backup.sql
16
+ backup.sql.gz
data/Gemfile.lock CHANGED
@@ -1,29 +1,54 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- martilla (0.0.1)
5
- aws-ses (~> 0.6.0)
4
+ martilla (0.2.0)
5
+ aws-sdk-s3 (~> 1.49)
6
+ aws-sdk-ses (~> 1.26)
7
+ memoist (~> 0.16.0)
6
8
  pony (~> 1.13)
7
9
  thor (~> 0.20.3)
8
10
 
9
11
  GEM
10
12
  remote: https://rubygems.org/
11
13
  specs:
12
- aws-ses (0.6.0)
13
- builder
14
- mail (> 2.2.5)
15
- mime-types
16
- xml-simple
17
- builder (3.2.3)
14
+ aws-eventstream (1.0.3)
15
+ aws-partitions (1.226.0)
16
+ aws-sdk-core (3.69.1)
17
+ aws-eventstream (~> 1.0, >= 1.0.2)
18
+ aws-partitions (~> 1.0)
19
+ aws-sigv4 (~> 1.1)
20
+ jmespath (~> 1.0)
21
+ aws-sdk-kms (1.24.0)
22
+ aws-sdk-core (~> 3, >= 3.61.1)
23
+ aws-sigv4 (~> 1.1)
24
+ aws-sdk-s3 (1.50.0)
25
+ aws-sdk-core (~> 3, >= 3.61.1)
26
+ aws-sdk-kms (~> 1)
27
+ aws-sigv4 (~> 1.1)
28
+ aws-sdk-ses (1.26.0)
29
+ aws-sdk-core (~> 3, >= 3.61.1)
30
+ aws-sigv4 (~> 1.1)
31
+ aws-sigv4 (1.1.0)
32
+ aws-eventstream (~> 1.0, >= 1.0.2)
33
+ byebug (11.0.1)
34
+ coderay (1.1.2)
18
35
  diff-lcs (1.3)
36
+ docile (1.3.2)
37
+ jmespath (1.4.0)
38
+ json (2.2.0)
19
39
  mail (2.7.1)
20
40
  mini_mime (>= 0.1.1)
21
- mime-types (3.3)
22
- mime-types-data (~> 3.2015)
23
- mime-types-data (3.2019.1009)
41
+ memoist (0.16.0)
42
+ method_source (0.9.2)
24
43
  mini_mime (1.0.2)
25
44
  pony (1.13.1)
26
45
  mail (>= 2.0)
46
+ pry (0.12.2)
47
+ coderay (~> 1.1.0)
48
+ method_source (~> 0.9.0)
49
+ pry-byebug (3.7.0)
50
+ byebug (~> 11.0)
51
+ pry (~> 0.10)
27
52
  rake (13.0.0)
28
53
  rspec (3.9.0)
29
54
  rspec-core (~> 3.9.0)
@@ -38,17 +63,24 @@ GEM
38
63
  diff-lcs (>= 1.2.0, < 2.0)
39
64
  rspec-support (~> 3.9.0)
40
65
  rspec-support (3.9.0)
66
+ simplecov (0.17.1)
67
+ docile (~> 1.1)
68
+ json (>= 1.8, < 3)
69
+ simplecov-html (~> 0.10.0)
70
+ simplecov-html (0.10.2)
41
71
  thor (0.20.3)
42
- xml-simple (1.1.5)
43
72
 
44
73
  PLATFORMS
45
74
  ruby
46
75
 
47
76
  DEPENDENCIES
48
77
  bundler (~> 2.0)
78
+ byebug (~> 11.0)
49
79
  martilla!
80
+ pry-byebug (~> 3.7)
50
81
  rake (~> 13.0)
51
82
  rspec (~> 3.9)
83
+ simplecov (~> 0.17.1)
52
84
 
53
85
  BUNDLED WITH
54
86
  2.0.2
data/README.md CHANGED
@@ -1,36 +1,145 @@
1
1
  # Martilla
2
2
 
3
- This is the first commit at the backup tool named Martilla. More to come soon.
3
+ Martilla is a tool to automate your backups. With simple but flexible configuration options you can have a database backup configured to run (using cron jobs or similar). Receive a notification whenever a backup fails, choose multiple ways of getting notified (i.e. email + slack).
4
+
5
+ The name Martilla comes from a local name for the [Kinkajou](https://en.wikipedia.org/wiki/Kinkajou). This nocturnal animal is goes fairly unnoticed, just like we hope database backups should remain.
4
6
 
5
7
  ## Installation
6
8
 
7
- Add this line to your application's Gemfile:
9
+ To use as a CLI tool
10
+
11
+ $ gem install martilla
12
+
13
+ Or add this line to your application's Gemfile:
8
14
 
9
15
  ```ruby
10
16
  gem 'martilla'
11
17
  ```
12
18
 
13
- And then execute:
14
-
15
- $ bundle
16
-
17
- Or install it yourself as:
18
-
19
- $ gem install martilla
20
-
21
19
  ## Usage
22
20
 
23
- TODO: Write usage instructions here
21
+ Martilla uses a YAML configuration file that specifies the backup to be performed. The gem works by making three main concepts work together, they're listed out with details that should generally be specified in the config file:
22
+
23
+ - Database
24
+ - What database are we going to backup
25
+ - How can we connect to the database
26
+ - Storage
27
+ - Where is this backup going to be stored
28
+ - Credentials needed to persist the backup
29
+ - Notifiers
30
+ - How will you get notified of the backup result
31
+ - Can be a list of multiple ways to get notified
32
+
33
+ Execute `martilla setup backup-config.yml` and you'll have your first (default) config file that looks like the following:
34
+
35
+ ```yaml
36
+ ---
37
+ db:
38
+ type: postgres
39
+ options:
40
+ host: localhost
41
+ user: username
42
+ password: password
43
+ db: databasename
44
+ storage:
45
+ type: local
46
+ options:
47
+ filename: database-backup.sql
48
+ notifiers:
49
+ - type: none
50
+ ```
51
+
52
+ From here on you pick the building blocks that work for your specific case:
53
+
54
+ ### Databases
55
+
56
+ Currently available DB types to choose from are 'postgres' & 'mysql'. They both have the same available options:
57
+ - `host`
58
+ - defaults to localhost
59
+ - can be set in ENV variable `PG_HOST` or `MYSQL_HOST`
60
+ - `user`
61
+ - required
62
+ - can be set in ENV variable `PG_USER` or `MYSQL_USER`
63
+ - `password`
64
+ - required
65
+ - can be set in ENV variable `PG_PASSWORD` or `MYSQL_PASSWORD`
66
+ - `db`
67
+ - required
68
+ - can be set in ENV variable `PG_DATABASE` or `MYSQL_DATABASE`
69
+ - `port`
70
+ - defaults to 5432 or 3306
71
+ - can be set in ENV variable `PG_USER` or `MYSQL_USER`
72
+
73
+ ### Storages
74
+
75
+ The available Storages types are 'local', 'S3' & 'SCP'. They each have different available options:
76
+ - options for type: 'local'
77
+ - `filename`
78
+ - The location to where the backup will be stored
79
+ - options for type: 's3'
80
+ - `filename`
81
+ - The location to where the backup will be stored within the S3 bucket
82
+ - `bucket`
83
+ - `region`
84
+ - `access_key_id`
85
+ - can be specified with the usual ENV variables or IAM roles
86
+ - `secret_access_key`
87
+ - can be specified with the usual ENV variables or IAM roles
88
+ - options for type: 'scp'
89
+ - `filename`
90
+ - The location to where the backup will be stored within remote server
91
+ - `host`
92
+ - `user`
93
+ - `identity_file`
94
+
95
+ All storage types also accept 'suffix' as a boolean that enables or disables a timestamp to be added as a suffix to the backup 'filename', it defaults as `true`.
96
+
97
+ ### Notifiers
98
+
99
+ The available Notifiers are 'ses', 'sendmail' & 'smtp'. They each have different available options:
100
+ - options for type: 'ses' (email notifier)
101
+ - `aws_region`
102
+ - `access_key_id`
103
+ - can be specified with the usual ENV variables or IAM role
104
+ - `secret_access_key`
105
+ - can be specified with the usual ENV variables or IAM roles
106
+ - options for type: 'sendmail' (email notifier)
107
+ - no custom options
108
+ - options for type: 'smtp' (email notifier)
109
+ - `address`
110
+ - `domain`
111
+ - `user_name`
112
+ - `password`
113
+
114
+ All of the previous email notifiers also have the following options they all use:
115
+ - `to`
116
+ - a list of comma separated emails to be notified
117
+ - `from`
118
+ - defaults to 'martilla@no-reply.com'
119
+ - `success_subject`
120
+ - the subject of the success email
121
+ - `failure_subject`
122
+ - the subject of the failure email
123
+
124
+ It's **HIGHLY RECOMMENDED** to test and make sure emails are being delivered correctly to each target inbox. Emails with standard messages like these automated backup notifications tend to be easily marked as spam.
125
+
126
+ ### Perform a backup
127
+
128
+ As simple as running the `backup` command on the martilla CLI and passing as argument the configuration file you want to use
129
+
130
+ $ martilla backup backup-config.yml
131
+
132
+ Help the help command help you
133
+
134
+ $ martilla help
24
135
 
25
136
  ## Development
26
137
 
27
138
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
28
139
 
29
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
30
-
31
140
  ## Contributing
32
141
 
33
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/martilla. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
142
+ Bug reports and pull requests are welcome on GitHub at https://github.com/fdoxyz/martilla. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
34
143
 
35
144
  ## License
36
145
 
@@ -38,4 +147,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
38
147
 
39
148
  ## Code of Conduct
40
149
 
41
- Everyone interacting in the Martilla project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/martilla/blob/master/CODE_OF_CONDUCT.md).
150
+ Everyone interacting in the Martilla project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/fdoxyz/martilla/blob/master/CODE_OF_CONDUCT.md).
data/config.yml ADDED
@@ -0,0 +1,14 @@
1
+ ---
2
+ db:
3
+ type: postgres
4
+ options:
5
+ host: localhost
6
+ user: username
7
+ password: password
8
+ db: databasename
9
+ storage:
10
+ type: local
11
+ options:
12
+ filename: database-backup.sql
13
+ notifiers:
14
+ - type: none
@@ -0,0 +1,115 @@
1
+ require 'memoist'
2
+ require 'martilla/utilities'
3
+
4
+ module Martilla
5
+ class Backup
6
+ extend Memoist
7
+
8
+ include Utilities
9
+
10
+ attr_reader :file_size
11
+
12
+ def initialize(config)
13
+ @options = config['options']
14
+ @db = Database.create(config['db'])
15
+ @storage = Storage.create(config['storage'])
16
+ @notifiers = config['notifiers'].map { |c| Notifier.create(c) }.compact
17
+ end
18
+
19
+ def self.create(config)
20
+ backup = Backup.new(config)
21
+ backup.execute
22
+ backup
23
+ end
24
+
25
+ def gzip?
26
+ return true if @options['gzip'].nil?
27
+ @options['gzip']
28
+ end
29
+
30
+ def tmp_file
31
+ filename = @options.dig('tmp_file') || '/tmp/backup'
32
+ return "#{filename}.gz" if gzip?
33
+ filename
34
+ end
35
+
36
+ def execute
37
+ begin
38
+ # Perform DB dump
39
+ @ticks = [Time.now]
40
+ @db.dump(tmp_file: tmp_file, gzip: gzip?)
41
+
42
+ # Metadata capture
43
+ @file_size = File.size(tmp_file).to_f if File.exist?(tmp_file)
44
+ @ticks << Time.now
45
+
46
+ # Persist the backup
47
+ @storage.persist(tmp_file: tmp_file, gzip: gzip?)
48
+ rescue Exception => e
49
+ @notifiers.each do |notifier|
50
+ notifier.error(e.message, metadata)
51
+ end
52
+ puts "An error occurred: #{e.inspect}"
53
+ else
54
+ @notifiers.each do |notifier|
55
+ notifier.success(metadata)
56
+ end
57
+ puts "Backup created and persisted successfully"
58
+ end
59
+
60
+ File.delete(tmp_file) if File.exist?(tmp_file)
61
+ end
62
+
63
+ def metadata
64
+ @ticks << Time.now
65
+ data = []
66
+
67
+ # Total backup size
68
+ if @file_size.nil?
69
+ data << "No backup file was created"
70
+ else
71
+ data << "Total backup size: #{formatted_file_size}"
72
+ end
73
+
74
+ # Backup time measurements
75
+ if @ticks.count >= 2
76
+ time_diff = duration_format(@ticks[1] - @ticks[0])
77
+ data << "Backup 'dump' duration: #{time_diff}"
78
+ end
79
+
80
+ # Storage time measurements
81
+ if @ticks.count >= 3
82
+ time_diff = duration_format(@ticks[2] - @ticks[1])
83
+ data << "Backup storage duration: #{time_diff}"
84
+ end
85
+
86
+ data
87
+ end
88
+ memoize :metadata
89
+
90
+ def self.sample_config
91
+ {
92
+ 'db' => {
93
+ 'type' => 'postgres',
94
+ 'options' => {
95
+ 'host' => 'localhost',
96
+ 'user' => 'username',
97
+ 'password' => 'password',
98
+ 'db' => 'databasename'
99
+ }
100
+ },
101
+ 'storage' => {
102
+ 'type' => 'local',
103
+ 'options' => {
104
+ 'filename' => 'database-backup.sql'
105
+ }
106
+ },
107
+ 'notifiers' => [
108
+ {
109
+ 'type' => 'none'
110
+ }
111
+ ]
112
+ }
113
+ end
114
+ end
115
+ end
data/lib/martilla/cli.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'byebug'
1
2
  require 'yaml'
2
3
  require 'thor'
3
4
 
@@ -15,13 +16,14 @@ module Martilla
15
16
  rescue Errno::ENOENT
16
17
  puts "Couldn't access non-existent file #{file_path}"
17
18
  else
18
- execute_backup(backup_config)
19
+ backup = Backup.create(backup_config)
19
20
  end
20
21
  end
21
22
 
22
23
  desc "setup FILEPATH", "Generates a sample backup config file at FILEPATH"
23
- def setup(filepath = './martilla.yml')
24
- puts filepath
24
+ def setup(filename = 'martilla.yml')
25
+ file_path = File.join(Dir.pwd, filename)
26
+ File.write(file_path, Backup.sample_config.to_yaml)
25
27
  end
26
28
  end
27
29
  end
@@ -2,20 +2,31 @@ require 'forwardable'
2
2
 
3
3
  module Martilla
4
4
  class Database
5
- extend Forwardable
5
+ attr_reader :options
6
6
 
7
7
  def initialize(config)
8
- # When a new core target is added to the project include it here
8
+ @options = config
9
+ raise Error.new(invalid_options_msg) if @options.nil?
10
+ end
11
+
12
+ def dump(temp_file:, gzip:)
13
+ raise NotImplementedError, 'You must implement the dump method'
14
+ end
15
+
16
+ def invalid_options_msg
17
+ 'DB configuration is invalid. Details here: https://github.com/fdoxyz/martilla'
18
+ end
19
+
20
+ # When a new DB is supported it needs to go here
21
+ def self.create(config = {})
9
22
  case config['type'].downcase
10
23
  when 'postgres'
11
- @database = Databases::Postgres.new(config['options'])
24
+ Postgres.new(config['options'])
12
25
  when 'mysql'
13
- @database = Databases::Mysql.new(config['options'])
26
+ Mysql.new(config['options'])
14
27
  else
15
28
  raise Error.new("Invalid Database type: #{config['type']}")
16
29
  end
17
30
  end
18
-
19
- def_delegators :@database, :dump
20
31
  end
21
32
  end
@@ -1,5 +1,40 @@
1
- class Martilla::Databases::Mysql < Martilla::Databases::Base
2
- def dump
1
+ module Martilla
2
+ class Mysql < Database
3
+ def dump(tmp_file:, gzip:)
4
+ if gzip
5
+ `mysqldump #{connection_arguments} | gzip -c > #{tmp_file}`
6
+ else
7
+ `mysqldump #{connection_arguments} > #{tmp_file}`
8
+ end
3
9
 
10
+ return if $?.success?
11
+ raise Error.new("Database dump failed with code #{$?.exitstatus}")
12
+ end
13
+
14
+ private
15
+
16
+ def connection_arguments
17
+ "-u #{user} -p #{password} -P #{port} #{db}"
18
+ end
19
+
20
+ def host
21
+ @options['host'] || ENV['MYSQL_HOST'] || 'localhost'
22
+ end
23
+
24
+ def port
25
+ @options['port'] || ENV['MYSQL_PORT'] || '3306'
26
+ end
27
+
28
+ def user
29
+ @options['user'] || ENV['MYSQL_USER']
30
+ end
31
+
32
+ def password
33
+ @options['password'] || ENV['MYSQL_PASSWORD']
34
+ end
35
+
36
+ def db
37
+ @options['db'] || ENV['MYSQL_DATABASE']
38
+ end
4
39
  end
5
40
  end
@@ -1,4 +1,40 @@
1
- class Martilla::Databases::Postgres < Martilla::Databases::Base
2
- def dump
1
+ module Martilla
2
+ class Postgres < Database
3
+ def dump(tmp_file:, gzip:)
4
+ if gzip
5
+ `pg_dump #{connection_string} | gzip -c > #{tmp_file}`
6
+ else
7
+ `pg_dump #{connection_string} > #{tmp_file}`
8
+ end
9
+
10
+ return if $?.success?
11
+ raise Error.new("Database dump failed with code #{$?.exitstatus}")
12
+ end
13
+
14
+ private
15
+
16
+ def connection_string
17
+ "postgres://#{user}:#{password}@#{host}:#{port}/#{db}"
18
+ end
19
+
20
+ def host
21
+ @options['host'] || ENV['PG_HOST'] || 'localhost'
22
+ end
23
+
24
+ def port
25
+ @options['port'] || ENV['PG_PORT'] || '5432'
26
+ end
27
+
28
+ def user
29
+ @options['user'] || ENV['PG_USER']
30
+ end
31
+
32
+ def password
33
+ @options['password'] || ENV['PG_PASSWORD']
34
+ end
35
+
36
+ def db
37
+ @options['db'] || ENV['PG_DATABASE']
38
+ end
3
39
  end
4
40
  end
@@ -2,22 +2,41 @@ require 'forwardable'
2
2
 
3
3
  module Martilla
4
4
  class Notifier
5
- extend Forwardable
5
+ attr_reader :options
6
6
 
7
7
  def initialize(config)
8
- # When a new core target is added to the project include it here
8
+ @options = config
9
+ raise Error.new(invalid_options_msg) if @options.nil?
10
+ end
11
+
12
+ def success(data)
13
+ raise NotImplementedError, 'You must implement the success method'
14
+ end
15
+
16
+ def error(msg, data)
17
+ raise NotImplementedError, 'You must implement the error method'
18
+ end
19
+
20
+ def invalid_options_msg
21
+ 'Notifier configuration is invalid. Details here: https://github.com/fdoxyz/martilla'
22
+ end
23
+
24
+ # When a new Notifier is supported it needs to go here
25
+ def self.create(config = {})
9
26
  case config['type'].downcase
10
- when 'email'
11
- @notifier = Notifiers::Email.new(config['options'])
27
+ when 'sendmail'
28
+ Sendmail.new(config['options'])
29
+ when 'smtp'
30
+ Smtp.new(config['options'])
12
31
  when 'ses'
13
- @notifier = Notifiers::Ses.new(config['options'])
32
+ Ses.new(config['options'])
14
33
  when 'slack'
15
- @notifier = Notifiers::Slack.new(config['options'])
34
+ Slack.new(config['options'])
35
+ when 'none'
36
+ nil
16
37
  else
17
38
  raise Error.new("Invalid Notifier type: #{config['type']}")
18
39
  end
19
40
  end
20
-
21
- def_delegators :@notifier, :success, :error
22
41
  end
23
42
  end
@@ -0,0 +1,65 @@
1
+ require 'pony'
2
+
3
+ module Martilla
4
+ class EmailNotifier < Notifier
5
+ private
6
+
7
+ def success_html(data)
8
+ data_list = data.map { |d| "<li>#{d}</li>" }.join("\n")
9
+ <<~HTML
10
+ <h2>The backup was created successfully</h2>
11
+ <ul>
12
+ #{data_list}
13
+ </ul>
14
+ HTML
15
+ end
16
+
17
+ def success_txt(data)
18
+ data_list = data.map { |d| "- #{d}" }.join("\n")
19
+ <<~TXT
20
+ The backup was created successfully
21
+
22
+ #{data_list}
23
+ TXT
24
+ end
25
+
26
+ def error_html(msg, data)
27
+ data_list = data.map { |d| "<li>#{d}</li>" }.join("\n")
28
+ <<~HTML
29
+ <h2>The backup attempt failed with the following error</h2>
30
+ <p><strong>#{msg}</strong></p>
31
+ <ul>
32
+ #{data_list}
33
+ </ul>
34
+ HTML
35
+ end
36
+
37
+ def error_txt(msg, data)
38
+ data_list = data.map { |d| "- #{d}" }.join("\n")
39
+ <<~TXT
40
+ The backup attempt failed with the following error:
41
+ #{msg}
42
+
43
+ #{data_list}
44
+ TXT
45
+ end
46
+
47
+ def to_email
48
+ email = @options['to']
49
+ raise config_error('to') if email.nil?
50
+ email
51
+ end
52
+
53
+ def from_email
54
+ @options['from'] || 'martilla@no-reply.com'
55
+ end
56
+
57
+ def success_subject
58
+ @options['success_subject'] || '[SUCCESS] Backup created'
59
+ end
60
+
61
+ def failure_subject
62
+ @options['failure_subject'] || '[FAILURE] Backup failed'
63
+ end
64
+ end
65
+ end