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
3 changes: 3 additions & 0 deletions lib/deploio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
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/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
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
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 @@ -72,10 +75,23 @@ def subcommands
commands = klass.commands.except("help").map do |cmd_name, cmd|
[cmd_name, cmd.description, cmd.options]
end
[name, commands, klass.class_options]
[name, commands, klass.class_options, 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
37 changes: 37 additions & 0 deletions lib/deploio/nctl_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,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?) &&
(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_all_builds
output = capture("get", "builds", "-A", "-o", "json")
return [] if output.nil? || output.empty?
Expand Down Expand Up @@ -266,6 +302,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
66 changes: 66 additions & 0 deletions lib/deploio/pg_database_ref.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

require "did_you_mean"

module Deploio
class PgDatabaseRef
attr_reader :project_name, :database_name

# The input is given in the format "<project>-<database>"
def initialize(input, available_databases: {})
@input = input.to_s
parse_from_available_databases(available_databases)
end

def full_name
"#{project_name}-#{database_name}"
end

def to_s
full_name
end

def ==(other)
return false unless other.is_a?(PgDatabaseRef)

project_name == other.project_name && database_name == other.database_name
end

private

def parse_from_available_databases(available_databases)
if available_databases.key?(@input)
match = available_databases[@input]
@project_name = match[:project_name]
@database_name = match[:database_name]
return
end

# If available_databases provided but no match, raise error with suggestions
raise_not_found_error(@input, available_databases.keys) unless available_databases.empty?

raise_not_found_error(@input, [])
end

def raise_not_found_error(input, available_database_names)
message = "Database not found: '#{input}'"

suggestions = suggest_similar(input, available_database_names)
unless suggestions.empty?
message += "\n\nDid you mean?"
suggestions.each { |s| message += "\n #{s}" }
end

message += "\n\nRun 'deploio pg' to see available Postgres databases."

raise Deploio::PgDatabaseNotFoundError, message
end

def suggest_similar(input, dictionary)
return [] if dictionary.empty?

spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
spell_checker.correct(input)
end
end
end
Loading