deploio-cli 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +24 -15
- data/deploio-cli.gemspec +4 -1
- data/lib/deploio/bash_completion_generator.rb +97 -0
- data/lib/deploio/cli.rb +14 -3
- data/lib/deploio/commands/mysql.rb +139 -0
- data/lib/deploio/commands/mysql_backups.rb +77 -0
- data/lib/deploio/commands/postgresql_backups.rb +47 -45
- data/lib/deploio/completion_generator.rb +2 -1
- data/lib/deploio/helpers/backups_helper.rb +25 -0
- data/lib/deploio/mysql_backup_service.rb +79 -0
- data/lib/deploio/mysql_database_backup_service.rb +129 -0
- data/lib/deploio/mysql_database_ref.rb +40 -0
- data/lib/deploio/mysql_database_resolver.rb +71 -0
- data/lib/deploio/nctl_client.rb +44 -0
- data/lib/deploio/postgres_backup_service.rb +68 -0
- data/lib/deploio/postgres_database_backup_service.rb +116 -0
- data/lib/deploio/rclone_client.rb +99 -0
- data/lib/deploio/shared_options.rb +2 -2
- data/lib/deploio/templates/completion.bash.erb +140 -0
- data/lib/deploio/version.rb +1 -1
- data/lib/deploio.rb +10 -0
- metadata +18 -3
- data/setup +0 -115
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class MysqlBackupService
|
|
5
|
+
DEFAULT_EXTENSION = ".zst"
|
|
6
|
+
|
|
7
|
+
# @param name [String] the name the user typed, used for hints in messages
|
|
8
|
+
def initialize(data:, name: nil, dry_run: false)
|
|
9
|
+
@data = data || {}
|
|
10
|
+
@name = name
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def default_destination = "./#{@name}-latest-backup"
|
|
15
|
+
|
|
16
|
+
def backups
|
|
17
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
18
|
+
"Listing backups is not yet supported for dedicated MySQL instances; Feel free to implement it!\n" \
|
|
19
|
+
"Use 'deploio mysql backups download #{@name}' to fetch it."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def capture
|
|
23
|
+
cmd = ["ssh", "dbadmin@#{fqdn}", "sudo", "nine-mysql-backup"]
|
|
24
|
+
Output.command(cmd.join(" "))
|
|
25
|
+
system(*cmd) unless @dry_run
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def download(destination:, db_name: nil)
|
|
29
|
+
name = resolve_db_name(db_name)
|
|
30
|
+
|
|
31
|
+
cmd = [
|
|
32
|
+
"rsync",
|
|
33
|
+
"-av",
|
|
34
|
+
"dbadmin@#{fqdn}:~/backup/mysql/latest/customer/#{name}/",
|
|
35
|
+
destination
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
Output.command(cmd.join(" "))
|
|
39
|
+
unless @dry_run || system(*cmd)
|
|
40
|
+
raise Deploio::Error, "Failed to download MySQL backup for database '#{name}'."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def resolve_db_name(db_name)
|
|
49
|
+
if databases.empty?
|
|
50
|
+
raise Deploio::Error,
|
|
51
|
+
"No databases found in MySQL instance; cannot download backup."
|
|
52
|
+
elsif databases.size > 1 && db_name.nil?
|
|
53
|
+
raise Deploio::Error,
|
|
54
|
+
"Multiple databases found in MySQL instance\n" \
|
|
55
|
+
"Databases: #{databases.join(", ")}\n" \
|
|
56
|
+
"Please specify the database name using the --db_name option."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
db_name || databases.first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def databases
|
|
63
|
+
@databases ||= (
|
|
64
|
+
@data.dig("status", "atProvider", "databases")&.keys || []
|
|
65
|
+
).reject { |db| db.strip.empty? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fqdn
|
|
69
|
+
value = @data.dig("status", "atProvider", "fqdn")
|
|
70
|
+
|
|
71
|
+
if value.nil? || value.empty?
|
|
72
|
+
raise Deploio::Error,
|
|
73
|
+
"Database FQDN not found; cannot reach the database server."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class MysqlDatabaseBackupService
|
|
5
|
+
DEFAULT_EXTENSION = ".sql.zst"
|
|
6
|
+
BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
db_ref:,
|
|
10
|
+
data:,
|
|
11
|
+
nctl_client:,
|
|
12
|
+
name: nil,
|
|
13
|
+
rclone_client_factory: nil
|
|
14
|
+
)
|
|
15
|
+
@db_ref = db_ref
|
|
16
|
+
@data = data || {}
|
|
17
|
+
@nctl = nctl_client
|
|
18
|
+
@name = name || db_ref.full_name
|
|
19
|
+
@rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def default_destination
|
|
23
|
+
"./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Nine takes these backups on a schedule and there is no way to trigger one.
|
|
27
|
+
def capture
|
|
28
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
29
|
+
"'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
|
|
30
|
+
"on their configured schedule and cannot be captured manually.\n" \
|
|
31
|
+
"Use 'deploio mysql backups list #{@name}' to see the available backups."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def backups
|
|
35
|
+
@backups ||= begin
|
|
36
|
+
entries = rclone.list(bucket_name)
|
|
37
|
+
entries = entries.select { |entry| own_backup?(entry["Name"].to_s) }
|
|
38
|
+
entries.sort_by { |entry| entry["ModTime"].to_s }.reverse
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def download(destination:, db_name: nil)
|
|
43
|
+
backup = backups.first
|
|
44
|
+
|
|
45
|
+
unless backup
|
|
46
|
+
raise Deploio::Error,
|
|
47
|
+
"No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
rclone.download(bucket_name, backup["Name"], destination)
|
|
51
|
+
backup
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def own_backup?(object_name)
|
|
57
|
+
return true if instance_name.empty?
|
|
58
|
+
|
|
59
|
+
object_name.start_with?("MySQLDatabase-#{instance_name}-")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def instance_name
|
|
63
|
+
@instance_name ||= @data.dig("status", "atProvider", "name").to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def bucket_name
|
|
67
|
+
bucket.dig("metadata", "name")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The database resource holds no reference to its backup bucket.
|
|
71
|
+
# Therefore, match the bucket by name for now.
|
|
72
|
+
def bucket
|
|
73
|
+
@bucket ||= begin
|
|
74
|
+
candidates = @nctl
|
|
75
|
+
.get_services_by_type("bucket", project: @db_ref.project_name)
|
|
76
|
+
.select { |candidate| backup_bucket_for_database?(candidate) }
|
|
77
|
+
|
|
78
|
+
if candidates.empty?
|
|
79
|
+
raise Deploio::Error,
|
|
80
|
+
"No backup bucket found for '#{@db_ref.full_name}'. " \
|
|
81
|
+
"Check that backups are enabled for this database " \
|
|
82
|
+
"(spec.forProvider.backupSchedule)."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
candidates.first
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def backup_bucket_for_database?(bucket)
|
|
90
|
+
metadata = bucket["metadata"] || {}
|
|
91
|
+
labels = metadata["labels"] || {}
|
|
92
|
+
|
|
93
|
+
return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
|
|
94
|
+
|
|
95
|
+
metadata["name"].to_s.match?(
|
|
96
|
+
/\Amysqldatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rclone
|
|
101
|
+
@rclone ||= @rclone_client_factory.call
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_rclone_client
|
|
105
|
+
endpoint = bucket.dig("status", "atProvider", "endpoint")
|
|
106
|
+
|
|
107
|
+
if endpoint.nil? || endpoint.to_s.empty?
|
|
108
|
+
raise Deploio::Error,
|
|
109
|
+
"Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
client = RcloneClient.new(
|
|
113
|
+
endpoint: "https://#{endpoint}",
|
|
114
|
+
access_key: @nctl.get_bucket_user_access_key(
|
|
115
|
+
bucket_name,
|
|
116
|
+
project: @db_ref.project_name
|
|
117
|
+
),
|
|
118
|
+
secret_key: @nctl.get_bucket_user_secret_key(
|
|
119
|
+
bucket_name,
|
|
120
|
+
project: @db_ref.project_name
|
|
121
|
+
),
|
|
122
|
+
dry_run: @nctl.dry_run
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
client.check_requirements unless @nctl.dry_run
|
|
126
|
+
client
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
class MysqlDatabaseRef
|
|
7
|
+
attr_reader :project_name, :database_name
|
|
8
|
+
|
|
9
|
+
def initialize(input, available_databases: {})
|
|
10
|
+
@input = input.to_s
|
|
11
|
+
if available_databases.key?(@input)
|
|
12
|
+
match = available_databases[@input]
|
|
13
|
+
@project_name = match[:project_name]
|
|
14
|
+
@database_name = match[:database_name]
|
|
15
|
+
else
|
|
16
|
+
raise_not_found_error(@input, available_databases.keys)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def full_name = "#{project_name}-#{database_name}"
|
|
21
|
+
def to_s = full_name
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
other.is_a?(self.class) && project_name == other.project_name && database_name == other.database_name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def raise_not_found_error(input, available_database_names)
|
|
30
|
+
message = "Database not found: '#{input}'"
|
|
31
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: available_database_names).correct(input) unless available_database_names.empty?
|
|
32
|
+
unless suggestions.nil? || suggestions.empty?
|
|
33
|
+
message += "\n\nDid you mean?"
|
|
34
|
+
suggestions.each { |suggestion| message += "\n #{suggestion}" }
|
|
35
|
+
end
|
|
36
|
+
message += "\n\nRun 'deploio mysql' to see available MySQL databases."
|
|
37
|
+
raise Deploio::MysqlDatabaseNotFoundError, message
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class MysqlDatabaseResolver
|
|
5
|
+
attr_reader :nctl, :current_org
|
|
6
|
+
|
|
7
|
+
def initialize(nctl_client:)
|
|
8
|
+
@nctl = nctl_client
|
|
9
|
+
@database_ref = MysqlDatabaseRef
|
|
10
|
+
@fetch_databases = -> { @nctl.get_all_mysql_databases }
|
|
11
|
+
@current_org = @nctl.current_org
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(database_name: nil)
|
|
15
|
+
if database_name
|
|
16
|
+
return @database_ref.new(
|
|
17
|
+
database_name,
|
|
18
|
+
available_databases: available_databases_hash
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
raise Deploio::Error, "No database specified"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns hash mapping database names -> { project_name:, database_name: }
|
|
26
|
+
def available_databases_hash
|
|
27
|
+
@available_databases_hash ||= begin
|
|
28
|
+
hash = {}
|
|
29
|
+
current_org = @nctl.current_org
|
|
30
|
+
|
|
31
|
+
@fetch_databases.call.each do |database|
|
|
32
|
+
metadata = database["metadata"] || {}
|
|
33
|
+
project_name = metadata["namespace"] || ""
|
|
34
|
+
database_name = metadata["name"]
|
|
35
|
+
full_name = "#{project_name}-#{database_name}"
|
|
36
|
+
|
|
37
|
+
hash[full_name] = {
|
|
38
|
+
project_name: project_name,
|
|
39
|
+
database_name: database_name
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Also index by short name (without org prefix) for convenience
|
|
43
|
+
if current_org && project_name.start_with?("#{current_org}-")
|
|
44
|
+
project = project_name.delete_prefix("#{current_org}-")
|
|
45
|
+
short_name = "#{project}-#{database_name}"
|
|
46
|
+
|
|
47
|
+
hash[short_name] ||= {
|
|
48
|
+
project_name: project_name,
|
|
49
|
+
database_name: database_name
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
hash
|
|
55
|
+
end
|
|
56
|
+
rescue
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def short_name_for(namespace, database_name)
|
|
61
|
+
org = current_org
|
|
62
|
+
|
|
63
|
+
if org && namespace.start_with?("#{org}-")
|
|
64
|
+
project = namespace.delete_prefix("#{org}-")
|
|
65
|
+
"#{project}-#{database_name}"
|
|
66
|
+
else
|
|
67
|
+
"#{namespace}-#{database_name}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/deploio/nctl_client.rb
CHANGED
|
@@ -100,6 +100,42 @@ module Deploio
|
|
|
100
100
|
nil
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def get_all_mysql_databases
|
|
104
|
+
output_dedicated_dbs = capture("get", "mysql", "-A", "-o", "json")
|
|
105
|
+
output_shared_dbs = capture("get", "mysqldatabase", "-A", "-o", "json")
|
|
106
|
+
if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
|
|
107
|
+
(output_shared_dbs.nil? || output_shared_dbs.empty?)
|
|
108
|
+
return []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[
|
|
112
|
+
*JSON.parse(output_dedicated_dbs),
|
|
113
|
+
*JSON.parse(output_shared_dbs)
|
|
114
|
+
]
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get_mysql_database(db_ref)
|
|
120
|
+
output = begin
|
|
121
|
+
capture("get", "mysql", db_ref.database_name,
|
|
122
|
+
"--project", db_ref.project_name, "-o", "json")
|
|
123
|
+
rescue Deploio::NctlError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if output.nil? || output.empty?
|
|
128
|
+
output = capture("get", "mysqldatabase", db_ref.database_name,
|
|
129
|
+
"--project", db_ref.project_name, "-o", "json")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return nil if output.nil? || output.empty?
|
|
133
|
+
|
|
134
|
+
JSON.parse(output)
|
|
135
|
+
rescue JSON::ParserError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
103
139
|
def get_apps_by_project(project)
|
|
104
140
|
output = capture("get", "apps", "--project", project, "-o", "json")
|
|
105
141
|
return [] if output.nil? || output.empty?
|
|
@@ -197,6 +233,14 @@ module Deploio
|
|
|
197
233
|
nil
|
|
198
234
|
end
|
|
199
235
|
|
|
236
|
+
def get_bucket_user_access_key(name, project:)
|
|
237
|
+
capture("get", "bucketuser", name, "--project", project, "--print-access-key").strip
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def get_bucket_user_secret_key(name, project:)
|
|
241
|
+
capture("get", "bucketuser", name, "--project", project, "--print-secret-key").strip
|
|
242
|
+
end
|
|
243
|
+
|
|
200
244
|
def get_projects
|
|
201
245
|
output = capture("get", "projects", "-o", "json")
|
|
202
246
|
return [] if output.nil? || output.empty?
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
# Backups for the dedicated tier (kind: Postgres), where we own the whole
|
|
5
|
+
# database server and reach it over SSH
|
|
6
|
+
# The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay
|
|
7
|
+
# consistent with that
|
|
8
|
+
class PostgresBackupService
|
|
9
|
+
DEFAULT_EXTENSION = ".zst"
|
|
10
|
+
|
|
11
|
+
# @param name [String] the name the user typed, used for hints in messages
|
|
12
|
+
def initialize(data:, name: nil, dry_run: false)
|
|
13
|
+
@data = data || {}
|
|
14
|
+
@name = name
|
|
15
|
+
@dry_run = dry_run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def default_destination = "./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
|
|
19
|
+
|
|
20
|
+
def backups
|
|
21
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
22
|
+
"Listing backups is not yet supported for dedicated PostgreSQL instances; Feel free to implement it!\n" \
|
|
23
|
+
"Use 'deploio pg backups download #{@name}' to fetch it."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture
|
|
27
|
+
cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"]
|
|
28
|
+
Output.command(cmd.join(" "))
|
|
29
|
+
system(*cmd) unless @dry_run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def download(destination:, db_name: nil)
|
|
33
|
+
name = resolve_db_name(db_name)
|
|
34
|
+
|
|
35
|
+
cmd = ["rsync", "-av", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{name}/#{name}.zst", destination]
|
|
36
|
+
Output.command(cmd.join(" "))
|
|
37
|
+
system(*cmd) unless @dry_run
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def resolve_db_name(db_name)
|
|
45
|
+
if databases.empty?
|
|
46
|
+
raise Deploio::Error, "No databases found in PostgreSQL instance; cannot download backup."
|
|
47
|
+
elsif databases.size > 1 && db_name.nil?
|
|
48
|
+
raise Deploio::Error,
|
|
49
|
+
"Multiple databases found in PostgreSQL instance\n" \
|
|
50
|
+
"Databases: #{databases.join(", ")}\n" \
|
|
51
|
+
"Please specify the database name using the --db_name option."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
db_name || databases.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def databases
|
|
58
|
+
@databases ||= (@data.dig("status", "atProvider", "databases")&.keys || []).reject { |db| db.strip.empty? }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fqdn
|
|
62
|
+
value = @data.dig("status", "atProvider", "fqdn")
|
|
63
|
+
raise Deploio::Error, "Database FQDN not found; cannot reach the database server." if value.nil? || value.empty?
|
|
64
|
+
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
# Backups for the economy tier (kind: PostgresDatabase), where the database
|
|
5
|
+
# lives on a shared server we have no access to.
|
|
6
|
+
# The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay
|
|
7
|
+
# consistent with that
|
|
8
|
+
class PostgresDatabaseBackupService
|
|
9
|
+
DEFAULT_EXTENSION = ".sql.zst"
|
|
10
|
+
|
|
11
|
+
BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
|
|
12
|
+
|
|
13
|
+
def initialize(db_ref:, data:, nctl_client:, name: nil, rclone_client_factory: nil)
|
|
14
|
+
@db_ref = db_ref
|
|
15
|
+
@data = data || {}
|
|
16
|
+
@nctl = nctl_client
|
|
17
|
+
@name = name || db_ref.full_name
|
|
18
|
+
@rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def default_destination
|
|
22
|
+
"./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Nine takes these backups on a schedule
|
|
26
|
+
# (you can even see them using `kubectl get databasebackupschedules.storage.nine.ch -n renuo-chess-tracker -o json`)
|
|
27
|
+
# => there is no way to trigger one.
|
|
28
|
+
def capture
|
|
29
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
30
|
+
"'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
|
|
31
|
+
"on their configured schedule and cannot be captured manually.\n" \
|
|
32
|
+
"Use 'deploio pg backups list #{@name}' to see the available backups."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def backups
|
|
36
|
+
@backups ||= begin
|
|
37
|
+
entries = rclone.list(bucket_name)
|
|
38
|
+
entries = entries.select { |e| own_backup?(e["Name"].to_s) }
|
|
39
|
+
entries.sort_by { |e| e["ModTime"].to_s }.reverse
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def download(destination:, db_name: nil)
|
|
44
|
+
backup = backups.first
|
|
45
|
+
unless backup
|
|
46
|
+
raise Deploio::Error, "No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rclone.download(bucket_name, backup["Name"], destination)
|
|
50
|
+
backup
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def own_backup?(object_name)
|
|
56
|
+
return true if instance_name.empty?
|
|
57
|
+
|
|
58
|
+
object_name.start_with?("PostgresDatabase-#{instance_name}-")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def instance_name
|
|
62
|
+
@instance_name ||= @data.dig("status", "atProvider", "name").to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bucket_name
|
|
66
|
+
bucket.dig("metadata", "name")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The PostgresDatabase resource holds no reference to its backup bucket
|
|
70
|
+
# Changes with service connections (I assume), but we're not there yet as we still have projects with the old setup.
|
|
71
|
+
# Therefore, we match by name
|
|
72
|
+
def bucket
|
|
73
|
+
@bucket ||= begin
|
|
74
|
+
candidates = @nctl.get_services_by_type("bucket", project: @db_ref.project_name).select do |bucket|
|
|
75
|
+
backup_bucket_for_database?(bucket)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if candidates.empty?
|
|
79
|
+
raise Deploio::Error,
|
|
80
|
+
"No backup bucket found for '#{@db_ref.full_name}'. " \
|
|
81
|
+
"Check that backups are enabled for this database (spec.forProvider.backupSchedule)."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
candidates.first
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def backup_bucket_for_database?(bucket)
|
|
89
|
+
metadata = bucket["metadata"] || {}
|
|
90
|
+
labels = metadata["labels"] || {}
|
|
91
|
+
return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
|
|
92
|
+
|
|
93
|
+
metadata["name"].to_s.match?(/\Apostgresdatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rclone
|
|
97
|
+
@rclone ||= @rclone_client_factory.call
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_rclone_client
|
|
101
|
+
endpoint = bucket.dig("status", "atProvider", "endpoint")
|
|
102
|
+
if endpoint.nil? || endpoint.to_s.empty?
|
|
103
|
+
raise Deploio::Error, "Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
client = RcloneClient.new(
|
|
107
|
+
endpoint: "https://#{endpoint}",
|
|
108
|
+
access_key: @nctl.get_bucket_user_access_key(bucket_name, project: @db_ref.project_name),
|
|
109
|
+
secret_key: @nctl.get_bucket_user_secret_key(bucket_name, project: @db_ref.project_name),
|
|
110
|
+
dry_run: @nctl.dry_run
|
|
111
|
+
)
|
|
112
|
+
client.check_requirements unless @nctl.dry_run
|
|
113
|
+
client
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Deploio
|
|
7
|
+
class RcloneClient
|
|
8
|
+
REMOTE = "DEPLOIO"
|
|
9
|
+
|
|
10
|
+
attr_reader :dry_run
|
|
11
|
+
|
|
12
|
+
def initialize(endpoint:, access_key:, secret_key:, dry_run: false)
|
|
13
|
+
@endpoint = endpoint
|
|
14
|
+
@access_key = access_key
|
|
15
|
+
@secret_key = secret_key
|
|
16
|
+
@dry_run = dry_run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check_requirements
|
|
20
|
+
check_rclone_installed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list(bucket)
|
|
24
|
+
output = capture("lsjson", remote_path(bucket))
|
|
25
|
+
return [] if output.nil? || output.empty?
|
|
26
|
+
|
|
27
|
+
data = JSON.parse(output)
|
|
28
|
+
data.is_a?(Array) ? data : []
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def download(bucket, object, destination)
|
|
34
|
+
run("copyto", remote_path(bucket, object), destination, "--progress", "--stats-one-line")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# --s3-no-check-bucket skips the HeadBucket call, which the read-only bucket
|
|
40
|
+
# user is not permitted to make.
|
|
41
|
+
# See also https://docs.nine.ch/docs/object-storage/object-storage-client-tools#rclone
|
|
42
|
+
def build_command(args)
|
|
43
|
+
["rclone", *args.map(&:to_s), "--s3-no-check-bucket"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remote_path(bucket, object = nil)
|
|
47
|
+
object ? "#{REMOTE}:#{bucket}/#{object}" : "#{REMOTE}:#{bucket}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def env
|
|
51
|
+
{
|
|
52
|
+
"RCLONE_CONFIG_#{REMOTE}_TYPE" => "s3",
|
|
53
|
+
"RCLONE_CONFIG_#{REMOTE}_PROVIDER" => "Other",
|
|
54
|
+
"RCLONE_CONFIG_#{REMOTE}_ENDPOINT" => @endpoint,
|
|
55
|
+
"RCLONE_CONFIG_#{REMOTE}_ACCESS_KEY_ID" => @access_key,
|
|
56
|
+
"RCLONE_CONFIG_#{REMOTE}_SECRET_ACCESS_KEY" => @secret_key
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def capture(*args)
|
|
61
|
+
cmd = build_command(args)
|
|
62
|
+
if dry_run
|
|
63
|
+
Output.command(cmd.join(" "))
|
|
64
|
+
return ""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"]
|
|
68
|
+
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
69
|
+
unless status.success?
|
|
70
|
+
raise Deploio::RcloneError, "rclone command failed: #{stderr}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
stdout
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run(*args)
|
|
77
|
+
cmd = build_command(args)
|
|
78
|
+
Output.command(cmd.join(" "))
|
|
79
|
+
return true if dry_run
|
|
80
|
+
|
|
81
|
+
unless system(env, *cmd)
|
|
82
|
+
raise Deploio::RcloneError, "rclone command failed: #{cmd.join(" ")}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_rclone_installed
|
|
89
|
+
_stdout, _stderr, status = Open3.capture3("rclone", "version")
|
|
90
|
+
return if status.success?
|
|
91
|
+
|
|
92
|
+
raise Deploio::RcloneError,
|
|
93
|
+
"rclone not found. Please install it: brew install rclone"
|
|
94
|
+
rescue Errno::ENOENT
|
|
95
|
+
raise Deploio::RcloneError,
|
|
96
|
+
"rclone not found. Please install it: brew install rclone"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -7,8 +7,8 @@ module Deploio
|
|
|
7
7
|
def self.included(base)
|
|
8
8
|
base.class_option :app, aliases: "-a", type: :string, desc: "App in <project>-<app> format"
|
|
9
9
|
base.class_option :org, aliases: "-o", type: :string, desc: "Organization"
|
|
10
|
-
base.class_option :dry_run, type: :boolean,
|
|
11
|
-
base.class_option :no_color, type: :boolean,
|
|
10
|
+
base.class_option :dry_run, type: :boolean, desc: "Print commands without executing"
|
|
11
|
+
base.class_option :no_color, type: :boolean, desc: "Disable colored output"
|
|
12
12
|
|
|
13
13
|
base.define_singleton_method(:exit_on_failure?) { true }
|
|
14
14
|
end
|