Class: Pvectl::Commands::Push

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

Overview

Push command -- applies YAML manifests to the Proxmox cluster. Creates resources that don't exist, updates those that do.

Examples:

Register with CLI

Push.register(cli)

Constant Summary collapse

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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(args, options, global_options) ⇒ Push



69
70
71
72
73
74
# File 'lib/pvectl/commands/push.rb', line 69

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

Class Method Details

.register(cli) ⇒ void

This method returns an undefined value.

Registers the push command with the CLI.



23
24
25
26
27
28
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
# File 'lib/pvectl/commands/push.rb', line 23

def self.register(cli)
  cli.desc "Push YAML manifest to cluster (create or update)"
  cli.long_desc "    DESCRIPTION\n      Applies resource configuration from YAML manifest files to the\n      Proxmox cluster. Creates resources that don't exist, updates those\n      that do. Shows a diff and asks for confirmation before applying.\n\n    EXAMPLES\n      $ pvectl push vm -f vm-100.yaml\n      $ pvectl push vm -f ./manifests/\n      $ pvectl push -f ./manifests/\n      $ pvectl push vm -f vm-100.yaml --dry-run\n      $ pvectl push vm -f vm-100.yaml --yes\n      $ pvectl push vm -f vm-new.yaml            # no vmid \u2192 auto-assign\n      $ pvectl pull vm 100 | pvectl push --yes\n      $ cat vm-100.yaml | pvectl push vm --dry-run\n\n    NOTES\n      Without -f, reads YAML from stdin (pipe-friendly).\n      With -f, reads from file or directory (repeatable).\n      Without resource type, reads kind from each manifest.\n      If metadata.vmid is omitted, a new VMID is auto-assigned\n      and the source YAML file is updated with the assigned ID.\n      Stdin mode requires --yes or --dry-run (no interactive prompt).\n      With --yes, skips confirmation (useful for CI/CD).\n      With --dry-run, shows diff without applying changes.\n\n    SEE ALSO\n      pull, edit, create, delete\n  HELP\n\n  cli.command :push do |c|\n    c.flag [:f, :file], desc: \"YAML file or directory to push\", multiple: true\n    c.switch [:y, :yes], desc: \"Auto-confirm without prompting\", negatable: false\n    c.switch [:\"dry-run\"], desc: \"Show diff without applying\", negatable: false\n\n    c.action do |global_options, options, args|\n      Push.new(args, options, global_options).execute\n    end\n  end\nend\n"

Instance Method Details

#build_services(connection) ⇒ Array(Services::PushConfig, Services::PullConfig)

Builds the PushConfig and PullConfig services with shared repositories.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/pvectl/commands/push.rb', line 314

def build_services(connection)
  vm_repo = Pvectl::Repositories::Vm.new(connection)
  ct_repo = Pvectl::Repositories::Container.new(connection)
  task_repo = Pvectl::Repositories::Task.new(connection)

  push_service = Pvectl::Services::PushConfig.new(
    vm_repository: vm_repo,
    container_repository: ct_repo,
    task_repository: task_repo
  )

  pull_service = Pvectl::Services::PullConfig.new(
    vm_repository: vm_repo,
    container_repository: ct_repo
  )

  [push_service, pull_service]
end

#collect_yaml_contents(paths) ⇒ Array<Hash>

Collects YAML file contents from given paths (files or directories).



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

def collect_yaml_contents(paths)
  contents = []
  paths.each do |path|
    if File.directory?(path)
      Dir.glob(File.join(path, "*.{yaml,yml}")).sort.each do |file|
        contents << { filename: File.basename(file), content: File.read(file), path: File.expand_path(file) }
      end
    elsif File.file?(path)
      contents << { filename: File.basename(path), content: File.read(path), path: File.expand_path(path) }
    else
      $stderr.puts "Error: File not found: #{path}"
    end
  end
  contents
end

#display_plans(plans) ⇒ void

This method returns an undefined value.

Displays push plans with diffs to stdout.



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/pvectl/commands/push.rb', line 228

def display_plans(plans)
  plans.each do |plan|
    label = plan[:type] == :container ? "Container" : "VM"
    if plan[:action] == :update
      $stdout.puts "\n#{label} #{plan[:vmid]} (#{plan[:node]}) -- UPDATE:"
      $stdout.puts ConfigSerializer.format_diff(plan[:diff])
      if plan[:resize_ops]&.any?
        plan[:resize_ops].each do |op|
          $stdout.puts "  (disk resize: #{op[:disk]} -> #{op[:size]})"
        end
      end
    elsif plan[:action] == :create
      id_note = plan[:auto_id] ? " (auto-assigned)" : ""
      $stdout.puts "\n#{label} #{plan[:vmid]}#{id_note} (#{plan[:node]}) -- CREATE:"
      plan[:params].each do |key, val|
        $stdout.puts "  + #{key}: #{val}"
      end
    end
  end
end

#executeInteger

Executes the push command.



79
80
81
82
83
84
85
86
87
88
89
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/pvectl/commands/push.rb', line 79

def execute
  args = @args.dup
  filter_type = parse_resource_type(args)

  unless args.empty?
    return usage_error("Unexpected arguments: #{args.join(', ')}. Use -f to specify files.")
  end

  yaml_contents = read_input
  return usage_error("No YAML content provided. Use -f <path> or pipe YAML to stdin.") if yaml_contents.empty?

  load_config
  connection = Pvectl::Connection.new(@config)
  service, pull_service = build_services(connection)

  result = service.prepare_batch(yaml_contents, filter_type: filter_type)

  # Report errors and skipped
  result[:errors].each { |e| $stderr.puts "Error: #{e}" }
  result[:skipped].each { |s| $stderr.puts "Info: #{s}" }

  if result[:plans].empty?
    if result[:errors].empty?
      $stdout.puts "No changes to apply."
    end

    # Refresh unchanged file-backed manifests with server-assigned values
    # (MAC addresses, volume names, UUIDs, etc.)
    refresh_unchanged_manifests(result[:unchanged] || [], pull_service)

    return result[:errors].empty? ? ExitCodes::SUCCESS : ExitCodes::GENERAL_ERROR
  end

  # Display plans
  display_plans(result[:plans])

  if @options[:"dry-run"]
    $stdout.puts "\n(dry-run mode -- no changes applied)"
    return ExitCodes::SUCCESS
  end

  # Confirm unless --yes
  unless @options[:yes]
    if @stdin_mode
      return usage_error("Stdin mode requires --yes or --dry-run (no interactive prompt available)")
    end
    $stdout.print "\nApply #{result[:plans].length} change(s)? [y/N] "
    answer = $stdin.gets&.strip&.downcase
    unless answer == "y" || answer == "yes"
      $stdout.puts "Cancelled."
      return ExitCodes::SUCCESS
    end
  end

  # Apply
  apply_result = service.apply(result[:plans])

  apply_result[:results].each do |r|
    if r[:success]
      $stdout.puts "#{r[:action].capitalize}d #{type_label_for(r)} #{r[:vmid]} successfully."
    else
      $stderr.puts "Error: Failed to #{r[:action]} #{r[:vmid]}: #{r[:error]}"
    end
  end

  # Refresh source manifest files with current server state
  refresh_manifests(apply_result[:results], result[:plans], pull_service)
  refresh_unchanged_manifests(result[:unchanged] || [], pull_service)

  apply_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

#load_configvoid

This method returns an undefined value.

Loads configuration from file/env.



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

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

#parse_resource_type(args) ⇒ Symbol?

Parses and removes the optional resource type from the argument list.



194
195
196
197
198
199
200
201
202
# File 'lib/pvectl/commands/push.rb', line 194

def parse_resource_type(args)
  return nil if args.empty?

  first = args.first.downcase
  if RESOURCE_TYPES.key?(first)
    args.shift
    RESOURCE_TYPES[first]
  end
end

#read_inputArray<Hash>

Reads YAML input from -f flag (files/directories) or stdin.



165
166
167
168
169
170
171
172
173
174
# File 'lib/pvectl/commands/push.rb', line 165

def read_input
  file_flag = @options[:file]
  if file_flag && !file_flag.empty?
    @stdin_mode = false
    collect_yaml_contents(Array(file_flag))
  else
    @stdin_mode = true
    read_stdin
  end
end

#read_stdinArray<Hash>

Reads YAML content from stdin.



179
180
181
182
183
184
185
186
187
188
# File 'lib/pvectl/commands/push.rb', line 179

def read_stdin
  if $stdin.tty?
    $stderr.puts "Error: No input. Use -f <path> or pipe YAML to stdin."
    return []
  end
  content = $stdin.read
  return [] if content.nil? || content.strip.empty?

  [{ filename: "stdin", content: content }]
end

#refresh_manifest_file(path, type, vmid, pull_service) ⇒ void

This method returns an undefined value.

Re-pulls a single resource and writes the updated YAML to the source file.



300
301
302
303
304
305
306
307
308
# File 'lib/pvectl/commands/push.rb', line 300

def refresh_manifest_file(path, type, vmid, pull_service)
  pull_result = pull_service.execute(type: type, ids: [vmid])
  return if pull_result[:manifests].empty?

  File.write(path, pull_result[:manifests].first[:yaml])
  $stderr.puts "Refreshed #{path}"
rescue StandardError => e
  $stderr.puts "Warning: Could not refresh #{path}: #{e.message}"
end

#refresh_manifests(results, plans, pull_service) ⇒ void

This method returns an undefined value.

Refreshes source manifest files with the current server state after successful apply. Re-pulls each resource and overwrites the source file, ensuring local manifests include server-assigned values (volume names, MAC addresses, etc.).



266
267
268
269
270
271
272
273
274
275
276
# File 'lib/pvectl/commands/push.rb', line 266

def refresh_manifests(results, plans, pull_service)
  results.each_with_index do |r, idx|
    next unless r[:success]

    plan = plans[idx]
    source_path = plan[:source_path]
    next unless source_path && File.file?(source_path)

    refresh_manifest_file(source_path, plan[:type], plan[:vmid], pull_service)
  end
end

#refresh_unchanged_manifests(unchanged, pull_service) ⇒ void

This method returns an undefined value.

Refreshes file-backed manifests that had no changes to apply. Re-pulls each resource to fill in server-assigned values (MAC addresses, volume names, UUIDs, etc.) that may not be in the local manifest.



285
286
287
288
289
290
291
# File 'lib/pvectl/commands/push.rb', line 285

def refresh_unchanged_manifests(unchanged, pull_service)
  unchanged.each do |entry|
    next unless entry[:source_path] && File.file?(entry[:source_path])

    refresh_manifest_file(entry[:source_path], entry[:type], entry[:vmid], pull_service)
  end
end

#type_label_for(result) ⇒ String

Returns a human-readable type label for a result hash.



253
254
255
# File 'lib/pvectl/commands/push.rb', line 253

def type_label_for(result)
  result[:type] == :container ? "Container" : "VM"
end

#usage_error(message) ⇒ Integer

Prints a usage error and returns the USAGE_ERROR exit code.



346
347
348
349
# File 'lib/pvectl/commands/push.rb', line 346

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