mirror of
https://github.com/ubicloud/ubicloud.git
synced 2025-10-06 06:41:57 +08:00
Subnets must now be in the same location, so the location name would be redundant. Make the ps connect/disconnect similar to other cli commands and no longer require the location name. This removes the last remaining use of the convert_loc_name_to_id helper method. All cli use allowing referencing objects by name instead id now only supports objects in the same location.
488 lines
13 KiB
Ruby
488 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "rodish"
|
|
require_relative "../sdk/ruby/lib/ubicloud"
|
|
|
|
class UbiCli
|
|
force_autoload = Config.production? || ENV["FORCE_AUTOLOAD"] == "1"
|
|
|
|
SDK_METHODS = {
|
|
"fw" => "firewall",
|
|
"ak" => "inference_api_key",
|
|
"kc" => "kubernetes_cluster",
|
|
"lb" => "load_balancer",
|
|
"pg" => "postgres",
|
|
"ps" => "private_subnet",
|
|
"vm" => "vm"
|
|
}.freeze
|
|
|
|
CAPITALIZED_LABELS = {
|
|
"fw" => "Firewall",
|
|
"ak" => "Inference API key",
|
|
"kc" => "Kubernetes cluster",
|
|
"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["kc"] = CAPITALIZED_LABELS["kc"]
|
|
LOWERCASE_LABELS["ak"] = "inference API key"
|
|
LOWERCASE_LABELS.freeze
|
|
|
|
OBJECT_INFO_REGEXP = /((fw|kc|1b|pg|ps|vm)[a-tv-z0-9]{24})/
|
|
EXACT_OBJECT_INFO_REGEXP = /\A#{OBJECT_INFO_REGEXP}\z/
|
|
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]
|
|
|
|
if command.post_subcommand(ref)
|
|
# support swapped reference and post command arguments
|
|
argv.insert(1, ref)
|
|
ref = argv.shift
|
|
end
|
|
|
|
@location, @name, extra = ref.split("/", 3)
|
|
|
|
if !@name && EXACT_OBJECT_INFO_REGEXP.match?(@location)
|
|
unless (object = sdk[@location])
|
|
raise Rodish::CommandFailure.new("no #{label} with id #{@location} exists", command)
|
|
end
|
|
|
|
@name = @location
|
|
@location = object.location
|
|
end
|
|
|
|
if extra || !@name
|
|
raise Rodish::CommandFailure.new("invalid #{cmd} reference (#{ref.inspect}), should be in location/#{cmd}-name or #{cmd}-id format", command)
|
|
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, command|
|
|
opts = opts[key]
|
|
if (location = opts[:location])
|
|
unless location.match(Validation::ALLOWED_NAME_PATTERN)
|
|
raise Rodish::CommandFailure.new("invalid location provided in #{cmd} list -l option", command)
|
|
end
|
|
end
|
|
|
|
items = sdk.send(sdk_method).list(location:)
|
|
keys = underscore_keys(check_fields(opts[:fields], fields, "#{cmd} list -f option", command))
|
|
response(format_rows(keys, items, headers: opts[:"no-headers"] != false))
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.rename(cmd)
|
|
on(cmd).run_on("rename") do
|
|
desc "Rename a #{LOWERCASE_LABELS[cmd]}"
|
|
|
|
banner "ubi #{cmd} (location/#{cmd}-name | #{cmd}-id) rename new-name"
|
|
|
|
args 1
|
|
|
|
run do |name|
|
|
sdk_object.rename_to(name)
|
|
response("#{CAPITALIZED_LABELS[cmd]} renamed to #{name}")
|
|
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
|
|
|
|
MIN_PGPASSWORD_VERSION = Gem::Version.new("1.1.0")
|
|
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
|
|
elsif comparable_client_version >= MIN_PGPASSWORD_VERSION
|
|
pgpassword = conn_string.password
|
|
conn_string.password = nil
|
|
headers = {"ubi-pgpassword" => pgpassword}
|
|
end
|
|
|
|
if (database = opts[:dbname])
|
|
conn_string.path = "/#{database}"
|
|
end
|
|
|
|
argv = [cmd, *argv, "--", conn_string]
|
|
argv = yield(argv) if block_given?
|
|
execute_argv(argv, **headers)
|
|
end
|
|
end
|
|
end
|
|
|
|
def initialize(env)
|
|
@env = env
|
|
end
|
|
|
|
private
|
|
|
|
def project_ubid
|
|
@project_ubid ||= @env["clover.project_ubid"]
|
|
end
|
|
|
|
def config_entries_response(entries, body: [])
|
|
entries.sort.each do |k, v|
|
|
body << k.to_s << "=" << v.to_s << "\n"
|
|
end
|
|
response(body)
|
|
end
|
|
|
|
def pg_tags_to_hash(params, cmd)
|
|
if params[:tags]
|
|
params[:tags] = params[:tags].split(",").to_h do
|
|
if it.include?("=")
|
|
it.split("=", 2)
|
|
else
|
|
raise Rodish::CommandFailure.new("invalid tag, does not include `=`: #{it.inspect}", cmd)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def config_entries_to_hash(args, cmd)
|
|
args.to_h do
|
|
if it.include?("=")
|
|
it.split("=", 2)
|
|
else
|
|
raise Rodish::CommandFailure.new("invalid argument, does not include `=`: #{it.inspect}", cmd)
|
|
end
|
|
end
|
|
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 need_integer_arg(v, arg_name, cmd)
|
|
raise Rodish::CommandFailure.new("invalid #{arg_name} argument: #{v.inspect}", cmd) unless (i = Integer(v, exception: false))
|
|
i
|
|
end
|
|
|
|
def execute_argv(argv, **headers)
|
|
headers["ubi-command-execute"] = argv.shift
|
|
response(argv.join("\0"), headers:)
|
|
end
|
|
|
|
def check_fields(given_fields, allowed_fields, option_name, cmd)
|
|
if given_fields
|
|
keys = given_fields.split(",")
|
|
|
|
if keys.empty?
|
|
raise Rodish::CommandFailure.new("no fields given in #{option_name}", cmd)
|
|
end
|
|
unless keys.size == keys.uniq.size
|
|
raise Rodish::CommandFailure.new("duplicate field(s) in #{option_name}", cmd)
|
|
end
|
|
|
|
invalid_keys = keys - allowed_fields
|
|
unless invalid_keys.empty?
|
|
raise Rodish::CommandFailure.new("invalid field(s) given in #{option_name}: #{invalid_keys.join(",")}", cmd)
|
|
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 { it.to_s.tr("-", "_").to_sym }
|
|
else # when Array
|
|
keys.map { it.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 comparable_client_version
|
|
Gem::Version.new(/\A(\d+)\.(\d+)\.(\d+)\z/.match?(client_version) ? client_version : "0.0.0")
|
|
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 convert_name_to_id(model_adapter, name)
|
|
unless model_adapter.id_regexp.match?(name)
|
|
id_for_loc_name(model_adapter, "#{@location}/#{name}")
|
|
end || name
|
|
end
|
|
|
|
def id_for_loc_name(model_adapter, loc_name)
|
|
_, name, extra = loc_name.split("/", 3)
|
|
if name && !extra
|
|
obj = model_adapter.new(loc_name)
|
|
obj.info
|
|
obj.id
|
|
end
|
|
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, cmd)
|
|
raise Rodish::CommandFailure.new(error_message, cmd) 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
|