Class: Pvectl::Commands::Pull
- Inherits:
-
Object
- Object
- Pvectl::Commands::Pull
- 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.
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
-
.register(cli) ⇒ void
Registers the pull command with the CLI.
Instance Method Summary collapse
-
#apply_file_operations(operations) ⇒ void
Writes files for all actionable operations.
-
#build_file_operations(manifests, type, output) ⇒ Array<Hash>
Builds a list of file operations (create/update/unchanged) for each manifest.
-
#build_operation(manifest, path, type) ⇒ Hash
Builds a single file operation by comparing new YAML with existing file.
- #build_selector(type) ⇒ Object
- #build_service(connection) ⇒ Services::PullConfig
-
#compute_manifest_diff(old_yaml, new_yaml, type) ⇒ Hash?
Computes a flat config diff between old and new manifest YAML strings.
-
#diff_has_changes?(diff) ⇒ Boolean
Checks if a diff hash has any actual changes.
- #directory_output?(path) ⇒ Boolean
-
#display_pull_plan(operations, type) ⇒ void
Displays the pull plan with diffs for each file operation.
-
#execute ⇒ Integer
Executes the pull command.
-
#infer_ids_from_directory(directory, type) ⇒ Array<Integer>
Infers resource IDs from existing manifest files in a directory.
-
#initialize(args, options, global_options) ⇒ Pull
constructor
A new instance of Pull.
- #load_config ⇒ void
- #usage_error(message) ⇒ Integer
-
#write_output(manifests, type, output) ⇒ void
Writes pull results to stdout or files.
Constructor Details
#initialize(args, options, global_options) ⇒ Pull
81 82 83 84 85 |
# File 'lib/pvectl/commands/pull.rb', line 81 def initialize(args, , ) @args = args @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 |, , args| Pull.new(args, , ).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 |
#execute ⇒ Integer
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.}" 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_config ⇒ void
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() $stderr.puts "Error: #{}" 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 |