parent
d36d2cb890
commit
42e56c9246
@ -0,0 +1,563 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require 'rubygems'
|
||||||
|
require 'uri'
|
||||||
|
require 'net/https'
|
||||||
|
require 'json'
|
||||||
|
require 'pp'
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# The CloudClient module contains general functionality to implement a
|
||||||
|
# Cloud Client
|
||||||
|
###############################################################################
|
||||||
|
module CloudClient
|
||||||
|
|
||||||
|
# OpenNebula version
|
||||||
|
VERSION = '4.8.0'
|
||||||
|
|
||||||
|
# #########################################################################
|
||||||
|
# Default location for the authentication file
|
||||||
|
# #########################################################################
|
||||||
|
DEFAULT_AUTH_FILE = ENV["HOME"]+"/.one/one_auth"
|
||||||
|
|
||||||
|
# #########################################################################
|
||||||
|
# Gets authorization credentials from ONE_AUTH or default
|
||||||
|
# auth file.
|
||||||
|
#
|
||||||
|
# Raises an error if authorization is not found
|
||||||
|
# #########################################################################
|
||||||
|
def self.get_one_auth
|
||||||
|
if ENV["ONE_AUTH"] and !ENV["ONE_AUTH"].empty? and
|
||||||
|
File.file?(ENV["ONE_AUTH"])
|
||||||
|
one_auth=File.read(ENV["ONE_AUTH"]).strip.split(':')
|
||||||
|
elsif File.file?(DEFAULT_AUTH_FILE)
|
||||||
|
one_auth=File.read(DEFAULT_AUTH_FILE).strip.split(':')
|
||||||
|
else
|
||||||
|
raise "No authorization data present"
|
||||||
|
end
|
||||||
|
|
||||||
|
raise "Authorization data malformed" if one_auth.length < 2
|
||||||
|
|
||||||
|
one_auth
|
||||||
|
end
|
||||||
|
|
||||||
|
# #########################################################################
|
||||||
|
# Starts an http connection and calls the block provided. SSL flag
|
||||||
|
# is set if needed.
|
||||||
|
# #########################################################################
|
||||||
|
def self.http_start(url, timeout, &block)
|
||||||
|
host = nil
|
||||||
|
port = nil
|
||||||
|
|
||||||
|
if ENV['http_proxy']
|
||||||
|
uri_proxy = URI.parse(ENV['http_proxy'])
|
||||||
|
host = uri_proxy.host
|
||||||
|
port = uri_proxy.port
|
||||||
|
end
|
||||||
|
|
||||||
|
http = Net::HTTP::Proxy(host, port).new(url.host, url.port)
|
||||||
|
|
||||||
|
if timeout
|
||||||
|
http.read_timeout = timeout.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
if url.scheme=='https'
|
||||||
|
http.use_ssl = true
|
||||||
|
http.verify_mode=OpenSSL::SSL::VERIFY_NONE
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
res = http.start do |connection|
|
||||||
|
block.call(connection)
|
||||||
|
end
|
||||||
|
rescue Errno::ECONNREFUSED => e
|
||||||
|
str = "Error connecting to server (#{e.to_s}).\n"
|
||||||
|
str << "Server: #{url.host}:#{url.port}"
|
||||||
|
|
||||||
|
return CloudClient::Error.new(str,"503")
|
||||||
|
rescue Errno::ETIMEDOUT => e
|
||||||
|
str = "Error timeout connecting to server (#{e.to_s}).\n"
|
||||||
|
str << "Server: #{url.host}:#{url.port}"
|
||||||
|
|
||||||
|
return CloudClient::Error.new(str,"504")
|
||||||
|
rescue Timeout::Error => e
|
||||||
|
str = "Error timeout while connected to server (#{e.to_s}).\n"
|
||||||
|
str << "Server: #{url.host}:#{url.port}"
|
||||||
|
|
||||||
|
return CloudClient::Error.new(str,"504")
|
||||||
|
rescue SocketError => e
|
||||||
|
str = "Error timeout while connected to server (#{e.to_s}).\n"
|
||||||
|
|
||||||
|
return CloudClient::Error.new(str,"503")
|
||||||
|
rescue
|
||||||
|
return CloudClient::Error.new($!.to_s,"503")
|
||||||
|
end
|
||||||
|
|
||||||
|
if res.is_a?(Net::HTTPSuccess)
|
||||||
|
res
|
||||||
|
else
|
||||||
|
CloudClient::Error.new(res.body, res.code)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# #########################################################################
|
||||||
|
# The Error Class represents a generic error in the Cloud Client
|
||||||
|
# library. It contains a readable representation of the error.
|
||||||
|
# #########################################################################
|
||||||
|
class Error
|
||||||
|
attr_reader :message
|
||||||
|
attr_reader :code
|
||||||
|
|
||||||
|
# +message+ a description of the error
|
||||||
|
def initialize(message=nil, code="500")
|
||||||
|
@message=message
|
||||||
|
@code=code
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s()
|
||||||
|
@message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# #########################################################################
|
||||||
|
# Returns true if the object returned by a method of the OpenNebula
|
||||||
|
# library is an Error
|
||||||
|
# #########################################################################
|
||||||
|
def self.is_error?(value)
|
||||||
|
value.class==CloudClient::Error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module OneGate
|
||||||
|
module VirtualMachine
|
||||||
|
VM_STATE=%w{INIT PENDING HOLD ACTIVE STOPPED SUSPENDED DONE FAILED
|
||||||
|
POWEROFF UNDEPLOYED}
|
||||||
|
|
||||||
|
LCM_STATE=%w{LCM_INIT PROLOG BOOT RUNNING MIGRATE SAVE_STOP SAVE_SUSPEND
|
||||||
|
SAVE_MIGRATE PROLOG_MIGRATE PROLOG_RESUME EPILOG_STOP EPILOG
|
||||||
|
SHUTDOWN CANCEL FAILURE CLEANUP_RESUBMIT UNKNOWN HOTPLUG SHUTDOWN_POWEROFF
|
||||||
|
BOOT_UNKNOWN BOOT_POWEROFF BOOT_SUSPENDED BOOT_STOPPED CLEANUP_DELETE
|
||||||
|
HOTPLUG_SNAPSHOT HOTPLUG_NIC HOTPLUG_SAVEAS HOTPLUG_SAVEAS_POWEROFF
|
||||||
|
HOTPLUG_SAVEAS_SUSPENDED SHUTDOWN_UNDEPLOY EPILOG_UNDEPLOY
|
||||||
|
PROLOG_UNDEPLOY BOOT_UNDEPLOY}
|
||||||
|
|
||||||
|
SHORT_VM_STATES={
|
||||||
|
"INIT" => "init",
|
||||||
|
"PENDING" => "pend",
|
||||||
|
"HOLD" => "hold",
|
||||||
|
"ACTIVE" => "actv",
|
||||||
|
"STOPPED" => "stop",
|
||||||
|
"SUSPENDED" => "susp",
|
||||||
|
"DONE" => "done",
|
||||||
|
"FAILED" => "fail",
|
||||||
|
"POWEROFF" => "poff",
|
||||||
|
"UNDEPLOYED"=> "unde"
|
||||||
|
}
|
||||||
|
|
||||||
|
SHORT_LCM_STATES={
|
||||||
|
"PROLOG" => "prol",
|
||||||
|
"BOOT" => "boot",
|
||||||
|
"RUNNING" => "runn",
|
||||||
|
"MIGRATE" => "migr",
|
||||||
|
"SAVE_STOP" => "save",
|
||||||
|
"SAVE_SUSPEND" => "save",
|
||||||
|
"SAVE_MIGRATE" => "save",
|
||||||
|
"PROLOG_MIGRATE" => "migr",
|
||||||
|
"PROLOG_RESUME" => "prol",
|
||||||
|
"EPILOG_STOP" => "epil",
|
||||||
|
"EPILOG" => "epil",
|
||||||
|
"SHUTDOWN" => "shut",
|
||||||
|
"CANCEL" => "shut",
|
||||||
|
"FAILURE" => "fail",
|
||||||
|
"CLEANUP_RESUBMIT" => "clea",
|
||||||
|
"UNKNOWN" => "unkn",
|
||||||
|
"HOTPLUG" => "hotp",
|
||||||
|
"SHUTDOWN_POWEROFF" => "shut",
|
||||||
|
"BOOT_UNKNOWN" => "boot",
|
||||||
|
"BOOT_POWEROFF" => "boot",
|
||||||
|
"BOOT_SUSPENDED" => "boot",
|
||||||
|
"BOOT_STOPPED" => "boot",
|
||||||
|
"CLEANUP_DELETE" => "clea",
|
||||||
|
"HOTPLUG_SNAPSHOT" => "snap",
|
||||||
|
"HOTPLUG_NIC" => "hotp",
|
||||||
|
"HOTPLUG_SAVEAS" => "hotp",
|
||||||
|
"HOTPLUG_SAVEAS_POWEROFF" => "hotp",
|
||||||
|
"HOTPLUG_SAVEAS_SUSPENDED" => "hotp",
|
||||||
|
"SHUTDOWN_UNDEPLOY" => "shut",
|
||||||
|
"EPILOG_UNDEPLOY" => "epil",
|
||||||
|
"PROLOG_UNDEPLOY" => "prol",
|
||||||
|
"BOOT_UNDEPLOY" => "boot"
|
||||||
|
}
|
||||||
|
|
||||||
|
def self.state_to_str(id, lcm_id)
|
||||||
|
id = id.to_i
|
||||||
|
state_str = VM_STATE[id]
|
||||||
|
|
||||||
|
if state_str=="ACTIVE"
|
||||||
|
lcm_id = lcm_id.to_i
|
||||||
|
return LCM_STATE[lcm_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
return state_str
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.print(json_hash)
|
||||||
|
OneGate.print_header("VM " + json_hash["VM"]["ID"])
|
||||||
|
OneGate.print_key_value("NAME", json_hash["VM"]["NAME"])
|
||||||
|
OneGate.print_key_value(
|
||||||
|
"STATE",
|
||||||
|
self.state_to_str(
|
||||||
|
json_hash["VM"]["STATE"],
|
||||||
|
json_hash["VM"]["LCM_STATE"]))
|
||||||
|
|
||||||
|
vm_nics = [json_hash['VM']['TEMPLATE']['NIC']].flatten
|
||||||
|
vm_nics.each { |nic|
|
||||||
|
# TODO: IPv6
|
||||||
|
OneGate.print_key_value("IP", nic["IP"])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Service
|
||||||
|
STATE = {
|
||||||
|
'PENDING' => 0,
|
||||||
|
'DEPLOYING' => 1,
|
||||||
|
'RUNNING' => 2,
|
||||||
|
'UNDEPLOYING' => 3,
|
||||||
|
'WARNING' => 4,
|
||||||
|
'DONE' => 5,
|
||||||
|
'FAILED_UNDEPLOYING' => 6,
|
||||||
|
'FAILED_DEPLOYING' => 7,
|
||||||
|
'SCALING' => 8,
|
||||||
|
'FAILED_SCALING' => 9,
|
||||||
|
'COOLDOWN' => 10
|
||||||
|
}
|
||||||
|
|
||||||
|
STATE_STR = [
|
||||||
|
'PENDING',
|
||||||
|
'DEPLOYING',
|
||||||
|
'RUNNING',
|
||||||
|
'UNDEPLOYING',
|
||||||
|
'WARNING',
|
||||||
|
'DONE',
|
||||||
|
'FAILED_UNDEPLOYING',
|
||||||
|
'FAILED_DEPLOYING',
|
||||||
|
'SCALING',
|
||||||
|
'FAILED_SCALING',
|
||||||
|
'COOLDOWN'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Returns the string representation of the service state
|
||||||
|
# @param [String] state String number representing the state
|
||||||
|
# @return the state string
|
||||||
|
def self.state_str(state_number)
|
||||||
|
return STATE_STR[state_number.to_i]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.print(json_hash)
|
||||||
|
OneGate.print_header("SERVICE " + json_hash["SERVICE"]["id"])
|
||||||
|
OneGate.print_key_value("NAME", json_hash["SERVICE"]["name"])
|
||||||
|
OneGate.print_key_value("STATE", Service.state_str(json_hash["SERVICE"]['state']))
|
||||||
|
puts
|
||||||
|
|
||||||
|
roles = [json_hash['SERVICE']['roles']].flatten
|
||||||
|
roles.each { |role|
|
||||||
|
OneGate.print_header("ROLE " + role["name"], false)
|
||||||
|
|
||||||
|
if role["nodes"]
|
||||||
|
role["nodes"].each{ |node|
|
||||||
|
OneGate::VirtualMachine.print(node["vm_info"])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
puts
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Client
|
||||||
|
def initialize(opts={})
|
||||||
|
@vmid = ENV["VMID"]
|
||||||
|
@token = ENV["TOKENTXT"]
|
||||||
|
|
||||||
|
url = opts[:url] || ENV['ONEGATE_ENDPOINT']
|
||||||
|
@uri = URI.parse(url)
|
||||||
|
|
||||||
|
@user_agent = "OpenNebula #{CloudClient::VERSION} " <<
|
||||||
|
"(#{opts[:user_agent]||"Ruby"})"
|
||||||
|
|
||||||
|
@host = nil
|
||||||
|
@port = nil
|
||||||
|
|
||||||
|
if ENV['http_proxy']
|
||||||
|
uri_proxy = URI.parse(ENV['http_proxy'])
|
||||||
|
@host = uri_proxy.host
|
||||||
|
@port = uri_proxy.port
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(path)
|
||||||
|
req = Net::HTTP::Proxy(@host, @port)::Get.new(path)
|
||||||
|
|
||||||
|
do_request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(path)
|
||||||
|
req =Net::HTTP::Proxy(@host, @port)::Delete.new(path)
|
||||||
|
|
||||||
|
do_request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(path, body)
|
||||||
|
req = Net::HTTP::Proxy(@host, @port)::Post.new(path)
|
||||||
|
req.body = body
|
||||||
|
|
||||||
|
do_request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
def put(path, body)
|
||||||
|
req = Net::HTTP::Proxy(@host, @port)::Put.new(path)
|
||||||
|
req.body = body
|
||||||
|
|
||||||
|
do_request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
def login
|
||||||
|
req = Net::HTTP::Proxy(@host, @port)::Post.new('/login')
|
||||||
|
|
||||||
|
do_request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
def logout
|
||||||
|
req = Net::HTTP::Proxy(@host, @port)::Post.new('/logout')
|
||||||
|
|
||||||
|
do_request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def do_request(req)
|
||||||
|
req.basic_auth @username, @password
|
||||||
|
|
||||||
|
req['User-Agent'] = @user_agent
|
||||||
|
req['X-ONEGATE-TOKEN'] = @token
|
||||||
|
req['X-ONEGATE-VMID'] = @vmid
|
||||||
|
|
||||||
|
res = CloudClient::http_start(@uri, @timeout) do |http|
|
||||||
|
http.request(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
res
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parse_json(response)
|
||||||
|
if CloudClient::is_error?(response)
|
||||||
|
puts "ERROR: "
|
||||||
|
puts response.message
|
||||||
|
exit -1
|
||||||
|
else
|
||||||
|
return JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sets bold font
|
||||||
|
def self.scr_bold
|
||||||
|
print "\33[1m"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sets underline
|
||||||
|
def self.scr_underline
|
||||||
|
print "\33[4m"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Restore normal font
|
||||||
|
def self.scr_restore
|
||||||
|
print "\33[0m"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Print header
|
||||||
|
def self.print_header(str, underline=true)
|
||||||
|
if $stdout.tty?
|
||||||
|
scr_bold
|
||||||
|
scr_underline if underline
|
||||||
|
print "%-80s" % str
|
||||||
|
scr_restore
|
||||||
|
else
|
||||||
|
print str
|
||||||
|
end
|
||||||
|
puts
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.print_key_value(key, value)
|
||||||
|
puts "%-20s: %-20s" % [key, value]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.help_str
|
||||||
|
return <<-EOT
|
||||||
|
Available commands
|
||||||
|
$ onegate vm show [VMID] [--json]
|
||||||
|
|
||||||
|
$ onegate vm update [VMID] --data KEY=VALUE[\\nKEY2=VALUE2]
|
||||||
|
|
||||||
|
$ onegate vm ACTION VMID
|
||||||
|
$ onegate resume [VMID]
|
||||||
|
$ onegate stop [VMID]
|
||||||
|
$ onegate suspend [VMID]
|
||||||
|
$ onegate delete [VMID] [--hard]
|
||||||
|
$ onegate shutdown [VMID] [--hard]
|
||||||
|
$ onegate reboot [VMID] [--hard]
|
||||||
|
$ onegate poweroff [VMID] [--hard]
|
||||||
|
$ onegate resubmit [VMID]
|
||||||
|
$ onegate resched [VMID]
|
||||||
|
$ onegate unresched [VMID]
|
||||||
|
$ onegate hold [VMID]
|
||||||
|
$ onegate release [VMID]
|
||||||
|
|
||||||
|
$ onegate service show [--json]
|
||||||
|
|
||||||
|
$ onegate service scale --role ROLE --cardinality CARDINALITY
|
||||||
|
EOT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
require 'optparse'
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
OptionParser.new do |opts|
|
||||||
|
opts.on("-d", "--data DATA", "Data to be included in the VM") do |data|
|
||||||
|
options[:data] = data
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-r", "--role ROLE", "Service role") do |role|
|
||||||
|
options[:role] = role
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-c", "--cardinality CARD", "Service cardinality") do |cardinality|
|
||||||
|
options[:cardinality] = cardinality
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-j", "--json", "Print resource information in JSON") do |json|
|
||||||
|
options[:json] = json
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-f", "--hard", "Hard option for power off operations") do |hard|
|
||||||
|
options[:hard] = hard
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-h", "--help", "Show this message") do
|
||||||
|
puts OneGate.help_str
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
end.parse!
|
||||||
|
|
||||||
|
client = OneGate::Client.new()
|
||||||
|
|
||||||
|
case ARGV[0]
|
||||||
|
when "vm"
|
||||||
|
case ARGV[1]
|
||||||
|
when "show"
|
||||||
|
if ARGV[2]
|
||||||
|
response = client.get("/vms/"+ARGV[2])
|
||||||
|
else
|
||||||
|
response = client.get("/vm")
|
||||||
|
end
|
||||||
|
|
||||||
|
json_hash = OneGate.parse_json(response)
|
||||||
|
if options[:json]
|
||||||
|
puts JSON.pretty_generate(json_hash)
|
||||||
|
else
|
||||||
|
OneGate::VirtualMachine.print(json_hash)
|
||||||
|
end
|
||||||
|
when "update"
|
||||||
|
if !options[:data]
|
||||||
|
puts "You have to provide the data as a param (--data)"
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
|
||||||
|
if ARGV[2]
|
||||||
|
response = client.put("/vms/"+ARGV[2], options[:data])
|
||||||
|
else
|
||||||
|
response = client.put("/vm", options[:data])
|
||||||
|
end
|
||||||
|
|
||||||
|
if CloudClient::is_error?(response)
|
||||||
|
puts "ERROR: "
|
||||||
|
puts response.message
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
when "resume",
|
||||||
|
"stop",
|
||||||
|
"suspend",
|
||||||
|
"delete",
|
||||||
|
"shutdown",
|
||||||
|
"reboot",
|
||||||
|
"poweroff",
|
||||||
|
"resubmit",
|
||||||
|
"resched",
|
||||||
|
"unresched",
|
||||||
|
"hold",
|
||||||
|
"release"
|
||||||
|
if ARGV[2]
|
||||||
|
action_hash = {
|
||||||
|
"action" => {
|
||||||
|
"perform" => ARGV[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if options[:hard]
|
||||||
|
action_hash["action"]["params"] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
response = client.post("/vms/"+ARGV[2]+"/action", action_hash.to_json)
|
||||||
|
|
||||||
|
if CloudClient::is_error?(response)
|
||||||
|
puts "ERROR: "
|
||||||
|
puts response.message
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts "You have to provide a VM ID"
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts OneGate.help_str
|
||||||
|
puts
|
||||||
|
puts "Action #{ARGV[1]} not supported"
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
when "service"
|
||||||
|
case ARGV[1]
|
||||||
|
when "show"
|
||||||
|
response = client.get("/service")
|
||||||
|
json_hash = OneGate.parse_json(response)
|
||||||
|
#pp json_hash
|
||||||
|
if options[:json]
|
||||||
|
puts JSON.pretty_generate(json_hash)
|
||||||
|
else
|
||||||
|
OneGate::Service.print(json_hash)
|
||||||
|
end
|
||||||
|
when "scale"
|
||||||
|
response = client.put(
|
||||||
|
"/service/role/" + options[:role],
|
||||||
|
{
|
||||||
|
:cardinality => options[:cardinality]
|
||||||
|
}.to_json)
|
||||||
|
|
||||||
|
if CloudClient::is_error?(response)
|
||||||
|
puts "ERROR: "
|
||||||
|
puts response.message
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts OneGate.help_str
|
||||||
|
puts
|
||||||
|
puts "Action #{ARGV[1]} not supported"
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts OneGate.help_str
|
||||||
|
exit -1
|
||||||
|
end
|
||||||
|
|
Loading…
Reference in new issue