Module: Pvectl::ConfigSerializer

Defined in:
lib/pvectl/config_serializer.rb

Overview

Converts flat Proxmox config hashes into nested, section-grouped YAML and back. Used by the ‘edit` command to present VM/container configuration in a human-friendly, structured format.

All methods are class-level; no instance state is needed.

Examples:

Round-trip conversion

yaml = ConfigSerializer.to_yaml(flat_config, type: :vm, resource: { vmid: 100, node: "pve1", status: "running" })
flat  = ConfigSerializer.from_yaml(yaml, type: :vm)

Constant Summary collapse

VM_SECTIONS =

Section mappings for QEMU VMs. Each section maps to an array of static keys and an array of dynamic key patterns. Keys marked as read-only are listed separately. Sections without a :static key are “wrapper” sections containing named subsections.

{
  general: {
    static: %i[vmid name description tags template lock digest],
    dynamic: [],
    readonly: %i[vmid template lock digest]
  },
  hardware: {
    cpu: {
      static: %i[cores sockets cpu cpulimit cpuunits numa affinity],
      dynamic: [/\Anuma\d+\z/],
      readonly: []
    },
    memory: {
      static: %i[memory balloon shares hugepages keephugepages],
      dynamic: [],
      readonly: []
    },
    disks: {
      static: %i[efidisk0 tpmstate0],
      dynamic: [/\Ascsi\d+\z/, /\Aide\d+\z/, /\Avirtio\d+\z/, /\Asata\d+\z/, /\Aunused\d+\z/],
      readonly: [/\Aunused\d+\z/]
    },
    network: {
      static: [],
      dynamic: [/\Anet\d+\z/],
      readonly: []
    },
    display: {
      static: %i[vga spice_enhancements keyboard],
      dynamic: [],
      readonly: []
    },
    devices: {
      static: %i[audio0 rng0 ivshmem],
      dynamic: [/\Aserial\d+\z/, /\Aparallel\d+\z/, /\Ausb\d+\z/, /\Ahostpci\d+\z/],
      readonly: []
    }
  },
  cloud_init: {
    static: %i[citype cicustom ciuser cipassword ciupgrade nameserver searchdomain sshkeys],
    dynamic: [/\Aipconfig\d+\z/],
    readonly: []
  },
  options: {
    static: %i[onboot startup boot bootdisk bios machine arch ostype scsihw kvm agent hotplug
               tablet args hookscript smbios1 localtime reboot freeze protection],
    dynamic: [],
    readonly: []
  },
  migration: {
    static: %i[migrate_downtime migrate_speed],
    dynamic: [],
    readonly: []
  },
  security: {
    static: %i[amd_sev intel_tdx],
    dynamic: [],
    readonly: []
  }
}.freeze
CONTAINER_SECTIONS =

Section mappings for LXC containers. Sections without a :static key are “wrapper” sections containing named subsections.

{
  general: {
    static: %i[vmid hostname description tags template lock digest],
    dynamic: [],
    readonly: %i[vmid template lock digest]
  },
  resources: {
    cpu: {
      static: %i[cores cpulimit cpuunits],
      dynamic: [],
      readonly: []
    },
    memory: {
      static: %i[memory swap],
      dynamic: [],
      readonly: []
    },
    disks: {
      static: %i[rootfs],
      dynamic: [/\Amp\d+\z/, /\Adev\d+\z/, /\Aunused\d+\z/],
      readonly: [/\Aunused\d+\z/]
    }
  },
  network: {
    static: [],
    dynamic: [/\Anet\d+\z/],
    readonly: []
  },
  dns: {
    static: %i[nameserver searchdomain],
    dynamic: [],
    readonly: []
  },
  options: {
    static: %i[onboot startup ostype arch unprivileged features hookscript protection
               debug timezone entrypoint env],
    dynamic: [],
    readonly: %i[arch]
  },
  console: {
    static: %i[console cmode tty],
    dynamic: [],
    readonly: []
  }
}.freeze
YAML_SPECIAL_CHARS =

Characters that require quoting in YAML output.

%w[: # [ ] { } > | * & ! % @ ` , ? -].freeze
AGENT_DEFAULTS =

Default values for QEMU Guest Agent properties (from Proxmox API docs). Used to fill in missing sub-properties when parsing agent config strings.

{
  enabled: "0",
  fstrim_cloned_disks: "0",
  :"freeze-fs-on-backup" => "1",
  type: "virtio"
}.freeze
VM_DEFAULTS =

Default values for VM config keys that Proxmox API omits when using defaults. These are injected by to_nested to produce complete manifests. Values sourced from Proxmox API docs (nodes-qemu-config.json).

{
  hotplug: "network,disk,usb"
}.freeze
VM_BOOLEAN_KEYS =

Top-level VM config keys that are boolean (0/1 in Proxmox API).

%i[onboot kvm tablet reboot freeze localtime protection numa keephugepages].freeze
CT_BOOLEAN_KEYS =

Top-level container config keys that are boolean (0/1 in Proxmox API).

%i[onboot unprivileged protection debug console].freeze
HOTPLUG_CAPABILITIES =

All possible hotplug capabilities for QEMU VMs.

%i[network disk usb cpu memory cloudinit].freeze
AGENT_BOOLEAN_SUBKEYS =

Sub-keys within agent config that are boolean (0/1 strings).

[:enabled, :fstrim_cloned_disks, :"freeze-fs-on-backup"].freeze
NET_BOOLEAN_SUBKEYS =

Sub-keys within VM network config that are boolean (0/1 strings).

%i[firewall link_down].freeze
DISK_BOOLEAN_SUBKEYS =

Sub-keys within disk config that are boolean (0/1 strings).

%i[iothread backup replicate ssd ro].freeze
VM_COMPLEX_KEYS =

Complex key mappings for QEMU VMs. Each entry maps a category to a regex pattern and parser/serializer method names. Used by to_nested/from_nested for bidirectional conversion of Proxmox config strings.

{
  net: { pattern: /\Anet\d+\z/, parser: :parse_vm_net_value, serializer: :serialize_vm_net_value },
  disk: { pattern: /\A(?:scsi|ide|virtio|sata|efidisk|tpmstate)\d*\z/, parser: :parse_disk_value,
          serializer: :serialize_disk_value },
  unused: { pattern: /\Aunused\d+\z/, parser: :parse_disk_value, serializer: :serialize_disk_value },
  boot: { pattern: /\Aboot\z/, parser: :parse_boot_value, serializer: :serialize_boot_value },
  agent: { pattern: /\Aagent\z/, parser: :parse_agent_value, serializer: :serialize_agent_value },
  hotplug: { pattern: /\Ahotplug\z/, parser: :parse_hotplug_value, serializer: :serialize_hotplug_value },
  startup: { pattern: /\Astartup\z/, parser: :parse_kv_value, serializer: :serialize_kv_value },
  ipconfig: { pattern: /\Aipconfig\d+\z/, parser: :parse_kv_value, serializer: :serialize_kv_value },
  smbios1: { pattern: /\Asmbios1\z/, parser: :parse_kv_value, serializer: :serialize_kv_value },
  numa_dev: { pattern: /\Anuma\d+\z/, parser: :parse_kv_value, serializer: :serialize_kv_value }
}.freeze
CT_COMPLEX_KEYS =

Complex key mappings for LXC containers.

{
  net: { pattern: /\Anet\d+\z/, parser: :parse_kv_value, serializer: :serialize_kv_value },
  rootfs: { pattern: /\Arootfs\z/, parser: :parse_disk_value, serializer: :serialize_disk_value },
  mp: { pattern: /\Amp\d+\z/, parser: :parse_disk_value, serializer: :serialize_disk_value },
  dev: { pattern: /\Adev\d+\z/, parser: :parse_disk_value, serializer: :serialize_disk_value },
  unused: { pattern: /\Aunused\d+\z/, parser: :parse_disk_value, serializer: :serialize_disk_value },
  startup: { pattern: /\Astartup\z/, parser: :parse_kv_value, serializer: :serialize_kv_value },
  features: { pattern: /\Afeatures\z/, parser: :parse_kv_value, serializer: :serialize_kv_value }
}.freeze

Class Method Summary collapse

Class Method Details

.complete_from_api(manifest_flat, api_flat, type:) ⇒ Hash{Symbol => Object}

Completes a manifest’s flat config with sub-properties from the API config. When a manifest omits sub-properties of complex keys (e.g., disk without volume, net without MAC address), fills them from the current API config to prevent false diffs during update comparison.

Examples:

manifest = { scsi0: "local-lvm,iothread=1,size=9G" }
api      = { scsi0: "local-lvm:vm-100-disk-0,iothread=1,size=9G" }
ConfigSerializer.complete_from_api(manifest, api, type: :vm)
#=> { scsi0: "local-lvm:vm-100-disk-0,iothread=1,size=9G" }

Parameters:

  • manifest_flat (Hash{Symbol => Object})

    flat config from manifest

  • api_flat (Hash{Symbol => Object})

    flat config from API (round-tripped)

  • type (Symbol)

    resource type (:vm or :container)

Returns:

  • (Hash{Symbol => Object})

    manifest config with completed complex values



451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/pvectl/config_serializer.rb', line 451

def complete_from_api(manifest_flat, api_flat, type:)
  manifest_flat.each_with_object({}) do |(key, value), result|
    api_value = api_flat[key]
    complex = find_complex_key(key, type)

    if complex && value.is_a?(String) && api_value.is_a?(String)
      parsed_api = send(complex[:parser], api_value)
      parsed_manifest = send(complex[:parser], value)
      merged = parsed_api.merge(parsed_manifest.compact)
      result[key] = send(complex[:serializer], merged)
    elsif api_value.is_a?(Integer) && value.is_a?(String) && value.match?(/\A\d+\z/)
      result[key] = value.to_i
    else
      result[key] = value
    end
  end
end

.diff(original, edited) ⇒ Hash{Symbol => Hash, Array}

Computes the diff between two flat config hashes.

Examples:

ConfigSerializer.diff({ cores: 4 }, { cores: 8, balloon: 2048 })
#=> { changed: { cores: [4, 8] }, added: { balloon: 2048 }, removed: [] }

Parameters:

  • original (Hash{Symbol => Object})

    original config

  • edited (Hash{Symbol => Object})

    edited config

Returns:

  • (Hash{Symbol => Hash, Array})

    diff with :changed, :added, :removed



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/pvectl/config_serializer.rb', line 323

def diff(original, edited)
  changed = {}
  added = {}
  removed = []

  all_keys = original.keys | edited.keys

  all_keys.each do |key|
    if original.key?(key) && edited.key?(key)
      changed[key] = [original[key], edited[key]] if original[key] != edited[key]
    elsif edited.key?(key)
      added[key] = edited[key]
    else
      removed << key
    end
  end

  { changed: changed, added: added, removed: removed }
end

.format_diff(diff_hash) ⇒ String

Formats a diff hash for colored terminal display.

Examples:

ConfigSerializer.format_diff(changed: { cores: [4, 8] }, added: {}, removed: [])
#=> "  ~ cores: 4 -> 8"  (yellow)

Parameters:

  • diff_hash (Hash)

    diff hash from diff

Returns:

  • (String)

    ANSI-colored diff output



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/pvectl/config_serializer.rb', line 351

def format_diff(diff_hash)
  lines = []

  diff_hash[:changed].each do |key, (old_val, new_val)|
    lines << "\e[33m  ~ #{key}: #{old_val} -> #{new_val}\e[0m"
  end

  diff_hash[:added].each do |key, value|
    lines << "\e[32m  + #{key}: #{value}\e[0m"
  end

  diff_hash[:removed].each do |key|
    lines << "\e[31m  - #{key}\e[0m"
  end

  lines.join("\n")
end

.from_nested(nested, type:) ⇒ Hash{Symbol => Object}

Converts a nested Hash (from manifest spec) back into a flat Proxmox config hash. Serializes parsed complex values back to Proxmox string format.

Examples:

nested = { hardware: { cpu: { cores: 4 } } }
ConfigSerializer.from_nested(nested, type: :vm)
#=> { cores: 4 }

Parameters:

  • nested (Hash{Symbol => Hash})

    nested hash from to_nested

  • type (Symbol)

    resource type (:vm or :container)

Returns:

  • (Hash{Symbol => Object})

    flat config hash



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/pvectl/config_serializer.rb', line 412

def from_nested(nested, type:)
  sections = sections_for(type)
  result = {}

  nested.each do |section_name, section_value|
    next unless section_value.is_a?(Hash)

    section_def = sections[section_name]
    next unless section_def

    if wrapper_section?(section_def)
      section_value.each do |_sub_name, sub_values|
        next unless sub_values.is_a?(Hash)

        flatten_nested_section(sub_values, type, result)
      end
    else
      flatten_nested_section(section_value, type, result)
    end
  end

  inject_defaults(result, type)
end

.from_yaml(yaml_string, type:) ⇒ Hash{Symbol => Object}

Parses a YAML string back into a flat config hash with symbol keys. Strips comment lines before parsing, then flattens nested sections.

Examples:

ConfigSerializer.from_yaml("general:\n  name: web\ncpu:\n  cores: 4", type: :vm)
#=> { name: "web", cores: 4 }

Parameters:

  • yaml_string (String)

    YAML string (potentially with comments)

  • type (Symbol)

    resource type (:vm or :container) - reserved for future use

Returns:

  • (Hash{Symbol => Object})

    flat config hash



235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/pvectl/config_serializer.rb', line 235

def from_yaml(yaml_string, type:)
  cleaned = strip_comments(yaml_string)
  return {} if cleaned.strip.empty?

  begin
    parsed = YAML.safe_load(cleaned)
  rescue Psych::SyntaxError
    return {}
  end
  return {} unless parsed.is_a?(Hash)

  flatten_sections(parsed)
end

.readonly_violations(original_flat, edited_flat, type:) ⇒ Array<String>

Checks if any read-only fields were modified between original and edited configs.

Examples:

ConfigSerializer.readonly_violations({ vmid: 100 }, { vmid: 999 }, type: :vm)
#=> ["vmid"]

Parameters:

  • original_flat (Hash{Symbol => Object})

    original flat config

  • edited_flat (Hash{Symbol => Object})

    edited flat config

  • type (Symbol)

    resource type (:vm or :container)

Returns:

  • (Array<String>)

    list of read-only field names that were changed



306
307
308
309
310
311
312
# File 'lib/pvectl/config_serializer.rb', line 306

def readonly_violations(original_flat, edited_flat, type:)
  sections = sections_for(type)
  readonly_keys = collect_readonly_keys(original_flat.keys | edited_flat.keys, sections)

  readonly_keys.select { |key| original_flat[key] != edited_flat[key] }
               .map(&:to_s)
end

.to_nested(flat_config, type:) ⇒ Hash{Symbol => Hash}

Converts a flat Proxmox config hash into a nested Hash with parsed complex values. Used by ManifestSerializer to build the spec section of YAML manifests.

Examples:

ConfigSerializer.to_nested({ cores: 4, net0: "virtio=AA:BB,bridge=vmbr0" }, type: :vm)
#=> { hardware: { cpu: { cores: 4 }, network: { net0: { model: "virtio", mac: "AA:BB", bridge: "vmbr0" } } } }

Parameters:

  • flat_config (Hash{Symbol => Object})

    flat config hash with symbol keys

  • type (Symbol)

    resource type (:vm or :container)

Returns:

  • (Hash{Symbol => Hash})

    nested hash matching section structure



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/pvectl/config_serializer.rb', line 379

def to_nested(flat_config, type:)
  sections = sections_for(type)
  config_with_defaults = inject_defaults(flat_config, type)
  result = {}

  sections.each do |section_name, section_def|
    if wrapper_section?(section_def)
      wrapper = {}
      section_def.each do |sub_name, sub_def|
        sub_hash = build_nested_section(config_with_defaults, sub_def, type)
        wrapper[sub_name] = sub_hash unless sub_hash.empty?
      end
      result[section_name] = wrapper unless wrapper.empty?
    else
      section_hash = build_nested_section(config_with_defaults, section_def, type)
      result[section_name] = section_hash unless section_hash.empty?
    end
  end

  result
end

.to_yaml(flat_config, type:, resource: {}) ⇒ String

Converts a flat Proxmox config hash into a nested, section-grouped YAML string with header comments and read-only markers.

Examples:

ConfigSerializer.to_yaml({ vmid: 100, cores: 4 }, type: :vm,
  resource: { vmid: 100, node: "pve1", status: "running" })

Parameters:

  • flat_config (Hash)

    flat config hash with symbol keys

  • type (Symbol)

    resource type (:vm or :container)

  • resource (Hash) (defaults to: {})

    resource metadata (vmid, node, status) for header

Returns:

  • (String)

    formatted YAML string with comments



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/pvectl/config_serializer.rb', line 207

def to_yaml(flat_config, type:, resource: {})
  sections = sections_for(type)
  lines = []

  lines << header_comment(type, resource)
  lines << ""

  sections.each do |section_name, section_def|
    if wrapper_section?(section_def)
      render_wrapper_section(lines, section_name, section_def, flat_config)
    else
      render_leaf_section(lines, section_name, section_def, flat_config)
    end
  end

  lines.join("\n")
end

.validate(yaml_string, type:) ⇒ Array<String>

Validates a YAML string against known section/key mappings.

Examples:

ConfigSerializer.validate("foo:\n  bar: 1", type: :vm)
#=> ["Unknown section 'foo'"]

Parameters:

  • yaml_string (String)

    YAML string to validate

  • type (Symbol)

    resource type (:vm or :container)

Returns:

  • (Array<String>)

    list of error messages (empty if valid)



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/pvectl/config_serializer.rb', line 258

def validate(yaml_string, type:)
  errors = []
  cleaned = strip_comments(yaml_string)

  begin
    parsed = YAML.safe_load(cleaned)
  rescue Psych::SyntaxError => e
    return ["YAML syntax error: #{e.message}"]
  end

  return errors unless parsed.is_a?(Hash)

  sections = sections_for(type)

  parsed.each do |section_name, section_values|
    unless sections.key?(section_name.to_sym)
      errors << "Unknown section '#{section_name}'"
      next
    end

    next unless section_values.is_a?(Hash)

    section_def = sections[section_name.to_sym]

    if wrapper_section?(section_def)
      validate_wrapper_section(errors, section_name, section_def, section_values)
    else
      section_values.each_key do |key|
        unless key_in_section?(key.to_sym, section_def)
          errors << "Unknown key '#{key}' in section '#{section_name}'"
        end
      end
    end
  end

  errors
end