knife-proxmox-ve 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d5214d06c2b3094351f1e2824fc0d3e9cceba436f2053bb32001b41fe18ea5c
4
- data.tar.gz: fad9ae85bb184f7dd8fdb6e54cac5a15a8f9036f1f5f4a07b939cd0a4a0e3f3a
3
+ metadata.gz: 918852ed3cc22ff0316c1eb9f3ae5e9da00254225926e37f0642ca5986085919
4
+ data.tar.gz: b096cd71927d635a5b3782a9b77cd558e8d782256120b6ab7b2ead348dfd1dea
5
5
  SHA512:
6
- metadata.gz: c4d3a3bdff34585a82c46888572244512f06a7a17b94475b135f2d5f520c2b47d983e07f69cb40baa2ebc2fca8526db2cd5c6c7928ab1f778d2db28061167a05
7
- data.tar.gz: 9008cdcd204db73879b3f2a4602e202d821dd70b9df217ebc8ff90e59342e7ff805c516497cd7c62970c531fd6f04df7e69f2fac84305836ba1f9faf821f7630
6
+ metadata.gz: 15d9f3e83831755684014fff78b5ed931e1951ebd99343406172f60bb307590626765505685ed52f57816b9a128f125d10bdd9c150f7b84b2bae84b827449981
7
+ data.tar.gz: ded4dcf52c6e1ae6f5dc58a850919fec0b7684cc62deda036c6b0f8e4deed321f7591ede5c4269dec282c1589068a7476edb25de1b2b17e138f698e9c6562891
data/README.md CHANGED
@@ -151,6 +151,8 @@ Both commands share the provisioning flags below.
151
151
  | `--linked-clone` | Linked clone instead of the default full clone (`--storage` is then ignored). |
152
152
  | `--storage NAME` / `--pool NAME` | Target storage / resource pool. |
153
153
  | `--cores N` / `--sockets N` / `--memory MiB` | CPU / RAM. |
154
+ | `--disk-size SIZE` | Grow the disk before the first boot: `+20G` adds to the template's size, `50G` sets it. |
155
+ | `--disk DISK` | Disk to resize (`scsi0`, `virtio0`, …). Defaults to the cloned VM's boot disk. |
154
156
  | `--bridge vmbrN` / `--vlan TAG` | net0 bridge and VLAN tag (needs a VLAN-aware bridge). |
155
157
  | `--ip CIDR\|dhcp` / `--gateway IP` / `--prefix N` | Static IPv4 (`10.0.10.5/24`, or a bare IP with `--prefix`) or `dhcp`. |
156
158
  | `--nameserver IP` / `--searchdomain DOMAIN` | cloud-init DNS. |
@@ -169,6 +171,23 @@ freshly-created host), `--yes`, etc. They do not apply to `vm create`, which nev
169
171
  clone (faster, thin) — it requires a storage that supports it, and any `--storage` (CLI or
170
172
  cluster default) is then dropped, because Proxmox rejects a target storage on a linked clone.
171
173
 
174
+ **Disk resize.** `--disk-size` grows the clone's disk **between the clone and the first start**,
175
+ so cloud-init's `growpart`/`resizefs` claim the extra space on that first boot and the guest
176
+ filesystem comes up at full size — no second run, no manual step in the guest. The size format is
177
+ Proxmox's own: `+20G` adds 20 GiB to whatever the template had (the portable choice), a bare `50G`
178
+ sets an absolute size. **Proxmox cannot shrink a disk** — an absolute size below the current one is
179
+ rejected by the API.
180
+
181
+ Without `--disk`, the disk is the cloned VM's boot disk: the first entry of its `boot` order that is
182
+ a real disk (cloud-init drives and CD-ROMs are skipped), falling back to its lowest-sorting disk key.
183
+ Pass `--disk scsi1` to grow a specific one — naming a disk the VM does not have fails before the VM
184
+ is started, rather than silently doing nothing. Growing the disk requires `VM.Config.Disk` plus
185
+ `Datastore.AllocateSpace` on the target storage.
186
+
187
+ ```bash
188
+ knife proxmox vm create web-01 --template ubuntu-2404-template --disk-size +40G
189
+ ```
190
+
172
191
  **Bootstrap product (CINC by default).** A `vm bootstrap`'d VM is bootstrapped with **CINC** out of the
173
192
  box — the install is pinned to the CINC omnitruck regardless of whether you invoke a CINC- or
174
193
  Chef-branded `knife`, which also avoids Chef's commercial license gate. To bootstrap with Chef
@@ -28,6 +28,13 @@ class Chef
28
28
  # ENV override for the cloud-init password so it never lands in shell history.
29
29
  ENV_CIPASSWORD = "KNIFE_PROXMOX_CIPASSWORD"
30
30
 
31
+ # A qemu disk key: bus name plus its index (scsi0, virtio1, sata0, ide0).
32
+ DISK_PATTERN = /\A(?:scsi|virtio|sata|ide)\d+\z/
33
+
34
+ # Proxmox's own size format for the resize endpoint: an optional "+" (grow by), a
35
+ # number, and an optional K/M/G/T unit (no unit means bytes).
36
+ DISK_SIZE_PATTERN = /\A\+?\d+(\.\d+)?[KMGT]?\z/
37
+
31
38
  def self.included(includer)
32
39
  includer.class_eval do
33
40
  # --- Source template / clone placement --------------------------------
@@ -72,6 +79,15 @@ class Chef
72
79
  long: "--memory MiB",
73
80
  description: "Memory in MiB."
74
81
 
82
+ option :disk,
83
+ long: "--disk DISK",
84
+ description: "Disk to resize (e.g. scsi0). Defaults to the cloned VM's boot disk."
85
+
86
+ option :disk_size,
87
+ long: "--disk-size SIZE",
88
+ description: "Resize the disk before the first boot: '+20G' adds to the template's " \
89
+ "size, '50G' sets it. Proxmox cannot shrink a disk."
90
+
75
91
  # --- Networking -------------------------------------------------------
76
92
 
77
93
  option :bridge,
@@ -147,6 +163,7 @@ class Chef
147
163
 
148
164
  validate_scalars!
149
165
  validate_network!
166
+ validate_disk!
150
167
  resolve_ssh_auth!(require_credential: require_ssh_auth)
151
168
  true
152
169
  end
@@ -222,6 +239,23 @@ class Chef
222
239
  ui.fatal!("--gateway #{gateway.inspect} is not a valid IPv4 address")
223
240
  end
224
241
 
242
+ # The size format is Proxmox's, so it is checked here rather than server-side: a typo
243
+ # like "20GB" would otherwise surface as an opaque HTTP 400 after the clone already ran.
244
+ def validate_disk!
245
+ disk = config[:disk]
246
+ size = config[:disk_size]
247
+
248
+ if !disk.nil? && !DISK_PATTERN.match?(disk.to_s)
249
+ ui.fatal!("--disk #{disk.inspect} is not a disk key (e.g. scsi0, virtio0, sata0, ide0)")
250
+ end
251
+ ui.fatal!("--disk needs --disk-size; naming a disk alone changes nothing") if !disk.nil? && size.nil?
252
+ return if size.nil?
253
+ return if DISK_SIZE_PATTERN.match?(size.to_s)
254
+
255
+ ui.fatal!("--disk-size #{size.inspect} is not a Proxmox size " \
256
+ "(e.g. +20G to grow by 20 GiB, or 50G for an absolute size)")
257
+ end
258
+
225
259
  def positive_integer!(key, value)
226
260
  int = integer!(key, value)
227
261
  ui.fatal!("--#{key.to_s.tr("_", "-")} must be greater than 0 (got #{int})") unless int > 0
@@ -305,6 +339,10 @@ class Chef
305
339
  # clones only — for a linked clone the CLI/cluster storage is dropped entirely.
306
340
  storage: full ? from_cli_or_cluster(:storage) : nil,
307
341
  pool: config[:pool],
342
+ # Disk growth is applied to the clone before its first start (see Provisioner);
343
+ # nil disk_size means the template's disk is kept as-is.
344
+ disk: config[:disk],
345
+ disk_size: config[:disk_size],
308
346
  clone_timeout: config[:clone_timeout],
309
347
  boot_timeout: config[:boot_timeout],
310
348
  # Post-start wait policy (see Provisioner#wait_for_ip). Defaults suit bootstrap;
@@ -28,13 +28,30 @@ class Chef
28
28
  CINC_PRODUCT = "cinc"
29
29
  CINC_INSTALL_URL = "https://omnitruck.cinc.sh/install.sh"
30
30
 
31
- # A freshly cloned VM often still runs cloud-init (which itself drives apt) when SSH first
32
- # answers. Installing the client then races cloud-init for the dpkg lock and fails
33
- # non-deterministically. Block on cloud-init completion before the omnibus install so a
34
- # single `vm bootstrap` is reliable. Guarded so non-cloud-init images don't error, and
35
- # `|| true` so a degraded/errored cloud-init state still lets the bootstrap proceed.
36
- CLOUD_INIT_WAIT_COMMAND =
37
- "if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi"
31
+ # A freshly cloned VM is still settling when SSH first answers, and two independent
32
+ # subsystems contend for the dpkg lock: cloud-init (which itself drives apt) and the
33
+ # apt-daily / unattended-upgrades systemd timers, which fire on their own schedule even
34
+ # after cloud-init reports done. Installing the client or the first client run's package
35
+ # resources then races them for the lock and fails non-deterministically. Clear the field
36
+ # before the omnibus install, in order:
37
+ # 1. wait for cloud-init to finish;
38
+ # 2. stop the apt-daily / unattended-upgrades timers and any in-flight run, so nothing
39
+ # grabs the lock again for the rest of this boot (`stop`, not `disable`/`mask`: the
40
+ # services return on the next reboot, leaving the image unchanged);
41
+ # 3. block (bounded) until the dpkg lock a stopped run may still hold is released.
42
+ # Every step is guarded on the tool existing and `|| true`, so non-cloud-init / non-systemd
43
+ # / non-apt images and degraded states still let the bootstrap proceed.
44
+ PREINSTALL_WAIT_COMMAND = <<~SH
45
+ if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi
46
+ if command -v systemctl >/dev/null 2>&1; then
47
+ systemctl stop apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service unattended-upgrades.service >/dev/null 2>&1 || true
48
+ fi
49
+ if command -v flock >/dev/null 2>&1; then
50
+ for lock in /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock; do
51
+ [ -e "$lock" ] && flock -w 300 "$lock" true >/dev/null 2>&1 || true
52
+ done
53
+ fi
54
+ SH
38
55
 
39
56
  deps do
40
57
  require "chef/knife/bootstrap"
@@ -60,8 +77,8 @@ class Chef
60
77
  # TOFU: a freshly cloned VM has no entry in known_hosts. Accept its key on
61
78
  # first connect rather than failing the bootstrap or disabling verification.
62
79
  config[:ssh_verify_host_key] ||= :accept_new
63
- # Wait for cloud-init to finish before the bootstrap installs the client (see constant).
64
- config[:bootstrap_preinstall_command] ||= CLOUD_INIT_WAIT_COMMAND
80
+ # Drain cloud-init and the dpkg lock before the bootstrap installs the client (see constant).
81
+ config[:bootstrap_preinstall_command] ||= PREINSTALL_WAIT_COMMAND
65
82
  end
66
83
 
67
84
  # The bootstrap target host does not exist yet — it is resolved to the VM's IP
@@ -76,6 +76,15 @@ module Knife
76
76
  @client.post("/nodes/#{enc(node)}/qemu/#{enc(vmid)}/config", params)
77
77
  end
78
78
 
79
+ # Grow a virtual disk. +size+ uses Proxmox's own format: "+<n>[KMGT]" adds to the
80
+ # current size, a bare "<n>[KMGT]" sets the new absolute size (Proxmox refuses to
81
+ # shrink). PVE 8 runs the resize in a worker and returns a UPID; earlier releases do
82
+ # the work inline and return nil — the caller decides whether there is a task to wait
83
+ # on, since sequencing is not this class's job.
84
+ def resize_disk(node:, vmid:, disk:, size:)
85
+ @client.put("/nodes/#{enc(node)}/qemu/#{enc(vmid)}/resize", { disk:, size: })
86
+ end
87
+
79
88
  # Start a VM. Returns the UPID of the async start task.
80
89
  def start(node:, vmid:)
81
90
  @client.post("/nodes/#{enc(node)}/qemu/#{enc(vmid)}/status/start")
@@ -62,6 +62,12 @@ module Knife
62
62
  send_with_body(Net::HTTP::Post, path, body)
63
63
  end
64
64
 
65
+ # PUT +path+ with a form-encoded body. Proxmox reserves PUT for in-place mutations of
66
+ # an existing object (e.g. the qemu disk resize endpoint).
67
+ def put(path, body = {})
68
+ send_with_body(Net::HTTP::Put, path, body)
69
+ end
70
+
65
71
  # Redact secret-bearing values from arbitrary text (e.g. a Proxmox task log, which is
66
72
  # returned with HTTP 200 and so does not pass through the error-body scrub path).
67
73
  def scrub_text(str)
@@ -31,6 +31,18 @@ module Knife
31
31
  CLOUDINIT_DRIVE_KEY = /\A(?:ide|scsi|sata)\d+\z/
32
32
  CLOUDINIT_DRIVE_VALUE = /cloudinit/
33
33
 
34
+ # Config keys that can carry a resizable virtual disk.
35
+ DISK_KEY = /\A(?:scsi|virtio|sata|ide)\d+\z/
36
+
37
+ # Drives that are not resizable storage: the cloud-init drive and any CD-ROM.
38
+ NON_DISK_VALUE = /cloudinit|media=cdrom/
39
+
40
+ # Modern boot config: "boot: order=scsi0;ide2;net0".
41
+ BOOT_ORDER = /\border=([^,]+)/
42
+
43
+ # A Proxmox worker id, as returned by endpoints that offload their work to a task.
44
+ UPID_PREFIX = "UPID:"
45
+
34
46
  # Proxmox reports a VMID collision as a non-2xx body mentioning the existing id.
35
47
  DUPLICATE_ID = /already exists|config file already exists|VM \d+ already/i
36
48
 
@@ -49,8 +61,12 @@ module Knife
49
61
  node = spec[:target_node] || template[:node]
50
62
 
51
63
  params = build_config(spec.fetch(:config, {}) || {})
52
- guard_cloud_init_drive!(node, newid, spec[:name]) if cloud_init?(params)
64
+ # One read serves both the cloud-init drive guard and disk discovery; a hardware-only
65
+ # provision needs neither and never reads the config back.
66
+ vm_config = read_vm_config(node, newid, params, spec)
67
+ guard_cloud_init_drive!(vm_config, spec[:name]) if cloud_init?(params)
53
68
  @api.update_config(node:, vmid: newid, **params) unless params.empty?
69
+ resize_disk!(node, newid, vm_config, spec) if spec[:disk_size]
54
70
 
55
71
  boot(node, newid)
56
72
  ip = wait_for_ip(spec, node, newid)
@@ -154,10 +170,16 @@ module Knife
154
170
  params.key?(:ipconfig0) || params.key?(:nameserver) || params.key?(:searchdomain)
155
171
  end
156
172
 
173
+ # Read the cloned VM's config only when something downstream actually needs it.
174
+ def read_vm_config(node, vmid, params, spec)
175
+ return nil unless cloud_init?(params) || spec[:disk_size]
176
+
177
+ @api.vm_config(node:, vmid:)
178
+ end
179
+
157
180
  # Fail fast before a doomed bootstrap: if the (template-derived) VM has no cloud-init
158
181
  # drive, the keys we are about to set would be silently ignored by the guest.
159
- def guard_cloud_init_drive!(node, vmid, name)
160
- config = @api.vm_config(node:, vmid:)
182
+ def guard_cloud_init_drive!(config, name)
161
183
  present = config.any? do |key, value|
162
184
  CLOUDINIT_DRIVE_KEY.match?(key.to_s) && CLOUDINIT_DRIVE_VALUE.match?(value.to_s)
163
185
  end
@@ -168,6 +190,61 @@ module Knife
168
190
  "cloud-init settings would be silently ignored"
169
191
  end
170
192
 
193
+ # Grow a disk of the fresh clone, BEFORE its first start: cloud-init's growpart/resizefs
194
+ # only run on boot, so a disk grown now ends up usable inside the guest, while a resize
195
+ # after boot leaves the extra space unclaimed until something in the guest is told to
196
+ # take it.
197
+ def resize_disk!(node, vmid, vm_config, spec)
198
+ disk = resolve_disk(vm_config, spec[:disk])
199
+ size = spec.fetch(:disk_size)
200
+ @ui&.info("Resizing #{disk} of VM #{vmid} to #{size}...")
201
+ upid = @api.resize_disk(node:, vmid:, disk:, size:)
202
+ # PVE 8 offloads the resize to a worker and hands back its UPID; older releases resize
203
+ # inline and return nothing, so there is nothing to wait on.
204
+ @api.wait_task(upid:) if task_upid?(upid)
205
+ end
206
+
207
+ # An explicitly named disk must exist on the VM — resizing a key Proxmox does not know
208
+ # is a silent no-op otherwise. Without one, fall back to the detected boot disk.
209
+ def resolve_disk(vm_config, disk)
210
+ return detect_disk(vm_config) if disk.nil?
211
+ return disk.to_s if vm_config.key?(disk.to_s)
212
+
213
+ raise Knife::Proxmox::Error,
214
+ "VM has no disk #{disk} (found: #{disk_keys(vm_config).join(", ")})"
215
+ end
216
+
217
+ # The disk to grow when the caller named none: the first entry of the VM's boot order
218
+ # that is a real disk, else the lowest-sorting disk key. cloud-init drives and CD-ROMs
219
+ # are never candidates.
220
+ def detect_disk(vm_config)
221
+ candidates = disk_keys(vm_config)
222
+ # Array#& keeps the receiver's order, so the boot order wins where it says anything.
223
+ disk = (boot_order(vm_config) & candidates).first || candidates.min
224
+ return disk if disk
225
+
226
+ raise Knife::Proxmox::Error,
227
+ "cannot tell which disk to resize (no disk found on the VM); pass --disk scsi0"
228
+ end
229
+
230
+ def disk_keys(vm_config)
231
+ vm_config.select do |key, value|
232
+ DISK_KEY.match?(key.to_s) && !NON_DISK_VALUE.match?(value.to_s)
233
+ end.keys.map(&:to_s)
234
+ end
235
+
236
+ def boot_order(vm_config)
237
+ match = BOOT_ORDER.match(vm_config["boot"].to_s)
238
+ return match[1].split(";") if match
239
+
240
+ # Pre-6.4 layout: "boot: cdn" plus a separate "bootdisk: scsi0".
241
+ [vm_config["bootdisk"]].compact.map(&:to_s)
242
+ end
243
+
244
+ def task_upid?(value)
245
+ value.is_a?(String) && value.start_with?(UPID_PREFIX)
246
+ end
247
+
171
248
  def boot(node, vmid)
172
249
  @ui&.info("Starting VM #{vmid}...")
173
250
  upid = @api.start(node:, vmid:)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Knife
4
4
  module Proxmox
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.5"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: knife-proxmox-ve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Piotr Wojcieszonek
@@ -77,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
77
  - !ruby/object:Gem::Version
78
78
  version: '0'
79
79
  requirements: []
80
- rubygems_version: 4.0.10
80
+ rubygems_version: 4.0.16
81
81
  specification_version: 4
82
82
  summary: Knife plugin to create and automatically bootstrap Proxmox VE VMs with Chef/CINC.
83
83
  test_files: []