Class: Pvectl::Services::PushConfig

Inherits:
Object
  • Object
show all
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.

Examples:

Push a single manifest

service = PushConfig.new(vm_repository: vm_repo, container_repository: ct_repo)
result = service.prepare(yaml_string)
service.apply(result[:plans]) unless result[:plans].empty?

Constant Summary collapse

DEFAULT_TASK_TIMEOUT =

Returns:

  • (Integer)
120

Instance Method Summary collapse

Constructor Details

#initialize(vm_repository:, container_repository:, task_repository: nil) ⇒ PushConfig

Returns a new instance of PushConfig.

Parameters:

  • vm_repository (Repositories::Vm)

    VM repository

  • container_repository (Repositories::Container)

    container repository

  • task_repository (Repositories::Task, nil) (defaults to: nil)

    task repository for tracking async operations

  • vm_repository: (Object)
  • container_repository: (Object)
  • task_repository: (Object) (defaults to: nil)


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.

Parameters:

Returns:

  • (Integer)

    next available VMID



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.

Parameters:

  • plans (Array<Hash>)

    plans from prepare/prepare_batch

Returns:

  • (Hash)

    { results: Array, errors: Array }



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.message }
    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.

Parameters:

Returns:

  • (Array<String>)

    list of error messages (empty if all succeeded)



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).

Parameters:

  • diff (Hash)

    diff from ConfigSerializer.diff

  • original_config (Hash)

    original flat config from API

  • type (Symbol)

    resource type (:vm or :container)

Returns:

  • (Hash)

    { params: Hash, resize_ops: Array }



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.

Parameters:

  • flat_config (Hash)

    flat config hash

  • type (Symbol)

    :vm or :container

Returns:

  • (Array<Symbol>)

    read-only keys found in the config



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*

Parameters:

  • key (Symbol, String)

    config key

  • type (Symbol)

    :vm or :container

Returns:

  • (Boolean)


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.

Parameters:

  • value (String)

    disk config string (e.g. "local-lvm:vm-100-disk-0,size=8G,iothread=1")

Returns:

  • (String)

    create format (e.g. "local-lvm:8,iothread=1")



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)
  options = parts[1..].map(&:strip).reject { |p| p.start_with?("size=") }
  create_parts = ["#{storage}:#{gib}"]
  create_parts.concat(options) if options.any?
  create_parts.join(",")
end

#disk_value_without_size(value) ⇒ String

Returns a disk config string with the size= part removed.

Parameters:

  • value (String)

    disk config string

Returns:

  • (String)

    string without size= component



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.

Parameters:

  • sections (Hash)

    section mapping

Yields:

  • (Hash)

    leaf section definition

Yield Parameters:

  • arg0 (Hash[Symbol, untyped])

Yield Returns:

  • (void)


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.

Parameters:

  • exception (ProxmoxAPI::ApiException)

    API exception with response

Returns:

  • (String)

    human-readable error detail



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.message
  end
rescue StandardError
  exception.message
end

#extract_disk_size(disk_value) ⇒ String?

Extracts the size value from a Proxmox disk config string.

Parameters:

  • disk_value (String)

    e.g. "local-lvm:vm-100-disk-0,size=8G,iothread=1"

Returns:

  • (String, nil)

    size value or nil if not found



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.

Parameters:

  • yaml_string (String)

    YAML manifest content

Returns:

  • (Hash)

    { plans: Array, errors: Array }



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.message] }
end

#prepare_batch(yaml_contents, filter_type: nil) ⇒ Hash

Prepares push plans from multiple YAML contents.

Parameters:

  • yaml_contents (Array<Hash>)

    array of { filename: String, content: String }

  • filter_type (Symbol, nil) (defaults to: nil)

    optional type filter (:vm or :container)

  • filter_type: (Symbol, nil) (defaults to: nil)

Returns:

  • (Hash)

    { plans: Array, errors: Array, skipped: Array }



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.

Parameters:

  • type (Symbol)

    :vm or :container

  • metadata (Hash)

    manifest metadata

  • flat_config (Hash)

    flat config from manifest spec

  • repo (Repositories::Vm, Repositories::Container)

    repository

  • vmid (Integer, nil) (defaults to: nil)

    explicit VMID (nil when auto_id)

  • auto_id (Boolean) (defaults to: false)

    whether to auto-allocate a VMID

  • vmid: (Integer, nil) (defaults to: nil)
  • auto_id: (Boolean) (defaults to: false)

Returns:

  • (Hash)

    { plans: Array, errors: Array }



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.

Parameters:

  • value (String)

    disk config string

  • size (String)

    new size value

Returns:

  • (String)

    string with replaced size



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.

Parameters:

  • type (Symbol)

    :vm or :container

Returns:



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.

Parameters:

  • size_str (String)

    e.g. "8G", "32G", "1T"

Returns:

  • (String)

    size in GiB as a string number



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.

Parameters:

  • flat_config (Hash)

    flat config from manifest

  • type (Symbol)

    :vm or :container

Returns:

  • (Hash)

    config with disk values in create format



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.

Parameters:

  • type (Symbol)

    :vm or :container

Returns:

  • (String)

    "VM" or "Container"



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).

Parameters:

  • key (Symbol, String)

    config key

Returns:

  • (Boolean)


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).

Parameters:

  • upid (String, nil)

    task UPID

Returns:



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