# Copyright:: Copyright (c) 2019 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 class Cloud class Google # Creates a Google folder as configured in {MU::Config::BasketofKittens::folders} class Folder < MU::Cloud::Folder # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat def initialize(**args) super cloud_desc if @cloud_id # XXX this maybe isn't my job @mu_name ||= @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} def create name_string = if @config['scrub_mu_isms'] @config["name"] else @deploy.getResourceName(@config["name"], max_length: 30).downcase end params = { display_name: name_string } if @config['parent']['name'] and !@config['parent']['id'] @config['parent']['deploy_id'] = @deploy.deploy_id end parent = MU::Cloud::Google::Folder.resolveParent(@config['parent'], credentials: @config['credentials']) folder_obj = MU::Cloud::Google.folder(:Folder).new(params) MU.log "Creating folder #{name_string} under #{parent}", details: folder_obj MU::Cloud::Google.folder(credentials: @config['credentials']).create_folder(folder_obj, parent: parent) # Wait for list_folders output to be consistent (for the folder we # just created to show up) retries = 0 begin found = MU::Cloud::Google::Folder.find(credentials: credentials, flags: { 'display_name' => name_string, 'parent_id' => parent }) if found.size > 0 @cloud_id = found.keys.first @parent = found.values.first.parent MU.log "Folder #{name_string} has identifier #{@cloud_id}" else if retries > 0 and (retries % 3) == 0 MU.log "Waiting for Google Cloud folder #{name_string} to appear in list_folder results...", MU::NOTICE end retries += 1 sleep 15 end end while found.size == 0 @habitat = parent end # Retrieve the IAM bindings for this folder (associates between IAM roles and groups/users) def bindings MU::Cloud::Google::Folder.bindings(@cloud_id, credentials: @config['credentials']) end # Retrieve the IAM bindings for this folder (associates between IAM roles and groups/users) # @param folder [String]: # @param credentials [String]: def self.bindings(folder, credentials: nil) MU::Cloud::Google.folder(credentials: credentials).get_folder_iam_policy(folder).bindings end # Given a {MU::Config::Folder.reference} configuration block, resolve # to a GCP resource id and type suitable for use in API calls to manage # projects and folders. # @param parentblock [Hash] # @return [String] def self.resolveParent(parentblock, credentials: nil) my_org = MU::Cloud::Google.getOrg(credentials) if my_org and (!parentblock or parentblock['id'] == my_org.name or parentblock['name'] == my_org.display_name or (parentblock['id'] and "organizations/"+parentblock['id'] == my_org.name)) return my_org.name end if parentblock['name'] sib_folder = MU::MommaCat.findStray( "Google", "folders", deploy_id: parentblock['deploy_id'], credentials: credentials, name: parentblock['name'] ).first if sib_folder return sib_folder.cloud_desc.name end end begin found = MU::Cloud::Google::Folder.find(cloud_id: parentblock['id'], credentials: credentials, flags: { 'display_name' => parentblock['name'] }) rescue ::Google::Apis::ClientError => e if !e.message.match(/Invalid request status_code: 404/) raise e end end if found and found.size > 0 return found.values.first.name end nil end @cached_cloud_desc = nil # Return the cloud descriptor for the Folder # @return [Google::Apis::Core::Hashable] def cloud_desc(use_cache: true) return @cached_cloud_desc if @cached_cloud_desc and use_cache @cached_cloud_desc = MU::Cloud::Google::Folder.find(cloud_id: @cloud_id, credentials: @config['credentials']).values.first @habitat_id ||= @cached_cloud_desc.parent.sub(/^(folders|organizations)\//, "") @cached_cloud_desc end # Return the metadata for this folders's configuration # @return [Hash] def notify desc = MU.structToHash(cloud_desc) desc["mu_name"] = @mu_name desc["parent"] = @parent desc["cloud_id"] = @cloud_id desc end # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] def self.isGlobal? true end # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality MU::Cloud::RELEASE end # Remove all Google projects associated with the currently loaded deployment. Try to, anyway. # @param noop [Boolean]: If true, will only print what would be done # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server # @return [void] def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, credentials: nil, flags: {}) filter = %Q{(labels.mu-id = "#{MU.deploy_id.downcase}")} if !ignoremaster and MU.mu_public_ip filter += %Q{ AND (labels.mu-master-ip = "#{MU.mu_public_ip.gsub(/\./, "_")}")} end MU.log "Placeholder: Google Folder artifacts do not support labels, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: filter # We can't label GCP folders, and their names are too short to encode # Mu deploy IDs, so all we can do is rely on flags['known'] passed in # from cleanup, which relies on our metadata to know what's ours. #noop = true if flags and flags['known'] threads = [] flags['known'].each { |cloud_id| threads << Thread.new { found = self.find(cloud_id: cloud_id, credentials: credentials) if found.size > 0 and found.values.first.lifecycle_state == "ACTIVE" MU.log "Deleting folder #{found.values.first.display_name} (#{found.keys.first})" if !noop max_retries = 10 retries = 0 success = false begin MU::Cloud::Google.folder(credentials: credentials).delete_folder( "folders/"+found.keys.first ) found = self.find(cloud_id: cloud_id, credentials: credentials) if found and found.size > 0 and found.values.first.lifecycle_state != "DELETE_REQUESTED" if retries < max_retries sleep 30 retries += 1 puts retries else MU.log "Folder #{cloud_id} still exists after #{max_retries.to_s} attempts to delete", MU::ERR break end else success = true end rescue ::Google::Apis::ClientError => e # XXX maybe see if the folder has disappeared already? # XXX look for child folders that haven't been deleted, that's what this tends # to mean if e.message.match(/failedPrecondition/) and retries < max_retries sleep 30 retries += 1 retry else MU.log "Got 'failedPrecondition' a bunch while trying to delete #{found.values.first.display_name} (#{found.keys.first})", MU::ERR break end end while !success end end } } threads.each { |t| t.join } end end # Locate and return cloud provider descriptors of this resource type # which match the provided parameters, or all visible resources if no # filters are specified. At minimum, implementations of +find+ must # honor +credentials+ and +cloud_id+ arguments. We may optionally # support other search methods, such as +tag_key+ and +tag_value+, or # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat # @return [Hash]: The cloud provider's complete descriptions of matching resources def self.find(**args) found = {} # Recursively search a GCP folder hierarchy for a folder matching our # supplied name or identifier. def self.find_matching_folder(parent, name: nil, id: nil, credentials: nil) resp = MU::Cloud::Google.folder(credentials: credentials).list_folders(parent: parent) if resp and resp.folders resp.folders.each { |f| if name and name.downcase == f.display_name.downcase return f elsif id and "folders/"+id== f.name return f else found = self.find_matching_folder(f.name, name: name, id: id, credentials: credentials) return found if found end } end nil end parent = if args[:flags] and args[:flags]['parent_id'] args[:flags]['parent_id'] else my_org = MU::Cloud::Google.getOrg(args[:credentials]) my_org.name end if args[:cloud_id] raw_id = args[:cloud_id].sub(/^folders\//, "") resp = MU::Cloud::Google.folder(credentials: args[:credentials]).get_folder("folders/"+raw_id) found[resp.name] = resp if resp elsif args[:flags] and args[:flags]['display_name'] if parent resp = self.find_matching_folder(parent, name: args[:flags]['display_name'], credentials: args[:credentials]) if resp found[resp.name] = resp end end else resp = MU::Cloud::Google.folder(credentials: args[:credentials]).list_folders(parent: parent) if resp and resp.folders resp.folders.each { |folder| next if folder.lifecycle_state == "DELETE_REQUESTED" found[folder.name] = folder # recurse so that we'll pick up child folders children = self.find( credentials: args[:credentials], flags: { 'parent_id' => folder.name } ) if !children.nil? and !children.empty? found.merge!(children) end } end end found end # Reverse-map our cloud description into a runnable config hash. # We assume that any values we have in +@config+ are placeholders, and # calculate our own accordingly based on what's live in the cloud. def toKitten(**args) bok = { "cloud" => "Google", "credentials" => @config['credentials'] } bok['display_name'] = cloud_desc.display_name bok['cloud_id'] = cloud_desc.name bok['name'] = cloud_desc.display_name#+bok['cloud_id'] # only way to guarantee uniqueness if cloud_desc.parent.match(/^folders\/(.*)/) bok['parent'] = MU::Config::Ref.get( id: cloud_desc.parent, cloud: "Google", credentials: @config['credentials'], type: "folders" ) elsif args[:rootparent] bok['parent'] = { 'id' => args[:rootparent].is_a?(String) ? args[:rootparent] : args[:rootparent].cloud_desc.name } else bok['parent'] = { 'id' => cloud_desc.parent } end bok end # Cloud-specific configuration properties. # @param _config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(_config) toplevel_required = [] schema = { "display_name" => { "type" => "string", "description" => "The +display_name+ field of this folder, specified only if we want it to be something other than the automatically-generated string derived from the +name+ field.", } } [toplevel_required, schema] end # Cloud-specific pre-processing of {MU::Config::BasketofKittens::folders}, bare and unvalidated. # @param folder [Hash]: The resource to process and validate # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(folder, configurator) ok = true if !MU::Cloud::Google.getOrg(folder['credentials']) MU.log "Cannot manage Google Cloud folders in environments without an organization", MU::ERR ok = false end if folder['parent'] and folder['parent']['name'] and !folder['parent']['deploy_id'] and configurator.haveLitterMate?(folder['parent']['name'], "folders") MU::Config.addDependency(folder, folder['parent']['name'], "folder") end ok end end end end end