Skip to content
Merged
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
3 changes: 3 additions & 0 deletions lib/deploio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
require_relative "deploio/utils"
require_relative "deploio/output"
require_relative "deploio/app_ref"
require_relative "deploio/pg_database_ref"
require_relative "deploio/nctl_client"
require_relative "deploio/app_resolver"
require_relative "deploio/pg_database_resolver"
require_relative "deploio/price_fetcher"
require_relative "deploio/shared_options"
require_relative "deploio/cli"

module Deploio
class Error < StandardError; end
class AppNotFoundError < Error; end
class PgDatabaseNotFoundError < Error; end
class NctlError < Error; end
end
5 changes: 5 additions & 0 deletions lib/deploio/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
require_relative "commands/orgs"
require_relative "commands/projects"
require_relative "commands/services"
require_relative "commands/postgresql_backups"
require_relative "commands/postgresql"
require_relative "completion_generator"

module Deploio
Expand Down Expand Up @@ -52,6 +54,9 @@ def completion
desc "builds COMMAND", "Build management commands"
subcommand "builds", Commands::Builds

desc "pg COMMAND", "PostgreSQL database management commands"
subcommand "pg", Commands::PostgreSQL

# Shortcut for auth:login
desc "login", "Authenticate with nctl (alias for auth:login)"
def login
Expand Down
131 changes: 131 additions & 0 deletions lib/deploio/commands/postgresql.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
module Deploio
module Commands
class PostgreSQL < Thor
include SharedOptions

namespace "pg"

class_option :json, type: :boolean, default: false, desc: "Output as JSON"

default_task :list

desc "list", "List all PostgreSQL databases"
def list
setup_options
raw_dbs = @nctl.get_all_pg_databases

if options[:json]
puts JSON.pretty_generate(raw_dbs)
return
end

if raw_dbs.empty?
Output.warning("No PostgreSQL databases found") unless merged_options[:dry_run]
return
end

resolver = PgDatabaseResolver.new(nctl_client: @nctl)

rows = raw_dbs.map do |pg|
kind = pg["kind"] || ""
metadata = pg["metadata"] || {}
spec = pg["spec"] || {}
for_provider = spec["forProvider"] || {}
version = for_provider["version"]
namespace = metadata["namespace"] || ""
name = metadata["name"] || ""

[
resolver.short_name_for(namespace, name),
project_from_namespace(namespace, resolver.current_org),
presence(kind, default: "-"),
presence(version, default: "?")
]
end

Output.table(rows, headers: ["NAME", "PROJECT", "KIND", "VERSION"])
end

desc "info NAME", "Show PostgreSQL database details"
def info(name)
setup_options
resolver = PgDatabaseResolver.new(nctl_client: @nctl)
db_ref = resolver.resolve(database_name: name)
data = @nctl.get_pg_database(db_ref)

if options[:json]
puts JSON.pretty_generate(data)
return
end

kind = data["kind"]
metadata = data["metadata"] || {}
spec = data["spec"] || {}
for_provider = spec["forProvider"] || {}
status = data["status"] || {}
at_provider = status["atProvider"] || {}

Output.header("PostgreSQL Database: #{db_ref.full_name}")
puts

Output.header("General")
Output.table([
["Name", presence(metadata["name"])],
["Project", presence(metadata["namespace"])],
["Kind", presence(kind, default: "-")],
["Version", presence(for_provider["version"], default: "?")],
["FQDN", presence(at_provider["fqdn"])],
["Size", presence(at_provider["size"], default: "-")]
])

puts

Output.header("Status")
conditions = status["conditions"] || []
ready_condition = conditions.find { |c| c["type"] == "Ready" }
synced_condition = conditions.find { |c| c["type"] == "Synced" }

Output.table([
["Ready", presence(ready_condition&.dig("status"))],
["Synced", presence(synced_condition&.dig("status"))]
])

if for_provider["allowedCIDRs"].is_a?(Array)
puts

Output.header("Access")
Output.table([
["Allowed CIDRs", for_provider["allowedCIDRs"].join(", ")]
])

puts

Output.header("SSH Keys")
for_provider["sshKeys"].each do |key|
puts "- #{key}"
end
end
rescue Deploio::Error => e
Output.error(e.message)
exit 1
end

desc "backups COMMAND", "Manage PostgreSQL database backups"
subcommand "backups", Commands::PostgreSQLBackups

private

def presence(value, default: "-")
(value.nil? || value.to_s.empty?) ? default : value
end

def project_from_namespace(namespace, current_org)
if current_org && namespace.start_with?("#{current_org}-")
namespace.delete_prefix("#{current_org}-")
else
namespace
end
end
end
end
end
75 changes: 75 additions & 0 deletions lib/deploio/commands/postgresql_backups.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
module Deploio
module Commands
class PostgreSQLBackups < Thor
include SharedOptions

namespace "pg:backups"

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"] || ""

unless kind == "Postgres" || @nctl.dry_run
Output.error("Backups can only be captured for PostgreSQL databases. (shared dbs are not supported)")
exit 1
end

fqdn = data.dig("status", "atProvider", "fqdn")
if fqdn.nil? || fqdn.empty?
Output.error("Database FQDN not found; cannot capture backup.")
exit 1
end

cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"]
Output.command(cmd.join(" "))
system(*cmd) unless @nctl.dry_run
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"

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)")
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.

Not supported yet afaik

exit 1
end

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
end

db_name = options[:db_name] || databases.first

fqdn = data.dig("status", "atProvider", "fqdn")
if fqdn.nil? || fqdn.empty?
Output.error("Database FQDN not found; cannot download backup.")
exit 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
end
end
end
end
4 changes: 2 additions & 2 deletions lib/deploio/commands/projects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def display_projects_with_breakdown(raw_projects, current_org)
price = price_fetcher.price_for_app(app) || 0
project_total += price

size_info = replicas.to_i > 1 ? "#{size} ×#{replicas}" : size
size_info = (replicas.to_i > 1) ? "#{size} ×#{replicas}" : size
rows << [project_label, app_name, "app", size_info, format_price(price)]
end

Expand Down Expand Up @@ -155,7 +155,7 @@ def service_size_info(type, spec)
end

def format_price(price)
price > 0 ? "CHF #{price}/mo" : "-"
(price > 0) ? "CHF #{price}/mo" : "-"
end

private
Expand Down
20 changes: 18 additions & 2 deletions lib/deploio/completion_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ def default_option_completers

def default_positional_completers
{
"orgs:set" => "'1:organization:_#{program_name}_orgs_list'"
"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:download" => "'1:database:_#{program_name}_pg_databases_list'"
}
end

Expand All @@ -73,10 +76,23 @@ def subcommands
[cmd_name, cmd.description, cmd.options]
end
default_task = klass.default_command if klass.respond_to?(:default_command)
[name, commands, klass.class_options, default_task]
[name, commands, klass.class_options, default_task, klass]
end
end

def nested_subcommands
result = []
cli_class.subcommand_classes.each do |parent_name, parent_klass|
parent_klass.subcommand_classes.each do |nested_name, nested_klass|
commands = nested_klass.commands.except("help").map do |cmd_name, cmd|
[cmd_name, cmd.description, cmd.options]
end
result << ["#{parent_name}:#{nested_name}", commands, nested_klass.class_options]
end
end
result
end

def main_commands
cli_class.commands.except("help").map do |name, cmd|
[name, cmd.description]
Expand Down
39 changes: 38 additions & 1 deletion lib/deploio/nctl_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def build_logs(build_name, app_ref: nil, tail: false, lines: 5000)
if app_ref
args += ["--project", app_ref.project_name, "-a", app_ref.app_name]
end
exec_passthrough("logs", "build", *([build_name].compact), *args)
exec_passthrough("logs", "build", *[build_name].compact, *args)
end

def exec_command(app_ref, command)
Expand Down Expand Up @@ -64,6 +64,42 @@ def get_app(app_ref)
{}
end

def get_all_pg_databases
output_dedicated_dbs = capture("get", "postgres", "-A", "-o", "json")
output_shared_dbs = capture("get", "postgresdatabase", "-A", "-o", "json")
if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
if output_dedicated_dbs.empty? &&

if it's nil, .empty? returns true already, no?

(output_shared_dbs.nil? || output_shared_dbs.empty?)
return []
end

[
*JSON.parse(output_dedicated_dbs),
*JSON.parse(output_shared_dbs)
]
rescue JSON::ParserError
[]
end

def get_pg_database(db_ref)
output = begin
capture("get", "postgres", db_ref.database_name,
"--project", db_ref.project_name, "-o", "json")
rescue Deploio::NctlError
nil
end

if output.nil? || output.empty?
output = capture("get", "postgresdatabase", db_ref.database_name,
"--project", db_ref.project_name, "-o", "json")
end

return nil if output.nil? || output.empty?

JSON.parse(output)
rescue JSON::ParserError
nil
end

def get_apps_by_project(project)
output = capture("get", "apps", "--project", project, "-o", "json")
return [] if output.nil? || output.empty?
Expand Down Expand Up @@ -287,6 +323,7 @@ def capture(*args)
Output.command(cmd.join(" "))
""
else
puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"]
stdout, stderr, status = Open3.capture3(*cmd)
unless status.success?
raise Deploio::NctlError, "nctl command failed: #{stderr}"
Expand Down
Loading
Loading