This allows the server to take appropriate action based on client version. It we add new features that require an updated client version, we can check the client's version, and give them a give the clients a nice error message instructing them to upgrade. This required a minor change to Rodish to execute code after option parsing and before subcommand processing, even if there are no valid subcommands provided. I made that change and released a new version, so this bumps the rodish version. The cli version is stored in cli/version.txt. bin/ubi reads this file when run. In the rake ubi/ubi-cross tasks, the Rakefile reads this file and sets the version in the compiled go file.
466 lines
12 KiB
Ruby
466 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" => "VM"
|
|
}.freeze
|
|
|
|
LOWERCASE_LABELS = CAPITALIZED_LABELS.transform_values(&:downcase)
|
|
LOWERCASE_LABELS["pg"] = CAPITALIZED_LABELS["pg"]
|
|
LOWERCASE_LABELS["vm"] = CAPITALIZED_LABELS["vm"]
|
|
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) do
|
|
options("ubi [options] [subcommand [subcommand-options] ...]") do
|
|
on("--version", "show program version")
|
|
on("--help", "show program help") { halt to_s }
|
|
on("--confirm=confirmation", "confirmation value (not for direct use)")
|
|
end
|
|
|
|
after_options do |_, opts|
|
|
raise Rodish::CommandExit, client_version if opts[:version]
|
|
end
|
|
|
|
# :nocov:
|
|
autoload_subcommand_dir("cli-commands") unless force_autoload
|
|
# :nocov:
|
|
end
|
|
|
|
def self.process(argv, env)
|
|
super
|
|
rescue Rodish::CommandExit => e
|
|
if e.failure?
|
|
status = 400
|
|
message = e.message_with_usage.dup
|
|
message[0] = "! #{message[0].capitalize}"
|
|
else
|
|
status = 200
|
|
message = e.message
|
|
end
|
|
|
|
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
|
|
# :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
|
|
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")
|
|
wrap("Fields:", fields)
|
|
end
|
|
|
|
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
|
|
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)
|
|
on("pg").run_on(cmd) do
|
|
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
|