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 +4 -4
- data/lib/vmpooler/api/helpers.rb +31 -10
- data/lib/vmpooler/api/input_validator.rb +159 -0
- data/lib/vmpooler/api/rate_limiter.rb +116 -0
- data/lib/vmpooler/api/v3.rb +133 -16
- data/lib/vmpooler/metrics/promstats.rb +24 -0
- data/lib/vmpooler/pool_manager.rb +772 -11
- data/lib/vmpooler/version.rb +1 -1
- metadata +5 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2cae8e11129fe1e0736ff88ecbd60862f2da4dee0577a8a0050fac8359cb9a0d
|
|
4
|
+
data.tar.gz: 28098629734e03607153b4fa786dcf4c8fbe5b0629ad2e21b1c491056bb8985e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e61bc25db3dc3812e373b452bcfbf042ba980acf8130ba963205506a3e26b05816a37d4feb8fed785548e2b865817cddf98f83359fb0bbf1982a2c50a82e7bb
|
|
7
|
+
data.tar.gz: d354769935355aaaa29b9803ac35cca99de70579cd3716cb780524f6367f664dc7564ce550cd37de1cb9fe4fc10632a626aad8943d34985c7a834ae1029d76c4
|
data/lib/vmpooler/api/helpers.rb
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
queue[:
|
|
312
|
-
|
|
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
|
data/lib/vmpooler/api/v3.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
|
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
|