Files
ubicloud/lib/ubi_cli.rb
Jeremy Evans e90e6deaf7 Reimplement CLI processing using the Ruby SDK
This changes the custom code in UbiCli and the cli-commands files to use
the Ruby SDK for all changes.  This is slightly slower, but allows for
testing Ruby SDK using the existing tests for the CLI.

The only spec change is to an error message, and the error message is now
more helpful.

As the Ruby SDK uses symbol keys instead of string keys, this switches
UbiCli and the cli-commands files to also use symbol keys.

This adds an UbiCli#check_no_slash helper method to DRY up code that
checks for slashes in strings used as path fragments.
2025-03-28 16:25:47 -07:00

404 lines
10 KiB
Ruby

# frozen_string_literal: true
require "rodish"
require_relative "../sdk/ruby/lib/ubicloud"
class UbiCli
force_autoload = Config.production? || ENV["FORCE_AUTOLOAD"] == "1"
FRAGMENTS = {
"fw" => "firewall",
"lb" => "load-balancer",
"pg" => "postgres",
"ps" => "private-subnet",
"vm" => "vm"
}.freeze
SDK_METHODS = FRAGMENTS.transform_values { _1.tr("-", "_").freeze }.freeze
CAPITALIZED_LABELS = {
"fw" => "Firewall",
"lb" => "Load balancer",
"pg" => "PostgreSQL database",
"ps" => "Private subnet",
"vm" => "Virtual machine"
}.freeze
LOWERCASE_LABELS = CAPITALIZED_LABELS.transform_values(&:downcase)
LOWERCASE_LABELS["pg"] = CAPITALIZED_LABELS["pg"]
LOWERCASE_LABELS.freeze
OBJECT_INFO_REGEXP = /((fw|1b|pg|ps|vm)[a-z0-9]{24})/
UBI_VERSION_REGEXP = /\A\d{1,4}\.\d{1,4}\.\d{1,4}\z/
Rodish.processor(self)
plugin :help_examples
plugin :help_option_values
plugin :help_order, default_help_order: [:desc, :banner, :examples, :commands, :options, :option_values]
plugin :post_commands
plugin :skip_option_parsing
on do
desc "CLI to interact with Ubicloud"
options("ubi command [command-options] ...") do
on("--confirm=confirmation", "confirmation value")
end
help_order(:desc, :banner, :examples, :commands)
help_example "ubi vm list # List virtual machines"
help_example "ubi help vm # Get help for vm subcommand"
# :nocov:
autoload_subcommand_dir("cli-commands") unless force_autoload
# :nocov:
end
def self.process(argv, env)
super
rescue Ubicloud::Error => e
status = e.code
message = "! Unexpected response status: #{e.code}"
parsed_body = e.params
message << "\nDetails: #{parsed_body.dig("error", "message")}"
if (details = parsed_body.dig("error", "details"))
details.each do |k, v|
message << "\n " << k.to_s << ": " << v.to_s
end
end
message += "\n"
[status, {"content-type" => "text/plain", "content-length" => message.bytesize.to_s}, [message]]
rescue Rodish::CommandFailure => e
status = 400
message = e.message_with_usage.dup
message[0] = "! #{message[0].capitalize}"
message += "\n" unless message.end_with?("\n")
[status, {"content-type" => "text/plain", "content-length" => message.bytesize.to_s}, [message]]
end
def self.base(cmd, &block)
on(cmd) do
label = LOWERCASE_LABELS[cmd]
desc "Manage #{label}s"
# :nocov:
unless Config.production? || ENV["FORCE_AUTOLOAD"] == "1"
autoload_subcommand_dir("cli-commands/#{cmd}")
autoload_post_subcommand_dir("cli-commands/#{cmd}/post")
end
# :nocov:
args(2...)
instance_exec(&block)
run do |(ref, *argv), opts, command|
@sdk_method = SDK_METHODS[cmd]
@location, @name, extra = ref.split("/", 3)
if !@name && OBJECT_INFO_REGEXP.match?(@location)
unless (object = sdk[@location])
raise Rodish::CommandFailure, "no #{label} with id #{@location} exists"
end
@name = @location
@location = object.location
end
if extra || !@name
raise Rodish::CommandFailure, "invalid #{cmd} reference, should be in location/#{cmd}-name or #{cmd}-id format"
end
command.run(self, opts, argv)
end
end
end
def self.list(cmd, fields)
fields.freeze.each(&:freeze)
key = :"#{cmd}_list"
sdk_method = SDK_METHODS[cmd]
on(cmd, "list") do
desc "List #{LOWERCASE_LABELS[cmd]}s"
options("ubi #{cmd} list [options]", key:) do
on("-f", "--fields=fields", "show specific fields (comma separated)")
on("-l", "--location=location", "only show #{LOWERCASE_LABELS[cmd]}s in given location")
on("-N", "--no-headers", "do not show headers")
end
help_option_values("Fields:", fields)
run do |opts|
opts = opts[key]
if (location = opts[:location])
unless location.match(Validation::ALLOWED_NAME_PATTERN)
raise Rodish::CommandFailure, "invalid location provided in #{cmd} list -l option"
end
end
items = sdk.send(sdk_method).list(location:)
keys = underscore_keys(check_fields(opts[:fields], fields, "#{cmd} list -f option"))
response(format_rows(keys, items, headers: opts[:"no-headers"] != false))
end
end
end
def self.destroy(cmd)
on(cmd).run_on("destroy") do
desc "Destroy a #{LOWERCASE_LABELS[cmd]}"
options("ubi #{cmd} (location/#{cmd}-name | #{cmd}-id) destroy [options]", key: :destroy) do
on("-f", "--force", "do not require confirmation")
end
run do |opts|
if opts.dig(:destroy, :force) || opts[:confirm] == @name
sdk_object.destroy
response("#{CAPITALIZED_LABELS[cmd]}, if it exists, is now scheduled for destruction")
elsif opts[:confirm]
invalid_confirmation <<~END
! Confirmation of #{LOWERCASE_LABELS[cmd]} name not successful.
END
else
require_confirmation("Confirmation", <<~END)
Destroying this #{LOWERCASE_LABELS[cmd]} is not recoverable.
Enter the following to confirm destruction of the #{LOWERCASE_LABELS[cmd]}: #{@name}
END
end
end
end
end
def self.pg_cmd(cmd, desc)
on("pg").run_on(cmd) do
desc(desc)
skip_option_parsing("ubi pg (location/pg-name | pg-id) [options] #{cmd} [#{cmd}-options]")
args(0...)
run do |argv, opts|
pg = sdk_object.info
conn_string = URI(pg.connection_string)
opts = opts[:pg_psql]
if (user = opts[:username])
conn_string.user = user
conn_string.password = nil
end
if (database = opts[:dbname])
conn_string.path = "/#{database}"
end
argv = [cmd, *argv, "--", conn_string]
argv = yield(argv) if block_given?
execute_argv(argv)
end
end
end
def initialize(env)
@env = env
end
private
def project_ubid
@project_ubid ||= @env["clover.project_ubid"]
end
def handle_ssh(opts)
vm = sdk_object.info
opts = opts[:vm_ssh]
user = opts[:user]
if opts[:ip4]
address = vm.ip4 || false
elsif opts[:ip6]
address = vm.ip6
end
if address.nil?
address = if ipv6_request?
vm.ip6 || vm.ip4
else
vm.ip4 || vm.ip6
end
end
if address
user ||= vm.unix_user
execute_argv(yield(user:, address:))
else
response("! No valid IPv4 address for requested VM", status: 400)
end
end
def execute_argv(argv)
cmd = argv.shift
response(argv.join("\0"), headers: {"ubi-command-execute" => cmd})
end
def check_fields(given_fields, allowed_fields, option_name)
if given_fields
keys = given_fields.split(",")
if keys.empty?
raise Rodish::CommandFailure, "no fields given in #{option_name}"
end
unless keys.size == keys.uniq.size
raise Rodish::CommandFailure, "duplicate field(s) in #{option_name}"
end
invalid_keys = keys - allowed_fields
unless invalid_keys.empty?
raise Rodish::CommandFailure, "invalid field(s) given in #{option_name}: #{invalid_keys.join(",")}"
end
keys
else
allowed_fields
end
end
def format_rows(keys, rows, headers: false, col_sep: " ")
results = []
sizes = Hash.new(0)
keys.each do |key|
sizes[key] = headers ? key.size : 0
end
rows = rows.map do |row|
row.to_h.transform_values(&:to_s)
end
rows.each do |row|
keys.each do |key|
size = row[key].size
sizes[key] = size if size > sizes[key]
end
end
sizes.transform_values! do |size|
"%-#{size}s"
end
if headers
sep = false
keys.each do |key|
if sep
results << col_sep
else
sep = true
end
results << (sizes[key] % key)
end
results << "\n"
end
rows.each do |row|
sep = false
keys.each do |key|
if sep
results << col_sep
else
sep = true
end
results << (sizes[key] % row[key])
end
results << "\n"
end
results
end
def ipv6_request?
@env["puma.socket"]&.local_address&.ipv6?
end
def underscore_keys(keys)
if keys.is_a?(Hash)
keys.transform_keys { _1.to_s.tr("-", "_").to_sym }
else # when Array
keys.map { _1.tr("-", "_").to_sym }
end
end
def client_version
@client_version ||= begin
version_header = @env["HTTP_X_UBI_VERSION"]
UBI_VERSION_REGEXP.match?(version_header) ? version_header : "unknown"
end
end
def invalid_confirmation(message)
response(message, status: 400)
end
def require_confirmation(prompt, confirmation)
response(confirmation, headers: {"ubi-confirm" => prompt})
end
def response(body, status: 200, headers: {})
body = [body] unless body.is_a?(Array)
finalize_response([status, headers, body])
end
def sdk
@sdk ||= Ubicloud.new(:rack, app: Clover, env: @env, project_id: project_ubid)
end
def sdk_object
sdk.send(@sdk_method).new("#{@location}/#{@name}")
end
def finalize_response(res)
headers = res[1]
body = res[2]
if !headers["ubi-command-execute"] && !headers["ubi-confirm"] && (body.empty? || !body[-1].end_with?("\n"))
body << "\n"
end
headers["content-length"] = body.sum(&:bytesize).to_s
headers["content-type"] = "text/plain"
res
end
def check_no_slash(string, error_message)
raise Rodish::CommandFailure, error_message if string.include?("/")
end
# :nocov:
if Config.test? && ENV["CLOVER_FREEZE"] == "1"
singleton_class.prepend(Module.new do
def process(argv, env)
DB.block_queries do
super
end
end
end)
require_relative "../sdk/ruby/lib/ubicloud/adapter"
require_relative "../sdk/ruby/lib/ubicloud/adapter/rack"
Ubicloud::Adapter::Rack.prepend(Module.new do
def call(...)
DB.allow_queries do
super
end
end
end)
end
# :nocov:
Unreloader.record_dependency("lib/rodish.rb", __FILE__)
Unreloader.record_dependency(__FILE__, "cli-commands")
if force_autoload
Unreloader.require("cli-commands") {}
# :nocov:
else
Unreloader.autoload("cli-commands") {}
end
# :nocov:
end