Merge pull request #4 from puppetlabs/add-cloud-dns

Adding the cloud DNS API library and related methods
This commit is contained in:
Gene Liverman 2022-01-10 16:05:55 -05:00 committed by GitHub
commit 480796fe39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 217 additions and 66 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ Gemfile.local
results.xml results.xml
/vmpooler.yaml /vmpooler.yaml
.idea .idea
*.json

View file

@ -36,16 +36,18 @@ Style/SwapValues: # (new in 1.1)
#disabled #disabled
Metrics/AbcSize: Metrics/AbcSize:
Max: 77 Enabled: false
Metrics/ClassLength: Metrics/ClassLength:
Max: 430 Enabled: false
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 14 Enabled: false
Metrics/MethodLength: Metrics/MethodLength:
Max: 48 Enabled: false
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 14 Enabled: false
Metrics/ParameterLists: Metrics/ParameterLists:
Max: 6 Enabled: false
Layout/LineLength: Layout/LineLength:
Max: 220 Enabled: false
Metrics/BlockLength:
Enabled: false

View file

@ -14,6 +14,10 @@ GCE authorization is handled via a service account (or personal account) private
1. GOOGLE_APPLICATION_CREDENTIALS environment variable eg GOOGLE_APPLICATION_CREDENTIALS=/my/home/directory/my_account_key.json 1. GOOGLE_APPLICATION_CREDENTIALS environment variable eg GOOGLE_APPLICATION_CREDENTIALS=/my/home/directory/my_account_key.json
### DNS
DNS is integrated via Google's CloudDNS service. To enable, a CloudDNS zone name must be provided in the config (see the example yaml file dns_zone_resource_name)
An A record is then created in that zone upon instance creation with the VM's internal IP, and deleted when the instance is destroyed.
### Labels ### Labels
This provider adds labels to all resources that are managed This provider adds labels to all resources that are managed
@ -27,6 +31,13 @@ This provider adds labels to all resources that are managed
Also see the usage of vmpooler's optional purge_unconfigured_resources, which is used to delete any resource found that Also see the usage of vmpooler's optional purge_unconfigured_resources, which is used to delete any resource found that
do not have the pool label, and can be configured to allow a specific list of unconfigured pool names. do not have the pool label, and can be configured to allow a specific list of unconfigured pool names.
### Pre-requisite
- A service account needs to be created and a private json key generated (see usage section)
- The service account needs to be given permissions to the project (broad permissions would be compute v1 admin and dns admin). A yaml file is provided that lists the least-privilege permissions needed
- if using DNS, a DNS zone needs to be created in CloudDNS, and configured in the provider's config section with the name of that zone (dns_zone_resource_name). When not specified, the DNS setup and teardown is skipped.
## License ## License
vmpooler-provider-gce is distributed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). See the [LICENSE](LICENSE) file for more details. vmpooler-provider-gce is distributed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). See the [LICENSE](LICENSE) file for more details.

View file

@ -2,6 +2,7 @@
require 'googleauth' require 'googleauth'
require 'google/apis/compute_v1' require 'google/apis/compute_v1'
require 'google/cloud/dns'
require 'bigdecimal' require 'bigdecimal'
require 'bigdecimal/util' require 'bigdecimal/util'
require 'vmpooler/providers/base' require 'vmpooler/providers/base'
@ -57,6 +58,11 @@ module Vmpooler
end end
end end
def dns
@dns ||= Google::Cloud::Dns.new(project_id: project)
@dns
end
# main configuration options # main configuration options
def project def project
provider_config['project'] provider_config['project']
@ -66,6 +72,10 @@ module Vmpooler
provider_config['network_name'] provider_config['network_name']
end end
def subnetwork_name(pool_name)
return pool_config(pool_name)['subnetwork_name'] if pool_config(pool_name)['subnetwork_name']
end
# main configuration options, overridable for each pool # main configuration options, overridable for each pool
def zone(pool_name) def zone(pool_name)
return pool_config(pool_name)['zone'] if pool_config(pool_name)['zone'] return pool_config(pool_name)['zone'] if pool_config(pool_name)['zone']
@ -77,6 +87,14 @@ module Vmpooler
return provider_config['machine_type'] if provider_config['machine_type'] return provider_config['machine_type'] if provider_config['machine_type']
end end
def domain
provider_config['domain']
end
def dns_zone_resource_name
provider_config['dns_zone_resource_name']
end
# Base methods that are implemented: # Base methods that are implemented:
# vms_in_pool lists all the VM names in a pool, which is based on the VMs # vms_in_pool lists all the VM names in a pool, which is based on the VMs
@ -117,7 +135,7 @@ module Vmpooler
# [String] hostname : Specifies the hostname of the instance. The specified hostname must be RFC1035 compliant. If hostname is not specified, # [String] hostname : Specifies the hostname of the instance. The specified hostname must be RFC1035 compliant. If hostname is not specified,
# the default hostname is [ INSTANCE_NAME].c.[PROJECT_ID].internal when using the global DNS, and # the default hostname is [ INSTANCE_NAME].c.[PROJECT_ID].internal when using the global DNS, and
# [ INSTANCE_NAME].[ZONE].c.[PROJECT_ID].internal when using zonal DNS # [ INSTANCE_NAME].[ZONE].c.[PROJECT_ID].internal when using zonal DNS
# [String] template : This is the name of template exposed by the API. It must _match_ the poolname ??? TODO # [String] template : This is the name of template
# [String] poolname : Name of the pool the VM as per labels # [String] poolname : Name of the pool the VM as per labels
# [Time] boottime : Time when the VM was created/booted # [Time] boottime : Time when the VM was created/booted
# [String] status : One of the following values: PROVISIONING, STAGING, RUNNING, STOPPING, SUSPENDING, SUSPENDED, REPAIRING, and TERMINATED # [String] status : One of the following values: PROVISIONING, STAGING, RUNNING, STOPPING, SUSPENDING, SUSPENDED, REPAIRING, and TERMINATED
@ -162,6 +180,7 @@ module Vmpooler
network_interfaces = Google::Apis::ComputeV1::NetworkInterface.new( network_interfaces = Google::Apis::ComputeV1::NetworkInterface.new(
network: network_name network: network_name
) )
network_interfaces.subnetwork = subnetwork_name(pool_name) if subnetwork_name(pool_name)
init_params = { init_params = {
source_image: pool['template'], # The source image to create this disk. source_image: pool['template'], # The source image to create this disk.
labels: { 'vm' => new_vmname, 'pool' => pool_name }, labels: { 'vm' => new_vmname, 'pool' => pool_name },
@ -172,19 +191,22 @@ module Vmpooler
boot: true, boot: true,
initialize_params: Google::Apis::ComputeV1::AttachedDiskInitializeParams.new(init_params) initialize_params: Google::Apis::ComputeV1::AttachedDiskInitializeParams.new(init_params)
) )
# Assume all pool config is valid i.e. not missing # Assume all pool config is valid i.e. not missing
client = ::Google::Apis::ComputeV1::Instance.new( client = ::Google::Apis::ComputeV1::Instance.new(
name: new_vmname, name: new_vmname,
machine_type: pool['machine_type'], machine_type: pool['machine_type'],
disks: [disk], disks: [disk],
network_interfaces: [network_interfaces], network_interfaces: [network_interfaces],
labels: { 'vm' => new_vmname, 'pool' => pool_name } labels: { 'vm' => new_vmname, 'pool' => pool_name },
tags: Google::Apis::ComputeV1::Tags.new(items: [project])
) )
debug_logger('trigger insert_instance') debug_logger('trigger insert_instance')
result = connection.insert_instance(project, zone(pool_name), client) result = connection.insert_instance(project, zone(pool_name), client)
wait_for_operation(project, pool_name, result) wait_for_operation(project, pool_name, result)
get_vm(pool_name, new_vmname) created_instance = get_vm(pool_name, new_vmname)
dns_setup(created_instance)
created_instance
end end
# create_disk creates an additional disk for an existing VM. It will name the new # create_disk creates an additional disk for an existing VM. It will name the new
@ -398,8 +420,10 @@ module Vmpooler
unless deleted unless deleted
debug_logger("trigger delete_instance #{vm_name}") debug_logger("trigger delete_instance #{vm_name}")
vm_hash = get_vm(pool_name, vm_name)
result = connection.delete_instance(project, zone(pool_name), vm_name) result = connection.delete_instance(project, zone(pool_name), vm_name)
wait_for_operation(project, pool_name, result, 10) wait_for_operation(project, pool_name, result, 10)
dns_teardown(vm_hash)
end end
# list and delete any leftover disk, for instance if they were detached from the instance # list and delete any leftover disk, for instance if they were detached from the instance
@ -437,7 +461,7 @@ module Vmpooler
def vm_ready?(_pool_name, vm_name) def vm_ready?(_pool_name, vm_name)
begin begin
# TODO: we could use a healthcheck resource attached to instance # TODO: we could use a healthcheck resource attached to instance
open_socket(vm_name, global_config[:config]['domain']) open_socket(vm_name, domain || global_config[:config]['domain'])
rescue StandardError => _e rescue StandardError => _e
return false return false
end end
@ -469,6 +493,9 @@ module Vmpooler
debug_logger("trigger async delete_instance #{vm.name}") debug_logger("trigger async delete_instance #{vm.name}")
result = connection.delete_instance(project, zone, vm.name) result = connection.delete_instance(project, zone, vm.name)
vm_pool = vm.labels&.key?('pool') ? vm.labels['pool'] : nil
existing_vm = generate_vm_hash(vm, vm_pool)
dns_teardown(existing_vm)
result_list << result result_list << result
end end
# now check they are done # now check they are done
@ -529,6 +556,31 @@ module Vmpooler
# END BASE METHODS # END BASE METHODS
def dns_setup(created_instance)
dns_zone = dns.zone(dns_zone_resource_name) if dns_zone_resource_name
return unless dns_zone && created_instance && created_instance['name'] && created_instance['ip']
name = created_instance['name']
begin
change = dns_zone.add(name, 'A', 60, [created_instance['ip']])
debug_logger("#{change.id} - #{change.started_at} - #{change.status} DNS address added") if change
rescue Google::Cloud::AlreadyExistsError => _e
# DNS setup is done only for new instances, so in the rare case where a DNS record already exists (it is stale) and we replace it.
# the error is Google::Cloud::AlreadyExistsError: alreadyExists: The resource 'entity.change.additions[0]' named 'instance-8.test.vmpooler.net. (A)' already exists
change = dns_zone.replace(name, 'A', 60, [created_instance['ip']])
debug_logger("#{change.id} - #{change.started_at} - #{change.status} DNS address previously existed and was replaced") if change
end
end
def dns_teardown(created_instance)
dns_zone = dns.zone(dns_zone_resource_name) if dns_zone_resource_name
return unless dns_zone && created_instance
name = created_instance['name']
change = dns_zone.remove(name, 'A')
debug_logger("#{change.id} - #{change.started_at} - #{change.status} DNS address removed") if change
end
def should_be_ignored(item, allowlist) def should_be_ignored(item, allowlist)
return false if allowlist.nil? return false if allowlist.nil?
@ -565,7 +617,7 @@ module Vmpooler
if result.error # unsure what kind of error can be stored here if result.error # unsure what kind of error can be stored here
error_message = '' error_message = ''
# array of errors, combine them all # array of errors, combine them all
result.error.each do |error| result.error.errors.each do |error|
error_message = "#{error_message} #{error.code}:#{error.message}" error_message = "#{error_message} #{error.code}:#{error.message}"
end end
raise "Operation: #{result.description} failed with error: #{error_message}" raise "Operation: #{result.description} failed with error: #{error_message}"
@ -591,7 +643,7 @@ module Vmpooler
end end
# Return a hash of VM data # Return a hash of VM data
# Provides vmname, hostname, template, poolname, boottime, status, zone, machine_type information # Provides vmname, hostname, template, poolname, boottime, status, zone, machine_type, labels, label_fingerprint, ip information
def generate_vm_hash(vm_object, pool_name) def generate_vm_hash(vm_object, pool_name)
pool_configuration = pool_config(pool_name) pool_configuration = pool_config(pool_name)
return nil if pool_configuration.nil? return nil if pool_configuration.nil?
@ -599,15 +651,15 @@ module Vmpooler
{ {
'name' => vm_object.name, 'name' => vm_object.name,
'hostname' => vm_object.hostname, 'hostname' => vm_object.hostname,
'template' => pool_configuration&.key?('template') ? pool_configuration['template'] : nil, # TODO: get it from the API, not from config, but this is what vSphere does too! 'template' => pool_configuration&.key?('template') ? pool_configuration['template'] : nil, # was expecting to get it from API, not from config, but this is what vSphere does too!
'poolname' => vm_object.labels&.key?('pool') ? vm_object.labels['pool'] : nil, 'poolname' => vm_object.labels&.key?('pool') ? vm_object.labels['pool'] : nil,
'boottime' => vm_object.creation_timestamp, 'boottime' => vm_object.creation_timestamp,
'status' => vm_object.status, # One of the following values: PROVISIONING, STAGING, RUNNING, STOPPING, SUSPENDING, SUSPENDED, REPAIRING, and TERMINATED 'status' => vm_object.status, # One of the following values: PROVISIONING, STAGING, RUNNING, STOPPING, SUSPENDING, SUSPENDED, REPAIRING, and TERMINATED
'zone' => vm_object.zone, 'zone' => vm_object.zone,
'machine_type' => vm_object.machine_type, 'machine_type' => vm_object.machine_type,
'labels' => vm_object.labels, 'labels' => vm_object.labels,
'label_fingerprint' => vm_object.label_fingerprint 'label_fingerprint' => vm_object.label_fingerprint,
# 'powerstate' => powerstate 'ip' => vm_object.network_interfaces ? vm_object.network_interfaces.first.network_ip : nil
} }
end end

View file

@ -0,0 +1,39 @@
title: Custom vmpooler provider
description: for the vmpooler provider
stage: GA
includedPermissions:
- compute.disks.create
- compute.disks.createSnapshot
- compute.disks.delete
- compute.disks.get
- compute.disks.list
- compute.disks.setLabels
- compute.disks.use
- compute.instances.attachDisk
- compute.instances.create
- compute.instances.delete
- compute.instances.detachDisk
- compute.instances.get
- compute.instances.list
- compute.instances.setLabels
- compute.instances.setTags
- compute.instances.start
- compute.instances.stop
- compute.snapshots.create
- compute.snapshots.delete
- compute.snapshots.get
- compute.snapshots.list
- compute.snapshots.setLabels
- compute.snapshots.useReadOnly
- compute.subnetworks.use
- compute.zoneOperations.get
- dns.changes.create
- dns.changes.get
- dns.changes.list
- dns.managedZones.get
- dns.managedZones.list
- dns.resourceRecordSets.create
- dns.resourceRecordSets.update
- dns.resourceRecordSets.delete
- dns.resourceRecordSets.get
- dns.resourceRecordSets.list

View file

@ -0,0 +1,5 @@
#!/bin/bash
#set your project name in GCE here
project_id="vmpooler-test"
#this creates a custom role, that should then be applied to a service account used to run the API requests.
gcloud iam roles create Customvmpoolerprovider --project=$project-id --file=GCE_custom_role_for_SA.yaml

View file

@ -0,0 +1,9 @@
MockDNS = Struct.new(
# https://rubydoc.info/gems/google-cloud-dns/0.35.1/Google/Cloud/Dns
:change, :credentials, :project, :record, :zone,
keyword_init: true
) do
def zone(zone)
self.zone = zone
end
end

View file

@ -10,6 +10,7 @@ require 'vmpooler'
require 'redis' require 'redis'
require 'vmpooler/metrics' require 'vmpooler/metrics'
require 'computeservice_helper' require 'computeservice_helper'
require 'dnsservice_helper'
def project_root_dir def project_root_dir
File.dirname(File.dirname(__FILE__)) File.dirname(File.dirname(__FILE__))

View file

@ -11,20 +11,20 @@ describe 'Vmpooler::PoolManager::Provider::Gce' do
let(:metrics) { Vmpooler::Metrics::DummyStatsd.new } let(:metrics) { Vmpooler::Metrics::DummyStatsd.new }
let(:poolname) { 'debian-9' } let(:poolname) { 'debian-9' }
let(:provider_options) { { 'param' => 'value' } } let(:provider_options) { { 'param' => 'value' } }
let(:project) { 'dio-samuel-dev' } let(:project) { 'vmpooler-test' }
let(:zone) { 'us-west1-b' } let(:zone) { 'us-west1-b' }
let(:config) { YAML.load(<<-EOT let(:config) { YAML.load(<<~EOT
--- ---
:config: :config:
max_tries: 3 max_tries: 3
retry_factor: 10 retry_factor: 10
:providers: :providers:
:gce: :gce:
connection_pool_timeout: 1 connection_pool_timeout: 1
project: '#{project}' project: '#{project}'
zone: '#{zone}' zone: '#{zone}'
network_name: 'global/networks/default' network_name: global/networks/default
:pools: :pools:
- name: '#{poolname}' - name: '#{poolname}'
alias: [ 'mockpool' ] alias: [ 'mockpool' ]
template: 'projects/debian-cloud/global/images/family/debian-9' template: 'projects/debian-cloud/global/images/family/debian-9'
@ -32,13 +32,12 @@ describe 'Vmpooler::PoolManager::Provider::Gce' do
timeout: 10 timeout: 10
ready_ttl: 1440 ready_ttl: 1440
provider: 'gce' provider: 'gce'
network_name: 'default'
machine_type: 'zones/#{zone}/machineTypes/e2-micro' machine_type: 'zones/#{zone}/machineTypes/e2-micro'
EOT EOT
) )
} }
let(:vmname) { 'vm16' } let(:vmname) { 'vm17' }
let(:connection) { MockComputeServiceConnection.new } let(:connection) { MockComputeServiceConnection.new }
let(:redis_connection_pool) do let(:redis_connection_pool) do
Vmpooler::PoolManager::GenericConnectionPool.new( Vmpooler::PoolManager::GenericConnectionPool.new(
@ -52,6 +51,8 @@ EOT
subject { Vmpooler::PoolManager::Provider::Gce.new(config, logger, metrics, redis_connection_pool, 'gce', provider_options) } subject { Vmpooler::PoolManager::Provider::Gce.new(config, logger, metrics, redis_connection_pool, 'gce', provider_options) }
before(:each) { allow(subject).to receive(:dns).and_return(MockDNS.new()) }
describe '#name' do describe '#name' do
it 'should be gce' do it 'should be gce' do
expect(subject.name).to eq('gce') expect(subject.name).to eq('gce')
@ -59,32 +60,42 @@ EOT
end end
describe '#manual tests live' do describe '#manual tests live' do
skip 'runs in gce' do context 'in itsysops' do
puts 'creating' before(:each) { allow(subject).to receive(:dns).and_call_original }
let(:vmname) { "instance-24" }
let(:project) { 'vmpooler-test' }
let(:config) { YAML.load(<<~EOT
---
:config:
max_tries: 3
retry_factor: 10
:providers:
:gce:
connection_pool_timeout: 1
project: '#{project}'
zone: '#{zone}'
network_name: 'projects/itsysopsnetworking/global/networks/shared1'
dns_zone_resource_name: 'test-vmpooler-puppet-net'
domain: 'test.vmpooler.puppet.net'
:pools:
- name: '#{poolname}'
alias: [ 'mockpool' ]
template: 'projects/debian-cloud/global/images/family/debian-9'
size: 5
timeout: 10
ready_ttl: 1440
provider: 'gce'
subnetwork_name: 'projects/itsysopsnetworking/regions/us-west1/subnetworks/vmpooler-test'
machine_type: 'zones/#{zone}/machineTypes/e2-micro'
EOT
) }
skip 'gets a vm' do
result = subject.create_vm(poolname, vmname) result = subject.create_vm(poolname, vmname)
#result = subject.destroy_vm(poolname, vmname)
subject.get_vm(poolname, vmname) subject.get_vm(poolname, vmname)
subject.vms_in_pool(poolname) #subject.dns_teardown({'name' => vmname})
# subject.dns_setup({'name' => vmname, 'ip' => '1.2.3.5'})
puts 'create snapshot w/ one disk'
result = subject.create_snapshot(poolname, vmname, 'sams')
puts 'create disk'
result = subject.create_disk(poolname, vmname, 10)
puts 'create snapshot w/ 2 disks'
result = subject.create_snapshot(poolname, vmname, 'sams2')
puts 'revert snapshot'
result = subject.revert_snapshot(poolname, vmname, 'sams')
result = subject.destroy_vm(poolname, vmname)
end end
skip 'runs existing' do
# result = subject.create_snapshot(poolname, vmname, "sams")
# result = subject.revert_snapshot(poolname, vmname, "sams")
# puts subject.get_vm(poolname, vmname)
result = subject.destroy_vm(poolname, vmname)
end
skip 'debug' do
puts subject.purge_unconfigured_resources(['foo', '', 'blah'])
end end
end end

View file

@ -0,0 +1,9 @@
require 'rspec'
describe 'VmpoolerProviderGce' do
context 'when creating class ' do
it 'sets a version' do
expect(VmpoolerProviderGce::VERSION).not_to be_nil
end
end
end

View file

@ -17,6 +17,7 @@ Gem::Specification.new do |s|
s.require_paths = ["lib"] s.require_paths = ["lib"]
s.add_dependency "google-apis-compute_v1", "~> 0.14" s.add_dependency "google-apis-compute_v1", "~> 0.14"
s.add_dependency "googleauth", "~> 0.16.2" s.add_dependency "googleauth", "~> 0.16.2"
s.add_dependency "google-cloud-dns", "~> 0.35.1"
s.add_development_dependency 'vmpooler', '~> 1.3', '>= 1.3.0' s.add_development_dependency 'vmpooler', '~> 1.3', '>= 1.3.0'

View file

@ -72,6 +72,14 @@
# - network_name # - network_name
# The GCE network_name to use # The GCE network_name to use
# (required) # (required)
# - dns_zone_resource_name
# The name given to the DNS zone ressource. This is not the domain, but the name identifier of a zone eg example-com
# (optional) when not set, the dns setup / teardown is skipped
# - domain
# Overwrites the global domain parameter. This should match the dns zone domain set for the dns_zone_resource_name.
# It is used to infer the domain part of the FQDN ie $vm_name.$domain
# When setting multiple providers at the same time, this value should be set for each GCE pools.
# (optional) If not explicitely set, the FQDN is inferred using the global 'domain' config parameter
# Example: # Example:
:gce: :gce:
@ -79,6 +87,8 @@
zone: 'us-central1-f' zone: 'us-central1-f'
machine_type: '' machine_type: ''
network_name: '' network_name: ''
dns_zone_resource_name: 'subdomain-example-com'
domain: 'subdomain.example.com'
# :pools: # :pools:
# #