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.
404 lines
10 KiB
Ruby
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
|