deploio-cli 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eae3786b2b0c98a4d02b1ac9037e9536da6cfc90ab7120ce80d4e98a76df90d7
4
- data.tar.gz: ac86804058048327a006cd3343839299408425dd4e3702fe99aafc571e8e372c
3
+ metadata.gz: 1ef7a08d44a816dd88331bf7e007a536ef399b10afbf05229bef8e1e3137629e
4
+ data.tar.gz: d042f89dc44e64df49d599f1291f88151af45461132156af5c36486fa6408baa
5
5
  SHA512:
6
- metadata.gz: 9a6a68ed578e07a9d0026f1dd61f3971ddd5f5e6b2ec590e2960644bef88de65cf054c04c2255051e0b550061eaa965a521b5700bfaf139ae5e3d76bf1f1aa19
7
- data.tar.gz: 36614043d839a6a59c7ecc761f6d47ce8b9ff05cf149a630f33b17cb10b45ed8e374c2d1293c3ef7b9d98324290eeeb9332c2c530aea90642e18878faa08b9c4
6
+ metadata.gz: 603238b92b6b23a32bf92f81a0a89a4cc0edbf8daad40519f1f165c095629ccb5f83775f01f0aae67541f6177f845ebc9797584fdcdb977425fa2307c2d8f98e
7
+ data.tar.gz: 2ac16607435cc562bcf3d3e94066002315bb06f198122fe6d088ee25782f9153106ab065e217f34a86d5aa99a4cb2d7d154876eabcc958266f2e74cc6771ef09
data/README.md CHANGED
@@ -6,6 +6,7 @@ A CLI for [Deploio](https://www.deplo.io/) that wraps [`nctl`](https://github.co
6
6
 
7
7
  - Ruby 3.3+
8
8
  - nctl version 1.10.0 or higher
9
+ - [rclone](https://rclone.org/) (`brew install rclone`) — only needed for `deploio pg backups` on economy-tier databases
9
10
 
10
11
  ## Installation
11
12
 
@@ -18,7 +19,7 @@ bundle install
18
19
  bundle exec bin/deploio --help
19
20
  ```
20
21
 
21
- ### As a gem (coming soon)
22
+ ### As a gem
22
23
 
23
24
  ```bash
24
25
  gem install deploio-cli
@@ -90,6 +91,13 @@ SERVICES
90
91
  deploio services -p PROJECT --connected-apps Show which apps use each service (requires -p)
91
92
  deploio services --chf Show estimated monthly price (CHF) for each service
92
93
 
94
+ POSTGRESQL
95
+ deploio pg List all PostgreSQL databases
96
+ deploio pg:info NAME Show database details
97
+ deploio pg backups list NAME List available backups
98
+ deploio pg backups download NAME Download the latest backup
99
+ deploio pg backups capture NAME Capture a new backup
100
+
93
101
  LOGS
94
102
  deploio logs -a APP Show recent logs
95
103
  deploio logs -a APP --tail Stream logs continuously
data/lib/deploio/cli.rb CHANGED
@@ -100,6 +100,8 @@ module Deploio
100
100
 
101
101
  private
102
102
 
103
+ # The aliases above hand off with Thor.start, which starts from a fresh
104
+ # option parse, so the shared class options have to be passed as arguments.
103
105
  def build_option_args
104
106
  args = []
105
107
  args << "--dry-run" if options[:dry_run]
@@ -1,3 +1,5 @@
1
+ require "time"
2
+
1
3
  module Deploio
2
4
  module Commands
3
5
  class PostgreSQLBackups < Thor
@@ -5,70 +7,89 @@ module Deploio
5
7
 
6
8
  namespace "pg:backups"
7
9
 
10
+ DEDICATED_KIND = "Postgres"
11
+ ECONOMY_KIND = "PostgresDatabase"
12
+
8
13
  desc "capture NAME", "Capture a new backup for the specified PostgreSQL database"
9
14
  def capture(name)
10
- setup_options
11
- resolver = PgDatabaseResolver.new(nctl_client: @nctl)
12
- db_ref = resolver.resolve(database_name: name)
13
- data = @nctl.get_pg_database(db_ref)
14
- kind = data["kind"] || ""
15
+ backup_service_for(name).capture
16
+ rescue Deploio::Error => e
17
+ Output.error(e.message)
18
+ exit 1
19
+ end
15
20
 
16
- unless kind == "Postgres" || @nctl.dry_run
17
- Output.error("Backups can only be captured for PostgreSQL databases. (shared dbs are not supported)")
18
- exit 1
21
+ desc "list NAME", "List the available backups for the specified PostgreSQL database"
22
+ def list(name)
23
+ backups = backup_service_for(name).backups
24
+ if backups.empty?
25
+ Output.warning("No backups found for '#{name}'")
26
+ return
19
27
  end
20
28
 
21
- fqdn = data.dig("status", "atProvider", "fqdn")
22
- if fqdn.nil? || fqdn.empty?
23
- Output.error("Database FQDN not found; cannot capture backup.")
24
- exit 1
29
+ rows = backups.map do |backup|
30
+ [format_time(backup["ModTime"]), format_size(backup["Size"]), backup["Name"]]
25
31
  end
26
-
27
- cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"]
28
- Output.command(cmd.join(" "))
29
- system(*cmd) unless @nctl.dry_run
32
+ Output.table(rows, headers: ["DATE", "SIZE", "NAME"])
33
+ rescue Deploio::Error => e
34
+ Output.error(e.message)
35
+ exit 1
30
36
  end
31
37
 
32
38
  desc "download NAME [--output destination_path]", "Download the latest backup for the specified PostgreSQL database instance"
33
39
  method_option :output, type: :string, desc: "Output file path (defaults to current directory with auto-generated name)"
34
40
  method_option :db_name, type: :string, desc: "If there are multiple DBs, specify which one to download the backup for", default: nil
35
41
  def download(name)
36
- destination = options[:output] || "./#{name}-latest-backup.zst"
42
+ service = backup_service_for(name)
43
+ destination = merged_options[:output] || service.default_destination
44
+ backup = service.download(destination: destination, db_name: merged_options[:db_name])
37
45
 
46
+ # Only the economy tier knows when its backup was taken.
47
+ if backup
48
+ Output.success("Downloaded backup from #{format_time(backup["ModTime"])} to #{destination}")
49
+ else
50
+ Output.success("Downloaded backup to #{destination}")
51
+ end
52
+ rescue Deploio::Error => e
53
+ Output.error(e.message)
54
+ exit 1
55
+ end
56
+
57
+ private
58
+
59
+ def backup_service_for(name)
38
60
  setup_options
39
61
  resolver = PgDatabaseResolver.new(nctl_client: @nctl)
40
62
  db_ref = resolver.resolve(database_name: name)
41
63
  data = @nctl.get_pg_database(db_ref)
42
- kind = data["kind"] || ""
43
-
44
- unless kind == "Postgres" || @nctl.dry_run
45
- Output.error("Backups can only be downloaded for PostgreSQL databases. (shared dbs are not supported)")
46
- exit 1
47
- end
64
+ raise Deploio::Error, "Could not read database '#{db_ref.full_name}'." if data.nil?
48
65
 
49
- databases = data.dig("status", "atProvider", "databases")&.keys || []
50
- databases.reject! { |db| db.strip.empty? }
51
- if databases.empty?
52
- Output.error("No databases found in PostgreSQL instance; cannot download backup.")
53
- exit 1
54
- elsif databases.size > 1 && options[:db_name].nil?
55
- Output.error("Multiple databases found in PostgreSQL instance")
56
- Output.error("Databases: #{databases.join(", ")}")
57
- Output.error("Please specify the database name using the --db_name option.")
58
- exit 1
66
+ case data["kind"]
67
+ when DEDICATED_KIND
68
+ PostgresBackupService.new(data: data, name: name, dry_run: @nctl.dry_run)
69
+ when ECONOMY_KIND
70
+ PostgresDatabaseBackupService.new(db_ref: db_ref, data: data, nctl_client: @nctl, name: name)
71
+ else
72
+ raise Deploio::UnsupportedBackupOperationError,
73
+ "Backups are not supported for databases of kind '#{data["kind"]}'."
59
74
  end
75
+ end
60
76
 
61
- db_name = options[:db_name] || databases.first
77
+ def format_time(value)
78
+ Time.parse(value.to_s).localtime.strftime("%Y-%m-%d %H:%M")
79
+ rescue ArgumentError, TypeError
80
+ value.to_s
81
+ end
62
82
 
63
- fqdn = data.dig("status", "atProvider", "fqdn")
64
- if fqdn.nil? || fqdn.empty?
65
- Output.error("Database FQDN not found; cannot download backup.")
66
- exit 1
83
+ def format_size(bytes)
84
+ bytes = bytes.to_i
85
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
86
+ index = 0
87
+ size = bytes.to_f
88
+ while size >= 1024 && index < units.size - 1
89
+ size /= 1024
90
+ index += 1
67
91
  end
68
-
69
- cmd = ["rsync", "-avz", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{db_name}/#{db_name}.zst", destination]
70
- Output.command(cmd.join(" "))
71
- system(*cmd) unless @nctl.dry_run
92
+ (index.zero? ? "#{bytes} B" : format("%.1f %s", size, units[index]))
72
93
  end
73
94
  end
74
95
  end
@@ -61,6 +61,7 @@ module Deploio
61
61
  "orgs:set" => "'1:organization:_#{program_name}_orgs_list'",
62
62
  "pg:info" => "'1:database:_#{program_name}_pg_databases_list'",
63
63
  "pg:backups:capture" => "'1:database:_#{program_name}_pg_databases_list'",
64
+ "pg:backups:list" => "'1:database:_#{program_name}_pg_databases_list'",
64
65
  "pg:backups:download" => "'1:database:_#{program_name}_pg_databases_list'"
65
66
  }
66
67
  end
@@ -197,6 +197,14 @@ module Deploio
197
197
  nil
198
198
  end
199
199
 
200
+ def get_bucket_user_access_key(name, project:)
201
+ capture("get", "bucketuser", name, "--project", project, "--print-access-key").strip
202
+ end
203
+
204
+ def get_bucket_user_secret_key(name, project:)
205
+ capture("get", "bucketuser", name, "--project", project, "--print-secret-key").strip
206
+ end
207
+
200
208
  def get_projects
201
209
  output = capture("get", "projects", "-o", "json")
202
210
  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, default: false, desc: "Print commands without executing"
11
- base.class_option :no_color, type: :boolean, default: false, desc: "Disable colored output"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Deploio
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/deploio.rb CHANGED
@@ -8,8 +8,11 @@ require_relative "deploio/output"
8
8
  require_relative "deploio/app_ref"
9
9
  require_relative "deploio/pg_database_ref"
10
10
  require_relative "deploio/nctl_client"
11
+ require_relative "deploio/rclone_client"
11
12
  require_relative "deploio/app_resolver"
12
13
  require_relative "deploio/pg_database_resolver"
14
+ require_relative "deploio/postgres_backup_service"
15
+ require_relative "deploio/postgres_database_backup_service"
13
16
  require_relative "deploio/price_fetcher"
14
17
  require_relative "deploio/shared_options"
15
18
  require_relative "deploio/cli"
@@ -19,4 +22,6 @@ module Deploio
19
22
  class AppNotFoundError < Error; end
20
23
  class PgDatabaseNotFoundError < Error; end
21
24
  class NctlError < Error; end
25
+ class RcloneError < Error; end
26
+ class UnsupportedBackupOperationError < Error; end
22
27
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deploio-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renuo AG
@@ -67,7 +67,10 @@ files:
67
67
  - lib/deploio/output.rb
68
68
  - lib/deploio/pg_database_ref.rb
69
69
  - lib/deploio/pg_database_resolver.rb
70
+ - lib/deploio/postgres_backup_service.rb
71
+ - lib/deploio/postgres_database_backup_service.rb
70
72
  - lib/deploio/price_fetcher.rb
73
+ - lib/deploio/rclone_client.rb
71
74
  - lib/deploio/shared_options.rb
72
75
  - lib/deploio/templates/completion.zsh.erb
73
76
  - lib/deploio/utils.rb