Class: Pvectl::Commands::Pull

Inherits:
Object
  • Object
show all
Defined in:
lib/pvectl/commands/pull.rb,
sig/pvectl/commands/pull.rbs

Overview

Pull command -- exports resource configuration from the Proxmox cluster as kubectl-like YAML manifest files.

Examples:

Register with CLI

Pull.register(cli)

Constant Summary collapse

RESOURCE_TYPES =
{
  "vm" => :vm,
  "vms" => :vm,
  "container" => :container,
  "containers" => :container,
  "ct" => :container
}.freeze
FILE_PREFIXES =
{
  vm: "vm",
  container: "ct"
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(args, options, global_options) ⇒ Pull



81
82
83
84
85
# File 'lib/pvectl/commands/pull.rb', line 81

def initialize(args, options, global_options)
  @args = args
  @options = options
  @global_options = global_options
end

Class Method Details

.register(cli) ⇒ void

This method returns an undefined value.

Registers the pull command with the CLI.



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
# File 'lib/pvectl/commands/pull.rb', line 30

def self.register(cli)
  cli.desc "Pull resource configuration to YAML manifest"
  cli.long_desc <<~HELP
    DESCRIPTION
      Exports resource configuration from the Proxmox cluster as kubectl-like
      YAML manifest files. Supports single resources, multiple IDs, selectors,
      and bulk export with --all.

      When writing to files (-f), shows a diff of changes and asks for
      confirmation before overwriting existing files. Use --yes to skip
      confirmation or --dry-run to preview changes without writing.

    EXAMPLES
      $ pvectl pull vm 100
      $ pvectl pull vm 100 -f vm-100.yaml
      $ pvectl pull vm 100 -f vm-100.yaml --dry-run
      $ pvectl pull vm 100 101 102 -f ./manifests/
      $ pvectl pull vm --all -f ./manifests/ --yes
      $ pvectl pull vm -f ./manifests/
      $ pvectl pull vm -l tags=prod -f ./manifests/
      $ pvectl pull container 200

    NOTES
      Without -f, YAML is printed to stdout (pipe-friendly).
      With -f, shows diff against existing files and asks to confirm.
      With --all or -l, -f must point to a directory.
      When -f points to a directory with existing manifests and no IDs
      are given, IDs are inferred from file names (vm-{vmid}.yaml).
      File naming convention: vm-{vmid}.yaml or ct-{vmid}.yaml.

    SEE ALSO
      push, get, describe, edit
  HELP

  cli.command :pull do |c|
    c.flag [:f, :file], desc: "Output file or directory"
    c.flag [:l, :selector], desc: "Filter by selector (e.g. tags=prod,status=running)", multiple: true
    c.switch [:all], desc: "Pull all resources of given type", negatable: false
    c.switch [:y, :yes], desc: "Auto-confirm without prompting", negatable: false
    c.switch [:"dry-run"], desc: "Show diff without writing files", negatable: false
    c.flag [:node], desc: "Limit to specific node"

    c.action do |global_options, options, args|
      Pull.new(args, options, global_options).execute
    end
  end
end

Instance Method Details

#apply_file_operations(operations) ⇒ void

This method returns an undefined value.

Writes files for all actionable operations.



293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/pvectl/commands/pull.rb', line 293

def apply_file_operations(operations)
  written = 0
  operations.each do |op|
    next if op[:action] == :unchanged

    dir = File.dirname(op[:path])
    FileUtils.mkdir_p(dir) unless File.directory?(dir)
    File.write(op[:path], op[:yaml])
    written += 1
  end
  $stderr.puts "Written #{written} manifest(s)"
end

#build_file_operations(manifests, type, output) ⇒ Array<Hash>

Builds a list of file operations (create/update/unchanged) for each manifest.



208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/pvectl/commands/pull.rb', line 208

def build_file_operations(manifests, type, output)
  if manifests.length == 1 && !directory_output?(output)
    [build_operation(manifests.first, output, type)]
  else
    prefix = FILE_PREFIXES[type]
    manifests.map do |m|
      filename = "#{prefix}-#{m[:vmid]}.yaml"
      path = File.join(output, filename)
      build_operation(m, path, type)
    end
  end
end

#build_operation(manifest, path, type) ⇒ Hash

Builds a single file operation by comparing new YAML with existing file.



227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/pvectl/commands/pull.rb', line 227

def build_operation(manifest, path, type)
  new_yaml = manifest[:yaml]

  unless File.file?(path)
    return { action: :create, path: path, vmid: manifest[:vmid], yaml: new_yaml }
  end

  old_yaml = File.read(path)
  return { action: :unchanged, path: path, vmid: manifest[:vmid] } if old_yaml == new_yaml

  diff = compute_manifest_diff(old_yaml, new_yaml, type)
  { action: :update, path: path, vmid: manifest[:vmid], yaml: new_yaml, diff: diff }
end

#build_selector(type) ⇒ Object



150
151
152
153
154
155
156
# File 'lib/pvectl/commands/pull.rb', line 150

def build_selector(type)
  expressions = @options[:selector]
  return nil if expressions.nil? || (expressions.is_a?(Array) && expressions.empty?)

  selector_class = type == :container ? Selectors::Container : Selectors::Vm
  selector_class.new(Array(expressions))
end

#build_service(connection) ⇒ Services::PullConfig



141
142
143
144
145
146
147
148
# File 'lib/pvectl/commands/pull.rb', line 141

def build_service(connection)
  vm_repo = Pvectl::Repositories::Vm.new(connection)
  ct_repo = Pvectl::Repositories::Container.new(connection)
  Pvectl::Services::PullConfig.new(
    vm_repository: vm_repo,
    container_repository: ct_repo
  )
end

#compute_manifest_diff(old_yaml, new_yaml, type) ⇒ Hash?

Computes a flat config diff between old and new manifest YAML strings.



247
248
249
250
251
252
253
254
255
# File 'lib/pvectl/commands/pull.rb', line 247

def compute_manifest_diff(old_yaml, new_yaml, type)
  old_manifest = ManifestSerializer.from_yaml(old_yaml)
  new_manifest = ManifestSerializer.from_yaml(new_yaml)
  old_flat = ConfigSerializer.from_nested(old_manifest[:spec], type: type)
  new_flat = ConfigSerializer.from_nested(new_manifest[:spec], type: type)
  ConfigSerializer.diff(old_flat, new_flat)
rescue StandardError
  nil
end

#diff_has_changes?(diff) ⇒ Boolean

Checks if a diff hash has any actual changes.



285
286
287
# File 'lib/pvectl/commands/pull.rb', line 285

def diff_has_changes?(diff)
  diff[:changed].any? || diff[:added].any? || diff[:removed].any?
end

#directory_output?(path) ⇒ Boolean



324
325
326
327
328
# File 'lib/pvectl/commands/pull.rb', line 324

def directory_output?(path)
  return false if path.nil?

  path.end_with?("/") || File.directory?(path)
end

#display_pull_plan(operations, type) ⇒ void

This method returns an undefined value.

Displays the pull plan with diffs for each file operation.



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/pvectl/commands/pull.rb', line 262

def display_pull_plan(operations, type)
  label = type == :container ? "Container" : "VM"
  operations.each do |op|
    case op[:action]
    when :create
      $stdout.puts "\n#{label} #{op[:vmid]} -- NEW (#{File.basename(op[:path])})"
    when :update
      $stdout.puts "\n#{label} #{op[:vmid]} -- UPDATE (#{File.basename(op[:path])}):"
      if op[:diff] && diff_has_changes?(op[:diff])
        $stdout.puts ConfigSerializer.format_diff(op[:diff])
      else
        $stdout.puts "  (metadata changed)"
      end
    when :unchanged
      $stderr.puts "Info: #{File.basename(op[:path])}: no changes"
    end
  end
end

#executeInteger

Executes the pull command.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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
# File 'lib/pvectl/commands/pull.rb', line 90

def execute
  resource_type_str = @args.shift
  return usage_error("Resource type is required (vm, container)") unless resource_type_str

  type = RESOURCE_TYPES[resource_type_str.downcase]
  return usage_error("Unknown resource type '#{resource_type_str}'. Valid: vm, container") unless type

  ids = @args.map(&:to_i)
  all = @options[:all]
  node = @options[:node]
  output = @options[:file]

  # Auto-infer IDs from existing manifest files in directory
  if ids.empty? && !all && @options[:selector].nil? && output && directory_output?(output)
    ids = infer_ids_from_directory(output, type)
  end

  if ids.empty? && !all && @options[:selector].nil?
    return usage_error("Provide resource IDs, --all, or -l selector")
  end

  if (all || @options[:selector]) && output && !directory_output?(output)
    return usage_error("--all and -l require -f to be a directory (end with /)")
  end

  selector = build_selector(type)

  load_config
  connection = Pvectl::Connection.new(@config)
  service = build_service(connection)

  result = service.execute(type: type, ids: ids, all: all, node: node, selector: selector)

  result[:errors].each { |e| $stderr.puts "Error: #{e}" }

  write_output(result[:manifests], type, output)

  result[:errors].empty? ? ExitCodes::SUCCESS : ExitCodes::GENERAL_ERROR
rescue Pvectl::Config::ConfigNotFoundError,
       Pvectl::Config::InvalidConfigError,
       Pvectl::Config::ContextNotFoundError,
       Pvectl::Config::ClusterNotFoundError,
       Pvectl::Config::UserNotFoundError
  raise
rescue StandardError => e
  $stderr.puts "Error: #{e.message}"
  ExitCodes::GENERAL_ERROR
end

#infer_ids_from_directory(directory, type) ⇒ Array<Integer>

Infers resource IDs from existing manifest files in a directory. Scans for files matching prefix-vmid.yaml pattern.



312
313
314
315
316
317
318
319
320
321
322
# File 'lib/pvectl/commands/pull.rb', line 312

def infer_ids_from_directory(directory, type)
  return [] unless File.directory?(directory)

  prefix = FILE_PREFIXES[type]
  pattern = File.join(directory, "#{prefix}-*.yaml")
  Dir.glob(pattern).filter_map do |path|
    basename = File.basename(path, ".yaml")
    match = basename.match(/\A#{Regexp.escape(prefix)}-(\d+)\z/)
    match[1].to_i if match
  end.sort
end

#load_configvoid



330
331
332
333
334
# File 'lib/pvectl/commands/pull.rb', line 330

def load_config
  service = Pvectl::Config::Service.new
  service.load(config: @global_options[:config])
  @config = service.current_config
end

#usage_error(message) ⇒ Integer



336
337
338
339
# File 'lib/pvectl/commands/pull.rb', line 336

def usage_error(message)
  $stderr.puts "Error: #{message}"
  ExitCodes::USAGE_ERROR
end

#write_output(manifests, type, output) ⇒ void

This method returns an undefined value.

Writes pull results to stdout or files. For file output, shows diff against existing files and asks for confirmation.



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
# File 'lib/pvectl/commands/pull.rb', line 165

def write_output(manifests, type, output)
  return if manifests.empty?

  if output.nil?
    # stdout mode -- no diff, just print
    manifests.each { |m| $stdout.puts m[:yaml] }
    return
  end

  # File output mode -- build operations, show diff, confirm, write
  operations = build_file_operations(manifests, type, output)
  actionable = operations.reject { |op| op[:action] == :unchanged }

  if actionable.empty?
    $stdout.puts "No changes."
    return
  end

  display_pull_plan(operations, type)

  if @options[:"dry-run"]
    $stdout.puts "\n(dry-run mode -- no files written)"
    return
  end

  unless @options[:yes]
    $stdout.print "\nWrite #{actionable.length} file(s)? [y/N] "
    answer = $stdin.gets&.strip&.downcase
    unless answer == "y" || answer == "yes"
      $stdout.puts "Cancelled."
      return
    end
  end

  apply_file_operations(operations)
end