Add zvol existence check and creation for iSCSI extents
client.py:
- check_iscsi_zvols(): queries pool.dataset.query for VOLUME type,
returns list of missing zvol names
- create_zvol(): creates a single zvol via pool.dataset.create
- create_missing_zvols(): opens a fresh connection and creates a
batch of zvols from a {name: volsize_bytes} dict
summary.py:
- Add zvols_to_check and missing_zvols list fields
- Report shows a WARNING block listing missing zvols when present
migrate.py:
- _migrate_iscsi_extents() populates summary.zvols_to_check with
the dataset name for each DISK-type extent during dry run
cli.py:
- Add _parse_size() to parse human-friendly size strings
(100G, 500GiB, 1T, etc.) to bytes
- run() calls check_iscsi_zvols() during dry run and stores results
in summary.missing_zvols
- Wizard prompts for size and creates missing zvols after the dry
run report, before asking the user to confirm the live run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .archive import parse_archive, list_archive_and_exit
|
||||
from .client import TrueNASClient, check_dataset_paths, create_missing_datasets
|
||||
from .client import TrueNASClient, check_dataset_paths, create_missing_datasets, check_iscsi_zvols, create_missing_zvols
|
||||
from .colors import log, _bold, _bold_cyan, _bold_red, _bold_yellow, _cyan, _dim, _green, _yellow
|
||||
from .csv_source import parse_csv_sources
|
||||
from .migrate import migrate_smb_shares, migrate_nfs_shares, migrate_iscsi
|
||||
@@ -112,6 +112,11 @@ async def run(
|
||||
client, summary.paths_to_create,
|
||||
)
|
||||
|
||||
if args.dry_run and summary.zvols_to_check:
|
||||
summary.missing_zvols = await check_iscsi_zvols(
|
||||
client, summary.zvols_to_check,
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
@@ -119,6 +124,24 @@ async def run(
|
||||
# Interactive wizard helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_size(s: str) -> int:
|
||||
"""Parse a human-friendly size string to bytes. E.g. '100G', '500GiB', '1T'."""
|
||||
s = s.strip().upper()
|
||||
for suffix, mult in [
|
||||
("PIB", 1 << 50), ("PB", 1 << 50), ("P", 1 << 50),
|
||||
("TIB", 1 << 40), ("TB", 1 << 40), ("T", 1 << 40),
|
||||
("GIB", 1 << 30), ("GB", 1 << 30), ("G", 1 << 30),
|
||||
("MIB", 1 << 20), ("MB", 1 << 20), ("M", 1 << 20),
|
||||
("KIB", 1 << 10), ("KB", 1 << 10), ("K", 1 << 10),
|
||||
]:
|
||||
if s.endswith(suffix):
|
||||
try:
|
||||
return int(float(s[:-len(suffix)]) * mult)
|
||||
except ValueError:
|
||||
pass
|
||||
return int(s) # plain bytes
|
||||
|
||||
|
||||
def _find_debug_archives(directory: str = ".") -> list[Path]:
|
||||
"""Return sorted list of TrueNAS debug archives found in *directory*."""
|
||||
patterns = ("*.tgz", "*.tar.gz", "*.tar", "*.txz", "*.tar.xz")
|
||||
@@ -405,6 +428,29 @@ def interactive_mode() -> None:
|
||||
))
|
||||
print()
|
||||
|
||||
if dry_summary.missing_zvols:
|
||||
print(f"\n {len(dry_summary.missing_zvols)} zvol(s) need to be created for iSCSI extents:")
|
||||
for z in dry_summary.missing_zvols:
|
||||
print(f" • {z}")
|
||||
print()
|
||||
if _confirm(f"Create these {len(dry_summary.missing_zvols)} zvol(s) on {host} now?"):
|
||||
zvol_sizes: dict[str, int] = {}
|
||||
for zvol in dry_summary.missing_zvols:
|
||||
while True:
|
||||
raw = _prompt(f" Size for {zvol} (e.g. 100G, 500GiB, 1T)").strip()
|
||||
if not raw:
|
||||
print(" Size is required.")
|
||||
continue
|
||||
try:
|
||||
zvol_sizes[zvol] = _parse_size(raw)
|
||||
break
|
||||
except ValueError:
|
||||
print(f" Cannot parse {raw!r} — try a format like 100G or 500GiB.")
|
||||
asyncio.run(create_missing_zvols(
|
||||
host=host, port=port, api_key=api_key, zvols=zvol_sizes,
|
||||
))
|
||||
print()
|
||||
|
||||
if not _confirm(f"Apply these changes to {host}?"):
|
||||
print("Aborted – no changes made.")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -306,3 +306,79 @@ async def create_missing_datasets(
|
||||
) as client:
|
||||
for path in paths:
|
||||
await create_dataset(client, path)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# iSCSI zvol utilities
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def check_iscsi_zvols(
|
||||
client: TrueNASClient,
|
||||
zvol_names: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Return the subset of *zvol_names* that do not exist on the destination.
|
||||
Names are the dataset path without the leading 'zvol/' prefix
|
||||
(e.g. 'tank/VMWARE001'). Returns [] when the query itself fails.
|
||||
"""
|
||||
if not zvol_names:
|
||||
return []
|
||||
|
||||
unique = sorted(set(zvol_names))
|
||||
log.info("Checking %d zvol(s) against destination datasets …", len(unique))
|
||||
try:
|
||||
datasets = await client.call(
|
||||
"pool.dataset.query", [[["type", "=", "VOLUME"]]]
|
||||
) or []
|
||||
except RuntimeError as exc:
|
||||
log.warning("Could not query zvols (skipping check): %s", exc)
|
||||
return []
|
||||
|
||||
existing = {d["name"] for d in datasets}
|
||||
missing = [n for n in unique if n not in existing]
|
||||
if missing:
|
||||
for n in missing:
|
||||
log.warning(" MISSING zvol: %s", n)
|
||||
else:
|
||||
log.info(" All iSCSI zvols exist on destination.")
|
||||
return missing
|
||||
|
||||
|
||||
async def create_zvol(
|
||||
client: TrueNASClient,
|
||||
name: str,
|
||||
volsize: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Create a ZFS volume (zvol) on the destination.
|
||||
*name* is the dataset path (e.g. 'tank/VMWARE001').
|
||||
*volsize* is the size in bytes.
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
log.info("Creating zvol %r (%d bytes) …", name, volsize)
|
||||
try:
|
||||
await client.call("pool.dataset.create", [{
|
||||
"name": name,
|
||||
"type": "VOLUME",
|
||||
"volsize": volsize,
|
||||
}])
|
||||
log.info(" Created: %s", name)
|
||||
return True
|
||||
except RuntimeError as exc:
|
||||
log.error(" Failed to create zvol %r: %s", name, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def create_missing_zvols(
|
||||
host: str,
|
||||
port: int,
|
||||
api_key: str,
|
||||
zvols: dict[str, int],
|
||||
verify_ssl: bool = False,
|
||||
) -> None:
|
||||
"""Open a fresh connection and create zvols from {name: volsize_bytes}."""
|
||||
async with TrueNASClient(
|
||||
host=host, port=port, api_key=api_key, verify_ssl=verify_ssl,
|
||||
) as client:
|
||||
for name, volsize in zvols.items():
|
||||
await create_zvol(client, name, volsize)
|
||||
|
||||
@@ -236,6 +236,8 @@ async def _migrate_iscsi_extents(
|
||||
_cyan("[DRY RUN]"), _bold_cyan(repr(name)),
|
||||
ext.get("disk") or ext.get("path"))
|
||||
summary.iscsi_extents_created += 1
|
||||
if ext.get("type") == "DISK" and ext.get("disk"):
|
||||
summary.zvols_to_check.append(ext["disk"].removeprefix("zvol/"))
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -52,6 +52,10 @@ class Summary:
|
||||
paths_to_create: list[str] = field(default_factory=list)
|
||||
missing_datasets: list[str] = field(default_factory=list)
|
||||
|
||||
# Populated during iSCSI dry-run zvol safety checks
|
||||
zvols_to_check: list[str] = field(default_factory=list)
|
||||
missing_zvols: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def _has_iscsi(self) -> bool:
|
||||
return (self.iscsi_extents_found + self.iscsi_initiators_found +
|
||||
@@ -147,5 +151,16 @@ class Summary:
|
||||
" These paths must exist before shares can be created.\n"
|
||||
" Use interactive mode or answer 'y' at the dataset prompt to create them."
|
||||
)
|
||||
if self.missing_zvols:
|
||||
lines.append(
|
||||
f"\n {_bold_yellow('WARNING:')} "
|
||||
f"{len(self.missing_zvols)} zvol(s) do not exist on the destination:"
|
||||
)
|
||||
for z in self.missing_zvols:
|
||||
lines.append(f" {_yellow('•')} {z}")
|
||||
lines.append(
|
||||
" These zvols must exist before iSCSI extents can be created.\n"
|
||||
" Use interactive mode to be prompted for size and auto-create them."
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user