Now that it is the only option left, we can suppress the display of the entire options section for the root command. I think this is fine to hide as users are not supposed to use it directly, it is used implicitly in destroy command handling to receive user confirmation. Update usage for root command so it does not show options, and it shows that a command is required instead of optional.
475 lines
12 KiB
Ruby
475 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "rodish"
|
|
|
|
class UbiCli
|
|
force_autoload = Config.production? || ENV["FORCE_AUTOLOAD"] == "1"
|
|
|
|
FRAGMENTS = {
|
|
"fw" => "firewall",
|
|
"lb" => "load-balancer",
|
|
"pg" => "postgres",
|
|
"ps" => "private-subnet",
|
|
"vm" => "vm"
|
|
}.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 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
|
|
desc "Manage #{LOWERCASE_LABELS[cmd]}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|
|
|
@location, @name, extra = ref.split("/", 3)
|
|
|
|
if !@name && OBJECT_INFO_REGEXP.match?(@location)
|
|
location = get(project_path("object-info/#{@location}")) do |data|
|
|
break data["location"]
|
|
end
|
|
|
|
if location.is_a?(Array)
|
|
location[0] = 400
|
|
next location
|
|
end
|
|
|
|
@name = @location
|
|
@location = 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"
|
|
fragment = FRAGMENTS[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]
|
|
path = if (location = opts[:location])
|
|
if !location.match(Validation::ALLOWED_NAME_PATTERN)
|
|
raise Rodish::CommandFailure, "invalid location provided in #{cmd} list -l option"
|
|
else
|
|
"location/#{location}/#{fragment}"
|
|
end
|
|
else
|
|
fragment
|
|
end
|
|
|
|
get(project_path(path)) do |data|
|
|
keys = underscore_keys(check_fields(opts[:fields], fields, "#{cmd} list -f option"))
|
|
format_rows(keys, data["items"], headers: opts[:"no-headers"] != false)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.destroy(cmd)
|
|
fragment = FRAGMENTS[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
|
|
delete(project_subpath(fragment)) do |_, res|
|
|
["#{CAPITALIZED_LABELS[cmd]}, if it exists, is now scheduled for destruction"]
|
|
end
|
|
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|
|
|
get(pg_path) do |data, res|
|
|
conn_string = URI(data["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, res)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def initialize(env)
|
|
@env = env
|
|
end
|
|
|
|
private
|
|
|
|
def project_ubid
|
|
@project_ubid ||= @env["clover.project_ubid"]
|
|
end
|
|
|
|
def handle_ssh(opts)
|
|
get(vm_path) do |data, res|
|
|
opts = opts[:vm_ssh]
|
|
user = opts[:user]
|
|
if opts[:ip4]
|
|
address = data["ip4"] || false
|
|
elsif opts[:ip6]
|
|
address = data["ip6"]
|
|
end
|
|
|
|
if address.nil?
|
|
address = if ipv6_request?
|
|
data["ip6"] || data["ip4"]
|
|
else
|
|
data["ip4"] || data["ip6"]
|
|
end
|
|
end
|
|
|
|
if address
|
|
user ||= data["unix_user"]
|
|
execute_argv(yield(user:, address:), res)
|
|
else
|
|
res[0] = 400
|
|
["! No valid IPv4 address for requested VM"]
|
|
end
|
|
end
|
|
end
|
|
|
|
def execute_argv(args, res)
|
|
res[1]["ubi-command-execute"] = args.shift
|
|
[args.join("\0")]
|
|
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 delete(path, params = {}, &block)
|
|
_req(_req_env("DELETE", path, params), &block)
|
|
end
|
|
|
|
def post(path, params = {}, &block)
|
|
_req(_req_env("POST", path, params), &block)
|
|
end
|
|
|
|
def patch(path, params = {}, &block)
|
|
_req(_req_env("PATCH", path, params), &block)
|
|
end
|
|
|
|
def get(path, &block)
|
|
_req(_req_env("GET", path, nil), &block)
|
|
end
|
|
|
|
def project_path(rest)
|
|
"/project/#{project_ubid}/#{rest}"
|
|
end
|
|
|
|
def project_subpath(fragment, rest = "")
|
|
project_path("location/#{@location}/#{fragment}/#{@name}#{rest}")
|
|
end
|
|
|
|
FRAGMENTS.each do |cmd, fragment|
|
|
define_method(:"#{cmd}_path") do |rest = ""|
|
|
project_subpath(fragment, rest)
|
|
end
|
|
end
|
|
|
|
def format_rows(keys, rows, headers: false, col_sep: " ")
|
|
results = []
|
|
|
|
sizes = Hash.new(0)
|
|
string_keys = keys.map(&:to_s)
|
|
string_keys.each do |key|
|
|
sizes[key] = headers ? key.size : 0
|
|
end
|
|
rows = rows.map do |row|
|
|
row.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
|
|
string_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)
|
|
# Used with symbol keyed hashes that need to be
|
|
# converted to strings
|
|
keys.transform_keys { _1.to_s.tr("-", "_") }
|
|
else # when Hash
|
|
keys.map { _1.tr("-", "_") }
|
|
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 _req_env(method, path, params)
|
|
env = @env.merge(
|
|
"REQUEST_METHOD" => method,
|
|
"PATH_INFO" => path,
|
|
"rack.request.form_input" => nil,
|
|
"rack.request.form_hash" => nil
|
|
)
|
|
params &&= params.to_json.force_encoding(Encoding::BINARY)
|
|
env["rack.input"] = StringIO.new(params || "".b)
|
|
env.delete("roda.json_params")
|
|
env
|
|
end
|
|
|
|
def _req(env)
|
|
res = _submit_req(env)
|
|
|
|
case res[0]
|
|
when 200
|
|
# Temporary nocov until at least one action pushed into routes
|
|
# :nocov:
|
|
if res[1]["content-type"] == "application/json"
|
|
# :nocov:
|
|
body = +""
|
|
res[2].each { body << _1 }
|
|
res[2] = yield(JSON.parse(body), res)
|
|
end
|
|
when 204
|
|
res[0] = 200
|
|
res[2] = yield(nil, res)
|
|
else
|
|
body = +""
|
|
res[2].each { body << _1 }
|
|
error_message = "! Unexpected response status: #{res[0]}"
|
|
# Temporary nocov until at least one action pushed into routes
|
|
# :nocov:
|
|
if (res[1]["content-type"] == "application/json") && (parsed_body = JSON.parse(body)) && (error = parsed_body.dig("error", "message"))
|
|
# :nocov:
|
|
error_message << "\nDetails: #{error}"
|
|
if (details = parsed_body.dig("error", "details"))
|
|
details.each do |k, v|
|
|
error_message << "\n " << k.to_s << ": " << v.to_s
|
|
end
|
|
end
|
|
end
|
|
res[2] = [error_message]
|
|
end
|
|
|
|
finalize_response(res)
|
|
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 _submit_req(env)
|
|
Clover.call(env)
|
|
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)
|
|
|
|
prepend(Module.new do
|
|
def _submit_req(env)
|
|
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
|