cem_acpt 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt::Platform::Gcp
4
+ require_relative 'cmd'
5
+ require_relative File.join(__dir__, '..', '..', 'logging.rb')
6
+
7
+ module Helper
8
+ def add_cmd(cmd_obj)
9
+ @cmd = cmd_obj
10
+ end
11
+
12
+ def cmd
13
+ @cmd ||= CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json')
14
+ end
15
+
16
+ def cmd_flag_vars?(*vars)
17
+ vars.none? { |v| v.nil? || (v.empty? if v.respond_to?(:empty?)) }
18
+ end
19
+ end
20
+
21
+ # This class represents a GCP project
22
+ class Project
23
+ include Helper
24
+
25
+ def initialize(name: nil, zone: nil)
26
+ @name = name
27
+ @zone = zone
28
+ end
29
+
30
+ def name
31
+ @name ||= cmd.project_from_config
32
+ end
33
+
34
+ def zone
35
+ @zone ||= cmd.zone_from_config
36
+ end
37
+
38
+ def cmd_flag
39
+ "--project=#{name}"
40
+ end
41
+ end
42
+
43
+ # This class represents a GCP VM service account
44
+ class ServiceAccount
45
+ include Helper
46
+
47
+ attr_reader :name, :scopes
48
+
49
+ def initialize(name: nil, scopes: ['devstorage.read_only', 'logging.write', 'monitoring.write', 'servicecontrol', 'service.management.readonly', 'trace.append' ])
50
+ @name = name
51
+ @scopes = scopes.map { |s| "https://www.googleapis.com/auth/#{s}" }
52
+ end
53
+
54
+ def cmd_flag
55
+ "--service-account=#{name} --scopes=#{scopes.join(',')}" if cmd_flag_vars?(name, scopes)
56
+ end
57
+ end
58
+
59
+ # This class represents a GCP VM disk
60
+ class Disk
61
+ include Helper
62
+
63
+ attr_reader :project, :name, :size, :image_name
64
+
65
+ def initialize(project: nil, name: 'disk-1', image_name: '', size: 20, type: 'pd-standard')
66
+ @project = project
67
+ @name = name
68
+ @image_name = image_name
69
+ @size = size
70
+ @type = type
71
+ end
72
+
73
+ def cmd_flag
74
+ return unless cmd_flag_vars?(name, image, size, type)
75
+
76
+ '--create-disk=auto-delete=yes,boot=yes,' \
77
+ "device-name=#{name},image=#{image}," \
78
+ "mode=rw,size=#{size},type=#{type}"
79
+ end
80
+
81
+ def type
82
+ type_path(@type)
83
+ end
84
+
85
+ def image
86
+ @image ||= image_path(@image_name)
87
+ end
88
+
89
+ private
90
+
91
+ def image_path(image_str)
92
+ raise ArgumentError, 'image must be a non-empty string' unless image_str.is_a?(String) && !image_str.empty?
93
+
94
+ data = cmd.local_exec('compute images list', out_filter: "name ~ '#{image_str}$'").first
95
+ if data.nil? || data.empty?
96
+ data = cmd.local_exec('compute images list', out_filter: "family ~ '#{image_str}$'").first
97
+ end
98
+ raise "Image #{image_str} not found" unless data
99
+ raise "Image status invalid for image #{data['selfLink']}: #{data[status]}" unless data['status'] == 'READY'
100
+
101
+ data['selfLink'].split('v1/')[-1]
102
+ end
103
+
104
+ def type_path(type)
105
+ raise ArgumentError, 'type must be a non-empty string' unless type.is_a?(String) && !type.empty?
106
+
107
+ "projects/#{project.name}/zones/#{project.zone}/diskTypes/#{type}"
108
+ end
109
+ end
110
+
111
+ # This class represents a GCP VM network interface
112
+ class NetworkInterface
113
+ include Helper
114
+
115
+ attr_reader :tier, :subnetwork
116
+
117
+ def initialize(tier: 'STANDARD', subnetwork: 'default')
118
+ @tier = tier.upcase
119
+ @subnetwork = subnetwork
120
+ end
121
+
122
+ def cmd_flag
123
+ "--network-interface=network-tier=#{tier},subnet=#{subnetwork}" if cmd_flag_vars?(tier, subnetwork)
124
+ end
125
+ end
126
+
127
+ # This class represents GCP VM metadata
128
+ class Metadata
129
+ include Helper
130
+
131
+ def initialize(**data)
132
+ @data = data.transform_keys(&:to_s)
133
+ end
134
+
135
+ def data
136
+ format_data(@data)
137
+ end
138
+
139
+ def cmd_flag
140
+ return unless cmd_flag_vars?(data)
141
+
142
+ data_from_file = data.select { |k, _v| k == 'ssh-keys' }.map { |k, v| "#{k}=#{v}" }.join(',')
143
+ normalized_data = data.reject { |k, _v| k == 'ssh-keys' }.map { |k, v| "#{k}=#{v}" }.join(',')
144
+ cmd = []
145
+ cmd << "--metadata-from-file=#{data_from_file}" unless data_from_file.empty?
146
+ cmd << "--metadata=#{normalized_data}" unless normalized_data.empty?
147
+ cmd.join(' ')
148
+ end
149
+
150
+ private
151
+
152
+ def format_data(data)
153
+ default_data = { 'enable-oslogin' => 'TRUE' }
154
+ if data['ephemeral_ssh_key']
155
+ args = {
156
+ username: (data.dig('ephemeral_ssh_key', 'username') || cmd.user_name),
157
+ }
158
+ args[:lifetime] = data.dig('ephemeral_ssh_key', 'lifetime') if data.dig('ephemeral_ssh_key', 'lifetime')
159
+ args[:keydir] = data.dig('ephemeral_ssh_key', 'keydir') if data.dig('ephemeral_ssh_key', 'keydir')
160
+ priv_key_path, ssh_keys = ephemeral_ssh_key(**args)
161
+ default_data['priv_key_local_path'] = priv_key_path
162
+ default_data['ssh-keys'] = ssh_keys
163
+ data.delete('ephemeral_ssh_key')
164
+ end
165
+ default_data.merge(data)
166
+ end
167
+
168
+ def ephemeral_ssh_key(username: nil, lifetime: 2, keydir: '/tmp/acpt_ssh_keys')
169
+ raise ArgumentError, 'username must be a non-empty string' unless username.is_a?(String) && !username.empty?
170
+
171
+ keypath = File.join(keydir, SecureRandom.hex(10))
172
+ Dir.mkdir(keydir) unless Dir.exist?(keydir)
173
+ lifetime_in_seconds = (lifetime.to_i * 60 * 60)
174
+ iso8601_expiration = (Time.now.utc + lifetime_in_seconds).strftime('%Y-%m-%dT%H:%M:%S%z')
175
+ keygen_cmd = [
176
+ 'ssh-keygen',
177
+ '-t ecdsa',
178
+ '-b 521',
179
+ "-C 'google-ssh {\"userName\":\"#{username}\",\"expireOn\":\"#{iso8601_expiration}\"}'",
180
+ "-f #{keypath}",
181
+ '-N ""',
182
+ '-q',
183
+ ].join(' ')
184
+ _, stderr, status = Open3.capture3(keygen_cmd)
185
+ raise stderr unless status.success?
186
+
187
+ File.write("#{keypath}.pub", "#{username}:#{File.read("#{keypath}.pub")}")
188
+ [keypath, "#{keypath}.pub"]
189
+ rescue StandardError => e
190
+ raise "Failed to generate ephemeral SSH key: #{e} #{e.backtrace}"
191
+ end
192
+ end
193
+
194
+ # This class represents a GCP VM. It is composed of various component classes.
195
+ class VM
196
+ include Helper
197
+ include CemAcpt::Logging
198
+
199
+ attr_accessor :name, :project, :service_account, :disk, :network_interface, :machine_type, :metadata
200
+ attr_reader :cmd
201
+
202
+ def initialize(name, components: {})
203
+ @name = name
204
+ @components = components
205
+ @machine_type = components[:machine_type]
206
+ @cmd = CemAcpt::Platform::Gcp::Cmd.new(
207
+ project: components[:project][:name],
208
+ zone: components[:project][:zone],
209
+ out_format: 'json',
210
+ local_port: components[:local_port],
211
+ )
212
+ @configured = false
213
+ end
214
+
215
+ def configure!
216
+ return @configured if @configured
217
+
218
+ @project = Project.new(**@components[:project])
219
+ @project.add_cmd(@cmd)
220
+ @service_account = @components.key?(:service_account) ? ServiceAccount.new(**@components[:service_account]) : nil
221
+ @disk = Disk.new(project: @project, **@components[:disk])
222
+ @disk.add_cmd(@cmd)
223
+ @network_interface = NetworkInterface.new(**@components[:network_interface])
224
+ @machine_type = @components[:machine_type]
225
+ @metadata = Metadata.new(**@components[:metadata])
226
+ @metadata.add_cmd(@cmd)
227
+ @configured = true
228
+ end
229
+
230
+ def info
231
+ describe_cmd = "compute instances describe #{name}"
232
+ logger.debug("Gathering info for VM #{name} with gcloud command: #{describe_cmd}")
233
+ data = @cmd.local_exec(describe_cmd, out_format: 'json')
234
+ opts = @cmd.ssh_opts(instance_name: name)
235
+ {
236
+ node_data: data,
237
+ transport: :ssh,
238
+ ssh_opts: opts,
239
+ }
240
+ end
241
+
242
+ def create
243
+ @cmd.local_exec(create_cmd)
244
+ rescue StandardError => e
245
+ raise "Failed to create VM #{name} with command #{create_cmd}: #{e}"
246
+ end
247
+
248
+ def ready?
249
+ logger.debug("Checking if VM #{name} is ready")
250
+ instance_status = @cmd.local_exec('compute instances list', out_filter: "NAME = #{name}").first['status']
251
+ logger.debug("Instance #{name} status: #{instance_status}")
252
+ return false unless instance_status == 'RUNNING'
253
+
254
+ logger.debug("Checking instance #{name} SSH connectivity")
255
+ @cmd.ssh_ready?(name)
256
+ rescue StandardError
257
+ false
258
+ end
259
+
260
+ def destroy
261
+ @cmd.delete_instance(name)
262
+ end
263
+
264
+ def install_puppet_module_package(module_pkg_path, remote_path, puppet_path)
265
+ @cmd.scp_upload(@name, module_pkg_path, remote_path)
266
+ @cmd.ssh(@name, "sudo #{puppet_path} module install #{remote_path}")
267
+ end
268
+
269
+ private
270
+
271
+ # Determines whether the given instance name is a fully qualified
272
+ # hostname or not.
273
+ def hostname?(instance_name)
274
+ instance_name.include?('.')
275
+ end
276
+
277
+ # Validates a GCP instance name.
278
+ def validate_instance_name(instance_name)
279
+ if instance_name.nil? || !instance_name.is_a?(String) || instance_name.empty?
280
+ raise ArgumentError, 'Instance name must be a non-empty string'
281
+ end
282
+ raise ArgumentError, 'Instance name must be less than 253 characters' if instance_name.length > 253
283
+
284
+ return true unless hostname?(instance_name)
285
+
286
+ labels = instance_name.split('.')
287
+ labels.each do |label|
288
+ unless label.match?(%r{^[a-z]([-a-z0-9]*[a-z0-9])?$})
289
+ raise ArgumentError, "Instance name portion #{label} contains invalid characters"
290
+ end
291
+ raise ArgumentError, "Instance name portion #{label} is too long" if label.length > 63
292
+ end
293
+ true
294
+ end
295
+
296
+ def create_cmd
297
+ validate_instance_name(name)
298
+ if hostname?(name)
299
+ instance_name = name.split('.')[0]
300
+ hostname = name
301
+ else
302
+ instance_name = name
303
+ hostname = nil
304
+ end
305
+
306
+ cmd = [
307
+ 'compute',
308
+ 'instances',
309
+ 'create',
310
+ instance_name,
311
+ '--async',
312
+ "--machine-type=#{machine_type}",
313
+ '--maintenance-policy=MIGRATE',
314
+ '--no-shielded-secure-boot',
315
+ '--shielded-vtpm',
316
+ '--shielded-integrity-monitoring',
317
+ '--reservation-affinity=any',
318
+ '--tags=acpt-test-node',
319
+ ]
320
+ [disk&.cmd_flag, network_interface&.cmd_flag, metadata&.cmd_flag, service_account&.cmd_flag].each do |flag|
321
+ cmd << flag unless flag.nil?
322
+ end
323
+ cmd << "--hostname=#{hostname}" unless hostname.nil?
324
+ cmd.join(' ')
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GCP platform implementation
4
+ module Platform
5
+ require_relative 'gcp/compute'
6
+ require_relative 'gcp/cmd'
7
+
8
+ # Returns information about the GCP instance
9
+ def node
10
+ @instance.info
11
+ end
12
+
13
+ # Provision a GCP instance
14
+ def provision
15
+ creation_params = config.dup
16
+ creation_params[:disk][:image_name] = image_name
17
+ creation_params[:local_port] = local_port
18
+ @instance = CemAcpt::Platform::Gcp::VM.new(
19
+ node_name,
20
+ components: creation_params,
21
+ )
22
+ @instance.configure!
23
+ logger.debug("Creating with command: #{@instance.send(:create_cmd)}")
24
+ @instance.create
25
+ end
26
+
27
+ # Destroy a GCP instance
28
+ def destroy
29
+ @instance.destroy
30
+ end
31
+
32
+ # Returns true if the GCP instance is ready for use in the test suite
33
+ def ready?
34
+ logger.debug("Checking if #{node_name} is ready...")
35
+ @instance.ready?
36
+ end
37
+
38
+ # Runs the test suite against the GCP instance. Must be given a block.
39
+ # If necessary, can pass information into the block to be used in the test suite.
40
+ def run_tests(&block)
41
+ logger.debug("Running tests for #{node_name}...")
42
+ block.call
43
+ end
44
+
45
+ # Uploads and installs a Puppet module package on the GCP instance.
46
+ def install_puppet_module_package(module_pkg_path, remote_path = nil, puppet_path = '/opt/puppetlabs/bin/puppet')
47
+ remote_path = remote_path.nil? ? File.join('/tmp', File.basename(module_pkg_path)) : remote_path
48
+ logger.info("Uploading module package #{module_pkg_path} to #{remote_path} on #{node_name} and installing it...")
49
+ logger.debug("Using puppet path: #{puppet_path}")
50
+ @instance.install_puppet_module_package(module_pkg_path, remote_path, puppet_path)
51
+ logger.info("Module package #{module_pkg_path} installed on #{node_name}")
52
+ end
53
+
54
+ # Extends the class with class methods from the SpecMethods module
55
+ def self.included(base)
56
+ base.extend(SpecMethods)
57
+ end
58
+
59
+ # Holds class methods called from spec tests.
60
+ module SpecMethods
61
+ # Returns an instance of the GCP platform class command provider
62
+ # @return [CemAcpt::Platform::Gcp::Cmd]
63
+ def command_provider
64
+ CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json')
65
+ end
66
+
67
+ # Apllies the given Puppet manifest on the given instance
68
+ # @param instance_name [String] the name of the instance to apply the manifest to
69
+ # @param manifest [String] the Puppet manifest to apply
70
+ # @param opts [Hash] options to pass to the apply command
71
+ # @return [String] the output of the apply command
72
+ def apply_manifest(instance_name, manifest, opts = {})
73
+ CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json').apply_manifest(instance_name, manifest, opts)
74
+ end
75
+
76
+ # Runs a shell command on the given instance
77
+ # @param instance_name [String] the name of the instance to run the command on
78
+ # @param command [String] the command to run
79
+ # @param opts [Hash] options to pass to the run_shell command
80
+ # @return [String] the output of the run_shell command
81
+ def run_shell(instance_name, cmd, opts = {})
82
+ CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json').run_shell(instance_name, cmd, opts)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Currently a placeholder for future functionality
4
+ module Platform
5
+ def node
6
+ logger.debug 'VMPooler node'
7
+ end
8
+
9
+ def provision
10
+ logger.debug 'VMPooler provision'
11
+ end
12
+
13
+ def destroy
14
+ logger.debug 'VMPooler destroy'
15
+ end
16
+
17
+ def ready?
18
+ logger.debug 'VMPooler ready?'
19
+ end
20
+
21
+ def install_puppet_module_package
22
+ logger.debug 'VMPooler install_puppet_module_package'
23
+ end
24
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ # CemAcpt::Platform manages creating and configring platform specific objects
6
+ # for the acceptance test suites.
7
+ module CemAcpt::Platform
8
+ require_relative 'logging'
9
+
10
+ class Error < StandardError; end
11
+
12
+ PLATFORM_DIR = File.expand_path(File.join(__dir__, 'platform'))
13
+
14
+ class << self
15
+ include CemAcpt::Logging
16
+
17
+ # Creates a new platform specific object of the given platform for each
18
+ # item in the test data.
19
+ # @param platform [String] the name of the platform
20
+ # @param config [CemAcpt::Config] the config object
21
+ # @param test_data [Hash] the test data
22
+ # @param local_port_allocator [CemAcpt::LocalPortAllocator] the local port allocator
23
+ def use(platform, config, test_data, local_port_allocator)
24
+ raise Error, "Platform #{platform} is not supported" unless platforms.include?(platform)
25
+ raise Error, 'test_data must be an Array' unless test_data.is_a?(Array)
26
+
27
+ logger.info "Using #{platform} for #{test_data.length} tests..."
28
+ test_data.each_with_object([]) do |single_test_data, ary|
29
+ local_port = local_port_allocator.allocate
30
+ logger.debug("Allocated local port #{local_port} for test #{single_test_data[:test_name]}")
31
+ ary << new_platform_object(platform, config, single_test_data, local_port)
32
+ end
33
+ end
34
+
35
+ # Returns an un-initialized platform specific Class of the given platform.
36
+ # @param platform [String] the name of the platform
37
+ def get(platform)
38
+ raise Error, "Platform #{platform} is not supported" unless platforms.include?(platform)
39
+
40
+ platform_class(platform)
41
+ end
42
+
43
+ private
44
+
45
+ # Dynamically creates a new platform class if it doesn't exist
46
+ # and returns a new instance of it.
47
+ # @param platform [String] the name of the platform.
48
+ # @param config [CemAcpt::Config] the config object.
49
+ # @param single_test_data [Hash] the test data for a single test.
50
+ # @return [CemAcpt::Platform::Base] an initialized platform class.
51
+ def new_platform_object(platform, config, single_test_data, local_port)
52
+ raise Error, 'single_test_data must be a Hash' unless single_test_data.is_a?(Hash)
53
+
54
+ platform_class(platform).new(config, single_test_data, local_port)
55
+ end
56
+
57
+ # Creates a new platform-specific Class object for the given platform.
58
+ # Does not initialize the class.
59
+ # @param platform [String] the name of the platform.
60
+ # @return [Class] the platform-specific Object.
61
+ def platform_class(platform)
62
+ # We require the platform base class here so that we can use it as
63
+ # a parent class for the platform-specific class.
64
+ require_relative 'platform/base'
65
+ # If the class has already been defined, we can just use it.
66
+ if Object.const_defined?(platform.capitalize)
67
+ klass = Object.const_get(platform.capitalize)
68
+ else
69
+ # Otherwise, we need to create the class. We do this by setting
70
+ # a new constant with the name of the platform capitalized, and
71
+ # associate that constant with a new instance of Class that inherits
72
+ # from the platform base class. We then require the platform file,
73
+ # include and extend our class with the Platform module from the file,
74
+ # include Logging and Concurrent::Async, and finally call the
75
+ # initialize method on the class.
76
+ klass = Object.const_set(
77
+ platform.capitalize,
78
+ Class.new(CemAcpt::Platform::Base) do
79
+ require_relative "platform/#{platform}"
80
+ include Platform
81
+ end,
82
+ )
83
+ end
84
+ klass
85
+ end
86
+
87
+ # Returns an array of the names of the supported platforms.
88
+ # Supported platforms are discovered by looking for files in the
89
+ # platform directory, and platform names are the basename (no extension)
90
+ # of the files. We deliberately exclude the base class, as it is not
91
+ # a platform.
92
+ def platforms
93
+ return @platforms if defined?(@platforms)
94
+
95
+ @platforms = Dir.glob(File.join(PLATFORM_DIR, '*.rb')).map do |file|
96
+ File.basename(file, '.rb') unless file.end_with?('base.rb')
97
+ end
98
+ @platforms.compact!
99
+ logger.debug "Discovered platform(s): #{@platforms}"
100
+ @platforms
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ module CemAcpt
6
+ # Holds modules that provide helper methods for various Puppet-related
7
+ # tasks.
8
+ module PuppetHelpers
9
+ # Provides helper methods for Puppet Modules.
10
+ module Module
11
+ class << self
12
+ include CemAcpt::Logging
13
+ end
14
+
15
+ # Builds a Puppet module package.
16
+ # @param module_dir [String] Path to the module directory. If target_dir
17
+ # is specified as a relative path, it will be relative to the module dir.
18
+ # @param target_dir [String] Path to the target directory where the package
19
+ # will be built. This defaults to the relative path 'pkg/'.
20
+ # @param should_log [Boolean] Whether or not to log the build process.
21
+ # @return [String] Path to the built package.
22
+ def self.build_module_package(module_dir, target_dir = nil, should_log: false)
23
+ require 'puppet/modulebuilder'
24
+
25
+ builder_logger = should_log ? logger : nil
26
+ builder = Puppet::Modulebuilder::Builder.new(File.expand_path(module_dir), target_dir, builder_logger)
27
+
28
+ # Validates module metadata by raising exception if invalid
29
+ _metadata = builder.metadata
30
+ logger.debug("Metadata for module #{builder.release_name} is valid")
31
+
32
+ # Builds the module package
33
+ logger.info("Building module package for #{builder.release_name}")
34
+ builder.build
35
+ end
36
+ end
37
+ end
38
+ end