diff --git a/.gitignore b/.gitignore index 9b97fbf..95e94de 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ Gemfile.local results.xml /vmpooler.yaml .idea +*.json diff --git a/.rubocop.yml b/.rubocop.yml index 782e71e..3333234 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -36,16 +36,18 @@ Style/SwapValues: # (new in 1.1) #disabled Metrics/AbcSize: - Max: 77 + Enabled: false Metrics/ClassLength: - Max: 430 + Enabled: false Metrics/CyclomaticComplexity: - Max: 14 + Enabled: false Metrics/MethodLength: - Max: 48 + Enabled: false Metrics/PerceivedComplexity: - Max: 14 + Enabled: false Metrics/ParameterLists: - Max: 6 + Enabled: false Layout/LineLength: - Max: 220 \ No newline at end of file + Enabled: false +Metrics/BlockLength: + Enabled: false \ No newline at end of file diff --git a/README.md b/README.md index d401c83..8260302 100644 --- a/README.md +++ b/README.md @@ -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 +### 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 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 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 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. \ No newline at end of file diff --git a/lib/vmpooler/providers/gce.rb b/lib/vmpooler/providers/gce.rb index b4f5f8e..3aa965e 100644 --- a/lib/vmpooler/providers/gce.rb +++ b/lib/vmpooler/providers/gce.rb @@ -2,6 +2,7 @@ require 'googleauth' require 'google/apis/compute_v1' +require 'google/cloud/dns' require 'bigdecimal' require 'bigdecimal/util' require 'vmpooler/providers/base' @@ -57,6 +58,11 @@ module Vmpooler end end + def dns + @dns ||= Google::Cloud::Dns.new(project_id: project) + @dns + end + # main configuration options def project provider_config['project'] @@ -66,6 +72,10 @@ module Vmpooler provider_config['network_name'] 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 def zone(pool_name) 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'] end + def domain + provider_config['domain'] + end + + def dns_zone_resource_name + provider_config['dns_zone_resource_name'] + end + # Base methods that are implemented: # 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, # 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 - # [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 # [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 @@ -162,6 +180,7 @@ module Vmpooler network_interfaces = Google::Apis::ComputeV1::NetworkInterface.new( network: network_name ) + network_interfaces.subnetwork = subnetwork_name(pool_name) if subnetwork_name(pool_name) init_params = { source_image: pool['template'], # The source image to create this disk. labels: { 'vm' => new_vmname, 'pool' => pool_name }, @@ -172,19 +191,22 @@ module Vmpooler boot: true, initialize_params: Google::Apis::ComputeV1::AttachedDiskInitializeParams.new(init_params) ) - # Assume all pool config is valid i.e. not missing client = ::Google::Apis::ComputeV1::Instance.new( name: new_vmname, machine_type: pool['machine_type'], disks: [disk], 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') result = connection.insert_instance(project, zone(pool_name), client) 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 # create_disk creates an additional disk for an existing VM. It will name the new @@ -398,8 +420,10 @@ module Vmpooler unless deleted 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) wait_for_operation(project, pool_name, result, 10) + dns_teardown(vm_hash) end # 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) begin # 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 return false end @@ -469,6 +493,9 @@ module Vmpooler debug_logger("trigger async delete_instance #{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 end # now check they are done @@ -529,6 +556,31 @@ module Vmpooler # 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) return false if allowlist.nil? @@ -565,7 +617,7 @@ module Vmpooler if result.error # unsure what kind of error can be stored here error_message = '' # 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}" end raise "Operation: #{result.description} failed with error: #{error_message}" @@ -591,7 +643,7 @@ module Vmpooler end # 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) pool_configuration = pool_config(pool_name) return nil if pool_configuration.nil? @@ -599,15 +651,15 @@ module Vmpooler { 'name' => vm_object.name, '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, 'boottime' => vm_object.creation_timestamp, 'status' => vm_object.status, # One of the following values: PROVISIONING, STAGING, RUNNING, STOPPING, SUSPENDING, SUSPENDED, REPAIRING, and TERMINATED 'zone' => vm_object.zone, 'machine_type' => vm_object.machine_type, 'labels' => vm_object.labels, - 'label_fingerprint' => vm_object.label_fingerprint - # 'powerstate' => powerstate + 'label_fingerprint' => vm_object.label_fingerprint, + 'ip' => vm_object.network_interfaces ? vm_object.network_interfaces.first.network_ip : nil } end diff --git a/scripts/GCE_custom_role_for_SA.yaml b/scripts/GCE_custom_role_for_SA.yaml new file mode 100644 index 0000000..fe26b4c --- /dev/null +++ b/scripts/GCE_custom_role_for_SA.yaml @@ -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 diff --git a/scripts/create_custom_role b/scripts/create_custom_role new file mode 100644 index 0000000..56d905e --- /dev/null +++ b/scripts/create_custom_role @@ -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 \ No newline at end of file diff --git a/spec/dnsservice_helper.rb b/spec/dnsservice_helper.rb new file mode 100644 index 0000000..c2752c0 --- /dev/null +++ b/spec/dnsservice_helper.rb @@ -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 \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 603f438..6cd4447 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,6 +10,7 @@ require 'vmpooler' require 'redis' require 'vmpooler/metrics' require 'computeservice_helper' +require 'dnsservice_helper' def project_root_dir File.dirname(File.dirname(__FILE__)) diff --git a/spec/unit/providers/gce_spec.rb b/spec/unit/providers/gce_spec.rb index 8fc07a0..cc43c0c 100644 --- a/spec/unit/providers/gce_spec.rb +++ b/spec/unit/providers/gce_spec.rb @@ -11,34 +11,33 @@ describe 'Vmpooler::PoolManager::Provider::Gce' do let(:metrics) { Vmpooler::Metrics::DummyStatsd.new } let(:poolname) { 'debian-9' } let(:provider_options) { { 'param' => 'value' } } - let(:project) { 'dio-samuel-dev' } + let(:project) { 'vmpooler-test' } let(:zone) { 'us-west1-b' } - let(:config) { YAML.load(<<-EOT ---- -:config: - max_tries: 3 - retry_factor: 10 -:providers: - :gce: - connection_pool_timeout: 1 - project: '#{project}' - zone: '#{zone}' - network_name: 'global/networks/default' -:pools: - - name: '#{poolname}' - alias: [ 'mockpool' ] - template: 'projects/debian-cloud/global/images/family/debian-9' - size: 5 - timeout: 10 - ready_ttl: 1440 - provider: 'gce' - network_name: 'default' - machine_type: 'zones/#{zone}/machineTypes/e2-micro' + let(:config) { YAML.load(<<~EOT + --- + :config: + max_tries: 3 + retry_factor: 10 + :providers: + :gce: + connection_pool_timeout: 1 + project: '#{project}' + zone: '#{zone}' + network_name: global/networks/default + :pools: + - name: '#{poolname}' + alias: [ 'mockpool' ] + template: 'projects/debian-cloud/global/images/family/debian-9' + size: 5 + timeout: 10 + ready_ttl: 1440 + provider: 'gce' + machine_type: 'zones/#{zone}/machineTypes/e2-micro' EOT ) } - let(:vmname) { 'vm16' } + let(:vmname) { 'vm17' } let(:connection) { MockComputeServiceConnection.new } let(:redis_connection_pool) do Vmpooler::PoolManager::GenericConnectionPool.new( @@ -52,6 +51,8 @@ EOT 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 it 'should be gce' do expect(subject.name).to eq('gce') @@ -59,32 +60,42 @@ EOT end describe '#manual tests live' do - skip 'runs in gce' do - puts 'creating' - result = subject.create_vm(poolname, vmname) - subject.get_vm(poolname, vmname) - subject.vms_in_pool(poolname) - - 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 - - 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']) + context 'in itsysops' do + 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.destroy_vm(poolname, vmname) + subject.get_vm(poolname, vmname) + #subject.dns_teardown({'name' => vmname}) + # subject.dns_setup({'name' => vmname, 'ip' => '1.2.3.5'}) + end end end diff --git a/spec/vmpooler-provider-gce/vmpooler_provider_gce_spec.rb b/spec/vmpooler-provider-gce/vmpooler_provider_gce_spec.rb new file mode 100644 index 0000000..3337597 --- /dev/null +++ b/spec/vmpooler-provider-gce/vmpooler_provider_gce_spec.rb @@ -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 \ No newline at end of file diff --git a/vmpooler-provider-gce.gemspec b/vmpooler-provider-gce.gemspec index 15bbca0..3127a46 100644 --- a/vmpooler-provider-gce.gemspec +++ b/vmpooler-provider-gce.gemspec @@ -17,6 +17,7 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.add_dependency "google-apis-compute_v1", "~> 0.14" 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' diff --git a/vmpooler.yaml.example b/vmpooler.yaml.example index 772de78..c51265d 100644 --- a/vmpooler.yaml.example +++ b/vmpooler.yaml.example @@ -72,6 +72,14 @@ # - network_name # The GCE network_name to use # (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: :gce: @@ -79,6 +87,8 @@ zone: 'us-central1-f' machine_type: '' network_name: '' + dns_zone_resource_name: 'subdomain-example-com' + domain: 'subdomain.example.com' # :pools: #