diff --git a/python/cifs_provision.py b/python/cifs_provision.py index 8adddc3..720213e 100644 --- a/python/cifs_provision.py +++ b/python/cifs_provision.py @@ -48,7 +48,7 @@ "SVM_NAME": "vs1", "VOLUME_NAME": "vol_002", "VOLUME_SIZE": "100MB", - "AGGR_NAME": "sti232_vsim_sr091o_aggr1", # required — set via --aggregate or AGGR_NAME env var + "AGGR_NAME": "", # required — set via --aggregate or AGGR_NAME env var "CLIENT_MATCH": "0.0.0.0/0", # required — set via --client-match or CLIENT_MATCH env var "SHARE_NAME": "cifs_share_demo", "SHARE_COMMENT": "Provisioned by orchestrio", @@ -114,215 +114,255 @@ def parse_args() -> argparse.Namespace: return p.parse_args() -def main() -> None: - args = parse_args() - - # Load env file first so its values can be read via os.environ below +def _resolve_config(args: argparse.Namespace) -> dict[str, str | bool]: + """Load env file and CLI args, then return the resolved configuration dict.""" if args.env_file: _load_env_file(args.env_file) - # Push ENV block values into os.environ so OntapClient.from_env() picks them up for key, value in ENV.items(): if value and key not in os.environ: os.environ[key] = value - # Resolve each value: CLI arg > env var > ENV block > built-in default (matches YAML priority) - svm = args.svm or os.environ.get("SVM_NAME") or ENV["SVM_NAME"] or "vs0" - volume = args.volume or os.environ.get("VOLUME_NAME") or ENV["VOLUME_NAME"] or "cifs_test_env" - size = args.size or os.environ.get("VOLUME_SIZE") or ENV["VOLUME_SIZE"] or "100MB" aggregate = args.aggregate or os.environ.get("AGGR_NAME") or ENV["AGGR_NAME"] or "" - share_name = ( - args.share_name or os.environ.get("SHARE_NAME") or ENV["SHARE_NAME"] or "cifs_share_demo" + if not aggregate: + logger.error("--aggregate is required (or set AGGR_NAME in env / --env-file)") + sys.exit(1) + + return { + "svm": args.svm or os.environ.get("SVM_NAME") or ENV["SVM_NAME"] or "vs0", + "volume": ( + args.volume or os.environ.get("VOLUME_NAME") or ENV["VOLUME_NAME"] or "cifs_test_env" + ), + "size": args.size or os.environ.get("VOLUME_SIZE") or ENV["VOLUME_SIZE"] or "100MB", + "aggregate": aggregate, + "share_name": ( + args.share_name + or os.environ.get("SHARE_NAME") + or ENV["SHARE_NAME"] + or "cifs_share_demo" + ), + "share_comment": ( + args.share_comment + or os.environ.get("SHARE_COMMENT") + or ENV["SHARE_COMMENT"] + or "Provisioned by orchestrio" + ), + "acl_user": (args.acl_user or os.environ.get("ACL_USER") or ENV["ACL_USER"] or "Everyone"), + "acl_permission": ( + args.acl_permission + or os.environ.get("ACL_PERMISSION") + or ENV["ACL_PERMISSION"] + or "full_control" + ), + "create_cifs_server": args.create_cifs_server, + "cifs_server_name": ( + args.cifs_server_name + or os.environ.get("CIFS_SERVER_NAME") + or ENV["CIFS_SERVER_NAME"] + or "ONTAP-CIFS" + ), + "workgroup": ( + args.workgroup + or os.environ.get("CIFS_WORKGROUP") + or ENV["CIFS_WORKGROUP"] + or "WORKGROUP" + ), + } + + +def _ensure_cifs_server( + client: OntapClient, + svm: str, + create_cifs_server: bool, + cifs_server_name: str, + workgroup: str, +) -> None: + """Verify a CIFS server exists on the SVM, optionally creating one if missing.""" + cifs_svc_resp = client.get( + "/protocols/cifs/services", + fields="svm.name,enabled", + **{"svm.name": svm}, + ) + if cifs_svc_resp.get("num_records", 0) > 0: + logger.info("CIFS server confirmed on SVM '%s'", svm) + return + + if not create_cifs_server: + logger.error( + "ABORTED - no CIFS server found on SVM '%s'. " + "Pass --create-cifs-server to create one automatically, or use " + "'vserver cifs create' before running this script.", + svm, + ) + sys.exit(1) + + logger.info( + "No CIFS server on SVM '%s' - creating workgroup server '%s' in workgroup '%s'...", + svm, + cifs_server_name, + workgroup, ) - share_comment = ( - args.share_comment - or os.environ.get("SHARE_COMMENT") - or ENV["SHARE_COMMENT"] - or "Provisioned by orchestrio" + resp = client.post( + "/protocols/cifs/services", + body={ + "svm": {"name": svm}, + "name": cifs_server_name, + "workgroup": workgroup, + "enabled": True, + }, ) - acl_user = args.acl_user or os.environ.get("ACL_USER") or ENV["ACL_USER"] or "Everyone" - acl_permission = ( - args.acl_permission - or os.environ.get("ACL_PERMISSION") - or ENV["ACL_PERMISSION"] - or "full_control" + if resp.get("job"): + client.poll_job(resp["job"]["uuid"]) + logger.info( + "CIFS server '%s' created in workgroup '%s' on SVM '%s'", + cifs_server_name, + workgroup, + svm, ) - create_cifs_server = args.create_cifs_server - cifs_server_name = ( - args.cifs_server_name - or os.environ.get("CIFS_SERVER_NAME") - or ENV["CIFS_SERVER_NAME"] - or "ONTAP-CIFS" + +def _ensure_volume_ntfs( + client: OntapClient, svm: str, volume: str, size: str, aggregate: str +) -> dict: + """Create the FlexVol (NTFS security style) if it does not exist. Returns the job result.""" + existing = client.get( + "/storage/volumes", + fields="name,uuid", + name=volume, + **{"svm.name": svm}, + ) + if existing.get("records"): + logger.info("Volume '%s' already exists - skipping create", volume) + return {"state": "skipped", "message": "volume already existed"} + + logger.info("Creating volume '%s' (%s) on SVM '%s'...", volume, size, svm) + resp = client.post( + "/storage/volumes", + body={ + "name": volume, + "svm": {"name": svm}, + "aggregates": [{"name": aggregate}], + "size": size, + "nas": { + "security_style": "ntfs", + "path": f"/{volume}", + }, + }, ) - workgroup = ( - args.workgroup or os.environ.get("CIFS_WORKGROUP") or ENV["CIFS_WORKGROUP"] or "WORKGROUP" + job_uuid = resp["job"]["uuid"] + logger.info("Volume creation job: %s", job_uuid) + return client.poll_job(job_uuid) + + +def _get_svm_uuid(client: OntapClient, svm: str) -> str: + """Fetch and return the UUID for the named SVM.""" + resp = client.get("/svm/svms", fields="name,uuid", name=svm) + return resp["records"][0]["uuid"] + + +def _ensure_cifs_share( + client: OntapClient, + svm_uuid: str, + share_name: str, + volume: str, + svm: str, + share_comment: str, +) -> None: + """Create the CIFS share if it does not already exist.""" + try: + existing = client.get( + f"/protocols/cifs/shares/{svm_uuid}/{share_name}", + fields="name", + ) + share_exists = bool(existing.get("name")) + except OntapApiError as exc: + if exc.status_code == 404: + share_exists = False + else: + raise + + if share_exists: + logger.info("CIFS share '%s' already exists - skipping create", share_name) + return + + logger.info("Creating CIFS share '%s' on path '/%s'...", share_name, volume) + client.post( + "/protocols/cifs/shares", + body={ + "name": share_name, + "path": f"/{volume}", + "svm": {"name": svm}, + "comment": share_comment, + }, ) - if not aggregate: - logger.error("--aggregate is required (or set AGGR_NAME in env / --env-file)") - sys.exit(1) - with OntapClient.from_env() as client: - # Pre-flight — verify CIFS server is enabled on the SVM - # A CIFS share cannot be created if no CIFS server exists on the SVM. - # Exits early with a clear error rather than failing mid-workflow. - cifs_svc_resp = client.get( - "/protocols/cifs/services", - fields="svm.name,enabled", - **{"svm.name": svm}, +def _set_share_acl( + client: OntapClient, + svm_uuid: str, + share_name: str, + acl_user: str, + acl_permission: str, +) -> None: + """Patch the share ACL entry for the given user with the specified permission.""" + logger.info("Setting ACL: %s -> %s...", acl_user, acl_permission) + client.patch( + f"/protocols/cifs/shares/{svm_uuid}/{share_name}/acls/{acl_user}/windows", + body={"permission": acl_permission}, + ) + + +def _verify_and_log_acls(client: OntapClient, svm_uuid: str, share_name: str) -> None: + """Fetch the share and log each ACL entry for confirmation.""" + logger.info("Verifying share '%s'...", share_name) + resp = client.get( + f"/protocols/cifs/shares/{svm_uuid}/{share_name}", + fields="name,path,acls", + ) + for acl in resp.get("acls", []): + logger.info( + " ACL: %s (%s) -> %s", + acl.get("user_or_group", "N/A"), + acl.get("type", "N/A"), + acl.get("permission", "N/A"), ) - if cifs_svc_resp.get("num_records", 0) == 0: - if not create_cifs_server: - logger.error( - "ABORTED — no CIFS server found on SVM '%s'. " - "Pass --create-cifs-server to create one automatically, or use " - "'vserver cifs create' before running this script.", - svm, - ) - sys.exit(1) - logger.info( - "No CIFS server on SVM '%s' — creating workgroup server '%s' in workgroup '%s'…", - svm, - cifs_server_name, - workgroup, - ) - cifs_create_resp = client.post( - "/protocols/cifs/services", - body={ - "svm": {"name": svm}, - "name": cifs_server_name, - "workgroup": workgroup, - "enabled": True, - }, - ) - # ONTAP may return an async job for CIFS server creation - if cifs_create_resp.get("job"): - cifs_job_uuid = cifs_create_resp["job"]["uuid"] - logger.info("CIFS server creation job: %s", cifs_job_uuid) - client.poll_job(cifs_job_uuid) - logger.info( - "CIFS server '%s' created in workgroup '%s' on SVM '%s'", - cifs_server_name, - workgroup, - svm, - ) - else: - logger.info("CIFS server confirmed on SVM '%s'", svm) - - # Step 1 — create volume with NTFS security style (idempotent: skip if exists) - # POST /storage/volumes to create a FlexVol with security_style=ntfs. - # NTFS security style is required for CIFS/SMB share ACL enforcement. - existing_vol = client.get( - "/storage/volumes", - fields="name,uuid", - name=volume, - **{"svm.name": svm}, + + +def main() -> None: + cfg = _resolve_config(parse_args()) + svm = cfg["svm"] + volume = cfg["volume"] + size = cfg["size"] + aggregate = cfg["aggregate"] + share_name = cfg["share_name"] + share_comment = cfg["share_comment"] + acl_user = cfg["acl_user"] + acl_permission = cfg["acl_permission"] + + with OntapClient.from_env() as client: + _ensure_cifs_server( + client, svm, cfg["create_cifs_server"], cfg["cifs_server_name"], cfg["workgroup"] ) - if existing_vol.get("records"): - logger.info("Volume '%s' already exists — skipping create", volume) - job_result = {"state": "skipped", "message": "volume already existed"} - else: - logger.info("Creating volume '%s' (%s) on SVM '%s'…", volume, size, svm) - create_resp = client.post( - "/storage/volumes", - body={ - "name": volume, - "svm": {"name": svm}, - "aggregates": [{"name": aggregate}], - "size": size, - "nas": { - "security_style": "ntfs", - "path": f"/{volume}", - }, - }, - ) - - # Step 2 — poll volume-creation job - # Block until the async job finishes; the job result is logged in Step 3. - job_uuid = create_resp["job"]["uuid"] - logger.info("Volume creation job: %s", job_uuid) - job_result = client.poll_job(job_uuid) - - # Step 3 — print volume creation status - # Log the final job state and message for confirmation before continuing. + + job_result = _ensure_volume_ntfs(client, svm, volume, size, aggregate) state = job_result.get("state", "unknown") message = job_result.get("message", "") - logger.info("Volume '%s' job → %s: %s", volume, state, message) - - # Step 4 — create CIFS share (idempotent: skip if already exists) - # POST /protocols/cifs/shares to create the share pointing at the volume junction. - # ONTAP auto-creates a default 'Everyone / Full Control' ACL entry on creation. - svm_resp = client.get( - "/svm/svms", - fields="name,uuid", - name=svm, - ) - svm_uuid = svm_resp["records"][0]["uuid"] - - try: - existing_share = client.get( - f"/protocols/cifs/shares/{svm_uuid}/{share_name}", - fields="name", - ) - share_exists = bool(existing_share.get("name")) - except OntapApiError as exc: - if exc.status_code == 404: - share_exists = False - else: - raise - if share_exists: - logger.info("CIFS share '%s' already exists — skipping create", share_name) - else: - logger.info("Creating CIFS share '%s' on path '/%s'…", share_name, volume) - client.post( - "/protocols/cifs/shares", - body={ - "name": share_name, - "path": f"/{volume}", - "svm": {"name": svm}, - "comment": share_comment, - }, - ) - - # Step 6 — set share ACL (PATCH the auto-created Everyone entry) - # svm_uuid was resolved in Step 4 above (needed for the ACL URL). - # PATCH replaces the permission on the existing ACL entry for the given user. - # Default is 'Everyone' with 'full_control'; customise via ACL_USER/ACL_PERMISSION. - logger.info("Setting ACL: %s → %s…", acl_user, acl_permission) - client.patch( - f"/protocols/cifs/shares/{svm_uuid}/{share_name}/acls/{acl_user}/windows", - body={"permission": acl_permission}, - ) - - # Step 7 — verify share and ACL - # GET the share and inspect the acls array to confirm the permission was applied. - # Logs each ACL entry (user, type, permission) for visual confirmation. - logger.info("Verifying share '%s'…", share_name) - verify_resp = client.get( - f"/protocols/cifs/shares/{svm_uuid}/{share_name}", - fields="name,path,acls", - ) - acls = verify_resp.get("acls", []) - for acl in acls: - logger.info( - " ACL: %s (%s) → %s", - acl.get("user_or_group", "—"), - acl.get("type", "—"), - acl.get("permission", "—"), - ) - - # Step 8 — print summary - # Log a single success line with share name, volume, SVM, path, and ACL. - logger.info( - "✓ CIFS share '%s' created on volume '%s' (SVM: %s) | Path: /%s | ACL: %s → %s", - share_name, - volume, - svm, - volume, - acl_user, - acl_permission, - ) + logger.info("Volume '%s' job -> %s: %s", volume, state, message) + + svm_uuid = _get_svm_uuid(client, svm) + _ensure_cifs_share(client, svm_uuid, share_name, volume, svm, share_comment) + _set_share_acl(client, svm_uuid, share_name, acl_user, acl_permission) + _verify_and_log_acls(client, svm_uuid, share_name) + + logger.info( + "[OK] CIFS share '%s' on volume '%s' (SVM: %s) | Path: /%s | ACL: %s -> %s", + share_name, + volume, + svm, + volume, + acl_user, + acl_permission, + ) if __name__ == "__main__": diff --git a/python/cluster_info.py b/python/cluster_info.py index ce2a48f..703517d 100644 --- a/python/cluster_info.py +++ b/python/cluster_info.py @@ -2,8 +2,8 @@ """Retrieve ONTAP cluster version and list all nodes with serial numbers. Steps: - 1. GET /cluster ΓÇö retrieve cluster name and ONTAP version - 2. GET /cluster/nodes ΓÇö list all nodes with serial numbers + 1. GET /cluster - retrieve cluster name and ONTAP version + 2. GET /cluster/nodes - list all nodes with serial numbers Prerequisites:: @@ -31,15 +31,15 @@ def main() -> None: with OntapClient.from_env() as client: - # Step 1 ΓÇö cluster version + # Step 1: cluster version cluster = client.get("/cluster", fields="version") logger.info( - "Cluster: %s ΓÇö ONTAP %s", + "Cluster: %s - ONTAP %s", cluster.get("name", "unknown"), cluster.get("version", {}).get("full", "unknown"), ) - # Step 2 ΓÇö node list with serial numbers + # Step 2: node list with serial numbers nodes_resp = client.get("/cluster/nodes", fields="name,serial_number") records = nodes_resp.get("records", []) logger.info("Nodes in cluster: %d", nodes_resp.get("num_records", len(records))) @@ -47,8 +47,8 @@ def main() -> None: for node in records: logger.info( " %-30s serial: %s", - node.get("name", "ΓÇö"), - node.get("serial_number", "ΓÇö"), + node.get("name", "N/A"), + node.get("serial_number", "N/A"), ) diff --git a/python/cluster_setup_basic.py b/python/cluster_setup_basic.py index 4f6682d..1d636b6 100644 --- a/python/cluster_setup_basic.py +++ b/python/cluster_setup_basic.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """Create an ONTAP cluster from two pre-cluster nodes. -Equivalent to: orchestrio run yaml-workflows/workflows/cluster_setup_basic.yaml Steps: 1. discover_nodes — GET /api/cluster/nodes (membership=available, retry 3x/30s) @@ -12,6 +11,7 @@ Usage:: + # env vars directly export ONTAP_HOST=10.x.x.x # pre-cluster node IP export ONTAP_USER=admin # usually admin, empty pass on pre-cluster nodes export ONTAP_PASS= @@ -22,14 +22,20 @@ export CLUSTER_GATEWAY=10.x.x.1 export PARTNER_MGMT_IP=10.x.x.y python cluster_setup_basic.py + + # or use a per-build .env file (analogous to -ir ) + python cluster_setup_basic.py --env-file r9141_build.env + python cluster_setup_basic.py --env-file r919_build.env """ from __future__ import annotations +import argparse import logging import os import sys import time +from pathlib import Path from ontap_client import OntapClient @@ -259,7 +265,36 @@ def main() -> None: ) +def _load_env_file(path: str) -> None: + """Load KEY=VALUE pairs from a .env file into the INPUTS dict.""" + for line in Path(path).read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + INPUTS[key.strip()] = value.strip().strip('"').strip("'") + + if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Create an ONTAP cluster from two pre-cluster nodes." + ) + parser.add_argument( + "--env-file", + metavar="FILE", + help="Path to a .env file with KEY=VALUE pairs (one per build, like -ir in ha_create.exp).", + ) + args = parser.parse_args() + + if args.env_file: + _load_env_file(args.env_file) + + # env vars always win over INPUTS block defaults + for key in list(INPUTS): + val = os.environ.get(key) + if val: + INPUTS[key] = val + try: main() except KeyboardInterrupt: diff --git a/python/nfs_provision.py b/python/nfs_provision.py index 59f8d92..3c2201d 100644 --- a/python/nfs_provision.py +++ b/python/nfs_provision.py @@ -26,11 +26,11 @@ python nfs_provision.py --env-file nfs-provision.env Default values (vs0, vol_nfs_test_01, 0.0.0.0/0, etc.) are for illustration -only. Replace them with values appropriate for your environment ΓÇö +only. Replace them with values appropriate for your environment - in particular, restrict ``--client-match`` to your actual client subnet. This script is *not* idempotent: running it twice with the same volume name -will fail. See ``python/README.md`` ΓåÆ "Adapting for Your Environment" for +will fail. See ``python/README.md`` -> "Adapting for Your Environment" for guidance on adding existence checks. """ @@ -50,19 +50,18 @@ ) logger = logging.getLogger(__name__) -# ΓöÇΓöÇ Inputs (edit these directly, same as the YAML env: block) ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +# Inputs (edit these directly, same as the YAML env: block) # These are the defaults. CLI args and env vars override them. ENV = { - "ONTAP_HOST": "", # cluster management IP ΓÇö set here or via ONTAP_HOST env var + "ONTAP_HOST": "", # cluster management IP - set here or via ONTAP_HOST env var "ONTAP_USER": "admin", - "ONTAP_PASS": "", # never hardcode ΓÇö set via ONTAP_PASS env var + "ONTAP_PASS": "", # never hardcode - set via ONTAP_PASS env var "SVM_NAME": "vs1", "VOLUME_NAME": "vol_001", "VOLUME_SIZE": "100MB", - "AGGR_NAME": "sti232_vsim_sr091o_aggr1", # required ΓÇö set via --aggregate or AGGR_NAME env var + "AGGR_NAME": "", # required - set via --aggregate or AGGR_NAME env var "CLIENT_MATCH": "0.0.0.0/0", } -# ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ def _load_env_file(path: str) -> None: @@ -98,167 +97,165 @@ def parse_args() -> argparse.Namespace: return p.parse_args() -def main() -> None: - args = parse_args() - - # Load env file first so its values can be read via os.environ below +def _resolve_config(args: argparse.Namespace) -> dict[str, str]: + """Load env file and CLI args, then return the resolved configuration dict.""" if args.env_file: _load_env_file(args.env_file) - # Push ENV block values into os.environ so OntapClient.from_env() picks them up for key, value in ENV.items(): if value and key not in os.environ: os.environ[key] = value - # Resolve each value: CLI arg > env var > ENV block > built-in default (matches YAML priority) - svm = args.svm or os.environ.get("SVM_NAME") or ENV["SVM_NAME"] or "vs0" - volume = ( - args.volume or os.environ.get("VOLUME_NAME") or ENV["VOLUME_NAME"] or "vol_nfs_test_01" - ) - size = args.size or os.environ.get("VOLUME_SIZE") or ENV["VOLUME_SIZE"] or "100MB" aggregate = args.aggregate or os.environ.get("AGGR_NAME") or ENV["AGGR_NAME"] or "" - client_match = ( - args.client_match or os.environ.get("CLIENT_MATCH") or ENV["CLIENT_MATCH"] or "0.0.0.0/0" - ) - if not aggregate: logger.error("--aggregate is required (or set AGGR_NAME in env / --env-file)") sys.exit(1) - policy_name = f"{volume}_export_policy" - - with OntapClient.from_env() as client: - # Step 1 ΓÇö create volume (idempotent: skip if already exists) - # POST /storage/volumes to create a new FlexVol with a NAS junction path. - # Volume creation is asynchronous ΓÇö the response contains a job UUID. - existing_vol = client.get( - "/storage/volumes", - fields="name,uuid", - name=volume, - **{"svm.name": svm}, - ) - if existing_vol.get("records"): - logger.info("Volume '%s' already exists ΓÇö skipping create", volume) - else: - logger.info("Creating volume '%s' (%s) on SVM '%s'ΓǪ", volume, size, svm) - create_resp = client.post( - "/storage/volumes", - body={ - "name": volume, - "svm": {"name": svm}, - "aggregates": [{"name": aggregate}], - "size": size, - "nas": {"path": f"/{volume}"}, - }, - ) - - # Step 2 ΓÇö poll volume-creation job - # Block until the async job finishes before proceeding. - # poll_job raises RuntimeError if the job ends in a failure state. - job_uuid = create_resp["job"]["uuid"] - logger.info("Volume creation job: %s", job_uuid) - client.poll_job(job_uuid) - logger.info("Volume '%s' created successfully", volume) - - # Step 3 ΓÇö fetch volume UUID - # The UUID is required to PATCH the volume later when assigning the export policy. - # Filter by name + svm.name to pinpoint exactly the volume just created. - vol_resp = client.get( + return { + "svm": args.svm or os.environ.get("SVM_NAME") or ENV["SVM_NAME"] or "vs0", + "volume": ( + args.volume or os.environ.get("VOLUME_NAME") or ENV["VOLUME_NAME"] or "vol_nfs_test_01" + ), + "size": args.size or os.environ.get("VOLUME_SIZE") or ENV["VOLUME_SIZE"] or "100MB", + "aggregate": aggregate, + "client_match": ( + args.client_match + or os.environ.get("CLIENT_MATCH") + or ENV["CLIENT_MATCH"] + or "0.0.0.0/0" + ), + } + + +def _ensure_volume(client: OntapClient, svm: str, volume: str, size: str, aggregate: str) -> str: + """Create the FlexVol if it does not exist. Returns the volume UUID.""" + existing = client.get( + "/storage/volumes", + fields="name,uuid", + name=volume, + **{"svm.name": svm}, + ) + if existing.get("records"): + logger.info("Volume '%s' already exists - skipping create", volume) + else: + logger.info("Creating volume '%s' (%s) on SVM '%s'...", volume, size, svm) + resp = client.post( "/storage/volumes", - fields="name,uuid", - name=volume, - **{"svm.name": svm}, - ) - if not vol_resp.get("records"): - raise RuntimeError(f"Volume '{volume}' not found on SVM '{svm}' after creation") - volume_uuid = vol_resp["records"][0]["uuid"] - - # Step 4 ΓÇö create export policy (idempotent: skip if already exists) - # Creates a dedicated policy named _export_policy scoped to the SVM. - # A per-volume policy makes it easy to manage access rules independently. - existing_policy = client.get( - "/protocols/nfs/export-policies", - fields="name,id", - name=policy_name, - **{"svm.name": svm}, + body={ + "name": volume, + "svm": {"name": svm}, + "aggregates": [{"name": aggregate}], + "size": size, + "nas": {"path": f"/{volume}"}, + }, ) - if existing_policy.get("records"): - logger.info("Export policy '%s' already exists ΓÇö skipping create", policy_name) - else: - logger.info("Creating export policy '%s'ΓǪ", policy_name) - client.post( - "/protocols/nfs/export-policies", - body={"name": policy_name, "svm": {"name": svm}}, - ) - - # Step 5 ΓÇö fetch export policy ID - # The numeric ID is required when POSTing rules to the policy. - # Filter by name + svm.name to retrieve only this policy's record. - policy_resp = client.get( + job_uuid = resp["job"]["uuid"] + logger.info("Volume creation job: %s", job_uuid) + client.poll_job(job_uuid) + logger.info("Volume '%s' created successfully", volume) + + vol_resp = client.get( + "/storage/volumes", + fields="name,uuid", + name=volume, + **{"svm.name": svm}, + ) + if not vol_resp.get("records"): + raise RuntimeError(f"Volume '{volume}' not found on SVM '{svm}' after creation") + return vol_resp["records"][0]["uuid"] + + +def _ensure_export_policy(client: OntapClient, svm: str, policy_name: str) -> int: + """Create the NFS export policy if it does not exist. Returns the policy ID.""" + existing = client.get( + "/protocols/nfs/export-policies", + fields="name,id", + name=policy_name, + **{"svm.name": svm}, + ) + if existing.get("records"): + logger.info("Export policy '%s' already exists - skipping create", policy_name) + else: + logger.info("Creating export policy '%s'...", policy_name) + client.post( "/protocols/nfs/export-policies", - fields="name,id", - name=policy_name, - **{"svm.name": svm}, - ) - if not policy_resp.get("records"): - raise RuntimeError( - f"Export policy '{policy_name}' not found on SVM '{svm}' after creation" - ) - policy_id = policy_resp["records"][0]["id"] - - # Step 6 ΓÇö add client rule (idempotent: skip if a matching rule already exists) - # POST a rule to the export policy allowing the given client IP or CIDR range. - # ro_rule, rw_rule, superuser = 'any' is suitable for lab; tighten for production. - existing_rules = client.get( - f"/protocols/nfs/export-policies/{policy_id}/rules", - fields="index,clients", - ) - rule_exists = any( - any(c.get("match") == client_match for c in r.get("clients", [])) - for r in existing_rules.get("records", []) - ) - if rule_exists: - logger.info("Client rule '%s' already exists in policy ΓÇö skipping", client_match) - else: - logger.info("Adding client rule '%s' to policyΓǪ", client_match) - client.post( - f"/protocols/nfs/export-policies/{policy_id}/rules", - body={ - "clients": [{"match": client_match}], - "ro_rule": ["any"], - "rw_rule": ["any"], - "superuser": ["any"], - }, - ) - - # Step 7 ΓÇö assign export policy to volume - # PATCH the volume's nas.export_policy field to link the policy. - # This makes the volume accessible to NFS clients that match the rule. - logger.info("Assigning export policy to volumeΓǪ") - patch_resp = client.patch( - f"/storage/volumes/{volume_uuid}", - body={"nas": {"export_policy": {"name": policy_name}}}, + body={"name": policy_name, "svm": {"name": svm}}, ) - # Step 8 ΓÇö poll assign-policy job - # The PATCH may return a job UUID if the operation is async. - # Only poll if a UUID was returned; sync responses skip this block. - if "job" in patch_resp: - client.poll_job(patch_resp["job"]["uuid"]) - - # Step 9 ΓÇö print summary - # Log a single success line with volume, size, SVM, mount path, - # export policy name, and client rule for quick confirmation. - logger.info( - "Γ£ô Volume '%s' (%s) created on SVM '%s' | Mount path: /%s | " - "Export policy '%s' created with client rule '%s' and assigned to volume", - volume, - size, - svm, - volume, - policy_name, - client_match, + policy_resp = client.get( + "/protocols/nfs/export-policies", + fields="name,id", + name=policy_name, + **{"svm.name": svm}, + ) + if not policy_resp.get("records"): + raise RuntimeError( + f"Export policy '{policy_name}' not found on SVM '{svm}' after creation" ) + return policy_resp["records"][0]["id"] + + +def _ensure_client_rule(client: OntapClient, policy_id: int, client_match: str) -> None: + """Add a client-match rule to the export policy if one does not already exist.""" + existing_rules = client.get( + f"/protocols/nfs/export-policies/{policy_id}/rules", + fields="index,clients", + ) + rule_exists = any( + any(c.get("match") == client_match for c in r.get("clients", [])) + for r in existing_rules.get("records", []) + ) + if rule_exists: + logger.info("Client rule '%s' already exists in policy - skipping", client_match) + return + logger.info("Adding client rule '%s' to policy...", client_match) + client.post( + f"/protocols/nfs/export-policies/{policy_id}/rules", + body={ + "clients": [{"match": client_match}], + "ro_rule": ["any"], + "rw_rule": ["any"], + "superuser": ["any"], + }, + ) + + +def _assign_export_policy(client: OntapClient, volume_uuid: str, policy_name: str) -> None: + """Assign the export policy to the volume and wait for any async job to complete.""" + logger.info("Assigning export policy to volume...") + patch_resp = client.patch( + f"/storage/volumes/{volume_uuid}", + body={"nas": {"export_policy": {"name": policy_name}}}, + ) + if "job" in patch_resp: + client.poll_job(patch_resp["job"]["uuid"]) + + +def main() -> None: + cfg = _resolve_config(parse_args()) + svm = cfg["svm"] + volume = cfg["volume"] + size = cfg["size"] + aggregate = cfg["aggregate"] + client_match = cfg["client_match"] + policy_name = f"{volume}_export_policy" + + with OntapClient.from_env() as client: + volume_uuid = _ensure_volume(client, svm, volume, size, aggregate) + policy_id = _ensure_export_policy(client, svm, policy_name) + _ensure_client_rule(client, policy_id, client_match) + _assign_export_policy(client, volume_uuid, policy_name) + + logger.info( + "[OK] Volume '%s' (%s) on SVM '%s' | Mount: /%s | " + "Export policy '%s' with client rule '%s' assigned", + volume, + size, + svm, + volume, + policy_name, + client_match, + ) if __name__ == "__main__":