Class: Pvectl::Services::PushConfig
- Inherits:
-
Object
- Object
- Pvectl::Services::PushConfig
- Defined in:
- lib/pvectl/services/push_config.rb,
sig/pvectl/services/push_config.rbs
Overview
Orchestrates pushing YAML manifests to the Proxmox cluster. Implements a two-phase approach: prepare (validate + diff) then apply.
Constant Summary collapse
- DEFAULT_TASK_TIMEOUT =
120
Instance Method Summary collapse
-
#allocate_vmid(repo, type) ⇒ Integer
Allocates the next available VMID from the repository.
-
#apply(plans) ⇒ Hash
Applies prepared plans (executes API calls).
-
#apply_resize_ops(repo, plan) ⇒ Array<String>
Applies disk resize operations from a plan.
-
#build_update_params(diff, original_config, type) ⇒ Hash
Builds flat update params from a diff and original config.
-
#collect_readonly_keys(flat_config, type) ⇒ Array<Symbol>
Collects read-only keys present in the given flat config.
-
#create_disk_key?(key, type) ⇒ Boolean
Checks if a key is a disk key that needs create-format transformation.
-
#disk_value_for_create(value) ⇒ String
Converts a single disk config string to Proxmox create API format.
-
#disk_value_without_size(value) ⇒ String
Returns a disk config string with the size= part removed.
-
#each_leaf_section(sections) {|Hash| ... } ⇒ void
Yields each leaf section definition (non-wrapper) from the sections hash.
-
#extract_api_error(exception) ⇒ String
Extracts detailed error info from a Proxmox API exception.
-
#extract_disk_size(disk_value) ⇒ String?
Extracts the size value from a Proxmox disk config string.
-
#initialize(vm_repository:, container_repository:, task_repository: nil) ⇒ PushConfig
constructor
A new instance of PushConfig.
-
#prepare(yaml_string) ⇒ Hash
Prepares a push plan from a single YAML manifest string.
-
#prepare_batch(yaml_contents, filter_type: nil) ⇒ Hash
Prepares push plans from multiple YAML contents.
-
#prepare_create(type, metadata, flat_config, repo, vmid: nil, auto_id: false) ⇒ Hash
Prepares a create plan, optionally allocating a VMID.
-
#replace_disk_size(value, size) ⇒ String
Replaces the size value in a disk config string.
-
#repository_for(type) ⇒ Repositories::Vm, Repositories::Container
Returns the appropriate repository for the given resource type.
-
#size_to_gib(size_str) ⇒ String
Converts a Proxmox size string to GiB number for create API.
-
#transform_disks_for_create(flat_config, type) ⇒ Hash
Transforms disk values in flat config to Proxmox create API format.
-
#type_label(type) ⇒ String
Returns a human-readable label for the resource type.
-
#vm_disk_key?(key) ⇒ Boolean
Checks if a key is a VM disk key (scsi, ide, virtio, sata, efidisk, tpmstate).
-
#wait_for_task(upid) ⇒ Models::Task?
Waits for an async Proxmox task to complete.
Constructor Details
#initialize(vm_repository:, container_repository:, task_repository: nil) ⇒ PushConfig
Returns a new instance of PushConfig.
18 19 20 21 22 |
# File 'lib/pvectl/services/push_config.rb', line 18 def initialize(vm_repository:, container_repository:, task_repository: nil) @vm_repository = vm_repository @container_repository = container_repository @task_repository = task_repository end |
Instance Method Details
#allocate_vmid(repo, type) ⇒ Integer
Allocates the next available VMID from the repository.
245 246 247 248 249 250 251 |
# File 'lib/pvectl/services/push_config.rb', line 245 def allocate_vmid(repo, type) if type == :container repo.next_available_ctid else repo.next_available_vmid end end |
#apply(plans) ⇒ Hash
Applies prepared plans (executes API calls). Tracks async task completion for resize and create operations.
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/pvectl/services/push_config.rb', line 154 def apply(plans) results = [] errors = [] plans.each do |plan| begin repo = repository_for(plan[:type]) if plan[:action] == :update config_params = plan[:params].reject { |k, _| k == :digest } unless config_params.empty? repo.update(plan[:vmid], plan[:node], plan[:params]) end resize_errors = apply_resize_ops(repo, plan) if resize_errors.any? resize_errors.each { |e| errors << "Error resizing #{type_label(plan[:type])} #{plan[:vmid]}: #{e}" } results << { action: :update, vmid: plan[:vmid], type: plan[:type], success: false, error: resize_errors.join("; ") } else results << { action: :update, vmid: plan[:vmid], type: plan[:type], success: true } end elsif plan[:action] == :create upid = repo.create(plan[:node], plan[:vmid], plan[:params]) task = wait_for_task(upid) if task&.failed? error_msg = task.exitstatus errors << "Error creating #{type_label(plan[:type])} #{plan[:vmid]}: #{error_msg}" results << { action: :create, vmid: plan[:vmid], type: plan[:type], success: false, error: error_msg } else results << { action: :create, vmid: plan[:vmid], type: plan[:type], success: true, auto_id: plan[:auto_id], source_path: plan[:source_path] } end end rescue ProxmoxAPI::ApiException => e detail = extract_api_error(e) errors << "Error applying #{plan[:action]} for #{type_label(plan[:type])} #{plan[:vmid]}: #{detail}" results << { action: plan[:action], vmid: plan[:vmid], type: plan[:type], success: false, error: detail } rescue StandardError => e errors << "Error applying #{plan[:action]} for #{type_label(plan[:type])} #{plan[:vmid]}: #{e.message}" results << { action: plan[:action], vmid: plan[:vmid], type: plan[:type], success: false, error: e. } end end { results: results, errors: errors } end |
#apply_resize_ops(repo, plan) ⇒ Array<String>
Applies disk resize operations from a plan. Waits for each resize task to complete and returns errors.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 |
# File 'lib/pvectl/services/push_config.rb', line 307 def apply_resize_ops(repo, plan) errors = [] return errors unless plan[:resize_ops]&.any? plan[:resize_ops].each do |op| upid = repo.resize(plan[:vmid], plan[:node], disk: op[:disk], size: op[:size]) task = wait_for_task(upid) if task&.failed? errors << "#{op[:disk]}: #{task.exitstatus}" end end errors end |
#build_update_params(diff, original_config, type) ⇒ Hash
Builds flat update params from a diff and original config. Includes changed keys, added keys, and a delete list for removed keys. Preserves the digest from original config for optimistic locking. Extracts disk resize operations into a separate list (Proxmox requires the dedicated /resize endpoint for actual disk size changes).
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/pvectl/services/push_config.rb', line 271 def build_update_params(diff, original_config, type) params = {} resize_ops = [] diff[:changed].each do |key, (old_val, new_val)| if vm_disk_key?(key) && type == :vm old_size = extract_disk_size(old_val.to_s) new_size = extract_disk_size(new_val.to_s) if old_size && new_size && old_size != new_size resize_ops << { disk: key.to_s, size: new_size } # Check if other disk options changed besides size if disk_value_without_size(old_val.to_s) != disk_value_without_size(new_val.to_s) params[key] = replace_disk_size(new_val.to_s, old_size) end next end end params[key] = new_val end diff[:added].each { |key, val| params[key] = val } unless diff[:removed].empty? params[:delete] = diff[:removed].map(&:to_s).join(",") end params[:digest] = original_config[:digest] if original_config[:digest] { params: params, resize_ops: resize_ops } end |
#collect_readonly_keys(flat_config, type) ⇒ Array<Symbol>
Collects read-only keys present in the given flat config. Uses ConfigSerializer section definitions to identify readonly fields.
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 |
# File 'lib/pvectl/services/push_config.rb', line 467 def collect_readonly_keys(flat_config, type) sections = type == :container ? ConfigSerializer::CONTAINER_SECTIONS : ConfigSerializer::VM_SECTIONS readonly = [] each_leaf_section(sections) do |section_def| section_def[:readonly].each do |ro| if ro.is_a?(Regexp) flat_config.each_key { |k| readonly << k if ro.match?(k.to_s) } else readonly << ro if flat_config.key?(ro) end end end readonly.uniq end |
#create_disk_key?(key, type) ⇒ Boolean
Checks if a key is a disk key that needs create-format transformation. For VMs: scsi*, ide*, virtio*, sata*, efidisk*, tpmstate* For containers: rootfs, mp*
348 349 350 351 352 353 354 355 356 |
# File 'lib/pvectl/services/push_config.rb', line 348 def create_disk_key?(key, type) key_str = key.to_s if type == :container ConfigSerializer::CT_COMPLEX_KEYS[:rootfs][:pattern].match?(key_str) || ConfigSerializer::CT_COMPLEX_KEYS[:mp][:pattern].match?(key_str) else vm_disk_key?(key) end end |
#disk_value_for_create(value) ⇒ String
Converts a single disk config string to Proxmox create API format. Handles: regular disks, cloud-init, EFI/TPM, and empty CD-ROMs.
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 |
# File 'lib/pvectl/services/push_config.rb', line 379 def disk_value_for_create(value) parts = value.split(",") first = parts.first.strip storage, volume = first.split(":", 2) # "none" storage = keep as-is (empty CD-ROM: "none,media=cdrom") return value if storage == "none" # Cloud-init disk (volume contains "cloudinit") return "#{storage}:cloudinit" if volume&.include?("cloudinit") # Already in create format (volume is a plain number) return value if volume&.match?(/\A\d+(\.\d+)?\z/) # Extract size from size= option size_str = extract_disk_size(value) if size_str gib = size_to_gib(size_str) elsif volume # Has volume name but no size (e.g., efidisk, tpmstate) — use default gib = "1" elsif parts.any? { |p| p.strip.start_with?("media=") } # No volume, no size, but has media= (e.g., cloud-init on real storage) # The only valid case for storage + media=cdrom without volume is cloud-init return "#{storage}:cloudinit" else # No volume, no size, no media — can't determine create format return value end # Build create format: storage:size,options (without size= key) = parts[1..].map(&:strip).reject { |p| p.start_with?("size=") } create_parts = ["#{storage}:#{gib}"] create_parts.concat() if .any? create_parts.join(",") end |
#disk_value_without_size(value) ⇒ String
Returns a disk config string with the size= part removed.
448 449 450 |
# File 'lib/pvectl/services/push_config.rb', line 448 def disk_value_without_size(value) value.split(",").reject { |p| p.strip.start_with?("size=") }.join(",") end |
#each_leaf_section(sections) {|Hash| ... } ⇒ void
This method returns an undefined value.
Yields each leaf section definition (non-wrapper) from the sections hash.
489 490 491 492 493 494 495 496 497 |
# File 'lib/pvectl/services/push_config.rb', line 489 def each_leaf_section(sections) sections.each_value do |section_def| if section_def.key?(:static) yield section_def else section_def.each_value { |sub_def| yield sub_def } end end end |
#extract_api_error(exception) ⇒ String
Extracts detailed error info from a Proxmox API exception. Parses the JSON response body to find field-level error messages.
512 513 514 515 516 517 518 519 520 521 |
# File 'lib/pvectl/services/push_config.rb', line 512 def extract_api_error(exception) body = JSON.parse(exception.response.body) if body["errors"] body["errors"].map { |k, v| "#{k}: #{v}" }.join("; ") else exception. end rescue StandardError exception. end |
#extract_disk_size(disk_value) ⇒ String?
Extracts the size value from a Proxmox disk config string.
439 440 441 442 |
# File 'lib/pvectl/services/push_config.rb', line 439 def extract_disk_size(disk_value) match = disk_value.match(/(?:^|,)size=([^,]+)/) match ? match[1] : nil end |
#prepare(yaml_string) ⇒ Hash
Prepares a push plan from a single YAML manifest string. Validates the manifest, determines update vs create, computes diff.
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/pvectl/services/push_config.rb', line 29 def prepare(yaml_string) errors = ManifestSerializer.validate(yaml_string) return { plans: [], errors: errors } unless errors.empty? manifest = ManifestSerializer.from_yaml(yaml_string) type = manifest[:type] = manifest[:metadata] spec = manifest[:spec] vmid = [:vmid] repo = repository_for(type) # Convert nested spec to flat config flat_from_manifest = ConfigSerializer.from_nested(spec, type: type) # No VMID → always create with auto-allocated ID unless vmid return prepare_create(type, , flat_from_manifest, repo, auto_id: true) end # Check if resource exists (update) or not (create) resource = repo.get(vmid) if resource # UPDATE path: fetch current config, compute diff current_config = repo.fetch_config(resource.node, vmid) original_flat = ConfigSerializer.from_nested( ConfigSerializer.to_nested(current_config, type: type), type: type ) # Filter nil/empty values from manifest (treated as "not specified"). # YAML null or empty strings mean the user didn't set the value. flat_from_manifest = flat_from_manifest.reject { |_, v| v.nil? || (v.is_a?(String) && v.empty?) } # Complete manifest's complex values with sub-properties from API. # When a manifest omits sub-properties (volume, MAC, size) the API # values fill them in, preventing false diffs from partial specs. flat_from_manifest = ConfigSerializer.complete_from_api(flat_from_manifest, original_flat, type: type) # Collect readonly keys and strip them from both sides. readonly_keys = collect_readonly_keys(flat_from_manifest.merge(original_flat), type) comparable_manifest = flat_from_manifest.reject { |k, _| readonly_keys.include?(k) } # Only compare API keys that are also present in manifest. # Keys only in API are "not specified" and should not generate diffs. comparable_original = original_flat.select { |k, _| comparable_manifest.key?(k) } diff = ConfigSerializer.diff(comparable_original, comparable_manifest) if diff[:changed].empty? && diff[:added].empty? && diff[:removed].empty? return { plans: [], errors: [], no_changes: true, vmid: vmid, type: type } end update_result = build_update_params(diff, current_config, type) plan = { action: :update, type: type, vmid: vmid, node: resource.node, diff: diff, params: update_result[:params], resize_ops: update_result[:resize_ops] } { plans: [plan], errors: [] } else prepare_create(type, , flat_from_manifest, repo, vmid: vmid) end rescue StandardError => e { plans: [], errors: [e.] } end |
#prepare_batch(yaml_contents, filter_type: nil) ⇒ Hash
Prepares push plans from multiple YAML contents.
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/pvectl/services/push_config.rb', line 106 def prepare_batch(yaml_contents, filter_type: nil) plans = [] errors = [] skipped = [] unchanged = [] yaml_contents.each do |entry| filename = entry[:filename] content = entry[:content] # Pre-check kind filter before full prepare if filter_type begin parsed = YAML.safe_load(content) kind = ManifestSerializer::KINDS_REVERSE[parsed&.dig("kind")] if kind && kind != filter_type skipped << "#{filename}: skipped (kind #{parsed['kind']} doesn't match filter)" next end rescue Psych::SyntaxError # Will be caught by prepare end end result = prepare(content) if result[:no_changes] skipped << "#{filename}: no changes" unchanged << { vmid: result[:vmid], type: result[:type], source_path: entry[:path] } next end result[:plans].each do |p| p[:filename] = filename p[:source_path] = entry[:path] end plans.concat(result[:plans]) errors.concat(result[:errors].map { |e| "#{filename}: #{e}" }) end { plans: plans, errors: errors, skipped: skipped, unchanged: unchanged } end |
#prepare_create(type, metadata, flat_config, repo, vmid: nil, auto_id: false) ⇒ Hash
Prepares a create plan, optionally allocating a VMID.
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/pvectl/services/push_config.rb', line 214 def prepare_create(type, , flat_config, repo, vmid: nil, auto_id: false) node = [:node] unless node label = vmid ? "VMID #{vmid}" : "new resource" return { plans: [], errors: ["Node is required for creating #{label}"] } end if auto_id vmid = allocate_vmid(repo, type) end create_config = transform_disks_for_create(flat_config, type) create_config = create_config.reject { |_, v| v.nil? || (v.is_a?(String) && v.empty?) } plan = { action: :create, type: type, vmid: vmid, node: node, params: create_config, auto_id: auto_id } { plans: [plan], errors: [] } end |
#replace_disk_size(value, size) ⇒ String
Replaces the size value in a disk config string.
457 458 459 |
# File 'lib/pvectl/services/push_config.rb', line 457 def replace_disk_size(value, size) value.split(",").map { |p| p.strip.start_with?("size=") ? "size=#{size}" : p }.join(",") end |
#repository_for(type) ⇒ Repositories::Vm, Repositories::Container
Returns the appropriate repository for the given resource type.
257 258 259 |
# File 'lib/pvectl/services/push_config.rb', line 257 def repository_for(type) type == :container ? @container_repository : @vm_repository end |
#size_to_gib(size_str) ⇒ String
Converts a Proxmox size string to GiB number for create API.
421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/pvectl/services/push_config.rb', line 421 def size_to_gib(size_str) if size_str.end_with?("G") size_str.chomp("G") elsif size_str.end_with?("T") (size_str.chomp("T").to_i * 1024).to_s elsif size_str.end_with?("M") (size_str.chomp("M").to_f / 1024).to_s elsif size_str.end_with?("K") "1" else size_str end end |
#transform_disks_for_create(flat_config, type) ⇒ Hash
Transforms disk values in flat config to Proxmox create API format. Replaces volume names with STORAGE_ID:SIZE_IN_GiB syntax.
364 365 366 367 368 369 370 371 372 |
# File 'lib/pvectl/services/push_config.rb', line 364 def transform_disks_for_create(flat_config, type) flat_config.each_with_object({}) do |(key, value), result| if create_disk_key?(key, type) && value.is_a?(String) result[key] = disk_value_for_create(value) else result[key] = value end end end |
#type_label(type) ⇒ String
Returns a human-readable label for the resource type.
503 504 505 |
# File 'lib/pvectl/services/push_config.rb', line 503 def type_label(type) type == :container ? "Container" : "VM" end |
#vm_disk_key?(key) ⇒ Boolean
Checks if a key is a VM disk key (scsi, ide, virtio, sata, efidisk, tpmstate).
337 338 339 |
# File 'lib/pvectl/services/push_config.rb', line 337 def vm_disk_key?(key) ConfigSerializer::VM_COMPLEX_KEYS[:disk][:pattern].match?(key.to_s) end |
#wait_for_task(upid) ⇒ Models::Task?
Waits for an async Proxmox task to complete. Returns nil when task_repository is not configured (fire-and-forget mode).
327 328 329 330 331 |
# File 'lib/pvectl/services/push_config.rb', line 327 def wait_for_task(upid) return nil unless @task_repository && upid @task_repository.wait(upid, timeout: DEFAULT_TASK_TIMEOUT) end |