modules/mu/mommacat/naming.rb
# Copyright:: Copyright (c) 2020 eGlobalTech, Inc., all rights reserved
#
# Licensed under the BSD-3 license (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License in the root of the project or at
#
# http://egt-labs.com/mu/LICENSE.html
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
module MU
# MommaCat is in charge of managing metadata about resources we've created,
# as well as orchestrating amongst them and bootstrapping nodes outside of
# the normal synchronous deploy sequence invoked by *mu-deploy*.
class MommaCat
# Lookup table to translate the word "habitat" back to its
# provider-specific jargon
HABITAT_SYNONYMS = {
"AWS" => "account",
"CloudFormation" => "account",
"Google" => "project",
"Azure" => "subscription",
"VMWare" => "sddc"
}
# Given a cloud provider's native descriptor for a resource, make some
# reasonable guesses about what the thing's name should be.
def self.guessName(desc, resourceclass, cloud_id: nil, tag_value: nil)
if desc.respond_to?(:tags) and
desc.tags.is_a?(Array) and
desc.tags.first.respond_to?(:key) and
desc.tags.map { |t| t.key }.include?("Name")
desc.tags.select { |t| t.key == "Name" }.first.value
else
try = nil
# Various GCP fields
[:display_name, :name, (resourceclass.cfg_name+"_name").to_sym].each { |field|
if desc.respond_to?(field) and desc.send(field).is_a?(String)
try = desc.send(field)
break
end
}
try ||= if !tag_value.nil?
tag_value
else
cloud_id
end
try
end
end
# Given a piece of a BoK resource descriptor Hash, come up with shorthand
# strings to give it a name for human readers. If nothing reasonable can be
# extracted, returns nil.
# @param obj [Hash]
# @param array_of [String]
# @param habitat_translate [String]
# @return [Array<String,nil>]
def self.getChunkName(obj, array_of = nil, habitat_translate: nil)
return [nil, nil] if obj.nil?
if [String, Integer, Boolean].include?(obj.class)
return [obj, nil]
end
obj_type = array_of || obj['type']
obj_name = obj['name'] || obj['id'] || obj['mu_name'] || obj['cloud_id']
name_string = if obj_name
if obj_type
"#{obj_type}[#{obj_name}]"
else
obj_name.dup
end
else
found_it = nil
using = nil
["entity", "role"].each { |subtype|
if obj[subtype] and obj[subtype].is_a?(Hash)
found_it = if obj[subtype]["id"]
obj[subtype]['id'].dup
elsif obj[subtype]["type"] and obj[subtype]["name"]
"#{obj[subtype]['type']}[#{obj[subtype]['name']}]"
end
break
end
}
found_it
end
if name_string
name_string.gsub!(/\[.+?\](\[.+?\]$)/, '\1')
if habitat_translate and HABITAT_SYNONYMS[habitat_translate]
name_string.sub!(/^habitats?\[(.+?)\]/i, HABITAT_SYNONYMS[habitat_translate]+'[\1]')
end
end
location_list = []
location = if obj['project']
obj['project']
elsif obj['habitat'] and (obj['habitat']['id'] or obj['habitat']['name'])
obj['habitat']['name'] || obj['habitat']['id']
else
hab_str = nil
['projects', 'habitats'].each { |key|
if obj[key] and obj[key].is_a?(Array)
location_list = obj[key].sort.map { |p|
(p["name"] || p["id"]).gsub(/^.*?[^\/]+\/([^\/]+)$/, '\1')
}
hab_str = location_list.join(", ")
name_string.gsub!(/^.*?[^\/]+\/([^\/]+)$/, '\1') if name_string
break
end
}
hab_str
end
[name_string, location, location_list]
end
# Generate a three-character string which can be used to unique-ify the
# names of resources which might potentially collide, e.g. Windows local
# hostnames, Amazon Elastic Load Balancers, or server pool instances.
# @return [String]: A three-character string consisting of two alphnumeric
# characters (uppercase) and one number.
def self.genUniquenessString
begin
candidate = SecureRandom.base64(2).slice(0..1) + SecureRandom.random_number(9).to_s
candidate.upcase!
end while candidate.match(/[^A-Z0-9]/)
return candidate
end
@unique_map_semaphore = Mutex.new
@name_unique_str_map = {}
# Keep a map of the uniqueness strings we assign to various full names, in
# case we want to reuse them later.
# @return [Hash<String>]
def self.name_unique_str_map
@name_unique_str_map
end
# Keep a map of the uniqueness strings we assign to various full names, in
# case we want to reuse them later.
# @return [Mutex]
def self.unique_map_semaphore
@unique_map_semaphore
end
# Generate a name string for a resource, incorporate the MU identifier
# for this deployment. Will dynamically shorten the name to fit for
# restrictive uses (e.g. Windows local hostnames, Amazon Elastic Load
# Balancers).
# @param name [String]: The shorthand name of the resource, usually the value of the "name" field in an Mu resource declaration.
# @param max_length [Integer]: The maximum length of the resulting resource name.
# @param need_unique_string [Boolean]: Whether to forcibly append a random three-character string to the name to ensure it's unique. Note that this behavior will be automatically invoked if the name must be truncated.
# @param scrub_mu_isms [Boolean]: Don't bother with generating names specific to this deployment. Used to generate generic CloudFormation templates, amongst other purposes.
# @param disallowed_chars [Regexp]: A pattern of characters that are illegal for this resource name, such as +/[^a-zA-Z0-9-]/+
# @return [String]: A full name string for this resource
def getResourceName(name, max_length: 255, need_unique_string: false, use_unique_string: nil, reuse_unique_string: false, scrub_mu_isms: @original_config['scrub_mu_isms'], disallowed_chars: nil, never_gen_unique: false)
if name.nil?
raise MuError, "Got no argument to MU::MommaCat.getResourceName"
end
if @appname.nil? or @environment.nil? or @timestamp.nil? or @seed.nil?
MU.log "getResourceName: Missing global deploy variables in thread #{Thread.current.object_id}, using bare name '#{name}' (appname: #{@appname}, environment: #{@environment}, timestamp: #{@timestamp}, seed: #{@seed}, deploy_id: #{@deploy_id}", MU::WARN, details: caller
return name
end
need_unique_string = false if scrub_mu_isms
muname = nil
if need_unique_string
reserved = 4
else
reserved = 0
end
# First, pare down the base name string until it will fit
basename = @appname.upcase + "-" + @environment.upcase + "-" + @timestamp + "-" + @seed.upcase + "-" + name.upcase
if scrub_mu_isms
basename = @appname.upcase + "-" + @environment.upcase + name.upcase
end
subchar = if disallowed_chars
if "-".match(disallowed_chars)
if !"_".match(disallowed_chars)
"_"
else
""
end
else
"-"
end
end
if disallowed_chars
basename.gsub!(disallowed_chars, subchar) if disallowed_chars
end
attempts = 0
begin
if (basename.length + reserved) > max_length
MU.log "Stripping name down from #{basename}[#{basename.length.to_s}] (reserved: #{reserved.to_s}, max_length: #{max_length.to_s})", MU::DEBUG
if basename == @appname.upcase + "-" + @seed.upcase + "-" + name.upcase
# If we've run out of stuff to strip, truncate what's left and
# just leave room for the deploy seed and uniqueness string. This
# is the bare minimum, and probably what you'll see for most Windows
# hostnames.
basename = name.upcase + "-" + @appname.upcase
basename.slice!((max_length-(reserved+3))..basename.length)
basename.sub!(/-$/, "")
basename = basename + "-" + @seed.upcase
basename.gsub!(disallowed_chars, subchar) if disallowed_chars
else
# If we have to strip anything, assume we've lost uniqueness and
# will have to compensate with #genUniquenessString.
need_unique_string = true if !never_gen_unique
reserved = 4
basename.sub!(/-[^-]+-#{@seed.upcase}-#{Regexp.escape(name.upcase)}$/, "")
basename = basename + "-" + @seed.upcase + "-" + name.upcase
basename.gsub!(disallowed_chars, subchar) if disallowed_chars
end
end
attempts += 1
raise MuError, "Failed to generate a reasonable name getResourceName(#{name}, max_length: #{max_length.to_s}, need_unique_string: #{need_unique_string.to_s}, use_unique_string: #{use_unique_string.to_s}, reuse_unique_string: #{reuse_unique_string.to_s}, scrub_mu_isms: #{scrub_mu_isms.to_s}, disallowed_chars: #{disallowed_chars})" if attempts > 10
end while (basename.length + reserved) > max_length
# Finally, apply our short random differentiator, if it's needed.
if need_unique_string
# Preferentially use a requested one, if it's not already in use.
if !use_unique_string.nil?
muname = basename + "-" + use_unique_string
if !allocateUniqueResourceName(muname) and !reuse_unique_string
MU.log "Requested to use #{use_unique_string} as differentiator when naming #{name}, but the name #{muname} is unavailable.", MU::WARN
muname = nil
end
end
if !muname
begin
unique_string = MU::MommaCat.genUniquenessString
muname = basename + "-" + unique_string
end while !allocateUniqueResourceName(muname)
MU::MommaCat.unique_map_semaphore.synchronize {
MU::MommaCat.name_unique_str_map[muname] = unique_string
}
end
else
muname = basename
end
muname.gsub!(disallowed_chars, subchar) if disallowed_chars
return muname
end
# List the name/value pairs for our mandatory standard set of resource tags, which
# should be applied to all taggable cloud provider resources.
# @return [Hash<String,String>]
def self.listStandardTags
return {} if !MU.deploy_id
{
"MU-ID" => MU.deploy_id,
"MU-APP" => MU.appname,
"MU-ENV" => MU.environment,
"MU-MASTER-IP" => MU.mu_public_ip
}
end
# List the name/value pairs for our mandatory standard set of resource tags
# for this deploy.
# @return [Hash<String,String>]
def listStandardTags
{
"MU-ID" => @deploy_id,
"MU-APP" => @appname,
"MU-ENV" => @environment,
"MU-MASTER-IP" => MU.mu_public_ip
}
end
# List the name/value pairs of our optional set of resource tags which
# should be applied to all taggable cloud provider resources.
# @return [Hash<String,String>]
def self.listOptionalTags
return {
"MU-HANDLE" => MU.handle,
"MU-MASTER-NAME" => Socket.gethostname,
"MU-OWNER" => MU.mu_user
}
end
# Make sure the given node has proper DNS entries, /etc/hosts entries,
# SSH config entries, etc.
# @param server [MU::Cloud::Server]: The {MU::Cloud::Server} we'll be setting up.
# @param sync_wait [Boolean]: Whether to wait for DNS to fully synchronize before returning.
def self.nameKitten(server, sync_wait: false, no_dns: false)
node, config, _deploydata = server.describe
mu_zone = nil
# XXX GCP!
if !no_dns and MU::Cloud::AWS.hosted? and !MU::Cloud::AWS.isGovCloud?
zones = MU::Cloud::DNSZone.find(cloud_id: "platform-mu")
mu_zone = zones.values.first if !zones.nil?
end
if !mu_zone.nil?
MU::Cloud::DNSZone.genericMuDNSEntry(name: node.gsub(/[^a-z0-9!"\#$%&'\(\)\*\+,\-\/:;<=>\?@\[\]\^_`{\|}~\.]/, '-').gsub(/--|^-/, ''), target: server.canonicalIP, cloudclass: MU::Cloud::Server, sync_wait: sync_wait)
else
MU::Master.addInstanceToEtcHosts(server.canonicalIP, node)
end
## TO DO: Do DNS registration of "real" records as the last stage after the groomer completes
if config && config['dns_records'] && !config['dns_records'].empty?
dnscfg = config['dns_records'].dup
dnscfg.each { |dnsrec|
if !dnsrec.has_key?('name')
dnsrec['name'] = node.downcase
dnsrec['name'] = "#{dnsrec['name']}.#{MU.environment.downcase}" if dnsrec["append_environment_name"] && !dnsrec['name'].match(/\.#{MU.environment.downcase}$/)
end
if !dnsrec.has_key?("target")
# Default to register public endpoint
public = true
if dnsrec.has_key?("target_type")
# See if we have a preference for pubic/private endpoint
public = dnsrec["target_type"] == "private" ? false : true
end
dnsrec["target"] =
if dnsrec["type"] == "CNAME"
if public
# Make sure we have a public canonical name to register. Use the private one if we don't
server.cloud_desc.public_dns_name.empty? ? server.cloud_desc.private_dns_name : server.cloud_desc.public_dns_name
else
# If we specifically requested to register the private canonical name lets use that
server.cloud_desc.private_dns_name
end
elsif dnsrec["type"] == "A"
if public
# Make sure we have a public IP address to register. Use the private one if we don't
server.cloud_desc.public_ip_address ? server.cloud_desc.public_ip_address : server.cloud_desc.private_ip_address
else
# If we specifically requested to register the private IP lets use that
server.cloud_desc.private_ip_address
end
end
end
}
if !MU::Cloud::AWS.isGovCloud?
MU::Cloud::DNSZone.createRecordsFromConfig(dnscfg)
end
end
MU::Master.removeHostFromSSHConfig(node)
if server and server.canonicalIP
MU::Master.removeIPFromSSHKnownHosts(server.canonicalIP)
end
# XXX add names paramater with useful stuff
MU::Master.addHostToSSHConfig(
server,
ssh_owner: server.deploy.mu_user,
ssh_dir: Etc.getpwnam(server.deploy.mu_user).dir+"/.ssh"
)
end
# Manufactures a human-readable deployment name from the random
# two-character seed in MU-ID. Cat-themed when possible.
# @param seed [String]: A two-character seed from which we'll generate a name.
# @return [String]: Two words
def self.generateHandle(seed)
word_one=word_two=nil
# Unless we've got two letters that don't have corresponding cat-themed
# words, we'll insist that our generated handle have at least one cat
# element to it.
require_cat_words = true
if @catwords.select { |word| word.match(/^#{seed[0]}/i) }.size == 0 and
@catwords.select { |word| word.match(/^#{seed[1]}/i) }.size == 0
require_cat_words = false
MU.log "Got an annoying pair of letters #{seed}, not forcing cat-theming", MU::DEBUG
end
allnouns = @catnouns + @jaegernouns
alladjs = @catadjs + @jaegeradjs
tries = 0
begin
# Try to avoid picking something "nouny" for the first word
source = @catadjs + @catmixed + @jaegeradjs + @jaegermixed
first_ltr = source.select { |word| word.match(/^#{seed[0]}/i) }
if !first_ltr or first_ltr.size == 0
first_ltr = @words.select { |word| word.match(/^#{seed[0]}/i) }
end
word_one = first_ltr.shuffle.first
# If we got a paired set that happen to match our letters, go with it
if !word_one.nil? and word_one.match(/-#{seed[1]}/i)
word_one, word_two = word_one.split(/-/)
else
source = @words
if @catwords.include?(word_one)
source = @jaegerwords
elsif require_cat_words
source = @catwords
end
second_ltr = source.select { |word| word.match(/^#{seed[1]}/i) and !word.match(/-/i) }
word_two = second_ltr.shuffle.first
end
tries = tries + 1
end while tries < 50 and (word_one.nil? or word_two.nil? or word_one.match(/-/) or word_one == word_two or (allnouns.include?(word_one) and allnouns.include?(word_two)) or (alladjs.include?(word_one) and alladjs.include?(word_two)) or (require_cat_words and !@catwords.include?(word_one) and !@catwords.include?(word_two) and !@catwords.include?(word_one+"-"+word_two)))
if tries >= 50 and (word_one.nil? or word_two.nil?)
MU.log "I failed to generated a valid handle from #{seed}, faking it", MU::ERR
return "#{seed[0].capitalize} #{seed[1].capitalize}"
end
return "#{word_one.capitalize} #{word_two.capitalize}"
end
private
# Check to see whether a given resource name is unique across all
# deployments on this Mu server. We only enforce this for certain classes
# of names. If the name in question is available, add it to our cache of
# said names. See #{MU::MommaCat.getResourceName}
# @param name [String]: The name to attempt to allocate.
# @return [Boolean]: True if allocation was successful.
def allocateUniqueResourceName(name)
raise MuError, "Cannot call allocateUniqueResourceName without an active deployment" if @deploy_id.nil?
path = File.expand_path(MU.dataDir+"/deployments")
File.open(path+"/unique_ids", File::CREAT|File::RDWR, 0600) { |f|
existing = []
f.flock(File::LOCK_EX)
f.readlines.each { |line|
existing << line.chomp
}
begin
existing.each { |used|
if used.match(/^#{name}:/)
if !used.match(/^#{name}:#{@deploy_id}$/)
MU.log "#{name} is already reserved by another resource on this Mu server.", MU::WARN, details: caller
return false
else
return true
end
end
}
f.puts name+":"+@deploy_id
return true
ensure
f.flock(File::LOCK_UN)
end
}
end
# 2019-06-03 adding things from https://aiweirdness.com/post/185339301987/once-again-a-neural-net-tries-to-name-cats
@catadjs = %w{fuzzy ginger lilac chocolate xanthic wiggly itty chonky norty slonky floofy heckin bebby}
@catnouns = %w{bastet biscuits bobcat catnip cheetah chonk dot felix hamb hambina jaguar kitty leopard lion lynx maru mittens moggy neko nip ocelot panther patches paws phoebe purr queen roar saber sekhmet skogkatt socks sphinx spot tail tiger tom whiskers wildcat yowl floof beans ailurophile dander dewclaw grimalkin kibble quick tuft misty simba slonk mew quat eek ziggy whiskeridoo cromch monch screm}
@catmixed = %w{abyssinian angora bengal birman bobtail bombay burmese calico chartreux cheshire cornish-rex curl devon egyptian-mau feline furever fumbs havana himilayan japanese-bobtail javanese khao-manee maine-coon manx marmalade mau munchkin norwegian pallas persian peterbald polydactyl ragdoll russian-blue savannah scottish-fold serengeti shorthair siamese siberian singapura snowshoe stray tabby tonkinese tortoiseshell turkish-van tuxedo uncia caterwaul lilac-point chocolate-point mackerel maltese knead whitenose vorpal chewie-bean chicken-whiskey fish-especially thelonious-monsieur tom-glitter serendipitous-kill sparky-buttons nip-nops murder-mittens bite}
@catwords = @catadjs + @catnouns + @catmixed
@jaegeradjs = %w{azure fearless lucky olive vivid electric grey yarely violet ivory jade cinnamon crimson tacit umber mammoth ultra iron zodiac}
@jaegernouns = %w{horizon hulk ultimatum yardarm watchman whilrwind wright rhythm ocean enigma eruption typhoon jaeger brawler blaze vandal excalibur paladin juliet kaleidoscope romeo}
@jaegermixed = %w{alpha ajax amber avenger brave bravo charlie chocolate chrome corinthian dancer danger dash delta duet echo edge elite eureka foxtrot guardian gold hyperion illusion imperative india intercept kilo lancer night nova november oscar omega pacer quickstrike rogue ronin striker tango titan valor victor vulcan warder xenomorph xenon xray xylem yankee yell yukon zeal zero zoner zodiac}
@jaegerwords = @jaegeradjs + @jaegernouns + @jaegermixed
@words = @catwords + @jaegerwords
end #class
end #module