vmpooler 2.5.0 → 3.0.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,1761 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vmpooler/util/parsing'
4
+ require 'vmpooler/dns'
5
+
6
+ module Vmpooler
7
+ class API
8
+ class V3 < Sinatra::Base
9
+ api_version = '3'
10
+ api_prefix = "/api/v#{api_version}"
11
+
12
+ helpers do
13
+ include Vmpooler::API::Helpers
14
+ end
15
+
16
+ def backend
17
+ Vmpooler::API.settings.redis
18
+ end
19
+
20
+ def metrics
21
+ Vmpooler::API.settings.metrics
22
+ end
23
+
24
+ def config
25
+ Vmpooler::API.settings.config[:config]
26
+ end
27
+
28
+ def full_config
29
+ Vmpooler::API.settings.config
30
+ end
31
+
32
+ def pools
33
+ Vmpooler::API.settings.config[:pools]
34
+ end
35
+
36
+ def pools_at_startup
37
+ Vmpooler::API.settings.config[:pools_at_startup]
38
+ end
39
+
40
+ def pool_exists?(template)
41
+ Vmpooler::API.settings.config[:pool_names].include?(template)
42
+ end
43
+
44
+ def need_auth!
45
+ validate_auth(backend)
46
+ end
47
+
48
+ def need_token!
49
+ validate_token(backend)
50
+ end
51
+
52
+ def checkoutlock
53
+ Vmpooler::API.settings.checkoutlock
54
+ end
55
+
56
+ def get_template_aliases(template)
57
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
58
+ result = []
59
+ aliases = Vmpooler::API.settings.config[:alias]
60
+ if aliases
61
+ result += aliases[template] if aliases[template].is_a?(Array)
62
+ template_backends << aliases[template] if aliases[template].is_a?(String)
63
+ end
64
+ result
65
+ end
66
+ end
67
+
68
+ def get_pool_weights(template_backends)
69
+ pool_index = pool_index(pools)
70
+ weighted_pools = {}
71
+ template_backends.each do |t|
72
+ next unless pool_index.key? t
73
+
74
+ index = pool_index[t]
75
+ clone_target = pools[index]['clone_target'] || config['clone_target']
76
+ next unless config.key?('backend_weight')
77
+
78
+ weight = config['backend_weight'][clone_target]
79
+ if weight
80
+ weighted_pools[t] = weight
81
+ end
82
+ end
83
+ weighted_pools
84
+ end
85
+
86
+ def count_selection(selection)
87
+ result = {}
88
+ selection.uniq.each do |poolname|
89
+ result[poolname] = selection.count(poolname)
90
+ end
91
+ result
92
+ end
93
+
94
+ def evaluate_template_aliases(template, count)
95
+ template_backends = []
96
+ template_backends << template if backend.sismember('vmpooler__pools', template)
97
+ selection = []
98
+ aliases = get_template_aliases(template)
99
+ if aliases
100
+ template_backends += aliases
101
+ weighted_pools = get_pool_weights(template_backends)
102
+
103
+ if weighted_pools.count > 1 && weighted_pools.count == template_backends.count
104
+ pickup = Pickup.new(weighted_pools)
105
+ count.to_i.times do
106
+ selection << pickup.pick
107
+ end
108
+ else
109
+ count.to_i.times do
110
+ selection << template_backends.sample
111
+ end
112
+ end
113
+ end
114
+
115
+ count_selection(selection)
116
+ end
117
+
118
+ # Fetch a single vm from a pool
119
+ #
120
+ # @param [String] template
121
+ # The template that the vm should be created from
122
+ #
123
+ # @return [Tuple] vmname, vmpool, vmtemplate
124
+ # Returns a tuple containing the vm's name, the pool it came from, and
125
+ # what template was used, if successful. Otherwise the tuple contains.
126
+ # nil values.
127
+ def fetch_single_vm(template)
128
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
129
+ template_backends = [template]
130
+ aliases = Vmpooler::API.settings.config[:alias]
131
+ if aliases
132
+ template_backends += aliases[template] if aliases[template].is_a?(Array)
133
+ template_backends << aliases[template] if aliases[template].is_a?(String)
134
+ pool_index = pool_index(pools)
135
+ weighted_pools = {}
136
+ template_backends.each do |t|
137
+ next unless pool_index.key? t
138
+
139
+ index = pool_index[t]
140
+ clone_target = pools[index]['clone_target'] || config['clone_target']
141
+ next unless config.key?('backend_weight')
142
+
143
+ weight = config['backend_weight'][clone_target]
144
+ if weight
145
+ weighted_pools[t] = weight
146
+ end
147
+ end
148
+
149
+ if weighted_pools.count == template_backends.count
150
+ pickup = Pickup.new(weighted_pools)
151
+ selection = pickup.pick
152
+ template_backends.delete(selection)
153
+ template_backends.unshift(selection)
154
+ else
155
+ first = template_backends.sample
156
+ template_backends.delete(first)
157
+ template_backends.unshift(first)
158
+ end
159
+ end
160
+
161
+ checkoutlock.synchronize do
162
+ template_backends.each do |template_backend|
163
+ vms = backend.smembers("vmpooler__ready__#{template_backend}")
164
+ next if vms.empty?
165
+
166
+ vm = vms.pop
167
+ smoved = backend.smove("vmpooler__ready__#{template_backend}", "vmpooler__running__#{template_backend}", vm)
168
+ if smoved
169
+ return [vm, template_backend, template]
170
+ end
171
+ end
172
+ [nil, nil, nil]
173
+ end
174
+ end
175
+ end
176
+
177
+ def return_vm_to_ready_state(template, vm)
178
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
179
+ backend.srem("vmpooler__migrating__#{template}", vm)
180
+ backend.hdel("vmpooler__active__#{template}", vm)
181
+ backend.hdel("vmpooler__vm__#{vm}", 'checkout', 'token:token', 'token:user')
182
+ backend.smove("vmpooler__running__#{template}", "vmpooler__ready__#{template}", vm)
183
+ end
184
+ end
185
+
186
+ def account_for_starting_vm(template, vm)
187
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do |span|
188
+ user = backend.hget("vmpooler__token__#{request.env['HTTP_X_AUTH_TOKEN']}", 'user')
189
+ span.set_attribute('enduser.id', user)
190
+ has_token_result = has_token?
191
+ backend.sadd("vmpooler__migrating__#{template}", vm)
192
+ backend.hset("vmpooler__active__#{template}", vm, Time.now)
193
+ backend.hset("vmpooler__vm__#{vm}", 'checkout', Time.now)
194
+
195
+ if Vmpooler::API.settings.config[:auth] and has_token_result
196
+ backend.hset("vmpooler__vm__#{vm}", 'token:token', request.env['HTTP_X_AUTH_TOKEN'])
197
+ backend.hset("vmpooler__vm__#{vm}", 'token:user', user)
198
+
199
+ if config['vm_lifetime_auth'].to_i > 0
200
+ backend.hset("vmpooler__vm__#{vm}", 'lifetime', config['vm_lifetime_auth'].to_i)
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def update_result_hosts(result, template, vm)
207
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
208
+ result[template] ||= {}
209
+ if result[template]['hostname']
210
+ result[template]['hostname'] = Array(result[template]['hostname'])
211
+ result[template]['hostname'].push(vm)
212
+ else
213
+ result[template]['hostname'] = vm
214
+ end
215
+ end
216
+ end
217
+
218
+ def atomically_allocate_vms(payload)
219
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do |span|
220
+ result = { 'ok' => false }
221
+ failed = false
222
+ vms = [] # vmpool, vmname, vmtemplate
223
+
224
+ validate_token(backend) if Vmpooler::API.settings.config[:auth] and has_token?
225
+
226
+ payload.each do |requested, count|
227
+ count.to_i.times do |_i|
228
+ vmname, vmpool, vmtemplate = fetch_single_vm(requested)
229
+ if vmname
230
+ account_for_starting_vm(vmpool, vmname)
231
+ vms << [vmpool, vmname, vmtemplate]
232
+ metrics.increment("checkout.success.#{vmpool}")
233
+ update_user_metrics('allocate', vmname) if Vmpooler::API.settings.config[:config]['usage_stats']
234
+ else
235
+ failed = true
236
+ metrics.increment("checkout.empty.#{requested}")
237
+ break
238
+ end
239
+ end
240
+ end
241
+
242
+ if failed
243
+ vms.each do |(vmpool, vmname, _vmtemplate)|
244
+ return_vm_to_ready_state(vmpool, vmname)
245
+ end
246
+ span.add_event('error', attributes: {
247
+ 'error.type' => 'Vmpooler::API::V3.atomically_allocate_vms',
248
+ 'error.message' => '503 due to failing to allocate one or more vms'
249
+ })
250
+ status 503
251
+ else
252
+ vm_names = []
253
+ vms.each do |(vmpool, vmname, vmtemplate)|
254
+ vmdomain = Dns.get_domain_for_pool(full_config, vmpool)
255
+ vmfqdn = "#{vmname}.#{vmdomain}"
256
+ update_result_hosts(result, vmtemplate, vmfqdn)
257
+ vm_names.append(vmfqdn)
258
+ end
259
+
260
+ span.set_attribute('vmpooler.vm_names', vm_names.join(',')) unless vm_names.empty?
261
+
262
+ result['ok'] = true
263
+ end
264
+
265
+ result
266
+ end
267
+ end
268
+
269
+ def component_to_test(match, labels_string)
270
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
271
+ return if labels_string.nil?
272
+
273
+ labels_string_parts = labels_string.split(',')
274
+ labels_string_parts.each do |part|
275
+ key, value = part.split('=')
276
+ next if value.nil?
277
+ return value if key == match
278
+ end
279
+ 'none'
280
+ end
281
+ end
282
+
283
+ def update_user_metrics(operation, vmname)
284
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do |span|
285
+ begin
286
+ backend.multi
287
+ backend.hget("vmpooler__vm__#{vmname}", 'tag:jenkins_build_url')
288
+ backend.hget("vmpooler__vm__#{vmname}", 'token:user')
289
+ backend.hget("vmpooler__vm__#{vmname}", 'template')
290
+ jenkins_build_url, user, poolname = backend.exec
291
+ poolname = poolname.gsub('.', '_')
292
+
293
+ if user
294
+ user = user.gsub('.', '_')
295
+ else
296
+ user = 'unauthenticated'
297
+ end
298
+ metrics.increment("user.#{user}.#{operation}.#{poolname}")
299
+
300
+ if jenkins_build_url
301
+ if jenkins_build_url.include? 'litmus'
302
+ # Very simple filter for Litmus jobs - just count them coming through for the moment.
303
+ metrics.increment("usage_litmus.#{user}.#{operation}.#{poolname}")
304
+ else
305
+ url_parts = jenkins_build_url.split('/')[2..-1]
306
+ jenkins_instance = url_parts[0].gsub('.', '_')
307
+ value_stream_parts = url_parts[2].split('_')
308
+ value_stream_parts = value_stream_parts.map { |s| s.gsub('.', '_') }
309
+ value_stream = value_stream_parts.shift
310
+ branch = value_stream_parts.pop
311
+ project = value_stream_parts.shift
312
+ job_name = value_stream_parts.join('_')
313
+ build_metadata_parts = url_parts[3]
314
+ component_to_test = component_to_test('RMM_COMPONENT_TO_TEST_NAME', build_metadata_parts)
315
+
316
+ metrics.increment("usage_jenkins_instance.#{jenkins_instance}.#{value_stream}.#{operation}.#{poolname}")
317
+ metrics.increment("usage_branch_project.#{branch}.#{project}.#{operation}.#{poolname}")
318
+ metrics.increment("usage_job_component.#{job_name}.#{component_to_test}.#{operation}.#{poolname}")
319
+ end
320
+ end
321
+ rescue StandardError => e
322
+ puts 'd', "[!] [#{poolname}] failed while evaluating usage labels on '#{vmname}' with an error: #{e}"
323
+ span.record_exception(e)
324
+ span.status = OpenTelemetry::Trace::Status.error(e.to_s)
325
+ span.add_event('log', attributes: {
326
+ 'log.severity' => 'debug',
327
+ 'log.message' => "[#{poolname}] failed while evaluating usage labels on '#{vmname}' with an error: #{e}"
328
+ })
329
+ end
330
+ end
331
+ end
332
+
333
+ def reset_pool_size(poolname)
334
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
335
+ result = { 'ok' => false }
336
+
337
+ pool_index = pool_index(pools)
338
+
339
+ pools_updated = 0
340
+ sync_pool_sizes
341
+
342
+ pool_size_now = pools[pool_index[poolname]]['size'].to_i
343
+ pool_size_original = pools_at_startup[pool_index[poolname]]['size'].to_i
344
+ result['pool_size_before_reset'] = pool_size_now
345
+ result['pool_size_before_overrides'] = pool_size_original
346
+
347
+ unless pool_size_now == pool_size_original
348
+ pools[pool_index[poolname]]['size'] = pool_size_original
349
+ backend.hdel('vmpooler__config__poolsize', poolname)
350
+ backend.sadd('vmpooler__pool__undo_size_override', poolname)
351
+ pools_updated += 1
352
+ status 201
353
+ end
354
+
355
+ status 200 unless pools_updated > 0
356
+ result['ok'] = true
357
+ result
358
+ end
359
+ end
360
+
361
+ def update_pool_size(payload)
362
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
363
+ result = { 'ok' => false }
364
+
365
+ pool_index = pool_index(pools)
366
+ pools_updated = 0
367
+ sync_pool_sizes
368
+
369
+ payload.each do |poolname, size|
370
+ unless pools[pool_index[poolname]]['size'] == size.to_i
371
+ pools[pool_index[poolname]]['size'] = size.to_i
372
+ backend.hset('vmpooler__config__poolsize', poolname, size)
373
+ pools_updated += 1
374
+ status 201
375
+ end
376
+ end
377
+ status 200 unless pools_updated > 0
378
+ result['ok'] = true
379
+ result
380
+ end
381
+ end
382
+
383
+ def reset_pool_template(poolname)
384
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
385
+ result = { 'ok' => false }
386
+
387
+ pool_index_live = pool_index(pools)
388
+ pool_index_original = pool_index(pools_at_startup)
389
+
390
+ pools_updated = 0
391
+ sync_pool_templates
392
+
393
+ template_now = pools[pool_index_live[poolname]]['template']
394
+ template_original = pools_at_startup[pool_index_original[poolname]]['template']
395
+ result['template_before_reset'] = template_now
396
+ result['template_before_overrides'] = template_original
397
+
398
+ unless template_now == template_original
399
+ pools[pool_index_live[poolname]]['template'] = template_original
400
+ backend.hdel('vmpooler__config__template', poolname)
401
+ backend.sadd('vmpooler__pool__undo_template_override', poolname)
402
+ pools_updated += 1
403
+ status 201
404
+ end
405
+
406
+ status 200 unless pools_updated > 0
407
+ result['ok'] = true
408
+ result
409
+ end
410
+ end
411
+
412
+ def update_pool_template(payload)
413
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
414
+ result = { 'ok' => false }
415
+
416
+ pool_index = pool_index(pools)
417
+ pools_updated = 0
418
+ sync_pool_templates
419
+
420
+ payload.each do |poolname, template|
421
+ unless pools[pool_index[poolname]]['template'] == template
422
+ pools[pool_index[poolname]]['template'] = template
423
+ backend.hset('vmpooler__config__template', poolname, template)
424
+ pools_updated += 1
425
+ status 201
426
+ end
427
+ end
428
+ status 200 unless pools_updated > 0
429
+ result['ok'] = true
430
+ result
431
+ end
432
+ end
433
+
434
+ def reset_pool(payload)
435
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
436
+ result = { 'ok' => false }
437
+
438
+ payload.each do |poolname, _count|
439
+ backend.sadd('vmpooler__poolreset', poolname)
440
+ end
441
+ status 201
442
+ result['ok'] = true
443
+ result
444
+ end
445
+ end
446
+
447
+ def update_clone_target(payload)
448
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
449
+ result = { 'ok' => false }
450
+
451
+ pool_index = pool_index(pools)
452
+ pools_updated = 0
453
+ sync_clone_targets
454
+
455
+ payload.each do |poolname, clone_target|
456
+ unless pools[pool_index[poolname]]['clone_target'] == clone_target
457
+ pools[pool_index[poolname]]['clone_target'] = clone_target
458
+ backend.hset('vmpooler__config__clone_target', poolname, clone_target)
459
+ pools_updated += 1
460
+ status 201
461
+ end
462
+ end
463
+ status 200 unless pools_updated > 0
464
+ result['ok'] = true
465
+ result
466
+ end
467
+ end
468
+
469
+ def sync_pool_templates
470
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
471
+ pool_index = pool_index(pools)
472
+ template_configs = backend.hgetall('vmpooler__config__template')
473
+ template_configs&.each do |poolname, template|
474
+ next unless pool_index.include? poolname
475
+
476
+ pools[pool_index[poolname]]['template'] = template
477
+ end
478
+ end
479
+ end
480
+
481
+ def sync_pool_sizes
482
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
483
+ pool_index = pool_index(pools)
484
+ poolsize_configs = backend.hgetall('vmpooler__config__poolsize')
485
+ poolsize_configs&.each do |poolname, size|
486
+ next unless pool_index.include? poolname
487
+
488
+ pools[pool_index[poolname]]['size'] = size.to_i
489
+ end
490
+ end
491
+ end
492
+
493
+ def sync_clone_targets
494
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
495
+ pool_index = pool_index(pools)
496
+ clone_target_configs = backend.hgetall('vmpooler__config__clone_target')
497
+ clone_target_configs&.each do |poolname, clone_target|
498
+ next unless pool_index.include? poolname
499
+
500
+ pools[pool_index[poolname]]['clone_target'] = clone_target
501
+ end
502
+ end
503
+ end
504
+
505
+ def too_many_requested?(payload)
506
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
507
+ payload&.each do |poolname, count|
508
+ next unless count.to_i > config['max_ondemand_instances_per_request']
509
+
510
+ metrics.increment("ondemandrequest_fail.toomanyrequests.#{poolname}")
511
+ return true
512
+ end
513
+ false
514
+ end
515
+ end
516
+
517
+ def generate_ondemand_request(payload)
518
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do |span|
519
+ result = { 'ok': false }
520
+
521
+ requested_instances = payload.reject { |k, _v| k == 'request_id' }
522
+ if too_many_requested?(requested_instances)
523
+ e_message = "requested amount of instances exceeds the maximum #{config['max_ondemand_instances_per_request']}"
524
+ result['message'] = e_message
525
+ status 403
526
+ span.add_event('error', attributes: {
527
+ 'error.type' => 'Vmpooler::API::V3.generate_ondemand_request',
528
+ 'error.message' => "403 due to #{e_message}"
529
+ })
530
+ return result
531
+ end
532
+
533
+ score = Time.now.to_i
534
+ request_id = payload['request_id']
535
+ request_id ||= generate_request_id
536
+ result['request_id'] = request_id
537
+ span.set_attribute('vmpooler.request_id', request_id)
538
+
539
+ if backend.exists?("vmpooler__odrequest__#{request_id}")
540
+ e_message = "request_id '#{request_id}' has already been created"
541
+ result['message'] = e_message
542
+ status 409
543
+ span.add_event('error', attributes: {
544
+ 'error.type' => 'Vmpooler::API::V3.generate_ondemand_request',
545
+ 'error.message' => "409 due to #{e_message}"
546
+ })
547
+ metrics.increment('ondemandrequest_generate.duplicaterequests')
548
+ return result
549
+ end
550
+
551
+ status 201
552
+
553
+ platforms_with_aliases = []
554
+ requested_instances.each do |poolname, count|
555
+ selection = evaluate_template_aliases(poolname, count)
556
+ selection.map { |selected_pool, selected_pool_count| platforms_with_aliases << "#{poolname}:#{selected_pool}:#{selected_pool_count}" }
557
+ end
558
+ platforms_string = platforms_with_aliases.join(',')
559
+
560
+ return result unless backend.zadd('vmpooler__provisioning__request', score, request_id)
561
+
562
+ backend.hset("vmpooler__odrequest__#{request_id}", 'requested', platforms_string)
563
+ if Vmpooler::API.settings.config[:auth] and has_token?
564
+ token_token = request.env['HTTP_X_AUTH_TOKEN']
565
+ token_user = backend.hget("vmpooler__token__#{token_token}", 'user')
566
+ backend.hset("vmpooler__odrequest__#{request_id}", 'token:token', token_token)
567
+ backend.hset("vmpooler__odrequest__#{request_id}", 'token:user', token_user)
568
+ span.set_attribute('enduser.id', token_user)
569
+ end
570
+
571
+ result[:ok] = true
572
+ metrics.increment('ondemandrequest_generate.success')
573
+ result
574
+ end
575
+ end
576
+
577
+ def generate_request_id
578
+ SecureRandom.uuid
579
+ end
580
+
581
+ get '/' do
582
+ sync_pool_sizes
583
+ redirect to('/dashboard/')
584
+ end
585
+
586
+ # Provide run-time statistics
587
+ #
588
+ # Example:
589
+ #
590
+ # {
591
+ # "boot": {
592
+ # "duration": {
593
+ # "average": 163.6,
594
+ # "min": 65.49,
595
+ # "max": 830.07,
596
+ # "total": 247744.71000000002
597
+ # },
598
+ # "count": {
599
+ # "total": 1514
600
+ # }
601
+ # },
602
+ # "capacity": {
603
+ # "current": 968,
604
+ # "total": 975,
605
+ # "percent": 99.3
606
+ # },
607
+ # "clone": {
608
+ # "duration": {
609
+ # "average": 17.0,
610
+ # "min": 4.66,
611
+ # "max": 637.96,
612
+ # "total": 25634.15
613
+ # },
614
+ # "count": {
615
+ # "total": 1507
616
+ # }
617
+ # },
618
+ # "queue": {
619
+ # "pending": 12,
620
+ # "cloning": 0,
621
+ # "booting": 12,
622
+ # "ready": 968,
623
+ # "running": 367,
624
+ # "completed": 0,
625
+ # "total": 1347
626
+ # },
627
+ # "pools": {
628
+ # "ready": 100,
629
+ # "running": 120,
630
+ # "pending": 5,
631
+ # "max": 250,
632
+ # }
633
+ # "status": {
634
+ # "ok": true,
635
+ # "message": "Battle station fully armed and operational.",
636
+ # "empty": [ # NOTE: would not have 'ok: true' w/ "empty" pools
637
+ # "redhat-7-x86_64",
638
+ # "ubuntu-1404-i386"
639
+ # ],
640
+ # "uptime": 179585.9
641
+ # }
642
+ #
643
+ # If the query parameter 'view' is provided, it will be used to select which top level
644
+ # element to compute and return. Select them by specifying them in a comma separated list.
645
+ # For example /status?view=capacity,boot
646
+ # would return only the "capacity" and "boot" statistics. "status" is always returned
647
+
648
+ get "#{api_prefix}/status/?" do
649
+ content_type :json
650
+
651
+ if params[:view]
652
+ views = params[:view].split(",")
653
+ end
654
+
655
+ result = {
656
+ status: {
657
+ ok: true,
658
+ message: 'Battle station fully armed and operational.'
659
+ }
660
+ }
661
+
662
+ sync_pool_sizes
663
+
664
+ result[:capacity] = get_capacity_metrics(pools, backend) unless views and not views.include?("capacity")
665
+ result[:queue] = get_queue_metrics(pools, backend) unless views and not views.include?("queue")
666
+ result[:clone] = get_task_metrics(backend, 'clone', Date.today.to_s) unless views and not views.include?("clone")
667
+ result[:boot] = get_task_metrics(backend, 'boot', Date.today.to_s) unless views and not views.include?("boot")
668
+
669
+ # Check for empty pools
670
+ result[:pools] = {} unless views and not views.include?("pools")
671
+ ready_hash = get_list_across_pools_redis_scard(pools, 'vmpooler__ready__', backend)
672
+ running_hash = get_list_across_pools_redis_scard(pools, 'vmpooler__running__', backend)
673
+ pending_hash = get_list_across_pools_redis_scard(pools, 'vmpooler__pending__', backend)
674
+ lastBoot_hash = get_list_across_pools_redis_hget(pools, 'vmpooler__lastboot', backend)
675
+
676
+ unless views and not views.include?("pools")
677
+ pools.each do |pool|
678
+ # REMIND: move this out of the API and into the back-end
679
+ ready = ready_hash[pool['name']]
680
+ running = running_hash[pool['name']]
681
+ pending = pending_hash[pool['name']]
682
+ max = pool['size']
683
+ lastBoot = lastBoot_hash[pool['name']]
684
+ aka = pool['alias']
685
+
686
+ result[:pools][pool['name']] = {
687
+ ready: ready,
688
+ running: running,
689
+ pending: pending,
690
+ max: max,
691
+ lastBoot: lastBoot
692
+ }
693
+
694
+ if aka
695
+ result[:pools][pool['name']][:alias] = aka
696
+ end
697
+
698
+ # for backwards compatibility, include separate "empty" stats in "status" block
699
+ if ready == 0 && max != 0
700
+ result[:status][:empty] ||= []
701
+ result[:status][:empty].push(pool['name'])
702
+
703
+ result[:status][:ok] = false
704
+ result[:status][:message] = "Found #{result[:status][:empty].length} empty pools."
705
+ end
706
+ end
707
+ end
708
+
709
+ result[:status][:uptime] = (Time.now - Vmpooler::API.settings.config[:uptime]).round(1) if Vmpooler::API.settings.config[:uptime]
710
+
711
+ JSON.pretty_generate(Hash[result.sort_by { |k, _v| k }])
712
+ end
713
+
714
+ # request statistics for specific pools by passing parameter 'pool'
715
+ # with a coma separated list of pools we want to query ?pool=ABC,DEF
716
+ # returns the ready, max numbers and the aliases (if set)
717
+ get "#{api_prefix}/poolstat/?" do
718
+ content_type :json
719
+
720
+ result = {}
721
+
722
+ poolscopy = []
723
+
724
+ if params[:pool]
725
+ subpool = params[:pool].split(",")
726
+ poolscopy = pools.select do |p|
727
+ if subpool.include?(p['name'])
728
+ true
729
+ elsif !p['alias'].nil?
730
+ if p['alias'].instance_of?(Array)
731
+ (p['alias'] & subpool).any?
732
+ elsif p['alias'].instance_of?(String)
733
+ subpool.include?(p['alias'])
734
+ end
735
+ end
736
+ end
737
+ end
738
+
739
+ result[:pools] = {}
740
+
741
+ poolscopy.each do |pool|
742
+ result[:pools][pool['name']] = {}
743
+
744
+ max = pool['size']
745
+ aka = pool['alias']
746
+
747
+ result[:pools][pool['name']][:max] = max
748
+
749
+ if aka
750
+ result[:pools][pool['name']][:alias] = aka
751
+ end
752
+ end
753
+
754
+ ready_hash = get_list_across_pools_redis_scard(poolscopy, 'vmpooler__ready__', backend)
755
+
756
+ ready_hash.each { |k, v| result[:pools][k][:ready] = v }
757
+
758
+ JSON.pretty_generate(Hash[result.sort_by { |k, _v| k }])
759
+ end
760
+
761
+ # requests the total number of running VMs
762
+ get "#{api_prefix}/totalrunning/?" do
763
+ content_type :json
764
+ queue = {
765
+ running: 0
766
+ }
767
+
768
+ queue[:running] = get_total_across_pools_redis_scard(pools, 'vmpooler__running__', backend)
769
+
770
+ JSON.pretty_generate(queue)
771
+ end
772
+
773
+ get "#{api_prefix}/summary/?" do
774
+ content_type :json
775
+
776
+ result = {
777
+ daily: []
778
+ }
779
+
780
+ from_param = params[:from] || Date.today.to_s
781
+ to_param = params[:to] || Date.today.to_s
782
+
783
+ # Validate date formats
784
+ [from_param, to_param].each do |param|
785
+ if !validate_date_str(param.to_s)
786
+ halt 400, "Invalid date format '#{param}', must match YYYY-MM-DD."
787
+ end
788
+ end
789
+
790
+ from_date, to_date = Date.parse(from_param), Date.parse(to_param)
791
+
792
+ if to_date < from_date
793
+ halt 400, 'Date range is invalid, \'to\' cannot come before \'from\'.'
794
+ elsif from_date > Date.today
795
+ halt 400, 'Date range is invalid, \'from\' must be in the past.'
796
+ end
797
+
798
+ boot = get_task_summary(backend, 'boot', from_date, to_date, :bypool => true)
799
+ clone = get_task_summary(backend, 'clone', from_date, to_date, :bypool => true)
800
+ tag = get_tag_summary(backend, from_date, to_date)
801
+
802
+ result[:boot] = boot[:boot]
803
+ result[:clone] = clone[:clone]
804
+ result[:tag] = tag[:tag]
805
+
806
+ daily = {}
807
+
808
+ boot[:daily].each do |day|
809
+ daily[day[:date]] ||= {}
810
+ daily[day[:date]][:boot] = day[:boot]
811
+ end
812
+
813
+ clone[:daily].each do |day|
814
+ daily[day[:date]] ||= {}
815
+ daily[day[:date]][:clone] = day[:clone]
816
+ end
817
+
818
+ tag[:daily].each do |day|
819
+ daily[day[:date]] ||= {}
820
+ daily[day[:date]][:tag] = day[:tag]
821
+ end
822
+
823
+ daily.each_key do |day|
824
+ result[:daily].push({
825
+ date: day,
826
+ boot: daily[day][:boot],
827
+ clone: daily[day][:clone],
828
+ tag: daily[day][:tag]
829
+ })
830
+ end
831
+
832
+ JSON.pretty_generate(result)
833
+ end
834
+
835
+ get "#{api_prefix}/summary/:route/?:key?/?" do
836
+ content_type :json
837
+
838
+ result = {}
839
+
840
+ from_param = params[:from] || Date.today.to_s
841
+ to_param = params[:to] || Date.today.to_s
842
+
843
+ # Validate date formats
844
+ [from_param, to_param].each do |param|
845
+ if !validate_date_str(param.to_s)
846
+ halt 400, "Invalid date format '#{param}', must match YYYY-MM-DD."
847
+ end
848
+ end
849
+
850
+ from_date, to_date = Date.parse(from_param), Date.parse(to_param)
851
+
852
+ if to_date < from_date
853
+ halt 400, 'Date range is invalid, \'to\' cannot come before \'from\'.'
854
+ elsif from_date > Date.today
855
+ halt 400, 'Date range is invalid, \'from\' must be in the past.'
856
+ end
857
+
858
+ case params[:route]
859
+ when 'boot'
860
+ result = get_task_summary(backend, 'boot', from_date, to_date, :bypool => true, :only => params[:key])
861
+ when 'clone'
862
+ result = get_task_summary(backend, 'clone', from_date, to_date, :bypool => true, :only => params[:key])
863
+ when 'tag'
864
+ result = get_tag_summary(backend, from_date, to_date, :only => params[:key])
865
+ else
866
+ halt 404, JSON.pretty_generate({ 'ok' => false })
867
+ end
868
+
869
+ JSON.pretty_generate(result)
870
+ end
871
+
872
+ get "#{api_prefix}/token/?" do
873
+ content_type :json
874
+
875
+ status 404
876
+ result = { 'ok' => false }
877
+
878
+ if Vmpooler::API.settings.config[:auth]
879
+ status 401
880
+
881
+ need_auth!
882
+
883
+ backend.keys('vmpooler__token__*').each do |key|
884
+ data = backend.hgetall(key)
885
+
886
+ if data['user'] == Rack::Auth::Basic::Request.new(request.env).username
887
+ span = OpenTelemetry::Trace.current_span
888
+ span.set_attribute('enduser.id', data['user'])
889
+ token = key.split('__').last
890
+
891
+ result[token] ||= {}
892
+
893
+ result[token]['created'] = data['created']
894
+ result[token]['last'] = data['last'] || 'never'
895
+
896
+ result['ok'] = true
897
+ end
898
+ end
899
+
900
+ if result['ok']
901
+ status 200
902
+ else
903
+ status 404
904
+ end
905
+ end
906
+
907
+ JSON.pretty_generate(result)
908
+ end
909
+
910
+ get "#{api_prefix}/token/:token/?" do
911
+ content_type :json
912
+
913
+ status 404
914
+ result = { 'ok' => false }
915
+
916
+ if Vmpooler::API.settings.config[:auth]
917
+ token = backend.hgetall("vmpooler__token__#{params[:token]}")
918
+
919
+ if not token.nil? and not token.empty?
920
+ status 200
921
+
922
+ pools.each do |pool|
923
+ backend.smembers("vmpooler__running__#{pool['name']}").each do |vm|
924
+ if backend.hget("vmpooler__vm__#{vm}", 'token:token') == params[:token]
925
+ token['vms'] ||= {}
926
+ token['vms']['running'] ||= []
927
+ token['vms']['running'].push(vm)
928
+ end
929
+ end
930
+ end
931
+
932
+ result = { 'ok' => true, params[:token] => token }
933
+ end
934
+ end
935
+
936
+ JSON.pretty_generate(result)
937
+ end
938
+
939
+ delete "#{api_prefix}/token/:token/?" do
940
+ content_type :json
941
+
942
+ status 404
943
+ result = { 'ok' => false }
944
+
945
+ if Vmpooler::API.settings.config[:auth]
946
+ status 401
947
+
948
+ need_auth!
949
+
950
+ if backend.del("vmpooler__token__#{params[:token]}").to_i > 0
951
+ status 200
952
+ result['ok'] = true
953
+ end
954
+ end
955
+
956
+ JSON.pretty_generate(result)
957
+ end
958
+
959
+ post "#{api_prefix}/token" do
960
+ content_type :json
961
+
962
+ status 404
963
+ result = { 'ok' => false }
964
+
965
+ if Vmpooler::API.settings.config[:auth]
966
+ status 401
967
+
968
+ need_auth!
969
+
970
+ o = [('a'..'z'), ('0'..'9')].map(&:to_a).flatten
971
+ result['token'] = o[rand(25)] + (0...31).map { o[rand(o.length)] }.join
972
+
973
+ backend.hset("vmpooler__token__#{result['token']}", 'user', @auth.username)
974
+ backend.hset("vmpooler__token__#{result['token']}", 'created', Time.now)
975
+ span = OpenTelemetry::Trace.current_span
976
+ span.set_attribute('enduser.id', @auth.username)
977
+
978
+ status 200
979
+ result['ok'] = true
980
+ end
981
+
982
+ JSON.pretty_generate(result)
983
+ end
984
+
985
+ get "#{api_prefix}/vm/?" do
986
+ content_type :json
987
+
988
+ result = []
989
+
990
+ pools.each do |pool|
991
+ result.push(pool['name'])
992
+ end
993
+
994
+ JSON.pretty_generate(result)
995
+ end
996
+
997
+ post "#{api_prefix}/ondemandvm/?" do
998
+ content_type :json
999
+ metrics.increment('http_requests_vm_total.post.ondemand.requestid')
1000
+
1001
+ need_token! if Vmpooler::API.settings.config[:auth]
1002
+
1003
+ result = { 'ok' => false }
1004
+
1005
+ begin
1006
+ payload = JSON.parse(request.body.read)
1007
+
1008
+ if payload
1009
+ invalid = invalid_templates(payload.reject { |k, _v| k == 'request_id' })
1010
+ if invalid.empty?
1011
+ result = generate_ondemand_request(payload)
1012
+ else
1013
+ result[:bad_templates] = invalid
1014
+ invalid.each do |bad_template|
1015
+ metrics.increment("ondemandrequest_fail.invalid.#{bad_template}")
1016
+ end
1017
+ status 404
1018
+ end
1019
+ else
1020
+ metrics.increment('ondemandrequest_fail.invalid.unknown')
1021
+ status 404
1022
+ end
1023
+ rescue JSON::ParserError
1024
+ span = OpenTelemetry::Trace.current_span
1025
+ span.status = OpenTelemetry::Trace::Status.error('JSON payload could not be parsed')
1026
+ status 400
1027
+ result = {
1028
+ 'ok' => false,
1029
+ 'message' => 'JSON payload could not be parsed'
1030
+ }
1031
+ end
1032
+
1033
+ JSON.pretty_generate(result)
1034
+ end
1035
+
1036
+ post "#{api_prefix}/ondemandvm/:template/?" do
1037
+ content_type :json
1038
+ result = { 'ok' => false }
1039
+ metrics.increment('http_requests_vm_total.delete.ondemand.template')
1040
+
1041
+ need_token! if Vmpooler::API.settings.config[:auth]
1042
+
1043
+ payload = extract_templates_from_query_params(params[:template])
1044
+
1045
+ if payload
1046
+ invalid = invalid_templates(payload.reject { |k, _v| k == 'request_id' })
1047
+ if invalid.empty?
1048
+ result = generate_ondemand_request(payload)
1049
+ else
1050
+ result[:bad_templates] = invalid
1051
+ invalid.each do |bad_template|
1052
+ metrics.increment("ondemandrequest_fail.invalid.#{bad_template}")
1053
+ end
1054
+ status 404
1055
+ end
1056
+ else
1057
+ metrics.increment('ondemandrequest_fail.invalid.unknown')
1058
+ status 404
1059
+ end
1060
+
1061
+ JSON.pretty_generate(result)
1062
+ end
1063
+
1064
+ get "#{api_prefix}/ondemandvm/:requestid/?" do
1065
+ content_type :json
1066
+ metrics.increment('http_requests_vm_total.get.ondemand.request')
1067
+
1068
+ status 404
1069
+ result = check_ondemand_request(params[:requestid])
1070
+
1071
+ JSON.pretty_generate(result)
1072
+ end
1073
+
1074
+ delete "#{api_prefix}/ondemandvm/:requestid/?" do
1075
+ content_type :json
1076
+ need_token! if Vmpooler::API.settings.config[:auth]
1077
+ metrics.increment('http_requests_vm_total.delete.ondemand.request')
1078
+
1079
+ status 404
1080
+ result = delete_ondemand_request(params[:requestid])
1081
+
1082
+ JSON.pretty_generate(result)
1083
+ end
1084
+
1085
+ post "#{api_prefix}/vm/?" do
1086
+ content_type :json
1087
+ result = { 'ok' => false }
1088
+ metrics.increment('http_requests_vm_total.post.vm.checkout')
1089
+
1090
+ payload = JSON.parse(request.body.read)
1091
+
1092
+ if payload
1093
+ invalid = invalid_templates(payload)
1094
+ if invalid.empty?
1095
+ result = atomically_allocate_vms(payload)
1096
+ else
1097
+ invalid.each do |bad_template|
1098
+ metrics.increment("checkout.invalid.#{bad_template}")
1099
+ end
1100
+ status 404
1101
+ end
1102
+ else
1103
+ metrics.increment('checkout.invalid.unknown')
1104
+ status 404
1105
+ end
1106
+
1107
+ JSON.pretty_generate(result)
1108
+ end
1109
+
1110
+ def extract_templates_from_query_params(params)
1111
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
1112
+ payload = {}
1113
+
1114
+ params.split('+').each do |template|
1115
+ payload[template] ||= 0
1116
+ payload[template] += 1
1117
+ end
1118
+
1119
+ payload
1120
+ end
1121
+ end
1122
+
1123
+ def invalid_templates(payload)
1124
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
1125
+ invalid = []
1126
+ payload.keys.each do |template|
1127
+ invalid << template unless pool_exists?(template)
1128
+ end
1129
+ invalid
1130
+ end
1131
+ end
1132
+
1133
+ def invalid_template_or_size(payload)
1134
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
1135
+ invalid = []
1136
+ payload.each do |pool, size|
1137
+ invalid << pool unless pool_exists?(pool)
1138
+ unless is_integer?(size)
1139
+ invalid << pool
1140
+ next
1141
+ end
1142
+ invalid << pool unless Integer(size) >= 0
1143
+ end
1144
+ invalid
1145
+ end
1146
+ end
1147
+
1148
+ def invalid_template_or_path(payload)
1149
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
1150
+ invalid = []
1151
+ payload.each do |pool, template|
1152
+ invalid << pool unless pool_exists?(pool)
1153
+ invalid << pool unless template.include? '/'
1154
+ invalid << pool if template[0] == '/'
1155
+ invalid << pool if template[-1] == '/'
1156
+ end
1157
+ invalid
1158
+ end
1159
+ end
1160
+
1161
+ def invalid_pool(payload)
1162
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do
1163
+ invalid = []
1164
+ payload.each do |pool, _clone_target|
1165
+ invalid << pool unless pool_exists?(pool)
1166
+ end
1167
+ invalid
1168
+ end
1169
+ end
1170
+
1171
+ def delete_ondemand_request(request_id)
1172
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do |span|
1173
+ span.set_attribute('vmpooler.request_id', request_id)
1174
+ result = { 'ok' => false }
1175
+
1176
+ platforms = backend.hget("vmpooler__odrequest__#{request_id}", 'requested')
1177
+ unless platforms
1178
+ e_message = "no request found for request_id '#{request_id}'"
1179
+ result['message'] = e_message
1180
+ span.add_event('error', attributes: {
1181
+ 'error.type' => 'Vmpooler::API::V3.delete_ondemand_request',
1182
+ 'error.message' => e_message
1183
+ })
1184
+ return result
1185
+ end
1186
+
1187
+ if backend.hget("vmpooler__odrequest__#{request_id}", 'status') == 'deleted'
1188
+ result['message'] = 'the request has already been deleted'
1189
+ else
1190
+ backend.hset("vmpooler__odrequest__#{request_id}", 'status', 'deleted')
1191
+
1192
+ Parsing.get_platform_pool_count(platforms) do |platform_alias, pool, _count|
1193
+ backend.smembers("vmpooler__#{request_id}__#{platform_alias}__#{pool}")&.each do |vm|
1194
+ backend.smove("vmpooler__running__#{pool}", "vmpooler__completed__#{pool}", vm)
1195
+ end
1196
+ backend.del("vmpooler__#{request_id}__#{platform_alias}__#{pool}")
1197
+ end
1198
+ backend.expire("vmpooler__odrequest__#{request_id}", 129_600_0)
1199
+ end
1200
+ status 200
1201
+ result['ok'] = true
1202
+ result
1203
+ end
1204
+ end
1205
+
1206
+ post "#{api_prefix}/vm/:template/?" do
1207
+ content_type :json
1208
+ result = { 'ok' => false }
1209
+ metrics.increment('http_requests_vm_total.get.vm.template')
1210
+
1211
+ payload = extract_templates_from_query_params(params[:template])
1212
+
1213
+ if payload
1214
+ invalid = invalid_templates(payload)
1215
+ if invalid.empty?
1216
+ result = atomically_allocate_vms(payload)
1217
+ else
1218
+ invalid.each do |bad_template|
1219
+ metrics.increment("checkout.invalid.#{bad_template}")
1220
+ end
1221
+ status 404
1222
+ end
1223
+ else
1224
+ metrics.increment('checkout.invalid.unknown')
1225
+ status 404
1226
+ end
1227
+
1228
+ JSON.pretty_generate(result)
1229
+ end
1230
+
1231
+ get "#{api_prefix}/vm/:hostname/?" do
1232
+ content_type :json
1233
+ metrics.increment('http_requests_vm_total.get.vm.hostname')
1234
+
1235
+ result = {}
1236
+
1237
+ status 404
1238
+ result['ok'] = false
1239
+
1240
+ params[:hostname] = hostname_shorten(params[:hostname])
1241
+
1242
+ rdata = backend.hgetall("vmpooler__vm__#{params[:hostname]}")
1243
+ unless rdata.empty?
1244
+ status 200
1245
+ result['ok'] = true
1246
+
1247
+ result[params[:hostname]] = {}
1248
+
1249
+ result[params[:hostname]]['template'] = rdata['template']
1250
+ result[params[:hostname]]['lifetime'] = (rdata['lifetime'] || config['vm_lifetime']).to_i
1251
+
1252
+ if rdata['destroy']
1253
+ result[params[:hostname]]['running'] = ((Time.parse(rdata['destroy']) - Time.parse(rdata['checkout'])) / 60 / 60).round(2) if rdata['checkout']
1254
+ result[params[:hostname]]['state'] = 'destroyed'
1255
+ elsif rdata['checkout']
1256
+ result[params[:hostname]]['running'] = ((Time.now - Time.parse(rdata['checkout'])) / 60 / 60).round(2)
1257
+ result[params[:hostname]]['remaining'] = ((Time.parse(rdata['checkout']) + rdata['lifetime'].to_i*60*60 - Time.now) / 60 / 60).round(2)
1258
+ result[params[:hostname]]['start_time'] = Time.parse(rdata['checkout']).to_datetime.rfc3339
1259
+ result[params[:hostname]]['end_time'] = (Time.parse(rdata['checkout']) + rdata['lifetime'].to_i*60*60).to_datetime.rfc3339
1260
+ result[params[:hostname]]['state'] = 'running'
1261
+ elsif rdata['check']
1262
+ result[params[:hostname]]['state'] = 'ready'
1263
+ else
1264
+ result[params[:hostname]]['state'] = 'pending'
1265
+ end
1266
+
1267
+ rdata.keys.each do |key|
1268
+ if key.match('^tag\:(.+?)$')
1269
+ result[params[:hostname]]['tags'] ||= {}
1270
+ result[params[:hostname]]['tags'][$1] = rdata[key]
1271
+ end
1272
+
1273
+ if key.match('^snapshot\:(.+?)$')
1274
+ result[params[:hostname]]['snapshots'] ||= []
1275
+ result[params[:hostname]]['snapshots'].push($1)
1276
+ end
1277
+ end
1278
+
1279
+ if rdata['disk']
1280
+ result[params[:hostname]]['disk'] = rdata['disk'].split(':')
1281
+ end
1282
+
1283
+ # Look up IP address of the hostname
1284
+ begin
1285
+ ipAddress = TCPSocket.gethostbyname(params[:hostname])[3]
1286
+ rescue StandardError
1287
+ ipAddress = ""
1288
+ end
1289
+
1290
+ result[params[:hostname]]['ip'] = ipAddress
1291
+
1292
+ if rdata['pool']
1293
+ vmdomain = Dns.get_domain_for_pool(full_config, rdata['pool'])
1294
+ if vmdomain
1295
+ result[params[:hostname]]['fqdn'] = "#{params[:hostname]}.#{vmdomain}"
1296
+ end
1297
+ end
1298
+
1299
+ result[params[:hostname]]['host'] = rdata['host'] if rdata['host']
1300
+ result[params[:hostname]]['migrated'] = rdata['migrated'] if rdata['migrated']
1301
+
1302
+ end
1303
+
1304
+ JSON.pretty_generate(result)
1305
+ end
1306
+
1307
+ def check_ondemand_request(request_id)
1308
+ tracer.in_span("Vmpooler::API::V3.#{__method__}") do |span|
1309
+ span.set_attribute('vmpooler.request_id', request_id)
1310
+ result = { 'ok' => false }
1311
+ request_hash = backend.hgetall("vmpooler__odrequest__#{request_id}")
1312
+ if request_hash.empty?
1313
+ e_message = "no request found for request_id '#{request_id}'"
1314
+ result['message'] = e_message
1315
+ span.add_event('error', attributes: {
1316
+ 'error.type' => 'Vmpooler::API::V3.check_ondemand_request',
1317
+ 'error.message' => e_message
1318
+ })
1319
+ return result
1320
+ end
1321
+
1322
+ result['request_id'] = request_id
1323
+ result['ready'] = false
1324
+ result['ok'] = true
1325
+ status 202
1326
+
1327
+ case request_hash['status']
1328
+ when 'ready'
1329
+ result['ready'] = true
1330
+ Parsing.get_platform_pool_count(request_hash['requested']) do |platform_alias, pool, _count|
1331
+ instances = backend.smembers("vmpooler__#{request_id}__#{platform_alias}__#{pool}")
1332
+ domain = Dns.get_domain_for_pool(full_config, pool)
1333
+ instances.map! { |instance| instance.concat(".#{domain}") }
1334
+
1335
+ if result.key?(platform_alias)
1336
+ result[platform_alias][:hostname] = result[platform_alias][:hostname] + instances
1337
+ else
1338
+ result[platform_alias] = { 'hostname': instances }
1339
+ end
1340
+ end
1341
+ status 200
1342
+ when 'failed'
1343
+ result['message'] = "The request failed to provision instances within the configured ondemand_request_ttl '#{config['ondemand_request_ttl']}'"
1344
+ status 200
1345
+ when 'deleted'
1346
+ result['message'] = 'The request has been deleted'
1347
+ status 200
1348
+ else
1349
+ Parsing.get_platform_pool_count(request_hash['requested']) do |platform_alias, pool, count|
1350
+ instance_count = backend.scard("vmpooler__#{request_id}__#{platform_alias}__#{pool}")
1351
+ instances_pending = count.to_i - instance_count.to_i
1352
+
1353
+ if result.key?(platform_alias) && result[platform_alias].key?(:ready)
1354
+ result[platform_alias][:ready] = (result[platform_alias][:ready].to_i + instance_count).to_s
1355
+ result[platform_alias][:pending] = (result[platform_alias][:pending].to_i + instances_pending).to_s
1356
+ else
1357
+ result[platform_alias] = {
1358
+ 'ready': instance_count.to_s,
1359
+ 'pending': instances_pending.to_s
1360
+ }
1361
+ end
1362
+ end
1363
+ end
1364
+
1365
+ result
1366
+ end
1367
+ end
1368
+
1369
+ delete "#{api_prefix}/vm/:hostname/?" do
1370
+ content_type :json
1371
+ metrics.increment('http_requests_vm_total.delete.vm.hostname')
1372
+
1373
+ result = {}
1374
+
1375
+ status 404
1376
+ result['ok'] = false
1377
+
1378
+ params[:hostname] = hostname_shorten(params[:hostname])
1379
+
1380
+ rdata = backend.hgetall("vmpooler__vm__#{params[:hostname]}")
1381
+ unless rdata.empty?
1382
+ need_token! if rdata['token:token']
1383
+
1384
+ if backend.srem("vmpooler__running__#{rdata['template']}", params[:hostname])
1385
+ backend.sadd("vmpooler__completed__#{rdata['template']}", params[:hostname])
1386
+
1387
+ status 200
1388
+ result['ok'] = true
1389
+ metrics.increment('delete.success')
1390
+ update_user_metrics('destroy', params[:hostname]) if Vmpooler::API.settings.config[:config]['usage_stats']
1391
+ else
1392
+ metrics.increment('delete.failed')
1393
+ end
1394
+ end
1395
+
1396
+ JSON.pretty_generate(result)
1397
+ end
1398
+
1399
+ put "#{api_prefix}/vm/:hostname/?" do
1400
+ content_type :json
1401
+ metrics.increment('http_requests_vm_total.put.vm.modify')
1402
+
1403
+ status 404
1404
+ result = { 'ok' => false }
1405
+
1406
+ failure = []
1407
+
1408
+ params[:hostname] = hostname_shorten(params[:hostname])
1409
+
1410
+ if backend.exists?("vmpooler__vm__#{params[:hostname]}")
1411
+ begin
1412
+ jdata = JSON.parse(request.body.read)
1413
+ rescue StandardError => e
1414
+ span = OpenTelemetry::Trace.current_span
1415
+ span.record_exception(e)
1416
+ span.status = OpenTelemetry::Trace::Status.error(e.to_s)
1417
+ halt 400, JSON.pretty_generate(result)
1418
+ end
1419
+
1420
+ # Validate data payload
1421
+ jdata.each do |param, arg|
1422
+ case param
1423
+ when 'lifetime'
1424
+ need_token! if Vmpooler::API.settings.config[:auth]
1425
+
1426
+ # in hours, defaults to one week
1427
+ max_lifetime_upper_limit = config['max_lifetime_upper_limit']
1428
+ if max_lifetime_upper_limit
1429
+ max_lifetime_upper_limit = max_lifetime_upper_limit.to_i
1430
+ if arg.to_i >= max_lifetime_upper_limit
1431
+ failure.push("You provided a lifetime (#{arg}) that exceeds the configured maximum of #{max_lifetime_upper_limit}.")
1432
+ end
1433
+ end
1434
+
1435
+ # validate lifetime is within boundaries
1436
+ unless arg.to_i > 0
1437
+ failure.push("You provided a lifetime (#{arg}) but you must provide a positive number.")
1438
+ end
1439
+
1440
+ when 'tags'
1441
+ failure.push("You provided tags (#{arg}) as something other than a hash.") unless arg.is_a?(Hash)
1442
+ failure.push("You provided unsuppored tags (#{arg}).") if config['allowed_tags'] && !(arg.keys - config['allowed_tags']).empty?
1443
+ else
1444
+ failure.push("Unknown argument #{arg}.")
1445
+ end
1446
+ end
1447
+
1448
+ if !failure.empty?
1449
+ status 400
1450
+ result['failure'] = failure
1451
+ else
1452
+ jdata.each do |param, arg|
1453
+ case param
1454
+ when 'lifetime'
1455
+ need_token! if Vmpooler::API.settings.config[:auth]
1456
+
1457
+ arg = arg.to_i
1458
+
1459
+ backend.hset("vmpooler__vm__#{params[:hostname]}", param, arg)
1460
+ when 'tags'
1461
+ filter_tags(arg)
1462
+ export_tags(backend, params[:hostname], arg)
1463
+ end
1464
+ end
1465
+
1466
+ status 200
1467
+ result['ok'] = true
1468
+ end
1469
+ end
1470
+
1471
+ JSON.pretty_generate(result)
1472
+ end
1473
+
1474
+ post "#{api_prefix}/vm/:hostname/disk/:size/?" do
1475
+ content_type :json
1476
+ metrics.increment('http_requests_vm_total.post.vm.disksize')
1477
+
1478
+ need_token! if Vmpooler::API.settings.config[:auth]
1479
+
1480
+ status 404
1481
+ result = { 'ok' => false }
1482
+
1483
+ params[:hostname] = hostname_shorten(params[:hostname])
1484
+
1485
+ if ((params[:size].to_i > 0 )and (backend.exists?("vmpooler__vm__#{params[:hostname]}")))
1486
+ result[params[:hostname]] = {}
1487
+ result[params[:hostname]]['disk'] = "+#{params[:size]}gb"
1488
+
1489
+ backend.sadd('vmpooler__tasks__disk', "#{params[:hostname]}:#{params[:size]}")
1490
+
1491
+ status 202
1492
+ result['ok'] = true
1493
+ end
1494
+
1495
+ JSON.pretty_generate(result)
1496
+ end
1497
+
1498
+ post "#{api_prefix}/vm/:hostname/snapshot/?" do
1499
+ content_type :json
1500
+ metrics.increment('http_requests_vm_total.post.vm.snapshot')
1501
+
1502
+ need_token! if Vmpooler::API.settings.config[:auth]
1503
+
1504
+ status 404
1505
+ result = { 'ok' => false }
1506
+
1507
+ params[:hostname] = hostname_shorten(params[:hostname])
1508
+
1509
+ if backend.exists?("vmpooler__vm__#{params[:hostname]}")
1510
+ result[params[:hostname]] = {}
1511
+
1512
+ o = [('a'..'z'), ('0'..'9')].map(&:to_a).flatten
1513
+ result[params[:hostname]]['snapshot'] = o[rand(25)] + (0...31).map { o[rand(o.length)] }.join
1514
+
1515
+ backend.sadd('vmpooler__tasks__snapshot', "#{params[:hostname]}:#{result[params[:hostname]]['snapshot']}")
1516
+
1517
+ status 202
1518
+ result['ok'] = true
1519
+ end
1520
+
1521
+ JSON.pretty_generate(result)
1522
+ end
1523
+
1524
+ post "#{api_prefix}/vm/:hostname/snapshot/:snapshot/?" do
1525
+ content_type :json
1526
+ metrics.increment('http_requests_vm_total.post.vm.snapshot')
1527
+
1528
+ need_token! if Vmpooler::API.settings.config[:auth]
1529
+
1530
+ status 404
1531
+ result = { 'ok' => false }
1532
+
1533
+ params[:hostname] = hostname_shorten(params[:hostname])
1534
+
1535
+ unless backend.hget("vmpooler__vm__#{params[:hostname]}", "snapshot:#{params[:snapshot]}").to_i.zero?
1536
+ backend.sadd('vmpooler__tasks__snapshot-revert', "#{params[:hostname]}:#{params[:snapshot]}")
1537
+
1538
+ status 202
1539
+ result['ok'] = true
1540
+ end
1541
+
1542
+ JSON.pretty_generate(result)
1543
+ end
1544
+
1545
+ delete "#{api_prefix}/config/poolsize/:pool/?" do
1546
+ content_type :json
1547
+ result = { 'ok' => false }
1548
+
1549
+ if config['experimental_features']
1550
+ need_token! if Vmpooler::API.settings.config[:auth]
1551
+
1552
+ if pool_exists?(params[:pool])
1553
+ result = reset_pool_size(params[:pool])
1554
+ else
1555
+ metrics.increment('config.invalid.unknown')
1556
+ status 404
1557
+ end
1558
+ else
1559
+ status 405
1560
+ end
1561
+
1562
+ JSON.pretty_generate(result)
1563
+ end
1564
+
1565
+ post "#{api_prefix}/config/poolsize/?" do
1566
+ content_type :json
1567
+ result = { 'ok' => false }
1568
+
1569
+ if config['experimental_features']
1570
+ need_token! if Vmpooler::API.settings.config[:auth]
1571
+
1572
+ payload = JSON.parse(request.body.read)
1573
+
1574
+ if payload
1575
+ invalid = invalid_template_or_size(payload)
1576
+ if invalid.empty?
1577
+ result = update_pool_size(payload)
1578
+ else
1579
+ invalid.each do |bad_template|
1580
+ metrics.increment("config.invalid.#{bad_template}")
1581
+ end
1582
+ result[:not_configured] = invalid
1583
+ status 400
1584
+ end
1585
+ else
1586
+ metrics.increment('config.invalid.unknown')
1587
+ status 404
1588
+ end
1589
+ else
1590
+ status 405
1591
+ end
1592
+
1593
+ JSON.pretty_generate(result)
1594
+ end
1595
+
1596
+ delete "#{api_prefix}/config/pooltemplate/:pool/?" do
1597
+ content_type :json
1598
+ result = { 'ok' => false }
1599
+
1600
+ if config['experimental_features']
1601
+ need_token! if Vmpooler::API.settings.config[:auth]
1602
+
1603
+ if pool_exists?(params[:pool])
1604
+ result = reset_pool_template(params[:pool])
1605
+ else
1606
+ metrics.increment('config.invalid.unknown')
1607
+ status 404
1608
+ end
1609
+ else
1610
+ status 405
1611
+ end
1612
+
1613
+ JSON.pretty_generate(result)
1614
+ end
1615
+
1616
+ post "#{api_prefix}/config/pooltemplate/?" do
1617
+ content_type :json
1618
+ result = { 'ok' => false }
1619
+
1620
+ if config['experimental_features']
1621
+ need_token! if Vmpooler::API.settings.config[:auth]
1622
+
1623
+ payload = JSON.parse(request.body.read)
1624
+
1625
+ if payload
1626
+ invalid = invalid_template_or_path(payload)
1627
+ if invalid.empty?
1628
+ result = update_pool_template(payload)
1629
+ else
1630
+ invalid.each do |bad_template|
1631
+ metrics.increment("config.invalid.#{bad_template}")
1632
+ end
1633
+ result[:bad_templates] = invalid
1634
+ status 400
1635
+ end
1636
+ else
1637
+ metrics.increment('config.invalid.unknown')
1638
+ status 404
1639
+ end
1640
+ else
1641
+ status 405
1642
+ end
1643
+
1644
+ JSON.pretty_generate(result)
1645
+ end
1646
+
1647
+ post "#{api_prefix}/poolreset/?" do
1648
+ content_type :json
1649
+ result = { 'ok' => false }
1650
+
1651
+ if config['experimental_features']
1652
+ need_token! if Vmpooler::API.settings.config[:auth]
1653
+
1654
+ begin
1655
+ payload = JSON.parse(request.body.read)
1656
+ if payload
1657
+ invalid = invalid_templates(payload)
1658
+ if invalid.empty?
1659
+ result = reset_pool(payload)
1660
+ else
1661
+ invalid.each do |bad_pool|
1662
+ metrics.increment("poolreset.invalid.#{bad_pool}")
1663
+ end
1664
+ result[:bad_pools] = invalid
1665
+ status 400
1666
+ end
1667
+ else
1668
+ metrics.increment('poolreset.invalid.unknown')
1669
+ status 404
1670
+ end
1671
+ rescue JSON::ParserError
1672
+ span = OpenTelemetry::Trace.current_span
1673
+ span.record_exception(e)
1674
+ span.status = OpenTelemetry::Trace::Status.error('JSON payload could not be parsed')
1675
+ status 400
1676
+ result = {
1677
+ 'ok' => false,
1678
+ 'message' => 'JSON payload could not be parsed'
1679
+ }
1680
+ end
1681
+ else
1682
+ status 405
1683
+ end
1684
+
1685
+ JSON.pretty_generate(result)
1686
+ end
1687
+
1688
+ post "#{api_prefix}/config/clonetarget/?" do
1689
+ content_type :json
1690
+ result = { 'ok' => false }
1691
+
1692
+ if config['experimental_features']
1693
+ need_token! if Vmpooler::API.settings.config[:auth]
1694
+
1695
+ payload = JSON.parse(request.body.read)
1696
+
1697
+ if payload
1698
+ invalid = invalid_pool(payload)
1699
+ if invalid.empty?
1700
+ result = update_clone_target(payload)
1701
+ else
1702
+ invalid.each do |bad_template|
1703
+ metrics.increment("config.invalid.#{bad_template}")
1704
+ end
1705
+ result[:bad_templates] = invalid
1706
+ status 400
1707
+ end
1708
+ else
1709
+ metrics.increment('config.invalid.unknown')
1710
+ status 404
1711
+ end
1712
+ else
1713
+ status 405
1714
+ end
1715
+
1716
+ JSON.pretty_generate(result)
1717
+ end
1718
+
1719
+ get "#{api_prefix}/config/?" do
1720
+ content_type :json
1721
+ result = { 'ok' => false }
1722
+ status 404
1723
+
1724
+ if pools
1725
+ sync_pool_sizes
1726
+ sync_pool_templates
1727
+
1728
+ pool_configuration = []
1729
+ pools.each do |pool|
1730
+ pool['template_ready'] = template_ready?(pool, backend)
1731
+ pool_configuration << pool
1732
+ end
1733
+
1734
+ result = {
1735
+ pool_configuration: pool_configuration,
1736
+ status: {
1737
+ ok: true
1738
+ }
1739
+ }
1740
+
1741
+ status 200
1742
+ end
1743
+ JSON.pretty_generate(result)
1744
+ end
1745
+
1746
+ get "#{api_prefix}/full_config/?" do
1747
+ content_type :json
1748
+
1749
+ result = {
1750
+ full_config: full_config,
1751
+ status: {
1752
+ ok: true
1753
+ }
1754
+ }
1755
+
1756
+ status 200
1757
+ JSON.pretty_generate(result)
1758
+ end
1759
+ end
1760
+ end
1761
+ end