cem_acpt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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