diff --git a/README.md b/README.md index f5dc37b..16acbf9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A CLI for [Deploio](https://www.deplo.io/) that wraps [`nctl`](https://github.co - Ruby 3.3+ - nctl version 1.10.0 or higher +- [rclone](https://rclone.org/) (`brew install rclone`) — only needed for `deploio pg backups` on economy-tier databases ## Installation @@ -90,6 +91,13 @@ SERVICES deploio services -p PROJECT --connected-apps Show which apps use each service (requires -p) deploio services --chf Show estimated monthly price (CHF) for each service +POSTGRESQL + deploio pg List all PostgreSQL databases + deploio pg:info NAME Show database details + deploio pg backups list NAME List available backups + deploio pg backups download NAME Download the latest backup + deploio pg backups capture NAME Capture a new backup + LOGS deploio logs -a APP Show recent logs deploio logs -a APP --tail Stream logs continuously diff --git a/lib/deploio.rb b/lib/deploio.rb index 9db06be..3446a69 100644 --- a/lib/deploio.rb +++ b/lib/deploio.rb @@ -8,8 +8,11 @@ require_relative "deploio/app_ref" require_relative "deploio/pg_database_ref" require_relative "deploio/nctl_client" +require_relative "deploio/rclone_client" require_relative "deploio/app_resolver" require_relative "deploio/pg_database_resolver" +require_relative "deploio/postgres_backup_service" +require_relative "deploio/postgres_database_backup_service" require_relative "deploio/price_fetcher" require_relative "deploio/shared_options" require_relative "deploio/cli" @@ -19,4 +22,6 @@ class Error < StandardError; end class AppNotFoundError < Error; end class PgDatabaseNotFoundError < Error; end class NctlError < Error; end + class RcloneError < Error; end + class UnsupportedBackupOperationError < Error; end end diff --git a/lib/deploio/cli.rb b/lib/deploio/cli.rb index 8e28696..d620056 100644 --- a/lib/deploio/cli.rb +++ b/lib/deploio/cli.rb @@ -100,13 +100,6 @@ def exec(*args) private - def build_option_args - args = [] - args << "--dry-run" if options[:dry_run] - args << "--no-color" if options[:no_color] - args << "--app" << options[:app] if options[:app] - args << "--org" << options[:org] if options[:org] - args - end + alias_method :build_option_args, :forwarded_option_args end end diff --git a/lib/deploio/commands/postgresql.rb b/lib/deploio/commands/postgresql.rb index 74d901a..fc7c3a5 100644 --- a/lib/deploio/commands/postgresql.rb +++ b/lib/deploio/commands/postgresql.rb @@ -113,6 +113,15 @@ def info(name) desc "backups COMMAND", "Manage PostgreSQL database backups" subcommand "backups", Commands::PostgreSQLBackups + # Replaces the dispatch method Thor generates for the subcommand above, + # which loses class options like --dry-run at this nesting depth. + remove_method :backups + no_commands do + def backups(*args) + Commands::PostgreSQLBackups.start(args + forwarded_option_args) + end + end + private def presence(value, default: "-") diff --git a/lib/deploio/commands/postgresql_backups.rb b/lib/deploio/commands/postgresql_backups.rb index 866cd6f..8f0b9c4 100644 --- a/lib/deploio/commands/postgresql_backups.rb +++ b/lib/deploio/commands/postgresql_backups.rb @@ -1,3 +1,5 @@ +require "time" + module Deploio module Commands class PostgreSQLBackups < Thor @@ -5,70 +7,89 @@ class PostgreSQLBackups < Thor namespace "pg:backups" + DEDICATED_KIND = "Postgres" + ECONOMY_KIND = "PostgresDatabase" + desc "capture NAME", "Capture a new backup for the specified PostgreSQL database" def capture(name) - setup_options - resolver = PgDatabaseResolver.new(nctl_client: @nctl) - db_ref = resolver.resolve(database_name: name) - data = @nctl.get_pg_database(db_ref) - kind = data["kind"] || "" + backup_service_for(name).capture + rescue Deploio::Error => e + Output.error(e.message) + exit 1 + end - unless kind == "Postgres" || @nctl.dry_run - Output.error("Backups can only be captured for PostgreSQL databases. (shared dbs are not supported)") - exit 1 + desc "list NAME", "List the available backups for the specified PostgreSQL database" + def list(name) + backups = backup_service_for(name).backups + if backups.empty? + Output.warning("No backups found for '#{name}'") + return end - fqdn = data.dig("status", "atProvider", "fqdn") - if fqdn.nil? || fqdn.empty? - Output.error("Database FQDN not found; cannot capture backup.") - exit 1 + rows = backups.map do |backup| + [format_time(backup["ModTime"]), format_size(backup["Size"]), backup["Name"]] end - - cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"] - Output.command(cmd.join(" ")) - system(*cmd) unless @nctl.dry_run + Output.table(rows, headers: ["DATE", "SIZE", "NAME"]) + rescue Deploio::Error => e + Output.error(e.message) + exit 1 end desc "download NAME [--output destination_path]", "Download the latest backup for the specified PostgreSQL database instance" method_option :output, type: :string, desc: "Output file path (defaults to current directory with auto-generated name)" method_option :db_name, type: :string, desc: "If there are multiple DBs, specify which one to download the backup for", default: nil def download(name) - destination = options[:output] || "./#{name}-latest-backup.zst" + service = backup_service_for(name) + destination = merged_options[:output] || service.default_destination + backup = service.download(destination: destination, db_name: merged_options[:db_name]) + # Only the economy tier knows when its backup was taken. + if backup + Output.success("Downloaded backup from #{format_time(backup["ModTime"])} to #{destination}") + else + Output.success("Downloaded backup to #{destination}") + end + rescue Deploio::Error => e + Output.error(e.message) + exit 1 + end + + private + + def backup_service_for(name) setup_options resolver = PgDatabaseResolver.new(nctl_client: @nctl) db_ref = resolver.resolve(database_name: name) data = @nctl.get_pg_database(db_ref) - kind = data["kind"] || "" - - unless kind == "Postgres" || @nctl.dry_run - Output.error("Backups can only be downloaded for PostgreSQL databases. (shared dbs are not supported)") - exit 1 - end + raise Deploio::Error, "Could not read database '#{db_ref.full_name}'." if data.nil? - databases = data.dig("status", "atProvider", "databases")&.keys || [] - databases.reject! { |db| db.strip.empty? } - if databases.empty? - Output.error("No databases found in PostgreSQL instance; cannot download backup.") - exit 1 - elsif databases.size > 1 && options[:db_name].nil? - Output.error("Multiple databases found in PostgreSQL instance") - Output.error("Databases: #{databases.join(", ")}") - Output.error("Please specify the database name using the --db_name option.") - exit 1 + case data["kind"] + when DEDICATED_KIND + PostgresBackupService.new(data: data, name: name, dry_run: @nctl.dry_run) + when ECONOMY_KIND + PostgresDatabaseBackupService.new(db_ref: db_ref, data: data, nctl_client: @nctl, name: name) + else + raise Deploio::UnsupportedBackupOperationError, + "Backups are not supported for databases of kind '#{data["kind"]}'." end + end - db_name = options[:db_name] || databases.first + def format_time(value) + Time.parse(value.to_s).localtime.strftime("%Y-%m-%d %H:%M") + rescue ArgumentError, TypeError + value.to_s + end - fqdn = data.dig("status", "atProvider", "fqdn") - if fqdn.nil? || fqdn.empty? - Output.error("Database FQDN not found; cannot download backup.") - exit 1 + def format_size(bytes) + bytes = bytes.to_i + units = ["B", "KiB", "MiB", "GiB", "TiB"] + index = 0 + size = bytes.to_f + while size >= 1024 && index < units.size - 1 + size /= 1024 + index += 1 end - - cmd = ["rsync", "-avz", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{db_name}/#{db_name}.zst", destination] - Output.command(cmd.join(" ")) - system(*cmd) unless @nctl.dry_run + (index.zero? ? "#{bytes} B" : format("%.1f %s", size, units[index])) end end end diff --git a/lib/deploio/completion_generator.rb b/lib/deploio/completion_generator.rb index 8f44273..f4abdb9 100644 --- a/lib/deploio/completion_generator.rb +++ b/lib/deploio/completion_generator.rb @@ -61,6 +61,7 @@ def default_positional_completers "orgs:set" => "'1:organization:_#{program_name}_orgs_list'", "pg:info" => "'1:database:_#{program_name}_pg_databases_list'", "pg:backups:capture" => "'1:database:_#{program_name}_pg_databases_list'", + "pg:backups:list" => "'1:database:_#{program_name}_pg_databases_list'", "pg:backups:download" => "'1:database:_#{program_name}_pg_databases_list'" } end diff --git a/lib/deploio/nctl_client.rb b/lib/deploio/nctl_client.rb index 58db4f5..3be0135 100644 --- a/lib/deploio/nctl_client.rb +++ b/lib/deploio/nctl_client.rb @@ -197,6 +197,14 @@ def get_service_connection_string(type, name, project:) nil end + def get_bucket_user_access_key(name, project:) + capture("get", "bucketuser", name, "--project", project, "--print-access-key").strip + end + + def get_bucket_user_secret_key(name, project:) + capture("get", "bucketuser", name, "--project", project, "--print-secret-key").strip + end + def get_projects output = capture("get", "projects", "-o", "json") return [] if output.nil? || output.empty? diff --git a/lib/deploio/postgres_backup_service.rb b/lib/deploio/postgres_backup_service.rb new file mode 100644 index 0000000..1f45d1f --- /dev/null +++ b/lib/deploio/postgres_backup_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Deploio + # Backups for the dedicated tier (kind: Postgres), where we own the whole + # database server and reach it over SSH + # The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay + # consistent with that + class PostgresBackupService + DEFAULT_EXTENSION = ".zst" + + # @param name [String] the name the user typed, used for hints in messages + def initialize(data:, name: nil, dry_run: false) + @data = data || {} + @name = name + @dry_run = dry_run + end + + def default_destination = "./#{@name}-latest-backup#{DEFAULT_EXTENSION}" + + def backups + raise Deploio::UnsupportedBackupOperationError, + "Listing backups is not yet supported for dedicated PostgreSQL instances; Feel free to implement it!\n" \ + "Use 'deploio pg backups download #{@name}' to fetch it." + end + + def capture + cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"] + Output.command(cmd.join(" ")) + system(*cmd) unless @dry_run + end + + def download(destination:, db_name: nil) + name = resolve_db_name(db_name) + + cmd = ["rsync", "-av", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{name}/#{name}.zst", destination] + Output.command(cmd.join(" ")) + system(*cmd) unless @dry_run + + nil + end + + private + + def resolve_db_name(db_name) + if databases.empty? + raise Deploio::Error, "No databases found in PostgreSQL instance; cannot download backup." + elsif databases.size > 1 && db_name.nil? + raise Deploio::Error, + "Multiple databases found in PostgreSQL instance\n" \ + "Databases: #{databases.join(", ")}\n" \ + "Please specify the database name using the --db_name option." + end + + db_name || databases.first + end + + def databases + @databases ||= (@data.dig("status", "atProvider", "databases")&.keys || []).reject { |db| db.strip.empty? } + end + + def fqdn + value = @data.dig("status", "atProvider", "fqdn") + raise Deploio::Error, "Database FQDN not found; cannot reach the database server." if value.nil? || value.empty? + + value + end + end +end diff --git a/lib/deploio/postgres_database_backup_service.rb b/lib/deploio/postgres_database_backup_service.rb new file mode 100644 index 0000000..7c222c4 --- /dev/null +++ b/lib/deploio/postgres_database_backup_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Deploio + # Backups for the economy tier (kind: PostgresDatabase), where the database + # lives on a shared server we have no access to. + # The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay + # consistent with that + class PostgresDatabaseBackupService + DEFAULT_EXTENSION = ".sql.zst" + + BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule" + + def initialize(db_ref:, data:, nctl_client:, name: nil, rclone_client_factory: nil) + @db_ref = db_ref + @data = data || {} + @nctl = nctl_client + @name = name || db_ref.full_name + @rclone_client_factory = rclone_client_factory || method(:build_rclone_client) + end + + def default_destination + "./#{@name}-latest-backup#{DEFAULT_EXTENSION}" + end + + # Nine takes these backups on a schedule + # (you can even see them using `kubectl get databasebackupschedules.storage.nine.ch -n renuo-chess-tracker -o json`) + # => there is no way to trigger one. + def capture + raise Deploio::UnsupportedBackupOperationError, + "'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \ + "on their configured schedule and cannot be captured manually.\n" \ + "Use 'deploio pg backups list #{@name}' to see the available backups." + end + + def backups + @backups ||= begin + entries = rclone.list(bucket_name) + entries = entries.select { |e| own_backup?(e["Name"].to_s) } + entries.sort_by { |e| e["ModTime"].to_s }.reverse + end + end + + def download(destination:, db_name: nil) + backup = backups.first + unless backup + raise Deploio::Error, "No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'." + end + + rclone.download(bucket_name, backup["Name"], destination) + backup + end + + private + + def own_backup?(object_name) + return true if instance_name.empty? + + object_name.start_with?("PostgresDatabase-#{instance_name}-") + end + + def instance_name + @instance_name ||= @data.dig("status", "atProvider", "name").to_s + end + + def bucket_name + bucket.dig("metadata", "name") + end + + # The PostgresDatabase resource holds no reference to its backup bucket + # Changes with service connections (I assume), but we're not there yet as we still have projects with the old setup. + # Therefore, we match by name + def bucket + @bucket ||= begin + candidates = @nctl.get_services_by_type("bucket", project: @db_ref.project_name).select do |bucket| + backup_bucket_for_database?(bucket) + end + + if candidates.empty? + raise Deploio::Error, + "No backup bucket found for '#{@db_ref.full_name}'. " \ + "Check that backups are enabled for this database (spec.forProvider.backupSchedule)." + end + + candidates.first + end + end + + def backup_bucket_for_database?(bucket) + metadata = bucket["metadata"] || {} + labels = metadata["labels"] || {} + return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL + + metadata["name"].to_s.match?(/\Apostgresdatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/) + end + + def rclone + @rclone ||= @rclone_client_factory.call + end + + def build_rclone_client + endpoint = bucket.dig("status", "atProvider", "endpoint") + if endpoint.nil? || endpoint.to_s.empty? + raise Deploio::Error, "Backup bucket '#{bucket_name}' has no endpoint; cannot access backups." + end + + client = RcloneClient.new( + endpoint: "https://#{endpoint}", + access_key: @nctl.get_bucket_user_access_key(bucket_name, project: @db_ref.project_name), + secret_key: @nctl.get_bucket_user_secret_key(bucket_name, project: @db_ref.project_name), + dry_run: @nctl.dry_run + ) + client.check_requirements unless @nctl.dry_run + client + end + end +end diff --git a/lib/deploio/rclone_client.rb b/lib/deploio/rclone_client.rb new file mode 100644 index 0000000..d49c635 --- /dev/null +++ b/lib/deploio/rclone_client.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "json" +require "open3" + +module Deploio + class RcloneClient + REMOTE = "DEPLOIO" + + attr_reader :dry_run + + def initialize(endpoint:, access_key:, secret_key:, dry_run: false) + @endpoint = endpoint + @access_key = access_key + @secret_key = secret_key + @dry_run = dry_run + end + + def check_requirements + check_rclone_installed + end + + def list(bucket) + output = capture("lsjson", remote_path(bucket)) + return [] if output.nil? || output.empty? + + data = JSON.parse(output) + data.is_a?(Array) ? data : [] + rescue JSON::ParserError + [] + end + + def download(bucket, object, destination) + run("copyto", remote_path(bucket, object), destination, "--progress", "--stats-one-line") + end + + private + + # --s3-no-check-bucket skips the HeadBucket call, which the read-only bucket + # user is not permitted to make. + # See also https://docs.nine.ch/docs/object-storage/object-storage-client-tools#rclone + def build_command(args) + ["rclone", *args.map(&:to_s), "--s3-no-check-bucket"] + end + + def remote_path(bucket, object = nil) + object ? "#{REMOTE}:#{bucket}/#{object}" : "#{REMOTE}:#{bucket}" + end + + def env + { + "RCLONE_CONFIG_#{REMOTE}_TYPE" => "s3", + "RCLONE_CONFIG_#{REMOTE}_PROVIDER" => "Other", + "RCLONE_CONFIG_#{REMOTE}_ENDPOINT" => @endpoint, + "RCLONE_CONFIG_#{REMOTE}_ACCESS_KEY_ID" => @access_key, + "RCLONE_CONFIG_#{REMOTE}_SECRET_ACCESS_KEY" => @secret_key + } + end + + def capture(*args) + cmd = build_command(args) + if dry_run + Output.command(cmd.join(" ")) + return "" + end + + puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"] + stdout, stderr, status = Open3.capture3(env, *cmd) + unless status.success? + raise Deploio::RcloneError, "rclone command failed: #{stderr}" + end + + stdout + end + + def run(*args) + cmd = build_command(args) + Output.command(cmd.join(" ")) + return true if dry_run + + unless system(env, *cmd) + raise Deploio::RcloneError, "rclone command failed: #{cmd.join(" ")}" + end + + true + end + + def check_rclone_installed + _stdout, _stderr, status = Open3.capture3("rclone", "version") + return if status.success? + + raise Deploio::RcloneError, + "rclone not found. Please install it: brew install rclone" + rescue Errno::ENOENT + raise Deploio::RcloneError, + "rclone not found. Please install it: brew install rclone" + end + end +end diff --git a/lib/deploio/shared_options.rb b/lib/deploio/shared_options.rb index 8d23821..6422018 100644 --- a/lib/deploio/shared_options.rb +++ b/lib/deploio/shared_options.rb @@ -20,10 +20,34 @@ def self.included(base) def merged_options @merged_options ||= options .to_h - .merge(parent_options.to_h) { |_key, sub, par| par.nil? ? sub : par } + .merge(parent_options.to_h) { |_key, sub, par| merge_option_value(sub, par) } .transform_keys(&:to_sym) end + # Boolean flags default to false rather than nil, so a parent that simply + # didn't get the flag is indistinguishable from one that had it disabled. + # Treating them as "set anywhere wins" keeps flags like --dry-run working + # when they are passed to a nested subcommand (e.g. `pg backups download`). + def merge_option_value(sub, parent) + return sub if parent.nil? + return sub || parent if [true, false].include?(sub) || [true, false].include?(parent) + + parent + end + + # Rebuilds the shared class options as CLI arguments so they survive being + # handed to another Thor class. Thor's generated subcommand dispatch passes + # the parent's option *values* along, which drops flags once subcommands are + # nested two levels deep (e.g. `deploio pg backups download`). + def forwarded_option_args + args = [] + args << "--dry-run" if merged_options[:dry_run] + args << "--no-color" if merged_options[:no_color] + args << "--app" << merged_options[:app] if merged_options[:app] + args << "--org" << merged_options[:org] if merged_options[:org] + args + end + def setup_options Output.color_enabled = !merged_options[:no_color] && $stdout.tty? @nctl = NctlClient.new(dry_run: merged_options[:dry_run]) diff --git a/test/deploio/cli_postgresql_test.rb b/test/deploio/cli_postgresql_test.rb index eb07f31..74e68c3 100644 --- a/test/deploio/cli_postgresql_test.rb +++ b/test/deploio/cli_postgresql_test.rb @@ -33,61 +33,81 @@ def test_pg_info_raises_error_when_database_not_found_in_dry_run assert_match(/Database not found/, err) end - def test_pg_backups_capture_in_dry_run - # Mock the scenario where we have a database available - mock_client = MockNctlClient.new( - pg_databases: [{ - "kind" => "Postgres", - "metadata" => {"namespace" => "myorg-myproject", "name" => "maindb"}, - "spec" => {"forProvider" => {"version" => "15"}}, - "status" => {"atProvider" => {"fqdn" => "db.example.com"}} - }], - current_org: "myorg" - ) + DEDICATED_DB = { + "kind" => "Postgres", + "metadata" => {"namespace" => "myorg-myproject", "name" => "maindb"}, + "spec" => {"forProvider" => {"version" => "15"}}, + "status" => { + "atProvider" => {"fqdn" => "db.example.com", "databases" => {"maindb" => {}}} + } + }.freeze + + ECONOMY_DB = { + "kind" => "PostgresDatabase", + "metadata" => {"namespace" => "myorg-myproject", "name" => "shareddb"}, + "spec" => {"forProvider" => {"version" => "17"}}, + "status" => {"atProvider" => {"name" => "1c62958_53f1258"}} + }.freeze + + # setup_options builds its own NctlClient, so swap the constructor out to run + # the real command against a mock. + def run_backups_command(args, mock_client, expect_exit: false) + capture_io do + Deploio::NctlClient.stub(:new, mock_client) do + if expect_exit + assert_raises(SystemExit) { Deploio::Commands::PostgreSQLBackups.start(args) } + else + Deploio::Commands::PostgreSQLBackups.start(args) + end + end + end + end - out, = capture_io do - resolver = Deploio::PgDatabaseResolver.new(nctl_client: mock_client) - _db_ref = resolver.resolve(database_name: "myproject-maindb") + def test_pg_backups_capture_runs_the_backup_script_for_a_dedicated_instance + mock_client = MockNctlClient.new(pg_databases: [DEDICATED_DB], current_org: "myorg") - # Simulate the capture command - fqdn = "db.example.com" - cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"] - puts "> #{cmd.join(" ")}" - end + out, = run_backups_command(["capture", "myproject-maindb"], mock_client) - assert_match(/ssh dbadmin@db.example.com sudo nine-postgresql-backup/, out) + assert_match(/ssh dbadmin@db\.example\.com sudo nine-postgresql-backup/, out) end - def test_pg_backups_download_in_dry_run - # Mock the scenario where we have a database available - mock_client = MockNctlClient.new( - pg_databases: [{ - "kind" => "Postgres", - "metadata" => {"namespace" => "myorg-myproject", "name" => "maindb"}, - "spec" => {"forProvider" => {"version" => "15"}}, - "status" => { - "atProvider" => { - "fqdn" => "db.example.com", - "databases" => {"maindb" => {}} - } - } - }], - current_org: "myorg" + def test_pg_backups_download_rsyncs_from_a_dedicated_instance + mock_client = MockNctlClient.new(pg_databases: [DEDICATED_DB], current_org: "myorg") + + out, = run_backups_command(["download", "myproject-maindb"], mock_client) + + assert_match( + %r{rsync -av dbadmin@db\.example\.com:~/backup/postgresql/latest/customer/maindb/maindb\.zst \./myproject-maindb-latest-backup\.zst}, + out ) + end - out, = capture_io do - resolver = Deploio::PgDatabaseResolver.new(nctl_client: mock_client) - _db_ref = resolver.resolve(database_name: "myproject-maindb") - - # Simulate the download command - fqdn = "db.example.com" - db_name = "maindb" - destination = "./myproject-maindb-latest-backup.zst" - cmd = ["rsync", "-avz", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{db_name}/#{db_name}.zst", destination] - puts "> #{cmd.join(" ")}" - end + def test_pg_backups_download_honours_the_output_option + mock_client = MockNctlClient.new(pg_databases: [DEDICATED_DB], current_org: "myorg") - assert_match(/rsync -avz dbadmin@db.example.com:~\/backup\/postgresql\/latest\/customer\/maindb\/maindb.zst/, out) + out, = run_backups_command(["download", "myproject-maindb", "--output", "/tmp/mine.zst"], mock_client) + + assert_match(%r{maindb\.zst /tmp/mine\.zst}, out) + end + + # What the tier classes refuse is their own business (and tested there); the + # CLI's job is to report the refusal and exit non-zero instead of crashing. + def test_pg_backups_capture_is_rejected_for_an_economy_database + mock_client = MockNctlClient.new(pg_databases: [ECONOMY_DB], current_org: "myorg") + + out, err = run_backups_command(["capture", "myproject-shareddb"], mock_client, expect_exit: true) + + assert_empty out, "nothing should have been attempted" + refute_empty err, "the reason should be reported on stderr" + end + + def test_pg_backups_list_is_rejected_for_a_dedicated_instance + mock_client = MockNctlClient.new(pg_databases: [DEDICATED_DB], current_org: "myorg") + + out, err = run_backups_command(["list", "myproject-maindb"], mock_client, expect_exit: true) + + assert_empty out, "nothing should have been attempted" + refute_empty err, "the reason should be reported on stderr" end class MockNctlClient @@ -99,6 +119,8 @@ def initialize(pg_databases: [], current_org: nil, dry_run: true) @dry_run = dry_run end + def check_requirements = nil + def get_all_pg_databases @pg_databases end diff --git a/test/deploio/postgres_backup_service_test.rb b/test/deploio/postgres_backup_service_test.rb new file mode 100644 index 0000000..35d851e --- /dev/null +++ b/test/deploio/postgres_backup_service_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "test_helper" + +class PostgresBackupServiceTest < Minitest::Test + def data(databases: {"maindb" => {}}, fqdn: "db.example.com") + { + "kind" => "Postgres", + "status" => {"atProvider" => {"fqdn" => fqdn, "databases" => databases}} + } + end + + def service(**kwargs) + Deploio::PostgresBackupService.new(data: data(**kwargs), name: "myproject-maindb", dry_run: true) + end + + def test_listing_backups_is_unsupported + assert_raises(Deploio::UnsupportedBackupOperationError) { service.backups } + end + + def test_default_destination_is_named_after_the_database + assert_equal "./myproject-maindb-latest-backup.zst", service.default_destination + end + + def test_capture_runs_the_nine_backup_script_over_ssh + out, = capture_io { service.capture } + + assert_match(/ssh dbadmin@db\.example\.com sudo nine-postgresql-backup/, out) + end + + def test_download_rsyncs_the_latest_backup + out, = capture_io { service.download(destination: "./out.zst") } + + assert_match( + %r{rsync -av dbadmin@db\.example\.com:~/backup/postgresql/latest/customer/maindb/maindb\.zst \./out\.zst}, + out + ) + end + + def test_download_uses_the_only_database_when_db_name_is_omitted + out, = capture_io { service(databases: {"solo" => {}}).download(destination: "./out.zst") } + + assert_match(%r{customer/solo/solo\.zst}, out) + end + + def test_download_uses_the_requested_database_when_there_are_several + out, = capture_io do + service(databases: {"one" => {}, "two" => {}}).download(destination: "./out.zst", db_name: "two") + end + + assert_match(%r{customer/two/two\.zst}, out) + end + + def test_download_raises_when_several_databases_and_none_requested + error = assert_raises(Deploio::Error) do + service(databases: {"one" => {}, "two" => {}}).download(destination: "./out.zst") + end + + assert_match(/Multiple databases found/, error.message) + assert_match(/one, two/, error.message) + end + + def test_download_raises_when_the_instance_has_no_databases + error = assert_raises(Deploio::Error) do + service(databases: {"" => {}}).download(destination: "./out.zst") + end + + assert_match(/No databases found/, error.message) + end + + def test_raises_when_the_fqdn_is_missing + error = assert_raises(Deploio::Error) { service(fqdn: "").capture } + + assert_match(/FQDN not found/, error.message) + end +end diff --git a/test/deploio/postgres_database_backup_service_test.rb b/test/deploio/postgres_database_backup_service_test.rb new file mode 100644 index 0000000..03feb1c --- /dev/null +++ b/test/deploio/postgres_database_backup_service_test.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "test_helper" + +class PostgresDatabaseBackupServiceTest < Minitest::Test + PROJECT = "renuo-chess-tracker" + INSTANCE_NAME = "1c62958_53f1258" + + def backup_bucket(name, endpoint: "cz42.objects.nineapis.ch") + { + "metadata" => { + "name" => name, + "namespace" => PROJECT, + "labels" => {"nine.ch/controllerKind" => "DatabaseBackupSchedule"} + }, + "status" => {"atProvider" => {"endpoint" => endpoint}} + } + end + + def plain_bucket(name) + { + "metadata" => {"name" => name, "namespace" => PROJECT, "labels" => {}}, + "status" => {"atProvider" => {"endpoint" => "es34.objects.nineapis.ch"}} + } + end + + def object(name, mod_time, size: 21_986) + {"Name" => name, "Size" => size, "ModTime" => mod_time} + end + + def db_ref(database_name = "main") + Deploio::PgDatabaseRef.new( + "#{PROJECT}-#{database_name}", + available_databases: { + "#{PROJECT}-#{database_name}" => {project_name: PROJECT, database_name: database_name} + } + ) + end + + def database_data(instance_name = INSTANCE_NAME) + { + "kind" => "PostgresDatabase", + "metadata" => {"namespace" => PROJECT, "name" => "main"}, + "status" => {"atProvider" => {"name" => instance_name}} + } + end + + def build_service(buckets:, objects: [], database_name: "main", data: database_data) + rclone = FakeRcloneClient.new(objects) + service = Deploio::PostgresDatabaseBackupService.new( + db_ref: db_ref(database_name), + data: data, + nctl_client: MockNctlClient.new(buckets: buckets), + name: "chess-tracker-#{database_name}", + rclone_client_factory: -> { rclone } + ) + [service, rclone] + end + + def test_capturing_a_backup_is_unsupported + service, rclone = build_service(buckets: []) + + assert_raises(Deploio::UnsupportedBackupOperationError) { service.capture } + assert_empty rclone.listed + end + + def test_default_destination_is_named_after_the_database + service, = build_service(buckets: []) + + assert_equal "./chess-tracker-main-latest-backup.sql.zst", service.default_destination + end + + def test_finds_the_backup_bucket_named_after_the_database + service, rclone = build_service( + buckets: [ + plain_bucket("chess-tracker-main"), + backup_bucket("postgresdatabase-main-cffe5c3") + ], + objects: [object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-30-0224.sql.zst", "2026-07-30T02:24:22Z")] + ) + + assert_equal 1, service.backups.size + assert_equal ["postgresdatabase-main-cffe5c3"], rclone.listed + end + + def test_ignores_buckets_not_owned_by_a_backup_schedule + service, = build_service(buckets: [plain_bucket("postgresdatabase-main-cffe5c3")]) + + error = assert_raises(Deploio::Error) { service.backups } + assert_match(/No backup bucket found/, error.message) + end + + def test_does_not_match_the_bucket_of_a_similarly_named_database + # Database "labels" must not pick up the bucket belonging to "labels-main". + service, = build_service( + buckets: [backup_bucket("postgresdatabase-labels-main-be40f63")], + database_name: "labels" + ) + + assert_raises(Deploio::Error) { service.backups } + end + + def test_matches_a_database_whose_name_contains_a_hyphen + service, rclone = build_service( + buckets: [backup_bucket("postgresdatabase-labels-main-be40f63")], + database_name: "labels-main" + ) + + assert_empty service.backups + assert_equal ["postgresdatabase-labels-main-be40f63"], rclone.listed + end + + def test_raises_when_no_bucket_exists_for_the_project + service, = build_service(buckets: []) + + error = assert_raises(Deploio::Error) { service.backups } + assert_match(/backupSchedule/, error.message) + end + + def test_keeps_only_objects_belonging_to_this_database + service, = build_service( + buckets: [backup_bucket("postgresdatabase-main-cffe5c3")], + objects: [ + object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-29-0224.sql.zst", "2026-07-29T02:24:45Z"), + object("PostgresDatabase-other_instance-2026-07-30-0224.sql.zst", "2026-07-30T02:24:22Z"), + object("some-unrelated-file.txt", "2026-07-30T03:00:00Z") + ] + ) + + assert_equal ["PostgresDatabase-#{INSTANCE_NAME}-2026-07-29-0224.sql.zst"], service.backups.map { |b| b["Name"] } + end + + def test_orders_backups_newest_first + service, = build_service( + buckets: [backup_bucket("postgresdatabase-main-cffe5c3")], + objects: [ + object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-28-0227.sql.zst", "2026-07-28T02:27:37Z"), + object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-30-0224.sql.zst", "2026-07-30T02:24:22Z"), + object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-29-0224.sql.zst", "2026-07-29T02:24:45Z") + ] + ) + + assert_equal( + %w[ + 2026-07-30T02:24:22Z + 2026-07-29T02:24:45Z + 2026-07-28T02:27:37Z + ], + service.backups.map { |b| b["ModTime"] } + ) + end + + def test_download_fetches_the_latest_backup + service, rclone = build_service( + buckets: [backup_bucket("postgresdatabase-main-cffe5c3")], + objects: [ + object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-29-0224.sql.zst", "2026-07-29T02:24:45Z"), + object("PostgresDatabase-#{INSTANCE_NAME}-2026-07-30-0224.sql.zst", "2026-07-30T02:24:22Z") + ] + ) + + backup = service.download(destination: "./out.sql.zst") + + assert_equal "PostgresDatabase-#{INSTANCE_NAME}-2026-07-30-0224.sql.zst", backup["Name"] + assert_equal [[ + "postgresdatabase-main-cffe5c3", + "PostgresDatabase-#{INSTANCE_NAME}-2026-07-30-0224.sql.zst", + "./out.sql.zst" + ]], rclone.downloaded + end + + def test_download_raises_when_the_bucket_holds_no_backups + service, rclone = build_service(buckets: [backup_bucket("postgresdatabase-main-cffe5c3")]) + + error = assert_raises(Deploio::Error) { service.download(destination: "./out.sql.zst") } + assert_match(/No backups found/, error.message) + assert_empty rclone.downloaded + end + + class FakeRcloneClient + attr_reader :listed, :downloaded + + def initialize(objects = []) + @objects = objects + @listed = [] + @downloaded = [] + end + + def list(bucket) + @listed << bucket + @objects + end + + def download(bucket, object, destination) + @downloaded << [bucket, object, destination] + true + end + end + + class MockNctlClient + attr_reader :dry_run + + def initialize(buckets: [], dry_run: false) + @buckets = buckets + @dry_run = dry_run + end + + def get_services_by_type(type, project:) + raise ArgumentError, "unexpected type #{type}" unless type == "bucket" + raise ArgumentError, "unexpected project #{project}" unless project == PROJECT + + @buckets + end + + def get_bucket_user_access_key(_name, project:) = "access-key" + + def get_bucket_user_secret_key(_name, project:) = "secret-key" + end +end