From 036de4d3570f740da565aa87323db9d769ab81ea Mon Sep 17 00:00:00 2001 From: Lukas Bischof Date: Wed, 4 Feb 2026 14:37:17 +0100 Subject: [PATCH 1/7] Listing of Postgres databases # Conflicts: # lib/deploio/nctl_client.rb --- lib/deploio/cli.rb | 4 ++ lib/deploio/commands/postgresql.rb | 64 ++++++++++++++++++++++++++++++ lib/deploio/nctl_client.rb | 17 ++++++++ 3 files changed, 85 insertions(+) create mode 100644 lib/deploio/commands/postgresql.rb diff --git a/lib/deploio/cli.rb b/lib/deploio/cli.rb index d7c47a7..85e740f 100644 --- a/lib/deploio/cli.rb +++ b/lib/deploio/cli.rb @@ -7,6 +7,7 @@ require_relative "commands/orgs" require_relative "commands/projects" require_relative "commands/services" +require_relative "commands/postgresql" require_relative "completion_generator" module Deploio @@ -52,6 +53,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 diff --git a/lib/deploio/commands/postgresql.rb b/lib/deploio/commands/postgresql.rb new file mode 100644 index 0000000..a81f0b2 --- /dev/null +++ b/lib/deploio/commands/postgresql.rb @@ -0,0 +1,64 @@ +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 = AppResolver.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 + + 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 diff --git a/lib/deploio/nctl_client.rb b/lib/deploio/nctl_client.rb index 3d9ea1e..6e7f577 100644 --- a/lib/deploio/nctl_client.rb +++ b/lib/deploio/nctl_client.rb @@ -64,6 +64,22 @@ 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_apps_by_project(project) output = capture("get", "apps", "--project", project, "-o", "json") return [] if output.nil? || output.empty? @@ -74,6 +90,7 @@ def get_apps_by_project(project) [] end + def get_all_builds output = capture("get", "builds", "-A", "-o", "json") return [] if output.nil? || output.empty? From e9aa83a02228332f2c493b8c78d742c6850d8441 Mon Sep 17 00:00:00 2001 From: Lukas Bischof Date: Wed, 4 Feb 2026 15:28:08 +0100 Subject: [PATCH 2/7] Info command --- lib/deploio.rb | 3 ++ lib/deploio/commands/postgresql.rb | 66 ++++++++++++++++++++++++++++- lib/deploio/nctl_client.rb | 21 +++++++++ lib/deploio/pg_database_ref.rb | 66 +++++++++++++++++++++++++++++ lib/deploio/pg_database_resolver.rb | 55 ++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 lib/deploio/pg_database_ref.rb create mode 100644 lib/deploio/pg_database_resolver.rb diff --git a/lib/deploio.rb b/lib/deploio.rb index dfaac3e..9db06be 100644 --- a/lib/deploio.rb +++ b/lib/deploio.rb @@ -6,8 +6,10 @@ 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" @@ -15,5 +17,6 @@ module Deploio class Error < StandardError; end class AppNotFoundError < Error; end + class PgDatabaseNotFoundError < Error; end class NctlError < Error; end end diff --git a/lib/deploio/commands/postgresql.rb b/lib/deploio/commands/postgresql.rb index a81f0b2..69ce378 100644 --- a/lib/deploio/commands/postgresql.rb +++ b/lib/deploio/commands/postgresql.rb @@ -24,7 +24,7 @@ def list return end - resolver = AppResolver.new(nctl_client: @nctl) + resolver = PgDatabaseResolver.new(nctl_client: @nctl) rows = raw_dbs.map do |pg| kind = pg["kind"] || "" @@ -46,6 +46,70 @@ def list 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 + private def presence(value, default: "-") diff --git a/lib/deploio/nctl_client.rb b/lib/deploio/nctl_client.rb index 6e7f577..202cff0 100644 --- a/lib/deploio/nctl_client.rb +++ b/lib/deploio/nctl_client.rb @@ -80,6 +80,26 @@ def get_all_pg_databases [] 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? @@ -304,6 +324,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}" diff --git a/lib/deploio/pg_database_ref.rb b/lib/deploio/pg_database_ref.rb new file mode 100644 index 0000000..df0a4ad --- /dev/null +++ b/lib/deploio/pg_database_ref.rb @@ -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 "-" + 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 diff --git a/lib/deploio/pg_database_resolver.rb b/lib/deploio/pg_database_resolver.rb new file mode 100644 index 0000000..f8b714c --- /dev/null +++ b/lib/deploio/pg_database_resolver.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Deploio + class PgDatabaseResolver + attr_reader :nctl, :current_org + + def initialize(nctl_client:) + @nctl = nctl_client + @current_org = @nctl.current_org + end + + def resolve(database_name: nil) + if database_name + return PgDatabaseRef.new(database_name, available_databases: available_databases_hash) + end + + raise Deploio::Error, "No database specified" + end + + # Returns hash mapping database names -> {project_name:, app_name:} + def available_databases_hash + @available_apps_hash ||= begin + hash = {} + current_org = @nctl.current_org + @nctl.get_all_pg_databases.each do |database| + metadata = database["metadata"] || {} + project_name = metadata["namespace"] || "" + database_name = metadata["name"] + full_name = "#{project_name}-#{database_name}" + hash[full_name] = {project_name: project_name, database_name: database_name} + + # Also index by short name (without org prefix) for convenience + if current_org && project_name.start_with?("#{current_org}-") + project = project_name.delete_prefix("#{current_org}-") + short_name = "#{project}-#{database_name}" + hash[short_name] ||= {project_name: project_name, database_name: database_name} + end + end + hash + end + rescue + {} + end + + def short_name_for(namespace, database_name) + org = current_org + if org && namespace.start_with?("#{org}-") + project = namespace.delete_prefix("#{org}-") + "#{project}-#{database_name}" + else + "#{namespace}-#{database_name}" + end + end + end +end From 45a8851f0c757c812a8847643f310fcd0b1b14e8 Mon Sep 17 00:00:00 2001 From: Lukas Bischof Date: Wed, 4 Feb 2026 16:10:39 +0100 Subject: [PATCH 3/7] Backups capture command --- lib/deploio/cli.rb | 1 + lib/deploio/commands/postgresql.rb | 3 ++ lib/deploio/commands/postgresql_backups.rb | 33 ++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 lib/deploio/commands/postgresql_backups.rb diff --git a/lib/deploio/cli.rb b/lib/deploio/cli.rb index 85e740f..8e28696 100644 --- a/lib/deploio/cli.rb +++ b/lib/deploio/cli.rb @@ -7,6 +7,7 @@ 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" diff --git a/lib/deploio/commands/postgresql.rb b/lib/deploio/commands/postgresql.rb index 69ce378..74d901a 100644 --- a/lib/deploio/commands/postgresql.rb +++ b/lib/deploio/commands/postgresql.rb @@ -110,6 +110,9 @@ def info(name) exit 1 end + desc "backups COMMAND", "Manage PostgreSQL database backups" + subcommand "backups", Commands::PostgreSQLBackups + private def presence(value, default: "-") diff --git a/lib/deploio/commands/postgresql_backups.rb b/lib/deploio/commands/postgresql_backups.rb new file mode 100644 index 0000000..4c5f8c3 --- /dev/null +++ b/lib/deploio/commands/postgresql_backups.rb @@ -0,0 +1,33 @@ +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 + end + end +end From 7fdeecce23ce6bfb669a3b4f929dd46dc60da29b Mon Sep 17 00:00:00 2001 From: Lukas Bischof Date: Wed, 4 Feb 2026 16:31:09 +0100 Subject: [PATCH 4/7] Backup download command --- lib/deploio/commands/postgresql_backups.rb | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/deploio/commands/postgresql_backups.rb b/lib/deploio/commands/postgresql_backups.rb index 4c5f8c3..e0c87a4 100644 --- a/lib/deploio/commands/postgresql_backups.rb +++ b/lib/deploio/commands/postgresql_backups.rb @@ -28,6 +28,49 @@ def capture(name) 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)") + 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? + p databases + 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 From e5f4495d32cafe14b29f8707563902e594ceb222 Mon Sep 17 00:00:00 2001 From: Lukas Bischof Date: Wed, 4 Feb 2026 16:43:36 +0100 Subject: [PATCH 5/7] Completion ^ Conflicts: ^ lib/deploio/completion_generator.rb ^ lib/deploio/templates/completion.zsh.erb --- lib/deploio/completion_generator.rb | 20 ++++++- lib/deploio/templates/completion.zsh.erb | 69 +++++++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/lib/deploio/completion_generator.rb b/lib/deploio/completion_generator.rb index 9edf679..8f44273 100644 --- a/lib/deploio/completion_generator.rb +++ b/lib/deploio/completion_generator.rb @@ -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 @@ -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] diff --git a/lib/deploio/templates/completion.zsh.erb b/lib/deploio/templates/completion.zsh.erb index e8758ef..cc36026 100644 --- a/lib/deploio/templates/completion.zsh.erb +++ b/lib/deploio/templates/completion.zsh.erb @@ -100,7 +100,37 @@ _<%= program_name %>_apps_list() { fi } -<% subcommands.each do |name, commands, class_options, default_task| %> +# Dynamic completion for PostgreSQL databases +_<%= program_name %>_pg_databases_list() { + local -a databases + databases=(${(f)"$(<%= program_name %> pg list --json 2>/dev/null | ruby -rjson -e ' + data = JSON.parse(STDIN.read) rescue [] + orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]" + orgs = JSON.parse(orgs_json) rescue [] + current_org = orgs.find { |o| o["current"] }&.fetch("name", nil) + + data.each do |db| + metadata = db["metadata"] || {} + spec = db["spec"] || {} + for_provider = spec["forProvider"] || {} + ns = metadata["namespace"] || "" + name = metadata["name"] || "" + version = for_provider["version"] || "?" + kind = db["kind"] || "" + project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns + short_name = "#{project}-#{name}" + puts "#{short_name}:#{name} (#{kind}, v#{version})" + end + ' 2>/dev/null)"}) + + if [[ ${#databases[@]} -gt 0 ]]; then + _describe -t databases 'available PostgreSQL databases' databases + else + _message 'database (format: project-dbname)' + fi +} + +<% subcommands.each do |name, commands, class_options, default_task, klass| %> # <%= name %> subcommand _<%= program_name %>_<%= name %>() { local -a <%= name %>_commands @@ -136,8 +166,45 @@ _<%= program_name %>_<%= name %>() { case "$words[1]" in <% commands.each do |cmd_name, _, options| -%> <%= cmd_name %>) +<% if klass.subcommand_classes.key?(cmd_name) -%> + _<%= program_name %>_<%= name %>_<%= cmd_name %> +<% else -%> _arguments -s \ <%= format_options(options, class_options, positional_arg(name, cmd_name)) %> +<% end -%> + ;; +<% end -%> + esac + ;; + esac +} +<% end %> + +<% nested_subcommands.each do |full_name, commands, class_options| %> +<% parent_name, nested_name = full_name.split(':') %> +# <%= full_name %> nested subcommand +_<%= program_name %>_<%= parent_name %>_<%= nested_name %>() { + local -a <%= nested_name %>_commands + <%= nested_name %>_commands=( +<% commands.each do |cmd_name, desc| -%> + '<%= cmd_name %>:<%= escape(desc) %>' +<% end -%> + ) + + _arguments -s \ + '1:<%= nested_name %> command:-><%= nested_name %>_cmd' \ + '*::<%= nested_name %> args:-><%= nested_name %>_args' + + case "$state" in + <%= nested_name %>_cmd) + _describe -t commands '<%= nested_name %> commands' <%= nested_name %>_commands + ;; + <%= nested_name %>_args) + case "$words[1]" in +<% commands.each do |cmd_name, _, options| -%> + <%= cmd_name %>) + _arguments -s \ +<%= format_options(options, class_options, positional_arg(full_name, cmd_name)) %> ;; <% end -%> esac From f332c35f67b67bbae28af02969f38070c57c0258 Mon Sep 17 00:00:00 2001 From: Lukas Bischof Date: Wed, 4 Feb 2026 16:59:20 +0100 Subject: [PATCH 6/7] tests --- lib/deploio/commands/postgresql_backups.rb | 1 - test/deploio/cli_postgresql_test.rb | 116 ++++++++++++++++ test/deploio/nctl_client_test.rb | 45 +++++++ test/deploio/pg_database_ref_test.rb | 83 ++++++++++++ test/deploio/pg_database_resolver_test.rb | 150 +++++++++++++++++++++ 5 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 test/deploio/cli_postgresql_test.rb create mode 100644 test/deploio/pg_database_ref_test.rb create mode 100644 test/deploio/pg_database_resolver_test.rb diff --git a/lib/deploio/commands/postgresql_backups.rb b/lib/deploio/commands/postgresql_backups.rb index e0c87a4..866cd6f 100644 --- a/lib/deploio/commands/postgresql_backups.rb +++ b/lib/deploio/commands/postgresql_backups.rb @@ -52,7 +52,6 @@ def download(name) Output.error("No databases found in PostgreSQL instance; cannot download backup.") exit 1 elsif databases.size > 1 && options[:db_name].nil? - p databases 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.") diff --git a/test/deploio/cli_postgresql_test.rb b/test/deploio/cli_postgresql_test.rb new file mode 100644 index 0000000..eb07f31 --- /dev/null +++ b/test/deploio/cli_postgresql_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "test_helper" +require "stringio" + +class CLIPostgreSQLTest < Minitest::Test + def test_pg_list_executes_correct_nctl_commands + out, = capture_io do + Deploio::Commands::PostgreSQL.start(["list", "--dry-run"]) + end + + # Should query both dedicated and shared databases + assert_match(/nctl get postgres -A -o json/, out) + assert_match(/nctl get postgresdatabase -A -o json/, out) + end + + def test_pg_via_main_cli_executes_correct_nctl_command + out, = capture_io do + Deploio::CLI.start(["pg", "--dry-run"]) + end + + assert_match(/nctl get postgres -A -o json/, out) + end + + def test_pg_info_raises_error_when_database_not_found_in_dry_run + # In dry-run mode, get_all_pg_databases returns empty, so database resolution fails + _out, err = capture_io do + assert_raises(SystemExit) do + Deploio::Commands::PostgreSQL.start(["info", "myproject-db", "--dry-run"]) + end + end + + 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" + ) + + out, = capture_io do + resolver = Deploio::PgDatabaseResolver.new(nctl_client: mock_client) + _db_ref = resolver.resolve(database_name: "myproject-maindb") + + # Simulate the capture command + fqdn = "db.example.com" + cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"] + puts "> #{cmd.join(" ")}" + end + + 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" + ) + + 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 + + assert_match(/rsync -avz dbadmin@db.example.com:~\/backup\/postgresql\/latest\/customer\/maindb\/maindb.zst/, out) + end + + class MockNctlClient + attr_reader :current_org + + def initialize(pg_databases: [], current_org: nil, dry_run: true) + @pg_databases = pg_databases + @current_org = current_org + @dry_run = dry_run + end + + def get_all_pg_databases + @pg_databases + end + + def get_pg_database(db_ref) + @pg_databases.find do |db| + metadata = db["metadata"] || {} + metadata["namespace"] == db_ref.project_name && + metadata["name"] == db_ref.database_name + end + end + + attr_reader :dry_run + end +end diff --git a/test/deploio/nctl_client_test.rb b/test/deploio/nctl_client_test.rb index 21706b0..830ffe5 100644 --- a/test/deploio/nctl_client_test.rb +++ b/test/deploio/nctl_client_test.rb @@ -42,4 +42,49 @@ def test_get_all_apps_returns_empty_in_dry_run result = @client.get_all_apps assert_equal [], result end + + def test_get_all_pg_databases_returns_empty_in_dry_run + result = @client.get_all_pg_databases + assert_equal [], result + end + + def test_get_all_pg_databases_queries_both_types + out, = capture_io do + @client.get_all_pg_databases + end + + # Should query both dedicated (postgres) and shared (postgresdatabase) instances + assert_match(/nctl get postgres -A -o json/, out) + assert_match(/nctl get postgresdatabase -A -o json/, out) + end + + def test_get_pg_database_with_dedicated_instance + available_databases = { + "myproject-maindb" => {project_name: "myproject", database_name: "maindb"} + } + db_ref = Deploio::PgDatabaseRef.new("myproject-maindb", available_databases: available_databases) + + out, = capture_io do + @client.get_pg_database(db_ref) + end + + # Should first try to get dedicated postgres instance + assert_match(/nctl get postgres maindb --project myproject -o json/, out) + end + + def test_get_pg_database_falls_back_to_shared + available_databases = { + "myproject-shareddb" => {project_name: "myproject", database_name: "shareddb"} + } + db_ref = Deploio::PgDatabaseRef.new("myproject-shareddb", available_databases: available_databases) + + out, = capture_io do + @client.get_pg_database(db_ref) + end + + # Should first try postgres, then fall back to postgresdatabase + assert_match(/nctl get postgres shareddb --project myproject -o json/, out) + # Note: In dry-run mode, the fallback to postgresdatabase will also be attempted + # but we can't easily test that here without more complex mocking + end end diff --git a/test/deploio/pg_database_ref_test.rb b/test/deploio/pg_database_ref_test.rb new file mode 100644 index 0000000..9152028 --- /dev/null +++ b/test/deploio/pg_database_ref_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "test_helper" + +class PgDatabaseRefTest < Minitest::Test + def test_parses_full_name_from_available_databases + available = { + "n10518-maindb" => {project_name: "n10518", database_name: "maindb"} + } + db_ref = Deploio::PgDatabaseRef.new("n10518-maindb", available_databases: available) + + assert_equal "n10518", db_ref.project_name + assert_equal "maindb", db_ref.database_name + assert_equal "n10518-maindb", db_ref.full_name + end + + def test_full_name_method + available = { + "myproject-postgres" => {project_name: "myproject", database_name: "postgres"} + } + db_ref = Deploio::PgDatabaseRef.new("myproject-postgres", available_databases: available) + + assert_equal "myproject-postgres", db_ref.full_name + end + + def test_to_s_returns_full_name + available = { + "proj-db" => {project_name: "proj", database_name: "db"} + } + db_ref = Deploio::PgDatabaseRef.new("proj-db", available_databases: available) + + assert_equal "proj-db", db_ref.to_s + end + + def test_equality_comparison + available = { + "proj-db1" => {project_name: "proj", database_name: "db1"}, + "proj-db2" => {project_name: "proj", database_name: "db2"} + } + + db_ref1 = Deploio::PgDatabaseRef.new("proj-db1", available_databases: available) + db_ref1_copy = Deploio::PgDatabaseRef.new("proj-db1", available_databases: available) + db_ref2 = Deploio::PgDatabaseRef.new("proj-db2", available_databases: available) + + assert_equal db_ref1, db_ref1_copy + refute_equal db_ref1, db_ref2 + end + + def test_raises_error_when_database_not_found + available = { + "existing-db" => {project_name: "existing", database_name: "db"} + } + + error = assert_raises(Deploio::PgDatabaseNotFoundError) do + Deploio::PgDatabaseRef.new("nonexistent-db", available_databases: available) + end + + assert_match(/Database not found: 'nonexistent-db'/, error.message) + assert_match(/Run 'deploio pg' to see available Postgres databases/, error.message) + end + + def test_suggests_similar_database_names + available = { + "myproject-maindb" => {project_name: "myproject", database_name: "maindb"}, + "myproject-testdb" => {project_name: "myproject", database_name: "testdb"} + } + + error = assert_raises(Deploio::PgDatabaseNotFoundError) do + Deploio::PgDatabaseRef.new("myproject-maindv", available_databases: available) + end + + assert_match(/Did you mean\?/, error.message) + assert_match(/myproject-maindb/, error.message) + end + + def test_raises_error_when_no_databases_available + error = assert_raises(Deploio::PgDatabaseNotFoundError) do + Deploio::PgDatabaseRef.new("some-db", available_databases: {}) + end + + assert_match(/Database not found/, error.message) + end +end diff --git a/test/deploio/pg_database_resolver_test.rb b/test/deploio/pg_database_resolver_test.rb new file mode 100644 index 0000000..dbcf2aa --- /dev/null +++ b/test/deploio/pg_database_resolver_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "test_helper" + +class PgDatabaseResolverTest < Minitest::Test + def setup + @nctl = MockNctlClient.new(dry_run: true) + end + + def test_raises_error_when_database_not_found_in_dry_run + # In dry_run mode, get_all_pg_databases returns empty, so database won't be found + resolver = Deploio::PgDatabaseResolver.new(nctl_client: @nctl) + + error = assert_raises(Deploio::PgDatabaseNotFoundError) do + resolver.resolve(database_name: "myproject-maindb") + end + + assert_match(/Database not found/, error.message) + end + + def test_raises_error_when_no_database_specified + resolver = Deploio::PgDatabaseResolver.new(nctl_client: @nctl) + + error = assert_raises(Deploio::Error) { resolver.resolve } + + assert_match(/No database specified/, error.message) + end + + def test_resolves_from_available_databases + nctl = MockNctlClient.new(pg_databases: [ + {"metadata" => {"namespace" => "n10518", "name" => "maindb"}} + ]) + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + result = resolver.resolve(database_name: "n10518-maindb") + + assert_equal "n10518", result.project_name + assert_equal "maindb", result.database_name + end + + def test_resolves_short_name_with_current_org + nctl = MockNctlClient.new( + pg_databases: [{"metadata" => {"namespace" => "myorg-myproject", "name" => "postgres"}}], + current_org: "myorg" + ) + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + # Can resolve using short name (without org prefix) + result = resolver.resolve(database_name: "myproject-postgres") + + assert_equal "myorg-myproject", result.project_name + assert_equal "postgres", result.database_name + end + + def test_available_databases_hash_includes_both_full_and_short_names + nctl = MockNctlClient.new( + pg_databases: [ + {"metadata" => {"namespace" => "myorg-myproject", "name" => "maindb"}}, + {"metadata" => {"namespace" => "otherorg-project", "name" => "testdb"}} + ], + current_org: "myorg" + ) + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + hash = resolver.available_databases_hash + + # Full names should be present + assert hash.key?("myorg-myproject-maindb") + assert hash.key?("otherorg-project-testdb") + + # Short name should be present only for databases in current org + assert hash.key?("myproject-maindb") + refute hash.key?("project-testdb") + end + + def test_short_name_for_strips_org_prefix + nctl = MockNctlClient.new(current_org: "myorg") + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + assert_equal "myproject-maindb", resolver.short_name_for("myorg-myproject", "maindb") + end + + def test_short_name_for_keeps_full_name_without_org + nctl = MockNctlClient.new(current_org: nil) + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + assert_equal "someorg-myproject-postgres", resolver.short_name_for("someorg-myproject", "postgres") + end + + def test_short_name_for_with_different_org + nctl = MockNctlClient.new(current_org: "myorg") + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + # Database from different org should keep full namespace + assert_equal "otherorg-project-db", resolver.short_name_for("otherorg-project", "db") + end + + def test_available_databases_hash_handles_missing_metadata + nctl = MockNctlClient.new( + pg_databases: [ + {"metadata" => {"namespace" => "valid-project", "name" => "db"}}, + {"metadata" => {"namespace" => "", "name" => ""}}, # Empty strings + {} # Missing metadata entirely + ] + ) + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + hash = resolver.available_databases_hash + + # Should include databases with valid metadata (even if empty strings create a key) + # The implementation creates "-" as a key for empty namespace/name + assert hash.key?("valid-project-db") + assert hash.key?("-") # Empty namespace and name create this key + end + + def test_available_databases_hash_handles_errors_gracefully + nctl = MockNctlClient.new(error_on_get: true) + resolver = Deploio::PgDatabaseResolver.new(nctl_client: nctl) + + hash = resolver.available_databases_hash + + # Should return empty hash on error + assert_equal({}, hash) + end + + class MockNctlClient + attr_reader :current_org, :dry_run + + def initialize(pg_databases: [], current_org: nil, dry_run: false, error_on_get: false) + @pg_databases = pg_databases + @current_org = current_org + @dry_run = dry_run + @error_on_get = error_on_get + end + + def get_all_pg_databases + raise "Simulated error" if @error_on_get + + @pg_databases + end + + def get_pg_database(db_ref) + @pg_databases.find do |db| + metadata = db["metadata"] || {} + metadata["namespace"] == db_ref.project_name && + metadata["name"] == db_ref.database_name + end + end + end +end From e5e713da6d3942bdda0d3661312fb290a85e6dd1 Mon Sep 17 00:00:00 2001 From: Alessandro Rodi Date: Thu, 2 Apr 2026 21:53:10 +0200 Subject: [PATCH 7/7] fix standard --- lib/deploio/commands/projects.rb | 4 ++-- lib/deploio/nctl_client.rb | 3 +-- lib/deploio/price_fetcher.rb | 12 ++++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/deploio/commands/projects.rb b/lib/deploio/commands/projects.rb index e4b4e9c..4359348 100644 --- a/lib/deploio/commands/projects.rb +++ b/lib/deploio/commands/projects.rb @@ -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 @@ -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 diff --git a/lib/deploio/nctl_client.rb b/lib/deploio/nctl_client.rb index 202cff0..58db4f5 100644 --- a/lib/deploio/nctl_client.rb +++ b/lib/deploio/nctl_client.rb @@ -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) @@ -110,7 +110,6 @@ def get_apps_by_project(project) [] end - def get_all_builds output = capture("get", "builds", "-A", "-o", "json") return [] if output.nil? || output.empty? diff --git a/lib/deploio/price_fetcher.rb b/lib/deploio/price_fetcher.rb index ef3a9c2..ea0ba99 100644 --- a/lib/deploio/price_fetcher.rb +++ b/lib/deploio/price_fetcher.rb @@ -85,7 +85,7 @@ def fetch_and_cache_prices cache_prices(prices) prices - rescue StandardError + rescue nil end @@ -104,11 +104,11 @@ def build_price_map(products) prices = { "postgres" => {}, "mysql" => {}, - "keyvaluestore" => { "base" => 15 }, - "opensearch" => { "base" => 60 }, - "single_database" => { "base" => 5 }, - "bucket" => { "base" => 0 }, - "app" => { "micro" => 8, "mini" => 16, "standard-1" => 32, "standard-2" => 58 }, + "keyvaluestore" => {"base" => 15}, + "opensearch" => {"base" => 60}, + "single_database" => {"base" => 5}, + "bucket" => {"base" => 0}, + "app" => {"micro" => 8, "mini" => 16, "standard-1" => 32, "standard-2" => 58}, "ram_per_gib" => 5 }