bamboolab_kaboom 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b6131743c73049e741f8e78b6523a920f7f43a169f268d7dee816a35b1cbfe5c
4
+ data.tar.gz: 0df5be6710bde734916305e1a9402ef83a040120cce5ede56430f289c8ce77f4
5
+ SHA512:
6
+ metadata.gz: 60d8e4c9ce9a98bcbcf2a52bf75f068d785c5e7192700fd51b95ce22eae1e013eb99dc1d128dc206a438fb1d8a50d156f2003abe10cc3ff8170a386bf82fb87a
7
+ data.tar.gz: 5a5400c999c25135c3fb711df4805b2ce42eb6876a56ddc37805b4ee6728c7106879b4c48ee87d74042dea1917e2b58bab33558d5e6ba56e94e3a0c2c5d8b124
data/bin/kaboom ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'kaboom'
4
+ Kaboom::CLI.start(ARGV)
@@ -0,0 +1,141 @@
1
+ require 'google/cloud/storage'
2
+ require 'fileutils'
3
+ require 'tty-progressbar'
4
+
5
+ module Kaboom
6
+ class Backup < Thor
7
+ include Thor::Actions
8
+ include Utils
9
+ include Config
10
+
11
+ desc 'pull', 'Pull the latest assets and database backup from the deploy destination'
12
+ def pull(destination = 'staging')
13
+ database_pull(destination)
14
+ puts "\n"
15
+ assets_pull(destination)
16
+ say "Completed!", :green
17
+ end
18
+
19
+ desc 'apply', 'Apply a local backup to the database'
20
+ method_option :file, aliases: '-f', type: :string, desc: 'Backup file in to apply', default: 'db/latest.sql'
21
+ def apply
22
+ backup_file = options[:file]
23
+ unless File.exist?(backup_file)
24
+ say_and_exit "Error: Backup file #{backup_file} not found", :red
25
+ end
26
+ confirm_proceed("Do you want to apply the backup to your local database? THIS WILL REMOVE YOUR EXISTING LOCAL DATABASE")
27
+
28
+ local_db_config = database_config('development')
29
+ if DatabaseHelper.apply_backup(local_db_config, backup_file)
30
+ say "Backup applied successfully to local database!", :green
31
+ else
32
+ say_and_exit \
33
+ "Error: Backup restore failed. Is there an open connection to the local database? Stop the Rails application and try again.",
34
+ :red
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def database_pull(destination)
41
+ validate_repository!
42
+ validate_destination(destination)
43
+ confirm_production_action('database backup pull') if destination == 'production'
44
+
45
+ load_env(destination)
46
+ db_config = database_config(destination)
47
+ connection_string = DatabaseHelper.connection_string(db_config)
48
+
49
+ database_size = DatabaseHelper.database_size(connection_string, destination)
50
+
51
+ if confirm_proceed("Database backup size will be around #{database_size}. Do you want to continue?", exit_on_no: false)
52
+ backup_file = ask_for_backup_filename
53
+
54
+ say "Started pulling backup from #{destination} | #{DateTime.now.strftime("%H:%M:%S")}"
55
+ if DatabaseHelper.pull_backup(connection_string, destination, backup_file)
56
+ say "Backup pulled successfully to db/#{backup_file}.sql | #{DateTime.now.strftime("%H:%M:%S")}", :green
57
+ else
58
+ say_and_exit "Error: Backup pull failed", :red
59
+ end
60
+
61
+ if confirm_proceed("Do you want to apply the backup to your local database? THIS WILL REMOVE YOUR EXISTING LOCAL DATABASE")
62
+ say "Applying backup to local database...", :white
63
+ local_db_config = database_config('development')
64
+ if DatabaseHelper.apply_backup(local_db_config, "db/#{backup_file}.sql")
65
+ say "Backup applied successfully to local database!", :green
66
+ else
67
+ say_and_exit \
68
+ "Error: Backup restore failed. Is there an open connection to the local database? Stop the Rails application and try again.",
69
+ :red
70
+
71
+ end
72
+
73
+ remove_backup_file(backup_file) if confirm_proceed("Do you want to remove the local backup file?", exit_on_no: false)
74
+ end
75
+ end
76
+ end
77
+
78
+ def assets_pull(destination)
79
+ validate_repository!
80
+ validate_destination(destination)
81
+ confirm_production_action('assets pull') if destination == 'production'
82
+
83
+ if confirm_proceed("Do you want to pull assets from #{destination}? Your local uploads folder will be deleted.")
84
+ credentials = load_credentials(destination)
85
+ say_and_exit("Error: Google Storage credentials not found in #{destination}.yml", :red) if credentials[:google_storage].nil?
86
+
87
+ project_name = credentials[:google_storage][:project_name]
88
+ bucket_name = credentials[:google_storage][:bucket_name]
89
+
90
+ File.open('.keyfile.json', 'w'){ |f| f.write(credentials[:google_storage][:json_key_string]) }
91
+ storage = Google::Cloud::Storage.new(
92
+ project_id: project_name,
93
+ credentials: ".keyfile.json", # It apsolutely needs to be a file, for some reason
94
+ )
95
+ FileUtils.rm('.keyfile.json')
96
+
97
+ bucket = storage.bucket(bucket_name, skip_lookup: true)
98
+
99
+ files_number = bucket.files(prefix: 'uploads/').all(request_limit: 10_000).count
100
+ if confirm_proceed("#{files_number} files will be downloaded. Do you want to continue?", exit_on_no: false)
101
+ say "WARNING: There is no way to estimate the total size of all assets, make sure you have enough disk space!", :yellow
102
+ say "WARNING: This will take a while, depending on the number of files", :yellow
103
+ say "Started pulling assets from #{destination} | #{DateTime.now.strftime("%H:%M:%S")}", :white
104
+
105
+ FileUtils.rm_rf('public/uploads')
106
+ FileUtils.mkdir_p('public/uploads')
107
+ local_folder = 'public/uploads'
108
+
109
+ progress_bar = TTY::ProgressBar.new("Downloading [:bar] :current/:total files", total: files_number, width: 60)
110
+
111
+ bucket = storage.bucket(bucket_name, skip_lookup: true)
112
+ bucket.files(prefix: 'uploads/').all(request_limit: 1000) do |file|
113
+ local_file_path = File.join(local_folder, file.name.sub('uploads/', ''))
114
+ FileUtils.mkdir_p(File.dirname(local_file_path))
115
+
116
+ file.download(local_file_path)
117
+
118
+ progress_bar.advance
119
+ end
120
+
121
+ say "Assets pulled successfully to #{local_folder} | #{DateTime.now.strftime("%H:%M:%S")}", :green
122
+ end
123
+ end
124
+ end
125
+
126
+ def ask_for_backup_filename
127
+ prompt = TTY::Prompt.new
128
+ prompt.ask("Enter the backup filename:", required: true) do |q|
129
+ q.modify :trim
130
+ q.validate(/^[\w\-. ]+$/, "Filename must contain only valid characters (letters, digits, dashes, underscores, periods, and spaces)")
131
+ q.default 'latest'
132
+ end
133
+ end
134
+
135
+ def remove_backup_file(backup_file)
136
+ File.delete("db/#{backup_file}.sql")
137
+ say "Local backup file removed", :white
138
+ end
139
+
140
+ end
141
+ end
@@ -0,0 +1,86 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'active_support/encrypted_file'
4
+ require 'base64'
5
+ require 'yaml'
6
+ require 'openssl'
7
+ require 'bundler'
8
+
9
+ module Kaboom
10
+ module Config
11
+ include Thor::Actions
12
+ include Utils
13
+
14
+ DESTINATIONS = Dir.glob("./config/deploy.*.yml").map { |file| file.match(/deploy\.(\w+)\.yml/)[1].to_sym }.freeze
15
+
16
+ def validate_repository!
17
+ unless Dir.exist?('.git')
18
+ say_and_exit "Error: This command must be run in a Git repository", :red
19
+ end
20
+
21
+ unless File.exist?('Gemfile') && File.exist?('config/application.rb') && File.exist?('config/routes.rb')
22
+ say_and_exit "Error: This command must be run in a root folder of a Rails application", :red
23
+ end
24
+
25
+ unless File.exist?('config/credentials/development.key')
26
+ say_and_exit "Error: development.key credentials file not found (source it from 1Password)", :red
27
+ end
28
+
29
+ if DESTINATIONS.empty? && !File.exist?('./config/deploy.yml')
30
+ say_and_exit "Error: No deploy configuration files found in config directory.", :red
31
+ end
32
+
33
+ if Bundler::Dsl.evaluate('Gemfile', nil, {})&.ruby_version&.versions&.first != RUBY_VERSION
34
+ say_and_exit "Error: Ruby version in current projects Gemfile is not the same as the currently running Ruby version.", :red
35
+ end
36
+ end
37
+
38
+ def load_env(destination)
39
+ env_file = ".env.#{destination}"
40
+ if File.exist?(env_file)
41
+ ENV.update(Dotenv::Environment.new(env_file))
42
+ else
43
+
44
+ say_and_exit "Environment file #{env_file} not found, run 'kamal envify -d destination'", :red
45
+ end
46
+
47
+ destination_clear_envs = YAML.load_file("config/deploy.#{destination}.yml")['env']['clear']
48
+ ENV.update(destination_clear_envs.transform_values(&:to_s))
49
+ end
50
+
51
+ def load_credentials(destination)
52
+ credentials_key_file = "config/credentials/#{destination}.key"
53
+ credentials_file = "config/credentials/#{destination}.yml.enc"
54
+
55
+ unless File.exist?(credentials_file) && File.exist?(credentials_key_file)
56
+ say_and_exit "Credentials file #{credentials_file} not found, make sure it is present", :red
57
+ end
58
+
59
+ key = [File.read(credentials_key_file)].pack("H*")
60
+ encrypted_credentials = File.binread(credentials_file)
61
+
62
+ cipher = OpenSSL::Cipher.new('aes-128-gcm')
63
+ encrypted_data, iv, auth_tag = encrypted_credentials.split("--".freeze).map { |v| Base64.strict_decode64(v) }
64
+
65
+ cipher.decrypt
66
+ cipher.key = key
67
+ cipher.iv = iv
68
+ cipher.auth_tag = auth_tag
69
+ cipher.auth_data = ""
70
+
71
+ decrypted_data = cipher.update(encrypted_data)
72
+ decrypted_data << cipher.final
73
+
74
+ return YAML.safe_load(Marshal.load(decrypted_data), symbolize_names: true)
75
+ end
76
+
77
+ def database_config(destination)
78
+ config = YAML.load_file('config/database.yml')[destination]
79
+ {
80
+ name: ERB.new(config['database']).result,
81
+ username: ERB.new(config['username']).result.presence || 'rails',
82
+ password: ERB.new(config['password']).result
83
+ }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,33 @@
1
+ module Kaboom
2
+ class DatabaseHelper
3
+ def self.connection_string(config)
4
+ "postgresql://#{config[:username]}:#{config[:password]}@127.0.0.2/#{config[:name]}"
5
+ end
6
+
7
+ def self.database_size(connection_string, destination)
8
+ query = "SELECT pg_size_pretty(pg_database_size(current_database()));"
9
+ command = %Q{kamal accessory exec db --reuse -i -q "psql #{connection_string} -c \\"#{query}\\"" -d #{destination} 2>/dev/null}
10
+ result = `#{command}`.strip
11
+ result.match(/\n\s*(.+?)\s*\n\(/) ? $1 : "Unknown MB"
12
+ end
13
+
14
+ def self.pull_backup(connection_string, destination, backup_file)
15
+ command = %Q{kamal accessory exec db --reuse -i -q "pg_dump #{connection_string}" -d #{destination} > db/#{backup_file}.sql 2>/dev/null}
16
+ system(command)
17
+ end
18
+
19
+ def self.apply_backup(config, backup_file)
20
+ local_connection_string = "postgresql://#{config[:username]}:#{config[:password]}@localhost/template1"
21
+
22
+ commands = [
23
+ %Q{psql #{local_connection_string} -c "DROP DATABASE IF EXISTS #{config[:name]}" >/dev/null 2>&1},
24
+ %Q{psql #{local_connection_string} -c "CREATE DATABASE #{config[:name]};" >/dev/null 2>&1},
25
+ %Q{psql #{local_connection_string.gsub('template1', config[:name])} < #{backup_file} >/dev/null 2>&1},
26
+ %Q{RAILS_MASTER_KEY=`cat config/credentials/development.key` env bin/rails db:environment:set RAILS_ENV=development >/dev/null 2>&1},
27
+ %Q{psql #{local_connection_string} -c "ALTER DATABASE #{config[:name]} OWNER TO #{config[:username]};" >/dev/null 2>&1},
28
+ ]
29
+ #require 'pry'; binding.pry
30
+ commands.all? { |cmd| system(cmd) }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ module Kaboom
2
+ module Utils
3
+ def say_and_exit(message, color)
4
+ say message, color
5
+ exit(1)
6
+ end
7
+
8
+ def validate_destination(destination)
9
+ unless Config::DESTINATIONS.include?(destination.to_sym)
10
+ say_and_exit("Error: Unknown destination '#{destination}', valid destinations are: #{Config::DESTINATIONS.join(', ')}", :red)
11
+ end
12
+ end
13
+
14
+ def confirm_production_action(command)
15
+ confirm_proceed("You are about to run a #{command} on a production environment. Are you sure you want to continue?")
16
+ end
17
+
18
+ def confirm_proceed(message, exit_on_no: true)
19
+ yes?(message + " (y/n):", :yellow) || (exit_on_no ? (say("Action aborted", :red) && exit(1)) : false)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaboom
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kaboom.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kaboom/version"
4
+ require 'yaml'
5
+ require 'erb'
6
+ require 'dotenv'
7
+ require 'thor'
8
+ require 'tty-prompt'
9
+ require 'kamal'
10
+ require 'google/cloud/storage'
11
+
12
+ require_relative 'kaboom/helpers/utils'
13
+ require_relative 'kaboom/helpers/config'
14
+ require_relative 'kaboom/helpers/database_helper'
15
+
16
+ require_relative 'kaboom/backup'
17
+
18
+ module Kaboom
19
+ class Error < StandardError; end
20
+
21
+ class CLI < Thor
22
+ include Thor::Actions
23
+ include Utils
24
+ include Config
25
+
26
+ def self.exit_on_failure?
27
+ true
28
+ end
29
+
30
+ desc 'backup', 'Run database backup/restore related commands'
31
+ subcommand 'backup', Kaboom::Backup
32
+
33
+ desc 'console DESTINATION', 'Run Rails console for deployed apps, defaults to staging'
34
+ method_option :role, aliases: '-r', type: :string, desc: 'Role to run the console at (web, sidekiq, cron...)', default: 'web'
35
+ def console(destination = 'staging')
36
+ validate_repository!
37
+ validate_destination(destination)
38
+
39
+ confirm_production_action('console') if destination == 'production'
40
+ run_command("bundle exec rails c", destination)
41
+ end
42
+
43
+ desc 'ssh DESTINATION', 'Run a bash SSH for deployed apps, defaults to staging'
44
+ method_option :role, aliases: '-r', type: :string, desc: 'Role to run the SSH at (web, sidekiq, cron...)', default: 'web'
45
+ def ssh(destination = 'staging')
46
+ validate_repository!
47
+ validate_destination(destination)
48
+
49
+ confirm_production_action('SSH') if destination == 'production'
50
+ run_command("bash", destination)
51
+ end
52
+
53
+ private
54
+
55
+ def run_command(command, destination)
56
+ say "Running #{command} on #{destination}...", :green
57
+ exec("kamal app exec -i --reuse -q '#{command}' -d #{destination} --roles=#{options[:role]}")
58
+ end
59
+
60
+ end
61
+ end
metadata ADDED
@@ -0,0 +1,192 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bamboolab_kaboom
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Your Name
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dotenv
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: kamal
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: tty-prompt
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: yaml
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: erb
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: google-cloud-storage
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: tty-progressbar
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: activesupport
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '6.0'
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '6.0'
153
+ description: Kaboom is a CLI tool to handle app maintenence, SSH connections, database
154
+ and assets backups for apps deployed by Kamal.
155
+ email:
156
+ - your.email@example.com
157
+ executables:
158
+ - kaboom
159
+ extensions: []
160
+ extra_rdoc_files: []
161
+ files:
162
+ - bin/kaboom
163
+ - lib/kaboom.rb
164
+ - lib/kaboom/backup.rb
165
+ - lib/kaboom/helpers/config.rb
166
+ - lib/kaboom/helpers/database_helper.rb
167
+ - lib/kaboom/helpers/utils.rb
168
+ - lib/kaboom/version.rb
169
+ homepage:
170
+ licenses:
171
+ - MIT
172
+ metadata: {}
173
+ post_install_message:
174
+ rdoc_options: []
175
+ require_paths:
176
+ - lib
177
+ required_ruby_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: 2.7.0
182
+ required_rubygems_version: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: '0'
187
+ requirements: []
188
+ rubygems_version: 3.5.11
189
+ signing_key:
190
+ specification_version: 4
191
+ summary: A CLI for managing Rails apps deployed by Kamal.
192
+ test_files: []