diff --git a/VERSION b/VERSION index a63aff8..f537bee 100644 --- a/VERSION +++ b/VERSION @@ -1,3 +1,3 @@ # VERSION file -VERSION=0.9.1 +VERSION=0.9.2 RELEASE=1 diff --git a/docs/v2k_n2k_rbd_sparse_migration_design.md b/docs/v2k_n2k_rbd_sparse_migration_design.md new file mode 100644 index 0000000..b503eec --- /dev/null +++ b/docs/v2k_n2k_rbd_sparse_migration_design.md @@ -0,0 +1,268 @@ +# v2k/n2k RBD Sparse Migration Design + +## Background + +v2k and n2k can migrate disks to ABLESTACK Cloud RBD targets as raw RBD +images. Current RBD write paths preserve logical disk contents, but they do not +consistently preserve sparse allocation. As a result, a disk with large unused +or zero-filled ranges can allocate close to the full logical size in Ceph RBD. + +The goal of this change is to keep RBD target images logically identical while +avoiding allocation for unused or zero-filled source ranges whenever the host +toolchain supports discard or sparse writes. + +## Current Code Path Summary + +### v2k base sync + +`lib/v2k/transfer_base.sh` selects direct RBD output when +`target.storage.type == "rbd"` and runs `qemu-img convert` directly to the +`rbd:pool/image` URI. + +Current behavior: + +```bash +qemu-img convert -p -t none -T none -O raw "$uri" "$out_target" +``` + +The command does not pass `-S` and does not pre-create the RBD image for `-n`, +so sparse conversion is not explicitly requested. + +### v2k incremental and final sync + +`lib/v2k/transfer_patch.sh` gets VMware CBT changed areas and calls +`lib/v2k/patch_apply.py`. The Python helper reads each changed range from the +source NBD device and writes the bytes to the target block device. + +VMware CBT changed areas do not carry a zero or hole type in the current helper +contract. Therefore, v2k must detect all-zero chunks during patch apply if it +wants to avoid allocating those chunks in RBD. + +### n2k base sync + +`lib/n2k/target_storage.sh` uses `qemu-img convert` for RBD target base sync. + +Current behavior: + +```bash +qemu-img convert -p -O raw "$source_path" "$target_path" +``` + +The command does not pass a sparse threshold. + +### n2k incremental and final sync + +`lib/n2k/transfer_patch.sh` already accepts region types: + +- `regular` +- `zero` +- `zeros` +- `zeroed` +- `hole` + +However, `lib/n2k/target_storage.sh` currently applies zero and hole regions by +writing `/dev/zero` to the mapped target. This makes the target contents +correct, but can allocate RBD objects for ranges that should remain sparse. + +## Design Goals + +- Preserve target disk logical size and data correctness. +- Enable sparse handling by default for RBD targets. +- Keep the change scoped to RBD target paths. +- Keep file, qcow2, and generic block target behavior unchanged unless + explicitly required. +- Fall back to the current write behavior when sparse/discard operations are not + supported by the runtime. +- Emit events or log lines that make it clear whether sparse handling was used, + skipped, or degraded to fallback behavior. + +## Proposed Runtime Controls + +Use tool-specific environment variables so operators can tune or disable sparse +handling without changing CLI compatibility. + +Common defaults: + +```bash +V2K_RBD_SPARSE="${V2K_RBD_SPARSE:-1}" +V2K_RBD_SPARSE_SIZE="${V2K_RBD_SPARSE_SIZE:-4M}" +V2K_RBD_SPARSIFY_AFTER="${V2K_RBD_SPARSIFY_AFTER:-auto}" + +N2K_RBD_SPARSE="${N2K_RBD_SPARSE:-1}" +N2K_RBD_SPARSE_SIZE="${N2K_RBD_SPARSE_SIZE:-4M}" +N2K_RBD_SPARSIFY_AFTER="${N2K_RBD_SPARSIFY_AFTER:-auto}" +``` + +`4M` is chosen as the first default because it commonly matches Ceph RBD object +size and avoids excessive metadata churn. The value remains configurable for +environments that need more aggressive zero detection. + +## Proposed v2k Changes + +### 1. RBD base sync sparse conversion + +When target storage is RBD: + +1. Ensure the RBD image exists and is at least the source logical size. +2. Run `qemu-img convert` with `-S`. +3. Add `-n` only when the RBD image did not exist before this run and was just + created by the migration code. This avoids stale data risk on reused target + images because sparse conversion can skip zero ranges. +4. Keep the current direct librbd URI path. + +Target command shape: + +Newly created target command shape: + +```bash +qemu-img convert -p -t none -T none -n -S "$V2K_RBD_SPARSE_SIZE" -O raw "$uri" "$out_target" +``` + +Pre-existing target command shape: + +```bash +qemu-img convert -p -t none -T none -S "$V2K_RBD_SPARSE_SIZE" -O raw "$uri" "$out_target" +``` + +If sparse mode is disabled, retain the current command. + +### 2. RBD patch sparse zero handling + +Extend `lib/v2k/patch_apply.py` with optional zero-chunk detection: + +1. Read the source chunk as today. +2. If the chunk is all zero and RBD sparse patch mode is enabled, perform a + discard or zero-unmap operation for that range. +3. If discard fails, write the zero chunk as fallback. +4. If the chunk is not all zero, write normally. + +Because `patch_apply.py` currently receives only block device paths, the first +implementation should support mapped-device discard with `blkdiscard` for RBD +targets. If direct RBD URI discard is needed later, add a qemu-io path. + +The shell caller should pass enough context to the helper, for example: + +```bash +--target-kind rbd --sparse-zero on --discard-mode auto +``` + +### 3. Optional post-pass RBD sparsify + +If discard support is unavailable or ambiguous, run `rbd sparsify` as a +compatibility fallback when available: + +```bash +rbd sparsify pool/image --sparse-size "$V2K_RBD_SPARSE_SIZE" +``` + +This should be controlled by `V2K_RBD_SPARSIFY_AFTER`: + +- `0`: never run. +- `1`: always run after RBD base/final sync. +- `auto`: run only when sparse conversion or discard support could not be + confirmed. + +## Proposed n2k Changes + +### 1. RBD base sync sparse conversion + +Update the RBD branch in `n2k_storage_copy_base` to use sparse conversion: + +Newly created target command shape: + +```bash +qemu-img convert -p -n -S "$N2K_RBD_SPARSE_SIZE" -O raw "$source_path" "$target_path" +``` + +Pre-existing target command shape: + +```bash +qemu-img convert -p -S "$N2K_RBD_SPARSE_SIZE" -O raw "$source_path" "$target_path" +``` + +Before using `-n`, ensure the RBD image exists and has the expected logical +size, and confirm that it was not pre-existing. + +### 2. RBD zero/hole patch discard + +Update the RBD patch path so zero and hole regions do not use `dd if=/dev/zero` +by default. + +Preferred order: + +1. Use `qemu-io` against the direct `rbd:pool/image` URI with discard or + zero-unmap. +2. Use `blkdiscard -o -l ` when the RBD image is + mapped as a block device. +3. Fall back to the current `/dev/zero` write when discard is unsupported. + +Regular regions should continue to use the current write path. + +### 3. Optional post-pass RBD sparsify + +Use `rbd sparsify` with `N2K_RBD_SPARSIFY_AFTER` using the same semantics as v2k. + +## Observability + +Add structured events or clear log lines for RBD sparse behavior: + +- base sparse enabled or disabled +- sparse size selected +- discard method selected +- discard fallback count +- optional `rbd sparsify` start/done/fail +- pre/post used bytes when `rbd du` is available + +For v2k, the existing `v2k_fleet_used_bytes_rbd` helper already uses `rbd du` +and can be reused for validation or summary reporting. + +## Validation Plan + +### Unit-level validation + +- Shell syntax checks for modified scripts. +- Python syntax check for `patch_apply.py`. +- Validate command generation for RBD base sync with sparse enabled and disabled. +- Validate n2k zero/hole region dispatch chooses discard before zero write. + +### Local synthetic RBD validation + +Use a synthetic source image with sparse and zero-filled ranges: + +1. Create a logical 60 GiB source. +2. Write non-zero data to a few small ranges. +3. Run v2k/n2k RBD base sync logic. +4. Confirm `rbd info` reports the full logical size. +5. Confirm `rbd du` reports allocated usage close to written data, not 60 GiB. +6. Read back non-zero ranges and verify checksums. + +### Incremental/final validation + +1. Start with a target image containing allocated data in a test range. +2. Make the corresponding source range zero. +3. Run v2k final sync or n2k patch sync. +4. Confirm target reads zeros. +5. Confirm `rbd du` does not grow unnecessarily and decreases when discard is + supported. + +## Risks and Fallbacks + +- `qemu-img -S` behavior depends on qemu and librbd capabilities. +- `blkdiscard` may not be supported by every RBD mapping mode. +- `qemu-io` discard support can differ across qemu builds. +- `rbd sparsify` is reliable but can be expensive on large disks. + +For these reasons, sparse handling should be best-effort with safe fallback to +the current full zero write behavior. Correctness must take priority over +allocation efficiency. + +## Implementation Order + +1. Add RBD sparse helper functions and environment defaults. +2. Update v2k RBD base sync. +3. Update n2k RBD base sync. +4. Add n2k zero/hole discard handling for RBD patch. +5. Extend v2k patch apply for zero chunk detection and discard fallback. +6. Add optional `rbd sparsify` fallback. +7. Add targeted validation scripts or tests. +8. Build v2k and n2k RPMs. diff --git a/lib/n2k/target_storage.sh b/lib/n2k/target_storage.sh index 0fad5cc..a7d2520 100644 --- a/lib/n2k/target_storage.sh +++ b/lib/n2k/target_storage.sh @@ -61,6 +61,91 @@ n2k_storage_require_command() { } } +n2k_storage_rbd_sparse_enabled() { + case "${N2K_RBD_SPARSE:-1}" in + 0|false|FALSE|no|NO|off|OFF) return 1 ;; + *) return 0 ;; + esac +} + +n2k_storage_rbd_sparse_size() { + printf '%s' "${N2K_RBD_SPARSE_SIZE:-4M}" +} + +n2k_storage_source_virtual_size_bytes() { + local path="$1" size + if [[ -b "${path}" ]]; then + blockdev --getsize64 "${path}" + return + fi + if command -v qemu-img >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + size="$(qemu-img info --output=json "${path}" 2>/dev/null | jq -r '."virtual-size" // .virtual_size // empty' 2>/dev/null || true)" + if [[ "${size}" =~ ^[0-9]+$ && "${size}" -gt 0 ]]; then + printf '%s' "${size}" + return + fi + fi + n2k_storage_file_size_bytes "${path}" +} + +n2k_storage_rbd_current_size_bytes() { + local target_path="$1" spec size + spec="$(n2k_storage_rbd_image_name "${target_path}")" + command -v rbd >/dev/null 2>&1 || return 1 + if ! rbd info "${spec}" >/dev/null 2>&1; then + return 1 + fi + if command -v jq >/dev/null 2>&1; then + size="$(rbd info --format json "${spec}" 2>/dev/null | jq -r '.size // .size_bytes // empty' 2>/dev/null || true)" + if [[ "${size}" =~ ^[0-9]+$ && "${size}" -gt 0 ]]; then + printf '%s' "${size}" + return 0 + fi + fi + rbd info "${spec}" 2>/dev/null | awk '/^ *size / {for (i=1; i<=NF; i++) if ($i ~ /^[0-9]+$/) {print $i; exit}}' +} + +n2k_storage_rbd_exists() { + local target_path="$1" spec + spec="$(n2k_storage_rbd_image_name "${target_path}")" + command -v rbd >/dev/null 2>&1 || return 1 + rbd info "${spec}" >/dev/null 2>&1 +} + +n2k_storage_rbd_ensure_image() { + local target_path="$1" size_bytes="$2" spec cur size_mb + spec="$(n2k_storage_rbd_image_name "${target_path}")" + [[ "${size_bytes}" =~ ^[0-9]+$ && "${size_bytes}" -gt 0 ]] || { + echo "RBD ensure requires a positive size for ${target_path}: ${size_bytes}" >&2 + return 2 + } + command -v rbd >/dev/null 2>&1 || return 1 + + size_mb="$(( (size_bytes + 1024*1024 - 1) / (1024*1024) ))" + [[ "${size_mb}" -gt 0 ]] || size_mb=1 + + if ! rbd info "${spec}" >/dev/null 2>&1; then + rbd create "${spec}" --size "${size_mb}" + return + fi + + cur="$(n2k_storage_rbd_current_size_bytes "${target_path}" || true)" + if [[ "${cur}" =~ ^[0-9]+$ && "${cur}" -lt "${size_bytes}" ]]; then + rbd resize "${spec}" --size "${size_mb}" + fi +} + +n2k_storage_rbd_sparsify() { + local target_path="$1" needed="${2:-0}" mode="${N2K_RBD_SPARSIFY_AFTER:-auto}" spec + case "${mode}" in + 0|false|FALSE|no|NO|off|OFF) return 0 ;; + auto|AUTO) [[ "${needed}" == "1" ]] || return 0 ;; + esac + command -v rbd >/dev/null 2>&1 || return 0 + spec="$(n2k_storage_rbd_image_name "${target_path}")" + rbd sparsify "${spec}" --sparse-size "$(n2k_storage_rbd_sparse_size)" >/dev/null 2>&1 || true +} + n2k_storage_detect_image_format() { local path="$1" fmt if [[ -b "${path}" ]]; then @@ -111,7 +196,26 @@ n2k_storage_copy_base() { echo "RBD target path must start with rbd: ${target_path}" >&2 return 2 } - qemu-img convert -p -O raw "${source_path}" "${target_path}" + if n2k_storage_rbd_sparse_enabled; then + local source_size sparse_size rbd_preexisting=0 + source_size="$(n2k_storage_source_virtual_size_bytes "${source_path}")" + sparse_size="$(n2k_storage_rbd_sparse_size)" + if command -v rbd >/dev/null 2>&1; then + n2k_storage_rbd_exists "${target_path}" && rbd_preexisting=1 + fi + if command -v rbd >/dev/null 2>&1 && n2k_storage_rbd_ensure_image "${target_path}" "${source_size}"; then + if [[ "${rbd_preexisting}" -eq 0 ]]; then + qemu-img convert -p -n -S "${sparse_size}" -O raw "${source_path}" "${target_path}" + else + qemu-img convert -p -S "${sparse_size}" -O raw "${source_path}" "${target_path}" + fi + else + qemu-img convert -p -S "${sparse_size}" -O raw "${source_path}" "${target_path}" + fi + n2k_storage_rbd_sparsify "${target_path}" 0 + else + qemu-img convert -p -O raw "${source_path}" "${target_path}" + fi ;; *) echo "Unsupported target storage: ${target_storage}" >&2 @@ -501,9 +605,28 @@ n2k_storage_unmap_rbd() { fi } +n2k_storage_discard_rbd_region() { + local target_path="$1" mapped_device="$2" offset="$3" length="$4" + n2k_storage_rbd_sparse_enabled || return 1 + + if command -v qemu-io >/dev/null 2>&1; then + if qemu-io -f raw -c "discard ${offset} ${length}" "${target_path}" >/dev/null 2>&1; then + return 0 + fi + fi + + if [[ -b "${mapped_device}" ]] && command -v blkdiscard >/dev/null 2>&1; then + if blkdiscard -o "${offset}" -l "${length}" "${mapped_device}" >/dev/null 2>&1; then + return 0 + fi + fi + + return 1 +} + n2k_storage_patch_rbd() { local source_path="$1" target_path="$2" regions="$3" - local mapped_device offset length region_type map_mode + local mapped_device offset length region_type map_mode discard_fallbacks=0 discards=0 map_mode="${N2K_RBD_PATCH_MAP_MODE:-auto}" mapped_device="$(n2k_storage_map_rbd "${target_path}" "${map_mode}")" @@ -511,9 +634,22 @@ n2k_storage_patch_rbd() { while IFS=$'\t' read -r offset length region_type; do [[ -n "${offset}" && -n "${length}" ]] || continue + case "${region_type:-regular}" in + zero|zeros|zeroed|hole) + if n2k_storage_discard_rbd_region "${target_path}" "${mapped_device}" "${offset}" "${length}"; then + discards=$((discards + 1)) + continue + fi + discard_fallbacks=$((discard_fallbacks + 1)) + ;; + esac n2k_storage_apply_patch_region_to_device "${source_path}" "${mapped_device}" "${offset}" "${length}" "${region_type:-regular}" done < <(jq -r '.[] | [(.offset | tostring), (.length | tostring), (.type // "regular")] | @tsv' <<<"${regions}") + if [[ "${discards}" -gt 0 || "${discard_fallbacks}" -gt 0 ]]; then + echo "RBD sparse patch summary: discards=${discards} zero_write_fallbacks=${discard_fallbacks}" >&2 + fi + n2k_storage_unmap_rbd "${mapped_device}" trap - RETURN } diff --git a/lib/v2k/engine.sh b/lib/v2k/engine.sh index 6eadebb..0208737 100644 --- a/lib/v2k/engine.sh +++ b/lib/v2k/engine.sh @@ -2741,8 +2741,8 @@ v2k_cmd_cutover() { "{\"guestFamily\":\"${guest_family}\",\"guestId\":\"${guest_id}\"}" fi - # If WinPE is skipped, auto-start unless define-only or caller explicitly controlled start. - if [[ "${winpe_bootstrap}" -eq 0 && "${define_only}" -eq 0 && "${start_cli_set}" -eq 0 ]]; then + # If WinPE is skipped, auto-start only for the default cutover policy. + if [[ "${winpe_bootstrap}" -eq 0 && "${define_only}" -eq 0 && "${apply_define}" -eq 0 && "${start_cli_set}" -eq 0 ]]; then start_vm=1 v2k_event INFO "cutover" "" "auto_start_enabled" "{}" fi diff --git a/lib/v2k/orchestrator.sh b/lib/v2k/orchestrator.sh index 9e1c47b..e77a617 100644 --- a/lib/v2k/orchestrator.sh +++ b/lib/v2k/orchestrator.sh @@ -487,6 +487,15 @@ v2k_cmd_run_foreground() { v2k_parse_arg_string "${cutover_args_str}" cutover_extra local winpe_bootstrap_auto="${V2K_RUN_WINPE_BOOTSTRAP_AUTO:-1}" + local cutover_policy_explicit=0 + local a + for a in "${cutover_extra[@]}"; do + case "${a}" in + --define-only|--apply|--start) + cutover_policy_explicit=1 + ;; + esac + done # Pipeline start local skip_init=0 @@ -652,7 +661,7 @@ v2k_cmd_run_foreground() { local -a cutover_args=(--shutdown "${shutdown}") if [[ "${is_windows}" -eq 1 && "${V2K_RUN_WINPE_BOOTSTRAP_AUTO}" == "1" ]]; then - if [[ "${kvm_vm_policy}" == "none" ]]; then + if [[ "${kvm_vm_policy}" == "none" && "${cutover_policy_explicit}" -eq 0 ]]; then kvm_vm_policy="define-and-start" fi cutover_args+=(--winpe-bootstrap) @@ -667,7 +676,6 @@ v2k_cmd_run_foreground() { if [[ "${winpe_bootstrap_auto}" == "1" ]]; then if declare -F v2k_manifest_is_windows >/dev/null 2>&1 && v2k_manifest_is_windows "${V2K_MANIFEST}"; then local winpe_explicit=0 - local a for a in "${cutover_extra[@]}"; do case "${a}" in --winpe-bootstrap|--winpe-iso|--virtio-iso|--winpe-timeout) @@ -681,6 +689,18 @@ v2k_cmd_run_foreground() { fi fi + if declare -F v2k_event >/dev/null 2>&1; then + local cutover_args_preview + cutover_args_preview="$(printf '%s\n' "${cutover_args[@]}" "${cutover_extra[@]}" | jq -R . | jq -sc .)" + v2k_event INFO "orchestrator" "" "cutover_policy_resolved" \ + "$(jq -nc \ + --arg kvm_vm_policy "${kvm_vm_policy}" \ + --argjson cutover_policy_explicit "${cutover_policy_explicit}" \ + --arg winpe_bootstrap_auto "${winpe_bootstrap_auto}" \ + --argjson args "${cutover_args_preview}" \ + '{kvm_vm_policy:$kvm_vm_policy,cutover_policy_explicit:$cutover_policy_explicit,winpe_bootstrap_auto:$winpe_bootstrap_auto,cutover_args:$args}')" + fi + cutover_args+=("${cutover_extra[@]}") [[ "${target_provider_arg_set}" -eq 1 ]] && cutover_args+=(--target-provider "${target_provider}") [[ -n "${cloud_endpoint}" ]] && cutover_args+=(--cloud-endpoint "${cloud_endpoint}") diff --git a/lib/v2k/patch_apply.py b/lib/v2k/patch_apply.py index 477da06..42556ce 100644 --- a/lib/v2k/patch_apply.py +++ b/lib/v2k/patch_apply.py @@ -22,6 +22,7 @@ import argparse import json import os +import subprocess from typing import Dict, List, Tuple @@ -44,19 +45,44 @@ def coalesce(areas: List[Tuple[int, int]], gap: int) -> List[Tuple[int, int]]: return merged -def copy_region(src_fd, dst_fd, offset: int, length: int, chunk: int) -> None: +def discard_range(target: str, offset: int, length: int) -> bool: + if length <= 0: + return True + try: + result = subprocess.run( + ["blkdiscard", "-o", str(offset), "-l", str(length), target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + except FileNotFoundError: + return False + + +def copy_region(src_fd, dst_fd, target: str, offset: int, length: int, chunk: int, sparse_zero: bool) -> Tuple[int, int]: remaining = length pos = offset + discarded = 0 + discard_failed = 0 while remaining > 0: n = chunk if remaining > chunk else remaining src_fd.seek(pos) buf = src_fd.read(n) if len(buf) != n: raise RuntimeError(f"short read at {pos}: expected {n}, got {len(buf)}") + if sparse_zero and not any(buf): + if discard_range(target, pos, n): + discarded += 1 + remaining -= n + pos += n + continue + discard_failed += 1 dst_fd.seek(pos) dst_fd.write(buf) remaining -= n pos += n + return discarded, discard_failed def main() -> None: @@ -66,6 +92,8 @@ def main() -> None: ap.add_argument("--areas-json", required=True) ap.add_argument("--coalesce-gap", type=int, default=1024 * 1024) ap.add_argument("--chunk", type=int, default=4 * 1024 * 1024) + ap.add_argument("--target-kind", default="") + ap.add_argument("--sparse-zero", choices=("on", "off"), default="off") args = ap.parse_args() areas_obj: Dict = json.loads(args.areas_json) @@ -77,9 +105,15 @@ def main() -> None: if not os.path.exists(args.target): raise SystemExit(f"target not found: {args.target}") + sparse_zero = args.sparse_zero == "on" and args.target_kind == "rbd" + total_discarded = 0 + total_discard_failed = 0 + with open(args.source, "rb", buffering=0) as src_fd, open(args.target, "r+b", buffering=0) as dst_fd: for off, ln in merged: - copy_region(src_fd, dst_fd, off, ln, args.chunk) + discarded, discard_failed = copy_region(src_fd, dst_fd, args.target, off, ln, args.chunk, sparse_zero) + total_discarded += discarded + total_discard_failed += discard_failed # Ensure data reaches the underlying block device before disconnect try: @@ -87,6 +121,17 @@ def main() -> None: os.fsync(dst_fd.fileno()) except Exception: pass + if sparse_zero: + print( + json.dumps( + { + "event": "sparse_zero_summary", + "discarded_chunks": total_discarded, + "discard_fallback_chunks": total_discard_failed, + }, + separators=(",", ":"), + ) + ) if __name__ == "__main__": main() diff --git a/lib/v2k/transfer_base.sh b/lib/v2k/transfer_base.sh index cf8a985..e7b484a 100644 --- a/lib/v2k/transfer_base.sh +++ b/lib/v2k/transfer_base.sh @@ -226,10 +226,27 @@ v2k_transfer_base_one() { nbdcopy_path="$(command -v nbdcopy 2>/dev/null || true)" local child_env_prefix child_env_prefix="$(v2k_compat_vddk_child_env_prefix)" + local convert_sparse_args="" + if [[ "${st}" == "rbd" ]] && v2k_rbd_sparse_enabled; then + if command -v rbd >/dev/null 2>&1; then + local rbd_preexisting=0 + v2k_rbd_exists "${target_path}" && rbd_preexisting=1 + v2k_rbd_ensure_image "${target_path}" "${size_bytes}" + if [[ "${rbd_preexisting}" -eq 0 ]]; then + convert_sparse_args="-n -S \"$(v2k_rbd_sparse_size)\"" + else + convert_sparse_args="-S \"$(v2k_rbd_sparse_size)\"" + fi + else + convert_sparse_args="-S \"$(v2k_rbd_sparse_size)\"" + fi + v2k_event INFO "sync.base" "${disk_id}" "rbd_sparse_enabled" \ + "{\"target\":\"${target_path}\",\"sparse_size\":\"$(v2k_rbd_sparse_size)\"}" + fi case "${base_method}" in convert|qemu-img) - run_str="${child_env_prefix} ${qemu_img_path} convert -p -t none -T none -O \"${out_fmt}\" \"\$uri\" \"${out_target}\"" + run_str="${child_env_prefix} ${qemu_img_path} convert -p -t none -T none ${convert_sparse_args} -O \"${out_fmt}\" \"\$uri\" \"${out_target}\"" ;; nbdcopy) if [[ "${st}" == "file" && "${fmt}" == "qcow2" ]]; then @@ -241,7 +258,7 @@ v2k_transfer_base_one() { fi ;; *) - run_str="${child_env_prefix} ${qemu_img_path} convert -p -t none -T none -O \"${out_fmt}\" \"\$uri\" \"${out_target}\"" + run_str="${child_env_prefix} ${qemu_img_path} convert -p -t none -T none ${convert_sparse_args} -O \"${out_fmt}\" \"\$uri\" \"${out_target}\"" ;; esac @@ -271,8 +288,12 @@ v2k_transfer_base_one() { vm="moref=${vm_moref}" \ snapshot="${snap_moref}" \ transports=nbd:nbdssl \ - file="${vmdk_path}" \ - --run "${run_str}" >>"${nbdlog}" 2>&1 + file="${vmdk_path}" \ + --run "${run_str}" >>"${nbdlog}" 2>&1 + + if [[ "${st}" == "rbd" ]] && v2k_rbd_sparse_enabled; then + v2k_rbd_sparsify "${target_path}" + fi v2k_manifest_mark_base_done "${manifest}" "${idx}" v2k_event INFO "sync.base" "${disk_id}" "disk_done" "{\"target\":\"${target_path}\"}" diff --git a/lib/v2k/transfer_patch.sh b/lib/v2k/transfer_patch.sh index 719da44..f57fb68 100644 --- a/lib/v2k/transfer_patch.sh +++ b/lib/v2k/transfer_patch.sh @@ -362,6 +362,13 @@ v2k_transfer_patch_one() { sleep 1 fi + local sparse_zero="off" + if [[ "${kind}" == "rbd" ]] && v2k_rbd_sparse_enabled; then + sparse_zero="on" + v2k_event INFO "sync.${which}" "${disk_id}" "rbd_sparse_patch_enabled" \ + "{\"target\":\"${target_path}\",\"method\":\"blkdiscard\",\"chunk\":${chunk}}" + fi + if [[ "${areas_count}" -gt 0 ]]; then v2k_python "${V2K_PY_DIR}/patch_apply.py" \ --source "${src_dev}" \ @@ -369,6 +376,8 @@ v2k_transfer_patch_one() { --areas-json "${areas_json}" \ --coalesce-gap "${coalesce_gap}" \ --chunk "${chunk}" \ + --target-kind "${kind}" \ + --sparse-zero "${sparse_zero}" \ || { cleanup_patch; exit 41; } fi diff --git a/lib/v2k/v2k_target_device.sh b/lib/v2k/v2k_target_device.sh index 8b28a66..f54bf2b 100644 --- a/lib/v2k/v2k_target_device.sh +++ b/lib/v2k/v2k_target_device.sh @@ -277,6 +277,40 @@ v2k_rbd_precheck() { fi } +v2k_rbd_exists() { + local rbd_uri="$1" + local spec="${rbd_uri#rbd:}" + command -v rbd >/dev/null 2>&1 || return 1 + rbd info "${spec}" >/dev/null 2>&1 +} + +v2k_rbd_sparse_enabled() { + case "${V2K_RBD_SPARSE:-1}" in + 0|false|FALSE|no|NO|off|OFF) return 1 ;; + *) return 0 ;; + esac +} + +v2k_rbd_sparse_size() { + printf '%s' "${V2K_RBD_SPARSE_SIZE:-4M}" +} + +v2k_rbd_sparsify() { + local rbd_uri="$1" + local needed="${2:-0}" + local mode="${V2K_RBD_SPARSIFY_AFTER:-auto}" + local spec="${rbd_uri#rbd:}" + + case "${mode}" in + 0|false|FALSE|no|NO|off|OFF) return 0 ;; + auto|AUTO) [[ "${needed}" == "1" ]] || return 0 ;; + esac + command -v rbd >/dev/null 2>&1 || return 0 + rbd sparsify "${spec}" --sparse-size "$(v2k_rbd_sparse_size)" >/dev/null 2>&1 || { + v2k_log "WARN: rbd sparsify failed for ${spec}" + return 0 + } +} # Ensure RBD image exists and is at least size_bytes. # Requires: rbd CLI available and ceph access configured on host. diff --git a/tests/v2k_apply_cutover_policy_smoke.sh b/tests/v2k_apply_cutover_policy_smoke.sh new file mode 100755 index 0000000..0595256 --- /dev/null +++ b/tests/v2k_apply_cutover_policy_smoke.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_ROOT="${TMPDIR:-/tmp}/v2k_apply_cutover_policy_smoke" + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "[ERR] Missing command: $1" >&2 + exit 2 + } +} + +cleanup() { + rm -rf "${WORK_ROOT}" +} + +require_cmd jq +trap cleanup EXIT +rm -rf "${WORK_ROOT}" +mkdir -p "${WORK_ROOT}" + +# shellcheck source=/dev/null +source "${ROOT_DIR}/lib/v2k/orchestrator.sh" + +v2k_valid_target_provider() { return 0; } +v2k_source_kv_env() { return 0; } +v2k_compat_bootstrap_env() { return 0; } +v2k_compat_resolve_profile() { return 0; } +v2k_manifest_split_is_done() { return 0; } +v2k_manifest_is_windows() { return 0; } +v2k_manifest_runtime_set() { return 0; } +v2k_manifest_phase_done() { return 0; } +v2k_event() { return 0; } +v2k_event_storage_snapshot() { return 0; } +v2k_emit_progress_event() { return 0; } +v2k_cmd_cleanup() { return 0; } +v2k_cmd_init() { return 0; } +v2k_cmd_cbt() { return 0; } +v2k_cmd_snapshot() { return 0; } +v2k_cmd_sync() { return 0; } +v2k_cmd_incr_sync() { return 0; } +v2k_cmd_cutover() { + printf '%s\n' "$@" > "${V2K_TEST_CUTOVER_ARGS_FILE}" +} + +write_phase2_manifest() { + local manifest="$1" + cat > "${manifest}" <<'JSON' +{ + "source": { + "vm": { + "guestFamily": "windowsGuest" + } + }, + "runtime": { + "split": { + "phase1": { + "done": true + } + }, + "sync_within_deadline": true + } +} +JSON +} + +run_case() { + local name="$1" + local cutover_args="${2:-}" + local workdir="${WORK_ROOT}/${name}" + mkdir -p "${workdir}" + write_phase2_manifest "${workdir}/manifest.json" + : > "${workdir}/govc.env" + : > "${workdir}/vddk.cred" + + export V2K_WORKDIR="${workdir}" + export V2K_MANIFEST="${workdir}/manifest.json" + export V2K_RUN_ID="${name}" + export V2K_TEST_CUTOVER_ARGS_FILE="${workdir}/cutover.args" + + local -a args=( + --foreground + --vm win-vm + --vcenter vc.example.local + --username administrator + --password dummy + --dst /tmp/v2k-target + --split phase2 + --target-provider ablestack-cloud + --no-incr + --shutdown guest + ) + [[ -z "${cutover_args}" ]] || args+=(--cutover-args "${cutover_args}") + + v2k_cmd_run_foreground "${args[@]}" +} + +run_case apply "--apply" +apply_args="$(cat "${WORK_ROOT}/apply/cutover.args")" +grep -qx -- "--winpe-bootstrap" <<<"${apply_args}" || { + echo "[ERR] v2k --apply should still enable WinPE bootstrap for Windows" >&2 + printf '%s\n' "${apply_args}" >&2 + exit 1 +} +grep -qx -- "--apply" <<<"${apply_args}" || { + echo "[ERR] v2k --apply was not forwarded to cutover" >&2 + printf '%s\n' "${apply_args}" >&2 + exit 1 +} +if grep -qx -- "--start" <<<"${apply_args}"; then + echo "[ERR] v2k --apply must not be converted to --start" >&2 + printf '%s\n' "${apply_args}" >&2 + exit 1 +fi + +run_case default "" +default_args="$(cat "${WORK_ROOT}/default/cutover.args")" +grep -qx -- "--winpe-bootstrap" <<<"${default_args}" || { + echo "[ERR] default Windows cutover should enable WinPE bootstrap" >&2 + printf '%s\n' "${default_args}" >&2 + exit 1 +} +grep -qx -- "--start" <<<"${default_args}" || { + echo "[ERR] default Windows cutover should still auto-start" >&2 + printf '%s\n' "${default_args}" >&2 + exit 1 +} + +echo "[OK] v2k apply cutover policy preserves WinPE without auto-start"