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 typing import Optional
|
||||||
|
|
||||||
from .archive import parse_archive, list_archive_and_exit
|
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 .colors import log, _bold, _bold_cyan, _bold_red, _bold_yellow, _cyan, _dim, _green, _yellow
|
||||||
from .csv_source import parse_csv_sources
|
from .csv_source import parse_csv_sources
|
||||||
from .migrate import migrate_smb_shares, migrate_nfs_shares, migrate_iscsi
|
from .migrate import migrate_smb_shares, migrate_nfs_shares, migrate_iscsi
|
||||||
@@ -112,6 +112,11 @@ async def run(
|
|||||||
client, summary.paths_to_create,
|
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
|
return summary
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +124,24 @@ async def run(
|
|||||||
# Interactive wizard helpers
|
# 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]:
|
def _find_debug_archives(directory: str = ".") -> list[Path]:
|
||||||
"""Return sorted list of TrueNAS debug archives found in *directory*."""
|
"""Return sorted list of TrueNAS debug archives found in *directory*."""
|
||||||
patterns = ("*.tgz", "*.tar.gz", "*.tar", "*.txz", "*.tar.xz")
|
patterns = ("*.tgz", "*.tar.gz", "*.tar", "*.txz", "*.tar.xz")
|
||||||
@@ -405,6 +428,29 @@ def interactive_mode() -> None:
|
|||||||
))
|
))
|
||||||
print()
|
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}?"):
|
if not _confirm(f"Apply these changes to {host}?"):
|
||||||
print("Aborted – no changes made.")
|
print("Aborted – no changes made.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -306,3 +306,79 @@ async def create_missing_datasets(
|
|||||||
) as client:
|
) as client:
|
||||||
for path in paths:
|
for path in paths:
|
||||||
await create_dataset(client, path)
|
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)),
|
_cyan("[DRY RUN]"), _bold_cyan(repr(name)),
|
||||||
ext.get("disk") or ext.get("path"))
|
ext.get("disk") or ext.get("path"))
|
||||||
summary.iscsi_extents_created += 1
|
summary.iscsi_extents_created += 1
|
||||||
|
if ext.get("type") == "DISK" and ext.get("disk"):
|
||||||
|
summary.zvols_to_check.append(ext["disk"].removeprefix("zvol/"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class Summary:
|
|||||||
paths_to_create: list[str] = field(default_factory=list)
|
paths_to_create: list[str] = field(default_factory=list)
|
||||||
missing_datasets: 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
|
@property
|
||||||
def _has_iscsi(self) -> bool:
|
def _has_iscsi(self) -> bool:
|
||||||
return (self.iscsi_extents_found + self.iscsi_initiators_found +
|
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"
|
" These paths must exist before shares can be created.\n"
|
||||||
" Use interactive mode or answer 'y' at the dataset prompt to create them."
|
" 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("")
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
Reference in New Issue
Block a user