mirror of
https://github.com/ubicloud/ubicloud.git
synced 2025-10-06 23:01:56 +08:00
This uses a shared route for all 6 objects, as well as shared cli and sdk code. Unfortunately, there is not a way to share such code in openapi.yml, so that part ends up quite verbose. Eventually, we should add a web interface for this, but it's best to wait until we refactor the web interfaces for the objects to consistently use the current postgres web interface.
472 lines
12 KiB
Ruby
472 lines
12 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-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]
|
|
|
|
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 && 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 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
|