Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/deploio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
9 changes: 1 addition & 8 deletions lib/deploio/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines -103 to -110

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to shared_options

alias_method :build_option_args, :forwarded_option_args
end
end
9 changes: 9 additions & 0 deletions lib/deploio/commands/postgresql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: "-")
Expand Down
105 changes: 63 additions & 42 deletions lib/deploio/commands/postgresql_backups.rb
Original file line number Diff line number Diff line change
@@ -1,74 +1,95 @@
require "time"

module Deploio
module Commands
class PostgreSQLBackups < Thor
include SharedOptions

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
Comment on lines +22 to 36

@lukasbischof lukasbischof Jul 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sneaky new feature: Implemented a list command for economy dbs as it's fairly cheap (we have to list the backups anyway).
Implementing the list for dedicated dbs and actually letting the user chose the backup they want to download is up for a new PR.


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)
Comment on lines +67 to +70

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted the logic that previously was inlined here into two services, one for managing dedicated dbs and one for the shared dbs

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
Expand Down
1 change: 1 addition & 0 deletions lib/deploio/completion_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/deploio/nctl_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
68 changes: 68 additions & 0 deletions lib/deploio/postgres_backup_service.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading