vmpooler 3.7.0 → 3.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b87824aca1844a9ce818ee4ed73ed50421b66ef55d7196ad58f43697ba4d1519
4
- data.tar.gz: c6abab19d5230d045be2197ecae237ab22b82c3e8ebf83b0e45db964b25cb034
3
+ metadata.gz: 2cae8e11129fe1e0736ff88ecbd60862f2da4dee0577a8a0050fac8359cb9a0d
4
+ data.tar.gz: 28098629734e03607153b4fa786dcf4c8fbe5b0629ad2e21b1c491056bb8985e
5
5
  SHA512:
6
- metadata.gz: 86d7c75a40b018031a955e43397a50e4f12c63a8e9548348f9c7849aa5b78fb1fd673554beff098a5520502154c14f4d2edcdb150a55da3b57d746fef632301d
7
- data.tar.gz: 0ac6b7105b4318954459c40efc10c16857536b94b0a8a1c8de4705000da974b1c24455ca48778e671a278ea21c04a64c9c0de0f2060c98b8b62526e2be488e82
6
+ metadata.gz: 5e61bc25db3dc3812e373b452bcfbf042ba980acf8130ba963205506a3e26b05816a37d4feb8fed785548e2b865817cddf98f83359fb0bbf1982a2c50a82e7bb
7
+ data.tar.gz: d354769935355aaaa29b9803ac35cca99de70579cd3716cb780524f6367f664dc7564ce550cd37de1cb9fe4fc10632a626aad8943d34985c7a834ae1029d76c4
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'vmpooler/api/input_validator'
4
+
3
5
  module Vmpooler
4
6
 
5
7
  class API
6
8
 
7
9
  module Helpers
10
+ include InputValidator
8
11
 
9
12
  def tracer
10
13
  @tracer ||= OpenTelemetry.tracer_provider.tracer('api', Vmpooler::VERSION)
@@ -299,17 +302,35 @@ module Vmpooler
299
302
  total: 0
300
303
  }
301
304
 
302
- queue[:requested] = get_total_across_pools_redis_scard(pools, 'vmpooler__provisioning__request', backend) + get_total_across_pools_redis_scard(pools, 'vmpooler__provisioning__processing', backend) + get_total_across_pools_redis_scard(pools, 'vmpooler__odcreate__task', backend)
303
-
304
- queue[:pending] = get_total_across_pools_redis_scard(pools, 'vmpooler__pending__', backend)
305
- queue[:ready] = get_total_across_pools_redis_scard(pools, 'vmpooler__ready__', backend)
306
- queue[:running] = get_total_across_pools_redis_scard(pools, 'vmpooler__running__', backend)
307
- queue[:completed] = get_total_across_pools_redis_scard(pools, 'vmpooler__completed__', backend)
305
+ # Use a single pipeline to fetch all queue counts at once for better performance
306
+ results = backend.pipelined do |pipeline|
307
+ # Order matters - we'll use indices to extract values
308
+ pools.each do |pool|
309
+ pipeline.scard("vmpooler__provisioning__request#{pool['name']}") # 0..n-1
310
+ pipeline.scard("vmpooler__provisioning__processing#{pool['name']}") # n..2n-1
311
+ pipeline.scard("vmpooler__odcreate__task#{pool['name']}") # 2n..3n-1
312
+ pipeline.scard("vmpooler__pending__#{pool['name']}") # 3n..4n-1
313
+ pipeline.scard("vmpooler__ready__#{pool['name']}") # 4n..5n-1
314
+ pipeline.scard("vmpooler__running__#{pool['name']}") # 5n..6n-1
315
+ pipeline.scard("vmpooler__completed__#{pool['name']}") # 6n..7n-1
316
+ end
317
+ pipeline.get('vmpooler__tasks__clone') # 7n
318
+ pipeline.get('vmpooler__tasks__ondemandclone') # 7n+1
319
+ end
308
320
 
309
- queue[:cloning] = backend.get('vmpooler__tasks__clone').to_i + backend.get('vmpooler__tasks__ondemandclone').to_i
310
- queue[:booting] = queue[:pending].to_i - queue[:cloning].to_i
311
- queue[:booting] = 0 if queue[:booting] < 0
312
- queue[:total] = queue[:requested] + queue[:pending].to_i + queue[:ready].to_i + queue[:running].to_i + queue[:completed].to_i
321
+ n = pools.length
322
+ # Safely extract results with default to empty array if slice returns nil
323
+ queue[:requested] = (results[0...n] || []).sum(&:to_i) +
324
+ (results[n...(2 * n)] || []).sum(&:to_i) +
325
+ (results[(2 * n)...(3 * n)] || []).sum(&:to_i)
326
+ queue[:pending] = (results[(3 * n)...(4 * n)] || []).sum(&:to_i)
327
+ queue[:ready] = (results[(4 * n)...(5 * n)] || []).sum(&:to_i)
328
+ queue[:running] = (results[(5 * n)...(6 * n)] || []).sum(&:to_i)
329
+ queue[:completed] = (results[(6 * n)...(7 * n)] || []).sum(&:to_i)
330
+ queue[:cloning] = (results[7 * n] || 0).to_i + (results[7 * n + 1] || 0).to_i
331
+ queue[:booting] = queue[:pending].to_i - queue[:cloning].to_i
332
+ queue[:booting] = 0 if queue[:booting] < 0
333
+ queue[:total] = queue[:requested] + queue[:pending].to_i + queue[:ready].to_i + queue[:running].to_i + queue[:completed].to_i
313
334
 
314
335
  queue
315
336
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vmpooler
4
+ class API
5
+ # Input validation helpers to enhance security
6
+ module InputValidator
7
+ # Maximum lengths to prevent abuse
8
+ MAX_HOSTNAME_LENGTH = 253
9
+ MAX_TAG_KEY_LENGTH = 50
10
+ MAX_TAG_VALUE_LENGTH = 255
11
+ MAX_REASON_LENGTH = 500
12
+ MAX_POOL_NAME_LENGTH = 100
13
+ MAX_TOKEN_LENGTH = 64
14
+
15
+ # Valid patterns
16
+ HOSTNAME_PATTERN = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)* \z/ix.freeze
17
+ POOL_NAME_PATTERN = /\A[a-zA-Z0-9_-]+\z/.freeze
18
+ TAG_KEY_PATTERN = /\A[a-zA-Z0-9_\-.]+\z/.freeze
19
+ TOKEN_PATTERN = /\A[a-zA-Z0-9\-_]+\z/.freeze
20
+ INTEGER_PATTERN = /\A\d+\z/.freeze
21
+
22
+ class ValidationError < StandardError; end
23
+
24
+ # Validate hostname format and length
25
+ def validate_hostname(hostname)
26
+ return error_response('Hostname is required') if hostname.nil? || hostname.empty?
27
+ return error_response('Hostname too long') if hostname.length > MAX_HOSTNAME_LENGTH
28
+ return error_response('Invalid hostname format') unless hostname.match?(HOSTNAME_PATTERN)
29
+
30
+ true
31
+ end
32
+
33
+ # Validate pool/template name
34
+ def validate_pool_name(pool_name)
35
+ return error_response('Pool name is required') if pool_name.nil? || pool_name.empty?
36
+ return error_response('Pool name too long') if pool_name.length > MAX_POOL_NAME_LENGTH
37
+ return error_response('Invalid pool name format') unless pool_name.match?(POOL_NAME_PATTERN)
38
+
39
+ true
40
+ end
41
+
42
+ # Validate tag key and value
43
+ def validate_tag(key, value)
44
+ return error_response('Tag key is required') if key.nil? || key.empty?
45
+ return error_response('Tag key too long') if key.length > MAX_TAG_KEY_LENGTH
46
+ return error_response('Invalid tag key format') unless key.match?(TAG_KEY_PATTERN)
47
+
48
+ if value
49
+ return error_response('Tag value too long') if value.length > MAX_TAG_VALUE_LENGTH
50
+
51
+ # Sanitize value to prevent injection attacks
52
+ sanitized_value = value.gsub(/[^\w\s\-.@:\/]/, '')
53
+ return error_response('Tag value contains invalid characters') if sanitized_value != value
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ # Validate token format
60
+ def validate_token_format(token)
61
+ return error_response('Token is required') if token.nil? || token.empty?
62
+ return error_response('Token too long') if token.length > MAX_TOKEN_LENGTH
63
+ return error_response('Invalid token format') unless token.match?(TOKEN_PATTERN)
64
+
65
+ true
66
+ end
67
+
68
+ # Validate integer parameter
69
+ def validate_integer(value, name = 'value', min: nil, max: nil)
70
+ return error_response("#{name} is required") if value.nil?
71
+
72
+ value_str = value.to_s
73
+ return error_response("#{name} must be a valid integer") unless value_str.match?(INTEGER_PATTERN)
74
+
75
+ int_value = value.to_i
76
+ return error_response("#{name} must be at least #{min}") if min && int_value < min
77
+ return error_response("#{name} must be at most #{max}") if max && int_value > max
78
+
79
+ int_value
80
+ end
81
+
82
+ # Validate VM request count
83
+ def validate_vm_count(count)
84
+ validated = validate_integer(count, 'VM count', min: 1, max: 100)
85
+ return validated if validated.is_a?(Hash) # error response
86
+
87
+ validated
88
+ end
89
+
90
+ # Validate disk size
91
+ def validate_disk_size(size)
92
+ validated = validate_integer(size, 'Disk size', min: 1, max: 2048)
93
+ return validated if validated.is_a?(Hash) # error response
94
+
95
+ validated
96
+ end
97
+
98
+ # Validate lifetime (TTL) in hours
99
+ def validate_lifetime(lifetime)
100
+ validated = validate_integer(lifetime, 'Lifetime', min: 1, max: 168) # max 1 week
101
+ return validated if validated.is_a?(Hash) # error response
102
+
103
+ validated
104
+ end
105
+
106
+ # Validate reason text
107
+ def validate_reason(reason)
108
+ return true if reason.nil? || reason.empty?
109
+ return error_response('Reason too long') if reason.length > MAX_REASON_LENGTH
110
+
111
+ # Sanitize to prevent XSS/injection
112
+ sanitized = reason.gsub(/[<>"']/, '')
113
+ return error_response('Reason contains invalid characters') if sanitized != reason
114
+
115
+ true
116
+ end
117
+
118
+ # Sanitize JSON body to prevent injection
119
+ def sanitize_json_body(body)
120
+ return {} if body.nil? || body.empty?
121
+
122
+ begin
123
+ parsed = JSON.parse(body)
124
+ return error_response('Request body must be a JSON object') unless parsed.is_a?(Hash)
125
+
126
+ # Limit depth and size to prevent DoS
127
+ return error_response('Request body too complex') if json_depth(parsed) > 5
128
+ return error_response('Request body too large') if body.length > 10_240 # 10KB max
129
+
130
+ parsed
131
+ rescue JSON::ParserError => e
132
+ error_response("Invalid JSON: #{e.message}")
133
+ end
134
+ end
135
+
136
+ # Check if validation result is an error
137
+ def validation_error?(result)
138
+ result.is_a?(Hash) && result['ok'] == false
139
+ end
140
+
141
+ private
142
+
143
+ def error_response(message)
144
+ { 'ok' => false, 'error' => message }
145
+ end
146
+
147
+ def json_depth(obj, depth = 0)
148
+ return depth unless obj.is_a?(Hash) || obj.is_a?(Array)
149
+ return depth + 1 if obj.empty?
150
+
151
+ if obj.is_a?(Hash)
152
+ depth + 1 + obj.values.map { |v| json_depth(v, 0) }.max
153
+ else
154
+ depth + 1 + obj.map { |v| json_depth(v, 0) }.max
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vmpooler
4
+ class API
5
+ # Rate limiter middleware to protect against abuse
6
+ # Uses Redis to track request counts per IP and token
7
+ class RateLimiter
8
+ DEFAULT_LIMITS = {
9
+ global_per_ip: { limit: 100, period: 60 }, # 100 requests per minute per IP
10
+ authenticated: { limit: 500, period: 60 }, # 500 requests per minute with token
11
+ vm_creation: { limit: 20, period: 60 }, # 20 VM creations per minute
12
+ vm_deletion: { limit: 50, period: 60 } # 50 VM deletions per minute
13
+ }.freeze
14
+
15
+ def initialize(app, redis, config = {})
16
+ @app = app
17
+ @redis = redis
18
+ @config = DEFAULT_LIMITS.merge(config[:rate_limits] || {})
19
+ @enabled = config.fetch(:rate_limiting_enabled, true)
20
+ end
21
+
22
+ def call(env)
23
+ return @app.call(env) unless @enabled
24
+
25
+ request = Rack::Request.new(env)
26
+ client_id = identify_client(request)
27
+ endpoint_type = classify_endpoint(request)
28
+
29
+ # Check rate limits
30
+ return rate_limit_response(client_id, endpoint_type) if rate_limit_exceeded?(client_id, endpoint_type, request)
31
+
32
+ # Track the request
33
+ increment_request_count(client_id, endpoint_type)
34
+
35
+ @app.call(env)
36
+ end
37
+
38
+ private
39
+
40
+ def identify_client(request)
41
+ # Prioritize token-based identification for authenticated requests
42
+ token = request.env['HTTP_X_AUTH_TOKEN']
43
+ return "token:#{token}" if token && !token.empty?
44
+
45
+ # Fall back to IP address
46
+ ip = request.ip || request.env['REMOTE_ADDR'] || 'unknown'
47
+ "ip:#{ip}"
48
+ end
49
+
50
+ def classify_endpoint(request)
51
+ path = request.path
52
+ method = request.request_method
53
+
54
+ return :vm_creation if method == 'POST' && path.include?('/vm')
55
+ return :vm_deletion if method == 'DELETE' && path.include?('/vm')
56
+ return :authenticated if request.env['HTTP_X_AUTH_TOKEN']
57
+
58
+ :global_per_ip
59
+ end
60
+
61
+ def rate_limit_exceeded?(client_id, endpoint_type, _request)
62
+ limit_config = @config[endpoint_type] || @config[:global_per_ip]
63
+ key = "vmpooler__ratelimit__#{endpoint_type}__#{client_id}"
64
+
65
+ current_count = @redis.get(key).to_i
66
+ current_count >= limit_config[:limit]
67
+ rescue StandardError => e
68
+ # If Redis fails, allow the request through (fail open)
69
+ warn "Rate limiter Redis error: #{e.message}"
70
+ false
71
+ end
72
+
73
+ def increment_request_count(client_id, endpoint_type)
74
+ limit_config = @config[endpoint_type] || @config[:global_per_ip]
75
+ key = "vmpooler__ratelimit__#{endpoint_type}__#{client_id}"
76
+
77
+ @redis.pipelined do |pipeline|
78
+ pipeline.incr(key)
79
+ pipeline.expire(key, limit_config[:period])
80
+ end
81
+ rescue StandardError => e
82
+ # Log error but don't fail the request
83
+ warn "Rate limiter increment error: #{e.message}"
84
+ end
85
+
86
+ def rate_limit_response(client_id, endpoint_type)
87
+ limit_config = @config[endpoint_type] || @config[:global_per_ip]
88
+ key = "vmpooler__ratelimit__#{endpoint_type}__#{client_id}"
89
+
90
+ begin
91
+ ttl = @redis.ttl(key)
92
+ rescue StandardError
93
+ ttl = limit_config[:period]
94
+ end
95
+
96
+ headers = {
97
+ 'Content-Type' => 'application/json',
98
+ 'X-RateLimit-Limit' => limit_config[:limit].to_s,
99
+ 'X-RateLimit-Remaining' => '0',
100
+ 'X-RateLimit-Reset' => (Time.now.to_i + ttl).to_s,
101
+ 'Retry-After' => ttl.to_s
102
+ }
103
+
104
+ body = JSON.pretty_generate({
105
+ 'ok' => false,
106
+ 'error' => 'Rate limit exceeded',
107
+ 'limit' => limit_config[:limit],
108
+ 'period' => limit_config[:period],
109
+ 'retry_after' => ttl
110
+ })
111
+
112
+ [429, headers, [body]]
113
+ end
114
+ end
115
+ end
116
+ end
@@ -9,6 +9,20 @@ module Vmpooler
9
9
  api_version = '3'
10
10
  api_prefix = "/api/v#{api_version}"
11
11
 
12
+ # Simple in-memory cache for status endpoint
13
+ # rubocop:disable Style/ClassVars
14
+ @@status_cache = {}
15
+ @@status_cache_mutex = Mutex.new
16
+ # rubocop:enable Style/ClassVars
17
+ STATUS_CACHE_TTL = 30 # seconds
18
+
19
+ # Clear cache (useful for testing)
20
+ def self.clear_status_cache
21
+ @@status_cache_mutex.synchronize do
22
+ @@status_cache.clear
23
+ end
24
+ end
25
+
12
26
  helpers do
13
27
  include Vmpooler::API::Helpers
14
28
  end
@@ -464,6 +478,32 @@ module Vmpooler
464
478
  end
465
479
  end
466
480
 
481
+ # Cache helper methods for status endpoint
482
+ def get_cached_status(cache_key)
483
+ @@status_cache_mutex.synchronize do
484
+ cached = @@status_cache[cache_key]
485
+ if cached && (Time.now - cached[:timestamp]) < STATUS_CACHE_TTL
486
+ return cached[:data]
487
+ end
488
+
489
+ nil
490
+ end
491
+ end
492
+
493
+ def set_cached_status(cache_key, data)
494
+ @@status_cache_mutex.synchronize do
495
+ @@status_cache[cache_key] = {
496
+ data: data,
497
+ timestamp: Time.now
498
+ }
499
+ # Cleanup old cache entries (keep only last 10 unique view combinations)
500
+ if @@status_cache.size > 10
501
+ oldest = @@status_cache.min_by { |_k, v| v[:timestamp] }
502
+ @@status_cache.delete(oldest[0])
503
+ end
504
+ end
505
+ end
506
+
467
507
  def sync_pool_templates
468
508
  tracer.in_span("Vmpooler::API::V3.#{__method__}") do
469
509
  pool_index = pool_index(pools)
@@ -646,6 +686,13 @@ module Vmpooler
646
686
  get "#{api_prefix}/status/?" do
647
687
  content_type :json
648
688
 
689
+ # Create cache key based on view parameters
690
+ cache_key = params[:view] ? "status_#{params[:view]}" : "status_all"
691
+
692
+ # Try to get cached response
693
+ cached_response = get_cached_status(cache_key)
694
+ return cached_response if cached_response
695
+
649
696
  if params[:view]
650
697
  views = params[:view].split(",")
651
698
  end
@@ -706,7 +753,12 @@ module Vmpooler
706
753
 
707
754
  result[:status][:uptime] = (Time.now - Vmpooler::API.settings.config[:uptime]).round(1) if Vmpooler::API.settings.config[:uptime]
708
755
 
709
- JSON.pretty_generate(Hash[result.sort_by { |k, _v| k }])
756
+ response = JSON.pretty_generate(Hash[result.sort_by { |k, _v| k }])
757
+
758
+ # Cache the response
759
+ set_cached_status(cache_key, response)
760
+
761
+ response
710
762
  end
711
763
 
712
764
  # request statistics for specific pools by passing parameter 'pool'
@@ -1085,9 +1137,29 @@ module Vmpooler
1085
1137
  result = { 'ok' => false }
1086
1138
  metrics.increment('http_requests_vm_total.post.vm.checkout')
1087
1139
 
1088
- payload = JSON.parse(request.body.read)
1140
+ # Validate and sanitize JSON body
1141
+ payload = sanitize_json_body(request.body.read)
1142
+ if validation_error?(payload)
1143
+ status 400
1144
+ return JSON.pretty_generate(payload)
1145
+ end
1089
1146
 
1090
- if payload
1147
+ # Validate each template and count
1148
+ payload.each do |template, count|
1149
+ validation = validate_pool_name(template)
1150
+ if validation_error?(validation)
1151
+ status 400
1152
+ return JSON.pretty_generate(validation)
1153
+ end
1154
+
1155
+ validated_count = validate_vm_count(count)
1156
+ if validation_error?(validated_count)
1157
+ status 400
1158
+ return JSON.pretty_generate(validated_count)
1159
+ end
1160
+ end
1161
+
1162
+ if payload && !payload.empty?
1091
1163
  invalid = invalid_templates(payload)
1092
1164
  if invalid.empty?
1093
1165
  result = atomically_allocate_vms(payload)
@@ -1206,6 +1278,7 @@ module Vmpooler
1206
1278
  result = { 'ok' => false }
1207
1279
  metrics.increment('http_requests_vm_total.get.vm.template')
1208
1280
 
1281
+ # Template can contain multiple pools separated by +, so validate after parsing
1209
1282
  payload = extract_templates_from_query_params(params[:template])
1210
1283
 
1211
1284
  if payload
@@ -1235,6 +1308,13 @@ module Vmpooler
1235
1308
  status 404
1236
1309
  result['ok'] = false
1237
1310
 
1311
+ # Validate hostname
1312
+ validation = validate_hostname(params[:hostname])
1313
+ if validation_error?(validation)
1314
+ status 400
1315
+ return JSON.pretty_generate(validation)
1316
+ end
1317
+
1238
1318
  params[:hostname] = hostname_shorten(params[:hostname])
1239
1319
 
1240
1320
  rdata = backend.hgetall("vmpooler__vm__#{params[:hostname]}")
@@ -1373,6 +1453,13 @@ module Vmpooler
1373
1453
  status 404
1374
1454
  result['ok'] = false
1375
1455
 
1456
+ # Validate hostname
1457
+ validation = validate_hostname(params[:hostname])
1458
+ if validation_error?(validation)
1459
+ status 400
1460
+ return JSON.pretty_generate(validation)
1461
+ end
1462
+
1376
1463
  params[:hostname] = hostname_shorten(params[:hostname])
1377
1464
 
1378
1465
  rdata = backend.hgetall("vmpooler__vm__#{params[:hostname]}")
@@ -1403,16 +1490,21 @@ module Vmpooler
1403
1490
 
1404
1491
  failure = []
1405
1492
 
1493
+ # Validate hostname
1494
+ validation = validate_hostname(params[:hostname])
1495
+ if validation_error?(validation)
1496
+ status 400
1497
+ return JSON.pretty_generate(validation)
1498
+ end
1499
+
1406
1500
  params[:hostname] = hostname_shorten(params[:hostname])
1407
1501
 
1408
1502
  if backend.exists?("vmpooler__vm__#{params[:hostname]}")
1409
- begin
1410
- jdata = JSON.parse(request.body.read)
1411
- rescue StandardError => e
1412
- span = OpenTelemetry::Trace.current_span
1413
- span.record_exception(e)
1414
- span.status = OpenTelemetry::Trace::Status.error(e.to_s)
1415
- halt 400, JSON.pretty_generate(result)
1503
+ # Validate and sanitize JSON body
1504
+ jdata = sanitize_json_body(request.body.read)
1505
+ if validation_error?(jdata)
1506
+ status 400
1507
+ return JSON.pretty_generate(jdata)
1416
1508
  end
1417
1509
 
1418
1510
  # Validate data payload
@@ -1421,6 +1513,13 @@ module Vmpooler
1421
1513
  when 'lifetime'
1422
1514
  need_token! if Vmpooler::API.settings.config[:auth]
1423
1515
 
1516
+ # Validate lifetime is a positive integer
1517
+ lifetime_int = arg.to_i
1518
+ if lifetime_int <= 0
1519
+ failure.push("Lifetime must be a positive integer (got #{arg})")
1520
+ next
1521
+ end
1522
+
1424
1523
  # in hours, defaults to one week
1425
1524
  max_lifetime_upper_limit = config['max_lifetime_upper_limit']
1426
1525
  if max_lifetime_upper_limit
@@ -1430,13 +1529,17 @@ module Vmpooler
1430
1529
  end
1431
1530
  end
1432
1531
 
1433
- # validate lifetime is within boundaries
1434
- unless arg.to_i > 0
1435
- failure.push("You provided a lifetime (#{arg}) but you must provide a positive number.")
1436
- end
1437
-
1438
1532
  when 'tags'
1439
1533
  failure.push("You provided tags (#{arg}) as something other than a hash.") unless arg.is_a?(Hash)
1534
+
1535
+ # Validate each tag key and value
1536
+ arg.each do |key, value|
1537
+ tag_validation = validate_tag(key, value)
1538
+ if validation_error?(tag_validation)
1539
+ failure.push(tag_validation['error'])
1540
+ end
1541
+ end
1542
+
1440
1543
  failure.push("You provided unsuppored tags (#{arg}).") if config['allowed_tags'] && !(arg.keys - config['allowed_tags']).empty?
1441
1544
  else
1442
1545
  failure.push("Unknown argument #{arg}.")
@@ -1478,9 +1581,23 @@ module Vmpooler
1478
1581
  status 404
1479
1582
  result = { 'ok' => false }
1480
1583
 
1584
+ # Validate hostname
1585
+ validation = validate_hostname(params[:hostname])
1586
+ if validation_error?(validation)
1587
+ status 400
1588
+ return JSON.pretty_generate(validation)
1589
+ end
1590
+
1591
+ # Validate disk size
1592
+ validated_size = validate_disk_size(params[:size])
1593
+ if validation_error?(validated_size)
1594
+ status 400
1595
+ return JSON.pretty_generate(validated_size)
1596
+ end
1597
+
1481
1598
  params[:hostname] = hostname_shorten(params[:hostname])
1482
1599
 
1483
- if ((params[:size].to_i > 0 )and (backend.exists?("vmpooler__vm__#{params[:hostname]}")))
1600
+ if backend.exists?("vmpooler__vm__#{params[:hostname]}")
1484
1601
  result[params[:hostname]] = {}
1485
1602
  result[params[:hostname]]['disk'] = "+#{params[:size]}gb"
1486
1603
 
@@ -329,6 +329,30 @@ module Vmpooler
329
329
  buckets: REDIS_CONNECT_BUCKETS,
330
330
  docstring: 'vmpooler redis connection wait time',
331
331
  param_labels: %i[type provider]
332
+ },
333
+ vmpooler_health: {
334
+ mtype: M_GAUGE,
335
+ torun: %i[manager],
336
+ docstring: 'vmpooler health check metrics',
337
+ param_labels: %i[metric_path]
338
+ },
339
+ vmpooler_purge: {
340
+ mtype: M_GAUGE,
341
+ torun: %i[manager],
342
+ docstring: 'vmpooler purge metrics',
343
+ param_labels: %i[metric_path]
344
+ },
345
+ vmpooler_destroy: {
346
+ mtype: M_GAUGE,
347
+ torun: %i[manager],
348
+ docstring: 'vmpooler destroy metrics',
349
+ param_labels: %i[poolname]
350
+ },
351
+ vmpooler_clone: {
352
+ mtype: M_GAUGE,
353
+ torun: %i[manager],
354
+ docstring: 'vmpooler clone metrics',
355
+ param_labels: %i[poolname]
332
356
  }
333
357
  }
334
358
  end