vmpooler 2.5.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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